Skip to content

pi joins the chat: a third agent over a pinned pi-acp - #422

Open
srid wants to merge 10 commits into
masterfrom
pi-acp
Open

pi joins the chat: a third agent over a pinned pi-acp#422
srid wants to merge 10 commits into
masterfrom
pi-acp

Conversation

@srid

@srid srid commented Aug 28, 2026

Copy link
Copy Markdown
Member

Why

Sprint pi-acp: pi becomes a selectable third agent in the chat panel. The method was binding: the wire decided the wiring, so the work started with a spike of the real third-party adapter and only then wrote the leg from what the spike saw.

What

  • packages/chat/src/agents/pi.ts — the new leg, one that reads like claude's own (same SDK handshake, no doors opened that make claude's special dispensation general), with capabilities set to exact spike facts, and the spiked frames documented in the file.
  • roster.ts — pi is the fixture that satisfies both of docs/chat.md's launch gates at once: the adapter is the shipped pin (nix/acp-agent.nix now builds pi-acp@0.0.33 — which is the revision this was spiked against: svkozak/pi-acp@d1cffc047ab37a096ee70ca39cfc1de463db8d12 sits two commits past the v0.0.33 tag with a README.md one-liner as the whole delta from the same committed lockfile as the claude adapter — npm's blessing, never npm's latest), and the agent is the found one (pi on the agent search path, handed to the adapter's spawn as PI_ACP_PI_COMMAND). No pi on a machine: no pi row, nothing handwritten.
  • agent.ts — one transcript-level rule, not a pi-shaped one: a leg may declare a prologue (Leg.prologueIn), the text its open will double as one ordinary agent chunk; armed at the open from the adapter's own answer, consumed on first equal chunk, never pattern-matched. pi's startupInfo banner (pi v0.84.$n) is thus left out — it would otherwise mask the silence arm: a transcript that has it can no longer tell the difference between the agent saying nothing and the adapter saying nothing yet (spike, §client-side queue). Arm: response-before-notification — the banner is emitted by pi-acp from a scheduled continuation behind its own session/new return, and on the response's microtask the request's continuation (arming) runs before the next notification can be pulled; an adapter that reorders ships the banner to the transcript, which is the safe direction.
  • Web: the π mark; NoAgent knows pi's where-from.
  • Docs: chat.md (pi row in the list, plus its rules — not-wired tools, queue interlude, π fair play, prologue posture), architecture.md (roster + pins), running.md (what olai looks for).

Wire facts the spike settled (why the leg says what it says)

  • Tool ids are the adapter's own mint (<tool>:<n>bash:0, edit:1), not opencode's call_*; bash frames arrive without _meta.toolName but with terminal content and the terminal_exit/terminal_output corners; edits carry structured diffs and locations.
  • No token previews (no usage_update, no per-message _piMessageInfo), and raw content is not re-announced afterwards — so the two doors claude's adapter opens (usageIn, contentIn) stay closed here.
  • session/set_mode answers invalid params (-32602) and _session/steering method not found (-32601) — neither exists, so both are declared null rather than lied about. Queueing is the adapter's own: a mid-turn prompt is announced (session_info_update with _meta.piAcp.queueDepth) and answered in order — verified over a held 12s turn.
  • mcpServers are accepted without complaint and wired to nothing: no permission is ever asked for pi's own tools, and olai's tools never reach pi. The roster's rows answer it from the open: each stands did-not-attach with the wire's own sentence under it ("…wires them to nothing…") — a row that stood handed for the life of the conversation would be the panel repeating the model's wrong answer in the place a person has decided to trust.

Evidence

Saatchi against real pi (kimi-k3, on this machine's PATH; JUSPAY_API_KEY in env) — the picker offers π, the header names pi litellm/kimi-k3, a real turn wrote evidence.md with its structured diff drawn, and the banner is nowhere:

picker-offers-pi

chosen-pi-header

pi-turn-with-write

One take: choose pi from the chooser, one real write streaming its own tool row, one message sent mid-turn answered by the adapter's own Queued message (position 1)., a second chat, and a reload that reopens the remembered conversation with its answer replayed — real pi, real store:

record.mp4

Runs

  • just typecheck — clean
  • just test — 4271 + 87 pass; just fmt-check clean
  • just fmt-check — clean
  • just nix — packages; the built wrapper's self-check answers both baked adapters (packaged pi adapter: …/bin/pi-acp)
  • choosing_an_agent.feature35/35, incl. the six @pi scenarios (picker offers pi; banner held out; silent-turn arm not poisoned; the mid-turn interlude — composer claim AND the adapter's own Queued message (position 1). chunk; roster rows at did-not-attach with the sentence; grouped list without badges; stored reopening with the fixed name)

Deferrals

No deferrals — both earlier filings were dissolved by ruling: the chats list's newest-fifty page and the mid-turn QUEUE-not-steer are the permanent shape, stated plainly in docs/chat.md. The fold section above names the filings as they stood when the fold answered them.

srid added 2 commits August 28, 2026 17:58
The wire first, then the leg. svkozak/pi-acp 0.0.33 speaks claude's ACP
shape over `pi --mode rpc`; the spike settled what olai does not wait for
and what it does not draw:

- no usage previews and no raw content re-announced afterwards, so it
  reads like claude's own adapter and the doors that could double-dip
  stay closed;
- session/set_mode unset, _session/steering unimplemented — both
  declared null rather than lied about, and queueing is the adapter's
  own (announce, answer in order);
- mcpServers are accepted and wired to nothing — no permission draw and
  no olai tools; the conversation keeps them listed;
- the editor prologue doubles as one ordinary agent chunk equal to the
  startupInfo its own session/new answer carries; the transcript drops
  it, armed at the open, consumed on the match, never pattern-matched;
- session/list answers the exact directory, paginated at 50 — the chats
  list's deferral is named in the leg.

The row is both fixtures from chat.md's gates: the adapter is the shipped
pin (nix/acp-agent.nix builds pi-acp beside the claude one from the same
lockfile at svkozak/pi-acp#d1cffc0069968bca00487b5726ba0b2bc6cf7217,
never npm's latest), and the agent is the found one — `pi` on the agent
search path, handed to the adapter at spawn as PI_ACP_PI_COMMAND. The
picker offers pi only when both halves hold, draws its own mark (pi),
and the resume-less badge publishes "stored by pi-acp locally".
The leg named svkozak/pi-acp @ d1cffc0; a reader of the pin (npm's
pi-acp@0.0.33) is left to work out whether that is the same code. It is:
d1cffc0 is two commits past the v0.0.33 tag and the delta is one README
line, which is now said where the claim is made, with the full SHA so the
check never has to be redone.
@srid

srid commented Aug 28, 2026

Copy link
Copy Markdown
Member Author

Grok

Read the diff at 4c1fdc5d against the PR body, the pi leg, Leg.prologueIn, fromElsewhere, the pin and its self-check, the scripted adapter, chat.md, and the two deferral nodes. One probe: v0.0.33 (e8b01c3) → d1cffc0 is two commits, README.md one-liner (/model note). No suites.

Verdict: the pin is the acp-agent.nix pattern — one lockfile, test -f on pi-acp/dist/index.js, wrapper baked as ${OLAI_ACP_PI-…} and the just nix sed fails if that assignment drops or the path is not executable. Roster is the pair (adapter from the variable, pi from the probe, PI_ACP_PI_COMMAND at spawn). Capability claims are wire-shaped, not README-shaped: allowedWithoutAsking is a bare null (no guessed prefix), listedIn answers nothing rather than a zero, queues is a verified yes with no handshake word, steering/bypassMode/rawMessages are the two refusals and the missing channel. Recognition edges on prologueIn hold (non-string, empty, null, absent → nothing dropped). mcpServers accepted-and-ignored is the right posture: olai always hands tools, refusing the open would make pi unselectable, and chat.md says the loss (no ticks, no counts, no usage, bash in the fold) rather than leaving it to be discovered. fromElsewhere's current === null window is the load-replay door, not a pi hole — after leaving(), the old id is in closed; a foreign id would have to be one this process never entered. Deferrals match the nodes (pi-list-pagination, pi-midturn-steering); the floors they did not file (terminal bash, authMethods, cancel-vs-queue unspiked) are named as floors. No MUST.

SHOULD

  • packages/chat/src/agent.ts:1499leaving() drops every session-keyed value except prologue, and the load path disarms it only after the replay it is trying to protect. The comment at 1495 says the load's null prologue is "really disarming: whatever a previous conversation of this process announced, nothing of it may consume a chunk of THIS session's replay." The assignment runs after ask(session/load) returns. Replay frames are notifications during that ask. fromElsewhere lets them through (closed.delete(id) first, session still null). Equality is still armed with the previous open's startupInfo.

    A person-hittable path needs a fresh pi session/new whose banner has not yet landed (setTimeout(0) on the adapter) and then a load of another conversation whose replayed agent chunk equals that banner. The real banner is a long distinctive string (version, Context/Skills/Prompts, optional nag), so a collision is unlikely — but the comment is describing a door the code leaves open, and leaving() is already the function that exists so a fourth call site cannot forget a fifth thing. Disarm in leaving() (and, for the load, before the request) so the comment is true.

  • packages/tests/agent/pi/pi-acp:298the fake is a witness of the happy-path subset of the spike, not of the shapes the brief named. It does emit Queued message (position n). plus session_info_update {queueDepth, running} and holds slow until the scenario releases — that is the right fake for a 12s interlude. What it does not do: session/load never refuses. The real adapter's findStoredSession prefers ~/.pi/pi-acp/session-map.json over pi's own store, then spawns pi --session <map.path> with no existsSync. Map hit / file gone (deleted session, moved store) is either a spawn error olai will draw as a refusal, or — if pi creates a new file at the stale path — an empty replay of a conversation the picker still named. Boot is safer (session/list is pi's store; adopt drops an id that is not listed). The fake's load always replays. The mid-turn @pi scenario never reads the queue frames it causes (composer says a message would queue is PI.queues(), not the wire).

NIT

  • packages/tests/agent/pi/pi-acp:99BANNER is pi v0.84.2\n---\n. The adapter's buildStartupInfo is that header plus Context/Skills/Prompts/Extensions and a possible update nag, trimmed with a trailing newline, sent as one chunk. Equality still matches whatever the response published, so the filter is not wrong; the e2e substring pi v0.84.2 would also pass against a banner that survived in pieces. The losing direction the leg already names.

@srid

srid commented Aug 28, 2026

Copy link
Copy Markdown
Member Author

Review — Claude Code

Verdict: changes requested. The leg itself is the good part: every null is a refusal code rather than an omission, prologueIn's recognition edges are all closed (non-string, empty string, absent corner, the load's null), listedIn: () => null keeps clear of the zero-vs-absent trap, the roster's pair (adapter from the variable, agent from the probe) is the right shape, and PI_ACP_PI_COMMAND really is what the pin reads. One probe, of the pin itself — dist/index.js inside the built acp-agent derivation — was where three of the findings below came from.


MUST

1. Every pi conversation is told it has olai's MCP servers, and the shipped pin proves it has none.packages/chat/src/agents/pi.ts:16, packages/chat/src/agent.ts:1430

servers composes and announces rosterOf(handing, …) unconditionally, so a pi conversation draws olai (and kolu) at standing handed — which packages/surface/src/chat.ts:1366 defines as "olai gave it to the session and nothing has said what became of it" and Roster.tsx draws as a name with no mark. On this wire something has said. pi.ts:16-22 records it, and the pin confirms it: in pi-acp@0.0.33's dist/index.js, this.mcpServers = opts.mcpServers (:807) is the field's only reference — assigned at construction, read nowhere. Not one olai tool is reachable from pi, and no report will ever move the row (rawMessages: null, no forwarded init), so handed is where it stays for the life of the conversation.

servers.ts's own header is the standard this fails: the roster exists because "somebody asked an agent which MCP servers it had, and it answered wrong — opencode listed olai and deepwiki, omitted kolu", and "a roster that says a conversation has a server it was never given is worse than the silence it replaces, because it is the same wrong answer the model was giving, in a place a person has decided to trust." The panel now gives that answer itself, on pi, every time. docs/chat.md:33 states the consequence in prose — "no per-server ticks" — which reads as a missing tick rather than as these are unreachable; a person looking at the roster is looking at the list, not at the doc.

The leg is the file allowed to know this. unattached already carries a why for exactly "handed over and it did not attach" — a leg-declared standing for this wire ("pi-acp accepts mcpServers and wires them to nothing") turns the diff's best-established wire fact into the one place the panel answers the question. As it stands this is the house law inverted: the capability claim the panel draws is the one the spike disproved.

SHOULD

2. The prologue arm is never disarmed, and the load's disarm lands after the replay it claims to protect.packages/chat/src/agent.ts:1499

The comment says arming at the load is "really disarming: whatever a previous conversation of this process announced, nothing of it may consume a chunk of THIS session's replay." It cannot: forty lines above, :1471 says "The flag is set before the call because a load replays THEN answers", and the disarm is written after the answer. The replay's agent_message_chunks reach onUpdate in between — loadSession has run leaving(); session = null and load has run closed.delete(id), so fromElsewhere passes them — with the previous arm still live. leaving() (:513) clears the questions, the calls, the model and the roster, and not this.

On 0.0.33 nothing reaches it: sendStartupInfoIfPending emits exactly preludeText, once (startupInfoSent), on a setTimeout(…, 0) behind the session/new return (dist/index.js:2032), so the arm is always consumed a macrotask later and the microtask argument in the PR body is comfortably true. But that makes the body's other sentence only half right — "an adapter that reorders ships the banner to the transcript, which is the safe direction." On a reorder the banner shows (safe) and the arm is left loaded for the life of the process, waiting for the first later chunk that happens to equal it. Same for a banner streamed in pieces, which leg.ts already anticipates. prologue = null inside leaving() is one line and makes both comments true.

3. chat.md does not carry the prologue posture the PR body says it does.docs/chat.md:33

The body lists "chat.md (pi row in the list, plus its rules — not-wired tools, queue interlude, π fair play, prologue posture)". What landed is the pi row, the two variable sentences, and three "does not offer" bullets. There is nothing in chat.md about the banner, and nothing pi-specific about queueing. Olai silently drops an agent_message_chunk the agent really sent — that is squarely the class the section's own rule covers: "except where you would expect the behaviour, and then the absence is stated rather than left to be discovered." A person who runs pi in a terminal, sees the banner, and does not see it here has no sentence to find. (Also: the body quotes chat.md as saying "the tool was never wired to anything" — that string is nowhere in the tree; :33 says something else.)

NIT

4. Both halves of the session/list deferral are off.packages/chat/src/agents/pi.ts:65, packages/chat/src/agent.ts:1217

"paginates at 50 with a nextCursor the app does not follow (the app's own limit arrives first) — named in the leg". storedFor sends { cwd } — no limit, no cursor — reads no nextCursor, and nothing caps the chats list downstream. So PAGE_SIZE = 50 (dist/index.js:2449, with nextCursor a real offset string at :2457) is the app's ceiling rather than a limit that arrives second: a directory with >50 stored pi conversations silently loses the tail. And neither roadmap id (pi-list-pagination, pi-midturn-steering) appears anywhere in the tree, so the deferral is not "named in the leg" — the prose is, the id is not. Separately, :65's "a null-cursored page of FIFTY" reads as one observation; a spike against a directory of fewer than fifty sessions can only have seen the null, and the fifty is the adapter's source. (It is right — I checked — but it is the class of claim this PR's method exists to keep separate.)

5. The derivation now builds two adapters and is still labelled as one.nix/acp-agent.nix:29

version = "0.66.0"; # tracks @agentclientprotocol/claude-agent-acp" and meta.description/homepage all describe the Claude adapter alone, so a pi-acp bump in acp/package.json moves npmDepsHash and leaves the store path's name unchanged. The self-check in justfile is honest — it mirrors the claude half exactly, including the load-bearing one-dash ${VAR-…} assertion — so this is only the label. In the same spirit, adapter.ts:89's whyNoAgent still names only OLAI_ACP_AGENT and OLAI_AGENT_PATH; the empty-roster log line is the one somebody greps, and the pi door is not in it.


The fake is the real thing, not a happy-path subset: the setTimeout doubling, the byte-exact banner, the held turn with queueDepth/running on a session_info_update and the request answered in order, the terminal corners a client must not crash on, and the silent-turn arm kept deliberately beside the prologue. session/load disagreeing with pi's own store is untested and unstated on either side — worth a line in the leg even if the answer is "the generic refusal face".

srid added 8 commits August 28, 2026 18:49
`leaving()` clears every session-keyed value, and the doubled-prologue arm
was the one it did not: an arm whose banner never landed (a reordered
adapter) would wait the life of the process for the first chunk of ANY
later conversation equal to it — and the load path's replay lands before
the answer that rearmed it, with `fromElsewhere` passing the frames.
Disarm with everything else; the load's post-answer assignment is restated
as what it is: a rearm.
…handed

Two claims the section exists to hold (the rule: an expected absence is
said rather than discovered): pi's roster rows name a server that never
reached the agent from the open, and pi's adapter-published startup banner
is left out of the transcript rather than answered for — matched on the
open's own text, with the silent-turn collision named.
…labelled for what it builds

The paginated-at-50 passage read as one wire observation of the spike's;
it is a PAGE_SIZE from the pin's source, and olai follows no cursor — said
so, and the open half filed as pi-list-pagination where the tail is lost
(agent.ts's storedFor, the leg's list passage). The steering deferral's id
sits where the refusal is declared. The empty-roster line a greppable
reader lands on names the pi row's two halves too, and the acp-agent
derivation's name/version now name both pins, so a bump of either is a new
store path and a new name — never a silent rebuild under the old one.
The scripted pi-acp reported a prompt arriving mid-turn at position 0 —
its standing count was stood down at turn START, so the announcement only
ever fired between two queued items. Kept the running turn and the count
of waiters apart, the real adapter's two facts: a fresh mid-turn prompt
answers 'Queued message (position 1).' with the queue corner's
session_info_update, and the mid-turn scenario now reads the frame rather
than trusting the composer's word for it. The banner grows the real
buildStartupInfo's shape (sections and trailing blank, one chunk), and
the prologue fixture follows it byte for byte.
…d owns

Ruling, after review: the chats list showing pi's newest fifty (the
adapter's page, not an index) and the mid-turn message QUEUEING rather
than steering are things the user doc states, not items filed. docs/chat.md
says both plainly; the code comments that pointed at the filings now say
the posture instead.
getcwd(2) answers the physical path — /var/folders and
/private/var/folders are one tree, two spellings — so the fake's exact
string scope answered `[]` for a client started before a symlink: both
@agent-stored pi scenarios died 15s and 30s deep on darwin's e2e, the only
lane whose cwd was ever spelled that way. Scope by either spelling and
answer the row in the request's; the store's own spelling of a directory
pi never visited is not something a fake can answer for.
The PI lane was a branch where one lane got special-cased into the
kept verse — a second vocabulary beside AGENT= for the same resource.
The dance's all there is: one passthrough for every knob that reaches
the answer, empty is off, and the call carries both halves of the pi
row; the recipe decides nothing.
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.

1 participant