@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.
Files changed (53) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +18 -5
  3. package/docs/agent-notes/adapters.md +1 -0
  4. package/docs/agent-notes/budget.md +5 -1
  5. package/docs/agent-notes/bus.md +2 -0
  6. package/docs/agent-notes/tasks.md +3 -1
  7. package/docs/agent-notes/tests.md +3 -1
  8. package/docs/operations.md +277 -11
  9. package/docs/quickstart.md +4 -4
  10. package/docs/security.md +26 -1
  11. package/docs/smoke.md +93 -0
  12. package/docs/specs/2026-09-19-agent-hub-design.md +223 -0
  13. package/docs/verification/2026-10-09-agent-shell-t0.md +123 -0
  14. package/docs/verified.json +168 -0
  15. package/package.json +1 -1
  16. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  17. package/plugins/agent-hub/server.js +18 -6
  18. package/src/adapters/acp.ts +2 -2
  19. package/src/adapters/claude-channel.ts +3 -2
  20. package/src/adapters/codex-appserver.ts +10 -2
  21. package/src/adapters/local-worker.ts +5 -5
  22. package/src/adapters/pi.ts +4 -14
  23. package/src/cli/console-state.ts +213 -0
  24. package/src/cli/console.ts +201 -0
  25. package/src/cli/facts-hook.ts +8 -1
  26. package/src/cli/identity-audit.ts +66 -0
  27. package/src/cli/identity.ts +54 -0
  28. package/src/cli/init.ts +40 -30
  29. package/src/cli/launch.ts +31 -1
  30. package/src/cli/main.ts +95 -39
  31. package/src/cli/preview.ts +80 -0
  32. package/src/cli/status-lines.ts +8 -2
  33. package/src/cli/statusline-tee.ts +14 -0
  34. package/src/cli/tail-render.ts +17 -0
  35. package/src/cli/upgrade-runtime.ts +1 -1
  36. package/src/hub/board.ts +9 -2
  37. package/src/hub/bus.ts +82 -10
  38. package/src/hub/child-process.ts +11 -0
  39. package/src/hub/conductor.ts +196 -0
  40. package/src/hub/context-window.ts +72 -0
  41. package/src/hub/control-client.ts +3 -3
  42. package/src/hub/daemon.ts +492 -77
  43. package/src/hub/envelope.ts +1 -1
  44. package/src/hub/events.ts +7 -1
  45. package/src/hub/hub-tools.ts +14 -1
  46. package/src/hub/report.ts +53 -4
  47. package/src/hub/supervision.ts +152 -0
  48. package/src/hub/task-sweep.ts +59 -0
  49. package/src/hub/tasks.ts +86 -15
  50. package/src/hub/usage.ts +37 -2
  51. package/src/models/relay.ts +6 -1
  52. package/src/pi/launch.ts +21 -0
  53. 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 { buildLaunch, UNATTENDED_WARNING } from "./launch.ts";
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 { backendLine, peerLine, type BackendRow, type PeerRow } from "./status-lines.ts";
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 write .agenthub/config.json and the AGENTS.md marker block (drops a legacy CLAUDE.md block)
57
- ahub up [--unattended] start the daemon for this directory
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 res = spawnSync(bin, argv, { cwd, stdio: "inherit", env: { ...childEnv(), AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd } });
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
- // A turn-free project (issue #108) gets the facts hook before and after every tool call, and at Stop.
510
- const facts = projectConfig().coordination === "turn-free" ? { script: join(import.meta.dir, "facts-hook.ts"), stateDir } : undefined;
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(render(msg.e));
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
- const run = commands[cmd] ?? (() => fail(`unknown command "${cmd}"\n\n${USAGE}`));
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
+ }
@@ -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-13 source is required");
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);