@staix/agent-hub 0.12.44 → 0.12.45

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 (137) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +3 -3
  3. package/THIRD_PARTY_NOTICES.md +29 -0
  4. package/docs/adding-an-ai-tool.md +102 -14
  5. package/docs/agent-notes/README.md +9 -0
  6. package/docs/agent-notes/adapters.md +58 -44
  7. package/docs/agent-notes/budget.md +1 -1
  8. package/docs/agent-notes/daemon.md +9 -7
  9. package/docs/agent-notes/local-worker.md +4 -3
  10. package/docs/agent-notes/tasks.md +8 -5
  11. package/docs/agent-notes/tests.md +7 -7
  12. package/docs/operations.md +99 -7
  13. package/docs/quickstart.md +5 -1
  14. package/docs/sdlc.md +26 -8
  15. package/docs/security.md +6 -3
  16. package/docs/specs/2026-09-19-agent-hub-design.md +114 -5
  17. package/docs/verification/2026-10-21-469-r1-dashboard-tokens.md +43 -0
  18. package/docs/verification/2026-10-21-469-r2-dashboard-shell.md +54 -0
  19. package/docs/verification/2026-10-21-469-r3-dashboard-board.md +51 -0
  20. package/docs/verification/2026-10-21-469-r4-dashboard-views.md +77 -0
  21. package/docs/verification/2026-10-21-469-r6-dashboard-motion-states.md +82 -0
  22. package/docs/verification/2026-10-22-0.12.45.md +60 -0
  23. package/docs/verification/assets/469-r1-board-1440-dark.png +0 -0
  24. package/docs/verification/assets/469-r1-board-1440-light.png +0 -0
  25. package/docs/verification/assets/469-r1-board-390-dark.png +0 -0
  26. package/docs/verification/assets/469-r1-board-390-light.png +0 -0
  27. package/docs/verification/assets/469-r2-agents-1440-dark.png +0 -0
  28. package/docs/verification/assets/469-r2-agents-768-dark.png +0 -0
  29. package/docs/verification/assets/469-r2-board-1024-light.png +0 -0
  30. package/docs/verification/assets/469-r2-board-1440-dark.png +0 -0
  31. package/docs/verification/assets/469-r2-board-1440-light.png +0 -0
  32. package/docs/verification/assets/469-r2-board-390-dark.png +0 -0
  33. package/docs/verification/assets/469-r2-board-390-light.png +0 -0
  34. package/docs/verification/assets/469-r2-board-768-light.png +0 -0
  35. package/docs/verification/assets/469-r3-board-1440-dark.png +0 -0
  36. package/docs/verification/assets/469-r3-board-1440-light.png +0 -0
  37. package/docs/verification/assets/469-r3-board-390-light.png +0 -0
  38. package/docs/verification/assets/469-r3-detail-1440-dark.png +0 -0
  39. package/docs/verification/assets/469-r3-detail-1440-light.png +0 -0
  40. package/docs/verification/assets/469-r3-detail-390-dark.png +0 -0
  41. package/docs/verification/assets/469-r3-plan-1440-light.png +0 -0
  42. package/docs/verification/assets/469-r3-plan-390-light.png +0 -0
  43. package/docs/verification/assets/469-r4-agents-1440-dark.png +0 -0
  44. package/docs/verification/assets/469-r4-agents-1440-light.png +0 -0
  45. package/docs/verification/assets/469-r4-agents-390-dark.png +0 -0
  46. package/docs/verification/assets/469-r4-memory-1440-dark.png +0 -0
  47. package/docs/verification/assets/469-r4-memory-1440-light.png +0 -0
  48. package/docs/verification/assets/469-r4-memory-390-dark.png +0 -0
  49. package/docs/verification/assets/469-r4-message-1440-light.png +0 -0
  50. package/docs/verification/assets/469-r4-office-1440-dark.png +0 -0
  51. package/docs/verification/assets/469-r4-office-1440-light.png +0 -0
  52. package/docs/verification/assets/469-r4-settings-1440-dark.png +0 -0
  53. package/docs/verification/assets/469-r4-settings-1440-light.png +0 -0
  54. package/docs/verification/assets/469-r4-stream-1440-light.png +0 -0
  55. package/docs/verification/assets/469-r4-stream-390-light.png +0 -0
  56. package/docs/verification/assets/469-r6-agents-empty-390-dark.png +0 -0
  57. package/docs/verification/assets/469-r6-agents-empty-390-light.png +0 -0
  58. package/docs/verification/assets/469-r6-board-1440-light.png +0 -0
  59. package/docs/verification/assets/469-r6-board-390-dark.png +0 -0
  60. package/docs/verification/assets/469-r6-board-empty-1440-dark.png +0 -0
  61. package/docs/verification/assets/469-r6-board-empty-1440-light.png +0 -0
  62. package/docs/verification/assets/469-r6-disconnected-1440-dark.png +0 -0
  63. package/docs/verification/assets/469-r6-disconnected-1440-light.png +0 -0
  64. package/docs/verification/assets/469-r6-disconnected-390-dark.png +0 -0
  65. package/docs/verification/assets/469-r6-skeleton-1440-light.png +0 -0
  66. package/docs/verification/assets/469-r6-skeleton-390-dark.png +0 -0
  67. package/docs/verification/assets/469-r6-toasts-1440-dark.png +0 -0
  68. package/docs/verification/assets/469-r6-toasts-1440-light.png +0 -0
  69. package/docs/verification/assets/469-r6-toasts-390-light.png +0 -0
  70. package/docs/verified.json +19 -16
  71. package/package.json +5 -1
  72. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  73. package/plugins/agent-hub/server.js +9818 -9605
  74. package/src/adapters/adapter-api.ts +2 -0
  75. package/src/adapters/agy/definition.ts +3 -1
  76. package/src/adapters/claude-channel.ts +14 -5
  77. package/src/adapters/codex-appserver.ts +1 -0
  78. package/src/adapters/conformance/acp.ts +114 -0
  79. package/src/adapters/conformance/codex.ts +191 -0
  80. package/src/adapters/conformance/enrollment.ts +43 -0
  81. package/src/adapters/conformance/factories.ts +17 -0
  82. package/src/adapters/conformance/fake-paths.ts +12 -0
  83. package/src/adapters/conformance/fakes/acp-server.ts +267 -0
  84. package/src/adapters/conformance/fakes/acp-watchdog-agent.ts +10 -0
  85. package/src/adapters/conformance/fakes/app-server.ts +119 -0
  86. package/src/adapters/conformance/fakes/codex-bin.ts +15 -0
  87. package/src/adapters/conformance/fakes/jcode-agent.ts +91 -0
  88. package/src/adapters/conformance/fakes/model-server.ts +39 -0
  89. package/src/adapters/conformance/fakes/pi-rpc.ts +32 -0
  90. package/src/adapters/conformance/index.ts +11 -0
  91. package/src/adapters/conformance/kit.ts +50 -0
  92. package/src/adapters/conformance/local-pi.ts +100 -0
  93. package/src/adapters/conformance/owned-acp.ts +44 -0
  94. package/src/adapters/definitions/builtin-metadata.ts +23 -6
  95. package/src/adapters/definitions/native-cli-entry-kimi.ts +7 -1
  96. package/src/adapters/definitions/native-cli-entry.ts +7 -19
  97. package/src/adapters/definitions/native-cli-preview.ts +2 -11
  98. package/src/adapters/definitions/qwen.ts +4 -1
  99. package/src/adapters/external-metadata.ts +48 -0
  100. package/src/adapters/external-module-scan.ts +126 -0
  101. package/src/adapters/external-modules.ts +240 -0
  102. package/src/adapters/pi.ts +2 -0
  103. package/src/adapters/public.ts +7 -0
  104. package/src/adapters/registry-types.ts +10 -0
  105. package/src/adapters/registry.ts +51 -15
  106. package/src/cli/help.ts +21 -16
  107. package/src/cli/identity.ts +1 -1
  108. package/src/cli/main.ts +23 -5
  109. package/src/cli/upgrade-labels.ts +1 -1
  110. package/src/cli/upgrade-presentation.ts +1 -1
  111. package/src/cli/upgrade-restore-state.ts +3 -2
  112. package/src/cli/upgrade-runtime.ts +4 -1
  113. package/src/hub/board.ts +30 -4
  114. package/src/hub/config-apply.ts +18 -10
  115. package/src/hub/config-reload.ts +8 -5
  116. package/src/hub/config-trust.ts +1 -1
  117. package/src/hub/control-client.ts +4 -1
  118. package/src/hub/daemon.ts +95 -39
  119. package/src/hub/facts.ts +76 -13
  120. package/src/hub/hub-tools.ts +21 -5
  121. package/src/hub/instance-config.ts +17 -5
  122. package/src/hub/instance-execution.ts +20 -8
  123. package/src/hub/instances.ts +2 -1
  124. package/src/hub/peers.ts +9 -0
  125. package/src/hub/plan-conformance.ts +83 -0
  126. package/src/hub/review-findings.ts +104 -0
  127. package/src/hub/roster.ts +31 -5
  128. package/src/hub/sdlc-queue.ts +19 -4
  129. package/src/hub/sdlc-release.ts +16 -1
  130. package/src/hub/sdlc.ts +27 -12
  131. package/src/hub/settings.ts +5 -1
  132. package/src/hub/snapshots.ts +8 -6
  133. package/src/hub/tasks.ts +275 -45
  134. package/src/hub/ui.ts +2 -2
  135. package/src/ui/index.html +559 -78
  136. package/src/ui/office-model.ts +3 -1
  137. package/src/ui/peer-palette.ts +44 -0
package/src/hub/daemon.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { loadExternalAdapterDefinitions, readAdapterModuleDeclarations, type AdapterModuleDeclaration } from "../adapters/external-modules.ts";
1
2
  import { WsPeer, isWsPeer } from "../adapters/channel-peer.ts";
2
3
  import { ownerToolEffect, canonicalWriteUnknown } from "./no-writes.ts";
3
4
  import { NativeHookLaunches } from "./native-hook-launch.ts";
@@ -26,7 +27,7 @@ import { JcodeOptionMismatch, isJcodePeer, type JcodePeer } from "../adapters/jc
26
27
  import { jcodeMcpReady } from "../cli/jcode-mcp.ts";
27
28
  import { loadSdlc, readSpecEdit, sdlcGh } from "./sdlc.ts";
28
29
  import { claudePeer as defaultChannelPeer, claudeStateFile, ownTerminalRow, pathEvidence, transcriptPersisted } from "./claude-state.ts";
29
- import { peerRosterConfig, rosterAllows, type PeerRoster } from "./roster.ts";
30
+ import { peerRosterConfig, rosterAllows, staleInstanceKey, type PeerRoster } from "./roster.ts";
30
31
  import { KNOWN_PEERS, OPTIONAL_PEERS, PLANNER_PEERS, SUPPORTED_PEERS, START_MODE_PEERS, isSupportedPeer, STARTABLE_PEERS, isStartablePeer } from "../adapters/registry.ts";
31
32
  import { RecoveryDeferredHubError } from "./deferred-hub.ts";
32
33
  import { databaseBusySite, isDatabaseBusy, RecoveryDatabaseBusy } from "./recovery-error.ts";
@@ -93,7 +94,7 @@ import { AssignmentUndeliveredError, ESCALATE_AFTER, Tasks } from "./tasks.ts";
93
94
  import { DEFAULT_INFERENCE, DIGEST, Inference, screenPii, type InferenceConfig } from "./inference.ts";
94
95
  import { ask, ASK_NOTE_TITLE, RUN_START } from "./ask.ts";
95
96
  import { currentRouting, loadRouting, detectSignals, OVERLAY_FILE, overlayToml, parseRouting, routingText, type Routing } from "./routing.ts";
96
- 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";
97
+ import { projectSettings, checkSettingValue, ignoredLine, pendingUndo, pendingUndoIdentity, readOverlay, restoredValue, routingCandidate, storedLocally, sharedValue, settingDef as instanceSettingDef, settingRefusal, settingRows, settingText, undoSetting, valueAt, writeConfigSetting, writeRoutingSetting, type SettingAuthority, type SettingDef, type SettingRow, type SettingValue, type UndoIdentity } from "./settings.ts";
97
98
  import { Bus } from "./bus.ts";
98
99
  import { attribute, deliveryTask, type TaskAttribution } from "./attribution.ts";
99
100
  import { DeliveryJournal } from "./delivery-journal.ts";
@@ -112,7 +113,7 @@ import { Facts, FACTS_PREFIX, type FactScope } from "./facts.ts";
112
113
  import { crashPlan, lossNotice, readSessions, removeSessions, writeSessions, type SessionsFile } from "./crash.ts";
113
114
  import type { JournalDelivery } from "./delivery-journal.ts";
114
115
  import { DEFAULT_LIMITS, Limiter, PROJECT_LIMITS, type LimitsConfig } from "./limits.ts";
115
- import { soleWorktree, branchHeads, changedPaths, knownChangedPaths, hasTree, headState, headMoveCursor, incomingHeadPaths, repoOf, snapshot, SnapshotBudgetExceeded, Turns, type SnapshotBudget, type HeadMoveCursor, type Repo, type TurnRecord } from "./snapshots.ts";
116
+ import { soleWorktree, branchHeads, changedPaths, knownChangedPaths, hasTree, headState, headMoveCursor, incomingHeadPaths, repoOf, snapshot, SnapshotBudgetExceeded, budgetNow, Turns, type SnapshotBudget, type HeadMoveCursor, type Repo, type TurnRecord } from "./snapshots.ts";
116
117
  import { archiveRestartSnapshot, readRecoveryWaivers, readRestartSnapshot, removeRestartSnapshot, restartPath, writeRestartSnapshot, type RecoveryPhase, type RestartPeerSnapshot, type RestartSnapshot } from "./restart.ts";
117
118
  import { consumeRecoveryContinuity, readRecoveryContinuity, retireRecoveryContinuity } from "./recovery-continuity.ts";
118
119
  import { shellQuote, publishTerminalLaunch, terminalLaunchCandidate } from "../cli/terminal-recovery.ts";
@@ -134,6 +135,7 @@ import { nativeRecall } from "../memory/recall.ts";
134
135
  import { promotionScreen, withoutPii, mentionsPrivatePath } from "./memory-policy.ts";
135
136
 
136
137
  export interface HubConfig {
138
+ adapter_modules?: readonly AdapterModuleDeclaration[];
137
139
  adapters?: Readonly<Record<string, import("../adapters/configured-acp.ts").ConfiguredAcpAdapterConfig>>;
138
140
  /** Person-owned declarations, read only from an untracked machine-local file. Bound for this daemon lifetime. */
139
141
  instances?: InstancesConfig;
@@ -322,12 +324,13 @@ function retiredRoutingSettings(cwd: string): readonly string[] {
322
324
  catch { return []; } // Routing admission and reload report parse errors using their own last-good state.
323
325
  }
324
326
 
325
- export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routingRetired: readonly string[] = retiredRoutingSettings(cwd)): HubConfig {
327
+ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routingRetired: readonly string[] = retiredRoutingSettings(cwd), externalDefinitions?: readonly import("../adapters/registry-types.ts").AdapterDefinition[]): HubConfig {
326
328
  const captured = layers ?? readInstanceConfigLayers(cwd);
327
- const instanceConfig = readInstanceConfig(cwd, captured);
329
+ const instanceConfig = readInstanceConfig(cwd, captured, externalDefinitions);
328
330
  const instances = instanceConfig.instances;
329
331
  const adapterCatalog = instanceConfig.catalog;
330
332
  const ignored: string[] = [...instanceConfig.ignored];
333
+ const inertModuleRoster = !!instanceConfig.adapter_modules?.length;
331
334
  const appliedPermissionSources: NonNullable<HubConfig["permission_default_sources"]> = {};
332
335
  const files = CONFIG_FILES.flatMap((name) => {
333
336
  let file: Record<string, any>; // parsed JSON, checked field by field below
@@ -338,9 +341,10 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
338
341
  if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
339
342
  throw error;
340
343
  }
344
+ delete file.adapter_modules; // Code provenance is captured separately and never evaluated by configuration reads.
341
345
  delete file.adapters; // Adapter provenance is compiled by the same project-local loader.
342
346
  delete file.instances; // Declaration authority was checked against its exact captured origin above.
343
- peerRosterConfig(file.roster, instances, adapterCatalog);
347
+ peerRosterConfig(file.roster, instances, adapterCatalog, inertModuleRoster);
344
348
  catalogAssignments(file.catalog_roles);
345
349
  profileName(file.profile);
346
350
  resolveProfile(file.profile, file.profile_overrides);
@@ -412,6 +416,7 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
412
416
  ...DEFAULT_CONFIG,
413
417
  ...file,
414
418
  ...(instanceConfig.adapters ? { adapters: instanceConfig.adapters } : {}),
419
+ ...(instanceConfig.adapter_modules ? { adapter_modules: instanceConfig.adapter_modules } : {}),
415
420
  ...(instances ? { instances } : {}),
416
421
  ...(file.text_projection !== undefined ? { text_projection: projectionConfig(file.text_projection) } : {}),
417
422
  ...(file.pii !== undefined ? { pii: { paths: policyPaths(file.pii.paths, "pii.paths") } } : {}),
@@ -421,7 +426,7 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
421
426
  permission_default_sources: appliedPermissionSources,
422
427
  memory: { ...DEFAULT_CONFIG.memory, native: true, ...file.memory, ...(file.memory?.short_ttl_days !== undefined ? { short_ttl_days: ttlDays(file.memory.short_ttl_days) } : {}),
423
428
  ...(file.memory?.sync_interval_min !== undefined ? { sync_interval_min: syncMinutes(file.memory.sync_interval_min) } : {}) }, // the native store is on with any project config (#258)
424
- roster: peerRosterConfig(file.roster, instances, adapterCatalog),
429
+ roster: peerRosterConfig(file.roster, instances, adapterCatalog, inertModuleRoster),
425
430
  roles: { ...configuredRoles, ...conductorPolicy.roles, ...extraRoles },
426
431
  planner: conductorPolicy.planner,
427
432
  conductor: conductorPolicy.conductor,
@@ -476,6 +481,8 @@ export interface DaemonOptions {
476
481
  /** Host-only snapshot/probe seams; never read from project config or tool arguments. */
477
482
  snapshot?: typeof snapshot;
478
483
  snapshotBurstMs?: number;
484
+ /** Host-only (#661): the clock the shared snapshot Git budget reads (default performance.now). A test holds it fixed so a slow runner cannot spend the 250 ms; production never sets it and no config key or tool reaches it. */
485
+ snapshotClock?: () => number;
479
486
  checkoutBranch?: typeof mainCheckoutBranch;
480
487
  /** Host-only admission transport; never selected by project config or tools. */
481
488
  releaseHostAdmission?: (cwd: string) => Promise<ReleaseHostAdmission>;
@@ -531,12 +538,21 @@ export async function startDaemon(opts: DaemonOptions) {
531
538
  const startupCleanup: (() => void)[] = [];
532
539
  let ready = false;
533
540
  try {
534
- const loadedConfig = opts.config ?? loadConfig(opts.cwd);
541
+ let startupLayers: ReturnType<typeof readInstanceConfigLayers>;
542
+ try { startupLayers = readInstanceConfigLayers(opts.cwd); }
543
+ catch (error) { if (!opts.config) throw error; startupLayers = []; } // Explicit embeddings may ignore unreadable file config; no code is loaded from it.
544
+ const externalAdapterRefusals: import("../adapters/external-modules.ts").ExternalAdapterRefusalRecord[] = [];
545
+ const externalDefinitions = await loadExternalAdapterDefinitions(opts.cwd, startupLayers, refusal => externalAdapterRefusals.push(refusal));
546
+ const externalAdapters = externalDefinitions.map(({ kind, displayName }) => ({ kind, displayName }));
547
+ const externalIds = new Set(externalAdapters.map(item => item.kind));
548
+ const moduleDeclarations = readAdapterModuleDeclarations(opts.cwd, startupLayers).modules;
549
+ const loadedConfig = opts.config ? { ...opts.config, adapter_modules: moduleDeclarations.length ? moduleDeclarations : undefined }
550
+ : loadConfig(opts.cwd, startupLayers, undefined, externalDefinitions);
535
551
  reviewerPoolConfig(loadedConfig.review, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS);
536
552
  const lifecycleConfig = loadSdlc(opts.cwd, VERSION);
537
553
  // Preserve embedding-owned policy references, but never publish reloads into shared defaults.
538
554
  const ownedConfig = Object.fromEntries(Object.entries(loadedConfig).map(([key, value]) => [key, !opts.config || value === (DEFAULT_CONFIG as unknown as Record<string, unknown>)[key] ? structuredClone(value) : value])) as unknown as HubConfig;
539
- const adapterCatalog = createProjectAdapterCatalog(loadedConfig.adapters);
555
+ const adapterCatalog = createProjectAdapterCatalog(loadedConfig.adapters, externalDefinitions);
540
556
  const configuredIds = adapterCatalog.definitions.filter(d => !builtinDefinitionOf(d.kind)).map(d => d.kind);
541
557
  const declarations = validateInstances(loadedConfig.instances, adapterCatalog);
542
558
  const extraIds = Object.keys(declarations);
@@ -562,7 +578,7 @@ export async function startDaemon(opts: DaemonOptions) {
562
578
  }
563
579
  }
564
580
  const config: HubConfig = { ...ownedConfig, ...(extraIds.length ? { instances: declarations } : {}),
565
- roster: peerRosterConfig(loadedConfig.roster, declarations, adapterCatalog),
581
+ roster: peerRosterConfig(loadedConfig.roster, declarations, adapterCatalog, moduleDeclarations.length > 0),
566
582
  permission_modes: { ...permissionModesFromInstances, ...permissionDefaults(loadedConfig.permission_modes, declarations, adapterCatalog) },
567
583
  peers: { ...startModesFromInstances, ...peerStartConfig(loadedConfig.peers, declarations, adapterCatalog) },
568
584
  ...(extraIds.length ? { permission_default_sources: { ...instancePermissionSources, ...loadedConfig.permission_default_sources } } : {}),
@@ -585,7 +601,16 @@ export async function startDaemon(opts: DaemonOptions) {
585
601
  const reviewOnlyItem = (peer: string, env: Envelope): boolean => env.from === USER || env.from === HUB && (env.kind === "review" || (env.kind === "task" && env.to?.includes(peer) === true));
586
602
  const readOnlyPeer = (peer: string): boolean => declarations[peer]?.readOnly === true;
587
603
  const SETTINGS = projectSettings(declarations, adapterCatalog);
588
- const settingDef = (key: unknown) => instanceSettingDef(key, declarations, adapterCatalog);
604
+ // #225: a roster key whose instance was undeclared has no row of its own. The only thing a person can do with it is delete it,
605
+ // so it gets a boolean roster row that checkSettingValue below only accepts as a removal.
606
+ const staleRosterKey = (key: unknown): string | undefined => {
607
+ const peer = typeof key === "string" && key.startsWith("roster.") ? key.slice("roster.".length) : undefined;
608
+ return peer !== undefined && !declarations[peer] && Object.hasOwn(config.roster, peer) && staleInstanceKey(peer, adapterCatalog) ? peer : undefined;
609
+ };
610
+ const settingDef = (key: unknown): SettingDef | string => {
611
+ const found = instanceSettingDef(key, declarations, adapterCatalog), stale = staleRosterKey(key);
612
+ return typeof found === "string" && stale ? { key: `roster.${stale}`, group: "Roster", label: `${stale}: stale roster key (instance no longer declared)`, type: "boolean", store: "config", applies: "live", risk: "raises", floor: false, path: ["roster", stale] } : found;
613
+ };
589
614
  const accountOf = (peer: string) => definitionOf(peer)?.budget?.account ?? peer;
590
615
  const accountMembers = (account: string) => SUPPORTED_PEERS.filter(peer => accountOf(peer) === account);
591
616
  let rosterChanging: string | undefined;
@@ -593,7 +618,8 @@ export async function startDaemon(opts: DaemonOptions) {
593
618
  let kimiAdmissionId: string | undefined;
594
619
  let kimiAdmissionExpiry: ReturnType<typeof setTimeout> | undefined;
595
620
  const clearKimiAdmission = () => { clearTimeout(kimiAdmissionExpiry); kimiAdmissionExpiry = undefined; kimiSessionMode = undefined; kimiAdmissionId = undefined; };
596
- const onRoster = (peer: string) => rosterAllows(config.roster, peer, config.pi.enabled, declarations, adapterCatalog);
621
+ const projectRosterAllows = (roster: PeerRoster, peer: string, piEnabled: boolean) => rosterAllows(roster, peer, piEnabled, declarations, adapterCatalog, moduleDeclarations.length > 0);
622
+ const onRoster = (peer: string) => projectRosterAllows(config.roster, peer, config.pi.enabled);
597
623
  // pi.enabled historically gates starts, not retained queues or tools sockets. Only an explicit roster.pi changes those.
598
624
  const membershipAllowed = (peer: string) => definitionOf(peer)?.enabledSwitchStartOnly && config.roster[peer] === undefined ? true : onRoster(peer);
599
625
  conductorPeer(config.roles); // reject ambiguous leadership before opening a control listener
@@ -604,7 +630,7 @@ export async function startDaemon(opts: DaemonOptions) {
604
630
  // The state dir can vanish under a running hub (issue #56): a log line must never take a handler down with it.
605
631
  const log = (line: string) => { try { appendFileSync(logFile, `${new Date().toISOString()} ${line}\n`); } catch { /* the state dir is gone; the watchdog is stopping the hub */ } };
606
632
  let runningRouting = currentRouting(opts.cwd, log);
607
- let acceptedSources = parseConfigSources(configSourceTexts(opts.cwd), runningRouting, opts.cwd);
633
+ let acceptedSources = parseConfigSources(configSourceTexts(opts.cwd), runningRouting, opts.cwd, externalAdapters);
608
634
  const startupSources = structuredClone(acceptedSources.trustLayers);
609
635
  const embeddedConfig = structuredClone(config);
610
636
  let desiredConfig = structuredClone(config);
@@ -1193,8 +1219,9 @@ export async function startDaemon(opts: DaemonOptions) {
1193
1219
  if (!peer || peer.state === "offline") return undefined;
1194
1220
  let token = checkConnections.get(peer);
1195
1221
  if (!token) { token = randomUUID(); checkConnections.set(peer, token); }
1196
- const native = isWsPeer(peer) || isCodexPeer(peer) || isPiPeer(peer) ? peer.sessionGeneration : peer.recoveryMetadata?.().sessionId;
1197
- if (isPiPeer(peer) && native === undefined) return undefined;
1222
+ const hasGeneration = "sessionGeneration" in peer;
1223
+ const native = hasGeneration ? peer.sessionGeneration : peer.recoveryMetadata?.().sessionId;
1224
+ if (hasGeneration && native === undefined) return undefined;
1198
1225
  return `${instanceId}:${token}:${String(native ?? "")}`;
1199
1226
  };
1200
1227
  const currentCheck = (receipt: { task: number; owner: string; updated: number | string; generation: number | string; revision?: number }) => {
@@ -1672,8 +1699,9 @@ export async function startDaemon(opts: DaemonOptions) {
1672
1699
  const owner = bus.peers.get(peer);
1673
1700
  const state = bus.stateOf(peer);
1674
1701
  const session = contextSession(peer);
1675
- const generation = isWsPeer(owner) || isCodexPeer(owner) ? owner.sessionGeneration : undefined;
1676
- if (!owner || (isWsPeer(owner) && owner.claiming) || (state !== "idle" && state !== "busy") || (reason === "context" && (!session || turns.get(peer)?.private || contextPii(peer)))) return Promise.resolve(undefined);
1702
+ const generationFenced = !!owner && owner.checkpointUsesSessionGeneration !== false && "sessionGeneration" in owner;
1703
+ const generation = generationFenced ? owner.sessionGeneration : undefined;
1704
+ if (!owner || (generationFenced && generation === undefined) || (isWsPeer(owner) && owner.claiming) || (state !== "idle" && state !== "busy") || (reason === "context" && (!session || turns.get(peer)?.private || contextPii(peer)))) return Promise.resolve(undefined);
1677
1705
  const openWork = board.list().some((t) => (["proposed", "in_progress", "changes_requested", "in_review"].includes(t.state) && t.owner === peer) || (t.state === "in_review" && t.reviewer === peer));
1678
1706
  if (!openWork && !bus.queued(peer) && !bus.hasInFlight(peer) && state !== "busy") {
1679
1707
  if (reason === "quota") log(`budget ${peer}: no open work, no checkpoint`);
@@ -1685,7 +1713,7 @@ export async function startDaemon(opts: DaemonOptions) {
1685
1713
  : `Checkpoint request: your native context window is near the configured threshold. Finish the step you are on and call hub_checkpoint {summary, request_id: "${requestId}"} with what you were doing, what is half done and what a fresh session would need. This only saves a checkpoint; keep your tasks and session. The operator decides whether to continue or restart. Do not include private task text.`;
1686
1714
  const ask = newEnvelope(HUB, body, { to: [peer], kind: "budget", priority: "important" });
1687
1715
  return new Promise((resolve) => {
1688
- const valid = () => !stopping && !recoveryActive() && bus.peers.get(peer) === owner && (!(isWsPeer(owner) || isCodexPeer(owner)) || owner.sessionGeneration === generation) && ["idle", "busy"].includes(bus.stateOf(peer)) && contextSession(peer) === session;
1716
+ const valid = () => !stopping && !recoveryActive() && bus.peers.get(peer) === owner && (!generationFenced || owner.sessionGeneration === generation) && ["idle", "busy"].includes(bus.stateOf(peer)) && contextSession(peer) === session;
1689
1717
  const done = (summary: string | undefined) => {
1690
1718
  clearTimeout(timer);
1691
1719
  checkpointWaits.delete(peer);
@@ -1815,7 +1843,8 @@ export async function startDaemon(opts: DaemonOptions) {
1815
1843
  if (!snapshotBudget) {
1816
1844
  const requested = opts.snapshotBurstMs ?? 250;
1817
1845
  const ms = Number.isFinite(requested) ? Math.max(1, Math.min(250, requested)) : 250;
1818
- snapshotBudget = { deadline: performance.now() + ms };
1846
+ const now = opts.snapshotClock;
1847
+ snapshotBudget = { deadline: (now ? now() : performance.now()) + ms, ...(now ? { now } : {}) };
1819
1848
  setImmediate(() => { snapshotBudget = undefined; snapshotBudgetNoticed = false; });
1820
1849
  }
1821
1850
  return snapshotBudget;
@@ -2309,7 +2338,7 @@ export async function startDaemon(opts: DaemonOptions) {
2309
2338
  await tasks.specStageGate(by, a.id, a.receipt); // #481 S1: a live spec edit-time check before a build stage
2310
2339
  return line(tasks.recordLifecycle(by, a.id, a.receipt));
2311
2340
  case "hub_review":
2312
- return line(await tasks.review(by, a.id, a.verdict, a.note, a.unmet, a.head, a.coverage));
2341
+ return line(await tasks.review(by, a.id, a.verdict, a.note, a.unmet, a.head, a.coverage, a.findings));
2313
2342
  case "hub_remember":
2314
2343
  return tasks.remember(by, a);
2315
2344
  case "hub_checkpoint": {
@@ -2684,13 +2713,14 @@ export async function startDaemon(opts: DaemonOptions) {
2684
2713
  };
2685
2714
  const permissionDisplay = (id: string, peer: PeerAdapter | undefined): string => {
2686
2715
  if (readOnlyPeer(id)) return "read-only";
2687
- if (isAcpPeer(peer) && ["unknown", "unmanaged"].includes(peer.permissionModeState)) return peer.permissionModeState;
2688
- if (starting.has(id) && (isAcpPeer(peer) || isCodexPeer(peer))) return peer.getPermissionMode();
2689
2716
  if (definitionOf(id)?.transport === "claude-channel" && isWsPeer(peer) && peer.state !== "offline") {
2690
2717
  const launch = claudePermissionLaunch(id);
2691
2718
  if (!launch || claudePermissionGenerations.get(id) !== peer.sessionGeneration) return "unverified";
2692
2719
  }
2693
- return permissionMode(id);
2720
+ const native = peer?.permissionModeState;
2721
+ // An offline ask clears the hub choice, not a stopped ACP runtime's retained native field.
2722
+ if (peer?.state === "offline" && !starting.has(id) && isPermissionMode(native)) return permissionMode(id);
2723
+ return native ?? permissionMode(id);
2694
2724
  };
2695
2725
  const permissionStatus = (id: string, peer: PeerAdapter | undefined): Record<string, unknown> => {
2696
2726
  const note = definitionOf(id)?.permission.unmanagedStatusNote;
@@ -2745,6 +2775,8 @@ export async function startDaemon(opts: DaemonOptions) {
2745
2775
  protocol: PROTOCOL,
2746
2776
  features: DAEMON_FEATURES,
2747
2777
  configuredAdapters: configuredIds,
2778
+ ...(externalAdapters.length ? { externalAdapters } : {}),
2779
+ ...(externalAdapterRefusals.length ? { externalAdapterRefusals } : {}),
2748
2780
  startablePeers: SUPPORTED_PEERS.filter(isStartablePeer),
2749
2781
  readOnlyPeers: extraIds.filter(readOnlyPeer),
2750
2782
  // #450 I3: the instance ids this daemon bound at startup; a launcher selects only one of these.
@@ -3156,7 +3188,7 @@ export async function startDaemon(opts: DaemonOptions) {
3156
3188
  if (!end.head) throw new Error("end HEAD evidence is unavailable");
3157
3189
  const dirtyPaths = knownChangedPaths(turnRepo, end.head, end.tree, end.budget);
3158
3190
  if (!dirtyPaths) throw new Error("end worktree diff evidence is unavailable");
3159
- const checkBudget = () => { if (end.budget && performance.now() >= end.budget.deadline) throw new SnapshotBudgetExceeded(); };
3191
+ const checkBudget = () => { if (end.budget && budgetNow(end.budget) >= end.budget.deadline) throw new SnapshotBudgetExceeded(); };
3160
3192
  const dirty = new Set(dirtyPaths), dirtyScope = new Set(dirtyPaths), incoming = new Set(paths);
3161
3193
  for (const path of dirtyPaths) {
3162
3194
  checkBudget();
@@ -3740,9 +3772,10 @@ export async function startDaemon(opts: DaemonOptions) {
3740
3772
  onActivity: item => activityRings[peer]?.append(item),
3741
3773
  isPrivateTurn: () => !!turns.get(peer)?.private || contextPii(peer),
3742
3774
  kind: adapter.kind, cmd: [launch.cmd, ...launch.args], permissionMode: permission === "read-only" ? "ask" : permission,
3775
+ ...(externalIds.has(adapter.kind) ? { requireOwnedGroup: true, requireLoadSessionEcho: true, nativePolicy: adapter.permission.genericAcp ?? {} } : {}),
3743
3776
  ...(args.model ? { launchModel: args.model } : {}), ...(args.sessionId ? { resumeSessionId: args.sessionId } : {}),
3744
3777
  cwd: executionCwd, watchdogMs: config.watchdog_ms, onPermission,
3745
- autoApprove: title => !stopping && HUB_TOOL_TITLES.has(title),
3778
+ autoApprove: title => !externalIds.has(adapter.kind) && !stopping && HUB_TOOL_TITLES.has(title),
3746
3779
  log: () => log(`${peer}: ACP runtime diagnostic: details withheld`),
3747
3780
  onUsageDiagnostic: (observation, sessionId) => {
3748
3781
  if (observation.source !== "usage_update" || observation.shape !== "context-used") return;
@@ -3755,7 +3788,7 @@ export async function startDaemon(opts: DaemonOptions) {
3755
3788
  mcpServers,
3756
3789
  preamble: sessionRoleContract(peer),
3757
3790
  } } });
3758
- if (!isAcpPeer(owner)) throw new Error("ACP factory did not return its native transport");
3791
+ if (!isAcpPeer(owner) || owner.id !== peer) throw new Error("ACP factory did not return its bound native transport");
3759
3792
  recoveryTaskPreface(peer); await ensurePreface(peer);
3760
3793
  try { await owner.start(); await reconcileNativeStart(peer, owner); }
3761
3794
  catch (error) {
@@ -4398,12 +4431,12 @@ export async function startDaemon(opts: DaemonOptions) {
4398
4431
  function roleHolders(candidate: HubConfig): { planners: string[]; conductors: string[] } {
4399
4432
  // A valid transfer may briefly have two conductors while its leaves are planned. Validate the final map later.
4400
4433
  const policy = Object.fromEntries(Object.entries(candidate.roles).map(([peer, roles]) => [peer, roles.filter(role => role !== "conductor")]));
4401
- const settled = resolveSettledRoles(policy, candidate.planner, peer => rosterAllows(candidate.roster, peer, candidate.pi.enabled, declarations, adapterCatalog));
4434
+ const settled = resolveSettledRoles(policy, candidate.planner, peer => projectRosterAllows(candidate.roster, peer, candidate.pi.enabled));
4402
4435
  return { planners: settled.planners, conductors: Object.keys(candidate.roles).filter(peer => candidate.roles[peer]!.includes("conductor")).sort() };
4403
4436
  }
4404
4437
  function prepareConfiguration(sources: ConfigSources, authority: SettingAuthority, scope?: string, confirmed: readonly string[] = []) {
4405
- const fileConfig = loadConfig(opts.cwd, sources.layers, sources.routing.retired ?? []);
4406
- validateEffectiveConfig(fileConfig);
4438
+ const fileConfig = loadConfig(opts.cwd, sources.layers, sources.routing.retired ?? [], externalDefinitions);
4439
+ validateEffectiveConfig(fileConfig, adapterCatalog);
4407
4440
  const candidate = structuredClone(desiredConfig), routing = structuredClone(desiredRouting);
4408
4441
  const accepted: string[] = [], rejected: { key: string; reason: string }[] = [], guarded: string[] = [];
4409
4442
  const previews: { key: string; before: string; after: string; effect?: string }[] = [];
@@ -4500,7 +4533,7 @@ export async function startDaemon(opts: DaemonOptions) {
4500
4533
  const running = structuredClone({ ...config, roles: policyRoles });
4501
4534
  for (const change of changes) if (change.applies !== "restart") putConfig(running, change.key, change.after);
4502
4535
  running.retired = fileConfig.retired;
4503
- validateEffectiveConfig(running);
4536
+ validateEffectiveConfig(running, adapterCatalog);
4504
4537
  conductorPeer(running.roles);
4505
4538
  for (const peer of Object.keys(running.catalog_roles ?? {})) selectedRoles(peer, running.roles, { ...resolvedProfile.catalog_roles, ...catalogAssignments(running.catalog_roles) }, catalog);
4506
4539
  const problem = running.mlx.enabled === false ? mlxPinProblem(routing, sources.texts["routing.toml"] !== null) : undefined;
@@ -4530,7 +4563,7 @@ export async function startDaemon(opts: DaemonOptions) {
4530
4563
  const texts = configSourceTexts(opts.cwd), next = configSourceTexts(scratch);
4531
4564
  texts["config.json"] = next["config.json"];
4532
4565
  texts["config.local.json"] = next["config.local.json"];
4533
- const prepared = prepareConfiguration(parseConfigSources(texts, undefined, opts.cwd), authority, scope);
4566
+ const prepared = prepareConfiguration(parseConfigSources(texts, undefined, opts.cwd, externalAdapters), authority, scope);
4534
4567
  const refusal = prepared.rejected.find(item => item.key === scope);
4535
4568
  if (refusal) throw new Error(`${refusal.key}: ${refusal.reason}`);
4536
4569
  return prepared.fileConfig;
@@ -4538,7 +4571,7 @@ export async function startDaemon(opts: DaemonOptions) {
4538
4571
  function routingWriteCheck(authority: SettingAuthority, overlay: import("./routing.ts").RoutingOverlay | undefined, scope?: string): void {
4539
4572
  const texts = configSourceTexts(opts.cwd);
4540
4573
  texts["routing.local.toml"] = overlay === undefined ? null : overlayToml(overlay);
4541
- const prepared = prepareConfiguration(parseConfigSources(texts, undefined, opts.cwd), authority, scope);
4574
+ const prepared = prepareConfiguration(parseConfigSources(texts, undefined, opts.cwd, externalAdapters), authority, scope);
4542
4575
  const refusal = prepared.rejected.find(item => item.key === scope);
4543
4576
  if (refusal) throw new Error(`${refusal.key}: ${refusal.reason}`);
4544
4577
  }
@@ -4555,7 +4588,7 @@ export async function startDaemon(opts: DaemonOptions) {
4555
4588
  const texts = configSourceTexts(opts.cwd);
4556
4589
  fingerprint = configFingerprint(texts);
4557
4590
  if (confirm !== undefined && (typeof confirm !== "string" || authority !== "terminal" || scope || !reloadConfirmation || reloadConfirmation.token !== confirm || reloadConfirmation.fingerprint !== fingerprint || reloadConfirmation.revision !== configRevision)) return { ok: false, error: "configuration confirmation is stale or changed; run ahub settings reload again" };
4558
- const sources = parseConfigSources(texts, undefined, opts.cwd);
4591
+ const sources = parseConfigSources(texts, undefined, opts.cwd, externalAdapters);
4559
4592
  const prepared = prepareConfiguration(sources, authority, scope, confirm === undefined ? [] : reloadConfirmation!.keys);
4560
4593
  if (confirm !== undefined && prepared.rejected.some(item => reloadConfirmation!.keys.includes(item.key))) return { ok: false, error: "configuration trust changed; run ahub settings reload again" };
4561
4594
  if (scope && prepared.rejected.some(item => item.key === scope)) return { ok: false, error: prepared.rejected.find(item => item.key === scope)!.reason };
@@ -4622,7 +4655,7 @@ export async function startDaemon(opts: DaemonOptions) {
4622
4655
  const stamp = CONFIG_FILES.map((name) => { try { const s = statSync(join(opts.cwd, ".agenthub", name)); return `${s.mtimeMs}:${s.size}`; } catch { return "-"; } }).join("|");
4623
4656
  if (storedConfigCache?.stamp !== stamp) {
4624
4657
  let loaded: HubConfig | undefined;
4625
- try { loaded = loadConfig(opts.cwd); } catch { /* the rows say that the files do not load */ }
4658
+ try { loaded = loadConfig(opts.cwd, undefined, undefined, externalDefinitions); } catch { /* the rows say that the files do not load */ }
4626
4659
  storedConfigCache = { stamp, config: loaded };
4627
4660
  }
4628
4661
  return storedConfigCache.config;
@@ -4645,7 +4678,7 @@ export async function startDaemon(opts: DaemonOptions) {
4645
4678
  const rows = settingRows({
4646
4679
  instances: declarations, adapterCatalog, cwd: opts.cwd, routing: activeRouting(), ...(routingProblem ? { routingProblem } : {}), ...(stored?.ignored ? { ignored: stored.ignored } : {}),
4647
4680
  running: (path) => path[0] === "roster" ? onRoster(path[1]!) : path[0] === "tasks" && path[1] === "wip_limits" ? tasks.wipLimit(path[2]!) : valueAt(config, path),
4648
- next: stored ? (path) => path[0] === "roster" ? rosterAllows(stored.roster, path[1]!, stored.pi.enabled, declarations, adapterCatalog) : path[0] === "tasks" && path[1] === "wip_limits" ? (Object.hasOwn(stored.tasks.wip_limits ?? {}, path[2]!) ? stored.tasks.wip_limits![path[2]!] : 1) : valueAt(stored, path) : undefined,
4681
+ next: stored ? (path) => path[0] === "roster" ? projectRosterAllows(stored.roster, path[1]!, stored.pi.enabled) : path[0] === "tasks" && path[1] === "wip_limits" ? (Object.hasOwn(stored.tasks.wip_limits ?? {}, path[2]!) ? stored.tasks.wip_limits![path[2]!] : 1) : valueAt(stored, path) : undefined,
4649
4682
  roles: roleState,
4650
4683
  mode: (peer) => {
4651
4684
  const owner = bus.peers.get(peer), attached = !!owner && owner.state !== "offline";
@@ -4698,10 +4731,12 @@ export async function startDaemon(opts: DaemonOptions) {
4698
4731
  if (rosterChanging) return { ok: false, error: `roster change for ${rosterChanging} is in progress` };
4699
4732
  const peer = def.path[1]!;
4700
4733
  let saved = false;
4734
+ // #225: a stale key has no peer behind it. What changes is that the key is deleted, not whether the peer is on the roster.
4735
+ const stale = staleRosterKey(def.key) !== undefined, previousKey = config.roster[peer], sharedCopy = stale && sharedValue(opts.cwd, def) !== undefined;
4701
4736
  const terminalNotice = retainedTerminalNotice(peer);
4702
4737
  let handoff: Awaited<ReturnType<Tasks["reassignForRoster"]>> = [];
4703
4738
  try {
4704
- const next = rosterCandidate(def, value, undo), before = onRoster(peer), after = rosterAllows(next, peer, config.pi.enabled, declarations, adapterCatalog);
4739
+ const next = rosterCandidate(def, value, undo), before = onRoster(peer), after = projectRosterAllows(next, peer, config.pi.enabled);
4705
4740
  if (!after) {
4706
4741
  const refusal = rosterRemovalRefusal(peer, now);
4707
4742
  if (refusal) return { ok: false, error: refusal };
@@ -4715,7 +4750,7 @@ export async function startDaemon(opts: DaemonOptions) {
4715
4750
  config.roster = next;
4716
4751
  settlePlanner(); // #455: an automatic planner follows the roster
4717
4752
  saved = true;
4718
- recordSetting(def.key, before, after, source, undo, reason);
4753
+ recordSetting(def.key, stale ? previousKey ?? null : before, stale ? null : after, source, undo, reason);
4719
4754
  bus.rosterChanged(peer); // fence the queue before stopping or detaching the adapter
4720
4755
  if (!after) {
4721
4756
  for (const pending of permissions.values()) if (pending.peer === peer) pending.done(undefined);
@@ -4740,6 +4775,7 @@ export async function startDaemon(opts: DaemonOptions) {
4740
4775
  const reloaded = await reloadConfig(authority, `roster.${peer}`);
4741
4776
  if (reloaded.ok !== true) return { ...reloaded, saved: true };
4742
4777
  writeStatus();
4778
+ if (stale) return { ok: true, text: [`${peer}: stale roster key removed from .agenthub/config.local.json`, ...(sharedCopy ? [`still set in .agenthub/config.json: that copy decides nothing, and stays until you edit it there`] : [])].join("\n") };
4743
4779
  const stayed = !after ? board.list().filter(task => !isClosedTaskState(task.state) && (task.owner === peer || task.reviewer === peer)) : [];
4744
4780
  return { ok: true, text: [`${peer}: ${after ? "on" : "off"} roster (in force now)`, ...(!after && terminalNotice ? [terminalNotice] : []), ...(after && definitionOf(peer)?.cli.rosterReturnNotice ? [definitionOf(peer)!.cli.rosterReturnNotice!(codexResumeCommand(peer))] : []), ...(!after ? handoff.map(move => sanitize(`task ${move.title}: ${move.role} assignment moved to ${move.to}`)) : []), ...stayed.map(task => sanitize(`task ${tasks.publicTitle(task)} stays with ${peer} as ${[task.owner === peer ? "owner" : "", task.reviewer === peer ? "reviewer" : ""].filter(Boolean).join(" and ")} (${task.state})`)), ...(!after && !handoff.length && !stayed.length ? ["No open tasks to hand over."] : [])].join("\n"), ...(!after ? { handoff } : {}) };
4745
4781
  } catch (error) {
@@ -4752,6 +4788,12 @@ export async function startDaemon(opts: DaemonOptions) {
4752
4788
  const def = settingDef(key);
4753
4789
  if (typeof def === "string") return { error: def };
4754
4790
  const routing = activeRouting();
4791
+ if (staleRosterKey(def.key) && raw !== null) return { error: `${def.key} names an instance that is no longer declared: it can only be removed (ahub roster remove ${def.path[1]})` };
4792
+ // A removal only edits the machine-local file. A key that lives only in the shared one would be "removed" and come back on reload.
4793
+ if (staleRosterKey(def.key) && !storedLocally(opts.cwd, def)) return { error: `${def.key} is not in .agenthub/config.local.json, so removing it here changes nothing: edit .agenthub/config.json` };
4794
+ // Removing the local copy would let a different shared value show through, which the reload refuses as setting a stale key, after the write.
4795
+ const shared = staleRosterKey(def.key) ? sharedValue(opts.cwd, def) : undefined;
4796
+ if (shared !== undefined && shared !== config.roster[def.path[1]!]) return { error: `${def.key} is also set to ${String(shared)} in .agenthub/config.json: edit .agenthub/config.json first; nothing was changed` };
4755
4797
  try { return { def, value: checkSettingValue(def, raw, def.type === "roles" ? compatibleRoles(def.path[1]!) : Object.keys(routing.hub_routes ?? {})) }; }
4756
4798
  catch (error) { return { error: (error as Error).message }; }
4757
4799
  }
@@ -4848,9 +4890,12 @@ export async function startDaemon(opts: DaemonOptions) {
4848
4890
  const input = settingInput(key, raw);
4849
4891
  if ("error" in input) return { ok: false, error: input.error };
4850
4892
  const { def, value } = input;
4893
+ if (def.group === "Roster" && staleRosterKey(def.key)) {
4894
+ return { ok: true, lines: [`${def.path[1]}: no longer declared; its roster key decides nothing today.`, `Removing it deletes roster.${def.path[1]} from .agenthub/config.local.json and nothing else.`, ...(sharedValue(opts.cwd, def) !== undefined ? [`.agenthub/config.json also holds roster.${def.path[1]}: that copy stays until you edit it there.`] : [])] };
4895
+ }
4851
4896
  if (def.group === "Roster") {
4852
4897
  try {
4853
- const peer = def.path[1]!, next = rosterCandidate(def, value), on = rosterAllows(next, peer, config.pi.enabled, declarations, adapterCatalog);
4898
+ const peer = def.path[1]!, next = rosterCandidate(def, value), on = projectRosterAllows(next, peer, config.pi.enabled);
4854
4899
  return { ok: true, lines: [`${peer}: ${onRoster(peer) ? "on" : "off"} -> ${on ? "on" : "off"} roster`, ...(on ? ["Admission, starts and new work become available; any existing pause or held delivery remains."] : [`${bus.queued(peer)} queued messages are retained until the peer returns.`, "Owned runtimes stop; native terminal sessions detach. Open tasks are offered to another eligible peer.", `For a busy peer, use ahub roster remove ${peer} --now explicitly; this stops its owned runtime or detaches its native session during the active turn.`, ...(retainedTerminalNotice(peer) ? [retainedTerminalNotice(peer)!] : []), ...tasks.rosterPreview(peer).map(move => sanitize(`task ${move.title}: ${move.to ? `${move.role} offered to ${move.to} if removed now` : `${move.role} stays with ${peer}; no eligible replacement`}`)), ...(rosterRemovalRefusal(peer, false) ? [rosterRemovalRefusal(peer, false)!] : [])])], ...(on ? {} : { confirm: peer }) };
4855
4900
  } catch (error) { return { ok: false, error: sanitize((error as Error).message) }; }
4856
4901
  }
@@ -5009,6 +5054,7 @@ export async function startDaemon(opts: DaemonOptions) {
5009
5054
  const incompatibleRecoveryTarget = () => {
5010
5055
  const cancelled = incompatibleCancellationTarget();
5011
5056
  if (cancelled) return cancelled;
5057
+ if (externalIds.size && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("external_adapter_modules"))) return "target lacks external_adapter_modules; verified external definitions cannot be preserved by that daemon";
5012
5058
  if (configuredIds.length && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("configured_acp"))) return "target lacks configured_acp; project adapter definitions cannot be preserved by that daemon";
5013
5059
  if (extraIds.length && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("peer_instances"))) return "target lacks peer_instances; declared instance identities cannot be restored by that daemon";
5014
5060
  if (board.releases().some(release => release.train && ["active", "frozen"].includes(release.state)) && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("release_trains"))) return "target lacks release_trains; active or frozen train safeguards cannot be preserved";
@@ -5171,8 +5217,11 @@ export async function startDaemon(opts: DaemonOptions) {
5171
5217
  if (nativeUnattended(peer)) return { ok: false, error: "Codex started --unattended already skips prompts; run ahub permission codex ask after it stops to clear the hub choice, then restart without --unattended" };
5172
5218
  }
5173
5219
  const from = permissionMode(peer);
5220
+ const runtime: PeerAdapter = owner;
5221
+ const nativeMode = runtime.permissionModeState;
5222
+ if (nativeMode === "unknown" || nativeMode === "unmanaged") return { ok: false, error: `${peer} permission mode ${nativeMode}; inspect its native policy before changing modes` };
5174
5223
  try {
5175
- if (isAcpPeer(owner) || isCodexPeer(owner)) await owner.setPermissionMode(mode);
5224
+ if (runtime.setPermissionMode) await runtime.setPermissionMode(mode);
5176
5225
  } catch (error) { return { ok: false, error: (error as Error).message }; }
5177
5226
  if (bus.peers.get(peer) !== owner || bus.stateOf(peer) === "offline") return { ok: false, error: `${peer} session changed; inspect status and retry` };
5178
5227
  permissionModes.set(peer, mode);
@@ -5710,6 +5759,13 @@ export async function startDaemon(opts: DaemonOptions) {
5710
5759
  if (msg.op === "export") {
5711
5760
  try { return void reply({ ok: true, export: tasks.workflowExport(msg.id) }); } catch { return void reply({ ok: false, error: "workflow export unavailable" }); }
5712
5761
  }
5762
+ if (msg.op === "accept-departure") {
5763
+ void tasks.acceptPlanDeparture(USER, msg.id, msg.head, msg.reason).then(
5764
+ () => reply({ ok: true }),
5765
+ () => reply({ ok: false, error: "plan departure not accepted: task changed, current complete file evidence unavailable, or arguments invalid" }),
5766
+ );
5767
+ return;
5768
+ }
5713
5769
  if (msg.op === "accept") {
5714
5770
  // #481 S1: the person's spec acceptance, bound to the issue's body edit time; no body is read.
5715
5771
  if (!Number.isSafeInteger(msg.id) || msg.id <= 0 || !Number.isSafeInteger(msg.issue) || msg.issue <= 0 || (msg.repo !== undefined && typeof msg.repo !== "string")) return void reply({ ok: false, error: "usage: ahub sdlc accept <task> --issue <n> [--repo owner/repository]" });
package/src/hub/facts.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { repoGitArgs, repoGitEnv, type Repo } from "./snapshots.ts";
2
2
  import { isHubWritePath } from "./permission-mode.ts";
3
3
  import { createHash, randomUUID } from "node:crypto";
4
- import { closeSync, constants, fstatSync, mkdirSync, openSync, readSync, rmSync, statSync, writeFileSync } from "node:fs";
4
+ import { closeSync, constants, fstatSync, mkdirSync, openSync, readSync, rmSync, statSync, writeFileSync, type Stats } from "node:fs";
5
5
  import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
6
6
  import { sanitize, type PeerId } from "./envelope.ts";
7
7
  import { isDenied } from "../local/deny.ts";
@@ -24,6 +24,22 @@ import { realPath } from "./project.ts";
24
24
  const MAX_BYTES = 256 * 1024;
25
25
  /** Larger files that are not read are hashed for the HEAD comparison while all of them in one comparison total this much or less; beyond it, none is compared. */
26
26
  const HASHED_BYTES = 64 * 1024 * 1024;
27
+ /**
28
+ * A large file whose last change (ctime) is this recent may still be rewritten with the same stat: Linux before 6.13
29
+ * stamps files from a coarse clock, so a same-size rewrite within one tick keeps size, mtime, ctime and inode (#428).
30
+ * Such a file is named by its bytes, as git treats a racily clean entry: at most RACY_HASH_MAX_BYTES each, hashed once
31
+ * per version in a public entry point, RACY_BURST_BYTES in all per store (Facts instance) per event-loop turn (the hooks
32
+ * run on the daemon's loop). The HEAD comparison keeps its own bound (HASHED_BYTES) and is not limited by these.
33
+ * ponytail: ceilings, each falling back to the stat-only stamp (no claim either way): a racy file over the per-file cap,
34
+ * or past the turn's budget, misses a same-size rewrite within one timestamp tick; a racy file first seen past the
35
+ * budget (kept stat-only) reads as changed once when a later turn hashes it with the same bytes; a file written continuously never settles and is hashed at every look, within that budget; FAT's 2 s mtime
36
+ * is the widest covered; a settled file made racy with the same bytes (chmod, `cp -p`, touch) reads as changed once; a
37
+ * version first seen racy costs an open and a stat at every later look (largeBlob's cache answers). A per-file history
38
+ * of racy versions if any of these matters.
39
+ */
40
+ const RACY_MS = 2000;
41
+ const RACY_HASH_MAX_BYTES = 4 * 1024 * 1024;
42
+ const RACY_BURST_BYTES = 16 * 1024 * 1024;
27
43
  /** Changed lines shown in one fact, all files together; the rest is counted and the files named. */
28
44
  export const MAX_LINES = 60;
29
45
  /** What Claude Code's Read returns by default: the first 2000 lines, each cut at 2000 characters. */
@@ -145,6 +161,10 @@ export class Facts {
145
161
  private readonly seenDropped = new Map<PeerId, Set<string>>();
146
162
  /** Git blob ids of large files, by path, with the stat they were taken at. */
147
163
  private readonly largeBlobs = new Map<string, { stamp: string; blob: string }>();
164
+ /** Blob ids of racy large files hashed in the current boundary, by path and stat (#428). */
165
+ private racyMemo = new Map<string, string>();
166
+ /** Bytes of racy large files left to hash in this event-loop turn. */
167
+ private racyLeft?: { bytes: number };
148
168
  /** Directories each peer acknowledged being told were cut: once per session, not at every boundary. */
149
169
  private readonly cutTold = new Map<PeerId, Set<string>>();
150
170
  /**
@@ -217,7 +237,7 @@ export class Facts {
217
237
  * ponytail: a directory swapped between the containment check and the open is followed once; an fd-relative walk
218
238
  * (openat per component) closes that if it matters.
219
239
  */
220
- private load(file: string): Omit<Version, "seq"> {
240
+ private load(file: string, statOnly = false): Omit<Version, "seq"> {
221
241
  if (this.rel(join(this.root, file)) !== file) return { hash: "missing" };
222
242
  const projected = this.o.projection?.(join(this.root, file));
223
243
  if (projected) return { hash: projected.rawHash, blob: projected.rawBlob, text: projected.text, projected: true };
@@ -226,7 +246,10 @@ export class Facts {
226
246
  fd = openSync(join(this.root, file), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
227
247
  const st = fstatSync(fd);
228
248
  if (!st.isFile()) return { hash: "missing" }; // a directory, a device or a fifo: never read
229
- if (st.size > MAX_BYTES) return { hash: `large:${st.size}:${st.mtimeMs}` };
249
+ if (st.size > MAX_BYTES) {
250
+ const blob = statOnly ? undefined : this.racyBlob(file, fd, st);
251
+ return blob ? { hash: `large:${st.size}:${st.mtimeMs}:${blob}`, blob } : { hash: `large:${st.size}:${st.mtimeMs}` };
252
+ }
230
253
  const buf = Buffer.alloc(st.size);
231
254
  let got = 0;
232
255
  while (got < buf.length) {
@@ -317,13 +340,17 @@ export class Facts {
317
340
  const seen = files.filter((f) => head.has(f)).map((f) => [f, version(f)] as const);
318
341
  const large = seen.reduce((n, [, v]) => n + (v?.hash.startsWith("large:") ? Number(v.hash.split(":")[1]) : 0), 0);
319
342
  return new Set(seen.filter(([f, v]) => {
320
- const blob = v?.blob ?? (v?.hash.startsWith("large:") && large <= HASHED_BYTES ? this.largeBlob(f, v.hash) : undefined);
343
+ const blob = !v?.hash.startsWith("large:") ? v?.blob : large <= HASHED_BYTES ? v.blob ?? this.largeBlob(f, v.hash, true) : undefined;
321
344
  return blob !== undefined && blob === head.get(f);
322
345
  }).map(([f]) => f));
323
346
  }
324
347
 
325
348
  /** Git's blob id of a file too large to load, streamed; undefined for anything that is not a small enough regular file. */
326
- private largeBlob(file: string, observed: string): string | undefined {
349
+ /**
350
+ * `head`: for the HEAD comparison, which bounds its own hashing (HASHED_BYTES): a racy file is streamed whatever the
351
+ * racy cap and the turn's budget, uncached. Otherwise a racy file goes through racyBlob's limits.
352
+ */
353
+ private largeBlob(file: string, observed: string, head = false): string | undefined {
327
354
  if (this.rel(join(this.root, file)) !== file) return undefined;
328
355
  let fd: number | undefined;
329
356
  try {
@@ -333,13 +360,10 @@ export class Facts {
333
360
  // A file that stays stat-dirty but equal to HEAD is looked at again at every boundary: hashed once per version.
334
361
  const stamp = `${st.ino}:${st.size}:${st.mtimeMs}:${st.ctimeMs}`, known = this.largeBlobs.get(file);
335
362
  if (known?.stamp === stamp) return known.blob;
336
- const h = createHash("sha1").update(`blob ${st.size}\0`);
337
- const buf = Buffer.alloc(1024 * 1024);
338
- let got = 0;
339
- for (let n = 0; got < st.size && (n = readSync(fd, buf, 0, buf.length, got)) > 0; got += n) h.update(buf.subarray(0, n));
340
- if (got !== st.size) return undefined; // changed while read: no claim
341
- const blob = h.digest("hex");
342
- this.largeBlobs.set(file, { stamp, blob });
363
+ // A racy stamp may name other bytes later: never cached.
364
+ if (Date.now() - st.ctimeMs < RACY_MS) return head ? this.racyMemo.get(racyKey(file, st)) ?? streamBlob(fd, st.size) : this.racyBlob(file, fd, st);
365
+ const blob = streamBlob(fd, st.size);
366
+ if (blob) this.largeBlobs.set(file, { stamp, blob });
343
367
  return blob;
344
368
  } catch {
345
369
  return undefined;
@@ -348,6 +372,22 @@ export class Facts {
348
372
  }
349
373
  }
350
374
 
375
+ /** A racy large file's blob id (#428), or undefined when it is settled, over the per-file cap or past the turn's budget. */
376
+ private racyBlob(file: string, fd: number, st: Stats): string | undefined {
377
+ if (Date.now() - st.ctimeMs >= RACY_MS || st.size > RACY_HASH_MAX_BYTES) return undefined;
378
+ const key = racyKey(file, st), known = this.racyMemo.get(key);
379
+ if (known) return known;
380
+ if (!this.racyLeft) {
381
+ const turn = this.racyLeft = { bytes: RACY_BURST_BYTES };
382
+ setImmediate(() => { if (this.racyLeft === turn) this.racyLeft = undefined; });
383
+ }
384
+ if (st.size > this.racyLeft.bytes) return undefined;
385
+ this.racyLeft.bytes -= st.size;
386
+ const blob = streamBlob(fd, st.size);
387
+ if (blob) this.racyMemo.set(key, blob);
388
+ return blob;
389
+ }
390
+
351
391
  /** `file` is covered again: an offer still unread that named it as dropped must not spend the notice of a later drop. */
352
392
  private coveredAgain(peer: PeerId, notice: "untracked" | "unfollowed", file: string): void {
353
393
  for (const o of this.offers.get(peer) ?? []) if (o[notice]?.includes(file)) o[notice] = o[notice]!.filter((f) => f !== file);
@@ -357,6 +397,7 @@ export class Facts {
357
397
  private boundary(): void {
358
398
  this.expanded = new Map();
359
399
  this.expandedCut = new Set();
400
+ this.racyMemo = new Map(); // not per turn: a rewrite between two entry points of one turn is another version
360
401
  }
361
402
 
362
403
  /** `peer` worked on `file`; `seen` is the file as it was when it did, the baseline if it has no view of it yet. */
@@ -463,6 +504,12 @@ export class Facts {
463
504
  const now = this.load(file);
464
505
  const was = this.latest.get(file);
465
506
  if (was && was.hash === now.hash) return was;
507
+ // Seen while racy (named by its bytes), now with the same stat but no blob (settled, or past the turn's budget): the
508
+ // same version if the bytes are, or when they cannot be hashed now (no claim).
509
+ if (was?.blob && was.hash === `${now.hash}:${was.blob}`) {
510
+ const blob = this.largeBlob(file, now.hash);
511
+ if (blob === undefined || blob === was.blob) return was;
512
+ }
466
513
  const version = { ...now, seq: ++this.seq };
467
514
  this.latest.set(file, version);
468
515
  if (was) {
@@ -720,7 +767,9 @@ export class Facts {
720
767
  // does, nor what they wrote before or after (a member settling keeps its files in the set).
721
768
  const written = windows.flatMap((w) => [...(this.writes.get(w.peer) ?? [])].filter(([, times]) => times.some((t) => t >= w.since && (w.until === undefined || t <= w.until))).map(([file]) => file));
722
769
  const files = [...new Set([...this.expand(paths.flatMap((p) => { const r = this.rel(p); return r ? [r] : []; }), true), ...written])].sort();
723
- for (const file of files) h.update(file).update("\0").update(this.load(file).hash).update("\0");
770
+ // ponytail: a large file counts by its stat, racy or not, so the target does not move when it settles; a same-size
771
+ // rewrite within one timestamp tick does not move it either (observe() sees that one).
772
+ for (const file of files) h.update(file).update("\0").update(this.load(file, true).hash).update("\0");
724
773
  return h.digest("hex");
725
774
  }
726
775
 
@@ -871,3 +920,17 @@ export function codexEffect(before: string | undefined, now: { hash: string; tex
871
920
  const observed = changedLines(diff(before, now.text));
872
921
  return reported.length > 0 && reported.length === observed.length && reported.every((l, i) => l === observed[i]);
873
922
  }
923
+
924
+ /** Git's blob id of the `size` bytes `fd` reads, streamed; undefined when fewer were read (changed while read). */
925
+ function streamBlob(fd: number, size: number): string | undefined {
926
+ const h = createHash("sha1").update(`blob ${size}\0`);
927
+ const buf = Buffer.alloc(1024 * 1024);
928
+ let got = 0;
929
+ for (let n = 0; got < size && (n = readSync(fd, buf, 0, buf.length, got)) > 0; got += n) h.update(buf.subarray(0, n));
930
+ return got === size ? h.digest("hex") : undefined;
931
+ }
932
+
933
+ /** A racy large file's version within one boundary: its path and every stat field. */
934
+ function racyKey(file: string, st: Stats): string {
935
+ return `${file}\0${st.ino}:${st.size}:${st.mtimeMs}:${st.ctimeMs}`;
936
+ }