@staix/agent-hub 0.12.44 → 0.12.45
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 +22 -0
- package/README.md +3 -3
- package/THIRD_PARTY_NOTICES.md +29 -0
- package/docs/adding-an-ai-tool.md +102 -14
- package/docs/agent-notes/README.md +9 -0
- package/docs/agent-notes/adapters.md +58 -44
- package/docs/agent-notes/budget.md +1 -1
- package/docs/agent-notes/daemon.md +9 -7
- package/docs/agent-notes/local-worker.md +4 -3
- package/docs/agent-notes/tasks.md +8 -5
- package/docs/agent-notes/tests.md +7 -7
- package/docs/operations.md +99 -7
- package/docs/quickstart.md +5 -1
- package/docs/sdlc.md +26 -8
- package/docs/security.md +6 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +114 -5
- package/docs/verification/2026-10-21-469-r1-dashboard-tokens.md +43 -0
- package/docs/verification/2026-10-21-469-r2-dashboard-shell.md +54 -0
- package/docs/verification/2026-10-21-469-r3-dashboard-board.md +51 -0
- package/docs/verification/2026-10-21-469-r4-dashboard-views.md +77 -0
- package/docs/verification/2026-10-21-469-r6-dashboard-motion-states.md +82 -0
- package/docs/verification/2026-10-22-0.12.45.md +60 -0
- package/docs/verification/assets/469-r1-board-1440-dark.png +0 -0
- package/docs/verification/assets/469-r1-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r1-board-390-dark.png +0 -0
- package/docs/verification/assets/469-r1-board-390-light.png +0 -0
- package/docs/verification/assets/469-r2-agents-1440-dark.png +0 -0
- package/docs/verification/assets/469-r2-agents-768-dark.png +0 -0
- package/docs/verification/assets/469-r2-board-1024-light.png +0 -0
- package/docs/verification/assets/469-r2-board-1440-dark.png +0 -0
- package/docs/verification/assets/469-r2-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r2-board-390-dark.png +0 -0
- package/docs/verification/assets/469-r2-board-390-light.png +0 -0
- package/docs/verification/assets/469-r2-board-768-light.png +0 -0
- package/docs/verification/assets/469-r3-board-1440-dark.png +0 -0
- package/docs/verification/assets/469-r3-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r3-board-390-light.png +0 -0
- package/docs/verification/assets/469-r3-detail-1440-dark.png +0 -0
- package/docs/verification/assets/469-r3-detail-1440-light.png +0 -0
- package/docs/verification/assets/469-r3-detail-390-dark.png +0 -0
- package/docs/verification/assets/469-r3-plan-1440-light.png +0 -0
- package/docs/verification/assets/469-r3-plan-390-light.png +0 -0
- package/docs/verification/assets/469-r4-agents-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-agents-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-agents-390-dark.png +0 -0
- package/docs/verification/assets/469-r4-memory-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-memory-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-memory-390-dark.png +0 -0
- package/docs/verification/assets/469-r4-message-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-office-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-office-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-settings-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-settings-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-stream-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-stream-390-light.png +0 -0
- package/docs/verification/assets/469-r6-agents-empty-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-agents-empty-390-light.png +0 -0
- package/docs/verification/assets/469-r6-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-board-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-board-empty-1440-dark.png +0 -0
- package/docs/verification/assets/469-r6-board-empty-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-disconnected-1440-dark.png +0 -0
- package/docs/verification/assets/469-r6-disconnected-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-disconnected-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-skeleton-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-skeleton-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-toasts-1440-dark.png +0 -0
- package/docs/verification/assets/469-r6-toasts-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-toasts-390-light.png +0 -0
- package/docs/verified.json +19 -16
- package/package.json +5 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +9818 -9605
- package/src/adapters/adapter-api.ts +2 -0
- package/src/adapters/agy/definition.ts +3 -1
- package/src/adapters/claude-channel.ts +14 -5
- package/src/adapters/codex-appserver.ts +1 -0
- package/src/adapters/conformance/acp.ts +114 -0
- package/src/adapters/conformance/codex.ts +191 -0
- package/src/adapters/conformance/enrollment.ts +43 -0
- package/src/adapters/conformance/factories.ts +17 -0
- package/src/adapters/conformance/fake-paths.ts +12 -0
- package/src/adapters/conformance/fakes/acp-server.ts +267 -0
- package/src/adapters/conformance/fakes/acp-watchdog-agent.ts +10 -0
- package/src/adapters/conformance/fakes/app-server.ts +119 -0
- package/src/adapters/conformance/fakes/codex-bin.ts +15 -0
- package/src/adapters/conformance/fakes/jcode-agent.ts +91 -0
- package/src/adapters/conformance/fakes/model-server.ts +39 -0
- package/src/adapters/conformance/fakes/pi-rpc.ts +32 -0
- package/src/adapters/conformance/index.ts +11 -0
- package/src/adapters/conformance/kit.ts +50 -0
- package/src/adapters/conformance/local-pi.ts +100 -0
- package/src/adapters/conformance/owned-acp.ts +44 -0
- package/src/adapters/definitions/builtin-metadata.ts +23 -6
- package/src/adapters/definitions/native-cli-entry-kimi.ts +7 -1
- package/src/adapters/definitions/native-cli-entry.ts +7 -19
- package/src/adapters/definitions/native-cli-preview.ts +2 -11
- package/src/adapters/definitions/qwen.ts +4 -1
- package/src/adapters/external-metadata.ts +48 -0
- package/src/adapters/external-module-scan.ts +126 -0
- package/src/adapters/external-modules.ts +240 -0
- package/src/adapters/pi.ts +2 -0
- package/src/adapters/public.ts +7 -0
- package/src/adapters/registry-types.ts +10 -0
- package/src/adapters/registry.ts +51 -15
- package/src/cli/help.ts +21 -16
- package/src/cli/identity.ts +1 -1
- package/src/cli/main.ts +23 -5
- package/src/cli/upgrade-labels.ts +1 -1
- package/src/cli/upgrade-presentation.ts +1 -1
- package/src/cli/upgrade-restore-state.ts +3 -2
- package/src/cli/upgrade-runtime.ts +4 -1
- package/src/hub/board.ts +30 -4
- package/src/hub/config-apply.ts +18 -10
- package/src/hub/config-reload.ts +8 -5
- package/src/hub/config-trust.ts +1 -1
- package/src/hub/control-client.ts +4 -1
- package/src/hub/daemon.ts +95 -39
- package/src/hub/facts.ts +76 -13
- package/src/hub/hub-tools.ts +21 -5
- package/src/hub/instance-config.ts +17 -5
- package/src/hub/instance-execution.ts +20 -8
- package/src/hub/instances.ts +2 -1
- package/src/hub/peers.ts +9 -0
- package/src/hub/plan-conformance.ts +83 -0
- package/src/hub/review-findings.ts +104 -0
- package/src/hub/roster.ts +31 -5
- package/src/hub/sdlc-queue.ts +19 -4
- package/src/hub/sdlc-release.ts +16 -1
- package/src/hub/sdlc.ts +27 -12
- package/src/hub/settings.ts +5 -1
- package/src/hub/snapshots.ts +8 -6
- package/src/hub/tasks.ts +275 -45
- package/src/hub/ui.ts +2 -2
- package/src/ui/index.html +559 -78
- package/src/ui/office-model.ts +3 -1
- package/src/ui/peer-palette.ts +44 -0
package/src/hub/daemon.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { loadExternalAdapterDefinitions, readAdapterModuleDeclarations, type AdapterModuleDeclaration } from "../adapters/external-modules.ts";
|
|
1
2
|
import { WsPeer, isWsPeer } from "../adapters/channel-peer.ts";
|
|
2
3
|
import { ownerToolEffect, canonicalWriteUnknown } from "./no-writes.ts";
|
|
3
4
|
import { NativeHookLaunches } from "./native-hook-launch.ts";
|
|
@@ -26,7 +27,7 @@ import { JcodeOptionMismatch, isJcodePeer, type JcodePeer } from "../adapters/jc
|
|
|
26
27
|
import { jcodeMcpReady } from "../cli/jcode-mcp.ts";
|
|
27
28
|
import { loadSdlc, readSpecEdit, sdlcGh } from "./sdlc.ts";
|
|
28
29
|
import { claudePeer as defaultChannelPeer, claudeStateFile, ownTerminalRow, pathEvidence, transcriptPersisted } from "./claude-state.ts";
|
|
29
|
-
import { peerRosterConfig, rosterAllows, type PeerRoster } from "./roster.ts";
|
|
30
|
+
import { peerRosterConfig, rosterAllows, staleInstanceKey, type PeerRoster } from "./roster.ts";
|
|
30
31
|
import { KNOWN_PEERS, OPTIONAL_PEERS, PLANNER_PEERS, SUPPORTED_PEERS, START_MODE_PEERS, isSupportedPeer, STARTABLE_PEERS, isStartablePeer } from "../adapters/registry.ts";
|
|
31
32
|
import { RecoveryDeferredHubError } from "./deferred-hub.ts";
|
|
32
33
|
import { databaseBusySite, isDatabaseBusy, RecoveryDatabaseBusy } from "./recovery-error.ts";
|
|
@@ -93,7 +94,7 @@ import { AssignmentUndeliveredError, ESCALATE_AFTER, Tasks } from "./tasks.ts";
|
|
|
93
94
|
import { DEFAULT_INFERENCE, DIGEST, Inference, screenPii, type InferenceConfig } from "./inference.ts";
|
|
94
95
|
import { ask, ASK_NOTE_TITLE, RUN_START } from "./ask.ts";
|
|
95
96
|
import { currentRouting, loadRouting, detectSignals, OVERLAY_FILE, overlayToml, parseRouting, routingText, type Routing } from "./routing.ts";
|
|
96
|
-
import { projectSettings, checkSettingValue, ignoredLine, pendingUndo, pendingUndoIdentity, readOverlay, restoredValue, routingCandidate, storedLocally, settingDef as instanceSettingDef, settingRefusal, settingRows, settingText, undoSetting, valueAt, writeConfigSetting, writeRoutingSetting, type SettingAuthority, type SettingDef, type SettingRow, type SettingValue, type UndoIdentity } from "./settings.ts";
|
|
97
|
+
import { projectSettings, checkSettingValue, ignoredLine, pendingUndo, pendingUndoIdentity, readOverlay, restoredValue, routingCandidate, storedLocally, sharedValue, settingDef as instanceSettingDef, settingRefusal, settingRows, settingText, undoSetting, valueAt, writeConfigSetting, writeRoutingSetting, type SettingAuthority, type SettingDef, type SettingRow, type SettingValue, type UndoIdentity } from "./settings.ts";
|
|
97
98
|
import { Bus } from "./bus.ts";
|
|
98
99
|
import { attribute, deliveryTask, type TaskAttribution } from "./attribution.ts";
|
|
99
100
|
import { DeliveryJournal } from "./delivery-journal.ts";
|
|
@@ -112,7 +113,7 @@ import { Facts, FACTS_PREFIX, type FactScope } from "./facts.ts";
|
|
|
112
113
|
import { crashPlan, lossNotice, readSessions, removeSessions, writeSessions, type SessionsFile } from "./crash.ts";
|
|
113
114
|
import type { JournalDelivery } from "./delivery-journal.ts";
|
|
114
115
|
import { DEFAULT_LIMITS, Limiter, PROJECT_LIMITS, type LimitsConfig } from "./limits.ts";
|
|
115
|
-
import { soleWorktree, branchHeads, changedPaths, knownChangedPaths, hasTree, headState, headMoveCursor, incomingHeadPaths, repoOf, snapshot, SnapshotBudgetExceeded, Turns, type SnapshotBudget, type HeadMoveCursor, type Repo, type TurnRecord } from "./snapshots.ts";
|
|
116
|
+
import { soleWorktree, branchHeads, changedPaths, knownChangedPaths, hasTree, headState, headMoveCursor, incomingHeadPaths, repoOf, snapshot, SnapshotBudgetExceeded, budgetNow, Turns, type SnapshotBudget, type HeadMoveCursor, type Repo, type TurnRecord } from "./snapshots.ts";
|
|
116
117
|
import { archiveRestartSnapshot, readRecoveryWaivers, readRestartSnapshot, removeRestartSnapshot, restartPath, writeRestartSnapshot, type RecoveryPhase, type RestartPeerSnapshot, type RestartSnapshot } from "./restart.ts";
|
|
117
118
|
import { consumeRecoveryContinuity, readRecoveryContinuity, retireRecoveryContinuity } from "./recovery-continuity.ts";
|
|
118
119
|
import { shellQuote, publishTerminalLaunch, terminalLaunchCandidate } from "../cli/terminal-recovery.ts";
|
|
@@ -134,6 +135,7 @@ import { nativeRecall } from "../memory/recall.ts";
|
|
|
134
135
|
import { promotionScreen, withoutPii, mentionsPrivatePath } from "./memory-policy.ts";
|
|
135
136
|
|
|
136
137
|
export interface HubConfig {
|
|
138
|
+
adapter_modules?: readonly AdapterModuleDeclaration[];
|
|
137
139
|
adapters?: Readonly<Record<string, import("../adapters/configured-acp.ts").ConfiguredAcpAdapterConfig>>;
|
|
138
140
|
/** Person-owned declarations, read only from an untracked machine-local file. Bound for this daemon lifetime. */
|
|
139
141
|
instances?: InstancesConfig;
|
|
@@ -322,12 +324,13 @@ function retiredRoutingSettings(cwd: string): readonly string[] {
|
|
|
322
324
|
catch { return []; } // Routing admission and reload report parse errors using their own last-good state.
|
|
323
325
|
}
|
|
324
326
|
|
|
325
|
-
export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routingRetired: readonly string[] = retiredRoutingSettings(cwd)): HubConfig {
|
|
327
|
+
export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routingRetired: readonly string[] = retiredRoutingSettings(cwd), externalDefinitions?: readonly import("../adapters/registry-types.ts").AdapterDefinition[]): HubConfig {
|
|
326
328
|
const captured = layers ?? readInstanceConfigLayers(cwd);
|
|
327
|
-
const instanceConfig = readInstanceConfig(cwd, captured);
|
|
329
|
+
const instanceConfig = readInstanceConfig(cwd, captured, externalDefinitions);
|
|
328
330
|
const instances = instanceConfig.instances;
|
|
329
331
|
const adapterCatalog = instanceConfig.catalog;
|
|
330
332
|
const ignored: string[] = [...instanceConfig.ignored];
|
|
333
|
+
const inertModuleRoster = !!instanceConfig.adapter_modules?.length;
|
|
331
334
|
const appliedPermissionSources: NonNullable<HubConfig["permission_default_sources"]> = {};
|
|
332
335
|
const files = CONFIG_FILES.flatMap((name) => {
|
|
333
336
|
let file: Record<string, any>; // parsed JSON, checked field by field below
|
|
@@ -338,9 +341,10 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
|
|
|
338
341
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
339
342
|
throw error;
|
|
340
343
|
}
|
|
344
|
+
delete file.adapter_modules; // Code provenance is captured separately and never evaluated by configuration reads.
|
|
341
345
|
delete file.adapters; // Adapter provenance is compiled by the same project-local loader.
|
|
342
346
|
delete file.instances; // Declaration authority was checked against its exact captured origin above.
|
|
343
|
-
peerRosterConfig(file.roster, instances, adapterCatalog);
|
|
347
|
+
peerRosterConfig(file.roster, instances, adapterCatalog, inertModuleRoster);
|
|
344
348
|
catalogAssignments(file.catalog_roles);
|
|
345
349
|
profileName(file.profile);
|
|
346
350
|
resolveProfile(file.profile, file.profile_overrides);
|
|
@@ -412,6 +416,7 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
|
|
|
412
416
|
...DEFAULT_CONFIG,
|
|
413
417
|
...file,
|
|
414
418
|
...(instanceConfig.adapters ? { adapters: instanceConfig.adapters } : {}),
|
|
419
|
+
...(instanceConfig.adapter_modules ? { adapter_modules: instanceConfig.adapter_modules } : {}),
|
|
415
420
|
...(instances ? { instances } : {}),
|
|
416
421
|
...(file.text_projection !== undefined ? { text_projection: projectionConfig(file.text_projection) } : {}),
|
|
417
422
|
...(file.pii !== undefined ? { pii: { paths: policyPaths(file.pii.paths, "pii.paths") } } : {}),
|
|
@@ -421,7 +426,7 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
|
|
|
421
426
|
permission_default_sources: appliedPermissionSources,
|
|
422
427
|
memory: { ...DEFAULT_CONFIG.memory, native: true, ...file.memory, ...(file.memory?.short_ttl_days !== undefined ? { short_ttl_days: ttlDays(file.memory.short_ttl_days) } : {}),
|
|
423
428
|
...(file.memory?.sync_interval_min !== undefined ? { sync_interval_min: syncMinutes(file.memory.sync_interval_min) } : {}) }, // the native store is on with any project config (#258)
|
|
424
|
-
roster: peerRosterConfig(file.roster, instances, adapterCatalog),
|
|
429
|
+
roster: peerRosterConfig(file.roster, instances, adapterCatalog, inertModuleRoster),
|
|
425
430
|
roles: { ...configuredRoles, ...conductorPolicy.roles, ...extraRoles },
|
|
426
431
|
planner: conductorPolicy.planner,
|
|
427
432
|
conductor: conductorPolicy.conductor,
|
|
@@ -476,6 +481,8 @@ export interface DaemonOptions {
|
|
|
476
481
|
/** Host-only snapshot/probe seams; never read from project config or tool arguments. */
|
|
477
482
|
snapshot?: typeof snapshot;
|
|
478
483
|
snapshotBurstMs?: number;
|
|
484
|
+
/** Host-only (#661): the clock the shared snapshot Git budget reads (default performance.now). A test holds it fixed so a slow runner cannot spend the 250 ms; production never sets it and no config key or tool reaches it. */
|
|
485
|
+
snapshotClock?: () => number;
|
|
479
486
|
checkoutBranch?: typeof mainCheckoutBranch;
|
|
480
487
|
/** Host-only admission transport; never selected by project config or tools. */
|
|
481
488
|
releaseHostAdmission?: (cwd: string) => Promise<ReleaseHostAdmission>;
|
|
@@ -531,12 +538,21 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
531
538
|
const startupCleanup: (() => void)[] = [];
|
|
532
539
|
let ready = false;
|
|
533
540
|
try {
|
|
534
|
-
|
|
541
|
+
let startupLayers: ReturnType<typeof readInstanceConfigLayers>;
|
|
542
|
+
try { startupLayers = readInstanceConfigLayers(opts.cwd); }
|
|
543
|
+
catch (error) { if (!opts.config) throw error; startupLayers = []; } // Explicit embeddings may ignore unreadable file config; no code is loaded from it.
|
|
544
|
+
const externalAdapterRefusals: import("../adapters/external-modules.ts").ExternalAdapterRefusalRecord[] = [];
|
|
545
|
+
const externalDefinitions = await loadExternalAdapterDefinitions(opts.cwd, startupLayers, refusal => externalAdapterRefusals.push(refusal));
|
|
546
|
+
const externalAdapters = externalDefinitions.map(({ kind, displayName }) => ({ kind, displayName }));
|
|
547
|
+
const externalIds = new Set(externalAdapters.map(item => item.kind));
|
|
548
|
+
const moduleDeclarations = readAdapterModuleDeclarations(opts.cwd, startupLayers).modules;
|
|
549
|
+
const loadedConfig = opts.config ? { ...opts.config, adapter_modules: moduleDeclarations.length ? moduleDeclarations : undefined }
|
|
550
|
+
: loadConfig(opts.cwd, startupLayers, undefined, externalDefinitions);
|
|
535
551
|
reviewerPoolConfig(loadedConfig.review, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS);
|
|
536
552
|
const lifecycleConfig = loadSdlc(opts.cwd, VERSION);
|
|
537
553
|
// Preserve embedding-owned policy references, but never publish reloads into shared defaults.
|
|
538
554
|
const ownedConfig = Object.fromEntries(Object.entries(loadedConfig).map(([key, value]) => [key, !opts.config || value === (DEFAULT_CONFIG as unknown as Record<string, unknown>)[key] ? structuredClone(value) : value])) as unknown as HubConfig;
|
|
539
|
-
const adapterCatalog = createProjectAdapterCatalog(loadedConfig.adapters);
|
|
555
|
+
const adapterCatalog = createProjectAdapterCatalog(loadedConfig.adapters, externalDefinitions);
|
|
540
556
|
const configuredIds = adapterCatalog.definitions.filter(d => !builtinDefinitionOf(d.kind)).map(d => d.kind);
|
|
541
557
|
const declarations = validateInstances(loadedConfig.instances, adapterCatalog);
|
|
542
558
|
const extraIds = Object.keys(declarations);
|
|
@@ -562,7 +578,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
562
578
|
}
|
|
563
579
|
}
|
|
564
580
|
const config: HubConfig = { ...ownedConfig, ...(extraIds.length ? { instances: declarations } : {}),
|
|
565
|
-
roster: peerRosterConfig(loadedConfig.roster, declarations, adapterCatalog),
|
|
581
|
+
roster: peerRosterConfig(loadedConfig.roster, declarations, adapterCatalog, moduleDeclarations.length > 0),
|
|
566
582
|
permission_modes: { ...permissionModesFromInstances, ...permissionDefaults(loadedConfig.permission_modes, declarations, adapterCatalog) },
|
|
567
583
|
peers: { ...startModesFromInstances, ...peerStartConfig(loadedConfig.peers, declarations, adapterCatalog) },
|
|
568
584
|
...(extraIds.length ? { permission_default_sources: { ...instancePermissionSources, ...loadedConfig.permission_default_sources } } : {}),
|
|
@@ -585,7 +601,16 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
585
601
|
const reviewOnlyItem = (peer: string, env: Envelope): boolean => env.from === USER || env.from === HUB && (env.kind === "review" || (env.kind === "task" && env.to?.includes(peer) === true));
|
|
586
602
|
const readOnlyPeer = (peer: string): boolean => declarations[peer]?.readOnly === true;
|
|
587
603
|
const SETTINGS = projectSettings(declarations, adapterCatalog);
|
|
588
|
-
|
|
604
|
+
// #225: a roster key whose instance was undeclared has no row of its own. The only thing a person can do with it is delete it,
|
|
605
|
+
// so it gets a boolean roster row that checkSettingValue below only accepts as a removal.
|
|
606
|
+
const staleRosterKey = (key: unknown): string | undefined => {
|
|
607
|
+
const peer = typeof key === "string" && key.startsWith("roster.") ? key.slice("roster.".length) : undefined;
|
|
608
|
+
return peer !== undefined && !declarations[peer] && Object.hasOwn(config.roster, peer) && staleInstanceKey(peer, adapterCatalog) ? peer : undefined;
|
|
609
|
+
};
|
|
610
|
+
const settingDef = (key: unknown): SettingDef | string => {
|
|
611
|
+
const found = instanceSettingDef(key, declarations, adapterCatalog), stale = staleRosterKey(key);
|
|
612
|
+
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;
|
|
613
|
+
};
|
|
589
614
|
const accountOf = (peer: string) => definitionOf(peer)?.budget?.account ?? peer;
|
|
590
615
|
const accountMembers = (account: string) => SUPPORTED_PEERS.filter(peer => accountOf(peer) === account);
|
|
591
616
|
let rosterChanging: string | undefined;
|
|
@@ -593,7 +618,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
593
618
|
let kimiAdmissionId: string | undefined;
|
|
594
619
|
let kimiAdmissionExpiry: ReturnType<typeof setTimeout> | undefined;
|
|
595
620
|
const clearKimiAdmission = () => { clearTimeout(kimiAdmissionExpiry); kimiAdmissionExpiry = undefined; kimiSessionMode = undefined; kimiAdmissionId = undefined; };
|
|
596
|
-
const
|
|
621
|
+
const projectRosterAllows = (roster: PeerRoster, peer: string, piEnabled: boolean) => rosterAllows(roster, peer, piEnabled, declarations, adapterCatalog, moduleDeclarations.length > 0);
|
|
622
|
+
const onRoster = (peer: string) => projectRosterAllows(config.roster, peer, config.pi.enabled);
|
|
597
623
|
// pi.enabled historically gates starts, not retained queues or tools sockets. Only an explicit roster.pi changes those.
|
|
598
624
|
const membershipAllowed = (peer: string) => definitionOf(peer)?.enabledSwitchStartOnly && config.roster[peer] === undefined ? true : onRoster(peer);
|
|
599
625
|
conductorPeer(config.roles); // reject ambiguous leadership before opening a control listener
|
|
@@ -604,7 +630,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
604
630
|
// The state dir can vanish under a running hub (issue #56): a log line must never take a handler down with it.
|
|
605
631
|
const log = (line: string) => { try { appendFileSync(logFile, `${new Date().toISOString()} ${line}\n`); } catch { /* the state dir is gone; the watchdog is stopping the hub */ } };
|
|
606
632
|
let runningRouting = currentRouting(opts.cwd, log);
|
|
607
|
-
let acceptedSources = parseConfigSources(configSourceTexts(opts.cwd), runningRouting, opts.cwd);
|
|
633
|
+
let acceptedSources = parseConfigSources(configSourceTexts(opts.cwd), runningRouting, opts.cwd, externalAdapters);
|
|
608
634
|
const startupSources = structuredClone(acceptedSources.trustLayers);
|
|
609
635
|
const embeddedConfig = structuredClone(config);
|
|
610
636
|
let desiredConfig = structuredClone(config);
|
|
@@ -1193,8 +1219,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1193
1219
|
if (!peer || peer.state === "offline") return undefined;
|
|
1194
1220
|
let token = checkConnections.get(peer);
|
|
1195
1221
|
if (!token) { token = randomUUID(); checkConnections.set(peer, token); }
|
|
1196
|
-
const
|
|
1197
|
-
|
|
1222
|
+
const hasGeneration = "sessionGeneration" in peer;
|
|
1223
|
+
const native = hasGeneration ? peer.sessionGeneration : peer.recoveryMetadata?.().sessionId;
|
|
1224
|
+
if (hasGeneration && native === undefined) return undefined;
|
|
1198
1225
|
return `${instanceId}:${token}:${String(native ?? "")}`;
|
|
1199
1226
|
};
|
|
1200
1227
|
const currentCheck = (receipt: { task: number; owner: string; updated: number | string; generation: number | string; revision?: number }) => {
|
|
@@ -1672,8 +1699,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1672
1699
|
const owner = bus.peers.get(peer);
|
|
1673
1700
|
const state = bus.stateOf(peer);
|
|
1674
1701
|
const session = contextSession(peer);
|
|
1675
|
-
const
|
|
1676
|
-
|
|
1702
|
+
const generationFenced = !!owner && owner.checkpointUsesSessionGeneration !== false && "sessionGeneration" in owner;
|
|
1703
|
+
const generation = generationFenced ? owner.sessionGeneration : undefined;
|
|
1704
|
+
if (!owner || (generationFenced && generation === undefined) || (isWsPeer(owner) && owner.claiming) || (state !== "idle" && state !== "busy") || (reason === "context" && (!session || turns.get(peer)?.private || contextPii(peer)))) return Promise.resolve(undefined);
|
|
1677
1705
|
const openWork = board.list().some((t) => (["proposed", "in_progress", "changes_requested", "in_review"].includes(t.state) && t.owner === peer) || (t.state === "in_review" && t.reviewer === peer));
|
|
1678
1706
|
if (!openWork && !bus.queued(peer) && !bus.hasInFlight(peer) && state !== "busy") {
|
|
1679
1707
|
if (reason === "quota") log(`budget ${peer}: no open work, no checkpoint`);
|
|
@@ -1685,7 +1713,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1685
1713
|
: `Checkpoint request: your native context window is near the configured threshold. Finish the step you are on and call hub_checkpoint {summary, request_id: "${requestId}"} with what you were doing, what is half done and what a fresh session would need. This only saves a checkpoint; keep your tasks and session. The operator decides whether to continue or restart. Do not include private task text.`;
|
|
1686
1714
|
const ask = newEnvelope(HUB, body, { to: [peer], kind: "budget", priority: "important" });
|
|
1687
1715
|
return new Promise((resolve) => {
|
|
1688
|
-
const valid = () => !stopping && !recoveryActive() && bus.peers.get(peer) === owner && (!
|
|
1716
|
+
const valid = () => !stopping && !recoveryActive() && bus.peers.get(peer) === owner && (!generationFenced || owner.sessionGeneration === generation) && ["idle", "busy"].includes(bus.stateOf(peer)) && contextSession(peer) === session;
|
|
1689
1717
|
const done = (summary: string | undefined) => {
|
|
1690
1718
|
clearTimeout(timer);
|
|
1691
1719
|
checkpointWaits.delete(peer);
|
|
@@ -1815,7 +1843,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1815
1843
|
if (!snapshotBudget) {
|
|
1816
1844
|
const requested = opts.snapshotBurstMs ?? 250;
|
|
1817
1845
|
const ms = Number.isFinite(requested) ? Math.max(1, Math.min(250, requested)) : 250;
|
|
1818
|
-
|
|
1846
|
+
const now = opts.snapshotClock;
|
|
1847
|
+
snapshotBudget = { deadline: (now ? now() : performance.now()) + ms, ...(now ? { now } : {}) };
|
|
1819
1848
|
setImmediate(() => { snapshotBudget = undefined; snapshotBudgetNoticed = false; });
|
|
1820
1849
|
}
|
|
1821
1850
|
return snapshotBudget;
|
|
@@ -2309,7 +2338,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2309
2338
|
await tasks.specStageGate(by, a.id, a.receipt); // #481 S1: a live spec edit-time check before a build stage
|
|
2310
2339
|
return line(tasks.recordLifecycle(by, a.id, a.receipt));
|
|
2311
2340
|
case "hub_review":
|
|
2312
|
-
return line(await tasks.review(by, a.id, a.verdict, a.note, a.unmet, a.head, a.coverage));
|
|
2341
|
+
return line(await tasks.review(by, a.id, a.verdict, a.note, a.unmet, a.head, a.coverage, a.findings));
|
|
2313
2342
|
case "hub_remember":
|
|
2314
2343
|
return tasks.remember(by, a);
|
|
2315
2344
|
case "hub_checkpoint": {
|
|
@@ -2684,13 +2713,14 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2684
2713
|
};
|
|
2685
2714
|
const permissionDisplay = (id: string, peer: PeerAdapter | undefined): string => {
|
|
2686
2715
|
if (readOnlyPeer(id)) return "read-only";
|
|
2687
|
-
if (isAcpPeer(peer) && ["unknown", "unmanaged"].includes(peer.permissionModeState)) return peer.permissionModeState;
|
|
2688
|
-
if (starting.has(id) && (isAcpPeer(peer) || isCodexPeer(peer))) return peer.getPermissionMode();
|
|
2689
2716
|
if (definitionOf(id)?.transport === "claude-channel" && isWsPeer(peer) && peer.state !== "offline") {
|
|
2690
2717
|
const launch = claudePermissionLaunch(id);
|
|
2691
2718
|
if (!launch || claudePermissionGenerations.get(id) !== peer.sessionGeneration) return "unverified";
|
|
2692
2719
|
}
|
|
2693
|
-
|
|
2720
|
+
const native = peer?.permissionModeState;
|
|
2721
|
+
// An offline ask clears the hub choice, not a stopped ACP runtime's retained native field.
|
|
2722
|
+
if (peer?.state === "offline" && !starting.has(id) && isPermissionMode(native)) return permissionMode(id);
|
|
2723
|
+
return native ?? permissionMode(id);
|
|
2694
2724
|
};
|
|
2695
2725
|
const permissionStatus = (id: string, peer: PeerAdapter | undefined): Record<string, unknown> => {
|
|
2696
2726
|
const note = definitionOf(id)?.permission.unmanagedStatusNote;
|
|
@@ -2745,6 +2775,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2745
2775
|
protocol: PROTOCOL,
|
|
2746
2776
|
features: DAEMON_FEATURES,
|
|
2747
2777
|
configuredAdapters: configuredIds,
|
|
2778
|
+
...(externalAdapters.length ? { externalAdapters } : {}),
|
|
2779
|
+
...(externalAdapterRefusals.length ? { externalAdapterRefusals } : {}),
|
|
2748
2780
|
startablePeers: SUPPORTED_PEERS.filter(isStartablePeer),
|
|
2749
2781
|
readOnlyPeers: extraIds.filter(readOnlyPeer),
|
|
2750
2782
|
// #450 I3: the instance ids this daemon bound at startup; a launcher selects only one of these.
|
|
@@ -3156,7 +3188,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3156
3188
|
if (!end.head) throw new Error("end HEAD evidence is unavailable");
|
|
3157
3189
|
const dirtyPaths = knownChangedPaths(turnRepo, end.head, end.tree, end.budget);
|
|
3158
3190
|
if (!dirtyPaths) throw new Error("end worktree diff evidence is unavailable");
|
|
3159
|
-
const checkBudget = () => { if (end.budget &&
|
|
3191
|
+
const checkBudget = () => { if (end.budget && budgetNow(end.budget) >= end.budget.deadline) throw new SnapshotBudgetExceeded(); };
|
|
3160
3192
|
const dirty = new Set(dirtyPaths), dirtyScope = new Set(dirtyPaths), incoming = new Set(paths);
|
|
3161
3193
|
for (const path of dirtyPaths) {
|
|
3162
3194
|
checkBudget();
|
|
@@ -3740,9 +3772,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3740
3772
|
onActivity: item => activityRings[peer]?.append(item),
|
|
3741
3773
|
isPrivateTurn: () => !!turns.get(peer)?.private || contextPii(peer),
|
|
3742
3774
|
kind: adapter.kind, cmd: [launch.cmd, ...launch.args], permissionMode: permission === "read-only" ? "ask" : permission,
|
|
3775
|
+
...(externalIds.has(adapter.kind) ? { requireOwnedGroup: true, requireLoadSessionEcho: true, nativePolicy: adapter.permission.genericAcp ?? {} } : {}),
|
|
3743
3776
|
...(args.model ? { launchModel: args.model } : {}), ...(args.sessionId ? { resumeSessionId: args.sessionId } : {}),
|
|
3744
3777
|
cwd: executionCwd, watchdogMs: config.watchdog_ms, onPermission,
|
|
3745
|
-
autoApprove: title => !stopping && HUB_TOOL_TITLES.has(title),
|
|
3778
|
+
autoApprove: title => !externalIds.has(adapter.kind) && !stopping && HUB_TOOL_TITLES.has(title),
|
|
3746
3779
|
log: () => log(`${peer}: ACP runtime diagnostic: details withheld`),
|
|
3747
3780
|
onUsageDiagnostic: (observation, sessionId) => {
|
|
3748
3781
|
if (observation.source !== "usage_update" || observation.shape !== "context-used") return;
|
|
@@ -3755,7 +3788,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3755
3788
|
mcpServers,
|
|
3756
3789
|
preamble: sessionRoleContract(peer),
|
|
3757
3790
|
} } });
|
|
3758
|
-
if (!isAcpPeer(owner)) throw new Error("ACP factory did not return its native transport");
|
|
3791
|
+
if (!isAcpPeer(owner) || owner.id !== peer) throw new Error("ACP factory did not return its bound native transport");
|
|
3759
3792
|
recoveryTaskPreface(peer); await ensurePreface(peer);
|
|
3760
3793
|
try { await owner.start(); await reconcileNativeStart(peer, owner); }
|
|
3761
3794
|
catch (error) {
|
|
@@ -4398,12 +4431,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4398
4431
|
function roleHolders(candidate: HubConfig): { planners: string[]; conductors: string[] } {
|
|
4399
4432
|
// A valid transfer may briefly have two conductors while its leaves are planned. Validate the final map later.
|
|
4400
4433
|
const policy = Object.fromEntries(Object.entries(candidate.roles).map(([peer, roles]) => [peer, roles.filter(role => role !== "conductor")]));
|
|
4401
|
-
const settled = resolveSettledRoles(policy, candidate.planner, peer =>
|
|
4434
|
+
const settled = resolveSettledRoles(policy, candidate.planner, peer => projectRosterAllows(candidate.roster, peer, candidate.pi.enabled));
|
|
4402
4435
|
return { planners: settled.planners, conductors: Object.keys(candidate.roles).filter(peer => candidate.roles[peer]!.includes("conductor")).sort() };
|
|
4403
4436
|
}
|
|
4404
4437
|
function prepareConfiguration(sources: ConfigSources, authority: SettingAuthority, scope?: string, confirmed: readonly string[] = []) {
|
|
4405
|
-
const fileConfig = loadConfig(opts.cwd, sources.layers, sources.routing.retired ?? []);
|
|
4406
|
-
validateEffectiveConfig(fileConfig);
|
|
4438
|
+
const fileConfig = loadConfig(opts.cwd, sources.layers, sources.routing.retired ?? [], externalDefinitions);
|
|
4439
|
+
validateEffectiveConfig(fileConfig, adapterCatalog);
|
|
4407
4440
|
const candidate = structuredClone(desiredConfig), routing = structuredClone(desiredRouting);
|
|
4408
4441
|
const accepted: string[] = [], rejected: { key: string; reason: string }[] = [], guarded: string[] = [];
|
|
4409
4442
|
const previews: { key: string; before: string; after: string; effect?: string }[] = [];
|
|
@@ -4500,7 +4533,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4500
4533
|
const running = structuredClone({ ...config, roles: policyRoles });
|
|
4501
4534
|
for (const change of changes) if (change.applies !== "restart") putConfig(running, change.key, change.after);
|
|
4502
4535
|
running.retired = fileConfig.retired;
|
|
4503
|
-
validateEffectiveConfig(running);
|
|
4536
|
+
validateEffectiveConfig(running, adapterCatalog);
|
|
4504
4537
|
conductorPeer(running.roles);
|
|
4505
4538
|
for (const peer of Object.keys(running.catalog_roles ?? {})) selectedRoles(peer, running.roles, { ...resolvedProfile.catalog_roles, ...catalogAssignments(running.catalog_roles) }, catalog);
|
|
4506
4539
|
const problem = running.mlx.enabled === false ? mlxPinProblem(routing, sources.texts["routing.toml"] !== null) : undefined;
|
|
@@ -4530,7 +4563,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4530
4563
|
const texts = configSourceTexts(opts.cwd), next = configSourceTexts(scratch);
|
|
4531
4564
|
texts["config.json"] = next["config.json"];
|
|
4532
4565
|
texts["config.local.json"] = next["config.local.json"];
|
|
4533
|
-
const prepared = prepareConfiguration(parseConfigSources(texts, undefined, opts.cwd), authority, scope);
|
|
4566
|
+
const prepared = prepareConfiguration(parseConfigSources(texts, undefined, opts.cwd, externalAdapters), authority, scope);
|
|
4534
4567
|
const refusal = prepared.rejected.find(item => item.key === scope);
|
|
4535
4568
|
if (refusal) throw new Error(`${refusal.key}: ${refusal.reason}`);
|
|
4536
4569
|
return prepared.fileConfig;
|
|
@@ -4538,7 +4571,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4538
4571
|
function routingWriteCheck(authority: SettingAuthority, overlay: import("./routing.ts").RoutingOverlay | undefined, scope?: string): void {
|
|
4539
4572
|
const texts = configSourceTexts(opts.cwd);
|
|
4540
4573
|
texts["routing.local.toml"] = overlay === undefined ? null : overlayToml(overlay);
|
|
4541
|
-
const prepared = prepareConfiguration(parseConfigSources(texts, undefined, opts.cwd), authority, scope);
|
|
4574
|
+
const prepared = prepareConfiguration(parseConfigSources(texts, undefined, opts.cwd, externalAdapters), authority, scope);
|
|
4542
4575
|
const refusal = prepared.rejected.find(item => item.key === scope);
|
|
4543
4576
|
if (refusal) throw new Error(`${refusal.key}: ${refusal.reason}`);
|
|
4544
4577
|
}
|
|
@@ -4555,7 +4588,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4555
4588
|
const texts = configSourceTexts(opts.cwd);
|
|
4556
4589
|
fingerprint = configFingerprint(texts);
|
|
4557
4590
|
if (confirm !== undefined && (typeof confirm !== "string" || authority !== "terminal" || scope || !reloadConfirmation || reloadConfirmation.token !== confirm || reloadConfirmation.fingerprint !== fingerprint || reloadConfirmation.revision !== configRevision)) return { ok: false, error: "configuration confirmation is stale or changed; run ahub settings reload again" };
|
|
4558
|
-
const sources = parseConfigSources(texts, undefined, opts.cwd);
|
|
4591
|
+
const sources = parseConfigSources(texts, undefined, opts.cwd, externalAdapters);
|
|
4559
4592
|
const prepared = prepareConfiguration(sources, authority, scope, confirm === undefined ? [] : reloadConfirmation!.keys);
|
|
4560
4593
|
if (confirm !== undefined && prepared.rejected.some(item => reloadConfirmation!.keys.includes(item.key))) return { ok: false, error: "configuration trust changed; run ahub settings reload again" };
|
|
4561
4594
|
if (scope && prepared.rejected.some(item => item.key === scope)) return { ok: false, error: prepared.rejected.find(item => item.key === scope)!.reason };
|
|
@@ -4622,7 +4655,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4622
4655
|
const stamp = CONFIG_FILES.map((name) => { try { const s = statSync(join(opts.cwd, ".agenthub", name)); return `${s.mtimeMs}:${s.size}`; } catch { return "-"; } }).join("|");
|
|
4623
4656
|
if (storedConfigCache?.stamp !== stamp) {
|
|
4624
4657
|
let loaded: HubConfig | undefined;
|
|
4625
|
-
try { loaded = loadConfig(opts.cwd); } catch { /* the rows say that the files do not load */ }
|
|
4658
|
+
try { loaded = loadConfig(opts.cwd, undefined, undefined, externalDefinitions); } catch { /* the rows say that the files do not load */ }
|
|
4626
4659
|
storedConfigCache = { stamp, config: loaded };
|
|
4627
4660
|
}
|
|
4628
4661
|
return storedConfigCache.config;
|
|
@@ -4645,7 +4678,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4645
4678
|
const rows = settingRows({
|
|
4646
4679
|
instances: declarations, adapterCatalog, cwd: opts.cwd, routing: activeRouting(), ...(routingProblem ? { routingProblem } : {}), ...(stored?.ignored ? { ignored: stored.ignored } : {}),
|
|
4647
4680
|
running: (path) => path[0] === "roster" ? onRoster(path[1]!) : path[0] === "tasks" && path[1] === "wip_limits" ? tasks.wipLimit(path[2]!) : valueAt(config, path),
|
|
4648
|
-
next: stored ? (path) => path[0] === "roster" ?
|
|
4681
|
+
next: stored ? (path) => path[0] === "roster" ? projectRosterAllows(stored.roster, path[1]!, stored.pi.enabled) : path[0] === "tasks" && path[1] === "wip_limits" ? (Object.hasOwn(stored.tasks.wip_limits ?? {}, path[2]!) ? stored.tasks.wip_limits![path[2]!] : 1) : valueAt(stored, path) : undefined,
|
|
4649
4682
|
roles: roleState,
|
|
4650
4683
|
mode: (peer) => {
|
|
4651
4684
|
const owner = bus.peers.get(peer), attached = !!owner && owner.state !== "offline";
|
|
@@ -4698,10 +4731,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4698
4731
|
if (rosterChanging) return { ok: false, error: `roster change for ${rosterChanging} is in progress` };
|
|
4699
4732
|
const peer = def.path[1]!;
|
|
4700
4733
|
let saved = false;
|
|
4734
|
+
// #225: a stale key has no peer behind it. What changes is that the key is deleted, not whether the peer is on the roster.
|
|
4735
|
+
const stale = staleRosterKey(def.key) !== undefined, previousKey = config.roster[peer], sharedCopy = stale && sharedValue(opts.cwd, def) !== undefined;
|
|
4701
4736
|
const terminalNotice = retainedTerminalNotice(peer);
|
|
4702
4737
|
let handoff: Awaited<ReturnType<Tasks["reassignForRoster"]>> = [];
|
|
4703
4738
|
try {
|
|
4704
|
-
const next = rosterCandidate(def, value, undo), before = onRoster(peer), after =
|
|
4739
|
+
const next = rosterCandidate(def, value, undo), before = onRoster(peer), after = projectRosterAllows(next, peer, config.pi.enabled);
|
|
4705
4740
|
if (!after) {
|
|
4706
4741
|
const refusal = rosterRemovalRefusal(peer, now);
|
|
4707
4742
|
if (refusal) return { ok: false, error: refusal };
|
|
@@ -4715,7 +4750,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4715
4750
|
config.roster = next;
|
|
4716
4751
|
settlePlanner(); // #455: an automatic planner follows the roster
|
|
4717
4752
|
saved = true;
|
|
4718
|
-
recordSetting(def.key, before, after, source, undo, reason);
|
|
4753
|
+
recordSetting(def.key, stale ? previousKey ?? null : before, stale ? null : after, source, undo, reason);
|
|
4719
4754
|
bus.rosterChanged(peer); // fence the queue before stopping or detaching the adapter
|
|
4720
4755
|
if (!after) {
|
|
4721
4756
|
for (const pending of permissions.values()) if (pending.peer === peer) pending.done(undefined);
|
|
@@ -4740,6 +4775,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4740
4775
|
const reloaded = await reloadConfig(authority, `roster.${peer}`);
|
|
4741
4776
|
if (reloaded.ok !== true) return { ...reloaded, saved: true };
|
|
4742
4777
|
writeStatus();
|
|
4778
|
+
if (stale) return { ok: true, text: [`${peer}: stale roster key removed from .agenthub/config.local.json`, ...(sharedCopy ? [`still set in .agenthub/config.json: that copy decides nothing, and stays until you edit it there`] : [])].join("\n") };
|
|
4743
4779
|
const stayed = !after ? board.list().filter(task => !isClosedTaskState(task.state) && (task.owner === peer || task.reviewer === peer)) : [];
|
|
4744
4780
|
return { ok: true, text: [`${peer}: ${after ? "on" : "off"} roster (in force now)`, ...(!after && terminalNotice ? [terminalNotice] : []), ...(after && definitionOf(peer)?.cli.rosterReturnNotice ? [definitionOf(peer)!.cli.rosterReturnNotice!(codexResumeCommand(peer))] : []), ...(!after ? handoff.map(move => sanitize(`task ${move.title}: ${move.role} assignment moved to ${move.to}`)) : []), ...stayed.map(task => sanitize(`task ${tasks.publicTitle(task)} stays with ${peer} as ${[task.owner === peer ? "owner" : "", task.reviewer === peer ? "reviewer" : ""].filter(Boolean).join(" and ")} (${task.state})`)), ...(!after && !handoff.length && !stayed.length ? ["No open tasks to hand over."] : [])].join("\n"), ...(!after ? { handoff } : {}) };
|
|
4745
4781
|
} catch (error) {
|
|
@@ -4752,6 +4788,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4752
4788
|
const def = settingDef(key);
|
|
4753
4789
|
if (typeof def === "string") return { error: def };
|
|
4754
4790
|
const routing = activeRouting();
|
|
4791
|
+
if (staleRosterKey(def.key) && raw !== null) return { error: `${def.key} names an instance that is no longer declared: it can only be removed (ahub roster remove ${def.path[1]})` };
|
|
4792
|
+
// A removal only edits the machine-local file. A key that lives only in the shared one would be "removed" and come back on reload.
|
|
4793
|
+
if (staleRosterKey(def.key) && !storedLocally(opts.cwd, def)) return { error: `${def.key} is not in .agenthub/config.local.json, so removing it here changes nothing: edit .agenthub/config.json` };
|
|
4794
|
+
// Removing the local copy would let a different shared value show through, which the reload refuses as setting a stale key, after the write.
|
|
4795
|
+
const shared = staleRosterKey(def.key) ? sharedValue(opts.cwd, def) : undefined;
|
|
4796
|
+
if (shared !== undefined && shared !== config.roster[def.path[1]!]) return { error: `${def.key} is also set to ${String(shared)} in .agenthub/config.json: edit .agenthub/config.json first; nothing was changed` };
|
|
4755
4797
|
try { return { def, value: checkSettingValue(def, raw, def.type === "roles" ? compatibleRoles(def.path[1]!) : Object.keys(routing.hub_routes ?? {})) }; }
|
|
4756
4798
|
catch (error) { return { error: (error as Error).message }; }
|
|
4757
4799
|
}
|
|
@@ -4848,9 +4890,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4848
4890
|
const input = settingInput(key, raw);
|
|
4849
4891
|
if ("error" in input) return { ok: false, error: input.error };
|
|
4850
4892
|
const { def, value } = input;
|
|
4893
|
+
if (def.group === "Roster" && staleRosterKey(def.key)) {
|
|
4894
|
+
return { ok: true, lines: [`${def.path[1]}: no longer declared; its roster key decides nothing today.`, `Removing it deletes roster.${def.path[1]} from .agenthub/config.local.json and nothing else.`, ...(sharedValue(opts.cwd, def) !== undefined ? [`.agenthub/config.json also holds roster.${def.path[1]}: that copy stays until you edit it there.`] : [])] };
|
|
4895
|
+
}
|
|
4851
4896
|
if (def.group === "Roster") {
|
|
4852
4897
|
try {
|
|
4853
|
-
const peer = def.path[1]!, next = rosterCandidate(def, value), on =
|
|
4898
|
+
const peer = def.path[1]!, next = rosterCandidate(def, value), on = projectRosterAllows(next, peer, config.pi.enabled);
|
|
4854
4899
|
return { ok: true, lines: [`${peer}: ${onRoster(peer) ? "on" : "off"} -> ${on ? "on" : "off"} roster`, ...(on ? ["Admission, starts and new work become available; any existing pause or held delivery remains."] : [`${bus.queued(peer)} queued messages are retained until the peer returns.`, "Owned runtimes stop; native terminal sessions detach. Open tasks are offered to another eligible peer.", `For a busy peer, use ahub roster remove ${peer} --now explicitly; this stops its owned runtime or detaches its native session during the active turn.`, ...(retainedTerminalNotice(peer) ? [retainedTerminalNotice(peer)!] : []), ...tasks.rosterPreview(peer).map(move => sanitize(`task ${move.title}: ${move.to ? `${move.role} offered to ${move.to} if removed now` : `${move.role} stays with ${peer}; no eligible replacement`}`)), ...(rosterRemovalRefusal(peer, false) ? [rosterRemovalRefusal(peer, false)!] : [])])], ...(on ? {} : { confirm: peer }) };
|
|
4855
4900
|
} catch (error) { return { ok: false, error: sanitize((error as Error).message) }; }
|
|
4856
4901
|
}
|
|
@@ -5009,6 +5054,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5009
5054
|
const incompatibleRecoveryTarget = () => {
|
|
5010
5055
|
const cancelled = incompatibleCancellationTarget();
|
|
5011
5056
|
if (cancelled) return cancelled;
|
|
5057
|
+
if (externalIds.size && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("external_adapter_modules"))) return "target lacks external_adapter_modules; verified external definitions cannot be preserved by that daemon";
|
|
5012
5058
|
if (configuredIds.length && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("configured_acp"))) return "target lacks configured_acp; project adapter definitions cannot be preserved by that daemon";
|
|
5013
5059
|
if (extraIds.length && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("peer_instances"))) return "target lacks peer_instances; declared instance identities cannot be restored by that daemon";
|
|
5014
5060
|
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";
|
|
@@ -5171,8 +5217,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5171
5217
|
if (nativeUnattended(peer)) return { ok: false, error: "Codex started --unattended already skips prompts; run ahub permission codex ask after it stops to clear the hub choice, then restart without --unattended" };
|
|
5172
5218
|
}
|
|
5173
5219
|
const from = permissionMode(peer);
|
|
5220
|
+
const runtime: PeerAdapter = owner;
|
|
5221
|
+
const nativeMode = runtime.permissionModeState;
|
|
5222
|
+
if (nativeMode === "unknown" || nativeMode === "unmanaged") return { ok: false, error: `${peer} permission mode ${nativeMode}; inspect its native policy before changing modes` };
|
|
5174
5223
|
try {
|
|
5175
|
-
if (
|
|
5224
|
+
if (runtime.setPermissionMode) await runtime.setPermissionMode(mode);
|
|
5176
5225
|
} catch (error) { return { ok: false, error: (error as Error).message }; }
|
|
5177
5226
|
if (bus.peers.get(peer) !== owner || bus.stateOf(peer) === "offline") return { ok: false, error: `${peer} session changed; inspect status and retry` };
|
|
5178
5227
|
permissionModes.set(peer, mode);
|
|
@@ -5710,6 +5759,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5710
5759
|
if (msg.op === "export") {
|
|
5711
5760
|
try { return void reply({ ok: true, export: tasks.workflowExport(msg.id) }); } catch { return void reply({ ok: false, error: "workflow export unavailable" }); }
|
|
5712
5761
|
}
|
|
5762
|
+
if (msg.op === "accept-departure") {
|
|
5763
|
+
void tasks.acceptPlanDeparture(USER, msg.id, msg.head, msg.reason).then(
|
|
5764
|
+
() => reply({ ok: true }),
|
|
5765
|
+
() => reply({ ok: false, error: "plan departure not accepted: task changed, current complete file evidence unavailable, or arguments invalid" }),
|
|
5766
|
+
);
|
|
5767
|
+
return;
|
|
5768
|
+
}
|
|
5713
5769
|
if (msg.op === "accept") {
|
|
5714
5770
|
// #481 S1: the person's spec acceptance, bound to the issue's body edit time; no body is read.
|
|
5715
5771
|
if (!Number.isSafeInteger(msg.id) || msg.id <= 0 || !Number.isSafeInteger(msg.issue) || msg.issue <= 0 || (msg.repo !== undefined && typeof msg.repo !== "string")) return void reply({ ok: false, error: "usage: ahub sdlc accept <task> --issue <n> [--repo owner/repository]" });
|
package/src/hub/facts.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { repoGitArgs, repoGitEnv, type Repo } from "./snapshots.ts";
|
|
2
2
|
import { isHubWritePath } from "./permission-mode.ts";
|
|
3
3
|
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
-
import { closeSync, constants, fstatSync, mkdirSync, openSync, readSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { closeSync, constants, fstatSync, mkdirSync, openSync, readSync, rmSync, statSync, writeFileSync, type Stats } from "node:fs";
|
|
5
5
|
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
6
6
|
import { sanitize, type PeerId } from "./envelope.ts";
|
|
7
7
|
import { isDenied } from "../local/deny.ts";
|
|
@@ -24,6 +24,22 @@ import { realPath } from "./project.ts";
|
|
|
24
24
|
const MAX_BYTES = 256 * 1024;
|
|
25
25
|
/** Larger files that are not read are hashed for the HEAD comparison while all of them in one comparison total this much or less; beyond it, none is compared. */
|
|
26
26
|
const HASHED_BYTES = 64 * 1024 * 1024;
|
|
27
|
+
/**
|
|
28
|
+
* A large file whose last change (ctime) is this recent may still be rewritten with the same stat: Linux before 6.13
|
|
29
|
+
* stamps files from a coarse clock, so a same-size rewrite within one tick keeps size, mtime, ctime and inode (#428).
|
|
30
|
+
* Such a file is named by its bytes, as git treats a racily clean entry: at most RACY_HASH_MAX_BYTES each, hashed once
|
|
31
|
+
* per version in a public entry point, RACY_BURST_BYTES in all per store (Facts instance) per event-loop turn (the hooks
|
|
32
|
+
* run on the daemon's loop). The HEAD comparison keeps its own bound (HASHED_BYTES) and is not limited by these.
|
|
33
|
+
* ponytail: ceilings, each falling back to the stat-only stamp (no claim either way): a racy file over the per-file cap,
|
|
34
|
+
* or past the turn's budget, misses a same-size rewrite within one timestamp tick; a racy file first seen past the
|
|
35
|
+
* budget (kept stat-only) reads as changed once when a later turn hashes it with the same bytes; a file written continuously never settles and is hashed at every look, within that budget; FAT's 2 s mtime
|
|
36
|
+
* is the widest covered; a settled file made racy with the same bytes (chmod, `cp -p`, touch) reads as changed once; a
|
|
37
|
+
* version first seen racy costs an open and a stat at every later look (largeBlob's cache answers). A per-file history
|
|
38
|
+
* of racy versions if any of these matters.
|
|
39
|
+
*/
|
|
40
|
+
const RACY_MS = 2000;
|
|
41
|
+
const RACY_HASH_MAX_BYTES = 4 * 1024 * 1024;
|
|
42
|
+
const RACY_BURST_BYTES = 16 * 1024 * 1024;
|
|
27
43
|
/** Changed lines shown in one fact, all files together; the rest is counted and the files named. */
|
|
28
44
|
export const MAX_LINES = 60;
|
|
29
45
|
/** What Claude Code's Read returns by default: the first 2000 lines, each cut at 2000 characters. */
|
|
@@ -145,6 +161,10 @@ export class Facts {
|
|
|
145
161
|
private readonly seenDropped = new Map<PeerId, Set<string>>();
|
|
146
162
|
/** Git blob ids of large files, by path, with the stat they were taken at. */
|
|
147
163
|
private readonly largeBlobs = new Map<string, { stamp: string; blob: string }>();
|
|
164
|
+
/** Blob ids of racy large files hashed in the current boundary, by path and stat (#428). */
|
|
165
|
+
private racyMemo = new Map<string, string>();
|
|
166
|
+
/** Bytes of racy large files left to hash in this event-loop turn. */
|
|
167
|
+
private racyLeft?: { bytes: number };
|
|
148
168
|
/** Directories each peer acknowledged being told were cut: once per session, not at every boundary. */
|
|
149
169
|
private readonly cutTold = new Map<PeerId, Set<string>>();
|
|
150
170
|
/**
|
|
@@ -217,7 +237,7 @@ export class Facts {
|
|
|
217
237
|
* ponytail: a directory swapped between the containment check and the open is followed once; an fd-relative walk
|
|
218
238
|
* (openat per component) closes that if it matters.
|
|
219
239
|
*/
|
|
220
|
-
private load(file: string): Omit<Version, "seq"> {
|
|
240
|
+
private load(file: string, statOnly = false): Omit<Version, "seq"> {
|
|
221
241
|
if (this.rel(join(this.root, file)) !== file) return { hash: "missing" };
|
|
222
242
|
const projected = this.o.projection?.(join(this.root, file));
|
|
223
243
|
if (projected) return { hash: projected.rawHash, blob: projected.rawBlob, text: projected.text, projected: true };
|
|
@@ -226,7 +246,10 @@ export class Facts {
|
|
|
226
246
|
fd = openSync(join(this.root, file), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
227
247
|
const st = fstatSync(fd);
|
|
228
248
|
if (!st.isFile()) return { hash: "missing" }; // a directory, a device or a fifo: never read
|
|
229
|
-
if (st.size > MAX_BYTES)
|
|
249
|
+
if (st.size > MAX_BYTES) {
|
|
250
|
+
const blob = statOnly ? undefined : this.racyBlob(file, fd, st);
|
|
251
|
+
return blob ? { hash: `large:${st.size}:${st.mtimeMs}:${blob}`, blob } : { hash: `large:${st.size}:${st.mtimeMs}` };
|
|
252
|
+
}
|
|
230
253
|
const buf = Buffer.alloc(st.size);
|
|
231
254
|
let got = 0;
|
|
232
255
|
while (got < buf.length) {
|
|
@@ -317,13 +340,17 @@ export class Facts {
|
|
|
317
340
|
const seen = files.filter((f) => head.has(f)).map((f) => [f, version(f)] as const);
|
|
318
341
|
const large = seen.reduce((n, [, v]) => n + (v?.hash.startsWith("large:") ? Number(v.hash.split(":")[1]) : 0), 0);
|
|
319
342
|
return new Set(seen.filter(([f, v]) => {
|
|
320
|
-
const blob = v?.
|
|
343
|
+
const blob = !v?.hash.startsWith("large:") ? v?.blob : large <= HASHED_BYTES ? v.blob ?? this.largeBlob(f, v.hash, true) : undefined;
|
|
321
344
|
return blob !== undefined && blob === head.get(f);
|
|
322
345
|
}).map(([f]) => f));
|
|
323
346
|
}
|
|
324
347
|
|
|
325
348
|
/** Git's blob id of a file too large to load, streamed; undefined for anything that is not a small enough regular file. */
|
|
326
|
-
|
|
349
|
+
/**
|
|
350
|
+
* `head`: for the HEAD comparison, which bounds its own hashing (HASHED_BYTES): a racy file is streamed whatever the
|
|
351
|
+
* racy cap and the turn's budget, uncached. Otherwise a racy file goes through racyBlob's limits.
|
|
352
|
+
*/
|
|
353
|
+
private largeBlob(file: string, observed: string, head = false): string | undefined {
|
|
327
354
|
if (this.rel(join(this.root, file)) !== file) return undefined;
|
|
328
355
|
let fd: number | undefined;
|
|
329
356
|
try {
|
|
@@ -333,13 +360,10 @@ export class Facts {
|
|
|
333
360
|
// A file that stays stat-dirty but equal to HEAD is looked at again at every boundary: hashed once per version.
|
|
334
361
|
const stamp = `${st.ino}:${st.size}:${st.mtimeMs}:${st.ctimeMs}`, known = this.largeBlobs.get(file);
|
|
335
362
|
if (known?.stamp === stamp) return known.blob;
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
if (got !== st.size) return undefined; // changed while read: no claim
|
|
341
|
-
const blob = h.digest("hex");
|
|
342
|
-
this.largeBlobs.set(file, { stamp, blob });
|
|
363
|
+
// A racy stamp may name other bytes later: never cached.
|
|
364
|
+
if (Date.now() - st.ctimeMs < RACY_MS) return head ? this.racyMemo.get(racyKey(file, st)) ?? streamBlob(fd, st.size) : this.racyBlob(file, fd, st);
|
|
365
|
+
const blob = streamBlob(fd, st.size);
|
|
366
|
+
if (blob) this.largeBlobs.set(file, { stamp, blob });
|
|
343
367
|
return blob;
|
|
344
368
|
} catch {
|
|
345
369
|
return undefined;
|
|
@@ -348,6 +372,22 @@ export class Facts {
|
|
|
348
372
|
}
|
|
349
373
|
}
|
|
350
374
|
|
|
375
|
+
/** A racy large file's blob id (#428), or undefined when it is settled, over the per-file cap or past the turn's budget. */
|
|
376
|
+
private racyBlob(file: string, fd: number, st: Stats): string | undefined {
|
|
377
|
+
if (Date.now() - st.ctimeMs >= RACY_MS || st.size > RACY_HASH_MAX_BYTES) return undefined;
|
|
378
|
+
const key = racyKey(file, st), known = this.racyMemo.get(key);
|
|
379
|
+
if (known) return known;
|
|
380
|
+
if (!this.racyLeft) {
|
|
381
|
+
const turn = this.racyLeft = { bytes: RACY_BURST_BYTES };
|
|
382
|
+
setImmediate(() => { if (this.racyLeft === turn) this.racyLeft = undefined; });
|
|
383
|
+
}
|
|
384
|
+
if (st.size > this.racyLeft.bytes) return undefined;
|
|
385
|
+
this.racyLeft.bytes -= st.size;
|
|
386
|
+
const blob = streamBlob(fd, st.size);
|
|
387
|
+
if (blob) this.racyMemo.set(key, blob);
|
|
388
|
+
return blob;
|
|
389
|
+
}
|
|
390
|
+
|
|
351
391
|
/** `file` is covered again: an offer still unread that named it as dropped must not spend the notice of a later drop. */
|
|
352
392
|
private coveredAgain(peer: PeerId, notice: "untracked" | "unfollowed", file: string): void {
|
|
353
393
|
for (const o of this.offers.get(peer) ?? []) if (o[notice]?.includes(file)) o[notice] = o[notice]!.filter((f) => f !== file);
|
|
@@ -357,6 +397,7 @@ export class Facts {
|
|
|
357
397
|
private boundary(): void {
|
|
358
398
|
this.expanded = new Map();
|
|
359
399
|
this.expandedCut = new Set();
|
|
400
|
+
this.racyMemo = new Map(); // not per turn: a rewrite between two entry points of one turn is another version
|
|
360
401
|
}
|
|
361
402
|
|
|
362
403
|
/** `peer` worked on `file`; `seen` is the file as it was when it did, the baseline if it has no view of it yet. */
|
|
@@ -463,6 +504,12 @@ export class Facts {
|
|
|
463
504
|
const now = this.load(file);
|
|
464
505
|
const was = this.latest.get(file);
|
|
465
506
|
if (was && was.hash === now.hash) return was;
|
|
507
|
+
// Seen while racy (named by its bytes), now with the same stat but no blob (settled, or past the turn's budget): the
|
|
508
|
+
// same version if the bytes are, or when they cannot be hashed now (no claim).
|
|
509
|
+
if (was?.blob && was.hash === `${now.hash}:${was.blob}`) {
|
|
510
|
+
const blob = this.largeBlob(file, now.hash);
|
|
511
|
+
if (blob === undefined || blob === was.blob) return was;
|
|
512
|
+
}
|
|
466
513
|
const version = { ...now, seq: ++this.seq };
|
|
467
514
|
this.latest.set(file, version);
|
|
468
515
|
if (was) {
|
|
@@ -720,7 +767,9 @@ export class Facts {
|
|
|
720
767
|
// does, nor what they wrote before or after (a member settling keeps its files in the set).
|
|
721
768
|
const written = windows.flatMap((w) => [...(this.writes.get(w.peer) ?? [])].filter(([, times]) => times.some((t) => t >= w.since && (w.until === undefined || t <= w.until))).map(([file]) => file));
|
|
722
769
|
const files = [...new Set([...this.expand(paths.flatMap((p) => { const r = this.rel(p); return r ? [r] : []; }), true), ...written])].sort();
|
|
723
|
-
|
|
770
|
+
// ponytail: a large file counts by its stat, racy or not, so the target does not move when it settles; a same-size
|
|
771
|
+
// rewrite within one timestamp tick does not move it either (observe() sees that one).
|
|
772
|
+
for (const file of files) h.update(file).update("\0").update(this.load(file, true).hash).update("\0");
|
|
724
773
|
return h.digest("hex");
|
|
725
774
|
}
|
|
726
775
|
|
|
@@ -871,3 +920,17 @@ export function codexEffect(before: string | undefined, now: { hash: string; tex
|
|
|
871
920
|
const observed = changedLines(diff(before, now.text));
|
|
872
921
|
return reported.length > 0 && reported.length === observed.length && reported.every((l, i) => l === observed[i]);
|
|
873
922
|
}
|
|
923
|
+
|
|
924
|
+
/** Git's blob id of the `size` bytes `fd` reads, streamed; undefined when fewer were read (changed while read). */
|
|
925
|
+
function streamBlob(fd: number, size: number): string | undefined {
|
|
926
|
+
const h = createHash("sha1").update(`blob ${size}\0`);
|
|
927
|
+
const buf = Buffer.alloc(1024 * 1024);
|
|
928
|
+
let got = 0;
|
|
929
|
+
for (let n = 0; got < size && (n = readSync(fd, buf, 0, buf.length, got)) > 0; got += n) h.update(buf.subarray(0, n));
|
|
930
|
+
return got === size ? h.digest("hex") : undefined;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/** A racy large file's version within one boundary: its path and every stat field. */
|
|
934
|
+
function racyKey(file: string, st: Stats): string {
|
|
935
|
+
return `${file}\0${st.ino}:${st.size}:${st.mtimeMs}:${st.ctimeMs}`;
|
|
936
|
+
}
|