@staix/agent-hub 0.12.16 → 0.12.17
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 +8 -0
- package/README.md +4 -4
- package/docs/agent-notes/adapters.md +1 -0
- package/docs/agent-notes/bus.md +2 -0
- package/docs/agent-notes/tasks.md +3 -1
- package/docs/agent-notes/tests.md +1 -0
- package/docs/operations.md +106 -9
- package/docs/quickstart.md +3 -3
- package/docs/security.md +24 -0
- package/docs/smoke.md +93 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +120 -1
- package/docs/verification/2026-10-09-agent-shell-t0.md +123 -0
- package/docs/verified.json +26 -17
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +17 -5
- package/src/adapters/acp.ts +2 -2
- package/src/adapters/claude-channel.ts +3 -2
- package/src/adapters/codex-appserver.ts +3 -2
- package/src/adapters/local-worker.ts +5 -5
- package/src/cli/console-state.ts +213 -0
- package/src/cli/console.ts +201 -0
- package/src/cli/facts-hook.ts +8 -1
- package/src/cli/identity-audit.ts +66 -0
- package/src/cli/identity.ts +54 -0
- package/src/cli/launch.ts +15 -2
- package/src/cli/main.ts +62 -29
- package/src/cli/tail-render.ts +17 -0
- package/src/cli/upgrade-runtime.ts +1 -1
- package/src/hub/board.ts +6 -2
- package/src/hub/bus.ts +82 -10
- package/src/hub/child-process.ts +11 -0
- package/src/hub/conductor.ts +196 -0
- package/src/hub/control-client.ts +3 -3
- package/src/hub/daemon.ts +367 -46
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +6 -1
- package/src/hub/hub-tools.ts +13 -0
- package/src/hub/report.ts +53 -4
- package/src/hub/supervision.ts +152 -0
- package/src/hub/tasks.ts +30 -15
- package/src/hub/usage.ts +37 -2
- package/src/pi/launch.ts +2 -1
package/src/cli/main.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { currentRouting } from "../hub/routing.ts";
|
|
3
3
|
import { spawn, spawnSync } from "node:child_process";
|
|
4
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import { chmodSync, existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
5
6
|
import { homedir } from "node:os";
|
|
6
7
|
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
7
8
|
import { ControlClient, readControl } from "../hub/control-client.ts";
|
|
@@ -11,12 +12,11 @@ import { projectContext, realPath } from "../hub/project.ts";
|
|
|
11
12
|
import { Registry, type Project } from "../hub/registry.ts";
|
|
12
13
|
import { inspectProject, startProject, stopProject, runProjectDaemon } from "../hub/lifecycle.ts";
|
|
13
14
|
import { openManager, startManager, stopManager } from "../hub/manager.ts";
|
|
14
|
-
import type { BusEvent } from "../hub/bus.ts";
|
|
15
15
|
import { OmniRoute } from "../omniroute/client.ts";
|
|
16
16
|
import { MemoryClient } from "../memory/client.ts";
|
|
17
17
|
import { init, planInit } from "./init.ts";
|
|
18
18
|
import { launcherPreview } from "./preview.ts";
|
|
19
|
-
import { buildLaunch, claudeObservationHooks, UNATTENDED_WARNING } from "./launch.ts";
|
|
19
|
+
import { buildLaunch, claudeObservationHooks, nativeLaunchEnv, UNATTENDED_WARNING } from "./launch.ts";
|
|
20
20
|
import { nextStep, parseList, pluginState, type InstalledPlugin, type Marketplace } from "./setup.ts";
|
|
21
21
|
import { CLASSES } from "../hub/board.ts";
|
|
22
22
|
import { VERSION } from "../version.ts";
|
|
@@ -36,6 +36,10 @@ import { parseSince, readEvents } from "../hub/events.ts";
|
|
|
36
36
|
import { formatReport, summarize } from "../hub/report.ts";
|
|
37
37
|
import { hasTree, planUndo, repoOf, restore, Turns } from "../hub/snapshots.ts";
|
|
38
38
|
import { pathWarnings } from "../hub/conflicts.ts";
|
|
39
|
+
import { classifyPeerCommand, cliCommandLabel, detectCliIdentity, peerCommandRefusal } from "./identity.ts";
|
|
40
|
+
import { recordCliAudit } from "./identity-audit.ts";
|
|
41
|
+
import { runConsole } from "./console.ts";
|
|
42
|
+
import { renderTailEvent } from "./tail-render.ts";
|
|
39
43
|
|
|
40
44
|
/** `--since 7d|24h|<iso>` for export and report; everything when absent. */
|
|
41
45
|
const since = (): number => {
|
|
@@ -56,7 +60,8 @@ const USAGE = `agent-hub ${VERSION}: Claude Code, Codex and Kimi as peers in one
|
|
|
56
60
|
ahub ui --all --stop stop only the dashboard manager
|
|
57
61
|
ahub setup [--yes] install or update the Claude Code channel plugin from this package, then run doctor
|
|
58
62
|
ahub init [--dry-run --json] preview or write .agenthub/config.json and the AGENTS.md marker block (drops a legacy CLAUDE.md block)
|
|
59
|
-
ahub up [--unattended]
|
|
63
|
+
ahub up [--unattended] [--no-console] start the daemon; interactive terminals enter console
|
|
64
|
+
ahub console [--panels] enter the human console, leaving the daemon running on exit
|
|
60
65
|
ahub upgrade --to <version> [--dry-run] [--yes] review and upgrade running projects
|
|
61
66
|
ahub restart [--dry-run] [--yes] recover this project's runtime
|
|
62
67
|
ahub recovery status|resume|abort <operation-id> inspect, resume or cancel a preflight
|
|
@@ -129,10 +134,21 @@ const projectConfig = () => {
|
|
|
129
134
|
return config;
|
|
130
135
|
};
|
|
131
136
|
const stateDir = selected.stateDir;
|
|
137
|
+
const identity = detectCliIdentity(process.env);
|
|
138
|
+
const commandLabel = cliCommandLabel(cmd, args);
|
|
139
|
+
const commandAccess = classifyPeerCommand(cmd, args);
|
|
140
|
+
function audit(outcome: "run" | "refused" | "invalid"): void {
|
|
141
|
+
if (identity.role === "console") return;
|
|
142
|
+
try { recordCliAudit(stateDir, identity.role === "tools" ? identity.peer : "unknown", commandLabel, outcome); }
|
|
143
|
+
catch { console.error("ahub: CLI audit could not be recorded (outbox unavailable or full)"); }
|
|
144
|
+
}
|
|
145
|
+
if (identity.role === "invalid") { audit("invalid"); fail(`${identity.reason}; use ahub console or a terminal with no agent markers`); }
|
|
146
|
+
if (identity.role === "tools" && commandAccess === "console") { audit("refused"); fail(peerCommandRefusal(identity.peer, commandLabel)); }
|
|
147
|
+
if (identity.role === "tools") audit("run");
|
|
132
148
|
try { process.chdir(cwd); } catch { fail(`project directory is unavailable: ${cwd}`); }
|
|
133
149
|
const unattendedEnv = process.env.AGENTHUB_UNATTENDED === "1";
|
|
134
150
|
const lifecycle = { inspectProject, startProject, stopProject };
|
|
135
|
-
const connect = () => ControlClient.connect(stateDir, { role: "console", projectRoot: cwd });
|
|
151
|
+
const connect = () => ControlClient.connect(stateDir, identity.role === "tools" ? { role: "tools", peer: identity.peer, projectRoot: cwd } : { role: "console", projectRoot: cwd });
|
|
136
152
|
|
|
137
153
|
function registeredProject(): Project {
|
|
138
154
|
const registry = new Registry();
|
|
@@ -147,14 +163,15 @@ async function healthy(): Promise<boolean> {
|
|
|
147
163
|
finally { hub?.close(); }
|
|
148
164
|
}
|
|
149
165
|
|
|
150
|
-
function exec(bin: string, argv: string[]): never {
|
|
151
|
-
const
|
|
166
|
+
function exec(bin: string, argv: string[], tool?: "claude" | "codex"): never {
|
|
167
|
+
const env = tool ? nativeLaunchEnv(tool, childEnv()) : childEnv();
|
|
168
|
+
const res = spawnSync(bin, argv, { cwd, stdio: "inherit", env: { ...env, AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd } });
|
|
152
169
|
if (res.error) fail(`cannot run ${bin}: ${res.error.message}`);
|
|
153
170
|
process.exit(res.status ?? 1);
|
|
154
171
|
}
|
|
155
172
|
|
|
156
173
|
function execWithEnv(bin: string, argv: string[], extra: NodeJS.ProcessEnv): never {
|
|
157
|
-
const env: NodeJS.ProcessEnv = { ...extra, AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd };
|
|
174
|
+
const env: NodeJS.ProcessEnv = { ...nativeLaunchEnv("pi", extra), AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd };
|
|
158
175
|
delete env.AGENTHUB_RECOVERY_OPERATION;
|
|
159
176
|
delete env.AGENTHUB_UNATTENDED;
|
|
160
177
|
const res = spawnSync(bin, argv, { cwd, stdio: "inherit", env });
|
|
@@ -284,26 +301,11 @@ function fail(message: string): never {
|
|
|
284
301
|
process.exit(1);
|
|
285
302
|
}
|
|
286
303
|
|
|
287
|
-
function render(e: BusEvent): string {
|
|
288
|
-
if (e.t === "state") return ` . ${e.peer} is ${e.state}`;
|
|
289
|
-
if (e.t === "undeliverable") return ` ! gave up delivering ${e.env.id} (from ${e.env.from}) to ${e.peer}`;
|
|
290
|
-
if (e.t === "envelope" && e.env.from === "hub" && e.env.kind !== "chat") {
|
|
291
|
-
return `${new Date(e.env.ts).toLocaleTimeString()} hub -> ${e.env.to?.join(",")} [${e.env.kind}${e.env.refs?.task ? ` #${e.env.refs.task}` : ""}]\n${e.env.body.split("\n")[0]!.replace(/^/, " ")}`;
|
|
292
|
-
}
|
|
293
|
-
if (e.t === "overflow") return ` ! ${e.peer}'s queue is full: dropped ${e.env.id} (from ${e.env.from})`;
|
|
294
|
-
if (e.t === "stale") return ` . dropped ${e.env.id} (from ${e.env.from}) for ${e.peer}: ${e.reason}`;
|
|
295
|
-
if (e.t === "quiet") return ` . ${e.env.id} (from ${e.env.from}) not delivered to ${e.peers.join(", ")}: turn-free cohort`;
|
|
296
|
-
const { env } = e;
|
|
297
|
-
const note = e.dropped === "hop" ? " [not delivered: hop limit]" : e.dropped === "fyi" ? " [fyi: record only]" : "";
|
|
298
|
-
const head = `${env.from} -> ${env.to?.join(",") ?? "*"}${env.priority === "important" ? " !" : ""}${note}`;
|
|
299
|
-
return `${new Date(env.ts).toLocaleTimeString()} ${head}\n${env.body.replace(/^/gm, " ")}`;
|
|
300
|
-
}
|
|
301
|
-
|
|
302
304
|
async function taskOp(op: string, a: Record<string, unknown>): Promise<string> {
|
|
303
305
|
const hub = await connect();
|
|
304
306
|
const res = await hub.request({ t: "task", op, args: a });
|
|
305
307
|
hub.close();
|
|
306
|
-
if (!res.ok) fail(res.error);
|
|
308
|
+
if (!res.ok) { audit("refused"); fail(res.error); }
|
|
307
309
|
return res.text;
|
|
308
310
|
}
|
|
309
311
|
|
|
@@ -488,11 +490,17 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
488
490
|
await runProjectDaemon(registeredProject(), unattendedEnv || args.includes("--unattended"));
|
|
489
491
|
},
|
|
490
492
|
|
|
493
|
+
console: async () => {
|
|
494
|
+
if (args.some(arg => arg !== "--panels")) fail("usage: ahub console [--panels]");
|
|
495
|
+
await runConsole({ client: await connect(), cwd, stateDir, panels: args.includes("--panels") });
|
|
496
|
+
},
|
|
497
|
+
|
|
491
498
|
up: async () => {
|
|
492
499
|
const unattended = unattendedEnv || args.includes("--unattended");
|
|
493
500
|
if (unattended) console.error(UNATTENDED_WARNING);
|
|
494
501
|
const status = await startProject(registeredProject(), { unattended });
|
|
495
502
|
console.log(`ahub up (ws://127.0.0.1:${status.controlPort}), state in ${stateDir}`);
|
|
503
|
+
if (process.stdin.isTTY && process.stdout.isTTY && !args.includes("--no-console")) await runConsole({ client: await connect(), cwd, stateDir });
|
|
496
504
|
},
|
|
497
505
|
|
|
498
506
|
claude: async () => {
|
|
@@ -504,7 +512,12 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
504
512
|
const control = readControl(stateDir);
|
|
505
513
|
if (control?.instanceId) {
|
|
506
514
|
process.env.AGENTHUB_INSTANCE_ID = control.instanceId;
|
|
507
|
-
await recordTerminalLaunch("claude", cwd, stateDir, control.instanceId);
|
|
515
|
+
const terminal = await recordTerminalLaunch("claude", cwd, stateDir, control.instanceId);
|
|
516
|
+
if (!terminal) process.env.AGENTHUB_LAUNCH_ID = randomUUID();
|
|
517
|
+
// Native hook identity exists in an ordinary terminal too; this is not an Orca recovery record.
|
|
518
|
+
const file = join(stateDir, "claude-launch.json");
|
|
519
|
+
writeFileSync(`${file}.tmp`, JSON.stringify({ instanceId: control.instanceId, launchId: process.env.AGENTHUB_LAUNCH_ID }), { mode: 0o600 });
|
|
520
|
+
chmodSync(`${file}.tmp`, 0o600); renameSync(`${file}.tmp`, file);
|
|
508
521
|
}
|
|
509
522
|
// `--settings` outranks project and user settings, so the tee has to wrap whichever status line would have won:
|
|
510
523
|
// project local, then project, then user.
|
|
@@ -520,7 +533,7 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
520
533
|
const facts = claudeObservationHooks(projectConfig(), { script: join(import.meta.dir, "facts-hook.ts"), stateDir });
|
|
521
534
|
const launch = buildLaunch("claude", args, { unattended: unattendedEnv, statusLine: { script: join(import.meta.dir, "statusline-tee.ts"), stateDir, ...(original ? { original } : {}) }, ...(facts ? { facts } : {}) });
|
|
522
535
|
if (launch.warning) console.error(launch.warning);
|
|
523
|
-
exec(launch.cmd, launch.args);
|
|
536
|
+
exec(launch.cmd, launch.args, "claude");
|
|
524
537
|
},
|
|
525
538
|
|
|
526
539
|
codex: async () => {
|
|
@@ -538,7 +551,7 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
538
551
|
if (control?.instanceId) await recordTerminalLaunch("codex", cwd, stateDir, control.instanceId);
|
|
539
552
|
const launch = buildLaunch("codex", args, { unattended: unattendedEnv, proxyUrl: res.proxyUrl, codexBin: projectConfig().codex_bin });
|
|
540
553
|
if (launch0.warning) console.error(launch0.warning);
|
|
541
|
-
exec(launch.cmd, launch.args);
|
|
554
|
+
exec(launch.cmd, launch.args, "codex");
|
|
542
555
|
},
|
|
543
556
|
|
|
544
557
|
kimi: async () => {
|
|
@@ -663,7 +676,7 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
663
676
|
tail: async () => {
|
|
664
677
|
const hub = await connect();
|
|
665
678
|
hub.onPush = (msg) => {
|
|
666
|
-
if (msg.t === "event") console.log(
|
|
679
|
+
if (msg.t === "event") console.log(renderTailEvent(msg.e));
|
|
667
680
|
else if (msg.t === "context") console.log(` ${msg.peer}: ${contextLine(msg.reading)}`);
|
|
668
681
|
else if (msg.t === "notice") console.log(` * ${msg.line}`);
|
|
669
682
|
else if (msg.t === "permission") {
|
|
@@ -998,9 +1011,29 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
998
1011
|
},
|
|
999
1012
|
};
|
|
1000
1013
|
|
|
1001
|
-
|
|
1014
|
+
async function runConductorCommand(): Promise<void> {
|
|
1015
|
+
let op: string;
|
|
1016
|
+
let input: Record<string, unknown>;
|
|
1017
|
+
if (cmd === "task") {
|
|
1018
|
+
op = args[0] === "assign" ? "hub_task_assign" : "hub_task_escalate";
|
|
1019
|
+
if (!args[1] || (args[0] === "assign" && !args[2]) || args.length !== (args[0] === "assign" ? 3 : 2)) fail("usage: ahub task assign <id> <peer> | escalate <id>");
|
|
1020
|
+
input = { id: Number(args[1]), ...(args[0] === "assign" ? { peer: args[2] } : {}) };
|
|
1021
|
+
} else if (cmd === "pause" || cmd === "resume") {
|
|
1022
|
+
if (args.length !== 1) fail(`usage: ahub ${cmd} <peer>`);
|
|
1023
|
+
op = cmd === "pause" ? "hub_peer_hold" : "hub_peer_release";
|
|
1024
|
+
input = { peer: args[0] };
|
|
1025
|
+
} else {
|
|
1026
|
+
// Native TUIs return a human launch command through the daemon; an agent shell never executes it.
|
|
1027
|
+
if (args.length && !(cmd === "pi" && args.length === 2 && args[0] === "--mode" && ["headless", "tui"].includes(args[1] ?? ""))) fail(`conductor starts accept no launch overrides; use ahub ${cmd}${cmd === "pi" ? " [--mode headless|tui]" : ""}`);
|
|
1028
|
+
op = "hub_peer_start";
|
|
1029
|
+
input = { peer: cmd, ...(cmd === "pi" ? { mode: args[1] ?? "headless" } : {}) };
|
|
1030
|
+
}
|
|
1031
|
+
console.log(await taskOp(op, input));
|
|
1032
|
+
}
|
|
1033
|
+
const run = identity.role === "tools" && commandAccess === "conductor" ? runConductorCommand : commands[cmd] ?? (() => fail(`unknown command "${cmd}"\n\n${USAGE}`));
|
|
1002
1034
|
try {
|
|
1003
1035
|
await run();
|
|
1004
1036
|
} catch (e) {
|
|
1037
|
+
audit("refused");
|
|
1005
1038
|
fail((e as Error).message);
|
|
1006
1039
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { BusEvent } from "../hub/bus.ts";
|
|
2
|
+
|
|
3
|
+
export function renderTailEvent(e: BusEvent): string {
|
|
4
|
+
if (e.t === "state") return ` . ${e.peer} is ${e.state}`;
|
|
5
|
+
if (e.t === "undeliverable") return ` ! gave up delivering ${e.env.id} (from ${e.env.from}) to ${e.peer}`;
|
|
6
|
+
if (e.t === "envelope" && e.env.from === "hub" && e.env.kind !== "chat") {
|
|
7
|
+
return `${new Date(e.env.ts).toLocaleTimeString()} hub -> ${e.env.to?.join(",")} [${e.env.kind}${e.env.refs?.task ? ` #${e.env.refs.task}` : ""}]\n${e.env.body.split("\n")[0]!.replace(/^/, " ")}`;
|
|
8
|
+
}
|
|
9
|
+
if (e.t === "overflow") return ` ! ${e.peer}'s queue is full: dropped ${e.env.id} (from ${e.env.from})`;
|
|
10
|
+
if (e.t === "stale") return ` . dropped ${e.env.id} (from ${e.env.from}) for ${e.peer}: ${e.reason}`;
|
|
11
|
+
if (e.t === "quiet") return ` . ${e.env.id} (from ${e.env.from}) not delivered to ${e.peers.join(", ")}: turn-free cohort`;
|
|
12
|
+
const { env } = e;
|
|
13
|
+
const note = e.dropped === "hop" ? " [not delivered: hop limit]" : e.dropped === "fyi" ? " [fyi: record only]" : "";
|
|
14
|
+
const head = `${env.from} -> ${env.to?.join(",") ?? "*"}${env.priority === "important" ? " !" : ""}${note}`;
|
|
15
|
+
return `${new Date(env.ts).toLocaleTimeString()} ${head}\n${env.body.replace(/^/gm, " ")}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
@@ -114,7 +114,7 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
|
|
|
114
114
|
catch { source = { state: "unavailable", peers: [], blockers: ["source recovery metadata could not be authenticated"] }; }
|
|
115
115
|
if (source.state === "stopped" || source.state === "missing") continue;
|
|
116
116
|
const blockers = [...source.blockers];
|
|
117
|
-
if (!RECOVERY_SOURCE_PROTOCOLS.includes(source.protocol as (typeof RECOVERY_SOURCE_PROTOCOLS)[number]) || source.state !== "running") blockers.push("manual-bootstrap-required: an authenticated protocol-9, protocol-10, protocol-11, protocol-12, protocol-13 or protocol-
|
|
117
|
+
if (!RECOVERY_SOURCE_PROTOCOLS.includes(source.protocol as (typeof RECOVERY_SOURCE_PROTOCOLS)[number]) || source.state !== "running") blockers.push("manual-bootstrap-required: an authenticated protocol-9, protocol-10, protocol-11, protocol-12, protocol-13, protocol-14 or protocol-15 source is required");
|
|
118
118
|
if (source.recovery?.operationId && source.recovery.phase !== "released") blockers.push(`existing recovery operation ${source.recovery.operationId} must be resolved first`);
|
|
119
119
|
const sessions: { codex?: string; claude?: string; pi?: SessionRef } = {};
|
|
120
120
|
for (const peer of source.peers) {
|
package/src/hub/board.ts
CHANGED
|
@@ -18,6 +18,8 @@ export type TaskPlan = Partial<Record<(typeof PLAN_KEYS)[number], string[]>>;
|
|
|
18
18
|
/** Task outcomes older than this are pruned: demotion (issue #36) reads one week, seven half-lives. */
|
|
19
19
|
export const OUTCOMES_KEPT_MS = 7 * 24 * 3_600_000;
|
|
20
20
|
|
|
21
|
+
export const TASK_MOVE_REASONS = ["manual", "budget", "offline", "idle", "rejections", "declined", "inference_failed", "delivery_failed"] as const;
|
|
22
|
+
export type TaskMoveReason = (typeof TASK_MOVE_REASONS)[number];
|
|
21
23
|
export interface HistoryEntry {
|
|
22
24
|
at: number;
|
|
23
25
|
by: PeerId;
|
|
@@ -27,6 +29,8 @@ export interface HistoryEntry {
|
|
|
27
29
|
owner?: PeerId | null;
|
|
28
30
|
/** On a hand-over: the new owner's split profile then (#109); absent when it was unknown. */
|
|
29
31
|
profile?: string;
|
|
32
|
+
/** Structured cause recorded at the operation source, never inferred from note text. */
|
|
33
|
+
reason?: TaskMoveReason;
|
|
30
34
|
/** Persisted task-idle ladder; never counted as fresh task activity (#186). */
|
|
31
35
|
sweep?: SweepRecord;
|
|
32
36
|
}
|
|
@@ -122,14 +126,14 @@ export class Board {
|
|
|
122
126
|
|
|
123
127
|
/** The only way a task changes. Validates the move, records who did what, returns the new row. */
|
|
124
128
|
/** A `plan` in the patch replaces the old one whole: a new plan is the owner's current intent, not an addition. */
|
|
125
|
-
update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "reviewer" | "refs" | "plan" | "rejections">>, note?: string, extra: Pick<HistoryEntry, "profile" | "sweep"> = {}): Task {
|
|
129
|
+
update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "reviewer" | "refs" | "plan" | "rejections">>, note?: string, extra: Pick<HistoryEntry, "profile" | "sweep" | "reason"> = {}): Task {
|
|
126
130
|
const task = this.get(id);
|
|
127
131
|
if (!task) throw new Error(`no task #${id}`);
|
|
128
132
|
if (patch.state && patch.state !== task.state && !MOVES[task.state].includes(patch.state)) {
|
|
129
133
|
throw new Error(`task #${id} is ${task.state}: cannot move to ${patch.state}`);
|
|
130
134
|
}
|
|
131
135
|
const next = { ...task, ...patch, refs: { ...task.refs, ...patch.refs } };
|
|
132
|
-
const history = [...task.history, { at: Date.now(), by, event, ...(note ? { note } : {}), ...("owner" in patch ? { owner: next.owner } : {}), ...(extra.profile ? { profile: extra.profile } : {}), ...(extra.sweep ? { sweep: extra.sweep } : {}) }];
|
|
136
|
+
const history = [...task.history, { at: Date.now(), by, event, ...(note ? { note } : {}), ...("owner" in patch ? { owner: next.owner } : {}), ...(extra.profile ? { profile: extra.profile } : {}), ...(extra.sweep ? { sweep: extra.sweep } : {}), ...(extra.reason && TASK_MOVE_REASONS.includes(extra.reason) ? { reason: extra.reason } : {}) }];
|
|
133
137
|
this.db
|
|
134
138
|
.query("UPDATE tasks SET state = ?, owner = ?, reviewer = ?, refs = ?, plan = ?, rejections = ?, history = ?, updated = ? WHERE id = ?")
|
|
135
139
|
.run(next.state, next.owner, next.reviewer, JSON.stringify(next.refs), JSON.stringify(next.plan ?? {}), next.rejections, JSON.stringify(history), Date.now(), id);
|
package/src/hub/bus.ts
CHANGED
|
@@ -82,6 +82,8 @@ function resolveTo(to: PeerId[], originals: Envelope[] | undefined): PeerId[] {
|
|
|
82
82
|
|
|
83
83
|
const SEEN_CAP = 2048;
|
|
84
84
|
const MAX_ATTEMPTS = 3;
|
|
85
|
+
// ponytail: one digest carries at most ten originals; larger supervision windows split into bounded deliveries.
|
|
86
|
+
// Revisit this ceiling after measuring native supervision cost, rather than bypassing the delivery limit.
|
|
85
87
|
const DIGEST_MAX = 10;
|
|
86
88
|
const NOTES_KEPT = 10;
|
|
87
89
|
|
|
@@ -100,6 +102,8 @@ export class Bus {
|
|
|
100
102
|
private readonly manualPaused = new Set<PeerId>();
|
|
101
103
|
private readonly timers = new Map<PeerId, ReturnType<typeof setTimeout>>();
|
|
102
104
|
private readonly draining = new Set<PeerId>();
|
|
105
|
+
/** Still pending while an asynchronous condensation runs; never includes native dispatches. */
|
|
106
|
+
private readonly preparing = new Map<PeerId, Envelope[]>();
|
|
103
107
|
private readonly seen = new Map<string, Envelope>();
|
|
104
108
|
private readonly taps = new Set<(e: BusEvent) => void>();
|
|
105
109
|
private readonly withdrawn = new Set<string>();
|
|
@@ -119,6 +123,10 @@ export class Bus {
|
|
|
119
123
|
* and is never corrected once the queue empties (measured: status.json kept `queued 4` on an empty queue).
|
|
120
124
|
*/
|
|
121
125
|
onQueues?: () => void;
|
|
126
|
+
/** Actual transport admission, using originals even when a digest was condensed. Never an enqueue metric. */
|
|
127
|
+
onDelivered?: (peer: PeerId, originals: Envelope[]) => void;
|
|
128
|
+
/** Synchronous native failure observation, before queue callbacks can finish turn metrics. */
|
|
129
|
+
onDeliveryFailed?: (peer: PeerId) => void;
|
|
122
130
|
private recoveryHeld = false;
|
|
123
131
|
private steering = 0;
|
|
124
132
|
private condensing = 0;
|
|
@@ -389,7 +397,9 @@ export class Bus {
|
|
|
389
397
|
const id = receipt.id;
|
|
390
398
|
const before = this.journal.get(id);
|
|
391
399
|
const state: JournalDeliveryState = receipt.state === "accepted" ? "accepted" : receipt.state === "completed" ? "completed" : receipt.state === "failed_safe" ? "failed" : "needs_review";
|
|
400
|
+
if (state === "failed" || state === "needs_review") this.deliveryFailed(peer);
|
|
392
401
|
this.journal.transition(id, state, receipt.reason);
|
|
402
|
+
if (before?.state === "dispatching" && (state === "accepted" || state === "completed")) this.delivered(peer, before.originals);
|
|
393
403
|
if (state === "failed") {
|
|
394
404
|
if (before) this.failed(peer, before.originals, before.originals.some((e) => e.private) ? "private delivery failed" : receipt.reason);
|
|
395
405
|
}
|
|
@@ -525,7 +535,8 @@ export class Bus {
|
|
|
525
535
|
this.activeDeliveries.set(deliveryId, id);
|
|
526
536
|
if (this.journal) this.durableHandoff(id, deliveryId, [env], [env]);
|
|
527
537
|
const steerResult = this.journal ? peer.steer([env], deliveryId) : peer.steer([env]);
|
|
528
|
-
steerResult.then(() => {}).catch(() => {
|
|
538
|
+
steerResult.then(() => { this.delivered(id, [env]); }).catch(() => {
|
|
539
|
+
this.deliveryFailed(id);
|
|
529
540
|
if (this.journal) this.journal.transition(deliveryId, "needs_review", "adapter steering outcome is uncertain");
|
|
530
541
|
this.activeDeliveries.delete(deliveryId);
|
|
531
542
|
if (!this.journal) this.enqueue(id, env, true);
|
|
@@ -570,8 +581,7 @@ export class Bus {
|
|
|
570
581
|
* dropped if the refused steer tries to queue it later.
|
|
571
582
|
*/
|
|
572
583
|
withdraw(envelopeId: string): boolean {
|
|
573
|
-
this.
|
|
574
|
-
if (this.withdrawn.size > 256) this.withdrawn.delete(this.withdrawn.values().next().value as string);
|
|
584
|
+
this.markWithdrawn(envelopeId);
|
|
575
585
|
let removed = false;
|
|
576
586
|
for (const queue of this.queues.values()) {
|
|
577
587
|
const i = queue.findIndex((e) => e.id === envelopeId);
|
|
@@ -582,6 +592,49 @@ export class Bus {
|
|
|
582
592
|
return removed;
|
|
583
593
|
}
|
|
584
594
|
|
|
595
|
+
/** Revoke pending feed copies only; accepted/dispatching journal records and native turns are untouched. */
|
|
596
|
+
revokeSupervision(peer: PeerId): number {
|
|
597
|
+
const queue = this.queues.get(peer);
|
|
598
|
+
if (!queue && !this.preparing.has(peer)) return 0;
|
|
599
|
+
let removed = 0;
|
|
600
|
+
const discarded = new Set<string>();
|
|
601
|
+
for (let i = (queue?.length ?? 0) - 1; i >= 0; i--) {
|
|
602
|
+
const env = queue![i]!;
|
|
603
|
+
if (env.from !== HUB || !env.refs?.supervision) continue;
|
|
604
|
+
queue!.splice(i, 1);
|
|
605
|
+
removed++;
|
|
606
|
+
discarded.add(env.id);
|
|
607
|
+
this.markWithdrawn(env.id);
|
|
608
|
+
this.discardQueued(peer, env, "supervision role or feed revoked");
|
|
609
|
+
}
|
|
610
|
+
for (const env of this.preparing.get(peer) ?? []) {
|
|
611
|
+
if (env.from !== HUB || !env.refs?.supervision || discarded.has(env.id)) continue;
|
|
612
|
+
this.markWithdrawn(env.id);
|
|
613
|
+
this.discardQueued(peer, env, "supervision role or feed revoked during preparation");
|
|
614
|
+
removed++;
|
|
615
|
+
}
|
|
616
|
+
if (removed) { this.persist(); this.onQueues?.(); }
|
|
617
|
+
return removed;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
private discardQueued(peer: PeerId, env: Envelope, reason: string): void {
|
|
621
|
+
if (this.journal) this.pendingOutcomes.push({ id: crypto.randomUUID(), peer, state: "discarded", createdAt: Date.now(), originals: [env], out: [], reason });
|
|
622
|
+
this.emit({ t: "stale", env, peer, reason });
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
private delivered(peer: PeerId, originals: Envelope[]): void {
|
|
626
|
+
try { this.onDelivered?.(peer, originals); } catch { /* observation must never affect delivery settlement */ }
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
private deliveryFailed(peer: PeerId): void {
|
|
630
|
+
try { this.onDeliveryFailed?.(peer); } catch { /* observation must never affect delivery settlement */ }
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
private markWithdrawn(id: string): void {
|
|
634
|
+
this.withdrawn.add(id);
|
|
635
|
+
if (this.withdrawn.size > 256) this.withdrawn.delete(this.withdrawn.values().next().value as string);
|
|
636
|
+
}
|
|
637
|
+
|
|
585
638
|
queued(id: PeerId): number {
|
|
586
639
|
return this.queues.get(id)?.length ?? 0;
|
|
587
640
|
}
|
|
@@ -599,8 +652,23 @@ export class Bus {
|
|
|
599
652
|
if (this.withdrawn.has(env.id)) return;
|
|
600
653
|
const queue = this.queues.get(id) ?? [];
|
|
601
654
|
this.queues.set(id, queue);
|
|
602
|
-
|
|
603
|
-
|
|
655
|
+
const key = env.from === HUB && env.refs?.supervision ? env.refs.supervisionKey : undefined;
|
|
656
|
+
const replace = !front && key ? queue.findIndex((old) => old.from === HUB && old.refs?.supervision && old.refs.supervisionKey === key && !this.attempts.has(`${id}:${old.id}`)) : -1;
|
|
657
|
+
const preparing = !front && key ? this.preparing.get(id)?.find((old) => old.from === HUB && old.refs?.supervision && old.refs.supervisionKey === key && !this.attempts.has(`${id}:${old.id}`) && !this.withdrawn.has(old.id)) : undefined;
|
|
658
|
+
let queuedEnv = preparing ? { ...env, ts: Math.min(preparing.ts, env.ts) } : env;
|
|
659
|
+
if (replace >= 0) {
|
|
660
|
+
const old = queue[replace]!;
|
|
661
|
+
// Keep the first milestone's deadline, so continuous updates cannot create another batching window.
|
|
662
|
+
queuedEnv = { ...queuedEnv, ts: Math.min(old.ts, queuedEnv.ts) };
|
|
663
|
+
queue[replace] = queuedEnv;
|
|
664
|
+
this.markWithdrawn(old.id);
|
|
665
|
+
this.discardQueued(id, old, "superseded supervision milestone");
|
|
666
|
+
} else if (front) queue.unshift(queuedEnv);
|
|
667
|
+
else queue.push(queuedEnv);
|
|
668
|
+
if (preparing && !this.withdrawn.has(preparing.id)) {
|
|
669
|
+
this.markWithdrawn(preparing.id);
|
|
670
|
+
this.discardQueued(id, preparing, "superseded supervision milestone during preparation");
|
|
671
|
+
}
|
|
604
672
|
if (queue.length > this.opts.queueCap) {
|
|
605
673
|
const victim = queue.findIndex((e) => e.priority !== "important");
|
|
606
674
|
const [lost] = queue.splice(victim === -1 ? 0 : victim, 1);
|
|
@@ -614,7 +682,8 @@ export class Bus {
|
|
|
614
682
|
|
|
615
683
|
/** 0 = deliver now; otherwise how long the oldest envelope still has to wait for company. */
|
|
616
684
|
private wait(queue: Envelope[]): number {
|
|
617
|
-
|
|
685
|
+
const supervisionOnly = queue.every((e) => e.from === HUB && e.refs?.supervision && e.priority === "status");
|
|
686
|
+
if ((!supervisionOnly && queue.length >= this.opts.batchMax) || queue.some((e) => e.priority === "important")) return 0;
|
|
618
687
|
return Math.max(0, queue[0]!.ts + this.opts.batchMs - Date.now());
|
|
619
688
|
}
|
|
620
689
|
|
|
@@ -656,6 +725,7 @@ export class Bus {
|
|
|
656
725
|
// may checkpoint during that wait; it must not checkpoint the batch out of existence.
|
|
657
726
|
if (!this.journal) this.prefaces.delete(id);
|
|
658
727
|
const batch = this.take(id, queue, !this.journal);
|
|
728
|
+
this.preparing.set(id, batch);
|
|
659
729
|
const delivery = preface ? [preface, ...batch] : batch;
|
|
660
730
|
const mayCondense = this.opts.condense && !delivery.some((e) => e.priority === "important");
|
|
661
731
|
this.condensing += mayCondense ? 1 : 0;
|
|
@@ -679,11 +749,12 @@ export class Bus {
|
|
|
679
749
|
for (const e of out) if (!this.seen.has(e.id)) this.seen.set(e.id, e);
|
|
680
750
|
while (this.seen.size > SEEN_CAP) this.seen.delete(this.seen.keys().next().value as string);
|
|
681
751
|
this.lastDelivery.set(id, { out, originals: delivery });
|
|
752
|
+
this.preparing.delete(id); // from here on the transport/journal owns settlement
|
|
682
753
|
const deliveryId = crypto.randomUUID();
|
|
683
754
|
if (this.journal) { this.durableHandoff(id, deliveryId, delivery, out); this.activeDeliveries.set(deliveryId, id); }
|
|
684
755
|
try {
|
|
685
756
|
if (this.journal) await peer.deliver(out, deliveryId);
|
|
686
|
-
else await peer.deliver(out);
|
|
757
|
+
else { await peer.deliver(out); this.delivered(id, delivery); }
|
|
687
758
|
} catch {
|
|
688
759
|
if (this.journal) this.uncertain(id, deliveryId, "adapter delivery outcome is uncertain");
|
|
689
760
|
else this.failed(id, delivery);
|
|
@@ -691,11 +762,11 @@ export class Bus {
|
|
|
691
762
|
}
|
|
692
763
|
}
|
|
693
764
|
} catch { this.storageError = "delivery journal unavailable"; }
|
|
694
|
-
finally { this.draining.delete(id); this.onQueues?.(); }
|
|
765
|
+
finally { this.preparing.delete(id); this.draining.delete(id); this.onQueues?.(); }
|
|
695
766
|
}
|
|
696
767
|
|
|
697
768
|
private isRelevant(id: PeerId, env: Envelope): boolean {
|
|
698
|
-
return this.opts.relevant?.(id, env) ?? true;
|
|
769
|
+
return !this.withdrawn.has(env.id) && (this.opts.relevant?.(id, env) ?? true);
|
|
699
770
|
}
|
|
700
771
|
|
|
701
772
|
/**
|
|
@@ -704,7 +775,7 @@ export class Bus {
|
|
|
704
775
|
* this recipient's copy goes: the other queues keep theirs, and no other delivery record changes.
|
|
705
776
|
*/
|
|
706
777
|
private dropIrrelevant(id: PeerId, queue: Envelope[]): boolean {
|
|
707
|
-
if (!this.opts.relevant) return false;
|
|
778
|
+
if (!this.opts.relevant && !this.withdrawn.size) return false;
|
|
708
779
|
let dropped = false;
|
|
709
780
|
for (let i = queue.length - 1; i >= 0; i--) {
|
|
710
781
|
const env = queue[i]!;
|
|
@@ -757,6 +828,7 @@ export class Bus {
|
|
|
757
828
|
}
|
|
758
829
|
|
|
759
830
|
private failed(id: PeerId, envs: Envelope[], reason = "delivery failed without an error detail"): void {
|
|
831
|
+
if (!this.journal) this.deliveryFailed(id);
|
|
760
832
|
this.lastFailure.set(id, reason.replace(/\s+/g, " ").slice(0, 300));
|
|
761
833
|
if (envs.some((env) => !(env.from === HUB && env.kind === "presence") && (this.attempts.get(`${id}:${env.id}`) ?? 0) + 1 >= MAX_ATTEMPTS)) {
|
|
762
834
|
this.failureStreak.set(id, (this.failureStreak.get(id) ?? 0) + 1);
|
package/src/hub/child-process.ts
CHANGED
|
@@ -11,6 +11,17 @@ export function childEnv(source: NodeJS.ProcessEnv = process.env): NodeJS.Proces
|
|
|
11
11
|
return env;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
/** Every native child owns its marker; caller vendor markers must not leak across peers (#193). */
|
|
15
|
+
export function peerChildEnv(peer: string, source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
|
16
|
+
const env = childEnv(source);
|
|
17
|
+
delete env.CLAUDECODE;
|
|
18
|
+
delete env.CLAUDE_CODE_SESSION_ID;
|
|
19
|
+
delete env.CODEX_THREAD_ID;
|
|
20
|
+
delete env.AGENTHUB_PEER_ID;
|
|
21
|
+
env.AGENTHUB_PEER_ID = peer;
|
|
22
|
+
return env;
|
|
23
|
+
}
|
|
24
|
+
|
|
14
25
|
/** One process: its identity is the pid with its start time (`lstart`, which exec keeps); group and command are evidence. */
|
|
15
26
|
export interface ProcRow {
|
|
16
27
|
pid: number;
|