@staix/agent-hub 0.12.23 → 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 (158) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/README.md +8 -3
  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 +8 -1
  11. package/docs/agent-notes/bus.md +3 -1
  12. package/docs/agent-notes/daemon.md +7 -1
  13. package/docs/agent-notes/local-worker.md +4 -2
  14. package/docs/agent-notes/tasks.md +4 -0
  15. package/docs/agent-notes/tests.md +2 -1
  16. package/docs/bench.md +28 -3
  17. package/docs/cooperbench.md +4 -0
  18. package/docs/data-policy.md +36 -0
  19. package/docs/events.md +3 -0
  20. package/docs/gsd-collaboration-analysis.md +48 -0
  21. package/docs/memory.md +231 -0
  22. package/docs/operations.md +651 -14
  23. package/docs/plans/2026-10-11-issues-109-sequential.md +81 -0
  24. package/docs/plans/2026-10-11-routing-evaluation-proposal.md +30 -0
  25. package/docs/profiles.md +57 -0
  26. package/docs/quickstart.md +1 -1
  27. package/docs/research.md +31 -1
  28. package/docs/roles.md +85 -0
  29. package/docs/sdlc.md +170 -0
  30. package/docs/security.md +111 -4
  31. package/docs/smoke.md +282 -0
  32. package/docs/specs/2026-09-19-agent-hub-design.md +601 -7
  33. package/docs/specs/2026-10-12-backlog-implementation-plan.md +186 -0
  34. package/docs/specs/2026-10-12-office-view-design.md +212 -0
  35. package/docs/text-projection.md +19 -0
  36. package/docs/verification/2026-10-11-acp-watch-t0.md +33 -0
  37. package/docs/verification/2026-10-11-agent-config-inventory.md +20 -0
  38. package/docs/verification/2026-10-11-jcode-peer-contract.md +27 -0
  39. package/docs/verification/2026-10-13-0.12.24.md +94 -0
  40. package/docs/verification/2026-10-13-board-phone.md +31 -0
  41. package/docs/verification/2026-10-13-fsmonitor-fixture-attribution.md +19 -0
  42. package/docs/verified.json +22 -13
  43. package/package.json +2 -1
  44. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  45. package/plugins/agent-hub/ROLE-NOTICE.md +26 -0
  46. package/plugins/agent-hub/server.js +1175 -339
  47. package/scripts/check-docs.mjs +129 -0
  48. package/src/adapters/acp.ts +112 -13
  49. package/src/adapters/claude-channel.ts +48 -11
  50. package/src/adapters/codex-appserver.ts +11 -0
  51. package/src/adapters/jcode.ts +110 -0
  52. package/src/adapters/local-worker.ts +10 -7
  53. package/src/adapters/pi.ts +94 -20
  54. package/src/cli/attention.ts +60 -0
  55. package/src/cli/bench.ts +24 -5
  56. package/src/cli/board-read.ts +12 -0
  57. package/src/cli/console-state.ts +103 -17
  58. package/src/cli/console.ts +54 -8
  59. package/src/cli/facts-hook.ts +12 -4
  60. package/src/cli/help.ts +35 -9
  61. package/src/cli/identity.ts +18 -7
  62. package/src/cli/init.ts +48 -10
  63. package/src/cli/jcode-mcp.ts +58 -0
  64. package/src/cli/kimi-mcp.ts +53 -0
  65. package/src/cli/launch.ts +16 -1
  66. package/src/cli/main.ts +392 -57
  67. package/src/cli/memory.ts +193 -0
  68. package/src/cli/output.ts +45 -4
  69. package/src/cli/pet-config.ts +1 -0
  70. package/src/cli/pet.ts +83 -0
  71. package/src/cli/preview.ts +38 -5
  72. package/src/cli/status-lines.ts +1 -1
  73. package/src/cli/tail-render.ts +1 -0
  74. package/src/cli/upgrade-runtime.ts +32 -3
  75. package/src/cli/watch.ts +31 -0
  76. package/src/hub/activity.ts +53 -0
  77. package/src/hub/ask.ts +10 -5
  78. package/src/hub/bench.ts +10 -3
  79. package/src/hub/board.ts +222 -16
  80. package/src/hub/bus.ts +48 -9
  81. package/src/hub/citations.ts +7 -0
  82. package/src/hub/conductor.ts +12 -7
  83. package/src/hub/config-trust.ts +4 -2
  84. package/src/hub/crash.ts +9 -0
  85. package/src/hub/daemon.ts +910 -120
  86. package/src/hub/delivery-journal.ts +5 -0
  87. package/src/hub/envelope.ts +1 -1
  88. package/src/hub/events.ts +12 -2
  89. package/src/hub/facts.ts +26 -7
  90. package/src/hub/git.ts +8 -2
  91. package/src/hub/github-import.ts +28 -0
  92. package/src/hub/hub-tools.ts +49 -10
  93. package/src/hub/inference.ts +1 -1
  94. package/src/hub/lifecycle-schema.ts +17 -0
  95. package/src/hub/listen.ts +15 -0
  96. package/src/hub/manager.ts +12 -5
  97. package/src/hub/path-policy.ts +96 -0
  98. package/src/hub/peer-catalog.ts +28 -0
  99. package/src/hub/permission-mode.ts +19 -5
  100. package/src/hub/planning.ts +70 -0
  101. package/src/hub/profiles.ts +74 -0
  102. package/src/hub/projected-diff.ts +73 -0
  103. package/src/hub/projection-exec.ts +24 -0
  104. package/src/hub/research.ts +144 -21
  105. package/src/hub/roles.ts +123 -0
  106. package/src/hub/roster.ts +23 -0
  107. package/src/hub/routing.ts +18 -5
  108. package/src/hub/sdlc-queue.ts +112 -0
  109. package/src/hub/sdlc-release.ts +125 -0
  110. package/src/hub/sdlc.ts +250 -0
  111. package/src/hub/settings.ts +34 -15
  112. package/src/hub/shipped-roles.ts +25 -0
  113. package/src/hub/snapshots.ts +89 -15
  114. package/src/hub/start-mode.ts +7 -5
  115. package/src/hub/supervision.ts +1 -0
  116. package/src/hub/task-sweep.ts +1 -1
  117. package/src/hub/tasks.ts +892 -87
  118. package/src/hub/text-projection.ts +126 -0
  119. package/src/hub/tooling.ts +159 -0
  120. package/src/hub/ui.ts +16 -2
  121. package/src/hub/workflow.ts +71 -0
  122. package/src/local/sandbox.ts +6 -4
  123. package/src/local/tools.ts +11 -6
  124. package/src/memory/brief.ts +28 -14
  125. package/src/memory/import.ts +55 -0
  126. package/src/memory/recall.ts +84 -0
  127. package/src/memory/store.ts +750 -0
  128. package/src/memory/sync.ts +145 -0
  129. package/src/pet/NOTICE.md +10 -0
  130. package/src/pet/characters.ts +1438 -0
  131. package/src/pet/config.ts +31 -0
  132. package/src/pet/frame.ts +17 -0
  133. package/src/pet/pixels.ts +28 -0
  134. package/src/role-text.d.ts +1 -0
  135. package/src/ui/index.html +842 -46
  136. package/src/ui/needs.ts +58 -0
  137. package/src/ui/office-model.ts +440 -0
  138. package/src/ui/peer-palette.ts +16 -0
  139. package/src/ui/task-board.ts +49 -0
  140. package/src/ui/task-progress.ts +5 -1
  141. package/templates/AGENTS.block.md +3 -2
  142. package/templates/config.json +5 -1
  143. package/templates/profiles/dev.json +22 -0
  144. package/templates/profiles/general.json +26 -0
  145. package/templates/profiles/research.json +57 -0
  146. package/templates/profiles/writing.json +28 -0
  147. package/templates/roles/NOTICE.md +26 -0
  148. package/templates/roles/dev/backend-engineer.md +26 -0
  149. package/templates/roles/dev/code-reviewer.md +26 -0
  150. package/templates/roles/dev/frontend-engineer.md +26 -0
  151. package/templates/roles/dev/security-reviewer.md +26 -0
  152. package/templates/roles/dev/software-architect.md +26 -0
  153. package/templates/roles/dev/test-engineer.md +26 -0
  154. package/templates/roles/research/analyst.md +26 -0
  155. package/templates/roles/research/fact-checker.md +26 -0
  156. package/templates/roles/research/research-lead.md +26 -0
  157. package/templates/roles/research/researcher.md +26 -0
  158. package/templates/roles/research/statistician.md +26 -0
@@ -0,0 +1,110 @@
1
+ import { spawn, spawnSync, type ChildProcess } from "node:child_process";
2
+ import { existsSync, lstatSync, mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { isAbsolute, join } from "node:path";
5
+ import type { Readable } from "node:stream";
6
+ import type { PeerState } from "../hub/envelope.ts";
7
+ import { AcpPeer, type AcpOptions } from "./acp.ts";
8
+ import { peerChildEnv, stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
9
+
10
+ export interface JcodeOptions extends Omit<AcpOptions, "cmd" | "qwen025" | "expectedAgent" | "mcpServers" | "permissionMode"> {
11
+ command: string[];
12
+ home: string;
13
+ providerProfile?: string;
14
+ }
15
+ export function jcodeCommand(command: string[], kind: "serve" | "acp", socket: string, cwd: string, model?: string, profile?: string): string[] {
16
+ if (!command.length || command.some(word => typeof word !== "string" || !word)) throw new Error("jcode_cmd must name an executable argv");
17
+ return [...command, kind, "--socket", socket, "--cwd", cwd, "--no-update", "--no-selfdev", ...(model ? ["--model", model] : []), ...(profile ? ["--provider-profile", profile] : []),
18
+ ...(kind === "serve" ? ["--temporary-server", "--owner-pid", String(process.pid), "--server-name", "agent-hub-jcode"] : ["--mcp-tools", "eager"])];
19
+ }
20
+ /** The wrapper contract preserves every configured prefix argument before the native probe. */
21
+ export function verifiedJcodeCommand(command: string[], cwd: string): boolean {
22
+ const [bin, ...prefix] = command;
23
+ if (!bin) return false;
24
+ try {
25
+ const result = spawnSync(bin, [...prefix, "--quiet", "version", "--json"], { cwd, env: peerChildEnv("jcode"), encoding: "utf8", timeout: 10_000, maxBuffer: 64 * 1024 });
26
+ if (result.status !== 0) return false;
27
+ const version = JSON.parse(result.stdout);
28
+ return version.semver === "0.94.0";
29
+ } catch { return false; }
30
+ }
31
+
32
+ /** Both processes are ours. The user's global daemon and its sockets are never stopped. */
33
+ export class JcodePeer extends AcpPeer {
34
+ private readonly root: string;
35
+ private readonly socket: string;
36
+ private server: ChildProcess | undefined;
37
+ private ending = false;
38
+ private preserveRoot = false;
39
+ private stopPromise: Promise<void> | undefined;
40
+
41
+ constructor(private readonly jcode: JcodeOptions) {
42
+ if (!isAbsolute(jcode.home)) throw new Error("jcode_home must be an absolute native data path");
43
+ jcodeCommand(jcode.command, "acp", "preview", jcode.cwd);
44
+ const root = mkdtempSync(join(tmpdir(), "ahub-jcode-")), socket = join(root, "s");
45
+ const cmd = jcodeCommand(jcode.command, "acp", socket, jcode.cwd, jcode.launchModel, jcode.providerProfile);
46
+ super("jcode", { ...jcode, cmd, env: { ...jcode.env, JCODE_HOME: jcode.home, JCODE_SOCKET: socket, JCODE_DISABLE_CLAUDE_MCP: "1" }, mcpServers: [], requireLoadSessionEcho: true, expectedAgent: { name: "jcode", version: "0.94.0" } });
47
+ this.root = root; this.socket = socket;
48
+ }
49
+
50
+ override async start(): Promise<void> {
51
+ // The native loader otherwise imports Codex MCP into the user's store on first use.
52
+ if (!existsSync(join(this.jcode.home, "mcp.json"))) throw new Error("initialize Jcode's global MCP store with its native client before enabling this peer; the hub never initializes or copies it");
53
+ if (existsSync(this.socket)) { this.preserveRoot = true; throw new Error("Jcode private socket is occupied; no daemon was adopted or removed"); }
54
+ this.ending = false;
55
+ const [bin, ...args] = jcodeCommand(this.jcode.command, "serve", this.socket, this.jcode.cwd, this.jcode.launchModel, this.jcode.providerProfile);
56
+ const server = spawn(bin!, args, { cwd: this.jcode.cwd, detached: true, env: { ...peerChildEnv("jcode"), JCODE_HOME: this.jcode.home, JCODE_SOCKET: this.socket, JCODE_READY_FD: "3", JCODE_DISABLE_CLAUDE_MCP: "1" }, stdio: ["ignore", "ignore", "ignore", "pipe"] });
57
+ this.server = server; trackGroup(server);
58
+ try {
59
+ await new Promise<void>((resolve, reject) => {
60
+ const ready = server.stdio[3] as Readable;
61
+ const timer = setTimeout(() => reject(new Error("Jcode owned daemon did not become ready within 30 s")), 30_000);
62
+ const fail = () => { clearTimeout(timer); reject(new Error("Jcode owned daemon exited before readiness; details withheld")); };
63
+ server.once("error", fail); server.once("exit", fail);
64
+ ready.once("data", () => { clearTimeout(timer); server.off("exit", fail); if (lstatSync(this.socket, { throwIfNoEntry: false })?.isSocket()) resolve(); else reject(new Error("Jcode readiness has no private socket")); });
65
+ });
66
+ server.once("exit", () => { if (!this.ending && this.server === server) void this.stop("Jcode owned daemon disconnected").catch(() => {}); });
67
+ await super.start();
68
+ } catch (error) { await this.stop().catch(() => {}); throw error; }
69
+ }
70
+
71
+ protected override setState(next: PeerState): void {
72
+ if (next === "offline" && this.server) {
73
+ // A removed socket is not process-exit proof. Retain a non-ready state until both owned groups settle.
74
+ if (!this.ending) { super.setState("busy"); void this.stop("Jcode ACP transport ended").catch(() => {}); }
75
+ return;
76
+ }
77
+ super.setState(next);
78
+ }
79
+ override async stop(reason?: string): Promise<void> {
80
+ if (this.stopPromise) return this.stopPromise;
81
+ const pending = this.end(reason); this.stopPromise = pending;
82
+ try { await pending; } finally { if (this.stopPromise === pending) this.stopPromise = undefined; }
83
+ }
84
+ private async end(reason?: string): Promise<void> {
85
+ this.ending = true;
86
+ if (this.server && this.state !== "offline") super.setState("busy");
87
+ let failure: unknown;
88
+ try { await super.stop(reason); } catch (error) { failure = error; }
89
+ try {
90
+ if (this.server) {
91
+ const server = this.server;
92
+ const closed = server.exitCode !== null || server.signalCode !== null ? Promise.resolve() : new Promise<void>((resolve) => server.once("close", () => resolve()));
93
+ await stopOwnedProcess(server, { group: true });
94
+ let timer: ReturnType<typeof setTimeout> | undefined;
95
+ try {
96
+ await Promise.race([closed, new Promise<never>((_, reject) => { timer = setTimeout(() => reject(new Error("Jcode child exit is not confirmed; runtime retained")), 3000); })]);
97
+ } finally { clearTimeout(timer); }
98
+ if (this.server === server) this.server = undefined;
99
+ }
100
+ } catch (error) { failure ??= error; }
101
+ if (!failure && !this.server) {
102
+ if (!this.preserveRoot) rmSync(this.root, { recursive: true, force: true });
103
+ super.setState("offline");
104
+ }
105
+ if (failure) throw failure;
106
+ }
107
+ override recoveryMetadata(): Record<string, unknown> {
108
+ return { ...super.recoveryMetadata(), launch: { kind: "jcode", peer: "jcode", mode: "headless", ...(this.jcode.launchModel ? { model: this.jcode.launchModel } : {}), ...(this.jcode.providerProfile ? { providerProfile: this.jcode.providerProfile } : {}) } };
109
+ }
110
+ }
@@ -5,7 +5,7 @@ import type { StaySwitchPolicy } from "../models/route/stage.ts";
5
5
  import type { ToolObservation } from "../models/route/signals.ts";
6
6
  import { randomUUID } from "node:crypto";
7
7
  import { renderDigest, replyAudience, replyParent, STANDING_INSTRUCTION, USER, type Envelope, type EnvelopeOpts, type PeerId } from "../hub/envelope.ts";
8
- import { CONDUCTOR_TOOL_NAMES, CONDUCTOR_TOOLS, TASK_TOOL_NAMES, TASK_TOOLS } from "../hub/hub-tools.ts";
8
+ import { CONDUCTOR_TOOL_NAMES, CONDUCTOR_TOOLS, TASK_TOOL_NAMES, TASK_TOOLS, profileTaskTools } from "../hub/hub-tools.ts";
9
9
  import { BasePeer } from "../hub/peers.ts";
10
10
  import { profile, proxyEnv, type SandboxNetwork } from "../local/sandbox.ts";
11
11
  import { runTool, toolResultFailed, TOOL_SCHEMAS, touchedPaths, ApprovalWaitStop, type ToolApproval, type ApprovalProvenance, type ToolContext } from "../local/tools.ts";
@@ -16,6 +16,8 @@ import type { Sidecar } from "../switchyard/sidecar.ts";
16
16
  import type { ExecutionBudgetDecision } from "../hub/execution-budget.ts";
17
17
 
18
18
  export interface LocalOptions {
19
+ profile?: import("../hub/profiles.ts").ProfileSelection;
20
+ sdlcStages?: readonly string[];
19
21
  cwd: string;
20
22
  omni: OmniRoute;
21
23
  /** Absent = always the fixed model on OmniRoute. */
@@ -34,7 +36,7 @@ export interface LocalOptions {
34
36
  turnId?: () => string | undefined;
35
37
  onApprovalStop?: (reason: string) => void;
36
38
  onTool?: (observation: ToolObservation, task?: string) => void;
37
- tools: { deny: string[]; permit: ToolContext["permit"]; bashNetwork?: SandboxNetwork; readAllow?: string[] };
39
+ tools: { deny: string[]; permit: ToolContext["permit"]; bashNetwork?: SandboxNetwork; readAllow?: string[]; readOnly?: string[] };
38
40
  capture?: Capture;
39
41
  /** Runs a hub task tool (hub_task_*, hub_review, hub_remember) as this peer. Absent = the tools are not offered. */
40
42
  taskTool?: (name: string, args: Record<string, unknown>, turn: { pii: boolean }) => Promise<string>;
@@ -106,7 +108,7 @@ export class LocalPeer extends BasePeer {
106
108
  await this.requireBudget(envs, "model_calls");
107
109
  if (generation !== this.turn) throw new Error("route belongs to an ended turn");
108
110
  if (signal.aborted) throw new Error("turn cancelled before model request");
109
- const tools = judge ? undefined : [...TOOL_SCHEMAS, ...(this.opts.taskTool ? [...TASK_TOOLS, ...CONDUCTOR_TOOLS].map(asFunction) : [])];
111
+ const tools = judge ? undefined : [...TOOL_SCHEMAS, ...(this.opts.taskTool ? [...profileTaskTools(this.opts.profile, this.opts.sdlcStages), ...CONDUCTOR_TOOLS].map(asFunction) : [])];
110
112
  const result = await this.opts.omni.chat({ model, messages, ...(tools ? { tools } : {}), ...(judge ? { max_tokens: maxTokens ?? 2048 } : {}) }, { signal, ...(pii ? { onCampusOnly: true } : {}) });
111
113
  this.recordUsage(result, model, task);
112
114
  if (generation !== this.turn) throw new Error("route belongs to an ended turn");
@@ -119,7 +121,7 @@ export class LocalPeer extends BasePeer {
119
121
  onAdvisor: event => this.opts.onAdvisor?.(event),
120
122
  onRouteOutcome: event => this.opts.onRouteOutcome?.(event),
121
123
  });
122
- this.sandboxProfile = profile(opts.cwd, opts.tools.bashNetwork ?? false, opts.tools.readAllow, opts.tools.deny);
124
+ this.sandboxProfile = profile(opts.cwd, opts.tools.bashNetwork ?? false, opts.tools.readAllow, opts.tools.deny, opts.tools.readOnly);
123
125
  }
124
126
 
125
127
  recoveryMetadata(): Record<string, unknown> {
@@ -291,7 +293,8 @@ export class LocalPeer extends BasePeer {
291
293
  return {
292
294
  cwd: this.opts.cwd,
293
295
  deny: this.opts.tools.deny,
294
- permit: async (title, tool, _signal, canonicalTarget) => {
296
+ readOnly: this.opts.tools.readOnly,
297
+ permit: async (title, tool, _signal, canonicalTarget, _observe, originalPath) => {
295
298
  if (turnSignal.aborted) return "aborted";
296
299
  const generation = this.turn;
297
300
  let observed = false;
@@ -311,7 +314,7 @@ export class LocalPeer extends BasePeer {
311
314
  const finish = (decision: ToolApproval) => { turnSignal.removeEventListener("abort", onAbort); resolve(decision); };
312
315
  const onAbort = () => finish("aborted");
313
316
  turnSignal.addEventListener("abort", onAbort, { once: true });
314
- this.opts.tools.permit(title, tool, turnSignal, canonicalTarget, observe).then(finish, (error) => { turnSignal.removeEventListener("abort", onAbort); reject(error); });
317
+ this.opts.tools.permit(title, tool, turnSignal, canonicalTarget, observe, originalPath).then(finish, (error) => { turnSignal.removeEventListener("abort", onAbort); reject(error); });
315
318
  });
316
319
  if (generation !== this.turn || (turnSignal.aborted && picked !== "expired")) return "aborted";
317
320
  // Legacy permit callbacks can represent a person's denial without provenance. A true
@@ -375,7 +378,7 @@ export class LocalPeer extends BasePeer {
375
378
  const route = policy?.route ?? this.opts.route;
376
379
  const fixedModel = policy?.fixedModel ?? this.opts.fixedModel;
377
380
  const task = policy?.task === undefined ? undefined : Number(policy.task);
378
- const tools = [...TOOL_SCHEMAS, ...(this.opts.taskTool ? [...TASK_TOOLS, ...CONDUCTOR_TOOLS].map(asFunction) : [])];
381
+ const tools = [...TOOL_SCHEMAS, ...(this.opts.taskTool ? [...profileTaskTools(this.opts.profile, this.opts.sdlcStages), ...CONDUCTOR_TOOLS].map(asFunction) : [])];
379
382
  const signal = this.abort!.signal;
380
383
  const messages: ChatMessage[] = [{ role: "system", content: system(this.opts.cwd, this.opts.preamble) }, ...this.history, ...turnMsgs];
381
384
  const routed = await this.hubCall(route, messages, policy, signal);
@@ -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
@@ -7,9 +7,11 @@ import { ControlClient } from "../hub/control-client.ts";
7
7
  import { readEvents } from "../hub/events.ts";
8
8
  import { hubGitArgv, hubGitEnv } from "../hub/git.ts";
9
9
  import { taskRecords } from "../hub/research.ts";
10
- 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";
11
11
  import { hubHome, realPath } from "../hub/project.ts";
12
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";
13
15
  import { VERSION } from "../version.ts";
14
16
 
15
17
  /** What the runner needs from the world, so tests can drive it without a terminal. */
@@ -24,8 +26,12 @@ export interface BenchIo {
24
26
  now(): number;
25
27
  sleep(ms: number): Promise<void>;
26
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 };
27
33
  }
28
- 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 }
29
35
 
30
36
  const fail = (message: string): never => { throw new Error(message); };
31
37
 
@@ -192,12 +198,25 @@ export async function runBench(opts: BenchOptions, io: BenchIo): Promise<string>
192
198
  // ponytail: the hub's status reports a requested model for Pi only and permission modes once #242 lands; the rest
193
199
  // are recorded as absent. Reading each peer's launch settings would fill them in.
194
200
  // The status lists every peer the hub has known; an arm is the ones attached now.
195
- peers: Object.entries(status.peers ?? {}).filter(([, p]) => p.state !== "offline")
196
- .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)),
197
204
  routingHash: existsSync(routing) ? createHash("sha256").update(readFileSync(routing)).digest("hex") : null,
198
205
  hubVersion: status.version,
199
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 } : {}) };
200
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 } : {}),
201
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);
202
221
  io.log(`bench run ${run}: ${suite.name} [${opts.arm}], ${tasks.length} task${tasks.length === 1 ? "" : "s"} x ${opts.repeat}`);
203
222
  let stopped: "timeout" | "interrupted" | "error" | undefined;
@@ -237,7 +256,7 @@ async function runAttempt(task: SuiteTask, commit: string, repeat: number, run:
237
256
  // The attempt ends when its outcome is known: the wait for its measures is not part of its time.
238
257
  const done = (outcome: Outcome, extra: { error?: AttemptError; verifyExit?: number | null; hubTask?: number | null; metrics?: AttemptMetrics | null; ended?: number; open?: boolean } = {}) => {
239
258
  const ended = extra.ended ?? io.now();
240
- 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,
241
260
  hubTask: extra.hubTask ?? null, startedAt: new Date(started).toISOString(), endedAt: new Date(ended).toISOString(), ms: ended - started, metrics: extra.metrics ?? null };
242
261
  return { attempt, open: extra.open ?? false };
243
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
+ }