@staix/agent-hub 0.12.35 → 0.12.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +8 -6
  3. package/docs/agent-notes/adapters.md +4 -1
  4. package/docs/agent-notes/budget.md +1 -1
  5. package/docs/agent-notes/bus.md +3 -0
  6. package/docs/agent-notes/daemon.md +6 -2
  7. package/docs/agent-notes/tasks.md +11 -1
  8. package/docs/agent-notes/tests.md +5 -1
  9. package/docs/operations.md +40 -16
  10. package/docs/quickstart.md +6 -8
  11. package/docs/sdlc.md +46 -5
  12. package/docs/security.md +5 -1
  13. package/docs/specs/2026-09-19-agent-hub-design.md +76 -1
  14. package/docs/verification/2026-10-19-0.12.36.md +63 -0
  15. package/docs/verified.json +18 -14
  16. package/package.json +1 -1
  17. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  18. package/plugins/agent-hub/server.js +385 -29
  19. package/src/adapters/claude-print.ts +230 -0
  20. package/src/adapters/definitions/builtin-metadata.ts +17 -1
  21. package/src/adapters/definitions/native-cli-entry-claude.ts +17 -3
  22. package/src/adapters/definitions/native-cli-entry-jcode.ts +3 -2
  23. package/src/adapters/definitions/native-cli-entry-kimi.ts +3 -2
  24. package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
  25. package/src/adapters/definitions/native-factories.ts +7 -0
  26. package/src/adapters/registry-types.ts +8 -2
  27. package/src/adapters/registry.ts +13 -1
  28. package/src/cli/help.ts +5 -0
  29. package/src/cli/identity.ts +2 -1
  30. package/src/cli/launch.ts +19 -6
  31. package/src/cli/main.ts +38 -4
  32. package/src/cli/reset.ts +4 -2
  33. package/src/cli/statusline-tee.ts +5 -2
  34. package/src/cli/terminal-recovery.ts +7 -2
  35. package/src/cli/upgrade-runtime.ts +18 -15
  36. package/src/hub/board.ts +29 -7
  37. package/src/hub/bus.ts +11 -5
  38. package/src/hub/claude-state.ts +37 -0
  39. package/src/hub/cost-governance.ts +76 -0
  40. package/src/hub/daemon.ts +181 -73
  41. package/src/hub/hub-tools.ts +20 -1
  42. package/src/hub/instances.ts +17 -3
  43. package/src/hub/review-brief.ts +175 -0
  44. package/src/hub/routing.ts +17 -2
  45. package/src/hub/sdlc.ts +205 -4
  46. package/src/hub/status-roster.ts +10 -0
  47. package/src/hub/task-sweep.ts +143 -0
  48. package/src/hub/tasks.ts +544 -26
  49. package/src/hub/usage.ts +57 -20
package/src/hub/daemon.ts CHANGED
@@ -14,12 +14,14 @@ import { needs, type NeedsInput } from "../ui/needs.ts";
14
14
  import { loadPetConfig } from "../pet/config.ts";
15
15
  import { projectionConfig, TextProjections, type ProjectionConfig } from "./text-projection.ts";
16
16
  import { policyPaths, matchesPolicy } from "./path-policy.ts";
17
+ import { buildReviewBrief } from "./review-brief.ts";
17
18
  import { profileName, projectProfile, resolveProfile, mergeProfileOverrides, type ProfileName } from "./profiles.ts";
18
19
  import { catalogAssignments, loadRoleCatalog, selectedRoles, roleIdentity } from "./roles.ts";
19
20
  import { ActivityRing } from "./activity.ts";
20
21
  import { JcodePeer } from "../adapters/jcode.ts";
21
22
  import { jcodeMcpReady } from "../cli/jcode-mcp.ts";
22
- import { loadSdlc, sdlcGh } from "./sdlc.ts";
23
+ import { loadSdlc, readSpecEdit, sdlcGh } from "./sdlc.ts";
24
+ import { claudeStateFile, ownTerminalRow, pathEvidence, transcriptPersisted } from "./claude-state.ts";
23
25
  import { peerRosterConfig, rosterAllows, type PeerRoster } from "./roster.ts";
24
26
  import { KNOWN_PEERS, OPTIONAL_PEERS, PLANNER_PEERS, SUPPORTED_PEERS, isSupportedPeer, STARTABLE_PEERS, isStartablePeer } from "../adapters/registry.ts";
25
27
  import { RecoveryDeferredHubError } from "./deferred-hub.ts";
@@ -46,7 +48,7 @@ import { resolveSettledRoles } from "./settled-roles.ts";
46
48
  import { CONFIG_SOURCE_FILES, configFingerprint, configSourceTexts, parseConfigSources, type ConfigSources } from "./config-reload.ts";
47
49
  import { eventLog, readEvents, tokenDeltas, type RoleIdentity } from "./events.ts";
48
50
  import { ExecutionBudget } from "./execution-budget.ts";
49
- import { claudeReportedTokens, readClaudeTranscriptUsage, waitForClaudeCompletion } from "./usage.ts";
51
+ import { claudeReportedTokens, readClaudeTranscriptCompletion, readClaudeTranscriptUsage, waitForClaudeCompletion } from "./usage.ts";
50
52
  import type { ServerWebSocket } from "bun";
51
53
  import { AcpPeer, QwenIdentityError, type PermissionRequest } from "../adapters/acp.ts";
52
54
  import { CodexPeer } from "../adapters/codex-appserver.ts";
@@ -61,6 +63,7 @@ import { profile, proxyEnv, type SandboxNetwork } from "../local/sandbox.ts";
61
63
  import { DEFAULT_NETWORK_ALLOW, startEgressProxy, type EgressProxy } from "../local/proxy.ts";
62
64
  import { runTool, toolResultFailed, TOOL_SCHEMAS, type ToolApproval, type ApprovalObserver, type ToolContext } from "../local/tools.ts";
63
65
  import { LocalPeer } from "../adapters/local-worker.ts";
66
+ import { reviewDiff, ClaudePrintPeer } from "../adapters/claude-print.ts";
64
67
  import { Capture, skipTools } from "../memory/capture.ts";
65
68
  import { DEFAULT_OMNIROUTE, OmniRoute, type OmniRouteConfig } from "../omniroute/client.ts";
66
69
  import { Sidecar } from "../switchyard/sidecar.ts";
@@ -69,12 +72,12 @@ import { Board, isClosedTaskState, taskProposalAt, taskHasStarted, CLASSES, type
69
72
  import { Budget, claudeWindows, codexWindows, DEFAULT_BUDGET, type BudgetConfig } from "./budget.ts";
70
73
  import { HUB } from "./envelope.ts";
71
74
  import { recallMemoryLines, trimToTokens } from "../memory/recall.ts";
72
- import { closeSync, constants as fsConstants, openSync, readSync, statSync } from "node:fs";
75
+ import { closeSync, constants as fsConstants, openSync, lstatSync, readSync, statSync } from "node:fs";
73
76
  import { basename, isAbsolute } from "node:path";
74
77
  import { fileURLToPath } from "node:url";
75
78
  import { realPath, hubHome } from "./project.ts";
76
79
  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";
80
+ import { CONDUCTOR_TOOLS, CONDUCTOR_TOOL_NAMES, DEFAULT_ROLES, roleContract, TASK_TOOL_NAMES, TASK_TOOLS, BOUNDED_TASK_TOOLS, profileTaskTools } from "./hub-tools.ts";
78
81
  import { Conductor, ConductorHolds, conductorPeer, conductorProgressSink, publicConductorTask, publicPeerBudget, type ConductEvent } from "./conductor.ts";
79
82
  import { SupervisionFeed } from "./supervision.ts";
80
83
  import { drainCliAudits } from "../cli/identity-audit.ts";
@@ -603,6 +606,10 @@ export async function startDaemon(opts: DaemonOptions) {
603
606
  const ownedConfig = Object.fromEntries(Object.entries(loadedConfig).map(([key, value]) => [key, !opts.config || value === (DEFAULT_CONFIG as unknown as Record<string, unknown>)[key] ? structuredClone(value) : value])) as unknown as HubConfig;
604
607
  const declarations = validateInstances(loadedConfig.instances);
605
608
  const extraIds = Object.keys(declarations);
609
+ // #450 I3: channel extras (Claude) attach from the person's terminal; the hub never starts them and their hello is admitted.
610
+ const channelExtras = extraIds.filter(id => instanceDefinitionOf(id, declarations)?.identity.mcp === "channel-plugin");
611
+ // Every Claude-family peer whose own state files the daemon reads: the builtin channel kinds, then their declared instances.
612
+ const channelPeers = [...ADAPTERS.filter(definition => definition.identity.mcp === "channel-plugin").map(definition => definition.kind), ...channelExtras];
606
613
  const executionRoots = new Map<string, string>();
607
614
  const executionOwners = new Map<string, { owner: PeerAdapter; cwd: string }>();
608
615
  const roles = { ...loadedConfig.roles };
@@ -611,7 +618,7 @@ export async function startDaemon(opts: DaemonOptions) {
611
618
  const instancePermissionSources: NonNullable<HubConfig["permission_default_sources"]> = {};
612
619
  for (const [id, declaration] of Object.entries(declarations)) {
613
620
  roles[id] = effectiveInstanceRoles(id, declarations, loadedConfig.roles);
614
- startModesFromInstances[id] = { start_mode: "headless" };
621
+ if (!channelExtras.includes(id)) startModesFromInstances[id] = { start_mode: "headless" };
615
622
  if (declaration.permission_mode !== undefined) {
616
623
  permissionModesFromInstances[id] = declaration.permission_mode;
617
624
  instancePermissionSources[id] = { mode: declaration.permission_mode, source: ".agenthub/config.local.json" };
@@ -627,10 +634,18 @@ export async function startDaemon(opts: DaemonOptions) {
627
634
  const definitionOf = (peer: string) => instanceDefinitionOf(peer, declarations);
628
635
  const KNOWN_PEERS: readonly string[] = [...BASE_KNOWN_PEERS, ...extraIds];
629
636
  const SUPPORTED_PEERS: readonly string[] = [...BASE_SUPPORTED_PEERS, ...extraIds];
630
- const STARTABLE_PEERS: readonly string[] = [...BASE_STARTABLE_PEERS, ...extraIds];
637
+ const STARTABLE_PEERS: readonly string[] = [...BASE_STARTABLE_PEERS, ...extraIds.filter(id => !channelExtras.includes(id))];
631
638
  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]);
639
+ const isStartablePeer = (peer: unknown): peer is string => typeof peer === "string" && !channelExtras.includes(peer) && definitionOf(peer)?.start.conductorStartable === true;
640
+ const RESERVED_IDS = new Set([...BASE_RESERVED_IDS, ...extraIds.filter(id => !channelExtras.includes(id))]);
641
+ // #223 R2: a declared instance whose effective roles are exactly reviewer gets review requests and its own task notices only.
642
+ const reviewOnlyPeer = (peer: string): boolean => Object.hasOwn(declarations, peer) && config.roles[peer]?.length === 1 && config.roles[peer]![0] === "reviewer";
643
+ /** #520: a task under review shows its review brief to whoever may read it (owner, reviewer, conductor). */
644
+ const briefField = (task: Task): { brief?: string } => { if (task.state !== "in_review") return {}; const text = tasks.briefText(task); return text ? { brief: text } : {}; };
645
+ /** #223 R2: a review-only instance reads the spec, refs and earlier verdicts of a task, never the builder's narrative. */
646
+ const reviewerView = (peer: string, view: Record<string, unknown>): Record<string, unknown> => !reviewOnlyPeer(peer) || !Array.isArray(view.history) ? view
647
+ : { ...view, history: (view.history as { event?: unknown; note?: unknown }[]).map(({ note, ...h }) => h.event === "approved" || h.event === "changes_requested" ? { ...h, note } : h) };
648
+ const reviewOnlyItem = (peer: string, env: Envelope): boolean => env.from === USER || env.from === HUB && (env.kind === "review" || (env.kind === "task" && env.to?.includes(peer) === true));
634
649
  const SETTINGS = projectSettings(declarations);
635
650
  const settingDef = (key: unknown) => instanceSettingDef(key, declarations);
636
651
  const accountOf = (peer: string) => definitionOf(peer)?.budget?.account ?? peer;
@@ -872,6 +887,7 @@ export async function startDaemon(opts: DaemonOptions) {
872
887
  * has not stopped since its cohort task closed. Workflow events and anything to the console are never held back.
873
888
  */
874
889
  const silence = (env: Envelope, to: PeerId): string | undefined => {
890
+ if (reviewOnlyPeer(to) && env.to?.includes(to) && !reviewOnlyItem(to, env)) return `${to} is a review-only instance: it receives review requests only (send a review through the task board)`;
875
891
  if (!turnFree() || env.from === USER || env.from === HUB || env.from === DIGEST || env.priority === "fyi" || env.kind !== "chat" || to === USER) return undefined;
876
892
  const cohort = tasks.silenced(env.from, to);
877
893
  if (!cohort) return undefined;
@@ -914,7 +930,7 @@ export async function startDaemon(opts: DaemonOptions) {
914
930
  const staleOff = () => config.experiments?.stale_notices === "deliver";
915
931
  if (staleOff()) log("experiment: stale notices are delivered as before 0.12.4 (the issue #106 ablation)");
916
932
  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,
933
+ const bus = new Bus({ rostered: membershipAllowed, deliveryHeld: peer => requestedPeerStops.has(peer) || rosterChanging === peer, journal, batchMax: config.batch_max, batchMs: config.batch_ms, queueCap: config.queue_cap, condense: (envs) => inference?.condense(envs) ?? Promise.resolve(envs), admit, relevant: (peer, env) => reviewOnlyPeer(peer) && !reviewOnlyItem(peer, env) ? `not for ${peer}: a review-only instance receives review requests only` : env.refs?.boundedCheck || env.refs?.supervision || env.refs?.backlog ? relevantNotice(peer, env) : staleOff() || relevantNotice(peer, env), silence,
918
934
  onImportantQueued: (peer, counts) => { const p = bus.peers.get(peer); if (p instanceof WsPeer) p.notifyQueued(counts); } });
919
935
  startupCleanup.push(() => bus.closeJournal());
920
936
  const manualPaused = new Set<PeerId>(bus.manualPausedPeers()); // recovery never lifts an operator's pause
@@ -1080,7 +1096,7 @@ export async function startDaemon(opts: DaemonOptions) {
1080
1096
 
1081
1097
  /** Session-start cross-platform recall. Awaited before the peer can receive anything, so it rides on the first delivery. Fail-open. */
1082
1098
  async function ensurePreface(peer: PeerId): Promise<void> {
1083
- if ((!config.memory.enabled && !memoryStore) || recalled.has(peer)) return;
1099
+ if ((!config.memory.enabled && !memoryStore) || recalled.has(peer) || reviewOnlyPeer(peer)) return; // #223 R2: no shared memory in a fresh review
1084
1100
  // The hub's own long-term decisions, contracts and fails lead; claude-mem's recent work follows, each with half the budget.
1085
1101
  let own: string | undefined;
1086
1102
  try {
@@ -1278,7 +1294,22 @@ export async function startDaemon(opts: DaemonOptions) {
1278
1294
  };
1279
1295
  const tasks: Tasks = new Tasks({
1280
1296
  capabilities: peerCapabilities, repository: memoryRepo,
1297
+ plannerBlocks: () => Object.fromEntries(bus.knownPeers().map(peer => {
1298
+ const codes: ("approvals" | "quota")[] = [];
1299
+ if ([...permissions.values()].some(request => request.peer === peer && request.expiresAt > Date.now())) codes.push("approvals");
1300
+ if (budget.record(peer)) codes.push("quota");
1301
+ return [peer, codes];
1302
+ })),
1281
1303
  instances: () => declarations,
1304
+ reviewOnly: reviewOnlyPeer,
1305
+ // #223: a headless reviewer's verdict holds only while the same child it started with still runs for that task.
1306
+ reviewAuthority: (by, task) => {
1307
+ if (definitionOf(by)?.start.daemonPort !== "claude-print") return undefined;
1308
+ const peer = bus.peers.get(by) as ClaudePrintPeer | undefined, token = peer?.reviewToken(task);
1309
+ return () => token !== undefined && bus.peers.get(by) === peer && peer?.reviewToken(task) === token ? undefined : `no review of task #${task} is running for ${by}`;
1310
+ },
1311
+ // #223 R4: fresh only when the verdict comes from a reviewer session the hub started for this task's review.
1312
+ reviewContext: (by, task) => definitionOf(by)?.start.daemonPort === "claude-print" && (bus.peers.get(by) as ClaudePrintPeer | undefined)?.reviewing === task ? "fresh" : "continuing",
1282
1313
  // Proof belongs to the adapter generation actually started in the validated root. A role-only
1283
1314
  // reload cannot turn a canonical reviewer process into an isolated implementation owner.
1284
1315
  ownerIsolation: () => Object.fromEntries(extraIds.map(id => {
@@ -1294,6 +1325,10 @@ export async function startDaemon(opts: DaemonOptions) {
1294
1325
  catalog,
1295
1326
  sessionRoles: peer => bus.peers.has(peer) && bus.stateOf(peer) !== "offline" ? preparedSessionRoles.get(peer) ?? [] : [],
1296
1327
  projectedReview: task => reviewProjection(task),
1328
+ reviewBrief: task => {
1329
+ const root = executionRoot(task.owner ?? "");
1330
+ return buildReviewBrief(root, task.refs.commit, { privatePath: path => isDenied(path, config.local.deny) || matchesPolicy(root, path, config.pii?.paths ?? [], true), nameable: tasks.nameable });
1331
+ },
1297
1332
  sdlc: { config: () => lifecycleConfig, gh: (args, timeout, _cwd, bound) => sdlcGh(args, timeout, opts.cwd, bound) },
1298
1333
  board,
1299
1334
  bus,
@@ -1547,8 +1582,8 @@ export async function startDaemon(opts: DaemonOptions) {
1547
1582
  } catch { /* no transcript yet: no readback */ }
1548
1583
  };
1549
1584
  /** 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();
1585
+ const claudeTranscript = (sessionId: string | undefined, fromHook: unknown, peer = "claude"): string | undefined => {
1586
+ const reported = claudeSession(peer);
1552
1587
  if (reported.transcriptPath && (!sessionId || reported.sessionId === sessionId)) return reported.transcriptPath;
1553
1588
  if (!sessionId || typeof fromHook !== "string" || !isAbsolute(fromHook) || basename(fromHook) !== `${sessionId}.jsonl`) return undefined;
1554
1589
  try {
@@ -1572,6 +1607,7 @@ export async function startDaemon(opts: DaemonOptions) {
1572
1607
  const activeAt = new Map<PeerId, number>();
1573
1608
  interface NativeTurnStart { at: number; baseline?: string; generation: string; session: string; launch: string }
1574
1609
  const nativeTurnStart = new Map<PeerId, NativeTurnStart>();
1610
+ const stopUnconfirmedNoticed = new WeakSet<NativeTurnStart>();
1575
1611
  const nativeStopWaits = new Map<PeerId, { start: NativeTurnStart; wait: ReturnType<typeof waitForClaudeCompletion> }>();
1576
1612
  /** Every Claude hook call's own start-up and the hub's time for it, summed per turn (issue #108): reported at Stop. */
1577
1613
  const hookStats = new Map<PeerId, { n: number; startupMs: number; hubMs: number; maxStartupMs: number }>();
@@ -1615,7 +1651,7 @@ export async function startDaemon(opts: DaemonOptions) {
1615
1651
  const contextSession = (peer: PeerId): string | undefined => {
1616
1652
  const p = bus.peers.get(peer);
1617
1653
  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; }
1654
+ if (definitionOf(peer)?.turnEnd === "native-hook") { const c = claudeSession(peer); return c.sessionId ? `${c.sessionId}:${c.launchId ?? ""}` : undefined; }
1619
1655
  if (p instanceof CodexPeer) return p.thread || undefined;
1620
1656
  if (p instanceof AcpPeer || p instanceof PiPeer) { const id = p.recoveryMetadata().sessionId; return typeof id === "string" ? id : undefined; }
1621
1657
  return undefined;
@@ -1829,7 +1865,7 @@ export async function startDaemon(opts: DaemonOptions) {
1829
1865
  };
1830
1866
  // Claude's numbers arrive through the status line tee `ahub claude` installs (src/cli/statusline-tee.ts).
1831
1867
  let claudeUsageSeen = 0;
1832
- let claudeContextSeen = "";
1868
+ const claudeContextSeen = new Map<string, string>();
1833
1869
  const contextTailSeen = new Map<PeerId, string>();
1834
1870
  const intervals = [
1835
1871
  setInterval(() => {
@@ -1861,16 +1897,16 @@ export async function startDaemon(opts: DaemonOptions) {
1861
1897
  for (const c of consoles) if (c.data.tail) c.send(JSON.stringify({ t: "context", peer, reading }));
1862
1898
  }
1863
1899
  }
1864
- try {
1865
- const file = join(opts.stateDir, "claude-context.json");
1900
+ for (const claudePeer of channelPeers) try {
1901
+ const file = join(opts.stateDir, claudeStateFile("claude-context", claudePeer));
1866
1902
  const payload = readFileSync(file, "utf8");
1867
- if (payload === claudeContextSeen) return;
1903
+ if (payload === claudeContextSeen.get(claudePeer)) continue;
1868
1904
  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);
1905
+ const session = claudeSession(claudePeer);
1906
+ if (c.instanceId !== instanceId || !session.sessionId || c.sessionId !== session.sessionId || c.launchId !== session.launchId || !["idle", "busy", "paused"].includes(bus.stateOf(claudePeer))) continue;
1907
+ claudeContextSeen.set(claudePeer, payload);
1908
+ const bound = contextSession(claudePeer);
1909
+ if (bound) contexts.report(claudePeer, claudeContext(c.context, bound, c.at), bound);
1874
1910
  } catch { /* absent or unreadable native telemetry is unknown */ }
1875
1911
  }, 1000),
1876
1912
  setInterval(() => budget.tick(), 30_000),
@@ -1925,7 +1961,7 @@ export async function startDaemon(opts: DaemonOptions) {
1925
1961
  const SERVER_JS = join(import.meta.dir, "..", "..", "plugins", "agent-hub", "server.js");
1926
1962
  // What the hub's MCP server offers a native peer. Codex approves them in config, Kimi's requests for exactly these
1927
1963
  // 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)];
1964
+ const HUB_TOOLS = ["hub_send", ...TASK_TOOL_NAMES, ...CONDUCTOR_TOOLS.map((t) => t.name)];
1929
1965
  const HUB_TOOL_TITLES = new Set(HUB_TOOLS.map((name) => `mcp__agent-hub__${name}`));
1930
1966
  const toolEnv = (peer: PeerId, deliveryOnly = false) => ({ ...(deliveryOnly ? { AGENTHUB_DELIVERY_ONLY: "true" } : {}), AGENTHUB_MODE: "tools", AGENTHUB_PEER_ID: peer, AGENTHUB_STATE_DIR: opts.stateDir, AGENTHUB_PROJECT_DIR: opts.cwd });
1931
1967
 
@@ -1980,11 +2016,13 @@ export async function startDaemon(opts: DaemonOptions) {
1980
2016
  if (op === "hub_memory_correct") correctionInput(a);
1981
2017
  if (CONDUCTOR_TOOL_NAMES.has(op)) {
1982
2018
  if (piiTurn && ["hub_task_assign", "hub_task_escalate", "hub_peer_start"].includes(op)) throw new PreEffectToolRefusal("conductor mutations are unavailable during a PII turn");
1983
- return JSON.stringify(await conductor.execute(by, op, a));
2019
+ const result = await conductor.execute(by, op, a);
2020
+ const shown = op === "hub_task_show" && result && typeof result === "object" ? board.get(Number(a.id)) : undefined;
2021
+ return JSON.stringify(shown ? { ...reviewerView(by, result as Record<string, unknown>), ...briefField(shown) } : result);
1984
2022
  }
1985
2023
  // Inside a PII turn the worker's words may carry the PII whatever they are attached to: a note would go to
1986
2024
  // 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`);
2025
+ if (piiTurn && (MEMORY_READ_TOOLS.has(op) || op === "hub_memory_correct" || op === "hub_remember" || op === "hub_task_propose" || op === "hub_sdlc_record" || op === "hub_plan_review" || op === "hub_sdlc_costs" || op === "hub_backlog_return_untouched" || op === "hub_backlog_plan" || op === "hub_backlog_undo" || op === "hub_backlog_pull" || op === "hub_backlog_split" || op === "release_new")) throw new PreEffectToolRefusal(`${op} is not available while working on a PII task: its text must not leave this machine`);
1988
2026
  // The same words attached to an ordinary task would reach its reviewer, its owner, overlapping owners (a plan, the
1989
2027
  // completed-change notice) and claude-mem. A turn that holds a PII task acts on ordinary tasks in a turn of its own.
1990
2028
  if (piiTurn && (op === "hub_task_done" || op === "hub_review" || (op === "hub_task_accept" && a.plan != null))) {
@@ -2100,6 +2138,16 @@ export async function startDaemon(opts: DaemonOptions) {
2100
2138
  return JSON.stringify(tasks.backlogList(by));
2101
2139
  case "hub_release_list":
2102
2140
  return JSON.stringify(tasks.releaseList(by));
2141
+ case "hub_planner_groom":
2142
+ return JSON.stringify(await tasks.groomingDigest(by));
2143
+ case "hub_sdlc_costs":
2144
+ return JSON.stringify(await tasks.costReport(by, a.repo));
2145
+ case "hub_plan_review": {
2146
+ const { id, ...review } = a;
2147
+ return JSON.stringify(tasks.publicView(await tasks.recordPlanReview(by, id, review)));
2148
+ }
2149
+ case "hub_backlog_return_untouched":
2150
+ return JSON.stringify(tasks.publicView(await tasks.returnUntouchedToBacklog(by, { id: a.id, revision: a.revision, reason: a.reason })));
2103
2151
  case "hub_backlog_plan":
2104
2152
  return JSON.stringify(tasks.publicView(await tasks.planBacklog(by, { id: a.id, revision: a.revision, fields: a.fields, reason: a.reason })));
2105
2153
  case "hub_backlog_pull":
@@ -2120,7 +2168,8 @@ export async function startDaemon(opts: DaemonOptions) {
2120
2168
  return [line(t), overlap, tasks.claimRoleGuidance(t, by), t.owner === by ? tasks.planGuardText(t) : ""].filter(Boolean).join("\n");
2121
2169
  }
2122
2170
  case "hub_task_accept": {
2123
- const t = tasks.accept(by, a.id, a.plan);
2171
+ // S1 spec acceptance and S9 plan review are checked against one task snapshot before acceptance.
2172
+ const t = await tasks.acceptReviewed(by, a.id, a.plan);
2124
2173
  // A silent cohort (issue #107): the owners' plans come with every accept, as what the newcomer works from.
2125
2174
  const silent = tasks.silentFor(t.id);
2126
2175
  const overlap = a.plan == null && !silent ? "" : tasks.overlaps(t);
@@ -2151,9 +2200,10 @@ export async function startDaemon(opts: DaemonOptions) {
2151
2200
  return extra ? `${result}\n${extra}` : result;
2152
2201
  }
2153
2202
  case "hub_sdlc_record":
2203
+ await tasks.specStageGate(by, a.id, a.receipt); // #481 S1: a live spec edit-time check before a build stage
2154
2204
  return line(tasks.recordLifecycle(by, a.id, a.receipt));
2155
2205
  case "hub_review":
2156
- return line(await tasks.review(by, a.id, a.verdict, a.note, a.unmet, a.head));
2206
+ return line(await tasks.review(by, a.id, a.verdict, a.note, a.unmet, a.head, a.coverage));
2157
2207
  case "hub_remember":
2158
2208
  return tasks.remember(by, a);
2159
2209
  case "hub_checkpoint": {
@@ -2193,7 +2243,8 @@ export async function startDaemon(opts: DaemonOptions) {
2193
2243
  if (op === "task_show" && by !== USER) {
2194
2244
  const task = board.get(Number(a.id));
2195
2245
  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}`);
2246
+ if (!task) return JSON.stringify(`no task #${a.id}`);
2247
+ return JSON.stringify({ ...reviewerView(by, publicConductorTask(task, (t) => tasks.publicView(t, true))), ...briefField(task), flowRevision: task.history.length });
2197
2248
  }
2198
2249
  if (op === "route_explain" && by !== USER) {
2199
2250
  if (a.id !== undefined && board.get(Number(a.id))?.state === "backlog") throw new PreEffectToolRefusal("backlog is read through planner tools");
@@ -2220,6 +2271,10 @@ export async function startDaemon(opts: DaemonOptions) {
2220
2271
  case "backlog_pull":
2221
2272
  case "backlog_return":
2222
2273
  return line(await tasks.moveBacklog(by, a.id, op === "backlog_pull"));
2274
+ case "review_brief": {
2275
+ const task = board.get(Number(a.id));
2276
+ return task ? tasks.briefText(task) || `task #${task.id} names no commit, or is private: no brief` : `no task #${a.id}`;
2277
+ }
2223
2278
  case "task_show": {
2224
2279
  const task = board.get(Number(a.id));
2225
2280
  return JSON.stringify(task ? { ...task, flowRevision: task.history.length, reviews: board.reviews({ task: task.id }) } : `no task #${a.id}`, null, 2);
@@ -2349,19 +2404,20 @@ export async function startDaemon(opts: DaemonOptions) {
2349
2404
  // #64: a Claude session that never persisted a transcript cannot be resumed, so the
2350
2405
  // restore gate accepted a fresh session and nothing it stood for is lost. Snapshots
2351
2406
  // 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)));
2407
+ const unpersistedClaude = definitionOf(saved.id)?.session.missingEvidence === "transcript" && (saved.sessionPersisted === false || (saved.sessionPersisted === undefined && !claudeTranscriptPersisted(saved.id, saved.sessionId)));
2353
2408
  if (!unpersistedClaude && !waived()) return false;
2354
2409
  }
2355
2410
  return true;
2356
2411
  });
2357
2412
  };
2358
- const claudeSession = (): { sessionId?: string; transcriptPath?: string; launchId?: string } => {
2413
+ /** #450 I3: each Claude peer (claude and every claude-<name> instance) has its own session and launch files. */
2414
+ const claudeSession = (peer = "claude"): { sessionId?: string; transcriptPath?: string; launchId?: string } => {
2359
2415
  try {
2360
- const value = JSON.parse(readFileSync(join(opts.stateDir, "claude-session.json"), "utf8"));
2416
+ const value = JSON.parse(readFileSync(join(opts.stateDir, claudeStateFile("claude-session", peer)), "utf8"));
2361
2417
  if (value.instanceId !== instanceId) return {};
2362
2418
  let currentLauncher = false;
2363
2419
  try {
2364
- const launch = JSON.parse(readFileSync(join(opts.stateDir, "claude-launch.json"), "utf8"));
2420
+ const launch = JSON.parse(readFileSync(join(opts.stateDir, claudeStateFile("claude-launch", peer)), "utf8"));
2365
2421
  if (launch.instanceId === instanceId && typeof launch.launchId === "string" && launch.launchId) {
2366
2422
  currentLauncher = true;
2367
2423
  if (value.launchId !== launch.launchId) return {};
@@ -2369,7 +2425,7 @@ export async function startDaemon(opts: DaemonOptions) {
2369
2425
  } catch { /* older/manual launches retain the existing instance fence */ }
2370
2426
  try {
2371
2427
  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;
2428
+ const current = ownTerminalRow(records, peer, opts.cwd, instanceId);
2373
2429
  if (!currentLauncher && current?.launchId && value.launchId !== current.launchId) return {};
2374
2430
  } catch { /* no managed terminal record: the instance fence is still enforced */ }
2375
2431
  return {
@@ -2427,33 +2483,30 @@ export async function startDaemon(opts: DaemonOptions) {
2427
2483
  }
2428
2484
  /** Claude persists projects/<slug>/<sessionId>.jsonl only with the first turn. Prefer the
2429
2485
  * 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
- };
2486
+ // ponytail: the version probe reads the builtin claude peer only; extend per peer if an instance's version matters (#450 I3).
2487
+ const claudeTranscriptPersisted = (peer: string, sessionId: string): boolean => transcriptPersisted(claudeSession(peer), sessionId, Object.hasOwn(declarations, peer),
2488
+ join(process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude"), "projects", opts.cwd.replace(/[^a-zA-Z0-9]/g, "-"), `${sessionId}.jsonl`), path => pathEvidence(path, lstatSync));
2436
2489
  // Optional native usage: only the instance-fenced explicit Claude session is read.
2437
2490
  // IDs persist in telemetry, so a restart/resume never counts the same native message twice.
2438
2491
  const claudeNativeUsageSeen = new Set(readEvents(join(opts.stateDir, "events.jsonl")).filter(e => e.type === "usage" && e.source === "claude_transcript").map(e => (e as { id: string }).id));
2439
- const collectClaudeUsage = () => {
2440
- const session = claudeSession();
2441
- if (!session.sessionId || !session.transcriptPath) return;
2492
+ const collectClaudeUsage = () => { for (const peer of channelPeers) {
2493
+ const session = claudeSession(peer);
2494
+ if (!session.sessionId || !session.transcriptPath) continue;
2442
2495
  for (const record of readClaudeTranscriptUsage(session.sessionId, session.transcriptPath)) {
2443
2496
  if (claudeNativeUsageSeen.has(record.id)) continue;
2444
2497
  claudeNativeUsageSeen.add(record.id);
2445
2498
  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 } : {}) });
2499
+ if (reported !== undefined) addTokens(peer, reported);
2500
+ event({ type: "usage", peer, source: "claude_transcript", id: record.id, ...record.usage, ...taskAttribution(peer), ...(record.servedModel ? { servedModel: record.servedModel } : {}), ...(record.at ? { measuredAt: record.at } : {}) });
2448
2501
  }
2449
- };
2502
+ } };
2450
2503
  const claudeUsageTimer = setInterval(collectClaudeUsage, 2000);
2451
2504
  claudeUsageTimer.unref(); intervals.push(claudeUsageTimer);
2452
2505
  const recoveryPeers = (): Record<string, RestartPeerSnapshot> => Object.fromEntries([...bus.peers].map(([id, peer]) => {
2453
2506
  const metadata = peer.recoveryMetadata?.() ?? {};
2454
2507
  const row: RestartPeerSnapshot = { id, state: peer.state, queueIds: bus.queueIds(id), ...(metadata.launch ? { launch: metadata.launch as Record<string, unknown> } : {}) };
2455
2508
  if (typeof metadata.threadId === "string") row.threadId = metadata.threadId;
2456
- const sessionId = definitionOf(id)?.transport === "claude-channel" ? claudeSession().sessionId : metadata.sessionId;
2509
+ const sessionId = definitionOf(id)?.transport === "claude-channel" ? claudeSession(id).sessionId : metadata.sessionId;
2457
2510
  if (typeof sessionId === "string" && sessionId) row.sessionId = sessionId;
2458
2511
  return [id, row];
2459
2512
  }));
@@ -2496,7 +2549,7 @@ export async function startDaemon(opts: DaemonOptions) {
2496
2549
  if (peer instanceof AcpPeer && ["unknown", "unmanaged"].includes(peer.permissionModeState)) return peer.permissionModeState;
2497
2550
  if (starting.has(id) && (peer instanceof AcpPeer || peer instanceof CodexPeer)) return peer.getPermissionMode();
2498
2551
  if (definitionOf(id)?.transport === "claude-channel" && peer instanceof WsPeer && peer.state !== "offline") {
2499
- const launch = claudePermissionLaunch();
2552
+ const launch = claudePermissionLaunch(id);
2500
2553
  if (!launch || claudePermissionGenerations.get(id) !== peer.sessionGeneration) return "unverified";
2501
2554
  }
2502
2555
  return permissionMode(id);
@@ -2508,6 +2561,12 @@ export async function startDaemon(opts: DaemonOptions) {
2508
2561
  return mode === "ask" ? {} : { permissionMode: mode };
2509
2562
  };
2510
2563
  const lastCodexThreads = new Map<string, string>();
2564
+ // #450 I3: the console and dashboard list each declared instance right after its kind.
2565
+ const byKind = (ids: string[]): string[] => {
2566
+ const kindOf = (id: string) => Object.hasOwn(declarations, id) ? declarations[id]!.kind : undefined;
2567
+ const out = ids.filter(id => kindOf(id) === undefined).flatMap(id => [id, ...ids.filter(extra => kindOf(extra) === id)]);
2568
+ return [...out, ...ids.filter(id => !out.includes(id))];
2569
+ };
2511
2570
  const statusPeerIds = (open: ReturnType<Board["statusTasks"]>) => {
2512
2571
  const reservations = board.statusReservations();
2513
2572
  const ids = new Set([...bus.knownPeers(), ...extraIds, ...reservations]);
@@ -2519,12 +2578,13 @@ export async function startDaemon(opts: DaemonOptions) {
2519
2578
  const taskNeeds = board.statusTasks();
2520
2579
  const taskStalls = tasks.stalled();
2521
2580
  const pendingTaskOffers = tasks.pendingOffers().filter(offer => offer.pullOnly === true);
2522
- const peerIds = statusPeerIds(taskNeeds);
2581
+ const peerIds = byKind(statusPeerIds(taskNeeds));
2523
2582
  const view = {
2524
2583
  ...(crashReport.length ? { crash: crashReport } : {}),
2525
2584
  projectId,
2526
2585
  instanceId,
2527
2586
  version: VERSION,
2587
+ pluginBundle: SERVER_JS,
2528
2588
  pendingRestart,
2529
2589
  memoryProfile: memoryProfileStatus(includeAttentionItems),
2530
2590
  lifecycleStages: [...lifecycleConfig.stages],
@@ -2534,6 +2594,8 @@ export async function startDaemon(opts: DaemonOptions) {
2534
2594
  roster: [...KNOWN_PEERS, ...OPTIONAL_PEERS].map(peer => ({ peer, on: onRoster(peer) })),
2535
2595
  protocol: PROTOCOL,
2536
2596
  features: DAEMON_FEATURES,
2597
+ // #450 I3: the instance ids this daemon bound at startup; a launcher selects only one of these.
2598
+ activeInstances: extraIds,
2537
2599
  stopping,
2538
2600
  pid: process.pid,
2539
2601
  cwd: opts.cwd,
@@ -3173,6 +3235,9 @@ export async function startDaemon(opts: DaemonOptions) {
3173
3235
  return { ok: true, mode, opened: opened.via, command };
3174
3236
  }
3175
3237
 
3238
+ /** The CLI command that starts a peer: `ahub <kind> --as <id>` for a declared instance. */
3239
+ const startCommand = (peer: string): string => { const kind = definitionOf(peer)?.kind; return kind && kind !== peer ? `ahub ${kind} --as ${peer}` : `ahub ${peer}`; };
3240
+
3176
3241
  async function stopPeer(peer: unknown, rosterRemoval = false): Promise<Record<string, unknown>> {
3177
3242
  if (!rosterRemoval && rosterChanging !== undefined && rosterChanging === peer) return { ok: false, error: `${String(peer)} roster change is in progress` };
3178
3243
  if (stopping || recoveryActive()) return { ok: false, error: "recovery or shutdown is holding peer stops; finish the operation before retrying" };
@@ -3180,9 +3245,9 @@ export async function startDaemon(opts: DaemonOptions) {
3180
3245
  const owner = bus.peers.get(peer);
3181
3246
  const stopNotice = definitionOf(peer)?.cli.stopNotice?.(owner instanceof WsPeer ? "tui" : !owner ? startMode(peer) : "headless", owner);
3182
3247
  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" };
3248
+ if (!(owner instanceof AcpPeer || owner instanceof PiPeer || owner instanceof LocalPeer || owner instanceof ClaudePrintPeer)) return { ok: false, error: "unknown or non-owned peer; run ahub status and choose a hub-owned headless peer" };
3184
3249
  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` };
3250
+ if (owner.state === "offline") return { ok: false, error: `peer is already offline; start it with ${startCommand(peer)} before stopping it` };
3186
3251
  if (starting.has(peer) || requestedPeerStops.has(peer) || permissionChanges.has(peer)) return { ok: false, error: "peer lifecycle or permission change is in progress; wait for ahub status before retrying" };
3187
3252
  requestedPeerStops.add(peer); // fence delivery, starts and recovery before the first asynchronous stop step
3188
3253
  try {
@@ -3199,7 +3264,7 @@ export async function startDaemon(opts: DaemonOptions) {
3199
3264
  writeStatus();
3200
3265
  const state = bus.stateOf(peer);
3201
3266
  if (state !== "offline" || bus.peers.get(peer) !== owner) return { ok: false, error: "peer stop did not reach offline on its owner; inspect ahub status and its terminal before retrying" };
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 */ }
3267
+ try { notify(rosterRemoval ? `${peer} stopped for roster removal; add it with ahub roster add ${peer} before starting it again` : `${peer} stopped by the console; start it again with ${startCommand(peer)}`); } catch { /* owner stop and readback remain authoritative */ }
3203
3268
  return { ok: true, state };
3204
3269
  });
3205
3270
  } catch {
@@ -3559,7 +3624,7 @@ export async function startDaemon(opts: DaemonOptions) {
3559
3624
  const result = await piReceipts!.execute(sessionId ?? "", callId, name, humanBash ? { origin: "verified-user-bash", args: raw } : raw, async () => {
3560
3625
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) return "error: invalid tool arguments";
3561
3626
  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,
3627
+ const output = TASK_TOOL_NAMES.has(name) || CONDUCTOR_TOOL_NAMES.has(name) ? await taskOp(peer, name, raw as Record<string, unknown>, true) : await runTool(name, JSON.stringify(raw), { ...ctx, signal,
3563
3628
  ...(humanBash ? { onProcessExit: (code: number | null) => { exitCode = code; } } : {}),
3564
3629
  permit: async (title, tool, toolSignal, path, _observe, originalPath) => {
3565
3630
  const picked = humanBash ? (toolSignal?.aborted || context?.isCurrent() !== true ? "aborted" : true) : await piPermit(title, tool ?? name, toolSignal, path, context?.isCurrent, context?.onApproval, originalPath);
@@ -3738,6 +3803,26 @@ export async function startDaemon(opts: DaemonOptions) {
3738
3803
  await local.start();
3739
3804
  return { ok: true, model: route ? `${route} (fallback ${routing.local.fixed_model})` : (args.model ?? routing.local.fixed_model) };
3740
3805
  }
3806
+ if (adapter?.start.daemonPort === "claude-print") {
3807
+ // #223 R3: one fresh `claude -p` session per review request; idle with no process between reviews.
3808
+ if (existing?.state === "busy") return { ok: false, error: `${peer} is reviewing; retry when it is idle` };
3809
+ if (existing) { mute(existing); await existing.stop(); }
3810
+ const reviewer = adapter.start.create!({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: config, permission: permissionMode(peer), mcpServers: [], daemon: DAEMON_PORTS["claude-print"]({
3811
+ cwd: executionCwd,
3812
+ mcpConfig: JSON.stringify({ mcpServers: { "agent-hub": { command: process.execPath, args: ["run", SERVER_JS], env: toolEnv(peer, true) } } }),
3813
+ systemPrompt: sessionRoleContract(peer),
3814
+ watchdogMs: config.watchdog_ms,
3815
+ privatePaths: [...(config.pii?.paths ?? []), ...config.local.deny],
3816
+ facts: env => env.private ? undefined : reviewDiff(executionCwd, env.refs?.commit, { privatePath: path => isDenied(path, config.local.deny) || matchesPolicy(executionCwd, path, config.pii?.paths ?? [], true), nameable: tasks.nameable }),
3817
+ verdictSince: (task, since) => board.get(task)?.history.some(h => h.by === peer && h.at >= since && (h.event === "approved" || h.event === "changes_requested")) === true,
3818
+ onUsage: usage => void addTokens(peer, (usage.input_tokens ?? 0) + (usage.output_tokens ?? 0)),
3819
+ log,
3820
+ }) }) as ClaudePrintPeer;
3821
+ executionOwners.set(peer, { owner: reviewer, cwd: executionCwd });
3822
+ bus.add(reviewer);
3823
+ await reviewer.start();
3824
+ return { ok: true };
3825
+ }
3741
3826
  return { ok: false, error: `unknown peer "${peer}"` };
3742
3827
  }
3743
3828
 
@@ -4669,7 +4754,7 @@ export async function startDaemon(opts: DaemonOptions) {
4669
4754
  ...saved,
4670
4755
  queueIds: bus.queueIds(saved.id),
4671
4756
  // 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) } : {}),
4757
+ ...(definitionOf(saved.id)?.session.missingEvidence === "transcript" && saved.sessionId ? { sessionPersisted: claudeTranscriptPersisted(saved.id, saved.sessionId) } : {}),
4673
4758
  }));
4674
4759
  const snapshot: RestartSnapshot = {
4675
4760
  schemaVersion: 1,
@@ -4717,9 +4802,9 @@ export async function startDaemon(opts: DaemonOptions) {
4717
4802
  return recoveryError("unknown recovery operation");
4718
4803
  }
4719
4804
 
4720
- function claudePermissionLaunch(): { launchId: string; unattended: boolean; hookPurpose?: string } | undefined {
4805
+ function claudePermissionLaunch(peer = "claude"): { launchId: string; unattended: boolean; hookPurpose?: string } | undefined {
4721
4806
  try {
4722
- const row = JSON.parse(readFileSync(join(opts.stateDir, "claude-launch.json"), "utf8"));
4807
+ const row = JSON.parse(readFileSync(join(opts.stateDir, claudeStateFile("claude-launch", peer)), "utf8"));
4723
4808
  if (row.instanceId !== instanceId || row.permissionHook !== true || typeof row.launchId !== "string" || !row.launchId) return undefined;
4724
4809
  return { launchId: row.launchId, unattended: row.unattended === true, ...(typeof row.hookPurpose === "string" ? { hookPurpose: row.hookPurpose } : {}) };
4725
4810
  } catch { return undefined; }
@@ -4748,7 +4833,7 @@ export async function startDaemon(opts: DaemonOptions) {
4748
4833
  if (!isPermissionMode(mode)) return { ok: false, error: "mode must be ask, ask-when-needed or never-ask" };
4749
4834
  if (mode === "never-ask" && confirmed !== true) return { ok: false, error: "never-ask needs --yes or console confirmation" };
4750
4835
  if (definitionOf(peer)?.transport === "claude-channel") {
4751
- const launch = claudePermissionLaunch();
4836
+ const launch = claudePermissionLaunch(peer);
4752
4837
  if (!launch || !(owner instanceof WsPeer) || claudePermissionGenerations.get(peer) !== owner.sessionGeneration) return { ok: false, error: "Claude has no verified ahub claude permission hook; restart with ahub claude and run a tool first" };
4753
4838
  if (launch.unattended) return { ok: false, error: "Claude started --unattended already skips prompts; run ahub permission claude ask after it stops to clear the hub choice, then restart without --unattended" };
4754
4839
  } else if (owner instanceof CodexPeer) {
@@ -5021,18 +5106,18 @@ export async function startDaemon(opts: DaemonOptions) {
5021
5106
  let expectedLaunch: string | undefined;
5022
5107
  try {
5023
5108
  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;
5109
+ expectedLaunch = Array.isArray(rows) ? rows.find(row => row?.peer === peer && definitionOf(row.peer)?.transport === "claude-channel" && row?.projectRoot === opts.cwd && row?.instanceId === instanceId)?.launchId : undefined;
5025
5110
  } catch { /* an ordinary terminal has no Orca recovery identity */ }
5026
5111
  try {
5027
- const launch = JSON.parse(readFileSync(join(opts.stateDir, "claude-launch.json"), "utf8"));
5112
+ const launch = JSON.parse(readFileSync(join(opts.stateDir, claudeStateFile("claude-launch", peer)), "utf8"));
5028
5113
  if (launch.instanceId === instanceId && typeof launch.launchId === "string") expectedLaunch = launch.launchId;
5029
5114
  } catch { /* older/manual launches do not have a launcher marker */ }
5030
- const current = claudeSession();
5115
+ const current = claudeSession(peer);
5031
5116
  if ((phase === "stop" || phase === "post") && current.sessionId && current.sessionId !== sessionId) return void reply({ t: "facts", ok: false });
5032
5117
  if (!expectedLaunch || msg.nativeLaunchId === expectedLaunch) {
5033
- const transcript = claudeTranscript(sessionId, msg.transcriptPath);
5118
+ const transcript = claudeTranscript(sessionId, msg.transcriptPath, peer);
5034
5119
  const launchId = typeof msg.nativeLaunchId === "string" ? msg.nativeLaunchId : undefined;
5035
- const file = join(opts.stateDir, "claude-session.json");
5120
+ const file = join(opts.stateDir, claudeStateFile("claude-session", peer));
5036
5121
  writeFileSync(`${file}.hook.tmp`, JSON.stringify({ at: Date.now(), instanceId, sessionId, ...(launchId ? { launchId } : {}), ...(transcript ? { transcriptPath: transcript } : {}) }), { mode: 0o600 });
5037
5122
  chmodSync(`${file}.hook.tmp`, 0o600); renameSync(`${file}.hook.tmp`, file);
5038
5123
  nativeClaude = true;
@@ -5041,7 +5126,7 @@ export async function startDaemon(opts: DaemonOptions) {
5041
5126
  }
5042
5127
  if (definitionOf(peer)?.transport === "claude-channel" && msg.nativeInstanceId !== undefined && msg.nativeInstanceId !== instanceId) return void reply({ t: "facts", ok: false });
5043
5128
  const nativePeer = nativeClaude ? bus.peers.get(peer) : undefined;
5044
- const permissionLaunch = definitionOf(peer)?.transport === "claude-channel" ? claudePermissionLaunch() : undefined;
5129
+ const permissionLaunch = definitionOf(peer)?.transport === "claude-channel" ? claudePermissionLaunch(peer) : undefined;
5045
5130
  const permission = nativeClaude && permissionLaunch && !permissionLaunch.unattended && msg.nativeLaunchId === permissionLaunch.launchId ? permissionMode(peer) : undefined;
5046
5131
  if (permission !== undefined && nativePeer instanceof WsPeer) claudePermissionGenerations.set(peer, nativePeer.sessionGeneration);
5047
5132
  const protectedWrite = scopedWriteBlocked(phase, permission, msg.tool, () => projectFileTool(msg.tool, msg.input, opts.cwd, config.protect_instruction_files, config.data?.readonly));
@@ -5053,10 +5138,10 @@ export async function startDaemon(opts: DaemonOptions) {
5053
5138
  if (phase === "session") { recordSessions(); return void reply({ t: "facts", ok: true }); }
5054
5139
  if ((phase === "start" || phase === "pre") && nativePeer instanceof WsPeer) {
5055
5140
  const previous = nativeTurnStart.get(peer);
5056
- const current = claudeSession();
5141
+ const current = claudeSession(peer);
5057
5142
  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;
5143
+ const session = claudeSession(peer);
5144
+ const baseline = session.sessionId && session.transcriptPath ? readClaudeTranscriptCompletion(session.sessionId, session.transcriptPath)?.id : undefined;
5060
5145
  nativeTurnStart.set(peer, { at: Date.now(), ...(baseline ? { baseline } : {}), generation: nativePeer.sessionGeneration, session: sessionId!, launch: session.launchId ?? "" });
5061
5146
  }
5062
5147
  nativePeer.nativeStarted(sessionId!);
@@ -5075,12 +5160,12 @@ export async function startDaemon(opts: DaemonOptions) {
5075
5160
  event({ type: "hook_stats", peer, n: st.n, startupMs: Math.round(st.startupMs), hubMs: Math.round(st.hubMs), maxStartupMs: Math.round(st.maxStartupMs) });
5076
5161
  hookStats.delete(peer);
5077
5162
  if (id && nativePeer instanceof WsPeer) nativePeer.nativeStopped(sessionId!);
5078
- if (factsOn()) { factSession(peer, sessionId); readbacks(peer, claudeTranscript(sessionId, msg.transcriptPath)); }
5163
+ if (factsOn()) { factSession(peer, sessionId); readbacks(peer, claudeTranscript(sessionId, msg.transcriptPath, peer)); }
5079
5164
  };
5080
5165
  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; };
5166
+ const session = claudeSession(peer);
5167
+ let transcript = session.transcriptPath ?? claudeTranscript(sessionId, msg.transcriptPath, peer);
5168
+ const readCompletion = () => { transcript ??= claudeTranscript(sessionId, msg.transcriptPath, peer); return session.sessionId && transcript ? readClaudeTranscriptCompletion(session.sessionId, transcript) : undefined; };
5084
5169
  const completionId = (id: string) => createHash("sha256").update(`${sessionId}:${session.launchId ?? "legacy"}:${id}`).digest("hex");
5085
5170
  const started = nativeTurnStart.get(peer);
5086
5171
  const activeStart = !!started && started.session === sessionId && started.launch === (session.launchId ?? "") && nativePeer instanceof WsPeer && started.generation === nativePeer.sessionGeneration;
@@ -5091,7 +5176,7 @@ export async function startDaemon(opts: DaemonOptions) {
5091
5176
  }
5092
5177
  if (!started || !activeStart || !(nativePeer instanceof WsPeer)) { log("native Stop refused for claude: current native turn start unavailable"); return void reply({ t: "facts", ok: false }); }
5093
5178
  const scopeCurrent = () => {
5094
- const now = claudeSession();
5179
+ const now = claudeSession(peer);
5095
5180
  return !stopping && bus.peers.get(peer) === nativePeer && nativePeer.sessionGeneration === started.generation && now.sessionId === started.session && (now.launchId ?? "") === started.launch;
5096
5181
  };
5097
5182
  const current = () => scopeCurrent() && nativeTurnStart.get(peer) === started;
@@ -5106,14 +5191,20 @@ export async function startDaemon(opts: DaemonOptions) {
5106
5191
  if (nativeStopWaits.get(peer) === ownWait) nativeStopWaits.delete(peer);
5107
5192
  if (!result.record) {
5108
5193
  const reason = ({ unavailable: "completed transcript message unavailable", baseline: "completion still matches start baseline", "before-start": "completion timestamp precedes native start", superseded: "native start superseded during completion wait", verified: "completed transcript message unavailable" } as const)[result.reason];
5109
- log(`native Stop refused for claude: ${reason}`); return;
5194
+ log(`native Stop refused for claude: ${reason}`);
5195
+ // #517: a peer left busy receives no pushed messages; say so where the person looks, once per turn.
5196
+ if (result.reason === "unavailable" && !stopUnconfirmedNoticed.has(started)) {
5197
+ stopUnconfirmedNoticed.add(started);
5198
+ notify(`${peer}: its turn end could not be confirmed from its transcript, so it stays busy and messages to it wait in its queue (hub_inbox reads them)`);
5199
+ }
5200
+ return;
5110
5201
  }
5111
5202
  const id = completionId(result.record.id);
5112
5203
  if (scopeCurrent() && nativeStopsSeen.has(id)) return;
5113
5204
  if (!current()) { log("native Stop refused for claude: native start superseded during completion wait"); return; }
5114
5205
  nativeStopsSeen.add(id); nativeTurnStart.delete(peer); // consume before idle admits another native turn
5115
5206
  if (transcript && !session.transcriptPath) {
5116
- const file = join(opts.stateDir, "claude-session.json");
5207
+ const file = join(opts.stateDir, claudeStateFile("claude-session", peer));
5117
5208
  writeFileSync(`${file}.hook.tmp`, JSON.stringify({ at: Date.now(), instanceId, sessionId, launchId: session.launchId, transcriptPath: transcript }), { mode: 0o600 });
5118
5209
  chmodSync(`${file}.hook.tmp`, 0o600); renameSync(`${file}.hook.tmp`, file);
5119
5210
  }
@@ -5127,7 +5218,7 @@ export async function startDaemon(opts: DaemonOptions) {
5127
5218
  if (!factsOn()) return void reply({ t: "facts", ok: true, ...permissionReply });
5128
5219
  if (definitionOf(peer)?.transport === "claude-channel" && phase === "pre") observeProgress(peer, normalizeClaudeObservation(tool, input));
5129
5220
  factSession(peer, sessionId);
5130
- const transcript = claudeTranscript(sessionId, msg.transcriptPath);
5221
+ const transcript = claudeTranscript(sessionId, msg.transcriptPath, peer);
5131
5222
  if (phase === "post") {
5132
5223
  factsFor(peer).postTool(peer, toolUseId, tool, input);
5133
5224
  readbacks(peer, transcript);
@@ -5202,6 +5293,23 @@ export async function startDaemon(opts: DaemonOptions) {
5202
5293
  if (msg.op === "export") {
5203
5294
  try { return void reply({ ok: true, export: tasks.workflowExport(msg.id) }); } catch { return void reply({ ok: false, error: "workflow export unavailable" }); }
5204
5295
  }
5296
+ if (msg.op === "accept") {
5297
+ // #481 S1: the person's spec acceptance, bound to the issue's body edit time; no body is read.
5298
+ if (!Number.isSafeInteger(msg.id) || msg.id <= 0 || !Number.isSafeInteger(msg.issue) || msg.issue <= 0 || (msg.repo !== undefined && typeof msg.repo !== "string")) return void reply({ ok: false, error: "usage: ahub sdlc accept <task> --issue <n> [--repo owner/repository]" });
5299
+ const task = board.get(msg.id);
5300
+ const repo = msg.repo ?? task?.sdlc?.repo;
5301
+ if (!task || typeof repo !== "string") return void reply({ ok: false, error: task ? "spec acceptance needs --repo owner/repository (the task's receipt names none)" : "invalid lifecycle task id" });
5302
+ const linked = `${task.sdlc?.repo ?? ""}#${task.sdlc?.issue ?? ""}`;
5303
+ void readSpecEdit(repo, msg.issue, (args, timeout, _cwd, bound) => sdlcGh(args, timeout, opts.cwd, bound)).then(edit => {
5304
+ if (!edit) return reply({ ok: false, error: "issue edit time unavailable; nothing was recorded" });
5305
+ // The readback is for the receipt read before it; a relink meanwhile makes it someone else's evidence.
5306
+ const now = board.get(msg.id)?.sdlc;
5307
+ if (`${now?.repo ?? ""}#${now?.issue ?? ""}` !== linked) return reply({ ok: false, error: "the task's spec link changed during the readback; nothing was recorded, run ahub sdlc accept again" });
5308
+ try { const next = tasks.acceptSpec(USER, msg.id, { repo, issue: msg.issue, edit }); reply({ ok: true, acceptance: next.sdlc?.acceptance }); }
5309
+ catch (error) { reply({ ok: false, error: (error as Error).message }); }
5310
+ }, () => reply({ ok: false, error: "issue edit time unavailable; nothing was recorded" }));
5311
+ return;
5312
+ }
5205
5313
  if (msg.op !== "status" && msg.op !== "explain") return void reply({ ok: false, error: "unknown SDLC operation" });
5206
5314
  const selected = msg.id === undefined ? board.list().filter(task => task.state !== "backlog" && (task.sdlc || lifecycleConfig.mode !== "off" || lifecycleConfig.workflow === "gsd-lite")) : [board.get(Number(msg.id))].filter((task): task is Task => !!task);
5207
5315
  if (msg.id !== undefined && (!Number.isSafeInteger(msg.id) || msg.id <= 0 || !selected.length)) return void reply({ ok: false, error: "invalid lifecycle task id" });