@staix/agent-hub 0.12.34 → 0.12.35
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 +13 -0
- package/README.md +3 -3
- package/docs/agent-notes/adapters.md +2 -0
- package/docs/agent-notes/budget.md +3 -1
- package/docs/agent-notes/bus.md +2 -1
- package/docs/agent-notes/daemon.md +5 -1
- package/docs/agent-notes/local-worker.md +1 -1
- package/docs/agent-notes/tasks.md +4 -1
- package/docs/gsd-collaboration-analysis.md +5 -1
- package/docs/operations.md +67 -13
- package/docs/quickstart.md +8 -2
- package/docs/sdlc.md +59 -34
- package/docs/security.md +3 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +161 -6
- package/docs/verification/2026-10-18-0.12.35.md +49 -0
- package/docs/verification/2026-10-18-493-claude-busy-channel.md +91 -0
- package/docs/verified.json +21 -16
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +15438 -14863
- package/src/adapters/acp.ts +3 -1
- package/src/adapters/claude-channel.ts +59 -29
- package/src/adapters/codex-appserver.ts +1 -1
- package/src/adapters/definitions/native-cli-entry-codex.ts +13 -5
- package/src/adapters/definitions/native-cli-entry-jcode.ts +4 -1
- package/src/adapters/definitions/native-cli-entry-kimi.ts +16 -11
- package/src/adapters/definitions/native-cli-entry-local.ts +3 -2
- package/src/adapters/definitions/native-cli-entry-pi.ts +8 -6
- package/src/adapters/definitions/native-cli-entry-qwen.ts +3 -2
- package/src/adapters/definitions/native-cli-entry-types.ts +1 -0
- package/src/adapters/definitions/native-recovery.ts +2 -2
- package/src/adapters/definitions/qwen-native.ts +2 -2
- package/src/adapters/local-worker.ts +3 -2
- package/src/adapters/pi.ts +3 -1
- package/src/adapters/registry-types.ts +3 -0
- package/src/adapters/registry.ts +24 -8
- package/src/cli/identity.ts +3 -3
- package/src/cli/launch.ts +29 -4
- package/src/cli/main.ts +101 -28
- package/src/cli/preview.ts +16 -5
- package/src/cli/recovery-guidance.ts +39 -22
- package/src/cli/terminal-recovery.ts +68 -16
- package/src/cli/upgrade-restore-state.ts +16 -7
- package/src/cli/upgrade-runtime.ts +67 -39
- package/src/cli/upgrade.ts +43 -27
- package/src/hub/budget.ts +55 -3
- package/src/hub/bus.ts +1 -1
- package/src/hub/conductor.ts +16 -10
- package/src/hub/config-apply.ts +13 -2
- package/src/hub/config-reload.ts +11 -1
- package/src/hub/config-trust.ts +1 -1
- package/src/hub/control-client.ts +1 -1
- package/src/hub/crash.ts +14 -5
- package/src/hub/daemon.ts +296 -125
- package/src/hub/execution-budget.ts +3 -3
- package/src/hub/hub-tools.ts +17 -6
- package/src/hub/instance-budget.ts +73 -0
- package/src/hub/instance-config.ts +47 -0
- package/src/hub/instance-execution.ts +50 -0
- package/src/hub/instances.ts +100 -0
- package/src/hub/lifecycle-schema.ts +7 -3
- package/src/hub/permission-mode.ts +7 -7
- package/src/hub/roster.ts +7 -6
- package/src/hub/routing.ts +82 -18
- package/src/hub/sdlc-queue.ts +2 -2
- package/src/hub/sdlc.ts +84 -38
- package/src/hub/settings.ts +34 -6
- package/src/hub/snapshots.ts +40 -9
- package/src/hub/start-mode.ts +7 -4
- package/src/hub/status-roster.ts +8 -0
- package/src/hub/tasks.ts +53 -23
- package/src/hub/text-projection.ts +7 -3
- package/src/hub/workflow.ts +30 -52
- package/src/pi/launch.ts +4 -3
- package/src/ui/index.html +1 -1
- package/templates/AGENTS.block.md +2 -1
package/src/hub/daemon.ts
CHANGED
|
@@ -25,6 +25,11 @@ import { KNOWN_PEERS, OPTIONAL_PEERS, PLANNER_PEERS, SUPPORTED_PEERS, isSupporte
|
|
|
25
25
|
import { RecoveryDeferredHubError } from "./deferred-hub.ts";
|
|
26
26
|
import { databaseBusySite, isDatabaseBusy, RecoveryDatabaseBusy } from "./recovery-error.ts";
|
|
27
27
|
import { ADAPTERS, QUOTA_HANDOFF_PLATFORMS, NATIVE_CONFIG_BLOCKS, nativeConfigurationDefaults, mergeNativeConfiguration, retainNativeRouting, DAEMON_PORTS, definitionOf } from "../adapters/registry.ts";
|
|
28
|
+
import { definitionOf as instanceDefinitionOf, KNOWN_PEERS as BASE_KNOWN_PEERS, SUPPORTED_PEERS as BASE_SUPPORTED_PEERS, STARTABLE_PEERS as BASE_STARTABLE_PEERS } from "../adapters/registry.ts";
|
|
29
|
+
import { effectiveInstanceRoles, instancePeerIds, validateInstances, type InstancesConfig } from "./instances.ts";
|
|
30
|
+
import { INSTANCE_CONFIG_FILES, readInstanceConfig, readInstanceConfigLayers, readInstanceRoleConfig } from "./instance-config.ts";
|
|
31
|
+
import { instanceBudgetHooks, projectBudgetHeadroom } from "./instance-budget.ts";
|
|
32
|
+
import { resolveInstanceExecutionCwd } from "./instance-execution.ts";
|
|
28
33
|
import { ProgressObserver, normalizeCodexObservation, normalizeClaudeObservation } from "./progress.ts";
|
|
29
34
|
import type { ToolObservation } from "../models/route/signals.ts";
|
|
30
35
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
@@ -78,7 +83,7 @@ import { AssignmentUndeliveredError, ESCALATE_AFTER, Tasks } from "./tasks.ts";
|
|
|
78
83
|
import { DEFAULT_INFERENCE, DIGEST, Inference, screenPii, type InferenceConfig } from "./inference.ts";
|
|
79
84
|
import { ask, ASK_NOTE_TITLE, RUN_START } from "./ask.ts";
|
|
80
85
|
import { currentRouting, detectSignals, OVERLAY_FILE, overlayToml, parseRouting, routingText, type Routing } from "./routing.ts";
|
|
81
|
-
import {
|
|
86
|
+
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";
|
|
82
87
|
import { Bus } from "./bus.ts";
|
|
83
88
|
import { attribute, deliveryTask, type TaskAttribution } from "./attribution.ts";
|
|
84
89
|
import { DeliveryJournal } from "./delivery-journal.ts";
|
|
@@ -118,6 +123,8 @@ import { nativeRecall } from "../memory/recall.ts";
|
|
|
118
123
|
import { promotionScreen, withoutPii, mentionsPrivatePath } from "./memory-policy.ts";
|
|
119
124
|
|
|
120
125
|
export interface HubConfig {
|
|
126
|
+
/** Person-owned declarations, read only from an untracked machine-local file. Bound for this daemon lifetime. */
|
|
127
|
+
instances?: InstancesConfig;
|
|
121
128
|
watchdog_ms: number;
|
|
122
129
|
kimi_cmd: string[];
|
|
123
130
|
qwen_cmd: string[];
|
|
@@ -233,11 +240,11 @@ export const DEFAULT_CONFIG: HubConfig = {
|
|
|
233
240
|
export { stateDirFor };
|
|
234
241
|
|
|
235
242
|
/** Ids an external process may not claim: the console user and the adapters the daemon runs itself. */
|
|
236
|
-
const
|
|
243
|
+
const BASE_RESERVED_IDS = new Set<string>([USER, ...SUPPORTED_PEERS.filter(peer => definitionOf(peer)?.identity.mcp !== "channel-plugin"), HUB, DIGEST]);
|
|
237
244
|
const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
|
|
238
245
|
|
|
239
246
|
/** The shared project config, then the machine's own file, which overrides it block by block (issue #17). */
|
|
240
|
-
const CONFIG_FILES =
|
|
247
|
+
const CONFIG_FILES = INSTANCE_CONFIG_FILES;
|
|
241
248
|
export interface ConfigLayer { name: typeof CONFIG_FILES[number]; raw: Record<string, any> }
|
|
242
249
|
const CONFIG_BLOCKS = ["memory", "roster", "roles", "catalog_roles", "pii", "data", "conductor", "budget", "context", "inference", "omniroute", ...NATIVE_CONFIG_BLOCKS, "approvals", "tasks", "task_sweep", "checks", "snapshots", "research", "bench", "limits", "review", "recovery", "capabilities", "mlx", "permission_modes", "peers", "terminal"];
|
|
243
250
|
|
|
@@ -285,18 +292,22 @@ function syncMinutes(v: unknown): number {
|
|
|
285
292
|
}
|
|
286
293
|
|
|
287
294
|
export function loadConfig(cwd: string, layers?: readonly ConfigLayer[]): HubConfig {
|
|
288
|
-
const
|
|
295
|
+
const captured = layers ?? readInstanceConfigLayers(cwd);
|
|
296
|
+
const instanceConfig = readInstanceConfig(cwd, captured);
|
|
297
|
+
const instances = instanceConfig.instances;
|
|
298
|
+
const ignored: string[] = [...instanceConfig.ignored];
|
|
289
299
|
const appliedPermissionSources: NonNullable<HubConfig["permission_default_sources"]> = {};
|
|
290
300
|
const files = CONFIG_FILES.flatMap((name) => {
|
|
291
301
|
let file: Record<string, any>; // parsed JSON, checked field by field below
|
|
292
|
-
if (
|
|
302
|
+
if (!captured.some(layer => layer.name === name)) return [];
|
|
293
303
|
try {
|
|
294
|
-
file =
|
|
304
|
+
file = structuredClone(captured.find(layer => layer.name === name)!.raw);
|
|
295
305
|
} catch (error) {
|
|
296
306
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
297
307
|
throw error;
|
|
298
308
|
}
|
|
299
|
-
|
|
309
|
+
delete file.instances; // Declaration authority was checked against its exact captured origin above.
|
|
310
|
+
peerRosterConfig(file.roster, instances);
|
|
300
311
|
catalogAssignments(file.catalog_roles);
|
|
301
312
|
profileName(file.profile);
|
|
302
313
|
resolveProfile(file.profile, file.profile_overrides);
|
|
@@ -311,14 +322,14 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[]): HubCon
|
|
|
311
322
|
for (const key of ["jcode_home", "jcode_provider_profile"]) if (file[key] !== undefined && typeof file[key] !== "string") throw new Error(`${key} must be a string`);
|
|
312
323
|
if (file.qwen_cmd !== undefined && (!Array.isArray(file.qwen_cmd) || !file.qwen_cmd.length || file.qwen_cmd.some((word: unknown) => typeof word !== "string" || !word))) throw new Error("qwen_cmd must be a nonempty executable argv");
|
|
313
324
|
instructionProtection(file.protect_instruction_files);
|
|
314
|
-
permissionDefaults(file.permission_modes); // malformed declarations fail even when trust would drop them
|
|
315
|
-
peerStartConfig(file.peers);
|
|
325
|
+
permissionDefaults(file.permission_modes, instances); // malformed declarations fail even when trust would drop them
|
|
326
|
+
peerStartConfig(file.peers, instances);
|
|
316
327
|
if (file.terminal != null && (typeof file.terminal !== "object" || Array.isArray(file.terminal))) throw new Error("terminal must be an object");
|
|
317
328
|
terminalTemplate(file.terminal?.open);
|
|
318
329
|
delete file.permission_default_sources; // provenance is loader-owned, never a file claim
|
|
319
330
|
const why = stripUntrusted(file, DEFAULT_CONFIG, cwd, name);
|
|
320
331
|
if (why) ignored.push(why);
|
|
321
|
-
for (const [peer, mode] of Object.entries(permissionDefaults(file.permission_modes))) appliedPermissionSources[peer] = { mode, source: `.agenthub/${name}` };
|
|
332
|
+
for (const [peer, mode] of Object.entries(permissionDefaults(file.permission_modes, instances))) appliedPermissionSources[peer] = { mode, source: `.agenthub/${name}` };
|
|
322
333
|
return [file];
|
|
323
334
|
});
|
|
324
335
|
if (!files.length) return DEFAULT_CONFIG;
|
|
@@ -346,23 +357,34 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[]): HubCon
|
|
|
346
357
|
throw new Error("legacy MLX configuration requires explicit mlx.provider=legacy; migrate to provider=ollama to avoid Python serving");
|
|
347
358
|
}
|
|
348
359
|
const mlx = { ...(file.mlx?.provider === "legacy" ? { enabled: true, provider: "legacy" as const, maxInputTokens: 16_000, maxTokens: 2048 } : DEFAULT_CONFIG.mlx), ...file.mlx };
|
|
349
|
-
const conductorPolicy = loadConductorPolicy(cwd,
|
|
360
|
+
const conductorPolicy = loadConductorPolicy(cwd, captured);
|
|
361
|
+
const extraRoles = Object.fromEntries(Object.keys(instances ?? {}).map(id => [id, effectiveInstanceRoles(id, instances, conductorPolicy.roles)]));
|
|
362
|
+
const instancePermissions: Record<string, PermissionMode> = {};
|
|
363
|
+
const instanceModes: PeerStartConfig = {};
|
|
364
|
+
for (const [id, declaration] of Object.entries(instances ?? {})) {
|
|
365
|
+
instanceModes[id] = { start_mode: declaration.start_mode ?? "headless" };
|
|
366
|
+
if (declaration.permission_mode !== undefined) {
|
|
367
|
+
instancePermissions[id] = declaration.permission_mode;
|
|
368
|
+
appliedPermissionSources[id] = { mode: declaration.permission_mode, source: ".agenthub/config.local.json" };
|
|
369
|
+
}
|
|
370
|
+
}
|
|
350
371
|
if (!mlx.enabled && file.pi?.backend === "mlx") throw new Error("Pi backend mlx conflicts with mlx.enabled=false");
|
|
351
372
|
if (typeof mlx.runtimeDir === "string" && mlx.runtimeDir) mlx.runtimeDir = resolve(cwd, mlx.runtimeDir);
|
|
352
373
|
if (typeof mlx.modelPath === "string" && mlx.modelPath) mlx.modelPath = resolve(cwd, mlx.modelPath);
|
|
353
374
|
return {
|
|
354
375
|
...DEFAULT_CONFIG,
|
|
355
376
|
...file,
|
|
377
|
+
...(instances ? { instances } : {}),
|
|
356
378
|
...(file.text_projection !== undefined ? { text_projection: projectionConfig(file.text_projection) } : {}),
|
|
357
379
|
...(file.pii !== undefined ? { pii: { paths: policyPaths(file.pii.paths, "pii.paths") } } : {}),
|
|
358
380
|
...(file.data !== undefined ? { data: { readonly: policyPaths(file.data.readonly, "data.readonly") } } : {}),
|
|
359
|
-
permission_modes: permissionDefaults(file.permission_modes),
|
|
381
|
+
permission_modes: { ...instancePermissions, ...permissionDefaults(file.permission_modes, instances) },
|
|
360
382
|
protect_instruction_files: instructionProtection(file.protect_instruction_files),
|
|
361
383
|
permission_default_sources: appliedPermissionSources,
|
|
362
384
|
memory: { ...DEFAULT_CONFIG.memory, native: true, ...file.memory, ...(file.memory?.short_ttl_days !== undefined ? { short_ttl_days: ttlDays(file.memory.short_ttl_days) } : {}),
|
|
363
385
|
...(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)
|
|
364
|
-
roster: peerRosterConfig(file.roster),
|
|
365
|
-
roles: conductorPolicy.roles,
|
|
386
|
+
roster: peerRosterConfig(file.roster, instances),
|
|
387
|
+
roles: { ...conductorPolicy.roles, ...extraRoles },
|
|
366
388
|
planner: conductorPolicy.planner,
|
|
367
389
|
conductor: conductorPolicy.conductor,
|
|
368
390
|
budget: { ...DEFAULT_CONFIG.budget, wait_max_min: 30, ...file.budget }, // on with any project config (issue #36)
|
|
@@ -380,7 +402,7 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[]): HubCon
|
|
|
380
402
|
snapshots: { ...DEFAULT_CONFIG.snapshots, enabled: true, ...file.snapshots },
|
|
381
403
|
research: { enabled: file.research?.enabled === true }, // opt-in: anything but true is off
|
|
382
404
|
bench: { enabled: file.bench?.enabled === true }, // opt-in: a run resets this project's work tree
|
|
383
|
-
peers: peerStartConfig(file.peers),
|
|
405
|
+
peers: { ...instanceModes, ...peerStartConfig(file.peers, instances) },
|
|
384
406
|
terminal: { open: terminalTemplate(file.terminal?.open) },
|
|
385
407
|
limits: { ...PROJECT_LIMITS, ...file.limits }, // on with any project config (issue #38)
|
|
386
408
|
review: { ...DEFAULT_CONFIG.review, ...file.review },
|
|
@@ -473,6 +495,7 @@ class WsPeer extends BasePeer {
|
|
|
473
495
|
private nativeActive = false;
|
|
474
496
|
/** The attached session said it cannot show channel pushes (issue #205): the bus keeps its queue for `inbox`. */
|
|
475
497
|
pullOnly = false;
|
|
498
|
+
steer: PeerAdapter["steer"] = undefined;
|
|
476
499
|
lastInboxAt: number | undefined;
|
|
477
500
|
private claimedPullOnly = false;
|
|
478
501
|
/** Called at hello, before the async preface: the newest hello wins even if an older one's recall finishes last. */
|
|
@@ -500,6 +523,12 @@ class WsPeer extends BasePeer {
|
|
|
500
523
|
this.nativeSession = undefined;
|
|
501
524
|
this.nativeActive = false;
|
|
502
525
|
this.pullOnly = this.claimedPullOnly; // before idle: the idle transition drains
|
|
526
|
+
// Native TUI readback in #493 proves important channel input can be consumed before the turn ends.
|
|
527
|
+
// Pull-only transports expose no steer capability; the bus keeps their important work queued.
|
|
528
|
+
this.steer = this.pullOnly ? undefined : async (envs, deliveryId) => {
|
|
529
|
+
if (this.state !== "busy" || !envs.length || envs.some(env => env.priority !== "important")) throw new Error("channel steering requires important work in the current busy turn");
|
|
530
|
+
await this.deliver(envs, deliveryId);
|
|
531
|
+
};
|
|
503
532
|
this.lastInboxAt = undefined;
|
|
504
533
|
this.setState("idle");
|
|
505
534
|
}
|
|
@@ -507,6 +536,7 @@ class WsPeer extends BasePeer {
|
|
|
507
536
|
if (this.claimed === sock) this.claimed = undefined;
|
|
508
537
|
if (this.sock !== sock) return;
|
|
509
538
|
this.sock = undefined;
|
|
539
|
+
this.steer = undefined;
|
|
510
540
|
this.nativeActive = false;
|
|
511
541
|
this.pullOnly = false;
|
|
512
542
|
this.setState("offline");
|
|
@@ -516,6 +546,7 @@ class WsPeer extends BasePeer {
|
|
|
516
546
|
this.sock?.close(4000, "peer is off roster; retry when a person adds it");
|
|
517
547
|
this.claimed = undefined;
|
|
518
548
|
this.sock = undefined;
|
|
549
|
+
this.steer = undefined;
|
|
519
550
|
this.nativeActive = false;
|
|
520
551
|
this.pullOnly = false;
|
|
521
552
|
this.setState("offline");
|
|
@@ -567,16 +598,49 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
567
598
|
let ready = false;
|
|
568
599
|
try {
|
|
569
600
|
const loadedConfig = opts.config ?? loadConfig(opts.cwd);
|
|
570
|
-
const lifecycleConfig = loadSdlc(opts.cwd);
|
|
601
|
+
const lifecycleConfig = loadSdlc(opts.cwd, VERSION);
|
|
571
602
|
// Preserve embedding-owned policy references, but never publish reloads into shared defaults.
|
|
572
603
|
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;
|
|
573
|
-
const
|
|
604
|
+
const declarations = validateInstances(loadedConfig.instances);
|
|
605
|
+
const extraIds = Object.keys(declarations);
|
|
606
|
+
const executionRoots = new Map<string, string>();
|
|
607
|
+
const executionOwners = new Map<string, { owner: PeerAdapter; cwd: string }>();
|
|
608
|
+
const roles = { ...loadedConfig.roles };
|
|
609
|
+
const permissionModesFromInstances: Record<string, PermissionMode> = {};
|
|
610
|
+
const startModesFromInstances: PeerStartConfig = {};
|
|
611
|
+
const instancePermissionSources: NonNullable<HubConfig["permission_default_sources"]> = {};
|
|
612
|
+
for (const [id, declaration] of Object.entries(declarations)) {
|
|
613
|
+
roles[id] = effectiveInstanceRoles(id, declarations, loadedConfig.roles);
|
|
614
|
+
startModesFromInstances[id] = { start_mode: "headless" };
|
|
615
|
+
if (declaration.permission_mode !== undefined) {
|
|
616
|
+
permissionModesFromInstances[id] = declaration.permission_mode;
|
|
617
|
+
instancePermissionSources[id] = { mode: declaration.permission_mode, source: ".agenthub/config.local.json" };
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
const config: HubConfig = { ...ownedConfig, ...(extraIds.length ? { instances: declarations } : {}),
|
|
621
|
+
roster: peerRosterConfig(loadedConfig.roster, declarations),
|
|
622
|
+
permission_modes: { ...permissionModesFromInstances, ...permissionDefaults(loadedConfig.permission_modes, declarations) },
|
|
623
|
+
peers: { ...startModesFromInstances, ...peerStartConfig(loadedConfig.peers, declarations) },
|
|
624
|
+
...(extraIds.length ? { permission_default_sources: { ...instancePermissionSources, ...loadedConfig.permission_default_sources } } : {}),
|
|
625
|
+
roles, conductor: { ...DEFAULT_CONFIG.conductor, ...loadedConfig.conductor } };
|
|
626
|
+
// The resolver and catalogs are bound to this daemon, never published into the process-global builtin registry.
|
|
627
|
+
const definitionOf = (peer: string) => instanceDefinitionOf(peer, declarations);
|
|
628
|
+
const KNOWN_PEERS: readonly string[] = [...BASE_KNOWN_PEERS, ...extraIds];
|
|
629
|
+
const SUPPORTED_PEERS: readonly string[] = [...BASE_SUPPORTED_PEERS, ...extraIds];
|
|
630
|
+
const STARTABLE_PEERS: readonly string[] = [...BASE_STARTABLE_PEERS, ...extraIds];
|
|
631
|
+
const isSupportedPeer = (peer: unknown): peer is string => typeof peer === "string" && definitionOf(peer) !== undefined;
|
|
632
|
+
const isStartablePeer = (peer: unknown): peer is string => typeof peer === "string" && definitionOf(peer)?.start.conductorStartable === true;
|
|
633
|
+
const RESERVED_IDS = new Set([...BASE_RESERVED_IDS, ...extraIds]);
|
|
634
|
+
const SETTINGS = projectSettings(declarations);
|
|
635
|
+
const settingDef = (key: unknown) => instanceSettingDef(key, declarations);
|
|
636
|
+
const accountOf = (peer: string) => definitionOf(peer)?.budget?.account ?? peer;
|
|
637
|
+
const accountMembers = (account: string) => SUPPORTED_PEERS.filter(peer => accountOf(peer) === account);
|
|
574
638
|
let rosterChanging: string | undefined;
|
|
575
639
|
let kimiSessionMode: StartMode | undefined;
|
|
576
640
|
let kimiAdmissionId: string | undefined;
|
|
577
641
|
let kimiAdmissionExpiry: ReturnType<typeof setTimeout> | undefined;
|
|
578
642
|
const clearKimiAdmission = () => { clearTimeout(kimiAdmissionExpiry); kimiAdmissionExpiry = undefined; kimiSessionMode = undefined; kimiAdmissionId = undefined; };
|
|
579
|
-
const onRoster = (peer: string) => rosterAllows(config.roster, peer, config.pi.enabled);
|
|
643
|
+
const onRoster = (peer: string) => rosterAllows(config.roster, peer, config.pi.enabled, declarations);
|
|
580
644
|
// pi.enabled historically gates starts, not retained queues or tools sockets. Only an explicit roster.pi changes those.
|
|
581
645
|
const membershipAllowed = (peer: string) => definitionOf(peer)?.enabledSwitchStartOnly && config.roster[peer] === undefined ? true : onRoster(peer);
|
|
582
646
|
conductorPeer(config.roles); // reject ambiguous leadership before opening a control listener
|
|
@@ -621,6 +685,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
621
685
|
settlePlanner();
|
|
622
686
|
const catalog = loadRoleCatalog(opts.cwd);
|
|
623
687
|
for (const error of catalog.errors) log(`catalog role ${error.id}: ${error.reason}`);
|
|
688
|
+
for (const note of lifecycleConfig.deprecations ?? []) log(note);
|
|
624
689
|
const resolvedProfile = resolveProfile(config.profile, config.profile_overrides);
|
|
625
690
|
let roleAssignments = { ...resolvedProfile.catalog_roles, ...catalogAssignments(config.catalog_roles) };
|
|
626
691
|
const event = eventLog(join(opts.stateDir, "events.jsonl"), (entry) => {
|
|
@@ -1036,7 +1101,29 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1036
1101
|
|
|
1037
1102
|
const omni = new OmniRoute(config.omniroute, log);
|
|
1038
1103
|
let modelRelay: ModelRelay | undefined;
|
|
1039
|
-
|
|
1104
|
+
const piReceiptLedgers = new Map<string, PiToolReceipts>();
|
|
1105
|
+
const modelRelays = new Map<string, ModelRelay>();
|
|
1106
|
+
const globalModelRelay = (relay: ModelRelay) => { modelRelay = relay; };
|
|
1107
|
+
const nativeStateDir = (peer: string) => declarations[peer] ? join(opts.stateDir, "instances", peer) : opts.stateDir;
|
|
1108
|
+
const instanceAppPorts = new Map<string, number>();
|
|
1109
|
+
const instanceAppPort = (peer: string): number => {
|
|
1110
|
+
const prior = instanceAppPorts.get(peer);
|
|
1111
|
+
if (prior !== undefined) return prior;
|
|
1112
|
+
// The native server must bind its selected port itself. A competing bind is refused by its existing ownership probe.
|
|
1113
|
+
const held: { stop(closeActiveConnections?: boolean): void }[] = [];
|
|
1114
|
+
try {
|
|
1115
|
+
for (let attempt = 0; attempt <= instanceAppPorts.size + 2; attempt++) {
|
|
1116
|
+
const listener = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
|
|
1117
|
+
held.push(listener);
|
|
1118
|
+
const port = listener.port;
|
|
1119
|
+
if (port !== undefined && port !== opts.codexAppPort && port !== opts.codexProxyPort && ![...instanceAppPorts.values()].includes(port)) {
|
|
1120
|
+
instanceAppPorts.set(peer, port); return port;
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
throw new Error("no distinct instance app-server port available");
|
|
1124
|
+
} finally { for (const listener of held) listener.stop(true); }
|
|
1125
|
+
};
|
|
1126
|
+
const piCallsInFlight = () => [...piReceiptLedgers.values()].reduce((n, ledger) => n + ledger.inFlight, 0);
|
|
1040
1127
|
/**
|
|
1041
1128
|
* PII decisions need a positive answer about the path a hub-owned model call will really take: a gateway that
|
|
1042
1129
|
* answered and is not behind Access, and no sidecar that was generated against the off-campus URL. Unknown is no.
|
|
@@ -1086,7 +1173,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1086
1173
|
if (before && (before !== after || config.conductor.feed === "off")) { bus.revokeSupervision(before); supervision.resetPending(before); budgetFeedSeen.clear(); }
|
|
1087
1174
|
if (config.conductor.feed === "off" && after) bus.revokeSupervision(after);
|
|
1088
1175
|
};
|
|
1089
|
-
const executionBudget = new ExecutionBudget(join(opts.stateDir, "hub.db"));
|
|
1176
|
+
const executionBudget = new ExecutionBudget(join(opts.stateDir, "hub.db"), declarations);
|
|
1090
1177
|
startupCleanup.push(() => executionBudget.close());
|
|
1091
1178
|
// Completion checks (issue #7): only from a config file git does not track, one at a time, killed on stop.
|
|
1092
1179
|
const allowedChecks = new Set([...CLASSES, ...Object.keys(resolvedProfile.classes)]);
|
|
@@ -1191,6 +1278,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1191
1278
|
};
|
|
1192
1279
|
const tasks: Tasks = new Tasks({
|
|
1193
1280
|
capabilities: peerCapabilities, repository: memoryRepo,
|
|
1281
|
+
instances: () => declarations,
|
|
1282
|
+
// Proof belongs to the adapter generation actually started in the validated root. A role-only
|
|
1283
|
+
// reload cannot turn a canonical reviewer process into an isolated implementation owner.
|
|
1284
|
+
ownerIsolation: () => Object.fromEntries(extraIds.map(id => {
|
|
1285
|
+
const bound = executionOwners.get(id);
|
|
1286
|
+
return [id, !!bound && bound.owner === bus.peers.get(id) && bound.cwd !== opts.cwd && bound.cwd === executionRoots.get(id)];
|
|
1287
|
+
})),
|
|
1194
1288
|
recordPlanning: change => event({ type: "planning", ...change }),
|
|
1195
1289
|
wipLimit: peer => Object.hasOwn(config.tasks.wip_limits ?? {}, peer) ? config.tasks.wip_limits![peer]! : 1,
|
|
1196
1290
|
escalateAfter: () => config.tasks.escalate_after ?? ESCALATE_AFTER,
|
|
@@ -1247,7 +1341,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1247
1341
|
// The PII screen (issue #198): the model on this machine when MLX/Ollama is enabled, else the gateway on campus only.
|
|
1248
1342
|
piiScreen: (text) => screenPii(text, { ...(config.mlx.enabled === false ? {} : { device: () => ensureMlx(config.mlx) }), onCampus, omni, fixedModel: () => activeRouting().local.fixed_model }),
|
|
1249
1343
|
recordScreen: (record) => event({ type: "pii_screen", ...record }),
|
|
1250
|
-
quota: (): ReturnType<Budget["headroom"]> => budget.headroom(), // budget is built below; this runs at assignment time
|
|
1344
|
+
quota: (): ReturnType<Budget["headroom"]> => projectBudgetHeadroom(budget.headroom(), accountMembers), // budget is built below; this runs at assignment time
|
|
1251
1345
|
review: config.review,
|
|
1252
1346
|
sweep: taskSweepConfig(config.task_sweep),
|
|
1253
1347
|
sweepHeld: () => stopping || recoveryActive(),
|
|
@@ -1264,12 +1358,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1264
1358
|
integrationFacts: (peer) => {
|
|
1265
1359
|
if (!factsOn()) return undefined;
|
|
1266
1360
|
let offered: ReturnType<Facts["due"]>;
|
|
1267
|
-
try { offered =
|
|
1361
|
+
try { offered = factsFor(peer).due(peer, undefined, true); } catch (error) { log(`integration facts for ${peer}: ${(error as Error).message}`); return undefined; }
|
|
1268
1362
|
if (offered) event({ type: "fact", peer, id: offered.id, files: offered.files, plans: offered.plans, unknown: offered.unknown, named: offered.named, bytes: offered.bytes, via: "done" });
|
|
1269
1363
|
return offered ? { id: offered.id, text: offered.text } : undefined;
|
|
1270
1364
|
},
|
|
1271
1365
|
ackFacts: (peer, id) => acked(peer, id, "done"),
|
|
1272
|
-
factsCurrent: (peer) => !factsOn() ||
|
|
1366
|
+
factsCurrent: (peer) => !factsOn() || factsFor(peer).current(peer),
|
|
1273
1367
|
splitProfile: (peer) => splitProfile(peer),
|
|
1274
1368
|
recordCohort: (c) => event({ type: "cohort", ...c }),
|
|
1275
1369
|
// The trace holds peer names and numbers only (never task text): it is the inputs a later check of the prediction needs.
|
|
@@ -1314,6 +1408,34 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1314
1408
|
const repositoryFacts = !!factsRepo || profileName(config.profile) === "dev";
|
|
1315
1409
|
if (!factsRepo) log(repositoryFacts ? "project has no Git or shadow repository; snapshots/undo and Git directory facts are unavailable; dev named-file facts remain (opt in with ahub init --shadow)" : "project has no Git or shadow repository; facts, snapshots and undo are off (opt in with ahub init --shadow)");
|
|
1316
1410
|
const facts: Facts = new Facts({ root: opts.cwd, repo: factsRepo, tmp: join(opts.stateDir, "facts"), instance: instanceId.slice(0, 8), scope: (peer) => repositoryFacts ? (scopes.has(peer) ? scopes.get(peer) : scopes.set(peer, tasks.factScope(peer)).get(peer)) : undefined, peers: () => [...bus.peers.keys()], nameable: tasks.nameable, deny: config.local.deny, privatePath: path => matchesPolicy(opts.cwd, path, config.pii?.paths ?? []), projection: file => projections.project(file) });
|
|
1411
|
+
const worktreeFacts = new Map<string, Facts>();
|
|
1412
|
+
const worktreeProjections = new Map<string, TextProjections>();
|
|
1413
|
+
const executionRoot = (peer: string) => executionRoots.get(peer) ?? opts.cwd;
|
|
1414
|
+
const repoFor = (peer: string) => executionRoot(peer) === opts.cwd ? factsRepo : repoOf(executionRoot(peer));
|
|
1415
|
+
const projectionsFor = (peer: string): TextProjections => {
|
|
1416
|
+
const root = executionRoot(peer);
|
|
1417
|
+
if (root === opts.cwd) return projections;
|
|
1418
|
+
let projection = worktreeProjections.get(peer);
|
|
1419
|
+
if (!projection) {
|
|
1420
|
+
projection = new TextProjections({ cwd: root, projectRoot: opts.cwd, stateDir: nativeStateDir(peer), config: config.text_projection ?? {}, deny: config.local.deny, readAllow: config.local.read_allow, log });
|
|
1421
|
+
worktreeProjections.set(peer, projection);
|
|
1422
|
+
}
|
|
1423
|
+
return projection;
|
|
1424
|
+
};
|
|
1425
|
+
const factsFor = (peer: string): Facts => {
|
|
1426
|
+
const root = executionRoot(peer);
|
|
1427
|
+
if (root === opts.cwd) return facts;
|
|
1428
|
+
let store = worktreeFacts.get(peer);
|
|
1429
|
+
if (!store) {
|
|
1430
|
+
const projection = projectionsFor(peer);
|
|
1431
|
+
store = new Facts({ root, repo: repoFor(peer), tmp: join(nativeStateDir(peer), "facts"), instance: `${instanceId.slice(0, 8)}-${peer}`,
|
|
1432
|
+
scope: id => scopes.has(id) ? scopes.get(id) : scopes.set(id, tasks.factScope(id)).get(id),
|
|
1433
|
+
peers: () => [...bus.peers.keys()].filter(id => executionRoot(id) === root), nameable: tasks.nameable, deny: config.local.deny,
|
|
1434
|
+
privatePath: path => matchesPolicy(root, path, config.pii?.paths ?? []), projection: file => projection.project(file) });
|
|
1435
|
+
worktreeFacts.set(peer, store);
|
|
1436
|
+
}
|
|
1437
|
+
return store;
|
|
1438
|
+
};
|
|
1317
1439
|
const progress = new ProgressObserver({
|
|
1318
1440
|
tasks: () => board.list(),
|
|
1319
1441
|
isPrivate: () => [...turns.values()].some(turn => turn.private) || board.list().some(task => !isClosedTaskState(task.state) && tasks.isPii(task)),
|
|
@@ -1333,7 +1455,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1333
1455
|
let factsWereOn = false;
|
|
1334
1456
|
const factsOn = (): boolean => {
|
|
1335
1457
|
const on = repositoryFacts && turnFree();
|
|
1336
|
-
if (on && !factsWereOn) facts.reset();
|
|
1458
|
+
if (on && !factsWereOn) { facts.reset(); for (const store of worktreeFacts.values()) store.reset(); }
|
|
1337
1459
|
// A PII task opened: every silent cohort speaks again, for good, and its members hear so at once (issue #108).
|
|
1338
1460
|
if (!on && factsWereOn) for (const cohort of tasks.cohorts.liftAll()) tasks.announceLift(cohort, "a private task opened, and facts are off while it is open");
|
|
1339
1461
|
factsWereOn = on;
|
|
@@ -1351,10 +1473,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1351
1473
|
probes.delete(peer);
|
|
1352
1474
|
}
|
|
1353
1475
|
factSessions.set(peer, id);
|
|
1354
|
-
|
|
1476
|
+
factsFor(peer).session(peer, id);
|
|
1355
1477
|
};
|
|
1356
1478
|
const acked = (peer: PeerId, id: string, via: string) => {
|
|
1357
|
-
const got =
|
|
1479
|
+
const got = factsFor(peer).ack(peer, id);
|
|
1358
1480
|
if (!got) return;
|
|
1359
1481
|
event({ type: "fact_ack", peer, id, via, ms: Date.now() - got.at });
|
|
1360
1482
|
if (via !== "done" && !capable.has(peer)) {
|
|
@@ -1374,7 +1496,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1374
1496
|
* integration request wait for the next done, not a readback, so they never count.
|
|
1375
1497
|
*/
|
|
1376
1498
|
const checkCapability = (peer: PeerId) => {
|
|
1377
|
-
if (capable.has(peer) &&
|
|
1499
|
+
if (capable.has(peer) && factsFor(peer).pending(peer).filter((o) => !o.done && Date.now() - o.at > 60_000).length >= 3) {
|
|
1378
1500
|
capable.delete(peer);
|
|
1379
1501
|
loseCapability(peer, "three facts found no readback");
|
|
1380
1502
|
}
|
|
@@ -1386,11 +1508,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1386
1508
|
*/
|
|
1387
1509
|
const offerFor = (peer: PeerId, toolUseId?: string) => {
|
|
1388
1510
|
if (!repositoryFacts) return undefined;
|
|
1389
|
-
if (!capable.has(peer) &&
|
|
1390
|
-
const due =
|
|
1391
|
-
if (due || capable.has(peer) ||
|
|
1511
|
+
if (!capable.has(peer) && factsFor(peer).pending(peer).filter((o) => !o.done).length >= 3) return undefined;
|
|
1512
|
+
const due = factsFor(peer).due(peer, toolUseId);
|
|
1513
|
+
if (due || capable.has(peer) || factsFor(peer).pending(peer).some((o) => o.probe) || (probes.get(peer) ?? 0) >= 3) return due;
|
|
1392
1514
|
probes.set(peer, (probes.get(peer) ?? 0) + 1);
|
|
1393
|
-
return
|
|
1515
|
+
return factsFor(peer).probe(peer, toolUseId);
|
|
1394
1516
|
};
|
|
1395
1517
|
// Claude's readback: the transcript row Claude Code writes for a hook's additional context, by tool use id.
|
|
1396
1518
|
const injected = new Map<string, string>();
|
|
@@ -1436,7 +1558,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1436
1558
|
} catch { return undefined; }
|
|
1437
1559
|
};
|
|
1438
1560
|
const readbacks = (peer: PeerId, transcript: string | undefined) => {
|
|
1439
|
-
const pending =
|
|
1561
|
+
const pending = factsFor(peer).pending(peer).filter((o) => o.toolUseId);
|
|
1440
1562
|
if (!pending.length || !transcript) return;
|
|
1441
1563
|
readTranscript(transcript);
|
|
1442
1564
|
for (const o of pending) if (injected.get(o.toolUseId!)?.includes(`[${o.id}]`)) acked(peer, o.id, "hook");
|
|
@@ -1498,7 +1620,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1498
1620
|
if (p instanceof AcpPeer || p instanceof PiPeer) { const id = p.recoveryMetadata().sessionId; return typeof id === "string" ? id : undefined; }
|
|
1499
1621
|
return undefined;
|
|
1500
1622
|
};
|
|
1501
|
-
const activityRings: Record<string, ActivityRing> = Object.fromEntries(
|
|
1623
|
+
const activityRings: Record<string, ActivityRing> = Object.fromEntries(SUPPORTED_PEERS.filter(peer => definitionOf(peer)?.capabilities?.watch).map(peer => [peer, new ActivityRing()]));
|
|
1502
1624
|
const contextPii = (peer: PeerId) => !!turns.get(peer)?.private || board.list().some(t => !isClosedTaskState(t.state) && (t.owner === peer || t.reviewer === peer) && tasks.isPii(t));
|
|
1503
1625
|
const requestCheckpoint = (peer: PeerId, reason: "quota" | "context"): Promise<string | undefined> => {
|
|
1504
1626
|
// One outstanding request per peer. A quota transition may proceed without a summary rather than steal a context waiter.
|
|
@@ -1546,21 +1668,21 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1546
1668
|
if (budgetFeedSeen.get(hold.peer) !== key && supervision.budgetPaused(hold.peer, hold.resetsAt)) budgetFeedSeen.set(hold.peer, key);
|
|
1547
1669
|
}
|
|
1548
1670
|
};
|
|
1549
|
-
const budget = new Budget(join(opts.stateDir, "hub.db"), config.budget, {
|
|
1671
|
+
const budget = new Budget(join(opts.stateDir, "hub.db"), config.budget, instanceBudgetHooks({
|
|
1550
1672
|
pause: (peer) => bus.pause(peer),
|
|
1551
1673
|
resume: (peer) => {
|
|
1552
1674
|
if (!manualPaused.has(peer) && !conductorHolds.has(peer)) bus.resume(peer);
|
|
1553
1675
|
},
|
|
1554
1676
|
requestCheckpoint: (peer) => requestCheckpoint(peer, "quota"),
|
|
1555
1677
|
platformContext: async (peer) => {
|
|
1556
|
-
const platform = PLATFORM[peer];
|
|
1678
|
+
const platform = PLATFORM[definitionOf(peer)?.kind ?? peer];
|
|
1557
1679
|
if (!config.memory.enabled || !platform) return undefined;
|
|
1558
1680
|
const text = await memory.contextInject(chain, platform);
|
|
1559
1681
|
return text?.startsWith("# [") ? `Recent ${platform} sessions in this project, from shared memory (the peer left no checkpoint):\n${trimToTokens(text.slice(Math.max(text.indexOf("\n### "), 0)).trim(), 800)}` : undefined;
|
|
1560
1682
|
},
|
|
1561
1683
|
attached: (peer) => bus.peers.has(peer),
|
|
1562
1684
|
// Somebody other than the paused peer has to be there, or the handoff would only leave its tasks without an owner.
|
|
1563
|
-
canHandOff: (peer) => [...bus.peers.keys()].some((id) => id !== peer && ["idle", "busy"].includes(bus.stateOf(id))),
|
|
1685
|
+
canHandOff: (peer) => [...bus.peers.keys()].some((id) => accountOf(id) !== accountOf(peer) && ["idle", "busy"].includes(bus.stateOf(id))),
|
|
1564
1686
|
// A reading that arrived through a file carries the file's time; a stale one must not look fresh in the export.
|
|
1565
1687
|
reading: (peer, windows, hard, at) => event({ type: "quota", peer, windows: windows.map((w) => ({ id: w.id, used: w.used, ...(w.resetsAt ? { resetsAt: w.resetsAt } : {}) })), hard, ...(Math.abs(Date.now() - at) > 1000 ? { measuredAt: new Date(at).toISOString() } : {}) }),
|
|
1566
1688
|
handoff: (peer, context, urgentOnly) => tasks.reassignForPause(peer, context, urgentOnly),
|
|
@@ -1586,7 +1708,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1586
1708
|
for (const id of superseded) bus.withdraw(id);
|
|
1587
1709
|
},
|
|
1588
1710
|
notify: (line) => { notify(line); feedBudgetPauses(); },
|
|
1589
|
-
});
|
|
1711
|
+
}, accountMembers, accountOf));
|
|
1590
1712
|
budget.setRecoveryHold(recoveryActive());
|
|
1591
1713
|
const kimiTokens: { at: number; n: number }[] = [];
|
|
1592
1714
|
startupCleanup.push(() => budget.close());
|
|
@@ -1646,7 +1768,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1646
1768
|
* runs inside the state change): three git calls at a turn's start and four at its end, each with the 10 s timeout
|
|
1647
1769
|
* in snapshots.ts.
|
|
1648
1770
|
*/
|
|
1649
|
-
const snap = (what: string, capture = config.snapshots.enabled): { tree?: string; ms: number } => {
|
|
1771
|
+
const snap = (what: string, capture = config.snapshots.enabled, peer?: string): { tree?: string; ms: number } => {
|
|
1772
|
+
const repo = peer ? repoFor(peer) : factsRepo;
|
|
1650
1773
|
if (!capture) return { ms: 0 };
|
|
1651
1774
|
const t0 = performance.now();
|
|
1652
1775
|
let tree: string | undefined;
|
|
@@ -1662,18 +1785,26 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1662
1785
|
return [...turns.values()].some(turn => turn.private) || board.list().some(task => !isClosedTaskState(task.state) && tasks.isPii(task));
|
|
1663
1786
|
}
|
|
1664
1787
|
function reviewProjection(task: Task): string | undefined {
|
|
1788
|
+
const owner = task.owner ?? "", root = executionRoot(owner);
|
|
1789
|
+
const repo = repoFor(owner), projections = projectionsFor(owner);
|
|
1665
1790
|
if (!projections.enabled || tasks.isPii(task)) return undefined;
|
|
1666
1791
|
if (!config.snapshots.enabled || !repo || !turnLog) return "(projected review unavailable: snapshots are disabled or unavailable)";
|
|
1667
1792
|
if (privateSnapshotWindow()) return "(projected review unavailable: private snapshot window)";
|
|
1668
1793
|
const created = taskProposalAt(task);
|
|
1669
1794
|
const from = created === undefined ? undefined : turnLog.taskStart(task.id, created);
|
|
1670
1795
|
if (!from || !hasTree(repo, from)) return "(projected review unavailable: original task snapshot is unknown)";
|
|
1671
|
-
const scopes = [...(task.refs.paths ?? []), ...(task.plan?.paths ?? []), ...(task.plan?.sections ?? []).map(anchor => anchor.split("#")[0]!)].map(path =>
|
|
1796
|
+
const scopes = [...(task.refs.paths ?? []), ...(task.plan?.paths ?? []), ...(task.plan?.sections ?? []).map(anchor => anchor.split("#")[0]!)].map(path => {
|
|
1797
|
+
const scoped = relative(root, resolve(root, path));
|
|
1798
|
+
if (root === opts.cwd || scoped !== ".." && !scoped.startsWith("../")) return scoped;
|
|
1799
|
+
// Task refs may name the canonical copy of this repository file while the owner executes its worktree copy.
|
|
1800
|
+
const canonical = relative(opts.cwd, resolve(root, path));
|
|
1801
|
+
return canonical !== ".." && !canonical.startsWith("../") && !canonical.startsWith(".worktrees/") ? canonical : scoped;
|
|
1802
|
+
});
|
|
1672
1803
|
if (!scopes.length) return "(projected review unavailable: task paths were not declared)";
|
|
1673
|
-
const to = snap(`task #${task.id} review
|
|
1804
|
+
const to = snap(`task #${task.id} review`, config.snapshots.enabled, task.owner ?? undefined).tree;
|
|
1674
1805
|
if (!to) return "(projected review unavailable: current snapshot is unknown)";
|
|
1675
1806
|
const paths = changedPaths(repo, from, to).filter(path => { const local = path.slice(repo.prefix.length); return scopes.some(scope => !scope || local === scope || local.startsWith(scope + "/")); });
|
|
1676
|
-
return projectedDiff({ repo, from, to, paths, projections, deny: config.local.deny, nameable: tasks.nameable, privatePath: path => matchesPolicy(
|
|
1807
|
+
return projectedDiff({ repo, from, to, paths, projections, deny: config.local.deny, nameable: tasks.nameable, privatePath: path => matchesPolicy(root, join(repo.top, path), config.pii?.paths ?? []) });
|
|
1677
1808
|
}
|
|
1678
1809
|
const delta = tokenDeltas();
|
|
1679
1810
|
/** Tokens a peer used, as increments: Kimi reports a session total (turned into increments below), Codex increments. */
|
|
@@ -1687,14 +1818,14 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1687
1818
|
}
|
|
1688
1819
|
return n;
|
|
1689
1820
|
};
|
|
1690
|
-
const onKimiTokens = (total: number, session: string) => {
|
|
1691
|
-
const n = addTokens(
|
|
1821
|
+
const onKimiTokens = (total: number, session: string, peer = "kimi") => {
|
|
1822
|
+
const n = addTokens(peer, delta(peer, total, session));
|
|
1692
1823
|
if (!config.budget.kimi_tokens_5h || !n) return;
|
|
1693
1824
|
const now = Date.now();
|
|
1694
1825
|
kimiTokens.push({ at: now, n });
|
|
1695
1826
|
while (kimiTokens.length && now - kimiTokens[0]!.at > 5 * 3_600_000) kimiTokens.shift();
|
|
1696
1827
|
const used = kimiTokens.reduce((s, t) => s + t.n, 0) / config.budget.kimi_tokens_5h;
|
|
1697
|
-
budget.report(
|
|
1828
|
+
budget.report(peer, [{ id: "tokens", used, resetsAt: kimiTokens[0]!.at + 5 * 3_600_000, source: "kimi usage_update (soft limit)" }]);
|
|
1698
1829
|
};
|
|
1699
1830
|
// Claude's numbers arrive through the status line tee `ahub claude` installs (src/cli/statusline-tee.ts).
|
|
1700
1831
|
let claudeUsageSeen = 0;
|
|
@@ -1985,7 +2116,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1985
2116
|
const t = await tasks.propose(by, a);
|
|
1986
2117
|
if (t.state === "backlog") return line(t);
|
|
1987
2118
|
const overlap = tasks.overlaps(t, t.owner === by);
|
|
1988
|
-
if (t.owner === by && tasks.silentFor(t.id) && factsOn())
|
|
2119
|
+
if (t.owner === by && tasks.silentFor(t.id) && factsOn()) factsFor(by).sawPlans(by, tasks.overlapTasks(t)); // as for an accept
|
|
1989
2120
|
return [line(t), overlap, tasks.claimRoleGuidance(t, by), t.owner === by ? tasks.planGuardText(t) : ""].filter(Boolean).join("\n");
|
|
1990
2121
|
}
|
|
1991
2122
|
case "hub_task_accept": {
|
|
@@ -1993,7 +2124,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1993
2124
|
// A silent cohort (issue #107): the owners' plans come with every accept, as what the newcomer works from.
|
|
1994
2125
|
const silent = tasks.silentFor(t.id);
|
|
1995
2126
|
const overlap = a.plan == null && !silent ? "" : tasks.overlaps(t);
|
|
1996
|
-
if (silent && factsOn())
|
|
2127
|
+
if (silent && factsOn()) factsFor(by).sawPlans(by, tasks.overlapTasks(t)); // the plans in this answer need no fact later
|
|
1997
2128
|
return [line(t), overlap, tasks.planGuardText(t)].filter(Boolean).join("\n");
|
|
1998
2129
|
}
|
|
1999
2130
|
case "hub_task_cancel": {
|
|
@@ -2123,6 +2254,14 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2123
2254
|
if (turnLog!.latest(turn.peer)?.id !== turn.id) throw new Error(`turn ${turn.id} is not Codex's latest turn; reverting it would drop the later ones too`);
|
|
2124
2255
|
const codex = bus.peers.get(turn.peer);
|
|
2125
2256
|
if (!(codex instanceof CodexPeer)) throw new Error("codex is not attached");
|
|
2257
|
+
const workspace = turnLog!.workspace(turn.id);
|
|
2258
|
+
if (workspace !== undefined) {
|
|
2259
|
+
const current = readInstanceRoleConfig(opts.cwd), adapter = instanceDefinitionOf(turn.peer, current.instances);
|
|
2260
|
+
if (!current.instances?.[turn.peer] || adapter?.kind !== definitionOf(turn.peer)?.kind) throw new Error("turn workspace declaration changed; inspect before reverting");
|
|
2261
|
+
const validated = resolveInstanceExecutionCwd(opts.cwd, turn.peer, current.instances, effectiveInstanceRoles(turn.peer, current.instances, current.roles));
|
|
2262
|
+
const bound = executionOwners.get(turn.peer);
|
|
2263
|
+
if (validated !== workspace || executionRoot(turn.peer) !== workspace || bound?.owner !== codex || bound.cwd !== workspace) throw new Error("turn workspace differs from the current native owner; inspect before reverting");
|
|
2264
|
+
}
|
|
2126
2265
|
// Nothing may reach Codex while its history is rewritten: a turn started meanwhile would go with the reverted ones.
|
|
2127
2266
|
const held = bus.stateOf(turn.peer) !== "paused";
|
|
2128
2267
|
if (held) bus.pause(turn.peer);
|
|
@@ -2187,7 +2326,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2187
2326
|
};
|
|
2188
2327
|
let releasing = false; // gone-owner release (#6) and the ready sweep (#34): one run at a time, and a recovery commit waits for it
|
|
2189
2328
|
const recoveryReady = () => {
|
|
2190
|
-
if (!recoveryActive() || releasing || taskOpsInFlight !== 0 || tasks.checksPending() !== 0 || (
|
|
2329
|
+
if (!recoveryActive() || releasing || taskOpsInFlight !== 0 || tasks.checksPending() !== 0 || piCallsInFlight() !== 0 || permissions.size !== 0 || starting.size !== 0 || requestedPeerStops.size !== 0 || rosterChanging !== undefined || !budget.recoverySettled || [...bus.peers.values()].some((peer) => peer.state === "busy" || (peer instanceof PiPeer && !peer.recoveryReady))) return false;
|
|
2191
2330
|
if (checkPending.size !== 0) return false;
|
|
2192
2331
|
if (!recoveryPeerSnapshot) return true;
|
|
2193
2332
|
const current = recoveryPeers();
|
|
@@ -2368,10 +2507,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2368
2507
|
const mode = permissionDisplay(id, peer);
|
|
2369
2508
|
return mode === "ask" ? {} : { permissionMode: mode };
|
|
2370
2509
|
};
|
|
2371
|
-
|
|
2510
|
+
const lastCodexThreads = new Map<string, string>();
|
|
2372
2511
|
const statusPeerIds = (open: ReturnType<Board["statusTasks"]>) => {
|
|
2373
2512
|
const reservations = board.statusReservations();
|
|
2374
|
-
const ids = new Set([...bus.knownPeers(), ...reservations]);
|
|
2513
|
+
const ids = new Set([...bus.knownPeers(), ...extraIds, ...reservations]);
|
|
2375
2514
|
if (!Object.keys(config.roster).some(peer => !membershipAllowed(peer))) return [...ids];
|
|
2376
2515
|
for (const task of open) for (const peer of [task.owner, task.reviewer]) if (peer && !membershipAllowed(peer)) ids.add(peer);
|
|
2377
2516
|
return [...ids].filter(peer => membershipAllowed(peer) || reservations.includes(peer) || bus.queued(peer) > 0 || (() => { const summary = bus.queueSummary(peer); return summary.needsReview > 0 || !!summary.liveAccepted?.length; })() || open.some(task => task.owner === peer || task.reviewer === peer));
|
|
@@ -2389,6 +2528,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2389
2528
|
pendingRestart,
|
|
2390
2529
|
memoryProfile: memoryProfileStatus(includeAttentionItems),
|
|
2391
2530
|
lifecycleStages: [...lifecycleConfig.stages],
|
|
2531
|
+
// ponytail: off keeps the #257 phase schema until RETIRED_WORKFLOW_FROM, when the alias and this flag go together.
|
|
2532
|
+
...(lifecycleConfig.mode !== "off" ? { lifecycleCriteria: true } : {}),
|
|
2533
|
+
// Every supported peer's membership, for launchers that run outside the console role (#484).
|
|
2534
|
+
roster: [...KNOWN_PEERS, ...OPTIONAL_PEERS].map(peer => ({ peer, on: onRoster(peer) })),
|
|
2392
2535
|
protocol: PROTOCOL,
|
|
2393
2536
|
features: DAEMON_FEATURES,
|
|
2394
2537
|
stopping,
|
|
@@ -2398,7 +2541,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2398
2541
|
...(dashboard ? { uiOrigin: dashboard.origin } : {}),
|
|
2399
2542
|
codexProxyPort: opts.codexProxyPort,
|
|
2400
2543
|
peers: Object.fromEntries(
|
|
2401
|
-
peerIds.map((id) => { const p = bus.peers.get(id); const summary = bus.queueSummary(id); const context = contexts.view(id, contextSession(id), !!p && ["idle", "busy", "paused"].includes(bus.stateOf(id))); return [id, { state: bus.stateOf(id), ...(!membershipAllowed(id) ? { rostered: false, rosterNote: "off roster" } : {}), ...permissionStatus(id, p), ...(context.source !== null || context.measuredAt !== null ? { context } : {}), queued: bus.queued(id), ...(p ? {} : { attached: false }), ...(summary.needsReview ? { needsReview: summary.needsReview } : {}), ...(summary.liveAccepted ? { liveAccepted: summary.liveAccepted, settlementNote: "awaiting adapter completion (Claude: correlated reply or hub_delivery_done); task state is independent" } : {}), ...queueHoldStatus(id, summary.heldBy), ...(summary.oldestQueuedAt !== undefined ? { oldestQueuedAt: summary.oldestQueuedAt } : {}), ...(bus.queuedImportant(id) ? { queuedImportant: bus.queuedImportant(id) } : {}), ...pausedNote(id), ...(p instanceof LocalPeer && p.lastServedBy ? { servedBy: p.lastServedBy } : {}), ...(p instanceof WsPeer && p.claiming ? { claiming: true } : {}), ...(p instanceof WsPeer && p.pullOnly ? { pullOnly: true, toolsOnly: definitionOf(id)?.attach?.pullOnlyNotice ?? "tools-only: messages wait for hub_inbox; for pushes restart Claude with ahub claude", lastInboxAt: p.lastInboxAt ?? null } : {}), ...(p instanceof PiPeer ? { requestedModel: p.getRequestedModel(), backends:
|
|
2544
|
+
peerIds.map((id) => { const p = bus.peers.get(id); const summary = bus.queueSummary(id); const context = contexts.view(id, contextSession(id), !!p && ["idle", "busy", "paused"].includes(bus.stateOf(id))); return [id, { state: bus.stateOf(id), ...(!membershipAllowed(id) ? { rostered: false, rosterNote: "off roster" } : {}), ...permissionStatus(id, p), ...(context.source !== null || context.measuredAt !== null ? { context } : {}), queued: bus.queued(id), ...(p ? {} : { attached: false }), ...(summary.needsReview ? { needsReview: summary.needsReview } : {}), ...(summary.liveAccepted ? { liveAccepted: summary.liveAccepted, settlementNote: "awaiting adapter completion (Claude: correlated reply or hub_delivery_done); task state is independent" } : {}), ...queueHoldStatus(id, summary.heldBy), ...(summary.oldestQueuedAt !== undefined ? { oldestQueuedAt: summary.oldestQueuedAt } : {}), ...(bus.queuedImportant(id) ? { queuedImportant: bus.queuedImportant(id) } : {}), ...pausedNote(id), ...(p instanceof LocalPeer && p.lastServedBy ? { servedBy: p.lastServedBy } : {}), ...(p instanceof WsPeer && p.claiming ? { claiming: true } : {}), ...(p instanceof WsPeer && p.pullOnly ? { pullOnly: true, toolsOnly: definitionOf(id)?.attach?.pullOnlyNotice ?? "tools-only: messages wait for hub_inbox; for pushes restart Claude with ahub claude", lastInboxAt: p.lastInboxAt ?? null } : {}), ...(p instanceof PiPeer ? { requestedModel: p.getRequestedModel(), backends: modelRelays.get(id)?.status().backends ?? [] } : {}) }]; }),
|
|
2402
2545
|
),
|
|
2403
2546
|
taskWip: Object.fromEntries(peerIds.map(id => [id, { wip: tasks.wip(id), wipLimit: tasks.wipLimit(id) }])),
|
|
2404
2547
|
boardReadCapabilities: { releaseFilter: true, columnMetadata: true },
|
|
@@ -2669,11 +2812,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2669
2812
|
const lostCount = [...lost.values()].reduce((n, l) => n + l.length, 0);
|
|
2670
2813
|
report(`the previous hub run stopped without shutting down${lostCount ? `; ${lostCount} deliveries it had in flight are in needs_review (ahub queue list)` : ""}`);
|
|
2671
2814
|
const start = (peer: string, args: Parameters<typeof startPeer>[1]) => startPeer(peer, args).catch((e: Error) => ({ ok: false, error: e.message }));
|
|
2672
|
-
for (const step of crashPlan(prev.peers)) {
|
|
2673
|
-
if (definitionOf(step.peer)?.session.restoreOnStart && archivedPiContinuity) { report("pi: archived continuity evidence holds crash-session resume and fresh fallback; pi.auto_start uses only the recorded archived session"); continue; }
|
|
2815
|
+
for (const step of crashPlan(prev.peers, declarations)) {
|
|
2816
|
+
if (!declarations[step.peer] && definitionOf(step.peer)?.session.restoreOnStart && archivedPiContinuity) { report("pi: archived continuity evidence holds crash-session resume and fresh fallback; pi.auto_start uses only the recorded archived session"); continue; }
|
|
2674
2817
|
// `pi.auto_start` already asks for Pi (#66): its recorded headless session comes back, auto-resume or not, and a
|
|
2675
2818
|
// fresh one starts when that fails or there is nothing headless to resume. Pi runs on-prem: no cloud quota.
|
|
2676
|
-
if (definitionOf(step.peer)?.session.restoreOnStart && piAutoStart()) {
|
|
2819
|
+
if (!declarations[step.peer] && definitionOf(step.peer)?.session.restoreOnStart && piAutoStart()) {
|
|
2677
2820
|
if (step.resume) {
|
|
2678
2821
|
const r = await start("pi", step.resume as Parameters<typeof startPeer>[1]);
|
|
2679
2822
|
if (r.ok) { report(`pi resumed (pi.auto_start): ${step.how}`); continue; }
|
|
@@ -2733,8 +2876,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2733
2876
|
for (const id of turnTasks.keys()) if (!turnLog?.get(id)) turnTasks.delete(id);
|
|
2734
2877
|
turnTasks.set(id, board.list().filter((t) => t.owner === e.peer && ["proposed", "in_progress", "changes_requested", "in_review"].includes(t.state)).map((t) => t.id));
|
|
2735
2878
|
const capture = config.snapshots.enabled && !!turnLog;
|
|
2736
|
-
const start = capture && !pii ? snap(`the start of ${id}
|
|
2737
|
-
try { if (capture) turnLog?.begin(id, e.peer, start?.tree); } catch (error) { log(`turn record ${id}: ${(error as Error).message}`); }
|
|
2879
|
+
const start = capture && !pii ? snap(`the start of ${id}`, config.snapshots.enabled, e.peer) : undefined; // before the peer is handed anything: the tap runs inside setState
|
|
2880
|
+
try { if (capture) turnLog?.begin(id, e.peer, start?.tree, declarations[e.peer] ? executionRoot(e.peer) : undefined); } catch (error) { log(`turn record ${id}: ${(error as Error).message}`); }
|
|
2738
2881
|
const preparedRoles = preparedSessionRoleIdentities.get(e.peer)?.map(role => ({ ...role })) ?? null;
|
|
2739
2882
|
turns.set(e.peer, { id, capture, start: Date.now(), tokens: 0, deliveryTask: delivery, attribution, preparedRoles, ...(start ? { tree: start.tree, snapshotMs: start.ms } : {}), ...(pii ? { private: true } : {}) });
|
|
2740
2883
|
event({ type: "turn_start", peer: e.peer, turn: id });
|
|
@@ -2746,8 +2889,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2746
2889
|
if (open.capture && turnLog && (open.private || holdsPii(e.peer))) {
|
|
2747
2890
|
try { turnLog.end(open.id, undefined, [], Math.max(1, Number(config.snapshots.keep) || 20)); } catch (error) { log(`turn record ${open.id}: ${(error as Error).message}`); }
|
|
2748
2891
|
} else if (open.capture && turnLog) {
|
|
2749
|
-
const end = snap(`the end of ${open.id}`, true);
|
|
2750
|
-
const changed = open.tree && end.tree ? changedPaths(
|
|
2892
|
+
const end = snap(`the end of ${open.id}`, true, e.peer);
|
|
2893
|
+
const changed = open.tree && end.tree ? changedPaths(repoFor(e.peer)!, open.tree, end.tree) : [];
|
|
2751
2894
|
if (open.tree && end.tree) files = changed.length; // unknown, not zero, when a snapshot failed
|
|
2752
2895
|
snapshotMs = (snapshotMs ?? 0) + end.ms;
|
|
2753
2896
|
try { turnLog.end(open.id, end.tree, changed, Math.max(1, Number(config.snapshots.keep) || 20)); } catch (error) { log(`turn record ${open.id}: ${(error as Error).message}`); }
|
|
@@ -2950,8 +3093,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2950
3093
|
const opener: TerminalOpener = opts.terminal?.(config.terminal.open) ?? { available: () => ({ ok: false, why: "this hub was started without a terminal provider" }), open: async () => ({ ok: false, why: "this hub was started without a terminal provider" }) };
|
|
2951
3094
|
const CLI_ENTRY = fileURLToPath(new URL("../cli/main.ts", import.meta.url));
|
|
2952
3095
|
/** `config.peers` follows a settings write (`settingsSet`), so a changed mode applies at the peer's next start. */
|
|
2953
|
-
const startMode = (peer: string): StartMode => startModeOf(config.peers, peer);
|
|
2954
|
-
const startWords = (peer: string, mode: StartMode): string[] =>
|
|
3096
|
+
const startMode = (peer: string): StartMode => startModeOf(config.peers, peer, declarations);
|
|
3097
|
+
const startWords = (peer: string, mode: StartMode): string[] => {
|
|
3098
|
+
const words = definitionOf(peer)?.cli.startWords(mode) ?? [peer];
|
|
3099
|
+
return declarations[peer] ? [words[0]!, "--as", peer, ...words.slice(1)] : words;
|
|
3100
|
+
};
|
|
2955
3101
|
const startable = onRoster;
|
|
2956
3102
|
// ponytail: one terminal per peer per 30 s, by the clock. The hub cannot see whether the terminal it opened still
|
|
2957
3103
|
// exists, so a peer that never attaches can be opened again after that; track the provider's handle to do better.
|
|
@@ -2960,7 +3106,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2960
3106
|
// ponytail: asks the provider on every dashboard poll (a launch-record read and a PATH lookup for Orca); cache it for a few seconds if a page feels it.
|
|
2961
3107
|
const startPlans = () => STARTABLE_PEERS.filter(startable).map((peer) => {
|
|
2962
3108
|
const mode = startMode(peer), owner = bus.peers.get(peer), can = mode === "tui" ? opener.available() : undefined;
|
|
2963
|
-
return { peer, mode, attached: !!owner && owner.state !== "offline", command: definitionOf(peer)?.start.planCommand?.(mode) ?? `ahub ${startWords(peer, mode).join(" ")}`, ...(can ? (can.ok ? { via: can.via } : { why: can.why }) : {}) };
|
|
3109
|
+
return { peer, mode, attached: !!owner && owner.state !== "offline", command: (!declarations[peer] ? definitionOf(peer)?.start.planCommand?.(mode) : undefined) ?? `ahub ${startWords(peer, mode).join(" ")}`, ...(can ? (can.ok ? { via: can.via } : { why: can.why }) : {}) };
|
|
2964
3110
|
});
|
|
2965
3111
|
// Printed for a person's explicit reconciliation only; never executed by auto-start.
|
|
2966
3112
|
const continuityCleanup = () => `After inspection, archive the active bookmark with ${["mv", "-n", "--", join(opts.stateDir, "recovery-continuity.json"), join(opts.stateDir, `recovery-continuity.retired.manual.${randomUUID()}.json`)].map(shellQuote).join(" ")} before choosing another session`;
|
|
@@ -3082,19 +3228,25 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3082
3228
|
async function startPeerOnce(peer: string, args: { model?: string; route?: string; mode?: "headless" | "tui"; backend?: "auto" | "dgx" | "mlx"; sessionId?: string; sessionFile?: string; replaceSession?: boolean; fresh?: boolean; unattended?: boolean; providerProfile?: string }): Promise<Record<string, unknown>> {
|
|
3083
3229
|
let unmute: (() => void) | undefined;
|
|
3084
3230
|
try {
|
|
3231
|
+
if (declarations[peer] && args.mode !== undefined && args.mode !== "headless") return { ok: false, error: "declared instances support headless starts only" };
|
|
3232
|
+
let executionCwd: string;
|
|
3233
|
+
try { executionCwd = resolveInstanceExecutionCwd(opts.cwd, peer, declarations, config.roles[peer] ?? []); }
|
|
3234
|
+
catch (error) { return { ok: false, error: (error as Error).message }; }
|
|
3235
|
+
executionRoots.set(peer, executionCwd);
|
|
3085
3236
|
applyPendingPermissionDefault(peer);
|
|
3086
3237
|
if (definitionOf(peer)?.start.refreshTools) { await ensureSpec(true); await refreshRunner(); }
|
|
3087
|
-
|
|
3238
|
+
const result = await startPeerBody(peer, args, (p) => { unmute = muteState(p); }, executionCwd);
|
|
3239
|
+
return result.ok && declarations[peer] ? { ...result, executionCwd } : result;
|
|
3088
3240
|
} finally {
|
|
3089
3241
|
unmute?.();
|
|
3090
3242
|
}
|
|
3091
3243
|
}
|
|
3092
3244
|
|
|
3093
|
-
async function startPeerBody(peer: string, args: { model?: string; route?: string; mode?: "headless" | "tui"; backend?: "auto" | "dgx" | "mlx"; sessionId?: string; sessionFile?: string; replaceSession?: boolean; fresh?: boolean; unattended?: boolean; providerProfile?: string }, mute: (p: PeerAdapter) => void): Promise<Record<string, unknown>> {
|
|
3245
|
+
async function startPeerBody(peer: string, args: { model?: string; route?: string; mode?: "headless" | "tui"; backend?: "auto" | "dgx" | "mlx"; sessionId?: string; sessionFile?: string; replaceSession?: boolean; fresh?: boolean; unattended?: boolean; providerProfile?: string }, mute: (p: PeerAdapter) => void, executionCwd: string): Promise<Record<string, unknown>> {
|
|
3094
3246
|
const adapter = definitionOf(peer);
|
|
3095
3247
|
const preflight = adapter?.start.preflight?.({ instance: peer, form: args.mode });
|
|
3096
3248
|
if (preflight) return { ok: false, error: preflight };
|
|
3097
|
-
if (adapter?.attach?.admission) {
|
|
3249
|
+
if (adapter?.attach?.admission && !declarations[peer]) {
|
|
3098
3250
|
if (args.mode !== undefined && !["headless", "tui"].includes(args.mode)) return { ok: false, error: "invalid Kimi mode" };
|
|
3099
3251
|
const owner = bus.peers.get(peer);
|
|
3100
3252
|
if ((args.mode ?? startMode(peer)) === "tui") {
|
|
@@ -3122,10 +3274,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3122
3274
|
if (args.replaceSession) {
|
|
3123
3275
|
if (recoveryActive()) return { ok: false, error: "recovery is holding session replacement" };
|
|
3124
3276
|
if (!args.sessionFile || args.sessionId || args.fresh) return { ok: false, error: "Pi replacement requires --replace-session --session-file <managed-file>, without --session-id or fresh" };
|
|
3125
|
-
if (!(existing instanceof PiPeer) || !existing.replacementReady ||
|
|
3126
|
-
const selected = validatePiSession(
|
|
3277
|
+
if (!(existing instanceof PiPeer) || !existing.replacementReady || piReceiptLedgers.get(peer)?.unsettled || [...permissions.values()].some(p => p.peer === peer)) return { ok: false, error: "Pi replacement requires an existing offline, settled owner with no live/unknown process, tools, receipts or approvals" };
|
|
3278
|
+
const selected = validatePiSession(nativeStateDir(peer), executionCwd, args.sessionFile);
|
|
3127
3279
|
const previous = existing.recoveryMetadata();
|
|
3128
|
-
recordPiReplacement(
|
|
3280
|
+
recordPiReplacement(nativeStateDir(peer), instanceId, previous, selected);
|
|
3129
3281
|
args = { ...args, ...selected };
|
|
3130
3282
|
mute(existing);
|
|
3131
3283
|
await stopPiForReplacement(existing);
|
|
@@ -3172,7 +3324,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3172
3324
|
if (adapter?.start.daemonPort === "owned-acp") {
|
|
3173
3325
|
if (!jcodeMcpReady(opts.cwd, opts.stateDir, SERVER_JS)) return { ok: false, error: "Jcode project MCP entry missing or stale; the person runs ahub jcode to configure it" };
|
|
3174
3326
|
const jcode: JcodePeer = adapter!.start.create!({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: config, permission: permissionMode(peer), mcpServers: [], daemon: DAEMON_PORTS["owned-acp"]({ command: config.jcode_cmd, home: config.jcode_home, providerProfile: (args.providerProfile ?? config.jcode_provider_profile) || undefined,
|
|
3175
|
-
...(args.model ? { launchModel: args.model } : {}), ...(args.sessionId ? { resumeSessionId: args.sessionId } : {}), cwd:
|
|
3327
|
+
...(args.model ? { launchModel: args.model } : {}), ...(args.sessionId ? { resumeSessionId: args.sessionId } : {}), cwd: executionCwd, watchdogMs: config.watchdog_ms,
|
|
3176
3328
|
preamble: sessionRoleContract(peer), log: () => log("Jcode native diagnostic: details withheld") }) }) as JcodePeer;
|
|
3177
3329
|
recoveryTaskPreface(peer); await ensurePreface(peer);
|
|
3178
3330
|
try { await jcode.start(); await reconcileNativeStart(peer, jcode); }
|
|
@@ -3184,11 +3336,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3184
3336
|
const kimi: AcpPeer = adapter!.start.create!({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: config, permission: permissionMode(peer), mcpServers: [], daemon: DAEMON_PORTS["acp"]({
|
|
3185
3337
|
onActivity: item => activityRings[peer]!.append(item),
|
|
3186
3338
|
isPrivateTurn: () => !!turns.get(peer)?.private || contextPii(peer),
|
|
3187
|
-
cmd,
|
|
3339
|
+
cmd, kind: adapter.kind,
|
|
3188
3340
|
permissionMode: permissionMode(peer),
|
|
3189
3341
|
...(args.model ? { launchModel: args.model } : {}),
|
|
3190
3342
|
...(args.sessionId ? { resumeSessionId: args.sessionId } : {}),
|
|
3191
|
-
cwd:
|
|
3343
|
+
cwd: executionCwd,
|
|
3192
3344
|
watchdogMs: config.watchdog_ms,
|
|
3193
3345
|
onPermission,
|
|
3194
3346
|
// The hub's own tools pass without a console prompt, as Codex's do (approval_mode below; issue #72).
|
|
@@ -3197,11 +3349,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3197
3349
|
// session's configured MCP servers (issue #138). Exact names only; a stopping hub approves nothing.
|
|
3198
3350
|
autoApprove: (title) => !stopping && HUB_TOOL_TITLES.has(title),
|
|
3199
3351
|
log,
|
|
3200
|
-
onTokens: onKimiTokens,
|
|
3352
|
+
onTokens: (total, session) => onKimiTokens(total, session, peer),
|
|
3201
3353
|
// Pause synchronously before the adapter requeues a provider-refused prompt (#306).
|
|
3202
3354
|
onQuotaRefusal: (window) => {
|
|
3203
3355
|
budget.report(peer, [window], true);
|
|
3204
|
-
return !!budget.record(peer) && bus.isPaused(
|
|
3356
|
+
return !!budget.record(peer) && bus.isPaused(peer);
|
|
3205
3357
|
},
|
|
3206
3358
|
// Kimi's usage_update is context occupancy ({used, size}), not consumption (#167): it becomes the peer's
|
|
3207
3359
|
// context reading (#285 phase 1), never a tokens event or a budget window. The reading is bound to the
|
|
@@ -3238,9 +3390,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3238
3390
|
const owner = adapter.start.create({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: undefined, permission, mcpServers, daemon: { acp: {
|
|
3239
3391
|
onActivity: item => activityRings[peer]!.append(item),
|
|
3240
3392
|
isPrivateTurn: () => !!turns.get(peer)?.private || contextPii(peer),
|
|
3241
|
-
cmd: [launch.cmd, ...launch.args], permissionMode: permission,
|
|
3393
|
+
kind: adapter.kind, cmd: [launch.cmd, ...launch.args], permissionMode: permission,
|
|
3242
3394
|
...(args.model ? { launchModel: args.model } : {}), ...(args.sessionId ? { resumeSessionId: args.sessionId } : {}),
|
|
3243
|
-
cwd:
|
|
3395
|
+
cwd: executionCwd, watchdogMs: config.watchdog_ms, onPermission,
|
|
3244
3396
|
autoApprove: title => !stopping && HUB_TOOL_TITLES.has(title),
|
|
3245
3397
|
log: () => log("Qwen runtime diagnostic: details withheld"),
|
|
3246
3398
|
onUsageDiagnostic: (observation, sessionId) => {
|
|
@@ -3273,7 +3425,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3273
3425
|
onTokens: (added) => void addTokens(peer, added),
|
|
3274
3426
|
onContext: (reading) => contexts.report(peer, reading, contextSession(peer)),
|
|
3275
3427
|
// #215: before the thread's first turn_start, so the planner can tell which turns belong to which thread.
|
|
3276
|
-
onThread: (thread, fresh) => {
|
|
3428
|
+
onThread: (thread, fresh) => { lastCodexThreads.set(peer, thread); event({ type: "native_thread", peer, thread, fresh }); },
|
|
3277
3429
|
onTurn: (native) => {
|
|
3278
3430
|
const open = turns.get(peer);
|
|
3279
3431
|
if (open) turnLog?.native(open.id, native);
|
|
@@ -3288,10 +3440,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3288
3440
|
factSession(peer, codex.thread);
|
|
3289
3441
|
if (item.type === "userMessage") {
|
|
3290
3442
|
const text = JSON.stringify(item.content ?? "");
|
|
3291
|
-
if (text.includes(FACTS_PREFIX)) for (const o of
|
|
3443
|
+
if (text.includes(FACTS_PREFIX)) for (const o of factsFor(peer).pending(peer)) if (text.includes(`[${o.id}]`)) acked(peer, o.id, "steer");
|
|
3292
3444
|
return;
|
|
3293
3445
|
}
|
|
3294
|
-
|
|
3446
|
+
factsFor(peer).codexItem(peer, item);
|
|
3295
3447
|
checkCapability(peer);
|
|
3296
3448
|
if (codexSteering) return; // one at a time: the next boundary offers what this one could not
|
|
3297
3449
|
const started = performance.now();
|
|
@@ -3304,12 +3456,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3304
3456
|
codexSteering = false;
|
|
3305
3457
|
// Refused, it never went in: not an unread offer, and the next boundary offers it again. Unanswered, it may
|
|
3306
3458
|
// have: its readback can still come.
|
|
3307
|
-
if (outcome === "refused")
|
|
3459
|
+
if (outcome === "refused") factsFor(peer).drop(peer, offered.id);
|
|
3308
3460
|
event({ type: "fact", peer, id: offered.id, files: offered.files, plans: offered.plans, unknown: offered.unknown, named: offered.named, bytes: offered.bytes, via: "steer", ms, ...(outcome === "accepted" ? { rttMs: Math.round(performance.now() - sent) } : {}), accepted: outcome === "accepted", ...(outcome === "unanswered" ? { unanswered: true } : {}), ...(offered.probe ? { probe: true } : {}), ...(offered.coverage ? { coverage: true } : {}) });
|
|
3309
3461
|
});
|
|
3310
3462
|
},
|
|
3311
|
-
appPort: opts.codexAppPort,
|
|
3312
|
-
proxyPort: opts.codexProxyPort,
|
|
3463
|
+
appPort: declarations[peer] ? instanceAppPort(peer) : opts.codexAppPort,
|
|
3464
|
+
proxyPort: declarations[peer] ? 0 : opts.codexProxyPort,
|
|
3313
3465
|
bin: config.codex_bin,
|
|
3314
3466
|
// The hub spawns this app-server, so it can give Codex the task tools without touching ~/.codex/config.toml
|
|
3315
3467
|
// (verified: app-server honours -c mcp_servers.* and the server reaches "ready").
|
|
@@ -3322,7 +3474,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3322
3474
|
preamble: sessionRoleContract(peer),
|
|
3323
3475
|
onUsage: (rateLimits, hard) => budget.report(peer, codexWindows(rateLimits), hard),
|
|
3324
3476
|
usagePollMs: config.budget.poll_min * 60_000,
|
|
3325
|
-
cwd:
|
|
3477
|
+
cwd: executionCwd,
|
|
3326
3478
|
watchdogMs: config.watchdog_ms,
|
|
3327
3479
|
log,
|
|
3328
3480
|
}) }) as CodexPeer;
|
|
@@ -3335,31 +3487,36 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3335
3487
|
return { ok: true, proxyUrl: codex.proxyUrl };
|
|
3336
3488
|
}
|
|
3337
3489
|
if (adapter?.start.daemonPort === "pi-rpc") {
|
|
3338
|
-
if (!onRoster(
|
|
3490
|
+
if (!onRoster(peer)) return { ok: false, error: config.roster.pi === undefined ? "Pi is disabled; set pi.enabled in .agenthub/config.json" : "pi is off roster" };
|
|
3339
3491
|
const mode = args.mode ?? "headless";
|
|
3340
3492
|
const backend = args.backend ?? config.pi.backend;
|
|
3341
3493
|
if (!["headless", "tui"].includes(mode) || !["auto", "dgx", "mlx"].includes(backend)) return { ok: false, error: "invalid Pi mode/backend" };
|
|
3342
3494
|
const problem = mlxLaunchProblem(config, { backend, model: args.model });
|
|
3343
3495
|
if (problem) return { ok: false, error: problem };
|
|
3344
|
-
modelRelay
|
|
3496
|
+
const modelRelay = modelRelays.get(peer) ?? await startModelRelay({ omni, admitRequest: () => admitPiRequest(peer), dgxMaxInputTokens: activeRouting().pi.dgx_max_context_tokens, allowedDGXmodels: { "dgx/coding": config.pi.dgx_coding, "dgx/fast": config.pi.dgx_fast }, mlx: config.mlx.enabled === false ? undefined : config.mlx, mlxAlias: "mlx/fast", fallbackDGXAlias: "dgx/fast", enableHubAuto: true,
|
|
3345
3497
|
routeSessionKey: () => { const session = bus.peers.get(peer)?.recoveryMetadata?.().sessionId; return typeof session === "string" ? session : undefined; },
|
|
3346
3498
|
staySwitch: () => activeRouting(),
|
|
3347
3499
|
onRoute: record => event({ type: "route", peer, ...record }),
|
|
3348
3500
|
efficientWaitMs: activeRouting().pi.efficient_wait_ms,
|
|
3349
|
-
underBudget: () => { const current = bus.peers.get(peer); return current instanceof PiPeer && tasks.budgetApplies(current.budgetEnvelopes,
|
|
3501
|
+
underBudget: () => { const current = bus.peers.get(peer); return current instanceof PiPeer && tasks.budgetApplies(current.budgetEnvelopes, peer); },
|
|
3350
3502
|
onCooldown: record => {
|
|
3351
3503
|
log(`model ${record.alias}: ${record.event === "start" ? `cooling down for ${Math.round(record.ms! / 1000)} s after ${record.failures} transport or startup failures` : "cooldown ended"}`);
|
|
3352
3504
|
event({ type: "cooldown", peer, ...record });
|
|
3353
3505
|
},
|
|
3354
3506
|
});
|
|
3355
|
-
|
|
3507
|
+
modelRelays.set(peer, modelRelay);
|
|
3508
|
+
if (!declarations[peer]) globalModelRelay(modelRelay);
|
|
3509
|
+
const nativeRoot = nativeStateDir(peer);
|
|
3510
|
+
mkdirSync(nativeRoot, { recursive: true });
|
|
3511
|
+
const piReceipts = piReceiptLedgers.get(peer) ?? new PiToolReceipts(join(nativeRoot, "hub.db"));
|
|
3512
|
+
piReceiptLedgers.set(peer, piReceipts);
|
|
3356
3513
|
let piReply: Envelope | undefined;
|
|
3357
3514
|
// Tools the person allowed "always" for this Pi start; a new start gets a new set (#209).
|
|
3358
3515
|
const piAlways = new Set<string>();
|
|
3359
3516
|
const piPermit = async (title: string, tool: string, signal?: AbortSignal, path?: string, valid: () => boolean = () => true, observe?: ApprovalObserver, originalPath?: string): Promise<ToolApproval> => {
|
|
3360
3517
|
if (stopping || signal?.aborted || !valid() || !pi.acceptingTools || bus.peers.get(peer) !== pi) return "aborted";
|
|
3361
3518
|
const selected = permissionMode(peer);
|
|
3362
|
-
if (selected === "never-ask" || (selected === "ask-when-needed" && PI_EDIT_TOOLS.has(tool) && !!path && grantablePath(realPath(
|
|
3519
|
+
if (selected === "never-ask" || (selected === "ask-when-needed" && PI_EDIT_TOOLS.has(tool) && !!path && grantablePath(realPath(executionCwd), path, originalPath, config.protect_instruction_files))) {
|
|
3363
3520
|
observe?.({ source: "automatic", answerEpoch: permissionAnswerEpoch(peer) });
|
|
3364
3521
|
return true;
|
|
3365
3522
|
}
|
|
@@ -3375,9 +3532,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3375
3532
|
return signal?.aborted || !valid() ? "aborted" : pi.acceptingTools && bus.peers.get(peer) === pi;
|
|
3376
3533
|
};
|
|
3377
3534
|
const ctx: ToolContext = {
|
|
3378
|
-
cwd:
|
|
3379
|
-
sandboxProfile: profile(
|
|
3380
|
-
sandboxEnv: { ...proxyEnv(sandboxNetwork), AGENTHUB_PEER_ID:
|
|
3535
|
+
cwd: executionCwd, deny: config.local.deny, readOnly: config.data?.readonly,
|
|
3536
|
+
sandboxProfile: profile(executionCwd, sandboxNetwork, config.local.read_allow, config.local.deny, config.data?.readonly),
|
|
3537
|
+
sandboxEnv: { ...proxyEnv(sandboxNetwork), AGENTHUB_PEER_ID: peer },
|
|
3381
3538
|
permit: async () => false, // Pi's calls ask through piPermit in executeTool, which knows the tool's name
|
|
3382
3539
|
send: (text, to) => {
|
|
3383
3540
|
if (to?.some((id) => !bus.peers.has(id) && id !== USER)) return "error: unknown peer";
|
|
@@ -3387,12 +3544,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3387
3544
|
};
|
|
3388
3545
|
const routing = activeRouting();
|
|
3389
3546
|
const pi: PiPeer = adapter!.start.create!({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: config, permission: permissionMode(peer), mcpServers: [], daemon: DAEMON_PORTS["pi-rpc"]({
|
|
3390
|
-
cwd:
|
|
3547
|
+
cwd: executionCwd, stateDir: nativeRoot, ...(declarations[peer] ? { hubStateDir: opts.stateDir } : {}), cmd: config.pi.cmd, mode, backend,
|
|
3391
3548
|
model: args.model,
|
|
3392
3549
|
sessionId: args.sessionId, sessionFile: args.sessionFile,
|
|
3393
3550
|
admitBudget: async (envs, unit) => unit === "model_calls" ? [] : tasks.admitExecutionEnvelopes(envs, peer, unit),
|
|
3394
3551
|
relay: { url: modelRelay.url, token: modelRelay.token, models: modelRelay.models.map((id) => ({ id, contextWindow: id.startsWith("mlx/") ? Math.min(routing.pi.mlx_max_context_tokens, config.mlx.provider === "ollama" ? (config.mlx.contextWindow ?? 8192) : routing.pi.mlx_max_context_tokens) : routing.pi.dgx_max_context_tokens, maxTokens: id === "hub/auto" ? (config.mlx.enabled === false ? 8192 : Math.min(config.mlx.maxTokens ?? 2048, 8192)) : id.startsWith("mlx/") ? (config.mlx.maxTokens ?? 2048) : 8192 })) },
|
|
3395
|
-
tools: [...TOOL_SCHEMAS.map((t) => t.function), ...[...profileTaskTools(resolvedProfile, lifecycleConfig.stages), ...CONDUCTOR_TOOLS].map((t) => ({ name: t.name, description: t.description, parameters: t.inputSchema }))],
|
|
3552
|
+
tools: [...TOOL_SCHEMAS.map((t) => t.function), ...[...profileTaskTools(resolvedProfile, lifecycleConfig.stages, lifecycleConfig.mode !== "off"), ...CONDUCTOR_TOOLS].map((t) => ({ name: t.name, description: t.description, parameters: t.inputSchema }))],
|
|
3396
3553
|
executeTool: async (name, raw, callId, sessionId, signal, context) => {
|
|
3397
3554
|
if (MEMORY_READ_TOOLS.has(name)) validateMemoryRead(name, raw as Record<string, unknown>);
|
|
3398
3555
|
if (signal?.aborted) return "error: turn cancelled before tool effects";
|
|
@@ -3454,7 +3611,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3454
3611
|
else if (recoveryActive()) action = "recovery holds automatic restart; inspect ahub status";
|
|
3455
3612
|
else if (!exit.started || starting.has(peer)) action = "startup failed; inspect its session, then ahub pi";
|
|
3456
3613
|
else if (exit.turnActive || exit.toolActive) action = "turn/tool effects may be partial; inspect its session, then ahub pi";
|
|
3457
|
-
else if (!piAutoStart()) action = "pi.auto_start is off; start it with ahub pi";
|
|
3614
|
+
else if (declarations[peer] || !piAutoStart()) action = "pi.auto_start is off; start it with ahub pi";
|
|
3458
3615
|
else if (mode !== "headless") action = "native terminal ended; start it with ahub pi";
|
|
3459
3616
|
else if (piAutoRestartPending || Date.now() - piAutoRestartAt < 60_000) action = "pi.auto_start restart limit (one in 60 s); start it with ahub pi";
|
|
3460
3617
|
else {
|
|
@@ -3467,7 +3624,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3467
3624
|
const saved = await pi.captureResume();
|
|
3468
3625
|
if (typeof saved.sessionId !== "string" || typeof saved.sessionFile !== "string") throw new Error("no verified persisted Pi session; refusing a fresh fallback");
|
|
3469
3626
|
if (stopping || recoveryActive() || bus.peers.get(peer) !== pi || starting.has(peer)) return;
|
|
3470
|
-
const result = await startPeer(
|
|
3627
|
+
const result = await startPeer(peer, { mode: "headless", backend, ...(args.model ? { model: args.model } : {}), sessionId: saved.sessionId, sessionFile: saved.sessionFile });
|
|
3471
3628
|
notify(result.ok ? "pi.auto_start resumed the recorded Pi session after its idle exit" : `pi.auto_start restart refused: ${String(result.error)}; start it with ahub pi`);
|
|
3472
3629
|
})().catch((error) => { try { notify(`pi.auto_start could not resume its recorded session: ${error instanceof Error ? error.message : String(error)}; start it with ahub pi`); } catch { /* no retry loop */ } })
|
|
3473
3630
|
.finally(() => { piAutoRestartAt = Date.now(); piAutoRestartPending = false; });
|
|
@@ -3495,6 +3652,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3495
3652
|
}) }) as PiPeer;
|
|
3496
3653
|
recoveryTaskPreface(peer);
|
|
3497
3654
|
await ensurePreface(peer);
|
|
3655
|
+
executionOwners.set(peer, { owner: pi, cwd: executionCwd });
|
|
3498
3656
|
bus.add(pi);
|
|
3499
3657
|
// The start's own error is the one reported; a stop that fails too is logged beside it (#115).
|
|
3500
3658
|
try { await pi.start(); } catch (error) { await pi.stop().catch((stop: Error) => log(`pi stop after a failed start: ${stop.message}`)); throw error; }
|
|
@@ -3524,12 +3682,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3524
3682
|
sidecar ??= new Sidecar({ routing, omni, stateDir: opts.stateDir, port: opts.switchyardPort, log, ...(opts.switchyardBin ? { bin: opts.switchyardBin } : {}) });
|
|
3525
3683
|
}
|
|
3526
3684
|
const capture = config.memory.enabled
|
|
3527
|
-
? new Capture(memory, { project: chain.at(-1)!, cwd:
|
|
3685
|
+
? new Capture(memory, { project: chain.at(-1)!, cwd: executionCwd, skip: skipTools(), deny: config.local.deny })
|
|
3528
3686
|
: undefined;
|
|
3529
3687
|
const permit = async (title: string, tool?: string, signal?: AbortSignal, path?: string, observe?: ApprovalObserver, originalPath?: string): Promise<ToolApproval> => {
|
|
3530
3688
|
if (stopping || signal?.aborted || bus.peers.get(peer) !== local) return "aborted";
|
|
3531
3689
|
const selected = permissionMode(peer);
|
|
3532
|
-
if (selected === "never-ask" || (selected === "ask-when-needed" && !!tool && PI_EDIT_TOOLS.has(tool) && !!path && grantablePath(realPath(
|
|
3690
|
+
if (selected === "never-ask" || (selected === "ask-when-needed" && !!tool && PI_EDIT_TOOLS.has(tool) && !!path && grantablePath(realPath(executionCwd), path, originalPath, config.protect_instruction_files))) {
|
|
3533
3691
|
observe?.({ source: "automatic", answerEpoch: permissionAnswerEpoch(peer) });
|
|
3534
3692
|
return true;
|
|
3535
3693
|
}
|
|
@@ -3542,7 +3700,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3542
3700
|
return picked.optionId === "allow";
|
|
3543
3701
|
};
|
|
3544
3702
|
const local: LocalPeer = adapter!.start.create!({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: config, permission: permissionMode(peer), mcpServers: [], daemon: DAEMON_PORTS["local-worker"]({
|
|
3545
|
-
cwd:
|
|
3703
|
+
cwd: executionCwd,
|
|
3546
3704
|
omni,
|
|
3547
3705
|
onUsage: record => event({ type: "usage", peer, source: "omniroute", id: record.id, ...record.usage, ...taskAttribution(peer, record.task), requestedModel: record.requestedModel, ...(record.servedModel ? { servedModel: record.servedModel } : {}), ...(record.provider ? { provider: record.provider } : {}), measuredAt: record.at }),
|
|
3548
3706
|
admitBudget: async (envs, unit) => tasks.admitExecutionEnvelopes(envs, peer, unit),
|
|
@@ -3561,6 +3719,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3561
3719
|
...(capture ? { capture } : {}),
|
|
3562
3720
|
profile: resolvedProfile,
|
|
3563
3721
|
sdlcStages: lifecycleConfig.stages,
|
|
3722
|
+
sdlcCriteria: lifecycleConfig.mode !== "off",
|
|
3564
3723
|
taskTool: (name, a, turn) => taskOp(peer, name, a, true, turn.pii),
|
|
3565
3724
|
turnPolicy: (envs) => {
|
|
3566
3725
|
const policy = tasks.turnPolicy(envs);
|
|
@@ -3574,6 +3733,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3574
3733
|
}) }) as LocalPeer;
|
|
3575
3734
|
recoveryTaskPreface(peer);
|
|
3576
3735
|
await ensurePreface(peer);
|
|
3736
|
+
executionOwners.set(peer, { owner: local, cwd: executionCwd });
|
|
3577
3737
|
bus.add(local);
|
|
3578
3738
|
await local.start();
|
|
3579
3739
|
return { ok: true, model: route ? `${route} (fallback ${routing.local.fixed_model})` : (args.model ?? routing.local.fixed_model) };
|
|
@@ -3581,10 +3741,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3581
3741
|
return { ok: false, error: `unknown peer "${peer}"` };
|
|
3582
3742
|
}
|
|
3583
3743
|
|
|
3584
|
-
async function admitPiRequest(): Promise<{ allowed: boolean; reason?: string; remainingMs?: number }> {
|
|
3585
|
-
const current = bus.peers.get(
|
|
3744
|
+
async function admitPiRequest(peer = "pi"): Promise<{ allowed: boolean; reason?: string; remainingMs?: number }> {
|
|
3745
|
+
const current = bus.peers.get(peer);
|
|
3586
3746
|
if (!(current instanceof PiPeer)) return { allowed: false, reason: "Pi owner is not active" };
|
|
3587
|
-
const decisions = tasks.admitExecutionEnvelopes(current.budgetEnvelopes,
|
|
3747
|
+
const decisions = tasks.admitExecutionEnvelopes(current.budgetEnvelopes, peer, "model_calls");
|
|
3588
3748
|
const denied = decisions.find(d => !d.allowed);
|
|
3589
3749
|
const stop = denied ? current.recordBudgetStop(denied) : undefined;
|
|
3590
3750
|
const deadline = decisions.filter(d => d.unit === "elapsed_ms" && d.remaining !== null).map(d => d.remaining!);
|
|
@@ -3641,7 +3801,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3641
3801
|
}
|
|
3642
3802
|
|
|
3643
3803
|
const conductor = new Conductor(conductorHolds, {
|
|
3644
|
-
roles: () => config.roles, capabilities: () => config.capabilities,
|
|
3804
|
+
roles: () => config.roles, capabilities: () => config.capabilities, instances: () => declarations,
|
|
3645
3805
|
status: () => {
|
|
3646
3806
|
const quota = budget.status();
|
|
3647
3807
|
const ids = [...new Set([...bus.knownPeers(), ...Object.keys(quota), ...conductorHolds.list().map(h => h.peer)])];
|
|
@@ -3649,8 +3809,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3649
3809
|
peers: ids.map(peer => {
|
|
3650
3810
|
const summary = bus.queueSummary(peer);
|
|
3651
3811
|
return { peer, state: bus.stateOf(peer), attached: !!bus.peers.get(peer) && bus.peers.get(peer)!.state !== "offline", queued: bus.queued(peer), needsReview: summary.needsReview,
|
|
3652
|
-
manualHeld: manualPaused.has(peer), hold: conductorHolds.get(peer), budgetPause: quota[peer]?.paused ?? null,
|
|
3653
|
-
...(quota[peer] ? { windows: quota[peer]!.windows.map(w => ({ id: w.id, ...(w.stale || (w.resetsAt !== undefined && w.resetsAt <= Date.now()) ? {} : { used: w.used }), resetsAt: w.resetsAt, at: w.at, source: w.source })) } : {}) };
|
|
3812
|
+
manualHeld: manualPaused.has(peer), hold: conductorHolds.get(peer), budgetPause: quota[accountOf(peer)]?.paused ?? null,
|
|
3813
|
+
...(quota[accountOf(peer)] ? { windows: quota[accountOf(peer)]!.windows.map(w => ({ id: w.id, ...(w.stale || (w.resetsAt !== undefined && w.resetsAt <= Date.now()) ? {} : { used: w.used }), resetsAt: w.resetsAt, at: w.at, source: w.source })) } : {}) };
|
|
3654
3814
|
}),
|
|
3655
3815
|
taskCounts: board.counts(), approvals: [...permissions.values()].map(p => ({ peer: p.peer, tool: p.tool, at: p.createdAt })),
|
|
3656
3816
|
};
|
|
@@ -3670,7 +3830,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3670
3830
|
if (result.ok !== true) throw new Error(String(result.error ?? "peer start failed"));
|
|
3671
3831
|
return result;
|
|
3672
3832
|
},
|
|
3673
|
-
known: peer => bus.knownPeers().includes(peer),
|
|
3833
|
+
known: peer => extraIds.includes(peer) || bus.knownPeers().includes(peer),
|
|
3674
3834
|
pause: peer => { pauseGeneration.set(peer, (pauseGeneration.get(peer) ?? 0) + 1); bus.pause(peer); writeStatus(); },
|
|
3675
3835
|
validateRelease: async peer => {
|
|
3676
3836
|
const owner = bus.peers.get(peer);
|
|
@@ -3793,7 +3953,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3793
3953
|
return { id: task.id, title: view.title, detail: view.detail, class: task.class, ...(task.profile ? { profile: task.profile, profileClass: task.profileClass } : {}), ...(task.role ? { role: task.role } : {}), state: task.state, owner: task.owner, reviewer: task.reviewer, deps: view.deps ?? [], ...(task.reserved ? { reserved: task.reserved } : {}), planningRevision: task.planningRevision ?? 0, rank: task.rank ?? null, priority: task.priority ?? null, release: task.release ?? null, plannedStart: task.plannedStart ?? null, due: task.due ?? null, pinned: task.pinned === true, actualDates: view.actualDates, ...(view.cancelled ? { cancelled: view.cancelled } : {}), ...(view.external ? { external: view.external } : {}), started: taskHasStarted(task), urgent: task.signals.includes("urgent"), updated: task.updated, reworkRound: task.history.filter(entry => entry.event === "changes_requested").length, checking: tasks.isChecking(task.id), ...(view.lifecycle ? { lifecycle: view.lifecycle } : {}) };
|
|
3794
3954
|
}),
|
|
3795
3955
|
links: links.filter(link => link.tasks.every(id => { const task = board.get(id); return task && !tasks.isPii(task); })),
|
|
3796
|
-
budget: Object.fromEntries(peers.map((id) => [id, { ...(quota[id] ?? { windows: [] }), context: contexts.view(id, contextSession(id), bus.peers.has(id) && ["idle", "busy", "paused"].includes(bus.stateOf(id))) }])),
|
|
3956
|
+
budget: Object.fromEntries(peers.map((id) => [id, { ...(quota[accountOf(id)] ?? { windows: [] }), context: contexts.view(id, contextSession(id), bus.peers.has(id) && ["idle", "busy", "paused"].includes(bus.stateOf(id))) }])),
|
|
3797
3957
|
permissions: [...permissions.values()].map(({ push }) => {
|
|
3798
3958
|
const req = JSON.parse(push);
|
|
3799
3959
|
// Local tool titles quote file contents and commands, including those of PII turns.
|
|
@@ -3842,7 +4002,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3842
4002
|
function roleHolders(candidate: HubConfig): { planners: string[]; conductors: string[] } {
|
|
3843
4003
|
// A valid transfer may briefly have two conductors while its leaves are planned. Validate the final map later.
|
|
3844
4004
|
const policy = Object.fromEntries(Object.entries(candidate.roles).map(([peer, roles]) => [peer, roles.filter(role => role !== "conductor")]));
|
|
3845
|
-
const settled = resolveSettledRoles(policy, candidate.planner, peer => rosterAllows(candidate.roster, peer, candidate.pi.enabled));
|
|
4005
|
+
const settled = resolveSettledRoles(policy, candidate.planner, peer => rosterAllows(candidate.roster, peer, candidate.pi.enabled, declarations));
|
|
3846
4006
|
return { planners: settled.planners, conductors: Object.keys(candidate.roles).filter(peer => candidate.roles[peer]!.includes("conductor")).sort() };
|
|
3847
4007
|
}
|
|
3848
4008
|
function prepareConfiguration(sources: ConfigSources, authority: SettingAuthority, scope?: string, confirmed: readonly string[] = []) {
|
|
@@ -3871,6 +4031,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3871
4031
|
const after = valueAt(route ? sources.routing : opts.config && !current ? embeddedConfig : fileConfig, path);
|
|
3872
4032
|
const rawAfter = current ? valueAt(current.raw, path) : undefined;
|
|
3873
4033
|
if (!rawChanged && JSON.stringify(before) === JSON.stringify(after)) continue;
|
|
4034
|
+
// Instance declarations are restart-bound. Their derived role/permission/start defaults cannot leak into
|
|
4035
|
+
// the live catalog while the old identities, paths and account callbacks still own the daemon.
|
|
4036
|
+
const boundId = ["roles", "permission_modes", "peers", "permission_default_sources"].includes(path[0] ?? "") ? path[1] : undefined;
|
|
4037
|
+
if (!route && boundId && JSON.stringify(desiredConfig.instances?.[boundId]) !== JSON.stringify(fileConfig.instances?.[boundId])) {
|
|
4038
|
+
rejected.push({ key, reason: "instance declaration changed; its derived defaults apply together after daemon restart" });
|
|
4039
|
+
continue;
|
|
4040
|
+
}
|
|
3874
4041
|
const machine = !route && isMachineKey(key);
|
|
3875
4042
|
const origin = current ?? prior;
|
|
3876
4043
|
let tracked = origin ? configTracked(opts.cwd, origin.name) : false;
|
|
@@ -4071,9 +4238,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4071
4238
|
let routingProblem: string | undefined;
|
|
4072
4239
|
try { parseRouting(routingText(opts.cwd), readOverlay(opts.cwd)); } catch (error) { routingProblem = (error as Error).message; }
|
|
4073
4240
|
const rows = settingRows({
|
|
4074
|
-
cwd: opts.cwd, routing: activeRouting(), ...(routingProblem ? { routingProblem } : {}), ...(stored?.ignored ? { ignored: stored.ignored } : {}),
|
|
4241
|
+
instances: declarations, cwd: opts.cwd, routing: activeRouting(), ...(routingProblem ? { routingProblem } : {}), ...(stored?.ignored ? { ignored: stored.ignored } : {}),
|
|
4075
4242
|
running: (path) => path[0] === "roster" ? onRoster(path[1]!) : path[0] === "tasks" && path[1] === "wip_limits" ? tasks.wipLimit(path[2]!) : valueAt(config, path),
|
|
4076
|
-
next: stored ? (path) => path[0] === "roster" ? rosterAllows(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,
|
|
4243
|
+
next: stored ? (path) => path[0] === "roster" ? rosterAllows(stored.roster, path[1]!, stored.pi.enabled, declarations) : 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,
|
|
4077
4244
|
roles: roleState,
|
|
4078
4245
|
mode: (peer) => {
|
|
4079
4246
|
const owner = bus.peers.get(peer), attached = !!owner && owner.state !== "offline";
|
|
@@ -4112,13 +4279,14 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4112
4279
|
if (owner?.state === "busy" && !now) return `${peer} is in a turn; retry when it is idle, or pass --now`;
|
|
4113
4280
|
return undefined;
|
|
4114
4281
|
}
|
|
4115
|
-
function codexResumeCommand(): string {
|
|
4116
|
-
const owner = bus.peers.get(
|
|
4117
|
-
|
|
4282
|
+
function codexResumeCommand(peer = "codex"): string {
|
|
4283
|
+
const owner = bus.peers.get(peer), thread = owner instanceof CodexPeer ? owner.thread || lastCodexThreads.get(peer) : lastCodexThreads.get(peer);
|
|
4284
|
+
const command = declarations[peer] ? `ahub codex --as ${peer} resume` : "ahub codex resume";
|
|
4285
|
+
return thread && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(thread) ? `${command} ${thread}` : command;
|
|
4118
4286
|
}
|
|
4119
4287
|
function retainedTerminalNotice(peer: string): string | undefined {
|
|
4120
4288
|
const owner = bus.peers.get(peer);
|
|
4121
|
-
return definitionOf(peer)?.cli.retainedTerminalNotice?.(peer, owner instanceof PiPeer ? owner.mode : undefined, codexResumeCommand());
|
|
4289
|
+
return definitionOf(peer)?.cli.retainedTerminalNotice?.(peer, owner instanceof PiPeer ? owner.mode : undefined, codexResumeCommand(peer));
|
|
4122
4290
|
}
|
|
4123
4291
|
|
|
4124
4292
|
async function changeRosterSetting(def: SettingDef, value: SettingValue, source: "dashboard" | "terminal", confirm: unknown, now: boolean, undo = false, authority: SettingAuthority = "ordinary", reason?: string, undoIdentity?: UndoIdentity): Promise<Record<string, unknown>> {
|
|
@@ -4128,7 +4296,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4128
4296
|
const terminalNotice = retainedTerminalNotice(peer);
|
|
4129
4297
|
let handoff: Awaited<ReturnType<Tasks["reassignForRoster"]>> = [];
|
|
4130
4298
|
try {
|
|
4131
|
-
const next = rosterCandidate(def, value, undo), before = onRoster(peer), after = rosterAllows(next, peer, config.pi.enabled);
|
|
4299
|
+
const next = rosterCandidate(def, value, undo), before = onRoster(peer), after = rosterAllows(next, peer, config.pi.enabled, declarations);
|
|
4132
4300
|
if (!after) {
|
|
4133
4301
|
const refusal = rosterRemovalRefusal(peer, now);
|
|
4134
4302
|
if (refusal) return { ok: false, error: refusal };
|
|
@@ -4168,7 +4336,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4168
4336
|
if (reloaded.ok !== true) return { ...reloaded, saved: true };
|
|
4169
4337
|
writeStatus();
|
|
4170
4338
|
const stayed = !after ? board.list().filter(task => !isClosedTaskState(task.state) && (task.owner === peer || task.reviewer === peer)) : [];
|
|
4171
|
-
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())] : []), ...(!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 } : {}) };
|
|
4339
|
+
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 } : {}) };
|
|
4172
4340
|
} catch (error) {
|
|
4173
4341
|
if (saved) writeStatus();
|
|
4174
4342
|
return { ok: false, error: sanitize((error as Error).message), ...(saved ? { saved: true } : {}) };
|
|
@@ -4277,14 +4445,14 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4277
4445
|
const { def, value } = input;
|
|
4278
4446
|
if (def.group === "Roster") {
|
|
4279
4447
|
try {
|
|
4280
|
-
const peer = def.path[1]!, next = rosterCandidate(def, value), on = rosterAllows(next, peer, config.pi.enabled);
|
|
4448
|
+
const peer = def.path[1]!, next = rosterCandidate(def, value), on = rosterAllows(next, peer, config.pi.enabled, declarations);
|
|
4281
4449
|
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 }) };
|
|
4282
4450
|
} catch (error) { return { ok: false, error: sanitize((error as Error).message) }; }
|
|
4283
4451
|
}
|
|
4284
4452
|
if (def.key === "scoped_grants.protect_instructions") return { ok: true, lines: ["Applies now to scoped writes and edits of AGENTS.md, CLAUDE.md and QWEN.md, including symlinks and folded spellings.", "true asks a person; false keeps these documents grantable (the default). Explicit approval and never-ask keep their meaning."] };
|
|
4285
4453
|
if (def.group === "Permissions") {
|
|
4286
4454
|
const peer = def.path.at(-1)!;
|
|
4287
|
-
return { ok: true, lines: [permissionBoundary(peer), ...(isPermissionMode(value) ? [`${value}: ${permissionGrant(peer, value)}`] : ["inherit: the shared file's default, or ask"])], ...(value === "never-ask" || value === null && restoredValue(opts.cwd, opts.stateDir, def, true) === "never-ask" ? { confirm: peer } : {}) };
|
|
4455
|
+
return { ok: true, lines: [permissionBoundary(peer, declarations), ...(isPermissionMode(value) ? [`${value}: ${permissionGrant(peer, value, declarations)}`] : ["inherit: the shared file's default, or ask"])], ...(value === "never-ask" || value === null && restoredValue(opts.cwd, opts.stateDir, def, true) === "never-ask" ? { confirm: peer } : {}) };
|
|
4288
4456
|
}
|
|
4289
4457
|
if (def.store !== "routing") return { ok: true, lines: [`${def.key}: ${settingText(value)}; ${def.applies === "next use" ? "applies at next use" : def.applies === "live" ? "in force as soon as it is saved" : "pending restart"}`] };
|
|
4290
4458
|
try {
|
|
@@ -4413,7 +4581,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4413
4581
|
...(taskOpsInFlight !== 0 ? ["a task command in flight"] : []),
|
|
4414
4582
|
...(tasks.checksPending() !== 0 ? ["completion checks queued or running"] : []),
|
|
4415
4583
|
...(checkPending.size !== 0 ? ["isolated checks queued or running"] : []),
|
|
4416
|
-
...((
|
|
4584
|
+
...(piCallsInFlight() !== 0 ? ["Pi tool calls in flight"] : []),
|
|
4417
4585
|
...(releasing ? ["ready tasks being released"] : []),
|
|
4418
4586
|
...([...bus.peers].filter(([, peer]) => peer instanceof PiPeer && peer.state !== "busy" && !peer.recoveryReady).map(([id, peer]) => !membershipAllowed(id) && peer instanceof PiPeer && peer.mode === "tui" ? `${id} is not settled; end the retained Pi TUI, then prepare again` : `${id} is not settled`)),
|
|
4419
4587
|
],
|
|
@@ -4434,11 +4602,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4434
4602
|
? `target lacks task_cancel; cancelled tasks: ${ids.map(id => `#${id}`).join(", ")}` : undefined;
|
|
4435
4603
|
};
|
|
4436
4604
|
const checkTarget = msg.op === "prepare" || msg.op === "commit" || msg.op === "inspect" && msg.targetFeatures !== undefined;
|
|
4437
|
-
if (checkTarget) {
|
|
4605
|
+
if (checkTarget) {
|
|
4606
|
+
const error = incompatibleCancellationTarget(); if (error) return recoveryError(error);
|
|
4607
|
+
if (extraIds.length && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("peer_instances"))) return recoveryError("target lacks peer_instances; declared instance identities cannot be restored by that daemon");
|
|
4608
|
+
}
|
|
4438
4609
|
|
|
4439
4610
|
if (msg.op === "inspect" || msg.op === "prepare" || msg.op === "commit") {
|
|
4440
|
-
const pi
|
|
4441
|
-
if (pi instanceof PiPeer && pi.state === "idle") {
|
|
4611
|
+
for (const pi of bus.peers.values()) if (pi instanceof PiPeer && pi.state === "idle") {
|
|
4442
4612
|
try { await pi.captureResume(); } catch (error) { return recoveryError((error as Error).message); }
|
|
4443
4613
|
if (checkTarget) { const error = incompatibleCancellationTarget(); if (error) return recoveryError(error); }
|
|
4444
4614
|
}
|
|
@@ -4646,6 +4816,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4646
4816
|
else await owner.setPermissionMode(selected);
|
|
4647
4817
|
}
|
|
4648
4818
|
// The bus may immediately drain at add(): do not expose an idle adapter until its current choice is applied.
|
|
4819
|
+
executionOwners.set(peer, { owner, cwd: executionRoots.get(peer) ?? opts.cwd });
|
|
4649
4820
|
bus.add(owner);
|
|
4650
4821
|
// start() finished before the bus installed its state hook: replay the current state to drain queued work and record it.
|
|
4651
4822
|
if (owner.state !== "offline") owner.onState?.(owner.state);
|
|
@@ -4746,7 +4917,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4746
4917
|
if (!c.peer || (c.role !== "peer" && c.role !== "tools")) return void reply({ ok: false, error: "held metadata requires a peer identity" });
|
|
4747
4918
|
try {
|
|
4748
4919
|
const held = bus.heldDeliveryHints(c.peer);
|
|
4749
|
-
return void reply(held ? { ok: true, held } : { ok: false, error: "held metadata unavailable" });
|
|
4920
|
+
return void reply(held ? { ok: true, held, queued: bus.queued(c.peer), important: bus.queuedImportant(c.peer) } : { ok: false, error: "held metadata unavailable" });
|
|
4750
4921
|
} catch { return void reply({ ok: false, error: "held metadata unavailable" }); }
|
|
4751
4922
|
}
|
|
4752
4923
|
case "delivery_receipt": {
|
|
@@ -4758,10 +4929,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4758
4929
|
return void reply({ ok: true });
|
|
4759
4930
|
}
|
|
4760
4931
|
case "inbox": {
|
|
4761
|
-
//
|
|
4932
|
+
// The attached session can explicitly claim queued work even during a native turn (#493).
|
|
4933
|
+
// Pull and push share holds and journal settlement; work read here is completed, never pushed again.
|
|
4762
4934
|
const peer = c.peer ? bus.peers.get(c.peer) : undefined;
|
|
4763
4935
|
if (c.role !== "peer" || !(peer instanceof WsPeer) || !peer.owns(sock)) return void reply({ ok: false, error: "not attached to the hub yet; retry hub_inbox in a moment" });
|
|
4764
|
-
if (!peer.pullOnly) return void reply({ ok: false, error: "messages are pushed to this session; hub_inbox only drains failed pushes" });
|
|
4765
4936
|
try {
|
|
4766
4937
|
const envs = bus.pull(peer.id);
|
|
4767
4938
|
if (!envs) return void reply({ ok: false, error: queueHold(peer.id) ?? (bus.isRecoveryHeld ? "recovery is holding deliveries" : pauseReason(peer.id)) });
|
|
@@ -4958,14 +5129,14 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4958
5129
|
factSession(peer, sessionId);
|
|
4959
5130
|
const transcript = claudeTranscript(sessionId, msg.transcriptPath);
|
|
4960
5131
|
if (phase === "post") {
|
|
4961
|
-
|
|
5132
|
+
factsFor(peer).postTool(peer, toolUseId, tool, input);
|
|
4962
5133
|
readbacks(peer, transcript);
|
|
4963
5134
|
return void reply({ t: "facts", ok: true });
|
|
4964
5135
|
}
|
|
4965
5136
|
readbacks(peer, transcript);
|
|
4966
5137
|
checkCapability(peer);
|
|
4967
5138
|
const started = performance.now();
|
|
4968
|
-
|
|
5139
|
+
factsFor(peer).preTool(peer, toolUseId, tool, input);
|
|
4969
5140
|
// Without a transcript nothing offered could ever be read back: a verified path is lost, and nothing is offered
|
|
4970
5141
|
// (the same diff would otherwise go in at every tool call).
|
|
4971
5142
|
if (!transcript) {
|
|
@@ -5235,9 +5406,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5235
5406
|
// logged and shutdown finishes anyway, or the process ignores SIGTERM forever.
|
|
5236
5407
|
for (const exit of exits) if (exit.status === "rejected") log(`peer stop failed during shutdown: ${(exit.reason as Error)?.message ?? exit.reason}`);
|
|
5237
5408
|
await runnerStopped;
|
|
5238
|
-
await
|
|
5409
|
+
await Promise.all([...modelRelays.values()].map(relay => relay.close()));
|
|
5239
5410
|
await egress?.close();
|
|
5240
|
-
await
|
|
5411
|
+
await Promise.all([...piReceiptLedgers.values()].map(ledger => ledger.close()));
|
|
5241
5412
|
collectClaudeUsage();
|
|
5242
5413
|
for (const id of [...researchPending.keys()]) writeResearch(id); // the peers have stopped, so their turns have ended
|
|
5243
5414
|
budget.close();
|