Skip to content

Plannotator Inbox, step 7: the Pi and OpenCode connections (plannotator_inbox and the reply wake) - #1763

Merged
backnotprop merged 2 commits into
mainfrom
feat/inbox-pi-opencode
Oct 7, 2026
Merged

backnotprop merged 2 commits into
mainfrom
feat/inbox-pi-opencode

Conversation

@backnotprop

@backnotprop backnotprop commented Oct 7, 2026 •

Copy link
Copy Markdown
Owner

The story

Pi and OpenCode now get the Plannotator Inbox the way Claude Code got it in step 6: a separate plannotator_inbox tool built from the running Inbox's own /mcp tool list, and the reply wake, so a person's Send in the Inbox reaches the Pi or OpenCode 2 session that asked as a new turn once that session is idle (OpenCode 1 gets the tool only). Both hosts share one node-runtime link (packages/shared/inbox/agent-link.ts) over step 6's contract (packages/shared/inbox/connection.ts, reused, not forked); the switch is off by default on both, and nothing is registered, polled or said where no inbox/inbox.json exists. It is proved on the real hosts: the installed Pi CLI in RPC mode and the installed OpenCode 2 package, each against the compiled Inbox, with a scripted model endpoint in place of a provider.

Plan step 7 (research/plans/plannotator-inbox-2026-10-06/PLAN.md). Branched from origin/main after step 6 (#1758) and rebased onto step 5 (#1760); no window change, so it does not stack on another lane.

What changed

  • packages/shared/inbox/agent-link.ts (new): discoverInboxTools (once at load/setup, only where the registry exists; tools/list bounded at 2 s, the mod's bound; a stopped Inbox is described by the list it last answered with, kept in inbox/connection-tools.json), InboxAgentConnection (the tool: re-reads the registry on every call, checks health against its serverSession, starts a stopped Inbox with plannotator inbox --background, proxies to /mcp with project_path, agent_session, agent_host, agent_name filled; no binary: "install Plannotator" once), InboxWake (one per session: 1 s tick, bridge long-poll with the 5 to 60 s backoff that never gives up, two idle ticks, a re-check with the Inbox, a mkdir claim inbox/claims/<reply id> for exactly-once across processes, inboxWakeText, delivered acknowledged; never interrupts). Imports only ./connection and node: modules so Pi can vendor it.
  • packages/shared/inbox/connection.ts: mcpAnswerOf moved here from the mod (pure, now shared); INBOX_TOOL_WAKE_NOTE made host-neutral; inboxAgentTool(tools, { wakes: false }) for OpenCode 1. The mod's byte-pinned copy apps/hook/hooks/mod/inbox-contract.ts re-pasted; apps/hook/hooks/mod/inbox.ts imports mcpAnswerOf from it.
  • Pi: apps/pi-extension/inbox.ts (new), called at the end of the factory in index.ts (the factory returns the bounded discovery for Pi to await only when the switch is on and the registry exists). pi.registerTool with defaultActive: false, made active by the first session_start with a UI (inactive in print/JSON). Listeners once at load. Busy = not ctx.isIdle(), queued messages, an "Ask this session" question running, or (once Pi has emitted agent_settled, 0.80.4+) a run not yet settled; older Pi settles on idle. Delivery pi.sendUserMessage(text, { deliverAs: "followUp" }), confirmed by that user message's message_start. Nothing aborts a run, the rule of pi-session-bridge.ts's run protection. vendor.sh vendors connection and agent-link into generated/inbox/; inbox.ts added to files.
  • OpenCode: apps/opencode-plugin/inbox.ts (new). OpenCode 2 (server.ts → registerInboxOpenCode2): the tool through ctx.tool.transform, signed by and woken in the ROOT session (resolveRootSession); a wake per root session made on its first tool call or first session.execution.started in this process; busy from execution events with the 20 s cross-check (session.wait, 300 ms bound; session.wait alone without an event stream); delivery session.prompt({ sessionID, id, text, delivery: "queue" }), never steer. OpenCode 1 (index.ts): the tool only, built with its own tool() and zod (inboxToolArgs), description without the wake line.
  • tests/parity/vendor-parity.test.ts: it matched only .js imports, and the package imports .ts everywhere, so it checked nothing; it now reads .ts (and dynamic) imports, single-file vendored outputs, and the generated/inbox/ prefix.
  • Proof helpers: tests/helpers/scripted-model.ts (an OpenAI-compatible streamed endpoint that keeps every request), tests/helpers/inbox-world.ts (temp HOME and data dir, the compiled binary or the CLI from source on PATH, the person's Send through the window's route).
  • CI: .github/workflows/inbox-pi.yml (new, path-gated: compile, Node 24, Pi floor 0.79.1, the Pi proofs and the parity test, transcripts uploaded); the "OpenCode 2 installed package" job in test.yml gains a compile step and the OpenCode proofs on the same packed tarball, transcripts uploaded.
  • AGENTS.md: the Inbox section (step 7 built; a "Pi and OpenCode (step 7)" paragraph), the PLANNOTATOR_INBOX_TOOL row.

What it makes real

PLAN.md step 7: the tool on Pi and OpenCode 2 registered only with the registry and the switch on (off by default); the wake once per reply as a Pi follow-up or an OpenCode 2 queued prompt, with the stable header, the fixed instruction line and the reply verbatim (never the thread, never the system prompt); take-over; silence without a registry; "install Plannotator" once; Pi without agent_settled still settles; OpenCode 1 tool only. Window record: the thread's existing "Delivered to , " now reads "Delivered to Pi" and "Delivered to OpenCode" (the agent_name the connection signs with); no UI change.

Proofs (transcripts in .local/proof/, uploaded by both CI jobs)

All against plannotator compiled from this branch with the release flags, under temp PLANNOTATOR_DATA_DIR and HOME, PLANNOTATOR_BROWSER=none.

  • Pi, apps/pi-extension/inbox.test.ts (the installed Pi 0.84.4 CLI in RPC mode through Pi's own RpcClient, -e this extension from source, a scripted model; 6 tests, 43 s):
    1. switch on and no registry: the tools Pi sends the model lack plannotator_inbox, no inbox/ folder, no notice; registry with the switch off: no tool; both: the tool with exactly the Inbox's actions (8, guides included), filled arguments absent.
    2. send_message lands a thread signed host: pi, name: Pi, the Pi session id; the person's Send arrives once as the user message the model receives last: Plannotator Inbox: <subject> (<reply id>), INBOX_WAKE_INSTRUCTION, the reply verbatim; the thread reads Delivered to Pi with the row's checked_at; the wake's send_message with reply_to lands in the same thread.
    3. a person's turn running: 0 wakes in 4 s, then the wake as a follow-up; a prompt steered into the wake's turn reaches the model; 1 wake, 0 aborted messages, delivered once.
    4. two Pi processes on one session (--session): 1 wake across both, one claim.
    5. a stopped Inbox is started by the call (new pid, message lands); with no plannotator on PATH or in ~/.local/bin, the first result carries INBOX_INSTALL_TEXT, the second only "not running".
    6. Pi 0.79.1 (PI_TEST_OLD_CLI, no agent_settled event seen): 1 wake, delivered.
  • OpenCode 2, apps/opencode-plugin/inbox-v2.test.ts (opencode2 2.0.19 serving, the packed @plannotator/opencode installed through a throwaway registry exactly as the smoke does, the scripted model as its provider, OpenCode's /api/event stream recorded; 2 tests, 14 s): silence, the switch, the tool from /mcp; send_message signed host: opencode, name: OpenCode; the reply waits 4 s while the person's turn runs; then ONE session row with delivery: "queue" carrying the wake text; Delivered to OpenCode; a prompt typed into the wake's turn reaches the model, 1 wake row, 0 session.execution.interrupted; the answer lands in the thread.
  • OpenCode 1, apps/opencode-plugin/inbox.test.ts (the plugin's OpenCode 1 entry with its own tool()/zod against a real Inbox): no tool without a registry or with the switch off; with both, the zod-produced schema has action required, no filled arguments, no wake line; a call lands a thread.
  • Claude Code unchanged in behaviour: apps/hook/hooks/mod/inbox.test.ts + inbox-contract.test.ts + packages/server/inbox-bridge.test.ts against the binary (17 pass); scripts/test-claude-code-mod.sh with a temp CLAUDE_CONFIG_DIR (24 pass); claude plugin validate apps/hook passes.

Commands:

bun run build:review && bun run build:hook && bun build apps/hook/server/index.ts --compile --no-compile-autoload-bunfig --define '__CLI_VERSION__="0.0.0-dev"' --outfile .local/plannotator
PLANNOTATOR_INBOX_TEST_BINARY=$PWD/.local/plannotator PI_TEST_OLD_CLI=<pi 0.79.1>/dist/cli.js INBOX_PROOF_DIR=.local/proof/inbox-pi bun test apps/pi-extension/inbox.test.ts tests/parity/vendor-parity.test.ts
bun run build:opencode && (cd apps/opencode-plugin && npm pack --ignore-scripts --pack-destination /tmp)
OPENCODE2_BIN=$(command -v opencode2) OPENCODE_PLUGIN_TARBALL=/tmp/plannotator-opencode-0.28.7.tgz PLANNOTATOR_INBOX_TEST_BINARY=$PWD/.local/plannotator INBOX_PROOF_DIR=.local/proof/inbox-opencode bun test apps/opencode-plugin/inbox-v2.test.ts apps/opencode-plugin/inbox.test.ts

Gates

  • bun run typecheck: pass. bun run check:release-version: v0.28.7 consistent.
  • bun test packages apps/hook tests scripts apps/pi-extension apps/opencode-plugin under a temp data dir with FORCE_COLOR unset: 6628 pass, 1462 skip, 0 fail (709 files). With this machine's FORCE_COLOR=3, annotate CLI on an occupied fixed port fails on main's code too (its Resolved: line starts with an ANSI escape); untouched here.
  • Pi parity gate: tests/parity/vendor-parity.test.ts (now real) passes; bun pm pack --dry-run in apps/pi-extension: 261 files, 43.98 MB unpacked, includes inbox.ts (8.17 KB), generated/inbox/agent-link.ts (19.22 KB), generated/inbox/connection.ts (14.00 KB); no inbox.html. apps/opencode-plugin: 13 files, 56.98 MB unpacked (dist/server.js 211.29 KB, dist/index.js 0.57 MB); no inbox.html.
  • gitleaks over origin/main..HEAD: no leaks. Semgrep 1.172.0 with .semgrep.yml over every new and changed source: no findings.
  • No DOM tests added; packages/ui and packages/core untouched (no ui smoke needed).

What it leaves out

An Inbox server inside Pi; the OpenCode 1 wake (no way to wake an idle OpenCode 1 session); Windows for the proofs (the world's plannotator wrapper is /bin/sh); a toast for OpenCode 2 (it has no toast surface; its one notice, a reply that could not be delivered, goes to the log, because a transcript notice would be a steer row left in an idle session, the #1734 trap).

Deviations from the plan, with reasons

  1. No per-session lease on Pi and OpenCode, only the claim. The mod's lease picks "the process the person last touched" when claude --continue runs twice. Two processes on one Pi session (pi --session twice) or one OpenCode session are rare, a lease is a second file protocol with its own staleness rules, and the mkdir claim alone already guarantees one delivery (proof 4). Which process delivers is whichever claims first.
  2. OpenCode 2 counts "delivered" on admission. session.prompt resolving means OpenCode durably admitted the row (its API: "Durably admit one session input and schedule agent-loop execution"); Pi's sendUserMessage returns nothing, so Pi waits for the user message's message_start (15 s watchdog while idle, then a notice with the thread link).
  3. The shared tool's wake line is now host-neutral ("as a message with the line Plannotator Inbox: <subject> (<reply id>)"), because "as a message from the plannotator plugin" is false on Pi and OpenCode. This changes the Claude Code tool description too (still under the 2,048-character cut, asserted by the mod proof). mcpAnswerOf moved into the shared contract rather than being copied a third time.
  4. inboxAgentTool(tools, { wakes: false }) for OpenCode 1 (and an OpenCode 2 host without session.prompt), so its description never promises a reply that cannot arrive.
  5. Scripted model endpoint in the host proofs. The owner's bar is no mocks; the host (real Pi binary, real installed OpenCode 2), the plugin and the Inbox are all real, and the endpoint receives exactly what a provider would. Only the model's words are scripted; there is no live model in CI.
  6. OpenCode 1 is proved through its plugin entry, not a live OpenCode 1; the plan's proofs name Pi and OpenCode 2 hosts.
  7. The 20 s cross-check is not exercised live: OpenCode 2 does not drop an end event on demand. It is the workspaces-agents rule (RUNNING_CROSS_CHECK_MS), read in code.
  8. OpenCode 2 wakes per root session, made on the first tool call or the first execution seen in the process, so a subagent's message wakes the session the person works in and a reply sent across an OpenCode restart still finds its session once it runs.
  9. The Pi tool is inactive in print/JSON mode, the existing rule for the plannotator tool: nothing could wake that session, and the description says replies arrive by themselves.
  10. The remembered tool list is a file, inbox/connection-tools.json (the mod keeps it in $.store); it lives in the Inbox folder, so uninstall --purge removes it with the claims.
  11. The start command honours PLANNOTATOR_BIN (as the OpenCode CLI bridge already does), then plannotator on PATH, then ~/.local/bin/plannotator. "Install Plannotator" is said in the first result only; later results in the session say the Inbox is not running.
  12. The vendor parity test was fixed (it matched nothing); this PR's new Pi imports and every existing one pass it.
  13. New CI time: a path-gated inbox-pi.yml job (build, compile, two Pi installs, about 45 s of proofs) and two steps on the existing OpenCode 2 job (a compile and about 15 s of proofs locally; a cold runner reinstalls the plugin per world, about 47 s each as the smoke measured). plannotator-ops to check runner time, per LANE-RULES.
  14. inbox/claims/ gains one small folder per delivered reply, as the mod's claims folder does; no pruning added.

After the first CI run

tests/e2e/inbox-guides.spec.ts:162 failed in CI (run 37659138886). Not caused by this branch, which changes no server, shim, store or window code. The spec opened the window before the two guide sends. A list already on screen holds a later arrival behind "N new" (held order, App.tsx adopts only an empty list), so the second row raced. A 2 s pause between the sends reproduces it locally on main's code. The fix opens the window after both sends (commit "Inbox guides e2e"). Rebased onto 12f21b6. Full bun run test:e2e:inbox: 51 passed. merge-tree clean.

@backnotprop
backnotprop force-pushed the feat/inbox-pi-opencode branch from 51ad708 to 7691dd7 Compare October 7, 2026 18:04
@backnotprop
backnotprop merged commit 93f6a98 into main Oct 7, 2026
43 of 44 checks passed
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