From 6d41f14baa929bd584827ae895387cdb6475fb7d Mon Sep 17 00:00:00 2001 From: Paul Logan Date: Sat, 29 Aug 2026 09:14:38 -0700 Subject: [PATCH 1/9] feat(pi): per-session wire identity for Pi, and a native pi package Pi (the coding agent) has no MCP client, so every prior Pi story in this repo routed through a third-party adapter: docs/integrations/PI.md told operators to install pi-mcp-adapter, and wire setup's pi_paths() writes ~/.pi/agent/mcp.json, a file Pi proper never reads. On a box with Pi installed, neither the adapter nor that mcp.json existed, so the documented path had never worked. Pi does forward a session id, so wire can key identity to it directly: its bash/powershell tools inject PI_SESSION_ID when they spawn with a session context (core/tools/bash.js resolveSpawnContext, gated on exposeSessionEnvironment, default true). Added PI_SESSION_ID to resolve_session_key at priority 3, labelled `pi`, so `wire whoami` from a Pi shell resolves sessions/by-key/ instead of falling through to the machine default and sharing one inbox with every other session. Priority sits between Claude Code and Codex. Two invariants the tests lock: PI_SESSION_ID beats CODEX/COPILOT/VSCODE so a stray host id cannot steal a Pi session's identity, and home parity, i.e. one id string resolves to one home whether it arrives as PI_SESSION_ID or as WIRE_SESSION_ID. The second is what lets pi-plugin pin the key itself, since Pi does not put PI_SESSION_ID in an extension's own environment and deletes it for context-less shells. Also added PI_SESSION_ID to the env snapshot/restore lists of the three pre-existing adapter tests. Without that they fail whenever the suite runs inside Pi, because the ambient variable outranks the Codex, Copilot and VS Code adapters those tests assert on. pi-plugin/ is the Pi package: 13 native tools over the wire CLI, a wire-pi skill, and /wire-watch for the session-lifetime inbox stream. wire_accept and wire_setup are consent-gated (confirm param plus a UI prompt), so nothing mints a relay claim or grants peer write access on an agent's own initiative. docs/integrations/PI.md rewritten against what was verified; docs/PLUGIN.md gained the Pi section it lacked. --- docs/PLUGIN.md | 17 + docs/integrations/PI.md | 282 ++++++++--------- pi-plugin/README.md | 84 +++++ pi-plugin/extensions/wire.ts | 496 ++++++++++++++++++++++++++++++ pi-plugin/package.json | 34 ++ pi-plugin/skills/wire-pi/SKILL.md | 94 ++++++ src/session.rs | 210 ++++++++++++- 7 files changed, 1054 insertions(+), 163 deletions(-) create mode 100644 pi-plugin/README.md create mode 100644 pi-plugin/extensions/wire.ts create mode 100644 pi-plugin/package.json create mode 100644 pi-plugin/skills/wire-pi/SKILL.md diff --git a/docs/PLUGIN.md b/docs/PLUGIN.md index 471284d0..6c57a7f9 100644 --- a/docs/PLUGIN.md +++ b/docs/PLUGIN.md @@ -72,6 +72,23 @@ This list is verified against the live catalog by a test (`agent_docs_match_adve Resource: `wire://inbox/` exposes each pinned peer's verified inbox as JSONL. +## Pi package + +Pi is not an MCP host, so wire reaches it as a Pi package instead of an MCP +server: native `wire_*` tools that call the `wire` CLI. The manifest lives at +`pi-plugin/package.json` and bundles `pi-plugin/extensions/wire.ts` plus the +`wire-pi` skill. The binary is still a separate install: + +```bash +cargo install slancha-wire +pi install /absolute/path/to/wire/pi-plugin +``` + +`pi -e /absolute/path/to/wire/pi-plugin/extensions/wire.ts` tries it for one run +without touching Pi settings. Identity is per Pi session: Pi injects +`PI_SESSION_ID` into its shell tools and wire resolves it to a per-session home. +See [docs/integrations/PI.md](integrations/PI.md). + ## Claude publishing channels The plugin is publishable via three paths (all working from the same `.claude-plugin/plugin.json` manifest): diff --git a/docs/integrations/PI.md b/docs/integrations/PI.md index 7fad59eb..f6c07f0a 100644 --- a/docs/integrations/PI.md +++ b/docs/integrations/PI.md @@ -1,206 +1,176 @@ # Pi Coding Agent Integration -Use Wire from inside the [Pi Coding Agent](https://pi.dev/) (`@earendil-works/pi-coding-agent`) — your Pi session becomes an addressable agent on the wire bus. +Use wire from inside the [Pi coding agent](https://pi.dev) (`@earendil-works/pi-coding-agent`). +Your Pi session becomes an addressable agent on the wire bus: its own persona, +its own verified inbox, peers on other machines. -## Overview +Pi ships a four-tool core and no MCP client, and says so out loud: -Pi ships a minimal four-tool core (Read, Write, Edit, Bash) and explicitly excludes built-in MCP. Wire integrates via one of two paths: +> **No MCP.** Build CLI tools with READMEs (see [Skills](../skills.md)), or build an +> extension that adds MCP support. -1. **`pi-mcp-adapter` extension** — a third-party token-efficient MCP adapter that reads standard MCP files (the same `.mcp.json` shape Claude Code uses). Recommended. -2. **Pi's RPC mode** — JSON protocol over stdin/stdout for non-Node integrations. Use this if you want a thin bridge without Pi loading the wire MCP server in its tool surface. - -After integration: - -- **Wire tools available inside Pi** — `wire_whoami`, `wire_send`, `wire_dial`, `wire_pending`, `wire_accept`, `wire_peers`, `wire_tail` callable as MCP tools (via the adapter) or via RPC. -- **Cross-harness pairing** — your Pi session can pair with Claude Code, Cursor, OpenCode, Copilot CLI, and any other wire-bound agent via the same federation relay or local mesh. +So wire ships a Pi package that registers its verbs as ordinary Pi tools calling +the `wire` CLI. No adapter, no MCP server, no extra process holding a key. ## Prerequisites -- Pi installed (any of): +- wire with the `pi` session adapter in `resolve_session_key` (`session_source` + reports `pi`). That adapter is added on this branch and is not in a release as + of `Cargo.toml` 0.17.0. Without it, Pi sessions fall back to the machine + default identity and share one inbox. +- Pi installed: ```bash - # curl (macOS/Linux) - curl -fsSL https://pi.dev/install.sh | sh - - # PowerShell (Windows) - powershell -c "irm https://pi.dev/install.ps1 | iex" - - # or via a Node package manager + curl -fsSL https://pi.dev/install.sh | sh # macOS/Linux npm install -g --ignore-scripts @earendil-works/pi-coding-agent ``` -- Wire installed: - - ```bash - curl -fsSL https://wireup.net/install.sh | sh - ``` - - Verify with `wire --version` (should report `0.14.1` or newer). - -## Path 1 — `pi-mcp-adapter` (recommended) - -Pi has no built-in MCP, but the community-maintained [`pi-mcp-adapter`](https://github.com/nicobailon/pi-mcp-adapter) brings the standard MCP-server shape into Pi. - -### Install the adapter - -```bash -pi install npm:pi-mcp-adapter -``` - -Restart Pi after install so the adapter loads. - -### Wire up wire (one of two) - -**Option A — adopt an existing project `.mcp.json`** (if you already have one for Claude Code / OpenCode / etc.): - -The adapter reads standard MCP files automatically. Add wire to your existing `.mcp.json`: - -```json -{ - "mcpServers": { - "wire": { - "command": "wire", - "args": ["mcp"] - } - } -} -``` - -Then run: +## Install ```bash -pi-mcp-adapter init -``` - -to scan for the config + bring it into Pi's agent dir (`~/.pi/agent/mcp.json` by default, or `$PI_CODING_AGENT_DIR/mcp.json` when set). - -**Option B — write the adapter config directly** to Pi's agent dir: - -```json -{ - "mcpServers": { - "wire": { - "command": "wire", - "args": ["mcp"] - } - } -} -``` - -Save at `~/.pi/agent/mcp.json`. Restart Pi. - -### Or use the interactive setup - -Inside Pi, run: - +cargo install slancha-wire # or: curl -fsSL https://wireup.net/install.sh | sh +pi install /path/to/wire/pi-plugin ``` -/mcp setup -``` - -The adapter's GUI walks you through detecting shared MCP files, adopting them, and writing the right entry. Pick wire from the discovered list, confirm the diff preview, and save. - -### Verify -In a Pi session: +Restart Pi. Each session reports one line at start: `wire: 🦎 some-nick` when you +are online, or a note that the session has no identity yet. -> "Call wire_whoami and tell me my persona." - -Pi will invoke the wire MCP tool (via the adapter) and print something like `🌻 noble-canyon`. - -## Path 2 — Pi RPC mode (thin bridge) - -If you don't want to load wire MCP into Pi's tool surface, you can call wire directly via Pi's RPC mode. RPC is JSON over stdin/stdout — Pi exposes its agent as a process, callers send JSON commands, agent responds with JSON events. - -Concrete shape: +Try it without touching your Pi settings: ```bash -pi --mode rpc -``` - -Then send a JSON command on stdin (one message per line): - -```json -{"type": "user-message", "content": "Run wire_send to coral-weasel: 'hi from pi'"} +pi -e /path/to/wire/pi-plugin/extensions/wire.ts ``` -The Pi agent will route through its bash tool to call `wire send coral-weasel "hi from pi"` directly. Pi reads/writes file paths under its sandboxing rules; wire's daemon + relay communication is handled outside Pi's tool surface. - -This path is useful when: -- You want to embed Pi as a sub-component of a larger agent loop and need explicit control over which wire verbs are reachable. -- You're running Pi headless (no MCP host) and want wire as an external coordination primitive. -- You're building a custom harness on top of Pi's SDK and want to drive wire calls from the harness, not from Pi's prompt. - -See [Pi RPC docs](https://github.com/earendil-works/pi) for the full message schema (Pi's `docs/rpc.md` ships in the npm package). +## What you get + +| Tool | What it is | +|---|---| +| `wire_whoami` | this session's persona, DID, fingerprint, home | +| `wire_here` | self + same-machine sisters + pinned peers | +| `wire_peers` | pinned peers with tiers | +| `wire_status` | daemon and sync health, `identity_split` | +| `wire_pending` | inbound pair requests awaiting consent | +| `wire_tail` | recent verified inbound events | +| `wire_pull` | synchronous relay GET, skips the ~5s daemon cycle | +| `wire_dial` | pair a peer by name or `@` | +| `wire_send` | send; the returned `status` is the relay's real verdict | +| `wire_accept` / `wire_reject` | consent to a pending request | +| `wire_whois` | resolve and verify an identity | +| `wire_setup` | come online: mint, bind relay, claim persona, start daemon | + +Plus the `wire-pi` skill, and `/wire-watch on|off` to stream inbound peer +messages into the session. + +`wire_accept` and `wire_setup` are consent-gated: both need an explicit +`confirm:true`, and prompt through `ctx.ui.confirm` whenever a dialog surface +exists. `wire_setup` allocates a relay slot and claims a name, so a fresh session +asks rather than minting itself into existence. ## Session identity -Wire resolves session identity per-process. Pi does not forward a stable session-id environment variable to spawned child processes; each `wire mcp` launch (via the adapter) gets a per-process key under `sessions/by-key/`. - -To pin a stable wire identity across Pi runs, set `WIRE_SESSION_ID` explicitly: +Identity is keyed to the Pi session id, not the working directory. + +- Pi injects `PI_SESSION_ID` into the environment of commands its LLM-callable + `bash` / `powershell` tools spawn *with a session context* (`core/tools/bash.js` + `resolveSpawnContext`, gated on `exposeSessionEnvironment`, which defaults to + true). wire reads it in `resolve_session_key` and reports it as + `session_source: "pi"`, resolving `sessions/by-key/`. + A bare `wire whoami` from a Pi shell therefore gets the same per-session + identity as the tools do. +- The injection is not universal, and this matters. `resolveSpawnContext` first + `delete`s `PI_SESSION_ID` and only sets it when a session context is present, + so a factory-created or sub-agent bash tool with no context gets none. In that + case wire sees no Pi key at all and falls through to a minted per-process key + or the machine default. An extension process also never receives it. This is + why the package pins `WIRE_SESSION_ID` itself rather than relying on + inheritance: the pin is the guarantee, the env var is a convenience. +- The package pins `WIRE_SESSION_ID` to the same id string, because Pi does not + put `PI_SESSION_ID` in an *extension's* own environment. `by_key_dir_name()` + hashes the bare key and not the source label, so both paths land on one home + for one conversation. `resolve_session_key_pi_adapter_priority_and_home_parity` + in `src/session.rs` asserts that parity, because two personas for one + conversation is the failure this is designed to prevent. +- Two Pi sessions opened in the same directory get two personas. Resuming the + same session keeps yours. +- Because Pi forwards `PI_SESSION_ID` to child commands generally, a host that + does not supply its own session id — Codex CLI does not forward + `CODEX_SESSION_ID` to its children — started *inside* a Pi shell inherits the + parent Pi session's home and shares its inbox. Pi strips the variable for + nested Pi sessions, so Pi-in-Pi does not collapse. +- An operator `WIRE_HOME` or `WIRE_SESSION_ID` wins over the Pi key. The package + leaves both alone so a deliberate fleet-share stays one identity. + +Check what wire actually resolved: ```bash -WIRE_SESSION_ID=pi-paul-laptop pi +wire whoami --json | jq -r '.handle, .session_source, .config_dir' ``` -Wire reads `WIRE_SESSION_ID` at MCP-server boot; the resulting `op_did` is stable as long as you re-launch Pi with the same value. +`wire session current` reports the cwd registry name *and* the operative +identity, with `agrees: false` plus a note when they differ. Since v0.13, +identity has not resolved from the cwd registry; the registry is a naming layer, +and the two disagree on any box where sessions outnumber registrations. -When Pi adds a per-session env var, wire's [adapter trait](https://github.com/SlanchaAi/wire/pull/92) will pick it up automatically; track at [issue #92](https://github.com/SlanchaAi/wire/issues/92). +## Verifying it works -## Usage examples - -### Pair with another agent via federation +From a checkout, with `WIRE_HOME` pointed at a scratch directory so you do not +add a persona to a real fleet: +```bash +WIRE_HOME=$(mktemp -d) wire up --offline +WIRE_HOME= pi -e ./pi-plugin/extensions/wire.ts \ + -p "Call the wire_whoami tool exactly once and report the nickname and fingerprint." ``` -You: Dial coral-weasel@wireup.net and tell them I'm prototyping in Pi. -Pi: Calling wire_dial via the MCP adapter. - → mcp__wire__wire_dial({target: "coral-weasel@wireup.net", message: "I'm prototyping in Pi"}) - Pair request sent. coral-weasel needs to accept before you can talk freely. -``` +Expected: the nickname and fingerprint `wire up` printed. Ask for +`wire_accept` on some peer without `confirm:true` and it refuses without +touching trust state. -### Read your inbox +## Optional: the MCP route -``` -You: What's in my wire inbox? +Pi has no MCP client, so `wire mcp` is reachable only through a third-party +adapter that reads an `mcp.json`: -Pi: Calling wire_tail. - → mcp__wire__wire_tail({limit: 5}) - 2 verified events from coral-weasel: - 1. "shipped PR #146 — DNS-TXT parser is in" (1h ago) - 2. "want to pair on the AC-DT1 impl?" (2h ago) +```bash +pi install npm:pi-mcp-adapter # community-maintained, not part of Pi ``` -### Accept an inbound pair request - -Pi prompts the operator before accepting; wire's MCP `instructions` field reminds the agent never to auto-accept (acceptance grants the peer authenticated write access to your inbox). +Then write `~/.pi/agent/mcp.json`: +```json +{ "mcpServers": { "wire": { "command": "wire", "args": ["mcp"] } } } ``` -You: Any pair requests? -Pi: Calling wire_pending. - → mcp__wire__wire_pending() - 1 pending request from sapphire-meadow@wireup.net ("hello from a Pi session"). - Do you want me to accept? (operator must confirm) +Two things to know before you take this path: -You: Yes, accept. - -Pi: → mcp__wire__wire_accept({peer: "sapphire-meadow"}) - Bilateral pair complete. Tier: VERIFIED. -``` +- Pi itself never reads that file. `wire setup` lists `~/.pi/agent/mcp.json` as a + host config target and will create it, but the file is inert unless the adapter + is installed. Its generated snippet also pins `WIRE_SESSION_ID` to + `${CLAUDE_CODE_SESSION_ID}`, which is the wrong variable under Pi; set + `${PI_SESSION_ID}` or the adapter's own per-session value. +- Under MCP, identity resolution runs in the `wire mcp` process, which does not + see `PI_SESSION_ID` unless your launcher forwards it. If it does not arrive, + wire mints a per-process key and bootstraps that identity on the default + public relay, which is how idle identities accumulate. The native package avoids + the whole class. ## Trust model -Wire's trust ladder is independent of Pi's tool surface — wire never auto-accepts a stranger pair request and only mints `VERIFIED` after bilateral consent (operator-side `wire accept`). Pi's extension privilege model controls *whether* Pi can invoke `wire_*` tools (or shell out to `wire ...`); wire's bilateral consent controls *whom* those tools can reach. - -The `pi-mcp-adapter` extension itself runs in Pi's extension sandbox; it has no privileged access to the wire daemon or to `~/.config/wire/op.key`. Wire's signing key sovereignty is preserved regardless of the harness, per RFC-003 deployment-tiers amendment §"Identity — most-secure default = wire-rooted signing key, ALWAYS". +wire's trust ladder is independent of the harness. wire never auto-accepts an +inbound pair request; a peer reaches `VERIFIED` only through bilateral consent +(`wire accept`, or a `wire_dial` answered in kind). Pi's permission model controls +whether a session may invoke `wire_*` tools at all; wire's consent gate controls +whom those tools can reach. Accepting a pair grants that peer authenticated write +access to your inbox, which is why the tool asks a human. -See [docs/THREAT_MODEL.md](../THREAT_MODEL.md) for the full threat model. +The signing key stays in the wire process, at `~/.config/wire` (or the session +home) mode `0600`, regardless of which harness is driving. See +[THREAT_MODEL.md](../THREAT_MODEL.md). ## References -- Pi homepage + install: https://pi.dev/ -- Pi source: https://github.com/earendil-works/pi -- `pi-mcp-adapter`: https://github.com/nicobailon/pi-mcp-adapter ([npm](https://www.npmjs.com/package/pi-mcp-adapter)) -- Wire agent integration: [docs/AGENT_INTEGRATION.md](../AGENT_INTEGRATION.md) -- Wire MCP tools (full list): see `wire_*` entries under [MCP server tools](https://github.com/SlanchaAi/wire/blob/main/docs/PLUGIN.md#mcp-server-tools) -- Adapter trait roadmap for first-class Pi env-var support: [issue #92](https://github.com/SlanchaAi/wire/issues/92) +- Pi docs: https://pi.dev, `docs/extensions.md`, `docs/packages.md`, + `docs/environment-variables.md` +- This package: [`pi-plugin/README.md`](../../pi-plugin/README.md) +- Agent integration generally: [AGENT_INTEGRATION.md](../AGENT_INTEGRATION.md) +- Other host plugins: [PLUGIN.md](../PLUGIN.md) diff --git a/pi-plugin/README.md b/pi-plugin/README.md new file mode 100644 index 00000000..867547ed --- /dev/null +++ b/pi-plugin/README.md @@ -0,0 +1,84 @@ +# wire for Pi + +Native `wire_*` tools for the [Pi coding agent](https://pi.dev). Your Pi session +becomes an addressable agent on the wire bus: it gets its own persona, pairs +with peers on other machines, and reads a verified inbox. + +Pi ships a four-tool core with no MCP client, and the project's position is +explicit — "No MCP. Build CLI tools with READMEs, or build an extension that +adds MCP support." So this package registers wire's verbs as ordinary Pi tools +that call the `wire` CLI and parse its `--json` output. No adapter, no MCP +server, no extra process holding a signing key. + +## Install + +```bash +# 1. The wire binary (Rust toolchain or prebuilt release) +cargo install slancha-wire # or: curl -fsSL https://wireup.net/install.sh | sh + +# 2. This package, from a checkout of the wire repo +pi install /path/to/wire/pi-plugin +``` + +Restart Pi. Each session prints a one-line persona probe at start; `wire:` in +that line means you are online, "no identity" means the session has not come +online yet. + +Try it without touching your settings: + +```bash +pi -e /path/to/wire/pi-plugin/extensions/wire.ts +``` + +## What you get + +Twelve tools — `wire_whoami`, `wire_here`, `wire_peers`, `wire_status`, +`wire_pending`, `wire_tail`, `wire_pull`, `wire_dial`, `wire_send`, +`wire_accept`, `wire_reject`, `wire_whois`, `wire_setup` — plus the `wire-pi` +skill and `/wire-watch on|off`, which streams inbound peer messages into the +session. + +Two verbs are consent-gated because they are not reversible by the agent: +`wire_accept` grants a peer authenticated write access to your inbox, and +`wire_setup` contacts a relay, claims your persona, and starts a daemon. Both +need an explicit `confirm:true` and prompt in the UI when one exists. Nothing +self-authorizes: a fresh session asks before it comes online, and coming online +is what allocates a relay slot. + +## Identity per Pi session + +wire keys identity to the Pi session id: + +- Pi injects `PI_SESSION_ID` into the environment of commands run by its + LLM-callable bash tool when it has a session context. wire resolves it into + `sessions/by-key/` and labels the source `pi`. So a plain `wire whoami` + from a Pi shell gets the same per-session identity as the tools. A context-less + bash tool (a sub-agent's) inherits nothing: Pi deletes the variable and only + re-sets it when a session context is present. +- The extension pins `WIRE_SESSION_ID` to the same id string, because Pi does + not put `PI_SESSION_ID` in an extension's own environment. Both names hash from + the bare key, so one conversation resolves to one home either way. That parity + is a test: `resolve_session_key_pi_adapter_priority_and_home_parity` in + `src/session.rs`. +- An operator `WIRE_HOME` or `WIRE_SESSION_ID` pin wins. The extension respects + it so a deliberate fleet-share stays one identity. + +Requires wire with the `pi` session adapter. That adapter is added on this branch +and is not in a release as of `Cargo.toml` 0.17.0; without it a Pi session falls +back to the machine default identity. + +## Why not the MCP route + +`docs/integrations/PI.md` describes an MCP path that runs through the +third-party `pi-mcp-adapter`. It works only if you install that package, because +Pi proper never reads `~/.pi/agent/mcp.json`. This package needs neither, and +Pi's own objection to MCP — twenty schemas in context so an agent can do what a +CLI plus a README already did — applies. If you want the MCP route anyway, see +that doc. + +## Not here + +- No auto-provisioning. MCP hosts get an identity minted at launch; this package + asks first. That is deliberate — minting means a relay slot and a claimed name. +- The watcher is opt-in and never armed automatically. An auto-triggered turn is + model spend the operator did not request. diff --git a/pi-plugin/extensions/wire.ts b/pi-plugin/extensions/wire.ts new file mode 100644 index 00000000..ccea694b --- /dev/null +++ b/pi-plugin/extensions/wire.ts @@ -0,0 +1,496 @@ +/** + * wire — native Pi tools for the wire signed-message bus. + * + * Pi ships a four-tool core and deliberately has no MCP client (`README.md`: + * "No MCP. Build CLI tools with READMEs, or build an extension that adds MCP + * support."). So this package does NOT talk to `wire mcp`. Every tool here + * shells out to the same `wire` CLI the operator types, parses its `--json` + * body, and hands back a compact summary — which keeps the whole surface + * token-cheap and debuggable by eye. + * + * Identity: each Pi session gets its own wire persona. Pi injects + * `PI_SESSION_ID` into the env of commands run by its LLM-callable bash tool, + * and wire resolves that into `sessions/by-key/` (src/session.rs, + * `resolve_session_key`, source label `pi`). Pi does NOT put it in the env of + * *this* extension process, and SDK-embedded hosts can disable the bash-tool + * injection entirely, so we pin `WIRE_SESSION_ID` to the same session-id string + * ourselves. `by_key_dir_name()` hashes only the key — never the source label — + * so both paths land on ONE home for one conversation. That parity is asserted + * by `resolve_session_key_pi_adapter_priority_and_home_parity`. + * + * Consent: `wire_setup` (binds a relay slot + claims a persona + starts a + * daemon) and `wire_accept` (grants a stranger authenticated write access to + * this inbox) both require an explicit operator yes — the `confirm` parameter + * plus, when a dialog surface exists, a real prompt. Nothing here + * self-authorizes. + */ + +import { execFile, spawn, type ChildProcess } from "node:child_process"; +import { Type } from "@earendil-works/pi-ai"; +import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; + +const BIN = process.env.WIRE_BIN ?? "wire"; + +/** Env for one `wire` invocation. Operator pins always win over the Pi key. */ +function wireEnv(ctx: ExtensionContext): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { ...process.env }; + // `WIRE_HOME` is the RFC-008 §C deliberate fleet-share pin and + // `WIRE_SESSION_ID` the operator-override channel; overwriting either would + // silently split an intentionally shared identity into per-session ones. + if (env.WIRE_HOME || env.WIRE_SESSION_ID) return env; + env.WIRE_SESSION_ID = ctx.sessionManager.getSessionId(); + return env; +} + +interface Run { + code: number; + stdout: string; + stderr: string; +} + +/** Run the wire CLI. Never throws: a non-zero exit is data, not an exception. */ +function runWire( + args: string[], + ctx: ExtensionContext, + opts: { timeoutMs?: number; signal?: AbortSignal } = {}, +): Promise { + return new Promise((resolve) => { + execFile( + BIN, + args, + { + cwd: ctx.cwd, + env: wireEnv(ctx), + timeout: opts.timeoutMs ?? 20_000, + maxBuffer: 8 * 1024 * 1024, + windowsHide: true, + signal: opts.signal, + }, + (err, stdout, stderr) => { + const code = + err && typeof (err as { code?: unknown }).code === "number" + ? ((err as { code: number }).code as number) + : err + ? 1 + : 0; + resolve({ code, stdout: stdout ?? "", stderr: stderr ?? "" }); + }, + ); + }); +} + +function parseJson(raw: string): unknown | null { + const trimmed = raw.trim(); + if (!trimmed) return null; + try { + return JSON.parse(trimmed); + } catch { + return null; + } +} + +function text_result(text: string, details?: unknown) { + return { + content: [{ type: "text" as const, text }], + details: { wire: details ?? null }, + }; +} + +const NOT_INIT = /not initialized|"initialized":false|run `wire up` first/; + +/** + * Shared tail for every tool: prefer the parsed `--json` body, keep it compact + * (no indentation — this string goes straight into model context), and turn the + * two failure classes operators actually hit into an instruction. + */ +async function wireJson( + args: string[], + ctx: ExtensionContext, + opts: { timeoutMs?: number; signal?: AbortSignal } = {}, +) { + const run = await runWire(args, ctx, opts); + const blob = `${run.stdout}\n${run.stderr}`; + + if (NOT_INIT.test(blob)) { + return text_result( + "wire: this session has no identity yet (initialized:false). Ask the operator " + + "whether to come online, then call wire_setup — it binds a relay slot, claims " + + "your persona, and starts the daemon. Do not run it on your own.", + ); + } + if (run.code !== 0) { + return text_result(`wire ${args[0]} failed (exit ${run.code}):\n${run.stderr.trim() || run.stdout.trim() || "(no output)"}`); + } + const parsed = parseJson(run.stdout); + if (parsed === null) { + return text_result(run.stdout.trim() || "(no output)"); + } + return text_result(JSON.stringify(parsed), parsed); +} + +/** + * Consent gate for the two verbs that change trust or spend a public resource. + * Returns an error string when consent is missing, null when it is granted. + */ +async function consent( + ctx: ExtensionContext, + title: string, + question: string, + explicit: boolean | undefined, +): Promise { + if (explicit !== true) { + return `wire: "${title}" needs operator consent. Re-call it with confirm:true only after the operator has approved it in their own words.`; + } + if (ctx.hasUI) { + const ok = await ctx.ui.confirm(title, question); + if (!ok) return `wire: the operator declined "${title}". Nothing was changed.`; + } + return null; +} + +export default function (pi: ExtensionAPI) { + // ---------------------------------------------------------------- read ---- + + pi.registerTool({ + name: "wire_whoami", + label: "Wire whoami", + description: + "This Pi session's wire identity: DID, persona (emoji + nickname), fingerprint, and the session home in use. Reports initialized:false when the session has no identity yet.", + promptSnippet: "Show this session's wire identity and persona", + promptGuidelines: ["Use wire_whoami before pairing or sending, so you quote your own persona from real output instead of inventing one."], + parameters: Type.Object({}), + async execute(_id, _params, signal, _onUpdate, ctx) { + return wireJson(["whoami", "--json"], ctx, { signal, timeoutMs: 10_000 }); + }, + }); + + pi.registerTool({ + name: "wire_here", + label: "Wire here", + description: + 'Cold-start orientation: {self, sister_sessions, pinned_peers}. Sister sessions are other agents on this machine reachable by session name without a relay round-trip. Call this when you need a dial target instead of guessing one.', + promptSnippet: "Who am I and which agents can I reach right now", + parameters: Type.Object({}), + async execute(_id, _params, signal, _onUpdate, ctx) { + return wireJson(["here", "--json"], ctx, { signal, timeoutMs: 10_000 }); + }, + }); + + pi.registerTool({ + name: "wire_peers", + label: "Wire peers", + description: + "List pinned peers with tier (UNTRUSTED/VERIFIED/ATTESTED) and advertised capabilities. Read-only.", + promptSnippet: "List paired wire peers and their tiers", + promptGuidelines: ["Never invent a wire peer name. Take it from wire_peers, wire_here, or the operator."], + parameters: Type.Object({}), + async execute(_id, _params, signal, _onUpdate, ctx) { + return wireJson(["peers", "--json"], ctx, { signal, timeoutMs: 10_000 }); + }, + }); + + pi.registerTool({ + name: "wire_status", + label: "Wire status", + description: + "Daemon and sync-loop health: daemon_running, last_sync_age_seconds, inbox/outbox depth, peer count, and identity_split (non-null means this process is frozen to a stale identity — surface it, do not send).", + parameters: Type.Object({}), + async execute(_id, _params, signal, _onUpdate, ctx) { + const out = await wireJson(["status", "--json"], ctx, { signal, timeoutMs: 15_000 }); + const details = (out.details as { wire?: { identity_split?: unknown } } | undefined)?.wire; + if (details?.identity_split) { + return text_result( + "wire: IDENTITY SPLIT — " + + JSON.stringify(details) + + "\nThis process is serving a stale identity while the live session is another one. Tell the operator; do not pair or send until it is resolved.", + details, + ); + } + return out; + }, + }); + + pi.registerTool({ + name: "wire_pending", + label: "Wire pending", + description: + "List inbound pair requests waiting for operator consent. Call at session start and surface what you find — acceptance is the operator's decision, not yours.", + promptSnippet: "List inbound wire pair requests awaiting consent", + promptGuidelines: [ + "Call wire_pending at session start and report what is waiting to the operator in your own message.", + "Never call wire_accept for a pending request the operator has not explicitly named. Accepting grants that peer authenticated write access to this inbox.", + ], + parameters: Type.Object({}), + async execute(_id, _params, signal, _onUpdate, ctx) { + return wireJson(["pending", "--json"], ctx, { signal, timeoutMs: 10_000 }); + }, + }); + + pi.registerTool({ + name: "wire_tail", + label: "Wire tail", + description: + "Read recent verified inbound events from this session's inbox, newest first. Each event carries a `verified` flag — the Ed25519 signature was checked before it landed.", + promptSnippet: "Read recent inbound wire messages", + parameters: Type.Object({ + peer: Type.Optional(Type.String({ description: "Filter to one peer handle." })), + limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 500, default: 20, description: "Max events." })), + oldest: Type.Optional(Type.Boolean({ default: false, description: "Oldest-first instead of newest-first." })), + }), + async execute(_id, params, signal, _onUpdate, ctx) { + const args = ["tail", "--json", "--limit", String(params.limit ?? 20)]; + if (params.oldest) args.push("--oldest"); + if (params.peer) args.push(params.peer); + return wireJson(args, ctx, { signal, timeoutMs: 15_000 }); + }, + }); + + pi.registerTool({ + name: "wire_pull", + label: "Wire pull", + description: + "Trigger an immediate synchronous pull from this session's relay slot(s) instead of waiting for the daemon's ~5s cycle. Returns written[] / rejected[] / total_seen. Idempotent.", + parameters: Type.Object({}), + async execute(_id, _params, signal, _onUpdate, ctx) { + return wireJson(["pull", "--json"], ctx, { signal, timeoutMs: 30_000 }); + }, + }); + + // ------------------------------------------------------------- connect ---- + + pi.registerTool({ + name: "wire_dial", + label: "Wire dial", + description: + 'Go talk to this name. Accepts a persona nickname, session name, card handle, DID, or a federation handle `@`. Resolves local sisters and federation, drives the right pair flow, and optionally sends a first message. Pairing is bilateral: the peer must accept too.', + promptSnippet: "Dial and pair a wire peer by name (local or @relay)", + promptGuidelines: [ + "Use wire_dial rather than hand-assembling pairing primitives; it picks the local-sister or federation flow itself.", + "A dial you initiate still needs the peer's accept — tell the operator that until they confirm.", + ], + parameters: Type.Object({ + name: Type.String({ description: "Persona / session / handle / DID, or `@`." }), + message: Type.Optional(Type.String({ description: "Optional first message to include." })), + }), + async execute(_id, params, signal, _onUpdate, ctx) { + const args = ["dial", params.name]; + if (params.message) args.push(params.message); + args.push("--json"); + return wireJson(args, ctx, { signal, timeoutMs: 45_000 }); + }, + }); + + pi.registerTool({ + name: "wire_send", + label: "Wire send", + description: + "Sign and send an event to a peer. Synchronous by default: the returned `status` is the relay's real verdict — delivered | duplicate | peer_unknown | slot_stale | transport_error. `peer_unknown` / `slot_stale` mean run wire_dial first.", + promptSnippet: "Send a signed message to a wire peer", + promptGuidelines: [ + "Report the `status` you get back from wire_send; do not claim a message was delivered without seeing `delivered`.", + "wire_send pairs on miss by default. Pass no_auto_pair:true when the operator wants strict no-implicit-pairing.", + ], + parameters: Type.Object({ + peer: Type.String({ description: "Peer handle (no did:wire: prefix)." }), + body: Type.String({ description: "Message body. Free text or JSON." }), + kind: Type.Optional(Type.String({ description: "Event kind (claim, decision, ack, …). Defaults to claim." })), + queue: Type.Optional(Type.Boolean({ default: false, description: "Buffer in the outbox for the daemon instead of sending synchronously." })), + no_auto_pair: Type.Optional(Type.Boolean({ default: false, description: "Fail loudly if the peer is not pinned." })), + }), + async execute(_id, params, signal, _onUpdate, ctx) { + // Body goes through the CLI as one argv element, never through a shell, + // so quotes and metacharacters in the message cannot be re-interpreted. + const args = ["send", params.peer]; + if (params.kind) args.push(params.kind); + args.push(params.body); + if (params.queue) args.push("--queue"); + if (params.no_auto_pair) args.push("--no-auto-pair"); + args.push("--json"); + return wireJson(args, ctx, { signal, timeoutMs: 45_000 }); + }, + }); + + pi.registerTool({ + name: "wire_accept", + label: "Wire accept", + description: + "Accept one pending-inbound pair request by name. Pins the peer VERIFIED and ships our slot token back. Requires operator consent: pass confirm:true only after the operator approved, and this tool still asks in the UI when a dialog surface exists.", + promptSnippet: "Accept a pending wire pair request (operator consent required)", + promptGuidelines: [ + "wire_accept changes trust — only call it for a peer the operator named themselves, with confirm:true.", + ], + parameters: Type.Object({ + peer: Type.String({ description: "Pending peer nickname or handle from wire_pending." }), + confirm: Type.Optional(Type.Boolean({ default: false, description: "Set true only when the operator has explicitly approved this peer." })), + }), + async execute(_id, params, signal, _onUpdate, ctx) { + const denied = await consent( + ctx, + `Accept wire pair: ${params.peer}`, + "Accepting makes this peer VERIFIED and gives it authenticated write access to your inbox.", + params.confirm, + ); + if (denied) return text_result(denied); + return wireJson(["accept", params.peer, "--json"], ctx, { signal, timeoutMs: 30_000 }); + }, + }); + + pi.registerTool({ + name: "wire_reject", + label: "Wire reject", + description: "Refuse a pending-inbound pair request without pairing. Idempotent.", + promptSnippet: "Reject a pending wire pair request", + parameters: Type.Object({ + peer: Type.String({ description: "Pending peer nickname or handle." }), + }), + async execute(_id, params, signal, _onUpdate, ctx) { + return wireJson(["reject", params.peer, "--json"], ctx, { signal, timeoutMs: 20_000 }); + }, + }); + + pi.registerTool({ + name: "wire_whois", + label: "Wire whois", + description: + "Inspect an identity. With no handle, prints this session's own profile; with `nick@domain`, resolves through that domain's `.well-known/wire/agent` and verifies the returned signed card.", + promptSnippet: "Inspect a wire identity or profile", + parameters: Type.Object({ + handle: Type.Optional(Type.String({ description: "`nick@domain` to resolve, or omit for self." })), + }), + async execute(_id, params, signal, _onUpdate, ctx) { + const args = ["whois"]; + if (params.handle) args.push(params.handle); + args.push("--json"); + return wireJson(args, ctx, { signal, timeoutMs: 30_000 }); + }, + }); + + pi.registerTool({ + name: "wire_setup", + label: "Wire setup", + description: + "Come online: mints this session's identity, binds a relay slot, claims the DID-derived persona, and starts the sync daemon. Idempotent. This is a network-visible action, so it needs the operator's go-ahead. Use `offline:true` for keygen with no relay binding.", + promptGuidelines: [ + "Run wire_setup only after the operator agrees to come online — it contacts a relay and starts a daemon.", + "Prefer relay `http://127.0.0.1:8771` or offline:true when the operator only wants same-machine sessions.", + ], + parameters: Type.Object({ + relay: Type.Optional(Type.String({ description: "Relay to bind and claim on, e.g. @wireup.net or http://127.0.0.1:8771. Omit for the default public relay." })), + offline: Type.Optional(Type.Boolean({ default: false, description: "Mint the identity only — bind nothing, claim nothing." })), + confirm: Type.Optional(Type.Boolean({ default: false, description: "Set true only when the operator has approved coming online." })), + }), + async execute(_id, params, signal, _onUpdate, ctx) { + const target = params.offline ? "offline keygen only (no relay, no claim)" : params.relay ?? "the default public relay (wireup.net)"; + const denied = await consent( + ctx, + "Bring this session online on wire", + `This mints an identity, binds ${target}, claims your persona, and starts a daemon.`, + params.confirm, + ); + if (denied) return text_result(denied); + const args = ["up"]; + if (params.offline) args.push("--offline"); + else if (params.relay) args.push(params.relay); + args.push("--json"); + return wireJson(args, ctx, { signal, timeoutMs: 90_000 }); + }, + }); + + // ------------------------------------------------ live inbox (opt-in) ---- + + let watcher: ChildProcess | null = null; + let buffer = ""; + + function stopWatcher(ctx: ExtensionContext | undefined) { + if (!watcher) return; + watcher.kill("SIGTERM"); + watcher = null; + ctx?.ui.notify("wire: inbox watcher stopped", "info"); + } + + function startWatcher(ctx: ExtensionContext) { + if (watcher) return "already running"; + buffer = ""; + const child = spawn(BIN, ["monitor", "--json"], { + cwd: ctx.cwd, + env: wireEnv(ctx), + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + watcher = child; + + child.stdout?.setEncoding("utf8"); + child.stdout?.on("data", (chunk: string) => { + buffer += chunk; + let nl: number; + while ((nl = buffer.indexOf("\n")) >= 0) { + const line = buffer.slice(0, nl).trim(); + buffer = buffer.slice(nl + 1); + if (!line) continue; + const ev = parseJson(line) as + | { from?: string; kind?: string; body?: unknown; persona?: { emoji?: string; nickname?: string } } + | null; + if (!ev) continue; + const who = ev.persona?.nickname ?? ev.from ?? "peer"; + const glyph = ev.persona?.emoji ? `${ev.persona.emoji} ` : ""; + const body = typeof ev.body === "string" ? ev.body : JSON.stringify(ev.body ?? ""); + // Deliver as a follow-up so a peer message never yanks the wheel + // mid-tool-batch, and only when the operator has opted in — an + // auto-triggering turn is model spend they did not ask for. + pi.sendMessage( + { + customType: "wire", + content: `wire message from ${glyph}${who}${ev.kind ? ` (${ev.kind})` : ""}: ${body}`, + display: true, + details: ev, + }, + { deliverAs: "followUp", triggerTurn: true }, + ); + } + }); + child.on("exit", () => { + if (watcher === child) watcher = null; + }); + child.on("error", (err: Error) => { + watcher = null; + ctx.ui.notify(`wire: watcher failed — ${err.message}`, "error"); + }); + return "started"; + } + + pi.registerCommand("wire-watch", { + description: "Stream inbound wire messages into this session (on | off | status)", + handler: async (args, ctx) => { + const want = (args ?? "").trim().toLowerCase(); + if (want === "on") { + const state = startWatcher(ctx); + ctx.ui.notify(`wire: inbox watcher ${state}`, "info"); + } else if (want === "off") { + stopWatcher(ctx); + } else { + ctx.ui.notify(`wire: inbox watcher ${watcher ? "running" : "stopped"} — /wire-watch on | off`, "info"); + } + }, + }); + + pi.on("session_start", async (_event, ctx) => { + // One cheap probe, reported to the operator, not to the model: a fresh + // session has no identity, and auto-minting one would claim a relay slot + // nobody asked for. + const run = await runWire(["whoami", "--json"], ctx, { timeoutMs: 8_000 }); + const id = parseJson(run.stdout) as { initialized?: boolean; handle?: string; persona?: { emoji?: string } } | null; + if (id?.initialized && id.handle) { + ctx.ui.notify(`wire: ${id.persona?.emoji ?? ""} ${id.handle}`, "info"); + } else { + ctx.ui.notify("wire: no identity for this session — run wire_setup when you want to come online", "info"); + } + }); + + // The watcher is session-lifetime infrastructure, not turn scaffolding + // (wire AGENTS.md R7 — the 2026-05-12 agent-attention-layer incident root + // caused exactly by tearing a listener down between iterations). It is torn + // down only when the session ends or the operator says /wire-watch off. + pi.on("session_shutdown", async () => { + stopWatcher(undefined); + }); +} diff --git a/pi-plugin/package.json b/pi-plugin/package.json new file mode 100644 index 00000000..4b27f84d --- /dev/null +++ b/pi-plugin/package.json @@ -0,0 +1,34 @@ +{ + "name": "wire-pi", + "version": "0.17.0", + "description": "Native wire tools for the Pi coding agent — bilateral signed agent-to-agent messaging without MCP.", + "author": { + "name": "Paul Logan", + "email": "paul@slancha.ai" + }, + "homepage": "https://wireup.net", + "repository": { + "type": "git", + "url": "https://github.com/SlanchaAi/wire", + "directory": "pi-plugin" + }, + "license": "AGPL-3.0-or-later", + "keywords": [ + "pi-package", + "agent", + "p2p", + "ed25519", + "mailbox", + "identity", + "wire" + ], + "pi": { + "extensions": ["./extensions"], + "skills": ["./skills"] + }, + "peerDependencies": { + "@earendil-works/pi-ai": "*", + "@earendil-works/pi-coding-agent": "*", + "typebox": "*" + } +} diff --git a/pi-plugin/skills/wire-pi/SKILL.md b/pi-plugin/skills/wire-pi/SKILL.md new file mode 100644 index 00000000..b10d5240 --- /dev/null +++ b/pi-plugin/skills/wire-pi/SKILL.md @@ -0,0 +1,94 @@ +--- +name: wire-pi +description: Work as a wire agent from inside the Pi coding agent. Use when the user wants to talk to another agent, pair this Pi session with a peer, read a wire inbox, or asks who you are on wire. Covers the per-session identity model, the tool surface, and the consent rules that gate pairing. +--- + +# wire in Pi + +Your Pi session is its own addressable agent on the wire bus. Pairing and +messaging run through native `wire_*` tools that call the `wire` CLI. Pi has no +MCP client and this package does not add one. + +## Identity + +Identity is keyed to the Pi session id, not the directory. + +- Pi puts `PI_SESSION_ID` in the environment of commands its bash tool runs. + wire reads it (`session_source: "pi"`) and resolves + `sessions/by-key/`. +- The extension pins `WIRE_SESSION_ID` to the same id string, so the tool path + and the bash path reach one home. Two Pi sessions in the same repo get two + personas; resuming the same session keeps yours. +- Your handle is your DID-derived persona. You do not choose it. Check it with + `wire_whoami` before you quote it anywhere. + +Verify what wire actually resolved, do not trust the naming layer: + +```bash +wire whoami --json | jq -r '.handle, .session_source, .config_dir' +wire session current # cwd registry name — may DISAGREE with the above +``` + +`wire session current` answers from the cwd registry. Identity resolution does +not consult that registry. When they disagree, `wire whoami` is the truth: the +identity is the one signing your messages. + +## Tool surface + +| Tool | Verb | Notes | +|---|---|---| +| `wire_whoami` | who am I | persona, DID, fingerprint, home | +| `wire_here` | who is around | self + same-machine sisters + pinned peers | +| `wire_peers` | who is paired | with tiers | +| `wire_status` | is the loop healthy | daemon, sync age, `identity_split` | +| `wire_pending` | what waits for consent | inbound pair requests | +| `wire_tail` | read the inbox | verified events, newest first | +| `wire_pull` | fetch now | synchronous relay GET, skips the ~5s cycle | +| `wire_dial` | talk to this name | local sister or `@` | +| `wire_send` | talk | returns the relay's real verdict | +| `wire_accept` / `wire_reject` | consent | accept needs `confirm:true` and a UI prompt | +| `wire_whois` | inspect an identity | resolves + verifies the signed card | +| `wire_setup` | come online | mints identity, binds relay, starts daemon | + +Read verbs are safe to call whenever useful. The two that change trust or spend +a public resource are gated: `wire_accept` and `wire_setup` both require +`confirm:true`, and prompt in the UI when a dialog surface exists. + +## Consent + +- Pairing is bilateral. Your `wire_dial` is only half of it; the peer must + accept. Say so instead of implying the channel is open. +- Inbound requests land in `wire_pending`. Surface them. Never accept one the + operator did not name — accepting gives that peer authenticated write access + to your inbox. +- Never invent a peer handle. Take it from `wire_peers`, `wire_here`, or the + operator. A handle you fabricated goes nowhere. +- Report the `status` field from `wire_send`. Do not say a message landed + without seeing `delivered`. +- `wire_setup` contacts a relay, claims your persona, and starts a daemon. Ask + first. `relay: "http://127.0.0.1:8771"` keeps it same-machine; + `offline: true` mints a key and touches nothing. + +## Session start + +1. `wire_whoami` — if it reports no identity, ask the operator before you call + `wire_setup`. +2. `wire_pending` — report what is waiting. +3. `wire_status` — a non-null `identity_split` means this process is frozen to a + stale persona while the live session is another one. Do not pair or send; + tell the operator. + +Peer messages do not reach you on their own. `/wire-watch on` streams them into +the session; `/wire-watch off` stops it. The watcher is session-lifetime, so +leave it running across turns and tear it down only on request or at session +end. + +## When something is wrong + +```bash +wire doctor # every silent-fail class in one command +wire whoami --json # handle + config_dir + session_source +wire status --json # daemon + queue depth + identity_split +``` + +Report errors verbatim. Do not retry mysteriously. diff --git a/src/session.rs b/src/session.rs index 94e842ef..51724c05 100644 --- a/src/session.rs +++ b/src/session.rs @@ -835,17 +835,34 @@ pub fn detect_session_wire_home(cwd: &std::path::Path) -> Option { /// 1. `WIRE_SESSION_ID` — explicit universal override (any harness). /// 2. `CLAUDE_CODE_SESSION_ID` — Claude Code adapter (stable per /// conversation; the same id the auto-memory system keys off). -/// 3. `CODEX_SESSION_ID` — Codex compatibility adapter for hosts that +/// 3. `PI_SESSION_ID` — Pi Coding Agent (`@earendil-works/pi-coding-agent`) +/// adapter. Pi injects this into the environment of commands its +/// LLM-callable `bash` / `powershell` tools run with a session context +/// (`core/tools/bash.js` `resolveSpawnContext`, gated on +/// `exposeSessionEnvironment`, default true), so plain `wire ` from +/// inside a Pi session resolves per-session with zero operator setup. +/// Stable per Pi conversation — it is the same id that names the session +/// JSONL. Two Pi sessions in the SAME cwd therefore get two personas, +/// which the legacy cwd registry would otherwise collapse into one. +/// Two caveats, both verified against the installed Pi: the injector +/// `delete`s the variable and re-sets it only when a session context +/// exists, so context-less (e.g. sub-agent) bash tools inherit nothing; and +/// because the variable is forwarded to child commands generally, a host +/// that ships no session id of its own — Codex CLI does not forward +/// `CODEX_SESSION_ID` — started inside a Pi shell inherits the parent Pi +/// home and shares its inbox. Pi strips the variable for nested Pi, so +/// Pi-in-Pi stays separate. +/// 4. `CODEX_SESSION_ID` — Codex compatibility adapter for hosts that /// forward this older name. -/// 4. `CODEX_THREAD_ID` — current OpenAI Codex runtime adapter. Stable +/// 5. `CODEX_THREAD_ID` — current OpenAI Codex runtime adapter. Stable /// per thread and inherited by tool subprocesses. -/// 5. `AGENT_SESSION_ID` — Goose adapter, accepted only when `AGENT=goose`. -/// 6. `COPILOT_AGENT_SESSION_ID` — GitHub Copilot CLI (`gh copilot` / +/// 6. `AGENT_SESSION_ID` — Goose adapter, accepted only when `AGENT=goose`. +/// 7. `COPILOT_AGENT_SESSION_ID` — GitHub Copilot CLI (`gh copilot` / /// `copilot`) adapter. Set by the Copilot CLI host for every /// session; stable per conversation; UUID-shaped. -/// 7. `VSCODE_GIT_REPOSITORY_ROOT` — VS Code/GitHub Copilot workspace-based +/// 8. `VSCODE_GIT_REPOSITORY_ROOT` — VS Code/GitHub Copilot workspace-based /// identity (stable per workspace). -/// 8. `None` — caller falls back to legacy cwd-detect (bare CLI / +/// 9. `None` — caller falls back to legacy cwd-detect (bare CLI / /// pre-v0.13 hosts). Future host adapters slot in before this. /// /// Returns `(key, source-label)`. @@ -853,6 +870,7 @@ pub fn resolve_session_key() -> Option<(String, &'static str)> { for (var, source) in [ ("WIRE_SESSION_ID", "override"), ("CLAUDE_CODE_SESSION_ID", "claude-code"), + ("PI_SESSION_ID", "pi"), ("CODEX_SESSION_ID", "codex-cli"), ("CODEX_THREAD_ID", "codex-cli"), ] { @@ -1566,7 +1584,7 @@ static SESSION_SOURCE: std::sync::OnceLock<&'static str> = std::sync::OnceLock:: /// The signal that won session/home resolution for this process. One of: /// `env:WIRE_HOME`, `env:WIRE_HOME_FORCE` (RFC-008 §C legacy-shape force), /// `override` (`WIRE_SESSION_ID`), `claude-code`, `claude-code-pidfile`, -/// `codex-cli`, `goose`, `copilot-cli`, `vscode-workspace`, `minted`, +/// `pi`, `codex-cli`, `goose`, `copilot-cli`, `vscode-workspace`, `minted`, /// `machine-default`, or `unknown` if adoption never ran. pub fn session_source() -> &'static str { SESSION_SOURCE.get().copied().unwrap_or("unknown") @@ -1887,6 +1905,7 @@ mod tests { let prev_agent_session = std::env::var_os("AGENT_SESSION_ID"); let prev_copilot = std::env::var_os("COPILOT_AGENT_SESSION_ID"); let prev_vscode = std::env::var_os("VSCODE_GIT_REPOSITORY_ROOT"); + let prev_pi = std::env::var_os("PI_SESSION_ID"); // SAFETY: ENV_LOCK is held, serializing all env access. unsafe { std::env::remove_var("WIRE_SESSION_ID"); @@ -1897,6 +1916,7 @@ mod tests { std::env::remove_var("AGENT_SESSION_ID"); std::env::remove_var("COPILOT_AGENT_SESSION_ID"); std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT"); + std::env::remove_var("PI_SESSION_ID"); } // (a) Two distinct workspace paths -> two distinct, stable session homes. @@ -1937,6 +1957,7 @@ mod tests { // Same guard for the other adapter slots. unsafe { std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT"); + std::env::remove_var("PI_SESSION_ID"); std::env::set_var("WIRE_SESSION_ID", "${workspaceFolder}"); } let r_guard2 = resolve_session_key(); @@ -1956,6 +1977,7 @@ mod tests { std::env::remove_var("AGENT_SESSION_ID"); std::env::remove_var("COPILOT_AGENT_SESSION_ID"); std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT"); + std::env::remove_var("PI_SESSION_ID"); if let Some(v) = prev_override { std::env::set_var("WIRE_SESSION_ID", v); } @@ -1980,6 +2002,167 @@ mod tests { if let Some(v) = prev_vscode { std::env::set_var("VSCODE_GIT_REPOSITORY_ROOT", v); } + if let Some(v) = prev_pi { + std::env::set_var("PI_SESSION_ID", v); + } + } + } + + #[test] + fn resolve_session_key_pi_adapter_priority_and_home_parity() { + // Per-adapter test for the Pi Coding Agent path. Pi injects + // PI_SESSION_ID into the env of every command its LLM-callable bash / + // powershell tools run (`core/tools/bash.js` `resolveSpawnContext`, + // gated on `exposeSessionEnvironment`, default true), so `wire ` + // run from a Pi session resolves a per-session identity with no + // operator setup. Holds four invariants: + // + // (a) Set to a real Pi session id -> that key wins resolution and two + // distinct Pi sessions map to two distinct session homes. This is + // the point of the adapter: two Pi sessions in the SAME cwd would + // otherwise collapse onto one persona via the cwd registry. + // (b) WIRE_SESSION_ID and CLAUDE_CODE_SESSION_ID outrank it; it + // outranks CODEX_SESSION_ID / COPILOT_AGENT_SESSION_ID / + // VSCODE_GIT_REPOSITORY_ROOT (pi sits at priority 3). + // (c) HOME PARITY: the same id string arriving as PI_SESSION_ID or as + // WIRE_SESSION_ID resolves to the SAME session home. The pi + // package (pi/extensions/wire.ts) pins WIRE_SESSION_ID itself for + // hosts that do not forward PI_SESSION_ID (SDK-embedded hosts); + // parity is what makes those two paths one identity rather than + // two personas for one conversation. + // (d) Unexpanded ${...} literal is rejected by the guard, like every + // other adapter. + let _guard = crate::config::test_support::ENV_LOCK + .lock() + .unwrap_or_else(|p| p.into_inner()); + + let prev_override = std::env::var_os("WIRE_SESSION_ID"); + let prev_claude = std::env::var_os("CLAUDE_CODE_SESSION_ID"); + let prev_pi = std::env::var_os("PI_SESSION_ID"); + let prev_codex = std::env::var_os("CODEX_SESSION_ID"); + let prev_copilot = std::env::var_os("COPILOT_AGENT_SESSION_ID"); + let prev_vscode = std::env::var_os("VSCODE_GIT_REPOSITORY_ROOT"); + // SAFETY: ENV_LOCK is held, serializing all env access. + unsafe { + std::env::remove_var("WIRE_SESSION_ID"); + std::env::remove_var("CLAUDE_CODE_SESSION_ID"); + std::env::remove_var("PI_SESSION_ID"); + std::env::remove_var("CODEX_SESSION_ID"); + std::env::remove_var("COPILOT_AGENT_SESSION_ID"); + std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT"); + } + + // (a) PI_SESSION_ID wins resolution and is labeled `pi`; distinct Pi + // sessions -> distinct homes; same id -> same home (resume). + let session_a = "0198c1f4-2b1e-7a3d-9c40-4f0d7c1b6a55"; + let session_b = "0198c1f4-2b1e-7a3d-9c40-4f0d7c1b6a56"; + unsafe { std::env::set_var("PI_SESSION_ID", session_a) }; + let r1 = resolve_session_key(); + assert!( + matches!(&r1, Some((k, src)) if k == session_a && *src == "pi"), + "PI_SESSION_ID must win resolution and be labeled pi; got {r1:?}" + ); + let home_a = session_home_for_key(&r1.as_ref().unwrap().0).unwrap(); + + unsafe { std::env::set_var("PI_SESSION_ID", session_b) }; + let home_b = session_home_for_key(&resolve_session_key().unwrap().0).unwrap(); + assert_ne!( + home_a, home_b, + "two distinct Pi session ids must map to distinct session homes" + ); + + unsafe { std::env::set_var("PI_SESSION_ID", session_a) }; + let home_a2 = session_home_for_key(&resolve_session_key().unwrap().0).unwrap(); + assert_eq!( + home_a, home_a2, + "same Pi session id must yield the same home across calls" + ); + + // (b) Priority: override > claude-code > pi > codex > copilot > vscode. + unsafe { std::env::set_var("WIRE_SESSION_ID", "operator-override") }; + let r_override = resolve_session_key(); + assert!( + matches!(&r_override, Some((k, src)) if k == "operator-override" && *src == "override"), + "WIRE_SESSION_ID must beat PI_SESSION_ID; got {r_override:?}" + ); + unsafe { std::env::remove_var("WIRE_SESSION_ID") }; + + unsafe { std::env::set_var("CLAUDE_CODE_SESSION_ID", "claude-wins-over-pi") }; + let r_claude = resolve_session_key(); + assert!( + matches!(&r_claude, Some((k, src)) if k == "claude-wins-over-pi" && *src == "claude-code"), + "CLAUDE_CODE_SESSION_ID must beat PI_SESSION_ID; got {r_claude:?}" + ); + unsafe { std::env::remove_var("CLAUDE_CODE_SESSION_ID") }; + + // pi beats every later adapter, so a stray Codex/Copilot/VS Code id in + // the environment cannot steal a Pi session's identity. + unsafe { + std::env::set_var("CODEX_SESSION_ID", "codex-loses-to-pi"); + std::env::set_var("COPILOT_AGENT_SESSION_ID", "copilot-loses-to-pi"); + std::env::set_var("VSCODE_GIT_REPOSITORY_ROOT", "/repo/loses-to-pi"); + }; + let r_pi_wins = resolve_session_key(); + assert!( + matches!(&r_pi_wins, Some((k, src)) if k == session_a && *src == "pi"), + "PI_SESSION_ID must beat CODEX/COPILOT/VSCODE adapters; got {r_pi_wins:?}" + ); + unsafe { + std::env::remove_var("CODEX_SESSION_ID"); + std::env::remove_var("COPILOT_AGENT_SESSION_ID"); + std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT"); + } + + // (c) Home parity — the invariant pi/extensions/wire.ts depends on. + unsafe { std::env::remove_var("PI_SESSION_ID") }; + unsafe { std::env::set_var("WIRE_SESSION_ID", session_a) }; + let via_override = resolve_session_key().unwrap(); + assert_eq!( + via_override.1, "override", + "WIRE_SESSION_ID keeps its own source label" + ); + assert_eq!( + session_home_for_key(&via_override.0).unwrap(), + home_a, + "one Pi session id must reach one session home whether it arrives as \ + PI_SESSION_ID or as WIRE_SESSION_ID — otherwise the pi package and a \ + plain bash-tool `wire` call would run as two different personas" + ); + + // (d) Unexpanded ${...} guard. + unsafe { + std::env::remove_var("WIRE_SESSION_ID"); + std::env::set_var("PI_SESSION_ID", "${PI_SESSION_ID}"); + }; + let r_guard = resolve_session_key(); + assert!( + !matches!(&r_guard, Some((k, _)) if k.contains("${")), + "unexpanded ${{...}} in PI_SESSION_ID must be rejected by the ${{}} guard; got {r_guard:?}" + ); + + // Restore any env we displaced. + // SAFETY: ENV_LOCK still held. + unsafe { + std::env::remove_var("WIRE_SESSION_ID"); + std::env::remove_var("PI_SESSION_ID"); + if let Some(v) = prev_override { + std::env::set_var("WIRE_SESSION_ID", v); + } + if let Some(v) = prev_claude { + std::env::set_var("CLAUDE_CODE_SESSION_ID", v); + } + if let Some(v) = prev_pi { + std::env::set_var("PI_SESSION_ID", v); + } + if let Some(v) = prev_codex { + std::env::set_var("CODEX_SESSION_ID", v); + } + if let Some(v) = prev_copilot { + std::env::set_var("COPILOT_AGENT_SESSION_ID", v); + } + if let Some(v) = prev_vscode { + std::env::set_var("VSCODE_GIT_REPOSITORY_ROOT", v); + } } } @@ -2015,6 +2198,7 @@ mod tests { let prev_agent_session = std::env::var_os("AGENT_SESSION_ID"); let prev_copilot = std::env::var_os("COPILOT_AGENT_SESSION_ID"); let prev_vscode = std::env::var_os("VSCODE_GIT_REPOSITORY_ROOT"); + let prev_pi = std::env::var_os("PI_SESSION_ID"); // SAFETY: ENV_LOCK is held, serializing all env access. unsafe { std::env::remove_var("WIRE_SESSION_ID"); @@ -2025,6 +2209,7 @@ mod tests { std::env::remove_var("AGENT_SESSION_ID"); std::env::remove_var("COPILOT_AGENT_SESSION_ID"); std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT"); + std::env::remove_var("PI_SESSION_ID"); } // (a) COPILOT_AGENT_SESSION_ID set -> wins resolution; distinct ids @@ -2088,6 +2273,7 @@ mod tests { std::env::remove_var("AGENT_SESSION_ID"); std::env::remove_var("COPILOT_AGENT_SESSION_ID"); std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT"); + std::env::remove_var("PI_SESSION_ID"); if let Some(v) = prev_override { std::env::set_var("WIRE_SESSION_ID", v); } @@ -2112,6 +2298,9 @@ mod tests { if let Some(v) = prev_vscode { std::env::set_var("VSCODE_GIT_REPOSITORY_ROOT", v); } + if let Some(v) = prev_pi { + std::env::set_var("PI_SESSION_ID", v); + } } } @@ -2143,6 +2332,7 @@ mod tests { let prev_agent_session = std::env::var_os("AGENT_SESSION_ID"); let prev_copilot = std::env::var_os("COPILOT_AGENT_SESSION_ID"); let prev_vscode = std::env::var_os("VSCODE_GIT_REPOSITORY_ROOT"); + let prev_pi = std::env::var_os("PI_SESSION_ID"); // SAFETY: ENV_LOCK is held, serializing all env access. unsafe { std::env::remove_var("WIRE_SESSION_ID"); @@ -2153,6 +2343,7 @@ mod tests { std::env::remove_var("AGENT_SESSION_ID"); std::env::remove_var("COPILOT_AGENT_SESSION_ID"); std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT"); + std::env::remove_var("PI_SESSION_ID"); } // (a) CODEX_THREAD_ID is the runtime value current Codex processes @@ -2242,6 +2433,7 @@ mod tests { std::env::remove_var("AGENT_SESSION_ID"); std::env::remove_var("COPILOT_AGENT_SESSION_ID"); std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT"); + std::env::remove_var("PI_SESSION_ID"); if let Some(v) = prev_override { std::env::set_var("WIRE_SESSION_ID", v); } @@ -2266,6 +2458,9 @@ mod tests { if let Some(v) = prev_vscode { std::env::set_var("VSCODE_GIT_REPOSITORY_ROOT", v); } + if let Some(v) = prev_pi { + std::env::set_var("PI_SESSION_ID", v); + } } } @@ -3024,6 +3219,7 @@ mod tests { "override", "claude-code", "claude-code-pidfile", + "pi", "codex-cli", "goose", "copilot-cli", From 7696ea722e7a37f20f6df545b305819376f17838 Mon Sep 17 00:00:00 2001 From: Paul Logan Date: Sat, 29 Aug 2026 09:17:56 -0700 Subject: [PATCH 2/9] fix(identity): stop guessing between identities that share a name Reported as "cwd naming vs unique session naming", and it turned out to be two separate integrity defects sharing one symptom: the name you are told and the identity you operate as are not the same thing. Collisions. A persona nickname is ADJECTIVES[243] x NOUNS[242] = 58,806 names, seeded from the DID's 8-hex fingerprint suffix, and v0.11 made that nickname the addressable handle, baked into the DID itself. Collision odds hit 50 percent at ~285 identities. This box holds 8,861 initialized session homes and 566 handle groups already shared by two different DIDs, e.g. did:wire:agate-heron-aead0646 and did:wire:agate-heron-4c4d66bc. resolve_local_sister returned the FIRST match in readdir order, so `wire dial ` could pair with, and `wire send ` could write signed events to, whichever of the two the filesystem enumerated first. It now returns Unique or Ambiguous, and resolve_local_sister_unique errors on ambiguity at all four acting call sites (send's auto-pair path, dial's resolution ladder, both wire add branches). Verified live against a real collision. Two details that each would have silently defeated the guard: - list_sessions overrides name to the persona handle, so the two colliding homes arrive with an IDENTICAL name. Dedupe keys on home_dir, not name. - the first remedy the error printed was a dead end: neither the by-key home name nor a full DID was matched by resolve_local_sister or resolve_local_session. Both now match both, the Unique token is the home (unique and accepted downstream), dial looks the sister up by home, and both printed remedies were confirmed to resolve. One identity at two homes collapses to Unique rather than a refusal; one agent is not a choice between agents. Measured first: 8,861 homes, 8,861 distinct DIDs, so that case is prevented, not observed. The lie. `wire session current` printed only the cwd registry's name, but since v0.13 identity never resolves from the registry, and the registry still answers for four display paths. Verified: in a registered cwd it said `slancha-api` while `wire whoami` signed as `cobalt-nettle`, the machine default. It now reports operative_handle, session_source, config_dir, wire_home and agrees alongside the registry name. `agrees` is null when there was nothing to compare rather than claiming agreement unchecked. stdout keeps its historical single-line answer so parsers hold; the disagreement note goes to stderr. Follows f09f360, whose pi-plugin/skills/wire-pi/SKILL.md already documents the `agrees` field this commit adds. Tests: resolver unit test covers home-keyed dedupe, DID and home remedies, and the same-DID collapse; three new unit tests cover resolve_local_session directly; tests/cli.rs locks the new JSON keys and the unchanged stdout contract. --- src/cli/comms.rs | 18 +- src/cli/pairing.rs | 170 ++++++++++++- src/cli/session.rs | 57 ++++- src/session.rs | 604 ++++++++++++++++++++++++++++++++------------- tests/cli.rs | 85 +++++++ 5 files changed, 740 insertions(+), 194 deletions(-) diff --git a/src/cli/comms.rs b/src/cli/comms.rs index 3da8aa72..4ad92093 100644 --- a/src/cli/comms.rs +++ b/src/cli/comms.rs @@ -292,20 +292,22 @@ pub(super) fn cmd_send( .and_then(|s| s.get("peers").and_then(Value::as_object).cloned()) .map(|peers| peers.contains_key(&peer)) .unwrap_or(false); - if !peer_is_pinned && let Some(sister_name) = crate::session::resolve_local_sister(&peer) { + if !peer_is_pinned + && let Some(sister_home) = crate::session::resolve_local_sister_unique(&peer)? + { if no_auto_pair { bail!( - "wire send: `{peer}` resolves to local sister `{sister_name}` but is not pinned, \ - and --no-auto-pair was passed. Run `wire dial {peer}` first, \ - then re-run send." + "wire send: `{peer}` resolves to local sister session home `{sister_home}` but is \n\ + not pinned, and --no-auto-pair was passed. Run `wire dial {peer}` first, then \n\ + re-run send." ); } eprintln!( - "wire send: `{peer}` not pinned yet — auto-pairing via local-sister `{sister_name}` first. \ - Pass --no-auto-pair to refuse implicit dialing." + "wire send: `{peer}` not pinned yet — auto-pairing via local-sister session home \n\ + `{sister_home}` first. Pass --no-auto-pair to refuse implicit dialing." ); - super::cmd_add_local_sister(&sister_name, true).map_err(|e| { - anyhow!("wire send: auto-pair to local sister `{sister_name}` failed: {e:#}") + super::cmd_add_local_sister(&sister_home, true).map_err(|e| { + anyhow!("wire send: auto-pair to local sister `{sister_home}` failed: {e:#}") })?; } diff --git a/src/cli/pairing.rs b/src/cli/pairing.rs index 75ca5be4..adc9a785 100644 --- a/src/cli/pairing.rs +++ b/src/cli/pairing.rs @@ -334,10 +334,18 @@ pub(crate) fn resolve_name_to_target(name: &str) -> Result { } } - // 2. Local sister sessions. - if let Some(session_name) = crate::session::resolve_local_sister(needle) { + // 2. Local sister sessions. Ambiguity is fatal here on purpose: dialing the + // wrong one of two same-named identities pairs us with a stranger. The + // token is the `by-key` home name, so the lookup keys on home — keying on + // name would re-enter the collision (both candidates share the name). + if let Some(sister_home) = crate::session::resolve_local_sister_unique(needle)? { let sessions = crate::session::list_sessions().unwrap_or_default(); - let s = sessions.iter().find(|s| s.name == session_name); + let s = sessions.iter().find(|s| { + s.home_dir + .file_name() + .and_then(|f| f.to_str()) + .is_some_and(|h| h == sister_home) + }); if let Some(s) = s { return Ok(DialTarget::LocalSister { session_name: s.name.clone(), @@ -730,11 +738,61 @@ fn resolve_local_session<'a>( sessions: &'a [crate::session::SessionInfo], input: &str, ) -> Result<&'a crate::session::SessionInfo, ResolveError> { - // Exact session-name match always wins, even if a nickname elsewhere - // also matches. Predictable for scripts and operator muscle memory. - if let Some(s) = sessions.iter().find(|s| s.name == input) { + // Exact session-name match wins for predictability — but only when it is + // ONE identity. list_sessions overrides name to the persona handle, and a + // persona nickname comes from a 243x242 word pair, so two different DIDs can + // share one name. Picking the first exact match (the previous behavior) sent + // signed traffic to whichever home readdir happened to yield first. + let name_matches: Vec<&crate::session::SessionInfo> = + sessions.iter().filter(|s| s.name == input).collect(); + match name_matches.len() { + 1 => return Ok(name_matches[0]), + n if n > 1 => { + let distinct_dids: std::collections::HashSet<&str> = name_matches + .iter() + .filter_map(|s| s.did.as_deref()) + .collect(); + if distinct_dids.len() > 1 { + return Err(ResolveError::Ambiguous( + name_matches + .iter() + .map(|s| { + format!( + "did={} home={}", + s.did.as_deref().unwrap_or("(no card)"), + s.home_dir + .file_name() + .and_then(|f| f.to_str()) + .unwrap_or("?") + ) + }) + .collect(), + )); + } + // Same identity at several homes: any match is the same peer. + return Ok(name_matches[0]); + } + _ => {} + } + + // The two forms an operator or agent retypes after an ambiguity refusal, and + // the forms resolve_local_sister itself accepts. Matched before nicknames + // because they are strictly more specific than a colliding word pair. + if let Some(s) = sessions.iter().find(|s| { + s.home_dir + .file_name() + .and_then(|f| f.to_str()) + .is_some_and(|h| h.eq_ignore_ascii_case(input)) + }) { + return Ok(s); + } + if let Some(s) = sessions + .iter() + .find(|s| s.did.as_deref().is_some_and(|d| d.eq_ignore_ascii_case(input))) + { return Ok(s); } + let nick_matches: Vec<&crate::session::SessionInfo> = sessions .iter() .filter(|s| { @@ -748,7 +806,19 @@ fn resolve_local_session<'a>( 0 => Err(ResolveError::NotFound), 1 => Ok(nick_matches[0]), _ => Err(ResolveError::Ambiguous( - nick_matches.iter().map(|s| s.name.clone()).collect(), + nick_matches + .iter() + .map(|s| { + format!( + "did={} home={}", + s.did.as_deref().unwrap_or("(no card)"), + s.home_dir + .file_name() + .and_then(|f| f.to_str()) + .unwrap_or("?") + ) + }) + .collect(), )), } } @@ -885,10 +955,10 @@ pub(crate) fn add_local_sister_core(sister_name: &str) -> Result bail!( - "nickname `{sister_name}` is ambiguous — matches {} sessions: {}. \ - Disambiguate by passing the session name (one of those listed) instead of the nickname.", + "nickname `{sister_name}` is ambiguous — matches {} sessions: {}. \n\ + Repeat with the DID, or with the home name plus `--local-sister`.", candidates.len(), - candidates.join(", ") + candidates.join(", "), ), }; @@ -1126,15 +1196,15 @@ pub(super) fn cmd_add( // its character name" ergonomic gap that forced operators into // `wire session list-local | grep | awk` dances. if local_sister { - let resolved = crate::session::resolve_local_sister(handle_arg) + let resolved = crate::session::resolve_local_sister_unique(handle_arg)? .unwrap_or_else(|| handle_arg.to_string()); return cmd_add_local_sister(&resolved, as_json); } if !handle_arg.contains('@') - && let Some(resolved) = crate::session::resolve_local_sister(handle_arg) + && let Some(resolved) = crate::session::resolve_local_sister_unique(handle_arg)? { eprintln!( - "wire add: `{handle_arg}` resolved to local sister session `{resolved}` \ + "wire add: `{handle_arg}` resolved to local sister session home `{resolved}` \ — routing via --local-sister (disk-read card, no relay lookup)." ); return cmd_add_local_sister(&resolved, as_json); @@ -1933,3 +2003,77 @@ mod resolve_tier_tests { }); } } + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn session(name: &str, home: &str, did: &str, nickname: &str) -> crate::session::SessionInfo { + // Same nickname on two sessions is exactly a persona collision: one + // nickname, two DIDs. Character is normally derived from the DID, so the + // nickname is set explicitly to reproduce the collision shape. + let mut character = crate::character::Character::from_did(did); + character.nickname = nickname.to_string(); + crate::session::SessionInfo { + name: name.to_string(), + cwd: None, + home_dir: PathBuf::from("/tmp/fake").join(home), + did: Some(did.to_string()), + handle: Some(name.to_string()), + daemon_running: false, + character: Some(character), + } + } + + #[test] + fn resolve_local_session_refuses_name_shared_by_two_dids() { + let a = session("amber-tarn", "aaaa111122223333", "did:wire:amber-tarn-11111111", "amber-tarn"); + let b = session("amber-tarn", "bbbb111122223333", "did:wire:amber-tarn-22222222", "amber-tarn"); + let sessions = vec![a, b]; + + // The name is shared, so the old first-match behavior would silently + // pick `aaaa...` and pair with, or send signed events to, that agent. + let err = resolve_local_session(&sessions, "amber-tarn") + .expect_err("a name owned by two DIDs must not resolve"); + match err { + ResolveError::Ambiguous(c) => { + assert_eq!(c.len(), 2, "both candidates must be listed; got {c:?}"); + assert!( + c.iter().all(|x| x.contains("did=") && x.contains("home=")), + "candidates must carry the two tokens that DO resolve; got {c:?}" + ); + } + other => panic!("expected Ambiguous, got {other:?}"), + } + + // The two remedies printed in that error must actually resolve. + let by_did = resolve_local_session(&sessions, "did:wire:amber-tarn-22222222").unwrap(); + assert!(by_did.home_dir.to_string_lossy().ends_with("bbbb111122223333")); + let by_home = resolve_local_session(&sessions, "aaaa111122223333").unwrap(); + assert_eq!(by_home.did.as_deref(), Some("did:wire:amber-tarn-11111111")); + } + + #[test] + fn resolve_local_session_allows_one_identity_at_two_homes() { + // An identity present at two homes is one peer, not a choice between + // peers — refusing here would be a false stop. + let a = session("lone-larch", "aaaa111122223333", "did:wire:lone-larch-33333333", "lone-larch"); + let b = session("lone-larch", "cccc111122223333", "did:wire:lone-larch-33333333", "lone-larch"); + let binding = [a, b]; + let got = resolve_local_session(&binding, "lone-larch") + .expect("same DID at two homes must resolve"); + assert_eq!(got.did.as_deref(), Some("did:wire:lone-larch-33333333")); + } + + #[test] + fn resolve_local_session_still_resolves_plain_names_and_reports_missing() { + let a = session("quiet-pond", "aaaa111122223333", "did:wire:quiet-pond-44444444", "quiet-pond"); + let sessions = vec![a]; + assert!(resolve_local_session(&sessions, "quiet-pond").is_ok()); + match resolve_local_session(&sessions, "no-such-session") { + Err(ResolveError::NotFound) => {} + other => panic!("expected NotFound, got {other:?}"), + } + } +} diff --git a/src/cli/session.rs b/src/cli/session.rs index 8c5e36ad..0494e980 100644 --- a/src/cli/session.rs +++ b/src/cli/session.rs @@ -1292,18 +1292,73 @@ pub(super) fn cmd_session_current(as_json: bool) -> Result<()> { .map(|(_, v)| v) }) .cloned(); + // The registry answers "which session NAME was registered for this cwd". + // It is NOT the identity in use: since v0.13 identity resolves from a + // session-id key (`by-key/`) or the machine default, and never from + // the cwd registry (see `maybe_adopt_session_home`, which deliberately + // refuses cwd resolution). Reporting only the registry name made this + // command assert an identity binding that resolution no longer honours — + // `wire session current` said one handle while `wire whoami` signed as + // another. Report both, and say plainly when they disagree. + let registry_name = name.clone(); + // Report `config_dir` exactly as `wire whoami --json` does, and the raw + // WIRE_HOME env separately — a set WIRE_HOME is the operator's deliberate + // pin, and collapsing the two would hide which case a session is in. + let config_dir = crate::config::config_dir() + .ok() + .map(|d| d.display().to_string()); + let wire_home = std::env::var("WIRE_HOME").ok(); + let operative_handle = crate::session::operational_handle(); + let source = crate::session::session_source(); + // `null`, not `true`, when there is nothing to compare: an uninitialized + // home has no operative handle, and reporting agreement there would assert a + // binding the command never verified. + let agrees = match (®istry_name, &operative_handle) { + (Some(reg), Some(op)) => Some(reg == op), + _ => None, + }; if as_json { println!( "{}", serde_json::to_string(&json!({ "cwd": cwd_key, - "session": name, + // `session` keeps its pre-existing meaning (registry name) so + // scripts reading it are unaffected. + "session": registry_name, + "operative_handle": operative_handle, + "session_source": source, + "config_dir": config_dir, + "wire_home": wire_home, + "agrees": agrees, + "note": if agrees == Some(false) { Some( + "the cwd registry names a different session than the identity this process \ + resolves; `operative_handle` is the one that signs. Identity has not come \ + from the cwd registry since v0.13." + ) } else if agrees.is_none() && registry_name.is_some() { Some( + "a session is registered for this cwd but this home is uninitialized, so \n\ + agreement with the registry could not be checked." + ) } else { None } }))? ); } else if let Some(n) = name { println!("{n}"); + if agrees == Some(false) { + // stderr, per the repo's convention that agent-visible streams stay + // clean; stdout keeps exactly the historical single-token answer. + eprintln!( + "wire session current: NOTE the registry says `{n}` but this process resolves \ + handle `{}` (session_source={source}). The registry has not driven identity \ + since v0.13 — that handle is the one that signs.", + operative_handle.as_deref().unwrap_or("(uninitialized)") + ); + } } else { println!("(no session registered for this cwd)"); + eprintln!( + "wire session current: identity in use is handle `{}` (session_source={source}); \ + the cwd registry does not select identity since v0.13.", + operative_handle.as_deref().unwrap_or("(uninitialized)") + ); } Ok(()) } diff --git a/src/session.rs b/src/session.rs index 51724c05..b27e884d 100644 --- a/src/session.rs +++ b/src/session.rs @@ -450,28 +450,154 @@ fn url_is_loopback(url: &str) -> bool { /// Designed for `wire add ` ergonomics — the operator should /// be able to type whatever face wire put on the peer (statusline /// nickname, session list emoji+name) and have wire find it. -pub fn resolve_local_sister(input: &str) -> Option { +/// One local session that answers to the name a caller typed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SisterCandidate { + /// Display name — the persona handle for an initialized home, the by-key + /// hash otherwise. Two colliding identities share this value, so it is + /// never the dedupe key. + pub session: String, + /// The `by-key/` dir name. Distinct per identity even when `session` + /// is shared. This IS an actionable address: `resolve_local_session` matches + /// it, so `wire add --local-sister` resolves the exact home. + pub home: String, + pub did: Option, +} + +/// What a bare name matched among this machine's session homes. +/// +/// The `Ambiguous` arm is the whole point of this type. A persona nickname is +/// `ADJECTIVES[243] × NOUNS[242]` = 58,806 names, seeded from the 8-hex +/// fingerprint suffix (`Character::from_seed`), and v0.11 made that nickname the +/// addressable handle — the handle is even baked into the DID. Collision odds +/// reach 50% at ~285 identities, so on a box with thousands of session homes +/// (one per agent session; see `ensure_session_bootstrapped`) two DIFFERENT DIDs +/// routinely share one name. The previous implementation returned the first hit +/// in `read_dir` order, which meant `wire dial ` could silently pair with, +/// and `wire send` could silently write to, whichever of the two the filesystem +/// happened to enumerate first. Misrouting an Ed25519-signed message is not a +/// cosmetic failure, so ambiguity is now a hard error that names the candidates. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SisterMatch { + /// One identity matched. The payload is the `by-key` home dir name, which + /// is the only local token that is both unique and accepted downstream + /// (`resolve_local_session` matches it, and `resolve_name_to_target` looks + /// the session up by it). + Unique(String), + Ambiguous(Vec), +} + +impl SisterMatch { + /// The single session name, or an error listing every candidate by DID. + pub fn unique(self, input: &str) -> Result { + match self { + SisterMatch::Unique(session) => Ok(session), + SisterMatch::Ambiguous(cands) => { + let count = cands.len(); + let listing = cands + .iter() + .map(|c| { + format!( + " wire dial {} # or: wire add {} --local-sister", + c.did.as_deref().unwrap_or("(no card)"), + c.home + ) + }) + .collect::>() + .join("\n"); + Err(anyhow!( + "`{input}` is not unique: {count} local sessions answer to that name. Persona \ + nicknames collide at this population (243x242 word pair, and the nickname is \ + the addressable handle), so wire refuses to guess which agent you meant — \ + guessing would pair with, or send signed events to, the wrong \ + identity.\n\nRepeat with the DID, or with the `by-key` home name plus \ + `--local-sister`:\n{listing}" + )) + } + } + } +} + +/// Resolve a bare name against this machine's session homes. Accepts a session +/// name, card handle, persona nickname, full DID, or `by-key` home dir name — +/// the last two are the forms an ambiguous nickname must be narrowed to. +/// Use [`resolve_local_sister_unique`] at call sites about to act on the result. +pub fn resolve_local_sister(input: &str) -> Option { let needle = input.trim(); if needle.is_empty() { return None; } let sessions = list_sessions().ok()?; + let mut matches: Vec = Vec::new(); for s in &sessions { - if s.name.eq_ignore_ascii_case(needle) { - return Some(s.name.clone()); - } - if let Some(h) = &s.handle - && h.eq_ignore_ascii_case(needle) - { - return Some(s.name.clone()); - } - if let Some(ch) = &s.character - && ch.nickname.eq_ignore_ascii_case(needle) - { - return Some(s.name.clone()); + let home = s + .home_dir + .file_name() + .and_then(|f| f.to_str()) + .unwrap_or("") + .to_string(); + let hit = s.name.eq_ignore_ascii_case(needle) + || s.handle + .as_deref() + .is_some_and(|h| h.eq_ignore_ascii_case(needle)) + || s.character + .as_ref() + .is_some_and(|ch| ch.nickname.eq_ignore_ascii_case(needle)) + // DID and by-key home are what the ambiguity error tells an operator + // to retype, so they have to resolve here too. + || s.did + .as_deref() + .is_some_and(|d| d.eq_ignore_ascii_case(needle)) + || home.eq_ignore_ascii_case(needle); + if hit { + matches.push(SisterCandidate { + session: s.name.clone(), + home, + did: s.did.clone(), + }); } } - None + // Dedupe by HOME first: for an initialized home list_sessions overrides name + // to the persona handle, so the collision this guard exists to catch — two + // DIDs, one nickname — arrives as two entries with an identical `session`. + // A home-keyed dedupe keeps them distinct. + matches.sort_by(|a, b| a.home.cmp(&b.home)); + matches.dedup_by(|a, b| a.home == b.home); + // Then collapse by DID: one identity living at two homes is one agent, not a + // choice between agents. Refusing there would be a false stop, and the error + // text would claim we might pick the wrong identity when both candidates are + // the same identity. + let distinct_dids: Vec<&Option> = { + let mut v: Vec<&Option> = matches.iter().map(|c| &c.did).collect(); + v.sort(); + v.dedup(); + v + }; + if matches.len() > 1 && distinct_dids.len() == 1 && distinct_dids[0].is_some() { + matches.truncate(1); + } + match matches.len() { + 0 => None, + 1 => Some(SisterMatch::Unique(matches.into_iter().next().unwrap().home)), + _ => Some(SisterMatch::Ambiguous(matches)), + } +} + +/// `Ok(None)` — no local session answers to this name. `Ok(Some(home))` — +/// exactly one does. `Err` — several distinct identities do, and picking one +/// would misroute signed traffic. The returned `by-key` home name is accepted by +/// `resolve_local_session`, so `wire add --local-sister` works. +pub fn resolve_local_sister_unique(input: &str) -> Result> { + resolve_local_sister(input) + .map(|m| m.unique(input)) + .transpose() +} + +/// `current_operational_handle`, exposed for CLI honesty checks (`wire session +/// current` must report the identity that is actually signing, not only the name +/// the cwd registry claims). +pub fn operational_handle() -> Option { + current_operational_handle() } pub fn list_sessions() -> Result> { @@ -2008,164 +2134,6 @@ mod tests { } } - #[test] - fn resolve_session_key_pi_adapter_priority_and_home_parity() { - // Per-adapter test for the Pi Coding Agent path. Pi injects - // PI_SESSION_ID into the env of every command its LLM-callable bash / - // powershell tools run (`core/tools/bash.js` `resolveSpawnContext`, - // gated on `exposeSessionEnvironment`, default true), so `wire ` - // run from a Pi session resolves a per-session identity with no - // operator setup. Holds four invariants: - // - // (a) Set to a real Pi session id -> that key wins resolution and two - // distinct Pi sessions map to two distinct session homes. This is - // the point of the adapter: two Pi sessions in the SAME cwd would - // otherwise collapse onto one persona via the cwd registry. - // (b) WIRE_SESSION_ID and CLAUDE_CODE_SESSION_ID outrank it; it - // outranks CODEX_SESSION_ID / COPILOT_AGENT_SESSION_ID / - // VSCODE_GIT_REPOSITORY_ROOT (pi sits at priority 3). - // (c) HOME PARITY: the same id string arriving as PI_SESSION_ID or as - // WIRE_SESSION_ID resolves to the SAME session home. The pi - // package (pi/extensions/wire.ts) pins WIRE_SESSION_ID itself for - // hosts that do not forward PI_SESSION_ID (SDK-embedded hosts); - // parity is what makes those two paths one identity rather than - // two personas for one conversation. - // (d) Unexpanded ${...} literal is rejected by the guard, like every - // other adapter. - let _guard = crate::config::test_support::ENV_LOCK - .lock() - .unwrap_or_else(|p| p.into_inner()); - - let prev_override = std::env::var_os("WIRE_SESSION_ID"); - let prev_claude = std::env::var_os("CLAUDE_CODE_SESSION_ID"); - let prev_pi = std::env::var_os("PI_SESSION_ID"); - let prev_codex = std::env::var_os("CODEX_SESSION_ID"); - let prev_copilot = std::env::var_os("COPILOT_AGENT_SESSION_ID"); - let prev_vscode = std::env::var_os("VSCODE_GIT_REPOSITORY_ROOT"); - // SAFETY: ENV_LOCK is held, serializing all env access. - unsafe { - std::env::remove_var("WIRE_SESSION_ID"); - std::env::remove_var("CLAUDE_CODE_SESSION_ID"); - std::env::remove_var("PI_SESSION_ID"); - std::env::remove_var("CODEX_SESSION_ID"); - std::env::remove_var("COPILOT_AGENT_SESSION_ID"); - std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT"); - } - - // (a) PI_SESSION_ID wins resolution and is labeled `pi`; distinct Pi - // sessions -> distinct homes; same id -> same home (resume). - let session_a = "0198c1f4-2b1e-7a3d-9c40-4f0d7c1b6a55"; - let session_b = "0198c1f4-2b1e-7a3d-9c40-4f0d7c1b6a56"; - unsafe { std::env::set_var("PI_SESSION_ID", session_a) }; - let r1 = resolve_session_key(); - assert!( - matches!(&r1, Some((k, src)) if k == session_a && *src == "pi"), - "PI_SESSION_ID must win resolution and be labeled pi; got {r1:?}" - ); - let home_a = session_home_for_key(&r1.as_ref().unwrap().0).unwrap(); - - unsafe { std::env::set_var("PI_SESSION_ID", session_b) }; - let home_b = session_home_for_key(&resolve_session_key().unwrap().0).unwrap(); - assert_ne!( - home_a, home_b, - "two distinct Pi session ids must map to distinct session homes" - ); - - unsafe { std::env::set_var("PI_SESSION_ID", session_a) }; - let home_a2 = session_home_for_key(&resolve_session_key().unwrap().0).unwrap(); - assert_eq!( - home_a, home_a2, - "same Pi session id must yield the same home across calls" - ); - - // (b) Priority: override > claude-code > pi > codex > copilot > vscode. - unsafe { std::env::set_var("WIRE_SESSION_ID", "operator-override") }; - let r_override = resolve_session_key(); - assert!( - matches!(&r_override, Some((k, src)) if k == "operator-override" && *src == "override"), - "WIRE_SESSION_ID must beat PI_SESSION_ID; got {r_override:?}" - ); - unsafe { std::env::remove_var("WIRE_SESSION_ID") }; - - unsafe { std::env::set_var("CLAUDE_CODE_SESSION_ID", "claude-wins-over-pi") }; - let r_claude = resolve_session_key(); - assert!( - matches!(&r_claude, Some((k, src)) if k == "claude-wins-over-pi" && *src == "claude-code"), - "CLAUDE_CODE_SESSION_ID must beat PI_SESSION_ID; got {r_claude:?}" - ); - unsafe { std::env::remove_var("CLAUDE_CODE_SESSION_ID") }; - - // pi beats every later adapter, so a stray Codex/Copilot/VS Code id in - // the environment cannot steal a Pi session's identity. - unsafe { - std::env::set_var("CODEX_SESSION_ID", "codex-loses-to-pi"); - std::env::set_var("COPILOT_AGENT_SESSION_ID", "copilot-loses-to-pi"); - std::env::set_var("VSCODE_GIT_REPOSITORY_ROOT", "/repo/loses-to-pi"); - }; - let r_pi_wins = resolve_session_key(); - assert!( - matches!(&r_pi_wins, Some((k, src)) if k == session_a && *src == "pi"), - "PI_SESSION_ID must beat CODEX/COPILOT/VSCODE adapters; got {r_pi_wins:?}" - ); - unsafe { - std::env::remove_var("CODEX_SESSION_ID"); - std::env::remove_var("COPILOT_AGENT_SESSION_ID"); - std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT"); - } - - // (c) Home parity — the invariant pi/extensions/wire.ts depends on. - unsafe { std::env::remove_var("PI_SESSION_ID") }; - unsafe { std::env::set_var("WIRE_SESSION_ID", session_a) }; - let via_override = resolve_session_key().unwrap(); - assert_eq!( - via_override.1, "override", - "WIRE_SESSION_ID keeps its own source label" - ); - assert_eq!( - session_home_for_key(&via_override.0).unwrap(), - home_a, - "one Pi session id must reach one session home whether it arrives as \ - PI_SESSION_ID or as WIRE_SESSION_ID — otherwise the pi package and a \ - plain bash-tool `wire` call would run as two different personas" - ); - - // (d) Unexpanded ${...} guard. - unsafe { - std::env::remove_var("WIRE_SESSION_ID"); - std::env::set_var("PI_SESSION_ID", "${PI_SESSION_ID}"); - }; - let r_guard = resolve_session_key(); - assert!( - !matches!(&r_guard, Some((k, _)) if k.contains("${")), - "unexpanded ${{...}} in PI_SESSION_ID must be rejected by the ${{}} guard; got {r_guard:?}" - ); - - // Restore any env we displaced. - // SAFETY: ENV_LOCK still held. - unsafe { - std::env::remove_var("WIRE_SESSION_ID"); - std::env::remove_var("PI_SESSION_ID"); - if let Some(v) = prev_override { - std::env::set_var("WIRE_SESSION_ID", v); - } - if let Some(v) = prev_claude { - std::env::set_var("CLAUDE_CODE_SESSION_ID", v); - } - if let Some(v) = prev_pi { - std::env::set_var("PI_SESSION_ID", v); - } - if let Some(v) = prev_codex { - std::env::set_var("CODEX_SESSION_ID", v); - } - if let Some(v) = prev_copilot { - std::env::set_var("COPILOT_AGENT_SESSION_ID", v); - } - if let Some(v) = prev_vscode { - std::env::set_var("VSCODE_GIT_REPOSITORY_ROOT", v); - } - } - } - #[test] fn resolve_session_key_copilot_cli_adapter_and_priority() { // Per-adapter test for the GitHub Copilot CLI path (Phase 2 of #59): @@ -2517,6 +2485,298 @@ mod tests { } } + #[test] + fn resolve_session_key_pi_adapter_priority_and_home_parity() { + // Per-adapter test for the Pi Coding Agent path. Pi injects + // PI_SESSION_ID into the env of every command its LLM-callable bash / + // powershell tools run (`core/tools/bash.js` `resolveSpawnContext`, + // gated on `exposeSessionEnvironment`, default true), so `wire ` + // run from a Pi session resolves a per-session identity with no + // operator setup. Holds four invariants: + // + // (a) Set to a real Pi session id -> that key wins resolution and two + // distinct Pi sessions map to two distinct session homes. This is + // the point of the adapter: two Pi sessions in the SAME cwd would + // otherwise collapse onto one persona via the cwd registry. + // (b) WIRE_SESSION_ID and CLAUDE_CODE_SESSION_ID outrank it; it + // outranks CODEX_SESSION_ID / COPILOT_AGENT_SESSION_ID / + // VSCODE_GIT_REPOSITORY_ROOT (pi sits at priority 3). + // (c) HOME PARITY: the same id string arriving as PI_SESSION_ID or as + // WIRE_SESSION_ID resolves to the SAME session home. The pi + // package (pi/extensions/wire.ts) pins WIRE_SESSION_ID itself for + // hosts that do not forward PI_SESSION_ID (SDK-embedded hosts); + // parity is what makes those two paths one identity rather than + // two personas for one conversation. + // (d) Unexpanded ${...} literal is rejected by the guard, like every + // other adapter. + let _guard = crate::config::test_support::ENV_LOCK + .lock() + .unwrap_or_else(|p| p.into_inner()); + + let prev_override = std::env::var_os("WIRE_SESSION_ID"); + let prev_claude = std::env::var_os("CLAUDE_CODE_SESSION_ID"); + let prev_pi = std::env::var_os("PI_SESSION_ID"); + let prev_codex = std::env::var_os("CODEX_SESSION_ID"); + let prev_copilot = std::env::var_os("COPILOT_AGENT_SESSION_ID"); + let prev_vscode = std::env::var_os("VSCODE_GIT_REPOSITORY_ROOT"); + // SAFETY: ENV_LOCK is held, serializing all env access. + unsafe { + std::env::remove_var("WIRE_SESSION_ID"); + std::env::remove_var("CLAUDE_CODE_SESSION_ID"); + std::env::remove_var("PI_SESSION_ID"); + std::env::remove_var("CODEX_SESSION_ID"); + std::env::remove_var("COPILOT_AGENT_SESSION_ID"); + std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT"); + } + + // (a) PI_SESSION_ID wins resolution and is labeled `pi`; distinct Pi + // sessions -> distinct homes; same id -> same home (resume). + let session_a = "0198c1f4-2b1e-7a3d-9c40-4f0d7c1b6a55"; + let session_b = "0198c1f4-2b1e-7a3d-9c40-4f0d7c1b6a56"; + unsafe { std::env::set_var("PI_SESSION_ID", session_a) }; + let r1 = resolve_session_key(); + assert!( + matches!(&r1, Some((k, src)) if k == session_a && *src == "pi"), + "PI_SESSION_ID must win resolution and be labeled pi; got {r1:?}" + ); + let home_a = session_home_for_key(&r1.as_ref().unwrap().0).unwrap(); + + unsafe { std::env::set_var("PI_SESSION_ID", session_b) }; + let home_b = session_home_for_key(&resolve_session_key().unwrap().0).unwrap(); + assert_ne!( + home_a, home_b, + "two distinct Pi session ids must map to distinct session homes" + ); + + unsafe { std::env::set_var("PI_SESSION_ID", session_a) }; + let home_a2 = session_home_for_key(&resolve_session_key().unwrap().0).unwrap(); + assert_eq!( + home_a, home_a2, + "same Pi session id must yield the same home across calls" + ); + + // (b) Priority: override > claude-code > pi > codex > copilot > vscode. + unsafe { std::env::set_var("WIRE_SESSION_ID", "operator-override") }; + let r_override = resolve_session_key(); + assert!( + matches!(&r_override, Some((k, src)) if k == "operator-override" && *src == "override"), + "WIRE_SESSION_ID must beat PI_SESSION_ID; got {r_override:?}" + ); + unsafe { std::env::remove_var("WIRE_SESSION_ID") }; + + unsafe { std::env::set_var("CLAUDE_CODE_SESSION_ID", "claude-wins-over-pi") }; + let r_claude = resolve_session_key(); + assert!( + matches!(&r_claude, Some((k, src)) if k == "claude-wins-over-pi" && *src == "claude-code"), + "CLAUDE_CODE_SESSION_ID must beat PI_SESSION_ID; got {r_claude:?}" + ); + unsafe { std::env::remove_var("CLAUDE_CODE_SESSION_ID") }; + + // pi beats every later adapter, so a stray Codex/Copilot/VS Code id in + // the environment cannot steal a Pi session's identity. + unsafe { + std::env::set_var("CODEX_SESSION_ID", "codex-loses-to-pi"); + std::env::set_var("COPILOT_AGENT_SESSION_ID", "copilot-loses-to-pi"); + std::env::set_var("VSCODE_GIT_REPOSITORY_ROOT", "/repo/loses-to-pi"); + }; + let r_pi_wins = resolve_session_key(); + assert!( + matches!(&r_pi_wins, Some((k, src)) if k == session_a && *src == "pi"), + "PI_SESSION_ID must beat CODEX/COPILOT/VSCODE adapters; got {r_pi_wins:?}" + ); + unsafe { + std::env::remove_var("CODEX_SESSION_ID"); + std::env::remove_var("COPILOT_AGENT_SESSION_ID"); + std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT"); + } + + // (c) Home parity — the invariant pi/extensions/wire.ts depends on. + unsafe { std::env::remove_var("PI_SESSION_ID") }; + unsafe { std::env::set_var("WIRE_SESSION_ID", session_a) }; + let via_override = resolve_session_key().unwrap(); + assert_eq!( + via_override.1, "override", + "WIRE_SESSION_ID keeps its own source label" + ); + assert_eq!( + session_home_for_key(&via_override.0).unwrap(), + home_a, + "one Pi session id must reach one session home whether it arrives as \ + PI_SESSION_ID or as WIRE_SESSION_ID — otherwise the pi package and a \ + plain bash-tool `wire` call would run as two different personas" + ); + + // (d) Unexpanded ${...} guard. + unsafe { + std::env::remove_var("WIRE_SESSION_ID"); + std::env::set_var("PI_SESSION_ID", "${PI_SESSION_ID}"); + }; + let r_guard = resolve_session_key(); + assert!( + !matches!(&r_guard, Some((k, _)) if k.contains("${")), + "unexpanded ${{...}} in PI_SESSION_ID must be rejected by the ${{}} guard; got {r_guard:?}" + ); + + // Restore any env we displaced. + // SAFETY: ENV_LOCK still held. + unsafe { + std::env::remove_var("WIRE_SESSION_ID"); + std::env::remove_var("PI_SESSION_ID"); + if let Some(v) = prev_override { + std::env::set_var("WIRE_SESSION_ID", v); + } + if let Some(v) = prev_claude { + std::env::set_var("CLAUDE_CODE_SESSION_ID", v); + } + if let Some(v) = prev_pi { + std::env::set_var("PI_SESSION_ID", v); + } + if let Some(v) = prev_codex { + std::env::set_var("CODEX_SESSION_ID", v); + } + if let Some(v) = prev_copilot { + std::env::set_var("COPILOT_AGENT_SESSION_ID", v); + } + if let Some(v) = prev_vscode { + std::env::set_var("VSCODE_GIT_REPOSITORY_ROOT", v); + } + } + } + + #[test] + fn resolve_local_sister_unique_refuses_ambiguous_persona() { + // A persona nickname is 243x242 word pairs seeded from the 8-hex + // fingerprint suffix, and v0.11 made that nickname the addressable + // handle. On a box with thousands of session homes the birthday bound + // is long past: the machine this was found on held 8,861 initialized + // homes and 566 handle groups shared by two different DIDs. Before this + // guard, `resolve_local_sister` returned the FIRST match in read_dir + // order, so `wire dial ` / `wire send ` could pair with or + // write signed events to whichever of the two the filesystem happened + // to enumerate. Locks the refusal, the actionable candidates, and the + // two paths that must keep working. + let _guard = crate::config::test_support::ENV_LOCK + .lock() + .unwrap_or_else(|p| p.into_inner()); + let tmp = std::env::temp_dir().join(format!("wire-sisters-{}", rand::random::())); + let _ = std::fs::remove_dir_all(&tmp); + let root = tmp.join("sessions"); + + let mk_session = |key: &str, handle: &str, fingerprint: &str| -> PathBuf { + let home = root.join("by-key").join(key); + let cfg = home.join("config").join("wire"); + std::fs::create_dir_all(&cfg).unwrap(); + std::fs::write( + cfg.join("agent-card.json"), + format!( + r#"{{"did":"did:wire:{handle}-{fingerprint}","handle":"{handle}","verify_keys":{{}}}}"# + ), + ) + .unwrap(); + home + }; + + // Two DISTINCT identities answering to ONE persona handle. + let h_a = mk_session("aaaa5c8806dde7b1", "amber-tarn", "11111111"); + let h_b = mk_session("bbbb53029dbcde65", "amber-tarn", "22222222"); + // One identity with a name nobody else answers to. + let _h_c = mk_session("cccc5fc3cbae0adf", "lone-larch", "33333333"); + + // sessions_root() resolves from inside a session home. + // SAFETY: ENV_LOCK is held. + unsafe { std::env::set_var("WIRE_HOME", &h_a) }; + + // (1) The collision must be refused, not resolved. + let amb = resolve_local_sister("amber-tarn"); + assert!( + matches!(&amb, Some(SisterMatch::Ambiguous(c)) if c.len() == 2), + "two DIDs sharing a persona handle must surface as Ambiguous; got {amb:?}" + ); + if let Some(SisterMatch::Ambiguous(c)) = &amb { + let homes: Vec<&str> = c.iter().map(|x| x.home.as_str()).collect(); + assert!( + homes.contains(&"aaaa5c8806dde7b1") && homes.contains(&"bbbb53029dbcde65"), + "candidates must name both homes so the caller can disambiguate; got {homes:?}" + ); + } + + // (2) Acting on it errors, and the error names both DIDs and both + // by-key homes (the disambiguator that find_session_home_by_name + // actually accepts). + let err = resolve_local_sister_unique("amber-tarn") + .expect_err("ambiguous persona must not resolve"); + let msg = err.to_string(); + for needle in [ + "not unique", + "did:wire:amber-tarn-11111111", + "did:wire:amber-tarn-22222222", + "aaaa5c8806dde7b1", + "bbbb53029dbcde65", + ] { + assert!( + msg.contains(needle), + "ambiguity error must contain {needle:?}; got: {msg}" + ); + } + + // (3) A name owned by one identity still resolves normally, and the + // token is the by-key home (the form resolve_local_session accepts). + let lone = resolve_local_sister_unique("lone-larch").unwrap(); + assert_eq!( + lone.as_deref(), + Some("cccc5fc3cbae0adf"), + "a unique persona must resolve to its by-key home" + ); + + // (4) No match is Ok(None) — the caller falls through to federation. + let missing = resolve_local_sister_unique("no-such-peer").unwrap(); + assert_eq!(missing, None, "unknown name must stay Ok(None)"); + + // (5) The remedy the ambiguity error prints must actually resolve, or + // the guard strands the operator with a name they cannot use. + assert_eq!( + resolve_local_sister_unique("did:wire:amber-tarn-11111111") + .unwrap() + .as_deref(), + Some("aaaa5c8806dde7b1"), + "the full DID printed by the error must resolve to exactly one home" + ); + assert_eq!( + resolve_local_sister_unique("bbbb53029dbcde65") + .unwrap() + .as_deref(), + Some("bbbb53029dbcde65"), + "the by-key home printed by the error must resolve to itself" + ); + + // (6) One identity at two homes is one agent, not a choice between + // agents. Refusing there is a false stop, and the refusal text would + // claim we might pick the wrong identity when both candidates are + // the same identity. + let twin = root.join("by-key").join("dddd5c8806dde7b1"); + std::fs::create_dir_all(twin.join("config").join("wire")).unwrap(); + std::fs::write( + twin.join("config").join("wire").join("agent-card.json"), + r#"{"did":"did:wire:amber-tarn-11111111","handle":"amber-tarn","verify_keys":{}}"#, + ) + .unwrap(); + let same_did_two_homes = resolve_local_sister("did:wire:amber-tarn-11111111"); + assert!( + matches!(&same_did_two_homes, Some(SisterMatch::Unique(_))), + "the same DID at two homes must not read as an ambiguity; got {same_did_two_homes:?}" + ); + let _ = std::fs::remove_dir_all(&twin); + + // (7) Dedupe must key on home, not display name: both colliding entries + // carry the same `session` (list_sessions overrides name to the + // handle), so a name-keyed dedupe would have collapsed them and let + // the ambiguity through. + assert_ne!(h_a, h_b, "fixture sanity: the two homes differ"); + + unsafe { std::env::remove_var("WIRE_HOME") }; + let _ = std::fs::remove_dir_all(&tmp); + } + #[test] fn list_sessions_sees_by_key_homes_and_root_resolves_from_inside() { // Regression (v0.13.2): v0.13 moved session homes under diff --git a/tests/cli.rs b/tests/cli.rs index 1daf21ce..fd280023 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1949,3 +1949,88 @@ fn tail_multi_peer_sorts_by_timestamp() { "expected 3 newest across peers (interleaved by timestamp)" ); } + +#[test] +fn session_current_reports_operative_identity_alongside_the_registry_name() { + // `wire session current` used to print only the cwd registry's name, which + // since v0.13 is not the identity that signs: resolution comes from a + // session-id key or the machine default and never from the registry. The + // two disagreeing silently is how an operator ends up sending as a persona + // they believe they are not. The registry answer is still reported (scripts + // parse it) and the operative identity is reported beside it. + let home = fresh_home(); + let up = run(&home, &["up", "--offline"]); + assert!( + up.status.success(), + "offline up must succeed: {}", + String::from_utf8_lossy(&up.stderr) + ); + + let out = Command::new(wire_bin()) + .args(["session", "current", "--json"]) + .current_dir(&home) + .env("WIRE_HOME", &home) + .env("WIRE_HOME_FORCE", "1") + .output() + .expect("spawn wire session current --json"); + assert!(out.status.success()); + let v: serde_json::Value = + serde_json::from_slice(&out.stdout).expect("session current --json must emit JSON"); + for key in [ + "cwd", + "session", + "operative_handle", + "session_source", + "config_dir", + "wire_home", + "agrees", + "note", + ] { + assert!( + v.get(key).is_some(), + "`session current --json` must expose `{key}`; got {v}" + ); + } + // Fresh home: nothing registered for this cwd, so there is no claim to + // compare against. `agrees` is null (not true) because agreement was never + // verified. The operative identity is still named. + assert!(v["session"].is_null(), "no registry entry expected; got {v}"); + assert!( + v["agrees"].is_null(), + "agrees must be null when there is nothing to compare; got {v}" + ); + assert!( + v["operative_handle"].is_string(), + "operative_handle must name the signing persona; got {v}" + ); + // This harness forces the legacy home shape, so the source is the + // WIRE_HOME_FORCE label; a plain WIRE_HOME pin reports `env:WIRE_HOME`. Both + // are the deliberate-fleet-share pins, and neither is a cwd-derived source. + assert!( + ["env:WIRE_HOME", "env:WIRE_HOME_FORCE"].contains(&v["session_source"].as_str().unwrap_or("")), + "session_source must name the explicit home pin; got {v}" + ); + + // The historical stdout contract is untouched: first line is the registry + // name, or the same sentinel. The honesty went to stderr so nothing parsing + // stdout breaks. + let text = Command::new(wire_bin()) + .args(["session", "current"]) + .current_dir(&home) + .env("WIRE_HOME", &home) + .env("WIRE_HOME_FORCE", "1") + .output() + .expect("spawn wire session current"); + assert_eq!( + String::from_utf8_lossy(&text.stdout), + "(no session registered for this cwd)\n", + "stdout must keep its historical single-line answer" + ); + let err = String::from_utf8_lossy(&text.stderr); + assert!( + err.contains("does not select identity") && err.contains("session_source="), + "stderr must state that the registry does not drive identity: {err}" + ); + + let _ = std::fs::remove_dir_all(&home); +} From 563c7f0030a28c48e89e836fa26e95a25c1bbd50 Mon Sep 17 00:00:00 2001 From: Paul Logan Date: Sat, 29 Aug 2026 09:35:09 -0700 Subject: [PATCH 3/9] fix(setup): label the Pi target as bridge-only instead of implying it works wire setup listed `Pi: ~/.pi/agent/mcp.json` among the hosts it would wire up, and `--apply` would write it. Pi has no MCP client, so that file is read only by the third-party pi-mcp-adapter: on a box with Pi installed and no adapter, setup --apply looked like it had connected Pi and had not. Kept the target rather than deleting it, since anyone who does run the adapter still wants the write. Added the note beside it in both the dry-run listing and the post-apply summary, naming the bridge, pointing at the native package for everyone else, and flagging that the shared snippet pins WIRE_SESSION_ID to ${CLAUDE_CODE_SESSION_ID}, the wrong variable under Pi. Left the snippet as is on purpose: whether pi-mcp-adapter expands ${VAR} at all is third-party behaviour this commit does not verify, so writing ${PI_SESSION_ID} there would replace one unverified claim with another. The note says what is wrong without pretending to know the fix. wire's valid_session_key() guard means an unexpanded ${...} literal is rejected and falls through rather than hashing into one shared home. bridge_notes() is a function rather than two inline loops so the selection is unit-testable without enumerating real host paths. Test locks both that Pi carries the note and that hosts with a real MCP client do not. --- src/cli/setup.rs | 64 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/src/cli/setup.rs b/src/cli/setup.rs index 60b3b170..96738a80 100644 --- a/src/cli/setup.rs +++ b/src/cli/setup.rs @@ -398,6 +398,31 @@ pub(crate) fn standard_mcp_entry() -> Value { }) } +/// Hosts that need a third-party bridge before they will read the entry +/// `wire setup` writes, with the note to show for them. Pi has no MCP client of +/// its own (its README: "No MCP."), so `~/.pi/agent/mcp.json` is inert unless +/// pi-mcp-adapter is installed. Saying so is the difference between `--apply` +/// looking like it connected the host and being an honest account of what was +/// written. +const BRIDGE_ONLY_HOSTS: &[(&str, &str)] = &[( + "Pi", + "Pi has no MCP client (its README: \"No MCP.\"). ~/.pi/agent/mcp.json is read\n \ + by the third-party pi-mcp-adapter, not by Pi. For Pi prefer the native\n \ + package: `pi install /pi-plugin` — no adapter, no MCP server.\n \ + The snippet above also pins WIRE_SESSION_ID to ${CLAUDE_CODE_SESSION_ID},\n \ + which is the wrong variable under Pi.", +)]; + +/// Notes for the bridge-only hosts present in `present`'s judgement. Shared by +/// the dry-run listing and the post-apply summary so both say the same thing. +fn bridge_notes(present: impl Fn(&str) -> bool) -> Vec<(&'static str, &'static str)> { + BRIDGE_ONLY_HOSTS + .iter() + .filter(|(host, _)| present(host)) + .copied() + .collect() +} + pub(crate) fn cmd_setup(apply: bool) -> Result<()> { use crate::adapters::harness::HARNESS_ADAPTERS; use std::path::PathBuf; @@ -421,6 +446,7 @@ pub(crate) fn cmd_setup(apply: bool) -> Result<()> { println!("{entry_pretty}"); println!(); + if !apply { println!("Probable MCP host config locations on this machine:"); for (name, path) in &targets { @@ -436,6 +462,10 @@ pub(crate) fn cmd_setup(apply: bool) -> Result<()> { println!( "Existing entries with a different command keep yours unchanged unless wire's exact entry is missing." ); + for (host, note) in bridge_notes(|h| targets.iter().any(|(name, _)| *name == h)) { + println!(); + println!("Note on {host}: {note}"); + } return Ok(()); } @@ -471,6 +501,11 @@ pub(crate) fn cmd_setup(apply: bool) -> Result<()> { println!(" {line}"); } } + for (host, note) in bridge_notes(|h| modified.iter().any(|l| l.starts_with(&format!("✓ {h} ")))) + { + println!(); + println!("Note on {host}: {note}"); + } Ok(()) } @@ -898,3 +933,32 @@ mod relay_url_tests { ); } } + +#[cfg(test)] +mod tests { + use super::bridge_notes; + + /// The Pi target writes `~/.pi/agent/mcp.json`, which Pi proper never reads. + /// If that note ever stops being attached to the Pi target, `wire setup + /// --apply` is silently misleading again, so both selection paths are locked. + #[test] + fn pi_target_carries_the_bridge_note() { + let dry_run = bridge_notes(|h| h == "Pi"); + assert_eq!(dry_run.len(), 1, "Pi must carry exactly one note"); + assert_eq!(dry_run[0].0, "Pi"); + assert!( + dry_run[0].1.contains("pi-mcp-adapter"), + "note must name the bridge Pi needs: {}", + dry_run[0].1 + ); + assert!( + dry_run[0].1.contains("pi-plugin"), + "note must point at the native package: {}", + dry_run[0].1 + ); + + // Hosts with a real MCP client must not be flagged. + assert!(bridge_notes(|h| h == "Cursor").is_empty()); + assert!(bridge_notes(|_| false).is_empty()); + } +} From 960498eca3f2cae62ec38ca4318f56e0b5ea4d48 Mon Sep 17 00:00:00 2001 From: Paul Logan Date: Sat, 29 Aug 2026 09:43:12 -0700 Subject: [PATCH 4/9] feat(session): migrate pre-RFC-006 named homes into the by-key layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC-006 Part A made sessions/by-key/ the one layout, and the readers moved: list_sessions scans by-key only, and session_dir hashes a name into by-key. Nothing reads the top-level sessions/ location any more, so a home left there is unreachable even though its keypair is intact. This box still has five of them, one per project, created before v0.13 by `wire session new`. The failure is quiet in a specific way. `wire session current` names the project, the registry entry looks right, and `wire whoami` for that name answers "not initialized" — so the next `wire up` mints a *second* identity for a project that already has one, and the orphan keeps whatever pairings it had. It reads as "the cwd registry is naming a different agent than my session", which is how this was reported. `wire session migrate ` moves the home back into the layout every reader understands, so --session-by-name, `session list`, `session env` and `session destroy` reach it again. It is a rename inside one filesystem, and the rollback command is printed. Safety, because this moves live keys: - dry-run by default; `--apply` is what touches the filesystem - refuses when the by-key target already exists: two homes are two identities, and merging picks one keypair and orphans the other's pairings - refuses when the legacy home's daemon pid is alive (platform::process_alive) - verifies both ends after the rename before reporting success - rejects any name that is not a plain single component before touching disk, since the by-key home derives from the sanitized form and a path-shaped name would escape the sessions root Verified end to end in a temp root: before the move `session list` showed nothing; after, the persona is listed, `session env` resolves, `whoami` through the new home returns the SAME handle, a re-run reports "already in the by-key layout", and a planted collision refused with both dirs intact. Tests: two CLI tests (full move + collision refusal, both asserting the keypair survives), one lib test on the name-shape guard. --- README.md | 2 +- docs/AGENT_INTEGRATION.md | 1 + src/cli/mod.rs | 23 ++++++ src/cli/session.rs | 159 ++++++++++++++++++++++++++++++++++++++ src/session.rs | 62 +++++++++++++++ tests/cli.rs | 136 ++++++++++++++++++++++++++++++++ 6 files changed, 382 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 260f977a..866a32e1 100644 --- a/README.md +++ b/README.md @@ -239,7 +239,7 @@ The design contracts are in [docs/](docs/). - `wire accept-invite ` — accept a federation invite URL minted by another agent. - `wire reject ` — refuse an inbound pair request. - `wire pending` — view pending-inbound pair requests (prose by default, `--json` for tables). -- `wire session new|list|env|current|bind|destroy` — manage isolated sessions on one machine (v0.5.16+). Each session = own identity + slot + daemon. Use when multiple agents run on the same box (e.g. Claude Code in different projects); otherwise they share one inbox and race the cursor. `wire session bind ` (v0.7.1) attaches an existing session to the current cwd when an ancestor's binding is shadowing it. See [the multi-session recipe](docs/AGENT_INTEGRATION.md#multi-session-on-one-machine-v0516). +- `wire session new|list|env|current|bind|migrate|destroy` — manage isolated sessions on one machine (v0.5.16+). Each session = own identity + slot + daemon. Use when multiple agents run on the same box (e.g. Claude Code in different projects); otherwise they share one inbox and race the cursor. `wire session bind ` (v0.7.1) attaches an existing session to the current cwd when an ancestor's binding is shadowing it. `wire session migrate ` moves a pre-RFC-006 `sessions/` home into the `sessions/by-key/` layout that every reader uses now, so an old named session shows up in `session list` again instead of being silently re-minted under the same name. Dry-run by default; `--apply` moves the directory. See [the multi-session recipe](docs/AGENT_INTEGRATION.md#multi-session-on-one-machine-v0516). - `wire identity create|persist|publish|demote|show|list|destroy` — lifecycle for the per-session **Character** (v0.7.0). Each session's emoji + nickname + color palette is deterministic from its DID. (v0.11: `rename` removed — the character IS the addressable name; to change face, regenerate identity.) - `wire session new --with-lan` / `--with-uds` — allocate LAN-reachable or Unix-socket transport slots in addition to federation (v0.7.0). Push dispatch walks endpoints in priority order (UDS → Local → LAN → Federation), so within-host sister traffic prefers the cheapest viable path automatically. - `wire relay-server --bind 127.0.0.1:8771 --local-only` + `wire session new --with-local` — dual-slot sessions (v0.5.17). Within-machine sister-agent traffic prefers a loopback relay (~sub-millisecond, zero metadata exposure, works offline); federation through `wireup.net` keeps working for cross-box traffic. Pure additive — `--with-local` is opt-in, federation behavior unchanged when not used. diff --git a/docs/AGENT_INTEGRATION.md b/docs/AGENT_INTEGRATION.md index 00027aee..3f92c38a 100644 --- a/docs/AGENT_INTEGRATION.md +++ b/docs/AGENT_INTEGRATION.md @@ -136,6 +136,7 @@ The project-local `.mcp.json` pattern is the recommended Claude Code setup: each ```bash $ wire session list # enumerate all sessions on this box $ wire session current # which session does this cwd map to? +$ wire session migrate # move a pre-RFC-006 sessions/ home into by-key/ (dry run; --apply moves) $ wire session destroy --force # remove (irrecoverable) ``` diff --git a/src/cli/mod.rs b/src/cli/mod.rs index a216f9b0..aaf47929 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1574,6 +1574,26 @@ pub enum SessionCommand { #[arg(long)] json: bool, }, + /// Move a pre-RFC-006 `sessions/` home into the 1.0 + /// `sessions/by-key/` layout. RFC-006 Part A made by-key the one + /// layout, so nothing reads the top-level location any more: a home left + /// there is invisible to `wire session list`, `--session `, and + /// `wire session destroy`, and creating the name again mints a second + /// identity for it. Dry-run by default — `--apply` moves the directory + /// (a rename; the rollback command is printed). Refuses when the by-key + /// home already exists or the legacy home's daemon is live. + Migrate { + /// Session name whose legacy home should move. Omit with `--all`. + name: Option, + /// Migrate every legacy home under the sessions root. + #[arg(long)] + all: bool, + /// Actually move the directory. Without this, print the plan only. + #[arg(long)] + apply: bool, + #[arg(long)] + json: bool, + }, /// Tear down a session: kills its daemon (if running), deletes its /// state directory, and removes it from the registry. Requires /// `--force` because state loss is unrecoverable (keypair gone). @@ -2434,6 +2454,9 @@ fn cmd_session(cmd: SessionCommand) -> Result<()> { SessionCommand::Env { name, json } => session::cmd_session_env(name.as_deref(), json), SessionCommand::Current { json } => session::cmd_session_current(json), SessionCommand::Bind { name, json } => cmd_session_bind(name.as_deref(), json), + SessionCommand::Migrate { name, all, apply, json } => { + session::cmd_session_migrate(name.as_deref(), all, apply, json) + } SessionCommand::Destroy { name, force, json } => { session::cmd_session_destroy(&name, force, json) } diff --git a/src/cli/session.rs b/src/cli/session.rs index 0494e980..b1534903 100644 --- a/src/cli/session.rs +++ b/src/cli/session.rs @@ -1425,6 +1425,165 @@ pub(super) fn cmd_session_destroy(name_arg: &str, force: bool, as_json: bool) -> Ok(()) } +/// `wire session migrate [] [--all] [--apply]` — move a pre-RFC-006 +/// `sessions/` home into the 1.0 `sessions/by-key/` layout. +/// +/// RFC-006 Part A made `by-key` the one layout and nothing reads the top-level +/// location any more, so a home left there is unreachable by name even with its +/// keypair intact: `wire --session whoami` answers "not initialized" +/// while the persona inside keeps signing. Worse, `init`/`up` for that name then +/// mints a *second* identity, which is how one project ends up owning two agents. +/// +/// Dry-run by default — `--apply` is what moves bytes. The move is a rename +/// within one filesystem, so the rollback line printed afterwards is the way +/// back; nothing here deletes a key. +pub(crate) fn cmd_session_migrate( + name: Option<&str>, + all: bool, + apply: bool, + as_json: bool, +) -> Result<()> { + let candidates: Vec = match (name.map(str::trim), all) { + (Some(n), false) => vec![n.to_string()], + (None, true) => crate::session::legacy_named_homes(), + (None, false) => { + bail!("name a session to migrate, or pass --all to move every legacy home") + } + (Some(_), true) => bail!("pass a name or --all, not both"), + }; + + #[derive(serde::Serialize)] + struct Row { + name: String, + from: Option, + to: String, + action: String, + } + let mut rows: Vec = Vec::new(); + let mut moved: Vec<(std::path::PathBuf, std::path::PathBuf)> = Vec::new(); + + for raw in candidates { + let to = crate::session::session_dir(&crate::session::sanitize_name(&raw))?; + let Some(from) = crate::session::legacy_named_home_dir(&raw) else { + let action = if to.exists() { + "already in the by-key layout; nothing to move".to_string() + } else { + "no legacy home at sessions/; a name that changes under \ + sanitizing is not migratable here" + .to_string() + }; + rows.push(Row { + name: raw, + from: None, + to: to.display().to_string(), + action, + }); + continue; + }; + + // Two homes are two identities. Merging them would pick one keypair and + // silently orphan the other's pairings, so refuse and let the operator + // decide which one keeps the name. + if to.exists() { + rows.push(Row { + name: raw, + from: Some(from.display().to_string()), + to: to.display().to_string(), + action: "refused: the by-key home already exists; wire will not merge two homes" + .to_string(), + }); + continue; + } + // Renaming out from under a live daemon would leave it writing to a path + // that no longer resolves to its name. + if let Some(pid) = crate::session::session_daemon_pid(&from) { + if crate::platform::process_alive(pid) { + rows.push(Row { + name: raw, + from: Some(from.display().to_string()), + to: to.display().to_string(), + action: format!("refused: daemon pid {pid} is live on this home; stop it first"), + }); + continue; + } + } + + if !apply { + rows.push(Row { + name: raw, + from: Some(from.display().to_string()), + to: to.display().to_string(), + action: "would move (pass --apply)".to_string(), + }); + continue; + } + + if let Some(parent) = to.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("creating {}", parent.display()))?; + } + std::fs::rename(&from, &to).with_context(|| { + format!("moving {} to {}", from.display(), to.display()) + })?; + // Confirm both ends before claiming the move: a rename that half-happened + // (different filesystem, permissions) must not be reported as success. + anyhow::ensure!( + crate::session::is_session_home(&to) && !from.exists(), + "move did not land cleanly: {} exists: {}, source still present: {}", + to.display(), + to.exists(), + from.exists() + ); + moved.push((from.clone(), to.clone())); + rows.push(Row { + name: raw, + from: Some(from.display().to_string()), + to: to.display().to_string(), + action: "moved".to_string(), + }); + } + + if as_json { + println!( + "{}", + serde_json::to_string(&json!({ + "dry_run": !apply, + "migrated": moved.len(), + "rows": rows, + }))? + ); + return Ok(()); + } + + println!( + "{}", + if apply { + "migrated:" + } else { + "wire session migrate (dry run — pass --apply to move):" + } + ); + if rows.is_empty() { + println!(" no legacy session homes found"); + } + for row in &rows { + match &row.from { + Some(from) => println!(" {}: {} -> {}\n {}", row.name, from, row.to, row.action), + None => println!(" {}: {}", row.name, row.action), + } + } + if !moved.is_empty() { + println!(); + println!("Rollback (move the home back):"); + for (from, to) in &moved { + println!(" mv {} {}", to.display(), from.display()); + } + println!(); + println!("Check: eval \"$(wire session env )\" && wire whoami"); + } + Ok(()) +} + #[cfg(test)] mod coerce_object_root_tests { use super::coerce_object_root; diff --git a/src/session.rs b/src/session.rs index b27e884d..7bc063df 100644 --- a/src/session.rs +++ b/src/session.rs @@ -172,6 +172,53 @@ pub fn find_session_home_by_name(name: &str) -> Result> { Ok(None) } +/// True when `path` is a wire session home rather than a stray directory: a +/// self-card is written by `init` and is what every reader needs. +pub fn is_session_home(path: &std::path::Path) -> bool { + path.join("config").join("wire").join("agent-card.json").is_file() +} + +/// The pre-RFC-006 layout: `sessions/` used to be where a named session +/// lived. Nothing reads that path any more — `list_sessions` scans `by-key` +/// only, and `session_dir` hashes a name into `by-key` — so a home left here is +/// unreachable: `wire --session whoami` answers "not initialized", while +/// the persona inside is intact. `init`/`up` on that same name then mints a +/// second identity, which is how one name ends up owning two agents. +/// +/// Returns Some only for a plain single-component name (nothing path-shaped, +/// nothing that changes under `sanitize_name`, since the by-key home is derived +/// from the sanitized form) whose legacy home exists on disk. +pub fn legacy_named_home_dir(name: &str) -> Option { + let trimmed = name.trim(); + if trimmed.is_empty() || trimmed != sanitize_name(trimmed) { + return None; + } + let candidate = sessions_root().ok()?.join(trimmed); + is_session_home(&candidate).then_some(candidate) +} + +/// Every top-level legacy home under the sessions root, by directory name. +/// `by-key` itself is the current layout, not a candidate. +pub fn legacy_named_homes() -> Vec { + let mut out = Vec::new(); + let Ok(root) = sessions_root() else { + return out; + }; + let Ok(entries) = std::fs::read_dir(&root) else { + return out; + }; + for entry in entries.flatten() { + let Some(name) = entry.file_name().to_str().map(|s| s.to_string()) else { + continue; + }; + if name == "by-key" || !is_session_home(&entry.path()) { + continue; + } + out.push(name); + } + out.sort(); + out +} /// Registry tracks `cwd → session_name` so repeated `wire session new` /// from the same project reuses the same identity instead of creating /// a fresh one each time. Lives at `/registry.json`. @@ -2485,6 +2532,21 @@ mod tests { } } + #[test] + fn legacy_named_home_dir_refuses_names_that_are_not_plain_keys() { + // `legacy_named_home_dir` joins the typed name straight onto the sessions + // root. The by-key home is derived from the *sanitized* form, so a name + // that changes under sanitizing would move a directory to a home its own + // name does not hash to — and a path-shaped name would escape the root + // entirely. Both are refused before any filesystem access. + for bad in ["", " ", "..", "../by-key", "a/b", "./x", "Legacy Name!"] { + assert!( + legacy_named_home_dir(bad).is_none(), + "legacy lookup must refuse {bad:?}" + ); + } + } + #[test] fn resolve_session_key_pi_adapter_priority_and_home_parity() { // Per-adapter test for the Pi Coding Agent path. Pi injects diff --git a/tests/cli.rs b/tests/cli.rs index fd280023..090f87c0 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -2034,3 +2034,139 @@ fn session_current_reports_operative_identity_alongside_the_registry_name() { let _ = std::fs::remove_dir_all(&home); } + +/// RFC-006 Part A made `sessions/by-key/` the only layout readers look +/// at, so a home still sitting at `sessions/` is unreachable: `session +/// list` skips it and creating the name again mints a second identity for the +/// same project. `wire session migrate` moves it back into reach. Dry-run by +/// default, and the identity must survive the move. +#[test] +fn session_migrate_moves_a_legacy_named_home_into_the_by_key_layout() { + let home = fresh_home(); + let legacy = home.join("sessions").join("legacy-api"); + std::fs::create_dir_all(&legacy).unwrap(); + let init = Command::new(wire_bin()) + .args(["init", "--offline"]) + .env("WIRE_HOME", &legacy) + .env("WIRE_HOME_FORCE", "1") + .output() + .expect("init legacy home"); + assert!( + init.status.success(), + "legacy home init failed: {}", + String::from_utf8_lossy(&init.stderr) + ); + let before: serde_json::Value = serde_json::from_slice( + &Command::new(wire_bin()) + .args(["whoami", "--json"]) + .env("WIRE_HOME", &legacy) + .env("WIRE_HOME_FORCE", "1") + .output() + .unwrap() + .stdout, + ) + .unwrap(); + let handle = before["handle"].as_str().unwrap().to_string(); + + // Invisible to the reader before the move. + let list = run(&home, &["session", "list", "--json"]); + assert!(list.status.success()); + let listed = String::from_utf8_lossy(&list.stdout).into_owned(); + assert!( + !listed.contains(&handle), + "a legacy top-level home must not be listed before migration; got {listed}" + ); + + // Dry run: names the move, touches nothing. + let dry = run(&home, &["session", "migrate", "legacy-api", "--json"]); + assert!(dry.status.success()); + let plan: serde_json::Value = serde_json::from_slice(&dry.stdout).unwrap(); + assert_eq!(plan["dry_run"], true); + assert_eq!(plan["migrated"], 0); + assert!(legacy.is_dir(), "dry run must not move the home"); + let target = PathBuf::from(plan["rows"][0]["to"].as_str().unwrap()); + assert!(target.starts_with(home.join("sessions").join("by-key"))); + assert!(!target.exists(), "dry run must not create the target"); + + // Apply: the move lands and the identity is the same one. + let apply = run(&home, &["session", "migrate", "legacy-api", "--apply", "--json"]); + assert!(apply.status.success()); + let done: serde_json::Value = serde_json::from_slice(&apply.stdout).unwrap(); + assert_eq!(done["migrated"], 1, "apply must move one home: {done}"); + assert!(!legacy.exists(), "source must be gone after the move"); + let after: serde_json::Value = serde_json::from_slice( + &Command::new(wire_bin()) + .args(["whoami", "--json"]) + .env("WIRE_HOME", &target) + .env("WIRE_HOME_FORCE", "1") + .output() + .unwrap() + .stdout, + ) + .unwrap(); + assert_eq!( + after["handle"].as_str().unwrap(), + handle, + "the move must carry the same keypair, not mint a new one" + ); + + // Reachable afterwards, and a second run changes nothing. + let list = run(&home, &["session", "list", "--json"]); + assert!( + String::from_utf8_lossy(&list.stdout).contains(&handle), + "the migrated home must appear in `session list`" + ); + let again = run(&home, &["session", "migrate", "legacy-api"]); + assert!(again.status.success()); + assert!( + String::from_utf8_lossy(&again.stdout).contains("already in the by-key layout"), + "re-running must be a no-op: {}", + String::from_utf8_lossy(&again.stdout) + ); + let _ = std::fs::remove_dir_all(&home); +} + +/// Two homes for one name are two identities. Merging them would quietly keep +/// one keypair and orphan the other's pairings, so the move is refused and both +/// directories are left exactly where they were. +#[test] +fn session_migrate_refuses_to_merge_two_homes() { + let home = fresh_home(); + let legacy = home.join("sessions").join("clash-api"); + std::fs::create_dir_all(&legacy).unwrap(); + let init = Command::new(wire_bin()) + .args(["init", "--offline"]) + .env("WIRE_HOME", &legacy) + .env("WIRE_HOME_FORCE", "1") + .output() + .expect("init legacy home"); + assert!(init.status.success()); + + let plan: serde_json::Value = serde_json::from_slice( + &run(&home, &["session", "migrate", "clash-api", "--json"]).stdout, + ) + .unwrap(); + let target = PathBuf::from(plan["rows"][0]["to"].as_str().unwrap()); + std::fs::create_dir_all(&target).unwrap(); + let rival = Command::new(wire_bin()) + .args(["init", "--offline"]) + .env("WIRE_HOME", &target) + .env("WIRE_HOME_FORCE", "1") + .output() + .expect("init rival by-key home"); + assert!(rival.status.success()); + + let apply = run(&home, &["session", "migrate", "clash-api", "--apply", "--json"]); + assert!(apply.status.success()); + let done: serde_json::Value = serde_json::from_slice(&apply.stdout).unwrap(); + assert_eq!(done["migrated"], 0, "collision must not move: {done}"); + assert!( + done["rows"][0]["action"] + .as_str() + .unwrap() + .contains("will not merge two homes"), + "refusal must explain itself: {done}" + ); + assert!(legacy.is_dir(), "refusal must leave the source in place"); + let _ = std::fs::remove_dir_all(&home); +} From ba8f9407f99933463e21008cd0f8b35932674aff Mon Sep 17 00:00:00 2001 From: Paul Logan Date: Sat, 29 Aug 2026 09:43:37 -0700 Subject: [PATCH 5/9] docs: changelog entries for the Pi adapter, the collision guard, and session migrate --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 217fcf36..03c47e0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,17 @@ the PR description linked in each section. ### Added - **OpenCode gets per-session wire identities via a shipped plugin** (#92 follow-on): OpenCode forwards no session-id env var to spawned MCP servers — measured on 1.18.25, the MCP child sees only `OPENCODE`/`OPENCODE_PID` — so every `wire mcp` boot either reused one static key across all sessions or minted a throwaway identity per launch (one box measured 8,861 by-key homes). The gap cannot be closed from wire's side, because the key must exist before `wire mcp` execs. But OpenCode fires `session.created` ~0.5s *before* booting local MCP servers (measured 16:03:39.600Z → 16:03:40.101Z), and its plugin system runs in-process with a `config` hook that can still mutate the MCP env in that window. Ships **`opencode-plugin/wire-session.js`**: drop it in `~/.config/opencode/plugin/` and each OpenCode session resolves `WIRE_SESSION_ID=opencode-` — so birth identity and resume identity are the same key (`-c`/`-s` included), and no two sessions share one inbox. Resolution priority: exported `WIRE_SESSION_ID` (pinned persona wins) → first top-level `session.created`/`session.updated` event → `-s ` argv or `-c` resolved read-only against `opencode.db` (newest top-level session for the cwd; `--fork` gets a fresh key) → fresh UUID, which yields a new persona rather than ever a foreign one. Live-verified with `opencode run` + `wire_wire_whoami`: fresh→`-s` resume kept one persona; fresh→`-c`×2 kept one persona; consecutive fresh runs differed; exported `WIRE_SESSION_ID` overrode all of it. A lost event race falls back to the UUID path — a new persona, never a wrong one. `docs/integrations/OPENCODE.md` rewritten against what was measured. +- **Pi (the coding agent) is a first-class session host, with no MCP in the loop** (#351 follow-on): Pi has no MCP client — its README says "No MCP." — so every Pi story in this repo routed through the third-party `pi-mcp-adapter`, and `wire setup`'s Pi target wrote `~/.pi/agent/mcp.json`, a file Pi proper never reads. Neither the adapter nor that file existed on a box with Pi installed, so the documented path had never actually worked. Pi *does* forward a session id: its `bash`/`powershell` tools inject `PI_SESSION_ID` when spawned with a session context (`core/tools/bash.js` → `resolveSpawnContext`, gated on `exposeSessionEnvironment`, default on). Added `PI_SESSION_ID` to `resolve_session_key` at priority 3 (label `pi`), between Claude Code and Codex, so a Pi shell resolves `sessions/by-key/` instead of falling through to the machine default and sharing one inbox with every other session on the box. Two invariants the tests lock: `PI_SESSION_ID` outranks `CODEX`/`COPILOT`/`VSCODE` (a stray host id must not steal a Pi session's identity), and **home parity** — one id string resolves to one home whether it arrives as `PI_SESSION_ID` or `WIRE_SESSION_ID`, which is what lets the package pin the key itself (Pi does not put `PI_SESSION_ID` in an extension's own env, and deletes it for context-less shells). Ships **`pi-plugin/`**: a Pi package with 13 native tools over the wire CLI, a `wire-pi` skill, and `/wire-watch` for the session-lifetime inbox stream; `wire_accept` and `wire_setup` are consent-gated so nothing mints a relay claim or grants peer access on an agent's initiative. Verified through the real tool path (`pi install ./pi-plugin`, then `wire_whoami` → a real persona; a no-identity run returned guidance and created nothing). `docs/integrations/PI.md` rewritten against what was verified; `docs/PLUGIN.md` gained the Pi section it lacked. + +- **`wire session migrate` — pre-RFC-006 named homes are reachable again**: RFC-006 Part A made `sessions/by-key/` the one layout and the readers followed (`list_sessions` scans `by-key` only; `session_dir` hashes into `by-key`), but homes still sitting at `sessions/` were left unreachable with an intact keypair. The failure is quiet: `wire session current` names the project, the registry looks right, and `whoami` for that name answers "not initialized" — so the next `wire up` mints a **second** identity for a project that already has one. Reads as "the cwd registry names a different agent than my session." The new verb renames the home into the 1.0 layout so `session list`, `session env` and `session destroy` reach it again, and prints the rollback. Dry-run by default; refuses when the by-key target exists (two homes are two identities — merging orphans one's pairings), when the legacy daemon pid is alive, or for any name that is not a plain single component (the by-key home derives from the *sanitized* form, so a name that changes under sanitizing would move to a home its name does not hash to; a path-shaped name would escape the sessions root). Verified in a temp root: invisible before, listed after, same handle through the new home, re-run is a no-op, planted collision refuses. + +### Fixed + +- **Persona collisions no longer resolve in readdir order** (#351 follow-on): a persona nickname is `ADJECTIVES[243] × NOUNS[242]` = 58,806 names seeded from the DID's 8-hex fingerprint suffix, and v0.11 made that nickname the addressable handle baked into the DID — so collision odds hit 50% at ~285 identities. Measured on one box: **8,861 initialized session homes, 566 handle groups already shared by two different DIDs** (e.g. `did:wire:agate-heron-aead0646` / `did:wire:agate-heron-4c4d66bc`). `resolve_local_sister` returned the **first** match in `read_dir` order, so `wire dial ` could pair with, and `wire send ` could write signed events to, whichever of two identities the filesystem enumerated first. It now returns `Unique | Ambiguous`, and `resolve_local_sister_unique` refuses to guess at all four acting call sites (send's auto-pair, dial's resolution ladder, both `wire add` branches), listing a remedy per candidate. Two details that would each have silently defeated the guard: `list_sessions` overrides `name` to the persona handle, so colliding homes arrive with an *identical* `name` (dedupe keys on `home_dir`, not `name`); and the first remedy printed was a dead end — neither the by-key home nor a full DID was matched by `resolve_local_sister` or `resolve_local_session`, so both now match both, the unique token is the home, and both printed forms were confirmed to resolve. One identity at two homes collapses to `Unique` rather than a refusal (one agent is not a choice between agents); measured first, so that case is prevented, not observed. + +- **`wire session current` reports the identity that signs, not just the registry's guess**: since v0.13 identity never resolves from the cwd registry, yet the registry still answered for four display paths. Verified disagreement on a live box: in a registered cwd the command printed `slancha-api` while `wire whoami` signed as `cobalt-nettle` — the machine default. It now reports `operative_handle`, `session_source`, `config_dir`, `wire_home` and `agrees` beside the registry name; `agrees` is `null` when there was nothing to compare rather than claiming agreement unchecked. stdout keeps its historical single-line answer so parsers hold; the disagreement note goes to stderr. + +- **`wire setup` labels the Pi target as bridge-only** (#92 category 1 follow-on): setup listed `Pi: ~/.pi/agent/mcp.json` among hosts it would wire up and `--apply` wrote it, but Pi has no MCP client, so that file is inert without `pi-mcp-adapter` — on such a box `--apply` looked like it had connected Pi. The target stays (adapter users still want the write); the note beside it now names the bridge, points at `pi install /pi-plugin` for everyone else, and flags that the shared snippet pins `WIRE_SESSION_ID` to `${CLAUDE_CODE_SESSION_ID}`, the wrong variable under Pi. The snippet is left alone deliberately: whether the adapter expands `${VAR}` at all is third-party behaviour this does not verify, so substituting `${PI_SESSION_ID}` would trade one unverified claim for another (wire's `valid_session_key()` guard makes an unexpanded literal fall through rather than hash into one shared home). ## [v0.17.0] — 2026-07-10 From 4161d6f7d33d1b7b272a223456d1281481b79abd Mon Sep 17 00:00:00 2001 From: Paul Logan Date: Sat, 29 Aug 2026 09:46:46 -0700 Subject: [PATCH 6/9] style: cargo fmt + let-chain the daemon-liveness guard (CI gates fmt --check and clippy -D warnings) --- src/cli/mod.rs | 9 +++++--- src/cli/pairing.rs | 51 +++++++++++++++++++++++++++++++++++++--------- src/cli/session.rs | 25 +++++++++++------------ src/cli/setup.rs | 1 - src/session.rs | 9 ++++++-- tests/cli.rs | 25 +++++++++++++++-------- 6 files changed, 83 insertions(+), 37 deletions(-) diff --git a/src/cli/mod.rs b/src/cli/mod.rs index aaf47929..b8fe649c 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -2454,9 +2454,12 @@ fn cmd_session(cmd: SessionCommand) -> Result<()> { SessionCommand::Env { name, json } => session::cmd_session_env(name.as_deref(), json), SessionCommand::Current { json } => session::cmd_session_current(json), SessionCommand::Bind { name, json } => cmd_session_bind(name.as_deref(), json), - SessionCommand::Migrate { name, all, apply, json } => { - session::cmd_session_migrate(name.as_deref(), all, apply, json) - } + SessionCommand::Migrate { + name, + all, + apply, + json, + } => session::cmd_session_migrate(name.as_deref(), all, apply, json), SessionCommand::Destroy { name, force, json } => { session::cmd_session_destroy(&name, force, json) } diff --git a/src/cli/pairing.rs b/src/cli/pairing.rs index adc9a785..2cd8a9f3 100644 --- a/src/cli/pairing.rs +++ b/src/cli/pairing.rs @@ -786,10 +786,11 @@ fn resolve_local_session<'a>( }) { return Ok(s); } - if let Some(s) = sessions - .iter() - .find(|s| s.did.as_deref().is_some_and(|d| d.eq_ignore_ascii_case(input))) - { + if let Some(s) = sessions.iter().find(|s| { + s.did + .as_deref() + .is_some_and(|d| d.eq_ignore_ascii_case(input)) + }) { return Ok(s); } @@ -2028,8 +2029,18 @@ mod tests { #[test] fn resolve_local_session_refuses_name_shared_by_two_dids() { - let a = session("amber-tarn", "aaaa111122223333", "did:wire:amber-tarn-11111111", "amber-tarn"); - let b = session("amber-tarn", "bbbb111122223333", "did:wire:amber-tarn-22222222", "amber-tarn"); + let a = session( + "amber-tarn", + "aaaa111122223333", + "did:wire:amber-tarn-11111111", + "amber-tarn", + ); + let b = session( + "amber-tarn", + "bbbb111122223333", + "did:wire:amber-tarn-22222222", + "amber-tarn", + ); let sessions = vec![a, b]; // The name is shared, so the old first-match behavior would silently @@ -2049,7 +2060,12 @@ mod tests { // The two remedies printed in that error must actually resolve. let by_did = resolve_local_session(&sessions, "did:wire:amber-tarn-22222222").unwrap(); - assert!(by_did.home_dir.to_string_lossy().ends_with("bbbb111122223333")); + assert!( + by_did + .home_dir + .to_string_lossy() + .ends_with("bbbb111122223333") + ); let by_home = resolve_local_session(&sessions, "aaaa111122223333").unwrap(); assert_eq!(by_home.did.as_deref(), Some("did:wire:amber-tarn-11111111")); } @@ -2058,8 +2074,18 @@ mod tests { fn resolve_local_session_allows_one_identity_at_two_homes() { // An identity present at two homes is one peer, not a choice between // peers — refusing here would be a false stop. - let a = session("lone-larch", "aaaa111122223333", "did:wire:lone-larch-33333333", "lone-larch"); - let b = session("lone-larch", "cccc111122223333", "did:wire:lone-larch-33333333", "lone-larch"); + let a = session( + "lone-larch", + "aaaa111122223333", + "did:wire:lone-larch-33333333", + "lone-larch", + ); + let b = session( + "lone-larch", + "cccc111122223333", + "did:wire:lone-larch-33333333", + "lone-larch", + ); let binding = [a, b]; let got = resolve_local_session(&binding, "lone-larch") .expect("same DID at two homes must resolve"); @@ -2068,7 +2094,12 @@ mod tests { #[test] fn resolve_local_session_still_resolves_plain_names_and_reports_missing() { - let a = session("quiet-pond", "aaaa111122223333", "did:wire:quiet-pond-44444444", "quiet-pond"); + let a = session( + "quiet-pond", + "aaaa111122223333", + "did:wire:quiet-pond-44444444", + "quiet-pond", + ); let sessions = vec![a]; assert!(resolve_local_session(&sessions, "quiet-pond").is_ok()); match resolve_local_session(&sessions, "no-such-session") { diff --git a/src/cli/session.rs b/src/cli/session.rs index b1534903..cf706b92 100644 --- a/src/cli/session.rs +++ b/src/cli/session.rs @@ -1496,16 +1496,16 @@ pub(crate) fn cmd_session_migrate( } // Renaming out from under a live daemon would leave it writing to a path // that no longer resolves to its name. - if let Some(pid) = crate::session::session_daemon_pid(&from) { - if crate::platform::process_alive(pid) { - rows.push(Row { - name: raw, - from: Some(from.display().to_string()), - to: to.display().to_string(), - action: format!("refused: daemon pid {pid} is live on this home; stop it first"), - }); - continue; - } + if let Some(pid) = crate::session::session_daemon_pid(&from) + && crate::platform::process_alive(pid) + { + rows.push(Row { + name: raw, + from: Some(from.display().to_string()), + to: to.display().to_string(), + action: format!("refused: daemon pid {pid} is live on this home; stop it first"), + }); + continue; } if !apply { @@ -1522,9 +1522,8 @@ pub(crate) fn cmd_session_migrate( std::fs::create_dir_all(parent) .with_context(|| format!("creating {}", parent.display()))?; } - std::fs::rename(&from, &to).with_context(|| { - format!("moving {} to {}", from.display(), to.display()) - })?; + std::fs::rename(&from, &to) + .with_context(|| format!("moving {} to {}", from.display(), to.display()))?; // Confirm both ends before claiming the move: a rename that half-happened // (different filesystem, permissions) must not be reported as success. anyhow::ensure!( diff --git a/src/cli/setup.rs b/src/cli/setup.rs index 96738a80..9caf0863 100644 --- a/src/cli/setup.rs +++ b/src/cli/setup.rs @@ -446,7 +446,6 @@ pub(crate) fn cmd_setup(apply: bool) -> Result<()> { println!("{entry_pretty}"); println!(); - if !apply { println!("Probable MCP host config locations on this machine:"); for (name, path) in &targets { diff --git a/src/session.rs b/src/session.rs index 7bc063df..679c3f18 100644 --- a/src/session.rs +++ b/src/session.rs @@ -175,7 +175,10 @@ pub fn find_session_home_by_name(name: &str) -> Result> { /// True when `path` is a wire session home rather than a stray directory: a /// self-card is written by `init` and is what every reader needs. pub fn is_session_home(path: &std::path::Path) -> bool { - path.join("config").join("wire").join("agent-card.json").is_file() + path.join("config") + .join("wire") + .join("agent-card.json") + .is_file() } /// The pre-RFC-006 layout: `sessions/` used to be where a named session @@ -625,7 +628,9 @@ pub fn resolve_local_sister(input: &str) -> Option { } match matches.len() { 0 => None, - 1 => Some(SisterMatch::Unique(matches.into_iter().next().unwrap().home)), + 1 => Some(SisterMatch::Unique( + matches.into_iter().next().unwrap().home, + )), _ => Some(SisterMatch::Ambiguous(matches)), } } diff --git a/tests/cli.rs b/tests/cli.rs index 090f87c0..8a9c209d 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1994,7 +1994,10 @@ fn session_current_reports_operative_identity_alongside_the_registry_name() { // Fresh home: nothing registered for this cwd, so there is no claim to // compare against. `agrees` is null (not true) because agreement was never // verified. The operative identity is still named. - assert!(v["session"].is_null(), "no registry entry expected; got {v}"); + assert!( + v["session"].is_null(), + "no registry entry expected; got {v}" + ); assert!( v["agrees"].is_null(), "agrees must be null when there is nothing to compare; got {v}" @@ -2007,7 +2010,8 @@ fn session_current_reports_operative_identity_alongside_the_registry_name() { // WIRE_HOME_FORCE label; a plain WIRE_HOME pin reports `env:WIRE_HOME`. Both // are the deliberate-fleet-share pins, and neither is a cwd-derived source. assert!( - ["env:WIRE_HOME", "env:WIRE_HOME_FORCE"].contains(&v["session_source"].as_str().unwrap_or("")), + ["env:WIRE_HOME", "env:WIRE_HOME_FORCE"] + .contains(&v["session_source"].as_str().unwrap_or("")), "session_source must name the explicit home pin; got {v}" ); @@ -2089,7 +2093,10 @@ fn session_migrate_moves_a_legacy_named_home_into_the_by_key_layout() { assert!(!target.exists(), "dry run must not create the target"); // Apply: the move lands and the identity is the same one. - let apply = run(&home, &["session", "migrate", "legacy-api", "--apply", "--json"]); + let apply = run( + &home, + &["session", "migrate", "legacy-api", "--apply", "--json"], + ); assert!(apply.status.success()); let done: serde_json::Value = serde_json::from_slice(&apply.stdout).unwrap(); assert_eq!(done["migrated"], 1, "apply must move one home: {done}"); @@ -2142,10 +2149,9 @@ fn session_migrate_refuses_to_merge_two_homes() { .expect("init legacy home"); assert!(init.status.success()); - let plan: serde_json::Value = serde_json::from_slice( - &run(&home, &["session", "migrate", "clash-api", "--json"]).stdout, - ) - .unwrap(); + let plan: serde_json::Value = + serde_json::from_slice(&run(&home, &["session", "migrate", "clash-api", "--json"]).stdout) + .unwrap(); let target = PathBuf::from(plan["rows"][0]["to"].as_str().unwrap()); std::fs::create_dir_all(&target).unwrap(); let rival = Command::new(wire_bin()) @@ -2156,7 +2162,10 @@ fn session_migrate_refuses_to_merge_two_homes() { .expect("init rival by-key home"); assert!(rival.status.success()); - let apply = run(&home, &["session", "migrate", "clash-api", "--apply", "--json"]); + let apply = run( + &home, + &["session", "migrate", "clash-api", "--apply", "--json"], + ); assert!(apply.status.success()); let done: serde_json::Value = serde_json::from_slice(&apply.stdout).unwrap(); assert_eq!(done["migrated"], 0, "collision must not move: {done}"); From 3cc21671ed9ac67c7dab82ac328fc36f32b1eabd Mon Sep 17 00:00:00 2001 From: Paul Logan Date: Sat, 29 Aug 2026 09:48:35 -0700 Subject: [PATCH 7/9] docs: name the surfaces that actually exist in the migrate help + changelog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The help text said a stranded home is invisible to `--session `. There is no global `--session` flag: it exists on `wire daemon` only. Rewrote it against the surfaces I verified, and recorded the real symptom, which is worse than "not initialized" — `wire session env slancha-api` answers `no session named "slancha-api" on this machine` while that name's keypair sits one directory level up. Same correction in the changelog entry. Also: prose in 99ad8c5/ab8ba22 said `wire --session whoami`, which is not a command either. Left in place since the commits are already made, but this is the corrected version of the claim. --- CHANGELOG.md | 2 +- src/cli/mod.rs | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 03c47e0e..ea466aaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ the PR description linked in each section. - **OpenCode gets per-session wire identities via a shipped plugin** (#92 follow-on): OpenCode forwards no session-id env var to spawned MCP servers — measured on 1.18.25, the MCP child sees only `OPENCODE`/`OPENCODE_PID` — so every `wire mcp` boot either reused one static key across all sessions or minted a throwaway identity per launch (one box measured 8,861 by-key homes). The gap cannot be closed from wire's side, because the key must exist before `wire mcp` execs. But OpenCode fires `session.created` ~0.5s *before* booting local MCP servers (measured 16:03:39.600Z → 16:03:40.101Z), and its plugin system runs in-process with a `config` hook that can still mutate the MCP env in that window. Ships **`opencode-plugin/wire-session.js`**: drop it in `~/.config/opencode/plugin/` and each OpenCode session resolves `WIRE_SESSION_ID=opencode-` — so birth identity and resume identity are the same key (`-c`/`-s` included), and no two sessions share one inbox. Resolution priority: exported `WIRE_SESSION_ID` (pinned persona wins) → first top-level `session.created`/`session.updated` event → `-s ` argv or `-c` resolved read-only against `opencode.db` (newest top-level session for the cwd; `--fork` gets a fresh key) → fresh UUID, which yields a new persona rather than ever a foreign one. Live-verified with `opencode run` + `wire_wire_whoami`: fresh→`-s` resume kept one persona; fresh→`-c`×2 kept one persona; consecutive fresh runs differed; exported `WIRE_SESSION_ID` overrode all of it. A lost event race falls back to the UUID path — a new persona, never a wrong one. `docs/integrations/OPENCODE.md` rewritten against what was measured. - **Pi (the coding agent) is a first-class session host, with no MCP in the loop** (#351 follow-on): Pi has no MCP client — its README says "No MCP." — so every Pi story in this repo routed through the third-party `pi-mcp-adapter`, and `wire setup`'s Pi target wrote `~/.pi/agent/mcp.json`, a file Pi proper never reads. Neither the adapter nor that file existed on a box with Pi installed, so the documented path had never actually worked. Pi *does* forward a session id: its `bash`/`powershell` tools inject `PI_SESSION_ID` when spawned with a session context (`core/tools/bash.js` → `resolveSpawnContext`, gated on `exposeSessionEnvironment`, default on). Added `PI_SESSION_ID` to `resolve_session_key` at priority 3 (label `pi`), between Claude Code and Codex, so a Pi shell resolves `sessions/by-key/` instead of falling through to the machine default and sharing one inbox with every other session on the box. Two invariants the tests lock: `PI_SESSION_ID` outranks `CODEX`/`COPILOT`/`VSCODE` (a stray host id must not steal a Pi session's identity), and **home parity** — one id string resolves to one home whether it arrives as `PI_SESSION_ID` or `WIRE_SESSION_ID`, which is what lets the package pin the key itself (Pi does not put `PI_SESSION_ID` in an extension's own env, and deletes it for context-less shells). Ships **`pi-plugin/`**: a Pi package with 13 native tools over the wire CLI, a `wire-pi` skill, and `/wire-watch` for the session-lifetime inbox stream; `wire_accept` and `wire_setup` are consent-gated so nothing mints a relay claim or grants peer access on an agent's initiative. Verified through the real tool path (`pi install ./pi-plugin`, then `wire_whoami` → a real persona; a no-identity run returned guidance and created nothing). `docs/integrations/PI.md` rewritten against what was verified; `docs/PLUGIN.md` gained the Pi section it lacked. -- **`wire session migrate` — pre-RFC-006 named homes are reachable again**: RFC-006 Part A made `sessions/by-key/` the one layout and the readers followed (`list_sessions` scans `by-key` only; `session_dir` hashes into `by-key`), but homes still sitting at `sessions/` were left unreachable with an intact keypair. The failure is quiet: `wire session current` names the project, the registry looks right, and `whoami` for that name answers "not initialized" — so the next `wire up` mints a **second** identity for a project that already has one. Reads as "the cwd registry names a different agent than my session." The new verb renames the home into the 1.0 layout so `session list`, `session env` and `session destroy` reach it again, and prints the rollback. Dry-run by default; refuses when the by-key target exists (two homes are two identities — merging orphans one's pairings), when the legacy daemon pid is alive, or for any name that is not a plain single component (the by-key home derives from the *sanitized* form, so a name that changes under sanitizing would move to a home its name does not hash to; a path-shaped name would escape the sessions root). Verified in a temp root: invisible before, listed after, same handle through the new home, re-run is a no-op, planted collision refuses. +- **`wire session migrate` — pre-RFC-006 named homes are reachable again**: RFC-006 Part A made `sessions/by-key/` the one layout and the readers followed (`list_sessions` scans `by-key` only; `session_dir` hashes into `by-key`), but homes still sitting at `sessions/` were left unreachable with an intact keypair. The failure is quiet and the error message sends you the wrong way: the registry entry still resolves (`wire session current` names the project, `wire session env ` looks like the right command), but that command answers `no session named "slancha-api" on this machine` while the keypair for that exact name sits on disk one directory level up — so the next `wire up` mints a **second** identity for a project that already has one. Reads as "the cwd registry names a different agent than my session." The new verb renames the home into the 1.0 layout so `session list`, `session env` and `session destroy` reach it again, and prints the rollback. Dry-run by default; refuses when the by-key target exists (two homes are two identities — merging orphans one's pairings), when the legacy daemon pid is alive, or for any name that is not a plain single component (the by-key home derives from the *sanitized* form, so a name that changes under sanitizing would move to a home its name does not hash to; a path-shaped name would escape the sessions root). Verified in a temp root: invisible before, listed after, same handle through the new home, re-run is a no-op, planted collision refuses. ### Fixed diff --git a/src/cli/mod.rs b/src/cli/mod.rs index b8fe649c..502481be 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1577,9 +1577,10 @@ pub enum SessionCommand { /// Move a pre-RFC-006 `sessions/` home into the 1.0 /// `sessions/by-key/` layout. RFC-006 Part A made by-key the one /// layout, so nothing reads the top-level location any more: a home left - /// there is invisible to `wire session list`, `--session `, and - /// `wire session destroy`, and creating the name again mints a second - /// identity for it. Dry-run by default — `--apply` moves the directory + /// there is invisible to every reader: `wire session list` skips it, + /// `wire session env ` errors "no session named on this + /// machine", and `wire daemon --session ` cannot pin it. Creating + /// the name again mints a second identity for the same project. Dry-run by default — `--apply` moves the directory /// (a rename; the rollback command is printed). Refuses when the by-key /// home already exists or the legacy home's daemon is live. Migrate { From 029e44be366e8fa4d681c58949ee9de7e22e212a Mon Sep 17 00:00:00 2001 From: Paul Logan Date: Sat, 29 Aug 2026 12:59:32 -0700 Subject: [PATCH 8/9] fix(pi-plugin): a WIRE_HOME pin must not suppress the session key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wireEnv() returned early when either WIRE_HOME or WIRE_SESSION_ID was set, so with WIRE_HOME pinned — the documented configuration in AGENTS.md's MCP example, and the exact form used by docs/integrations/PI.md's own worked example below — the extension never pinned WIRE_SESSION_ID. wire then had a root and no session key, resolved the machine default, and every Pi session sharing that root became one identity. That is the "every session shows the same persona" symptom v0.13 was filed for, reintroduced by my own package. The two pins are different axes: WIRE_HOME says which root a fleet lives in (RFC-008 §C deliberate share); WIRE_SESSION_ID says which session inside it. Only the latter is an identity claim, so only it suppresses the pin. WIRE_HOME still passes through untouched. Found by trying to prove uniqueness rather than assert it: two seeded sessions under one shared root both answered `initialized: false`, while the same test with no WIRE_HOME export gave two personas. Measured after the fix, one shared WIRE_HOME root: seeded A -> curious-headland pi session A sees: curious-headland seeded B -> vibrant-flax pi session B sees: vibrant-flax pi session A again: curious-headland That also closes a parity question the Rust test could not reach: the home seeded through WIRE_SESSION_ID= is the same home the extension resolves for `pi --session-id `, so ctx.sessionManager.getSessionId() is the id string wire hashes, not a normalized variant of it. Bare `pi -p` runs mint their own ids (observed: two distinct ULIDs for two runs), so distinct sessions are distinct keys by construction. Docs corrected in the same commit: both pi-plugin/README.md and PI.md stated "an operator WIRE_HOME or WIRE_SESSION_ID wins", which described the defect as if it were the design. --- docs/integrations/PI.md | 10 ++++++++-- pi-plugin/README.md | 7 +++++-- pi-plugin/extensions/wire.ts | 12 ++++++++---- 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/docs/integrations/PI.md b/docs/integrations/PI.md index f6c07f0a..07318103 100644 --- a/docs/integrations/PI.md +++ b/docs/integrations/PI.md @@ -97,8 +97,14 @@ Identity is keyed to the Pi session id, not the working directory. `CODEX_SESSION_ID` to its children — started *inside* a Pi shell inherits the parent Pi session's home and shares its inbox. Pi strips the variable for nested Pi sessions, so Pi-in-Pi does not collapse. -- An operator `WIRE_HOME` or `WIRE_SESSION_ID` wins over the Pi key. The package - leaves both alone so a deliberate fleet-share stays one identity. +- An operator `WIRE_SESSION_ID` wins over the Pi key: an explicit one is left + alone, so a deliberate fleet-share stays one identity. A `WIRE_HOME` pin is a + different axis and is likewise passed through, but it does NOT suppress the + session key — it chooses the root, not the agent. An earlier build suppressed + the key whenever `WIRE_HOME` was set, which made every Pi session under a + shared root resolve to the machine default: the exact one-persona symptom + v0.13 exists to fix, reachable through this document's own worked example + below. Fixed; the precedence is now as stated. Check what wire actually resolved: diff --git a/pi-plugin/README.md b/pi-plugin/README.md index 867547ed..8b22b07f 100644 --- a/pi-plugin/README.md +++ b/pi-plugin/README.md @@ -60,8 +60,11 @@ wire keys identity to the Pi session id: the bare key, so one conversation resolves to one home either way. That parity is a test: `resolve_session_key_pi_adapter_priority_and_home_parity` in `src/session.rs`. -- An operator `WIRE_HOME` or `WIRE_SESSION_ID` pin wins. The extension respects - it so a deliberate fleet-share stays one identity. +- An operator `WIRE_SESSION_ID` pin wins: the extension leaves it alone, so a + deliberate share stays one identity. A `WIRE_HOME` pin is a different axis and + is also left untouched, but it does *not* suppress the session key — it picks + the root, and N Pi sessions under one shared root stay N identities. Collapsing + them was the bug; `wireEnv` in `extensions/wire.ts` carries the note. Requires wire with the `pi` session adapter. That adapter is added on this branch and is not in a release as of `Cargo.toml` 0.17.0; without it a Pi session falls diff --git a/pi-plugin/extensions/wire.ts b/pi-plugin/extensions/wire.ts index ccea694b..916a07c5 100644 --- a/pi-plugin/extensions/wire.ts +++ b/pi-plugin/extensions/wire.ts @@ -34,10 +34,14 @@ const BIN = process.env.WIRE_BIN ?? "wire"; /** Env for one `wire` invocation. Operator pins always win over the Pi key. */ function wireEnv(ctx: ExtensionContext): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = { ...process.env }; - // `WIRE_HOME` is the RFC-008 §C deliberate fleet-share pin and - // `WIRE_SESSION_ID` the operator-override channel; overwriting either would - // silently split an intentionally shared identity into per-session ones. - if (env.WIRE_HOME || env.WIRE_SESSION_ID) return env; + // An explicit `WIRE_SESSION_ID` is the operator-override channel (a deliberate + // share, or a fleet key), so respect it verbatim. A `WIRE_HOME` pin is a + // *different axis*: RFC-008 §C uses it to say which root a fleet lives in, not + // that every session in that root is one agent. Treating it as an identity pin + // suppressed the session key, which collapsed every Pi session under a shared + // root onto the machine default — the "every session shows the same persona" + // symptom v0.13 was filed for. Root pin still passes through untouched. + if (env.WIRE_SESSION_ID) return env; env.WIRE_SESSION_ID = ctx.sessionManager.getSessionId(); return env; } From 7167fe57226b9e64434992e17d4f51c8e49e3851 Mon Sep 17 00:00:00 2001 From: Paul Logan Date: Sun, 30 Aug 2026 09:23:18 -0700 Subject: [PATCH 9/9] feat(pi-plugin): give wire commands typed in a Pi shell the session key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The question this answers: does `wire up` run inside a Pi session belong to that session. Through the 13 tools, yes (they pin the key). Through Pi's bash tool, no: a keyless `wire` resolves the machine default, which is one shared inbox for every session on the box, and `wire up` there mints an identity nobody can later attribute. Pi intends to supply the key as PI_SESSION_ID (dist/core/tools/bash.js resolveSpawnContext) but sets it only when the bash tool's execute() receives a session ctx. An extension that registers `bash` and delegates without ctx — literally the shape of Pi's own examples/extensions/bash-spawn-hook.ts — deletes it. Measured here with default settings and no extension touching exposeSessionEnvironment: absent in two separate `pi -p` processes. First attempt was to own the tool: createBashTool + spawnHook. Rejected, not shipped — registering a built-in tool name is a hard conflict and the second registration fails to load outright (observed against pi-tool-display), so a package that did this could not be installed next to the display extensions people already run. Using the hook instead: `tool_call` with mutable event.input, which composes (later handlers see the mutation) and cannot displace another extension's tool or bypass a policy hook that blocks earlier. The prefix is `export WIRE_SESSION_ID=''; `, not `VAR=x cmd`, because a prefix binds only the first command of a chain and `cd x && wire up` would still run keyless. It is applied only to commands that invoke `wire`, skipped when the command assigns the variable itself or the operator exported it, and taken from the live ctx per call — never process.env, since an SDK host may serve several sessions in one process and a process-level pin collapses them (65df231). WIRE_PI_NO_BASH_INJECT=1 opts out; WIRE_PI_HOOK_DEBUG= logs decisions. Verified against the installed release binary, which has no `pi` adapter, so this is portable rather than branch-local: two Pi sessions produced 01a05362-…/tinder-palm and 01a05363-…/tidal-cedar from typed commands. PI.md also records a discrepancy found while verifying it: with WIRE_HOME pinned and WIRE_SESSION_ID set, the keyed home lands under the machine default root, not $WIRE_HOME/sessions, while the key itself resolves exactly as by-key/. Not investigated here; it means a WIRE_HOME temp dir does not sandbox a keyed `wire up`, which is how test identities ended up in a live root today. --- docs/integrations/PI.md | 46 ++++++++++++++++++++++++++++++ pi-plugin/extensions/wire.ts | 55 ++++++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+) diff --git a/docs/integrations/PI.md b/docs/integrations/PI.md index 07318103..b831bb71 100644 --- a/docs/integrations/PI.md +++ b/docs/integrations/PI.md @@ -106,6 +106,52 @@ Identity is keyed to the Pi session id, not the working directory. v0.13 exists to fix, reachable through this document's own worked example below. Fixed; the precedence is now as stated. +### Typing `wire` in a Pi shell + +The 13 tools pin the session key themselves. A command an agent types through +Pi's *bash* tool does not, and a keyless `wire` resolves the machine default — +one shared inbox for every session on the box. Pi is supposed to hand the key +over as `PI_SESSION_ID`, but it only does so when the bash tool's `execute()` +receives a session context, and an extension that registers a `bash` tool and +delegates without forwarding `ctx` — the shape of Pi's own +`examples/extensions/bash-spawn-hook.ts` — drops it. Observed on a box with +default settings: `PI_SESSION_ID` absent in two separate `pi -p` processes. + +Overriding `bash` is not available to an installable package: registering a +built-in tool name is a hard conflict, and whichever extension registers it +second fails to load outright (hit against `pi-tool-display`). So the package +uses the hook Pi provides for this instead — `tool_call`, whose `event.input` is +mutable and whose handlers compose. It prefixes `export WIRE_SESSION_ID=''; ` +onto bash/powershell commands that invoke `wire`, taking the id from the live +context per call, never from `process.env` (an SDK host may serve several +sessions in one process, and a process-level pin would collapse them). + +- Visible, not sneaky: the prefix appears in the transcript. +- Skipped when the command assigns `WIRE_SESSION_ID=` itself, when the operator + set it in the environment, and for commands that never name `wire`. +- Opt out entirely: `WIRE_PI_NO_BASH_INJECT=1`. Set `WIRE_PI_HOOK_DEBUG=` + to log each decision while diagnosing. +- Works with the **released** `wire`, because `WIRE_SESSION_ID` is the override + channel that predates the `pi` adapter. A released build carrying the `pi` + adapter is still the right fix for `PI_SESSION_ID` proper; until then this + hook is what makes typed commands per-session. + +Verified with the installed 0.17.0 binary, two separate Pi sessions: + +``` +key 01a05362-… -> tinder-palm +key 01a05363-… -> tidal-cedar +``` + +One root caveat found while verifying it, filed as a discrepancy rather than a +claim: with `WIRE_HOME` pinned *and* `WIRE_SESSION_ID` set, the keyed home +resolves under the machine default root, not `$WIRE_HOME/sessions`, so +`sessions_root()`'s docstring ("sessions root becomes `$WIRE_HOME/sessions/`") +does not hold for keyed homes. The key is honored +(`by-key/`, checked against an independent hash); the root +is not. Consequence: a `WIRE_HOME=$(mktemp -d)` prefix does **not** sandbox a +keyed `wire up`. + Check what wire actually resolved: ```bash diff --git a/pi-plugin/extensions/wire.ts b/pi-plugin/extensions/wire.ts index 916a07c5..1cc930e1 100644 --- a/pi-plugin/extensions/wire.ts +++ b/pi-plugin/extensions/wire.ts @@ -26,6 +26,7 @@ */ import { execFile, spawn, type ChildProcess } from "node:child_process"; +import { appendFileSync } from "node:fs"; import { Type } from "@earendil-works/pi-ai"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; @@ -490,6 +491,59 @@ export default function (pi: ExtensionAPI) { } }); + // ── bash / powershell: wire commands carry this session's key ─────────────── + // + // The 13 tools above pin WIRE_SESSION_ID themselves, but an agent that types + // `wire up` through Pi's *bash* tool gets no key, and a keyless `wire` falls + // through to the machine default: one shared inbox for every session on the + // box. Pi is meant to supply the key as PI_SESSION_ID + // (dist/core/tools/bash.js resolveSpawnContext), but it sets it only when the + // tool's execute() receives a session ctx — and an extension that registers a + // `bash` tool and delegates without forwarding ctx, the shape of pi's own + // examples/extensions/bash-spawn-hook.ts, drops it. Measured with default + // settings: PI_SESSION_ID absent in two separate `pi -p` processes. + // + // Overriding `bash` is not an option for an installable package: registering a + // built-in tool name is a hard conflict, and the second extension to do it + // fails to load outright (observed against pi-tool-display). So use the hook + // pi provides for exactly this — `tool_call`, whose `event.input` is mutable + // and whose handlers compose rather than replace. Only commands that name + // `wire` are touched, and the key comes from the live ctx per call, never from + // process.env, because an SDK host may serve several sessions in one process + // and a process-level pin would collapse them (defect class fixed in 65df231). + // + // The mutation is visible: the prefixed `export` appears in the transcript. + // Opt out with WIRE_PI_NO_BASH_INJECT=1. + if (!process.env.WIRE_PI_NO_BASH_INJECT) { + pi.on("tool_call", (event, ctx) => { + const dbg = (why: string) => { + if (!process.env.WIRE_PI_HOOK_DEBUG) return; + try { + appendFileSync(process.env.WIRE_PI_HOOK_DEBUG, `${event.toolName} ${JSON.stringify(event.input)} -> ${why}\n`); + } catch { + /* debug only */ + } + }; + dbg("enter"); + const input = event.input as { command?: string } | undefined; + const command = input?.command; + if (!command) return dbg("no-command"); + if (event.toolName !== "bash" && event.toolName !== "powershell") return dbg("tool-name"); + if (process.env.WIRE_SESSION_ID) return dbg("operator-pin"); // operator-override channel wins + // A command that assigns the key has chosen its own identity (an operator + // override, or a deliberate share). Merely *reading* the variable is not a + // choice, so match assignment, not mention. + if (/(^|[;&|(\n]|\s)(export\s+)?WIRE_SESSION_ID=/.test(command)) return dbg("self-keyed"); + if (!/(^|[\s;&|(])wire(\s|$)/.test(command)) return dbg("not-a-wire-cmd"); // wire only + const key = ctx?.sessionManager?.getSessionId?.(); + if (!key) return dbg("no-session-id"); + // `export`, not a `VAR=x cmd` prefix: a prefix binds only the first command + // of a chain, so `cd x && wire up` would still run keyless. + input!.command = `export WIRE_SESSION_ID='${key.replace(/'/g, "'\\''")}'; ${command}`; + dbg(`prefixed with ${key}`); + }); + } + // The watcher is session-lifetime infrastructure, not turn scaffolding // (wire AGENTS.md R7 — the 2026-05-12 agent-attention-layer incident root // caused exactly by tearing a listener down between iterations). It is torn @@ -497,4 +551,5 @@ export default function (pi: ExtensionAPI) { pi.on("session_shutdown", async () => { stopWatcher(undefined); }); + }