@staix/agent-hub 0.12.35 → 0.12.37

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 (71) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +15 -8
  3. package/docs/agent-notes/adapters.md +9 -2
  4. package/docs/agent-notes/budget.md +3 -2
  5. package/docs/agent-notes/bus.md +3 -0
  6. package/docs/agent-notes/daemon.md +11 -3
  7. package/docs/agent-notes/tasks.md +14 -1
  8. package/docs/agent-notes/tests.md +6 -2
  9. package/docs/memory.md +3 -1
  10. package/docs/operations.md +74 -18
  11. package/docs/quickstart.md +7 -9
  12. package/docs/sdlc.md +72 -5
  13. package/docs/security.md +11 -3
  14. package/docs/smoke.md +18 -2
  15. package/docs/specs/2026-09-19-agent-hub-design.md +108 -3
  16. package/docs/verification/2026-10-19-0.12.36.md +63 -0
  17. package/docs/verification/2026-10-19-0.12.37.md +60 -0
  18. package/docs/verification/2026-10-19-jcode-0.98.1.md +30 -0
  19. package/docs/verification/2026-10-19-reviewer-isolation.md +119 -0
  20. package/docs/verified.json +23 -15
  21. package/package.json +2 -2
  22. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  23. package/plugins/agent-hub/server.js +2843 -1632
  24. package/src/adapters/acp.ts +3 -2
  25. package/src/adapters/claude-print.ts +92 -0
  26. package/src/adapters/codex-review-isolation.ts +276 -0
  27. package/src/adapters/definitions/builtin-metadata.ts +20 -1
  28. package/src/adapters/definitions/native-cli-entry-claude.ts +17 -3
  29. package/src/adapters/definitions/native-cli-entry-jcode.ts +3 -2
  30. package/src/adapters/definitions/native-cli-entry-kimi.ts +3 -2
  31. package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
  32. package/src/adapters/definitions/native-factories.ts +24 -0
  33. package/src/adapters/definitions/native-policy-aux.ts +1 -1
  34. package/src/adapters/definitions/reviewer-variants.ts +32 -0
  35. package/src/adapters/jcode.ts +20 -5
  36. package/src/adapters/registry-types.ts +13 -3
  37. package/src/adapters/registry.ts +29 -5
  38. package/src/adapters/reviewer-print.ts +319 -0
  39. package/src/cli/help.ts +7 -0
  40. package/src/cli/identity.ts +2 -1
  41. package/src/cli/launch.ts +19 -6
  42. package/src/cli/main.ts +57 -4
  43. package/src/cli/memory.ts +13 -12
  44. package/src/cli/reset.ts +4 -2
  45. package/src/cli/statusline-tee.ts +5 -2
  46. package/src/cli/terminal-recovery.ts +7 -2
  47. package/src/cli/upgrade-runtime.ts +18 -15
  48. package/src/hub/board.ts +61 -11
  49. package/src/hub/bus.ts +11 -5
  50. package/src/hub/claude-state.ts +37 -0
  51. package/src/hub/config-apply.ts +3 -2
  52. package/src/hub/control-client.ts +1 -1
  53. package/src/hub/cost-governance.ts +76 -0
  54. package/src/hub/daemon.ts +269 -87
  55. package/src/hub/envelope.ts +1 -1
  56. package/src/hub/host-executable.ts +1 -1
  57. package/src/hub/hub-tools.ts +25 -1
  58. package/src/hub/instances.ts +29 -5
  59. package/src/hub/memory-profile.ts +67 -11
  60. package/src/hub/planning.ts +2 -0
  61. package/src/hub/release-admission.ts +64 -0
  62. package/src/hub/review-brief.ts +175 -0
  63. package/src/hub/reviewer-pool-config.ts +11 -0
  64. package/src/hub/reviewer-pool.ts +70 -0
  65. package/src/hub/routing.ts +58 -4
  66. package/src/hub/sdlc-release.ts +113 -0
  67. package/src/hub/sdlc.ts +205 -4
  68. package/src/hub/status-roster.ts +10 -0
  69. package/src/hub/task-sweep.ts +143 -0
  70. package/src/hub/tasks.ts +788 -52
  71. package/src/hub/usage.ts +57 -20
package/src/hub/daemon.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { readReleaseHostAdmission, type ReleaseHostAdmission } from "./release-admission.ts";
1
2
  import { ContainerRunner, type RunnerOptions, type RunReceipt } from "./check-runner.ts";
2
3
  import { fetchSpec, specAvailable, specRefs } from "./spec-read.ts";
3
4
  import { isDenied } from "../local/deny.ts";
@@ -14,17 +15,19 @@ import { needs, type NeedsInput } from "../ui/needs.ts";
14
15
  import { loadPetConfig } from "../pet/config.ts";
15
16
  import { projectionConfig, TextProjections, type ProjectionConfig } from "./text-projection.ts";
16
17
  import { policyPaths, matchesPolicy } from "./path-policy.ts";
18
+ import { buildReviewBrief } from "./review-brief.ts";
17
19
  import { profileName, projectProfile, resolveProfile, mergeProfileOverrides, type ProfileName } from "./profiles.ts";
18
20
  import { catalogAssignments, loadRoleCatalog, selectedRoles, roleIdentity } from "./roles.ts";
19
21
  import { ActivityRing } from "./activity.ts";
20
22
  import { JcodePeer } from "../adapters/jcode.ts";
21
23
  import { jcodeMcpReady } from "../cli/jcode-mcp.ts";
22
- import { loadSdlc, sdlcGh } from "./sdlc.ts";
24
+ import { loadSdlc, readSpecEdit, sdlcGh } from "./sdlc.ts";
25
+ import { claudeStateFile, ownTerminalRow, pathEvidence, transcriptPersisted } from "./claude-state.ts";
23
26
  import { peerRosterConfig, rosterAllows, type PeerRoster } from "./roster.ts";
24
27
  import { KNOWN_PEERS, OPTIONAL_PEERS, PLANNER_PEERS, SUPPORTED_PEERS, isSupportedPeer, STARTABLE_PEERS, isStartablePeer } from "../adapters/registry.ts";
25
28
  import { RecoveryDeferredHubError } from "./deferred-hub.ts";
26
29
  import { databaseBusySite, isDatabaseBusy, RecoveryDatabaseBusy } from "./recovery-error.ts";
27
- import { ADAPTERS, QUOTA_HANDOFF_PLATFORMS, NATIVE_CONFIG_BLOCKS, nativeConfigurationDefaults, mergeNativeConfiguration, retainNativeRouting, DAEMON_PORTS, definitionOf } from "../adapters/registry.ts";
30
+ import { ADAPTERS, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS, QUOTA_HANDOFF_PLATFORMS, NATIVE_CONFIG_BLOCKS, nativeConfigurationDefaults, mergeNativeConfiguration, retainNativeRouting, DAEMON_PORTS, definitionOf } from "../adapters/registry.ts";
28
31
  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
32
  import { effectiveInstanceRoles, instancePeerIds, validateInstances, type InstancesConfig } from "./instances.ts";
30
33
  import { INSTANCE_CONFIG_FILES, readInstanceConfig, readInstanceConfigLayers, readInstanceRoleConfig } from "./instance-config.ts";
@@ -46,7 +49,7 @@ import { resolveSettledRoles } from "./settled-roles.ts";
46
49
  import { CONFIG_SOURCE_FILES, configFingerprint, configSourceTexts, parseConfigSources, type ConfigSources } from "./config-reload.ts";
47
50
  import { eventLog, readEvents, tokenDeltas, type RoleIdentity } from "./events.ts";
48
51
  import { ExecutionBudget } from "./execution-budget.ts";
49
- import { claudeReportedTokens, readClaudeTranscriptUsage, waitForClaudeCompletion } from "./usage.ts";
52
+ import { claudeReportedTokens, readClaudeTranscriptCompletion, readClaudeTranscriptUsage, waitForClaudeCompletion } from "./usage.ts";
50
53
  import type { ServerWebSocket } from "bun";
51
54
  import { AcpPeer, QwenIdentityError, type PermissionRequest } from "../adapters/acp.ts";
52
55
  import { CodexPeer } from "../adapters/codex-appserver.ts";
@@ -61,6 +64,9 @@ import { profile, proxyEnv, type SandboxNetwork } from "../local/sandbox.ts";
61
64
  import { DEFAULT_NETWORK_ALLOW, startEgressProxy, type EgressProxy } from "../local/proxy.ts";
62
65
  import { runTool, toolResultFailed, TOOL_SCHEMAS, type ToolApproval, type ApprovalObserver, type ToolContext } from "../local/tools.ts";
63
66
  import { LocalPeer } from "../adapters/local-worker.ts";
67
+ import { ReviewerPrintPeer } from "../adapters/reviewer-print.ts";
68
+ import { reviewerPoolConfig } from "./reviewer-pool-config.ts";
69
+ import { reviewDiff } from "../adapters/claude-print.ts";
64
70
  import { Capture, skipTools } from "../memory/capture.ts";
65
71
  import { DEFAULT_OMNIROUTE, OmniRoute, type OmniRouteConfig } from "../omniroute/client.ts";
66
72
  import { Sidecar } from "../switchyard/sidecar.ts";
@@ -69,12 +75,12 @@ import { Board, isClosedTaskState, taskProposalAt, taskHasStarted, CLASSES, type
69
75
  import { Budget, claudeWindows, codexWindows, DEFAULT_BUDGET, type BudgetConfig } from "./budget.ts";
70
76
  import { HUB } from "./envelope.ts";
71
77
  import { recallMemoryLines, trimToTokens } from "../memory/recall.ts";
72
- import { closeSync, constants as fsConstants, openSync, readSync, statSync } from "node:fs";
78
+ import { closeSync, constants as fsConstants, openSync, lstatSync, readSync, statSync } from "node:fs";
73
79
  import { basename, isAbsolute } from "node:path";
74
80
  import { fileURLToPath } from "node:url";
75
81
  import { realPath, hubHome } from "./project.ts";
76
82
  import type { BusEvent } from "./bus.ts";
77
- import { CONDUCTOR_TOOLS, CONDUCTOR_TOOL_NAMES, DEFAULT_ROLES, roleContract, TASK_TOOLS, BOUNDED_TASK_TOOLS, profileTaskTools } from "./hub-tools.ts";
83
+ import { CONDUCTOR_TOOLS, CONDUCTOR_TOOL_NAMES, DEFAULT_ROLES, roleContract, TASK_TOOL_NAMES, TASK_TOOLS, BOUNDED_TASK_TOOLS, profileTaskTools } from "./hub-tools.ts";
78
84
  import { Conductor, ConductorHolds, conductorPeer, conductorProgressSink, publicConductorTask, publicPeerBudget, type ConductEvent } from "./conductor.ts";
79
85
  import { SupervisionFeed } from "./supervision.ts";
80
86
  import { drainCliAudits } from "../cli/identity-audit.ts";
@@ -115,10 +121,10 @@ import { ContextWindows, DEFAULT_CONTEXT, acpContext, claudeContext, type Contex
115
121
  import { projectFileTool, scopedWriteBlocked } from "../cli/facts-hook.ts";
116
122
  import { detectTooling } from "./tooling.ts";
117
123
  import { looksSecret, DEFAULT_LEASE_MS, extractNodes, memoryHome, SUGGESTED, taskKey, type CandidateStore, type MemoryStore, type GoalView, type Suggested, type View as MemoryView } from "@staix/agent-mem";
118
- import { AGENT_MEM_VERSION, openProfileMemory, openProfileCandidates, type MemoryProfileBinding, type UnboundMemoryProfileReceipt } from "./memory-profile.ts";
124
+ import { memoryProjectKey } from "@staix/agent-mem";
125
+ import { AGENT_MEM_VERSION, MEMORY_SPLIT_INCOMPLETE, memoryProjectFor, openProfileMemory, openProfileCandidates, type MemoryProfileBinding, type UnboundMemoryProfileReceipt } from "./memory-profile.ts";
119
126
  import { syncMemory } from "@staix/agent-mem";
120
127
  import { MEMORY_READ_TOOLS, memoryRead, validateMemoryRead } from "./read-tools.ts";
121
- import { memoryProjectKey } from "@staix/agent-mem";
122
128
  import { nativeRecall } from "../memory/recall.ts";
123
129
  import { promotionScreen, withoutPii, mentionsPrivatePath } from "./memory-policy.ts";
124
130
 
@@ -181,7 +187,7 @@ export interface HubConfig {
181
187
  /** Per-sender rate limits and repeat suppression for what agents send (issue #38). */
182
188
  limits: LimitsConfig;
183
189
  /** Reviewer choice from recorded review outcomes, once a reviewer has `min_reviews` of an implementer (issue #35). */
184
- review: { adaptive: boolean; min_reviews: number };
190
+ review: { adaptive: boolean; min_reviews: number; pool?: string[]; rerotate?: boolean };
185
191
  /** After an unplanned stop, start Kimi, Pi and the local worker again with their recorded sessions (issue #37). */
186
192
  recovery: { auto_resume_after_crash: boolean };
187
193
  /** Per peer, the hub-tool capabilities it has (issue #39); a peer not listed has all of them. */
@@ -435,6 +441,8 @@ function mlxPinProblem(routing: Routing, project: boolean): string | undefined {
435
441
  }
436
442
 
437
443
  export interface DaemonOptions {
444
+ /** Host-only admission transport; never selected by project config or tools. */
445
+ releaseHostAdmission?: (cwd: string) => Promise<ReleaseHostAdmission>;
438
446
  projectId?: string;
439
447
  instanceId?: string;
440
448
  cwd: string;
@@ -598,20 +606,26 @@ export async function startDaemon(opts: DaemonOptions) {
598
606
  let ready = false;
599
607
  try {
600
608
  const loadedConfig = opts.config ?? loadConfig(opts.cwd);
609
+ reviewerPoolConfig(loadedConfig.review, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS);
601
610
  const lifecycleConfig = loadSdlc(opts.cwd, VERSION);
602
611
  // Preserve embedding-owned policy references, but never publish reloads into shared defaults.
603
612
  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;
604
613
  const declarations = validateInstances(loadedConfig.instances);
605
614
  const extraIds = Object.keys(declarations);
615
+ // #450 I3: channel extras (Claude) attach from the person's terminal; the hub never starts them and their hello is admitted.
616
+ const channelExtras = extraIds.filter(id => instanceDefinitionOf(id, declarations)?.identity.mcp === "channel-plugin");
617
+ // Every Claude-family peer whose own state files the daemon reads: the builtin channel kinds, then their declared instances.
618
+ const channelPeers = [...ADAPTERS.filter(definition => definition.identity.mcp === "channel-plugin").map(definition => definition.kind), ...channelExtras];
606
619
  const executionRoots = new Map<string, string>();
607
620
  const executionOwners = new Map<string, { owner: PeerAdapter; cwd: string }>();
621
+ const reviewerUnavailable = new Map<string, string>();
608
622
  const roles = { ...loadedConfig.roles };
609
623
  const permissionModesFromInstances: Record<string, PermissionMode> = {};
610
624
  const startModesFromInstances: PeerStartConfig = {};
611
625
  const instancePermissionSources: NonNullable<HubConfig["permission_default_sources"]> = {};
612
626
  for (const [id, declaration] of Object.entries(declarations)) {
613
627
  roles[id] = effectiveInstanceRoles(id, declarations, loadedConfig.roles);
614
- startModesFromInstances[id] = { start_mode: "headless" };
628
+ if (!channelExtras.includes(id)) startModesFromInstances[id] = { start_mode: "headless" };
615
629
  if (declaration.permission_mode !== undefined) {
616
630
  permissionModesFromInstances[id] = declaration.permission_mode;
617
631
  instancePermissionSources[id] = { mode: declaration.permission_mode, source: ".agenthub/config.local.json" };
@@ -627,10 +641,18 @@ export async function startDaemon(opts: DaemonOptions) {
627
641
  const definitionOf = (peer: string) => instanceDefinitionOf(peer, declarations);
628
642
  const KNOWN_PEERS: readonly string[] = [...BASE_KNOWN_PEERS, ...extraIds];
629
643
  const SUPPORTED_PEERS: readonly string[] = [...BASE_SUPPORTED_PEERS, ...extraIds];
630
- const STARTABLE_PEERS: readonly string[] = [...BASE_STARTABLE_PEERS, ...extraIds];
644
+ const STARTABLE_PEERS: readonly string[] = [...BASE_STARTABLE_PEERS, ...extraIds.filter(id => !channelExtras.includes(id))];
631
645
  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]);
646
+ const isStartablePeer = (peer: unknown): peer is string => typeof peer === "string" && !channelExtras.includes(peer) && definitionOf(peer)?.start.conductorStartable === true;
647
+ const RESERVED_IDS = new Set([...BASE_RESERVED_IDS, ...extraIds.filter(id => !channelExtras.includes(id))]);
648
+ // #223 R2: a declared instance whose effective roles are exactly reviewer gets review requests and its own task notices only.
649
+ const reviewOnlyPeer = (peer: string): boolean => Object.hasOwn(declarations, peer) && config.roles[peer]?.length === 1 && config.roles[peer]![0] === "reviewer";
650
+ /** #520: a task under review shows its review brief to whoever may read it (owner, reviewer, conductor). */
651
+ const briefField = (task: Task): { brief?: string } => { if (task.state !== "in_review") return {}; const text = tasks.briefText(task); return text ? { brief: text } : {}; };
652
+ /** #223 R2: a review-only instance reads the spec, refs and earlier verdicts of a task, never the builder's narrative. */
653
+ const reviewerView = (peer: string, view: Record<string, unknown>): Record<string, unknown> => !reviewOnlyPeer(peer) || !Array.isArray(view.history) ? view
654
+ : { ...view, history: (view.history as { event?: unknown; note?: unknown }[]).map(({ note, ...h }) => h.event === "approved" || h.event === "changes_requested" ? { ...h, note } : h) };
655
+ const reviewOnlyItem = (peer: string, env: Envelope): boolean => env.from === USER || env.from === HUB && (env.kind === "review" || (env.kind === "task" && env.to?.includes(peer) === true));
634
656
  const SETTINGS = projectSettings(declarations);
635
657
  const settingDef = (key: unknown) => instanceSettingDef(key, declarations);
636
658
  const accountOf = (peer: string) => definitionOf(peer)?.budget?.account ?? peer;
@@ -872,6 +894,7 @@ export async function startDaemon(opts: DaemonOptions) {
872
894
  * has not stopped since its cohort task closed. Workflow events and anything to the console are never held back.
873
895
  */
874
896
  const silence = (env: Envelope, to: PeerId): string | undefined => {
897
+ if (reviewOnlyPeer(to) && env.to?.includes(to) && !reviewOnlyItem(to, env)) return `${to} is a review-only instance: it receives review requests only (send a review through the task board)`;
875
898
  if (!turnFree() || env.from === USER || env.from === HUB || env.from === DIGEST || env.priority === "fyi" || env.kind !== "chat" || to === USER) return undefined;
876
899
  const cohort = tasks.silenced(env.from, to);
877
900
  if (!cohort) return undefined;
@@ -914,7 +937,7 @@ export async function startDaemon(opts: DaemonOptions) {
914
937
  const staleOff = () => config.experiments?.stale_notices === "deliver";
915
938
  if (staleOff()) log("experiment: stale notices are delivered as before 0.12.4 (the issue #106 ablation)");
916
939
  const requestedPeerStops = new Set<string>();
917
- const bus = new Bus({ rostered: membershipAllowed, deliveryHeld: peer => requestedPeerStops.has(peer) || rosterChanging === peer, journal, batchMax: config.batch_max, batchMs: config.batch_ms, queueCap: config.queue_cap, condense: (envs) => inference?.condense(envs) ?? Promise.resolve(envs), admit, relevant: (peer, env) => env.refs?.boundedCheck || env.refs?.supervision || env.refs?.backlog ? relevantNotice(peer, env) : staleOff() || relevantNotice(peer, env), silence,
940
+ const bus = new Bus({ rostered: membershipAllowed, deliveryHeld: peer => requestedPeerStops.has(peer) || rosterChanging === peer, journal, batchMax: config.batch_max, batchMs: config.batch_ms, queueCap: config.queue_cap, condense: (envs) => inference?.condense(envs) ?? Promise.resolve(envs), admit, relevant: (peer, env) => reviewOnlyPeer(peer) && !reviewOnlyItem(peer, env) ? `not for ${peer}: a review-only instance receives review requests only` : env.refs?.boundedCheck || env.refs?.supervision || env.refs?.backlog ? relevantNotice(peer, env) : staleOff() || relevantNotice(peer, env), silence,
918
941
  onImportantQueued: (peer, counts) => { const p = bus.peers.get(peer); if (p instanceof WsPeer) p.notifyQueued(counts); } });
919
942
  startupCleanup.push(() => bus.closeJournal());
920
943
  const manualPaused = new Set<PeerId>(bus.manualPausedPeers()); // recovery never lifts an operator's pause
@@ -961,7 +984,9 @@ export async function startDaemon(opts: DaemonOptions) {
961
984
  const recalled = new Set<PeerId>(); // once per peer per hub run, however often the peer's session restarts
962
985
  // #258: the hub's own store, machine-wide in ~/.agenthub/memory. Entries are filed under the project's memory key (its
963
986
  // normalized origin remote, the same on every machine; without one, its path). Fail-open: without it the hub works as before.
964
- const memoryProject = memoryProjectKey(opts.cwd);
987
+ // Daemon start never depends on the memory home: an unresolvable home falls back to the raw key.
988
+ const memoryProjectRaw = memoryProjectKey(opts.cwd);
989
+ const memoryProject = (() => { try { return memoryProjectFor(opts.cwd); } catch { return memoryProjectRaw; } })();
965
990
  let memoryStore: MemoryStore | undefined;
966
991
  let memoryBinding: MemoryProfileBinding | undefined;
967
992
  let memoryProblem: string | undefined;
@@ -973,11 +998,11 @@ export async function startDaemon(opts: DaemonOptions) {
973
998
  memoryNotice(`hub native memory disabled: ${reason}`);
974
999
  };
975
1000
  if (config.memory.native) {
976
- try { const opened = openProfileMemory({ ttlMs: (config.memory.short_ttl_days ?? 14) * 24 * 3600_000, log, screen: promotionScreen(memoryProject, opts.cwd, () => activeRouting(), () => config.pii?.paths ?? [], (key) => {
1001
+ try { const opened = openProfileMemory({ project: memoryProject, projectKey: memoryProjectRaw, ttlMs: (config.memory.short_ttl_days ?? 14) * 24 * 3600_000, log, screen: promotionScreen(memoryProject, opts.cwd, () => activeRouting(), () => config.pii?.paths ?? [], (key) => {
977
1002
  const m = /#(\d+)@(\d+)$/.exec(key), t = m ? board.get(Number(m[1])) : undefined;
978
1003
  return t && String(t.created) === m![2] ? [...(t.refs.paths ?? []), ...(t.plan?.paths ?? [])] : [];
979
1004
  }) }, memoryUnavailable); memoryStore = opened.store; memoryBinding = opened.binding; startupCleanup.push(() => opened.store.close()); }
980
- catch { if (!memoryProblem) memoryUnavailable("memory profile unavailable; inspect the selector and generation, then restart the hub"); }
1005
+ catch (error) { if (!memoryProblem) memoryUnavailable(error instanceof Error && error.message === MEMORY_SPLIT_INCOMPLETE ? error.message : "memory profile unavailable; inspect the selector and generation, then restart the hub"); }
981
1006
  }
982
1007
  const memoryProfileStatus = (person = false) => {
983
1008
  const value = memoryBinding?.status(memoryStore) ?? ({
@@ -1080,7 +1105,7 @@ export async function startDaemon(opts: DaemonOptions) {
1080
1105
 
1081
1106
  /** Session-start cross-platform recall. Awaited before the peer can receive anything, so it rides on the first delivery. Fail-open. */
1082
1107
  async function ensurePreface(peer: PeerId): Promise<void> {
1083
- if ((!config.memory.enabled && !memoryStore) || recalled.has(peer)) return;
1108
+ if ((!config.memory.enabled && !memoryStore) || recalled.has(peer) || reviewOnlyPeer(peer)) return; // #223 R2: no shared memory in a fresh review
1084
1109
  // The hub's own long-term decisions, contracts and fails lead; claude-mem's recent work follows, each with half the budget.
1085
1110
  let own: string | undefined;
1086
1111
  try {
@@ -1276,9 +1301,55 @@ export async function startDaemon(opts: DaemonOptions) {
1276
1301
  specReason: !may(peer, "spec_read") ? `capabilities.${peer}` : specState.reason ?? "gh unauthenticated" }];
1277
1302
  }));
1278
1303
  };
1304
+ const releaseQuotaSnapshot = () => {
1305
+ const headroom = budget.headroom(), rows = [...bus.peers].filter(([, peer]) => peer.state !== "offline");
1306
+ const reasons: string[] = [], notes: string[] = [];
1307
+ for (const [id] of rows) {
1308
+ const definition = definitionOf(id), account = definition?.budget?.account ?? id;
1309
+ if (manualPaused.has(id)) reasons.push(`${id}: manual pause; ahub resume ${id}`);
1310
+ if (budget.record(account)) reasons.push(`${id}: paused quota window; inspect ahub budget or ahub budget resume ${id}`);
1311
+ if (definition && (!definition.budget || definition.budget.quotaExempt)) continue;
1312
+ // Source availability belongs to the shared account, including its offline base and reviewer variants.
1313
+ // The soft token window uses the same definition capability that resets it on resume.
1314
+ const hasSource = accountMembers(account).some(member => {
1315
+ const policy = definitionOf(member)?.budget;
1316
+ return !!policy?.reader || !!policy?.resetSoftTokensOnResume && config.budget.kimi_tokens_5h > 0;
1317
+ });
1318
+ if (definition && !hasSource) {
1319
+ notes.push(`${id}: no quota source`);
1320
+ continue;
1321
+ }
1322
+ const reading = headroom[account];
1323
+ if (!reading || !Number.isFinite(reading.headroom)) reasons.push(`${id}: quota window unknown or stale; inspect ahub budget or ahub budget set ${id} <0..1>`);
1324
+ else if (reading.headroom <= 1 - config.budget.gate) reasons.push(`${id}: quota window below release headroom; wait for reset or inspect ahub budget set ${id} <0..1>`);
1325
+ }
1326
+ const idle = rows.every(([, peer]) => peer.state === "idle");
1327
+ return { quota: reasons.length === 0, idle, reasons, notes, signature: JSON.stringify({ gate: config.budget.gate, softTokens: config.budget.kimi_tokens_5h, rows: rows.map(([id, peer]) => [id, peer.state]), headroom, paused: rows.map(([id]) => manualPaused.has(id)), records: Object.entries(budget.status()).map(([peer, row]) => [peer, row.paused?.since, row.paused?.resetsAt]) }) };
1328
+ };
1329
+ const releaseWindow = async () => {
1330
+ const host = await (opts.releaseHostAdmission ?? readReleaseHostAdmission)(opts.cwd), quota = releaseQuotaSnapshot();
1331
+ if (stopping || recoveryActive()) quota.reasons.push("recovery or shutdown is holding release planning");
1332
+ return { at: Date.now(), host: host.admitted && !stopping && !recoveryActive(), quota: quota.quota, idle: quota.idle, reasons: [...(host.reason ? [host.reason] : []), ...quota.reasons, ...quota.notes], close: host.close, current: () => host.current?.() !== false && !stopping && !recoveryActive() && releaseQuotaSnapshot().signature === quota.signature };
1333
+ };
1279
1334
  const tasks: Tasks = new Tasks({
1335
+ releaseWindow,
1280
1336
  capabilities: peerCapabilities, repository: memoryRepo,
1337
+ plannerBlocks: () => Object.fromEntries(bus.knownPeers().map(peer => {
1338
+ const codes: ("approvals" | "quota")[] = [];
1339
+ if ([...permissions.values()].some(request => request.peer === peer && request.expiresAt > Date.now())) codes.push("approvals");
1340
+ if (budget.record(peer)) codes.push("quota");
1341
+ return [peer, codes];
1342
+ })),
1281
1343
  instances: () => declarations,
1344
+ reviewOnly: reviewOnlyPeer,
1345
+ // #223: a headless reviewer's verdict holds only while the same child it started with still runs for that task.
1346
+ reviewAuthority: (by, task) => {
1347
+ if (!definitionOf(by)?.reviewer) return undefined;
1348
+ const peer = bus.peers.get(by) as ReviewerPrintPeer | undefined, token = peer?.reviewToken(task);
1349
+ return () => token !== undefined && bus.peers.get(by) === peer && peer?.reviewToken(task) === token ? undefined : `no review of task #${task} is running for ${by}`;
1350
+ },
1351
+ // #223 R4: fresh only when the verdict comes from a reviewer session the hub started for this task's review.
1352
+ reviewContext: (by, task) => !!definitionOf(by)?.reviewer && (bus.peers.get(by) as ReviewerPrintPeer | undefined)?.reviewing === task ? "fresh" : "continuing",
1282
1353
  // Proof belongs to the adapter generation actually started in the validated root. A role-only
1283
1354
  // reload cannot turn a canonical reviewer process into an isolated implementation owner.
1284
1355
  ownerIsolation: () => Object.fromEntries(extraIds.map(id => {
@@ -1294,6 +1365,10 @@ export async function startDaemon(opts: DaemonOptions) {
1294
1365
  catalog,
1295
1366
  sessionRoles: peer => bus.peers.has(peer) && bus.stateOf(peer) !== "offline" ? preparedSessionRoles.get(peer) ?? [] : [],
1296
1367
  projectedReview: task => reviewProjection(task),
1368
+ reviewBrief: task => {
1369
+ const root = executionRoot(task.owner ?? "");
1370
+ return buildReviewBrief(root, task.refs.commit, { privatePath: path => isDenied(path, config.local.deny) || matchesPolicy(root, path, config.pii?.paths ?? [], true), nameable: tasks.nameable });
1371
+ },
1297
1372
  sdlc: { config: () => lifecycleConfig, gh: (args, timeout, _cwd, bound) => sdlcGh(args, timeout, opts.cwd, bound) },
1298
1373
  board,
1299
1374
  bus,
@@ -1368,7 +1443,7 @@ export async function startDaemon(opts: DaemonOptions) {
1368
1443
  recordCohort: (c) => event({ type: "cohort", ...c }),
1369
1444
  // The trace holds peer names and numbers only (never task text): it is the inputs a later check of the prediction needs.
1370
1445
  recordSplit: (task, p, where) => event({ type: "split", task, where, verdict: p.verdict, ...(p.single ? { single: p.single, splitS: p.splitS, singleS: p.singleS } : {}), ...(p.verdict === "unknown" ? { reason: p.trace.at(-1)!.replace(/^ {2}unknown: /, "").slice(0, 200) } : {}), trace: p.trace.slice(1).map((l) => l.trim()).slice(0, 10) }),
1371
- failing: () => bus.failingPeers(),
1446
+ failing: () => ({ ...bus.failingPeers(), ...Object.fromEntries(reviewerUnavailable) }),
1372
1447
  held: () => Object.fromEntries(bus.knownPeers().flatMap((peer) => { const hold = queueHold(peer); return hold ? [[peer, hold]] : []; })),
1373
1448
  });
1374
1449
  relevantNotice = (peer, env) => {
@@ -1547,8 +1622,8 @@ export async function startDaemon(opts: DaemonOptions) {
1547
1622
  } catch { /* no transcript yet: no readback */ }
1548
1623
  };
1549
1624
  /** Claude's transcript for this session: the one the status line reported, or the hook's own, inside Claude's projects. */
1550
- const claudeTranscript = (sessionId: string | undefined, fromHook: unknown): string | undefined => {
1551
- const reported = claudeSession();
1625
+ const claudeTranscript = (sessionId: string | undefined, fromHook: unknown, peer = "claude"): string | undefined => {
1626
+ const reported = claudeSession(peer);
1552
1627
  if (reported.transcriptPath && (!sessionId || reported.sessionId === sessionId)) return reported.transcriptPath;
1553
1628
  if (!sessionId || typeof fromHook !== "string" || !isAbsolute(fromHook) || basename(fromHook) !== `${sessionId}.jsonl`) return undefined;
1554
1629
  try {
@@ -1572,6 +1647,7 @@ export async function startDaemon(opts: DaemonOptions) {
1572
1647
  const activeAt = new Map<PeerId, number>();
1573
1648
  interface NativeTurnStart { at: number; baseline?: string; generation: string; session: string; launch: string }
1574
1649
  const nativeTurnStart = new Map<PeerId, NativeTurnStart>();
1650
+ const stopUnconfirmedNoticed = new WeakSet<NativeTurnStart>();
1575
1651
  const nativeStopWaits = new Map<PeerId, { start: NativeTurnStart; wait: ReturnType<typeof waitForClaudeCompletion> }>();
1576
1652
  /** Every Claude hook call's own start-up and the hub's time for it, summed per turn (issue #108): reported at Stop. */
1577
1653
  const hookStats = new Map<PeerId, { n: number; startupMs: number; hubMs: number; maxStartupMs: number }>();
@@ -1615,7 +1691,7 @@ export async function startDaemon(opts: DaemonOptions) {
1615
1691
  const contextSession = (peer: PeerId): string | undefined => {
1616
1692
  const p = bus.peers.get(peer);
1617
1693
  if (p instanceof WsPeer && p.claiming) return undefined;
1618
- if (definitionOf(peer)?.turnEnd === "native-hook") { const c = claudeSession(); return c.sessionId ? `${c.sessionId}:${c.launchId ?? ""}` : undefined; }
1694
+ if (definitionOf(peer)?.turnEnd === "native-hook") { const c = claudeSession(peer); return c.sessionId ? `${c.sessionId}:${c.launchId ?? ""}` : undefined; }
1619
1695
  if (p instanceof CodexPeer) return p.thread || undefined;
1620
1696
  if (p instanceof AcpPeer || p instanceof PiPeer) { const id = p.recoveryMetadata().sessionId; return typeof id === "string" ? id : undefined; }
1621
1697
  return undefined;
@@ -1829,7 +1905,7 @@ export async function startDaemon(opts: DaemonOptions) {
1829
1905
  };
1830
1906
  // Claude's numbers arrive through the status line tee `ahub claude` installs (src/cli/statusline-tee.ts).
1831
1907
  let claudeUsageSeen = 0;
1832
- let claudeContextSeen = "";
1908
+ const claudeContextSeen = new Map<string, string>();
1833
1909
  const contextTailSeen = new Map<PeerId, string>();
1834
1910
  const intervals = [
1835
1911
  setInterval(() => {
@@ -1861,16 +1937,16 @@ export async function startDaemon(opts: DaemonOptions) {
1861
1937
  for (const c of consoles) if (c.data.tail) c.send(JSON.stringify({ t: "context", peer, reading }));
1862
1938
  }
1863
1939
  }
1864
- try {
1865
- const file = join(opts.stateDir, "claude-context.json");
1940
+ for (const claudePeer of channelPeers) try {
1941
+ const file = join(opts.stateDir, claudeStateFile("claude-context", claudePeer));
1866
1942
  const payload = readFileSync(file, "utf8");
1867
- if (payload === claudeContextSeen) return;
1943
+ if (payload === claudeContextSeen.get(claudePeer)) continue;
1868
1944
  const c = JSON.parse(payload);
1869
- const session = claudeSession();
1870
- if (c.instanceId !== instanceId || !session.sessionId || c.sessionId !== session.sessionId || c.launchId !== session.launchId || !["idle", "busy", "paused"].includes(bus.stateOf("claude"))) return;
1871
- claudeContextSeen = payload;
1872
- const bound = contextSession("claude");
1873
- if (bound) contexts.report("claude", claudeContext(c.context, bound, c.at), bound);
1945
+ const session = claudeSession(claudePeer);
1946
+ if (c.instanceId !== instanceId || !session.sessionId || c.sessionId !== session.sessionId || c.launchId !== session.launchId || !["idle", "busy", "paused"].includes(bus.stateOf(claudePeer))) continue;
1947
+ claudeContextSeen.set(claudePeer, payload);
1948
+ const bound = contextSession(claudePeer);
1949
+ if (bound) contexts.report(claudePeer, claudeContext(c.context, bound, c.at), bound);
1874
1950
  } catch { /* absent or unreadable native telemetry is unknown */ }
1875
1951
  }, 1000),
1876
1952
  setInterval(() => budget.tick(), 30_000),
@@ -1925,7 +2001,7 @@ export async function startDaemon(opts: DaemonOptions) {
1925
2001
  const SERVER_JS = join(import.meta.dir, "..", "..", "plugins", "agent-hub", "server.js");
1926
2002
  // What the hub's MCP server offers a native peer. Codex approves them in config, Kimi's requests for exactly these
1927
2003
  // names are answered by the hub; every call still passes daemon role/capability checks before its effects.
1928
- const HUB_TOOLS = ["hub_send", ...TASK_TOOLS.map((t) => t.name), ...CONDUCTOR_TOOLS.map((t) => t.name)];
2004
+ const HUB_TOOLS = ["hub_send", ...TASK_TOOL_NAMES, ...CONDUCTOR_TOOLS.map((t) => t.name)];
1929
2005
  const HUB_TOOL_TITLES = new Set(HUB_TOOLS.map((name) => `mcp__agent-hub__${name}`));
1930
2006
  const toolEnv = (peer: PeerId, deliveryOnly = false) => ({ ...(deliveryOnly ? { AGENTHUB_DELIVERY_ONLY: "true" } : {}), AGENTHUB_MODE: "tools", AGENTHUB_PEER_ID: peer, AGENTHUB_STATE_DIR: opts.stateDir, AGENTHUB_PROJECT_DIR: opts.cwd });
1931
2007
 
@@ -1980,11 +2056,13 @@ export async function startDaemon(opts: DaemonOptions) {
1980
2056
  if (op === "hub_memory_correct") correctionInput(a);
1981
2057
  if (CONDUCTOR_TOOL_NAMES.has(op)) {
1982
2058
  if (piiTurn && ["hub_task_assign", "hub_task_escalate", "hub_peer_start"].includes(op)) throw new PreEffectToolRefusal("conductor mutations are unavailable during a PII turn");
1983
- return JSON.stringify(await conductor.execute(by, op, a));
2059
+ const result = await conductor.execute(by, op, a);
2060
+ const shown = op === "hub_task_show" && result && typeof result === "object" ? board.get(Number(a.id)) : undefined;
2061
+ return JSON.stringify(shown ? { ...reviewerView(by, result as Record<string, unknown>), ...briefField(shown) } : result);
1984
2062
  }
1985
2063
  // Inside a PII turn the worker's words may carry the PII whatever they are attached to: a note would go to
1986
2064
  // claude-mem (a cloud observer) and a new task could be routed to a cloud peer without matching any pattern.
1987
- if (piiTurn && (MEMORY_READ_TOOLS.has(op) || op === "hub_memory_correct" || op === "hub_remember" || op === "hub_task_propose" || op === "hub_sdlc_record" || op === "hub_backlog_plan" || op === "hub_backlog_undo" || op === "hub_backlog_pull" || op === "hub_backlog_split" || op === "release_new")) throw new PreEffectToolRefusal(`${op} is not available while working on a PII task: its text must not leave this machine`);
2065
+ if (piiTurn && (MEMORY_READ_TOOLS.has(op) || op === "hub_memory_correct" || op === "hub_remember" || op === "hub_task_propose" || op === "hub_sdlc_record" || op === "hub_plan_review" || op === "hub_sdlc_costs" || op.startsWith("hub_release_") || op === "hub_backlog_return_untouched" || op === "hub_backlog_plan" || op === "hub_backlog_undo" || op === "hub_backlog_pull" || op === "hub_backlog_split" || op === "release_new")) throw new PreEffectToolRefusal(`${op} is not available while working on a PII task: its text must not leave this machine`);
1988
2066
  // The same words attached to an ordinary task would reach its reviewer, its owner, overlapping owners (a plan, the
1989
2067
  // completed-change notice) and claude-mem. A turn that holds a PII task acts on ordinary tasks in a turn of its own.
1990
2068
  if (piiTurn && (op === "hub_task_done" || op === "hub_review" || (op === "hub_task_accept" && a.plan != null))) {
@@ -2098,8 +2176,28 @@ export async function startDaemon(opts: DaemonOptions) {
2098
2176
  }
2099
2177
  case "hub_backlog_list":
2100
2178
  return JSON.stringify(tasks.backlogList(by));
2179
+ case "hub_release_new":
2180
+ return JSON.stringify(tasks.publicRelease(await tasks.newRelease(by, { name: a.name, goal: a.goal, order: a.order })));
2181
+ case "hub_release_report":
2182
+ return JSON.stringify(tasks.releaseReport(by, a.id));
2183
+ case "hub_release_configure":
2184
+ return JSON.stringify(tasks.publicRelease(await tasks.configureRelease(by, { id: a.id, revision: a.revision, hotfix: a.hotfix, state: a.state, cut: a.cut })));
2185
+ case "hub_release_assign":
2186
+ return JSON.stringify(tasks.publicView(await tasks.assignRelease(by, { id: a.id, revision: a.revision, release: a.release, reason: a.reason })));
2187
+ case "hub_release_cut":
2188
+ return JSON.stringify(tasks.publicRelease(await tasks.cutRelease(by, { id: a.id, revision: a.revision })));
2101
2189
  case "hub_release_list":
2102
2190
  return JSON.stringify(tasks.releaseList(by));
2191
+ case "hub_planner_groom":
2192
+ return JSON.stringify(await tasks.groomingDigest(by));
2193
+ case "hub_sdlc_costs":
2194
+ return JSON.stringify(await tasks.costReport(by, a.repo));
2195
+ case "hub_plan_review": {
2196
+ const { id, ...review } = a;
2197
+ return JSON.stringify(tasks.publicView(await tasks.recordPlanReview(by, id, review)));
2198
+ }
2199
+ case "hub_backlog_return_untouched":
2200
+ return JSON.stringify(tasks.publicView(await tasks.returnUntouchedToBacklog(by, { id: a.id, revision: a.revision, reason: a.reason })));
2103
2201
  case "hub_backlog_plan":
2104
2202
  return JSON.stringify(tasks.publicView(await tasks.planBacklog(by, { id: a.id, revision: a.revision, fields: a.fields, reason: a.reason })));
2105
2203
  case "hub_backlog_pull":
@@ -2120,7 +2218,8 @@ export async function startDaemon(opts: DaemonOptions) {
2120
2218
  return [line(t), overlap, tasks.claimRoleGuidance(t, by), t.owner === by ? tasks.planGuardText(t) : ""].filter(Boolean).join("\n");
2121
2219
  }
2122
2220
  case "hub_task_accept": {
2123
- const t = tasks.accept(by, a.id, a.plan);
2221
+ // S1 spec acceptance and S9 plan review are checked against one task snapshot before acceptance.
2222
+ const t = await tasks.acceptReviewed(by, a.id, a.plan);
2124
2223
  // A silent cohort (issue #107): the owners' plans come with every accept, as what the newcomer works from.
2125
2224
  const silent = tasks.silentFor(t.id);
2126
2225
  const overlap = a.plan == null && !silent ? "" : tasks.overlaps(t);
@@ -2151,9 +2250,10 @@ export async function startDaemon(opts: DaemonOptions) {
2151
2250
  return extra ? `${result}\n${extra}` : result;
2152
2251
  }
2153
2252
  case "hub_sdlc_record":
2253
+ await tasks.specStageGate(by, a.id, a.receipt); // #481 S1: a live spec edit-time check before a build stage
2154
2254
  return line(tasks.recordLifecycle(by, a.id, a.receipt));
2155
2255
  case "hub_review":
2156
- return line(await tasks.review(by, a.id, a.verdict, a.note, a.unmet, a.head));
2256
+ return line(await tasks.review(by, a.id, a.verdict, a.note, a.unmet, a.head, a.coverage));
2157
2257
  case "hub_remember":
2158
2258
  return tasks.remember(by, a);
2159
2259
  case "hub_checkpoint": {
@@ -2193,7 +2293,8 @@ export async function startDaemon(opts: DaemonOptions) {
2193
2293
  if (op === "task_show" && by !== USER) {
2194
2294
  const task = board.get(Number(a.id));
2195
2295
  if (task?.state === "backlog") throw new PreEffectToolRefusal("backlog is read through planner tools");
2196
- return JSON.stringify(task ? { ...publicConductorTask(task, (t) => tasks.publicView(t, true)), flowRevision: task.history.length } : `no task #${a.id}`);
2296
+ if (!task) return JSON.stringify(`no task #${a.id}`);
2297
+ return JSON.stringify({ ...reviewerView(by, publicConductorTask(task, (t) => tasks.publicView(t, true))), ...briefField(task), flowRevision: task.history.length });
2197
2298
  }
2198
2299
  if (op === "route_explain" && by !== USER) {
2199
2300
  if (a.id !== undefined && board.get(Number(a.id))?.state === "backlog") throw new PreEffectToolRefusal("backlog is read through planner tools");
@@ -2220,6 +2321,10 @@ export async function startDaemon(opts: DaemonOptions) {
2220
2321
  case "backlog_pull":
2221
2322
  case "backlog_return":
2222
2323
  return line(await tasks.moveBacklog(by, a.id, op === "backlog_pull"));
2324
+ case "review_brief": {
2325
+ const task = board.get(Number(a.id));
2326
+ return task ? tasks.briefText(task) || `task #${task.id} names no commit, or is private: no brief` : `no task #${a.id}`;
2327
+ }
2223
2328
  case "task_show": {
2224
2329
  const task = board.get(Number(a.id));
2225
2330
  return JSON.stringify(task ? { ...task, flowRevision: task.history.length, reviews: board.reviews({ task: task.id }) } : `no task #${a.id}`, null, 2);
@@ -2349,19 +2454,20 @@ export async function startDaemon(opts: DaemonOptions) {
2349
2454
  // #64: a Claude session that never persisted a transcript cannot be resumed, so the
2350
2455
  // restore gate accepted a fresh session and nothing it stood for is lost. Snapshots
2351
2456
  // written before this flag existed are re-derived from disk.
2352
- const unpersistedClaude = definitionOf(saved.id)?.session.missingEvidence === "transcript" && (saved.sessionPersisted === false || (saved.sessionPersisted === undefined && !claudeTranscriptPersisted(saved.sessionId)));
2457
+ const unpersistedClaude = definitionOf(saved.id)?.session.missingEvidence === "transcript" && (saved.sessionPersisted === false || (saved.sessionPersisted === undefined && !claudeTranscriptPersisted(saved.id, saved.sessionId)));
2353
2458
  if (!unpersistedClaude && !waived()) return false;
2354
2459
  }
2355
2460
  return true;
2356
2461
  });
2357
2462
  };
2358
- const claudeSession = (): { sessionId?: string; transcriptPath?: string; launchId?: string } => {
2463
+ /** #450 I3: each Claude peer (claude and every claude-<name> instance) has its own session and launch files. */
2464
+ const claudeSession = (peer = "claude"): { sessionId?: string; transcriptPath?: string; launchId?: string } => {
2359
2465
  try {
2360
- const value = JSON.parse(readFileSync(join(opts.stateDir, "claude-session.json"), "utf8"));
2466
+ const value = JSON.parse(readFileSync(join(opts.stateDir, claudeStateFile("claude-session", peer)), "utf8"));
2361
2467
  if (value.instanceId !== instanceId) return {};
2362
2468
  let currentLauncher = false;
2363
2469
  try {
2364
- const launch = JSON.parse(readFileSync(join(opts.stateDir, "claude-launch.json"), "utf8"));
2470
+ const launch = JSON.parse(readFileSync(join(opts.stateDir, claudeStateFile("claude-launch", peer)), "utf8"));
2365
2471
  if (launch.instanceId === instanceId && typeof launch.launchId === "string" && launch.launchId) {
2366
2472
  currentLauncher = true;
2367
2473
  if (value.launchId !== launch.launchId) return {};
@@ -2369,7 +2475,7 @@ export async function startDaemon(opts: DaemonOptions) {
2369
2475
  } catch { /* older/manual launches retain the existing instance fence */ }
2370
2476
  try {
2371
2477
  const records = JSON.parse(readFileSync(join(opts.stateDir, "terminal-recovery.json"), "utf8"));
2372
- const current = Array.isArray(records) ? records.find((row) => typeof row?.peer === "string" && definitionOf(row.peer)?.transport === "claude-channel" && row?.projectRoot === opts.cwd && row?.instanceId === instanceId) : undefined;
2478
+ const current = ownTerminalRow(records, peer, opts.cwd, instanceId);
2373
2479
  if (!currentLauncher && current?.launchId && value.launchId !== current.launchId) return {};
2374
2480
  } catch { /* no managed terminal record: the instance fence is still enforced */ }
2375
2481
  return {
@@ -2427,33 +2533,30 @@ export async function startDaemon(opts: DaemonOptions) {
2427
2533
  }
2428
2534
  /** Claude persists projects/<slug>/<sessionId>.jsonl only with the first turn. Prefer the
2429
2535
  * path Claude itself reported through the status line; fall back to the slug computation. */
2430
- const claudeTranscriptPersisted = (sessionId: string): boolean => {
2431
- const reported = claudeSession();
2432
- if (reported.sessionId === sessionId && reported.transcriptPath) return existsSync(reported.transcriptPath);
2433
- const config = process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude");
2434
- return existsSync(join(config, "projects", opts.cwd.replace(/[^a-zA-Z0-9]/g, "-"), `${sessionId}.jsonl`));
2435
- };
2536
+ // ponytail: the version probe reads the builtin claude peer only; extend per peer if an instance's version matters (#450 I3).
2537
+ const claudeTranscriptPersisted = (peer: string, sessionId: string): boolean => transcriptPersisted(claudeSession(peer), sessionId, Object.hasOwn(declarations, peer),
2538
+ join(process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude"), "projects", opts.cwd.replace(/[^a-zA-Z0-9]/g, "-"), `${sessionId}.jsonl`), path => pathEvidence(path, lstatSync));
2436
2539
  // Optional native usage: only the instance-fenced explicit Claude session is read.
2437
2540
  // IDs persist in telemetry, so a restart/resume never counts the same native message twice.
2438
2541
  const claudeNativeUsageSeen = new Set(readEvents(join(opts.stateDir, "events.jsonl")).filter(e => e.type === "usage" && e.source === "claude_transcript").map(e => (e as { id: string }).id));
2439
- const collectClaudeUsage = () => {
2440
- const session = claudeSession();
2441
- if (!session.sessionId || !session.transcriptPath) return;
2542
+ const collectClaudeUsage = () => { for (const peer of channelPeers) {
2543
+ const session = claudeSession(peer);
2544
+ if (!session.sessionId || !session.transcriptPath) continue;
2442
2545
  for (const record of readClaudeTranscriptUsage(session.sessionId, session.transcriptPath)) {
2443
2546
  if (claudeNativeUsageSeen.has(record.id)) continue;
2444
2547
  claudeNativeUsageSeen.add(record.id);
2445
2548
  const reported = claudeReportedTokens(record.usage);
2446
- if (reported !== undefined) addTokens("claude", reported);
2447
- event({ type: "usage", peer: "claude", source: "claude_transcript", id: record.id, ...record.usage, ...taskAttribution("claude"), ...(record.servedModel ? { servedModel: record.servedModel } : {}), ...(record.at ? { measuredAt: record.at } : {}) });
2549
+ if (reported !== undefined) addTokens(peer, reported);
2550
+ event({ type: "usage", peer, source: "claude_transcript", id: record.id, ...record.usage, ...taskAttribution(peer), ...(record.servedModel ? { servedModel: record.servedModel } : {}), ...(record.at ? { measuredAt: record.at } : {}) });
2448
2551
  }
2449
- };
2552
+ } };
2450
2553
  const claudeUsageTimer = setInterval(collectClaudeUsage, 2000);
2451
2554
  claudeUsageTimer.unref(); intervals.push(claudeUsageTimer);
2452
2555
  const recoveryPeers = (): Record<string, RestartPeerSnapshot> => Object.fromEntries([...bus.peers].map(([id, peer]) => {
2453
2556
  const metadata = peer.recoveryMetadata?.() ?? {};
2454
2557
  const row: RestartPeerSnapshot = { id, state: peer.state, queueIds: bus.queueIds(id), ...(metadata.launch ? { launch: metadata.launch as Record<string, unknown> } : {}) };
2455
2558
  if (typeof metadata.threadId === "string") row.threadId = metadata.threadId;
2456
- const sessionId = definitionOf(id)?.transport === "claude-channel" ? claudeSession().sessionId : metadata.sessionId;
2559
+ const sessionId = definitionOf(id)?.transport === "claude-channel" ? claudeSession(id).sessionId : metadata.sessionId;
2457
2560
  if (typeof sessionId === "string" && sessionId) row.sessionId = sessionId;
2458
2561
  return [id, row];
2459
2562
  }));
@@ -2496,7 +2599,7 @@ export async function startDaemon(opts: DaemonOptions) {
2496
2599
  if (peer instanceof AcpPeer && ["unknown", "unmanaged"].includes(peer.permissionModeState)) return peer.permissionModeState;
2497
2600
  if (starting.has(id) && (peer instanceof AcpPeer || peer instanceof CodexPeer)) return peer.getPermissionMode();
2498
2601
  if (definitionOf(id)?.transport === "claude-channel" && peer instanceof WsPeer && peer.state !== "offline") {
2499
- const launch = claudePermissionLaunch();
2602
+ const launch = claudePermissionLaunch(id);
2500
2603
  if (!launch || claudePermissionGenerations.get(id) !== peer.sessionGeneration) return "unverified";
2501
2604
  }
2502
2605
  return permissionMode(id);
@@ -2508,6 +2611,12 @@ export async function startDaemon(opts: DaemonOptions) {
2508
2611
  return mode === "ask" ? {} : { permissionMode: mode };
2509
2612
  };
2510
2613
  const lastCodexThreads = new Map<string, string>();
2614
+ // #450 I3: the console and dashboard list each declared instance right after its kind.
2615
+ const byKind = (ids: string[]): string[] => {
2616
+ const kindOf = (id: string) => Object.hasOwn(declarations, id) ? declarations[id]!.kind : undefined;
2617
+ const out = ids.filter(id => kindOf(id) === undefined).flatMap(id => [id, ...ids.filter(extra => kindOf(extra) === id)]);
2618
+ return [...out, ...ids.filter(id => !out.includes(id))];
2619
+ };
2511
2620
  const statusPeerIds = (open: ReturnType<Board["statusTasks"]>) => {
2512
2621
  const reservations = board.statusReservations();
2513
2622
  const ids = new Set([...bus.knownPeers(), ...extraIds, ...reservations]);
@@ -2519,12 +2628,13 @@ export async function startDaemon(opts: DaemonOptions) {
2519
2628
  const taskNeeds = board.statusTasks();
2520
2629
  const taskStalls = tasks.stalled();
2521
2630
  const pendingTaskOffers = tasks.pendingOffers().filter(offer => offer.pullOnly === true);
2522
- const peerIds = statusPeerIds(taskNeeds);
2631
+ const peerIds = byKind(statusPeerIds(taskNeeds));
2523
2632
  const view = {
2524
2633
  ...(crashReport.length ? { crash: crashReport } : {}),
2525
2634
  projectId,
2526
2635
  instanceId,
2527
2636
  version: VERSION,
2637
+ pluginBundle: SERVER_JS,
2528
2638
  pendingRestart,
2529
2639
  memoryProfile: memoryProfileStatus(includeAttentionItems),
2530
2640
  lifecycleStages: [...lifecycleConfig.stages],
@@ -2534,6 +2644,8 @@ export async function startDaemon(opts: DaemonOptions) {
2534
2644
  roster: [...KNOWN_PEERS, ...OPTIONAL_PEERS].map(peer => ({ peer, on: onRoster(peer) })),
2535
2645
  protocol: PROTOCOL,
2536
2646
  features: DAEMON_FEATURES,
2647
+ // #450 I3: the instance ids this daemon bound at startup; a launcher selects only one of these.
2648
+ activeInstances: extraIds,
2537
2649
  stopping,
2538
2650
  pid: process.pid,
2539
2651
  cwd: opts.cwd,
@@ -3173,6 +3285,9 @@ export async function startDaemon(opts: DaemonOptions) {
3173
3285
  return { ok: true, mode, opened: opened.via, command };
3174
3286
  }
3175
3287
 
3288
+ /** The CLI command that starts a peer: `ahub <kind> --as <id>` for a declared instance. */
3289
+ const startCommand = (peer: string): string => { const kind = definitionOf(peer)?.kind; return kind && kind !== peer ? `ahub ${kind} --as ${peer}` : `ahub ${peer}`; };
3290
+
3176
3291
  async function stopPeer(peer: unknown, rosterRemoval = false): Promise<Record<string, unknown>> {
3177
3292
  if (!rosterRemoval && rosterChanging !== undefined && rosterChanging === peer) return { ok: false, error: `${String(peer)} roster change is in progress` };
3178
3293
  if (stopping || recoveryActive()) return { ok: false, error: "recovery or shutdown is holding peer stops; finish the operation before retrying" };
@@ -3180,9 +3295,9 @@ export async function startDaemon(opts: DaemonOptions) {
3180
3295
  const owner = bus.peers.get(peer);
3181
3296
  const stopNotice = definitionOf(peer)?.cli.stopNotice?.(owner instanceof WsPeer ? "tui" : !owner ? startMode(peer) : "headless", owner);
3182
3297
  if (stopNotice) return { ok: false, error: stopNotice };
3183
- if (!(owner instanceof AcpPeer || owner instanceof PiPeer || owner instanceof LocalPeer)) return { ok: false, error: "unknown or non-owned peer; run ahub status and choose a hub-owned headless peer" };
3298
+ if (!(owner instanceof AcpPeer || owner instanceof PiPeer || owner instanceof LocalPeer || owner instanceof ReviewerPrintPeer)) return { ok: false, error: "unknown or non-owned peer; run ahub status and choose a hub-owned headless peer" };
3184
3299
  if (owner instanceof PiPeer && owner.mode !== "headless") return { ok: false, error: "Pi belongs to a native TUI terminal; end it in its terminal" };
3185
- if (owner.state === "offline") return { ok: false, error: `peer is already offline; start it with ahub ${peer} before stopping it` };
3300
+ if (owner.state === "offline") return { ok: false, error: `peer is already offline; start it with ${startCommand(peer)} before stopping it` };
3186
3301
  if (starting.has(peer) || requestedPeerStops.has(peer) || permissionChanges.has(peer)) return { ok: false, error: "peer lifecycle or permission change is in progress; wait for ahub status before retrying" };
3187
3302
  requestedPeerStops.add(peer); // fence delivery, starts and recovery before the first asynchronous stop step
3188
3303
  try {
@@ -3199,7 +3314,7 @@ export async function startDaemon(opts: DaemonOptions) {
3199
3314
  writeStatus();
3200
3315
  const state = bus.stateOf(peer);
3201
3316
  if (state !== "offline" || bus.peers.get(peer) !== owner) return { ok: false, error: "peer stop did not reach offline on its owner; inspect ahub status and its terminal before retrying" };
3202
- try { notify(rosterRemoval ? `${peer} stopped for roster removal; add it with ahub roster add ${peer} before starting it again` : `${peer} stopped by the console; start it again with ahub ${peer}`); } catch { /* owner stop and readback remain authoritative */ }
3317
+ try { notify(rosterRemoval ? `${peer} stopped for roster removal; add it with ahub roster add ${peer} before starting it again` : `${peer} stopped by the console; start it again with ${startCommand(peer)}`); } catch { /* owner stop and readback remain authoritative */ }
3203
3318
  return { ok: true, state };
3204
3319
  });
3205
3320
  } catch {
@@ -3244,6 +3359,7 @@ export async function startDaemon(opts: DaemonOptions) {
3244
3359
 
3245
3360
  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>> {
3246
3361
  const adapter = definitionOf(peer);
3362
+ if (adapter?.reviewer?.unavailable) return { ok: false, error: adapter.reviewer.unavailable };
3247
3363
  const preflight = adapter?.start.preflight?.({ instance: peer, form: args.mode });
3248
3364
  if (preflight) return { ok: false, error: preflight };
3249
3365
  if (adapter?.attach?.admission && !declarations[peer]) {
@@ -3559,7 +3675,7 @@ export async function startDaemon(opts: DaemonOptions) {
3559
3675
  const result = await piReceipts!.execute(sessionId ?? "", callId, name, humanBash ? { origin: "verified-user-bash", args: raw } : raw, async () => {
3560
3676
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) return "error: invalid tool arguments";
3561
3677
  const nativeTurn = pi.observationTurn;
3562
- const output = TASK_TOOLS.some((t) => t.name === name) || CONDUCTOR_TOOL_NAMES.has(name) ? await taskOp(peer, name, raw as Record<string, unknown>, true) : await runTool(name, JSON.stringify(raw), { ...ctx, signal,
3678
+ const output = TASK_TOOL_NAMES.has(name) || CONDUCTOR_TOOL_NAMES.has(name) ? await taskOp(peer, name, raw as Record<string, unknown>, true) : await runTool(name, JSON.stringify(raw), { ...ctx, signal,
3563
3679
  ...(humanBash ? { onProcessExit: (code: number | null) => { exitCode = code; } } : {}),
3564
3680
  permit: async (title, tool, toolSignal, path, _observe, originalPath) => {
3565
3681
  const picked = humanBash ? (toolSignal?.aborted || context?.isCurrent() !== true ? "aborted" : true) : await piPermit(title, tool ?? name, toolSignal, path, context?.isCurrent, context?.onApproval, originalPath);
@@ -3738,6 +3854,41 @@ export async function startDaemon(opts: DaemonOptions) {
3738
3854
  await local.start();
3739
3855
  return { ok: true, model: route ? `${route} (fallback ${routing.local.fixed_model})` : (args.model ?? routing.local.fixed_model) };
3740
3856
  }
3857
+ if (adapter?.reviewer) {
3858
+ const backend = adapter.reviewer.backend;
3859
+ if (!backend || !adapter.start.create) return { ok: false, error: "reviewer has no verified runtime" };
3860
+ // #519: each backend proves isolation before admission, then starts one fresh child per review.
3861
+ if (existing?.state === "busy") return { ok: false, error: `${peer} is reviewing; retry when it is idle` };
3862
+ if (existing) { mute(existing); await existing.stop(); }
3863
+ const reviewer = adapter.start.create!({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: config, permission: permissionMode(peer), mcpServers: [], daemon: DAEMON_PORTS["reviewer-print"]({
3864
+ backend, cwd: executionCwd,
3865
+ mcpConfig: JSON.stringify({ mcpServers: { "agent-hub": { command: process.execPath, args: ["run", SERVER_JS], env: toolEnv(peer, true) } } }),
3866
+ systemPrompt: sessionRoleContract(peer),
3867
+ watchdogMs: config.watchdog_ms,
3868
+ privatePaths: [opts.stateDir, ...(config.pii?.paths ?? []), ...config.local.deny],
3869
+ facts: env => env.private ? undefined : reviewDiff(executionCwd, env.refs?.commit, { privatePath: path => isDenied(path, config.local.deny) || matchesPolicy(executionCwd, path, config.pii?.paths ?? [], true), nameable: tasks.nameable }),
3870
+ verdictSince: (task, since) => board.get(task)?.history.some(h => h.by === peer && h.at >= since && (h.event === "approved" || h.event === "changes_requested")) === true,
3871
+ onUsage: usage => void addTokens(peer, (usage.input_tokens ?? 0) + (usage.output_tokens ?? 0)),
3872
+ onUnavailable: (failure, current) => {
3873
+ const attached = () => current() && bus.peers.get(peer) === reviewer;
3874
+ if (!attached()) return;
3875
+ if (failure.cause === "start_failed") reviewerUnavailable.set(peer, "reviewer isolation unavailable; restart after checking the verified CLI and isolation requirements");
3876
+ return tasks.retryReviewer(failure.task, peer, failure, attached).then(() => undefined);
3877
+ },
3878
+ log,
3879
+ }) }) as ReviewerPrintPeer;
3880
+ try { await reviewer.start(); }
3881
+ catch {
3882
+ await reviewer.stop().catch(() => {});
3883
+ const reason = "reviewer isolation unavailable; inspect the verified CLI version and isolation requirements";
3884
+ reviewerUnavailable.set(peer, reason);
3885
+ return { ok: false, error: reason };
3886
+ }
3887
+ reviewerUnavailable.delete(peer);
3888
+ executionOwners.set(peer, { owner: reviewer, cwd: executionCwd });
3889
+ bus.add(reviewer);
3890
+ return { ok: true };
3891
+ }
3741
3892
  return { ok: false, error: `unknown peer "${peer}"` };
3742
3893
  }
3743
3894
 
@@ -4601,16 +4752,23 @@ export async function startDaemon(opts: DaemonOptions) {
4601
4752
  return ids.length && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("task_cancel"))
4602
4753
  ? `target lacks task_cancel; cancelled tasks: ${ids.map(id => `#${id}`).join(", ")}` : undefined;
4603
4754
  };
4755
+ const incompatibleRecoveryTarget = () => {
4756
+ const cancelled = incompatibleCancellationTarget();
4757
+ if (cancelled) return cancelled;
4758
+ if (extraIds.length && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("peer_instances"))) return "target lacks peer_instances; declared instance identities cannot be restored by that daemon";
4759
+ if (board.releases().some(release => release.train && ["active", "frozen"].includes(release.state)) && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("release_trains"))) return "target lacks release_trains; active or frozen train safeguards cannot be preserved";
4760
+ if ((extraIds.some(id => !!definitionOf(id)?.reviewer) || config.review.pool !== undefined) && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("reviewer_pool"))) return "target lacks reviewer_pool; fresh review-only variants and pool policy cannot be preserved";
4761
+ return undefined;
4762
+ };
4604
4763
  const checkTarget = msg.op === "prepare" || msg.op === "commit" || msg.op === "inspect" && msg.targetFeatures !== undefined;
4605
4764
  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");
4765
+ const error = incompatibleRecoveryTarget(); if (error) return recoveryError(error);
4608
4766
  }
4609
4767
 
4610
4768
  if (msg.op === "inspect" || msg.op === "prepare" || msg.op === "commit") {
4611
4769
  for (const pi of bus.peers.values()) if (pi instanceof PiPeer && pi.state === "idle") {
4612
4770
  try { await pi.captureResume(); } catch (error) { return recoveryError((error as Error).message); }
4613
- if (checkTarget) { const error = incompatibleCancellationTarget(); if (error) return recoveryError(error); }
4771
+ if (checkTarget) { const error = incompatibleRecoveryTarget(); if (error) return recoveryError(error); }
4614
4772
  }
4615
4773
  }
4616
4774
  if (msg.op === "inspect" && msg.operationId === undefined) return { t: "recovery", ok: true, recovery: recoveryView() };
@@ -4626,7 +4784,7 @@ export async function startDaemon(opts: DaemonOptions) {
4626
4784
  recoveryPeerSnapshot = undefined;
4627
4785
  }
4628
4786
  if (recoveryOperationId && recoveryOperationId !== op) return recoveryError("another recovery operation is active");
4629
- { const error = incompatibleCancellationTarget(); if (error) return recoveryError(error); }
4787
+ { const error = incompatibleRecoveryTarget(); if (error) return recoveryError(error); }
4630
4788
  recoveryOperationId = op;
4631
4789
  if (!recoveryPhase) recoveryPhase = "preparing";
4632
4790
  armRecoveryLease();
@@ -4634,7 +4792,7 @@ export async function startDaemon(opts: DaemonOptions) {
4634
4792
  budget.setRecoveryHold(true);
4635
4793
  bus.setRecoveryHold(true, Array.isArray(msg.targetFeatures) && msg.targetFeatures.includes("recovery_deferred_hub"));
4636
4794
  await bus.fenceRecovery();
4637
- { const error = incompatibleCancellationTarget(); if (error) return recoveryError(error); }
4795
+ { const error = incompatibleRecoveryTarget(); if (error) return recoveryError(error); }
4638
4796
  const blocked = [...bus.peers].filter(([, peer]) => peer.state === "busy").map(([id]) => id);
4639
4797
  if (!blocked.length && permissions.size === 0 && recoveryReady()) {
4640
4798
  recoveryPeerSnapshot ??= Object.values(recoveryPeers());
@@ -4661,6 +4819,7 @@ export async function startDaemon(opts: DaemonOptions) {
4661
4819
  }
4662
4820
  if (msg.op === "commit") {
4663
4821
  if ((recoveryPhase !== "prepared" && recoveryPhase !== "preparing") || !recoveryReady()) return recoveryError("recovery is not ready; inspect until peers are idle, approvals are complete and completion checks have finished");
4822
+ // Person ruling 2026-10-19: upgrade is excluded from release resource gates; original readiness still binds.
4664
4823
  recoveryPhase = "prepared";
4665
4824
  bus.setRecoveryHold(true, Array.isArray(msg.targetFeatures) && msg.targetFeatures.includes("recovery_deferred_hub"));
4666
4825
  recoveryPeerSnapshot ??= Object.values(recoveryPeers());
@@ -4669,7 +4828,7 @@ export async function startDaemon(opts: DaemonOptions) {
4669
4828
  ...saved,
4670
4829
  queueIds: bus.queueIds(saved.id),
4671
4830
  // Recorded at commit, not prepare: a first turn landing in between flips it (#64).
4672
- ...(definitionOf(saved.id)?.session.missingEvidence === "transcript" && saved.sessionId ? { sessionPersisted: claudeTranscriptPersisted(saved.sessionId) } : {}),
4831
+ ...(definitionOf(saved.id)?.session.missingEvidence === "transcript" && saved.sessionId ? { sessionPersisted: claudeTranscriptPersisted(saved.id, saved.sessionId) } : {}),
4673
4832
  }));
4674
4833
  const snapshot: RestartSnapshot = {
4675
4834
  schemaVersion: 1,
@@ -4683,7 +4842,7 @@ export async function startDaemon(opts: DaemonOptions) {
4683
4842
  peers,
4684
4843
  integrity: integrity(),
4685
4844
  };
4686
- { const error = incompatibleCancellationTarget(); if (error) return recoveryError(error); }
4845
+ { const error = incompatibleRecoveryTarget(); if (error) return recoveryError(error); }
4687
4846
  try { writeRestartSnapshot(opts.stateDir, snapshot); } catch { return recoveryError("could not persist restart state"); }
4688
4847
  recoveryCommitted = true;
4689
4848
  clearTimeout(recoveryLeaseTimer);
@@ -4717,9 +4876,9 @@ export async function startDaemon(opts: DaemonOptions) {
4717
4876
  return recoveryError("unknown recovery operation");
4718
4877
  }
4719
4878
 
4720
- function claudePermissionLaunch(): { launchId: string; unattended: boolean; hookPurpose?: string } | undefined {
4879
+ function claudePermissionLaunch(peer = "claude"): { launchId: string; unattended: boolean; hookPurpose?: string } | undefined {
4721
4880
  try {
4722
- const row = JSON.parse(readFileSync(join(opts.stateDir, "claude-launch.json"), "utf8"));
4881
+ const row = JSON.parse(readFileSync(join(opts.stateDir, claudeStateFile("claude-launch", peer)), "utf8"));
4723
4882
  if (row.instanceId !== instanceId || row.permissionHook !== true || typeof row.launchId !== "string" || !row.launchId) return undefined;
4724
4883
  return { launchId: row.launchId, unattended: row.unattended === true, ...(typeof row.hookPurpose === "string" ? { hookPurpose: row.hookPurpose } : {}) };
4725
4884
  } catch { return undefined; }
@@ -4748,7 +4907,7 @@ export async function startDaemon(opts: DaemonOptions) {
4748
4907
  if (!isPermissionMode(mode)) return { ok: false, error: "mode must be ask, ask-when-needed or never-ask" };
4749
4908
  if (mode === "never-ask" && confirmed !== true) return { ok: false, error: "never-ask needs --yes or console confirmation" };
4750
4909
  if (definitionOf(peer)?.transport === "claude-channel") {
4751
- const launch = claudePermissionLaunch();
4910
+ const launch = claudePermissionLaunch(peer);
4752
4911
  if (!launch || !(owner instanceof WsPeer) || claudePermissionGenerations.get(peer) !== owner.sessionGeneration) return { ok: false, error: "Claude has no verified ahub claude permission hook; restart with ahub claude and run a tool first" };
4753
4912
  if (launch.unattended) return { ok: false, error: "Claude started --unattended already skips prompts; run ahub permission claude ask after it stops to clear the hub choice, then restart without --unattended" };
4754
4913
  } else if (owner instanceof CodexPeer) {
@@ -5021,18 +5180,18 @@ export async function startDaemon(opts: DaemonOptions) {
5021
5180
  let expectedLaunch: string | undefined;
5022
5181
  try {
5023
5182
  const rows = JSON.parse(readFileSync(join(opts.stateDir, "terminal-recovery.json"), "utf8"));
5024
- expectedLaunch = Array.isArray(rows) ? rows.find(row => typeof row?.peer === "string" && definitionOf(row.peer)?.transport === "claude-channel" && row?.projectRoot === opts.cwd && row?.instanceId === instanceId)?.launchId : undefined;
5183
+ expectedLaunch = Array.isArray(rows) ? rows.find(row => row?.peer === peer && definitionOf(row.peer)?.transport === "claude-channel" && row?.projectRoot === opts.cwd && row?.instanceId === instanceId)?.launchId : undefined;
5025
5184
  } catch { /* an ordinary terminal has no Orca recovery identity */ }
5026
5185
  try {
5027
- const launch = JSON.parse(readFileSync(join(opts.stateDir, "claude-launch.json"), "utf8"));
5186
+ const launch = JSON.parse(readFileSync(join(opts.stateDir, claudeStateFile("claude-launch", peer)), "utf8"));
5028
5187
  if (launch.instanceId === instanceId && typeof launch.launchId === "string") expectedLaunch = launch.launchId;
5029
5188
  } catch { /* older/manual launches do not have a launcher marker */ }
5030
- const current = claudeSession();
5189
+ const current = claudeSession(peer);
5031
5190
  if ((phase === "stop" || phase === "post") && current.sessionId && current.sessionId !== sessionId) return void reply({ t: "facts", ok: false });
5032
5191
  if (!expectedLaunch || msg.nativeLaunchId === expectedLaunch) {
5033
- const transcript = claudeTranscript(sessionId, msg.transcriptPath);
5192
+ const transcript = claudeTranscript(sessionId, msg.transcriptPath, peer);
5034
5193
  const launchId = typeof msg.nativeLaunchId === "string" ? msg.nativeLaunchId : undefined;
5035
- const file = join(opts.stateDir, "claude-session.json");
5194
+ const file = join(opts.stateDir, claudeStateFile("claude-session", peer));
5036
5195
  writeFileSync(`${file}.hook.tmp`, JSON.stringify({ at: Date.now(), instanceId, sessionId, ...(launchId ? { launchId } : {}), ...(transcript ? { transcriptPath: transcript } : {}) }), { mode: 0o600 });
5037
5196
  chmodSync(`${file}.hook.tmp`, 0o600); renameSync(`${file}.hook.tmp`, file);
5038
5197
  nativeClaude = true;
@@ -5041,7 +5200,7 @@ export async function startDaemon(opts: DaemonOptions) {
5041
5200
  }
5042
5201
  if (definitionOf(peer)?.transport === "claude-channel" && msg.nativeInstanceId !== undefined && msg.nativeInstanceId !== instanceId) return void reply({ t: "facts", ok: false });
5043
5202
  const nativePeer = nativeClaude ? bus.peers.get(peer) : undefined;
5044
- const permissionLaunch = definitionOf(peer)?.transport === "claude-channel" ? claudePermissionLaunch() : undefined;
5203
+ const permissionLaunch = definitionOf(peer)?.transport === "claude-channel" ? claudePermissionLaunch(peer) : undefined;
5045
5204
  const permission = nativeClaude && permissionLaunch && !permissionLaunch.unattended && msg.nativeLaunchId === permissionLaunch.launchId ? permissionMode(peer) : undefined;
5046
5205
  if (permission !== undefined && nativePeer instanceof WsPeer) claudePermissionGenerations.set(peer, nativePeer.sessionGeneration);
5047
5206
  const protectedWrite = scopedWriteBlocked(phase, permission, msg.tool, () => projectFileTool(msg.tool, msg.input, opts.cwd, config.protect_instruction_files, config.data?.readonly));
@@ -5053,10 +5212,10 @@ export async function startDaemon(opts: DaemonOptions) {
5053
5212
  if (phase === "session") { recordSessions(); return void reply({ t: "facts", ok: true }); }
5054
5213
  if ((phase === "start" || phase === "pre") && nativePeer instanceof WsPeer) {
5055
5214
  const previous = nativeTurnStart.get(peer);
5056
- const current = claudeSession();
5215
+ const current = claudeSession(peer);
5057
5216
  if (phase === "start" || nativePeer.state !== "busy" || previous?.generation !== nativePeer.sessionGeneration || previous?.session !== sessionId || previous?.launch !== (current.launchId ?? "")) {
5058
- const session = claudeSession();
5059
- const baseline = session.sessionId && session.transcriptPath ? readClaudeTranscriptUsage(session.sessionId, session.transcriptPath).filter(record => record.completedTurn).at(-1)?.id : undefined;
5217
+ const session = claudeSession(peer);
5218
+ const baseline = session.sessionId && session.transcriptPath ? readClaudeTranscriptCompletion(session.sessionId, session.transcriptPath)?.id : undefined;
5060
5219
  nativeTurnStart.set(peer, { at: Date.now(), ...(baseline ? { baseline } : {}), generation: nativePeer.sessionGeneration, session: sessionId!, launch: session.launchId ?? "" });
5061
5220
  }
5062
5221
  nativePeer.nativeStarted(sessionId!);
@@ -5075,12 +5234,12 @@ export async function startDaemon(opts: DaemonOptions) {
5075
5234
  event({ type: "hook_stats", peer, n: st.n, startupMs: Math.round(st.startupMs), hubMs: Math.round(st.hubMs), maxStartupMs: Math.round(st.maxStartupMs) });
5076
5235
  hookStats.delete(peer);
5077
5236
  if (id && nativePeer instanceof WsPeer) nativePeer.nativeStopped(sessionId!);
5078
- if (factsOn()) { factSession(peer, sessionId); readbacks(peer, claudeTranscript(sessionId, msg.transcriptPath)); }
5237
+ if (factsOn()) { factSession(peer, sessionId); readbacks(peer, claudeTranscript(sessionId, msg.transcriptPath, peer)); }
5079
5238
  };
5080
5239
  if (!nativeClaude) { finishStop(); reply({ t: "facts", ok: true }); return; } // legacy bookkeeping cannot certify native completion
5081
- const session = claudeSession();
5082
- let transcript = session.transcriptPath ?? claudeTranscript(sessionId, msg.transcriptPath);
5083
- const readCompletion = () => { transcript ??= claudeTranscript(sessionId, msg.transcriptPath); return session.sessionId && transcript ? readClaudeTranscriptUsage(session.sessionId, transcript).filter(record => record.completedTurn).at(-1) : undefined; };
5240
+ const session = claudeSession(peer);
5241
+ let transcript = session.transcriptPath ?? claudeTranscript(sessionId, msg.transcriptPath, peer);
5242
+ const readCompletion = () => { transcript ??= claudeTranscript(sessionId, msg.transcriptPath, peer); return session.sessionId && transcript ? readClaudeTranscriptCompletion(session.sessionId, transcript) : undefined; };
5084
5243
  const completionId = (id: string) => createHash("sha256").update(`${sessionId}:${session.launchId ?? "legacy"}:${id}`).digest("hex");
5085
5244
  const started = nativeTurnStart.get(peer);
5086
5245
  const activeStart = !!started && started.session === sessionId && started.launch === (session.launchId ?? "") && nativePeer instanceof WsPeer && started.generation === nativePeer.sessionGeneration;
@@ -5091,7 +5250,7 @@ export async function startDaemon(opts: DaemonOptions) {
5091
5250
  }
5092
5251
  if (!started || !activeStart || !(nativePeer instanceof WsPeer)) { log("native Stop refused for claude: current native turn start unavailable"); return void reply({ t: "facts", ok: false }); }
5093
5252
  const scopeCurrent = () => {
5094
- const now = claudeSession();
5253
+ const now = claudeSession(peer);
5095
5254
  return !stopping && bus.peers.get(peer) === nativePeer && nativePeer.sessionGeneration === started.generation && now.sessionId === started.session && (now.launchId ?? "") === started.launch;
5096
5255
  };
5097
5256
  const current = () => scopeCurrent() && nativeTurnStart.get(peer) === started;
@@ -5106,14 +5265,20 @@ export async function startDaemon(opts: DaemonOptions) {
5106
5265
  if (nativeStopWaits.get(peer) === ownWait) nativeStopWaits.delete(peer);
5107
5266
  if (!result.record) {
5108
5267
  const reason = ({ unavailable: "completed transcript message unavailable", baseline: "completion still matches start baseline", "before-start": "completion timestamp precedes native start", superseded: "native start superseded during completion wait", verified: "completed transcript message unavailable" } as const)[result.reason];
5109
- log(`native Stop refused for claude: ${reason}`); return;
5268
+ log(`native Stop refused for claude: ${reason}`);
5269
+ // #517: a peer left busy receives no pushed messages; say so where the person looks, once per turn.
5270
+ if (result.reason === "unavailable" && !stopUnconfirmedNoticed.has(started)) {
5271
+ stopUnconfirmedNoticed.add(started);
5272
+ notify(`${peer}: its turn end could not be confirmed from its transcript, so it stays busy and messages to it wait in its queue (hub_inbox reads them)`);
5273
+ }
5274
+ return;
5110
5275
  }
5111
5276
  const id = completionId(result.record.id);
5112
5277
  if (scopeCurrent() && nativeStopsSeen.has(id)) return;
5113
5278
  if (!current()) { log("native Stop refused for claude: native start superseded during completion wait"); return; }
5114
5279
  nativeStopsSeen.add(id); nativeTurnStart.delete(peer); // consume before idle admits another native turn
5115
5280
  if (transcript && !session.transcriptPath) {
5116
- const file = join(opts.stateDir, "claude-session.json");
5281
+ const file = join(opts.stateDir, claudeStateFile("claude-session", peer));
5117
5282
  writeFileSync(`${file}.hook.tmp`, JSON.stringify({ at: Date.now(), instanceId, sessionId, launchId: session.launchId, transcriptPath: transcript }), { mode: 0o600 });
5118
5283
  chmodSync(`${file}.hook.tmp`, 0o600); renameSync(`${file}.hook.tmp`, file);
5119
5284
  }
@@ -5127,7 +5292,7 @@ export async function startDaemon(opts: DaemonOptions) {
5127
5292
  if (!factsOn()) return void reply({ t: "facts", ok: true, ...permissionReply });
5128
5293
  if (definitionOf(peer)?.transport === "claude-channel" && phase === "pre") observeProgress(peer, normalizeClaudeObservation(tool, input));
5129
5294
  factSession(peer, sessionId);
5130
- const transcript = claudeTranscript(sessionId, msg.transcriptPath);
5295
+ const transcript = claudeTranscript(sessionId, msg.transcriptPath, peer);
5131
5296
  if (phase === "post") {
5132
5297
  factsFor(peer).postTool(peer, toolUseId, tool, input);
5133
5298
  readbacks(peer, transcript);
@@ -5202,6 +5367,23 @@ export async function startDaemon(opts: DaemonOptions) {
5202
5367
  if (msg.op === "export") {
5203
5368
  try { return void reply({ ok: true, export: tasks.workflowExport(msg.id) }); } catch { return void reply({ ok: false, error: "workflow export unavailable" }); }
5204
5369
  }
5370
+ if (msg.op === "accept") {
5371
+ // #481 S1: the person's spec acceptance, bound to the issue's body edit time; no body is read.
5372
+ if (!Number.isSafeInteger(msg.id) || msg.id <= 0 || !Number.isSafeInteger(msg.issue) || msg.issue <= 0 || (msg.repo !== undefined && typeof msg.repo !== "string")) return void reply({ ok: false, error: "usage: ahub sdlc accept <task> --issue <n> [--repo owner/repository]" });
5373
+ const task = board.get(msg.id);
5374
+ const repo = msg.repo ?? task?.sdlc?.repo;
5375
+ if (!task || typeof repo !== "string") return void reply({ ok: false, error: task ? "spec acceptance needs --repo owner/repository (the task's receipt names none)" : "invalid lifecycle task id" });
5376
+ const linked = `${task.sdlc?.repo ?? ""}#${task.sdlc?.issue ?? ""}`;
5377
+ void readSpecEdit(repo, msg.issue, (args, timeout, _cwd, bound) => sdlcGh(args, timeout, opts.cwd, bound)).then(edit => {
5378
+ if (!edit) return reply({ ok: false, error: "issue edit time unavailable; nothing was recorded" });
5379
+ // The readback is for the receipt read before it; a relink meanwhile makes it someone else's evidence.
5380
+ const now = board.get(msg.id)?.sdlc;
5381
+ if (`${now?.repo ?? ""}#${now?.issue ?? ""}` !== linked) return reply({ ok: false, error: "the task's spec link changed during the readback; nothing was recorded, run ahub sdlc accept again" });
5382
+ try { const next = tasks.acceptSpec(USER, msg.id, { repo, issue: msg.issue, edit }); reply({ ok: true, acceptance: next.sdlc?.acceptance }); }
5383
+ catch (error) { reply({ ok: false, error: (error as Error).message }); }
5384
+ }, () => reply({ ok: false, error: "issue edit time unavailable; nothing was recorded" }));
5385
+ return;
5386
+ }
5205
5387
  if (msg.op !== "status" && msg.op !== "explain") return void reply({ ok: false, error: "unknown SDLC operation" });
5206
5388
  const selected = msg.id === undefined ? board.list().filter(task => task.state !== "backlog" && (task.sdlc || lifecycleConfig.mode !== "off" || lifecycleConfig.workflow === "gsd-lite")) : [board.get(Number(msg.id))].filter((task): task is Task => !!task);
5207
5389
  if (msg.id !== undefined && (!Number.isSafeInteger(msg.id) || msg.id <= 0 || !selected.length)) return void reply({ ok: false, error: "invalid lifecycle task id" });