@staix/agent-hub 0.12.16 → 0.12.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +8 -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/operations.md +106 -9
  8. package/docs/quickstart.md +3 -3
  9. package/docs/security.md +24 -0
  10. package/docs/smoke.md +93 -0
  11. package/docs/specs/2026-09-19-agent-hub-design.md +120 -1
  12. package/docs/verification/2026-10-09-agent-shell-t0.md +123 -0
  13. package/docs/verified.json +26 -17
  14. package/package.json +1 -1
  15. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  16. package/plugins/agent-hub/server.js +17 -5
  17. package/src/adapters/acp.ts +2 -2
  18. package/src/adapters/claude-channel.ts +3 -2
  19. package/src/adapters/codex-appserver.ts +3 -2
  20. package/src/adapters/local-worker.ts +5 -5
  21. package/src/cli/console-state.ts +213 -0
  22. package/src/cli/console.ts +201 -0
  23. package/src/cli/facts-hook.ts +8 -1
  24. package/src/cli/identity-audit.ts +66 -0
  25. package/src/cli/identity.ts +54 -0
  26. package/src/cli/launch.ts +15 -2
  27. package/src/cli/main.ts +62 -29
  28. package/src/cli/tail-render.ts +17 -0
  29. package/src/cli/upgrade-runtime.ts +1 -1
  30. package/src/hub/board.ts +6 -2
  31. package/src/hub/bus.ts +82 -10
  32. package/src/hub/child-process.ts +11 -0
  33. package/src/hub/conductor.ts +196 -0
  34. package/src/hub/control-client.ts +3 -3
  35. package/src/hub/daemon.ts +367 -46
  36. package/src/hub/envelope.ts +1 -1
  37. package/src/hub/events.ts +6 -1
  38. package/src/hub/hub-tools.ts +13 -0
  39. package/src/hub/report.ts +53 -4
  40. package/src/hub/supervision.ts +152 -0
  41. package/src/hub/tasks.ts +30 -15
  42. package/src/hub/usage.ts +37 -2
  43. package/src/pi/launch.ts +2 -1
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";
@@ -36,6 +36,10 @@ import { parseSince, readEvents } from "../hub/events.ts";
36
36
  import { formatReport, summarize } 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 { renderTailEvent } from "./tail-render.ts";
39
43
 
40
44
  /** `--since 7d|24h|<iso>` for export and report; everything when absent. */
41
45
  const since = (): number => {
@@ -56,7 +60,8 @@ const USAGE = `agent-hub ${VERSION}: Claude Code, Codex and Kimi as peers in one
56
60
  ahub ui --all --stop stop only the dashboard manager
57
61
  ahub setup [--yes] install or update the Claude Code channel plugin from this package, then run doctor
58
62
  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
63
+ ahub up [--unattended] [--no-console] start the daemon; interactive terminals enter console
64
+ ahub console [--panels] enter the human console, leaving the daemon running on exit
60
65
  ahub upgrade --to <version> [--dry-run] [--yes] review and upgrade running projects
61
66
  ahub restart [--dry-run] [--yes] recover this project's runtime
62
67
  ahub recovery status|resume|abort <operation-id> inspect, resume or cancel a preflight
@@ -129,10 +134,21 @@ const projectConfig = () => {
129
134
  return config;
130
135
  };
131
136
  const stateDir = selected.stateDir;
137
+ const identity = detectCliIdentity(process.env);
138
+ const commandLabel = cliCommandLabel(cmd, args);
139
+ const commandAccess = classifyPeerCommand(cmd, args);
140
+ function audit(outcome: "run" | "refused" | "invalid"): void {
141
+ if (identity.role === "console") return;
142
+ try { recordCliAudit(stateDir, identity.role === "tools" ? identity.peer : "unknown", commandLabel, outcome); }
143
+ catch { console.error("ahub: CLI audit could not be recorded (outbox unavailable or full)"); }
144
+ }
145
+ if (identity.role === "invalid") { audit("invalid"); fail(`${identity.reason}; use ahub console or a terminal with no agent markers`); }
146
+ if (identity.role === "tools" && commandAccess === "console") { audit("refused"); fail(peerCommandRefusal(identity.peer, commandLabel)); }
147
+ if (identity.role === "tools") audit("run");
132
148
  try { process.chdir(cwd); } catch { fail(`project directory is unavailable: ${cwd}`); }
133
149
  const unattendedEnv = process.env.AGENTHUB_UNATTENDED === "1";
134
150
  const lifecycle = { inspectProject, startProject, stopProject };
135
- const connect = () => ControlClient.connect(stateDir, { role: "console", projectRoot: cwd });
151
+ const connect = () => ControlClient.connect(stateDir, identity.role === "tools" ? { role: "tools", peer: identity.peer, projectRoot: cwd } : { role: "console", projectRoot: cwd });
136
152
 
137
153
  function registeredProject(): Project {
138
154
  const registry = new Registry();
@@ -147,14 +163,15 @@ async function healthy(): Promise<boolean> {
147
163
  finally { hub?.close(); }
148
164
  }
149
165
 
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 } });
166
+ function exec(bin: string, argv: string[], tool?: "claude" | "codex"): never {
167
+ const env = tool ? nativeLaunchEnv(tool, childEnv()) : childEnv();
168
+ const res = spawnSync(bin, argv, { cwd, stdio: "inherit", env: { ...env, AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd } });
152
169
  if (res.error) fail(`cannot run ${bin}: ${res.error.message}`);
153
170
  process.exit(res.status ?? 1);
154
171
  }
155
172
 
156
173
  function execWithEnv(bin: string, argv: string[], extra: NodeJS.ProcessEnv): never {
157
- const env: NodeJS.ProcessEnv = { ...extra, AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd };
174
+ const env: NodeJS.ProcessEnv = { ...nativeLaunchEnv("pi", extra), AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd };
158
175
  delete env.AGENTHUB_RECOVERY_OPERATION;
159
176
  delete env.AGENTHUB_UNATTENDED;
160
177
  const res = spawnSync(bin, argv, { cwd, stdio: "inherit", env });
@@ -284,26 +301,11 @@ function fail(message: string): never {
284
301
  process.exit(1);
285
302
  }
286
303
 
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
304
  async function taskOp(op: string, a: Record<string, unknown>): Promise<string> {
303
305
  const hub = await connect();
304
306
  const res = await hub.request({ t: "task", op, args: a });
305
307
  hub.close();
306
- if (!res.ok) fail(res.error);
308
+ if (!res.ok) { audit("refused"); fail(res.error); }
307
309
  return res.text;
308
310
  }
309
311
 
@@ -488,11 +490,17 @@ const commands: Record<string, () => Promise<void> | void> = {
488
490
  await runProjectDaemon(registeredProject(), unattendedEnv || args.includes("--unattended"));
489
491
  },
490
492
 
493
+ console: async () => {
494
+ if (args.some(arg => arg !== "--panels")) fail("usage: ahub console [--panels]");
495
+ await runConsole({ client: await connect(), cwd, stateDir, panels: args.includes("--panels") });
496
+ },
497
+
491
498
  up: async () => {
492
499
  const unattended = unattendedEnv || args.includes("--unattended");
493
500
  if (unattended) console.error(UNATTENDED_WARNING);
494
501
  const status = await startProject(registeredProject(), { unattended });
495
502
  console.log(`ahub up (ws://127.0.0.1:${status.controlPort}), state in ${stateDir}`);
503
+ if (process.stdin.isTTY && process.stdout.isTTY && !args.includes("--no-console")) await runConsole({ client: await connect(), cwd, stateDir });
496
504
  },
497
505
 
498
506
  claude: async () => {
@@ -504,7 +512,12 @@ const commands: Record<string, () => Promise<void> | void> = {
504
512
  const control = readControl(stateDir);
505
513
  if (control?.instanceId) {
506
514
  process.env.AGENTHUB_INSTANCE_ID = control.instanceId;
507
- await recordTerminalLaunch("claude", cwd, stateDir, control.instanceId);
515
+ const terminal = await recordTerminalLaunch("claude", cwd, stateDir, control.instanceId);
516
+ if (!terminal) process.env.AGENTHUB_LAUNCH_ID = randomUUID();
517
+ // Native hook identity exists in an ordinary terminal too; this is not an Orca recovery record.
518
+ const file = join(stateDir, "claude-launch.json");
519
+ writeFileSync(`${file}.tmp`, JSON.stringify({ instanceId: control.instanceId, launchId: process.env.AGENTHUB_LAUNCH_ID }), { mode: 0o600 });
520
+ chmodSync(`${file}.tmp`, 0o600); renameSync(`${file}.tmp`, file);
508
521
  }
509
522
  // `--settings` outranks project and user settings, so the tee has to wrap whichever status line would have won:
510
523
  // project local, then project, then user.
@@ -520,7 +533,7 @@ const commands: Record<string, () => Promise<void> | void> = {
520
533
  const facts = claudeObservationHooks(projectConfig(), { script: join(import.meta.dir, "facts-hook.ts"), stateDir });
521
534
  const launch = buildLaunch("claude", args, { unattended: unattendedEnv, statusLine: { script: join(import.meta.dir, "statusline-tee.ts"), stateDir, ...(original ? { original } : {}) }, ...(facts ? { facts } : {}) });
522
535
  if (launch.warning) console.error(launch.warning);
523
- exec(launch.cmd, launch.args);
536
+ exec(launch.cmd, launch.args, "claude");
524
537
  },
525
538
 
526
539
  codex: async () => {
@@ -538,7 +551,7 @@ const commands: Record<string, () => Promise<void> | void> = {
538
551
  if (control?.instanceId) await recordTerminalLaunch("codex", cwd, stateDir, control.instanceId);
539
552
  const launch = buildLaunch("codex", args, { unattended: unattendedEnv, proxyUrl: res.proxyUrl, codexBin: projectConfig().codex_bin });
540
553
  if (launch0.warning) console.error(launch0.warning);
541
- exec(launch.cmd, launch.args);
554
+ exec(launch.cmd, launch.args, "codex");
542
555
  },
543
556
 
544
557
  kimi: async () => {
@@ -663,7 +676,7 @@ const commands: Record<string, () => Promise<void> | void> = {
663
676
  tail: async () => {
664
677
  const hub = await connect();
665
678
  hub.onPush = (msg) => {
666
- if (msg.t === "event") console.log(render(msg.e));
679
+ if (msg.t === "event") console.log(renderTailEvent(msg.e));
667
680
  else if (msg.t === "context") console.log(` ${msg.peer}: ${contextLine(msg.reading)}`);
668
681
  else if (msg.t === "notice") console.log(` * ${msg.line}`);
669
682
  else if (msg.t === "permission") {
@@ -998,9 +1011,29 @@ const commands: Record<string, () => Promise<void> | void> = {
998
1011
  },
999
1012
  };
1000
1013
 
1001
- const run = commands[cmd] ?? (() => fail(`unknown command "${cmd}"\n\n${USAGE}`));
1014
+ async function runConductorCommand(): Promise<void> {
1015
+ let op: string;
1016
+ let input: Record<string, unknown>;
1017
+ if (cmd === "task") {
1018
+ op = args[0] === "assign" ? "hub_task_assign" : "hub_task_escalate";
1019
+ if (!args[1] || (args[0] === "assign" && !args[2]) || args.length !== (args[0] === "assign" ? 3 : 2)) fail("usage: ahub task assign <id> <peer> | escalate <id>");
1020
+ input = { id: Number(args[1]), ...(args[0] === "assign" ? { peer: args[2] } : {}) };
1021
+ } else if (cmd === "pause" || cmd === "resume") {
1022
+ if (args.length !== 1) fail(`usage: ahub ${cmd} <peer>`);
1023
+ op = cmd === "pause" ? "hub_peer_hold" : "hub_peer_release";
1024
+ input = { peer: args[0] };
1025
+ } else {
1026
+ // Native TUIs return a human launch command through the daemon; an agent shell never executes it.
1027
+ if (args.length && !(cmd === "pi" && args.length === 2 && args[0] === "--mode" && ["headless", "tui"].includes(args[1] ?? ""))) fail(`conductor starts accept no launch overrides; use ahub ${cmd}${cmd === "pi" ? " [--mode headless|tui]" : ""}`);
1028
+ op = "hub_peer_start";
1029
+ input = { peer: cmd, ...(cmd === "pi" ? { mode: args[1] ?? "headless" } : {}) };
1030
+ }
1031
+ console.log(await taskOp(op, input));
1032
+ }
1033
+ const run = identity.role === "tools" && commandAccess === "conductor" ? runConductorCommand : commands[cmd] ?? (() => fail(`unknown command "${cmd}"\n\n${USAGE}`));
1002
1034
  try {
1003
1035
  await run();
1004
1036
  } catch (e) {
1037
+ audit("refused");
1005
1038
  fail((e as Error).message);
1006
1039
  }
@@ -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) {
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);
package/src/hub/bus.ts CHANGED
@@ -82,6 +82,8 @@ function resolveTo(to: PeerId[], originals: Envelope[] | undefined): PeerId[] {
82
82
 
83
83
  const SEEN_CAP = 2048;
84
84
  const MAX_ATTEMPTS = 3;
85
+ // ponytail: one digest carries at most ten originals; larger supervision windows split into bounded deliveries.
86
+ // Revisit this ceiling after measuring native supervision cost, rather than bypassing the delivery limit.
85
87
  const DIGEST_MAX = 10;
86
88
  const NOTES_KEPT = 10;
87
89
 
@@ -100,6 +102,8 @@ export class Bus {
100
102
  private readonly manualPaused = new Set<PeerId>();
101
103
  private readonly timers = new Map<PeerId, ReturnType<typeof setTimeout>>();
102
104
  private readonly draining = new Set<PeerId>();
105
+ /** Still pending while an asynchronous condensation runs; never includes native dispatches. */
106
+ private readonly preparing = new Map<PeerId, Envelope[]>();
103
107
  private readonly seen = new Map<string, Envelope>();
104
108
  private readonly taps = new Set<(e: BusEvent) => void>();
105
109
  private readonly withdrawn = new Set<string>();
@@ -119,6 +123,10 @@ export class Bus {
119
123
  * and is never corrected once the queue empties (measured: status.json kept `queued 4` on an empty queue).
120
124
  */
121
125
  onQueues?: () => void;
126
+ /** Actual transport admission, using originals even when a digest was condensed. Never an enqueue metric. */
127
+ onDelivered?: (peer: PeerId, originals: Envelope[]) => void;
128
+ /** Synchronous native failure observation, before queue callbacks can finish turn metrics. */
129
+ onDeliveryFailed?: (peer: PeerId) => void;
122
130
  private recoveryHeld = false;
123
131
  private steering = 0;
124
132
  private condensing = 0;
@@ -389,7 +397,9 @@ export class Bus {
389
397
  const id = receipt.id;
390
398
  const before = this.journal.get(id);
391
399
  const state: JournalDeliveryState = receipt.state === "accepted" ? "accepted" : receipt.state === "completed" ? "completed" : receipt.state === "failed_safe" ? "failed" : "needs_review";
400
+ if (state === "failed" || state === "needs_review") this.deliveryFailed(peer);
392
401
  this.journal.transition(id, state, receipt.reason);
402
+ if (before?.state === "dispatching" && (state === "accepted" || state === "completed")) this.delivered(peer, before.originals);
393
403
  if (state === "failed") {
394
404
  if (before) this.failed(peer, before.originals, before.originals.some((e) => e.private) ? "private delivery failed" : receipt.reason);
395
405
  }
@@ -525,7 +535,8 @@ export class Bus {
525
535
  this.activeDeliveries.set(deliveryId, id);
526
536
  if (this.journal) this.durableHandoff(id, deliveryId, [env], [env]);
527
537
  const steerResult = this.journal ? peer.steer([env], deliveryId) : peer.steer([env]);
528
- steerResult.then(() => {}).catch(() => {
538
+ steerResult.then(() => { this.delivered(id, [env]); }).catch(() => {
539
+ this.deliveryFailed(id);
529
540
  if (this.journal) this.journal.transition(deliveryId, "needs_review", "adapter steering outcome is uncertain");
530
541
  this.activeDeliveries.delete(deliveryId);
531
542
  if (!this.journal) this.enqueue(id, env, true);
@@ -570,8 +581,7 @@ export class Bus {
570
581
  * dropped if the refused steer tries to queue it later.
571
582
  */
572
583
  withdraw(envelopeId: string): boolean {
573
- this.withdrawn.add(envelopeId);
574
- if (this.withdrawn.size > 256) this.withdrawn.delete(this.withdrawn.values().next().value as string);
584
+ this.markWithdrawn(envelopeId);
575
585
  let removed = false;
576
586
  for (const queue of this.queues.values()) {
577
587
  const i = queue.findIndex((e) => e.id === envelopeId);
@@ -582,6 +592,49 @@ export class Bus {
582
592
  return removed;
583
593
  }
584
594
 
595
+ /** Revoke pending feed copies only; accepted/dispatching journal records and native turns are untouched. */
596
+ revokeSupervision(peer: PeerId): number {
597
+ const queue = this.queues.get(peer);
598
+ if (!queue && !this.preparing.has(peer)) return 0;
599
+ let removed = 0;
600
+ const discarded = new Set<string>();
601
+ for (let i = (queue?.length ?? 0) - 1; i >= 0; i--) {
602
+ const env = queue![i]!;
603
+ if (env.from !== HUB || !env.refs?.supervision) continue;
604
+ queue!.splice(i, 1);
605
+ removed++;
606
+ discarded.add(env.id);
607
+ this.markWithdrawn(env.id);
608
+ this.discardQueued(peer, env, "supervision role or feed revoked");
609
+ }
610
+ for (const env of this.preparing.get(peer) ?? []) {
611
+ if (env.from !== HUB || !env.refs?.supervision || discarded.has(env.id)) continue;
612
+ this.markWithdrawn(env.id);
613
+ this.discardQueued(peer, env, "supervision role or feed revoked during preparation");
614
+ removed++;
615
+ }
616
+ if (removed) { this.persist(); this.onQueues?.(); }
617
+ return removed;
618
+ }
619
+
620
+ private discardQueued(peer: PeerId, env: Envelope, reason: string): void {
621
+ if (this.journal) this.pendingOutcomes.push({ id: crypto.randomUUID(), peer, state: "discarded", createdAt: Date.now(), originals: [env], out: [], reason });
622
+ this.emit({ t: "stale", env, peer, reason });
623
+ }
624
+
625
+ private delivered(peer: PeerId, originals: Envelope[]): void {
626
+ try { this.onDelivered?.(peer, originals); } catch { /* observation must never affect delivery settlement */ }
627
+ }
628
+
629
+ private deliveryFailed(peer: PeerId): void {
630
+ try { this.onDeliveryFailed?.(peer); } catch { /* observation must never affect delivery settlement */ }
631
+ }
632
+
633
+ private markWithdrawn(id: string): void {
634
+ this.withdrawn.add(id);
635
+ if (this.withdrawn.size > 256) this.withdrawn.delete(this.withdrawn.values().next().value as string);
636
+ }
637
+
585
638
  queued(id: PeerId): number {
586
639
  return this.queues.get(id)?.length ?? 0;
587
640
  }
@@ -599,8 +652,23 @@ export class Bus {
599
652
  if (this.withdrawn.has(env.id)) return;
600
653
  const queue = this.queues.get(id) ?? [];
601
654
  this.queues.set(id, queue);
602
- if (front) queue.unshift(env);
603
- else queue.push(env);
655
+ const key = env.from === HUB && env.refs?.supervision ? env.refs.supervisionKey : undefined;
656
+ const replace = !front && key ? queue.findIndex((old) => old.from === HUB && old.refs?.supervision && old.refs.supervisionKey === key && !this.attempts.has(`${id}:${old.id}`)) : -1;
657
+ const preparing = !front && key ? this.preparing.get(id)?.find((old) => old.from === HUB && old.refs?.supervision && old.refs.supervisionKey === key && !this.attempts.has(`${id}:${old.id}`) && !this.withdrawn.has(old.id)) : undefined;
658
+ let queuedEnv = preparing ? { ...env, ts: Math.min(preparing.ts, env.ts) } : env;
659
+ if (replace >= 0) {
660
+ const old = queue[replace]!;
661
+ // Keep the first milestone's deadline, so continuous updates cannot create another batching window.
662
+ queuedEnv = { ...queuedEnv, ts: Math.min(old.ts, queuedEnv.ts) };
663
+ queue[replace] = queuedEnv;
664
+ this.markWithdrawn(old.id);
665
+ this.discardQueued(id, old, "superseded supervision milestone");
666
+ } else if (front) queue.unshift(queuedEnv);
667
+ else queue.push(queuedEnv);
668
+ if (preparing && !this.withdrawn.has(preparing.id)) {
669
+ this.markWithdrawn(preparing.id);
670
+ this.discardQueued(id, preparing, "superseded supervision milestone during preparation");
671
+ }
604
672
  if (queue.length > this.opts.queueCap) {
605
673
  const victim = queue.findIndex((e) => e.priority !== "important");
606
674
  const [lost] = queue.splice(victim === -1 ? 0 : victim, 1);
@@ -614,7 +682,8 @@ export class Bus {
614
682
 
615
683
  /** 0 = deliver now; otherwise how long the oldest envelope still has to wait for company. */
616
684
  private wait(queue: Envelope[]): number {
617
- if (queue.length >= this.opts.batchMax || queue.some((e) => e.priority === "important")) return 0;
685
+ const supervisionOnly = queue.every((e) => e.from === HUB && e.refs?.supervision && e.priority === "status");
686
+ if ((!supervisionOnly && queue.length >= this.opts.batchMax) || queue.some((e) => e.priority === "important")) return 0;
618
687
  return Math.max(0, queue[0]!.ts + this.opts.batchMs - Date.now());
619
688
  }
620
689
 
@@ -656,6 +725,7 @@ export class Bus {
656
725
  // may checkpoint during that wait; it must not checkpoint the batch out of existence.
657
726
  if (!this.journal) this.prefaces.delete(id);
658
727
  const batch = this.take(id, queue, !this.journal);
728
+ this.preparing.set(id, batch);
659
729
  const delivery = preface ? [preface, ...batch] : batch;
660
730
  const mayCondense = this.opts.condense && !delivery.some((e) => e.priority === "important");
661
731
  this.condensing += mayCondense ? 1 : 0;
@@ -679,11 +749,12 @@ export class Bus {
679
749
  for (const e of out) if (!this.seen.has(e.id)) this.seen.set(e.id, e);
680
750
  while (this.seen.size > SEEN_CAP) this.seen.delete(this.seen.keys().next().value as string);
681
751
  this.lastDelivery.set(id, { out, originals: delivery });
752
+ this.preparing.delete(id); // from here on the transport/journal owns settlement
682
753
  const deliveryId = crypto.randomUUID();
683
754
  if (this.journal) { this.durableHandoff(id, deliveryId, delivery, out); this.activeDeliveries.set(deliveryId, id); }
684
755
  try {
685
756
  if (this.journal) await peer.deliver(out, deliveryId);
686
- else await peer.deliver(out);
757
+ else { await peer.deliver(out); this.delivered(id, delivery); }
687
758
  } catch {
688
759
  if (this.journal) this.uncertain(id, deliveryId, "adapter delivery outcome is uncertain");
689
760
  else this.failed(id, delivery);
@@ -691,11 +762,11 @@ export class Bus {
691
762
  }
692
763
  }
693
764
  } catch { this.storageError = "delivery journal unavailable"; }
694
- finally { this.draining.delete(id); this.onQueues?.(); }
765
+ finally { this.preparing.delete(id); this.draining.delete(id); this.onQueues?.(); }
695
766
  }
696
767
 
697
768
  private isRelevant(id: PeerId, env: Envelope): boolean {
698
- return this.opts.relevant?.(id, env) ?? true;
769
+ return !this.withdrawn.has(env.id) && (this.opts.relevant?.(id, env) ?? true);
699
770
  }
700
771
 
701
772
  /**
@@ -704,7 +775,7 @@ export class Bus {
704
775
  * this recipient's copy goes: the other queues keep theirs, and no other delivery record changes.
705
776
  */
706
777
  private dropIrrelevant(id: PeerId, queue: Envelope[]): boolean {
707
- if (!this.opts.relevant) return false;
778
+ if (!this.opts.relevant && !this.withdrawn.size) return false;
708
779
  let dropped = false;
709
780
  for (let i = queue.length - 1; i >= 0; i--) {
710
781
  const env = queue[i]!;
@@ -757,6 +828,7 @@ export class Bus {
757
828
  }
758
829
 
759
830
  private failed(id: PeerId, envs: Envelope[], reason = "delivery failed without an error detail"): void {
831
+ if (!this.journal) this.deliveryFailed(id);
760
832
  this.lastFailure.set(id, reason.replace(/\s+/g, " ").slice(0, 300));
761
833
  if (envs.some((env) => !(env.from === HUB && env.kind === "presence") && (this.attempts.get(`${id}:${env.id}`) ?? 0) + 1 >= MAX_ATTEMPTS)) {
762
834
  this.failureStreak.set(id, (this.failureStreak.get(id) ?? 0) + 1);
@@ -11,6 +11,17 @@ export function childEnv(source: NodeJS.ProcessEnv = process.env): NodeJS.Proces
11
11
  return env;
12
12
  }
13
13
 
14
+ /** Every native child owns its marker; caller vendor markers must not leak across peers (#193). */
15
+ export function peerChildEnv(peer: string, source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
16
+ const env = childEnv(source);
17
+ delete env.CLAUDECODE;
18
+ delete env.CLAUDE_CODE_SESSION_ID;
19
+ delete env.CODEX_THREAD_ID;
20
+ delete env.AGENTHUB_PEER_ID;
21
+ env.AGENTHUB_PEER_ID = peer;
22
+ return env;
23
+ }
24
+
14
25
  /** One process: its identity is the pid with its start time (`lstart`, which exec keeps); group and command are evidence. */
15
26
  export interface ProcRow {
16
27
  pid: number;