@staix/agent-hub 0.23.1 → 0.25.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 +24 -0
- package/README.md +32 -5
- package/docs/agent-notes/adapters.md +5 -2
- package/docs/agent-notes/bus.md +1 -0
- package/docs/agent-notes/daemon.md +16 -2
- package/docs/agent-notes/tasks.md +9 -2
- package/docs/agent-notes/tests.md +3 -0
- package/docs/dogfood.md +16 -2
- package/docs/memory.md +1 -1
- package/docs/operations.md +198 -5
- package/docs/profiles.md +2 -0
- package/docs/quickstart.md +19 -4
- package/docs/security.md +225 -8
- package/docs/specs/2026-09-19-agent-hub-design.md +54 -1
- package/docs/specs/2026-09-19-multi-project-design.md +12 -1
- package/docs/specs/2026-11-01-research-campaigns-design.md +351 -0
- package/docs/verification/2026-10-31-0.24.0.md +63 -0
- package/docs/verification/2026-10-31-claude-implementer-2.1.316.md +65 -0
- package/docs/verification/2026-11-01-0.25.0.md +91 -0
- package/docs/verification/claude-shell-wrapper-native.py +1 -1
- package/docs/verified.json +31 -15
- package/package.json +4 -3
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +5120 -4710
- package/src/adapters/claude-channel.ts +29 -6
- package/src/adapters/compat.ts +3 -3
- package/src/adapters/definitions/native-cli-claude-setup.ts +4 -0
- package/src/adapters/definitions/native-cli-doctor.ts +30 -0
- package/src/adapters/jcode.ts +3 -0
- package/src/adapters/registry.ts +1 -1
- package/src/cli/acon-register.ts +29 -0
- package/src/cli/autonomy-homes.ts +33 -0
- package/src/cli/autonomy-offline.ts +75 -0
- package/src/cli/autonomy-projects.ts +103 -0
- package/src/cli/autonomy.ts +44 -0
- package/src/cli/claude-homes.ts +123 -0
- package/src/cli/claude-profile-hook.ts +130 -0
- package/src/cli/handoff-args.ts +27 -0
- package/src/cli/handoff-output.ts +25 -0
- package/src/cli/help.ts +8 -4
- package/src/cli/identity.ts +8 -1
- package/src/cli/main.ts +174 -33
- package/src/cli/output.ts +8 -5
- package/src/cli/schedule-output.ts +8 -4
- package/src/cli/setup-homes.ts +46 -0
- package/src/cli/setup.ts +1 -1
- package/src/cli/study-output.ts +71 -0
- package/src/cli/study.ts +58 -0
- package/src/cli/task-output.ts +1 -0
- package/src/hub/action-class.ts +203 -14
- package/src/hub/action-enforce.ts +8 -6
- package/src/hub/action-policy.ts +95 -9
- package/src/hub/autonomy-consent.ts +173 -0
- package/src/hub/autonomy-defaults.ts +58 -0
- package/src/hub/autonomy.ts +232 -0
- package/src/hub/board.ts +871 -2
- package/src/hub/channel-project.ts +61 -0
- package/src/hub/config-apply.ts +3 -2
- package/src/hub/confirmation-matrix.ts +202 -0
- package/src/hub/control-client.ts +10 -1
- package/src/hub/daemon.ts +546 -27
- package/src/hub/envelope.ts +2 -0
- package/src/hub/events.ts +4 -1
- package/src/hub/execution-budget.ts +12 -0
- package/src/hub/handoff.ts +126 -0
- package/src/hub/hub-tools.ts +12 -2
- package/src/hub/manager.ts +64 -57
- package/src/hub/no-writes.ts +1 -1
- package/src/hub/path-policy.ts +12 -8
- package/src/hub/project.ts +14 -5
- package/src/hub/provenance.ts +30 -0
- package/src/hub/research-campaign.ts +265 -0
- package/src/hub/research-experiments.ts +445 -0
- package/src/hub/restart.ts +26 -0
- package/src/hub/schedules.ts +210 -9
- package/src/hub/session-identity.ts +49 -0
- package/src/hub/settings.ts +105 -8
- package/src/hub/task-repository.ts +11 -6
- package/src/hub/tasks.ts +894 -22
- package/src/hub/ui.ts +15 -6
- package/src/memory/capture.ts +2 -2
- package/src/ui/index.html +5 -3
- package/src/ui/office-space.ts +118 -0
- package/src/ui/plugin.ts +293 -0
- package/templates/AGENTS.block.md +1 -0
- package/templates/presets/autonomous.json +161 -0
- package/templates/presets/confirmation-matrix.json +943 -0
- package/templates/profiles/general.json +1 -1
- package/templates/profiles/research.json +4 -4
- package/templates/profiles/writing.json +1 -1
- package/templates/roles/research/analyst.md +1 -0
- package/templates/roles/research/fact-checker.md +1 -0
- package/templates/roles/research/research-lead.md +1 -0
- package/templates/roles/research/researcher.md +1 -0
- package/templates/roles/research/statistician.md +1 -0
package/src/hub/daemon.ts
CHANGED
|
@@ -21,8 +21,9 @@ import { createClaudeFailureTrace, type ClaudeFailureTrace } from "./claude-fail
|
|
|
21
21
|
import { buildNativePermissionProbe, NATIVE_PERMISSION_PROBE_MAX_PER_LAUNCH, validateNativePermissionRequest } from "./native-permission-request.ts";
|
|
22
22
|
import { createClaudeLaunchProof, readClaudeLaunchProof } from "./claude-launch-proof.ts";
|
|
23
23
|
import { InboxPolling } from "./inbox-polling.ts";
|
|
24
|
-
import { ActionBudgets, actionPolicyConfig, type ActionPolicyConfig } from "./action-policy.ts";
|
|
24
|
+
import { ActionBudgets, actionPolicyConfig, floorEnforcement, floorReads, type ActionPolicyConfig } from "./action-policy.ts";
|
|
25
25
|
import { actionGate, claudeActionClasses } from "./action-enforce.ts";
|
|
26
|
+
import type { FloorFacts } from "./action-class.ts";
|
|
26
27
|
import { ReleaseAuthorizations, releaseAuthorizationError } from "./sdlc-release-authorization.ts";
|
|
27
28
|
import { parseReleaseGuardArgv, parseReleasePushUpdates } from "../cli/sdlc-release-guard.ts";
|
|
28
29
|
import type { DocEvidence } from "./sdlc-release.ts";
|
|
@@ -90,7 +91,7 @@ import { foldersConfig, scheduleViews, schedulesConfig, ScheduleStore, type Fold
|
|
|
90
91
|
import { FolderStore, registerScanned, scanFolder, type FolderPolicy } from "./folder-trigger.ts";
|
|
91
92
|
import type { ScannedFile } from "./schedules.ts";
|
|
92
93
|
import { corpusConfig } from "./corpus-config.ts";
|
|
93
|
-
import { ALL_MACHINE_LOCAL, configRepositoryPresent, configTracked, stripUntrusted } from "./config-trust.ts";
|
|
94
|
+
import { ALL_MACHINE_LOCAL, configRefusal, configRepositoryPresent, configTracked, stripUntrusted } from "./config-trust.ts";
|
|
94
95
|
import { authorizeConfigLeaf, configApply, configIdentityScope, configChanges, validateEffectiveConfig, validateOperationalConfig, validatePetConfig } from "./config-apply.ts";
|
|
95
96
|
import { resolveSettledRoles } from "./settled-roles.ts";
|
|
96
97
|
import { CONFIG_SOURCE_FILES, configFingerprint, configSourceTexts, parseConfigSources, type ConfigSourceTexts, type ConfigSources } from "./config-reload.ts";
|
|
@@ -148,7 +149,8 @@ import { recallMemoryLines, trimToTokens } from "../memory/recall.ts";
|
|
|
148
149
|
import { closeSync, constants as fsConstants, openSync, lstatSync, readSync, statSync } from "node:fs";
|
|
149
150
|
import { basename, isAbsolute } from "node:path";
|
|
150
151
|
import { fileURLToPath } from "node:url";
|
|
151
|
-
import { realPath, hubHome } from "./project.ts";
|
|
152
|
+
import { canonicalPath, realPath, hubHome } from "./project.ts";
|
|
153
|
+
import { HandoffRefusal, checkTarget, parseHandoff, resolveTarget, handoffEventFor, type HandoffEvent } from "./handoff.ts";
|
|
152
154
|
import { mainCheckoutBranch, mainCheckoutWarning } from "./main-checkout.ts";
|
|
153
155
|
import type { BusEvent, PublishResult } from "./bus.ts";
|
|
154
156
|
import { CONDUCTOR_TOOLS, CONDUCTOR_TOOL_NAMES, DEFAULT_ROLES, IMPLEMENTER_HUB_TOOL_NAMES, roleContract, TASK_TOOL_NAMES, TASK_TOOLS, BOUNDED_TASK_TOOLS, CAPABILITY_TOOLS, profileTaskTools } from "./hub-tools.ts";
|
|
@@ -163,14 +165,20 @@ import { DEFAULT_INFERENCE, DIGEST, Inference, screenPii, type InferenceConfig,
|
|
|
163
165
|
import { taskEvidencePrivacy, type TaskEvidencePrivacy, ask, ASK_NOTE_TITLE, RUN_START } from "./ask.ts";
|
|
164
166
|
import { currentRouting, loadRouting, detectSignals, OVERLAY_FILE, overlayToml, parseRouting, routingText, type Routing } from "./routing.ts";
|
|
165
167
|
import { withChecklistLine } from "./learning.ts";
|
|
166
|
-
import {
|
|
167
|
-
import {
|
|
168
|
+
import { autonomyConsentStateDir, createAutonomyConsent, type AutonomyConsentValues } from "./autonomy-consent.ts";
|
|
169
|
+
import { insideRoot } from "./path-policy.ts";
|
|
170
|
+
import { autonomyPreset, autonomyEnvironment, planAutonomy, autonomyRemovalKeys, autonomyStatus, type AutonomyEnvironment, type AutonomyPeer } from "./autonomy.ts";
|
|
171
|
+
import { readAutonomyDefaults } from "./autonomy-defaults.ts";
|
|
172
|
+
import { AUTONOMY_UNDO, autonomyOwnershipDigest, autonomyUndoKeys, writeAutonomyPreset, LEARN_CHECKLIST, NEVER_EDITABLE, projectSettings, checkSettingValue, ignoredLine, pendingUndo, pendingUndoIdentity, readOverlay, restoredValue, routingCandidate, storedLocally, sharedValue, settingDef as instanceSettingDef, settingRefusal, settingRows, settingText, undoSetting, valueAt, writeConfigSetting, writeRoutingSetting, type SettingAuthority, type SettingSource, type SettingDef, type SettingRow, type SettingValue, type UndoIdentity } from "./settings.ts";
|
|
173
|
+
import { githubOriginRepository, githubRemoteRepository } from "./task-repository.ts";
|
|
168
174
|
import { Bus } from "./bus.ts";
|
|
169
175
|
import { attribute, deliveryTask, type TaskAttribution } from "./attribution.ts";
|
|
170
176
|
import { DeliveryJournal } from "./delivery-journal.ts";
|
|
171
177
|
import { startDashboard, dashboardView, type DashboardSession } from "./ui.ts";
|
|
172
178
|
import { codeGroup } from "./task-code-group.ts";
|
|
173
|
-
import { DAEMON_FEATURES, PROTOCOL, readControl, stateDirFor } from "./control-client.ts";
|
|
179
|
+
import { ControlClient, DAEMON_FEATURES, PROTOCOL, readControl, stateDirFor } from "./control-client.ts";
|
|
180
|
+
import { attachEvent, originOf, type Origin } from "./provenance.ts";
|
|
181
|
+
import { mintSessionId, sessionGate } from "./session-identity.ts";
|
|
174
182
|
import { MAX_HOP, newEnvelope, parseMarker, replyParent, sanitize, USER, type Envelope, type PeerId, type Priority } from "./envelope.ts";
|
|
175
183
|
import { BasePeer, DEFAULT_WATCHDOG_MS, type FrontendProxyPort, type PeerAdapter } from "./peers.ts";
|
|
176
184
|
import { ClaudeTodoSource, DoingTracker, readAiTitle, type DoingKind, type PeerDoing, type TitleCursor } from "./peer-doing.ts";
|
|
@@ -355,7 +363,7 @@ export const DEFAULT_CONFIG: HubConfig = {
|
|
|
355
363
|
export { stateDirFor };
|
|
356
364
|
|
|
357
365
|
/** Ids an external process may not claim: the console user and the adapters the daemon runs itself. */
|
|
358
|
-
const READ_ONLY_HUB_TOOLS = new Set(["hub_capabilities", "hub_inbox", "hub_send", "hub_delivery_done", "hub_review", "hub_task_show", "hub_task_list", "hub_memory_search", "hub_memory_show", "hub_memory_graph", "hub_source_read", "hub_status"]);
|
|
366
|
+
const READ_ONLY_HUB_TOOLS = new Set(["hub_capabilities", "hub_inbox", "hub_send", "hub_delivery_done", "hub_review", "hub_task_show", "hub_task_list", "hub_study_show", "hub_memory_search", "hub_memory_show", "hub_memory_graph", "hub_source_read", "hub_status"]);
|
|
359
367
|
const BASE_RESERVED_IDS = new Set<string>([USER, ...SUPPORTED_PEERS.filter(peer => definitionOf(peer)?.attach?.externalHello !== true), HUB, DIGEST]);
|
|
360
368
|
const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
|
|
361
369
|
|
|
@@ -474,6 +482,7 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
|
|
|
474
482
|
if (file.pet && typeof file.pet === "object" && !Object.keys(file.pet).length) delete file.pet;
|
|
475
483
|
delete file.declaredExternalInstances; // host-derived refusal rows, never a file claim
|
|
476
484
|
delete file.permission_default_sources; // provenance is loader-owned, never a file claim
|
|
485
|
+
delete file._autonomy; // Inert restoration metadata, never policy or approval provenance.
|
|
477
486
|
if (file.checks?.runner === "remote" || file.checks?.remote !== undefined) {
|
|
478
487
|
if (name !== "config.local.json" || configTracked(cwd, name) !== false) throw new Error("remote checks require untracked config.local.json");
|
|
479
488
|
if (file.checks.remote !== undefined) remoteRunnerConfig(file.checks.remote);
|
|
@@ -691,6 +700,8 @@ interface Client {
|
|
|
691
700
|
authed: boolean;
|
|
692
701
|
role?: "peer" | "console" | "tools";
|
|
693
702
|
peer?: PeerId;
|
|
703
|
+
/** agent-hub#1036: this connection is a distinct session; `peer` is the id the daemon minted for it (`<peer>~<origin>`), never a bus peer. */
|
|
704
|
+
session?: true;
|
|
694
705
|
launchId?: string;
|
|
695
706
|
externalOwner?: { owner: PeerAdapter; launchId: string; generation?: string };
|
|
696
707
|
toolsOwner?: { owner: PeerAdapter; launchId: string; generation: string; cwd: string };
|
|
@@ -799,6 +810,27 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
799
810
|
const childToolAuthority = new AsyncLocalStorage<{ by: string; current: () => boolean }>();
|
|
800
811
|
const implementerTranscripts = new Map<string, string>();
|
|
801
812
|
const executionOwners = new Map<string, { owner: PeerAdapter; cwd: string; socket?: Sock; launchId?: string; generation?: string | number }>();
|
|
813
|
+
/** #604 PR B: envelope id to the connection that sent it, written where a connection builds an envelope and read once when the envelope event is logged. Bounded: an entry whose event never comes ages out. */
|
|
814
|
+
const envelopeOrigins = new Map<string, Origin>();
|
|
815
|
+
/** agent-hub#1036: the ids this daemon minted for live distinct sessions. Only the daemon makes them, so `PEER_ID` is not widened and no `@` reaches the remote-room paths. */
|
|
816
|
+
const sessionIds = new Set<string>();
|
|
817
|
+
const rememberOrigin = (id: string, origin: Origin): void => { envelopeOrigins.set(id, origin); if (envelopeOrigins.size > 1024) envelopeOrigins.delete(envelopeOrigins.keys().next().value as string); };
|
|
818
|
+
const takeOrigin = (id: string): Origin | undefined => { const origin = envelopeOrigins.get(id); envelopeOrigins.delete(id); return origin; };
|
|
819
|
+
/**
|
|
820
|
+
* agent-hub#1036: the origin of an envelope that no control connection sent, one an adapter published itself (a native peer's final answer goes through `peer.onMessage`, not the `send` case).
|
|
821
|
+
* It names the sending peer and, where the adapter knows one, the launch it runs under: a channel session's `managedLaunchId`, an owned Jcode's launch id. The hub's own writers (`hub`, `user`,
|
|
822
|
+
* `digest`) and remote room ids have none, as before. Evidence for the log, never a path or text.
|
|
823
|
+
*/
|
|
824
|
+
const adapterOrigin = (env: Envelope): Origin | undefined => {
|
|
825
|
+
if (env.from === "hub" || env.from === USER || env.from === "digest" || env.from.includes("@")) return undefined;
|
|
826
|
+
const peer = bus.peers.get(env.from);
|
|
827
|
+
if (!peer) return undefined;
|
|
828
|
+
// Read by shape, not by a native-branch predicate: a channel session reports `managedLaunchId`, an owned Jcode `ownedLaunchId`, and the value only labels the log. Nothing here decides
|
|
829
|
+
// authority or ownership, so it is not an `isWsPeer` host branch (the #625 ratchet lists those, and this is not one).
|
|
830
|
+
const evidence = peer as { managedLaunchId?: unknown; ownedLaunchId?: unknown };
|
|
831
|
+
const launchId = [evidence.managedLaunchId, evidence.ownedLaunchId].find((value): value is string => typeof value === "string" && value.length > 0);
|
|
832
|
+
return originOf({ role: "peer", peer: env.from, ...(launchId ? { launchId } : {}) });
|
|
833
|
+
};
|
|
802
834
|
const externalLaunches = new Map<string, { owner: PeerAdapter; options: AcpOptions; launchId: string; binding: ExternalInstanceBinding; workspace: string; generation?: string }>();
|
|
803
835
|
const externalBindings = new Map<string, ExternalInstanceBinding>();
|
|
804
836
|
const externalSessions = new Map<string, string>();
|
|
@@ -944,7 +976,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
944
976
|
return typeof found === "string" && stale ? { key: `roster.${stale}`, group: "Roster", label: `${stale}: stale roster key (instance no longer declared)`, type: "boolean", store: "config", applies: "live", risk: "raises", floor: false, path: ["roster", stale] } : found;
|
|
945
977
|
};
|
|
946
978
|
// #514b: undo resolves the managed checklist key, which is not a settings row, and every other key through the project-scoped lookup above (declared instances, the adapter catalog, a stale roster key).
|
|
947
|
-
const undoableDef = (key: unknown): SettingDef | string => key === LEARN_CHECKLIST.key ? LEARN_CHECKLIST : settingDef(key);
|
|
979
|
+
const undoableDef = (key: unknown): SettingDef | string => key === AUTONOMY_UNDO.key ? AUTONOMY_UNDO : key === LEARN_CHECKLIST.key ? LEARN_CHECKLIST : settingDef(key);
|
|
948
980
|
const accountOf = (peer: string) => definitionOf(peer)?.budget?.account ?? peer;
|
|
949
981
|
const accountMembers = (account: string) => SUPPORTED_PEERS.filter(peer => accountOf(peer) === account);
|
|
950
982
|
let rosterChanging: string | undefined;
|
|
@@ -1148,6 +1180,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1148
1180
|
for (const request of sock.data.invocationRequests?.values() ?? []) request.abort();
|
|
1149
1181
|
sock.data.invocationRequests?.clear();
|
|
1150
1182
|
nativeHookLaunches.endOwner(sock);
|
|
1183
|
+
if (sock.data.session && sock.data.peer) sessionIds.delete(sock.data.peer);
|
|
1151
1184
|
consoles.delete(sock);
|
|
1152
1185
|
operationalLogSubscribers.delete(sock);
|
|
1153
1186
|
sock.data.tail?.();
|
|
@@ -1347,6 +1380,18 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1347
1380
|
onImportantQueued: (peer, counts) => { const p = bus.peers.get(peer); if (isWsPeer(p)) p.notifyQueued(counts); } });
|
|
1348
1381
|
startupCleanup.push(() => bus.closeJournal());
|
|
1349
1382
|
const claudeLaunchProof = createClaudeLaunchProof({ stateDir: opts.stateDir, cwd: opts.cwd, instanceId, definitionOf, peer: peer => bus.peers.get(peer) });
|
|
1383
|
+
/**
|
|
1384
|
+
* agent-hub#1036: whether a tools hello is a distinct session instead of the peer it names. True when the plugin asked for it (`session`: it has no project of its own and inherited this hub's
|
|
1385
|
+
* state), or when the name is a Claude channel peer and the hello PRESENTS a launch id that is not the current launch record's. An ABSENT id is never enough: the facts hook, a native
|
|
1386
|
+
* helper or a person's own shell connects as the peer with none, and it must keep working (agent-hub#1036 AC4). Everything else keeps its admission as before: codex, kimi, qwen, agent
|
|
1387
|
+
* shells without an inherited id, owned ACP peers and the Claude implementer child are not affected.
|
|
1388
|
+
*/
|
|
1389
|
+
const sessionHello = (msg: Record<string, any>): boolean => {
|
|
1390
|
+
if (typeof msg.session === "string") return true;
|
|
1391
|
+
const peer = String(msg.peer ?? "");
|
|
1392
|
+
if (definitionOf(peer)?.transport !== "claude-channel" || definitionOf(peer)?.start.daemonPort === "claude-implementer") return false;
|
|
1393
|
+
try { const launch = readClaudeLaunchProof(opts.stateDir, peer, instanceId); return !!launch && typeof msg.launchId === "string" && msg.launchId !== launch.launchId; } catch { return false; }
|
|
1394
|
+
};
|
|
1350
1395
|
function frontendProxyState(peer: string, owner: PeerAdapter | undefined): { present: boolean; port: FrontendProxyPort | undefined } {
|
|
1351
1396
|
if (!owner || !isFixedBuiltinDefinition(definitionOf(peer)) || !("frontendProxy" in owner)) return { present: false, port: undefined };
|
|
1352
1397
|
return { present: true, port: owner.frontendProxy };
|
|
@@ -2134,6 +2179,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2134
2179
|
// #334: the hub model's split, validated against the same `hub_backlog_split` parts schema a planner's call is, and the source registry for `--source`.
|
|
2135
2180
|
intake: { split: (system, user, opts) => inference?.split(system, user, opts) ?? Promise.resolve(undefined), sourceKnown: id => sourceRegistry.get(id) !== undefined,
|
|
2136
2181
|
partsSchema: () => profileTaskTools(resolvedProfile, lifecycleConfig.stages, lifecycleConfig.mode !== "off").find(tool => tool.name === "hub_backlog_split")?.inputSchema.properties.parts },
|
|
2182
|
+
studySource: id => sourceRegistry.get(id),
|
|
2183
|
+
experimentCheck: run => checkRunner.result(run),
|
|
2137
2184
|
// The PII screen (issue #198): the model on this machine when MLX/Ollama is enabled, else the gateway on campus only.
|
|
2138
2185
|
piiScreen: opts.piiScreen ?? ((text) => screenPii(text, { ...(config.mlx.enabled === false ? {} : { device: () => ensureMlx(config.mlx) }), onCampus, omni, fixedModel: () => activeRouting().local.fixed_model })),
|
|
2139
2186
|
recordScreen: (record) => event({ type: "pii_screen", ...record }),
|
|
@@ -3160,6 +3207,71 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3160
3207
|
// commit waits for those in flight, or its integrity digest misses their later writes. Completion checks outlive
|
|
3161
3208
|
// their op, so recoveryReady() also waits for `tasks.checksPending()`: a check the commit's stop kills would write.
|
|
3162
3209
|
let taskOpsInFlight = 0;
|
|
3210
|
+
/**
|
|
3211
|
+
* #604 ORIGIN side. Order matters: the caller's authority and the PII gates run first (nothing has left this project), then the target is resolved from the machine registry and its own
|
|
3212
|
+
* status descriptor is read, and only then is the request sent. The target answers with the task id it created, which is recorded here. A refusal at any step leaves no row in either board.
|
|
3213
|
+
*/
|
|
3214
|
+
async function handoffOut(by: PeerId, input: unknown): Promise<{ project: string; task: number; state: string; note?: string }> {
|
|
3215
|
+
const request = parseHandoff(input);
|
|
3216
|
+
const own = request.task === undefined ? undefined : board.get(request.task);
|
|
3217
|
+
if (request.task !== undefined && !own) throw new HandoffRefusal("invalid", `no task #${request.task} in this project to hand off from`);
|
|
3218
|
+
tasks.handoffOutAuthority(by, own);
|
|
3219
|
+
const self = { id: projectId, root: opts.cwd };
|
|
3220
|
+
const target = resolveTarget(request.project, registeredProjects(hubHome()).map(project => ({ id: project.id, root: project.root, stateDir: project.stateDir })), self, canonicalPath);
|
|
3221
|
+
const descriptor = readControl(target.stateDir);
|
|
3222
|
+
checkTarget(target, descriptor && { ...(descriptor.features ? { features: descriptor.features } : {}), ...(descriptor.projectId ? { projectId: descriptor.projectId } : {}) });
|
|
3223
|
+
let client: ControlClient;
|
|
3224
|
+
try { client = await ControlClient.connect(target.stateDir, { role: "console", projectRoot: descriptor!.cwd ?? target.root }, 5000); }
|
|
3225
|
+
catch { throw new HandoffRefusal("not_running", `the hub of ${target.id} did not answer; run \`ahub up\` in ${target.root}, then hand the task off again (handoffs are not queued)`); }
|
|
3226
|
+
try {
|
|
3227
|
+
const { project: _project, task: originTask, ...body } = request;
|
|
3228
|
+
const reply = await client.request({ t: "task_handoff", origin: { projectId, peer: by, ...(originTask === undefined ? {} : { taskId: originTask }) }, request: body }, 30_000);
|
|
3229
|
+
if (!reply.ok) throw new HandoffRefusal("invalid", sanitize(String(reply.error ?? "the target refused the handoff")).slice(0, 300));
|
|
3230
|
+
const remote = Number(reply.task);
|
|
3231
|
+
if (!Number.isSafeInteger(remote) || remote < 1) throw new HandoffRefusal("invalid", "the target answered without a task id");
|
|
3232
|
+
tasks.handoffSent(by, { projectId: target.id }, remote, request.title, request.task);
|
|
3233
|
+
return { project: target.id, task: remote, state: String(reply.state ?? "proposed"), ...(typeof reply.note === "string" && reply.note ? { note: sanitize(reply.note).slice(0, 300) } : {}) };
|
|
3234
|
+
} finally { client.close(); }
|
|
3235
|
+
}
|
|
3236
|
+
/** The head a handoff reports: the owner's `refs.commit`, else the receipt's verified head. Only a full 40-hex SHA crosses; anything else is dropped. */
|
|
3237
|
+
const handoffHead = (task: Task): string | undefined => { const head = task.refs.commit ?? task.sdlc?.head ?? task.sdlc?.localHead; return typeof head === "string" && /^[0-9a-f]{40}$/.test(head) ? head : undefined; };
|
|
3238
|
+
/** #604 TARGET side: tell the origin project's daemon about a state change of a task it handed us. Best effort and never blocking a board write: an origin that is down reads the state later. */
|
|
3239
|
+
const handoffNotifyOrigin = (task: Task, entry: { event: string }): void => {
|
|
3240
|
+
const event: HandoffEvent | undefined = handoffEventFor(entry.event, task.state);
|
|
3241
|
+
const origin = event ? board.handoffOrigin(task.id) : undefined;
|
|
3242
|
+
if (!event || !origin) return;
|
|
3243
|
+
void (async () => {
|
|
3244
|
+
try {
|
|
3245
|
+
const project = registeredProjects(hubHome()).find(candidate => candidate.id === origin.projectId);
|
|
3246
|
+
const descriptor = project && readControl(project.stateDir);
|
|
3247
|
+
if (!project || !descriptor || !descriptor.features?.includes("task_handoff")) return;
|
|
3248
|
+
const client = await ControlClient.connect(project.stateDir, { role: "console", projectRoot: descriptor.cwd ?? project.root }, 3000);
|
|
3249
|
+
try { await client.request({ t: "task_handoff_event", event: { projectId, remoteTask: task.id, state: event, ...(typeof task.sdlc?.pr === "number" ? { pr: task.sdlc.pr } : {}), ...(handoffHead(task) ? { head: handoffHead(task) } : {}) } }, 5000); }
|
|
3250
|
+
finally { client.close(); }
|
|
3251
|
+
} catch { /* the origin is down or refused: it reads the target's state on its next start */ }
|
|
3252
|
+
})();
|
|
3253
|
+
};
|
|
3254
|
+
/**
|
|
3255
|
+
* #604 ORIGIN side, on start: for every handoff this project made that has not reached a final state, ask the target (if it is running) where the task stands now, and record the answer.
|
|
3256
|
+
* A target that is down is skipped: it is asked again on the next start. Best effort, in the background, never delaying readiness.
|
|
3257
|
+
*/
|
|
3258
|
+
const handoffCatchUp = (): void => {
|
|
3259
|
+
void (async () => {
|
|
3260
|
+
const open = board.handoffOutbound().filter(row => !["approved", "cancelled"].includes(row.state));
|
|
3261
|
+
const byProject = new Map<string, number[]>();
|
|
3262
|
+
for (const row of open) byProject.set(row.remoteProject, [...(byProject.get(row.remoteProject) ?? []), row.remoteTask]);
|
|
3263
|
+
for (const [remote, ids] of byProject) {
|
|
3264
|
+
try {
|
|
3265
|
+
const project = registeredProjects(hubHome()).find(candidate => candidate.id === remote);
|
|
3266
|
+
const descriptor = project && readControl(project.stateDir);
|
|
3267
|
+
if (!project || !descriptor?.features?.includes("task_handoff")) continue;
|
|
3268
|
+
const client = await ControlClient.connect(project.stateDir, { role: "console", projectRoot: descriptor.cwd ?? project.root }, 3000);
|
|
3269
|
+
try { const reply = await client.request({ t: "task_handoff_states", projectId, tasks: ids.slice(0, 200) }, 5000); if (reply.ok && Array.isArray(reply.states)) tasks.handoffCaughtUp(remote, reply.states); }
|
|
3270
|
+
finally { client.close(); }
|
|
3271
|
+
} catch { /* that target is down or refused: it is asked again on the next start */ }
|
|
3272
|
+
}
|
|
3273
|
+
})();
|
|
3274
|
+
};
|
|
3163
3275
|
const conductedMutations = new Set(["hub_task_propose", "hub_task_accept", "hub_task_decline", "hub_task_done", "hub_review", "hub_checkpoint", "hub_remember"]);
|
|
3164
3276
|
const taskOp = async (...args: Parameters<typeof taskOpBody>): Promise<string> => {
|
|
3165
3277
|
const [by, op, input] = args;
|
|
@@ -3371,7 +3483,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3371
3483
|
}
|
|
3372
3484
|
// Inside a PII turn the worker's words may carry the PII whatever they are attached to: a note would go to
|
|
3373
3485
|
// shared memory and a new task could be routed to a cloud peer without matching any pattern.
|
|
3374
|
-
if (piiTurn && (MEMORY_READ_TOOLS.has(op) || op === "hub_memory_correct" || op === "hub_remember" || op === "hub_task_propose" || op === "hub_sdlc_record" || op === "hub_plan_review" || op === "hub_sdlc_costs" || op === "hub_sdlc_metrics" || op.startsWith("hub_release_") || op === "hub_backlog_return_untouched" || op === "hub_backlog_plan" || op === "hub_backlog_undo" || op === "hub_backlog_pull" || op === "hub_backlog_split" || op === "release_new")) throw new PreEffectToolRefusal(`${op} is not available while working on a PII task: its text must not leave this machine`);
|
|
3486
|
+
if (piiTurn && (MEMORY_READ_TOOLS.has(op) || op === "hub_memory_correct" || op === "hub_remember" || op === "hub_task_propose" || op === "hub_sdlc_record" || op === "hub_plan_review" || op === "hub_sdlc_costs" || op === "hub_sdlc_metrics" || op.startsWith("hub_release_") || op === "hub_backlog_return_untouched" || op === "hub_backlog_plan" || op === "hub_backlog_undo" || op === "hub_backlog_pull" || op === "hub_backlog_split" || op === "release_new" || op === "hub_study_draft" || op === "hub_study_revise")) throw new PreEffectToolRefusal(`${op} is not available while working on a PII task: its text must not leave this machine`);
|
|
3375
3487
|
// The same words attached to an ordinary task would reach its reviewer, its owner, overlapping owners (a plan, the
|
|
3376
3488
|
// completed-change notice) and shared memory. A turn that holds a PII task acts on ordinary tasks in a turn of its own.
|
|
3377
3489
|
if (piiTurn && (op === "hub_task_done" || op === "hub_review" || (op === "hub_task_accept" && (a.plan != null || a.refs != null)))) {
|
|
@@ -3577,6 +3689,34 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3577
3689
|
}
|
|
3578
3690
|
case "hub_backlog_undo":
|
|
3579
3691
|
return JSON.stringify(tasks.publicView(await tasks.undoPlanning(by, a.id, a.revision)));
|
|
3692
|
+
// #601: the study tools only call Tasks. Draft and revise are the configured planner's (Tasks checks planningAuthority and the private turn); none of them approves.
|
|
3693
|
+
// The arguments go to Tasks whole, never re-picked field by field: an unknown field is refused there (`studyFields`), not silently dropped here, and MCP schemas are not enforced on the way in.
|
|
3694
|
+
case "hub_study_draft":
|
|
3695
|
+
return JSON.stringify(await tasks.studyDraft(by, a));
|
|
3696
|
+
case "hub_study_revise":
|
|
3697
|
+
return JSON.stringify(await tasks.studyRevise(by, a));
|
|
3698
|
+
case "hub_study_show":
|
|
3699
|
+
return JSON.stringify(a.id === undefined ? tasks.studyList(by, a) : tasks.studyShow(by, a));
|
|
3700
|
+
case "hub_study_handoff":
|
|
3701
|
+
if (!a || typeof a !== "object" || Array.isArray(a) || Object.keys(a).some(key => key !== "handoff")) throw new PreEffectToolRefusal("study handoff takes only a handoff object");
|
|
3702
|
+
return JSON.stringify(await tasks.studyHandoff(by, a.handoff));
|
|
3703
|
+
// #604 (agent-hub#1030): a handoff to another project. Tasks checks who may send it and the PII gates; `handoffOut` resolves the target, asks its daemon and records the answer.
|
|
3704
|
+
case "hub_task_handoff":
|
|
3705
|
+
return JSON.stringify(await handoffOut(by, a));
|
|
3706
|
+
case "hub_task_handoffs":
|
|
3707
|
+
return JSON.stringify({ handoffs: tasks.handoffList() });
|
|
3708
|
+
// #601: the person's own forms (a read is hub_study_show, which every connection may use). `by` is USER only on the console path; approval and close are never a tool.
|
|
3709
|
+
case "study_draft":
|
|
3710
|
+
return JSON.stringify(await tasks.studyDraft(by, a));
|
|
3711
|
+
case "study_revise":
|
|
3712
|
+
return JSON.stringify(await tasks.studyRevise(by, a));
|
|
3713
|
+
case "study_approve":
|
|
3714
|
+
return JSON.stringify(await tasks.studyApprove(by, a));
|
|
3715
|
+
case "study_approve_protocol":
|
|
3716
|
+
return JSON.stringify(await tasks.experimentApproveProtocol(by, a));
|
|
3717
|
+
case "study_close":
|
|
3718
|
+
return JSON.stringify(await tasks.studyClose(by, a));
|
|
3719
|
+
|
|
3580
3720
|
case "hub_task_propose": {
|
|
3581
3721
|
const t = await tasks.propose(by, a);
|
|
3582
3722
|
const repoHint = tasks.refsRepoHint(t); // #723: advice only; never part of the write
|
|
@@ -3784,7 +3924,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3784
3924
|
const related = tasks.isPii(task) ? undefined : await taskMemoryResult(task, true).catch(() => ({ items: [], state: "unknown" as const }));
|
|
3785
3925
|
const fresh = board.get(task.id);
|
|
3786
3926
|
const relatedMemory = related && fresh?.updated === task.updated && !tasks.isPii(fresh) ? { state: related.state, count: related.items.length, ...("head" in related ? { head: related.head } : {}) } : { state: "unknown" };
|
|
3787
|
-
return JSON.stringify({ ...detail, ...holderReasons(publicTask), relatedMemory, ...(codeSymbols ? { codeSymbols } : {}), ...(consoleDeliverables ? { deliverables: consoleDeliverables } : {}), symbolOverlaps: tasks.symbolOverlaps(task), flowRevision: task.history.length, reviews: board.reviews({ task: task.id }) }, null, 2);
|
|
3927
|
+
return JSON.stringify({ ...detail, ...holderReasons(publicTask), relatedMemory, ...(publicTask.handoffOrigin ? { handoffOrigin: publicTask.handoffOrigin } : {}), ...(codeSymbols ? { codeSymbols } : {}), ...(consoleDeliverables ? { deliverables: consoleDeliverables } : {}), symbolOverlaps: tasks.symbolOverlaps(task), flowRevision: task.history.length, reviews: board.reviews({ task: task.id }) }, null, 2);
|
|
3788
3928
|
}
|
|
3789
3929
|
case "deliverables": {
|
|
3790
3930
|
// #317 (`ahub deliverables`): the board's deliverable records, person only. `check` measures the files now; without it nothing is opened.
|
|
@@ -3855,20 +3995,71 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3855
3995
|
}
|
|
3856
3996
|
// Runtime choices stay in this daemon only; recovery reads project defaults anew.
|
|
3857
3997
|
const defaultSources = config.permission_default_sources ?? Object.fromEntries(Object.entries(config.permission_modes).map(([peer, mode]) => [peer, { mode, source: "embedded configuration" }]));
|
|
3858
|
-
const
|
|
3998
|
+
const autonomyConsentDir = autonomyConsentStateDir(hubHome(), projectId);
|
|
3999
|
+
const autonomyConsent = createAutonomyConsent(autonomyConsentDir, projectId);
|
|
4000
|
+
const autonomyOwnershipWitness = createAutonomyConsent(join(autonomyConsentDir, "ownership"), projectId, "ownership");
|
|
4001
|
+
const autonomyUndoWitness = createAutonomyConsent(join(autonomyConsentDir, "undo"), projectId, "undo");
|
|
4002
|
+
const autonomyWitnessValues = (peer: "ownership" | "undo", digest: string): AutonomyConsentValues => ({ [peer]: { digest } });
|
|
4003
|
+
const ownAutonomyMetadata = (metadata: unknown) => {
|
|
4004
|
+
const digest = autonomyOwnershipDigest(metadata);
|
|
4005
|
+
if (digest) autonomyOwnershipWitness.confirm(autonomyWitnessValues("ownership", digest));
|
|
4006
|
+
else autonomyOwnershipWitness.clear();
|
|
4007
|
+
};
|
|
4008
|
+
const autonomyConsentLocationSafe = () => {
|
|
4009
|
+
try { const held = canonicalPath(autonomyConsentDir); const roots = [opts.cwd, ...executionRoots.values(), ...Object.values(config.instances ?? {}).flatMap(instance => instance.worktree ? [resolve(opts.cwd, instance.worktree)] : [])]; return !roots.some(root => insideRoot(relative(canonicalPath(root), held))); }
|
|
4010
|
+
catch { return false; }
|
|
4011
|
+
};
|
|
4012
|
+
const autonomyConsentTrusted = () => autonomyConsentLocationSafe() && configRefusal(opts.cwd, "config.local.json") === undefined;
|
|
4013
|
+
function autonomyConsentValues(candidate: HubConfig = config): AutonomyConsentValues {
|
|
4014
|
+
return Object.fromEntries(Object.entries(candidate.permission_modes).filter(([peer, mode]) => mode === "never-ask" && candidate.permission_default_sources?.[peer]?.source === ".agenthub/config.local.json")
|
|
4015
|
+
.map(([peer, mode]) => [peer, { [`permission_modes.${peer}`]: mode }]));
|
|
4016
|
+
}
|
|
4017
|
+
// Machine opt-in is a second host-owned approval trigger. Offline CLI writes never issue consent.
|
|
4018
|
+
const machineAutonomy = readAutonomyDefaults(hubHome(), [opts.cwd, ...executionRoots.values(), ...Object.values(config.instances ?? {}).flatMap(instance => instance.worktree ? [resolve(opts.cwd, instance.worktree)] : [])]);
|
|
4019
|
+
if (machineAutonomy.notice) log(machineAutonomy.notice);
|
|
4020
|
+
if (machineAutonomy.enabled && autonomyConsentTrusted()) {
|
|
4021
|
+
try {
|
|
4022
|
+
const peers: AutonomyPeer[] = SUPPORTED_PEERS.map(id => ({ id, kind: definitionOf(id)?.kind ?? id, unmanaged: !!definitionOf(id)?.permission.unmanaged,
|
|
4023
|
+
...(declarations[id]?.start_mode ? { form: declarations[id]!.start_mode } : {}) }));
|
|
4024
|
+
// The daemon is detached even for a terminal project; explicit Orca/CI evidence selects those rows.
|
|
4025
|
+
const operations = planAutonomy(autonomyEnvironment(process.env, true), key => {
|
|
4026
|
+
const def = settingDef(key);
|
|
4027
|
+
return typeof def === "string" || def.store !== "config" ? undefined : { store: def.store, values: def.values, locked: def.locked };
|
|
4028
|
+
}, peers);
|
|
4029
|
+
const current = autonomyConsentValues(), observed = autonomyConsentValues(loadConfig(opts.cwd, undefined, undefined, externalDefinitions));
|
|
4030
|
+
const retained = autonomyConsent.confirmed(current, true);
|
|
4031
|
+
const approved: AutonomyConsentValues = Object.fromEntries([...retained].map(peer => [peer, current[peer]!]));
|
|
4032
|
+
for (const operation of operations) if (operation.key.startsWith("permission_modes.") && operation.value === "never-ask") {
|
|
4033
|
+
const peer = operation.key.slice("permission_modes.".length);
|
|
4034
|
+
if (current[peer]?.[operation.key] === operation.value && observed[peer]?.[operation.key] === operation.value) approved[peer] = current[peer]!;
|
|
4035
|
+
}
|
|
4036
|
+
if (Object.keys(approved).some(peer => !retained.has(peer))) {
|
|
4037
|
+
if (!autonomyConsentTrusted() || readAutonomyDefaults(hubHome(), [opts.cwd, ...executionRoots.values()]).fingerprint !== machineAutonomy.fingerprint) throw new Error("machine autonomy evidence changed during startup");
|
|
4038
|
+
autonomyConsent.confirm(approved);
|
|
4039
|
+
}
|
|
4040
|
+
} catch { log("Machine autonomy consent was not issued: preset, project configuration or private state could not be verified."); }
|
|
4041
|
+
}
|
|
4042
|
+
let autonomyConfirmed = autonomyConsentLocationSafe() ? autonomyConsent.confirmed(autonomyConsentValues(), autonomyConsentTrusted()) : new Set<string>();
|
|
4043
|
+
const pendingPermissionDefaults = new Map(Object.entries(defaultSources).filter(([peer, value]) => value.mode === "never-ask" && !autonomyConfirmed.has(peer)).map(([peer, value]) => [peer, value.source]));
|
|
3859
4044
|
const permissionChanges = new Map<string, Promise<void>>();
|
|
3860
|
-
const permissionModes = new Map<string, PermissionMode>(Object.entries(config.permission_modes).map(([peer, mode]) => [peer, mode === "never-ask" ? "ask" : mode]));
|
|
4045
|
+
const permissionModes = new Map<string, PermissionMode>(Object.entries(config.permission_modes).map(([peer, mode]) => [peer, mode === "never-ask" && !autonomyConfirmed.has(peer) ? "ask" : mode]));
|
|
3861
4046
|
for (const peer of pendingPermissionDefaults.keys()) permissionModes.set(peer, "ask");
|
|
3862
4047
|
const changedPermissionDefaults = new Set<string>();
|
|
4048
|
+
function refreshAutonomyConsent(candidate: HubConfig = config): void {
|
|
4049
|
+
const next = autonomyConsentLocationSafe() ? autonomyConsent.confirmed(autonomyConsentValues(candidate), autonomyConsentTrusted()) : new Set<string>();
|
|
4050
|
+
for (const peer of new Set([...next, ...autonomyConfirmed])) if (next.has(peer) !== autonomyConfirmed.has(peer)) changedPermissionDefaults.add(peer);
|
|
4051
|
+
autonomyConfirmed = next;
|
|
4052
|
+
}
|
|
3863
4053
|
const applyPendingPermissionDefault = (peer: string): void => {
|
|
4054
|
+
refreshAutonomyConsent();
|
|
3864
4055
|
if (changedPermissionDefaults.has(peer) && (!bus.peers.has(peer) || bus.peers.get(peer)!.state === "offline")) {
|
|
3865
4056
|
changedPermissionDefaults.delete(peer);
|
|
3866
4057
|
const mode = config.permission_modes[peer] ?? "ask";
|
|
3867
4058
|
const source = config.permission_default_sources?.[peer]?.source ?? "configuration default";
|
|
3868
|
-
permissionModes.set(peer, mode === "never-ask" ? "ask" : mode);
|
|
4059
|
+
permissionModes.set(peer, mode === "never-ask" && !autonomyConfirmed.has(peer) ? "ask" : mode);
|
|
3869
4060
|
permissionModeSources.set(peer, source);
|
|
3870
4061
|
pendingPermissionDefaults.delete(peer);
|
|
3871
|
-
if (mode === "never-ask") pendingPermissionDefaults.set(peer, source);
|
|
4062
|
+
if (mode === "never-ask" && !autonomyConfirmed.has(peer)) pendingPermissionDefaults.set(peer, source);
|
|
3872
4063
|
if (config.permission_default_sources?.[peer]) defaultSources[peer] = config.permission_default_sources[peer]!;
|
|
3873
4064
|
else delete defaultSources[peer];
|
|
3874
4065
|
}
|
|
@@ -3881,7 +4072,62 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3881
4072
|
};
|
|
3882
4073
|
const actionBudgets = new ActionBudgets(() => config.action_policy, actionSession);
|
|
3883
4074
|
// #636 slice 2: the always-ask gate of an ACP peer, built per peer from the host's own ports (root, task, PII screen); never from the agent.
|
|
3884
|
-
|
|
4075
|
+
// #999 T3, the floor: its facts are read where the classifier runs, per peer, and an unknown one keeps the class asking. The default branch comes from the same
|
|
4076
|
+
// authoritative remote read the task shell uses (never a local origin/HEAD), cached because the gate is synchronous; before the read answers, or when it fails, every push asks.
|
|
4077
|
+
let floorDefaultBranch: string | undefined, floorGeneration = 0;
|
|
4078
|
+
const floorGitRaw = (cwd: string, args: string[]) => { try { return hubGitSync(["-C", cwd, ...args], { encoding: "utf8", timeout: 1000, maxBuffer: 4096 }); } catch { return undefined; } };
|
|
4079
|
+
const floorFacts = (peer: PeerId): FloorFacts | undefined => {
|
|
4080
|
+
if (!actionBudgets.floorOn()) return undefined;
|
|
4081
|
+
let root: string;
|
|
4082
|
+
try { root = realPath(executionRoot(peer)); } catch { return undefined; }
|
|
4083
|
+
// One classification gets a bounded amount of synchronous work (`floorReads`): every authority read goes through it, the project origin included, so one shared cap, memo and deadline
|
|
4084
|
+
// cover all of them. Past the bound a fact is unknown, and an unknown fact keeps the call asking.
|
|
4085
|
+
const FLOOR_GIT_CALLS = 16, FLOOR_GIT_MS = 2500;
|
|
4086
|
+
const reads = floorReads(FLOOR_GIT_CALLS, FLOOR_GIT_MS);
|
|
4087
|
+
const floorGit = (cwd: string, args: string[]): ReturnType<typeof floorGitRaw> => reads.read(`git\0${cwd}\0${args.join("\0")}`, () => floorGitRaw(cwd, args));
|
|
4088
|
+
const floorOrigin = (): string | undefined => reads.read(`origin\0${root}`, () => githubOriginRepository(root));
|
|
4089
|
+
return {
|
|
4090
|
+
...(floorDefaultBranch ? { defaultBranch: floorDefaultBranch } : {}),
|
|
4091
|
+
...(shellProjectRepository ? { repository: shellProjectRepository } : {}),
|
|
4092
|
+
// Proven only: the directory's own top level is the peer's execution root and that checkout's origin is the project's repository.
|
|
4093
|
+
inOwnRepository: cwd => {
|
|
4094
|
+
try {
|
|
4095
|
+
const top = floorGit(cwd, ["rev-parse", "--show-toplevel"]);
|
|
4096
|
+
if (!top || top.status !== 0 || typeof top.stdout !== "string" || realPath(top.stdout.trim()) !== root) return undefined;
|
|
4097
|
+
return !!shellProjectRepository && floorOrigin() === shellProjectRepository;
|
|
4098
|
+
} catch { return undefined; }
|
|
4099
|
+
},
|
|
4100
|
+
refIsTag: (name, cwd) => { const found = floorGit(cwd, ["rev-parse", "-q", "--verify", `refs/tags/${name}`]); return found === undefined || found.status === null ? undefined : found.status === 0 ? true : found.status === 1 ? false : undefined; },
|
|
4101
|
+
// The effective push destinations, not the fetch URL: pushurl and url.<base>.pushInsteadOf can send `git push origin` to another repository, and push.followTags,
|
|
4102
|
+
// push.pushOption, push.recurseSubmodules and a remote.origin.push or mirror setting add refs, tags or submodule pushes to a plain branch push. `--all` because a remote can have several push
|
|
4103
|
+
// URLs and git pushes to every one of them: each must name the project's own repository. Any of those settings present, or any read that fails, is not proof.
|
|
4104
|
+
pushIsOwn: cwd => {
|
|
4105
|
+
const added = floorGit(cwd, ["config", "--get-regexp", "^(push\\.(followtags|pushoption|recursesubmodules)|remote\\.origin\\.(push|mirror|pushurl))$"]);
|
|
4106
|
+
if (added === undefined || added.status !== 1) return added?.status === 0 ? false : undefined;
|
|
4107
|
+
const urls = floorGit(cwd, ["remote", "get-url", "--push", "--all", "origin"]);
|
|
4108
|
+
if (urls === undefined || urls.status !== 0 || typeof urls.stdout !== "string") return undefined;
|
|
4109
|
+
const lines = urls.stdout.split("\n").map(line => line.trim()).filter(Boolean);
|
|
4110
|
+
return !!shellProjectRepository && lines.length > 0 && lines.every(line => githubRemoteRepository(line) === shellProjectRepository);
|
|
4111
|
+
},
|
|
4112
|
+
// gh's default repository. A peer whose commands the hub builds the environment for (Pi and the local worker: `trust.hubSandboxed`) is judged on the facts the hub can read: only origin, no
|
|
4113
|
+
// `gh repo set-default` marker naming another repository, no GH_REPO, GH_HOST or GH_ENTERPRISE_TOKEN in the hub's own environment. For every other peer (Claude, Codex, Kimi, Qwen, Jcode, agy)
|
|
4114
|
+
// the hub cannot see the environment its shell runs in, and a GH_REPO exported there would redirect an unqualified call: such a call is never proven own, so it asks (foreign-repo-write)
|
|
4115
|
+
// and only an explicit own `--repo`, `-R` or pull request URL runs.
|
|
4116
|
+
ghDefaultIsOwn: cwd => {
|
|
4117
|
+
if (definitionOf(peer)?.trust.hubSandboxed !== true) return false;
|
|
4118
|
+
if (process.env.GH_REPO || process.env.GH_HOST || process.env.GH_ENTERPRISE_TOKEN) return false;
|
|
4119
|
+
const remotes = floorGit(cwd, ["remote"]);
|
|
4120
|
+
if (remotes === undefined || remotes.status !== 0 || typeof remotes.stdout !== "string" || remotes.stdout.trim() !== "origin") return remotes?.status === 0 ? false : undefined;
|
|
4121
|
+
const resolved = floorGit(cwd, ["config", "--get-regexp", "^remote\\..*\\.gh-resolved$"]);
|
|
4122
|
+
if (resolved === undefined) return undefined;
|
|
4123
|
+
if (resolved.status === 1) return true;
|
|
4124
|
+
if (resolved.status !== 0 || typeof resolved.stdout !== "string") return undefined;
|
|
4125
|
+
return resolved.stdout.trim().split("\n").every(line => line.trim().split(/\s+/)[1] === "base");
|
|
4126
|
+
},
|
|
4127
|
+
currentBranch: cwd => { const found = floorGit(cwd, ["symbolic-ref", "-q", "--short", "HEAD"]); return found?.status === 0 && typeof found.stdout === "string" && found.stdout.trim() ? found.stdout.trim() : undefined; },
|
|
4128
|
+
};
|
|
4129
|
+
};
|
|
4130
|
+
const acpActionGate = (peer: PeerId) => actionGate({ budgets: actionBudgets, policy: () => config.action_policy, root: executionRoot, floor: floorFacts,
|
|
3885
4131
|
task: p => board.list("in_progress").find(t => t.owner === p), publicView: task => tasks.publicView(task), isPrivate: p => !!turns.get(p)?.private || contextPii(p),
|
|
3886
4132
|
nameable: text => tasks.nameable(text) }, peer);
|
|
3887
4133
|
// A peer is enforceable only where the hub sees the call before it runs, in the mode it runs in now; anything else says why, never a silent claim.
|
|
@@ -3918,6 +4164,14 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3918
4164
|
const nativeUnattended = (peer: string): boolean => startingUnattended.get(peer) ?? unattendedPeers.has(peer);
|
|
3919
4165
|
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 }>();
|
|
3920
4166
|
let stopping = false;
|
|
4167
|
+
/** Read the default branch for the floor. A newer policy or a stop makes an older read's answer void, and a failed read leaves the branch unknown (every push asks). */
|
|
4168
|
+
const refreshFloorFacts = async (): Promise<void> => {
|
|
4169
|
+
const generation = ++floorGeneration;
|
|
4170
|
+
if (config.action_policy?.floor !== true || !shellProjectRepository) { floorDefaultBranch = undefined; return; }
|
|
4171
|
+
const found = await (opts.shellDefaultBranchProbe ?? readTaskShellDefaultBranch)(opts.cwd, hostShellEnvironment({ PATH: hostShellPath(opts.cwd, opts.cwd) ?? "" }), () => !stopping && generation === floorGeneration, opts.cwd, shellProjectRepository).catch(() => undefined);
|
|
4172
|
+
if (generation === floorGeneration) floorDefaultBranch = found;
|
|
4173
|
+
};
|
|
4174
|
+
void refreshFloorFacts();
|
|
3921
4175
|
const roomInvocations: RoomInvocations = new RoomInvocations({ bus, ledger: invocations, shares: () => readShare(config.invocation?.share), identity: (peer, sender) => roomChat.invocationIdentity(peer, sender),
|
|
3922
4176
|
target: async (input, request) => { await invokeCapability(input.remotePeer, { peer: input.envelope.to, capability: request.capability, args: request.args }, false, () => { if (!input.current()) throw new PreEffectToolRefusal("remote invocation authority changed"); }, { ...input, request }); },
|
|
3923
4177
|
safeResult: async (body, current) => {
|
|
@@ -4014,6 +4268,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4014
4268
|
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
|
|
4015
4269
|
if (tasks.queuePressureRechecksPending() !== 0) return false;
|
|
4016
4270
|
if (tasks.schedulesPending() !== 0) return false; // #512a: a scheduled run mid-admission (its PII screen is awaited) would write after the commit's digest; the hold fence refuses its write, and this waits for it to end
|
|
4271
|
+
if (tasks.studiesPending() !== 0) return false;
|
|
4017
4272
|
if (!recoveryPeerSnapshot) return true;
|
|
4018
4273
|
const current = recoveryPeers();
|
|
4019
4274
|
return recoveryPeerSnapshot.every((saved) => {
|
|
@@ -4163,7 +4418,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4163
4418
|
return row;
|
|
4164
4419
|
});
|
|
4165
4420
|
const budgetState = budget.persistedPauseDigestRows().sort((a, b) => a.peer.localeCompare(b.peer));
|
|
4166
|
-
|
|
4421
|
+
const research = board.researchIntegrity();
|
|
4422
|
+
const researchDispatch = board.researchDispatchIntegrity();
|
|
4423
|
+
const researchExperiments = board.researchExperimentsIntegrity();
|
|
4424
|
+
const researchComparisons = board.researchComparisonIntegrity();
|
|
4425
|
+
return { queues, manualPaused: [...manualPaused].sort(), boardDigest: digest(boardState), budgetDigest: digest(budgetState), ...(research.campaigns || research.units ? { research } : {}), ...(researchDispatch.activations || researchDispatch.handoffs ? { researchDispatch } : {}), ...(researchExperiments.protocols || researchExperiments.attempts || researchExperiments.events || researchExperiments.observations ? { researchExperiments } : {}), ...(researchComparisons.plans || researchComparisons.approvals || researchComparisons.contexts ? { researchComparisons } : {}), ...(allExternalInstanceIds().length ? { externalBindingsDigest: digest(allExternalInstanceIds().sort().map(id => [id, (externalRefusedRows.has(id) ? externalBindings.get(id) : retainedExternalBinding(id)) ?? null])) } : {}) };
|
|
4167
4426
|
};
|
|
4168
4427
|
/** The integrity in the shape `expected` was recorded in: the current one unless only an older shape matches. */
|
|
4169
4428
|
const integrityAs = (expected: unknown) => {
|
|
@@ -4331,6 +4590,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4331
4590
|
board.onChange = (task, entry) => {
|
|
4332
4591
|
try { taskChanged?.(task, entry); }
|
|
4333
4592
|
finally {
|
|
4593
|
+
try { handoffNotifyOrigin(task, entry); } catch { /* never throw after a board write */ }
|
|
4334
4594
|
try { settleSupersededDeliveries(); }
|
|
4335
4595
|
finally { writeStatus(); } // committed task transitions refresh token-free attention even without bus activity
|
|
4336
4596
|
}
|
|
@@ -4826,7 +5086,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4826
5086
|
} else {
|
|
4827
5087
|
log(`msg ${e.env.from} -> ${e.env.to?.join(",") ?? "*"} ${e.env.priority} hop=${e.env.hop}${e.dropped ? ` NOT DELIVERED(${e.dropped})` : ""}: ${e.env.body.slice(0, 200)}`);
|
|
4828
5088
|
const env = e.env;
|
|
4829
|
-
|
|
5089
|
+
const origin = takeOrigin(env.id) ?? adapterOrigin(env);
|
|
5090
|
+
event({ type: "envelope", id: env.id, from: env.from, ...(origin ? { origin } : {}), ...(env.to ? { to: env.to } : {}), priority: env.priority, hop: env.hop, ...(env.kind ? { kind: env.kind } : {}), ...(env.refs?.task ? { task: env.refs.task } : {}), ...(env.private ? {} : { bytes: Buffer.byteLength(env.body) }), ...(env.private ? { private: true } : {}), ...(e.dropped ? { dropped: e.dropped } : {}) });
|
|
4830
5091
|
}
|
|
4831
5092
|
if (!stopping) writeStatus();
|
|
4832
5093
|
});
|
|
@@ -6443,6 +6704,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6443
6704
|
return JSON.stringify(before) === JSON.stringify(after) ? [] : [prefix];
|
|
6444
6705
|
}
|
|
6445
6706
|
function declaredPaths(value: unknown, prefix = ""): string[] {
|
|
6707
|
+
if (prefix === "_autonomy" || prefix.startsWith("_autonomy.")) return [];
|
|
6446
6708
|
// Retired dictionaries never enter active publication; hub ids remain opaque keys.
|
|
6447
6709
|
if (prefix === "routing.targets" || prefix === "routing.routes") return [];
|
|
6448
6710
|
if (prefix === "routing.hub_routes") return [prefix];
|
|
@@ -6627,7 +6889,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6627
6889
|
if (change.applies === "restart" || change.key === "roles" || change.key.startsWith("roles.")) continue;
|
|
6628
6890
|
putConfig(config, change.key, change.after);
|
|
6629
6891
|
}
|
|
6630
|
-
if (changes.some(change => change.key === "action_policy" || change.key.startsWith("action_policy."))) actionBudgets.policyChanged();
|
|
6892
|
+
if (changes.some(change => change.key === "action_policy" || change.key.startsWith("action_policy."))) { actionBudgets.policyChanged(); void refreshFloorFacts(); }
|
|
6631
6893
|
if (rolesChanged) { policyRoles = structuredClone(prepared.running.roles); settlePlanner(); }
|
|
6632
6894
|
for (const change of changes.filter(change => change.key === "permission_modes" || change.key.startsWith("permission_modes."))) {
|
|
6633
6895
|
for (const peer of change.key === "permission_modes" ? SUPPORTED_PEERS : [change.key.split(".")[1]!]) changedPermissionDefaults.add(peer);
|
|
@@ -6636,6 +6898,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6636
6898
|
config.retired = prepared.running.retired;
|
|
6637
6899
|
for (const line of config.retired ?? []) if (!priorRetired.has(line)) log(`config: ${line}`);
|
|
6638
6900
|
config.permission_default_sources = candidate.permission_default_sources;
|
|
6901
|
+
refreshAutonomyConsent(prepared.fileConfig);
|
|
6639
6902
|
for (const peer of changedPermissionDefaults) applyPendingPermissionDefault(peer);
|
|
6640
6903
|
if (changes.some(change => change.key === "task_sweep" || change.key.startsWith("task_sweep."))) rescheduleSweep();
|
|
6641
6904
|
if (changes.some(change => change.key === "schedules" || change.key.startsWith("schedules.") || change.key === "folder_triggers" || change.key.startsWith("folder_triggers."))) rescheduleSchedules();
|
|
@@ -6774,7 +7037,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6774
7037
|
if (stopping || recoveryActive()) return { ok: false, error: "recovery or shutdown is holding roster changes" };
|
|
6775
7038
|
rosterChanging = peer;
|
|
6776
7039
|
if (undo) undoSetting(opts.cwd, opts.stateDir, scratch => configWriteCheck(scratch, authority, `roster.${peer}`), (_routing, overlay) => routingWriteCheck(authority, overlay), undoIdentity);
|
|
6777
|
-
else
|
|
7040
|
+
else {
|
|
7041
|
+
const written = writeConfigSetting(opts.cwd, opts.stateDir, def, value, scratch => configWriteCheck(scratch, authority, `roster.${peer}`), {preserveUndo:authority==="conductor"});
|
|
7042
|
+
if (written && authority !== "conductor") autonomyUndoWitness.clear();
|
|
7043
|
+
}
|
|
6778
7044
|
// Apply exactly the authorized key. Other file edits stay pending until the next hub start.
|
|
6779
7045
|
config.roster = next;
|
|
6780
7046
|
settlePlanner(); // #455: an automatic planner follows the roster
|
|
@@ -6795,9 +7061,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6795
7061
|
checkOperational(authority);
|
|
6796
7062
|
const mode = config.permission_modes[peer] ?? "ask";
|
|
6797
7063
|
permissionModes.set(peer, mode === "never-ask" ? "ask" : mode);
|
|
7064
|
+
if (mode === "never-ask" && autonomyConfirmed.has(peer)) permissionModes.set(peer, mode);
|
|
6798
7065
|
permissionModeSources.set(peer, defaultSources[peer]?.source ?? "embedded configuration");
|
|
6799
7066
|
pendingPermissionDefaults.delete(peer);
|
|
6800
|
-
if (mode === "never-ask") pendingPermissionDefaults.set(peer, defaultSources[peer]?.source ?? "embedded configuration");
|
|
7067
|
+
if (mode === "never-ask" && !autonomyConfirmed.has(peer)) pendingPermissionDefaults.set(peer, defaultSources[peer]?.source ?? "embedded configuration");
|
|
6801
7068
|
const handoffCurrent = authority === "conductor" ? operationalContext.getStore()!.current : undefined;
|
|
6802
7069
|
handoff = await tasks.reassignForRoster(peer, handoffCurrent);
|
|
6803
7070
|
} else {
|
|
@@ -6878,6 +7145,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6878
7145
|
const next = writeRoutingSetting(opts.cwd, opts.stateDir, def, value, (_routing, overlay) => routingWriteCheck(authority, overlay, def.key));
|
|
6879
7146
|
if (!next) return { ok: true, text: `${def.key}: ${OVERLAY_FILE} already ${value === null ? "leaves it unset" : `holds ${settingText(value)}`}; nothing was written` };
|
|
6880
7147
|
saved = true;
|
|
7148
|
+
if (authority !== "conductor") autonomyUndoWitness.clear();
|
|
6881
7149
|
const result = await reloadConfig(authority, def.key);
|
|
6882
7150
|
if (result.ok !== true) return { ...result, saved: true };
|
|
6883
7151
|
recordSetting(def.key, from, routingValue(activeRouting(), def), source, false, reason);
|
|
@@ -6887,6 +7155,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6887
7155
|
const from = settingScalar(valueAt(storedConfig(), def.path));
|
|
6888
7156
|
if (!writeConfigSetting(opts.cwd, opts.stateDir, def, value, scratch => configWriteCheck(scratch, authority, def.path.join(".")), {preserveUndo:authority==="conductor"})) return { ok: true, text: `${def.key}: config.local.json already ${value === null ? "leaves it unset" : `holds ${settingText(value)}`}; nothing was written` };
|
|
6889
7157
|
saved = true;
|
|
7158
|
+
if (authority !== "conductor") autonomyUndoWitness.clear();
|
|
6890
7159
|
const result = await reloadConfig(authority, def.path.join("."));
|
|
6891
7160
|
if (result.ok !== true) return { ...result, saved: true };
|
|
6892
7161
|
const to = settingScalar(valueAt(storedConfig(), def.path));
|
|
@@ -6902,6 +7171,132 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6902
7171
|
}
|
|
6903
7172
|
}
|
|
6904
7173
|
|
|
7174
|
+
function autonomyPeers(): AutonomyPeer[] {
|
|
7175
|
+
return SUPPORTED_PEERS.map(id => {
|
|
7176
|
+
const setting = settingDef(`permission_modes.${id}`), definition = definitionOf(id), owner = bus.peers.get(id);
|
|
7177
|
+
const lockedMode = typeof setting !== "string" && setting.locked ? setting.values?.[0] : undefined;
|
|
7178
|
+
const form = owner?.form ?? startMode(id);
|
|
7179
|
+
const unmanaged = definition?.permission.unmanaged || definition?.permission.unmanagedForms?.includes(owner ? owner.form ?? "tui" : form);
|
|
7180
|
+
return { id, kind: definition?.kind ?? id, unmanaged: !!definition?.permission.unmanaged, ...(lockedMode ? { lockedMode } : {}), form,
|
|
7181
|
+
permissionMode: unmanaged && !lockedMode ? "unmanaged" : permissionDisplay(id, owner), actionEnforcement: actionEnforcement(id) };
|
|
7182
|
+
});
|
|
7183
|
+
}
|
|
7184
|
+
function autonomyOperations(environment: AutonomyEnvironment) {
|
|
7185
|
+
return planAutonomy(environment, key => {
|
|
7186
|
+
const def = settingDef(key);
|
|
7187
|
+
return typeof def === "string" || def.store !== "config" ? undefined : { store: def.store, values: def.values, locked: def.locked };
|
|
7188
|
+
}, autonomyPeers()).map(operation => {
|
|
7189
|
+
const def = settingDef(operation.key);
|
|
7190
|
+
if (typeof def !== "string") checkSettingValue(def, operation.value);
|
|
7191
|
+
return operation;
|
|
7192
|
+
});
|
|
7193
|
+
}
|
|
7194
|
+
async function autonomyPublish(keys: string[]): Promise<Record<string, unknown>> {
|
|
7195
|
+
for (const key of keys) {
|
|
7196
|
+
const result = await reloadConfig("terminal", key);
|
|
7197
|
+
if (result.ok !== true) return { ...result, saved: true };
|
|
7198
|
+
}
|
|
7199
|
+
return { ok: true };
|
|
7200
|
+
}
|
|
7201
|
+
async function autonomyUndo(authority: SettingAuthority, confirm: unknown, identity: UndoIdentity): Promise<Record<string, unknown>> {
|
|
7202
|
+
if (authority !== "terminal" || confirm !== "autonomy") return { ok: false, error: "undoing project autonomy needs the person's terminal and the autonomy confirmation" };
|
|
7203
|
+
let saved = false;
|
|
7204
|
+
try {
|
|
7205
|
+
if (!autonomyConsentLocationSafe() || !autonomyUndoWitness.confirmed(autonomyWitnessValues("undo", identity.undoDigest), true).has("undo")) {
|
|
7206
|
+
const removed = await autonomyCommand("off", Object.keys(autonomyPreset().environments)[0], true);
|
|
7207
|
+
return removed.ok === true ? { ...removed, text: "Preset undo witness did not verify; fell back to removing the preset keys and clearing retained consent." } : removed;
|
|
7208
|
+
}
|
|
7209
|
+
const keys = autonomyUndoKeys(opts.stateDir);
|
|
7210
|
+
const restored = undoSetting(opts.cwd, opts.stateDir, scratch => { configWriteCheck(scratch, "terminal", AUTONOMY_UNDO.key); for (const key of keys) configWriteCheck(scratch, "terminal", key); }, undefined, identity);
|
|
7211
|
+
saved = true;
|
|
7212
|
+
try { if (autonomyConsentLocationSafe()) autonomyConsent.clear(); } finally { refreshAutonomyConsent(); }
|
|
7213
|
+
ownAutonomyMetadata(restored.ownership); autonomyUndoWitness.clear();
|
|
7214
|
+
const result = await autonomyPublish(keys);
|
|
7215
|
+
if (result.ok !== true) return result;
|
|
7216
|
+
recordSetting(AUTONOMY_UNDO.key, "preset change", "previous configuration", "terminal", true);
|
|
7217
|
+
return { ok: true, text: "Project autonomy change undone; live keys apply now, permission and start defaults at the next peer start." };
|
|
7218
|
+
} catch (error) {
|
|
7219
|
+
for (const peer of changedPermissionDefaults) applyPendingPermissionDefault(peer);
|
|
7220
|
+
return { ok: false, error: sanitize((error as Error).message), ...(saved ? { saved: true } : {}) };
|
|
7221
|
+
}
|
|
7222
|
+
}
|
|
7223
|
+
let autonomyPreview: { token: string; environment: string; fingerprint: string; revision: number; operations: string } | undefined;
|
|
7224
|
+
async function autonomyCommand(action: unknown, environment: unknown, confirmed: unknown, confirmation?: unknown): Promise<Record<string, unknown>> {
|
|
7225
|
+
if (stopping || recoveryActive() || rosterChanging) return { ok: false, error: "recovery, shutdown or roster change is holding autonomy" };
|
|
7226
|
+
if (typeof action !== "string" || typeof environment !== "string" || !Object.entries(autonomyPreset().environments).some(([name, row]) => name === environment || row.aliases.includes(environment)) || !["preview", "on", "off", "status"].includes(action)) return { ok: false, error: "invalid autonomy command or environment" };
|
|
7227
|
+
let saved = false;
|
|
7228
|
+
try {
|
|
7229
|
+
const env = environment as AutonomyEnvironment;
|
|
7230
|
+
const timing = (key: string) => { const def = settingDef(key); return typeof def !== "string" && def.applies === "next use" ? "next peer start" : typeof def !== "string" && def.applies === "restart" ? "next hub start" : "live"; };
|
|
7231
|
+
if (action === "status") {
|
|
7232
|
+
refreshAutonomyConsent();
|
|
7233
|
+
for (const peer of changedPermissionDefaults) applyPendingPermissionDefault(peer);
|
|
7234
|
+
const terminal = opener.available();
|
|
7235
|
+
const peers = autonomyPeers();
|
|
7236
|
+
const rows = autonomyStatus({ environment: env, peers, terminalOpener: terminal.ok ? { available: true, via: terminal.via } : { available: false, reason: terminal.why },
|
|
7237
|
+
floorEnforcement: Object.fromEntries(peers.map(peer => [peer.id, floorEnforcement(config.action_policy, peer.actionEnforcement ?? { enforced: false })])) });
|
|
7238
|
+
for (const row of rows) if (pendingPermissionDefaults.has(row.id)) row.missing.push("This never-ask default has no matching retained consent; confirm it in ahub console or run ahub autonomy on again.");
|
|
7239
|
+
return { ok: true, status: { environment: env, consentedPeers: [...autonomyConfirmed], peers: rows }, lines: [`Project autonomy, environment: ${env}`, ...rows.flatMap(row => [`${row.id}: ${row.effectiveMode}; preset ${row.nextMode ?? "unmanaged"} at ${row.applies}`, ...row.floor.map(floor => ` ${floor.class}: ${floor.status}; ${floor.reason}`), ...row.missing.map(reason => ` missing: ${reason}`)])] };
|
|
7240
|
+
}
|
|
7241
|
+
const operations = action === "off" ? autonomyRemovalKeys(autonomyPeers()).map(key => ({ key, value: null, store: "config" as const })) : autonomyOperations(env);
|
|
7242
|
+
if (action === "preview") {
|
|
7243
|
+
autonomyPreview = { token: randomUUID(), environment: env, fingerprint: configFingerprint(configSourceTexts(opts.cwd)), revision: configRevision, operations: JSON.stringify(operations) };
|
|
7244
|
+
return { ok: true, confirmation: autonomyPreview.token, lines: ["Project autonomy: approval prompts change; sandbox, deny, PII and trust rules remain. Check autonomy status for the current floor enforcement and missing guards.", autonomyPreset().about, ...operations.map(operation => `${operation.key}: ${settingText(operation.value)} (${timing(operation.key)})`), "Unmanaged native clients and missing structural guards are reported by autonomy status; the preset cannot enforce those paths."] };
|
|
7245
|
+
}
|
|
7246
|
+
if (action === "on" && confirmed !== true) return { ok: false, error: "project autonomy needs one person confirmation; use ahub autonomy on [--yes]" };
|
|
7247
|
+
if (action === "on") {
|
|
7248
|
+
if (!autonomyPreview || confirmation !== autonomyPreview.token || env !== autonomyPreview.environment || autonomyPreview.revision !== configRevision || autonomyPreview.operations !== JSON.stringify(operations) || autonomyPreview.fingerprint !== configFingerprint(configSourceTexts(opts.cwd))) return { ok: false, error: "autonomy preview is stale or missing; run ahub autonomy on again to review the current values" };
|
|
7249
|
+
autonomyPreview = undefined;
|
|
7250
|
+
}
|
|
7251
|
+
// Off has a fixed shipped removal scope, independent of environment and untrusted ownership metadata.
|
|
7252
|
+
const allowed = operations;
|
|
7253
|
+
if (action === "on" && !autonomyConsentLocationSafe()) return { ok: false, error: "retained autonomy consent requires private hub state outside the project and execution roots" };
|
|
7254
|
+
if (action === "off") { try { if (autonomyConsentLocationSafe()) autonomyConsent.clear(); } finally { refreshAutonomyConsent(); } }
|
|
7255
|
+
refreshAutonomyConsent();
|
|
7256
|
+
const renewConsentUndo = action === "on" && operations.some(operation => operation.key.startsWith("permission_modes.") && operation.value === "never-ask" && !autonomyConfirmed.has(operation.key.slice("permission_modes.".length)));
|
|
7257
|
+
const result = writeAutonomyPreset(opts.cwd, opts.stateDir, action === "on", allowed, (scratch, keys) => { configWriteCheck(scratch, "terminal", AUTONOMY_UNDO.key); for (const key of keys) configWriteCheck(scratch, "terminal", key); }, metadata => {
|
|
7258
|
+
try {
|
|
7259
|
+
const digest = autonomyOwnershipDigest(metadata);
|
|
7260
|
+
return !!digest && autonomyConsentLocationSafe() && autonomyOwnershipWitness.confirmed(autonomyWitnessValues("ownership", digest), true).has("ownership");
|
|
7261
|
+
} catch { return false; }
|
|
7262
|
+
}, renewConsentUndo);
|
|
7263
|
+
saved = result.changed;
|
|
7264
|
+
if (result.changed) {
|
|
7265
|
+
if (action === "on") {
|
|
7266
|
+
ownAutonomyMetadata(result.ownership);
|
|
7267
|
+
if (result.undo) autonomyUndoWitness.confirm(autonomyWitnessValues("undo", result.undo.undoDigest));
|
|
7268
|
+
else autonomyUndoWitness.clear();
|
|
7269
|
+
} else {
|
|
7270
|
+
// Private restoration metadata cannot veto the person's off switch either.
|
|
7271
|
+
try { ownAutonomyMetadata(null); } catch { /* Invalid state is never restoration authority. */ }
|
|
7272
|
+
try { if (result.undo) autonomyUndoWitness.confirm(autonomyWitnessValues("undo", result.undo.undoDigest)); else autonomyUndoWitness.clear(); } catch { /* Undo will use the safe removal fallback. */ }
|
|
7273
|
+
}
|
|
7274
|
+
const published = await autonomyPublish(result.keys);
|
|
7275
|
+
if (published.ok !== true) return published;
|
|
7276
|
+
recordSetting(AUTONOMY_UNDO.key, action === "on" ? "off" : "on", action, "terminal", false);
|
|
7277
|
+
}
|
|
7278
|
+
if (action === "off" && autonomyConsentLocationSafe()) { try { autonomyOwnershipWitness.clear(); } catch { /* Off does not depend on restoration metadata. */ } }
|
|
7279
|
+
if (action === "on") {
|
|
7280
|
+
if (!autonomyConsentTrusted()) throw new Error("config.local.json trust changed; retained consent was not issued");
|
|
7281
|
+
const fileConfig = loadConfig(opts.cwd, undefined, undefined, externalDefinitions);
|
|
7282
|
+
validateEffectiveConfig(fileConfig, adapterCatalog);
|
|
7283
|
+
const current = autonomyConsentValues(fileConfig);
|
|
7284
|
+
const retained = autonomyConsent.confirmed(current, true);
|
|
7285
|
+
const approved: AutonomyConsentValues = Object.fromEntries([...retained].map(peer => [peer, current[peer]!]));
|
|
7286
|
+
for (const operation of operations) if (operation.key.startsWith("permission_modes.") && operation.value === "never-ask") {
|
|
7287
|
+
const peer = operation.key.slice("permission_modes.".length);
|
|
7288
|
+
if (current[peer]) approved[peer] = current[peer]!;
|
|
7289
|
+
}
|
|
7290
|
+
try { autonomyConsent.confirm(approved); } finally { refreshAutonomyConsent(); }
|
|
7291
|
+
}
|
|
7292
|
+
for (const peer of changedPermissionDefaults) applyPendingPermissionDefault(peer);
|
|
7293
|
+
return { ok: true, lines: [`Project autonomy ${action}: ${result.changed ? "saved" : "unchanged"}. ahub settings undo restores the complete last write.`, ...result.keys.map(key => `${key}: ${timing(key)}`), ...result.preserved.map(key => `${key}: kept the later manual edit`)] };
|
|
7294
|
+
} catch (error) {
|
|
7295
|
+
if (action === "off") for (const peer of changedPermissionDefaults) applyPendingPermissionDefault(peer);
|
|
7296
|
+
return { ok: false, error: sanitize((error as Error).message), ...(saved ? { saved: true } : {}) };
|
|
7297
|
+
}
|
|
7298
|
+
}
|
|
7299
|
+
|
|
6905
7300
|
async function settingsUndo(authority: SettingAuthority, confirm?: unknown, now?: unknown, rawReason?: unknown, expectedKey?: unknown, expectedDigest?: unknown): Promise<Record<string, unknown>> {
|
|
6906
7301
|
if (stopping || recoveryActive()) return { ok: false, error: "recovery or shutdown is holding settings" };
|
|
6907
7302
|
let reason: string | undefined;
|
|
@@ -6915,6 +7310,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6915
7310
|
}
|
|
6916
7311
|
const def = undoableDef(identity?.key);
|
|
6917
7312
|
if (typeof def === "string" || !identity) return { ok: false, error: "nothing to undo" };
|
|
7313
|
+
if (def === AUTONOMY_UNDO) return autonomyUndo(authority, confirm, identity);
|
|
6918
7314
|
if (def.managed && authority !== "terminal") return { ok: false, error: `${def.key} was written by ahub learn accept: put it back with ahub settings undo in a terminal` };
|
|
6919
7315
|
if (def.group === "Roster") return changeRosterSetting(def, null, settingSource(authority), confirm, now === true, true, authority, reason, identity);
|
|
6920
7316
|
if (rosterChanging) return { ok: false, error: `roster change for ${rosterChanging} is in progress` };
|
|
@@ -6971,6 +7367,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6971
7367
|
function settingsUndoPreview(): Record<string, unknown> {
|
|
6972
7368
|
const identity = pendingUndoIdentity(opts.stateDir), def = undoableDef(identity?.key);
|
|
6973
7369
|
if (typeof def === "string" || !identity) return { ok: false, error: "nothing to undo" };
|
|
7370
|
+
if (def === AUTONOMY_UNDO) return { ok: true, ...identity, lines: ["Undo the complete project autonomy change when its private witness verifies; otherwise remove the fixed preset keys and clear consent. Type autonomy to confirm; modes at peer start and existing live keys keep their apply timing."] };
|
|
6974
7371
|
if (def.managed) return { ok: true, ...identity, lines: [`Undo ${def.key}: put back the previous machine-local file (the review checklist ahub learn accept changed; its lines are not shown here)`, "The saved previous machine-local file is revalidated before writing."] };
|
|
6975
7372
|
const value = restoredValue(opts.cwd, opts.stateDir, def), preview = settingsPreview(def.key, value ?? null);
|
|
6976
7373
|
if (pendingUndoIdentity(opts.stateDir)?.undoDigest !== identity.undoDigest) return { ok: false, error: "undo record changed during preview; preview again" };
|
|
@@ -7130,6 +7527,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7130
7527
|
...(tasks.autoPullsPending() !== 0 ? ["an automatic backlog pull in flight"] : []),
|
|
7131
7528
|
...(tasks.queuePressureRechecksPending() !== 0 ? ["a queue-pressure assignment recheck in flight"] : []),
|
|
7132
7529
|
...(tasks.schedulesPending() !== 0 ? ["a scheduled task proposal in flight"] : []),
|
|
7530
|
+
...(tasks.studiesPending() !== 0 ? ["a research campaign admission in flight"] : []),
|
|
7133
7531
|
...(checkPending.size !== 0 ? ["isolated checks queued or running"] : []),
|
|
7134
7532
|
...(piCallsInFlight() !== 0 ? ["Pi tool calls in flight"] : []),
|
|
7135
7533
|
...(releasing ? ["ready tasks being released"] : []),
|
|
@@ -7171,6 +7569,74 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7171
7569
|
&& (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("task_planners"))) return "target lacks task_planners; configured or persisted planner authority cannot be restored by that daemon";
|
|
7172
7570
|
if (board.releases().some(release => release.train && ["active", "frozen"].includes(release.state)) && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("release_trains"))) return "target lacks release_trains; active or frozen train safeguards cannot be preserved";
|
|
7173
7571
|
if ((extraIds.some(id => !!definitionOf(id)?.reviewer) || config.review.pool !== undefined) && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("reviewer_pool"))) return "target lacks reviewer_pool; fresh review-only variants and pool policy cannot be preserved";
|
|
7572
|
+
if (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("research_records")) {
|
|
7573
|
+
if (board.hasResearchState()) return "target lacks research_records; persisted research campaign records require a supporting daemon";
|
|
7574
|
+
let namesRunners = false;
|
|
7575
|
+
try {
|
|
7576
|
+
const texts = configSourceTexts(opts.cwd);
|
|
7577
|
+
for (const name of CONFIG_FILES) {
|
|
7578
|
+
if (texts[name] === null) continue;
|
|
7579
|
+
const raw: unknown = JSON.parse(texts[name]!);
|
|
7580
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("invalid configuration");
|
|
7581
|
+
const research = (raw as Record<string, unknown>).research;
|
|
7582
|
+
if (research !== undefined && (!research || typeof research !== "object" || Array.isArray(research))) throw new Error("invalid research configuration");
|
|
7583
|
+
namesRunners ||= research !== undefined && Object.hasOwn(research, "runners");
|
|
7584
|
+
}
|
|
7585
|
+
} catch { return "research_records compatibility is unknown; configuration could not be read"; }
|
|
7586
|
+
// Raw presence matters even for a disabled or ignored shared declaration: an older target parses the unknown key first.
|
|
7587
|
+
if (namesRunners) return "target lacks research_records; the project's research.runners cannot be loaded by that daemon";
|
|
7588
|
+
}
|
|
7589
|
+
if ((!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("research_dispatch")) && board.hasResearchDispatchState())
|
|
7590
|
+
return "target lacks research_dispatch; persisted campaign stop policies and handoffs require a supporting daemon";
|
|
7591
|
+
if ((!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("research_dropped_offers")) && board.hasResearchDroppedOffers())
|
|
7592
|
+
return "target lacks research_dropped_offers; persisted study re-offers require a supporting daemon";
|
|
7593
|
+
// agent-hub#1036: an unsettled delivery that holds a distinct session's message is an envelope with `session: true` and a minted `<peer>~<id>` sender. A target without the feature
|
|
7594
|
+
// would read that sender as a peer it does not know and queue a reply to it; this is its own label, so a daemon that has the older floors but not this one is still refused.
|
|
7595
|
+
if ((!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("session_identity")) && bus.queueList(undefined, true).some(row => (row.originals as Envelope[]).some(env => env.session === true)))
|
|
7596
|
+
return "target lacks session_identity; unsettled deliveries hold messages from distinct sessions that require a supporting daemon";
|
|
7597
|
+
// #604: a handoff row in either direction (a task another project handed us, or one we handed over) needs a target that knows `task_handoff`; without it the origin is silently lost.
|
|
7598
|
+
if ((!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("task_handoff")) && board.hasHandoffState())
|
|
7599
|
+
return "target lacks task_handoff; stored handoffs require a supporting daemon";
|
|
7600
|
+
if ((!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("research_experiments")) && board.hasResearchExperimentState())
|
|
7601
|
+
return "target lacks research_experiments; persisted experiment records require a supporting daemon";
|
|
7602
|
+
if ((!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("research_comparisons")) && board.hasResearchComparisonState())
|
|
7603
|
+
return "target lacks research_comparisons; persisted comparison plans, approvals or protocol context require a supporting daemon";
|
|
7604
|
+
if (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("project_autonomy")) {
|
|
7605
|
+
let needsAutonomy = autonomyConfirmed.size > 0;
|
|
7606
|
+
try {
|
|
7607
|
+
const texts = configSourceTexts(opts.cwd);
|
|
7608
|
+
needsAutonomy ||= CONFIG_FILES.some(name => texts[name] !== null && Object.hasOwn(JSON.parse(texts[name]!), "_autonomy"));
|
|
7609
|
+
} catch { return "project autonomy compatibility is unknown; configuration could not be read"; }
|
|
7610
|
+
if (needsAutonomy) return "target lacks project_autonomy; project preset metadata and retained mode consent require a supporting daemon";
|
|
7611
|
+
}
|
|
7612
|
+
// #999 T3: a target without the floor key rejects `action_policy.floor` as an unknown key, so the project would fail to load there. The check is the key's presence in the project's file, true or false:
|
|
7613
|
+
// `floor: false` is just as unknown to an older target, and a disabled policy still names it.
|
|
7614
|
+
// The raw files decide, not the effective config: a shared `config.json` that names `action_policy.floor` is ignored (the key is machine-local) but an older target's parser rejects it
|
|
7615
|
+
// before that rule runs, and a `floor: false` is normalized away. Unreadable files are unknown, never absent.
|
|
7616
|
+
if (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("action_policy_floor")) {
|
|
7617
|
+
let namesFloor = config.action_policy?.floor !== undefined;
|
|
7618
|
+
try {
|
|
7619
|
+
const texts = configSourceTexts(opts.cwd);
|
|
7620
|
+
namesFloor ||= CONFIG_FILES.some(name => {
|
|
7621
|
+
const policy = texts[name] === null ? undefined : (JSON.parse(texts[name]!) as Record<string, unknown>).action_policy;
|
|
7622
|
+
return !!policy && typeof policy === "object" && Object.hasOwn(policy, "floor");
|
|
7623
|
+
});
|
|
7624
|
+
} catch { return "action_policy_floor compatibility is unknown; configuration could not be read"; }
|
|
7625
|
+
if (namesFloor) return "target lacks action_policy_floor; the project's action_policy.floor cannot be loaded by that daemon";
|
|
7626
|
+
}
|
|
7627
|
+
// #1017: an older target refuses a `cron` or `tz` key in a `schedules` entry ("cron not supported yet"), so the project would not load there. The raw files decide, as for the floor: a calendar
|
|
7628
|
+
// entry in either file, even one an ordinary reload would reject, names the key; unreadable files are unknown, never absent.
|
|
7629
|
+
if (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("calendar_schedules")) {
|
|
7630
|
+
let namesCalendar = config.schedules.some(entry => entry.cron !== undefined || entry.tz !== undefined);
|
|
7631
|
+
try {
|
|
7632
|
+
const texts = configSourceTexts(opts.cwd);
|
|
7633
|
+
namesCalendar ||= CONFIG_FILES.some(name => {
|
|
7634
|
+
const list = texts[name] === null ? undefined : (JSON.parse(texts[name]!) as Record<string, unknown>).schedules;
|
|
7635
|
+
return Array.isArray(list) && list.some(entry => !!entry && typeof entry === "object" && (Object.hasOwn(entry, "cron") || Object.hasOwn(entry, "tz")));
|
|
7636
|
+
});
|
|
7637
|
+
} catch { return "calendar_schedules compatibility is unknown; configuration could not be read"; }
|
|
7638
|
+
if (namesCalendar) return "target lacks calendar_schedules; the project's schedules use cron or tz, which that daemon cannot load";
|
|
7639
|
+
}
|
|
7174
7640
|
return undefined;
|
|
7175
7641
|
};
|
|
7176
7642
|
const checkTarget = msg.op === "prepare" || msg.op === "commit" || msg.op === "inspect" && msg.targetFeatures !== undefined;
|
|
@@ -7453,12 +7919,22 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7453
7919
|
c.authed = true;
|
|
7454
7920
|
c.role = msg.role;
|
|
7455
7921
|
if (c.role === "console") consoles.add(sock);
|
|
7922
|
+
else if (c.role === "tools" && sessionHello(msg)) {
|
|
7923
|
+
// agent-hub#1036: a connection that names a peer id but is not that peer's current session gets an id of its own instead of becoming the peer. It can send and read; it holds none of the
|
|
7924
|
+
// peer's board roles, is never a bus peer (no delivery, no push) and replaces nobody. Nothing is refused: a hello that worked before still works, as a session.
|
|
7925
|
+
const named = String(msg.peer ?? "");
|
|
7926
|
+
if (!PEER_ID.test(named) || named === USER || named === "hub") return sock.close(4403, "peer id is reserved or malformed");
|
|
7927
|
+
c.peer = mintSessionId(named, msg.session, id => sessionIds.has(id) || bus.peers.has(id), () => crypto.randomUUID().slice(0, 6));
|
|
7928
|
+
c.session = true; sessionIds.add(c.peer);
|
|
7929
|
+
if (typeof msg.launchId === "string" && msg.launchId.length <= 128) c.launchId = msg.launchId;
|
|
7930
|
+
}
|
|
7456
7931
|
else if (c.role === "tools") {
|
|
7457
7932
|
// Acts for a peer the hub manages itself (kimi, codex): may send and use the board as that peer, is never a delivery target.
|
|
7458
7933
|
c.peer = String(msg.peer ?? "");
|
|
7459
7934
|
if (!PEER_ID.test(c.peer) || c.peer === USER || c.peer === "hub") return sock.close(4403, "peer id is reserved or malformed");
|
|
7460
7935
|
if (externalRefusedRows.has(c.peer)) return sock.close(4403, "instance kind is unavailable in the startup catalog");
|
|
7461
7936
|
if (!membershipAllowed(c.peer)) return sock.close(4000, "peer is off roster; retry when a person adds it");
|
|
7937
|
+
if (typeof msg.launchId === "string" && msg.launchId.length <= 128) c.launchId = msg.launchId;
|
|
7462
7938
|
if (definitionOf(c.peer)?.start.daemonPort === "claude-implementer") {
|
|
7463
7939
|
const owner = bus.peers.get(c.peer), launch = owner?.ownedTools?.(msg.launchId), root = ownerWriteRoot(c.peer);
|
|
7464
7940
|
if (!owner || !launch || launch.signal.aborted || !root || root === opts.cwd || typeof msg.launchId !== "string") return sock.close(4403, "this process is not the current Claude child");
|
|
@@ -7525,7 +8001,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7525
8001
|
if (important) ws.notifyQueued({ queued: bus.queued(c.peer!), important });
|
|
7526
8002
|
});
|
|
7527
8003
|
}
|
|
7528
|
-
|
|
8004
|
+
// #604 PR B: one attach event per admitted peer or tools hello, with the same fields an envelope's `origin` carries. A refused hello returned above and writes none.
|
|
8005
|
+
if (c.role === "peer" || c.role === "tools") event(attachEvent({ role: c.role, peer: c.peer, launchId: c.launchId, ...(c.session ? { session: true } : {}) }));
|
|
8006
|
+
return void reply({ t: "welcome", projectId, instanceId, cwd: opts.cwd, protocol: PROTOCOL, features: DAEMON_FEATURES, ...(c.session ? { session: c.peer } : {}) });
|
|
7529
8007
|
}
|
|
7530
8008
|
const committedSourceProof = c.role === "tools" && msg.t === "recovery_authority" && msg.op === "verify" && recoveryCommitted
|
|
7531
8009
|
&& msg.binding?.operationId === recoveryOperationId && msg.binding?.sourceInstanceId === instanceId;
|
|
@@ -7533,6 +8011,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7533
8011
|
if (c.role !== "console" && c.peer && !membershipAllowed(c.peer)) return void reply({ ok: false, error: `${c.peer} is off roster` });
|
|
7534
8012
|
if (c.externalOwner && !currentExternalTools(c)) return void reply({ ok: false, error: "external instance launch or execution root changed" });
|
|
7535
8013
|
if (c.toolsOwner && !currentImplementerTools(c)) return void reply({ ok: false, error: "Claude child generation or execution root changed" });
|
|
8014
|
+
if (c.session) {
|
|
8015
|
+
// agent-hub#1036: a distinct session sends and reads, and nothing else. The gate runs before any case, so a new control message is refused to a session unless it is added to the list.
|
|
8016
|
+
const refusal = sessionGate(c.peer!, msg);
|
|
8017
|
+
if (refusal) return void reply({ t: msg.t, ok: false, error: refusal });
|
|
8018
|
+
}
|
|
7536
8019
|
switch (msg.t) {
|
|
7537
8020
|
case "request_cancel": {
|
|
7538
8021
|
if (!Number.isSafeInteger(msg.request) || msg.request < 1) return void reply({ t: "request_cancel", ok: false, error: "invalid request id" });
|
|
@@ -7905,6 +8388,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7905
8388
|
}
|
|
7906
8389
|
// Built first: limits count the audience it really has (a reply goes to the parent's sender).
|
|
7907
8390
|
let env = newEnvelope(c.peer ?? USER, body, { priority, ...(to ? { to } : {}), ...(inReplyTo ? { inReplyTo } : {}) });
|
|
8391
|
+
if (c.session) env = { ...env, session: true }; // agent-hub#1036: sent by a distinct session; `from` is its minted id, and the header a recipient reads shows it as is
|
|
8392
|
+
rememberOrigin(env.id, originOf({ role: c.role, peer: c.peer, launchId: c.launchId, ...(c.session ? { session: true } : {}) })); // the connection that sent it, for events.jsonl (never a path or text)
|
|
7908
8393
|
try { bus.checkRemotePublication(env); }
|
|
7909
8394
|
catch (error) { return void reply({ t: "sent", ok: false, error: error instanceof Error && /^(?:room|remote) /.test(error.message) ? sanitize(error.message).slice(0, 8192) : "message admission unavailable" }); }
|
|
7910
8395
|
const offRoster = bus.offRosterRecipients(env);
|
|
@@ -8029,7 +8514,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
8029
8514
|
const mode = permissionMode(peer);
|
|
8030
8515
|
if (mode !== "ask" && mode !== "ask-when-needed") return void reply({ t: "facts", ok: false });
|
|
8031
8516
|
const blocked = scopedWriteBlocked(phase, mode, msg.tool, () => projectFileTool(msg.tool, msg.input, root, config.protect_instruction_files, config.data?.readonly));
|
|
8032
|
-
const actionClasses = phase === "pre" && typeof msg.tool === "string" ? claudeActionClasses(actionBudgets, msg.tool, msg.input, root) : [];
|
|
8517
|
+
const actionClasses = phase === "pre" && typeof msg.tool === "string" ? claudeActionClasses(actionBudgets, msg.tool, msg.input, root, floorFacts(peer)) : [];
|
|
8033
8518
|
return void reply({ t: "facts", ok: true, ...(phase === "pre" ? { permission: mode === "ask-when-needed" && blocked ? "ask" : mode, projectRoot: root, protectInstructionFiles: config.protect_instruction_files, ...(actionClasses.length ? { actionClasses } : {}) } : {}) });
|
|
8034
8519
|
}
|
|
8035
8520
|
let nativeClaude = false, nativeTranscript: string | undefined;
|
|
@@ -8059,7 +8544,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
8059
8544
|
if (permission !== undefined && nativePeer) claudeLaunchProof.bindGeneration(peer, nativePeer.sessionGeneration);
|
|
8060
8545
|
const protectedWrite = scopedWriteBlocked(phase, permission, msg.tool, () => projectFileTool(msg.tool, msg.input, writeRoot ?? opts.cwd, config.protect_instruction_files, config.data?.readonly));
|
|
8061
8546
|
// 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`.
|
|
8062
|
-
const actionClasses = phase === "pre" && permission !== undefined && typeof msg.tool === "string" ? claudeActionClasses(actionBudgets, msg.tool, msg.input, writeRoot ?? opts.cwd) : [];
|
|
8547
|
+
const actionClasses = phase === "pre" && permission !== undefined && typeof msg.tool === "string" ? claudeActionClasses(actionBudgets, msg.tool, msg.input, writeRoot ?? opts.cwd, floorFacts(peer)) : [];
|
|
8063
8548
|
const plannerOnly = !!declarations[peer] && definitionOf(peer)?.transport === "claude-channel" && isPlannerOnly(config.roles[peer] ?? []);
|
|
8064
8549
|
// Older hooks do not know mutationGrants; the existing permission field must also withhold their mutation grant.
|
|
8065
8550
|
const plannerMutation = plannerOnly && !["Read", "LS", "Glob", "Grep"].includes(tool);
|
|
@@ -8424,6 +8909,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
8424
8909
|
if (recoveryActive() || stopping) return void reply({ ok: false, error: "recovery or shutdown is holding task role changes" });
|
|
8425
8910
|
try { const task = tasks.setRole(c.role === "console" ? USER : c.peer!, msg.id, msg.role); return void reply({ ok: true, text: `task #${task.id}: catalog role ${task.role ?? "none"}` }); }
|
|
8426
8911
|
catch (error) { return void reply({ ok: false, error: (error as Error).message }); }
|
|
8912
|
+
case "autonomy":
|
|
8913
|
+
if (c.role !== "console") return void reply({ ok: false, error: "autonomy is a person-only command; run ahub autonomy in a person's terminal" });
|
|
8914
|
+
void autonomyCommand(msg.action, msg.environment, msg.confirmed, msg.confirmation).then(reply, () => reply({ ok: false, error: "project autonomy could not be changed" }));
|
|
8915
|
+
return;
|
|
8427
8916
|
case "settings_get":
|
|
8428
8917
|
if (c.role !== "console") {
|
|
8429
8918
|
try { const current = publicReadCurrent(sock); current(); const view = publicSettingsView(); current(); return void reply({ ok: true, ...view }); }
|
|
@@ -8519,6 +9008,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
8519
9008
|
try { const current = publicReadCurrent(sock); current(); const release = publicReleaseShow(msg.args ?? {}); current(); return void reply({ t: "task", ok: true, text: JSON.stringify(release) }); }
|
|
8520
9009
|
catch { return void reply({ t: "task", ok: false, error: "public release read unavailable" }); }
|
|
8521
9010
|
}
|
|
9011
|
+
// #601: ahub study is the person's. A tools connection (any agent) may use the hub_study_* tools, never these op names, whatever Tasks would answer.
|
|
9012
|
+
if (String(msg.op).startsWith("study_") && c.role !== "console") return void reply({ t: "task", ok: false, error: "ahub study draft, revise, approve and close are console commands; agents use the hub_study_* tools" });
|
|
8522
9013
|
if (c.toolsOwner && !IMPLEMENTER_HUB_TOOL_NAMES.includes(String(msg.op))) return void reply({ t: "task", ok: false, error: "tool is not available to this Claude implementer child" });
|
|
8523
9014
|
if (recoveryActive() && recoveryPhase !== "preparing" && String(msg.op) !== "hub_checkpoint") return void reply({ t: "task", ok: false, error: "recovery is holding mutations" });
|
|
8524
9015
|
if (c.role === "tools" && c.peer && Object.hasOwn(CONDUCTOR_TASK_OPS, String(msg.op))) {
|
|
@@ -8690,6 +9181,32 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
8690
9181
|
// Let the acknowledgement flush before closing the control listener.
|
|
8691
9182
|
setTimeout(() => void stop().catch((error) => log(`shutdown incomplete: ${(error as Error).message}`)), 0);
|
|
8692
9183
|
return;
|
|
9184
|
+
// #604 (agent-hub#1030): another project's daemon hands us a task, or reports a state change of one we handed it. Console role only (the same token and the same machine user as every
|
|
9185
|
+
// control message): an agent connection never reaches these. The origin peer named in the request is a record, never an identity here, and nothing in it grants a role.
|
|
9186
|
+
case "task_handoff": {
|
|
9187
|
+
if (c.role !== "console") return void reply({ t: "task_handoff", ok: false, error: "a handoff comes from another project's hub, not from an agent connection" });
|
|
9188
|
+
if (recoveryActive()) return void reply({ t: "task_handoff", ok: false, error: "recovery is holding mutations; hand the task off again after it ends" });
|
|
9189
|
+
// Counted like a task op: a recovery commit waits for it (`recoveryReady` reads `taskOpsInFlight`), and the fence is asked again right before the board write, after the PII screen.
|
|
9190
|
+
taskOpsInFlight++;
|
|
9191
|
+
const recoveryFence = (): string | undefined => recoveryActive() || stopping ? "recovery or shutdown is holding mutations; hand the task off again after it ends" : undefined;
|
|
9192
|
+
void tasks.handoffIn(msg.origin ?? {}, msg.request, recoveryFence).then(
|
|
9193
|
+
result => reply({ t: "task_handoff", ok: true, ...result }),
|
|
9194
|
+
error => reply({ t: "task_handoff", ok: false, error: error instanceof HandoffRefusal ? error.message : "the handoff was refused" })).finally(() => { taskOpsInFlight--; });
|
|
9195
|
+
return;
|
|
9196
|
+
}
|
|
9197
|
+
case "task_handoff_event": {
|
|
9198
|
+
if (c.role !== "console") return void reply({ t: "task_handoff_event", ok: false, error: "a handoff event comes from another project's hub, not from an agent connection" });
|
|
9199
|
+
try { reply({ t: "task_handoff_event", ok: true, ...tasks.handoffEvent(msg.event) }); }
|
|
9200
|
+
catch (error) { reply({ t: "task_handoff_event", ok: false, error: error instanceof HandoffRefusal ? error.message : "the handoff event was refused" }); }
|
|
9201
|
+
return;
|
|
9202
|
+
}
|
|
9203
|
+
case "task_handoff_states": {
|
|
9204
|
+
// #604: the origin asks, after a restart, where the tasks it handed us stand now. Console role only, and only for tasks whose recorded origin is the asking project.
|
|
9205
|
+
if (c.role !== "console") return void reply({ t: "task_handoff_states", ok: false, error: "a handoff state request comes from another project's hub, not from an agent connection" });
|
|
9206
|
+
try { reply({ t: "task_handoff_states", ok: true, states: tasks.handoffStatesFor(msg.projectId, msg.tasks) }); }
|
|
9207
|
+
catch (error) { reply({ t: "task_handoff_states", ok: false, error: error instanceof HandoffRefusal ? error.message : "the handoff state request was refused" }); }
|
|
9208
|
+
return;
|
|
9209
|
+
}
|
|
8693
9210
|
default:
|
|
8694
9211
|
// A newer CLI talking to an older hub must get an answer, not wait forever.
|
|
8695
9212
|
if (msg.rid !== undefined) reply({ t: String(msg.t), ok: false, error: `this hub does not know "${msg.t}" (restart it: ahub kill && ahub up)` });
|
|
@@ -8708,6 +9225,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
8708
9225
|
stopping = true;
|
|
8709
9226
|
roomCommands.stop();
|
|
8710
9227
|
tasks.stopQueuePressureRechecks();
|
|
9228
|
+
const studiesStopped = tasks.stopStudies();
|
|
8711
9229
|
let implementerShellCleanupFailed = false;
|
|
8712
9230
|
const implementerShellStopped = implementerShellStart?.then(wrapper => wrapper.close()).catch(() => { implementerShellCleanupFailed = true; log("host shell cleanup failed during shutdown"); });
|
|
8713
9231
|
terminalLaunches.clear();
|
|
@@ -8730,11 +9248,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
8730
9248
|
for (const pending of checkpointWaits.values()) pending.done(undefined);
|
|
8731
9249
|
for (const p of permissions.values()) p.done(undefined);
|
|
8732
9250
|
try {
|
|
8733
|
-
// A
|
|
8734
|
-
// its own timeout) before the peers stop, the board closes and the store closes, so no
|
|
8735
|
-
if (schedulesPass || manualSchedulePasses.size) {
|
|
9251
|
+
// A schedule or study admission may be inside its PII screen: `stopping` is set, so its fence refuses the write when the screen returns. Wait for it (bounded, the screen has
|
|
9252
|
+
// its own timeout) before the peers stop, the board closes and the store closes, so no admission runs against a closed database.
|
|
9253
|
+
if (schedulesPass || manualSchedulePasses.size || tasks.studiesPending() !== 0) {
|
|
8736
9254
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
8737
|
-
await Promise.race([Promise.all([schedulesPass?.catch(() => undefined), ...manualSchedulePasses]), new Promise<void>(resolve => { timer = setTimeout(resolve, 5_000); timer.unref?.(); })]);
|
|
9255
|
+
await Promise.race([Promise.all([schedulesPass?.catch(() => undefined), ...manualSchedulePasses, studiesStopped]), new Promise<void>(resolve => { timer = setTimeout(resolve, 5_000); timer.unref?.(); })]);
|
|
8738
9256
|
clearTimeout(timer);
|
|
8739
9257
|
}
|
|
8740
9258
|
await Promise.all([terminalReadsStopped, codeFactsStopped, implementerShellStopped]);
|
|
@@ -8804,6 +9322,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
8804
9322
|
bus.onQueues(); // initial restored holds are reported only after the listener is bound; writes status.json too
|
|
8805
9323
|
log(`${RUN_START}${process.pid} control=127.0.0.1:${server.port} cwd=${opts.cwd}`);
|
|
8806
9324
|
ready = true;
|
|
9325
|
+
handoffCatchUp();
|
|
8807
9326
|
tasks.scheduleQueuePressureRecheck();
|
|
8808
9327
|
for (const name of opts.configWatch === false ? [] : CONFIG_SOURCE_FILES) {
|
|
8809
9328
|
const path = join(opts.cwd, ".agenthub", name);
|