@fitchmultz/pi-subagents 0.47.0 → 0.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +15 -3
  2. package/dist/extension/build-info.js +1 -1
  3. package/dist/pi-intercom/bridge-broadcast.js +60 -0
  4. package/dist/pi-intercom/bridge-catalog.js +372 -0
  5. package/dist/pi-intercom/bridge-config.js +14 -2
  6. package/dist/pi-intercom/bridge-control.js +104 -0
  7. package/dist/pi-intercom/bridge-groups.js +135 -0
  8. package/dist/pi-intercom/bridge-http.js +8 -1
  9. package/dist/pi-intercom/bridge-launch-journal.js +84 -0
  10. package/dist/pi-intercom/bridge-launch-policy.js +71 -5
  11. package/dist/pi-intercom/bridge-launch.js +186 -76
  12. package/dist/pi-intercom/bridge-operations.js +127 -0
  13. package/dist/pi-intercom/bridge-protocol.js +7 -0
  14. package/dist/pi-intercom/bridge-routes.js +171 -22
  15. package/dist/pi-intercom/bridge-rpc-process.js +43 -15
  16. package/dist/pi-intercom/bridge-session.js +24 -22
  17. package/dist/pi-intercom/bridge-target.js +22 -0
  18. package/dist/pi-intercom/bridge.js +29 -37
  19. package/dist/pi-intercom/broker/client.js +1 -0
  20. package/dist/pi-intercom/connection.js +2 -3
  21. package/dist/pi-intercom/inbound.js +2 -4
  22. package/dist/pi-intercom/index.js +7 -1
  23. package/dist/pi-intercom/native-control-auth.js +80 -0
  24. package/dist/pi-intercom/native-control-protocol.js +111 -0
  25. package/dist/pi-intercom/native-control.js +130 -0
  26. package/dist/pi-intercom/saved-transcript.js +135 -0
  27. package/dist/pi-intercom/session-targets.js +3 -0
  28. package/dist/pi-intercom/transcript.js +175 -0
  29. package/docs/intercom-bridge.md +285 -175
  30. package/docs/intercom.md +1 -1
  31. package/docs/reference.md +1 -1
  32. package/package.json +1 -1
  33. package/scripts/intercom-remote.mjs +176 -35
  34. package/src/pi-intercom/bridge-broadcast.ts +70 -0
  35. package/src/pi-intercom/bridge-catalog.ts +489 -0
  36. package/src/pi-intercom/bridge-config.ts +19 -2
  37. package/src/pi-intercom/bridge-control.ts +179 -0
  38. package/src/pi-intercom/bridge-groups.ts +169 -0
  39. package/src/pi-intercom/bridge-http.ts +15 -1
  40. package/src/pi-intercom/bridge-launch-journal.ts +122 -0
  41. package/src/pi-intercom/bridge-launch-policy.ts +88 -7
  42. package/src/pi-intercom/bridge-launch.ts +296 -107
  43. package/src/pi-intercom/bridge-operations.ts +169 -0
  44. package/src/pi-intercom/bridge-protocol.ts +8 -0
  45. package/src/pi-intercom/bridge-routes.ts +254 -42
  46. package/src/pi-intercom/bridge-rpc-process.ts +53 -16
  47. package/src/pi-intercom/bridge-session.ts +35 -36
  48. package/src/pi-intercom/bridge-target.ts +35 -0
  49. package/src/pi-intercom/bridge.ts +30 -47
  50. package/src/pi-intercom/broker/client.ts +3 -0
  51. package/src/pi-intercom/connection.ts +2 -3
  52. package/src/pi-intercom/inbound.ts +2 -4
  53. package/src/pi-intercom/index.ts +7 -1
  54. package/src/pi-intercom/native-control-auth.ts +89 -0
  55. package/src/pi-intercom/native-control-protocol.ts +180 -0
  56. package/src/pi-intercom/native-control.ts +160 -0
  57. package/src/pi-intercom/saved-transcript.ts +175 -0
  58. package/src/pi-intercom/session-targets.ts +4 -0
  59. package/src/pi-intercom/transcript.ts +226 -0
  60. package/src/pi-intercom/types.ts +2 -0
@@ -1,289 +1,399 @@
1
1
  # Secure remote Intercom bridge
2
2
 
3
- Optional HTTPS messaging proxy and locally controlled Pi-session launcher for a remote agent such as **Grok Bot**. Pi-intercom's broker remains same-machine Unix IPC. Nothing is added to the Pi extension loader, and starting Pi alone never starts this proxy.
3
+ The bridge lets one authenticated remote agent, such as a Grok bot, work with Pi on your Mac unattended. It can message and ask any live Pi session, keep incoming messages and asks in a durable mailbox, wake the agent when mail sits unread, browse live and saved sessions, spawn and resume sessions, read recent transcripts, cancel or stop work, and broadcast to named groups.
4
4
 
5
- ## Design note
5
+ The bridge is a standalone Node HTTPS helper, `dist/pi-intercom/bridge.js`. Starting Pi never starts it. Pi-intercom's broker stays a same-machine Unix socket, and remote callers can't send raw broker frames.
6
6
 
7
- **Transport:** a Mac-local Node HTTPS server bound to **127.0.0.1**, carried by a Mac-initiated **SSH reverse TCP forward** to the Linux box's loopback interface. Grok calls HTTPS on its own loopback. Unlike a Unix-socket tunnel, only the local helper and normal local Pi peers use `broker.sock`; neither the remote nor SSH can issue raw broker frames. This choice needs no cloud account, public DNS, inbound Mac SSH, or per-call laptop approval. An existing SSH account on Linux is the only tunnel prerequisite. SSH authenticates the tunnel host; end-to-end mTLS authenticates both application endpoints even to other processes on Linux.
7
+ ## Design
8
8
 
9
- **Threat model:** deny unauthenticated local/network callers, stolen credentials after revocation/expiry, identity spoofing, arbitrary broker commands, forged replies, accidental LAN exposure, and unbounded waits/resource use. The Mac user, local broker, OS, private CA, and approved remote host are trusted. A compromised authorized Grok credential can read peer metadata and send agent-visible text to local peers (including instructions); mTLS does not sandbox those agents or make the content trustworthy. Listed peers can be in other projects. Approve only a host allowed to see that metadata and message those peers. Root/compromised endpoints and denial of service beyond fixed connection/request limits are outside this boundary.
9
+ **Transport.** The helper listens only on `127.0.0.1`. Tailscale Serve exposes that port to your tailnet as a raw TCP forwarder, and the remote host reaches it through a forwarder on its own loopback. TLS runs end to end between the remote client and the helper; Tailscale carries encrypted bytes and never terminates TLS. Don't use Funnel, `--https` or `--tls-terminated-tcp` for this port. A Mac-initiated [SSH reverse forward](#ssh-alternative) also works.
10
10
 
11
- **Auth/authority:** TLS 1.2+ with client certificates issued by a private CA **and** an explicit SHA-256 certificate fingerprint allowlist. Each allowlisted certificate fixes the peer name and Mac project directory; remote payloads cannot change identity, cwd, socket, shell commands, or presence. Certificates last seven days. TLS verifies the server CA and loopback SAN; never use `curl -k` or disable certificate checks. The proxy exposes register/list/send/ask/reply, inbox/ack receipt plumbing, and optional name-only start plus own-launched sessions/stop operations. No broker admin, topic operations, file reads, or shell endpoint.
11
+ **Authentication.** TLS 1.2+ mutual authentication. A client presents a certificate issued by the bridge's private CA, and its SHA-256 fingerprint must be in the private allowlist. The client verifies the server against the same CA and its loopback SAN (`localhost`, `127.0.0.1`), so remote clients always use a loopback URL. Never use `curl -k` or disable verification.
12
12
 
13
- **Socket discovery:** reuse the actual `IntercomClient`, `getBrokerSocketPath()` and framing code. With the same Mac uid, `PI_CODING_AGENT_DIR` (default `~/.pi/agent`) and `TMPDIR` as Pi, the preferred path is `$TMPDIR/pi-intercom-<sha256(uid:agentDir)[0:16]>/broker.sock`; long paths fall back to `/tmp`. The client's existing owned-socket legacy fallback is retained. No hardcoded `/var/folders` path, socket chmod, or broker restart. Without `stateDir`, an unavailable broker returns an error; start a normal Pi session to bring it up. Durable presence uses Intercom's safe acquisition to start a detached broker only when none is running, including before any Pi session starts after reboot. Acquisition maintains the normal PID-file/spawn-lock bookkeeping, reuses a healthy broker and refuses to replace a live unhealthy broker that may own accepted work.
13
+ **Authority.** Each allowlisted certificate fixes one identity: its peer name, its Intercom ID (`remote-<fingerprint>`) and its home `cwd`. Request payloads can't change identity, and display names grant nothing. An authorized identity can use every bridge capability: list peers in any project, send text that agents treat as instructions, read transcripts, manage groups, spawn and resume sessions in any locally approved directory with any model, and cancel or stop work. Spawned sessions use your local tools, credentials and model budget. mTLS authenticates the caller; it doesn't sandbox the agents or make messages trustworthy. Issue certificates only to agents you trust with that authority. There is no broker-admin, raw RPC, shell or caller-supplied file-path endpoint.
14
14
 
15
- **Delivery/lifecycle:** one broker peer per authorized certificate. Without `stateDir`, registration is lazy and has a five-minute inactivity lease; poll inbox to maintain presence. With `stateDir`, an authenticated identity has durable presence restored after helper restart and refreshed without remote polling until expiry, revocation or helper shutdown. Send returns broker acceptance, not proof a model consumed it. Ask steers the recipient and waits at most two minutes for both the exact recipient ID and exact `replyTo`. One outgoing ask per identity. Revocation/expiry/shutdown/client disconnect terminate waits. With a local `stateDir`, inbox intake is committed to private SQLite storage before publication or satisfying an outgoing ask. Unacked messages and unanswered inbound asks survive helper restart, idle-lease retirement and broker outage. Inbox reads are non-destructive, bounded cursor pages; ack deletes only ordinary inbox payloads, and successful reply retires an inbound ask. Sender departure does not discard a reply obligation: reply still targets the saved exact sender ID and returns `peer_offline` without deletion while that ID is offline. Without `stateDir`, the legacy 256-message/4 MiB volatile inbox remains; overflow and lease/shutdown loss are reported. Outgoing HTTP ask waits themselves are not resumed after helper restart; persistent mode retains received answers in inbox for recovery and ack.
15
+ **Threat model.** The bridge denies unauthenticated callers on the tailnet or the Mac, revoked or expired certificates, identity spoofing, forged replies and control results, browser-origin requests and unbounded waits. It trusts the Mac user account, the OS, the broker, the private CA and the authorized remote host. Compromised endpoints and denial of service beyond the fixed connection and request limits are out of scope.
16
16
 
17
- **Audit:** JSONL metadata on stdout: request/connection identity, certificate fingerprint, operation, peer/message IDs and outcome. `session_closed` confirms broker disconnection and removal of the retiring session; cancellation still rejects requests immediately. Message/attachment bodies and keys are not logged. Protect logs (0700 directory, 0600 files); rotate them with your normal log tooling. Never publish private config/keys or raw inbox/list output as smoke evidence.
17
+ **Broker.** The helper reuses Intercom's client, socket discovery and framing, so run it with Pi's `PI_CODING_AGENT_DIR` (default `~/.pi/agent`) and macOS per-user `TMPDIR`. It never restarts or replaces a live broker. Legacy messaging without `stateDir` never starts a broker. Persistent presence, spawn and resume use Pi's safe acquisition, including before any Pi session starts after reboot: reuse a healthy broker, start a detached broker only when none is running, and refuse a second broker while an unhealthy broker process may still own accepted work. Acquisition uses the normal PID-file/spawn-lock bookkeeping.
18
+
19
+ **Availability.** The Mac must be awake, online and logged in, because the helper runs as a per-user LaunchAgent. Mail for the remote agent waits in its Mac mailbox while the agent is away. Nothing reaches Pi while the Mac is asleep, powered off or offline.
20
+
21
+ **Audit.** stdout is JSONL metadata: identity, fingerprint, route, peer and message IDs, and outcomes, including wake and launch results. Message bodies, wake keys and private keys are never logged. Keep logs in a 0700 directory with 0600 files.
18
22
 
19
23
  ## Mac setup
20
24
 
21
- Requires Node **24.21.0+**, OpenSSL with `req -addext` (Homebrew OpenSSL works), and a connected Pi session with Intercom. Run commands from this checkout/package directory. A runtime-only installed checkout already has `dist/`; for development use `npm ci` first. See the [installation guide](reference.md#installation) before rebuilding an in-use extension. This standalone helper can run from its own installed copy without touching the live Pi runtime.
25
+ You need Node 24.21.0+, OpenSSL with `req -addext` (Homebrew OpenSSL works), Tailscale on both machines, and Pi with this package's Intercom extension. Examples use the identity `fitch-os` on port 9443. Replace `/Users/you` and other placeholders.
26
+
27
+ ### 1. Install a release
28
+
29
+ Install each helper version into a new directory and never edit it afterward. The service runs whatever `current` points to.
30
+
31
+ ```bash
32
+ RELEASES="$HOME/.pi/intercom-bridge-releases"
33
+ NEW="$RELEASES/$(date +%Y%m%d-%H%M%S)"
34
+ mkdir -p "$NEW"
35
+ npm install --prefix "$NEW" --omit=dev @fitchmultz/pi-subagents
36
+ PKG="$NEW/node_modules/@fitchmultz/pi-subagents"
37
+ node "$PKG/dist/pi-intercom/bridge.js" --help
38
+ ln -sfn "$NEW" "$RELEASES/current"
39
+ ```
40
+
41
+ ### 2. Create credentials
22
42
 
23
43
  ```bash
24
- # Use a NEW directory; init refuses to overwrite any existing CA.
25
44
  umask 077
26
45
  BRIDGE="$HOME/.pi/intercom-bridge"
27
- node scripts/intercom-bridge-credentials.mjs init "$BRIDGE" --cwd "$PWD"
46
+ node "$PKG/scripts/intercom-bridge-credentials.mjs" init "$BRIDGE" fitch-os --cwd "$HOME" --port 9443 --days 3650
28
47
  ```
29
48
 
30
- The last JSON line contains paths to the issued client `cert`/`key`, trusted `ca`, and `config`; it does not print secret contents. Keep those paths for the copy step. Configuration is local-only, owned by the Mac user and private:
49
+ `init` refuses an existing directory. It creates a ten-year CA, a one-year loopback server certificate, a client certificate whose CN is the identity name, and a private 0600 `config.json`. `--port` (default 9443) sets the helper port. `--days` (1–3650, default 7) sets the client certificate lifetime for `init` and `issue`. Authorization uses only the fingerprint. The last JSON line lists file paths and never secrets. `ca.key` and `server.key` stay on the Mac.
50
+
51
+ ### 3. Configure
52
+
53
+ ```bash
54
+ mkdir -m 700 "$BRIDGE/state" "$BRIDGE/sessions" "$BRIDGE/wake" "$BRIDGE/logs"
55
+ # Two lines in a 0600 file. Keep it out of Git, logs and shell history.
56
+ (umask 077; printf 'GROK_WAKE_WEBHOOK_URL=%s\nGROK_WAKE_WEBHOOK_KEY=%s\n' "$WAKE_URL" "$WAKE_KEY" > "$BRIDGE/wake/fitch-os.env")
57
+ ```
58
+
59
+ Add `stateDir`, `wake` and `launch` to the generated `config.json`. Keep the file 0600.
31
60
 
32
61
  ```json
33
62
  {
34
63
  "port": 9443,
35
- "ca": "/Users/mitch/.pi/intercom-bridge/ca.crt",
36
- "cert": "/Users/mitch/.pi/intercom-bridge/server.crt",
37
- "key": "/Users/mitch/.pi/intercom-bridge/server.key",
64
+ "ca": "/Users/you/.pi/intercom-bridge/ca.crt",
65
+ "cert": "/Users/you/.pi/intercom-bridge/server.crt",
66
+ "key": "/Users/you/.pi/intercom-bridge/server.key",
67
+ "stateDir": "/Users/you/.pi/intercom-bridge/state",
38
68
  "clients": [
39
69
  {
40
- "fingerprint256": "SHA256_FINGERPRINT_FROM_ISSUE",
41
- "name": "grok-bot",
42
- "cwd": "/Users/mitch/project"
70
+ "fingerprint256": "FINGERPRINT_FROM_INIT",
71
+ "name": "fitch-os",
72
+ "cwd": "/Users/you",
73
+ "wake": { "envFile": "/Users/you/.pi/intercom-bridge/wake/fitch-os.env" },
74
+ "launch": {
75
+ "packageRoot": "/absolute/path/to/node_modules/@earendil-works/pi-coding-agent",
76
+ "sessionDir": "/Users/you/.pi/intercom-bridge/sessions",
77
+ "workspaceRoots": ["/"],
78
+ "resumeDirs": ["/Users/you/.pi/agent/sessions"],
79
+ "context": "You were started by the remote fitch-os agent through the Intercom bridge. Reply through Intercom.",
80
+ "extensions": [
81
+ "/absolute/path/to/pi-subagents/dist/pi-intercom/index.js",
82
+ "/absolute/path/to/pi-subagents/dist/extension/index.js"
83
+ ],
84
+ "discoverResources": true,
85
+ "trustProject": true,
86
+ "maxSessions": 16
87
+ }
43
88
  }
44
89
  ]
45
90
  }
46
91
  ```
47
92
 
48
- ### Enable durable mailboxes
49
-
50
- Create a dedicated owned **0700** directory and add its absolute path as top-level `stateDir` in the private configuration:
93
+ This example gives the agent full authority over every Mac path. The defaults stay restrictive for other identities.
94
+
95
+ | Field | Purpose |
96
+ | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
97
+ | `stateDir` | Owned 0700 directory, required for unattended use. Holds one 0600 SQLite mailbox per certificate fingerprint, presence proofs and groups. Symlinks, wrong owner or mode, and hard links are rejected. Change it only with a helper restart. Without it, the helper falls back to a legacy volatile inbox (256 messages, 4 MiB) with no wake or presence restore, and keeps groups in `$PI_CODING_AGENT_DIR/intercom/bridge-state`. |
98
+ | `cwd` | The identity's presence and project association, the legacy `start` directory and the default spawn directory. It doesn't limit access. |
99
+ | `wake.envFile` | Absolute path to an owned 0600 file defining `GROK_WAKE_WEBHOOK_URL` (HTTPS, without URL credentials or fragment) and `GROK_WAKE_WEBHOOK_KEY`. Requires `stateDir`. Reread on SIGHUP. |
100
+ | `launch` | Enables saved-session browsing, spawn and resume. Omit it for a messaging-only identity. |
101
+ | `packageRoot` | The installed Pi package for children. The helper runs that package's `bin.pi` with its own physical Node. Use the intended installed Pi, not a development SDK with the same version. |
102
+ | `sessionDir` | Owned 0700 directory for new journals, written 0600. |
103
+ | `workspaceRoots` | Up to 32 directories. Spawn and resume directories must be inside one. `["/"]` approves every path; the default empty list rejects spawn and resume. |
104
+ | `resumeDirs` | Up to 32 owned directories searched, with `sessionDir`, for saved sessions. Every journal path component must be owned and symlink-free. |
105
+ | `provider`, `model` | Optional default model; set both or neither. Omit them to use Pi's normal model selection. |
106
+ | `extensions` | 1–32 absolute extension paths, including Intercom. With `discoverResources: true`, use the same physical Intercom path that native settings load, so a session never runs two Intercom copies. |
107
+ | `discoverResources` | Default `false`. `true` keeps Pi's normal discovery of extensions, skills, prompts and context files. |
108
+ | `trustProject` | Default `false`. Chooses native `--approve` or `--no-approve` for the selected directory. |
109
+ | `offline` | Default `false`. `true` disables automatic catalog and network activity; requested inference still runs. |
110
+ | `maxSessions`, `startupTimeoutMs` | Owned-session cap per identity, 1–16 (default 4; 32 per helper), and readiness deadline, 1–60000 ms (default 15000). |
111
+
112
+ Children inherit the helper's environment, minus enclosing `PI_SUBAGENT_*` metadata, so set `PATH` in the service. A launch succeeds only when the exact saved session ID, name and file run idle in RPC mode and the session's own Intercom peer is published. That proves startup, not working provider credentials. A Pi dialog that needs a human is cancelled, and the session is stopped with `interaction_required`.
113
+
114
+ ### 4. Run as a LaunchAgent
115
+
116
+ Save as `~/Library/LaunchAgents/com.example.pi-intercom-bridge.fitch-os.plist`. Use your real Node path, and the output of `getconf DARWIN_USER_TEMP_DIR` for `TMPDIR`.
117
+
118
+ ```xml
119
+ <?xml version="1.0" encoding="UTF-8"?>
120
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
121
+ <plist version="1.0">
122
+ <dict>
123
+ <key>Label</key><string>com.example.pi-intercom-bridge.fitch-os</string>
124
+ <key>ProgramArguments</key>
125
+ <array>
126
+ <string>/absolute/path/to/node</string>
127
+ <string>/Users/you/.pi/intercom-bridge-releases/current/node_modules/@fitchmultz/pi-subagents/dist/pi-intercom/bridge.js</string>
128
+ <string>--config</string>
129
+ <string>/Users/you/.pi/intercom-bridge/config.json</string>
130
+ </array>
131
+ <key>EnvironmentVariables</key>
132
+ <dict>
133
+ <key>PI_CODING_AGENT_DIR</key><string>/Users/you/.pi/agent</string>
134
+ <key>TMPDIR</key><string>/var/folders/XX/YYYY/T/</string>
135
+ <key>PATH</key><string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
136
+ </dict>
137
+ <key>RunAtLoad</key><true/>
138
+ <key>KeepAlive</key><true/>
139
+ <key>ExitTimeOut</key><integer>30</integer>
140
+ <key>Umask</key><integer>63</integer>
141
+ <key>StandardOutPath</key><string>/Users/you/.pi/intercom-bridge/logs/audit.jsonl</string>
142
+ <key>StandardErrorPath</key><string>/Users/you/.pi/intercom-bridge/logs/stderr.log</string>
143
+ </dict>
144
+ </plist>
145
+ ```
51
146
 
52
147
  ```bash
53
- mkdir -m 700 "$BRIDGE/mailboxes"
148
+ LABEL=com.example.pi-intercom-bridge.fitch-os
149
+ PLIST="$HOME/Library/LaunchAgents/$LABEL.plist"
150
+ plutil -lint "$PLIST" && launchctl bootstrap "gui/$(id -u)" "$PLIST"
151
+ lsof -nP -iTCP:9443 -sTCP:LISTEN # expect only 127.0.0.1:9443
54
152
  ```
55
153
 
56
- ```json
57
- { "stateDir": "/Users/mitch/.pi/intercom-bridge/mailboxes" }
154
+ Wait for the `listening` audit record before making requests. Day-to-day control:
155
+
156
+ ```bash
157
+ launchctl kill SIGHUP "gui/$(id -u)/$LABEL" # reload allowlist, launch and wake policy
158
+ launchctl kill SIGTERM "gui/$(id -u)/$LABEL" # graceful restart; KeepAlive starts the current release
159
+ launchctl bootout "gui/$(id -u)" "$PLIST" # stop and stay stopped
58
160
  ```
59
161
 
60
- This opt-in does not enable Pi-session launches or require a Pi SDK. It does enable unattended broker acquisition for durable presence: after reboot or broker exit, the helper can start a broker without a running Pi session. It never restarts or replaces a live broker. Each certificate fingerprint owns a separate **0600** SQLite database. Directory symlinks, database/SQLite-sidecar symlinks, wrong ownership/modes and hard-linked files are rejected. SQLite uses full synchronous commits and a bounded cache; no mailbox-wide hydration is needed. Protect the directory and any backups: databases contain private message bodies, though audit logs do not. `stateDir` changes require helper restart, not SIGHUP.
162
+ SIGHUP can't change the port, TLS files or `stateDir`; restart for those. With `KeepAlive`, killing the process only restarts it, so use `bootout` to stop access. SIGTERM stops owned sessions before exit, which is why `ExitTimeOut` allows 30 seconds.
61
163
 
62
- There is no automatic retention limit on unacked messages or unanswered asks. Pages contain at most 256 messages and 4 MiB of serialized envelopes (plus small response metadata); read additional pages with `nextCursor` while `hasMore` is true. Message IDs remain as payload-free tombstones after completion to suppress duplicate/replayed IDs, including after restart. Provision and monitor disk space. Failed disk admission sets durable `overflow`, `lostMessages` and `storageError` when the loss ledger can still be committed; if that also fails, the helper reports `mailbox_storage_failed` (HTTP 507), closes the affected broker peer and logs metadata with `lossRecorded:false`. A failing disk cannot guarantee durable loss bookkeeping. Broker acceptance alone never proves mailbox persistence or remote consumption.
164
+ ### 5. Publish on the tailnet
165
+
166
+ ```bash
167
+ tailscale serve --bg --tcp=9443 tcp://localhost:9443
168
+ tailscale serve status
169
+ ```
63
170
 
64
- Persistent inbox/ack calls remain available while the broker is down; they do not require broker registration. Authenticate once with `register` or another successful connection to establish the private presence proof. Durable presence then reconnects independently, including after helper restart, without remote polling. Revoked/expired certificates still cannot read stored mail or restore presence. Mailboxes are fingerprint-scoped: rotating a certificate does not transfer old private mail to its replacement. Retain the old database for local recovery rather than automatically authorizing a new credential to read it.
171
+ Restrict that port in your tailnet policy to the remote host. To withdraw it, run the same `serve` command with `off` appended.
65
172
 
66
- `cwd` controls presence/project association, **not** an authorization restriction. `list` intentionally returns all connected peer identities, omitting topics/subscriptions. Target full IDs or the returned `target` when names collide. Port `0` requests an ephemeral port, reported in the listening log; a fixed port is easier for SSH.
173
+ ### 6. Provision the remote host
67
174
 
68
- Start the helper in a dedicated terminal (or supervise this exact command with launchd):
175
+ Copy only the client certificate and key printed by `init`, `ca.crt`, and `scripts/intercom-remote.mjs`, over an existing secure channel, into a 0700 directory with 0600 files. Then run a supervised loopback forwarder from `127.0.0.1:9443` to the Mac's tailnet name on port 9443. With kernel-mode Tailscale:
69
176
 
70
177
  ```bash
71
- # Match Pi's agent directory and temp environment, especially for launchd.
72
- export PI_CODING_AGENT_DIR="${PI_CODING_AGENT_DIR:-$HOME/.pi/agent}"
73
- export TMPDIR="$(getconf DARWIN_USER_TEMP_DIR)"
74
- node dist/pi-intercom/bridge.js --config "$BRIDGE/config.json" >> "$BRIDGE/audit.jsonl"
178
+ socat TCP-LISTEN:9443,bind=127.0.0.1,reuseaddr,fork TCP:mac-name.example-tailnet.ts.net:9443
75
179
  ```
76
180
 
77
- The listening record includes the helper PID and port. Record the PID as `HELPER_PID` in your administration terminal; **do not use the broker PID**. Wait for that readiness record before making a request: a running process is not proof its listener is ready. Verify `lsof -nP -iTCP:9443 -sTCP:LISTEN` shows only `127.0.0.1:9443`. Keep the helper running; no per-message approvals are needed.
78
-
79
- For the installed Mac LaunchAgent (`com.fitchmultz.pi-intercom-bridge`), inspect and administer it with:
181
+ Without root, run `tailscaled --tun=userspace-networking --socks5-server=127.0.0.1:1055` and relay the same loopback listener through that SOCKS5 proxy.
80
182
 
81
183
  ```bash
82
- launchctl print "gui/$(id -u)/com.fitchmultz.pi-intercom-bridge"
83
- # Reload allowlist without restarting:
84
- launchctl kill SIGHUP "gui/$(id -u)/com.fitchmultz.pi-intercom-bridge"
85
- # Stop immediately; remove the plist too to disable future login starts:
86
- launchctl bootout "gui/$(id -u)" "$HOME/Library/LaunchAgents/com.fitchmultz.pi-intercom-bridge.plist"
87
- # Start again explicitly:
88
- launchctl bootstrap "gui/$(id -u)" "$HOME/Library/LaunchAgents/com.fitchmultz.pi-intercom-bridge.plist"
184
+ export INTERCOM_BRIDGE_URL='https://127.0.0.1:9443'
185
+ export INTERCOM_BRIDGE_CA="$HOME/.config/pi-intercom/ca.crt"
186
+ export INTERCOM_BRIDGE_CERT="$HOME/.config/pi-intercom/client.crt"
187
+ export INTERCOM_BRIDGE_KEY="$HOME/.config/pi-intercom/client.key"
188
+ REMOTE="$HOME/.config/pi-intercom/intercom-remote.mjs"
189
+ node "$REMOTE" register && node "$REMOTE" list
89
190
  ```
90
191
 
91
- The installed agent has no `KeepAlive` auto-restart; stopping its helper really stops access. Remove its plist to disable future login starts permanently. With `stateDir` enabled, unacked mail survives helper restart. Inspect/drain inbox before restarting a legacy volatile-mode helper.
192
+ The first authenticated connection establishes persistent presence (see [Mailbox, presence and wake](#mailbox-presence-and-wake)).
92
193
 
93
- ### Tunnel and provision Grok
194
+ ### More instances
94
195
 
95
- Use the **known, approved** Linux SSH host/account, not an invented example host. Preserve SSH host-key verification and do not forward your SSH agent:
196
+ Run one helper per remote agent instance. Each instance gets its own client name and key, port, `stateDir`, wake webhook, LaunchAgent label, Tailscale Serve port and remote host.
96
197
 
97
- ```bash
98
- GROK_SSH='grok@YOUR_LINUX_HOST'
99
- # On Linux, first inspect effective SSH config with the administrator:
100
- # sudo sshd -T | grep gatewayports
101
- # Require GatewayPorts=no (default) or clientspecified, NEVER yes.
102
-
103
- # On the Mac, dedicated terminal/supervised process:
104
- ssh -NT -a -o BatchMode=yes -o StrictHostKeyChecking=yes \
105
- -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 \
106
- -R 127.0.0.1:9443:127.0.0.1:9443 "$GROK_SSH"
107
- ```
198
+ Helpers on the same Mac must share one CA; `init` always creates a new one. Pi sessions on the Mac check every native control (see [Stop and cancel](#stop-and-cancel)) against a single policy file: `PI_INTERCOM_BRIDGE_CONFIG`, else `$PI_CODING_AGENT_DIR/intercom/bridge.json`, else `~/.pi/intercom-bridge/config.json`. That file must name the common CA and list the current pin of every instance, so update it whenever you issue, rotate or revoke any instance's certificate. A different Mac has its own CA and policy file.
108
199
 
109
- **On Linux, verify `ss -ltn '( sport = :9443 )'` shows only `127.0.0.1:9443` before use.** `GatewayPorts=yes` forces wildcard binding even when `-R` requests loopback. If it shows `0.0.0.0`, `*`, or `[::]`, stop the tunnel and correct that SSH policy. Do not open a firewall port. OpenSSH references: [ssh `-R`](https://man.openbsd.org/ssh.1) and [sshd `GatewayPorts`](https://man.openbsd.org/sshd_config.5#GatewayPorts).
200
+ ### SSH alternative
110
201
 
111
- Transfer **only** the client certificate/key, public `ca.crt`, and the dependency-free remote script through the existing approved SSH/SFTP channel:
202
+ A Mac-initiated SSH reverse forward can replace steps 5 and 6. Use an approved Linux account, keep host-key checking and don't forward your agent:
112
203
 
113
204
  ```bash
114
- # Use cert/key paths printed by init/issue, not the CA or server keys.
115
- CLIENT_CERT='/absolute/path/from/issue/grok-bot-ISSUED.crt'
116
- CLIENT_KEY='/absolute/path/from/issue/grok-bot-ISSUED.key'
117
- ssh "$GROK_SSH" 'umask 077; mkdir -p ~/.config/pi-intercom; chmod 700 ~/.config/pi-intercom'
118
- scp "$CLIENT_CERT" "$GROK_SSH:.config/pi-intercom/client.crt"
119
- scp "$CLIENT_KEY" "$GROK_SSH:.config/pi-intercom/client.key"
120
- scp "$BRIDGE/ca.crt" scripts/intercom-remote.mjs "$GROK_SSH:.config/pi-intercom/"
121
- ssh "$GROK_SSH" 'chmod 600 ~/.config/pi-intercom/*'
205
+ ssh -NT -a -o BatchMode=yes -o StrictHostKeyChecking=yes -o ExitOnForwardFailure=yes \
206
+ -o ServerAliveInterval=30 -o ServerAliveCountMax=3 \
207
+ -R 127.0.0.1:9443:127.0.0.1:9443 user@linux-host
122
208
  ```
123
209
 
124
- For a least-privileged dedicated Linux SSH tunnel account, the administrator can restrict its key to remote forwarding with `restrict,port-forwarding,permitlisten="127.0.0.1:9443",command="/bin/false"` and `AllowTcpForwarding remote`, `GatewayPorts no`. `-N` requests no shell. Provision files separately; do not reuse Grok's client key as an SSH key. No SSH login credential for the Mac is given to Grok.
210
+ On Linux, `ss -ltn '( sport = :9443 )'` must show only `127.0.0.1:9443`. `GatewayPorts=yes` forces a wildcard bind; require `no` or `clientspecified`. A dedicated tunnel key can be limited with `restrict,port-forwarding,permitlisten="127.0.0.1:9443",command="/bin/false"`. Never reuse the bridge client key as an SSH key.
211
+
212
+ ## Remote API
125
213
 
126
- ## Enable controlled destination-session launches
214
+ Every route requires mTLS and the allowlist. Bodies are JSON up to 64 KiB. Unknown routes, fields, query keys and browser-origin requests are rejected. Success is `{ "ok": true, ... }`; failure is `{ "ok": false, "error": { "code", "message", ... } }` with a non-2xx status. A failed certificate check can end the TLS handshake before any HTTP response. Nothing retries a mutation automatically. A lost response means the outcome is unknown, so inspect the inbox, sessions or peer before retrying.
127
215
 
128
- Launching is **disabled by default**, independently for each certificate identity. Messaging-only installs and helper `--help` do not require a Pi SDK. To enable launches, add `launch` to the selected client in the private Mac configuration and create an owned **0700** session directory first:
216
+ `intercom-remote.mjs` wraps the routes with the verbs `register`, `list`, `send`, `ask`, `inbox`, `reply`, `ack`, `start`, `spawn`, `resume`, `sessions`, `stop`, `cancel`, `capabilities`, `transcript`, `groups`, `group-upsert`, `broadcast` and `group-send` (broadcast that requires `--group`). Run it with `--help` for options. It prints JSON and exits 0 on success, 1 on an API, TLS or transport failure and 2 on invalid arguments. The examples below call routes directly:
129
217
 
130
218
  ```bash
131
- mkdir -m 700 "$BRIDGE/sessions"
219
+ api() { # api METHOD PATH [JSON]
220
+ curl --fail-with-body -sS --max-time 135 \
221
+ --cacert "$INTERCOM_BRIDGE_CA" --cert "$INTERCOM_BRIDGE_CERT" --key "$INTERCOM_BRIDGE_KEY" \
222
+ -X "$1" -H 'Content-Type: application/json' ${3+--data "$3"} "$INTERCOM_BRIDGE_URL$2"
223
+ }
132
224
  ```
133
225
 
226
+ | Route | Request | Result |
227
+ | ----------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------- |
228
+ | `POST /v1/register` | `{}` | `sessionId`, `name`; other routes register automatically |
229
+ | `GET /v1/list` | — | live peers with unambiguous `target`s |
230
+ | `POST /v1/send` | `{to,message}` | `id`, `accepted`, `delivered` |
231
+ | `POST /v1/ask` | `{to,message,timeoutMs?}` | send receipt and `reply:{from,message}` |
232
+ | `POST /v1/reply` | `{replyTo,message}` | send receipt to the ask's saved sender |
233
+ | `GET /v1/inbox` | `?after=CURSOR&limit=1..256` | `messages`, `nextCursor`, `hasMore`, `overflow`, `lostMessages`, `storageError` |
234
+ | `POST /v1/ack` | `{ids:[...]}`, at most 256 | `acked`, `retained` |
235
+ | `POST /v1/broadcast` | `{message,group?}` | `status`, per-target `receipts`, member `errors` |
236
+ | `GET /v1/groups` | — | this identity's `groups` |
237
+ | `POST /v1/groups` | `{name,members:[...]}` or `{name,match}` | upserted `groups` |
238
+ | `GET /v1/sessions` | `?live=true&saved=true&q=&offset=&limit=` | `sessions`, `nextOffset?`, `liveTruncated?` |
239
+ | `POST /v1/spawn` | `{name,cwd?,task?,model?}` | `session`, `id`, `reused`, `task?` |
240
+ | `POST /v1/resume` | `{target,task?}` | `session`, `id`, `reused`, `task?` |
241
+ | `POST /v1/stop` | one of `sessionId`, `target`, `to`; `mode?`, `timeoutMs?` | see [Stop and cancel](#stop-and-cancel) |
242
+ | `POST /v1/cancel` | one of `sessionId`, `target`, `to`; `timeoutMs?` | same as `stop` with `mode:"cancel"` |
243
+ | `GET /v1/transcript` | `?target=&limit=&maxBytes=&cursor=&timeoutMs=` | live or saved transcript page; see [Transcripts](#transcripts) |
244
+ | `POST /v1/transcript` | `{to,limit?,maxBytes?,cursor?,timeoutMs?}` | same as `GET /v1/transcript` |
245
+ | `POST /v1/capabilities` | `{to,timeoutMs?}` | native capabilities receipt |
246
+ | `POST /v1/control` | `{to,verb,options?,timeoutMs?}` | native control receipt; `verb:"transcript"` behaves like `/v1/transcript` |
247
+ | `POST /v1/start` | `{name}` | legacy idle launch in the identity `cwd` |
248
+
249
+ Ask waits up to two minutes (`timeoutMs` 100–120000) for the exact recipient and `replyTo`; each identity has one outgoing ask at a time. A timeout doesn't retract a delivered ask, and a late answer arrives in the inbox. Spawn, resume and transcript lookup have a 75-second helper deadline (90 seconds in the CLI); ask has its timeout plus 15 seconds; other routes have 15 seconds. Live transcript controls keep their separate native response deadline; saved name lookup may scan the full catalog.
250
+
251
+ ### Mailbox, presence and wake
252
+
253
+ **Intake.** Every message addressed to the identity is committed to its SQLite mailbox (full synchronous writes) before the helper surfaces it or completes a waiting ask. Unread mail and unanswered asks survive helper crashes, restarts and broker outages. There is no retention limit, so monitor disk space. If a write fails, the mailbox records `overflow`, `lostMessages` and `storageError`. If the loss itself can't be recorded, requests return 507 `mailbox_storage_failed` and the audit log shows `lossRecorded:false`.
254
+
255
+ **Reading.** `GET /v1/inbox` returns a non-destructive page of at most 256 messages and 4 MiB. Keep reading with `after=nextCursor` while `hasMore` is true; start again from `0` to revisit unacked mail. The cursor is an opaque sequence number. Inbox and ack work while the broker is down.
256
+
257
+ **Acknowledging.** Ack an ordinary message to delete its payload; its ID stays as a tombstone so replays are ignored. Ack an ask to record that you've stored it: it appears in `retained`, stays in the inbox as a reply obligation and never triggers a fallback wake. A successful `reply` retires the ask. Reply goes to the ask's saved sender ID; if that sender is offline, it returns `peer_offline` and keeps the obligation. Ack is idempotent. In persistent mode, an answer returned by `ask` is also kept in the inbox; ack `reply.message.id` once you've consumed it.
258
+
259
+ **Presence.** After the first authenticated connection, the helper writes a private presence proof (identity, `cwd` and certificate expiry) to `stateDir` and keeps the identity registered with the broker, refreshing every 15 seconds. It stays registered without remote polling until the certificate expires, the identity changes or is revoked, or the helper stops. A restarted helper restores presence from that proof without any remote request. A revoked identity is never restored.
260
+
261
+ **Primary wake.** The remote consumer owns waking its agent. Poll the inbox, for example every 15 seconds; append each message to durable storage and fsync it; only then ack it. A crash before the ack replays the message, so deduplicate by `message.id`.
262
+
263
+ **Fallback wake.** If a message stays unacked for at least 120 seconds, the helper POSTs one wake for the oldest such message to `GROK_WAKE_WEBHOOK_URL` with `Authorization: Bearer <GROK_WAKE_WEBHOOK_KEY>` and this body:
264
+
134
265
  ```json
135
266
  {
136
- "packageRoot": "/absolute/immutable/node_modules/@earendil-works/pi-coding-agent",
137
- "sessionDir": "/Users/mitch/.pi/intercom-bridge/sessions",
138
- "provider": "openai-codex",
139
- "model": "EXACT_MODEL_ID",
140
- "context": "This is a locally authorized remote Intercom session. Stay idle until an Intercom task arrives and reply through Intercom.",
141
- "extensions": [
142
- "/absolute/pi-subagents/dist/pi-intercom/index.js",
143
- "/absolute/pi-subagents/dist/extension/index.js"
144
- ],
145
- "discoverResources": false,
146
- "trustProject": false,
147
- "offline": false,
148
- "maxSessions": 4,
149
- "startupTimeoutMs": 15000
267
+ "from": "sender name",
268
+ "from_id": "pi-…",
269
+ "to": "fitch-os",
270
+ "message_id": "…",
271
+ "ask_id": null,
272
+ "text": "…"
150
273
  }
151
274
  ```
152
275
 
153
- The helper runs its **physical Node executable**, resolves only the pinned package's manifest `bin.pi`, and uses the identity's fixed, canonical Mac `cwd`. Use the intended installed fork, not a development SDK with the same version. `provider` and `model` must be exact IDs, without fuzzy matching or a thinking suffix. Local settings still choose model reasoning. Native launch context is a genuine user instruction written using that package's exported `SessionManager`; it creates a **0600 native JSONL journal without submitting a prompt or running inference**. The session then starts idle in RPC mode. Success requires exact saved ID/name/file/model, idle RPC state, and actual publication of its named Intercom broker peer. This proves startup, **not provider credential usability**; verify the first real task/reply separately.
276
+ `ask_id` is the message ID for asks. A 2xx response is logged as `accepted`, but it can't prove the consumer stored the message, so the wake stays pending: the helper posts the same oldest unacked message again every 5 minutes until the consumer acks it. Acking an ask counts. Deduplicate wakes by `message_id`. Other responses and transport failures retry with doubling backoff up to 5 minutes. Redirects are refused, each request times out after 10 seconds, and the helper sends at most one wake per cycle. Fallback stops while the identity is revoked or its certificate has expired.
154
277
 
155
- Resources are locally controlled. `extensions` requires 1–32 existing absolute paths including Intercom; include subagents and any other useful locally approved extensions there. By default (`discoverResources:false`), discovered/configured/built-in extensions, skills, templates, themes and context-file discovery are disabled; explicit extensions still load. This is an explicit resource selection, not full normal discovery. Set `discoverResources:true` to retain normal native discovery; use the **same physical Intercom extension path already selected by native settings**, avoiding duplicate owners loaded from separate installed copies. `trustProject` (default false) chooses native `--approve` versus `--no-approve` for the fixed local project; it is not a remote approval. `offline` (default false) disables automatic catalog/network activity, not requested model inference. The child retains local credentials and normal environment, but strips enclosing `PI_SUBAGENT_*` identity/control metadata so it is an independent session.
278
+ ### Sessions
156
279
 
157
- Send SIGHUP after editing policy. Removing/changing an identity or its launch policy stops its existing children; invalid reload denies everyone and stops all owned children. Credential `issue` preserves launch policy but replaces the fingerprint, so rotation also stops the old credential's children. Only this helper's own children may be stopped, never arbitrary Pi sessions or the broker.
280
+ `GET /v1/sessions?live=true&saved=true` returns rows of `id`, `sessionId`, `peerId`, `name`, `cwd`, `status`, `lastSeen`, `saved` and `owned`. `owned` is true only for this identity's current helper subprocesses. Saved rows require `launch` policy and come from `sessionDir` and `resumeDirs`; rows never include journal paths or transcript bodies. `q` filters ID, name and cwd. `limit` is 1–100 (default 50). `offset` and `nextOffset` are filesystem candidate cursors, with at most 1,000 candidates scanned per request, not row counts. `liveTruncated` means live-only peers didn't fit; page them with `live=true` alone. A bare `GET /v1/sessions` keeps the legacy list of this identity's owned processes, including PID and journal path.
158
281
 
159
282
  ```bash
160
- node "$REMOTE" start --name planner
161
- node "$REMOTE" sessions
162
- # Use peerId from start/sessions as the ordinary messaging target:
163
- node "$REMOTE" ask --to PI_PEER_ID --message 'Review the current project and reply with your findings.'
164
- node "$REMOTE" stop --session-id NATIVE_SESSION_ID
283
+ api GET '/v1/sessions?live=true&saved=true&limit=50'
284
+ api POST /v1/spawn '{"name":"reviewer","cwd":"/Users/you/src/app","task":"Review the current branch and reply with findings.","model":"provider/model-id"}'
285
+ api POST /v1/resume '{"target":"NATIVE_SESSION_ID","task":"Continue where you left off."}'
165
286
  ```
166
287
 
167
- Names are prefixed `remote:IDENTITY:NAME` to avoid impersonating local peers. An already-live name for the same identity is reused; a concurrent start/stop returns a conflict, not a duplicate launch. After a lost start response, inspect `sessions` or repeat the **same name**, not a fresh name. Name is the **only** remote launch input: no task, cwd, shell, environment, flags, model, resource paths or raw RPC commands. Submit tasks afterward through ordinary Intercom. Fixed cwd and launch settings are **not a sandbox**: the authorized agent can use its locally enabled tools and credentials. Grant launch permission only to a remote identity trusted with that authority and potential model costs.
288
+ **Spawn** creates a session named `remote:IDENTITY:NAME` in `cwd` (default: the identity `cwd`). `model` is `provider/id`, split at the first slash; you can also send `provider` and `model` separately. The helper writes a new 0600 journal containing the launch context without running inference, starts Pi idle in RPC mode, and waits for readiness. It then sends `task` as an ordinary Intercom message from the identity, so the session's replies land in the mailbox. `task:{id,accepted,delivered,completed:false}` is broker acceptance. A live session with the same name is reused (`reused:true`), or rejected with `cwd_conflict` or `model_conflict` when those differ. If the task send fails, the error includes the live `session`; deliver the task with `/v1/send` instead of spawning again. After a lost response, check sessions or repeat the same name.
168
289
 
169
- Caps are 4 sessions per identity by default (local range 1–16), and 32 active launches helper-wide. Readiness deadline defaults to 15 seconds (local range 1–60000 ms). HTTP start has a 75-second outer deadline; CLI start has 90 seconds including cleanup. Startup failure, abort, deadline, explicit stop, identity revocation, certificate expiry and helper SIGINT/SIGTERM stop owned children. Each launch has a private, helper-owned process group. Cleanup closes stdin, escalates that group to TERM/KILL after 1/3 seconds if needed (including unresponsive native workers), and observes actual process closure within 8 seconds; it does not report a signal request as a completed exit. A cleanup deadline returns `stop_timeout`; inspect `sessions` and retry stop, even if the process has since exited. Unsupported RPC dialogs are cancelled and the session is stopped with `interaction_required`; they are never automatically approved. RPC events/diagnostics are continuously drained. Control records are bounded to 4 MiB; oversized unused native transcript/tool events are discarded through their LF boundary with metadata-only overflow reporting, without truncating the native session/tool result or killing a healthy peer. Raw stderr/model/tool content is never sent to the audit log.
290
+ **Resume** takes `target` (or exactly one of `sessionId` or `name`) plus an optional `task`. A target matches an exact native session ID or peer ID first, then a unique saved name. Name lookups scan the whole catalog to prove uniqueness and fail rather than accept a partial match. Resume reuses a live owned process, then a live Intercom peer proven to own that journal, without reopening the journal. Otherwise it opens the original journal in place, with the same session ID, cwd, name and history, and restores the recorded model. If Pi falls back to a different model, resume fails with `model_restore_failed` and submits no task. It refuses with `active_session` when a live peer matches the name and cwd but can't be proven to own the journal. A process that resume starts is helper-owned.
170
291
 
171
- Long-lived peers are **not tied to the remote presence peer's five-minute idle lease** or an HTTP connection. They remain available while the helper and certificate authorization remain valid. Journals survive stop/failure/restart and can be resumed locally with the pinned Pi's `--session PATH`; the helper does not silently restart or adopt old/native processes. `sessions` reports current helper ownership, state, PID, native journal path and metadata-only error codes; at most 64 recent records are retained. The PID identifies the owned manifest-CLI process; a supervised fork can run Pi in its native worker. It is not an all-machine session admin API. Planned helper shutdown stops its children; do not SIGKILL the helper to perform routine cleanup.
292
+ Duplicate-writer protection covers helper-owned startups and discoverable Intercom peers. Pi's `SessionManager` has no cross-process journal lock, so a Pi process without Intercom, or one that hasn't published its peer yet, is invisible to the bridge. The helper never adopts processes after a restart. A surviving child that is still a live Intercom peer is reused by resume rather than reopened.
172
293
 
173
- ## Grok Bot: exact remote commands
294
+ ### Stop and cancel
174
295
 
175
- On Linux, configure Grok's shell environment:
296
+ Both routes take exactly one of `sessionId`, `target` or `to`. `stop` also takes `mode`: `kill` (default) or `cancel`. `POST /v1/cancel` is `stop` with `mode:"cancel"`. The helper first looks for this identity's helper-owned process by native session ID, peer ID or name. Only when the identity owns no match (`unknown_session`) does it send a native control to a live Pi, with `timeoutMs` 100–10000 (default 3000). Any other failure, including an unknown outcome, is returned as is and never redirected. Ownership decides what the bridge can guarantee:
176
297
 
177
- ```bash
178
- export INTERCOM_BRIDGE_URL='https://127.0.0.1:9443'
179
- export INTERCOM_BRIDGE_CA="$HOME/.config/pi-intercom/ca.crt"
180
- export INTERCOM_BRIDGE_CERT="$HOME/.config/pi-intercom/client.crt"
181
- export INTERCOM_BRIDGE_KEY="$HOME/.config/pi-intercom/client.key"
182
- REMOTE="$HOME/.config/pi-intercom/intercom-remote.mjs"
298
+ | Target | `mode: "cancel"` | `mode: "kill"` (default) |
299
+ | -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
300
+ | This identity's helper-owned process | Native RPC abort. Returns after observed idle with `queuesPreserved:true` and the pending count, or `cancel_unconfirmed`. Future Intercom tasks still run. | Closes stdin and signals the private process group (TERM after 1 s, KILL after 3 s). Returns `status:"exited"` only after observing exit within 8 seconds; otherwise `stop_timeout`, so retry. The journal is kept. |
301
+ | Any other live Pi with the updated extension | Native control `cancel_requested`: the current operation is aborted and accepted queued input is kept (`queueCleared:false`). | Native shutdown request: `shutdown_requested`, or `unsupported` while still requesting shutdown and abort. Always `processExited:false`. |
183
302
 
184
- node "$REMOTE" register
185
- node "$REMOTE" list
186
- node "$REMOTE" send --to planner --message 'Grok checking in. Please send your current status.'
187
- node "$REMOTE" ask --to planner --message 'What is the current status?' --timeout-ms 120000
188
- node "$REMOTE" inbox
189
- node "$REMOTE" reply --reply-to QUESTION_ID_FROM_INBOX --message 'Proceed with the stable API.'
190
- node "$REMOTE" ack ORDINARY_MESSAGE_ID_FROM_INBOX
191
- ```
303
+ For other sessions, TUI shuts down in order, idle RPC waits for its next command, and print or JSON modes report `unsupported`. A disconnect doesn't prove exit, and detached background jobs keep their own lifecycle. The bridge never signals another process by PID and never stops the broker.
192
304
 
193
- Poll inbox at a reasonable cadence (for example every 5–15 seconds) while Grok is available. Fetch is non-destructive, so retrying a fetch cannot lose messages; persist/consume then ack ordinary messages. In persistent mode, follow `hasMore` using `inbox --after NEXT_CURSOR --limit 256`; do not treat the first page as the entire inbox. Restart a drain from cursor `0` to revisit unacked messages. Inbound asks remain in the inbox until `reply` succeeds; ack does not discard a reply obligation. Use `send` for most coordination and `ask` only when Grok really needs a blocking answer. The CLI has a hard request deadline (ask timeout + 15 seconds), validates TLS, emits JSON, and exits 1 on errors / 2 on invalid arguments. It never retries mutations automatically.
305
+ **Native controls** travel as passive, typed Intercom messages. Before acting, the recipient checks that the sender is `remote-<fingerprint>` and that the TLS certificate chain supplied by the bridge is current, issued by the configured CA and pinned in its policy file, then replies with a correlated result. A Pi process still running older extension code receives only a short passive note; the request then fails with `control_unconfirmed` and `deliveryUnknown:true`. Don't retry automatically. Use `POST /v1/control` with `verb` `capabilities`, `cancel`, `stop` or `transcript` for the direct form; it skips the owned-process lookup. `timeoutMs` is 100–10000 (default 3000), with at most eight controls in flight per identity.
194
306
 
195
- Curl works too, with bounded waiting and the same TLS files:
307
+ ### Transcripts
196
308
 
197
- ```bash
198
- curl --fail-with-body --max-time 135 \
199
- --cacert "$INTERCOM_BRIDGE_CA" --cert "$INTERCOM_BRIDGE_CERT" --key "$INTERCOM_BRIDGE_KEY" \
200
- -H 'Content-Type: application/json' \
201
- -d '{"to":"planner","message":"Please reply with your status.","timeoutMs":120000}' \
202
- "$INTERCOM_BRIDGE_URL/v1/ask"
203
- ```
309
+ `GET /v1/transcript?target=TARGET&limit=20`, `POST /v1/transcript {to,...}` and `POST /v1/control` with `verb:"transcript"` read the same way. The helper first resolves the target against live Intercom peers:
204
310
 
205
- ### JSON API contract
311
+ - **Live.** A live Pi with the updated extension answers through native control. The result is the control receipt plus `source:"live"` and `transcript`, the page (also still at `native.transcript`). A live Pi running older extension code fails with `control_unconfirmed`; the helper doesn't fall back to its journal.
312
+ - **Saved.** Only when no live peer matches (`peer_offline`) does the helper read the saved journal. That requires `launch` policy (otherwise 403 `launch_disabled`). The target resolves through the saved catalog by stable ID or unique name, and the result is `{status:"ok", source:"saved", session, transcript}`. The journal is parsed read-only with the identity's `packageRoot` and is never opened for writing or migrated. Saved reads reject journals over 64 MiB or 50,000 entries, ignore an unfinished trailing record, and fail with 409 `session_changed` if the journal's session ID changes during the read.
206
313
 
207
- All routes require mTLS and the certificate allowlist. Unknown routes/fields, wrong methods, non-JSON bodies, unsafe control characters, and requests larger than 64 KiB are rejected. No CORS; browser-origin requests are rejected. Success is `{ "ok": true, ... }`; application failures are `{ "ok": false, "error": { "code": "...", "message": "..." } }` with a non-2xx status. Certificate/allowlist admission can terminate TLS before HTTP; the CLI reports that as a TLS/transport error. No remote-controlled broker frame or command is passed through.
314
+ A page holds `records` (`id`, `timestamp`, `role`, `text`, `truncated`) in chronological order. Only finalized user, assistant, tool-result and custom text on the active branch is included; thinking, images, tool arguments, and system or provider data are not, and streaming output isn't available. Defaults are 50 records and 64 KiB, with maximums of 100 records and 128 KiB (`maxBytes` minimum 256). A read scans at most 1,024 entries and reports `scanLimitReached`. `cursor` continues to older output while the selected branch is unchanged.
208
315
 
209
- | Route | Request | Success fields |
210
- | ------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------ |
211
- | `POST /v1/register` | `{}` | `sessionId`, `name` (also automatic on other operations) |
212
- | `GET /v1/list` | none | `sessionId`, `sessions` (metadata + unambiguous targets) |
213
- | `POST /v1/send` | `{to,message}` | `id`, `accepted`, `delivered` |
214
- | `POST /v1/ask` | `{to,message,timeoutMs?}` | send receipt and `reply:{from,message}` |
215
- | `GET /v1/inbox` | `?after=CURSOR&limit=N` (optional) | `messages:[{from,message}]`, `nextCursor`, `hasMore`, `overflow`, `lostMessages`, `storageError` |
216
- | `POST /v1/reply` | `{replyTo,message}` | send receipt; target derived from the received ask |
217
- | `POST /v1/ack` | `{ids:[...]}` | `acked`, `retained` |
218
- | `POST /v1/start` | `{name}` | `session:{sessionId,peerId,name,status,pid,sessionFile,error?}`, `reused` |
219
- | `GET /v1/sessions` | none | `sessions` (only current helper/identity-owned launch records) |
220
- | `POST /v1/stop` | `{sessionId}` | `session` with observed `exited` status; journal retained |
316
+ ### Groups and broadcast
221
317
 
222
- Inbox `after` is an opaque decimal sequence cursor (`0` starts a drain), not a message ID or offset. `limit` is 1–256, default 256; unknown/repeated query keys and invalid cursors are rejected. Ack accepts at most 256 IDs per request and is idempotent; it returns asks in `retained`. Persistent duplicate IDs never increment loss counts or replace the original envelope. In persistent mode an answer returned by `ask` is also retained in inbox; ack its `reply.message.id` after consuming it.
318
+ ```bash
319
+ api POST /v1/groups '{"name":"app","members":["reviewer","pi-0123456789abcdef0123456789abcdef"]}'
320
+ api POST /v1/groups '{"name":"workers","match":"remote:fitch-os:*"}'
321
+ api POST /v1/broadcast '{"group":"workers","message":"Report status."}'
322
+ ```
223
323
 
224
- The storage API also supports opt-in durable notification intents for a local delivery worker: `Mailbox(stateDir, fingerprint, true)`, `pendingNotifications(after, limit)` and `notificationAccepted(messageId)`. Pending envelopes expose local `receivedAt` and `acknowledged` metadata. Intake and notification intent commit in one SQLite insert. Remote ack/reply hides the inbox row but preserves the notification envelope until the worker marks it accepted; notification acceptance never acknowledges unread mail. This API does not post webhooks or enable any wake service on its own.
324
+ Groups are upserted by name and private to the certificate fingerprint, stored in `stateDir` (or `$PI_CODING_AGENT_DIR/intercom/bridge-state`, created 0700, when `stateDir` is unset). Each identity can have 64 groups of up to 256 explicit members, within 1 MiB. `match` is a name glob or `{id?, nameglob?, cwd?}`; the canonical fields are `members`, `namePattern` and `cwdPattern`. Explicit members are unioned with glob matches, and when both globs are set a peer must match both. Globs use Node's `path.matchesGlob`.
225
325
 
226
- Timeout does **not** retract a delivered ask; a late answer can arrive in inbox. Offline/missing peers, broker outages, ambiguous/self targets, a second concurrent ask, invalid/revoked credentials, timeout, and resource limits have explicit errors. HTTP send/list deadlines are bounded by the real client (8/5 seconds); broker registration by 10 seconds. A lost HTTP response or acknowledgement timeout means **delivery unknown**—inspect inbox/local peer before retrying to avoid duplicate instructions.
326
+ Broadcast resolves current live Pi sessions, excluding remote identities, and deduplicates them. Without `group` it targets every live Pi session. Each target is sent once, sequentially, with its own receipt or error. Offline or ambiguous members appear in `errors`, and any failure makes `status` `partial`.
227
327
 
228
- ## Rotate credentials
328
+ If the broker stalls or disconnects mid-broadcast, the HTTP request fails (`ok:false`) but retains `status:"partial"`, all per-target `receipts` and member `errors`. A receipt with `accepted:true` is confirmed; an in-flight failure carries its message `id` and `deliveryUnknown:true`; targets not sent have `attempted:false` and `deliveryUnknown:false`. Revocation or certificate expiry denies the response instead. Nothing is retried automatically. Resend only definite failures or unattempted targets; inspect unknown outcomes before deciding whether to resend.
229
329
 
230
- Client renewal replaces its old fingerprint rather than authorizing both indefinitely. Credential commands hold a private directory lock across the complete config read/modify/write and certificate issuance, so concurrent operations fail instead of restoring a revoked fingerprint. After a crashed command, inspect `DIR/.credentials.lock/owner`, confirm that process is gone, then remove that lock and retry; never clear a live command's lock.
330
+ ## Update and roll back
231
331
 
232
- Renew with:
332
+ **Helper.** Install a new release (step 1), point `current` at it, then restart gracefully:
233
333
 
234
334
  ```bash
235
- node scripts/intercom-bridge-credentials.mjs issue "$BRIDGE" grok-bot
236
- # Transfer the NEW cert/key using the same private copy commands above.
237
- kill -HUP "$HELPER_PID"
335
+ ln -sfn "$RELEASES/NEW_RELEASE" "$RELEASES/current"
336
+ launchctl kill SIGTERM "gui/$(id -u)/$LABEL"
238
337
  ```
239
338
 
240
- SIGHUP rereads the private config, drops removed/changed peers and their TLS connections, and cancels pending waits. Confirm the reload audit record and verify the old certificate is denied. Invalid reload fails closed. Client expiry also applies to existing connections/peer leases, not just new TLS handshakes. Restart Grok's client process if it caches its files.
339
+ To roll back, point `current` at the previous release and restart the same way. A restart stops the helper's owned sessions; their journals remain, so resume them afterward. Mailboxes, presence proofs and groups persist. Delete an old release only when no helper or broker process still runs from it, because a broker started by the helper runs from that release.
340
+
341
+ **Pi extension.** Update Pi's own installation by following the [installation guide](reference.md#installation), and never overwrite extension files that a running Pi has loaded. New Pi processes use the newly selected extension. Each existing process keeps the code it loaded and gains native controls only after it adopts the update at a safe boundary: finish or checkpoint its work, quit, and resume the same saved session with `pi --session PATH`. Until then it stays messageable and listed, but controls to it fail with `control_unconfirmed`. The bridge can't force old processes to reload and doesn't kill them.
241
342
 
242
- Server certificate expires after one year; CA after ten. To rotate server/CA, create a **new** credential directory with `init`, transfer its new public CA and new client credential, stop/restart **only this helper** with the new config, and retain the old directory briefly as a private rollback. Never restart the shared broker or change its socket permissions for rotation. If the CA key is compromised, replace the entire CA immediately. Old private key files retained by `issue` should be removed only when no rollback is needed.
343
+ ## Credentials
243
344
 
244
- ## How to revoke Grok
345
+ **Rotate a client.** `issue` always creates a new fingerprint, keeps the identity's `launch` and `wake` policy, and replaces the old pin. Don't run it for a long-lived certificate you mean to keep.
245
346
 
246
347
  ```bash
247
- node scripts/intercom-bridge-credentials.mjs revoke "$BRIDGE" grok-bot
248
- kill -HUP "$HELPER_PID"
348
+ node "$PKG/scripts/intercom-bridge-credentials.mjs" issue "$BRIDGE" fitch-os --days 3650
349
+ launchctl kill SIGHUP "gui/$(id -u)/$LABEL"
249
350
  ```
250
351
 
251
- Confirm the reload/revocation audit record, that Grok disappears from the local peer list, and that its old cert cannot call `/v1/list`. Stop the SSH forward too if no other client needs it. Remove Grok's saved client key on Linux and obsolete local client keys when no longer needed. Emergency immediate cut-off: stop **the helper process**, not Pi or the broker. Certificate renewal cannot bypass the removed fingerprint; reauthorization requires a new local `issue` + reload.
352
+ Copy the new certificate and key to the remote host. After the reload, the old certificate is denied and its owned sessions stop. Mailboxes, presence proofs and groups are keyed by fingerprint, so the new certificate starts empty; the old files stay on the Mac for local recovery.
252
353
 
253
- ## Verification / smoke transcript
254
-
255
- The process-boundary tests use a private real broker, real TLS certificates, actual HTTPS requests and the remote CLI. Mailbox coverage includes cold SIGKILL restart, sender reconnection, idle-lease retirement, broker-outage reads/acks, more than both former count/byte caps, bounded cursor drains, deduplication, failed storage admission, private-file enforcement and notification/ack races across process restarts. Launch tests use the actually installed Pi dependency graph with a selected-host native faux provider (no paid inference or external credentials); they prove native persistence, zero pre-startup inference, real Intercom wake/tool reply, ownership/caps, startup failure/abort/deadline, safe dialog cancellation, presence-lease independence, short-lived certificate expiry, rotation/revocation and child exit before helper shutdown completes:
354
+ **Renew the server certificate.** The server certificate from `init` lasts one year. Before it expires, reissue it from the same CA and restart the helper, because SIGHUP can't change TLS files:
256
355
 
257
356
  ```bash
258
- node --test test/integration/intercom-bridge.test.ts test/integration/intercom-bridge-launch.test.ts
259
- # Do not inject host startup diagnostics into native fixture stderr:
260
- env -u PI_TIMING -u PI_EXTENSION_PERFORMANCE npm run ci
357
+ node "$PKG/scripts/intercom-bridge-credentials.mjs" renew-server "$BRIDGE" --days 3650
358
+ launchctl kill SIGTERM "gui/$(id -u)/$LABEL"
261
359
  ```
262
360
 
263
- For a live smoke, from a separate Mac process and then Linux use the remote CLI to list peers and ask an intentionally selected connected session:
361
+ `renew-server` takes no identity name or `--cwd`. `--days` is 1–3650 (default 365). It writes a new `server-<hex>` key and certificate and points `cert` and `key` in `config.json` at them. The CA, client certificates and pins don't change, so remote hosts need nothing new. Check any certificate with `openssl x509 -noout -enddate -in FILE`; the CA itself expires ten years after `init`.
362
+
363
+ **Revoke.**
264
364
 
265
365
  ```bash
266
- node "$REMOTE" list
267
- node "$REMOTE" ask --to KNOWN_FULL_SESSION_ID --message 'Bridge smoke only: reply with bridge-smoke-ok.' --timeout-ms 120000
366
+ node "$PKG/scripts/intercom-bridge-credentials.mjs" revoke "$BRIDGE" fitch-os
367
+ launchctl kill SIGHUP "gui/$(id -u)/$LABEL"
268
368
  ```
269
369
 
270
- Save only a redacted transcript: platform, operation, returned peer names relevant to the test, accepted/delivered, and reply text. Do not publish unrelated cwd/model/topic/message content.
370
+ Confirm the `allowlist_reloaded` record, that the identity has left the peer list, and that its certificate can't call `/v1/list`. Revocation also stops its owned sessions and fallback wakes. For an immediate cut-off, `bootout` the helper or withdraw the Tailscale Serve port. If the CA key is compromised, start over with a new `init`.
271
371
 
272
- Observed **2026-10-06**, from a separate process on the Mac through the installed runtime-only helper to the live broker and a real connected Pi session (not a mocked recipient):
372
+ Credential commands hold `DIR/.credentials.lock` for the whole update and fail rather than overwrite each other. After a crash, confirm the PID in `DIR/.credentials.lock/owner` has exited before removing the lock.
273
373
 
274
- ```json
275
- {
276
- "platform": "darwin",
277
- "transport": "loopback HTTPS mTLS",
278
- "list": { "ok": true, "peers": ["secure-intercom-bridge", "grok-bot"] },
279
- "ask": {
280
- "ok": true,
281
- "accepted": true,
282
- "delivered": true,
283
- "replied": true,
284
- "reply": "bridge-smoke-ok"
285
- }
286
- }
374
+ ## Recovery
375
+
376
+ | Symptom | What to do |
377
+ | ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
378
+ | TLS handshake fails from the remote host | Check client, server and CA expiry; the remote forwarder; `tailscale serve status`; and the helper's `listening` record. |
379
+ | Local peers get `peer_offline` for the remote identity | The helper is stopped or the certificate expired or was revoked. A restarted helper re-registers from a valid presence proof; otherwise one authenticated request does. |
380
+ | `broker_unavailable`, or a refusal to start a second broker | Let the unhealthy broker exit, or start a normal Pi session, then retry. Don't delete broker files or kill a working broker. |
381
+ | 507 `mailbox_storage_failed` | Fix disk space or `stateDir` permissions, then restart the helper. `lossRecorded:false` audit records mark uncounted losses. |
382
+ | No fallback wakes | Check `wake_delivery` records (`http_rejected` with status, `transport_unconfirmed`, `storage_unavailable`) and the env file's mode and URL. Acked messages never wake. |
383
+ | Repeated wakes for one `message_id` | Expected every 5 minutes after an `accepted` wake until the consumer acks the message. Fix the consumer's store-then-ack loop. |
384
+ | Lost response, `task_unconfirmed` or `control_unconfirmed` | The outcome is unknown. Inspect the inbox, sessions or peer before retrying. |
385
+ | `launch_in_progress` or `stop_timeout` | Inspect `GET /v1/sessions`; retry stop until it reports `exited`. |
386
+ | `allowlist_reloaded` with `failed_closed` | The config is invalid; every identity is denied and owned sessions stopped. Fix the config and send SIGHUP again. |
387
+
388
+ ## Verification
389
+
390
+ The process-boundary tests use a private real broker, real certificates, real HTTPS and the remote CLI. Launch tests use the installed Pi dependency graph with a native faux provider, so no paid inference or external credentials are needed. Coverage includes cold SIGKILL restarts with mailbox and presence recovery, delayed fallback wakes and ack suppression, bounded cursor drains, storage failures, spawn and resume identity and model preservation, owned cancel and observed exit, native control authorization and old-owner timeouts, live transcripts and the saved-journal reader, same-CA server renewal with an unchanged client key, and restart-persistent groups.
391
+
392
+ ```bash
393
+ node --test test/integration/intercom-bridge.test.ts test/integration/intercom-bridge-delivery.test.ts \
394
+ test/integration/intercom-bridge-launch.test.ts test/integration/intercom-bridge-lifecycle.test.ts \
395
+ test/integration/intercom-native-control.test.ts test/unit/intercom-transcript.test.ts
396
+ env -u PI_TIMING -u PI_EXTENSION_PERFORMANCE npm run ci
287
397
  ```
288
398
 
289
- **Off-box deployment/smoke is not yet verified:** the actual Grok Linux SSH destination/access was unavailable. No credentials were transferred to an unverified host. Once that access is supplied, verify Linux's listener and repeat these exact list/ask commands there.
399
+ For a live smoke test from the remote host, run `list`, ask one deliberately chosen session to reply `bridge-smoke-ok`, and fetch `GET /v1/sessions?live=true&saved=true`. Pause the remote consumer for more than two minutes to confirm a fallback wake. Keep only a redacted record: platform, route, accepted or delivered flags and the reply text. Never publish config, keys, wake URLs or raw inbox and list output.