@staix/agent-hub 0.12.23 → 0.12.25

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 (161) hide show
  1. package/CHANGELOG.md +109 -1
  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 +10 -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 +8 -0
  15. package/docs/agent-notes/tests.md +5 -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 +666 -19
  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 +2 -2
  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 +288 -0
  32. package/docs/specs/2026-09-19-agent-hub-design.md +633 -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/verification/2026-10-14-0.12.24-audit.md +123 -0
  43. package/docs/verification/2026-10-14-0.12.25.md +27 -0
  44. package/docs/verified.json +22 -13
  45. package/package.json +2 -1
  46. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  47. package/plugins/agent-hub/ROLE-NOTICE.md +26 -0
  48. package/plugins/agent-hub/server.js +1175 -339
  49. package/scripts/check-docs.mjs +129 -0
  50. package/src/adapters/acp.ts +112 -13
  51. package/src/adapters/claude-channel.ts +48 -11
  52. package/src/adapters/codex-appserver.ts +11 -0
  53. package/src/adapters/jcode.ts +110 -0
  54. package/src/adapters/local-worker.ts +10 -7
  55. package/src/adapters/pi.ts +171 -23
  56. package/src/cli/attention.ts +60 -0
  57. package/src/cli/bench.ts +24 -5
  58. package/src/cli/board-read.ts +12 -0
  59. package/src/cli/console-state.ts +103 -17
  60. package/src/cli/console.ts +54 -8
  61. package/src/cli/facts-hook.ts +12 -4
  62. package/src/cli/help.ts +35 -9
  63. package/src/cli/identity.ts +18 -7
  64. package/src/cli/init.ts +48 -10
  65. package/src/cli/jcode-mcp.ts +58 -0
  66. package/src/cli/kimi-mcp.ts +53 -0
  67. package/src/cli/launch.ts +16 -1
  68. package/src/cli/main.ts +392 -57
  69. package/src/cli/memory.ts +193 -0
  70. package/src/cli/output.ts +45 -4
  71. package/src/cli/pet-config.ts +1 -0
  72. package/src/cli/pet.ts +83 -0
  73. package/src/cli/preview.ts +38 -5
  74. package/src/cli/status-lines.ts +1 -1
  75. package/src/cli/tail-render.ts +1 -0
  76. package/src/cli/upgrade-runtime.ts +32 -3
  77. package/src/cli/watch.ts +31 -0
  78. package/src/hub/activity.ts +53 -0
  79. package/src/hub/ask.ts +10 -5
  80. package/src/hub/bench.ts +10 -3
  81. package/src/hub/board.ts +222 -16
  82. package/src/hub/bus.ts +48 -9
  83. package/src/hub/citations.ts +7 -0
  84. package/src/hub/conductor.ts +12 -7
  85. package/src/hub/config-trust.ts +4 -2
  86. package/src/hub/crash.ts +9 -0
  87. package/src/hub/daemon.ts +914 -120
  88. package/src/hub/delivery-journal.ts +5 -0
  89. package/src/hub/envelope.ts +1 -1
  90. package/src/hub/events.ts +12 -2
  91. package/src/hub/facts.ts +26 -7
  92. package/src/hub/git.ts +8 -2
  93. package/src/hub/github-import.ts +28 -0
  94. package/src/hub/hub-tools.ts +49 -10
  95. package/src/hub/inference.ts +1 -1
  96. package/src/hub/lifecycle-schema.ts +17 -0
  97. package/src/hub/listen.ts +15 -0
  98. package/src/hub/manager.ts +12 -5
  99. package/src/hub/path-policy.ts +115 -0
  100. package/src/hub/peer-catalog.ts +28 -0
  101. package/src/hub/permission-mode.ts +19 -5
  102. package/src/hub/planning.ts +70 -0
  103. package/src/hub/profiles.ts +74 -0
  104. package/src/hub/projected-diff.ts +73 -0
  105. package/src/hub/projection-exec.ts +24 -0
  106. package/src/hub/research.ts +144 -21
  107. package/src/hub/roles.ts +123 -0
  108. package/src/hub/roster.ts +23 -0
  109. package/src/hub/routing.ts +18 -5
  110. package/src/hub/sdlc-queue.ts +112 -0
  111. package/src/hub/sdlc-release.ts +125 -0
  112. package/src/hub/sdlc.ts +250 -0
  113. package/src/hub/settings.ts +34 -15
  114. package/src/hub/shipped-roles.ts +25 -0
  115. package/src/hub/snapshots.ts +89 -15
  116. package/src/hub/start-mode.ts +7 -5
  117. package/src/hub/supervision.ts +1 -0
  118. package/src/hub/task-sweep.ts +1 -1
  119. package/src/hub/tasks.ts +905 -89
  120. package/src/hub/text-projection.ts +151 -0
  121. package/src/hub/tooling.ts +170 -0
  122. package/src/hub/ui.ts +16 -2
  123. package/src/hub/workflow.ts +71 -0
  124. package/src/local/sandbox.ts +6 -4
  125. package/src/local/tools.ts +11 -6
  126. package/src/memory/brief.ts +28 -14
  127. package/src/memory/import.ts +55 -0
  128. package/src/memory/recall.ts +84 -0
  129. package/src/memory/store.ts +757 -0
  130. package/src/memory/sync.ts +145 -0
  131. package/src/pet/NOTICE.md +10 -0
  132. package/src/pet/characters.ts +1438 -0
  133. package/src/pet/config.ts +31 -0
  134. package/src/pet/frame.ts +17 -0
  135. package/src/pet/pixels.ts +28 -0
  136. package/src/pi/extension.ts +40 -5
  137. package/src/role-text.d.ts +1 -0
  138. package/src/ui/index.html +843 -46
  139. package/src/ui/needs.ts +58 -0
  140. package/src/ui/office-model.ts +440 -0
  141. package/src/ui/peer-palette.ts +16 -0
  142. package/src/ui/task-board.ts +49 -0
  143. package/src/ui/task-progress.ts +5 -1
  144. package/templates/AGENTS.block.md +3 -2
  145. package/templates/config.json +5 -1
  146. package/templates/profiles/dev.json +22 -0
  147. package/templates/profiles/general.json +26 -0
  148. package/templates/profiles/research.json +57 -0
  149. package/templates/profiles/writing.json +28 -0
  150. package/templates/roles/NOTICE.md +26 -0
  151. package/templates/roles/dev/backend-engineer.md +26 -0
  152. package/templates/roles/dev/code-reviewer.md +26 -0
  153. package/templates/roles/dev/frontend-engineer.md +26 -0
  154. package/templates/roles/dev/security-reviewer.md +26 -0
  155. package/templates/roles/dev/software-architect.md +26 -0
  156. package/templates/roles/dev/test-engineer.md +26 -0
  157. package/templates/roles/research/analyst.md +26 -0
  158. package/templates/roles/research/fact-checker.md +26 -0
  159. package/templates/roles/research/research-lead.md +26 -0
  160. package/templates/roles/research/researcher.md +26 -0
  161. 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);
@@ -28,6 +28,10 @@ export interface PiExit {
28
28
  export interface PiToolContext { userBash: boolean; isCurrent: () => boolean; onApproval: (provenance: ApprovalProvenance) => void; }
29
29
  export interface PiToolResult { text: string; exitCode: number | null; }
30
30
  class PiToolSettlementFailure extends Error {}
31
+ class PiPromptNotStarted extends Error {
32
+ constructor(readonly promptId: string) { super("Pi native session changed before prompt insertion; reverify its retained terminal before retrying"); }
33
+ }
34
+ class PiPromptUncertain extends Error {}
31
35
  export interface PiOptions {
32
36
  cwd: string; stateDir: string; cmd?: string[]; mode: "headless" | "tui"; backend: "auto" | "dgx" | "mlx"; sessionFile?: string; sessionId?: string;
33
37
  model?: string;
@@ -99,6 +103,15 @@ export class PiPeer extends BasePeer {
99
103
  private ownerClaimed = false;
100
104
  private ownerToken = "";
101
105
  private stopping = true;
106
+ private rosterDetached = false;
107
+ private rosterEpoch = 0;
108
+ private rosterLifecycleRevision = 0;
109
+ private fencedPrompt = false;
110
+ private hubPromptId: string | undefined;
111
+ private hubPromptGeneration: number | undefined;
112
+ private pendingPromptId: string | undefined;
113
+ /** A personal native turn observed on reattach has no hub reply audience. */
114
+ private rosterUnownedRun = false;
102
115
  private exitReported = false;
103
116
  private started = false;
104
117
  private lastToolName?: string;
@@ -118,7 +131,7 @@ export class PiPeer extends BasePeer {
118
131
  if (this.canReuseVerifiedEmptyResume()) return { sessionId: this.verifiedEmptyResume!.sessionId };
119
132
  return { sessionId: this.opts.sessionId, sessionFile: this.opts.sessionFile };
120
133
  }
121
- get acceptingTools(): boolean { return !this.stopping; }
134
+ get acceptingTools(): boolean { return !this.stopping && !this.rosterDetached && !this.rosterUnownedRun; }
122
135
  /** Idle verified owners can be stopped; uncertain live owners and pending launches stay fenced. */
123
136
  get recoveryReady(): boolean {
124
137
  if (this.state === "busy" || this.starting || this.activeTools) return false;
@@ -171,13 +184,13 @@ export class PiPeer extends BasePeer {
171
184
 
172
185
  /** Envelopes belonging to the currently executing Pi turn, for relay-side model-call admission. */
173
186
  /** 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; }
187
+ get observationTurn(): string | undefined { return this.acceptingTools && this.agentRunning && this.modelStep ? `${this.observationScope}.${this.budgetGeneration}.${this.modelStep}` : undefined; }
175
188
 
176
- get budgetEnvelopes(): Envelope[] { return this.agentRunning && this.state === "busy" ? this.activeEnvs.slice() : []; }
189
+ get budgetEnvelopes(): Envelope[] { return this.acceptingTools && this.agentRunning && this.state === "busy" ? this.activeEnvs.slice() : []; }
177
190
 
178
191
  /** Mark an authoritative relay admission stop against the current Pi turn, never a later turn. */
179
192
  recordBudgetStop(decision: ExecutionBudgetDecision): { generation: number; reason: string } | undefined {
180
- if (!this.agentRunning || this.state !== "busy") return undefined;
193
+ if (!this.acceptingTools || !this.agentRunning || this.state !== "busy") return undefined;
181
194
  const generation = this.budgetGeneration;
182
195
  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
196
  this.budgetStops.set(generation, reason);
@@ -192,7 +205,7 @@ export class PiPeer extends BasePeer {
192
205
  try { await this.starting; } finally { this.starting = undefined; }
193
206
  }
194
207
  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 });
208
+ 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
209
  if (!this.verifiedOwner() || typeof body.sessionId !== "string" || !body.sessionId || body.sessionId !== this.sessionId || !Number.isSafeInteger(body.generation) || body.generation !== this.budgetGeneration) {
197
210
  const text = "error: Pi tool lineage is missing, stale or unverified; restart Pi with the current hub extension before retrying";
198
211
  this.opts.log?.(`[${this.id}] ${text}`);
@@ -238,9 +251,9 @@ export class PiPeer extends BasePeer {
238
251
  try {
239
252
  const result = await this.opts.executeTool(name, body.args, callId, this.sessionId, executionSignal, {
240
253
  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),
254
+ 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
255
  onApproval: (provenance) => {
243
- if (!this.agentRunning || generation !== this.budgetGeneration || turnSignal !== this.executionAbort?.signal || this.approvalStopReason) return;
256
+ if (!this.acceptingTools || !this.agentRunning || generation !== this.budgetGeneration || turnSignal !== this.executionAbort?.signal || this.approvalStopReason) return;
244
257
  if (this.approvalAnswerEpoch !== provenance.answerEpoch) {
245
258
  this.approvalExpiries = 0; this.approvalAnswerEpoch = provenance.answerEpoch;
246
259
  }
@@ -269,7 +282,7 @@ export class PiPeer extends BasePeer {
269
282
  if (this.agentRunning && generation === this.budgetGeneration && this.approvalStopReason === reason && !this.stopping) void this.endApprovalTurn(generation, reason);
270
283
  }, 10);
271
284
  }
272
- if (this.state === "busy") {
285
+ if (this.state === "busy" && this.acceptingTools) {
273
286
  if (!this.activeTools && !this.agentRunning && !this.activeEnvs.length) this.setState("idle");
274
287
  else this.touch();
275
288
  }
@@ -277,11 +290,12 @@ export class PiPeer extends BasePeer {
277
290
  }
278
291
 
279
292
  private async handleBudgetRequest(body: any): Promise<Response> {
293
+ if (this.rosterDetached || this.rosterUnownedRun) return Response.json({ error: this.rosterDetached ? "Pi is off roster" : "Pi native turn is pending" }, { status: 409 });
280
294
  if (!["model_calls", "tool_calls"].includes(String(body.unit))) return Response.json({ error: "invalid Pi budget unit" }, { status: 400 });
281
295
  const idleUserBash = body.idleUserBash === true;
282
296
  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;
297
+ const current = () => Number.isSafeInteger(generation) && generation === this.budgetGeneration && controller === this.executionAbort && session === this.sessionId && ownerPid === this.ownerPid && ownerSignature === this.ownerSignature && this.acceptingTools && this.verifiedOwner();
298
+ const idleOwner = this.opts.mode === "tui" && this.acceptingTools && this.verifiedOwner() && this.state === "idle" && !this.agentRunning && this.activeTools === 0 && this.activeEnvs.length === 0;
285
299
  if (!current() || (idleUserBash ? !idleOwner : !this.agentRunning || this.state !== "busy")) return Response.json({ error: "stale Pi budget request" }, { status: 409 });
286
300
  // An interactive user shell has no task delivery. It may spend an eligible run budget only.
287
301
  const envs = idleUserBash ? [] : this.budgetEnvelopes;
@@ -311,8 +325,10 @@ export class PiPeer extends BasePeer {
311
325
 
312
326
  private async startImpl(): Promise<void> {
313
327
  this.stopping = false;
328
+ this.rosterDetached = false; this.rosterUnownedRun = false;
314
329
  this.requestedStopReason = undefined;
315
330
  this.approvalTurnAbort = false;
331
+ this.fencedPrompt = false; this.hubPromptId = undefined; this.hubPromptGeneration = undefined; this.pendingPromptId = undefined;
316
332
  this.exitReported = false; this.started = false; this.lastToolName = undefined; this.shutdownExit = undefined;
317
333
  this.approvalExpiries = 0; this.approvalAnswerEpoch = undefined; this.approvalStopReason = ""; this.approvalAbortSent = false;
318
334
  mkdirSync(this.opts.stateDir, { recursive: true });
@@ -341,7 +357,13 @@ export class PiPeer extends BasePeer {
341
357
  if (url.pathname === "/ack") {
342
358
  const pending = this.tuiCommands.get(String(body.id));
343
359
  if (!pending) return Response.json({ ok: false }, { status: 404 });
344
- this.tuiCommands.delete(String(body.id)); body.ok ? pending.resolve(body.result) : pending.reject(new Error(String(body.error ?? "Pi TUI command failed")));
360
+ this.tuiCommands.delete(String(body.id));
361
+ const refusal = body.refusal;
362
+ const boundRefusal = this.fencedPrompt && this.verifiedOwner() && pending.command.type === "prompt" && pending.command.sessionId === this.sessionId
363
+ && refusal?.promptId === pending.command.id && refusal.sessionId === this.sessionId
364
+ && Number.isSafeInteger(refusal.generation) && refusal.generation >= Number(pending.command.generation);
365
+ body.ok ? pending.resolve(body.result) : pending.reject(boundRefusal && refusal.kind === "prompt_not_started" ? new PiPromptNotStarted(String(body.id))
366
+ : boundRefusal && refusal.kind === "prompt_uncertain" ? new PiPromptUncertain("Pi native prompt insertion is uncertain; inspect its retained terminal before retrying") : new Error(String(body.error ?? "Pi TUI command failed")));
345
367
  return Response.json({ ok: true });
346
368
  }
347
369
  if (url.pathname === "/tool") return this.handleToolRequest(body);
@@ -403,8 +425,50 @@ export class PiPeer extends BasePeer {
403
425
  }
404
426
  get mode(): "headless" | "tui" { return this.opts.mode; }
405
427
 
428
+ /** Withdraw hub admission without shutting down a person's Pi TUI or its bridge. */
429
+ async detachForRoster(): Promise<void> {
430
+ if (this.opts.mode !== "tui") throw new Error("Pi roster detach requires a personal TUI");
431
+ this.rosterDetached = true; this.rosterEpoch++;
432
+ this.setState("offline");
433
+ clearTimeout(this.executionBudgetTimer); this.executionBudgetTimer = undefined;
434
+ const ids = [...this.activeDeliveryIds]; this.activeDeliveryIds.clear();
435
+ this.activeEnvs = []; this.currentReply = undefined;
436
+ this.settledText = ""; this.settledError = ""; this.settledCancelled = false;
437
+ for (const id of ids) this.delivery({ id, state: "needs_review", reason: "Pi removed from roster before settlement; inspect the native session before retrying" });
438
+ const settling = this.abortTools();
439
+ // Revoke hub commands not yet consumed, and fence an ACK already in flight by rosterEpoch.
440
+ for (const [id, pending] of this.tuiCommands) {
441
+ if (!["prompt", "steer", "set_model"].includes(String(pending.command.type))) continue;
442
+ this.tuiCommands.delete(id); this.tuiQueue = this.tuiQueue.filter((command) => command.id !== id);
443
+ pending.reject(new Error("Pi is off roster"));
444
+ }
445
+ if (settling.length) {
446
+ let timer: ReturnType<typeof setTimeout> | undefined;
447
+ 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); })]); }
448
+ finally { clearTimeout(timer); }
449
+ }
450
+ }
451
+
452
+ /** A return reuses only the same verified native owner and a fresh, truthful snapshot. */
453
+ async resumeFromRoster(): Promise<void> {
454
+ if (!this.rosterDetached) return;
455
+ 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");
456
+ if (!this.fencedPrompt) throw new Error("Pi roster return needs a fenced-prompt extension; restart the retained terminal with the current hub extension");
457
+ if (this.pendingPromptId) throw new Error("Pi prompt admission is still pending; wait before reverifying the retained terminal");
458
+ const epoch = this.rosterEpoch, lifecycle = this.rosterLifecycleRevision, session = this.sessionId, file = this.sessionFile;
459
+ const snapshot = await this.sendTui({ type: "get_session_state" });
460
+ 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");
461
+ if (lifecycle !== this.rosterLifecycleRevision) throw new Error("Pi native activity changed during roster return; retry with a fresh session snapshot");
462
+ this.rosterDetached = false;
463
+ this.hubPromptId = undefined; this.hubPromptGeneration = undefined;
464
+ this.rosterUnownedRun = !snapshot.idle; this.agentRunning = !snapshot.idle;
465
+ this.setState(snapshot.idle ? "idle" : "busy");
466
+ if (!snapshot.idle) this.clearWatchdog();
467
+ }
468
+
406
469
  async stop(reason?: string): Promise<void> {
407
470
  if (reason !== undefined) this.requestedStopReason = reason;
471
+ this.rosterEpoch++;
408
472
  this.stopping = true; // fence new calls before withdrawing both turn and idle-shell execution
409
473
  for (const id of this.activeDeliveryIds) this.delivery({ id, state: "needs_review", reason: reason ?? "Pi session stopped before settlement" });
410
474
  this.activeDeliveryIds.clear();
@@ -482,22 +546,53 @@ export class PiPeer extends BasePeer {
482
546
  }
483
547
 
484
548
  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`);
549
+ if (this.state !== "idle" || !this.acceptingTools) {
550
+ const reason = `${this.id} is not ready${this.rosterDetached ? ": off roster" : this.rosterUnownedRun ? ": native turn pending" : ""}`;
551
+ if (deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason });
552
+ throw new Error(reason);
488
553
  }
489
554
  this.setState("busy"); this.activeEnvs = envs; this.currentReply = replyParent(envs); this.settledText = "";
555
+ const rosterEpoch = this.rosterEpoch;
490
556
  let attempted = false;
557
+ let promptId: string | undefined;
491
558
  try {
492
559
  const requested = await this.opts.selectModel?.(envs);
560
+ if (rosterEpoch !== this.rosterEpoch || !this.acceptingTools) throw new Error("Pi is off roster or stopped");
493
561
  if (requested) await this.setRequestedModel(requested);
562
+ if (rosterEpoch !== this.rosterEpoch || !this.acceptingTools) throw new Error("Pi is off roster or stopped");
494
563
  this.noteActivity();
495
564
  attempted = true;
496
565
  if (deliveryId) this.activeDeliveryIds.add(deliveryId);
497
- if (this.opts.mode === "tui") await this.sendTui({ type: "prompt", message: renderDigest(envs, true) });
566
+ if (this.opts.mode === "tui") { const pending = this.sendTui({ type: "prompt", message: renderDigest(envs, true) }); promptId = this.pendingPromptId; await pending; }
498
567
  else await this.sendRpc({ type: "prompt", message: renderDigest(envs, true) });
568
+ if (promptId !== undefined && promptId !== this.pendingPromptId) return;
569
+ if (this.fencedPrompt && this.rosterDetached) {
570
+ if (deliveryId && this.activeDeliveryIds.delete(deliveryId)) this.delivery({ id: deliveryId, state: "needs_review", reason: "Pi native activity changed before prompt acceptance was confirmed" });
571
+ this.setState("offline");
572
+ return;
573
+ }
499
574
  if (deliveryId && this.activeDeliveryIds.has(deliveryId)) this.delivery({ id: deliveryId, state: "accepted" });
500
575
  } catch (error) {
576
+ if (promptId !== undefined && promptId !== this.pendingPromptId) return;
577
+ if (error instanceof PiPromptUncertain) { this.pendingPromptId = undefined; await this.detachForRoster(); return; }
578
+ if (error instanceof PiPromptNotStarted && error.promptId === this.pendingPromptId) {
579
+ this.pendingPromptId = undefined;
580
+ if (deliveryId) this.activeDeliveryIds.delete(deliveryId);
581
+ if (deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason: error.message });
582
+ else this.onFailed?.(envs);
583
+ await this.detachForRoster();
584
+ return;
585
+ }
586
+ if (this.fencedPrompt && this.pendingPromptId && this.rosterDetached) {
587
+ this.pendingPromptId = undefined;
588
+ if (deliveryId && this.activeDeliveryIds.delete(deliveryId)) this.delivery({ id: deliveryId, state: "needs_review", reason: "Pi prompt acknowledgement is uncertain after personal native activity" });
589
+ this.setState("offline");
590
+ return;
591
+ }
592
+ if (rosterEpoch !== this.rosterEpoch) {
593
+ if (!attempted && deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason: "Pi withdrawn before native dispatch" });
594
+ return;
595
+ }
501
596
  if (!attempted) {
502
597
  this.activeEnvs = []; this.setState("idle");
503
598
  if (deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason: error instanceof Error ? error.message : String(error) });
@@ -511,14 +606,18 @@ export class PiPeer extends BasePeer {
511
606
  this.delivery({ id: deliveryId, state: "failed_safe", reason });
512
607
  }
513
608
  await this.terminateFailedTurn(error instanceof Error ? error : new Error(reason));
609
+ } finally {
610
+ if (promptId !== undefined && this.pendingPromptId === promptId) this.pendingPromptId = undefined;
514
611
  }
515
612
  }
516
613
 
517
614
  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");
615
+ if (this.state !== "busy" || !this.acceptingTools || (this.fencedPrompt && this.pendingPromptId && this.hubPromptGeneration === undefined) || envs.some((e) => e.kind !== "chat" || e.private || e.priority !== "important")) {
616
+ const reason = `Pi workflow/private messages must queue${this.rosterDetached ? ": off roster" : this.rosterUnownedRun ? ": native turn pending" : ""}`;
617
+ if (deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason });
618
+ throw new Error(reason);
521
619
  }
620
+ const rosterEpoch = this.rosterEpoch;
522
621
  const reply = replyParent([...this.activeEnvs, ...envs]);
523
622
  this.activeEnvs.push(...envs); this.currentReply = reply;
524
623
  try {
@@ -527,12 +626,14 @@ export class PiPeer extends BasePeer {
527
626
  else await this.sendRpc({ type: "steer", message: renderDigest(envs, true) });
528
627
  if (deliveryId && this.activeDeliveryIds.has(deliveryId)) this.delivery({ id: deliveryId, state: "accepted" });
529
628
  } catch (error) {
629
+ if (rosterEpoch !== this.rosterEpoch) return;
530
630
  await this.terminateFailedTurn(error instanceof Error ? error : new Error(String(error)));
531
631
  }
532
632
  }
533
633
 
534
634
  getRequestedModel(): string { return this.requestedModel; }
535
635
  async setRequestedModel(model: string): Promise<void> {
636
+ if (!this.acceptingTools) throw new Error("Pi is off roster or stopped");
536
637
  const slash = model.indexOf("/");
537
638
  const provider = "agent-hub-local";
538
639
  const modelId = model;
@@ -541,10 +642,54 @@ export class PiPeer extends BasePeer {
541
642
  }
542
643
 
543
644
  private handleBridgeEvent(event: any): void {
645
+ if (this.fencedPrompt && this.opts.mode === "tui" && !this.rosterUnownedRun && ["agent_start", "agent_end", "agent_settled"].includes(event.type)
646
+ && typeof event.promptId === "string" && event.promptId !== this.hubPromptId) return;
647
+ if (this.fencedPrompt && this.opts.mode === "tui" && (this.activeEnvs.length > 0 || this.pendingPromptId || this.hubPromptId) && !this.rosterDetached && !this.rosterUnownedRun && !this.stopping && ["agent_start", "agent_end", "agent_settled"].includes(event.type)) {
648
+ const bound = this.verifiedOwner() && (event.sessionId === undefined || event.sessionId === this.sessionId);
649
+ if (!bound || !Number.isSafeInteger(event.generation) || event.generation < this.budgetGeneration) return;
650
+ const owned = this.hubPromptId !== undefined && event.promptId === this.hubPromptId;
651
+ if (event.type === "agent_start" && event.generation > this.budgetGeneration) {
652
+ if (owned) this.hubPromptGeneration = event.generation;
653
+ else {
654
+ if (this.pendingPromptId || this.activeEnvs.length) {
655
+ // Await the prompt's exact ACK before deciding whether its effect was absent or uncertain.
656
+ this.rosterDetached = true; this.rosterEpoch++; this.clearWatchdog();
657
+ this.activeEnvs = []; this.currentReply = undefined; this.settledText = ""; this.settledError = "";
658
+ this.abortTools();
659
+ if (!this.pendingPromptId) {
660
+ for (const id of this.activeDeliveryIds) this.delivery({ id, state: "needs_review", reason: "Pi personal native activity replaced the accepted hub turn" });
661
+ this.activeDeliveryIds.clear(); this.setState("offline");
662
+ }
663
+ } else { this.rosterUnownedRun = true; this.setState("busy"); this.clearWatchdog(); }
664
+ }
665
+ } else if (!this.rosterUnownedRun && (!owned || event.generation !== this.hubPromptGeneration)) return;
666
+ }
667
+ if (this.rosterDetached || this.rosterUnownedRun) {
668
+ // The shipped extension's legacy agent_start omits sessionId. Its authenticated bridge
669
+ // and still-verified owner bind that event to this session; an explicit mismatch never does.
670
+ const bound = !this.stopping && this.verifiedOwner() && (event.sessionId === undefined || event.sessionId === this.sessionId);
671
+ if (bound && event.type === "agent_start" && Number.isSafeInteger(event.generation) && event.generation > this.budgetGeneration) {
672
+ this.rosterLifecycleRevision++;
673
+ this.budgetGeneration = event.generation;
674
+ this.noteActivity(); // personal native activity also revokes an earlier ID-only empty-resume proof
675
+ }
676
+ // Snapshot ACKs and lifecycle events use separate requests, so their arrival order is not native state order.
677
+ if (bound && event.type === "agent_settled" && (event.generation === undefined || event.generation === this.budgetGeneration)) this.rosterLifecycleRevision++;
678
+ if (!bound && event.type !== "session_shutdown") return;
679
+ }
680
+ if (this.rosterDetached && event.type !== "session_shutdown") return;
681
+ if (this.rosterUnownedRun && event.type !== "session_shutdown") {
682
+ if (event.type === "agent_settled" && (event.generation === undefined || event.generation === this.budgetGeneration)) {
683
+ this.rosterUnownedRun = false; this.agentRunning = false;
684
+ if (!this.stopping && this.verifiedOwner() && !this.activeTools) this.setState("idle");
685
+ }
686
+ return;
687
+ }
544
688
  if (this.stopping && ["agent_start", "agent_end", "agent_settled"].includes(event.type)) return;
545
689
  if (event.type === "session_start") {
546
690
  if (!this.ownerClaimed) { this.ownerClaimed = true; this.ownerToken = String(event.ownerToken ?? ""); }
547
691
  this.approvalTurnAbort = event.approvalTurnAbort === true;
692
+ this.fencedPrompt ||= event.fencedPrompt === true;
548
693
  this.sessionId = String(event.sessionId ?? ""); this.sessionFile = String(event.sessionFile ?? "");
549
694
  this.ownerPid = Number.isInteger(event.pid) ? event.pid : undefined; this.ownerSignature = typeof event.signature === "string" ? event.signature : undefined;
550
695
  if ((this.opts.sessionId && this.sessionId !== this.opts.sessionId) || (this.opts.sessionFile && this.sessionFile !== this.opts.sessionFile)) { this.opts.log?.(`[${this.id}] Pi session identity mismatch`); return; }
@@ -625,6 +770,7 @@ export class PiPeer extends BasePeer {
625
770
  for (const id of this.activeDeliveryIds) this.delivery({ id, state: approvalStopReason ? "completed" : error || cancelled ? "needs_review" : "completed", ...(error || cancelled ? { reason: error || "Pi turn cancelled; partial effects are possible" } : {}) });
626
771
  this.activeDeliveryIds.clear();
627
772
  this.currentReply = undefined; this.activeEnvs = [];
773
+ this.hubPromptId = undefined; this.hubPromptGeneration = undefined;
628
774
  this.executionAbort?.abort(); this.executionAbort = undefined;
629
775
  this.approvalExpiries = 0; this.approvalAnswerEpoch = undefined; this.approvalStopReason = ""; this.approvalAbortSent = false;
630
776
  this.abortedToolIds.clear(); this.abortIdsOverflow = false;
@@ -643,7 +789,9 @@ export class PiPeer extends BasePeer {
643
789
  }
644
790
  private sendTui(command: Record<string, unknown>): Promise<any> {
645
791
  const id = `ahub-${++this.seq}`;
646
- const wire = { id, ...command };
792
+ const fenced = this.fencedPrompt && command.type === "prompt";
793
+ if (fenced) { this.pendingPromptId = id; this.hubPromptId = id; this.hubPromptGeneration = undefined; }
794
+ const wire = { id, ...command, ...(fenced ? { sessionId: this.sessionId, generation: this.budgetGeneration } : {}) };
647
795
  return new Promise((resolvePromise, reject) => {
648
796
  const timer = setTimeout(() => {
649
797
  this.tuiCommands.delete(id);
@@ -655,7 +803,7 @@ export class PiPeer extends BasePeer {
655
803
  if (waiter) { clearTimeout(waiter.timer); waiter.resolve(wire); } else this.tuiQueue.push(wire);
656
804
  });
657
805
  }
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.
806
+ 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
807
  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
808
  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
809
  private async waitRpc(command: string, timeout: number): Promise<RpcMessage> {
@@ -688,7 +836,7 @@ export class PiPeer extends BasePeer {
688
836
  const envs = this.activeEnvs; this.activeEnvs = [];
689
837
  for (const id of this.activeDeliveryIds) this.delivery({ id, state: "needs_review", reason: error.message });
690
838
  this.activeDeliveryIds.clear();
691
- if (envs.length && !this.requestedStopReason) {
839
+ if (envs.length && !this.requestedStopReason && !this.rosterDetached) {
692
840
  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
841
  if (!approvalStopReason && !(error instanceof ApprovalWaitStop)) void this.opts.onTurnFailure?.(envs, error.message).catch(() => this.opts.log?.("Pi failure handoff could not be completed"));
694
842
  }
@@ -712,7 +860,7 @@ export class PiPeer extends BasePeer {
712
860
  }
713
861
  private clearOwnerMonitor(): void { if (this.ownerMonitor) clearInterval(this.ownerMonitor); this.ownerMonitor = undefined; }
714
862
  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;
863
+ if (!this.agentRunning || generation !== this.budgetGeneration || this.approvalStopReason !== reason || !this.acceptingTools || this.state === "offline") return;
716
864
  this.executionAbort?.abort();
717
865
  if (this.approvalTurnAbort) {
718
866
  try { await this.sendTui({ type: "abort_budget", generation, cause: "approval", reason }); return; }
@@ -742,7 +890,7 @@ export class PiPeer extends BasePeer {
742
890
  }
743
891
  }
744
892
  protected override onWatchdog(): void {
745
- if (this.stopping) return;
893
+ if (this.stopping || this.rosterDetached || this.rosterUnownedRun) return;
746
894
  if (this.activeTools) { this.touch(); return; }
747
895
  void this.terminateFailedTurn(new Error("Pi run became inactive before agent_settled"));
748
896
  }
@@ -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
+ }