@staix/agent-hub 0.12.39 → 0.12.41

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 (81) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +16 -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 +23 -5
  13. package/docs/security.md +20 -8
  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/verification/2026-10-20-0.12.41.md +44 -0
  19. package/docs/verified.json +38 -19
  20. package/package.json +1 -1
  21. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  22. package/plugins/agent-hub/server.js +415 -151
  23. package/src/adapters/acp.ts +34 -1
  24. package/src/adapters/claude-channel.ts +35 -4
  25. package/src/adapters/codex-appserver.ts +12 -3
  26. package/src/adapters/definitions/builtin-metadata.ts +4 -2
  27. package/src/adapters/definitions/native-cli-codex-launch.ts +2 -2
  28. package/src/adapters/definitions/native-cli-doctor.ts +8 -5
  29. package/src/adapters/definitions/native-cli-entry-claude.ts +7 -2
  30. package/src/adapters/definitions/native-cli-entry-codex.ts +13 -14
  31. package/src/adapters/definitions/native-cli-entry-jcode.ts +13 -3
  32. package/src/adapters/definitions/native-cli-entry-pi.ts +9 -7
  33. package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
  34. package/src/adapters/definitions/native-cli-jcode-mcp.ts +55 -1
  35. package/src/adapters/jcode.ts +110 -4
  36. package/src/adapters/local-worker.ts +21 -37
  37. package/src/adapters/pi.ts +7 -0
  38. package/src/adapters/registry-types.ts +2 -0
  39. package/src/cli/console-state.ts +1 -0
  40. package/src/cli/console.ts +11 -1
  41. package/src/cli/help.ts +13 -8
  42. package/src/cli/identity-audit.ts +33 -7
  43. package/src/cli/jcode-mcp.ts +1 -1
  44. package/src/cli/launch.ts +1 -1
  45. package/src/cli/main.ts +134 -24
  46. package/src/cli/output.ts +5 -2
  47. package/src/cli/status-lines.ts +1 -1
  48. package/src/cli/terminal-recovery.ts +54 -4
  49. package/src/cli/upgrade-interactive.ts +5 -0
  50. package/src/cli/upgrade-runtime.ts +13 -1
  51. package/src/cli/upgrade.ts +67 -1
  52. package/src/hub/board.ts +4 -2
  53. package/src/hub/bus.ts +8 -3
  54. package/src/hub/checks.ts +15 -5
  55. package/src/hub/conductor.ts +2 -1
  56. package/src/hub/config-apply.ts +20 -2
  57. package/src/hub/config-reload.ts +2 -8
  58. package/src/hub/control-client.ts +26 -3
  59. package/src/hub/daemon.ts +155 -68
  60. package/src/hub/delivery-superseded.ts +19 -2
  61. package/src/hub/events.ts +4 -1
  62. package/src/hub/hub-tools.ts +4 -4
  63. package/src/hub/inference.ts +4 -8
  64. package/src/hub/lifecycle.ts +3 -3
  65. package/src/hub/main-checkout.ts +36 -0
  66. package/src/hub/memory-profile-check.ts +6 -2
  67. package/src/hub/memory-profile.ts +18 -1
  68. package/src/hub/peers.ts +2 -0
  69. package/src/hub/ports.ts +1 -0
  70. package/src/hub/routing.ts +24 -14
  71. package/src/hub/settings.ts +1 -1
  72. package/src/hub/task-repository.ts +136 -0
  73. package/src/hub/tasks.ts +101 -21
  74. package/src/hub/terminal-launch.ts +130 -0
  75. package/src/hub/usage.ts +44 -18
  76. package/src/models/route/config.ts +1 -1
  77. package/src/omniroute/client.ts +7 -19
  78. package/templates/AGENTS.block.md +1 -0
  79. package/templates/routing.toml +10 -44
  80. package/src/switchyard/config.ts +0 -45
  81. package/src/switchyard/sidecar.ts +0 -136
package/src/hub/daemon.ts CHANGED
@@ -20,7 +20,7 @@ import { buildReviewBrief } from "./review-brief.ts";
20
20
  import { profileName, projectProfile, resolveProfile, mergeProfileOverrides, type ProfileName } from "./profiles.ts";
21
21
  import { catalogAssignments, loadRoleCatalog, selectedRoles, roleIdentity } from "./roles.ts";
22
22
  import { ActivityRing } from "./activity.ts";
23
- import { JcodePeer } from "../adapters/jcode.ts";
23
+ import { JcodeOptionMismatch, JcodePeer } from "../adapters/jcode.ts";
24
24
  import { jcodeMcpReady } from "../cli/jcode-mcp.ts";
25
25
  import { loadSdlc, readSpecEdit, sdlcGh } from "./sdlc.ts";
26
26
  import { claudeStateFile, ownTerminalRow, pathEvidence, transcriptPersisted } from "./claude-state.ts";
@@ -45,7 +45,7 @@ import { childEnv } from "./child-process.ts";
45
45
  import { runCheck } from "./checks.ts";
46
46
  import { DEFAULT_TASK_SWEEP, taskSweepConfig, type TaskSweepConfig } from "./task-sweep.ts";
47
47
  import { ALL_MACHINE_LOCAL, configRepositoryPresent, configTracked, stripUntrusted } from "./config-trust.ts";
48
- import { authorizeConfigLeaf, configApply, configChanges, validateEffectiveConfig } from "./config-apply.ts";
48
+ import { authorizeConfigLeaf, configApply, configChanges, validateEffectiveConfig, validatePetConfig } from "./config-apply.ts";
49
49
  import { resolveSettledRoles } from "./settled-roles.ts";
50
50
  import { CONFIG_SOURCE_FILES, configFingerprint, configSourceTexts, parseConfigSources, type ConfigSources } from "./config-reload.ts";
51
51
  import { eventLog, readEvents, tokenDeltas, type RoleIdentity } from "./events.ts";
@@ -71,7 +71,6 @@ import { reviewerPoolConfig } from "./reviewer-pool-config.ts";
71
71
  import { reviewDiff } from "../adapters/claude-print.ts";
72
72
  import { Capture, skipTools } from "../memory/capture.ts";
73
73
  import { DEFAULT_OMNIROUTE, OmniRoute, type OmniRouteConfig } from "../omniroute/client.ts";
74
- import { Sidecar } from "../switchyard/sidecar.ts";
75
74
  import { Briefs } from "../memory/brief.ts";
76
75
  import { Board, isClosedTaskState, taskProposalAt, taskHasStarted, CLASSES, type Task, type TaskClass } from "./board.ts";
77
76
  import { Budget, claudeWindows, codexWindows, DEFAULT_BUDGET, type BudgetConfig } from "./budget.ts";
@@ -81,7 +80,8 @@ import { closeSync, constants as fsConstants, openSync, lstatSync, readSync, sta
81
80
  import { basename, isAbsolute } from "node:path";
82
81
  import { fileURLToPath } from "node:url";
83
82
  import { realPath, hubHome } from "./project.ts";
84
- import type { BusEvent } from "./bus.ts";
83
+ import { mainCheckoutWarning } from "./main-checkout.ts";
84
+ import type { BusEvent, PublishResult } from "./bus.ts";
85
85
  import { CONDUCTOR_TOOLS, CONDUCTOR_TOOL_NAMES, DEFAULT_ROLES, roleContract, TASK_TOOL_NAMES, TASK_TOOLS, BOUNDED_TASK_TOOLS, profileTaskTools } from "./hub-tools.ts";
86
86
  import { Conductor, ConductorHolds, conductorPeer, conductorProgressSink, publicConductorTask, publicPeerBudget, type ConductEvent } from "./conductor.ts";
87
87
  import { SupervisionFeed } from "./supervision.ts";
@@ -90,7 +90,7 @@ import { launcherPreview } from "../cli/preview.ts";
90
90
  import { AssignmentUndeliveredError, ESCALATE_AFTER, Tasks } from "./tasks.ts";
91
91
  import { DEFAULT_INFERENCE, DIGEST, Inference, screenPii, type InferenceConfig } from "./inference.ts";
92
92
  import { ask, ASK_NOTE_TITLE, RUN_START } from "./ask.ts";
93
- import { currentRouting, detectSignals, OVERLAY_FILE, overlayToml, parseRouting, routingText, type Routing } from "./routing.ts";
93
+ import { currentRouting, loadRouting, detectSignals, OVERLAY_FILE, overlayToml, parseRouting, routingText, type Routing } from "./routing.ts";
94
94
  import { projectSettings, checkSettingValue, ignoredLine, pendingUndo, pendingUndoIdentity, readOverlay, restoredValue, routingCandidate, storedLocally, settingDef as instanceSettingDef, settingRefusal, settingRows, settingText, undoSetting, valueAt, writeConfigSetting, writeRoutingSetting, type SettingAuthority, type SettingDef, type SettingRow, type SettingValue, type UndoIdentity } from "./settings.ts";
95
95
  import { Bus } from "./bus.ts";
96
96
  import { attribute, deliveryTask, type TaskAttribution } from "./attribution.ts";
@@ -113,7 +113,8 @@ import { DEFAULT_LIMITS, Limiter, PROJECT_LIMITS, type LimitsConfig } from "./li
113
113
  import { changedPaths, hasTree, repoOf, snapshot, Turns, type TurnRecord } from "./snapshots.ts";
114
114
  import { archiveRestartSnapshot, readRecoveryWaivers, readRestartSnapshot, removeRestartSnapshot, restartPath, writeRestartSnapshot, type RecoveryPhase, type RestartPeerSnapshot, type RestartSnapshot } from "./restart.ts";
115
115
  import { consumeRecoveryContinuity, readRecoveryContinuity, retireRecoveryContinuity } from "./recovery-continuity.ts";
116
- import { shellQuote } from "../cli/terminal-recovery.ts";
116
+ import { shellQuote, publishTerminalLaunch, terminalLaunchCandidate } from "../cli/terminal-recovery.ts";
117
+ import { TerminalLaunches, terminalOwnerReads } from "./terminal-launch.ts";
117
118
 
118
119
  import { peerStartConfig, PEER_START_RETRY_MS, startModeOf, terminalTemplate, type PeerStartConfig, type StartMode } from "./start-mode.ts";
119
120
  import { hasKimiHubMcp } from "../cli/kimi-mcp.ts";
@@ -201,6 +202,8 @@ export interface HubConfig {
201
202
  coordination: "advisory" | "turn-free";
202
203
  /** Switches for controlled ablations only (issue #110): `stale_notices: "deliver"` turns #106's dropping off. */
203
204
  experiments?: { stale_notices?: "drop" | "deliver" };
205
+ /** Console-local character and window presentation (#280, #483); grants no authority. */
206
+ pet?: false | Record<string, unknown>;
204
207
  /** Machine-local fields a config file set but git could not vouch for, and why (issue #17). */
205
208
  ignored?: string[];
206
209
  /** Settings that were removed or are about to be (issue #83): what each does now, for `hub.log` and `ahub doctor`. */
@@ -254,7 +257,7 @@ const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
254
257
  /** The shared project config, then the machine's own file, which overrides it block by block (issue #17). */
255
258
  const CONFIG_FILES = INSTANCE_CONFIG_FILES;
256
259
  export interface ConfigLayer { name: typeof CONFIG_FILES[number]; raw: Record<string, any> }
257
- const CONFIG_BLOCKS = ["memory", "roster", "roles", "catalog_roles", "pii", "data", "conductor", "budget", "context", "inference", "omniroute", ...NATIVE_CONFIG_BLOCKS, "approvals", "tasks", "task_sweep", "checks", "snapshots", "research", "bench", "limits", "review", "recovery", "capabilities", "mlx", "permission_modes", "peers", "terminal"];
260
+ const CONFIG_BLOCKS = ["memory", "roster", "roles", "catalog_roles", "pii", "data", "conductor", "budget", "context", "inference", "omniroute", ...NATIVE_CONFIG_BLOCKS, "approvals", "tasks", "task_sweep", "checks", "snapshots", "research", "bench", "limits", "review", "recovery", "capabilities", "mlx", "permission_modes", "peers", "terminal", "pet"];
258
261
 
259
262
  /** Connection-time policy refresh reads role/feed fields only, never launches or machine-local configuration. */
260
263
  export function loadConductorPolicy(cwd: string, layers?: readonly ConfigLayer[]): { roles: Record<string, string[]>; conductor: HubConfig["conductor"]; planner?: string; notices?: string[] } {
@@ -309,7 +312,13 @@ function syncMinutes(v: unknown): number {
309
312
  return v;
310
313
  }
311
314
 
312
- export function loadConfig(cwd: string, layers?: readonly ConfigLayer[]): HubConfig {
315
+ /** Routing diagnostics must not make independent config reads depend on valid routing. */
316
+ function retiredRoutingSettings(cwd: string): readonly string[] {
317
+ try { return loadRouting(cwd).retired ?? []; }
318
+ catch { return []; } // Routing admission and reload report parse errors using their own last-good state.
319
+ }
320
+
321
+ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routingRetired: readonly string[] = retiredRoutingSettings(cwd)): HubConfig {
313
322
  const captured = layers ?? readInstanceConfigLayers(cwd);
314
323
  const instanceConfig = readInstanceConfig(cwd, captured);
315
324
  const instances = instanceConfig.instances;
@@ -344,13 +353,15 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[]): HubCon
344
353
  peerStartConfig(file.peers, instances);
345
354
  if (file.terminal != null && (typeof file.terminal !== "object" || Array.isArray(file.terminal))) throw new Error("terminal must be an object");
346
355
  terminalTemplate(file.terminal?.open);
356
+ if (file.pet !== undefined) validatePetConfig(file.pet, "config.pet");
357
+ if (file.pet && typeof file.pet === "object" && !Object.keys(file.pet).length) delete file.pet;
347
358
  delete file.permission_default_sources; // provenance is loader-owned, never a file claim
348
359
  const why = stripUntrusted(file, DEFAULT_CONFIG, cwd, name);
349
360
  if (why) ignored.push(why);
350
361
  for (const [peer, mode] of Object.entries(permissionDefaults(file.permission_modes, instances))) appliedPermissionSources[peer] = { mode, source: `.agenthub/${name}` };
351
362
  return [file];
352
363
  });
353
- if (!files.length) return DEFAULT_CONFIG;
364
+ if (!files.length) return routingRetired.length ? { ...DEFAULT_CONFIG, retired: [...routingRetired] } : DEFAULT_CONFIG;
354
365
  const file = files.reduce((a, b) => ({ ...a, ...b, ...Object.fromEntries(CONFIG_BLOCKS.filter((k) => a[k] !== undefined && b[k] !== undefined).map((k) => [k, { ...a[k], ...b[k] }])) }));
355
366
  const wipLayers = files.filter(layer => layer.tasks?.wip_limits !== undefined);
356
367
  if (wipLayers.length) file.tasks = { ...file.tasks, wip_limits: wipLayers.reduce((prior, layer) => ({ ...prior, ...planningWipLimits(layer.tasks.wip_limits) }), {} as Record<string, number>) };
@@ -361,7 +372,7 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[]): HubCon
361
372
  delete file.retired;
362
373
  // Escape hatches with a stated end (issue #83). A removed one is ignored, never an error: an old config.local.json
363
374
  // must not stop a hub from starting.
364
- const retired: string[] = [];
375
+ const retired: string[] = [...routingRetired];
365
376
  if (file.local?.sandbox !== undefined) {
366
377
  if (file.local.sandbox === "allow-default") retired.push('local.sandbox "allow-default" was removed in 0.12.0: the deny-default sandbox applies (local.read_allow adds paths)');
367
378
  file.local = { ...file.local };
@@ -463,9 +474,6 @@ export interface DaemonOptions {
463
474
  controlPort: number;
464
475
  codexAppPort: number;
465
476
  codexProxyPort: number;
466
- /** 0 disables the Switchyard sidecar. */
467
- switchyardPort?: number;
468
- switchyardBin?: string;
469
477
  config?: HubConfig;
470
478
  /** #269: the terminal providers for a TUI start the hub makes itself, built from `terminal.open`. Absent: none, and such a start is refused. */
471
479
  terminal?: (template: string[]) => TerminalOpener;
@@ -496,6 +504,7 @@ interface Client {
496
504
  authed: boolean;
497
505
  role?: "peer" | "console" | "tools";
498
506
  peer?: PeerId;
507
+ launchId?: string;
499
508
  tail?: () => void;
500
509
  watch?: () => void;
501
510
  /** The one-launch Kimi TUI admission this socket attached under; its close clears only that admission. */
@@ -509,6 +518,10 @@ class WsPeer extends BasePeer {
509
518
  // Checkpoints become stale at hello/claim, before asynchronous recall finishes and attach changes delivery generation.
510
519
  private claimGeneration = crypto.randomUUID();
511
520
  get sessionGeneration(): string { return this.claimGeneration; }
521
+ terminalOwner(launchId: string): { generation: string } | undefined {
522
+ return this.sock?.readyState === WebSocket.OPEN && this.sock.data.launchId === launchId && this.state !== "offline" && !this.claiming
523
+ ? { generation: this.claimGeneration } : undefined;
524
+ }
512
525
  private delivered = new Set<string>();
513
526
  private sock: Sock | undefined;
514
527
  private claimed: Sock | undefined;
@@ -539,6 +552,10 @@ class WsPeer extends BasePeer {
539
552
  get channelHeld(): boolean {
540
553
  return (!!this.sock && !this.pullOnly) || (this.claiming && !this.claimedPullOnly);
541
554
  }
555
+ /** Status-file observations are advisory; a replaced client cannot evict a live socket or pending claim. */
556
+ standbyHeld(pullOnly: boolean): boolean {
557
+ return this.claiming || (!!this.sock && this.sock.readyState === WebSocket.OPEN && (pullOnly || !this.pullOnly));
558
+ }
542
559
  /** A hello that has not finished its preface yet. The peer reads as offline until then, and a session standing by
543
560
  * for the id must not take it from a claimant that is still arriving. */
544
561
  get claiming(): boolean {
@@ -1199,14 +1216,9 @@ export async function startDaemon(opts: DaemonOptions) {
1199
1216
  } finally { for (const listener of held) listener.stop(true); }
1200
1217
  };
1201
1218
  const piCallsInFlight = () => [...piReceiptLedgers.values()].reduce((n, ledger) => n + ledger.inFlight, 0);
1202
- /**
1203
- * PII decisions need a positive answer about the path a hub-owned model call will really take: a gateway that
1204
- * answered and is not behind Access, and no sidecar that was generated against the off-campus URL. Unknown is no.
1205
- */
1206
- const onCampus = async (): Promise<boolean> => (await omni.onCampus()) && !(sidecar?.upstream && omni.isAccessHost(sidecar.upstream));
1207
- let sidecar: Sidecar | undefined; // L2, started by the first hub-owned model call, stopped with the hub
1208
- let sidecarRouting = "";
1209
- inference = new Inference(config.inference, { omni, sidecar: () => sidecar, route: "sy/fast", fixedModel: () => activeRouting().local.fixed_model, log });
1219
+ /** PII needs a positively confirmed campus gateway; unknown never counts as on campus. */
1220
+ const onCampus = (): Promise<boolean> => omni.onCampus();
1221
+ inference = new Inference(config.inference, { omni, fixedModel: () => activeRouting().local.fixed_model, log });
1210
1222
 
1211
1223
  let dashboard: ReturnType<typeof startDashboard> | undefined;
1212
1224
  const uiEvents: { seq: number; event: BusEvent }[] = [];
@@ -1960,6 +1972,24 @@ export async function startDaemon(opts: DaemonOptions) {
1960
1972
  let claudeUsageSeen = 0;
1961
1973
  const claudeContextSeen = new Map<string, string>();
1962
1974
  const contextTailSeen = new Map<PeerId, string>();
1975
+ const retainedCliAudits = new Set<string>();
1976
+ function drainCliAuditsNow(): void {
1977
+ for (const audit of drainCliAudits(opts.stateDir)) {
1978
+ const delegated = audit.outcome === "delegated";
1979
+ const refused = audit.outcome !== "run" && !delegated;
1980
+ if (!event({ type: "agent_cli", peer: audit.peer, command: audit.command, refused, ...(delegated ? { delegated: true } : {}) })) {
1981
+ throw new Error("CLI audit event was not retained");
1982
+ }
1983
+ retainedCliAudits.add(audit.id); // Only after the event append succeeded.
1984
+ // ponytail: 4096 live-instance acknowledgements; eviction or restart fails closed. Durable keyed receipts can extend this window.
1985
+ if (retainedCliAudits.size > 4096) retainedCliAudits.delete(retainedCliAudits.values().next().value!);
1986
+ try {
1987
+ notify(refused ? `${audit.peer} refused ahub ${audit.command}` : delegated
1988
+ ? `${audit.peer} ran ahub ${audit.command} with delegated live-test console authority`
1989
+ : `${audit.peer} ran ahub ${audit.command} as ${audit.peer}`);
1990
+ } catch { /* The audit event is retained even if its advisory notice cannot be delivered. */ }
1991
+ }
1992
+ }
1963
1993
  const intervals = [
1964
1994
  setInterval(() => {
1965
1995
  void refreshRunner().catch(() => {});
@@ -1970,14 +2000,7 @@ export async function startDaemon(opts: DaemonOptions) {
1970
2000
  feedBudgetPauses();
1971
2001
  supervision.checkRound();
1972
2002
  try {
1973
- for (const audit of drainCliAudits(opts.stateDir)) {
1974
- const delegated = audit.outcome === "delegated";
1975
- const refused = audit.outcome !== "run" && !delegated;
1976
- event({ type: "agent_cli", peer: audit.peer, command: audit.command, refused, ...(delegated ? { delegated: true } : {}) });
1977
- notify(refused ? `${audit.peer} refused ahub ${audit.command}` : delegated
1978
- ? `${audit.peer} ran ahub ${audit.command} with delegated live-test console authority`
1979
- : `${audit.peer} ran ahub ${audit.command} as ${audit.peer}`);
1980
- }
2003
+ drainCliAuditsNow();
1981
2004
  } catch { log("CLI audit outbox unavailable; inspect state before retrying an agent command"); }
1982
2005
  for (const peer of bus.knownPeers()) {
1983
2006
  contexts.retry(peer, contextSession(peer));
@@ -2121,7 +2144,7 @@ export async function startDaemon(opts: DaemonOptions) {
2121
2144
  if (piiTurn && (MEMORY_READ_TOOLS.has(op) || op === "hub_memory_correct" || op === "hub_remember" || op === "hub_task_propose" || op === "hub_sdlc_record" || op === "hub_plan_review" || op === "hub_sdlc_costs" || op.startsWith("hub_release_") || op === "hub_backlog_return_untouched" || op === "hub_backlog_plan" || op === "hub_backlog_undo" || op === "hub_backlog_pull" || op === "hub_backlog_split" || op === "release_new")) throw new PreEffectToolRefusal(`${op} is not available while working on a PII task: its text must not leave this machine`);
2122
2145
  // The same words attached to an ordinary task would reach its reviewer, its owner, overlapping owners (a plan, the
2123
2146
  // completed-change notice) and claude-mem. A turn that holds a PII task acts on ordinary tasks in a turn of its own.
2124
- if (piiTurn && (op === "hub_task_done" || op === "hub_review" || (op === "hub_task_accept" && a.plan != null))) {
2147
+ if (piiTurn && (op === "hub_task_done" || op === "hub_review" || (op === "hub_task_accept" && (a.plan != null || a.refs != null)))) {
2125
2148
  const target = board.get(Number(a.id));
2126
2149
  if (target && !tasks.isPii(target)) throw new PreEffectToolRefusal(`${op} on task #${target.id} is not available while working on a PII task: its text would reach other peers; do it in a turn without the PII task`);
2127
2150
  }
@@ -2275,10 +2298,10 @@ export async function startDaemon(opts: DaemonOptions) {
2275
2298
  }
2276
2299
  case "hub_task_accept": {
2277
2300
  // S1 spec acceptance and S9 plan review are checked against one task snapshot before acceptance.
2278
- const t = await tasks.acceptReviewed(by, a.id, a.plan);
2301
+ const t = await tasks.acceptReviewed(by, a.id, a.plan, a.refs);
2279
2302
  // A silent cohort (issue #107): the owners' plans come with every accept, as what the newcomer works from.
2280
2303
  const silent = tasks.silentFor(t.id);
2281
- const overlap = a.plan == null && !silent ? "" : tasks.overlaps(t);
2304
+ const overlap = a.plan == null && a.refs == null && !silent ? "" : tasks.overlaps(t);
2282
2305
  if (silent && factsOn()) factsFor(by).sawPlans(by, tasks.overlapTasks(t)); // the plans in this answer need no fact later
2283
2306
  return [line(t), overlap, tasks.planGuardText(t)].filter(Boolean).join("\n");
2284
2307
  }
@@ -2291,7 +2314,7 @@ export async function startDaemon(opts: DaemonOptions) {
2291
2314
  return JSON.stringify(tasks.publicView(await tasks.cancel(a.id, by, { reason: a.reason, supersededBy: a.superseded_by, expectedFlowRevision: a.revision })));
2292
2315
  }
2293
2316
  case "hub_task_decline":
2294
- return line(await tasks.decline(by, a.id, a.reason));
2317
+ return line(await tasks.decline(by, a.id, a.reason, a.capacity === true));
2295
2318
  case "hub_task_done": {
2296
2319
  const t = await tasks.done(by, a.id, a.summary, a.refs);
2297
2320
  const completion = t.history.findLast(h => h.by === by && (h.event === "done" || h.event === "done (checking)"));
@@ -2301,7 +2324,7 @@ export async function startDaemon(opts: DaemonOptions) {
2301
2324
  // A silent cohort (issue #107): the member that integrates is asked to check its work first; no done yet.
2302
2325
  const last = t.history.at(-1);
2303
2326
  if (last?.event === "integration requested" && last.by === HUB) return last.note ?? "";
2304
- const extra = tasks.takeDoneNote(t.id); // the notices a silence held, when no integration step ran
2327
+ const extra = [tasks.takeDoneNote(t.id), tasks.checkoutNote()].filter(Boolean).join("\n"); // held silence notices, then the #572 checkout advisory
2305
2328
  const result = tasks.isChecking(t.id) ? `${line(t)}; its check is queued or running, and the result comes as a task message` : line(t);
2306
2329
  return extra ? `${result}\n${extra}` : result;
2307
2330
  }
@@ -2475,11 +2498,41 @@ export async function startDaemon(opts: DaemonOptions) {
2475
2498
  const claudePermissionGenerations = new Map<string, string>();
2476
2499
  const permissions = new Map<string, { push: string; peer: string; tool?: string; createdAt: number; expiresAt: number; done: (optionId: string | undefined, surface?: "console" | "dashboard" | "terminal", reason?: "expired" | "cancelled") => boolean }>();
2477
2500
  let stopping = false;
2501
+ const terminalReads = terminalOwnerReads();
2502
+ const terminalLaunches = new TerminalLaunches({
2503
+ owner: row => {
2504
+ const owner = bus.peers.get(row.peer), proof = owner?.terminalOwner?.(row.launchId);
2505
+ if (!proof) return undefined;
2506
+ return { ...proof, isCurrent: () => {
2507
+ if (stopping || !membershipAllowed(row.peer) || bus.peers.get(row.peer) !== owner) return false;
2508
+ const current = owner?.terminalOwner?.(row.launchId);
2509
+ return current?.generation === proof.generation && current?.nativePid === proof.nativePid;
2510
+ } };
2511
+ },
2512
+ publish: (row, current) => publishTerminalLaunch(opts.stateDir, row, current, { runner: terminalReads.run }),
2513
+ log,
2514
+ });
2515
+ const terminalLaunchTimer = setInterval(() => { void terminalLaunches.reconcile(); }, 1_000);
2516
+ terminalLaunchTimer.unref();
2517
+ intervals.push(terminalLaunchTimer);
2518
+ startupCleanup.push(() => terminalLaunches.clear());
2478
2519
 
2479
2520
  const pausedNote = (id: PeerId) => {
2480
2521
  const r = budget.record(id); // one read per peer: status.json is rewritten on every bus event
2481
2522
  return r ? { paused: `budget: ${r.reason}, resets ${new Date(r.resetsAt).toLocaleTimeString()}` } : manualPaused.has(id) && bus.stateOf(id) === "offline" ? { paused: "manual" } : {};
2482
2523
  };
2524
+ /**
2525
+ * #572 AC1: an ACP peer's turn cannot take a message mid-turn unless its adapter has a `steer` (jcode's does; headless Kimi
2526
+ * and Qwen do not). The hub never cancels a turn to deliver, so an `important` message waits in the queue; once it has
2527
+ * waited past `budget.stale_min` the status says so, and the person decides. Read-only.
2528
+ */
2529
+ const importantWaitingStatus = (id: PeerId, peer: PeerAdapter | undefined): Record<string, unknown> => {
2530
+ if (!(peer instanceof AcpPeer) || (peer as PeerAdapter).steer || bus.stateOf(id) !== "busy") return {};
2531
+ const since = bus.oldestQueuedImportantAt(id);
2532
+ if (since === undefined || Date.now() - since < config.budget.stale_min * 60_000) return {};
2533
+ const count = bus.queuedImportant(id), minutes = Math.floor((Date.now() - since) / 60_000);
2534
+ return { importantWaitingSince: since, importantWaitingNote: `${count} important message${count === 1 ? "" : "s"} waiting on a busy ACP peer for ${minutes} min; its turn is never cancelled to deliver, so decide whether to stop it` };
2535
+ };
2483
2536
  /** Why a paused peer gets nothing; the bus pause itself is the hold. */
2484
2537
  const pauseReason = (id: PeerId) => {
2485
2538
  const r = budget.record(id), hold = conductorHolds.get(id);
@@ -2693,6 +2746,7 @@ export async function startDaemon(opts: DaemonOptions) {
2693
2746
  pluginBundle: SERVER_JS,
2694
2747
  pendingRestart,
2695
2748
  retainedConfig,
2749
+ ...(() => { const warning = mainCheckoutWarning(opts.cwd, tasks.publicMetadataText); return warning ? { mainCheckout: warning } : {}; })(),
2696
2750
  startModes: Object.fromEntries(START_MODE_PEERS.map(peer => [peer, startModeOf(config.peers, peer, declarations)])),
2697
2751
  memoryProfile: memoryProfileStatus(includeAttentionItems),
2698
2752
  lifecycleStages: [...lifecycleConfig.stages],
@@ -2711,14 +2765,13 @@ export async function startDaemon(opts: DaemonOptions) {
2711
2765
  ...(dashboard ? { uiOrigin: dashboard.origin } : {}),
2712
2766
  codexProxyPort: opts.codexProxyPort,
2713
2767
  peers: Object.fromEntries(
2714
- peerIds.map((id) => { const p = bus.peers.get(id); const summary = bus.queueSummary(id); const context = contexts.view(id, contextSession(id), !!p && ["idle", "busy", "paused"].includes(bus.stateOf(id))); return [id, { state: bus.stateOf(id), ...(!membershipAllowed(id) ? { rostered: false, rosterNote: "off roster" } : {}), ...permissionStatus(id, p), ...(context.source !== null || context.measuredAt !== null ? { context } : {}), queued: bus.queued(id), ...(p ? {} : { attached: false }), ...(summary.needsReview ? { needsReview: summary.needsReview } : {}), ...(summary.liveAccepted ? { liveAccepted: summary.liveAccepted, settlementNote: "awaiting adapter completion (Claude: correlated reply or hub_delivery_done); task state is independent" } : {}), ...queueHoldStatus(id, summary.heldBy), ...(summary.oldestQueuedAt !== undefined ? { oldestQueuedAt: summary.oldestQueuedAt } : {}), ...(bus.queuedImportant(id) ? { queuedImportant: bus.queuedImportant(id) } : {}), ...pausedNote(id), ...(p instanceof LocalPeer && p.lastServedBy ? { servedBy: p.lastServedBy } : {}), ...(p instanceof WsPeer && p.claiming ? { claiming: true } : {}), ...(p instanceof WsPeer && p.pullOnly ? { pullOnly: true, toolsOnly: definitionOf(id)?.attach?.pullOnlyNotice ?? "tools-only: messages wait for hub_inbox; for pushes restart Claude with ahub claude", lastInboxAt: p.lastInboxAt ?? null } : {}), ...(p instanceof PiPeer ? { requestedModel: p.getRequestedModel(), backends: modelRelays.get(id)?.status().backends ?? [] } : {}) }]; }),
2768
+ peerIds.map((id) => { const p = bus.peers.get(id); const summary = bus.queueSummary(id); const context = contexts.view(id, contextSession(id), !!p && ["idle", "busy", "paused"].includes(bus.stateOf(id))); return [id, { state: bus.stateOf(id), ...(!membershipAllowed(id) ? { rostered: false, rosterNote: "off roster" } : {}), ...permissionStatus(id, p), ...(context.source !== null || context.measuredAt !== null ? { context } : {}), queued: bus.queued(id), ...(p ? {} : { attached: false }), ...(summary.needsReview ? { needsReview: summary.needsReview } : {}), ...(summary.liveAccepted ? { liveAccepted: summary.liveAccepted, settlementNote: "awaiting adapter completion (Claude: correlated reply or hub_delivery_done); task state is independent" } : {}), ...queueHoldStatus(id, summary.heldBy), ...(summary.oldestQueuedAt !== undefined ? { oldestQueuedAt: summary.oldestQueuedAt } : {}), ...(bus.queuedImportant(id) ? { queuedImportant: bus.queuedImportant(id) } : {}), ...importantWaitingStatus(id, p), ...pausedNote(id), ...(p instanceof LocalPeer && p.lastServedBy ? { servedBy: p.lastServedBy } : {}), ...(p instanceof WsPeer && p.claiming ? { claiming: true } : {}), ...(p instanceof WsPeer && p.pullOnly ? { pullOnly: true, toolsOnly: definitionOf(id)?.attach?.pullOnlyNotice ?? "tools-only: messages wait for hub_inbox; for pushes restart Claude with ahub claude", lastInboxAt: p.lastInboxAt ?? null } : {}), ...(p instanceof PiPeer ? { requestedModel: p.getRequestedModel(), backends: modelRelays.get(id)?.status().backends ?? [] } : {}) }]; }),
2715
2769
  ),
2716
2770
  taskWip: Object.fromEntries(peerIds.map(id => [id, { wip: tasks.wip(id), wipLimit: tasks.wipLimit(id) }])),
2717
2771
  boardReadCapabilities: { releaseFilter: true, columnMetadata: true },
2718
2772
  roleInfo: Object.fromEntries(peerIds.map(id => [id, { flowRoles: config.roles[id] ?? [], catalogRoles: roleState(id), ...(config.roles[id]?.includes("planner") ? { planner: plannerSource } : {}) }])),
2719
2773
  planner: { peers: plannerPeers(), source: plannerSource },
2720
2774
  ...(pendingPermissionDefaults.size ? { permissionDefaults: [...pendingPermissionDefaults].map(([peer, source]) => ({ peer, mode: "never-ask", source })) } : {}),
2721
- ...(sidecar ? { switchyard: sidecar.status } : {}),
2722
2775
  ...(modelRelay ? { models: modelRelay.status() } : {}),
2723
2776
  isolatedChecks: { pending: checkPending.size, orphans: orphanChecks, unknownReceipts: checkRunner.unknownReceipts ?? [], maintenanceUnavailable: checkRunner.maintenanceUnavailable === true },
2724
2777
  taskCapabilities: peerCapabilities(),
@@ -3322,7 +3375,9 @@ export async function startDaemon(opts: DaemonOptions) {
3322
3375
  if (owner instanceof PiPeer && owner.state !== "offline" && owner.mode === "tui") return { ok: false, error: "Pi runs in a terminal; end it there before starting a headless one", command: "ahub pi --headless" };
3323
3376
  return startPeer(peer, definitionOf(peer)?.start.explicitHeadlessMode ? { mode: "headless", ...(resume ?? {}) } : {});
3324
3377
  }
3325
- if (owner && owner.state !== "offline" && definitionOf(peer)?.start.daemonPort !== "owned-acp") return { ok: true, already: true, mode };
3378
+ // #559 N1: only the console's own start reopens a frontend on an attached owned-ACP peer. The conductor and the
3379
+ // dashboard answer `already`, so a repeated call never adds another TUI writer to the live session.
3380
+ if (owner && owner.state !== "offline" && !(definitionOf(peer)?.start.daemonPort === "owned-acp" && by === "the console")) return { ok: true, already: true, mode };
3326
3381
  const words = [...startWords(peer, mode)];
3327
3382
  if (resume) words.push(...(resume.sessionFile ? ["--session-file", resume.sessionFile] : ["--session-id", resume.sessionId]), ...(resume.backend ? ["--backend", resume.backend] : []), ...(resume.model ? ["--model", resume.model] : []));
3328
3383
  const command = `ahub ${resume ? words.map(shellQuote).join(" ") : words.join(" ")}`;
@@ -3712,8 +3767,10 @@ export async function startDaemon(opts: DaemonOptions) {
3712
3767
  permit: async () => false, // Pi's calls ask through piPermit in executeTool, which knows the tool's name
3713
3768
  send: (text, to) => {
3714
3769
  if (to?.some((id) => !bus.peers.has(id) && id !== USER)) return "error: unknown peer";
3715
- const refused = pi.onMessage?.(text, { inReplyTo: piReply, ...(to?.length ? { to } : {}) });
3716
- return typeof refused === "string" ? `not sent: ${refused}` : "sent";
3770
+ const result = pi.onMessage?.(text, { inReplyTo: piReply, ...(to?.length ? { to } : {}) }) as PublishResult | string | undefined;
3771
+ if (typeof result === "string") return `not sent: ${result}`;
3772
+ if (result?.dropped === "hop") return `not delivered: reply chain reached the hop cap (${MAX_HOP}); send a new message without reply_to`;
3773
+ return "sent";
3717
3774
  },
3718
3775
  };
3719
3776
  const routing = activeRouting();
@@ -3834,9 +3891,9 @@ export async function startDaemon(opts: DaemonOptions) {
3834
3891
  }
3835
3892
  if (adapter?.start.daemonPort === "local-worker") {
3836
3893
  const routing = activeRouting();
3837
- // `--model` pins a model on OmniRoute and skips L2; `--route` picks another Switchyard route.
3894
+ // `--model` pins a gateway alias; `--route` selects an in-process policy or a legacy fixed fallback.
3838
3895
  const route = args.model ? undefined : (args.route ?? routing.local.route);
3839
- if (route && !(route.startsWith("hub/") ? routing.hub_routes?.[route] : routing.routes[route])) return { ok: false, ...(existing && existing.state !== "offline" ? { already: true } : {}), error: `routing.toml has no route "${route}"` };
3896
+ if (route && route.startsWith("hub/") && !routing.hub_routes?.[route]) return { ok: false, ...(existing && existing.state !== "offline" ? { already: true } : {}), error: `routing.toml has no route "${route}"` };
3840
3897
  const fixedModel = args.model ?? routing.local.fixed_model;
3841
3898
  // A manually paused recovery roster must be reconstructible while its gateway is unavailable.
3842
3899
  // It stays held; holdPeer validates the restored choice before an operator can resume it.
@@ -3845,16 +3902,6 @@ export async function startDaemon(opts: DaemonOptions) {
3845
3902
  // Validation can await network: a queued delivery may have started meanwhile.
3846
3903
  if (existing?.state === "busy") return { ok: false, error: "local is busy; retry when it is idle" };
3847
3904
  if (existing) { mute(existing); await existing.stop(); }
3848
- // The sidecar serves the routes it was generated from: a changed routing.toml needs a new one.
3849
- const routingKey = JSON.stringify([routing.targets, routing.routes]);
3850
- if (sidecar && routingKey !== sidecarRouting) {
3851
- await sidecar.stop();
3852
- sidecar = undefined;
3853
- }
3854
- if (route && !route.startsWith("hub/") && opts.switchyardPort) {
3855
- sidecarRouting = routingKey;
3856
- sidecar ??= new Sidecar({ routing, omni, stateDir: opts.stateDir, port: opts.switchyardPort, log, ...(opts.switchyardBin ? { bin: opts.switchyardBin } : {}) });
3857
- }
3858
3905
  const capture = config.memory.enabled
3859
3906
  ? new Capture(memory, { project: chain.at(-1)!, cwd: executionCwd, skip: skipTools(), deny: config.local.deny })
3860
3907
  : undefined;
@@ -3879,7 +3926,6 @@ export async function startDaemon(opts: DaemonOptions) {
3879
3926
  onUsage: record => event({ type: "usage", peer, source: "omniroute", id: record.id, ...record.usage, ...taskAttribution(peer, record.task), requestedModel: record.requestedModel, ...(record.servedModel ? { servedModel: record.servedModel } : {}), ...(record.provider ? { provider: record.provider } : {}), measuredAt: record.at }),
3880
3927
  admitBudget: async (envs, unit) => tasks.admitExecutionEnvelopes(envs, peer, unit),
3881
3928
  ...(route ? { route } : {}),
3882
- ...(sidecar && route && !route.startsWith("hub/") ? { sidecar } : {}),
3883
3929
  hubRoutes: () => activeRouting().hub_routes ?? {},
3884
3930
  staySwitch: () => activeRouting(),
3885
3931
  onRoute: record => event({ type: "route", peer, ...record }),
@@ -3977,19 +4023,14 @@ export async function startDaemon(opts: DaemonOptions) {
3977
4023
 
3978
4024
  async function validateLocalChoice(route: string | undefined, fixedModel: string): Promise<void> {
3979
4025
  const routing = activeRouting();
3980
- if (route && !(route.startsWith("hub/") ? routing.hub_routes?.[route] : routing.routes[route])) throw new Error(`routing.toml has no route "${route}"`);
4026
+ if (route && route.startsWith("hub/") && !routing.hub_routes?.[route]) throw new Error(`routing.toml has no route "${route}"`);
3981
4027
  const models = await omni.models();
3982
4028
  const required = new Set([fixedModel]);
3983
- const visit = (value: unknown): void => {
3984
- if (typeof value === "string" && routing.targets[value]) required.add(routing.targets[value]!.id);
3985
- else if (Array.isArray(value)) value.forEach(visit);
3986
- else if (value && typeof value === "object") Object.values(value).forEach(visit);
3987
- };
3988
4029
  if (route?.startsWith("hub/")) {
3989
4030
  const policy = routing.hub_routes![route]!;
3990
4031
  required.add(policy.efficient ?? "fast"); required.add(policy.capable ?? "coding");
3991
4032
  if (policy.judge) required.add(policy.judge);
3992
- } else if (route) visit(routing.routes[route]);
4033
+ }
3993
4034
  for (const model of required) await omni.checkModel(model, models);
3994
4035
  }
3995
4036
 
@@ -4201,12 +4242,18 @@ export async function startDaemon(opts: DaemonOptions) {
4201
4242
  const parts = key.split(".");
4202
4243
  if (parts.some(part => ["__proto__", "constructor", "prototype"].includes(part))) throw new Error("unsafe configuration path");
4203
4244
  let node = target as Record<string, unknown>;
4245
+ const trail: [Record<string, unknown>, string][] = [];
4204
4246
  for (const part of parts.slice(0, -1)) {
4205
4247
  if (!Object.hasOwn(node, part) || !node[part] || typeof node[part] !== "object" || Array.isArray(node[part])) node[part] = {};
4248
+ trail.push([node, part]);
4206
4249
  node = node[part] as Record<string, unknown>;
4207
4250
  }
4208
- if (value === undefined) delete node[parts.at(-1)!];
4209
- else node[parts.at(-1)!] = structuredClone(value);
4251
+ if (value === undefined) {
4252
+ delete node[parts.at(-1)!];
4253
+ for (const [parent, part] of trail.reverse()) {
4254
+ if (!Object.hasOwn(DEFAULT_CONFIG, part) && !Object.keys(parent[part] as object).length) delete parent[part];
4255
+ }
4256
+ } else node[parts.at(-1)!] = structuredClone(value);
4210
4257
  }
4211
4258
  function changedRawPaths(before: unknown, after: unknown, prefix = ""): string[] {
4212
4259
  const object = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
@@ -4217,8 +4264,9 @@ export async function startDaemon(opts: DaemonOptions) {
4217
4264
  return JSON.stringify(before) === JSON.stringify(after) ? [] : [prefix];
4218
4265
  }
4219
4266
  function declaredPaths(value: unknown, prefix = ""): string[] {
4220
- // Switchyard dictionaries have opaque keys (including dots); publish each dictionary as one value.
4221
- if (["routing.targets", "routing.routes", "routing.hub_routes"].includes(prefix)) return [prefix];
4267
+ // Retired dictionaries never enter active publication; hub ids remain opaque keys.
4268
+ if (prefix === "routing.targets" || prefix === "routing.routes") return [];
4269
+ if (prefix === "routing.hub_routes") return [prefix];
4222
4270
  if (value && typeof value === "object" && !Array.isArray(value)) return Object.entries(value).flatMap(([key, child]) => declaredPaths(child, prefix ? `${prefix}.${key}` : key));
4223
4271
  return prefix ? [prefix] : [];
4224
4272
  }
@@ -4230,7 +4278,7 @@ export async function startDaemon(opts: DaemonOptions) {
4230
4278
  return { planners: settled.planners, conductors: Object.keys(candidate.roles).filter(peer => candidate.roles[peer]!.includes("conductor")).sort() };
4231
4279
  }
4232
4280
  function prepareConfiguration(sources: ConfigSources, authority: SettingAuthority, scope?: string, confirmed: readonly string[] = []) {
4233
- const fileConfig = loadConfig(opts.cwd, sources.layers);
4281
+ const fileConfig = loadConfig(opts.cwd, sources.layers, sources.routing.retired ?? []);
4234
4282
  validateEffectiveConfig(fileConfig);
4235
4283
  const candidate = structuredClone(desiredConfig), routing = structuredClone(desiredRouting);
4236
4284
  const accepted: string[] = [], rejected: { key: string; reason: string }[] = [], guarded: string[] = [];
@@ -4320,9 +4368,12 @@ export async function startDaemon(opts: DaemonOptions) {
4320
4368
  for (const item of rejected) if (guarded.includes(item.key)) item.reason += "; combined confirmation preview exceeds its 128 KiB bound; split this edit";
4321
4369
  guarded.length = 0; previews.length = 0;
4322
4370
  }
4371
+ candidate.retired = fileConfig.retired;
4372
+ routing.retired = sources.routing.retired;
4323
4373
  const changes = configChanges({ ...config, roles: policyRoles }, candidate);
4324
4374
  const running = structuredClone({ ...config, roles: policyRoles });
4325
4375
  for (const change of changes) if (change.applies !== "restart") putConfig(running, change.key, change.after);
4376
+ running.retired = fileConfig.retired;
4326
4377
  validateEffectiveConfig(running);
4327
4378
  conductorPeer(running.roles);
4328
4379
  for (const peer of Object.keys(running.catalog_roles ?? {})) selectedRoles(peer, running.roles, { ...resolvedProfile.catalog_roles, ...catalogAssignments(running.catalog_roles) }, catalog);
@@ -4383,7 +4434,7 @@ export async function startDaemon(opts: DaemonOptions) {
4383
4434
  if (confirm !== undefined && prepared.rejected.some(item => reloadConfirmation!.keys.includes(item.key))) return { ok: false, error: "configuration trust changed; run ahub settings reload again" };
4384
4435
  if (scope && prepared.rejected.some(item => item.key === scope)) return { ok: false, error: prepared.rejected.find(item => item.key === scope)!.reason };
4385
4436
  const { changes, candidate } = prepared;
4386
- const routingChanges = changedRawPaths({ ...runningRouting, sources: undefined }, { ...prepared.routing, sources: undefined }, "routing");
4437
+ const routingChanges = changedRawPaths({ ...runningRouting, sources: undefined, retired: undefined }, { ...prepared.routing, sources: undefined, retired: undefined }, "routing");
4387
4438
  if (prepared.running.snapshots.enabled && repo && !turnLog) turnLog = new Turns(join(opts.stateDir, "hub.db"));
4388
4439
  const priorDesired = JSON.stringify([desiredConfig, desiredRouting, acceptedSources.trustLayers]);
4389
4440
  const rolesChanged = changes.some(change => change.key === "roles" || change.key.startsWith("roles.") || change.key === "planner");
@@ -4397,6 +4448,9 @@ export async function startDaemon(opts: DaemonOptions) {
4397
4448
  for (const change of changes.filter(change => change.key === "permission_modes" || change.key.startsWith("permission_modes."))) {
4398
4449
  for (const peer of change.key === "permission_modes" ? SUPPORTED_PEERS : [change.key.split(".")[1]!]) changedPermissionDefaults.add(peer);
4399
4450
  }
4451
+ const priorRetired = new Set(config.retired ?? []);
4452
+ config.retired = prepared.running.retired;
4453
+ for (const line of config.retired ?? []) if (!priorRetired.has(line)) log(`config: ${line}`);
4400
4454
  config.permission_default_sources = candidate.permission_default_sources;
4401
4455
  for (const peer of changedPermissionDefaults) applyPendingPermissionDefault(peer);
4402
4456
  if (changes.some(change => change.key === "task_sweep" || change.key.startsWith("task_sweep."))) rescheduleSweep();
@@ -4572,7 +4626,7 @@ export async function startDaemon(opts: DaemonOptions) {
4572
4626
  const def = settingDef(key);
4573
4627
  if (typeof def === "string") return { error: def };
4574
4628
  const routing = activeRouting();
4575
- try { return { def, value: checkSettingValue(def, raw, def.type === "roles" ? compatibleRoles(def.path[1]!) : [...Object.keys(routing.routes), ...Object.keys(routing.hub_routes ?? {})]) }; }
4629
+ try { return { def, value: checkSettingValue(def, raw, def.type === "roles" ? compatibleRoles(def.path[1]!) : Object.keys(routing.hub_routes ?? {})) }; }
4576
4630
  catch (error) { return { error: (error as Error).message }; }
4577
4631
  }
4578
4632
  const settingReason = (reason: unknown): string | undefined => {
@@ -5088,11 +5142,21 @@ export async function startDaemon(opts: DaemonOptions) {
5088
5142
  if (!PEER_ID.test(c.peer) || (RESERVED_IDS.has(c.peer) && !kimiPull)) return sock.close(4403, definitionOf(c.peer)?.attach?.admission?.reservedRefusal ?? "peer id is reserved or malformed");
5089
5143
  if (!membershipAllowed(c.peer)) return sock.close(4000, "peer is off roster; retry when a person adds it");
5090
5144
  if (!recoveryPeerAllowed(c.peer)) return sock.close(4403, "peer id is not part of the recovery roster");
5145
+ if (definitionOf(c.peer)?.transport === "claude-channel" && msg.launchId !== undefined) {
5146
+ let currentLaunch: unknown;
5147
+ try {
5148
+ const launch = JSON.parse(readFileSync(join(opts.stateDir, claudeStateFile("claude-launch", c.peer)), "utf8"));
5149
+ if (launch.instanceId === instanceId && typeof launch.launchId === "string" && launch.launchId) currentLaunch = launch.launchId;
5150
+ } catch { /* No current launch evidence: retain plain/pre-daemon attachment compatibility. */ }
5151
+ if (currentLaunch !== undefined && msg.launchId !== currentLaunch) return sock.close(4430, "Claude launch ended; start a new session with ahub claude");
5152
+ if (typeof msg.launchId === "string" && msg.launchId.length <= 128) c.launchId = msg.launchId;
5153
+ }
5091
5154
  let peer = bus.peers.get(c.peer);
5092
5155
  if (kimiPull && peer instanceof AcpPeer && peer.state === "offline") peer = undefined;
5093
5156
  if (!peer) bus.add((peer = new WsPeer(c.peer)));
5094
5157
  if (!(peer instanceof WsPeer)) return sock.close(4409, "peer id is taken by a hub-managed adapter");
5095
5158
  const ws = peer;
5159
+ if (msg.standby === true && ws.standbyHeld(msg.channel === false)) return sock.close(4000, "another session holds or is claiming this peer");
5096
5160
  // Without pushes it stands by, as a replaced session does, and never strands a channel session's deliveries.
5097
5161
  if (msg.channel === false && ws.channelHeld) return sock.close(4000, "a session with channel pushes holds this peer");
5098
5162
  preparedSessionRoles.delete(c.peer); // an external transport has no receipt for this daemon's native preamble
@@ -5542,6 +5606,13 @@ export async function startDaemon(opts: DaemonOptions) {
5542
5606
  }
5543
5607
  case "status":
5544
5608
  return void reply({ t: "status", status: status(c.role === "console") });
5609
+ case "cli_audit_ack": {
5610
+ if (c.role !== "console") return void reply({ ok: false, error: "cli_audit_ack is a console command" });
5611
+ if (typeof msg.auditId !== "string" || !/^[a-f0-9-]{36}$/.test(msg.auditId)) return void reply({ ok: false, error: "invalid audit id" });
5612
+ try { drainCliAuditsNow(); }
5613
+ catch { return void reply({ ok: false, error: "CLI audit was not retained" }); }
5614
+ return void reply({ ok: retainedCliAudits.has(msg.auditId), auditId: msg.auditId, instanceId });
5615
+ }
5545
5616
  case "ui_snapshot":
5546
5617
  if (c.role !== "console") return void reply({ ok: false, error: "ui_snapshot is a console command" });
5547
5618
  if (!Number.isSafeInteger(msg.after ?? 0) || (msg.after ?? 0) < 0) return void reply({ ok: false, error: "invalid cursor" });
@@ -5579,6 +5650,14 @@ export async function startDaemon(opts: DaemonOptions) {
5579
5650
  if (typeof msg.admissionId === "string" && msg.admissionId === kimiAdmissionId) clearKimiAdmission();
5580
5651
  return void reply({ ok: true });
5581
5652
  }
5653
+ case "terminal_launch": {
5654
+ if (c.role !== "console") return void reply({ ok: false, error: "terminal launch registration is console only" });
5655
+ if (stopping || recoveryActive() && !(recoveryPhase === "restored" && msg.operationId === recoveryOperationId)) return void reply({ ok: false, error: "recovery is holding terminal launches" });
5656
+ const row = terminalLaunchCandidate(msg.launch, opts.cwd, opts.stateDir, instanceId);
5657
+ if (!row || !membershipAllowed(row.peer) || !definitionOf(row.peer)?.session.launchRecord || recoveryActive() && !recoveryPeerAllowed(row.peer)) return void reply({ ok: false, error: "terminal launch identity unavailable" });
5658
+ try { terminalLaunches.register(row); return void reply({ ok: true }); }
5659
+ catch { return void reply({ ok: false, error: "terminal launch could not be registered; managed recovery is unavailable" }); }
5660
+ }
5582
5661
  case "native_tui_launch": {
5583
5662
  if (c.role !== "console") return void reply({ ok: false, error: "native TUI launch is console only" });
5584
5663
  const peer = String(msg.peer), owner = bus.peers.get(peer);
@@ -5589,7 +5668,10 @@ export async function startDaemon(opts: DaemonOptions) {
5589
5668
  const options = msg.args ?? {};
5590
5669
  for (const key of ["model", "providerProfile", "sessionId"]) if (options[key] !== undefined && typeof options[key] !== "string") throw new Error("invalid option");
5591
5670
  return void reply({ ok: true, ...owner.tuiLaunch(msg.binding, options) });
5592
- } catch { return void reply({ ok: false, error: "owned Jcode launch or saved session could not be verified; details withheld" }); }
5671
+ } catch (error) {
5672
+ if (error instanceof JcodeOptionMismatch) return void reply({ ok: false, option: error.option, error: `owned Jcode launch differs from its running session in ${error.option}; start without it or stop jcode first` });
5673
+ return void reply({ ok: false, error: "owned Jcode launch or saved session could not be verified; details withheld" });
5674
+ }
5593
5675
  }
5594
5676
  case "peer_start":
5595
5677
  // #269: a start the hub makes in the peer's start mode, for a console child with no terminal of its own.
@@ -5719,6 +5801,9 @@ export async function startDaemon(opts: DaemonOptions) {
5719
5801
  }
5720
5802
  async function stopOnce(): Promise<void> {
5721
5803
  stopping = true;
5804
+ terminalLaunches.clear();
5805
+ // Start cancellation immediately; a failed read cleanup must not strand the other owned peers or finalization.
5806
+ const terminalReadsStopped = terminalReads.stop().catch(() => { log("terminal owner query cleanup failed during shutdown"); });
5722
5807
  runnerRefreshAbort.abort();
5723
5808
  // A stop someone asked for is not a crash, even if it then runs past the shutdown deadline: forget the sessions now.
5724
5809
  try { removeSessions(opts.stateDir, instanceId); } catch { /* the state dir is gone */ }
@@ -5733,6 +5818,8 @@ export async function startDaemon(opts: DaemonOptions) {
5733
5818
  for (const pending of checkpointWaits.values()) pending.done(undefined);
5734
5819
  for (const p of permissions.values()) p.done(undefined);
5735
5820
  try {
5821
+ await terminalReadsStopped;
5822
+ await terminalLaunches.settled();
5736
5823
  // A peer can still be inside memory recall or its native handshake when kill arrives.
5737
5824
  // Drain those starts before taking the final owned-process snapshot.
5738
5825
  // Fence/abort the runner now, but slow container teardown must not consume the peer-stop deadline.
@@ -5741,7 +5828,7 @@ export async function startDaemon(opts: DaemonOptions) {
5741
5828
  // A fingerprint still being detected belongs in the research records written below; its peer must still be
5742
5829
  // attached when it settles, so this comes before the peers stop (each detection is bounded by its probe deadline).
5743
5830
  await Promise.allSettled([...detecting]);
5744
- const exits = await Promise.allSettled([...bus.peers.values()].map((p) => p.stop()).concat(sidecar ? [sidecar.stop()] : []));
5831
+ const exits = await Promise.allSettled([...bus.peers.values()].map((p) => p.stop()));
5745
5832
  // A peer whose stop rejects must not strand the daemon (issue #56): its failure is
5746
5833
  // logged and shutdown finishes anyway, or the process ignores SIGTERM forever.
5747
5834
  for (const exit of exits) if (exit.status === "rejected") log(`peer stop failed during shutdown: ${(exit.reason as Error)?.message ?? exit.reason}`);
@@ -1,10 +1,25 @@
1
1
  import type { Envelope } from "./envelope.ts";
2
2
  import type { Task } from "./board.ts";
3
3
 
4
+ type DeliveryTask = Pick<Task, "id" | "state" | "owner" | "reviewer" | "cancelled"> & Partial<Pick<Task, "history">>;
5
+
6
+ /** No task text or timestamps: only this recipient's own acceptance in its current ownership span. */
7
+ function recipientAccepted(task: DeliveryTask, recipient: string): boolean {
8
+ const history = task.history;
9
+ if (!history) return false;
10
+ // ponytail: inspect at most 4096 recent entries. Older/unknown acceptance keeps the brief held; a persisted owner epoch can extend it.
11
+ for (let index = history.length - 1, first = Math.max(0, history.length - 4096); index >= first; index--) {
12
+ const entry = history[index]!;
13
+ if (entry.owner !== undefined || ["assigned", "reassigned", "escalated", "unassigned", "backlog returned untouched"].includes(entry.event)) return false;
14
+ if (entry.event === "accepted" && entry.by === recipient) return true;
15
+ }
16
+ return false;
17
+ }
18
+
4
19
  /** Evidence only, never task text: a mixed/unknown/live row requires a person's decision. */
5
20
  export function supersededDeliveryReason(
6
21
  originals: readonly Envelope[],
7
- board: { get(id: number): Pick<Task, "id" | "state" | "owner" | "reviewer" | "cancelled"> | undefined },
22
+ board: { get(id: number): DeliveryTask | undefined },
8
23
  recipient: string,
9
24
  ): string | undefined {
10
25
  if (!originals.length) return undefined;
@@ -22,6 +37,8 @@ export function supersededDeliveryReason(
22
37
  if (typeof env.refs.taskCancelled === "number" && env.refs.taskCancelled === task.cancelled?.flowRevision) return undefined;
23
38
  reasons.add(`task #${task.id} cancelled`);
24
39
  } else if (task.state === "approved") reasons.add(`task #${task.id} approved`);
40
+ else if (env.kind === "task" && env.refs.taskRecipient === "owner" && Number.isSafeInteger(env.refs.taskOffer) && env.refs.taskOffer! >= 1 &&
41
+ task.owner === recipient && ["in_progress", "in_review", "changes_requested"].includes(task.state) && recipientAccepted(task, recipient)) reasons.add(`task #${task.id} accepted`);
25
42
  else if ((env.kind === "review" || env.refs.taskRecipient === "reviewer") && (task.state !== "in_review" || task.reviewer !== recipient)) reasons.add(`task #${task.id} review moved`);
26
43
  else if (env.kind === "task" && env.refs.taskRecipient === "owner" && task.owner !== recipient) reasons.add(`task #${task.id} owner moved`);
27
44
  else return undefined; // Untagged legacy task notices and planner coordination carry no proof of supersession.
@@ -33,7 +50,7 @@ export function supersededDeliveryReason(
33
50
  /** Informational context for every referenced task, including peer/user and mixed rows. Never authorizes settlement. */
34
51
  export function deliveryTaskContext(
35
52
  originals: readonly Envelope[],
36
- board: { get(id: number): Pick<Task, "id" | "state" | "owner" | "reviewer" | "cancelled"> | undefined },
53
+ board: { get(id: number): DeliveryTask | undefined },
37
54
  recipient: string,
38
55
  ): string | undefined {
39
56
  const context = new Set<string>();