@staix/agent-hub 0.12.34 → 0.12.35

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 (76) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +3 -3
  3. package/docs/agent-notes/adapters.md +2 -0
  4. package/docs/agent-notes/budget.md +3 -1
  5. package/docs/agent-notes/bus.md +2 -1
  6. package/docs/agent-notes/daemon.md +5 -1
  7. package/docs/agent-notes/local-worker.md +1 -1
  8. package/docs/agent-notes/tasks.md +4 -1
  9. package/docs/gsd-collaboration-analysis.md +5 -1
  10. package/docs/operations.md +67 -13
  11. package/docs/quickstart.md +8 -2
  12. package/docs/sdlc.md +59 -34
  13. package/docs/security.md +3 -3
  14. package/docs/specs/2026-09-19-agent-hub-design.md +161 -6
  15. package/docs/verification/2026-10-18-0.12.35.md +49 -0
  16. package/docs/verification/2026-10-18-493-claude-busy-channel.md +91 -0
  17. package/docs/verified.json +21 -16
  18. package/package.json +1 -1
  19. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  20. package/plugins/agent-hub/server.js +15438 -14863
  21. package/src/adapters/acp.ts +3 -1
  22. package/src/adapters/claude-channel.ts +59 -29
  23. package/src/adapters/codex-appserver.ts +1 -1
  24. package/src/adapters/definitions/native-cli-entry-codex.ts +13 -5
  25. package/src/adapters/definitions/native-cli-entry-jcode.ts +4 -1
  26. package/src/adapters/definitions/native-cli-entry-kimi.ts +16 -11
  27. package/src/adapters/definitions/native-cli-entry-local.ts +3 -2
  28. package/src/adapters/definitions/native-cli-entry-pi.ts +8 -6
  29. package/src/adapters/definitions/native-cli-entry-qwen.ts +3 -2
  30. package/src/adapters/definitions/native-cli-entry-types.ts +1 -0
  31. package/src/adapters/definitions/native-recovery.ts +2 -2
  32. package/src/adapters/definitions/qwen-native.ts +2 -2
  33. package/src/adapters/local-worker.ts +3 -2
  34. package/src/adapters/pi.ts +3 -1
  35. package/src/adapters/registry-types.ts +3 -0
  36. package/src/adapters/registry.ts +24 -8
  37. package/src/cli/identity.ts +3 -3
  38. package/src/cli/launch.ts +29 -4
  39. package/src/cli/main.ts +101 -28
  40. package/src/cli/preview.ts +16 -5
  41. package/src/cli/recovery-guidance.ts +39 -22
  42. package/src/cli/terminal-recovery.ts +68 -16
  43. package/src/cli/upgrade-restore-state.ts +16 -7
  44. package/src/cli/upgrade-runtime.ts +67 -39
  45. package/src/cli/upgrade.ts +43 -27
  46. package/src/hub/budget.ts +55 -3
  47. package/src/hub/bus.ts +1 -1
  48. package/src/hub/conductor.ts +16 -10
  49. package/src/hub/config-apply.ts +13 -2
  50. package/src/hub/config-reload.ts +11 -1
  51. package/src/hub/config-trust.ts +1 -1
  52. package/src/hub/control-client.ts +1 -1
  53. package/src/hub/crash.ts +14 -5
  54. package/src/hub/daemon.ts +296 -125
  55. package/src/hub/execution-budget.ts +3 -3
  56. package/src/hub/hub-tools.ts +17 -6
  57. package/src/hub/instance-budget.ts +73 -0
  58. package/src/hub/instance-config.ts +47 -0
  59. package/src/hub/instance-execution.ts +50 -0
  60. package/src/hub/instances.ts +100 -0
  61. package/src/hub/lifecycle-schema.ts +7 -3
  62. package/src/hub/permission-mode.ts +7 -7
  63. package/src/hub/roster.ts +7 -6
  64. package/src/hub/routing.ts +82 -18
  65. package/src/hub/sdlc-queue.ts +2 -2
  66. package/src/hub/sdlc.ts +84 -38
  67. package/src/hub/settings.ts +34 -6
  68. package/src/hub/snapshots.ts +40 -9
  69. package/src/hub/start-mode.ts +7 -4
  70. package/src/hub/status-roster.ts +8 -0
  71. package/src/hub/tasks.ts +53 -23
  72. package/src/hub/text-projection.ts +7 -3
  73. package/src/hub/workflow.ts +30 -52
  74. package/src/pi/launch.ts +4 -3
  75. package/src/ui/index.html +1 -1
  76. package/templates/AGENTS.block.md +2 -1
package/src/hub/daemon.ts CHANGED
@@ -25,6 +25,11 @@ import { KNOWN_PEERS, OPTIONAL_PEERS, PLANNER_PEERS, SUPPORTED_PEERS, isSupporte
25
25
  import { RecoveryDeferredHubError } from "./deferred-hub.ts";
26
26
  import { databaseBusySite, isDatabaseBusy, RecoveryDatabaseBusy } from "./recovery-error.ts";
27
27
  import { ADAPTERS, QUOTA_HANDOFF_PLATFORMS, NATIVE_CONFIG_BLOCKS, nativeConfigurationDefaults, mergeNativeConfiguration, retainNativeRouting, DAEMON_PORTS, definitionOf } from "../adapters/registry.ts";
28
+ 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";
29
+ import { effectiveInstanceRoles, instancePeerIds, validateInstances, type InstancesConfig } from "./instances.ts";
30
+ import { INSTANCE_CONFIG_FILES, readInstanceConfig, readInstanceConfigLayers, readInstanceRoleConfig } from "./instance-config.ts";
31
+ import { instanceBudgetHooks, projectBudgetHeadroom } from "./instance-budget.ts";
32
+ import { resolveInstanceExecutionCwd } from "./instance-execution.ts";
28
33
  import { ProgressObserver, normalizeCodexObservation, normalizeClaudeObservation } from "./progress.ts";
29
34
  import type { ToolObservation } from "../models/route/signals.ts";
30
35
  import { createHash, randomBytes, randomUUID } from "node:crypto";
@@ -78,7 +83,7 @@ import { AssignmentUndeliveredError, ESCALATE_AFTER, Tasks } from "./tasks.ts";
78
83
  import { DEFAULT_INFERENCE, DIGEST, Inference, screenPii, type InferenceConfig } from "./inference.ts";
79
84
  import { ask, ASK_NOTE_TITLE, RUN_START } from "./ask.ts";
80
85
  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";
86
+ 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
87
  import { Bus } from "./bus.ts";
83
88
  import { attribute, deliveryTask, type TaskAttribution } from "./attribution.ts";
84
89
  import { DeliveryJournal } from "./delivery-journal.ts";
@@ -118,6 +123,8 @@ import { nativeRecall } from "../memory/recall.ts";
118
123
  import { promotionScreen, withoutPii, mentionsPrivatePath } from "./memory-policy.ts";
119
124
 
120
125
  export interface HubConfig {
126
+ /** Person-owned declarations, read only from an untracked machine-local file. Bound for this daemon lifetime. */
127
+ instances?: InstancesConfig;
121
128
  watchdog_ms: number;
122
129
  kimi_cmd: string[];
123
130
  qwen_cmd: string[];
@@ -233,11 +240,11 @@ export const DEFAULT_CONFIG: HubConfig = {
233
240
  export { stateDirFor };
234
241
 
235
242
  /** 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]);
243
+ const BASE_RESERVED_IDS = new Set<string>([USER, ...SUPPORTED_PEERS.filter(peer => definitionOf(peer)?.identity.mcp !== "channel-plugin"), HUB, DIGEST]);
237
244
  const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
238
245
 
239
246
  /** 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;
247
+ const CONFIG_FILES = INSTANCE_CONFIG_FILES;
241
248
  export interface ConfigLayer { name: typeof CONFIG_FILES[number]; raw: Record<string, any> }
242
249
  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
250
 
@@ -285,18 +292,22 @@ function syncMinutes(v: unknown): number {
285
292
  }
286
293
 
287
294
  export function loadConfig(cwd: string, layers?: readonly ConfigLayer[]): HubConfig {
288
- const ignored: string[] = [];
295
+ const captured = layers ?? readInstanceConfigLayers(cwd);
296
+ const instanceConfig = readInstanceConfig(cwd, captured);
297
+ const instances = instanceConfig.instances;
298
+ const ignored: string[] = [...instanceConfig.ignored];
289
299
  const appliedPermissionSources: NonNullable<HubConfig["permission_default_sources"]> = {};
290
300
  const files = CONFIG_FILES.flatMap((name) => {
291
301
  let file: Record<string, any>; // parsed JSON, checked field by field below
292
- if (layers && !layers.some(layer => layer.name === name)) return [];
302
+ if (!captured.some(layer => layer.name === name)) return [];
293
303
  try {
294
- file = layers ? structuredClone(layers.find(layer => layer.name === name)!.raw) : JSON.parse(readFileSync(join(cwd, ".agenthub", name), "utf8"));
304
+ file = structuredClone(captured.find(layer => layer.name === name)!.raw);
295
305
  } catch (error) {
296
306
  if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
297
307
  throw error;
298
308
  }
299
- peerRosterConfig(file.roster);
309
+ delete file.instances; // Declaration authority was checked against its exact captured origin above.
310
+ peerRosterConfig(file.roster, instances);
300
311
  catalogAssignments(file.catalog_roles);
301
312
  profileName(file.profile);
302
313
  resolveProfile(file.profile, file.profile_overrides);
@@ -311,14 +322,14 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[]): HubCon
311
322
  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
323
  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
324
  instructionProtection(file.protect_instruction_files);
314
- permissionDefaults(file.permission_modes); // malformed declarations fail even when trust would drop them
315
- peerStartConfig(file.peers);
325
+ permissionDefaults(file.permission_modes, instances); // malformed declarations fail even when trust would drop them
326
+ peerStartConfig(file.peers, instances);
316
327
  if (file.terminal != null && (typeof file.terminal !== "object" || Array.isArray(file.terminal))) throw new Error("terminal must be an object");
317
328
  terminalTemplate(file.terminal?.open);
318
329
  delete file.permission_default_sources; // provenance is loader-owned, never a file claim
319
330
  const why = stripUntrusted(file, DEFAULT_CONFIG, cwd, name);
320
331
  if (why) ignored.push(why);
321
- for (const [peer, mode] of Object.entries(permissionDefaults(file.permission_modes))) appliedPermissionSources[peer] = { mode, source: `.agenthub/${name}` };
332
+ for (const [peer, mode] of Object.entries(permissionDefaults(file.permission_modes, instances))) appliedPermissionSources[peer] = { mode, source: `.agenthub/${name}` };
322
333
  return [file];
323
334
  });
324
335
  if (!files.length) return DEFAULT_CONFIG;
@@ -346,23 +357,34 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[]): HubCon
346
357
  throw new Error("legacy MLX configuration requires explicit mlx.provider=legacy; migrate to provider=ollama to avoid Python serving");
347
358
  }
348
359
  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);
360
+ const conductorPolicy = loadConductorPolicy(cwd, captured);
361
+ const extraRoles = Object.fromEntries(Object.keys(instances ?? {}).map(id => [id, effectiveInstanceRoles(id, instances, conductorPolicy.roles)]));
362
+ const instancePermissions: Record<string, PermissionMode> = {};
363
+ const instanceModes: PeerStartConfig = {};
364
+ for (const [id, declaration] of Object.entries(instances ?? {})) {
365
+ instanceModes[id] = { start_mode: declaration.start_mode ?? "headless" };
366
+ if (declaration.permission_mode !== undefined) {
367
+ instancePermissions[id] = declaration.permission_mode;
368
+ appliedPermissionSources[id] = { mode: declaration.permission_mode, source: ".agenthub/config.local.json" };
369
+ }
370
+ }
350
371
  if (!mlx.enabled && file.pi?.backend === "mlx") throw new Error("Pi backend mlx conflicts with mlx.enabled=false");
351
372
  if (typeof mlx.runtimeDir === "string" && mlx.runtimeDir) mlx.runtimeDir = resolve(cwd, mlx.runtimeDir);
352
373
  if (typeof mlx.modelPath === "string" && mlx.modelPath) mlx.modelPath = resolve(cwd, mlx.modelPath);
353
374
  return {
354
375
  ...DEFAULT_CONFIG,
355
376
  ...file,
377
+ ...(instances ? { instances } : {}),
356
378
  ...(file.text_projection !== undefined ? { text_projection: projectionConfig(file.text_projection) } : {}),
357
379
  ...(file.pii !== undefined ? { pii: { paths: policyPaths(file.pii.paths, "pii.paths") } } : {}),
358
380
  ...(file.data !== undefined ? { data: { readonly: policyPaths(file.data.readonly, "data.readonly") } } : {}),
359
- permission_modes: permissionDefaults(file.permission_modes),
381
+ permission_modes: { ...instancePermissions, ...permissionDefaults(file.permission_modes, instances) },
360
382
  protect_instruction_files: instructionProtection(file.protect_instruction_files),
361
383
  permission_default_sources: appliedPermissionSources,
362
384
  memory: { ...DEFAULT_CONFIG.memory, native: true, ...file.memory, ...(file.memory?.short_ttl_days !== undefined ? { short_ttl_days: ttlDays(file.memory.short_ttl_days) } : {}),
363
385
  ...(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,
386
+ roster: peerRosterConfig(file.roster, instances),
387
+ roles: { ...conductorPolicy.roles, ...extraRoles },
366
388
  planner: conductorPolicy.planner,
367
389
  conductor: conductorPolicy.conductor,
368
390
  budget: { ...DEFAULT_CONFIG.budget, wait_max_min: 30, ...file.budget }, // on with any project config (issue #36)
@@ -380,7 +402,7 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[]): HubCon
380
402
  snapshots: { ...DEFAULT_CONFIG.snapshots, enabled: true, ...file.snapshots },
381
403
  research: { enabled: file.research?.enabled === true }, // opt-in: anything but true is off
382
404
  bench: { enabled: file.bench?.enabled === true }, // opt-in: a run resets this project's work tree
383
- peers: peerStartConfig(file.peers),
405
+ peers: { ...instanceModes, ...peerStartConfig(file.peers, instances) },
384
406
  terminal: { open: terminalTemplate(file.terminal?.open) },
385
407
  limits: { ...PROJECT_LIMITS, ...file.limits }, // on with any project config (issue #38)
386
408
  review: { ...DEFAULT_CONFIG.review, ...file.review },
@@ -473,6 +495,7 @@ class WsPeer extends BasePeer {
473
495
  private nativeActive = false;
474
496
  /** The attached session said it cannot show channel pushes (issue #205): the bus keeps its queue for `inbox`. */
475
497
  pullOnly = false;
498
+ steer: PeerAdapter["steer"] = undefined;
476
499
  lastInboxAt: number | undefined;
477
500
  private claimedPullOnly = false;
478
501
  /** Called at hello, before the async preface: the newest hello wins even if an older one's recall finishes last. */
@@ -500,6 +523,12 @@ class WsPeer extends BasePeer {
500
523
  this.nativeSession = undefined;
501
524
  this.nativeActive = false;
502
525
  this.pullOnly = this.claimedPullOnly; // before idle: the idle transition drains
526
+ // Native TUI readback in #493 proves important channel input can be consumed before the turn ends.
527
+ // Pull-only transports expose no steer capability; the bus keeps their important work queued.
528
+ this.steer = this.pullOnly ? undefined : async (envs, deliveryId) => {
529
+ 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");
530
+ await this.deliver(envs, deliveryId);
531
+ };
503
532
  this.lastInboxAt = undefined;
504
533
  this.setState("idle");
505
534
  }
@@ -507,6 +536,7 @@ class WsPeer extends BasePeer {
507
536
  if (this.claimed === sock) this.claimed = undefined;
508
537
  if (this.sock !== sock) return;
509
538
  this.sock = undefined;
539
+ this.steer = undefined;
510
540
  this.nativeActive = false;
511
541
  this.pullOnly = false;
512
542
  this.setState("offline");
@@ -516,6 +546,7 @@ class WsPeer extends BasePeer {
516
546
  this.sock?.close(4000, "peer is off roster; retry when a person adds it");
517
547
  this.claimed = undefined;
518
548
  this.sock = undefined;
549
+ this.steer = undefined;
519
550
  this.nativeActive = false;
520
551
  this.pullOnly = false;
521
552
  this.setState("offline");
@@ -567,16 +598,49 @@ export async function startDaemon(opts: DaemonOptions) {
567
598
  let ready = false;
568
599
  try {
569
600
  const loadedConfig = opts.config ?? loadConfig(opts.cwd);
570
- const lifecycleConfig = loadSdlc(opts.cwd);
601
+ const lifecycleConfig = loadSdlc(opts.cwd, VERSION);
571
602
  // Preserve embedding-owned policy references, but never publish reloads into shared defaults.
572
603
  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 } };
604
+ const declarations = validateInstances(loadedConfig.instances);
605
+ const extraIds = Object.keys(declarations);
606
+ const executionRoots = new Map<string, string>();
607
+ const executionOwners = new Map<string, { owner: PeerAdapter; cwd: string }>();
608
+ const roles = { ...loadedConfig.roles };
609
+ const permissionModesFromInstances: Record<string, PermissionMode> = {};
610
+ const startModesFromInstances: PeerStartConfig = {};
611
+ const instancePermissionSources: NonNullable<HubConfig["permission_default_sources"]> = {};
612
+ for (const [id, declaration] of Object.entries(declarations)) {
613
+ roles[id] = effectiveInstanceRoles(id, declarations, loadedConfig.roles);
614
+ startModesFromInstances[id] = { start_mode: "headless" };
615
+ if (declaration.permission_mode !== undefined) {
616
+ permissionModesFromInstances[id] = declaration.permission_mode;
617
+ instancePermissionSources[id] = { mode: declaration.permission_mode, source: ".agenthub/config.local.json" };
618
+ }
619
+ }
620
+ const config: HubConfig = { ...ownedConfig, ...(extraIds.length ? { instances: declarations } : {}),
621
+ roster: peerRosterConfig(loadedConfig.roster, declarations),
622
+ permission_modes: { ...permissionModesFromInstances, ...permissionDefaults(loadedConfig.permission_modes, declarations) },
623
+ peers: { ...startModesFromInstances, ...peerStartConfig(loadedConfig.peers, declarations) },
624
+ ...(extraIds.length ? { permission_default_sources: { ...instancePermissionSources, ...loadedConfig.permission_default_sources } } : {}),
625
+ roles, conductor: { ...DEFAULT_CONFIG.conductor, ...loadedConfig.conductor } };
626
+ // The resolver and catalogs are bound to this daemon, never published into the process-global builtin registry.
627
+ const definitionOf = (peer: string) => instanceDefinitionOf(peer, declarations);
628
+ const KNOWN_PEERS: readonly string[] = [...BASE_KNOWN_PEERS, ...extraIds];
629
+ const SUPPORTED_PEERS: readonly string[] = [...BASE_SUPPORTED_PEERS, ...extraIds];
630
+ const STARTABLE_PEERS: readonly string[] = [...BASE_STARTABLE_PEERS, ...extraIds];
631
+ const isSupportedPeer = (peer: unknown): peer is string => typeof peer === "string" && definitionOf(peer) !== undefined;
632
+ const isStartablePeer = (peer: unknown): peer is string => typeof peer === "string" && definitionOf(peer)?.start.conductorStartable === true;
633
+ const RESERVED_IDS = new Set([...BASE_RESERVED_IDS, ...extraIds]);
634
+ const SETTINGS = projectSettings(declarations);
635
+ const settingDef = (key: unknown) => instanceSettingDef(key, declarations);
636
+ const accountOf = (peer: string) => definitionOf(peer)?.budget?.account ?? peer;
637
+ const accountMembers = (account: string) => SUPPORTED_PEERS.filter(peer => accountOf(peer) === account);
574
638
  let rosterChanging: string | undefined;
575
639
  let kimiSessionMode: StartMode | undefined;
576
640
  let kimiAdmissionId: string | undefined;
577
641
  let kimiAdmissionExpiry: ReturnType<typeof setTimeout> | undefined;
578
642
  const clearKimiAdmission = () => { clearTimeout(kimiAdmissionExpiry); kimiAdmissionExpiry = undefined; kimiSessionMode = undefined; kimiAdmissionId = undefined; };
579
- const onRoster = (peer: string) => rosterAllows(config.roster, peer, config.pi.enabled);
643
+ const onRoster = (peer: string) => rosterAllows(config.roster, peer, config.pi.enabled, declarations);
580
644
  // pi.enabled historically gates starts, not retained queues or tools sockets. Only an explicit roster.pi changes those.
581
645
  const membershipAllowed = (peer: string) => definitionOf(peer)?.enabledSwitchStartOnly && config.roster[peer] === undefined ? true : onRoster(peer);
582
646
  conductorPeer(config.roles); // reject ambiguous leadership before opening a control listener
@@ -621,6 +685,7 @@ export async function startDaemon(opts: DaemonOptions) {
621
685
  settlePlanner();
622
686
  const catalog = loadRoleCatalog(opts.cwd);
623
687
  for (const error of catalog.errors) log(`catalog role ${error.id}: ${error.reason}`);
688
+ for (const note of lifecycleConfig.deprecations ?? []) log(note);
624
689
  const resolvedProfile = resolveProfile(config.profile, config.profile_overrides);
625
690
  let roleAssignments = { ...resolvedProfile.catalog_roles, ...catalogAssignments(config.catalog_roles) };
626
691
  const event = eventLog(join(opts.stateDir, "events.jsonl"), (entry) => {
@@ -1036,7 +1101,29 @@ export async function startDaemon(opts: DaemonOptions) {
1036
1101
 
1037
1102
  const omni = new OmniRoute(config.omniroute, log);
1038
1103
  let modelRelay: ModelRelay | undefined;
1039
- let piReceipts: PiToolReceipts | undefined;
1104
+ const piReceiptLedgers = new Map<string, PiToolReceipts>();
1105
+ const modelRelays = new Map<string, ModelRelay>();
1106
+ const globalModelRelay = (relay: ModelRelay) => { modelRelay = relay; };
1107
+ const nativeStateDir = (peer: string) => declarations[peer] ? join(opts.stateDir, "instances", peer) : opts.stateDir;
1108
+ const instanceAppPorts = new Map<string, number>();
1109
+ const instanceAppPort = (peer: string): number => {
1110
+ const prior = instanceAppPorts.get(peer);
1111
+ if (prior !== undefined) return prior;
1112
+ // The native server must bind its selected port itself. A competing bind is refused by its existing ownership probe.
1113
+ const held: { stop(closeActiveConnections?: boolean): void }[] = [];
1114
+ try {
1115
+ for (let attempt = 0; attempt <= instanceAppPorts.size + 2; attempt++) {
1116
+ const listener = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
1117
+ held.push(listener);
1118
+ const port = listener.port;
1119
+ if (port !== undefined && port !== opts.codexAppPort && port !== opts.codexProxyPort && ![...instanceAppPorts.values()].includes(port)) {
1120
+ instanceAppPorts.set(peer, port); return port;
1121
+ }
1122
+ }
1123
+ throw new Error("no distinct instance app-server port available");
1124
+ } finally { for (const listener of held) listener.stop(true); }
1125
+ };
1126
+ const piCallsInFlight = () => [...piReceiptLedgers.values()].reduce((n, ledger) => n + ledger.inFlight, 0);
1040
1127
  /**
1041
1128
  * PII decisions need a positive answer about the path a hub-owned model call will really take: a gateway that
1042
1129
  * answered and is not behind Access, and no sidecar that was generated against the off-campus URL. Unknown is no.
@@ -1086,7 +1173,7 @@ export async function startDaemon(opts: DaemonOptions) {
1086
1173
  if (before && (before !== after || config.conductor.feed === "off")) { bus.revokeSupervision(before); supervision.resetPending(before); budgetFeedSeen.clear(); }
1087
1174
  if (config.conductor.feed === "off" && after) bus.revokeSupervision(after);
1088
1175
  };
1089
- const executionBudget = new ExecutionBudget(join(opts.stateDir, "hub.db"));
1176
+ const executionBudget = new ExecutionBudget(join(opts.stateDir, "hub.db"), declarations);
1090
1177
  startupCleanup.push(() => executionBudget.close());
1091
1178
  // Completion checks (issue #7): only from a config file git does not track, one at a time, killed on stop.
1092
1179
  const allowedChecks = new Set([...CLASSES, ...Object.keys(resolvedProfile.classes)]);
@@ -1191,6 +1278,13 @@ export async function startDaemon(opts: DaemonOptions) {
1191
1278
  };
1192
1279
  const tasks: Tasks = new Tasks({
1193
1280
  capabilities: peerCapabilities, repository: memoryRepo,
1281
+ instances: () => declarations,
1282
+ // Proof belongs to the adapter generation actually started in the validated root. A role-only
1283
+ // reload cannot turn a canonical reviewer process into an isolated implementation owner.
1284
+ ownerIsolation: () => Object.fromEntries(extraIds.map(id => {
1285
+ const bound = executionOwners.get(id);
1286
+ return [id, !!bound && bound.owner === bus.peers.get(id) && bound.cwd !== opts.cwd && bound.cwd === executionRoots.get(id)];
1287
+ })),
1194
1288
  recordPlanning: change => event({ type: "planning", ...change }),
1195
1289
  wipLimit: peer => Object.hasOwn(config.tasks.wip_limits ?? {}, peer) ? config.tasks.wip_limits![peer]! : 1,
1196
1290
  escalateAfter: () => config.tasks.escalate_after ?? ESCALATE_AFTER,
@@ -1247,7 +1341,7 @@ export async function startDaemon(opts: DaemonOptions) {
1247
1341
  // The PII screen (issue #198): the model on this machine when MLX/Ollama is enabled, else the gateway on campus only.
1248
1342
  piiScreen: (text) => screenPii(text, { ...(config.mlx.enabled === false ? {} : { device: () => ensureMlx(config.mlx) }), onCampus, omni, fixedModel: () => activeRouting().local.fixed_model }),
1249
1343
  recordScreen: (record) => event({ type: "pii_screen", ...record }),
1250
- quota: (): ReturnType<Budget["headroom"]> => budget.headroom(), // budget is built below; this runs at assignment time
1344
+ quota: (): ReturnType<Budget["headroom"]> => projectBudgetHeadroom(budget.headroom(), accountMembers), // budget is built below; this runs at assignment time
1251
1345
  review: config.review,
1252
1346
  sweep: taskSweepConfig(config.task_sweep),
1253
1347
  sweepHeld: () => stopping || recoveryActive(),
@@ -1264,12 +1358,12 @@ export async function startDaemon(opts: DaemonOptions) {
1264
1358
  integrationFacts: (peer) => {
1265
1359
  if (!factsOn()) return undefined;
1266
1360
  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; }
1361
+ try { offered = factsFor(peer).due(peer, undefined, true); } catch (error) { log(`integration facts for ${peer}: ${(error as Error).message}`); return undefined; }
1268
1362
  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
1363
  return offered ? { id: offered.id, text: offered.text } : undefined;
1270
1364
  },
1271
1365
  ackFacts: (peer, id) => acked(peer, id, "done"),
1272
- factsCurrent: (peer) => !factsOn() || facts.current(peer),
1366
+ factsCurrent: (peer) => !factsOn() || factsFor(peer).current(peer),
1273
1367
  splitProfile: (peer) => splitProfile(peer),
1274
1368
  recordCohort: (c) => event({ type: "cohort", ...c }),
1275
1369
  // The trace holds peer names and numbers only (never task text): it is the inputs a later check of the prediction needs.
@@ -1314,6 +1408,34 @@ export async function startDaemon(opts: DaemonOptions) {
1314
1408
  const repositoryFacts = !!factsRepo || profileName(config.profile) === "dev";
1315
1409
  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
1410
  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) });
1411
+ const worktreeFacts = new Map<string, Facts>();
1412
+ const worktreeProjections = new Map<string, TextProjections>();
1413
+ const executionRoot = (peer: string) => executionRoots.get(peer) ?? opts.cwd;
1414
+ const repoFor = (peer: string) => executionRoot(peer) === opts.cwd ? factsRepo : repoOf(executionRoot(peer));
1415
+ const projectionsFor = (peer: string): TextProjections => {
1416
+ const root = executionRoot(peer);
1417
+ if (root === opts.cwd) return projections;
1418
+ let projection = worktreeProjections.get(peer);
1419
+ if (!projection) {
1420
+ 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 });
1421
+ worktreeProjections.set(peer, projection);
1422
+ }
1423
+ return projection;
1424
+ };
1425
+ const factsFor = (peer: string): Facts => {
1426
+ const root = executionRoot(peer);
1427
+ if (root === opts.cwd) return facts;
1428
+ let store = worktreeFacts.get(peer);
1429
+ if (!store) {
1430
+ const projection = projectionsFor(peer);
1431
+ store = new Facts({ root, repo: repoFor(peer), tmp: join(nativeStateDir(peer), "facts"), instance: `${instanceId.slice(0, 8)}-${peer}`,
1432
+ scope: id => scopes.has(id) ? scopes.get(id) : scopes.set(id, tasks.factScope(id)).get(id),
1433
+ peers: () => [...bus.peers.keys()].filter(id => executionRoot(id) === root), nameable: tasks.nameable, deny: config.local.deny,
1434
+ privatePath: path => matchesPolicy(root, path, config.pii?.paths ?? []), projection: file => projection.project(file) });
1435
+ worktreeFacts.set(peer, store);
1436
+ }
1437
+ return store;
1438
+ };
1317
1439
  const progress = new ProgressObserver({
1318
1440
  tasks: () => board.list(),
1319
1441
  isPrivate: () => [...turns.values()].some(turn => turn.private) || board.list().some(task => !isClosedTaskState(task.state) && tasks.isPii(task)),
@@ -1333,7 +1455,7 @@ export async function startDaemon(opts: DaemonOptions) {
1333
1455
  let factsWereOn = false;
1334
1456
  const factsOn = (): boolean => {
1335
1457
  const on = repositoryFacts && turnFree();
1336
- if (on && !factsWereOn) facts.reset();
1458
+ if (on && !factsWereOn) { facts.reset(); for (const store of worktreeFacts.values()) store.reset(); }
1337
1459
  // A PII task opened: every silent cohort speaks again, for good, and its members hear so at once (issue #108).
1338
1460
  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
1461
  factsWereOn = on;
@@ -1351,10 +1473,10 @@ export async function startDaemon(opts: DaemonOptions) {
1351
1473
  probes.delete(peer);
1352
1474
  }
1353
1475
  factSessions.set(peer, id);
1354
- facts.session(peer, id);
1476
+ factsFor(peer).session(peer, id);
1355
1477
  };
1356
1478
  const acked = (peer: PeerId, id: string, via: string) => {
1357
- const got = facts.ack(peer, id);
1479
+ const got = factsFor(peer).ack(peer, id);
1358
1480
  if (!got) return;
1359
1481
  event({ type: "fact_ack", peer, id, via, ms: Date.now() - got.at });
1360
1482
  if (via !== "done" && !capable.has(peer)) {
@@ -1374,7 +1496,7 @@ export async function startDaemon(opts: DaemonOptions) {
1374
1496
  * integration request wait for the next done, not a readback, so they never count.
1375
1497
  */
1376
1498
  const checkCapability = (peer: PeerId) => {
1377
- if (capable.has(peer) && facts.pending(peer).filter((o) => !o.done && Date.now() - o.at > 60_000).length >= 3) {
1499
+ if (capable.has(peer) && factsFor(peer).pending(peer).filter((o) => !o.done && Date.now() - o.at > 60_000).length >= 3) {
1378
1500
  capable.delete(peer);
1379
1501
  loseCapability(peer, "three facts found no readback");
1380
1502
  }
@@ -1386,11 +1508,11 @@ export async function startDaemon(opts: DaemonOptions) {
1386
1508
  */
1387
1509
  const offerFor = (peer: PeerId, toolUseId?: string) => {
1388
1510
  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;
1511
+ if (!capable.has(peer) && factsFor(peer).pending(peer).filter((o) => !o.done).length >= 3) return undefined;
1512
+ const due = factsFor(peer).due(peer, toolUseId);
1513
+ if (due || capable.has(peer) || factsFor(peer).pending(peer).some((o) => o.probe) || (probes.get(peer) ?? 0) >= 3) return due;
1392
1514
  probes.set(peer, (probes.get(peer) ?? 0) + 1);
1393
- return facts.probe(peer, toolUseId);
1515
+ return factsFor(peer).probe(peer, toolUseId);
1394
1516
  };
1395
1517
  // Claude's readback: the transcript row Claude Code writes for a hook's additional context, by tool use id.
1396
1518
  const injected = new Map<string, string>();
@@ -1436,7 +1558,7 @@ export async function startDaemon(opts: DaemonOptions) {
1436
1558
  } catch { return undefined; }
1437
1559
  };
1438
1560
  const readbacks = (peer: PeerId, transcript: string | undefined) => {
1439
- const pending = facts.pending(peer).filter((o) => o.toolUseId);
1561
+ const pending = factsFor(peer).pending(peer).filter((o) => o.toolUseId);
1440
1562
  if (!pending.length || !transcript) return;
1441
1563
  readTranscript(transcript);
1442
1564
  for (const o of pending) if (injected.get(o.toolUseId!)?.includes(`[${o.id}]`)) acked(peer, o.id, "hook");
@@ -1498,7 +1620,7 @@ export async function startDaemon(opts: DaemonOptions) {
1498
1620
  if (p instanceof AcpPeer || p instanceof PiPeer) { const id = p.recoveryMetadata().sessionId; return typeof id === "string" ? id : undefined; }
1499
1621
  return undefined;
1500
1622
  };
1501
- const activityRings: Record<string, ActivityRing> = Object.fromEntries(ADAPTERS.filter(adapter => adapter.capabilities?.watch).map(adapter => [adapter.kind, new ActivityRing()]));
1623
+ const activityRings: Record<string, ActivityRing> = Object.fromEntries(SUPPORTED_PEERS.filter(peer => definitionOf(peer)?.capabilities?.watch).map(peer => [peer, new ActivityRing()]));
1502
1624
  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
1625
  const requestCheckpoint = (peer: PeerId, reason: "quota" | "context"): Promise<string | undefined> => {
1504
1626
  // One outstanding request per peer. A quota transition may proceed without a summary rather than steal a context waiter.
@@ -1546,21 +1668,21 @@ export async function startDaemon(opts: DaemonOptions) {
1546
1668
  if (budgetFeedSeen.get(hold.peer) !== key && supervision.budgetPaused(hold.peer, hold.resetsAt)) budgetFeedSeen.set(hold.peer, key);
1547
1669
  }
1548
1670
  };
1549
- const budget = new Budget(join(opts.stateDir, "hub.db"), config.budget, {
1671
+ const budget = new Budget(join(opts.stateDir, "hub.db"), config.budget, instanceBudgetHooks({
1550
1672
  pause: (peer) => bus.pause(peer),
1551
1673
  resume: (peer) => {
1552
1674
  if (!manualPaused.has(peer) && !conductorHolds.has(peer)) bus.resume(peer);
1553
1675
  },
1554
1676
  requestCheckpoint: (peer) => requestCheckpoint(peer, "quota"),
1555
1677
  platformContext: async (peer) => {
1556
- const platform = PLATFORM[peer];
1678
+ const platform = PLATFORM[definitionOf(peer)?.kind ?? peer];
1557
1679
  if (!config.memory.enabled || !platform) return undefined;
1558
1680
  const text = await memory.contextInject(chain, platform);
1559
1681
  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
1682
  },
1561
1683
  attached: (peer) => bus.peers.has(peer),
1562
1684
  // 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))),
1685
+ canHandOff: (peer) => [...bus.peers.keys()].some((id) => accountOf(id) !== accountOf(peer) && ["idle", "busy"].includes(bus.stateOf(id))),
1564
1686
  // A reading that arrived through a file carries the file's time; a stale one must not look fresh in the export.
1565
1687
  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
1688
  handoff: (peer, context, urgentOnly) => tasks.reassignForPause(peer, context, urgentOnly),
@@ -1586,7 +1708,7 @@ export async function startDaemon(opts: DaemonOptions) {
1586
1708
  for (const id of superseded) bus.withdraw(id);
1587
1709
  },
1588
1710
  notify: (line) => { notify(line); feedBudgetPauses(); },
1589
- });
1711
+ }, accountMembers, accountOf));
1590
1712
  budget.setRecoveryHold(recoveryActive());
1591
1713
  const kimiTokens: { at: number; n: number }[] = [];
1592
1714
  startupCleanup.push(() => budget.close());
@@ -1646,7 +1768,8 @@ export async function startDaemon(opts: DaemonOptions) {
1646
1768
  * 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
1769
  * in snapshots.ts.
1648
1770
  */
1649
- const snap = (what: string, capture = config.snapshots.enabled): { tree?: string; ms: number } => {
1771
+ const snap = (what: string, capture = config.snapshots.enabled, peer?: string): { tree?: string; ms: number } => {
1772
+ const repo = peer ? repoFor(peer) : factsRepo;
1650
1773
  if (!capture) return { ms: 0 };
1651
1774
  const t0 = performance.now();
1652
1775
  let tree: string | undefined;
@@ -1662,18 +1785,26 @@ export async function startDaemon(opts: DaemonOptions) {
1662
1785
  return [...turns.values()].some(turn => turn.private) || board.list().some(task => !isClosedTaskState(task.state) && tasks.isPii(task));
1663
1786
  }
1664
1787
  function reviewProjection(task: Task): string | undefined {
1788
+ const owner = task.owner ?? "", root = executionRoot(owner);
1789
+ const repo = repoFor(owner), projections = projectionsFor(owner);
1665
1790
  if (!projections.enabled || tasks.isPii(task)) return undefined;
1666
1791
  if (!config.snapshots.enabled || !repo || !turnLog) return "(projected review unavailable: snapshots are disabled or unavailable)";
1667
1792
  if (privateSnapshotWindow()) return "(projected review unavailable: private snapshot window)";
1668
1793
  const created = taskProposalAt(task);
1669
1794
  const from = created === undefined ? undefined : turnLog.taskStart(task.id, created);
1670
1795
  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)));
1796
+ const scopes = [...(task.refs.paths ?? []), ...(task.plan?.paths ?? []), ...(task.plan?.sections ?? []).map(anchor => anchor.split("#")[0]!)].map(path => {
1797
+ const scoped = relative(root, resolve(root, path));
1798
+ if (root === opts.cwd || scoped !== ".." && !scoped.startsWith("../")) return scoped;
1799
+ // Task refs may name the canonical copy of this repository file while the owner executes its worktree copy.
1800
+ const canonical = relative(opts.cwd, resolve(root, path));
1801
+ return canonical !== ".." && !canonical.startsWith("../") && !canonical.startsWith(".worktrees/") ? canonical : scoped;
1802
+ });
1672
1803
  if (!scopes.length) return "(projected review unavailable: task paths were not declared)";
1673
- const to = snap(`task #${task.id} review`).tree;
1804
+ const to = snap(`task #${task.id} review`, config.snapshots.enabled, task.owner ?? undefined).tree;
1674
1805
  if (!to) return "(projected review unavailable: current snapshot is unknown)";
1675
1806
  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 ?? []) });
1807
+ 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
1808
  }
1678
1809
  const delta = tokenDeltas();
1679
1810
  /** Tokens a peer used, as increments: Kimi reports a session total (turned into increments below), Codex increments. */
@@ -1687,14 +1818,14 @@ export async function startDaemon(opts: DaemonOptions) {
1687
1818
  }
1688
1819
  return n;
1689
1820
  };
1690
- const onKimiTokens = (total: number, session: string) => {
1691
- const n = addTokens("kimi", delta("kimi", total, session));
1821
+ const onKimiTokens = (total: number, session: string, peer = "kimi") => {
1822
+ const n = addTokens(peer, delta(peer, total, session));
1692
1823
  if (!config.budget.kimi_tokens_5h || !n) return;
1693
1824
  const now = Date.now();
1694
1825
  kimiTokens.push({ at: now, n });
1695
1826
  while (kimiTokens.length && now - kimiTokens[0]!.at > 5 * 3_600_000) kimiTokens.shift();
1696
1827
  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)" }]);
1828
+ budget.report(peer, [{ id: "tokens", used, resetsAt: kimiTokens[0]!.at + 5 * 3_600_000, source: "kimi usage_update (soft limit)" }]);
1698
1829
  };
1699
1830
  // Claude's numbers arrive through the status line tee `ahub claude` installs (src/cli/statusline-tee.ts).
1700
1831
  let claudeUsageSeen = 0;
@@ -1985,7 +2116,7 @@ export async function startDaemon(opts: DaemonOptions) {
1985
2116
  const t = await tasks.propose(by, a);
1986
2117
  if (t.state === "backlog") return line(t);
1987
2118
  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
2119
+ if (t.owner === by && tasks.silentFor(t.id) && factsOn()) factsFor(by).sawPlans(by, tasks.overlapTasks(t)); // as for an accept
1989
2120
  return [line(t), overlap, tasks.claimRoleGuidance(t, by), t.owner === by ? tasks.planGuardText(t) : ""].filter(Boolean).join("\n");
1990
2121
  }
1991
2122
  case "hub_task_accept": {
@@ -1993,7 +2124,7 @@ export async function startDaemon(opts: DaemonOptions) {
1993
2124
  // A silent cohort (issue #107): the owners' plans come with every accept, as what the newcomer works from.
1994
2125
  const silent = tasks.silentFor(t.id);
1995
2126
  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
2127
+ if (silent && factsOn()) factsFor(by).sawPlans(by, tasks.overlapTasks(t)); // the plans in this answer need no fact later
1997
2128
  return [line(t), overlap, tasks.planGuardText(t)].filter(Boolean).join("\n");
1998
2129
  }
1999
2130
  case "hub_task_cancel": {
@@ -2123,6 +2254,14 @@ export async function startDaemon(opts: DaemonOptions) {
2123
2254
  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
2255
  const codex = bus.peers.get(turn.peer);
2125
2256
  if (!(codex instanceof CodexPeer)) throw new Error("codex is not attached");
2257
+ const workspace = turnLog!.workspace(turn.id);
2258
+ if (workspace !== undefined) {
2259
+ const current = readInstanceRoleConfig(opts.cwd), adapter = instanceDefinitionOf(turn.peer, current.instances);
2260
+ if (!current.instances?.[turn.peer] || adapter?.kind !== definitionOf(turn.peer)?.kind) throw new Error("turn workspace declaration changed; inspect before reverting");
2261
+ const validated = resolveInstanceExecutionCwd(opts.cwd, turn.peer, current.instances, effectiveInstanceRoles(turn.peer, current.instances, current.roles));
2262
+ const bound = executionOwners.get(turn.peer);
2263
+ 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");
2264
+ }
2126
2265
  // Nothing may reach Codex while its history is rewritten: a turn started meanwhile would go with the reverted ones.
2127
2266
  const held = bus.stateOf(turn.peer) !== "paused";
2128
2267
  if (held) bus.pause(turn.peer);
@@ -2187,7 +2326,7 @@ export async function startDaemon(opts: DaemonOptions) {
2187
2326
  };
2188
2327
  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
2328
  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;
2329
+ 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
2330
  if (checkPending.size !== 0) return false;
2192
2331
  if (!recoveryPeerSnapshot) return true;
2193
2332
  const current = recoveryPeers();
@@ -2368,10 +2507,10 @@ export async function startDaemon(opts: DaemonOptions) {
2368
2507
  const mode = permissionDisplay(id, peer);
2369
2508
  return mode === "ask" ? {} : { permissionMode: mode };
2370
2509
  };
2371
- let lastCodexThread: string | undefined;
2510
+ const lastCodexThreads = new Map<string, string>();
2372
2511
  const statusPeerIds = (open: ReturnType<Board["statusTasks"]>) => {
2373
2512
  const reservations = board.statusReservations();
2374
- const ids = new Set([...bus.knownPeers(), ...reservations]);
2513
+ const ids = new Set([...bus.knownPeers(), ...extraIds, ...reservations]);
2375
2514
  if (!Object.keys(config.roster).some(peer => !membershipAllowed(peer))) return [...ids];
2376
2515
  for (const task of open) for (const peer of [task.owner, task.reviewer]) if (peer && !membershipAllowed(peer)) ids.add(peer);
2377
2516
  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));
@@ -2389,6 +2528,10 @@ export async function startDaemon(opts: DaemonOptions) {
2389
2528
  pendingRestart,
2390
2529
  memoryProfile: memoryProfileStatus(includeAttentionItems),
2391
2530
  lifecycleStages: [...lifecycleConfig.stages],
2531
+ // ponytail: off keeps the #257 phase schema until RETIRED_WORKFLOW_FROM, when the alias and this flag go together.
2532
+ ...(lifecycleConfig.mode !== "off" ? { lifecycleCriteria: true } : {}),
2533
+ // Every supported peer's membership, for launchers that run outside the console role (#484).
2534
+ roster: [...KNOWN_PEERS, ...OPTIONAL_PEERS].map(peer => ({ peer, on: onRoster(peer) })),
2392
2535
  protocol: PROTOCOL,
2393
2536
  features: DAEMON_FEATURES,
2394
2537
  stopping,
@@ -2398,7 +2541,7 @@ export async function startDaemon(opts: DaemonOptions) {
2398
2541
  ...(dashboard ? { uiOrigin: dashboard.origin } : {}),
2399
2542
  codexProxyPort: opts.codexProxyPort,
2400
2543
  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 ?? [] } : {}) }]; }),
2544
+ 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
2545
  ),
2403
2546
  taskWip: Object.fromEntries(peerIds.map(id => [id, { wip: tasks.wip(id), wipLimit: tasks.wipLimit(id) }])),
2404
2547
  boardReadCapabilities: { releaseFilter: true, columnMetadata: true },
@@ -2669,11 +2812,11 @@ export async function startDaemon(opts: DaemonOptions) {
2669
2812
  const lostCount = [...lost.values()].reduce((n, l) => n + l.length, 0);
2670
2813
  report(`the previous hub run stopped without shutting down${lostCount ? `; ${lostCount} deliveries it had in flight are in needs_review (ahub queue list)` : ""}`);
2671
2814
  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; }
2815
+ for (const step of crashPlan(prev.peers, declarations)) {
2816
+ 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
2817
  // `pi.auto_start` already asks for Pi (#66): its recorded headless session comes back, auto-resume or not, and a
2675
2818
  // 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()) {
2819
+ if (!declarations[step.peer] && definitionOf(step.peer)?.session.restoreOnStart && piAutoStart()) {
2677
2820
  if (step.resume) {
2678
2821
  const r = await start("pi", step.resume as Parameters<typeof startPeer>[1]);
2679
2822
  if (r.ok) { report(`pi resumed (pi.auto_start): ${step.how}`); continue; }
@@ -2733,8 +2876,8 @@ export async function startDaemon(opts: DaemonOptions) {
2733
2876
  for (const id of turnTasks.keys()) if (!turnLog?.get(id)) turnTasks.delete(id);
2734
2877
  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
2878
  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}`); }
2879
+ 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
2880
+ 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
2881
  const preparedRoles = preparedSessionRoleIdentities.get(e.peer)?.map(role => ({ ...role })) ?? null;
2739
2882
  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
2883
  event({ type: "turn_start", peer: e.peer, turn: id });
@@ -2746,8 +2889,8 @@ export async function startDaemon(opts: DaemonOptions) {
2746
2889
  if (open.capture && turnLog && (open.private || holdsPii(e.peer))) {
2747
2890
  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
2891
  } 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) : [];
2892
+ const end = snap(`the end of ${open.id}`, true, e.peer);
2893
+ const changed = open.tree && end.tree ? changedPaths(repoFor(e.peer)!, open.tree, end.tree) : [];
2751
2894
  if (open.tree && end.tree) files = changed.length; // unknown, not zero, when a snapshot failed
2752
2895
  snapshotMs = (snapshotMs ?? 0) + end.ms;
2753
2896
  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 +3093,11 @@ export async function startDaemon(opts: DaemonOptions) {
2950
3093
  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
3094
  const CLI_ENTRY = fileURLToPath(new URL("../cli/main.ts", import.meta.url));
2952
3095
  /** `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];
3096
+ const startMode = (peer: string): StartMode => startModeOf(config.peers, peer, declarations);
3097
+ const startWords = (peer: string, mode: StartMode): string[] => {
3098
+ const words = definitionOf(peer)?.cli.startWords(mode) ?? [peer];
3099
+ return declarations[peer] ? [words[0]!, "--as", peer, ...words.slice(1)] : words;
3100
+ };
2955
3101
  const startable = onRoster;
2956
3102
  // ponytail: one terminal per peer per 30 s, by the clock. The hub cannot see whether the terminal it opened still
2957
3103
  // exists, so a peer that never attaches can be opened again after that; track the provider's handle to do better.
@@ -2960,7 +3106,7 @@ export async function startDaemon(opts: DaemonOptions) {
2960
3106
  // 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
3107
  const startPlans = () => STARTABLE_PEERS.filter(startable).map((peer) => {
2962
3108
  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 }) : {}) };
3109
+ 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
3110
  });
2965
3111
  // Printed for a person's explicit reconciliation only; never executed by auto-start.
2966
3112
  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`;
@@ -3082,19 +3228,25 @@ export async function startDaemon(opts: DaemonOptions) {
3082
3228
  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
3229
  let unmute: (() => void) | undefined;
3084
3230
  try {
3231
+ if (declarations[peer] && args.mode !== undefined && args.mode !== "headless") return { ok: false, error: "declared instances support headless starts only" };
3232
+ let executionCwd: string;
3233
+ try { executionCwd = resolveInstanceExecutionCwd(opts.cwd, peer, declarations, config.roles[peer] ?? []); }
3234
+ catch (error) { return { ok: false, error: (error as Error).message }; }
3235
+ executionRoots.set(peer, executionCwd);
3085
3236
  applyPendingPermissionDefault(peer);
3086
3237
  if (definitionOf(peer)?.start.refreshTools) { await ensureSpec(true); await refreshRunner(); }
3087
- return await startPeerBody(peer, args, (p) => { unmute = muteState(p); });
3238
+ const result = await startPeerBody(peer, args, (p) => { unmute = muteState(p); }, executionCwd);
3239
+ return result.ok && declarations[peer] ? { ...result, executionCwd } : result;
3088
3240
  } finally {
3089
3241
  unmute?.();
3090
3242
  }
3091
3243
  }
3092
3244
 
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>> {
3245
+ 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
3246
  const adapter = definitionOf(peer);
3095
3247
  const preflight = adapter?.start.preflight?.({ instance: peer, form: args.mode });
3096
3248
  if (preflight) return { ok: false, error: preflight };
3097
- if (adapter?.attach?.admission) {
3249
+ if (adapter?.attach?.admission && !declarations[peer]) {
3098
3250
  if (args.mode !== undefined && !["headless", "tui"].includes(args.mode)) return { ok: false, error: "invalid Kimi mode" };
3099
3251
  const owner = bus.peers.get(peer);
3100
3252
  if ((args.mode ?? startMode(peer)) === "tui") {
@@ -3122,10 +3274,10 @@ export async function startDaemon(opts: DaemonOptions) {
3122
3274
  if (args.replaceSession) {
3123
3275
  if (recoveryActive()) return { ok: false, error: "recovery is holding session replacement" };
3124
3276
  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);
3277
+ 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" };
3278
+ const selected = validatePiSession(nativeStateDir(peer), executionCwd, args.sessionFile);
3127
3279
  const previous = existing.recoveryMetadata();
3128
- recordPiReplacement(opts.stateDir, instanceId, previous, selected);
3280
+ recordPiReplacement(nativeStateDir(peer), instanceId, previous, selected);
3129
3281
  args = { ...args, ...selected };
3130
3282
  mute(existing);
3131
3283
  await stopPiForReplacement(existing);
@@ -3172,7 +3324,7 @@ export async function startDaemon(opts: DaemonOptions) {
3172
3324
  if (adapter?.start.daemonPort === "owned-acp") {
3173
3325
  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
3326
  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,
3327
+ ...(args.model ? { launchModel: args.model } : {}), ...(args.sessionId ? { resumeSessionId: args.sessionId } : {}), cwd: executionCwd, watchdogMs: config.watchdog_ms,
3176
3328
  preamble: sessionRoleContract(peer), log: () => log("Jcode native diagnostic: details withheld") }) }) as JcodePeer;
3177
3329
  recoveryTaskPreface(peer); await ensurePreface(peer);
3178
3330
  try { await jcode.start(); await reconcileNativeStart(peer, jcode); }
@@ -3184,11 +3336,11 @@ export async function startDaemon(opts: DaemonOptions) {
3184
3336
  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
3337
  onActivity: item => activityRings[peer]!.append(item),
3186
3338
  isPrivateTurn: () => !!turns.get(peer)?.private || contextPii(peer),
3187
- cmd,
3339
+ cmd, kind: adapter.kind,
3188
3340
  permissionMode: permissionMode(peer),
3189
3341
  ...(args.model ? { launchModel: args.model } : {}),
3190
3342
  ...(args.sessionId ? { resumeSessionId: args.sessionId } : {}),
3191
- cwd: opts.cwd,
3343
+ cwd: executionCwd,
3192
3344
  watchdogMs: config.watchdog_ms,
3193
3345
  onPermission,
3194
3346
  // The hub's own tools pass without a console prompt, as Codex's do (approval_mode below; issue #72).
@@ -3197,11 +3349,11 @@ export async function startDaemon(opts: DaemonOptions) {
3197
3349
  // session's configured MCP servers (issue #138). Exact names only; a stopping hub approves nothing.
3198
3350
  autoApprove: (title) => !stopping && HUB_TOOL_TITLES.has(title),
3199
3351
  log,
3200
- onTokens: onKimiTokens,
3352
+ onTokens: (total, session) => onKimiTokens(total, session, peer),
3201
3353
  // Pause synchronously before the adapter requeues a provider-refused prompt (#306).
3202
3354
  onQuotaRefusal: (window) => {
3203
3355
  budget.report(peer, [window], true);
3204
- return !!budget.record(peer) && bus.isPaused("kimi");
3356
+ return !!budget.record(peer) && bus.isPaused(peer);
3205
3357
  },
3206
3358
  // Kimi's usage_update is context occupancy ({used, size}), not consumption (#167): it becomes the peer's
3207
3359
  // context reading (#285 phase 1), never a tokens event or a budget window. The reading is bound to the
@@ -3238,9 +3390,9 @@ export async function startDaemon(opts: DaemonOptions) {
3238
3390
  const owner = adapter.start.create({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: undefined, permission, mcpServers, daemon: { acp: {
3239
3391
  onActivity: item => activityRings[peer]!.append(item),
3240
3392
  isPrivateTurn: () => !!turns.get(peer)?.private || contextPii(peer),
3241
- cmd: [launch.cmd, ...launch.args], permissionMode: permission,
3393
+ kind: adapter.kind, cmd: [launch.cmd, ...launch.args], permissionMode: permission,
3242
3394
  ...(args.model ? { launchModel: args.model } : {}), ...(args.sessionId ? { resumeSessionId: args.sessionId } : {}),
3243
- cwd: opts.cwd, watchdogMs: config.watchdog_ms, onPermission,
3395
+ cwd: executionCwd, watchdogMs: config.watchdog_ms, onPermission,
3244
3396
  autoApprove: title => !stopping && HUB_TOOL_TITLES.has(title),
3245
3397
  log: () => log("Qwen runtime diagnostic: details withheld"),
3246
3398
  onUsageDiagnostic: (observation, sessionId) => {
@@ -3273,7 +3425,7 @@ export async function startDaemon(opts: DaemonOptions) {
3273
3425
  onTokens: (added) => void addTokens(peer, added),
3274
3426
  onContext: (reading) => contexts.report(peer, reading, contextSession(peer)),
3275
3427
  // #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 }); },
3428
+ onThread: (thread, fresh) => { lastCodexThreads.set(peer, thread); event({ type: "native_thread", peer, thread, fresh }); },
3277
3429
  onTurn: (native) => {
3278
3430
  const open = turns.get(peer);
3279
3431
  if (open) turnLog?.native(open.id, native);
@@ -3288,10 +3440,10 @@ export async function startDaemon(opts: DaemonOptions) {
3288
3440
  factSession(peer, codex.thread);
3289
3441
  if (item.type === "userMessage") {
3290
3442
  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");
3443
+ if (text.includes(FACTS_PREFIX)) for (const o of factsFor(peer).pending(peer)) if (text.includes(`[${o.id}]`)) acked(peer, o.id, "steer");
3292
3444
  return;
3293
3445
  }
3294
- facts.codexItem("codex", item);
3446
+ factsFor(peer).codexItem(peer, item);
3295
3447
  checkCapability(peer);
3296
3448
  if (codexSteering) return; // one at a time: the next boundary offers what this one could not
3297
3449
  const started = performance.now();
@@ -3304,12 +3456,12 @@ export async function startDaemon(opts: DaemonOptions) {
3304
3456
  codexSteering = false;
3305
3457
  // Refused, it never went in: not an unread offer, and the next boundary offers it again. Unanswered, it may
3306
3458
  // have: its readback can still come.
3307
- if (outcome === "refused") facts.drop("codex", offered.id);
3459
+ if (outcome === "refused") factsFor(peer).drop(peer, offered.id);
3308
3460
  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
3461
  });
3310
3462
  },
3311
- appPort: opts.codexAppPort,
3312
- proxyPort: opts.codexProxyPort,
3463
+ appPort: declarations[peer] ? instanceAppPort(peer) : opts.codexAppPort,
3464
+ proxyPort: declarations[peer] ? 0 : opts.codexProxyPort,
3313
3465
  bin: config.codex_bin,
3314
3466
  // The hub spawns this app-server, so it can give Codex the task tools without touching ~/.codex/config.toml
3315
3467
  // (verified: app-server honours -c mcp_servers.* and the server reaches "ready").
@@ -3322,7 +3474,7 @@ export async function startDaemon(opts: DaemonOptions) {
3322
3474
  preamble: sessionRoleContract(peer),
3323
3475
  onUsage: (rateLimits, hard) => budget.report(peer, codexWindows(rateLimits), hard),
3324
3476
  usagePollMs: config.budget.poll_min * 60_000,
3325
- cwd: opts.cwd,
3477
+ cwd: executionCwd,
3326
3478
  watchdogMs: config.watchdog_ms,
3327
3479
  log,
3328
3480
  }) }) as CodexPeer;
@@ -3335,31 +3487,36 @@ export async function startDaemon(opts: DaemonOptions) {
3335
3487
  return { ok: true, proxyUrl: codex.proxyUrl };
3336
3488
  }
3337
3489
  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" };
3490
+ 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
3491
  const mode = args.mode ?? "headless";
3340
3492
  const backend = args.backend ?? config.pi.backend;
3341
3493
  if (!["headless", "tui"].includes(mode) || !["auto", "dgx", "mlx"].includes(backend)) return { ok: false, error: "invalid Pi mode/backend" };
3342
3494
  const problem = mlxLaunchProblem(config, { backend, model: args.model });
3343
3495
  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,
3496
+ 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
3497
  routeSessionKey: () => { const session = bus.peers.get(peer)?.recoveryMetadata?.().sessionId; return typeof session === "string" ? session : undefined; },
3346
3498
  staySwitch: () => activeRouting(),
3347
3499
  onRoute: record => event({ type: "route", peer, ...record }),
3348
3500
  efficientWaitMs: activeRouting().pi.efficient_wait_ms,
3349
- underBudget: () => { const current = bus.peers.get(peer); return current instanceof PiPeer && tasks.budgetApplies(current.budgetEnvelopes, "pi"); },
3501
+ underBudget: () => { const current = bus.peers.get(peer); return current instanceof PiPeer && tasks.budgetApplies(current.budgetEnvelopes, peer); },
3350
3502
  onCooldown: record => {
3351
3503
  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
3504
  event({ type: "cooldown", peer, ...record });
3353
3505
  },
3354
3506
  });
3355
- piReceipts ??= new PiToolReceipts(join(opts.stateDir, "hub.db"));
3507
+ modelRelays.set(peer, modelRelay);
3508
+ if (!declarations[peer]) globalModelRelay(modelRelay);
3509
+ const nativeRoot = nativeStateDir(peer);
3510
+ mkdirSync(nativeRoot, { recursive: true });
3511
+ const piReceipts = piReceiptLedgers.get(peer) ?? new PiToolReceipts(join(nativeRoot, "hub.db"));
3512
+ piReceiptLedgers.set(peer, piReceipts);
3356
3513
  let piReply: Envelope | undefined;
3357
3514
  // Tools the person allowed "always" for this Pi start; a new start gets a new set (#209).
3358
3515
  const piAlways = new Set<string>();
3359
3516
  const piPermit = async (title: string, tool: string, signal?: AbortSignal, path?: string, valid: () => boolean = () => true, observe?: ApprovalObserver, originalPath?: string): Promise<ToolApproval> => {
3360
3517
  if (stopping || signal?.aborted || !valid() || !pi.acceptingTools || bus.peers.get(peer) !== pi) return "aborted";
3361
3518
  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))) {
3519
+ if (selected === "never-ask" || (selected === "ask-when-needed" && PI_EDIT_TOOLS.has(tool) && !!path && grantablePath(realPath(executionCwd), path, originalPath, config.protect_instruction_files))) {
3363
3520
  observe?.({ source: "automatic", answerEpoch: permissionAnswerEpoch(peer) });
3364
3521
  return true;
3365
3522
  }
@@ -3375,9 +3532,9 @@ export async function startDaemon(opts: DaemonOptions) {
3375
3532
  return signal?.aborted || !valid() ? "aborted" : pi.acceptingTools && bus.peers.get(peer) === pi;
3376
3533
  };
3377
3534
  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" },
3535
+ cwd: executionCwd, deny: config.local.deny, readOnly: config.data?.readonly,
3536
+ sandboxProfile: profile(executionCwd, sandboxNetwork, config.local.read_allow, config.local.deny, config.data?.readonly),
3537
+ sandboxEnv: { ...proxyEnv(sandboxNetwork), AGENTHUB_PEER_ID: peer },
3381
3538
  permit: async () => false, // Pi's calls ask through piPermit in executeTool, which knows the tool's name
3382
3539
  send: (text, to) => {
3383
3540
  if (to?.some((id) => !bus.peers.has(id) && id !== USER)) return "error: unknown peer";
@@ -3387,12 +3544,12 @@ export async function startDaemon(opts: DaemonOptions) {
3387
3544
  };
3388
3545
  const routing = activeRouting();
3389
3546
  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,
3547
+ cwd: executionCwd, stateDir: nativeRoot, ...(declarations[peer] ? { hubStateDir: opts.stateDir } : {}), cmd: config.pi.cmd, mode, backend,
3391
3548
  model: args.model,
3392
3549
  sessionId: args.sessionId, sessionFile: args.sessionFile,
3393
3550
  admitBudget: async (envs, unit) => unit === "model_calls" ? [] : tasks.admitExecutionEnvelopes(envs, peer, unit),
3394
3551
  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 }))],
3552
+ 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
3553
  executeTool: async (name, raw, callId, sessionId, signal, context) => {
3397
3554
  if (MEMORY_READ_TOOLS.has(name)) validateMemoryRead(name, raw as Record<string, unknown>);
3398
3555
  if (signal?.aborted) return "error: turn cancelled before tool effects";
@@ -3454,7 +3611,7 @@ export async function startDaemon(opts: DaemonOptions) {
3454
3611
  else if (recoveryActive()) action = "recovery holds automatic restart; inspect ahub status";
3455
3612
  else if (!exit.started || starting.has(peer)) action = "startup failed; inspect its session, then ahub pi";
3456
3613
  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";
3614
+ else if (declarations[peer] || !piAutoStart()) action = "pi.auto_start is off; start it with ahub pi";
3458
3615
  else if (mode !== "headless") action = "native terminal ended; start it with ahub pi";
3459
3616
  else if (piAutoRestartPending || Date.now() - piAutoRestartAt < 60_000) action = "pi.auto_start restart limit (one in 60 s); start it with ahub pi";
3460
3617
  else {
@@ -3467,7 +3624,7 @@ export async function startDaemon(opts: DaemonOptions) {
3467
3624
  const saved = await pi.captureResume();
3468
3625
  if (typeof saved.sessionId !== "string" || typeof saved.sessionFile !== "string") throw new Error("no verified persisted Pi session; refusing a fresh fallback");
3469
3626
  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 });
3627
+ const result = await startPeer(peer, { mode: "headless", backend, ...(args.model ? { model: args.model } : {}), sessionId: saved.sessionId, sessionFile: saved.sessionFile });
3471
3628
  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
3629
  })().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
3630
  .finally(() => { piAutoRestartAt = Date.now(); piAutoRestartPending = false; });
@@ -3495,6 +3652,7 @@ export async function startDaemon(opts: DaemonOptions) {
3495
3652
  }) }) as PiPeer;
3496
3653
  recoveryTaskPreface(peer);
3497
3654
  await ensurePreface(peer);
3655
+ executionOwners.set(peer, { owner: pi, cwd: executionCwd });
3498
3656
  bus.add(pi);
3499
3657
  // The start's own error is the one reported; a stop that fails too is logged beside it (#115).
3500
3658
  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 +3682,12 @@ export async function startDaemon(opts: DaemonOptions) {
3524
3682
  sidecar ??= new Sidecar({ routing, omni, stateDir: opts.stateDir, port: opts.switchyardPort, log, ...(opts.switchyardBin ? { bin: opts.switchyardBin } : {}) });
3525
3683
  }
3526
3684
  const capture = config.memory.enabled
3527
- ? new Capture(memory, { project: chain.at(-1)!, cwd: opts.cwd, skip: skipTools(), deny: config.local.deny })
3685
+ ? new Capture(memory, { project: chain.at(-1)!, cwd: executionCwd, skip: skipTools(), deny: config.local.deny })
3528
3686
  : undefined;
3529
3687
  const permit = async (title: string, tool?: string, signal?: AbortSignal, path?: string, observe?: ApprovalObserver, originalPath?: string): Promise<ToolApproval> => {
3530
3688
  if (stopping || signal?.aborted || bus.peers.get(peer) !== local) return "aborted";
3531
3689
  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))) {
3690
+ 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
3691
  observe?.({ source: "automatic", answerEpoch: permissionAnswerEpoch(peer) });
3534
3692
  return true;
3535
3693
  }
@@ -3542,7 +3700,7 @@ export async function startDaemon(opts: DaemonOptions) {
3542
3700
  return picked.optionId === "allow";
3543
3701
  };
3544
3702
  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,
3703
+ cwd: executionCwd,
3546
3704
  omni,
3547
3705
  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
3706
  admitBudget: async (envs, unit) => tasks.admitExecutionEnvelopes(envs, peer, unit),
@@ -3561,6 +3719,7 @@ export async function startDaemon(opts: DaemonOptions) {
3561
3719
  ...(capture ? { capture } : {}),
3562
3720
  profile: resolvedProfile,
3563
3721
  sdlcStages: lifecycleConfig.stages,
3722
+ sdlcCriteria: lifecycleConfig.mode !== "off",
3564
3723
  taskTool: (name, a, turn) => taskOp(peer, name, a, true, turn.pii),
3565
3724
  turnPolicy: (envs) => {
3566
3725
  const policy = tasks.turnPolicy(envs);
@@ -3574,6 +3733,7 @@ export async function startDaemon(opts: DaemonOptions) {
3574
3733
  }) }) as LocalPeer;
3575
3734
  recoveryTaskPreface(peer);
3576
3735
  await ensurePreface(peer);
3736
+ executionOwners.set(peer, { owner: local, cwd: executionCwd });
3577
3737
  bus.add(local);
3578
3738
  await local.start();
3579
3739
  return { ok: true, model: route ? `${route} (fallback ${routing.local.fixed_model})` : (args.model ?? routing.local.fixed_model) };
@@ -3581,10 +3741,10 @@ export async function startDaemon(opts: DaemonOptions) {
3581
3741
  return { ok: false, error: `unknown peer "${peer}"` };
3582
3742
  }
3583
3743
 
3584
- async function admitPiRequest(): Promise<{ allowed: boolean; reason?: string; remainingMs?: number }> {
3585
- const current = bus.peers.get("pi");
3744
+ async function admitPiRequest(peer = "pi"): Promise<{ allowed: boolean; reason?: string; remainingMs?: number }> {
3745
+ const current = bus.peers.get(peer);
3586
3746
  if (!(current instanceof PiPeer)) return { allowed: false, reason: "Pi owner is not active" };
3587
- const decisions = tasks.admitExecutionEnvelopes(current.budgetEnvelopes, "pi", "model_calls");
3747
+ const decisions = tasks.admitExecutionEnvelopes(current.budgetEnvelopes, peer, "model_calls");
3588
3748
  const denied = decisions.find(d => !d.allowed);
3589
3749
  const stop = denied ? current.recordBudgetStop(denied) : undefined;
3590
3750
  const deadline = decisions.filter(d => d.unit === "elapsed_ms" && d.remaining !== null).map(d => d.remaining!);
@@ -3641,7 +3801,7 @@ export async function startDaemon(opts: DaemonOptions) {
3641
3801
  }
3642
3802
 
3643
3803
  const conductor = new Conductor(conductorHolds, {
3644
- roles: () => config.roles, capabilities: () => config.capabilities,
3804
+ roles: () => config.roles, capabilities: () => config.capabilities, instances: () => declarations,
3645
3805
  status: () => {
3646
3806
  const quota = budget.status();
3647
3807
  const ids = [...new Set([...bus.knownPeers(), ...Object.keys(quota), ...conductorHolds.list().map(h => h.peer)])];
@@ -3649,8 +3809,8 @@ export async function startDaemon(opts: DaemonOptions) {
3649
3809
  peers: ids.map(peer => {
3650
3810
  const summary = bus.queueSummary(peer);
3651
3811
  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 })) } : {}) };
3812
+ manualHeld: manualPaused.has(peer), hold: conductorHolds.get(peer), budgetPause: quota[accountOf(peer)]?.paused ?? null,
3813
+ ...(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
3814
  }),
3655
3815
  taskCounts: board.counts(), approvals: [...permissions.values()].map(p => ({ peer: p.peer, tool: p.tool, at: p.createdAt })),
3656
3816
  };
@@ -3670,7 +3830,7 @@ export async function startDaemon(opts: DaemonOptions) {
3670
3830
  if (result.ok !== true) throw new Error(String(result.error ?? "peer start failed"));
3671
3831
  return result;
3672
3832
  },
3673
- known: peer => bus.knownPeers().includes(peer),
3833
+ known: peer => extraIds.includes(peer) || bus.knownPeers().includes(peer),
3674
3834
  pause: peer => { pauseGeneration.set(peer, (pauseGeneration.get(peer) ?? 0) + 1); bus.pause(peer); writeStatus(); },
3675
3835
  validateRelease: async peer => {
3676
3836
  const owner = bus.peers.get(peer);
@@ -3793,7 +3953,7 @@ export async function startDaemon(opts: DaemonOptions) {
3793
3953
  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
3954
  }),
3795
3955
  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))) }])),
3956
+ 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
3957
  permissions: [...permissions.values()].map(({ push }) => {
3798
3958
  const req = JSON.parse(push);
3799
3959
  // Local tool titles quote file contents and commands, including those of PII turns.
@@ -3842,7 +4002,7 @@ export async function startDaemon(opts: DaemonOptions) {
3842
4002
  function roleHolders(candidate: HubConfig): { planners: string[]; conductors: string[] } {
3843
4003
  // A valid transfer may briefly have two conductors while its leaves are planned. Validate the final map later.
3844
4004
  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));
4005
+ const settled = resolveSettledRoles(policy, candidate.planner, peer => rosterAllows(candidate.roster, peer, candidate.pi.enabled, declarations));
3846
4006
  return { planners: settled.planners, conductors: Object.keys(candidate.roles).filter(peer => candidate.roles[peer]!.includes("conductor")).sort() };
3847
4007
  }
3848
4008
  function prepareConfiguration(sources: ConfigSources, authority: SettingAuthority, scope?: string, confirmed: readonly string[] = []) {
@@ -3871,6 +4031,13 @@ export async function startDaemon(opts: DaemonOptions) {
3871
4031
  const after = valueAt(route ? sources.routing : opts.config && !current ? embeddedConfig : fileConfig, path);
3872
4032
  const rawAfter = current ? valueAt(current.raw, path) : undefined;
3873
4033
  if (!rawChanged && JSON.stringify(before) === JSON.stringify(after)) continue;
4034
+ // Instance declarations are restart-bound. Their derived role/permission/start defaults cannot leak into
4035
+ // the live catalog while the old identities, paths and account callbacks still own the daemon.
4036
+ const boundId = ["roles", "permission_modes", "peers", "permission_default_sources"].includes(path[0] ?? "") ? path[1] : undefined;
4037
+ if (!route && boundId && JSON.stringify(desiredConfig.instances?.[boundId]) !== JSON.stringify(fileConfig.instances?.[boundId])) {
4038
+ rejected.push({ key, reason: "instance declaration changed; its derived defaults apply together after daemon restart" });
4039
+ continue;
4040
+ }
3874
4041
  const machine = !route && isMachineKey(key);
3875
4042
  const origin = current ?? prior;
3876
4043
  let tracked = origin ? configTracked(opts.cwd, origin.name) : false;
@@ -4071,9 +4238,9 @@ export async function startDaemon(opts: DaemonOptions) {
4071
4238
  let routingProblem: string | undefined;
4072
4239
  try { parseRouting(routingText(opts.cwd), readOverlay(opts.cwd)); } catch (error) { routingProblem = (error as Error).message; }
4073
4240
  const rows = settingRows({
4074
- cwd: opts.cwd, routing: activeRouting(), ...(routingProblem ? { routingProblem } : {}), ...(stored?.ignored ? { ignored: stored.ignored } : {}),
4241
+ instances: declarations, cwd: opts.cwd, routing: activeRouting(), ...(routingProblem ? { routingProblem } : {}), ...(stored?.ignored ? { ignored: stored.ignored } : {}),
4075
4242
  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,
4243
+ 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
4244
  roles: roleState,
4078
4245
  mode: (peer) => {
4079
4246
  const owner = bus.peers.get(peer), attached = !!owner && owner.state !== "offline";
@@ -4112,13 +4279,14 @@ export async function startDaemon(opts: DaemonOptions) {
4112
4279
  if (owner?.state === "busy" && !now) return `${peer} is in a turn; retry when it is idle, or pass --now`;
4113
4280
  return undefined;
4114
4281
  }
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";
4282
+ function codexResumeCommand(peer = "codex"): string {
4283
+ const owner = bus.peers.get(peer), thread = owner instanceof CodexPeer ? owner.thread || lastCodexThreads.get(peer) : lastCodexThreads.get(peer);
4284
+ const command = declarations[peer] ? `ahub codex --as ${peer} resume` : "ahub codex resume";
4285
+ return thread && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(thread) ? `${command} ${thread}` : command;
4118
4286
  }
4119
4287
  function retainedTerminalNotice(peer: string): string | undefined {
4120
4288
  const owner = bus.peers.get(peer);
4121
- return definitionOf(peer)?.cli.retainedTerminalNotice?.(peer, owner instanceof PiPeer ? owner.mode : undefined, codexResumeCommand());
4289
+ return definitionOf(peer)?.cli.retainedTerminalNotice?.(peer, owner instanceof PiPeer ? owner.mode : undefined, codexResumeCommand(peer));
4122
4290
  }
4123
4291
 
4124
4292
  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 +4296,7 @@ export async function startDaemon(opts: DaemonOptions) {
4128
4296
  const terminalNotice = retainedTerminalNotice(peer);
4129
4297
  let handoff: Awaited<ReturnType<Tasks["reassignForRoster"]>> = [];
4130
4298
  try {
4131
- const next = rosterCandidate(def, value, undo), before = onRoster(peer), after = rosterAllows(next, peer, config.pi.enabled);
4299
+ const next = rosterCandidate(def, value, undo), before = onRoster(peer), after = rosterAllows(next, peer, config.pi.enabled, declarations);
4132
4300
  if (!after) {
4133
4301
  const refusal = rosterRemovalRefusal(peer, now);
4134
4302
  if (refusal) return { ok: false, error: refusal };
@@ -4168,7 +4336,7 @@ export async function startDaemon(opts: DaemonOptions) {
4168
4336
  if (reloaded.ok !== true) return { ...reloaded, saved: true };
4169
4337
  writeStatus();
4170
4338
  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 } : {}) };
4339
+ 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
4340
  } catch (error) {
4173
4341
  if (saved) writeStatus();
4174
4342
  return { ok: false, error: sanitize((error as Error).message), ...(saved ? { saved: true } : {}) };
@@ -4277,14 +4445,14 @@ export async function startDaemon(opts: DaemonOptions) {
4277
4445
  const { def, value } = input;
4278
4446
  if (def.group === "Roster") {
4279
4447
  try {
4280
- const peer = def.path[1]!, next = rosterCandidate(def, value), on = rosterAllows(next, peer, config.pi.enabled);
4448
+ const peer = def.path[1]!, next = rosterCandidate(def, value), on = rosterAllows(next, peer, config.pi.enabled, declarations);
4281
4449
  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
4450
  } catch (error) { return { ok: false, error: sanitize((error as Error).message) }; }
4283
4451
  }
4284
4452
  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
4453
  if (def.group === "Permissions") {
4286
4454
  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 } : {}) };
4455
+ 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
4456
  }
4289
4457
  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
4458
  try {
@@ -4413,7 +4581,7 @@ export async function startDaemon(opts: DaemonOptions) {
4413
4581
  ...(taskOpsInFlight !== 0 ? ["a task command in flight"] : []),
4414
4582
  ...(tasks.checksPending() !== 0 ? ["completion checks queued or running"] : []),
4415
4583
  ...(checkPending.size !== 0 ? ["isolated checks queued or running"] : []),
4416
- ...((piReceipts?.inFlight ?? 0) !== 0 ? ["Pi tool calls in flight"] : []),
4584
+ ...(piCallsInFlight() !== 0 ? ["Pi tool calls in flight"] : []),
4417
4585
  ...(releasing ? ["ready tasks being released"] : []),
4418
4586
  ...([...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
4587
  ],
@@ -4434,11 +4602,13 @@ export async function startDaemon(opts: DaemonOptions) {
4434
4602
  ? `target lacks task_cancel; cancelled tasks: ${ids.map(id => `#${id}`).join(", ")}` : undefined;
4435
4603
  };
4436
4604
  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); }
4605
+ if (checkTarget) {
4606
+ const error = incompatibleCancellationTarget(); if (error) return recoveryError(error);
4607
+ 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");
4608
+ }
4438
4609
 
4439
4610
  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") {
4611
+ for (const pi of bus.peers.values()) if (pi instanceof PiPeer && pi.state === "idle") {
4442
4612
  try { await pi.captureResume(); } catch (error) { return recoveryError((error as Error).message); }
4443
4613
  if (checkTarget) { const error = incompatibleCancellationTarget(); if (error) return recoveryError(error); }
4444
4614
  }
@@ -4646,6 +4816,7 @@ export async function startDaemon(opts: DaemonOptions) {
4646
4816
  else await owner.setPermissionMode(selected);
4647
4817
  }
4648
4818
  // The bus may immediately drain at add(): do not expose an idle adapter until its current choice is applied.
4819
+ executionOwners.set(peer, { owner, cwd: executionRoots.get(peer) ?? opts.cwd });
4649
4820
  bus.add(owner);
4650
4821
  // start() finished before the bus installed its state hook: replay the current state to drain queued work and record it.
4651
4822
  if (owner.state !== "offline") owner.onState?.(owner.state);
@@ -4746,7 +4917,7 @@ export async function startDaemon(opts: DaemonOptions) {
4746
4917
  if (!c.peer || (c.role !== "peer" && c.role !== "tools")) return void reply({ ok: false, error: "held metadata requires a peer identity" });
4747
4918
  try {
4748
4919
  const held = bus.heldDeliveryHints(c.peer);
4749
- return void reply(held ? { ok: true, held } : { ok: false, error: "held metadata unavailable" });
4920
+ return void reply(held ? { ok: true, held, queued: bus.queued(c.peer), important: bus.queuedImportant(c.peer) } : { ok: false, error: "held metadata unavailable" });
4750
4921
  } catch { return void reply({ ok: false, error: "held metadata unavailable" }); }
4751
4922
  }
4752
4923
  case "delivery_receipt": {
@@ -4758,10 +4929,10 @@ export async function startDaemon(opts: DaemonOptions) {
4758
4929
  return void reply({ ok: true });
4759
4930
  }
4760
4931
  case "inbox": {
4761
- // A Claude session that cannot show channel pushes reads its queue here; nothing it reads is ever `accepted` (issue #205).
4932
+ // The attached session can explicitly claim queued work even during a native turn (#493).
4933
+ // Pull and push share holds and journal settlement; work read here is completed, never pushed again.
4762
4934
  const peer = c.peer ? bus.peers.get(c.peer) : undefined;
4763
4935
  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
4936
  try {
4766
4937
  const envs = bus.pull(peer.id);
4767
4938
  if (!envs) return void reply({ ok: false, error: queueHold(peer.id) ?? (bus.isRecoveryHeld ? "recovery is holding deliveries" : pauseReason(peer.id)) });
@@ -4958,14 +5129,14 @@ export async function startDaemon(opts: DaemonOptions) {
4958
5129
  factSession(peer, sessionId);
4959
5130
  const transcript = claudeTranscript(sessionId, msg.transcriptPath);
4960
5131
  if (phase === "post") {
4961
- facts.postTool(peer, toolUseId, tool, input);
5132
+ factsFor(peer).postTool(peer, toolUseId, tool, input);
4962
5133
  readbacks(peer, transcript);
4963
5134
  return void reply({ t: "facts", ok: true });
4964
5135
  }
4965
5136
  readbacks(peer, transcript);
4966
5137
  checkCapability(peer);
4967
5138
  const started = performance.now();
4968
- facts.preTool(peer, toolUseId, tool, input);
5139
+ factsFor(peer).preTool(peer, toolUseId, tool, input);
4969
5140
  // Without a transcript nothing offered could ever be read back: a verified path is lost, and nothing is offered
4970
5141
  // (the same diff would otherwise go in at every tool call).
4971
5142
  if (!transcript) {
@@ -5235,9 +5406,9 @@ export async function startDaemon(opts: DaemonOptions) {
5235
5406
  // logged and shutdown finishes anyway, or the process ignores SIGTERM forever.
5236
5407
  for (const exit of exits) if (exit.status === "rejected") log(`peer stop failed during shutdown: ${(exit.reason as Error)?.message ?? exit.reason}`);
5237
5408
  await runnerStopped;
5238
- await modelRelay?.close();
5409
+ await Promise.all([...modelRelays.values()].map(relay => relay.close()));
5239
5410
  await egress?.close();
5240
- await piReceipts?.close();
5411
+ await Promise.all([...piReceiptLedgers.values()].map(ledger => ledger.close()));
5241
5412
  collectClaudeUsage();
5242
5413
  for (const id of [...researchPending.keys()]) writeResearch(id); // the peers have stopped, so their turns have ended
5243
5414
  budget.close();