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