@staix/agent-hub 0.12.34 → 0.12.36

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 (91) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +8 -6
  3. package/docs/agent-notes/adapters.md +6 -1
  4. package/docs/agent-notes/budget.md +4 -2
  5. package/docs/agent-notes/bus.md +5 -1
  6. package/docs/agent-notes/daemon.md +11 -3
  7. package/docs/agent-notes/local-worker.md +1 -1
  8. package/docs/agent-notes/tasks.md +15 -2
  9. package/docs/agent-notes/tests.md +5 -1
  10. package/docs/gsd-collaboration-analysis.md +5 -1
  11. package/docs/operations.md +97 -19
  12. package/docs/quickstart.md +8 -4
  13. package/docs/sdlc.md +100 -34
  14. package/docs/security.md +7 -3
  15. package/docs/specs/2026-09-19-agent-hub-design.md +237 -7
  16. package/docs/verification/2026-10-18-0.12.35.md +49 -0
  17. package/docs/verification/2026-10-18-493-claude-busy-channel.md +91 -0
  18. package/docs/verification/2026-10-19-0.12.36.md +63 -0
  19. package/docs/verified.json +26 -17
  20. package/package.json +1 -1
  21. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  22. package/plugins/agent-hub/server.js +15781 -14850
  23. package/src/adapters/acp.ts +3 -1
  24. package/src/adapters/claude-channel.ts +59 -29
  25. package/src/adapters/claude-print.ts +230 -0
  26. package/src/adapters/codex-appserver.ts +1 -1
  27. package/src/adapters/definitions/builtin-metadata.ts +17 -1
  28. package/src/adapters/definitions/native-cli-entry-claude.ts +17 -3
  29. package/src/adapters/definitions/native-cli-entry-codex.ts +13 -5
  30. package/src/adapters/definitions/native-cli-entry-jcode.ts +6 -2
  31. package/src/adapters/definitions/native-cli-entry-kimi.ts +18 -12
  32. package/src/adapters/definitions/native-cli-entry-local.ts +3 -2
  33. package/src/adapters/definitions/native-cli-entry-pi.ts +8 -6
  34. package/src/adapters/definitions/native-cli-entry-qwen.ts +3 -2
  35. package/src/adapters/definitions/native-cli-entry-types.ts +3 -0
  36. package/src/adapters/definitions/native-factories.ts +7 -0
  37. package/src/adapters/definitions/native-recovery.ts +2 -2
  38. package/src/adapters/definitions/qwen-native.ts +2 -2
  39. package/src/adapters/local-worker.ts +3 -2
  40. package/src/adapters/pi.ts +3 -1
  41. package/src/adapters/registry-types.ts +11 -2
  42. package/src/adapters/registry.ts +36 -8
  43. package/src/cli/help.ts +5 -0
  44. package/src/cli/identity.ts +5 -4
  45. package/src/cli/launch.ts +45 -7
  46. package/src/cli/main.ts +136 -29
  47. package/src/cli/preview.ts +16 -5
  48. package/src/cli/recovery-guidance.ts +39 -22
  49. package/src/cli/reset.ts +4 -2
  50. package/src/cli/statusline-tee.ts +5 -2
  51. package/src/cli/terminal-recovery.ts +73 -16
  52. package/src/cli/upgrade-restore-state.ts +16 -7
  53. package/src/cli/upgrade-runtime.ts +83 -52
  54. package/src/cli/upgrade.ts +43 -27
  55. package/src/hub/board.ts +29 -7
  56. package/src/hub/budget.ts +55 -3
  57. package/src/hub/bus.ts +12 -6
  58. package/src/hub/claude-state.ts +37 -0
  59. package/src/hub/conductor.ts +16 -10
  60. package/src/hub/config-apply.ts +13 -2
  61. package/src/hub/config-reload.ts +11 -1
  62. package/src/hub/config-trust.ts +1 -1
  63. package/src/hub/control-client.ts +1 -1
  64. package/src/hub/cost-governance.ts +76 -0
  65. package/src/hub/crash.ts +14 -5
  66. package/src/hub/daemon.ts +473 -194
  67. package/src/hub/execution-budget.ts +3 -3
  68. package/src/hub/hub-tools.ts +36 -6
  69. package/src/hub/instance-budget.ts +73 -0
  70. package/src/hub/instance-config.ts +47 -0
  71. package/src/hub/instance-execution.ts +50 -0
  72. package/src/hub/instances.ts +114 -0
  73. package/src/hub/lifecycle-schema.ts +7 -3
  74. package/src/hub/permission-mode.ts +7 -7
  75. package/src/hub/review-brief.ts +175 -0
  76. package/src/hub/roster.ts +7 -6
  77. package/src/hub/routing.ts +97 -18
  78. package/src/hub/sdlc-queue.ts +2 -2
  79. package/src/hub/sdlc.ts +286 -39
  80. package/src/hub/settings.ts +34 -6
  81. package/src/hub/snapshots.ts +40 -9
  82. package/src/hub/start-mode.ts +7 -4
  83. package/src/hub/status-roster.ts +18 -0
  84. package/src/hub/task-sweep.ts +143 -0
  85. package/src/hub/tasks.ts +595 -47
  86. package/src/hub/text-projection.ts +7 -3
  87. package/src/hub/usage.ts +57 -20
  88. package/src/hub/workflow.ts +30 -52
  89. package/src/pi/launch.ts +4 -3
  90. package/src/ui/index.html +1 -1
  91. package/templates/AGENTS.block.md +2 -1
package/src/hub/daemon.ts CHANGED
@@ -14,17 +14,24 @@ import { needs, type NeedsInput } from "../ui/needs.ts";
14
14
  import { loadPetConfig } from "../pet/config.ts";
15
15
  import { projectionConfig, TextProjections, type ProjectionConfig } from "./text-projection.ts";
16
16
  import { policyPaths, matchesPolicy } from "./path-policy.ts";
17
+ import { buildReviewBrief } from "./review-brief.ts";
17
18
  import { profileName, projectProfile, resolveProfile, mergeProfileOverrides, type ProfileName } from "./profiles.ts";
18
19
  import { catalogAssignments, loadRoleCatalog, selectedRoles, roleIdentity } from "./roles.ts";
19
20
  import { ActivityRing } from "./activity.ts";
20
21
  import { JcodePeer } from "../adapters/jcode.ts";
21
22
  import { jcodeMcpReady } from "../cli/jcode-mcp.ts";
22
- import { loadSdlc, sdlcGh } from "./sdlc.ts";
23
+ import { loadSdlc, readSpecEdit, sdlcGh } from "./sdlc.ts";
24
+ import { claudeStateFile, ownTerminalRow, pathEvidence, transcriptPersisted } from "./claude-state.ts";
23
25
  import { peerRosterConfig, rosterAllows, type PeerRoster } from "./roster.ts";
24
26
  import { KNOWN_PEERS, OPTIONAL_PEERS, PLANNER_PEERS, SUPPORTED_PEERS, isSupportedPeer, STARTABLE_PEERS, isStartablePeer } from "../adapters/registry.ts";
25
27
  import { RecoveryDeferredHubError } from "./deferred-hub.ts";
26
28
  import { databaseBusySite, isDatabaseBusy, RecoveryDatabaseBusy } from "./recovery-error.ts";
27
29
  import { ADAPTERS, QUOTA_HANDOFF_PLATFORMS, NATIVE_CONFIG_BLOCKS, nativeConfigurationDefaults, mergeNativeConfiguration, retainNativeRouting, DAEMON_PORTS, definitionOf } from "../adapters/registry.ts";
30
+ import { 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";
31
+ import { effectiveInstanceRoles, instancePeerIds, validateInstances, type InstancesConfig } from "./instances.ts";
32
+ import { INSTANCE_CONFIG_FILES, readInstanceConfig, readInstanceConfigLayers, readInstanceRoleConfig } from "./instance-config.ts";
33
+ import { instanceBudgetHooks, projectBudgetHeadroom } from "./instance-budget.ts";
34
+ import { resolveInstanceExecutionCwd } from "./instance-execution.ts";
28
35
  import { ProgressObserver, normalizeCodexObservation, normalizeClaudeObservation } from "./progress.ts";
29
36
  import type { ToolObservation } from "../models/route/signals.ts";
30
37
  import { createHash, randomBytes, randomUUID } from "node:crypto";
@@ -41,7 +48,7 @@ import { resolveSettledRoles } from "./settled-roles.ts";
41
48
  import { CONFIG_SOURCE_FILES, configFingerprint, configSourceTexts, parseConfigSources, type ConfigSources } from "./config-reload.ts";
42
49
  import { eventLog, readEvents, tokenDeltas, type RoleIdentity } from "./events.ts";
43
50
  import { ExecutionBudget } from "./execution-budget.ts";
44
- import { claudeReportedTokens, readClaudeTranscriptUsage, waitForClaudeCompletion } from "./usage.ts";
51
+ import { claudeReportedTokens, readClaudeTranscriptCompletion, readClaudeTranscriptUsage, waitForClaudeCompletion } from "./usage.ts";
45
52
  import type { ServerWebSocket } from "bun";
46
53
  import { AcpPeer, QwenIdentityError, type PermissionRequest } from "../adapters/acp.ts";
47
54
  import { CodexPeer } from "../adapters/codex-appserver.ts";
@@ -56,6 +63,7 @@ import { profile, proxyEnv, type SandboxNetwork } from "../local/sandbox.ts";
56
63
  import { DEFAULT_NETWORK_ALLOW, startEgressProxy, type EgressProxy } from "../local/proxy.ts";
57
64
  import { runTool, toolResultFailed, TOOL_SCHEMAS, type ToolApproval, type ApprovalObserver, type ToolContext } from "../local/tools.ts";
58
65
  import { LocalPeer } from "../adapters/local-worker.ts";
66
+ import { reviewDiff, ClaudePrintPeer } from "../adapters/claude-print.ts";
59
67
  import { Capture, skipTools } from "../memory/capture.ts";
60
68
  import { DEFAULT_OMNIROUTE, OmniRoute, type OmniRouteConfig } from "../omniroute/client.ts";
61
69
  import { Sidecar } from "../switchyard/sidecar.ts";
@@ -64,12 +72,12 @@ import { Board, isClosedTaskState, taskProposalAt, taskHasStarted, CLASSES, type
64
72
  import { Budget, claudeWindows, codexWindows, DEFAULT_BUDGET, type BudgetConfig } from "./budget.ts";
65
73
  import { HUB } from "./envelope.ts";
66
74
  import { recallMemoryLines, trimToTokens } from "../memory/recall.ts";
67
- import { closeSync, constants as fsConstants, openSync, readSync, statSync } from "node:fs";
75
+ import { closeSync, constants as fsConstants, openSync, lstatSync, readSync, statSync } from "node:fs";
68
76
  import { basename, isAbsolute } from "node:path";
69
77
  import { fileURLToPath } from "node:url";
70
78
  import { realPath, hubHome } from "./project.ts";
71
79
  import type { BusEvent } from "./bus.ts";
72
- import { CONDUCTOR_TOOLS, CONDUCTOR_TOOL_NAMES, DEFAULT_ROLES, roleContract, TASK_TOOLS, BOUNDED_TASK_TOOLS, profileTaskTools } from "./hub-tools.ts";
80
+ import { CONDUCTOR_TOOLS, CONDUCTOR_TOOL_NAMES, DEFAULT_ROLES, roleContract, TASK_TOOL_NAMES, TASK_TOOLS, BOUNDED_TASK_TOOLS, profileTaskTools } from "./hub-tools.ts";
73
81
  import { Conductor, ConductorHolds, conductorPeer, conductorProgressSink, publicConductorTask, publicPeerBudget, type ConductEvent } from "./conductor.ts";
74
82
  import { SupervisionFeed } from "./supervision.ts";
75
83
  import { drainCliAudits } from "../cli/identity-audit.ts";
@@ -78,7 +86,7 @@ import { AssignmentUndeliveredError, ESCALATE_AFTER, Tasks } from "./tasks.ts";
78
86
  import { DEFAULT_INFERENCE, DIGEST, Inference, screenPii, type InferenceConfig } from "./inference.ts";
79
87
  import { ask, ASK_NOTE_TITLE, RUN_START } from "./ask.ts";
80
88
  import { currentRouting, detectSignals, OVERLAY_FILE, overlayToml, parseRouting, routingText, type Routing } from "./routing.ts";
81
- import { SETTINGS, checkSettingValue, ignoredLine, pendingUndo, pendingUndoIdentity, readOverlay, restoredValue, routingCandidate, storedLocally, settingDef, settingRefusal, settingRows, settingText, undoSetting, valueAt, writeConfigSetting, writeRoutingSetting, type SettingAuthority, type SettingDef, type SettingRow, type SettingValue, type UndoIdentity } from "./settings.ts";
89
+ import { projectSettings, checkSettingValue, ignoredLine, pendingUndo, pendingUndoIdentity, readOverlay, restoredValue, routingCandidate, storedLocally, settingDef as instanceSettingDef, settingRefusal, settingRows, settingText, undoSetting, valueAt, writeConfigSetting, writeRoutingSetting, type SettingAuthority, type SettingDef, type SettingRow, type SettingValue, type UndoIdentity } from "./settings.ts";
82
90
  import { Bus } from "./bus.ts";
83
91
  import { attribute, deliveryTask, type TaskAttribution } from "./attribution.ts";
84
92
  import { DeliveryJournal } from "./delivery-journal.ts";
@@ -118,6 +126,8 @@ import { nativeRecall } from "../memory/recall.ts";
118
126
  import { promotionScreen, withoutPii, mentionsPrivatePath } from "./memory-policy.ts";
119
127
 
120
128
  export interface HubConfig {
129
+ /** Person-owned declarations, read only from an untracked machine-local file. Bound for this daemon lifetime. */
130
+ instances?: InstancesConfig;
121
131
  watchdog_ms: number;
122
132
  kimi_cmd: string[];
123
133
  qwen_cmd: string[];
@@ -233,11 +243,11 @@ export const DEFAULT_CONFIG: HubConfig = {
233
243
  export { stateDirFor };
234
244
 
235
245
  /** Ids an external process may not claim: the console user and the adapters the daemon runs itself. */
236
- const RESERVED_IDS = new Set<string>([USER, ...SUPPORTED_PEERS.filter(peer => definitionOf(peer)?.identity.mcp !== "channel-plugin"), HUB, DIGEST]);
246
+ const BASE_RESERVED_IDS = new Set<string>([USER, ...SUPPORTED_PEERS.filter(peer => definitionOf(peer)?.identity.mcp !== "channel-plugin"), HUB, DIGEST]);
237
247
  const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
238
248
 
239
249
  /** The shared project config, then the machine's own file, which overrides it block by block (issue #17). */
240
- const CONFIG_FILES = ["config.json", "config.local.json"] as const;
250
+ const CONFIG_FILES = INSTANCE_CONFIG_FILES;
241
251
  export interface ConfigLayer { name: typeof CONFIG_FILES[number]; raw: Record<string, any> }
242
252
  const CONFIG_BLOCKS = ["memory", "roster", "roles", "catalog_roles", "pii", "data", "conductor", "budget", "context", "inference", "omniroute", ...NATIVE_CONFIG_BLOCKS, "approvals", "tasks", "task_sweep", "checks", "snapshots", "research", "bench", "limits", "review", "recovery", "capabilities", "mlx", "permission_modes", "peers", "terminal"];
243
253
 
@@ -285,18 +295,22 @@ function syncMinutes(v: unknown): number {
285
295
  }
286
296
 
287
297
  export function loadConfig(cwd: string, layers?: readonly ConfigLayer[]): HubConfig {
288
- const ignored: string[] = [];
298
+ const captured = layers ?? readInstanceConfigLayers(cwd);
299
+ const instanceConfig = readInstanceConfig(cwd, captured);
300
+ const instances = instanceConfig.instances;
301
+ const ignored: string[] = [...instanceConfig.ignored];
289
302
  const appliedPermissionSources: NonNullable<HubConfig["permission_default_sources"]> = {};
290
303
  const files = CONFIG_FILES.flatMap((name) => {
291
304
  let file: Record<string, any>; // parsed JSON, checked field by field below
292
- if (layers && !layers.some(layer => layer.name === name)) return [];
305
+ if (!captured.some(layer => layer.name === name)) return [];
293
306
  try {
294
- file = layers ? structuredClone(layers.find(layer => layer.name === name)!.raw) : JSON.parse(readFileSync(join(cwd, ".agenthub", name), "utf8"));
307
+ file = structuredClone(captured.find(layer => layer.name === name)!.raw);
295
308
  } catch (error) {
296
309
  if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
297
310
  throw error;
298
311
  }
299
- peerRosterConfig(file.roster);
312
+ delete file.instances; // Declaration authority was checked against its exact captured origin above.
313
+ peerRosterConfig(file.roster, instances);
300
314
  catalogAssignments(file.catalog_roles);
301
315
  profileName(file.profile);
302
316
  resolveProfile(file.profile, file.profile_overrides);
@@ -311,14 +325,14 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[]): HubCon
311
325
  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`);
312
326
  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");
313
327
  instructionProtection(file.protect_instruction_files);
314
- permissionDefaults(file.permission_modes); // malformed declarations fail even when trust would drop them
315
- peerStartConfig(file.peers);
328
+ permissionDefaults(file.permission_modes, instances); // malformed declarations fail even when trust would drop them
329
+ peerStartConfig(file.peers, instances);
316
330
  if (file.terminal != null && (typeof file.terminal !== "object" || Array.isArray(file.terminal))) throw new Error("terminal must be an object");
317
331
  terminalTemplate(file.terminal?.open);
318
332
  delete file.permission_default_sources; // provenance is loader-owned, never a file claim
319
333
  const why = stripUntrusted(file, DEFAULT_CONFIG, cwd, name);
320
334
  if (why) ignored.push(why);
321
- for (const [peer, mode] of Object.entries(permissionDefaults(file.permission_modes))) appliedPermissionSources[peer] = { mode, source: `.agenthub/${name}` };
335
+ for (const [peer, mode] of Object.entries(permissionDefaults(file.permission_modes, instances))) appliedPermissionSources[peer] = { mode, source: `.agenthub/${name}` };
322
336
  return [file];
323
337
  });
324
338
  if (!files.length) return DEFAULT_CONFIG;
@@ -346,23 +360,34 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[]): HubCon
346
360
  throw new Error("legacy MLX configuration requires explicit mlx.provider=legacy; migrate to provider=ollama to avoid Python serving");
347
361
  }
348
362
  const mlx = { ...(file.mlx?.provider === "legacy" ? { enabled: true, provider: "legacy" as const, maxInputTokens: 16_000, maxTokens: 2048 } : DEFAULT_CONFIG.mlx), ...file.mlx };
349
- const conductorPolicy = loadConductorPolicy(cwd, layers);
363
+ const conductorPolicy = loadConductorPolicy(cwd, captured);
364
+ const extraRoles = Object.fromEntries(Object.keys(instances ?? {}).map(id => [id, effectiveInstanceRoles(id, instances, conductorPolicy.roles)]));
365
+ const instancePermissions: Record<string, PermissionMode> = {};
366
+ const instanceModes: PeerStartConfig = {};
367
+ for (const [id, declaration] of Object.entries(instances ?? {})) {
368
+ instanceModes[id] = { start_mode: declaration.start_mode ?? "headless" };
369
+ if (declaration.permission_mode !== undefined) {
370
+ instancePermissions[id] = declaration.permission_mode;
371
+ appliedPermissionSources[id] = { mode: declaration.permission_mode, source: ".agenthub/config.local.json" };
372
+ }
373
+ }
350
374
  if (!mlx.enabled && file.pi?.backend === "mlx") throw new Error("Pi backend mlx conflicts with mlx.enabled=false");
351
375
  if (typeof mlx.runtimeDir === "string" && mlx.runtimeDir) mlx.runtimeDir = resolve(cwd, mlx.runtimeDir);
352
376
  if (typeof mlx.modelPath === "string" && mlx.modelPath) mlx.modelPath = resolve(cwd, mlx.modelPath);
353
377
  return {
354
378
  ...DEFAULT_CONFIG,
355
379
  ...file,
380
+ ...(instances ? { instances } : {}),
356
381
  ...(file.text_projection !== undefined ? { text_projection: projectionConfig(file.text_projection) } : {}),
357
382
  ...(file.pii !== undefined ? { pii: { paths: policyPaths(file.pii.paths, "pii.paths") } } : {}),
358
383
  ...(file.data !== undefined ? { data: { readonly: policyPaths(file.data.readonly, "data.readonly") } } : {}),
359
- permission_modes: permissionDefaults(file.permission_modes),
384
+ permission_modes: { ...instancePermissions, ...permissionDefaults(file.permission_modes, instances) },
360
385
  protect_instruction_files: instructionProtection(file.protect_instruction_files),
361
386
  permission_default_sources: appliedPermissionSources,
362
387
  memory: { ...DEFAULT_CONFIG.memory, native: true, ...file.memory, ...(file.memory?.short_ttl_days !== undefined ? { short_ttl_days: ttlDays(file.memory.short_ttl_days) } : {}),
363
388
  ...(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)
364
- roster: peerRosterConfig(file.roster),
365
- roles: conductorPolicy.roles,
389
+ roster: peerRosterConfig(file.roster, instances),
390
+ roles: { ...conductorPolicy.roles, ...extraRoles },
366
391
  planner: conductorPolicy.planner,
367
392
  conductor: conductorPolicy.conductor,
368
393
  budget: { ...DEFAULT_CONFIG.budget, wait_max_min: 30, ...file.budget }, // on with any project config (issue #36)
@@ -380,7 +405,7 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[]): HubCon
380
405
  snapshots: { ...DEFAULT_CONFIG.snapshots, enabled: true, ...file.snapshots },
381
406
  research: { enabled: file.research?.enabled === true }, // opt-in: anything but true is off
382
407
  bench: { enabled: file.bench?.enabled === true }, // opt-in: a run resets this project's work tree
383
- peers: peerStartConfig(file.peers),
408
+ peers: { ...instanceModes, ...peerStartConfig(file.peers, instances) },
384
409
  terminal: { open: terminalTemplate(file.terminal?.open) },
385
410
  limits: { ...PROJECT_LIMITS, ...file.limits }, // on with any project config (issue #38)
386
411
  review: { ...DEFAULT_CONFIG.review, ...file.review },
@@ -473,6 +498,7 @@ class WsPeer extends BasePeer {
473
498
  private nativeActive = false;
474
499
  /** The attached session said it cannot show channel pushes (issue #205): the bus keeps its queue for `inbox`. */
475
500
  pullOnly = false;
501
+ steer: PeerAdapter["steer"] = undefined;
476
502
  lastInboxAt: number | undefined;
477
503
  private claimedPullOnly = false;
478
504
  /** Called at hello, before the async preface: the newest hello wins even if an older one's recall finishes last. */
@@ -500,6 +526,12 @@ class WsPeer extends BasePeer {
500
526
  this.nativeSession = undefined;
501
527
  this.nativeActive = false;
502
528
  this.pullOnly = this.claimedPullOnly; // before idle: the idle transition drains
529
+ // Native TUI readback in #493 proves important channel input can be consumed before the turn ends.
530
+ // Pull-only transports expose no steer capability; the bus keeps their important work queued.
531
+ this.steer = this.pullOnly ? undefined : async (envs, deliveryId) => {
532
+ 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");
533
+ await this.deliver(envs, deliveryId);
534
+ };
503
535
  this.lastInboxAt = undefined;
504
536
  this.setState("idle");
505
537
  }
@@ -507,6 +539,7 @@ class WsPeer extends BasePeer {
507
539
  if (this.claimed === sock) this.claimed = undefined;
508
540
  if (this.sock !== sock) return;
509
541
  this.sock = undefined;
542
+ this.steer = undefined;
510
543
  this.nativeActive = false;
511
544
  this.pullOnly = false;
512
545
  this.setState("offline");
@@ -516,6 +549,7 @@ class WsPeer extends BasePeer {
516
549
  this.sock?.close(4000, "peer is off roster; retry when a person adds it");
517
550
  this.claimed = undefined;
518
551
  this.sock = undefined;
552
+ this.steer = undefined;
519
553
  this.nativeActive = false;
520
554
  this.pullOnly = false;
521
555
  this.setState("offline");
@@ -567,16 +601,61 @@ export async function startDaemon(opts: DaemonOptions) {
567
601
  let ready = false;
568
602
  try {
569
603
  const loadedConfig = opts.config ?? loadConfig(opts.cwd);
570
- const lifecycleConfig = loadSdlc(opts.cwd);
604
+ const lifecycleConfig = loadSdlc(opts.cwd, VERSION);
571
605
  // Preserve embedding-owned policy references, but never publish reloads into shared defaults.
572
606
  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;
573
- const config = { ...ownedConfig, roster: peerRosterConfig(loadedConfig.roster), permission_modes: permissionDefaults(loadedConfig.permission_modes), roles: { ...loadedConfig.roles }, conductor: { ...DEFAULT_CONFIG.conductor, ...loadedConfig.conductor } };
607
+ const declarations = validateInstances(loadedConfig.instances);
608
+ const extraIds = Object.keys(declarations);
609
+ // #450 I3: channel extras (Claude) attach from the person's terminal; the hub never starts them and their hello is admitted.
610
+ const channelExtras = extraIds.filter(id => instanceDefinitionOf(id, declarations)?.identity.mcp === "channel-plugin");
611
+ // Every Claude-family peer whose own state files the daemon reads: the builtin channel kinds, then their declared instances.
612
+ const channelPeers = [...ADAPTERS.filter(definition => definition.identity.mcp === "channel-plugin").map(definition => definition.kind), ...channelExtras];
613
+ const executionRoots = new Map<string, string>();
614
+ const executionOwners = new Map<string, { owner: PeerAdapter; cwd: string }>();
615
+ const roles = { ...loadedConfig.roles };
616
+ const permissionModesFromInstances: Record<string, PermissionMode> = {};
617
+ const startModesFromInstances: PeerStartConfig = {};
618
+ const instancePermissionSources: NonNullable<HubConfig["permission_default_sources"]> = {};
619
+ for (const [id, declaration] of Object.entries(declarations)) {
620
+ roles[id] = effectiveInstanceRoles(id, declarations, loadedConfig.roles);
621
+ if (!channelExtras.includes(id)) startModesFromInstances[id] = { start_mode: "headless" };
622
+ if (declaration.permission_mode !== undefined) {
623
+ permissionModesFromInstances[id] = declaration.permission_mode;
624
+ instancePermissionSources[id] = { mode: declaration.permission_mode, source: ".agenthub/config.local.json" };
625
+ }
626
+ }
627
+ const config: HubConfig = { ...ownedConfig, ...(extraIds.length ? { instances: declarations } : {}),
628
+ roster: peerRosterConfig(loadedConfig.roster, declarations),
629
+ permission_modes: { ...permissionModesFromInstances, ...permissionDefaults(loadedConfig.permission_modes, declarations) },
630
+ peers: { ...startModesFromInstances, ...peerStartConfig(loadedConfig.peers, declarations) },
631
+ ...(extraIds.length ? { permission_default_sources: { ...instancePermissionSources, ...loadedConfig.permission_default_sources } } : {}),
632
+ roles, conductor: { ...DEFAULT_CONFIG.conductor, ...loadedConfig.conductor } };
633
+ // The resolver and catalogs are bound to this daemon, never published into the process-global builtin registry.
634
+ const definitionOf = (peer: string) => instanceDefinitionOf(peer, declarations);
635
+ const KNOWN_PEERS: readonly string[] = [...BASE_KNOWN_PEERS, ...extraIds];
636
+ const SUPPORTED_PEERS: readonly string[] = [...BASE_SUPPORTED_PEERS, ...extraIds];
637
+ const STARTABLE_PEERS: readonly string[] = [...BASE_STARTABLE_PEERS, ...extraIds.filter(id => !channelExtras.includes(id))];
638
+ const isSupportedPeer = (peer: unknown): peer is string => typeof peer === "string" && definitionOf(peer) !== undefined;
639
+ const isStartablePeer = (peer: unknown): peer is string => typeof peer === "string" && !channelExtras.includes(peer) && definitionOf(peer)?.start.conductorStartable === true;
640
+ const RESERVED_IDS = new Set([...BASE_RESERVED_IDS, ...extraIds.filter(id => !channelExtras.includes(id))]);
641
+ // #223 R2: a declared instance whose effective roles are exactly reviewer gets review requests and its own task notices only.
642
+ const reviewOnlyPeer = (peer: string): boolean => Object.hasOwn(declarations, peer) && config.roles[peer]?.length === 1 && config.roles[peer]![0] === "reviewer";
643
+ /** #520: a task under review shows its review brief to whoever may read it (owner, reviewer, conductor). */
644
+ const briefField = (task: Task): { brief?: string } => { if (task.state !== "in_review") return {}; const text = tasks.briefText(task); return text ? { brief: text } : {}; };
645
+ /** #223 R2: a review-only instance reads the spec, refs and earlier verdicts of a task, never the builder's narrative. */
646
+ const reviewerView = (peer: string, view: Record<string, unknown>): Record<string, unknown> => !reviewOnlyPeer(peer) || !Array.isArray(view.history) ? view
647
+ : { ...view, history: (view.history as { event?: unknown; note?: unknown }[]).map(({ note, ...h }) => h.event === "approved" || h.event === "changes_requested" ? { ...h, note } : h) };
648
+ const reviewOnlyItem = (peer: string, env: Envelope): boolean => env.from === USER || env.from === HUB && (env.kind === "review" || (env.kind === "task" && env.to?.includes(peer) === true));
649
+ const SETTINGS = projectSettings(declarations);
650
+ const settingDef = (key: unknown) => instanceSettingDef(key, declarations);
651
+ const accountOf = (peer: string) => definitionOf(peer)?.budget?.account ?? peer;
652
+ const accountMembers = (account: string) => SUPPORTED_PEERS.filter(peer => accountOf(peer) === account);
574
653
  let rosterChanging: string | undefined;
575
654
  let kimiSessionMode: StartMode | undefined;
576
655
  let kimiAdmissionId: string | undefined;
577
656
  let kimiAdmissionExpiry: ReturnType<typeof setTimeout> | undefined;
578
657
  const clearKimiAdmission = () => { clearTimeout(kimiAdmissionExpiry); kimiAdmissionExpiry = undefined; kimiSessionMode = undefined; kimiAdmissionId = undefined; };
579
- const onRoster = (peer: string) => rosterAllows(config.roster, peer, config.pi.enabled);
658
+ const onRoster = (peer: string) => rosterAllows(config.roster, peer, config.pi.enabled, declarations);
580
659
  // pi.enabled historically gates starts, not retained queues or tools sockets. Only an explicit roster.pi changes those.
581
660
  const membershipAllowed = (peer: string) => definitionOf(peer)?.enabledSwitchStartOnly && config.roster[peer] === undefined ? true : onRoster(peer);
582
661
  conductorPeer(config.roles); // reject ambiguous leadership before opening a control listener
@@ -621,6 +700,7 @@ export async function startDaemon(opts: DaemonOptions) {
621
700
  settlePlanner();
622
701
  const catalog = loadRoleCatalog(opts.cwd);
623
702
  for (const error of catalog.errors) log(`catalog role ${error.id}: ${error.reason}`);
703
+ for (const note of lifecycleConfig.deprecations ?? []) log(note);
624
704
  const resolvedProfile = resolveProfile(config.profile, config.profile_overrides);
625
705
  let roleAssignments = { ...resolvedProfile.catalog_roles, ...catalogAssignments(config.catalog_roles) };
626
706
  const event = eventLog(join(opts.stateDir, "events.jsonl"), (entry) => {
@@ -807,6 +887,7 @@ export async function startDaemon(opts: DaemonOptions) {
807
887
  * has not stopped since its cohort task closed. Workflow events and anything to the console are never held back.
808
888
  */
809
889
  const silence = (env: Envelope, to: PeerId): string | undefined => {
890
+ if (reviewOnlyPeer(to) && env.to?.includes(to) && !reviewOnlyItem(to, env)) return `${to} is a review-only instance: it receives review requests only (send a review through the task board)`;
810
891
  if (!turnFree() || env.from === USER || env.from === HUB || env.from === DIGEST || env.priority === "fyi" || env.kind !== "chat" || to === USER) return undefined;
811
892
  const cohort = tasks.silenced(env.from, to);
812
893
  if (!cohort) return undefined;
@@ -849,7 +930,7 @@ export async function startDaemon(opts: DaemonOptions) {
849
930
  const staleOff = () => config.experiments?.stale_notices === "deliver";
850
931
  if (staleOff()) log("experiment: stale notices are delivered as before 0.12.4 (the issue #106 ablation)");
851
932
  const requestedPeerStops = new Set<string>();
852
- 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) => env.refs?.boundedCheck || env.refs?.supervision || env.refs?.backlog ? relevantNotice(peer, env) : staleOff() || relevantNotice(peer, env), silence,
933
+ 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,
853
934
  onImportantQueued: (peer, counts) => { const p = bus.peers.get(peer); if (p instanceof WsPeer) p.notifyQueued(counts); } });
854
935
  startupCleanup.push(() => bus.closeJournal());
855
936
  const manualPaused = new Set<PeerId>(bus.manualPausedPeers()); // recovery never lifts an operator's pause
@@ -1015,7 +1096,7 @@ export async function startDaemon(opts: DaemonOptions) {
1015
1096
 
1016
1097
  /** Session-start cross-platform recall. Awaited before the peer can receive anything, so it rides on the first delivery. Fail-open. */
1017
1098
  async function ensurePreface(peer: PeerId): Promise<void> {
1018
- if ((!config.memory.enabled && !memoryStore) || recalled.has(peer)) return;
1099
+ if ((!config.memory.enabled && !memoryStore) || recalled.has(peer) || reviewOnlyPeer(peer)) return; // #223 R2: no shared memory in a fresh review
1019
1100
  // The hub's own long-term decisions, contracts and fails lead; claude-mem's recent work follows, each with half the budget.
1020
1101
  let own: string | undefined;
1021
1102
  try {
@@ -1036,7 +1117,29 @@ export async function startDaemon(opts: DaemonOptions) {
1036
1117
 
1037
1118
  const omni = new OmniRoute(config.omniroute, log);
1038
1119
  let modelRelay: ModelRelay | undefined;
1039
- let piReceipts: PiToolReceipts | undefined;
1120
+ const piReceiptLedgers = new Map<string, PiToolReceipts>();
1121
+ const modelRelays = new Map<string, ModelRelay>();
1122
+ const globalModelRelay = (relay: ModelRelay) => { modelRelay = relay; };
1123
+ const nativeStateDir = (peer: string) => declarations[peer] ? join(opts.stateDir, "instances", peer) : opts.stateDir;
1124
+ const instanceAppPorts = new Map<string, number>();
1125
+ const instanceAppPort = (peer: string): number => {
1126
+ const prior = instanceAppPorts.get(peer);
1127
+ if (prior !== undefined) return prior;
1128
+ // The native server must bind its selected port itself. A competing bind is refused by its existing ownership probe.
1129
+ const held: { stop(closeActiveConnections?: boolean): void }[] = [];
1130
+ try {
1131
+ for (let attempt = 0; attempt <= instanceAppPorts.size + 2; attempt++) {
1132
+ const listener = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
1133
+ held.push(listener);
1134
+ const port = listener.port;
1135
+ if (port !== undefined && port !== opts.codexAppPort && port !== opts.codexProxyPort && ![...instanceAppPorts.values()].includes(port)) {
1136
+ instanceAppPorts.set(peer, port); return port;
1137
+ }
1138
+ }
1139
+ throw new Error("no distinct instance app-server port available");
1140
+ } finally { for (const listener of held) listener.stop(true); }
1141
+ };
1142
+ const piCallsInFlight = () => [...piReceiptLedgers.values()].reduce((n, ledger) => n + ledger.inFlight, 0);
1040
1143
  /**
1041
1144
  * PII decisions need a positive answer about the path a hub-owned model call will really take: a gateway that
1042
1145
  * answered and is not behind Access, and no sidecar that was generated against the off-campus URL. Unknown is no.
@@ -1086,7 +1189,7 @@ export async function startDaemon(opts: DaemonOptions) {
1086
1189
  if (before && (before !== after || config.conductor.feed === "off")) { bus.revokeSupervision(before); supervision.resetPending(before); budgetFeedSeen.clear(); }
1087
1190
  if (config.conductor.feed === "off" && after) bus.revokeSupervision(after);
1088
1191
  };
1089
- const executionBudget = new ExecutionBudget(join(opts.stateDir, "hub.db"));
1192
+ const executionBudget = new ExecutionBudget(join(opts.stateDir, "hub.db"), declarations);
1090
1193
  startupCleanup.push(() => executionBudget.close());
1091
1194
  // Completion checks (issue #7): only from a config file git does not track, one at a time, killed on stop.
1092
1195
  const allowedChecks = new Set([...CLASSES, ...Object.keys(resolvedProfile.classes)]);
@@ -1191,6 +1294,28 @@ export async function startDaemon(opts: DaemonOptions) {
1191
1294
  };
1192
1295
  const tasks: Tasks = new Tasks({
1193
1296
  capabilities: peerCapabilities, repository: memoryRepo,
1297
+ plannerBlocks: () => Object.fromEntries(bus.knownPeers().map(peer => {
1298
+ const codes: ("approvals" | "quota")[] = [];
1299
+ if ([...permissions.values()].some(request => request.peer === peer && request.expiresAt > Date.now())) codes.push("approvals");
1300
+ if (budget.record(peer)) codes.push("quota");
1301
+ return [peer, codes];
1302
+ })),
1303
+ instances: () => declarations,
1304
+ reviewOnly: reviewOnlyPeer,
1305
+ // #223: a headless reviewer's verdict holds only while the same child it started with still runs for that task.
1306
+ reviewAuthority: (by, task) => {
1307
+ if (definitionOf(by)?.start.daemonPort !== "claude-print") return undefined;
1308
+ const peer = bus.peers.get(by) as ClaudePrintPeer | undefined, token = peer?.reviewToken(task);
1309
+ return () => token !== undefined && bus.peers.get(by) === peer && peer?.reviewToken(task) === token ? undefined : `no review of task #${task} is running for ${by}`;
1310
+ },
1311
+ // #223 R4: fresh only when the verdict comes from a reviewer session the hub started for this task's review.
1312
+ reviewContext: (by, task) => definitionOf(by)?.start.daemonPort === "claude-print" && (bus.peers.get(by) as ClaudePrintPeer | undefined)?.reviewing === task ? "fresh" : "continuing",
1313
+ // Proof belongs to the adapter generation actually started in the validated root. A role-only
1314
+ // reload cannot turn a canonical reviewer process into an isolated implementation owner.
1315
+ ownerIsolation: () => Object.fromEntries(extraIds.map(id => {
1316
+ const bound = executionOwners.get(id);
1317
+ return [id, !!bound && bound.owner === bus.peers.get(id) && bound.cwd !== opts.cwd && bound.cwd === executionRoots.get(id)];
1318
+ })),
1194
1319
  recordPlanning: change => event({ type: "planning", ...change }),
1195
1320
  wipLimit: peer => Object.hasOwn(config.tasks.wip_limits ?? {}, peer) ? config.tasks.wip_limits![peer]! : 1,
1196
1321
  escalateAfter: () => config.tasks.escalate_after ?? ESCALATE_AFTER,
@@ -1200,6 +1325,10 @@ export async function startDaemon(opts: DaemonOptions) {
1200
1325
  catalog,
1201
1326
  sessionRoles: peer => bus.peers.has(peer) && bus.stateOf(peer) !== "offline" ? preparedSessionRoles.get(peer) ?? [] : [],
1202
1327
  projectedReview: task => reviewProjection(task),
1328
+ reviewBrief: task => {
1329
+ const root = executionRoot(task.owner ?? "");
1330
+ return buildReviewBrief(root, task.refs.commit, { privatePath: path => isDenied(path, config.local.deny) || matchesPolicy(root, path, config.pii?.paths ?? [], true), nameable: tasks.nameable });
1331
+ },
1203
1332
  sdlc: { config: () => lifecycleConfig, gh: (args, timeout, _cwd, bound) => sdlcGh(args, timeout, opts.cwd, bound) },
1204
1333
  board,
1205
1334
  bus,
@@ -1247,7 +1376,7 @@ export async function startDaemon(opts: DaemonOptions) {
1247
1376
  // The PII screen (issue #198): the model on this machine when MLX/Ollama is enabled, else the gateway on campus only.
1248
1377
  piiScreen: (text) => screenPii(text, { ...(config.mlx.enabled === false ? {} : { device: () => ensureMlx(config.mlx) }), onCampus, omni, fixedModel: () => activeRouting().local.fixed_model }),
1249
1378
  recordScreen: (record) => event({ type: "pii_screen", ...record }),
1250
- quota: (): ReturnType<Budget["headroom"]> => budget.headroom(), // budget is built below; this runs at assignment time
1379
+ quota: (): ReturnType<Budget["headroom"]> => projectBudgetHeadroom(budget.headroom(), accountMembers), // budget is built below; this runs at assignment time
1251
1380
  review: config.review,
1252
1381
  sweep: taskSweepConfig(config.task_sweep),
1253
1382
  sweepHeld: () => stopping || recoveryActive(),
@@ -1264,12 +1393,12 @@ export async function startDaemon(opts: DaemonOptions) {
1264
1393
  integrationFacts: (peer) => {
1265
1394
  if (!factsOn()) return undefined;
1266
1395
  let offered: ReturnType<Facts["due"]>;
1267
- try { offered = facts.due(peer, undefined, true); } catch (error) { log(`integration facts for ${peer}: ${(error as Error).message}`); return undefined; }
1396
+ try { offered = factsFor(peer).due(peer, undefined, true); } catch (error) { log(`integration facts for ${peer}: ${(error as Error).message}`); return undefined; }
1268
1397
  if (offered) event({ type: "fact", peer, id: offered.id, files: offered.files, plans: offered.plans, unknown: offered.unknown, named: offered.named, bytes: offered.bytes, via: "done" });
1269
1398
  return offered ? { id: offered.id, text: offered.text } : undefined;
1270
1399
  },
1271
1400
  ackFacts: (peer, id) => acked(peer, id, "done"),
1272
- factsCurrent: (peer) => !factsOn() || facts.current(peer),
1401
+ factsCurrent: (peer) => !factsOn() || factsFor(peer).current(peer),
1273
1402
  splitProfile: (peer) => splitProfile(peer),
1274
1403
  recordCohort: (c) => event({ type: "cohort", ...c }),
1275
1404
  // The trace holds peer names and numbers only (never task text): it is the inputs a later check of the prediction needs.
@@ -1314,6 +1443,34 @@ export async function startDaemon(opts: DaemonOptions) {
1314
1443
  const repositoryFacts = !!factsRepo || profileName(config.profile) === "dev";
1315
1444
  if (!factsRepo) log(repositoryFacts ? "project has no Git or shadow repository; snapshots/undo and Git directory facts are unavailable; dev named-file facts remain (opt in with ahub init --shadow)" : "project has no Git or shadow repository; facts, snapshots and undo are off (opt in with ahub init --shadow)");
1316
1445
  const facts: Facts = new Facts({ root: opts.cwd, repo: factsRepo, tmp: join(opts.stateDir, "facts"), instance: instanceId.slice(0, 8), scope: (peer) => repositoryFacts ? (scopes.has(peer) ? scopes.get(peer) : scopes.set(peer, tasks.factScope(peer)).get(peer)) : undefined, peers: () => [...bus.peers.keys()], nameable: tasks.nameable, deny: config.local.deny, privatePath: path => matchesPolicy(opts.cwd, path, config.pii?.paths ?? []), projection: file => projections.project(file) });
1446
+ const worktreeFacts = new Map<string, Facts>();
1447
+ const worktreeProjections = new Map<string, TextProjections>();
1448
+ const executionRoot = (peer: string) => executionRoots.get(peer) ?? opts.cwd;
1449
+ const repoFor = (peer: string) => executionRoot(peer) === opts.cwd ? factsRepo : repoOf(executionRoot(peer));
1450
+ const projectionsFor = (peer: string): TextProjections => {
1451
+ const root = executionRoot(peer);
1452
+ if (root === opts.cwd) return projections;
1453
+ let projection = worktreeProjections.get(peer);
1454
+ if (!projection) {
1455
+ projection = new TextProjections({ cwd: root, projectRoot: opts.cwd, stateDir: nativeStateDir(peer), config: config.text_projection ?? {}, deny: config.local.deny, readAllow: config.local.read_allow, log });
1456
+ worktreeProjections.set(peer, projection);
1457
+ }
1458
+ return projection;
1459
+ };
1460
+ const factsFor = (peer: string): Facts => {
1461
+ const root = executionRoot(peer);
1462
+ if (root === opts.cwd) return facts;
1463
+ let store = worktreeFacts.get(peer);
1464
+ if (!store) {
1465
+ const projection = projectionsFor(peer);
1466
+ store = new Facts({ root, repo: repoFor(peer), tmp: join(nativeStateDir(peer), "facts"), instance: `${instanceId.slice(0, 8)}-${peer}`,
1467
+ scope: id => scopes.has(id) ? scopes.get(id) : scopes.set(id, tasks.factScope(id)).get(id),
1468
+ peers: () => [...bus.peers.keys()].filter(id => executionRoot(id) === root), nameable: tasks.nameable, deny: config.local.deny,
1469
+ privatePath: path => matchesPolicy(root, path, config.pii?.paths ?? []), projection: file => projection.project(file) });
1470
+ worktreeFacts.set(peer, store);
1471
+ }
1472
+ return store;
1473
+ };
1317
1474
  const progress = new ProgressObserver({
1318
1475
  tasks: () => board.list(),
1319
1476
  isPrivate: () => [...turns.values()].some(turn => turn.private) || board.list().some(task => !isClosedTaskState(task.state) && tasks.isPii(task)),
@@ -1333,7 +1490,7 @@ export async function startDaemon(opts: DaemonOptions) {
1333
1490
  let factsWereOn = false;
1334
1491
  const factsOn = (): boolean => {
1335
1492
  const on = repositoryFacts && turnFree();
1336
- if (on && !factsWereOn) facts.reset();
1493
+ if (on && !factsWereOn) { facts.reset(); for (const store of worktreeFacts.values()) store.reset(); }
1337
1494
  // A PII task opened: every silent cohort speaks again, for good, and its members hear so at once (issue #108).
1338
1495
  if (!on && factsWereOn) for (const cohort of tasks.cohorts.liftAll()) tasks.announceLift(cohort, "a private task opened, and facts are off while it is open");
1339
1496
  factsWereOn = on;
@@ -1351,10 +1508,10 @@ export async function startDaemon(opts: DaemonOptions) {
1351
1508
  probes.delete(peer);
1352
1509
  }
1353
1510
  factSessions.set(peer, id);
1354
- facts.session(peer, id);
1511
+ factsFor(peer).session(peer, id);
1355
1512
  };
1356
1513
  const acked = (peer: PeerId, id: string, via: string) => {
1357
- const got = facts.ack(peer, id);
1514
+ const got = factsFor(peer).ack(peer, id);
1358
1515
  if (!got) return;
1359
1516
  event({ type: "fact_ack", peer, id, via, ms: Date.now() - got.at });
1360
1517
  if (via !== "done" && !capable.has(peer)) {
@@ -1374,7 +1531,7 @@ export async function startDaemon(opts: DaemonOptions) {
1374
1531
  * integration request wait for the next done, not a readback, so they never count.
1375
1532
  */
1376
1533
  const checkCapability = (peer: PeerId) => {
1377
- if (capable.has(peer) && facts.pending(peer).filter((o) => !o.done && Date.now() - o.at > 60_000).length >= 3) {
1534
+ if (capable.has(peer) && factsFor(peer).pending(peer).filter((o) => !o.done && Date.now() - o.at > 60_000).length >= 3) {
1378
1535
  capable.delete(peer);
1379
1536
  loseCapability(peer, "three facts found no readback");
1380
1537
  }
@@ -1386,11 +1543,11 @@ export async function startDaemon(opts: DaemonOptions) {
1386
1543
  */
1387
1544
  const offerFor = (peer: PeerId, toolUseId?: string) => {
1388
1545
  if (!repositoryFacts) return undefined;
1389
- if (!capable.has(peer) && facts.pending(peer).filter((o) => !o.done).length >= 3) return undefined;
1390
- const due = facts.due(peer, toolUseId);
1391
- if (due || capable.has(peer) || facts.pending(peer).some((o) => o.probe) || (probes.get(peer) ?? 0) >= 3) return due;
1546
+ if (!capable.has(peer) && factsFor(peer).pending(peer).filter((o) => !o.done).length >= 3) return undefined;
1547
+ const due = factsFor(peer).due(peer, toolUseId);
1548
+ if (due || capable.has(peer) || factsFor(peer).pending(peer).some((o) => o.probe) || (probes.get(peer) ?? 0) >= 3) return due;
1392
1549
  probes.set(peer, (probes.get(peer) ?? 0) + 1);
1393
- return facts.probe(peer, toolUseId);
1550
+ return factsFor(peer).probe(peer, toolUseId);
1394
1551
  };
1395
1552
  // Claude's readback: the transcript row Claude Code writes for a hook's additional context, by tool use id.
1396
1553
  const injected = new Map<string, string>();
@@ -1425,8 +1582,8 @@ export async function startDaemon(opts: DaemonOptions) {
1425
1582
  } catch { /* no transcript yet: no readback */ }
1426
1583
  };
1427
1584
  /** Claude's transcript for this session: the one the status line reported, or the hook's own, inside Claude's projects. */
1428
- const claudeTranscript = (sessionId: string | undefined, fromHook: unknown): string | undefined => {
1429
- const reported = claudeSession();
1585
+ const claudeTranscript = (sessionId: string | undefined, fromHook: unknown, peer = "claude"): string | undefined => {
1586
+ const reported = claudeSession(peer);
1430
1587
  if (reported.transcriptPath && (!sessionId || reported.sessionId === sessionId)) return reported.transcriptPath;
1431
1588
  if (!sessionId || typeof fromHook !== "string" || !isAbsolute(fromHook) || basename(fromHook) !== `${sessionId}.jsonl`) return undefined;
1432
1589
  try {
@@ -1436,7 +1593,7 @@ export async function startDaemon(opts: DaemonOptions) {
1436
1593
  } catch { return undefined; }
1437
1594
  };
1438
1595
  const readbacks = (peer: PeerId, transcript: string | undefined) => {
1439
- const pending = facts.pending(peer).filter((o) => o.toolUseId);
1596
+ const pending = factsFor(peer).pending(peer).filter((o) => o.toolUseId);
1440
1597
  if (!pending.length || !transcript) return;
1441
1598
  readTranscript(transcript);
1442
1599
  for (const o of pending) if (injected.get(o.toolUseId!)?.includes(`[${o.id}]`)) acked(peer, o.id, "hook");
@@ -1450,6 +1607,7 @@ export async function startDaemon(opts: DaemonOptions) {
1450
1607
  const activeAt = new Map<PeerId, number>();
1451
1608
  interface NativeTurnStart { at: number; baseline?: string; generation: string; session: string; launch: string }
1452
1609
  const nativeTurnStart = new Map<PeerId, NativeTurnStart>();
1610
+ const stopUnconfirmedNoticed = new WeakSet<NativeTurnStart>();
1453
1611
  const nativeStopWaits = new Map<PeerId, { start: NativeTurnStart; wait: ReturnType<typeof waitForClaudeCompletion> }>();
1454
1612
  /** Every Claude hook call's own start-up and the hub's time for it, summed per turn (issue #108): reported at Stop. */
1455
1613
  const hookStats = new Map<PeerId, { n: number; startupMs: number; hubMs: number; maxStartupMs: number }>();
@@ -1493,12 +1651,12 @@ export async function startDaemon(opts: DaemonOptions) {
1493
1651
  const contextSession = (peer: PeerId): string | undefined => {
1494
1652
  const p = bus.peers.get(peer);
1495
1653
  if (p instanceof WsPeer && p.claiming) return undefined;
1496
- if (definitionOf(peer)?.turnEnd === "native-hook") { const c = claudeSession(); return c.sessionId ? `${c.sessionId}:${c.launchId ?? ""}` : undefined; }
1654
+ if (definitionOf(peer)?.turnEnd === "native-hook") { const c = claudeSession(peer); return c.sessionId ? `${c.sessionId}:${c.launchId ?? ""}` : undefined; }
1497
1655
  if (p instanceof CodexPeer) return p.thread || undefined;
1498
1656
  if (p instanceof AcpPeer || p instanceof PiPeer) { const id = p.recoveryMetadata().sessionId; return typeof id === "string" ? id : undefined; }
1499
1657
  return undefined;
1500
1658
  };
1501
- const activityRings: Record<string, ActivityRing> = Object.fromEntries(ADAPTERS.filter(adapter => adapter.capabilities?.watch).map(adapter => [adapter.kind, new ActivityRing()]));
1659
+ const activityRings: Record<string, ActivityRing> = Object.fromEntries(SUPPORTED_PEERS.filter(peer => definitionOf(peer)?.capabilities?.watch).map(peer => [peer, new ActivityRing()]));
1502
1660
  const contextPii = (peer: PeerId) => !!turns.get(peer)?.private || board.list().some(t => !isClosedTaskState(t.state) && (t.owner === peer || t.reviewer === peer) && tasks.isPii(t));
1503
1661
  const requestCheckpoint = (peer: PeerId, reason: "quota" | "context"): Promise<string | undefined> => {
1504
1662
  // One outstanding request per peer. A quota transition may proceed without a summary rather than steal a context waiter.
@@ -1546,21 +1704,21 @@ export async function startDaemon(opts: DaemonOptions) {
1546
1704
  if (budgetFeedSeen.get(hold.peer) !== key && supervision.budgetPaused(hold.peer, hold.resetsAt)) budgetFeedSeen.set(hold.peer, key);
1547
1705
  }
1548
1706
  };
1549
- const budget = new Budget(join(opts.stateDir, "hub.db"), config.budget, {
1707
+ const budget = new Budget(join(opts.stateDir, "hub.db"), config.budget, instanceBudgetHooks({
1550
1708
  pause: (peer) => bus.pause(peer),
1551
1709
  resume: (peer) => {
1552
1710
  if (!manualPaused.has(peer) && !conductorHolds.has(peer)) bus.resume(peer);
1553
1711
  },
1554
1712
  requestCheckpoint: (peer) => requestCheckpoint(peer, "quota"),
1555
1713
  platformContext: async (peer) => {
1556
- const platform = PLATFORM[peer];
1714
+ const platform = PLATFORM[definitionOf(peer)?.kind ?? peer];
1557
1715
  if (!config.memory.enabled || !platform) return undefined;
1558
1716
  const text = await memory.contextInject(chain, platform);
1559
1717
  return text?.startsWith("# [") ? `Recent ${platform} sessions in this project, from shared memory (the peer left no checkpoint):\n${trimToTokens(text.slice(Math.max(text.indexOf("\n### "), 0)).trim(), 800)}` : undefined;
1560
1718
  },
1561
1719
  attached: (peer) => bus.peers.has(peer),
1562
1720
  // Somebody other than the paused peer has to be there, or the handoff would only leave its tasks without an owner.
1563
- canHandOff: (peer) => [...bus.peers.keys()].some((id) => id !== peer && ["idle", "busy"].includes(bus.stateOf(id))),
1721
+ canHandOff: (peer) => [...bus.peers.keys()].some((id) => accountOf(id) !== accountOf(peer) && ["idle", "busy"].includes(bus.stateOf(id))),
1564
1722
  // A reading that arrived through a file carries the file's time; a stale one must not look fresh in the export.
1565
1723
  reading: (peer, windows, hard, at) => event({ type: "quota", peer, windows: windows.map((w) => ({ id: w.id, used: w.used, ...(w.resetsAt ? { resetsAt: w.resetsAt } : {}) })), hard, ...(Math.abs(Date.now() - at) > 1000 ? { measuredAt: new Date(at).toISOString() } : {}) }),
1566
1724
  handoff: (peer, context, urgentOnly) => tasks.reassignForPause(peer, context, urgentOnly),
@@ -1586,7 +1744,7 @@ export async function startDaemon(opts: DaemonOptions) {
1586
1744
  for (const id of superseded) bus.withdraw(id);
1587
1745
  },
1588
1746
  notify: (line) => { notify(line); feedBudgetPauses(); },
1589
- });
1747
+ }, accountMembers, accountOf));
1590
1748
  budget.setRecoveryHold(recoveryActive());
1591
1749
  const kimiTokens: { at: number; n: number }[] = [];
1592
1750
  startupCleanup.push(() => budget.close());
@@ -1646,7 +1804,8 @@ export async function startDaemon(opts: DaemonOptions) {
1646
1804
  * runs inside the state change): three git calls at a turn's start and four at its end, each with the 10 s timeout
1647
1805
  * in snapshots.ts.
1648
1806
  */
1649
- const snap = (what: string, capture = config.snapshots.enabled): { tree?: string; ms: number } => {
1807
+ const snap = (what: string, capture = config.snapshots.enabled, peer?: string): { tree?: string; ms: number } => {
1808
+ const repo = peer ? repoFor(peer) : factsRepo;
1650
1809
  if (!capture) return { ms: 0 };
1651
1810
  const t0 = performance.now();
1652
1811
  let tree: string | undefined;
@@ -1662,18 +1821,26 @@ export async function startDaemon(opts: DaemonOptions) {
1662
1821
  return [...turns.values()].some(turn => turn.private) || board.list().some(task => !isClosedTaskState(task.state) && tasks.isPii(task));
1663
1822
  }
1664
1823
  function reviewProjection(task: Task): string | undefined {
1824
+ const owner = task.owner ?? "", root = executionRoot(owner);
1825
+ const repo = repoFor(owner), projections = projectionsFor(owner);
1665
1826
  if (!projections.enabled || tasks.isPii(task)) return undefined;
1666
1827
  if (!config.snapshots.enabled || !repo || !turnLog) return "(projected review unavailable: snapshots are disabled or unavailable)";
1667
1828
  if (privateSnapshotWindow()) return "(projected review unavailable: private snapshot window)";
1668
1829
  const created = taskProposalAt(task);
1669
1830
  const from = created === undefined ? undefined : turnLog.taskStart(task.id, created);
1670
1831
  if (!from || !hasTree(repo, from)) return "(projected review unavailable: original task snapshot is unknown)";
1671
- const scopes = [...(task.refs.paths ?? []), ...(task.plan?.paths ?? []), ...(task.plan?.sections ?? []).map(anchor => anchor.split("#")[0]!)].map(path => relative(opts.cwd, resolve(opts.cwd, path)));
1832
+ const scopes = [...(task.refs.paths ?? []), ...(task.plan?.paths ?? []), ...(task.plan?.sections ?? []).map(anchor => anchor.split("#")[0]!)].map(path => {
1833
+ const scoped = relative(root, resolve(root, path));
1834
+ if (root === opts.cwd || scoped !== ".." && !scoped.startsWith("../")) return scoped;
1835
+ // Task refs may name the canonical copy of this repository file while the owner executes its worktree copy.
1836
+ const canonical = relative(opts.cwd, resolve(root, path));
1837
+ return canonical !== ".." && !canonical.startsWith("../") && !canonical.startsWith(".worktrees/") ? canonical : scoped;
1838
+ });
1672
1839
  if (!scopes.length) return "(projected review unavailable: task paths were not declared)";
1673
- const to = snap(`task #${task.id} review`).tree;
1840
+ const to = snap(`task #${task.id} review`, config.snapshots.enabled, task.owner ?? undefined).tree;
1674
1841
  if (!to) return "(projected review unavailable: current snapshot is unknown)";
1675
1842
  const paths = changedPaths(repo, from, to).filter(path => { const local = path.slice(repo.prefix.length); return scopes.some(scope => !scope || local === scope || local.startsWith(scope + "/")); });
1676
- return projectedDiff({ repo, from, to, paths, projections, deny: config.local.deny, nameable: tasks.nameable, privatePath: path => matchesPolicy(opts.cwd, join(repo.top, path), config.pii?.paths ?? []) });
1843
+ return projectedDiff({ repo, from, to, paths, projections, deny: config.local.deny, nameable: tasks.nameable, privatePath: path => matchesPolicy(root, join(repo.top, path), config.pii?.paths ?? []) });
1677
1844
  }
1678
1845
  const delta = tokenDeltas();
1679
1846
  /** Tokens a peer used, as increments: Kimi reports a session total (turned into increments below), Codex increments. */
@@ -1687,18 +1854,18 @@ export async function startDaemon(opts: DaemonOptions) {
1687
1854
  }
1688
1855
  return n;
1689
1856
  };
1690
- const onKimiTokens = (total: number, session: string) => {
1691
- const n = addTokens("kimi", delta("kimi", total, session));
1857
+ const onKimiTokens = (total: number, session: string, peer = "kimi") => {
1858
+ const n = addTokens(peer, delta(peer, total, session));
1692
1859
  if (!config.budget.kimi_tokens_5h || !n) return;
1693
1860
  const now = Date.now();
1694
1861
  kimiTokens.push({ at: now, n });
1695
1862
  while (kimiTokens.length && now - kimiTokens[0]!.at > 5 * 3_600_000) kimiTokens.shift();
1696
1863
  const used = kimiTokens.reduce((s, t) => s + t.n, 0) / config.budget.kimi_tokens_5h;
1697
- budget.report("kimi", [{ id: "tokens", used, resetsAt: kimiTokens[0]!.at + 5 * 3_600_000, source: "kimi usage_update (soft limit)" }]);
1864
+ budget.report(peer, [{ id: "tokens", used, resetsAt: kimiTokens[0]!.at + 5 * 3_600_000, source: "kimi usage_update (soft limit)" }]);
1698
1865
  };
1699
1866
  // Claude's numbers arrive through the status line tee `ahub claude` installs (src/cli/statusline-tee.ts).
1700
1867
  let claudeUsageSeen = 0;
1701
- let claudeContextSeen = "";
1868
+ const claudeContextSeen = new Map<string, string>();
1702
1869
  const contextTailSeen = new Map<PeerId, string>();
1703
1870
  const intervals = [
1704
1871
  setInterval(() => {
@@ -1730,16 +1897,16 @@ export async function startDaemon(opts: DaemonOptions) {
1730
1897
  for (const c of consoles) if (c.data.tail) c.send(JSON.stringify({ t: "context", peer, reading }));
1731
1898
  }
1732
1899
  }
1733
- try {
1734
- const file = join(opts.stateDir, "claude-context.json");
1900
+ for (const claudePeer of channelPeers) try {
1901
+ const file = join(opts.stateDir, claudeStateFile("claude-context", claudePeer));
1735
1902
  const payload = readFileSync(file, "utf8");
1736
- if (payload === claudeContextSeen) return;
1903
+ if (payload === claudeContextSeen.get(claudePeer)) continue;
1737
1904
  const c = JSON.parse(payload);
1738
- const session = claudeSession();
1739
- if (c.instanceId !== instanceId || !session.sessionId || c.sessionId !== session.sessionId || c.launchId !== session.launchId || !["idle", "busy", "paused"].includes(bus.stateOf("claude"))) return;
1740
- claudeContextSeen = payload;
1741
- const bound = contextSession("claude");
1742
- if (bound) contexts.report("claude", claudeContext(c.context, bound, c.at), bound);
1905
+ const session = claudeSession(claudePeer);
1906
+ if (c.instanceId !== instanceId || !session.sessionId || c.sessionId !== session.sessionId || c.launchId !== session.launchId || !["idle", "busy", "paused"].includes(bus.stateOf(claudePeer))) continue;
1907
+ claudeContextSeen.set(claudePeer, payload);
1908
+ const bound = contextSession(claudePeer);
1909
+ if (bound) contexts.report(claudePeer, claudeContext(c.context, bound, c.at), bound);
1743
1910
  } catch { /* absent or unreadable native telemetry is unknown */ }
1744
1911
  }, 1000),
1745
1912
  setInterval(() => budget.tick(), 30_000),
@@ -1794,7 +1961,7 @@ export async function startDaemon(opts: DaemonOptions) {
1794
1961
  const SERVER_JS = join(import.meta.dir, "..", "..", "plugins", "agent-hub", "server.js");
1795
1962
  // What the hub's MCP server offers a native peer. Codex approves them in config, Kimi's requests for exactly these
1796
1963
  // names are answered by the hub; every call still passes daemon role/capability checks before its effects.
1797
- const HUB_TOOLS = ["hub_send", ...TASK_TOOLS.map((t) => t.name), ...CONDUCTOR_TOOLS.map((t) => t.name)];
1964
+ const HUB_TOOLS = ["hub_send", ...TASK_TOOL_NAMES, ...CONDUCTOR_TOOLS.map((t) => t.name)];
1798
1965
  const HUB_TOOL_TITLES = new Set(HUB_TOOLS.map((name) => `mcp__agent-hub__${name}`));
1799
1966
  const toolEnv = (peer: PeerId, deliveryOnly = false) => ({ ...(deliveryOnly ? { AGENTHUB_DELIVERY_ONLY: "true" } : {}), AGENTHUB_MODE: "tools", AGENTHUB_PEER_ID: peer, AGENTHUB_STATE_DIR: opts.stateDir, AGENTHUB_PROJECT_DIR: opts.cwd });
1800
1967
 
@@ -1849,11 +2016,13 @@ export async function startDaemon(opts: DaemonOptions) {
1849
2016
  if (op === "hub_memory_correct") correctionInput(a);
1850
2017
  if (CONDUCTOR_TOOL_NAMES.has(op)) {
1851
2018
  if (piiTurn && ["hub_task_assign", "hub_task_escalate", "hub_peer_start"].includes(op)) throw new PreEffectToolRefusal("conductor mutations are unavailable during a PII turn");
1852
- return JSON.stringify(await conductor.execute(by, op, a));
2019
+ const result = await conductor.execute(by, op, a);
2020
+ const shown = op === "hub_task_show" && result && typeof result === "object" ? board.get(Number(a.id)) : undefined;
2021
+ return JSON.stringify(shown ? { ...reviewerView(by, result as Record<string, unknown>), ...briefField(shown) } : result);
1853
2022
  }
1854
2023
  // Inside a PII turn the worker's words may carry the PII whatever they are attached to: a note would go to
1855
2024
  // claude-mem (a cloud observer) and a new task could be routed to a cloud peer without matching any pattern.
1856
- if (piiTurn && (MEMORY_READ_TOOLS.has(op) || op === "hub_memory_correct" || op === "hub_remember" || op === "hub_task_propose" || op === "hub_sdlc_record" || op === "hub_backlog_plan" || op === "hub_backlog_undo" || op === "hub_backlog_pull" || op === "hub_backlog_split" || op === "release_new")) throw new PreEffectToolRefusal(`${op} is not available while working on a PII task: its text must not leave this machine`);
2025
+ if (piiTurn && (MEMORY_READ_TOOLS.has(op) || op === "hub_memory_correct" || op === "hub_remember" || op === "hub_task_propose" || op === "hub_sdlc_record" || op === "hub_plan_review" || op === "hub_sdlc_costs" || op === "hub_backlog_return_untouched" || op === "hub_backlog_plan" || op === "hub_backlog_undo" || op === "hub_backlog_pull" || op === "hub_backlog_split" || op === "release_new")) throw new PreEffectToolRefusal(`${op} is not available while working on a PII task: its text must not leave this machine`);
1857
2026
  // The same words attached to an ordinary task would reach its reviewer, its owner, overlapping owners (a plan, the
1858
2027
  // completed-change notice) and claude-mem. A turn that holds a PII task acts on ordinary tasks in a turn of its own.
1859
2028
  if (piiTurn && (op === "hub_task_done" || op === "hub_review" || (op === "hub_task_accept" && a.plan != null))) {
@@ -1969,6 +2138,16 @@ export async function startDaemon(opts: DaemonOptions) {
1969
2138
  return JSON.stringify(tasks.backlogList(by));
1970
2139
  case "hub_release_list":
1971
2140
  return JSON.stringify(tasks.releaseList(by));
2141
+ case "hub_planner_groom":
2142
+ return JSON.stringify(await tasks.groomingDigest(by));
2143
+ case "hub_sdlc_costs":
2144
+ return JSON.stringify(await tasks.costReport(by, a.repo));
2145
+ case "hub_plan_review": {
2146
+ const { id, ...review } = a;
2147
+ return JSON.stringify(tasks.publicView(await tasks.recordPlanReview(by, id, review)));
2148
+ }
2149
+ case "hub_backlog_return_untouched":
2150
+ return JSON.stringify(tasks.publicView(await tasks.returnUntouchedToBacklog(by, { id: a.id, revision: a.revision, reason: a.reason })));
1972
2151
  case "hub_backlog_plan":
1973
2152
  return JSON.stringify(tasks.publicView(await tasks.planBacklog(by, { id: a.id, revision: a.revision, fields: a.fields, reason: a.reason })));
1974
2153
  case "hub_backlog_pull":
@@ -1985,15 +2164,16 @@ export async function startDaemon(opts: DaemonOptions) {
1985
2164
  const t = await tasks.propose(by, a);
1986
2165
  if (t.state === "backlog") return line(t);
1987
2166
  const overlap = tasks.overlaps(t, t.owner === by);
1988
- if (t.owner === by && tasks.silentFor(t.id) && factsOn()) facts.sawPlans(by, tasks.overlapTasks(t)); // as for an accept
2167
+ if (t.owner === by && tasks.silentFor(t.id) && factsOn()) factsFor(by).sawPlans(by, tasks.overlapTasks(t)); // as for an accept
1989
2168
  return [line(t), overlap, tasks.claimRoleGuidance(t, by), t.owner === by ? tasks.planGuardText(t) : ""].filter(Boolean).join("\n");
1990
2169
  }
1991
2170
  case "hub_task_accept": {
1992
- const t = tasks.accept(by, a.id, a.plan);
2171
+ // S1 spec acceptance and S9 plan review are checked against one task snapshot before acceptance.
2172
+ const t = await tasks.acceptReviewed(by, a.id, a.plan);
1993
2173
  // A silent cohort (issue #107): the owners' plans come with every accept, as what the newcomer works from.
1994
2174
  const silent = tasks.silentFor(t.id);
1995
2175
  const overlap = a.plan == null && !silent ? "" : tasks.overlaps(t);
1996
- if (silent && factsOn()) facts.sawPlans(by, tasks.overlapTasks(t)); // the plans in this answer need no fact later
2176
+ if (silent && factsOn()) factsFor(by).sawPlans(by, tasks.overlapTasks(t)); // the plans in this answer need no fact later
1997
2177
  return [line(t), overlap, tasks.planGuardText(t)].filter(Boolean).join("\n");
1998
2178
  }
1999
2179
  case "hub_task_cancel": {
@@ -2020,9 +2200,10 @@ export async function startDaemon(opts: DaemonOptions) {
2020
2200
  return extra ? `${result}\n${extra}` : result;
2021
2201
  }
2022
2202
  case "hub_sdlc_record":
2203
+ await tasks.specStageGate(by, a.id, a.receipt); // #481 S1: a live spec edit-time check before a build stage
2023
2204
  return line(tasks.recordLifecycle(by, a.id, a.receipt));
2024
2205
  case "hub_review":
2025
- return line(await tasks.review(by, a.id, a.verdict, a.note, a.unmet, a.head));
2206
+ return line(await tasks.review(by, a.id, a.verdict, a.note, a.unmet, a.head, a.coverage));
2026
2207
  case "hub_remember":
2027
2208
  return tasks.remember(by, a);
2028
2209
  case "hub_checkpoint": {
@@ -2062,7 +2243,8 @@ export async function startDaemon(opts: DaemonOptions) {
2062
2243
  if (op === "task_show" && by !== USER) {
2063
2244
  const task = board.get(Number(a.id));
2064
2245
  if (task?.state === "backlog") throw new PreEffectToolRefusal("backlog is read through planner tools");
2065
- return JSON.stringify(task ? { ...publicConductorTask(task, (t) => tasks.publicView(t, true)), flowRevision: task.history.length } : `no task #${a.id}`);
2246
+ if (!task) return JSON.stringify(`no task #${a.id}`);
2247
+ return JSON.stringify({ ...reviewerView(by, publicConductorTask(task, (t) => tasks.publicView(t, true))), ...briefField(task), flowRevision: task.history.length });
2066
2248
  }
2067
2249
  if (op === "route_explain" && by !== USER) {
2068
2250
  if (a.id !== undefined && board.get(Number(a.id))?.state === "backlog") throw new PreEffectToolRefusal("backlog is read through planner tools");
@@ -2089,6 +2271,10 @@ export async function startDaemon(opts: DaemonOptions) {
2089
2271
  case "backlog_pull":
2090
2272
  case "backlog_return":
2091
2273
  return line(await tasks.moveBacklog(by, a.id, op === "backlog_pull"));
2274
+ case "review_brief": {
2275
+ const task = board.get(Number(a.id));
2276
+ return task ? tasks.briefText(task) || `task #${task.id} names no commit, or is private: no brief` : `no task #${a.id}`;
2277
+ }
2092
2278
  case "task_show": {
2093
2279
  const task = board.get(Number(a.id));
2094
2280
  return JSON.stringify(task ? { ...task, flowRevision: task.history.length, reviews: board.reviews({ task: task.id }) } : `no task #${a.id}`, null, 2);
@@ -2123,6 +2309,14 @@ export async function startDaemon(opts: DaemonOptions) {
2123
2309
  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`);
2124
2310
  const codex = bus.peers.get(turn.peer);
2125
2311
  if (!(codex instanceof CodexPeer)) throw new Error("codex is not attached");
2312
+ const workspace = turnLog!.workspace(turn.id);
2313
+ if (workspace !== undefined) {
2314
+ const current = readInstanceRoleConfig(opts.cwd), adapter = instanceDefinitionOf(turn.peer, current.instances);
2315
+ if (!current.instances?.[turn.peer] || adapter?.kind !== definitionOf(turn.peer)?.kind) throw new Error("turn workspace declaration changed; inspect before reverting");
2316
+ const validated = resolveInstanceExecutionCwd(opts.cwd, turn.peer, current.instances, effectiveInstanceRoles(turn.peer, current.instances, current.roles));
2317
+ const bound = executionOwners.get(turn.peer);
2318
+ 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");
2319
+ }
2126
2320
  // Nothing may reach Codex while its history is rewritten: a turn started meanwhile would go with the reverted ones.
2127
2321
  const held = bus.stateOf(turn.peer) !== "paused";
2128
2322
  if (held) bus.pause(turn.peer);
@@ -2187,7 +2381,7 @@ export async function startDaemon(opts: DaemonOptions) {
2187
2381
  };
2188
2382
  let releasing = false; // gone-owner release (#6) and the ready sweep (#34): one run at a time, and a recovery commit waits for it
2189
2383
  const recoveryReady = () => {
2190
- if (!recoveryActive() || releasing || taskOpsInFlight !== 0 || tasks.checksPending() !== 0 || (piReceipts?.inFlight ?? 0) !== 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;
2384
+ 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;
2191
2385
  if (checkPending.size !== 0) return false;
2192
2386
  if (!recoveryPeerSnapshot) return true;
2193
2387
  const current = recoveryPeers();
@@ -2210,19 +2404,20 @@ export async function startDaemon(opts: DaemonOptions) {
2210
2404
  // #64: a Claude session that never persisted a transcript cannot be resumed, so the
2211
2405
  // restore gate accepted a fresh session and nothing it stood for is lost. Snapshots
2212
2406
  // written before this flag existed are re-derived from disk.
2213
- const unpersistedClaude = definitionOf(saved.id)?.session.missingEvidence === "transcript" && (saved.sessionPersisted === false || (saved.sessionPersisted === undefined && !claudeTranscriptPersisted(saved.sessionId)));
2407
+ const unpersistedClaude = definitionOf(saved.id)?.session.missingEvidence === "transcript" && (saved.sessionPersisted === false || (saved.sessionPersisted === undefined && !claudeTranscriptPersisted(saved.id, saved.sessionId)));
2214
2408
  if (!unpersistedClaude && !waived()) return false;
2215
2409
  }
2216
2410
  return true;
2217
2411
  });
2218
2412
  };
2219
- const claudeSession = (): { sessionId?: string; transcriptPath?: string; launchId?: string } => {
2413
+ /** #450 I3: each Claude peer (claude and every claude-<name> instance) has its own session and launch files. */
2414
+ const claudeSession = (peer = "claude"): { sessionId?: string; transcriptPath?: string; launchId?: string } => {
2220
2415
  try {
2221
- const value = JSON.parse(readFileSync(join(opts.stateDir, "claude-session.json"), "utf8"));
2416
+ const value = JSON.parse(readFileSync(join(opts.stateDir, claudeStateFile("claude-session", peer)), "utf8"));
2222
2417
  if (value.instanceId !== instanceId) return {};
2223
2418
  let currentLauncher = false;
2224
2419
  try {
2225
- const launch = JSON.parse(readFileSync(join(opts.stateDir, "claude-launch.json"), "utf8"));
2420
+ const launch = JSON.parse(readFileSync(join(opts.stateDir, claudeStateFile("claude-launch", peer)), "utf8"));
2226
2421
  if (launch.instanceId === instanceId && typeof launch.launchId === "string" && launch.launchId) {
2227
2422
  currentLauncher = true;
2228
2423
  if (value.launchId !== launch.launchId) return {};
@@ -2230,7 +2425,7 @@ export async function startDaemon(opts: DaemonOptions) {
2230
2425
  } catch { /* older/manual launches retain the existing instance fence */ }
2231
2426
  try {
2232
2427
  const records = JSON.parse(readFileSync(join(opts.stateDir, "terminal-recovery.json"), "utf8"));
2233
- const current = Array.isArray(records) ? records.find((row) => typeof row?.peer === "string" && definitionOf(row.peer)?.transport === "claude-channel" && row?.projectRoot === opts.cwd && row?.instanceId === instanceId) : undefined;
2428
+ const current = ownTerminalRow(records, peer, opts.cwd, instanceId);
2234
2429
  if (!currentLauncher && current?.launchId && value.launchId !== current.launchId) return {};
2235
2430
  } catch { /* no managed terminal record: the instance fence is still enforced */ }
2236
2431
  return {
@@ -2288,33 +2483,30 @@ export async function startDaemon(opts: DaemonOptions) {
2288
2483
  }
2289
2484
  /** Claude persists projects/<slug>/<sessionId>.jsonl only with the first turn. Prefer the
2290
2485
  * path Claude itself reported through the status line; fall back to the slug computation. */
2291
- const claudeTranscriptPersisted = (sessionId: string): boolean => {
2292
- const reported = claudeSession();
2293
- if (reported.sessionId === sessionId && reported.transcriptPath) return existsSync(reported.transcriptPath);
2294
- const config = process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude");
2295
- return existsSync(join(config, "projects", opts.cwd.replace(/[^a-zA-Z0-9]/g, "-"), `${sessionId}.jsonl`));
2296
- };
2486
+ // ponytail: the version probe reads the builtin claude peer only; extend per peer if an instance's version matters (#450 I3).
2487
+ const claudeTranscriptPersisted = (peer: string, sessionId: string): boolean => transcriptPersisted(claudeSession(peer), sessionId, Object.hasOwn(declarations, peer),
2488
+ join(process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude"), "projects", opts.cwd.replace(/[^a-zA-Z0-9]/g, "-"), `${sessionId}.jsonl`), path => pathEvidence(path, lstatSync));
2297
2489
  // Optional native usage: only the instance-fenced explicit Claude session is read.
2298
2490
  // IDs persist in telemetry, so a restart/resume never counts the same native message twice.
2299
2491
  const claudeNativeUsageSeen = new Set(readEvents(join(opts.stateDir, "events.jsonl")).filter(e => e.type === "usage" && e.source === "claude_transcript").map(e => (e as { id: string }).id));
2300
- const collectClaudeUsage = () => {
2301
- const session = claudeSession();
2302
- if (!session.sessionId || !session.transcriptPath) return;
2492
+ const collectClaudeUsage = () => { for (const peer of channelPeers) {
2493
+ const session = claudeSession(peer);
2494
+ if (!session.sessionId || !session.transcriptPath) continue;
2303
2495
  for (const record of readClaudeTranscriptUsage(session.sessionId, session.transcriptPath)) {
2304
2496
  if (claudeNativeUsageSeen.has(record.id)) continue;
2305
2497
  claudeNativeUsageSeen.add(record.id);
2306
2498
  const reported = claudeReportedTokens(record.usage);
2307
- if (reported !== undefined) addTokens("claude", reported);
2308
- event({ type: "usage", peer: "claude", source: "claude_transcript", id: record.id, ...record.usage, ...taskAttribution("claude"), ...(record.servedModel ? { servedModel: record.servedModel } : {}), ...(record.at ? { measuredAt: record.at } : {}) });
2499
+ if (reported !== undefined) addTokens(peer, reported);
2500
+ event({ type: "usage", peer, source: "claude_transcript", id: record.id, ...record.usage, ...taskAttribution(peer), ...(record.servedModel ? { servedModel: record.servedModel } : {}), ...(record.at ? { measuredAt: record.at } : {}) });
2309
2501
  }
2310
- };
2502
+ } };
2311
2503
  const claudeUsageTimer = setInterval(collectClaudeUsage, 2000);
2312
2504
  claudeUsageTimer.unref(); intervals.push(claudeUsageTimer);
2313
2505
  const recoveryPeers = (): Record<string, RestartPeerSnapshot> => Object.fromEntries([...bus.peers].map(([id, peer]) => {
2314
2506
  const metadata = peer.recoveryMetadata?.() ?? {};
2315
2507
  const row: RestartPeerSnapshot = { id, state: peer.state, queueIds: bus.queueIds(id), ...(metadata.launch ? { launch: metadata.launch as Record<string, unknown> } : {}) };
2316
2508
  if (typeof metadata.threadId === "string") row.threadId = metadata.threadId;
2317
- const sessionId = definitionOf(id)?.transport === "claude-channel" ? claudeSession().sessionId : metadata.sessionId;
2509
+ const sessionId = definitionOf(id)?.transport === "claude-channel" ? claudeSession(id).sessionId : metadata.sessionId;
2318
2510
  if (typeof sessionId === "string" && sessionId) row.sessionId = sessionId;
2319
2511
  return [id, row];
2320
2512
  }));
@@ -2357,7 +2549,7 @@ export async function startDaemon(opts: DaemonOptions) {
2357
2549
  if (peer instanceof AcpPeer && ["unknown", "unmanaged"].includes(peer.permissionModeState)) return peer.permissionModeState;
2358
2550
  if (starting.has(id) && (peer instanceof AcpPeer || peer instanceof CodexPeer)) return peer.getPermissionMode();
2359
2551
  if (definitionOf(id)?.transport === "claude-channel" && peer instanceof WsPeer && peer.state !== "offline") {
2360
- const launch = claudePermissionLaunch();
2552
+ const launch = claudePermissionLaunch(id);
2361
2553
  if (!launch || claudePermissionGenerations.get(id) !== peer.sessionGeneration) return "unverified";
2362
2554
  }
2363
2555
  return permissionMode(id);
@@ -2368,10 +2560,16 @@ export async function startDaemon(opts: DaemonOptions) {
2368
2560
  const mode = permissionDisplay(id, peer);
2369
2561
  return mode === "ask" ? {} : { permissionMode: mode };
2370
2562
  };
2371
- let lastCodexThread: string | undefined;
2563
+ const lastCodexThreads = new Map<string, string>();
2564
+ // #450 I3: the console and dashboard list each declared instance right after its kind.
2565
+ const byKind = (ids: string[]): string[] => {
2566
+ const kindOf = (id: string) => Object.hasOwn(declarations, id) ? declarations[id]!.kind : undefined;
2567
+ const out = ids.filter(id => kindOf(id) === undefined).flatMap(id => [id, ...ids.filter(extra => kindOf(extra) === id)]);
2568
+ return [...out, ...ids.filter(id => !out.includes(id))];
2569
+ };
2372
2570
  const statusPeerIds = (open: ReturnType<Board["statusTasks"]>) => {
2373
2571
  const reservations = board.statusReservations();
2374
- const ids = new Set([...bus.knownPeers(), ...reservations]);
2572
+ const ids = new Set([...bus.knownPeers(), ...extraIds, ...reservations]);
2375
2573
  if (!Object.keys(config.roster).some(peer => !membershipAllowed(peer))) return [...ids];
2376
2574
  for (const task of open) for (const peer of [task.owner, task.reviewer]) if (peer && !membershipAllowed(peer)) ids.add(peer);
2377
2575
  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));
@@ -2380,17 +2578,24 @@ export async function startDaemon(opts: DaemonOptions) {
2380
2578
  const taskNeeds = board.statusTasks();
2381
2579
  const taskStalls = tasks.stalled();
2382
2580
  const pendingTaskOffers = tasks.pendingOffers().filter(offer => offer.pullOnly === true);
2383
- const peerIds = statusPeerIds(taskNeeds);
2581
+ const peerIds = byKind(statusPeerIds(taskNeeds));
2384
2582
  const view = {
2385
2583
  ...(crashReport.length ? { crash: crashReport } : {}),
2386
2584
  projectId,
2387
2585
  instanceId,
2388
2586
  version: VERSION,
2587
+ pluginBundle: SERVER_JS,
2389
2588
  pendingRestart,
2390
2589
  memoryProfile: memoryProfileStatus(includeAttentionItems),
2391
2590
  lifecycleStages: [...lifecycleConfig.stages],
2591
+ // ponytail: off keeps the #257 phase schema until RETIRED_WORKFLOW_FROM, when the alias and this flag go together.
2592
+ ...(lifecycleConfig.mode !== "off" ? { lifecycleCriteria: true } : {}),
2593
+ // Every supported peer's membership, for launchers that run outside the console role (#484).
2594
+ roster: [...KNOWN_PEERS, ...OPTIONAL_PEERS].map(peer => ({ peer, on: onRoster(peer) })),
2392
2595
  protocol: PROTOCOL,
2393
2596
  features: DAEMON_FEATURES,
2597
+ // #450 I3: the instance ids this daemon bound at startup; a launcher selects only one of these.
2598
+ activeInstances: extraIds,
2394
2599
  stopping,
2395
2600
  pid: process.pid,
2396
2601
  cwd: opts.cwd,
@@ -2398,7 +2603,7 @@ export async function startDaemon(opts: DaemonOptions) {
2398
2603
  ...(dashboard ? { uiOrigin: dashboard.origin } : {}),
2399
2604
  codexProxyPort: opts.codexProxyPort,
2400
2605
  peers: Object.fromEntries(
2401
- peerIds.map((id) => { const p = bus.peers.get(id); const summary = bus.queueSummary(id); const context = contexts.view(id, contextSession(id), !!p && ["idle", "busy", "paused"].includes(bus.stateOf(id))); return [id, { state: bus.stateOf(id), ...(!membershipAllowed(id) ? { rostered: false, rosterNote: "off roster" } : {}), ...permissionStatus(id, p), ...(context.source !== null || context.measuredAt !== null ? { context } : {}), queued: bus.queued(id), ...(p ? {} : { attached: false }), ...(summary.needsReview ? { needsReview: summary.needsReview } : {}), ...(summary.liveAccepted ? { liveAccepted: summary.liveAccepted, settlementNote: "awaiting adapter completion (Claude: correlated reply or hub_delivery_done); task state is independent" } : {}), ...queueHoldStatus(id, summary.heldBy), ...(summary.oldestQueuedAt !== undefined ? { oldestQueuedAt: summary.oldestQueuedAt } : {}), ...(bus.queuedImportant(id) ? { queuedImportant: bus.queuedImportant(id) } : {}), ...pausedNote(id), ...(p instanceof LocalPeer && p.lastServedBy ? { servedBy: p.lastServedBy } : {}), ...(p instanceof WsPeer && p.claiming ? { claiming: true } : {}), ...(p instanceof WsPeer && p.pullOnly ? { pullOnly: true, toolsOnly: definitionOf(id)?.attach?.pullOnlyNotice ?? "tools-only: messages wait for hub_inbox; for pushes restart Claude with ahub claude", lastInboxAt: p.lastInboxAt ?? null } : {}), ...(p instanceof PiPeer ? { requestedModel: p.getRequestedModel(), backends: modelRelay?.status().backends ?? [] } : {}) }]; }),
2606
+ peerIds.map((id) => { const p = bus.peers.get(id); const summary = bus.queueSummary(id); const context = contexts.view(id, contextSession(id), !!p && ["idle", "busy", "paused"].includes(bus.stateOf(id))); return [id, { state: bus.stateOf(id), ...(!membershipAllowed(id) ? { rostered: false, rosterNote: "off roster" } : {}), ...permissionStatus(id, p), ...(context.source !== null || context.measuredAt !== null ? { context } : {}), queued: bus.queued(id), ...(p ? {} : { attached: false }), ...(summary.needsReview ? { needsReview: summary.needsReview } : {}), ...(summary.liveAccepted ? { liveAccepted: summary.liveAccepted, settlementNote: "awaiting adapter completion (Claude: correlated reply or hub_delivery_done); task state is independent" } : {}), ...queueHoldStatus(id, summary.heldBy), ...(summary.oldestQueuedAt !== undefined ? { oldestQueuedAt: summary.oldestQueuedAt } : {}), ...(bus.queuedImportant(id) ? { queuedImportant: bus.queuedImportant(id) } : {}), ...pausedNote(id), ...(p instanceof LocalPeer && p.lastServedBy ? { servedBy: p.lastServedBy } : {}), ...(p instanceof WsPeer && p.claiming ? { claiming: true } : {}), ...(p instanceof WsPeer && p.pullOnly ? { pullOnly: true, toolsOnly: definitionOf(id)?.attach?.pullOnlyNotice ?? "tools-only: messages wait for hub_inbox; for pushes restart Claude with ahub claude", lastInboxAt: p.lastInboxAt ?? null } : {}), ...(p instanceof PiPeer ? { requestedModel: p.getRequestedModel(), backends: modelRelays.get(id)?.status().backends ?? [] } : {}) }]; }),
2402
2607
  ),
2403
2608
  taskWip: Object.fromEntries(peerIds.map(id => [id, { wip: tasks.wip(id), wipLimit: tasks.wipLimit(id) }])),
2404
2609
  boardReadCapabilities: { releaseFilter: true, columnMetadata: true },
@@ -2669,11 +2874,11 @@ export async function startDaemon(opts: DaemonOptions) {
2669
2874
  const lostCount = [...lost.values()].reduce((n, l) => n + l.length, 0);
2670
2875
  report(`the previous hub run stopped without shutting down${lostCount ? `; ${lostCount} deliveries it had in flight are in needs_review (ahub queue list)` : ""}`);
2671
2876
  const start = (peer: string, args: Parameters<typeof startPeer>[1]) => startPeer(peer, args).catch((e: Error) => ({ ok: false, error: e.message }));
2672
- for (const step of crashPlan(prev.peers)) {
2673
- if (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; }
2877
+ for (const step of crashPlan(prev.peers, declarations)) {
2878
+ 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; }
2674
2879
  // `pi.auto_start` already asks for Pi (#66): its recorded headless session comes back, auto-resume or not, and a
2675
2880
  // fresh one starts when that fails or there is nothing headless to resume. Pi runs on-prem: no cloud quota.
2676
- if (definitionOf(step.peer)?.session.restoreOnStart && piAutoStart()) {
2881
+ if (!declarations[step.peer] && definitionOf(step.peer)?.session.restoreOnStart && piAutoStart()) {
2677
2882
  if (step.resume) {
2678
2883
  const r = await start("pi", step.resume as Parameters<typeof startPeer>[1]);
2679
2884
  if (r.ok) { report(`pi resumed (pi.auto_start): ${step.how}`); continue; }
@@ -2733,8 +2938,8 @@ export async function startDaemon(opts: DaemonOptions) {
2733
2938
  for (const id of turnTasks.keys()) if (!turnLog?.get(id)) turnTasks.delete(id);
2734
2939
  turnTasks.set(id, board.list().filter((t) => t.owner === e.peer && ["proposed", "in_progress", "changes_requested", "in_review"].includes(t.state)).map((t) => t.id));
2735
2940
  const capture = config.snapshots.enabled && !!turnLog;
2736
- const start = capture && !pii ? snap(`the start of ${id}`) : undefined; // before the peer is handed anything: the tap runs inside setState
2737
- try { if (capture) turnLog?.begin(id, e.peer, start?.tree); } catch (error) { log(`turn record ${id}: ${(error as Error).message}`); }
2941
+ 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
2942
+ 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}`); }
2738
2943
  const preparedRoles = preparedSessionRoleIdentities.get(e.peer)?.map(role => ({ ...role })) ?? null;
2739
2944
  turns.set(e.peer, { id, capture, start: Date.now(), tokens: 0, deliveryTask: delivery, attribution, preparedRoles, ...(start ? { tree: start.tree, snapshotMs: start.ms } : {}), ...(pii ? { private: true } : {}) });
2740
2945
  event({ type: "turn_start", peer: e.peer, turn: id });
@@ -2746,8 +2951,8 @@ export async function startDaemon(opts: DaemonOptions) {
2746
2951
  if (open.capture && turnLog && (open.private || holdsPii(e.peer))) {
2747
2952
  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}`); }
2748
2953
  } else if (open.capture && turnLog) {
2749
- const end = snap(`the end of ${open.id}`, true);
2750
- const changed = open.tree && end.tree ? changedPaths(repo!, open.tree, end.tree) : [];
2954
+ const end = snap(`the end of ${open.id}`, true, e.peer);
2955
+ const changed = open.tree && end.tree ? changedPaths(repoFor(e.peer)!, open.tree, end.tree) : [];
2751
2956
  if (open.tree && end.tree) files = changed.length; // unknown, not zero, when a snapshot failed
2752
2957
  snapshotMs = (snapshotMs ?? 0) + end.ms;
2753
2958
  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}`); }
@@ -2950,8 +3155,11 @@ export async function startDaemon(opts: DaemonOptions) {
2950
3155
  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" }) };
2951
3156
  const CLI_ENTRY = fileURLToPath(new URL("../cli/main.ts", import.meta.url));
2952
3157
  /** `config.peers` follows a settings write (`settingsSet`), so a changed mode applies at the peer's next start. */
2953
- const startMode = (peer: string): StartMode => startModeOf(config.peers, peer);
2954
- const startWords = (peer: string, mode: StartMode): string[] => definitionOf(peer)?.cli.startWords(mode) ?? [peer];
3158
+ const startMode = (peer: string): StartMode => startModeOf(config.peers, peer, declarations);
3159
+ const startWords = (peer: string, mode: StartMode): string[] => {
3160
+ const words = definitionOf(peer)?.cli.startWords(mode) ?? [peer];
3161
+ return declarations[peer] ? [words[0]!, "--as", peer, ...words.slice(1)] : words;
3162
+ };
2955
3163
  const startable = onRoster;
2956
3164
  // ponytail: one terminal per peer per 30 s, by the clock. The hub cannot see whether the terminal it opened still
2957
3165
  // exists, so a peer that never attaches can be opened again after that; track the provider's handle to do better.
@@ -2960,7 +3168,7 @@ export async function startDaemon(opts: DaemonOptions) {
2960
3168
  // 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.
2961
3169
  const startPlans = () => STARTABLE_PEERS.filter(startable).map((peer) => {
2962
3170
  const mode = startMode(peer), owner = bus.peers.get(peer), can = mode === "tui" ? opener.available() : undefined;
2963
- return { peer, mode, attached: !!owner && owner.state !== "offline", command: definitionOf(peer)?.start.planCommand?.(mode) ?? `ahub ${startWords(peer, mode).join(" ")}`, ...(can ? (can.ok ? { via: can.via } : { why: can.why }) : {}) };
3171
+ return { peer, mode, attached: !!owner && owner.state !== "offline", command: (!declarations[peer] ? definitionOf(peer)?.start.planCommand?.(mode) : undefined) ?? `ahub ${startWords(peer, mode).join(" ")}`, ...(can ? (can.ok ? { via: can.via } : { why: can.why }) : {}) };
2964
3172
  });
2965
3173
  // Printed for a person's explicit reconciliation only; never executed by auto-start.
2966
3174
  const continuityCleanup = () => `After inspection, archive the active bookmark with ${["mv", "-n", "--", join(opts.stateDir, "recovery-continuity.json"), join(opts.stateDir, `recovery-continuity.retired.manual.${randomUUID()}.json`)].map(shellQuote).join(" ")} before choosing another session`;
@@ -3027,6 +3235,9 @@ export async function startDaemon(opts: DaemonOptions) {
3027
3235
  return { ok: true, mode, opened: opened.via, command };
3028
3236
  }
3029
3237
 
3238
+ /** The CLI command that starts a peer: `ahub <kind> --as <id>` for a declared instance. */
3239
+ const startCommand = (peer: string): string => { const kind = definitionOf(peer)?.kind; return kind && kind !== peer ? `ahub ${kind} --as ${peer}` : `ahub ${peer}`; };
3240
+
3030
3241
  async function stopPeer(peer: unknown, rosterRemoval = false): Promise<Record<string, unknown>> {
3031
3242
  if (!rosterRemoval && rosterChanging !== undefined && rosterChanging === peer) return { ok: false, error: `${String(peer)} roster change is in progress` };
3032
3243
  if (stopping || recoveryActive()) return { ok: false, error: "recovery or shutdown is holding peer stops; finish the operation before retrying" };
@@ -3034,9 +3245,9 @@ export async function startDaemon(opts: DaemonOptions) {
3034
3245
  const owner = bus.peers.get(peer);
3035
3246
  const stopNotice = definitionOf(peer)?.cli.stopNotice?.(owner instanceof WsPeer ? "tui" : !owner ? startMode(peer) : "headless", owner);
3036
3247
  if (stopNotice) return { ok: false, error: stopNotice };
3037
- if (!(owner instanceof AcpPeer || owner instanceof PiPeer || owner instanceof LocalPeer)) return { ok: false, error: "unknown or non-owned peer; run ahub status and choose a hub-owned headless peer" };
3248
+ if (!(owner instanceof AcpPeer || owner instanceof PiPeer || owner instanceof LocalPeer || owner instanceof ClaudePrintPeer)) return { ok: false, error: "unknown or non-owned peer; run ahub status and choose a hub-owned headless peer" };
3038
3249
  if (owner instanceof PiPeer && owner.mode !== "headless") return { ok: false, error: "Pi belongs to a native TUI terminal; end it in its terminal" };
3039
- if (owner.state === "offline") return { ok: false, error: `peer is already offline; start it with ahub ${peer} before stopping it` };
3250
+ if (owner.state === "offline") return { ok: false, error: `peer is already offline; start it with ${startCommand(peer)} before stopping it` };
3040
3251
  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" };
3041
3252
  requestedPeerStops.add(peer); // fence delivery, starts and recovery before the first asynchronous stop step
3042
3253
  try {
@@ -3053,7 +3264,7 @@ export async function startDaemon(opts: DaemonOptions) {
3053
3264
  writeStatus();
3054
3265
  const state = bus.stateOf(peer);
3055
3266
  if (state !== "offline" || bus.peers.get(peer) !== owner) return { ok: false, error: "peer stop did not reach offline on its owner; inspect ahub status and its terminal before retrying" };
3056
- try { notify(rosterRemoval ? `${peer} stopped for roster removal; add it with ahub roster add ${peer} before starting it again` : `${peer} stopped by the console; start it again with ahub ${peer}`); } catch { /* owner stop and readback remain authoritative */ }
3267
+ try { notify(rosterRemoval ? `${peer} stopped for roster removal; add it with ahub roster add ${peer} before starting it again` : `${peer} stopped by the console; start it again with ${startCommand(peer)}`); } catch { /* owner stop and readback remain authoritative */ }
3057
3268
  return { ok: true, state };
3058
3269
  });
3059
3270
  } catch {
@@ -3082,19 +3293,25 @@ export async function startDaemon(opts: DaemonOptions) {
3082
3293
  async function startPeerOnce(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 }): Promise<Record<string, unknown>> {
3083
3294
  let unmute: (() => void) | undefined;
3084
3295
  try {
3296
+ if (declarations[peer] && args.mode !== undefined && args.mode !== "headless") return { ok: false, error: "declared instances support headless starts only" };
3297
+ let executionCwd: string;
3298
+ try { executionCwd = resolveInstanceExecutionCwd(opts.cwd, peer, declarations, config.roles[peer] ?? []); }
3299
+ catch (error) { return { ok: false, error: (error as Error).message }; }
3300
+ executionRoots.set(peer, executionCwd);
3085
3301
  applyPendingPermissionDefault(peer);
3086
3302
  if (definitionOf(peer)?.start.refreshTools) { await ensureSpec(true); await refreshRunner(); }
3087
- return await startPeerBody(peer, args, (p) => { unmute = muteState(p); });
3303
+ const result = await startPeerBody(peer, args, (p) => { unmute = muteState(p); }, executionCwd);
3304
+ return result.ok && declarations[peer] ? { ...result, executionCwd } : result;
3088
3305
  } finally {
3089
3306
  unmute?.();
3090
3307
  }
3091
3308
  }
3092
3309
 
3093
- 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): Promise<Record<string, unknown>> {
3310
+ 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>> {
3094
3311
  const adapter = definitionOf(peer);
3095
3312
  const preflight = adapter?.start.preflight?.({ instance: peer, form: args.mode });
3096
3313
  if (preflight) return { ok: false, error: preflight };
3097
- if (adapter?.attach?.admission) {
3314
+ if (adapter?.attach?.admission && !declarations[peer]) {
3098
3315
  if (args.mode !== undefined && !["headless", "tui"].includes(args.mode)) return { ok: false, error: "invalid Kimi mode" };
3099
3316
  const owner = bus.peers.get(peer);
3100
3317
  if ((args.mode ?? startMode(peer)) === "tui") {
@@ -3122,10 +3339,10 @@ export async function startDaemon(opts: DaemonOptions) {
3122
3339
  if (args.replaceSession) {
3123
3340
  if (recoveryActive()) return { ok: false, error: "recovery is holding session replacement" };
3124
3341
  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" };
3125
- if (!(existing instanceof PiPeer) || !existing.replacementReady || piReceipts?.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" };
3126
- const selected = validatePiSession(opts.stateDir, opts.cwd, args.sessionFile);
3342
+ 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" };
3343
+ const selected = validatePiSession(nativeStateDir(peer), executionCwd, args.sessionFile);
3127
3344
  const previous = existing.recoveryMetadata();
3128
- recordPiReplacement(opts.stateDir, instanceId, previous, selected);
3345
+ recordPiReplacement(nativeStateDir(peer), instanceId, previous, selected);
3129
3346
  args = { ...args, ...selected };
3130
3347
  mute(existing);
3131
3348
  await stopPiForReplacement(existing);
@@ -3172,7 +3389,7 @@ export async function startDaemon(opts: DaemonOptions) {
3172
3389
  if (adapter?.start.daemonPort === "owned-acp") {
3173
3390
  if (!jcodeMcpReady(opts.cwd, opts.stateDir, SERVER_JS)) return { ok: false, error: "Jcode project MCP entry missing or stale; the person runs ahub jcode to configure it" };
3174
3391
  const jcode: JcodePeer = adapter!.start.create!({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: config, permission: permissionMode(peer), mcpServers: [], daemon: DAEMON_PORTS["owned-acp"]({ command: config.jcode_cmd, home: config.jcode_home, providerProfile: (args.providerProfile ?? config.jcode_provider_profile) || undefined,
3175
- ...(args.model ? { launchModel: args.model } : {}), ...(args.sessionId ? { resumeSessionId: args.sessionId } : {}), cwd: opts.cwd, watchdogMs: config.watchdog_ms,
3392
+ ...(args.model ? { launchModel: args.model } : {}), ...(args.sessionId ? { resumeSessionId: args.sessionId } : {}), cwd: executionCwd, watchdogMs: config.watchdog_ms,
3176
3393
  preamble: sessionRoleContract(peer), log: () => log("Jcode native diagnostic: details withheld") }) }) as JcodePeer;
3177
3394
  recoveryTaskPreface(peer); await ensurePreface(peer);
3178
3395
  try { await jcode.start(); await reconcileNativeStart(peer, jcode); }
@@ -3184,11 +3401,11 @@ export async function startDaemon(opts: DaemonOptions) {
3184
3401
  const kimi: AcpPeer = adapter!.start.create!({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: config, permission: permissionMode(peer), mcpServers: [], daemon: DAEMON_PORTS["acp"]({
3185
3402
  onActivity: item => activityRings[peer]!.append(item),
3186
3403
  isPrivateTurn: () => !!turns.get(peer)?.private || contextPii(peer),
3187
- cmd,
3404
+ cmd, kind: adapter.kind,
3188
3405
  permissionMode: permissionMode(peer),
3189
3406
  ...(args.model ? { launchModel: args.model } : {}),
3190
3407
  ...(args.sessionId ? { resumeSessionId: args.sessionId } : {}),
3191
- cwd: opts.cwd,
3408
+ cwd: executionCwd,
3192
3409
  watchdogMs: config.watchdog_ms,
3193
3410
  onPermission,
3194
3411
  // The hub's own tools pass without a console prompt, as Codex's do (approval_mode below; issue #72).
@@ -3197,11 +3414,11 @@ export async function startDaemon(opts: DaemonOptions) {
3197
3414
  // session's configured MCP servers (issue #138). Exact names only; a stopping hub approves nothing.
3198
3415
  autoApprove: (title) => !stopping && HUB_TOOL_TITLES.has(title),
3199
3416
  log,
3200
- onTokens: onKimiTokens,
3417
+ onTokens: (total, session) => onKimiTokens(total, session, peer),
3201
3418
  // Pause synchronously before the adapter requeues a provider-refused prompt (#306).
3202
3419
  onQuotaRefusal: (window) => {
3203
3420
  budget.report(peer, [window], true);
3204
- return !!budget.record(peer) && bus.isPaused("kimi");
3421
+ return !!budget.record(peer) && bus.isPaused(peer);
3205
3422
  },
3206
3423
  // Kimi's usage_update is context occupancy ({used, size}), not consumption (#167): it becomes the peer's
3207
3424
  // context reading (#285 phase 1), never a tokens event or a budget window. The reading is bound to the
@@ -3238,9 +3455,9 @@ export async function startDaemon(opts: DaemonOptions) {
3238
3455
  const owner = adapter.start.create({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: undefined, permission, mcpServers, daemon: { acp: {
3239
3456
  onActivity: item => activityRings[peer]!.append(item),
3240
3457
  isPrivateTurn: () => !!turns.get(peer)?.private || contextPii(peer),
3241
- cmd: [launch.cmd, ...launch.args], permissionMode: permission,
3458
+ kind: adapter.kind, cmd: [launch.cmd, ...launch.args], permissionMode: permission,
3242
3459
  ...(args.model ? { launchModel: args.model } : {}), ...(args.sessionId ? { resumeSessionId: args.sessionId } : {}),
3243
- cwd: opts.cwd, watchdogMs: config.watchdog_ms, onPermission,
3460
+ cwd: executionCwd, watchdogMs: config.watchdog_ms, onPermission,
3244
3461
  autoApprove: title => !stopping && HUB_TOOL_TITLES.has(title),
3245
3462
  log: () => log("Qwen runtime diagnostic: details withheld"),
3246
3463
  onUsageDiagnostic: (observation, sessionId) => {
@@ -3273,7 +3490,7 @@ export async function startDaemon(opts: DaemonOptions) {
3273
3490
  onTokens: (added) => void addTokens(peer, added),
3274
3491
  onContext: (reading) => contexts.report(peer, reading, contextSession(peer)),
3275
3492
  // #215: before the thread's first turn_start, so the planner can tell which turns belong to which thread.
3276
- onThread: (thread, fresh) => { lastCodexThread = thread; event({ type: "native_thread", peer, thread, fresh }); },
3493
+ onThread: (thread, fresh) => { lastCodexThreads.set(peer, thread); event({ type: "native_thread", peer, thread, fresh }); },
3277
3494
  onTurn: (native) => {
3278
3495
  const open = turns.get(peer);
3279
3496
  if (open) turnLog?.native(open.id, native);
@@ -3288,10 +3505,10 @@ export async function startDaemon(opts: DaemonOptions) {
3288
3505
  factSession(peer, codex.thread);
3289
3506
  if (item.type === "userMessage") {
3290
3507
  const text = JSON.stringify(item.content ?? "");
3291
- if (text.includes(FACTS_PREFIX)) for (const o of facts.pending("codex")) if (text.includes(`[${o.id}]`)) acked("codex", o.id, "steer");
3508
+ if (text.includes(FACTS_PREFIX)) for (const o of factsFor(peer).pending(peer)) if (text.includes(`[${o.id}]`)) acked(peer, o.id, "steer");
3292
3509
  return;
3293
3510
  }
3294
- facts.codexItem("codex", item);
3511
+ factsFor(peer).codexItem(peer, item);
3295
3512
  checkCapability(peer);
3296
3513
  if (codexSteering) return; // one at a time: the next boundary offers what this one could not
3297
3514
  const started = performance.now();
@@ -3304,12 +3521,12 @@ export async function startDaemon(opts: DaemonOptions) {
3304
3521
  codexSteering = false;
3305
3522
  // Refused, it never went in: not an unread offer, and the next boundary offers it again. Unanswered, it may
3306
3523
  // have: its readback can still come.
3307
- if (outcome === "refused") facts.drop("codex", offered.id);
3524
+ if (outcome === "refused") factsFor(peer).drop(peer, offered.id);
3308
3525
  event({ type: "fact", peer, id: offered.id, files: offered.files, plans: offered.plans, unknown: offered.unknown, named: offered.named, bytes: offered.bytes, via: "steer", ms, ...(outcome === "accepted" ? { rttMs: Math.round(performance.now() - sent) } : {}), accepted: outcome === "accepted", ...(outcome === "unanswered" ? { unanswered: true } : {}), ...(offered.probe ? { probe: true } : {}), ...(offered.coverage ? { coverage: true } : {}) });
3309
3526
  });
3310
3527
  },
3311
- appPort: opts.codexAppPort,
3312
- proxyPort: opts.codexProxyPort,
3528
+ appPort: declarations[peer] ? instanceAppPort(peer) : opts.codexAppPort,
3529
+ proxyPort: declarations[peer] ? 0 : opts.codexProxyPort,
3313
3530
  bin: config.codex_bin,
3314
3531
  // The hub spawns this app-server, so it can give Codex the task tools without touching ~/.codex/config.toml
3315
3532
  // (verified: app-server honours -c mcp_servers.* and the server reaches "ready").
@@ -3322,7 +3539,7 @@ export async function startDaemon(opts: DaemonOptions) {
3322
3539
  preamble: sessionRoleContract(peer),
3323
3540
  onUsage: (rateLimits, hard) => budget.report(peer, codexWindows(rateLimits), hard),
3324
3541
  usagePollMs: config.budget.poll_min * 60_000,
3325
- cwd: opts.cwd,
3542
+ cwd: executionCwd,
3326
3543
  watchdogMs: config.watchdog_ms,
3327
3544
  log,
3328
3545
  }) }) as CodexPeer;
@@ -3335,31 +3552,36 @@ export async function startDaemon(opts: DaemonOptions) {
3335
3552
  return { ok: true, proxyUrl: codex.proxyUrl };
3336
3553
  }
3337
3554
  if (adapter?.start.daemonPort === "pi-rpc") {
3338
- if (!onRoster("pi")) return { ok: false, error: config.roster.pi === undefined ? "Pi is disabled; set pi.enabled in .agenthub/config.json" : "pi is off roster" };
3555
+ if (!onRoster(peer)) return { ok: false, error: config.roster.pi === undefined ? "Pi is disabled; set pi.enabled in .agenthub/config.json" : "pi is off roster" };
3339
3556
  const mode = args.mode ?? "headless";
3340
3557
  const backend = args.backend ?? config.pi.backend;
3341
3558
  if (!["headless", "tui"].includes(mode) || !["auto", "dgx", "mlx"].includes(backend)) return { ok: false, error: "invalid Pi mode/backend" };
3342
3559
  const problem = mlxLaunchProblem(config, { backend, model: args.model });
3343
3560
  if (problem) return { ok: false, error: problem };
3344
- modelRelay ??= await startModelRelay({ omni, admitRequest: admitPiRequest, dgxMaxInputTokens: activeRouting().pi.dgx_max_context_tokens, allowedDGXmodels: { "dgx/coding": config.pi.dgx_coding, "dgx/fast": config.pi.dgx_fast }, mlx: config.mlx.enabled === false ? undefined : config.mlx, mlxAlias: "mlx/fast", fallbackDGXAlias: "dgx/fast", enableHubAuto: true,
3561
+ const modelRelay = modelRelays.get(peer) ?? await startModelRelay({ omni, admitRequest: () => admitPiRequest(peer), dgxMaxInputTokens: activeRouting().pi.dgx_max_context_tokens, allowedDGXmodels: { "dgx/coding": config.pi.dgx_coding, "dgx/fast": config.pi.dgx_fast }, mlx: config.mlx.enabled === false ? undefined : config.mlx, mlxAlias: "mlx/fast", fallbackDGXAlias: "dgx/fast", enableHubAuto: true,
3345
3562
  routeSessionKey: () => { const session = bus.peers.get(peer)?.recoveryMetadata?.().sessionId; return typeof session === "string" ? session : undefined; },
3346
3563
  staySwitch: () => activeRouting(),
3347
3564
  onRoute: record => event({ type: "route", peer, ...record }),
3348
3565
  efficientWaitMs: activeRouting().pi.efficient_wait_ms,
3349
- underBudget: () => { const current = bus.peers.get(peer); return current instanceof PiPeer && tasks.budgetApplies(current.budgetEnvelopes, "pi"); },
3566
+ underBudget: () => { const current = bus.peers.get(peer); return current instanceof PiPeer && tasks.budgetApplies(current.budgetEnvelopes, peer); },
3350
3567
  onCooldown: record => {
3351
3568
  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"}`);
3352
3569
  event({ type: "cooldown", peer, ...record });
3353
3570
  },
3354
3571
  });
3355
- piReceipts ??= new PiToolReceipts(join(opts.stateDir, "hub.db"));
3572
+ modelRelays.set(peer, modelRelay);
3573
+ if (!declarations[peer]) globalModelRelay(modelRelay);
3574
+ const nativeRoot = nativeStateDir(peer);
3575
+ mkdirSync(nativeRoot, { recursive: true });
3576
+ const piReceipts = piReceiptLedgers.get(peer) ?? new PiToolReceipts(join(nativeRoot, "hub.db"));
3577
+ piReceiptLedgers.set(peer, piReceipts);
3356
3578
  let piReply: Envelope | undefined;
3357
3579
  // Tools the person allowed "always" for this Pi start; a new start gets a new set (#209).
3358
3580
  const piAlways = new Set<string>();
3359
3581
  const piPermit = async (title: string, tool: string, signal?: AbortSignal, path?: string, valid: () => boolean = () => true, observe?: ApprovalObserver, originalPath?: string): Promise<ToolApproval> => {
3360
3582
  if (stopping || signal?.aborted || !valid() || !pi.acceptingTools || bus.peers.get(peer) !== pi) return "aborted";
3361
3583
  const selected = permissionMode(peer);
3362
- if (selected === "never-ask" || (selected === "ask-when-needed" && PI_EDIT_TOOLS.has(tool) && !!path && grantablePath(realPath(opts.cwd), path, originalPath, config.protect_instruction_files))) {
3584
+ if (selected === "never-ask" || (selected === "ask-when-needed" && PI_EDIT_TOOLS.has(tool) && !!path && grantablePath(realPath(executionCwd), path, originalPath, config.protect_instruction_files))) {
3363
3585
  observe?.({ source: "automatic", answerEpoch: permissionAnswerEpoch(peer) });
3364
3586
  return true;
3365
3587
  }
@@ -3375,9 +3597,9 @@ export async function startDaemon(opts: DaemonOptions) {
3375
3597
  return signal?.aborted || !valid() ? "aborted" : pi.acceptingTools && bus.peers.get(peer) === pi;
3376
3598
  };
3377
3599
  const ctx: ToolContext = {
3378
- cwd: opts.cwd, deny: config.local.deny, readOnly: config.data?.readonly,
3379
- sandboxProfile: profile(opts.cwd, sandboxNetwork, config.local.read_allow, config.local.deny, config.data?.readonly),
3380
- sandboxEnv: { ...proxyEnv(sandboxNetwork), AGENTHUB_PEER_ID: "pi" },
3600
+ cwd: executionCwd, deny: config.local.deny, readOnly: config.data?.readonly,
3601
+ sandboxProfile: profile(executionCwd, sandboxNetwork, config.local.read_allow, config.local.deny, config.data?.readonly),
3602
+ sandboxEnv: { ...proxyEnv(sandboxNetwork), AGENTHUB_PEER_ID: peer },
3381
3603
  permit: async () => false, // Pi's calls ask through piPermit in executeTool, which knows the tool's name
3382
3604
  send: (text, to) => {
3383
3605
  if (to?.some((id) => !bus.peers.has(id) && id !== USER)) return "error: unknown peer";
@@ -3387,12 +3609,12 @@ export async function startDaemon(opts: DaemonOptions) {
3387
3609
  };
3388
3610
  const routing = activeRouting();
3389
3611
  const pi: PiPeer = adapter!.start.create!({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: config, permission: permissionMode(peer), mcpServers: [], daemon: DAEMON_PORTS["pi-rpc"]({
3390
- cwd: opts.cwd, stateDir: opts.stateDir, cmd: config.pi.cmd, mode, backend,
3612
+ cwd: executionCwd, stateDir: nativeRoot, ...(declarations[peer] ? { hubStateDir: opts.stateDir } : {}), cmd: config.pi.cmd, mode, backend,
3391
3613
  model: args.model,
3392
3614
  sessionId: args.sessionId, sessionFile: args.sessionFile,
3393
3615
  admitBudget: async (envs, unit) => unit === "model_calls" ? [] : tasks.admitExecutionEnvelopes(envs, peer, unit),
3394
3616
  relay: { url: modelRelay.url, token: modelRelay.token, models: modelRelay.models.map((id) => ({ id, contextWindow: id.startsWith("mlx/") ? Math.min(routing.pi.mlx_max_context_tokens, config.mlx.provider === "ollama" ? (config.mlx.contextWindow ?? 8192) : routing.pi.mlx_max_context_tokens) : routing.pi.dgx_max_context_tokens, maxTokens: id === "hub/auto" ? (config.mlx.enabled === false ? 8192 : Math.min(config.mlx.maxTokens ?? 2048, 8192)) : id.startsWith("mlx/") ? (config.mlx.maxTokens ?? 2048) : 8192 })) },
3395
- tools: [...TOOL_SCHEMAS.map((t) => t.function), ...[...profileTaskTools(resolvedProfile, lifecycleConfig.stages), ...CONDUCTOR_TOOLS].map((t) => ({ name: t.name, description: t.description, parameters: t.inputSchema }))],
3617
+ tools: [...TOOL_SCHEMAS.map((t) => t.function), ...[...profileTaskTools(resolvedProfile, lifecycleConfig.stages, lifecycleConfig.mode !== "off"), ...CONDUCTOR_TOOLS].map((t) => ({ name: t.name, description: t.description, parameters: t.inputSchema }))],
3396
3618
  executeTool: async (name, raw, callId, sessionId, signal, context) => {
3397
3619
  if (MEMORY_READ_TOOLS.has(name)) validateMemoryRead(name, raw as Record<string, unknown>);
3398
3620
  if (signal?.aborted) return "error: turn cancelled before tool effects";
@@ -3402,7 +3624,7 @@ export async function startDaemon(opts: DaemonOptions) {
3402
3624
  const result = await piReceipts!.execute(sessionId ?? "", callId, name, humanBash ? { origin: "verified-user-bash", args: raw } : raw, async () => {
3403
3625
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) return "error: invalid tool arguments";
3404
3626
  const nativeTurn = pi.observationTurn;
3405
- const output = TASK_TOOLS.some((t) => t.name === name) || CONDUCTOR_TOOL_NAMES.has(name) ? await taskOp(peer, name, raw as Record<string, unknown>, true) : await runTool(name, JSON.stringify(raw), { ...ctx, signal,
3627
+ const output = TASK_TOOL_NAMES.has(name) || CONDUCTOR_TOOL_NAMES.has(name) ? await taskOp(peer, name, raw as Record<string, unknown>, true) : await runTool(name, JSON.stringify(raw), { ...ctx, signal,
3406
3628
  ...(humanBash ? { onProcessExit: (code: number | null) => { exitCode = code; } } : {}),
3407
3629
  permit: async (title, tool, toolSignal, path, _observe, originalPath) => {
3408
3630
  const picked = humanBash ? (toolSignal?.aborted || context?.isCurrent() !== true ? "aborted" : true) : await piPermit(title, tool ?? name, toolSignal, path, context?.isCurrent, context?.onApproval, originalPath);
@@ -3454,7 +3676,7 @@ export async function startDaemon(opts: DaemonOptions) {
3454
3676
  else if (recoveryActive()) action = "recovery holds automatic restart; inspect ahub status";
3455
3677
  else if (!exit.started || starting.has(peer)) action = "startup failed; inspect its session, then ahub pi";
3456
3678
  else if (exit.turnActive || exit.toolActive) action = "turn/tool effects may be partial; inspect its session, then ahub pi";
3457
- else if (!piAutoStart()) action = "pi.auto_start is off; start it with ahub pi";
3679
+ else if (declarations[peer] || !piAutoStart()) action = "pi.auto_start is off; start it with ahub pi";
3458
3680
  else if (mode !== "headless") action = "native terminal ended; start it with ahub pi";
3459
3681
  else if (piAutoRestartPending || Date.now() - piAutoRestartAt < 60_000) action = "pi.auto_start restart limit (one in 60 s); start it with ahub pi";
3460
3682
  else {
@@ -3467,7 +3689,7 @@ export async function startDaemon(opts: DaemonOptions) {
3467
3689
  const saved = await pi.captureResume();
3468
3690
  if (typeof saved.sessionId !== "string" || typeof saved.sessionFile !== "string") throw new Error("no verified persisted Pi session; refusing a fresh fallback");
3469
3691
  if (stopping || recoveryActive() || bus.peers.get(peer) !== pi || starting.has(peer)) return;
3470
- const result = await startPeer("pi", { mode: "headless", backend, ...(args.model ? { model: args.model } : {}), sessionId: saved.sessionId, sessionFile: saved.sessionFile });
3692
+ const result = await startPeer(peer, { mode: "headless", backend, ...(args.model ? { model: args.model } : {}), sessionId: saved.sessionId, sessionFile: saved.sessionFile });
3471
3693
  notify(result.ok ? "pi.auto_start resumed the recorded Pi session after its idle exit" : `pi.auto_start restart refused: ${String(result.error)}; start it with ahub pi`);
3472
3694
  })().catch((error) => { try { notify(`pi.auto_start could not resume its recorded session: ${error instanceof Error ? error.message : String(error)}; start it with ahub pi`); } catch { /* no retry loop */ } })
3473
3695
  .finally(() => { piAutoRestartAt = Date.now(); piAutoRestartPending = false; });
@@ -3495,6 +3717,7 @@ export async function startDaemon(opts: DaemonOptions) {
3495
3717
  }) }) as PiPeer;
3496
3718
  recoveryTaskPreface(peer);
3497
3719
  await ensurePreface(peer);
3720
+ executionOwners.set(peer, { owner: pi, cwd: executionCwd });
3498
3721
  bus.add(pi);
3499
3722
  // The start's own error is the one reported; a stop that fails too is logged beside it (#115).
3500
3723
  try { await pi.start(); } catch (error) { await pi.stop().catch((stop: Error) => log(`pi stop after a failed start: ${stop.message}`)); throw error; }
@@ -3524,12 +3747,12 @@ export async function startDaemon(opts: DaemonOptions) {
3524
3747
  sidecar ??= new Sidecar({ routing, omni, stateDir: opts.stateDir, port: opts.switchyardPort, log, ...(opts.switchyardBin ? { bin: opts.switchyardBin } : {}) });
3525
3748
  }
3526
3749
  const capture = config.memory.enabled
3527
- ? new Capture(memory, { project: chain.at(-1)!, cwd: opts.cwd, skip: skipTools(), deny: config.local.deny })
3750
+ ? new Capture(memory, { project: chain.at(-1)!, cwd: executionCwd, skip: skipTools(), deny: config.local.deny })
3528
3751
  : undefined;
3529
3752
  const permit = async (title: string, tool?: string, signal?: AbortSignal, path?: string, observe?: ApprovalObserver, originalPath?: string): Promise<ToolApproval> => {
3530
3753
  if (stopping || signal?.aborted || bus.peers.get(peer) !== local) return "aborted";
3531
3754
  const selected = permissionMode(peer);
3532
- if (selected === "never-ask" || (selected === "ask-when-needed" && !!tool && PI_EDIT_TOOLS.has(tool) && !!path && grantablePath(realPath(opts.cwd), path, originalPath, config.protect_instruction_files))) {
3755
+ if (selected === "never-ask" || (selected === "ask-when-needed" && !!tool && PI_EDIT_TOOLS.has(tool) && !!path && grantablePath(realPath(executionCwd), path, originalPath, config.protect_instruction_files))) {
3533
3756
  observe?.({ source: "automatic", answerEpoch: permissionAnswerEpoch(peer) });
3534
3757
  return true;
3535
3758
  }
@@ -3542,7 +3765,7 @@ export async function startDaemon(opts: DaemonOptions) {
3542
3765
  return picked.optionId === "allow";
3543
3766
  };
3544
3767
  const local: LocalPeer = adapter!.start.create!({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: config, permission: permissionMode(peer), mcpServers: [], daemon: DAEMON_PORTS["local-worker"]({
3545
- cwd: opts.cwd,
3768
+ cwd: executionCwd,
3546
3769
  omni,
3547
3770
  onUsage: record => event({ type: "usage", peer, source: "omniroute", id: record.id, ...record.usage, ...taskAttribution(peer, record.task), requestedModel: record.requestedModel, ...(record.servedModel ? { servedModel: record.servedModel } : {}), ...(record.provider ? { provider: record.provider } : {}), measuredAt: record.at }),
3548
3771
  admitBudget: async (envs, unit) => tasks.admitExecutionEnvelopes(envs, peer, unit),
@@ -3561,6 +3784,7 @@ export async function startDaemon(opts: DaemonOptions) {
3561
3784
  ...(capture ? { capture } : {}),
3562
3785
  profile: resolvedProfile,
3563
3786
  sdlcStages: lifecycleConfig.stages,
3787
+ sdlcCriteria: lifecycleConfig.mode !== "off",
3564
3788
  taskTool: (name, a, turn) => taskOp(peer, name, a, true, turn.pii),
3565
3789
  turnPolicy: (envs) => {
3566
3790
  const policy = tasks.turnPolicy(envs);
@@ -3574,17 +3798,38 @@ export async function startDaemon(opts: DaemonOptions) {
3574
3798
  }) }) as LocalPeer;
3575
3799
  recoveryTaskPreface(peer);
3576
3800
  await ensurePreface(peer);
3801
+ executionOwners.set(peer, { owner: local, cwd: executionCwd });
3577
3802
  bus.add(local);
3578
3803
  await local.start();
3579
3804
  return { ok: true, model: route ? `${route} (fallback ${routing.local.fixed_model})` : (args.model ?? routing.local.fixed_model) };
3580
3805
  }
3806
+ if (adapter?.start.daemonPort === "claude-print") {
3807
+ // #223 R3: one fresh `claude -p` session per review request; idle with no process between reviews.
3808
+ if (existing?.state === "busy") return { ok: false, error: `${peer} is reviewing; retry when it is idle` };
3809
+ if (existing) { mute(existing); await existing.stop(); }
3810
+ const reviewer = adapter.start.create!({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: config, permission: permissionMode(peer), mcpServers: [], daemon: DAEMON_PORTS["claude-print"]({
3811
+ cwd: executionCwd,
3812
+ mcpConfig: JSON.stringify({ mcpServers: { "agent-hub": { command: process.execPath, args: ["run", SERVER_JS], env: toolEnv(peer, true) } } }),
3813
+ systemPrompt: sessionRoleContract(peer),
3814
+ watchdogMs: config.watchdog_ms,
3815
+ privatePaths: [...(config.pii?.paths ?? []), ...config.local.deny],
3816
+ facts: env => env.private ? undefined : reviewDiff(executionCwd, env.refs?.commit, { privatePath: path => isDenied(path, config.local.deny) || matchesPolicy(executionCwd, path, config.pii?.paths ?? [], true), nameable: tasks.nameable }),
3817
+ verdictSince: (task, since) => board.get(task)?.history.some(h => h.by === peer && h.at >= since && (h.event === "approved" || h.event === "changes_requested")) === true,
3818
+ onUsage: usage => void addTokens(peer, (usage.input_tokens ?? 0) + (usage.output_tokens ?? 0)),
3819
+ log,
3820
+ }) }) as ClaudePrintPeer;
3821
+ executionOwners.set(peer, { owner: reviewer, cwd: executionCwd });
3822
+ bus.add(reviewer);
3823
+ await reviewer.start();
3824
+ return { ok: true };
3825
+ }
3581
3826
  return { ok: false, error: `unknown peer "${peer}"` };
3582
3827
  }
3583
3828
 
3584
- async function admitPiRequest(): Promise<{ allowed: boolean; reason?: string; remainingMs?: number }> {
3585
- const current = bus.peers.get("pi");
3829
+ async function admitPiRequest(peer = "pi"): Promise<{ allowed: boolean; reason?: string; remainingMs?: number }> {
3830
+ const current = bus.peers.get(peer);
3586
3831
  if (!(current instanceof PiPeer)) return { allowed: false, reason: "Pi owner is not active" };
3587
- const decisions = tasks.admitExecutionEnvelopes(current.budgetEnvelopes, "pi", "model_calls");
3832
+ const decisions = tasks.admitExecutionEnvelopes(current.budgetEnvelopes, peer, "model_calls");
3588
3833
  const denied = decisions.find(d => !d.allowed);
3589
3834
  const stop = denied ? current.recordBudgetStop(denied) : undefined;
3590
3835
  const deadline = decisions.filter(d => d.unit === "elapsed_ms" && d.remaining !== null).map(d => d.remaining!);
@@ -3641,7 +3886,7 @@ export async function startDaemon(opts: DaemonOptions) {
3641
3886
  }
3642
3887
 
3643
3888
  const conductor = new Conductor(conductorHolds, {
3644
- roles: () => config.roles, capabilities: () => config.capabilities,
3889
+ roles: () => config.roles, capabilities: () => config.capabilities, instances: () => declarations,
3645
3890
  status: () => {
3646
3891
  const quota = budget.status();
3647
3892
  const ids = [...new Set([...bus.knownPeers(), ...Object.keys(quota), ...conductorHolds.list().map(h => h.peer)])];
@@ -3649,8 +3894,8 @@ export async function startDaemon(opts: DaemonOptions) {
3649
3894
  peers: ids.map(peer => {
3650
3895
  const summary = bus.queueSummary(peer);
3651
3896
  return { peer, state: bus.stateOf(peer), attached: !!bus.peers.get(peer) && bus.peers.get(peer)!.state !== "offline", queued: bus.queued(peer), needsReview: summary.needsReview,
3652
- manualHeld: manualPaused.has(peer), hold: conductorHolds.get(peer), budgetPause: quota[peer]?.paused ?? null,
3653
- ...(quota[peer] ? { windows: quota[peer]!.windows.map(w => ({ id: w.id, ...(w.stale || (w.resetsAt !== undefined && w.resetsAt <= Date.now()) ? {} : { used: w.used }), resetsAt: w.resetsAt, at: w.at, source: w.source })) } : {}) };
3897
+ manualHeld: manualPaused.has(peer), hold: conductorHolds.get(peer), budgetPause: quota[accountOf(peer)]?.paused ?? null,
3898
+ ...(quota[accountOf(peer)] ? { windows: quota[accountOf(peer)]!.windows.map(w => ({ id: w.id, ...(w.stale || (w.resetsAt !== undefined && w.resetsAt <= Date.now()) ? {} : { used: w.used }), resetsAt: w.resetsAt, at: w.at, source: w.source })) } : {}) };
3654
3899
  }),
3655
3900
  taskCounts: board.counts(), approvals: [...permissions.values()].map(p => ({ peer: p.peer, tool: p.tool, at: p.createdAt })),
3656
3901
  };
@@ -3670,7 +3915,7 @@ export async function startDaemon(opts: DaemonOptions) {
3670
3915
  if (result.ok !== true) throw new Error(String(result.error ?? "peer start failed"));
3671
3916
  return result;
3672
3917
  },
3673
- known: peer => bus.knownPeers().includes(peer),
3918
+ known: peer => extraIds.includes(peer) || bus.knownPeers().includes(peer),
3674
3919
  pause: peer => { pauseGeneration.set(peer, (pauseGeneration.get(peer) ?? 0) + 1); bus.pause(peer); writeStatus(); },
3675
3920
  validateRelease: async peer => {
3676
3921
  const owner = bus.peers.get(peer);
@@ -3793,7 +4038,7 @@ export async function startDaemon(opts: DaemonOptions) {
3793
4038
  return { id: task.id, title: view.title, detail: view.detail, class: task.class, ...(task.profile ? { profile: task.profile, profileClass: task.profileClass } : {}), ...(task.role ? { role: task.role } : {}), state: task.state, owner: task.owner, reviewer: task.reviewer, deps: view.deps ?? [], ...(task.reserved ? { reserved: task.reserved } : {}), planningRevision: task.planningRevision ?? 0, rank: task.rank ?? null, priority: task.priority ?? null, release: task.release ?? null, plannedStart: task.plannedStart ?? null, due: task.due ?? null, pinned: task.pinned === true, actualDates: view.actualDates, ...(view.cancelled ? { cancelled: view.cancelled } : {}), ...(view.external ? { external: view.external } : {}), started: taskHasStarted(task), urgent: task.signals.includes("urgent"), updated: task.updated, reworkRound: task.history.filter(entry => entry.event === "changes_requested").length, checking: tasks.isChecking(task.id), ...(view.lifecycle ? { lifecycle: view.lifecycle } : {}) };
3794
4039
  }),
3795
4040
  links: links.filter(link => link.tasks.every(id => { const task = board.get(id); return task && !tasks.isPii(task); })),
3796
- budget: Object.fromEntries(peers.map((id) => [id, { ...(quota[id] ?? { windows: [] }), context: contexts.view(id, contextSession(id), bus.peers.has(id) && ["idle", "busy", "paused"].includes(bus.stateOf(id))) }])),
4041
+ budget: Object.fromEntries(peers.map((id) => [id, { ...(quota[accountOf(id)] ?? { windows: [] }), context: contexts.view(id, contextSession(id), bus.peers.has(id) && ["idle", "busy", "paused"].includes(bus.stateOf(id))) }])),
3797
4042
  permissions: [...permissions.values()].map(({ push }) => {
3798
4043
  const req = JSON.parse(push);
3799
4044
  // Local tool titles quote file contents and commands, including those of PII turns.
@@ -3842,7 +4087,7 @@ export async function startDaemon(opts: DaemonOptions) {
3842
4087
  function roleHolders(candidate: HubConfig): { planners: string[]; conductors: string[] } {
3843
4088
  // A valid transfer may briefly have two conductors while its leaves are planned. Validate the final map later.
3844
4089
  const policy = Object.fromEntries(Object.entries(candidate.roles).map(([peer, roles]) => [peer, roles.filter(role => role !== "conductor")]));
3845
- const settled = resolveSettledRoles(policy, candidate.planner, peer => rosterAllows(candidate.roster, peer, candidate.pi.enabled));
4090
+ const settled = resolveSettledRoles(policy, candidate.planner, peer => rosterAllows(candidate.roster, peer, candidate.pi.enabled, declarations));
3846
4091
  return { planners: settled.planners, conductors: Object.keys(candidate.roles).filter(peer => candidate.roles[peer]!.includes("conductor")).sort() };
3847
4092
  }
3848
4093
  function prepareConfiguration(sources: ConfigSources, authority: SettingAuthority, scope?: string, confirmed: readonly string[] = []) {
@@ -3871,6 +4116,13 @@ export async function startDaemon(opts: DaemonOptions) {
3871
4116
  const after = valueAt(route ? sources.routing : opts.config && !current ? embeddedConfig : fileConfig, path);
3872
4117
  const rawAfter = current ? valueAt(current.raw, path) : undefined;
3873
4118
  if (!rawChanged && JSON.stringify(before) === JSON.stringify(after)) continue;
4119
+ // Instance declarations are restart-bound. Their derived role/permission/start defaults cannot leak into
4120
+ // the live catalog while the old identities, paths and account callbacks still own the daemon.
4121
+ const boundId = ["roles", "permission_modes", "peers", "permission_default_sources"].includes(path[0] ?? "") ? path[1] : undefined;
4122
+ if (!route && boundId && JSON.stringify(desiredConfig.instances?.[boundId]) !== JSON.stringify(fileConfig.instances?.[boundId])) {
4123
+ rejected.push({ key, reason: "instance declaration changed; its derived defaults apply together after daemon restart" });
4124
+ continue;
4125
+ }
3874
4126
  const machine = !route && isMachineKey(key);
3875
4127
  const origin = current ?? prior;
3876
4128
  let tracked = origin ? configTracked(opts.cwd, origin.name) : false;
@@ -4071,9 +4323,9 @@ export async function startDaemon(opts: DaemonOptions) {
4071
4323
  let routingProblem: string | undefined;
4072
4324
  try { parseRouting(routingText(opts.cwd), readOverlay(opts.cwd)); } catch (error) { routingProblem = (error as Error).message; }
4073
4325
  const rows = settingRows({
4074
- cwd: opts.cwd, routing: activeRouting(), ...(routingProblem ? { routingProblem } : {}), ...(stored?.ignored ? { ignored: stored.ignored } : {}),
4326
+ instances: declarations, cwd: opts.cwd, routing: activeRouting(), ...(routingProblem ? { routingProblem } : {}), ...(stored?.ignored ? { ignored: stored.ignored } : {}),
4075
4327
  running: (path) => path[0] === "roster" ? onRoster(path[1]!) : path[0] === "tasks" && path[1] === "wip_limits" ? tasks.wipLimit(path[2]!) : valueAt(config, path),
4076
- next: stored ? (path) => path[0] === "roster" ? rosterAllows(stored.roster, path[1]!, stored.pi.enabled) : path[0] === "tasks" && path[1] === "wip_limits" ? (Object.hasOwn(stored.tasks.wip_limits ?? {}, path[2]!) ? stored.tasks.wip_limits![path[2]!] : 1) : valueAt(stored, path) : undefined,
4328
+ 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,
4077
4329
  roles: roleState,
4078
4330
  mode: (peer) => {
4079
4331
  const owner = bus.peers.get(peer), attached = !!owner && owner.state !== "offline";
@@ -4112,13 +4364,14 @@ export async function startDaemon(opts: DaemonOptions) {
4112
4364
  if (owner?.state === "busy" && !now) return `${peer} is in a turn; retry when it is idle, or pass --now`;
4113
4365
  return undefined;
4114
4366
  }
4115
- function codexResumeCommand(): string {
4116
- const owner = bus.peers.get("codex"), thread = owner instanceof CodexPeer ? owner.thread || lastCodexThread : lastCodexThread;
4117
- return thread && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(thread) ? `ahub codex resume ${thread}` : "ahub codex resume";
4367
+ function codexResumeCommand(peer = "codex"): string {
4368
+ const owner = bus.peers.get(peer), thread = owner instanceof CodexPeer ? owner.thread || lastCodexThreads.get(peer) : lastCodexThreads.get(peer);
4369
+ const command = declarations[peer] ? `ahub codex --as ${peer} resume` : "ahub codex resume";
4370
+ return thread && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(thread) ? `${command} ${thread}` : command;
4118
4371
  }
4119
4372
  function retainedTerminalNotice(peer: string): string | undefined {
4120
4373
  const owner = bus.peers.get(peer);
4121
- return definitionOf(peer)?.cli.retainedTerminalNotice?.(peer, owner instanceof PiPeer ? owner.mode : undefined, codexResumeCommand());
4374
+ return definitionOf(peer)?.cli.retainedTerminalNotice?.(peer, owner instanceof PiPeer ? owner.mode : undefined, codexResumeCommand(peer));
4122
4375
  }
4123
4376
 
4124
4377
  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>> {
@@ -4128,7 +4381,7 @@ export async function startDaemon(opts: DaemonOptions) {
4128
4381
  const terminalNotice = retainedTerminalNotice(peer);
4129
4382
  let handoff: Awaited<ReturnType<Tasks["reassignForRoster"]>> = [];
4130
4383
  try {
4131
- const next = rosterCandidate(def, value, undo), before = onRoster(peer), after = rosterAllows(next, peer, config.pi.enabled);
4384
+ const next = rosterCandidate(def, value, undo), before = onRoster(peer), after = rosterAllows(next, peer, config.pi.enabled, declarations);
4132
4385
  if (!after) {
4133
4386
  const refusal = rosterRemovalRefusal(peer, now);
4134
4387
  if (refusal) return { ok: false, error: refusal };
@@ -4168,7 +4421,7 @@ export async function startDaemon(opts: DaemonOptions) {
4168
4421
  if (reloaded.ok !== true) return { ...reloaded, saved: true };
4169
4422
  writeStatus();
4170
4423
  const stayed = !after ? board.list().filter(task => !isClosedTaskState(task.state) && (task.owner === peer || task.reviewer === peer)) : [];
4171
- return { ok: true, text: [`${peer}: ${after ? "on" : "off"} roster (in force now)`, ...(!after && terminalNotice ? [terminalNotice] : []), ...(after && definitionOf(peer)?.cli.rosterReturnNotice ? [definitionOf(peer)!.cli.rosterReturnNotice!(codexResumeCommand())] : []), ...(!after ? handoff.map(move => sanitize(`task ${move.title}: ${move.role} assignment moved to ${move.to}`)) : []), ...stayed.map(task => sanitize(`task ${tasks.publicTitle(task)} stays with ${peer} as ${[task.owner === peer ? "owner" : "", task.reviewer === peer ? "reviewer" : ""].filter(Boolean).join(" and ")} (${task.state})`)), ...(!after && !handoff.length && !stayed.length ? ["No open tasks to hand over."] : [])].join("\n"), ...(!after ? { handoff } : {}) };
4424
+ return { ok: true, text: [`${peer}: ${after ? "on" : "off"} roster (in force now)`, ...(!after && terminalNotice ? [terminalNotice] : []), ...(after && definitionOf(peer)?.cli.rosterReturnNotice ? [definitionOf(peer)!.cli.rosterReturnNotice!(codexResumeCommand(peer))] : []), ...(!after ? handoff.map(move => sanitize(`task ${move.title}: ${move.role} assignment moved to ${move.to}`)) : []), ...stayed.map(task => sanitize(`task ${tasks.publicTitle(task)} stays with ${peer} as ${[task.owner === peer ? "owner" : "", task.reviewer === peer ? "reviewer" : ""].filter(Boolean).join(" and ")} (${task.state})`)), ...(!after && !handoff.length && !stayed.length ? ["No open tasks to hand over."] : [])].join("\n"), ...(!after ? { handoff } : {}) };
4172
4425
  } catch (error) {
4173
4426
  if (saved) writeStatus();
4174
4427
  return { ok: false, error: sanitize((error as Error).message), ...(saved ? { saved: true } : {}) };
@@ -4277,14 +4530,14 @@ export async function startDaemon(opts: DaemonOptions) {
4277
4530
  const { def, value } = input;
4278
4531
  if (def.group === "Roster") {
4279
4532
  try {
4280
- const peer = def.path[1]!, next = rosterCandidate(def, value), on = rosterAllows(next, peer, config.pi.enabled);
4533
+ const peer = def.path[1]!, next = rosterCandidate(def, value), on = rosterAllows(next, peer, config.pi.enabled, declarations);
4281
4534
  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 }) };
4282
4535
  } catch (error) { return { ok: false, error: sanitize((error as Error).message) }; }
4283
4536
  }
4284
4537
  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."] };
4285
4538
  if (def.group === "Permissions") {
4286
4539
  const peer = def.path.at(-1)!;
4287
- return { ok: true, lines: [permissionBoundary(peer), ...(isPermissionMode(value) ? [`${value}: ${permissionGrant(peer, value)}`] : ["inherit: the shared file's default, or ask"])], ...(value === "never-ask" || value === null && restoredValue(opts.cwd, opts.stateDir, def, true) === "never-ask" ? { confirm: peer } : {}) };
4540
+ 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 } : {}) };
4288
4541
  }
4289
4542
  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"}`] };
4290
4543
  try {
@@ -4413,7 +4666,7 @@ export async function startDaemon(opts: DaemonOptions) {
4413
4666
  ...(taskOpsInFlight !== 0 ? ["a task command in flight"] : []),
4414
4667
  ...(tasks.checksPending() !== 0 ? ["completion checks queued or running"] : []),
4415
4668
  ...(checkPending.size !== 0 ? ["isolated checks queued or running"] : []),
4416
- ...((piReceipts?.inFlight ?? 0) !== 0 ? ["Pi tool calls in flight"] : []),
4669
+ ...(piCallsInFlight() !== 0 ? ["Pi tool calls in flight"] : []),
4417
4670
  ...(releasing ? ["ready tasks being released"] : []),
4418
4671
  ...([...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`)),
4419
4672
  ],
@@ -4434,11 +4687,13 @@ export async function startDaemon(opts: DaemonOptions) {
4434
4687
  ? `target lacks task_cancel; cancelled tasks: ${ids.map(id => `#${id}`).join(", ")}` : undefined;
4435
4688
  };
4436
4689
  const checkTarget = msg.op === "prepare" || msg.op === "commit" || msg.op === "inspect" && msg.targetFeatures !== undefined;
4437
- if (checkTarget) { const error = incompatibleCancellationTarget(); if (error) return recoveryError(error); }
4690
+ if (checkTarget) {
4691
+ const error = incompatibleCancellationTarget(); if (error) return recoveryError(error);
4692
+ if (extraIds.length && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("peer_instances"))) return recoveryError("target lacks peer_instances; declared instance identities cannot be restored by that daemon");
4693
+ }
4438
4694
 
4439
4695
  if (msg.op === "inspect" || msg.op === "prepare" || msg.op === "commit") {
4440
- const pi = bus.peers.get("pi");
4441
- if (pi instanceof PiPeer && pi.state === "idle") {
4696
+ for (const pi of bus.peers.values()) if (pi instanceof PiPeer && pi.state === "idle") {
4442
4697
  try { await pi.captureResume(); } catch (error) { return recoveryError((error as Error).message); }
4443
4698
  if (checkTarget) { const error = incompatibleCancellationTarget(); if (error) return recoveryError(error); }
4444
4699
  }
@@ -4499,7 +4754,7 @@ export async function startDaemon(opts: DaemonOptions) {
4499
4754
  ...saved,
4500
4755
  queueIds: bus.queueIds(saved.id),
4501
4756
  // Recorded at commit, not prepare: a first turn landing in between flips it (#64).
4502
- ...(definitionOf(saved.id)?.session.missingEvidence === "transcript" && saved.sessionId ? { sessionPersisted: claudeTranscriptPersisted(saved.sessionId) } : {}),
4757
+ ...(definitionOf(saved.id)?.session.missingEvidence === "transcript" && saved.sessionId ? { sessionPersisted: claudeTranscriptPersisted(saved.id, saved.sessionId) } : {}),
4503
4758
  }));
4504
4759
  const snapshot: RestartSnapshot = {
4505
4760
  schemaVersion: 1,
@@ -4547,9 +4802,9 @@ export async function startDaemon(opts: DaemonOptions) {
4547
4802
  return recoveryError("unknown recovery operation");
4548
4803
  }
4549
4804
 
4550
- function claudePermissionLaunch(): { launchId: string; unattended: boolean; hookPurpose?: string } | undefined {
4805
+ function claudePermissionLaunch(peer = "claude"): { launchId: string; unattended: boolean; hookPurpose?: string } | undefined {
4551
4806
  try {
4552
- const row = JSON.parse(readFileSync(join(opts.stateDir, "claude-launch.json"), "utf8"));
4807
+ const row = JSON.parse(readFileSync(join(opts.stateDir, claudeStateFile("claude-launch", peer)), "utf8"));
4553
4808
  if (row.instanceId !== instanceId || row.permissionHook !== true || typeof row.launchId !== "string" || !row.launchId) return undefined;
4554
4809
  return { launchId: row.launchId, unattended: row.unattended === true, ...(typeof row.hookPurpose === "string" ? { hookPurpose: row.hookPurpose } : {}) };
4555
4810
  } catch { return undefined; }
@@ -4578,7 +4833,7 @@ export async function startDaemon(opts: DaemonOptions) {
4578
4833
  if (!isPermissionMode(mode)) return { ok: false, error: "mode must be ask, ask-when-needed or never-ask" };
4579
4834
  if (mode === "never-ask" && confirmed !== true) return { ok: false, error: "never-ask needs --yes or console confirmation" };
4580
4835
  if (definitionOf(peer)?.transport === "claude-channel") {
4581
- const launch = claudePermissionLaunch();
4836
+ const launch = claudePermissionLaunch(peer);
4582
4837
  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" };
4583
4838
  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" };
4584
4839
  } else if (owner instanceof CodexPeer) {
@@ -4646,6 +4901,7 @@ export async function startDaemon(opts: DaemonOptions) {
4646
4901
  else await owner.setPermissionMode(selected);
4647
4902
  }
4648
4903
  // The bus may immediately drain at add(): do not expose an idle adapter until its current choice is applied.
4904
+ executionOwners.set(peer, { owner, cwd: executionRoots.get(peer) ?? opts.cwd });
4649
4905
  bus.add(owner);
4650
4906
  // start() finished before the bus installed its state hook: replay the current state to drain queued work and record it.
4651
4907
  if (owner.state !== "offline") owner.onState?.(owner.state);
@@ -4746,7 +5002,7 @@ export async function startDaemon(opts: DaemonOptions) {
4746
5002
  if (!c.peer || (c.role !== "peer" && c.role !== "tools")) return void reply({ ok: false, error: "held metadata requires a peer identity" });
4747
5003
  try {
4748
5004
  const held = bus.heldDeliveryHints(c.peer);
4749
- return void reply(held ? { ok: true, held } : { ok: false, error: "held metadata unavailable" });
5005
+ return void reply(held ? { ok: true, held, queued: bus.queued(c.peer), important: bus.queuedImportant(c.peer) } : { ok: false, error: "held metadata unavailable" });
4750
5006
  } catch { return void reply({ ok: false, error: "held metadata unavailable" }); }
4751
5007
  }
4752
5008
  case "delivery_receipt": {
@@ -4758,10 +5014,10 @@ export async function startDaemon(opts: DaemonOptions) {
4758
5014
  return void reply({ ok: true });
4759
5015
  }
4760
5016
  case "inbox": {
4761
- // A Claude session that cannot show channel pushes reads its queue here; nothing it reads is ever `accepted` (issue #205).
5017
+ // The attached session can explicitly claim queued work even during a native turn (#493).
5018
+ // Pull and push share holds and journal settlement; work read here is completed, never pushed again.
4762
5019
  const peer = c.peer ? bus.peers.get(c.peer) : undefined;
4763
5020
  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" });
4764
- if (!peer.pullOnly) return void reply({ ok: false, error: "messages are pushed to this session; hub_inbox only drains failed pushes" });
4765
5021
  try {
4766
5022
  const envs = bus.pull(peer.id);
4767
5023
  if (!envs) return void reply({ ok: false, error: queueHold(peer.id) ?? (bus.isRecoveryHeld ? "recovery is holding deliveries" : pauseReason(peer.id)) });
@@ -4850,18 +5106,18 @@ export async function startDaemon(opts: DaemonOptions) {
4850
5106
  let expectedLaunch: string | undefined;
4851
5107
  try {
4852
5108
  const rows = JSON.parse(readFileSync(join(opts.stateDir, "terminal-recovery.json"), "utf8"));
4853
- expectedLaunch = Array.isArray(rows) ? rows.find(row => typeof row?.peer === "string" && definitionOf(row.peer)?.transport === "claude-channel" && row?.projectRoot === opts.cwd && row?.instanceId === instanceId)?.launchId : undefined;
5109
+ expectedLaunch = Array.isArray(rows) ? rows.find(row => row?.peer === peer && definitionOf(row.peer)?.transport === "claude-channel" && row?.projectRoot === opts.cwd && row?.instanceId === instanceId)?.launchId : undefined;
4854
5110
  } catch { /* an ordinary terminal has no Orca recovery identity */ }
4855
5111
  try {
4856
- const launch = JSON.parse(readFileSync(join(opts.stateDir, "claude-launch.json"), "utf8"));
5112
+ const launch = JSON.parse(readFileSync(join(opts.stateDir, claudeStateFile("claude-launch", peer)), "utf8"));
4857
5113
  if (launch.instanceId === instanceId && typeof launch.launchId === "string") expectedLaunch = launch.launchId;
4858
5114
  } catch { /* older/manual launches do not have a launcher marker */ }
4859
- const current = claudeSession();
5115
+ const current = claudeSession(peer);
4860
5116
  if ((phase === "stop" || phase === "post") && current.sessionId && current.sessionId !== sessionId) return void reply({ t: "facts", ok: false });
4861
5117
  if (!expectedLaunch || msg.nativeLaunchId === expectedLaunch) {
4862
- const transcript = claudeTranscript(sessionId, msg.transcriptPath);
5118
+ const transcript = claudeTranscript(sessionId, msg.transcriptPath, peer);
4863
5119
  const launchId = typeof msg.nativeLaunchId === "string" ? msg.nativeLaunchId : undefined;
4864
- const file = join(opts.stateDir, "claude-session.json");
5120
+ const file = join(opts.stateDir, claudeStateFile("claude-session", peer));
4865
5121
  writeFileSync(`${file}.hook.tmp`, JSON.stringify({ at: Date.now(), instanceId, sessionId, ...(launchId ? { launchId } : {}), ...(transcript ? { transcriptPath: transcript } : {}) }), { mode: 0o600 });
4866
5122
  chmodSync(`${file}.hook.tmp`, 0o600); renameSync(`${file}.hook.tmp`, file);
4867
5123
  nativeClaude = true;
@@ -4870,7 +5126,7 @@ export async function startDaemon(opts: DaemonOptions) {
4870
5126
  }
4871
5127
  if (definitionOf(peer)?.transport === "claude-channel" && msg.nativeInstanceId !== undefined && msg.nativeInstanceId !== instanceId) return void reply({ t: "facts", ok: false });
4872
5128
  const nativePeer = nativeClaude ? bus.peers.get(peer) : undefined;
4873
- const permissionLaunch = definitionOf(peer)?.transport === "claude-channel" ? claudePermissionLaunch() : undefined;
5129
+ const permissionLaunch = definitionOf(peer)?.transport === "claude-channel" ? claudePermissionLaunch(peer) : undefined;
4874
5130
  const permission = nativeClaude && permissionLaunch && !permissionLaunch.unattended && msg.nativeLaunchId === permissionLaunch.launchId ? permissionMode(peer) : undefined;
4875
5131
  if (permission !== undefined && nativePeer instanceof WsPeer) claudePermissionGenerations.set(peer, nativePeer.sessionGeneration);
4876
5132
  const protectedWrite = scopedWriteBlocked(phase, permission, msg.tool, () => projectFileTool(msg.tool, msg.input, opts.cwd, config.protect_instruction_files, config.data?.readonly));
@@ -4882,10 +5138,10 @@ export async function startDaemon(opts: DaemonOptions) {
4882
5138
  if (phase === "session") { recordSessions(); return void reply({ t: "facts", ok: true }); }
4883
5139
  if ((phase === "start" || phase === "pre") && nativePeer instanceof WsPeer) {
4884
5140
  const previous = nativeTurnStart.get(peer);
4885
- const current = claudeSession();
5141
+ const current = claudeSession(peer);
4886
5142
  if (phase === "start" || nativePeer.state !== "busy" || previous?.generation !== nativePeer.sessionGeneration || previous?.session !== sessionId || previous?.launch !== (current.launchId ?? "")) {
4887
- const session = claudeSession();
4888
- const baseline = session.sessionId && session.transcriptPath ? readClaudeTranscriptUsage(session.sessionId, session.transcriptPath).filter(record => record.completedTurn).at(-1)?.id : undefined;
5143
+ const session = claudeSession(peer);
5144
+ const baseline = session.sessionId && session.transcriptPath ? readClaudeTranscriptCompletion(session.sessionId, session.transcriptPath)?.id : undefined;
4889
5145
  nativeTurnStart.set(peer, { at: Date.now(), ...(baseline ? { baseline } : {}), generation: nativePeer.sessionGeneration, session: sessionId!, launch: session.launchId ?? "" });
4890
5146
  }
4891
5147
  nativePeer.nativeStarted(sessionId!);
@@ -4904,12 +5160,12 @@ export async function startDaemon(opts: DaemonOptions) {
4904
5160
  event({ type: "hook_stats", peer, n: st.n, startupMs: Math.round(st.startupMs), hubMs: Math.round(st.hubMs), maxStartupMs: Math.round(st.maxStartupMs) });
4905
5161
  hookStats.delete(peer);
4906
5162
  if (id && nativePeer instanceof WsPeer) nativePeer.nativeStopped(sessionId!);
4907
- if (factsOn()) { factSession(peer, sessionId); readbacks(peer, claudeTranscript(sessionId, msg.transcriptPath)); }
5163
+ if (factsOn()) { factSession(peer, sessionId); readbacks(peer, claudeTranscript(sessionId, msg.transcriptPath, peer)); }
4908
5164
  };
4909
5165
  if (!nativeClaude) { finishStop(); reply({ t: "facts", ok: true }); return; } // legacy bookkeeping cannot certify native completion
4910
- const session = claudeSession();
4911
- let transcript = session.transcriptPath ?? claudeTranscript(sessionId, msg.transcriptPath);
4912
- const readCompletion = () => { transcript ??= claudeTranscript(sessionId, msg.transcriptPath); return session.sessionId && transcript ? readClaudeTranscriptUsage(session.sessionId, transcript).filter(record => record.completedTurn).at(-1) : undefined; };
5166
+ const session = claudeSession(peer);
5167
+ let transcript = session.transcriptPath ?? claudeTranscript(sessionId, msg.transcriptPath, peer);
5168
+ const readCompletion = () => { transcript ??= claudeTranscript(sessionId, msg.transcriptPath, peer); return session.sessionId && transcript ? readClaudeTranscriptCompletion(session.sessionId, transcript) : undefined; };
4913
5169
  const completionId = (id: string) => createHash("sha256").update(`${sessionId}:${session.launchId ?? "legacy"}:${id}`).digest("hex");
4914
5170
  const started = nativeTurnStart.get(peer);
4915
5171
  const activeStart = !!started && started.session === sessionId && started.launch === (session.launchId ?? "") && nativePeer instanceof WsPeer && started.generation === nativePeer.sessionGeneration;
@@ -4920,7 +5176,7 @@ export async function startDaemon(opts: DaemonOptions) {
4920
5176
  }
4921
5177
  if (!started || !activeStart || !(nativePeer instanceof WsPeer)) { log("native Stop refused for claude: current native turn start unavailable"); return void reply({ t: "facts", ok: false }); }
4922
5178
  const scopeCurrent = () => {
4923
- const now = claudeSession();
5179
+ const now = claudeSession(peer);
4924
5180
  return !stopping && bus.peers.get(peer) === nativePeer && nativePeer.sessionGeneration === started.generation && now.sessionId === started.session && (now.launchId ?? "") === started.launch;
4925
5181
  };
4926
5182
  const current = () => scopeCurrent() && nativeTurnStart.get(peer) === started;
@@ -4935,14 +5191,20 @@ export async function startDaemon(opts: DaemonOptions) {
4935
5191
  if (nativeStopWaits.get(peer) === ownWait) nativeStopWaits.delete(peer);
4936
5192
  if (!result.record) {
4937
5193
  const reason = ({ unavailable: "completed transcript message unavailable", baseline: "completion still matches start baseline", "before-start": "completion timestamp precedes native start", superseded: "native start superseded during completion wait", verified: "completed transcript message unavailable" } as const)[result.reason];
4938
- log(`native Stop refused for claude: ${reason}`); return;
5194
+ log(`native Stop refused for claude: ${reason}`);
5195
+ // #517: a peer left busy receives no pushed messages; say so where the person looks, once per turn.
5196
+ if (result.reason === "unavailable" && !stopUnconfirmedNoticed.has(started)) {
5197
+ stopUnconfirmedNoticed.add(started);
5198
+ notify(`${peer}: its turn end could not be confirmed from its transcript, so it stays busy and messages to it wait in its queue (hub_inbox reads them)`);
5199
+ }
5200
+ return;
4939
5201
  }
4940
5202
  const id = completionId(result.record.id);
4941
5203
  if (scopeCurrent() && nativeStopsSeen.has(id)) return;
4942
5204
  if (!current()) { log("native Stop refused for claude: native start superseded during completion wait"); return; }
4943
5205
  nativeStopsSeen.add(id); nativeTurnStart.delete(peer); // consume before idle admits another native turn
4944
5206
  if (transcript && !session.transcriptPath) {
4945
- const file = join(opts.stateDir, "claude-session.json");
5207
+ const file = join(opts.stateDir, claudeStateFile("claude-session", peer));
4946
5208
  writeFileSync(`${file}.hook.tmp`, JSON.stringify({ at: Date.now(), instanceId, sessionId, launchId: session.launchId, transcriptPath: transcript }), { mode: 0o600 });
4947
5209
  chmodSync(`${file}.hook.tmp`, 0o600); renameSync(`${file}.hook.tmp`, file);
4948
5210
  }
@@ -4956,16 +5218,16 @@ export async function startDaemon(opts: DaemonOptions) {
4956
5218
  if (!factsOn()) return void reply({ t: "facts", ok: true, ...permissionReply });
4957
5219
  if (definitionOf(peer)?.transport === "claude-channel" && phase === "pre") observeProgress(peer, normalizeClaudeObservation(tool, input));
4958
5220
  factSession(peer, sessionId);
4959
- const transcript = claudeTranscript(sessionId, msg.transcriptPath);
5221
+ const transcript = claudeTranscript(sessionId, msg.transcriptPath, peer);
4960
5222
  if (phase === "post") {
4961
- facts.postTool(peer, toolUseId, tool, input);
5223
+ factsFor(peer).postTool(peer, toolUseId, tool, input);
4962
5224
  readbacks(peer, transcript);
4963
5225
  return void reply({ t: "facts", ok: true });
4964
5226
  }
4965
5227
  readbacks(peer, transcript);
4966
5228
  checkCapability(peer);
4967
5229
  const started = performance.now();
4968
- facts.preTool(peer, toolUseId, tool, input);
5230
+ factsFor(peer).preTool(peer, toolUseId, tool, input);
4969
5231
  // Without a transcript nothing offered could ever be read back: a verified path is lost, and nothing is offered
4970
5232
  // (the same diff would otherwise go in at every tool call).
4971
5233
  if (!transcript) {
@@ -5031,6 +5293,23 @@ export async function startDaemon(opts: DaemonOptions) {
5031
5293
  if (msg.op === "export") {
5032
5294
  try { return void reply({ ok: true, export: tasks.workflowExport(msg.id) }); } catch { return void reply({ ok: false, error: "workflow export unavailable" }); }
5033
5295
  }
5296
+ if (msg.op === "accept") {
5297
+ // #481 S1: the person's spec acceptance, bound to the issue's body edit time; no body is read.
5298
+ if (!Number.isSafeInteger(msg.id) || msg.id <= 0 || !Number.isSafeInteger(msg.issue) || msg.issue <= 0 || (msg.repo !== undefined && typeof msg.repo !== "string")) return void reply({ ok: false, error: "usage: ahub sdlc accept <task> --issue <n> [--repo owner/repository]" });
5299
+ const task = board.get(msg.id);
5300
+ const repo = msg.repo ?? task?.sdlc?.repo;
5301
+ if (!task || typeof repo !== "string") return void reply({ ok: false, error: task ? "spec acceptance needs --repo owner/repository (the task's receipt names none)" : "invalid lifecycle task id" });
5302
+ const linked = `${task.sdlc?.repo ?? ""}#${task.sdlc?.issue ?? ""}`;
5303
+ void readSpecEdit(repo, msg.issue, (args, timeout, _cwd, bound) => sdlcGh(args, timeout, opts.cwd, bound)).then(edit => {
5304
+ if (!edit) return reply({ ok: false, error: "issue edit time unavailable; nothing was recorded" });
5305
+ // The readback is for the receipt read before it; a relink meanwhile makes it someone else's evidence.
5306
+ const now = board.get(msg.id)?.sdlc;
5307
+ if (`${now?.repo ?? ""}#${now?.issue ?? ""}` !== linked) return reply({ ok: false, error: "the task's spec link changed during the readback; nothing was recorded, run ahub sdlc accept again" });
5308
+ try { const next = tasks.acceptSpec(USER, msg.id, { repo, issue: msg.issue, edit }); reply({ ok: true, acceptance: next.sdlc?.acceptance }); }
5309
+ catch (error) { reply({ ok: false, error: (error as Error).message }); }
5310
+ }, () => reply({ ok: false, error: "issue edit time unavailable; nothing was recorded" }));
5311
+ return;
5312
+ }
5034
5313
  if (msg.op !== "status" && msg.op !== "explain") return void reply({ ok: false, error: "unknown SDLC operation" });
5035
5314
  const selected = msg.id === undefined ? board.list().filter(task => task.state !== "backlog" && (task.sdlc || lifecycleConfig.mode !== "off" || lifecycleConfig.workflow === "gsd-lite")) : [board.get(Number(msg.id))].filter((task): task is Task => !!task);
5036
5315
  if (msg.id !== undefined && (!Number.isSafeInteger(msg.id) || msg.id <= 0 || !selected.length)) return void reply({ ok: false, error: "invalid lifecycle task id" });
@@ -5235,9 +5514,9 @@ export async function startDaemon(opts: DaemonOptions) {
5235
5514
  // logged and shutdown finishes anyway, or the process ignores SIGTERM forever.
5236
5515
  for (const exit of exits) if (exit.status === "rejected") log(`peer stop failed during shutdown: ${(exit.reason as Error)?.message ?? exit.reason}`);
5237
5516
  await runnerStopped;
5238
- await modelRelay?.close();
5517
+ await Promise.all([...modelRelays.values()].map(relay => relay.close()));
5239
5518
  await egress?.close();
5240
- await piReceipts?.close();
5519
+ await Promise.all([...piReceiptLedgers.values()].map(ledger => ledger.close()));
5241
5520
  collectClaudeUsage();
5242
5521
  for (const id of [...researchPending.keys()]) writeResearch(id); // the peers have stopped, so their turns have ended
5243
5522
  budget.close();