@staix/agent-hub 0.12.16 → 0.12.18

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 (45) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +4 -4
  3. package/docs/agent-notes/adapters.md +1 -0
  4. package/docs/agent-notes/bus.md +2 -0
  5. package/docs/agent-notes/tasks.md +3 -1
  6. package/docs/agent-notes/tests.md +1 -0
  7. package/docs/events.md +37 -3
  8. package/docs/operations.md +149 -10
  9. package/docs/quickstart.md +3 -3
  10. package/docs/security.md +28 -1
  11. package/docs/smoke.md +93 -0
  12. package/docs/specs/2026-09-19-agent-hub-design.md +176 -1
  13. package/docs/verification/2026-10-09-agent-shell-t0.md +123 -0
  14. package/docs/verified.json +26 -17
  15. package/package.json +1 -1
  16. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  17. package/plugins/agent-hub/server.js +17 -5
  18. package/src/adapters/acp.ts +2 -2
  19. package/src/adapters/claude-channel.ts +3 -2
  20. package/src/adapters/codex-appserver.ts +3 -2
  21. package/src/adapters/local-worker.ts +16 -12
  22. package/src/cli/console-state.ts +279 -0
  23. package/src/cli/console.ts +224 -0
  24. package/src/cli/facts-hook.ts +8 -1
  25. package/src/cli/identity-audit.ts +66 -0
  26. package/src/cli/identity.ts +54 -0
  27. package/src/cli/launch.ts +15 -2
  28. package/src/cli/main.ts +75 -32
  29. package/src/cli/tail-render.ts +17 -0
  30. package/src/cli/upgrade-runtime.ts +1 -1
  31. package/src/hub/attribution.ts +20 -0
  32. package/src/hub/board.ts +6 -2
  33. package/src/hub/bus.ts +88 -10
  34. package/src/hub/child-process.ts +11 -0
  35. package/src/hub/conductor.ts +196 -0
  36. package/src/hub/control-client.ts +3 -3
  37. package/src/hub/daemon.ts +388 -51
  38. package/src/hub/envelope.ts +1 -1
  39. package/src/hub/events.ts +10 -4
  40. package/src/hub/hub-tools.ts +13 -0
  41. package/src/hub/report.ts +162 -4
  42. package/src/hub/supervision.ts +152 -0
  43. package/src/hub/tasks.ts +30 -15
  44. package/src/hub/usage.ts +37 -2
  45. package/src/pi/launch.ts +2 -1
@@ -5,15 +5,22 @@
5
5
  // call; the hub confirms it from the transcript row Claude Code writes for it, by tool use id, before it counts as seen.
6
6
  import { ControlClient } from "../hub/control-client.ts";
7
7
 
8
+ /** A library call may target another hub; only the managed command hook inherits that target's native identity. */
9
+ export function nativeHookIdentity(stateDir: string, peer: string, env: NodeJS.ProcessEnv = process.env): { nativeInstanceId?: string; nativeLaunchId?: string } {
10
+ if (env.AGENTHUB_STATE_DIR !== stateDir || env.AGENTHUB_PEER_ID !== peer) return {};
11
+ return { ...(env.AGENTHUB_INSTANCE_ID ? { nativeInstanceId: env.AGENTHUB_INSTANCE_ID } : {}), ...(env.AGENTHUB_LAUNCH_ID ? { nativeLaunchId: env.AGENTHUB_LAUNCH_ID } : {}) };
12
+ }
13
+
8
14
  /** The hook's stdout for one Claude Code hook input, or undefined for none. */
9
15
  export async function factsHook(stdin: string, stateDir: string, peer: string, timeoutMs = 2000): Promise<string | undefined> {
10
16
  const input = JSON.parse(stdin) as { hook_event_name?: unknown; tool_name?: unknown; tool_input?: unknown; tool_use_id?: unknown; session_id?: unknown; transcript_path?: unknown };
11
- const phase = ({ PostToolUse: "post", Stop: "stop" } as Record<string, string>)[String(input.hook_event_name)] ?? "pre";
17
+ const phase = ({ SessionStart: "session", UserPromptSubmit: "start", PostToolUse: "post", Stop: "stop" } as Record<string, string>)[String(input.hook_event_name)] ?? "pre";
12
18
  const hub = await ControlClient.connect(stateDir, { role: "tools", peer }, timeoutMs);
13
19
  try {
14
20
  const res = await hub.request({
15
21
  t: "facts",
16
22
  phase,
23
+ ...nativeHookIdentity(stateDir, peer),
17
24
  tool: typeof input.tool_name === "string" ? input.tool_name : "",
18
25
  input: input.tool_input ?? {},
19
26
  ...(typeof input.tool_use_id === "string" ? { toolUseId: input.tool_use_id } : {}),
@@ -0,0 +1,66 @@
1
+ import { mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { cliCommandLabel } from "./identity.ts";
4
+
5
+ export interface CliAudit {
6
+ v: 1; id: string; at: number; peer: string; command: string; outcome: "run" | "refused" | "invalid";
7
+ }
8
+ const MAX_FILES = 256;
9
+ const MAX_BYTES = 64 * 1024;
10
+ const MAX_ROW_BYTES = 512;
11
+ const READY = /^[a-f0-9-]{36}\.json$/;
12
+ const PENDING = /^[a-f0-9-]{36}\.tmp$/;
13
+ const valid = (row: any): row is CliAudit => row?.v === 1 && typeof row.id === "string" && /^[a-f0-9-]{36}$/.test(row.id)
14
+ && typeof row.at === "number" && Number.isFinite(row.at) && row.at > 0
15
+ && typeof row.peer === "string" && /^[a-z][a-z0-9-]{0,31}$/.test(row.peer) && !/\s/.test(row.peer)
16
+ && typeof row.command === "string" && row.command.length <= 64 && row.command === cliCommandLabel(...splitLabel(row.command))
17
+ && ["run", "refused", "invalid"].includes(row.outcome);
18
+ const splitLabel = (label: string): [string, string[]] => { const [command = "unknown", ...args] = label.split(" "); return [command, args]; };
19
+
20
+ /**
21
+ * An ids-only outbox, published atomically without a writer lock. Concurrent writers can overshoot
22
+ * the checked 256-file / 64 KiB ceiling by at most one bounded row per concurrent writer.
23
+ * Unpublished temp files count against the ceiling, so a crashed writer cannot grow the spool forever.
24
+ */
25
+ export function recordCliAudit(stateDir: string, peer: string, command: string, outcome: CliAudit["outcome"]): void {
26
+ const row: CliAudit = { v: 1, id: crypto.randomUUID(), at: Date.now(), peer, command, outcome };
27
+ if (!valid(row)) throw new Error("invalid CLI audit identifiers");
28
+ const dir = join(stateDir, "cli-audit");
29
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
30
+ const files = readdirSync(dir).filter(name => READY.test(name) || PENDING.test(name));
31
+ let bytes = 0;
32
+ for (const name of files) {
33
+ try { bytes += statSync(join(dir, name)).size; }
34
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
35
+ }
36
+ const text = JSON.stringify(row) + "\n";
37
+ if (Buffer.byteLength(text) > MAX_ROW_BYTES || files.length >= MAX_FILES || bytes + Buffer.byteLength(text) > MAX_BYTES) throw new Error("CLI audit outbox is full");
38
+ const pending = join(dir, `${row.id}.tmp`);
39
+ try {
40
+ writeFileSync(pending, text, { mode: 0o600, flag: "wx" });
41
+ renameSync(pending, join(dir, `${row.id}.json`));
42
+ } catch (error) {
43
+ try { unlinkSync(pending); } catch { /* only this writer's unpublished file */ }
44
+ throw error;
45
+ }
46
+ }
47
+
48
+ /** Consume ready files only; rebuild fixed fields so titles or other injected text cannot be rendered. */
49
+ export function drainCliAudits(stateDir: string): CliAudit[] {
50
+ const dir = join(stateDir, "cli-audit");
51
+ let files: string[];
52
+ try { files = readdirSync(dir).filter(name => READY.test(name)).sort(); }
53
+ catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; throw error; }
54
+ const rows: CliAudit[] = [];
55
+ for (const name of files) {
56
+ const path = join(dir, name);
57
+ try {
58
+ if (statSync(path).size <= MAX_ROW_BYTES) {
59
+ const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
60
+ if (valid(parsed) && `${parsed.id}.json` === name) rows.push({ v: 1, id: parsed.id, at: parsed.at, peer: parsed.peer, command: parsed.command, outcome: parsed.outcome });
61
+ }
62
+ } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT" && !(error instanceof SyntaxError)) throw error; }
63
+ try { unlinkSync(path); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
64
+ }
65
+ return rows;
66
+ }
@@ -0,0 +1,54 @@
1
+ export type CliIdentity =
2
+ | { role: "console" }
3
+ | { role: "tools"; peer: string }
4
+ | { role: "invalid"; reason: string };
5
+
6
+ /** Honest defaults for native shells, not authentication against an agent that can edit its environment. */
7
+ export function detectCliIdentity(env: Readonly<Record<string, string | undefined>>): CliIdentity {
8
+ const hub = env.AGENTHUB_PEER_ID;
9
+ const claude = env.CLAUDECODE;
10
+ const session = env.CLAUDE_CODE_SESSION_ID;
11
+ const codex = env.CODEX_THREAD_ID;
12
+ const invalid = (reason: string): CliIdentity => ({ role: "invalid", reason });
13
+ if (hub !== undefined && (!/^[a-z][a-z0-9-]{0,31}$/.test(hub) || /\s/.test(hub) || ["user", "hub", "digest"].includes(hub))) return invalid("invalid AGENTHUB_PEER_ID marker");
14
+ if (claude !== undefined && claude !== "1") return invalid("invalid CLAUDECODE marker");
15
+ if (session !== undefined && (!session || /\s/.test(session))) return invalid("invalid CLAUDE_CODE_SESSION_ID marker");
16
+ if (codex !== undefined && (!codex || /\s/.test(codex))) return invalid("invalid CODEX_THREAD_ID marker");
17
+ if (session !== undefined && claude === undefined) return invalid("CLAUDE_CODE_SESSION_ID without CLAUDECODE marker");
18
+ if (claude !== undefined && codex !== undefined) return invalid("conflicting native agent markers");
19
+ const native = claude !== undefined ? "claude" : codex !== undefined ? "codex" : undefined;
20
+ if (hub !== undefined && native !== undefined && hub !== native) return invalid("AGENTHUB_PEER_ID conflicts with native agent marker");
21
+ const peer = hub ?? native;
22
+ return peer === undefined ? { role: "console" } : { role: "tools", peer };
23
+ }
24
+
25
+ export type PeerCommandAccess = "allowed" | "conductor" | "console";
26
+
27
+ /** A closed list: unknown commands cannot acquire console authority through an agent shell. */
28
+ export function classifyPeerCommand(command: string, args: readonly string[]): PeerCommandAccess {
29
+ if (args.some((arg) => arg === "--as-user" || arg.startsWith("--as-user="))) return "console";
30
+ if (command === "task") {
31
+ if (["propose", "show"].includes(args[0] ?? "")) return "allowed";
32
+ if (["assign", "escalate"].includes(args[0] ?? "")) return "conductor";
33
+ return "console";
34
+ }
35
+ if (command === "route") return args[0] === "explain" ? "allowed" : "console";
36
+ if (command === "budget") return !args.length || args.every((arg) => arg === "--json") ? "allowed" : "console";
37
+ if (["claude", "codex", "kimi", "pi", "local", "pause", "resume"].includes(command)) return "conductor";
38
+ if (["help", "--help", "version", "--version", "say", "status", "board", "report", "turns", "review", "remember", "facts", "check-path"].includes(command)) return "allowed";
39
+ return "console";
40
+ }
41
+
42
+ /** No argument text is included: notices and refusal logs must not copy task text or credentials. */
43
+ export function cliCommandLabel(command: string, args: readonly string[]): string {
44
+ if (!["upgrade", "restart", "recovery", "recovery-run", "help", "--help", "--version", "version", "projects", "ui", "manager", "setup", "init", "daemon", "up", "console", "claude", "codex", "kimi", "pi", "local", "models", "say", "queue", "tail", "budget", "board", "task", "review", "remember", "ask", "route", "pause", "resume", "permit", "status", "logs", "export", "report", "facts", "check-path", "turns", "undo", "kill", "doctor"].includes(command)) return "unknown";
45
+ if (["task", "queue", "budget", "route", "recovery", "models", "projects"].includes(command)) {
46
+ const sub = args[0];
47
+ if (sub && ["propose", "show", "assign", "escalate", "list", "resolve", "resume", "set", "execution", "explain", "status", "abort", "setup", "start", "stop", "remove"].includes(sub)) return `${command} ${sub}`;
48
+ }
49
+ return (/^[a-z][a-z0-9-]*$/.test(command) && !/\s/.test(command)) || ["--help", "--version"].includes(command) ? command : "unknown";
50
+ }
51
+
52
+ export function peerCommandRefusal(peer: string, command: string): string {
53
+ return `${peer} cannot run ahub ${command}; the person runs it in ahub console or a terminal`;
54
+ }
package/src/cli/launch.ts CHANGED
@@ -1,8 +1,16 @@
1
1
  // Launchers inject only the flags the hub owns and refuse user-supplied duplicates.
2
2
  import { readFileSync } from "node:fs";
3
3
  import { resolve } from "node:path";
4
+ import { peerChildEnv } from "../hub/child-process.ts";
4
5
  export const CLAUDE_CHANNEL = "plugin:agent-hub@agent-hub";
5
6
 
7
+ /** Launch identity belongs to the native child, never to the agent that invoked the wrapper. */
8
+ export function nativeLaunchEnv(tool: "claude" | "codex" | "pi", source: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
9
+ const env = peerChildEnv(tool, source);
10
+ delete env.AGENTHUB_UNATTENDED;
11
+ return env;
12
+ }
13
+
6
14
  /** A scoped candidate MCP server can be selected without promoting the installed plugin. */
7
15
  function claudeChannel(userArgs: string[]): string {
8
16
  const bundle = resolve(import.meta.dir, "../../plugins/agent-hub/server.js");
@@ -57,13 +65,16 @@ export interface FactsHook {
57
65
  stateDir: string;
58
66
  /** The same hook transport can observe native turns without injecting facts. */
59
67
  purpose?: "facts" | "idle" | "facts-and-idle";
68
+ /** Observe native sessions/turns for every enabled facts, idle or conductor hook configuration. */
69
+ observeNative?: boolean;
60
70
  }
61
71
 
62
72
  /** Shared by actual launch and read-only preview; advisory facts remain disabled. */
63
- export function claudeObservationHooks(config: { coordination?: string; task_sweep?: { enabled: boolean } }, paths: Pick<FactsHook, "script" | "stateDir">): FactsHook | undefined {
73
+ export function claudeObservationHooks(config: { coordination?: string; task_sweep?: { enabled: boolean }; roles?: Record<string, string[]> }, paths: Pick<FactsHook, "script" | "stateDir">): FactsHook | undefined {
64
74
  const facts = config.coordination === "turn-free";
65
75
  const idle = config.task_sweep?.enabled === true;
66
- return facts || idle ? { ...paths, purpose: facts ? (idle ? "facts-and-idle" : "facts") : "idle" } : undefined;
76
+ const observeNative = facts || idle || config.roles?.claude?.includes("conductor") === true;
77
+ return facts || idle || observeNative ? { ...paths, purpose: facts ? (idle ? "facts-and-idle" : "facts") : "idle", ...(observeNative ? { observeNative: true } : {}) } : undefined;
67
78
  }
68
79
 
69
80
  /**
@@ -76,6 +87,7 @@ export function sessionSettings(tee: StatusLineTee, facts?: FactsHook): string {
76
87
  if (facts) {
77
88
  const hooks = [{ type: "command", command: `AGENTHUB_STATE_DIR=${sh(facts.stateDir)} bun ${sh(facts.script)}`, timeout: 5 }];
78
89
  settings.hooks = { PreToolUse: [{ matcher: "*", hooks }], PostToolUse: [{ matcher: "*", hooks }], Stop: [{ hooks }] };
90
+ if (facts.observeNative) Object.assign(settings.hooks as object, { SessionStart: [{ matcher: "*", hooks }], UserPromptSubmit: [{ hooks }] });
79
91
  }
80
92
  return JSON.stringify(settings);
81
93
  }
@@ -101,6 +113,7 @@ export function buildLaunch(
101
113
  ctx.statusLine && own ? "note: you passed --settings, so the hub's status line tee is off and the budget coordinator cannot see Claude's quota (ahub budget set claude <0..1> still works)." : "",
102
114
  ctx.facts && own && ctx.facts.purpose !== "idle" ? "note: you passed --settings, so the hub's turn-free facts hooks are off for this session: Claude will not see the other agents' changes at its tool calls." : "",
103
115
  ctx.facts && own && (ctx.facts.purpose === "idle" || ctx.facts.purpose === "facts-and-idle") ? "note: you passed --settings, so native idle observation hooks are off for this session: task idle sweeps cannot verify Claude between turns." : "",
116
+ ctx.facts?.observeNative && own ? "note: you passed --settings, so native session/turn observation hooks are off: completed turns and supervision tokens may be unavailable." : "",
104
117
  ].filter(Boolean);
105
118
  return {
106
119
  cmd: "claude",
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";
@@ -33,9 +33,14 @@ import { setupOllamaModel } from "./models-setup.ts";
33
33
  import { unknownContext } from "../hub/context-window.ts";
34
34
  import { backendLine, contextLine, peerLine, type BackendRow, type PeerRow } from "./status-lines.ts";
35
35
  import { parseSince, readEvents } from "../hub/events.ts";
36
- import { formatReport, summarize } from "../hub/report.ts";
36
+ import { formatReport, summarize, formatTaskReport, summarizeByTask } 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 { resolveColor } from "./console-state.ts";
43
+ import { renderTailEvent } from "./tail-render.ts";
39
44
 
40
45
  /** `--since 7d|24h|<iso>` for export and report; everything when absent. */
41
46
  const since = (): number => {
@@ -56,7 +61,8 @@ const USAGE = `agent-hub ${VERSION}: Claude Code, Codex and Kimi as peers in one
56
61
  ahub ui --all --stop stop only the dashboard manager
57
62
  ahub setup [--yes] install or update the Claude Code channel plugin from this package, then run doctor
58
63
  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] start the daemon for this directory
64
+ ahub up [--unattended] [--no-console] start the daemon; interactive terminals enter console
65
+ ahub console [--panels] [--color=auto|always|never] enter the human console, leaving the daemon running on exit
60
66
  ahub upgrade --to <version> [--dry-run] [--yes] review and upgrade running projects
61
67
  ahub restart [--dry-run] [--yes] recover this project's runtime
62
68
  ahub recovery status|resume|abort <operation-id> inspect, resume or cancel a preflight
@@ -91,7 +97,7 @@ const USAGE = `agent-hub ${VERSION}: Claude Code, Codex and Kimi as peers in one
91
97
  ahub queue resolve <delivery-id> --action completed|retry|discard --reason <text>
92
98
  ahub status | logs [-f] | doctor | kill
93
99
  ahub export [--since 7d|<iso>] structured events (events.jsonl) as JSON lines; never message bodies
94
- ahub report [--since 7d|<iso>] [--json] turns, tokens, messages, overlaps and task events per period
100
+ ahub report [--since 7d|<iso>] [--by task] [--json] turns, tokens, messages, overlaps and task events per period
95
101
  ahub check-path <file> [--peer <id>] other owners' open tasks that claim or changed a file
96
102
  ahub check-path --hook the same as a Claude Code PreToolUse hook (templates/claude-hooks.json); never blocks
97
103
  ahub facts --hook turn-free facts as a Claude Code PreToolUse, PostToolUse and Stop hook (issue #108); never blocks
@@ -129,10 +135,21 @@ const projectConfig = () => {
129
135
  return config;
130
136
  };
131
137
  const stateDir = selected.stateDir;
138
+ const identity = detectCliIdentity(process.env);
139
+ const commandLabel = cliCommandLabel(cmd, args);
140
+ const commandAccess = classifyPeerCommand(cmd, args);
141
+ function audit(outcome: "run" | "refused" | "invalid"): void {
142
+ if (identity.role === "console") return;
143
+ try { recordCliAudit(stateDir, identity.role === "tools" ? identity.peer : "unknown", commandLabel, outcome); }
144
+ catch { console.error("ahub: CLI audit could not be recorded (outbox unavailable or full)"); }
145
+ }
146
+ if (identity.role === "invalid") { audit("invalid"); fail(`${identity.reason}; use ahub console or a terminal with no agent markers`); }
147
+ if (identity.role === "tools" && commandAccess === "console") { audit("refused"); fail(peerCommandRefusal(identity.peer, commandLabel)); }
148
+ if (identity.role === "tools") audit("run");
132
149
  try { process.chdir(cwd); } catch { fail(`project directory is unavailable: ${cwd}`); }
133
150
  const unattendedEnv = process.env.AGENTHUB_UNATTENDED === "1";
134
151
  const lifecycle = { inspectProject, startProject, stopProject };
135
- const connect = () => ControlClient.connect(stateDir, { role: "console", projectRoot: cwd });
152
+ const connect = () => ControlClient.connect(stateDir, identity.role === "tools" ? { role: "tools", peer: identity.peer, projectRoot: cwd } : { role: "console", projectRoot: cwd });
136
153
 
137
154
  function registeredProject(): Project {
138
155
  const registry = new Registry();
@@ -147,14 +164,15 @@ async function healthy(): Promise<boolean> {
147
164
  finally { hub?.close(); }
148
165
  }
149
166
 
150
- function exec(bin: string, argv: string[]): never {
151
- const res = spawnSync(bin, argv, { cwd, stdio: "inherit", env: { ...childEnv(), AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd } });
167
+ function exec(bin: string, argv: string[], tool?: "claude" | "codex"): never {
168
+ const env = tool ? nativeLaunchEnv(tool, childEnv()) : childEnv();
169
+ const res = spawnSync(bin, argv, { cwd, stdio: "inherit", env: { ...env, AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd } });
152
170
  if (res.error) fail(`cannot run ${bin}: ${res.error.message}`);
153
171
  process.exit(res.status ?? 1);
154
172
  }
155
173
 
156
174
  function execWithEnv(bin: string, argv: string[], extra: NodeJS.ProcessEnv): never {
157
- const env: NodeJS.ProcessEnv = { ...extra, AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd };
175
+ const env: NodeJS.ProcessEnv = { ...nativeLaunchEnv("pi", extra), AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd };
158
176
  delete env.AGENTHUB_RECOVERY_OPERATION;
159
177
  delete env.AGENTHUB_UNATTENDED;
160
178
  const res = spawnSync(bin, argv, { cwd, stdio: "inherit", env });
@@ -284,26 +302,11 @@ function fail(message: string): never {
284
302
  process.exit(1);
285
303
  }
286
304
 
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
305
  async function taskOp(op: string, a: Record<string, unknown>): Promise<string> {
303
306
  const hub = await connect();
304
307
  const res = await hub.request({ t: "task", op, args: a });
305
308
  hub.close();
306
- if (!res.ok) fail(res.error);
309
+ if (!res.ok) { audit("refused"); fail(res.error); }
307
310
  return res.text;
308
311
  }
309
312
 
@@ -488,11 +491,19 @@ const commands: Record<string, () => Promise<void> | void> = {
488
491
  await runProjectDaemon(registeredProject(), unattendedEnv || args.includes("--unattended"));
489
492
  },
490
493
 
494
+ console: async () => {
495
+ if (args.some(arg => arg !== "--panels" && !arg.startsWith("--color=")) || args.filter(arg => arg.startsWith("--color=")).length > 1) fail("usage: ahub console [--panels] [--color=auto|always|never]");
496
+ const color = resolveColor(args.find(arg => arg.startsWith("--color="))?.slice("--color=".length), { isTTY: !!process.stdin.isTTY && !!process.stdout.isTTY, TERM: process.env.TERM, NO_COLOR: process.env.NO_COLOR });
497
+ if (color instanceof Error) return fail(color.message);
498
+ await runConsole({ client: await connect(), cwd, stateDir, panels: args.includes("--panels"), color });
499
+ },
500
+
491
501
  up: async () => {
492
502
  const unattended = unattendedEnv || args.includes("--unattended");
493
503
  if (unattended) console.error(UNATTENDED_WARNING);
494
504
  const status = await startProject(registeredProject(), { unattended });
495
505
  console.log(`ahub up (ws://127.0.0.1:${status.controlPort}), state in ${stateDir}`);
506
+ if (process.stdin.isTTY && process.stdout.isTTY && !args.includes("--no-console")) await runConsole({ client: await connect(), cwd, stateDir });
496
507
  },
497
508
 
498
509
  claude: async () => {
@@ -504,7 +515,12 @@ const commands: Record<string, () => Promise<void> | void> = {
504
515
  const control = readControl(stateDir);
505
516
  if (control?.instanceId) {
506
517
  process.env.AGENTHUB_INSTANCE_ID = control.instanceId;
507
- await recordTerminalLaunch("claude", cwd, stateDir, control.instanceId);
518
+ const terminal = await recordTerminalLaunch("claude", cwd, stateDir, control.instanceId);
519
+ if (!terminal) process.env.AGENTHUB_LAUNCH_ID = randomUUID();
520
+ // Native hook identity exists in an ordinary terminal too; this is not an Orca recovery record.
521
+ const file = join(stateDir, "claude-launch.json");
522
+ writeFileSync(`${file}.tmp`, JSON.stringify({ instanceId: control.instanceId, launchId: process.env.AGENTHUB_LAUNCH_ID }), { mode: 0o600 });
523
+ chmodSync(`${file}.tmp`, 0o600); renameSync(`${file}.tmp`, file);
508
524
  }
509
525
  // `--settings` outranks project and user settings, so the tee has to wrap whichever status line would have won:
510
526
  // project local, then project, then user.
@@ -520,7 +536,7 @@ const commands: Record<string, () => Promise<void> | void> = {
520
536
  const facts = claudeObservationHooks(projectConfig(), { script: join(import.meta.dir, "facts-hook.ts"), stateDir });
521
537
  const launch = buildLaunch("claude", args, { unattended: unattendedEnv, statusLine: { script: join(import.meta.dir, "statusline-tee.ts"), stateDir, ...(original ? { original } : {}) }, ...(facts ? { facts } : {}) });
522
538
  if (launch.warning) console.error(launch.warning);
523
- exec(launch.cmd, launch.args);
539
+ exec(launch.cmd, launch.args, "claude");
524
540
  },
525
541
 
526
542
  codex: async () => {
@@ -538,7 +554,7 @@ const commands: Record<string, () => Promise<void> | void> = {
538
554
  if (control?.instanceId) await recordTerminalLaunch("codex", cwd, stateDir, control.instanceId);
539
555
  const launch = buildLaunch("codex", args, { unattended: unattendedEnv, proxyUrl: res.proxyUrl, codexBin: projectConfig().codex_bin });
540
556
  if (launch0.warning) console.error(launch0.warning);
541
- exec(launch.cmd, launch.args);
557
+ exec(launch.cmd, launch.args, "codex");
542
558
  },
543
559
 
544
560
  kimi: async () => {
@@ -663,7 +679,7 @@ const commands: Record<string, () => Promise<void> | void> = {
663
679
  tail: async () => {
664
680
  const hub = await connect();
665
681
  hub.onPush = (msg) => {
666
- if (msg.t === "event") console.log(render(msg.e));
682
+ if (msg.t === "event") console.log(renderTailEvent(msg.e));
667
683
  else if (msg.t === "context") console.log(` ${msg.peer}: ${contextLine(msg.reading)}`);
668
684
  else if (msg.t === "notice") console.log(` * ${msg.line}`);
669
685
  else if (msg.t === "permission") {
@@ -812,7 +828,14 @@ const commands: Record<string, () => Promise<void> | void> = {
812
828
  for (const e of readEvents(join(stateDir, "events.jsonl"), since())) console.log(JSON.stringify(e));
813
829
  },
814
830
  report: () => {
815
- const r = summarize(readEvents(join(stateDir, "events.jsonl"), since()));
831
+ const by = args.indexOf("--by");
832
+ if (by >= 0 && args[by + 1] !== "task") return fail("--by takes task");
833
+ const events = readEvents(join(stateDir, "events.jsonl"), since());
834
+ if (by >= 0) {
835
+ const r = summarizeByTask(events);
836
+ return console.log(args.includes("--json") ? JSON.stringify(r, null, 2) : formatTaskReport(r).join("\n"));
837
+ }
838
+ const r = summarize(events);
816
839
  console.log(args.includes("--json") ? JSON.stringify(r, null, 2) : formatReport(r).join("\n"));
817
840
  },
818
841
  facts: async () => {
@@ -998,9 +1021,29 @@ const commands: Record<string, () => Promise<void> | void> = {
998
1021
  },
999
1022
  };
1000
1023
 
1001
- const run = commands[cmd] ?? (() => fail(`unknown command "${cmd}"\n\n${USAGE}`));
1024
+ async function runConductorCommand(): Promise<void> {
1025
+ let op: string;
1026
+ let input: Record<string, unknown>;
1027
+ if (cmd === "task") {
1028
+ op = args[0] === "assign" ? "hub_task_assign" : "hub_task_escalate";
1029
+ if (!args[1] || (args[0] === "assign" && !args[2]) || args.length !== (args[0] === "assign" ? 3 : 2)) fail("usage: ahub task assign <id> <peer> | escalate <id>");
1030
+ input = { id: Number(args[1]), ...(args[0] === "assign" ? { peer: args[2] } : {}) };
1031
+ } else if (cmd === "pause" || cmd === "resume") {
1032
+ if (args.length !== 1) fail(`usage: ahub ${cmd} <peer>`);
1033
+ op = cmd === "pause" ? "hub_peer_hold" : "hub_peer_release";
1034
+ input = { peer: args[0] };
1035
+ } else {
1036
+ // Native TUIs return a human launch command through the daemon; an agent shell never executes it.
1037
+ 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]" : ""}`);
1038
+ op = "hub_peer_start";
1039
+ input = { peer: cmd, ...(cmd === "pi" ? { mode: args[1] ?? "headless" } : {}) };
1040
+ }
1041
+ console.log(await taskOp(op, input));
1042
+ }
1043
+ const run = identity.role === "tools" && commandAccess === "conductor" ? runConductorCommand : commands[cmd] ?? (() => fail(`unknown command "${cmd}"\n\n${USAGE}`));
1002
1044
  try {
1003
1045
  await run();
1004
1046
  } catch (e) {
1047
+ audit("refused");
1005
1048
  fail((e as Error).message);
1006
1049
  }
@@ -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-14 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) {
@@ -0,0 +1,20 @@
1
+ import type { Envelope } from "./envelope.ts";
2
+
3
+ export type Attribution = "delivery" | "single_open" | "unattributed";
4
+ export interface TaskAttribution { task?: number; attribution: Attribution; pii?: true }
5
+
6
+ /** Only one distinct positive task id in the original delivery can identify its turn. */
7
+ export function deliveryTask(originals: Envelope[]): number | undefined {
8
+ const ids = new Set(originals.flatMap(env => {
9
+ const id = Number(env.refs?.task);
10
+ return Number.isSafeInteger(id) && id > 0 ? [id] : [];
11
+ }));
12
+ return ids.size === 1 ? ids.values().next().value : undefined;
13
+ }
14
+
15
+ /** Rule order is deliberate: a delivered review belongs to its task, regardless of ownership. */
16
+ export function attribute(delivery: number | undefined, inProgress: number[]): TaskAttribution {
17
+ if (delivery !== undefined && Number.isSafeInteger(delivery) && delivery > 0) return { task: delivery, attribution: "delivery" };
18
+ if (inProgress.length === 1) return { task: inProgress[0]!, attribution: "single_open" };
19
+ return { attribution: "unattributed" };
20
+ }
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);