@staix/agent-hub 0.18.0 → 0.20.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 (90) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +4 -4
  3. package/docs/agent-notes/README.md +6 -0
  4. package/docs/agent-notes/adapters.md +13 -10
  5. package/docs/agent-notes/bus.md +1 -1
  6. package/docs/agent-notes/daemon.md +11 -4
  7. package/docs/agent-notes/local-worker.md +3 -1
  8. package/docs/agent-notes/tasks.md +6 -1
  9. package/docs/agent-notes/tests.md +8 -4
  10. package/docs/dogfood.md +13 -4
  11. package/docs/memory.md +1 -1
  12. package/docs/operations.md +98 -28
  13. package/docs/quickstart.md +13 -5
  14. package/docs/sdlc.md +12 -0
  15. package/docs/security.md +24 -15
  16. package/docs/smoke.md +3 -3
  17. package/docs/specs/2026-09-19-agent-hub-design.md +101 -8
  18. package/docs/verification/2026-10-25-remote-docker-transport.md +10 -0
  19. package/docs/verification/2026-10-26-agy-1.4.7.md +69 -0
  20. package/docs/verification/2026-10-26-claude-implementer-2.1.310.md +69 -0
  21. package/docs/verification/2026-10-26-headless-claude-shell-context.md +62 -0
  22. package/docs/verification/2026-10-26-jcode-0.99.1.md +49 -0
  23. package/docs/verification/2026-10-26-kimi-232-reviewer-compat.md +53 -0
  24. package/docs/verification/2026-10-26-pi-140-compat.md +41 -0
  25. package/docs/verification/2026-10-26-qwen-0261-acp.md +60 -0
  26. package/docs/verification/2026-10-27-0.19.0.md +86 -0
  27. package/docs/verification/2026-10-27-0.20.0.md +75 -0
  28. package/docs/verification/claude-permission-transport-native.py +187 -0
  29. package/docs/verification/claude-shell-wrapper-host.ts +20 -0
  30. package/docs/verification/claude-shell-wrapper-native.py +145 -0
  31. package/docs/verification/pi-140-model-free.ts +57 -0
  32. package/docs/verification/pi-140-native.mjs +21 -0
  33. package/docs/verified.json +25 -17
  34. package/package.json +2 -2
  35. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  36. package/plugins/agent-hub/server.js +373 -72
  37. package/src/adapters/acp.ts +8 -0
  38. package/src/adapters/agy/entry.ts +1 -1
  39. package/src/adapters/channel-peer.ts +7 -5
  40. package/src/adapters/claude-channel.ts +7 -3
  41. package/src/adapters/claude-implementer.ts +6 -3
  42. package/src/adapters/codex-appserver.ts +11 -1
  43. package/src/adapters/compat.ts +32 -18
  44. package/src/adapters/conformance/enrollment.ts +4 -4
  45. package/src/adapters/conformance/factories.ts +2 -1
  46. package/src/adapters/conformance/fakes/pi-rpc.ts +1 -0
  47. package/src/adapters/conformance/kit.ts +10 -0
  48. package/src/adapters/definitions/builtin-metadata.ts +2 -2
  49. package/src/adapters/definitions/native-factories.ts +7 -2
  50. package/src/adapters/definitions/reviewer-variants.ts +2 -2
  51. package/src/adapters/local-worker.ts +2 -0
  52. package/src/adapters/pi.ts +46 -3
  53. package/src/adapters/qwen-dual.ts +25 -0
  54. package/src/adapters/registry-types.ts +6 -2
  55. package/src/adapters/registry.ts +1 -0
  56. package/src/adapters/reviewer-print.ts +1 -0
  57. package/src/cli/agents.ts +69 -7
  58. package/src/cli/claude-shell-wrapper.ts +54 -0
  59. package/src/cli/main.ts +30 -24
  60. package/src/cli/output.ts +4 -0
  61. package/src/hub/agent-drift.ts +61 -0
  62. package/src/hub/board.ts +14 -2
  63. package/src/hub/check-runner.ts +148 -18
  64. package/src/hub/ci-watch.ts +140 -0
  65. package/src/hub/claude-launch-proof.ts +67 -2
  66. package/src/hub/claude-shell-wrapper.ts +349 -0
  67. package/src/hub/config-apply.ts +2 -2
  68. package/src/hub/config-edit.ts +2 -2
  69. package/src/hub/config-tier.ts +2 -2
  70. package/src/hub/config-trust.ts +1 -0
  71. package/src/hub/daemon.ts +282 -113
  72. package/src/hub/host-executable.ts +20 -1
  73. package/src/hub/instances.ts +18 -2
  74. package/src/hub/lifecycle.ts +1 -1
  75. package/src/hub/operational-authority.ts +7 -0
  76. package/src/hub/operational-view.ts +6 -10
  77. package/src/hub/peers.ts +34 -0
  78. package/src/hub/read-only-shell-proof.ts +7 -6
  79. package/src/hub/review-scope.ts +13 -0
  80. package/src/hub/sdlc-metrics.ts +6 -4
  81. package/src/hub/settings.ts +1 -0
  82. package/src/hub/task-shell-command.ts +85 -0
  83. package/src/hub/task-shell-default.ts +79 -0
  84. package/src/hub/task-shell-proof.ts +171 -0
  85. package/src/hub/task-shell-sandbox.ts +61 -0
  86. package/src/hub/tasks.ts +274 -16
  87. package/src/local/deny.ts +112 -11
  88. package/src/local/sandbox.ts +70 -29
  89. package/src/local/shell-environment.ts +10 -0
  90. package/src/ui/index.html +1 -0
package/src/hub/daemon.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { operationalRosterSetting, operationalSetting, operationalRefusal, operationalRequestLabel } from "./operational-authority.ts";
1
+ import { OperationalAuthorityError, OPERATIONAL_PERSON_GUIDANCE, operationalRosterSetting, operationalSetting, operationalRefusal, operationalRequestLabel } from "./operational-authority.ts";
2
2
  import { operationalView, redactBusEvent } from "./operational-view.ts";
3
3
  import { inspectSelectedOrphans, stopSelectedOrphan, orphanProcessPorts } from "./orphan-process.ts";
4
4
  import { dogfoodLines } from "./dogfood-read.ts";
@@ -8,7 +8,7 @@ import { canonicalMemorySymbolNode, resolveMemorySymbolNodes, type MemorySymbolN
8
8
  import { AsyncLocalStorage } from "node:async_hooks";
9
9
  import { normalizeUsage } from "../omniroute/usage.ts";
10
10
  import { permissionPayload } from "./permission-payload.ts";
11
- import { readClaudeLaunchProof } from "./claude-launch-proof.ts";
11
+ import { createClaudeLaunchProof, readClaudeLaunchProof } from "./claude-launch-proof.ts";
12
12
  import { InboxPolling } from "./inbox-polling.ts";
13
13
  import { ActionBudgets, actionPolicyConfig, type ActionPolicyConfig } from "./action-policy.ts";
14
14
  import { actionGate, claudeActionClasses } from "./action-enforce.ts";
@@ -22,7 +22,8 @@ import { NativeHookLaunches } from "./native-hook-launch.ts";
22
22
  import { readReleaseHostAdmission, type ReleaseHostAdmission } from "./release-admission.ts";
23
23
  import { ContainerRunner, RemoteRunner, remoteRunnerConfig, type RemoteRunnerConfig, type RunnerOptions, type RunReceipt } from "./check-runner.ts";
24
24
  import { fetchSpec, specAvailable, specRefs } from "./spec-read.ts";
25
- import { isDenied } from "../local/deny.ts";
25
+ import { isDenied, sbplString } from "../local/deny.ts";
26
+ import { hostShellPath } from "./host-executable.ts";
26
27
  import { supersededDeliveryReason, deliveryTaskContext } from "./delivery-superseded.ts";
27
28
  import { renderHeldHint } from "./delivery-hints.ts";
28
29
  import { correctionInput, MemoryTelemetry } from "./memory-candidates.ts";
@@ -42,7 +43,9 @@ import { profileName, projectProfile, resolveProfile, mergeProfileOverrides, typ
42
43
  import { catalogAssignments, loadRoleCatalog, selectedRoles, roleIdentity } from "./roles.ts";
43
44
  import { ActivityRing } from "./activity.ts";
44
45
  import { JcodeOptionMismatch, isJcodePeer, jcodeVersionAsync, type JcodePeer } from "../adapters/jcode.ts";
45
- import { VERSION_REFUSAL, describe, isCompatId } from "../adapters/compat.ts";
46
+ import { VERSION_REFUSAL, configuredCommands, describe, isCompatId } from "../adapters/compat.ts";
47
+ import { agentVersionsAsync, type AgentVersionRow } from "../cli/agents.ts";
48
+ import { driftedAgents, noticeDrift, type DriftedAgent } from "./agent-drift.ts";
46
49
  import { jcodeMcpReady } from "../cli/jcode-mcp.ts";
47
50
  import { loadSdlc, readSpecEdit, sdlcGh } from "./sdlc.ts";
48
51
  import { claudePeer as defaultChannelPeer, claudeStateFile, ownTerminalRow, pathEvidence, transcriptPersisted } from "./claude-state.ts";
@@ -52,6 +55,7 @@ import { RecoveryDeferredHubError } from "./deferred-hub.ts";
52
55
  import { databaseBusySite, isDatabaseBusy, RecoveryDatabaseBusy } from "./recovery-error.ts";
53
56
  import { ADAPTERS, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS, QUOTA_HANDOFF_PLATFORMS, NATIVE_CONFIG_BLOCKS, nativeConfigurationDefaults, mergeNativeConfiguration, retainNativeRouting, DAEMON_PORTS, definitionOf, createProjectAdapterCatalog, isFixedBuiltinDefinition } from "../adapters/registry.ts";
54
57
  import { isSupportedPeer as registrySupportedPeer, definitionOf as builtinDefinitionOf, KNOWN_PEERS as BASE_KNOWN_PEERS, SUPPORTED_PEERS as BASE_SUPPORTED_PEERS, STARTABLE_PEERS as BASE_STARTABLE_PEERS } from "../adapters/registry.ts";
58
+ import type { ReviewScope } from "./review-scope.ts";
55
59
  import { checkJcodeInstanceHomes, instanceDefinitionOf, effectiveInstanceRoles, instancePeerIds, validateInstances, type InstancesConfig } from "./instances.ts";
56
60
  import { INSTANCE_CONFIG_FILES, readInstanceConfig, readInstanceConfigLayers, readInstanceRoleConfig } from "./instance-config.ts";
57
61
  import { instanceBudgetHooks, projectBudgetHeadroom } from "./instance-budget.ts";
@@ -82,7 +86,7 @@ import { claudeReportedTokens, readClaudeTranscriptCompletion, readClaudeTranscr
82
86
  import type { ServerWebSocket } from "bun";
83
87
  import { isAcpPeer, type AcpPeer, QwenIdentityError, ACTION_BUDGET_OPTION, type PermissionRequest, type ActionGateVerdict } from "../adapters/acp.ts";
84
88
  import { isCodexPeer, type CodexPeer } from "../adapters/codex-appserver.ts";
85
- import { isPiPeer, type PiPeer } from "../adapters/pi.ts";
89
+ import { isPiPeer, PiVersionRefusal, type PiPeer } from "../adapters/pi.ts";
86
90
  import { isQwenDualPeer, qwenDualVersionAsync, type QwenDualPeer } from "../adapters/qwen-dual.ts";
87
91
  import { startModelRelay, type ModelRelay } from "../models/relay.ts";
88
92
  import { ensureMlx, type MlxOptions } from "../models/mlx.ts";
@@ -90,10 +94,15 @@ import { PiToolReceipts, packPiShellResult } from "../pi/tool-receipts.ts";
90
94
  import { PreEffectToolRefusal } from "./tool-refusal.ts";
91
95
  import { processSignature } from "../pi/process-signature.ts";
92
96
  import { PiSessionBookmarkMismatch, PiSessionUnresumable, recordPiReplacement, validatePiSession } from "../pi/session.ts";
93
- import { profile, proxyEnv, type SandboxNetwork } from "../local/sandbox.ts";
97
+ import { profile, proxyEnv, sandboxAvailable, type SandboxNetwork } from "../local/sandbox.ts";
94
98
  import { DEFAULT_NETWORK_ALLOW, startEgressProxy, type EgressProxy } from "../local/proxy.ts";
95
99
  import { runTool, toolResultFailed, TOOL_SCHEMAS, type ToolApproval, type ApprovalObserver, type ToolContext, type ShellPermitPolicy } from "../local/tools.ts";
96
100
  import { proveReadOnlyShell, ShellExecutionProofs } from "./read-only-shell-proof.ts";
101
+ import { createHostShellWrapper, type HostShellWrapper } from "./claude-shell-wrapper.ts";
102
+ import { readTaskShellDefaultBranch, readTaskShellBinding, proveTaskShell } from "./task-shell-proof.ts";
103
+ import { taskShellSandboxRestrictions } from "./task-shell-sandbox.ts";
104
+ import { hostShellEnvironment } from "../local/shell-environment.ts";
105
+ import { hubGitArgv, hubGitEnv } from "./git.ts";
97
106
  import { isLocalPeer, type LocalPeer } from "../adapters/local-worker.ts";
98
107
  import { isReviewerPrintPeer, reviewerNoVerdictLine, reviewerUnavailableReason, ReviewerUnavailableError, REVIEWER_FALLBACK_UNCONFIRMED, type ReviewerPrintPeer } from "../adapters/reviewer-print.ts";
99
108
  import { createReviewerMcpSession, type ReviewerToolName } from "../adapters/reviewer-mcp.ts";
@@ -131,7 +140,7 @@ import { startDashboard, dashboardView, type DashboardSession } from "./ui.ts";
131
140
  import { codeGroup } from "./task-code-group.ts";
132
141
  import { DAEMON_FEATURES, PROTOCOL, readControl, stateDirFor } from "./control-client.ts";
133
142
  import { MAX_HOP, newEnvelope, parseMarker, replyParent, sanitize, USER, type Envelope, type PeerId, type Priority } from "./envelope.ts";
134
- import { BasePeer, DEFAULT_WATCHDOG_MS, type PeerAdapter } from "./peers.ts";
143
+ import { BasePeer, DEFAULT_WATCHDOG_MS, type FrontendProxyPort, type PeerAdapter } from "./peers.ts";
135
144
  import { MemoryClient } from "../memory/client.ts";
136
145
  import { VERSION } from "../version.ts";
137
146
  import { appendRecords, taskRecords } from "./research.ts";
@@ -208,7 +217,7 @@ export interface HubConfig {
208
217
  /** Pending permission requests: how long they wait, and whether the desktop is told (issue #5). */
209
218
  approvals: { timeout_s: number; notify: boolean };
210
219
  /** An owner offline this long loses its open tasks back to routing; 0 turns it off (issue #6). */
211
- tasks: { release_after_min: number; wip_limits?: Record<string, number>; escalate_after: number };
220
+ tasks: { release_after_min: number; wip_limits?: Record<string, number>; escalate_after: number; /** #785 P1c: pull the top explicitly reserved ready card when a slot frees; machine-local, default off. */ auto_pull?: boolean };
212
221
  /** Between-turn task escalation, disabled unless explicitly enabled (#186). */
213
222
  task_sweep: TaskSweepConfig;
214
223
  /** A command per task class run when the owner marks the task done, and its timeout (issue #7). */
@@ -269,7 +278,7 @@ export const DEFAULT_CONFIG: HubConfig = {
269
278
  ...nativeDefaults.sandbox,
270
279
  // Off here, so tests and a hub without a config file stay silent; a project's config defaults it on for macOS.
271
280
  approvals: { timeout_s: 120, notify: false },
272
- tasks: { release_after_min: 30, escalate_after: ESCALATE_AFTER },
281
+ tasks: { release_after_min: 30, escalate_after: ESCALATE_AFTER, auto_pull: false },
273
282
  task_sweep: DEFAULT_TASK_SWEEP,
274
283
  checks: { timeout_s: 600 },
275
284
  // Off here like approvals.notify, so tests (whose cwd is this repository) write no objects; a project's config turns it on.
@@ -523,11 +532,15 @@ function mlxPinProblem(routing: Routing, project: boolean): string | undefined {
523
532
  export interface DaemonOptions {
524
533
  /** Host-only test/embedding port; never read from project configuration or a tool. */
525
534
  memoryClient?: MemoryClient;
535
+ /** Host-only remote-default evidence seam; never read from config or control messages. */
536
+ shellDefaultBranchProbe?: typeof readTaskShellDefaultBranch;
526
537
  /** Host-only snapshot/probe seams; never read from project config or tool arguments. */
527
538
  snapshot?: typeof snapshot;
528
539
  snapshotBurstMs?: number;
529
540
  /** Host-only (#661): the clock the shared snapshot Git budget reads (default performance.now). A test holds it fixed so a slow runner cannot spend the 250 ms; production never sets it and no config key or tool reaches it. */
530
541
  snapshotClock?: () => number;
542
+ /** Host-only bounds of the per-head CI watcher (#785 P1b); tests shorten them, never read from config or a tool. */
543
+ ciWatch?: { deadlineMs?: number; pollMs?: number };
531
544
  checkoutBranch?: typeof mainCheckoutBranch;
532
545
  /** Host-only admission transport; never selected by project config or tools. */
533
546
  releaseHostAdmission?: (cwd: string) => Promise<ReleaseHostAdmission>;
@@ -558,6 +571,13 @@ export interface DaemonOptions {
558
571
  taskSweepNow?: () => number;
559
572
  /** Test seam: the period of the SDLC band evaluation, default five minutes. */
560
573
  bandsEveryMs?: number;
574
+ /**
575
+ * #843 AC3: read each gated agent CLI's installed version at start and daily, and tell the planner about drift.
576
+ * `true` uses the same probe as `ahub agents` (the product sets it); a function replaces the probe (tests). Off by default.
577
+ */
578
+ agentDrift?: boolean | (() => Promise<readonly AgentVersionRow[]>);
579
+ /** Test seam: the period of the agent drift sweep, default one day. */
580
+ agentDriftEveryMs?: number;
561
581
  /** Test seam: an extra hold on the SDLC band evaluation, like a storage error. */
562
582
  bandsHeld?: () => boolean;
563
583
  /** How long a research record waits after an approval for the task's open turns to end (#247). Tests shrink this. */
@@ -720,12 +740,12 @@ export async function startDaemon(opts: DaemonOptions) {
720
740
  mkdirSync(opts.stateDir, { recursive: true });
721
741
  const operationalLogSubscribers=new Map<Sock,()=>void>();
722
742
  const operationalContext=new AsyncLocalStorage<{actor:string;current:()=>void}|undefined>();
723
- const checkOperational=(authority:SettingAuthority)=>{if(authority==="conductor"){const scope=operationalContext.getStore();if(!scope)throw new Error("operational conductor authority unavailable");scope.current();}};
743
+ const checkOperational=(authority:SettingAuthority)=>{if(authority==="conductor"){const scope=operationalContext.getStore();if(!scope)throw new OperationalAuthorityError("operational conductor authority unavailable");scope.current();}};
724
744
  const logFile = join(opts.stateDir, "hub.log");
725
745
  // The state dir can vanish under a running hub (issue #56): a log line must never take a handler down with it.
726
746
  const log = (line: string) => { const text=`${new Date().toISOString()} ${line}`;try { appendFileSync(logFile, `${text}\n`); } catch { /* the state dir is gone; the watchdog is stopping the hub */ }
727
- let publicLine:string|undefined;
728
- for(const [sock,current] of operationalLogSubscribers)try {current();if(sock.getBufferedAmount()>64*1024)throw new Error("slow log consumer");publicLine??=publicOperationalView().line(text);sock.send(JSON.stringify({t:"operational_log",line:publicLine}));}catch{operationalLogSubscribers.delete(sock);try{sock.close(4403,"operational log authority ended");}catch{/* The subscription is already gone. */}}
747
+ let publicLine:string|undefined,view:ReturnType<typeof operationalView>|undefined;
748
+ for(const [sock,current] of operationalLogSubscribers)try {current();if(sock.getBufferedAmount()>64*1024)throw new Error("slow log consumer");view??=publicOperationalView();publicLine??=view.line(text);sock.send(JSON.stringify({t:"operational_log",line:publicLine,complete:view.complete,...(!view.complete?{incompleteReason:view.incompleteReason}:{})}));}catch{operationalLogSubscribers.delete(sock);try{sock.close(4403,"operational log authority ended");}catch{/* The subscription is already gone. */}}
729
749
  };
730
750
  let runningRouting = currentRouting(opts.cwd, log);
731
751
  let acceptedSources = parseConfigSources(configSourceTexts(opts.cwd), runningRouting, opts.cwd, externalAdapters);
@@ -1021,13 +1041,24 @@ export async function startDaemon(opts: DaemonOptions) {
1021
1041
  if (refused) log(`limits: ${env.from}: ${refused}`);
1022
1042
  return refused;
1023
1043
  };
1044
+ /**
1045
+ * #852: what a reviewer reads. A fresh-reviewer instance's own `review_scope` (config.local.json only, tier 3) wins, else its definition's
1046
+ * scope (the sandboxed codex and kimi reviewers are `code`), else `full`. Only a review-only instance has one; any other peer is `full`.
1047
+ */
1048
+ const reviewScopeOf = (peer: string): ReviewScope => {
1049
+ const declared = Object.hasOwn(declarations, peer) ? declarations[peer]!.review_scope : undefined;
1050
+ if (declared) return declared;
1051
+ return reviewOnlyPeer(peer) && definitionOf(peer)?.reviewer?.scope === "code" ? "code" : "full";
1052
+ };
1024
1053
  /**
1025
1054
  * #685: the provider, model and profile names an extra Jcode instance was declared with, for status, doctor and the dashboard,
1026
1055
  * exactly as the person wrote them (they authenticate inside Jcode). Never a URL, a token or the native home path.
1027
1056
  */
1028
1057
  const jcodeModelSource = (id: string): { modelSource?: string } => {
1029
1058
  const instance = declarations[id];
1030
- if (!instance || definitionOf(id)?.instances?.nativeHome !== true) return {};
1059
+ if (!instance) return {};
1060
+ // #844: a model exists on an instance only where validation admitted it (a headless implementer whose definition declares modelSelect).
1061
+ if (definitionOf(id)?.instances?.nativeHome !== true) return instance.model ? { modelSource: instance.model } : {};
1031
1062
  return { modelSource: [instance.provider, instance.model, instance.provider_profile && `profile ${instance.provider_profile}`].filter(Boolean).join(" ") || "native default" };
1032
1063
  };
1033
1064
  /**
@@ -1051,6 +1082,11 @@ export async function startDaemon(opts: DaemonOptions) {
1051
1082
  const bus = new Bus({ rostered: membershipAllowed, deliveryHeld: peer => requestedPeerStops.has(peer) || rosterChanging === peer, journal, batchMax: config.batch_max, batchMs: config.batch_ms, queueCap: config.queue_cap, condense: (envs) => inference?.condense(envs) ?? Promise.resolve(envs), admit, relevant: (peer, env) => reviewOnlyPeer(peer) && !reviewOnlyItem(peer, env) ? `not for ${peer}: a review-only instance receives review requests only` : env.refs?.boundedCheck || env.refs?.supervision || env.refs?.backlog ? relevantNotice(peer, env) : staleOff() || relevantNotice(peer, env), silence,
1052
1083
  onImportantQueued: (peer, counts) => { const p = bus.peers.get(peer); if (isWsPeer(p)) p.notifyQueued(counts); } });
1053
1084
  startupCleanup.push(() => bus.closeJournal());
1085
+ const claudeLaunchProof = createClaudeLaunchProof({ stateDir: opts.stateDir, cwd: opts.cwd, instanceId, definitionOf, peer: peer => bus.peers.get(peer) });
1086
+ function frontendProxyState(peer: string, owner: PeerAdapter | undefined): { present: boolean; port: FrontendProxyPort | undefined } {
1087
+ if (!owner || !isFixedBuiltinDefinition(definitionOf(peer)) || !("frontendProxy" in owner)) return { present: false, port: undefined };
1088
+ return { present: true, port: owner.frontendProxy };
1089
+ }
1054
1090
  const manualPaused = new Set<PeerId>(bus.manualPausedPeers()); // recovery never lifts an operator's pause
1055
1091
  const conductorHolds = new ConductorHolds(join(opts.stateDir, "hub.db"));
1056
1092
  startupCleanup.push(() => conductorHolds.close());
@@ -1591,6 +1627,7 @@ export async function startDaemon(opts: DaemonOptions) {
1591
1627
  instances: () => declarations,
1592
1628
  adapterCatalog,
1593
1629
  reviewOnly: reviewOnlyPeer,
1630
+ reviewScope: reviewScopeOf,
1594
1631
  // #223: a headless reviewer's verdict holds only while the same child it started with still runs for that task.
1595
1632
  reviewAuthority: runningReviewAuthority,
1596
1633
  requestAuthority: by => {
@@ -1700,9 +1737,11 @@ export async function startDaemon(opts: DaemonOptions) {
1700
1737
  review: config.review,
1701
1738
  sweep: taskSweepConfig(config.task_sweep),
1702
1739
  sweepHeld: () => stopping || recoveryActive(),
1740
+ ciWatch: opts.ciWatch,
1741
+ autoPull: () => config.tasks.auto_pull === true,
1703
1742
  cancellationHeld: () => stopping || recoveryActive(),
1704
1743
  offerRead: (peer, envelopeId) => journal.offerRead(peer, envelopeId),
1705
- offerPullOnly: (id) => { const peer = bus.peers.get(id); return isWsPeer(peer) && peer.pullOnly; },
1744
+ offerPullOnly: (id) => { const peer = bus.peers.get(id); return peer?.pullOnly === true; },
1706
1745
  stallNotice: stall => { supervision.taskStalled(stall); },
1707
1746
  roles: config.roles,
1708
1747
  turnFree,
@@ -1981,9 +2020,8 @@ export async function startDaemon(opts: DaemonOptions) {
1981
2020
  const p = bus.peers.get(peer);
1982
2021
  if (isWsPeer(p) && p.claiming) return undefined;
1983
2022
  if (definitionOf(peer)?.transport === "claude-channel") { const c = claudeSession(peer); return c.sessionId ? `${c.sessionId}:${c.launchId ?? ""}` : undefined; }
1984
- if (isCodexPeer(p)) return p.thread || undefined;
1985
- if (isAcpPeer(p) || isPiPeer(p)) { const id = p.recoveryMetadata().sessionId; return typeof id === "string" ? id : undefined; }
1986
- return undefined;
2023
+ const session = p?.nativeSession;
2024
+ return typeof session === "string" ? session : undefined;
1987
2025
  };
1988
2026
  const activityRings: Record<string, ActivityRing> = Object.fromEntries(SUPPORTED_PEERS.filter(peer => definitionOf(peer)?.capabilities?.watch).map(peer => [peer, new ActivityRing()]));
1989
2027
  const contextPii = (peer: PeerId) => !!turns.get(peer)?.private || board.list().some(t => !isClosedTaskState(t.state) && (t.owner === peer || t.reviewer === peer) && tasks.isPii(t));
@@ -2273,6 +2311,86 @@ export async function startDaemon(opts: DaemonOptions) {
2273
2311
  const binding = c.toolsOwner, launch = currentImplementerTools(c);
2274
2312
  return () => c.toolsOwner === binding && !!launch && currentImplementerTools(c) === launch;
2275
2313
  }
2314
+ const shellProjectRepository = githubOriginRepository(opts.cwd);
2315
+ const shellNeedsDefault = Object.keys(declarations).some(peer => instanceDefinitionOf(peer, declarations, adapterCatalog)?.start.daemonPort === "claude-implementer");
2316
+ const shellDefaultBranch = shellNeedsDefault && shellProjectRepository
2317
+ ? await (opts.shellDefaultBranchProbe ?? readTaskShellDefaultBranch)(opts.cwd, hostShellEnvironment({ PATH: hostShellPath(opts.cwd, opts.cwd) ?? "" }), () => true, opts.cwd, shellProjectRepository)
2318
+ : undefined;
2319
+ let implementerShellStart: Promise<HostShellWrapper> | undefined;
2320
+ const implementerShellProfiles = new WeakMap<object, { key: string; sandbox: { profile: string; readOnlyData: boolean } }>();
2321
+ async function automaticImplementerShell(c: Client, tool: string, original: Record<string, unknown>, current: () => boolean): Promise<{ command: string; timeout: number; description: string } | undefined> {
2322
+ const peer = c.peer, launch = currentImplementerTools(c), root = peer && ownerWriteRoot(peer), policy = config;
2323
+ if (!peer || !launch || !root || launch.task === undefined || tool !== "Bash" || permissionMode(peer) !== "ask-when-needed") return;
2324
+ if (Object.keys(original).some(key => !["command", "description", "timeout"].includes(key)) || typeof original.command !== "string"
2325
+ || original.description !== undefined && (typeof original.description !== "string" || original.description.length > 2000)
2326
+ || original.timeout !== undefined && (!Number.isSafeInteger(original.timeout) || Number(original.timeout) < 1 || Number(original.timeout) > 600_000)) return;
2327
+ const input = Object.freeze({ ...original }), serialized = JSON.stringify(input);
2328
+ if (serialized.length > 8000 || !tasks.publicMetadataText(serialized)) return;
2329
+ const path = hostShellPath(opts.cwd, root);
2330
+ if (!path) return;
2331
+ const taskId = launch.task, extra = Object.freeze({ ...proxyEnv(sandboxNetwork), AGENTHUB_PEER_ID: peer, PATH: path });
2332
+ const env = hostShellEnvironment(extra), environment = JSON.stringify(env);
2333
+ const policyState = () => JSON.stringify([config.local.deny, config.local.read_allow, config.data?.readonly, config.protect_instruction_files]);
2334
+ const policyFingerprint = policyState();
2335
+ const authority = () => {
2336
+ const task = board.get(taskId);
2337
+ return current() && config === policy && policyState() === policyFingerprint && permissionMode(peer) === "ask-when-needed" && !contextPii(peer)
2338
+ && !!task && task.owner === peer && ["in_progress", "changes_requested"].includes(task.state) && !tasks.isPii(task)
2339
+ && claudeActionClasses(actionBudgets, tool, input, root).length === 0 && hostShellPath(opts.cwd, root) === path && JSON.stringify(hostShellEnvironment(extra)) === environment;
2340
+ };
2341
+ if (!authority()) return;
2342
+ const binding = readTaskShellBinding(root, env, authority, opts.cwd, shellDefaultBranch);
2343
+ if (!binding) return;
2344
+ const shellOptions = { root, projectRoot: opts.cwd, deny: config.local.deny, readOnly: config.data?.readonly, env: extra };
2345
+ const taskOptions = { ...shellOptions, env, binding, protectInstructions: config.protect_instruction_files, repository: shellProjectRepository, publicText: (text: string) => tasks.publicMetadataText(text) };
2346
+ const readProof = proveReadOnlyShell(input, shellOptions, authority), taskProof = readProof ? undefined : proveTaskShell(input.command, taskOptions, authority);
2347
+ const confined = !!readProof || taskProof?.requiresSandbox === true;
2348
+ if (confined && !sandboxAvailable()) return;
2349
+ // The planner's fallback binds only on a task-gate request; read-only requests use an already recorded task branch.
2350
+ if (board.get(taskId)?.refs.branch === undefined && !taskProof) return;
2351
+ if (!readProof && !taskProof || !tasks.bindShellBranch(peer, taskId, { ...binding, worktree: root }, authority)) return;
2352
+ const fingerprint = (readProof ?? taskProof)!.fingerprint;
2353
+ const currentSandbox = () => {
2354
+ if (!confined) return undefined;
2355
+ const state = realPath(opts.stateDir);
2356
+ const owner = c.toolsOwner!.owner, key = JSON.stringify([root, state, policyFingerprint]), cached = implementerShellProfiles.get(owner);
2357
+ if (cached?.key === key) return cached.sandbox;
2358
+ // Cache only this synchronous proof burst: newly initialized submodules change concrete Git-directory protections.
2359
+ // ponytail: shared profile discovery is synchronous; move it to an asynchronous, deadline-bound inventory proof.
2360
+ // read_allow may include the canonical checkout. Its live control credentials remain denied independently of the worktree root.
2361
+ const restrictions = taskShellSandboxRestrictions(root, binding.branch, { projectRoot: opts.cwd, env });
2362
+ if (!restrictions) return undefined;
2363
+ const sandbox = { profile: `${profile(root, sandboxNetwork, config.local.read_allow, config.local.deny, config.data?.readonly)}\n${restrictions}\n(deny file-read* file-write* (subpath ${sbplString(state)}))`, readOnlyData: !!config.data?.readonly?.length };
2364
+ const entry = { key, sandbox };
2365
+ implementerShellProfiles.set(owner, entry);
2366
+ queueMicrotask(() => { if (implementerShellProfiles.get(owner) === entry) implementerShellProfiles.delete(owner); });
2367
+ return sandbox;
2368
+ };
2369
+ let sandbox: { profile: string; readOnlyData: boolean } | undefined;
2370
+ try { sandbox = currentSandbox(); } catch { return; }
2371
+ if (confined && !sandbox) return;
2372
+ const recheck = () => {
2373
+ if (!authority() || !tasks.bindShellBranch(peer, taskId, { ...binding, worktree: root }, authority)) return false;
2374
+ const observed = readTaskShellBinding(root, env, authority, opts.cwd, shellDefaultBranch);
2375
+ if (!observed || observed.branch !== binding.branch || observed.defaultBranch !== binding.defaultBranch) return false;
2376
+ const proof = readProof ? proveReadOnlyShell(input, shellOptions, authority) : proveTaskShell(input.command, taskOptions, authority);
2377
+ let observedSandbox: typeof sandbox;
2378
+ try { observedSandbox = currentSandbox(); } catch { return false; }
2379
+ return proof?.fingerprint === fingerprint && observedSandbox?.profile === sandbox?.profile && observedSandbox?.readOnlyData === sandbox?.readOnlyData && authority();
2380
+ };
2381
+ // A fixed capability on the channel request prevents an older plugin from ignoring updatedInput and running the original command.
2382
+ let wrapper: HostShellWrapper;
2383
+ try { wrapper = await (implementerShellStart ??= createHostShellWrapper().catch(error => { implementerShellStart = undefined; throw error; })); }
2384
+ catch { return; } // An unavailable host capability supplies no allow; the ordinary person path remains available.
2385
+ if (!recheck()) return;
2386
+ const proof = (readProof ?? taskProof)!, git = proof.program === "git";
2387
+ const argv = readProof?.program === "git" ? hubGitArgv([...proof.argv]).slice(1) : proof.argv;
2388
+ const command = wrapper.issue({ peer, task: taskId, generation: launch.generation, root, branch: binding.branch, executable: proof.executable, argv,
2389
+ env: taskProof?.env ?? (git ? Object.fromEntries(Object.entries(hubGitEnv(env, { repositoryIsolation: true })).filter((entry): entry is [string, string] => entry[1] !== undefined)) : env),
2390
+ signal: launch.signal, timeoutMs: Number(input.timeout ?? 120_000), current: recheck, activity: () => c.toolsOwner?.owner.keepApprovalAlive?.(), ...(sandbox ? { sandbox } : {}) });
2391
+ if (command) log(`permission auto-allowed for ${peer}: bash (host wrapper ${readProof ? "read-only shell" : "task command"})`);
2392
+ return command && { command: command.command, timeout: Math.min(600_000, Number(input.timeout ?? 120_000) + 2000), description: "Hub-owned task command" };
2393
+ }
2276
2394
  function canonicalWriteScope(repo: Repo, budget?: SnapshotBudget): boolean | undefined {
2277
2395
  // ponytail: D1 is inert for builtin owners while .worktrees exists (a deliberate false negative).
2278
2396
  // Bind each owner to its real execution worktree before narrowing this unknown scope.
@@ -2376,10 +2494,36 @@ export async function startDaemon(opts: DaemonOptions) {
2376
2494
  } catch { /* The audit event is retained even if its advisory notice cannot be delivered. */ }
2377
2495
  }
2378
2496
  }
2497
+ // #843 AC3: local agent CLI drift. One probe path (`agentVersionsAsync`, the one `ahub agents` shares), no network, no install.
2498
+ let agentDrift: DriftedAgent[] = [];
2499
+ let agentDriftRunning = false;
2500
+ // The first probe is owed until one has run: a daemon that starts in recovery skips it, and the tick runs it once the hold releases.
2501
+ let agentDriftOwed = true;
2502
+ // A planner that is not attached yet gets it from the tick as soon as it is; a recorded marker is never sent twice.
2503
+ const offerAgentDrift = () => noticeDrift(agentDrift, {
2504
+ record: marker => board.recordAgentDriftNotice(marker), forget: marker => board.forgetAgentDriftNotice(marker),
2505
+ planners: plannerPeers, isRostered: peer => bus.isRostered(peer), publish: env => bus.publish(env),
2506
+ });
2507
+ const sweepAgentDrift = async () => {
2508
+ const probe = opts.agentDrift === true ? () => agentVersionsAsync(configuredCommands(config)) : opts.agentDrift || undefined;
2509
+ if (!probe || agentDriftRunning || stopping || recoveryActive()) return;
2510
+ agentDriftRunning = true;
2511
+ agentDriftOwed = false;
2512
+ try {
2513
+ const drift = driftedAgents(await probe());
2514
+ if (stopping) return;
2515
+ agentDrift = drift;
2516
+ offerAgentDrift();
2517
+ writeStatus();
2518
+ } catch { log("agent CLI drift sweep failed; run ahub agents"); } finally { agentDriftRunning = false; }
2519
+ };
2379
2520
  const intervals = [
2521
+ setInterval(() => { void sweepAgentDrift(); }, opts.agentDriftEveryMs ?? 86_400_000),
2380
2522
  setInterval(() => {
2381
2523
  void refreshRunner().catch(() => {});
2382
2524
  try { tasks.flushPlannerNotices(); } catch { /* the board remains authoritative if notice delivery fails */ }
2525
+ if (agentDriftOwed && !stopping && !recoveryActive()) void sweepAgentDrift();
2526
+ if (agentDrift.length && !stopping && !recoveryActive()) try { offerAgentDrift(); } catch { /* the next tick tries again */ }
2383
2527
  for (const pending of checkpointWaits.values()) if (!pending.valid()) pending.done(undefined);
2384
2528
  for (const [id, request] of permissions) if (request.expiresAt > Date.now()) supervision.approvalWaiting({ id, peer: request.peer, tool: request.tool ?? "unknown", createdAt: request.createdAt });
2385
2529
  for (const row of bus.queueList(undefined, true)) if (row.state === "needs_review") supervision.needsReview(row.peer, row.id);
@@ -2524,8 +2668,9 @@ export async function startDaemon(opts: DaemonOptions) {
2524
2668
  taskOpsInFlight--;
2525
2669
  }
2526
2670
  };
2671
+ const deliveryRetryAllowed = (owner: PeerAdapter | undefined): boolean => !owner || !("deliveryOnly" in owner) || owner.deliveryOnly === false;
2527
2672
  function ownHeldHint(peer: PeerId): string {
2528
- try { return renderHeldHint(bus.heldDeliveryHints(peer), Date.now(), !(isLocalPeer(bus.peers.get(peer)) || isAcpPeer(bus.peers.get(peer)))); }
2673
+ try { return renderHeldHint(bus.heldDeliveryHints(peer), Date.now(), deliveryRetryAllowed(bus.peers.get(peer))); }
2529
2674
  catch { return ""; } // unavailable telemetry is not a zero count or a reason to fail the tool itself
2530
2675
  }
2531
2676
  /** A model-written peer argument: a peer id, or nothing for absent, null or "". Anything else is refused. */
@@ -2667,7 +2812,7 @@ export async function startDaemon(opts: DaemonOptions) {
2667
2812
  switch (op) {
2668
2813
  case "hub_delivery_retry": {
2669
2814
  if (by === USER || by === HUB || !a || typeof a !== "object" || Array.isArray(a) || Object.keys(a).some(key => key !== "delivery_id") || typeof a.delivery_id !== "string" || a.delivery_id.length < 1 || a.delivery_id.length > 256) throw new PreEffectToolRefusal("hub_delivery_retry accepts only your delivery_id; completed and discard require the person");
2670
- if (isLocalPeer(bus.peers.get(by)) || isAcpPeer(bus.peers.get(by))) throw new PreEffectToolRefusal("delivery-only peers require the person to resolve held deliveries");
2815
+ if (!deliveryRetryAllowed(bus.peers.get(by))) throw new PreEffectToolRefusal("delivery-only peers require the person to resolve held deliveries");
2671
2816
  if (stopping || recoveryActive()) throw new PreEffectToolRefusal("recovery or shutdown is holding delivery mutations");
2672
2817
  bus.retryDelivery(by, a.delivery_id);
2673
2818
  return `retry queued for delivery ${a.delivery_id}; check for earlier effects before acting`;
@@ -2872,7 +3017,7 @@ export async function startDaemon(opts: DaemonOptions) {
2872
3017
  if (!isFixedBuiltinDefinition(definitionOf(turn.peer)) || !definitionOf(turn.peer)?.session.nativeRevert || !turn.native) throw new Error(`turn ${turn.id} has no Codex conversation turn to revert`);
2873
3018
  if (turnLog!.latest(turn.peer)?.id !== turn.id) throw new Error(`turn ${turn.id} is not Codex's latest turn; reverting it would drop the later ones too`);
2874
3019
  const codex = bus.peers.get(turn.peer);
2875
- if (!isCodexPeer(codex)) throw new Error("codex is not attached");
3020
+ if (!codex?.revertTurn) throw new Error("codex is not attached");
2876
3021
  const workspace = turnLog!.workspace(turn.id);
2877
3022
  if (workspace !== undefined) {
2878
3023
  const current = readInstanceRoleConfig(opts.cwd), adapter = instanceDefinitionOf(turn.peer, current.instances, current.catalog);
@@ -2885,7 +3030,7 @@ export async function startDaemon(opts: DaemonOptions) {
2885
3030
  const held = bus.stateOf(turn.peer) !== "paused";
2886
3031
  if (held) bus.pause(turn.peer);
2887
3032
  try {
2888
- await codex.revert(turn.native);
3033
+ await codex.revertTurn(turn.native);
2889
3034
  } finally {
2890
3035
  if (held && !manualPaused.has(turn.peer) && !budget.record(turn.peer) && !conductorHolds.has(turn.peer)) bus.resume(turn.peer);
2891
3036
  }
@@ -2941,11 +3086,11 @@ export async function startDaemon(opts: DaemonOptions) {
2941
3086
  // 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.
2942
3087
  const actionEnforcement = (peer: PeerId): { enforced: boolean; reason?: string } => {
2943
3088
  if (definitionOf(peer)?.transport === "claude-channel") {
2944
- const launch = claudePermissionLaunch(peer), attached = bus.peers.get(peer);
3089
+ const launch = claudeLaunchProof.permissionLaunch(peer), attached = claudeLaunchProof.owner(peer);
2945
3090
  // 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.
2946
3091
  if (!launch) return { enforced: false, reason: "no hub permission hook in this Claude session (start it with ahub claude)" };
2947
3092
  if (launch.unattended) return { enforced: false, reason: "this Claude launch is --unattended: its permission checks, hooks included, are off" };
2948
- if (!isWsPeer(attached) || attached.state === "offline" || claudePermissionGenerations.get(peer) !== attached.sessionGeneration)
3093
+ if (!attached || attached.state === "offline" || !claudeLaunchProof.verified(peer))
2949
3094
  return { enforced: false, reason: "no live Claude session has run the hub permission hook yet" };
2950
3095
  return { enforced: true };
2951
3096
  }
@@ -2968,7 +3113,6 @@ export async function startDaemon(opts: DaemonOptions) {
2968
3113
  const unattendedPeers = new Set<string>();
2969
3114
  const startingUnattended = new Map<string, boolean>();
2970
3115
  const nativeUnattended = (peer: string): boolean => startingUnattended.get(peer) ?? unattendedPeers.has(peer);
2971
- const claudePermissionGenerations = new Map<string, string>();
2972
3116
  const permissions = new Map<string, { push: string; peer: string; tool?: string; createdAt: number; expiresAt: number; done: (optionId: string | undefined, surface?: "console" | "dashboard" | "terminal", reason?: "expired" | "cancelled") => boolean }>();
2973
3117
  let stopping = false;
2974
3118
  const releaseAuthorizations = new ReleaseAuthorizations(opts.cwd, {
@@ -3008,7 +3152,7 @@ export async function startDaemon(opts: DaemonOptions) {
3008
3152
  * waited past `budget.stale_min` the status says so, and the person decides. Read-only.
3009
3153
  */
3010
3154
  const importantWaitingStatus = (id: PeerId, peer: PeerAdapter | undefined): Record<string, unknown> => {
3011
- if (!isAcpPeer(peer) || (peer as PeerAdapter).steer || bus.stateOf(id) !== "busy") return {};
3155
+ if (definitionOf(id)?.transport !== "acp" || peer?.steer || bus.stateOf(id) !== "busy") return {};
3012
3156
  const since = bus.oldestQueuedImportantAt(id);
3013
3157
  if (since === undefined || Date.now() - since < config.budget.stale_min * 60_000) return {};
3014
3158
  const count = bus.queuedImportant(id), minutes = Math.floor((Date.now() - since) / 60_000);
@@ -3021,8 +3165,9 @@ export async function startDaemon(opts: DaemonOptions) {
3021
3165
  };
3022
3166
  let releasing = false; // gone-owner release (#6) and the ready sweep (#34): one run at a time, and a recovery commit waits for it
3023
3167
  const recoveryReady = () => {
3024
- if (!recoveryActive() || releasing || taskOpsInFlight !== 0 || tasks.checksPending() !== 0 || piCallsInFlight() !== 0 || permissions.size !== 0 || starting.size !== 0 || requestedPeerStops.size !== 0 || rosterChanging !== undefined || !budget.recoverySettled || [...bus.peers.values()].some((peer) => peer.state === "busy" || (isPiPeer(peer) && !peer.recoveryReady))) return false;
3168
+ if (!recoveryActive() || releasing || taskOpsInFlight !== 0 || tasks.checksPending() !== 0 || piCallsInFlight() !== 0 || permissions.size !== 0 || starting.size !== 0 || requestedPeerStops.size !== 0 || rosterChanging !== undefined || !budget.recoverySettled || [...bus.peers.values()].some((peer) => peer.state === "busy" || ("recoveryReady" in peer && peer.recoveryReady !== true))) return false;
3025
3169
  if (checkPending.size !== 0) return false;
3170
+ if (tasks.autoPullsPending() !== 0) return false; // #785 P1c: an automatic backlog pull mid-admission would write after the commit's digest; its own line keeps the line above exact for scripts/seeds.json
3026
3171
  if (!recoveryPeerSnapshot) return true;
3027
3172
  const current = recoveryPeers();
3028
3173
  return recoveryPeerSnapshot.every((saved) => {
@@ -3188,9 +3333,10 @@ export async function startDaemon(opts: DaemonOptions) {
3188
3333
  };
3189
3334
  const permissionDisplay = (id: string, peer: PeerAdapter | undefined): string => {
3190
3335
  if (readOnlyPeer(id)) return "read-only";
3191
- if (definitionOf(id)?.transport === "claude-channel" && isWsPeer(peer) && peer.state !== "offline") {
3192
- const launch = claudePermissionLaunch(id);
3193
- if (!launch || claudePermissionGenerations.get(id) !== peer.sessionGeneration) return "unverified";
3336
+ const channelOwner = claudeLaunchProof.owner(id);
3337
+ if (channelOwner && channelOwner === peer && channelOwner.state !== "offline") {
3338
+ const launch = claudeLaunchProof.permissionLaunch(id);
3339
+ if (!launch || !claudeLaunchProof.verified(id)) return "unverified";
3194
3340
  }
3195
3341
  const native = peer?.permissionModeState;
3196
3342
  // An offline ask clears the hub choice, not a stopped ACP runtime's retained native field.
@@ -3199,7 +3345,7 @@ export async function startDaemon(opts: DaemonOptions) {
3199
3345
  };
3200
3346
  const permissionStatus = (id: string, peer: PeerAdapter | undefined): Record<string, unknown> => {
3201
3347
  const note = definitionOf(id)?.permission.unmanagedStatusNote;
3202
- if (note && isWsPeer(peer) && peer.pullOnly) return { permissionNote: note };
3348
+ if (note && peer?.pullOnly === true) return { permissionNote: note };
3203
3349
  const mode = permissionDisplay(id, peer);
3204
3350
  return mode === "ask" ? {} : { permissionMode: mode };
3205
3351
  };
@@ -3239,6 +3385,7 @@ export async function startDaemon(opts: DaemonOptions) {
3239
3385
  pluginBundle: SERVER_JS,
3240
3386
  pendingRestart: allPendingRestart(),
3241
3387
  retainedConfig,
3388
+ ...(agentDrift.length ? { agentDrift } : {}),
3242
3389
  ...(() => { const warning = mainCheckoutWarning(opts.cwd, tasks.publicMetadataText, checkoutBranch); return warning ? { mainCheckout: warning } : {}; })(),
3243
3390
  startModes: Object.fromEntries([...START_MODE_PEERS, ...configuredIds].map(peer => [peer, startModeOf(config.peers, peer, declarations, adapterCatalog)])),
3244
3391
  memoryProfile: memoryProfileStatus(includeAttentionItems),
@@ -3266,7 +3413,7 @@ export async function startDaemon(opts: DaemonOptions) {
3266
3413
  ...(dashboard ? { uiOrigin: dashboard.origin } : {}),
3267
3414
  codexProxyPort: opts.codexProxyPort,
3268
3415
  peers: Object.fromEntries(
3269
- 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), ...(isFixedBuiltinDefinition(definitionOf(id)) && (isCodexPeer(p) || isQwenDualPeer(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 ?? [] } : {}) }]; }),
3416
+ 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))); const statusNotice = isFixedBuiltinDefinition(definitionOf(id)) ? p?.statusNotice : undefined; return [id, { state: bus.stateOf(id), ...(!membershipAllowed(id) ? { rostered: false, rosterNote: "off roster" } : {}), ...permissionStatus(id, p), ...(statusNotice ? { 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), ...(reviewOnlyPeer(id) ? { reviewScope: reviewScopeOf(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 ?? [] } : {}) }]; }),
3270
3417
  ),
3271
3418
  taskWip: tasks.wipBreakdown(peerIds),
3272
3419
  boardReadCapabilities: { releaseFilter: true, columnMetadata: true },
@@ -4059,12 +4206,13 @@ export async function startDaemon(opts: DaemonOptions) {
4059
4206
  if (stopping || recoveryActive()) return { ok: false, error: "recovery or shutdown is holding peer stops; finish the operation before retrying" };
4060
4207
  if (typeof peer !== "string" || !PEER_ID.test(peer)) return { ok: false, error: "unknown peer; run ahub status and choose a hub-owned headless peer" };
4061
4208
  const owner = bus.peers.get(peer);
4062
- const stopNotice = definitionOf(peer)?.cli.stopNotice?.(isWsPeer(owner) ? "tui" : !owner ? startMode(peer) : "headless", owner);
4209
+ if (owner && "form" in owner && owner.form === undefined) return { ok: false, error: "peer form is unknown; refresh ahub status before stopping it" };
4210
+ const stopNotice = definitionOf(peer)?.cli.stopNotice?.(owner?.form ?? (owner ? "headless" : startMode(peer)), owner);
4063
4211
  if (stopNotice) return { ok: false, error: stopNotice };
4064
4212
  if (isQwenDualPeer(owner)) return { ok: false, error: "Qwen belongs to its own terminal; end it there (the hub never signals a person's Qwen)" };
4065
- 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" };
4066
- if (isPiPeer(owner) && owner.mode !== "headless") return { ok: false, error: "Pi belongs to a native TUI terminal; end it in its terminal" };
4067
- if (owner.state === "offline" && !(isAcpPeer(owner) && owner.pendingOwnedProcess)) return { ok: false, error: `peer is already offline; start it with ${startCommand(peer)} before stopping it` };
4213
+ if (!owner || owner.hubOwned !== true) return { ok: false, error: "unknown or non-owned peer; run ahub status and choose a hub-owned headless peer" };
4214
+ if (owner.form === "tui") return { ok: false, error: `${definitionOf(peer)?.displayName ?? peer} belongs to a native TUI terminal; end it in its terminal` };
4215
+ if (owner.state === "offline" && owner.pendingOwnedProcess !== true) return { ok: false, error: `peer is already offline; start it with ${startCommand(peer)} before stopping it` };
4068
4216
  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" };
4069
4217
  requestedPeerStops.add(peer); // fence delivery, starts and recovery before the first asynchronous stop step
4070
4218
  try {
@@ -4073,7 +4221,7 @@ export async function startDaemon(opts: DaemonOptions) {
4073
4221
  operational?.current();
4074
4222
  const reason = `requested stop: ahub stop ${peer}`; // ids only, never task or message text
4075
4223
  // Pi persists a session only after its first message: take live empty-session proof before stopping.
4076
- if (isPiPeer(owner) && owner.state === "idle") {
4224
+ if (owner.captureResume && owner.state === "idle") {
4077
4225
  try { await owner.captureResume(); }
4078
4226
  catch { log(`requested stop ${peer}: resume evidence unavailable; later start requires verified persisted history`); }
4079
4227
  }
@@ -4133,7 +4281,7 @@ export async function startDaemon(opts: DaemonOptions) {
4133
4281
  return { ok: false, error: "configured ACP session selector is invalid" };
4134
4282
  }
4135
4283
  const retainedOwner = bus.peers.get(peer);
4136
- if (adapter?.permission.genericAcp && isAcpPeer(retainedOwner) && retainedOwner.pendingOwnedProcess && retainedOwner.state === "offline") return { ok: false, error: "configured ACP previous process teardown is unconfirmed; retry ahub stop before starting again" };
4284
+ if (adapter?.permission.genericAcp && retainedOwner?.state === "offline" && "pendingOwnedProcess" in retainedOwner && retainedOwner.pendingOwnedProcess !== false) return { ok: false, error: "configured ACP previous process teardown is unconfirmed; retry ahub stop before starting again" };
4137
4285
  if (adapter?.permission.genericAcp && (args.model || args.route || args.backend || args.unattended || args.providerProfile || args.sessionFile || args.fresh)) return { ok: false, error: "configured ACP accepts no native launch overrides" };
4138
4286
  if (adapter?.reviewer?.unavailable) return { ok: false, error: adapter.reviewer.unavailable };
4139
4287
  const preflight = adapter?.start.preflight?.({ instance: peer, form: args.mode });
@@ -4216,7 +4364,9 @@ export async function startDaemon(opts: DaemonOptions) {
4216
4364
  if (adapter && configuredIds.includes(adapter.kind) && args.sessionId !== undefined && args.sessionId !== adapter.session.currentId(existing)) {
4217
4365
  return { ok: false, error: "configured ACP is already running a different native session; stop it before selecting another" };
4218
4366
  }
4219
- return { ok: true, already: true, ...(isFixedBuiltinDefinition(definitionOf(peer)) && isCodexPeer(existing) ? { proxyUrl: existing.proxyUrl } : {}) };
4367
+ const proxy = frontendProxyState(peer, existing);
4368
+ if (proxy.present && proxy.port === undefined) return { ok: false, error: "frontend proxy state unknown" };
4369
+ return { ok: true, already: true, ...(proxy.port ? { proxyUrl: proxy.port.url } : {}) };
4220
4370
  }
4221
4371
  if (!adapter?.start.reconfigureWhileAttached) {
4222
4372
  if (isPiPeer(existing)) await stopPiForReplacement(existing);
@@ -4359,7 +4509,7 @@ export async function startDaemon(opts: DaemonOptions) {
4359
4509
  mcpServers,
4360
4510
  preamble: sessionRoleContract(peer),
4361
4511
  } } });
4362
- if (!isAcpPeer(owner) || owner.id !== peer) throw new Error("ACP factory did not return its bound native transport");
4512
+ if (!isAcpPeer(owner) || owner.id !== peer || owner.deliveryOnly !== true || owner.hubOwned !== true || owner.form !== "headless") throw new Error("ACP factory did not return its bound native transport");
4363
4513
  recoveryTaskPreface(peer); await ensurePreface(peer);
4364
4514
  try { await owner.start(); await reconcileNativeStart(peer, owner); }
4365
4515
  catch (error) {
@@ -4448,7 +4598,9 @@ export async function startDaemon(opts: DaemonOptions) {
4448
4598
  await codex.start();
4449
4599
  await reconcileNativeStart(peer, codex);
4450
4600
  } catch (error) { await codex.stop().catch(stop => log(`codex stop after a failed start: ${(stop as Error).message}`)); throw error; }
4451
- return { ok: true, proxyUrl: codex.proxyUrl };
4601
+ const proxy = frontendProxyState(peer, codex);
4602
+ if (proxy.present && proxy.port === undefined) return { ok: false, error: "frontend proxy state unknown" };
4603
+ return { ok: true, ...(proxy.port ? { proxyUrl: proxy.port.url } : {}) };
4452
4604
  }
4453
4605
  if (adapter?.start.daemonPort === "pi-rpc") {
4454
4606
  if (!onRoster(peer)) return { ok: false, error: config.roster.pi === undefined ? "Pi is disabled; set pi.enabled in .agenthub/config.json" : "pi is off roster" };
@@ -4645,7 +4797,11 @@ export async function startDaemon(opts: DaemonOptions) {
4645
4797
  executionOwners.set(peer, { owner: pi, cwd: executionCwd });
4646
4798
  bus.add(pi);
4647
4799
  // The start's own error is the one reported; a stop that fails too is logged beside it (#115).
4648
- try { await pi.start(); } catch (error) { await pi.stop().catch((stop: Error) => log(`pi stop after a failed start: ${stop.message}`)); throw error; }
4800
+ try { await pi.start(); } catch (error) {
4801
+ await pi.stop().catch((stop: Error) => log(`pi stop after a failed start: ${stop.message}`));
4802
+ if (error instanceof PiVersionRefusal) return { ok: false, error: sanitize(error.message).slice(0, 1000) };
4803
+ throw error;
4804
+ }
4649
4805
  return { ok: true, ...(mode === "tui" ? { launch: pi.tuiLaunch } : {}) };
4650
4806
  }
4651
4807
  if (adapter?.start.daemonPort === "local-worker") {
@@ -4754,7 +4910,7 @@ export async function startDaemon(opts: DaemonOptions) {
4754
4910
  if (selected !== "ask" && selected !== "ask-when-needed") return { ok: false, error: "Claude implementer refuses never-ask" };
4755
4911
  const implementer = adapter.start.create!({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: config, permission: selected, mcpServers: [], daemon: DAEMON_PORTS["claude-implementer"]({
4756
4912
  cwd: executionCwd, stateDir: opts.stateDir, projectRoot: opts.cwd, instanceId, server: SERVER_JS,
4757
- factsHook: join(import.meta.dir, "..", "cli", "facts-hook.ts"), systemPrompt: sessionRoleContract(peer), mode: selected, watchdogMs: config.watchdog_ms,
4913
+ factsHook: join(import.meta.dir, "..", "cli", "facts-hook.ts"), systemPrompt: sessionRoleContract(peer), mode: selected, watchdogMs: config.watchdog_ms, ...(declarations[peer]?.model ? { model: declarations[peer].model } : {}),
4758
4914
  admit: env => {
4759
4915
  try {
4760
4916
  if (env.private || !currentRoot() || bus.peers.get(peer) !== implementer) return false;
@@ -4976,8 +5132,17 @@ export async function startDaemon(opts: DaemonOptions) {
4976
5132
  };
4977
5133
  }
4978
5134
 
5135
+ let operationalViewCache: { board: number; config: number; routing: ReturnType<typeof activeRouting>; privacy: string; view: ReturnType<typeof operationalView> } | undefined;
4979
5136
  function publicOperationalView() {
4980
- return operationalView({tasks:board.list(),isPii:task=>tasks.isPii(task),publicView:task=>tasks.publicView(task),publicMetadataText:tasks.publicMetadataText,redact:record=>redact(record as BusEvent)});
5137
+ const revision=board.statusRevision(),routing=activeRouting(),privacy=JSON.stringify([config.pii?.paths??[],config.local.deny]);
5138
+ if(operationalViewCache && operationalViewCache.board===revision && operationalViewCache.config===configRevision && operationalViewCache.routing===routing && operationalViewCache.privacy===privacy)return operationalViewCache.view;
5139
+ const privatePaths=[...(config.pii?.paths??[]),...config.local.deny];
5140
+ const currentPrivate=(task:Task)=>tasks.isPii(task) || [...(task.refs.paths??[]),...(task.plan?.paths??[])].some(path=>matchesPolicy(opts.cwd,path,privatePaths,true));
5141
+ const view=operationalView({tasks:board.list(),isPii:currentPrivate,publicView:task=>tasks.publicView(task),publicMetadataText:text=>tasks.publicMetadataText(text) && boundedSafe(text),redact:record=>redact(record as BusEvent)});
5142
+ operationalViewCache={board:revision,config:configRevision,routing,privacy,view};return view;
5143
+ }
5144
+ function operationalFailure(error: unknown, fallback?: string): Record<string, unknown> {
5145
+ return {ok:false,error:error instanceof OperationalAuthorityError?error.message:fallback??(error instanceof Error?error.message:"operational request refused"),...(error instanceof OperationalAuthorityError?{authorityRefused:true,authorityScope:error.scope}: {})};
4981
5146
  }
4982
5147
  /** Ordinary read authority never borrows the console identity or conductor admission. */
4983
5148
  function publicReadCurrent(sock: Sock): () => void {
@@ -5056,9 +5221,9 @@ export async function startDaemon(opts: DaemonOptions) {
5056
5221
  function operationalCurrent(sock:Sock,acceptedAfterClose=false):()=>void {
5057
5222
  const client=sock.data,peer=client.peer;
5058
5223
  return ()=>{
5059
- if(!peer || sock.data!==client || client.role!=="tools" || !acceptedAfterClose && sock.readyState!==WebSocket.OPEN || client.toolsOwner || readOnlyPeer(peer) || stopping || !membershipAllowed(peer) || contextPii(peer))throw new Error("operational conductor authority unavailable");
5224
+ if(!peer || sock.data!==client || client.role!=="tools" || !acceptedAfterClose && sock.readyState!==WebSocket.OPEN || client.toolsOwner || readOnlyPeer(peer) || stopping || !membershipAllowed(peer) || contextPii(peer))throw new OperationalAuthorityError("operational conductor authority unavailable");
5060
5225
  try {requireConductor(peer,config.roles);}
5061
- catch {throw new Error(operationalRefusal(peer,"operational command"));}
5226
+ catch {throw new OperationalAuthorityError("this operation requires the explicit conductor role","conductor");}
5062
5227
  };
5063
5228
  }
5064
5229
  function admitOperational(sock:Sock,command:string):()=>void {
@@ -5486,7 +5651,7 @@ export async function startDaemon(opts: DaemonOptions) {
5486
5651
  const message = sanitize(error instanceof Error ? error.message : "configuration reload could not be confirmed").slice(0, 600);
5487
5652
  const failure = `${fingerprint}:${message}`;
5488
5653
  if (lastReloadFailure !== failure) { lastReloadFailure = failure; try { notify(`configuration reload ${published ? "unconfirmed" : "refused"}: ${message}; run ahub settings reload after reviewing or reverting the file`); } catch { log("configuration reload notice unavailable"); } }
5489
- return { ok: false, error: message, ...(published ? { applied: true } : {}), pendingRestart };
5654
+ return { ok: false, error: message, ...(error instanceof OperationalAuthorityError ? { authorityRefused: true, authorityScope: error.scope } : {}), ...(published ? { applied: true } : {}), pendingRestart };
5490
5655
  }
5491
5656
  }
5492
5657
 
@@ -5561,13 +5726,13 @@ export async function startDaemon(opts: DaemonOptions) {
5561
5726
  return undefined;
5562
5727
  }
5563
5728
  function codexResumeCommand(peer: string): string {
5564
- const owner = bus.peers.get(peer), thread = isCodexPeer(owner) ? owner.thread || lastCodexThreads.get(peer) : lastCodexThreads.get(peer);
5729
+ const owner = bus.peers.get(peer), thread = owner?.nativeSession ?? lastCodexThreads.get(peer);
5565
5730
  const command = declarations[peer] ? `ahub codex --as ${peer} resume` : "ahub codex resume";
5566
5731
  return thread && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(thread) ? `${command} ${thread}` : command;
5567
5732
  }
5568
5733
  function retainedTerminalNotice(peer: string): string | undefined {
5569
5734
  const owner = bus.peers.get(peer);
5570
- return definitionOf(peer)?.cli.retainedTerminalNotice?.(peer, isPiPeer(owner) ? owner.mode : undefined, codexResumeCommand(peer));
5735
+ return definitionOf(peer)?.cli.retainedTerminalNotice?.(peer, owner?.form, codexResumeCommand(peer));
5571
5736
  }
5572
5737
 
5573
5738
  async function changeRosterSetting(def: SettingDef, value: SettingValue, source: SettingSource, confirm: unknown, now: boolean, undo = false, authority: SettingAuthority = "ordinary", reason?: string, undoIdentity?: UndoIdentity): Promise<Record<string, unknown>> {
@@ -5599,9 +5764,10 @@ export async function startDaemon(opts: DaemonOptions) {
5599
5764
  if (!after) {
5600
5765
  for (const pending of permissions.values()) if (pending.peer === peer) pending.done(undefined);
5601
5766
  const owner = bus.peers.get(peer);
5602
- if (isWsPeer(owner)) { owner.detachForRoster(); if (definitionOf(peer)?.attach?.admission) clearKimiAdmission(); }
5603
- else if (isCodexPeer(owner)) owner.detachForRoster(config.permission_modes[peer] === "never-ask" ? "ask" : config.permission_modes[peer] ?? "ask");
5604
- else if (isPiPeer(owner) && owner.mode === "tui") await owner.detachForRoster();
5767
+ if (owner?.detachForRoster && owner.form !== "headless") {
5768
+ await owner.detachForRoster(config.permission_modes[peer] === "never-ask" ? "ask" : config.permission_modes[peer] ?? "ask");
5769
+ if (definitionOf(peer)?.attach?.admission) clearKimiAdmission();
5770
+ }
5605
5771
  else if (owner && owner.state !== "offline") {
5606
5772
  const result = await stopPeer(peer, true, authority==="conductor"?operationalContext.getStore():undefined);
5607
5773
  if (result.ok !== true) throw new Error(`roster saved; ${peer} stop is unconfirmed; permission reset and task handoff were not attempted; inspect ahub status and reapply ${def.key} false to retry`);
@@ -5616,7 +5782,7 @@ export async function startDaemon(opts: DaemonOptions) {
5616
5782
  handoff = await tasks.reassignForRoster(peer, handoffCurrent);
5617
5783
  } else {
5618
5784
  const owner = bus.peers.get(peer);
5619
- if (isPiPeer(owner) && owner.mode === "tui") await owner.resumeFromRoster();
5785
+ if (owner?.form !== "headless") await owner?.resumeFromRoster?.();
5620
5786
  }
5621
5787
  checkOperational(authority);
5622
5788
  const reloaded = await reloadConfig(authority, `roster.${peer}`);
@@ -5633,7 +5799,7 @@ export async function startDaemon(opts: DaemonOptions) {
5633
5799
  return {ok:true,saved:true,authorityEnded:true,text:`${peer}: roster write saved; authority ended before remaining lifecycle work; inspect status before continuing`};
5634
5800
  }
5635
5801
  }
5636
- return { ok: false, error: sanitize((error as Error).message), ...(saved ? { saved: true } : {}) };
5802
+ return { ok: false, error: sanitize((error as Error).message), ...(error instanceof OperationalAuthorityError ? { authorityRefused: true, authorityScope: error.scope } : {}), ...(saved ? { saved: true } : {}) };
5637
5803
  } finally { if (rosterChanging === peer) { rosterChanging = undefined; bus.rosterChanged(peer); if (configWatchPending && !stopping) { configWatchPending = false; clearTimeout(reloadDebounce); reloadDebounce = setTimeout(runConfigWatch, 100); reloadDebounce.unref?.(); } } }
5638
5804
  }
5639
5805
  /** A checked definition and value, or the refusal. Every settings write and preview starts here. */
@@ -5706,7 +5872,7 @@ export async function startDaemon(opts: DaemonOptions) {
5706
5872
  : ["permission_modes", "catalog_roles", "peers"].includes(def.path[0]!) ? "applies at the peer's next start" : def.key === "coordination" ? "applies to new tasks" : "applies at next use";
5707
5873
  return { ok: true, text: `${def.key}: ${settingText(to)} (${timing})` };
5708
5874
  } catch (error) {
5709
- return { ok: false, error: sanitize((error as Error).message), ...(saved ? { saved: true } : {}) };
5875
+ return { ok: false, error: sanitize((error as Error).message), ...(error instanceof OperationalAuthorityError ? { authorityRefused: true, authorityScope: error.scope } : {}), ...(saved ? { saved: true } : {}) };
5710
5876
  }
5711
5877
  }
5712
5878
 
@@ -5930,10 +6096,11 @@ export async function startDaemon(opts: DaemonOptions) {
5930
6096
  // #272: the rest of what recoveryReady() waits for, so the upgrade screen can name the wait. Display only.
5931
6097
  ...(taskOpsInFlight !== 0 ? ["a task command in flight"] : []),
5932
6098
  ...(tasks.checksPending() !== 0 ? ["completion checks queued or running"] : []),
6099
+ ...(tasks.autoPullsPending() !== 0 ? ["an automatic backlog pull in flight"] : []),
5933
6100
  ...(checkPending.size !== 0 ? ["isolated checks queued or running"] : []),
5934
6101
  ...(piCallsInFlight() !== 0 ? ["Pi tool calls in flight"] : []),
5935
6102
  ...(releasing ? ["ready tasks being released"] : []),
5936
- ...([...bus.peers].filter(([, peer]) => isPiPeer(peer) && peer.state !== "busy" && !peer.recoveryReady).map(([id, peer]) => !membershipAllowed(id) && isPiPeer(peer) && peer.mode === "tui" ? `${id} is not settled; end the retained Pi TUI, then prepare again` : `${id} is not settled`)),
6103
+ ...([...bus.peers].filter(([, peer]) => peer.state !== "busy" && "recoveryReady" in peer && peer.recoveryReady !== true).map(([id, peer]) => !membershipAllowed(id) && peer.form === "tui" ? `${id} is not settled; end the retained ${definitionOf(id)?.displayName ?? id} TUI, then prepare again` : `${id} is not settled`)),
5937
6104
  ],
5938
6105
  integrity: { current: integrityAs(restored?.integrity), ...(restored?.integrity ? { expected: restored.integrity } : {}) },
5939
6106
  peers: Object.fromEntries([...(recoveryPeerSnapshot ?? []), ...Object.values(recoveryPeers()).filter((peer) => !(recoveryPeerSnapshot ?? []).some((saved) => saved.id === peer.id))].map((peer) => {
@@ -5970,8 +6137,8 @@ export async function startDaemon(opts: DaemonOptions) {
5970
6137
  }
5971
6138
 
5972
6139
  if (msg.op === "inspect" || msg.op === "prepare" || msg.op === "commit") {
5973
- for (const pi of bus.peers.values()) if (isPiPeer(pi) && pi.state === "idle") {
5974
- try { await pi.captureResume(); } catch (error) { return recoveryError((error as Error).message); }
6140
+ for (const peer of bus.peers.values()) if (peer.captureResume && peer.state === "idle") {
6141
+ try { await peer.captureResume(); } catch (error) { return recoveryError((error as Error).message); }
5975
6142
  if (checkTarget) { const error = incompatibleRecoveryTarget(); if (error) return recoveryError(error); }
5976
6143
  }
5977
6144
  }
@@ -6080,23 +6247,21 @@ export async function startDaemon(opts: DaemonOptions) {
6080
6247
  return recoveryError("unknown recovery operation");
6081
6248
  }
6082
6249
 
6083
- function claudePermissionLaunch(peer: string): { launchId: string; unattended: boolean; hookPurpose?: string } | undefined {
6084
- if (!isFixedBuiltinDefinition(definitionOf(peer))) return undefined;
6085
- const row = readClaudeLaunchProof(opts.stateDir, peer, instanceId);
6086
- return row?.permissionHook ? row : undefined;
6087
- }
6088
6250
  async function changePermission(peer: unknown, mode: unknown, confirmed: unknown): Promise<Record<string, unknown>> {
6089
6251
  if (!isSupportedPeer(peer)) return { ok: false, error: "unknown permission peer" };
6090
6252
  if (readOnlyPeer(peer)) return { ok: false, error: "read-only ceiling is locked for this instance" };
6091
6253
  if (definitionOf(peer)?.permission.unmanaged) return { ok: false, error: definitionOf(peer)!.permission.unmanaged };
6092
- if (definitionOf(peer)?.permission.unmanagedForms?.includes("tui") && (isWsPeer(bus.peers.get(peer)) || (!bus.peers.has(peer) && startMode(peer) === "tui"))) return { ok: false, error: definitionOf(peer)!.permission.unmanagedNote };
6093
- if (requestedPeerStops.has(peer)) return { ok: false, error: "peer stop is in progress; wait for ahub status before changing its mode" };
6094
6254
  const owner = bus.peers.get(peer);
6255
+ const unmanagedForm = owner ? owner.form === "tui" || "form" in owner && owner.form === undefined : startMode(peer) === "tui";
6256
+ if (definitionOf(peer)?.permission.unmanagedForms?.includes("tui") && unmanagedForm) return { ok: false, error: definitionOf(peer)!.permission.unmanagedNote };
6257
+ if (requestedPeerStops.has(peer)) return { ok: false, error: "peer stop is in progress; wait for ahub status before changing its mode" };
6258
+ const proxy = frontendProxyState(peer, owner);
6259
+ if (proxy.present && proxy.port === undefined) return { ok: false, error: "frontend proxy state unknown" };
6095
6260
  if (mode === "ask" && (!owner || owner.state === "offline")) {
6096
6261
  // A Codex proxy outlives its TUI and keeps its own copy of the mode: clear that too, or the next TUI that attaches
6097
6262
  // to it would run a mode the status no longer shows.
6098
6263
  // It is never refused here: a leftover non-ask mode must always be undoable while the peer is away.
6099
- const note = isFixedBuiltinDefinition(definitionOf(peer)) && isCodexPeer(owner) && !owner.clearPermissionMode() ? "The detached session's own approval policy was never reported, so its thread keeps what it last got; a new ahub codex session starts in ask" : undefined;
6264
+ const note = proxy.port && !proxy.port.clearPermissionMode() ? "The detached session's own approval policy was never reported, so its thread keeps what it last got; a new ahub codex session starts in ask" : undefined;
6100
6265
  if (note) log(`permission mode codex: ${note}`);
6101
6266
  const from = permissionMode(peer);
6102
6267
  permissionModes.set(peer, "ask"); permissionModeSources.set(peer, "human runtime command"); pendingPermissionDefaults.delete(peer);
@@ -6104,17 +6269,17 @@ export async function startDaemon(opts: DaemonOptions) {
6104
6269
  log(`permission mode ${peer}: ${from} -> ask (offline hub choice cleared)`); writeStatus();
6105
6270
  return { ok: true, peer, permissionMode: "ask", ...(note ? { note } : {}) };
6106
6271
  }
6107
- if (isFixedBuiltinDefinition(definitionOf(peer)) && isCodexPeer(owner) && !owner.proxyAttached) return { ok: false, error: "Codex TUI is not behind the hub proxy; reconnect with ahub codex" };
6272
+ if (proxy.port && !proxy.port.attached) return { ok: false, error: "Codex TUI is not behind the hub proxy; reconnect with ahub codex" };
6108
6273
  if (!owner || owner.state === "offline") return { ok: false, error: `${peer} is not attached; start it through ahub first` };
6109
6274
  if (mode === undefined) return { ok: true, peer, permissionMode: permissionDisplay(peer, owner) };
6110
6275
  if (!isPermissionMode(mode)) return { ok: false, error: "mode must be ask, ask-when-needed or never-ask" };
6111
6276
  if (mode === "never-ask" && confirmed !== true) return { ok: false, error: "never-ask needs --yes or console confirmation" };
6112
6277
  if (definitionOf(peer)?.transport === "claude-channel") {
6113
- const launch = claudePermissionLaunch(peer);
6114
- if (!launch || !isWsPeer(owner) || claudePermissionGenerations.get(peer) !== owner.sessionGeneration) return { ok: false, error: "Claude has no verified ahub claude permission hook; restart with ahub claude and run a tool first" };
6278
+ const launch = claudeLaunchProof.permissionLaunch(peer);
6279
+ if (!launch || claudeLaunchProof.owner(peer) !== owner || !claudeLaunchProof.verified(peer)) return { ok: false, error: "Claude has no verified ahub claude permission hook; restart with ahub claude and run a tool first" };
6115
6280
  if (launch.unattended) return { ok: false, error: "Claude started --unattended already skips prompts; run ahub permission claude ask after it stops to clear the hub choice, then restart without --unattended" };
6116
- } else if (isFixedBuiltinDefinition(definitionOf(peer)) && isCodexPeer(owner)) {
6117
- if (!owner.proxyAttached) return { ok: false, error: "Codex TUI is not behind the hub proxy; reconnect with ahub codex" };
6281
+ } else if (proxy.port) {
6282
+ if (!proxy.port.attached) return { ok: false, error: "Codex TUI is not behind the hub proxy; reconnect with ahub codex" };
6118
6283
  if (nativeUnattended(peer)) return { ok: false, error: "Codex started --unattended already skips prompts; run ahub permission codex ask after it stops to clear the hub choice, then restart without --unattended" };
6119
6284
  }
6120
6285
  const from = permissionMode(peer);
@@ -6144,8 +6309,10 @@ export async function startDaemon(opts: DaemonOptions) {
6144
6309
  writeStatus(); return { ok: true, peer, permissionMode: permissionMode(peer) };
6145
6310
  }
6146
6311
  const owner = bus.peers.get(peer);
6312
+ const proxy = frontendProxyState(peer, owner);
6313
+ if (proxy.present && proxy.port === undefined) return { ok: false, error: "frontend proxy state unknown" };
6147
6314
  if (definitionOf(peer)?.permission.trackUnattended && nativeUnattended(peer)) return { ok: false, error: "Codex started --unattended cannot confirm a permission default; run ahub permission codex ask after it stops, or restart without --unattended" };
6148
- if (!starting.has(peer) && isFixedBuiltinDefinition(definitionOf(peer)) && isCodexPeer(owner) && owner.state === "offline") return { ok: false, error: "connect the Codex TUI through the hub proxy before confirming its default" };
6315
+ if (!starting.has(peer) && proxy.port && owner?.state === "offline") return { ok: false, error: "connect the Codex TUI through the hub proxy before confirming its default" };
6149
6316
  if (!starting.has(peer) && owner && owner.state !== "offline") {
6150
6317
  const result = await changePermission(peer, "never-ask", true);
6151
6318
  if (!result.ok) return result;
@@ -6176,9 +6343,11 @@ export async function startDaemon(opts: DaemonOptions) {
6176
6343
  const result = await permissionChange(peer, async () => {
6177
6344
  if (stopping) return { ok: false, error: "hub is stopping" };
6178
6345
  const selected = permissionMode(peer);
6179
- if (isFixedBuiltinDefinition(definitionOf(peer)) && isCodexPeer(owner) && nativeUnattended(peer) && selected !== "ask") return { ok: false, error: "Codex --unattended cannot take a non-ask mode; run ahub permission codex ask, or restart without --unattended" };
6180
- if (!readOnlyPeer(peer) && !(isAcpPeer(owner) && owner.permissionModeState === "unmanaged") && owner.getPermissionMode() !== selected) {
6181
- if (isFixedBuiltinDefinition(definitionOf(peer)) && isCodexPeer(owner) && !owner.proxyAttached) owner.setStartupPermissionMode(selected);
6346
+ const proxy = frontendProxyState(peer, owner);
6347
+ if (proxy.present && proxy.port === undefined) return { ok: false, error: "frontend proxy state unknown" };
6348
+ if (isFixedBuiltinDefinition(definitionOf(peer)) && definitionOf(peer)?.permission.trackUnattended && nativeUnattended(peer) && selected !== "ask") return { ok: false, error: "Codex --unattended cannot take a non-ask mode; run ahub permission codex ask, or restart without --unattended" };
6349
+ if (!readOnlyPeer(peer) && owner.permissionModeState !== "unmanaged" && owner.getPermissionMode() !== selected) {
6350
+ if (proxy.port && !proxy.port.attached) proxy.port.setStartupPermissionMode(selected);
6182
6351
  else await owner.setPermissionMode(selected);
6183
6352
  }
6184
6353
  // The bus may immediately drain at add(): do not expose an idle adapter until its current choice is applied.
@@ -6195,11 +6364,14 @@ export async function startDaemon(opts: DaemonOptions) {
6195
6364
  const c = sock.data;
6196
6365
  let refusalRecorded=false;
6197
6366
  const reply = (body: Record<string, unknown>) => {
6198
- if (c.toolsOwner && !currentImplementerTools(c)) body = { ok: false, error: "Claude child generation or execution root changed" };
6199
6367
  const label=operationalRequestLabel(msg);
6368
+ if (c.toolsOwner && !currentImplementerTools(c)) body = { ok: false, error: "Claude child generation or execution root changed", ...(label ? {authorityRefused:true,authorityScope:"operational"}: {}) };
6200
6369
  if(c.authed && c.role==="tools" && c.peer && body.ok===false && label) {
6201
- if(!refusalRecorded)refusalRecorded=event({type:"agent_cli",peer:c.peer,command:label,refused:true});
6202
- body={...body,error:`${String(body.error??"operational request refused")}; ${operationalRefusal(c.peer,label)}`,refusalAudited:refusalRecorded};
6370
+ if(body.authorityRefused===true) {
6371
+ if(!refusalRecorded)refusalRecorded=event({type:"agent_cli",peer:c.peer,command:label,refused:true});
6372
+ const error=body.authorityScope==="conductor"?operationalRefusal(c.peer,label):body.authorityScope==="person"?String(body.error):`${String(body.error??"operational authority unavailable")}; ${OPERATIONAL_PERSON_GUIDANCE}`;
6373
+ body={...body,error,refusalAudited:refusalRecorded};
6374
+ } else body={...body,authorityRefused:false};
6203
6375
  }
6204
6376
  if (sock.readyState === WebSocket.OPEN) sock.send(JSON.stringify({ rid: msg.rid, ...body }));
6205
6377
  };
@@ -6244,12 +6416,7 @@ export async function startDaemon(opts: DaemonOptions) {
6244
6416
  if (!membershipAllowed(c.peer)) return sock.close(4000, "peer is off roster; retry when a person adds it");
6245
6417
  if (!recoveryPeerAllowed(c.peer)) return sock.close(4403, "peer id is not part of the recovery roster");
6246
6418
  if (definitionOf(c.peer)?.transport === "claude-channel" && msg.launchId !== undefined) {
6247
- let currentLaunch: unknown;
6248
- try {
6249
- const launch = readClaudeLaunchProof(opts.stateDir, c.peer, instanceId);
6250
- if (launch) currentLaunch = launch.launchId;
6251
- } catch { /* No current launch evidence: retain plain/pre-daemon attachment compatibility. */ }
6252
- if (currentLaunch !== undefined && msg.launchId !== currentLaunch) return sock.close(4430, "Claude launch ended; start a new session with ahub claude");
6419
+ if (claudeLaunchProof.helloLaunch(c.peer, msg.launchId) === "refuse") return sock.close(4430, "Claude launch ended; start a new session with ahub claude");
6253
6420
  if (typeof msg.launchId === "string" && msg.launchId.length <= 128) c.launchId = msg.launchId;
6254
6421
  }
6255
6422
  let peer = bus.peers.get(c.peer);
@@ -6300,10 +6467,14 @@ export async function startDaemon(opts: DaemonOptions) {
6300
6467
  // answer still revalidate native root evidence, without synchronous Git on every tick.
6301
6468
  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();
6302
6469
  c.toolsOwner?.owner.keepApprovalAlive?.();
6303
- 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 => {
6470
+ void (async () => {
6471
+ const updatedInput = msg.wrapperProtocol === 1 ? await automaticImplementerShell(c, msg.tool, msg.input, () => sock.readyState === WebSocket.OPEN && current()) : undefined;
6472
+ if (updatedInput && current()) return void reply({ ok: true, behavior: "allow", wrapperProtocol: 1, updatedInput });
6473
+ if (!current()) return void reply({ ok: true, behavior: "deny", message: "permission denied" });
6474
+ const result = await 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);
6304
6475
  const allowed = current() && result.outcome === "answered" && result.optionId === "allow";
6305
6476
  reply({ ok: true, behavior: allowed ? "allow" : "deny", ...(allowed ? {} : { message: result.outcome === "expired" ? "no person answered" : "permission denied" }) });
6306
- }, () => reply({ ok: true, behavior: "deny", message: "hub permission unavailable" })).finally(() => { clearInterval(heartbeat); launch.signal.removeEventListener("abort", abortPending); }).catch(() => {});
6477
+ })().catch(() => reply({ ok: true, behavior: "deny", message: "hub permission unavailable" })).finally(() => { clearInterval(heartbeat); launch.signal.removeEventListener("abort", abortPending); }).catch(() => {});
6307
6478
  return;
6308
6479
  }
6309
6480
  case "conductor_operational": {
@@ -6312,7 +6483,7 @@ export async function startDaemon(opts: DaemonOptions) {
6312
6483
  const commands=["roster add","roster remove","stop","queue show","queue resolve","queue discard","task cancel","settings set","doctor orphans kill","logs","export"];
6313
6484
  const command=msg.op==="authorize"?msg.command:msg.op==="orphans"?"doctor orphans kill":msg.op;
6314
6485
  if(!commands.includes(command))return void reply({ok:false,error:"unknown operational command"});
6315
- const current=admitOperational(sock,command);
6486
+ const current=msg.op==="authorize"?operationalCurrent(sock):admitOperational(sock,command);current();
6316
6487
  if(msg.op==="authorize")return void reply({ok:true,instanceId});
6317
6488
  if(msg.op==="logs" || msg.op==="export") {
6318
6489
  const lines=dogfoodLines(join(opts.stateDir,msg.op==="logs"?"hub.log":"events.jsonl"),128*1024);
@@ -6321,11 +6492,11 @@ export async function startDaemon(opts: DaemonOptions) {
6321
6492
  if(msg.op==="logs") {
6322
6493
  if(msg.follow!==undefined && typeof msg.follow!=="boolean")return void reply({ok:false,error:"invalid log subscription"});
6323
6494
  if(msg.follow)operationalLogSubscribers.set(sock,current);
6324
- return void reply({ok:true,lines:lines.data.slice(-100).map(view.line),complete:lines.complete&&view.complete,truncated:!lines.complete || lines.data.length>100});
6495
+ return void reply({ok:true,lines:lines.data.slice(-100).map(view.line),complete:lines.complete&&view.complete,...(!view.complete?{incompleteReason:view.incompleteReason}:!lines.complete?{incompleteReason:"history read limit"}:{}),truncated:!lines.complete || lines.data.length>100});
6325
6496
  }
6326
6497
  if(msg.since!==undefined && (typeof msg.since!=="number" || !Number.isFinite(msg.since) || msg.since<0))return void reply({ok:false,error:"invalid export window"});
6327
6498
  const records=lines.data.flatMap(line=>{try{const row=JSON.parse(line);return typeof row?.at==="string" && Date.parse(row.at)>=(msg.since??0)?[view.event(row)]:[];}catch{return [{type:"withheld"}];}});
6328
- return void reply({ok:true,events:records.slice(-1000),complete:lines.complete&&view.complete,truncated:!lines.complete || records.length>1000});
6499
+ return void reply({ok:true,events:records.slice(-1000),complete:lines.complete&&view.complete,...(!view.complete?{incompleteReason:view.incompleteReason}:!lines.complete?{incompleteReason:"history read limit"}:{}),truncated:!lines.complete || records.length>1000});
6329
6500
  }
6330
6501
  if(msg.op!=="orphans")return void reply({ok:false,error:"unknown operational request"});
6331
6502
  const selection={root:opts.cwd,projectId,instanceId,pid:process.pid,executable:process.execPath,programs:[CLI_ENTRY,fileURLToPath(new URL("../cli/main.js",import.meta.url))]},ports={...orphanProcessPorts,current};
@@ -6339,8 +6510,8 @@ export async function startDaemon(opts: DaemonOptions) {
6339
6510
  const fresh=inspectSelectedOrphans(selection,undefined,readControl(opts.stateDir),{...ports,current:accepted});
6340
6511
  if(fresh.candidates.some(candidate=>candidate.pid===process.pid))void stop().catch(()=>{});
6341
6512
  }catch{/* Changed authority, instance or ownership never stops the hub. */}},0);
6342
- })().catch(()=>reply({ok:false,error:"operational orphan cleanup unconfirmed"}));
6343
- } catch(error) {reply({ok:false,error:error instanceof Error?error.message:"operational request refused"});}
6513
+ })().catch(error=>reply(operationalFailure(error,"operational orphan cleanup unconfirmed")));
6514
+ } catch(error) {reply(operationalFailure(error));}
6344
6515
  return;
6345
6516
  }
6346
6517
  case "permission_default": {
@@ -6362,7 +6533,7 @@ export async function startDaemon(opts: DaemonOptions) {
6362
6533
  if (c.role !== "console" && msg.op === "list") {
6363
6534
  try { publicReadCurrent(sock)(); } catch { return void reply({ ok: false, error: "public queue read unavailable for this caller" }); }
6364
6535
  } else if(c.role!=="console") {
6365
- try {if(!["show","resolve","discard"].includes(msg.op))throw new Error("queue inspection and resolution are console-only");admitOperational(sock,`queue ${msg.op}`);}catch(error){return void reply({ok:false,error:(error as Error).message});}
6536
+ try {if(!["show","resolve","discard"].includes(msg.op))throw new Error("queue inspection and resolution are console-only");const current=msg.op==="show"?operationalCurrent(sock):admitOperational(sock,`queue ${msg.op}`);current();}catch(error){return void reply(operationalFailure(error));}
6366
6537
  }
6367
6538
  if(c.role!=="console" && msg.reason!==undefined && (typeof msg.reason!=="string" || !tasks.publicMetadataText(msg.reason)))return void reply({ok:false,error:"queue reason withheld by privacy policy"});
6368
6539
  if (msg.op === "list") {
@@ -6620,15 +6791,7 @@ export async function startDaemon(opts: DaemonOptions) {
6620
6791
  }
6621
6792
  let nativeClaude = false;
6622
6793
  if (definitionOf(peer)?.transport === "claude-channel" && sessionId && msg.nativeInstanceId === instanceId) {
6623
- let expectedLaunch: string | undefined;
6624
- try {
6625
- const rows = JSON.parse(readFileSync(join(opts.stateDir, "terminal-recovery.json"), "utf8"));
6626
- 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;
6627
- } catch { /* an ordinary terminal has no Orca recovery identity */ }
6628
- try {
6629
- const launch = readClaudeLaunchProof(opts.stateDir, peer, instanceId);
6630
- if (launch) expectedLaunch = launch.launchId;
6631
- } catch { /* older/manual launches do not have a launcher marker */ }
6794
+ const expectedLaunch = claudeLaunchProof.expectedLaunch(peer);
6632
6795
  const current = claudeSession(peer);
6633
6796
  if ((phase === "stop" || phase === "post") && current.sessionId && current.sessionId !== sessionId) return void reply({ t: "facts", ok: false });
6634
6797
  if (!expectedLaunch || msg.nativeLaunchId === expectedLaunch) {
@@ -6642,12 +6805,12 @@ export async function startDaemon(opts: DaemonOptions) {
6642
6805
  if (!nativeClaude) return void reply({ t: "facts", ok: false });
6643
6806
  }
6644
6807
  if (definitionOf(peer)?.transport === "claude-channel" && msg.nativeInstanceId !== undefined && msg.nativeInstanceId !== instanceId) return void reply({ t: "facts", ok: false });
6645
- const nativePeer = nativeClaude ? bus.peers.get(peer) : undefined;
6646
- const permissionLaunch = definitionOf(peer)?.transport === "claude-channel" ? claudePermissionLaunch(peer) : undefined;
6808
+ const nativePeer = nativeClaude ? claudeLaunchProof.owner(peer) : undefined;
6809
+ const permissionLaunch = definitionOf(peer)?.transport === "claude-channel" ? claudeLaunchProof.permissionLaunch(peer) : undefined;
6647
6810
  const writeRoot = declarations[peer]?.worktree ? ownerWriteRoot(peer) : executionRoot(peer);
6648
6811
  if (declarations[peer]?.worktree && writeRoot === undefined) return void reply({ t: "facts", ok: false });
6649
6812
  const permission = nativeClaude && permissionLaunch && !permissionLaunch.unattended && msg.nativeLaunchId === permissionLaunch.launchId && writeRoot !== undefined ? permissionMode(peer) : undefined;
6650
- if (permission !== undefined && isWsPeer(nativePeer)) claudePermissionGenerations.set(peer, nativePeer.sessionGeneration);
6813
+ if (permission !== undefined && nativePeer) claudeLaunchProof.bindGeneration(peer, nativePeer.sessionGeneration);
6651
6814
  const protectedWrite = scopedWriteBlocked(phase, permission, msg.tool, () => projectFileTool(msg.tool, msg.input, writeRoot ?? opts.cwd, config.protect_instruction_files, config.data?.readonly));
6652
6815
  // 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`.
6653
6816
  const actionClasses = phase === "pre" && permission !== undefined && typeof msg.tool === "string" ? claudeActionClasses(actionBudgets, msg.tool, msg.input, writeRoot ?? opts.cwd) : [];
@@ -6657,7 +6820,7 @@ export async function startDaemon(opts: DaemonOptions) {
6657
6820
  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")));
6658
6821
  if (nativeClaude && !observesNative) return void reply({ t: "facts", ok: true, ...permissionReply });
6659
6822
  if (phase === "session") { recordSessions(); return void reply({ t: "facts", ok: true }); }
6660
- if ((phase === "start" || phase === "pre") && isWsPeer(nativePeer)) {
6823
+ if ((phase === "start" || phase === "pre") && nativePeer) {
6661
6824
  const previous = nativeTurnStart.get(peer);
6662
6825
  const current = claudeSession(peer);
6663
6826
  if (phase === "start" || nativePeer.state !== "busy" || previous?.generation !== nativePeer.sessionGeneration || previous?.session !== sessionId || previous?.launch !== (current.launchId ?? "")) {
@@ -6680,7 +6843,7 @@ export async function startDaemon(opts: DaemonOptions) {
6680
6843
  const st = hookStats.get(peer)!;
6681
6844
  event({ type: "hook_stats", peer, n: st.n, startupMs: Math.round(st.startupMs), hubMs: Math.round(st.hubMs), maxStartupMs: Math.round(st.maxStartupMs) });
6682
6845
  hookStats.delete(peer);
6683
- if (id && isWsPeer(nativePeer)) nativePeer.nativeStopped(sessionId!);
6846
+ if (id && nativePeer) nativePeer.nativeStopped(sessionId!);
6684
6847
  if (factsOn()) { factSession(peer, sessionId); readbacks(peer, claudeTranscript(sessionId, msg.transcriptPath, peer)); }
6685
6848
  };
6686
6849
  if (!nativeClaude) { finishStop(); reply({ t: "facts", ok: true }); return; } // legacy bookkeeping cannot certify native completion
@@ -6689,13 +6852,13 @@ export async function startDaemon(opts: DaemonOptions) {
6689
6852
  const readCompletion = () => { transcript ??= claudeTranscript(sessionId, msg.transcriptPath, peer); return session.sessionId && transcript ? readClaudeTranscriptCompletion(session.sessionId, transcript) : undefined; };
6690
6853
  const completionId = (id: string) => createHash("sha256").update(`${sessionId}:${session.launchId ?? "legacy"}:${id}`).digest("hex");
6691
6854
  const started = nativeTurnStart.get(peer);
6692
- const activeStart = !!started && started.session === sessionId && started.launch === (session.launchId ?? "") && isWsPeer(nativePeer) && started.generation === nativePeer.sessionGeneration;
6855
+ const activeStart = !!started && started.session === sessionId && started.launch === (session.launchId ?? "") && !!nativePeer && started.generation === nativePeer.sessionGeneration;
6693
6856
  // A seen previous completion is a duplicate only outside an active start. During B it is B's baseline.
6694
6857
  if (!activeStart) {
6695
6858
  const latest = readCompletion();
6696
6859
  if (latest?.at && nativeStopsSeen.has(completionId(latest.id))) return void reply({ t: "facts", ok: true });
6697
6860
  }
6698
- if (!started || !activeStart || !isWsPeer(nativePeer)) { log("native Stop refused for claude: current native turn start unavailable"); return void reply({ t: "facts", ok: false }); }
6861
+ if (!started || !activeStart || !nativePeer) { log("native Stop refused for claude: current native turn start unavailable"); return void reply({ t: "facts", ok: false }); }
6699
6862
  const scopeCurrent = () => {
6700
6863
  const now = claudeSession(peer);
6701
6864
  return !stopping && bus.peers.get(peer) === nativePeer && nativePeer.sessionGeneration === started.generation && now.sessionId === started.session && (now.launchId ?? "") === started.launch;
@@ -6959,12 +7122,12 @@ export async function startDaemon(opts: DaemonOptions) {
6959
7122
  if (c.role !== "console") return void reply({ ok: false, error: "settings are a person command" });
6960
7123
  return void reloadConfig("terminal", undefined, msg.confirm).then(reply, () => reply({ ok: false, error: "configuration reload could not be confirmed; inspect hub.log" }));
6961
7124
  case "settings_preview":
6962
- if(c.role!=="console")try {const def=settingDef(msg.key);if(msg.undo===true || typeof def==="string" || !(operationalSetting(def)||operationalRosterSetting(def)))throw new Error("setting is a person command");admitOperational(sock,"settings set");}catch(error){return void reply({ok:false,error:(error as Error).message});}
7125
+ if(c.role!=="console")try {const def=settingDef(msg.key);if(msg.undo===true || typeof def==="string" || !(operationalSetting(def)||operationalRosterSetting(def)))throw new OperationalAuthorityError(`this setting is reserved for the person; ${OPERATIONAL_PERSON_GUIDANCE}`,"person");operationalCurrent(sock)();}catch(error){return void reply(operationalFailure(error));}
6963
7126
  return void reply(msg.undo === true ? settingsUndoPreview() : settingsPreview(msg.key, msg.value));
6964
7127
  case "settings_set":
6965
7128
  let settingsCurrent:(()=>void)|undefined;
6966
- if(c.role!=="console")try {const def=settingDef(msg.key);if(typeof def==="string" || !(operationalSetting(def)||operationalRosterSetting(def)))throw new Error("setting is a person command");settingsCurrent=admitOperational(sock,"settings set");}catch(error){return void reply({ok:false,error:(error as Error).message});}
6967
- void operationalContext.run(settingsCurrent?{actor:c.peer!,current:settingsCurrent}:undefined,()=>settingsSet(msg.key,msg.value,settingsCurrent?"conductor":"terminal",msg.confirm,msg.now,msg.reason)).then(reply, () => reply({ ok: false, error: "the setting could not be changed; inspect hub.log" }));
7129
+ if(c.role!=="console")try {const def=settingDef(msg.key);if(typeof def==="string" || !(operationalSetting(def)||operationalRosterSetting(def)))throw new OperationalAuthorityError(`this setting is reserved for the person; ${OPERATIONAL_PERSON_GUIDANCE}`,"person");settingsCurrent=admitOperational(sock,"settings set");}catch(error){return void reply(operationalFailure(error));}
7130
+ void operationalContext.run(settingsCurrent?{actor:c.peer!,current:settingsCurrent}:undefined,()=>settingsSet(msg.key,msg.value,settingsCurrent?"conductor":"terminal",msg.confirm,msg.now,msg.reason)).then(reply, error => reply(operationalFailure(error,"the setting could not be changed; inspect hub.log")));
6968
7131
  return;
6969
7132
  case "settings_undo":
6970
7133
  if (c.role !== "console") return void reply({ ok: false, error: "settings are a person's: run ahub settings in a terminal" });
@@ -7014,8 +7177,8 @@ export async function startDaemon(opts: DaemonOptions) {
7014
7177
  return;
7015
7178
  case "peer_stop":
7016
7179
  let stopCurrent:(()=>void)|undefined;
7017
- if(c.role!=="console")try{stopCurrent=admitOperational(sock,"stop");}catch(error){return void reply({ok:false,error:(error as Error).message});}
7018
- void stopPeer(msg.peer,false,stopCurrent?{actor:c.peer!,current:stopCurrent}:undefined).then(reply).catch(() => reply({ ok: false, error: "peer stop failed; inspect ahub status and logs before retrying" }));
7180
+ if(c.role!=="console")try{stopCurrent=admitOperational(sock,"stop");}catch(error){return void reply(operationalFailure(error));}
7181
+ void stopPeer(msg.peer,false,stopCurrent?{actor:c.peer!,current:stopCurrent}:undefined).then(reply).catch(error => reply(operationalFailure(error,"peer stop failed; inspect ahub status and logs before retrying")));
7019
7182
  return;
7020
7183
  case "start":
7021
7184
  if (c.role !== "console") return void reply({ t: "started", ok: false, error: "start is a console command; use hub_peer_start with the conductor role" });
@@ -7176,6 +7339,8 @@ export async function startDaemon(opts: DaemonOptions) {
7176
7339
  }
7177
7340
  async function stopOnce(): Promise<void> {
7178
7341
  stopping = true;
7342
+ let implementerShellCleanupFailed = false;
7343
+ const implementerShellStopped = implementerShellStart?.then(wrapper => wrapper.close()).catch(() => { implementerShellCleanupFailed = true; log("host shell cleanup failed during shutdown"); });
7179
7344
  terminalLaunches.clear();
7180
7345
  // Start cancellation immediately; a failed read cleanup must not strand the other owned peers or finalization.
7181
7346
  const codeFactsStopped = codeFacts.close().catch(() => { log("code facts cleanup failed during shutdown"); });
@@ -7190,11 +7355,12 @@ export async function startDaemon(opts: DaemonOptions) {
7190
7355
  dashboard?.stop();
7191
7356
  stopConfigWatch();
7192
7357
  clearInterval(sweepTimer);
7358
+ tasks.stopCiWatchers(); // #785 P1b: no per-head CI watcher outlives the hub
7193
7359
  for (const i of intervals) clearInterval(i);
7194
7360
  for (const pending of checkpointWaits.values()) pending.done(undefined);
7195
7361
  for (const p of permissions.values()) p.done(undefined);
7196
7362
  try {
7197
- await Promise.all([terminalReadsStopped, codeFactsStopped]);
7363
+ await Promise.all([terminalReadsStopped, codeFactsStopped, implementerShellStopped]);
7198
7364
  await terminalLaunches.settled();
7199
7365
  // A peer can still be inside memory recall or its native handshake when kill arrives.
7200
7366
  // Drain those starts before taking the final owned-process snapshot.
@@ -7235,6 +7401,7 @@ export async function startDaemon(opts: DaemonOptions) {
7235
7401
  } catch { /* another owner or no published state: never remove it */ }
7236
7402
  onStop?.();
7237
7403
  }
7404
+ if (implementerShellCleanupFailed) throw new Error("host shell cleanup unconfirmed");
7238
7405
  }
7239
7406
  let onStop: (() => void) | undefined;
7240
7407
 
@@ -7296,6 +7463,8 @@ export async function startDaemon(opts: DaemonOptions) {
7296
7463
  // After a crash that recorded Pi, crash recovery starts it (#66): its recorded session first, a fresh one if that fails.
7297
7464
  const piRecovered = !archivedPiContinuity && !!crashed?.peers.some((p) => definitionOf(p.peer)?.session.restoreOnStart);
7298
7465
  if (piAutoStart() && !recoveryActive() && !piRecovered) autoStartPi();
7466
+ // #843 AC3: at hub start, after the roster and the control socket exist; never awaited, so a slow probe delays no start.
7467
+ void sweepAgentDrift();
7299
7468
  return { bus, token, port: server.port as number, bandEvaluations: () => bandEvaluations, sdlcFileReads: () => sdlcFileReads, stop, stopped: new Promise<void>((r) => (onStop = r)) };
7300
7469
  } finally {
7301
7470
  if (!ready) for (const cleanup of startupCleanup.reverse()) { try { await cleanup(); } catch { /* preserve startup error */ } }