Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions docs/agent-colab.md
Original file line number Diff line number Diff line change
@@ -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>` | 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` | `<agentDir>/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 `<agentDir>/peers/` (`<sessionId>.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 `<agentDir>/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).
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
40 changes: 40 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
70 changes: 70 additions & 0 deletions src/integrations/agent-colab.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading
Loading