@fitchmultz/pi-subagents 0.45.0 → 0.46.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.
@@ -8,12 +8,14 @@ import { ASK_TIMEOUT, Fault, fingerprint, log } from "./bridge-protocol.js";
8
8
  import { Session } from "./bridge-session.js";
9
9
  import { operationFor, routeFor } from "./bridge-routes.js";
10
10
  import { reply } from "./bridge-http.js";
11
+ import { Launcher, sameIdentity } from "./bridge-launch.js";
11
12
  const MAX_CONNECTIONS = 128;
12
13
  class Bridge {
13
14
  config;
14
15
  configPath;
15
16
  sessions = new Map();
16
17
  losses = new Map();
18
+ launcher = new Launcher();
17
19
  sockets = new Set();
18
20
  connections = new Map();
19
21
  server;
@@ -72,7 +74,7 @@ class Bridge {
72
74
  }
73
75
  allowed(identity) {
74
76
  const current = this.config.clients.get(identity.fingerprint256);
75
- return !this.stopping && current?.name === identity.name && current.cwd === identity.cwd;
77
+ return !this.stopping && sameIdentity(identity, current);
76
78
  }
77
79
  lost(fp, count = 1) {
78
80
  if (count > 0 && this.config.clients.has(fp)) {
@@ -160,8 +162,9 @@ class Bridge {
160
162
  });
161
163
  });
162
164
  process.on("SIGHUP", () => this.reload());
163
- process.once("SIGINT", () => this.stop());
164
- process.once("SIGTERM", () => this.stop());
165
+ // Retain listeners during dispatch: selected SDK dependencies use signal-exit's listener check.
166
+ process.on("SIGINT", () => this.stop());
167
+ process.on("SIGTERM", () => this.stop());
165
168
  }
166
169
  reload() {
167
170
  if (this.stopping) {
@@ -174,10 +177,14 @@ class Bridge {
174
177
  }
175
178
  this.config = next;
176
179
  this.revokeRemoved();
180
+ this.launcher.revoke(next.clients).catch(() => log("launch_cleanup", { result: "failed" }));
177
181
  log("allowlist_reloaded", { result: "accepted", clients: next.clients.size });
178
182
  }
179
183
  catch {
180
184
  this.config = { ...this.config, clients: new Map() };
185
+ this.launcher
186
+ .revoke(this.config.clients)
187
+ .catch(() => log("launch_cleanup", { result: "failed" }));
181
188
  for (const session of this.sessions.values()) {
182
189
  session.close(new Fault(503, "configuration_invalid", "Configuration reload failed; all identities denied."));
183
190
  }
@@ -224,7 +231,11 @@ class Bridge {
224
231
  for (const socket of this.sockets) {
225
232
  socket.destroy();
226
233
  }
227
- await Promise.all([...this.handlers, ...sessions.map((session) => session.settled())]);
234
+ await Promise.all([
235
+ ...this.handlers,
236
+ ...sessions.map((session) => session.settled()),
237
+ this.launcher.shutdown(),
238
+ ]);
228
239
  log("shutdown", { result: "closed" });
229
240
  }
230
241
  admission(req) {
@@ -270,14 +281,16 @@ class Bridge {
270
281
  const signal = controller.signal;
271
282
  req.once("aborted", abort);
272
283
  res.once("close", onClose);
273
- setDeadline(route === "POST /v1/ask" ? ASK_TIMEOUT + 15000 : 15000);
284
+ const duration = route === "POST /v1/ask" ? ASK_TIMEOUT + 15000 : 15000;
285
+ setDeadline(route === "POST /v1/start" ? 75000 : duration);
274
286
  const operation = await operationFor(req, route, signal);
275
287
  if (operation.route === "POST /v1/ask") {
276
288
  setDeadline(operation.timeoutMs + 15000);
277
289
  }
278
- const data = await session.execute(operation, {
290
+ const data = await this.execute(operation, session, {
279
291
  signal,
280
292
  requestId,
293
+ expires,
281
294
  onPeer: (id) => {
282
295
  peer = id;
283
296
  },
@@ -305,6 +318,29 @@ class Bridge {
305
318
  }
306
319
  }
307
320
  }
321
+ async execute(operation, session, context) {
322
+ switch (operation.route) {
323
+ case "POST /v1/start":
324
+ return this.launcher.start(operation.name, {
325
+ identity: session.identity,
326
+ expires: context.expires,
327
+ signal: context.signal,
328
+ peers: (signal) => session.connectedPeers(signal),
329
+ });
330
+ case "GET /v1/sessions":
331
+ return this.launcher.list(session.identity);
332
+ case "POST /v1/stop":
333
+ return this.launcher.stop(session.identity, operation.sessionId);
334
+ case "GET /v1/list":
335
+ case "GET /v1/inbox":
336
+ case "POST /v1/register":
337
+ case "POST /v1/ask":
338
+ case "POST /v1/send":
339
+ case "POST /v1/reply":
340
+ case "POST /v1/ack":
341
+ return session.execute(operation, context);
342
+ }
343
+ }
308
344
  }
309
345
  function main() {
310
346
  const args = process.argv.slice(2);
@@ -322,7 +358,7 @@ function main() {
322
358
  new Bridge(loadConfig(path), path).start();
323
359
  }
324
360
  catch {
325
- process.stderr.write("Bridge startup failed: check config schema, file ownership/mode 0600, absolute TLS/cwd paths, and TLS credentials.\n");
361
+ process.stderr.write("Bridge startup failed: check config schema, file ownership/mode 0600, absolute TLS/cwd paths, TLS credentials, and launch policy (0700 sessionDir, Pi packageRoot, extension paths).\n");
326
362
  process.exitCode = 1;
327
363
  }
328
364
  }
@@ -0,0 +1,17 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ import * as path from "node:path";
10
+ import { pathToFileURL } from "node:url";
11
+ export async function loadNativeSession(packageRoot) {
12
+ // The trusted selected Pi package supplies this exact SDK declaration contract.
13
+ // A computed URL is required: Jiti rewrites literal SDK imports to its own host graph.
14
+ // oxlint-disable-next-line typescript/no-unsafe-assignment
15
+ const sessionModule = await import(__rewriteRelativeImportExtension(pathToFileURL(path.join(packageRoot, "dist/index.js")).href));
16
+ return sessionModule;
17
+ }
@@ -1,18 +1,4 @@
1
- var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
- if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
- return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
- return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
- });
6
- }
7
- return path;
8
- };
9
- import * as path from "node:path";
10
- import { pathToFileURL } from "node:url";
11
1
  import { requirePiPackageRoot } from "../runs/shared/pi-spawn.js";
2
+ import { loadNativeSession } from "./native-session-loader.js";
12
3
  // Detached Node runners do not get Pi's extension-loader package aliases.
13
- const selectedSessionURL = pathToFileURL(path.join(requirePiPackageRoot(), "dist/index.js")).href;
14
- // The trusted selected Pi package supplies this exact SDK declaration contract.
15
- // A computed URL is required: Jiti rewrites literal SDK imports to its own host graph.
16
- // oxlint-disable-next-line typescript/no-unsafe-assignment
17
- const sessionModule = await import(__rewriteRelativeImportExtension(selectedSessionURL));
18
- export const { buildSessionContext, parseSessionEntries, migrateSessionEntries, SessionManager } = sessionModule;
4
+ export const { buildSessionContext, parseSessionEntries, migrateSessionEntries, SessionManager } = await loadNativeSession(requirePiPackageRoot());
@@ -1,14 +1,14 @@
1
1
  # Secure remote Intercom bridge
2
2
 
3
- Optional HTTPS messaging proxy 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
+ 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.
4
4
 
5
5
  ## Design note
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 proxy opens `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
+ **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.
8
8
 
9
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.
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 only register/list/send/ask/reply, plus inbox/ack receipt plumbing needed to receive messages and answer local asks. No broker admin, topic operations, file reads, or shell endpoint.
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.
12
12
 
13
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, chmod, broker spawn, PID-file operation, or broker restart. An unavailable broker returns an error; start a normal Pi session to bring it up.
14
14
 
@@ -105,6 +105,53 @@ ssh "$GROK_SSH" 'chmod 600 ~/.config/pi-intercom/*'
105
105
 
106
106
  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.
107
107
 
108
+ ## Enable controlled destination-session launches
109
+
110
+ 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:
111
+
112
+ ```bash
113
+ mkdir -m 700 "$BRIDGE/sessions"
114
+ ```
115
+
116
+ ```json
117
+ {
118
+ "packageRoot": "/absolute/immutable/node_modules/@earendil-works/pi-coding-agent",
119
+ "sessionDir": "/Users/mitch/.pi/intercom-bridge/sessions",
120
+ "provider": "openai-codex",
121
+ "model": "EXACT_MODEL_ID",
122
+ "context": "This is a locally authorized remote Intercom session. Stay idle until an Intercom task arrives and reply through Intercom.",
123
+ "extensions": [
124
+ "/absolute/pi-subagents/dist/pi-intercom/index.js",
125
+ "/absolute/pi-subagents/dist/extension/index.js"
126
+ ],
127
+ "discoverResources": false,
128
+ "trustProject": false,
129
+ "offline": false,
130
+ "maxSessions": 4,
131
+ "startupTimeoutMs": 15000
132
+ }
133
+ ```
134
+
135
+ 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.
136
+
137
+ 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.
138
+
139
+ 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.
140
+
141
+ ```bash
142
+ node "$REMOTE" start --name planner
143
+ node "$REMOTE" sessions
144
+ # Use peerId from start/sessions as the ordinary messaging target:
145
+ node "$REMOTE" ask --to PI_PEER_ID --message 'Review the current project and reply with your findings.'
146
+ node "$REMOTE" stop --session-id NATIVE_SESSION_ID
147
+ ```
148
+
149
+ 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.
150
+
151
+ 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.
152
+
153
+ 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.
154
+
108
155
  ## Grok Bot: exact remote commands
109
156
 
110
157
  On Linux, configure Grok's shell environment:
@@ -141,15 +188,18 @@ curl --fail-with-body --max-time 135 \
141
188
 
142
189
  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.
143
190
 
144
- | Route | Request | Success fields |
145
- | ------------------- | ------------------------- | -------------------------------------------------------- |
146
- | `POST /v1/register` | `{}` | `sessionId`, `name` (also automatic on other operations) |
147
- | `GET /v1/list` | none | `sessionId`, `sessions` (metadata + unambiguous targets) |
148
- | `POST /v1/send` | `{to,message}` | `id`, `accepted`, `delivered` |
149
- | `POST /v1/ask` | `{to,message,timeoutMs?}` | send receipt and `reply:{from,message}` |
150
- | `GET /v1/inbox` | none | `messages:[{from,message}]`, `overflow`, `lostMessages` |
151
- | `POST /v1/reply` | `{replyTo,message}` | send receipt; target derived from the received ask |
152
- | `POST /v1/ack` | `{ids:[...]}` | `acked`, `retained` |
191
+ | Route | Request | Success fields |
192
+ | ------------------- | ------------------------- | ------------------------------------------------------------------------- |
193
+ | `POST /v1/register` | `{}` | `sessionId`, `name` (also automatic on other operations) |
194
+ | `GET /v1/list` | none | `sessionId`, `sessions` (metadata + unambiguous targets) |
195
+ | `POST /v1/send` | `{to,message}` | `id`, `accepted`, `delivered` |
196
+ | `POST /v1/ask` | `{to,message,timeoutMs?}` | send receipt and `reply:{from,message}` |
197
+ | `GET /v1/inbox` | none | `messages:[{from,message}]`, `overflow`, `lostMessages` |
198
+ | `POST /v1/reply` | `{replyTo,message}` | send receipt; target derived from the received ask |
199
+ | `POST /v1/ack` | `{ids:[...]}` | `acked`, `retained` |
200
+ | `POST /v1/start` | `{name}` | `session:{sessionId,peerId,name,status,pid,sessionFile,error?}`, `reused` |
201
+ | `GET /v1/sessions` | none | `sessions` (only current helper/identity-owned launch records) |
202
+ | `POST /v1/stop` | `{sessionId}` | `session` with observed `exited` status; journal retained |
153
203
 
154
204
  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.
155
205
 
@@ -180,10 +230,10 @@ Confirm the reload/revocation audit record, that Grok disappears from the local
180
230
 
181
231
  ## Verification / smoke transcript
182
232
 
183
- The process-boundary integration test uses a private real broker, real TLS certificates, actual HTTPS requests and the remote CLI; it requires no model/provider credentials:
233
+ The process-boundary tests use a private real broker, real TLS certificates, actual HTTPS requests and the remote CLI. 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:
184
234
 
185
235
  ```bash
186
- node --test test/integration/intercom-bridge.test.ts
236
+ node --test test/integration/intercom-bridge.test.ts test/integration/intercom-bridge-launch.test.ts
187
237
  # Do not inject host startup diagnostics into native fixture stderr:
188
238
  env -u PI_TIMING -u PI_EXTENSION_PERFORMANCE npm run ci
189
239
  ```
package/docs/reference.md CHANGED
@@ -375,7 +375,7 @@ The files in `prompts/` document common workflows without registering additional
375
375
 
376
376
  ## Bundled intercom
377
377
 
378
- For a remote agent on Linux, the optional [secure remote bridge](intercom-bridge.md) provides a loopback-only mTLS HTTPS API over an SSH reverse forward. It registers a certificate-bound peer such as `grok-bot` and permits only messaging operations; the private Unix broker socket remains local. The guide includes setup, remote commands, credential rotation and revocation.
378
+ For a remote agent on Linux, the optional [secure remote bridge](intercom-bridge.md) provides a loopback-only mTLS HTTPS API over an SSH reverse forward. It registers a certificate-bound peer such as `grok-bot` and supports messaging and optional locally pinned, name-only starts of persistent Pi sessions on the destination machine; the private Unix broker socket remains local. The guide includes setup, remote start/sessions/stop commands, lifecycle limits, credential rotation and revocation.
379
379
 
380
380
  The same `pi-subagents` install registers the intercom extension and skill. `load_intercom({})` exposes the full peer tool explicitly; actionable peer inbound and recovered reply needs also expose it before use. Presence, passive breadcrumbs, routine subagent notices, and human messages answered in chat do not. Broker presence and delivery continue even with the schema hidden. Managed children get a private coordination channel back to the parent Pi session unless an explicit agent extension allowlist excludes it. See [the intercom guide](intercom.md) for direct peer messaging, keyboard UI, tool actions, configuration, and broker details.
381
381
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fitchmultz/pi-subagents",
3
- "version": "0.45.0",
3
+ "version": "0.46.0",
4
4
  "description": "Pi extension for subagent orchestration and local session coordination",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -25,7 +25,7 @@ function help() {
25
25
  console.log(`Usage: node scripts/intercom-bridge-credentials.mjs <init|issue|revoke> DIR [NAME] [--cwd PATH]
26
26
 
27
27
  init Create a new private CA, loopback server certificate and grok-bot credential.
28
- issue Issue a fresh 7-day client certificate; replace that name's old authorization.
28
+ issue Issue a fresh 7-day client certificate; preserve local launch policy.
29
29
  revoke Remove the named client from the allowlist. Does not delete saved credentials.
30
30
 
31
31
  Options: --cwd PATH Local project identity for init/issue (default: current directory)
@@ -38,6 +38,7 @@ Examples:
38
38
  node scripts/intercom-bridge-credentials.mjs revoke ~/.pi/intercom-bridge grok-bot
39
39
 
40
40
  After issue/revoke, send SIGHUP to the helper PID to activate the allowlist.
41
+ Rotation/revocation stops that identity's helper-launched Pi children; journals remain.
41
42
  Concurrent credential commands fail rather than overwrite each other's authorization.
42
43
  After a crashed command, inspect DIR/.credentials.lock/owner and remove that lock
43
44
  only after confirming its process has stopped.
@@ -247,6 +248,7 @@ try {
247
248
  fingerprint256: credential.fingerprint256,
248
249
  name,
249
250
  cwd: resolve(args.values.cwd || previous?.cwd || process.cwd()),
251
+ ...(previous?.launch === undefined ? {} : { launch: previous.launch }),
250
252
  });
251
253
  atomicConfig(configPath, config);
252
254
  console.log(
@@ -4,7 +4,7 @@ import { lstatSync, readFileSync } from "node:fs";
4
4
  import { parseArgs } from "node:util";
5
5
 
6
6
  function help() {
7
- console.log(`Usage: node intercom-remote.mjs <register|list|inbox|send|ask|reply|ack> [options] [IDs...]
7
+ console.log(`Usage: node intercom-remote.mjs <register|list|inbox|send|ask|reply|ack|start|sessions|stop> [options] [IDs...]
8
8
 
9
9
  Environment (required):
10
10
  INTERCOM_BRIDGE_CA Path to trusted bridge CA certificate
@@ -17,6 +17,8 @@ Options:
17
17
  --message TEXT Message for send/ask/reply
18
18
  --reply-to ID Inbound ask ID from inbox, for reply
19
19
  --timeout-ms MS Ask timeout (100..120000, default: 120000)
20
+ --name NAME Start/reuse a named idle Pi session under local policy
21
+ --session-id ID Stop only a Pi session launched by this identity/helper
20
22
  -h, --help Show help
21
23
 
22
24
  Examples:
@@ -25,12 +27,21 @@ Examples:
25
27
  node intercom-remote.mjs inbox
26
28
  node intercom-remote.mjs reply --reply-to QUESTION_ID --message 'Proceed.'
27
29
  node intercom-remote.mjs ack MESSAGE_ID
30
+ node intercom-remote.mjs start --name planner
31
+ node intercom-remote.mjs sessions
32
+ node intercom-remote.mjs stop --session-id NATIVE_SESSION_ID
28
33
 
29
34
  Outputs JSON. Exit: 0 success, 1 API/TLS/transport failure, 2 invalid arguments.
30
35
  No automatic retries: a lost response does not prove a message was unsent.`);
31
36
  }
32
37
 
33
38
  function allowedOptions(action) {
39
+ if (action === "start") {
40
+ return ["name"];
41
+ }
42
+ if (action === "stop") {
43
+ return ["session-id"];
44
+ }
34
45
  if (action === "send") {
35
46
  return ["to", "message"];
36
47
  }
@@ -54,7 +65,17 @@ function requireMessage(recipient, message) {
54
65
  }
55
66
  }
56
67
 
68
+ function launchBody(action, options) {
69
+ const value = action === "start" ? options.name : options["session-id"];
70
+ if (typeof value !== "string" || value.trim() === "") {
71
+ throw new Error(action === "start" ? "--name is required." : "--session-id is required.");
72
+ }
73
+ return action === "start" ? { name: value } : { sessionId: value };
74
+ }
57
75
  function requestBody(action, options, ids, timeout) {
76
+ if (action === "start" || action === "stop") {
77
+ return launchBody(action, options);
78
+ }
58
79
  if (["send", "ask", "reply"].includes(action)) {
59
80
  const recipient = action === "reply" ? options["reply-to"] : options.to;
60
81
  requireMessage(recipient, options.message);
@@ -108,6 +129,8 @@ try {
108
129
  message: { type: "string" },
109
130
  "reply-to": { type: "string" },
110
131
  "timeout-ms": { type: "string" },
132
+ name: { type: "string" },
133
+ "session-id": { type: "string" },
111
134
  },
112
135
  });
113
136
  if (parsed.values.help) {
@@ -116,7 +139,20 @@ try {
116
139
  }
117
140
  const options = parsed.values;
118
141
  [action] = parsed.positionals;
119
- if (!["register", "list", "inbox", "send", "ask", "reply", "ack"].includes(action)) {
142
+ if (
143
+ ![
144
+ "register",
145
+ "list",
146
+ "inbox",
147
+ "send",
148
+ "ask",
149
+ "reply",
150
+ "ack",
151
+ "start",
152
+ "sessions",
153
+ "stop",
154
+ ].includes(action)
155
+ ) {
120
156
  throw new Error("Choose an action; use --help.");
121
157
  }
122
158
  const allowed = allowedOptions(action);
@@ -159,7 +195,7 @@ try {
159
195
  const files = tlsFiles();
160
196
  origin.pathname = `/v1/${action}`;
161
197
  const payload = JSON.stringify(body);
162
- const get = ["list", "inbox"].includes(action);
198
+ const get = ["list", "inbox", "sessions"].includes(action);
163
199
  const result = await new Promise((resolve, reject) => {
164
200
  const req = https.request(
165
201
  origin,
@@ -203,7 +239,7 @@ try {
203
239
  () => {
204
240
  req.destroy(new Error("Bridge request deadline exceeded; delivery may be unknown."));
205
241
  },
206
- (action === "ask" ? timeout : 0) + 15000,
242
+ action === "start" ? 90000 : (action === "ask" ? timeout : 0) + 15000,
207
243
  );
208
244
  req.once("close", () => clearTimeout(timer));
209
245
  req.once("error", reject);
@@ -1,8 +1,17 @@
1
- import { constants, closeSync, fstatSync, openSync, readFileSync, statSync } from "node:fs";
1
+ import {
2
+ constants,
3
+ closeSync,
4
+ fstatSync,
5
+ openSync,
6
+ readFileSync,
7
+ realpathSync,
8
+ statSync,
9
+ } from "node:fs";
2
10
  import { isAbsolute } from "node:path";
3
11
  import { createSecureContext } from "node:tls";
4
12
  import { isUnknownArray } from "../shared/unknown.ts";
5
13
  import { BODY_LIMIT, object, label, fingerprint } from "./bridge-protocol.ts";
14
+ import { launchPolicy, type LaunchPolicy } from "./bridge-launch-policy.ts";
6
15
 
7
16
  const MAX_CLIENTS = 64;
8
17
 
@@ -10,6 +19,7 @@ export interface Identity {
10
19
  readonly fingerprint256: string;
11
20
  readonly name: string;
12
21
  readonly cwd: string;
22
+ readonly launch?: LaunchPolicy;
13
23
  }
14
24
  export interface Config {
15
25
  readonly port: number;
@@ -47,17 +57,26 @@ function identities(value: unknown): Map<string, Identity> {
47
57
  const clients = new Map<string, Identity>();
48
58
  const names = new Set<string>();
49
59
  for (const raw of value) {
50
- const entry = object(raw, ["fingerprint256", "name", "cwd"]);
60
+ const entry = object(raw, ["fingerprint256", "name", "cwd", "launch"]);
51
61
  const fingerprint256 = fingerprint(entry.fingerprint256);
52
62
  const name = label(entry.name, "name", 128).trim();
53
63
  const cwd = label(entry.cwd, "cwd", 4096);
54
- if (!isAbsolute(cwd) || !statSync(cwd).isDirectory()) {
55
- throw new Error("Client cwd must be an existing absolute directory.");
64
+ if (!isAbsolute(cwd)) {
65
+ throw new Error("Client cwd must be absolute.");
66
+ }
67
+ const launch = launchPolicy(entry.launch);
68
+ if (launch && !statSync(cwd).isDirectory()) {
69
+ throw new Error("Launch cwd must be an existing directory.");
56
70
  }
57
71
  if (clients.has(fingerprint256) || names.has(name.toLowerCase())) {
58
72
  throw new Error("Client fingerprints and names must be unique.");
59
73
  }
60
- clients.set(fingerprint256, { fingerprint256, name, cwd });
74
+ clients.set(fingerprint256, {
75
+ fingerprint256,
76
+ name,
77
+ cwd: launch ? realpathSync(cwd) : cwd,
78
+ launch,
79
+ });
61
80
  names.add(name.toLowerCase());
62
81
  }
63
82
  return clients;
@@ -0,0 +1,109 @@
1
+ import { lstatSync, readFileSync, realpathSync, statSync } from "node:fs";
2
+ import { isAbsolute, join, relative } from "node:path";
3
+ import { isRecord, isUnknownArray } from "../shared/unknown.ts";
4
+ import { label, object, text } from "./bridge-protocol.ts";
5
+
6
+ export interface LaunchPolicy {
7
+ readonly packageRoot: string;
8
+ readonly cli: string;
9
+ readonly sessionDir: string;
10
+ readonly provider: string;
11
+ readonly model: string;
12
+ readonly context: string;
13
+ readonly extensions: readonly string[];
14
+ readonly discoverResources: boolean;
15
+ readonly trustProject: boolean;
16
+ readonly offline: boolean;
17
+ readonly maxSessions: number;
18
+ readonly startupTimeoutMs: number;
19
+ }
20
+
21
+ function absolute(value: unknown, field: string): string {
22
+ const path = label(value, field, 4096);
23
+ if (!isAbsolute(path)) {
24
+ throw new Error(`${field} must be absolute.`);
25
+ }
26
+ return realpathSync(path);
27
+ }
28
+ function bounded(value: unknown, fallback: number, max: number): number {
29
+ const number = value ?? fallback;
30
+ if (typeof number !== "number" || !Number.isInteger(number) || number < 1 || number > max) {
31
+ throw new Error("Invalid launch limit.");
32
+ }
33
+ return number;
34
+ }
35
+ function boolean(value: unknown, fallback: boolean): boolean {
36
+ if (value === undefined) {
37
+ return fallback;
38
+ }
39
+ if (typeof value !== "boolean") {
40
+ throw new Error("Launch switches must be boolean.");
41
+ }
42
+ return value;
43
+ }
44
+ function executable(packageRoot: string): string {
45
+ const manifest: unknown = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8"));
46
+ if (
47
+ !isRecord(manifest) ||
48
+ manifest.name !== "@earendil-works/pi-coding-agent" ||
49
+ !isRecord(manifest.bin)
50
+ ) {
51
+ throw new Error("Launch package must be an installed Pi package.");
52
+ }
53
+ const bin = label(manifest.bin.pi, "Pi manifest bin", 4096);
54
+ const cli = realpathSync(join(packageRoot, bin));
55
+ const within = relative(packageRoot, cli);
56
+ if (within.startsWith("..") || isAbsolute(within) || !statSync(cli).isFile()) {
57
+ throw new Error("Pi bin must be a file inside the pinned package.");
58
+ }
59
+ return cli;
60
+ }
61
+ function extensions(value: unknown): readonly string[] {
62
+ if (!isUnknownArray(value) || value.length < 1 || value.length > 32) {
63
+ throw new Error("Launch extensions must contain 1..32 local paths.");
64
+ }
65
+ return value.map((entry) => absolute(entry, "extension path"));
66
+ }
67
+
68
+ export function launchPolicy(value: unknown): LaunchPolicy | undefined {
69
+ if (value === undefined) {
70
+ return;
71
+ }
72
+ const raw = object(value, [
73
+ "packageRoot",
74
+ "sessionDir",
75
+ "provider",
76
+ "model",
77
+ "context",
78
+ "extensions",
79
+ "discoverResources",
80
+ "trustProject",
81
+ "offline",
82
+ "maxSessions",
83
+ "startupTimeoutMs",
84
+ ]);
85
+ const packageRoot = absolute(raw.packageRoot, "Pi package root");
86
+ const sessionDir = absolute(raw.sessionDir, "session directory");
87
+ const storage = lstatSync(sessionDir);
88
+ if (
89
+ !storage.isDirectory() ||
90
+ storage.uid !== process.getuid?.() ||
91
+ (storage.mode & 0o777) !== 0o700
92
+ ) {
93
+ throw new Error("Launch storage must be an owned directory with mode 0700.");
94
+ }
95
+ return {
96
+ packageRoot,
97
+ cli: executable(packageRoot),
98
+ sessionDir,
99
+ provider: label(raw.provider, "provider", 128),
100
+ model: label(raw.model, "model", 256),
101
+ context: text(raw.context),
102
+ extensions: extensions(raw.extensions),
103
+ discoverResources: boolean(raw.discoverResources, false),
104
+ trustProject: boolean(raw.trustProject, false),
105
+ offline: boolean(raw.offline, false),
106
+ maxSessions: bounded(raw.maxSessions, 4, 16),
107
+ startupTimeoutMs: bounded(raw.startupTimeoutMs, 15000, 60000),
108
+ };
109
+ }