@staix/agent-hub 0.29.0 → 0.31.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 (154) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +15 -7
  3. package/docs/agent-notes/README.md +13 -0
  4. package/docs/agent-notes/adapters.md +7 -3
  5. package/docs/agent-notes/budget.md +1 -0
  6. package/docs/agent-notes/bus.md +10 -4
  7. package/docs/agent-notes/daemon.md +16 -8
  8. package/docs/agent-notes/local-worker.md +1 -0
  9. package/docs/agent-notes/tasks.md +10 -4
  10. package/docs/agent-notes/tests.md +8 -4
  11. package/docs/design/adapters.md +108 -0
  12. package/docs/design/agent-hub-architecture.md +58 -0
  13. package/docs/design/bus.md +147 -1
  14. package/docs/design/task-board.md +100 -0
  15. package/docs/dogfood.md +29 -8
  16. package/docs/events.md +3 -0
  17. package/docs/memory.md +17 -1
  18. package/docs/operations.md +410 -86
  19. package/docs/profiles.md +35 -0
  20. package/docs/quickstart.md +25 -5
  21. package/docs/sdlc.md +1 -1
  22. package/docs/security.md +182 -62
  23. package/docs/smoke.md +13 -2
  24. package/docs/specs/2026-09-19-agent-hub-design.md +84 -14
  25. package/docs/specs/2026-09-19-multi-project-design.md +7 -2
  26. package/docs/specs/2026-10-12-office-view-design.md +86 -3
  27. package/docs/verification/2026-11-03-0.30.0.md +54 -0
  28. package/docs/verification/2026-11-03-agy-1.4.12-launcher-nonce.md +42 -0
  29. package/docs/verification/2026-11-03-agy-1.4.12.md +90 -0
  30. package/docs/verification/2026-11-03-agy-tui-close.md +49 -0
  31. package/docs/verification/2026-11-03-grok-1.0.62-t0.md +150 -0
  32. package/docs/verification/2026-11-03-pi-150-compat.md +40 -0
  33. package/docs/verification/2026-11-03-pi-title-api.md +26 -0
  34. package/docs/verification/2026-11-03-qwen-0265-acp.md +55 -0
  35. package/docs/verification/2026-11-03-qwen-0265-dual-output.md +40 -0
  36. package/docs/verification/2026-11-04-1171-channel-client-evidence.md +31 -0
  37. package/docs/verification/2026-11-05-0.31.0.md +182 -0
  38. package/docs/verification/2026-11-05-claude-implementer-2.1.318.md +70 -0
  39. package/docs/verification/2026-11-05-hardcoded-values-classification-rules.md +257 -0
  40. package/docs/verification/2026-11-05-hardcoded-values-ratchet.md +55 -0
  41. package/docs/verification/2026-11-06-rooms-presence-cost.md +152 -0
  42. package/docs/verification/claude-shell-wrapper-native.py +1 -1
  43. package/docs/verification/dogfood/2026-11-03.md +3095 -0
  44. package/docs/verification/pi-150-model-free.ts +57 -0
  45. package/docs/verification/pi-150-native.mjs +21 -0
  46. package/docs/verification/pi-150-title-api.mjs +31 -0
  47. package/docs/verified.json +14 -14
  48. package/package.json +3 -4
  49. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  50. package/plugins/agent-hub/server.js +4565 -3617
  51. package/src/adapters/acp.ts +5 -4
  52. package/src/adapters/channel-peer.ts +72 -3
  53. package/src/adapters/claude-channel-evidence.ts +52 -0
  54. package/src/adapters/claude-channel.ts +61 -18
  55. package/src/adapters/claude-implementer.ts +29 -7
  56. package/src/adapters/claude-print-child.ts +12 -2
  57. package/src/adapters/codex-appserver.ts +13 -12
  58. package/src/adapters/compat.ts +13 -13
  59. package/src/adapters/definitions/builtin-metadata.ts +13 -13
  60. package/src/adapters/definitions/qwen.ts +1 -0
  61. package/src/adapters/local-worker.ts +82 -23
  62. package/src/adapters/pi.ts +140 -16
  63. package/src/adapters/registry-types.ts +16 -0
  64. package/src/adapters/registry.ts +4 -0
  65. package/src/adapters/reviewer-print.ts +50 -11
  66. package/src/broker/server.ts +44 -13
  67. package/src/broker/store.ts +38 -8
  68. package/src/cli/answer-args.ts +50 -0
  69. package/src/cli/attention.ts +17 -2
  70. package/src/cli/autonomy.ts +66 -1
  71. package/src/cli/backlog-return-args.ts +37 -0
  72. package/src/cli/console-question.ts +110 -0
  73. package/src/cli/console-state.ts +82 -17
  74. package/src/cli/console.ts +54 -1
  75. package/src/cli/handoff-args.ts +15 -5
  76. package/src/cli/handoff-output.ts +15 -2
  77. package/src/cli/help.ts +10 -5
  78. package/src/cli/identity.ts +21 -4
  79. package/src/cli/main.ts +167 -16
  80. package/src/cli/memory.ts +107 -3
  81. package/src/cli/output.ts +29 -6
  82. package/src/cli/pet.ts +4 -4
  83. package/src/cli/results-args.ts +30 -0
  84. package/src/cli/runtime-version-warning.ts +50 -0
  85. package/src/cli/status-lines.ts +41 -1
  86. package/src/cli/tail-render.ts +1 -0
  87. package/src/cli/upgrade-interactive.ts +14 -2
  88. package/src/cli/worktree-tidy.ts +158 -0
  89. package/src/cli/worktrees-output.ts +43 -0
  90. package/src/hub/action-class.ts +9 -1
  91. package/src/hub/board.ts +396 -20
  92. package/src/hub/bus.ts +497 -57
  93. package/src/hub/completion-detail.ts +50 -0
  94. package/src/hub/completions-store.ts +168 -0
  95. package/src/hub/completions.ts +120 -0
  96. package/src/hub/conductor.ts +4 -3
  97. package/src/hub/config-apply.ts +8 -5
  98. package/src/hub/config-reload.ts +10 -1
  99. package/src/hub/control-client.ts +40 -4
  100. package/src/hub/daemon.ts +1795 -142
  101. package/src/hub/delivery-failure.ts +14 -0
  102. package/src/hub/delivery-hints.ts +14 -4
  103. package/src/hub/delivery-journal.ts +751 -42
  104. package/src/hub/delivery-superseded.ts +4 -1
  105. package/src/hub/dogfood-read.ts +62 -9
  106. package/src/hub/envelope.ts +3 -3
  107. package/src/hub/events.ts +6 -0
  108. package/src/hub/handoff.ts +145 -6
  109. package/src/hub/hub-tools.ts +66 -11
  110. package/src/hub/manager.ts +67 -3
  111. package/src/hub/memory-policy.ts +44 -1
  112. package/src/hub/memory-sync-policy.ts +109 -0
  113. package/src/hub/memory-sync-state.ts +145 -0
  114. package/src/hub/no-writes.ts +1 -1
  115. package/src/hub/operational-settings.ts +11 -2
  116. package/src/hub/operational-view.ts +12 -1
  117. package/src/hub/peer-doing.ts +33 -3
  118. package/src/hub/peers.ts +32 -2
  119. package/src/hub/planning.ts +6 -1
  120. package/src/hub/question-desk.ts +201 -0
  121. package/src/hub/question-detach.ts +6 -0
  122. package/src/hub/questions.ts +260 -0
  123. package/src/hub/research-evidence.ts +106 -0
  124. package/src/hub/restart.ts +14 -0
  125. package/src/hub/review-anchor.ts +181 -0
  126. package/src/hub/review-brief.ts +13 -0
  127. package/src/hub/review-comments.ts +115 -0
  128. package/src/hub/review-findings.ts +7 -2
  129. package/src/hub/review-material.ts +76 -0
  130. package/src/hub/routing.ts +3 -1
  131. package/src/hub/settings.ts +3 -0
  132. package/src/hub/task-sweep.ts +2 -0
  133. package/src/hub/tasks.ts +607 -71
  134. package/src/hub/ui.ts +2 -0
  135. package/src/hub/worktree-prs.ts +100 -0
  136. package/src/hub/worktree-shelf.ts +246 -0
  137. package/src/memory/capture.ts +5 -2
  138. package/src/pet/config.ts +25 -13
  139. package/src/pi/extension.ts +43 -3
  140. package/src/rooms/link.ts +209 -21
  141. package/src/rooms/remote-peer.ts +186 -19
  142. package/src/rooms/service.ts +338 -22
  143. package/src/rooms/store.ts +437 -65
  144. package/src/rooms/wire.ts +24 -6
  145. package/src/ui/index.html +271 -17
  146. package/src/ui/needs.ts +10 -4
  147. package/src/ui/office-model.ts +64 -8
  148. package/src/ui/office-space.ts +1 -0
  149. package/src/ui/plugin-questions.ts +56 -0
  150. package/src/ui/plugin-results.ts +81 -0
  151. package/src/ui/plugin.ts +219 -28
  152. package/src/pet/window.html +0 -114
  153. package/src/pet/window.jxa.js +0 -122
  154. package/src/pet/window.ts +0 -154
@@ -3,8 +3,9 @@ import { randomUUID } from "node:crypto";
3
3
  import { existsSync, mkdirSync } from "node:fs";
4
4
  import { join, resolve } from "node:path";
5
5
  import { spawn, type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process";
6
- import { renderDigest, replyAudience, replyParent, type Envelope, type PeerId } from "../hub/envelope.ts";
7
- import { BasePeer } from "../hub/peers.ts";
6
+ import { renderDigest, remoteEnvelope, replyAudience, replyParent, type Envelope, type PeerId } from "../hub/envelope.ts";
7
+ import { BasePeer, type SessionTitleResult } from "../hub/peers.ts";
8
+ import type { DeliveryFailureCause } from "../hub/delivery-failure.ts";
8
9
  import { peerChildEnv, stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
9
10
  import { assessVersion, COMPAT, type CompatVerdict } from "./compat.ts";
10
11
  import { processLiveness, processSignature } from "../pi/process-signature.ts";
@@ -12,6 +13,7 @@ import { PiSessionBookmarkMismatch, validatePiSession } from "../pi/session.ts";
12
13
  import { ApprovalWaitStop, toolResultFailed, type ApprovalProvenance } from "../local/tools.ts";
13
14
  import { piToolStepCeiling, type PiToolStepCeiling } from "../pi/ceiling.ts";
14
15
  import type { ExecutionBudgetDecision, ExecutionUnit } from "../hub/execution-budget.ts";
16
+ import { QuestionWaitDetached } from "../hub/question-detach.ts";
15
17
  import type { StartForm } from "./registry-types.ts";
16
18
 
17
19
  export interface PiModelDescriptor { id: string; name?: string; contextWindow?: number; maxTokens?: number; reasoning?: boolean; }
@@ -41,6 +43,8 @@ export interface PiOptions {
41
43
  model?: string;
42
44
  relay: PiRelay; executeTool: (name: string, args: unknown, toolCallId: string, sessionId?: string, signal?: AbortSignal, context?: PiToolContext) => Promise<string | PiToolResult>; tools: PiToolSchema[];
43
45
  preamble?: string;
46
+ /** Host-owned conditional instruction, sampled once per delivered turn. */
47
+ titlePreface?: () => string | undefined;
44
48
  selectModel?: (envs: Envelope[]) => Promise<string | undefined>; maxSteps?: number;
45
49
  /** Atomic task/run admission immediately before provider requests or tool execution. */
46
50
  admitBudget?: (envs: Envelope[], unit: ExecutionUnit) => Promise<ExecutionBudgetDecision[]>;
@@ -113,6 +117,66 @@ export class PiPeer extends BasePeer {
113
117
  private currentReply?: Envelope;
114
118
  private sessionId = "";
115
119
  private sessionFile = "";
120
+ /** A read-only observation of Pi's native slot, never a second writable title store. */
121
+ private observedTitle: string | undefined;
122
+ private titleRevision = -1;
123
+ private titleEpoch = 0;
124
+ private nativeTitleEpoch: string | undefined;
125
+ get sessionTitle(): string | undefined { return this.acceptingTools && this.verifiedOwner() ? this.observedTitle : undefined; }
126
+ get titleSession(): string | undefined { return this.nativeSession; }
127
+ async setSessionTitle(title: string, onlyIfUnnamed: boolean, current?: () => boolean): Promise<SessionTitleResult> {
128
+ const invalid = (error: string, mayHaveApplied = false): SessionTitleResult => ({ ok: false, reason: "invalid", error, ...(mayHaveApplied ? { mayHaveApplied: true as const } : {}) });
129
+ if (typeof title !== "string" || !title.trim() || [...title].length > 80 || typeof onlyIfUnnamed !== "boolean") return invalid("Session title must contain 1-80 normalized characters and use a conditional-write flag; correct the title request and try again.");
130
+ if (this.stopping || this.state === "offline") return invalid("Pi session is offline or stopped; start or reconnect Pi and check ahub status before naming it.");
131
+ const session = this.sessionId, generation = this.budgetGeneration, owner = this.sessionGeneration, epoch = this.titleEpoch;
132
+ if (!owner || !this.verifiedOwner()) return invalid("Pi native session owner is unverified; reconnect the retained Pi session and check ahub status before naming it.");
133
+ if (!this.acceptingTools) return invalid("Pi session is detached or running a personal turn; reconnect its current hub session and check ahub status before naming it.");
134
+ const valid = () => {
135
+ try { return this.acceptingTools && this.verifiedOwner() && this.sessionGeneration === owner && this.sessionId === session && this.budgetGeneration === generation && this.titleEpoch === epoch && current?.() !== false; }
136
+ catch { return false; }
137
+ };
138
+ if (!valid()) return invalid("Pi session, turn or title admission changed before dispatch; check ahub status before retrying in the current public session.");
139
+ let dispatched = false;
140
+ try {
141
+ const result = await this.sendTui({ type: "set_session_title", title, onlyIfUnnamed, sessionId: session, generation, ...(this.nativeTitleEpoch ? { titleEpoch: this.nativeTitleEpoch } : {}) }, valid, () => { dispatched = true; });
142
+ if (!valid()) return invalid("Pi session, turn or title admission changed after dispatch; the title may have been applied; check ahub status before retrying.", true);
143
+ if (result?.ok === false && result.reason === "invalid") {
144
+ if (result.cause === "session") return invalid("Pi native session changed before the title write; check ahub status and reconnect the current session before retrying.");
145
+ if (result.cause === "generation") return invalid("Pi native turn changed before the title write; wait for the current turn and check ahub status before retrying.");
146
+ if (result.cause === "arguments") return invalid("Pi rejected invalid title command arguments; use a 1-80 character normalized title and retry in the current session.");
147
+ if (result.cause === "native_unavailable") return invalid("Pi native title API is unavailable; restart Pi with the supported hub extension and check ahub status before retrying.");
148
+ if (result.cause === "native_failed") return invalid("Pi native title API failed without confirming the write; the title may have been applied; check ahub status before retrying.", true);
149
+ if (result.cause === "native_readback") return invalid("Pi native title readback did not confirm the write; the title may have been applied; check ahub status before retrying.", true);
150
+ }
151
+ if (result?.sessionId !== session || typeof result.sessionName !== "string" || this.nativeTitleEpoch !== undefined && result.titleEpoch !== this.nativeTitleEpoch) return invalid("Pi title acknowledgement did not confirm the current native slot; the title may have been applied; check ahub status before retrying.", true);
152
+ this.observeTitle(result.sessionName, result.titleRevision, result.titleEpoch);
153
+ if (result.ok === false && result.reason === "already_named") return { ok: false, reason: "already_named", error: "This session already has a title; keep it or ask the person to rename it with ahub title." };
154
+ if (result.ok === true && result.sessionName === title) return { ok: true };
155
+ return invalid("Pi native title write or readback was not confirmed; the title may have been applied; check ahub status before retrying.", true);
156
+ } catch (error) {
157
+ const timeout = error instanceof Error && error.message.includes("acknowledgement timed out");
158
+ if (dispatched) return invalid(`Pi title ${timeout ? "acknowledgement timed out" : "bridge command failed after dispatch"}; the title may have been applied; check ahub status before retrying.`, true);
159
+ return invalid(`Pi title ${timeout ? "command expired" : "admission changed or bridge failed"} before dispatch; check ahub status before retrying in the current session.`);
160
+ }
161
+ }
162
+ private observeTitle(value: unknown, revision?: unknown, epoch?: unknown): void {
163
+ if (this.nativeTitleEpoch !== undefined && epoch !== this.nativeTitleEpoch) return;
164
+ if (typeof revision === "number" && Number.isSafeInteger(revision) && revision >= 0) {
165
+ if (revision < this.titleRevision) return;
166
+ this.titleRevision = revision;
167
+ }
168
+ if (value === undefined) value = ""; // A verified native session omits its name while unnamed.
169
+ if (typeof value !== "string") return;
170
+ if (value) this.noteActivity(); // Native session_info is history even before Pi creates its session file.
171
+ if (this.observedTitle !== value) { this.observedTitle = value; this.onDoing?.("title", value); }
172
+ }
173
+ private observeSnapshotTitle(snapshot: { sessionName?: unknown; titleRevision?: unknown; titleEpoch?: unknown }, before: number, beforeEpoch: number): boolean {
174
+ if (this.titleEpoch !== beforeEpoch || snapshot.titleEpoch !== undefined && snapshot.titleEpoch !== this.nativeTitleEpoch) return false;
175
+ if (snapshot.titleRevision === undefined && this.titleRevision !== before) return false;
176
+ if (typeof snapshot.titleRevision === "number" && snapshot.titleRevision < this.titleRevision) return false;
177
+ this.observeTitle(snapshot.sessionName, snapshot.titleRevision, snapshot.titleEpoch ?? this.nativeTitleEpoch);
178
+ return true;
179
+ }
116
180
  private executionBudgetTimer?: ReturnType<typeof setTimeout>;
117
181
  private executionAbort?: AbortController;
118
182
  private readonly budgetStops = new Map<number, string>();
@@ -164,7 +228,7 @@ export class PiPeer extends BasePeer {
164
228
  private terminalOwnerObservation?: TerminalOwnerObservation;
165
229
  private tuiExit?: Promise<void>;
166
230
  private resolveTuiExit?: () => void;
167
- private tuiCommands = new Map<string, { command: Record<string, unknown>; resolve: (value: any) => void; reject: (error: Error) => void }>();
231
+ private tuiCommands = new Map<string, { command: Record<string, unknown>; current?: () => boolean; onDispatch?: () => void; resolve: (value: any) => void; reject: (error: Error) => void }>();
168
232
  private tuiQueue: Record<string, unknown>[] = [];
169
233
  private tuiWaiters: Array<{ resolve: (command: Record<string, unknown> | undefined) => void; timer: ReturnType<typeof setTimeout> }> = [];
170
234
 
@@ -219,14 +283,16 @@ export class PiPeer extends BasePeer {
219
283
  this.emptyResumeVerified = false;
220
284
  if (this.state !== "idle" || this.stopping || this.activeTools) throw new Error("Pi must settle before session capture");
221
285
  if (this.sessionFile && existsSync(this.sessionFile)) return this.recoveryMetadata();
286
+ const titleRevisionBefore = this.titleRevision, titleEpochBefore = this.titleEpoch;
222
287
  const snapshot = this.opts.mode === "headless"
223
288
  ? (await this.waitRpc("get_state", 15_000)).data
224
289
  : await this.sendTui({ type: "get_session_state" });
225
290
  if (this.state !== "idle" || this.activeTools || snapshot?.sessionId !== this.sessionId || snapshot?.sessionFile !== this.sessionFile) throw new Error("Pi source session changed during capture");
291
+ if (!this.observeSnapshotTitle(snapshot, titleRevisionBefore, titleEpochBefore)) throw new Error("Pi source session title changed during capture; retry with a fresh native session snapshot");
226
292
  const empty = this.opts.mode === "headless"
227
293
  ? snapshot.messageCount === 0 && snapshot.pendingMessageCount === 0 && snapshot.isStreaming === false && snapshot.isCompacting !== true && !snapshot.sessionName
228
294
  : snapshot.empty === true && snapshot.idle === true;
229
- if (!empty || this.activityObserved || this.persistedOnce) throw new Error("Pi session history is not persisted; refusing to recreate it");
295
+ if (!empty || this.activityObserved || this.persistedOnce) throw new Error("Pi session history is not persisted; refusing to recreate it. Exchange one message or start a new session, then retry");
230
296
  this.emptyResumeVerified = true;
231
297
  const captured = this.recoveryMetadata();
232
298
  if (typeof captured.sessionId === "string" && !captured.sessionFile) this.verifiedEmptyResume = { sessionId: captured.sessionId };
@@ -265,6 +331,35 @@ export class PiPeer extends BasePeer {
265
331
  this.starting = this.startImpl();
266
332
  try { await this.starting; } finally { this.starting = undefined; }
267
333
  }
334
+ /** The keepalive period of a long tool reply; well under every timer between the hub and Pi's runtime (60 s Bun idle, 300 s undici). A test shortens it. */
335
+ toolKeepaliveMs = 15_000;
336
+ /**
337
+ * A tool reply that may take minutes (`hub_ask`): the status line and headers go out at once, a space is written every `toolKeepaliveMs`, and the JSON body
338
+ * follows when the call settles. Whitespace before a JSON value is valid JSON, so the extension's `response.json()` reads it unchanged. When the client goes away
339
+ * the stream is cancelled and the call is aborted through its own signal with `QuestionWaitDetached`, so the wait ends `pending` and the row stays open for the late envelope;
340
+ * nothing else about `handleToolRequest` changes.
341
+ */
342
+ private streamedToolResponse(body: any, clientGone: AbortSignal): Response {
343
+ const encoder = new TextEncoder();
344
+ let timer: ReturnType<typeof setInterval> | undefined, closed = false;
345
+ const stop = () => { closed = true; if (timer) clearInterval(timer); timer = undefined; };
346
+ return new Response(new ReadableStream({
347
+ start: controller => {
348
+ timer = setInterval(() => { if (!closed) try { controller.enqueue(encoder.encode(" ")); } catch { stop(); } }, this.toolKeepaliveMs);
349
+ timer.unref?.();
350
+ const abort = () => { stop(); this.pendingToolGenerations.get(String(body?.toolCallId ?? ""))?.abort.abort(new QuestionWaitDetached()); };
351
+ clientGone.addEventListener("abort", abort, { once: true });
352
+ void this.handleToolRequest(body).then(async response => {
353
+ const text = await response.text();
354
+ if (closed) return;
355
+ stop();
356
+ try { controller.enqueue(encoder.encode(text)); controller.close(); } catch { /* the client already left */ }
357
+ }, () => { if (closed) return; stop(); try { controller.enqueue(encoder.encode(JSON.stringify({ text: "error: Pi tool call failed", failed: true }))); controller.close(); } catch { /* the client already left */ } });
358
+ },
359
+ cancel: () => { stop(); this.pendingToolGenerations.get(String(body?.toolCallId ?? ""))?.abort.abort(new QuestionWaitDetached()); },
360
+ }), { headers: { "content-type": "application/json" } });
361
+ }
362
+
268
363
  private async handleToolRequest(body: any): Promise<Response> {
269
364
  if (!this.acceptingTools || this.state === "offline") return Response.json({ text: this.rosterDetached ? "error: Pi is off roster" : this.rosterUnownedRun ? "error: Pi native turn is pending" : "error: Pi owner is stopped" }, { status: 409 });
270
365
  if (!this.verifiedOwner() || typeof body.sessionId !== "string" || !body.sessionId || body.sessionId !== this.sessionId || !Number.isSafeInteger(body.generation) || body.generation !== this.budgetGeneration) {
@@ -388,6 +483,7 @@ export class PiPeer extends BasePeer {
388
483
  if (this.preflight) await this.preflight();
389
484
  this.terminalOwnerObservation = undefined;
390
485
  this.stopping = false;
486
+ this.observedTitle = undefined; this.titleRevision = -1; this.nativeTitleEpoch = undefined; this.onDoing?.("reset");
391
487
  this.rosterDetached = false; this.rosterUnownedRun = false;
392
488
  this.requestedStopReason = undefined;
393
489
  this.approvalTurnAbort = false;
@@ -429,7 +525,18 @@ export class PiPeer extends BasePeer {
429
525
  : boundRefusal && refusal.kind === "prompt_uncertain" ? new PiPromptUncertain("Pi native prompt insertion is uncertain; inspect its retained terminal before retrying") : new Error(String(body.error ?? "Pi TUI command failed")));
430
526
  return Response.json({ ok: true });
431
527
  }
432
- if (url.pathname === "/tool") return this.handleToolRequest(body);
528
+ if (url.pathname === "/tool") {
529
+ // #1053: `hub_ask` holds this request open for the person's wait window. Two timers would cut it: Bun's 60 s bridge idle timeout (lifted for this one call with
530
+ // `timeout(request, 0)`) and the extension's own fetch, whose undici dispatcher Pi installs with headersTimeout and bodyTimeout of 300 s (Pi 1.4.0
531
+ // `configureHttpDispatcher`, measured: UND_ERR_HEADERS_TIMEOUT). So the reply is streamed: headers go out at once and a space every 15 s keeps both timers
532
+ // quiet, then the JSON follows (`response.json()` ignores leading whitespace). A client that gives up anyway cancels the stream, which aborts the call; the
533
+ // question row stays open and its answer takes the late-envelope path.
534
+ if (body?.name === "hub_ask") {
535
+ bridge.timeout(request, 0);
536
+ return this.streamedToolResponse(body, request.signal);
537
+ }
538
+ return this.handleToolRequest(body);
539
+ }
433
540
  if (url.pathname === "/budget") return this.handleBudgetRequest(body);
434
541
  return new Response("not found", { status: 404 });
435
542
  } });
@@ -456,6 +563,7 @@ export class PiPeer extends BasePeer {
456
563
  this.fail(new Error(`pi exited with ${signal ? `signal ${signal}` : `code ${code}`}`));
457
564
  this.reportExit(exit);
458
565
  });
566
+ const titleRevisionBefore = this.titleRevision, titleEpochBefore = this.titleEpoch;
459
567
  const state = await this.waitRpc("get_state", 15_000);
460
568
  if (state.success !== true || typeof state.data?.sessionId !== "string" || typeof state.data?.sessionFile !== "string" || !state.data.sessionId || !state.data.sessionFile) throw new Error("Pi get_state did not prove session identity");
461
569
  if ((this.opts.sessionId && state.data.sessionId !== this.opts.sessionId) || (this.opts.sessionFile && state.data.sessionFile !== this.opts.sessionFile)) {
@@ -472,6 +580,7 @@ export class PiPeer extends BasePeer {
472
580
  }
473
581
  this.persistedOnce = existsSync(this.sessionFile);
474
582
  if ((this.opts.sessionId && this.sessionId !== this.opts.sessionId) || (this.opts.sessionFile && this.sessionFile !== this.opts.sessionFile)) throw new Error("Pi startup session identity mismatch");
583
+ this.observeSnapshotTitle(state.data, titleRevisionBefore, titleEpochBefore);
475
584
  this.started = true;
476
585
  this.setState("idle");
477
586
  }
@@ -542,9 +651,11 @@ export class PiPeer extends BasePeer {
542
651
  if (!this.fencedPrompt) throw new Error("Pi roster return needs a fenced-prompt extension; restart the retained terminal with the current hub extension");
543
652
  if (this.pendingPromptId) throw new Error("Pi prompt admission is still pending; wait before reverifying the retained terminal");
544
653
  const epoch = this.rosterEpoch, lifecycle = this.rosterLifecycleRevision, session = this.sessionId, file = this.sessionFile;
654
+ const titleRevisionBefore = this.titleRevision, titleEpochBefore = this.titleEpoch;
545
655
  const snapshot = await this.sendTui({ type: "get_session_state" });
546
656
  if (epoch !== this.rosterEpoch || this.stopping || !this.verifiedOwner() || this.activeTools || snapshot?.sessionId !== session || snapshot?.sessionFile !== file || typeof snapshot?.idle !== "boolean") throw new Error("Pi roster return could not verify the current native session");
547
657
  if (lifecycle !== this.rosterLifecycleRevision) throw new Error("Pi native activity changed during roster return; retry with a fresh session snapshot");
658
+ this.observeSnapshotTitle(snapshot, titleRevisionBefore, titleEpochBefore);
548
659
  this.rosterDetached = false;
549
660
  this.hubPromptId = undefined; this.hubPromptGeneration = undefined;
550
661
  this.rosterUnownedRun = !snapshot.idle; this.agentRunning = !snapshot.idle;
@@ -552,12 +663,12 @@ export class PiPeer extends BasePeer {
552
663
  if (!snapshot.idle) this.clearWatchdog();
553
664
  }
554
665
 
555
- async stop(reason?: string): Promise<void> {
666
+ async stop(reason?: string, failureCause?: DeliveryFailureCause): Promise<void> {
556
667
  if (reason !== undefined) this.requestedStopReason = reason;
557
668
  this.rosterEpoch++;
558
669
  this.terminalOwnerObservation = undefined;
559
670
  this.stopping = true; // fence new calls before withdrawing both turn and idle-shell execution
560
- for (const id of this.activeDeliveryIds) this.delivery({ id, state: "needs_review", reason: reason ?? "Pi session stopped before settlement" });
671
+ for (const id of this.activeDeliveryIds) this.delivery({ id, state: "needs_review", reason: reason ?? "Pi session stopped before settlement", ...(failureCause ? { failureCause } : {}) });
561
672
  this.activeDeliveryIds.clear();
562
673
  const settling = this.abortTools();
563
674
  let unsettled: unknown;
@@ -650,8 +761,9 @@ export class PiPeer extends BasePeer {
650
761
  this.noteActivity();
651
762
  attempted = true;
652
763
  if (deliveryId) this.activeDeliveryIds.add(deliveryId);
653
- if (this.opts.mode === "tui") { const pending = this.sendTui({ type: "prompt", message: renderDigest(envs, true) }); promptId = this.pendingPromptId; await pending; }
654
- else await this.sendRpc({ type: "prompt", message: renderDigest(envs, true) });
764
+ const message = [envs.some(env => env.private || remoteEnvelope(env)) ? undefined : this.opts.titlePreface?.(), renderDigest(envs, true)].filter(Boolean).join("\n");
765
+ if (this.opts.mode === "tui") { const pending = this.sendTui({ type: "prompt", message }); promptId = this.pendingPromptId; await pending; }
766
+ else await this.sendRpc({ type: "prompt", message });
655
767
  if (promptId !== undefined && promptId !== this.pendingPromptId) return;
656
768
  if (this.fencedPrompt && this.rosterDetached) {
657
769
  if (deliveryId && this.activeDeliveryIds.delete(deliveryId)) this.delivery({ id: deliveryId, state: "needs_review", reason: "Pi native activity changed before prompt acceptance was confirmed" });
@@ -780,10 +892,15 @@ export class PiPeer extends BasePeer {
780
892
  this.sessionId = String(event.sessionId ?? ""); this.sessionFile = String(event.sessionFile ?? "");
781
893
  this.ownerPid = Number.isInteger(event.pid) ? event.pid : undefined; this.ownerSignature = typeof event.signature === "string" ? event.signature : undefined;
782
894
  if ((this.opts.sessionId && this.sessionId !== this.opts.sessionId) || (this.opts.sessionFile && this.sessionFile !== this.opts.sessionFile)) { this.opts.log?.(`[${this.id}] Pi session identity mismatch`); return; }
895
+ this.titleRevision = -1; this.titleEpoch++;
896
+ this.nativeTitleEpoch = typeof event.titleEpoch === "string" && event.titleEpoch ? event.titleEpoch : undefined;
897
+ this.observeTitle(event.sessionName, event.titleRevision, event.titleEpoch);
783
898
  this.rememberTerminalOwner();
784
899
  this.startOwnerMonitor(); if (this.opts.mode === "tui") { this.started = true; this.setState("idle"); }
785
900
  }
901
+ if ((event.type === "session_title" || event.type === "agent_start" && Object.hasOwn(event, "sessionName")) && event.sessionId === this.sessionId && this.verifiedOwner()) this.observeTitle(event.sessionName, event.titleRevision, event.titleEpoch);
786
902
  if (event.type === "session_shutdown") {
903
+ this.observedTitle = undefined; this.titleRevision = -1; this.nativeTitleEpoch = undefined; this.onDoing?.("reset");
787
904
  const exit = this.exitMetadata("session_shutdown");
788
905
  this.shutdownExit ??= exit; // headless waits for the OS exit, retaining the pre-cleanup turn and stop facts
789
906
  this.stopping = true; this.ownerClaimed = false; this.clearOwnerMonitor(); this.resolveTuiExit?.(); this.resolveTuiExit = undefined;
@@ -872,10 +989,17 @@ export class PiPeer extends BasePeer {
872
989
  while ((index = this.buffer.indexOf("\n")) >= 0) { const line = this.buffer.slice(0, index).replace(/\r$/, ""); this.buffer = this.buffer.slice(index + 1); if (!line) continue; try { this.handleRpc(JSON.parse(line) as RpcMessage); } catch (error) { this.opts.log?.(`[${this.id}] invalid Pi RPC output: ${(error as Error).message}`); } }
873
990
  }
874
991
  private nextTuiCommand(timeout: number): Promise<Record<string, unknown> | undefined> {
875
- const next = this.tuiQueue.shift(); if (next) return Promise.resolve(next);
992
+ let next = this.tuiQueue.shift();
993
+ while (next) {
994
+ const pending = this.tuiCommands.get(String(next.id));
995
+ if (pending && pending.current?.() !== false) { pending.onDispatch?.(); return Promise.resolve(next); }
996
+ if (pending) { this.tuiCommands.delete(String(next.id)); pending.reject(new Error("Pi session title admission changed before dispatch")); }
997
+ next = this.tuiQueue.shift();
998
+ }
876
999
  return new Promise((resolvePromise) => { const entry = { resolve: resolvePromise, timer: setTimeout(() => { const i = this.tuiWaiters.indexOf(entry); if (i >= 0) this.tuiWaiters.splice(i, 1); resolvePromise(undefined); }, timeout) }; this.tuiWaiters.push(entry); });
877
1000
  }
878
- private sendTui(command: Record<string, unknown>): Promise<any> {
1001
+ private sendTui(command: Record<string, unknown>, current?: () => boolean, onDispatch?: () => void): Promise<any> {
1002
+ if (current?.() === false) return Promise.reject(new Error("Pi session title admission changed before dispatch"));
879
1003
  const id = `ahub-${++this.seq}`;
880
1004
  const fenced = this.fencedPrompt && command.type === "prompt";
881
1005
  if (fenced) { this.pendingPromptId = id; this.hubPromptId = id; this.hubPromptGeneration = undefined; }
@@ -886,9 +1010,9 @@ export class PiPeer extends BasePeer {
886
1010
  this.tuiQueue = this.tuiQueue.filter((queued) => queued.id !== id);
887
1011
  reject(new Error(`Pi TUI ${String(command.type)} acknowledgement timed out`));
888
1012
  }, 30_000);
889
- this.tuiCommands.set(id, { command: wire, resolve: (value) => { clearTimeout(timer); resolvePromise(value); }, reject: (error) => { clearTimeout(timer); reject(error); } });
1013
+ this.tuiCommands.set(id, { command: wire, current, onDispatch, resolve: (value) => { clearTimeout(timer); resolvePromise(value); }, reject: (error) => { clearTimeout(timer); reject(error); } });
890
1014
  const waiter = this.tuiWaiters.shift();
891
- if (waiter) { clearTimeout(waiter.timer); waiter.resolve(wire); } else this.tuiQueue.push(wire);
1015
+ if (waiter) { clearTimeout(waiter.timer); onDispatch?.(); waiter.resolve(wire); } else this.tuiQueue.push(wire);
892
1016
  });
893
1017
  }
894
1018
  private handleRpc(message: RpcMessage): void { if (this.acceptingTools && ["message_update", "tool_execution_update"].includes(message.type ?? "") && this.state === "busy") this.touch(); if (message.type === "agent_settled") return; // The authenticated extension is the single lifecycle source.
@@ -965,11 +1089,11 @@ export class PiPeer extends BasePeer {
965
1089
  await this.terminateFailedTurn(new ApprovalWaitStop(reason));
966
1090
  }
967
1091
 
968
- private async terminateFailedTurn(error: Error): Promise<void> {
1092
+ private async terminateFailedTurn(error: Error, failureCause?: DeliveryFailureCause): Promise<void> {
969
1093
  const envs = this.activeEnvs.slice(), reply = this.currentReply;
970
1094
  this.stopping = true;
971
1095
  try {
972
- await this.stop();
1096
+ await this.stop(undefined, failureCause);
973
1097
  // The process exit handler may already have reported this failure.
974
1098
  if (this.activeEnvs.length) this.fail(error);
975
1099
  } catch (stopError) {
@@ -987,7 +1111,7 @@ export class PiPeer extends BasePeer {
987
1111
  protected override onWatchdog(): void {
988
1112
  if (this.stopping || this.rosterDetached || this.rosterUnownedRun) return;
989
1113
  if (this.activeTools) { this.touch(); return; }
990
- void this.terminateFailedTurn(new Error("Pi run became inactive before agent_settled"));
1114
+ void this.terminateFailedTurn(new Error("Pi run became inactive before agent_settled"), "timeout");
991
1115
  }
992
1116
  }
993
1117
 
@@ -81,6 +81,10 @@ export interface CreateContext<Ports extends DaemonPorts = DaemonPorts> {
81
81
  daemon: Ports;
82
82
  }
83
83
 
84
+ /** `native-stop`: Claude's transcript-verified Stop. `turn-completed`: Codex's `turn/completed` with no active turn. `agent-settled`: Pi's own settled event. `route-outcome`: the local worker's per-decision outcome. `prompt-response`: an ACP `session/prompt` response. `message-stop`: Qwen's TUI `message_stop`. */
85
+ export type SettleSignal = "native-stop" | "turn-completed" | "agent-settled" | "route-outcome" | "prompt-response" | "message-stop";
86
+ export const SETTLE_SIGNALS: readonly SettleSignal[] = Object.freeze(["native-stop", "turn-completed", "agent-settled", "route-outcome", "prompt-response", "message-stop"]);
87
+
84
88
  export interface StartRequest {
85
89
  instance: string;
86
90
  form?: StartForm;
@@ -204,6 +208,18 @@ export interface AdapterDefinition {
204
208
  conductorStartArgs?(args: string[]): { mode?: StartForm } | Error;
205
209
  };
206
210
  identity: { mcp: "acp-session" | "project-file" | "env" | "channel-plugin"; currentChild?: true };
211
+ /** Who owns the session title; only hub-owned slots receive hub_set_title. */
212
+ title?: "native" | "hub";
213
+ /** Fixed builtins only: the title slot survives native-session restoration. Absent means ephemeral. */
214
+ titlePersistence?: "native";
215
+ /** #1053: how long a `hub_ask` call waits for the person before it answers `pending` (milliseconds). MCP peers 45 s, hub-native peers 300 s; config `questions.wait_ms.<kind>` overrides it. Fixed builtins only. */
216
+ questionWaitMs?: number;
217
+ /**
218
+ * #1054: the code-verified signal that ends a peer's logical run, declared per definition. ABSENT means the adapter creates no completion: nothing is guessed (owner
219
+ * decision 2026-11-04). Fixed builtins only (`assertNativeCapabilityScope`): a project or external definition cannot claim one. A definition with a pull-only form (Kimi's TUI)
220
+ * declares it for its headless form only, because the daemon starts no run for a peer that attached pull-only.
221
+ */
222
+ settle?: { signal: SettleSignal };
207
223
  /** External module data-only opt-in. The verified loader derives the internal instances policy from it (#754). */
208
224
  headlessInstances?: { form: "headless"; roles: readonly ("implementer" | "verifier" | "reviewer")[] };
209
225
  /** Declaration admission only; execution-root and runtime owner proof remain host responsibilities. */
@@ -239,6 +239,10 @@ export function assertNativeCapabilityScope(definition: AdapterDefinition): void
239
239
  if (isFixedBuiltinDefinition(definition)) return;
240
240
  const declared = (block: object | undefined, key: string): boolean => block !== undefined && key in block;
241
241
  const restricted: [string, boolean][] = [
242
+ ["title", definition.title !== undefined],
243
+ ["titlePersistence", definition.titlePersistence !== undefined],
244
+ ["questionWaitMs", definition.questionWaitMs !== undefined],
245
+ ["settle", definition.settle !== undefined],
242
246
  ["start.acpCommand", declared(definition.start, "acpCommand")],
243
247
  ["attach.admission", declared(definition.attach, "admission")],
244
248
  ["budget.resetSoftTokensOnResume", declared(definition.budget, "resetSoftTokensOnResume")],
@@ -1,8 +1,9 @@
1
1
  import type { ChildProcess } from "node:child_process";
2
- import { OwnedPrintChild, PrintChildCleanupError } from "./claude-print-child.ts";
2
+ import { OwnedPrintChild, PrintChildCleanupError, type OwnedInputContext, type OwnedInputObserver } from "./claude-print-child.ts";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { HUB, frame, sanitize } from "../hub/envelope.ts";
5
5
  import { BasePeer } from "../hub/peers.ts";
6
+ import type { DeliveryFailureCause } from "../hub/delivery-failure.ts";
6
7
  import type { Envelope } from "../hub/envelope.ts";
7
8
  import type { ReviewerMcpSession, ReviewerToolName } from "./reviewer-mcp.ts";
8
9
 
@@ -31,6 +32,7 @@ export class ReviewerUnavailableError extends Error {
31
32
  class ReviewerAdmissionError extends Error {
32
33
  constructor(readonly reason: "stale_binding" | "policy_changed") { super("review admission changed"); }
33
34
  }
35
+ class ReviewerInputAdmissionError extends Error {}
34
36
 
35
37
  export interface ReviewerPrintUsage { input_tokens?: number; output_tokens?: number; cache_creation_input_tokens?: number; cache_read_input_tokens?: number }
36
38
  export interface ReviewerIsolationReceipt {
@@ -136,6 +138,13 @@ export interface ReviewerPrintOptions {
136
138
  /** Synchronous host task/privacy/policy fence, checked again after awaited preparation. */
137
139
  admit?(env: Envelope): boolean;
138
140
  bindingCurrent?(task: number, binding: ReviewerReviewBinding): boolean;
141
+ /** Private-profile observer input provenance only; never a native resume or budget identity. */
142
+ beforeInput?: OwnedInputObserver;
143
+ inputReleased?: OwnedInputObserver;
144
+ /** Host privacy state for the currently attached reviewer input, read only for diagnostics. */
145
+ reviewObserverHeld?(): boolean;
146
+ /** Runs after this fresh child group, task MCP and launch cleanup are confirmed closed. */
147
+ inputFinished?: OwnedInputObserver;
139
148
  }
140
149
 
141
150
  type ReviewTarget = Omit<ReviewerFailure, "cause">;
@@ -166,9 +175,13 @@ export class ReviewerPrintPeer extends BasePeer {
166
175
  private batch: ReviewBatch | undefined;
167
176
  private preparation: { abort: AbortController; done: Promise<ReviewerLaunch> } | undefined;
168
177
  private receipt: ReviewerIsolationReceipt | undefined;
178
+ private observerSessionIdValue: string | undefined;
169
179
  get isolationReceipt(): ReviewerIsolationReceipt | undefined { return this.receipt; }
180
+ reviewObserverInputHeld(): boolean { return this.opts.reviewObserverHeld?.() ?? false; }
170
181
  /** The task whose review the running child was started for (R4: the hub records `fresh` for its verdict). */
171
182
  reviewing: number | undefined;
183
+ /** Privacy-only identity for a fresh review profile; never part of resume or budget authority. */
184
+ get observerSessionId(): string | undefined { return this.observerSessionIdValue; }
172
185
  private reviewSeq = 0;
173
186
  private runningBinding: { task: number; binding: ReviewerReviewBinding } | undefined;
174
187
  private activeTrace: { task: number; generation: number; value: ReviewerTraceState } | undefined;
@@ -234,16 +247,16 @@ export class ReviewerPrintPeer extends BasePeer {
234
247
  this.setState("offline");
235
248
  }
236
249
 
237
- protected override onWatchdog(): void { this.abortReview("review watchdog timeout"); }
250
+ protected override onWatchdog(): void { this.abortReview("review watchdog timeout", "timeout"); }
238
251
 
239
- private abortReview(reason: "review watchdog timeout" | "review output limit exceeded" | "review policy changed"): void {
252
+ private abortReview(reason: "review watchdog timeout" | "review output limit exceeded" | "review policy changed", failureCause?: DeliveryFailureCause): void {
240
253
  const generation = ++this.generation, batch = this.batch, preparation = this.preparation;
241
254
  this.reviewing = undefined;
242
255
  this.reviewMcpClose?.();
243
256
  this.receipt = undefined;
244
257
  this.clearWatchdog();
245
258
  preparation?.abort.abort();
246
- this.settle("needs_review", reason);
259
+ this.settle("needs_review", reason, failureCause);
247
260
  // Revoke first, then confirm both preparation and the model's whole group are gone before fallback.
248
261
  void (async () => {
249
262
  if (preparation) await preparation.done.catch(() => {});
@@ -288,6 +301,11 @@ export class ReviewerPrintPeer extends BasePeer {
288
301
  try { result = await this.review(env, task, generation); }
289
302
  catch (error) {
290
303
  if (generation !== this.generation) return;
304
+ if (error instanceof ReviewerInputAdmissionError) {
305
+ failure = { state: started ? "needs_review" : "failed_safe", reason: "review input provenance unavailable" };
306
+ batch.next = envs.length;
307
+ break;
308
+ }
291
309
  if (error instanceof ReviewerAdmissionError) {
292
310
  this.opts.log?.(`[${this.id}] review of task #${task} skipped: ${error.reason === "stale_binding" ? "stale binding" : "review policy changed"}`);
293
311
  if (error.reason === "policy_changed") {
@@ -395,18 +413,30 @@ export class ReviewerPrintPeer extends BasePeer {
395
413
  let facts: string | undefined;
396
414
  try { facts = this.opts.facts?.(env); } catch (error) { this.opts.log?.(`[${this.id}] review facts for task #${task} failed: ${(error as Error).message}`); }
397
415
  const input = `${frame(env)}${facts ? `\n\n${facts}` : ""}`;
416
+ const sessionId = randomUUID();
417
+ const inputContext: OwnedInputContext = Object.freeze({ sessionId, inputId: randomUUID() });
418
+ this.observerSessionIdValue = sessionId;
419
+ try { this.opts.beforeInput?.(inputContext, [env]); }
420
+ catch { this.observerSessionIdValue = undefined; throw new ReviewerInputAdmissionError(); }
398
421
  let launch: ReviewerLaunch | undefined;
399
422
  const trace: ReviewerTraceState = { reviewCalled: false };
400
423
  this.activeTrace = { task, generation, value: trace };
401
424
  const mcp = this.opts.backend.taskMcp ? this.opts.taskMcp?.(task, () => generation === this.generation && this.reviewToken(task) !== undefined && this.opts.bindingCurrent?.(task,binding) !== false) : undefined;
402
- let mcpClosed=false;
403
- const closeMcp=()=>{ if(mcpClosed)return; mcpClosed=true; mcp?.close(); if(this.reviewMcpClose===closeMcp)this.reviewMcpClose=undefined; };
425
+ let mcpClosed = false, mcpCloseConfirmed = false;
426
+ const closeMcp = () => {
427
+ if (mcpClosed) return;
428
+ mcpClosed = true;
429
+ try { mcp?.close(); mcpCloseConfirmed = true; }
430
+ catch { this.opts.log?.(`[${this.id}] reviewer task MCP cleanup incomplete`); }
431
+ if (this.reviewMcpClose === closeMcp) this.reviewMcpClose = undefined;
432
+ };
404
433
  this.reviewMcpClose=closeMcp;
405
434
  let ownedChild: OwnedPrintChild | undefined, releasing: Promise<void> | undefined;
435
+ let inputReleased = false, childClosed = false, launchCleanupConfirmed = false;
406
436
  let policyTimer: ReturnType<typeof setInterval> | undefined;
407
437
  const release = () => releasing ??= Promise.resolve().then(() => launch?.cleanup?.()).then(() => {});
408
438
  try {
409
- launch = await this.prepare(randomUUID(), input, task, mcp?.config);
439
+ launch = await this.prepare(sessionId, input, task, mcp?.config);
410
440
  if (generation !== this.generation) throw new ReviewerAdmissionError("stale_binding");
411
441
  this.assertAdmission(env, task, binding);
412
442
  if (launch.current?.() === false) throw new ReviewerUnavailableError("reviewer isolation changed before child spawn", "binary");
@@ -415,6 +445,7 @@ export class ReviewerPrintPeer extends BasePeer {
415
445
  const child = new OwnedPrintChild({ peer: this.id, command: launch.command, args: launch.args,
416
446
  cwd: launch.cwd ?? this.opts.cwd, env: launch.env, envMode: launch.envMode, input,
417
447
  current: () => generation === this.generation, stop: launch.stop,
448
+ onInputReleased: () => { inputReleased = true; this.opts.inputReleased?.(inputContext, [env]); },
418
449
  onError: () => { if (this.child === child) this.child = undefined; },
419
450
  onSpawn: () => {
420
451
  this.runningBinding={task,binding};
@@ -441,21 +472,29 @@ export class ReviewerPrintPeer extends BasePeer {
441
472
  this.childCleanup = release;
442
473
  this.childCurrent = launch.watchCurrent ? launch.current : undefined;
443
474
  const code = await child.done;
475
+ childClosed = true;
444
476
  if (this.child === child) this.child = undefined;
445
477
  return { code, quota, trace };
446
478
  } finally {
447
479
  if (this.activeTrace?.value === trace) this.activeTrace = undefined;
448
480
  closeMcp();
449
481
  if (policyTimer) clearInterval(policyTimer);
450
- try { if (!ownedChild || this.child !== ownedChild) { await release(); if (this.childCleanup === release) this.childCleanup = undefined; } }
451
- catch { this.opts.log?.(`[${this.id}] reviewer isolation cleanup incomplete`); }
482
+ try {
483
+ if (!ownedChild || this.child !== ownedChild) {
484
+ await release(); if (this.childCleanup === release) this.childCleanup = undefined;
485
+ launchCleanupConfirmed = true;
486
+ }
487
+ } catch { this.opts.log?.(`[${this.id}] reviewer isolation cleanup incomplete`); }
488
+ const closedWithoutChild = !ownedChild || ownedChild.process.pid === undefined;
489
+ if (mcpCloseConfirmed && launchCleanupConfirmed && (closedWithoutChild || inputReleased && childClosed))
490
+ try { this.opts.inputFinished?.(inputContext, [env]); } catch { /* receipt cleanup never changes the review verdict */ }
452
491
  }
453
492
  }
454
493
 
455
- private settle(state: "completed" | "failed_safe" | "needs_review", reason?: string): void {
494
+ private settle(state: "completed" | "failed_safe" | "needs_review", reason?: string, failureCause?: DeliveryFailureCause): void {
456
495
  const id = this.activeDeliveryId;
457
496
  this.activeDeliveryId = undefined;
458
- if (id) this.delivery({ id, state, ...(reason ? { reason } : {}) });
497
+ if (id) this.delivery({ id, state, ...(reason ? { reason } : {}), ...(failureCause ? { failureCause } : {}) });
459
498
  }
460
499
 
461
500
  /** Revokes the verdict authority at once, then stops the child; on failure the handle is kept and the error thrown. */
@@ -25,6 +25,7 @@ export interface BrokerOptions {
25
25
  interface Client {
26
26
  ws?: ServerWebSocket<Client>;
27
27
  room: string;
28
+ ephemeral: boolean;
28
29
  challenge: BrokerChallenge;
29
30
  phase: "challenge" | "authenticating" | "roster" | "ready" | "closed";
30
31
  busy: boolean;
@@ -35,6 +36,9 @@ interface Client {
35
36
  rosterDigest?: string;
36
37
  rosterDeadline?: number;
37
38
  inFlight?: string;
39
+ inFlightEphemeral?: boolean;
40
+ inFlightExpiresAt?: number;
41
+ inFlightEpoch?: string;
38
42
  }
39
43
  export interface BrokerRuntime {
40
44
  readonly port: number;
@@ -104,8 +108,12 @@ export async function startBroker(options: BrokerOptions, ports: BrokerPorts = {
104
108
  function close(client: Client, code: string): void {
105
109
  if (client.phase === "closed") return;
106
110
  client.phase = "closed";
111
+ client.inFlight = undefined; client.inFlightEphemeral = undefined; client.inFlightExpiresAt = undefined; client.inFlightEpoch = undefined;
107
112
  queuedBytes -= client.inboxBytes; client.inboxBytes = 0; client.inbox.length = 0;
108
- if (client.member && members.get(memberKey(client)) === client) members.delete(memberKey(client));
113
+ if (client.member && members.get(memberKey(client)) === client) {
114
+ members.delete(memberKey(client));
115
+ try { store.disconnect(client.room, client.member); } catch { emit("refused", client, "state_unavailable"); }
116
+ }
109
117
  if (client.ws) { try { client.ws.close(4403, code); } catch { /* already closed */ } }
110
118
  else clients.delete(client);
111
119
  }
@@ -117,9 +125,9 @@ export async function startBroker(options: BrokerOptions, ports: BrokerPorts = {
117
125
  close(client, "backpressure"); return false;
118
126
  }
119
127
  const control = (client: Client, value: unknown) => send(client, canonicalBytes(value));
120
- function refused(client: Client, code: string, terminal = true): void {
128
+ function refused(client: Client, code: string, terminal = true, digest?: string): void {
121
129
  emit("refused", client, code);
122
- control(client, { v: 1, type: "error", code });
130
+ control(client, { v: 1, type: "error", code, ...(code === "queue_full" && digest ? { digest } : {}) });
123
131
  if (terminal) close(client, code);
124
132
  }
125
133
  function active(client: Client): VerifiedPublicRoster {
@@ -133,16 +141,29 @@ export async function startBroker(options: BrokerOptions, ports: BrokerPorts = {
133
141
  const roster = active(client);
134
142
  client.phase = "roster"; client.rosterDigest = roster.digest;
135
143
  client.rosterDeadline = now() + BROKER_SOCKET_LIMITS.rosterAckMs;
136
- if (send(client, publicRosterWire(roster))) control(client, { v: 1, type: "ready", room: client.room, member: client.member, epoch: roster.epoch, digest: roster.digest });
144
+ // Legacy hubs have a closed ready grammar; advertise only after HTTP opt-in.
145
+ if (send(client, publicRosterWire(roster))) control(client, { v: 1, type: "ready", room: client.room, member: client.member, epoch: roster.epoch, digest: roster.digest, ...(client.ephemeral ? { ephemeral: true } : {}) });
137
146
  }
138
147
  function pump(client: Client): void {
139
148
  if (stopping || client.phase === "closed" || !client.member) return;
140
- active(client);
149
+ const roster = active(client);
141
150
  const next = store.next(client.room, client.member);
142
- if (client.inFlight && next?.digest === client.inFlight) return;
143
- client.inFlight = undefined; // the previous packet may have expired or been purged.
151
+ if (client.inFlightEphemeral && client.inFlightEpoch !== roster.epoch) {
152
+ const previous = store.current(client.room)?.previous;
153
+ client.inFlightExpiresAt = previous && previous.roster.epoch === client.inFlightEpoch
154
+ ? Math.min(client.inFlightExpiresAt ?? 0, previous.expiresAt) : 0;
155
+ }
156
+ // Replacement retires an ephemeral queue row, not the packet already handed
157
+ // to this socket. Keep its slot until ACK or its original TTL/grace deadline.
158
+ if (client.inFlight && (client.inFlightEphemeral && now() < (client.inFlightExpiresAt ?? 0) || next?.digest === client.inFlight)) return;
159
+ client.inFlight = undefined; client.inFlightEphemeral = undefined; client.inFlightExpiresAt = undefined; client.inFlightEpoch = undefined; // durable expiry/purge still releases its slot.
144
160
  if (client.phase !== "ready" || !next) return;
145
- if (send(client, next.wire)) client.inFlight = next.digest;
161
+ if (send(client, next.wire)) {
162
+ client.inFlight = next.digest;
163
+ const frame = decodeFrame(next.wire);
164
+ client.inFlightEphemeral = frame.ephemeral === true;
165
+ client.inFlightExpiresAt = next.expiresAt; client.inFlightEpoch = frame.epoch;
166
+ }
146
167
  }
147
168
  function safePump(client: Client): void {
148
169
  try { pump(client); } catch (error) { refused(client, errorCode(error)); }
@@ -195,9 +216,19 @@ export async function startBroker(options: BrokerOptions, ports: BrokerPorts = {
195
216
  const selected = state && (header.epoch === state.current.epoch ? state.current : state.previous?.roster);
196
217
  if (!selected) throw new BrokerServerError("wrong_epoch");
197
218
  const proof = await (ports.verifyFrame ?? verifyPublicFrame)({ wire: bytes, roster: selected });
198
- active(client);
199
- const result = store.enqueue(proof, client.member!); // latest membership/grace/caps checked transactionally.
200
- control(client, { v: 1, type: "stored", digest: result.digest, seq: proof.seq, duplicate: result.duplicate });
219
+ const onlineRecipients = proof.recipients.filter(recipient => {
220
+ const target = members.get(`${client.room}|${recipient}`);
221
+ return target?.phase === "ready" && (!proof.ephemeral || target.ephemeral);
222
+ });
223
+ try {
224
+ active(client);
225
+ const result = store.enqueue(proof, client.member!, onlineRecipients); // latest membership/grace/caps checked transactionally.
226
+ control(client, { v: 1, type: "stored", digest: result.digest, seq: proof.seq, duplicate: result.duplicate });
227
+ } catch (error) {
228
+ if (errorCode(error) !== "queue_full") throw error;
229
+ refused(client, "queue_full", false, client.ephemeral ? proof.digest : undefined);
230
+ return;
231
+ }
201
232
  for (const recipient of proof.recipients) {
202
233
  const target = members.get(`${client.room}|${recipient}`);
203
234
  if (target) safePump(target);
@@ -215,7 +246,7 @@ export async function startBroker(options: BrokerOptions, ports: BrokerPorts = {
215
246
  if (packet.digest !== client.inFlight) { refused(client, "unexpected_ack", false); return; }
216
247
  active(client);
217
248
  store.ack(client.room, client.member!, packet.digest);
218
- client.inFlight = undefined;
249
+ client.inFlight = undefined; client.inFlightEphemeral = undefined; client.inFlightExpiresAt = undefined; client.inFlightEpoch = undefined;
219
250
  control(client, { v: 1, type: "queue_ack", digest: packet.digest });
220
251
  safePump(client);
221
252
  }
@@ -259,7 +290,7 @@ export async function startBroker(options: BrokerOptions, ports: BrokerPorts = {
259
290
  const room = roomId(url.pathname.slice("/v1/rooms/".length));
260
291
  if (clients.size >= BROKER_SOCKET_LIMITS.connections) return new Response("busy", { status: 503 });
261
292
  const current = store.current(room)?.current;
262
- const client: Client = { room, challenge: { v: 1, type: "challenge", room, broker: publicUrl, nonce: encodeBase64url(randomBytes(32)), expiresAt: now() + BROKER_SOCKET_LIMITS.authMs, rosterDigest: current?.digest ?? null }, phase: "challenge", busy: false, inbox: [], inboxBytes: 0 };
293
+ const client: Client = { room, ephemeral: req.headers.get("x-agent-hub-ephemeral") === "1", challenge: { v: 1, type: "challenge", room, broker: publicUrl, nonce: encodeBase64url(randomBytes(32)), expiresAt: now() + BROKER_SOCKET_LIMITS.authMs, rosterDigest: current?.digest ?? null }, phase: "challenge", busy: false, inbox: [], inboxBytes: 0 };
263
294
  clients.add(client);
264
295
  if (host.upgrade(req, { data: client })) return;
265
296
  clients.delete(client); return new Response("websocket required", { status: 400 });