Skip to content

feat: agent-colab — live session-to-session collaboration (on by default) - #1196

Open
igor-susic1 wants to merge 2 commits into
masterfrom
agent-colab
Open

igor-susic1 wants to merge 2 commits into
masterfrom
agent-colab

Conversation

@igor-susic1

@igor-susic1 igor-susic1 commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

What

Integrates agent-colab as a built-in, default-on extension: running kimchi TUI sessions discover each other and can delegate bounded work to one another — subagent-style delegation, but the worker is a full interactive session with its own user.

  • Commands: /colab (pick a live session as a worker), /agent-name (persistent naming)
  • Agent tools: list_peers, link_peer/unlink_peer, ask_peer (blocking, reply captured), message_peer (fire-and-forget, optional one-shot idle notice)
  • Transport: per-session loopback A2A v1.0 inbox (agent card, message/send, tasks/get/cancel, mandatory bearer token) + shared peer registry at <agentDir>/peers with pid-liveness pruning

Why this design

  • Cache-aware by construction: peer content enters as small labeled plain-text deltas appended at the conversation tail — never transcripts or JSON dumps. Every session stays one unbroken, prefix-cache-friendly token stream; session merging is deliberately rejected (a merged file is a token stream no inference server has cached).
  • Transport-level acks, never model acks: the JSON-RPC task state is the receipt; the receiving agent never burns a turn acknowledging. Fire-and-forget messages queue as nextTurn when idle (no forced turn); only ask_peer (sender blocked on the answer) wakes an idle peer.
  • Consent & safety: AGENT_COLAB_INBOUND=accept|hold|refuse; peer messages cannot approve permissions, change config, or run commands; receiver-side permission gates still apply; sender labeled 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.

Integration notes

  • Consumes the standalone package instead of vendoring: pi-agent-colab is a pinned
    dependency (github:getkimchi/pi-agent-colab#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 (same
    write-into-extensions-dir pattern as the herdr bridge; version stamp → no-op on later
    starts). pi's extension loader aliases bare imports (typebox, pi-tui,
    pi-coding-agent) to its bundled copies, so the mirrored files need no node_modules.
    Upstream repo owns the code and its 37 tests; kimchi adds an installer test (5 tests).
  • Docs: docs/agent-colab.md.
  • Discovery needs no setup: the registry is read fresh on every list_peers/colab, new/late/reloaded peers appear on their own, and linked workers re-attach by persisted name after peer restarts.
  • The extension module imports only pi's public API (ExtensionAPI, getAgentDir), typebox, and pi-tui; the agent dir for the registry is inferred from the live session-file path, so pi and kimchi installs converge on their own redirected agent dirs.

Test plan

  • pnpm vitest run src/integrations — installer tests passing (extension's own 37 tests run upstream in getkimchi/pi-agent-colab)
  • pnpm exec tsc --noEmit clean; biome check clean on touched paths
  • Manual two-terminal smoke: /colab pick → ask_peer round-trip → fire-and-forget + idle notice → /reload attach on a running session
  • CI

Each TUI session binds a loopback A2A v1.0 inbox (agent card,
message/send, tasks/get/cancel; mandatory bearer token) and registers in
a shared peer registry (<agentDir>/peers, pid-liveness pruning).

Adds /colab (pick a running session as a worker), /agent-name
(persistent naming), and five agent tools: list_peers, link_peer,
unlink_peer, ask_peer (blocking delegation with reply capture),
message_peer (fire-and-forget, transport-level ack, optional one-shot
idle notice).

Delivery is context/cache-economical: peer content enters as small
labeled plain-text deltas appended at the conversation tail — never
transcripts or JSON — so each session stays one unbroken
prefix-cache-friendly token stream. Consent controls
(AGENT_COLAB_INBOUND=accept|hold|refuse), abuse caps (size, burst,
dedupe, in-flight, self-send refusal), TUI-only, kill-switch
AGENT_COLAB=off. 37 tests.

Co-Authored-By: Kimchi <noreply@kimchi.dev>
@readme-ai-writer

readme-ai-writer Bot commented Sep 14, 2026

Copy link
Copy Markdown

⚠️ The documentation bot was unable to process documentation updates.

You can re-trigger by converting this PR to draft, then opening for review again.

@kimchi-review

kimchi-review Bot commented Sep 14, 2026

Copy link
Copy Markdown

Kimchi Code Review

Property Value
Commit 184ce5b
Author @igor-susic1
Files changed 13
Review status Completed
Comments 12 (7 info, 5 warning)
Duration 146s

Summary

📊 Review Score: 82/100 (overall code quality — 0 lowest, 100 highest)
⏱️ Estimated effort to review: 4/5 (1 = trivial, 5 = very complex)

🧪 Tests: yes — Strong coverage: 4 test files covering the A2A protocol handler (auth, size caps, burst/dedupe/in-flight limits, task lifecycle, cancel-frees-slot) including a real-socket round-trip; the registry (liveness pruning, malformed records, 0600 permissions, name persistence); the tools end-to-end against a live peer server; and the full extension lifecycle on a mock pi harness (delivery modes, hold/refuse consent, reply capture, one-shot idle notices, peer restart/re-attach, shutdown cleanup, non-TUI skip). Minor hygiene issue: index.test.ts reassigns dir in beforeEach but afterAll only removes the last temp dir.

🔒 Security concerns found: Low-severity, loopback-mitigated concerns: (1) the HTTP listener buffers the entire request body before the bearer-token and size-cap checks run, so a local process can force unbounded memory allocation despite the documented 200 KB cap; (2) the unauthenticated agent card embeds the session's cwd in description, disclosing local filesystem paths to any process that can reach the port; (3) the self-send guard relies solely on sender-supplied fromName metadata, which is forgeable. The bearer token, 0600 registry records, and 127.0.0.1-only binding are otherwise sound.

📝 Found 12 issue(s). See inline comments for details.

What to expect

Kimchi will analyze the changes in this pull request and post:

  • A summary of the overall changes
  • Inline comments on specific lines with findings categorized by issue type

The review typically completes within a few minutes. This comment will be updated once the review is ready.

Interact with Kimchi
  • @getkimchi review — re-trigger a full review on the latest commit
  • @getkimchi summary — regenerate the PR summary
  • @getkimchi ignore — skip this PR (no review will be posted)
  • Reply to any inline comment to ask follow-up questions or request clarification
Configuration

Reviews are configured by your organization admin.
Review instructions, excluded directories, and severity thresholds can be adjusted per repository in the Kimchi dashboard.


Powered by Kimchi — AI-powered code review by CAST AI

@kimchi-review kimchi-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📊 Review Score: 82/100 (overall code quality — 0 lowest, 100 highest)
⏱️ Estimated effort to review: 4/5 (1 = trivial, 5 = very complex)

🧪 Tests: yes — Strong coverage: 4 test files covering the A2A protocol handler (auth, size caps, burst/dedupe/in-flight limits, task lifecycle, cancel-frees-slot) including a real-socket round-trip; the registry (liveness pruning, malformed records, 0600 permissions, name persistence); the tools end-to-end against a live peer server; and the full extension lifecycle on a mock pi harness (delivery modes, hold/refuse consent, reply capture, one-shot idle notices, peer restart/re-attach, shutdown cleanup, non-TUI skip). Minor hygiene issue: index.test.ts reassigns dir in beforeEach but afterAll only removes the last temp dir.

🔒 Security concerns found: Low-severity, loopback-mitigated concerns: (1) the HTTP listener buffers the entire request body before the bearer-token and size-cap checks run, so a local process can force unbounded memory allocation despite the documented 200 KB cap; (2) the unauthenticated agent card embeds the session's cwd in description, disclosing local filesystem paths to any process that can reach the port; (3) the self-send guard relies solely on sender-supplied fromName metadata, which is forgeable. The bearer token, 0600 registry records, and 127.0.0.1-only binding are otherwise sound.

📝 Found 12 issue(s). See inline comments for details.

Comment thread src/extensions/agent-colab/tools.ts Outdated
"Self-contained task or question. The peer cannot see your conversation — include file paths and specifics. Ask for a concise answer with pointers, not dumps.",
minLength: 1,
}),
timeoutMs: Type.Optional(Type.Number({ description: "Max seconds to wait for the reply (default 120)." })),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️🐛 Bug

Contradictory unit contract on the ask_peer timeout: the parameter is named timeoutMs but the schema description says "Max seconds to wait for the reply (default 120)", and the implementation multiplies by 1000 via (params.timeoutMs ?? 120) * 1000. Since tool descriptions are load-bearing for the model, an agent reading the name will pass milliseconds (e.g. 30000) and block the tool call for ~8 hours instead of 30 seconds; one reading the description passes seconds correctly. There is also no upper bound.

💡 Suggestion: Rename the parameter to timeoutSeconds (or change the description to milliseconds and drop the * 1000), and clamp to a sane range, e.g. Type.Number({ minimum: 5, maximum: 1800 }) with Math.min(...) in the execute path.

}
state.tasks.set(task.id, task)
state.inflightCount += 1
// Settle fail-safe: a hung deliver must not leak an in-flight slot forever.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️⚠️ Error Handling

makeTask uses setInterval for the settle fail-safe and never clears it. The timer fires every 10 minutes for the lifetime of the process even after the task settles normally (the guard makes re-fires no-ops, and unref prevents it from blocking exit, but every task still leaks a permanently firing interval).

💡 Suggestion: Use setTimeout instead of setInterval (one-shot is the intended semantics), and call clearTimeout(timer) inside settleTask or store the handle on the task so a normally-settled task releases its timer.

history: [],
createdAt: Date.now(),
}
state.tasks.set(task.id, task)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️Performance

state.tasks grows unboundedly: state.tasks.set(task.id, task) on every message/send, and entries (including up to 200 KB of reply history each) are never removed. A long-lived TUI session with steady peer traffic accumulates task records forever, and tasks/get can resurrect arbitrarily old entries.

💡 Suggestion: Prune settled tasks — e.g. delete entries where settledAt is older than a TTL (5–10 min) inside the existing checkDedupe-style housekeeping or a periodic sweep — and cap the map size.

port?: number
}): Promise<{ port: number; stop: () => Promise<void> }> {
const state = createA2aState(opts)
const server: Server = createServer((req, res) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️🔒 Security

The HTTP listener accumulates the full request body via req.on("data", ...) with no byte limit; both the bearer-token check and the MAX_TEXT_CHARS + 65_536 cap inside handleA2aRequest run only after the entire body is buffered. Any local process can POST an arbitrarily large body to the inbox port and exhaust session memory, which violates the documented 200 KB abuse-resistance cap.

💡 Suggestion: Track the accumulated byte length in the data handler and call req.destroy() (responding with a 413/error) as soon as the total exceeds MAX_TEXT_CHARS + 65_536, before any auth or parsing occurs.

Comment thread src/extensions/agent-colab/index.ts Outdated
const started = await startA2aServer({ card, token, deliver })
server = started
card.url = `http://127.0.0.1:${started.port}/`

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️⚠️ Error Handling

The session_start handler performs unguarded awaits — startA2aServer({ card, token, deliver }) and registerPeer(registryDir, record) (plus writePeerName earlier). Any failure (bind error, unwritable state dir, ENOSPC) rejects the event handler, producing an unhandled rejection and potentially leaving self/activeCard set while the registry record and server are missing — a half-initialized colab state inside an otherwise healthy TUI session.

💡 Suggestion: Wrap the setup block in try/catch: on failure call await teardown(), reset self/activeCard, and ctx.ui.notify(...) that agent-colab is disabled for the session instead of propagating.

if (text === undefined || text.length === 0) {
return rpcError(req.id, ERR_INVALID_PARAMS, "message.send requires message.parts with a text part.")
}
const from = extractSender(params)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️🔒 Security

The self-send guard compares only from.name === state.card.name — both values are sender-controlled metadata (a peer can omit fromName, and two sessions can legitimately share a name). Self-messaging is therefore easy to trigger, which weakens the documented "agent-to-agent loops die on their own" guarantee since the remaining defenses (burst/dedupe) only rate-limit rather than stop cross-session ping-pong.

💡 Suggestion: Include the receiver's own sessionId in A2aState and reject when from.sessionId matches it; treat same-name matches as ambiguous rather than silently allowing.

return state.burst.count <= BURST_MAX
}

function checkDedupe(state: A2aState, from: PeerSender, text: string): boolean {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️🐛 Bug

checkDedupe keys on from.name + text. Two distinct anonymous peers (no fromName) sending identical text within DEDUPE_WINDOW_MS collide on the same key, so the second peer's legitimate message is rejected with "Duplicate message within the dedupe window."

💡 Suggestion: Incorporate from.sessionId into the dedupe hash input (falling back to name as now), so dedupe is truly per-sender as the header comment states.

Comment thread src/extensions/agent-colab/index.ts Outdated
}

self = { sessionId, name, token }
const card: AgentCard = {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️🔒 Security

The agent card is served unauthenticated (GET /.well-known/agent-card.json) and its description embeds the session's cwd (kimchi/pi coding session in ${ctx.cwd}). On multi-user machines any local process can scan loopback ports and harvest session names plus project paths (typically including usernames), information the 0600 registry file otherwise protects.

💡 Suggestion: Consider requiring the bearer token for the card as well (peers already read the token from the shared registry before calling), or drop the cwd from the unauthenticated card and expose it only via authenticated RPC.

Comment thread src/extensions/agent-colab/registry.ts Outdated
} catch {
return undefined
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️🔀 Concurrency

writePeerName does a non-atomic read-modify-write of names.json; two sessions naming themselves concurrently (both start with /agent-name) can lose one entry, and a torn write is silently swallowed on next read (catch resets to {}). Additionally names.json never prunes entries for sessions that no longer exist, so it grows forever.

💡 Suggestion: Write via a temp file + renameSync and re-read/retry on parse failure; opportunistically drop sessionIds not present among live records during listLivePeers or writePeerName.

agentColab(harness.pi as never, { registryDir: dir })
const ctx = { ...baseCtx(), mode: "rpc" }
await harness.emit("session_start", { reason: "startup" }, ctx)
expect(listLivePeers(dir)).toHaveLength(0)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️🧪 Testing

beforeEach creates a fresh mkdtempSync dir but afterAll removes only the most recent one (dir is reassigned each test), leaking a temp directory per test into the OS temp dir on every run.

💡 Suggestion: Track dirs in an array (dirs.push(dir) in beforeEach) and remove all of them in afterAll, or clean the current dir in afterEach.

Single source of truth: drop the vendored src/extensions/agent-colab
copy; ship the extension as the pinned dependency
pi-agent-colab@github:getkimchi/pi-agent-colab#v0.1.0 and mirror its
TypeScript files into <agentDir>/extensions/agent-colab at startup
(before extension discovery) via src/integrations/agent-colab.ts —
version-stamp gated, best-effort, same write-into-extensions-dir
pattern as the herdr bridge. The pi extension loader aliases bare
imports (typebox, pi-tui, pi-coding-agent) to bundled copies, so the
mirrored files need no node_modules of their own.

Co-Authored-By: Kimchi <noreply@kimchi.dev>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants