@staix/agent-hub 0.14.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 (82) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +16 -6
  3. package/docs/agent-notes/README.md +4 -0
  4. package/docs/agent-notes/adapters.md +17 -3
  5. package/docs/agent-notes/budget.md +4 -4
  6. package/docs/agent-notes/bus.md +1 -0
  7. package/docs/agent-notes/daemon.md +14 -9
  8. package/docs/agent-notes/tasks.md +9 -4
  9. package/docs/agent-notes/tests.md +3 -3
  10. package/docs/dogfood.md +9 -4
  11. package/docs/events.md +21 -7
  12. package/docs/operations.md +90 -18
  13. package/docs/profiles.md +1 -0
  14. package/docs/quickstart.md +9 -3
  15. package/docs/sdlc.md +17 -0
  16. package/docs/security.md +37 -10
  17. package/docs/specs/2026-09-19-agent-hub-design.md +74 -10
  18. package/docs/verification/2026-10-23-0.15.0.md +80 -0
  19. package/docs/verification/2026-10-23-daemon-responsiveness.md +44 -0
  20. package/docs/verification/2026-10-23-headless-claude-permissions.md +47 -0
  21. package/docs/verification/2026-10-23-terminal-claude-execution-root.md +41 -0
  22. package/docs/verified.json +29 -17
  23. package/package.json +1 -1
  24. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  25. package/plugins/agent-hub/server.js +992 -312
  26. package/src/adapters/acp.ts +43 -7
  27. package/src/adapters/claude-channel.ts +26 -4
  28. package/src/adapters/claude-implementer.ts +243 -0
  29. package/src/adapters/claude-print-child.ts +90 -0
  30. package/src/adapters/claude-task-sessions.ts +80 -0
  31. package/src/adapters/compat.ts +4 -0
  32. package/src/adapters/conformance/enrollment.ts +2 -1
  33. package/src/adapters/conformance/fakes/acp-server.ts +3 -1
  34. package/src/adapters/definitions/builtin-metadata.ts +14 -2
  35. package/src/adapters/definitions/native-channel-state.ts +1 -1
  36. package/src/adapters/definitions/native-cli-entry-claude.ts +13 -8
  37. package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
  38. package/src/adapters/definitions/native-factories.ts +8 -0
  39. package/src/adapters/kimi-wire-usage.ts +303 -0
  40. package/src/adapters/registry-types.ts +7 -3
  41. package/src/adapters/registry.ts +14 -2
  42. package/src/adapters/reviewer-print.ts +30 -53
  43. package/src/cli/console-config.ts +123 -0
  44. package/src/cli/console-state.ts +28 -1
  45. package/src/cli/console.ts +25 -1
  46. package/src/cli/facts-hook.ts +9 -5
  47. package/src/cli/help.ts +4 -3
  48. package/src/cli/launch.ts +10 -4
  49. package/src/cli/main.ts +47 -9
  50. package/src/cli/output.ts +28 -0
  51. package/src/cli/reset.ts +1 -1
  52. package/src/cli/settings-edit.ts +89 -0
  53. package/src/cli/terminal-recovery.ts +1 -6
  54. package/src/hub/action-enforce.ts +82 -0
  55. package/src/hub/action-policy.ts +22 -10
  56. package/src/hub/board.ts +26 -2
  57. package/src/hub/budget.ts +5 -0
  58. package/src/hub/bus.ts +5 -5
  59. package/src/hub/claude-launch-proof.ts +33 -0
  60. package/src/hub/config-apply.ts +5 -0
  61. package/src/hub/config-edit-host.ts +173 -0
  62. package/src/hub/config-edit.ts +282 -0
  63. package/src/hub/config-tier.ts +89 -0
  64. package/src/hub/control-client.ts +24 -3
  65. package/src/hub/daemon.ts +368 -59
  66. package/src/hub/deliverable-files.ts +131 -0
  67. package/src/hub/deliverables.ts +125 -0
  68. package/src/hub/delivery-journal.ts +18 -2
  69. package/src/hub/events.ts +3 -1
  70. package/src/hub/hub-tools.ts +19 -3
  71. package/src/hub/instances.ts +7 -4
  72. package/src/hub/kimi-usage.ts +144 -0
  73. package/src/hub/manager.ts +1 -1
  74. package/src/hub/peers.ts +7 -0
  75. package/src/hub/permission-payload.ts +15 -0
  76. package/src/hub/routing.ts +118 -3
  77. package/src/hub/settings.ts +1 -1
  78. package/src/hub/tasks.ts +202 -15
  79. package/src/hub/token-window.ts +33 -0
  80. package/src/hub/usage-rollup.ts +16 -6
  81. package/src/hub/workflow.ts +16 -0
  82. package/src/ui/index.html +115 -1
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";
@@ -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";
@@ -500,6 +509,8 @@ export interface DaemonOptions {
500
509
  checkoutBranch?: typeof mainCheckoutBranch;
501
510
  /** Host-only admission transport; never selected by project config or tools. */
502
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;
503
514
  /** Host-only evidence seam; never supplied by project config or control messages. */
504
515
  releaseDocEvidence?: (root: string) => Promise<DocEvidence | undefined>;
505
516
  projectId?: string;
@@ -544,6 +555,7 @@ interface Client {
544
555
  role?: "peer" | "console" | "tools";
545
556
  peer?: PeerId;
546
557
  launchId?: string;
558
+ toolsOwner?: { owner: PeerAdapter; launchId: string; generation: string; cwd: string };
547
559
  tail?: () => void;
548
560
  watch?: () => void;
549
561
  /** The one-launch Kimi TUI admission this socket attached under; its close clears only that admission. */
@@ -603,7 +615,10 @@ export async function startDaemon(opts: DaemonOptions) {
603
615
  const channelPeers = [...ADAPTERS.filter(definition => definition.transport === "claude-channel").map(definition => definition.kind),
604
616
  ...channelExtras.filter(id => adapterCatalog.definitionOf(id, declarations)?.transport === "claude-channel")];
605
617
  const executionRoots = new Map<string, string>();
606
- 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>();
607
622
  const reviewerUnavailable = new Map<string, string>();
608
623
  const roles = { ...Object.fromEntries(adapterCatalog.definitions.filter(d => configuredIds.includes(d.kind)).map(d => [d.kind, [...d.roles]])), ...loadedConfig.roles };
609
624
  const permissionModesFromInstances: Record<string, PermissionMode> = {};
@@ -679,6 +694,13 @@ export async function startDaemon(opts: DaemonOptions) {
679
694
  let configRevision = 0;
680
695
  let reloadConfirmation: { token: string; fingerprint: string; revision: number; keys: string[] } | undefined;
681
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() ?? [])];
682
704
  let retainedConfig: { key: string; reason: string }[] = [];
683
705
  function retainedConfigAfterReload(sources: ConfigSources, fileConfig: HubConfig, rejected: readonly { key: string; reason: string }[], scope?: string): { key: string; reason: string }[] {
684
706
  const rows = new Map<string, string>();
@@ -840,6 +862,7 @@ export async function startDaemon(opts: DaemonOptions) {
840
862
  sock.data.watch?.();
841
863
  const peer = sock.data.peer ? bus.peers.get(sock.data.peer) : undefined;
842
864
  if (isWsPeer(peer)) {
865
+ if (sock.data.peer && executionOwners.get(sock.data.peer)?.socket === sock) revokeTerminalExecution(sock.data.peer);
843
866
  peer.detach(sock);
844
867
  if (!!sock.data.peer && !!definitionOf(sock.data.peer)?.attach?.admission && peer.state === "offline" && !peer.claiming && sock.data.kimiAdmission === kimiAdmissionId) clearKimiAdmission();
845
868
  }
@@ -1405,12 +1428,16 @@ export async function startDaemon(opts: DaemonOptions) {
1405
1428
  reviewOnly: reviewOnlyPeer,
1406
1429
  // #223: a headless reviewer's verdict holds only while the same child it started with still runs for that task.
1407
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
+ },
1408
1435
  // #223 R4: fresh only when the verdict comes from a reviewer session the hub started for this task's review.
1409
1436
  reviewContext: (by, task) => !!definitionOf(by)?.reviewer && (bus.peers.get(by) as ReviewerPrintPeer | undefined)?.reviewing === task ? "fresh" : "continuing",
1410
1437
  // Proof belongs to the adapter generation actually started in the validated root. A role-only
1411
1438
  // reload cannot turn a canonical reviewer process into an isolated implementation owner.
1412
1439
  ownerIsolation: () => Object.fromEntries(extraIds.map(id => {
1413
- const bound = executionOwners.get(id);
1440
+ const bound = currentExecutionOwner(id);
1414
1441
  return [id, !!bound && bound.owner === bus.peers.get(id) && bound.cwd !== opts.cwd && bound.cwd === executionRoots.get(id)];
1415
1442
  })),
1416
1443
  recordPlanning: change => event({ type: "planning", ...change }),
@@ -1419,6 +1446,9 @@ export async function startDaemon(opts: DaemonOptions) {
1419
1446
  profile: profileName(config.profile),
1420
1447
  profileData: resolvedProfile,
1421
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),
1422
1452
  catalog,
1423
1453
  sessionRoles: peer => bus.peers.has(peer) && bus.stateOf(peer) !== "offline" ? preparedSessionRoles.get(peer) ?? [] : [],
1424
1454
  projectedReview: task => reviewProjection(task),
@@ -1554,7 +1584,10 @@ export async function startDaemon(opts: DaemonOptions) {
1554
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) });
1555
1585
  const worktreeFacts = new Map<string, Facts>();
1556
1586
  const worktreeProjections = new Map<string, TextProjections>();
1557
- 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
+ };
1558
1591
  const repoFor = (peer: string, budget?: SnapshotBudget) => executionRoot(peer) === opts.cwd ? factsRepo : repoOf(executionRoot(peer), budget);
1559
1592
  const projectionsFor = (peer: string): TextProjections => {
1560
1593
  const root = executionRoot(peer);
@@ -1856,7 +1889,7 @@ export async function startDaemon(opts: DaemonOptions) {
1856
1889
  }
1857
1890
  for (const move of record.moved) moves.set(JSON.stringify([move.id, move.role, move.to, move.title]), move);
1858
1891
  const unreadMoves = [...moves.values()];
1859
- 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
1860
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.";
1861
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 } : {}) } }));
1862
1895
  // Publish the complete replacement before removing its evidence. A failed write keeps the old notices.
@@ -1868,8 +1901,28 @@ export async function startDaemon(opts: DaemonOptions) {
1868
1901
  // ponytail: a third full read of events.jsonl at startup (after nativeStopsSeen and claudeNativeUsageSeen). Measured at about 70 ms on the real
1869
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
1870
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).
1871
- 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 } : {}) }] : []));
1872
- 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}`); } };
1873
1926
  startupCleanup.push(() => budget.close());
1874
1927
  // #683 U2: events.jsonl folded into hub.db, a bounded pass at a time, so `usage` never rescans the log.
1875
1928
  const usageRollup = new UsageRollup(join(opts.stateDir, "hub.db"), join(opts.stateDir, "events.jsonl"));
@@ -1968,13 +2021,70 @@ export async function startDaemon(opts: DaemonOptions) {
1968
2021
  function privateSnapshotWindow(): boolean {
1969
2022
  return [...turns.values()].some(turn => turn.private) || board.list().some(task => !isClosedTaskState(task.state) && tasks.isPii(task));
1970
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
+ }
1971
2060
  function ownerWriteRoot(peer: string): string | undefined {
1972
2061
  if (declarations[peer]) {
1973
- const bound = executionOwners.get(peer);
2062
+ const bound = currentExecutionOwner(peer);
1974
2063
  if (!bound || bound.owner !== bus.peers.get(peer) || bound.cwd !== executionRoots.get(peer)) return undefined;
1975
2064
  }
1976
2065
  return executionRoot(peer);
1977
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
+ }
1978
2088
  function canonicalWriteScope(repo: Repo, budget?: SnapshotBudget): boolean | undefined {
1979
2089
  // ponytail: D1 is inert for builtin owners while .worktrees exists (a deliberate false negative).
1980
2090
  // Bind each owner to its real execution worktree before narrowing this unknown scope.
@@ -2033,28 +2143,18 @@ export async function startDaemon(opts: DaemonOptions) {
2033
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 + "/")); });
2034
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 ?? []) });
2035
2145
  }
2036
- const delta = tokenDeltas();
2037
- /** Tokens a peer used, as increments: Kimi reports a session total (turned into increments below), Codex increments. */
2146
+ /** Tokens a peer used, as increments. */
2038
2147
  let codexUsageSeq = 0; // per hub run: with the run id and peer it names one usage record
2039
- const addTokens = (peer: PeerId, n: number): number => {
2148
+ const addTokens = (peer: PeerId, n: number, task?: number): number => {
2040
2149
  const supervisionTurn = supervisionTurns.get(peer);
2041
2150
  if (supervisionTurn && Number.isFinite(n) && n >= 0) supervisionTurn.tokens = (supervisionTurn.tokens ?? 0) + n;
2042
2151
  if (n > 0) {
2043
- event({ type: "tokens", peer, n, ...taskAttribution(peer) });
2152
+ event({ type: "tokens", peer, n, ...taskAttribution(peer, task) });
2044
2153
  const turn = turns.get(peer);
2045
2154
  if (turn) turn.tokens += n;
2046
2155
  }
2047
2156
  return n;
2048
2157
  };
2049
- const onKimiTokens = (total: number, session: string, peer: string) => {
2050
- const n = addTokens(peer, delta(peer, total, session));
2051
- if (!config.budget.kimi_tokens_5h || !n) return;
2052
- const now = Date.now();
2053
- kimiTokens.push({ at: now, n });
2054
- while (kimiTokens.length && now - kimiTokens[0]!.at > 5 * 3_600_000) kimiTokens.shift();
2055
- const used = kimiTokens.reduce((s, t) => s + t.n, 0) / config.budget.kimi_tokens_5h;
2056
- budget.report(peer, [{ id: "tokens", used, resetsAt: kimiTokens[0]!.at + 5 * 3_600_000, source: "kimi usage_update (soft limit)" }]);
2057
- };
2058
2158
  // Claude's numbers arrive through the status line tee `ahub claude` installs (src/cli/statusline-tee.ts).
2059
2159
  let claudeUsageSeen = 0;
2060
2160
  let bandsRevision = ""; // #481 S7: the board revision and band configuration the last band evaluation that ran saw
@@ -2085,7 +2185,7 @@ export async function startDaemon(opts: DaemonOptions) {
2085
2185
  try { tasks.flushPlannerNotices(); } catch { /* the board remains authoritative if notice delivery fails */ }
2086
2186
  for (const pending of checkpointWaits.values()) if (!pending.valid()) pending.done(undefined);
2087
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 });
2088
- 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);
2089
2189
  feedBudgetPauses();
2090
2190
  supervision.checkRound();
2091
2191
  try {
@@ -2435,7 +2535,7 @@ export async function startDaemon(opts: DaemonOptions) {
2435
2535
  case "hub_task_decline":
2436
2536
  return line(await tasks.decline(by, a.id, a.reason, a.capacity === true));
2437
2537
  case "hub_task_done": {
2438
- 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);
2439
2539
  const completion = t.history.findLast(h => h.by === by && (h.event === "done" || h.event === "done (checking)"));
2440
2540
  if (!piiTurn && !tasks.isPii(t) && completion && !completion.withheld && typeof a.summary === "string" && completion.note?.startsWith(a.summary)) {
2441
2541
  citeMemory(by, a.summary, `summary:${t.id}:${t.history.indexOf(completion)}`, [newEnvelope(by, "", { refs: { task: String(t.id) } })]);
@@ -2449,7 +2549,7 @@ export async function startDaemon(opts: DaemonOptions) {
2449
2549
  }
2450
2550
  case "hub_sdlc_record":
2451
2551
  await tasks.specStageGate(by, a.id, a.receipt); // #481 S1: a live spec edit-time check before a build stage
2452
- return line(tasks.recordLifecycle(by, a.id, a.receipt));
2552
+ return line(await tasks.recordSdlc(by, a.id, a.receipt));
2453
2553
  case "hub_review":
2454
2554
  return line(await tasks.review(by, a.id, a.verdict, a.note, a.unmet, a.head, a.coverage, a.findings));
2455
2555
  case "hub_remember":
@@ -2492,7 +2592,8 @@ export async function startDaemon(opts: DaemonOptions) {
2492
2592
  const task = board.get(Number(a.id));
2493
2593
  if (task?.state === "backlog") throw new PreEffectToolRefusal("backlog is read through planner tools");
2494
2594
  if (!task) return JSON.stringify(`no task #${a.id}`);
2495
- 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 });
2496
2597
  }
2497
2598
  if (op === "route_explain" && by !== USER) {
2498
2599
  if (a.id !== undefined && board.get(Number(a.id))?.state === "backlog") throw new PreEffectToolRefusal("backlog is read through planner tools");
@@ -2528,7 +2629,8 @@ export async function startDaemon(opts: DaemonOptions) {
2528
2629
  if (!task) return JSON.stringify(`no task #${a.id}`);
2529
2630
  const { codeSymbols: _storedCodeSymbols, ...detail } = task;
2530
2631
  const codeSymbols = tasks.publicView(task).codeSymbols;
2531
- 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);
2532
2634
  }
2533
2635
  case "task_assign":
2534
2636
  case "task_assign_checked": {
@@ -2613,6 +2715,30 @@ export async function startDaemon(opts: DaemonOptions) {
2613
2715
  }
2614
2716
  };
2615
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
+ };
2616
2742
  const permissionMode = (peer: string): PermissionMode => permissionModes.get(peer) ?? "ask";
2617
2743
  const permissionModeSources = new Map(Object.entries(defaultSources).map(([peer, value]) => [peer, value.source]));
2618
2744
  const unattendedPeers = new Set<string>();
@@ -2632,8 +2758,10 @@ export async function startDaemon(opts: DaemonOptions) {
2632
2758
  owner: row => {
2633
2759
  const owner = bus.peers.get(row.peer), proof = owner?.terminalOwner?.(row.launchId);
2634
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;
2635
2763
  return { ...proof, isCurrent: () => {
2636
- 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;
2637
2765
  const current = owner?.terminalOwner?.(row.launchId);
2638
2766
  return current?.generation === proof.generation && current?.nativePid === proof.nativePid;
2639
2767
  } };
@@ -2706,8 +2834,8 @@ export async function startDaemon(opts: DaemonOptions) {
2706
2834
  if (value.instanceId !== instanceId) return {};
2707
2835
  let currentLauncher = false;
2708
2836
  try {
2709
- const launch = JSON.parse(readFileSync(join(opts.stateDir, claudeStateFile("claude-launch", peer)), "utf8"));
2710
- if (launch.instanceId === instanceId && typeof launch.launchId === "string" && launch.launchId) {
2837
+ const launch = readClaudeLaunchProof(opts.stateDir, peer, instanceId);
2838
+ if (launch) {
2711
2839
  currentLauncher = true;
2712
2840
  if (value.launchId !== launch.launchId) return {};
2713
2841
  }
@@ -2806,7 +2934,7 @@ export async function startDaemon(opts: DaemonOptions) {
2806
2934
  // filled one stays in the row and never matches.
2807
2935
  const ADDED_COLUMNS = [["deps", "[]"], ["plan", "{}"]] as const;
2808
2936
  const integrity = (older = 0) => {
2809
- 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)]));
2810
2938
  const boardState = board.list().sort((a, b) => a.id - b.id).map((t) => {
2811
2939
  const row: Record<string, unknown> = { ...t };
2812
2940
  for (const [col, empty] of ADDED_COLUMNS.slice(0, older)) if (JSON.stringify(row[col]) === empty) delete row[col];
@@ -2885,7 +3013,7 @@ export async function startDaemon(opts: DaemonOptions) {
2885
3013
  instanceId,
2886
3014
  version: VERSION,
2887
3015
  pluginBundle: SERVER_JS,
2888
- pendingRestart,
3016
+ pendingRestart: allPendingRestart(),
2889
3017
  retainedConfig,
2890
3018
  ...(() => { const warning = mainCheckoutWarning(opts.cwd, tasks.publicMetadataText, checkoutBranch); return warning ? { mainCheckout: warning } : {}; })(),
2891
3019
  startModes: Object.fromEntries([...START_MODE_PEERS, ...configuredIds].map(peer => [peer, startModeOf(config.peers, peer, declarations, adapterCatalog)])),
@@ -2904,6 +3032,8 @@ export async function startDaemon(opts: DaemonOptions) {
2904
3032
  readOnlyPeers: extraIds.filter(readOnlyPeer),
2905
3033
  // #450 I3: the instance ids this daemon bound at startup; a launcher selects only one of these.
2906
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"])),
2907
3037
  stopping,
2908
3038
  pid: process.pid,
2909
3039
  cwd: opts.cwd,
@@ -2911,11 +3041,11 @@ export async function startDaemon(opts: DaemonOptions) {
2911
3041
  ...(dashboard ? { uiOrigin: dashboard.origin } : {}),
2912
3042
  codexProxyPort: opts.codexProxyPort,
2913
3043
  peers: Object.fromEntries(
2914
- 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), ...(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 ?? [] } : {}) }]; }),
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 ?? [] } : {}) }]; }),
2915
3045
  ),
2916
3046
  taskWip: Object.fromEntries(peerIds.map(id => [id, { wip: tasks.wip(id), wipLimit: tasks.wipLimit(id) }])),
2917
3047
  boardReadCapabilities: { releaseFilter: true, columnMetadata: true },
2918
- 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)]; })),
2919
3049
  roleInfo: Object.fromEntries(peerIds.map(id => [id, { flowRoles: config.roles[id] ?? [], catalogRoles: roleState(id), ...(config.roles[id]?.includes("planner") ? { planner: plannerSource } : {}) }])),
2920
3050
  planner: { peers: plannerPeers(), source: plannerSource },
2921
3051
  ...(pendingPermissionDefaults.size ? { permissionDefaults: [...pendingPermissionDefaults].map(([peer, source]) => ({ peer, mode: "never-ask", source })) } : {}),
@@ -2969,7 +3099,7 @@ export async function startDaemon(opts: DaemonOptions) {
2969
3099
  // A queue change is not a bus event: without this, status.json reports the queue as it was before the last
2970
3100
  // message and keeps a phantom `queued N` after the queue drains (measured on a live 0.6.3 hub).
2971
3101
  function heldRows(peer: PeerId) {
2972
- 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 => ({
2973
3103
  id: row.id, senders: [...new Set(row.originals.map(env => sanitize(env.from)))],
2974
3104
  kinds: [...new Set(row.originals.map(env => env.kind))], createdAt: row.createdAt,
2975
3105
  context: deliveryTaskContext(row.originals, board, peer),
@@ -2985,7 +3115,7 @@ export async function startDaemon(opts: DaemonOptions) {
2985
3115
  if (settlingSuperseded || stopping || recoveryActive() || bus.isRecoveryHeld) return;
2986
3116
  settlingSuperseded = true;
2987
3117
  try {
2988
- for (const row of bus.queueList()) {
3118
+ for (const row of bus.queueList(undefined, true)) {
2989
3119
  if (row.state !== "needs_review" || row.reason?.startsWith("requested stop:")) continue;
2990
3120
  const reason = supersededDeliveryReason(row.originals, board, row.peer);
2991
3121
  if (!reason) continue;
@@ -3286,7 +3416,9 @@ export async function startDaemon(opts: DaemonOptions) {
3286
3416
  const preparedRoles = preparedSessionRoleIdentities.get(e.peer)?.map(role => ({ ...role })) ?? null;
3287
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 } : {}) });
3288
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 */ }
3289
3420
  } else if (!busy && open) {
3421
+ collectKimiUsage(e.peer); // before the turn closes: its rows count toward the turn's tokens
3290
3422
  turns.delete(e.peer);
3291
3423
  let afterTurn: (() => void) | undefined;
3292
3424
  let files: number | undefined;
@@ -3374,6 +3506,10 @@ export async function startDaemon(opts: DaemonOptions) {
3374
3506
  try { tasks.recordOwnerTurn(e.peer, open.writeTasks, changed, !!open.taskEffect || !!open.effectsPending); } catch { /* advisory cannot fail native completion */ }
3375
3507
  }
3376
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);
3377
3513
  if (e.state === "offline") { offlineSince.set(e.peer, offlineSince.get(e.peer) ?? Date.now()); supervision.peerOffline(e.peer); supervisionTurns.delete(e.peer); }
3378
3514
  else offlineSince.delete(e.peer);
3379
3515
  // A session that ended may come back without hooks: its context path is verified again or not at all (issue #108).
@@ -3460,7 +3596,8 @@ export async function startDaemon(opts: DaemonOptions) {
3460
3596
  catch { try { log(`permission observer failed for ${req.peer}`); } catch { /* settlement is authoritative */ } }
3461
3597
  };
3462
3598
  if (stopping || signal?.aborted) { observed("aborted"); return { outcome: "cancelled" }; }
3463
- 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 }; }
3464
3601
  const id = randomUUID().slice(0, 8);
3465
3602
  // The title is written by an agent and read by the person approving it: escape sequences and carriage returns
3466
3603
  // could repaint the terminal line, so everything but newline and tab is made visible.
@@ -3676,7 +3813,7 @@ export async function startDaemon(opts: DaemonOptions) {
3676
3813
  const owner = bus.peers.get(peer);
3677
3814
  const stopNotice = definitionOf(peer)?.cli.stopNotice?.(isWsPeer(owner) ? "tui" : !owner ? startMode(peer) : "headless", owner);
3678
3815
  if (stopNotice) return { ok: false, error: stopNotice };
3679
- 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" };
3680
3817
  if (isPiPeer(owner) && owner.mode !== "headless") return { ok: false, error: "Pi belongs to a native TUI terminal; end it in its terminal" };
3681
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` };
3682
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" };
@@ -3852,9 +3989,11 @@ export async function startDaemon(opts: DaemonOptions) {
3852
3989
  }
3853
3990
  if (adapter?.start.acpCommand) {
3854
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
3855
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"]({
3856
3994
  onActivity: item => activityRings[peer]?.append(item),
3857
3995
  isPrivateTurn: () => !!turns.get(peer)?.private || contextPii(peer),
3996
+ actionGate: acpActionGate(peer),
3858
3997
  cmd, kind: adapter.kind,
3859
3998
  permissionMode: permissionMode(peer),
3860
3999
  ...(args.model ? { launchModel: args.model } : {}),
@@ -3868,7 +4007,6 @@ export async function startDaemon(opts: DaemonOptions) {
3868
4007
  // session's configured MCP servers (issue #138). Exact names only; a stopping hub approves nothing.
3869
4008
  autoApprove: (title) => !stopping && HUB_TOOL_TITLES.has(title),
3870
4009
  log,
3871
- onTokens: (total, session) => onKimiTokens(total, session, peer),
3872
4010
  // Pause synchronously before the adapter requeues a provider-refused prompt (#306).
3873
4011
  onQuotaRefusal: (window) => {
3874
4012
  budget.report(peer, [window], true);
@@ -3879,6 +4017,7 @@ export async function startDaemon(opts: DaemonOptions) {
3879
4017
  // adapter's own session id; report() drops it when that is not the daemon's current session. An invalid
3880
4018
  // shape reports a null reading: unknown, not the previous value (#285).
3881
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
3882
4021
  if (observation.source !== "usage_update" || observation.shape !== "context-used") return;
3883
4022
  const current = contextSession(peer);
3884
4023
  if (!current) return;
@@ -3904,9 +4043,11 @@ export async function startDaemon(opts: DaemonOptions) {
3904
4043
  userArgs: [], unattended: false, env: {}, nativeCommand: configuredIds.includes(adapter.kind) ? undefined : config.qwen_cmd, model: args.model });
3905
4044
  const mcpServers = [{ name: "agent-hub", command: "bun", args: ["run", SERVER_JS], env: Object.entries(toolEnv(peer, true)).map(([name, value]) => ({ name, value })) }];
3906
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
3907
4047
  const owner = adapter.start.create({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: undefined, permission, mcpServers, daemon: { acp: {
3908
4048
  onActivity: item => activityRings[peer]?.append(item),
3909
4049
  isPrivateTurn: () => !!turns.get(peer)?.private || contextPii(peer),
4050
+ actionGate: acpActionGate(peer),
3910
4051
  kind: adapter.kind, cmd: [launch.cmd, ...launch.args], permissionMode: permission === "read-only" ? "ask" : permission,
3911
4052
  ...(externalIds.has(adapter.kind) ? { requireOwnedGroup: true, requireLoadSessionEcho: true, nativePolicy: adapter.permission.genericAcp ?? {} } : {}),
3912
4053
  ...(args.model ? { launchModel: args.model } : {}), ...(args.sessionId ? { resumeSessionId: args.sessionId } : {}),
@@ -4256,6 +4397,51 @@ export async function startDaemon(opts: DaemonOptions) {
4256
4397
  await local.start();
4257
4398
  return { ok: true, model: route ? `${route} (fallback ${routing.local.fixed_model})` : (args.model ?? routing.local.fixed_model) };
4258
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
+ }
4259
4445
  if (adapter?.reviewer) {
4260
4446
  const backend = adapter.reviewer.backend;
4261
4447
  if (!backend || !adapter.start.create) return { ok: false, error: "reviewer has no verified runtime" };
@@ -4545,7 +4731,7 @@ export async function startDaemon(opts: DaemonOptions) {
4545
4731
  instanceId,
4546
4732
  attention: attentionStatus.attentionInput,
4547
4733
  pet,
4548
- status: { peers: peerView, pendingRestart, retainedConfig, taskWip: attentionStatus.taskWip, roleInfo: attentionStatus.roleInfo },
4734
+ status: { peers: peerView, pendingRestart: allPendingRestart(), retainedConfig, taskWip: attentionStatus.taskWip, roleInfo: attentionStatus.roleInfo },
4549
4735
  boardCapabilities: { backlog: true },
4550
4736
  planningCapabilities: { enabled: true, wipPull: true, split: true },
4551
4737
  releases: tasks.releaseList(USER),
@@ -4674,7 +4860,7 @@ export async function startDaemon(opts: DaemonOptions) {
4674
4860
  settledHolderChange = JSON.stringify(oldHolders) !== JSON.stringify(newHolders);
4675
4861
  if (settledHolderChange) effect = `planner ${JSON.stringify(oldHolders.planners)} -> ${JSON.stringify(newHolders.planners)}; conductor ${JSON.stringify(oldHolders.conductors)} -> ${JSON.stringify(newHolders.conductors)}`;
4676
4862
  }
4677
- 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) };
4678
4864
  const decision = authorizeConfigLeaf(input);
4679
4865
  if (!decision.allowed) {
4680
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" : ""}`;
@@ -4854,7 +5040,7 @@ export async function startDaemon(opts: DaemonOptions) {
4854
5040
  /** For the dashboard: the rows, and until when this session may raise. A project whose files do not read still gets its dashboard. */
4855
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.
4856
5042
  function settingsSnapshot(session?: DashboardSession): { settings?: Record<string, unknown> } {
4857
- try { return { settings: { ...settingsView(), ...(session?.settings ? { sessionUntil: session.settingsUntil } : {}) } }; }
5043
+ try { return { settings: { ...settingsView(), ...(session?.settings ? { sessionUntil: session.settingsUntil } : {}), ...(configEditor ? { configEdit: configEditor.view() } : {}) } }; }
4858
5044
  catch { return {}; }
4859
5045
  }
4860
5046
  function settingsView(): { rows: SettingRow[]; undo?: string; pendingRestart: { key: string; reason: string }[]; retainedConfig: { key: string; reason: string }[] } {
@@ -4876,7 +5062,7 @@ export async function startDaemon(opts: DaemonOptions) {
4876
5062
  });
4877
5063
  const undo = pendingUndo(opts.stateDir);
4878
5064
  const planner = `In force: ${plannerPeers().join(", ") || "nobody"} (${plannerSource}). Setting it makes that peer the only planner; agents cannot change it.`;
4879
- 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 } : {}) };
4880
5066
  }
4881
5067
  /** The same announcement whatever surface made the change: events.jsonl, hub.log and every open console. */
4882
5068
  const recordSetting = (key: string, from: SettingValue, to: SettingValue, source: "dashboard" | "terminal", undo = false, reason?: string) => {
@@ -5114,6 +5300,46 @@ export async function startDaemon(opts: DaemonOptions) {
5114
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."] };
5115
5301
  }
5116
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
+
5117
5343
  async function uiAction(a: Record<string, unknown>, session?: DashboardSession): Promise<unknown> {
5118
5344
  const bad = { ok: false, error: "invalid or unavailable dashboard action" };
5119
5345
  // #269: a session opened by `ahub ui --settings` may raise authority while its window lasts; any other may not.
@@ -5199,6 +5425,11 @@ export async function startDaemon(opts: DaemonOptions) {
5199
5425
  return a.undo === true ? settingsUndoPreview() : settingsPreview(a.key, a.value);
5200
5426
  case "setting_reload":
5201
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);
5202
5433
  default:
5203
5434
  return bad;
5204
5435
  }
@@ -5245,6 +5476,7 @@ export async function startDaemon(opts: DaemonOptions) {
5245
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";
5246
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";
5247
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";
5248
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";
5249
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";
5250
5482
  return undefined;
@@ -5366,11 +5598,8 @@ export async function startDaemon(opts: DaemonOptions) {
5366
5598
  }
5367
5599
 
5368
5600
  function claudePermissionLaunch(peer: string): { launchId: string; unattended: boolean; hookPurpose?: string } | undefined {
5369
- try {
5370
- const row = JSON.parse(readFileSync(join(opts.stateDir, claudeStateFile("claude-launch", peer)), "utf8"));
5371
- if (row.instanceId !== instanceId || row.permissionHook !== true || typeof row.launchId !== "string" || !row.launchId) return undefined;
5372
- return { launchId: row.launchId, unattended: row.unattended === true, ...(typeof row.hookPurpose === "string" ? { hookPurpose: row.hookPurpose } : {}) };
5373
- } catch { return undefined; }
5601
+ const row = readClaudeLaunchProof(opts.stateDir, peer, instanceId);
5602
+ return row?.permissionHook ? row : undefined;
5374
5603
  }
5375
5604
  async function changePermission(peer: unknown, mode: unknown, confirmed: unknown): Promise<Record<string, unknown>> {
5376
5605
  if (!isSupportedPeer(peer)) return { ok: false, error: "unknown permission peer" };
@@ -5480,7 +5709,10 @@ export async function startDaemon(opts: DaemonOptions) {
5480
5709
 
5481
5710
  function onMessage(sock: Sock, msg: any): void {
5482
5711
  const c = sock.data;
5483
- 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
+ };
5484
5716
  if (!c.authed) {
5485
5717
  if (msg.t !== "hello" || msg.token !== token) return sock.close(4401, "bad token");
5486
5718
  if (msg.v !== PROTOCOL) {
@@ -5501,6 +5733,13 @@ export async function startDaemon(opts: DaemonOptions) {
5501
5733
  c.peer = String(msg.peer ?? "");
5502
5734
  if (!PEER_ID.test(c.peer) || c.peer === USER || c.peer === "hub") return sock.close(4403, "peer id is reserved or malformed");
5503
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
+ }
5504
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
5505
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.
5506
5745
  if (definitionOf(c.peer)?.start.daemonPort === "owned-acp") {
@@ -5517,8 +5756,8 @@ export async function startDaemon(opts: DaemonOptions) {
5517
5756
  if (definitionOf(c.peer)?.transport === "claude-channel" && msg.launchId !== undefined) {
5518
5757
  let currentLaunch: unknown;
5519
5758
  try {
5520
- const launch = JSON.parse(readFileSync(join(opts.stateDir, claudeStateFile("claude-launch", c.peer)), "utf8"));
5521
- 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;
5522
5761
  } catch { /* No current launch evidence: retain plain/pre-daemon attachment compatibility. */ }
5523
5762
  if (currentLaunch !== undefined && msg.launchId !== currentLaunch) return sock.close(4430, "Claude launch ended; start a new session with ahub claude");
5524
5763
  if (typeof msg.launchId === "string" && msg.launchId.length <= 128) c.launchId = msg.launchId;
@@ -5534,6 +5773,17 @@ export async function startDaemon(opts: DaemonOptions) {
5534
5773
  preparedSessionRoles.delete(c.peer); // an external transport has no receipt for this daemon's native preamble
5535
5774
  preparedSessionRoleIdentities.delete(c.peer);
5536
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
+ }
5537
5787
  if (kimiPull) { clearTimeout(kimiAdmissionExpiry); kimiAdmissionExpiry = undefined; c.kimiAdmission = kimiAdmissionId; }
5538
5788
  if (definitionOf(c.peer)?.transport === "claude-channel") logPermissionStart(c.peer);
5539
5789
  writeStatus(); // the claim has to be visible before the preface, or a standing-by session takes the id back
@@ -5548,7 +5798,24 @@ export async function startDaemon(opts: DaemonOptions) {
5548
5798
  }
5549
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" });
5550
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" });
5551
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
+ }
5552
5819
  case "permission_default": {
5553
5820
  if (typeof msg.peer === "string" && rosterChanging === msg.peer) return void reply({ ok: false, error: `${msg.peer} roster change is in progress` });
5554
5821
  if (c.role !== "console") return void reply({ ok: false, error: "permission default confirmation is a human console action" });
@@ -5624,6 +5891,18 @@ export async function startDaemon(opts: DaemonOptions) {
5624
5891
  return void reply({ ok: true });
5625
5892
  }
5626
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
+ }
5627
5906
  // The attached session can explicitly claim queued work even during a native turn (#493).
5628
5907
  // Pull and push share holds and journal settlement; work read here is completed, never pushed again.
5629
5908
  const peer = c.peer ? bus.peers.get(c.peer) : undefined;
@@ -5792,6 +6071,21 @@ export async function startDaemon(opts: DaemonOptions) {
5792
6071
  };
5793
6072
  try {
5794
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
+ }
5795
6089
  let nativeClaude = false;
5796
6090
  if (definitionOf(peer)?.transport === "claude-channel" && sessionId && msg.nativeInstanceId === instanceId) {
5797
6091
  let expectedLaunch: string | undefined;
@@ -5800,8 +6094,8 @@ export async function startDaemon(opts: DaemonOptions) {
5800
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;
5801
6095
  } catch { /* an ordinary terminal has no Orca recovery identity */ }
5802
6096
  try {
5803
- const launch = JSON.parse(readFileSync(join(opts.stateDir, claudeStateFile("claude-launch", peer)), "utf8"));
5804
- 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;
5805
6099
  } catch { /* older/manual launches do not have a launcher marker */ }
5806
6100
  const current = claudeSession(peer);
5807
6101
  if ((phase === "stop" || phase === "post") && current.sessionId && current.sessionId !== sessionId) return void reply({ t: "facts", ok: false });
@@ -5818,10 +6112,14 @@ export async function startDaemon(opts: DaemonOptions) {
5818
6112
  if (definitionOf(peer)?.transport === "claude-channel" && msg.nativeInstanceId !== undefined && msg.nativeInstanceId !== instanceId) return void reply({ t: "facts", ok: false });
5819
6113
  const nativePeer = nativeClaude ? bus.peers.get(peer) : undefined;
5820
6114
  const permissionLaunch = definitionOf(peer)?.transport === "claude-channel" ? claudePermissionLaunch(peer) : undefined;
5821
- 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;
5822
6118
  if (permission !== undefined && isWsPeer(nativePeer)) claudePermissionGenerations.set(peer, nativePeer.sessionGeneration);
5823
- const protectedWrite = scopedWriteBlocked(phase, permission, msg.tool, () => projectFileTool(msg.tool, msg.input, opts.cwd, config.protect_instruction_files, config.data?.readonly));
5824
- 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 } : {}) } : {};
5825
6123
  // Permission-only hooks carry no native-turn evidence and must never drive busy state.
5826
6124
  const purpose = permissionLaunch?.hookPurpose ?? msg.hookPurpose;
5827
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")));
@@ -6069,7 +6367,7 @@ export async function startDaemon(opts: DaemonOptions) {
6069
6367
  catch (error) { return void reply({ ok: false, error: (error as Error).message }); }
6070
6368
  case "settings_get":
6071
6369
  if (c.role !== "console") return void reply({ ok: false, error: "settings are a person's: run ahub settings in a terminal" });
6072
- 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 }; })() });
6073
6371
  case "settings_reload":
6074
6372
  if (c.role !== "console") return void reply({ ok: false, error: "settings are a person command" });
6075
6373
  return void reloadConfig("terminal", undefined, msg.confirm).then(reply, () => reply({ ok: false, error: "configuration reload could not be confirmed; inspect hub.log" }));
@@ -6083,6 +6381,16 @@ export async function startDaemon(opts: DaemonOptions) {
6083
6381
  case "settings_undo":
6084
6382
  if (c.role !== "console") return void reply({ ok: false, error: "settings are a person's: run ahub settings in a terminal" });
6085
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;
6086
6394
  case "kimi_launch_end": {
6087
6395
  if (c.role !== "console") return void reply({ ok: false, error: "Kimi launch cleanup is console only" });
6088
6396
  if (typeof msg.admissionId === "string" && msg.admissionId === kimiAdmissionId) clearKimiAdmission();
@@ -6132,8 +6440,9 @@ export async function startDaemon(opts: DaemonOptions) {
6132
6440
  });
6133
6441
  return;
6134
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" });
6135
6444
  if (recoveryActive() && recoveryPhase !== "preparing" && String(msg.op) !== "hub_checkpoint") return void reply({ t: "task", ok: false, error: "recovery is holding mutations" });
6136
- 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(
6137
6446
  (text) => reply({ t: "task", ok: true, text }),
6138
6447
  (e: Error) => reply({ t: "task", ok: false, error: e.message }),
6139
6448
  );
@@ -6393,7 +6702,7 @@ export async function startDaemon(opts: DaemonOptions) {
6393
6702
  // After a crash that recorded Pi, crash recovery starts it (#66): its recorded session first, a fresh one if that fails.
6394
6703
  const piRecovered = !archivedPiContinuity && !!crashed?.peers.some((p) => definitionOf(p.peer)?.session.restoreOnStart);
6395
6704
  if (piAutoStart() && !recoveryActive() && !piRecovered) autoStartPi();
6396
- return { bus, token, port: server.port as number, bandEvaluations: () => bandEvaluations, 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)) };
6397
6706
  } finally {
6398
6707
  if (!ready) for (const cleanup of startupCleanup.reverse()) { try { await cleanup(); } catch { /* preserve startup error */ } }
6399
6708
  }