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