@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.
- package/CHANGELOG.md +109 -1
- package/README.md +8 -3
- package/docs/addons/claude-mem.md +24 -0
- package/docs/addons/codex-plugin.md +8 -0
- package/docs/addons/dot-admit.md +8 -0
- package/docs/addons/open-code-review.md +8 -0
- package/docs/addons/ponytail.md +23 -0
- package/docs/addons/ripwire.md +22 -0
- package/docs/agent-notes/README.md +5 -0
- package/docs/agent-notes/adapters.md +10 -1
- package/docs/agent-notes/bus.md +3 -1
- package/docs/agent-notes/daemon.md +7 -1
- package/docs/agent-notes/local-worker.md +4 -2
- package/docs/agent-notes/tasks.md +8 -0
- package/docs/agent-notes/tests.md +5 -1
- package/docs/bench.md +28 -3
- package/docs/cooperbench.md +4 -0
- package/docs/data-policy.md +36 -0
- package/docs/events.md +3 -0
- package/docs/gsd-collaboration-analysis.md +48 -0
- package/docs/memory.md +231 -0
- package/docs/operations.md +666 -19
- package/docs/plans/2026-10-11-issues-109-sequential.md +81 -0
- package/docs/plans/2026-10-11-routing-evaluation-proposal.md +30 -0
- package/docs/profiles.md +57 -0
- package/docs/quickstart.md +2 -2
- package/docs/research.md +31 -1
- package/docs/roles.md +85 -0
- package/docs/sdlc.md +170 -0
- package/docs/security.md +111 -4
- package/docs/smoke.md +288 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +633 -7
- package/docs/specs/2026-10-12-backlog-implementation-plan.md +186 -0
- package/docs/specs/2026-10-12-office-view-design.md +212 -0
- package/docs/text-projection.md +19 -0
- package/docs/verification/2026-10-11-acp-watch-t0.md +33 -0
- package/docs/verification/2026-10-11-agent-config-inventory.md +20 -0
- package/docs/verification/2026-10-11-jcode-peer-contract.md +27 -0
- package/docs/verification/2026-10-13-0.12.24.md +94 -0
- package/docs/verification/2026-10-13-board-phone.md +31 -0
- package/docs/verification/2026-10-13-fsmonitor-fixture-attribution.md +19 -0
- package/docs/verification/2026-10-14-0.12.24-audit.md +123 -0
- package/docs/verification/2026-10-14-0.12.25.md +27 -0
- package/docs/verified.json +22 -13
- package/package.json +2 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/ROLE-NOTICE.md +26 -0
- package/plugins/agent-hub/server.js +1175 -339
- package/scripts/check-docs.mjs +129 -0
- package/src/adapters/acp.ts +112 -13
- package/src/adapters/claude-channel.ts +48 -11
- package/src/adapters/codex-appserver.ts +11 -0
- package/src/adapters/jcode.ts +110 -0
- package/src/adapters/local-worker.ts +10 -7
- package/src/adapters/pi.ts +171 -23
- package/src/cli/attention.ts +60 -0
- package/src/cli/bench.ts +24 -5
- package/src/cli/board-read.ts +12 -0
- package/src/cli/console-state.ts +103 -17
- package/src/cli/console.ts +54 -8
- package/src/cli/facts-hook.ts +12 -4
- package/src/cli/help.ts +35 -9
- package/src/cli/identity.ts +18 -7
- package/src/cli/init.ts +48 -10
- package/src/cli/jcode-mcp.ts +58 -0
- package/src/cli/kimi-mcp.ts +53 -0
- package/src/cli/launch.ts +16 -1
- package/src/cli/main.ts +392 -57
- package/src/cli/memory.ts +193 -0
- package/src/cli/output.ts +45 -4
- package/src/cli/pet-config.ts +1 -0
- package/src/cli/pet.ts +83 -0
- package/src/cli/preview.ts +38 -5
- package/src/cli/status-lines.ts +1 -1
- package/src/cli/tail-render.ts +1 -0
- package/src/cli/upgrade-runtime.ts +32 -3
- package/src/cli/watch.ts +31 -0
- package/src/hub/activity.ts +53 -0
- package/src/hub/ask.ts +10 -5
- package/src/hub/bench.ts +10 -3
- package/src/hub/board.ts +222 -16
- package/src/hub/bus.ts +48 -9
- package/src/hub/citations.ts +7 -0
- package/src/hub/conductor.ts +12 -7
- package/src/hub/config-trust.ts +4 -2
- package/src/hub/crash.ts +9 -0
- package/src/hub/daemon.ts +914 -120
- package/src/hub/delivery-journal.ts +5 -0
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +12 -2
- package/src/hub/facts.ts +26 -7
- package/src/hub/git.ts +8 -2
- package/src/hub/github-import.ts +28 -0
- package/src/hub/hub-tools.ts +49 -10
- package/src/hub/inference.ts +1 -1
- package/src/hub/lifecycle-schema.ts +17 -0
- package/src/hub/listen.ts +15 -0
- package/src/hub/manager.ts +12 -5
- package/src/hub/path-policy.ts +115 -0
- package/src/hub/peer-catalog.ts +28 -0
- package/src/hub/permission-mode.ts +19 -5
- package/src/hub/planning.ts +70 -0
- package/src/hub/profiles.ts +74 -0
- package/src/hub/projected-diff.ts +73 -0
- package/src/hub/projection-exec.ts +24 -0
- package/src/hub/research.ts +144 -21
- package/src/hub/roles.ts +123 -0
- package/src/hub/roster.ts +23 -0
- package/src/hub/routing.ts +18 -5
- package/src/hub/sdlc-queue.ts +112 -0
- package/src/hub/sdlc-release.ts +125 -0
- package/src/hub/sdlc.ts +250 -0
- package/src/hub/settings.ts +34 -15
- package/src/hub/shipped-roles.ts +25 -0
- package/src/hub/snapshots.ts +89 -15
- package/src/hub/start-mode.ts +7 -5
- package/src/hub/supervision.ts +1 -0
- package/src/hub/task-sweep.ts +1 -1
- package/src/hub/tasks.ts +905 -89
- package/src/hub/text-projection.ts +151 -0
- package/src/hub/tooling.ts +170 -0
- package/src/hub/ui.ts +16 -2
- package/src/hub/workflow.ts +71 -0
- package/src/local/sandbox.ts +6 -4
- package/src/local/tools.ts +11 -6
- package/src/memory/brief.ts +28 -14
- package/src/memory/import.ts +55 -0
- package/src/memory/recall.ts +84 -0
- package/src/memory/store.ts +757 -0
- package/src/memory/sync.ts +145 -0
- package/src/pet/NOTICE.md +10 -0
- package/src/pet/characters.ts +1438 -0
- package/src/pet/config.ts +31 -0
- package/src/pet/frame.ts +17 -0
- package/src/pet/pixels.ts +28 -0
- package/src/pi/extension.ts +40 -5
- package/src/role-text.d.ts +1 -0
- package/src/ui/index.html +843 -46
- package/src/ui/needs.ts +58 -0
- package/src/ui/office-model.ts +440 -0
- package/src/ui/peer-palette.ts +16 -0
- package/src/ui/task-board.ts +49 -0
- package/src/ui/task-progress.ts +5 -1
- package/templates/AGENTS.block.md +3 -2
- package/templates/config.json +5 -1
- package/templates/profiles/dev.json +22 -0
- package/templates/profiles/general.json +26 -0
- package/templates/profiles/research.json +57 -0
- package/templates/profiles/writing.json +28 -0
- package/templates/roles/NOTICE.md +26 -0
- package/templates/roles/dev/backend-engineer.md +26 -0
- package/templates/roles/dev/code-reviewer.md +26 -0
- package/templates/roles/dev/frontend-engineer.md +26 -0
- package/templates/roles/dev/security-reviewer.md +26 -0
- package/templates/roles/dev/software-architect.md +26 -0
- package/templates/roles/dev/test-engineer.md +26 -0
- package/templates/roles/research/analyst.md +26 -0
- package/templates/roles/research/fact-checker.md +26 -0
- package/templates/roles/research/research-lead.md +26 -0
- package/templates/roles/research/researcher.md +26 -0
- 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 ? [...
|
|
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
|
-
|
|
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 ? [...
|
|
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);
|
package/src/adapters/pi.ts
CHANGED
|
@@ -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.
|
|
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 &&
|
|
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 &&
|
|
284
|
-
const idleOwner = this.opts.mode === "tui" &&
|
|
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));
|
|
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.
|
|
486
|
-
|
|
487
|
-
|
|
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")
|
|
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.
|
|
519
|
-
|
|
520
|
-
|
|
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
|
|
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.
|
|
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
|
+
}
|