@staix/agent-hub 0.12.14 → 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.14",
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.14",
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.14",
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";
@@ -7,6 +8,8 @@ import { BasePeer } from "../hub/peers.ts";
7
8
  import { stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
8
9
  import { processSignature } from "../pi/process-signature.ts";
9
10
  import { realPath } from "../hub/project.ts";
11
+ import { toolResultFailed } from "../local/tools.ts";
12
+ import { piToolStepCeiling, type PiToolStepCeiling } from "../pi/ceiling.ts";
10
13
  import type { ExecutionBudgetDecision, ExecutionUnit } from "../hub/execution-budget.ts";
11
14
 
12
15
  export interface PiModelDescriptor { id: string; name?: string; contextWindow?: number; maxTokens?: number; reasoning?: boolean; }
@@ -22,7 +25,8 @@ export interface PiOptions {
22
25
  admitBudget?: (envs: Envelope[], unit: ExecutionUnit) => Promise<ExecutionBudgetDecision[]>;
23
26
  /** Reported message usage as increments, before agent_settled releases the turn. */
24
27
  onTokens?: (added: number) => void;
25
- onTurnFailure?: (envs: Envelope[], reason: string) => Promise<void>;
28
+ /** `ceiling` is the validated, session/turn-bound tool-step ceiling signal (#179), present only when the trusted extension emitted one for this turn. */
29
+ onTurnFailure?: (envs: Envelope[], reason: string, ceiling?: PiToolStepCeiling) => Promise<void>;
26
30
  watchdogMs?: number; log?: (line: string) => void;
27
31
  /** How long stop() waits for a graceful TUI owner exit before verified teardown. Tests shrink this. */
28
32
  stopGraceMs?: number;
@@ -32,14 +36,6 @@ type RpcMessage = { type?: string; id?: string | number; command?: string; succe
32
36
  type VerifiedEmptyResume = { sessionId: string };
33
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; } }
34
38
 
35
- const modelFor = (backend: PiOptions["backend"], models: PiModelDescriptor[]): string => {
36
- const ids = models.map((m) => m.id);
37
- const find = (needle: string) => ids.find((id) => id === needle) ?? needle;
38
- if (backend === "mlx") return find("mlx/fast");
39
- if (backend === "dgx") return find("dgx/coding");
40
- if (ids.includes("hub/auto")) return "hub/auto";
41
- return find(ids.find((id) => id === "dgx/coding") ? "dgx/coding" : ids.find((id) => id === "mlx/fast") ?? "dgx/coding");
42
- };
43
39
 
44
40
  export class PiPeer extends BasePeer {
45
41
  readonly hubNative = true;
@@ -58,6 +54,7 @@ export class PiPeer extends BasePeer {
58
54
  private executionBudgetTimer?: ReturnType<typeof setTimeout>;
59
55
  private executionAbort?: AbortController;
60
56
  private readonly budgetStops = new Map<number, string>();
57
+ private readonly ceilingStops = new Map<number, PiToolStepCeiling>();
61
58
  private readonly idleBashReservations = new Map<string, { generation: number; expiresAt: number; deadlineAt?: number }>();
62
59
  private budgetGeneration = 0;
63
60
  private modelStep = 0;
@@ -185,8 +182,11 @@ export class PiPeer extends BasePeer {
185
182
  this.activeTools++;
186
183
  if (this.state === "idle") this.setState("busy");
187
184
  if (this.state === "busy") this.touch();
188
- try { return Response.json({ text: await this.opts.executeTool(String(body.name), body.args, String(body.toolCallId ?? ""), this.sessionId, executionSignal) }); }
189
- catch (error) { return Response.json({ text: `error: ${(error as Error).message}` }, { status: 200 }); }
185
+ // `failed` is the managed-tool failure verdict (toolResultFailed, the one contract): Pi 1.0.1
186
+ // classifies a native tool result by isError === true alone, so the extension needs the flag,
187
+ // not just the text (#181). An older extension simply ignores the extra field.
188
+ try { const text = await this.opts.executeTool(String(body.name), body.args, String(body.toolCallId ?? ""), this.sessionId, executionSignal); return Response.json({ text, failed: toolResultFailed(String(body.name), text) }); }
189
+ catch (error) { return Response.json({ text: `error: ${(error as Error).message}`, failed: true }, { status: 200 }); }
190
190
  finally {
191
191
  clearTimeout(idleBashTimer);
192
192
  this.activeTools--;
@@ -267,14 +267,11 @@ export class PiPeer extends BasePeer {
267
267
  const extension = resolve(join(import.meta.dir, "../pi/extension.ts"));
268
268
  const inherited: NodeJS.ProcessEnv = {};
269
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];
270
- 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) };
271
- 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")];
272
- if (this.opts.sessionFile) args.push("--session", this.opts.sessionFile); else if (this.opts.sessionId) args.push("--session-id", this.opts.sessionId);
273
- const command = this.opts.cmd ?? ["pi"];
274
- 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;
275
272
  if (this.opts.mode === "tui") { return; }
276
273
  // Its own process group, stopped as a whole (#115, as Codex's in #113).
277
- 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 });
278
275
  trackGroup(this.proc);
279
276
  this.proc.stdout.on("data", (chunk) => this.onOutput(String(chunk)));
280
277
  this.proc.stderr.on("data", (chunk) => this.opts.log?.(`[${this.id}] ${String(chunk).trimEnd()}`));
@@ -426,7 +423,17 @@ export class PiPeer extends BasePeer {
426
423
  const generation = Number.isSafeInteger(event.generation) ? event.generation : this.budgetGeneration + 1;
427
424
  if (generation <= this.budgetGeneration) return;
428
425
  this.modelStep = 0;
429
- this.usageSeen.clear(); this.idleBashReservations.clear(); this.executionAbort = new AbortController(); this.budgetGeneration = generation; this.noteActivity(); this.agentRunning = true; this.setState("busy");
426
+ this.usageSeen.clear(); this.idleBashReservations.clear(); this.ceilingStops.clear(); this.executionAbort = new AbortController(); this.budgetGeneration = generation; this.noteActivity(); this.agentRunning = true; this.setState("busy");
427
+ }
428
+ // #179: the extension's tool-step ceiling signal. Only a schema-valid event bound to THIS session,
429
+ // THIS turn generation and a running turn is stored; the first signal of a turn stands. Anything
430
+ // else (stale, cross-session, free text) is dropped and the failure stays unclassified.
431
+ if (event.type === "ceiling") {
432
+ const ceiling = piToolStepCeiling(event);
433
+ if (ceiling && ceiling.sessionId === this.sessionId && ceiling.generation === this.budgetGeneration && this.agentRunning && this.state === "busy" && !this.ceilingStops.has(ceiling.generation)) {
434
+ this.ceilingStops.set(ceiling.generation, ceiling);
435
+ for (const old of this.ceilingStops.keys()) if (old < ceiling.generation - 8) this.ceilingStops.delete(old);
436
+ }
430
437
  }
431
438
  if (event.type === "activity" && this.state === "busy") this.touch();
432
439
  if (event.type === "tokens" && this.state === "busy" && typeof event.id === "string" && event.id.length <= 100 && Number.isSafeInteger(event.tokens) && event.tokens >= 0 && !this.usageSeen.has(event.id)) {
@@ -451,14 +458,16 @@ export class PiPeer extends BasePeer {
451
458
  if (typeof event.text === "string" && event.text.trim()) this.settledText = event.text;
452
459
  const generation = Number.isSafeInteger(event.generation) ? event.generation : this.budgetGeneration;
453
460
  const text = this.settledText.trim(); const error = this.budgetStops.get(generation) ?? this.settledError; const cancelled = !error && this.settledCancelled;
461
+ const ceiling = this.ceilingStops.get(generation);
454
462
  this.budgetStops.delete(generation);
463
+ this.ceilingStops.delete(generation);
455
464
  this.settledText = ""; this.settledError = ""; this.settledCancelled = false;
456
465
  // Answer the peers this turn was for, not every peer on the bus (issue #29). activeEnvs still holds the
457
466
  // whole delivery here, steered additions included.
458
467
  const reply = { inReplyTo: this.currentReply, to: replyAudience(this.activeEnvs) };
459
468
  if (cancelled) this.onMessage?.("Pi turn cancelled; inspect any partial effects before continuing.", reply);
460
469
  else if (error?.startsWith("execution budget")) this.onMessage?.(`Pi stopped at the execution budget: ${error}. Inspect partial work before continuing.`, reply);
461
- else if (error) void this.opts.onTurnFailure?.(this.activeEnvs, error);
470
+ else if (error) void this.opts.onTurnFailure?.(this.activeEnvs, error, ceiling);
462
471
  else if (text) this.onMessage?.(text, reply);
463
472
  for (const id of this.activeDeliveryIds) this.delivery({ id, state: error || cancelled ? "needs_review" : "completed", ...(error || cancelled ? { reason: error || "Pi turn cancelled; partial effects are possible" } : {}) });
464
473
  this.activeDeliveryIds.clear();
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}`);