@staix/agent-hub 0.12.15 → 0.12.16

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.
@@ -0,0 +1,159 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "documents": {
4
+ "README.md": {
5
+ "verifiedAgainst": "2f6274d007d47aa3db0976c4989021520c03a3ef",
6
+ "paths": [
7
+ "package.json",
8
+ "src/",
9
+ "templates/",
10
+ "plugins/agent-hub/.claude-plugin/plugin.json"
11
+ ]
12
+ },
13
+ "docs/security.md": {
14
+ "verifiedAgainst": "2f6274d007d47aa3db0976c4989021520c03a3ef",
15
+ "paths": [
16
+ "src/",
17
+ "templates/"
18
+ ]
19
+ },
20
+ "docs/operations.md": {
21
+ "verifiedAgainst": "2f6274d007d47aa3db0976c4989021520c03a3ef",
22
+ "paths": [
23
+ "package.json",
24
+ "src/",
25
+ "templates/",
26
+ "scripts/overlaps.ts",
27
+ "scripts/check-docs.mjs",
28
+ "scripts/seeded-check.ts",
29
+ "scripts/seeds.json",
30
+ ".github/workflows/check.yml",
31
+ "scripts/ci-gates.mjs",
32
+ "scripts/ci-reuse.mjs",
33
+ ".github/workflows/release.yml"
34
+ ]
35
+ },
36
+ "docs/quickstart.md": {
37
+ "verifiedAgainst": "2f6274d007d47aa3db0976c4989021520c03a3ef",
38
+ "paths": [
39
+ "package.json",
40
+ "src/",
41
+ "templates/"
42
+ ]
43
+ },
44
+ "docs/agent-notes/adapters.md": {
45
+ "verifiedAgainst": "2f6274d007d47aa3db0976c4989021520c03a3ef",
46
+ "paths": [
47
+ "src/adapters/",
48
+ "src/pi/",
49
+ "src/hub/peers.ts",
50
+ "src/hub/child-process.ts",
51
+ "src/hub/lifecycle.ts",
52
+ "src/memory/capture.ts",
53
+ "scripts/benchmarks/teardown.ts",
54
+ "src/hub/daemon.ts"
55
+ ]
56
+ },
57
+ "docs/agent-notes/benchmarks.md": {
58
+ "verifiedAgainst": "2f6274d007d47aa3db0976c4989021520c03a3ef",
59
+ "paths": [
60
+ "scripts/benchmarks/",
61
+ "test/benchmarks/",
62
+ "src/pi/",
63
+ "src/adapters/acp.ts",
64
+ "src/adapters/pi.ts",
65
+ "src/models/relay.ts",
66
+ "src/local/"
67
+ ]
68
+ },
69
+ "docs/agent-notes/budget.md": {
70
+ "verifiedAgainst": "2f6274d007d47aa3db0976c4989021520c03a3ef",
71
+ "paths": [
72
+ "src/hub/budget.ts",
73
+ "src/cli/statusline-tee.ts",
74
+ "src/hub/bus.ts",
75
+ "src/hub/delivery-journal.ts",
76
+ "src/hub/restart.ts",
77
+ "src/hub/context-window.ts",
78
+ "src/hub/daemon.ts"
79
+ ]
80
+ },
81
+ "docs/agent-notes/bus.md": {
82
+ "verifiedAgainst": "2f6274d007d47aa3db0976c4989021520c03a3ef",
83
+ "paths": [
84
+ "src/hub/bus.ts",
85
+ "src/hub/envelope.ts",
86
+ "src/hub/limits.ts",
87
+ "src/hub/delivery-journal.ts",
88
+ "src/hub/inference.ts",
89
+ "src/hub/daemon.ts"
90
+ ]
91
+ },
92
+ "docs/agent-notes/daemon.md": {
93
+ "verifiedAgainst": "2f6274d007d47aa3db0976c4989021520c03a3ef",
94
+ "paths": [
95
+ "src/hub/daemon.ts",
96
+ "src/hub/control-client.ts",
97
+ "src/adapters/claude-channel.ts",
98
+ "src/hub/restart.ts",
99
+ "src/hub/recovery-store.ts",
100
+ "src/hub/snapshots.ts",
101
+ "src/hub/manager.ts",
102
+ "src/hub/lifecycle.ts",
103
+ "src/hub/crash.ts",
104
+ "src/cli/setup.ts",
105
+ "src/cli/upgrade*.ts",
106
+ "src/cli/terminal-recovery.ts",
107
+ "src/cli/recovery-package.ts",
108
+ "src/cli/facts-hook.ts",
109
+ "src/pi/process-signature.ts",
110
+ "src/hub/context-window.ts"
111
+ ]
112
+ },
113
+ "docs/agent-notes/local-worker.md": {
114
+ "verifiedAgainst": "2f6274d007d47aa3db0976c4989021520c03a3ef",
115
+ "paths": [
116
+ "src/adapters/local-worker.ts",
117
+ "src/local/",
118
+ "src/memory/capture.ts",
119
+ "src/hub/facts.ts"
120
+ ]
121
+ },
122
+ "docs/agent-notes/models.md": {
123
+ "verifiedAgainst": "2f6274d007d47aa3db0976c4989021520c03a3ef",
124
+ "paths": [
125
+ "src/models/",
126
+ "src/hub/inference.ts",
127
+ "src/omniroute/",
128
+ "src/switchyard/"
129
+ ]
130
+ },
131
+ "docs/agent-notes/tasks.md": {
132
+ "verifiedAgainst": "2f6274d007d47aa3db0976c4989021520c03a3ef",
133
+ "paths": [
134
+ "src/hub/tasks.ts",
135
+ "src/hub/board.ts",
136
+ "src/hub/routing.ts",
137
+ "src/hub/hub-tools.ts",
138
+ "src/hub/task-sweep.ts",
139
+ "src/cli/launch.ts",
140
+ "src/cli/main.ts",
141
+ "src/cli/preview.ts"
142
+ ]
143
+ },
144
+ "docs/agent-notes/tests.md": {
145
+ "verifiedAgainst": "2f6274d007d47aa3db0976c4989021520c03a3ef",
146
+ "paths": [
147
+ "test/",
148
+ "scripts/check.sh",
149
+ "scripts/hang-watch.sh",
150
+ "scripts/seeded-check.ts",
151
+ "scripts/seeds.json",
152
+ "scripts/ci-gates.mjs",
153
+ "scripts/ci-reuse.mjs",
154
+ ".github/workflows/check.yml",
155
+ ".github/workflows/release.yml"
156
+ ]
157
+ }
158
+ }
159
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@staix/agent-hub",
3
- "version": "0.12.15",
3
+ "version": "0.12.16",
4
4
  "description": "Native multi-agent hub: Claude Code, Codex, Kimi Code, Pi and local inference as peers in one project",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-hub",
3
- "version": "0.12.15",
3
+ "version": "0.12.16",
4
4
  "description": "Channel between Claude Code and the agent-hub daemon: peer messages from Codex, Kimi and the local worker arrive as channel events; hub_send replies.",
5
5
  "author": {
6
6
  "name": "Young Joon Lee",
@@ -15291,8 +15291,8 @@ function projectContext(cwd, env = process.env) {
15291
15291
  function stateDirFor(cwd) {
15292
15292
  return projectContext(cwd).stateDir;
15293
15293
  }
15294
- var PROTOCOL = 13;
15295
- var RECOVERY_SOURCE_PROTOCOLS = [9, 10, 11, 12, PROTOCOL];
15294
+ var PROTOCOL = 14;
15295
+ var RECOVERY_SOURCE_PROTOCOLS = [9, 10, 11, 12, 13, PROTOCOL];
15296
15296
  function readControl(stateDir) {
15297
15297
  try {
15298
15298
  const status = JSON.parse(readFileSync2(join2(stateDir, "status.json"), "utf8"));
@@ -15403,7 +15403,7 @@ class ControlClient {
15403
15403
  // package.json
15404
15404
  var package_default = {
15405
15405
  name: "@staix/agent-hub",
15406
- version: "0.12.15",
15406
+ version: "0.12.16",
15407
15407
  description: "Native multi-agent hub: Claude Code, Codex, Kimi Code, Pi and local inference as peers in one project",
15408
15408
  license: "MIT",
15409
15409
  type: "module",
@@ -15499,7 +15499,7 @@ var TASK_TOOLS = [
15499
15499
  tool("hub_task_done", "Mark your task finished. It goes to its reviewer with your summary and refs. When the project configures a check for its class, the hub runs it first and the result comes as a task message: a failed check keeps the task with you.", { id, summary: { type: "string", description: "what changed, why, and the check you ran with its result" }, refs }, ["id", "summary"]),
15500
15500
  tool("hub_task_list", "The task board. PII tasks show as [pii].", { state: { type: "string", enum: ["proposed", "in_progress", "in_review", "approved", "changes_requested"] }, ready: { type: "boolean", description: "only proposed tasks with nothing left to wait for" } }),
15501
15501
  tool("hub_review", "Give your verdict on a task you were asked to review: map the changed signatures and call sites to the task's plan or detail, read the check result, and list what is unmet. Two changes_requested in a row move the task to another peer.", { id, verdict: { type: "string", enum: ["approved", "changes_requested"] }, note: str, unmet: { type: "array", items: str, description: "each requirement of the plan or detail that the change does not meet" } }, ["id", "verdict"]),
15502
- tool("hub_checkpoint", "Answer a checkpoint request from the hub (your quota window is nearly used up): what you were doing, what is half done, what whoever continues must know. Write the same to .agenthub/checkpoint.md first if you can.", { summary: str }, ["summary"]),
15502
+ tool("hub_checkpoint", "Answer a checkpoint request from the hub (quota or context window pressure): what you were doing, what is half done, what whoever continues must know. Write the same to .agenthub/checkpoint.md first if you can.", { summary: str, request_id: { type: "string", description: "The request id supplied by a context checkpoint request; required for context checkpoints" } }, ["summary"]),
15503
15503
  tool("hub_remember", "Save a decision, finding, contract or fail to the memory all agents share (claude-mem); the other agents also get it with their next message. A fail is an approach you tried that does not work, and why: the most useful note, it stops the others spending their quota on it. Do not retry what a fail note rules out without new evidence. Conclusions worth recalling, not chatter.", { text: str, title: str, kind: { type: "string", enum: [...NOTE_KINDS] }, task: id }, ["text"])
15504
15504
  ];
15505
15505
  var TASK_TOOL_NAMES = new Set(TASK_TOOLS.map((t) => t.name));
@@ -2,6 +2,7 @@ import { spawn, type ChildProcess } from "node:child_process";
2
2
  import type { Server, ServerWebSocket } from "bun";
3
3
  import { renderDigest, replyAudience, replyParent, type Envelope, type PeerId } from "../hub/envelope.ts";
4
4
  import { BasePeer } from "../hub/peers.ts";
5
+ import { codexContext, type ContextReading } from "../hub/context-window.ts";
5
6
  import { childEnv, stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
6
7
 
7
8
  export interface CodexOptions {
@@ -20,6 +21,7 @@ export interface CodexOptions {
20
21
  onUsage?: (rateLimits: unknown, hard: boolean) => void;
21
22
  /** Tokens the thread used since the previous update (see `tokenTotal` for how they are counted). */
22
23
  onTokens?: (added: number) => void;
24
+ onContext?: (reading: ContextReading) => void;
23
25
  /** The native id of each turn as it starts, after the peer turned busy (issue #33: `ahub undo --context`). */
24
26
  onTurn?: (turnId: string) => void;
25
27
  /**
@@ -62,6 +64,8 @@ export class CodexPeer extends BasePeer {
62
64
  /** Claimed as soon as a TUI WebSocket opens, before it can start a thread. */
63
65
  private claimedTui: Link | undefined;
64
66
  private threadId = "";
67
+ private contextEpoch = 0;
68
+ get sessionGeneration(): number { return this.contextEpoch; }
65
69
  /**
66
70
  * The thread's running token total at the last update, and whether the thread started here. Codex 0.156.1 also
67
71
  * sends `thread/tokenUsage/updated` for compaction (an estimate in `last`, `total` unchanged), a usage-limit refresh
@@ -344,6 +348,7 @@ export class CodexPeer extends BasePeer {
344
348
  clearInterval(this.usageTimer);
345
349
  this.link = undefined;
346
350
  this.threadId = "";
351
+ this.contextEpoch++;
347
352
  this.activeTurns.clear();
348
353
  for (const p of this.pending.values()) {
349
354
  if (p.deliveryId) this.delivery({ id: p.deliveryId, state: "needs_review", reason: "Codex TUI detached" });
@@ -396,6 +401,7 @@ export class CodexPeer extends BasePeer {
396
401
  this.freshThread = fresh;
397
402
  this.link = link;
398
403
  this.threadId = threadId;
404
+ this.contextEpoch++;
399
405
  this.activeTurns.clear();
400
406
  this.forgetTurn();
401
407
  this.opts.log?.(`[${this.id}] thread ${threadId}`);
@@ -440,6 +446,7 @@ export class CodexPeer extends BasePeer {
440
446
  }
441
447
  if (link !== this.link || params.threadId !== this.threadId) return;
442
448
  if (method === "thread/tokenUsage/updated") {
449
+ this.opts.onContext?.(codexContext(params.tokenUsage, this.threadId, Date.now()));
443
450
  const total = Number(params.tokenUsage?.total?.totalTokens);
444
451
  if (!Number.isFinite(total)) return;
445
452
  const added = this.tokenTotal === undefined ? (this.freshThread ? total : 0) : Math.max(0, total - this.tokenTotal);
@@ -1,3 +1,4 @@
1
+ import { buildPiLaunch } from "../pi/launch.ts";
1
2
  import { randomUUID } from "node:crypto";
2
3
  import { existsSync, mkdirSync, readFileSync } from "node:fs";
3
4
  import { join, resolve, relative } from "node:path";
@@ -35,14 +36,6 @@ type RpcMessage = { type?: string; id?: string | number; command?: string; succe
35
36
  type VerifiedEmptyResume = { sessionId: string };
36
37
  function ownerStillAlive(pid: number, signature: string | undefined): boolean { const current = processSignature(pid); if (current !== undefined) return current === signature; try { process.kill(pid, 0); return true; } catch { return false; } }
37
38
 
38
- const modelFor = (backend: PiOptions["backend"], models: PiModelDescriptor[]): string => {
39
- const ids = models.map((m) => m.id);
40
- const find = (needle: string) => ids.find((id) => id === needle) ?? needle;
41
- if (backend === "mlx") return find("mlx/fast");
42
- if (backend === "dgx") return find("dgx/coding");
43
- if (ids.includes("hub/auto")) return "hub/auto";
44
- return find(ids.find((id) => id === "dgx/coding") ? "dgx/coding" : ids.find((id) => id === "mlx/fast") ?? "dgx/coding");
45
- };
46
39
 
47
40
  export class PiPeer extends BasePeer {
48
41
  readonly hubNative = true;
@@ -274,14 +267,11 @@ export class PiPeer extends BasePeer {
274
267
  const extension = resolve(join(import.meta.dir, "../pi/extension.ts"));
275
268
  const inherited: NodeJS.ProcessEnv = {};
276
269
  for (const key of ["PATH", "HOME", "USER", "SHELL", "TMPDIR", "TERM", "TERM_PROGRAM", "LANG", "LC_ALL", "LC_CTYPE", "NO_COLOR", "CODEX_HOME"]) if (process.env[key]) inherited[key] = process.env[key];
277
- const env: NodeJS.ProcessEnv = { ...inherited, PI_CODING_AGENT_DIR: join(this.opts.stateDir, "pi"), AGENTHUB_PI_BRIDGE_URL: `http://127.0.0.1:${bridge.port}`, AGENTHUB_PI_BRIDGE_TOKEN: token, AGENTHUB_PI_OWNER_TOKEN: token, AGENTHUB_PI_RELAY_URL: this.opts.relay.url, AGENTHUB_PI_RELAY_TOKEN: this.opts.relay.token, AGENTHUB_PI_MODELS: JSON.stringify(this.opts.relay.models), AGENTHUB_PI_TOOLS: JSON.stringify(this.opts.tools), AGENTHUB_PI_MAX_STEPS: String(this.opts.maxSteps ?? 30) };
278
- const args = [ ...(this.opts.mode === "headless" ? ["--mode", "rpc"] : []), "--provider", "agent-hub-local", "--model", this.opts.model ?? modelFor(this.opts.backend, this.opts.relay.models), "--models", this.opts.relay.models.map((model) => `agent-hub-local/${model.id}`).join(","), "--no-builtin-tools", "--no-skills", "--no-prompt-templates", "--no-extensions", "--extension", extension, ...(this.opts.preamble ? ["--append-system-prompt", this.opts.preamble] : []), "--session-dir", join(this.opts.stateDir, "pi-sessions")];
279
- if (this.opts.sessionFile) args.push("--session", this.opts.sessionFile); else if (this.opts.sessionId) args.push("--session-id", this.opts.sessionId);
280
- const command = this.opts.cmd ?? ["pi"];
281
- this._tuiLaunch = { cmd: command[0]!, args: [...command.slice(1), ...args], env };
270
+ this._tuiLaunch = buildPiLaunch(this.opts, inherited, extension, bridge.port!, token);
271
+ const { cmd: bin, args, env } = this._tuiLaunch;
282
272
  if (this.opts.mode === "tui") { return; }
283
273
  // Its own process group, stopped as a whole (#115, as Codex's in #113).
284
- this.proc = spawn(command[0]!, [...command.slice(1), ...args], { cwd: this.opts.cwd, env, stdio: ["pipe", "pipe", "pipe"], detached: true });
274
+ this.proc = spawn(bin, args, { cwd: this.opts.cwd, env, stdio: ["pipe", "pipe", "pipe"], detached: true });
285
275
  trackGroup(this.proc);
286
276
  this.proc.stdout.on("data", (chunk) => this.onOutput(String(chunk)));
287
277
  this.proc.stderr.on("data", (chunk) => this.opts.log?.(`[${this.id}] ${String(chunk).trimEnd()}`));
package/src/cli/init.ts CHANGED
@@ -23,47 +23,57 @@ export function removeBlock(existing: string): string {
23
23
  return rest && `${rest}\n`;
24
24
  }
25
25
 
26
- /** Returns the paths it changed. */
27
- export function init(cwd: string): string[] {
28
- const changed: string[] = [];
29
- const write = (path: string, next: string) => {
30
- if (existsSync(path) && readFileSync(path, "utf8") === next) return;
31
- writeFileSync(path, next);
32
- changed.push(path);
26
+ export interface InitChange {
27
+ action: "create" | "modify" | "delete";
28
+ path: string;
29
+ reason: string;
30
+ managedBlock?: "insert" | "replace" | "remove";
31
+ }
32
+ /** Content is internal to apply, never included in a public preview. */
33
+ interface PlannedWrite extends InitChange { next?: string; }
34
+ function changes(cwd: string): PlannedWrite[] {
35
+ const changed: PlannedWrite[] = [];
36
+ const write = (path: string, next: string, reason: string, managedBlock?: InitChange["managedBlock"]) => {
37
+ const exists = existsSync(path);
38
+ if (exists && readFileSync(path, "utf8") === next) return;
39
+ changed.push({ action: exists ? "modify" : "create", path, next, reason, ...(managedBlock ? { managedBlock } : {}) });
33
40
  };
34
-
35
- mkdirSync(join(cwd, ".agenthub"), { recursive: true });
36
- const config = join(cwd, ".agenthub", "config.json");
37
- if (!existsSync(config)) write(config, readFileSync(join(TEMPLATES, "config.json"), "utf8"));
38
-
39
- const routing = join(cwd, ".agenthub", "routing.toml");
40
- if (!existsSync(routing)) write(routing, readFileSync(join(TEMPLATES, "routing.toml"), "utf8"));
41
-
41
+ for (const name of ["config.json", "routing.toml"]) {
42
+ const path = join(cwd, ".agenthub", name);
43
+ if (!existsSync(path)) write(path, readFileSync(join(TEMPLATES, name), "utf8"), "create missing project defaults");
44
+ }
42
45
  const agents = join(cwd, "AGENTS.md");
43
- write(agents, upsertBlock(existsSync(agents) ? readFileSync(agents, "utf8") : "", readFileSync(join(TEMPLATES, "AGENTS.block.md"), "utf8")));
44
-
45
- // Any CLAUDE.md, even an empty one, stops Claude Code from loading AGENTS.md: take back the block older versions wrote there.
46
- // Only a regular file of its own: a symlink may lead out of the project, and a link to AGENTS.md
47
- // (hard, or AGENTS.md -> CLAUDE.md) holds the block just written.
46
+ const existing = existsSync(agents) ? readFileSync(agents, "utf8") : "";
47
+ write(agents, upsertBlock(existing, readFileSync(join(TEMPLATES, "AGENTS.block.md"), "utf8")), "refresh hub instructions; preserve surrounding text", existing.includes("<!-- AGENT_HUB:BEGIN") ? "replace" : "insert");
48
48
  const claude = join(cwd, "CLAUDE.md");
49
49
  const own = lstatSync(claude, { throwIfNoEntry: false });
50
- const target = statSync(agents);
51
- const legacy = own?.isFile() && !(own.dev === target.dev && own.ino === target.ino) ? readFileSync(claude, "utf8") : "";
50
+ // The write preserves an existing inode, including AGENTS.md -> CLAUDE.md and hard links.
51
+ const target = existsSync(agents) ? statSync(agents) : undefined;
52
+ const legacy = own?.isFile() && !(target && own.dev === target.dev && own.ino === target.ino) ? readFileSync(claude, "utf8") : "";
52
53
  const rest = removeBlock(legacy);
53
54
  if (rest !== legacy) {
54
- if (rest) write(claude, rest);
55
- else {
56
- unlinkSync(claude);
57
- changed.push(claude);
58
- }
55
+ if (rest) write(claude, rest, "remove legacy hub instructions; preserve surrounding text", "remove");
56
+ else changed.push({ action: "delete", path: claude, reason: "remove legacy-only instructions", managedBlock: "remove" });
59
57
  }
60
-
61
58
  const ignore = join(cwd, ".gitignore");
62
59
  let lines = existsSync(ignore) ? readFileSync(ignore, "utf8") : "";
63
- // The machine's own config: only a file nobody committed may choose commands and credential files (issue #17).
64
60
  for (const entry of [".agenthub/state/", ".agenthub/config.local.json"]) {
65
61
  if (!lines.split("\n").includes(entry)) lines = `${lines}${lines && !lines.endsWith("\n") ? "\n" : ""}${entry}\n`;
66
62
  }
67
- write(ignore, lines);
63
+ write(ignore, lines, "ignore runtime state and machine-local configuration");
68
64
  return changed;
69
65
  }
66
+ /** Read-only metadata, deliberately omitting project/user file contents. */
67
+ export function planInit(cwd: string): InitChange[] {
68
+ return changes(cwd).map(({ next, ...change }) => change);
69
+ }
70
+ /** Returns the paths it changed, applying the same plan as the preview. */
71
+ export function init(cwd: string): string[] {
72
+ const plan = changes(cwd);
73
+ mkdirSync(join(cwd, ".agenthub"), { recursive: true });
74
+ for (const change of plan) {
75
+ if (change.action === "delete") unlinkSync(change.path);
76
+ else writeFileSync(change.path, change.next!);
77
+ }
78
+ return plan.map(change => change.path);
79
+ }
package/src/cli/launch.ts CHANGED
@@ -55,6 +55,15 @@ export function statusLineSettings(tee: StatusLineTee): string {
55
55
  export interface FactsHook {
56
56
  script: string;
57
57
  stateDir: string;
58
+ /** The same hook transport can observe native turns without injecting facts. */
59
+ purpose?: "facts" | "idle" | "facts-and-idle";
60
+ }
61
+
62
+ /** 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 {
64
+ const facts = config.coordination === "turn-free";
65
+ const idle = config.task_sweep?.enabled === true;
66
+ return facts || idle ? { ...paths, purpose: facts ? (idle ? "facts-and-idle" : "facts") : "idle" } : undefined;
58
67
  }
59
68
 
60
69
  /**
@@ -90,7 +99,8 @@ export function buildLaunch(
90
99
  const notes = [
91
100
  unattended ? UNATTENDED_WARNING : "",
92
101
  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)." : "",
93
- ctx.facts && own ? "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." : "",
102
+ 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
+ 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." : "",
94
104
  ].filter(Boolean);
95
105
  return {
96
106
  cmd: "claude",
@@ -105,3 +115,10 @@ export function buildLaunch(
105
115
  ...warning,
106
116
  };
107
117
  }
118
+
119
+
120
+ /** The daemon and preview use exactly the same ACP command insertion. */
121
+ export function buildKimiLaunch(command: string[], model?: string): Launch {
122
+ const [cmd, ...args] = command;
123
+ return { cmd: cmd!, args: model ? ["--model", model, ...args] : args };
124
+ }
package/src/cli/main.ts CHANGED
@@ -14,8 +14,9 @@ import { openManager, startManager, stopManager } from "../hub/manager.ts";
14
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, 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,7 +30,8 @@ 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";
@@ -53,15 +55,15 @@ const USAGE = `agent-hub ${VERSION}: Claude Code, Codex and Kimi as peers in one
53
55
  ahub ui --all [--no-open] open the unified project dashboard
54
56
  ahub ui --all --stop stop only the dashboard manager
55
57
  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)
58
+ ahub init [--dry-run --json] preview or write .agenthub/config.json and the AGENTS.md marker block (drops a legacy CLAUDE.md block)
57
59
  ahub up [--unattended] start the daemon for this directory
58
60
  ahub upgrade --to <version> [--dry-run] [--yes] review and upgrade running projects
59
61
  ahub restart [--dry-run] [--yes] recover this project's runtime
60
62
  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
63
+ ahub claude [--print-command] [args...] launch Claude Code with the hub channel [--unattended]
64
+ ahub codex [--print-command] [args...] start the Codex adapter and attach the TUI [--unattended]
65
+ ahub kimi [--print-command] [--model <alias>] start Kimi headless under ACP
66
+ ahub pi [--print-command] [--mode headless|tui] [--backend auto|dgx|mlx] [--session-id <id>] [--session-file <path>] start Pi
65
67
  ahub models setup|status|start|stop prepare or inspect local Ollama MLX (legacy stop is explicit)
66
68
  ahub local [--route <id> | --model <id>]
67
69
  start the hub-native worker on the self-hosted models (routing.toml)
@@ -469,6 +471,10 @@ const commands: Record<string, () => Promise<void> | void> = {
469
471
  },
470
472
 
471
473
  init: () => {
474
+ if (args.includes("--dry-run")) {
475
+ const plan = planInit(cwd);
476
+ 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");
477
+ }
472
478
  assertLifecycleAvailable();
473
479
  const changed = init(cwd);
474
480
  registeredProject();
@@ -490,6 +496,10 @@ const commands: Record<string, () => Promise<void> | void> = {
490
496
  },
491
497
 
492
498
  claude: async () => {
499
+ if (args.includes("--print-command") || args.includes("--dry-run")) {
500
+ try { return console.log(JSON.stringify(launcherPreview("claude", args, cwd, stateDir, unattendedEnv), null, 2)); }
501
+ catch { fail("cannot preview launch: invalid arguments or configuration (details withheld to protect credentials)"); }
502
+ }
493
503
  assertLifecycleAvailable();
494
504
  const control = readControl(stateDir);
495
505
  if (control?.instanceId) {
@@ -506,14 +516,18 @@ const commands: Record<string, () => Promise<void> | void> = {
506
516
  // no such file, or no status line in it
507
517
  }
508
518
  }
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;
519
+ // Turn-free facts and opted-in task sweeps share native PreToolUse/PostToolUse/Stop observations.
520
+ const facts = claudeObservationHooks(projectConfig(), { script: join(import.meta.dir, "facts-hook.ts"), stateDir });
511
521
  const launch = buildLaunch("claude", args, { unattended: unattendedEnv, statusLine: { script: join(import.meta.dir, "statusline-tee.ts"), stateDir, ...(original ? { original } : {}) }, ...(facts ? { facts } : {}) });
512
522
  if (launch.warning) console.error(launch.warning);
513
523
  exec(launch.cmd, launch.args);
514
524
  },
515
525
 
516
526
  codex: async () => {
527
+ if (args.includes("--print-command") || args.includes("--dry-run")) {
528
+ try { return console.log(JSON.stringify(launcherPreview("codex", args, cwd, stateDir, unattendedEnv), null, 2)); }
529
+ catch { fail("cannot preview launch: invalid arguments or configuration (details withheld to protect credentials)"); }
530
+ }
517
531
  assertLifecycleAvailable();
518
532
  const launch0 = buildLaunch("codex", args, { unattended: unattendedEnv, proxyUrl: "pending" }); // refuse bad flags before starting anything
519
533
  const hub = await connect();
@@ -528,6 +542,10 @@ const commands: Record<string, () => Promise<void> | void> = {
528
542
  },
529
543
 
530
544
  kimi: async () => {
545
+ if (args.includes("--print-command") || args.includes("--dry-run")) {
546
+ try { return console.log(JSON.stringify(launcherPreview("kimi", args, cwd, stateDir, unattendedEnv), null, 2)); }
547
+ catch { fail("cannot preview launch: invalid arguments or configuration (details withheld to protect credentials)"); }
548
+ }
531
549
  const i = args.indexOf("--model");
532
550
  const model = i === -1 ? undefined : args[i + 1] ?? fail("--model needs an alias");
533
551
  const hub = await connect();
@@ -538,6 +556,10 @@ const commands: Record<string, () => Promise<void> | void> = {
538
556
  },
539
557
 
540
558
  pi: async () => {
559
+ if (args.includes("--print-command") || args.includes("--dry-run")) {
560
+ try { return console.log(JSON.stringify(launcherPreview("pi", args, cwd, stateDir, unattendedEnv), null, 2)); }
561
+ catch { fail("cannot preview launch: invalid arguments or configuration (details withheld to protect credentials)"); }
562
+ }
541
563
  const options = piFlags();
542
564
  const hub = await connect();
543
565
  const res = await hub.request({ t: "start", peer: "pi", args: options, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
@@ -642,6 +664,7 @@ const commands: Record<string, () => Promise<void> | void> = {
642
664
  const hub = await connect();
643
665
  hub.onPush = (msg) => {
644
666
  if (msg.t === "event") console.log(render(msg.e));
667
+ else if (msg.t === "context") console.log(` ${msg.peer}: ${contextLine(msg.reading)}`);
645
668
  else if (msg.t === "notice") console.log(` * ${msg.line}`);
646
669
  else if (msg.t === "permission") {
647
670
  const options = msg.options.map((o: any) => `${o.optionId} (${o.name})`).join(", ");
@@ -775,7 +798,7 @@ const commands: Record<string, () => Promise<void> | void> = {
775
798
  if (status.deliveryError) console.log(` delivery storage: ${status.deliveryError}; dispatch is stopped`);
776
799
  for (const line of (status as { crash?: string[] }).crash ?? []) console.log(` crash recovery: ${line}`);
777
800
  const peers = Object.entries(status.peers as Record<string, PeerRow>);
778
- for (const [id, p] of peers) console.log(peerLine(id, p));
801
+ for (const [id, p] of peers) console.log(peerLine(id, { ...p, context: p.context ?? unknownContext() }));
779
802
  const models = (status as any).models?.backends as BackendRow[] | undefined;
780
803
  if (models?.length) for (const backend of models) console.log(backendLine(backend));
781
804
  if (status.switchyard) console.log(` switchyard: ${status.switchyard}`);
@@ -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
+ }