feat(coding-agents): first-class pi support, sharing one extension adapter and installer with Prime Agent (#3993)

Adds pi (@earendil-works/pi-coding-agent) as a harness and extracts createPiExtension(harness),
the extension adapter pi and its fork Prime Agent share. Supersedes #3775.

The `pi` key is removed from package.json: both hosts read that same key, so it could only ever
name one bundle, and the host it did not name loaded the other's and reported the wrong harness.
`hindsight-coding-agents install pi|prime-agent` is now the only route for both.

Also makes the companion skill self-update for every host that installs one — the paths now live
once in core/skill-dirs.ts, which both the installer and skill-sync read.

Co-authored-by: Sebastian Otaegui <feniix@gmail.com>
This commit is contained in:
Nicolò Boschi
2026-09-01 15:11:06 +02:00
committed by GitHub
parent ac41cee604
commit 71a9a7008f
31 changed files with 1276 additions and 431 deletions
+1 -1
View File
@@ -254,7 +254,7 @@ npx @vectorize-io/hindsight-coding-agents install all # every detected
npx @vectorize-io/hindsight-coding-agents install claude-code # or just one
```
Supports Claude Code, Codex CLI, Cursor CLI, GitHub Copilot CLI, opencode, Kilo CLI, Cline CLI, Antigravity CLI, Devin CLI, Prime Agent, Grok Build and DeepSeek Harness. Ingestion is automatic — there is no setup command. See the [coding agents integration](https://hindsight.vectorize.io/sdks/integrations/coding-agents).
Supports Claude Code, Codex CLI, Cursor CLI, GitHub Copilot CLI, opencode, Kilo CLI, Cline CLI, Antigravity CLI, Devin CLI, pi, Prime Agent, Grok Build and DeepSeek Harness. Ingestion is automatic — there is no setup command. See the [coding agents integration](https://hindsight.vectorize.io/sdks/integrations/coding-agents).
### MCP Server
@@ -0,0 +1,5 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#383e45" stroke-width="6" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 20 H52" />
<path d="M24 20 V46" />
<path d="M40 20 V46" />
</svg>

After

Width:  |  Height:  |  Size: 239 B

@@ -13,7 +13,9 @@
* The ids below are exactly the ones that integration emits — see its
* `src/harness/hook-lifecycle.ts` (one HookSpec per hook-driven agent) and
* `src/harness/registry.ts` (the persistent-plugin agents, whose id is the
* `createPluginEntry(...)` argument of their entrypoint). Do not add entries for
* argument their entrypoint passes to the shared factory it builds on —
* `createPluginEntry(...)` for the opencode family, `createPiExtension(...)` for
* the pi family). Do not add entries for
* agents that cannot appear yet: an id nothing writes is a logo nothing renders.
* When that integration gains a harness, add it here in the same change — drop
* its icon in `public/img/harness/` (copied from
@@ -92,6 +94,10 @@ export const HARNESS_LOGO_REGISTRY: Record<string, HarnessLogoEntry> = {
// its own harness id, but it is the same product — so it deliberately shares
// v1's brand mark rather than inventing a second one.
opencode2: { id: "opencode2", label: "OpenCode 2", src: "/img/harness/opencode.png" },
// pi ships no brand mark of its own, so this is the letter the agent is named
// for, drawn in the same monochrome style as the Prime Agent mark below (the
// fork of pi) rather than a logo invented for it.
pi: { id: "pi", label: "pi", src: "/img/harness/pi.svg", invertOnDark: true },
"qwen-code": { id: "qwen-code", label: "Qwen Code", src: "/img/harness/qwen-code.svg" },
"prime-agent": {
id: "prime-agent",
@@ -36,8 +36,9 @@ describe("documentHarness", () => {
describe("resolveHarnessLogo", () => {
// The exact set hindsight-coding-agents emits: one per HookSpec in
// src/harness/hook-lifecycle.ts, plus the persistent-plugin harnesses whose id
// is their entrypoint's createPluginEntry(...) argument (opencode, opencode2,
// kilo, cline-cli). The registry must cover these and nothing speculative.
// is their entrypoint's argument to the shared factory they use (opencode,
// opencode2, kilo, cline-cli, pi, prime-agent). The registry must cover these
// and nothing speculative.
const EMITTED_HARNESSES = [
"antigravity-cli",
"claude-code",
@@ -52,6 +53,7 @@ describe("resolveHarnessLogo", () => {
"kilo",
"opencode",
"opencode2",
"pi",
"prime-agent",
"qwen-code",
];
@@ -9,7 +9,7 @@ description: "One Hindsight memory plugin for coding agents — per-repo memory
Long-term project memory for **coding agents**, backed by [Hindsight](https://vectorize.io/hindsight).
One package, several agents: a shared reflect-and-inject core with a thin entry point per agent
(**opencode**, **opencode 2**, **Kilo CLI**, **Cline CLI**, **Prime Agent**, **DeepSeek Harness**, **Claude Code**, **Codex CLI**, **DeepAgents Dcode**, **Antigravity CLI**, **Cursor CLI**, **GitHub Copilot CLI**, **Devin CLI**, **Grok Build**). Ingestion is fully
(**opencode**, **opencode 2**, **Kilo CLI**, **Cline CLI**, **pi**, **Prime Agent**, **DeepSeek Harness**, **Claude Code**, **Codex CLI**, **DeepAgents Dcode**, **Antigravity CLI**, **Cursor CLI**, **GitHub Copilot CLI**, **Devin CLI**, **Grok Build**). Ingestion is fully
automatic — there is no setup command: a repo's git history and conversations flow into its memory
bank in the background as you work.
@@ -179,14 +179,35 @@ npx @vectorize-io/hindsight-coding-agents install cline-cli
A native plugin via `cline plugin install`, plus MCP and the companion skill.
#### <img src="/img/harness/pi.svg" alt="" width="20" height="20" /> pi
```bash
npx @vectorize-io/hindsight-coding-agents install pi
```
An extension entry in `~/.pi/agent/settings.json`, plus the companion skill in
`~/.pi/agent/skills` — native tools, no MCP needed.
This command is the only supported route, for pi and for Prime Agent below. Installing us as a pi
package (`pi install npm:@vectorize-io/hindsight-coding-agents`) is deliberately not wired: both
hosts read the same `pi` key of a package's `package.json`, and that key can only name one entry —
whichever host it did not name would load the other's bundle and report itself as the wrong agent,
taking that harness's config section and stamping every document it retains with it. So the package
carries no `pi` key at all, and each host is pointed at its own bundle by the install command above.
#### <img src="/img/harness/prime-agent.svg" alt="" width="20" height="20" /> Prime Agent
```bash
npx @vectorize-io/hindsight-coding-agents install prime-agent
```
An extension entry in `~/.prime/agent/settings.json` — native tools, no MCP needed — plus the
companion skill in `~/.prime/agent/skills`.
Prime Agent is a fork of pi, so it is wired the same way: an extension entry, here in
`~/.prime/agent/settings.json`, plus the companion skill in `~/.prime/agent/skills` — native tools,
no MCP needed. Installing both is fine and expected:
each host loads its own entry from its own settings file, and like every other pair of agents they
**share one bank per repo** (the default `coding-agent::{gitProject}`), so what you tell pi is there
when you open Prime Agent. Separate entries are what keeps each side attributable — its own
`harnesses.<name>` config section, and its own agent stamped on every document it retains.
#### <img src="/img/harness/dsh.svg" alt="" width="20" height="20" /> DeepSeek Harness
@@ -274,7 +295,8 @@ transcripts are needed either way; going through them directly is simply the sho
**How sessions are matched.** A conversation is imported only when the session itself records the
directory it ran in — never inferred from a file or folder name. Claude Code writes that directory
on its entries, Codex in its `session_meta` header and DeepSeek Harness in its session-log header,
so all three can be attributed exactly, including sessions started in a subdirectory of the repo. Guessing was tempting (Claude names its history
and pi and Prime Agent in their session header, so all five can be attributed exactly, including
sessions started in a subdirectory of the repo. Guessing was tempting (Claude names its history
folders after the project path) but unsafe: `/` and `.` both encode to `-`, so `repo-sub` is either
the subdirectory `repo/sub` or an unrelated sibling repo — and a wrong guess files someone else's
conversation into your bank. Sessions that record nothing are skipped and the count is reported.
@@ -384,7 +406,7 @@ what reads it:
| host | reads the file | an edit applies |
| ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | -------------------------- |
| hook harnesses (Claude Code, Codex CLI, Cursor CLI, GitHub Copilot CLI, Grok Build, Antigravity CLI, Devin) | once per hook invocation — each hook is its own short-lived process | on your next prompt |
| persistent plugins (opencode, opencode 2, Kilo CLI, Cline CLI, Prime Agent, DeepSeek Harness) | once per workspace, when the host loads the plugin | after restarting the agent |
| persistent plugins (opencode, opencode 2, Kilo CLI, Cline CLI, pi, Prime Agent, DeepSeek Harness) | once per workspace, when the host loads the plugin | after restarting the agent |
| the MCP server behind the `hindsight_*` tools | once at startup | in your next session |
`apiToken` is the exception. Every host re-reads it when the server rejects a request, so enabling
@@ -31,6 +31,8 @@ const CODING_AGENT_LOGOS: {id: string; name: string; file: string}[] = [
{id: 'devin-cli', name: 'Devin CLI', file: 'devin-cli.svg'},
{id: 'cline-cli', name: 'Cline CLI', file: 'cline-cli.svg'},
{id: 'dsh', name: 'DeepSeek Harness', file: 'dsh.svg'},
{id: 'pi', name: 'pi', file: 'pi.svg'},
{id: 'prime-agent', name: 'Prime Agent', file: 'prime-agent.svg'},
];
const INTEGRATIONS_JSON_URL =
+5
View File
@@ -0,0 +1,5 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#383e45" stroke-width="6" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 20 H52" />
<path d="M24 20 V46" />
<path d="M40 20 V46" />
</svg>

After

Width:  |  Height:  |  Size: 239 B

+27 -5
View File
@@ -2,7 +2,7 @@
Long-term project memory for **coding agents**, backed by [Hindsight](https://vectorize.io/hindsight).
One package, several agents: a shared reflect-and-inject core with a thin entry point per agent
(**opencode**, **opencode 2**, **Kilo CLI**, **Cline CLI**, **Prime Agent**, **DeepSeek Harness**, **Claude Code**, **Codex CLI**, **DeepAgents Dcode**, **Antigravity CLI**, **Cursor CLI**, **GitHub Copilot CLI**, **Devin CLI**, **Grok Build**). Ingestion is fully
(**opencode**, **opencode 2**, **Kilo CLI**, **Cline CLI**, **pi**, **Prime Agent**, **DeepSeek Harness**, **Claude Code**, **Codex CLI**, **DeepAgents Dcode**, **Antigravity CLI**, **Cursor CLI**, **GitHub Copilot CLI**, **Devin CLI**, **Grok Build**). Ingestion is fully
automatic — there is no setup command: a repo's git history and conversations flow into its memory
bank in the background as you work.
@@ -176,14 +176,35 @@ npx @vectorize-io/hindsight-coding-agents install cline-cli
A native plugin via `cline plugin install`, plus MCP and the companion skill.
#### <img src="https://hindsight.vectorize.io/img/harness/pi.svg" alt="" width="20" height="20" /> pi
```bash
npx @vectorize-io/hindsight-coding-agents install pi
```
An extension entry in `~/.pi/agent/settings.json`, plus the companion skill in
`~/.pi/agent/skills` — native tools, no MCP needed.
This command is the only supported route, for pi and for Prime Agent below. Installing us as a pi
package (`pi install npm:@vectorize-io/hindsight-coding-agents`) is deliberately not wired: both
hosts read the same `pi` key of a package's `package.json`, and that key can only name one entry —
whichever host it did not name would load the other's bundle and report itself as the wrong agent,
taking that harness's config section and stamping every document it retains with it. So the package
carries no `pi` key at all, and each host is pointed at its own bundle by the install command above.
#### <img src="https://hindsight.vectorize.io/img/harness/prime-agent.svg" alt="" width="20" height="20" /> Prime Agent
```bash
npx @vectorize-io/hindsight-coding-agents install prime-agent
```
An extension entry in `~/.prime/agent/settings.json` — native tools, no MCP needed — plus the
companion skill in `~/.prime/agent/skills`.
Prime Agent is a fork of pi, so it is wired the same way: an extension entry, here in
`~/.prime/agent/settings.json`, plus the companion skill in `~/.prime/agent/skills` — native tools,
no MCP needed. Installing both is fine and expected:
each host loads its own entry from its own settings file, and like every other pair of agents they
**share one bank per repo** (the default `coding-agent::{gitProject}`), so what you tell pi is there
when you open Prime Agent. Separate entries are what keeps each side attributable — its own
`harnesses.<name>` config section, and its own agent stamped on every document it retains.
#### <img src="https://hindsight.vectorize.io/img/harness/dsh.svg" alt="" width="20" height="20" /> DeepSeek Harness
@@ -271,7 +292,8 @@ transcripts are needed either way; going through them directly is simply the sho
**How sessions are matched.** A conversation is imported only when the session itself records the
directory it ran in — never inferred from a file or folder name. Claude Code writes that directory
on its entries, Codex in its `session_meta` header and DeepSeek Harness in its session-log header,
so all three can be attributed exactly, including sessions started in a subdirectory of the repo. Guessing was tempting (Claude names its history
and pi and Prime Agent in their session header, so all five can be attributed exactly, including
sessions started in a subdirectory of the repo. Guessing was tempting (Claude names its history
folders after the project path) but unsafe: `/` and `.` both encode to `-`, so `repo-sub` is either
the subdirectory `repo/sub` or an unrelated sibling repo — and a wrong guess files someone else's
conversation into your bank. Sessions that record nothing are skipped and the count is reported.
@@ -387,7 +409,7 @@ what reads it:
| host | reads the file | an edit applies |
| ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | -------------------------- |
| hook harnesses (Claude Code, Codex CLI, Cursor CLI, GitHub Copilot CLI, Grok Build, Antigravity CLI, Devin) | once per hook invocation — each hook is its own short-lived process | on your next prompt |
| persistent plugins (opencode, opencode 2, Kilo CLI, Cline CLI, Prime Agent, DeepSeek Harness) | once per workspace, when the host loads the plugin | after restarting the agent |
| persistent plugins (opencode, opencode 2, Kilo CLI, Cline CLI, pi, Prime Agent, DeepSeek Harness) | once per workspace, when the host loads the plugin | after restarting the agent |
| the MCP server behind the `hindsight_*` tools | once at startup | in your next session |
`apiToken` is the exception. Every host re-reads it when the server rejects a request, so enabling
@@ -0,0 +1,3 @@
FROM hindsight-coding-agents-e2e-base
ARG VERSION=latest
RUN npm install --global "@earendil-works/pi-coding-agent@${VERSION}" && command -v pi
@@ -68,11 +68,6 @@
}
]
},
"pi": {
"extensions": [
"./dist/prime-agent.js"
]
},
"scripts": {
"build": "tsup",
"skill:build": "node scripts/build-skill.mjs",
@@ -132,7 +132,7 @@ what reads it:
| host | reads the file | an edit applies |
| ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | -------------------------- |
| hook harnesses (Claude Code, Codex CLI, Cursor CLI, GitHub Copilot CLI, Grok Build, Antigravity CLI, Devin) | once per hook invocation — each hook is its own short-lived process | on your next prompt |
| persistent plugins (opencode, opencode 2, Kilo CLI, Cline CLI, Prime Agent, DeepSeek Harness) | once per workspace, when the host loads the plugin | after restarting the agent |
| persistent plugins (opencode, opencode 2, Kilo CLI, Cline CLI, pi, Prime Agent, DeepSeek Harness) | once per workspace, when the host loads the plugin | after restarting the agent |
| the MCP server behind the `hindsight_*` tools | once at startup | in your next session |
`apiToken` is the exception. Every host re-reads it when the server rejects a request, so enabling
@@ -3,7 +3,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { claudeProjectDir, importLocalHistory } from "./history";
import { claudeProjectDir, importLocalHistory, piSessionDir } from "./history";
let home: string;
afterEach(() => {
@@ -22,6 +22,43 @@ const claudeLine = (role: string, text: string, cwd?: string) =>
message: { role, content: [{ type: "text", text }] },
});
/** One entry of a stored pi/Prime Agent session log. */
const piHeader = (id: string, cwd?: string) =>
JSON.stringify({
type: "session",
version: 3,
id,
timestamp: "2026-08-24T12:00:00.000Z",
...(cwd ? { cwd } : {}),
});
const piMessage = (role: string, text: string) =>
JSON.stringify({
type: "message",
id: "e1",
timestamp: "2026-08-24T12:00:01.000Z",
message: { role, content: [{ type: "text", text }] },
});
const piLog = (id: string, cwd: string | undefined, ...turns: string[]) =>
`${[piHeader(id, cwd), ...turns].join("\n")}\n`;
/** Write a stored session where each host actually keeps it: pi under an encoded per-cwd folder,
* Prime Agent flat. */
function writePiSession(home: string, cwd: string, id: string, ...turns: string[]): void {
const dir = join(home, ".pi", "agent", "sessions", piSessionDir(cwd));
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, `2026-08-24T12-00-00-000Z_${id}.jsonl`), piLog(id, cwd, ...turns));
}
function writePrimeSession(
home: string,
cwd: string | undefined,
id: string,
...turns: string[]
): void {
const dir = join(home, ".prime", "agent", "sessions");
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, `${id}.jsonl`), piLog(id, cwd, ...turns));
}
describe("local history import", () => {
it("reads Claude sessions from the project directory for THIS repo only", () => {
const h = newHome();
@@ -224,6 +261,129 @@ describe("attribution must be proven, never guessed", () => {
});
});
/**
* pi and Prime Agent write the same session schema but lay the files out differently, so each host
* needs its own enumeration proved the shared body that reads a header is only reached if the
* files were found at all.
*/
describe("pi-family history import", () => {
it("reads pi sessions from the encoded per-directory folder for THIS repo only", () => {
const h = newHome();
const repo = "/Users/x/dev/myrepo";
writePiSession(
h,
repo,
"s1",
piMessage("user", "why retry 429?"),
piMessage("assistant", "backpressure")
);
writePiSession(h, "/Users/x/dev/otherrepo", "s2", piMessage("user", "unrelated"));
const r = importLocalHistory("pi", repo, h);
expect(r.supported).toBe(true);
expect(r.sessions).toHaveLength(1);
expect(r.sessions[0].id).toBe("s1");
expect(JSON.stringify(r.sessions[0].turns)).toContain("why retry 429?");
expect(JSON.stringify(r.sessions)).not.toContain("unrelated");
});
it("includes a pi session started in a SUBDIRECTORY of the repo", () => {
const h = newHome();
writePiSession(
h,
"/Users/x/dev/repo/packages/api",
"s1",
piMessage("user", "work in a subpackage")
);
expect(JSON.stringify(importLocalHistory("pi", "/Users/x/dev/repo", h).sessions)).toContain(
"work in a subpackage"
);
});
// The folder name is lossy in exactly the way the Claude encoding is: "/Users/x/dev/repo-sub"
// matches the prefix scan for "/Users/x/dev/repo", so only the header cwd can separate them.
it("does not pull a SIBLING repo's pi sessions in via the ambiguous folder name", () => {
const h = newHome();
writePiSession(h, "/Users/x/dev/repo-sub", "s1", piMessage("user", "sibling repo work"));
expect(JSON.stringify(importLocalHistory("pi", "/Users/x/dev/repo", h).sessions)).not.toContain(
"sibling repo work"
);
});
// Regression: `~/.pi/agent/sessions` itself as a regular file passed the existsSync check and the
// root readdirSync threw ENOTDIR out of importLocalHistory — which documents that it never throws,
// and whose caller does not catch. One junk file killed the whole --import-conversations run.
it("treats a stray file where the pi sessions ROOT was expected as no history", () => {
const h = newHome();
mkdirSync(join(h, ".pi", "agent"), { recursive: true });
writeFileSync(join(h, ".pi", "agent", "sessions"), "not a folder");
expect(importLocalHistory("pi", "/Users/x/dev/myrepo", h)).toEqual({
supported: true,
sessions: [],
unattributed: 0,
});
});
// Same shape one level down: a regular file named like a session folder must cost that one
// entry, not the run.
it("skips a stray file where a pi session folder was expected, keeping the real sessions", () => {
const h = newHome();
const repo = "/Users/x/dev/myrepo";
writePiSession(h, repo, "s1", piMessage("user", "real work"));
writeFileSync(join(h, ".pi", "agent", "sessions", piSessionDir(`${repo}/sub`)), "not a folder");
const r = importLocalHistory("pi", repo, h);
expect(r.sessions).toHaveLength(1);
expect(JSON.stringify(r.sessions)).toContain("real work");
});
it("reads Prime Agent sessions out of its FLAT directory, attributing by header cwd", () => {
const h = newHome();
const repo = "/Users/x/dev/myrepo";
writePrimeSession(h, repo, "p1", piMessage("user", "which statuses retry?"));
writePrimeSession(h, "/Users/x/dev/otherrepo", "p2", piMessage("user", "unrelated"));
const r = importLocalHistory("prime-agent", repo, h);
expect(r.sessions).toHaveLength(1);
expect(r.sessions[0].id).toBe("p1");
expect(JSON.stringify(r.sessions)).not.toContain("unrelated");
});
it("skips a session whose header records no cwd rather than trusting where the file sits", () => {
const h = newHome();
writePrimeSession(h, undefined, "p1", piMessage("user", "ambiguous"));
const r = importLocalHistory("prime-agent", "/Users/x/dev/myrepo", h);
expect(r.sessions).toEqual([]);
expect(r.unattributed).toBe(1);
});
it("reports no sessions, not an error, when the host has never run on this machine", () => {
const h = newHome();
for (const harness of ["pi", "prime-agent"]) {
const r = importLocalHistory(harness, "/Users/x/dev/myrepo", h);
expect(r.supported).toBe(true);
expect(r.sessions).toEqual([]);
}
});
});
describe("piSessionDir", () => {
// Verified against pi 0.84.2's SessionManager: one leading separator dropped, `/`, `\` and `:`
// mapped to `-`, everything else — dots, spaces, case — left alone.
it.each([
["/Users/x/dev/myrepo", "--Users-x-dev-myrepo--"],
["/private/tmp", "--private-tmp--"],
["/Users/x/dev/repo.git", "--Users-x-dev-repo.git--"],
["/Users/x/My Projects/App", "--Users-x-My Projects-App--"],
// Windows: no leading separator to strip, and the drive colon maps to `-` like the
// separators, so the drive letter survives as its own segment.
["C:\\Users\\x\\repo", "--C--Users-x-repo--"],
])("encodes %s", (repo, encoded) => {
expect(piSessionDir(repo)).toBe(encoded);
});
});
describe("importLocalHistory — dcode", () => {
const REPO = "/Users/x/dev/repo";
@@ -41,6 +41,7 @@ import { readClaudeTranscript } from "./transcript";
import { readCodexTranscript } from "./transcript-codex";
import { readDcodeTranscript } from "./transcript-dcode";
import { readDshEvents, type DshSessionEvent } from "./transcript-dsh";
import { readPiTranscript } from "./transcript-pi";
import { zstdDecompressFrames } from "./zstd-frames";
import type { TransportTurn } from "./chat";
@@ -62,6 +63,18 @@ export function claudeProjectDir(repoDir: string, home = homedir()): string {
return join(home, ".claude", "projects", repoDir.replace(/[^a-zA-Z0-9]/g, "-"));
}
/** pi names a session folder after the working directory it was started in: one leading separator
* stripped, then `/`, `\` and `:` replaced by `-`, wrapped in `--``--`. Verified against pi
* 0.84.2, whose SessionManager builds exactly
* `` `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--` ``.
*
* Unlike Claude's encoding this keeps dots, spaces and case, but it is still NOT injective `/a/b`
* and `/a-b` both give `--a-b--` so it only narrows the search; attribution comes from the `cwd`
* recorded in each session's header line. */
export function piSessionDir(repoDir: string): string {
return `--${repoDir.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
}
/** Is `dir` the repo itself or somewhere inside it? */
export function withinRepo(dir: string | undefined, repoDir: string): boolean {
return (
@@ -376,6 +389,72 @@ function readDshLog(file: string): string[] {
return text.split("\n").filter((line) => line.trim());
}
/**
* pi and its fork Prime Agent: a `{type:"session", id, cwd}` header line followed by
* `{type:"message"}` entries the same schema from both hosts, so one body reads either.
*
* Only the file layout differs, which is why the two callers below exist:
* pi `sessions/--<encoded cwd>--/<timestamp>_<uuid>.jsonl`
* Prime Agent `sessions/<uuid>.jsonl` flat, no per-directory folder
*
* Attribution is the header's `cwd` in both cases. pi's folder name is a lossy rendering of that
* same path (see piSessionDir), so it is used only to narrow the walk, never to decide ownership
* exactly the rule the Claude reader follows, and for the same reason: a wrong guess files someone
* else's conversation into this repo's memory.
*/
function piFamilySessions(files: string[], repoDir: string): HistoryImport {
const sessions: ChatSession[] = [];
let unattributed = 0;
for (const file of files) {
try {
const head = firstLine(file);
if (!head) continue;
const meta = JSON.parse(head) as { type?: string; cwd?: string; id?: string };
// A file whose first line is not the session header is not a session log (pi keeps other
// artifacts under the same tree), not a session we failed to attribute.
if (meta?.type !== "session") continue;
if (!meta.cwd) {
unattributed++;
continue;
}
if (!withinRepo(meta.cwd, repoDir)) continue;
const s = toSession(meta.id ?? file, readPiTranscript(file));
if (s) sessions.push(s);
} catch {
/* a single unreadable transcript must not abort the import */
}
}
return { supported: true, sessions, unattributed };
}
function piHistory(repoDir: string, home: string): HistoryImport {
const root = join(home, ".pi", "agent", "sessions");
if (!existsSync(root)) return { supported: true, sessions: [] };
// Starting pi from a subdirectory gives that subdirectory its own folder, so an exact match would
// silently miss that history. Prefilter on the encoded name — `--<repo>--` itself plus anything
// nested under `--<repo>-` — then let the header cwd confirm; a sibling repo whose name merely
// extends this one (`repo-other`) matches the prefix and is dropped by that check.
const exact = piSessionDir(repoDir);
const nested = `${exact.slice(0, -2)}-`;
const files: string[] = [];
// listDir at both levels: a stray FILE where either `sessions` itself or one of its session
// folders was expected must cost that one entry, not the run — importLocalHistory promises never
// to throw and its caller (installer.ts importConversations) has no catch of its own.
for (const dir of listDir(root).filter((d) => d === exact || d.startsWith(nested))) {
files.push(...jsonlFiles(join(root, dir)));
}
return piFamilySessions(files, repoDir);
}
function primeAgentHistory(repoDir: string, home: string): HistoryImport {
// Flat storage, so there is nothing to prefilter on and every header is read. That stays cheap
// because the directory holds one file per SESSION, where pi's holds one folder per working
// directory — the scale that made pi's prefilter necessary does not arise here.
const root = join(home, ".prime", "agent", "sessions");
if (!existsSync(root)) return { supported: true, sessions: [] };
return piFamilySessions(jsonlFiles(root), repoDir);
}
const SQLITE_HISTORY =
"keeps session history in an internal SQLite database, whose schema is unversioned and would " +
"break on any upstream change";
@@ -403,6 +482,10 @@ export function importLocalHistory(
);
case "dsh":
return dshHistory(repoDir, home);
case "pi":
return piHistory(repoDir, home);
case "prime-agent":
return primeAgentHistory(repoDir, home);
case "opencode":
// opencode v2 keeps sessions in the SAME `opencode.db` v1 does.
case "opencode2":
@@ -0,0 +1,32 @@
/**
* Where each host keeps the companion skill, as home-relative path parts.
*
* ONE map, because two independent code paths write and refresh those directories: the installer
* copies the packaged skill in and removes it again (src/installer.ts), and every session start
* re-copies it on drift so `npm update -g` upgrades the skill too (core/skill-sync.ts).
*
* They used to hold the paths separately, and the self-update copy listed only four of the ten
* hosts the installer writes so Copilot, Grok Build, Cline, dsh, pi and Prime Agent stayed pinned
* to whichever SKILL.md they happened to be installed with, forever. Same shape as #3524: the
* sibling nobody wrote a test for is the sibling that gets forgotten, so the list lives once and
* `installer.test.ts` asserts it over the whole family.
*
* A host absent here has no skills mechanism at all (opencode and its Kilo fork).
*/
export const SKILL_DIRS: Record<string, string[]> = {
"claude-code": [".claude", "skills"],
// Codex and dsh share the agentskills-standard root; uninstalling either removes the one copy.
codex: [".agents", "skills"],
dsh: [".agents", "skills"],
"antigravity-cli": [".gemini", "config", "skills"],
"cursor-cli": [".cursor", "skills"],
"copilot-cli": [".copilot", "skills"],
"grok-build": [".grok", "skills"],
"cline-cli": [".cline", "data", "settings", "skills"],
"qwen-code": [".qwen", "skills"], // Qwen's user-level skills root (Storage.getUserSkillsDirs)
// The pi family reads the shared ~/.agents/skills too, but writes its OWN root: skill removal is
// by fixed directory name, so installing to the shared one would make `uninstall pi` take Codex's
// and dsh's copy with it.
pi: [".pi", "agent", "skills"],
"prime-agent": [".prime", "agent", "skills"],
};
@@ -2,7 +2,9 @@ import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, existsSync
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { SKILL_DIRS, syncCompanionSkill } from "./skill-sync";
import { INSTALLERS } from "../installer";
import { SKILL_DIRS } from "./skill-dirs";
import { syncCompanionSkill } from "./skill-sync";
describe("syncCompanionSkill", () => {
const dirs: string[] = [];
@@ -55,19 +57,23 @@ describe("syncCompanionSkill", () => {
* Family-wide guard (#3524 shape): a harness whose installer copies the skill but that SKILL_DIRS
* does not map installs it once and then never refreshes it `npm update -g` upgrades the package
* while that host keeps the old SKILL.md until someone re-installs. A per-harness test cannot catch
* this, because the harness that forgets is by definition the one whose test nobody wrote. So
* enumerate the installer's own call sites instead and assert each is mapped.
* this, because the harness that forgets is by definition the one whose test nobody wrote.
*
* KNOWN_UNMAPPED are pre-existing gaps found while adding qwen-code; they are real defects, listed
* so they stay visible and so a NEW harness cannot silently join them.
* This once grepped `installSkill(c, "<harness>"` out of installer.ts, with an exemption list for
* five hosts it found unmapped. Both halves are gone. The installer now derives every skills
* directory from this map, so an unmapped host cannot install at all (skillsBaseFor throws) and
* the pi family, which reaches installSkill through a shared factory with a `harness` variable,
* matched no literal and so was invisible to that regex anyway. installer.test.ts owns the forward
* direction by behaviour instead: it installs EVERY entry of INSTALLERS, finds where the skill
* actually landed, and requires the self-update to refresh that copy.
*
* What is left here is the reverse direction, which nothing else states: a mapped host must be a
* host that exists. A stale entry is a path this module would keep writing to for an agent the
* installer no longer supports.
*/
describe("SKILL_DIRS covers every harness the installer copies the skill into", () => {
const KNOWN_UNMAPPED = new Set(["copilot-cli", "grok-build", "cline-cli", "dsh", "prime-agent"]);
it("maps every installSkill target", () => {
const src = readFileSync(new URL("../installer.ts", import.meta.url), "utf8");
const targets = [...src.matchAll(/installSkill\(c, "([^"]+)"/g)].map((m) => m[1]);
expect(targets.length).toBeGreaterThan(5); // the regex still matches the real call sites
expect(targets.filter((h) => !KNOWN_UNMAPPED.has(h) && !(h in SKILL_DIRS))).toEqual([]);
describe("SKILL_DIRS maps only real harnesses", () => {
it("names no harness the installer does not offer", () => {
const installable = new Set(INSTALLERS.map((i) => i.name));
expect(Object.keys(SKILL_DIRS).filter((h) => !installable.has(h))).toEqual([]);
});
});
@@ -11,21 +11,7 @@ import { cpSync, existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
/**
* Where each host keeps the installed skill, so a session start can refresh it in place.
*
* MUST list every harness whose installer calls `installSkill` the structural guard in
* skill-sync.test.ts asserts that, with an explicit exemption list for the hosts that predate it
* (see there). A harness missing here installs the skill once and then keeps that copy forever.
*/
export const SKILL_DIRS: Record<string, string[]> = {
"claude-code": [".claude", "skills"],
codex: [".agents", "skills"], // agentskills-standard shared dir
"antigravity-cli": [".gemini", "config", "skills"],
"cursor-cli": [".cursor", "skills"],
"qwen-code": [".qwen", "skills"], // Qwen's user-level skills root (Storage.getUserSkillsDirs)
};
import { SKILL_DIRS } from "./skill-dirs";
/** The packaged skill dir (pkgRoot/skill, resolved relative to the built dist). */
function packagedSkillDir(): string {
@@ -0,0 +1,155 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { type PiMessage, readPiMessages, readPiTranscript } from "./transcript-pi";
describe("readPiMessages", () => {
it("keeps user/assistant text + compact action turns; drops other roles/blocks and tool args", () => {
const messages: PiMessage[] = [
// non-conversational role: dropped
{ role: "system", content: [{ type: "text", text: "you are pi" }] },
// string content is accepted as a prose turn
{ role: "user", content: "add retry backoff to the uploader" },
// assistant message: text + a toolCall (args NOT retained) + a dropped reasoning block
{
role: "assistant",
content: [
{ type: "reasoning", text: "thinking…" },
{ type: "text", text: "I'll add exponential backoff." },
{ type: "toolCall", name: "bash", arguments: { command: "npm test" } },
],
},
// assistant message with only a toolCall: just the compact action line
{
role: "assistant",
content: [{ type: "toolCall", name: "read", arguments: { path: "nope.ts" } }],
},
];
expect(readPiMessages(messages)).toEqual([
{ role: "user", content: "add retry backoff to the uploader" },
{ role: "assistant", content: "I'll add exponential backoff." },
{ role: "action", content: "bash npm test" },
{ role: "action", content: "read nope.ts" },
]);
});
it("strips injected memory that leaks into a kept message", () => {
const messages: PiMessage[] = [
{
role: "user",
content: [
{ type: "text", text: "<hindsight_memories>\nleak\n</hindsight_memories>\nWhy retry?" },
],
},
];
expect(readPiMessages(messages)).toEqual([{ role: "user", content: "Why retry?" }]);
});
it("never throws on malformed entries", () => {
const messages = [
null,
{},
{ role: "user" },
{ role: "assistant", content: [null, 3, "x"] },
] as unknown as PiMessage[];
expect(() => readPiMessages(messages)).not.toThrow();
expect(readPiMessages(messages)).toEqual([]);
});
});
describe("readPiTranscript", () => {
let dir: string | undefined;
afterEach(() => {
if (dir) rmSync(dir, { recursive: true, force: true });
dir = undefined;
});
function writeLog(...lines: unknown[]): string {
dir = mkdtempSync(join(tmpdir(), "hs-pi-log-"));
const file = join(dir, "session.jsonl");
writeFileSync(file, `${lines.map((l) => JSON.stringify(l)).join("\n")}\n`);
return file;
}
it("renders a stored session exactly like the live agent_end path, stamped with entry timestamps", () => {
const file = writeLog(
{ type: "session", version: 3, id: "s1", cwd: "/repo" },
// Settings entries sit between messages and carry no conversation.
{ type: "model_change", provider: "openai-codex", modelId: "gpt-5.6-sol" },
{
type: "message",
timestamp: "2026-08-24T12:00:01.000Z",
message: { role: "user", content: [{ type: "text", text: "add retry backoff" }] },
},
{
type: "message",
timestamp: "2026-08-24T12:00:02.000Z",
message: {
role: "assistant",
content: [
{ type: "thinking", thinking: "internal" },
{ type: "text", text: "Adding exponential backoff." },
{ type: "toolCall", name: "read", arguments: { path: "uploader.ts" } },
],
},
},
// Tool OUTPUT is mechanical noise, dropped like the Codex reader drops function_call_output.
{
type: "message",
timestamp: "2026-08-24T12:00:03.000Z",
message: { role: "toolResult", content: [{ type: "text", text: "file contents" }] },
}
);
expect(readPiTranscript(file)).toEqual([
{ role: "user", content: "add retry backoff", timestamp: "2026-08-24T12:00:01.000Z" },
{
role: "assistant",
content: "Adding exponential backoff.",
timestamp: "2026-08-24T12:00:02.000Z",
},
{ role: "action", content: "read uploader.ts", timestamp: "2026-08-24T12:00:02.000Z" },
]);
});
// An imported session must not feed our own injected memory back into the bank — the same
// stripInjectedMemory guarantee the live path has, exercised through the file reader.
it("strips injected memory from a stored user turn", () => {
const file = writeLog(
{ type: "session", version: 3, id: "s1", cwd: "/repo" },
{
type: "message",
timestamp: "2026-08-24T12:00:01.000Z",
message: {
role: "user",
content: [
{
type: "text",
text: "<hindsight_memories>only 429 and 408 retry</hindsight_memories>\nship it",
},
],
},
}
);
const turns = readPiTranscript(file);
expect(turns).toEqual([
{ role: "user", content: "ship it", timestamp: "2026-08-24T12:00:01.000Z" },
]);
});
it("survives a torn tail line and a missing file instead of throwing", () => {
dir = mkdtempSync(join(tmpdir(), "hs-pi-log-"));
const file = join(dir, "session.jsonl");
writeFileSync(
file,
`${JSON.stringify({ type: "session", id: "s1", cwd: "/repo" })}\n` +
`${JSON.stringify({ type: "message", message: { role: "user", content: "kept" } })}\n` +
`{"type":"message","message":{"role":"assis`
);
expect(readPiTranscript(file)).toEqual([{ role: "user", content: "kept" }]);
expect(readPiTranscript(join(dir, "gone.jsonl"))).toEqual([]);
});
});
@@ -0,0 +1,117 @@
/**
* pi live-transcript normalizer shared by pi and its fork Prime Agent.
*
* These hosts hand an extension the completed exchange as an in-memory message list on the
* `agent_end` event (not a JSONL file like Claude/Codex), so this is a pure function over that list,
* mirroring transcript-opencode.ts. It produces the same rich `TransportTurn[]` shape (prose turns +
* compact `role:"action"` tool turns) and reuses the shared `stripInjectedMemory`/`actionLine`
* helpers so a retain never feeds injected memory back into recall and tool noise stays out of the
* bank.
*/
import type { TransportTurn } from "./chat";
import { readJsonlTail } from "./jsonl";
import { actionLine, stripInjectedMemory } from "./transcript-util";
/** Structural subset of a pi message content block (TextContent | ToolCall | dropped). */
export interface PiBlock {
type?: string;
text?: string; // text block
name?: string; // toolCall block: the tool name
arguments?: unknown; // toolCall block: the call input
}
/** Structural subset of a pi message ({ role, content }). */
export interface PiMessage {
role?: string;
content?: unknown; // string | PiBlock[]
}
/** Structural subset of one line of a stored pi session (`~/.pi/agent/sessions/**\/*.jsonl`).
* Conversation lives in `type:"message"` entries; the file's first line is the `type:"session"`
* header (id + cwd) and the rest are settings changes, which carry no conversation. */
interface PiEntry {
type?: string;
timestamp?: string;
message?: PiMessage;
}
/**
* Render one pi message into turns. Text (string content or text blocks) joins into one
* prose turn (injected-memory stripped); each `toolCall` block becomes its own compact
* `role:"action"` turn (tool name + primary target via `actionLine` no args, no output). Other
* block types and non-conversational roles are dropped.
*/
function renderMessage(m: PiMessage): TransportTurn[] {
if (!m || typeof m !== "object") return [];
const role = m.role;
if (role !== "user" && role !== "assistant") return [];
const texts: string[] = [];
const actions: TransportTurn[] = [];
if (typeof m.content === "string") {
const t = stripInjectedMemory(m.content).trim();
if (t) texts.push(t);
} else if (Array.isArray(m.content)) {
for (const part of m.content) {
if (!part || typeof part !== "object") continue;
const block = part as PiBlock;
if (block.type === "text" && typeof block.text === "string") {
const t = stripInjectedMemory(block.text).trim();
if (t) texts.push(t);
} else if (block.type === "toolCall" && typeof block.name === "string") {
actions.push({ role: "action", content: actionLine(block.name, block.arguments) });
}
}
}
const out: TransportTurn[] = [];
const joined = texts.join("\n").trim();
if (joined) out.push({ role, content: joined });
out.push(...actions);
return out;
}
/**
* Normalize a pi `agent_end` message list into transcript turns (user/assistant prose plus
* compact action turns for tool calls). Never throws on malformed entries.
*/
export function readPiMessages(messages: readonly PiMessage[]): TransportTurn[] {
return (messages || []).flatMap((m) => renderMessage(m));
}
/**
* Read a STORED pi session file (the history-import path) into the same turns the live `agent_end`
* path produces.
*
* pi and its fork Prime Agent both persist a session as JSONL whose conversation entries wrap
* exactly the message objects the live event hands over, so this shares `renderMessage` with
* readPiMessages rather than re-deriving the normalization an imported session and a live one
* must reach the bank identically. `toolResult` entries are dropped with every other
* non-conversational role, matching how the Codex reader drops `function_call_output`.
*
* Bounded and fail-open like the other stored-transcript readers: a missing file, a torn line or a
* transcript past the size cap yields fewer turns, never a throw (see core/jsonl.ts).
*/
export function readPiTranscript(path: string): TransportTurn[] {
const turns: TransportTurn[] = [];
for (const rawLine of readJsonlTail(path, { scope: "pi" }).lines) {
const trimmed = rawLine.trim();
if (!trimmed) continue;
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
continue;
}
if (typeof parsed !== "object" || parsed === null) continue;
const entry = parsed as PiEntry;
if (entry.type !== "message" || !entry.message) continue;
// The entry's timestamp, not the inner message's: the outer one is the ISO string every other
// reader's turns carry, while the inner is epoch milliseconds.
for (const turn of renderMessage(entry.message)) {
turns.push(entry.timestamp ? { ...turn, timestamp: entry.timestamp } : turn);
}
}
return turns;
}
@@ -1,57 +0,0 @@
import { describe, expect, it } from "vitest";
import { type PaMessage, readPrimeAgentMessages } from "./transcript-prime-agent";
describe("readPrimeAgentMessages", () => {
it("keeps user/assistant text + compact action turns; drops other roles/blocks and tool args", () => {
const messages: PaMessage[] = [
// non-conversational role: dropped
{ role: "system", content: [{ type: "text", text: "you are prime-agent" }] },
// string content is accepted as a prose turn
{ role: "user", content: "add retry backoff to the uploader" },
// assistant message: text + a toolCall (args NOT retained) + a dropped reasoning block
{
role: "assistant",
content: [
{ type: "reasoning", text: "thinking…" },
{ type: "text", text: "I'll add exponential backoff." },
{ type: "toolCall", name: "bash", arguments: { command: "npm test" } },
],
},
// assistant message with only a toolCall: just the compact action line
{
role: "assistant",
content: [{ type: "toolCall", name: "read", arguments: { path: "nope.ts" } }],
},
];
expect(readPrimeAgentMessages(messages)).toEqual([
{ role: "user", content: "add retry backoff to the uploader" },
{ role: "assistant", content: "I'll add exponential backoff." },
{ role: "action", content: "bash npm test" },
{ role: "action", content: "read nope.ts" },
]);
});
it("strips injected memory that leaks into a kept message", () => {
const messages: PaMessage[] = [
{
role: "user",
content: [
{ type: "text", text: "<hindsight_memories>\nleak\n</hindsight_memories>\nWhy retry?" },
],
},
];
expect(readPrimeAgentMessages(messages)).toEqual([{ role: "user", content: "Why retry?" }]);
});
it("never throws on malformed entries", () => {
const messages = [
null,
{},
{ role: "user" },
{ role: "assistant", content: [null, 3, "x"] },
] as unknown as PaMessage[];
expect(() => readPrimeAgentMessages(messages)).not.toThrow();
expect(readPrimeAgentMessages(messages)).toEqual([]);
});
});
@@ -1,71 +0,0 @@
/**
* Prime Agent live-transcript normalizer.
*
* Prime Agent hands an extension the completed exchange as an in-memory message list on the
* `agent_end` event (not a JSONL file like Claude/Codex), so this is a pure function over that list,
* mirroring transcript-opencode.ts. It produces the same rich `TransportTurn[]` shape (prose turns +
* compact `role:"action"` tool turns) and reuses the shared `stripInjectedMemory`/`actionLine`
* helpers so a retain never feeds injected memory back into recall and tool noise stays out of the
* bank.
*/
import type { TransportTurn } from "./chat";
import { actionLine, stripInjectedMemory } from "./transcript-util";
/** Structural subset of a Prime Agent message content block (TextBlock | ToolCallBlock | dropped). */
export interface PaBlock {
type?: string;
text?: string; // text block
name?: string; // toolCall block: the tool name
arguments?: unknown; // toolCall block: the call input
}
/** Structural subset of a Prime Agent message ({ role, content }). */
export interface PaMessage {
role?: string;
content?: unknown; // string | PaBlock[]
}
/**
* Render one Prime Agent message into turns. Text (string content or text blocks) joins into one
* prose turn (injected-memory stripped); each `toolCall` block becomes its own compact
* `role:"action"` turn (tool name + primary target via `actionLine` no args, no output). Other
* block types and non-conversational roles are dropped.
*/
function renderMessage(m: PaMessage): TransportTurn[] {
if (!m || typeof m !== "object") return [];
const role = m.role;
if (role !== "user" && role !== "assistant") return [];
const texts: string[] = [];
const actions: TransportTurn[] = [];
if (typeof m.content === "string") {
const t = stripInjectedMemory(m.content).trim();
if (t) texts.push(t);
} else if (Array.isArray(m.content)) {
for (const part of m.content) {
if (!part || typeof part !== "object") continue;
const block = part as PaBlock;
if (block.type === "text" && typeof block.text === "string") {
const t = stripInjectedMemory(block.text).trim();
if (t) texts.push(t);
} else if (block.type === "toolCall" && typeof block.name === "string") {
actions.push({ role: "action", content: actionLine(block.name, block.arguments) });
}
}
}
const out: TransportTurn[] = [];
const joined = texts.join("\n").trim();
if (joined) out.push({ role, content: joined });
out.push(...actions);
return out;
}
/**
* Normalize Prime Agent's `agent_end` message list into transcript turns (user/assistant prose plus
* compact action turns for tool calls). Never throws on malformed entries.
*/
export function readPrimeAgentMessages(messages: readonly PaMessage[]): TransportTurn[] {
return (messages || []).flatMap((m) => renderMessage(m));
}
@@ -244,6 +244,20 @@ export const clineDockerSetup: HarnessDockerSetup = {
command: (prompt) => ["cline", "--auto-approve", "true", "--cwd", "/workspace", prompt],
};
/**
* pi extension host, and the upstream Prime Agent forked from. `-p` runs one prompt
* non-interactively and prints the reply, and `~/.pi/agent/auth.json` carries the provider
* credentials on its own.
*/
export const piDockerSetup: HarnessDockerSetup = {
name: "pi",
hindsightHarness: "pi",
credentialPath: () => authPath("PI_E2E_AUTH_PATH", ".pi", "agent", "auth.json"),
credentialTarget: "/root/.pi/agent/auth.json",
installCommand: "hindsight-coding-agents install pi",
command: (prompt) => ["pi", "-p", prompt],
};
/**
* Prime Agent extension host. `-p` runs one prompt non-interactively and prints the reply.
* Auth is the whole `~/.prime/agent` directory (auth.json plus the kernel venv it provisions on
@@ -300,6 +314,7 @@ export const ALL_HARNESS_SETUPS: HarnessDockerSetup[] = [
qwenDockerSetup,
devinDockerSetup,
clineDockerSetup,
piDockerSetup,
primeAgentDockerSetup,
dshDockerSetup,
];
@@ -1,9 +1,9 @@
import { z } from "zod";
import { describe, expect, it, vi } from "vitest";
import type { ToolSpec } from "./core/knowledge-tools";
import { createPrimeAgentHooks, toPrimeAgentTool } from "./prime-agent";
import type { ToolSpec } from "../core/knowledge-tools";
import { createPiHooks, toPiTool } from "./pi-extension";
describe("Prime Agent extension adapter", () => {
describe("pi extension adapter", () => {
it("recalls on each prompt and appends the injection to the system prompt", async () => {
const onPrompt = vi.fn(async () => {});
const core = {
@@ -11,17 +11,17 @@ describe("Prime Agent extension adapter", () => {
getInjection: vi.fn(() => "<hindsight_memories>remember this</hindsight_memories>"),
onTranscript: vi.fn(async () => {}),
};
const hooks = createPrimeAgentHooks(core as never);
const hooks = createPiHooks(core as never, "pi");
const result = await hooks.beforeAgentStart(
{ prompt: " plan the change ", systemPrompt: "You are Prime Agent." },
{ prompt: " plan the change ", systemPrompt: "You are pi." },
"session-1"
);
expect(onPrompt).toHaveBeenCalledOnce();
expect(onPrompt).toHaveBeenCalledWith("session-1", "plan the change");
expect(result?.systemPrompt).toBe(
"You are Prime Agent.\n\n<hindsight_memories>remember this</hindsight_memories>"
"You are pi.\n\n<hindsight_memories>remember this</hindsight_memories>"
);
});
@@ -31,7 +31,7 @@ describe("Prime Agent extension adapter", () => {
getInjection: vi.fn(() => undefined),
onTranscript: vi.fn(async () => {}),
};
const hooks = createPrimeAgentHooks(core as never);
const hooks = createPiHooks(core as never, "pi");
const result = await hooks.beforeAgentStart({ prompt: "hi", systemPrompt: "sys" }, "session-1");
expect(result).toBeUndefined();
});
@@ -46,7 +46,7 @@ describe("Prime Agent extension adapter", () => {
getInjection: vi.fn(() => undefined),
onTranscript: vi.fn(async () => {}),
};
const hooks = createPrimeAgentHooks(core as never, sessionStart);
const hooks = createPiHooks(core as never, "pi", sessionStart);
const pending = hooks.beforeAgentStart(
{ prompt: "first prompt", systemPrompt: "sys" },
"session-1"
@@ -66,7 +66,7 @@ describe("Prime Agent extension adapter", () => {
getInjection: vi.fn(() => undefined),
onTranscript,
};
const hooks = createPrimeAgentHooks(core as never);
const hooks = createPiHooks(core as never, "pi");
await hooks.agentEnd(
{
@@ -98,12 +98,12 @@ describe("Prime Agent extension adapter", () => {
getInjection: vi.fn(() => undefined),
onTranscript,
};
const hooks = createPrimeAgentHooks(core as never);
const hooks = createPiHooks(core as never, "pi");
await hooks.agentEnd({ messages: [] }, "session-1");
expect(onTranscript).not.toHaveBeenCalled();
});
it("adapts a knowledge ToolSpec into a Prime Agent native tool with a JSON-Schema parameters object", async () => {
it("adapts a knowledge ToolSpec into a pi native tool with a JSON-Schema parameters object", async () => {
const spec: ToolSpec = {
name: "hindsight_search_knowledge_pages",
description: "Search the knowledge pages",
@@ -117,7 +117,7 @@ describe("Prime Agent extension adapter", () => {
handler: async () => ({ content: [{ type: "text", text: "page A\npage B" }] }),
};
const def = toPrimeAgentTool(spec);
const def = toPiTool(spec);
expect(def.name).toBe("hindsight_search_knowledge_pages");
expect(def.label).toBe("hindsight_search_knowledge_pages");
expect(def.parameters.type).toBe("object");
@@ -0,0 +1,184 @@
/**
* Shared entrypoint factory for the pi-family EXTENSION hosts (pi and its fork Prime Agent).
*
* pi (`@earendil-works/pi-coding-agent`) loads extensions listed in `~/.pi/agent/settings.json`;
* Prime Agent (PrimeIntellect) is a fork of pi and loads the same shape from
* `~/.prime/agent/settings.json`. Both call an extension's default export with their `pi` API, and
* both expose the identical surface this adapter needs: `before_agent_start` (recall +
* system-prompt injection), `agent_end` (transcript write-back), and `registerTool` for the native
* `hindsight_*` knowledge tools. So neither host needs an adapter of its own they differ only in
* which harness name they report, which selects the `harnesses.<name>` config section, feeds
* `{harness}` bank templating, and keeps their sessions attributable in diagnostics separately.
*
* The memory behaviour itself stays in RuntimeCore the same reflect-and-inject core every
* Hindsight harness uses; this file only adapts the pi extension API at its boundary.
*/
import { z } from "zod";
import { resolveHostMemory } from "../core/host-client";
import { diag } from "../core/diag";
import type { ToolSpec } from "../core/knowledge-tools";
import { RuntimeCore } from "../core/runtime";
import { type PiMessage, readPiMessages } from "../core/transcript-pi";
// ── Structural subset of the pi extension API ───────────────────────────────────────────────────
// Declared locally so this package takes no dependency on the fast-moving pi / Prime Agent SDKs;
// the real runtime passes a compatible object at load time.
interface BeforeAgentStartEvent {
type: "before_agent_start";
/** The raw user prompt text. */
prompt: string;
/** The fully assembled system prompt for this turn. */
systemPrompt: string;
}
interface AgentEndEvent {
type: "agent_end";
/** The conversation messages for the completed agent loop. */
messages: readonly PiMessage[];
}
interface BeforeAgentStartResult {
systemPrompt?: string;
}
interface SessionManagerLike {
getSessionId(): string;
}
/** Only what this adapter reads. Both hosts also expose a UI notifier, but the seed banner it would
* carry is raised inside seedIfCold at extension load, before any handler has a `ctx` to notify
* through so it is logged rather than toasted here, and the field is not declared. */
interface ExtensionContext {
sessionManager: SessionManagerLike;
}
/** A JSON-Schema-shaped parameters object. The host forwards it to the model provider verbatim. */
type JsonSchema = Record<string, unknown>;
interface ToolDefinition {
name: string;
label: string;
description: string;
parameters: JsonSchema;
execute(
toolCallId: string,
params: Record<string, unknown>
): Promise<{ content: { type: "text"; text: string }[]; details: unknown }>;
}
interface ExtensionAPI {
on(
event: "before_agent_start",
handler: (
event: BeforeAgentStartEvent,
ctx: ExtensionContext
) => Promise<BeforeAgentStartResult | void> | BeforeAgentStartResult | void
): void;
on(
event: "agent_end",
handler: (event: AgentEndEvent, ctx: ExtensionContext) => Promise<void> | void
): void;
registerTool(definition: ToolDefinition): void;
}
export type ExtensionFactory = (pi: ExtensionAPI) => void;
/**
* Adapt a harness-agnostic ToolSpec (MCP-shaped, shared by every harness) to a pi native tool. The
* spec's Zod raw shape is converted to a JSON Schema for `parameters` — pi's documented type is a
* TypeBox `TSchema`, which is itself a JSON Schema, and neither host validates tool arguments
* against it (the agent loop only runs an optional `prepareArguments`), so the schema is passed
* straight to the model provider. A plain JSON Schema is therefore exactly what the tool needs. The
* spec's handler returns an MCP `{content:[{text}]}` result and never throws, so we surface the
* joined text back to the model.
*/
export function toPiTool(spec: ToolSpec): ToolDefinition {
const parameters = z.toJSONSchema(z.object(spec.inputSchema)) as JsonSchema;
return {
name: spec.name,
label: spec.name,
description: spec.description,
parameters,
async execute(_toolCallId: string, params: Record<string, unknown>) {
const r = await spec.handler(params);
const text = r.content?.map((c) => c.text).join("\n") || "";
return { content: [{ type: "text", text }], details: null };
},
};
}
/**
* Make the host-specific pi hooks testable without importing either host's SDK. RuntimeCore is the
* shared lifecycle implementation; this adapter only converts pi messages at its boundary and never
* calls Hindsight directly.
*/
export function createPiHooks(
core: Pick<RuntimeCore, "onPrompt" | "getInjection" | "onTranscript">,
harness: string,
sessionStart?: Promise<void>
) {
let sessionStartAwaited = false;
return {
async beforeAgentStart(
event: { prompt: string; systemPrompt: string },
sessionId: string
): Promise<BeforeAgentStartResult | undefined> {
if (!sessionStartAwaited) {
sessionStartAwaited = true;
// Awaiting the shared SessionStart lifecycle before the first prompt preserves the invariant
// that a brand-new bank skips its first auto-reflect instead of spending that synthesis
// before it has any knowledge (mirrors the other harnesses).
await sessionStart;
}
const prompt = event.prompt.trim();
if (prompt) await core.onPrompt(sessionId, prompt);
const injection = core.getInjection(sessionId);
if (!injection) {
diag(harness, "inject_empty", { session: sessionId });
return undefined;
}
diag(harness, "inject_ok", { session: sessionId, chars: injection.length });
return { systemPrompt: `${event.systemPrompt}\n\n${injection}` };
},
async agentEnd(event: { messages: readonly PiMessage[] }, sessionId: string): Promise<void> {
const turns = readPiMessages(event.messages);
if (turns.length) await core.onTranscript(sessionId, turns);
},
};
}
function createRuntime(harness: string, repoPath: string): RuntimeCore | undefined {
const { cfg, bankId, client } = resolveHostMemory(harness, repoPath);
if (cfg.disabled) return undefined; // global switch, per-bank opt-out or optInOnly
return new RuntimeCore(client, bankId, cfg, harness, repoPath);
}
/**
* Build the default export for a pi-family extension host. `harness` is the name the host is known
* by ("pi", "prime-agent"), used for config lookup, bank derivation and diagnostics scoping it is
* NOT config-chosen; the entrypoint the host loaded determines it.
*
* The returned factory is called once per session in the project directory; it resolves config and
* bank from `process.cwd()`, registers the knowledge tools, kicks off the shared cold-seed, and
* wires the recall/retain hooks.
*/
export function createPiExtension(harness: string): ExtensionFactory {
return (pi) => {
const repoPath = process.cwd();
const core = createRuntime(harness, repoPath);
if (!core) return;
for (const spec of core.toolSpecs()) pi.registerTool(toPiTool(spec));
// Fire-and-forget cold seed (bank check + background git seed + knowledge preamble); the first
// before_agent_start awaits it via createPiHooks.
const sessionStart = core.seedIfCold(repoPath);
const hooks = createPiHooks(core, harness, sessionStart);
pi.on("before_agent_start", (event, ctx) =>
hooks.beforeAgentStart(event, ctx.sessionManager.getSessionId())
);
pi.on("agent_end", (event, ctx) => hooks.agentEnd(event, ctx.sessionManager.getSessionId()));
};
}
@@ -1,5 +1,8 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { getHarness, HARNESS_NAMES } from "./registry";
import { getHarness, HARNESS_NAMES, PLUGIN_ENTRYPOINTS } from "./registry";
describe("HARNESS_NAMES", () => {
it("lists all registered harnesses", () => {
@@ -9,6 +12,7 @@ describe("HARNESS_NAMES", () => {
"opencode2",
"kilo",
"cline-cli",
"pi",
"prime-agent",
"dsh",
"claude-code",
@@ -22,7 +26,7 @@ describe("HARNESS_NAMES", () => {
"qwen-code",
])
);
expect(HARNESS_NAMES).toHaveLength(15);
expect(HARNESS_NAMES).toHaveLength(16);
});
});
@@ -69,6 +73,20 @@ describe("getHarness", () => {
expect(() => adapter.createRuntime({} as never)).toThrow(/src\/cline\.ts/);
});
it("resolves pi and its Prime Agent fork as separate extension harnesses", async () => {
for (const [name, entry] of [
["pi", /src\/pi\.ts/],
["prime-agent", /src\/prime-agent\.ts/],
] as const) {
const adapter = await getHarness(name);
expect(adapter.name).toBe(name);
// Both drive the same shared adapter (harness/pi-extension.ts) but are wired by their own
// entrypoint, so each must resolve to its own dist bundle — swapping them would silently
// retain one host's sessions under the other's bank.
expect(() => adapter.createRuntime({} as never)).toThrow(entry);
}
});
it("resolves DeepSeek Harness as a native Cordis-plugin harness", async () => {
const adapter = await getHarness("dsh");
expect(adapter.name).toBe("dsh");
@@ -105,3 +123,32 @@ describe("registry covers every installable harness", () => {
expect(missing).toEqual([]);
});
});
/**
* The harness name an entrypoint reports is a bare string literal that nothing else checks: it
* selects the `harnesses.<name>` config section, feeds `{harness}` bank templating, and is stamped
* on every document that host retains. A typo there ships green the registry, the installer and
* the control plane's logo map are three SEPARATE hand-maintained lists, so none of them notices
* that the running plugin calls itself something else.
*
* So assert it over the whole family, enumerated from the registry rather than a fourth list: every
* entrypoint the registry names must report the harness the registry maps it to.
*/
describe("every plugin entrypoint reports the harness the registry maps it to", () => {
const PKG = fileURLToPath(new URL("../..", import.meta.url));
/** The two idioms an entrypoint uses to name itself: the argument it hands its shared factory
* (opencode/Kilo via createPluginEntry, opencode 2 via createOpencode2PluginEntry, pi/Prime Agent
* via createPiExtension), or its own module constant (Cline and dsh, which build their runtime
* themselves). Matching the factory NAMES rather than a generic call keeps the guard honest: an
* entrypoint that names itself some new way fails here until this list is widened, which is
* exactly the review that a new host needs. */
const DECLARES_HARNESS =
/(?:createPluginEntry|createOpencode2PluginEntry|createPiExtension)\("([^"]+)"\)|const HARNESS = "([^"]+)"/;
it.each(Object.entries(PLUGIN_ENTRYPOINTS))("%s (%s)", (harness, entry) => {
const declaration = readFileSync(join(PKG, entry), "utf8").match(DECLARES_HARNESS);
expect(declaration, `${entry} names no harness in a form this guard recognises`).not.toBeNull();
expect(declaration![1] ?? declaration![2]).toBe(harness);
});
});
@@ -51,7 +51,9 @@ export const HARNESS_NAMES = [
// DeepSeek Harness loads dist/dsh.js as a native Cordis plugin (src/dsh.ts). Its Claude Code /
// Codex hook bridges are optional packages, so there is no hook binary to install either.
"dsh",
// Prime Agent loads dist/prime-agent.js as an extension (src/prime-agent.ts); no hook binary.
// pi loads dist/pi.js as an extension (src/pi.ts), and Prime Agent — a fork of pi — loads
// dist/prime-agent.js the same way (src/prime-agent.ts). Neither has a hook binary.
"pi",
"prime-agent",
"claude-code",
"cursor-cli",
@@ -77,12 +79,15 @@ const HOOK_BINS: Record<string, string> = {
// more hook harnesses: add a HookSpec entry point (see src/cursor-hook.ts) + a registration here.
};
/** Where each persistent-plugin harness's runtime is actually built (see the branch below). */
const PLUGIN_ENTRYPOINTS: Record<string, string> = {
/** Where each persistent-plugin harness's runtime is actually built (see the branch below).
* Exported so a test can assert, over the whole family, that each of those files reports the
* harness mapped to it here see registry.test.ts. */
export const PLUGIN_ENTRYPOINTS: Record<string, string> = {
opencode: "src/index.ts",
opencode2: "src/opencode2.ts",
kilo: "src/kilo.ts",
"cline-cli": "src/cline.ts",
pi: "src/pi.ts",
"prime-agent": "src/prime-agent.ts",
dsh: "src/dsh.ts",
};
@@ -12,6 +12,7 @@ import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { pathToFileURL } from "node:url";
import { INSTALLERS, MARKER, parseJsonc, run, type InstallCtx } from "./installer";
import { SKILL_DIRS } from "./core/skill-dirs";
// Every test gets a FRESH temp dir as ctx.home (never the real $HOME) and a stubbed
// claudeMcp so the real `claude` CLI is never executed. run() is always called with
@@ -751,37 +752,153 @@ describe("opencode installer", () => {
});
});
describe("prime-agent installer", () => {
const cfgPath = (ctx: InstallCtx) => join(ctx.home, ".prime", "agent", "settings.json");
const entry = (ctx: InstallCtx) => join(ctx.pkgRoot, "dist", "prime-agent.js");
// pi and Prime Agent share one installer factory but must stay independently wired: each writes
// only its own settings.json, and each registers the bundle that reports its own harness.
describe.each([
{ harness: "pi", dir: [".pi", "agent"] },
{ harness: "prime-agent", dir: [".prime", "agent"] },
])("$harness installer", ({ harness, dir }) => {
const cfgPath = (ctx: InstallCtx) => join(ctx.home, ...dir, "settings.json");
const entry = (ctx: InstallCtx) => join(ctx.pkgRoot, "dist", `${harness}.js`);
it("install adds the built extension to the extensions array exactly once, even across reinstalls", () => {
const ctx = makeCtx();
expect(run(["install", "prime-agent"], ctx)).toBe(0);
run(["install", "prime-agent"], ctx);
expect(run(["install", harness], ctx)).toBe(0);
run(["install", harness], ctx);
expect(readJson(cfgPath(ctx)).extensions).toEqual([entry(ctx)]);
});
it("preserves other extension entries", () => {
const ctx = makeCtx();
writeJsonAt(cfgPath(ctx), { extensions: ["/some/other/ext.js"] });
run(["install", "prime-agent"], ctx);
run(["install", harness], ctx);
expect(readJson(cfgPath(ctx)).extensions).toEqual(["/some/other/ext.js", entry(ctx)]);
});
it("uninstall removes our entry and deletes the extensions key when empty", () => {
const ctx = makeCtx();
run(["install", "prime-agent"], ctx);
run(["uninstall", "prime-agent"], ctx);
run(["install", harness], ctx);
run(["uninstall", harness], ctx);
expect(readJson(cfgPath(ctx)).extensions).toBeUndefined();
});
it("uninstall keeps the extensions key when other entries remain", () => {
const ctx = makeCtx();
writeJsonAt(cfgPath(ctx), { extensions: ["/some/other/ext.js"] });
run(["install", harness], ctx);
run(["uninstall", harness], ctx);
expect(readJson(cfgPath(ctx)).extensions).toEqual(["/some/other/ext.js"]);
});
});
describe("pi-family companion skill", () => {
// Both hosts discover their own skills root AND the shared `~/.agents/skills`. We write the OWN
// directory: the shared root is where Codex and dsh install, and uninstallSkill removes by a
// fixed name, so installing there would make `uninstall pi` delete their copy too.
const HOSTS: [string, string[]][] = [
["pi", [".pi", "agent", "skills"]],
["prime-agent", [".prime", "agent", "skills"]],
];
/** makeCtx's pkgRoot is a synthetic /opt path that does not exist, so the packaged skill can't be
* staged in it. Build a real temp package root instead, like the cross-host skill test below. */
function ctxWithSkill(): InstallCtx {
const home = mkdtempSync(join(tmpdir(), "hs-inst-pi-skill-"));
homes.push(home);
const pkgRoot = mkdtempSync(join(tmpdir(), "hs-pkg-"));
homes.push(pkgRoot);
mkdirSync(join(pkgRoot, "skill"), { recursive: true });
writeFileSync(
join(pkgRoot, "skill", "SKILL.md"),
"---\nname: hindsight-coding-agent\n---\nbody"
);
return { home, pkgRoot, dist: join(pkgRoot, "dist"), claudeMcp: vi.fn(() => true) };
}
it.each(HOSTS)(
"%s installs the packaged skill into its own skills directory and removes it on uninstall",
(harness, dir) => {
const ctx = ctxWithSkill();
const base = join(ctx.home, ...dir);
run(["install", harness], ctx);
expect(existsSync(join(base, "hindsight-coding-agent", "SKILL.md"))).toBe(true);
run(["uninstall", harness], ctx);
expect(existsSync(join(base, "hindsight-coding-agent"))).toBe(false);
}
);
it.each(HOSTS)(
"uninstalling %s cannot strip Codex's copy from the shared ~/.agents/skills root",
(harness) => {
const ctx = ctxWithSkill();
run(["install", "codex"], ctx);
const shared = join(ctx.home, ".agents", "skills", "hindsight-coding-agent");
expect(existsSync(shared)).toBe(true);
run(["install", harness], ctx);
run(["uninstall", harness], ctx);
expect(existsSync(shared)).toBe(true);
}
);
// The two hosts write DIFFERENT roots, so one's uninstall must not touch the other's skill —
// the same independence the settings files already have.
it("installing both gives each its own copy, and uninstalling one leaves the other", () => {
const ctx = ctxWithSkill();
run(["install", "pi"], ctx);
run(["install", "prime-agent"], ctx);
const primeSkill = join(ctx.home, ".prime", "agent", "skills", "hindsight-coding-agent");
expect(existsSync(primeSkill)).toBe(true);
run(["uninstall", "pi"], ctx);
expect(existsSync(primeSkill)).toBe(true);
});
});
/**
* pi and Prime Agent both read `pkg.pi.extensions` when this package is installed as a distributed
* pi package, so that key can only ever name one bundle and the host it did not name loads the
* other's, reports the wrong harness, and stamps it on every document it retains. It named Prime
* Agent's until pi got an entry of its own, which is exactly how pi mis-attributed.
*
* `hindsight-coding-agents install pi|prime-agent` is the supported route for both, so the key is
* gone. Re-adding it would be silent: nothing in this package reads it, the wrong attribution only
* shows up later on retained documents, and it looks like the obvious way to support `pi install`.
*/
describe("the published manifest", () => {
it("carries no `pi` key, which could only ever be right for one of the two hosts", () => {
const manifest = JSON.parse(
readFileSync(new URL("../package.json", import.meta.url), "utf8")
) as Record<string, unknown>;
expect(manifest.pi).toBeUndefined();
// The single-host manifest keys are fine and stay: exactly one harness reads each.
expect(manifest.dsh).toBeDefined();
expect(manifest.cline).toBeDefined();
});
});
describe("pi and prime-agent do not disturb each other", () => {
const piCfg = (ctx: InstallCtx) => join(ctx.home, ".pi", "agent", "settings.json");
const primeCfg = (ctx: InstallCtx) => join(ctx.home, ".prime", "agent", "settings.json");
it("wires each host to its own bundle and leaves the other config untouched", () => {
const ctx = makeCtx();
run(["install", "pi"], ctx);
expect(existsSync(primeCfg(ctx))).toBe(false);
run(["install", "prime-agent"], ctx);
expect(readJson(piCfg(ctx)).extensions).toEqual([join(ctx.pkgRoot, "dist", "pi.js")]);
expect(readJson(primeCfg(ctx)).extensions).toEqual([
join(ctx.pkgRoot, "dist", "prime-agent.js"),
]);
});
it("uninstalling one leaves the other wired", () => {
const ctx = makeCtx();
run(["install", "pi"], ctx);
run(["install", "prime-agent"], ctx);
run(["uninstall", "prime-agent"], ctx);
expect(readJson(cfgPath(ctx)).extensions).toEqual(["/some/other/ext.js"]);
expect(readJson(piCfg(ctx)).extensions).toEqual([join(ctx.pkgRoot, "dist", "pi.js")]);
});
});
@@ -1047,6 +1164,7 @@ describe("run() CLI behavior", () => {
"opencode",
"opencode2",
"kilo",
"pi",
"prime-agent",
"claude-code",
"codex",
@@ -1082,10 +1200,18 @@ describe("MCP registrations name the calling harness", () => {
}
// These hosts have no MCP registration at all: they load our plugin/extension in-process
// (src/kilo.ts, src/dsh.ts, src/prime-agent.ts, dist/index.js for opencode, index.js ->
// dist/opencode2.js for opencode2), and that entry hands its own harness name straight to
// RuntimeCore.
const IN_PROCESS = new Set(["opencode", "opencode2", "kilo", "prime-agent", "dsh", "dcode"]);
// (src/kilo.ts, src/dsh.ts, src/pi.ts, src/prime-agent.ts, dist/index.js for opencode,
// index.js -> dist/opencode2.js for opencode2), and that entry hands its own harness name
// straight to RuntimeCore.
const IN_PROCESS = new Set([
"opencode",
"opencode2",
"kilo",
"pi",
"prime-agent",
"dsh",
"dcode",
]);
const MCP_HOSTS = INSTALLERS.map((i) => i.name).filter((n) => !IN_PROCESS.has(n));
it.each(MCP_HOSTS)("%s", (harness) => {
@@ -1657,3 +1783,73 @@ describe("server setup", () => {
expect(logs.join("\n")).toContain("rustup");
});
});
/**
* Companion-skill parity across every host that installs one (#3524 shape).
*
* The installer writes the skill and core/skill-sync.ts re-copies it on drift at every session
* start, so `npm update -g` upgrades the skill too. Those were two hand-maintained path lists, and
* the self-update one covered four of the ten hosts the six it missed kept whichever SKILL.md
* they were installed with, forever, with nothing failing. A per-host test cannot catch that: the
* host that is forgotten is by definition the one nobody wrote a test for.
*
* So drive the real thing over the WHOLE family: install each harness into a temp home, find where
* the skill actually landed, and require that the self-update refreshes that same copy.
*/
describe("every installed companion skill is kept current by the session-start self-update", () => {
const SKILL_NAME = "hindsight-coding-agent";
/** makeCtx's pkgRoot is a synthetic /opt path, so the packaged skill can't be staged in it.
* Build a real temp package root holding a SKILL.md the installer can copy. */
function ctxWithPackagedSkill(body: string): InstallCtx {
const ctx = makeCtx();
const pkgRoot = mkdtempSync(join(tmpdir(), "hs-pkg-skillsync-"));
homes.push(pkgRoot);
mkdirSync(join(pkgRoot, "skill"), { recursive: true });
writeFileSync(join(pkgRoot, "skill", "SKILL.md"), body);
return { ...ctx, pkgRoot, dist: join(pkgRoot, "dist") };
}
/** Every directory named `hindsight-coding-agent` under `root`, home-relative. */
function findSkillCopies(root: string, prefix: string[] = []): string[][] {
return readdirSync(root, { withFileTypes: true }).flatMap((entry) => {
if (!entry.isDirectory()) return [];
const rel = [...prefix, entry.name];
return entry.name === SKILL_NAME ? [rel] : findSkillCopies(join(root, entry.name), rel);
});
}
/** Hosts that install no skill at all, and why so a host that silently STOPS installing one
* fails here instead of passing as "nothing to check". opencode (both major versions) and its
* Kilo fork have no skills mechanism; Devin and Dcode read no user-level skills directory
* either. */
const NO_SKILL_MECHANISM = ["dcode", "devin-cli", "kilo", "opencode", "opencode2"];
it("lands in the mapped directory and is refreshed there, for every host that has one", async () => {
const { syncCompanionSkill } = await import("./core/skill-sync");
const withoutSkill: string[] = [];
for (const harness of INSTALLERS.map((i) => i.name)) {
const ctx = ctxWithPackagedSkill("packaged v1");
run(["install", harness], ctx);
const copies = findSkillCopies(ctx.home);
if (!copies.length) {
withoutSkill.push(harness);
continue;
}
// Where it landed must be the directory the shared map names, so the self-update looks there.
expect(
copies.map((parts) => parts.slice(0, -1)),
harness
).toEqual([SKILL_DIRS[harness]]);
// And the self-update must actually refresh THIS host's copy when the package moves on.
const installed = join(ctx.home, ...copies[0], "SKILL.md");
const srcDir = mkdtempSync(join(tmpdir(), "hs-pkg-newer-"));
homes.push(srcDir);
writeFileSync(join(srcDir, "SKILL.md"), "packaged v2");
syncCompanionSkill(harness, { home: ctx.home, srcDir });
expect(readFileSync(installed, "utf8"), harness).toBe("packaged v2");
}
expect(withoutSkill.sort()).toEqual(NO_SKILL_MECHANISM);
});
});
@@ -44,6 +44,7 @@ import { HOOK_HARNESSES, type HookHarnessName } from "./harness/hook-lifecycle";
import { importLocalHistory } from "./core/history";
import { detectLlm, hasRustToolchain, hasUvx, type LlmChoice } from "./core/daemon";
import { readLegacyEndpoint } from "./core/legacy";
import { SKILL_DIRS } from "./core/skill-dirs";
import { createInstallerUi, type SelectOption } from "./install-ui";
/**
@@ -238,21 +239,33 @@ function stripHarnessHooks(hooks: Record<string, any>, harness: HookHarnessName)
}
}
/** This host's skills directory, from the map core/skill-sync.ts also reads see SKILL_DIRS for
* why the two sides must not keep separate copies of these paths. */
function skillsBaseFor(c: InstallCtx, harness: string): string {
const parts = SKILL_DIRS[harness];
if (!parts) throw new Error(`${harness} installs a skill but names no directory in SKILL_DIRS`);
return join(c.home, ...parts);
}
/** Copy the packaged companion SKILL into a host's skills directory (idempotent overwrite).
* The log line carries the harness prefix like every adapter message: several adapters install
* the skill before their first own log, and an unprefixed line would render under the PREVIOUS
* harness's group in the CLI output. */
function installSkill(c: InstallCtx, harness: string, skillsBase: string): void {
function installSkill(c: InstallCtx, harness: string): void {
const src = join(c.pkgRoot, "skill");
if (!existsSync(join(src, "SKILL.md"))) return;
const skillsBase = skillsBaseFor(c, harness);
const dst = join(skillsBase, "hindsight-coding-agent");
mkdirSync(skillsBase, { recursive: true });
cpSync(src, dst, { recursive: true });
c.log?.(`${harness}: skill installed at ${dst}`);
}
function uninstallSkill(c: InstallCtx, skillsBase: string): void {
rmSync(join(skillsBase, "hindsight-coding-agent"), { recursive: true, force: true });
function uninstallSkill(c: InstallCtx, harness: string): void {
rmSync(join(skillsBaseFor(c, harness), "hindsight-coding-agent"), {
recursive: true,
force: true,
});
}
// ── per-harness adapters ────────────────────────────────────────────────────────
@@ -377,43 +390,61 @@ const opencode2: HarnessInstaller = {
};
/**
* Prime Agent (PrimeIntellect) a persistent plugin loaded as an extension. Register the built
* `dist/prime-agent.js` in the `extensions` array of `~/.prime/agent/settings.json`; Prime Agent
* loads that file's default export at session start. The entry path contains MARKER (the package is
* `hindsight-coding-agents`), so uninstall's MARKER filter removes exactly what install added.
* The pi-family extension hosts: pi and its fork Prime Agent (PrimeIntellect). Both load a
* persistent extension by absolute path from the `extensions` array of their own `settings.json`,
* calling that file's default export at session start they differ only in the config directory
* (`~/.pi/agent` vs `~/.prime/agent`), the executable name, and which dist bundle reports which
* harness. The entry path runs through the package root, which contains MARKER, so uninstall's
* MARKER filter removes exactly what install added and leaves every other extension alone.
*
* The skill goes to Prime Agent's OWN `~/.prime/agent/skills`, not the shared `~/.agents/skills`
* root it also reads: `uninstallSkill` removes a fixed directory name, so installing to the shared
* root would make `uninstall prime-agent` delete Codex's and dsh's copy too (#3772).
* These installs are the ONLY supported route for either host, and the package deliberately carries
* no `pi` key. Both hosts read that same `pkg.pi.extensions` when this package is installed as a
* distributed pi package, so it can only ever name one bundle and the host it did not name would
* load the other's, reporting the wrong harness, taking that harness's config section and stamping
* it on every document it retained. It used to name Prime Agent's, which is exactly how pi
* mis-attributed before it had an entry of its own. A key that is right for at most one of two
* hosts is worse than none, so there is none; installer.test.ts holds that line.
*
* The skills directory (SKILL_DIRS) is per-host rather than shared: both read `~/.agents/skills`
* too, but that is the root Codex and dsh install into, and uninstallSkill removes by a fixed
* directory name so putting ours there would make uninstalling one host delete the other hosts'
* copy. Each writes its OWN skills directory instead.
*/
const primeAgent: HarnessInstaller = {
name: "prime-agent",
detect: (c) => onPath("prime-agent") || existsSync(join(c.home, ".prime", "agent")),
install(c) {
const path = join(c.home, ".prime", "agent", "settings.json");
const cfg = readJson(path);
const entry = join(c.pkgRoot, "dist", "prime-agent.js");
const exts: string[] = Array.isArray(cfg.extensions) ? cfg.extensions : [];
cfg.extensions = [...exts.filter((p) => !String(p).includes(MARKER)), entry];
writeJson(path, cfg);
installSkill(c, "prime-agent", join(c.home, ".prime", "agent", "skills"));
c.log?.(`prime-agent: extension registered in ${path}`);
},
uninstall(c) {
const path = join(c.home, ".prime", "agent", "settings.json");
if (existsSync(path)) {
function piFamilyInstaller(harness: string, configDir: string[]): HarnessInstaller {
const settings = (c: InstallCtx) => join(c.home, ...configDir, "settings.json");
return {
name: harness,
// Both hosts name their executable exactly as we name the harness, so the harness id doubles
// as the PATH probe here — unlike, say, antigravity-cli, whose binary is `agy`.
detect: (c) => onPath(harness) || existsSync(join(c.home, ...configDir)),
install(c) {
const path = settings(c);
const cfg = readJson(path);
const entry = join(c.pkgRoot, "dist", `${harness}.js`);
const exts: string[] = Array.isArray(cfg.extensions) ? cfg.extensions : [];
cfg.extensions = [...exts.filter((p) => !String(p).includes(MARKER)), entry];
writeJson(path, cfg);
installSkill(c, harness);
c.log?.(`${harness}: extension registered in ${path}`);
},
uninstall(c) {
// Before the settings guard on purpose: a hand-deleted settings.json must not strand the skill.
uninstallSkill(c, harness);
const path = settings(c);
if (!existsSync(path)) return;
const cfg = readJson(path);
if (Array.isArray(cfg.extensions)) {
cfg.extensions = cfg.extensions.filter((p: string) => !String(p).includes(MARKER));
if (!cfg.extensions.length) delete cfg.extensions;
writeJson(path, cfg);
}
}
// Outside the settings guard on purpose: a hand-deleted settings.json must not strand the skill.
uninstallSkill(c, join(c.home, ".prime", "agent", "skills"));
c.log?.("prime-agent: extension entry + skill removed");
},
};
c.log?.(`${harness}: extension entry + skill removed`);
},
};
}
const pi = piFamilyInstaller("pi", [".pi", "agent"]);
const primeAgent = piFamilyInstaller("prime-agent", [".prime", "agent"]);
/**
* Kilo Code CLI an opencode fork, so registration is opencode's: append our entry to the config's
@@ -485,7 +516,7 @@ const claudeCode: HarnessInstaller = {
c.log?.(`claude-code: hooks merged into ${path}`);
// Companion SKILL: every skills-capable host gets it (claude/antigravity/cursor native dirs;
// codex via the ~/.agents/skills standard).
installSkill(c, "claude-code", join(c.home, ".claude", "skills"));
installSkill(c, "claude-code");
const mcp = c.claudeMcp ?? defaultClaudeMcp;
// `claude mcp add` REFUSES when the name is taken ("MCP server hindsight already exists in
// user config") — so on a machine that already had Hindsight, a re-install could never
@@ -528,7 +559,7 @@ const claudeCode: HarnessInstaller = {
}
const mcp = c.claudeMcp ?? defaultClaudeMcp;
mcp(["mcp", "remove", "--scope", "user", "hindsight"]);
uninstallSkill(c, join(c.home, ".claude", "skills"));
uninstallSkill(c, "claude-code");
c.log?.("claude-code: hooks + MCP registration + skill removed");
},
};
@@ -593,7 +624,7 @@ const codex: HarnessInstaller = {
writeFileSync(tomlPath, next);
c.log?.(`codex: wrote ${additions.length} section(s) to ${tomlPath}`);
}
installSkill(c, "codex", join(c.home, ".agents", "skills")); // agentskills-standard shared dir
installSkill(c, "codex"); // agentskills-standard shared dir
},
uninstall(c) {
const hooksPath = join(c.home, ".codex", "hooks.json");
@@ -604,7 +635,7 @@ const codex: HarnessInstaller = {
writeJson(hooksPath, cfg);
}
}
uninstallSkill(c, join(c.home, ".agents", "skills"));
uninstallSkill(c, "codex");
const tomlPath = join(c.home, ".codex", "config.toml");
if (existsSync(tomlPath)) {
const toml = readFileSync(tomlPath, "utf8");
@@ -674,7 +705,7 @@ const antigravity: HarnessInstaller = {
);
}
c.log?.(`antigravity-cli: hooks merged into ${hooksPath}, MCP into ${mcpPath}`);
installSkill(c, "antigravity-cli", join(c.home, ".gemini", "config", "skills"));
installSkill(c, "antigravity-cli");
},
uninstall(c) {
const hooksPath = join(c.home, ".gemini", "config", "hooks.json");
@@ -706,7 +737,7 @@ const antigravity: HarnessInstaller = {
writeJson(settingsPath, settings);
}
}
uninstallSkill(c, join(c.home, ".gemini", "config", "skills"));
uninstallSkill(c, "antigravity-cli");
c.log?.("antigravity-cli: hooks + MCP entry + status line + skill removed");
},
};
@@ -1120,7 +1151,7 @@ const cursor: HarnessInstaller = {
};
writeJson(mcpPath, mcp);
c.log?.(`cursor-cli: hooks merged into ${hooksPath}, MCP into ${mcpPath}`);
installSkill(c, "cursor-cli", join(c.home, ".cursor", "skills"));
installSkill(c, "cursor-cli");
},
uninstall(c) {
const hooksPath = join(c.home, ".cursor", "hooks.json");
@@ -1140,7 +1171,7 @@ const cursor: HarnessInstaller = {
writeJson(mcpPath, mcp);
}
}
uninstallSkill(c, join(c.home, ".cursor", "skills"));
uninstallSkill(c, "cursor-cli");
c.log?.("cursor-cli: hooks + MCP entry + skill removed");
},
};
@@ -1161,7 +1192,7 @@ const copilot: HarnessInstaller = {
hindsight: mcpServerEntry(c.dist, "copilot-cli"),
};
writeJson(mcpPath, mcp);
installSkill(c, "copilot-cli", join(c.home, ".copilot", "skills"));
installSkill(c, "copilot-cli");
c.log?.(`copilot-cli: hooks installed at ${hooksPath}, MCP into ${mcpPath}`);
},
uninstall(c) {
@@ -1176,7 +1207,7 @@ const copilot: HarnessInstaller = {
writeJson(mcpPath, mcp);
}
}
uninstallSkill(c, join(c.home, ".copilot", "skills"));
uninstallSkill(c, "copilot-cli");
c.log?.("copilot-cli: hooks + MCP entry + skill removed");
},
};
@@ -1211,7 +1242,7 @@ const grok: HarnessInstaller = {
copyFileSync(path, `${path}.hindsight-backup`);
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, `${withoutOurs.replace(/\n*$/, "\n")}${block}`);
installSkill(c, "grok-build", join(c.home, ".grok", "skills"));
installSkill(c, "grok-build");
c.log?.(`grok-build: native hooks + MCP installed in ${path}`);
},
uninstall(c) {
@@ -1221,7 +1252,7 @@ const grok: HarnessInstaller = {
const cleaned = existing.replace(GROK_BLOCK_RE, "\n");
if (cleaned !== existing) writeFileSync(path, cleaned);
}
uninstallSkill(c, join(c.home, ".grok", "skills"));
uninstallSkill(c, "grok-build");
c.log?.("grok-build: native hooks + MCP + skill removed");
},
};
@@ -1260,7 +1291,7 @@ const cline: HarnessInstaller = {
hindsight: mcpServerEntry(c.dist, "cline-cli"),
};
writeJson(mcpPath, mcp);
installSkill(c, "cline-cli", join(c.home, ".cline", "data", "settings", "skills"));
installSkill(c, "cline-cli");
c.log?.(
installed
? "cline-cli: native plugin + MCP + skill installed"
@@ -1280,7 +1311,7 @@ const cline: HarnessInstaller = {
writeJson(mcpPath, mcp);
}
}
uninstallSkill(c, join(c.home, ".cline", "data", "settings", "skills"));
uninstallSkill(c, "cline-cli");
c.log?.("cline-cli: native plugin + MCP + skill removed");
},
};
@@ -1448,7 +1479,7 @@ const dsh: HarnessInstaller = {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, others ? `${others}\n\n${block}` : block);
// dsh's skill provider scans the shared agentskills root, the same one Codex reads.
installSkill(c, "dsh", join(c.home, ".agents", "skills"));
installSkill(c, "dsh");
c.log?.(`dsh: plugin registered in ${path} (applies to every dsh profile)`);
},
uninstall(c) {
@@ -1460,7 +1491,7 @@ const dsh: HarnessInstaller = {
// BOOT on anything else, so removing the last block must leave an empty list behind.
if (others !== existing.trim()) writeFileSync(path, others ? `${others}\n` : "[]\n");
}
uninstallSkill(c, join(c.home, ".agents", "skills"));
uninstallSkill(c, "dsh");
c.log?.("dsh: plugin entry + skill removed");
},
};
@@ -1477,7 +1508,7 @@ const qwen: HarnessInstaller = {
mergeHarnessHooks(settings.hooks, "qwen-code", c.dist);
writeJson(path, settings);
c.log?.(`qwen-code: hooks merged into ${path}`);
installSkill(c, "qwen-code", join(c.home, ".qwen", "skills"));
installSkill(c, "qwen-code");
const mcp = c.qwenMcp ?? defaultQwenMcp;
// Same rationale as claude-code: `qwen mcp add` refuses a name that already exists, so a
// re-install could never repoint a stale server. Remove first (a no-op when absent).
@@ -1515,7 +1546,7 @@ const qwen: HarnessInstaller = {
}
const mcp = c.qwenMcp ?? defaultQwenMcp;
mcp(["mcp", "remove", "hindsight"]);
uninstallSkill(c, join(c.home, ".qwen", "skills"));
uninstallSkill(c, "qwen-code");
c.log?.("qwen-code: hooks + MCP registration + skill removed");
},
};
@@ -1533,6 +1564,7 @@ export const INSTALLERS: HarnessInstaller[] = [
opencode,
opencode2,
kilo,
pi,
primeAgent,
claudeCode,
codex,
@@ -0,0 +1,21 @@
/**
* pi's extension entrypoint (`pi`, npm `@earendil-works/pi-coding-agent`).
*
* pi loads extensions listed in the `extensions` array of `~/.pi/agent/settings.json` and calls each
* one's default export with its `pi` API. The whole adapter lives in src/harness/pi-extension.ts,
* shared with Prime Agent pi's fork which registers the same default-export shape from its own
* settings file.
*
* pi has no hooks system, which is why it is a persistent-extension harness rather than a
* hook-binary harness like claude-code/cursor/codex.
*
* This entry exists rather than pointing pi at dist/prime-agent.js purely so the harness reports
* as "pi": that selects the `harnesses.pi` config section, feeds `{harness}` bank templating, and
* keeps pi sessions attributable in diagnostics separately from Prime Agent's.
*/
import { createPiExtension, type ExtensionFactory } from "./harness/pi-extension";
const extension: ExtensionFactory = createPiExtension("pi");
export { extension };
export default extension;
@@ -1,174 +1,21 @@
/**
* Prime Agent's extension entrypoint.
* Prime Agent's extension entrypoint (`prime-agent`, npm `prime-agent`).
*
* Prime Agent (PrimeIntellect) loads distributed packages listed in `~/.prime/agent/settings.json`
* and calls each extension's default export with its `pi` API. This wires Prime Agent's
* `before_agent_start` (recall + system-prompt injection) and `agent_end` (transcript write-back)
* onto the shared RuntimeCore the same reflect-and-inject core every Hindsight harness uses and
* registers the `hindsight_*` knowledge tools natively via `pi.registerTool`. The memory behaviour
* itself stays in RuntimeCore; this file only adapts Prime Agent's API at its boundary.
* Prime Agent (PrimeIntellect) is a FORK OF PI: same extension API, same `extensions` array in
* `settings.json` (under `~/.prime/agent/` rather than `~/.pi/agent/`), same `before_agent_start` /
* `agent_end` events and `registerTool`. So it needs no adapter of its own and drives the exact pi
* runtime see src/harness/pi-extension.ts.
*
* Prime Agent has no hooks system, which is why it is a persistent-extension harness rather than a
* hook-binary harness like claude-code/cursor/codex.
*
* This entry exists rather than pointing Prime Agent at dist/pi.js purely so the harness reports
* as "prime-agent": that selects the `harnesses.prime-agent` config section, feeds `{harness}` bank
* templating, and keeps Prime Agent sessions attributable in diagnostics separately from pi's.
*/
import { z } from "zod";
import { resolveHostMemory } from "./core/host-client";
import { diag } from "./core/diag";
import type { ToolSpec } from "./core/knowledge-tools";
import { RuntimeCore } from "./core/runtime";
import { type PaMessage, readPrimeAgentMessages } from "./core/transcript-prime-agent";
import { createPiExtension, type ExtensionFactory } from "./harness/pi-extension";
const HARNESS = "prime-agent";
// ── Structural subset of Prime Agent's extension API (@earendil-works/pi-coding-agent) ──────────
// Declared locally so this package takes no dependency on the fast-moving Prime Agent SDK; the real
// runtime passes a compatible object at load time.
interface BeforeAgentStartEvent {
type: "before_agent_start";
/** The raw user prompt text. */
prompt: string;
/** The fully assembled system prompt for this turn. */
systemPrompt: string;
}
interface AgentEndEvent {
type: "agent_end";
/** The conversation messages for the completed agent loop. */
messages: readonly PaMessage[];
}
interface BeforeAgentStartResult {
systemPrompt?: string;
}
interface SessionManagerLike {
getSessionId(): string;
}
interface ExtensionContext {
hasUI: boolean;
ui: { notify(message: string, type?: "info" | "warning" | "error"): void };
sessionManager: SessionManagerLike;
}
/** A JSON-Schema-shaped parameters object. Prime Agent forwards it to the model provider verbatim. */
type JsonSchema = Record<string, unknown>;
interface ToolDefinition {
name: string;
label: string;
description: string;
parameters: JsonSchema;
execute(
toolCallId: string,
params: Record<string, unknown>
): Promise<{ content: { type: "text"; text: string }[]; details: unknown }>;
}
interface ExtensionAPI {
on(
event: "before_agent_start",
handler: (
event: BeforeAgentStartEvent,
ctx: ExtensionContext
) => Promise<BeforeAgentStartResult | void> | BeforeAgentStartResult | void
): void;
on(
event: "agent_end",
handler: (event: AgentEndEvent, ctx: ExtensionContext) => Promise<void> | void
): void;
registerTool(definition: ToolDefinition): void;
}
export type ExtensionFactory = (pi: ExtensionAPI) => void;
/**
* Adapt a harness-agnostic ToolSpec (MCP-shaped, shared by every harness) to a Prime Agent native
* tool. The spec's Zod raw shape is converted to a JSON Schema for `parameters` Prime Agent passes
* that straight to the model provider (it does not run TypeBox validation on tool args), so a plain
* JSON Schema is exactly what the tool needs. The spec's handler returns an MCP `{content:[{text}]}`
* result and never throws, so we surface the joined text back to the model.
*/
export function toPrimeAgentTool(spec: ToolSpec): ToolDefinition {
const parameters = z.toJSONSchema(z.object(spec.inputSchema)) as JsonSchema;
return {
name: spec.name,
label: spec.name,
description: spec.description,
parameters,
async execute(_toolCallId: string, params: Record<string, unknown>) {
const r = await spec.handler(params);
const text = r.content?.map((c) => c.text).join("\n") || "";
return { content: [{ type: "text", text }], details: null };
},
};
}
/**
* Make the host-specific Prime Agent hooks testable without importing Prime Agent's SDK. RuntimeCore
* is the shared lifecycle implementation; this adapter only converts Prime Agent messages at its
* boundary and never calls Hindsight directly.
*/
export function createPrimeAgentHooks(
core: Pick<RuntimeCore, "onPrompt" | "getInjection" | "onTranscript">,
sessionStart?: Promise<void>
) {
let sessionStartAwaited = false;
return {
async beforeAgentStart(
event: { prompt: string; systemPrompt: string },
sessionId: string
): Promise<BeforeAgentStartResult | undefined> {
if (!sessionStartAwaited) {
sessionStartAwaited = true;
// Awaiting the shared SessionStart lifecycle before the first prompt preserves the invariant
// that a brand-new bank skips its first auto-reflect instead of spending that synthesis
// before it has any knowledge (mirrors the other harnesses).
await sessionStart;
}
const prompt = event.prompt.trim();
if (prompt) await core.onPrompt(sessionId, prompt);
const injection = core.getInjection(sessionId);
if (!injection) {
diag(HARNESS, "inject_empty", { session: sessionId });
return undefined;
}
diag(HARNESS, "inject_ok", { session: sessionId, chars: injection.length });
return { systemPrompt: `${event.systemPrompt}\n\n${injection}` };
},
async agentEnd(event: { messages: readonly PaMessage[] }, sessionId: string): Promise<void> {
const turns = readPrimeAgentMessages(event.messages);
if (turns.length) await core.onTranscript(sessionId, turns);
},
};
}
function createRuntime(repoPath: string): RuntimeCore | undefined {
const { cfg, bankId, client } = resolveHostMemory(HARNESS, repoPath);
if (cfg.disabled) return undefined; // global switch, per-bank opt-out or optInOnly
return new RuntimeCore(client, bankId, cfg, HARNESS, repoPath);
}
/**
* Prime Agent extension factory. Loaded once per session in the project directory; resolves config
* and bank from `process.cwd()`, registers the knowledge tools, kicks off the shared cold-seed, and
* wires the recall/retain hooks.
*/
const extension: ExtensionFactory = (pi) => {
const repoPath = process.cwd();
const core = createRuntime(repoPath);
if (!core) return;
for (const spec of core.toolSpecs()) pi.registerTool(toPrimeAgentTool(spec));
// Fire-and-forget cold seed (bank check + background git seed + knowledge preamble); the first
// before_agent_start awaits it via createPrimeAgentHooks.
const sessionStart = core.seedIfCold(repoPath);
const hooks = createPrimeAgentHooks(core, sessionStart);
pi.on("before_agent_start", (event, ctx) =>
hooks.beforeAgentStart(event, ctx.sessionManager.getSessionId())
);
pi.on("agent_end", (event, ctx) => hooks.agentEnd(event, ctx.sessionManager.getSessionId()));
};
const extension: ExtensionFactory = createPiExtension("prime-agent");
export { extension };
export default extension;
@@ -25,8 +25,11 @@ export default defineConfig({
cline: "src/cline.ts",
// DeepSeek Harness loads this as a native Cordis plugin — see src/dsh.ts.
dsh: "src/dsh.ts",
// Prime Agent loads this module as an extension (default export) by absolute path from its
// settings.json `extensions` array, so it must be self-contained like the hook bins.
// pi and its fork Prime Agent each load their own module as an extension (default export) by
// absolute path from their settings.json `extensions` array, so both must be self-contained
// like the hook bins. They share src/harness/pi-extension.ts, which `splitting: false` inlines
// into each bundle rather than a shared chunk.
pi: "src/pi.ts",
"prime-agent": "src/prime-agent.ts",
"qwen-hook": "src/qwen-hook.ts",
"qwen-sessionstart-hook": "src/qwen-sessionstart-hook.ts",
@@ -4,7 +4,7 @@
Long-term project memory for **coding agents**, backed by [Hindsight](https://vectorize.io/hindsight).
One package, several agents: a shared reflect-and-inject core with a thin entry point per agent
(**opencode**, **opencode 2**, **Kilo CLI**, **Cline CLI**, **Prime Agent**, **DeepSeek Harness**, **Claude Code**, **Codex CLI**, **DeepAgents Dcode**, **Antigravity CLI**, **Cursor CLI**, **GitHub Copilot CLI**, **Devin CLI**, **Grok Build**). Ingestion is fully
(**opencode**, **opencode 2**, **Kilo CLI**, **Cline CLI**, **pi**, **Prime Agent**, **DeepSeek Harness**, **Claude Code**, **Codex CLI**, **DeepAgents Dcode**, **Antigravity CLI**, **Cursor CLI**, **GitHub Copilot CLI**, **Devin CLI**, **Grok Build**). Ingestion is fully
automatic — there is no setup command: a repo's git history and conversations flow into its memory
bank in the background as you work.
@@ -174,14 +174,35 @@ npx @vectorize-io/hindsight-coding-agents install cline-cli
A native plugin via `cline plugin install`, plus MCP and the companion skill.
#### pi
```bash
npx @vectorize-io/hindsight-coding-agents install pi
```
An extension entry in `~/.pi/agent/settings.json`, plus the companion skill in
`~/.pi/agent/skills` — native tools, no MCP needed.
This command is the only supported route, for pi and for Prime Agent below. Installing us as a pi
package (`pi install npm:@vectorize-io/hindsight-coding-agents`) is deliberately not wired: both
hosts read the same `pi` key of a package's `package.json`, and that key can only name one entry —
whichever host it did not name would load the other's bundle and report itself as the wrong agent,
taking that harness's config section and stamping every document it retains with it. So the package
carries no `pi` key at all, and each host is pointed at its own bundle by the install command above.
#### Prime Agent
```bash
npx @vectorize-io/hindsight-coding-agents install prime-agent
```
An extension entry in `~/.prime/agent/settings.json` — native tools, no MCP needed — plus the
companion skill in `~/.prime/agent/skills`.
Prime Agent is a fork of pi, so it is wired the same way: an extension entry, here in
`~/.prime/agent/settings.json`, plus the companion skill in `~/.prime/agent/skills` — native tools,
no MCP needed. Installing both is fine and expected:
each host loads its own entry from its own settings file, and like every other pair of agents they
**share one bank per repo** (the default `coding-agent::{gitProject}`), so what you tell pi is there
when you open Prime Agent. Separate entries are what keeps each side attributable — its own
`harnesses.<name>` config section, and its own agent stamped on every document it retains.
#### DeepSeek Harness
@@ -269,7 +290,8 @@ transcripts are needed either way; going through them directly is simply the sho
**How sessions are matched.** A conversation is imported only when the session itself records the
directory it ran in — never inferred from a file or folder name. Claude Code writes that directory
on its entries, Codex in its `session_meta` header and DeepSeek Harness in its session-log header,
so all three can be attributed exactly, including sessions started in a subdirectory of the repo. Guessing was tempting (Claude names its history
and pi and Prime Agent in their session header, so all five can be attributed exactly, including
sessions started in a subdirectory of the repo. Guessing was tempting (Claude names its history
folders after the project path) but unsafe: `/` and `.` both encode to `-`, so `repo-sub` is either
the subdirectory `repo/sub` or an unrelated sibling repo — and a wrong guess files someone else's
conversation into your bank. Sessions that record nothing are skipped and the count is reported.
@@ -379,7 +401,7 @@ what reads it:
| host | reads the file | an edit applies |
| ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | -------------------------- |
| hook harnesses (Claude Code, Codex CLI, Cursor CLI, GitHub Copilot CLI, Grok Build, Antigravity CLI, Devin) | once per hook invocation — each hook is its own short-lived process | on your next prompt |
| persistent plugins (opencode, opencode 2, Kilo CLI, Cline CLI, Prime Agent, DeepSeek Harness) | once per workspace, when the host loads the plugin | after restarting the agent |
| persistent plugins (opencode, opencode 2, Kilo CLI, Cline CLI, pi, Prime Agent, DeepSeek Harness) | once per workspace, when the host loads the plugin | after restarting the agent |
| the MCP server behind the `hindsight_*` tools | once at startup | in your next session |
`apiToken` is the exception. Every host re-reads it when the server rejects a request, so enabling