@staix/agent-hub 0.19.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 (184) hide show
  1. package/CHANGELOG.md +70 -3
  2. package/README.md +2 -2
  3. package/docs/agent-notes/README.md +11 -0
  4. package/docs/agent-notes/adapters.md +10 -5
  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 +3 -3
  8. package/docs/agent-notes/daemon.md +20 -7
  9. package/docs/agent-notes/local-worker.md +5 -0
  10. package/docs/agent-notes/models.md +2 -0
  11. package/docs/agent-notes/tasks.md +20 -10
  12. package/docs/agent-notes/tests.md +13 -5
  13. package/docs/bench.md +1 -1
  14. package/docs/data-policy.md +2 -0
  15. package/docs/dogfood.md +41 -5
  16. package/docs/events.md +2 -1
  17. package/docs/memory.md +9 -3
  18. package/docs/operations.md +598 -63
  19. package/docs/profiles.md +13 -1
  20. package/docs/quickstart.md +18 -9
  21. package/docs/research.md +7 -4
  22. package/docs/sdlc.md +66 -10
  23. package/docs/security.md +397 -35
  24. package/docs/smoke.md +127 -0
  25. package/docs/specs/2026-09-19-agent-hub-design.md +1149 -19
  26. package/docs/verification/2026-10-27-0.19.0.md +5 -5
  27. package/docs/verification/2026-10-27-0.20.0.md +75 -0
  28. package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-bash-permission.json +635 -0
  29. package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-permission-transport.json +403 -0
  30. package/docs/verification/2026-10-28-claude-implementer-permission-triage.md +123 -0
  31. package/docs/verification/2026-10-28-jcode-0.99.1-usage.md +41 -0
  32. package/docs/verification/2026-10-28-realpath-name-audit.md +222 -0
  33. package/docs/verification/2026-10-28-routing-f0bb8ad0.md +1000 -0
  34. package/docs/verification/2026-10-29-0.21.0.md +61 -0
  35. package/docs/verification/2026-10-29-model-effort-capabilities.md +123 -0
  36. package/docs/verification/2026-10-29-modelrouting-b152ddb9.md +953 -0
  37. package/docs/verification/claude-implementer-permission-triage.ts +160 -0
  38. package/docs/verification/claude-permission-transport-native.py +71 -14
  39. package/docs/verification/profiles/YYYY-MM-DD-research.md +1 -1
  40. package/docs/verified.json +26 -18
  41. package/package.json +2 -2
  42. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  43. package/plugins/agent-hub/server.js +1116 -284
  44. package/src/adapters/acp.ts +87 -3
  45. package/src/adapters/channel-peer.ts +11 -5
  46. package/src/adapters/claude-channel.ts +13 -6
  47. package/src/adapters/claude-implementer.ts +61 -7
  48. package/src/adapters/codex-appserver.ts +88 -5
  49. package/src/adapters/conformance/enrollment.ts +4 -4
  50. package/src/adapters/conformance/factories.ts +2 -1
  51. package/src/adapters/conformance/fakes/jcode-agent.ts +6 -2
  52. package/src/adapters/conformance/kit.ts +10 -6
  53. package/src/adapters/definitions/builtin-metadata.ts +3 -114
  54. package/src/adapters/definitions/native-config-defaults.ts +1 -1
  55. package/src/adapters/definitions/native-config-policy.ts +3 -2
  56. package/src/adapters/definitions/native-context.ts +12 -0
  57. package/src/adapters/external-metadata.ts +48 -0
  58. package/src/adapters/external-modules.ts +63 -3
  59. package/src/adapters/jcode.ts +6 -2
  60. package/src/adapters/local-worker.ts +42 -25
  61. package/src/adapters/native-proof-ports.ts +10 -0
  62. package/src/adapters/pi.ts +7 -0
  63. package/src/adapters/registry-types.ts +4 -0
  64. package/src/adapters/reviewer-print.ts +1 -0
  65. package/src/broker/protocol.ts +203 -0
  66. package/src/broker/server.ts +314 -0
  67. package/src/broker/store.ts +548 -0
  68. package/src/cli/bench.ts +3 -2
  69. package/src/cli/broker.ts +58 -0
  70. package/src/cli/conductor-recovery.ts +142 -0
  71. package/src/cli/configured-acp-entry.ts +26 -6
  72. package/src/cli/console-state.ts +59 -17
  73. package/src/cli/console.ts +17 -2
  74. package/src/cli/facts-hook.ts +3 -1
  75. package/src/cli/help.ts +28 -16
  76. package/src/cli/identity.ts +23 -10
  77. package/src/cli/init.ts +34 -7
  78. package/src/cli/intake-output.ts +33 -0
  79. package/src/cli/main.ts +314 -71
  80. package/src/cli/output.ts +41 -1
  81. package/src/cli/recovery-package.ts +18 -1
  82. package/src/cli/role-holders.ts +35 -0
  83. package/src/cli/room.ts +304 -0
  84. package/src/cli/say-input.ts +16 -0
  85. package/src/cli/schedule-output.ts +49 -0
  86. package/src/cli/sdlc-output.ts +44 -6
  87. package/src/cli/smoke-pty-bridge.ts +33 -0
  88. package/src/cli/smoke-pty.ts +145 -0
  89. package/src/cli/smoke-run.ts +139 -0
  90. package/src/cli/smoke-target.ts +130 -0
  91. package/src/cli/source-output.ts +29 -0
  92. package/src/cli/task-output.ts +3 -2
  93. package/src/cli/upgrade-runtime.ts +98 -16
  94. package/src/cli/upgrade.ts +66 -7
  95. package/src/hub/ask.ts +76 -12
  96. package/src/hub/board.ts +132 -22
  97. package/src/hub/bus.ts +399 -56
  98. package/src/hub/child-process.ts +2 -0
  99. package/src/hub/ci-watch.ts +162 -0
  100. package/src/hub/claude-failure-stage.ts +85 -0
  101. package/src/hub/claude-launch-proof.ts +67 -2
  102. package/src/hub/claude-shell-wrapper.ts +11 -5
  103. package/src/hub/conductor.ts +17 -4
  104. package/src/hub/config-apply.ts +32 -5
  105. package/src/hub/config-reload.ts +7 -5
  106. package/src/hub/config-trust.ts +8 -1
  107. package/src/hub/context-window.ts +33 -3
  108. package/src/hub/control-client.ts +14 -2
  109. package/src/hub/corpus-config.ts +34 -0
  110. package/src/hub/corpus-evidence.ts +184 -0
  111. package/src/hub/corpus.ts +373 -0
  112. package/src/hub/cost-governance.ts +26 -0
  113. package/src/hub/daemon.ts +1228 -242
  114. package/src/hub/deliverable-files.ts +8 -2
  115. package/src/hub/delivery-journal.ts +123 -17
  116. package/src/hub/dogfood-profile.ts +32 -5
  117. package/src/hub/dogfood-read.ts +6 -2
  118. package/src/hub/envelope.ts +11 -0
  119. package/src/hub/eval-gate.ts +131 -0
  120. package/src/hub/events.ts +1 -1
  121. package/src/hub/external-instance.ts +69 -0
  122. package/src/hub/facts.ts +15 -5
  123. package/src/hub/folder-trigger.ts +223 -0
  124. package/src/hub/hub-tools.ts +2 -1
  125. package/src/hub/inference.ts +20 -5
  126. package/src/hub/instance-config.ts +69 -3
  127. package/src/hub/instance-execution.ts +6 -2
  128. package/src/hub/instances.ts +32 -4
  129. package/src/hub/intake.ts +125 -0
  130. package/src/hub/learning.ts +85 -0
  131. package/src/hub/lifecycle.ts +7 -3
  132. package/src/hub/limits.ts +12 -5
  133. package/src/hub/native-permission-request.ts +77 -0
  134. package/src/hub/operational-authority.ts +63 -5
  135. package/src/hub/operational-settings.ts +76 -0
  136. package/src/hub/operational-view.ts +1 -1
  137. package/src/hub/packs.ts +132 -0
  138. package/src/hub/path-policy.ts +7 -0
  139. package/src/hub/peer-doing.ts +130 -0
  140. package/src/hub/peers.ts +46 -0
  141. package/src/hub/queue-backpressure.ts +58 -0
  142. package/src/hub/read-tools.ts +2 -0
  143. package/src/hub/recovery-authority.ts +151 -0
  144. package/src/hub/recovery-policy.ts +220 -0
  145. package/src/hub/research.ts +24 -14
  146. package/src/hub/restart.ts +7 -0
  147. package/src/hub/routing.ts +54 -11
  148. package/src/hub/schedules.ts +266 -0
  149. package/src/hub/sdlc-merge.ts +38 -14
  150. package/src/hub/sdlc-metrics.ts +32 -10
  151. package/src/hub/sdlc-queue.ts +7 -1
  152. package/src/hub/sdlc-release.ts +1 -1
  153. package/src/hub/sdlc.ts +37 -7
  154. package/src/hub/security-baseline.ts +38 -0
  155. package/src/hub/settings.ts +7 -1
  156. package/src/hub/shipped-packs.ts +8 -0
  157. package/src/hub/snapshots.ts +72 -6
  158. package/src/hub/source-read.ts +160 -0
  159. package/src/hub/sources.ts +311 -0
  160. package/src/hub/task-shell-default.ts +3 -2
  161. package/src/hub/tasks.ts +1047 -115
  162. package/src/hub/workflow.ts +1 -1
  163. package/src/local/deny.ts +21 -0
  164. package/src/local/doc-read.ts +69 -0
  165. package/src/local/proxy.ts +4 -13
  166. package/src/local/sandbox.ts +3 -1
  167. package/src/local/tools.ts +37 -3
  168. package/src/local/web-fetch-config.ts +43 -0
  169. package/src/local/web-fetch.ts +183 -0
  170. package/src/memory/brief.ts +85 -13
  171. package/src/memory/capture.ts +1 -1
  172. package/src/rooms/application.ts +114 -0
  173. package/src/rooms/boundary.ts +103 -0
  174. package/src/rooms/commands.ts +294 -0
  175. package/src/rooms/crypto.ts +371 -0
  176. package/src/rooms/link.ts +584 -0
  177. package/src/rooms/policy.ts +82 -0
  178. package/src/rooms/remote-peer.ts +80 -0
  179. package/src/rooms/service.ts +426 -0
  180. package/src/rooms/status.ts +146 -0
  181. package/src/rooms/store.ts +1210 -0
  182. package/src/rooms/wire.ts +465 -0
  183. package/src/ui/index.html +91 -4
  184. 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";
@@ -10,6 +11,7 @@ export { QwenIdentityError } from "./definitions/qwen-native.ts";
10
11
  import { BasePeer } from "../hub/peers.ts";
11
12
  import { peerChildEnv, stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
12
13
  import { AcpOwnedGroup } from "./acp-owned-group.ts";
14
+ import type { StartForm } from "./registry-types.ts";
13
15
 
14
16
  export interface PermissionOption {
15
17
  optionId: string;
@@ -48,6 +50,23 @@ export interface ACPUsageDiagnostic {
48
50
  total?: number;
49
51
  }
50
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
+
51
70
  /** Counter-only projection: no source payload, text, tools or metadata escapes. */
52
71
  export function normalizeACPUsage(value: unknown, source: ACPUsageDiagnostic["source"] = "usage_update"): ACPUsageDiagnostic {
53
72
  const unknown: ACPUsageDiagnostic = { source, availability: "unsupported", shape: "none" };
@@ -65,7 +84,8 @@ export function normalizeACPUsage(value: unknown, source: ACPUsageDiagnostic["so
65
84
  const a = f[input!], b = f[output!];
66
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" };
67
86
  }
68
- 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) {
69
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" };
70
90
  // Qwen 0.24.7 derives this from collectContextData: occupancy is not cumulative consumption.
71
91
  return { source, availability: "unsupported", shape: "context-used", contextUsed: f.used, contextCapacity: f.size };
@@ -109,6 +129,8 @@ export interface AcpOptions {
109
129
  preamble?: string;
110
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. */
111
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;
112
134
  /** The prompt rejected or ended without normal completion, even when it streamed partial text.
113
135
  * Same shape as Pi's onTurnFailure; a stale cancelled turn never reports. */
114
136
  onTurnFailure?: (envs: Envelope[], reason: string) => Promise<void> | void;
@@ -137,6 +159,45 @@ const HANDSHAKE_MS = 30_000;
137
159
  const TOOL_INPUT_CAP = 64;
138
160
  const TOOL_CEILING_MS = 30 * 60_000;
139
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
+
140
201
  /**
141
202
  * The canonical tool binding (#138): an announced `<tool> (<server> MCP Server)` title resolves to
142
203
  * `mcp__<server>__<tool>` only when <server> is one this session was configured with. A title that
@@ -163,6 +224,9 @@ function jsonObject(text: string | undefined): object | undefined {
163
224
 
164
225
  /** ACP client (JSON-RPC 2.0, newline-delimited, over the child's stdio). One session, one prompt in flight. */
165
226
  export class AcpPeer extends BasePeer {
227
+ readonly deliveryOnly: boolean = true;
228
+ readonly hubOwned: boolean = true;
229
+ readonly form: StartForm = "headless";
166
230
  private permissionMode: PermissionMode;
167
231
  private readonly nativePolicy: AcpOptions["nativePolicy"];
168
232
  private modeUnknown = false;
@@ -235,7 +299,7 @@ export class AcpPeer extends BasePeer {
235
299
  private readonly permissionWaits = new Set<AbortController>();
236
300
 
237
301
  protected override setState(next: PeerState): void {
238
- if (next === "offline") { this.clearActivity(); this.cancelPermissions(); }
302
+ if (next === "offline") { managedLaunches.delete(this); this.clearActivity(); this.cancelPermissions(); }
239
303
  super.setState(next);
240
304
  }
241
305
 
@@ -256,13 +320,20 @@ export class AcpPeer extends BasePeer {
256
320
  recoveryMetadata(): Record<string, unknown> {
257
321
  return { launch: { kind: "acp", ...(this.opts.launchModel ? { model: this.opts.launchModel } : {}) }, ...(this.sessionId ? { sessionId: this.sessionId } : {}) };
258
322
  }
323
+ get nativeSession(): string | undefined {
324
+ const session = this.recoveryMetadata().sessionId;
325
+ return typeof session === "string" ? session : undefined;
326
+ }
259
327
 
260
328
  async start(): Promise<void> {
329
+ managedLaunches.delete(this);
261
330
  if (this.opts.requireOwnedGroup && this.proc) throw new Error("Configured ACP still has a process; stop it before starting another");
262
331
  this.stopping = false;
332
+ const launchedId = this.id;
333
+ const launchedCwd = this.opts.cwd;
263
334
  const [bin, ...args] = this.opts.cmd;
264
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.
265
- 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 });
266
337
  this.proc = proc;
267
338
  trackGroup(proc);
268
339
  proc.on("error", (e) => { if (!this.opts.requireOwnedGroup || this.proc === proc) this.down(`spawn failed: ${e.message}`); });
@@ -302,11 +373,13 @@ export class AcpPeer extends BasePeer {
302
373
  setTimeout(() => reject(new Error(`${this.id} did not complete the ACP handshake within ${HANDSHAKE_MS / 1000} s`)), HANDSHAKE_MS).unref();
303
374
  });
304
375
  const session = await Promise.race([handshake(), timeout]);
376
+ const nativeSession = session?.sessionId;
305
377
  if (this.nativePolicy !== undefined && (typeof session?.sessionId !== "string" || !session.sessionId.trim()
306
378
  || Buffer.byteLength(session.sessionId) > 4096 || /[\p{Cc}\p{Cf}]/u.test(session.sessionId))) {
307
379
  throw new Error("Configured ACP did not provide a valid native session identity");
308
380
  }
309
381
  this.sessionId = session.sessionId;
382
+ this.onDoing?.("reset");
310
383
  const modes: unknown = session.modes?.availableModes;
311
384
  this.availableModes = new Set(Array.isArray(modes) ? modes.flatMap((mode) => typeof mode?.id === "string" ? [mode.id] : []) : []);
312
385
  // A recovered session can retain a previous runtime opt-in. An advertised non-default mode
@@ -320,8 +393,13 @@ export class AcpPeer extends BasePeer {
320
393
  if (!owned || owned.proc !== proc || this.proc !== proc || this.stopping) throw new Error("Configured ACP process ownership could not be verified");
321
394
  await owned.proof.verify();
322
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 }) });
323
400
  }
324
401
  } catch (e) {
402
+ managedLaunches.delete(this);
325
403
  this.setState("offline");
326
404
  if (this.proc === proc) {
327
405
  if (this.opts.requireOwnedGroup) {
@@ -382,6 +460,7 @@ export class AcpPeer extends BasePeer {
382
460
  }
383
461
 
384
462
  async stop(reason?: string): Promise<void> {
463
+ managedLaunches.delete(this);
385
464
  if (this.activityOpen) this.endActivity("stopped", true);
386
465
  this.stopping = true;
387
466
  this.cancelPermissions();
@@ -445,6 +524,8 @@ export class AcpPeer extends BasePeer {
445
524
  if (this.activeLegacy?.turn === turn) this.activeLegacy = undefined;
446
525
  this.endActivity(typeof result?.stopReason === "string" ? result.stopReason : "unknown", result?.stopReason === "cancelled");
447
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 */ }
448
529
  this.primed = true;
449
530
  const body = this.chunks.join("").trim();
450
531
  // What was steered into the turn is answered by it too: its senders hear back, and the hop cap still holds.
@@ -589,6 +670,7 @@ export class AcpPeer extends BasePeer {
589
670
  }
590
671
 
591
672
  private down(reason: string): void {
673
+ managedLaunches.delete(this);
592
674
  this.clearCancelWait();
593
675
  if (this.nativePolicy !== undefined || this.opts.requireOwnedGroup) reason = "Configured ACP transport stopped";
594
676
  this.opts.log?.(`[${this.id}] ${reason}`);
@@ -649,6 +731,8 @@ export class AcpPeer extends BasePeer {
649
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);
650
732
  }
651
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);
652
736
  } else if (msg.method === "session/request_permission") {
653
737
  if (this.cancelWait || this.state === "offline") { this.send({ jsonrpc: "2.0", id: msg.id, result: { outcome: { outcome: "cancelled" } } }); return; }
654
738
  if (this.state === "busy" && (!msg.params?.sessionId || msg.params.sessionId === this.sessionId)) this.promptActivity = true;
@@ -1,6 +1,7 @@
1
1
  import { BasePeer, type PeerAdapter } from "../hub/peers.ts";
2
2
  import type { Envelope } from "../hub/envelope.ts";
3
3
  import { definitionOf } from "./registry.ts";
4
+ import type { StartForm } from "./registry-types.ts";
4
5
 
5
6
  /** Only the control socket operations the peer owns; authentication remains in the daemon. */
6
7
  export interface ChannelSocket {
@@ -12,6 +13,7 @@ export interface ChannelSocket {
12
13
 
13
14
  /** A peer that lives in another process and attaches over the control WS (the Claude channel plugin). */
14
15
  export class WsPeer extends BasePeer {
16
+ readonly form: StartForm = "tui";
15
17
  private generation = crypto.randomUUID();
16
18
  // Checkpoints become stale at hello/claim, before asynchronous recall finishes and attach changes delivery generation.
17
19
  private claimGeneration = crypto.randomUUID();
@@ -23,7 +25,7 @@ export class WsPeer extends BasePeer {
23
25
  private delivered = new Set<string>();
24
26
  private sock: ChannelSocket | undefined;
25
27
  private claimed: ChannelSocket | undefined;
26
- private nativeSession: string | undefined;
28
+ private nativeTurnSession: string | undefined;
27
29
  private nativeActive = false;
28
30
  /** The attached session said it cannot show channel pushes (issue #205): the bus keeps its queue for `inbox`. */
29
31
  pullOnly = false;
@@ -46,6 +48,10 @@ export class WsPeer extends BasePeer {
46
48
  this.claimed = sock;
47
49
  this.claimedPullOnly = pullOnly;
48
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
+
49
55
  /** A session that shows pushes holds the peer or is arriving: a session without them must not take it (issue #205). */
50
56
  get channelHeld(): boolean {
51
57
  return (!!this.sock && !this.pullOnly) || (this.claiming && !this.claimedPullOnly);
@@ -66,7 +72,7 @@ export class WsPeer extends BasePeer {
66
72
  this.sock = sock;
67
73
  this.generation = crypto.randomUUID();
68
74
  this.delivered.clear();
69
- this.nativeSession = undefined;
75
+ this.nativeTurnSession = undefined;
70
76
  this.nativeActive = false;
71
77
  this.pullOnly = this.claimedPullOnly; // before idle: the idle transition drains
72
78
  // Native TUI readback in #493 proves important channel input can be consumed before the turn ends.
@@ -112,13 +118,13 @@ export class WsPeer extends BasePeer {
112
118
  owns(sock: ChannelSocket): boolean { return this.sock === sock; }
113
119
  nativeStarted(session: string): void {
114
120
  if (!this.sock) return;
115
- this.nativeSession = session;
121
+ this.nativeTurnSession = session;
116
122
  this.nativeActive = true;
117
123
  this.setState("busy");
118
124
  }
119
125
  nativeStopped(session: string): boolean {
120
- if (!this.sock || (this.nativeSession !== undefined && this.nativeSession !== session)) return false;
121
- this.nativeSession = session;
126
+ if (!this.sock || (this.nativeTurnSession !== undefined && this.nativeTurnSession !== session)) return false;
127
+ this.nativeTurnSession = session;
122
128
  this.nativeActive = false;
123
129
  this.setState("idle");
124
130
  return true;
@@ -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,13 +83,16 @@ 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
 
72
92
  /** One owned print child at a time. Idle holds no native process; task continuity is ids-only on disk. */
73
93
  export class ClaudeImplementerPeer extends BasePeer {
74
94
  readonly hubNative = true;
95
+ readonly hubOwned: boolean = true;
75
96
  private generation = 0;
76
97
  private owned: OwnedPrintChild | undefined;
77
98
  private running: Promise<void> | undefined;
@@ -83,6 +104,7 @@ export class ClaudeImplementerPeer extends BasePeer {
83
104
  private command: readonly string[] | undefined;
84
105
  private deliveryId: string | undefined;
85
106
  private inputReleased = false;
107
+ private failureTrace: ClaudeFailureTrace | undefined;
86
108
  private sessions: ClaudeTaskSessions;
87
109
  private stopping: Promise<void> | undefined;
88
110
  private stoppingTarget: "idle" | "offline" | undefined;
@@ -125,6 +147,7 @@ export class ClaudeImplementerPeer extends BasePeer {
125
147
  }
126
148
  this.stoppingTarget = next;
127
149
  ++this.generation; this.active = undefined; this.childAbort?.abort(); this.childAbort = undefined; this.abort?.abort(); this.clearWatchdog();
150
+ discardFailureTrace(this.failureTrace); this.failureTrace = undefined;
128
151
  this.settle(this.inputReleased ? "needs_review" : "failed_safe", reason);
129
152
  if (this.owned || this.running) this.setState("busy");
130
153
  const work: Promise<void> = Promise.resolve().then(async () => {
@@ -165,8 +188,10 @@ export class ClaudeImplementerPeer extends BasePeer {
165
188
  }
166
189
  private async run(groups: { task?: number; envs: Envelope[] }[], generation: number, running: Promise<void>): Promise<void> {
167
190
  const current = () => generation === this.generation && !this.abort?.signal.aborted;
191
+ let failureReason: string | undefined;
168
192
  try {
169
193
  for (let group of groups) {
194
+ failureReason = undefined;
170
195
  if (!current() || !this.binding?.()) throw new Error("Claude implementer admission changed");
171
196
  group = { ...group, envs: group.envs.filter(env => !env.private && this.options.admit(env)) };
172
197
  if (!group.envs.length) continue;
@@ -180,6 +205,7 @@ export class ClaudeImplementerPeer extends BasePeer {
180
205
  }
181
206
  }
182
207
  const launchId = randomUUID(), directory = mkdtempSync(join(this.options.stateDir, "claude-child-")), settings = join(directory, "settings.json");
208
+ let launchFailureTrace: ClaudeFailureTrace | undefined;
183
209
  this.cleanup = () => rmSync(directory, { recursive: true, force: true });
184
210
  writeFileSync(settings, sessionSettings(undefined, { script: this.options.factsHook, stateDir: this.options.stateDir, purpose: "permission" }), { mode: 0o600, flag: "wx" });
185
211
  const env = { AGENTHUB_MODE: "tools", AGENTHUB_PEER_ID: this.id, AGENTHUB_STATE_DIR: this.options.stateDir, AGENTHUB_PROJECT_DIR: this.options.projectRoot,
@@ -196,11 +222,15 @@ export class ClaudeImplementerPeer extends BasePeer {
196
222
  void this.cancel("Claude implementer admission changed before child start", "idle").catch(() => {});
197
223
  return;
198
224
  }
199
- this.inputReleased = true; // OwnedPrintChild releases stdin immediately after this synchronous admission fence.
200
225
  this.childAbort = new AbortController();
201
- 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.
202
232
  },
203
- onLeaderExit: () => { this.active = undefined; this.childAbort?.abort(); this.childAbort = undefined; },
233
+ onLeaderExit: () => { sealFailureTrace(launchFailureTrace); this.active = undefined; this.childAbort?.abort(); this.childAbort = undefined; },
204
234
  onActivity: () => this.touch(),
205
235
  onLimit: () => { void this.cancel("Claude implementer output limit exceeded", "idle").catch(() => {}); },
206
236
  onFrame: event => {
@@ -219,12 +249,21 @@ export class ClaudeImplementerPeer extends BasePeer {
219
249
  catch (error) {
220
250
  // spawn ENOENT has no owned group. A started child with failed group cleanup must keep its handle.
221
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;
222
255
  throw error;
223
256
  }
224
257
  this.active = undefined; this.owned = undefined;
225
258
  this.cleanup?.(); this.cleanup = undefined;
226
- if (!current()) return;
227
- 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;
228
267
  }
229
268
  if (current()) {
230
269
  if (this.running === running) this.running = undefined;
@@ -233,7 +272,11 @@ export class ClaudeImplementerPeer extends BasePeer {
233
272
  } catch (error) {
234
273
  this.active = undefined; this.childAbort?.abort(); this.childAbort = undefined;
235
274
  if (current()) {
236
- 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);
237
280
  if (this.owned) this.setState("busy"); else {
238
281
  this.cleanup?.(); this.cleanup = undefined;
239
282
  if (this.running === running) this.running = undefined;
@@ -242,4 +285,15 @@ export class ClaudeImplementerPeer extends BasePeer {
242
285
  }
243
286
  }
244
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
+ }
245
299
  }