diff --git a/docs/agent-colab.md b/docs/agent-colab.md new file mode 100644 index 000000000..23f9fb05e --- /dev/null +++ b/docs/agent-colab.md @@ -0,0 +1,89 @@ +# agent-colab + +Live session-to-session collaboration between running kimchi TUI sessions. Sessions +discover each other automatically, bind an A2A-compatible loopback inbox, and can hand +bounded work to a peer — "kinda like a subagent", except the worker is a full +interactive session with its own user, context, and TUI. + +Enabled by default in TUI sessions. Disable with `AGENT_COLAB=off`. + +## Commands + +| Command | What it does | +|---|---| +| `/colab` | Pick a live session → link it as a worker, optionally tell your agent | +| `/agent-name ` | Name this session so peers can address it (persisted across restarts) | + +## Tools + +| Tool | Behavior | +|---|---| +| `list_peers` | Live local sessions (self hidden, linked marked) | +| `link_peer` / `unlink_peer` | Designate / drop a worker | +| `ask_peer` | Blocking task → wakes an idle peer, returns its reply as the tool result | +| `message_peer` | Fire-and-forget → never wakes the peer; optional `notifyWhenIdle` one-shot notice | + +## Delivery semantics + +Acknowledgment is transport-level, never model-level — the JSON-RPC task state is the +receipt, handled by this extension. The receiving agent never burns a turn to +acknowledge. + +- **Fire-and-forget** (`message_peer`): task completes at injection. The message is + integrated append-only as a small labeled plain-text block (`[peer message from …]`) — + queued for the agent's next turn when idle (`nextTurn`, no turn started), or between + tool calls when busy (`steer`). +- **Blocking ask** (`ask_peer`): an idle receiving agent is woken (`followUp` + + triggerTurn); its next settled text reply is captured from the session file and + returned to the sender as the task result. +- **Notices** (`notifyWhenIdle`): one-shot, sent by the extension — immediately if the + peer is already idle, otherwise after its next settle. + +Peers exchange conclusions + file pointers, never transcripts or JSON dumps. Inbound +messages append at the conversation tail, so every session remains a single +prefix-cache-friendly token stream. (Session merging is deliberately not supported: a +merged file is a token stream no inference server has cached. Use `kimchi -r` to move a +whole conversation.) + +## Configuration + +| Variable | Default | Meaning | +|---|---|---| +| `AGENT_COLAB` | on | `off` disables the extension | +| `AGENT_COLAB_INBOUND` | `accept` | `hold` = approval dialog per message · `refuse` = reject at the door | +| `AGENT_COLAB_STATE_DIR` | `/peers` | peer registry location | + +## Security + +- Inboxes bind 127.0.0.1 only, with a mandatory per-session bearer token. +- Peer messages cannot approve permissions, change configuration, or execute commands; + the receiver's own permission gates still apply. +- Every inbound message is labeled with its sender in the transcript. +- Abuse resistance: 200 KB message cap, burst cap, duplicate suppression, in-flight cap, + self-send refusal — agent-to-agent loops die on their own. + +## Implementation notes + +- Each TUI session binds `GET /.well-known/agent-card.json` + `message/send`, + `tasks/get`, `tasks/cancel` (JSON-RPC 2.0 over loopback HTTP — an A2A v1.0 subset; + `client.ts`/`a2a-server.ts` is shaped for a later swap to the official `a2a-js` SDK). +- Peer registry lives at `/peers/` (`.json` records, pruned by pid + liveness on read; `names.json` persists `/agent-name`). The agent dir is inferred from + the live session-file path so all sessions converge on one registry. +- **Single source of truth**: the extension ships as the pinned npm dependency + `pi-agent-colab` (upstream `getkimchi/pi-agent-colab`, `github:…#v0.1.0`). At startup — + before extension discovery — `src/integrations/agent-colab.ts` mirrors its TypeScript + files into `/extensions/agent-colab` and stamps the installed version (the + same write-into-extensions-dir pattern as the herdr bridge; pi's loader aliases bare + imports like `typebox`/`pi-tui` to its bundled copies, so the mirrored files need no + node_modules). Version-stamp gate: same version → no-op. +- New/late/reloaded peers need no registration step: the registry is read fresh on every + `list_peers` and `/colab`. Linked workers survive peer restarts — links re-attach by + persisted name at the next agent turn. + +## Tests + +`pnpm vitest run src/extensions/agent-colab` — 37 tests covering the registry (liveness, +pruning, persistent naming), the A2A protocol (auth, caps, task lifecycle, real-socket +round-trip), the tools, and the full extension lifecycle on a mock harness (delivery +modes, consent, reply capture, idle notices, naming). diff --git a/package.json b/package.json index 4c1007ea4..25e758c5d 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "dependencies": { "@agentclientprotocol/sdk": "0.19.2", "@bulkhead-ai/core": "^0.7.0", + "pi-agent-colab": "github:getkimchi/pi-agent-colab#v0.1.0", "@kimchi-dev/kimchi-workflows": "0.0.9", "@clack/prompts": "^1.3.0", "@earendil-works/pi-coding-agent": "0.84.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e2fe4b763..68e176794 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -70,6 +70,9 @@ importers: open: specifier: ^10.2.0 version: 10.2.0 + pi-agent-colab: + specifier: github:getkimchi/pi-agent-colab#v0.1.0 + version: https://codeload.github.com/getkimchi/pi-agent-colab/tar.gz/697a8ad599674a97af9269cc6345a657829db014(@modelcontextprotocol/sdk@1.29.0(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)(ws@8.20.1)(zod@4.4.3) proper-lockfile: specifier: ^4.1.2 version: 4.1.2 @@ -341,24 +344,28 @@ packages: engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [musl] '@biomejs/cli-linux-arm64@2.5.3': resolution: {integrity: sha512-ksx1KWeyYW18ILL04msF/J4ZBtBDN33znYK8Z/aNv/vlBVxL9/g3mGP+omgHJKy4+KWbK87vcmmpmurfNjSgiA==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [glibc] '@biomejs/cli-linux-x64-musl@2.5.3': resolution: {integrity: sha512-O/yU9YKRUiHhmcjF2f38PSjseVk3G4VLWYc0G2HWpzdBVREV6G8IGWIVEFf7MFPfWIzNUIvPsEjeAZQIOgnLcQ==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [musl] '@biomejs/cli-linux-x64@2.5.3': resolution: {integrity: sha512-yMkJtilsgvILDcVkh187aVLTb64xYsrxYajx5kym+r1ULkO5HUOfu9AYKLGQbOVLwJtT2utNw7hhFNg+17mUYA==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [glibc] '@biomejs/cli-win32-arm64@2.5.3': resolution: {integrity: sha512-cX5z+GYwRcqEok0AH3KSfQGgqYd0Nomfp6Fbe1uiTtELE38hdH2k842wQ9wLNaF/JJ7r4rjJQ4VR+ce+fRmQbw==} @@ -661,54 +668,63 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@mariozechner/clipboard-linux-arm64-gnu@0.3.9': resolution: {integrity: sha512-g59OkUGP2DDfCOIKypHeYgv2M55u/cKvXa5dSxFbEJ34XvIQMdcVmpKCkGUro3ZgefXiGVdwguvTMQGpHWzIXw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@mariozechner/clipboard-linux-arm64-musl@0.3.6': resolution: {integrity: sha512-JlVjxxw0GbGC0djXYWRIqyteO3J1KZ/QG3udlEFaOD5TLOM1FnmXXAPDQBqr+aBVr720ef9K00dirYnJ0LDCtw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@mariozechner/clipboard-linux-arm64-musl@0.3.9': resolution: {integrity: sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@mariozechner/clipboard-linux-riscv64-gnu@0.3.9': resolution: {integrity: sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] + libc: [glibc] '@mariozechner/clipboard-linux-x64-gnu@0.3.6': resolution: {integrity: sha512-trtPwcNLW37irwQCJLtCxLw757jjJZk3TSnY/MU9bhtWtA3K9b/eLW0e4RGhUXDoFRds9opNWWaUDuFLa8dm0w==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@mariozechner/clipboard-linux-x64-gnu@0.3.9': resolution: {integrity: sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@mariozechner/clipboard-linux-x64-musl@0.3.6': resolution: {integrity: sha512-WfnzIvOCCWQiN0MmltCEo6cLceUDbYe+I7xyFZjaps5A+2Op/M2CY7Rey+C4ucQhrvmpoHmTSFgY9ODWk7snoA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@mariozechner/clipboard-linux-x64-musl@0.3.9': resolution: {integrity: sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@mariozechner/clipboard-win32-arm64-msvc@0.3.6': resolution: {integrity: sha512-+8+1aHYsBPUjmW3otmWlg+Hijt0iJvoBBs5e0mxFeUd4gDaKMB8Bn6x7c6KVtscg7E5j5NFXnwQqNSIAO4p8zQ==} @@ -1114,36 +1130,42 @@ packages: engines: {node: '>=10'} cpu: [arm64] os: [linux] + libc: [glibc] '@swc/core-linux-arm64-musl@1.15.41': resolution: {integrity: sha512-dXu/5vd4gh8symyhRF+4G7gOPkjmb4pONhh7sl+6GSiW0LOKZlfu5kXmyFbTz9smOT7jgr002qY9b1nujjXt2A==} engines: {node: '>=10'} cpu: [arm64] os: [linux] + libc: [musl] '@swc/core-linux-ppc64-gnu@1.15.41': resolution: {integrity: sha512-XGO6zVPXoPE0gf/XnI4jBbafNT13AYgoh6ns0JCSdOetI/kqVf0vhpz7NuNgAzZrMVCsmieqjPoTwViDgh4mOQ==} engines: {node: '>=10'} cpu: [ppc64] os: [linux] + libc: [glibc] '@swc/core-linux-s390x-gnu@1.15.41': resolution: {integrity: sha512-0WUglRwyZtW+iMi7J3iFdrCxreZZIKf4egTwEQfIYRsqFax69A0OrFj+NIoFSE03xBT/IFRrg+S8K6f9Ky+4hA==} engines: {node: '>=10'} cpu: [s390x] os: [linux] + libc: [glibc] '@swc/core-linux-x64-gnu@1.15.41': resolution: {integrity: sha512-VxkuQK59c0tHm6uJZCUrS3cyA2JhGGfdU6e41SZz0x/JS+4Sm7C1mIc97In14vkZJopEt7yXA2TouCqZDSygEA==} engines: {node: '>=10'} cpu: [x64] os: [linux] + libc: [glibc] '@swc/core-linux-x64-musl@1.15.41': resolution: {integrity: sha512-/0qXIu1ZxggLuovLb22vFfKHq2AA4n6Whw5UwmVCHk4pkw7KWnPIQpMCEqUMPsNkFJig7PPp/TSYFu8ZEb2rtQ==} engines: {node: '>=10'} cpu: [x64] os: [linux] + libc: [musl] '@swc/core-win32-arm64-msvc@1.15.41': resolution: {integrity: sha512-Y481sMNZM6rECh9VO4+y26N1lWEDAyxnBZskUf37fl90uHE946VHfmiVQWT0uMFOhyJJFovGTRuF4W82dwewUg==} @@ -2265,6 +2287,11 @@ packages: resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} engines: {node: '>= 14.16'} + pi-agent-colab@https://codeload.github.com/getkimchi/pi-agent-colab/tar.gz/697a8ad599674a97af9269cc6345a657829db014: + resolution: {tarball: https://codeload.github.com/getkimchi/pi-agent-colab/tar.gz/697a8ad599674a97af9269cc6345a657829db014} + version: 0.1.0 + engines: {node: '>=20'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -4886,6 +4913,19 @@ snapshots: pathval@2.0.1: {} + pi-agent-colab@https://codeload.github.com/getkimchi/pi-agent-colab/tar.gz/697a8ad599674a97af9269cc6345a657829db014(@modelcontextprotocol/sdk@1.29.0(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)(ws@8.20.1)(zod@4.4.3): + dependencies: + '@earendil-works/pi-coding-agent': 0.84.1(patch_hash=d3074927a86746b8c663af0b071a0ec301579ca4016b50bb1f162a8ec99fa36d)(@modelcontextprotocol/sdk@1.29.0(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)(ws@8.20.1)(zod@4.4.3) + '@earendil-works/pi-tui': 0.84.1(patch_hash=994f8b20d3f066d88c967e3bcdc4c86cbd603b33c556843cc9445bdce98ee602) + typebox: 1.3.7 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + picocolors@1.1.1: {} picomatch@2.3.2: {} diff --git a/src/cli.ts b/src/cli.ts index a7a1f3783..917613d7e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -145,6 +145,7 @@ import { createInfrastructureErrorTracker, KIMCHI_INFRA_ERROR_EXIT_CODE, } from "./infrastructure-error.js" +import { ensureAgentColabExtension } from "./integrations/agent-colab.js" import { injectAutoModel, injectExperimentalProvider, @@ -354,6 +355,10 @@ try { if (!agentDir) { throw new Error("KIMCHI_CODING_AGENT_DIR is not set; cli.ts must be entered via entry.ts") } + // Sync the pi-agent-colab extension (pinned dependency, upstream + // getkimchi/pi-agent-colab) into pi's discovered extensions dir BEFORE + // extension discovery runs — same version stamp → no-op. Best-effort. + ensureAgentColabExtension(agentDir) const modelsJsonPath = resolve(agentDir, "models.json") let currentApiKey = apiKey diff --git a/src/integrations/agent-colab.test.ts b/src/integrations/agent-colab.test.ts new file mode 100644 index 000000000..d2c2f353b --- /dev/null +++ b/src/integrations/agent-colab.test.ts @@ -0,0 +1,70 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { beforeEach, describe, expect, it } from "vitest" +import { ensureAgentColabExtension } from "./agent-colab.js" + +let sourceDir: string +let agentDir: string + +function makeSourcePackage(version: string): void { + writeFileSync(join(sourceDir, "package.json"), JSON.stringify({ name: "pi-agent-colab", version })) + writeFileSync(join(sourceDir, "index.ts"), "export default () => {}") + writeFileSync(join(sourceDir, "registry.ts"), "export const x = 1") + writeFileSync(join(sourceDir, "registry.test.ts"), "// tests must not be mirrored") + writeFileSync(join(sourceDir, "README.md"), "# docs stay in the package") +} + +function targetDir(): string { + return join(agentDir, "extensions", "agent-colab") +} + +beforeEach(() => { + sourceDir = mkdtempSync(join(tmpdir(), "agent-colab-src-")) + agentDir = mkdtempSync(join(tmpdir(), "agent-colab-agent-")) + makeSourcePackage("0.1.0") +}) + +describe("agent-colab installer", () => { + it("mirrors extension TS files and stamps the version", () => { + ensureAgentColabExtension(agentDir, { sourceDir }) + const target = targetDir() + expect(existsSync(join(target, "index.ts"))).toBe(true) + expect(existsSync(join(target, "registry.ts"))).toBe(true) + expect(existsSync(join(target, "registry.test.ts"))).toBe(false) + expect(existsSync(join(target, "README.md"))).toBe(false) + expect(readFileSync(join(target, ".kimchi-agent-colab-version"), "utf8")).toBe("0.1.0") + }) + + it("skips re-sync when the version stamp matches", () => { + ensureAgentColabExtension(agentDir, { sourceDir }) + // Mutate the source without bumping the version — stamp gate must skip. + writeFileSync(join(sourceDir, "index.ts"), "export default () => 'changed'") + ensureAgentColabExtension(agentDir, { sourceDir }) + expect(readFileSync(join(targetDir(), "index.ts"), "utf8")).toBe("export default () => {}") + }) + + it("re-mirrors when the version changes", () => { + ensureAgentColabExtension(agentDir, { sourceDir }) + writeFileSync(join(sourceDir, "index.ts"), "export default () => 'v2'") + writeFileSync(join(sourceDir, "package.json"), JSON.stringify({ name: "pi-agent-colab", version: "0.2.0" })) + ensureAgentColabExtension(agentDir, { sourceDir }) + expect(readFileSync(join(targetDir(), "index.ts"), "utf8")).toBe("export default () => 'v2'") + expect(readFileSync(join(targetDir(), ".kimchi-agent-colab-version"), "utf8")).toBe("0.2.0") + }) + + it("recovers from a corrupted/partial target dir", () => { + ensureAgentColabExtension(agentDir, { sourceDir }) + // Simulate a partial copy: stamp present but index.ts missing. + const target = targetDir() + rmSync(join(target, "index.ts"), { force: true }) + rmSync(join(target, "registry.ts"), { force: true }) + ensureAgentColabExtension(agentDir, { sourceDir }) + expect(existsSync(join(target, "index.ts"))).toBe(true) + }) + + it("warns and continues when the source package is missing", () => { + expect(() => ensureAgentColabExtension(agentDir, { sourceDir: join(sourceDir, "missing") })).not.toThrow() + expect(existsSync(targetDir())).toBe(false) + }) +}) diff --git a/src/integrations/agent-colab.ts b/src/integrations/agent-colab.ts new file mode 100644 index 000000000..e45fdd033 --- /dev/null +++ b/src/integrations/agent-colab.ts @@ -0,0 +1,92 @@ +/** + * agent-colab installer — bridges the standalone pi package into kimchi. + * + * The extension's single source of truth is the `pi-agent-colab` npm package + * (github:getkimchi/pi-agent-colab, pinned via package.json). This installer + * mirrors its TypeScript files into pi's *discovered* extensions dir + * (`/extensions/agent-colab`) and stamps the installed version — + * the same write-into-extensions-dir pattern as the herdr bridge. pi's + * extension loader aliases bare imports (typebox, pi-tui, + * @earendil-works/pi-coding-agent) to its bundled copies, so the mirrored + * files resolve without any node_modules of their own. + * + * Runs once per startup, before extension discovery: same version stamp → + * no-op; missing/changed → re-mirror. Best-effort by design — a failed sync + * downgrades to "no collaboration", never a broken startup. + */ + +import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs" +import { createRequire } from "node:module" +import { dirname, join } from "node:path" +import { isBunBinary } from "../env.js" + +const VERSION_STAMP = ".kimchi-agent-colab-version" + +/** Locate the installed pi-agent-colab package directory. */ +export function resolveAgentColabSourceDir(): string | undefined { + if (isBunBinary) { + // Compiled binary: deps live inside the pi package dir (npm layout). + const packageDir = process.env.PI_PACKAGE_DIR + if (!packageDir) return undefined + const candidate = join(packageDir, "node_modules", "pi-agent-colab") + return existsSync(join(candidate, "package.json")) ? candidate : undefined + } + try { + // Dev (repo checkout): resolve through the repo's node_modules. + const require = createRequire(import.meta.url) + return dirname(require.resolve("pi-agent-colab/package.json")) + } catch { + return undefined + } +} + +function versionOf(sourceDir: string): string | undefined { + try { + const pkg = JSON.parse(readFileSync(join(sourceDir, "package.json"), "utf8")) as { version?: unknown } + return typeof pkg.version === "string" ? pkg.version : undefined + } catch { + return undefined + } +} + +/** + * Mirror the package's extension files into `/extensions/agent-colab`. + * Skips tests and non-TS assets; re-mirrors when the version stamp changes. + */ +export function ensureAgentColabExtension(agentDir: string, opts?: { sourceDir?: string; targetDir?: string }): void { + try { + const sourceDir = opts?.sourceDir ?? resolveAgentColabSourceDir() + if (!sourceDir) { + console.warn("agent-colab: pi-agent-colab package not found; collaboration unavailable this session.") + return + } + const version = versionOf(sourceDir) + if (!version) { + console.warn("agent-colab: pi-agent-colab package has no readable version; skipping sync.") + return + } + + const targetDir = opts?.targetDir ?? join(agentDir, "extensions", "agent-colab") + const stampPath = join(targetDir, VERSION_STAMP) + let needsSync = true + try { + needsSync = readFileSync(stampPath, "utf8") !== version || !existsSync(join(targetDir, "index.ts")) + } catch { + needsSync = true + } + if (!needsSync) return + + mkdirSync(targetDir, { recursive: true }) + for (const file of readdirSync(sourceDir)) { + // Extension sources only — tests and assets stay in the package. + if (!file.endsWith(".ts") || file.endsWith(".test.ts")) continue + copyFileSync(join(sourceDir, file), join(targetDir, file)) + } + writeFileSync(stampPath, version) + console.warn(`agent-colab: synced extension v${version} → ${targetDir}`) + } catch (err) { + console.warn( + `agent-colab: failed to sync extension (${err instanceof Error ? err.message : String(err)}); continuing without it.`, + ) + } +}