@staix/agent-hub 0.12.22 → 0.12.24

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 (183) hide show
  1. package/CHANGELOG.md +117 -0
  2. package/README.md +22 -9
  3. package/docs/addons/claude-mem.md +24 -0
  4. package/docs/addons/codex-plugin.md +8 -0
  5. package/docs/addons/dot-admit.md +8 -0
  6. package/docs/addons/open-code-review.md +8 -0
  7. package/docs/addons/ponytail.md +23 -0
  8. package/docs/addons/ripwire.md +22 -0
  9. package/docs/agent-notes/README.md +5 -0
  10. package/docs/agent-notes/adapters.md +18 -6
  11. package/docs/agent-notes/benchmarks.md +3 -0
  12. package/docs/agent-notes/budget.md +4 -0
  13. package/docs/agent-notes/bus.md +3 -1
  14. package/docs/agent-notes/daemon.md +14 -1
  15. package/docs/agent-notes/local-worker.md +6 -3
  16. package/docs/agent-notes/tasks.md +6 -1
  17. package/docs/agent-notes/tests.md +4 -1
  18. package/docs/bench.md +28 -3
  19. package/docs/cooperbench.md +4 -0
  20. package/docs/data-policy.md +36 -0
  21. package/docs/events.md +10 -3
  22. package/docs/gsd-collaboration-analysis.md +48 -0
  23. package/docs/memory.md +231 -0
  24. package/docs/operations.md +873 -30
  25. package/docs/plans/2026-10-11-issues-109-sequential.md +81 -0
  26. package/docs/plans/2026-10-11-routing-evaluation-proposal.md +30 -0
  27. package/docs/profiles.md +57 -0
  28. package/docs/quickstart.md +9 -4
  29. package/docs/research.md +31 -1
  30. package/docs/roles.md +85 -0
  31. package/docs/sdlc.md +170 -0
  32. package/docs/security.md +124 -8
  33. package/docs/smoke.md +354 -5
  34. package/docs/specs/2026-09-19-agent-hub-design.md +676 -15
  35. package/docs/specs/2026-10-12-backlog-implementation-plan.md +186 -0
  36. package/docs/specs/2026-10-12-office-view-design.md +212 -0
  37. package/docs/text-projection.md +19 -0
  38. package/docs/verification/2026-10-10-cli-output-phase1.md +309 -0
  39. package/docs/verification/2026-10-10-cli-output-phase2.md +672 -0
  40. package/docs/verification/2026-10-11-0.12.23.md +90 -0
  41. package/docs/verification/2026-10-11-acp-watch-t0.md +33 -0
  42. package/docs/verification/2026-10-11-agent-config-inventory.md +20 -0
  43. package/docs/verification/2026-10-11-jcode-peer-contract.md +27 -0
  44. package/docs/verification/2026-10-13-0.12.24.md +94 -0
  45. package/docs/verification/2026-10-13-board-phone.md +31 -0
  46. package/docs/verification/2026-10-13-fsmonitor-fixture-attribution.md +19 -0
  47. package/docs/verified.json +34 -16
  48. package/package.json +2 -1
  49. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  50. package/plugins/agent-hub/ROLE-NOTICE.md +26 -0
  51. package/plugins/agent-hub/server.js +1108 -261
  52. package/scripts/check-docs.mjs +129 -0
  53. package/src/adapters/acp-quota.ts +13 -0
  54. package/src/adapters/acp.ts +201 -20
  55. package/src/adapters/claude-channel.ts +48 -11
  56. package/src/adapters/codex-appserver.ts +54 -13
  57. package/src/adapters/jcode.ts +110 -0
  58. package/src/adapters/local-worker.ts +10 -7
  59. package/src/adapters/pi.ts +94 -20
  60. package/src/cli/attention.ts +60 -0
  61. package/src/cli/bench.ts +26 -6
  62. package/src/cli/board-read.ts +12 -0
  63. package/src/cli/console-state.ts +131 -39
  64. package/src/cli/console.ts +73 -17
  65. package/src/cli/facts-hook.ts +12 -4
  66. package/src/cli/help.ts +52 -21
  67. package/src/cli/identity.ts +19 -8
  68. package/src/cli/init.ts +48 -10
  69. package/src/cli/jcode-mcp.ts +58 -0
  70. package/src/cli/kimi-mcp.ts +53 -0
  71. package/src/cli/launch.ts +16 -1
  72. package/src/cli/main.ts +641 -193
  73. package/src/cli/memory.ts +193 -0
  74. package/src/cli/output.ts +404 -0
  75. package/src/cli/pet-config.ts +1 -0
  76. package/src/cli/pet.ts +83 -0
  77. package/src/cli/preview.ts +40 -6
  78. package/src/cli/reset.ts +3 -0
  79. package/src/cli/status-lines.ts +1 -29
  80. package/src/cli/tail-render.ts +1 -0
  81. package/src/cli/terminal-recovery.ts +27 -1
  82. package/src/cli/upgrade-runtime.ts +56 -10
  83. package/src/cli/upgrade.ts +23 -5
  84. package/src/cli/watch.ts +31 -0
  85. package/src/hub/activity.ts +53 -0
  86. package/src/hub/ask.ts +10 -5
  87. package/src/hub/bench.ts +10 -3
  88. package/src/hub/board.ts +224 -16
  89. package/src/hub/budget.ts +39 -7
  90. package/src/hub/bus.ts +58 -20
  91. package/src/hub/child-process.ts +11 -0
  92. package/src/hub/citations.ts +7 -0
  93. package/src/hub/conductor.ts +34 -17
  94. package/src/hub/config-trust.ts +20 -7
  95. package/src/hub/conflicts.ts +2 -0
  96. package/src/hub/context-window.ts +8 -1
  97. package/src/hub/crash.ts +11 -1
  98. package/src/hub/daemon.ts +1218 -113
  99. package/src/hub/db-timeout.ts +3 -0
  100. package/src/hub/delivery-journal.ts +7 -1
  101. package/src/hub/envelope.ts +1 -1
  102. package/src/hub/events.ts +14 -2
  103. package/src/hub/execution-budget.ts +2 -0
  104. package/src/hub/facts.ts +28 -8
  105. package/src/hub/git.ts +38 -0
  106. package/src/hub/github-import.ts +28 -0
  107. package/src/hub/hub-tools.ts +49 -10
  108. package/src/hub/inference.ts +1 -1
  109. package/src/hub/lifecycle-schema.ts +17 -0
  110. package/src/hub/lifecycle.ts +10 -4
  111. package/src/hub/listen.ts +15 -0
  112. package/src/hub/manager.ts +26 -6
  113. package/src/hub/path-policy.ts +96 -0
  114. package/src/hub/peer-catalog.ts +28 -0
  115. package/src/hub/peers.ts +5 -3
  116. package/src/hub/permission-mode.ts +33 -5
  117. package/src/hub/planning.ts +70 -0
  118. package/src/hub/profiles.ts +74 -0
  119. package/src/hub/project.ts +2 -1
  120. package/src/hub/projected-diff.ts +73 -0
  121. package/src/hub/projection-exec.ts +24 -0
  122. package/src/hub/recovery-error.ts +31 -0
  123. package/src/hub/recovery-store.ts +6 -5
  124. package/src/hub/registry.ts +2 -1
  125. package/src/hub/research.ts +144 -21
  126. package/src/hub/roles.ts +123 -0
  127. package/src/hub/roster.ts +23 -0
  128. package/src/hub/routing.ts +132 -18
  129. package/src/hub/sdlc-queue.ts +112 -0
  130. package/src/hub/sdlc-release.ts +125 -0
  131. package/src/hub/sdlc.ts +250 -0
  132. package/src/hub/settings.ts +411 -0
  133. package/src/hub/shipped-roles.ts +25 -0
  134. package/src/hub/snapshots.ts +92 -16
  135. package/src/hub/start-mode.ts +50 -0
  136. package/src/hub/supervision.ts +1 -0
  137. package/src/hub/task-sweep.ts +1 -1
  138. package/src/hub/tasks.ts +1014 -111
  139. package/src/hub/terminal-open.ts +64 -0
  140. package/src/hub/text-projection.ts +126 -0
  141. package/src/hub/tooling.ts +159 -0
  142. package/src/hub/ui.ts +40 -14
  143. package/src/hub/workflow.ts +71 -0
  144. package/src/local/deny.ts +29 -7
  145. package/src/local/sandbox.ts +50 -10
  146. package/src/local/tools.ts +11 -6
  147. package/src/memory/brief.ts +28 -14
  148. package/src/memory/import.ts +55 -0
  149. package/src/memory/recall.ts +86 -2
  150. package/src/memory/store.ts +750 -0
  151. package/src/memory/sync.ts +145 -0
  152. package/src/models/mlx.ts +2 -1
  153. package/src/pet/NOTICE.md +10 -0
  154. package/src/pet/characters.ts +1438 -0
  155. package/src/pet/config.ts +31 -0
  156. package/src/pet/frame.ts +17 -0
  157. package/src/pet/pixels.ts +28 -0
  158. package/src/pi/tool-receipts.ts +2 -1
  159. package/src/role-text.d.ts +1 -0
  160. package/src/ui/index.html +983 -40
  161. package/src/ui/needs.ts +58 -0
  162. package/src/ui/office-model.ts +440 -0
  163. package/src/ui/peer-palette.ts +16 -0
  164. package/src/ui/task-board.ts +49 -0
  165. package/src/ui/task-progress.ts +5 -1
  166. package/templates/AGENTS.block.md +3 -2
  167. package/templates/config.json +5 -1
  168. package/templates/profiles/dev.json +22 -0
  169. package/templates/profiles/general.json +26 -0
  170. package/templates/profiles/research.json +57 -0
  171. package/templates/profiles/writing.json +28 -0
  172. package/templates/roles/NOTICE.md +26 -0
  173. package/templates/roles/dev/backend-engineer.md +26 -0
  174. package/templates/roles/dev/code-reviewer.md +26 -0
  175. package/templates/roles/dev/frontend-engineer.md +26 -0
  176. package/templates/roles/dev/security-reviewer.md +26 -0
  177. package/templates/roles/dev/software-architect.md +26 -0
  178. package/templates/roles/dev/test-engineer.md +26 -0
  179. package/templates/roles/research/analyst.md +26 -0
  180. package/templates/roles/research/fact-checker.md +26 -0
  181. package/templates/roles/research/research-lead.md +26 -0
  182. package/templates/roles/research/researcher.md +26 -0
  183. package/templates/roles/research/statistician.md +26 -0
@@ -99,6 +99,10 @@ export class PiPeer extends BasePeer {
99
99
  private ownerClaimed = false;
100
100
  private ownerToken = "";
101
101
  private stopping = true;
102
+ private rosterDetached = false;
103
+ private rosterEpoch = 0;
104
+ /** A personal native turn observed on reattach has no hub reply audience. */
105
+ private rosterUnownedRun = false;
102
106
  private exitReported = false;
103
107
  private started = false;
104
108
  private lastToolName?: string;
@@ -118,7 +122,7 @@ export class PiPeer extends BasePeer {
118
122
  if (this.canReuseVerifiedEmptyResume()) return { sessionId: this.verifiedEmptyResume!.sessionId };
119
123
  return { sessionId: this.opts.sessionId, sessionFile: this.opts.sessionFile };
120
124
  }
121
- get acceptingTools(): boolean { return !this.stopping; }
125
+ get acceptingTools(): boolean { return !this.stopping && !this.rosterDetached && !this.rosterUnownedRun; }
122
126
  /** Idle verified owners can be stopped; uncertain live owners and pending launches stay fenced. */
123
127
  get recoveryReady(): boolean {
124
128
  if (this.state === "busy" || this.starting || this.activeTools) return false;
@@ -171,13 +175,13 @@ export class PiPeer extends BasePeer {
171
175
 
172
176
  /** Envelopes belonging to the currently executing Pi turn, for relay-side model-call admission. */
173
177
  /** One id per admitted provider request; parallel tools share its assistant step. */
174
- get observationTurn(): string | undefined { return this.agentRunning && this.modelStep ? `${this.observationScope}.${this.budgetGeneration}.${this.modelStep}` : undefined; }
178
+ get observationTurn(): string | undefined { return this.acceptingTools && this.agentRunning && this.modelStep ? `${this.observationScope}.${this.budgetGeneration}.${this.modelStep}` : undefined; }
175
179
 
176
- get budgetEnvelopes(): Envelope[] { return this.agentRunning && this.state === "busy" ? this.activeEnvs.slice() : []; }
180
+ get budgetEnvelopes(): Envelope[] { return this.acceptingTools && this.agentRunning && this.state === "busy" ? this.activeEnvs.slice() : []; }
177
181
 
178
182
  /** Mark an authoritative relay admission stop against the current Pi turn, never a later turn. */
179
183
  recordBudgetStop(decision: ExecutionBudgetDecision): { generation: number; reason: string } | undefined {
180
- if (!this.agentRunning || this.state !== "busy") return undefined;
184
+ if (!this.acceptingTools || !this.agentRunning || this.state !== "busy") return undefined;
181
185
  const generation = this.budgetGeneration;
182
186
  const reason = `execution budget ${decision.reason ?? "exhausted"}: ${decision.scope} ${decision.unit} used ${decision.used}${decision.limit === null ? "" : ` of ${decision.limit}`}; ${decision.remaining === null ? "remaining unknown" : `${decision.remaining} remaining`}`;
183
187
  this.budgetStops.set(generation, reason);
@@ -192,7 +196,7 @@ export class PiPeer extends BasePeer {
192
196
  try { await this.starting; } finally { this.starting = undefined; }
193
197
  }
194
198
  private async handleToolRequest(body: any): Promise<Response> {
195
- if (this.stopping || this.state === "offline") return Response.json({ text: "error: Pi owner is stopped" }, { status: 409 });
199
+ 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 });
196
200
  if (!this.verifiedOwner() || typeof body.sessionId !== "string" || !body.sessionId || body.sessionId !== this.sessionId || !Number.isSafeInteger(body.generation) || body.generation !== this.budgetGeneration) {
197
201
  const text = "error: Pi tool lineage is missing, stale or unverified; restart Pi with the current hub extension before retrying";
198
202
  this.opts.log?.(`[${this.id}] ${text}`);
@@ -238,9 +242,9 @@ export class PiPeer extends BasePeer {
238
242
  try {
239
243
  const result = await this.opts.executeTool(name, body.args, callId, this.sessionId, executionSignal, {
240
244
  userBash: body.purpose === "idle_user_bash",
241
- isCurrent: () => generation === this.budgetGeneration && !this.stopping && this.state !== "offline" && !executionSignal?.aborted && !this.approvalStopReason && this.verifiedOwner() && (body.purpose === "idle_user_bash" ? this.opts.mode === "tui" : turnSignal === this.executionAbort?.signal),
245
+ isCurrent: () => generation === this.budgetGeneration && this.acceptingTools && this.state !== "offline" && !executionSignal?.aborted && !this.approvalStopReason && this.verifiedOwner() && (body.purpose === "idle_user_bash" ? this.opts.mode === "tui" : turnSignal === this.executionAbort?.signal),
242
246
  onApproval: (provenance) => {
243
- if (!this.agentRunning || generation !== this.budgetGeneration || turnSignal !== this.executionAbort?.signal || this.approvalStopReason) return;
247
+ if (!this.acceptingTools || !this.agentRunning || generation !== this.budgetGeneration || turnSignal !== this.executionAbort?.signal || this.approvalStopReason) return;
244
248
  if (this.approvalAnswerEpoch !== provenance.answerEpoch) {
245
249
  this.approvalExpiries = 0; this.approvalAnswerEpoch = provenance.answerEpoch;
246
250
  }
@@ -269,7 +273,7 @@ export class PiPeer extends BasePeer {
269
273
  if (this.agentRunning && generation === this.budgetGeneration && this.approvalStopReason === reason && !this.stopping) void this.endApprovalTurn(generation, reason);
270
274
  }, 10);
271
275
  }
272
- if (this.state === "busy") {
276
+ if (this.state === "busy" && this.acceptingTools) {
273
277
  if (!this.activeTools && !this.agentRunning && !this.activeEnvs.length) this.setState("idle");
274
278
  else this.touch();
275
279
  }
@@ -277,11 +281,12 @@ export class PiPeer extends BasePeer {
277
281
  }
278
282
 
279
283
  private async handleBudgetRequest(body: any): Promise<Response> {
284
+ if (this.rosterDetached || this.rosterUnownedRun) return Response.json({ error: this.rosterDetached ? "Pi is off roster" : "Pi native turn is pending" }, { status: 409 });
280
285
  if (!["model_calls", "tool_calls"].includes(String(body.unit))) return Response.json({ error: "invalid Pi budget unit" }, { status: 400 });
281
286
  const idleUserBash = body.idleUserBash === true;
282
287
  const generation = body.generation, controller = this.executionAbort, session = this.sessionId, ownerPid = this.ownerPid, ownerSignature = this.ownerSignature;
283
- const current = () => Number.isSafeInteger(generation) && generation === this.budgetGeneration && controller === this.executionAbort && session === this.sessionId && ownerPid === this.ownerPid && ownerSignature === this.ownerSignature && !this.stopping && this.verifiedOwner();
284
- const idleOwner = this.opts.mode === "tui" && !this.stopping && this.verifiedOwner() && this.state === "idle" && !this.agentRunning && this.activeTools === 0 && this.activeEnvs.length === 0;
288
+ const current = () => Number.isSafeInteger(generation) && generation === this.budgetGeneration && controller === this.executionAbort && session === this.sessionId && ownerPid === this.ownerPid && ownerSignature === this.ownerSignature && this.acceptingTools && this.verifiedOwner();
289
+ const idleOwner = this.opts.mode === "tui" && this.acceptingTools && this.verifiedOwner() && this.state === "idle" && !this.agentRunning && this.activeTools === 0 && this.activeEnvs.length === 0;
285
290
  if (!current() || (idleUserBash ? !idleOwner : !this.agentRunning || this.state !== "busy")) return Response.json({ error: "stale Pi budget request" }, { status: 409 });
286
291
  // An interactive user shell has no task delivery. It may spend an eligible run budget only.
287
292
  const envs = idleUserBash ? [] : this.budgetEnvelopes;
@@ -311,6 +316,7 @@ export class PiPeer extends BasePeer {
311
316
 
312
317
  private async startImpl(): Promise<void> {
313
318
  this.stopping = false;
319
+ this.rosterDetached = false; this.rosterUnownedRun = false;
314
320
  this.requestedStopReason = undefined;
315
321
  this.approvalTurnAbort = false;
316
322
  this.exitReported = false; this.started = false; this.lastToolName = undefined; this.shutdownExit = undefined;
@@ -403,8 +409,46 @@ export class PiPeer extends BasePeer {
403
409
  }
404
410
  get mode(): "headless" | "tui" { return this.opts.mode; }
405
411
 
412
+ /** Withdraw hub admission without shutting down a person's Pi TUI or its bridge. */
413
+ async detachForRoster(): Promise<void> {
414
+ if (this.opts.mode !== "tui") throw new Error("Pi roster detach requires a personal TUI");
415
+ this.rosterDetached = true; this.rosterEpoch++;
416
+ this.setState("offline");
417
+ clearTimeout(this.executionBudgetTimer); this.executionBudgetTimer = undefined;
418
+ const ids = [...this.activeDeliveryIds]; this.activeDeliveryIds.clear();
419
+ this.activeEnvs = []; this.currentReply = undefined;
420
+ this.settledText = ""; this.settledError = ""; this.settledCancelled = false;
421
+ for (const id of ids) this.delivery({ id, state: "needs_review", reason: "Pi removed from roster before settlement; inspect the native session before retrying" });
422
+ const settling = this.abortTools();
423
+ // Revoke hub commands not yet consumed, and fence an ACK already in flight by rosterEpoch.
424
+ for (const [id, pending] of this.tuiCommands) {
425
+ if (!["prompt", "steer", "set_model"].includes(String(pending.command.type))) continue;
426
+ this.tuiCommands.delete(id); this.tuiQueue = this.tuiQueue.filter((command) => command.id !== id);
427
+ pending.reject(new Error("Pi is off roster"));
428
+ }
429
+ if (settling.length) {
430
+ let timer: ReturnType<typeof setTimeout> | undefined;
431
+ try { await Promise.race([Promise.all(settling), new Promise<never>((_, reject) => { timer = setTimeout(() => reject(new PiToolSettlementFailure("Pi managed tools did not settle after roster detach")), this.opts.stopGraceMs ?? 5_000); })]); }
432
+ finally { clearTimeout(timer); }
433
+ }
434
+ }
435
+
436
+ /** A return reuses only the same verified native owner and a fresh, truthful snapshot. */
437
+ async resumeFromRoster(): Promise<void> {
438
+ if (!this.rosterDetached) return;
439
+ if (this.opts.mode !== "tui" || this.stopping || !this.rosterDetached || !this.server || !this.verifiedOwner()) throw new Error("Pi roster return has no verified live TUI owner");
440
+ const epoch = this.rosterEpoch, session = this.sessionId, file = this.sessionFile;
441
+ const snapshot = await this.sendTui({ type: "get_session_state" });
442
+ 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");
443
+ this.rosterDetached = false;
444
+ this.rosterUnownedRun = !snapshot.idle; this.agentRunning = !snapshot.idle;
445
+ this.setState(snapshot.idle ? "idle" : "busy");
446
+ if (!snapshot.idle) this.clearWatchdog();
447
+ }
448
+
406
449
  async stop(reason?: string): Promise<void> {
407
450
  if (reason !== undefined) this.requestedStopReason = reason;
451
+ this.rosterEpoch++;
408
452
  this.stopping = true; // fence new calls before withdrawing both turn and idle-shell execution
409
453
  for (const id of this.activeDeliveryIds) this.delivery({ id, state: "needs_review", reason: reason ?? "Pi session stopped before settlement" });
410
454
  this.activeDeliveryIds.clear();
@@ -482,15 +526,19 @@ export class PiPeer extends BasePeer {
482
526
  }
483
527
 
484
528
  async deliver(envs: Envelope[], deliveryId?: string): Promise<void> {
485
- if (this.state !== "idle" || this.stopping) {
486
- if (deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason: `${this.id} is not ready` });
487
- throw new Error(`${this.id} is not ready`);
529
+ if (this.state !== "idle" || !this.acceptingTools) {
530
+ const reason = `${this.id} is not ready${this.rosterDetached ? ": off roster" : this.rosterUnownedRun ? ": native turn pending" : ""}`;
531
+ if (deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason });
532
+ throw new Error(reason);
488
533
  }
489
534
  this.setState("busy"); this.activeEnvs = envs; this.currentReply = replyParent(envs); this.settledText = "";
535
+ const rosterEpoch = this.rosterEpoch;
490
536
  let attempted = false;
491
537
  try {
492
538
  const requested = await this.opts.selectModel?.(envs);
539
+ if (rosterEpoch !== this.rosterEpoch || !this.acceptingTools) throw new Error("Pi is off roster or stopped");
493
540
  if (requested) await this.setRequestedModel(requested);
541
+ if (rosterEpoch !== this.rosterEpoch || !this.acceptingTools) throw new Error("Pi is off roster or stopped");
494
542
  this.noteActivity();
495
543
  attempted = true;
496
544
  if (deliveryId) this.activeDeliveryIds.add(deliveryId);
@@ -498,6 +546,10 @@ export class PiPeer extends BasePeer {
498
546
  else await this.sendRpc({ type: "prompt", message: renderDigest(envs, true) });
499
547
  if (deliveryId && this.activeDeliveryIds.has(deliveryId)) this.delivery({ id: deliveryId, state: "accepted" });
500
548
  } catch (error) {
549
+ if (rosterEpoch !== this.rosterEpoch) {
550
+ if (!attempted && deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason: "Pi withdrawn before native dispatch" });
551
+ return;
552
+ }
501
553
  if (!attempted) {
502
554
  this.activeEnvs = []; this.setState("idle");
503
555
  if (deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason: error instanceof Error ? error.message : String(error) });
@@ -515,10 +567,12 @@ export class PiPeer extends BasePeer {
515
567
  }
516
568
 
517
569
  async steer(envs: Envelope[], deliveryId?: string): Promise<void> {
518
- if (this.state !== "busy" || this.stopping || envs.some((e) => e.kind !== "chat" || e.private || e.priority !== "important")) {
519
- if (deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason: "Pi workflow/private messages must queue" });
520
- throw new Error("Pi workflow/private messages must queue");
570
+ if (this.state !== "busy" || !this.acceptingTools || envs.some((e) => e.kind !== "chat" || e.private || e.priority !== "important")) {
571
+ const reason = `Pi workflow/private messages must queue${this.rosterDetached ? ": off roster" : this.rosterUnownedRun ? ": native turn pending" : ""}`;
572
+ if (deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason });
573
+ throw new Error(reason);
521
574
  }
575
+ const rosterEpoch = this.rosterEpoch;
522
576
  const reply = replyParent([...this.activeEnvs, ...envs]);
523
577
  this.activeEnvs.push(...envs); this.currentReply = reply;
524
578
  try {
@@ -527,12 +581,14 @@ export class PiPeer extends BasePeer {
527
581
  else await this.sendRpc({ type: "steer", message: renderDigest(envs, true) });
528
582
  if (deliveryId && this.activeDeliveryIds.has(deliveryId)) this.delivery({ id: deliveryId, state: "accepted" });
529
583
  } catch (error) {
584
+ if (rosterEpoch !== this.rosterEpoch) return;
530
585
  await this.terminateFailedTurn(error instanceof Error ? error : new Error(String(error)));
531
586
  }
532
587
  }
533
588
 
534
589
  getRequestedModel(): string { return this.requestedModel; }
535
590
  async setRequestedModel(model: string): Promise<void> {
591
+ if (!this.acceptingTools) throw new Error("Pi is off roster or stopped");
536
592
  const slash = model.indexOf("/");
537
593
  const provider = "agent-hub-local";
538
594
  const modelId = model;
@@ -541,6 +597,24 @@ export class PiPeer extends BasePeer {
541
597
  }
542
598
 
543
599
  private handleBridgeEvent(event: any): void {
600
+ if (this.rosterDetached || this.rosterUnownedRun) {
601
+ // The shipped extension's legacy agent_start omits sessionId. Its authenticated bridge
602
+ // and still-verified owner bind that event to this session; an explicit mismatch never does.
603
+ const bound = !this.stopping && this.verifiedOwner() && (event.sessionId === undefined || event.sessionId === this.sessionId);
604
+ if (bound && event.type === "agent_start" && Number.isSafeInteger(event.generation) && event.generation > this.budgetGeneration) {
605
+ this.budgetGeneration = event.generation;
606
+ this.noteActivity(); // personal native activity also revokes an earlier ID-only empty-resume proof
607
+ }
608
+ if (!bound && event.type !== "session_shutdown") return;
609
+ }
610
+ if (this.rosterDetached && event.type !== "session_shutdown") return;
611
+ if (this.rosterUnownedRun && event.type !== "session_shutdown") {
612
+ if (event.type === "agent_settled" && (event.generation === undefined || event.generation === this.budgetGeneration)) {
613
+ this.rosterUnownedRun = false; this.agentRunning = false;
614
+ if (!this.stopping && this.verifiedOwner() && !this.activeTools) this.setState("idle");
615
+ }
616
+ return;
617
+ }
544
618
  if (this.stopping && ["agent_start", "agent_end", "agent_settled"].includes(event.type)) return;
545
619
  if (event.type === "session_start") {
546
620
  if (!this.ownerClaimed) { this.ownerClaimed = true; this.ownerToken = String(event.ownerToken ?? ""); }
@@ -655,7 +729,7 @@ export class PiPeer extends BasePeer {
655
729
  if (waiter) { clearTimeout(waiter.timer); waiter.resolve(wire); } else this.tuiQueue.push(wire);
656
730
  });
657
731
  }
658
- private handleRpc(message: RpcMessage): void { if (["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.
732
+ 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.
659
733
  if (message.id !== undefined) { const pending = this.pending.get(String(message.id)); if (pending) { this.pending.delete(String(message.id)); message.success === false ? pending.reject(new Error(message.error ?? "Pi RPC command failed")) : pending.resolve(message); } } }
660
734
  private sendRpc(command: Record<string, unknown>): Promise<RpcMessage> { if (!this.proc?.stdin.writable) return Promise.reject(new Error("Pi process is not running")); const id = `ahub-${++this.seq}`; return new Promise((resolvePromise, reject) => { const timer = setTimeout(() => { this.pending.delete(id); reject(new Error(`Pi RPC ${String(command.type)} timed out`)); }, 30_000); this.pending.set(id, { resolve: (m) => { clearTimeout(timer); resolvePromise(m); }, reject: (e) => { clearTimeout(timer); reject(e); } }); this.proc!.stdin.write(`${JSON.stringify({ id, ...command })}\n`); }); }
661
735
  private async waitRpc(command: string, timeout: number): Promise<RpcMessage> {
@@ -688,7 +762,7 @@ export class PiPeer extends BasePeer {
688
762
  const envs = this.activeEnvs; this.activeEnvs = [];
689
763
  for (const id of this.activeDeliveryIds) this.delivery({ id, state: "needs_review", reason: error.message });
690
764
  this.activeDeliveryIds.clear();
691
- if (envs.length && !this.requestedStopReason) {
765
+ if (envs.length && !this.requestedStopReason && !this.rosterDetached) {
692
766
  this.onMessage?.(approvalStopReason ? `${approvalStopReason} Pi is offline because its extension could not abort the turn; restart with ahub pi and settle the held delivery after inspecting prior work.` : "Pi turn failed; inspect its session and any partial effects before continuing.", { inReplyTo: this.currentReply });
693
767
  if (!approvalStopReason && !(error instanceof ApprovalWaitStop)) void this.opts.onTurnFailure?.(envs, error.message).catch(() => this.opts.log?.("Pi failure handoff could not be completed"));
694
768
  }
@@ -712,7 +786,7 @@ export class PiPeer extends BasePeer {
712
786
  }
713
787
  private clearOwnerMonitor(): void { if (this.ownerMonitor) clearInterval(this.ownerMonitor); this.ownerMonitor = undefined; }
714
788
  private async endApprovalTurn(generation: number, reason: string): Promise<void> {
715
- if (!this.agentRunning || generation !== this.budgetGeneration || this.approvalStopReason !== reason || this.stopping || this.state === "offline") return;
789
+ if (!this.agentRunning || generation !== this.budgetGeneration || this.approvalStopReason !== reason || !this.acceptingTools || this.state === "offline") return;
716
790
  this.executionAbort?.abort();
717
791
  if (this.approvalTurnAbort) {
718
792
  try { await this.sendTui({ type: "abort_budget", generation, cause: "approval", reason }); return; }
@@ -742,7 +816,7 @@ export class PiPeer extends BasePeer {
742
816
  }
743
817
  }
744
818
  protected override onWatchdog(): void {
745
- if (this.stopping) return;
819
+ if (this.stopping || this.rosterDetached || this.rosterUnownedRun) return;
746
820
  if (this.activeTools) { this.touch(); return; }
747
821
  void this.terminateFailedTurn(new Error("Pi run became inactive before agent_settled"));
748
822
  }
@@ -0,0 +1,60 @@
1
+ import { needs, type Needs, type NeedsInput, type NeedItem } from "../ui/needs.ts";
2
+ import { petFrame, type CharacterId, type PetPose } from "../pet/frame.ts";
3
+ export interface AttentionState {
4
+ approvals: readonly { id: string; peer?: string; expiresAt: number }[];
5
+ permissionDefaults: readonly { peer: string }[];
6
+ peers: Record<string, { state?: string; attached?: boolean; needsReview?: number; heldBy?: string }>;
7
+ budget: Record<string, { paused?: { since?: number; resetsAt?: number } }>;
8
+ attentionMessages?: readonly { id?: string; peer?: string; important: boolean; since?: number }[];
9
+ storageBlocked?: boolean;
10
+ recoveryNeed?: { id?: string; running: boolean; since?: number };
11
+ queue?: readonly { id: string; peer?: string; state: string }[];
12
+ attentionFacts?: { kind: "review" | "offline"; id: number; peer?: string }[];
13
+ }
14
+ /** Allowlisted adapter between console-owned facts and the shared body-free model. */
15
+ 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);
18
+ // Panel snapshots know each held row; enrich only ids, without changing the status-derived count.
19
+ const used = new Set<string | number>();
20
+ for (const item of result.items) if (item.kind === "delivery") {
21
+ const eligible = (row: { id: string; peer?: string; state: string }) => row.state === "needs_review" && row.peer === item.peer && !used.has(row.id) && /^[A-Za-z0-9_.:-]{1,128}$/.test(row.id);
22
+ const row = state.queue?.find(row => row.id === item.id && eligible(row)) ?? state.queue?.find(eligible);
23
+ if (row) { item.id = row.id; used.add(row.id); }
24
+ else if (item.id !== undefined) used.add(item.id);
25
+ }
26
+ return result;
27
+ }
28
+ export function attentionLine(value: Needs, now = 0, blink = false, next = true): string {
29
+ const label = value.state === "waiting" ? `! ${value.act} need you` : value.state === "blocked" ? `! blocked (${value.act})` : value.state === "review" ? `? ${value.act} for review` : value.state === "working" ? "* working" : value.state === "idle" ? "idle" : "z asleep";
30
+ const eyes = blink && value.items.some(item => item.kind === "approval") && Math.floor(now / 1000) % 2 ? "(- -)" : "(O O)";
31
+ return `${eyes} ${label}${value.watch ? ` +${value.watch}` : ""}${next && value.items.length ? " n next" : ""}`;
32
+ }
33
+
34
+ /** Preserve the shipped line layout; only its trusted built-in face is selectable. */
35
+ export function characterAttentionLine(value: Needs, character: CharacterId = "stai", now = 0, blink = false, next = true, glyphs: "ascii" | "blocks" = "ascii"): string {
36
+ const pose: PetPose = blink && value.items.some(item => item.kind === "approval") && Math.floor(now / 1000) % 2 ? "waitingBlink" : value.state;
37
+ return petFrame(character, pose, "line", glyphs)[0]! + attentionLine(value, now, blink, next).slice(5);
38
+ }
39
+
40
+ /** Accept only body-free board facts from the authenticated status reply. */
41
+ export function attentionFacts(raw: unknown): NonNullable<AttentionState["attentionFacts"]> {
42
+ if (!Array.isArray(raw)) return [];
43
+ return raw.flatMap<NonNullable<AttentionState["attentionFacts"]>[number]>(row => {
44
+ if (!row || typeof row !== "object" || !Number.isSafeInteger(row.id) || row.id < 1) return [];
45
+ if (row.kind === "review" && row.level === "act") return [{ kind: "review" as const, id: row.id }];
46
+ if (row.kind === "offline" && row.level === "watch" && typeof row.peer === "string" && /^[A-Za-z0-9_.:-]{1,128}$/.test(row.peer)) return [{ kind: "offline" as const, id: row.id, peer: row.peer }];
47
+ return [];
48
+ });
49
+ }
50
+
51
+ export function attentionCommand(item: NeedItem): string {
52
+ if (item.kind === "delivery") return `ahub queue list${item.id ? `; ahub queue show ${item.id}` : ""}`;
53
+ if (item.kind === "review") return `ahub task show ${item.id}; ahub review ${item.id} approved|changes_requested`;
54
+ if (item.kind === "offline") return `ahub status; ahub task show ${item.id}; ahub task assign ${item.id} <peer>`;
55
+ if (item.kind === "storage") return "ahub doctor";
56
+ if (item.kind === "recovery") return `ahub recovery status${item.id ? ` ${item.id}` : ""}`;
57
+ if (item.kind === "budget") return "ahub budget";
58
+ if (item.kind === "default") return "ahub permission";
59
+ return item.peer ? `ahub say @${item.peer} <message>` : "ahub console";
60
+ }
package/src/cli/bench.ts CHANGED
@@ -5,10 +5,13 @@ import { join } from "node:path";
5
5
  import { childEnv, stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
6
6
  import { ControlClient } from "../hub/control-client.ts";
7
7
  import { readEvents } from "../hub/events.ts";
8
+ import { hubGitArgv, hubGitEnv } from "../hub/git.ts";
8
9
  import { taskRecords } from "../hub/research.ts";
9
- import { appendBench, BENCH_SCHEMA, parseSuite, suiteHash, type Attempt, type AttemptError, type AttemptMetrics, type Fingerprint, type Outcome, type SuiteTask } from "../hub/bench.ts";
10
+ import { appendBench, BENCH_SCHEMA, memoryMode, type MemoryMode, parseSuite, suiteHash, type Attempt, type AttemptError, type AttemptMetrics, type Fingerprint, type Outcome, type SuiteTask } from "../hub/bench.ts";
10
11
  import { hubHome, realPath } from "../hub/project.ts";
11
12
  import { processSignature } from "../pi/process-signature.ts";
13
+ import { loadConfig } from "../hub/daemon.ts";
14
+ import { addonState, detectTooling, type Addon, type AddonName } from "../hub/tooling.ts";
12
15
  import { VERSION } from "../version.ts";
13
16
 
14
17
  /** What the runner needs from the world, so tests can drive it without a terminal. */
@@ -23,14 +26,18 @@ export interface BenchIo {
23
26
  now(): number;
24
27
  sleep(ms: number): Promise<void>;
25
28
  log(line: string): void;
29
+ /** #248: the add-ons a peer works with; `detectTooling` on this machine unless a test supplies its own. */
30
+ tooling?(peer: string): Addon[] | Promise<Addon[]>;
31
+ /** #258: the memory settings the project's hub runs with; the project config unless a test supplies them. */
32
+ memoryConfig?(): { enabled: boolean; native?: boolean };
26
33
  }
27
- export interface BenchOptions { cwd: string; stateDir: string; suitePath: string; arm: string; repeat: number; only?: string[]; home?: string; pollMs?: number; verifyTimeoutMs?: number; setupTimeoutMs?: number; settleMs?: number; stopped?: () => boolean }
34
+ export interface BenchOptions { cwd: string; stateDir: string; suitePath: string; arm: string; repeat: number; addons?: Partial<Record<AddonName, "on" | "off">>; memory?: MemoryMode; only?: string[]; home?: string; pollMs?: number; verifyTimeoutMs?: number; setupTimeoutMs?: number; settleMs?: number; stopped?: () => boolean }
28
35
 
29
36
  const fail = (message: string): never => { throw new Error(message); };
30
37
 
31
38
  /** Commands git runs without a shell; the arguments are ours, never suite text. */
32
39
  async function git(cwd: string, args: string[]): Promise<{ code: number; out: string }> {
33
- const p = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "ignore" });
40
+ const p = Bun.spawn(hubGitArgv(args), { cwd, stdout: "pipe", stderr: "ignore", env: hubGitEnv() });
34
41
  const [code, out] = await Promise.all([p.exited, new Response(p.stdout).text()]);
35
42
  return { code, out: out.trim() };
36
43
  }
@@ -191,12 +198,25 @@ export async function runBench(opts: BenchOptions, io: BenchIo): Promise<string>
191
198
  // ponytail: the hub's status reports a requested model for Pi only and permission modes once #242 lands; the rest
192
199
  // are recorded as absent. Reading each peer's launch settings would fill them in.
193
200
  // The status lists every peer the hub has known; an arm is the ones attached now.
194
- peers: Object.entries(status.peers ?? {}).filter(([, p]) => p.state !== "offline")
195
- .map(([id, p]) => ({ id, ...(p.requestedModel ? { model: p.requestedModel } : {}), ...(p.permissionMode ? { permissionMode: p.permissionMode } : {}) })).sort((a, b) => a.id.localeCompare(b.id)),
201
+ peers: (await Promise.all(Object.entries(status.peers ?? {}).filter(([, p]) => p.state !== "offline")
202
+ .map(async ([id, p]) => ({ id, ...(p.requestedModel ? { model: p.requestedModel } : {}), ...(p.permissionMode ? { permissionMode: p.permissionMode } : {}),
203
+ addons: await (io.tooling ?? ((peer: string) => detectTooling(peer, { cwd: opts.cwd })))(id) })))).sort((a, b) => a.id.localeCompare(b.id)),
196
204
  routingHash: existsSync(routing) ? createHash("sha256").update(readFileSync(routing)).digest("hex") : null,
197
205
  hubVersion: status.version,
198
206
  };
207
+ // #248: a declared add-on condition must hold for the attached peers before anything is recorded or reset.
208
+ const addons = Object.entries(opts.addons ?? {}) as [AddonName, "on" | "off"][];
209
+ for (const [name, want] of addons) {
210
+ const found = addonState(name, fingerprint.peers.map((p) => p.addons));
211
+ if (found !== want) throw new Error(`the arm declares ${name} ${want}, but the attached peers have it ${found}; change their setup or the declaration (nothing was run)`);
212
+ }
213
+ // #258 AC11: the memory the project's hub runs with (its config, read at hub start) must be the arm's.
214
+ // ponytail: read from the project config, not the running hub; a config edited since the hub started reads wrong.
215
+ const runsWith = opts.memory ? memoryMode((io.memoryConfig ?? (() => loadConfig(opts.cwd).memory))()) : undefined;
216
+ if (opts.memory && runsWith !== opts.memory) throw new Error(`the arm declares memory ${opts.memory}, but this project's hub runs with ${runsWith}; change memory.enabled / memory.native and restart the hub, or the declaration (nothing was run)`);
217
+ const condition = { ...(addons.length ? { addons: Object.fromEntries(addons.sort(([a], [b]) => a.localeCompare(b))) } : {}), ...(opts.memory ? { memory: opts.memory } : {}) };
199
218
  appendBench({ schema: BENCH_SCHEMA, kind: "run", run, suite: suite.name, suiteHash: suiteHash(suiteText), arm: opts.arm, fingerprint, version: VERSION,
219
+ ...(Object.keys(condition).length ? { condition } : {}),
200
220
  startedAt: new Date(io.now()).toISOString(), tasks: tasks.length, repeats: opts.repeat, order: tasks.map((t) => t.id), runner: { pid: process.pid, signature: processSignature(process.pid) ?? null } }, home);
201
221
  io.log(`bench run ${run}: ${suite.name} [${opts.arm}], ${tasks.length} task${tasks.length === 1 ? "" : "s"} x ${opts.repeat}`);
202
222
  let stopped: "timeout" | "interrupted" | "error" | undefined;
@@ -236,7 +256,7 @@ async function runAttempt(task: SuiteTask, commit: string, repeat: number, run:
236
256
  // The attempt ends when its outcome is known: the wait for its measures is not part of its time.
237
257
  const done = (outcome: Outcome, extra: { error?: AttemptError; verifyExit?: number | null; hubTask?: number | null; metrics?: AttemptMetrics | null; ended?: number; open?: boolean } = {}) => {
238
258
  const ended = extra.ended ?? io.now();
239
- const attempt: Attempt = { schema: BENCH_SCHEMA, kind: "attempt", run, task: task.id, repeat, outcome, ...(extra.error ? { error: extra.error } : {}), verifyExit: extra.verifyExit ?? null,
259
+ const attempt: Attempt = { schema: BENCH_SCHEMA, kind: "attempt", run, arm: opts.arm, task: task.id, repeat, outcome, ...(extra.error ? { error: extra.error } : {}), verifyExit: extra.verifyExit ?? null,
240
260
  hubTask: extra.hubTask ?? null, startedAt: new Date(started).toISOString(), endedAt: new Date(ended).toISOString(), ms: ended - started, metrics: extra.metrics ?? null };
241
261
  return { attempt, open: extra.open ?? false };
242
262
  };
@@ -0,0 +1,12 @@
1
+ /** Older same-protocol daemons may ignore newly added filter arguments. */
2
+ export function supportsBoardReleaseFilter(status: unknown): boolean {
3
+ if (!status || typeof status !== "object") return false;
4
+ const capability = (status as Record<string,unknown>).boardReadCapabilities;
5
+ return !!capability && typeof capability === "object" && (capability as Record<string,unknown>).releaseFilter === true;
6
+ }
7
+
8
+ export function supportsBoardColumnMetadata(status: unknown): boolean {
9
+ if (!status || typeof status !== "object") return false;
10
+ const capability = (status as Record<string,unknown>).boardReadCapabilities;
11
+ return !!capability && typeof capability === "object" && (capability as Record<string,unknown>).columnMetadata === true;
12
+ }