@staix/agent-hub 0.30.0 → 0.32.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 (130) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +13 -7
  3. package/docs/agent-notes/README.md +1 -0
  4. package/docs/agent-notes/adapters.md +5 -2
  5. package/docs/agent-notes/budget.md +1 -0
  6. package/docs/agent-notes/bus.md +11 -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 +7 -3
  10. package/docs/agent-notes/tests.md +6 -4
  11. package/docs/design/adapters.md +52 -0
  12. package/docs/design/agent-hub-architecture.md +46 -0
  13. package/docs/design/bus.md +178 -1
  14. package/docs/design/task-board.md +82 -0
  15. package/docs/dogfood.md +23 -4
  16. package/docs/events.md +3 -0
  17. package/docs/memory.md +17 -1
  18. package/docs/operations.md +355 -78
  19. package/docs/profiles.md +35 -0
  20. package/docs/quickstart.md +24 -5
  21. package/docs/security.md +178 -60
  22. package/docs/specs/2026-09-19-agent-hub-design.md +127 -14
  23. package/docs/specs/2026-09-19-multi-project-design.md +7 -2
  24. package/docs/verification/2026-11-04-1171-channel-client-evidence.md +31 -0
  25. package/docs/verification/2026-11-05-0.31.0.md +182 -0
  26. package/docs/verification/2026-11-05-claude-implementer-2.1.318.md +70 -0
  27. package/docs/verification/2026-11-05-hardcoded-values-classification-rules.md +311 -0
  28. package/docs/verification/2026-11-05-hardcoded-values-ratchet.md +55 -0
  29. package/docs/verification/2026-11-06-0.32.0.md +149 -0
  30. package/docs/verification/2026-11-06-rooms-presence-cost.md +152 -0
  31. package/docs/verification/claude-shell-wrapper-native.py +1 -1
  32. package/docs/verified.json +14 -14
  33. package/package.json +3 -4
  34. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  35. package/plugins/agent-hub/server.js +4393 -3609
  36. package/src/adapters/acp.ts +5 -4
  37. package/src/adapters/channel-peer.ts +72 -3
  38. package/src/adapters/claude-channel-evidence.ts +52 -0
  39. package/src/adapters/claude-channel.ts +61 -18
  40. package/src/adapters/claude-implementer.ts +29 -7
  41. package/src/adapters/claude-print-child.ts +12 -2
  42. package/src/adapters/codex-appserver.ts +13 -12
  43. package/src/adapters/compat.ts +3 -3
  44. package/src/adapters/definitions/builtin-metadata.ts +13 -13
  45. package/src/adapters/definitions/qwen.ts +1 -0
  46. package/src/adapters/local-worker.ts +61 -18
  47. package/src/adapters/pi.ts +48 -6
  48. package/src/adapters/registry-types.ts +12 -0
  49. package/src/adapters/registry.ts +2 -0
  50. package/src/adapters/reviewer-print.ts +50 -11
  51. package/src/broker/server.ts +44 -13
  52. package/src/broker/store.ts +38 -8
  53. package/src/cli/answer-args.ts +50 -0
  54. package/src/cli/attention.ts +17 -2
  55. package/src/cli/backlog-return-args.ts +37 -0
  56. package/src/cli/console-question.ts +110 -0
  57. package/src/cli/console-state.ts +78 -17
  58. package/src/cli/console.ts +54 -1
  59. package/src/cli/handoff-args.ts +15 -5
  60. package/src/cli/handoff-output.ts +15 -2
  61. package/src/cli/help.ts +8 -5
  62. package/src/cli/identity.ts +13 -3
  63. package/src/cli/main.ts +128 -16
  64. package/src/cli/memory.ts +107 -3
  65. package/src/cli/output.ts +29 -6
  66. package/src/cli/pet.ts +4 -4
  67. package/src/cli/results-args.ts +30 -0
  68. package/src/cli/room.ts +20 -10
  69. package/src/cli/runtime-version-warning.ts +50 -0
  70. package/src/cli/status-lines.ts +41 -1
  71. package/src/cli/tail-render.ts +1 -0
  72. package/src/cli/upgrade-interactive.ts +14 -2
  73. package/src/hub/action-class.ts +9 -1
  74. package/src/hub/board.ts +369 -18
  75. package/src/hub/bus.ts +511 -53
  76. package/src/hub/completion-detail.ts +50 -0
  77. package/src/hub/completions-store.ts +168 -0
  78. package/src/hub/completions.ts +120 -0
  79. package/src/hub/config-apply.ts +7 -5
  80. package/src/hub/config-reload.ts +10 -1
  81. package/src/hub/control-client.ts +40 -4
  82. package/src/hub/daemon.ts +1576 -139
  83. package/src/hub/delivery-failure.ts +14 -0
  84. package/src/hub/delivery-hints.ts +14 -4
  85. package/src/hub/delivery-journal.ts +751 -42
  86. package/src/hub/delivery-superseded.ts +4 -1
  87. package/src/hub/envelope.ts +3 -3
  88. package/src/hub/events.ts +6 -0
  89. package/src/hub/handoff.ts +145 -6
  90. package/src/hub/hub-tools.ts +56 -7
  91. package/src/hub/manager.ts +59 -3
  92. package/src/hub/memory-policy.ts +44 -1
  93. package/src/hub/memory-sync-policy.ts +109 -0
  94. package/src/hub/memory-sync-state.ts +145 -0
  95. package/src/hub/no-writes.ts +1 -1
  96. package/src/hub/operational-settings.ts +11 -2
  97. package/src/hub/operational-view.ts +12 -1
  98. package/src/hub/peers.ts +25 -2
  99. package/src/hub/planning.ts +6 -1
  100. package/src/hub/question-desk.ts +201 -0
  101. package/src/hub/question-detach.ts +6 -0
  102. package/src/hub/questions.ts +260 -0
  103. package/src/hub/research-evidence.ts +106 -0
  104. package/src/hub/restart.ts +14 -0
  105. package/src/hub/review-anchor.ts +181 -0
  106. package/src/hub/review-brief.ts +13 -0
  107. package/src/hub/review-comments.ts +115 -0
  108. package/src/hub/review-findings.ts +7 -2
  109. package/src/hub/review-material.ts +76 -0
  110. package/src/hub/routing.ts +3 -1
  111. package/src/hub/task-sweep.ts +2 -0
  112. package/src/hub/tasks.ts +577 -63
  113. package/src/memory/capture.ts +5 -2
  114. package/src/pet/config.ts +25 -13
  115. package/src/rooms/commands.ts +61 -16
  116. package/src/rooms/link.ts +338 -36
  117. package/src/rooms/liveness.ts +11 -0
  118. package/src/rooms/remote-peer.ts +186 -19
  119. package/src/rooms/service.ts +475 -35
  120. package/src/rooms/status.ts +8 -1
  121. package/src/rooms/store.ts +447 -65
  122. package/src/rooms/wire.ts +24 -6
  123. package/src/ui/index.html +142 -9
  124. package/src/ui/needs.ts +10 -4
  125. package/src/ui/plugin-questions.ts +56 -0
  126. package/src/ui/plugin-results.ts +81 -0
  127. package/src/ui/plugin.ts +207 -27
  128. package/src/pet/window.html +0 -114
  129. package/src/pet/window.jxa.js +0 -122
  130. package/src/pet/window.ts +0 -154
@@ -241,6 +241,8 @@ export function assertNativeCapabilityScope(definition: AdapterDefinition): void
241
241
  const restricted: [string, boolean][] = [
242
242
  ["title", definition.title !== undefined],
243
243
  ["titlePersistence", definition.titlePersistence !== undefined],
244
+ ["questionWaitMs", definition.questionWaitMs !== undefined],
245
+ ["settle", definition.settle !== undefined],
244
246
  ["start.acpCommand", declared(definition.start, "acpCommand")],
245
247
  ["attach.admission", declared(definition.attach, "admission")],
246
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 });
@@ -303,6 +303,8 @@ export class BrokerStore {
303
303
  fail("corrupt");
304
304
  }
305
305
  }
306
+ // No recipient is connected when a broker opens. Ephemeral presence never survives that boundary.
307
+ this.transaction(() => { this.removeEphemeral(); });
306
308
  // Recover only structurally valid public/ciphertext queue rows, then enforce the current caps.
307
309
  this.sweep();
308
310
  const stats = this.stats();
@@ -358,7 +360,7 @@ export class BrokerStore {
358
360
  return { changed: true, revoked };
359
361
  }
360
362
 
361
- enqueue(proof: VerifiedPublicFrame, authorizedSender: string): { digest: string; duplicate: boolean } {
363
+ enqueue(proof: VerifiedPublicFrame, authorizedSender: string, onlineRecipients: readonly string[] = []): { digest: string; duplicate: boolean } {
362
364
  this.assertOpen();
363
365
  const wire = checkedProofFrame(proof);
364
366
  if (authorizedSender !== proof.sender) fail("untrusted");
@@ -402,16 +404,24 @@ export class BrokerStore {
402
404
  }
403
405
  if (order < 0n) fail("conflict");
404
406
  }
407
+ if (proof.ephemeral) {
408
+ // ponytail: while A awaits C, A's broadcast reply to B may replace C's request;
409
+ // C waits for A's next state change/reconnect. Task #736 idle refresh or
410
+ // per-(sender,recipient) coalescing closes this latest-per-sender ceiling.
411
+ this.removeEphemeral(proof.room, proof.sender);
412
+ const connected = new Set(onlineRecipients);
413
+ recipients = recipients.filter(recipient => connected.has(recipient));
414
+ }
405
415
  const counts = this.db.query<{ count: number; bytes: number }, []>("SELECT COUNT(*) AS count, COALESCE(SUM(length(wire)),0) AS bytes FROM queued_frames").get();
406
416
  const room = this.db.query<{ count: number; bytes: number }, [string]>("SELECT COUNT(*) AS count, COALESCE(SUM(length(wire)),0) AS bytes FROM queued_frames WHERE room=?").get(proof.room);
407
- if (byteLength > this.limits.packetBytes || byteLength > this.limits.queuedBytesPerRoom || (room?.count ?? 0) >= this.limits.queuedPerRoom ||
417
+ if (byteLength > this.limits.packetBytes || byteLength > this.limits.queuedBytesPerRoom || recipients.length > 0 && ((room?.count ?? 0) >= this.limits.queuedPerRoom ||
408
418
  (room?.bytes ?? 0) + byteLength > this.limits.queuedBytesPerRoom || (counts?.count ?? 0) >= this.limits.queuedGlobal ||
409
- (counts?.bytes ?? 0) + byteLength > this.limits.packetBytes) fail("full");
419
+ (counts?.bytes ?? 0) + byteLength > this.limits.packetBytes)) fail("full");
410
420
  const expiresAt = writeNow + this.limits.queueTtlMs;
411
421
  if (!Number.isSafeInteger(expiresAt)) fail("invalid");
412
422
  this.db.query("INSERT INTO sender_floors(room,sender,seq,digest) VALUES(?,?,?,?) ON CONFLICT(room,sender) DO UPDATE SET seq=excluded.seq,digest=excluded.digest")
413
423
  .run(proof.room, proof.sender, proof.seq, proof.digest);
414
- this.db.query("INSERT INTO queued_frames(room,sender,seq,digest,wire,recipients,created_at,expires_at,epoch) VALUES(?,?,?,?,?,?,?,?,?)")
424
+ if (recipients.length) this.db.query("INSERT INTO queued_frames(room,sender,seq,digest,wire,recipients,created_at,expires_at,epoch) VALUES(?,?,?,?,?,?,?,?,?)")
415
425
  .run(proof.room, proof.sender, proof.seq, proof.digest, wire, JSON.stringify(recipients), writeNow, expiresAt, proof.epoch);
416
426
  result = { digest: proof.digest, duplicate: false };
417
427
  });
@@ -423,7 +433,24 @@ export class BrokerStore {
423
433
  return fail("invalid");
424
434
  }
425
435
 
426
- next(room: string, recipient: string): { digest: string; wire: Uint8Array } | undefined {
436
+ /** A disconnected member never retains ephemeral presence, while durable work stays queued. */
437
+ disconnect(room: string, recipient: string): void {
438
+ this.transaction(() => { this.removeEphemeral(room, undefined, recipient); });
439
+ }
440
+
441
+ private removeEphemeral(room?: string, sender?: string, recipient?: string): void {
442
+ const rows = this.db.query<{ rowid: number; wire: Uint8Array; recipients: string }, [string | null, string | null, string | null, string | null]>(
443
+ "SELECT rowid,wire,recipients FROM queued_frames WHERE (? IS NULL OR room=?) AND (? IS NULL OR sender=?)",
444
+ ).all(room ?? null, room ?? null, sender ?? null, sender ?? null);
445
+ for (const row of rows) {
446
+ if (!decodeFrame(row.wire).ephemeral) continue;
447
+ const remaining = recipient === undefined ? [] : parseRecipients(row.recipients).filter(id => id !== recipient);
448
+ if (remaining.length) this.db.query("UPDATE queued_frames SET recipients=? WHERE rowid=?").run(JSON.stringify(remaining), row.rowid);
449
+ else this.db.query("DELETE FROM queued_frames WHERE rowid=?").run(row.rowid);
450
+ }
451
+ }
452
+
453
+ next(room: string, recipient: string): { digest: string; wire: Uint8Array; expiresAt: number } | undefined {
427
454
  this.assertOpen(); let now = 0;
428
455
  let state = this.rooms.get(room);
429
456
  if (!state) return undefined;
@@ -431,13 +458,16 @@ export class BrokerStore {
431
458
  if (state) this.rooms.set(room, state);
432
459
  if (!state?.current.members.some(member => member.fingerprint === recipient)) return undefined;
433
460
  const previous = state.previous && state.previous.expiresAt > now ? state.previous.roster.epoch : state.current.epoch;
434
- const rows = this.db.query<{ id: number; digest: string; recipients: string }, [string, number, string, string]>(
435
- "SELECT rowid AS id,digest,recipients FROM queued_frames WHERE room=? AND expires_at>? AND epoch IN (?,?) ORDER BY rowid",
461
+ const rows = this.db.query<{ id: number; digest: string; recipients: string; epoch: string; expiresAt: number }, [string, number, string, string]>(
462
+ "SELECT rowid AS id,digest,recipients,epoch,expires_at AS expiresAt FROM queued_frames WHERE room=? AND expires_at>? AND epoch IN (?,?) ORDER BY rowid",
436
463
  ).all(room, now, state.current.epoch, previous);
437
464
  for (const row of rows) {
438
465
  if (!parseRecipients(row.recipients).includes(recipient)) continue;
439
466
  const packet = this.db.query<{ wire: Uint8Array }, [number]>("SELECT wire FROM queued_frames WHERE rowid=?").get(row.id);
440
- if (packet?.wire instanceof Uint8Array) return { digest: row.digest, wire: new Uint8Array(packet.wire) };
467
+ if (packet?.wire instanceof Uint8Array) {
468
+ const expiresAt = row.epoch === state.current.epoch ? row.expiresAt : Math.min(row.expiresAt, state.previous!.expiresAt);
469
+ return { digest: row.digest, wire: new Uint8Array(packet.wire), expiresAt };
470
+ }
441
471
  }
442
472
  return undefined;
443
473
  }
@@ -0,0 +1,50 @@
1
+ import { QUESTION_SET } from "../hub/questions.ts";
2
+
3
+ /**
4
+ * #1053 T4: `ahub answer <set> <question>=<option>[,<option>]... [--other <text>]...`. Pure: it turns argv into the `answers` list the hub validates
5
+ * against the stored row, and never decides whether an answer is valid (the store does, with a reason that names a question by position).
6
+ *
7
+ * `--other <text>` is the free text of the one question named, or `--other <question>=<text>` when several are named; a question named with no options
8
+ * (`extras=`) answers with its other text alone. The question ids are the agent's, bounded by the same pattern the store uses.
9
+ */
10
+
11
+ const ID = /^[A-Za-z0-9_.-]{1,64}$/;
12
+ export interface ParsedAnswer { set: string; answers: { questionId: string; optionIds: string[]; other?: string }[] }
13
+ export type AnswerArgs = { ok: true; value: ParsedAnswer } | { ok: false; error: string };
14
+ export const ANSWER_USAGE = "usage: ahub answer <set> <question>=<option>[,<option>] [--other <text>] (ahub answer lists the open questions)";
15
+
16
+ export function parseAnswerArgs(args: readonly string[]): AnswerArgs {
17
+ const fail = (error: string): { ok: false; error: string } => ({ ok: false, error });
18
+ const [set, ...rest] = args;
19
+ if (!set || !QUESTION_SET.test(set)) return fail(`${ANSWER_USAGE}; the set looks like qs-0123456789ab`);
20
+ const picks = new Map<string, string[]>(), others: string[] = [];
21
+ for (let index = 0; index < rest.length; index++) {
22
+ const word = rest[index]!;
23
+ if (word === "--other" || word.startsWith("--other=")) {
24
+ const value = word === "--other" ? rest[++index] : word.slice("--other=".length);
25
+ if (value === undefined || value === "") return fail("--other needs the text to give");
26
+ others.push(value);
27
+ continue;
28
+ }
29
+ if (word.startsWith("--")) return fail(`${ANSWER_USAGE}; unknown flag ${word.split("=")[0]}`);
30
+ const at = word.indexOf("=");
31
+ if (at < 1) return fail(`${ANSWER_USAGE}; "${word.slice(0, 40)}" is not <question>=<option>`);
32
+ const question = word.slice(0, at);
33
+ if (!ID.test(question)) return fail("a question id is 1 to 64 characters of letters, digits, dot, dash or underscore");
34
+ if (picks.has(question)) return fail(`question ${question} is answered twice`);
35
+ const options = word.slice(at + 1).split(",").filter(Boolean);
36
+ if (options.some(option => !ID.test(option))) return fail(`question ${question}: an option id is 1 to 64 characters of letters, digits, dot, dash or underscore`);
37
+ picks.set(question, options);
38
+ }
39
+ if (!picks.size) return fail(`${ANSWER_USAGE}; name at least one question`);
40
+ const other = new Map<string, string>();
41
+ for (const text of others) {
42
+ const at = text.indexOf("=");
43
+ const named = at > 0 && picks.has(text.slice(0, at)) ? text.slice(0, at) : undefined;
44
+ const target = named ?? (picks.size === 1 ? [...picks.keys()][0]! : undefined);
45
+ if (target === undefined) return fail("with several questions, give --other <question>=<text>");
46
+ if (other.has(target)) return fail(`question ${target} has two --other texts`);
47
+ other.set(target, named ? text.slice(at + 1) : text);
48
+ }
49
+ return { ok: true, value: { set, answers: [...picks].map(([questionId, optionIds]) => ({ questionId, optionIds, ...(other.has(questionId) ? { other: other.get(questionId)! } : {}) })) } };
50
+ }
@@ -10,11 +10,15 @@ export interface AttentionState {
10
10
  recoveryNeed?: { id?: string; running: boolean; since?: number };
11
11
  queue?: readonly { id: string; peer?: string; state: string }[];
12
12
  attentionFacts?: { kind: "review" | "offline" | "stall" | "offer"; id: number; peer?: string }[];
13
+ /** #1053: open questions; only the set, the peer and the time reach the model. */
14
+ questions?: readonly { set: string; peer: string; created: number }[];
15
+ /** #1054: unread results from the hub (ids, peers and settle times only). */
16
+ results?: readonly { id: string; peer?: string; since?: number }[];
13
17
  }
14
18
  /** Allowlisted adapter between console-owned facts and the shared body-free model. */
15
19
  export function consoleNeeds(state: AttentionState, now: number): Needs {
16
- const input: NeedsInput = { approvals: state.approvals.map(row => ({ id: row.id, peer: row.peer, expiresAt: row.expiresAt })), defaults: state.permissionDefaults.map(row => ({ peer: row.peer })), peers: Object.fromEntries(Object.entries(state.peers).map(([peer,row]) => [peer,{ state: row.state, attached: row.attached, needsReview: row.needsReview, heldBy: row.heldBy }])), budget: Object.fromEntries(Object.entries(state.budget).map(([peer,row]) => [peer,{ ...(row.paused ? { paused: { since: row.paused.since, resetsAt: row.paused.resetsAt } } : {}) }])), messages: (state.attentionMessages ?? []).map(row => ({ id: row.id, peer: row.peer, important: row.important, since: row.since })), storageBlocked: state.storageBlocked, recovery: state.recoveryNeed };
17
- const result = needs({ ...input, facts: state.attentionFacts }, now);
20
+ const input: NeedsInput = { approvals: state.approvals.map(row => ({ id: row.id, peer: row.peer, expiresAt: row.expiresAt })), defaults: state.permissionDefaults.map(row => ({ peer: row.peer })), peers: Object.fromEntries(Object.entries(state.peers).map(([peer,row]) => [peer,{ state: row.state, attached: row.attached, needsReview: row.needsReview, heldBy: row.heldBy }])), budget: Object.fromEntries(Object.entries(state.budget).map(([peer,row]) => [peer,{ ...(row.paused ? { paused: { since: row.paused.since, resetsAt: row.paused.resetsAt } } : {}) }])), messages: (state.attentionMessages ?? []).map(row => ({ id: row.id, peer: row.peer, important: row.important, since: row.since })), storageBlocked: state.storageBlocked, recovery: state.recoveryNeed, questions: (state.questions ?? []).map(row => ({ set: row.set, peer: row.peer, since: row.created })) };
21
+ const result = needs({ ...input, facts: state.attentionFacts, results: state.results }, now);
18
22
  // Panel snapshots know each held row; enrich only ids, without changing the status-derived count.
19
23
  const used = new Set<string | number>();
20
24
  for (const item of result.items) if (item.kind === "delivery") {
@@ -50,7 +54,17 @@ export function attentionFacts(raw: unknown): NonNullable<AttentionState["attent
50
54
  });
51
55
  }
52
56
 
57
+ /** The unread results in the authenticated status reply's attention items: a `result` item at level `watch` with a completion id, never any text. */
58
+ export function attentionResults(raw: unknown): NonNullable<AttentionState["results"]> {
59
+ if (!Array.isArray(raw)) return [];
60
+ return raw.flatMap<NonNullable<AttentionState["results"]>[number]>(row => {
61
+ if (!row || typeof row !== "object" || row.kind !== "result" || row.level !== "watch" || typeof row.id !== "string" || !/^cmp-[a-f0-9]{12}$/.test(row.id)) return [];
62
+ return [{ id: row.id, ...(typeof row.peer === "string" && /^[A-Za-z0-9_.:-]{1,128}$/.test(row.peer) ? { peer: row.peer } : {}), ...(Number.isSafeInteger(row.since) && row.since >= 0 ? { since: row.since } : {}) }];
63
+ });
64
+ }
65
+
53
66
  export function attentionCommand(item: NeedItem): string {
67
+ if (item.kind === "result") return `ahub results${item.id ? ` --read ${item.id}` : ""}`;
54
68
  if (item.kind === "offer") return `open ${item.peer}: task #${item.id} is waiting for it; ahub task show ${item.id}`;
55
69
  if (item.kind === "stall") return `ahub task show ${item.id}; configure the peer or ahub task assign ${item.id} <peer>, or ahub task cancel ${item.id} --reason <text>`;
56
70
  if (item.kind === "delivery") return `ahub queue list${item.id ? `; ahub queue show ${item.id}` : ""}`;
@@ -60,5 +74,6 @@ export function attentionCommand(item: NeedItem): string {
60
74
  if (item.kind === "recovery") return `ahub recovery status${item.id ? ` ${item.id}` : ""}`;
61
75
  if (item.kind === "budget") return "ahub budget";
62
76
  if (item.kind === "default") return "ahub permission";
77
+ if (item.kind === "question") return `A answers it here; ahub answer lists it and ${item.id ? `ahub answer ${item.id} <question>=<option>` : "ahub answer <set> <question>=<option>"} answers`;
63
78
  return item.peer ? `ahub say @${item.peer} <message>` : "ahub console";
64
79
  }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * #1177: `ahub backlog return <task> <revision> <reason...> [--reserved <peer> | --unreserve]`. Pure: argv to the `hub_backlog_return_untouched` arguments, and never a
3
+ * decision about whether the return applies (the hub checks authority, the revision and the reservation). With neither flag the arguments are exactly what the command
4
+ * sent before, so today's behaviour is unchanged.
5
+ *
6
+ * The flags are the two reservation edits the tool already takes as `fields.reserved` (a peer id, or null to clear). They are read wherever they stand among the reason's
7
+ * words, are removed from the reason, and at most one of them may be given: a reason is free text, so the only way to tell a flag from a word of it is that it is one of
8
+ * these exact tokens. `--reserved=<peer>` is the same as `--reserved <peer>`.
9
+ */
10
+ const PEER = /^[a-z][a-z0-9-]{0,31}$/;
11
+ export type BacklogReturn = { ok: true; value: { reason: string; fields?: { reserved: string | null } } } | { ok: false; error: string };
12
+ export const BACKLOG_RETURN_USAGE = "usage: ahub backlog return <task> <revision> <reason...> [--reserved <peer> | --unreserve]";
13
+
14
+ export function parseBacklogReturn(words: readonly string[]): BacklogReturn {
15
+ const fail = (error: string): { ok: false; error: string } => ({ ok: false, error: `${BACKLOG_RETURN_USAGE}; ${error}` });
16
+ const reason: string[] = [];
17
+ let reserved: string | null | undefined;
18
+ for (let index = 0; index < words.length; index++) {
19
+ const word = words[index]!;
20
+ if (word === "--unreserve") {
21
+ if (reserved !== undefined) return fail("give one of --reserved and --unreserve, once");
22
+ reserved = null;
23
+ continue;
24
+ }
25
+ if (word === "--reserved" || word.startsWith("--reserved=")) {
26
+ if (reserved !== undefined) return fail("give one of --reserved and --unreserve, once");
27
+ const peer = word === "--reserved" ? words[++index] : word.slice("--reserved=".length);
28
+ if (peer === undefined || !PEER.test(peer)) return fail("--reserved needs a peer id like codex");
29
+ reserved = peer;
30
+ continue;
31
+ }
32
+ reason.push(word);
33
+ }
34
+ const text = reason.join(" ").trim();
35
+ if (!text) return fail("a reason is required");
36
+ return { ok: true, value: { reason: text, ...(reserved !== undefined ? { fields: { reserved } } : {}) } };
37
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * #1053 T4: answering a question a peer asked with hub_ask, with the console's keys. Pure: the reducer passes it the open rows and a key, and it answers with the
3
+ * next flow state, the answers once the last question is done, or nothing. The hub validates every answer against the stored row; this only builds them.
4
+ *
5
+ * One question at a time, the oldest open set first: a digit picks that option (single choice) or toggles it (multi, Enter moves on), `o` types free text where the
6
+ * question allows it (Enter keeps it, Esc stops typing), Esc leaves the flow. Answering is neither outward nor destructive, so the last answer is sent with no confirm.
7
+ */
8
+ import { MAX_OTHER, type AskQuestion } from "../hub/questions.ts";
9
+
10
+ /** What the hub's `questions` read returns for one open set. The text is the person's: it is shown here and never logged. */
11
+ export interface QuestionView { set: string; peer: string; created: number; private?: boolean; questions: AskQuestion[] }
12
+ /** `picks` and `others` are Maps, never plain objects: a question id is the agent's (`constructor`, `toString` and `__proto__` are valid ids), and an object lookup would find an inherited member. */
13
+ export interface QuestionFlow { set: string; index: number; picks: Map<string, string[]>; others: Map<string, string>; typing?: string }
14
+ export interface FlowAnswer { questionId: string; optionIds: string[]; other?: string }
15
+ export interface FlowStep { flow?: QuestionFlow; answers?: FlowAnswer[]; notice?: string }
16
+
17
+ /** Rows from the hub's reply, copied by shape so a malformed row is dropped rather than rendered. */
18
+ export function questionRows(raw: unknown): QuestionView[] {
19
+ if (!Array.isArray(raw)) return [];
20
+ return raw.flatMap<QuestionView>(row => {
21
+ if (!row || typeof row !== "object" || typeof row.set !== "string" || typeof row.peer !== "string" || !Array.isArray(row.questions) || !row.questions.length) return [];
22
+ return [{ set: row.set, peer: row.peer, created: Number.isSafeInteger(row.created) ? row.created : 0, ...(row.private === true ? { private: true } : {}), questions: row.questions as AskQuestion[] }];
23
+ }).sort((a, b) => a.created - b.created || a.set.localeCompare(b.set));
24
+ }
25
+
26
+ export const startFlow = (row: QuestionView): QuestionFlow => ({ set: row.set, index: 0, picks: new Map(), others: new Map() });
27
+
28
+ const printable = (key: string) => key.length > 0 && !key.startsWith("\x1b") && ![...key].some(char => char < " " || char === "\x7f");
29
+
30
+ export function stepFlow(flow: QuestionFlow, row: QuestionView, key: string): FlowStep {
31
+ const question = row.questions[flow.index];
32
+ if (!question) return { notice: "that question has no more parts" };
33
+ const next: QuestionFlow = { ...flow, picks: new Map(flow.picks), others: new Map(flow.others) };
34
+ const advance = (): FlowStep => {
35
+ next.index = flow.index + 1; next.typing = undefined;
36
+ if (next.index < row.questions.length) return { flow: next };
37
+ return { answers: row.questions.map(item => ({ questionId: item.id, optionIds: next.picks.get(item.id) ?? [], ...(next.others.get(item.id) ? { other: next.others.get(item.id)! } : {}) })) };
38
+ };
39
+ if (flow.typing !== undefined) {
40
+ if (key === "\x1b") { next.typing = undefined; return { flow: next }; }
41
+ if (key === "\r" || key === "\n") {
42
+ const text = flow.typing.trim();
43
+ if (!text) { next.typing = undefined; return { flow: next }; }
44
+ next.others.set(question.id, text);
45
+ if (question.multi !== true) next.picks.set(question.id, []);
46
+ return advance();
47
+ }
48
+ if (key === "\x7f" || key === "\b") { next.typing = Array.from(flow.typing).slice(0, -1).join(""); return { flow: next }; }
49
+ if (printable(key)) next.typing = Array.from(flow.typing + key).slice(0, MAX_OTHER).join("");
50
+ return { flow: next };
51
+ }
52
+ if (key === "\x1b") return { notice: "answer cancelled; the question stays open" };
53
+ if (key === "o" && question.allowOther === true) { next.typing = ""; return { flow: next }; }
54
+ if (/^[1-9]$/.test(key)) {
55
+ const option = question.options[Number(key) - 1];
56
+ if (!option) return { flow: next };
57
+ if (question.multi === true) {
58
+ const chosen = new Set(next.picks.get(question.id) ?? []);
59
+ if (chosen.has(option.id)) chosen.delete(option.id); else chosen.add(option.id);
60
+ next.picks.set(question.id, question.options.filter(item => chosen.has(item.id)).map(item => item.id));
61
+ return { flow: next };
62
+ }
63
+ next.picks.set(question.id, [option.id]); next.others.delete(question.id);
64
+ return advance();
65
+ }
66
+ if ((key === "\r" || key === "\n") && question.multi === true && ((next.picks.get(question.id)?.length ?? 0) > 0 || next.others.get(question.id))) return advance();
67
+ return { flow: next };
68
+ }
69
+
70
+ /**
71
+ * A pasted chunk while the person is typing an `o` answer: its printable characters join the text, bounded as typed keys are. A paste is only ever text here: a newline
72
+ * in it is a space and an escape or control character is dropped, so a pasted Enter or Esc can never keep, send or leave the answer. Outside text entry it changes nothing.
73
+ */
74
+ export function pasteFlow(flow: QuestionFlow, pasted: string): QuestionFlow {
75
+ if (flow.typing === undefined) return flow;
76
+ const text = [...pasted.replace(/[\r\n]+/g, " ")].filter(char => printable(char)).join("");
77
+ return { ...flow, typing: Array.from(flow.typing + text).slice(0, MAX_OTHER).join("") };
78
+ }
79
+
80
+ /**
81
+ * The question as lines to show in a wrapped area, never the one-line footer: the head, the prompt, then every option numbered with the chosen ones marked. The footer
82
+ * clips a long line, and a digit submits at once, so an option that is not on screen must never be one a key can pick. `text` flattens agent text for a terminal.
83
+ */
84
+ export function flowBody(flow: QuestionFlow, row: QuestionView, text: (value: unknown) => string): { head: string; prompt: string; options: string[] } | undefined {
85
+ const question = row.questions[flow.index];
86
+ if (!question) return undefined;
87
+ const chosen = new Set(flow.picks.get(question.id) ?? []);
88
+ return {
89
+ head: `${row.peer} asks ${flow.index + 1}/${row.questions.length}`,
90
+ prompt: text(question.prompt),
91
+ options: question.options.map((option, index) => `${index + 1}${question.multi === true ? (chosen.has(option.id) ? "*" : " ") : ""} ${text(option.label)}`),
92
+ };
93
+ }
94
+
95
+ /**
96
+ * The footer line while a flow is open: only the keys that act, and the text being typed (its tail, so the cursor stays in view). It names no option and no prompt: those
97
+ * are in `flowBody`'s wrapped area, where they cannot be cut off.
98
+ */
99
+ export function flowPrompt(flow: QuestionFlow, row: QuestionView, text: (value: unknown) => string, columns = 80): string {
100
+ const question = row.questions[flow.index];
101
+ if (!question) return "";
102
+ const head = `${row.peer} asks ${flow.index + 1}/${row.questions.length}`;
103
+ if (flow.typing !== undefined) {
104
+ const tail = Array.from(text(flow.typing)), room = Math.max(8, columns - 32);
105
+ return `${head} other: ${tail.length > room ? "..." + tail.slice(-room).join("") : tail.join("")}_ Enter keeps Esc back`;
106
+ }
107
+ const chosen = new Set(flow.picks.get(question.id) ?? []);
108
+ const marked = question.multi === true ? question.options.flatMap((option, index) => chosen.has(option.id) ? [String(index + 1)] : []) : [];
109
+ return `${head} number picks${question.allowOther === true ? " o other" : ""}${question.multi === true ? `${marked.length ? ` chosen ${marked.join(",")}` : ""} Enter done` : ""} Esc cancel`;
110
+ }