Skip to content
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ npm run dev -- status --short
- Rule/check metadata changes require `npm run docs`; do not hand-edit stale generated counts.
- Major changes in this repository MUST include a skill-impact review. This includes user-visible CLI commands/options/output, harness behavior or policy, configuration schemas/defaults, supported-client behavior, baseline semantics, and architecture or operator workflows. Update every affected `skills/*/SKILL.md` in the same change; code or design-doc updates alone are not complete when agent guidance changed. If no skill is affected, state why in the final handoff.
- Keep the skill router and focused skills aligned: update `skills/interlinked/SKILL.md` when a capability's routing changes, and update the relevant focused skill with the current command/config contract and operational gotchas. Treat `.agents/skills/` as an installed/generated copy, not the source of truth. Validate every touched skill folder with the skill validator before handoff.
- Supported coding clients (`interlinked enable --clients <list>`): `claude`, `copilot`, `gemini`, `codex`, `cursor`, `opencode`, `pi`. Native hook contracts live in `src/harness/adapters/`; legacy/per-client migration helpers live in `src/lib/hook-installers*.ts`; `src/lib/hooks.ts` is the orchestrator that wires them through `CLIENT_INSTALL_REGISTRY`. The generated `.mjs` script disambiguates Codex (Claude-shaped payloads) from Claude via the `INTERLINKED_CLIENT` env var set by the installed hook command. OpenCode and Pi use managed bridge files rather than the generated `.mjs` runtime.
- Supported coding clients (`interlinked enable --clients <list>`): `claude`, `copilot`, `gemini`, `codex`, `cursor`, `opencode`, `opencode2`, `pi`. Native hook contracts live in `src/harness/adapters/`; legacy/per-client migration helpers live in `src/lib/hook-installers*.ts`; `src/lib/hooks.ts` is the orchestrator that wires them through `CLIENT_INSTALL_REGISTRY`. The generated `.mjs` script disambiguates Codex (Claude-shaped payloads) from Claude via the `INTERLINKED_CLIENT` env var set by the installed hook command. OpenCode v1 and Pi use managed bridge files rather than the generated `.mjs` runtime. OpenCode v1 installs `.opencode/plugins/interlinked.ts`; OpenCode v2 (`opencode2`) installs `.opencode/plugins/interlinked-opencode2.ts`. Both can be enabled in the same repo: distinct filenames and plugin ids, and each plugin no-ops in the other binary.
- Codex CLI hook gating: `installCodexHooks` writes `.codex/hooks.json` AND ensures `[features] hooks = true` in `.codex/config.toml`. Without that flag Codex silently ignores the hooks.json file. Uninstall leaves config.toml untouched to avoid clobbering user-managed Codex configuration.

## Coding Style & Naming Conventions
Expand Down
5 changes: 3 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,15 +168,16 @@ a common shape:
| Copilot CLI (experimental) | 6 events | `.github/hooks/hooks.json` |
| Gemini CLI (experimental) | 9 events | `.gemini/settings.json` hooks |
| OpenCode (experimental) | 11 installed plugin callbacks; stable permission/Stop signals are observation- or deny-only | managed `.opencode/plugins/interlinked.ts` |
| OpenCode v2 (`opencode2`, experimental) | 5 plugin hooks (`tool.execute.before/after`, `session.created/deleted/idle` via `event.subscribe`); ask collapses to deny | managed `.opencode/plugins/interlinked-opencode2.ts` |
| Pi (experimental) | 13 extension callbacks, including `tool_call`, `tool_result`, and direct `user_bash` | managed `.pi/extensions/interlinked.js` |

Runtime reporting derives from the adapters' `nativeEventNames` lists
(`installedEventsFor`); the dated table above and the parity regression tests intentionally
pin expected SNAPSHOTS of those lists so drift is visible, and they carry their date for the
same reason. Claude Code and Codex are the supported provider-contract pair. Copilot,
Gemini, and Cursor remain experimental because their end-to-end provider contracts are not
proven. OpenCode and Pi have managed-bridge execution tests, but also remain experimental:
their upstream extension APIs expose a narrower native control surface than Claude/Codex.
proven. OpenCode, OpenCode v2, and Pi have managed-bridge execution tests, but also remain experimental:
their upstream extension APIs expose a narrower native control surface than Claude/Codex. OpenCode v2 stays experimental until a real-host acceptance test passes.

All adapters share the capability and envelope layer described in
[`docs/design/cli-hook-normalization.md`](design/cli-hook-normalization.md). Unknown
Expand Down
20 changes: 10 additions & 10 deletions skills/interlinked-setup/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,9 @@ prompts on genuinely NEW packages.
> block is a message the agent must guess at, and the likeliest guess is to work around the gate.

Key `enable` flags: `--server <url>` · `--agent <name>` · `--clients <list>`
(`claude,copilot,gemini,codex,cursor,opencode,pi`) · `--sync-mode <realtime|local|manual>` ·
(`claude,copilot,gemini,codex,cursor,opencode,opencode2,pi`) · `--sync-mode <realtime|local|manual>` ·
`--data-dir <path>` · `--structure <mode>` · `--dry-run`.
`install-hooks` uses different vocabulary: `--runner <claude-code,copilot-cli,cursor,gemini-cli,codex,opencode,pi>`
`install-hooks` uses different vocabulary: `--runner <claude-code,copilot-cli,cursor,gemini-cli,codex,opencode,opencode2,pi>`
· `--scope <user|project|local>` · `--mode <balanced|strict|lenient>`.
Explicit values are strict: an unknown client, sync mode, structure mode, install scope, or
enforcement mode exits nonzero before config, hook, manifest, stand-down, binary-fallback,
Expand Down Expand Up @@ -468,14 +468,14 @@ interlinked logout [--all]
- **Gemini is a compatibility lane, not the Antigravity adapter.** Consumer Gemini CLI service
ended in June 2026, while enterprise and paid API-key Gemini CLI use remain supported. The
current `gemini` client installs Gemini CLI hooks/skills; do not treat it as Antigravity.
- **OpenCode and Pi installs are managed source bridges.** Project scope writes
`.opencode/plugins/interlinked.ts` or `.pi/extensions/interlinked.js` as an Interlinked-owned
whole file; user scope uses `~/.config/opencode/plugins/interlinked.ts` and
`~/.pi/agent/extensions/interlinked.js`. Install refuses to overwrite a foreign file at that
path, and uninstall preserves a bridge whose bytes changed after install. A loaded bridge writes
its provider row to `.interlinked/hook-runtime.json`, but only Codex currently has a dedicated
doctor trust-verification row. Restart OpenCode after install (its plugin trust is implicit). In
Pi, run `/reload` or restart and approve the project-extension trust prompt.
- **OpenCode and Pi installs are managed source bridges.** OpenCode v1 writes
`.opencode/plugins/interlinked.ts` (user: `~/.config/opencode/plugins/interlinked.ts`).
OpenCode v2 (`opencode2`, experimental) writes `.opencode/plugins/interlinked-opencode2.ts` (user:
`~/.config/opencode/plugins/interlinked-opencode2.ts`, or `$XDG_CONFIG_HOME/opencode/plugins/` when set). Distinct filenames and plugin ids, so both
can be enabled in one repo with `--clients opencode,opencode2`; bare enable does not install v2 from `.opencode/` alone. Each plugin no-ops in the other binary. Pi writes
`.pi/extensions/interlinked.js`. Install refuses to overwrite a foreign file at that
path, and uninstall preserves a bridge whose bytes changed after install. Restart OpenCode
after install. In Pi, run `/reload` or restart and approve the project-extension trust prompt.
- **Their native parity is intentionally bounded.** OpenCode's stable tool-before hook cannot
open confirmation, so `ask` denies; permission bus and `session.idle`/Stop are observation-only.
Pi prompts through `ctx.ui.confirm` when interactive and denies headless; `user_bash` gates
Expand Down
1 change: 1 addition & 0 deletions src/commands/disable.mutation-kill-luna.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const CLIENTS: ClientName[] = [
"codex",
"cursor",
"opencode",
"opencode2",
"pi",
];

Expand Down
1 change: 1 addition & 0 deletions src/commands/disable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ const ALL_CLIENTS: ClientName[] = [
"codex",
"cursor",
"opencode",
"opencode2",
"pi",
];

Expand Down
1 change: 1 addition & 0 deletions src/commands/disable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ const ALL_CLIENTS: ClientName[] = [
"codex",
"cursor",
"opencode",
"opencode2",
"pi",
];

Expand Down
4 changes: 2 additions & 2 deletions src/commands/enable.mutation-kill-w44.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,7 @@ describe("noteUndetectedClients", () => {
await enableCommand({});

const out = logged(logSpy);
expect(out).toContain("Not detected: copilot, gemini, codex, cursor, opencode, pi");
expect(out).toContain("Not detected: copilot, gemini, codex, cursor, opencode, opencode2, pi");
});
});

Expand All @@ -386,7 +386,7 @@ describe("printSummary", () => {
await enableCommand({});

expect(logged(logSpy)).toContain(
"--clients claude,copilot,gemini,codex,cursor,opencode,pi",
"--clients claude,copilot,gemini,codex,cursor,opencode,opencode2,pi",
);
});
});
Expand Down
14 changes: 10 additions & 4 deletions src/commands/enable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ vi.mock("../lib/settings.js", () => ({
codex: "codex",
cursor: "cursor",
opencode: "opencode",
opencode2: "opencode2",
pi: "pi",
},
}));
Expand Down Expand Up @@ -168,6 +169,7 @@ function eventCount(client: ClientName): number {
codex: "codex",
cursor: "cursor",
opencode: "opencode",
opencode2: "opencode2",
pi: "pi",
} as const;
const adapter = getAdapter(runner[client]);
Expand Down Expand Up @@ -283,7 +285,7 @@ describe("enableCommand — dry run", () => {
it("renders every requested client's event summary via --clients", async () => {
vi.mocked(detectClients).mockReturnValue([]);

await enableCommand({ dryRun: true, clients: "copilot,gemini,codex,cursor,opencode,pi" });
await enableCommand({ dryRun: true, clients: "copilot,gemini,codex,cursor,opencode,opencode2,pi" });

const out = logged(logSpy);
expect(out).toContain(`copilot — ${eventCount("copilot")} events (.github/hooks/hooks.json) (forced)`);
Expand All @@ -293,6 +295,9 @@ describe("enableCommand — dry run", () => {
expect(out).toContain(
`opencode — ${eventCount("opencode")} events (.opencode/plugins/interlinked.ts)`,
);
expect(out).toContain(
`opencode2 — ${eventCount("opencode2")} events (.opencode/plugins/interlinked-opencode2.ts)`,
);
expect(out).toContain(
`pi — ${eventCount("pi")} events (.pi/extensions/interlinked.js)`,
);
Expand All @@ -319,7 +324,7 @@ describe("enableCommand — explicit client validation", () => {

expect(logged(errorSpy)).toContain("Unknown client: bogus");
expect(logged(errorSpy)).toContain(
"Supported clients: claude,copilot,gemini,codex,cursor,opencode,pi",
"Supported clients: claude,copilot,gemini,codex,cursor,opencode,opencode2,pi",
);
expect(process.exitCode).toBe(1);
expect(vi.mocked(isConfigured)).not.toHaveBeenCalled();
Expand Down Expand Up @@ -608,7 +613,7 @@ describe("enableCommand — hook managers + install results", () => {
const out = logged(logSpy);
expect(out).toContain("Warning: No hooks were installed.");
expect(out).toContain("No client directories");
expect(out).toContain("Use --clients claude,copilot,gemini,codex,cursor,opencode,pi");
expect(out).toContain("Use --clients claude,copilot,gemini,codex,cursor,opencode,opencode2,pi");
});

it("warns WITHOUT the directory hint when clients were detected but none installed", async () => {
Expand Down Expand Up @@ -812,7 +817,7 @@ describe("enableCommand — undetected client hint", () => {
await enableCommand({});

const out = logged(logSpy);
expect(out).toContain("Not detected: copilot, gemini, codex, cursor, opencode, pi");
expect(out).toContain("Not detected: copilot, gemini, codex, cursor, opencode, opencode2, pi");
expect(out).toContain("(add with --clients)");
});

Expand All @@ -830,6 +835,7 @@ describe("enableCommand — undetected client hint", () => {
detected("codex", true),
detected("cursor", true),
detected("opencode", true),
detected("opencode2", true),
detected("pi", true),
]);
vi.mocked(installAllHooks).mockReturnValue([
Expand Down
8 changes: 5 additions & 3 deletions src/commands/enable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ const ALL_CLIENTS: readonly ClientName[] = [
"codex",
"cursor",
"opencode",
"opencode2",
"pi",
] as const;

Expand All @@ -77,6 +78,7 @@ const CLIENT_DESTINATIONS: Record<ClientName, string> = {
codex: ".codex/hooks.json + [features] hooks=true flag",
cursor: ".cursor/hooks.json",
opencode: ".opencode/plugins/interlinked.ts",
opencode2: ".opencode/plugins/interlinked-opencode2.ts",
pi: ".pi/extensions/interlinked.js",
};

Expand Down Expand Up @@ -342,7 +344,7 @@ function printInstallResults(results: InstallResultLike[], detected: ClientName[
);
console.log(
c.dim(
" Use --clients claude,copilot,gemini,codex,cursor,opencode,pi to force installation.",
" Use --clients claude,copilot,gemini,codex,cursor,opencode,opencode2,pi to force installation.",
),
);
}
Expand Down Expand Up @@ -457,7 +459,7 @@ function printSummary(
}
} else {
console.log(
`\n${c.yellow("Hooks are not active.")} No hook entries were installed. Re-run with ${c.cyan("--clients claude,copilot,gemini,codex,cursor,opencode,pi")} or check client settings paths.`,
`\n${c.yellow("Hooks are not active.")} No hook entries were installed. Re-run with ${c.cyan("--clients claude,copilot,gemini,codex,cursor,opencode,opencode2,pi")} or check client settings paths.`,
);
}
}
Expand All @@ -470,7 +472,7 @@ export function buildPostEnableNotes(targetClients: readonly ClientName[]): stri
if (targetClients.includes("codex")) {
notes.push("Restart Codex or open a new Codex session to load updated hooks.");
}
if (targetClients.includes("opencode")) {
if (targetClients.includes("opencode") || targetClients.includes("opencode2")) {
notes.push("Restart OpenCode or open a new OpenCode session to load the Interlinked plugin.");
}
if (targetClients.includes("pi")) {
Expand Down
2 changes: 1 addition & 1 deletion src/commands/env.mutation-kill-w56.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ describe("envCommand — json mode descriptions/examples", () => {
"Comma-separated list of clients for non-interactive bootstrap",
);
expect(byName.INTERLINKED_CLIENTS.example).toBe(
"claude,copilot,gemini,codex,cursor,opencode,pi",
"claude,copilot,gemini,codex,cursor,opencode,opencode2,pi",
);
});
});
Expand Down
2 changes: 1 addition & 1 deletion src/commands/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ function getEnvVars(): EnvVarDoc[] {
{
name: "INTERLINKED_CLIENTS",
description: "Comma-separated list of clients for non-interactive bootstrap",
example: "claude,copilot,gemini,codex,cursor,opencode,pi",
example: "claude,copilot,gemini,codex,cursor,opencode,opencode2,pi",
currentValue: process.env.INTERLINKED_CLIENTS,
},
];
Expand Down
4 changes: 2 additions & 2 deletions src/commands/install-hooks.mutation-kill-w44.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,14 +213,14 @@ describe("parseRunners (mutants f0c1edd2a57e52e0, 91195e68e2570134, 8cd077e5ef93
describe("VALID_RUNNERS / VALID_SCOPES literals (mutant symbol 41d0eb8f71fbd934)", () => {
it("recognizes cursor, gemini-cli, codex, OpenCode, and Pi as valid runners", async () => {
await installHooksCommand({
runner: "cursor,gemini-cli,codex,opencode,pi",
runner: "cursor,gemini-cli,codex,opencode,opencode2,pi",
mode: "balanced",
json: true,
dryRun: true,
});
expect(installHooksMock).toHaveBeenCalledWith(
expect.objectContaining({
runners: ["cursor", "gemini-cli", "codex", "opencode", "pi"],
runners: ["cursor", "gemini-cli", "codex", "opencode", "opencode2", "pi"],
}),
);
expect(stderrText()).not.toContain("unknown runner");
Expand Down
2 changes: 1 addition & 1 deletion src/commands/install-hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ describe("install-hooks command", () => {
const manifest = JSON.parse(
readFileSync(join(tmp, ".interlinked", "installer-manifest.json"), "utf-8"),
) as { entries: Array<{ runner: string }> };
expect(manifest.entries.length).toBe(7);
expect(manifest.entries.length).toBe(8);
});

it("respects --dry-run", async () => {
Expand Down
1 change: 1 addition & 0 deletions src/commands/install-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const VALID_RUNNERS = new Set<RunnerId>([
"gemini-cli",
"codex",
"opencode",
"opencode2",
"pi",
]);
const VALID_SCOPES = new Set(["user", "project", "local"]);
Expand Down
1 change: 1 addition & 0 deletions src/commands/uninstall-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const VALID_RUNNERS = new Set<RunnerId>([
"gemini-cli",
"codex",
"opencode",
"opencode2",
"pi",
]);

Expand Down
1 change: 1 addition & 0 deletions src/harness/adapters/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ into the runner's stdout/stderr/exit-code contract.
| Codex CLI | `codex` | Supported | Complete 12-event native surface | native hook JSON with permission/continuation envelopes | `PreToolUse` ask → deny; `PermissionRequest` uses native prompt | ✅ `additionalContext` |
| Gemini CLI | `gemini-cli` | Experimental | 9 events | stdout JSON (provisional) | 🚧 provisional | 🚧 provisional |
| OpenCode | `opencode` | Experimental managed plugin | 11 installed callbacks: generic tool/prompt/compaction plus session/permission bus observations | throw from `tool.execute.before` to deny | ❌ → deny | ✅ tool result/context mutation; bus events observe only |
| OpenCode v2 (`opencode2`) | `opencode2` | Experimental plugin | 5 plugin hooks: `tool.execute.before/after`, `session.created/deleted/idle` | socket JSON `{decision}`; in-process throw on block | ❌ → deny | ✅ append to tool output |
| Pi | `pi` | Experimental managed extension | 13 callbacks, including `tool_call`, `tool_result`, `input`, `user_bash`, lifecycle, and compaction | Pi extension return objects | ✅ `ctx.ui.confirm`; headless → deny | ✅ `tool_result` content + UI notification |

**Native ask** = runner has a user-confirm primitive (Claude `permissionDecision: "ask"`, Cursor `permission: "ask"` on `beforeShellExecution` / `beforeMCPExecution`, Pi `ctx.ui.confirm` when `ctx.hasUI`). When absent — including headless Pi and OpenCode's stable `tool.execute.before` — the harness collapses canonical `ask` to a hard deny so the user still sees the reason and can refine.
Expand Down
21 changes: 19 additions & 2 deletions src/harness/adapters/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import { buildAllAdapters, detectAdapter, getAdapter } from "./index.js";

describe("buildAllAdapters", () => {
const adapters = buildAllAdapters();
it("returns all seven runner adapters", () => {
expect(adapters.length).toBe(7);
it("returns all eight runner adapters", () => {
expect(adapters.length).toBe(8);
const ids = adapters.map((a) => a.id).sort();
expect(ids).toEqual([
"claude-code",
Expand All @@ -16,6 +16,7 @@ describe("buildAllAdapters", () => {
"cursor",
"gemini-cli",
"opencode",
"opencode2",
"pi",
]);
});
Expand Down Expand Up @@ -58,6 +59,9 @@ describe("detectAdapter", () => {
it("detects OpenCode via OPENCODE", () => {
expect(detectAdapter({ OPENCODE: "1" })?.id).toBe("opencode");
});
it("detects OpenCode v2 via OPENCODE2 before OPENCODE*", () => {
expect(detectAdapter({ OPENCODE2: "1" })?.id).toBe("opencode2");
});
it("detects Pi via PI_CODING_AGENT", () => {
expect(detectAdapter({ PI_CODING_AGENT: "1" })?.id).toBe("pi");
});
Expand All @@ -69,6 +73,7 @@ describe("getAdapter", () => {
expect(getAdapter("copilot-cli")?.id).toBe("copilot-cli");
expect(getAdapter("cursor")?.id).toBe("cursor");
expect(getAdapter("opencode")?.id).toBe("opencode");
expect(getAdapter("opencode2")?.id).toBe("opencode2");
expect(getAdapter("pi")?.id).toBe("pi");
});
it("returns null for unknown ids", () => {
Expand Down Expand Up @@ -142,6 +147,18 @@ describe("cross-runner equivalence — semantically identical Edit events", () =
"PreToolUse",
),
],
[
"opencode2",
nonNull(buildAllAdapters()[5]).parseHookInput(
{
sessionID: "s",
cwd: "/r",
tool: "edit",
args: { filePath: "/r/a.ts", oldString: "x", newString: "y" },
},
"tool.execute.before",
),
],
];
for (const [name, event] of runners) {
it(`${name} Edit → modify tool_class`, () => {
Expand Down
Loading