@staix/agent-hub 0.18.0 → 0.19.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 (73) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +4 -4
  3. package/docs/agent-notes/README.md +6 -0
  4. package/docs/agent-notes/adapters.md +10 -8
  5. package/docs/agent-notes/daemon.md +5 -2
  6. package/docs/agent-notes/local-worker.md +2 -1
  7. package/docs/agent-notes/tasks.md +4 -1
  8. package/docs/agent-notes/tests.md +6 -3
  9. package/docs/dogfood.md +9 -3
  10. package/docs/memory.md +1 -1
  11. package/docs/operations.md +66 -28
  12. package/docs/quickstart.md +9 -5
  13. package/docs/security.md +22 -13
  14. package/docs/smoke.md +3 -3
  15. package/docs/specs/2026-09-19-agent-hub-design.md +16 -5
  16. package/docs/verification/2026-10-25-remote-docker-transport.md +10 -0
  17. package/docs/verification/2026-10-26-agy-1.4.7.md +69 -0
  18. package/docs/verification/2026-10-26-claude-implementer-2.1.310.md +69 -0
  19. package/docs/verification/2026-10-26-headless-claude-shell-context.md +62 -0
  20. package/docs/verification/2026-10-26-jcode-0.99.1.md +49 -0
  21. package/docs/verification/2026-10-26-kimi-232-reviewer-compat.md +53 -0
  22. package/docs/verification/2026-10-26-pi-140-compat.md +41 -0
  23. package/docs/verification/2026-10-26-qwen-0261-acp.md +60 -0
  24. package/docs/verification/2026-10-27-0.19.0.md +86 -0
  25. package/docs/verification/claude-permission-transport-native.py +187 -0
  26. package/docs/verification/claude-shell-wrapper-host.ts +20 -0
  27. package/docs/verification/claude-shell-wrapper-native.py +145 -0
  28. package/docs/verification/pi-140-model-free.ts +57 -0
  29. package/docs/verification/pi-140-native.mjs +21 -0
  30. package/docs/verified.json +14 -14
  31. package/package.json +2 -2
  32. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  33. package/plugins/agent-hub/server.js +273 -68
  34. package/src/adapters/agy/entry.ts +1 -1
  35. package/src/adapters/claude-channel.ts +7 -3
  36. package/src/adapters/claude-implementer.ts +5 -3
  37. package/src/adapters/compat.ts +32 -18
  38. package/src/adapters/conformance/fakes/pi-rpc.ts +1 -0
  39. package/src/adapters/definitions/builtin-metadata.ts +2 -2
  40. package/src/adapters/definitions/native-factories.ts +7 -2
  41. package/src/adapters/definitions/reviewer-variants.ts +2 -2
  42. package/src/adapters/pi.ts +39 -3
  43. package/src/adapters/qwen-dual.ts +25 -0
  44. package/src/adapters/registry-types.ts +6 -2
  45. package/src/adapters/registry.ts +1 -0
  46. package/src/cli/agents.ts +69 -7
  47. package/src/cli/claude-shell-wrapper.ts +54 -0
  48. package/src/cli/main.ts +30 -24
  49. package/src/cli/output.ts +4 -0
  50. package/src/hub/agent-drift.ts +61 -0
  51. package/src/hub/board.ts +14 -2
  52. package/src/hub/check-runner.ts +148 -18
  53. package/src/hub/claude-shell-wrapper.ts +349 -0
  54. package/src/hub/config-edit.ts +2 -2
  55. package/src/hub/config-tier.ts +2 -2
  56. package/src/hub/daemon.ts +197 -35
  57. package/src/hub/host-executable.ts +20 -1
  58. package/src/hub/instances.ts +18 -2
  59. package/src/hub/lifecycle.ts +1 -1
  60. package/src/hub/operational-authority.ts +7 -0
  61. package/src/hub/operational-view.ts +6 -10
  62. package/src/hub/read-only-shell-proof.ts +7 -6
  63. package/src/hub/review-scope.ts +13 -0
  64. package/src/hub/sdlc-metrics.ts +6 -4
  65. package/src/hub/task-shell-command.ts +85 -0
  66. package/src/hub/task-shell-default.ts +78 -0
  67. package/src/hub/task-shell-proof.ts +171 -0
  68. package/src/hub/task-shell-sandbox.ts +61 -0
  69. package/src/hub/tasks.ts +139 -13
  70. package/src/local/deny.ts +91 -11
  71. package/src/local/sandbox.ts +67 -28
  72. package/src/local/shell-environment.ts +10 -0
  73. 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";
@@ -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";
@@ -523,6 +532,8 @@ 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;
@@ -558,6 +569,13 @@ export interface DaemonOptions {
558
569
  taskSweepNow?: () => number;
559
570
  /** Test seam: the period of the SDLC band evaluation, default five minutes. */
560
571
  bandsEveryMs?: number;
572
+ /**
573
+ * #843 AC3: read each gated agent CLI's installed version at start and daily, and tell the planner about drift.
574
+ * `true` uses the same probe as `ahub agents` (the product sets it); a function replaces the probe (tests). Off by default.
575
+ */
576
+ agentDrift?: boolean | (() => Promise<readonly AgentVersionRow[]>);
577
+ /** Test seam: the period of the agent drift sweep, default one day. */
578
+ agentDriftEveryMs?: number;
561
579
  /** Test seam: an extra hold on the SDLC band evaluation, like a storage error. */
562
580
  bandsHeld?: () => boolean;
563
581
  /** How long a research record waits after an approval for the task's open turns to end (#247). Tests shrink this. */
@@ -720,12 +738,12 @@ export async function startDaemon(opts: DaemonOptions) {
720
738
  mkdirSync(opts.stateDir, { recursive: true });
721
739
  const operationalLogSubscribers=new Map<Sock,()=>void>();
722
740
  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();}};
741
+ const checkOperational=(authority:SettingAuthority)=>{if(authority==="conductor"){const scope=operationalContext.getStore();if(!scope)throw new OperationalAuthorityError("operational conductor authority unavailable");scope.current();}};
724
742
  const logFile = join(opts.stateDir, "hub.log");
725
743
  // The state dir can vanish under a running hub (issue #56): a log line must never take a handler down with it.
726
744
  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. */}}
745
+ let publicLine:string|undefined,view:ReturnType<typeof operationalView>|undefined;
746
+ 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
747
  };
730
748
  let runningRouting = currentRouting(opts.cwd, log);
731
749
  let acceptedSources = parseConfigSources(configSourceTexts(opts.cwd), runningRouting, opts.cwd, externalAdapters);
@@ -1021,13 +1039,24 @@ export async function startDaemon(opts: DaemonOptions) {
1021
1039
  if (refused) log(`limits: ${env.from}: ${refused}`);
1022
1040
  return refused;
1023
1041
  };
1042
+ /**
1043
+ * #852: what a reviewer reads. A fresh-reviewer instance's own `review_scope` (config.local.json only, tier 3) wins, else its definition's
1044
+ * scope (the sandboxed codex and kimi reviewers are `code`), else `full`. Only a review-only instance has one; any other peer is `full`.
1045
+ */
1046
+ const reviewScopeOf = (peer: string): ReviewScope => {
1047
+ const declared = Object.hasOwn(declarations, peer) ? declarations[peer]!.review_scope : undefined;
1048
+ if (declared) return declared;
1049
+ return reviewOnlyPeer(peer) && definitionOf(peer)?.reviewer?.scope === "code" ? "code" : "full";
1050
+ };
1024
1051
  /**
1025
1052
  * #685: the provider, model and profile names an extra Jcode instance was declared with, for status, doctor and the dashboard,
1026
1053
  * exactly as the person wrote them (they authenticate inside Jcode). Never a URL, a token or the native home path.
1027
1054
  */
1028
1055
  const jcodeModelSource = (id: string): { modelSource?: string } => {
1029
1056
  const instance = declarations[id];
1030
- if (!instance || definitionOf(id)?.instances?.nativeHome !== true) return {};
1057
+ if (!instance) return {};
1058
+ // #844: a model exists on an instance only where validation admitted it (a headless implementer whose definition declares modelSelect).
1059
+ if (definitionOf(id)?.instances?.nativeHome !== true) return instance.model ? { modelSource: instance.model } : {};
1031
1060
  return { modelSource: [instance.provider, instance.model, instance.provider_profile && `profile ${instance.provider_profile}`].filter(Boolean).join(" ") || "native default" };
1032
1061
  };
1033
1062
  /**
@@ -1591,6 +1620,7 @@ export async function startDaemon(opts: DaemonOptions) {
1591
1620
  instances: () => declarations,
1592
1621
  adapterCatalog,
1593
1622
  reviewOnly: reviewOnlyPeer,
1623
+ reviewScope: reviewScopeOf,
1594
1624
  // #223: a headless reviewer's verdict holds only while the same child it started with still runs for that task.
1595
1625
  reviewAuthority: runningReviewAuthority,
1596
1626
  requestAuthority: by => {
@@ -2273,6 +2303,86 @@ export async function startDaemon(opts: DaemonOptions) {
2273
2303
  const binding = c.toolsOwner, launch = currentImplementerTools(c);
2274
2304
  return () => c.toolsOwner === binding && !!launch && currentImplementerTools(c) === launch;
2275
2305
  }
2306
+ const shellProjectRepository = githubOriginRepository(opts.cwd);
2307
+ const shellNeedsDefault = Object.keys(declarations).some(peer => instanceDefinitionOf(peer, declarations, adapterCatalog)?.start.daemonPort === "claude-implementer");
2308
+ const shellDefaultBranch = shellNeedsDefault && shellProjectRepository
2309
+ ? await (opts.shellDefaultBranchProbe ?? readTaskShellDefaultBranch)(opts.cwd, hostShellEnvironment({ PATH: hostShellPath(opts.cwd, opts.cwd) ?? "" }), () => true, opts.cwd, shellProjectRepository)
2310
+ : undefined;
2311
+ let implementerShellStart: Promise<HostShellWrapper> | undefined;
2312
+ const implementerShellProfiles = new WeakMap<object, { key: string; sandbox: { profile: string; readOnlyData: boolean } }>();
2313
+ async function automaticImplementerShell(c: Client, tool: string, original: Record<string, unknown>, current: () => boolean): Promise<{ command: string; timeout: number; description: string } | undefined> {
2314
+ const peer = c.peer, launch = currentImplementerTools(c), root = peer && ownerWriteRoot(peer), policy = config;
2315
+ if (!peer || !launch || !root || launch.task === undefined || tool !== "Bash" || permissionMode(peer) !== "ask-when-needed") return;
2316
+ if (Object.keys(original).some(key => !["command", "description", "timeout"].includes(key)) || typeof original.command !== "string"
2317
+ || original.description !== undefined && (typeof original.description !== "string" || original.description.length > 2000)
2318
+ || original.timeout !== undefined && (!Number.isSafeInteger(original.timeout) || Number(original.timeout) < 1 || Number(original.timeout) > 600_000)) return;
2319
+ const input = Object.freeze({ ...original }), serialized = JSON.stringify(input);
2320
+ if (serialized.length > 8000 || !tasks.publicMetadataText(serialized)) return;
2321
+ const path = hostShellPath(opts.cwd, root);
2322
+ if (!path) return;
2323
+ const taskId = launch.task, extra = Object.freeze({ ...proxyEnv(sandboxNetwork), AGENTHUB_PEER_ID: peer, PATH: path });
2324
+ const env = hostShellEnvironment(extra), environment = JSON.stringify(env);
2325
+ const policyState = () => JSON.stringify([config.local.deny, config.local.read_allow, config.data?.readonly, config.protect_instruction_files]);
2326
+ const policyFingerprint = policyState();
2327
+ const authority = () => {
2328
+ const task = board.get(taskId);
2329
+ return current() && config === policy && policyState() === policyFingerprint && permissionMode(peer) === "ask-when-needed" && !contextPii(peer)
2330
+ && !!task && task.owner === peer && ["in_progress", "changes_requested"].includes(task.state) && !tasks.isPii(task)
2331
+ && claudeActionClasses(actionBudgets, tool, input, root).length === 0 && hostShellPath(opts.cwd, root) === path && JSON.stringify(hostShellEnvironment(extra)) === environment;
2332
+ };
2333
+ if (!authority()) return;
2334
+ const binding = readTaskShellBinding(root, env, authority, opts.cwd, shellDefaultBranch);
2335
+ if (!binding) return;
2336
+ const shellOptions = { root, projectRoot: opts.cwd, deny: config.local.deny, readOnly: config.data?.readonly, env: extra };
2337
+ const taskOptions = { ...shellOptions, env, binding, protectInstructions: config.protect_instruction_files, repository: shellProjectRepository, publicText: (text: string) => tasks.publicMetadataText(text) };
2338
+ const readProof = proveReadOnlyShell(input, shellOptions, authority), taskProof = readProof ? undefined : proveTaskShell(input.command, taskOptions, authority);
2339
+ const confined = !!readProof || taskProof?.requiresSandbox === true;
2340
+ if (confined && !sandboxAvailable()) return;
2341
+ // The planner's fallback binds only on a task-gate request; read-only requests use an already recorded task branch.
2342
+ if (board.get(taskId)?.refs.branch === undefined && !taskProof) return;
2343
+ if (!readProof && !taskProof || !tasks.bindShellBranch(peer, taskId, { ...binding, worktree: root }, authority)) return;
2344
+ const fingerprint = (readProof ?? taskProof)!.fingerprint;
2345
+ const currentSandbox = () => {
2346
+ if (!confined) return undefined;
2347
+ const state = realPath(opts.stateDir);
2348
+ const owner = c.toolsOwner!.owner, key = JSON.stringify([root, state, policyFingerprint]), cached = implementerShellProfiles.get(owner);
2349
+ if (cached?.key === key) return cached.sandbox;
2350
+ // Cache only this synchronous proof burst: newly initialized submodules change concrete Git-directory protections.
2351
+ // ponytail: shared profile discovery is synchronous; move it to an asynchronous, deadline-bound inventory proof.
2352
+ // read_allow may include the canonical checkout. Its live control credentials remain denied independently of the worktree root.
2353
+ const restrictions = taskShellSandboxRestrictions(root, binding.branch, { projectRoot: opts.cwd, env });
2354
+ if (!restrictions) return undefined;
2355
+ 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 };
2356
+ const entry = { key, sandbox };
2357
+ implementerShellProfiles.set(owner, entry);
2358
+ queueMicrotask(() => { if (implementerShellProfiles.get(owner) === entry) implementerShellProfiles.delete(owner); });
2359
+ return sandbox;
2360
+ };
2361
+ let sandbox: { profile: string; readOnlyData: boolean } | undefined;
2362
+ try { sandbox = currentSandbox(); } catch { return; }
2363
+ if (confined && !sandbox) return;
2364
+ const recheck = () => {
2365
+ if (!authority() || !tasks.bindShellBranch(peer, taskId, { ...binding, worktree: root }, authority)) return false;
2366
+ const observed = readTaskShellBinding(root, env, authority, opts.cwd, shellDefaultBranch);
2367
+ if (!observed || observed.branch !== binding.branch || observed.defaultBranch !== binding.defaultBranch) return false;
2368
+ const proof = readProof ? proveReadOnlyShell(input, shellOptions, authority) : proveTaskShell(input.command, taskOptions, authority);
2369
+ let observedSandbox: typeof sandbox;
2370
+ try { observedSandbox = currentSandbox(); } catch { return false; }
2371
+ return proof?.fingerprint === fingerprint && observedSandbox?.profile === sandbox?.profile && observedSandbox?.readOnlyData === sandbox?.readOnlyData && authority();
2372
+ };
2373
+ // A fixed capability on the channel request prevents an older plugin from ignoring updatedInput and running the original command.
2374
+ let wrapper: HostShellWrapper;
2375
+ try { wrapper = await (implementerShellStart ??= createHostShellWrapper().catch(error => { implementerShellStart = undefined; throw error; })); }
2376
+ catch { return; } // An unavailable host capability supplies no allow; the ordinary person path remains available.
2377
+ if (!recheck()) return;
2378
+ const proof = (readProof ?? taskProof)!, git = proof.program === "git";
2379
+ const argv = readProof?.program === "git" ? hubGitArgv([...proof.argv]).slice(1) : proof.argv;
2380
+ const command = wrapper.issue({ peer, task: taskId, generation: launch.generation, root, branch: binding.branch, executable: proof.executable, argv,
2381
+ env: taskProof?.env ?? (git ? Object.fromEntries(Object.entries(hubGitEnv(env, { repositoryIsolation: true })).filter((entry): entry is [string, string] => entry[1] !== undefined)) : env),
2382
+ signal: launch.signal, timeoutMs: Number(input.timeout ?? 120_000), current: recheck, activity: () => c.toolsOwner?.owner.keepApprovalAlive?.(), ...(sandbox ? { sandbox } : {}) });
2383
+ if (command) log(`permission auto-allowed for ${peer}: bash (host wrapper ${readProof ? "read-only shell" : "task command"})`);
2384
+ return command && { command: command.command, timeout: Math.min(600_000, Number(input.timeout ?? 120_000) + 2000), description: "Hub-owned task command" };
2385
+ }
2276
2386
  function canonicalWriteScope(repo: Repo, budget?: SnapshotBudget): boolean | undefined {
2277
2387
  // ponytail: D1 is inert for builtin owners while .worktrees exists (a deliberate false negative).
2278
2388
  // Bind each owner to its real execution worktree before narrowing this unknown scope.
@@ -2376,10 +2486,36 @@ export async function startDaemon(opts: DaemonOptions) {
2376
2486
  } catch { /* The audit event is retained even if its advisory notice cannot be delivered. */ }
2377
2487
  }
2378
2488
  }
2489
+ // #843 AC3: local agent CLI drift. One probe path (`agentVersionsAsync`, the one `ahub agents` shares), no network, no install.
2490
+ let agentDrift: DriftedAgent[] = [];
2491
+ let agentDriftRunning = false;
2492
+ // 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.
2493
+ let agentDriftOwed = true;
2494
+ // A planner that is not attached yet gets it from the tick as soon as it is; a recorded marker is never sent twice.
2495
+ const offerAgentDrift = () => noticeDrift(agentDrift, {
2496
+ record: marker => board.recordAgentDriftNotice(marker), forget: marker => board.forgetAgentDriftNotice(marker),
2497
+ planners: plannerPeers, isRostered: peer => bus.isRostered(peer), publish: env => bus.publish(env),
2498
+ });
2499
+ const sweepAgentDrift = async () => {
2500
+ const probe = opts.agentDrift === true ? () => agentVersionsAsync(configuredCommands(config)) : opts.agentDrift || undefined;
2501
+ if (!probe || agentDriftRunning || stopping || recoveryActive()) return;
2502
+ agentDriftRunning = true;
2503
+ agentDriftOwed = false;
2504
+ try {
2505
+ const drift = driftedAgents(await probe());
2506
+ if (stopping) return;
2507
+ agentDrift = drift;
2508
+ offerAgentDrift();
2509
+ writeStatus();
2510
+ } catch { log("agent CLI drift sweep failed; run ahub agents"); } finally { agentDriftRunning = false; }
2511
+ };
2379
2512
  const intervals = [
2513
+ setInterval(() => { void sweepAgentDrift(); }, opts.agentDriftEveryMs ?? 86_400_000),
2380
2514
  setInterval(() => {
2381
2515
  void refreshRunner().catch(() => {});
2382
2516
  try { tasks.flushPlannerNotices(); } catch { /* the board remains authoritative if notice delivery fails */ }
2517
+ if (agentDriftOwed && !stopping && !recoveryActive()) void sweepAgentDrift();
2518
+ if (agentDrift.length && !stopping && !recoveryActive()) try { offerAgentDrift(); } catch { /* the next tick tries again */ }
2383
2519
  for (const pending of checkpointWaits.values()) if (!pending.valid()) pending.done(undefined);
2384
2520
  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
2521
  for (const row of bus.queueList(undefined, true)) if (row.state === "needs_review") supervision.needsReview(row.peer, row.id);
@@ -3239,6 +3375,7 @@ export async function startDaemon(opts: DaemonOptions) {
3239
3375
  pluginBundle: SERVER_JS,
3240
3376
  pendingRestart: allPendingRestart(),
3241
3377
  retainedConfig,
3378
+ ...(agentDrift.length ? { agentDrift } : {}),
3242
3379
  ...(() => { const warning = mainCheckoutWarning(opts.cwd, tasks.publicMetadataText, checkoutBranch); return warning ? { mainCheckout: warning } : {}; })(),
3243
3380
  startModes: Object.fromEntries([...START_MODE_PEERS, ...configuredIds].map(peer => [peer, startModeOf(config.peers, peer, declarations, adapterCatalog)])),
3244
3381
  memoryProfile: memoryProfileStatus(includeAttentionItems),
@@ -3266,7 +3403,7 @@ export async function startDaemon(opts: DaemonOptions) {
3266
3403
  ...(dashboard ? { uiOrigin: dashboard.origin } : {}),
3267
3404
  codexProxyPort: opts.codexProxyPort,
3268
3405
  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 ?? [] } : {}) }]; }),
3406
+ 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), ...(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
3407
  ),
3271
3408
  taskWip: tasks.wipBreakdown(peerIds),
3272
3409
  boardReadCapabilities: { releaseFilter: true, columnMetadata: true },
@@ -4645,7 +4782,11 @@ export async function startDaemon(opts: DaemonOptions) {
4645
4782
  executionOwners.set(peer, { owner: pi, cwd: executionCwd });
4646
4783
  bus.add(pi);
4647
4784
  // 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; }
4785
+ try { await pi.start(); } catch (error) {
4786
+ await pi.stop().catch((stop: Error) => log(`pi stop after a failed start: ${stop.message}`));
4787
+ if (error instanceof PiVersionRefusal) return { ok: false, error: sanitize(error.message).slice(0, 1000) };
4788
+ throw error;
4789
+ }
4649
4790
  return { ok: true, ...(mode === "tui" ? { launch: pi.tuiLaunch } : {}) };
4650
4791
  }
4651
4792
  if (adapter?.start.daemonPort === "local-worker") {
@@ -4754,7 +4895,7 @@ export async function startDaemon(opts: DaemonOptions) {
4754
4895
  if (selected !== "ask" && selected !== "ask-when-needed") return { ok: false, error: "Claude implementer refuses never-ask" };
4755
4896
  const implementer = adapter.start.create!({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: config, permission: selected, mcpServers: [], daemon: DAEMON_PORTS["claude-implementer"]({
4756
4897
  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,
4898
+ 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
4899
  admit: env => {
4759
4900
  try {
4760
4901
  if (env.private || !currentRoot() || bus.peers.get(peer) !== implementer) return false;
@@ -4976,8 +5117,17 @@ export async function startDaemon(opts: DaemonOptions) {
4976
5117
  };
4977
5118
  }
4978
5119
 
5120
+ let operationalViewCache: { board: number; config: number; routing: ReturnType<typeof activeRouting>; privacy: string; view: ReturnType<typeof operationalView> } | undefined;
4979
5121
  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)});
5122
+ const revision=board.statusRevision(),routing=activeRouting(),privacy=JSON.stringify([config.pii?.paths??[],config.local.deny]);
5123
+ if(operationalViewCache && operationalViewCache.board===revision && operationalViewCache.config===configRevision && operationalViewCache.routing===routing && operationalViewCache.privacy===privacy)return operationalViewCache.view;
5124
+ const privatePaths=[...(config.pii?.paths??[]),...config.local.deny];
5125
+ const currentPrivate=(task:Task)=>tasks.isPii(task) || [...(task.refs.paths??[]),...(task.plan?.paths??[])].some(path=>matchesPolicy(opts.cwd,path,privatePaths,true));
5126
+ 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)});
5127
+ operationalViewCache={board:revision,config:configRevision,routing,privacy,view};return view;
5128
+ }
5129
+ function operationalFailure(error: unknown, fallback?: string): Record<string, unknown> {
5130
+ 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
5131
  }
4982
5132
  /** Ordinary read authority never borrows the console identity or conductor admission. */
4983
5133
  function publicReadCurrent(sock: Sock): () => void {
@@ -5056,9 +5206,9 @@ export async function startDaemon(opts: DaemonOptions) {
5056
5206
  function operationalCurrent(sock:Sock,acceptedAfterClose=false):()=>void {
5057
5207
  const client=sock.data,peer=client.peer;
5058
5208
  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");
5209
+ 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
5210
  try {requireConductor(peer,config.roles);}
5061
- catch {throw new Error(operationalRefusal(peer,"operational command"));}
5211
+ catch {throw new OperationalAuthorityError("this operation requires the explicit conductor role","conductor");}
5062
5212
  };
5063
5213
  }
5064
5214
  function admitOperational(sock:Sock,command:string):()=>void {
@@ -5486,7 +5636,7 @@ export async function startDaemon(opts: DaemonOptions) {
5486
5636
  const message = sanitize(error instanceof Error ? error.message : "configuration reload could not be confirmed").slice(0, 600);
5487
5637
  const failure = `${fingerprint}:${message}`;
5488
5638
  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 };
5639
+ return { ok: false, error: message, ...(error instanceof OperationalAuthorityError ? { authorityRefused: true, authorityScope: error.scope } : {}), ...(published ? { applied: true } : {}), pendingRestart };
5490
5640
  }
5491
5641
  }
5492
5642
 
@@ -5633,7 +5783,7 @@ export async function startDaemon(opts: DaemonOptions) {
5633
5783
  return {ok:true,saved:true,authorityEnded:true,text:`${peer}: roster write saved; authority ended before remaining lifecycle work; inspect status before continuing`};
5634
5784
  }
5635
5785
  }
5636
- return { ok: false, error: sanitize((error as Error).message), ...(saved ? { saved: true } : {}) };
5786
+ return { ok: false, error: sanitize((error as Error).message), ...(error instanceof OperationalAuthorityError ? { authorityRefused: true, authorityScope: error.scope } : {}), ...(saved ? { saved: true } : {}) };
5637
5787
  } finally { if (rosterChanging === peer) { rosterChanging = undefined; bus.rosterChanged(peer); if (configWatchPending && !stopping) { configWatchPending = false; clearTimeout(reloadDebounce); reloadDebounce = setTimeout(runConfigWatch, 100); reloadDebounce.unref?.(); } } }
5638
5788
  }
5639
5789
  /** A checked definition and value, or the refusal. Every settings write and preview starts here. */
@@ -5706,7 +5856,7 @@ export async function startDaemon(opts: DaemonOptions) {
5706
5856
  : ["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
5857
  return { ok: true, text: `${def.key}: ${settingText(to)} (${timing})` };
5708
5858
  } catch (error) {
5709
- return { ok: false, error: sanitize((error as Error).message), ...(saved ? { saved: true } : {}) };
5859
+ return { ok: false, error: sanitize((error as Error).message), ...(error instanceof OperationalAuthorityError ? { authorityRefused: true, authorityScope: error.scope } : {}), ...(saved ? { saved: true } : {}) };
5710
5860
  }
5711
5861
  }
5712
5862
 
@@ -6195,11 +6345,14 @@ export async function startDaemon(opts: DaemonOptions) {
6195
6345
  const c = sock.data;
6196
6346
  let refusalRecorded=false;
6197
6347
  const reply = (body: Record<string, unknown>) => {
6198
- if (c.toolsOwner && !currentImplementerTools(c)) body = { ok: false, error: "Claude child generation or execution root changed" };
6199
6348
  const label=operationalRequestLabel(msg);
6349
+ if (c.toolsOwner && !currentImplementerTools(c)) body = { ok: false, error: "Claude child generation or execution root changed", ...(label ? {authorityRefused:true,authorityScope:"operational"}: {}) };
6200
6350
  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};
6351
+ if(body.authorityRefused===true) {
6352
+ if(!refusalRecorded)refusalRecorded=event({type:"agent_cli",peer:c.peer,command:label,refused:true});
6353
+ const error=body.authorityScope==="conductor"?operationalRefusal(c.peer,label):body.authorityScope==="person"?String(body.error):`${String(body.error??"operational authority unavailable")}; ${OPERATIONAL_PERSON_GUIDANCE}`;
6354
+ body={...body,error,refusalAudited:refusalRecorded};
6355
+ } else body={...body,authorityRefused:false};
6203
6356
  }
6204
6357
  if (sock.readyState === WebSocket.OPEN) sock.send(JSON.stringify({ rid: msg.rid, ...body }));
6205
6358
  };
@@ -6300,10 +6453,14 @@ export async function startDaemon(opts: DaemonOptions) {
6300
6453
  // answer still revalidate native root evidence, without synchronous Git on every tick.
6301
6454
  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
6455
  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 => {
6456
+ void (async () => {
6457
+ const updatedInput = msg.wrapperProtocol === 1 ? await automaticImplementerShell(c, msg.tool, msg.input, () => sock.readyState === WebSocket.OPEN && current()) : undefined;
6458
+ if (updatedInput && current()) return void reply({ ok: true, behavior: "allow", wrapperProtocol: 1, updatedInput });
6459
+ if (!current()) return void reply({ ok: true, behavior: "deny", message: "permission denied" });
6460
+ 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
6461
  const allowed = current() && result.outcome === "answered" && result.optionId === "allow";
6305
6462
  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(() => {});
6463
+ })().catch(() => reply({ ok: true, behavior: "deny", message: "hub permission unavailable" })).finally(() => { clearInterval(heartbeat); launch.signal.removeEventListener("abort", abortPending); }).catch(() => {});
6307
6464
  return;
6308
6465
  }
6309
6466
  case "conductor_operational": {
@@ -6312,7 +6469,7 @@ export async function startDaemon(opts: DaemonOptions) {
6312
6469
  const commands=["roster add","roster remove","stop","queue show","queue resolve","queue discard","task cancel","settings set","doctor orphans kill","logs","export"];
6313
6470
  const command=msg.op==="authorize"?msg.command:msg.op==="orphans"?"doctor orphans kill":msg.op;
6314
6471
  if(!commands.includes(command))return void reply({ok:false,error:"unknown operational command"});
6315
- const current=admitOperational(sock,command);
6472
+ const current=msg.op==="authorize"?operationalCurrent(sock):admitOperational(sock,command);current();
6316
6473
  if(msg.op==="authorize")return void reply({ok:true,instanceId});
6317
6474
  if(msg.op==="logs" || msg.op==="export") {
6318
6475
  const lines=dogfoodLines(join(opts.stateDir,msg.op==="logs"?"hub.log":"events.jsonl"),128*1024);
@@ -6321,11 +6478,11 @@ export async function startDaemon(opts: DaemonOptions) {
6321
6478
  if(msg.op==="logs") {
6322
6479
  if(msg.follow!==undefined && typeof msg.follow!=="boolean")return void reply({ok:false,error:"invalid log subscription"});
6323
6480
  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});
6481
+ 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
6482
  }
6326
6483
  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
6484
  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});
6485
+ 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
6486
  }
6330
6487
  if(msg.op!=="orphans")return void reply({ok:false,error:"unknown operational request"});
6331
6488
  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 +6496,8 @@ export async function startDaemon(opts: DaemonOptions) {
6339
6496
  const fresh=inspectSelectedOrphans(selection,undefined,readControl(opts.stateDir),{...ports,current:accepted});
6340
6497
  if(fresh.candidates.some(candidate=>candidate.pid===process.pid))void stop().catch(()=>{});
6341
6498
  }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"});}
6499
+ })().catch(error=>reply(operationalFailure(error,"operational orphan cleanup unconfirmed")));
6500
+ } catch(error) {reply(operationalFailure(error));}
6344
6501
  return;
6345
6502
  }
6346
6503
  case "permission_default": {
@@ -6362,7 +6519,7 @@ export async function startDaemon(opts: DaemonOptions) {
6362
6519
  if (c.role !== "console" && msg.op === "list") {
6363
6520
  try { publicReadCurrent(sock)(); } catch { return void reply({ ok: false, error: "public queue read unavailable for this caller" }); }
6364
6521
  } 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});}
6522
+ 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
6523
  }
6367
6524
  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
6525
  if (msg.op === "list") {
@@ -6959,12 +7116,12 @@ export async function startDaemon(opts: DaemonOptions) {
6959
7116
  if (c.role !== "console") return void reply({ ok: false, error: "settings are a person command" });
6960
7117
  return void reloadConfig("terminal", undefined, msg.confirm).then(reply, () => reply({ ok: false, error: "configuration reload could not be confirmed; inspect hub.log" }));
6961
7118
  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});}
7119
+ 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
7120
  return void reply(msg.undo === true ? settingsUndoPreview() : settingsPreview(msg.key, msg.value));
6964
7121
  case "settings_set":
6965
7122
  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" }));
7123
+ 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));}
7124
+ 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
7125
  return;
6969
7126
  case "settings_undo":
6970
7127
  if (c.role !== "console") return void reply({ ok: false, error: "settings are a person's: run ahub settings in a terminal" });
@@ -7014,8 +7171,8 @@ export async function startDaemon(opts: DaemonOptions) {
7014
7171
  return;
7015
7172
  case "peer_stop":
7016
7173
  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" }));
7174
+ if(c.role!=="console")try{stopCurrent=admitOperational(sock,"stop");}catch(error){return void reply(operationalFailure(error));}
7175
+ 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
7176
  return;
7020
7177
  case "start":
7021
7178
  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 +7333,8 @@ export async function startDaemon(opts: DaemonOptions) {
7176
7333
  }
7177
7334
  async function stopOnce(): Promise<void> {
7178
7335
  stopping = true;
7336
+ let implementerShellCleanupFailed = false;
7337
+ const implementerShellStopped = implementerShellStart?.then(wrapper => wrapper.close()).catch(() => { implementerShellCleanupFailed = true; log("host shell cleanup failed during shutdown"); });
7179
7338
  terminalLaunches.clear();
7180
7339
  // Start cancellation immediately; a failed read cleanup must not strand the other owned peers or finalization.
7181
7340
  const codeFactsStopped = codeFacts.close().catch(() => { log("code facts cleanup failed during shutdown"); });
@@ -7194,7 +7353,7 @@ export async function startDaemon(opts: DaemonOptions) {
7194
7353
  for (const pending of checkpointWaits.values()) pending.done(undefined);
7195
7354
  for (const p of permissions.values()) p.done(undefined);
7196
7355
  try {
7197
- await Promise.all([terminalReadsStopped, codeFactsStopped]);
7356
+ await Promise.all([terminalReadsStopped, codeFactsStopped, implementerShellStopped]);
7198
7357
  await terminalLaunches.settled();
7199
7358
  // A peer can still be inside memory recall or its native handshake when kill arrives.
7200
7359
  // Drain those starts before taking the final owned-process snapshot.
@@ -7235,6 +7394,7 @@ export async function startDaemon(opts: DaemonOptions) {
7235
7394
  } catch { /* another owner or no published state: never remove it */ }
7236
7395
  onStop?.();
7237
7396
  }
7397
+ if (implementerShellCleanupFailed) throw new Error("host shell cleanup unconfirmed");
7238
7398
  }
7239
7399
  let onStop: (() => void) | undefined;
7240
7400
 
@@ -7296,6 +7456,8 @@ export async function startDaemon(opts: DaemonOptions) {
7296
7456
  // After a crash that recorded Pi, crash recovery starts it (#66): its recorded session first, a fresh one if that fails.
7297
7457
  const piRecovered = !archivedPiContinuity && !!crashed?.peers.some((p) => definitionOf(p.peer)?.session.restoreOnStart);
7298
7458
  if (piAutoStart() && !recoveryActive() && !piRecovered) autoStartPi();
7459
+ // #843 AC3: at hub start, after the roster and the control socket exist; never awaited, so a slow probe delays no start.
7460
+ void sweepAgentDrift();
7299
7461
  return { bus, token, port: server.port as number, bandEvaluations: () => bandEvaluations, sdlcFileReads: () => sdlcFileReads, stop, stopped: new Promise<void>((r) => (onStop = r)) };
7300
7462
  } finally {
7301
7463
  if (!ready) for (const cleanup of startupCleanup.reverse()) { try { await cleanup(); } catch { /* preserve startup error */ } }
@@ -1,12 +1,31 @@
1
1
  import type { ReadOnlyProgram } from "./read-only-shell.ts";
2
2
  import { isAbsolute, relative } from "node:path";
3
+ import { lstatSync } from "node:fs";
3
4
  import { realPath } from "./project.ts";
4
5
 
5
6
  /** A repo-writable PATH shim is not a host-owned authenticated capability. No caller-selected binary. */
6
- export function hostExecutable(name: "gh" | "container" | "git" | "dot" | "ssh" | "tar" | ReadOnlyProgram, projectDir: string, path = process.env.PATH ?? ""): string {
7
+ export function hostExecutable(name: "gh" | "bun" | "container" | "git" | "dot" | "ssh" | "tar" | ReadOnlyProgram, projectDir: string, path = process.env.PATH ?? ""): string {
7
8
  const found = Bun.which(name, { PATH: path });
8
9
  if (!found || !isAbsolute(found)) throw new Error(`${name} unavailable`);
9
10
  const target = realPath(found), inside = relative(realPath(projectDir), target);
10
11
  if (!inside || inside !== ".." && !inside.startsWith("../") && !isAbsolute(inside)) throw new Error(`${name} unavailable`);
11
12
  return target;
12
13
  }
14
+
15
+ /** A privileged command's children must not discover a project-controlled PATH shim either. */
16
+ export function hostShellPath(projectDir: string, executionDir: string, value = process.env.PATH ?? "/usr/bin:/bin"): string | undefined {
17
+ try {
18
+ if (!value || value.length > 16384 || value.split(":").length > 128) return;
19
+ const roots = [realPath(projectDir), realPath(executionDir)], directories = new Set<string>();
20
+ const inside = (path: string) => roots.some(root => { const p = relative(root, path); return !p || p !== ".." && !p.startsWith("../") && !isAbsolute(p); });
21
+ for (const entry of value.split(":")) {
22
+ if (!isAbsolute(entry) || entry.split("/").includes("..")) return;
23
+ if (inside(entry)) continue;
24
+ try {
25
+ const canonical = realPath(entry);
26
+ if (!inside(canonical) && lstatSync(canonical).isDirectory()) directories.add(canonical);
27
+ } catch { /* A missing PATH directory supplies no trusted program. */ }
28
+ }
29
+ return directories.size ? [...directories].join(":") : undefined;
30
+ } catch { return undefined; }
31
+ }
@@ -4,6 +4,7 @@ import { dirname, isAbsolute, resolve, sep } from "node:path";
4
4
  import { canonicalPath, realPath } from "./project.ts";
5
5
  import { defaultPeerRoles, definitionOf, type AdapterDefinition, type StartForm, type ProjectAdapterCatalog } from "../adapters/registry.ts";
6
6
  import { isPermissionMode, type PermissionMode } from "./permission-mode.ts";
7
+ import { isReviewScope } from "./review-scope.ts";
7
8
 
8
9
  export interface InstanceConfig {
9
10
  kind: string;
@@ -11,9 +12,11 @@ export interface InstanceConfig {
11
12
  start_mode?: StartForm;
12
13
  permission_mode?: PermissionMode;
13
14
  review_mode?: "fresh";
15
+ /** A fresh reviewer's review scope (#852): `code` reads the code only, `full` also runs tests and reads CI. Machine-local, tier 3. */
16
+ review_scope?: "full" | "code";
14
17
  worktree?: string;
15
18
  readOnly?: boolean;
16
- /** Jcode instances only (#685): their own native data home, and optional model selection. Machine-local, never editable from settings. */
19
+ /** Jcode instances (#685): their own native data home, and optional model selection. A headless Claude implementer also takes `model` (#844). Machine-local, never editable from settings. */
17
20
  jcode_home?: string;
18
21
  provider?: string;
19
22
  model?: string;
@@ -129,7 +132,9 @@ export function validateInstances(raw: unknown, catalog: ProjectAdapterCatalog):
129
132
  const path = `instances.${id}`, entry = fields(descriptor.value, path);
130
133
  const declaredKind: unknown = entry.kind?.value;
131
134
  const jcodeFields = typeof declaredKind === "string" && resolve(declaredKind)?.instances?.nativeHome === true;
132
- if (Object.keys(entry).some(key => !["kind", "roles", "start_mode", "permission_mode", "worktree", "review_mode", "readOnly", ...(jcodeFields ? JCODE_FIELDS : [])].includes(key))) return fail(path, "unsupported field");
135
+ // #844: a kind whose headless variant declares `instances.modelSelect` may name its model; no other kind but Jcode has the field.
136
+ const modelSelect = typeof declaredKind === "string" && resolve(declaredKind)?.headlessVariant?.instances?.modelSelect === true;
137
+ if (Object.keys(entry).some(key => !["kind", "roles", "start_mode", "permission_mode", "worktree", "review_mode", "readOnly", "review_scope", ...(jcodeFields ? JCODE_FIELDS : []), ...(modelSelect ? ["model"] : [])].includes(key))) return fail(path, "unsupported field");
133
138
  const kind: unknown = entry.kind?.value;
134
139
  if (typeof kind !== "string") return fail(`${path}.kind`, "must name a native kind");
135
140
  const definition = resolve(kind), prefix = `${kind}-`;
@@ -185,6 +190,17 @@ export function validateInstances(raw: unknown, catalog: ProjectAdapterCatalog):
185
190
  if (++jcodeCount > MAX_JCODE_INSTANCES) return fail("instances", `at most ${MAX_JCODE_INSTANCES} extra Jcode instances are supported per project`);
186
191
  Object.assign(value, jcodeInstanceFields(entry, path));
187
192
  }
193
+ if (entry.model && modelSelect) {
194
+ if (!headlessWriter) return fail(`${path}.model`, "only a headless implementer takes a model");
195
+ const word: unknown = entry.model.value;
196
+ if (typeof word !== "string" || !NATIVE_WORD.test(word)) return fail(`${path}.model`, "must be one bounded printable word without spaces");
197
+ value.model = word;
198
+ }
199
+ if (entry.review_scope) {
200
+ if (!freshReviewer) return fail(`${path}.review_scope`, "only a fresh reviewer instance has a review scope");
201
+ if (!isReviewScope(entry.review_scope.value)) return fail(`${path}.review_scope`, 'must be "code" or "full"');
202
+ value.review_scope = entry.review_scope.value;
203
+ }
188
204
  if (entry.readOnly) value.readOnly = entry.readOnly.value as boolean;
189
205
  if (entry.roles) { value.roles = roles(entry.roles.value, `${path}.roles`); Object.freeze(value.roles); }
190
206
  if (entry.start_mode) {
@@ -119,7 +119,7 @@ export async function runProjectDaemon(project: Project, unattended = false): Pr
119
119
  try {
120
120
  daemon = await startDaemon({ cwd: project.root, stateDir: project.stateDir, projectId: project.id, instanceId,
121
121
  controlPort: base + CONTROL, codexAppPort: base + CODEX_APP, codexProxyPort: base + CODEX_PROXY,
122
- unattended, onShutdownStart,
122
+ unattended, onShutdownStart, agentDrift: true,
123
123
  // #269: the real terminal providers belong to the running product; a daemon built without them opens nothing.
124
124
  terminal: (template) => terminalOpener({ template, cwd: project.root, stateDir: project.stateDir }) });
125
125
  break;