@staix/agent-hub 0.12.15 → 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 +17 -0
- package/README.md +18 -5
- package/docs/agent-notes/adapters.md +1 -0
- package/docs/agent-notes/budget.md +5 -1
- package/docs/agent-notes/bus.md +2 -0
- package/docs/agent-notes/tasks.md +3 -1
- package/docs/agent-notes/tests.md +3 -1
- package/docs/operations.md +277 -11
- package/docs/quickstart.md +4 -4
- package/docs/security.md +26 -1
- package/docs/smoke.md +93 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +223 -0
- package/docs/verification/2026-10-09-agent-shell-t0.md +123 -0
- package/docs/verified.json +168 -0
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +18 -6
- package/src/adapters/acp.ts +2 -2
- package/src/adapters/claude-channel.ts +3 -2
- package/src/adapters/codex-appserver.ts +10 -2
- package/src/adapters/local-worker.ts +5 -5
- package/src/adapters/pi.ts +4 -14
- 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/init.ts +40 -30
- package/src/cli/launch.ts +31 -1
- package/src/cli/main.ts +95 -39
- package/src/cli/preview.ts +80 -0
- package/src/cli/status-lines.ts +8 -2
- package/src/cli/statusline-tee.ts +14 -0
- package/src/cli/tail-render.ts +17 -0
- package/src/cli/upgrade-runtime.ts +1 -1
- package/src/hub/board.ts +9 -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/context-window.ts +72 -0
- package/src/hub/control-client.ts +3 -3
- package/src/hub/daemon.ts +492 -77
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +7 -1
- package/src/hub/hub-tools.ts +14 -1
- package/src/hub/report.ts +53 -4
- package/src/hub/supervision.ts +152 -0
- package/src/hub/task-sweep.ts +59 -0
- package/src/hub/tasks.ts +86 -15
- package/src/hub/usage.ts +37 -2
- package/src/models/relay.ts +6 -1
- package/src/pi/launch.ts +21 -0
- package/src/ui/index.html +6 -0
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,11 +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
|
-
import { init } from "./init.ts";
|
|
18
|
-
import {
|
|
17
|
+
import { init, planInit } from "./init.ts";
|
|
18
|
+
import { launcherPreview } from "./preview.ts";
|
|
19
|
+
import { buildLaunch, claudeObservationHooks, nativeLaunchEnv, UNATTENDED_WARNING } from "./launch.ts";
|
|
19
20
|
import { nextStep, parseList, pluginState, type InstalledPlugin, type Marketplace } from "./setup.ts";
|
|
20
21
|
import { CLASSES } from "../hub/board.ts";
|
|
21
22
|
import { VERSION } from "../version.ts";
|
|
@@ -29,11 +30,16 @@ import { recordTerminalLaunch } from "./terminal-recovery.ts";
|
|
|
29
30
|
import { ensureMlx, inspectMlx, stopMlx } from "../models/mlx.ts";
|
|
30
31
|
import { setupOllamaModel } from "./models-setup.ts";
|
|
31
32
|
|
|
32
|
-
import {
|
|
33
|
+
import { unknownContext } from "../hub/context-window.ts";
|
|
34
|
+
import { backendLine, contextLine, peerLine, type BackendRow, type PeerRow } from "./status-lines.ts";
|
|
33
35
|
import { parseSince, readEvents } from "../hub/events.ts";
|
|
34
36
|
import { formatReport, summarize } from "../hub/report.ts";
|
|
35
37
|
import { hasTree, planUndo, repoOf, restore, Turns } from "../hub/snapshots.ts";
|
|
36
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";
|
|
37
43
|
|
|
38
44
|
/** `--since 7d|24h|<iso>` for export and report; everything when absent. */
|
|
39
45
|
const since = (): number => {
|
|
@@ -53,15 +59,16 @@ const USAGE = `agent-hub ${VERSION}: Claude Code, Codex and Kimi as peers in one
|
|
|
53
59
|
ahub ui --all [--no-open] open the unified project dashboard
|
|
54
60
|
ahub ui --all --stop stop only the dashboard manager
|
|
55
61
|
ahub setup [--yes] install or update the Claude Code channel plugin from this package, then run doctor
|
|
56
|
-
ahub init
|
|
57
|
-
ahub up [--unattended]
|
|
62
|
+
ahub init [--dry-run --json] preview or write .agenthub/config.json and the AGENTS.md marker block (drops a legacy CLAUDE.md block)
|
|
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
|
|
58
65
|
ahub upgrade --to <version> [--dry-run] [--yes] review and upgrade running projects
|
|
59
66
|
ahub restart [--dry-run] [--yes] recover this project's runtime
|
|
60
67
|
ahub recovery status|resume|abort <operation-id> inspect, resume or cancel a preflight
|
|
61
|
-
ahub claude [args...] launch Claude Code with the hub channel [--unattended]
|
|
62
|
-
ahub codex [args...] start the Codex adapter and attach the TUI [--unattended]
|
|
63
|
-
ahub kimi [--model <alias>] start Kimi headless under ACP
|
|
64
|
-
ahub pi [--mode headless|tui] [--backend auto|dgx|mlx] [--session-id <id>] [--session-file <path>] start Pi
|
|
68
|
+
ahub claude [--print-command] [args...] launch Claude Code with the hub channel [--unattended]
|
|
69
|
+
ahub codex [--print-command] [args...] start the Codex adapter and attach the TUI [--unattended]
|
|
70
|
+
ahub kimi [--print-command] [--model <alias>] start Kimi headless under ACP
|
|
71
|
+
ahub pi [--print-command] [--mode headless|tui] [--backend auto|dgx|mlx] [--session-id <id>] [--session-file <path>] start Pi
|
|
65
72
|
ahub models setup|status|start|stop prepare or inspect local Ollama MLX (legacy stop is explicit)
|
|
66
73
|
ahub local [--route <id> | --model <id>]
|
|
67
74
|
start the hub-native worker on the self-hosted models (routing.toml)
|
|
@@ -127,10 +134,21 @@ const projectConfig = () => {
|
|
|
127
134
|
return config;
|
|
128
135
|
};
|
|
129
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");
|
|
130
148
|
try { process.chdir(cwd); } catch { fail(`project directory is unavailable: ${cwd}`); }
|
|
131
149
|
const unattendedEnv = process.env.AGENTHUB_UNATTENDED === "1";
|
|
132
150
|
const lifecycle = { inspectProject, startProject, stopProject };
|
|
133
|
-
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 });
|
|
134
152
|
|
|
135
153
|
function registeredProject(): Project {
|
|
136
154
|
const registry = new Registry();
|
|
@@ -145,14 +163,15 @@ async function healthy(): Promise<boolean> {
|
|
|
145
163
|
finally { hub?.close(); }
|
|
146
164
|
}
|
|
147
165
|
|
|
148
|
-
function exec(bin: string, argv: string[]): never {
|
|
149
|
-
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 } });
|
|
150
169
|
if (res.error) fail(`cannot run ${bin}: ${res.error.message}`);
|
|
151
170
|
process.exit(res.status ?? 1);
|
|
152
171
|
}
|
|
153
172
|
|
|
154
173
|
function execWithEnv(bin: string, argv: string[], extra: NodeJS.ProcessEnv): never {
|
|
155
|
-
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 };
|
|
156
175
|
delete env.AGENTHUB_RECOVERY_OPERATION;
|
|
157
176
|
delete env.AGENTHUB_UNATTENDED;
|
|
158
177
|
const res = spawnSync(bin, argv, { cwd, stdio: "inherit", env });
|
|
@@ -282,26 +301,11 @@ function fail(message: string): never {
|
|
|
282
301
|
process.exit(1);
|
|
283
302
|
}
|
|
284
303
|
|
|
285
|
-
function render(e: BusEvent): string {
|
|
286
|
-
if (e.t === "state") return ` . ${e.peer} is ${e.state}`;
|
|
287
|
-
if (e.t === "undeliverable") return ` ! gave up delivering ${e.env.id} (from ${e.env.from}) to ${e.peer}`;
|
|
288
|
-
if (e.t === "envelope" && e.env.from === "hub" && e.env.kind !== "chat") {
|
|
289
|
-
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(/^/, " ")}`;
|
|
290
|
-
}
|
|
291
|
-
if (e.t === "overflow") return ` ! ${e.peer}'s queue is full: dropped ${e.env.id} (from ${e.env.from})`;
|
|
292
|
-
if (e.t === "stale") return ` . dropped ${e.env.id} (from ${e.env.from}) for ${e.peer}: ${e.reason}`;
|
|
293
|
-
if (e.t === "quiet") return ` . ${e.env.id} (from ${e.env.from}) not delivered to ${e.peers.join(", ")}: turn-free cohort`;
|
|
294
|
-
const { env } = e;
|
|
295
|
-
const note = e.dropped === "hop" ? " [not delivered: hop limit]" : e.dropped === "fyi" ? " [fyi: record only]" : "";
|
|
296
|
-
const head = `${env.from} -> ${env.to?.join(",") ?? "*"}${env.priority === "important" ? " !" : ""}${note}`;
|
|
297
|
-
return `${new Date(env.ts).toLocaleTimeString()} ${head}\n${env.body.replace(/^/gm, " ")}`;
|
|
298
|
-
}
|
|
299
|
-
|
|
300
304
|
async function taskOp(op: string, a: Record<string, unknown>): Promise<string> {
|
|
301
305
|
const hub = await connect();
|
|
302
306
|
const res = await hub.request({ t: "task", op, args: a });
|
|
303
307
|
hub.close();
|
|
304
|
-
if (!res.ok) fail(res.error);
|
|
308
|
+
if (!res.ok) { audit("refused"); fail(res.error); }
|
|
305
309
|
return res.text;
|
|
306
310
|
}
|
|
307
311
|
|
|
@@ -469,6 +473,10 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
469
473
|
},
|
|
470
474
|
|
|
471
475
|
init: () => {
|
|
476
|
+
if (args.includes("--dry-run")) {
|
|
477
|
+
const plan = planInit(cwd);
|
|
478
|
+
return console.log(args.includes("--json") ? JSON.stringify(plan, null, 2) : plan.map(change => `${change.action} ${change.path}: ${change.reason}${change.managedBlock ? ` (managed block: ${change.managedBlock})` : ""}`).join("\n") || "already up to date");
|
|
479
|
+
}
|
|
472
480
|
assertLifecycleAvailable();
|
|
473
481
|
const changed = init(cwd);
|
|
474
482
|
registeredProject();
|
|
@@ -482,19 +490,34 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
482
490
|
await runProjectDaemon(registeredProject(), unattendedEnv || args.includes("--unattended"));
|
|
483
491
|
},
|
|
484
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
|
+
|
|
485
498
|
up: async () => {
|
|
486
499
|
const unattended = unattendedEnv || args.includes("--unattended");
|
|
487
500
|
if (unattended) console.error(UNATTENDED_WARNING);
|
|
488
501
|
const status = await startProject(registeredProject(), { unattended });
|
|
489
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 });
|
|
490
504
|
},
|
|
491
505
|
|
|
492
506
|
claude: async () => {
|
|
507
|
+
if (args.includes("--print-command") || args.includes("--dry-run")) {
|
|
508
|
+
try { return console.log(JSON.stringify(launcherPreview("claude", args, cwd, stateDir, unattendedEnv), null, 2)); }
|
|
509
|
+
catch { fail("cannot preview launch: invalid arguments or configuration (details withheld to protect credentials)"); }
|
|
510
|
+
}
|
|
493
511
|
assertLifecycleAvailable();
|
|
494
512
|
const control = readControl(stateDir);
|
|
495
513
|
if (control?.instanceId) {
|
|
496
514
|
process.env.AGENTHUB_INSTANCE_ID = control.instanceId;
|
|
497
|
-
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);
|
|
498
521
|
}
|
|
499
522
|
// `--settings` outranks project and user settings, so the tee has to wrap whichever status line would have won:
|
|
500
523
|
// project local, then project, then user.
|
|
@@ -506,14 +529,18 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
506
529
|
// no such file, or no status line in it
|
|
507
530
|
}
|
|
508
531
|
}
|
|
509
|
-
//
|
|
510
|
-
const facts = projectConfig()
|
|
532
|
+
// Turn-free facts and opted-in task sweeps share native PreToolUse/PostToolUse/Stop observations.
|
|
533
|
+
const facts = claudeObservationHooks(projectConfig(), { script: join(import.meta.dir, "facts-hook.ts"), stateDir });
|
|
511
534
|
const launch = buildLaunch("claude", args, { unattended: unattendedEnv, statusLine: { script: join(import.meta.dir, "statusline-tee.ts"), stateDir, ...(original ? { original } : {}) }, ...(facts ? { facts } : {}) });
|
|
512
535
|
if (launch.warning) console.error(launch.warning);
|
|
513
|
-
exec(launch.cmd, launch.args);
|
|
536
|
+
exec(launch.cmd, launch.args, "claude");
|
|
514
537
|
},
|
|
515
538
|
|
|
516
539
|
codex: async () => {
|
|
540
|
+
if (args.includes("--print-command") || args.includes("--dry-run")) {
|
|
541
|
+
try { return console.log(JSON.stringify(launcherPreview("codex", args, cwd, stateDir, unattendedEnv), null, 2)); }
|
|
542
|
+
catch { fail("cannot preview launch: invalid arguments or configuration (details withheld to protect credentials)"); }
|
|
543
|
+
}
|
|
517
544
|
assertLifecycleAvailable();
|
|
518
545
|
const launch0 = buildLaunch("codex", args, { unattended: unattendedEnv, proxyUrl: "pending" }); // refuse bad flags before starting anything
|
|
519
546
|
const hub = await connect();
|
|
@@ -524,10 +551,14 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
524
551
|
if (control?.instanceId) await recordTerminalLaunch("codex", cwd, stateDir, control.instanceId);
|
|
525
552
|
const launch = buildLaunch("codex", args, { unattended: unattendedEnv, proxyUrl: res.proxyUrl, codexBin: projectConfig().codex_bin });
|
|
526
553
|
if (launch0.warning) console.error(launch0.warning);
|
|
527
|
-
exec(launch.cmd, launch.args);
|
|
554
|
+
exec(launch.cmd, launch.args, "codex");
|
|
528
555
|
},
|
|
529
556
|
|
|
530
557
|
kimi: async () => {
|
|
558
|
+
if (args.includes("--print-command") || args.includes("--dry-run")) {
|
|
559
|
+
try { return console.log(JSON.stringify(launcherPreview("kimi", args, cwd, stateDir, unattendedEnv), null, 2)); }
|
|
560
|
+
catch { fail("cannot preview launch: invalid arguments or configuration (details withheld to protect credentials)"); }
|
|
561
|
+
}
|
|
531
562
|
const i = args.indexOf("--model");
|
|
532
563
|
const model = i === -1 ? undefined : args[i + 1] ?? fail("--model needs an alias");
|
|
533
564
|
const hub = await connect();
|
|
@@ -538,6 +569,10 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
538
569
|
},
|
|
539
570
|
|
|
540
571
|
pi: async () => {
|
|
572
|
+
if (args.includes("--print-command") || args.includes("--dry-run")) {
|
|
573
|
+
try { return console.log(JSON.stringify(launcherPreview("pi", args, cwd, stateDir, unattendedEnv), null, 2)); }
|
|
574
|
+
catch { fail("cannot preview launch: invalid arguments or configuration (details withheld to protect credentials)"); }
|
|
575
|
+
}
|
|
541
576
|
const options = piFlags();
|
|
542
577
|
const hub = await connect();
|
|
543
578
|
const res = await hub.request({ t: "start", peer: "pi", args: options, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
|
|
@@ -641,7 +676,8 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
641
676
|
tail: async () => {
|
|
642
677
|
const hub = await connect();
|
|
643
678
|
hub.onPush = (msg) => {
|
|
644
|
-
if (msg.t === "event") console.log(
|
|
679
|
+
if (msg.t === "event") console.log(renderTailEvent(msg.e));
|
|
680
|
+
else if (msg.t === "context") console.log(` ${msg.peer}: ${contextLine(msg.reading)}`);
|
|
645
681
|
else if (msg.t === "notice") console.log(` * ${msg.line}`);
|
|
646
682
|
else if (msg.t === "permission") {
|
|
647
683
|
const options = msg.options.map((o: any) => `${o.optionId} (${o.name})`).join(", ");
|
|
@@ -775,7 +811,7 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
775
811
|
if (status.deliveryError) console.log(` delivery storage: ${status.deliveryError}; dispatch is stopped`);
|
|
776
812
|
for (const line of (status as { crash?: string[] }).crash ?? []) console.log(` crash recovery: ${line}`);
|
|
777
813
|
const peers = Object.entries(status.peers as Record<string, PeerRow>);
|
|
778
|
-
for (const [id, p] of peers) console.log(peerLine(id, p));
|
|
814
|
+
for (const [id, p] of peers) console.log(peerLine(id, { ...p, context: p.context ?? unknownContext() }));
|
|
779
815
|
const models = (status as any).models?.backends as BackendRow[] | undefined;
|
|
780
816
|
if (models?.length) for (const backend of models) console.log(backendLine(backend));
|
|
781
817
|
if (status.switchyard) console.log(` switchyard: ${status.switchyard}`);
|
|
@@ -975,9 +1011,29 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
975
1011
|
},
|
|
976
1012
|
};
|
|
977
1013
|
|
|
978
|
-
|
|
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}`));
|
|
979
1034
|
try {
|
|
980
1035
|
await run();
|
|
981
1036
|
} catch (e) {
|
|
1037
|
+
audit("refused");
|
|
982
1038
|
fail((e as Error).message);
|
|
983
1039
|
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { loadConfig } from "../hub/daemon.ts";
|
|
4
|
+
import { childEnv } from "../hub/child-process.ts";
|
|
5
|
+
import { relayModelIds } from "../models/relay.ts";
|
|
6
|
+
import { buildPiLaunch } from "../pi/launch.ts";
|
|
7
|
+
import { buildLaunch, buildKimiLaunch, claudeObservationHooks, type Launch } from "./launch.ts";
|
|
8
|
+
|
|
9
|
+
const REDACTED = "[redacted]";
|
|
10
|
+
const UNRESOLVED = "[unresolved]";
|
|
11
|
+
/** Arbitrary native arguments can contain secrets even without a credential-looking key. */
|
|
12
|
+
function publicArg(arg: string): string {
|
|
13
|
+
const flags = new Set(["--settings", "--model", "--mcp-config", "--session-id", "--session-file", "--resume", "--continue", "--print", "--safe", "--new"]);
|
|
14
|
+
const key = arg.split("=", 1)[0]!;
|
|
15
|
+
return flags.has(key) ? (arg.includes("=") ? key + "=" + REDACTED : key) : REDACTED;
|
|
16
|
+
}
|
|
17
|
+
function nativeCommand(command: string[], trusted: string[]): string[] {
|
|
18
|
+
return command.map((value, i) => value === trusted[i] ? value : REDACTED);
|
|
19
|
+
}
|
|
20
|
+
/** Read-only preview. Inputs passed to shared native builders are redacted before serialization. */
|
|
21
|
+
export function launcherPreview(tool: "claude" | "codex" | "kimi" | "pi", raw: string[], cwd: string, stateDir: string, unattended: boolean) {
|
|
22
|
+
const args = raw.filter(arg => !["--print-command", "--dry-run", "--json"].includes(arg));
|
|
23
|
+
const config = loadConfig(cwd);
|
|
24
|
+
const unresolved: { field: string; reason: string }[] = [];
|
|
25
|
+
let launch: Launch;
|
|
26
|
+
let envNames = Object.keys(childEnv()).concat(["AGENTHUB_STATE_DIR", "AGENTHUB_PROJECT_DIR"]);
|
|
27
|
+
const value = (flag: string) => {
|
|
28
|
+
const at = args.indexOf(flag);
|
|
29
|
+
if (at < 0) return undefined;
|
|
30
|
+
const next = args[at + 1];
|
|
31
|
+
if (!next || next.startsWith("--")) throw new Error("missing option value");
|
|
32
|
+
return next;
|
|
33
|
+
};
|
|
34
|
+
if (tool === "claude") {
|
|
35
|
+
let original: { command?: string; refreshInterval?: number; padding?: number } | undefined;
|
|
36
|
+
for (const file of [join(cwd, ".claude/settings.local.json"), join(cwd, ".claude/settings.json"), join(process.env.HOME ?? "", ".claude/settings.json")]) {
|
|
37
|
+
try { original ??= JSON.parse(readFileSync(file, "utf8")).statusLine; } catch { /* optional */ }
|
|
38
|
+
}
|
|
39
|
+
// Build with original inputs so channel selection and owned-flag validation match normal launch.
|
|
40
|
+
const facts = claudeObservationHooks(config, { script: join(import.meta.dir, "facts-hook.ts"), stateDir });
|
|
41
|
+
launch = buildLaunch(tool, args, { unattended, statusLine: { script: join(import.meta.dir, "statusline-tee.ts"), stateDir, ...(original ? { original: { command: original.command ? REDACTED : "", ...(typeof original.refreshInterval === "number" && Number.isFinite(original.refreshInterval) ? { refreshInterval: original.refreshInterval } : {}), ...(typeof original.padding === "number" && Number.isFinite(original.padding) ? { padding: original.padding } : {}) } } : {}) }, ...(facts ? { facts } : {}) });
|
|
42
|
+
const passthrough = args.filter(arg => !["--unattended", "--safe", "--new"].includes(arg));
|
|
43
|
+
launch.args = launch.args.slice(0, launch.args.length - passthrough.length).concat(passthrough.map(publicArg));
|
|
44
|
+
} else if (tool === "codex") {
|
|
45
|
+
// A fresh native proxy is assigned by start, never guessed from an old status file.
|
|
46
|
+
launch = buildLaunch(tool, args, { unattended, proxyUrl: UNRESOLVED, codexBin: config.codex_bin === "codex" ? "codex" : REDACTED });
|
|
47
|
+
const passthrough = args.filter(arg => !["--unattended", "--safe", "--new"].includes(arg));
|
|
48
|
+
launch.args = launch.args.slice(0, launch.args.length - passthrough.length).concat(passthrough.map(publicArg));
|
|
49
|
+
unresolved.push({ field: "proxyUrl", reason: "assigned when the native adapter starts; preview does not start it" });
|
|
50
|
+
} else if (tool === "kimi") {
|
|
51
|
+
const model = value("--model");
|
|
52
|
+
launch = buildKimiLaunch(nativeCommand(config.kimi_cmd, ["kimi", "acp"]), model ? REDACTED : undefined);
|
|
53
|
+
unresolved.push({ field: "sessionId", reason: "ACP session identity is available only after native initialization" });
|
|
54
|
+
} else {
|
|
55
|
+
const mode = value("--mode") ?? "headless";
|
|
56
|
+
const backend = value("--backend") ?? config.pi.backend;
|
|
57
|
+
if (!["headless", "tui"].includes(mode) || !["auto", "dgx", "mlx"].includes(backend) || (value("--session-id") && value("--session-file"))) throw new Error("invalid Pi options");
|
|
58
|
+
for (let i = 0; i < args.length; i += 2) if (!["--mode", "--backend", "--model", "--session-id", "--session-file"].includes(args[i]!)) throw new Error("unknown Pi option");
|
|
59
|
+
const models = relayModelIds({ enableHubAuto: true, mlx: config.mlx.enabled === false ? undefined : config.mlx, mlxAlias: "mlx/fast", allowedDGXmodels: { "dgx/coding": config.pi.dgx_coding, "dgx/fast": config.pi.dgx_fast } });
|
|
60
|
+
const pi = buildPiLaunch({
|
|
61
|
+
stateDir, cmd: nativeCommand(config.pi.cmd, ["pi"]), mode: mode as "headless" | "tui", backend: backend as "auto" | "dgx" | "mlx",
|
|
62
|
+
...(value("--model") ? { model: REDACTED } : {}),
|
|
63
|
+
...(value("--session-id") ? { sessionId: REDACTED } : {}),
|
|
64
|
+
...(value("--session-file") ? { sessionFile: REDACTED } : {}),
|
|
65
|
+
relay: { url: UNRESOLVED, token: UNRESOLVED, models: models.map(id => ({ id })) },
|
|
66
|
+
tools: [], preamble: REDACTED, maxSteps: config.pi.max_steps,
|
|
67
|
+
}, {}, resolve(join(import.meta.dir, "../pi/extension.ts")), UNRESOLVED, UNRESOLVED);
|
|
68
|
+
launch = pi;
|
|
69
|
+
envNames = Object.keys(pi.env).concat(["PATH", "HOME", "USER", "SHELL", "TMPDIR", "TERM", "TERM_PROGRAM", "LANG", "LC_ALL", "LC_CTYPE", "NO_COLOR", "CODEX_HOME"].filter(key => process.env[key] !== undefined));
|
|
70
|
+
if (mode === "tui") envNames.push("AGENTHUB_STATE_DIR", "AGENTHUB_PROJECT_DIR");
|
|
71
|
+
unresolved.push({ field: "bridge/relay endpoints and owner credentials", reason: "assigned at runtime; preview does not bind a server or start a model" });
|
|
72
|
+
if (!value("--session-id") && !value("--session-file")) unresolved.push({ field: "sessionId", reason: "new native session identity is not allocated by preview" });
|
|
73
|
+
}
|
|
74
|
+
if (tool === "claude" || tool === "codex") {
|
|
75
|
+
envNames.push("AGENTHUB_INSTANCE_ID", "AGENTHUB_LAUNCH_ID");
|
|
76
|
+
unresolved.push({ field: "env.AGENTHUB_INSTANCE_ID", reason: tool === "claude" ? "injected when an existing daemon instance is read at launch; preview does not read runtime state" : "injected when a verified Orca terminal launch is recorded against the daemon; preview does not record it" });
|
|
77
|
+
unresolved.push({ field: "env.AGENTHUB_LAUNCH_ID", reason: "allocated only for a verified Orca terminal launch; preview does not query Orca, allocate identity or write launch records" });
|
|
78
|
+
}
|
|
79
|
+
return { tool, cmd: launch.cmd, args: launch.args, settings: tool === "claude" && launch.args.includes("--settings") ? launch.args[launch.args.indexOf("--settings") + 1] : undefined, envNames: [...new Set(envNames)].sort(), ...(launch.warning ? { warning: launch.warning } : {}), unresolved, redaction: "Arbitrary user arguments, settings, commands and configured executable overrides are redacted. Environment values are never displayed.", needs: ["Native executable, accounts and runtime readiness are not checked."] };
|
|
80
|
+
}
|
package/src/cli/status-lines.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import type { ContextView } from "../hub/context-window.ts";
|
|
1
2
|
// What `ahub status` prints for a peer and for a model backend, kept pure so it can be checked.
|
|
2
3
|
|
|
3
|
-
export interface PeerRow { state?: string; queued?: number; queuedImportant?: number; needsReview?: number; heldBy?: string; holdNote?: string; liveAccepted?: string[]; oldestQueuedAt?: number; attached?: boolean; paused?: string; servedBy?: string; requestedModel?: string }
|
|
4
|
+
export interface PeerRow { context?: ContextView; state?: string; queued?: number; queuedImportant?: number; needsReview?: number; heldBy?: string; holdNote?: string; liveAccepted?: string[]; oldestQueuedAt?: number; attached?: boolean; paused?: string; servedBy?: string; requestedModel?: string }
|
|
4
5
|
export interface BackendRow { kind?: string; alias?: string; state?: string; active?: number; requestedModel?: string; actualModel?: string; provider?: string }
|
|
5
6
|
|
|
6
7
|
/** Aliases are already namespaced ("dgx/coding"); only an alias that is not gets its kind in front of it. */
|
|
@@ -10,6 +11,10 @@ export function backendLabel(backend: BackendRow): string {
|
|
|
10
11
|
return alias.startsWith(`${kind}/`) ? alias : `${kind}/${alias}`;
|
|
11
12
|
}
|
|
12
13
|
|
|
14
|
+
export function contextLine(reading: ContextView): string {
|
|
15
|
+
return `context ${reading.used === null ? "unknown" : `${Math.round(reading.used * 100)}%`} (${reading.freshness}${reading.source ? `, ${reading.source}` : ""}${reading.measuredAt !== null ? `, measured ${new Date(reading.measuredAt).toISOString()}` : ""})`;
|
|
16
|
+
}
|
|
17
|
+
|
|
13
18
|
export function peerLine(id: string, p: PeerRow): string {
|
|
14
19
|
return ` ${id.padEnd(8)} ${(p.state ?? "unknown").padEnd(8)} queued ${p.queued ?? 0}` +
|
|
15
20
|
// Which queued messages the peer will not wait out the batch window for (issue #41).
|
|
@@ -23,7 +28,8 @@ export function peerLine(id: string, p: PeerRow): string {
|
|
|
23
28
|
(p.servedBy ? ` last call: ${p.servedBy}` : "") +
|
|
24
29
|
// Which backend the peer asked for on its last turn: without it, telling a Pi DGX turn from an MLX one
|
|
25
30
|
// meant reading status.json by hand.
|
|
26
|
-
(p.requestedModel ? ` model: ${p.requestedModel}` : "")
|
|
31
|
+
(p.requestedModel ? ` model: ${p.requestedModel}` : "") +
|
|
32
|
+
(p.context ? ` ${contextLine(p.context)}` : "");
|
|
27
33
|
}
|
|
28
34
|
|
|
29
35
|
export function backendLine(backend: BackendRow): string {
|
|
@@ -22,6 +22,20 @@ try {
|
|
|
22
22
|
renameSync(`${file}.tmp`, file);
|
|
23
23
|
}
|
|
24
24
|
const sessionId = typeof parsed.session_id === "string" ? parsed.session_id : typeof parsed.sessionId === "string" ? parsed.sessionId : "";
|
|
25
|
+
if (sessionId && dir) {
|
|
26
|
+
// Bind context to the launcher, daemon and native session. Persist no prompts or transcript text.
|
|
27
|
+
mkdirSync(dir, { recursive: true });
|
|
28
|
+
const file = join(dir, "claude-context.json");
|
|
29
|
+
const c = parsed.context_window;
|
|
30
|
+
const current = c?.current_usage;
|
|
31
|
+
const numeric = (v: unknown) => typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : null;
|
|
32
|
+
const record = current !== null && typeof current === "object" && !Array.isArray(current);
|
|
33
|
+
const context = { context_window_size: numeric(c?.context_window_size), used_percentage: numeric(c?.used_percentage),
|
|
34
|
+
current_usage: !record ? null : { input_tokens: numeric(current.input_tokens), cache_creation_input_tokens: numeric(current.cache_creation_input_tokens), cache_read_input_tokens: numeric(current.cache_read_input_tokens) } };
|
|
35
|
+
writeFileSync(`${file}.tmp`, JSON.stringify({ at: Date.now(), sessionId, instanceId: process.env.AGENTHUB_INSTANCE_ID, launchId: process.env.AGENTHUB_LAUNCH_ID, context }), { mode: 0o600 });
|
|
36
|
+
chmodSync(`${file}.tmp`, 0o600);
|
|
37
|
+
renameSync(`${file}.tmp`, file);
|
|
38
|
+
}
|
|
25
39
|
if (sessionId && dir) {
|
|
26
40
|
mkdirSync(dir, { recursive: true });
|
|
27
41
|
const file = join(dir, "claude-session.json");
|
|
@@ -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 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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
2
|
import type { PeerId } from "./envelope.ts";
|
|
3
|
+
import type { SweepRecord } from "./task-sweep.ts";
|
|
3
4
|
|
|
4
5
|
export const CLASSES = ["plan", "implement", "bulk_edit", "test", "review", "summarize", "triage"] as const;
|
|
5
6
|
export type TaskClass = (typeof CLASSES)[number];
|
|
@@ -17,6 +18,8 @@ export type TaskPlan = Partial<Record<(typeof PLAN_KEYS)[number], string[]>>;
|
|
|
17
18
|
/** Task outcomes older than this are pruned: demotion (issue #36) reads one week, seven half-lives. */
|
|
18
19
|
export const OUTCOMES_KEPT_MS = 7 * 24 * 3_600_000;
|
|
19
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];
|
|
20
23
|
export interface HistoryEntry {
|
|
21
24
|
at: number;
|
|
22
25
|
by: PeerId;
|
|
@@ -26,6 +29,10 @@ export interface HistoryEntry {
|
|
|
26
29
|
owner?: PeerId | null;
|
|
27
30
|
/** On a hand-over: the new owner's split profile then (#109); absent when it was unknown. */
|
|
28
31
|
profile?: string;
|
|
32
|
+
/** Structured cause recorded at the operation source, never inferred from note text. */
|
|
33
|
+
reason?: TaskMoveReason;
|
|
34
|
+
/** Persisted task-idle ladder; never counted as fresh task activity (#186). */
|
|
35
|
+
sweep?: SweepRecord;
|
|
29
36
|
}
|
|
30
37
|
export interface Task {
|
|
31
38
|
id: number;
|
|
@@ -119,14 +126,14 @@ export class Board {
|
|
|
119
126
|
|
|
120
127
|
/** The only way a task changes. Validates the move, records who did what, returns the new row. */
|
|
121
128
|
/** A `plan` in the patch replaces the old one whole: a new plan is the owner's current intent, not an addition. */
|
|
122
|
-
update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "reviewer" | "refs" | "plan" | "rejections">>, note?: string, extra: Pick<HistoryEntry, "profile"> = {}): 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 {
|
|
123
130
|
const task = this.get(id);
|
|
124
131
|
if (!task) throw new Error(`no task #${id}`);
|
|
125
132
|
if (patch.state && patch.state !== task.state && !MOVES[task.state].includes(patch.state)) {
|
|
126
133
|
throw new Error(`task #${id} is ${task.state}: cannot move to ${patch.state}`);
|
|
127
134
|
}
|
|
128
135
|
const next = { ...task, ...patch, refs: { ...task.refs, ...patch.refs } };
|
|
129
|
-
const history = [...task.history, { at: Date.now(), by, event, ...(note ? { note } : {}), ...("owner" in patch ? { owner: next.owner } : {}), ...(extra.profile ? { profile: extra.profile } : {}) }];
|
|
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 } : {}) }];
|
|
130
137
|
this.db
|
|
131
138
|
.query("UPDATE tasks SET state = ?, owner = ?, reviewer = ?, refs = ?, plan = ?, rejections = ?, history = ?, updated = ? WHERE id = ?")
|
|
132
139
|
.run(next.state, next.owner, next.reviewer, JSON.stringify(next.refs), JSON.stringify(next.plan ?? {}), next.rejections, JSON.stringify(history), Date.now(), id);
|