@staix/agent-hub 0.13.0 → 0.15.0

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 (92) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +19 -7
  3. package/docs/agent-notes/README.md +4 -0
  4. package/docs/agent-notes/adapters.md +18 -4
  5. package/docs/agent-notes/budget.md +5 -4
  6. package/docs/agent-notes/bus.md +1 -0
  7. package/docs/agent-notes/daemon.md +19 -7
  8. package/docs/agent-notes/tasks.md +15 -7
  9. package/docs/agent-notes/tests.md +4 -4
  10. package/docs/dogfood.md +18 -10
  11. package/docs/events.md +21 -7
  12. package/docs/operations.md +131 -27
  13. package/docs/profiles.md +1 -0
  14. package/docs/quickstart.md +13 -3
  15. package/docs/sdlc.md +40 -2
  16. package/docs/security.md +45 -14
  17. package/docs/specs/2026-09-19-agent-hub-design.md +112 -10
  18. package/docs/verification/2026-10-22-codex-reviewer-isolation.md +14 -0
  19. package/docs/verification/2026-10-23-0.14.0.md +69 -0
  20. package/docs/verification/2026-10-23-0.15.0.md +80 -0
  21. package/docs/verification/2026-10-23-daemon-responsiveness.md +44 -0
  22. package/docs/verification/2026-10-23-headless-claude-permissions.md +47 -0
  23. package/docs/verification/2026-10-23-terminal-claude-execution-root.md +41 -0
  24. package/docs/verified.json +34 -17
  25. package/package.json +1 -1
  26. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  27. package/plugins/agent-hub/server.js +1078 -327
  28. package/src/adapters/acp.ts +43 -7
  29. package/src/adapters/claude-channel.ts +26 -4
  30. package/src/adapters/claude-implementer.ts +243 -0
  31. package/src/adapters/claude-print-child.ts +90 -0
  32. package/src/adapters/claude-task-sessions.ts +80 -0
  33. package/src/adapters/codex-review-isolation.ts +18 -7
  34. package/src/adapters/compat.ts +4 -0
  35. package/src/adapters/conformance/enrollment.ts +2 -1
  36. package/src/adapters/conformance/fakes/acp-server.ts +3 -1
  37. package/src/adapters/definitions/builtin-metadata.ts +15 -3
  38. package/src/adapters/definitions/native-channel-state.ts +1 -1
  39. package/src/adapters/definitions/native-cli-entry-claude.ts +13 -8
  40. package/src/adapters/definitions/native-cli-entry-codex.ts +13 -0
  41. package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
  42. package/src/adapters/definitions/native-factories.ts +8 -0
  43. package/src/adapters/kimi-wire-usage.ts +303 -0
  44. package/src/adapters/registry-types.ts +7 -3
  45. package/src/adapters/registry.ts +14 -2
  46. package/src/adapters/reviewer-print.ts +64 -60
  47. package/src/cli/console-config.ts +123 -0
  48. package/src/cli/console-state.ts +28 -1
  49. package/src/cli/console.ts +25 -1
  50. package/src/cli/facts-hook.ts +9 -5
  51. package/src/cli/help.ts +6 -4
  52. package/src/cli/launch.ts +10 -4
  53. package/src/cli/main.ts +52 -9
  54. package/src/cli/output.ts +75 -2
  55. package/src/cli/reset.ts +1 -1
  56. package/src/cli/settings-edit.ts +89 -0
  57. package/src/cli/status-lines.ts +1 -1
  58. package/src/cli/terminal-recovery.ts +1 -6
  59. package/src/hub/action-enforce.ts +82 -0
  60. package/src/hub/action-policy.ts +22 -10
  61. package/src/hub/board.ts +48 -3
  62. package/src/hub/budget.ts +5 -0
  63. package/src/hub/bus.ts +5 -5
  64. package/src/hub/claude-launch-proof.ts +33 -0
  65. package/src/hub/config-apply.ts +5 -0
  66. package/src/hub/config-edit-host.ts +173 -0
  67. package/src/hub/config-edit.ts +282 -0
  68. package/src/hub/config-tier.ts +89 -0
  69. package/src/hub/control-client.ts +24 -3
  70. package/src/hub/daemon.ts +450 -71
  71. package/src/hub/deliverable-files.ts +131 -0
  72. package/src/hub/deliverables.ts +125 -0
  73. package/src/hub/delivery-journal.ts +18 -2
  74. package/src/hub/events.ts +3 -1
  75. package/src/hub/hub-tools.ts +21 -4
  76. package/src/hub/instances.ts +19 -6
  77. package/src/hub/kimi-usage.ts +144 -0
  78. package/src/hub/manager.ts +3 -3
  79. package/src/hub/peers.ts +7 -0
  80. package/src/hub/permission-payload.ts +15 -0
  81. package/src/hub/routing.ts +118 -3
  82. package/src/hub/sdlc-metrics.ts +260 -0
  83. package/src/hub/sdlc.ts +5 -3
  84. package/src/hub/settings.ts +1 -1
  85. package/src/hub/spec-read.ts +31 -0
  86. package/src/hub/task-code-group.ts +52 -0
  87. package/src/hub/tasks.ts +374 -37
  88. package/src/hub/token-window.ts +33 -0
  89. package/src/hub/ui.ts +3 -0
  90. package/src/hub/usage-rollup.ts +74 -6
  91. package/src/hub/workflow.ts +16 -0
  92. package/src/ui/index.html +388 -3
package/src/hub/daemon.ts CHANGED
@@ -1,5 +1,10 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { normalizeUsage } from "../omniroute/usage.ts";
3
+ import { permissionPayload } from "./permission-payload.ts";
4
+ import { readClaudeLaunchProof } from "./claude-launch-proof.ts";
1
5
  import { InboxPolling } from "./inbox-polling.ts";
2
6
  import { ActionBudgets, actionPolicyConfig, type ActionPolicyConfig } from "./action-policy.ts";
7
+ import { actionGate, claudeActionClasses } from "./action-enforce.ts";
3
8
  import { ReleaseAuthorizations, releaseAuthorizationError } from "./sdlc-release-authorization.ts";
4
9
  import { parseReleaseGuardArgv, parseReleasePushUpdates } from "../cli/sdlc-release-guard.ts";
5
10
  import type { DocEvidence } from "./sdlc-release.ts";
@@ -58,7 +63,11 @@ import { ALL_MACHINE_LOCAL, configRepositoryPresent, configTracked, stripUntrust
58
63
  import { authorizeConfigLeaf, configApply, configIdentityScope, configChanges, validateEffectiveConfig, validatePetConfig } from "./config-apply.ts";
59
64
  import { resolveSettledRoles } from "./settled-roles.ts";
60
65
  import { CONFIG_SOURCE_FILES, configFingerprint, configSourceTexts, parseConfigSources, type ConfigSources } from "./config-reload.ts";
61
- import { eventLog, readEvents, tokenDeltas, type RoleIdentity } from "./events.ts";
66
+ import { createConfigEditor } from "./config-edit-host.ts";
67
+ import { readEditable } from "./config-edit.ts";
68
+ import { eventLog, readEvents, type RoleIdentity } from "./events.ts";
69
+ import { KimiUsage, kimiSeedRows } from "./kimi-usage.ts";
70
+ import { kimiRowTotal } from "../adapters/kimi-wire-usage.ts";
62
71
  import { ExecutionBudget } from "./execution-budget.ts";
63
72
  import { claudeReportedTokens, readClaudeTranscriptCompletion, readClaudeTranscriptUsage, waitForClaudeCompletion } from "./usage.ts";
64
73
  import type { ServerWebSocket } from "bun";
@@ -75,7 +84,7 @@ import { profile, proxyEnv, type SandboxNetwork } from "../local/sandbox.ts";
75
84
  import { DEFAULT_NETWORK_ALLOW, startEgressProxy, type EgressProxy } from "../local/proxy.ts";
76
85
  import { runTool, toolResultFailed, TOOL_SCHEMAS, type ToolApproval, type ApprovalObserver, type ToolContext } from "../local/tools.ts";
77
86
  import { isLocalPeer, type LocalPeer } from "../adapters/local-worker.ts";
78
- import { isReviewerPrintPeer, type ReviewerPrintPeer } from "../adapters/reviewer-print.ts";
87
+ import { isReviewerPrintPeer, reviewerUnavailableReason, ReviewerUnavailableError, type ReviewerPrintPeer } from "../adapters/reviewer-print.ts";
79
88
  import { createReviewerMcpSession } from "../adapters/reviewer-mcp.ts";
80
89
  import { reviewerPoolConfig } from "./reviewer-pool-config.ts";
81
90
  import { reviewDiff } from "../adapters/claude-print.ts";
@@ -93,7 +102,7 @@ import { fileURLToPath } from "node:url";
93
102
  import { realPath, hubHome } from "./project.ts";
94
103
  import { mainCheckoutBranch, mainCheckoutWarning } from "./main-checkout.ts";
95
104
  import type { BusEvent, PublishResult } from "./bus.ts";
96
- import { CONDUCTOR_TOOLS, CONDUCTOR_TOOL_NAMES, DEFAULT_ROLES, roleContract, TASK_TOOL_NAMES, TASK_TOOLS, BOUNDED_TASK_TOOLS, profileTaskTools } from "./hub-tools.ts";
105
+ import { CONDUCTOR_TOOLS, CONDUCTOR_TOOL_NAMES, DEFAULT_ROLES, IMPLEMENTER_HUB_TOOL_NAMES, roleContract, TASK_TOOL_NAMES, TASK_TOOLS, BOUNDED_TASK_TOOLS, profileTaskTools } from "./hub-tools.ts";
97
106
  import { Conductor, ConductorHolds, conductorPeer, conductorProgressSink, publicConductorTask, publicPeerBudget, type ConductEvent } from "./conductor.ts";
98
107
  import { SupervisionFeed } from "./supervision.ts";
99
108
  import { drainCliAudits } from "../cli/identity-audit.ts";
@@ -106,7 +115,8 @@ import { projectSettings, checkSettingValue, ignoredLine, pendingUndo, pendingUn
106
115
  import { Bus } from "./bus.ts";
107
116
  import { attribute, deliveryTask, type TaskAttribution } from "./attribution.ts";
108
117
  import { DeliveryJournal } from "./delivery-journal.ts";
109
- import { startDashboard, type DashboardSession } from "./ui.ts";
118
+ import { startDashboard, dashboardView, type DashboardSession } from "./ui.ts";
119
+ import { codeGroup } from "./task-code-group.ts";
110
120
  import { DAEMON_FEATURES, PROTOCOL, stateDirFor } from "./control-client.ts";
111
121
  import { MAX_HOP, newEnvelope, parseMarker, replyParent, sanitize, USER, type Envelope, type PeerId, type Priority } from "./envelope.ts";
112
122
  import { BasePeer, DEFAULT_WATCHDOG_MS, type PeerAdapter } from "./peers.ts";
@@ -290,9 +300,10 @@ export function loadConductorPolicy(cwd: string, layers?: readonly ConfigLayer[]
290
300
  if (!file.roles || typeof file.roles !== "object" || Array.isArray(file.roles)) throw new Error("roles must be an object of peer role lists");
291
301
  for (const [peer, assigned] of Object.entries(file.roles)) {
292
302
  conductorPeer({ [peer]: assigned });
293
- const ceiling = definitionOf(peer)?.routing?.allowedRoles;
303
+ const definition = definitionOf(peer), ceiling = definition?.routing?.allowedRoles;
294
304
  const declared = assigned as string[];
295
- roles[peer] = ceiling ? declared.filter(role => ceiling.includes(role)) : declared;
305
+ // #525 R1: planner needs a kind that can plan (projection.plannerEligible); such an entry is dropped with the ceiling notice, never thrown.
306
+ roles[peer] = (ceiling ? declared.filter(role => ceiling.includes(role)) : declared).filter(role => role !== "planner" || !definition || definition.projection?.plannerEligible === true);
296
307
  if (roles[peer]!.length !== declared.length) notices.push(`roles.${peer}: ignored roles outside the adapter ceiling`);
297
308
  }
298
309
  }
@@ -498,6 +509,8 @@ export interface DaemonOptions {
498
509
  checkoutBranch?: typeof mainCheckoutBranch;
499
510
  /** Host-only admission transport; never selected by project config or tools. */
500
511
  releaseHostAdmission?: (cwd: string) => Promise<ReleaseHostAdmission>;
512
+ /** Host-only (#285): Kimi's data home for its wire-log usage reader; default `KIMI_CODE_HOME`, else `~/.kimi-code`. Never read from config or tools. */
513
+ kimiHome?: string;
501
514
  /** Host-only evidence seam; never supplied by project config or control messages. */
502
515
  releaseDocEvidence?: (root: string) => Promise<DocEvidence | undefined>;
503
516
  projectId?: string;
@@ -521,6 +534,10 @@ export interface DaemonOptions {
521
534
  orphanWatchMs?: number;
522
535
  /** Deterministic task-sweep time; the production default is Date.now. */
523
536
  taskSweepNow?: () => number;
537
+ /** Test seam: the period of the SDLC band evaluation, default five minutes. */
538
+ bandsEveryMs?: number;
539
+ /** Test seam: an extra hold on the SDLC band evaluation, like a storage error. */
540
+ bandsHeld?: () => boolean;
524
541
  /** How long a research record waits after an approval for the task's open turns to end (#247). Tests shrink this. */
525
542
  researchGraceMs?: number;
526
543
  /** Tests observe timer-driven sync without opening a remote; production uses the package sync. */
@@ -538,6 +555,7 @@ interface Client {
538
555
  role?: "peer" | "console" | "tools";
539
556
  peer?: PeerId;
540
557
  launchId?: string;
558
+ toolsOwner?: { owner: PeerAdapter; launchId: string; generation: string; cwd: string };
541
559
  tail?: () => void;
542
560
  watch?: () => void;
543
561
  /** The one-launch Kimi TUI admission this socket attached under; its close clears only that admission. */
@@ -597,7 +615,10 @@ export async function startDaemon(opts: DaemonOptions) {
597
615
  const channelPeers = [...ADAPTERS.filter(definition => definition.transport === "claude-channel").map(definition => definition.kind),
598
616
  ...channelExtras.filter(id => adapterCatalog.definitionOf(id, declarations)?.transport === "claude-channel")];
599
617
  const executionRoots = new Map<string, string>();
600
- const executionOwners = new Map<string, { owner: PeerAdapter; cwd: string }>();
618
+ const childToolAuthority = new AsyncLocalStorage<{ by: string; current: () => boolean }>();
619
+ const implementerTranscripts = new Map<string, string>();
620
+ const executionOwners = new Map<string, { owner: PeerAdapter; cwd: string; socket?: Sock; launchId?: string; generation?: string | number }>();
621
+ const verifiedTerminalRootsThisTurn = new Set<object>();
601
622
  const reviewerUnavailable = new Map<string, string>();
602
623
  const roles = { ...Object.fromEntries(adapterCatalog.definitions.filter(d => configuredIds.includes(d.kind)).map(d => [d.kind, [...d.roles]])), ...loadedConfig.roles };
603
624
  const permissionModesFromInstances: Record<string, PermissionMode> = {};
@@ -673,6 +694,13 @@ export async function startDaemon(opts: DaemonOptions) {
673
694
  let configRevision = 0;
674
695
  let reloadConfirmation: { token: string; fingerprint: string; revision: number; keys: string[] } | undefined;
675
696
  let pendingRestart: { key: string; reason: string }[] = [];
697
+ // #689: structural config edits. Assigned with the settings section; status and the settings view read it before then, so it is held here.
698
+ let configEditor: ReturnType<typeof createConfigEditor> | undefined;
699
+ const structuralScopes = new Set<string>();
700
+ let sdlcFileReads = 0;
701
+ let sdlcFileCache: { stamp: string; value: { mode: string; requiredChecks: readonly string[] } | string } | undefined;
702
+ /** What waits for a restart: the reload's own keys, then a changed sdlc.toml (read once at start). */
703
+ const allPendingRestart = (): { key: string; reason: string }[] => [...pendingRestart, ...(configEditor?.pendingRestart() ?? [])];
676
704
  let retainedConfig: { key: string; reason: string }[] = [];
677
705
  function retainedConfigAfterReload(sources: ConfigSources, fileConfig: HubConfig, rejected: readonly { key: string; reason: string }[], scope?: string): { key: string; reason: string }[] {
678
706
  const rows = new Map<string, string>();
@@ -834,6 +862,7 @@ export async function startDaemon(opts: DaemonOptions) {
834
862
  sock.data.watch?.();
835
863
  const peer = sock.data.peer ? bus.peers.get(sock.data.peer) : undefined;
836
864
  if (isWsPeer(peer)) {
865
+ if (sock.data.peer && executionOwners.get(sock.data.peer)?.socket === sock) revokeTerminalExecution(sock.data.peer);
837
866
  peer.detach(sock);
838
867
  if (!!sock.data.peer && !!definitionOf(sock.data.peer)?.attach?.admission && peer.state === "offline" && !peer.claiming && sock.data.kimiAdmission === kimiAdmissionId) clearKimiAdmission();
839
868
  }
@@ -1399,12 +1428,16 @@ export async function startDaemon(opts: DaemonOptions) {
1399
1428
  reviewOnly: reviewOnlyPeer,
1400
1429
  // #223: a headless reviewer's verdict holds only while the same child it started with still runs for that task.
1401
1430
  reviewAuthority: runningReviewAuthority,
1431
+ requestAuthority: by => {
1432
+ const scope = childToolAuthority.getStore();
1433
+ return scope?.by === by ? () => scope.current() ? undefined : "Claude child generation or execution root changed" : undefined;
1434
+ },
1402
1435
  // #223 R4: fresh only when the verdict comes from a reviewer session the hub started for this task's review.
1403
1436
  reviewContext: (by, task) => !!definitionOf(by)?.reviewer && (bus.peers.get(by) as ReviewerPrintPeer | undefined)?.reviewing === task ? "fresh" : "continuing",
1404
1437
  // Proof belongs to the adapter generation actually started in the validated root. A role-only
1405
1438
  // reload cannot turn a canonical reviewer process into an isolated implementation owner.
1406
1439
  ownerIsolation: () => Object.fromEntries(extraIds.map(id => {
1407
- const bound = executionOwners.get(id);
1440
+ const bound = currentExecutionOwner(id);
1408
1441
  return [id, !!bound && bound.owner === bus.peers.get(id) && bound.cwd !== opts.cwd && bound.cwd === executionRoots.get(id)];
1409
1442
  })),
1410
1443
  recordPlanning: change => event({ type: "planning", ...change }),
@@ -1413,6 +1446,9 @@ export async function startDaemon(opts: DaemonOptions) {
1413
1446
  profile: profileName(config.profile),
1414
1447
  profileData: resolvedProfile,
1415
1448
  piiPaths: config.pii?.paths,
1449
+ deny: config.local.deny,
1450
+ // #509a: deliverables are hashed under the owner's verified execution root (a declared worktree instance's worktree), `undefined` when unproven.
1451
+ executionRoot: peer => ownerWriteRoot(peer),
1416
1452
  catalog,
1417
1453
  sessionRoles: peer => bus.peers.has(peer) && bus.stateOf(peer) !== "offline" ? preparedSessionRoles.get(peer) ?? [] : [],
1418
1454
  projectedReview: task => reviewProjection(task),
@@ -1548,7 +1584,10 @@ export async function startDaemon(opts: DaemonOptions) {
1548
1584
  const facts: Facts = new Facts({ root: opts.cwd, repo: factsRepo, tmp: join(opts.stateDir, "facts"), instance: instanceId.slice(0, 8), scope: (peer) => repositoryFacts ? (scopes.has(peer) ? scopes.get(peer) : scopes.set(peer, tasks.factScope(peer)).get(peer)) : undefined, peers: () => [...bus.peers.keys()], nameable: tasks.nameable, deny: config.local.deny, privatePath: path => matchesPolicy(opts.cwd, path, config.pii?.paths ?? []), projection: file => projections.project(file) });
1549
1585
  const worktreeFacts = new Map<string, Facts>();
1550
1586
  const worktreeProjections = new Map<string, TextProjections>();
1551
- const executionRoot = (peer: string) => executionRoots.get(peer) ?? opts.cwd;
1587
+ const executionRoot = (peer: string) => {
1588
+ if (executionOwners.get(peer)?.socket && !currentExecutionOwner(peer)) return opts.cwd;
1589
+ return executionRoots.get(peer) ?? opts.cwd;
1590
+ };
1552
1591
  const repoFor = (peer: string, budget?: SnapshotBudget) => executionRoot(peer) === opts.cwd ? factsRepo : repoOf(executionRoot(peer), budget);
1553
1592
  const projectionsFor = (peer: string): TextProjections => {
1554
1593
  const root = executionRoot(peer);
@@ -1850,7 +1889,7 @@ export async function startDaemon(opts: DaemonOptions) {
1850
1889
  }
1851
1890
  for (const move of record.moved) moves.set(JSON.stringify([move.id, move.role, move.to, move.title]), move);
1852
1891
  const unreadMoves = [...moves.values()];
1853
- if (definitionOf(record.peer)?.budget?.resetSoftTokensOnResume) kimiTokens.length = 0; // a new window: the old counts would pause it again at once
1892
+ if (definitionOf(record.peer)?.budget?.resetSoftTokensOnResume) kimiUsage.reset(); // a new window: the old counts would pause it again at once
1854
1893
  const moved = unreadMoves.length ? `While you were paused these moved: ${unreadMoves.map((m) => `${m.title} (${m.role} -> ${m.to ?? "nobody"})`).join("; ")}. They stay where they are; ask the user if you should take one back.` : earlier.length ? "Moves from earlier pauses are preserved in the unread notices below." : "Nothing was moved while you were paused.";
1855
1894
  bus.publish(newEnvelope(HUB, `Your quota window has reset and the hub has resumed you (paused since ${new Date(record.since).toLocaleTimeString()}, ${record.reason}). ${moved}${earlier.length ? ` Earlier unread resume notices: ${earlier.join("\n")}` : ""} Messages queued for you follow.`, { to: [record.peer], kind: "budget", priority: "important", refs: { budgetResume: record.peer, budgetResumeMoves: unreadMoves, ...(earlier.length ? { budgetResumeEarlier: earlier } : {}) } }));
1856
1895
  // Publish the complete replacement before removing its evidence. A failed write keeps the old notices.
@@ -1862,8 +1901,28 @@ export async function startDaemon(opts: DaemonOptions) {
1862
1901
  // ponytail: a third full read of events.jsonl at startup (after nativeStopsSeen and claudeNativeUsageSeen). Measured at about 70 ms on the real
1863
1902
  // 46 MB, 238k-line log, so it is not folded into one pass across startup. Ceiling: the log is never rotated, so the cost grows with it; when a
1864
1903
  // read passes a few hundred ms, make one `readEvents` pass feed all three sets (or keep the resets in hub.db, the #683 U2 roll-up).
1865
- budget.seedResets(readEvents(join(opts.stateDir, "events.jsonl")).flatMap(e => e.type === "quota_reset" ? [{ peer: e.peer, window: e.window, ...(e.resetsAt !== undefined ? { resetsAt: e.resetsAt } : {}) }] : []));
1866
- const kimiTokens: { at: number; n: number }[] = [];
1904
+ const startupEvents = readEvents(join(opts.stateDir, "events.jsonl"));
1905
+ budget.seedResets(startupEvents.flatMap(e => e.type === "quota_reset" ? [{ peer: e.peer, window: e.window, ...(e.resetsAt !== undefined ? { resetsAt: e.resetsAt } : {}) }] : []));
1906
+ // #285 phase 2: Kimi's consumption is the hub's own count of its session wire log (`src/adapters/kimi-wire-usage.ts`), not an ACP total.
1907
+ const kimiUsage = new KimiUsage({
1908
+ home: opts.kimiHome ?? process.env.KIMI_CODE_HOME ?? join(homedir(), ".kimi-code"),
1909
+ now: () => Date.now(),
1910
+ ceiling: () => config.budget.kimi_tokens_5h,
1911
+ session: peer => contextSession(peer),
1912
+ record: (peer, row, counted) => {
1913
+ addTokens(peer, counted);
1914
+ event({ type: "usage", peer, source: "kimi_wire", id: row.id, measuredAt: new Date(row.at).toISOString(), inputTokens: row.inputOther, outputTokens: row.output,
1915
+ cacheReadTokens: row.inputCacheRead, cacheWriteTokens: row.inputCacheCreation, totalTokens: kimiRowTotal(row),
1916
+ ...taskAttribution(peer), ...(row.model ? { servedModel: row.model } : {}) });
1917
+ },
1918
+ report: (peer, window) => budget.report(peer, [{ id: "tokens", used: window.used, resetsAt: window.resetsAt, source: `hub count, kimi wire log, of kimi_tokens_5h ${window.ceiling}` }]),
1919
+ forget: peer => budget.forget(peer, "tokens"),
1920
+ notice: line => notify(line),
1921
+ });
1922
+ kimiUsage.seed(kimiSeedRows(startupEvents, Math.max(0, ...startupEvents.flatMap(e => e.type === "budget_resume" && definitionOf(e.peer)?.budget?.resetSoftTokensOnResume ? [Date.parse(e.at)] : []).filter(Number.isFinite))));
1923
+ /** A peer whose definition keeps host-owned soft token accounting (Kimi): its rows are read at a turn start (the floor), a turn end and a usage_update. */
1924
+ const softTokenPeer = (peer: PeerId): boolean => !!definitionOf(peer)?.budget?.resetSoftTokensOnResume;
1925
+ const collectKimiUsage = (peer: PeerId): void => { if (!softTokenPeer(peer)) return; try { kimiUsage.collect(peer); } catch (error) { log(`kimi usage: ${(error as Error).name}`); } };
1867
1926
  startupCleanup.push(() => budget.close());
1868
1927
  // #683 U2: events.jsonl folded into hub.db, a bounded pass at a time, so `usage` never rescans the log.
1869
1928
  const usageRollup = new UsageRollup(join(opts.stateDir, "hub.db"), join(opts.stateDir, "events.jsonl"));
@@ -1962,13 +2021,70 @@ export async function startDaemon(opts: DaemonOptions) {
1962
2021
  function privateSnapshotWindow(): boolean {
1963
2022
  return [...turns.values()].some(turn => turn.private) || board.list().some(task => !isClosedTaskState(task.state) && tasks.isPii(task));
1964
2023
  }
2024
+ function revokeTerminalExecution(peer: string): void {
2025
+ const bound = executionOwners.get(peer);
2026
+ if (bound) verifiedTerminalRootsThisTurn.delete(bound);
2027
+ executionOwners.delete(peer); executionRoots.delete(peer);
2028
+ worktreeFacts.delete(peer); worktreeProjections.delete(peer);
2029
+ }
2030
+ function currentExecutionOwner(peer: string) {
2031
+ const bound = executionOwners.get(peer);
2032
+ if (bound?.socket) {
2033
+ const launch = readClaudeLaunchProof(opts.stateDir, peer, instanceId);
2034
+ if (!isWsPeer(bound.owner) || bound.owner.sessionGeneration !== bound.generation || !launch?.privateRecord || launch.launchId !== bound.launchId || launch.executionCwd !== bound.cwd) {
2035
+ revokeTerminalExecution(peer);
2036
+ return undefined;
2037
+ }
2038
+ // A new event-loop turn revalidates Git inventory and the attached branch before
2039
+ // an admission/effect. Reuse only within synchronous code, never across an await.
2040
+ if (!verifiedTerminalRootsThisTurn.has(bound)) {
2041
+ try {
2042
+ if (resolveInstanceExecutionCwd(opts.cwd, peer, declarations, config.roles[peer] ?? [], adapterCatalog) !== bound.cwd) throw new Error("execution root changed");
2043
+ } catch { revokeTerminalExecution(peer); return undefined; }
2044
+ verifiedTerminalRootsThisTurn.add(bound);
2045
+ queueMicrotask(() => verifiedTerminalRootsThisTurn.delete(bound));
2046
+ }
2047
+ // The hello claim is not attached until its asynchronous preface finishes.
2048
+ if (!bound.owner.owns(bound.socket)) return undefined;
2049
+ } else if (bound && definitionOf(peer)?.start.daemonPort === "claude-implementer") {
2050
+ try {
2051
+ if (bound.owner !== bus.peers.get(peer) || bound.owner.state === "offline" || bound.cwd === opts.cwd) throw new Error("execution root changed");
2052
+ if (!verifiedTerminalRootsThisTurn.has(bound)) {
2053
+ if (resolveInstanceExecutionCwd(opts.cwd, peer, declarations, config.roles[peer] ?? [], adapterCatalog) !== bound.cwd) throw new Error("execution root changed");
2054
+ verifiedTerminalRootsThisTurn.add(bound); queueMicrotask(() => verifiedTerminalRootsThisTurn.delete(bound));
2055
+ }
2056
+ } catch { revokeTerminalExecution(peer); return undefined; }
2057
+ }
2058
+ return bound;
2059
+ }
1965
2060
  function ownerWriteRoot(peer: string): string | undefined {
1966
2061
  if (declarations[peer]) {
1967
- const bound = executionOwners.get(peer);
2062
+ const bound = currentExecutionOwner(peer);
1968
2063
  if (!bound || bound.owner !== bus.peers.get(peer) || bound.cwd !== executionRoots.get(peer)) return undefined;
1969
2064
  }
1970
2065
  return executionRoot(peer);
1971
2066
  }
2067
+ function implementerChatTask(peer: string): number | undefined {
2068
+ const owned = board.list("in_progress").filter(task => task.owner === peer && !tasks.isPii(task));
2069
+ return owned.length === 1 ? owned[0]!.id : undefined;
2070
+ }
2071
+ function currentImplementerTools(c: Client, verifyRoot = true): ReturnType<NonNullable<PeerAdapter["ownedTools"]>> {
2072
+ const bound = c.toolsOwner, peer = c.peer;
2073
+ if (!bound || !peer || c.role !== "tools" || stopping || recoveryActive() || !membershipAllowed(peer) || contextPii(peer) || definitionOf(peer)?.start.daemonPort !== "claude-implementer" || bus.peers.get(peer) !== bound.owner) return undefined;
2074
+ const launch = bound.owner.ownedTools?.(bound.launchId);
2075
+ if (!launch || launch.signal.aborted || launch.generation !== bound.generation || bound.cwd === opts.cwd) return undefined;
2076
+ const root = verifyRoot ? ownerWriteRoot(peer) : executionOwners.get(peer)?.owner === bound.owner ? executionRoots.get(peer) : undefined;
2077
+ if (root !== bound.cwd) return undefined;
2078
+ if (launch.task !== undefined) {
2079
+ const task = board.get(launch.task);
2080
+ if (!task || task.owner !== peer || task.state === "cancelled" || tasks.isPii(task)) return undefined;
2081
+ }
2082
+ return launch;
2083
+ }
2084
+ function captureImplementerTools(c: Client): () => boolean {
2085
+ const binding = c.toolsOwner, launch = currentImplementerTools(c);
2086
+ return () => c.toolsOwner === binding && !!launch && currentImplementerTools(c) === launch;
2087
+ }
1972
2088
  function canonicalWriteScope(repo: Repo, budget?: SnapshotBudget): boolean | undefined {
1973
2089
  // ponytail: D1 is inert for builtin owners while .worktrees exists (a deliberate false negative).
1974
2090
  // Bind each owner to its real execution worktree before narrowing this unknown scope.
@@ -2027,30 +2143,22 @@ export async function startDaemon(opts: DaemonOptions) {
2027
2143
  const paths = changedPaths(repo, from, to).filter(path => { const local = path.slice(repo.prefix.length); return scopes.some(scope => !scope || local === scope || local.startsWith(scope + "/")); });
2028
2144
  return projectedDiff({ repo, from, to, paths, projections, deny: config.local.deny, nameable: tasks.nameable, privatePath: path => matchesPolicy(root, join(repo.top, path), config.pii?.paths ?? []) });
2029
2145
  }
2030
- const delta = tokenDeltas();
2031
- /** Tokens a peer used, as increments: Kimi reports a session total (turned into increments below), Codex increments. */
2146
+ /** Tokens a peer used, as increments. */
2032
2147
  let codexUsageSeq = 0; // per hub run: with the run id and peer it names one usage record
2033
- const addTokens = (peer: PeerId, n: number): number => {
2148
+ const addTokens = (peer: PeerId, n: number, task?: number): number => {
2034
2149
  const supervisionTurn = supervisionTurns.get(peer);
2035
2150
  if (supervisionTurn && Number.isFinite(n) && n >= 0) supervisionTurn.tokens = (supervisionTurn.tokens ?? 0) + n;
2036
2151
  if (n > 0) {
2037
- event({ type: "tokens", peer, n, ...taskAttribution(peer) });
2152
+ event({ type: "tokens", peer, n, ...taskAttribution(peer, task) });
2038
2153
  const turn = turns.get(peer);
2039
2154
  if (turn) turn.tokens += n;
2040
2155
  }
2041
2156
  return n;
2042
2157
  };
2043
- const onKimiTokens = (total: number, session: string, peer: string) => {
2044
- const n = addTokens(peer, delta(peer, total, session));
2045
- if (!config.budget.kimi_tokens_5h || !n) return;
2046
- const now = Date.now();
2047
- kimiTokens.push({ at: now, n });
2048
- while (kimiTokens.length && now - kimiTokens[0]!.at > 5 * 3_600_000) kimiTokens.shift();
2049
- const used = kimiTokens.reduce((s, t) => s + t.n, 0) / config.budget.kimi_tokens_5h;
2050
- budget.report(peer, [{ id: "tokens", used, resetsAt: kimiTokens[0]!.at + 5 * 3_600_000, source: "kimi usage_update (soft limit)" }]);
2051
- };
2052
2158
  // Claude's numbers arrive through the status line tee `ahub claude` installs (src/cli/statusline-tee.ts).
2053
2159
  let claudeUsageSeen = 0;
2160
+ let bandsRevision = ""; // #481 S7: the board revision and band configuration the last band evaluation that ran saw
2161
+ let bandEvaluations = 0; // evaluations that ran (not skipped for an unchanged board); read by tests through the daemon
2054
2162
  const claudeContextSeen = new Map<string, string>();
2055
2163
  const contextTailSeen = new Map<PeerId, string>();
2056
2164
  const retainedCliAudits = new Set<string>();
@@ -2077,7 +2185,7 @@ export async function startDaemon(opts: DaemonOptions) {
2077
2185
  try { tasks.flushPlannerNotices(); } catch { /* the board remains authoritative if notice delivery fails */ }
2078
2186
  for (const pending of checkpointWaits.values()) if (!pending.valid()) pending.done(undefined);
2079
2187
  for (const [id, request] of permissions) if (request.expiresAt > Date.now()) supervision.approvalWaiting({ id, peer: request.peer, tool: request.tool ?? "unknown", createdAt: request.createdAt });
2080
- for (const row of bus.queueList()) if (row.state === "needs_review") supervision.needsReview(row.peer, row.id);
2188
+ for (const row of bus.queueList(undefined, true)) if (row.state === "needs_review") supervision.needsReview(row.peer, row.id);
2081
2189
  feedBudgetPauses();
2082
2190
  supervision.checkRound();
2083
2191
  try {
@@ -2109,6 +2217,21 @@ export async function startDaemon(opts: DaemonOptions) {
2109
2217
  }, 1000),
2110
2218
  setInterval(() => budget.tick(), 30_000),
2111
2219
  setInterval(pollUsageRollup, 10_000),
2220
+ // #481 S7: band tiers are evaluated every five minutes, and only when the board changed since the last evaluation (the status
2221
+ // revision moves on every task write), so an idle board costs one integer read. Held while stopping or recovering, like the task sweep.
2222
+ // ponytail: one evaluation reads every task with its history (a few ms per hundred tasks); bound it by a history window if a board passes several thousand tasks.
2223
+ setInterval(() => {
2224
+ if (stopping || recoveryActive()) return;
2225
+ try {
2226
+ const state = tasks.bandsState();
2227
+ if (!state.ready || opts.bandsHeld?.()) return; // held, mode off or no bands: nothing is consumed, so the breach is still there when it clears
2228
+ const revision = `${board.statusRevision()}:${state.signature}`;
2229
+ if (revision === bandsRevision) return;
2230
+ bandEvaluations++;
2231
+ tasks.proposeFromBands();
2232
+ bandsRevision = `${board.statusRevision()}:${state.signature}`; // after the write: the cards this pass made are not a reason to run again
2233
+ } catch (error) { log(`sdlc band evaluation failed: ${sanitize((error as Error).message).slice(0, 160)}`); }
2234
+ }, opts.bandsEveryMs ?? 300_000),
2112
2235
  setInterval(() => {
2113
2236
  if (!ready || !memoryBinding) return;
2114
2237
  const before = memoryProblem;
@@ -2234,7 +2357,7 @@ export async function startDaemon(opts: DaemonOptions) {
2234
2357
  }
2235
2358
  // Inside a PII turn the worker's words may carry the PII whatever they are attached to: a note would go to
2236
2359
  // claude-mem (a cloud observer) and a new task could be routed to a cloud peer without matching any pattern.
2237
- 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`);
2360
+ 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_sdlc_metrics" || 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`);
2238
2361
  // The same words attached to an ordinary task would reach its reviewer, its owner, overlapping owners (a plan, the
2239
2362
  // completed-change notice) and claude-mem. A turn that holds a PII task acts on ordinary tasks in a turn of its own.
2240
2363
  if (piiTurn && (op === "hub_task_done" || op === "hub_review" || (op === "hub_task_accept" && (a.plan != null || a.refs != null)))) {
@@ -2364,6 +2487,8 @@ export async function startDaemon(opts: DaemonOptions) {
2364
2487
  return JSON.stringify(await tasks.groomingDigest(by));
2365
2488
  case "hub_sdlc_costs":
2366
2489
  return JSON.stringify(await tasks.costReport(by, a.repo));
2490
+ case "hub_sdlc_metrics":
2491
+ return JSON.stringify(tasks.sdlcMetrics(by, a.since));
2367
2492
  case "hub_plan_review": {
2368
2493
  const { id, ...review } = a;
2369
2494
  return JSON.stringify(tasks.publicView(await tasks.recordPlanReview(by, id, review)));
@@ -2384,10 +2509,11 @@ export async function startDaemon(opts: DaemonOptions) {
2384
2509
  return JSON.stringify(tasks.publicView(await tasks.undoPlanning(by, a.id, a.revision)));
2385
2510
  case "hub_task_propose": {
2386
2511
  const t = await tasks.propose(by, a);
2387
- if (t.state === "backlog") return line(t);
2512
+ const repoHint = tasks.refsRepoHint(t); // #723: advice only; never part of the write
2513
+ if (t.state === "backlog") return [line(t), repoHint].filter(Boolean).join("\n");
2388
2514
  const overlap = tasks.overlaps(t, t.owner === by);
2389
2515
  if (t.owner === by && tasks.silentFor(t.id) && factsOn()) factsFor(by).sawPlans(by, tasks.overlapTasks(t)); // as for an accept
2390
- return [line(t), tasks.namedOwnerNote(t, a.owner as PeerId | undefined, by), overlap, tasks.claimRoleGuidance(t, by), t.owner === by ? tasks.planGuardText(t) : ""].filter(Boolean).join("\n");
2516
+ return [line(t), tasks.namedOwnerNote(t, a.owner as PeerId | undefined, by), overlap, tasks.claimRoleGuidance(t, by), t.owner === by ? tasks.planGuardText(t) : "", repoHint].filter(Boolean).join("\n");
2391
2517
  }
2392
2518
  case "hub_task_accept": {
2393
2519
  // S1 spec acceptance and S9 plan review are checked against one task snapshot before acceptance.
@@ -2409,7 +2535,7 @@ export async function startDaemon(opts: DaemonOptions) {
2409
2535
  case "hub_task_decline":
2410
2536
  return line(await tasks.decline(by, a.id, a.reason, a.capacity === true));
2411
2537
  case "hub_task_done": {
2412
- const t = await tasks.done(by, a.id, a.summary, a.refs);
2538
+ const t = await tasks.done(by, a.id, a.summary, a.refs, a.deliverables);
2413
2539
  const completion = t.history.findLast(h => h.by === by && (h.event === "done" || h.event === "done (checking)"));
2414
2540
  if (!piiTurn && !tasks.isPii(t) && completion && !completion.withheld && typeof a.summary === "string" && completion.note?.startsWith(a.summary)) {
2415
2541
  citeMemory(by, a.summary, `summary:${t.id}:${t.history.indexOf(completion)}`, [newEnvelope(by, "", { refs: { task: String(t.id) } })]);
@@ -2423,7 +2549,7 @@ export async function startDaemon(opts: DaemonOptions) {
2423
2549
  }
2424
2550
  case "hub_sdlc_record":
2425
2551
  await tasks.specStageGate(by, a.id, a.receipt); // #481 S1: a live spec edit-time check before a build stage
2426
- return line(tasks.recordLifecycle(by, a.id, a.receipt));
2552
+ return line(await tasks.recordSdlc(by, a.id, a.receipt));
2427
2553
  case "hub_review":
2428
2554
  return line(await tasks.review(by, a.id, a.verdict, a.note, a.unmet, a.head, a.coverage, a.findings));
2429
2555
  case "hub_remember":
@@ -2466,7 +2592,8 @@ export async function startDaemon(opts: DaemonOptions) {
2466
2592
  const task = board.get(Number(a.id));
2467
2593
  if (task?.state === "backlog") throw new PreEffectToolRefusal("backlog is read through planner tools");
2468
2594
  if (!task) return JSON.stringify(`no task #${a.id}`);
2469
- return JSON.stringify({ ...reviewerView(by, publicConductorTask(task, (t) => tasks.publicView(t, true))), ...briefField(task), flowRevision: task.history.length });
2595
+ const peerDeliverables = await tasks.deliverableStatus(task);
2596
+ return JSON.stringify({ ...reviewerView(by, publicConductorTask(task, (t) => tasks.publicView(t, true))), ...briefField(task), ...(peerDeliverables ? { deliverables: peerDeliverables } : {}), flowRevision: task.history.length });
2470
2597
  }
2471
2598
  if (op === "route_explain" && by !== USER) {
2472
2599
  if (a.id !== undefined && board.get(Number(a.id))?.state === "backlog") throw new PreEffectToolRefusal("backlog is read through planner tools");
@@ -2502,7 +2629,8 @@ export async function startDaemon(opts: DaemonOptions) {
2502
2629
  if (!task) return JSON.stringify(`no task #${a.id}`);
2503
2630
  const { codeSymbols: _storedCodeSymbols, ...detail } = task;
2504
2631
  const codeSymbols = tasks.publicView(task).codeSymbols;
2505
- return JSON.stringify({ ...detail, ...(codeSymbols ? { codeSymbols } : {}), symbolOverlaps: tasks.symbolOverlaps(task), flowRevision: task.history.length, reviews: board.reviews({ task: task.id }) }, null, 2);
2632
+ const consoleDeliverables = await tasks.deliverableStatus(task);
2633
+ return JSON.stringify({ ...detail, ...(codeSymbols ? { codeSymbols } : {}), ...(consoleDeliverables ? { deliverables: consoleDeliverables } : {}), symbolOverlaps: tasks.symbolOverlaps(task), flowRevision: task.history.length, reviews: board.reviews({ task: task.id }) }, null, 2);
2506
2634
  }
2507
2635
  case "task_assign":
2508
2636
  case "task_assign_checked": {
@@ -2587,6 +2715,30 @@ export async function startDaemon(opts: DaemonOptions) {
2587
2715
  }
2588
2716
  };
2589
2717
  const actionBudgets = new ActionBudgets(() => config.action_policy, contextSession);
2718
+ // #636 slice 2: the always-ask gate of an ACP peer, built per peer from the host's own ports (root, task, PII screen); never from the agent.
2719
+ const acpActionGate = (peer: PeerId) => actionGate({ budgets: actionBudgets, policy: () => config.action_policy, root: executionRoot,
2720
+ task: p => board.list("in_progress").find(t => t.owner === p), publicView: task => tasks.publicView(task), isPrivate: p => !!turns.get(p)?.private || contextPii(p),
2721
+ nameable: text => tasks.nameable(text) }, peer);
2722
+ // A peer is enforceable only where the hub sees the call before it runs, in the mode it runs in now; anything else says why, never a silent claim.
2723
+ // Claude: the launcher installed the PreToolUse hook (an `--unattended` launch skips every permission check, hooks included). ACP: a hub-owned
2724
+ // adapter with the gate, and only while its native mode is `ask`: Kimi's `yolo` and `auto` modes send no request for a call they allow.
2725
+ const actionEnforcement = (peer: PeerId): { enforced: boolean; reason?: string } => {
2726
+ if (definitionOf(peer)?.transport === "claude-channel") {
2727
+ const launch = claudePermissionLaunch(peer), attached = bus.peers.get(peer);
2728
+ // The launch record outlives the process, so it proves nothing alone: the attached session must be the generation whose hook last spoke to the hub.
2729
+ if (!launch) return { enforced: false, reason: "no hub permission hook in this Claude session (start it with ahub claude)" };
2730
+ if (launch.unattended) return { enforced: false, reason: "this Claude launch is --unattended: its permission checks, hooks included, are off" };
2731
+ if (!isWsPeer(attached) || attached.state === "offline" || claudePermissionGenerations.get(peer) !== attached.sessionGeneration)
2732
+ return { enforced: false, reason: "no live Claude session has run the hub permission hook yet" };
2733
+ return { enforced: true };
2734
+ }
2735
+ const owner = bus.peers.get(peer);
2736
+ if (isAcpPeer(owner) && owner.actionGated) {
2737
+ const mode = owner.permissionModeState;
2738
+ return mode === "ask" ? { enforced: true } : { enforced: false, reason: mode === "unknown" || mode === "unmanaged" ? "its native approval mode is not managed by the hub, so a request may never be sent" : `its native mode ${mode} sends no request for a call it allows` };
2739
+ }
2740
+ return { enforced: false };
2741
+ };
2590
2742
  const permissionMode = (peer: string): PermissionMode => permissionModes.get(peer) ?? "ask";
2591
2743
  const permissionModeSources = new Map(Object.entries(defaultSources).map(([peer, value]) => [peer, value.source]));
2592
2744
  const unattendedPeers = new Set<string>();
@@ -2606,8 +2758,10 @@ export async function startDaemon(opts: DaemonOptions) {
2606
2758
  owner: row => {
2607
2759
  const owner = bus.peers.get(row.peer), proof = owner?.terminalOwner?.(row.launchId);
2608
2760
  if (!proof) return undefined;
2761
+ const terminalRootCurrent = () => !declarations[row.peer] || definitionOf(row.peer)?.transport !== "claude-channel" || !config.roles[row.peer]?.includes("implementer") || (ownerWriteRoot(row.peer) === row.executionCwd && row.executionCwd !== opts.cwd);
2762
+ if (!terminalRootCurrent()) return undefined;
2609
2763
  return { ...proof, isCurrent: () => {
2610
- if (stopping || !membershipAllowed(row.peer) || bus.peers.get(row.peer) !== owner) return false;
2764
+ if (stopping || !membershipAllowed(row.peer) || bus.peers.get(row.peer) !== owner || !terminalRootCurrent()) return false;
2611
2765
  const current = owner?.terminalOwner?.(row.launchId);
2612
2766
  return current?.generation === proof.generation && current?.nativePid === proof.nativePid;
2613
2767
  } };
@@ -2680,8 +2834,8 @@ export async function startDaemon(opts: DaemonOptions) {
2680
2834
  if (value.instanceId !== instanceId) return {};
2681
2835
  let currentLauncher = false;
2682
2836
  try {
2683
- const launch = JSON.parse(readFileSync(join(opts.stateDir, claudeStateFile("claude-launch", peer)), "utf8"));
2684
- if (launch.instanceId === instanceId && typeof launch.launchId === "string" && launch.launchId) {
2837
+ const launch = readClaudeLaunchProof(opts.stateDir, peer, instanceId);
2838
+ if (launch) {
2685
2839
  currentLauncher = true;
2686
2840
  if (value.launchId !== launch.launchId) return {};
2687
2841
  }
@@ -2780,7 +2934,7 @@ export async function startDaemon(opts: DaemonOptions) {
2780
2934
  // filled one stays in the row and never matches.
2781
2935
  const ADDED_COLUMNS = [["deps", "[]"], ["plan", "{}"]] as const;
2782
2936
  const integrity = (older = 0) => {
2783
- const queues = Object.fromEntries(Object.keys(bus.snapshot().queues).sort().map((id) => [id, bus.queueIds(id)]));
2937
+ const queues = Object.fromEntries(Object.keys(bus.snapshot(false).queues).sort().map((id) => [id, bus.queueIds(id)]));
2784
2938
  const boardState = board.list().sort((a, b) => a.id - b.id).map((t) => {
2785
2939
  const row: Record<string, unknown> = { ...t };
2786
2940
  for (const [col, empty] of ADDED_COLUMNS.slice(0, older)) if (JSON.stringify(row[col]) === empty) delete row[col];
@@ -2859,7 +3013,7 @@ export async function startDaemon(opts: DaemonOptions) {
2859
3013
  instanceId,
2860
3014
  version: VERSION,
2861
3015
  pluginBundle: SERVER_JS,
2862
- pendingRestart,
3016
+ pendingRestart: allPendingRestart(),
2863
3017
  retainedConfig,
2864
3018
  ...(() => { const warning = mainCheckoutWarning(opts.cwd, tasks.publicMetadataText, checkoutBranch); return warning ? { mainCheckout: warning } : {}; })(),
2865
3019
  startModes: Object.fromEntries([...START_MODE_PEERS, ...configuredIds].map(peer => [peer, startModeOf(config.peers, peer, declarations, adapterCatalog)])),
@@ -2878,6 +3032,8 @@ export async function startDaemon(opts: DaemonOptions) {
2878
3032
  readOnlyPeers: extraIds.filter(readOnlyPeer),
2879
3033
  // #450 I3: the instance ids this daemon bound at startup; a launcher selects only one of these.
2880
3034
  activeInstances: extraIds,
3035
+ ownerIsolation: Object.fromEntries(extraIds.map(id => [id, !!ownerWriteRoot(id) && ownerWriteRoot(id) !== opts.cwd])),
3036
+ ownerIsolationNotes: Object.fromEntries(extraIds.filter(id => definitionOf(id)?.transport === "claude-channel" && !ownerWriteRoot(id)).map(id => [id, "terminal execution root not verified; owner admission refused"])),
2881
3037
  stopping,
2882
3038
  pid: process.pid,
2883
3039
  cwd: opts.cwd,
@@ -2885,11 +3041,11 @@ export async function startDaemon(opts: DaemonOptions) {
2885
3041
  ...(dashboard ? { uiOrigin: dashboard.origin } : {}),
2886
3042
  codexProxyPort: opts.codexProxyPort,
2887
3043
  peers: Object.fromEntries(
2888
- peerIds.map((id) => { const p = bus.peers.get(id); const summary = bus.queueSummary(id); const context = contexts.view(id, contextSession(id), !!p && ["idle", "busy", "paused"].includes(bus.stateOf(id))); return [id, { state: bus.stateOf(id), ...(!membershipAllowed(id) ? { rostered: false, rosterNote: "off roster" } : {}), ...permissionStatus(id, p), ...(isCodexPeer(p) && p.statusNotice ? { statusNotice: p.statusNotice } : {}), ...(context.source !== null || context.measuredAt !== null ? { context } : {}), queued: bus.queued(id), ...(p ? {} : { attached: false }), ...(summary.needsReview ? { needsReview: summary.needsReview } : {}), ...(summary.liveAccepted ? { liveAccepted: summary.liveAccepted, settlementNote: "awaiting adapter completion (Claude: correlated reply or hub_delivery_done); task state is independent" } : {}), ...queueHoldStatus(id, summary.heldBy), ...(summary.oldestQueuedAt !== undefined ? { oldestQueuedAt: summary.oldestQueuedAt } : {}), ...(bus.queuedImportant(id) ? { queuedImportant: bus.queuedImportant(id) } : {}), ...importantWaitingStatus(id, p), ...pausedNote(id), ...jcodeModelSource(id), ...(isLocalPeer(p) && p.lastServedBy ? { servedBy: p.lastServedBy } : {}), ...(isWsPeer(p) && p.claiming ? { claiming: true } : {}), ...(isWsPeer(p) && p.pullOnly ? { pullOnly: true, toolsOnly: definitionOf(id)?.attach?.pullOnlyNotice ?? "tools-only: messages wait for hub_inbox; for pushes restart Claude with ahub claude", lastInboxAt: p.lastInboxAt ?? null } : {}), ...(isPiPeer(p) ? { requestedModel: p.getRequestedModel(), backends: modelRelays.get(id)?.status().backends ?? [] } : {}) }]; }),
3044
+ peerIds.map((id) => { const p = bus.peers.get(id); const summary = bus.queueSummary(id); const context = contexts.view(id, contextSession(id), !!p && ["idle", "busy", "paused"].includes(bus.stateOf(id))); return [id, { state: bus.stateOf(id), ...(!membershipAllowed(id) ? { rostered: false, rosterNote: "off roster" } : {}), ...permissionStatus(id, p), ...(isCodexPeer(p) && p.statusNotice ? { statusNotice: p.statusNotice } : {}), ...(context.source !== null || context.measuredAt !== null ? { context } : {}), queued: bus.queued(id), ...(p ? {} : { attached: false }), ...(declarations[id] && config.roles[id]?.includes("implementer") && definitionOf(id)?.transport === "claude-channel" ? { ownerIsolation: !!ownerWriteRoot(id) && ownerWriteRoot(id) !== opts.cwd, ...(!ownerWriteRoot(id) ? { ownerIsolationNote: "terminal execution root not verified; owner admission refused" } : {}) } : {}), ...(summary.needsReview ? { needsReview: summary.needsReview } : {}), ...(summary.liveAccepted ? { liveAccepted: summary.liveAccepted, settlementNote: "awaiting adapter completion (Claude: correlated reply or hub_delivery_done); task state is independent" } : {}), ...queueHoldStatus(id, summary.heldBy), ...(summary.oldestQueuedAt !== undefined ? { oldestQueuedAt: summary.oldestQueuedAt } : {}), ...(bus.queuedImportant(id) ? { queuedImportant: bus.queuedImportant(id) } : {}), ...importantWaitingStatus(id, p), ...pausedNote(id), ...(reviewerUnavailable.has(id) ? { reviewerUnavailable: reviewerUnavailable.get(id) } : {}), ...jcodeModelSource(id), ...(isLocalPeer(p) && p.lastServedBy ? { servedBy: p.lastServedBy } : {}), ...(isWsPeer(p) && p.claiming ? { claiming: true } : {}), ...(isWsPeer(p) && p.pullOnly ? { pullOnly: true, toolsOnly: definitionOf(id)?.attach?.pullOnlyNotice ?? "tools-only: messages wait for hub_inbox; for pushes restart Claude with ahub claude", lastInboxAt: p.lastInboxAt ?? null } : {}), ...(isPiPeer(p) ? { requestedModel: p.getRequestedModel(), backends: modelRelays.get(id)?.status().backends ?? [] } : {}) }]; }),
2889
3045
  ),
2890
3046
  taskWip: Object.fromEntries(peerIds.map(id => [id, { wip: tasks.wip(id), wipLimit: tasks.wipLimit(id) }])),
2891
3047
  boardReadCapabilities: { releaseFilter: true, columnMetadata: true },
2892
- actionPolicyInfo: Object.fromEntries(peerIds.map(id => [id, actionBudgets.status(id, false)])),
3048
+ actionPolicyInfo: Object.fromEntries(peerIds.map(id => { const e = actionEnforcement(id); return [id, actionBudgets.status(id, e.enforced, e.reason)]; })),
2893
3049
  roleInfo: Object.fromEntries(peerIds.map(id => [id, { flowRoles: config.roles[id] ?? [], catalogRoles: roleState(id), ...(config.roles[id]?.includes("planner") ? { planner: plannerSource } : {}) }])),
2894
3050
  planner: { peers: plannerPeers(), source: plannerSource },
2895
3051
  ...(pendingPermissionDefaults.size ? { permissionDefaults: [...pendingPermissionDefaults].map(([peer, source]) => ({ peer, mode: "never-ask", source })) } : {}),
@@ -2943,7 +3099,7 @@ export async function startDaemon(opts: DaemonOptions) {
2943
3099
  // A queue change is not a bus event: without this, status.json reports the queue as it was before the last
2944
3100
  // message and keeps a phantom `queued N` after the queue drains (measured on a live 0.6.3 hub).
2945
3101
  function heldRows(peer: PeerId) {
2946
- return bus.queueList(peer).filter(row => row.state === "needs_review").map(row => ({
3102
+ return bus.queueList(peer, true).filter(row => row.state === "needs_review").map(row => ({
2947
3103
  id: row.id, senders: [...new Set(row.originals.map(env => sanitize(env.from)))],
2948
3104
  kinds: [...new Set(row.originals.map(env => env.kind))], createdAt: row.createdAt,
2949
3105
  context: deliveryTaskContext(row.originals, board, peer),
@@ -2959,7 +3115,7 @@ export async function startDaemon(opts: DaemonOptions) {
2959
3115
  if (settlingSuperseded || stopping || recoveryActive() || bus.isRecoveryHeld) return;
2960
3116
  settlingSuperseded = true;
2961
3117
  try {
2962
- for (const row of bus.queueList()) {
3118
+ for (const row of bus.queueList(undefined, true)) {
2963
3119
  if (row.state !== "needs_review" || row.reason?.startsWith("requested stop:")) continue;
2964
3120
  const reason = supersededDeliveryReason(row.originals, board, row.peer);
2965
3121
  if (!reason) continue;
@@ -3260,7 +3416,9 @@ export async function startDaemon(opts: DaemonOptions) {
3260
3416
  const preparedRoles = preparedSessionRoleIdentities.get(e.peer)?.map(role => ({ ...role })) ?? null;
3261
3417
  turns.set(e.peer, { id, capture, start: Date.now(), tokens: 0, writeTasks, branches, writeScope, deliveryTask: delivery, attribution, preparedRoles, ...(start ? { tree: start.tree, head: start.head, commit: start.commit, reflog: start.reflog, snapshotMs: start.ms } : {}), ...(pii ? { private: true } : {}) });
3262
3418
  event({ type: "turn_start", peer: e.peer, turn: id });
3419
+ if (softTokenPeer(e.peer)) try { kimiUsage.turnStarted(e.peer); } catch { /* counting never costs a turn */ }
3263
3420
  } else if (!busy && open) {
3421
+ collectKimiUsage(e.peer); // before the turn closes: its rows count toward the turn's tokens
3264
3422
  turns.delete(e.peer);
3265
3423
  let afterTurn: (() => void) | undefined;
3266
3424
  let files: number | undefined;
@@ -3348,6 +3506,10 @@ export async function startDaemon(opts: DaemonOptions) {
3348
3506
  try { tasks.recordOwnerTurn(e.peer, open.writeTasks, changed, !!open.taskEffect || !!open.effectsPending); } catch { /* advisory cannot fail native completion */ }
3349
3507
  }
3350
3508
  }
3509
+ if (e.state === "offline" && softTokenPeer(e.peer)) { collectKimiUsage(e.peer); kimiUsage.drop(e.peer); }
3510
+ // #636: a class-budget allowance belongs to the owner that earned it. A stopped, crashed or removed owner that later resumes the same native
3511
+ // session id starts from zero (a replaced adapter's own `offline` is muted, so a new owner also revokes where it is created).
3512
+ if (e.state === "offline") actionBudgets.revoke(e.peer);
3351
3513
  if (e.state === "offline") { offlineSince.set(e.peer, offlineSince.get(e.peer) ?? Date.now()); supervision.peerOffline(e.peer); supervisionTurns.delete(e.peer); }
3352
3514
  else offlineSince.delete(e.peer);
3353
3515
  // A session that ended may come back without hooks: its context path is verified again or not at all (issue #108).
@@ -3434,7 +3596,8 @@ export async function startDaemon(opts: DaemonOptions) {
3434
3596
  catch { try { log(`permission observer failed for ${req.peer}`); } catch { /* settlement is authoritative */ } }
3435
3597
  };
3436
3598
  if (stopping || signal?.aborted) { observed("aborted"); return { outcome: "cancelled" }; }
3437
- if (opts.unattended) { observed("automatic"); return { outcome: "answered", optionId: req.options.find((o) => o.kind === "allow_once")?.optionId }; }
3599
+ // Always-ask (#636): a classed call is never answered for the person, `--unattended` included; it waits for a person and expires as a denial.
3600
+ if (opts.unattended && !req.classed) { observed("automatic"); return { outcome: "answered", optionId: req.options.find((o) => o.kind === "allow_once")?.optionId }; }
3438
3601
  const id = randomUUID().slice(0, 8);
3439
3602
  // The title is written by an agent and read by the person approving it: escape sequences and carriage returns
3440
3603
  // could repaint the terminal line, so everything but newline and tab is made visible.
@@ -3650,7 +3813,7 @@ export async function startDaemon(opts: DaemonOptions) {
3650
3813
  const owner = bus.peers.get(peer);
3651
3814
  const stopNotice = definitionOf(peer)?.cli.stopNotice?.(isWsPeer(owner) ? "tui" : !owner ? startMode(peer) : "headless", owner);
3652
3815
  if (stopNotice) return { ok: false, error: stopNotice };
3653
- if (!(isAcpPeer(owner) || isPiPeer(owner) || isLocalPeer(owner) || isReviewerPrintPeer(owner))) return { ok: false, error: "unknown or non-owned peer; run ahub status and choose a hub-owned headless peer" };
3816
+ if (!(isAcpPeer(owner) || isPiPeer(owner) || isLocalPeer(owner) || isReviewerPrintPeer(owner) || definitionOf(peer)?.start.daemonPort === "claude-implementer" && !!owner?.ownedTools)) return { ok: false, error: "unknown or non-owned peer; run ahub status and choose a hub-owned headless peer" };
3654
3817
  if (isPiPeer(owner) && owner.mode !== "headless") return { ok: false, error: "Pi belongs to a native TUI terminal; end it in its terminal" };
3655
3818
  if (owner.state === "offline" && !(isAcpPeer(owner) && owner.pendingOwnedProcess)) return { ok: false, error: `peer is already offline; start it with ${startCommand(peer)} before stopping it` };
3656
3819
  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" };
@@ -3826,9 +3989,11 @@ export async function startDaemon(opts: DaemonOptions) {
3826
3989
  }
3827
3990
  if (adapter?.start.acpCommand) {
3828
3991
  const cmd = adapter.start.acpCommand(config.kimi_cmd, args.model);
3992
+ actionBudgets.revoke(peer); // a new owner never inherits the allowance of the one it replaces, even for the same session id
3829
3993
  const kimi: AcpPeer = adapter!.start.create!({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: config, permission: permissionMode(peer), mcpServers: [], daemon: DAEMON_PORTS["acp"]({
3830
3994
  onActivity: item => activityRings[peer]?.append(item),
3831
3995
  isPrivateTurn: () => !!turns.get(peer)?.private || contextPii(peer),
3996
+ actionGate: acpActionGate(peer),
3832
3997
  cmd, kind: adapter.kind,
3833
3998
  permissionMode: permissionMode(peer),
3834
3999
  ...(args.model ? { launchModel: args.model } : {}),
@@ -3842,7 +4007,6 @@ export async function startDaemon(opts: DaemonOptions) {
3842
4007
  // session's configured MCP servers (issue #138). Exact names only; a stopping hub approves nothing.
3843
4008
  autoApprove: (title) => !stopping && HUB_TOOL_TITLES.has(title),
3844
4009
  log,
3845
- onTokens: (total, session) => onKimiTokens(total, session, peer),
3846
4010
  // Pause synchronously before the adapter requeues a provider-refused prompt (#306).
3847
4011
  onQuotaRefusal: (window) => {
3848
4012
  budget.report(peer, [window], true);
@@ -3853,6 +4017,7 @@ export async function startDaemon(opts: DaemonOptions) {
3853
4017
  // adapter's own session id; report() drops it when that is not the daemon's current session. An invalid
3854
4018
  // shape reports a null reading: unknown, not the previous value (#285).
3855
4019
  onUsageDiagnostic: (observation, sessionId) => {
4020
+ if (observation.source === "usage_update") collectKimiUsage(peer); // phase 2: the update is the cue to read the wire log, never a token total
3856
4021
  if (observation.source !== "usage_update" || observation.shape !== "context-used") return;
3857
4022
  const current = contextSession(peer);
3858
4023
  if (!current) return;
@@ -3878,9 +4043,11 @@ export async function startDaemon(opts: DaemonOptions) {
3878
4043
  userArgs: [], unattended: false, env: {}, nativeCommand: configuredIds.includes(adapter.kind) ? undefined : config.qwen_cmd, model: args.model });
3879
4044
  const mcpServers = [{ name: "agent-hub", command: "bun", args: ["run", SERVER_JS], env: Object.entries(toolEnv(peer, true)).map(([name, value]) => ({ name, value })) }];
3880
4045
  const permission = readOnlyPeer(peer) ? "read-only" as const : permissionMode(peer);
4046
+ actionBudgets.revoke(peer); // a new owner never inherits the allowance of the one it replaces, even for the same session id
3881
4047
  const owner = adapter.start.create({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: undefined, permission, mcpServers, daemon: { acp: {
3882
4048
  onActivity: item => activityRings[peer]?.append(item),
3883
4049
  isPrivateTurn: () => !!turns.get(peer)?.private || contextPii(peer),
4050
+ actionGate: acpActionGate(peer),
3884
4051
  kind: adapter.kind, cmd: [launch.cmd, ...launch.args], permissionMode: permission === "read-only" ? "ask" : permission,
3885
4052
  ...(externalIds.has(adapter.kind) ? { requireOwnedGroup: true, requireLoadSessionEcho: true, nativePolicy: adapter.permission.genericAcp ?? {} } : {}),
3886
4053
  ...(args.model ? { launchModel: args.model } : {}), ...(args.sessionId ? { resumeSessionId: args.sessionId } : {}),
@@ -4230,6 +4397,51 @@ export async function startDaemon(opts: DaemonOptions) {
4230
4397
  await local.start();
4231
4398
  return { ok: true, model: route ? `${route} (fallback ${routing.local.fixed_model})` : (args.model ?? routing.local.fixed_model) };
4232
4399
  }
4400
+ if (adapter?.start.daemonPort === "claude-implementer") {
4401
+ if (args.unattended) return { ok: false, error: "Claude implementer refuses unattended permission bypass" };
4402
+ const currentRoot = () => {
4403
+ const roles = config.roles[peer] ?? [];
4404
+ return !stopping && !recoveryActive() && membershipAllowed(peer) && !contextPii(peer) && roles.includes("implementer") && roles.every(role => role === "implementer" || role === "verifier") && permissionMode(peer) !== "never-ask" && resolveInstanceExecutionCwd(opts.cwd, peer, declarations, roles, adapterCatalog) === executionCwd && executionCwd !== opts.cwd;
4405
+ };
4406
+ try { if (!declarations[peer]?.worktree || !currentRoot()) return { ok: false, error: "Claude implementer requires an isolated worktree and ask permissions" }; }
4407
+ catch { return { ok: false, error: "Claude implementer execution root unavailable" }; }
4408
+ if (existing?.state === "busy") return { ok: false, error: "Claude implementer is busy; cleanup must finish before restart" };
4409
+ if (existing) { mute(existing); await existing.stop(); if (!currentRoot()) throw new Error("Claude implementer startup admission changed"); }
4410
+ const selected = permissionMode(peer);
4411
+ if (selected !== "ask" && selected !== "ask-when-needed") return { ok: false, error: "Claude implementer refuses never-ask" };
4412
+ const implementer = adapter.start.create!({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: config, permission: selected, mcpServers: [], daemon: DAEMON_PORTS["claude-implementer"]({
4413
+ cwd: executionCwd, stateDir: opts.stateDir, projectRoot: opts.cwd, instanceId, server: SERVER_JS,
4414
+ factsHook: join(import.meta.dir, "..", "cli", "facts-hook.ts"), systemPrompt: sessionRoleContract(peer), mode: selected, watchdogMs: config.watchdog_ms,
4415
+ admit: env => {
4416
+ try {
4417
+ if (env.private || !currentRoot() || bus.peers.get(peer) !== implementer) return false;
4418
+ if (env.refs?.task === undefined) return true;
4419
+ const task = board.get(Number(env.refs.task));
4420
+ return !!task && task.owner === peer && !tasks.isPii(task) && ["proposed", "in_progress", "changes_requested"].includes(task.state);
4421
+ } catch { return false; }
4422
+ },
4423
+ chatTask: () => implementerChatTask(peer),
4424
+ transcriptPersisted: (task, session) => {
4425
+ const path = implementerTranscripts.get(`${peer}:${task}:${session}`);
4426
+ return path ? pathEvidence(path, lstatSync) : "unknown";
4427
+ },
4428
+ onUsage: (usage, task) => {
4429
+ if (bus.peers.get(peer) !== implementer) return;
4430
+ const normalized = normalizeUsage(usage), total = claudeReportedTokens(normalized);
4431
+ if (total !== undefined) addTokens(peer, total, task);
4432
+ event({ type: "usage", peer, source: "claude_result", id: `${runId}:${peer}:${++codexUsageSeq}`, ...normalized,
4433
+ ...(total !== undefined ? { totalTokens: total } : {}), ...taskAttribution(peer, task) });
4434
+ }, notify,
4435
+ }) });
4436
+ await implementer.start();
4437
+ try {
4438
+ if (!currentRoot()) throw new Error("Claude implementer startup admission changed");
4439
+ recoveryTaskPreface(peer); await ensurePreface(peer);
4440
+ if (!currentRoot()) throw new Error("Claude implementer startup admission changed");
4441
+ executionOwners.set(peer, { owner: implementer, cwd: executionCwd }); bus.add(implementer);
4442
+ return { ok: true };
4443
+ } catch (error) { await implementer.stop().catch(() => {}); throw error; }
4444
+ }
4233
4445
  if (adapter?.reviewer) {
4234
4446
  const backend = adapter.reviewer.backend;
4235
4447
  if (!backend || !adapter.start.create) return { ok: false, error: "reviewer has no verified runtime" };
@@ -4262,7 +4474,12 @@ export async function startDaemon(opts: DaemonOptions) {
4262
4474
  onUnavailable: (failure, current) => {
4263
4475
  const attached = () => current() && bus.peers.get(peer) === reviewer;
4264
4476
  if (!attached()) return;
4265
- if (failure.cause === "start_failed") reviewerUnavailable.set(peer, "reviewer isolation unavailable; restart after checking the verified CLI and isolation requirements");
4477
+ if (failure.cause === "start_failed") {
4478
+ // #722: the class (a closed list) is the only detail; never a path, native text or token.
4479
+ const reason = reviewerUnavailableReason(failure.causeClass ?? "unknown", failure.causeDetail);
4480
+ log(`reviewer ${peer} unavailable for task #${failure.task}: ${sanitize(reason).slice(0, 300)}`);
4481
+ reviewerUnavailable.set(peer, sanitize(reason).slice(0, 1000));
4482
+ }
4266
4483
  return tasks.retryReviewer(failure.task, peer, failure, attached).then(() => undefined);
4267
4484
  },
4268
4485
  log,
@@ -4272,7 +4489,8 @@ export async function startDaemon(opts: DaemonOptions) {
4272
4489
  await reviewer.stop().catch(() => {});
4273
4490
  // #691: a version refusal names only versions and the trust command, so it is shown for every backend.
4274
4491
  const reason = error instanceof Error && (backend.nativePolicy || error.message.startsWith(VERSION_REFUSAL)) ? sanitize(error.message).slice(0, 1000)
4275
- : "reviewer isolation unavailable; inspect the verified CLI version and isolation requirements";
4492
+ : reviewerUnavailableReason(error instanceof ReviewerUnavailableError ? error.causeClass : "unknown", error instanceof ReviewerUnavailableError ? error.detail : undefined);
4493
+ log(`start ${peer} failed [cause: ${error instanceof ReviewerUnavailableError ? error.causeClass : "unknown"}]`);
4276
4494
  reviewerUnavailable.set(peer, reason);
4277
4495
  return { ok: false, error: reason };
4278
4496
  }
@@ -4432,7 +4650,7 @@ export async function startDaemon(opts: DaemonOptions) {
4432
4650
  const text = (v: unknown, max = 200) => typeof v === "string" && v.trim() && v.length <= max ? v.trim() : undefined;
4433
4651
  const tier = q.tier === "short" || q.tier === "long" || q.tier === "goals" ? q.tier : "long";
4434
4652
  const kind = typeof q.kind === "string" && (["decision", "finding", "contract", "fail"] as string[]).includes(q.kind) ? q.kind as MemoryView["kind"] : undefined;
4435
- const peer = text(q.peer, 32), machine = text(q.machine, 32), search = text(q.q), node = text(q.node), since = [text(q.since, 40)].find((v) => v !== undefined && !Number.isNaN(Date.parse(v))); // an unreadable since filters nothing
4653
+ const peer = text(q.peer, 32), machine = text(q.machine, 32), search = text(q.q), node = text(q.node, typeof q.node === "string" && q.node.startsWith("symbol:") ? 512 : 200), since = [text(q.since, 40)].find((v) => v !== undefined && !Number.isNaN(Date.parse(v))); // an unreadable since filters nothing
4436
4654
  const hops = q.hops === 2 ? 2 : 1;
4437
4655
  const all = memoryStore.list({ project: memoryProject, limit: Number.MAX_SAFE_INTEGER });
4438
4656
  const goals = memoryStore.goals(memoryProject);
@@ -4470,13 +4688,50 @@ export async function startDaemon(opts: DaemonOptions) {
4470
4688
  const attentionStatus = status(false, true);
4471
4689
  let pet; try { const config = loadPetConfig(opts.cwd); pet = { enabled: config.enabled, character: config.character ?? "stai", blink: config.blink }; } catch { pet = { enabled: false }; }
4472
4690
  const peerView = Object.fromEntries(Object.entries(attentionStatus.peers).map(([id, peer]) => [id, { ...peer, ...turnView(id as PeerId) }])), boardTasks = board.list();
4691
+ const usageView = (open: boolean) => {
4692
+ // #683 U4: the roll-up runs only while the Agents view that shows it is open, never on a hidden view's 1 s poll.
4693
+ if (!open) return undefined;
4694
+ try {
4695
+ const caught = usageRollup.catchUp(100);
4696
+ const status = budget.status();
4697
+ const live = new Set(Object.entries(status).filter(([, v]) => v.paused).map(([peer]) => peer));
4698
+ const limits = usageRollup.limits(undefined, { limit: 100, live });
4699
+ const total = usageRollup.total();
4700
+ const daily = usageRollup.daily(14);
4701
+ const taskUsage = usageRollup.query({ by: "task" });
4702
+ return {
4703
+ // `behind`: the roll-up has not folded the whole log, so every figure below is a lower bound, never the whole (unknown is not 0).
4704
+ behind: !caught,
4705
+ daily,
4706
+ limits,
4707
+ total,
4708
+ tasks: taskUsage.rows.map(r => ({
4709
+ id: r.key === "unattributed" ? 0 : Number(r.key.replace(/^#/, "")),
4710
+ pii: r.pii === true,
4711
+ records: r.records,
4712
+ input: r.input,
4713
+ output: r.output,
4714
+ cacheRead: r.cacheRead,
4715
+ cacheWrite: r.cacheWrite,
4716
+ reasoning: r.reasoning,
4717
+ total: r.total,
4718
+ counted: r.counted,
4719
+ totalWithoutCache: r.totalWithoutCache,
4720
+ })),
4721
+ };
4722
+ } catch (e) {
4723
+ log(`dashboard usage view failed: ${(e as Error).message}`);
4724
+ // The page keeps the last figures for `undefined` (a hidden view), so a failed read says so explicitly instead.
4725
+ return { unavailable: true };
4726
+ }
4727
+ };
4473
4728
  return {
4474
4729
  ok: true,
4475
4730
  projectId,
4476
4731
  instanceId,
4477
4732
  attention: attentionStatus.attentionInput,
4478
4733
  pet,
4479
- status: { peers: peerView, pendingRestart, retainedConfig, taskWip: attentionStatus.taskWip, roleInfo: attentionStatus.roleInfo },
4734
+ status: { peers: peerView, pendingRestart: allPendingRestart(), retainedConfig, taskWip: attentionStatus.taskWip, roleInfo: attentionStatus.roleInfo },
4480
4735
  boardCapabilities: { backlog: true },
4481
4736
  planningCapabilities: { enabled: true, wipPull: true, split: true },
4482
4737
  releases: tasks.releaseList(USER),
@@ -4489,7 +4744,7 @@ export async function startDaemon(opts: DaemonOptions) {
4489
4744
  tasks: boardTasks.map((task) => {
4490
4745
  const view = tasks.publicView(task);
4491
4746
  // Refs and history can themselves quote private text. The dashboard needs neither.
4492
- return { id: task.id, title: view.title, detail: view.detail, class: task.class, ...(task.profile ? { profile: task.profile, profileClass: task.profileClass } : {}), ...(task.role ? { role: task.role } : {}), state: task.state, owner: task.owner, reviewer: task.reviewer, deps: view.deps ?? [], ...(task.reserved ? { reserved: task.reserved } : {}), planningRevision: task.planningRevision ?? 0, rank: task.rank ?? null, priority: task.priority ?? null, release: task.release ?? null, plannedStart: task.plannedStart ?? null, due: task.due ?? null, pinned: task.pinned === true, actualDates: view.actualDates, ...(view.codeSymbols ? { codeSymbols: view.codeSymbols } : {}), ...(view.cancelled ? { cancelled: view.cancelled } : {}), ...(view.external ? { external: view.external } : {}), started: taskHasStarted(task), urgent: task.signals.includes("urgent"), updated: task.updated, reworkRound: task.history.filter(entry => entry.event === "changes_requested").length, checking: tasks.isChecking(task.id), ...(view.lifecycle ? { lifecycle: view.lifecycle } : {}) };
4747
+ return { id: task.id, title: view.title, detail: view.detail, class: task.class, ...(task.profile ? { profile: task.profile, profileClass: task.profileClass } : {}), ...(task.role ? { role: task.role } : {}), state: task.state, owner: task.owner, reviewer: task.reviewer, deps: view.deps ?? [], ...(task.reserved ? { reserved: task.reserved } : {}), planningRevision: task.planningRevision ?? 0, rank: task.rank ?? null, priority: task.priority ?? null, release: task.release ?? null, plannedStart: task.plannedStart ?? null, due: task.due ?? null, pinned: task.pinned === true, actualDates: view.actualDates, ...(view.codeSymbols ? { codeSymbols: view.codeSymbols } : {}), ...(() => { const group = codeGroup(task, view.codeSymbols, task.plan?.symbols, tasks.publicMetadataText, tasks.isPii(task)); return group ? { codeGroup: group } : {}; })(), ...(view.cancelled ? { cancelled: view.cancelled } : {}), ...(view.external ? { external: view.external } : {}), started: taskHasStarted(task), urgent: task.signals.includes("urgent"), updated: task.updated, reworkRound: task.history.filter(entry => entry.event === "changes_requested").length, checking: tasks.isChecking(task.id), ...(view.lifecycle ? { lifecycle: view.lifecycle } : {}) };
4493
4748
  }),
4494
4749
  links: links.filter(link => link.tasks.every(id => { const task = board.get(id); return task && !tasks.isPii(task); })),
4495
4750
  budget: Object.fromEntries(peers.map((id) => [id, { ...(quota[accountOf(id)] ?? { windows: [] }), context: contexts.view(id, contextSession(id), bus.peers.has(id) && ["idle", "busy", "paused"].includes(bus.stateOf(id))) }])),
@@ -4508,6 +4763,7 @@ export async function startDaemon(opts: DaemonOptions) {
4508
4763
  roster: rosterView(),
4509
4764
  ...benchView(),
4510
4765
  ...memoryView(_input?.memory),
4766
+ usage: usageView(_input?.view === "agents"),
4511
4767
  };
4512
4768
  }
4513
4769
 
@@ -4604,7 +4860,7 @@ export async function startDaemon(opts: DaemonOptions) {
4604
4860
  settledHolderChange = JSON.stringify(oldHolders) !== JSON.stringify(newHolders);
4605
4861
  if (settledHolderChange) effect = `planner ${JSON.stringify(oldHolders.planners)} -> ${JSON.stringify(newHolders.planners)}; conductor ${JSON.stringify(oldHolders.conductors)} -> ${JSON.stringify(newHolders.conductors)}`;
4606
4862
  }
4607
- const input = { key, before, acceptedFallback, settledHolderChange, after: machine && rawChanged ? rawAfter : after, source: route ? "routing" as const : "config" as const, sourceFile: origin?.name, tracked, authority, guard, confirmed: !!scope || confirmed.includes(key) };
4863
+ const input = { key, before, acceptedFallback, settledHolderChange, after: machine && rawChanged ? rawAfter : after, source: route ? "routing" as const : "config" as const, sourceFile: origin?.name, tracked, authority, guard, confirmed: !!scope || confirmed.includes(key), structural: !!scope && structuralScopes.has(scope) };
4608
4864
  const decision = authorizeConfigLeaf(input);
4609
4865
  if (!decision.allowed) {
4610
4866
  const next = `${decision.reason}; review and run ahub settings reload, or revert this key${machine && tracked !== false ? "; move machine-local declarations to an untracked local file first" : ""}`;
@@ -4784,7 +5040,7 @@ export async function startDaemon(opts: DaemonOptions) {
4784
5040
  /** For the dashboard: the rows, and until when this session may raise. A project whose files do not read still gets its dashboard. */
4785
5041
  // ponytail: reads the two config files and the undo record and parses the routing files on each dashboard poll; cache by mtime if a page ever feels it.
4786
5042
  function settingsSnapshot(session?: DashboardSession): { settings?: Record<string, unknown> } {
4787
- try { return { settings: { ...settingsView(), ...(session?.settings ? { sessionUntil: session.settingsUntil } : {}) } }; }
5043
+ try { return { settings: { ...settingsView(), ...(session?.settings ? { sessionUntil: session.settingsUntil } : {}), ...(configEditor ? { configEdit: configEditor.view() } : {}) } }; }
4788
5044
  catch { return {}; }
4789
5045
  }
4790
5046
  function settingsView(): { rows: SettingRow[]; undo?: string; pendingRestart: { key: string; reason: string }[]; retainedConfig: { key: string; reason: string }[] } {
@@ -4806,7 +5062,7 @@ export async function startDaemon(opts: DaemonOptions) {
4806
5062
  });
4807
5063
  const undo = pendingUndo(opts.stateDir);
4808
5064
  const planner = `In force: ${plannerPeers().join(", ") || "nobody"} (${plannerSource}). Setting it makes that peer the only planner; agents cannot change it.`;
4809
- return { pendingRestart, retainedConfig, rows: rows.map(row => row.key === "planner" ? { ...row, hint: planner } : row), ...(undo ? { undo } : {}) };
5065
+ return { pendingRestart: allPendingRestart(), retainedConfig, rows: rows.map(row => row.key === "planner" ? { ...row, hint: planner } : row), ...(undo ? { undo } : {}) };
4810
5066
  }
4811
5067
  /** The same announcement whatever surface made the change: events.jsonl, hub.log and every open console. */
4812
5068
  const recordSetting = (key: string, from: SettingValue, to: SettingValue, source: "dashboard" | "terminal", undo = false, reason?: string) => {
@@ -5044,6 +5300,46 @@ export async function startDaemon(opts: DaemonOptions) {
5044
5300
  return { ...preview, ...identity, lines: [`Undo ${def.key}: put back ${settingText(value)}`, ...(Array.isArray(preview.lines) ? preview.lines : []), "The saved previous machine-local file is revalidated before writing."] };
5045
5301
  }
5046
5302
 
5303
+ // #689: structural config edits (src/hub/config-edit-host.ts). The editor decides authority, the typed confirmation and the audit event;
5304
+ // the daemon supplies the loaders' verdict, the hold and the one scoped reload path. A structural key is allowed through reload authority
5305
+ // only while its own scope is registered here, for the duration of that one reload.
5306
+ configEditor = createConfigEditor({
5307
+ cwd: opts.cwd, stateDir: opts.stateDir,
5308
+ check: (file, text) => {
5309
+ if (file !== "config.local.json") return;
5310
+ const texts = configSourceTexts(opts.cwd);
5311
+ texts["config.local.json"] = text;
5312
+ const sources = parseConfigSources(texts, undefined, opts.cwd, externalAdapters);
5313
+ const candidate = loadConfig(opts.cwd, sources.layers, sources.routing.retired ?? [], externalDefinitions);
5314
+ // Bounds and kinds are the raw-config check parseConfigSources just ran; the effective merge below is loadConfig itself.
5315
+ checkJcodeInstanceHomes(sources.identity.instances, candidate.jcode_home);
5316
+ },
5317
+ held: () => stopping || recoveryActive() ? "recovery or shutdown" : undefined,
5318
+ reload: async (authority, key) => {
5319
+ structuralScopes.add(key);
5320
+ try { return await reloadConfig(authority, key); } finally { structuralScopes.delete(key); }
5321
+ },
5322
+ event: entry => event(entry),
5323
+ notify: text => notify(text),
5324
+ changed: () => writeStatus(),
5325
+ pending: () => pendingRestart,
5326
+ effective: path => valueAt(config, path),
5327
+ runningSdlc: () => ({ mode: lifecycleConfig.mode, requiredChecks: lifecycleConfig.requiredChecks }),
5328
+ // Status and the settings view poll this: read sdlc.toml again only when its stamp changed. ponytail: the stamp is mtime, ctime, inode and size, so an in-place rewrite of the same size inside one filesystem timestamp tick is missed until the next change; it feeds display only (a status line), never an authority. A watcher is the upgrade if a poll ever shows.
5329
+ fileSdlc: () => {
5330
+ const stamp = (() => { try { const info = statSync(join(opts.cwd, ".agenthub", "sdlc.toml")); return `${info.mtimeMs}:${info.ctimeMs}:${info.ino}:${info.size}`; } catch { return "-"; } })();
5331
+ if (sdlcFileCache?.stamp !== stamp) {
5332
+ let value: { mode: string; requiredChecks: readonly string[] } | string;
5333
+ sdlcFileReads++;
5334
+ try { const loaded = loadSdlc(opts.cwd, VERSION); value = { mode: loaded.mode, requiredChecks: loaded.requiredChecks }; } catch (error) { value = sanitize((error as Error).message).slice(0, 300); }
5335
+ sdlcFileCache = { stamp, value };
5336
+ }
5337
+ return sdlcFileCache.value;
5338
+ },
5339
+ declared: () => Object.fromEntries(Object.entries(declarations).map(([id, value]) => [id, { ...value }])),
5340
+ localConfig: () => { try { const text = readEditable(opts.cwd, "config.local.json"); const doc = text === null ? {} : JSON.parse(text); return doc && typeof doc === "object" && !Array.isArray(doc) ? doc : {}; } catch { return {}; } },
5341
+ });
5342
+
5047
5343
  async function uiAction(a: Record<string, unknown>, session?: DashboardSession): Promise<unknown> {
5048
5344
  const bad = { ok: false, error: "invalid or unavailable dashboard action" };
5049
5345
  // #269: a session opened by `ahub ui --settings` may raise authority while its window lasts; any other may not.
@@ -5129,6 +5425,11 @@ export async function startDaemon(opts: DaemonOptions) {
5129
5425
  return a.undo === true ? settingsUndoPreview() : settingsPreview(a.key, a.value);
5130
5426
  case "setting_reload":
5131
5427
  return reloadConfig(authority);
5428
+ case "config_edit_preview":
5429
+ return configEditor!.preview(a.edit, authority);
5430
+ case "config_edit_apply":
5431
+ // A dashboard session is never given the typed confirmation of an executable key: that is the console's, and refused here by the editor.
5432
+ return configEditor!.apply(a.edit, authority, undefined, a.reason);
5132
5433
  default:
5133
5434
  return bad;
5134
5435
  }
@@ -5175,6 +5476,7 @@ export async function startDaemon(opts: DaemonOptions) {
5175
5476
  if (externalIds.size && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("external_adapter_modules"))) return "target lacks external_adapter_modules; verified external definitions cannot be preserved by that daemon";
5176
5477
  if (configuredIds.length && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("configured_acp"))) return "target lacks configured_acp; project adapter definitions cannot be preserved by that daemon";
5177
5478
  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";
5479
+ if (extraIds.some(id => adapterCatalog.definitionOf(declarations[id]!.kind)?.transport === "claude-channel" && declarations[id]!.roles?.includes("implementer")) && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("role_stages"))) return "target lacks role_stages; declared Claude implementer authority cannot be restored by that daemon";
5178
5480
  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";
5179
5481
  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";
5180
5482
  return undefined;
@@ -5296,11 +5598,8 @@ export async function startDaemon(opts: DaemonOptions) {
5296
5598
  }
5297
5599
 
5298
5600
  function claudePermissionLaunch(peer: string): { launchId: string; unattended: boolean; hookPurpose?: string } | undefined {
5299
- try {
5300
- const row = JSON.parse(readFileSync(join(opts.stateDir, claudeStateFile("claude-launch", peer)), "utf8"));
5301
- if (row.instanceId !== instanceId || row.permissionHook !== true || typeof row.launchId !== "string" || !row.launchId) return undefined;
5302
- return { launchId: row.launchId, unattended: row.unattended === true, ...(typeof row.hookPurpose === "string" ? { hookPurpose: row.hookPurpose } : {}) };
5303
- } catch { return undefined; }
5601
+ const row = readClaudeLaunchProof(opts.stateDir, peer, instanceId);
5602
+ return row?.permissionHook ? row : undefined;
5304
5603
  }
5305
5604
  async function changePermission(peer: unknown, mode: unknown, confirmed: unknown): Promise<Record<string, unknown>> {
5306
5605
  if (!isSupportedPeer(peer)) return { ok: false, error: "unknown permission peer" };
@@ -5410,7 +5709,10 @@ export async function startDaemon(opts: DaemonOptions) {
5410
5709
 
5411
5710
  function onMessage(sock: Sock, msg: any): void {
5412
5711
  const c = sock.data;
5413
- const reply = (body: Record<string, unknown>) => { if (sock.readyState === WebSocket.OPEN) sock.send(JSON.stringify({ rid: msg.rid, ...body })); };
5712
+ const reply = (body: Record<string, unknown>) => {
5713
+ if (c.toolsOwner && !currentImplementerTools(c)) body = { ok: false, error: "Claude child generation or execution root changed" };
5714
+ if (sock.readyState === WebSocket.OPEN) sock.send(JSON.stringify({ rid: msg.rid, ...body }));
5715
+ };
5414
5716
  if (!c.authed) {
5415
5717
  if (msg.t !== "hello" || msg.token !== token) return sock.close(4401, "bad token");
5416
5718
  if (msg.v !== PROTOCOL) {
@@ -5431,6 +5733,13 @@ export async function startDaemon(opts: DaemonOptions) {
5431
5733
  c.peer = String(msg.peer ?? "");
5432
5734
  if (!PEER_ID.test(c.peer) || c.peer === USER || c.peer === "hub") return sock.close(4403, "peer id is reserved or malformed");
5433
5735
  if (!membershipAllowed(c.peer)) return sock.close(4000, "peer is off roster; retry when a person adds it");
5736
+ if (definitionOf(c.peer)?.start.daemonPort === "claude-implementer") {
5737
+ const owner = bus.peers.get(c.peer), launch = owner?.ownedTools?.(msg.launchId), root = ownerWriteRoot(c.peer);
5738
+ if (!owner || !launch || launch.signal.aborted || !root || root === opts.cwd || typeof msg.launchId !== "string") return sock.close(4403, "this process is not the current Claude child");
5739
+ c.launchId = msg.launchId;
5740
+ c.toolsOwner = { owner, launchId: launch.launchId, generation: launch.generation, cwd: root };
5741
+ if (!currentImplementerTools(c)) return sock.close(4403, "Claude child admission changed");
5742
+ }
5434
5743
  // #368: a hub-tools socket for an owned Jcode (a project-file MCP entry, which names no peer) is admitted only with the launch id of that
5435
5744
  // peer's current owned launch. A forged AGENTHUB_PEER_ID in some other process has no such id, so it never acts as that peer.
5436
5745
  if (definitionOf(c.peer)?.start.daemonPort === "owned-acp") {
@@ -5447,8 +5756,8 @@ export async function startDaemon(opts: DaemonOptions) {
5447
5756
  if (definitionOf(c.peer)?.transport === "claude-channel" && msg.launchId !== undefined) {
5448
5757
  let currentLaunch: unknown;
5449
5758
  try {
5450
- const launch = JSON.parse(readFileSync(join(opts.stateDir, claudeStateFile("claude-launch", c.peer)), "utf8"));
5451
- if (launch.instanceId === instanceId && typeof launch.launchId === "string" && launch.launchId) currentLaunch = launch.launchId;
5759
+ const launch = readClaudeLaunchProof(opts.stateDir, c.peer, instanceId);
5760
+ if (launch) currentLaunch = launch.launchId;
5452
5761
  } catch { /* No current launch evidence: retain plain/pre-daemon attachment compatibility. */ }
5453
5762
  if (currentLaunch !== undefined && msg.launchId !== currentLaunch) return sock.close(4430, "Claude launch ended; start a new session with ahub claude");
5454
5763
  if (typeof msg.launchId === "string" && msg.launchId.length <= 128) c.launchId = msg.launchId;
@@ -5464,6 +5773,17 @@ export async function startDaemon(opts: DaemonOptions) {
5464
5773
  preparedSessionRoles.delete(c.peer); // an external transport has no receipt for this daemon's native preamble
5465
5774
  preparedSessionRoleIdentities.delete(c.peer);
5466
5775
  ws.claim(sock, msg.channel === false);
5776
+ if (declarations[c.peer] && definitionOf(c.peer)?.transport === "claude-channel") {
5777
+ revokeTerminalExecution(c.peer);
5778
+ const launch = readClaudeLaunchProof(opts.stateDir, c.peer, instanceId);
5779
+ try {
5780
+ const root = resolveInstanceExecutionCwd(opts.cwd, c.peer, declarations, config.roles[c.peer] ?? [], adapterCatalog);
5781
+ if (root !== opts.cwd && launch?.privateRecord && launch.launchId === c.launchId && launch.executionCwd === root && typeof msg.cwd === "string" && realPath(msg.cwd) === root) {
5782
+ executionRoots.set(c.peer, root);
5783
+ executionOwners.set(c.peer, { owner: ws, cwd: root, socket: sock, launchId: launch.launchId, generation: ws.sessionGeneration });
5784
+ }
5785
+ } catch { /* Unknown or changed worktree evidence never grants owner isolation. */ }
5786
+ }
5467
5787
  if (kimiPull) { clearTimeout(kimiAdmissionExpiry); kimiAdmissionExpiry = undefined; c.kimiAdmission = kimiAdmissionId; }
5468
5788
  if (definitionOf(c.peer)?.transport === "claude-channel") logPermissionStart(c.peer);
5469
5789
  writeStatus(); // the claim has to be visible before the preface, or a standing-by session takes the id back
@@ -5478,7 +5798,24 @@ export async function startDaemon(opts: DaemonOptions) {
5478
5798
  }
5479
5799
  if (stopping && msg.t !== "status" && msg.t !== "kill" && !(c.role === "console" && msg.t === "native_hook_launch" && msg.op === "end")) return void reply({ ok: false, error: "hub is stopping" });
5480
5800
  if (c.role !== "console" && c.peer && !membershipAllowed(c.peer)) return void reply({ ok: false, error: `${c.peer} is off roster` });
5801
+ if (c.toolsOwner && !currentImplementerTools(c)) return void reply({ ok: false, error: "Claude child generation or execution root changed" });
5481
5802
  switch (msg.t) {
5803
+ case "permission_request": {
5804
+ const peer = c.peer, launch = currentImplementerTools(c), current = captureImplementerTools(c);
5805
+ if (!peer || !launch || c.role !== "tools" || typeof msg.tool !== "string" || !msg.input || typeof msg.input !== "object" || Array.isArray(msg.input)) return void reply({ ok: false, behavior: "deny", message: "permission unavailable" });
5806
+ const shown = permissionPayload(msg.tool, msg.input, tasks.publicMetadataText);
5807
+ const pending = new AbortController(), abortPending = () => pending.abort();
5808
+ launch.signal.addEventListener("abort", abortPending, { once: true });
5809
+ // Heartbeats keep an approval alive; they grant nothing. Admission and the final
5810
+ // answer still revalidate native root evidence, without synchronous Git on every tick.
5811
+ const heartbeat = setInterval(() => { if (currentImplementerTools(c, false) === launch) c.toolsOwner?.owner.keepApprovalAlive?.(); else pending.abort(); }, Math.max(10, Math.min(1000, Math.floor(config.watchdog_ms / 3)))); heartbeat.unref();
5812
+ c.toolsOwner?.owner.keepApprovalAlive?.();
5813
+ void requestPermission({ peer, title: shown.title, tool: shown.tool, classed: true, options: [{ optionId: "allow", name: "Allow once", kind: "allow_once" }, { optionId: "deny", name: "Deny", kind: "reject_once" }] }, pending.signal).then(result => {
5814
+ const allowed = current() && result.outcome === "answered" && result.optionId === "allow";
5815
+ reply({ ok: true, behavior: allowed ? "allow" : "deny", ...(allowed ? {} : { message: result.outcome === "expired" ? "no person answered" : "permission denied" }) });
5816
+ }, () => reply({ ok: true, behavior: "deny", message: "hub permission unavailable" })).finally(() => { clearInterval(heartbeat); launch.signal.removeEventListener("abort", abortPending); }).catch(() => {});
5817
+ return;
5818
+ }
5482
5819
  case "permission_default": {
5483
5820
  if (typeof msg.peer === "string" && rosterChanging === msg.peer) return void reply({ ok: false, error: `${msg.peer} roster change is in progress` });
5484
5821
  if (c.role !== "console") return void reply({ ok: false, error: "permission default confirmation is a human console action" });
@@ -5554,6 +5891,18 @@ export async function startDaemon(opts: DaemonOptions) {
5554
5891
  return void reply({ ok: true });
5555
5892
  }
5556
5893
  case "inbox": {
5894
+ if (c.toolsOwner) {
5895
+ const peer = c.peer!, current = captureImplementerTools(c), launch = currentImplementerTools(c);
5896
+ try {
5897
+ // Inspect the still-pending batch before pull commits/removes it: private work never reaches this native child.
5898
+ const pending = bus.pendingEnvelopes(peer);
5899
+ if (!current() || pending.some(env => { const task = env.refs?.task === undefined ? undefined : board.get(Number(env.refs.task)); return env.private || contextPii(peer) || !!task && tasks.isPii(task); })) return void reply({ ok: false, error: "private inbox delivery refused" });
5900
+ if (!launch || pending.some(env => (env.refs?.task === undefined ? implementerChatTask(peer) : Number(env.refs.task)) !== launch.task)) return void reply({ ok: false, error: "inbox delivery belongs to another task session" });
5901
+ const envs = bus.pull(peer);
5902
+ if (!envs) return void reply({ ok: false, error: queueHold(peer) ?? "delivery held" });
5903
+ return void reply({ ok: true, envs, waiting: bus.queued(peer), waitingImportant: bus.queuedImportant(peer) });
5904
+ } catch { return void reply({ ok: false, error: "delivery journal unavailable" }); }
5905
+ }
5557
5906
  // The attached session can explicitly claim queued work even during a native turn (#493).
5558
5907
  // Pull and push share holds and journal settlement; work read here is completed, never pushed again.
5559
5908
  const peer = c.peer ? bus.peers.get(c.peer) : undefined;
@@ -5722,6 +6071,21 @@ export async function startDaemon(opts: DaemonOptions) {
5722
6071
  };
5723
6072
  try {
5724
6073
  const peer = c.peer;
6074
+ if (definitionOf(peer)?.start.daemonPort === "claude-implementer") {
6075
+ const launch = currentImplementerTools(c), root = ownerWriteRoot(peer);
6076
+ if (!launch || !root || root === opts.cwd || sessionId !== launch.sessionId || msg.nativeLaunchId !== launch.launchId || msg.nativeInstanceId !== instanceId) return void reply({ t: "facts", ok: false });
6077
+ if (typeof msg.transcriptPath === "string" && isAbsolute(msg.transcriptPath) && basename(msg.transcriptPath) === `${launch.sessionId}.jsonl`) {
6078
+ try {
6079
+ const projects = realPath(join(process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude"), "projects")), transcript = realPath(msg.transcriptPath);
6080
+ if (transcript.startsWith(`${projects}/`) && statSync(transcript).isFile() && launch.task !== undefined) implementerTranscripts.set(`${peer}:${launch.task}:${launch.sessionId}`, transcript);
6081
+ } catch { /* Exact path is not yet present or is unreadable: unknown preserves session identity. */ }
6082
+ }
6083
+ const mode = permissionMode(peer);
6084
+ if (mode !== "ask" && mode !== "ask-when-needed") return void reply({ t: "facts", ok: false });
6085
+ const blocked = scopedWriteBlocked(phase, mode, msg.tool, () => projectFileTool(msg.tool, msg.input, root, config.protect_instruction_files, config.data?.readonly));
6086
+ const actionClasses = phase === "pre" && typeof msg.tool === "string" ? claudeActionClasses(actionBudgets, msg.tool, msg.input, root) : [];
6087
+ return void reply({ t: "facts", ok: true, ...(phase === "pre" ? { permission: mode === "ask-when-needed" && blocked ? "ask" : mode, projectRoot: root, protectInstructionFiles: config.protect_instruction_files, ...(actionClasses.length ? { actionClasses } : {}) } : {}) });
6088
+ }
5725
6089
  let nativeClaude = false;
5726
6090
  if (definitionOf(peer)?.transport === "claude-channel" && sessionId && msg.nativeInstanceId === instanceId) {
5727
6091
  let expectedLaunch: string | undefined;
@@ -5730,8 +6094,8 @@ export async function startDaemon(opts: DaemonOptions) {
5730
6094
  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;
5731
6095
  } catch { /* an ordinary terminal has no Orca recovery identity */ }
5732
6096
  try {
5733
- const launch = JSON.parse(readFileSync(join(opts.stateDir, claudeStateFile("claude-launch", peer)), "utf8"));
5734
- if (launch.instanceId === instanceId && typeof launch.launchId === "string") expectedLaunch = launch.launchId;
6097
+ const launch = readClaudeLaunchProof(opts.stateDir, peer, instanceId);
6098
+ if (launch) expectedLaunch = launch.launchId;
5735
6099
  } catch { /* older/manual launches do not have a launcher marker */ }
5736
6100
  const current = claudeSession(peer);
5737
6101
  if ((phase === "stop" || phase === "post") && current.sessionId && current.sessionId !== sessionId) return void reply({ t: "facts", ok: false });
@@ -5748,10 +6112,14 @@ export async function startDaemon(opts: DaemonOptions) {
5748
6112
  if (definitionOf(peer)?.transport === "claude-channel" && msg.nativeInstanceId !== undefined && msg.nativeInstanceId !== instanceId) return void reply({ t: "facts", ok: false });
5749
6113
  const nativePeer = nativeClaude ? bus.peers.get(peer) : undefined;
5750
6114
  const permissionLaunch = definitionOf(peer)?.transport === "claude-channel" ? claudePermissionLaunch(peer) : undefined;
5751
- const permission = nativeClaude && permissionLaunch && !permissionLaunch.unattended && msg.nativeLaunchId === permissionLaunch.launchId ? permissionMode(peer) : undefined;
6115
+ const writeRoot = declarations[peer]?.worktree ? ownerWriteRoot(peer) : executionRoot(peer);
6116
+ if (declarations[peer]?.worktree && writeRoot === undefined) return void reply({ t: "facts", ok: false });
6117
+ const permission = nativeClaude && permissionLaunch && !permissionLaunch.unattended && msg.nativeLaunchId === permissionLaunch.launchId && writeRoot !== undefined ? permissionMode(peer) : undefined;
5752
6118
  if (permission !== undefined && isWsPeer(nativePeer)) claudePermissionGenerations.set(peer, nativePeer.sessionGeneration);
5753
- const protectedWrite = scopedWriteBlocked(phase, permission, msg.tool, () => projectFileTool(msg.tool, msg.input, opts.cwd, config.protect_instruction_files, config.data?.readonly));
5754
- const permissionReply = phase === "pre" && permission !== undefined ? { permission: permission === "ask-when-needed" && protectedWrite ? "ask" : permission, projectRoot: opts.cwd, protectInstructionFiles: config.protect_instruction_files } : {};
6119
+ const protectedWrite = scopedWriteBlocked(phase, permission, msg.tool, () => projectFileTool(msg.tool, msg.input, writeRoot ?? opts.cwd, config.protect_instruction_files, config.data?.readonly));
6120
+ // Always-ask (#636): a classed call is asked about in every permission mode. The hub names only the classes, under the same verified root as the file policy above; the hook turns them into `ask`.
6121
+ const actionClasses = phase === "pre" && permission !== undefined && typeof msg.tool === "string" ? claudeActionClasses(actionBudgets, msg.tool, msg.input, writeRoot ?? opts.cwd) : [];
6122
+ const permissionReply = phase === "pre" && permission !== undefined ? { permission: permission === "ask-when-needed" && protectedWrite ? "ask" : permission, projectRoot: writeRoot ?? opts.cwd, protectInstructionFiles: config.protect_instruction_files, ...(actionClasses.length ? { actionClasses } : {}) } : {};
5755
6123
  // Permission-only hooks carry no native-turn evidence and must never drive busy state.
5756
6124
  const purpose = permissionLaunch?.hookPurpose ?? msg.hookPurpose;
5757
6125
  const observesNative = purpose === "facts" || purpose === "idle" || purpose === "facts-and-idle" || (purpose === undefined && (config.coordination === "turn-free" || config.task_sweep.enabled || config.roles[peer]?.includes("conductor")));
@@ -5984,7 +6352,7 @@ export async function startDaemon(opts: DaemonOptions) {
5984
6352
  case "ui_snapshot":
5985
6353
  if (c.role !== "console") return void reply({ ok: false, error: "ui_snapshot is a console command" });
5986
6354
  if (!Number.isSafeInteger(msg.after ?? 0) || (msg.after ?? 0) < 0) return void reply({ ok: false, error: "invalid cursor" });
5987
- return void reply(uiSnapshot(msg.after ?? 0, msg.memory !== undefined ? { memory: msg.memory } : undefined));
6355
+ return void reply(uiSnapshot(msg.after ?? 0, msg.memory !== undefined || dashboardView(msg.view) ? { ...(msg.memory !== undefined ? { memory: msg.memory } : {}), ...(dashboardView(msg.view) ? { view: msg.view } : {}) } : undefined));
5988
6356
  case "ui_action":
5989
6357
  if (c.role !== "console") return void reply({ ok: false, error: "ui_action is a console command" });
5990
6358
  if (recoveryActive()) return void reply({ ok: false, error: "recovery is holding mutations" });
@@ -5999,7 +6367,7 @@ export async function startDaemon(opts: DaemonOptions) {
5999
6367
  catch (error) { return void reply({ ok: false, error: (error as Error).message }); }
6000
6368
  case "settings_get":
6001
6369
  if (c.role !== "console") return void reply({ ok: false, error: "settings are a person's: run ahub settings in a terminal" });
6002
- return void reply({ ok: true, ...settingsView(), roster: rosterView() });
6370
+ return void reply({ ok: true, ...settingsView(), roster: rosterView(), configEdit: (() => { const view = configEditor!.view(); return { revisions: view.revisions, known: view.known }; })() });
6003
6371
  case "settings_reload":
6004
6372
  if (c.role !== "console") return void reply({ ok: false, error: "settings are a person command" });
6005
6373
  return void reloadConfig("terminal", undefined, msg.confirm).then(reply, () => reply({ ok: false, error: "configuration reload could not be confirmed; inspect hub.log" }));
@@ -6013,6 +6381,16 @@ export async function startDaemon(opts: DaemonOptions) {
6013
6381
  case "settings_undo":
6014
6382
  if (c.role !== "console") return void reply({ ok: false, error: "settings are a person's: run ahub settings in a terminal" });
6015
6383
  return void settingsUndo("terminal", msg.confirm, msg.now, msg.reason, msg.key, msg.undoDigest).then(reply, () => reply({ ok: false, error: "the setting could not be undone; inspect hub.log" }));
6384
+ case "config_edit_get":
6385
+ if (c.role !== "console") return void reply({ ok: false, error: "config edits are a person's: run ahub settings edit in a terminal" });
6386
+ return void reply({ ok: true, ...configEditor!.view() });
6387
+ case "config_edit_preview":
6388
+ if (c.role !== "console") return void reply({ ok: false, error: "config edits are a person's: run ahub settings edit in a terminal" });
6389
+ return void reply(configEditor!.preview(msg.edit, "terminal"));
6390
+ case "config_edit_apply":
6391
+ if (c.role !== "console") return void reply({ ok: false, error: "config edits are a person's: run ahub settings edit in a terminal" });
6392
+ void configEditor!.apply(msg.edit, "terminal", msg.confirm, msg.reason).then(reply, () => reply({ ok: false, error: "the config edit could not be completed; inspect hub.log" }));
6393
+ return;
6016
6394
  case "kimi_launch_end": {
6017
6395
  if (c.role !== "console") return void reply({ ok: false, error: "Kimi launch cleanup is console only" });
6018
6396
  if (typeof msg.admissionId === "string" && msg.admissionId === kimiAdmissionId) clearKimiAdmission();
@@ -6062,8 +6440,9 @@ export async function startDaemon(opts: DaemonOptions) {
6062
6440
  });
6063
6441
  return;
6064
6442
  case "task":
6443
+ if (c.toolsOwner && !IMPLEMENTER_HUB_TOOL_NAMES.includes(String(msg.op))) return void reply({ t: "task", ok: false, error: "tool is not available to this Claude implementer child" });
6065
6444
  if (recoveryActive() && recoveryPhase !== "preparing" && String(msg.op) !== "hub_checkpoint") return void reply({ t: "task", ok: false, error: "recovery is holding mutations" });
6066
- taskOp(c.peer ?? USER, String(msg.op), msg.args ?? {}).then(
6445
+ (c.toolsOwner ? childToolAuthority.run({ by: c.peer!, current: captureImplementerTools(c) }, () => taskOp(c.peer!, String(msg.op), msg.args ?? {})) : taskOp(c.peer ?? USER, String(msg.op), msg.args ?? {})).then(
6067
6446
  (text) => reply({ t: "task", ok: true, text }),
6068
6447
  (e: Error) => reply({ t: "task", ok: false, error: e.message }),
6069
6448
  );
@@ -6323,7 +6702,7 @@ export async function startDaemon(opts: DaemonOptions) {
6323
6702
  // After a crash that recorded Pi, crash recovery starts it (#66): its recorded session first, a fresh one if that fails.
6324
6703
  const piRecovered = !archivedPiContinuity && !!crashed?.peers.some((p) => definitionOf(p.peer)?.session.restoreOnStart);
6325
6704
  if (piAutoStart() && !recoveryActive() && !piRecovered) autoStartPi();
6326
- return { bus, token, port: server.port as number, stop, stopped: new Promise<void>((r) => (onStop = r)) };
6705
+ return { bus, token, port: server.port as number, bandEvaluations: () => bandEvaluations, sdlcFileReads: () => sdlcFileReads, stop, stopped: new Promise<void>((r) => (onStop = r)) };
6327
6706
  } finally {
6328
6707
  if (!ready) for (const cleanup of startupCleanup.reverse()) { try { await cleanup(); } catch { /* preserve startup error */ } }
6329
6708
  }