@staix/agent-hub 0.12.42 → 0.12.44

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 (100) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +5 -4
  3. package/docs/adding-an-ai-tool.md +140 -0
  4. package/docs/agent-notes/README.md +5 -0
  5. package/docs/agent-notes/adapters.md +11 -4
  6. package/docs/agent-notes/budget.md +2 -2
  7. package/docs/agent-notes/bus.md +2 -0
  8. package/docs/agent-notes/daemon.md +15 -6
  9. package/docs/agent-notes/tasks.md +7 -2
  10. package/docs/agent-notes/tests.md +6 -2
  11. package/docs/operations.md +159 -26
  12. package/docs/quickstart.md +11 -3
  13. package/docs/sdlc.md +1 -1
  14. package/docs/security.md +7 -5
  15. package/docs/specs/2026-09-19-agent-hub-design.md +209 -13
  16. package/docs/verification/2026-10-21-0.12.43.md +50 -0
  17. package/docs/verification/2026-10-21-0.12.44.md +54 -0
  18. package/docs/verification/2026-10-21-agy-1.4.3-t0.md +65 -0
  19. package/docs/verification/2026-10-21-grok-config-acp-t0.md +30 -0
  20. package/docs/verification/2026-10-21-pii-screen.md +14 -0
  21. package/docs/verified.json +34 -19
  22. package/package.json +1 -1
  23. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  24. package/plugins/agent-hub/server.js +808 -255
  25. package/src/adapters/acp-owned-group.ts +124 -0
  26. package/src/adapters/acp.ts +92 -25
  27. package/src/adapters/agy/definition.ts +1 -3
  28. package/src/adapters/agy/entry.ts +4 -1
  29. package/src/adapters/agy/hooks.ts +7 -2
  30. package/src/adapters/channel-peer.ts +143 -0
  31. package/src/adapters/claude-channel.ts +7 -7
  32. package/src/adapters/codex-appserver.ts +16 -2
  33. package/src/adapters/configured-acp.ts +124 -0
  34. package/src/adapters/definitions/builtin-metadata.ts +37 -33
  35. package/src/adapters/definitions/native-channel-state.ts +41 -0
  36. package/src/adapters/definitions/native-cli-entry-kimi.ts +3 -1
  37. package/src/adapters/definitions/native-cli-preview-qwen.ts +1 -1
  38. package/src/adapters/definitions/native-identity-defaults.ts +14 -0
  39. package/src/adapters/definitions/qwen.ts +3 -8
  40. package/src/adapters/definitions/reviewer-variants.ts +3 -7
  41. package/src/adapters/jcode.ts +4 -0
  42. package/src/adapters/local-worker.ts +4 -0
  43. package/src/adapters/pi.ts +50 -16
  44. package/src/adapters/registry-types.ts +18 -16
  45. package/src/adapters/registry.ts +68 -6
  46. package/src/adapters/reviewer-print.ts +4 -0
  47. package/src/cli/agy-mcp.ts +3 -2
  48. package/src/cli/completion.ts +173 -0
  49. package/src/cli/configured-acp-entry.ts +58 -0
  50. package/src/cli/console-state.ts +116 -24
  51. package/src/cli/console.ts +9 -6
  52. package/src/cli/facts-hook.ts +2 -1
  53. package/src/cli/help.ts +8 -1
  54. package/src/cli/identity.ts +8 -7
  55. package/src/cli/launch.ts +5 -4
  56. package/src/cli/main.js +5 -1
  57. package/src/cli/main.ts +84 -27
  58. package/src/cli/memory.ts +13 -10
  59. package/src/cli/output.ts +1 -0
  60. package/src/cli/preview.ts +9 -0
  61. package/src/cli/recovery-guidance.ts +1 -1
  62. package/src/cli/terminal-recovery.ts +33 -21
  63. package/src/cli/upgrade-restore-state.ts +3 -2
  64. package/src/cli/upgrade-runtime.ts +21 -6
  65. package/src/cli/upgrade.ts +3 -2
  66. package/src/hub/board.ts +9 -6
  67. package/src/hub/budget.ts +9 -2
  68. package/src/hub/bus.ts +6 -2
  69. package/src/hub/claude-state.ts +2 -37
  70. package/src/hub/conductor.ts +8 -6
  71. package/src/hub/config-apply.ts +26 -14
  72. package/src/hub/config-reload.ts +39 -11
  73. package/src/hub/config-trust.ts +1 -1
  74. package/src/hub/context-window.ts +6 -0
  75. package/src/hub/control-client.ts +1 -1
  76. package/src/hub/crash.ts +8 -6
  77. package/src/hub/daemon.ts +471 -338
  78. package/src/hub/hub-tools.ts +37 -1
  79. package/src/hub/instance-config.ts +20 -11
  80. package/src/hub/instance-execution.ts +4 -4
  81. package/src/hub/instances.ts +45 -21
  82. package/src/hub/lifecycle-schema.ts +5 -3
  83. package/src/hub/main-checkout.ts +2 -2
  84. package/src/hub/manager.ts +10 -2
  85. package/src/hub/memory-profile.ts +16 -3
  86. package/src/hub/no-writes.ts +50 -0
  87. package/src/hub/permission-mode.ts +9 -7
  88. package/src/hub/roster.ts +7 -4
  89. package/src/hub/routing.ts +21 -20
  90. package/src/hub/settings.ts +51 -17
  91. package/src/hub/snapshots.ts +276 -17
  92. package/src/hub/start-mode.ts +10 -8
  93. package/src/hub/task-sweep.ts +8 -2
  94. package/src/hub/tasks.ts +101 -19
  95. package/src/memory/capture.ts +2 -1
  96. package/src/ui/index.html +5 -0
  97. package/templates/AGENTS.block.md +1 -1
  98. package/templates/profiles/general.json +1 -1
  99. package/templates/profiles/research.json +1 -1
  100. package/templates/profiles/writing.json +1 -1
package/src/hub/daemon.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { WsPeer, isWsPeer } from "../adapters/channel-peer.ts";
2
+ import { ownerToolEffect, canonicalWriteUnknown } from "./no-writes.ts";
1
3
  import { NativeHookLaunches } from "./native-hook-launch.ts";
2
4
  import { readReleaseHostAdmission, type ReleaseHostAdmission } from "./release-admission.ts";
3
5
  import { ContainerRunner, type RunnerOptions, type RunReceipt } from "./check-runner.ts";
@@ -20,17 +22,17 @@ import { buildReviewBrief } from "./review-brief.ts";
20
22
  import { profileName, projectProfile, resolveProfile, mergeProfileOverrides, type ProfileName } from "./profiles.ts";
21
23
  import { catalogAssignments, loadRoleCatalog, selectedRoles, roleIdentity } from "./roles.ts";
22
24
  import { ActivityRing } from "./activity.ts";
23
- import { JcodeOptionMismatch, JcodePeer } from "../adapters/jcode.ts";
25
+ import { JcodeOptionMismatch, isJcodePeer, type JcodePeer } from "../adapters/jcode.ts";
24
26
  import { jcodeMcpReady } from "../cli/jcode-mcp.ts";
25
27
  import { loadSdlc, readSpecEdit, sdlcGh } from "./sdlc.ts";
26
- import { claudeStateFile, ownTerminalRow, pathEvidence, transcriptPersisted } from "./claude-state.ts";
28
+ import { claudePeer as defaultChannelPeer, claudeStateFile, ownTerminalRow, pathEvidence, transcriptPersisted } from "./claude-state.ts";
27
29
  import { peerRosterConfig, rosterAllows, type PeerRoster } from "./roster.ts";
28
30
  import { KNOWN_PEERS, OPTIONAL_PEERS, PLANNER_PEERS, SUPPORTED_PEERS, START_MODE_PEERS, isSupportedPeer, STARTABLE_PEERS, isStartablePeer } from "../adapters/registry.ts";
29
31
  import { RecoveryDeferredHubError } from "./deferred-hub.ts";
30
32
  import { databaseBusySite, isDatabaseBusy, RecoveryDatabaseBusy } from "./recovery-error.ts";
31
- import { ADAPTERS, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS, QUOTA_HANDOFF_PLATFORMS, NATIVE_CONFIG_BLOCKS, nativeConfigurationDefaults, mergeNativeConfiguration, retainNativeRouting, DAEMON_PORTS, definitionOf } from "../adapters/registry.ts";
32
- import { isSupportedPeer as registrySupportedPeer, definitionOf as instanceDefinitionOf, KNOWN_PEERS as BASE_KNOWN_PEERS, SUPPORTED_PEERS as BASE_SUPPORTED_PEERS, STARTABLE_PEERS as BASE_STARTABLE_PEERS } from "../adapters/registry.ts";
33
- import { effectiveInstanceRoles, instancePeerIds, validateInstances, type InstancesConfig } from "./instances.ts";
33
+ import { ADAPTERS, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS, QUOTA_HANDOFF_PLATFORMS, NATIVE_CONFIG_BLOCKS, nativeConfigurationDefaults, mergeNativeConfiguration, retainNativeRouting, DAEMON_PORTS, definitionOf, createProjectAdapterCatalog } from "../adapters/registry.ts";
34
+ import { isSupportedPeer as registrySupportedPeer, definitionOf as builtinDefinitionOf, KNOWN_PEERS as BASE_KNOWN_PEERS, SUPPORTED_PEERS as BASE_SUPPORTED_PEERS, STARTABLE_PEERS as BASE_STARTABLE_PEERS } from "../adapters/registry.ts";
35
+ import { instanceDefinitionOf, effectiveInstanceRoles, instancePeerIds, validateInstances, type InstancesConfig } from "./instances.ts";
34
36
  import { INSTANCE_CONFIG_FILES, readInstanceConfig, readInstanceConfigLayers, readInstanceRoleConfig } from "./instance-config.ts";
35
37
  import { instanceBudgetHooks, projectBudgetHeadroom } from "./instance-budget.ts";
36
38
  import { resolveInstanceExecutionCwd } from "./instance-execution.ts";
@@ -45,16 +47,16 @@ import { childEnv } from "./child-process.ts";
45
47
  import { runCheck } from "./checks.ts";
46
48
  import { DEFAULT_TASK_SWEEP, taskSweepConfig, type TaskSweepConfig } from "./task-sweep.ts";
47
49
  import { ALL_MACHINE_LOCAL, configRepositoryPresent, configTracked, stripUntrusted } from "./config-trust.ts";
48
- import { authorizeConfigLeaf, configApply, configChanges, validateEffectiveConfig, validatePetConfig } from "./config-apply.ts";
50
+ import { authorizeConfigLeaf, configApply, configIdentityScope, configChanges, validateEffectiveConfig, validatePetConfig } from "./config-apply.ts";
49
51
  import { resolveSettledRoles } from "./settled-roles.ts";
50
52
  import { CONFIG_SOURCE_FILES, configFingerprint, configSourceTexts, parseConfigSources, type ConfigSources } from "./config-reload.ts";
51
53
  import { eventLog, readEvents, tokenDeltas, type RoleIdentity } from "./events.ts";
52
54
  import { ExecutionBudget } from "./execution-budget.ts";
53
55
  import { claudeReportedTokens, readClaudeTranscriptCompletion, readClaudeTranscriptUsage, waitForClaudeCompletion } from "./usage.ts";
54
56
  import type { ServerWebSocket } from "bun";
55
- import { AcpPeer, QwenIdentityError, type PermissionRequest } from "../adapters/acp.ts";
56
- import { CodexPeer } from "../adapters/codex-appserver.ts";
57
- import { PiPeer } from "../adapters/pi.ts";
57
+ import { isAcpPeer, type AcpPeer, QwenIdentityError, type PermissionRequest } from "../adapters/acp.ts";
58
+ import { isCodexPeer, type CodexPeer } from "../adapters/codex-appserver.ts";
59
+ import { isPiPeer, type PiPeer } from "../adapters/pi.ts";
58
60
  import { startModelRelay, type ModelRelay } from "../models/relay.ts";
59
61
  import { ensureMlx, type MlxOptions } from "../models/mlx.ts";
60
62
  import { PiToolReceipts, packPiShellResult } from "../pi/tool-receipts.ts";
@@ -64,8 +66,8 @@ import { PiSessionBookmarkMismatch, PiSessionUnresumable, recordPiReplacement, v
64
66
  import { profile, proxyEnv, type SandboxNetwork } from "../local/sandbox.ts";
65
67
  import { DEFAULT_NETWORK_ALLOW, startEgressProxy, type EgressProxy } from "../local/proxy.ts";
66
68
  import { runTool, toolResultFailed, TOOL_SCHEMAS, type ToolApproval, type ApprovalObserver, type ToolContext } from "../local/tools.ts";
67
- import { LocalPeer } from "../adapters/local-worker.ts";
68
- import { ReviewerPrintPeer } from "../adapters/reviewer-print.ts";
69
+ import { isLocalPeer, type LocalPeer } from "../adapters/local-worker.ts";
70
+ import { isReviewerPrintPeer, type ReviewerPrintPeer } from "../adapters/reviewer-print.ts";
69
71
  import { createReviewerMcpSession } from "../adapters/reviewer-mcp.ts";
70
72
  import { reviewerPoolConfig } from "./reviewer-pool-config.ts";
71
73
  import { reviewDiff } from "../adapters/claude-print.ts";
@@ -73,14 +75,14 @@ import { Capture, skipTools } from "../memory/capture.ts";
73
75
  import { DEFAULT_OMNIROUTE, OmniRoute, type OmniRouteConfig } from "../omniroute/client.ts";
74
76
  import { Briefs } from "../memory/brief.ts";
75
77
  import { Board, isClosedTaskState, taskProposalAt, taskHasStarted, CLASSES, type Task, type TaskClass } from "./board.ts";
76
- import { Budget, claudeWindows, codexWindows, DEFAULT_BUDGET, type BudgetConfig } from "./budget.ts";
78
+ import { Budget, readNativeQuota, DEFAULT_BUDGET, type BudgetConfig } from "./budget.ts";
77
79
  import { HUB } from "./envelope.ts";
78
80
  import { recallMemoryLines, trimToTokens } from "../memory/recall.ts";
79
81
  import { closeSync, constants as fsConstants, openSync, lstatSync, readSync, statSync } from "node:fs";
80
82
  import { basename, isAbsolute } from "node:path";
81
83
  import { fileURLToPath } from "node:url";
82
84
  import { realPath, hubHome } from "./project.ts";
83
- import { mainCheckoutWarning } from "./main-checkout.ts";
85
+ import { mainCheckoutBranch, mainCheckoutWarning } from "./main-checkout.ts";
84
86
  import type { BusEvent, PublishResult } from "./bus.ts";
85
87
  import { CONDUCTOR_TOOLS, CONDUCTOR_TOOL_NAMES, DEFAULT_ROLES, roleContract, TASK_TOOL_NAMES, TASK_TOOLS, BOUNDED_TASK_TOOLS, profileTaskTools } from "./hub-tools.ts";
86
88
  import { Conductor, ConductorHolds, conductorPeer, conductorProgressSink, publicConductorTask, publicPeerBudget, type ConductEvent } from "./conductor.ts";
@@ -110,7 +112,7 @@ import { Facts, FACTS_PREFIX, type FactScope } from "./facts.ts";
110
112
  import { crashPlan, lossNotice, readSessions, removeSessions, writeSessions, type SessionsFile } from "./crash.ts";
111
113
  import type { JournalDelivery } from "./delivery-journal.ts";
112
114
  import { DEFAULT_LIMITS, Limiter, PROJECT_LIMITS, type LimitsConfig } from "./limits.ts";
113
- import { changedPaths, hasTree, repoOf, snapshot, Turns, type TurnRecord } from "./snapshots.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";
114
116
  import { archiveRestartSnapshot, readRecoveryWaivers, readRestartSnapshot, removeRestartSnapshot, restartPath, writeRestartSnapshot, type RecoveryPhase, type RestartPeerSnapshot, type RestartSnapshot } from "./restart.ts";
115
117
  import { consumeRecoveryContinuity, readRecoveryContinuity, retireRecoveryContinuity } from "./recovery-continuity.ts";
116
118
  import { shellQuote, publishTerminalLaunch, terminalLaunchCandidate } from "../cli/terminal-recovery.ts";
@@ -120,7 +122,7 @@ import { peerStartConfig, PEER_START_RETRY_MS, startModeOf, terminalTemplate, ty
120
122
  import { hasKimiHubMcp } from "../cli/kimi-mcp.ts";
121
123
  import type { TerminalOpener } from "./terminal-open.ts";
122
124
  import { grantablePath, instructionProtection, isPermissionMode, permissionBoundary, permissionDefaults, permissionGrant, PI_EDIT_TOOLS, type PermissionMode } from "./permission-mode.ts";
123
- import { ContextWindows, DEFAULT_CONTEXT, acpContext, claudeContext, type ContextConfig } from "./context-window.ts";
125
+ import { ContextWindows, DEFAULT_CONTEXT, readNativeContext, type ContextConfig } from "./context-window.ts";
124
126
  import { projectFileTool, scopedWriteBlocked } from "../cli/facts-hook.ts";
125
127
  import { detectTooling } from "./tooling.ts";
126
128
  import { looksSecret, DEFAULT_LEASE_MS, extractNodes, memoryHome, SUGGESTED, taskKey, type CandidateStore, type MemoryStore, type GoalView, type Suggested, type View as MemoryView } from "@staix/agent-mem";
@@ -132,6 +134,7 @@ import { nativeRecall } from "../memory/recall.ts";
132
134
  import { promotionScreen, withoutPii, mentionsPrivatePath } from "./memory-policy.ts";
133
135
 
134
136
  export interface HubConfig {
137
+ adapters?: Readonly<Record<string, import("../adapters/configured-acp.ts").ConfiguredAcpAdapterConfig>>;
135
138
  /** Person-owned declarations, read only from an untracked machine-local file. Bound for this daemon lifetime. */
136
139
  instances?: InstancesConfig;
137
140
  watchdog_ms: number;
@@ -251,7 +254,8 @@ export const DEFAULT_CONFIG: HubConfig = {
251
254
  export { stateDirFor };
252
255
 
253
256
  /** Ids an external process may not claim: the console user and the adapters the daemon runs itself. */
254
- const BASE_RESERVED_IDS = new Set<string>([USER, ...SUPPORTED_PEERS.filter(peer => definitionOf(peer)?.identity.mcp !== "channel-plugin"), HUB, DIGEST]);
257
+ const READ_ONLY_HUB_TOOLS = new Set(["hub_inbox", "hub_send", "hub_delivery_done", "hub_review", "hub_task_show", "hub_task_list", "hub_memory_search", "hub_memory_show", "hub_memory_graph", "hub_status"]);
258
+ const BASE_RESERVED_IDS = new Set<string>([USER, ...SUPPORTED_PEERS.filter(peer => definitionOf(peer)?.attach?.externalHello !== true), HUB, DIGEST]);
255
259
  const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
256
260
 
257
261
  /** The shared project config, then the machine's own file, which overrides it block by block (issue #17). */
@@ -322,6 +326,7 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
322
326
  const captured = layers ?? readInstanceConfigLayers(cwd);
323
327
  const instanceConfig = readInstanceConfig(cwd, captured);
324
328
  const instances = instanceConfig.instances;
329
+ const adapterCatalog = instanceConfig.catalog;
325
330
  const ignored: string[] = [...instanceConfig.ignored];
326
331
  const appliedPermissionSources: NonNullable<HubConfig["permission_default_sources"]> = {};
327
332
  const files = CONFIG_FILES.flatMap((name) => {
@@ -333,8 +338,9 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
333
338
  if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
334
339
  throw error;
335
340
  }
341
+ delete file.adapters; // Adapter provenance is compiled by the same project-local loader.
336
342
  delete file.instances; // Declaration authority was checked against its exact captured origin above.
337
- peerRosterConfig(file.roster, instances);
343
+ peerRosterConfig(file.roster, instances, adapterCatalog);
338
344
  catalogAssignments(file.catalog_roles);
339
345
  profileName(file.profile);
340
346
  resolveProfile(file.profile, file.profile_overrides);
@@ -349,8 +355,8 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
349
355
  for (const key of ["jcode_home", "jcode_provider_profile"]) if (file[key] !== undefined && typeof file[key] !== "string") throw new Error(`${key} must be a string`);
350
356
  if (file.qwen_cmd !== undefined && (!Array.isArray(file.qwen_cmd) || !file.qwen_cmd.length || file.qwen_cmd.some((word: unknown) => typeof word !== "string" || !word))) throw new Error("qwen_cmd must be a nonempty executable argv");
351
357
  instructionProtection(file.protect_instruction_files);
352
- permissionDefaults(file.permission_modes, instances); // malformed declarations fail even when trust would drop them
353
- peerStartConfig(file.peers, instances);
358
+ permissionDefaults(file.permission_modes, instances, adapterCatalog); // malformed declarations fail even when trust would drop them
359
+ peerStartConfig(file.peers, instances, adapterCatalog);
354
360
  if (file.terminal != null && (typeof file.terminal !== "object" || Array.isArray(file.terminal))) throw new Error("terminal must be an object");
355
361
  terminalTemplate(file.terminal?.open);
356
362
  if (file.pet !== undefined) validatePetConfig(file.pet, "config.pet");
@@ -358,7 +364,7 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
358
364
  delete file.permission_default_sources; // provenance is loader-owned, never a file claim
359
365
  const why = stripUntrusted(file, DEFAULT_CONFIG, cwd, name);
360
366
  if (why) ignored.push(why);
361
- for (const [peer, mode] of Object.entries(permissionDefaults(file.permission_modes, instances))) appliedPermissionSources[peer] = { mode, source: `.agenthub/${name}` };
367
+ for (const [peer, mode] of Object.entries(permissionDefaults(file.permission_modes, instances, adapterCatalog))) appliedPermissionSources[peer] = { mode, source: `.agenthub/${name}` };
362
368
  return [file];
363
369
  });
364
370
  if (!files.length) return routingRetired.length ? { ...DEFAULT_CONFIG, retired: [...routingRetired] } : DEFAULT_CONFIG;
@@ -388,7 +394,8 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
388
394
  const mlx = { ...(file.mlx?.provider === "legacy" ? { enabled: true, provider: "legacy" as const, maxInputTokens: 16_000, maxTokens: 2048 } : DEFAULT_CONFIG.mlx), ...file.mlx };
389
395
  const conductorPolicy = loadConductorPolicy(cwd, captured);
390
396
  retired.push(...conductorPolicy.notices ?? []);
391
- const extraRoles = Object.fromEntries(Object.keys(instances ?? {}).map(id => [id, effectiveInstanceRoles(id, instances, conductorPolicy.roles)]));
397
+ const configuredRoles = Object.fromEntries(adapterCatalog.definitions.filter(d => !definitionOf(d.kind)).map(d => [d.kind, [...d.roles]]));
398
+ const extraRoles = Object.fromEntries(Object.keys(instances ?? {}).map(id => [id, effectiveInstanceRoles(id, instances, conductorPolicy.roles, adapterCatalog)]));
392
399
  const instancePermissions: Record<string, PermissionMode> = {};
393
400
  const instanceModes: PeerStartConfig = {};
394
401
  for (const [id, declaration] of Object.entries(instances ?? {})) {
@@ -404,17 +411,18 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
404
411
  return {
405
412
  ...DEFAULT_CONFIG,
406
413
  ...file,
414
+ ...(instanceConfig.adapters ? { adapters: instanceConfig.adapters } : {}),
407
415
  ...(instances ? { instances } : {}),
408
416
  ...(file.text_projection !== undefined ? { text_projection: projectionConfig(file.text_projection) } : {}),
409
417
  ...(file.pii !== undefined ? { pii: { paths: policyPaths(file.pii.paths, "pii.paths") } } : {}),
410
418
  ...(file.data !== undefined ? { data: { readonly: policyPaths(file.data.readonly, "data.readonly") } } : {}),
411
- permission_modes: { ...instancePermissions, ...permissionDefaults(file.permission_modes, instances) },
419
+ permission_modes: { ...instancePermissions, ...permissionDefaults(file.permission_modes, instances, adapterCatalog) },
412
420
  protect_instruction_files: instructionProtection(file.protect_instruction_files),
413
421
  permission_default_sources: appliedPermissionSources,
414
422
  memory: { ...DEFAULT_CONFIG.memory, native: true, ...file.memory, ...(file.memory?.short_ttl_days !== undefined ? { short_ttl_days: ttlDays(file.memory.short_ttl_days) } : {}),
415
423
  ...(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)
416
- roster: peerRosterConfig(file.roster, instances),
417
- roles: { ...conductorPolicy.roles, ...extraRoles },
424
+ roster: peerRosterConfig(file.roster, instances, adapterCatalog),
425
+ roles: { ...configuredRoles, ...conductorPolicy.roles, ...extraRoles },
418
426
  planner: conductorPolicy.planner,
419
427
  conductor: conductorPolicy.conductor,
420
428
  budget: { ...DEFAULT_CONFIG.budget, wait_max_min: 30, ...file.budget }, // on with any project config (issue #36)
@@ -432,7 +440,7 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
432
440
  snapshots: { ...DEFAULT_CONFIG.snapshots, enabled: true, ...file.snapshots },
433
441
  research: { enabled: file.research?.enabled === true }, // opt-in: anything but true is off
434
442
  bench: { enabled: file.bench?.enabled === true }, // opt-in: a run resets this project's work tree
435
- peers: { ...instanceModes, ...peerStartConfig(file.peers, instances) },
443
+ peers: { ...instanceModes, ...peerStartConfig(file.peers, instances, adapterCatalog) },
436
444
  terminal: { open: terminalTemplate(file.terminal?.open) },
437
445
  limits: { ...PROJECT_LIMITS, ...file.limits }, // on with any project config (issue #38)
438
446
  review: { ...DEFAULT_CONFIG.review, ...file.review },
@@ -465,6 +473,10 @@ function mlxPinProblem(routing: Routing, project: boolean): string | undefined {
465
473
  }
466
474
 
467
475
  export interface DaemonOptions {
476
+ /** Host-only snapshot/probe seams; never read from project config or tool arguments. */
477
+ snapshot?: typeof snapshot;
478
+ snapshotBurstMs?: number;
479
+ checkoutBranch?: typeof mainCheckoutBranch;
468
480
  /** Host-only admission transport; never selected by project config or tools. */
469
481
  releaseHostAdmission?: (cwd: string) => Promise<ReleaseHostAdmission>;
470
482
  projectId?: string;
@@ -512,135 +524,6 @@ interface Client {
512
524
  }
513
525
  type Sock = ServerWebSocket<Client>;
514
526
 
515
- /** A peer that lives in another process and attaches over the control WS (the Claude channel plugin). */
516
- class WsPeer extends BasePeer {
517
- private generation = crypto.randomUUID();
518
- // Checkpoints become stale at hello/claim, before asynchronous recall finishes and attach changes delivery generation.
519
- private claimGeneration = crypto.randomUUID();
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
- }
525
- private delivered = new Set<string>();
526
- private sock: Sock | undefined;
527
- private claimed: Sock | undefined;
528
- private nativeSession: string | undefined;
529
- private nativeActive = false;
530
- /** The attached session said it cannot show channel pushes (issue #205): the bus keeps its queue for `inbox`. */
531
- pullOnly = false;
532
- steer: PeerAdapter["steer"] = undefined;
533
- lastInboxAt: number | undefined;
534
- private claimedPullOnly = false;
535
- private readonly attachmentWaiters: { generation: string; resolve: (attached: boolean) => void }[] = [];
536
- private settleAttachmentWaiters(attached: boolean): void {
537
- for (const waiter of this.attachmentWaiters.splice(0)) waiter.resolve(attached && waiter.generation === this.claimGeneration);
538
- }
539
- /** Bounded native-hook observations wait for this exact claim, never a later socket. */
540
- waitForAttach(generation: string): Promise<boolean> {
541
- if (generation !== this.claimGeneration || !this.claiming || this.attachmentWaiters.length >= 64) return Promise.resolve(false);
542
- return new Promise(resolve => { this.attachmentWaiters.push({ generation, resolve }); });
543
- }
544
- /** Called at hello, before the async preface: the newest hello wins even if an older one's recall finishes last. */
545
- claim(sock: Sock, pullOnly = false): void {
546
- this.settleAttachmentWaiters(false);
547
- this.claimGeneration = crypto.randomUUID();
548
- this.claimed = sock;
549
- this.claimedPullOnly = pullOnly;
550
- }
551
- /** A session that shows pushes holds the peer or is arriving: a session without them must not take it (issue #205). */
552
- get channelHeld(): boolean {
553
- return (!!this.sock && !this.pullOnly) || (this.claiming && !this.claimedPullOnly);
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
- }
559
- /** A hello that has not finished its preface yet. The peer reads as offline until then, and a session standing by
560
- * for the id must not take it from a claimant that is still arriving. */
561
- get claiming(): boolean {
562
- return !!this.claimed && this.claimed !== this.sock && this.claimed.readyState === WebSocket.OPEN;
563
- }
564
- attach(sock: Sock): void {
565
- if (sock !== this.claimed) return void sock.close(4000, "replaced"); // a newer session said hello meanwhile
566
- if (this.sock) this.setState("offline"); // unresolved work belongs to the previous connection
567
- this.sock?.close(4000, "replaced");
568
- this.sock = sock;
569
- this.generation = crypto.randomUUID();
570
- this.delivered.clear();
571
- this.nativeSession = undefined;
572
- this.nativeActive = false;
573
- this.pullOnly = this.claimedPullOnly; // before idle: the idle transition drains
574
- // Native TUI readback in #493 proves important channel input can be consumed before the turn ends.
575
- // Pull-only transports expose no steer capability; the bus keeps their important work queued.
576
- this.steer = this.pullOnly ? undefined : async (envs, deliveryId) => {
577
- if (this.state !== "busy" || !envs.length || envs.some(env => env.priority !== "important")) throw new Error("channel steering requires important work in the current busy turn");
578
- await this.deliver(envs, deliveryId);
579
- };
580
- this.lastInboxAt = undefined;
581
- this.setState("idle");
582
- this.settleAttachmentWaiters(true);
583
- }
584
- detach(sock: Sock): void {
585
- if (this.claimed === sock) { this.claimed = undefined; this.settleAttachmentWaiters(false); }
586
- if (this.sock !== sock) return;
587
- this.sock = undefined;
588
- this.steer = undefined;
589
- this.nativeActive = false;
590
- this.pullOnly = false;
591
- this.setState("offline");
592
- }
593
- detachForRoster(): void {
594
- this.settleAttachmentWaiters(false);
595
- this.claimed?.close(4000, "peer is off roster; retry when a person adds it");
596
- this.sock?.close(4000, "peer is off roster; retry when a person adds it");
597
- this.claimed = undefined;
598
- this.sock = undefined;
599
- this.steer = undefined;
600
- this.nativeActive = false;
601
- this.pullOnly = false;
602
- this.setState("offline");
603
- }
604
- async deliver(envs: Envelope[], deliveryId?: string): Promise<void> {
605
- if (!this.sock) throw new Error(`${this.id} is offline`);
606
- if (deliveryId) this.delivered.add(deliveryId);
607
- this.sock.send(JSON.stringify({ t: "deliver", envs, generation: this.generation, ...(deliveryId ? { deliveryId } : {}) }));
608
- }
609
- /** Advisory nudge (issue #398): important work waits in the pull queue. Not a delivery: nothing to settle or journal. */
610
- notifyQueued(counts: { queued: number; important: number }): void {
611
- if (!this.sock || !this.pullOnly) return;
612
- this.sock.send(JSON.stringify({ t: "queued", queued: counts.queued, important: counts.important }));
613
- }
614
- owns(sock: Sock): boolean { return this.sock === sock; }
615
- nativeStarted(session: string): void {
616
- if (!this.sock) return;
617
- this.nativeSession = session;
618
- this.nativeActive = true;
619
- this.setState("busy");
620
- }
621
- nativeStopped(session: string): boolean {
622
- if (!this.sock || (this.nativeSession !== undefined && this.nativeSession !== session)) return false;
623
- this.nativeSession = session;
624
- this.nativeActive = false;
625
- this.setState("idle");
626
- return true;
627
- }
628
- protected override onWatchdog(): void {
629
- // A silent native model/approval wait does not prove Stop. Retain the observed active turn until Stop/detach.
630
- if (this.nativeActive) { this.touch(); return; }
631
- super.onWatchdog();
632
- }
633
- ownsDelivery(sock: Sock, generation: unknown, id: string): boolean { return this.owns(sock) && generation === this.generation && this.delivered.has(id); }
634
- async start(): Promise<void> {}
635
- async stop(): Promise<void> {
636
- this.settleAttachmentWaiters(false);
637
- this.sock?.close();
638
- }
639
-
640
- recoveryMetadata(): Record<string, unknown> {
641
- return { launch: definitionOf(this.id)?.attach?.recoveryLaunch?.(this.id) ?? { kind: "claude-channel", peer: this.id } };
642
- }
643
- }
644
527
 
645
528
  export async function startDaemon(opts: DaemonOptions) {
646
529
  const projectId = opts.projectId ?? randomUUID();
@@ -653,21 +536,25 @@ export async function startDaemon(opts: DaemonOptions) {
653
536
  const lifecycleConfig = loadSdlc(opts.cwd, VERSION);
654
537
  // Preserve embedding-owned policy references, but never publish reloads into shared defaults.
655
538
  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;
656
- const declarations = validateInstances(loadedConfig.instances);
539
+ const adapterCatalog = createProjectAdapterCatalog(loadedConfig.adapters);
540
+ const configuredIds = adapterCatalog.definitions.filter(d => !builtinDefinitionOf(d.kind)).map(d => d.kind);
541
+ const declarations = validateInstances(loadedConfig.instances, adapterCatalog);
657
542
  const extraIds = Object.keys(declarations);
658
543
  // #450 I3: channel extras (Claude) attach from the person's terminal; the hub never starts them and their hello is admitted.
659
- const channelExtras = extraIds.filter(id => instanceDefinitionOf(id, declarations)?.identity.mcp === "channel-plugin");
660
- // Every Claude-family peer whose own state files the daemon reads: the builtin channel kinds, then their declared instances.
661
- const channelPeers = [...ADAPTERS.filter(definition => definition.identity.mcp === "channel-plugin").map(definition => definition.kind), ...channelExtras];
544
+ const channelExtras = extraIds.filter(id => adapterCatalog.definitionOf(id, declarations)?.attach?.externalHello === true);
545
+ // #624: every Claude peer whose own state files the daemon reads, keyed on the Claude transport, never on channel-plugin
546
+ // (agy is a channel-plugin peer with no Claude state): the builtin, then its declared instances.
547
+ const channelPeers = [...ADAPTERS.filter(definition => definition.transport === "claude-channel").map(definition => definition.kind),
548
+ ...channelExtras.filter(id => adapterCatalog.definitionOf(id, declarations)?.transport === "claude-channel")];
662
549
  const executionRoots = new Map<string, string>();
663
550
  const executionOwners = new Map<string, { owner: PeerAdapter; cwd: string }>();
664
551
  const reviewerUnavailable = new Map<string, string>();
665
- const roles = { ...loadedConfig.roles };
552
+ const roles = { ...Object.fromEntries(adapterCatalog.definitions.filter(d => configuredIds.includes(d.kind)).map(d => [d.kind, [...d.roles]])), ...loadedConfig.roles };
666
553
  const permissionModesFromInstances: Record<string, PermissionMode> = {};
667
554
  const startModesFromInstances: PeerStartConfig = {};
668
555
  const instancePermissionSources: NonNullable<HubConfig["permission_default_sources"]> = {};
669
556
  for (const [id, declaration] of Object.entries(declarations)) {
670
- roles[id] = effectiveInstanceRoles(id, declarations, loadedConfig.roles);
557
+ roles[id] = effectiveInstanceRoles(id, declarations, loadedConfig.roles, adapterCatalog);
671
558
  if (!channelExtras.includes(id)) startModesFromInstances[id] = { start_mode: "headless" };
672
559
  if (declaration.permission_mode !== undefined) {
673
560
  permissionModesFromInstances[id] = declaration.permission_mode;
@@ -675,19 +562,19 @@ export async function startDaemon(opts: DaemonOptions) {
675
562
  }
676
563
  }
677
564
  const config: HubConfig = { ...ownedConfig, ...(extraIds.length ? { instances: declarations } : {}),
678
- roster: peerRosterConfig(loadedConfig.roster, declarations),
679
- permission_modes: { ...permissionModesFromInstances, ...permissionDefaults(loadedConfig.permission_modes, declarations) },
680
- peers: { ...startModesFromInstances, ...peerStartConfig(loadedConfig.peers, declarations) },
565
+ roster: peerRosterConfig(loadedConfig.roster, declarations, adapterCatalog),
566
+ permission_modes: { ...permissionModesFromInstances, ...permissionDefaults(loadedConfig.permission_modes, declarations, adapterCatalog) },
567
+ peers: { ...startModesFromInstances, ...peerStartConfig(loadedConfig.peers, declarations, adapterCatalog) },
681
568
  ...(extraIds.length ? { permission_default_sources: { ...instancePermissionSources, ...loadedConfig.permission_default_sources } } : {}),
682
569
  roles, conductor: { ...DEFAULT_CONFIG.conductor, ...loadedConfig.conductor } };
683
570
  // The resolver and catalogs are bound to this daemon, never published into the process-global builtin registry.
684
- const definitionOf = (peer: string) => instanceDefinitionOf(peer, declarations);
685
- const KNOWN_PEERS: readonly string[] = [...BASE_KNOWN_PEERS, ...extraIds];
686
- const SUPPORTED_PEERS: readonly string[] = [...BASE_SUPPORTED_PEERS, ...extraIds];
687
- const STARTABLE_PEERS: readonly string[] = [...BASE_STARTABLE_PEERS, ...extraIds.filter(id => !channelExtras.includes(id))];
688
- const isSupportedPeer = (peer: unknown): peer is string => registrySupportedPeer(peer, declarations);
571
+ const definitionOf = (peer: string) => adapterCatalog.definitionOf(peer, declarations);
572
+ const KNOWN_PEERS: readonly string[] = [...BASE_KNOWN_PEERS, ...configuredIds, ...extraIds];
573
+ const SUPPORTED_PEERS: readonly string[] = [...BASE_SUPPORTED_PEERS, ...configuredIds, ...extraIds];
574
+ const STARTABLE_PEERS: readonly string[] = [...BASE_STARTABLE_PEERS, ...configuredIds, ...extraIds.filter(id => !channelExtras.includes(id))];
575
+ const isSupportedPeer = (peer: unknown): peer is string => adapterCatalog.isSupportedPeer(peer, declarations);
689
576
  const isStartablePeer = (peer: unknown): peer is string => typeof peer === "string" && !channelExtras.includes(peer) && definitionOf(peer)?.start.conductorStartable === true;
690
- const RESERVED_IDS = new Set([...BASE_RESERVED_IDS, ...extraIds.filter(id => !channelExtras.includes(id))]);
577
+ const RESERVED_IDS = new Set([...BASE_RESERVED_IDS, ...configuredIds, ...extraIds.filter(id => !channelExtras.includes(id))]);
691
578
  // #223 R2: a declared instance whose effective roles are exactly reviewer gets review requests and its own task notices only.
692
579
  const reviewOnlyPeer = (peer: string): boolean => Object.hasOwn(declarations, peer) && config.roles[peer]?.length === 1 && config.roles[peer]![0] === "reviewer";
693
580
  /** #520: a task under review shows its review brief to whoever may read it (owner, reviewer, conductor). */
@@ -696,8 +583,9 @@ export async function startDaemon(opts: DaemonOptions) {
696
583
  const reviewerView = (peer: string, view: Record<string, unknown>): Record<string, unknown> => !reviewOnlyPeer(peer) || !Array.isArray(view.history) ? view
697
584
  : { ...view, history: (view.history as { event?: unknown; note?: unknown }[]).map(({ note, ...h }) => h.event === "approved" || h.event === "changes_requested" ? { ...h, note } : h) };
698
585
  const reviewOnlyItem = (peer: string, env: Envelope): boolean => env.from === USER || env.from === HUB && (env.kind === "review" || (env.kind === "task" && env.to?.includes(peer) === true));
699
- const SETTINGS = projectSettings(declarations);
700
- const settingDef = (key: unknown) => instanceSettingDef(key, declarations);
586
+ const readOnlyPeer = (peer: string): boolean => declarations[peer]?.readOnly === true;
587
+ const SETTINGS = projectSettings(declarations, adapterCatalog);
588
+ const settingDef = (key: unknown) => instanceSettingDef(key, declarations, adapterCatalog);
701
589
  const accountOf = (peer: string) => definitionOf(peer)?.budget?.account ?? peer;
702
590
  const accountMembers = (account: string) => SUPPORTED_PEERS.filter(peer => accountOf(peer) === account);
703
591
  let rosterChanging: string | undefined;
@@ -705,7 +593,7 @@ export async function startDaemon(opts: DaemonOptions) {
705
593
  let kimiAdmissionId: string | undefined;
706
594
  let kimiAdmissionExpiry: ReturnType<typeof setTimeout> | undefined;
707
595
  const clearKimiAdmission = () => { clearTimeout(kimiAdmissionExpiry); kimiAdmissionExpiry = undefined; kimiSessionMode = undefined; kimiAdmissionId = undefined; };
708
- const onRoster = (peer: string) => rosterAllows(config.roster, peer, config.pi.enabled, declarations);
596
+ const onRoster = (peer: string) => rosterAllows(config.roster, peer, config.pi.enabled, declarations, adapterCatalog);
709
597
  // pi.enabled historically gates starts, not retained queues or tools sockets. Only an explicit roster.pi changes those.
710
598
  const membershipAllowed = (peer: string) => definitionOf(peer)?.enabledSwitchStartOnly && config.roster[peer] === undefined ? true : onRoster(peer);
711
599
  conductorPeer(config.roles); // reject ambiguous leadership before opening a control listener
@@ -716,7 +604,7 @@ export async function startDaemon(opts: DaemonOptions) {
716
604
  // The state dir can vanish under a running hub (issue #56): a log line must never take a handler down with it.
717
605
  const log = (line: string) => { try { appendFileSync(logFile, `${new Date().toISOString()} ${line}\n`); } catch { /* the state dir is gone; the watchdog is stopping the hub */ } };
718
606
  let runningRouting = currentRouting(opts.cwd, log);
719
- let acceptedSources = parseConfigSources(configSourceTexts(opts.cwd), runningRouting);
607
+ let acceptedSources = parseConfigSources(configSourceTexts(opts.cwd), runningRouting, opts.cwd);
720
608
  const startupSources = structuredClone(acceptedSources.trustLayers);
721
609
  const embeddedConfig = structuredClone(config);
722
610
  let desiredConfig = structuredClone(config);
@@ -885,7 +773,7 @@ export async function startDaemon(opts: DaemonOptions) {
885
773
  sock.data.tail?.();
886
774
  sock.data.watch?.();
887
775
  const peer = sock.data.peer ? bus.peers.get(sock.data.peer) : undefined;
888
- if (peer instanceof WsPeer) {
776
+ if (isWsPeer(peer)) {
889
777
  peer.detach(sock);
890
778
  if (!!sock.data.peer && !!definitionOf(sock.data.peer)?.attach?.admission && peer.state === "offline" && !peer.claiming && sock.data.kimiAdmission === kimiAdmissionId) clearKimiAdmission();
891
779
  }
@@ -921,7 +809,9 @@ export async function startDaemon(opts: DaemonOptions) {
921
809
  // a record left now would make the next ordinary start look like one.
922
810
  if (recoveryOperation || restartFilePresent) try { removeSessions(opts.stateDir); } catch { /* nothing to remove */ }
923
811
  const autoResume = () => config.recovery.auto_resume_after_crash === true; // a string "false" is not a yes
924
- const piAutoStart = () => onRoster("pi") && config.pi.auto_start;
812
+ const autoStartAdapter = ADAPTERS.find(adapter => adapter.start.autoStart !== undefined);
813
+ const continuityKind = ADAPTERS.find(adapter => adapter.start.daemonPort === "pi-rpc")!.kind;
814
+ const piAutoStart = () => !!autoStartAdapter && onRoster(autoStartAdapter.kind) && config.pi.auto_start;
925
815
  const startedAt = Date.now();
926
816
  /** What crash recovery did or asks the user to do, for `ahub status`. */
927
817
  const crashReport: string[] = [];
@@ -954,7 +844,8 @@ export async function startDaemon(opts: DaemonOptions) {
954
844
  // silence without facts would leave overlapping owners blind. Facts and silence ask the same question, so both switch
955
845
  // together. Cached until the board changes: it is asked per recipient of every message.
956
846
  let turnFreeNow: boolean | undefined;
957
- const turns = new Map<PeerId, { capture?: boolean; id: string; start: number; tokens: number; tree?: string; snapshotMs?: number; private?: boolean; deliveryTask?: number; attribution: TaskAttribution; preparedRoles: RoleIdentity[] | null }>();
847
+ const turns = new Map<PeerId, { capture?: boolean; id: string; start: number; tokens: number; tree?: string; head?: string | null; commit?: string | null; branches?: string; writeScope?: boolean; reflog?: HeadMoveCursor; snapshotMs?: number; private?: boolean; deliveryTask?: number; attribution: TaskAttribution; preparedRoles: RoleIdentity[] | null; writeTasks: { id: number; acceptance: number }[]; taskEffect?: boolean; effectsPending?: number }>();
848
+ const ownerWriteStarts = new Map<number, { owner: string; acceptance: number; at: number; root: string; tree: string }>();
958
849
  const turnFree = (): boolean => (config.coordination === "turn-free" || tasks.cohorts.list().some(cohort => cohort.silent)) && ![...turns.values()].some(turn => turn.private) && (turnFreeNow ??= !board.list().some((t) => !isClosedTaskState(t.state) && tasks.isPii(t)));
959
850
  /**
960
851
  * Why `to` does not get an agent's message (issue #107): sender and recipient are in one silent cohort, and the sender
@@ -1005,7 +896,7 @@ export async function startDaemon(opts: DaemonOptions) {
1005
896
  if (staleOff()) log("experiment: stale notices are delivered as before 0.12.4 (the issue #106 ablation)");
1006
897
  const requestedPeerStops = new Set<string>();
1007
898
  const bus = new Bus({ rostered: membershipAllowed, deliveryHeld: peer => requestedPeerStops.has(peer) || rosterChanging === peer, journal, batchMax: config.batch_max, batchMs: config.batch_ms, queueCap: config.queue_cap, condense: (envs) => inference?.condense(envs) ?? Promise.resolve(envs), admit, relevant: (peer, env) => reviewOnlyPeer(peer) && !reviewOnlyItem(peer, env) ? `not for ${peer}: a review-only instance receives review requests only` : env.refs?.boundedCheck || env.refs?.supervision || env.refs?.backlog ? relevantNotice(peer, env) : staleOff() || relevantNotice(peer, env), silence,
1008
- onImportantQueued: (peer, counts) => { const p = bus.peers.get(peer); if (p instanceof WsPeer) p.notifyQueued(counts); } });
899
+ onImportantQueued: (peer, counts) => { const p = bus.peers.get(peer); if (isWsPeer(p)) p.notifyQueued(counts); } });
1009
900
  startupCleanup.push(() => bus.closeJournal());
1010
901
  const manualPaused = new Set<PeerId>(bus.manualPausedPeers()); // recovery never lifts an operator's pause
1011
902
  const conductorHolds = new ConductorHolds(join(opts.stateDir, "hub.db"));
@@ -1031,11 +922,18 @@ export async function startDaemon(opts: DaemonOptions) {
1031
922
  bus.setRecoveryHold(false);
1032
923
  }
1033
924
  const recoveryPeerAllowed = (id: PeerId) => !recoveryActive() || !recoveryPeerSnapshot || recoveryPeerSnapshot.some((peer) => peer.id === id);
1034
- const armRecoveryLease = (delayMs = 10 * 60_000): void => {
925
+ // One log line per failure streak: the 1 s retry itself is unchanged, only its repeated line is not.
926
+ let recoveryLeaseRetryLogged = false;
927
+ const armRecoveryLease = (delayMs = 10 * 60_000, retrying = false): void => {
928
+ if (!retrying) recoveryLeaseRetryLogged = false;
1035
929
  clearTimeout(recoveryLeaseTimer);
1036
930
  recoveryLeaseTimer = setTimeout(() => {
1037
- if (!recoveryOperationId || recoveryCommitted || (recoveryPhase !== "preparing" && recoveryPhase !== "prepared")) return;
1038
- try { bus.setRecoveryHold(false); } catch (error) { log("recovery lease release deferred; delivery storage must be restored before abort"); armRecoveryLease(1000); return; }
931
+ if (!recoveryOperationId || recoveryCommitted || (recoveryPhase !== "preparing" && recoveryPhase !== "prepared")) { recoveryLeaseRetryLogged = false; return; }
932
+ try { bus.setRecoveryHold(false); } catch {
933
+ if (!recoveryLeaseRetryLogged) { recoveryLeaseRetryLogged = true; log("recovery lease release deferred; delivery storage must be restored before abort (retrying every second)"); }
934
+ armRecoveryLease(1000, true); return;
935
+ }
936
+ recoveryLeaseRetryLogged = false;
1039
937
  log(`recovery lease expired for ${recoveryOperationId}; hold aborted`);
1040
938
  recoveryOperationId = undefined;
1041
939
  recoveryPhase = undefined;
@@ -1295,8 +1193,8 @@ export async function startDaemon(opts: DaemonOptions) {
1295
1193
  if (!peer || peer.state === "offline") return undefined;
1296
1194
  let token = checkConnections.get(peer);
1297
1195
  if (!token) { token = randomUUID(); checkConnections.set(peer, token); }
1298
- const native = peer instanceof WsPeer || peer instanceof CodexPeer || peer instanceof PiPeer ? peer.sessionGeneration : peer.recoveryMetadata?.().sessionId;
1299
- if (peer instanceof PiPeer && native === undefined) return undefined;
1196
+ const native = isWsPeer(peer) || isCodexPeer(peer) || isPiPeer(peer) ? peer.sessionGeneration : peer.recoveryMetadata?.().sessionId;
1197
+ if (isPiPeer(peer) && native === undefined) return undefined;
1300
1198
  return `${instanceId}:${token}:${String(native ?? "")}`;
1301
1199
  };
1302
1200
  const currentCheck = (receipt: { task: number; owner: string; updated: number | string; generation: number | string; revision?: number }) => {
@@ -1396,7 +1294,7 @@ export async function startDaemon(opts: DaemonOptions) {
1396
1294
  // Bind both legacy print transports and task-MCP calls to the child's original review request.
1397
1295
  const runningReviewAuthority = (by: PeerId, task: number): (() => string | undefined) | undefined => {
1398
1296
  if (!definitionOf(by)?.reviewer) return undefined;
1399
- const attached=bus.peers.get(by), peer=attached instanceof ReviewerPrintPeer?attached:undefined, token=peer?.reviewToken(task), binding=peer?.reviewBinding(task);
1297
+ const attached=bus.peers.get(by), peer=isReviewerPrintPeer(attached)?attached:undefined, token=peer?.reviewToken(task), binding=peer?.reviewBinding(task);
1400
1298
  return () => token!==undefined && binding!==undefined && bus.peers.get(by)===peer && peer?.reviewToken(task)===token && tasks.reviewBindingCurrent(by,task,binding)
1401
1299
  ? undefined : `no review of task #${task} is running for ${by} at its bound head and revision`;
1402
1300
  };
@@ -1410,6 +1308,7 @@ export async function startDaemon(opts: DaemonOptions) {
1410
1308
  return [peer, codes];
1411
1309
  })),
1412
1310
  instances: () => declarations,
1311
+ adapterCatalog,
1413
1312
  reviewOnly: reviewOnlyPeer,
1414
1313
  // #223: a headless reviewer's verdict holds only while the same child it started with still runs for that task.
1415
1314
  reviewAuthority: runningReviewAuthority,
@@ -1430,6 +1329,8 @@ export async function startDaemon(opts: DaemonOptions) {
1430
1329
  catalog,
1431
1330
  sessionRoles: peer => bus.peers.has(peer) && bus.stateOf(peer) !== "offline" ? preparedSessionRoles.get(peer) ?? [] : [],
1432
1331
  projectedReview: task => reviewProjection(task),
1332
+ ownerWrites: (task, since) => ownerWriteEvidence(task, since),
1333
+ ownerAccepted: task => captureOwnerWriteStart(task),
1433
1334
  reviewBrief: task => {
1434
1335
  const root = executionRoot(task.owner ?? "");
1435
1336
  return buildReviewBrief(root, task.refs.commit, { privatePath: path => isDenied(path, config.local.deny) || matchesPolicy(root, path, config.pii?.paths ?? [], true), nameable: tasks.nameable });
@@ -1487,7 +1388,7 @@ export async function startDaemon(opts: DaemonOptions) {
1487
1388
  sweepHeld: () => stopping || recoveryActive(),
1488
1389
  cancellationHeld: () => stopping || recoveryActive(),
1489
1390
  offerRead: (peer, envelopeId) => journal.offerRead(peer, envelopeId),
1490
- offerPullOnly: (id) => { const peer = bus.peers.get(id); return peer instanceof WsPeer && peer.pullOnly; },
1391
+ offerPullOnly: (id) => { const peer = bus.peers.get(id); return isWsPeer(peer) && peer.pullOnly; },
1491
1392
  stallNotice: stall => { supervision.taskStalled(stall); },
1492
1393
  roles: config.roles,
1493
1394
  turnFree,
@@ -1551,7 +1452,7 @@ export async function startDaemon(opts: DaemonOptions) {
1551
1452
  const worktreeFacts = new Map<string, Facts>();
1552
1453
  const worktreeProjections = new Map<string, TextProjections>();
1553
1454
  const executionRoot = (peer: string) => executionRoots.get(peer) ?? opts.cwd;
1554
- const repoFor = (peer: string) => executionRoot(peer) === opts.cwd ? factsRepo : repoOf(executionRoot(peer));
1455
+ const repoFor = (peer: string, budget?: SnapshotBudget) => executionRoot(peer) === opts.cwd ? factsRepo : repoOf(executionRoot(peer), budget);
1555
1456
  const projectionsFor = (peer: string): TextProjections => {
1556
1457
  const root = executionRoot(peer);
1557
1458
  if (root === opts.cwd) return projections;
@@ -1687,7 +1588,8 @@ export async function startDaemon(opts: DaemonOptions) {
1687
1588
  } catch { /* no transcript yet: no readback */ }
1688
1589
  };
1689
1590
  /** Claude's transcript for this session: the one the status line reported, or the hook's own, inside Claude's projects. */
1690
- const claudeTranscript = (sessionId: string | undefined, fromHook: unknown, peer = "claude"): string | undefined => {
1591
+ const claudeTranscript = (sessionId: string | undefined, fromHook: unknown, peer: string): string | undefined => {
1592
+ if (definitionOf(peer)?.transport !== "claude-channel") return undefined; // #624: no Claude transcript for another kind
1691
1593
  const reported = claudeSession(peer);
1692
1594
  if (reported.transcriptPath && (!sessionId || reported.sessionId === sessionId)) return reported.transcriptPath;
1693
1595
  if (!sessionId || typeof fromHook !== "string" || !isAbsolute(fromHook) || basename(fromHook) !== `${sessionId}.jsonl`) return undefined;
@@ -1732,6 +1634,7 @@ export async function startDaemon(opts: DaemonOptions) {
1732
1634
  scopes = new Map();
1733
1635
  progress.prune();
1734
1636
  factsOn(); // a PII task opening or closing switches tracking at once, not at the next boundary (issue #108)
1637
+ if (t.state !== "in_progress" || ownerWriteStarts.get(t.id)?.owner !== t.owner) ownerWriteStarts.delete(t.id);
1735
1638
  if ((h.event === "integration requested" || h.event === "integration unresolved") && /has not stopped/.test(h.note ?? "")) {
1736
1639
  for (const m of tasks.cohorts.of(t.id)?.members.values() ?? []) if (m.task !== t.id) log(`turn-free: task #${t.id} waits on ${stopEvidence(m.owner)}`);
1737
1640
  }
@@ -1755,10 +1658,10 @@ export async function startDaemon(opts: DaemonOptions) {
1755
1658
  const checkpointWaits = new Map<PeerId, { reason: "quota" | "context"; requestId: string; session: string | undefined; valid: () => boolean; done: (summary: string | undefined) => void }>();
1756
1659
  const contextSession = (peer: PeerId): string | undefined => {
1757
1660
  const p = bus.peers.get(peer);
1758
- if (p instanceof WsPeer && p.claiming) return undefined;
1759
- if (definitionOf(peer)?.turnEnd === "native-hook") { const c = claudeSession(peer); return c.sessionId ? `${c.sessionId}:${c.launchId ?? ""}` : undefined; }
1760
- if (p instanceof CodexPeer) return p.thread || undefined;
1761
- if (p instanceof AcpPeer || p instanceof PiPeer) { const id = p.recoveryMetadata().sessionId; return typeof id === "string" ? id : undefined; }
1661
+ if (isWsPeer(p) && p.claiming) return undefined;
1662
+ if (definitionOf(peer)?.transport === "claude-channel") { const c = claudeSession(peer); return c.sessionId ? `${c.sessionId}:${c.launchId ?? ""}` : undefined; }
1663
+ if (isCodexPeer(p)) return p.thread || undefined;
1664
+ if (isAcpPeer(p) || isPiPeer(p)) { const id = p.recoveryMetadata().sessionId; return typeof id === "string" ? id : undefined; }
1762
1665
  return undefined;
1763
1666
  };
1764
1667
  const activityRings: Record<string, ActivityRing> = Object.fromEntries(SUPPORTED_PEERS.filter(peer => definitionOf(peer)?.capabilities?.watch).map(peer => [peer, new ActivityRing()]));
@@ -1769,8 +1672,8 @@ export async function startDaemon(opts: DaemonOptions) {
1769
1672
  const owner = bus.peers.get(peer);
1770
1673
  const state = bus.stateOf(peer);
1771
1674
  const session = contextSession(peer);
1772
- const generation = owner instanceof WsPeer || owner instanceof CodexPeer ? owner.sessionGeneration : undefined;
1773
- if (!owner || (owner instanceof WsPeer && owner.claiming) || (state !== "idle" && state !== "busy") || (reason === "context" && (!session || turns.get(peer)?.private || contextPii(peer)))) return Promise.resolve(undefined);
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);
1774
1677
  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));
1775
1678
  if (!openWork && !bus.queued(peer) && !bus.hasInFlight(peer) && state !== "busy") {
1776
1679
  if (reason === "quota") log(`budget ${peer}: no open work, no checkpoint`);
@@ -1782,7 +1685,7 @@ export async function startDaemon(opts: DaemonOptions) {
1782
1685
  : `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.`;
1783
1686
  const ask = newEnvelope(HUB, body, { to: [peer], kind: "budget", priority: "important" });
1784
1687
  return new Promise((resolve) => {
1785
- const valid = () => !stopping && !recoveryActive() && bus.peers.get(peer) === owner && (!(owner instanceof WsPeer || owner instanceof CodexPeer) || owner.sessionGeneration === generation) && ["idle", "busy"].includes(bus.stateOf(peer)) && contextSession(peer) === session;
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;
1786
1689
  const done = (summary: string | undefined) => {
1787
1690
  clearTimeout(timer);
1788
1691
  checkpointWaits.delete(peer);
@@ -1810,6 +1713,7 @@ export async function startDaemon(opts: DaemonOptions) {
1810
1713
  }
1811
1714
  };
1812
1715
  const budget = new Budget(join(opts.stateDir, "hub.db"), config.budget, instanceBudgetHooks({
1716
+ definitionOf,
1813
1717
  pause: (peer) => bus.pause(peer),
1814
1718
  resume: (peer) => {
1815
1719
  if (!manualPaused.has(peer) && !conductorHolds.has(peer)) bus.resume(peer);
@@ -1904,27 +1808,89 @@ export async function startDaemon(opts: DaemonOptions) {
1904
1808
  if (!tasks.nameable(text) || looksSecret(text) || !screenMemory([{ title: null, text }]).length) return;
1905
1809
  memoryObservations?.cite(peer, text, id, envs);
1906
1810
  };
1907
- /**
1908
- * A snapshot that fails costs the undo record of that turn and nothing else. The peer's prompt waits for it (it
1909
- * runs inside the state change): three git calls at a turn's start and four at its end, each with the 10 s timeout
1910
- * in snapshots.ts.
1911
- */
1912
- const snap = (what: string, capture = config.snapshots.enabled, peer?: string): { tree?: string; ms: number } => {
1913
- const repo = peer ? repoFor(peer) : factsRepo;
1811
+ // Preserve real synchronous native boundaries, but never spend an unbounded sequence of Git waits
1812
+ // inside a single callback burst. Failed/slow captures are explicitly unknown, never stale baselines.
1813
+ let snapshotBudget: SnapshotBudget | undefined, snapshotBudgetNoticed = false;
1814
+ const reserveSnapshotBudget = (): SnapshotBudget => {
1815
+ if (!snapshotBudget) {
1816
+ const requested = opts.snapshotBurstMs ?? 250;
1817
+ const ms = Number.isFinite(requested) ? Math.max(1, Math.min(250, requested)) : 250;
1818
+ snapshotBudget = { deadline: performance.now() + ms };
1819
+ setImmediate(() => { snapshotBudget = undefined; snapshotBudgetNoticed = false; });
1820
+ }
1821
+ return snapshotBudget;
1822
+ };
1823
+ const snapshotFailure = (what: string, error: unknown) => {
1824
+ if (error instanceof SnapshotBudgetExceeded) {
1825
+ if (!snapshotBudgetNoticed) log(`snapshot unavailable: ${error.message}`);
1826
+ snapshotBudgetNoticed = true;
1827
+ } else log(`snapshot failed at ${what}: ${(error as Error).message}`);
1828
+ };
1829
+ const snap = (what: string, capture = config.snapshots.enabled, peer?: string): { tree?: string; head?: string | null; commit?: string | null; reflog?: HeadMoveCursor; ms: number; budget?: SnapshotBudget; repo?: Repo } => {
1914
1830
  if (!capture) return { ms: 0 };
1915
- const t0 = performance.now();
1916
- let tree: string | undefined;
1831
+ const t0 = performance.now(), budget = reserveSnapshotBudget();
1832
+ let tree: string | undefined, head: string | null | undefined, commit: string | null | undefined, repo: Repo | undefined, reflog: HeadMoveCursor | undefined;
1917
1833
  try {
1918
- tree = repo && snapshot(repo);
1834
+ repo = peer ? repoFor(peer, budget) : factsRepo;
1835
+ const before = repo && headState(repo, budget);
1836
+ tree = repo && (opts.snapshot ?? snapshot)(repo, budget);
1837
+ const after = repo && headState(repo, budget);
1838
+ head = after?.tree; commit = after?.head;
1839
+ if (!before || !after || before.tree !== after.tree) tree = undefined;
1840
+ if (tree && repo && head !== undefined && after) reflog = headMoveCursor(repo, head, budget, after.head);
1919
1841
  if (repo && !tree) log(`snapshot failed at ${what}`);
1920
1842
  } catch (error) {
1921
- log(`snapshot failed at ${what}: ${(error as Error).message}`);
1843
+ tree = undefined; head = undefined; commit = undefined; reflog = undefined;
1844
+ snapshotFailure(what, error);
1922
1845
  }
1923
- return { tree, ms: Math.round(performance.now() - t0) };
1846
+ return { tree, head, commit, reflog, budget, repo, ms: Math.round(performance.now() - t0) };
1924
1847
  };
1925
1848
  function privateSnapshotWindow(): boolean {
1926
1849
  return [...turns.values()].some(turn => turn.private) || board.list().some(task => !isClosedTaskState(task.state) && tasks.isPii(task));
1927
1850
  }
1851
+ function ownerWriteRoot(peer: string): string | undefined {
1852
+ if (declarations[peer]) {
1853
+ const bound = executionOwners.get(peer);
1854
+ if (!bound || bound.owner !== bus.peers.get(peer) || bound.cwd !== executionRoots.get(peer)) return undefined;
1855
+ }
1856
+ return executionRoot(peer);
1857
+ }
1858
+ function canonicalWriteScope(repo: Repo, budget?: SnapshotBudget): boolean | undefined {
1859
+ // ponytail: D1 is inert for builtin owners while .worktrees exists (a deliberate false negative).
1860
+ // Bind each owner to its real execution worktree before narrowing this unknown scope.
1861
+ // The directory may contain a plain or still-uncommitted checkout, not just registered refs.
1862
+ if (lstatSync(join(opts.cwd, ".worktrees"), { throwIfNoEntry: false })) return false;
1863
+ return soleWorktree(repo, budget);
1864
+ }
1865
+ function captureOwnerWriteStart(task: Task): void {
1866
+ const accepted = tasks.ownerWriteAcceptance(task), prior = ownerWriteStarts.get(task.id);
1867
+ if (prior && prior.owner === task.owner && prior.acceptance === accepted?.index) return;
1868
+ ownerWriteStarts.delete(task.id);
1869
+ if (!accepted || accepted.index !== task.history.length - 1) return;
1870
+ if (!task.owner || !["implement", "bulk_edit"].includes(task.class) || privateSnapshotWindow()) return;
1871
+ const root = ownerWriteRoot(task.owner);
1872
+ if (root === undefined) return;
1873
+ const captured = snap(`task #${task.id} acceptance`, config.snapshots.enabled, task.owner);
1874
+ if (captured.tree) ownerWriteStarts.set(task.id, { owner: task.owner, acceptance: accepted.index, at: accepted.at, root, tree: captured.tree });
1875
+ }
1876
+ function ownerWriteEvidence(task: Task, since: number): boolean | undefined {
1877
+ if (!task.owner || !turnLog || !config.snapshots.enabled || privateSnapshotWindow()) return undefined;
1878
+ const baseline = ownerWriteStarts.get(task.id);
1879
+ const acceptance = tasks.ownerWriteAcceptance(task)?.index;
1880
+ if (!baseline || baseline.owner !== task.owner || baseline.at !== since || baseline.acceptance !== acceptance || baseline.root !== ownerWriteRoot(task.owner)) return undefined;
1881
+ try {
1882
+ if (turnLog.touchesFor([task.id]).some(touch => touch.peer === task.owner && touch.at >= since)) return true;
1883
+ const canonical = baseline.root === opts.cwd;
1884
+ if (canonical && canonicalWriteUnknown(task)) return undefined;
1885
+ const current = snap(`task #${task.id} done evidence`, true, task.owner);
1886
+ if (!current.repo || !current.tree) return undefined;
1887
+ const paths = knownChangedPaths(current.repo, baseline.tree, current.tree, current.budget);
1888
+ if (paths === undefined) return undefined;
1889
+ if (paths.length) return true;
1890
+ if (canonical && canonicalWriteScope(current.repo, current.budget) !== true) return undefined;
1891
+ return false;
1892
+ } catch { return undefined; } // Unknown is never evidence of no writes, including a missing/pruned tree.
1893
+ }
1928
1894
  function reviewProjection(task: Task): string | undefined {
1929
1895
  const owner = task.owner ?? "", root = executionRoot(owner);
1930
1896
  const repo = repoFor(owner), projections = projectionsFor(owner);
@@ -1959,7 +1925,7 @@ export async function startDaemon(opts: DaemonOptions) {
1959
1925
  }
1960
1926
  return n;
1961
1927
  };
1962
- const onKimiTokens = (total: number, session: string, peer = "kimi") => {
1928
+ const onKimiTokens = (total: number, session: string, peer: string) => {
1963
1929
  const n = addTokens(peer, delta(peer, total, session));
1964
1930
  if (!config.budget.kimi_tokens_5h || !n) return;
1965
1931
  const now = Date.now();
@@ -2022,7 +1988,8 @@ export async function startDaemon(opts: DaemonOptions) {
2022
1988
  if (c.instanceId !== instanceId || !session.sessionId || c.sessionId !== session.sessionId || c.launchId !== session.launchId || !["idle", "busy", "paused"].includes(bus.stateOf(claudePeer))) continue;
2023
1989
  claudeContextSeen.set(claudePeer, payload);
2024
1990
  const bound = contextSession(claudePeer);
2025
- if (bound) contexts.report(claudePeer, claudeContext(c.context, bound, c.at), bound);
1991
+ const reading = bound ? readNativeContext(definitionOf(claudePeer), c.context, bound, c.at) : undefined;
1992
+ if (reading) contexts.report(claudePeer, reading, bound);
2026
1993
  } catch { /* absent or unreadable native telemetry is unknown */ }
2027
1994
  }, 1000),
2028
1995
  setInterval(() => budget.tick(), 30_000),
@@ -2042,7 +2009,10 @@ export async function startDaemon(opts: DaemonOptions) {
2042
2009
  claudeUsageSeen = mtime;
2043
2010
  const usage = JSON.parse(readFileSync(file, "utf8"));
2044
2011
  // The file's own timestamp, not "now": a file left behind by yesterday's session is a stale reading, not a fresh one.
2045
- budget.report("claude", claudeWindows(usage.rate_limits), false, Number(usage.at) || mtime);
2012
+ for (const adapter of ADAPTERS.filter(adapter => adapter.transport === "claude-channel")) {
2013
+ const windows = readNativeQuota(adapter, usage.rate_limits);
2014
+ if (windows) budget.report(adapter.kind, windows, false, Number(usage.at) || mtime);
2015
+ }
2046
2016
  } catch {
2047
2017
  // no tee in this session, or a half-written file: next round
2048
2018
  }
@@ -2089,6 +2059,10 @@ export async function startDaemon(opts: DaemonOptions) {
2089
2059
  const conductedMutations = new Set(["hub_task_propose", "hub_task_accept", "hub_task_decline", "hub_task_done", "hub_review", "hub_checkpoint", "hub_remember"]);
2090
2060
  const taskOp = async (...args: Parameters<typeof taskOpBody>): Promise<string> => {
2091
2061
  const [by, op, input] = args;
2062
+ if (readOnlyPeer(by) && !READ_ONLY_HUB_TOOLS.has(op)) throw new PreEffectToolRefusal("read-only instance cannot mutate hub state");
2063
+ const nativeTurn = turns.get(by);
2064
+ const effectful = ownerToolEffect(op, input);
2065
+ if (nativeTurn && effectful) nativeTurn.effectsPending = (nativeTurn.effectsPending ?? 0) + 1;
2092
2066
  const conducted = conductorPeer(config.roles) === by && conductedMutations.has(op);
2093
2067
  const admittedId = typeof input?.id === "number" && Number.isSafeInteger(input.id) && input.id > 0 ? input.id : undefined;
2094
2068
  taskOpsInFlight++;
@@ -2096,6 +2070,7 @@ export async function startDaemon(opts: DaemonOptions) {
2096
2070
  const readAuthority=op==="hub_task_show"?runningReviewAuthority(by,Number(input?.id)):undefined;
2097
2071
  const before=readAuthority?.();if(before)throw new PreEffectToolRefusal(before);
2098
2072
  const text = await taskOpBody(...args);
2073
+ if (nativeTurn && effectful && turns.get(by) === nativeTurn) nativeTurn.taskEffect = true;
2099
2074
  const after=readAuthority?.();if(after)throw new PreEffectToolRefusal(after);
2100
2075
  if (conducted) {
2101
2076
  const proposed = op === "hub_task_propose" ? Number(text.match(/task #(\d+):/)?.[1]) : undefined;
@@ -2105,16 +2080,18 @@ export async function startDaemon(opts: DaemonOptions) {
2105
2080
  const hint = args[3] ? ownHeldHint(by) : "";
2106
2081
  return hint ? `${text}\n\n${hint}` : text;
2107
2082
  } catch (error) {
2083
+ if (nativeTurn && effectful && !(error instanceof PreEffectToolRefusal) && turns.get(by) === nativeTurn) nativeTurn.taskEffect = true;
2108
2084
  // Keep proven pre-effect refusal identity/code for native receipts; only add body-free metadata.
2109
2085
  const hint = args[3] ? ownHeldHint(by) : "";
2110
2086
  if (hint && error instanceof Error) error.message += `\n\n${hint}`;
2111
2087
  throw error;
2112
2088
  } finally {
2089
+ if (nativeTurn && effectful) nativeTurn.effectsPending = Math.max(0, (nativeTurn.effectsPending ?? 1) - 1);
2113
2090
  taskOpsInFlight--;
2114
2091
  }
2115
2092
  };
2116
2093
  function ownHeldHint(peer: PeerId): string {
2117
- try { return renderHeldHint(bus.heldDeliveryHints(peer), Date.now(), !(bus.peers.get(peer) instanceof LocalPeer || bus.peers.get(peer) instanceof AcpPeer)); }
2094
+ try { return renderHeldHint(bus.heldDeliveryHints(peer), Date.now(), !(isLocalPeer(bus.peers.get(peer)) || isAcpPeer(bus.peers.get(peer)))); }
2118
2095
  catch { return ""; } // unavailable telemetry is not a zero count or a reason to fail the tool itself
2119
2096
  }
2120
2097
  /** A model-written peer argument: a peer id, or nothing for absent, null or "". Anything else is refused. */
@@ -2248,7 +2225,7 @@ export async function startDaemon(opts: DaemonOptions) {
2248
2225
  switch (op) {
2249
2226
  case "hub_delivery_retry": {
2250
2227
  if (by === USER || by === HUB || !a || typeof a !== "object" || Array.isArray(a) || Object.keys(a).some(key => key !== "delivery_id") || typeof a.delivery_id !== "string" || a.delivery_id.length < 1 || a.delivery_id.length > 256) throw new PreEffectToolRefusal("hub_delivery_retry accepts only your delivery_id; completed and discard require the person");
2251
- if (bus.peers.get(by) instanceof LocalPeer || bus.peers.get(by) instanceof AcpPeer) throw new PreEffectToolRefusal("delivery-only peers require the person to resolve held deliveries");
2228
+ if (isLocalPeer(bus.peers.get(by)) || isAcpPeer(bus.peers.get(by))) throw new PreEffectToolRefusal("delivery-only peers require the person to resolve held deliveries");
2252
2229
  if (stopping || recoveryActive()) throw new PreEffectToolRefusal("recovery or shutdown is holding delivery mutations");
2253
2230
  bus.retryDelivery(by, a.delivery_id);
2254
2231
  return `retry queued for delivery ${a.delivery_id}; check for earlier effects before acting`;
@@ -2294,7 +2271,7 @@ export async function startDaemon(opts: DaemonOptions) {
2294
2271
  if (t.state === "backlog") return line(t);
2295
2272
  const overlap = tasks.overlaps(t, t.owner === by);
2296
2273
  if (t.owner === by && tasks.silentFor(t.id) && factsOn()) factsFor(by).sawPlans(by, tasks.overlapTasks(t)); // as for an accept
2297
- return [line(t), overlap, tasks.claimRoleGuidance(t, by), t.owner === by ? tasks.planGuardText(t) : ""].filter(Boolean).join("\n");
2274
+ return [line(t), tasks.namedOwnerNote(t, a.owner as PeerId | undefined, by), overlap, tasks.claimRoleGuidance(t, by), t.owner === by ? tasks.planGuardText(t) : ""].filter(Boolean).join("\n");
2298
2275
  }
2299
2276
  case "hub_task_accept": {
2300
2277
  // S1 spec acceptance and S9 plan review are checked against one task snapshot before acceptance.
@@ -2437,12 +2414,12 @@ export async function startDaemon(opts: DaemonOptions) {
2437
2414
  if (!definitionOf(turn.peer)?.session.nativeRevert || !turn.native) throw new Error(`turn ${turn.id} has no Codex conversation turn to revert`);
2438
2415
  if (turnLog!.latest(turn.peer)?.id !== turn.id) throw new Error(`turn ${turn.id} is not Codex's latest turn; reverting it would drop the later ones too`);
2439
2416
  const codex = bus.peers.get(turn.peer);
2440
- if (!(codex instanceof CodexPeer)) throw new Error("codex is not attached");
2417
+ if (!isCodexPeer(codex)) throw new Error("codex is not attached");
2441
2418
  const workspace = turnLog!.workspace(turn.id);
2442
2419
  if (workspace !== undefined) {
2443
- const current = readInstanceRoleConfig(opts.cwd), adapter = instanceDefinitionOf(turn.peer, current.instances);
2420
+ const current = readInstanceRoleConfig(opts.cwd), adapter = instanceDefinitionOf(turn.peer, current.instances, current.catalog);
2444
2421
  if (!current.instances?.[turn.peer] || adapter?.kind !== definitionOf(turn.peer)?.kind) throw new Error("turn workspace declaration changed; inspect before reverting");
2445
- const validated = resolveInstanceExecutionCwd(opts.cwd, turn.peer, current.instances, effectiveInstanceRoles(turn.peer, current.instances, current.roles));
2422
+ const validated = resolveInstanceExecutionCwd(opts.cwd, turn.peer, current.instances, effectiveInstanceRoles(turn.peer, current.instances, current.roles, current.catalog), current.catalog);
2446
2423
  const bound = executionOwners.get(turn.peer);
2447
2424
  if (validated !== workspace || executionRoot(turn.peer) !== workspace || bound?.owner !== codex || bound.cwd !== workspace) throw new Error("turn workspace differs from the current native owner; inspect before reverting");
2448
2425
  }
@@ -2527,7 +2504,7 @@ export async function startDaemon(opts: DaemonOptions) {
2527
2504
  * waited past `budget.stale_min` the status says so, and the person decides. Read-only.
2528
2505
  */
2529
2506
  const importantWaitingStatus = (id: PeerId, peer: PeerAdapter | undefined): Record<string, unknown> => {
2530
- if (!(peer instanceof AcpPeer) || (peer as PeerAdapter).steer || bus.stateOf(id) !== "busy") return {};
2507
+ if (!isAcpPeer(peer) || (peer as PeerAdapter).steer || bus.stateOf(id) !== "busy") return {};
2531
2508
  const since = bus.oldestQueuedImportantAt(id);
2532
2509
  if (since === undefined || Date.now() - since < config.budget.stale_min * 60_000) return {};
2533
2510
  const count = bus.queuedImportant(id), minutes = Math.floor((Date.now() - since) / 60_000);
@@ -2540,7 +2517,7 @@ export async function startDaemon(opts: DaemonOptions) {
2540
2517
  };
2541
2518
  let releasing = false; // gone-owner release (#6) and the ready sweep (#34): one run at a time, and a recovery commit waits for it
2542
2519
  const recoveryReady = () => {
2543
- if (!recoveryActive() || releasing || taskOpsInFlight !== 0 || tasks.checksPending() !== 0 || piCallsInFlight() !== 0 || permissions.size !== 0 || starting.size !== 0 || requestedPeerStops.size !== 0 || rosterChanging !== undefined || !budget.recoverySettled || [...bus.peers.values()].some((peer) => peer.state === "busy" || (peer instanceof PiPeer && !peer.recoveryReady))) return false;
2520
+ if (!recoveryActive() || releasing || taskOpsInFlight !== 0 || tasks.checksPending() !== 0 || piCallsInFlight() !== 0 || permissions.size !== 0 || starting.size !== 0 || requestedPeerStops.size !== 0 || rosterChanging !== undefined || !budget.recoverySettled || [...bus.peers.values()].some((peer) => peer.state === "busy" || (isPiPeer(peer) && !peer.recoveryReady))) return false;
2544
2521
  if (checkPending.size !== 0) return false;
2545
2522
  if (!recoveryPeerSnapshot) return true;
2546
2523
  const current = recoveryPeers();
@@ -2570,7 +2547,8 @@ export async function startDaemon(opts: DaemonOptions) {
2570
2547
  });
2571
2548
  };
2572
2549
  /** #450 I3: each Claude peer (claude and every claude-<name> instance) has its own session and launch files. */
2573
- const claudeSession = (peer = "claude"): { sessionId?: string; transcriptPath?: string; launchId?: string } => {
2550
+ const claudeSession = (peer: string): { sessionId?: string; transcriptPath?: string; launchId?: string } => {
2551
+ if (definitionOf(peer)?.transport !== "claude-channel") return {}; // #624: Claude state files belong to Claude peers only
2574
2552
  try {
2575
2553
  const value = JSON.parse(readFileSync(join(opts.stateDir, claudeStateFile("claude-session", peer)), "utf8"));
2576
2554
  if (value.instanceId !== instanceId) return {};
@@ -2601,7 +2579,7 @@ export async function startDaemon(opts: DaemonOptions) {
2601
2579
  const claudeVersions = new Map<string, string>();
2602
2580
  const claudeStamps = new Map<string, string>(); // the transcript's size and mtime when its version was read (#115)
2603
2581
  const claudeVersion = (): string | undefined => {
2604
- const path = claudeSession().transcriptPath;
2582
+ const path = claudeSession(defaultChannelPeer(undefined)).transcriptPath;
2605
2583
  if (!path) return undefined;
2606
2584
  let fd: number | undefined;
2607
2585
  try {
@@ -2705,9 +2683,10 @@ export async function startDaemon(opts: DaemonOptions) {
2705
2683
  log(`WARNING permission start ${peer}: ${mode} from ${permissionModeSources.get(peer) ?? "human runtime command"}; effective ${effective}${effective === "unmanaged" ? "; native mode is unmanaged; requested mode not applied" : pending ? "; waits for a person's y in ahub console" : "; matching actions run without asking"}`);
2706
2684
  };
2707
2685
  const permissionDisplay = (id: string, peer: PeerAdapter | undefined): string => {
2708
- if (peer instanceof AcpPeer && ["unknown", "unmanaged"].includes(peer.permissionModeState)) return peer.permissionModeState;
2709
- if (starting.has(id) && (peer instanceof AcpPeer || peer instanceof CodexPeer)) return peer.getPermissionMode();
2710
- if (definitionOf(id)?.transport === "claude-channel" && peer instanceof WsPeer && peer.state !== "offline") {
2686
+ 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
+ if (definitionOf(id)?.transport === "claude-channel" && isWsPeer(peer) && peer.state !== "offline") {
2711
2690
  const launch = claudePermissionLaunch(id);
2712
2691
  if (!launch || claudePermissionGenerations.get(id) !== peer.sessionGeneration) return "unverified";
2713
2692
  }
@@ -2715,7 +2694,7 @@ export async function startDaemon(opts: DaemonOptions) {
2715
2694
  };
2716
2695
  const permissionStatus = (id: string, peer: PeerAdapter | undefined): Record<string, unknown> => {
2717
2696
  const note = definitionOf(id)?.permission.unmanagedStatusNote;
2718
- if (note && peer instanceof WsPeer && peer.pullOnly) return { permissionNote: note };
2697
+ if (note && isWsPeer(peer) && peer.pullOnly) return { permissionNote: note };
2719
2698
  const mode = permissionDisplay(id, peer);
2720
2699
  return mode === "ask" ? {} : { permissionMode: mode };
2721
2700
  };
@@ -2729,10 +2708,19 @@ export async function startDaemon(opts: DaemonOptions) {
2729
2708
  const statusPeerIds = (open: ReturnType<Board["statusTasks"]>) => {
2730
2709
  const reservations = board.statusReservations();
2731
2710
  const ids = new Set([...bus.knownPeers(), ...extraIds, ...reservations]);
2711
+ for (const peer of configuredIds) if (onRoster(peer)) ids.add(peer);
2732
2712
  if (!Object.keys(config.roster).some(peer => !membershipAllowed(peer))) return [...ids];
2733
2713
  for (const task of open) for (const peer of [task.owner, task.reviewer]) if (peer && !membershipAllowed(peer)) ids.add(peer);
2734
2714
  return [...ids].filter(peer => membershipAllowed(peer) || reservations.includes(peer) || bus.queued(peer) > 0 || (() => { const summary = bus.queueSummary(peer); return summary.needsReview > 0 || !!summary.liveAccepted?.length; })() || open.some(task => task.owner === peer || task.reviewer === peer));
2735
2715
  };
2716
+ let checkoutInTick: { branch: ReturnType<typeof mainCheckoutBranch> } | undefined;
2717
+ const checkoutBranch = () => {
2718
+ if (!checkoutInTick) {
2719
+ checkoutInTick = { branch: (opts.checkoutBranch ?? mainCheckoutBranch)(opts.cwd) };
2720
+ queueMicrotask(() => { checkoutInTick = undefined; });
2721
+ }
2722
+ return checkoutInTick.branch;
2723
+ };
2736
2724
  const status = (includeAttentionItems = false, includeAttentionInput = false) => {
2737
2725
  const taskNeeds = board.statusTasks();
2738
2726
  const taskStalls = tasks.stalled();
@@ -2746,8 +2734,8 @@ export async function startDaemon(opts: DaemonOptions) {
2746
2734
  pluginBundle: SERVER_JS,
2747
2735
  pendingRestart,
2748
2736
  retainedConfig,
2749
- ...(() => { const warning = mainCheckoutWarning(opts.cwd, tasks.publicMetadataText); return warning ? { mainCheckout: warning } : {}; })(),
2750
- startModes: Object.fromEntries(START_MODE_PEERS.map(peer => [peer, startModeOf(config.peers, peer, declarations)])),
2737
+ ...(() => { const warning = mainCheckoutWarning(opts.cwd, tasks.publicMetadataText, checkoutBranch); return warning ? { mainCheckout: warning } : {}; })(),
2738
+ startModes: Object.fromEntries([...START_MODE_PEERS, ...configuredIds].map(peer => [peer, startModeOf(config.peers, peer, declarations, adapterCatalog)])),
2751
2739
  memoryProfile: memoryProfileStatus(includeAttentionItems),
2752
2740
  lifecycleStages: [...lifecycleConfig.stages],
2753
2741
  // ponytail: off keeps the #257 phase schema until RETIRED_WORKFLOW_FROM, when the alias and this flag go together.
@@ -2756,6 +2744,9 @@ export async function startDaemon(opts: DaemonOptions) {
2756
2744
  roster: [...KNOWN_PEERS, ...OPTIONAL_PEERS].map(peer => ({ peer, on: onRoster(peer) })),
2757
2745
  protocol: PROTOCOL,
2758
2746
  features: DAEMON_FEATURES,
2747
+ configuredAdapters: configuredIds,
2748
+ startablePeers: SUPPORTED_PEERS.filter(isStartablePeer),
2749
+ readOnlyPeers: extraIds.filter(readOnlyPeer),
2759
2750
  // #450 I3: the instance ids this daemon bound at startup; a launcher selects only one of these.
2760
2751
  activeInstances: extraIds,
2761
2752
  stopping,
@@ -2765,7 +2756,7 @@ export async function startDaemon(opts: DaemonOptions) {
2765
2756
  ...(dashboard ? { uiOrigin: dashboard.origin } : {}),
2766
2757
  codexProxyPort: opts.codexProxyPort,
2767
2758
  peers: Object.fromEntries(
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 ?? [] } : {}) }]; }),
2759
+ 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), ...(isCodexPeer(p) && p.statusNotice ? { statusNotice: p.statusNotice } : {}), ...(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), ...(isLocalPeer(p) && p.lastServedBy ? { servedBy: p.lastServedBy } : {}), ...(isWsPeer(p) && p.claiming ? { claiming: true } : {}), ...(isWsPeer(p) && 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 } : {}), ...(isPiPeer(p) ? { requestedModel: p.getRequestedModel(), backends: modelRelays.get(id)?.status().backends ?? [] } : {}) }]; }),
2769
2760
  ),
2770
2761
  taskWip: Object.fromEntries(peerIds.map(id => [id, { wip: tasks.wip(id), wipLimit: tasks.wipLimit(id) }])),
2771
2762
  boardReadCapabilities: { releaseFilter: true, columnMetadata: true },
@@ -2912,28 +2903,57 @@ export async function startDaemon(opts: DaemonOptions) {
2912
2903
  /** Work a conflict notice is about: open tasks and tasks in review (#91); a notice for anything else is dropped (#106). */
2913
2904
  const CONFLICT_STATES: Task["state"][] = ["proposed", "in_progress", "changes_requested", "in_review"];
2914
2905
  const detectConcurrentConflicts = (record: TurnRecord, mine: Task[]) => {
2906
+ const hits: { ours: Task; theirs: Task; paths: string[]; turn: TurnRecord }[] = [];
2915
2907
  for (const otherTurn of turnLog!.concurrentWith(record)) {
2916
2908
  const otherTasks = (turnTasks.get(otherTurn.id) ?? []).flatMap((id) => { const task = board.get(id); return task ? [task] : []; });
2917
2909
  if (otherTasks.some(tasks.isPii)) continue;
2918
- const shared = record.changed.filter((p) => otherTurn.changed.includes(p));
2919
- const pairs = mine.flatMap((ours) => otherTasks.map((theirs) => ({ ours, theirs })));
2920
- for (const { ours, theirs } of pairs) {
2910
+ const theirs = new Set(otherTurn.changed);
2911
+ const shared = record.changed.filter(path => theirs.has(path));
2912
+ for (const ours of mine) for (const theirs of otherTasks) {
2921
2913
  if (ours.id === theirs.id) continue;
2922
2914
  const pair = [ours.id, theirs.id].sort((a, b) => a - b).join(":");
2923
- const paths = shared.filter((p) => !conflictSeen.has(`concurrent:${pair}:${p}`));
2915
+ const paths = shared.filter(path => !conflictSeen.has(`concurrent:${pair}:${path}`));
2924
2916
  if (!paths.length) continue;
2925
2917
  for (const path of paths) conflictSeen.add(`concurrent:${pair}:${path}`);
2926
- const named = paths.filter(tasks.nameable);
2927
- const files = [...named, ...(paths.length > named.length ? [`${paths.length - named.length} file(s) whose names are withheld (they match a PII pattern)`] : [])].join(", ");
2928
- const together = tasks.silentFor(ours.id) && tasks.silentFor(theirs.id) ? "Check the working tree before continuing; do not message the other owner (you are in one turn-free cohort: the hub shows you its changes)." : "Check the working tree together before continuing.";
2929
- const text = `Concurrent edit: ${record.id} (task #${ours.id}) and ${otherTurn.id} (task #${theirs.id}) both include changes to ${files}. These snapshots do not attribute the changes to either peer. ${together}`;
2930
- notify(`conflict: ${text}`);
2931
- event({ type: "conflict", peer: record.peer, task: ours.id, other: theirs.id, owner: otherTurn.peer, paths: named, concurrent: true, turns: [record.id, otherTurn.id] });
2932
- recordConflictLink(ours.id, theirs.id, [record.peer, otherTurn.peer]);
2933
- // Each owner hears it while its own task is open; once that task is closed the notice is dropped (issue #106).
2934
- for (const [owner, task] of [[record.peer, ours.id], [otherTurn.peer, theirs.id]] as const) if (owner !== USER && owner !== HUB) tasks.whileOpen(owner, task, text, CONFLICT_STATES);
2918
+ hits.push({ ours, theirs, paths, turn: otherTurn });
2935
2919
  }
2936
2920
  }
2921
+ if (!hits.length) return;
2922
+ const paths = [...new Set(hits.flatMap(hit => hit.paths))];
2923
+ const named = paths.filter(tasks.nameable), visible = new Set(named);
2924
+ const files = (shared: string[]) => {
2925
+ const shown = shared.filter(path => visible.has(path));
2926
+ return [...shown, ...(shared.length > shown.length ? [`${shared.length - shown.length} file(s) whose names are withheld (they match a PII pattern)`] : [])].join(", ");
2927
+ };
2928
+ const affected = new Map<string, Map<number, Task>>();
2929
+ const add = (owner: string, task: Task) => {
2930
+ if (!affected.has(owner)) affected.set(owner, new Map());
2931
+ affected.get(owner)!.set(task.id, task);
2932
+ };
2933
+ for (const hit of hits) { add(record.peer, hit.ours); add(hit.turn.peer, hit.theirs); }
2934
+ const labels = (rows: Task[]) => `${rows.length === 1 ? "task" : "tasks"} ${rows.map(task => `#${task.id}`).join(", ")}`;
2935
+ const descriptions = [`${record.id} (${labels([...affected.get(record.peer)!.values()])}): ${files(paths)}`];
2936
+ const byTurn = new Map<string, { tasks: Map<number, Task>; paths: Set<string> }>();
2937
+ for (const hit of hits) {
2938
+ if (!byTurn.has(hit.turn.id)) byTurn.set(hit.turn.id, { tasks: new Map(), paths: new Set() });
2939
+ const group = byTurn.get(hit.turn.id)!;
2940
+ group.tasks.set(hit.theirs.id, hit.theirs);
2941
+ for (const path of hit.paths) group.paths.add(path);
2942
+ }
2943
+ for (const [id, group] of byTurn) descriptions.push(`${id} (${labels([...group.tasks.values()])}): ${files([...group.paths])}`);
2944
+ const rows = [...affected.values()].flatMap(group => [...group.values()]);
2945
+ const together = rows.every(task => tasks.silentFor(task.id)) ? "Check the working tree before continuing; do not message the other owner (you are in one turn-free cohort: the hub shows you its changes)." : rows.some(task => tasks.silentFor(task.id)) ? "Check the working tree before continuing. Do not message another owner for tasks in the same turn-free cohort; the hub shows their changes. Check other work together." : "Check the working tree together before continuing.";
2946
+ const text = `Concurrent edit: shared changes by turn: ${descriptions.join("; ")}. These snapshots do not attribute the changes to any peer. ${together}`;
2947
+ notify(`conflict: ${text}`);
2948
+ // Research retains task-pair evidence. Durable notices are computed once across all overlapping turns,
2949
+ // and each affected task gets only one owner-fenced notice for this change set.
2950
+ for (const hit of hits) {
2951
+ event({ type: "conflict", peer: record.peer, task: hit.ours.id, other: hit.theirs.id, owner: hit.turn.peer, paths: hit.paths.filter(path => visible.has(path)), concurrent: true, turns: [record.id, hit.turn.id] });
2952
+ recordConflictLink(hit.ours.id, hit.theirs.id, [record.peer, hit.turn.peer]);
2953
+ }
2954
+ for (const [owner, group] of affected) {
2955
+ if (owner !== USER && owner !== HUB) for (const task of group.values()) tasks.whileOpen(owner, task.id, text, CONFLICT_STATES);
2956
+ }
2937
2957
  };
2938
2958
  const detectConflicts = (peer: PeerId, turnId: string, since: number, changed: string[]) => {
2939
2959
  const open = board.list().filter((t) => t.owner && CONFLICT_STATES.includes(t.state));
@@ -2982,8 +3002,8 @@ export async function startDaemon(opts: DaemonOptions) {
2982
3002
  let piSettlementFailure: { key: string; error: string; retryAfter: number } | undefined;
2983
3003
  function settlePiContinuity(): void {
2984
3004
  if (stopping || recoveryActive() || piContinuity.state !== "ready") return;
2985
- const peer = bus.peers.get("pi");
2986
- if (!(peer instanceof PiPeer) || peer.state === "offline" || !peer.sessionGeneration || !peer.recoveryReady) return;
3005
+ const peer = bus.peers.get(continuityKind);
3006
+ if (!isPiPeer(peer) || peer.state === "offline" || !peer.sessionGeneration || !peer.recoveryReady) return;
2987
3007
  const saved = piContinuity.bookmark, actual = peer.recoveryMetadata();
2988
3008
  if (typeof actual.sessionId !== "string" || !actual.sessionId) return;
2989
3009
  const key = JSON.stringify([piContinuity.digest, peer.sessionGeneration, actual]);
@@ -3012,8 +3032,8 @@ export async function startDaemon(opts: DaemonOptions) {
3012
3032
  }
3013
3033
  const recordSessions = () => {
3014
3034
  if (stopping) return;
3015
- const pi = bus.peers.get("pi");
3016
- if (piContinuity.state === "ready" && (!(pi instanceof PiPeer) || pi.state === "offline" || !pi.sessionGeneration || !pi.recoveryReady)) {
3035
+ const pi = bus.peers.get(continuityKind);
3036
+ if (piContinuity.state === "ready" && (!isPiPeer(pi) || pi.state === "offline" || !pi.sessionGeneration || !pi.recoveryReady)) {
3017
3037
  piContinuity = readRecoveryContinuity(opts.stateDir, { projectId, projectRoot: opts.cwd });
3018
3038
  }
3019
3039
  const peers = [...bus.peers.values()].filter((p) => p.state !== "offline").map((p) => {
@@ -3035,19 +3055,19 @@ export async function startDaemon(opts: DaemonOptions) {
3035
3055
  const lostCount = [...lost.values()].reduce((n, l) => n + l.length, 0);
3036
3056
  report(`the previous hub run stopped without shutting down${lostCount ? `; ${lostCount} deliveries it had in flight are in needs_review (ahub queue list)` : ""}`);
3037
3057
  const start = (peer: string, args: Parameters<typeof startPeer>[1]) => startPeer(peer, args).catch((e: Error) => ({ ok: false, error: e.message }));
3038
- for (const step of crashPlan(prev.peers, declarations)) {
3058
+ for (const step of crashPlan(prev.peers, declarations, adapterCatalog)) {
3039
3059
  if (!declarations[step.peer] && definitionOf(step.peer)?.session.restoreOnStart && archivedPiContinuity) { report("pi: archived continuity evidence holds crash-session resume and fresh fallback; pi.auto_start uses only the recorded archived session"); continue; }
3040
3060
  // `pi.auto_start` already asks for Pi (#66): its recorded headless session comes back, auto-resume or not, and a
3041
3061
  // fresh one starts when that fails or there is nothing headless to resume. Pi runs on-prem: no cloud quota.
3042
3062
  if (!declarations[step.peer] && definitionOf(step.peer)?.session.restoreOnStart && piAutoStart()) {
3043
3063
  if (step.resume) {
3044
- const r = await start("pi", step.resume as Parameters<typeof startPeer>[1]);
3064
+ const r = await start(step.peer, step.resume as Parameters<typeof startPeer>[1]);
3045
3065
  if (r.ok) { report(`pi resumed (pi.auto_start): ${step.how}`); continue; }
3046
3066
  report(`pi not resumed (${String(r.error)}); ${step.how}`);
3047
3067
  } else report(step.how);
3048
3068
  // #269: a Pi that ran in a terminal is not replaced by a headless one unless headless is its start mode.
3049
- if (step.tui && startMode("pi") === "tui") { report("pi.auto_start starts no headless Pi in place of a terminal one: run the command above, or set peers.pi.start_mode to headless"); continue; }
3050
- const fresh = await start("pi", { ...(step.fresh as Parameters<typeof startPeer>[1]), fresh: true });
3069
+ if (step.tui && startMode(step.peer) === "tui") { report("pi.auto_start starts no headless Pi in place of a terminal one: run the command above, or set peers.pi.start_mode to headless"); continue; }
3070
+ const fresh = await start(step.peer, { ...(step.fresh as Parameters<typeof startPeer>[1]), fresh: true });
3051
3071
  const back = step.tui ? `; to go back to the recorded session, run ahub kill, start the hub without pi.auto_start, then the command above` : "";
3052
3072
  report(fresh.ok ? `pi.auto_start started a fresh session${back}` : `pi.auto_start could not start Pi either (${String(fresh.error)})`);
3053
3073
  continue;
@@ -3101,20 +3121,83 @@ export async function startDaemon(opts: DaemonOptions) {
3101
3121
  const capture = config.snapshots.enabled && !!turnLog;
3102
3122
  const start = capture && !pii ? snap(`the start of ${id}`, config.snapshots.enabled, e.peer) : undefined; // before the peer is handed anything: the tap runs inside setState
3103
3123
  try { if (capture) turnLog?.begin(id, e.peer, start?.tree, declarations[e.peer] ? executionRoot(e.peer) : undefined); } catch (error) { log(`turn record ${id}: ${(error as Error).message}`); }
3124
+ const writeTasks = tasks.ownerTurnTasks(e.peer);
3125
+ let branches: string | undefined, writeScope: boolean | undefined;
3126
+ if (writeTasks.length && start?.repo) try {
3127
+ branches = branchHeads(start.repo, start.budget);
3128
+ writeScope = ownerWriteRoot(e.peer) === opts.cwd ? canonicalWriteScope(start.repo, start.budget) : true;
3129
+ } catch (error) { snapshotFailure(`the write scope of ${id}`, error); }
3104
3130
  const preparedRoles = preparedSessionRoleIdentities.get(e.peer)?.map(role => ({ ...role })) ?? null;
3105
- turns.set(e.peer, { id, capture, start: Date.now(), tokens: 0, deliveryTask: delivery, attribution, preparedRoles, ...(start ? { tree: start.tree, snapshotMs: start.ms } : {}), ...(pii ? { private: true } : {}) });
3131
+ turns.set(e.peer, { id, capture, start: Date.now(), tokens: 0, writeTasks, branches, writeScope, deliveryTask: delivery, attribution, preparedRoles, ...(start ? { tree: start.tree, head: start.head, commit: start.commit, reflog: start.reflog, snapshotMs: start.ms } : {}), ...(pii ? { private: true } : {}) });
3106
3132
  event({ type: "turn_start", peer: e.peer, turn: id });
3107
3133
  } else if (!busy && open) {
3108
3134
  turns.delete(e.peer);
3109
3135
  let afterTurn: (() => void) | undefined;
3110
3136
  let files: number | undefined;
3137
+ let writeProgress: boolean | undefined;
3111
3138
  let snapshotMs = open.snapshotMs;
3112
3139
  if (open.capture && turnLog && (open.private || holdsPii(e.peer))) {
3113
3140
  try { turnLog.end(open.id, undefined, [], Math.max(1, Number(config.snapshots.keep) || 20)); } catch (error) { log(`turn record ${open.id}: ${(error as Error).message}`); }
3114
3141
  } else if (open.capture && turnLog) {
3115
3142
  const end = snap(`the end of ${open.id}`, true, e.peer);
3116
- const changed = open.tree && end.tree ? changedPaths(repoFor(e.peer)!, open.tree, end.tree) : [];
3143
+ let changed: string[] = [];
3144
+ if (open.tree && end.tree) {
3145
+ const turnRepo = end.repo!;
3146
+ try {
3147
+ const observed = knownChangedPaths(turnRepo, open.tree, end.tree, end.budget);
3148
+ if (!observed) throw new Error("turn diff evidence is unavailable");
3149
+ changed = observed;
3150
+ // HEAD movement alone does not identify incoming work: commits/amends/rebases/cherry-picks
3151
+ // are normal peer edits. Only a bounded, append-fenced reflog classifies an incoming move.
3152
+ if (open.head !== end.head || open.reflog || end.reflog) {
3153
+ const paths = open.reflog && end.reflog ? incomingHeadPaths(turnRepo, open.reflog, end.reflog, end.budget) : undefined;
3154
+ if (!paths) throw new Error("HEAD move evidence is ambiguous or unavailable");
3155
+ if (paths.length) {
3156
+ if (!end.head) throw new Error("end HEAD evidence is unavailable");
3157
+ const dirtyPaths = knownChangedPaths(turnRepo, end.head, end.tree, end.budget);
3158
+ 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(); };
3160
+ const dirty = new Set(dirtyPaths), dirtyScope = new Set(dirtyPaths), incoming = new Set(paths);
3161
+ for (const path of dirtyPaths) {
3162
+ checkBudget();
3163
+ for (let at = path.lastIndexOf("/"); at > 0; at = path.lastIndexOf("/", at - 1)) {
3164
+ checkBudget(); dirtyScope.add(path.slice(0, at));
3165
+ }
3166
+ }
3167
+ // Autostash and checkout -m can carry uncommitted edits across an incoming move.
3168
+ // Preserve every still-dirty path (including file/directory overlaps), never subtract it.
3169
+ changed = changed.filter(path => {
3170
+ checkBudget();
3171
+ if (!incoming.has(path) || dirtyScope.has(path)) return true;
3172
+ for (let at = path.lastIndexOf("/"); at > 0; at = path.lastIndexOf("/", at - 1)) {
3173
+ checkBudget(); if (dirty.has(path.slice(0, at))) return true;
3174
+ }
3175
+ return false;
3176
+ });
3177
+ checkBudget();
3178
+ }
3179
+ }
3180
+ } catch (error) {
3181
+ end.tree = undefined; end.head = undefined; changed = [];
3182
+ snapshotFailure(`the diff of ${open.id}`, error);
3183
+ }
3184
+ }
3117
3185
  if (open.tree && end.tree) files = changed.length; // unknown, not zero, when a snapshot failed
3186
+ if (open.writeTasks.length) {
3187
+ if ((files ?? 0) > 0 || open.commit !== undefined && end.commit !== undefined && open.commit !== end.commit) writeProgress = true;
3188
+ else if (open.commit !== undefined && end.commit !== undefined && open.branches !== undefined && end.repo) {
3189
+ try {
3190
+ const branches = branchHeads(end.repo, end.budget);
3191
+ if (branches !== undefined) {
3192
+ if (branches !== open.branches) writeProgress = true;
3193
+ else if (files === 0 && open.writeScope === true) {
3194
+ const scope = ownerWriteRoot(e.peer) === opts.cwd ? canonicalWriteScope(end.repo, end.budget) : true;
3195
+ if (scope === true) writeProgress = false;
3196
+ }
3197
+ }
3198
+ } catch (error) { snapshotFailure(`the branch refs of ${open.id}`, error); }
3199
+ }
3200
+ }
3118
3201
  snapshotMs = (snapshotMs ?? 0) + end.ms;
3119
3202
  try { turnLog.end(open.id, end.tree, changed, Math.max(1, Number(config.snapshots.keep) || 20)); } catch (error) { log(`turn record ${open.id}: ${(error as Error).message}`); }
3120
3203
  // After the turn_end event: a notice delivered at once starts the peer's next turn, which must come after it.
@@ -3128,6 +3211,12 @@ export async function startDaemon(opts: DaemonOptions) {
3128
3211
  tasks.cohorts.turnEnded(e.peer);
3129
3212
  }
3130
3213
  try { afterTurn?.(); } catch (error) { log(`conflict check after ${open.id}: ${(error as Error).message}`); }
3214
+ if (definitionOf(e.peer)?.turnEnd !== "native-hook" || (nativeCompletedAt.get(e.peer) ?? -1) >= open.start) {
3215
+ const root = ownerWriteRoot(e.peer);
3216
+ const elsewhere = root === opts.cwd && open.writeTasks.some(binding => { const task = board.get(binding.id); return task && canonicalWriteUnknown(task); });
3217
+ const changed = e.state === "idle" && !open.private && root !== undefined && !(writeProgress === false && elsewhere) ? writeProgress : undefined;
3218
+ try { tasks.recordOwnerTurn(e.peer, open.writeTasks, changed, !!open.taskEffect || !!open.effectsPending); } catch { /* advisory cannot fail native completion */ }
3219
+ }
3131
3220
  }
3132
3221
  if (e.state === "offline") { offlineSince.set(e.peer, offlineSince.get(e.peer) ?? Date.now()); supervision.peerOffline(e.peer); supervisionTurns.delete(e.peer); }
3133
3222
  else offlineSince.delete(e.peer);
@@ -3275,7 +3364,7 @@ export async function startDaemon(opts: DaemonOptions) {
3275
3364
  const replacingPi = new Set<PiPeer>();
3276
3365
  const piReplacementAuthority = Symbol("console Pi replacement");
3277
3366
  /** pi.auto_start at hub start (#269): in Pi's start mode, and said out loud when it could not be done. */
3278
- const autoStartPi = () => void hubStart("pi", "pi.auto_start").then(
3367
+ const autoStartPi = () => void hubStart(autoStartAdapter!.kind, "pi.auto_start").then(
3279
3368
  (result) => { if (result.ok !== true) notify(`pi.auto_start did not start Pi: ${String(result.error)}`); },
3280
3369
  (error) => log(`Pi auto-start failed: ${(error as Error).message}${piContinuity.state === "ready" ? `. ${continuityCleanup()}` : ""}`));
3281
3370
  async function stopPiForReplacement(pi: PiPeer): Promise<void> {
@@ -3316,7 +3405,7 @@ export async function startDaemon(opts: DaemonOptions) {
3316
3405
  const opener: TerminalOpener = opts.terminal?.(config.terminal.open) ?? { available: () => ({ ok: false, why: "this hub was started without a terminal provider" }), open: async () => ({ ok: false, why: "this hub was started without a terminal provider" }) };
3317
3406
  const CLI_ENTRY = fileURLToPath(new URL("../cli/main.ts", import.meta.url));
3318
3407
  /** `config.peers` follows a settings write (`settingsSet`), so a changed mode applies at the peer's next start. */
3319
- const startMode = (peer: string): StartMode => startModeOf(config.peers, peer, declarations);
3408
+ const startMode = (peer: string): StartMode => startModeOf(config.peers, peer, declarations, adapterCatalog);
3320
3409
  const startWords = (peer: string, mode: StartMode): string[] => {
3321
3410
  const words = definitionOf(peer)?.cli.startWords(mode) ?? [peer];
3322
3411
  return declarations[peer] ? [words[0]!, "--as", peer, ...words.slice(1)] : words;
@@ -3325,6 +3414,29 @@ export async function startDaemon(opts: DaemonOptions) {
3325
3414
  // ponytail: one terminal per peer per 30 s, by the clock. The hub cannot see whether the terminal it opened still
3326
3415
  // exists, so a peer that never attaches can be opened again after that; track the provider's handle to do better.
3327
3416
  const opening = new Map<string, number>();
3417
+ // #286 AC10: a peer with no native TUI (headless Kimi, Qwen) is watched through `ahub watch <peer>`. A start the hub made
3418
+ // itself that really started such a peer opens that view through the same provider, with fixed words and no caller text.
3419
+ // The headless start has already succeeded: a missing or failing provider never fails it, it only reports the command.
3420
+ const watchOpening = new Map<string, number>();
3421
+ const watchSentence = (watch: unknown): string => {
3422
+ const w = watch as { command?: string; opened?: string } | undefined;
3423
+ return w?.command ? (w.opened ? ` A watch pane is open (${w.command}).` : ` Watch it with ${w.command}.`) : "";
3424
+ };
3425
+ async function openWatchPane(peer: string, result: Record<string, unknown>): Promise<Record<string, unknown>> {
3426
+ if (result.ok !== true || result.already || declarations[peer] || !definitionOf(peer)?.capabilities?.watch) return result;
3427
+ const command = `ahub watch ${peer}`;
3428
+ if (Date.now() - (watchOpening.get(peer) ?? 0) < PEER_START_RETRY_MS) return { ...result, watch: { command } };
3429
+ watchOpening.set(peer, Date.now());
3430
+ const opened = await opener.open(`${peer} watch (agent-hub)`, [process.execPath, CLI_ENTRY, "--project", opts.cwd, "watch", peer]).catch((error: Error) => ({ ok: false as const, why: "the terminal provider failed", detail: error.message }));
3431
+ if (!opened.ok) {
3432
+ watchOpening.delete(peer);
3433
+ if (opened.detail) log(`terminal provider for ${peer} watch: ${opened.detail}`);
3434
+ try { notify(`${peer}: started headless; the hub opened no watch pane (${opened.why}). Watch it with ${command}`); } catch { /* the start stands */ }
3435
+ return { ...result, watch: { command } };
3436
+ }
3437
+ try { notify(`${peer}: started headless; the hub opened a watch pane running ${command} (${opened.via})`); } catch { /* the pane is open either way */ }
3438
+ return { ...result, watch: { command, opened: opened.via } };
3439
+ }
3328
3440
  /** For the dashboard's Start control: what a start of each peer would do now, and for a TUI where its terminal comes from. */
3329
3441
  // ponytail: asks the provider on every dashboard poll (a launch-record read and a PATH lookup for Orca); cache it for a few seconds if a page feels it.
3330
3442
  const startPlans = () => STARTABLE_PEERS.filter(startable).map((peer) => {
@@ -3372,8 +3484,8 @@ export async function startDaemon(opts: DaemonOptions) {
3372
3484
  // ponytail: an attached terminal Pi only. A terminal launch that the TUI has not claimed yet reads as offline,
3373
3485
  // like one whose terminal was closed, so a headless start in that window replaces it; record when a launch was
3374
3486
  // issued to tell the two apart.
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" };
3376
- return startPeer(peer, definitionOf(peer)?.start.explicitHeadlessMode ? { mode: "headless", ...(resume ?? {}) } : {});
3487
+ if (isPiPeer(owner) && 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" };
3488
+ return openWatchPane(peer, await startPeer(peer, definitionOf(peer)?.start.explicitHeadlessMode ? { mode: "headless", ...(resume ?? {}) } : {}));
3377
3489
  }
3378
3490
  // #559 N1: only the console's own start reopens a frontend on an attached owned-ACP peer. The conductor and the
3379
3491
  // dashboard answer `already`, so a repeated call never adds another TUI writer to the live session.
@@ -3406,11 +3518,11 @@ export async function startDaemon(opts: DaemonOptions) {
3406
3518
  if (stopping || recoveryActive()) return { ok: false, error: "recovery or shutdown is holding peer stops; finish the operation before retrying" };
3407
3519
  if (typeof peer !== "string" || !PEER_ID.test(peer)) return { ok: false, error: "unknown peer; run ahub status and choose a hub-owned headless peer" };
3408
3520
  const owner = bus.peers.get(peer);
3409
- const stopNotice = definitionOf(peer)?.cli.stopNotice?.(owner instanceof WsPeer ? "tui" : !owner ? startMode(peer) : "headless", owner);
3521
+ const stopNotice = definitionOf(peer)?.cli.stopNotice?.(isWsPeer(owner) ? "tui" : !owner ? startMode(peer) : "headless", owner);
3410
3522
  if (stopNotice) return { ok: false, error: stopNotice };
3411
- if (!(owner instanceof AcpPeer || owner instanceof PiPeer || owner instanceof LocalPeer || owner instanceof ReviewerPrintPeer)) return { ok: false, error: "unknown or non-owned peer; run ahub status and choose a hub-owned headless peer" };
3412
- if (owner instanceof PiPeer && owner.mode !== "headless") return { ok: false, error: "Pi belongs to a native TUI terminal; end it in its terminal" };
3413
- if (owner.state === "offline") return { ok: false, error: `peer is already offline; start it with ${startCommand(peer)} before stopping it` };
3523
+ if (!(isAcpPeer(owner) || isPiPeer(owner) || isLocalPeer(owner) || isReviewerPrintPeer(owner))) return { ok: false, error: "unknown or non-owned peer; run ahub status and choose a hub-owned headless peer" };
3524
+ if (isPiPeer(owner) && owner.mode !== "headless") return { ok: false, error: "Pi belongs to a native TUI terminal; end it in its terminal" };
3525
+ if (owner.state === "offline" && !(isAcpPeer(owner) && owner.pendingOwnedProcess)) return { ok: false, error: `peer is already offline; start it with ${startCommand(peer)} before stopping it` };
3414
3526
  if (starting.has(peer) || requestedPeerStops.has(peer) || permissionChanges.has(peer)) return { ok: false, error: "peer lifecycle or permission change is in progress; wait for ahub status before retrying" };
3415
3527
  requestedPeerStops.add(peer); // fence delivery, starts and recovery before the first asynchronous stop step
3416
3528
  try {
@@ -3418,7 +3530,7 @@ export async function startDaemon(opts: DaemonOptions) {
3418
3530
  if (stopping || recoveryActive() || bus.peers.get(peer) !== owner) return { ok: false, error: "peer or recovery changed; refresh ahub status before retrying" };
3419
3531
  const reason = `requested stop: ahub stop ${peer}`; // ids only, never task or message text
3420
3532
  // Pi persists a session only after its first message: take live empty-session proof before stopping.
3421
- if (owner instanceof PiPeer && owner.state === "idle") {
3533
+ if (isPiPeer(owner) && owner.state === "idle") {
3422
3534
  try { await owner.captureResume(); }
3423
3535
  catch { log(`requested stop ${peer}: resume evidence unavailable; later start requires verified persisted history`); }
3424
3536
  }
@@ -3458,7 +3570,7 @@ export async function startDaemon(opts: DaemonOptions) {
3458
3570
  try {
3459
3571
  if (declarations[peer] && args.mode !== undefined && args.mode !== "headless") return { ok: false, error: "declared instances support headless starts only" };
3460
3572
  let executionCwd: string;
3461
- try { executionCwd = resolveInstanceExecutionCwd(opts.cwd, peer, declarations, config.roles[peer] ?? []); }
3573
+ try { executionCwd = resolveInstanceExecutionCwd(opts.cwd, peer, declarations, config.roles[peer] ?? [], adapterCatalog); }
3462
3574
  catch (error) { return { ok: false, error: (error as Error).message }; }
3463
3575
  executionRoots.set(peer, executionCwd);
3464
3576
  applyPendingPermissionDefault(peer);
@@ -3472,6 +3584,13 @@ export async function startDaemon(opts: DaemonOptions) {
3472
3584
 
3473
3585
  async function startPeerBody(peer: string, args: { model?: string; route?: string; mode?: "headless" | "tui"; backend?: "auto" | "dgx" | "mlx"; sessionId?: string; sessionFile?: string; replaceSession?: boolean; fresh?: boolean; unattended?: boolean; providerProfile?: string }, mute: (p: PeerAdapter) => void, executionCwd: string): Promise<Record<string, unknown>> {
3474
3586
  const adapter = definitionOf(peer);
3587
+ if (adapter && configuredIds.includes(adapter.kind) && args.sessionId !== undefined
3588
+ && (typeof args.sessionId !== "string" || !args.sessionId.trim() || Buffer.byteLength(args.sessionId) > 4096 || /[\p{Cc}\p{Cf}]/u.test(args.sessionId))) {
3589
+ return { ok: false, error: "configured ACP session selector is invalid" };
3590
+ }
3591
+ const retainedOwner = bus.peers.get(peer);
3592
+ if (adapter?.permission.genericAcp && isAcpPeer(retainedOwner) && retainedOwner.pendingOwnedProcess && retainedOwner.state === "offline") return { ok: false, error: "configured ACP previous process teardown is unconfirmed; retry ahub stop before starting again" };
3593
+ if (adapter?.permission.genericAcp && (args.model || args.route || args.backend || args.unattended || args.providerProfile || args.sessionFile || args.fresh)) return { ok: false, error: "configured ACP accepts no native launch overrides" };
3475
3594
  if (adapter?.reviewer?.unavailable) return { ok: false, error: adapter.reviewer.unavailable };
3476
3595
  const preflight = adapter?.start.preflight?.({ instance: peer, form: args.mode });
3477
3596
  if (preflight) return { ok: false, error: preflight };
@@ -3480,7 +3599,7 @@ export async function startDaemon(opts: DaemonOptions) {
3480
3599
  const owner = bus.peers.get(peer);
3481
3600
  if ((args.mode ?? startMode(peer)) === "tui") {
3482
3601
  if (args.mode !== "tui") return { ok: false, error: "Kimi uses its own TUI (pull-only); run ahub kimi in a terminal, or ahub kimi --mode headless", command: "ahub kimi" };
3483
- if (owner instanceof AcpPeer && owner.state !== "offline") return { ok: false, error: "Kimi is running headless; run ahub stop kimi before starting its TUI" };
3602
+ if (isAcpPeer(owner) && owner.state !== "offline") return { ok: false, error: "Kimi is running headless; run ahub stop kimi before starting its TUI" };
3484
3603
  clearKimiAdmission();
3485
3604
  kimiAdmissionId = crypto.randomUUID();
3486
3605
  kimiSessionMode = "tui"; // scoped to this pending launch and its attached session
@@ -3488,7 +3607,7 @@ export async function startDaemon(opts: DaemonOptions) {
3488
3607
  kimiAdmissionExpiry = setTimeout(clearKimiAdmission, 30_000); kimiAdmissionExpiry.unref();
3489
3608
  return { ok: true, mode: "tui", pullOnly: true, command: "ahub kimi", admissionId: kimiAdmissionId };
3490
3609
  }
3491
- if (owner instanceof WsPeer && (owner.state !== "offline" || owner.claiming)) return { ok: false, error: "Kimi runs in a terminal; end it there before starting a headless one" };
3610
+ if (isWsPeer(owner) && (owner.state !== "offline" || owner.claiming)) return { ok: false, error: "Kimi runs in a terminal; end it there before starting a headless one" };
3492
3611
  if (hasKimiHubMcp(opts.cwd)) return { ok: false, error: "Kimi's project MCP file still has agent-hub; a person must run ahub kimi --mode headless to remove it before ACP starts", command: "ahub kimi --mode headless" };
3493
3612
  clearKimiAdmission();
3494
3613
  }
@@ -3503,7 +3622,7 @@ export async function startDaemon(opts: DaemonOptions) {
3503
3622
  if (args.replaceSession) {
3504
3623
  if (recoveryActive()) return { ok: false, error: "recovery is holding session replacement" };
3505
3624
  if (!args.sessionFile || args.sessionId || args.fresh) return { ok: false, error: "Pi replacement requires --replace-session --session-file <managed-file>, without --session-id or fresh" };
3506
- if (!(existing instanceof PiPeer) || !existing.replacementReady || piReceiptLedgers.get(peer)?.unsettled || [...permissions.values()].some(p => p.peer === peer)) return { ok: false, error: "Pi replacement requires an existing offline, settled owner with no live/unknown process, tools, receipts or approvals" };
3625
+ if (!isPiPeer(existing) || !existing.replacementReady || piReceiptLedgers.get(peer)?.unsettled || [...permissions.values()].some(p => p.peer === peer)) return { ok: false, error: "Pi replacement requires an existing offline, settled owner with no live/unknown process, tools, receipts or approvals" };
3507
3626
  const selected = validatePiSession(nativeStateDir(peer), executionCwd, args.sessionFile);
3508
3627
  const previous = existing.recoveryMetadata();
3509
3628
  recordPiReplacement(nativeStateDir(peer), instanceId, previous, selected);
@@ -3511,7 +3630,7 @@ export async function startDaemon(opts: DaemonOptions) {
3511
3630
  mute(existing);
3512
3631
  await stopPiForReplacement(existing);
3513
3632
  }
3514
- if (existing instanceof PiPeer && !args.replaceSession) {
3633
+ if (isPiPeer(existing) && !args.replaceSession) {
3515
3634
  let saved = existing.recoveryMetadata();
3516
3635
  const launch = saved.launch as Record<string, unknown>;
3517
3636
  if ((args.sessionId && saved.sessionId && args.sessionId !== saved.sessionId) || (args.sessionFile && saved.sessionFile && args.sessionFile !== saved.sessionFile)) return { ok: false, error: "Pi already owns a different session; refusing to replace its identity" };
@@ -3544,10 +3663,13 @@ export async function startDaemon(opts: DaemonOptions) {
3544
3663
  if (adapter?.start.reconfigureWhileAttached && existing?.state !== "offline" && existing && (args.model || args.route)) {
3545
3664
  if (existing.state === "busy") return { ok: false, error: "local is busy; retry when it is idle" };
3546
3665
  } else if (existing && existing.state !== "offline") {
3547
- return { ok: true, already: true, ...(existing instanceof CodexPeer ? { proxyUrl: existing.proxyUrl } : {}) };
3666
+ if (adapter && configuredIds.includes(adapter.kind) && args.sessionId !== undefined && args.sessionId !== adapter.session.currentId(existing)) {
3667
+ return { ok: false, error: "configured ACP is already running a different native session; stop it before selecting another" };
3668
+ }
3669
+ return { ok: true, already: true, ...(isCodexPeer(existing) ? { proxyUrl: existing.proxyUrl } : {}) };
3548
3670
  }
3549
3671
  if (!adapter?.start.reconfigureWhileAttached) {
3550
- if (existing instanceof PiPeer) await stopPiForReplacement(existing);
3672
+ if (isPiPeer(existing)) await stopPiForReplacement(existing);
3551
3673
  else await existing?.stop();
3552
3674
  }
3553
3675
  if (adapter?.start.daemonPort === "owned-acp") {
@@ -3563,7 +3685,7 @@ export async function startDaemon(opts: DaemonOptions) {
3563
3685
  if (adapter?.start.acpCommand) {
3564
3686
  const cmd = adapter.start.acpCommand(config.kimi_cmd, args.model);
3565
3687
  const kimi: AcpPeer = adapter!.start.create!({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: config, permission: permissionMode(peer), mcpServers: [], daemon: DAEMON_PORTS["acp"]({
3566
- onActivity: item => activityRings[peer]!.append(item),
3688
+ onActivity: item => activityRings[peer]?.append(item),
3567
3689
  isPrivateTurn: () => !!turns.get(peer)?.private || contextPii(peer),
3568
3690
  cmd, kind: adapter.kind,
3569
3691
  permissionMode: permissionMode(peer),
@@ -3592,11 +3714,9 @@ export async function startDaemon(opts: DaemonOptions) {
3592
3714
  if (observation.source !== "usage_update" || observation.shape !== "context-used") return;
3593
3715
  const current = contextSession(peer);
3594
3716
  if (!current) return;
3595
- contexts.report(peer,
3596
- observation.contextUsed !== undefined && observation.contextCapacity !== undefined
3597
- ? acpContext({ used: observation.contextUsed, size: observation.contextCapacity }, sessionId, Date.now())
3598
- : { source: "acp_usage_update", sessionId, measuredAt: Date.now(), tokens: null, window: null, used: null },
3599
- current);
3717
+ const reading = readNativeContext(adapter,
3718
+ { used: observation.contextUsed, size: observation.contextCapacity }, sessionId, Date.now());
3719
+ if (reading) contexts.report(peer, reading, current);
3600
3720
  },
3601
3721
  onTurnFailure: () => { supervisionTurns.delete(peer); },
3602
3722
  mcpServers: [{ name: "agent-hub", command: "bun", args: ["run", SERVER_JS], env: Object.entries(toolEnv(peer, true)).map(([name, value]) => ({ name, value })) }],
@@ -3610,36 +3730,38 @@ export async function startDaemon(opts: DaemonOptions) {
3610
3730
  } catch (error) { await kimi.stop().catch(stop => log(`kimi stop after a failed start: ${(stop as Error).message}`)); throw error; }
3611
3731
  return { ok: true };
3612
3732
  }
3613
- if (adapter?.transport === "acp" && adapter.start.create) {
3614
- // ponytail: A1 migrates one ACP factory; A2 generalizes its native config and activity ports before adding another.
3733
+ if (adapter?.transport === "acp" && adapter.start.create && adapter.start.launch) {
3734
+ // Configured ACP definitions own immutable argv and policy; builtins retain their verified launch port.
3615
3735
  const launch = adapter.start.launch({ instance: peer, form: "headless", projectRoot: opts.cwd, stateDir: opts.stateDir, entrypoint: CLI_ENTRY,
3616
- userArgs: [], unattended: false, env: {}, nativeCommand: config.qwen_cmd, model: args.model });
3736
+ userArgs: [], unattended: false, env: {}, nativeCommand: configuredIds.includes(adapter.kind) ? undefined : config.qwen_cmd, model: args.model });
3617
3737
  const mcpServers = [{ name: "agent-hub", command: "bun", args: ["run", SERVER_JS], env: Object.entries(toolEnv(peer, true)).map(([name, value]) => ({ name, value })) }];
3618
- const permission = permissionMode(peer);
3738
+ const permission = readOnlyPeer(peer) ? "read-only" as const : permissionMode(peer);
3619
3739
  const owner = adapter.start.create({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: undefined, permission, mcpServers, daemon: { acp: {
3620
- onActivity: item => activityRings[peer]!.append(item),
3740
+ onActivity: item => activityRings[peer]?.append(item),
3621
3741
  isPrivateTurn: () => !!turns.get(peer)?.private || contextPii(peer),
3622
- kind: adapter.kind, cmd: [launch.cmd, ...launch.args], permissionMode: permission,
3742
+ kind: adapter.kind, cmd: [launch.cmd, ...launch.args], permissionMode: permission === "read-only" ? "ask" : permission,
3623
3743
  ...(args.model ? { launchModel: args.model } : {}), ...(args.sessionId ? { resumeSessionId: args.sessionId } : {}),
3624
3744
  cwd: executionCwd, watchdogMs: config.watchdog_ms, onPermission,
3625
3745
  autoApprove: title => !stopping && HUB_TOOL_TITLES.has(title),
3626
- log: () => log("Qwen runtime diagnostic: details withheld"),
3746
+ log: () => log(`${peer}: ACP runtime diagnostic: details withheld`),
3627
3747
  onUsageDiagnostic: (observation, sessionId) => {
3628
3748
  if (observation.source !== "usage_update" || observation.shape !== "context-used") return;
3629
3749
  const current = contextSession(peer); if (!current) return;
3630
- contexts.report(peer, observation.contextUsed !== undefined && observation.contextCapacity !== undefined
3631
- ? acpContext({ used: observation.contextUsed, size: observation.contextCapacity }, sessionId, Date.now())
3632
- : { source: "acp_usage_update", sessionId, measuredAt: Date.now(), tokens: null, window: null, used: null }, current);
3750
+ const reading = readNativeContext(adapter,
3751
+ { used: observation.contextUsed, size: observation.contextCapacity }, sessionId, Date.now());
3752
+ if (reading) contexts.report(peer, reading, current);
3633
3753
  },
3634
3754
  onTurnFailure: () => { supervisionTurns.delete(peer); },
3635
3755
  mcpServers,
3636
3756
  preamble: sessionRoleContract(peer),
3637
3757
  } } });
3638
- if (!(owner instanceof AcpPeer)) throw new Error("ACP factory did not return its native transport");
3758
+ if (!isAcpPeer(owner)) throw new Error("ACP factory did not return its native transport");
3639
3759
  recoveryTaskPreface(peer); await ensurePreface(peer);
3640
3760
  try { await owner.start(); await reconcileNativeStart(peer, owner); }
3641
3761
  catch (error) {
3642
- await owner.stop().catch(() => log("Qwen stop after failed startup was not confirmed"));
3762
+ await owner.stop().catch(() => log(`${peer}: ACP stop after failed startup was not confirmed`));
3763
+ if (configuredIds.includes(adapter.kind) && owner.pendingOwnedProcess) bus.add(owner); // Retain the failed owner for explicit stop retry; never replace its live child.
3764
+ if (configuredIds.includes(adapter.kind)) throw new Error(`${peer}: ACP startup identity, session, permission or owned-group verification failed (details withheld)`);
3643
3765
  if (error instanceof QwenIdentityError) throw error;
3644
3766
  throw new Error("Qwen could not reach its provider or complete its startup handshake; verify the provider connection and Qwen Code 0.25.0 (details withheld)");
3645
3767
  }
@@ -3652,7 +3774,8 @@ export async function startDaemon(opts: DaemonOptions) {
3652
3774
  admitAttach: () => membershipAllowed(peer),
3653
3775
  permissionMode: permissionMode(peer),
3654
3776
  onTokens: (added) => void addTokens(peer, added),
3655
- onContext: (reading) => contexts.report(peer, reading, contextSession(peer)),
3777
+ contextReader: adapter.budget?.context,
3778
+ onContext: adapter.budget?.context ? (reading) => contexts.report(peer, reading, contextSession(peer)) : undefined,
3656
3779
  // #215: before the thread's first turn_start, so the planner can tell which turns belong to which thread.
3657
3780
  onThread: (thread, fresh) => { lastCodexThreads.set(peer, thread); event({ type: "native_thread", peer, thread, fresh }); },
3658
3781
  onTurn: (native) => {
@@ -3701,7 +3824,7 @@ export async function startDaemon(opts: DaemonOptions) {
3701
3824
  ...HUB_TOOLS.map((name) => [`tools.${name}.approval_mode`, '"approve"']),
3702
3825
  ].flatMap(([k, v]) => ["-c", `mcp_servers.agent-hub.${k}=${v}`]),
3703
3826
  preamble: sessionRoleContract(peer),
3704
- onUsage: (rateLimits, hard) => budget.report(peer, codexWindows(rateLimits), hard),
3827
+ onUsage: (rateLimits, hard) => { const windows = readNativeQuota(adapter, rateLimits); if (windows) budget.report(peer, windows, hard); },
3705
3828
  usagePollMs: config.budget.poll_min * 60_000,
3706
3829
  cwd: executionCwd,
3707
3830
  watchdogMs: config.watchdog_ms,
@@ -3727,7 +3850,7 @@ export async function startDaemon(opts: DaemonOptions) {
3727
3850
  staySwitch: () => activeRouting(),
3728
3851
  onRoute: record => event({ type: "route", peer, ...record }),
3729
3852
  efficientWaitMs: activeRouting().pi.efficient_wait_ms,
3730
- underBudget: () => { const current = bus.peers.get(peer); return current instanceof PiPeer && tasks.budgetApplies(current.budgetEnvelopes, peer); },
3853
+ underBudget: () => { const current = bus.peers.get(peer); return isPiPeer(current) && tasks.budgetApplies(current.budgetEnvelopes, peer); },
3731
3854
  onCooldown: record => {
3732
3855
  log(`model ${record.alias}: ${record.event === "start" ? `cooling down for ${Math.round(record.ms! / 1000)} s after ${record.failures} transport or startup failures` : "cooldown ended"}`);
3733
3856
  event({ type: "cooldown", peer, ...record });
@@ -4011,9 +4134,9 @@ export async function startDaemon(opts: DaemonOptions) {
4011
4134
  return { ok: false, error: `unknown peer "${peer}"` };
4012
4135
  }
4013
4136
 
4014
- async function admitPiRequest(peer = "pi"): Promise<{ allowed: boolean; reason?: string; remainingMs?: number }> {
4137
+ async function admitPiRequest(peer: string): Promise<{ allowed: boolean; reason?: string; remainingMs?: number }> {
4015
4138
  const current = bus.peers.get(peer);
4016
- if (!(current instanceof PiPeer)) return { allowed: false, reason: "Pi owner is not active" };
4139
+ if (!isPiPeer(current)) return { allowed: false, reason: "Pi owner is not active" };
4017
4140
  const decisions = tasks.admitExecutionEnvelopes(current.budgetEnvelopes, peer, "model_calls");
4018
4141
  const denied = decisions.find(d => !d.allowed);
4019
4142
  const stop = denied ? current.recordBudgetStop(denied) : undefined;
@@ -4046,7 +4169,7 @@ export async function startDaemon(opts: DaemonOptions) {
4046
4169
  } else {
4047
4170
  if (budget.record(id)) return { ok: false, error: `${id} is paused by the budget coordinator until its window resets (ahub budget); to override: ahub budget resume ${id}` };
4048
4171
  const peer = bus.peers.get(id);
4049
- if (peer instanceof LocalPeer) {
4172
+ if (isLocalPeer(peer)) {
4050
4173
  const generation = pauseGeneration.get(id);
4051
4174
  const launch = peer.recoveryMetadata().launch as { route?: string; model: string };
4052
4175
  try { await validateLocalChoice(launch.route, launch.model); }
@@ -4067,6 +4190,7 @@ export async function startDaemon(opts: DaemonOptions) {
4067
4190
 
4068
4191
  const conductor = new Conductor(conductorHolds, {
4069
4192
  roles: () => config.roles, capabilities: () => config.capabilities, instances: () => declarations,
4193
+ adapterCatalog,
4070
4194
  status: () => {
4071
4195
  const quota = budget.status();
4072
4196
  const ids = [...new Set([...bus.knownPeers(), ...Object.keys(quota), ...conductorHolds.list().map(h => h.peer)])];
@@ -4099,7 +4223,7 @@ export async function startDaemon(opts: DaemonOptions) {
4099
4223
  pause: peer => { pauseGeneration.set(peer, (pauseGeneration.get(peer) ?? 0) + 1); bus.pause(peer); writeStatus(); },
4100
4224
  validateRelease: async peer => {
4101
4225
  const owner = bus.peers.get(peer);
4102
- if (!(owner instanceof LocalPeer)) return;
4226
+ if (!isLocalPeer(owner)) return;
4103
4227
  const generation = pauseGeneration.get(peer);
4104
4228
  const launch = owner.recoveryMetadata().launch as { route?: string; model: string };
4105
4229
  await validateLocalChoice(launch.route, launch.model);
@@ -4274,7 +4398,7 @@ export async function startDaemon(opts: DaemonOptions) {
4274
4398
  function roleHolders(candidate: HubConfig): { planners: string[]; conductors: string[] } {
4275
4399
  // A valid transfer may briefly have two conductors while its leaves are planned. Validate the final map later.
4276
4400
  const policy = Object.fromEntries(Object.entries(candidate.roles).map(([peer, roles]) => [peer, roles.filter(role => role !== "conductor")]));
4277
- const settled = resolveSettledRoles(policy, candidate.planner, peer => rosterAllows(candidate.roster, peer, candidate.pi.enabled, declarations));
4401
+ const settled = resolveSettledRoles(policy, candidate.planner, peer => rosterAllows(candidate.roster, peer, candidate.pi.enabled, declarations, adapterCatalog));
4278
4402
  return { planners: settled.planners, conductors: Object.keys(candidate.roles).filter(peer => candidate.roles[peer]!.includes("conductor")).sort() };
4279
4403
  }
4280
4404
  function prepareConfiguration(sources: ConfigSources, authority: SettingAuthority, scope?: string, confirmed: readonly string[] = []) {
@@ -4288,7 +4412,7 @@ export async function startDaemon(opts: DaemonOptions) {
4288
4412
  const prefix = layer.name.endsWith(".toml") ? "routing" : "";
4289
4413
  for (const path of declaredPaths(layer.raw, prefix)) paths.add(path);
4290
4414
  }
4291
- if (!opts.config) for (const change of configChanges(desiredConfig, fileConfig)) paths.add(change.key);
4415
+ if (!opts.config) for (const change of configChanges(desiredConfig, fileConfig, [{ catalog: adapterCatalog, instances: declarations }, configIdentityScope(acceptedSources.identity), configIdentityScope(sources.identity)])) paths.add(change.key);
4292
4416
  for (const key of paths) {
4293
4417
  if (scope && key !== scope) continue;
4294
4418
  const route = key.startsWith("routing."), path = (route ? key.slice(8) : key).split(".");
@@ -4303,10 +4427,12 @@ export async function startDaemon(opts: DaemonOptions) {
4303
4427
  const after = valueAt(route ? sources.routing : opts.config && !current ? embeddedConfig : fileConfig, path);
4304
4428
  const rawAfter = current ? valueAt(current.raw, path) : undefined;
4305
4429
  if (!rawChanged && JSON.stringify(before) === JSON.stringify(after)) continue;
4430
+ // Compare the running declaration, never a pending desired value accepted by an earlier reload.
4306
4431
  // Instance declarations are restart-bound. Their derived role/permission/start defaults cannot leak into
4307
4432
  // the live catalog while the old identities, paths and account callbacks still own the daemon.
4308
4433
  const boundId = ["roles", "permission_modes", "peers", "permission_default_sources"].includes(path[0] ?? "") ? path[1] : undefined;
4309
- if (!route && boundId && JSON.stringify(desiredConfig.instances?.[boundId]) !== JSON.stringify(fileConfig.instances?.[boundId])) {
4434
+ const boundKind = boundId ? declarations[boundId]?.kind ?? boundId : undefined;
4435
+ if (!route && boundId && (JSON.stringify(config.instances?.[boundId]) !== JSON.stringify(fileConfig.instances?.[boundId]) || JSON.stringify(boundKind ? config.adapters?.[boundKind] : undefined) !== JSON.stringify(boundKind ? fileConfig.adapters?.[boundKind] : undefined))) {
4310
4436
  rejected.push({ key, reason: "instance declaration changed; its derived defaults apply together after daemon restart" });
4311
4437
  continue;
4312
4438
  }
@@ -4370,7 +4496,7 @@ export async function startDaemon(opts: DaemonOptions) {
4370
4496
  }
4371
4497
  candidate.retired = fileConfig.retired;
4372
4498
  routing.retired = sources.routing.retired;
4373
- const changes = configChanges({ ...config, roles: policyRoles }, candidate);
4499
+ const changes = configChanges({ ...config, roles: policyRoles }, candidate, [{ catalog: adapterCatalog, instances: declarations }, configIdentityScope(acceptedSources.identity), configIdentityScope(sources.identity)]);
4374
4500
  const running = structuredClone({ ...config, roles: policyRoles });
4375
4501
  for (const change of changes) if (change.applies !== "restart") putConfig(running, change.key, change.after);
4376
4502
  running.retired = fileConfig.retired;
@@ -4404,7 +4530,7 @@ export async function startDaemon(opts: DaemonOptions) {
4404
4530
  const texts = configSourceTexts(opts.cwd), next = configSourceTexts(scratch);
4405
4531
  texts["config.json"] = next["config.json"];
4406
4532
  texts["config.local.json"] = next["config.local.json"];
4407
- const prepared = prepareConfiguration(parseConfigSources(texts), authority, scope);
4533
+ const prepared = prepareConfiguration(parseConfigSources(texts, undefined, opts.cwd), authority, scope);
4408
4534
  const refusal = prepared.rejected.find(item => item.key === scope);
4409
4535
  if (refusal) throw new Error(`${refusal.key}: ${refusal.reason}`);
4410
4536
  return prepared.fileConfig;
@@ -4412,7 +4538,7 @@ export async function startDaemon(opts: DaemonOptions) {
4412
4538
  function routingWriteCheck(authority: SettingAuthority, overlay: import("./routing.ts").RoutingOverlay | undefined, scope?: string): void {
4413
4539
  const texts = configSourceTexts(opts.cwd);
4414
4540
  texts["routing.local.toml"] = overlay === undefined ? null : overlayToml(overlay);
4415
- const prepared = prepareConfiguration(parseConfigSources(texts), authority, scope);
4541
+ const prepared = prepareConfiguration(parseConfigSources(texts, undefined, opts.cwd), authority, scope);
4416
4542
  const refusal = prepared.rejected.find(item => item.key === scope);
4417
4543
  if (refusal) throw new Error(`${refusal.key}: ${refusal.reason}`);
4418
4544
  }
@@ -4429,7 +4555,7 @@ export async function startDaemon(opts: DaemonOptions) {
4429
4555
  const texts = configSourceTexts(opts.cwd);
4430
4556
  fingerprint = configFingerprint(texts);
4431
4557
  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" };
4432
- const sources = parseConfigSources(texts);
4558
+ const sources = parseConfigSources(texts, undefined, opts.cwd);
4433
4559
  const prepared = prepareConfiguration(sources, authority, scope, confirm === undefined ? [] : reloadConfirmation!.keys);
4434
4560
  if (confirm !== undefined && prepared.rejected.some(item => reloadConfirmation!.keys.includes(item.key))) return { ok: false, error: "configuration trust changed; run ahub settings reload again" };
4435
4561
  if (scope && prepared.rejected.some(item => item.key === scope)) return { ok: false, error: prepared.rejected.find(item => item.key === scope)!.reason };
@@ -4463,7 +4589,7 @@ export async function startDaemon(opts: DaemonOptions) {
4463
4589
  desiredRouting = structuredClone(prepared.routing);
4464
4590
  acknowledgeConfiguration(sources, prepared.accepted);
4465
4591
  if (priorDesired !== JSON.stringify([desiredConfig, desiredRouting, acceptedSources.trustLayers])) configRevision++;
4466
- pendingRestart = configChanges({ ...config, roles: policyRoles }, candidate).filter(change => change.applies === "restart").map(change => ({ key: change.key, reason: change.reason! }));
4592
+ pendingRestart = configChanges({ ...config, roles: policyRoles }, candidate, [{ catalog: adapterCatalog, instances: declarations }, configIdentityScope(acceptedSources.identity), configIdentityScope(sources.identity)]).filter(change => change.applies === "restart").map(change => ({ key: change.key, reason: change.reason! }));
4467
4593
  pendingRestart.push(...routingChanges.filter(key => configApply(key).applies === "restart").map(key => ({ key, reason: configApply(key).reason! })));
4468
4594
  retainedConfig = retainedConfigAfterReload(sources, prepared.fileConfig, prepared.rejected, scope);
4469
4595
  storedConfigCache = undefined;
@@ -4517,9 +4643,9 @@ export async function startDaemon(opts: DaemonOptions) {
4517
4643
  let routingProblem: string | undefined;
4518
4644
  try { parseRouting(routingText(opts.cwd), readOverlay(opts.cwd)); } catch (error) { routingProblem = (error as Error).message; }
4519
4645
  const rows = settingRows({
4520
- instances: declarations, cwd: opts.cwd, routing: activeRouting(), ...(routingProblem ? { routingProblem } : {}), ...(stored?.ignored ? { ignored: stored.ignored } : {}),
4646
+ instances: declarations, adapterCatalog, cwd: opts.cwd, routing: activeRouting(), ...(routingProblem ? { routingProblem } : {}), ...(stored?.ignored ? { ignored: stored.ignored } : {}),
4521
4647
  running: (path) => path[0] === "roster" ? onRoster(path[1]!) : path[0] === "tasks" && path[1] === "wip_limits" ? tasks.wipLimit(path[2]!) : valueAt(config, path),
4522
- next: stored ? (path) => path[0] === "roster" ? rosterAllows(stored.roster, path[1]!, stored.pi.enabled, declarations) : 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,
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,
4523
4649
  roles: roleState,
4524
4650
  mode: (peer) => {
4525
4651
  const owner = bus.peers.get(peer), attached = !!owner && owner.state !== "offline";
@@ -4558,14 +4684,14 @@ export async function startDaemon(opts: DaemonOptions) {
4558
4684
  if (owner?.state === "busy" && !now) return `${peer} is in a turn; retry when it is idle, or pass --now`;
4559
4685
  return undefined;
4560
4686
  }
4561
- function codexResumeCommand(peer = "codex"): string {
4562
- const owner = bus.peers.get(peer), thread = owner instanceof CodexPeer ? owner.thread || lastCodexThreads.get(peer) : lastCodexThreads.get(peer);
4687
+ function codexResumeCommand(peer: string): string {
4688
+ const owner = bus.peers.get(peer), thread = isCodexPeer(owner) ? owner.thread || lastCodexThreads.get(peer) : lastCodexThreads.get(peer);
4563
4689
  const command = declarations[peer] ? `ahub codex --as ${peer} resume` : "ahub codex resume";
4564
4690
  return thread && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(thread) ? `${command} ${thread}` : command;
4565
4691
  }
4566
4692
  function retainedTerminalNotice(peer: string): string | undefined {
4567
4693
  const owner = bus.peers.get(peer);
4568
- return definitionOf(peer)?.cli.retainedTerminalNotice?.(peer, owner instanceof PiPeer ? owner.mode : undefined, codexResumeCommand(peer));
4694
+ return definitionOf(peer)?.cli.retainedTerminalNotice?.(peer, isPiPeer(owner) ? owner.mode : undefined, codexResumeCommand(peer));
4569
4695
  }
4570
4696
 
4571
4697
  async function changeRosterSetting(def: SettingDef, value: SettingValue, source: "dashboard" | "terminal", confirm: unknown, now: boolean, undo = false, authority: SettingAuthority = "ordinary", reason?: string, undoIdentity?: UndoIdentity): Promise<Record<string, unknown>> {
@@ -4575,7 +4701,7 @@ export async function startDaemon(opts: DaemonOptions) {
4575
4701
  const terminalNotice = retainedTerminalNotice(peer);
4576
4702
  let handoff: Awaited<ReturnType<Tasks["reassignForRoster"]>> = [];
4577
4703
  try {
4578
- const next = rosterCandidate(def, value, undo), before = onRoster(peer), after = rosterAllows(next, peer, config.pi.enabled, declarations);
4704
+ const next = rosterCandidate(def, value, undo), before = onRoster(peer), after = rosterAllows(next, peer, config.pi.enabled, declarations, adapterCatalog);
4579
4705
  if (!after) {
4580
4706
  const refusal = rosterRemovalRefusal(peer, now);
4581
4707
  if (refusal) return { ok: false, error: refusal };
@@ -4594,9 +4720,9 @@ export async function startDaemon(opts: DaemonOptions) {
4594
4720
  if (!after) {
4595
4721
  for (const pending of permissions.values()) if (pending.peer === peer) pending.done(undefined);
4596
4722
  const owner = bus.peers.get(peer);
4597
- if (owner instanceof WsPeer) { owner.detachForRoster(); if (definitionOf(peer)?.attach?.admission) clearKimiAdmission(); }
4598
- else if (owner instanceof CodexPeer) owner.detachForRoster(config.permission_modes[peer] === "never-ask" ? "ask" : config.permission_modes[peer] ?? "ask");
4599
- else if (owner instanceof PiPeer && owner.mode === "tui") await owner.detachForRoster();
4723
+ if (isWsPeer(owner)) { owner.detachForRoster(); if (definitionOf(peer)?.attach?.admission) clearKimiAdmission(); }
4724
+ else if (isCodexPeer(owner)) owner.detachForRoster(config.permission_modes[peer] === "never-ask" ? "ask" : config.permission_modes[peer] ?? "ask");
4725
+ else if (isPiPeer(owner) && owner.mode === "tui") await owner.detachForRoster();
4600
4726
  else if (owner && owner.state !== "offline") {
4601
4727
  const result = await stopPeer(peer, true);
4602
4728
  if (result.ok !== true) throw new Error(`roster saved; ${peer} stop is unconfirmed; permission reset and task handoff were not attempted; inspect ahub status and reapply ${def.key} false to retry`);
@@ -4609,7 +4735,7 @@ export async function startDaemon(opts: DaemonOptions) {
4609
4735
  handoff = await tasks.reassignForRoster(peer);
4610
4736
  } else {
4611
4737
  const owner = bus.peers.get(peer);
4612
- if (owner instanceof PiPeer && owner.mode === "tui") await owner.resumeFromRoster();
4738
+ if (isPiPeer(owner) && owner.mode === "tui") await owner.resumeFromRoster();
4613
4739
  }
4614
4740
  const reloaded = await reloadConfig(authority, `roster.${peer}`);
4615
4741
  if (reloaded.ok !== true) return { ...reloaded, saved: true };
@@ -4724,14 +4850,14 @@ export async function startDaemon(opts: DaemonOptions) {
4724
4850
  const { def, value } = input;
4725
4851
  if (def.group === "Roster") {
4726
4852
  try {
4727
- const peer = def.path[1]!, next = rosterCandidate(def, value), on = rosterAllows(next, peer, config.pi.enabled, declarations);
4853
+ const peer = def.path[1]!, next = rosterCandidate(def, value), on = rosterAllows(next, peer, config.pi.enabled, declarations, adapterCatalog);
4728
4854
  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 }) };
4729
4855
  } catch (error) { return { ok: false, error: sanitize((error as Error).message) }; }
4730
4856
  }
4731
4857
  if (def.key === "scoped_grants.protect_instructions") return { ok: true, lines: ["Applies now to scoped writes and edits of AGENTS.md, CLAUDE.md and QWEN.md, including symlinks and folded spellings.", "true asks a person; false keeps these documents grantable (the default). Explicit approval and never-ask keep their meaning."] };
4732
4858
  if (def.group === "Permissions") {
4733
4859
  const peer = def.path.at(-1)!;
4734
- return { ok: true, lines: [permissionBoundary(peer, declarations), ...(isPermissionMode(value) ? [`${value}: ${permissionGrant(peer, value, declarations)}`] : ["inherit: the shared file's default, or ask"])], ...(value === "never-ask" || value === null && restoredValue(opts.cwd, opts.stateDir, def, true) === "never-ask" ? { confirm: peer } : {}) };
4860
+ return { ok: true, lines: [permissionBoundary(peer, declarations, adapterCatalog), ...(isPermissionMode(value) ? [`${value}: ${permissionGrant(peer, value, declarations, adapterCatalog)}`] : ["inherit: the shared file's default, or ask"])], ...(value === "never-ask" || value === null && restoredValue(opts.cwd, opts.stateDir, def, true) === "never-ask" ? { confirm: peer } : {}) };
4735
4861
  }
4736
4862
  if (def.store !== "routing") return { ok: true, lines: [`${def.key}: ${settingText(value)}; ${def.applies === "next use" ? "applies at next use" : def.applies === "live" ? "in force as soon as it is saved" : "pending restart"}`] };
4737
4863
  try {
@@ -4826,7 +4952,7 @@ export async function startDaemon(opts: DaemonOptions) {
4826
4952
  // #269: the hub's own start of a peer from the closed list, in the mode a person set. No argument reaches a command.
4827
4953
  if (!peer()) return bad;
4828
4954
  const result = await hubStart(a.peer, "the dashboard");
4829
- if (result.ok === true) return { ok: true, text: result.already ? `${a.peer} is already attached.` : result.opened ? `Opened a terminal running ${result.command}; ${a.peer} attaches when its TUI is ready.` : `${a.peer} started (headless).` };
4955
+ if (result.ok === true) return { ok: true, text: result.already ? `${a.peer} is already attached.` : result.opened ? `Opened a terminal running ${result.command}; ${a.peer} attaches when its TUI is ready.` : `${a.peer} started (headless).${watchSentence(result.watch)}` };
4830
4956
  // A refusal the hub worded itself carries the command to run by hand; an adapter's own failure text stays in the log.
4831
4957
  if (typeof result.command === "string" || typeof a.peer === "string" && !membershipAllowed(a.peer)) return { ok: false, error: sanitize(String(result.error)) };
4832
4958
  log(`dashboard start ${a.peer} failed: ${String(result.error)}`);
@@ -4862,7 +4988,7 @@ export async function startDaemon(opts: DaemonOptions) {
4862
4988
  ...(checkPending.size !== 0 ? ["isolated checks queued or running"] : []),
4863
4989
  ...(piCallsInFlight() !== 0 ? ["Pi tool calls in flight"] : []),
4864
4990
  ...(releasing ? ["ready tasks being released"] : []),
4865
- ...([...bus.peers].filter(([, peer]) => peer instanceof PiPeer && peer.state !== "busy" && !peer.recoveryReady).map(([id, peer]) => !membershipAllowed(id) && peer instanceof PiPeer && peer.mode === "tui" ? `${id} is not settled; end the retained Pi TUI, then prepare again` : `${id} is not settled`)),
4991
+ ...([...bus.peers].filter(([, peer]) => isPiPeer(peer) && peer.state !== "busy" && !peer.recoveryReady).map(([id, peer]) => !membershipAllowed(id) && isPiPeer(peer) && peer.mode === "tui" ? `${id} is not settled; end the retained Pi TUI, then prepare again` : `${id} is not settled`)),
4866
4992
  ],
4867
4993
  integrity: { current: integrityAs(restored?.integrity), ...(restored?.integrity ? { expected: restored.integrity } : {}) },
4868
4994
  peers: Object.fromEntries([...(recoveryPeerSnapshot ?? []), ...Object.values(recoveryPeers()).filter((peer) => !(recoveryPeerSnapshot ?? []).some((saved) => saved.id === peer.id))].map((peer) => {
@@ -4883,6 +5009,7 @@ export async function startDaemon(opts: DaemonOptions) {
4883
5009
  const incompatibleRecoveryTarget = () => {
4884
5010
  const cancelled = incompatibleCancellationTarget();
4885
5011
  if (cancelled) return cancelled;
5012
+ 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";
4886
5013
  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";
4887
5014
  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";
4888
5015
  if ((extraIds.some(id => !!definitionOf(id)?.reviewer) || config.review.pool !== undefined) && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("reviewer_pool"))) return "target lacks reviewer_pool; fresh review-only variants and pool policy cannot be preserved";
@@ -4894,7 +5021,7 @@ export async function startDaemon(opts: DaemonOptions) {
4894
5021
  }
4895
5022
 
4896
5023
  if (msg.op === "inspect" || msg.op === "prepare" || msg.op === "commit") {
4897
- for (const pi of bus.peers.values()) if (pi instanceof PiPeer && pi.state === "idle") {
5024
+ for (const pi of bus.peers.values()) if (isPiPeer(pi) && pi.state === "idle") {
4898
5025
  try { await pi.captureResume(); } catch (error) { return recoveryError((error as Error).message); }
4899
5026
  if (checkTarget) { const error = incompatibleRecoveryTarget(); if (error) return recoveryError(error); }
4900
5027
  }
@@ -5004,7 +5131,7 @@ export async function startDaemon(opts: DaemonOptions) {
5004
5131
  return recoveryError("unknown recovery operation");
5005
5132
  }
5006
5133
 
5007
- function claudePermissionLaunch(peer = "claude"): { launchId: string; unattended: boolean; hookPurpose?: string } | undefined {
5134
+ function claudePermissionLaunch(peer: string): { launchId: string; unattended: boolean; hookPurpose?: string } | undefined {
5008
5135
  try {
5009
5136
  const row = JSON.parse(readFileSync(join(opts.stateDir, claudeStateFile("claude-launch", peer)), "utf8"));
5010
5137
  if (row.instanceId !== instanceId || row.permissionHook !== true || typeof row.launchId !== "string" || !row.launchId) return undefined;
@@ -5013,15 +5140,16 @@ export async function startDaemon(opts: DaemonOptions) {
5013
5140
  }
5014
5141
  async function changePermission(peer: unknown, mode: unknown, confirmed: unknown): Promise<Record<string, unknown>> {
5015
5142
  if (!isSupportedPeer(peer)) return { ok: false, error: "unknown permission peer" };
5143
+ if (readOnlyPeer(peer)) return { ok: false, error: "read-only ceiling is locked for this instance" };
5016
5144
  if (definitionOf(peer)?.permission.unmanaged) return { ok: false, error: definitionOf(peer)!.permission.unmanaged };
5017
- if (definitionOf(peer)?.permission.unmanagedForms?.includes("tui") && (bus.peers.get(peer) instanceof WsPeer || (!bus.peers.has(peer) && startMode(peer) === "tui"))) return { ok: false, error: definitionOf(peer)!.permission.unmanagedNote };
5145
+ if (definitionOf(peer)?.permission.unmanagedForms?.includes("tui") && (isWsPeer(bus.peers.get(peer)) || (!bus.peers.has(peer) && startMode(peer) === "tui"))) return { ok: false, error: definitionOf(peer)!.permission.unmanagedNote };
5018
5146
  if (requestedPeerStops.has(peer)) return { ok: false, error: "peer stop is in progress; wait for ahub status before changing its mode" };
5019
5147
  const owner = bus.peers.get(peer);
5020
5148
  if (mode === "ask" && (!owner || owner.state === "offline")) {
5021
5149
  // A Codex proxy outlives its TUI and keeps its own copy of the mode: clear that too, or the next TUI that attaches
5022
5150
  // to it would run a mode the status no longer shows.
5023
5151
  // It is never refused here: a leftover non-ask mode must always be undoable while the peer is away.
5024
- const note = owner instanceof CodexPeer && !owner.clearPermissionMode() ? "The detached session's own approval policy was never reported, so its thread keeps what it last got; a new ahub codex session starts in ask" : undefined;
5152
+ const note = isCodexPeer(owner) && !owner.clearPermissionMode() ? "The detached session's own approval policy was never reported, so its thread keeps what it last got; a new ahub codex session starts in ask" : undefined;
5025
5153
  if (note) log(`permission mode codex: ${note}`);
5026
5154
  const from = permissionMode(peer);
5027
5155
  permissionModes.set(peer, "ask"); permissionModeSources.set(peer, "human runtime command"); pendingPermissionDefaults.delete(peer);
@@ -5029,22 +5157,22 @@ export async function startDaemon(opts: DaemonOptions) {
5029
5157
  log(`permission mode ${peer}: ${from} -> ask (offline hub choice cleared)`); writeStatus();
5030
5158
  return { ok: true, peer, permissionMode: "ask", ...(note ? { note } : {}) };
5031
5159
  }
5032
- if (owner instanceof CodexPeer && !owner.proxyAttached) return { ok: false, error: "Codex TUI is not behind the hub proxy; reconnect with ahub codex" };
5160
+ if (isCodexPeer(owner) && !owner.proxyAttached) return { ok: false, error: "Codex TUI is not behind the hub proxy; reconnect with ahub codex" };
5033
5161
  if (!owner || owner.state === "offline") return { ok: false, error: `${peer} is not attached; start it through ahub first` };
5034
5162
  if (mode === undefined) return { ok: true, peer, permissionMode: permissionDisplay(peer, owner) };
5035
5163
  if (!isPermissionMode(mode)) return { ok: false, error: "mode must be ask, ask-when-needed or never-ask" };
5036
5164
  if (mode === "never-ask" && confirmed !== true) return { ok: false, error: "never-ask needs --yes or console confirmation" };
5037
5165
  if (definitionOf(peer)?.transport === "claude-channel") {
5038
5166
  const launch = claudePermissionLaunch(peer);
5039
- if (!launch || !(owner instanceof WsPeer) || claudePermissionGenerations.get(peer) !== owner.sessionGeneration) return { ok: false, error: "Claude has no verified ahub claude permission hook; restart with ahub claude and run a tool first" };
5167
+ if (!launch || !isWsPeer(owner) || claudePermissionGenerations.get(peer) !== owner.sessionGeneration) return { ok: false, error: "Claude has no verified ahub claude permission hook; restart with ahub claude and run a tool first" };
5040
5168
  if (launch.unattended) return { ok: false, error: "Claude started --unattended already skips prompts; run ahub permission claude ask after it stops to clear the hub choice, then restart without --unattended" };
5041
- } else if (owner instanceof CodexPeer) {
5169
+ } else if (isCodexPeer(owner)) {
5042
5170
  if (!owner.proxyAttached) return { ok: false, error: "Codex TUI is not behind the hub proxy; reconnect with ahub codex" };
5043
5171
  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" };
5044
5172
  }
5045
5173
  const from = permissionMode(peer);
5046
5174
  try {
5047
- if (owner instanceof AcpPeer || owner instanceof CodexPeer) await owner.setPermissionMode(mode);
5175
+ if (isAcpPeer(owner) || isCodexPeer(owner)) await owner.setPermissionMode(mode);
5048
5176
  } catch (error) { return { ok: false, error: (error as Error).message }; }
5049
5177
  if (bus.peers.get(peer) !== owner || bus.stateOf(peer) === "offline") return { ok: false, error: `${peer} session changed; inspect status and retry` };
5050
5178
  permissionModes.set(peer, mode);
@@ -5057,6 +5185,7 @@ export async function startDaemon(opts: DaemonOptions) {
5057
5185
  }
5058
5186
 
5059
5187
  async function confirmPermissionDefault(peer: unknown, confirmed: unknown): Promise<Record<string, unknown>> {
5188
+ if (typeof peer === "string" && readOnlyPeer(peer)) return { ok: false, error: "read-only ceiling is locked for this instance" };
5060
5189
  if (typeof peer !== "string" || !pendingPermissionDefaults.has(peer)) return { ok: false, error: "no pending permission default for this peer" };
5061
5190
  const source = pendingPermissionDefaults.get(peer)!;
5062
5191
  if (confirmed !== true) {
@@ -5066,7 +5195,7 @@ export async function startDaemon(opts: DaemonOptions) {
5066
5195
  }
5067
5196
  const owner = bus.peers.get(peer);
5068
5197
  if (definitionOf(peer)?.permission.trackUnattended && nativeUnattended(peer)) return { ok: false, error: "Codex started --unattended cannot confirm a permission default; run ahub permission codex ask after it stops, or restart without --unattended" };
5069
- if (!starting.has(peer) && owner instanceof CodexPeer && owner.state === "offline") return { ok: false, error: "connect the Codex TUI through the hub proxy before confirming its default" };
5198
+ if (!starting.has(peer) && isCodexPeer(owner) && owner.state === "offline") return { ok: false, error: "connect the Codex TUI through the hub proxy before confirming its default" };
5070
5199
  if (!starting.has(peer) && owner && owner.state !== "offline") {
5071
5200
  const result = await changePermission(peer, "never-ask", true);
5072
5201
  if (!result.ok) return result;
@@ -5097,9 +5226,9 @@ export async function startDaemon(opts: DaemonOptions) {
5097
5226
  const result = await permissionChange(peer, async () => {
5098
5227
  if (stopping) return { ok: false, error: "hub is stopping" };
5099
5228
  const selected = permissionMode(peer);
5100
- if (owner instanceof CodexPeer && nativeUnattended(peer) && selected !== "ask") return { ok: false, error: "Codex --unattended cannot take a non-ask mode; run ahub permission codex ask, or restart without --unattended" };
5101
- if (!(owner instanceof AcpPeer && owner.permissionModeState === "unmanaged") && owner.getPermissionMode() !== selected) {
5102
- if (owner instanceof CodexPeer && !owner.proxyAttached) owner.setStartupPermissionMode(selected);
5229
+ if (isCodexPeer(owner) && nativeUnattended(peer) && selected !== "ask") return { ok: false, error: "Codex --unattended cannot take a non-ask mode; run ahub permission codex ask, or restart without --unattended" };
5230
+ if (!readOnlyPeer(peer) && !(isAcpPeer(owner) && owner.permissionModeState === "unmanaged") && owner.getPermissionMode() !== selected) {
5231
+ if (isCodexPeer(owner) && !owner.proxyAttached) owner.setStartupPermissionMode(selected);
5103
5232
  else await owner.setPermissionMode(selected);
5104
5233
  }
5105
5234
  // The bus may immediately drain at add(): do not expose an idle adapter until its current choice is applied.
@@ -5136,7 +5265,7 @@ export async function startDaemon(opts: DaemonOptions) {
5136
5265
  if (!PEER_ID.test(c.peer) || c.peer === USER || c.peer === "hub") return sock.close(4403, "peer id is reserved or malformed");
5137
5266
  if (!membershipAllowed(c.peer)) return sock.close(4000, "peer is off roster; retry when a person adds it");
5138
5267
  } else {
5139
- c.peer = String(msg.peer ?? "claude");
5268
+ c.peer = String(msg.peer ?? defaultChannelPeer(undefined));
5140
5269
  if (definitionOf(c.peer)?.transport === "ws-pull" && msg.channel !== false) return sock.close(4403, "this native peer supports pull-only attachment; use hub_inbox");
5141
5270
  const kimiPull = !!definitionOf(c.peer)?.attach?.admission && (kimiSessionMode ?? startMode(c.peer)) === "tui" && msg.channel === false;
5142
5271
  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");
@@ -5152,9 +5281,9 @@ export async function startDaemon(opts: DaemonOptions) {
5152
5281
  if (typeof msg.launchId === "string" && msg.launchId.length <= 128) c.launchId = msg.launchId;
5153
5282
  }
5154
5283
  let peer = bus.peers.get(c.peer);
5155
- if (kimiPull && peer instanceof AcpPeer && peer.state === "offline") peer = undefined;
5284
+ if (kimiPull && isAcpPeer(peer) && peer.state === "offline") peer = undefined;
5156
5285
  if (!peer) bus.add((peer = new WsPeer(c.peer)));
5157
- if (!(peer instanceof WsPeer)) return sock.close(4409, "peer id is taken by a hub-managed adapter");
5286
+ if (!isWsPeer(peer)) return sock.close(4409, "peer id is taken by a hub-managed adapter");
5158
5287
  const ws = peer;
5159
5288
  if (msg.standby === true && ws.standbyHeld(msg.channel === false)) return sock.close(4000, "another session holds or is claiming this peer");
5160
5289
  // Without pushes it stands by, as a replaced session does, and never strands a channel session's deliveries.
@@ -5220,7 +5349,7 @@ export async function startDaemon(opts: DaemonOptions) {
5220
5349
  }
5221
5350
  case "delivery_receipt": {
5222
5351
  const peer = c.peer ? bus.peers.get(c.peer) : undefined;
5223
- if (c.role !== "peer" || !(peer instanceof WsPeer) || typeof msg.deliveryId !== "string" || !peer.ownsDelivery(sock, msg.generation, msg.deliveryId) || !["accepted", "needs_review", "failed_safe"].includes(msg.state)) return void reply({ ok: false, error: "invalid delivery receipt" });
5352
+ if (c.role !== "peer" || !isWsPeer(peer) || typeof msg.deliveryId !== "string" || !peer.ownsDelivery(sock, msg.generation, msg.deliveryId) || !["accepted", "needs_review", "failed_safe"].includes(msg.state)) return void reply({ ok: false, error: "invalid delivery receipt" });
5224
5353
  if (msg.state === "failed_safe" && (!peer.pullOnly || bus.queueShow(msg.deliveryId)?.state !== "dispatching")) return void reply({ ok: false, error: "safe failure requires an unseen push to a pull-only session" });
5225
5354
  const reason = typeof msg.reason === "string" ? sanitize(msg.reason).replace(/\s+/g, " ").trim().slice(0, 300) : "";
5226
5355
  bus.deliveryReceipt(peer.id, { id: msg.deliveryId, state: msg.state, ...(msg.state !== "accepted" ? { reason: reason || "Claude bridge could not confirm notification delivery" } : {}) });
@@ -5230,7 +5359,7 @@ export async function startDaemon(opts: DaemonOptions) {
5230
5359
  // The attached session can explicitly claim queued work even during a native turn (#493).
5231
5360
  // Pull and push share holds and journal settlement; work read here is completed, never pushed again.
5232
5361
  const peer = c.peer ? bus.peers.get(c.peer) : undefined;
5233
- if (c.role !== "peer" || !(peer instanceof WsPeer) || !peer.owns(sock)) return void reply({ ok: false, error: "not attached to the hub yet; retry hub_inbox in a moment" });
5362
+ if (c.role !== "peer" || !isWsPeer(peer) || !peer.owns(sock)) return void reply({ ok: false, error: "not attached to the hub yet; retry hub_inbox in a moment" });
5234
5363
  try {
5235
5364
  const envs = bus.pull(peer.id);
5236
5365
  if (!envs) return void reply({ ok: false, error: queueHold(peer.id) ?? (bus.isRecoveryHeld ? "recovery is holding deliveries" : pauseReason(peer.id)) });
@@ -5242,7 +5371,7 @@ export async function startDaemon(opts: DaemonOptions) {
5242
5371
  }
5243
5372
  case "delivery_complete": {
5244
5373
  const peer = c.peer ? bus.peers.get(c.peer) : undefined;
5245
- if (c.role !== "peer" || !(peer instanceof WsPeer) || typeof msg.deliveryId !== "string" || !peer.ownsDelivery(sock, msg.generation, msg.deliveryId)) return void reply({ ok: false, error: "delivery does not belong to this connection generation" });
5374
+ if (c.role !== "peer" || !isWsPeer(peer) || typeof msg.deliveryId !== "string" || !peer.ownsDelivery(sock, msg.generation, msg.deliveryId)) return void reply({ ok: false, error: "delivery does not belong to this connection generation" });
5246
5375
  return void reply(bus.completeDelivery(peer.id, msg.deliveryId) ? { ok: true } : { ok: false, error: "delivery is not live and accepted; inspect ahub queue show before resolving" });
5247
5376
  }
5248
5377
  case "execution_budget": {
@@ -5318,7 +5447,7 @@ export async function startDaemon(opts: DaemonOptions) {
5318
5447
  if (launch.session && launch.session !== native.sessionId) return void reply({ ok: false });
5319
5448
  launch.session = native.sessionId;
5320
5449
  const owner = bus.peers.get(peer);
5321
- if (!(owner instanceof WsPeer) || owner.state === "offline" && !owner.claiming) return void reply({ ok: false });
5450
+ if (!isWsPeer(owner) || owner.state === "offline" && !owner.claiming) return void reply({ ok: false });
5322
5451
  if (launch.generation && launch.generation !== owner.sessionGeneration) return void reply({ ok: false });
5323
5452
  launch.generation = owner.sessionGeneration;
5324
5453
  const current = () => !stopping && nativeHookLaunches.get(peer, msg.nativeLaunchId) === launch && bus.peers.get(peer) === owner && owner.sessionGeneration === launch.generation && owner.state !== "offline";
@@ -5356,6 +5485,10 @@ export async function startDaemon(opts: DaemonOptions) {
5356
5485
  return;
5357
5486
  }
5358
5487
  const offered = factsOn() && (native.event === "PreInvocation" || native.event === "SessionStart") ? offerFor(peer, native.toolUseId ?? `${native.sessionId}:invocation`) : undefined;
5488
+ // The decoded native read (agy view_file or read_file, as `Read`) is a peer fact: its target is observed before it runs and the
5489
+ // view moves after it. Nothing is offered here: agy's hook encodes an offer only on SessionStart/PreInvocation.
5490
+ if (factsOn() && native.event === "PreToolUse") factsFor(peer).preTool(peer, native.toolUseId, native.tool, native.input);
5491
+ if (factsOn() && native.event === "PostToolUse") factsFor(peer).postTool(peer, native.toolUseId, native.tool, native.input);
5359
5492
  return void reply({ ok: true, ...(offered ? { text: offered.text } : {}) });
5360
5493
  };
5361
5494
  if (owner.claiming) {
@@ -5412,15 +5545,15 @@ export async function startDaemon(opts: DaemonOptions) {
5412
5545
  const nativePeer = nativeClaude ? bus.peers.get(peer) : undefined;
5413
5546
  const permissionLaunch = definitionOf(peer)?.transport === "claude-channel" ? claudePermissionLaunch(peer) : undefined;
5414
5547
  const permission = nativeClaude && permissionLaunch && !permissionLaunch.unattended && msg.nativeLaunchId === permissionLaunch.launchId ? permissionMode(peer) : undefined;
5415
- if (permission !== undefined && nativePeer instanceof WsPeer) claudePermissionGenerations.set(peer, nativePeer.sessionGeneration);
5548
+ if (permission !== undefined && isWsPeer(nativePeer)) claudePermissionGenerations.set(peer, nativePeer.sessionGeneration);
5416
5549
  const protectedWrite = scopedWriteBlocked(phase, permission, msg.tool, () => projectFileTool(msg.tool, msg.input, opts.cwd, config.protect_instruction_files, config.data?.readonly));
5417
5550
  const permissionReply = phase === "pre" && permission !== undefined ? { permission: permission === "ask-when-needed" && protectedWrite ? "ask" : permission, projectRoot: opts.cwd, protectInstructionFiles: config.protect_instruction_files } : {};
5418
5551
  // Permission-only hooks carry no native-turn evidence and must never drive busy state.
5419
5552
  const purpose = permissionLaunch?.hookPurpose ?? msg.hookPurpose;
5420
- const observesNative = purpose === "facts" || purpose === "idle" || purpose === "facts-and-idle" || (purpose === undefined && (config.coordination === "turn-free" || config.task_sweep.enabled || config.roles.claude?.includes("conductor")));
5553
+ const observesNative = purpose === "facts" || purpose === "idle" || purpose === "facts-and-idle" || (purpose === undefined && (config.coordination === "turn-free" || config.task_sweep.enabled || config.roles[peer]?.includes("conductor")));
5421
5554
  if (nativeClaude && !observesNative) return void reply({ t: "facts", ok: true, ...permissionReply });
5422
5555
  if (phase === "session") { recordSessions(); return void reply({ t: "facts", ok: true }); }
5423
- if ((phase === "start" || phase === "pre") && nativePeer instanceof WsPeer) {
5556
+ if ((phase === "start" || phase === "pre") && isWsPeer(nativePeer)) {
5424
5557
  const previous = nativeTurnStart.get(peer);
5425
5558
  const current = claudeSession(peer);
5426
5559
  if (phase === "start" || nativePeer.state !== "busy" || previous?.generation !== nativePeer.sessionGeneration || previous?.session !== sessionId || previous?.launch !== (current.launchId ?? "")) {
@@ -5443,7 +5576,7 @@ export async function startDaemon(opts: DaemonOptions) {
5443
5576
  const st = hookStats.get(peer)!;
5444
5577
  event({ type: "hook_stats", peer, n: st.n, startupMs: Math.round(st.startupMs), hubMs: Math.round(st.hubMs), maxStartupMs: Math.round(st.maxStartupMs) });
5445
5578
  hookStats.delete(peer);
5446
- if (id && nativePeer instanceof WsPeer) nativePeer.nativeStopped(sessionId!);
5579
+ if (id && isWsPeer(nativePeer)) nativePeer.nativeStopped(sessionId!);
5447
5580
  if (factsOn()) { factSession(peer, sessionId); readbacks(peer, claudeTranscript(sessionId, msg.transcriptPath, peer)); }
5448
5581
  };
5449
5582
  if (!nativeClaude) { finishStop(); reply({ t: "facts", ok: true }); return; } // legacy bookkeeping cannot certify native completion
@@ -5452,13 +5585,13 @@ export async function startDaemon(opts: DaemonOptions) {
5452
5585
  const readCompletion = () => { transcript ??= claudeTranscript(sessionId, msg.transcriptPath, peer); return session.sessionId && transcript ? readClaudeTranscriptCompletion(session.sessionId, transcript) : undefined; };
5453
5586
  const completionId = (id: string) => createHash("sha256").update(`${sessionId}:${session.launchId ?? "legacy"}:${id}`).digest("hex");
5454
5587
  const started = nativeTurnStart.get(peer);
5455
- const activeStart = !!started && started.session === sessionId && started.launch === (session.launchId ?? "") && nativePeer instanceof WsPeer && started.generation === nativePeer.sessionGeneration;
5588
+ const activeStart = !!started && started.session === sessionId && started.launch === (session.launchId ?? "") && isWsPeer(nativePeer) && started.generation === nativePeer.sessionGeneration;
5456
5589
  // A seen previous completion is a duplicate only outside an active start. During B it is B's baseline.
5457
5590
  if (!activeStart) {
5458
5591
  const latest = readCompletion();
5459
5592
  if (latest?.at && nativeStopsSeen.has(completionId(latest.id))) return void reply({ t: "facts", ok: true });
5460
5593
  }
5461
- if (!started || !activeStart || !(nativePeer instanceof WsPeer)) { log("native Stop refused for claude: current native turn start unavailable"); return void reply({ t: "facts", ok: false }); }
5594
+ if (!started || !activeStart || !isWsPeer(nativePeer)) { log("native Stop refused for claude: current native turn start unavailable"); return void reply({ t: "facts", ok: false }); }
5462
5595
  const scopeCurrent = () => {
5463
5596
  const now = claudeSession(peer);
5464
5597
  return !stopping && bus.peers.get(peer) === nativePeer && nativePeer.sessionGeneration === started.generation && now.sessionId === started.session && (now.launchId ?? "") === started.launch;
@@ -5539,7 +5672,7 @@ export async function startDaemon(opts: DaemonOptions) {
5539
5672
  // Judged by the live owner: a headless Kimi started by --mode headless is watchable under a TUI start mode.
5540
5673
  const owner = bus.peers.get(peer);
5541
5674
  const admission = definitionOf(peer)?.attach?.admission;
5542
- if (admission && !(owner instanceof AcpPeer) && (owner instanceof WsPeer || (kimiSessionMode ?? startMode(peer)) === "tui")) return void reply({ ok: false, error: admission.watchRefusal });
5675
+ if (admission && !isAcpPeer(owner) && (isWsPeer(owner) || (kimiSessionMode ?? startMode(peer)) === "tui")) return void reply({ ok: false, error: admission.watchRefusal });
5543
5676
  if (!membershipAllowed(peer)) return void reply({ ok: false, error: `${peer} is off roster` });
5544
5677
  c.watch?.();
5545
5678
  c.watch = activityRings[peer]!.subscribe(item => {
@@ -5662,7 +5795,7 @@ export async function startDaemon(opts: DaemonOptions) {
5662
5795
  if (c.role !== "console") return void reply({ ok: false, error: "native TUI launch is console only" });
5663
5796
  const peer = String(msg.peer), owner = bus.peers.get(peer);
5664
5797
  if (stopping || recoveryActive() || !membershipAllowed(peer) || rosterChanging === peer || requestedPeerStops.has(peer) ||
5665
- peer !== definitionOf(peer)?.kind || !(owner instanceof JcodePeer) || !jcodeMcpReady(opts.cwd, opts.stateDir, SERVER_JS)) return void reply({ ok: false, error: "owned native TUI launch is unavailable" });
5798
+ peer !== definitionOf(peer)?.kind || !isJcodePeer(owner) || !jcodeMcpReady(opts.cwd, opts.stateDir, SERVER_JS)) return void reply({ ok: false, error: "owned native TUI launch is unavailable" });
5666
5799
  try {
5667
5800
  if (msg.binding !== undefined && (!msg.binding || typeof msg.binding.launchId !== "string" || typeof msg.binding.sessionId !== "string")) throw new Error("invalid binding");
5668
5801
  const options = msg.args ?? {};