@staix/agent-hub 0.20.0 → 0.21.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 (175) hide show
  1. package/CHANGELOG.md +59 -3
  2. package/README.md +2 -2
  3. package/docs/agent-notes/README.md +11 -0
  4. package/docs/agent-notes/adapters.md +7 -3
  5. package/docs/agent-notes/benchmarks.md +27 -0
  6. package/docs/agent-notes/budget.md +2 -1
  7. package/docs/agent-notes/bus.md +2 -2
  8. package/docs/agent-notes/daemon.md +15 -6
  9. package/docs/agent-notes/local-worker.md +4 -0
  10. package/docs/agent-notes/models.md +2 -0
  11. package/docs/agent-notes/tasks.md +17 -9
  12. package/docs/agent-notes/tests.md +9 -2
  13. package/docs/bench.md +1 -1
  14. package/docs/data-policy.md +2 -0
  15. package/docs/dogfood.md +37 -4
  16. package/docs/events.md +2 -1
  17. package/docs/memory.md +9 -3
  18. package/docs/operations.md +565 -62
  19. package/docs/profiles.md +13 -1
  20. package/docs/quickstart.md +14 -9
  21. package/docs/research.md +7 -4
  22. package/docs/sdlc.md +54 -10
  23. package/docs/security.md +396 -34
  24. package/docs/smoke.md +127 -0
  25. package/docs/specs/2026-09-19-agent-hub-design.md +1070 -22
  26. package/docs/verification/2026-10-27-0.19.0.md +5 -5
  27. package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-bash-permission.json +635 -0
  28. package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-permission-transport.json +403 -0
  29. package/docs/verification/2026-10-28-claude-implementer-permission-triage.md +123 -0
  30. package/docs/verification/2026-10-28-jcode-0.99.1-usage.md +41 -0
  31. package/docs/verification/2026-10-28-realpath-name-audit.md +222 -0
  32. package/docs/verification/2026-10-28-routing-f0bb8ad0.md +1000 -0
  33. package/docs/verification/2026-10-29-0.21.0.md +61 -0
  34. package/docs/verification/2026-10-29-model-effort-capabilities.md +123 -0
  35. package/docs/verification/2026-10-29-modelrouting-b152ddb9.md +953 -0
  36. package/docs/verification/claude-implementer-permission-triage.ts +160 -0
  37. package/docs/verification/claude-permission-transport-native.py +71 -14
  38. package/docs/verification/profiles/YYYY-MM-DD-research.md +1 -1
  39. package/docs/verified.json +15 -15
  40. package/package.json +2 -2
  41. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  42. package/plugins/agent-hub/server.js +1016 -280
  43. package/src/adapters/acp.ts +79 -3
  44. package/src/adapters/channel-peer.ts +4 -0
  45. package/src/adapters/claude-channel.ts +13 -6
  46. package/src/adapters/claude-implementer.ts +60 -7
  47. package/src/adapters/codex-appserver.ts +77 -4
  48. package/src/adapters/conformance/fakes/jcode-agent.ts +6 -2
  49. package/src/adapters/conformance/kit.ts +3 -9
  50. package/src/adapters/definitions/builtin-metadata.ts +3 -114
  51. package/src/adapters/definitions/native-config-defaults.ts +1 -1
  52. package/src/adapters/definitions/native-config-policy.ts +3 -2
  53. package/src/adapters/definitions/native-context.ts +12 -0
  54. package/src/adapters/external-metadata.ts +48 -0
  55. package/src/adapters/external-modules.ts +63 -3
  56. package/src/adapters/jcode.ts +6 -2
  57. package/src/adapters/local-worker.ts +40 -25
  58. package/src/adapters/native-proof-ports.ts +10 -0
  59. package/src/adapters/registry-types.ts +4 -0
  60. package/src/broker/protocol.ts +203 -0
  61. package/src/broker/server.ts +314 -0
  62. package/src/broker/store.ts +548 -0
  63. package/src/cli/bench.ts +3 -2
  64. package/src/cli/broker.ts +58 -0
  65. package/src/cli/conductor-recovery.ts +142 -0
  66. package/src/cli/configured-acp-entry.ts +26 -6
  67. package/src/cli/console-state.ts +59 -17
  68. package/src/cli/console.ts +17 -2
  69. package/src/cli/facts-hook.ts +3 -1
  70. package/src/cli/help.ts +28 -16
  71. package/src/cli/identity.ts +23 -10
  72. package/src/cli/init.ts +34 -7
  73. package/src/cli/intake-output.ts +33 -0
  74. package/src/cli/main.ts +314 -71
  75. package/src/cli/output.ts +41 -1
  76. package/src/cli/recovery-package.ts +18 -1
  77. package/src/cli/role-holders.ts +35 -0
  78. package/src/cli/room.ts +304 -0
  79. package/src/cli/say-input.ts +16 -0
  80. package/src/cli/schedule-output.ts +49 -0
  81. package/src/cli/sdlc-output.ts +44 -6
  82. package/src/cli/smoke-pty-bridge.ts +33 -0
  83. package/src/cli/smoke-pty.ts +145 -0
  84. package/src/cli/smoke-run.ts +139 -0
  85. package/src/cli/smoke-target.ts +130 -0
  86. package/src/cli/source-output.ts +29 -0
  87. package/src/cli/task-output.ts +3 -2
  88. package/src/cli/upgrade-runtime.ts +98 -16
  89. package/src/cli/upgrade.ts +66 -7
  90. package/src/hub/ask.ts +76 -12
  91. package/src/hub/board.ts +132 -22
  92. package/src/hub/bus.ts +399 -56
  93. package/src/hub/child-process.ts +2 -0
  94. package/src/hub/ci-watch.ts +42 -20
  95. package/src/hub/claude-failure-stage.ts +85 -0
  96. package/src/hub/claude-shell-wrapper.ts +11 -5
  97. package/src/hub/conductor.ts +17 -4
  98. package/src/hub/config-apply.ts +32 -5
  99. package/src/hub/config-reload.ts +7 -5
  100. package/src/hub/config-trust.ts +7 -1
  101. package/src/hub/context-window.ts +33 -3
  102. package/src/hub/control-client.ts +14 -2
  103. package/src/hub/corpus-config.ts +34 -0
  104. package/src/hub/corpus-evidence.ts +184 -0
  105. package/src/hub/corpus.ts +373 -0
  106. package/src/hub/cost-governance.ts +26 -0
  107. package/src/hub/daemon.ts +1150 -171
  108. package/src/hub/deliverable-files.ts +8 -2
  109. package/src/hub/delivery-journal.ts +123 -17
  110. package/src/hub/dogfood-profile.ts +32 -5
  111. package/src/hub/dogfood-read.ts +6 -2
  112. package/src/hub/envelope.ts +11 -0
  113. package/src/hub/eval-gate.ts +131 -0
  114. package/src/hub/events.ts +1 -1
  115. package/src/hub/external-instance.ts +69 -0
  116. package/src/hub/facts.ts +15 -5
  117. package/src/hub/folder-trigger.ts +223 -0
  118. package/src/hub/hub-tools.ts +2 -1
  119. package/src/hub/inference.ts +20 -5
  120. package/src/hub/instance-config.ts +69 -3
  121. package/src/hub/instance-execution.ts +6 -2
  122. package/src/hub/instances.ts +32 -4
  123. package/src/hub/intake.ts +125 -0
  124. package/src/hub/learning.ts +85 -0
  125. package/src/hub/lifecycle.ts +7 -3
  126. package/src/hub/limits.ts +12 -5
  127. package/src/hub/native-permission-request.ts +77 -0
  128. package/src/hub/operational-authority.ts +63 -5
  129. package/src/hub/operational-settings.ts +76 -0
  130. package/src/hub/operational-view.ts +1 -1
  131. package/src/hub/packs.ts +132 -0
  132. package/src/hub/path-policy.ts +7 -0
  133. package/src/hub/peer-doing.ts +130 -0
  134. package/src/hub/peers.ts +12 -0
  135. package/src/hub/queue-backpressure.ts +58 -0
  136. package/src/hub/read-tools.ts +2 -0
  137. package/src/hub/recovery-authority.ts +151 -0
  138. package/src/hub/recovery-policy.ts +220 -0
  139. package/src/hub/research.ts +24 -14
  140. package/src/hub/restart.ts +7 -0
  141. package/src/hub/routing.ts +54 -11
  142. package/src/hub/schedules.ts +266 -0
  143. package/src/hub/sdlc-merge.ts +38 -14
  144. package/src/hub/sdlc-metrics.ts +32 -10
  145. package/src/hub/sdlc-queue.ts +7 -1
  146. package/src/hub/sdlc-release.ts +1 -1
  147. package/src/hub/sdlc.ts +37 -7
  148. package/src/hub/security-baseline.ts +38 -0
  149. package/src/hub/settings.ts +6 -1
  150. package/src/hub/shipped-packs.ts +8 -0
  151. package/src/hub/snapshots.ts +72 -6
  152. package/src/hub/source-read.ts +160 -0
  153. package/src/hub/sources.ts +311 -0
  154. package/src/hub/tasks.ts +910 -110
  155. package/src/hub/workflow.ts +1 -1
  156. package/src/local/doc-read.ts +69 -0
  157. package/src/local/proxy.ts +4 -13
  158. package/src/local/tools.ts +37 -3
  159. package/src/local/web-fetch-config.ts +43 -0
  160. package/src/local/web-fetch.ts +183 -0
  161. package/src/memory/brief.ts +85 -13
  162. package/src/memory/capture.ts +1 -1
  163. package/src/rooms/application.ts +114 -0
  164. package/src/rooms/boundary.ts +103 -0
  165. package/src/rooms/commands.ts +294 -0
  166. package/src/rooms/crypto.ts +371 -0
  167. package/src/rooms/link.ts +584 -0
  168. package/src/rooms/policy.ts +82 -0
  169. package/src/rooms/remote-peer.ts +80 -0
  170. package/src/rooms/service.ts +426 -0
  171. package/src/rooms/status.ts +146 -0
  172. package/src/rooms/store.ts +1210 -0
  173. package/src/rooms/wire.ts +465 -0
  174. package/src/ui/index.html +91 -4
  175. package/templates/packs/example.pack.json +13 -0
@@ -1,6 +1,7 @@
1
1
  import { activityText, updateActivity, type Activity, type ActivityKind } from "../hub/activity.ts";
2
2
  import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
3
3
  import { createInterface } from "node:readline";
4
+ import { randomUUID } from "node:crypto";
4
5
  import { renderDigest, replyAudience, replyParent, sanitize, type Envelope, type PeerId, type PeerState } from "../hub/envelope.ts";
5
6
  import { acpQuotaRefusal, type ACPQuotaRefusal } from "./acp-quota.ts";
6
7
  import type { PermissionMode } from "../hub/permission-mode.ts";
@@ -49,6 +50,23 @@ export interface ACPUsageDiagnostic {
49
50
  total?: number;
50
51
  }
51
52
 
53
+ /**
54
+ * One prompt's own token counters from its result's `usage` (Jcode 0.99.1: `inputTokens`, `outputTokens`, optional `cachedReadTokens` and `cachedWriteTokens`; a result with several provider
55
+ * calls carries their sum). Per turn, not a session total, and the provider's `totalTokens` is deliberately not read: Jcode adds the cached read on top of an input that already contains it for
56
+ * OpenAI-style providers, so that total double counts. Input and output must both be non-negative safe integers, else the whole record is unknown; an optional bucket that is present but
57
+ * malformed is dropped alone (unknown, never zero). Counters only: nothing else of the payload escapes.
58
+ */
59
+ export interface ACPTurnUsage { inputTokens: number; outputTokens: number; cacheReadTokens?: number; cacheWriteTokens?: number }
60
+ export function acpTurnUsage(result: unknown): ACPTurnUsage | undefined {
61
+ if (!result || typeof result !== "object" || Array.isArray(result)) return undefined;
62
+ const usage = (result as Record<string, unknown>).usage;
63
+ if (!usage || typeof usage !== "object" || Array.isArray(usage)) return undefined;
64
+ const u = usage as Record<string, unknown>;
65
+ const count = (v: unknown): v is number => typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
66
+ if (!count(u.inputTokens) || !count(u.outputTokens)) return undefined;
67
+ return { inputTokens: u.inputTokens, outputTokens: u.outputTokens, ...(count(u.cachedReadTokens) ? { cacheReadTokens: u.cachedReadTokens } : {}), ...(count(u.cachedWriteTokens) ? { cacheWriteTokens: u.cachedWriteTokens } : {}) };
68
+ }
69
+
52
70
  /** Counter-only projection: no source payload, text, tools or metadata escapes. */
53
71
  export function normalizeACPUsage(value: unknown, source: ACPUsageDiagnostic["source"] = "usage_update"): ACPUsageDiagnostic {
54
72
  const unknown: ACPUsageDiagnostic = { source, availability: "unsupported", shape: "none" };
@@ -66,7 +84,8 @@ export function normalizeACPUsage(value: unknown, source: ACPUsageDiagnostic["so
66
84
  const a = f[input!], b = f[output!];
67
85
  return valid(a) && valid(b) && Number.isFinite(a + b) ? { source, availability: "known", shape: "input-output", total: a + b } : { source, availability: "invalid", shape: "input-output" };
68
86
  }
69
- if (f.used !== undefined) {
87
+ // Either half of the pair names an occupancy update: one with `size` but no `used` (or the reverse) is a malformed reading, which must invalidate the previous value, not vanish as unrelated.
88
+ if (f.used !== undefined || f.size !== undefined) {
70
89
  if (!valid(f.used) || !valid(f.size) || !Number.isSafeInteger(f.used) || !Number.isSafeInteger(f.size) || f.size <= 0) return { source, availability: "invalid", shape: "context-used" };
71
90
  // Qwen 0.24.7 derives this from collectContextData: occupancy is not cumulative consumption.
72
91
  return { source, availability: "unsupported", shape: "context-used", contextUsed: f.used, contextCapacity: f.size };
@@ -110,6 +129,8 @@ export interface AcpOptions {
110
129
  preamble?: string;
111
130
  /** The session's running token total from `usage_update` (checked totalTokens or input/output pair; `used` context occupancy is diagnostic only). Cumulative, not a delta. */
112
131
  onTokens?: (sessionTotal: number, sessionId: string) => void;
132
+ /** One finished prompt's own counters (see `acpTurnUsage`), once per prompt that reported them; never a session total, so it never feeds `onTokens`. The sessionId is the adapter's own. */
133
+ onTurnUsage?: (usage: ACPTurnUsage, sessionId: string) => void;
113
134
  /** The prompt rejected or ended without normal completion, even when it streamed partial text.
114
135
  * Same shape as Pi's onTurnFailure; a stale cancelled turn never reports. */
115
136
  onTurnFailure?: (envs: Envelope[], reason: string) => Promise<void> | void;
@@ -138,6 +159,45 @@ const HANDSHAKE_MS = 30_000;
138
159
  const TOOL_INPUT_CAP = 64;
139
160
  const TOOL_CEILING_MS = 30 * 60_000;
140
161
 
162
+ export interface ManagedAcpLaunch {
163
+ readonly generation: string;
164
+ readonly cwd: string;
165
+ readonly sessionId: string;
166
+ }
167
+ interface ManagedAcpLaunchRecord {
168
+ readonly id: PeerId;
169
+ readonly options: AcpOptions;
170
+ readonly proc: ChildProcessWithoutNullStreams;
171
+ readonly proof: AcpOwnedGroup;
172
+ readonly receipt: Readonly<ManagedAcpLaunch>;
173
+ }
174
+ // Host evidence is deliberately outside runtime properties: copied metadata, private-field names
175
+ // and an overridden start/recovery getter cannot manufacture a verified managed spawn.
176
+ const managedLaunches = new WeakMap<object, ManagedAcpLaunchRecord>();
177
+ function currentManagedLaunch(peer: unknown, expectedOptions: AcpOptions): ManagedAcpLaunchRecord | undefined {
178
+ if (!peer || (typeof peer !== "object" && typeof peer !== "function")) return undefined;
179
+ const record = managedLaunches.get(peer);
180
+ if (!record) return undefined;
181
+ const id = Object.getOwnPropertyDescriptor(peer, "id");
182
+ if (id?.value !== record.id || record.options !== expectedOptions || record.proc.exitCode !== null || record.proc.signalCode !== null) return undefined;
183
+ return record;
184
+ }
185
+ /** Exact host options identity is required; a factory that copies/replaces those options has no host binding. */
186
+ export function managedAcpLaunch(peer: unknown, expectedOptions: AcpOptions): Readonly<ManagedAcpLaunch> | undefined {
187
+ return currentManagedLaunch(peer, expectedOptions)?.receipt;
188
+ }
189
+ /** Recheck the actual captured owned group after an await, then require the same current launch. */
190
+ export async function verifyManagedAcpLaunch(peer: unknown, expectedOptions: AcpOptions): Promise<boolean> {
191
+ const record = currentManagedLaunch(peer, expectedOptions);
192
+ if (!record) return false;
193
+ try { await record.proof.verify(); }
194
+ catch {
195
+ if (peer && typeof peer === "object" && managedLaunches.get(peer) === record) managedLaunches.delete(peer);
196
+ return false;
197
+ }
198
+ return currentManagedLaunch(peer, expectedOptions) === record;
199
+ }
200
+
141
201
  /**
142
202
  * The canonical tool binding (#138): an announced `<tool> (<server> MCP Server)` title resolves to
143
203
  * `mcp__<server>__<tool>` only when <server> is one this session was configured with. A title that
@@ -239,7 +299,7 @@ export class AcpPeer extends BasePeer {
239
299
  private readonly permissionWaits = new Set<AbortController>();
240
300
 
241
301
  protected override setState(next: PeerState): void {
242
- if (next === "offline") { this.clearActivity(); this.cancelPermissions(); }
302
+ if (next === "offline") { managedLaunches.delete(this); this.clearActivity(); this.cancelPermissions(); }
243
303
  super.setState(next);
244
304
  }
245
305
 
@@ -266,11 +326,14 @@ export class AcpPeer extends BasePeer {
266
326
  }
267
327
 
268
328
  async start(): Promise<void> {
329
+ managedLaunches.delete(this);
269
330
  if (this.opts.requireOwnedGroup && this.proc) throw new Error("Configured ACP still has a process; stop it before starting another");
270
331
  this.stopping = false;
332
+ const launchedId = this.id;
333
+ const launchedCwd = this.opts.cwd;
271
334
  const [bin, ...args] = this.opts.cmd;
272
335
  // Its own process group, stopped as a whole (#115, as Codex's in #113): an agent CLI may be a launcher with a native child.
273
- const proc = spawn(bin!, args, { cwd: this.opts.cwd, env: peerChildEnv(this.id, { ...process.env, ...(this.opts.env ?? {}) }), stdio: ["pipe", "pipe", "pipe"], detached: true });
336
+ const proc = spawn(bin!, args, { cwd: launchedCwd, env: peerChildEnv(this.id, { ...process.env, ...(this.opts.env ?? {}) }), stdio: ["pipe", "pipe", "pipe"], detached: true });
274
337
  this.proc = proc;
275
338
  trackGroup(proc);
276
339
  proc.on("error", (e) => { if (!this.opts.requireOwnedGroup || this.proc === proc) this.down(`spawn failed: ${e.message}`); });
@@ -310,11 +373,13 @@ export class AcpPeer extends BasePeer {
310
373
  setTimeout(() => reject(new Error(`${this.id} did not complete the ACP handshake within ${HANDSHAKE_MS / 1000} s`)), HANDSHAKE_MS).unref();
311
374
  });
312
375
  const session = await Promise.race([handshake(), timeout]);
376
+ const nativeSession = session?.sessionId;
313
377
  if (this.nativePolicy !== undefined && (typeof session?.sessionId !== "string" || !session.sessionId.trim()
314
378
  || Buffer.byteLength(session.sessionId) > 4096 || /[\p{Cc}\p{Cf}]/u.test(session.sessionId))) {
315
379
  throw new Error("Configured ACP did not provide a valid native session identity");
316
380
  }
317
381
  this.sessionId = session.sessionId;
382
+ this.onDoing?.("reset");
318
383
  const modes: unknown = session.modes?.availableModes;
319
384
  this.availableModes = new Set(Array.isArray(modes) ? modes.flatMap((mode) => typeof mode?.id === "string" ? [mode.id] : []) : []);
320
385
  // A recovered session can retain a previous runtime opt-in. An advertised non-default mode
@@ -328,8 +393,13 @@ export class AcpPeer extends BasePeer {
328
393
  if (!owned || owned.proc !== proc || this.proc !== proc || this.stopping) throw new Error("Configured ACP process ownership could not be verified");
329
394
  await owned.proof.verify();
330
395
  if (this.proc !== proc || this.stopping) throw new Error("Configured ACP process ownership could not be verified");
396
+ if (typeof nativeSession !== "string" || !nativeSession.trim() || Buffer.byteLength(nativeSession) > 4096 || /[\p{Cc}\p{Cf}]/u.test(nativeSession)) throw new Error("Configured ACP did not provide a valid native session identity");
397
+ if (this.id !== launchedId || this.opts.cwd !== launchedCwd || this.sessionId !== nativeSession) throw new Error("Configured ACP peer identity changed during startup");
398
+ managedLaunches.set(this, { id: launchedId, options: this.opts, proc, proof: owned.proof,
399
+ receipt: Object.freeze({ generation: randomUUID(), cwd: launchedCwd, sessionId: nativeSession }) });
331
400
  }
332
401
  } catch (e) {
402
+ managedLaunches.delete(this);
333
403
  this.setState("offline");
334
404
  if (this.proc === proc) {
335
405
  if (this.opts.requireOwnedGroup) {
@@ -390,6 +460,7 @@ export class AcpPeer extends BasePeer {
390
460
  }
391
461
 
392
462
  async stop(reason?: string): Promise<void> {
463
+ managedLaunches.delete(this);
393
464
  if (this.activityOpen) this.endActivity("stopped", true);
394
465
  this.stopping = true;
395
466
  this.cancelPermissions();
@@ -453,6 +524,8 @@ export class AcpPeer extends BasePeer {
453
524
  if (this.activeLegacy?.turn === turn) this.activeLegacy = undefined;
454
525
  this.endActivity(typeof result?.stopReason === "string" ? result.stopReason : "unknown", result?.stopReason === "cancelled");
455
526
  this.observeUsage(result, "prompt_result");
527
+ const turnUsage = acpTurnUsage(result);
528
+ if (turnUsage) try { this.opts.onTurnUsage?.(turnUsage, this.sessionId); } catch { /* a counter sink never costs a turn */ }
456
529
  this.primed = true;
457
530
  const body = this.chunks.join("").trim();
458
531
  // What was steered into the turn is answered by it too: its senders hear back, and the hop cap still holds.
@@ -597,6 +670,7 @@ export class AcpPeer extends BasePeer {
597
670
  }
598
671
 
599
672
  private down(reason: string): void {
673
+ managedLaunches.delete(this);
600
674
  this.clearCancelWait();
601
675
  if (this.nativePolicy !== undefined || this.opts.requireOwnedGroup) reason = "Configured ACP transport stopped";
602
676
  this.opts.log?.(`[${this.id}] ${reason}`);
@@ -657,6 +731,8 @@ export class AcpPeer extends BasePeer {
657
731
  for (const map of [this.toolInputs, this.toolText, this.toolTitles]) while (map.size > TOOL_INPUT_CAP) map.delete(map.keys().next().value as string);
658
732
  }
659
733
  else if (u?.sessionUpdate === "usage_update") this.observeUsage(u, "usage_update");
734
+ // #878: the plan's in-progress entry, for the hub's DOING column only.
735
+ else if (u?.sessionUpdate === "plan") this.onDoing?.("todo", Array.isArray(u.entries) ? u.entries.find((e: any) => e?.status === "in_progress")?.content : undefined);
660
736
  } else if (msg.method === "session/request_permission") {
661
737
  if (this.cancelWait || this.state === "offline") { this.send({ jsonrpc: "2.0", id: msg.id, result: { outcome: { outcome: "cancelled" } } }); return; }
662
738
  if (this.state === "busy" && (!msg.params?.sessionId || msg.params.sessionId === this.sessionId)) this.promptActivity = true;
@@ -48,6 +48,10 @@ export class WsPeer extends BasePeer {
48
48
  this.claimed = sock;
49
49
  this.claimedPullOnly = pullOnly;
50
50
  }
51
+ /** Host transport observation, distinct from the peer's activity state. */
52
+ get attached(): boolean { return !!this.sock && this.sock.readyState === WebSocket.OPEN && !this.claiming; }
53
+ get managedLaunchId(): string | undefined { return this.attached ? this.sock?.data.launchId : undefined; }
54
+
51
55
  /** A session that shows pushes holds the peer or is arriving: a session without them must not take it (issue #205). */
52
56
  get channelHeld(): boolean {
53
57
  return (!!this.sock && !this.pullOnly) || (this.claiming && !this.claimedPullOnly);
@@ -17,6 +17,7 @@ import { VERSION } from "../version.ts";
17
17
  import { IMPLEMENTER_HUB_TOOL_NAMES, IMPLEMENTER_PERMISSION_TOOL, ACTIONABLE_MESSAGE_INSTRUCTION, projectConductorTools, projectToolList, CONDUCTOR_TOOL_NAMES, DEFAULT_ROLES, roleContract, TASK_TOOL_NAMES, TASK_TOOLS, profileTaskTools } from "../hub/hub-tools.ts";
18
18
  import { frame, replyParent, sanitize, HUB_MESSAGE_INSTRUCTION, type Envelope } from "../hub/envelope.ts";
19
19
  import { renderHeldHint } from "../hub/delivery-hints.ts";
20
+ import { acceptsNativePermissionRequest, buildNativePermissionProbe, NATIVE_PERMISSION_PROBE_MAX_PER_LAUNCH } from "../hub/native-permission-request.ts";
20
21
 
21
22
  // A native session is pinned at launch. Unlike a new CLI invocation after `cd`,
22
23
  // its MCP server must not silently move to another hub (including while offline).
@@ -26,6 +27,7 @@ const projectRoot = process.env.AGENTHUB_PROJECT_DIR ?? process.cwd();
26
27
  /** tools mode: the same server, run by Kimi (ACP mcpServers), Codex (mcp_servers override) or Jcode. Their messages arrive through their own adapters, so no channel here. Not the "tools-only" Claude below. */
27
28
  const toolsMode = process.env.AGENTHUB_MODE === "tools";
28
29
  const permissionTool = toolsMode && process.env.AGENTHUB_PERMISSION_TOOL === "1";
30
+ let invalidPermissionAttempts = 0;
29
31
  /**
30
32
  * #368: a tools-mode server acts as exactly the peer whose owned launch started it, and the project's Jcode MCP entry names none, so the
31
33
  * id comes from the owned process env alone. With none (a person's own `jcode` run in this project outside the hub) it connects as nobody,
@@ -212,7 +214,7 @@ async function withPendingHint<T>(result: T, recommendInbox = true): Promise<T>
212
214
  async function push(envs: Envelope[], deliveryId?: string, generation?: string): Promise<void> {
213
215
  const parent = replyParent(envs); // reply_to on this id keeps the hop count honest
214
216
  const single = envs.length === 1;
215
- const content = single ? parent.body : envs.map((e) => `--- from ${e.from} (id ${e.id}, kind ${e.kind}) ---\n${sanitize(e.body)}`).join("\n\n");
217
+ const content = single ? sanitize(parent.body) : envs.map((e) => `--- from ${e.from} (id ${e.id}, kind ${e.kind}) ---\n${sanitize(e.body)}`).join("\n\n");
216
218
  const meta = {
217
219
  source: single ? parent.from : "hub-digest",
218
220
  ...(single ? {} : { sources: [...new Set(envs.map((e) => e.from))].join(",") }),
@@ -396,16 +398,21 @@ async function callTool(req: { params: { name: string; arguments?: Record<string
396
398
  if (name === "hub_permission_request") {
397
399
  const deny = (message: string) => text(JSON.stringify({ behavior: "deny", message }));
398
400
  if (!permissionTool || !hub) return deny("hub permission unavailable");
399
- if (typeof args?.tool_name !== "string" || !/^[A-Za-z][A-Za-z0-9_.:/-]{0,127}$/.test(args.tool_name)
400
- || !args.input || typeof args.input !== "object" || Array.isArray(args.input)
401
- || typeof args.tool_use_id !== "string" || !args.tool_use_id.length || args.tool_use_id.length > 256) return deny("invalid native permission request");
401
+ if (!acceptsNativePermissionRequest(args)) {
402
+ // Diagnostic validation only: its response can never authorize this invalid native request.
403
+ if (++invalidPermissionAttempts <= NATIVE_PERMISSION_PROBE_MAX_PER_LAUNCH) {
404
+ const probe = buildNativePermissionProbe(args);
405
+ if (probe) try { await hub.request(probe, 1000); } catch { /* Old or unavailable hubs still deny locally. */ }
406
+ }
407
+ return deny("invalid native permission request");
408
+ }
402
409
  try {
403
410
  // Only this fixed client capability permits host wrapper grants; older clients keep person approval.
404
411
  const res = await hub.request({ t: "permission_request", tool: args?.tool_name, input: args?.input, wrapperProtocol: 1 }, 3_605_000);
405
412
  if (res.ok !== true || res.behavior !== "allow") return deny(typeof res.message === "string" ? res.message : "permission denied");
406
413
  if (res.wrapperProtocol !== undefined && res.wrapperProtocol !== 1) return deny("unsupported host execution response");
407
414
  const updated = res.wrapperProtocol === 1 ? res.updatedInput : undefined;
408
- if (res.wrapperProtocol === 1 && (args.tool_name !== "Bash" || !updated || typeof updated !== "object" || Array.isArray(updated) || typeof updated.command !== "string")) return deny("invalid host execution response");
415
+ if (res.wrapperProtocol === 1 && (args?.tool_name !== "Bash" || !updated || typeof updated !== "object" || Array.isArray(updated) || typeof updated.command !== "string")) return deny("invalid host execution response");
409
416
  return text(JSON.stringify({ behavior: "allow", updatedInput: updated ?? args?.input }));
410
417
  } catch { return deny("hub permission unavailable"); }
411
418
  }
@@ -458,7 +465,7 @@ async function callTool(req: { params: { name: string; arguments?: Record<string
458
465
  }
459
466
 
460
467
  // Inbox results still show fresh counts, without telling a held or refused reader to retry in a loop.
461
- server.setRequestHandler(CallToolRequestSchema, async (req) => withPendingHint(await callTool(req), req.params.name !== "hub_inbox"));
468
+ server.setRequestHandler(CallToolRequestSchema, async (req) => withPendingHint(await callTool(req), req.params.name !== "hub_inbox" && req.params.name !== "hub_source_read"));
462
469
 
463
470
  await server.connect(new StdioServerTransport());
464
471
  // The host (Claude Code, Codex, Kimi) talks over stdin; once it is gone nothing can use this server, and a leftover would keep reconnecting.
@@ -11,9 +11,21 @@ import { sessionSettings } from "../cli/launch.ts";
11
11
  import { assessVersion, COMPAT } from "./compat.ts";
12
12
  import { OwnedPrintChild, PrintChildCleanupError, type OwnedPrintChildOptions } from "./claude-print-child.ts";
13
13
  import { ClaudeTaskSessions } from "./claude-task-sessions.ts";
14
+ import { formatClaudeFailure, type ClaudeFailureTrace } from "../hub/claude-failure-stage.ts";
14
15
  import type { PermissionMode } from "../hub/permission-mode.ts";
15
16
  import type { ReviewerPrintUsage } from "./reviewer-print.ts";
16
17
 
18
+ const provenVersions = new WeakMap<() => boolean, string>();
19
+ function provenVersion(binding: () => boolean): string | undefined { return provenVersions.get(binding); }
20
+ function sealFailureTrace(trace: ClaudeFailureTrace | undefined): void { try { trace?.seal(); } catch { /* diagnostics never affect owned cleanup */ } }
21
+ function discardFailureTrace(trace: ClaudeFailureTrace | undefined): void { try { trace?.discard(); } catch { /* diagnostics never affect owned cleanup */ } }
22
+ function readFailureTrace(trace: ClaudeFailureTrace | undefined): unknown {
23
+ if (!trace) return undefined;
24
+ // Every returned trace is sealed by onLeaderExit before authority is revoked.
25
+ try { return trace.take(); }
26
+ catch { discardFailureTrace(trace); return undefined; }
27
+ }
28
+
17
29
  export interface ClaudeImplementerOptions {
18
30
  cwd: string;
19
31
  stateDir: string;
@@ -38,6 +50,12 @@ export interface ClaudeImplementerOptions {
38
50
  notify?(message: string): void;
39
51
  /** Test/embedding-only proof port; project configuration cannot select it. */
40
52
  prove?(): Promise<() => boolean>;
53
+ /** Host-observed failure evidence bound to one admitted native launch. */
54
+ failureTrace?(launch: Readonly<OwnedToolsLaunch>, nativeVersion?: string): ClaudeFailureTrace;
55
+ /** Resolve current privacy at final rendering; unknown or a throw is private. */
56
+ failurePrivate?(task?: number): boolean;
57
+ /** Optional host observer of the fixed, already privacy-screened failure message. */
58
+ failureLog?(message: string): void;
41
59
  }
42
60
  export interface ImplementerTurn { sessionId: string; resume: boolean; persistent: boolean }
43
61
  export function claudeImplementerArgs(turn: ImplementerTurn, mcp: string, settings: string, prompt: string, model?: string): string[] {
@@ -65,7 +83,9 @@ export async function proveClaudeImplementer(peer: string, command: readonly str
65
83
  const observed = COMPAT["claude-implementer"].parse(version), verdict = assessVersion("claude-implementer", observed);
66
84
  if (!verdict.ok) throw new Error(verdict.reason);
67
85
  if (fingerprint() !== binding) throw new Error("Claude implementer binary changed during proof");
68
- return () => { try { return fingerprint() === binding; } catch { return false; } };
86
+ const proof = () => { try { return fingerprint() === binding; } catch { return false; } };
87
+ provenVersions.set(proof, verdict.version);
88
+ return proof;
69
89
  } finally { clearTimeout(timer); await child.stop(); rmSync(home, { recursive: true, force: true }); }
70
90
  }
71
91
 
@@ -84,6 +104,7 @@ export class ClaudeImplementerPeer extends BasePeer {
84
104
  private command: readonly string[] | undefined;
85
105
  private deliveryId: string | undefined;
86
106
  private inputReleased = false;
107
+ private failureTrace: ClaudeFailureTrace | undefined;
87
108
  private sessions: ClaudeTaskSessions;
88
109
  private stopping: Promise<void> | undefined;
89
110
  private stoppingTarget: "idle" | "offline" | undefined;
@@ -126,6 +147,7 @@ export class ClaudeImplementerPeer extends BasePeer {
126
147
  }
127
148
  this.stoppingTarget = next;
128
149
  ++this.generation; this.active = undefined; this.childAbort?.abort(); this.childAbort = undefined; this.abort?.abort(); this.clearWatchdog();
150
+ discardFailureTrace(this.failureTrace); this.failureTrace = undefined;
129
151
  this.settle(this.inputReleased ? "needs_review" : "failed_safe", reason);
130
152
  if (this.owned || this.running) this.setState("busy");
131
153
  const work: Promise<void> = Promise.resolve().then(async () => {
@@ -166,8 +188,10 @@ export class ClaudeImplementerPeer extends BasePeer {
166
188
  }
167
189
  private async run(groups: { task?: number; envs: Envelope[] }[], generation: number, running: Promise<void>): Promise<void> {
168
190
  const current = () => generation === this.generation && !this.abort?.signal.aborted;
191
+ let failureReason: string | undefined;
169
192
  try {
170
193
  for (let group of groups) {
194
+ failureReason = undefined;
171
195
  if (!current() || !this.binding?.()) throw new Error("Claude implementer admission changed");
172
196
  group = { ...group, envs: group.envs.filter(env => !env.private && this.options.admit(env)) };
173
197
  if (!group.envs.length) continue;
@@ -181,6 +205,7 @@ export class ClaudeImplementerPeer extends BasePeer {
181
205
  }
182
206
  }
183
207
  const launchId = randomUUID(), directory = mkdtempSync(join(this.options.stateDir, "claude-child-")), settings = join(directory, "settings.json");
208
+ let launchFailureTrace: ClaudeFailureTrace | undefined;
184
209
  this.cleanup = () => rmSync(directory, { recursive: true, force: true });
185
210
  writeFileSync(settings, sessionSettings(undefined, { script: this.options.factsHook, stateDir: this.options.stateDir, purpose: "permission" }), { mode: 0o600, flag: "wx" });
186
211
  const env = { AGENTHUB_MODE: "tools", AGENTHUB_PEER_ID: this.id, AGENTHUB_STATE_DIR: this.options.stateDir, AGENTHUB_PROJECT_DIR: this.options.projectRoot,
@@ -197,11 +222,15 @@ export class ClaudeImplementerPeer extends BasePeer {
197
222
  void this.cancel("Claude implementer admission changed before child start", "idle").catch(() => {});
198
223
  return;
199
224
  }
200
- this.inputReleased = true; // OwnedPrintChild releases stdin immediately after this synchronous admission fence.
201
225
  this.childAbort = new AbortController();
202
- this.active = { permissionHook: true, launchId, generation: `${generation}:${launchId}`, sessionId: turn.sessionId, signal: this.childAbort.signal, ...(group.task !== undefined ? { task: group.task } : {}) };
226
+ const launch: OwnedToolsLaunch = Object.freeze({ permissionHook: true, launchId, generation: `${generation}:${launchId}`, sessionId: turn.sessionId, signal: this.childAbort.signal, ...(group.task !== undefined ? { task: group.task } : {}) });
227
+ this.active = launch;
228
+ try { launchFailureTrace = this.options.failureTrace?.(Object.freeze({ ...launch }), provenVersion(this.binding!)); }
229
+ catch { launchFailureTrace = undefined; }
230
+ this.failureTrace = launchFailureTrace;
231
+ this.inputReleased = true; // OwnedPrintChild releases stdin immediately after this synchronous admission fence.
203
232
  },
204
- onLeaderExit: () => { this.active = undefined; this.childAbort?.abort(); this.childAbort = undefined; },
233
+ onLeaderExit: () => { sealFailureTrace(launchFailureTrace); this.active = undefined; this.childAbort?.abort(); this.childAbort = undefined; },
205
234
  onActivity: () => this.touch(),
206
235
  onLimit: () => { void this.cancel("Claude implementer output limit exceeded", "idle").catch(() => {}); },
207
236
  onFrame: event => {
@@ -220,12 +249,21 @@ export class ClaudeImplementerPeer extends BasePeer {
220
249
  catch (error) {
221
250
  // spawn ENOENT has no owned group. A started child with failed group cleanup must keep its handle.
222
251
  if (child.process.pid === undefined) this.owned = undefined;
252
+ if (error instanceof PrintChildCleanupError) failureReason = this.failureReason(group.task, launchFailureTrace);
253
+ else discardFailureTrace(launchFailureTrace);
254
+ if (this.failureTrace === launchFailureTrace) this.failureTrace = undefined;
223
255
  throw error;
224
256
  }
225
257
  this.active = undefined; this.owned = undefined;
226
258
  this.cleanup?.(); this.cleanup = undefined;
227
- if (!current()) return;
228
- if (code !== 0 || !result || failed) throw new Error("Claude implementer turn failed");
259
+ if (!current()) { discardFailureTrace(launchFailureTrace); if (this.failureTrace === launchFailureTrace) this.failureTrace = undefined; return; }
260
+ if (code !== 0 || !result || failed) {
261
+ failureReason = this.failureReason(group.task, launchFailureTrace) ?? "Claude implementer turn failed";
262
+ if (this.failureTrace === launchFailureTrace) this.failureTrace = undefined;
263
+ throw new Error("Claude implementer turn failed");
264
+ }
265
+ discardFailureTrace(launchFailureTrace);
266
+ if (this.failureTrace === launchFailureTrace) this.failureTrace = undefined;
229
267
  }
230
268
  if (current()) {
231
269
  if (this.running === running) this.running = undefined;
@@ -234,7 +272,11 @@ export class ClaudeImplementerPeer extends BasePeer {
234
272
  } catch (error) {
235
273
  this.active = undefined; this.childAbort?.abort(); this.childAbort = undefined;
236
274
  if (current()) {
237
- this.settle(this.inputReleased ? "needs_review" : "failed_safe", error instanceof PrintChildCleanupError ? "Claude implementer cleanup incomplete" : "Claude implementer turn failed");
275
+ const reason = error instanceof PrintChildCleanupError
276
+ ? failureReason === "Claude implementer turn failed (details withheld)" ? "Claude implementer cleanup incomplete; details withheld"
277
+ : failureReason ? `Claude implementer cleanup incomplete; ${failureReason}` : "Claude implementer cleanup incomplete"
278
+ : failureReason ?? "Claude implementer turn failed";
279
+ this.settle(this.inputReleased ? "needs_review" : "failed_safe", reason);
238
280
  if (this.owned) this.setState("busy"); else {
239
281
  this.cleanup?.(); this.cleanup = undefined;
240
282
  if (this.running === running) this.running = undefined;
@@ -243,4 +285,15 @@ export class ClaudeImplementerPeer extends BasePeer {
243
285
  }
244
286
  }
245
287
  }
288
+
289
+ private failureReason(task: number | undefined, trace: ClaudeFailureTrace | undefined): string | undefined {
290
+ if (!trace) return undefined;
291
+ const evidence = readFailureTrace(trace);
292
+ if (!evidence) return undefined;
293
+ let privateTurn = true;
294
+ try { privateTurn = this.options.failurePrivate?.(task) !== false; } catch { /* unknown privacy is private */ }
295
+ const message = formatClaudeFailure(evidence, privateTurn);
296
+ try { this.options.failureLog?.(message); } catch { /* host observation never changes settlement */ }
297
+ return message;
298
+ }
246
299
  }
@@ -9,6 +9,15 @@ import type { ContextReader } from "./registry-types.ts";
9
9
  import { ACTION_BUDGET_OPTION, type ActionGateVerdict, type PermissionRequest } from "./acp.ts";
10
10
 
11
11
  const UNIDENTIFIED_CANCELLATION_NOTICE = "Codex cancelled turn has no verified native id; reattach the native session before further hub delivery";
12
+ const PLANNER_SANDBOX_REFUSAL = "Codex planner thread requires a confirmed read-only sandbox";
13
+
14
+ /** Codex 0.164.0 SandboxPolicy readOnly variant; an unreadable or widened policy is not proof. */
15
+ function confirmsReadOnlySandbox(value: unknown): boolean {
16
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
17
+ const policy = value as Record<string, unknown>;
18
+ return policy.type === "readOnly" && Object.keys(policy).every(key => key === "type" || key === "networkAccess")
19
+ && (!Object.hasOwn(policy, "networkAccess") || typeof policy.networkAccess === "boolean");
20
+ }
12
21
 
13
22
  /** A counter Codex reported: a finite non-negative number, otherwise 0 (an absent kind is not a negative delta). */
14
23
  const counter = (value: unknown): number => { const n = Number(value); return Number.isFinite(n) && n >= 0 ? n : 0; };
@@ -16,6 +25,8 @@ export interface CodexOptions {
16
25
  /** Port the TUI attaches to: `codex --enable tui_app_server --remote ws://127.0.0.1:<proxyPort>`. */
17
26
  proxyPort: number;
18
27
  permissionMode?: PermissionMode;
28
+ /** Live host planner ceiling on initial thread start/resume; the attached person may change later turn policy. */
29
+ threadSandbox?: "read-only" | (() => "read-only" | "unavailable" | undefined);
19
30
  /** Host-only always-ask gate, shared with ACP; an absent verdict preserves native handling. */
20
31
  actionGate?: (call: { names: readonly string[]; kind?: string; input: unknown }, session: string) => ActionGateVerdict | undefined;
21
32
  onPermission?: (request: PermissionRequest, signal?: AbortSignal) => Promise<string | undefined>;
@@ -71,6 +82,10 @@ interface Link {
71
82
  backlog: string[]; // TUI frames that arrived before the upstream socket opened
72
83
  approvalIds: Set<string | number>; // intercepted native ids stay tombstoned for this socket lifetime
73
84
  tracked: Map<string | number, string>; // request id -> method, for thread/start and thread/resume
85
+ /** Correlated native initial policy, retained even before this instance gains the planner role. */
86
+ readOnlyThread?: string;
87
+ /** An ordinary client reused an outstanding id; this link cannot attest a planner thread until reattach. */
88
+ ambiguousThreadRpc?: true;
74
89
  }
75
90
 
76
91
  const TRACKED = new Set(["thread/start", "thread/resume"]);
@@ -233,7 +248,7 @@ export class CodexPeer extends BasePeer {
233
248
  /** Resolves when app-server accepts the turn. Only called while idle, i.e. a thread exists and no turn runs. */
234
249
  deliver(envs: Envelope[], deliveryId?: string): Promise<void> {
235
250
  const link = this.link;
236
- if (this.state !== "idle" || !link || link.up.readyState !== WebSocket.OPEN) {
251
+ if (this.state !== "idle" || !link || link.up.readyState !== WebSocket.OPEN || !this.threadSandboxReady(link)) {
237
252
  if (deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason: `${this.id} is not injectable` });
238
253
  return Promise.reject(new Error(`${this.id} is not injectable`));
239
254
  }
@@ -285,6 +300,10 @@ export class CodexPeer extends BasePeer {
285
300
  reject(e);
286
301
  },
287
302
  });
303
+ if (!this.threadSandboxReady(link)) {
304
+ const pending = this.pending.get(id); this.pending.delete(id);
305
+ pending?.reject(new Error(`${this.id} is not injectable`)); return;
306
+ }
288
307
  link.up.send(
289
308
  JSON.stringify({ method: "turn/start", id, params: this.applyPermissionPolicy({ threadId: this.threadId, input: [{ type: "text", text }] }, id) }),
290
309
  );
@@ -299,7 +318,7 @@ export class CodexPeer extends BasePeer {
299
318
  steer(envs: Envelope[], deliveryId?: string): Promise<void> {
300
319
  const link = this.link;
301
320
  const expectedTurnId = [...this.activeTurns].reverse().find((t) => !t.startsWith("unknown:"));
302
- if (!link || link.up.readyState !== WebSocket.OPEN || !expectedTurnId) {
321
+ if (!link || link.up.readyState !== WebSocket.OPEN || !expectedTurnId || !this.threadSandboxReady(link)) {
303
322
  if (deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason: `${this.id} has no steerable turn` });
304
323
  return Promise.reject(new Error(`${this.id} has no steerable turn`));
305
324
  }
@@ -346,6 +365,10 @@ export class CodexPeer extends BasePeer {
346
365
  reject: fail,
347
366
  });
348
367
  const input = [{ type: "text", text: this.render(envs) }];
368
+ if (!this.threadSandboxReady(link)) {
369
+ const pending = this.pending.get(id); this.pending.delete(id);
370
+ pending?.reject(new Error(`${this.id} is not injectable`)); return;
371
+ }
349
372
  link.up.send(JSON.stringify({ method: "turn/steer", id, params: { threadId: this.threadId, expectedTurnId, input } }));
350
373
  });
351
374
  }
@@ -358,12 +381,16 @@ export class CodexPeer extends BasePeer {
358
381
  steerText(text: string): Promise<"accepted" | "refused" | "unanswered"> {
359
382
  const link = this.link;
360
383
  const expectedTurnId = [...this.activeTurns].reverse().find((t) => !t.startsWith("unknown:"));
361
- if (!link || link.up.readyState !== WebSocket.OPEN || !expectedTurnId) return Promise.resolve("refused");
384
+ if (!link || link.up.readyState !== WebSocket.OPEN || !expectedTurnId || !this.threadSandboxReady(link)) return Promise.resolve("refused");
362
385
  const id = this.nextId--;
363
386
  return new Promise((resolve) => {
364
387
  const timer = setTimeout(() => this.pending.delete(id) && resolve("unanswered"), this.opts.steerTimeoutMs ?? 10_000);
365
388
  timer.unref?.();
366
389
  this.pending.set(id, { resolve: () => (clearTimeout(timer), resolve("accepted")), reject: () => (clearTimeout(timer), resolve("refused")) });
390
+ if (!this.threadSandboxReady(link)) {
391
+ const pending = this.pending.get(id); this.pending.delete(id);
392
+ pending?.reject(new Error(`${this.id} is not injectable`)); return;
393
+ }
367
394
  link.up.send(JSON.stringify({ method: "turn/steer", id, params: { threadId: this.threadId, expectedTurnId, input: [{ type: "text", text }] } }));
368
395
  });
369
396
  }
@@ -483,6 +510,7 @@ export class CodexPeer extends BasePeer {
483
510
  this.link = undefined;
484
511
  this.restoringApprovals.clear();
485
512
  this.threadId = "";
513
+ this.onDoing?.("reset");
486
514
  this.contextEpoch++;
487
515
  this.deliveryEpoch++;
488
516
  this.retiredTurns.clear(); this.unidentifiedCancellation = false;
@@ -560,6 +588,9 @@ export class CodexPeer extends BasePeer {
560
588
  private fromTui(link: Link, raw: string): void {
561
589
  const msg = parse(raw);
562
590
  if (msg?.id !== undefined && !msg.method && link.approvalIds.has(msg.id)) return;
591
+ const sandbox = this.threadSandboxPolicy();
592
+ const threadRequest = TRACKED.has(msg?.method), turnRequest = msg?.method === "turn/start" || msg?.method === "turn/steer";
593
+ if (sandbox === "unavailable" && (threadRequest || turnRequest) || sandbox === "read-only" && turnRequest && (link !== this.link || link.tracked.size > 0 || link.readOnlyThread !== this.threadId)) return this.refusePlannerThread(link, msg.id);
563
594
  if (msg?.method === "turn/interrupt" && link === this.link) this.abortActionWaits(link, typeof msg.params?.turnId === "string" ? msg.params.turnId : undefined);
564
595
  if (msg?.method === "turn/start" && msg.params && link === this.link) {
565
596
  const thread = typeof msg.params.threadId === "string" ? msg.params.threadId : this.threadId;
@@ -572,7 +603,15 @@ export class CodexPeer extends BasePeer {
572
603
  raw = JSON.stringify(msg);
573
604
  }
574
605
  }
575
- if (msg?.id !== undefined && TRACKED.has(msg.method)) link.tracked.set(msg.id, msg.method);
606
+ if (msg?.id !== undefined && TRACKED.has(msg.method)) {
607
+ if (link.tracked.has(msg.id)) link.ambiguousThreadRpc = true;
608
+ if (sandbox === "read-only") {
609
+ if (link.ambiguousThreadRpc || msg.params !== undefined && (!msg.params || typeof msg.params !== "object" || Array.isArray(msg.params))) return this.refusePlannerThread(link, msg.id);
610
+ msg.params = { ...msg.params, sandbox: "read-only" };
611
+ raw = JSON.stringify(msg);
612
+ }
613
+ link.tracked.set(msg.id, msg.method);
614
+ }
576
615
  if (link.up.readyState === WebSocket.OPEN) link.up.send(raw);
577
616
  else link.backlog.push(raw);
578
617
  }
@@ -595,7 +634,11 @@ export class CodexPeer extends BasePeer {
595
634
  if (typeof msg.result?.userAgent === "string") this.version = /^[^/\s]+\/(\d+\.\d+\.\d+(?:-[\w.]+)?)/.exec(msg.result.userAgent)?.[1] ?? this.version;
596
635
  const tracked = msg.id !== undefined && !msg.method ? link.tracked.get(msg.id) : undefined;
597
636
  if (tracked && link.tracked.delete(msg.id)) {
637
+ const sandbox = this.threadSandboxPolicy();
638
+ const readOnly = !link.ambiguousThreadRpc && msg.error === undefined && typeof msg.result?.thread?.id === "string" && !!msg.result.thread.id && confirmsReadOnlySandbox(msg.result?.sandbox);
639
+ if (sandbox === "unavailable" || sandbox === "read-only" && !readOnly) return this.refusePlannerThread(link, msg.id);
598
640
  if (typeof msg.result?.thread?.id === "string") {
641
+ link.readOnlyThread = readOnly ? msg.result.thread.id : undefined;
599
642
  // A resumed response may report the sticky hub override, not the original native choice.
600
643
  const thread = msg.result.thread.id;
601
644
  if (!this.overriddenApprovals.has(thread) && msg.result.approvalPolicy != null) this.nativeApprovalPolicies.set(thread, msg.result.approvalPolicy);
@@ -606,6 +649,32 @@ export class CodexPeer extends BasePeer {
606
649
  link.tui.send(raw);
607
650
  }
608
651
 
652
+ private threadSandboxPolicy(): "read-only" | "unavailable" | undefined {
653
+ try {
654
+ const value = typeof this.opts.threadSandbox === "function" ? this.opts.threadSandbox() : this.opts.threadSandbox;
655
+ return value === undefined || value === "read-only" ? value : "unavailable";
656
+ } catch { return "unavailable"; }
657
+ }
658
+
659
+ /** Role changes never turn an ordinary writable/unknown thread into a proved planner thread. */
660
+ private threadSandboxReady(link: Link): boolean {
661
+ const sandbox = this.threadSandboxPolicy();
662
+ if (sandbox === "unavailable") { this.refusePlannerThread(link, undefined); return false; }
663
+ if (sandbox === "read-only") {
664
+ if (link.tracked.size > 0) return false;
665
+ if (link !== this.link || link.readOnlyThread !== this.threadId) { this.refusePlannerThread(link, undefined); return false; }
666
+ }
667
+ return true;
668
+ }
669
+
670
+ /** Refuse before adoption or another host delivery, including while a previous thread was idle. */
671
+ private refusePlannerThread(link: Link, id: unknown): void {
672
+ if (link.tui.readyState === WebSocket.OPEN && (typeof id === "string" || typeof id === "number")) link.tui.send(JSON.stringify({ id, error: { code: -32000, message: PLANNER_SANDBOX_REFUSAL } }));
673
+ link.tracked.clear();
674
+ this.detach(link);
675
+ link.tui.close(1008, PLANNER_SANDBOX_REFUSAL);
676
+ }
677
+
609
678
  private abortActionWaits(link?: Link, turn?: string): void {
610
679
  for (const [controller, wait] of this.actionWaits) if ((!link || wait.link === link) && (!turn || wait.turn === turn)) controller.abort();
611
680
  }
@@ -673,6 +742,7 @@ export class CodexPeer extends BasePeer {
673
742
  this.freshThread = fresh;
674
743
  this.link = link;
675
744
  this.threadId = threadId;
745
+ this.onDoing?.("reset");
676
746
  this.contextEpoch++;
677
747
  this.deliveryEpoch++;
678
748
  this.retiredTurns.clear(); this.unidentifiedCancellation = false;
@@ -722,6 +792,9 @@ export class CodexPeer extends BasePeer {
722
792
  this.opts.onUsage?.({ rateLimitReachedType: "usageLimitExceeded" }, true);
723
793
  }
724
794
  if (link !== this.link || params.threadId !== this.threadId) return;
795
+ // #878: the bound thread's name and its plan's in-progress step, for the hub's DOING column only.
796
+ if (method === "thread/name/updated") return void this.onDoing?.("title", params.threadName);
797
+ if (method === "turn/plan/updated") return void this.onDoing?.("todo", Array.isArray(params.plan) ? params.plan.find((s: any) => s?.status === "inProgress")?.step : undefined);
725
798
  const nativeId = method === "turn/started" || method === "turn/completed" ? params.turn?.id : params.turnId;
726
799
  if (typeof nativeId === "string" && this.retiredTurns.has(nativeId)) {
727
800
  if (method === "turn/completed") this.retiredTurns.delete(nativeId);
@@ -1,6 +1,6 @@
1
1
  import { createServer } from "node:net";
2
2
  import { basename, dirname, join } from "node:path";
3
- import { writeSync, appendFileSync, mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs";
3
+ import { writeSync, appendFileSync, mkdirSync, writeFileSync, existsSync, readFileSync, rmSync } from "node:fs";
4
4
  import { createInterface } from "node:readline";
5
5
  const args = process.argv.slice(2), value = (key: string) => args[args.indexOf(key) + 1];
6
6
  const record = args.includes("--record") ? value("--record") : undefined;
@@ -76,7 +76,11 @@ if (args.includes("version")) {
76
76
  }
77
77
  const finish = () => {
78
78
  send({ jsonrpc: "2.0", method: "session/update", params: { sessionId: session, update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "JCODE_REPLY" } } } });
79
- send({ jsonrpc: "2.0", id: msg.id, result: { stopReason: args.includes("--max-tokens-stop") ? "max_tokens" : "end_turn" } });
79
+ // `--usage-script F`: the 0.99.1 shapes, as probed (docs/verification/2026-10-28-jcode-0.99.1-usage.md). F is a JSON object `{ updates: [...], usage: {...} | null, sessionId?: string }`:
80
+ // each `updates` entry becomes a `usage_update` notification (for `sessionId` when given, else this session), and `usage` is the prompt result's `usage` member (absent when null).
81
+ const script = args.includes("--usage-script") && existsSync(value("--usage-script")!) ? JSON.parse(readFileSync(value("--usage-script")!, "utf8")) : undefined;
82
+ for (const update of script?.updates ?? []) send({ jsonrpc: "2.0", method: "session/update", params: { sessionId: script.sessionId ?? session, update: { sessionUpdate: "usage_update", ...update } } });
83
+ send({ jsonrpc: "2.0", id: msg.id, result: { stopReason: args.includes("--max-tokens-stop") ? "max_tokens" : "end_turn", ...(script?.usage ? { usage: script.usage } : {}) } });
80
84
  if (args.includes("--exit-after-prompt")) setTimeout(() => process.exit(0), 30);
81
85
  };
82
86
  const steerFile = args.includes("--steer-file") ? value("--steer-file")! : undefined;