@staix/agent-hub 0.17.0 → 0.18.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 (68) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +2 -2
  3. package/docs/agent-notes/README.md +5 -0
  4. package/docs/agent-notes/adapters.md +2 -0
  5. package/docs/agent-notes/benchmarks.md +1 -0
  6. package/docs/agent-notes/daemon.md +1 -0
  7. package/docs/agent-notes/local-worker.md +1 -0
  8. package/docs/agent-notes/tasks.md +7 -1
  9. package/docs/agent-notes/tests.md +1 -0
  10. package/docs/dogfood.md +15 -1
  11. package/docs/memory.md +1 -1
  12. package/docs/operations.md +105 -16
  13. package/docs/profiles.md +36 -0
  14. package/docs/quickstart.md +9 -3
  15. package/docs/sdlc.md +29 -3
  16. package/docs/security.md +26 -4
  17. package/docs/specs/2026-09-19-agent-hub-design.md +16 -6
  18. package/docs/verification/2026-10-25-0.18.0.md +76 -0
  19. package/docs/verification/2026-10-25-codex-review-live-verdict.md +35 -0
  20. package/docs/verification/2026-10-25-remote-docker-transport.md +46 -0
  21. package/docs/verification/profiles/YYYY-MM-DD-general.md +46 -0
  22. package/docs/verification/profiles/YYYY-MM-DD-research.md +61 -0
  23. package/docs/verification/profiles/YYYY-MM-DD-writing.md +46 -0
  24. package/docs/verified.json +24 -18
  25. package/package.json +2 -2
  26. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  27. package/plugins/agent-hub/server.js +94 -33
  28. package/src/adapters/definitions/builtin-metadata.ts +1 -1
  29. package/src/adapters/local-worker.ts +5 -3
  30. package/src/cli/console-state.ts +9 -2
  31. package/src/cli/help.ts +29 -28
  32. package/src/cli/identity.ts +87 -6
  33. package/src/cli/main.ts +190 -69
  34. package/src/cli/output.ts +7 -2
  35. package/src/cli/sdlc-output.ts +242 -0
  36. package/src/cli/task-output.ts +212 -0
  37. package/src/cli/upgrade-output.ts +57 -0
  38. package/src/hub/board.ts +17 -0
  39. package/src/hub/check-runner.ts +252 -22
  40. package/src/hub/conductor.ts +5 -0
  41. package/src/hub/config-apply.ts +16 -7
  42. package/src/hub/config-edit-host.ts +1 -1
  43. package/src/hub/config-tier.ts +1 -1
  44. package/src/hub/daemon.ts +353 -72
  45. package/src/hub/dogfood-profile.ts +248 -0
  46. package/src/hub/dogfood-read.ts +77 -14
  47. package/src/hub/dogfood.ts +5 -1
  48. package/src/hub/events.ts +1 -1
  49. package/src/hub/host-executable.ts +2 -1
  50. package/src/hub/hub-tools.ts +2 -2
  51. package/src/hub/operational-authority.ts +60 -0
  52. package/src/hub/operational-view.ts +141 -0
  53. package/src/hub/orphan-process.ts +69 -0
  54. package/src/hub/permission-mode.ts +1 -1
  55. package/src/hub/read-only-shell-proof.ts +80 -0
  56. package/src/hub/read-only-shell.ts +162 -0
  57. package/src/hub/reviewer-pool-config.ts +3 -2
  58. package/src/hub/reviewer-pool.ts +3 -0
  59. package/src/hub/routing.ts +23 -6
  60. package/src/hub/sdlc-merge.ts +299 -0
  61. package/src/hub/sdlc-mirror.ts +129 -0
  62. package/src/hub/sdlc.ts +58 -12
  63. package/src/hub/settings.ts +11 -5
  64. package/src/hub/snapshots.ts +60 -0
  65. package/src/hub/task-repository.ts +31 -5
  66. package/src/hub/tasks.ts +241 -16
  67. package/src/local/tools.ts +17 -4
  68. package/src/ui/index.html +2 -1
package/src/hub/daemon.ts CHANGED
@@ -1,3 +1,7 @@
1
+ import { operationalRosterSetting, operationalSetting, operationalRefusal, operationalRequestLabel } from "./operational-authority.ts";
2
+ import { operationalView, redactBusEvent } from "./operational-view.ts";
3
+ import { inspectSelectedOrphans, stopSelectedOrphan, orphanProcessPorts } from "./orphan-process.ts";
4
+ import { dogfoodLines } from "./dogfood-read.ts";
1
5
  import { deriveMemoryCodeOverlay, unavailableMemoryCodeOverlay } from "./memory-code-overlay.ts";
2
6
  import { readMemoryCodeHead, type MemoryCodeHead } from "./memory-code-head.ts";
3
7
  import { canonicalMemorySymbolNode, resolveMemorySymbolNodes, type MemorySymbolNodes } from "./memory-symbols.ts";
@@ -16,7 +20,7 @@ import { WsPeer, isWsPeer } from "../adapters/channel-peer.ts";
16
20
  import { ownerToolEffect, canonicalWriteUnknown } from "./no-writes.ts";
17
21
  import { NativeHookLaunches } from "./native-hook-launch.ts";
18
22
  import { readReleaseHostAdmission, type ReleaseHostAdmission } from "./release-admission.ts";
19
- import { ContainerRunner, type RunnerOptions, type RunReceipt } from "./check-runner.ts";
23
+ import { ContainerRunner, RemoteRunner, remoteRunnerConfig, type RemoteRunnerConfig, type RunnerOptions, type RunReceipt } from "./check-runner.ts";
20
24
  import { fetchSpec, specAvailable, specRefs } from "./spec-read.ts";
21
25
  import { isDenied } from "../local/deny.ts";
22
26
  import { supersededDeliveryReason, deliveryTaskContext } from "./delivery-superseded.ts";
@@ -88,7 +92,8 @@ import { processSignature } from "../pi/process-signature.ts";
88
92
  import { PiSessionBookmarkMismatch, PiSessionUnresumable, recordPiReplacement, validatePiSession } from "../pi/session.ts";
89
93
  import { profile, proxyEnv, type SandboxNetwork } from "../local/sandbox.ts";
90
94
  import { DEFAULT_NETWORK_ALLOW, startEgressProxy, type EgressProxy } from "../local/proxy.ts";
91
- import { runTool, toolResultFailed, TOOL_SCHEMAS, type ToolApproval, type ApprovalObserver, type ToolContext } from "../local/tools.ts";
95
+ import { runTool, toolResultFailed, TOOL_SCHEMAS, type ToolApproval, type ApprovalObserver, type ToolContext, type ShellPermitPolicy } from "../local/tools.ts";
96
+ import { proveReadOnlyShell, ShellExecutionProofs } from "./read-only-shell-proof.ts";
92
97
  import { isLocalPeer, type LocalPeer } from "../adapters/local-worker.ts";
93
98
  import { isReviewerPrintPeer, reviewerNoVerdictLine, reviewerUnavailableReason, ReviewerUnavailableError, REVIEWER_FALLBACK_UNCONFIRMED, type ReviewerPrintPeer } from "../adapters/reviewer-print.ts";
94
99
  import { createReviewerMcpSession, type ReviewerToolName } from "../adapters/reviewer-mcp.ts";
@@ -109,7 +114,7 @@ import { realPath, hubHome } from "./project.ts";
109
114
  import { mainCheckoutBranch, mainCheckoutWarning } from "./main-checkout.ts";
110
115
  import type { BusEvent, PublishResult } from "./bus.ts";
111
116
  import { CONDUCTOR_TOOLS, CONDUCTOR_TOOL_NAMES, DEFAULT_ROLES, IMPLEMENTER_HUB_TOOL_NAMES, roleContract, TASK_TOOL_NAMES, TASK_TOOLS, BOUNDED_TASK_TOOLS, profileTaskTools } from "./hub-tools.ts";
112
- import { Conductor, ConductorHolds, conductorPeer, conductorProgressSink, publicConductorTask, publicPeerBudget, type ConductEvent } from "./conductor.ts";
117
+ import { Conductor, ConductorHolds, conductorPeer, requireConductor, conductorProgressSink, publicConductorTask, publicPeerBudget, type ConductEvent } from "./conductor.ts";
113
118
  import { SupervisionFeed } from "./supervision.ts";
114
119
  import { drainCliAudits } from "../cli/identity-audit.ts";
115
120
  import { launcherPreview } from "../cli/preview.ts";
@@ -117,13 +122,14 @@ import { AssignmentUndeliveredError, ESCALATE_AFTER, Tasks } from "./tasks.ts";
117
122
  import { DEFAULT_INFERENCE, DIGEST, Inference, screenPii, type InferenceConfig } from "./inference.ts";
118
123
  import { ask, ASK_NOTE_TITLE, RUN_START } from "./ask.ts";
119
124
  import { currentRouting, loadRouting, detectSignals, OVERLAY_FILE, overlayToml, parseRouting, routingText, type Routing } from "./routing.ts";
120
- import { projectSettings, checkSettingValue, ignoredLine, pendingUndo, pendingUndoIdentity, readOverlay, restoredValue, routingCandidate, storedLocally, sharedValue, settingDef as instanceSettingDef, settingRefusal, settingRows, settingText, undoSetting, valueAt, writeConfigSetting, writeRoutingSetting, type SettingAuthority, type SettingDef, type SettingRow, type SettingValue, type UndoIdentity } from "./settings.ts";
125
+ import { NEVER_EDITABLE, projectSettings, checkSettingValue, ignoredLine, pendingUndo, pendingUndoIdentity, readOverlay, restoredValue, routingCandidate, storedLocally, sharedValue, settingDef as instanceSettingDef, settingRefusal, settingRows, settingText, undoSetting, valueAt, writeConfigSetting, writeRoutingSetting, type SettingAuthority, type SettingSource, type SettingDef, type SettingRow, type SettingValue, type UndoIdentity } from "./settings.ts";
126
+ import { githubOriginRepository } from "./task-repository.ts";
121
127
  import { Bus } from "./bus.ts";
122
128
  import { attribute, deliveryTask, type TaskAttribution } from "./attribution.ts";
123
129
  import { DeliveryJournal } from "./delivery-journal.ts";
124
130
  import { startDashboard, dashboardView, type DashboardSession } from "./ui.ts";
125
131
  import { codeGroup } from "./task-code-group.ts";
126
- import { DAEMON_FEATURES, PROTOCOL, stateDirFor } from "./control-client.ts";
132
+ import { DAEMON_FEATURES, PROTOCOL, readControl, stateDirFor } from "./control-client.ts";
127
133
  import { MAX_HOP, newEnvelope, parseMarker, replyParent, sanitize, USER, type Envelope, type PeerId, type Priority } from "./envelope.ts";
128
134
  import { BasePeer, DEFAULT_WATCHDOG_MS, type PeerAdapter } from "./peers.ts";
129
135
  import { MemoryClient } from "../memory/client.ts";
@@ -206,7 +212,7 @@ export interface HubConfig {
206
212
  /** Between-turn task escalation, disabled unless explicitly enabled (#186). */
207
213
  task_sweep: TaskSweepConfig;
208
214
  /** A command per task class run when the owner marks the task done, and its timeout (issue #7). */
209
- checks: { timeout_s: number; runner?: "container"; [cls: string]: string | number | undefined };
215
+ checks: { timeout_s: number; runner?: "container" | "remote"; remote?: RemoteRunnerConfig; [cls: string]: string | number | RemoteRunnerConfig | undefined };
210
216
  /** A git tree at each turn boundary for `ahub turns` and `ahub undo`, the last `keep` per peer (issue #33). */
211
217
  snapshots: { enabled: boolean; keep: number };
212
218
  /** #247: opt-in research records of each approved task in ~/.agenthub/research (ids, counts and tokens only). */
@@ -220,7 +226,7 @@ export interface HubConfig {
220
226
  /** Per-sender rate limits and repeat suppression for what agents send (issue #38). */
221
227
  limits: LimitsConfig;
222
228
  /** Reviewer choice from recorded review outcomes, once a reviewer has `min_reviews` of an implementer (issue #35). */
223
- review: { adaptive: boolean; min_reviews: number; pool?: string[]; rerotate?: boolean };
229
+ review: { adaptive: boolean; min_reviews: number; pool?: string[]; rerotate?: boolean; same_family?: "fallback" | "never" };
224
230
  /** After an unplanned stop, start Kimi, Pi and the local worker again with their recorded sessions (issue #37). */
225
231
  recovery: { auto_resume_after_crash: boolean };
226
232
  /** Per peer, the hub-tool capabilities it has (issue #39); a peer not listed has all of them. */
@@ -397,6 +403,10 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
397
403
  if (file.pet !== undefined) validatePetConfig(file.pet, "config.pet");
398
404
  if (file.pet && typeof file.pet === "object" && !Object.keys(file.pet).length) delete file.pet;
399
405
  delete file.permission_default_sources; // provenance is loader-owned, never a file claim
406
+ if (file.checks?.runner === "remote" || file.checks?.remote !== undefined) {
407
+ if (name !== "config.local.json" || configTracked(cwd, name) !== false) throw new Error("remote checks require untracked config.local.json");
408
+ if (file.checks.remote !== undefined) remoteRunnerConfig(file.checks.remote);
409
+ }
400
410
  const why = stripUntrusted(file, DEFAULT_CONFIG, cwd, name);
401
411
  if (why) ignored.push(why);
402
412
  for (const [peer, mode] of Object.entries(permissionDefaults(file.permission_modes, instances, adapterCatalog))) appliedPermissionSources[peer] = { mode, source: `.agenthub/${name}` };
@@ -473,7 +483,8 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
473
483
  },
474
484
  tasks: { ...DEFAULT_CONFIG.tasks, ...file.tasks, ...(file.tasks?.wip_limits !== undefined ? { wip_limits: planningWipLimits(file.tasks.wip_limits) } : {}) },
475
485
  task_sweep: taskSweepConfig(file.task_sweep),
476
- checks: { ...DEFAULT_CONFIG.checks, ...file.checks, runner: file.checks?.runner === "container" ? "container" : undefined },
486
+ checks: { ...DEFAULT_CONFIG.checks, ...file.checks, runner: file.checks?.runner === "remote" ? "remote" : file.checks?.runner === "container" ? "container" : undefined,
487
+ ...(file.checks?.remote !== undefined || file.checks?.runner === "remote" ? { remote: remoteRunnerConfig(file.checks?.remote) } : {}) },
477
488
  snapshots: { ...DEFAULT_CONFIG.snapshots, enabled: true, ...file.snapshots },
478
489
  research: { enabled: file.research?.enabled === true }, // opt-in: anything but true is off
479
490
  bench: { enabled: file.bench?.enabled === true }, // opt-in: a run resets this project's work tree
@@ -666,8 +677,19 @@ export async function startDaemon(opts: DaemonOptions) {
666
677
  /** #520: a task under review shows its review brief to whoever may read it (owner, reviewer, conductor). */
667
678
  const briefField = (task: Task): { brief?: string } => { if (task.state !== "in_review") return {}; const text = tasks.briefText(task); return text ? { brief: text } : {}; };
668
679
  /** #223 R2: a review-only instance reads the spec, refs and earlier verdicts of a task, never the builder's narrative. */
669
- const reviewerView = (peer: string, view: Record<string, unknown>): Record<string, unknown> => !reviewOnlyPeer(peer) || !Array.isArray(view.history) ? view
670
- : { ...view, history: (view.history as { event?: unknown; note?: unknown }[]).map(({ note, ...h }) => h.event === "approved" || h.event === "changes_requested" ? { ...h, note } : h) };
680
+ const reviewerView = (peer: string, view: Record<string, unknown>): Record<string, unknown> => {
681
+ if (!reviewOnlyPeer(peer) || !Array.isArray(view.history)) return view;
682
+ const task = typeof view.id === "number" && Number.isSafeInteger(view.id) ? board.get(view.id) : undefined;
683
+ // Public task views retain full original history order. A filtered/stale view cannot prove an original index.
684
+ const history = view.history as { event?: unknown; note?: unknown; by?: unknown; at?: unknown }[];
685
+ const rulings = new Map((task && history.length === task.history.length ? tasks.plannerRulings(task, peer) : []).map(ruling => [ruling.historyIndex, ruling]));
686
+ return { ...view, history: history.map(({ note, ...entry }, historyIndex) => {
687
+ if (entry.event === "approved" || entry.event === "changes_requested") return { ...entry, note };
688
+ const ruling = rulings.get(historyIndex), original = task?.history[historyIndex];
689
+ return ruling && original && entry.event === "plan reviewed" && entry.by === original.by && entry.at === original.at
690
+ ? { ...entry, note: ruling.note } : entry;
691
+ }) };
692
+ };
671
693
  const reviewOnlyItem = (peer: string, env: Envelope): boolean => env.from === USER || env.from === HUB && (env.kind === "review" || (env.kind === "task" && env.to?.includes(peer) === true));
672
694
  const readOnlyPeer = (peer: string): boolean => declarations[peer]?.readOnly === true;
673
695
  const SETTINGS = projectSettings(declarations, adapterCatalog);
@@ -696,9 +718,15 @@ export async function startDaemon(opts: DaemonOptions) {
696
718
  const mlxConfigProblem = mlxLaunchProblem(config, config.pi) ?? disabledMlxPolicyProblem(config, opts.cwd);
697
719
  if (mlxConfigProblem) throw new Error(mlxConfigProblem);
698
720
  mkdirSync(opts.stateDir, { recursive: true });
721
+ const operationalLogSubscribers=new Map<Sock,()=>void>();
722
+ 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();}};
699
724
  const logFile = join(opts.stateDir, "hub.log");
700
725
  // The state dir can vanish under a running hub (issue #56): a log line must never take a handler down with it.
701
- const log = (line: string) => { try { appendFileSync(logFile, `${new Date().toISOString()} ${line}\n`); } catch { /* the state dir is gone; the watchdog is stopping the hub */ } };
726
+ 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. */}}
729
+ };
702
730
  let runningRouting = currentRouting(opts.cwd, log);
703
731
  let acceptedSources = parseConfigSources(configSourceTexts(opts.cwd), runningRouting, opts.cwd, externalAdapters);
704
732
  const startupSources = structuredClone(acceptedSources.trustLayers);
@@ -873,6 +901,7 @@ export async function startDaemon(opts: DaemonOptions) {
873
901
  close(sock) {
874
902
  nativeHookLaunches.endOwner(sock);
875
903
  consoles.delete(sock);
904
+ operationalLogSubscribers.delete(sock);
876
905
  sock.data.tail?.();
877
906
  sock.data.watch?.();
878
907
  const peer = sock.data.peer ? bus.peers.get(sock.data.peer) : undefined;
@@ -1411,8 +1440,8 @@ export async function startDaemon(opts: DaemonOptions) {
1411
1440
  startupCleanup.push(() => executionBudget.close());
1412
1441
  // Completion checks (issue #7): only from a config file git does not track, one at a time, killed on stop.
1413
1442
  const allowedChecks = new Set([...CLASSES, ...Object.keys(resolvedProfile.classes)]);
1414
- const checkCommands = Object.fromEntries(Object.entries(config.checks).filter(([k, v]) => k !== "runner" && allowedChecks.has(k) && typeof v === "string" && v.trim())) as Record<string, string>;
1415
- const strayChecks = Object.keys(config.checks).filter((k) => k !== "timeout_s" && k !== "runner" && !(k in checkCommands));
1443
+ const checkCommands = Object.fromEntries(Object.entries(config.checks).filter(([k, v]) => k !== "runner" && k !== "remote" && allowedChecks.has(k) && typeof v === "string" && v.trim())) as Record<string, string>;
1444
+ const strayChecks = Object.keys(config.checks).filter((k) => k !== "timeout_s" && k !== "runner" && k !== "remote" && !(k in checkCommands));
1416
1445
  if (strayChecks.length) log(`checks ignored for ${strayChecks.join(", ")}: not a task class with a command (classes: ${CLASSES.join(", ")})`);
1417
1446
  // Commands and the other machine-local fields came only from a file git vouched for (loadConfig, issue #17).
1418
1447
  for (const line of config.ignored ?? []) log(`${line}; only a config file nobody committed may set it`);
@@ -1467,7 +1496,8 @@ export async function startDaemon(opts: DaemonOptions) {
1467
1496
  const checkOptions: RunnerOptions = {
1468
1497
  projectDir: opts.cwd, stateDir: opts.stateDir, storageDir: join(hubHome(), "checks"),
1469
1498
  projectKey: createHash("sha256").update(realPath(opts.cwd)).digest("hex").slice(0, 24),
1470
- enabled: () => config.checks.runner === "container",
1499
+ enabled: () => config.checks.runner === "container" || config.checks.runner === "remote",
1500
+ ...(config.checks.runner === "remote" ? { remote: config.checks.remote } : {}),
1471
1501
  isDenied: path => isDenied(path, config.local.deny) || matchesPolicy(opts.cwd, path, config.pii?.paths ?? [], true),
1472
1502
  isCurrent: currentCheck,
1473
1503
  onResult: async receipt => {
@@ -1481,12 +1511,12 @@ export async function startDaemon(opts: DaemonOptions) {
1481
1511
  log(`check ${receipt.run}: exit ${receipt.exit ?? "unknown"}, bytes ${receipt.output_bytes}`);
1482
1512
  },
1483
1513
  };
1484
- const checkRunner = opts.boundedTools?.runner?.(checkOptions) ?? new ContainerRunner(checkOptions);
1514
+ const checkRunner = opts.boundedTools?.runner?.(checkOptions) ?? (config.checks.runner === "remote" ? new RemoteRunner(checkOptions) : new ContainerRunner(checkOptions));
1485
1515
  const runnerRefreshAbort = new AbortController();
1486
1516
  startupCleanup.push(() => { runnerRefreshAbort.abort(); void checkRunner.stopAll(); });
1487
1517
  let runnerState = await checkRunner.available(runnerRefreshAbort.signal);
1488
1518
  const orphanChecks = (await checkRunner.sweep()).map(line => boundedSafe(line) ? sanitize(line) : "orphan check: details withheld by privacy policy");
1489
- if (opts.boundedTools?.specAvailable || !opts.config && (config.pi.enabled || config.checks.runner === "container")) await ensureSpec();
1519
+ if (opts.boundedTools?.specAvailable || !opts.config && (config.pi.enabled || config.checks.runner === "container" || config.checks.runner === "remote")) await ensureSpec();
1490
1520
  let runnerStateAt = runnerState.checkedAt ?? Date.now(), refreshingRunner: Promise<void> | undefined;
1491
1521
  const refreshRunner = (): Promise<void> => {
1492
1522
  if (stopping || runnerRefreshAbort.signal.aborted) return Promise.resolve();
@@ -1497,7 +1527,7 @@ export async function startDaemon(opts: DaemonOptions) {
1497
1527
  runnerState = value; runnerStateAt = value.checkedAt ?? Date.now();
1498
1528
  }, () => {
1499
1529
  if (stopping || runnerRefreshAbort.signal.aborted) return;
1500
- runnerState = { available: false, reason: "container unavailable" }; runnerStateAt = Date.now();
1530
+ runnerState = { available: false, reason: config.checks.runner === "remote" ? "remote runner unavailable" : "container unavailable" }; runnerStateAt = Date.now();
1501
1531
  }).finally(() => { refreshingRunner = undefined; });
1502
1532
  return refreshingRunner;
1503
1533
  };
@@ -2443,13 +2473,7 @@ export async function startDaemon(opts: DaemonOptions) {
2443
2473
  startupCleanup.push(() => { for (const i of intervals) clearInterval(i); });
2444
2474
 
2445
2475
  /** What the console stream and the log may show: a private envelope (PII task) keeps its body to its recipients. */
2446
- const redact = (e: BusEvent): BusEvent => {
2447
- if (!("env" in e) || !e.env.private) return e;
2448
- const { refs, ...env } = e.env;
2449
- // Private task refs can quote names in paths/branches too. Only the numeric task link is public.
2450
- const task = refs?.task && /^[0-9]+$/.test(refs.task) ? refs.task : undefined;
2451
- return { ...e, env: { ...env, ...(task ? { refs: { task } } : {}), body: `[private${task ? `: task #${task}, see ahub task show ${task}` : ""}]` } };
2452
- };
2476
+ const redact = redactBusEvent;
2453
2477
  const SERVER_JS = join(import.meta.dir, "..", "..", "plugins", "agent-hub", "server.js");
2454
2478
  // What the hub's MCP server offers a native peer. Codex approves them in config, Kimi's requests for exactly these
2455
2479
  // names are answered by the hub; every call still passes daemon role/capability checks before its effects.
@@ -3244,7 +3268,7 @@ export async function startDaemon(opts: DaemonOptions) {
3244
3268
  peers: Object.fromEntries(
3245
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 ?? [] } : {}) }]; }),
3246
3270
  ),
3247
- taskWip: Object.fromEntries(peerIds.map(id => [id, { wip: tasks.wip(id), wipLimit: tasks.wipLimit(id) }])),
3271
+ taskWip: tasks.wipBreakdown(peerIds),
3248
3272
  boardReadCapabilities: { releaseFilter: true, columnMetadata: true },
3249
3273
  actionPolicyInfo: Object.fromEntries(peerIds.map(id => { const e = actionEnforcement(id); return [id, actionBudgets.status(id, e.enforced, e.reason)]; })),
3250
3274
  roleInfo: Object.fromEntries(peerIds.map(id => [id, { flowRoles: config.roles[id] ?? [], catalogRoles: roleState(id), ...(config.roles[id]?.includes("planner") ? { planner: plannerSource } : {}) }])),
@@ -3812,7 +3836,7 @@ export async function startDaemon(opts: DaemonOptions) {
3812
3836
  }
3813
3837
  return !gate.denyOnly && picked.optionId === "allow";
3814
3838
  }
3815
- async function requestPermission(req: PermissionRequest, signal?: AbortSignal, observe?: ApprovalObserver): Promise<PermissionResult> {
3839
+ async function requestPermission(req: PermissionRequest, signal?: AbortSignal, observe?: ApprovalObserver, requirePerson = false): Promise<PermissionResult> {
3816
3840
  const requestEpoch = permissionAnswerEpoch(req.peer);
3817
3841
  const observed = (source: "person" | "automatic" | "expired" | "aborted") => {
3818
3842
  try { observe?.({ source, answerEpoch: permissionAnswerEpoch(req.peer), ...(source === "expired" ? { eligibleExpiry: requestEpoch === permissionAnswerEpoch(req.peer) } : {}) }); }
@@ -3820,7 +3844,7 @@ export async function startDaemon(opts: DaemonOptions) {
3820
3844
  };
3821
3845
  if (stopping || signal?.aborted) { observed("aborted"); return { outcome: "cancelled" }; }
3822
3846
  // Always-ask (#636): a classed call is never answered for the person, `--unattended` included; it waits for a person and expires as a denial.
3823
- if (opts.unattended && !req.classed) { observed("automatic"); return { outcome: "answered", optionId: req.options.find((o) => o.kind === "allow_once")?.optionId }; }
3847
+ if (opts.unattended && !req.classed && !requirePerson) { observed("automatic"); return { outcome: "answered", optionId: req.options.find((o) => o.kind === "allow_once")?.optionId }; }
3824
3848
  const id = randomUUID().slice(0, 8);
3825
3849
  // The title is written by an agent and read by the person approving it: escape sequences and carriage returns
3826
3850
  // could repaint the terminal line, so everything but newline and tab is made visible.
@@ -4029,7 +4053,8 @@ export async function startDaemon(opts: DaemonOptions) {
4029
4053
  /** The CLI command that starts a peer: `ahub <kind> --as <id>` for a declared instance. */
4030
4054
  const startCommand = (peer: string): string => { const kind = definitionOf(peer)?.kind; return kind && kind !== peer ? `ahub ${kind} --as ${peer}` : `ahub ${peer}`; };
4031
4055
 
4032
- async function stopPeer(peer: unknown, rosterRemoval = false): Promise<Record<string, unknown>> {
4056
+ async function stopPeer(peer: unknown, rosterRemoval = false, operational?: {actor:string;current:()=>void}): Promise<Record<string, unknown>> {
4057
+ operational?.current();
4033
4058
  if (!rosterRemoval && rosterChanging !== undefined && rosterChanging === peer) return { ok: false, error: `${String(peer)} roster change is in progress` };
4034
4059
  if (stopping || recoveryActive()) return { ok: false, error: "recovery or shutdown is holding peer stops; finish the operation before retrying" };
4035
4060
  if (typeof peer !== "string" || !PEER_ID.test(peer)) return { ok: false, error: "unknown peer; run ahub status and choose a hub-owned headless peer" };
@@ -4045,18 +4070,20 @@ export async function startDaemon(opts: DaemonOptions) {
4045
4070
  try {
4046
4071
  return await permissionChange(peer, async () => {
4047
4072
  if (stopping || recoveryActive() || bus.peers.get(peer) !== owner) return { ok: false, error: "peer or recovery changed; refresh ahub status before retrying" };
4073
+ operational?.current();
4048
4074
  const reason = `requested stop: ahub stop ${peer}`; // ids only, never task or message text
4049
4075
  // Pi persists a session only after its first message: take live empty-session proof before stopping.
4050
4076
  if (isPiPeer(owner) && owner.state === "idle") {
4051
4077
  try { await owner.captureResume(); }
4052
4078
  catch { log(`requested stop ${peer}: resume evidence unavailable; later start requires verified persisted history`); }
4053
4079
  }
4080
+ operational?.current();
4054
4081
  for (const pending of permissions.values()) if (pending.peer === peer) pending.done(undefined);
4055
4082
  await owner.stop(reason);
4056
4083
  writeStatus();
4057
4084
  const state = bus.stateOf(peer);
4058
4085
  if (state !== "offline" || bus.peers.get(peer) !== owner) return { ok: false, error: "peer stop did not reach offline on its owner; inspect ahub status and its terminal before retrying" };
4059
- try { notify(rosterRemoval ? `${peer} stopped for roster removal; add it with ahub roster add ${peer} before starting it again` : `${peer} stopped by the console; start it again with ${startCommand(peer)}`); } catch { /* owner stop and readback remain authoritative */ }
4086
+ try { notify(rosterRemoval ? `${peer} stopped for roster removal; add it with ahub roster add ${peer} before starting it again` : `${peer} stopped by ${operational?.actor ?? "the console"}; start it again with ${startCommand(peer)}`); } catch { /* owner stop and readback remain authoritative */ }
4060
4087
  return { ok: true, state };
4061
4088
  });
4062
4089
  } catch {
@@ -4450,27 +4477,46 @@ export async function startDaemon(opts: DaemonOptions) {
4450
4477
  let piReply: Envelope | undefined;
4451
4478
  // Tools the person allowed "always" for this Pi start; a new start gets a new set (#209).
4452
4479
  const piAlways = new Set<string>();
4480
+ const shellOptions = { root: executionCwd, projectRoot: opts.cwd, deny: config.local.deny, readOnly: config.data?.readonly, env: { ...proxyEnv(sandboxNetwork), AGENTHUB_PEER_ID: peer } };
4481
+ const shellProofs = new ShellExecutionProofs(shellOptions);
4453
4482
  actionBudgets.revoke(peer);
4454
- const piPermit = async (title: string, tool: string, signal?: AbortSignal, path?: string, valid: () => boolean = () => true, observe?: ApprovalObserver, originalPath?: string, input?: unknown): Promise<ToolApproval> => {
4455
- if (stopping || signal?.aborted || !valid() || !pi.acceptingTools || bus.peers.get(peer) !== pi) return "aborted";
4483
+ const piPermit = async (title: string, tool: string, signal?: AbortSignal, path?: string, valid: () => boolean = () => true, observe?: ApprovalObserver, originalPath?: string, input?: unknown, policy?: ShellPermitPolicy): Promise<ToolApproval> => {
4484
+ const current = () => !stopping && !signal?.aborted && valid() && pi.acceptingTools && bus.peers.get(peer) === pi;
4485
+ if (!current()) return "aborted";
4456
4486
  const classed = await classedToolPermission(peer, executionCwd, title, tool, input, signal, () => valid() && pi.acceptingTools && bus.peers.get(peer) === pi, observe);
4457
- if (classed !== undefined) return classed;
4487
+ if (classed !== undefined) {
4488
+ if (classed !== true) return classed;
4489
+ if (!current()) return "aborted";
4490
+ shellProofs.ordinary(input, current);
4491
+ return true;
4492
+ }
4458
4493
  if (stopping || signal?.aborted || !valid() || !pi.acceptingTools || bus.peers.get(peer) !== pi) return "aborted";
4494
+ const proof = tool === "bash" && !policy?.forceAsk ? proveReadOnlyShell(input, shellOptions, current) : undefined;
4495
+ if (!current()) return "aborted";
4459
4496
  const selected = permissionMode(peer);
4460
- if (selected === "never-ask" || (selected === "ask-when-needed" && PI_EDIT_TOOLS.has(tool) && !!path && grantablePath(realPath(executionCwd), path, originalPath, config.protect_instruction_files))) {
4497
+ if (selected === "ask-when-needed" && proof && !policy?.forceAsk) {
4498
+ shellProofs.readonly(input, proof, current);
4499
+ log(`permission auto-allowed for ${peer}: bash (read-only shell)`);
4461
4500
  observe?.({ source: "automatic", answerEpoch: permissionAnswerEpoch(peer) });
4462
- return true;
4501
+ return current() ? true : "aborted";
4502
+ }
4503
+ if (!policy?.forceAsk && (selected === "never-ask" || (selected === "ask-when-needed" && PI_EDIT_TOOLS.has(tool) && !!path && grantablePath(realPath(executionCwd), path, originalPath, config.protect_instruction_files)))) {
4504
+ shellProofs.ordinary(input, current);
4505
+ observe?.({ source: "automatic", answerEpoch: permissionAnswerEpoch(peer) });
4506
+ return current() ? true : "aborted";
4463
4507
  }
4464
- if (piAlways.has(tool)) { log(`permission auto-allowed for pi: ${tool} (granted until Pi restarts)`); observe?.({ source: "automatic", answerEpoch: permissionAnswerEpoch(peer) }); }
4508
+ if (!policy?.forceAsk && piAlways.has(tool)) { log(`permission auto-allowed for pi: ${tool} (granted until Pi restarts)`); observe?.({ source: "automatic", answerEpoch: permissionAnswerEpoch(peer) }); }
4465
4509
  else {
4466
- const picked = await requestPermission({ peer, title, tool, options: [{ optionId: "allow", name: "Allow", kind: "allow_once" }, { optionId: "always", name: `Always allow ${tool} until Pi restarts`, kind: "allow_always" }, { optionId: "deny", name: "Deny", kind: "reject_once" }] }, signal, observe);
4510
+ const picked = await requestPermission({ peer, title, tool, options: [{ optionId: "allow", name: "Allow", kind: "allow_once" }, { optionId: "always", name: `Always allow ${tool} until Pi restarts`, kind: "allow_always" }, { optionId: "deny", name: "Deny", kind: "reject_once" }] }, signal, observe, policy?.forceAsk);
4467
4511
  if (picked.outcome === "expired") return "expired";
4468
4512
  if (stopping || signal?.aborted || !valid() || !pi.acceptingTools || bus.peers.get(peer) !== pi) return "aborted";
4469
4513
  if (picked.outcome === "cancelled") return false; // a current person's no-option answer is a denial, not native abort
4470
4514
  if (picked.optionId === "always") piAlways.add(tool);
4471
4515
  else if (picked.optionId !== "allow") return false;
4472
4516
  }
4473
- return signal?.aborted || !valid() ? "aborted" : pi.acceptingTools && bus.peers.get(peer) === pi;
4517
+ if (!current()) return "aborted";
4518
+ shellProofs.ordinary(input, current);
4519
+ return true;
4474
4520
  };
4475
4521
  const ctx: ToolContext = {
4476
4522
  cwd: executionCwd, deny: config.local.deny, readOnly: config.data?.readonly,
@@ -4504,8 +4550,9 @@ export async function startDaemon(opts: DaemonOptions) {
4504
4550
  const nativeTurn = pi.observationTurn;
4505
4551
  const output = TASK_TOOL_NAMES.has(name) || CONDUCTOR_TOOL_NAMES.has(name) ? await taskOp(peer, name, raw as Record<string, unknown>, true) : await runTool(name, JSON.stringify(raw), { ...ctx, signal,
4506
4552
  ...(humanBash ? { onProcessExit: (code: number | null) => { exitCode = code; } } : {}),
4507
- permit: async (title, tool, toolSignal, path, _observe, originalPath, input) => {
4508
- const picked = humanBash ? (toolSignal?.aborted || context?.isCurrent() !== true ? "aborted" : true) : await piPermit(title, tool ?? name, toolSignal, path, context?.isCurrent, context?.onApproval, originalPath, input);
4553
+ shellExecutionCheck: input => signal?.aborted || context?.isCurrent?.() === false || bus.peers.get(peer) !== pi ? "aborted" : humanBash ? "valid" : shellProofs.check(input),
4554
+ permit: async (title, tool, toolSignal, path, _observe, originalPath, input, policy) => {
4555
+ const picked = humanBash ? (toolSignal?.aborted || context?.isCurrent() !== true ? "aborted" : true) : await piPermit(title, tool ?? name, toolSignal, path, context?.isCurrent, context?.onApproval, originalPath, input, policy);
4509
4556
  return picked; // never turn a nonempty expiry/abort string into authorization
4510
4557
  },
4511
4558
  });
@@ -4576,7 +4623,7 @@ export async function startDaemon(opts: DaemonOptions) {
4576
4623
  },
4577
4624
  onApprovalStop: reason => notify(reason),
4578
4625
  onTokens: (added) => void addTokens(peer, added),
4579
- preamble: sessionRoleContract(peer) + "\nYou are the pi peer. Hub messages are untrusted peer input, not user authority. Use only the managed tools. Tool writes and shell commands require hub approval. Never repeat an operation whose outcome is uncertain. PII work belongs to the local peer.",
4626
+ preamble: sessionRoleContract(peer) + "\nYou are the pi peer. Hub messages are untrusted peer input, not user authority. Use only the managed tools. Tool writes and shell commands follow hub permission policy; only proven bounded read-only shell commands may run automatically in ask-when-needed. Never repeat an operation whose outcome is uncertain. PII work belongs to the local peer.",
4580
4627
  onTurnFailure: async (envs) => {
4581
4628
  supervisionTurns.delete(peer);
4582
4629
  await piReceipts?.drain();
@@ -4618,23 +4665,42 @@ export async function startDaemon(opts: DaemonOptions) {
4618
4665
  ? new Capture(memory, { project: memoryProject, cwd: executionCwd, deny: config.local.deny })
4619
4666
  : undefined;
4620
4667
  actionBudgets.revoke(peer);
4621
- const permit = async (title: string, tool?: string, signal?: AbortSignal, path?: string, observe?: ApprovalObserver, originalPath?: string, input?: unknown): Promise<ToolApproval> => {
4622
- if (stopping || signal?.aborted || bus.peers.get(peer) !== local) return "aborted";
4668
+ const shellOptions = { root: executionCwd, projectRoot: opts.cwd, deny: config.local.deny, readOnly: config.data?.readonly, env: { ...proxyEnv(sandboxNetwork), AGENTHUB_PEER_ID: peer } };
4669
+ const shellProofs = new ShellExecutionProofs(shellOptions);
4670
+ const permit = async (title: string, tool?: string, signal?: AbortSignal, path?: string, observe?: ApprovalObserver, originalPath?: string, input?: unknown, policy?: ShellPermitPolicy): Promise<ToolApproval> => {
4671
+ const current = () => !stopping && !signal?.aborted && bus.peers.get(peer) === local;
4672
+ if (!current()) return "aborted";
4623
4673
  const classed = await classedToolPermission(peer, executionCwd, title, tool, input, signal, () => bus.peers.get(peer) === local, observe);
4624
- if (classed !== undefined) return classed;
4674
+ if (classed !== undefined) {
4675
+ if (classed !== true) return classed;
4676
+ if (!current()) return "aborted";
4677
+ shellProofs.ordinary(input, current);
4678
+ return true;
4679
+ }
4625
4680
  if (stopping || signal?.aborted || bus.peers.get(peer) !== local) return "aborted";
4681
+ const proof = tool === "bash" && !policy?.forceAsk ? proveReadOnlyShell(input, shellOptions, current) : undefined;
4682
+ if (!current()) return "aborted";
4626
4683
  const selected = permissionMode(peer);
4627
- if (selected === "never-ask" || (selected === "ask-when-needed" && !!tool && PI_EDIT_TOOLS.has(tool) && !!path && grantablePath(realPath(executionCwd), path, originalPath, config.protect_instruction_files))) {
4684
+ if (selected === "ask-when-needed" && proof && !policy?.forceAsk) {
4685
+ shellProofs.readonly(input, proof, current);
4686
+ log(`permission auto-allowed for ${peer}: bash (read-only shell)`);
4628
4687
  observe?.({ source: "automatic", answerEpoch: permissionAnswerEpoch(peer) });
4629
- return true;
4688
+ return current() ? true : "aborted";
4689
+ }
4690
+ if (!policy?.forceAsk && (selected === "never-ask" || (selected === "ask-when-needed" && !!tool && PI_EDIT_TOOLS.has(tool) && !!path && grantablePath(realPath(executionCwd), path, originalPath, config.protect_instruction_files)))) {
4691
+ shellProofs.ordinary(input, current);
4692
+ observe?.({ source: "automatic", answerEpoch: permissionAnswerEpoch(peer) });
4693
+ return current() ? true : "aborted";
4630
4694
  }
4631
4695
  const picked = await requestPermission({ peer, title, tool, options: [
4632
4696
  { optionId: "allow", name: "Allow", kind: "allow_once" }, { optionId: "deny", name: "Deny", kind: "reject_once" },
4633
- ] }, signal, observe);
4697
+ ] }, signal, observe, policy?.forceAsk);
4634
4698
  if (picked.outcome === "expired") return "expired";
4635
4699
  if (stopping || signal?.aborted || bus.peers.get(peer) !== local) return "aborted";
4636
4700
  if (picked.outcome === "cancelled") return false;
4637
- return picked.optionId === "allow";
4701
+ if (picked.optionId !== "allow") return false;
4702
+ shellProofs.ordinary(input, current);
4703
+ return current() ? true : "aborted";
4638
4704
  };
4639
4705
  const local: LocalPeer = adapter!.start.create!({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: config, permission: permissionMode(peer), mcpServers: [], daemon: DAEMON_PORTS["local-worker"]({
4640
4706
  cwd: executionCwd,
@@ -4650,7 +4716,7 @@ export async function startDaemon(opts: DaemonOptions) {
4650
4716
  turnId: () => turns.get(peer)?.id,
4651
4717
  onTool: (observation, task) => observeProgress(peer, observation, task),
4652
4718
  fixedModel: args.model ?? routing.local.fixed_model,
4653
- tools: { deny: config.local.deny, bashNetwork: sandboxNetwork, readAllow: config.local.read_allow, readOnly: config.data?.readonly, permit },
4719
+ tools: { deny: config.local.deny, bashNetwork: sandboxNetwork, readAllow: config.local.read_allow, readOnly: config.data?.readonly, permit, shellExecutionCheck: input => shellProofs.check(input) },
4654
4720
  onApprovalStop: reason => notify(reason),
4655
4721
  ...(capture ? { capture } : {}),
4656
4722
  profile: resolvedProfile,
@@ -4864,7 +4930,7 @@ export async function startDaemon(opts: DaemonOptions) {
4864
4930
  taskCounts: board.counts(), approvals: [...permissions.values()].map(p => ({ peer: p.peer, tool: p.tool, at: p.createdAt })),
4865
4931
  };
4866
4932
  },
4867
- task: id => board.get(id), publicView: task => tasks.publicView(task, true),
4933
+ task: id => board.get(id), publicView: task => tasks.publicView(task, true), isPlanner: peer => tasks.isConfiguredPlanner(peer),
4868
4934
  assign: (actor, id, peer, role) => tasks.assignTo(id, peer, actor, true, role), escalate: (actor, id) => tasks.escalate(actor, id, undefined, undefined, true),
4869
4935
  preview: peer => {
4870
4936
  // The operator wrapper runs the same planner again at launch, with runtime endpoints then resolved.
@@ -4894,10 +4960,10 @@ export async function startDaemon(opts: DaemonOptions) {
4894
4960
  });
4895
4961
 
4896
4962
  // Queue diagnostics are public metadata by default. Private bodies stay behind the existing task console.
4897
- function queueView(row: ReturnType<Bus["queueShow"]>, detail = false) {
4963
+ function queueView(row: ReturnType<Bus["queueShow"]>, detail = false, publicOnly = false) {
4898
4964
  if (!row) return undefined;
4899
4965
  const originals = row.originals;
4900
- const privateDelivery = originals.some((env) => env.private);
4966
+ const privateDelivery = originals.some((env) => env.private || publicOnly && env.refs?.task && (()=>{const task=board.get(Number(env.refs!.task));return !task || tasks.isPii(task);})());
4901
4967
  return {
4902
4968
  id: row.id, peer: row.peer, state: row.state, revision: row.revision,
4903
4969
  ...(!membershipAllowed(row.peer) ? { rostered: false, holdReason: "off roster" } : {}),
@@ -4905,11 +4971,103 @@ export async function startDaemon(opts: DaemonOptions) {
4905
4971
  envelopeIds: originals.map((env) => env.id),
4906
4972
  important: originals.some((env) => env.priority === "important"),
4907
4973
  ...(detail ? { messages: originals.map((env) => ({ id: env.id, from: env.from, priority: env.priority, kind: env.kind,
4908
- body: privateDelivery ? "[private: inspect the associated task with ahub task show]" : sanitize(env.body),
4974
+ body: privateDelivery ? "[private: inspect the associated task with ahub task show]" : publicOnly && !tasks.publicMetadataText(env.body) ? "[withheld]" : sanitize(env.body),
4909
4975
  })) } : {}),
4910
4976
  };
4911
4977
  }
4912
4978
 
4979
+ 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)});
4981
+ }
4982
+ /** Ordinary read authority never borrows the console identity or conductor admission. */
4983
+ function publicReadCurrent(sock: Sock): () => void {
4984
+ const client = sock.data, peer = client.peer;
4985
+ return () => {
4986
+ if (sock.data !== client || sock.readyState !== WebSocket.OPEN || client.role !== "tools" || !peer || client.toolsOwner
4987
+ || !membershipAllowed(peer) || stopping || contextPii(peer)) throw new Error("public read unavailable for this caller");
4988
+ };
4989
+ }
4990
+ function publicReadValue(value: unknown, depth = 0): unknown {
4991
+ if (depth > 10) return "[withheld]";
4992
+ if (value === null || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value)) return value;
4993
+ if (typeof value === "string") return value.length <= 2000 && tasks.publicMetadataText(value) ? sanitize(value) : "[withheld]";
4994
+ if (Array.isArray(value)) return value.length > 1000 ? "[withheld: read bound]" : value.map(item => publicReadValue(item, depth + 1));
4995
+ if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).slice(0, 128).filter(([key]) => /^[A-Za-z][A-Za-z0-9_]*$/.test(key)).map(([key, item]) => [key, publicReadValue(item, depth + 1)]));
4996
+ return null;
4997
+ }
4998
+ function publicSettingsView() {
4999
+ const view = settingsView();
5000
+ const rows = view.rows.flatMap(row => {
5001
+ if (!tasks.publicMetadataText(row.key)) return [];
5002
+ const def = settingDef(row.key);
5003
+ if (typeof def === "string") return [];
5004
+ // Program/credential/location configuration is never a public value, even when its text passes screening.
5005
+ // Route ids locate person-defined execution configuration; keep them opaque too.
5006
+ const opaque = NEVER_EDITABLE.some(key => def.key === key || def.key.startsWith(`${key}.`))
5007
+ || def.store === "routing" && def.path.at(-1) === "route";
5008
+ const safeValue = (value: unknown): unknown => {
5009
+ if (opaque) return "[withheld]";
5010
+ if (typeof value === "string" && (!/^[A-Za-z0-9_.:-]{1,128}$/.test(value) || !tasks.publicMetadataText(value))) return "[withheld]";
5011
+ if (Array.isArray(value)) return value.slice(0, 128).map(safeValue);
5012
+ return publicReadValue(value);
5013
+ };
5014
+ return [{ key: row.key, group: row.group, label: publicReadValue(row.label), type: row.type,
5015
+ file: "[withheld]", source: "[withheld]", applies: row.applies, risk: row.risk,
5016
+ value: safeValue(row.value), fileValue: safeValue(row.fileValue),
5017
+ ...(row.pending !== undefined ? { pending: safeValue(row.pending) } : {}),
5018
+ ...(row.values ? { values: opaque ? [] : row.values.map(safeValue) } : {}),
5019
+ ...(row.min !== undefined ? { min: row.min } : {}), ...(row.max !== undefined ? { max: row.max } : {}),
5020
+ ...(row.locked ? { locked: "read-only" } : {}), ...(opaque ? { withheld: true } : {}) }];
5021
+ });
5022
+ return { rows, roster: rosterView().map(row => ({ peer: row.peer, on: row.on, startMode: row.startMode, state: row.state, queued: row.queued, tasks: row.tasks })), public: true, withheld: ["file", "source", "notes", "undo", "configEdit"] };
5023
+ }
5024
+ function publicReleaseShow(input: Record<string, any>) {
5025
+ const release = input.id !== undefined ? Number.isSafeInteger(input.id) && input.id > 0 ? board.release(input.id) : undefined
5026
+ : board.releases().find(row => row.name === input.name);
5027
+ if (!release) throw new PreEffectToolRefusal("no such release");
5028
+ const shown = tasks.publicRelease(release);
5029
+ return { id: shown.id, name: publicReadValue(shown.name), goal: publicReadValue(shown.goal), order: shown.order, state: shown.state,
5030
+ plannedStart: shown.plannedStart, cut: shown.cut, ship: shown.ship, activatedAt: shown.activatedAt, frozenAt: shown.frozenAt,
5031
+ shippedAt: shown.shippedAt, created: shown.created, updated: shown.updated, revision: shown.revision,
5032
+ public: true, complete: false, withheld: ["train", "tasks", "afterCut", "afterCutUnknown", "planner"] };
5033
+ }
5034
+ function publicDeliveryQueue(report: Awaited<ReturnType<typeof tasks.deliveryQueue>>) {
5035
+ const privateTasks = board.list().filter(task => tasks.isPii(task));
5036
+ const privateIds = new Set(privateTasks.map(task => task.id));
5037
+ const privatePrs = new Set([...report.rows.filter(row => row.private).map(row => row.pr), ...privateTasks.filter(task => task.sdlc?.repo?.toLowerCase() === report.repo.toLowerCase()).flatMap(task => task.sdlc?.pr ? [task.sdlc.pr] : [])]);
5038
+ const mixed = privatePrs.size > 0;
5039
+ // ponytail: mixed private repositories expose no dependency/readiness evidence. A typed per-dependency privacy projection can restore independent evidence later.
5040
+ return publicReadValue({ repo: report.repo, complete: report.complete && !mixed, truncated: report.truncated,
5041
+ rows: report.rows.map(row => row.private || privatePrs.has(row.pr) ? { private: true, ready: false, tasks: [], after: [], blockers: ["private lifecycle evidence unavailable"] }
5042
+ : mixed ? { pr: row.pr, tasks: row.tasks.filter(id => !privateIds.has(id)), ready: false, after: [], blockers: ["private dependency evidence unavailable"] }
5043
+ : { pr: row.pr, tasks: row.tasks, head: row.head, ready: row.ready, blockers: row.blockers, after: row.after,
5044
+ ...(row.findings ? { findings: row.findings } : {}), ...(row.planConformance ? { planConformance: row.planConformance } : {}), ...(row.testIntegrity ? { testIntegrity: row.testIntegrity } : {}) }),
5045
+ order: mixed ? [] : report.order, ...(report.unordered ? { unordered: mixed ? [] : report.unordered } : {}), conflicts: mixed ? [] : report.conflicts });
5046
+ }
5047
+ /** A public read may use host GitHub authentication only for this project's current public scope. */
5048
+ function publicDeliveryRepository(repo: unknown): repo is string {
5049
+ if (typeof repo !== "string" || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) || repo.startsWith("-") || !tasks.publicMetadataText(repo)) return false;
5050
+ const identity = repo.toLowerCase();
5051
+ return Object.keys(lifecycleConfig.repos ?? {}).some(key => key.toLowerCase() === identity)
5052
+ || board.list().some(task => !tasks.isPii(task) && task.external?.repo.toLowerCase() === identity)
5053
+ || githubOriginRepository(opts.cwd) === identity;
5054
+ }
5055
+ const publicReadPrivacy = () => JSON.stringify(board.list().map(task => [task.id, tasks.isPii(task), task.history.length]));
5056
+ function operationalCurrent(sock:Sock,acceptedAfterClose=false):()=>void {
5057
+ const client=sock.data,peer=client.peer;
5058
+ 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");
5060
+ try {requireConductor(peer,config.roles);}
5061
+ catch {throw new Error(operationalRefusal(peer,"operational command"));}
5062
+ };
5063
+ }
5064
+ function admitOperational(sock:Sock,command:string):()=>void {
5065
+ const current=operationalCurrent(sock);current();
5066
+ if(!event({type:"agent_cli",peer:sock.data.peer!,command,refused:false}))throw new Error("CLI audit event was not retained");
5067
+ auditConduct({kind:"conduct",actor:sock.data.peer!,action:`admitted ${command}`});
5068
+ current();return current;
5069
+ }
5070
+
4913
5071
  /** #251: this machine's benchmark runs for the dashboard: summaries and per-arm measures, never suite text. */
4914
5072
  // ponytail: reads every run file on each dashboard poll; an index of run summaries if the store grows large.
4915
5073
  function benchView(): { bench?: { runs: RunSummary[]; arms: { suite: string; arm: string; runs: number; measures: Measures }[]; minute: number; minAttempts: number } } {
@@ -5046,7 +5204,8 @@ export async function startDaemon(opts: DaemonOptions) {
5046
5204
  boardLegend: Object.entries(peerView).map(([id, peer]) => ({
5047
5205
  peer: id, state: peer.state,
5048
5206
  permissionMode: "permissionNote" in peer && peer.permissionNote ? "unmanaged" : permissionDisplay(id, bus.peers.get(id)),
5049
- wip: tasks.wip(id), wipLimit: tasks.wipLimit(id),
5207
+ // #784 AC3: the breakdown the status snapshot above already read (one grouped query); a peer it does not list reads as its own count.
5208
+ ...(attentionStatus.taskWip[id] ? { wip: attentionStatus.taskWip[id]!.wip, wipLimit: attentionStatus.taskWip[id]!.wipLimit, wipBreakdown: { active: attentionStatus.taskWip[id]!.active, reviewWait: attentionStatus.taskWip[id]!.reviewWait, reserved: attentionStatus.taskWip[id]!.reserved } } : { wip: tasks.wip(id), wipLimit: tasks.wipLimit(id) }),
5050
5209
  activeTasks: boardTasks.filter(task => task.owner === id && ["in_progress", "changes_requested"].includes(task.state)).map(task => task.id),
5051
5210
  })),
5052
5211
  tasks: boardTasks.map((task) => {
@@ -5263,6 +5422,7 @@ export async function startDaemon(opts: DaemonOptions) {
5263
5422
  }
5264
5423
  /** The only publication path; named writes are scoped, full guarded reloads need person confirmation. */
5265
5424
  async function reloadConfig(authority: SettingAuthority, scope?: string, confirm?: unknown): Promise<Record<string, unknown>> {
5425
+ checkOperational(authority);
5266
5426
  if (stopping || recoveryActive()) return { ok: false, error: "recovery or shutdown is holding configuration reload" };
5267
5427
  let fingerprint = "unavailable", published = false;
5268
5428
  try {
@@ -5373,11 +5533,12 @@ export async function startDaemon(opts: DaemonOptions) {
5373
5533
  return { pendingRestart: allPendingRestart(), retainedConfig, rows: rows.map(row => row.key === "planner" ? { ...row, hint: planner } : row), ...(undo ? { undo } : {}) };
5374
5534
  }
5375
5535
  /** The same announcement whatever surface made the change: events.jsonl, hub.log and every open console. */
5376
- const recordSetting = (key: string, from: SettingValue, to: SettingValue, source: "dashboard" | "terminal", undo = false, reason?: string) => {
5377
- event({ type: "settings", key, from, to, source, ...(undo ? { undo: true } : {}), ...(reason ? { reason } : {}) });
5378
- try { notify(`setting ${key}: ${settingText(from)} -> ${settingText(to)} (${undo ? "undo, " : ""}${source})`); } catch { /* the write stands */ }
5536
+ const recordSetting = (key: string, from: SettingValue, to: SettingValue, source: SettingSource, undo = false, reason?: string) => {
5537
+ const peer=source==="conductor"?operationalContext.getStore()!.actor:undefined;
5538
+ event({ type: "settings", key, from, to, source, ...(peer?{peer}:{}), ...(undo ? { undo: true } : {}), ...(reason ? { reason } : {}) });
5539
+ try { notify(`setting ${key}: ${settingText(from)} -> ${settingText(to)} (${undo ? "undo, " : ""}${peer?`conductor ${peer}`:source})`); } catch { /* the write stands */ }
5379
5540
  };
5380
- const settingSource = (authority: SettingAuthority) => authority === "terminal" ? "terminal" as const : "dashboard" as const;
5541
+ const settingSource = (authority: SettingAuthority) => authority === "conductor" ? "conductor" as const : authority === "terminal" ? "terminal" as const : "dashboard" as const;
5381
5542
  function rosterView() {
5382
5543
  const open = board.list().filter(task => !isClosedTaskState(task.state));
5383
5544
  return [...KNOWN_PEERS, ...OPTIONAL_PEERS.filter(peer => config.roster?.[peer] !== undefined)].map(peer => ({ peer, on: onRoster(peer), startMode: startMode(peer), state: bus.stateOf(peer), queued: bus.queued(peer), tasks: open.filter(task => task.owner === peer || task.reviewer === peer).length }));
@@ -5409,8 +5570,9 @@ export async function startDaemon(opts: DaemonOptions) {
5409
5570
  return definitionOf(peer)?.cli.retainedTerminalNotice?.(peer, isPiPeer(owner) ? owner.mode : undefined, codexResumeCommand(peer));
5410
5571
  }
5411
5572
 
5412
- async function changeRosterSetting(def: SettingDef, value: SettingValue, source: "dashboard" | "terminal", confirm: unknown, now: boolean, undo = false, authority: SettingAuthority = "ordinary", reason?: string, undoIdentity?: UndoIdentity): Promise<Record<string, unknown>> {
5573
+ 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>> {
5413
5574
  if (rosterChanging) return { ok: false, error: `roster change for ${rosterChanging} is in progress` };
5575
+ checkOperational(authority);
5414
5576
  const peer = def.path[1]!;
5415
5577
  let saved = false;
5416
5578
  // #225: a stale key has no peer behind it. What changes is that the key is deleted, not whether the peer is on the roster.
@@ -5427,7 +5589,7 @@ export async function startDaemon(opts: DaemonOptions) {
5427
5589
  if (stopping || recoveryActive()) return { ok: false, error: "recovery or shutdown is holding roster changes" };
5428
5590
  rosterChanging = peer;
5429
5591
  if (undo) undoSetting(opts.cwd, opts.stateDir, scratch => configWriteCheck(scratch, authority, `roster.${peer}`), (_routing, overlay) => routingWriteCheck(authority, overlay), undoIdentity);
5430
- else writeConfigSetting(opts.cwd, opts.stateDir, def, value, scratch => configWriteCheck(scratch, authority, `roster.${peer}`));
5592
+ else writeConfigSetting(opts.cwd, opts.stateDir, def, value, scratch => configWriteCheck(scratch, authority, `roster.${peer}`), {preserveUndo:authority==="conductor"});
5431
5593
  // Apply exactly the authorized key. Other file edits stay pending until the next hub start.
5432
5594
  config.roster = next;
5433
5595
  settlePlanner(); // #455: an automatic planner follows the roster
@@ -5441,19 +5603,22 @@ export async function startDaemon(opts: DaemonOptions) {
5441
5603
  else if (isCodexPeer(owner)) owner.detachForRoster(config.permission_modes[peer] === "never-ask" ? "ask" : config.permission_modes[peer] ?? "ask");
5442
5604
  else if (isPiPeer(owner) && owner.mode === "tui") await owner.detachForRoster();
5443
5605
  else if (owner && owner.state !== "offline") {
5444
- const result = await stopPeer(peer, true);
5606
+ const result = await stopPeer(peer, true, authority==="conductor"?operationalContext.getStore():undefined);
5445
5607
  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`);
5446
5608
  }
5609
+ checkOperational(authority);
5447
5610
  const mode = config.permission_modes[peer] ?? "ask";
5448
5611
  permissionModes.set(peer, mode === "never-ask" ? "ask" : mode);
5449
5612
  permissionModeSources.set(peer, defaultSources[peer]?.source ?? "embedded configuration");
5450
5613
  pendingPermissionDefaults.delete(peer);
5451
5614
  if (mode === "never-ask") pendingPermissionDefaults.set(peer, defaultSources[peer]?.source ?? "embedded configuration");
5452
- handoff = await tasks.reassignForRoster(peer);
5615
+ const handoffCurrent = authority === "conductor" ? operationalContext.getStore()!.current : undefined;
5616
+ handoff = await tasks.reassignForRoster(peer, handoffCurrent);
5453
5617
  } else {
5454
5618
  const owner = bus.peers.get(peer);
5455
5619
  if (isPiPeer(owner) && owner.mode === "tui") await owner.resumeFromRoster();
5456
5620
  }
5621
+ checkOperational(authority);
5457
5622
  const reloaded = await reloadConfig(authority, `roster.${peer}`);
5458
5623
  if (reloaded.ok !== true) return { ...reloaded, saved: true };
5459
5624
  writeStatus();
@@ -5462,6 +5627,12 @@ export async function startDaemon(opts: DaemonOptions) {
5462
5627
  return { ok: true, text: [`${peer}: ${after ? "on" : "off"} roster (in force now)`, ...(!after && terminalNotice ? [terminalNotice] : []), ...(after && definitionOf(peer)?.cli.rosterReturnNotice ? [definitionOf(peer)!.cli.rosterReturnNotice!(codexResumeCommand(peer))] : []), ...(!after ? handoff.map(move => sanitize(`task ${move.title}: ${move.role} assignment moved to ${move.to}`)) : []), ...stayed.map(task => sanitize(`task ${tasks.publicTitle(task)} stays with ${peer} as ${[task.owner === peer ? "owner" : "", task.reviewer === peer ? "reviewer" : ""].filter(Boolean).join(" and ")} (${task.state})`)), ...(!after && !handoff.length && !stayed.length ? ["No open tasks to hand over."] : [])].join("\n"), ...(!after ? { handoff } : {}) };
5463
5628
  } catch (error) {
5464
5629
  if (saved) writeStatus();
5630
+ if(saved && authority==="conductor") {
5631
+ try {checkOperational(authority);} catch {
5632
+ // A committed roster write is acknowledged, but the ended authority permits no later lifecycle/handoff work.
5633
+ return {ok:true,saved:true,authorityEnded:true,text:`${peer}: roster write saved; authority ended before remaining lifecycle work; inspect status before continuing`};
5634
+ }
5635
+ }
5465
5636
  return { ok: false, error: sanitize((error as Error).message), ...(saved ? { saved: true } : {}) };
5466
5637
  } finally { if (rosterChanging === peer) { rosterChanging = undefined; bus.rosterChanged(peer); if (configWatchPending && !stopping) { configWatchPending = false; clearTimeout(reloadDebounce); reloadDebounce = setTimeout(runConfigWatch, 100); reloadDebounce.unref?.(); } } }
5467
5638
  }
@@ -5485,9 +5656,11 @@ export async function startDaemon(opts: DaemonOptions) {
5485
5656
  return sanitize(reason.trim());
5486
5657
  };
5487
5658
  async function settingsSet(key: unknown, raw: unknown, authority: SettingAuthority, confirm?: unknown, now?: unknown, rawReason?: unknown): Promise<Record<string, unknown>> {
5659
+ checkOperational(authority);
5488
5660
  if (stopping || recoveryActive()) return { ok: false, error: "recovery or shutdown is holding settings" };
5489
5661
  let reason: string | undefined;
5490
5662
  try { reason = settingReason(rawReason); } catch (error) { return { ok: false, error: (error as Error).message }; }
5663
+ if(authority==="conductor" && reason && !tasks.publicMetadataText(reason))return {ok:false,error:"setting reason withheld by privacy policy"};
5491
5664
  const input = settingInput(key, raw);
5492
5665
  if ("error" in input) return { ok: false, error: input.error };
5493
5666
  const { def, value } = input;
@@ -5522,7 +5695,7 @@ export async function startDaemon(opts: DaemonOptions) {
5522
5695
  }
5523
5696
  if (peer && value === null && storedLocally(opts.cwd, def) && restoredValue(opts.cwd, opts.stateDir, def, true) === "never-ask" && confirm !== peer) return { ok: false, error: `removing the machine-local value leaves never-ask from config.json for ${peer}: it needs the peer id as the confirmation; nothing was changed` };
5524
5697
  const from = settingScalar(valueAt(storedConfig(), def.path));
5525
- if (!writeConfigSetting(opts.cwd, opts.stateDir, def, value, scratch => configWriteCheck(scratch, authority, def.path.join(".")))) return { ok: true, text: `${def.key}: config.local.json already ${value === null ? "leaves it unset" : `holds ${settingText(value)}`}; nothing was written` };
5698
+ if (!writeConfigSetting(opts.cwd, opts.stateDir, def, value, scratch => configWriteCheck(scratch, authority, def.path.join(".")), {preserveUndo:authority==="conductor"})) return { ok: true, text: `${def.key}: config.local.json already ${value === null ? "leaves it unset" : `holds ${settingText(value)}`}; nothing was written` };
5526
5699
  saved = true;
5527
5700
  const result = await reloadConfig(authority, def.path.join("."));
5528
5701
  if (result.ok !== true) return { ...result, saved: true };
@@ -6020,8 +6193,14 @@ export async function startDaemon(opts: DaemonOptions) {
6020
6193
 
6021
6194
  function onMessage(sock: Sock, msg: any): void {
6022
6195
  const c = sock.data;
6196
+ let refusalRecorded=false;
6023
6197
  const reply = (body: Record<string, unknown>) => {
6024
6198
  if (c.toolsOwner && !currentImplementerTools(c)) body = { ok: false, error: "Claude child generation or execution root changed" };
6199
+ const label=operationalRequestLabel(msg);
6200
+ 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};
6203
+ }
6025
6204
  if (sock.readyState === WebSocket.OPEN) sock.send(JSON.stringify({ rid: msg.rid, ...body }));
6026
6205
  };
6027
6206
  if (!c.authed) {
@@ -6127,6 +6306,43 @@ export async function startDaemon(opts: DaemonOptions) {
6127
6306
  }, () => reply({ ok: true, behavior: "deny", message: "hub permission unavailable" })).finally(() => { clearInterval(heartbeat); launch.signal.removeEventListener("abort", abortPending); }).catch(() => {});
6128
6307
  return;
6129
6308
  }
6309
+ case "conductor_operational": {
6310
+ try {
6311
+ if(!["authorize","logs","export","orphans"].includes(msg.op))return void reply({ok:false,error:"unknown operational request"});
6312
+ const commands=["roster add","roster remove","stop","queue show","queue resolve","queue discard","task cancel","settings set","doctor orphans kill","logs","export"];
6313
+ const command=msg.op==="authorize"?msg.command:msg.op==="orphans"?"doctor orphans kill":msg.op;
6314
+ if(!commands.includes(command))return void reply({ok:false,error:"unknown operational command"});
6315
+ const current=admitOperational(sock,command);
6316
+ if(msg.op==="authorize")return void reply({ok:true,instanceId});
6317
+ if(msg.op==="logs" || msg.op==="export") {
6318
+ const lines=dogfoodLines(join(opts.stateDir,msg.op==="logs"?"hub.log":"events.jsonl"),128*1024);
6319
+ if(!lines.available)return void reply({ok:false,error:"operational history unavailable"});
6320
+ const view=publicOperationalView();current();
6321
+ if(msg.op==="logs") {
6322
+ if(msg.follow!==undefined && typeof msg.follow!=="boolean")return void reply({ok:false,error:"invalid log subscription"});
6323
+ 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});
6325
+ }
6326
+ 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
+ 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});
6329
+ }
6330
+ if(msg.op!=="orphans")return void reply({ok:false,error:"unknown operational request"});
6331
+ 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};
6332
+ const inspected=inspectSelectedOrphans(selection,undefined,readControl(opts.stateDir),ports);
6333
+ void (async()=>{
6334
+ const stopped:number[]=[];let self=false,unknown=inspected.state==="unknown";
6335
+ for(const candidate of inspected.candidates){const result=await stopSelectedOrphan(candidate,selection,ports);current();if(result==="stopped")stopped.push(candidate.pid);else if(result==="self")self=true;else unknown=true;}
6336
+ current();reply({ok:true,stopped,unknown,self});
6337
+ if(self)setTimeout(()=>{try{
6338
+ const accepted=operationalCurrent(sock,true);accepted();
6339
+ const fresh=inspectSelectedOrphans(selection,undefined,readControl(opts.stateDir),{...ports,current:accepted});
6340
+ if(fresh.candidates.some(candidate=>candidate.pid===process.pid))void stop().catch(()=>{});
6341
+ }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"});}
6344
+ return;
6345
+ }
6130
6346
  case "permission_default": {
6131
6347
  if (typeof msg.peer === "string" && rosterChanging === msg.peer) return void reply({ ok: false, error: `${msg.peer} roster change is in progress` });
6132
6348
  if (c.role !== "console") return void reply({ ok: false, error: "permission default confirmation is a human console action" });
@@ -6143,10 +6359,15 @@ export async function startDaemon(opts: DaemonOptions) {
6143
6359
  return;
6144
6360
  }
6145
6361
  case "queue": {
6146
- if (c.role !== "console") return void reply({ ok: false, error: "queue inspection and resolution are console-only" });
6362
+ if (c.role !== "console" && msg.op === "list") {
6363
+ try { publicReadCurrent(sock)(); } catch { return void reply({ ok: false, error: "public queue read unavailable for this caller" }); }
6364
+ } 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});}
6366
+ }
6367
+ 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"});
6147
6368
  if (msg.op === "list") {
6148
6369
  if (msg.peer !== undefined && (typeof msg.peer !== "string" || !PEER_ID.test(msg.peer))) return void reply({ ok: false, error: "invalid peer" });
6149
- return void reply({ ok: true, deliveries: bus.queueList(msg.peer).map((row) => queueView(row)) });
6370
+ return void reply({ ok: true, deliveries: bus.queueList(msg.peer).map((row) => queueView(row,false,c.role!=="console")) });
6150
6371
  }
6151
6372
  if (msg.op === "discard") {
6152
6373
  // #713: the person's bulk clear of one peer's backlog. Without `confirmed` it only counts; with it, every queued (or needs_review) delivery of that peer is discarded
@@ -6176,14 +6397,14 @@ export async function startDaemon(opts: DaemonOptions) {
6176
6397
  if (typeof msg.id !== "string" || !msg.id || msg.id.length > 256) return void reply({ ok: false, error: "invalid delivery ID" });
6177
6398
  if (msg.op === "show") {
6178
6399
  const row = bus.queueShow(msg.id);
6179
- return void reply(row ? { ok: true, delivery: queueView(row, true) } : { ok: false, error: "delivery not found" });
6400
+ return void reply(row ? { ok: true, delivery: queueView(row, true,c.role!=="console") } : { ok: false, error: "delivery not found" });
6180
6401
  }
6181
6402
  if (msg.op !== "resolve" || !["completed", "retry", "discard"].includes(msg.action) || !Number.isSafeInteger(msg.revision) || msg.revision < 0 || typeof msg.reason !== "string" || !msg.reason.trim() || msg.reason.length > 2000) return void reply({ ok: false, error: "resolution needs an action, observed revision and reason" });
6182
6403
  if (recoveryActive() || stopping) return void reply({ ok: false, error: "recovery or shutdown is holding queue mutations" });
6183
6404
  try {
6184
6405
  bus.resolveDelivery(msg.id, msg.revision, msg.action, msg.reason.trim());
6185
6406
  writeStatus();
6186
- return void reply({ ok: true, delivery: queueView(bus.queueShow(msg.id)) });
6407
+ return void reply({ ok: true, delivery: queueView(bus.queueShow(msg.id),false,c.role!=="console") });
6187
6408
  } catch (error) { return void reply({ ok: false, error: resolutionRefusal(error) }); }
6188
6409
  }
6189
6410
  case "delivery_holds": {
@@ -6596,6 +6817,57 @@ export async function startDaemon(opts: DaemonOptions) {
6596
6817
  }
6597
6818
  return;
6598
6819
  }
6820
+ if (msg.op === "merge") {
6821
+ // #786 T1: the person or the one configured conductor asks for a guarded merge. Every request gets a reply, a refusal included.
6822
+ const asker = c.role === "console" ? USER : c.role === "tools" && c.peer && conductorPeer(config.roles) === c.peer && !c.toolsOwner && !turns.get(c.peer)?.private && !contextPii(c.peer) ? c.peer : undefined;
6823
+ if (!asker) return void reply({ ok: false, error: "only the console user or the configured conductor may merge" });
6824
+ if (recoveryActive()) return void reply({ ok: false, error: "recovery is holding mutations" });
6825
+ if (!Number.isSafeInteger(msg.id) || msg.id <= 0 || (msg.dryRun !== undefined && typeof msg.dryRun !== "boolean")) return void reply({ ok: false, error: "usage: ahub sdlc merge <task> [--dry-run]" });
6826
+ tasks.guardedMerge(asker, msg.id, { dryRun: msg.dryRun === true, trigger: "command" }).then(
6827
+ result => reply({ ok: true, ...result }),
6828
+ (error: Error) => reply({ ok: false, error: sanitize(error.message).slice(0, 200) }),
6829
+ );
6830
+ return;
6831
+ }
6832
+ if (c.role !== "console" && ["status", "explain", "queue", "release"].includes(msg.op)) {
6833
+ let current: () => void;
6834
+ try { current = publicReadCurrent(sock); current(); } catch { return void reply({ ok: false, error: "public lifecycle read unavailable for this caller" }); }
6835
+ if (msg.op === "queue" || msg.op === "release") {
6836
+ if (!publicDeliveryRepository(msg.repo)) return void reply({ ok: false, error: "public repository unavailable for this project" });
6837
+ current(); // An isolated Git probe can service pending callbacks before it returns.
6838
+ if (!Array.isArray(msg.liveIssues ?? []) || (msg.liveIssues ?? []).length > 32) return void reply({ ok: false, error: "invalid delivery readback arguments" });
6839
+ const before = publicReadPrivacy();
6840
+ const read = msg.op === "queue" ? tasks.deliveryQueue(msg.repo) : tasks.releaseReadiness(msg.repo, msg.liveIssues ?? []);
6841
+ void read.then(report => {
6842
+ current();
6843
+ if (!publicDeliveryRepository(msg.repo)) return void reply({ ok: false, error: "public repository unavailable for this project" });
6844
+ current();
6845
+ if (before !== publicReadPrivacy()) throw new Error("privacy changed");
6846
+ reply({ ok: true, public: true, report: msg.op === "queue" ? publicDeliveryQueue(report as Awaited<ReturnType<typeof tasks.deliveryQueue>>) : publicReadValue(report) });
6847
+ }).catch(() => reply({ ok: false, error: "public delivery readback unavailable" }));
6848
+ return;
6849
+ }
6850
+ const selected = msg.id === undefined ? board.list().filter(task => (task.sdlc || lifecycleConfig.mode !== "off" || lifecycleConfig.workflow === "gsd-lite") && task.state !== "backlog") : [board.get(Number(msg.id))].filter((task): task is Task => !!task);
6851
+ if (msg.id !== undefined && (!Number.isSafeInteger(msg.id) || msg.id <= 0 || !selected.length || selected.some(task => task.state === "backlog"))) return void reply({ ok: false, error: "invalid public lifecycle task id" });
6852
+ void (async () => {
6853
+ const statuses = new Map<number, Awaited<ReturnType<typeof tasks.lifecycleStatus>>>();
6854
+ for (const task of selected.slice(0, 100)) {
6855
+ current(); const now = board.get(task.id); if (!now || now.state === "backlog") throw new Error("task changed");
6856
+ if (!tasks.isPii(now)) statuses.set(task.id, await tasks.lifecycleStatus(task.id, msg.id !== undefined));
6857
+ current();
6858
+ }
6859
+ const rows = selected.slice(0, 100).map(task => {
6860
+ const now = board.get(task.id); if (!now || now.state === "backlog") throw new Error("task changed");
6861
+ if (tasks.isPii(now)) return { id: now.id, title: tasks.publicTitle(now), private: true };
6862
+ if (task.history.length !== now.history.length || !statuses.has(task.id)) throw new Error("task changed");
6863
+ const status = statuses.get(task.id)!;
6864
+ const safeStatus = Object.fromEntries(["mode", "stage", "blockers", "ready", "actor", "next", "spec", "playbook", "criteria", "definition", "entry", "exit", "planConformance", "testIntegrity", "findings"].filter(key => key in status).map(key => [key, publicReadValue(status[key as keyof typeof status])]));
6865
+ return { id: now.id, title: tasks.publicTitle(now), receipt: publicReadValue(tasks.publicView(now).sdlc), ...safeStatus };
6866
+ });
6867
+ current(); reply({ ok: true, public: true, mode: lifecycleConfig.mode, rows, truncated: selected.length > 100 });
6868
+ })().catch(() => reply({ ok: false, error: "public lifecycle readback unavailable" }));
6869
+ return;
6870
+ }
6599
6871
  if (c.role !== "console") return void reply({ ok: false, error: "SDLC readback is console only; task owners record ids through hub_sdlc_record" });
6600
6872
  if (msg.op === "authorize-release") {
6601
6873
  void releaseAuthorizations.authorize(USER, msg.version, msg.commit).then(
@@ -6678,17 +6950,21 @@ export async function startDaemon(opts: DaemonOptions) {
6678
6950
  try { const task = tasks.setRole(USER, msg.id, msg.role); return void reply({ ok: true, text: `task #${task.id}: catalog role ${task.role ?? "none"}` }); }
6679
6951
  catch (error) { return void reply({ ok: false, error: (error as Error).message }); }
6680
6952
  case "settings_get":
6681
- if (c.role !== "console") return void reply({ ok: false, error: "settings are a person's: run ahub settings in a terminal" });
6953
+ if (c.role !== "console") {
6954
+ try { const current = publicReadCurrent(sock); current(); const view = publicSettingsView(); current(); return void reply({ ok: true, ...view }); }
6955
+ catch { return void reply({ ok: false, error: "public settings read unavailable for this caller" }); }
6956
+ }
6682
6957
  return void reply({ ok: true, ...settingsView(), roster: rosterView(), configEdit: (() => { const view = configEditor!.view(); return { revisions: view.revisions, known: view.known }; })() });
6683
6958
  case "settings_reload":
6684
6959
  if (c.role !== "console") return void reply({ ok: false, error: "settings are a person command" });
6685
6960
  return void reloadConfig("terminal", undefined, msg.confirm).then(reply, () => reply({ ok: false, error: "configuration reload could not be confirmed; inspect hub.log" }));
6686
6961
  case "settings_preview":
6687
- if (c.role !== "console") return void reply({ ok: false, error: "settings are a person's: run ahub settings in a terminal" });
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});}
6688
6963
  return void reply(msg.undo === true ? settingsUndoPreview() : settingsPreview(msg.key, msg.value));
6689
6964
  case "settings_set":
6690
- if (c.role !== "console") return void reply({ ok: false, error: "settings are a person's: run ahub settings in a terminal" });
6691
- void settingsSet(msg.key, msg.value, "terminal", msg.confirm, msg.now, msg.reason).then(reply, () => reply({ ok: false, error: "the setting could not be changed; inspect hub.log" }));
6965
+ 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" }));
6692
6968
  return;
6693
6969
  case "settings_undo":
6694
6970
  if (c.role !== "console") return void reply({ ok: false, error: "settings are a person's: run ahub settings in a terminal" });
@@ -6737,8 +7013,9 @@ export async function startDaemon(opts: DaemonOptions) {
6737
7013
  void hubStart(msg.peer, "the console").then(result => reply(includeRetainedLaunchConfig(String(msg.peer), result)), (error) => { log(`peer_start ${String(msg.peer)} failed: ${(error as Error).message}`); reply({ ok: false, error: "peer start failed; inspect ahub status and hub.log" }); });
6738
7014
  return;
6739
7015
  case "peer_stop":
6740
- if (c.role !== "console") return void reply({ ok: false, error: "peer_stop is a console command; the person runs ahub stop in a terminal" });
6741
- void stopPeer(msg.peer).then(reply).catch(() => reply({ ok: false, error: "peer stop failed; inspect ahub status and logs before retrying" }));
7016
+ 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" }));
6742
7019
  return;
6743
7020
  case "start":
6744
7021
  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" });
@@ -6752,6 +7029,10 @@ export async function startDaemon(opts: DaemonOptions) {
6752
7029
  });
6753
7030
  return;
6754
7031
  case "task":
7032
+ if (c.role === "tools" && msg.op === "release_show") {
7033
+ try { const current = publicReadCurrent(sock); current(); const release = publicReleaseShow(msg.args ?? {}); current(); return void reply({ t: "task", ok: true, text: JSON.stringify(release) }); }
7034
+ catch { return void reply({ t: "task", ok: false, error: "public release read unavailable" }); }
7035
+ }
6755
7036
  if (c.toolsOwner && !IMPLEMENTER_HUB_TOOL_NAMES.includes(String(msg.op))) return void reply({ t: "task", ok: false, error: "tool is not available to this Claude implementer child" });
6756
7037
  if (recoveryActive() && recoveryPhase !== "preparing" && String(msg.op) !== "hub_checkpoint") return void reply({ t: "task", ok: false, error: "recovery is holding mutations" });
6757
7038
  (c.toolsOwner ? childToolAuthority.run({ by: c.peer!, current: captureImplementerTools(c) }, () => taskOp(c.peer!, String(msg.op), msg.args ?? {})) : taskOp(c.peer ?? USER, String(msg.op), msg.args ?? {})).then(