@staix/agent-hub 0.12.43 → 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 (89) hide show
  1. package/CHANGELOG.md +16 -3
  2. package/README.md +3 -3
  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 +8 -3
  6. package/docs/agent-notes/budget.md +2 -2
  7. package/docs/agent-notes/daemon.md +9 -5
  8. package/docs/agent-notes/tasks.md +6 -2
  9. package/docs/agent-notes/tests.md +3 -1
  10. package/docs/operations.md +64 -5
  11. package/docs/quickstart.md +6 -2
  12. package/docs/security.md +5 -3
  13. package/docs/specs/2026-09-19-agent-hub-design.md +160 -4
  14. package/docs/verification/2026-10-21-0.12.44.md +54 -0
  15. package/docs/verification/2026-10-21-grok-config-acp-t0.md +30 -0
  16. package/docs/verified.json +34 -19
  17. package/package.json +1 -1
  18. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  19. package/plugins/agent-hub/server.js +743 -229
  20. package/src/adapters/acp-owned-group.ts +124 -0
  21. package/src/adapters/acp.ts +92 -25
  22. package/src/adapters/agy/definition.ts +1 -3
  23. package/src/adapters/agy/entry.ts +4 -1
  24. package/src/adapters/channel-peer.ts +143 -0
  25. package/src/adapters/claude-channel.ts +7 -7
  26. package/src/adapters/codex-appserver.ts +16 -2
  27. package/src/adapters/configured-acp.ts +124 -0
  28. package/src/adapters/definitions/builtin-metadata.ts +37 -33
  29. package/src/adapters/definitions/native-channel-state.ts +41 -0
  30. package/src/adapters/definitions/native-cli-entry-kimi.ts +3 -1
  31. package/src/adapters/definitions/native-cli-preview-qwen.ts +1 -1
  32. package/src/adapters/definitions/native-identity-defaults.ts +14 -0
  33. package/src/adapters/definitions/qwen.ts +3 -8
  34. package/src/adapters/definitions/reviewer-variants.ts +3 -7
  35. package/src/adapters/jcode.ts +4 -0
  36. package/src/adapters/local-worker.ts +4 -0
  37. package/src/adapters/pi.ts +4 -0
  38. package/src/adapters/registry-types.ts +18 -16
  39. package/src/adapters/registry.ts +68 -6
  40. package/src/adapters/reviewer-print.ts +4 -0
  41. package/src/cli/agy-mcp.ts +3 -2
  42. package/src/cli/configured-acp-entry.ts +58 -0
  43. package/src/cli/console-state.ts +9 -4
  44. package/src/cli/facts-hook.ts +2 -1
  45. package/src/cli/help.ts +6 -0
  46. package/src/cli/identity.ts +7 -6
  47. package/src/cli/launch.ts +5 -4
  48. package/src/cli/main.ts +78 -27
  49. package/src/cli/memory.ts +13 -10
  50. package/src/cli/output.ts +1 -0
  51. package/src/cli/preview.ts +9 -0
  52. package/src/cli/recovery-guidance.ts +1 -1
  53. package/src/cli/terminal-recovery.ts +16 -13
  54. package/src/cli/upgrade-restore-state.ts +3 -2
  55. package/src/cli/upgrade-runtime.ts +21 -6
  56. package/src/cli/upgrade.ts +3 -2
  57. package/src/hub/board.ts +9 -6
  58. package/src/hub/budget.ts +9 -2
  59. package/src/hub/bus.ts +6 -2
  60. package/src/hub/claude-state.ts +2 -37
  61. package/src/hub/conductor.ts +8 -6
  62. package/src/hub/config-apply.ts +26 -14
  63. package/src/hub/config-reload.ts +39 -11
  64. package/src/hub/config-trust.ts +1 -1
  65. package/src/hub/context-window.ts +6 -0
  66. package/src/hub/control-client.ts +1 -1
  67. package/src/hub/crash.ts +8 -6
  68. package/src/hub/daemon.ts +341 -313
  69. package/src/hub/hub-tools.ts +36 -0
  70. package/src/hub/instance-config.ts +20 -11
  71. package/src/hub/instance-execution.ts +4 -4
  72. package/src/hub/instances.ts +45 -21
  73. package/src/hub/lifecycle-schema.ts +5 -3
  74. package/src/hub/memory-profile.ts +16 -3
  75. package/src/hub/no-writes.ts +50 -0
  76. package/src/hub/permission-mode.ts +9 -7
  77. package/src/hub/roster.ts +7 -4
  78. package/src/hub/routing.ts +21 -20
  79. package/src/hub/settings.ts +50 -16
  80. package/src/hub/snapshots.ts +26 -2
  81. package/src/hub/start-mode.ts +10 -8
  82. package/src/hub/task-sweep.ts +8 -2
  83. package/src/hub/tasks.ts +85 -17
  84. package/src/memory/capture.ts +2 -1
  85. package/src/ui/index.html +5 -0
  86. package/templates/AGENTS.block.md +1 -1
  87. package/templates/profiles/general.json +1 -1
  88. package/templates/profiles/research.json +1 -1
  89. 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,7 +75,7 @@ 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";
@@ -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, knownChangedPaths, hasTree, headState, headMoveCursor, incomingHeadPaths, repoOf, snapshot, SnapshotBudgetExceeded, Turns, type SnapshotBudget, type HeadMoveCursor, type Repo, 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 },
@@ -516,135 +524,6 @@ interface Client {
516
524
  }
517
525
  type Sock = ServerWebSocket<Client>;
518
526
 
519
- /** A peer that lives in another process and attaches over the control WS (the Claude channel plugin). */
520
- class WsPeer extends BasePeer {
521
- private generation = crypto.randomUUID();
522
- // Checkpoints become stale at hello/claim, before asynchronous recall finishes and attach changes delivery generation.
523
- private claimGeneration = crypto.randomUUID();
524
- get sessionGeneration(): string { return this.claimGeneration; }
525
- terminalOwner(launchId: string): { generation: string } | undefined {
526
- return this.sock?.readyState === WebSocket.OPEN && this.sock.data.launchId === launchId && this.state !== "offline" && !this.claiming
527
- ? { generation: this.claimGeneration } : undefined;
528
- }
529
- private delivered = new Set<string>();
530
- private sock: Sock | undefined;
531
- private claimed: Sock | undefined;
532
- private nativeSession: string | undefined;
533
- private nativeActive = false;
534
- /** The attached session said it cannot show channel pushes (issue #205): the bus keeps its queue for `inbox`. */
535
- pullOnly = false;
536
- steer: PeerAdapter["steer"] = undefined;
537
- lastInboxAt: number | undefined;
538
- private claimedPullOnly = false;
539
- private readonly attachmentWaiters: { generation: string; resolve: (attached: boolean) => void }[] = [];
540
- private settleAttachmentWaiters(attached: boolean): void {
541
- for (const waiter of this.attachmentWaiters.splice(0)) waiter.resolve(attached && waiter.generation === this.claimGeneration);
542
- }
543
- /** Bounded native-hook observations wait for this exact claim, never a later socket. */
544
- waitForAttach(generation: string): Promise<boolean> {
545
- if (generation !== this.claimGeneration || !this.claiming || this.attachmentWaiters.length >= 64) return Promise.resolve(false);
546
- return new Promise(resolve => { this.attachmentWaiters.push({ generation, resolve }); });
547
- }
548
- /** Called at hello, before the async preface: the newest hello wins even if an older one's recall finishes last. */
549
- claim(sock: Sock, pullOnly = false): void {
550
- this.settleAttachmentWaiters(false);
551
- this.claimGeneration = crypto.randomUUID();
552
- this.claimed = sock;
553
- this.claimedPullOnly = pullOnly;
554
- }
555
- /** A session that shows pushes holds the peer or is arriving: a session without them must not take it (issue #205). */
556
- get channelHeld(): boolean {
557
- return (!!this.sock && !this.pullOnly) || (this.claiming && !this.claimedPullOnly);
558
- }
559
- /** Status-file observations are advisory; a replaced client cannot evict a live socket or pending claim. */
560
- standbyHeld(pullOnly: boolean): boolean {
561
- return this.claiming || (!!this.sock && this.sock.readyState === WebSocket.OPEN && (pullOnly || !this.pullOnly));
562
- }
563
- /** A hello that has not finished its preface yet. The peer reads as offline until then, and a session standing by
564
- * for the id must not take it from a claimant that is still arriving. */
565
- get claiming(): boolean {
566
- return !!this.claimed && this.claimed !== this.sock && this.claimed.readyState === WebSocket.OPEN;
567
- }
568
- attach(sock: Sock): void {
569
- if (sock !== this.claimed) return void sock.close(4000, "replaced"); // a newer session said hello meanwhile
570
- if (this.sock) this.setState("offline"); // unresolved work belongs to the previous connection
571
- this.sock?.close(4000, "replaced");
572
- this.sock = sock;
573
- this.generation = crypto.randomUUID();
574
- this.delivered.clear();
575
- this.nativeSession = undefined;
576
- this.nativeActive = false;
577
- this.pullOnly = this.claimedPullOnly; // before idle: the idle transition drains
578
- // Native TUI readback in #493 proves important channel input can be consumed before the turn ends.
579
- // Pull-only transports expose no steer capability; the bus keeps their important work queued.
580
- this.steer = this.pullOnly ? undefined : async (envs, deliveryId) => {
581
- 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");
582
- await this.deliver(envs, deliveryId);
583
- };
584
- this.lastInboxAt = undefined;
585
- this.setState("idle");
586
- this.settleAttachmentWaiters(true);
587
- }
588
- detach(sock: Sock): void {
589
- if (this.claimed === sock) { this.claimed = undefined; this.settleAttachmentWaiters(false); }
590
- if (this.sock !== sock) return;
591
- this.sock = undefined;
592
- this.steer = undefined;
593
- this.nativeActive = false;
594
- this.pullOnly = false;
595
- this.setState("offline");
596
- }
597
- detachForRoster(): void {
598
- this.settleAttachmentWaiters(false);
599
- this.claimed?.close(4000, "peer is off roster; retry when a person adds it");
600
- this.sock?.close(4000, "peer is off roster; retry when a person adds it");
601
- this.claimed = undefined;
602
- this.sock = undefined;
603
- this.steer = undefined;
604
- this.nativeActive = false;
605
- this.pullOnly = false;
606
- this.setState("offline");
607
- }
608
- async deliver(envs: Envelope[], deliveryId?: string): Promise<void> {
609
- if (!this.sock) throw new Error(`${this.id} is offline`);
610
- if (deliveryId) this.delivered.add(deliveryId);
611
- this.sock.send(JSON.stringify({ t: "deliver", envs, generation: this.generation, ...(deliveryId ? { deliveryId } : {}) }));
612
- }
613
- /** Advisory nudge (issue #398): important work waits in the pull queue. Not a delivery: nothing to settle or journal. */
614
- notifyQueued(counts: { queued: number; important: number }): void {
615
- if (!this.sock || !this.pullOnly) return;
616
- this.sock.send(JSON.stringify({ t: "queued", queued: counts.queued, important: counts.important }));
617
- }
618
- owns(sock: Sock): boolean { return this.sock === sock; }
619
- nativeStarted(session: string): void {
620
- if (!this.sock) return;
621
- this.nativeSession = session;
622
- this.nativeActive = true;
623
- this.setState("busy");
624
- }
625
- nativeStopped(session: string): boolean {
626
- if (!this.sock || (this.nativeSession !== undefined && this.nativeSession !== session)) return false;
627
- this.nativeSession = session;
628
- this.nativeActive = false;
629
- this.setState("idle");
630
- return true;
631
- }
632
- protected override onWatchdog(): void {
633
- // A silent native model/approval wait does not prove Stop. Retain the observed active turn until Stop/detach.
634
- if (this.nativeActive) { this.touch(); return; }
635
- super.onWatchdog();
636
- }
637
- ownsDelivery(sock: Sock, generation: unknown, id: string): boolean { return this.owns(sock) && generation === this.generation && this.delivered.has(id); }
638
- async start(): Promise<void> {}
639
- async stop(): Promise<void> {
640
- this.settleAttachmentWaiters(false);
641
- this.sock?.close();
642
- }
643
-
644
- recoveryMetadata(): Record<string, unknown> {
645
- return { launch: definitionOf(this.id)?.attach?.recoveryLaunch?.(this.id) ?? { kind: "claude-channel", peer: this.id } };
646
- }
647
- }
648
527
 
649
528
  export async function startDaemon(opts: DaemonOptions) {
650
529
  const projectId = opts.projectId ?? randomUUID();
@@ -657,21 +536,25 @@ export async function startDaemon(opts: DaemonOptions) {
657
536
  const lifecycleConfig = loadSdlc(opts.cwd, VERSION);
658
537
  // Preserve embedding-owned policy references, but never publish reloads into shared defaults.
659
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;
660
- 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);
661
542
  const extraIds = Object.keys(declarations);
662
543
  // #450 I3: channel extras (Claude) attach from the person's terminal; the hub never starts them and their hello is admitted.
663
- const channelExtras = extraIds.filter(id => instanceDefinitionOf(id, declarations)?.identity.mcp === "channel-plugin");
664
- // Every Claude-family peer whose own state files the daemon reads: the builtin channel kinds, then their declared instances.
665
- 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")];
666
549
  const executionRoots = new Map<string, string>();
667
550
  const executionOwners = new Map<string, { owner: PeerAdapter; cwd: string }>();
668
551
  const reviewerUnavailable = new Map<string, string>();
669
- 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 };
670
553
  const permissionModesFromInstances: Record<string, PermissionMode> = {};
671
554
  const startModesFromInstances: PeerStartConfig = {};
672
555
  const instancePermissionSources: NonNullable<HubConfig["permission_default_sources"]> = {};
673
556
  for (const [id, declaration] of Object.entries(declarations)) {
674
- roles[id] = effectiveInstanceRoles(id, declarations, loadedConfig.roles);
557
+ roles[id] = effectiveInstanceRoles(id, declarations, loadedConfig.roles, adapterCatalog);
675
558
  if (!channelExtras.includes(id)) startModesFromInstances[id] = { start_mode: "headless" };
676
559
  if (declaration.permission_mode !== undefined) {
677
560
  permissionModesFromInstances[id] = declaration.permission_mode;
@@ -679,19 +562,19 @@ export async function startDaemon(opts: DaemonOptions) {
679
562
  }
680
563
  }
681
564
  const config: HubConfig = { ...ownedConfig, ...(extraIds.length ? { instances: declarations } : {}),
682
- roster: peerRosterConfig(loadedConfig.roster, declarations),
683
- permission_modes: { ...permissionModesFromInstances, ...permissionDefaults(loadedConfig.permission_modes, declarations) },
684
- 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) },
685
568
  ...(extraIds.length ? { permission_default_sources: { ...instancePermissionSources, ...loadedConfig.permission_default_sources } } : {}),
686
569
  roles, conductor: { ...DEFAULT_CONFIG.conductor, ...loadedConfig.conductor } };
687
570
  // The resolver and catalogs are bound to this daemon, never published into the process-global builtin registry.
688
- const definitionOf = (peer: string) => instanceDefinitionOf(peer, declarations);
689
- const KNOWN_PEERS: readonly string[] = [...BASE_KNOWN_PEERS, ...extraIds];
690
- const SUPPORTED_PEERS: readonly string[] = [...BASE_SUPPORTED_PEERS, ...extraIds];
691
- const STARTABLE_PEERS: readonly string[] = [...BASE_STARTABLE_PEERS, ...extraIds.filter(id => !channelExtras.includes(id))];
692
- 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);
693
576
  const isStartablePeer = (peer: unknown): peer is string => typeof peer === "string" && !channelExtras.includes(peer) && definitionOf(peer)?.start.conductorStartable === true;
694
- 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))]);
695
578
  // #223 R2: a declared instance whose effective roles are exactly reviewer gets review requests and its own task notices only.
696
579
  const reviewOnlyPeer = (peer: string): boolean => Object.hasOwn(declarations, peer) && config.roles[peer]?.length === 1 && config.roles[peer]![0] === "reviewer";
697
580
  /** #520: a task under review shows its review brief to whoever may read it (owner, reviewer, conductor). */
@@ -700,8 +583,9 @@ export async function startDaemon(opts: DaemonOptions) {
700
583
  const reviewerView = (peer: string, view: Record<string, unknown>): Record<string, unknown> => !reviewOnlyPeer(peer) || !Array.isArray(view.history) ? view
701
584
  : { ...view, history: (view.history as { event?: unknown; note?: unknown }[]).map(({ note, ...h }) => h.event === "approved" || h.event === "changes_requested" ? { ...h, note } : h) };
702
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));
703
- const SETTINGS = projectSettings(declarations);
704
- 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);
705
589
  const accountOf = (peer: string) => definitionOf(peer)?.budget?.account ?? peer;
706
590
  const accountMembers = (account: string) => SUPPORTED_PEERS.filter(peer => accountOf(peer) === account);
707
591
  let rosterChanging: string | undefined;
@@ -709,7 +593,7 @@ export async function startDaemon(opts: DaemonOptions) {
709
593
  let kimiAdmissionId: string | undefined;
710
594
  let kimiAdmissionExpiry: ReturnType<typeof setTimeout> | undefined;
711
595
  const clearKimiAdmission = () => { clearTimeout(kimiAdmissionExpiry); kimiAdmissionExpiry = undefined; kimiSessionMode = undefined; kimiAdmissionId = undefined; };
712
- 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);
713
597
  // pi.enabled historically gates starts, not retained queues or tools sockets. Only an explicit roster.pi changes those.
714
598
  const membershipAllowed = (peer: string) => definitionOf(peer)?.enabledSwitchStartOnly && config.roster[peer] === undefined ? true : onRoster(peer);
715
599
  conductorPeer(config.roles); // reject ambiguous leadership before opening a control listener
@@ -720,7 +604,7 @@ export async function startDaemon(opts: DaemonOptions) {
720
604
  // The state dir can vanish under a running hub (issue #56): a log line must never take a handler down with it.
721
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 */ } };
722
606
  let runningRouting = currentRouting(opts.cwd, log);
723
- let acceptedSources = parseConfigSources(configSourceTexts(opts.cwd), runningRouting);
607
+ let acceptedSources = parseConfigSources(configSourceTexts(opts.cwd), runningRouting, opts.cwd);
724
608
  const startupSources = structuredClone(acceptedSources.trustLayers);
725
609
  const embeddedConfig = structuredClone(config);
726
610
  let desiredConfig = structuredClone(config);
@@ -889,7 +773,7 @@ export async function startDaemon(opts: DaemonOptions) {
889
773
  sock.data.tail?.();
890
774
  sock.data.watch?.();
891
775
  const peer = sock.data.peer ? bus.peers.get(sock.data.peer) : undefined;
892
- if (peer instanceof WsPeer) {
776
+ if (isWsPeer(peer)) {
893
777
  peer.detach(sock);
894
778
  if (!!sock.data.peer && !!definitionOf(sock.data.peer)?.attach?.admission && peer.state === "offline" && !peer.claiming && sock.data.kimiAdmission === kimiAdmissionId) clearKimiAdmission();
895
779
  }
@@ -925,7 +809,9 @@ export async function startDaemon(opts: DaemonOptions) {
925
809
  // a record left now would make the next ordinary start look like one.
926
810
  if (recoveryOperation || restartFilePresent) try { removeSessions(opts.stateDir); } catch { /* nothing to remove */ }
927
811
  const autoResume = () => config.recovery.auto_resume_after_crash === true; // a string "false" is not a yes
928
- 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;
929
815
  const startedAt = Date.now();
930
816
  /** What crash recovery did or asks the user to do, for `ahub status`. */
931
817
  const crashReport: string[] = [];
@@ -958,7 +844,8 @@ export async function startDaemon(opts: DaemonOptions) {
958
844
  // silence without facts would leave overlapping owners blind. Facts and silence ask the same question, so both switch
959
845
  // together. Cached until the board changes: it is asked per recipient of every message.
960
846
  let turnFreeNow: boolean | undefined;
961
- const turns = new Map<PeerId, { capture?: boolean; id: string; start: number; tokens: number; tree?: string; head?: string | null; reflog?: HeadMoveCursor; 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 }>();
962
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)));
963
850
  /**
964
851
  * Why `to` does not get an agent's message (issue #107): sender and recipient are in one silent cohort, and the sender
@@ -1009,7 +896,7 @@ export async function startDaemon(opts: DaemonOptions) {
1009
896
  if (staleOff()) log("experiment: stale notices are delivered as before 0.12.4 (the issue #106 ablation)");
1010
897
  const requestedPeerStops = new Set<string>();
1011
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,
1012
- 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); } });
1013
900
  startupCleanup.push(() => bus.closeJournal());
1014
901
  const manualPaused = new Set<PeerId>(bus.manualPausedPeers()); // recovery never lifts an operator's pause
1015
902
  const conductorHolds = new ConductorHolds(join(opts.stateDir, "hub.db"));
@@ -1035,11 +922,18 @@ export async function startDaemon(opts: DaemonOptions) {
1035
922
  bus.setRecoveryHold(false);
1036
923
  }
1037
924
  const recoveryPeerAllowed = (id: PeerId) => !recoveryActive() || !recoveryPeerSnapshot || recoveryPeerSnapshot.some((peer) => peer.id === id);
1038
- 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;
1039
929
  clearTimeout(recoveryLeaseTimer);
1040
930
  recoveryLeaseTimer = setTimeout(() => {
1041
- if (!recoveryOperationId || recoveryCommitted || (recoveryPhase !== "preparing" && recoveryPhase !== "prepared")) return;
1042
- 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;
1043
937
  log(`recovery lease expired for ${recoveryOperationId}; hold aborted`);
1044
938
  recoveryOperationId = undefined;
1045
939
  recoveryPhase = undefined;
@@ -1299,8 +1193,8 @@ export async function startDaemon(opts: DaemonOptions) {
1299
1193
  if (!peer || peer.state === "offline") return undefined;
1300
1194
  let token = checkConnections.get(peer);
1301
1195
  if (!token) { token = randomUUID(); checkConnections.set(peer, token); }
1302
- const native = peer instanceof WsPeer || peer instanceof CodexPeer || peer instanceof PiPeer ? peer.sessionGeneration : peer.recoveryMetadata?.().sessionId;
1303
- 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;
1304
1198
  return `${instanceId}:${token}:${String(native ?? "")}`;
1305
1199
  };
1306
1200
  const currentCheck = (receipt: { task: number; owner: string; updated: number | string; generation: number | string; revision?: number }) => {
@@ -1400,7 +1294,7 @@ export async function startDaemon(opts: DaemonOptions) {
1400
1294
  // Bind both legacy print transports and task-MCP calls to the child's original review request.
1401
1295
  const runningReviewAuthority = (by: PeerId, task: number): (() => string | undefined) | undefined => {
1402
1296
  if (!definitionOf(by)?.reviewer) return undefined;
1403
- 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);
1404
1298
  return () => token!==undefined && binding!==undefined && bus.peers.get(by)===peer && peer?.reviewToken(task)===token && tasks.reviewBindingCurrent(by,task,binding)
1405
1299
  ? undefined : `no review of task #${task} is running for ${by} at its bound head and revision`;
1406
1300
  };
@@ -1414,6 +1308,7 @@ export async function startDaemon(opts: DaemonOptions) {
1414
1308
  return [peer, codes];
1415
1309
  })),
1416
1310
  instances: () => declarations,
1311
+ adapterCatalog,
1417
1312
  reviewOnly: reviewOnlyPeer,
1418
1313
  // #223: a headless reviewer's verdict holds only while the same child it started with still runs for that task.
1419
1314
  reviewAuthority: runningReviewAuthority,
@@ -1434,6 +1329,8 @@ export async function startDaemon(opts: DaemonOptions) {
1434
1329
  catalog,
1435
1330
  sessionRoles: peer => bus.peers.has(peer) && bus.stateOf(peer) !== "offline" ? preparedSessionRoles.get(peer) ?? [] : [],
1436
1331
  projectedReview: task => reviewProjection(task),
1332
+ ownerWrites: (task, since) => ownerWriteEvidence(task, since),
1333
+ ownerAccepted: task => captureOwnerWriteStart(task),
1437
1334
  reviewBrief: task => {
1438
1335
  const root = executionRoot(task.owner ?? "");
1439
1336
  return buildReviewBrief(root, task.refs.commit, { privatePath: path => isDenied(path, config.local.deny) || matchesPolicy(root, path, config.pii?.paths ?? [], true), nameable: tasks.nameable });
@@ -1491,7 +1388,7 @@ export async function startDaemon(opts: DaemonOptions) {
1491
1388
  sweepHeld: () => stopping || recoveryActive(),
1492
1389
  cancellationHeld: () => stopping || recoveryActive(),
1493
1390
  offerRead: (peer, envelopeId) => journal.offerRead(peer, envelopeId),
1494
- 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; },
1495
1392
  stallNotice: stall => { supervision.taskStalled(stall); },
1496
1393
  roles: config.roles,
1497
1394
  turnFree,
@@ -1691,7 +1588,8 @@ export async function startDaemon(opts: DaemonOptions) {
1691
1588
  } catch { /* no transcript yet: no readback */ }
1692
1589
  };
1693
1590
  /** Claude's transcript for this session: the one the status line reported, or the hook's own, inside Claude's projects. */
1694
- 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
1695
1593
  const reported = claudeSession(peer);
1696
1594
  if (reported.transcriptPath && (!sessionId || reported.sessionId === sessionId)) return reported.transcriptPath;
1697
1595
  if (!sessionId || typeof fromHook !== "string" || !isAbsolute(fromHook) || basename(fromHook) !== `${sessionId}.jsonl`) return undefined;
@@ -1736,6 +1634,7 @@ export async function startDaemon(opts: DaemonOptions) {
1736
1634
  scopes = new Map();
1737
1635
  progress.prune();
1738
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);
1739
1638
  if ((h.event === "integration requested" || h.event === "integration unresolved") && /has not stopped/.test(h.note ?? "")) {
1740
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)}`);
1741
1640
  }
@@ -1759,10 +1658,10 @@ export async function startDaemon(opts: DaemonOptions) {
1759
1658
  const checkpointWaits = new Map<PeerId, { reason: "quota" | "context"; requestId: string; session: string | undefined; valid: () => boolean; done: (summary: string | undefined) => void }>();
1760
1659
  const contextSession = (peer: PeerId): string | undefined => {
1761
1660
  const p = bus.peers.get(peer);
1762
- if (p instanceof WsPeer && p.claiming) return undefined;
1763
- if (definitionOf(peer)?.turnEnd === "native-hook") { const c = claudeSession(peer); return c.sessionId ? `${c.sessionId}:${c.launchId ?? ""}` : undefined; }
1764
- if (p instanceof CodexPeer) return p.thread || undefined;
1765
- 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; }
1766
1665
  return undefined;
1767
1666
  };
1768
1667
  const activityRings: Record<string, ActivityRing> = Object.fromEntries(SUPPORTED_PEERS.filter(peer => definitionOf(peer)?.capabilities?.watch).map(peer => [peer, new ActivityRing()]));
@@ -1773,8 +1672,8 @@ export async function startDaemon(opts: DaemonOptions) {
1773
1672
  const owner = bus.peers.get(peer);
1774
1673
  const state = bus.stateOf(peer);
1775
1674
  const session = contextSession(peer);
1776
- const generation = owner instanceof WsPeer || owner instanceof CodexPeer ? owner.sessionGeneration : undefined;
1777
- 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);
1778
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));
1779
1678
  if (!openWork && !bus.queued(peer) && !bus.hasInFlight(peer) && state !== "busy") {
1780
1679
  if (reason === "quota") log(`budget ${peer}: no open work, no checkpoint`);
@@ -1786,7 +1685,7 @@ export async function startDaemon(opts: DaemonOptions) {
1786
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.`;
1787
1686
  const ask = newEnvelope(HUB, body, { to: [peer], kind: "budget", priority: "important" });
1788
1687
  return new Promise((resolve) => {
1789
- 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;
1790
1689
  const done = (summary: string | undefined) => {
1791
1690
  clearTimeout(timer);
1792
1691
  checkpointWaits.delete(peer);
@@ -1814,6 +1713,7 @@ export async function startDaemon(opts: DaemonOptions) {
1814
1713
  }
1815
1714
  };
1816
1715
  const budget = new Budget(join(opts.stateDir, "hub.db"), config.budget, instanceBudgetHooks({
1716
+ definitionOf,
1817
1717
  pause: (peer) => bus.pause(peer),
1818
1718
  resume: (peer) => {
1819
1719
  if (!manualPaused.has(peer) && !conductorHolds.has(peer)) bus.resume(peer);
@@ -1926,28 +1826,71 @@ export async function startDaemon(opts: DaemonOptions) {
1926
1826
  snapshotBudgetNoticed = true;
1927
1827
  } else log(`snapshot failed at ${what}: ${(error as Error).message}`);
1928
1828
  };
1929
- const snap = (what: string, capture = config.snapshots.enabled, peer?: string): { tree?: string; head?: string | null; reflog?: HeadMoveCursor; ms: number; budget?: SnapshotBudget; repo?: Repo } => {
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 } => {
1930
1830
  if (!capture) return { ms: 0 };
1931
1831
  const t0 = performance.now(), budget = reserveSnapshotBudget();
1932
- let tree: string | undefined, head: string | null | undefined, repo: Repo | undefined, reflog: HeadMoveCursor | undefined;
1832
+ let tree: string | undefined, head: string | null | undefined, commit: string | null | undefined, repo: Repo | undefined, reflog: HeadMoveCursor | undefined;
1933
1833
  try {
1934
1834
  repo = peer ? repoFor(peer, budget) : factsRepo;
1935
1835
  const before = repo && headState(repo, budget);
1936
1836
  tree = repo && (opts.snapshot ?? snapshot)(repo, budget);
1937
1837
  const after = repo && headState(repo, budget);
1938
- head = after?.tree;
1838
+ head = after?.tree; commit = after?.head;
1939
1839
  if (!before || !after || before.tree !== after.tree) tree = undefined;
1940
1840
  if (tree && repo && head !== undefined && after) reflog = headMoveCursor(repo, head, budget, after.head);
1941
1841
  if (repo && !tree) log(`snapshot failed at ${what}`);
1942
1842
  } catch (error) {
1943
- tree = undefined; head = undefined; reflog = undefined;
1843
+ tree = undefined; head = undefined; commit = undefined; reflog = undefined;
1944
1844
  snapshotFailure(what, error);
1945
1845
  }
1946
- return { tree, head, reflog, budget, repo, ms: Math.round(performance.now() - t0) };
1846
+ return { tree, head, commit, reflog, budget, repo, ms: Math.round(performance.now() - t0) };
1947
1847
  };
1948
1848
  function privateSnapshotWindow(): boolean {
1949
1849
  return [...turns.values()].some(turn => turn.private) || board.list().some(task => !isClosedTaskState(task.state) && tasks.isPii(task));
1950
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
+ }
1951
1894
  function reviewProjection(task: Task): string | undefined {
1952
1895
  const owner = task.owner ?? "", root = executionRoot(owner);
1953
1896
  const repo = repoFor(owner), projections = projectionsFor(owner);
@@ -1982,7 +1925,7 @@ export async function startDaemon(opts: DaemonOptions) {
1982
1925
  }
1983
1926
  return n;
1984
1927
  };
1985
- const onKimiTokens = (total: number, session: string, peer = "kimi") => {
1928
+ const onKimiTokens = (total: number, session: string, peer: string) => {
1986
1929
  const n = addTokens(peer, delta(peer, total, session));
1987
1930
  if (!config.budget.kimi_tokens_5h || !n) return;
1988
1931
  const now = Date.now();
@@ -2045,7 +1988,8 @@ export async function startDaemon(opts: DaemonOptions) {
2045
1988
  if (c.instanceId !== instanceId || !session.sessionId || c.sessionId !== session.sessionId || c.launchId !== session.launchId || !["idle", "busy", "paused"].includes(bus.stateOf(claudePeer))) continue;
2046
1989
  claudeContextSeen.set(claudePeer, payload);
2047
1990
  const bound = contextSession(claudePeer);
2048
- 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);
2049
1993
  } catch { /* absent or unreadable native telemetry is unknown */ }
2050
1994
  }, 1000),
2051
1995
  setInterval(() => budget.tick(), 30_000),
@@ -2065,7 +2009,10 @@ export async function startDaemon(opts: DaemonOptions) {
2065
2009
  claudeUsageSeen = mtime;
2066
2010
  const usage = JSON.parse(readFileSync(file, "utf8"));
2067
2011
  // The file's own timestamp, not "now": a file left behind by yesterday's session is a stale reading, not a fresh one.
2068
- 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
+ }
2069
2016
  } catch {
2070
2017
  // no tee in this session, or a half-written file: next round
2071
2018
  }
@@ -2112,6 +2059,10 @@ export async function startDaemon(opts: DaemonOptions) {
2112
2059
  const conductedMutations = new Set(["hub_task_propose", "hub_task_accept", "hub_task_decline", "hub_task_done", "hub_review", "hub_checkpoint", "hub_remember"]);
2113
2060
  const taskOp = async (...args: Parameters<typeof taskOpBody>): Promise<string> => {
2114
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;
2115
2066
  const conducted = conductorPeer(config.roles) === by && conductedMutations.has(op);
2116
2067
  const admittedId = typeof input?.id === "number" && Number.isSafeInteger(input.id) && input.id > 0 ? input.id : undefined;
2117
2068
  taskOpsInFlight++;
@@ -2119,6 +2070,7 @@ export async function startDaemon(opts: DaemonOptions) {
2119
2070
  const readAuthority=op==="hub_task_show"?runningReviewAuthority(by,Number(input?.id)):undefined;
2120
2071
  const before=readAuthority?.();if(before)throw new PreEffectToolRefusal(before);
2121
2072
  const text = await taskOpBody(...args);
2073
+ if (nativeTurn && effectful && turns.get(by) === nativeTurn) nativeTurn.taskEffect = true;
2122
2074
  const after=readAuthority?.();if(after)throw new PreEffectToolRefusal(after);
2123
2075
  if (conducted) {
2124
2076
  const proposed = op === "hub_task_propose" ? Number(text.match(/task #(\d+):/)?.[1]) : undefined;
@@ -2128,16 +2080,18 @@ export async function startDaemon(opts: DaemonOptions) {
2128
2080
  const hint = args[3] ? ownHeldHint(by) : "";
2129
2081
  return hint ? `${text}\n\n${hint}` : text;
2130
2082
  } catch (error) {
2083
+ if (nativeTurn && effectful && !(error instanceof PreEffectToolRefusal) && turns.get(by) === nativeTurn) nativeTurn.taskEffect = true;
2131
2084
  // Keep proven pre-effect refusal identity/code for native receipts; only add body-free metadata.
2132
2085
  const hint = args[3] ? ownHeldHint(by) : "";
2133
2086
  if (hint && error instanceof Error) error.message += `\n\n${hint}`;
2134
2087
  throw error;
2135
2088
  } finally {
2089
+ if (nativeTurn && effectful) nativeTurn.effectsPending = Math.max(0, (nativeTurn.effectsPending ?? 1) - 1);
2136
2090
  taskOpsInFlight--;
2137
2091
  }
2138
2092
  };
2139
2093
  function ownHeldHint(peer: PeerId): string {
2140
- 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)))); }
2141
2095
  catch { return ""; } // unavailable telemetry is not a zero count or a reason to fail the tool itself
2142
2096
  }
2143
2097
  /** A model-written peer argument: a peer id, or nothing for absent, null or "". Anything else is refused. */
@@ -2271,7 +2225,7 @@ export async function startDaemon(opts: DaemonOptions) {
2271
2225
  switch (op) {
2272
2226
  case "hub_delivery_retry": {
2273
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");
2274
- 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");
2275
2229
  if (stopping || recoveryActive()) throw new PreEffectToolRefusal("recovery or shutdown is holding delivery mutations");
2276
2230
  bus.retryDelivery(by, a.delivery_id);
2277
2231
  return `retry queued for delivery ${a.delivery_id}; check for earlier effects before acting`;
@@ -2460,12 +2414,12 @@ export async function startDaemon(opts: DaemonOptions) {
2460
2414
  if (!definitionOf(turn.peer)?.session.nativeRevert || !turn.native) throw new Error(`turn ${turn.id} has no Codex conversation turn to revert`);
2461
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`);
2462
2416
  const codex = bus.peers.get(turn.peer);
2463
- if (!(codex instanceof CodexPeer)) throw new Error("codex is not attached");
2417
+ if (!isCodexPeer(codex)) throw new Error("codex is not attached");
2464
2418
  const workspace = turnLog!.workspace(turn.id);
2465
2419
  if (workspace !== undefined) {
2466
- 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);
2467
2421
  if (!current.instances?.[turn.peer] || adapter?.kind !== definitionOf(turn.peer)?.kind) throw new Error("turn workspace declaration changed; inspect before reverting");
2468
- 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);
2469
2423
  const bound = executionOwners.get(turn.peer);
2470
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");
2471
2425
  }
@@ -2550,7 +2504,7 @@ export async function startDaemon(opts: DaemonOptions) {
2550
2504
  * waited past `budget.stale_min` the status says so, and the person decides. Read-only.
2551
2505
  */
2552
2506
  const importantWaitingStatus = (id: PeerId, peer: PeerAdapter | undefined): Record<string, unknown> => {
2553
- 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 {};
2554
2508
  const since = bus.oldestQueuedImportantAt(id);
2555
2509
  if (since === undefined || Date.now() - since < config.budget.stale_min * 60_000) return {};
2556
2510
  const count = bus.queuedImportant(id), minutes = Math.floor((Date.now() - since) / 60_000);
@@ -2563,7 +2517,7 @@ export async function startDaemon(opts: DaemonOptions) {
2563
2517
  };
2564
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
2565
2519
  const recoveryReady = () => {
2566
- 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;
2567
2521
  if (checkPending.size !== 0) return false;
2568
2522
  if (!recoveryPeerSnapshot) return true;
2569
2523
  const current = recoveryPeers();
@@ -2593,7 +2547,8 @@ export async function startDaemon(opts: DaemonOptions) {
2593
2547
  });
2594
2548
  };
2595
2549
  /** #450 I3: each Claude peer (claude and every claude-<name> instance) has its own session and launch files. */
2596
- 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
2597
2552
  try {
2598
2553
  const value = JSON.parse(readFileSync(join(opts.stateDir, claudeStateFile("claude-session", peer)), "utf8"));
2599
2554
  if (value.instanceId !== instanceId) return {};
@@ -2624,7 +2579,7 @@ export async function startDaemon(opts: DaemonOptions) {
2624
2579
  const claudeVersions = new Map<string, string>();
2625
2580
  const claudeStamps = new Map<string, string>(); // the transcript's size and mtime when its version was read (#115)
2626
2581
  const claudeVersion = (): string | undefined => {
2627
- const path = claudeSession().transcriptPath;
2582
+ const path = claudeSession(defaultChannelPeer(undefined)).transcriptPath;
2628
2583
  if (!path) return undefined;
2629
2584
  let fd: number | undefined;
2630
2585
  try {
@@ -2728,9 +2683,10 @@ export async function startDaemon(opts: DaemonOptions) {
2728
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"}`);
2729
2684
  };
2730
2685
  const permissionDisplay = (id: string, peer: PeerAdapter | undefined): string => {
2731
- if (peer instanceof AcpPeer && ["unknown", "unmanaged"].includes(peer.permissionModeState)) return peer.permissionModeState;
2732
- if (starting.has(id) && (peer instanceof AcpPeer || peer instanceof CodexPeer)) return peer.getPermissionMode();
2733
- 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") {
2734
2690
  const launch = claudePermissionLaunch(id);
2735
2691
  if (!launch || claudePermissionGenerations.get(id) !== peer.sessionGeneration) return "unverified";
2736
2692
  }
@@ -2738,7 +2694,7 @@ export async function startDaemon(opts: DaemonOptions) {
2738
2694
  };
2739
2695
  const permissionStatus = (id: string, peer: PeerAdapter | undefined): Record<string, unknown> => {
2740
2696
  const note = definitionOf(id)?.permission.unmanagedStatusNote;
2741
- if (note && peer instanceof WsPeer && peer.pullOnly) return { permissionNote: note };
2697
+ if (note && isWsPeer(peer) && peer.pullOnly) return { permissionNote: note };
2742
2698
  const mode = permissionDisplay(id, peer);
2743
2699
  return mode === "ask" ? {} : { permissionMode: mode };
2744
2700
  };
@@ -2752,6 +2708,7 @@ export async function startDaemon(opts: DaemonOptions) {
2752
2708
  const statusPeerIds = (open: ReturnType<Board["statusTasks"]>) => {
2753
2709
  const reservations = board.statusReservations();
2754
2710
  const ids = new Set([...bus.knownPeers(), ...extraIds, ...reservations]);
2711
+ for (const peer of configuredIds) if (onRoster(peer)) ids.add(peer);
2755
2712
  if (!Object.keys(config.roster).some(peer => !membershipAllowed(peer))) return [...ids];
2756
2713
  for (const task of open) for (const peer of [task.owner, task.reviewer]) if (peer && !membershipAllowed(peer)) ids.add(peer);
2757
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));
@@ -2778,7 +2735,7 @@ export async function startDaemon(opts: DaemonOptions) {
2778
2735
  pendingRestart,
2779
2736
  retainedConfig,
2780
2737
  ...(() => { const warning = mainCheckoutWarning(opts.cwd, tasks.publicMetadataText, checkoutBranch); return warning ? { mainCheckout: warning } : {}; })(),
2781
- startModes: Object.fromEntries(START_MODE_PEERS.map(peer => [peer, startModeOf(config.peers, peer, declarations)])),
2738
+ startModes: Object.fromEntries([...START_MODE_PEERS, ...configuredIds].map(peer => [peer, startModeOf(config.peers, peer, declarations, adapterCatalog)])),
2782
2739
  memoryProfile: memoryProfileStatus(includeAttentionItems),
2783
2740
  lifecycleStages: [...lifecycleConfig.stages],
2784
2741
  // ponytail: off keeps the #257 phase schema until RETIRED_WORKFLOW_FROM, when the alias and this flag go together.
@@ -2787,6 +2744,9 @@ export async function startDaemon(opts: DaemonOptions) {
2787
2744
  roster: [...KNOWN_PEERS, ...OPTIONAL_PEERS].map(peer => ({ peer, on: onRoster(peer) })),
2788
2745
  protocol: PROTOCOL,
2789
2746
  features: DAEMON_FEATURES,
2747
+ configuredAdapters: configuredIds,
2748
+ startablePeers: SUPPORTED_PEERS.filter(isStartablePeer),
2749
+ readOnlyPeers: extraIds.filter(readOnlyPeer),
2790
2750
  // #450 I3: the instance ids this daemon bound at startup; a launcher selects only one of these.
2791
2751
  activeInstances: extraIds,
2792
2752
  stopping,
@@ -2796,7 +2756,7 @@ export async function startDaemon(opts: DaemonOptions) {
2796
2756
  ...(dashboard ? { uiOrigin: dashboard.origin } : {}),
2797
2757
  codexProxyPort: opts.codexProxyPort,
2798
2758
  peers: Object.fromEntries(
2799
- 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 ?? [] } : {}) }]; }),
2800
2760
  ),
2801
2761
  taskWip: Object.fromEntries(peerIds.map(id => [id, { wip: tasks.wip(id), wipLimit: tasks.wipLimit(id) }])),
2802
2762
  boardReadCapabilities: { releaseFilter: true, columnMetadata: true },
@@ -3042,8 +3002,8 @@ export async function startDaemon(opts: DaemonOptions) {
3042
3002
  let piSettlementFailure: { key: string; error: string; retryAfter: number } | undefined;
3043
3003
  function settlePiContinuity(): void {
3044
3004
  if (stopping || recoveryActive() || piContinuity.state !== "ready") return;
3045
- const peer = bus.peers.get("pi");
3046
- 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;
3047
3007
  const saved = piContinuity.bookmark, actual = peer.recoveryMetadata();
3048
3008
  if (typeof actual.sessionId !== "string" || !actual.sessionId) return;
3049
3009
  const key = JSON.stringify([piContinuity.digest, peer.sessionGeneration, actual]);
@@ -3072,8 +3032,8 @@ export async function startDaemon(opts: DaemonOptions) {
3072
3032
  }
3073
3033
  const recordSessions = () => {
3074
3034
  if (stopping) return;
3075
- const pi = bus.peers.get("pi");
3076
- 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)) {
3077
3037
  piContinuity = readRecoveryContinuity(opts.stateDir, { projectId, projectRoot: opts.cwd });
3078
3038
  }
3079
3039
  const peers = [...bus.peers.values()].filter((p) => p.state !== "offline").map((p) => {
@@ -3095,19 +3055,19 @@ export async function startDaemon(opts: DaemonOptions) {
3095
3055
  const lostCount = [...lost.values()].reduce((n, l) => n + l.length, 0);
3096
3056
  report(`the previous hub run stopped without shutting down${lostCount ? `; ${lostCount} deliveries it had in flight are in needs_review (ahub queue list)` : ""}`);
3097
3057
  const start = (peer: string, args: Parameters<typeof startPeer>[1]) => startPeer(peer, args).catch((e: Error) => ({ ok: false, error: e.message }));
3098
- for (const step of crashPlan(prev.peers, declarations)) {
3058
+ for (const step of crashPlan(prev.peers, declarations, adapterCatalog)) {
3099
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; }
3100
3060
  // `pi.auto_start` already asks for Pi (#66): its recorded headless session comes back, auto-resume or not, and a
3101
3061
  // fresh one starts when that fails or there is nothing headless to resume. Pi runs on-prem: no cloud quota.
3102
3062
  if (!declarations[step.peer] && definitionOf(step.peer)?.session.restoreOnStart && piAutoStart()) {
3103
3063
  if (step.resume) {
3104
- 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]);
3105
3065
  if (r.ok) { report(`pi resumed (pi.auto_start): ${step.how}`); continue; }
3106
3066
  report(`pi not resumed (${String(r.error)}); ${step.how}`);
3107
3067
  } else report(step.how);
3108
3068
  // #269: a Pi that ran in a terminal is not replaced by a headless one unless headless is its start mode.
3109
- 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; }
3110
- 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 });
3111
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` : "";
3112
3072
  report(fresh.ok ? `pi.auto_start started a fresh session${back}` : `pi.auto_start could not start Pi either (${String(fresh.error)})`);
3113
3073
  continue;
@@ -3161,13 +3121,20 @@ export async function startDaemon(opts: DaemonOptions) {
3161
3121
  const capture = config.snapshots.enabled && !!turnLog;
3162
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
3163
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); }
3164
3130
  const preparedRoles = preparedSessionRoleIdentities.get(e.peer)?.map(role => ({ ...role })) ?? null;
3165
- turns.set(e.peer, { id, capture, start: Date.now(), tokens: 0, deliveryTask: delivery, attribution, preparedRoles, ...(start ? { tree: start.tree, head: start.head, reflog: start.reflog, 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 } : {}) });
3166
3132
  event({ type: "turn_start", peer: e.peer, turn: id });
3167
3133
  } else if (!busy && open) {
3168
3134
  turns.delete(e.peer);
3169
3135
  let afterTurn: (() => void) | undefined;
3170
3136
  let files: number | undefined;
3137
+ let writeProgress: boolean | undefined;
3171
3138
  let snapshotMs = open.snapshotMs;
3172
3139
  if (open.capture && turnLog && (open.private || holdsPii(e.peer))) {
3173
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}`); }
@@ -3216,6 +3183,21 @@ export async function startDaemon(opts: DaemonOptions) {
3216
3183
  }
3217
3184
  }
3218
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
+ }
3219
3201
  snapshotMs = (snapshotMs ?? 0) + end.ms;
3220
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}`); }
3221
3203
  // After the turn_end event: a notice delivered at once starts the peer's next turn, which must come after it.
@@ -3229,6 +3211,12 @@ export async function startDaemon(opts: DaemonOptions) {
3229
3211
  tasks.cohorts.turnEnded(e.peer);
3230
3212
  }
3231
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
+ }
3232
3220
  }
3233
3221
  if (e.state === "offline") { offlineSince.set(e.peer, offlineSince.get(e.peer) ?? Date.now()); supervision.peerOffline(e.peer); supervisionTurns.delete(e.peer); }
3234
3222
  else offlineSince.delete(e.peer);
@@ -3376,7 +3364,7 @@ export async function startDaemon(opts: DaemonOptions) {
3376
3364
  const replacingPi = new Set<PiPeer>();
3377
3365
  const piReplacementAuthority = Symbol("console Pi replacement");
3378
3366
  /** pi.auto_start at hub start (#269): in Pi's start mode, and said out loud when it could not be done. */
3379
- const autoStartPi = () => void hubStart("pi", "pi.auto_start").then(
3367
+ const autoStartPi = () => void hubStart(autoStartAdapter!.kind, "pi.auto_start").then(
3380
3368
  (result) => { if (result.ok !== true) notify(`pi.auto_start did not start Pi: ${String(result.error)}`); },
3381
3369
  (error) => log(`Pi auto-start failed: ${(error as Error).message}${piContinuity.state === "ready" ? `. ${continuityCleanup()}` : ""}`));
3382
3370
  async function stopPiForReplacement(pi: PiPeer): Promise<void> {
@@ -3417,7 +3405,7 @@ export async function startDaemon(opts: DaemonOptions) {
3417
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" }) };
3418
3406
  const CLI_ENTRY = fileURLToPath(new URL("../cli/main.ts", import.meta.url));
3419
3407
  /** `config.peers` follows a settings write (`settingsSet`), so a changed mode applies at the peer's next start. */
3420
- const startMode = (peer: string): StartMode => startModeOf(config.peers, peer, declarations);
3408
+ const startMode = (peer: string): StartMode => startModeOf(config.peers, peer, declarations, adapterCatalog);
3421
3409
  const startWords = (peer: string, mode: StartMode): string[] => {
3422
3410
  const words = definitionOf(peer)?.cli.startWords(mode) ?? [peer];
3423
3411
  return declarations[peer] ? [words[0]!, "--as", peer, ...words.slice(1)] : words;
@@ -3426,6 +3414,29 @@ export async function startDaemon(opts: DaemonOptions) {
3426
3414
  // ponytail: one terminal per peer per 30 s, by the clock. The hub cannot see whether the terminal it opened still
3427
3415
  // exists, so a peer that never attaches can be opened again after that; track the provider's handle to do better.
3428
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
+ }
3429
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. */
3430
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.
3431
3442
  const startPlans = () => STARTABLE_PEERS.filter(startable).map((peer) => {
@@ -3473,8 +3484,8 @@ export async function startDaemon(opts: DaemonOptions) {
3473
3484
  // ponytail: an attached terminal Pi only. A terminal launch that the TUI has not claimed yet reads as offline,
3474
3485
  // like one whose terminal was closed, so a headless start in that window replaces it; record when a launch was
3475
3486
  // issued to tell the two apart.
3476
- 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" };
3477
- 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 ?? {}) } : {}));
3478
3489
  }
3479
3490
  // #559 N1: only the console's own start reopens a frontend on an attached owned-ACP peer. The conductor and the
3480
3491
  // dashboard answer `already`, so a repeated call never adds another TUI writer to the live session.
@@ -3507,11 +3518,11 @@ export async function startDaemon(opts: DaemonOptions) {
3507
3518
  if (stopping || recoveryActive()) return { ok: false, error: "recovery or shutdown is holding peer stops; finish the operation before retrying" };
3508
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" };
3509
3520
  const owner = bus.peers.get(peer);
3510
- 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);
3511
3522
  if (stopNotice) return { ok: false, error: stopNotice };
3512
- 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" };
3513
- if (owner instanceof PiPeer && owner.mode !== "headless") return { ok: false, error: "Pi belongs to a native TUI terminal; end it in its terminal" };
3514
- 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` };
3515
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" };
3516
3527
  requestedPeerStops.add(peer); // fence delivery, starts and recovery before the first asynchronous stop step
3517
3528
  try {
@@ -3519,7 +3530,7 @@ export async function startDaemon(opts: DaemonOptions) {
3519
3530
  if (stopping || recoveryActive() || bus.peers.get(peer) !== owner) return { ok: false, error: "peer or recovery changed; refresh ahub status before retrying" };
3520
3531
  const reason = `requested stop: ahub stop ${peer}`; // ids only, never task or message text
3521
3532
  // Pi persists a session only after its first message: take live empty-session proof before stopping.
3522
- if (owner instanceof PiPeer && owner.state === "idle") {
3533
+ if (isPiPeer(owner) && owner.state === "idle") {
3523
3534
  try { await owner.captureResume(); }
3524
3535
  catch { log(`requested stop ${peer}: resume evidence unavailable; later start requires verified persisted history`); }
3525
3536
  }
@@ -3559,7 +3570,7 @@ export async function startDaemon(opts: DaemonOptions) {
3559
3570
  try {
3560
3571
  if (declarations[peer] && args.mode !== undefined && args.mode !== "headless") return { ok: false, error: "declared instances support headless starts only" };
3561
3572
  let executionCwd: string;
3562
- try { executionCwd = resolveInstanceExecutionCwd(opts.cwd, peer, declarations, config.roles[peer] ?? []); }
3573
+ try { executionCwd = resolveInstanceExecutionCwd(opts.cwd, peer, declarations, config.roles[peer] ?? [], adapterCatalog); }
3563
3574
  catch (error) { return { ok: false, error: (error as Error).message }; }
3564
3575
  executionRoots.set(peer, executionCwd);
3565
3576
  applyPendingPermissionDefault(peer);
@@ -3573,6 +3584,13 @@ export async function startDaemon(opts: DaemonOptions) {
3573
3584
 
3574
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>> {
3575
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" };
3576
3594
  if (adapter?.reviewer?.unavailable) return { ok: false, error: adapter.reviewer.unavailable };
3577
3595
  const preflight = adapter?.start.preflight?.({ instance: peer, form: args.mode });
3578
3596
  if (preflight) return { ok: false, error: preflight };
@@ -3581,7 +3599,7 @@ export async function startDaemon(opts: DaemonOptions) {
3581
3599
  const owner = bus.peers.get(peer);
3582
3600
  if ((args.mode ?? startMode(peer)) === "tui") {
3583
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" };
3584
- 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" };
3585
3603
  clearKimiAdmission();
3586
3604
  kimiAdmissionId = crypto.randomUUID();
3587
3605
  kimiSessionMode = "tui"; // scoped to this pending launch and its attached session
@@ -3589,7 +3607,7 @@ export async function startDaemon(opts: DaemonOptions) {
3589
3607
  kimiAdmissionExpiry = setTimeout(clearKimiAdmission, 30_000); kimiAdmissionExpiry.unref();
3590
3608
  return { ok: true, mode: "tui", pullOnly: true, command: "ahub kimi", admissionId: kimiAdmissionId };
3591
3609
  }
3592
- 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" };
3593
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" };
3594
3612
  clearKimiAdmission();
3595
3613
  }
@@ -3604,7 +3622,7 @@ export async function startDaemon(opts: DaemonOptions) {
3604
3622
  if (args.replaceSession) {
3605
3623
  if (recoveryActive()) return { ok: false, error: "recovery is holding session replacement" };
3606
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" };
3607
- 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" };
3608
3626
  const selected = validatePiSession(nativeStateDir(peer), executionCwd, args.sessionFile);
3609
3627
  const previous = existing.recoveryMetadata();
3610
3628
  recordPiReplacement(nativeStateDir(peer), instanceId, previous, selected);
@@ -3612,7 +3630,7 @@ export async function startDaemon(opts: DaemonOptions) {
3612
3630
  mute(existing);
3613
3631
  await stopPiForReplacement(existing);
3614
3632
  }
3615
- if (existing instanceof PiPeer && !args.replaceSession) {
3633
+ if (isPiPeer(existing) && !args.replaceSession) {
3616
3634
  let saved = existing.recoveryMetadata();
3617
3635
  const launch = saved.launch as Record<string, unknown>;
3618
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" };
@@ -3645,10 +3663,13 @@ export async function startDaemon(opts: DaemonOptions) {
3645
3663
  if (adapter?.start.reconfigureWhileAttached && existing?.state !== "offline" && existing && (args.model || args.route)) {
3646
3664
  if (existing.state === "busy") return { ok: false, error: "local is busy; retry when it is idle" };
3647
3665
  } else if (existing && existing.state !== "offline") {
3648
- 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 } : {}) };
3649
3670
  }
3650
3671
  if (!adapter?.start.reconfigureWhileAttached) {
3651
- if (existing instanceof PiPeer) await stopPiForReplacement(existing);
3672
+ if (isPiPeer(existing)) await stopPiForReplacement(existing);
3652
3673
  else await existing?.stop();
3653
3674
  }
3654
3675
  if (adapter?.start.daemonPort === "owned-acp") {
@@ -3664,7 +3685,7 @@ export async function startDaemon(opts: DaemonOptions) {
3664
3685
  if (adapter?.start.acpCommand) {
3665
3686
  const cmd = adapter.start.acpCommand(config.kimi_cmd, args.model);
3666
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"]({
3667
- onActivity: item => activityRings[peer]!.append(item),
3688
+ onActivity: item => activityRings[peer]?.append(item),
3668
3689
  isPrivateTurn: () => !!turns.get(peer)?.private || contextPii(peer),
3669
3690
  cmd, kind: adapter.kind,
3670
3691
  permissionMode: permissionMode(peer),
@@ -3693,11 +3714,9 @@ export async function startDaemon(opts: DaemonOptions) {
3693
3714
  if (observation.source !== "usage_update" || observation.shape !== "context-used") return;
3694
3715
  const current = contextSession(peer);
3695
3716
  if (!current) return;
3696
- contexts.report(peer,
3697
- observation.contextUsed !== undefined && observation.contextCapacity !== undefined
3698
- ? acpContext({ used: observation.contextUsed, size: observation.contextCapacity }, sessionId, Date.now())
3699
- : { source: "acp_usage_update", sessionId, measuredAt: Date.now(), tokens: null, window: null, used: null },
3700
- current);
3717
+ const reading = readNativeContext(adapter,
3718
+ { used: observation.contextUsed, size: observation.contextCapacity }, sessionId, Date.now());
3719
+ if (reading) contexts.report(peer, reading, current);
3701
3720
  },
3702
3721
  onTurnFailure: () => { supervisionTurns.delete(peer); },
3703
3722
  mcpServers: [{ name: "agent-hub", command: "bun", args: ["run", SERVER_JS], env: Object.entries(toolEnv(peer, true)).map(([name, value]) => ({ name, value })) }],
@@ -3711,36 +3730,38 @@ export async function startDaemon(opts: DaemonOptions) {
3711
3730
  } catch (error) { await kimi.stop().catch(stop => log(`kimi stop after a failed start: ${(stop as Error).message}`)); throw error; }
3712
3731
  return { ok: true };
3713
3732
  }
3714
- if (adapter?.transport === "acp" && adapter.start.create) {
3715
- // 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.
3716
3735
  const launch = adapter.start.launch({ instance: peer, form: "headless", projectRoot: opts.cwd, stateDir: opts.stateDir, entrypoint: CLI_ENTRY,
3717
- 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 });
3718
3737
  const mcpServers = [{ name: "agent-hub", command: "bun", args: ["run", SERVER_JS], env: Object.entries(toolEnv(peer, true)).map(([name, value]) => ({ name, value })) }];
3719
- const permission = permissionMode(peer);
3738
+ const permission = readOnlyPeer(peer) ? "read-only" as const : permissionMode(peer);
3720
3739
  const owner = adapter.start.create({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: undefined, permission, mcpServers, daemon: { acp: {
3721
- onActivity: item => activityRings[peer]!.append(item),
3740
+ onActivity: item => activityRings[peer]?.append(item),
3722
3741
  isPrivateTurn: () => !!turns.get(peer)?.private || contextPii(peer),
3723
- 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,
3724
3743
  ...(args.model ? { launchModel: args.model } : {}), ...(args.sessionId ? { resumeSessionId: args.sessionId } : {}),
3725
3744
  cwd: executionCwd, watchdogMs: config.watchdog_ms, onPermission,
3726
3745
  autoApprove: title => !stopping && HUB_TOOL_TITLES.has(title),
3727
- log: () => log("Qwen runtime diagnostic: details withheld"),
3746
+ log: () => log(`${peer}: ACP runtime diagnostic: details withheld`),
3728
3747
  onUsageDiagnostic: (observation, sessionId) => {
3729
3748
  if (observation.source !== "usage_update" || observation.shape !== "context-used") return;
3730
3749
  const current = contextSession(peer); if (!current) return;
3731
- contexts.report(peer, observation.contextUsed !== undefined && observation.contextCapacity !== undefined
3732
- ? acpContext({ used: observation.contextUsed, size: observation.contextCapacity }, sessionId, Date.now())
3733
- : { 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);
3734
3753
  },
3735
3754
  onTurnFailure: () => { supervisionTurns.delete(peer); },
3736
3755
  mcpServers,
3737
3756
  preamble: sessionRoleContract(peer),
3738
3757
  } } });
3739
- 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");
3740
3759
  recoveryTaskPreface(peer); await ensurePreface(peer);
3741
3760
  try { await owner.start(); await reconcileNativeStart(peer, owner); }
3742
3761
  catch (error) {
3743
- 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)`);
3744
3765
  if (error instanceof QwenIdentityError) throw error;
3745
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)");
3746
3767
  }
@@ -3753,7 +3774,8 @@ export async function startDaemon(opts: DaemonOptions) {
3753
3774
  admitAttach: () => membershipAllowed(peer),
3754
3775
  permissionMode: permissionMode(peer),
3755
3776
  onTokens: (added) => void addTokens(peer, added),
3756
- 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,
3757
3779
  // #215: before the thread's first turn_start, so the planner can tell which turns belong to which thread.
3758
3780
  onThread: (thread, fresh) => { lastCodexThreads.set(peer, thread); event({ type: "native_thread", peer, thread, fresh }); },
3759
3781
  onTurn: (native) => {
@@ -3802,7 +3824,7 @@ export async function startDaemon(opts: DaemonOptions) {
3802
3824
  ...HUB_TOOLS.map((name) => [`tools.${name}.approval_mode`, '"approve"']),
3803
3825
  ].flatMap(([k, v]) => ["-c", `mcp_servers.agent-hub.${k}=${v}`]),
3804
3826
  preamble: sessionRoleContract(peer),
3805
- 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); },
3806
3828
  usagePollMs: config.budget.poll_min * 60_000,
3807
3829
  cwd: executionCwd,
3808
3830
  watchdogMs: config.watchdog_ms,
@@ -3828,7 +3850,7 @@ export async function startDaemon(opts: DaemonOptions) {
3828
3850
  staySwitch: () => activeRouting(),
3829
3851
  onRoute: record => event({ type: "route", peer, ...record }),
3830
3852
  efficientWaitMs: activeRouting().pi.efficient_wait_ms,
3831
- 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); },
3832
3854
  onCooldown: record => {
3833
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"}`);
3834
3856
  event({ type: "cooldown", peer, ...record });
@@ -4112,9 +4134,9 @@ export async function startDaemon(opts: DaemonOptions) {
4112
4134
  return { ok: false, error: `unknown peer "${peer}"` };
4113
4135
  }
4114
4136
 
4115
- 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 }> {
4116
4138
  const current = bus.peers.get(peer);
4117
- 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" };
4118
4140
  const decisions = tasks.admitExecutionEnvelopes(current.budgetEnvelopes, peer, "model_calls");
4119
4141
  const denied = decisions.find(d => !d.allowed);
4120
4142
  const stop = denied ? current.recordBudgetStop(denied) : undefined;
@@ -4147,7 +4169,7 @@ export async function startDaemon(opts: DaemonOptions) {
4147
4169
  } else {
4148
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}` };
4149
4171
  const peer = bus.peers.get(id);
4150
- if (peer instanceof LocalPeer) {
4172
+ if (isLocalPeer(peer)) {
4151
4173
  const generation = pauseGeneration.get(id);
4152
4174
  const launch = peer.recoveryMetadata().launch as { route?: string; model: string };
4153
4175
  try { await validateLocalChoice(launch.route, launch.model); }
@@ -4168,6 +4190,7 @@ export async function startDaemon(opts: DaemonOptions) {
4168
4190
 
4169
4191
  const conductor = new Conductor(conductorHolds, {
4170
4192
  roles: () => config.roles, capabilities: () => config.capabilities, instances: () => declarations,
4193
+ adapterCatalog,
4171
4194
  status: () => {
4172
4195
  const quota = budget.status();
4173
4196
  const ids = [...new Set([...bus.knownPeers(), ...Object.keys(quota), ...conductorHolds.list().map(h => h.peer)])];
@@ -4200,7 +4223,7 @@ export async function startDaemon(opts: DaemonOptions) {
4200
4223
  pause: peer => { pauseGeneration.set(peer, (pauseGeneration.get(peer) ?? 0) + 1); bus.pause(peer); writeStatus(); },
4201
4224
  validateRelease: async peer => {
4202
4225
  const owner = bus.peers.get(peer);
4203
- if (!(owner instanceof LocalPeer)) return;
4226
+ if (!isLocalPeer(owner)) return;
4204
4227
  const generation = pauseGeneration.get(peer);
4205
4228
  const launch = owner.recoveryMetadata().launch as { route?: string; model: string };
4206
4229
  await validateLocalChoice(launch.route, launch.model);
@@ -4375,7 +4398,7 @@ export async function startDaemon(opts: DaemonOptions) {
4375
4398
  function roleHolders(candidate: HubConfig): { planners: string[]; conductors: string[] } {
4376
4399
  // A valid transfer may briefly have two conductors while its leaves are planned. Validate the final map later.
4377
4400
  const policy = Object.fromEntries(Object.entries(candidate.roles).map(([peer, roles]) => [peer, roles.filter(role => role !== "conductor")]));
4378
- 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));
4379
4402
  return { planners: settled.planners, conductors: Object.keys(candidate.roles).filter(peer => candidate.roles[peer]!.includes("conductor")).sort() };
4380
4403
  }
4381
4404
  function prepareConfiguration(sources: ConfigSources, authority: SettingAuthority, scope?: string, confirmed: readonly string[] = []) {
@@ -4389,7 +4412,7 @@ export async function startDaemon(opts: DaemonOptions) {
4389
4412
  const prefix = layer.name.endsWith(".toml") ? "routing" : "";
4390
4413
  for (const path of declaredPaths(layer.raw, prefix)) paths.add(path);
4391
4414
  }
4392
- 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);
4393
4416
  for (const key of paths) {
4394
4417
  if (scope && key !== scope) continue;
4395
4418
  const route = key.startsWith("routing."), path = (route ? key.slice(8) : key).split(".");
@@ -4404,10 +4427,12 @@ export async function startDaemon(opts: DaemonOptions) {
4404
4427
  const after = valueAt(route ? sources.routing : opts.config && !current ? embeddedConfig : fileConfig, path);
4405
4428
  const rawAfter = current ? valueAt(current.raw, path) : undefined;
4406
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.
4407
4431
  // Instance declarations are restart-bound. Their derived role/permission/start defaults cannot leak into
4408
4432
  // the live catalog while the old identities, paths and account callbacks still own the daemon.
4409
4433
  const boundId = ["roles", "permission_modes", "peers", "permission_default_sources"].includes(path[0] ?? "") ? path[1] : undefined;
4410
- 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))) {
4411
4436
  rejected.push({ key, reason: "instance declaration changed; its derived defaults apply together after daemon restart" });
4412
4437
  continue;
4413
4438
  }
@@ -4471,7 +4496,7 @@ export async function startDaemon(opts: DaemonOptions) {
4471
4496
  }
4472
4497
  candidate.retired = fileConfig.retired;
4473
4498
  routing.retired = sources.routing.retired;
4474
- 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)]);
4475
4500
  const running = structuredClone({ ...config, roles: policyRoles });
4476
4501
  for (const change of changes) if (change.applies !== "restart") putConfig(running, change.key, change.after);
4477
4502
  running.retired = fileConfig.retired;
@@ -4505,7 +4530,7 @@ export async function startDaemon(opts: DaemonOptions) {
4505
4530
  const texts = configSourceTexts(opts.cwd), next = configSourceTexts(scratch);
4506
4531
  texts["config.json"] = next["config.json"];
4507
4532
  texts["config.local.json"] = next["config.local.json"];
4508
- const prepared = prepareConfiguration(parseConfigSources(texts), authority, scope);
4533
+ const prepared = prepareConfiguration(parseConfigSources(texts, undefined, opts.cwd), authority, scope);
4509
4534
  const refusal = prepared.rejected.find(item => item.key === scope);
4510
4535
  if (refusal) throw new Error(`${refusal.key}: ${refusal.reason}`);
4511
4536
  return prepared.fileConfig;
@@ -4513,7 +4538,7 @@ export async function startDaemon(opts: DaemonOptions) {
4513
4538
  function routingWriteCheck(authority: SettingAuthority, overlay: import("./routing.ts").RoutingOverlay | undefined, scope?: string): void {
4514
4539
  const texts = configSourceTexts(opts.cwd);
4515
4540
  texts["routing.local.toml"] = overlay === undefined ? null : overlayToml(overlay);
4516
- const prepared = prepareConfiguration(parseConfigSources(texts), authority, scope);
4541
+ const prepared = prepareConfiguration(parseConfigSources(texts, undefined, opts.cwd), authority, scope);
4517
4542
  const refusal = prepared.rejected.find(item => item.key === scope);
4518
4543
  if (refusal) throw new Error(`${refusal.key}: ${refusal.reason}`);
4519
4544
  }
@@ -4530,7 +4555,7 @@ export async function startDaemon(opts: DaemonOptions) {
4530
4555
  const texts = configSourceTexts(opts.cwd);
4531
4556
  fingerprint = configFingerprint(texts);
4532
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" };
4533
- const sources = parseConfigSources(texts);
4558
+ const sources = parseConfigSources(texts, undefined, opts.cwd);
4534
4559
  const prepared = prepareConfiguration(sources, authority, scope, confirm === undefined ? [] : reloadConfirmation!.keys);
4535
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" };
4536
4561
  if (scope && prepared.rejected.some(item => item.key === scope)) return { ok: false, error: prepared.rejected.find(item => item.key === scope)!.reason };
@@ -4564,7 +4589,7 @@ export async function startDaemon(opts: DaemonOptions) {
4564
4589
  desiredRouting = structuredClone(prepared.routing);
4565
4590
  acknowledgeConfiguration(sources, prepared.accepted);
4566
4591
  if (priorDesired !== JSON.stringify([desiredConfig, desiredRouting, acceptedSources.trustLayers])) configRevision++;
4567
- 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! }));
4568
4593
  pendingRestart.push(...routingChanges.filter(key => configApply(key).applies === "restart").map(key => ({ key, reason: configApply(key).reason! })));
4569
4594
  retainedConfig = retainedConfigAfterReload(sources, prepared.fileConfig, prepared.rejected, scope);
4570
4595
  storedConfigCache = undefined;
@@ -4618,9 +4643,9 @@ export async function startDaemon(opts: DaemonOptions) {
4618
4643
  let routingProblem: string | undefined;
4619
4644
  try { parseRouting(routingText(opts.cwd), readOverlay(opts.cwd)); } catch (error) { routingProblem = (error as Error).message; }
4620
4645
  const rows = settingRows({
4621
- 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 } : {}),
4622
4647
  running: (path) => path[0] === "roster" ? onRoster(path[1]!) : path[0] === "tasks" && path[1] === "wip_limits" ? tasks.wipLimit(path[2]!) : valueAt(config, path),
4623
- 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,
4624
4649
  roles: roleState,
4625
4650
  mode: (peer) => {
4626
4651
  const owner = bus.peers.get(peer), attached = !!owner && owner.state !== "offline";
@@ -4659,14 +4684,14 @@ export async function startDaemon(opts: DaemonOptions) {
4659
4684
  if (owner?.state === "busy" && !now) return `${peer} is in a turn; retry when it is idle, or pass --now`;
4660
4685
  return undefined;
4661
4686
  }
4662
- function codexResumeCommand(peer = "codex"): string {
4663
- 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);
4664
4689
  const command = declarations[peer] ? `ahub codex --as ${peer} resume` : "ahub codex resume";
4665
4690
  return thread && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(thread) ? `${command} ${thread}` : command;
4666
4691
  }
4667
4692
  function retainedTerminalNotice(peer: string): string | undefined {
4668
4693
  const owner = bus.peers.get(peer);
4669
- 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));
4670
4695
  }
4671
4696
 
4672
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>> {
@@ -4676,7 +4701,7 @@ export async function startDaemon(opts: DaemonOptions) {
4676
4701
  const terminalNotice = retainedTerminalNotice(peer);
4677
4702
  let handoff: Awaited<ReturnType<Tasks["reassignForRoster"]>> = [];
4678
4703
  try {
4679
- 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);
4680
4705
  if (!after) {
4681
4706
  const refusal = rosterRemovalRefusal(peer, now);
4682
4707
  if (refusal) return { ok: false, error: refusal };
@@ -4695,9 +4720,9 @@ export async function startDaemon(opts: DaemonOptions) {
4695
4720
  if (!after) {
4696
4721
  for (const pending of permissions.values()) if (pending.peer === peer) pending.done(undefined);
4697
4722
  const owner = bus.peers.get(peer);
4698
- if (owner instanceof WsPeer) { owner.detachForRoster(); if (definitionOf(peer)?.attach?.admission) clearKimiAdmission(); }
4699
- else if (owner instanceof CodexPeer) owner.detachForRoster(config.permission_modes[peer] === "never-ask" ? "ask" : config.permission_modes[peer] ?? "ask");
4700
- 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();
4701
4726
  else if (owner && owner.state !== "offline") {
4702
4727
  const result = await stopPeer(peer, true);
4703
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`);
@@ -4710,7 +4735,7 @@ export async function startDaemon(opts: DaemonOptions) {
4710
4735
  handoff = await tasks.reassignForRoster(peer);
4711
4736
  } else {
4712
4737
  const owner = bus.peers.get(peer);
4713
- if (owner instanceof PiPeer && owner.mode === "tui") await owner.resumeFromRoster();
4738
+ if (isPiPeer(owner) && owner.mode === "tui") await owner.resumeFromRoster();
4714
4739
  }
4715
4740
  const reloaded = await reloadConfig(authority, `roster.${peer}`);
4716
4741
  if (reloaded.ok !== true) return { ...reloaded, saved: true };
@@ -4825,14 +4850,14 @@ export async function startDaemon(opts: DaemonOptions) {
4825
4850
  const { def, value } = input;
4826
4851
  if (def.group === "Roster") {
4827
4852
  try {
4828
- 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);
4829
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 }) };
4830
4855
  } catch (error) { return { ok: false, error: sanitize((error as Error).message) }; }
4831
4856
  }
4832
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."] };
4833
4858
  if (def.group === "Permissions") {
4834
4859
  const peer = def.path.at(-1)!;
4835
- 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 } : {}) };
4836
4861
  }
4837
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"}`] };
4838
4863
  try {
@@ -4927,7 +4952,7 @@ export async function startDaemon(opts: DaemonOptions) {
4927
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.
4928
4953
  if (!peer()) return bad;
4929
4954
  const result = await hubStart(a.peer, "the dashboard");
4930
- 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)}` };
4931
4956
  // A refusal the hub worded itself carries the command to run by hand; an adapter's own failure text stays in the log.
4932
4957
  if (typeof result.command === "string" || typeof a.peer === "string" && !membershipAllowed(a.peer)) return { ok: false, error: sanitize(String(result.error)) };
4933
4958
  log(`dashboard start ${a.peer} failed: ${String(result.error)}`);
@@ -4963,7 +4988,7 @@ export async function startDaemon(opts: DaemonOptions) {
4963
4988
  ...(checkPending.size !== 0 ? ["isolated checks queued or running"] : []),
4964
4989
  ...(piCallsInFlight() !== 0 ? ["Pi tool calls in flight"] : []),
4965
4990
  ...(releasing ? ["ready tasks being released"] : []),
4966
- ...([...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`)),
4967
4992
  ],
4968
4993
  integrity: { current: integrityAs(restored?.integrity), ...(restored?.integrity ? { expected: restored.integrity } : {}) },
4969
4994
  peers: Object.fromEntries([...(recoveryPeerSnapshot ?? []), ...Object.values(recoveryPeers()).filter((peer) => !(recoveryPeerSnapshot ?? []).some((saved) => saved.id === peer.id))].map((peer) => {
@@ -4984,6 +5009,7 @@ export async function startDaemon(opts: DaemonOptions) {
4984
5009
  const incompatibleRecoveryTarget = () => {
4985
5010
  const cancelled = incompatibleCancellationTarget();
4986
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";
4987
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";
4988
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";
4989
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";
@@ -4995,7 +5021,7 @@ export async function startDaemon(opts: DaemonOptions) {
4995
5021
  }
4996
5022
 
4997
5023
  if (msg.op === "inspect" || msg.op === "prepare" || msg.op === "commit") {
4998
- 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") {
4999
5025
  try { await pi.captureResume(); } catch (error) { return recoveryError((error as Error).message); }
5000
5026
  if (checkTarget) { const error = incompatibleRecoveryTarget(); if (error) return recoveryError(error); }
5001
5027
  }
@@ -5105,7 +5131,7 @@ export async function startDaemon(opts: DaemonOptions) {
5105
5131
  return recoveryError("unknown recovery operation");
5106
5132
  }
5107
5133
 
5108
- function claudePermissionLaunch(peer = "claude"): { launchId: string; unattended: boolean; hookPurpose?: string } | undefined {
5134
+ function claudePermissionLaunch(peer: string): { launchId: string; unattended: boolean; hookPurpose?: string } | undefined {
5109
5135
  try {
5110
5136
  const row = JSON.parse(readFileSync(join(opts.stateDir, claudeStateFile("claude-launch", peer)), "utf8"));
5111
5137
  if (row.instanceId !== instanceId || row.permissionHook !== true || typeof row.launchId !== "string" || !row.launchId) return undefined;
@@ -5114,15 +5140,16 @@ export async function startDaemon(opts: DaemonOptions) {
5114
5140
  }
5115
5141
  async function changePermission(peer: unknown, mode: unknown, confirmed: unknown): Promise<Record<string, unknown>> {
5116
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" };
5117
5144
  if (definitionOf(peer)?.permission.unmanaged) return { ok: false, error: definitionOf(peer)!.permission.unmanaged };
5118
- 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 };
5119
5146
  if (requestedPeerStops.has(peer)) return { ok: false, error: "peer stop is in progress; wait for ahub status before changing its mode" };
5120
5147
  const owner = bus.peers.get(peer);
5121
5148
  if (mode === "ask" && (!owner || owner.state === "offline")) {
5122
5149
  // A Codex proxy outlives its TUI and keeps its own copy of the mode: clear that too, or the next TUI that attaches
5123
5150
  // to it would run a mode the status no longer shows.
5124
5151
  // It is never refused here: a leftover non-ask mode must always be undoable while the peer is away.
5125
- 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;
5126
5153
  if (note) log(`permission mode codex: ${note}`);
5127
5154
  const from = permissionMode(peer);
5128
5155
  permissionModes.set(peer, "ask"); permissionModeSources.set(peer, "human runtime command"); pendingPermissionDefaults.delete(peer);
@@ -5130,22 +5157,22 @@ export async function startDaemon(opts: DaemonOptions) {
5130
5157
  log(`permission mode ${peer}: ${from} -> ask (offline hub choice cleared)`); writeStatus();
5131
5158
  return { ok: true, peer, permissionMode: "ask", ...(note ? { note } : {}) };
5132
5159
  }
5133
- 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" };
5134
5161
  if (!owner || owner.state === "offline") return { ok: false, error: `${peer} is not attached; start it through ahub first` };
5135
5162
  if (mode === undefined) return { ok: true, peer, permissionMode: permissionDisplay(peer, owner) };
5136
5163
  if (!isPermissionMode(mode)) return { ok: false, error: "mode must be ask, ask-when-needed or never-ask" };
5137
5164
  if (mode === "never-ask" && confirmed !== true) return { ok: false, error: "never-ask needs --yes or console confirmation" };
5138
5165
  if (definitionOf(peer)?.transport === "claude-channel") {
5139
5166
  const launch = claudePermissionLaunch(peer);
5140
- 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" };
5141
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" };
5142
- } else if (owner instanceof CodexPeer) {
5169
+ } else if (isCodexPeer(owner)) {
5143
5170
  if (!owner.proxyAttached) return { ok: false, error: "Codex TUI is not behind the hub proxy; reconnect with ahub codex" };
5144
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" };
5145
5172
  }
5146
5173
  const from = permissionMode(peer);
5147
5174
  try {
5148
- if (owner instanceof AcpPeer || owner instanceof CodexPeer) await owner.setPermissionMode(mode);
5175
+ if (isAcpPeer(owner) || isCodexPeer(owner)) await owner.setPermissionMode(mode);
5149
5176
  } catch (error) { return { ok: false, error: (error as Error).message }; }
5150
5177
  if (bus.peers.get(peer) !== owner || bus.stateOf(peer) === "offline") return { ok: false, error: `${peer} session changed; inspect status and retry` };
5151
5178
  permissionModes.set(peer, mode);
@@ -5158,6 +5185,7 @@ export async function startDaemon(opts: DaemonOptions) {
5158
5185
  }
5159
5186
 
5160
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" };
5161
5189
  if (typeof peer !== "string" || !pendingPermissionDefaults.has(peer)) return { ok: false, error: "no pending permission default for this peer" };
5162
5190
  const source = pendingPermissionDefaults.get(peer)!;
5163
5191
  if (confirmed !== true) {
@@ -5167,7 +5195,7 @@ export async function startDaemon(opts: DaemonOptions) {
5167
5195
  }
5168
5196
  const owner = bus.peers.get(peer);
5169
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" };
5170
- 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" };
5171
5199
  if (!starting.has(peer) && owner && owner.state !== "offline") {
5172
5200
  const result = await changePermission(peer, "never-ask", true);
5173
5201
  if (!result.ok) return result;
@@ -5198,9 +5226,9 @@ export async function startDaemon(opts: DaemonOptions) {
5198
5226
  const result = await permissionChange(peer, async () => {
5199
5227
  if (stopping) return { ok: false, error: "hub is stopping" };
5200
5228
  const selected = permissionMode(peer);
5201
- 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" };
5202
- if (!(owner instanceof AcpPeer && owner.permissionModeState === "unmanaged") && owner.getPermissionMode() !== selected) {
5203
- 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);
5204
5232
  else await owner.setPermissionMode(selected);
5205
5233
  }
5206
5234
  // The bus may immediately drain at add(): do not expose an idle adapter until its current choice is applied.
@@ -5237,7 +5265,7 @@ export async function startDaemon(opts: DaemonOptions) {
5237
5265
  if (!PEER_ID.test(c.peer) || c.peer === USER || c.peer === "hub") return sock.close(4403, "peer id is reserved or malformed");
5238
5266
  if (!membershipAllowed(c.peer)) return sock.close(4000, "peer is off roster; retry when a person adds it");
5239
5267
  } else {
5240
- c.peer = String(msg.peer ?? "claude");
5268
+ c.peer = String(msg.peer ?? defaultChannelPeer(undefined));
5241
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");
5242
5270
  const kimiPull = !!definitionOf(c.peer)?.attach?.admission && (kimiSessionMode ?? startMode(c.peer)) === "tui" && msg.channel === false;
5243
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");
@@ -5253,9 +5281,9 @@ export async function startDaemon(opts: DaemonOptions) {
5253
5281
  if (typeof msg.launchId === "string" && msg.launchId.length <= 128) c.launchId = msg.launchId;
5254
5282
  }
5255
5283
  let peer = bus.peers.get(c.peer);
5256
- if (kimiPull && peer instanceof AcpPeer && peer.state === "offline") peer = undefined;
5284
+ if (kimiPull && isAcpPeer(peer) && peer.state === "offline") peer = undefined;
5257
5285
  if (!peer) bus.add((peer = new WsPeer(c.peer)));
5258
- 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");
5259
5287
  const ws = peer;
5260
5288
  if (msg.standby === true && ws.standbyHeld(msg.channel === false)) return sock.close(4000, "another session holds or is claiming this peer");
5261
5289
  // Without pushes it stands by, as a replaced session does, and never strands a channel session's deliveries.
@@ -5321,7 +5349,7 @@ export async function startDaemon(opts: DaemonOptions) {
5321
5349
  }
5322
5350
  case "delivery_receipt": {
5323
5351
  const peer = c.peer ? bus.peers.get(c.peer) : undefined;
5324
- 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" });
5325
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" });
5326
5354
  const reason = typeof msg.reason === "string" ? sanitize(msg.reason).replace(/\s+/g, " ").trim().slice(0, 300) : "";
5327
5355
  bus.deliveryReceipt(peer.id, { id: msg.deliveryId, state: msg.state, ...(msg.state !== "accepted" ? { reason: reason || "Claude bridge could not confirm notification delivery" } : {}) });
@@ -5331,7 +5359,7 @@ export async function startDaemon(opts: DaemonOptions) {
5331
5359
  // The attached session can explicitly claim queued work even during a native turn (#493).
5332
5360
  // Pull and push share holds and journal settlement; work read here is completed, never pushed again.
5333
5361
  const peer = c.peer ? bus.peers.get(c.peer) : undefined;
5334
- 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" });
5335
5363
  try {
5336
5364
  const envs = bus.pull(peer.id);
5337
5365
  if (!envs) return void reply({ ok: false, error: queueHold(peer.id) ?? (bus.isRecoveryHeld ? "recovery is holding deliveries" : pauseReason(peer.id)) });
@@ -5343,7 +5371,7 @@ export async function startDaemon(opts: DaemonOptions) {
5343
5371
  }
5344
5372
  case "delivery_complete": {
5345
5373
  const peer = c.peer ? bus.peers.get(c.peer) : undefined;
5346
- 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" });
5347
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" });
5348
5376
  }
5349
5377
  case "execution_budget": {
@@ -5419,7 +5447,7 @@ export async function startDaemon(opts: DaemonOptions) {
5419
5447
  if (launch.session && launch.session !== native.sessionId) return void reply({ ok: false });
5420
5448
  launch.session = native.sessionId;
5421
5449
  const owner = bus.peers.get(peer);
5422
- 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 });
5423
5451
  if (launch.generation && launch.generation !== owner.sessionGeneration) return void reply({ ok: false });
5424
5452
  launch.generation = owner.sessionGeneration;
5425
5453
  const current = () => !stopping && nativeHookLaunches.get(peer, msg.nativeLaunchId) === launch && bus.peers.get(peer) === owner && owner.sessionGeneration === launch.generation && owner.state !== "offline";
@@ -5517,15 +5545,15 @@ export async function startDaemon(opts: DaemonOptions) {
5517
5545
  const nativePeer = nativeClaude ? bus.peers.get(peer) : undefined;
5518
5546
  const permissionLaunch = definitionOf(peer)?.transport === "claude-channel" ? claudePermissionLaunch(peer) : undefined;
5519
5547
  const permission = nativeClaude && permissionLaunch && !permissionLaunch.unattended && msg.nativeLaunchId === permissionLaunch.launchId ? permissionMode(peer) : undefined;
5520
- if (permission !== undefined && nativePeer instanceof WsPeer) claudePermissionGenerations.set(peer, nativePeer.sessionGeneration);
5548
+ if (permission !== undefined && isWsPeer(nativePeer)) claudePermissionGenerations.set(peer, nativePeer.sessionGeneration);
5521
5549
  const protectedWrite = scopedWriteBlocked(phase, permission, msg.tool, () => projectFileTool(msg.tool, msg.input, opts.cwd, config.protect_instruction_files, config.data?.readonly));
5522
5550
  const permissionReply = phase === "pre" && permission !== undefined ? { permission: permission === "ask-when-needed" && protectedWrite ? "ask" : permission, projectRoot: opts.cwd, protectInstructionFiles: config.protect_instruction_files } : {};
5523
5551
  // Permission-only hooks carry no native-turn evidence and must never drive busy state.
5524
5552
  const purpose = permissionLaunch?.hookPurpose ?? msg.hookPurpose;
5525
- 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")));
5526
5554
  if (nativeClaude && !observesNative) return void reply({ t: "facts", ok: true, ...permissionReply });
5527
5555
  if (phase === "session") { recordSessions(); return void reply({ t: "facts", ok: true }); }
5528
- if ((phase === "start" || phase === "pre") && nativePeer instanceof WsPeer) {
5556
+ if ((phase === "start" || phase === "pre") && isWsPeer(nativePeer)) {
5529
5557
  const previous = nativeTurnStart.get(peer);
5530
5558
  const current = claudeSession(peer);
5531
5559
  if (phase === "start" || nativePeer.state !== "busy" || previous?.generation !== nativePeer.sessionGeneration || previous?.session !== sessionId || previous?.launch !== (current.launchId ?? "")) {
@@ -5548,7 +5576,7 @@ export async function startDaemon(opts: DaemonOptions) {
5548
5576
  const st = hookStats.get(peer)!;
5549
5577
  event({ type: "hook_stats", peer, n: st.n, startupMs: Math.round(st.startupMs), hubMs: Math.round(st.hubMs), maxStartupMs: Math.round(st.maxStartupMs) });
5550
5578
  hookStats.delete(peer);
5551
- if (id && nativePeer instanceof WsPeer) nativePeer.nativeStopped(sessionId!);
5579
+ if (id && isWsPeer(nativePeer)) nativePeer.nativeStopped(sessionId!);
5552
5580
  if (factsOn()) { factSession(peer, sessionId); readbacks(peer, claudeTranscript(sessionId, msg.transcriptPath, peer)); }
5553
5581
  };
5554
5582
  if (!nativeClaude) { finishStop(); reply({ t: "facts", ok: true }); return; } // legacy bookkeeping cannot certify native completion
@@ -5557,13 +5585,13 @@ export async function startDaemon(opts: DaemonOptions) {
5557
5585
  const readCompletion = () => { transcript ??= claudeTranscript(sessionId, msg.transcriptPath, peer); return session.sessionId && transcript ? readClaudeTranscriptCompletion(session.sessionId, transcript) : undefined; };
5558
5586
  const completionId = (id: string) => createHash("sha256").update(`${sessionId}:${session.launchId ?? "legacy"}:${id}`).digest("hex");
5559
5587
  const started = nativeTurnStart.get(peer);
5560
- 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;
5561
5589
  // A seen previous completion is a duplicate only outside an active start. During B it is B's baseline.
5562
5590
  if (!activeStart) {
5563
5591
  const latest = readCompletion();
5564
5592
  if (latest?.at && nativeStopsSeen.has(completionId(latest.id))) return void reply({ t: "facts", ok: true });
5565
5593
  }
5566
- 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 }); }
5567
5595
  const scopeCurrent = () => {
5568
5596
  const now = claudeSession(peer);
5569
5597
  return !stopping && bus.peers.get(peer) === nativePeer && nativePeer.sessionGeneration === started.generation && now.sessionId === started.session && (now.launchId ?? "") === started.launch;
@@ -5644,7 +5672,7 @@ export async function startDaemon(opts: DaemonOptions) {
5644
5672
  // Judged by the live owner: a headless Kimi started by --mode headless is watchable under a TUI start mode.
5645
5673
  const owner = bus.peers.get(peer);
5646
5674
  const admission = definitionOf(peer)?.attach?.admission;
5647
- 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 });
5648
5676
  if (!membershipAllowed(peer)) return void reply({ ok: false, error: `${peer} is off roster` });
5649
5677
  c.watch?.();
5650
5678
  c.watch = activityRings[peer]!.subscribe(item => {
@@ -5767,7 +5795,7 @@ export async function startDaemon(opts: DaemonOptions) {
5767
5795
  if (c.role !== "console") return void reply({ ok: false, error: "native TUI launch is console only" });
5768
5796
  const peer = String(msg.peer), owner = bus.peers.get(peer);
5769
5797
  if (stopping || recoveryActive() || !membershipAllowed(peer) || rosterChanging === peer || requestedPeerStops.has(peer) ||
5770
- 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" });
5771
5799
  try {
5772
5800
  if (msg.binding !== undefined && (!msg.binding || typeof msg.binding.launchId !== "string" || typeof msg.binding.sessionId !== "string")) throw new Error("invalid binding");
5773
5801
  const options = msg.args ?? {};