@staix/agent-hub 0.12.39 → 0.12.40

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 (77) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +15 -10
  3. package/docs/agent-notes/adapters.md +4 -1
  4. package/docs/agent-notes/budget.md +2 -0
  5. package/docs/agent-notes/bus.md +2 -2
  6. package/docs/agent-notes/daemon.md +10 -6
  7. package/docs/agent-notes/models.md +4 -4
  8. package/docs/agent-notes/tasks.md +3 -0
  9. package/docs/agent-notes/tests.md +1 -1
  10. package/docs/memory.md +1 -1
  11. package/docs/operations.md +152 -28
  12. package/docs/quickstart.md +19 -5
  13. package/docs/security.md +19 -7
  14. package/docs/smoke.md +2 -2
  15. package/docs/specs/2026-09-19-agent-hub-design.md +158 -70
  16. package/docs/specs/2026-10-04-switchyard-source-port-design.md +12 -7
  17. package/docs/verification/2026-10-20-0.12.40.md +63 -0
  18. package/docs/verified.json +38 -19
  19. package/package.json +1 -1
  20. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  21. package/plugins/agent-hub/server.js +413 -147
  22. package/src/adapters/acp.ts +34 -1
  23. package/src/adapters/claude-channel.ts +35 -4
  24. package/src/adapters/codex-appserver.ts +12 -3
  25. package/src/adapters/definitions/builtin-metadata.ts +2 -0
  26. package/src/adapters/definitions/native-cli-doctor.ts +8 -5
  27. package/src/adapters/definitions/native-cli-entry-claude.ts +7 -2
  28. package/src/adapters/definitions/native-cli-entry-codex.ts +13 -14
  29. package/src/adapters/definitions/native-cli-entry-jcode.ts +13 -3
  30. package/src/adapters/definitions/native-cli-entry-pi.ts +9 -7
  31. package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
  32. package/src/adapters/definitions/native-cli-jcode-mcp.ts +55 -1
  33. package/src/adapters/jcode.ts +110 -4
  34. package/src/adapters/local-worker.ts +21 -37
  35. package/src/adapters/pi.ts +7 -0
  36. package/src/cli/console-state.ts +1 -0
  37. package/src/cli/console.ts +11 -1
  38. package/src/cli/help.ts +13 -8
  39. package/src/cli/identity-audit.ts +33 -7
  40. package/src/cli/jcode-mcp.ts +1 -1
  41. package/src/cli/main.ts +134 -24
  42. package/src/cli/output.ts +5 -2
  43. package/src/cli/status-lines.ts +1 -1
  44. package/src/cli/terminal-recovery.ts +54 -4
  45. package/src/cli/upgrade-interactive.ts +5 -0
  46. package/src/cli/upgrade-runtime.ts +13 -1
  47. package/src/cli/upgrade.ts +67 -1
  48. package/src/hub/board.ts +4 -2
  49. package/src/hub/bus.ts +8 -3
  50. package/src/hub/checks.ts +15 -5
  51. package/src/hub/conductor.ts +2 -1
  52. package/src/hub/config-apply.ts +20 -2
  53. package/src/hub/config-reload.ts +2 -8
  54. package/src/hub/control-client.ts +26 -3
  55. package/src/hub/daemon.ts +155 -68
  56. package/src/hub/delivery-superseded.ts +19 -2
  57. package/src/hub/events.ts +4 -1
  58. package/src/hub/hub-tools.ts +4 -4
  59. package/src/hub/inference.ts +4 -8
  60. package/src/hub/lifecycle.ts +3 -3
  61. package/src/hub/main-checkout.ts +36 -0
  62. package/src/hub/memory-profile-check.ts +6 -2
  63. package/src/hub/memory-profile.ts +18 -1
  64. package/src/hub/peers.ts +2 -0
  65. package/src/hub/ports.ts +1 -0
  66. package/src/hub/routing.ts +24 -14
  67. package/src/hub/settings.ts +1 -1
  68. package/src/hub/task-repository.ts +136 -0
  69. package/src/hub/tasks.ts +101 -21
  70. package/src/hub/terminal-launch.ts +130 -0
  71. package/src/hub/usage.ts +44 -18
  72. package/src/models/route/config.ts +1 -1
  73. package/src/omniroute/client.ts +7 -19
  74. package/templates/AGENTS.block.md +1 -0
  75. package/templates/routing.toml +10 -44
  76. package/src/switchyard/config.ts +0 -45
  77. package/src/switchyard/sidecar.ts +0 -136
@@ -1,9 +1,10 @@
1
1
  import { spawn, spawnSync, type ChildProcess } from "node:child_process";
2
+ import { createConnection } from "node:net";
2
3
  import { closeSync, constants, existsSync, fstatSync, lstatSync, mkdtempSync, openSync, readSync, rmSync } from "node:fs";
3
4
  import { tmpdir } from "node:os";
4
5
  import { isAbsolute, join } from "node:path";
5
6
  import type { Readable } from "node:stream";
6
- import type { PeerState } from "../hub/envelope.ts";
7
+ import { renderDigest, type Envelope, type PeerState } from "../hub/envelope.ts";
7
8
  import { AcpPeer, type AcpOptions } from "./acp.ts";
8
9
  import { peerChildEnv, stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
9
10
 
@@ -11,6 +12,8 @@ export interface JcodeOptions extends Omit<AcpOptions, "cmd" | "qwen025" | "expe
11
12
  command: string[];
12
13
  home: string;
13
14
  providerProfile?: string;
15
+ /** How long a native steer may take to be acknowledged (#572); default 5 s. */
16
+ steerTimeoutMs?: number;
14
17
  }
15
18
  export function jcodeCommand(command: string[], kind: "serve" | "acp", socket: string, cwd: string, model?: string, profile?: string): string[] {
16
19
  if (!command.length || command.some(word => typeof word !== "string" || !word)) throw new Error("jcode_cmd must name an executable argv");
@@ -57,6 +60,10 @@ function savedSession(home: string, id: string): boolean {
57
60
  }
58
61
 
59
62
  /** Set on every owned jcode process: no Claude MCP import, and no native telemetry (jcode-telemetry-core is_enabled, #529). */
63
+ /** A TUI launch option that differs from the running owned session; carries the flag name only. */
64
+ export class JcodeOptionMismatch extends Error {
65
+ constructor(readonly option: "--session-id" | "--model" | "--provider-profile") { super("Jcode launch options differ from its running session"); }
66
+ }
60
67
  const JCODE_OWNED_ENV = { JCODE_DISABLE_CLAUDE_MCP: "1", JCODE_NO_TELEMETRY: "1" } as const;
61
68
  /** The oldest native release accepted; its ACP, serve and MCP contract was verified live on 0.98.1 (docs/verification). */
62
69
  export const JCODE_MIN_VERSION = "0.98.0";
@@ -70,6 +77,65 @@ export function jcodeVersionSupported(version: unknown): boolean {
70
77
  return !!v && compare(v, release(JCODE_MIN_VERSION)!) >= 0 && compare(v, release(JCODE_BREAKING_VERSION)!) < 0;
71
78
  }
72
79
 
80
+ /** A native steer the server did not take, with nothing sent: the message may safely stay queued. */
81
+ class SteerRefused extends Error {}
82
+ /** A native steer that was sent but not acknowledged: it may have gone in, so it is never requeued as if it had not. */
83
+ class SteerUncertain extends Error {}
84
+ const STEER_TIMEOUT_MS = 5000;
85
+
86
+ /**
87
+ * #572 AC1: a second client on the hub's own private server socket queues a soft interrupt into the running session
88
+ * (jcode injects it at the next tool boundary, or when a text-only turn ends). Established on 0.99.0 under an isolated
89
+ * home: ping lists `soft_interrupt_ids`, subscribe with `target_session_id` joins the same session without a new one,
90
+ * `state` says whether a turn runs, and the injection survives this client's disconnect. Nothing here cancels the turn.
91
+ * `canSend` runs immediately before the soft interrupt is written and names why the hub no longer wants it sent. What
92
+ * cannot be closed from here is the native side: the server's `state` reply is a snapshot, so a turn that ends and
93
+ * another that begins inside the server between its reply and the write still receives the message. Such a steer is
94
+ * acknowledged, adopted by nobody, and held for review; an upstream atomic "steer turn N" request would remove it.
95
+ */
96
+ function nativeSteer(socket: string, cwd: string, sessionId: string, text: string, clientId: string, timeoutMs = STEER_TIMEOUT_MS, canSend: () => string | undefined = () => undefined): Promise<void> {
97
+ return new Promise<void>((resolve, reject) => {
98
+ const conn = createConnection(socket);
99
+ let phase: "ping" | "subscribe" | "state" | "sent" = "ping", buffer = "", finished = false;
100
+ const finish = (error?: Error) => {
101
+ if (finished) return;
102
+ finished = true; clearTimeout(timer); conn.destroy();
103
+ if (error) reject(error); else resolve();
104
+ };
105
+ const fail = (why: string) => finish(phase === "sent" ? new SteerUncertain(`Jcode steering outcome is uncertain: ${why}`) : new SteerRefused(`Jcode owned server cannot take a steer: ${why}`));
106
+ const timer = setTimeout(() => fail(`no answer in the ${phase} step`), timeoutMs);
107
+ const send = (value: Record<string, unknown>) => conn.write(`${JSON.stringify(value)}\n`);
108
+ conn.once("error", () => fail("connection failed"));
109
+ conn.once("close", () => fail("connection closed"));
110
+ conn.once("connect", () => send({ type: "ping", id: 1 }));
111
+ conn.on("data", chunk => {
112
+ buffer += chunk.toString();
113
+ if (buffer.length > 1024 * 1024) return fail("oversized reply");
114
+ const lines = buffer.split("\n"); buffer = lines.pop()!;
115
+ for (const line of lines) {
116
+ let msg: { type?: unknown; id?: unknown; capabilities?: unknown; session_id?: unknown; is_processing?: unknown };
117
+ try { msg = JSON.parse(line); } catch { continue; }
118
+ if (msg.type === "error" && typeof msg.id === "number" && msg.id >= 1) return fail("the server refused a request");
119
+ if (phase === "ping" && msg.type === "pong" && msg.id === 1) {
120
+ if (!Array.isArray(msg.capabilities) || !msg.capabilities.includes("soft_interrupt_ids")) return fail("no soft interrupt support");
121
+ phase = "subscribe"; send({ type: "subscribe", id: 2, working_dir: cwd, target_session_id: sessionId, continue_on_disconnect: true });
122
+ } else if (phase === "subscribe" && msg.type === "session") {
123
+ if (msg.session_id !== sessionId) return fail("the server answered for another session");
124
+ phase = "state"; send({ type: "state", id: 3 });
125
+ } else if (phase === "state" && msg.type === "state" && msg.id === 3) {
126
+ if (msg.session_id !== sessionId || msg.is_processing !== true) return fail("the session has no running turn");
127
+ // The last check before anything is sent (review round 2 I1): the hub's own turn, launch, session and stop state may
128
+ // have changed during the connect, ping, subscribe and state round trips. Past this write the server may have taken
129
+ // the message, so it is never refused as unsent again.
130
+ const changed = canSend();
131
+ if (changed) return fail(changed);
132
+ phase = "sent"; send({ type: "soft_interrupt", id: 4, content: text, urgent: false, client_id: clientId });
133
+ } else if (phase === "sent" && msg.type === "ack" && msg.id === 4) finish();
134
+ }
135
+ });
136
+ });
137
+ }
138
+
73
139
  /** The wrapper contract preserves every configured prefix argument before the native probe. */
74
140
  export function verifiedJcodeCommand(command: string[], cwd: string): boolean {
75
141
  const [bin, ...prefix] = command;
@@ -162,6 +228,44 @@ export class JcodePeer extends AcpPeer {
162
228
  }
163
229
  if (failure) throw failure;
164
230
  }
231
+ /**
232
+ * #572 AC1: an `important` message to a busy Jcode goes into its running turn through its own server (a soft
233
+ * interrupt), with the receipt contract of Codex's steer: `failed_safe` when nothing was sent, `needs_review` when it
234
+ * may have gone in, `accepted` once the server acknowledged it, and `completed` when the turn ends normally.
235
+ * The hub never cancels the turn to deliver.
236
+ */
237
+ async steer(envs: Envelope[], deliveryId?: string): Promise<void> {
238
+ const refuse = (reason: string): never => {
239
+ if (deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason });
240
+ throw new Error(reason);
241
+ };
242
+ const sessionId = super.recoveryMetadata().sessionId;
243
+ const socket = lstatSync(this.socket, { throwIfNoEntry: false });
244
+ if (this.state !== "busy") refuse(`jcode is ${this.state}`);
245
+ if (this.ending || !this.server || this.server.exitCode !== null || this.server.signalCode !== null ||
246
+ !socket?.isSocket() || socket.dev !== this.socketIdentity?.dev || socket.ino !== this.socketIdentity.ino || typeof sessionId !== "string") refuse("owned Jcode server is unavailable");
247
+ // The turn this steer is for. The native acknowledgement is asynchronous: only this exact turn may take the message.
248
+ const turn = this.currentTurn(), launch = this.server!, identity = this.socketIdentity; // both proven present above
249
+ const stillCurrent = (): string | undefined => {
250
+ if (this.currentTurn() !== turn || this.state !== "busy") return `jcode's turn changed before the steer was sent (${this.state})`;
251
+ if (this.ending || this.server !== launch || launch.exitCode !== null || launch.signalCode !== null || this.socketIdentity !== identity) return "owned Jcode server changed before the steer was sent";
252
+ if (super.recoveryMetadata().sessionId !== sessionId) return "jcode's session changed before the steer was sent";
253
+ const now = lstatSync(this.socket, { throwIfNoEntry: false });
254
+ if (!now?.isSocket() || now.dev !== identity?.dev || now.ino !== identity.ino) return "owned Jcode socket changed before the steer was sent";
255
+ return undefined;
256
+ };
257
+ try { await nativeSteer(this.socket, this.jcode.cwd, sessionId as string, renderDigest(envs, true), deliveryId ?? crypto.randomUUID(), this.jcode.steerTimeoutMs, stillCurrent); }
258
+ catch (error) {
259
+ if (error instanceof SteerUncertain) {
260
+ if (deliveryId) this.delivery({ id: deliveryId, state: "needs_review", reason: "steering outcome is uncertain" });
261
+ throw error;
262
+ }
263
+ return refuse(error instanceof Error ? error.message : "Jcode steer failed");
264
+ }
265
+ // The turn may have ended, or another begun, while the server answered: it did take the message, so it is held for review, never refused as unsent and never adopted into a different turn.
266
+ try { this.adoptIntoTurn(turn, envs, deliveryId); }
267
+ catch { if (deliveryId) this.delivery({ id: deliveryId, state: "needs_review", reason: "steered turn ended before its receipt" }); throw new SteerUncertain("Jcode steering outcome is uncertain: its turn ended first"); }
268
+ }
165
269
  tuiLaunch(expected?: JcodeTuiBinding, options: { model?: string; providerProfile?: string; sessionId?: string } = {}): JcodeTuiState {
166
270
  const sessionId = super.recoveryMetadata().sessionId;
167
271
  const socket = lstatSync(this.socket, { throwIfNoEntry: false });
@@ -169,9 +273,11 @@ export class JcodePeer extends AcpPeer {
169
273
  !socket?.isSocket() || socket.dev !== this.socketIdentity?.dev || socket.ino !== this.socketIdentity.ino || typeof sessionId !== "string") throw new Error("owned Jcode launch is unavailable");
170
274
  const binding = { launchId: this.launchId, sessionId };
171
275
  if (expected && (expected.launchId !== binding.launchId || expected.sessionId !== binding.sessionId)) throw new Error("Jcode launch changed");
172
- if ((options.sessionId !== undefined && options.sessionId !== sessionId) ||
173
- (options.model !== undefined && options.model !== this.jcode.launchModel) ||
174
- (options.providerProfile !== undefined && options.providerProfile !== this.jcode.providerProfile)) throw new Error("Jcode launch options differ from its running session");
276
+ // Names the option, never its value: the flag is the person's own and carries nothing private (#559).
277
+ const differs = options.sessionId !== undefined && options.sessionId !== sessionId ? "--session-id"
278
+ : options.model !== undefined && options.model !== this.jcode.launchModel ? "--model"
279
+ : options.providerProfile !== undefined && options.providerProfile !== this.jcode.providerProfile ? "--provider-profile" : undefined;
280
+ if (differs) throw new JcodeOptionMismatch(differs);
175
281
  if (!savedSession(this.jcode.home, sessionId)) return { state: "waiting", binding };
176
282
  const [cmd, ...args] = jcodeTuiCommand(this.jcode.command, this.socket, sessionId, this.jcode.launchModel, this.jcode.providerProfile);
177
283
  return { state: "ready", binding, launch: { cmd: cmd!, args, env: { JCODE_HOME: this.jcode.home, JCODE_SOCKET: this.socket, ...JCODE_OWNED_ENV } } };
@@ -4,15 +4,16 @@ import type { RouteLabelEvent, RouteTurnOutcome } from "../models/route/labels.t
4
4
  import type { StaySwitchPolicy } from "../models/route/stage.ts";
5
5
  import type { ToolObservation } from "../models/route/signals.ts";
6
6
  import { randomUUID } from "node:crypto";
7
- import { renderDigest, replyAudience, replyParent, STANDING_INSTRUCTION, USER, type Envelope, type EnvelopeOpts, type PeerId } from "../hub/envelope.ts";
7
+ import { MAX_HOP, renderDigest, replyAudience, replyParent, STANDING_INSTRUCTION, USER, type Envelope, type EnvelopeOpts, type PeerId } from "../hub/envelope.ts";
8
8
  import { CONDUCTOR_TOOL_NAMES, CONDUCTOR_TOOLS, TASK_TOOL_NAMES, TASK_TOOLS, profileTaskTools } from "../hub/hub-tools.ts";
9
9
  import { BasePeer } from "../hub/peers.ts";
10
+ import type { PublishResult } from "../hub/bus.ts";
10
11
  import { profile, proxyEnv, type SandboxNetwork } from "../local/sandbox.ts";
11
12
  import { runTool, toolResultFailed, TOOL_SCHEMAS, touchedPaths, ApprovalWaitStop, type ToolApproval, type ApprovalProvenance, type ToolContext } from "../local/tools.ts";
12
13
  import type { Capture } from "../memory/capture.ts";
13
14
  import type { ChatMessage, ChatResult, OmniRoute, ToolCall } from "../omniroute/client.ts";
14
15
  import { safeModelLabel } from "../omniroute/usage.ts";
15
- import type { Sidecar } from "../switchyard/sidecar.ts";
16
+ import { effectiveLocalRoute } from "../hub/routing.ts";
16
17
  import type { ExecutionBudgetDecision } from "../hub/execution-budget.ts";
17
18
 
18
19
  export interface LocalOptions {
@@ -21,11 +22,9 @@ export interface LocalOptions {
21
22
  sdlcCriteria?: boolean;
22
23
  cwd: string;
23
24
  omni: OmniRoute;
24
- /** Absent = always the fixed model on OmniRoute. */
25
- sidecar?: Sidecar;
26
- /** Switchyard route id asked of the sidecar. */
25
+ /** In-process hub route id; legacy routes use the fixed model. */
27
26
  route?: string;
28
- /** Model id sent straight to OmniRoute when the sidecar is absent, unhealthy or fails a call. */
27
+ /** Model alias sent straight to OmniRoute when no hub route is active or its call fails. */
29
28
  fixedModel: string;
30
29
  hubRoutes?: () => Record<string, HubRoute>;
31
30
  /** routing.toml `stay_switch` for hub stage routes (#197). */
@@ -81,6 +80,7 @@ export class LocalPeer extends BasePeer {
81
80
  private readonly history: ChatMessage[] = [];
82
81
  private readonly sessionId = `agent-hub-local-${randomUUID()}`;
83
82
  private readonly routes: HubRouteRuntime;
83
+ private readonly warnedLegacyRoutes = new Set<string>();
84
84
  private routeEnvs: Envelope[] = [];
85
85
  private routePii = false;
86
86
  private routeTask?: number;
@@ -237,10 +237,8 @@ export class LocalPeer extends BasePeer {
237
237
  const turnSignal = this.abort!.signal;
238
238
  // claude-mem's observer is a cloud model: nothing of a PII turn is captured.
239
239
  const capture = policy?.pii ? undefined : this.opts.capture;
240
- // Positive confirmation, and for the path this turn will really take: a sidecar generated against the off-campus URL
241
- // keeps sending there even after the client has found the campus gateway again.
242
- const viaOffCampus = !(policy?.route ?? this.opts.route)?.startsWith("hub/") && !!this.opts.sidecar?.upstream && this.opts.omni.isAccessHost(this.opts.sidecar.upstream);
243
- if (policy?.pii && (viaOffCampus || !(await this.opts.omni.onCampus()))) {
240
+ // Unknown gateway state is never permission to send PII.
241
+ if (policy?.pii && !(await this.opts.omni.onCampus())) {
244
242
  return "Refused: this is a PII task and the only reachable gateway is off campus (Cloudflare Access). Connect the VPN and assign it again.";
245
243
  }
246
244
  this.routeEnvs = envs;
@@ -328,8 +326,9 @@ export class LocalPeer extends BasePeer {
328
326
  sandboxEnv: { ...proxyEnv(this.opts.tools.bashNetwork ?? false), AGENTHUB_PEER_ID: this.id },
329
327
  signal: turnSignal,
330
328
  send: (text, to) => {
331
- const refused = this.onMessage?.(text, pii ? reply : { inReplyTo: replyParent(envs), to: to?.length ? to : replyAudience(envs) });
332
- if (typeof refused === "string") return `not sent: ${refused}`;
329
+ const result = this.onMessage?.(text, pii ? reply : { inReplyTo: replyParent(envs), to: to?.length ? to : replyAudience(envs) }) as PublishResult | string | undefined;
330
+ if (typeof result === "string") return `not sent: ${result}`;
331
+ if (result?.dropped === "hop") return `not delivered: reply chain reached the hop cap (${MAX_HOP}); send a new message without reply_to`;
333
332
  return pii ? "sent to the console user only (PII task)" : "sent";
334
333
  },
335
334
  };
@@ -372,11 +371,14 @@ export class LocalPeer extends BasePeer {
372
371
  for (const tool_call_id of missing) msgs.push({ role: "tool", tool_call_id, content: result });
373
372
  }
374
373
 
375
- /** L2 when the sidecar is up, otherwise (or when a call through it fails) the fixed model on L3. */
374
+ /** Hub-owned in-process policy, with a fixed OmniRoute alias as fallback. */
376
375
  private async call(turnMsgs: ChatMessage[], policy: { route?: string; fixedModel?: string; pii?: boolean; task?: string } | undefined, envs: Envelope[]): Promise<ChatResult> {
377
- const { omni, sidecar } = this.opts;
378
- // A task turn asks for its class's route; a route needs the sidecar, which exists only when the worker was started with one.
379
- const route = policy?.route ?? this.opts.route;
376
+ const { omni } = this.opts;
377
+ const route = effectiveLocalRoute(this.opts.route, policy?.route, policy?.fixedModel);
378
+ if (route && !route.startsWith("hub/") && !this.warnedLegacyRoutes.has(route)) {
379
+ this.warnedLegacyRoutes.add(route);
380
+ this.opts.log?.(`legacy route ${route} is deprecated and ignored; using fixed model`);
381
+ }
380
382
  const fixedModel = policy?.fixedModel ?? this.opts.fixedModel;
381
383
  const task = policy?.task === undefined ? undefined : Number(policy.task);
382
384
  const tools = [...TOOL_SCHEMAS, ...(this.opts.taskTool ? [...profileTaskTools(this.opts.profile, this.opts.sdlcStages, this.opts.sdlcCriteria).filter(tool => tool.name !== "hub_delivery_retry"), ...CONDUCTOR_TOOLS].map(asFunction) : [])];
@@ -384,19 +386,6 @@ export class LocalPeer extends BasePeer {
384
386
  const messages: ChatMessage[] = [{ role: "system", content: system(this.opts.cwd, this.opts.preamble) }, ...this.history, ...turnMsgs];
385
387
  const routed = await this.hubCall(route, messages, policy, signal);
386
388
  if (routed) return routed;
387
- const via = await this.sidecarEndpoint(route, !!policy?.pii);
388
- if (via) {
389
- await this.requireBudget(envs, "model_calls");
390
- try {
391
- const res = await omni.chat({ model: route!, messages, tools }, { via, sessionId: this.sessionId, signal });
392
- this.lastServedBy = `switchyard ${route} -> ${res.selectedModel ?? "?"}`;
393
- this.recordUsage(res, route!, task);
394
- return res;
395
- } catch (e) {
396
- if (signal.aborted) throw e;
397
- sidecar!.disable((e as Error).message);
398
- }
399
- }
400
389
  await this.requireBudget(envs, "model_calls");
401
390
  const res = await omni.chat({ model: fixedModel, messages, tools }, { signal, ...(policy?.pii ? { onCampusOnly: true } : {}) });
402
391
  this.lastServedBy = `omniroute ${fixedModel} (provider ${res.provider ?? "?"})`;
@@ -441,13 +430,8 @@ export class LocalPeer extends BasePeer {
441
430
  }
442
431
  }
443
432
 
444
- private async sidecarEndpoint(route: string | undefined, pii: boolean): Promise<string | undefined> {
445
- if (!route || route.startsWith("hub/") || pii) return undefined;
446
- return this.opts.sidecar?.endpoint();
447
- }
448
-
449
- private async conclude(msgs: ChatMessage[], policy: { route?: string; pii: boolean } | undefined, turn: number, step: number, maxSteps: number, answer: string, signal: AbortSignal, decisionId?: string): Promise<string | undefined> {
450
- const route = policy?.route ?? this.opts.route, config = this.hubConfig(route);
433
+ private async conclude(msgs: ChatMessage[], policy: { route?: string; fixedModel?: string; pii: boolean } | undefined, turn: number, step: number, maxSteps: number, answer: string, signal: AbortSignal, decisionId?: string): Promise<string | undefined> {
434
+ const route = effectiveLocalRoute(this.opts.route, policy?.route, policy?.fixedModel), config = this.hubConfig(route);
451
435
  if (!route || !config) return answer;
452
436
  const messages: ChatMessage[] = [{ role: "system", content: system(this.opts.cwd, this.opts.preamble) }, ...this.history, ...msgs];
453
437
  const feedback = await this.routes.review(route, config, messages, this.routeScope(!!policy?.pii, turn), !!policy?.pii, signal, decisionId);
@@ -472,7 +456,7 @@ export class LocalPeer extends BasePeer {
472
456
  ...(res.usage ? { usage: res.usage } : {}),
473
457
  requestedModel: safeModelLabel(requestedModel) ?? "unknown",
474
458
  ...(Number.isSafeInteger(task) && task! > 0 ? { task } : {}),
475
- ...(safeModelLabel(res.servedModel ?? res.selectedModel) ? { servedModel: safeModelLabel(res.servedModel ?? res.selectedModel)! } : {}),
459
+ ...(safeModelLabel(res.servedModel) ? { servedModel: safeModelLabel(res.servedModel)! } : {}),
476
460
  ...(safeModelLabel(res.provider) ? { provider: safeModelLabel(res.provider)! } : {}),
477
461
  });
478
462
  } catch {
@@ -429,6 +429,13 @@ export class PiPeer extends BasePeer {
429
429
  private verifiedOwner(): boolean {
430
430
  return this.ownerClaimed && !!this.ownerPid && !!this.ownerSignature && processSignature(this.ownerPid) === this.ownerSignature;
431
431
  }
432
+
433
+ terminalOwner(_launchId: string): { generation: string; nativePid: number } | undefined {
434
+ if (this.opts.mode !== "tui" || !this.started || this.stopping || this.rosterDetached || !this.verifiedOwner() || !this.sessionId || !this.sessionFile ||
435
+ this.opts.sessionId && this.opts.sessionId !== this.sessionId || this.opts.sessionFile && this.opts.sessionFile !== this.sessionFile) return undefined;
436
+ const generation = this.sessionGeneration;
437
+ return generation && this.ownerPid ? { generation, nativePid: this.ownerPid } : undefined;
438
+ }
432
439
  private abortTools(): Promise<void>[] {
433
440
  this.executionAbort?.abort();
434
441
  this.idleBashReservations.clear();
@@ -891,6 +891,7 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
891
891
  if (p.permissionMode && p.permissionMode !== "ask") summary.push(span(` ${p.permissionMode}`, "attention"));
892
892
  if (p.needsReview) summary.push(span(` review${p.needsReview}`, "failure"));
893
893
  if (p.paused) summary.push(span(" paused", "attention"));
894
+ if (typeof p.importantWaitingSince === "number") summary.push(span(` important waiting ${duration(now - p.importantWaitingSince)}`, "attention")); // #572 AC1: a busy ACP peer that cannot be steered
894
895
  if (p.toolsOnly) summary.push(span(definitionOf(id)?.attach?.pullOnlyForms?.includes("tui") ? ` pull-only, read ${typeof p.lastInboxAt === "number" ? relative(p.lastInboxAt, now) : "never"}` : " tools-only: ahub claude", "attention"));
895
896
  }
896
897
  if (s.pendingRestart?.length) summary.push(span(`${summary.length ? " | " : ""}restart pending: ${s.pendingRestart.map(row => row.key).join(", ")}`, "attention"));
@@ -86,7 +86,8 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
86
86
  const { client } = options; const terminal = options.terminal ?? nativeTerminal();
87
87
  const color = options.color ?? resolveColor(undefined, { isTTY: terminal.isTTY, TERM: process.env.TERM, NO_COLOR: process.env.NO_COLOR }) === true;
88
88
  let state = initialConsoleState(!!options.panels); state.project = basename(options.cwd);
89
- const pet = loadPetConfig(options.cwd);
89
+ let pet = loadPetConfig(options.cwd);
90
+ let petSignature = JSON.stringify([pet.enabled, pet.character, pet.size, pet.glyphs, pet.blink, pet.bell]);
90
91
  state.petEnabled = pet.enabled && !options.noPet; state.petBlink = pet.blink;
91
92
  state.petChoices = CHARACTER_IDS; state.petCharacter = pet.character ?? "stai";
92
93
  state.petSize = pet.size; state.petGlyphs = petGlyphs(pet.glyphs);
@@ -194,6 +195,15 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
194
195
  if (!active) return;
195
196
  const [status, budget, tasks, queue, ready, settings] = replies;
196
197
  captureConsoleSelection(state);
198
+ try {
199
+ const latestPet = loadPetConfig(options.cwd);
200
+ const nextSignature = JSON.stringify([latestPet.enabled, latestPet.character, latestPet.size, latestPet.glyphs, latestPet.blink, latestPet.bell]);
201
+ if (nextSignature !== petSignature) {
202
+ pet = latestPet; petSignature = nextSignature;
203
+ state.petEnabled = pet.enabled && !options.noPet; state.petBlink = pet.blink;
204
+ state.petCharacter = pet.character ?? "stai"; state.petSize = pet.size; state.petGlyphs = petGlyphs(pet.glyphs);
205
+ }
206
+ } catch { /* retain running console presentation while a config file is unreadable */ }
197
207
  if (status.status?.tasks && status.ok !== false) state.taskCounts = status.status.tasks;
198
208
  if (status.status && status.ok !== false) state.pendingRestart = Array.isArray(status.status.pendingRestart) ? status.status.pendingRestart : [];
199
209
  if (status.status && status.ok !== false) state.retainedConfig = Array.isArray(status.status.retainedConfig) ? status.status.retainedConfig : [];
package/src/cli/help.ts CHANGED
@@ -21,6 +21,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
21
21
  ["ahub console --no-pet", "turn off attention for this session; combines with --panels and --color"],
22
22
  ["ahub pet --window [--close]", "start or close the macOS floating window, from a person's terminal"],
23
23
  ["ahub pet [--short]", "read attention without connecting; --short prints ahub!N only when action is needed"],
24
+ ["ahub pet --palette <blue|teal|violet|ochre>", "save the floating window's base color to pet.palette in .agenthub/config.local.json"],
24
25
  ["ahub sdlc status [task] | explain <task> | export <task> | record <task> <receipt-json>", "read head-bound lifecycle gates and the next actor/action (person only)"],
25
26
  ["ahub sdlc queue <owner/repo> | release <owner/repo> [live-issue...]", "read-only merge and release evidence (person only)"],
26
27
  ["ahub sdlc accept <task> --issue <n> [--repo owner/repo]", "accept the task's spec issue at its current body; build entry needs it in enforce (person only)"],
@@ -36,8 +37,8 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
36
37
  ["ahub doctor --orphans [--kill] [--json] [--color=auto|always|never]", "list registrations whose project root is gone; --kill stops their daemons (SIGTERM, then SIGKILL) only after the process identity checks out"],
37
38
  ["ahub kill", "stop this project's hub"],
38
39
  ["ahub reset [--all] [--yes]", "list, then with --yes stop the hub and discard every queued and needs_review delivery, clear holds and pauses and drop session pointers; --all moves the state directory to .agenthub/archive instead"],
39
- ["ahub restart [--dry-run] [--yes]", "recover this project's runtime; on a terminal without a flag it reviews the plan with you, follows the progress and offers the next steps"],
40
- ["ahub upgrade [--to <version>] [--dry-run] [--yes]", "review and upgrade running projects; --to defaults to the latest release (--yes needs it), and a newer release's own coordinator takes over; on a terminal without --dry-run or --yes: review, end agents first if you choose, apply, follow, and resume, cancel, end or reset on the spot"],
40
+ ["ahub restart [--dry-run] [--yes] [--json] [--verbose] [--color=auto|always|never]", "recover this project's runtime; on a terminal without a flag it reviews the plan with you, follows the progress and offers the next steps"],
41
+ ["ahub upgrade [--to <version>] [--dry-run] [--yes] [--json] [--verbose] [--color=auto|always|never]", "review and upgrade running projects; --to defaults to the latest release (--yes needs it), and a newer release's own coordinator takes over; on a terminal without --dry-run or --yes: review, end agents first if you choose, apply, follow, and resume, cancel, end or reset on the spot"],
41
42
  ["ahub recovery [status|resume|abort] [<operation-id>]", "inspect, resume or cancel a preflight; status names the next actions; without an id, the operation that holds the lock, and with nothing else on a terminal its screen"],
42
43
  ["ahub recovery reattach <operation-id> codex|claude|pi", "person only: repair that blocked peer with its recorded session; leaves the rest for recovery resume"],
43
44
  ["ahub recovery dispose [<operation-id>] --fresh-session codex|claude --reason <text>", "end a partial operation by starting that peer as a new session, recording the lost conversation"],
@@ -49,10 +50,12 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
49
50
  ["ahub claude [--print-command] [--unattended] [args...]", "launch Claude Code with the hub channel"],
50
51
  ["ahub codex [--print-command] [--unattended] [args...]", "start the Codex adapter and attach the TUI"],
51
52
  ["ahub kimi [--print-command] [--mode tui|headless] [--model <alias>]", "default: Kimi's own TUI, pull-only via hub_inbox; no push, steer, watchdog, console approvals or automatic quota handoff. Select headless for ACP"],
52
- ["ahub jcode [--mode headless] [--model <alias>] [--provider-profile <name>] [--session-id <id>] [--print-command]", "optional owned Jcode daemon/ACP; native policy unmanaged, no hub sandbox"],
53
+ ["ahub jcode [--mode tui|headless] [--model <alias>] [--provider-profile <name>] [--session-id <id>] [--print-command]", "optional Jcode (roster.jcode, default off): its TUI on the hub-owned ACP session, opened once that session is saved; --mode headless for ACP only; native policy unmanaged, no hub sandbox"],
54
+ ["ahub agy [--print-command | --dry-run] [args...]", "optional Antigravity CLI 1.4.2 TUI in this terminal (roster.agy, default off): pull-only via hub_inbox, implementer/verifier only; native permissions unmanaged, no hub sandbox"],
53
55
  ["ahub qwen [--mode headless] [--model <alias>] [--session-id <id>] [--print-command]", "start optional Qwen Code 0.25.0 ACP (roster.qwen, default off)"],
54
- ["ahub pi [--print-command] [--mode headless|tui | --headless] [--backend auto|dgx|mlx] [--session-id <id>] [--session-file <path>] [--replace-session]", "start Pi: its TUI in this terminal unless --headless or the start mode setting (peers.pi.start_mode) says headless; without a terminal the hub opens one or says how"],
56
+ ["ahub pi [--print-command] [--mode headless|tui | --headless] [--backend auto|dgx|mlx] [--model <alias>] [--session-id <id>] [--session-file <path>] [--replace-session]", "start Pi: its TUI in this terminal unless --headless or the start mode setting (peers.pi.start_mode) says headless; without a terminal the hub opens one or says how"],
55
57
  ["ahub local [--route <id> | --model <id>]", "start the hub-native worker on the self-hosted models (routing.toml)"],
58
+ ["ahub claude|codex|kimi|qwen|pi|local --as <instance> [options...]", "start an extra instance of that kind declared under instances in .agenthub/config.local.json"],
56
59
  ["ahub role list [--group <group>] | show <id>", "read bounded catalog guidance with layer and content hash"],
57
60
  ["ahub role assign <peer> <id> | clear <peer> [<id>]", "person only: add or clear catalog guidance, applied at the next peer start"],
58
61
  ["ahub stop <peer>", "human only: stop a hub-owned headless peer; end TUI agents in their terminals"],
@@ -67,7 +70,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
67
70
  ["ahub permit <id> <option>", "answer a permission request shown by tail (\"deny\" cancels)"],
68
71
  ["ahub roster [--json] [--color=auto|always|never]", "human only: every known peer, participation, start mode and retained work"],
69
72
  ["ahub roster add <peer> | remove <peer> [--yes] [--now]", "human only: change live participation through settings; removal previews retained messages and task handoff"],
70
- ["ahub settings [list] [--json] | get <key>", "human only: the settings the dashboard can change, each with its value, the file it comes from and when it applies"],
73
+ ["ahub settings [list] [--json] | get <key> [--json]", "human only: the settings the dashboard can change, each with its value, the file it comes from and when it applies"],
71
74
  ["ahub settings set <key> <value|inherit> [--yes] [--preview] [--now] | undo | reload", "human only: change or reload validated settings, written to .agenthub/config.local.json or routing.local.toml (never a tracked file); --preview shows the effect and saves nothing; undo puts back the last write"],
72
75
  ["ahub pause|resume <peer>", "hold a peer's deliveries in its queue / release them"],
73
76
  ["ahub queue list [--peer <id>] [--json] [--color=auto|always|never]", "inspect durable deliveries"],
@@ -88,8 +91,8 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
88
91
  ["ahub task assign <id> <peer> [--role owner|reviewer] [--revision N]", "name the owner (default) or reviewer; a task that still waits reserves its owner; --revision refuses stale console actions"],
89
92
  ["ahub task unassign <id> [--role owner|reviewer] [--revision N]", "person only: remove the owner/reservation or reviewer without routing a replacement or stopping native work"],
90
93
  ["ahub task cancel <id> --reason <text> [--superseded-by <ref>] [--revision N]", "person only: close obsolete work with a reason; retain its history without stopping native work or releasing dependents"],
91
- ["ahub task label <id> ok|regressed|reverted|incomplete|wrong|abandoned", "your later verdict on an approved task, kept in the research records (research must be on)"],
92
- ["ahub review <id> approved|changes_requested [note...] [--unmet <item>]...", "give a review verdict on a task"],
94
+ ["ahub task label <id> ok|regressed|reverted|incomplete|wrong|abandoned [--created <time>]", "your later verdict on an approved task, kept in the research records (research must be on)"],
95
+ ["ahub review <id> approved|changes_requested [note...] [--unmet <item>]... [--head <sha>]", "give a review verdict on a task"],
93
96
  ["ahub review brief <id>", "print the review brief of a task's commit: reviewable and excluded files, rules, coverage to account for"],
94
97
  ["ahub route explain <id>", "why a task went where it went, its reserved owner included"],
95
98
  ["ahub route explain --class <c> <title...>", "what would happen to such a task now"],
@@ -113,7 +116,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
113
116
  ["ahub research export [--format jsonl|csv] [--since 30d] [--all]", "the research records for outside analysis: ids, counts and tokens, never text"],
114
117
  ["ahub research backfill", "build this project's research records once from events.jsonl"],
115
118
  ["ahub bench run <suite.json> --arm <label> [--repeat N] [--tasks a,b] [--addon <name>=on|off]... [--memory off|claude-mem|native|both]", "run a benchmark suite against the peers attached here, in a project kept for benchmarks (it resets the work tree); --addon and --memory declare the arm's add-on and memory state and refuse a setup that differs"],
116
- ["ahub bench list|status", "benchmark runs on this machine / the one running now"],
119
+ ["ahub bench list|status [--json]", "benchmark runs on this machine / the one running now"],
117
120
  ["ahub bench report <run> [--json]", "pass rate, first pass, rework, tokens and wall time per task and overall"],
118
121
  ["ahub bench compare <run|arm>... [--suite <name>] [--mixed] [--json]", "compare arms of one suite with 95% bootstrap intervals; inconclusive below 5 counted attempts per arm"],
119
122
  ["ahub bench export [--format jsonl|csv]", "benchmark records for outside analysis: ids, outcomes and counts, never suite text"],
@@ -123,6 +126,8 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
123
126
  ["ahub memory promote|demote|forget <id> | supersede <old> <new> | rebuild", "a person's changes to memory: move an entry between tiers, forget it, mark it superseded, or rebuild the index from the logs"],
124
127
  ["ahub memory sync [--remote <private git url>] | machine [--label <name>]", "sync hub memory with your other machines through a private git remote (a person's), or show or name this machine"],
125
128
  ["ahub memory import claude-mem [--db <path>] [--limit N] [--restart] | dedupe [--apply] | suggestions | confirm|reject <id>", "copy the hub's notes from claude-mem into hub memory (a person's); review the model's suggested edges"],
129
+ ["ahub memory decay [--days N] [--json]", "person only: list long-term entries older than N days (default 90) that no open goal links; review only, nothing is changed"],
130
+ ["ahub memory profile-check [--json]", "read-only check of the running hubs' memory bindings against their memory profiles; exits 1 when one is not ready"],
126
131
  ["ahub goal [list] [--all] | show <id> [--json]", "long-term tasks: state, which machine holds each and until when, linked board tasks"],
127
132
  ["ahub goal new <title> [--milestone <text>]... [--depends <id>]... [--shared] | link <id> <task> | state <id> <state> | done <id> | take <id> [--force]", "a person's changes to long-term tasks; take --force voids other machines' claims"],
128
133
  ]],
@@ -22,7 +22,7 @@ const splitLabel = (label: string): [string, string[]] => { const [command = "un
22
22
  * the checked 256-file / 64 KiB ceiling by at most one bounded row per concurrent writer.
23
23
  * Unpublished temp files count against the ceiling, so a crashed writer cannot grow the spool forever.
24
24
  */
25
- export function recordCliAudit(stateDir: string, peer: string, command: string, outcome: CliAudit["outcome"]): void {
25
+ function reserveCliAudit(stateDir: string, peer: string, command: string, outcome: CliAudit["outcome"]) {
26
26
  const row: CliAudit = { v: 1, id: crypto.randomUUID(), at: Date.now(), peer, command, outcome };
27
27
  if (!valid(row)) throw new Error("invalid CLI audit identifiers");
28
28
  const dir = join(stateDir, "cli-audit");
@@ -36,13 +36,39 @@ export function recordCliAudit(stateDir: string, peer: string, command: string,
36
36
  const text = JSON.stringify(row) + "\n";
37
37
  if (Buffer.byteLength(text) > MAX_ROW_BYTES || files.length >= MAX_FILES || bytes + Buffer.byteLength(text) > MAX_BYTES) throw new Error("CLI audit outbox is full");
38
38
  const pending = join(dir, `${row.id}.tmp`);
39
+ try { writeFileSync(pending, text, { mode: 0o600, flag: "wx" }); }
40
+ catch (error) { try { unlinkSync(pending); } catch { /* only this writer's unpublished file */ } throw error; }
41
+ return {
42
+ id: row.id,
43
+ commit(outcome: CliAudit["outcome"]) {
44
+ if (!valid({ ...row, outcome })) throw new Error("invalid CLI audit identifiers");
45
+ writeFileSync(pending, JSON.stringify({ ...row, outcome }) + "\n", { mode: 0o600 });
46
+ renameSync(pending, join(dir, `${row.id}.json`));
47
+ },
48
+ close() { try { unlinkSync(pending); } catch { /* only this writer's unpublished file */ } },
49
+ };
50
+ }
51
+
52
+ export function recordCliAudit(stateDir: string, peer: string, command: string, outcome: CliAudit["outcome"]): void {
53
+ const pending = reserveCliAudit(stateDir, peer, command, outcome);
54
+ try { pending.commit(outcome); }
55
+ finally { pending.close(); }
56
+ }
57
+
58
+ /** Reserve the bounded audit before connecting; old drains never see an unsupported ready row. */
59
+ export async function recordDelegatedCliAudit(stateDir: string, peer: string, command: string, features: () => Promise<readonly string[]>): Promise<string> {
60
+ let pending: ReturnType<typeof reserveCliAudit>;
61
+ try { pending = reserveCliAudit(stateDir, peer, command, "delegated"); }
62
+ catch { throw new Error("live-test delegation audit unavailable; nothing was executed"); }
39
63
  try {
40
- writeFileSync(pending, text, { mode: 0o600, flag: "wx" });
41
- renameSync(pending, join(dir, `${row.id}.json`));
42
- } catch (error) {
43
- try { unlinkSync(pending); } catch { /* only this writer's unpublished file */ }
44
- throw error;
45
- }
64
+ let supported = false;
65
+ try { supported = (await features()).includes("cli_audit_delegated"); }
66
+ catch { /* unavailable authentication is not proof of an audit consumer */ }
67
+ try { pending.commit(supported ? "delegated" : "refused"); }
68
+ catch { throw new Error("live-test delegation audit unavailable; nothing was executed"); }
69
+ if (!supported) throw new Error("running hub cannot retain delegated CLI audits; upgrade it from a person's terminal, or use the audited upgrade/recovery path; nothing was executed");
70
+ return pending.id;
71
+ } finally { pending.close(); }
46
72
  }
47
73
 
48
74
  /** Consume ready files only; rebuild fixed fields so titles or other injected text cannot be rendered. */
@@ -1,2 +1,2 @@
1
1
  /** Compatibility exports; the project MCP layer is native adapter policy. */
2
- export { JCODE_MCP_FILE, JCODE_MCP_NAME, jcodeMcpEntry, jcodeMcpReady, configureJcodeMcp } from "../adapters/definitions/native-cli-jcode-mcp.ts";
2
+ export { JCODE_MCP_FILE, JCODE_MCP_NAME, jcodeMcpEntry, jcodeMcpReady, configureJcodeMcp, jcodeExtraMcpServers } from "../adapters/definitions/native-cli-jcode-mcp.ts";