@staix/agent-hub 0.12.34 → 0.12.36
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 +27 -0
- package/README.md +8 -6
- package/docs/agent-notes/adapters.md +6 -1
- package/docs/agent-notes/budget.md +4 -2
- package/docs/agent-notes/bus.md +5 -1
- package/docs/agent-notes/daemon.md +11 -3
- package/docs/agent-notes/local-worker.md +1 -1
- package/docs/agent-notes/tasks.md +15 -2
- package/docs/agent-notes/tests.md +5 -1
- package/docs/gsd-collaboration-analysis.md +5 -1
- package/docs/operations.md +97 -19
- package/docs/quickstart.md +8 -4
- package/docs/sdlc.md +100 -34
- package/docs/security.md +7 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +237 -7
- 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/verification/2026-10-19-0.12.36.md +63 -0
- package/docs/verified.json +26 -17
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +15781 -14850
- package/src/adapters/acp.ts +3 -1
- package/src/adapters/claude-channel.ts +59 -29
- package/src/adapters/claude-print.ts +230 -0
- package/src/adapters/codex-appserver.ts +1 -1
- package/src/adapters/definitions/builtin-metadata.ts +17 -1
- package/src/adapters/definitions/native-cli-entry-claude.ts +17 -3
- package/src/adapters/definitions/native-cli-entry-codex.ts +13 -5
- package/src/adapters/definitions/native-cli-entry-jcode.ts +6 -2
- package/src/adapters/definitions/native-cli-entry-kimi.ts +18 -12
- 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 +3 -0
- package/src/adapters/definitions/native-factories.ts +7 -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 +11 -2
- package/src/adapters/registry.ts +36 -8
- package/src/cli/help.ts +5 -0
- package/src/cli/identity.ts +5 -4
- package/src/cli/launch.ts +45 -7
- package/src/cli/main.ts +136 -29
- package/src/cli/preview.ts +16 -5
- package/src/cli/recovery-guidance.ts +39 -22
- package/src/cli/reset.ts +4 -2
- package/src/cli/statusline-tee.ts +5 -2
- package/src/cli/terminal-recovery.ts +73 -16
- package/src/cli/upgrade-restore-state.ts +16 -7
- package/src/cli/upgrade-runtime.ts +83 -52
- package/src/cli/upgrade.ts +43 -27
- package/src/hub/board.ts +29 -7
- package/src/hub/budget.ts +55 -3
- package/src/hub/bus.ts +12 -6
- package/src/hub/claude-state.ts +37 -0
- 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/cost-governance.ts +76 -0
- package/src/hub/crash.ts +14 -5
- package/src/hub/daemon.ts +473 -194
- package/src/hub/execution-budget.ts +3 -3
- package/src/hub/hub-tools.ts +36 -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 +114 -0
- package/src/hub/lifecycle-schema.ts +7 -3
- package/src/hub/permission-mode.ts +7 -7
- package/src/hub/review-brief.ts +175 -0
- package/src/hub/roster.ts +7 -6
- package/src/hub/routing.ts +97 -18
- package/src/hub/sdlc-queue.ts +2 -2
- package/src/hub/sdlc.ts +286 -39
- 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 +18 -0
- package/src/hub/task-sweep.ts +143 -0
- package/src/hub/tasks.ts +595 -47
- package/src/hub/text-projection.ts +7 -3
- package/src/hub/usage.ts +57 -20
- 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
|
@@ -14,17 +14,24 @@ import { needs, type NeedsInput } from "../ui/needs.ts";
|
|
|
14
14
|
import { loadPetConfig } from "../pet/config.ts";
|
|
15
15
|
import { projectionConfig, TextProjections, type ProjectionConfig } from "./text-projection.ts";
|
|
16
16
|
import { policyPaths, matchesPolicy } from "./path-policy.ts";
|
|
17
|
+
import { buildReviewBrief } from "./review-brief.ts";
|
|
17
18
|
import { profileName, projectProfile, resolveProfile, mergeProfileOverrides, type ProfileName } from "./profiles.ts";
|
|
18
19
|
import { catalogAssignments, loadRoleCatalog, selectedRoles, roleIdentity } from "./roles.ts";
|
|
19
20
|
import { ActivityRing } from "./activity.ts";
|
|
20
21
|
import { JcodePeer } from "../adapters/jcode.ts";
|
|
21
22
|
import { jcodeMcpReady } from "../cli/jcode-mcp.ts";
|
|
22
|
-
import { loadSdlc, sdlcGh } from "./sdlc.ts";
|
|
23
|
+
import { loadSdlc, readSpecEdit, sdlcGh } from "./sdlc.ts";
|
|
24
|
+
import { claudeStateFile, ownTerminalRow, pathEvidence, transcriptPersisted } from "./claude-state.ts";
|
|
23
25
|
import { peerRosterConfig, rosterAllows, type PeerRoster } from "./roster.ts";
|
|
24
26
|
import { KNOWN_PEERS, OPTIONAL_PEERS, PLANNER_PEERS, SUPPORTED_PEERS, isSupportedPeer, STARTABLE_PEERS, isStartablePeer } from "../adapters/registry.ts";
|
|
25
27
|
import { RecoveryDeferredHubError } from "./deferred-hub.ts";
|
|
26
28
|
import { databaseBusySite, isDatabaseBusy, RecoveryDatabaseBusy } from "./recovery-error.ts";
|
|
27
29
|
import { ADAPTERS, QUOTA_HANDOFF_PLATFORMS, NATIVE_CONFIG_BLOCKS, nativeConfigurationDefaults, mergeNativeConfiguration, retainNativeRouting, DAEMON_PORTS, definitionOf } from "../adapters/registry.ts";
|
|
30
|
+
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";
|
|
31
|
+
import { effectiveInstanceRoles, instancePeerIds, validateInstances, type InstancesConfig } from "./instances.ts";
|
|
32
|
+
import { INSTANCE_CONFIG_FILES, readInstanceConfig, readInstanceConfigLayers, readInstanceRoleConfig } from "./instance-config.ts";
|
|
33
|
+
import { instanceBudgetHooks, projectBudgetHeadroom } from "./instance-budget.ts";
|
|
34
|
+
import { resolveInstanceExecutionCwd } from "./instance-execution.ts";
|
|
28
35
|
import { ProgressObserver, normalizeCodexObservation, normalizeClaudeObservation } from "./progress.ts";
|
|
29
36
|
import type { ToolObservation } from "../models/route/signals.ts";
|
|
30
37
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
@@ -41,7 +48,7 @@ import { resolveSettledRoles } from "./settled-roles.ts";
|
|
|
41
48
|
import { CONFIG_SOURCE_FILES, configFingerprint, configSourceTexts, parseConfigSources, type ConfigSources } from "./config-reload.ts";
|
|
42
49
|
import { eventLog, readEvents, tokenDeltas, type RoleIdentity } from "./events.ts";
|
|
43
50
|
import { ExecutionBudget } from "./execution-budget.ts";
|
|
44
|
-
import { claudeReportedTokens, readClaudeTranscriptUsage, waitForClaudeCompletion } from "./usage.ts";
|
|
51
|
+
import { claudeReportedTokens, readClaudeTranscriptCompletion, readClaudeTranscriptUsage, waitForClaudeCompletion } from "./usage.ts";
|
|
45
52
|
import type { ServerWebSocket } from "bun";
|
|
46
53
|
import { AcpPeer, QwenIdentityError, type PermissionRequest } from "../adapters/acp.ts";
|
|
47
54
|
import { CodexPeer } from "../adapters/codex-appserver.ts";
|
|
@@ -56,6 +63,7 @@ import { profile, proxyEnv, type SandboxNetwork } from "../local/sandbox.ts";
|
|
|
56
63
|
import { DEFAULT_NETWORK_ALLOW, startEgressProxy, type EgressProxy } from "../local/proxy.ts";
|
|
57
64
|
import { runTool, toolResultFailed, TOOL_SCHEMAS, type ToolApproval, type ApprovalObserver, type ToolContext } from "../local/tools.ts";
|
|
58
65
|
import { LocalPeer } from "../adapters/local-worker.ts";
|
|
66
|
+
import { reviewDiff, ClaudePrintPeer } from "../adapters/claude-print.ts";
|
|
59
67
|
import { Capture, skipTools } from "../memory/capture.ts";
|
|
60
68
|
import { DEFAULT_OMNIROUTE, OmniRoute, type OmniRouteConfig } from "../omniroute/client.ts";
|
|
61
69
|
import { Sidecar } from "../switchyard/sidecar.ts";
|
|
@@ -64,12 +72,12 @@ import { Board, isClosedTaskState, taskProposalAt, taskHasStarted, CLASSES, type
|
|
|
64
72
|
import { Budget, claudeWindows, codexWindows, DEFAULT_BUDGET, type BudgetConfig } from "./budget.ts";
|
|
65
73
|
import { HUB } from "./envelope.ts";
|
|
66
74
|
import { recallMemoryLines, trimToTokens } from "../memory/recall.ts";
|
|
67
|
-
import { closeSync, constants as fsConstants, openSync, readSync, statSync } from "node:fs";
|
|
75
|
+
import { closeSync, constants as fsConstants, openSync, lstatSync, readSync, statSync } from "node:fs";
|
|
68
76
|
import { basename, isAbsolute } from "node:path";
|
|
69
77
|
import { fileURLToPath } from "node:url";
|
|
70
78
|
import { realPath, hubHome } from "./project.ts";
|
|
71
79
|
import type { BusEvent } from "./bus.ts";
|
|
72
|
-
import { CONDUCTOR_TOOLS, CONDUCTOR_TOOL_NAMES, DEFAULT_ROLES, roleContract, TASK_TOOLS, BOUNDED_TASK_TOOLS, profileTaskTools } from "./hub-tools.ts";
|
|
80
|
+
import { CONDUCTOR_TOOLS, CONDUCTOR_TOOL_NAMES, DEFAULT_ROLES, roleContract, TASK_TOOL_NAMES, TASK_TOOLS, BOUNDED_TASK_TOOLS, profileTaskTools } from "./hub-tools.ts";
|
|
73
81
|
import { Conductor, ConductorHolds, conductorPeer, conductorProgressSink, publicConductorTask, publicPeerBudget, type ConductEvent } from "./conductor.ts";
|
|
74
82
|
import { SupervisionFeed } from "./supervision.ts";
|
|
75
83
|
import { drainCliAudits } from "../cli/identity-audit.ts";
|
|
@@ -78,7 +86,7 @@ import { AssignmentUndeliveredError, ESCALATE_AFTER, Tasks } from "./tasks.ts";
|
|
|
78
86
|
import { DEFAULT_INFERENCE, DIGEST, Inference, screenPii, type InferenceConfig } from "./inference.ts";
|
|
79
87
|
import { ask, ASK_NOTE_TITLE, RUN_START } from "./ask.ts";
|
|
80
88
|
import { currentRouting, detectSignals, OVERLAY_FILE, overlayToml, parseRouting, routingText, type Routing } from "./routing.ts";
|
|
81
|
-
import {
|
|
89
|
+
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
90
|
import { Bus } from "./bus.ts";
|
|
83
91
|
import { attribute, deliveryTask, type TaskAttribution } from "./attribution.ts";
|
|
84
92
|
import { DeliveryJournal } from "./delivery-journal.ts";
|
|
@@ -118,6 +126,8 @@ import { nativeRecall } from "../memory/recall.ts";
|
|
|
118
126
|
import { promotionScreen, withoutPii, mentionsPrivatePath } from "./memory-policy.ts";
|
|
119
127
|
|
|
120
128
|
export interface HubConfig {
|
|
129
|
+
/** Person-owned declarations, read only from an untracked machine-local file. Bound for this daemon lifetime. */
|
|
130
|
+
instances?: InstancesConfig;
|
|
121
131
|
watchdog_ms: number;
|
|
122
132
|
kimi_cmd: string[];
|
|
123
133
|
qwen_cmd: string[];
|
|
@@ -233,11 +243,11 @@ export const DEFAULT_CONFIG: HubConfig = {
|
|
|
233
243
|
export { stateDirFor };
|
|
234
244
|
|
|
235
245
|
/** Ids an external process may not claim: the console user and the adapters the daemon runs itself. */
|
|
236
|
-
const
|
|
246
|
+
const BASE_RESERVED_IDS = new Set<string>([USER, ...SUPPORTED_PEERS.filter(peer => definitionOf(peer)?.identity.mcp !== "channel-plugin"), HUB, DIGEST]);
|
|
237
247
|
const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
|
|
238
248
|
|
|
239
249
|
/** The shared project config, then the machine's own file, which overrides it block by block (issue #17). */
|
|
240
|
-
const CONFIG_FILES =
|
|
250
|
+
const CONFIG_FILES = INSTANCE_CONFIG_FILES;
|
|
241
251
|
export interface ConfigLayer { name: typeof CONFIG_FILES[number]; raw: Record<string, any> }
|
|
242
252
|
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
253
|
|
|
@@ -285,18 +295,22 @@ function syncMinutes(v: unknown): number {
|
|
|
285
295
|
}
|
|
286
296
|
|
|
287
297
|
export function loadConfig(cwd: string, layers?: readonly ConfigLayer[]): HubConfig {
|
|
288
|
-
const
|
|
298
|
+
const captured = layers ?? readInstanceConfigLayers(cwd);
|
|
299
|
+
const instanceConfig = readInstanceConfig(cwd, captured);
|
|
300
|
+
const instances = instanceConfig.instances;
|
|
301
|
+
const ignored: string[] = [...instanceConfig.ignored];
|
|
289
302
|
const appliedPermissionSources: NonNullable<HubConfig["permission_default_sources"]> = {};
|
|
290
303
|
const files = CONFIG_FILES.flatMap((name) => {
|
|
291
304
|
let file: Record<string, any>; // parsed JSON, checked field by field below
|
|
292
|
-
if (
|
|
305
|
+
if (!captured.some(layer => layer.name === name)) return [];
|
|
293
306
|
try {
|
|
294
|
-
file =
|
|
307
|
+
file = structuredClone(captured.find(layer => layer.name === name)!.raw);
|
|
295
308
|
} catch (error) {
|
|
296
309
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
297
310
|
throw error;
|
|
298
311
|
}
|
|
299
|
-
|
|
312
|
+
delete file.instances; // Declaration authority was checked against its exact captured origin above.
|
|
313
|
+
peerRosterConfig(file.roster, instances);
|
|
300
314
|
catalogAssignments(file.catalog_roles);
|
|
301
315
|
profileName(file.profile);
|
|
302
316
|
resolveProfile(file.profile, file.profile_overrides);
|
|
@@ -311,14 +325,14 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[]): HubCon
|
|
|
311
325
|
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
326
|
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
327
|
instructionProtection(file.protect_instruction_files);
|
|
314
|
-
permissionDefaults(file.permission_modes); // malformed declarations fail even when trust would drop them
|
|
315
|
-
peerStartConfig(file.peers);
|
|
328
|
+
permissionDefaults(file.permission_modes, instances); // malformed declarations fail even when trust would drop them
|
|
329
|
+
peerStartConfig(file.peers, instances);
|
|
316
330
|
if (file.terminal != null && (typeof file.terminal !== "object" || Array.isArray(file.terminal))) throw new Error("terminal must be an object");
|
|
317
331
|
terminalTemplate(file.terminal?.open);
|
|
318
332
|
delete file.permission_default_sources; // provenance is loader-owned, never a file claim
|
|
319
333
|
const why = stripUntrusted(file, DEFAULT_CONFIG, cwd, name);
|
|
320
334
|
if (why) ignored.push(why);
|
|
321
|
-
for (const [peer, mode] of Object.entries(permissionDefaults(file.permission_modes))) appliedPermissionSources[peer] = { mode, source: `.agenthub/${name}` };
|
|
335
|
+
for (const [peer, mode] of Object.entries(permissionDefaults(file.permission_modes, instances))) appliedPermissionSources[peer] = { mode, source: `.agenthub/${name}` };
|
|
322
336
|
return [file];
|
|
323
337
|
});
|
|
324
338
|
if (!files.length) return DEFAULT_CONFIG;
|
|
@@ -346,23 +360,34 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[]): HubCon
|
|
|
346
360
|
throw new Error("legacy MLX configuration requires explicit mlx.provider=legacy; migrate to provider=ollama to avoid Python serving");
|
|
347
361
|
}
|
|
348
362
|
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,
|
|
363
|
+
const conductorPolicy = loadConductorPolicy(cwd, captured);
|
|
364
|
+
const extraRoles = Object.fromEntries(Object.keys(instances ?? {}).map(id => [id, effectiveInstanceRoles(id, instances, conductorPolicy.roles)]));
|
|
365
|
+
const instancePermissions: Record<string, PermissionMode> = {};
|
|
366
|
+
const instanceModes: PeerStartConfig = {};
|
|
367
|
+
for (const [id, declaration] of Object.entries(instances ?? {})) {
|
|
368
|
+
instanceModes[id] = { start_mode: declaration.start_mode ?? "headless" };
|
|
369
|
+
if (declaration.permission_mode !== undefined) {
|
|
370
|
+
instancePermissions[id] = declaration.permission_mode;
|
|
371
|
+
appliedPermissionSources[id] = { mode: declaration.permission_mode, source: ".agenthub/config.local.json" };
|
|
372
|
+
}
|
|
373
|
+
}
|
|
350
374
|
if (!mlx.enabled && file.pi?.backend === "mlx") throw new Error("Pi backend mlx conflicts with mlx.enabled=false");
|
|
351
375
|
if (typeof mlx.runtimeDir === "string" && mlx.runtimeDir) mlx.runtimeDir = resolve(cwd, mlx.runtimeDir);
|
|
352
376
|
if (typeof mlx.modelPath === "string" && mlx.modelPath) mlx.modelPath = resolve(cwd, mlx.modelPath);
|
|
353
377
|
return {
|
|
354
378
|
...DEFAULT_CONFIG,
|
|
355
379
|
...file,
|
|
380
|
+
...(instances ? { instances } : {}),
|
|
356
381
|
...(file.text_projection !== undefined ? { text_projection: projectionConfig(file.text_projection) } : {}),
|
|
357
382
|
...(file.pii !== undefined ? { pii: { paths: policyPaths(file.pii.paths, "pii.paths") } } : {}),
|
|
358
383
|
...(file.data !== undefined ? { data: { readonly: policyPaths(file.data.readonly, "data.readonly") } } : {}),
|
|
359
|
-
permission_modes: permissionDefaults(file.permission_modes),
|
|
384
|
+
permission_modes: { ...instancePermissions, ...permissionDefaults(file.permission_modes, instances) },
|
|
360
385
|
protect_instruction_files: instructionProtection(file.protect_instruction_files),
|
|
361
386
|
permission_default_sources: appliedPermissionSources,
|
|
362
387
|
memory: { ...DEFAULT_CONFIG.memory, native: true, ...file.memory, ...(file.memory?.short_ttl_days !== undefined ? { short_ttl_days: ttlDays(file.memory.short_ttl_days) } : {}),
|
|
363
388
|
...(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,
|
|
389
|
+
roster: peerRosterConfig(file.roster, instances),
|
|
390
|
+
roles: { ...conductorPolicy.roles, ...extraRoles },
|
|
366
391
|
planner: conductorPolicy.planner,
|
|
367
392
|
conductor: conductorPolicy.conductor,
|
|
368
393
|
budget: { ...DEFAULT_CONFIG.budget, wait_max_min: 30, ...file.budget }, // on with any project config (issue #36)
|
|
@@ -380,7 +405,7 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[]): HubCon
|
|
|
380
405
|
snapshots: { ...DEFAULT_CONFIG.snapshots, enabled: true, ...file.snapshots },
|
|
381
406
|
research: { enabled: file.research?.enabled === true }, // opt-in: anything but true is off
|
|
382
407
|
bench: { enabled: file.bench?.enabled === true }, // opt-in: a run resets this project's work tree
|
|
383
|
-
peers: peerStartConfig(file.peers),
|
|
408
|
+
peers: { ...instanceModes, ...peerStartConfig(file.peers, instances) },
|
|
384
409
|
terminal: { open: terminalTemplate(file.terminal?.open) },
|
|
385
410
|
limits: { ...PROJECT_LIMITS, ...file.limits }, // on with any project config (issue #38)
|
|
386
411
|
review: { ...DEFAULT_CONFIG.review, ...file.review },
|
|
@@ -473,6 +498,7 @@ class WsPeer extends BasePeer {
|
|
|
473
498
|
private nativeActive = false;
|
|
474
499
|
/** The attached session said it cannot show channel pushes (issue #205): the bus keeps its queue for `inbox`. */
|
|
475
500
|
pullOnly = false;
|
|
501
|
+
steer: PeerAdapter["steer"] = undefined;
|
|
476
502
|
lastInboxAt: number | undefined;
|
|
477
503
|
private claimedPullOnly = false;
|
|
478
504
|
/** Called at hello, before the async preface: the newest hello wins even if an older one's recall finishes last. */
|
|
@@ -500,6 +526,12 @@ class WsPeer extends BasePeer {
|
|
|
500
526
|
this.nativeSession = undefined;
|
|
501
527
|
this.nativeActive = false;
|
|
502
528
|
this.pullOnly = this.claimedPullOnly; // before idle: the idle transition drains
|
|
529
|
+
// Native TUI readback in #493 proves important channel input can be consumed before the turn ends.
|
|
530
|
+
// Pull-only transports expose no steer capability; the bus keeps their important work queued.
|
|
531
|
+
this.steer = this.pullOnly ? undefined : async (envs, deliveryId) => {
|
|
532
|
+
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");
|
|
533
|
+
await this.deliver(envs, deliveryId);
|
|
534
|
+
};
|
|
503
535
|
this.lastInboxAt = undefined;
|
|
504
536
|
this.setState("idle");
|
|
505
537
|
}
|
|
@@ -507,6 +539,7 @@ class WsPeer extends BasePeer {
|
|
|
507
539
|
if (this.claimed === sock) this.claimed = undefined;
|
|
508
540
|
if (this.sock !== sock) return;
|
|
509
541
|
this.sock = undefined;
|
|
542
|
+
this.steer = undefined;
|
|
510
543
|
this.nativeActive = false;
|
|
511
544
|
this.pullOnly = false;
|
|
512
545
|
this.setState("offline");
|
|
@@ -516,6 +549,7 @@ class WsPeer extends BasePeer {
|
|
|
516
549
|
this.sock?.close(4000, "peer is off roster; retry when a person adds it");
|
|
517
550
|
this.claimed = undefined;
|
|
518
551
|
this.sock = undefined;
|
|
552
|
+
this.steer = undefined;
|
|
519
553
|
this.nativeActive = false;
|
|
520
554
|
this.pullOnly = false;
|
|
521
555
|
this.setState("offline");
|
|
@@ -567,16 +601,61 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
567
601
|
let ready = false;
|
|
568
602
|
try {
|
|
569
603
|
const loadedConfig = opts.config ?? loadConfig(opts.cwd);
|
|
570
|
-
const lifecycleConfig = loadSdlc(opts.cwd);
|
|
604
|
+
const lifecycleConfig = loadSdlc(opts.cwd, VERSION);
|
|
571
605
|
// Preserve embedding-owned policy references, but never publish reloads into shared defaults.
|
|
572
606
|
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
|
|
607
|
+
const declarations = validateInstances(loadedConfig.instances);
|
|
608
|
+
const extraIds = Object.keys(declarations);
|
|
609
|
+
// #450 I3: channel extras (Claude) attach from the person's terminal; the hub never starts them and their hello is admitted.
|
|
610
|
+
const channelExtras = extraIds.filter(id => instanceDefinitionOf(id, declarations)?.identity.mcp === "channel-plugin");
|
|
611
|
+
// Every Claude-family peer whose own state files the daemon reads: the builtin channel kinds, then their declared instances.
|
|
612
|
+
const channelPeers = [...ADAPTERS.filter(definition => definition.identity.mcp === "channel-plugin").map(definition => definition.kind), ...channelExtras];
|
|
613
|
+
const executionRoots = new Map<string, string>();
|
|
614
|
+
const executionOwners = new Map<string, { owner: PeerAdapter; cwd: string }>();
|
|
615
|
+
const roles = { ...loadedConfig.roles };
|
|
616
|
+
const permissionModesFromInstances: Record<string, PermissionMode> = {};
|
|
617
|
+
const startModesFromInstances: PeerStartConfig = {};
|
|
618
|
+
const instancePermissionSources: NonNullable<HubConfig["permission_default_sources"]> = {};
|
|
619
|
+
for (const [id, declaration] of Object.entries(declarations)) {
|
|
620
|
+
roles[id] = effectiveInstanceRoles(id, declarations, loadedConfig.roles);
|
|
621
|
+
if (!channelExtras.includes(id)) startModesFromInstances[id] = { start_mode: "headless" };
|
|
622
|
+
if (declaration.permission_mode !== undefined) {
|
|
623
|
+
permissionModesFromInstances[id] = declaration.permission_mode;
|
|
624
|
+
instancePermissionSources[id] = { mode: declaration.permission_mode, source: ".agenthub/config.local.json" };
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
const config: HubConfig = { ...ownedConfig, ...(extraIds.length ? { instances: declarations } : {}),
|
|
628
|
+
roster: peerRosterConfig(loadedConfig.roster, declarations),
|
|
629
|
+
permission_modes: { ...permissionModesFromInstances, ...permissionDefaults(loadedConfig.permission_modes, declarations) },
|
|
630
|
+
peers: { ...startModesFromInstances, ...peerStartConfig(loadedConfig.peers, declarations) },
|
|
631
|
+
...(extraIds.length ? { permission_default_sources: { ...instancePermissionSources, ...loadedConfig.permission_default_sources } } : {}),
|
|
632
|
+
roles, conductor: { ...DEFAULT_CONFIG.conductor, ...loadedConfig.conductor } };
|
|
633
|
+
// The resolver and catalogs are bound to this daemon, never published into the process-global builtin registry.
|
|
634
|
+
const definitionOf = (peer: string) => instanceDefinitionOf(peer, declarations);
|
|
635
|
+
const KNOWN_PEERS: readonly string[] = [...BASE_KNOWN_PEERS, ...extraIds];
|
|
636
|
+
const SUPPORTED_PEERS: readonly string[] = [...BASE_SUPPORTED_PEERS, ...extraIds];
|
|
637
|
+
const STARTABLE_PEERS: readonly string[] = [...BASE_STARTABLE_PEERS, ...extraIds.filter(id => !channelExtras.includes(id))];
|
|
638
|
+
const isSupportedPeer = (peer: unknown): peer is string => typeof peer === "string" && definitionOf(peer) !== undefined;
|
|
639
|
+
const isStartablePeer = (peer: unknown): peer is string => typeof peer === "string" && !channelExtras.includes(peer) && definitionOf(peer)?.start.conductorStartable === true;
|
|
640
|
+
const RESERVED_IDS = new Set([...BASE_RESERVED_IDS, ...extraIds.filter(id => !channelExtras.includes(id))]);
|
|
641
|
+
// #223 R2: a declared instance whose effective roles are exactly reviewer gets review requests and its own task notices only.
|
|
642
|
+
const reviewOnlyPeer = (peer: string): boolean => Object.hasOwn(declarations, peer) && config.roles[peer]?.length === 1 && config.roles[peer]![0] === "reviewer";
|
|
643
|
+
/** #520: a task under review shows its review brief to whoever may read it (owner, reviewer, conductor). */
|
|
644
|
+
const briefField = (task: Task): { brief?: string } => { if (task.state !== "in_review") return {}; const text = tasks.briefText(task); return text ? { brief: text } : {}; };
|
|
645
|
+
/** #223 R2: a review-only instance reads the spec, refs and earlier verdicts of a task, never the builder's narrative. */
|
|
646
|
+
const reviewerView = (peer: string, view: Record<string, unknown>): Record<string, unknown> => !reviewOnlyPeer(peer) || !Array.isArray(view.history) ? view
|
|
647
|
+
: { ...view, history: (view.history as { event?: unknown; note?: unknown }[]).map(({ note, ...h }) => h.event === "approved" || h.event === "changes_requested" ? { ...h, note } : h) };
|
|
648
|
+
const reviewOnlyItem = (peer: string, env: Envelope): boolean => env.from === USER || env.from === HUB && (env.kind === "review" || (env.kind === "task" && env.to?.includes(peer) === true));
|
|
649
|
+
const SETTINGS = projectSettings(declarations);
|
|
650
|
+
const settingDef = (key: unknown) => instanceSettingDef(key, declarations);
|
|
651
|
+
const accountOf = (peer: string) => definitionOf(peer)?.budget?.account ?? peer;
|
|
652
|
+
const accountMembers = (account: string) => SUPPORTED_PEERS.filter(peer => accountOf(peer) === account);
|
|
574
653
|
let rosterChanging: string | undefined;
|
|
575
654
|
let kimiSessionMode: StartMode | undefined;
|
|
576
655
|
let kimiAdmissionId: string | undefined;
|
|
577
656
|
let kimiAdmissionExpiry: ReturnType<typeof setTimeout> | undefined;
|
|
578
657
|
const clearKimiAdmission = () => { clearTimeout(kimiAdmissionExpiry); kimiAdmissionExpiry = undefined; kimiSessionMode = undefined; kimiAdmissionId = undefined; };
|
|
579
|
-
const onRoster = (peer: string) => rosterAllows(config.roster, peer, config.pi.enabled);
|
|
658
|
+
const onRoster = (peer: string) => rosterAllows(config.roster, peer, config.pi.enabled, declarations);
|
|
580
659
|
// pi.enabled historically gates starts, not retained queues or tools sockets. Only an explicit roster.pi changes those.
|
|
581
660
|
const membershipAllowed = (peer: string) => definitionOf(peer)?.enabledSwitchStartOnly && config.roster[peer] === undefined ? true : onRoster(peer);
|
|
582
661
|
conductorPeer(config.roles); // reject ambiguous leadership before opening a control listener
|
|
@@ -621,6 +700,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
621
700
|
settlePlanner();
|
|
622
701
|
const catalog = loadRoleCatalog(opts.cwd);
|
|
623
702
|
for (const error of catalog.errors) log(`catalog role ${error.id}: ${error.reason}`);
|
|
703
|
+
for (const note of lifecycleConfig.deprecations ?? []) log(note);
|
|
624
704
|
const resolvedProfile = resolveProfile(config.profile, config.profile_overrides);
|
|
625
705
|
let roleAssignments = { ...resolvedProfile.catalog_roles, ...catalogAssignments(config.catalog_roles) };
|
|
626
706
|
const event = eventLog(join(opts.stateDir, "events.jsonl"), (entry) => {
|
|
@@ -807,6 +887,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
807
887
|
* has not stopped since its cohort task closed. Workflow events and anything to the console are never held back.
|
|
808
888
|
*/
|
|
809
889
|
const silence = (env: Envelope, to: PeerId): string | undefined => {
|
|
890
|
+
if (reviewOnlyPeer(to) && env.to?.includes(to) && !reviewOnlyItem(to, env)) return `${to} is a review-only instance: it receives review requests only (send a review through the task board)`;
|
|
810
891
|
if (!turnFree() || env.from === USER || env.from === HUB || env.from === DIGEST || env.priority === "fyi" || env.kind !== "chat" || to === USER) return undefined;
|
|
811
892
|
const cohort = tasks.silenced(env.from, to);
|
|
812
893
|
if (!cohort) return undefined;
|
|
@@ -849,7 +930,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
849
930
|
const staleOff = () => config.experiments?.stale_notices === "deliver";
|
|
850
931
|
if (staleOff()) log("experiment: stale notices are delivered as before 0.12.4 (the issue #106 ablation)");
|
|
851
932
|
const requestedPeerStops = new Set<string>();
|
|
852
|
-
const bus = new Bus({ rostered: membershipAllowed, deliveryHeld: peer => requestedPeerStops.has(peer) || rosterChanging === peer, journal, batchMax: config.batch_max, batchMs: config.batch_ms, queueCap: config.queue_cap, condense: (envs) => inference?.condense(envs) ?? Promise.resolve(envs), admit, relevant: (peer, env) => env.refs?.boundedCheck || env.refs?.supervision || env.refs?.backlog ? relevantNotice(peer, env) : staleOff() || relevantNotice(peer, env), silence,
|
|
933
|
+
const bus = new Bus({ rostered: membershipAllowed, deliveryHeld: peer => requestedPeerStops.has(peer) || rosterChanging === peer, journal, batchMax: config.batch_max, batchMs: config.batch_ms, queueCap: config.queue_cap, condense: (envs) => inference?.condense(envs) ?? Promise.resolve(envs), admit, relevant: (peer, env) => reviewOnlyPeer(peer) && !reviewOnlyItem(peer, env) ? `not for ${peer}: a review-only instance receives review requests only` : env.refs?.boundedCheck || env.refs?.supervision || env.refs?.backlog ? relevantNotice(peer, env) : staleOff() || relevantNotice(peer, env), silence,
|
|
853
934
|
onImportantQueued: (peer, counts) => { const p = bus.peers.get(peer); if (p instanceof WsPeer) p.notifyQueued(counts); } });
|
|
854
935
|
startupCleanup.push(() => bus.closeJournal());
|
|
855
936
|
const manualPaused = new Set<PeerId>(bus.manualPausedPeers()); // recovery never lifts an operator's pause
|
|
@@ -1015,7 +1096,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1015
1096
|
|
|
1016
1097
|
/** Session-start cross-platform recall. Awaited before the peer can receive anything, so it rides on the first delivery. Fail-open. */
|
|
1017
1098
|
async function ensurePreface(peer: PeerId): Promise<void> {
|
|
1018
|
-
if ((!config.memory.enabled && !memoryStore) || recalled.has(peer)) return;
|
|
1099
|
+
if ((!config.memory.enabled && !memoryStore) || recalled.has(peer) || reviewOnlyPeer(peer)) return; // #223 R2: no shared memory in a fresh review
|
|
1019
1100
|
// The hub's own long-term decisions, contracts and fails lead; claude-mem's recent work follows, each with half the budget.
|
|
1020
1101
|
let own: string | undefined;
|
|
1021
1102
|
try {
|
|
@@ -1036,7 +1117,29 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1036
1117
|
|
|
1037
1118
|
const omni = new OmniRoute(config.omniroute, log);
|
|
1038
1119
|
let modelRelay: ModelRelay | undefined;
|
|
1039
|
-
|
|
1120
|
+
const piReceiptLedgers = new Map<string, PiToolReceipts>();
|
|
1121
|
+
const modelRelays = new Map<string, ModelRelay>();
|
|
1122
|
+
const globalModelRelay = (relay: ModelRelay) => { modelRelay = relay; };
|
|
1123
|
+
const nativeStateDir = (peer: string) => declarations[peer] ? join(opts.stateDir, "instances", peer) : opts.stateDir;
|
|
1124
|
+
const instanceAppPorts = new Map<string, number>();
|
|
1125
|
+
const instanceAppPort = (peer: string): number => {
|
|
1126
|
+
const prior = instanceAppPorts.get(peer);
|
|
1127
|
+
if (prior !== undefined) return prior;
|
|
1128
|
+
// The native server must bind its selected port itself. A competing bind is refused by its existing ownership probe.
|
|
1129
|
+
const held: { stop(closeActiveConnections?: boolean): void }[] = [];
|
|
1130
|
+
try {
|
|
1131
|
+
for (let attempt = 0; attempt <= instanceAppPorts.size + 2; attempt++) {
|
|
1132
|
+
const listener = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
|
|
1133
|
+
held.push(listener);
|
|
1134
|
+
const port = listener.port;
|
|
1135
|
+
if (port !== undefined && port !== opts.codexAppPort && port !== opts.codexProxyPort && ![...instanceAppPorts.values()].includes(port)) {
|
|
1136
|
+
instanceAppPorts.set(peer, port); return port;
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
throw new Error("no distinct instance app-server port available");
|
|
1140
|
+
} finally { for (const listener of held) listener.stop(true); }
|
|
1141
|
+
};
|
|
1142
|
+
const piCallsInFlight = () => [...piReceiptLedgers.values()].reduce((n, ledger) => n + ledger.inFlight, 0);
|
|
1040
1143
|
/**
|
|
1041
1144
|
* PII decisions need a positive answer about the path a hub-owned model call will really take: a gateway that
|
|
1042
1145
|
* answered and is not behind Access, and no sidecar that was generated against the off-campus URL. Unknown is no.
|
|
@@ -1086,7 +1189,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1086
1189
|
if (before && (before !== after || config.conductor.feed === "off")) { bus.revokeSupervision(before); supervision.resetPending(before); budgetFeedSeen.clear(); }
|
|
1087
1190
|
if (config.conductor.feed === "off" && after) bus.revokeSupervision(after);
|
|
1088
1191
|
};
|
|
1089
|
-
const executionBudget = new ExecutionBudget(join(opts.stateDir, "hub.db"));
|
|
1192
|
+
const executionBudget = new ExecutionBudget(join(opts.stateDir, "hub.db"), declarations);
|
|
1090
1193
|
startupCleanup.push(() => executionBudget.close());
|
|
1091
1194
|
// Completion checks (issue #7): only from a config file git does not track, one at a time, killed on stop.
|
|
1092
1195
|
const allowedChecks = new Set([...CLASSES, ...Object.keys(resolvedProfile.classes)]);
|
|
@@ -1191,6 +1294,28 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1191
1294
|
};
|
|
1192
1295
|
const tasks: Tasks = new Tasks({
|
|
1193
1296
|
capabilities: peerCapabilities, repository: memoryRepo,
|
|
1297
|
+
plannerBlocks: () => Object.fromEntries(bus.knownPeers().map(peer => {
|
|
1298
|
+
const codes: ("approvals" | "quota")[] = [];
|
|
1299
|
+
if ([...permissions.values()].some(request => request.peer === peer && request.expiresAt > Date.now())) codes.push("approvals");
|
|
1300
|
+
if (budget.record(peer)) codes.push("quota");
|
|
1301
|
+
return [peer, codes];
|
|
1302
|
+
})),
|
|
1303
|
+
instances: () => declarations,
|
|
1304
|
+
reviewOnly: reviewOnlyPeer,
|
|
1305
|
+
// #223: a headless reviewer's verdict holds only while the same child it started with still runs for that task.
|
|
1306
|
+
reviewAuthority: (by, task) => {
|
|
1307
|
+
if (definitionOf(by)?.start.daemonPort !== "claude-print") return undefined;
|
|
1308
|
+
const peer = bus.peers.get(by) as ClaudePrintPeer | undefined, token = peer?.reviewToken(task);
|
|
1309
|
+
return () => token !== undefined && bus.peers.get(by) === peer && peer?.reviewToken(task) === token ? undefined : `no review of task #${task} is running for ${by}`;
|
|
1310
|
+
},
|
|
1311
|
+
// #223 R4: fresh only when the verdict comes from a reviewer session the hub started for this task's review.
|
|
1312
|
+
reviewContext: (by, task) => definitionOf(by)?.start.daemonPort === "claude-print" && (bus.peers.get(by) as ClaudePrintPeer | undefined)?.reviewing === task ? "fresh" : "continuing",
|
|
1313
|
+
// Proof belongs to the adapter generation actually started in the validated root. A role-only
|
|
1314
|
+
// reload cannot turn a canonical reviewer process into an isolated implementation owner.
|
|
1315
|
+
ownerIsolation: () => Object.fromEntries(extraIds.map(id => {
|
|
1316
|
+
const bound = executionOwners.get(id);
|
|
1317
|
+
return [id, !!bound && bound.owner === bus.peers.get(id) && bound.cwd !== opts.cwd && bound.cwd === executionRoots.get(id)];
|
|
1318
|
+
})),
|
|
1194
1319
|
recordPlanning: change => event({ type: "planning", ...change }),
|
|
1195
1320
|
wipLimit: peer => Object.hasOwn(config.tasks.wip_limits ?? {}, peer) ? config.tasks.wip_limits![peer]! : 1,
|
|
1196
1321
|
escalateAfter: () => config.tasks.escalate_after ?? ESCALATE_AFTER,
|
|
@@ -1200,6 +1325,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1200
1325
|
catalog,
|
|
1201
1326
|
sessionRoles: peer => bus.peers.has(peer) && bus.stateOf(peer) !== "offline" ? preparedSessionRoles.get(peer) ?? [] : [],
|
|
1202
1327
|
projectedReview: task => reviewProjection(task),
|
|
1328
|
+
reviewBrief: task => {
|
|
1329
|
+
const root = executionRoot(task.owner ?? "");
|
|
1330
|
+
return buildReviewBrief(root, task.refs.commit, { privatePath: path => isDenied(path, config.local.deny) || matchesPolicy(root, path, config.pii?.paths ?? [], true), nameable: tasks.nameable });
|
|
1331
|
+
},
|
|
1203
1332
|
sdlc: { config: () => lifecycleConfig, gh: (args, timeout, _cwd, bound) => sdlcGh(args, timeout, opts.cwd, bound) },
|
|
1204
1333
|
board,
|
|
1205
1334
|
bus,
|
|
@@ -1247,7 +1376,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1247
1376
|
// The PII screen (issue #198): the model on this machine when MLX/Ollama is enabled, else the gateway on campus only.
|
|
1248
1377
|
piiScreen: (text) => screenPii(text, { ...(config.mlx.enabled === false ? {} : { device: () => ensureMlx(config.mlx) }), onCampus, omni, fixedModel: () => activeRouting().local.fixed_model }),
|
|
1249
1378
|
recordScreen: (record) => event({ type: "pii_screen", ...record }),
|
|
1250
|
-
quota: (): ReturnType<Budget["headroom"]> => budget.headroom(), // budget is built below; this runs at assignment time
|
|
1379
|
+
quota: (): ReturnType<Budget["headroom"]> => projectBudgetHeadroom(budget.headroom(), accountMembers), // budget is built below; this runs at assignment time
|
|
1251
1380
|
review: config.review,
|
|
1252
1381
|
sweep: taskSweepConfig(config.task_sweep),
|
|
1253
1382
|
sweepHeld: () => stopping || recoveryActive(),
|
|
@@ -1264,12 +1393,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1264
1393
|
integrationFacts: (peer) => {
|
|
1265
1394
|
if (!factsOn()) return undefined;
|
|
1266
1395
|
let offered: ReturnType<Facts["due"]>;
|
|
1267
|
-
try { offered =
|
|
1396
|
+
try { offered = factsFor(peer).due(peer, undefined, true); } catch (error) { log(`integration facts for ${peer}: ${(error as Error).message}`); return undefined; }
|
|
1268
1397
|
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
1398
|
return offered ? { id: offered.id, text: offered.text } : undefined;
|
|
1270
1399
|
},
|
|
1271
1400
|
ackFacts: (peer, id) => acked(peer, id, "done"),
|
|
1272
|
-
factsCurrent: (peer) => !factsOn() ||
|
|
1401
|
+
factsCurrent: (peer) => !factsOn() || factsFor(peer).current(peer),
|
|
1273
1402
|
splitProfile: (peer) => splitProfile(peer),
|
|
1274
1403
|
recordCohort: (c) => event({ type: "cohort", ...c }),
|
|
1275
1404
|
// The trace holds peer names and numbers only (never task text): it is the inputs a later check of the prediction needs.
|
|
@@ -1314,6 +1443,34 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1314
1443
|
const repositoryFacts = !!factsRepo || profileName(config.profile) === "dev";
|
|
1315
1444
|
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
1445
|
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) });
|
|
1446
|
+
const worktreeFacts = new Map<string, Facts>();
|
|
1447
|
+
const worktreeProjections = new Map<string, TextProjections>();
|
|
1448
|
+
const executionRoot = (peer: string) => executionRoots.get(peer) ?? opts.cwd;
|
|
1449
|
+
const repoFor = (peer: string) => executionRoot(peer) === opts.cwd ? factsRepo : repoOf(executionRoot(peer));
|
|
1450
|
+
const projectionsFor = (peer: string): TextProjections => {
|
|
1451
|
+
const root = executionRoot(peer);
|
|
1452
|
+
if (root === opts.cwd) return projections;
|
|
1453
|
+
let projection = worktreeProjections.get(peer);
|
|
1454
|
+
if (!projection) {
|
|
1455
|
+
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 });
|
|
1456
|
+
worktreeProjections.set(peer, projection);
|
|
1457
|
+
}
|
|
1458
|
+
return projection;
|
|
1459
|
+
};
|
|
1460
|
+
const factsFor = (peer: string): Facts => {
|
|
1461
|
+
const root = executionRoot(peer);
|
|
1462
|
+
if (root === opts.cwd) return facts;
|
|
1463
|
+
let store = worktreeFacts.get(peer);
|
|
1464
|
+
if (!store) {
|
|
1465
|
+
const projection = projectionsFor(peer);
|
|
1466
|
+
store = new Facts({ root, repo: repoFor(peer), tmp: join(nativeStateDir(peer), "facts"), instance: `${instanceId.slice(0, 8)}-${peer}`,
|
|
1467
|
+
scope: id => scopes.has(id) ? scopes.get(id) : scopes.set(id, tasks.factScope(id)).get(id),
|
|
1468
|
+
peers: () => [...bus.peers.keys()].filter(id => executionRoot(id) === root), nameable: tasks.nameable, deny: config.local.deny,
|
|
1469
|
+
privatePath: path => matchesPolicy(root, path, config.pii?.paths ?? []), projection: file => projection.project(file) });
|
|
1470
|
+
worktreeFacts.set(peer, store);
|
|
1471
|
+
}
|
|
1472
|
+
return store;
|
|
1473
|
+
};
|
|
1317
1474
|
const progress = new ProgressObserver({
|
|
1318
1475
|
tasks: () => board.list(),
|
|
1319
1476
|
isPrivate: () => [...turns.values()].some(turn => turn.private) || board.list().some(task => !isClosedTaskState(task.state) && tasks.isPii(task)),
|
|
@@ -1333,7 +1490,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1333
1490
|
let factsWereOn = false;
|
|
1334
1491
|
const factsOn = (): boolean => {
|
|
1335
1492
|
const on = repositoryFacts && turnFree();
|
|
1336
|
-
if (on && !factsWereOn) facts.reset();
|
|
1493
|
+
if (on && !factsWereOn) { facts.reset(); for (const store of worktreeFacts.values()) store.reset(); }
|
|
1337
1494
|
// A PII task opened: every silent cohort speaks again, for good, and its members hear so at once (issue #108).
|
|
1338
1495
|
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
1496
|
factsWereOn = on;
|
|
@@ -1351,10 +1508,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1351
1508
|
probes.delete(peer);
|
|
1352
1509
|
}
|
|
1353
1510
|
factSessions.set(peer, id);
|
|
1354
|
-
|
|
1511
|
+
factsFor(peer).session(peer, id);
|
|
1355
1512
|
};
|
|
1356
1513
|
const acked = (peer: PeerId, id: string, via: string) => {
|
|
1357
|
-
const got =
|
|
1514
|
+
const got = factsFor(peer).ack(peer, id);
|
|
1358
1515
|
if (!got) return;
|
|
1359
1516
|
event({ type: "fact_ack", peer, id, via, ms: Date.now() - got.at });
|
|
1360
1517
|
if (via !== "done" && !capable.has(peer)) {
|
|
@@ -1374,7 +1531,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1374
1531
|
* integration request wait for the next done, not a readback, so they never count.
|
|
1375
1532
|
*/
|
|
1376
1533
|
const checkCapability = (peer: PeerId) => {
|
|
1377
|
-
if (capable.has(peer) &&
|
|
1534
|
+
if (capable.has(peer) && factsFor(peer).pending(peer).filter((o) => !o.done && Date.now() - o.at > 60_000).length >= 3) {
|
|
1378
1535
|
capable.delete(peer);
|
|
1379
1536
|
loseCapability(peer, "three facts found no readback");
|
|
1380
1537
|
}
|
|
@@ -1386,11 +1543,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1386
1543
|
*/
|
|
1387
1544
|
const offerFor = (peer: PeerId, toolUseId?: string) => {
|
|
1388
1545
|
if (!repositoryFacts) return undefined;
|
|
1389
|
-
if (!capable.has(peer) &&
|
|
1390
|
-
const due =
|
|
1391
|
-
if (due || capable.has(peer) ||
|
|
1546
|
+
if (!capable.has(peer) && factsFor(peer).pending(peer).filter((o) => !o.done).length >= 3) return undefined;
|
|
1547
|
+
const due = factsFor(peer).due(peer, toolUseId);
|
|
1548
|
+
if (due || capable.has(peer) || factsFor(peer).pending(peer).some((o) => o.probe) || (probes.get(peer) ?? 0) >= 3) return due;
|
|
1392
1549
|
probes.set(peer, (probes.get(peer) ?? 0) + 1);
|
|
1393
|
-
return
|
|
1550
|
+
return factsFor(peer).probe(peer, toolUseId);
|
|
1394
1551
|
};
|
|
1395
1552
|
// Claude's readback: the transcript row Claude Code writes for a hook's additional context, by tool use id.
|
|
1396
1553
|
const injected = new Map<string, string>();
|
|
@@ -1425,8 +1582,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1425
1582
|
} catch { /* no transcript yet: no readback */ }
|
|
1426
1583
|
};
|
|
1427
1584
|
/** Claude's transcript for this session: the one the status line reported, or the hook's own, inside Claude's projects. */
|
|
1428
|
-
const claudeTranscript = (sessionId: string | undefined, fromHook: unknown): string | undefined => {
|
|
1429
|
-
const reported = claudeSession();
|
|
1585
|
+
const claudeTranscript = (sessionId: string | undefined, fromHook: unknown, peer = "claude"): string | undefined => {
|
|
1586
|
+
const reported = claudeSession(peer);
|
|
1430
1587
|
if (reported.transcriptPath && (!sessionId || reported.sessionId === sessionId)) return reported.transcriptPath;
|
|
1431
1588
|
if (!sessionId || typeof fromHook !== "string" || !isAbsolute(fromHook) || basename(fromHook) !== `${sessionId}.jsonl`) return undefined;
|
|
1432
1589
|
try {
|
|
@@ -1436,7 +1593,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1436
1593
|
} catch { return undefined; }
|
|
1437
1594
|
};
|
|
1438
1595
|
const readbacks = (peer: PeerId, transcript: string | undefined) => {
|
|
1439
|
-
const pending =
|
|
1596
|
+
const pending = factsFor(peer).pending(peer).filter((o) => o.toolUseId);
|
|
1440
1597
|
if (!pending.length || !transcript) return;
|
|
1441
1598
|
readTranscript(transcript);
|
|
1442
1599
|
for (const o of pending) if (injected.get(o.toolUseId!)?.includes(`[${o.id}]`)) acked(peer, o.id, "hook");
|
|
@@ -1450,6 +1607,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1450
1607
|
const activeAt = new Map<PeerId, number>();
|
|
1451
1608
|
interface NativeTurnStart { at: number; baseline?: string; generation: string; session: string; launch: string }
|
|
1452
1609
|
const nativeTurnStart = new Map<PeerId, NativeTurnStart>();
|
|
1610
|
+
const stopUnconfirmedNoticed = new WeakSet<NativeTurnStart>();
|
|
1453
1611
|
const nativeStopWaits = new Map<PeerId, { start: NativeTurnStart; wait: ReturnType<typeof waitForClaudeCompletion> }>();
|
|
1454
1612
|
/** Every Claude hook call's own start-up and the hub's time for it, summed per turn (issue #108): reported at Stop. */
|
|
1455
1613
|
const hookStats = new Map<PeerId, { n: number; startupMs: number; hubMs: number; maxStartupMs: number }>();
|
|
@@ -1493,12 +1651,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1493
1651
|
const contextSession = (peer: PeerId): string | undefined => {
|
|
1494
1652
|
const p = bus.peers.get(peer);
|
|
1495
1653
|
if (p instanceof WsPeer && p.claiming) return undefined;
|
|
1496
|
-
if (definitionOf(peer)?.turnEnd === "native-hook") { const c = claudeSession(); return c.sessionId ? `${c.sessionId}:${c.launchId ?? ""}` : undefined; }
|
|
1654
|
+
if (definitionOf(peer)?.turnEnd === "native-hook") { const c = claudeSession(peer); return c.sessionId ? `${c.sessionId}:${c.launchId ?? ""}` : undefined; }
|
|
1497
1655
|
if (p instanceof CodexPeer) return p.thread || undefined;
|
|
1498
1656
|
if (p instanceof AcpPeer || p instanceof PiPeer) { const id = p.recoveryMetadata().sessionId; return typeof id === "string" ? id : undefined; }
|
|
1499
1657
|
return undefined;
|
|
1500
1658
|
};
|
|
1501
|
-
const activityRings: Record<string, ActivityRing> = Object.fromEntries(
|
|
1659
|
+
const activityRings: Record<string, ActivityRing> = Object.fromEntries(SUPPORTED_PEERS.filter(peer => definitionOf(peer)?.capabilities?.watch).map(peer => [peer, new ActivityRing()]));
|
|
1502
1660
|
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
1661
|
const requestCheckpoint = (peer: PeerId, reason: "quota" | "context"): Promise<string | undefined> => {
|
|
1504
1662
|
// One outstanding request per peer. A quota transition may proceed without a summary rather than steal a context waiter.
|
|
@@ -1546,21 +1704,21 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1546
1704
|
if (budgetFeedSeen.get(hold.peer) !== key && supervision.budgetPaused(hold.peer, hold.resetsAt)) budgetFeedSeen.set(hold.peer, key);
|
|
1547
1705
|
}
|
|
1548
1706
|
};
|
|
1549
|
-
const budget = new Budget(join(opts.stateDir, "hub.db"), config.budget, {
|
|
1707
|
+
const budget = new Budget(join(opts.stateDir, "hub.db"), config.budget, instanceBudgetHooks({
|
|
1550
1708
|
pause: (peer) => bus.pause(peer),
|
|
1551
1709
|
resume: (peer) => {
|
|
1552
1710
|
if (!manualPaused.has(peer) && !conductorHolds.has(peer)) bus.resume(peer);
|
|
1553
1711
|
},
|
|
1554
1712
|
requestCheckpoint: (peer) => requestCheckpoint(peer, "quota"),
|
|
1555
1713
|
platformContext: async (peer) => {
|
|
1556
|
-
const platform = PLATFORM[peer];
|
|
1714
|
+
const platform = PLATFORM[definitionOf(peer)?.kind ?? peer];
|
|
1557
1715
|
if (!config.memory.enabled || !platform) return undefined;
|
|
1558
1716
|
const text = await memory.contextInject(chain, platform);
|
|
1559
1717
|
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
1718
|
},
|
|
1561
1719
|
attached: (peer) => bus.peers.has(peer),
|
|
1562
1720
|
// 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))),
|
|
1721
|
+
canHandOff: (peer) => [...bus.peers.keys()].some((id) => accountOf(id) !== accountOf(peer) && ["idle", "busy"].includes(bus.stateOf(id))),
|
|
1564
1722
|
// A reading that arrived through a file carries the file's time; a stale one must not look fresh in the export.
|
|
1565
1723
|
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
1724
|
handoff: (peer, context, urgentOnly) => tasks.reassignForPause(peer, context, urgentOnly),
|
|
@@ -1586,7 +1744,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1586
1744
|
for (const id of superseded) bus.withdraw(id);
|
|
1587
1745
|
},
|
|
1588
1746
|
notify: (line) => { notify(line); feedBudgetPauses(); },
|
|
1589
|
-
});
|
|
1747
|
+
}, accountMembers, accountOf));
|
|
1590
1748
|
budget.setRecoveryHold(recoveryActive());
|
|
1591
1749
|
const kimiTokens: { at: number; n: number }[] = [];
|
|
1592
1750
|
startupCleanup.push(() => budget.close());
|
|
@@ -1646,7 +1804,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1646
1804
|
* 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
1805
|
* in snapshots.ts.
|
|
1648
1806
|
*/
|
|
1649
|
-
const snap = (what: string, capture = config.snapshots.enabled): { tree?: string; ms: number } => {
|
|
1807
|
+
const snap = (what: string, capture = config.snapshots.enabled, peer?: string): { tree?: string; ms: number } => {
|
|
1808
|
+
const repo = peer ? repoFor(peer) : factsRepo;
|
|
1650
1809
|
if (!capture) return { ms: 0 };
|
|
1651
1810
|
const t0 = performance.now();
|
|
1652
1811
|
let tree: string | undefined;
|
|
@@ -1662,18 +1821,26 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1662
1821
|
return [...turns.values()].some(turn => turn.private) || board.list().some(task => !isClosedTaskState(task.state) && tasks.isPii(task));
|
|
1663
1822
|
}
|
|
1664
1823
|
function reviewProjection(task: Task): string | undefined {
|
|
1824
|
+
const owner = task.owner ?? "", root = executionRoot(owner);
|
|
1825
|
+
const repo = repoFor(owner), projections = projectionsFor(owner);
|
|
1665
1826
|
if (!projections.enabled || tasks.isPii(task)) return undefined;
|
|
1666
1827
|
if (!config.snapshots.enabled || !repo || !turnLog) return "(projected review unavailable: snapshots are disabled or unavailable)";
|
|
1667
1828
|
if (privateSnapshotWindow()) return "(projected review unavailable: private snapshot window)";
|
|
1668
1829
|
const created = taskProposalAt(task);
|
|
1669
1830
|
const from = created === undefined ? undefined : turnLog.taskStart(task.id, created);
|
|
1670
1831
|
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 =>
|
|
1832
|
+
const scopes = [...(task.refs.paths ?? []), ...(task.plan?.paths ?? []), ...(task.plan?.sections ?? []).map(anchor => anchor.split("#")[0]!)].map(path => {
|
|
1833
|
+
const scoped = relative(root, resolve(root, path));
|
|
1834
|
+
if (root === opts.cwd || scoped !== ".." && !scoped.startsWith("../")) return scoped;
|
|
1835
|
+
// Task refs may name the canonical copy of this repository file while the owner executes its worktree copy.
|
|
1836
|
+
const canonical = relative(opts.cwd, resolve(root, path));
|
|
1837
|
+
return canonical !== ".." && !canonical.startsWith("../") && !canonical.startsWith(".worktrees/") ? canonical : scoped;
|
|
1838
|
+
});
|
|
1672
1839
|
if (!scopes.length) return "(projected review unavailable: task paths were not declared)";
|
|
1673
|
-
const to = snap(`task #${task.id} review
|
|
1840
|
+
const to = snap(`task #${task.id} review`, config.snapshots.enabled, task.owner ?? undefined).tree;
|
|
1674
1841
|
if (!to) return "(projected review unavailable: current snapshot is unknown)";
|
|
1675
1842
|
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(
|
|
1843
|
+
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
1844
|
}
|
|
1678
1845
|
const delta = tokenDeltas();
|
|
1679
1846
|
/** Tokens a peer used, as increments: Kimi reports a session total (turned into increments below), Codex increments. */
|
|
@@ -1687,18 +1854,18 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1687
1854
|
}
|
|
1688
1855
|
return n;
|
|
1689
1856
|
};
|
|
1690
|
-
const onKimiTokens = (total: number, session: string) => {
|
|
1691
|
-
const n = addTokens(
|
|
1857
|
+
const onKimiTokens = (total: number, session: string, peer = "kimi") => {
|
|
1858
|
+
const n = addTokens(peer, delta(peer, total, session));
|
|
1692
1859
|
if (!config.budget.kimi_tokens_5h || !n) return;
|
|
1693
1860
|
const now = Date.now();
|
|
1694
1861
|
kimiTokens.push({ at: now, n });
|
|
1695
1862
|
while (kimiTokens.length && now - kimiTokens[0]!.at > 5 * 3_600_000) kimiTokens.shift();
|
|
1696
1863
|
const used = kimiTokens.reduce((s, t) => s + t.n, 0) / config.budget.kimi_tokens_5h;
|
|
1697
|
-
budget.report(
|
|
1864
|
+
budget.report(peer, [{ id: "tokens", used, resetsAt: kimiTokens[0]!.at + 5 * 3_600_000, source: "kimi usage_update (soft limit)" }]);
|
|
1698
1865
|
};
|
|
1699
1866
|
// Claude's numbers arrive through the status line tee `ahub claude` installs (src/cli/statusline-tee.ts).
|
|
1700
1867
|
let claudeUsageSeen = 0;
|
|
1701
|
-
|
|
1868
|
+
const claudeContextSeen = new Map<string, string>();
|
|
1702
1869
|
const contextTailSeen = new Map<PeerId, string>();
|
|
1703
1870
|
const intervals = [
|
|
1704
1871
|
setInterval(() => {
|
|
@@ -1730,16 +1897,16 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1730
1897
|
for (const c of consoles) if (c.data.tail) c.send(JSON.stringify({ t: "context", peer, reading }));
|
|
1731
1898
|
}
|
|
1732
1899
|
}
|
|
1733
|
-
try {
|
|
1734
|
-
const file = join(opts.stateDir, "claude-context
|
|
1900
|
+
for (const claudePeer of channelPeers) try {
|
|
1901
|
+
const file = join(opts.stateDir, claudeStateFile("claude-context", claudePeer));
|
|
1735
1902
|
const payload = readFileSync(file, "utf8");
|
|
1736
|
-
if (payload === claudeContextSeen)
|
|
1903
|
+
if (payload === claudeContextSeen.get(claudePeer)) continue;
|
|
1737
1904
|
const c = JSON.parse(payload);
|
|
1738
|
-
const session = claudeSession();
|
|
1739
|
-
if (c.instanceId !== instanceId || !session.sessionId || c.sessionId !== session.sessionId || c.launchId !== session.launchId || !["idle", "busy", "paused"].includes(bus.stateOf(
|
|
1740
|
-
claudeContextSeen
|
|
1741
|
-
const bound = contextSession(
|
|
1742
|
-
if (bound) contexts.report(
|
|
1905
|
+
const session = claudeSession(claudePeer);
|
|
1906
|
+
if (c.instanceId !== instanceId || !session.sessionId || c.sessionId !== session.sessionId || c.launchId !== session.launchId || !["idle", "busy", "paused"].includes(bus.stateOf(claudePeer))) continue;
|
|
1907
|
+
claudeContextSeen.set(claudePeer, payload);
|
|
1908
|
+
const bound = contextSession(claudePeer);
|
|
1909
|
+
if (bound) contexts.report(claudePeer, claudeContext(c.context, bound, c.at), bound);
|
|
1743
1910
|
} catch { /* absent or unreadable native telemetry is unknown */ }
|
|
1744
1911
|
}, 1000),
|
|
1745
1912
|
setInterval(() => budget.tick(), 30_000),
|
|
@@ -1794,7 +1961,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1794
1961
|
const SERVER_JS = join(import.meta.dir, "..", "..", "plugins", "agent-hub", "server.js");
|
|
1795
1962
|
// What the hub's MCP server offers a native peer. Codex approves them in config, Kimi's requests for exactly these
|
|
1796
1963
|
// names are answered by the hub; every call still passes daemon role/capability checks before its effects.
|
|
1797
|
-
const HUB_TOOLS = ["hub_send", ...
|
|
1964
|
+
const HUB_TOOLS = ["hub_send", ...TASK_TOOL_NAMES, ...CONDUCTOR_TOOLS.map((t) => t.name)];
|
|
1798
1965
|
const HUB_TOOL_TITLES = new Set(HUB_TOOLS.map((name) => `mcp__agent-hub__${name}`));
|
|
1799
1966
|
const toolEnv = (peer: PeerId, deliveryOnly = false) => ({ ...(deliveryOnly ? { AGENTHUB_DELIVERY_ONLY: "true" } : {}), AGENTHUB_MODE: "tools", AGENTHUB_PEER_ID: peer, AGENTHUB_STATE_DIR: opts.stateDir, AGENTHUB_PROJECT_DIR: opts.cwd });
|
|
1800
1967
|
|
|
@@ -1849,11 +2016,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1849
2016
|
if (op === "hub_memory_correct") correctionInput(a);
|
|
1850
2017
|
if (CONDUCTOR_TOOL_NAMES.has(op)) {
|
|
1851
2018
|
if (piiTurn && ["hub_task_assign", "hub_task_escalate", "hub_peer_start"].includes(op)) throw new PreEffectToolRefusal("conductor mutations are unavailable during a PII turn");
|
|
1852
|
-
|
|
2019
|
+
const result = await conductor.execute(by, op, a);
|
|
2020
|
+
const shown = op === "hub_task_show" && result && typeof result === "object" ? board.get(Number(a.id)) : undefined;
|
|
2021
|
+
return JSON.stringify(shown ? { ...reviewerView(by, result as Record<string, unknown>), ...briefField(shown) } : result);
|
|
1853
2022
|
}
|
|
1854
2023
|
// Inside a PII turn the worker's words may carry the PII whatever they are attached to: a note would go to
|
|
1855
2024
|
// claude-mem (a cloud observer) and a new task could be routed to a cloud peer without matching any pattern.
|
|
1856
|
-
if (piiTurn && (MEMORY_READ_TOOLS.has(op) || op === "hub_memory_correct" || op === "hub_remember" || op === "hub_task_propose" || op === "hub_sdlc_record" || op === "hub_backlog_plan" || op === "hub_backlog_undo" || op === "hub_backlog_pull" || op === "hub_backlog_split" || op === "release_new")) throw new PreEffectToolRefusal(`${op} is not available while working on a PII task: its text must not leave this machine`);
|
|
2025
|
+
if (piiTurn && (MEMORY_READ_TOOLS.has(op) || op === "hub_memory_correct" || op === "hub_remember" || op === "hub_task_propose" || op === "hub_sdlc_record" || op === "hub_plan_review" || op === "hub_sdlc_costs" || op === "hub_backlog_return_untouched" || op === "hub_backlog_plan" || op === "hub_backlog_undo" || op === "hub_backlog_pull" || op === "hub_backlog_split" || op === "release_new")) throw new PreEffectToolRefusal(`${op} is not available while working on a PII task: its text must not leave this machine`);
|
|
1857
2026
|
// The same words attached to an ordinary task would reach its reviewer, its owner, overlapping owners (a plan, the
|
|
1858
2027
|
// completed-change notice) and claude-mem. A turn that holds a PII task acts on ordinary tasks in a turn of its own.
|
|
1859
2028
|
if (piiTurn && (op === "hub_task_done" || op === "hub_review" || (op === "hub_task_accept" && a.plan != null))) {
|
|
@@ -1969,6 +2138,16 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1969
2138
|
return JSON.stringify(tasks.backlogList(by));
|
|
1970
2139
|
case "hub_release_list":
|
|
1971
2140
|
return JSON.stringify(tasks.releaseList(by));
|
|
2141
|
+
case "hub_planner_groom":
|
|
2142
|
+
return JSON.stringify(await tasks.groomingDigest(by));
|
|
2143
|
+
case "hub_sdlc_costs":
|
|
2144
|
+
return JSON.stringify(await tasks.costReport(by, a.repo));
|
|
2145
|
+
case "hub_plan_review": {
|
|
2146
|
+
const { id, ...review } = a;
|
|
2147
|
+
return JSON.stringify(tasks.publicView(await tasks.recordPlanReview(by, id, review)));
|
|
2148
|
+
}
|
|
2149
|
+
case "hub_backlog_return_untouched":
|
|
2150
|
+
return JSON.stringify(tasks.publicView(await tasks.returnUntouchedToBacklog(by, { id: a.id, revision: a.revision, reason: a.reason })));
|
|
1972
2151
|
case "hub_backlog_plan":
|
|
1973
2152
|
return JSON.stringify(tasks.publicView(await tasks.planBacklog(by, { id: a.id, revision: a.revision, fields: a.fields, reason: a.reason })));
|
|
1974
2153
|
case "hub_backlog_pull":
|
|
@@ -1985,15 +2164,16 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1985
2164
|
const t = await tasks.propose(by, a);
|
|
1986
2165
|
if (t.state === "backlog") return line(t);
|
|
1987
2166
|
const overlap = tasks.overlaps(t, t.owner === by);
|
|
1988
|
-
if (t.owner === by && tasks.silentFor(t.id) && factsOn())
|
|
2167
|
+
if (t.owner === by && tasks.silentFor(t.id) && factsOn()) factsFor(by).sawPlans(by, tasks.overlapTasks(t)); // as for an accept
|
|
1989
2168
|
return [line(t), overlap, tasks.claimRoleGuidance(t, by), t.owner === by ? tasks.planGuardText(t) : ""].filter(Boolean).join("\n");
|
|
1990
2169
|
}
|
|
1991
2170
|
case "hub_task_accept": {
|
|
1992
|
-
|
|
2171
|
+
// S1 spec acceptance and S9 plan review are checked against one task snapshot before acceptance.
|
|
2172
|
+
const t = await tasks.acceptReviewed(by, a.id, a.plan);
|
|
1993
2173
|
// A silent cohort (issue #107): the owners' plans come with every accept, as what the newcomer works from.
|
|
1994
2174
|
const silent = tasks.silentFor(t.id);
|
|
1995
2175
|
const overlap = a.plan == null && !silent ? "" : tasks.overlaps(t);
|
|
1996
|
-
if (silent && factsOn())
|
|
2176
|
+
if (silent && factsOn()) factsFor(by).sawPlans(by, tasks.overlapTasks(t)); // the plans in this answer need no fact later
|
|
1997
2177
|
return [line(t), overlap, tasks.planGuardText(t)].filter(Boolean).join("\n");
|
|
1998
2178
|
}
|
|
1999
2179
|
case "hub_task_cancel": {
|
|
@@ -2020,9 +2200,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2020
2200
|
return extra ? `${result}\n${extra}` : result;
|
|
2021
2201
|
}
|
|
2022
2202
|
case "hub_sdlc_record":
|
|
2203
|
+
await tasks.specStageGate(by, a.id, a.receipt); // #481 S1: a live spec edit-time check before a build stage
|
|
2023
2204
|
return line(tasks.recordLifecycle(by, a.id, a.receipt));
|
|
2024
2205
|
case "hub_review":
|
|
2025
|
-
return line(await tasks.review(by, a.id, a.verdict, a.note, a.unmet, a.head));
|
|
2206
|
+
return line(await tasks.review(by, a.id, a.verdict, a.note, a.unmet, a.head, a.coverage));
|
|
2026
2207
|
case "hub_remember":
|
|
2027
2208
|
return tasks.remember(by, a);
|
|
2028
2209
|
case "hub_checkpoint": {
|
|
@@ -2062,7 +2243,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2062
2243
|
if (op === "task_show" && by !== USER) {
|
|
2063
2244
|
const task = board.get(Number(a.id));
|
|
2064
2245
|
if (task?.state === "backlog") throw new PreEffectToolRefusal("backlog is read through planner tools");
|
|
2065
|
-
|
|
2246
|
+
if (!task) return JSON.stringify(`no task #${a.id}`);
|
|
2247
|
+
return JSON.stringify({ ...reviewerView(by, publicConductorTask(task, (t) => tasks.publicView(t, true))), ...briefField(task), flowRevision: task.history.length });
|
|
2066
2248
|
}
|
|
2067
2249
|
if (op === "route_explain" && by !== USER) {
|
|
2068
2250
|
if (a.id !== undefined && board.get(Number(a.id))?.state === "backlog") throw new PreEffectToolRefusal("backlog is read through planner tools");
|
|
@@ -2089,6 +2271,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2089
2271
|
case "backlog_pull":
|
|
2090
2272
|
case "backlog_return":
|
|
2091
2273
|
return line(await tasks.moveBacklog(by, a.id, op === "backlog_pull"));
|
|
2274
|
+
case "review_brief": {
|
|
2275
|
+
const task = board.get(Number(a.id));
|
|
2276
|
+
return task ? tasks.briefText(task) || `task #${task.id} names no commit, or is private: no brief` : `no task #${a.id}`;
|
|
2277
|
+
}
|
|
2092
2278
|
case "task_show": {
|
|
2093
2279
|
const task = board.get(Number(a.id));
|
|
2094
2280
|
return JSON.stringify(task ? { ...task, flowRevision: task.history.length, reviews: board.reviews({ task: task.id }) } : `no task #${a.id}`, null, 2);
|
|
@@ -2123,6 +2309,14 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2123
2309
|
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
2310
|
const codex = bus.peers.get(turn.peer);
|
|
2125
2311
|
if (!(codex instanceof CodexPeer)) throw new Error("codex is not attached");
|
|
2312
|
+
const workspace = turnLog!.workspace(turn.id);
|
|
2313
|
+
if (workspace !== undefined) {
|
|
2314
|
+
const current = readInstanceRoleConfig(opts.cwd), adapter = instanceDefinitionOf(turn.peer, current.instances);
|
|
2315
|
+
if (!current.instances?.[turn.peer] || adapter?.kind !== definitionOf(turn.peer)?.kind) throw new Error("turn workspace declaration changed; inspect before reverting");
|
|
2316
|
+
const validated = resolveInstanceExecutionCwd(opts.cwd, turn.peer, current.instances, effectiveInstanceRoles(turn.peer, current.instances, current.roles));
|
|
2317
|
+
const bound = executionOwners.get(turn.peer);
|
|
2318
|
+
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");
|
|
2319
|
+
}
|
|
2126
2320
|
// Nothing may reach Codex while its history is rewritten: a turn started meanwhile would go with the reverted ones.
|
|
2127
2321
|
const held = bus.stateOf(turn.peer) !== "paused";
|
|
2128
2322
|
if (held) bus.pause(turn.peer);
|
|
@@ -2187,7 +2381,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2187
2381
|
};
|
|
2188
2382
|
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
2383
|
const recoveryReady = () => {
|
|
2190
|
-
if (!recoveryActive() || releasing || taskOpsInFlight !== 0 || tasks.checksPending() !== 0 || (
|
|
2384
|
+
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
2385
|
if (checkPending.size !== 0) return false;
|
|
2192
2386
|
if (!recoveryPeerSnapshot) return true;
|
|
2193
2387
|
const current = recoveryPeers();
|
|
@@ -2210,19 +2404,20 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2210
2404
|
// #64: a Claude session that never persisted a transcript cannot be resumed, so the
|
|
2211
2405
|
// restore gate accepted a fresh session and nothing it stood for is lost. Snapshots
|
|
2212
2406
|
// written before this flag existed are re-derived from disk.
|
|
2213
|
-
const unpersistedClaude = definitionOf(saved.id)?.session.missingEvidence === "transcript" && (saved.sessionPersisted === false || (saved.sessionPersisted === undefined && !claudeTranscriptPersisted(saved.sessionId)));
|
|
2407
|
+
const unpersistedClaude = definitionOf(saved.id)?.session.missingEvidence === "transcript" && (saved.sessionPersisted === false || (saved.sessionPersisted === undefined && !claudeTranscriptPersisted(saved.id, saved.sessionId)));
|
|
2214
2408
|
if (!unpersistedClaude && !waived()) return false;
|
|
2215
2409
|
}
|
|
2216
2410
|
return true;
|
|
2217
2411
|
});
|
|
2218
2412
|
};
|
|
2219
|
-
|
|
2413
|
+
/** #450 I3: each Claude peer (claude and every claude-<name> instance) has its own session and launch files. */
|
|
2414
|
+
const claudeSession = (peer = "claude"): { sessionId?: string; transcriptPath?: string; launchId?: string } => {
|
|
2220
2415
|
try {
|
|
2221
|
-
const value = JSON.parse(readFileSync(join(opts.stateDir, "claude-session
|
|
2416
|
+
const value = JSON.parse(readFileSync(join(opts.stateDir, claudeStateFile("claude-session", peer)), "utf8"));
|
|
2222
2417
|
if (value.instanceId !== instanceId) return {};
|
|
2223
2418
|
let currentLauncher = false;
|
|
2224
2419
|
try {
|
|
2225
|
-
const launch = JSON.parse(readFileSync(join(opts.stateDir, "claude-launch
|
|
2420
|
+
const launch = JSON.parse(readFileSync(join(opts.stateDir, claudeStateFile("claude-launch", peer)), "utf8"));
|
|
2226
2421
|
if (launch.instanceId === instanceId && typeof launch.launchId === "string" && launch.launchId) {
|
|
2227
2422
|
currentLauncher = true;
|
|
2228
2423
|
if (value.launchId !== launch.launchId) return {};
|
|
@@ -2230,7 +2425,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2230
2425
|
} catch { /* older/manual launches retain the existing instance fence */ }
|
|
2231
2426
|
try {
|
|
2232
2427
|
const records = JSON.parse(readFileSync(join(opts.stateDir, "terminal-recovery.json"), "utf8"));
|
|
2233
|
-
const current =
|
|
2428
|
+
const current = ownTerminalRow(records, peer, opts.cwd, instanceId);
|
|
2234
2429
|
if (!currentLauncher && current?.launchId && value.launchId !== current.launchId) return {};
|
|
2235
2430
|
} catch { /* no managed terminal record: the instance fence is still enforced */ }
|
|
2236
2431
|
return {
|
|
@@ -2288,33 +2483,30 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2288
2483
|
}
|
|
2289
2484
|
/** Claude persists projects/<slug>/<sessionId>.jsonl only with the first turn. Prefer the
|
|
2290
2485
|
* path Claude itself reported through the status line; fall back to the slug computation. */
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
const config = process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude");
|
|
2295
|
-
return existsSync(join(config, "projects", opts.cwd.replace(/[^a-zA-Z0-9]/g, "-"), `${sessionId}.jsonl`));
|
|
2296
|
-
};
|
|
2486
|
+
// ponytail: the version probe reads the builtin claude peer only; extend per peer if an instance's version matters (#450 I3).
|
|
2487
|
+
const claudeTranscriptPersisted = (peer: string, sessionId: string): boolean => transcriptPersisted(claudeSession(peer), sessionId, Object.hasOwn(declarations, peer),
|
|
2488
|
+
join(process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude"), "projects", opts.cwd.replace(/[^a-zA-Z0-9]/g, "-"), `${sessionId}.jsonl`), path => pathEvidence(path, lstatSync));
|
|
2297
2489
|
// Optional native usage: only the instance-fenced explicit Claude session is read.
|
|
2298
2490
|
// IDs persist in telemetry, so a restart/resume never counts the same native message twice.
|
|
2299
2491
|
const claudeNativeUsageSeen = new Set(readEvents(join(opts.stateDir, "events.jsonl")).filter(e => e.type === "usage" && e.source === "claude_transcript").map(e => (e as { id: string }).id));
|
|
2300
|
-
const collectClaudeUsage = () => {
|
|
2301
|
-
const session = claudeSession();
|
|
2302
|
-
if (!session.sessionId || !session.transcriptPath)
|
|
2492
|
+
const collectClaudeUsage = () => { for (const peer of channelPeers) {
|
|
2493
|
+
const session = claudeSession(peer);
|
|
2494
|
+
if (!session.sessionId || !session.transcriptPath) continue;
|
|
2303
2495
|
for (const record of readClaudeTranscriptUsage(session.sessionId, session.transcriptPath)) {
|
|
2304
2496
|
if (claudeNativeUsageSeen.has(record.id)) continue;
|
|
2305
2497
|
claudeNativeUsageSeen.add(record.id);
|
|
2306
2498
|
const reported = claudeReportedTokens(record.usage);
|
|
2307
|
-
if (reported !== undefined) addTokens(
|
|
2308
|
-
event({ type: "usage", peer
|
|
2499
|
+
if (reported !== undefined) addTokens(peer, reported);
|
|
2500
|
+
event({ type: "usage", peer, source: "claude_transcript", id: record.id, ...record.usage, ...taskAttribution(peer), ...(record.servedModel ? { servedModel: record.servedModel } : {}), ...(record.at ? { measuredAt: record.at } : {}) });
|
|
2309
2501
|
}
|
|
2310
|
-
};
|
|
2502
|
+
} };
|
|
2311
2503
|
const claudeUsageTimer = setInterval(collectClaudeUsage, 2000);
|
|
2312
2504
|
claudeUsageTimer.unref(); intervals.push(claudeUsageTimer);
|
|
2313
2505
|
const recoveryPeers = (): Record<string, RestartPeerSnapshot> => Object.fromEntries([...bus.peers].map(([id, peer]) => {
|
|
2314
2506
|
const metadata = peer.recoveryMetadata?.() ?? {};
|
|
2315
2507
|
const row: RestartPeerSnapshot = { id, state: peer.state, queueIds: bus.queueIds(id), ...(metadata.launch ? { launch: metadata.launch as Record<string, unknown> } : {}) };
|
|
2316
2508
|
if (typeof metadata.threadId === "string") row.threadId = metadata.threadId;
|
|
2317
|
-
const sessionId = definitionOf(id)?.transport === "claude-channel" ? claudeSession().sessionId : metadata.sessionId;
|
|
2509
|
+
const sessionId = definitionOf(id)?.transport === "claude-channel" ? claudeSession(id).sessionId : metadata.sessionId;
|
|
2318
2510
|
if (typeof sessionId === "string" && sessionId) row.sessionId = sessionId;
|
|
2319
2511
|
return [id, row];
|
|
2320
2512
|
}));
|
|
@@ -2357,7 +2549,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2357
2549
|
if (peer instanceof AcpPeer && ["unknown", "unmanaged"].includes(peer.permissionModeState)) return peer.permissionModeState;
|
|
2358
2550
|
if (starting.has(id) && (peer instanceof AcpPeer || peer instanceof CodexPeer)) return peer.getPermissionMode();
|
|
2359
2551
|
if (definitionOf(id)?.transport === "claude-channel" && peer instanceof WsPeer && peer.state !== "offline") {
|
|
2360
|
-
const launch = claudePermissionLaunch();
|
|
2552
|
+
const launch = claudePermissionLaunch(id);
|
|
2361
2553
|
if (!launch || claudePermissionGenerations.get(id) !== peer.sessionGeneration) return "unverified";
|
|
2362
2554
|
}
|
|
2363
2555
|
return permissionMode(id);
|
|
@@ -2368,10 +2560,16 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2368
2560
|
const mode = permissionDisplay(id, peer);
|
|
2369
2561
|
return mode === "ask" ? {} : { permissionMode: mode };
|
|
2370
2562
|
};
|
|
2371
|
-
|
|
2563
|
+
const lastCodexThreads = new Map<string, string>();
|
|
2564
|
+
// #450 I3: the console and dashboard list each declared instance right after its kind.
|
|
2565
|
+
const byKind = (ids: string[]): string[] => {
|
|
2566
|
+
const kindOf = (id: string) => Object.hasOwn(declarations, id) ? declarations[id]!.kind : undefined;
|
|
2567
|
+
const out = ids.filter(id => kindOf(id) === undefined).flatMap(id => [id, ...ids.filter(extra => kindOf(extra) === id)]);
|
|
2568
|
+
return [...out, ...ids.filter(id => !out.includes(id))];
|
|
2569
|
+
};
|
|
2372
2570
|
const statusPeerIds = (open: ReturnType<Board["statusTasks"]>) => {
|
|
2373
2571
|
const reservations = board.statusReservations();
|
|
2374
|
-
const ids = new Set([...bus.knownPeers(), ...reservations]);
|
|
2572
|
+
const ids = new Set([...bus.knownPeers(), ...extraIds, ...reservations]);
|
|
2375
2573
|
if (!Object.keys(config.roster).some(peer => !membershipAllowed(peer))) return [...ids];
|
|
2376
2574
|
for (const task of open) for (const peer of [task.owner, task.reviewer]) if (peer && !membershipAllowed(peer)) ids.add(peer);
|
|
2377
2575
|
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));
|
|
@@ -2380,17 +2578,24 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2380
2578
|
const taskNeeds = board.statusTasks();
|
|
2381
2579
|
const taskStalls = tasks.stalled();
|
|
2382
2580
|
const pendingTaskOffers = tasks.pendingOffers().filter(offer => offer.pullOnly === true);
|
|
2383
|
-
const peerIds = statusPeerIds(taskNeeds);
|
|
2581
|
+
const peerIds = byKind(statusPeerIds(taskNeeds));
|
|
2384
2582
|
const view = {
|
|
2385
2583
|
...(crashReport.length ? { crash: crashReport } : {}),
|
|
2386
2584
|
projectId,
|
|
2387
2585
|
instanceId,
|
|
2388
2586
|
version: VERSION,
|
|
2587
|
+
pluginBundle: SERVER_JS,
|
|
2389
2588
|
pendingRestart,
|
|
2390
2589
|
memoryProfile: memoryProfileStatus(includeAttentionItems),
|
|
2391
2590
|
lifecycleStages: [...lifecycleConfig.stages],
|
|
2591
|
+
// ponytail: off keeps the #257 phase schema until RETIRED_WORKFLOW_FROM, when the alias and this flag go together.
|
|
2592
|
+
...(lifecycleConfig.mode !== "off" ? { lifecycleCriteria: true } : {}),
|
|
2593
|
+
// Every supported peer's membership, for launchers that run outside the console role (#484).
|
|
2594
|
+
roster: [...KNOWN_PEERS, ...OPTIONAL_PEERS].map(peer => ({ peer, on: onRoster(peer) })),
|
|
2392
2595
|
protocol: PROTOCOL,
|
|
2393
2596
|
features: DAEMON_FEATURES,
|
|
2597
|
+
// #450 I3: the instance ids this daemon bound at startup; a launcher selects only one of these.
|
|
2598
|
+
activeInstances: extraIds,
|
|
2394
2599
|
stopping,
|
|
2395
2600
|
pid: process.pid,
|
|
2396
2601
|
cwd: opts.cwd,
|
|
@@ -2398,7 +2603,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2398
2603
|
...(dashboard ? { uiOrigin: dashboard.origin } : {}),
|
|
2399
2604
|
codexProxyPort: opts.codexProxyPort,
|
|
2400
2605
|
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:
|
|
2606
|
+
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
2607
|
),
|
|
2403
2608
|
taskWip: Object.fromEntries(peerIds.map(id => [id, { wip: tasks.wip(id), wipLimit: tasks.wipLimit(id) }])),
|
|
2404
2609
|
boardReadCapabilities: { releaseFilter: true, columnMetadata: true },
|
|
@@ -2669,11 +2874,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2669
2874
|
const lostCount = [...lost.values()].reduce((n, l) => n + l.length, 0);
|
|
2670
2875
|
report(`the previous hub run stopped without shutting down${lostCount ? `; ${lostCount} deliveries it had in flight are in needs_review (ahub queue list)` : ""}`);
|
|
2671
2876
|
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; }
|
|
2877
|
+
for (const step of crashPlan(prev.peers, declarations)) {
|
|
2878
|
+
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
2879
|
// `pi.auto_start` already asks for Pi (#66): its recorded headless session comes back, auto-resume or not, and a
|
|
2675
2880
|
// 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()) {
|
|
2881
|
+
if (!declarations[step.peer] && definitionOf(step.peer)?.session.restoreOnStart && piAutoStart()) {
|
|
2677
2882
|
if (step.resume) {
|
|
2678
2883
|
const r = await start("pi", step.resume as Parameters<typeof startPeer>[1]);
|
|
2679
2884
|
if (r.ok) { report(`pi resumed (pi.auto_start): ${step.how}`); continue; }
|
|
@@ -2733,8 +2938,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2733
2938
|
for (const id of turnTasks.keys()) if (!turnLog?.get(id)) turnTasks.delete(id);
|
|
2734
2939
|
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
2940
|
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}`); }
|
|
2941
|
+
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
|
|
2942
|
+
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
2943
|
const preparedRoles = preparedSessionRoleIdentities.get(e.peer)?.map(role => ({ ...role })) ?? null;
|
|
2739
2944
|
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
2945
|
event({ type: "turn_start", peer: e.peer, turn: id });
|
|
@@ -2746,8 +2951,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2746
2951
|
if (open.capture && turnLog && (open.private || holdsPii(e.peer))) {
|
|
2747
2952
|
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
2953
|
} else if (open.capture && turnLog) {
|
|
2749
|
-
const end = snap(`the end of ${open.id}`, true);
|
|
2750
|
-
const changed = open.tree && end.tree ? changedPaths(
|
|
2954
|
+
const end = snap(`the end of ${open.id}`, true, e.peer);
|
|
2955
|
+
const changed = open.tree && end.tree ? changedPaths(repoFor(e.peer)!, open.tree, end.tree) : [];
|
|
2751
2956
|
if (open.tree && end.tree) files = changed.length; // unknown, not zero, when a snapshot failed
|
|
2752
2957
|
snapshotMs = (snapshotMs ?? 0) + end.ms;
|
|
2753
2958
|
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 +3155,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2950
3155
|
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
3156
|
const CLI_ENTRY = fileURLToPath(new URL("../cli/main.ts", import.meta.url));
|
|
2952
3157
|
/** `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[] =>
|
|
3158
|
+
const startMode = (peer: string): StartMode => startModeOf(config.peers, peer, declarations);
|
|
3159
|
+
const startWords = (peer: string, mode: StartMode): string[] => {
|
|
3160
|
+
const words = definitionOf(peer)?.cli.startWords(mode) ?? [peer];
|
|
3161
|
+
return declarations[peer] ? [words[0]!, "--as", peer, ...words.slice(1)] : words;
|
|
3162
|
+
};
|
|
2955
3163
|
const startable = onRoster;
|
|
2956
3164
|
// ponytail: one terminal per peer per 30 s, by the clock. The hub cannot see whether the terminal it opened still
|
|
2957
3165
|
// exists, so a peer that never attaches can be opened again after that; track the provider's handle to do better.
|
|
@@ -2960,7 +3168,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2960
3168
|
// 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
3169
|
const startPlans = () => STARTABLE_PEERS.filter(startable).map((peer) => {
|
|
2962
3170
|
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 }) : {}) };
|
|
3171
|
+
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
3172
|
});
|
|
2965
3173
|
// Printed for a person's explicit reconciliation only; never executed by auto-start.
|
|
2966
3174
|
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`;
|
|
@@ -3027,6 +3235,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3027
3235
|
return { ok: true, mode, opened: opened.via, command };
|
|
3028
3236
|
}
|
|
3029
3237
|
|
|
3238
|
+
/** The CLI command that starts a peer: `ahub <kind> --as <id>` for a declared instance. */
|
|
3239
|
+
const startCommand = (peer: string): string => { const kind = definitionOf(peer)?.kind; return kind && kind !== peer ? `ahub ${kind} --as ${peer}` : `ahub ${peer}`; };
|
|
3240
|
+
|
|
3030
3241
|
async function stopPeer(peer: unknown, rosterRemoval = false): Promise<Record<string, unknown>> {
|
|
3031
3242
|
if (!rosterRemoval && rosterChanging !== undefined && rosterChanging === peer) return { ok: false, error: `${String(peer)} roster change is in progress` };
|
|
3032
3243
|
if (stopping || recoveryActive()) return { ok: false, error: "recovery or shutdown is holding peer stops; finish the operation before retrying" };
|
|
@@ -3034,9 +3245,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3034
3245
|
const owner = bus.peers.get(peer);
|
|
3035
3246
|
const stopNotice = definitionOf(peer)?.cli.stopNotice?.(owner instanceof WsPeer ? "tui" : !owner ? startMode(peer) : "headless", owner);
|
|
3036
3247
|
if (stopNotice) return { ok: false, error: stopNotice };
|
|
3037
|
-
if (!(owner instanceof AcpPeer || owner instanceof PiPeer || owner instanceof LocalPeer)) return { ok: false, error: "unknown or non-owned peer; run ahub status and choose a hub-owned headless peer" };
|
|
3248
|
+
if (!(owner instanceof AcpPeer || owner instanceof PiPeer || owner instanceof LocalPeer || owner instanceof ClaudePrintPeer)) return { ok: false, error: "unknown or non-owned peer; run ahub status and choose a hub-owned headless peer" };
|
|
3038
3249
|
if (owner instanceof PiPeer && owner.mode !== "headless") return { ok: false, error: "Pi belongs to a native TUI terminal; end it in its terminal" };
|
|
3039
|
-
if (owner.state === "offline") return { ok: false, error: `peer is already offline; start it with
|
|
3250
|
+
if (owner.state === "offline") return { ok: false, error: `peer is already offline; start it with ${startCommand(peer)} before stopping it` };
|
|
3040
3251
|
if (starting.has(peer) || requestedPeerStops.has(peer) || permissionChanges.has(peer)) return { ok: false, error: "peer lifecycle or permission change is in progress; wait for ahub status before retrying" };
|
|
3041
3252
|
requestedPeerStops.add(peer); // fence delivery, starts and recovery before the first asynchronous stop step
|
|
3042
3253
|
try {
|
|
@@ -3053,7 +3264,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3053
3264
|
writeStatus();
|
|
3054
3265
|
const state = bus.stateOf(peer);
|
|
3055
3266
|
if (state !== "offline" || bus.peers.get(peer) !== owner) return { ok: false, error: "peer stop did not reach offline on its owner; inspect ahub status and its terminal before retrying" };
|
|
3056
|
-
try { notify(rosterRemoval ? `${peer} stopped for roster removal; add it with ahub roster add ${peer} before starting it again` : `${peer} stopped by the console; start it again with
|
|
3267
|
+
try { notify(rosterRemoval ? `${peer} stopped for roster removal; add it with ahub roster add ${peer} before starting it again` : `${peer} stopped by the console; start it again with ${startCommand(peer)}`); } catch { /* owner stop and readback remain authoritative */ }
|
|
3057
3268
|
return { ok: true, state };
|
|
3058
3269
|
});
|
|
3059
3270
|
} catch {
|
|
@@ -3082,19 +3293,25 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3082
3293
|
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
3294
|
let unmute: (() => void) | undefined;
|
|
3084
3295
|
try {
|
|
3296
|
+
if (declarations[peer] && args.mode !== undefined && args.mode !== "headless") return { ok: false, error: "declared instances support headless starts only" };
|
|
3297
|
+
let executionCwd: string;
|
|
3298
|
+
try { executionCwd = resolveInstanceExecutionCwd(opts.cwd, peer, declarations, config.roles[peer] ?? []); }
|
|
3299
|
+
catch (error) { return { ok: false, error: (error as Error).message }; }
|
|
3300
|
+
executionRoots.set(peer, executionCwd);
|
|
3085
3301
|
applyPendingPermissionDefault(peer);
|
|
3086
3302
|
if (definitionOf(peer)?.start.refreshTools) { await ensureSpec(true); await refreshRunner(); }
|
|
3087
|
-
|
|
3303
|
+
const result = await startPeerBody(peer, args, (p) => { unmute = muteState(p); }, executionCwd);
|
|
3304
|
+
return result.ok && declarations[peer] ? { ...result, executionCwd } : result;
|
|
3088
3305
|
} finally {
|
|
3089
3306
|
unmute?.();
|
|
3090
3307
|
}
|
|
3091
3308
|
}
|
|
3092
3309
|
|
|
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>> {
|
|
3310
|
+
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
3311
|
const adapter = definitionOf(peer);
|
|
3095
3312
|
const preflight = adapter?.start.preflight?.({ instance: peer, form: args.mode });
|
|
3096
3313
|
if (preflight) return { ok: false, error: preflight };
|
|
3097
|
-
if (adapter?.attach?.admission) {
|
|
3314
|
+
if (adapter?.attach?.admission && !declarations[peer]) {
|
|
3098
3315
|
if (args.mode !== undefined && !["headless", "tui"].includes(args.mode)) return { ok: false, error: "invalid Kimi mode" };
|
|
3099
3316
|
const owner = bus.peers.get(peer);
|
|
3100
3317
|
if ((args.mode ?? startMode(peer)) === "tui") {
|
|
@@ -3122,10 +3339,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3122
3339
|
if (args.replaceSession) {
|
|
3123
3340
|
if (recoveryActive()) return { ok: false, error: "recovery is holding session replacement" };
|
|
3124
3341
|
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(
|
|
3342
|
+
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" };
|
|
3343
|
+
const selected = validatePiSession(nativeStateDir(peer), executionCwd, args.sessionFile);
|
|
3127
3344
|
const previous = existing.recoveryMetadata();
|
|
3128
|
-
recordPiReplacement(
|
|
3345
|
+
recordPiReplacement(nativeStateDir(peer), instanceId, previous, selected);
|
|
3129
3346
|
args = { ...args, ...selected };
|
|
3130
3347
|
mute(existing);
|
|
3131
3348
|
await stopPiForReplacement(existing);
|
|
@@ -3172,7 +3389,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3172
3389
|
if (adapter?.start.daemonPort === "owned-acp") {
|
|
3173
3390
|
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
3391
|
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:
|
|
3392
|
+
...(args.model ? { launchModel: args.model } : {}), ...(args.sessionId ? { resumeSessionId: args.sessionId } : {}), cwd: executionCwd, watchdogMs: config.watchdog_ms,
|
|
3176
3393
|
preamble: sessionRoleContract(peer), log: () => log("Jcode native diagnostic: details withheld") }) }) as JcodePeer;
|
|
3177
3394
|
recoveryTaskPreface(peer); await ensurePreface(peer);
|
|
3178
3395
|
try { await jcode.start(); await reconcileNativeStart(peer, jcode); }
|
|
@@ -3184,11 +3401,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3184
3401
|
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
3402
|
onActivity: item => activityRings[peer]!.append(item),
|
|
3186
3403
|
isPrivateTurn: () => !!turns.get(peer)?.private || contextPii(peer),
|
|
3187
|
-
cmd,
|
|
3404
|
+
cmd, kind: adapter.kind,
|
|
3188
3405
|
permissionMode: permissionMode(peer),
|
|
3189
3406
|
...(args.model ? { launchModel: args.model } : {}),
|
|
3190
3407
|
...(args.sessionId ? { resumeSessionId: args.sessionId } : {}),
|
|
3191
|
-
cwd:
|
|
3408
|
+
cwd: executionCwd,
|
|
3192
3409
|
watchdogMs: config.watchdog_ms,
|
|
3193
3410
|
onPermission,
|
|
3194
3411
|
// The hub's own tools pass without a console prompt, as Codex's do (approval_mode below; issue #72).
|
|
@@ -3197,11 +3414,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3197
3414
|
// session's configured MCP servers (issue #138). Exact names only; a stopping hub approves nothing.
|
|
3198
3415
|
autoApprove: (title) => !stopping && HUB_TOOL_TITLES.has(title),
|
|
3199
3416
|
log,
|
|
3200
|
-
onTokens: onKimiTokens,
|
|
3417
|
+
onTokens: (total, session) => onKimiTokens(total, session, peer),
|
|
3201
3418
|
// Pause synchronously before the adapter requeues a provider-refused prompt (#306).
|
|
3202
3419
|
onQuotaRefusal: (window) => {
|
|
3203
3420
|
budget.report(peer, [window], true);
|
|
3204
|
-
return !!budget.record(peer) && bus.isPaused(
|
|
3421
|
+
return !!budget.record(peer) && bus.isPaused(peer);
|
|
3205
3422
|
},
|
|
3206
3423
|
// Kimi's usage_update is context occupancy ({used, size}), not consumption (#167): it becomes the peer's
|
|
3207
3424
|
// context reading (#285 phase 1), never a tokens event or a budget window. The reading is bound to the
|
|
@@ -3238,9 +3455,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3238
3455
|
const owner = adapter.start.create({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: undefined, permission, mcpServers, daemon: { acp: {
|
|
3239
3456
|
onActivity: item => activityRings[peer]!.append(item),
|
|
3240
3457
|
isPrivateTurn: () => !!turns.get(peer)?.private || contextPii(peer),
|
|
3241
|
-
cmd: [launch.cmd, ...launch.args], permissionMode: permission,
|
|
3458
|
+
kind: adapter.kind, cmd: [launch.cmd, ...launch.args], permissionMode: permission,
|
|
3242
3459
|
...(args.model ? { launchModel: args.model } : {}), ...(args.sessionId ? { resumeSessionId: args.sessionId } : {}),
|
|
3243
|
-
cwd:
|
|
3460
|
+
cwd: executionCwd, watchdogMs: config.watchdog_ms, onPermission,
|
|
3244
3461
|
autoApprove: title => !stopping && HUB_TOOL_TITLES.has(title),
|
|
3245
3462
|
log: () => log("Qwen runtime diagnostic: details withheld"),
|
|
3246
3463
|
onUsageDiagnostic: (observation, sessionId) => {
|
|
@@ -3273,7 +3490,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3273
3490
|
onTokens: (added) => void addTokens(peer, added),
|
|
3274
3491
|
onContext: (reading) => contexts.report(peer, reading, contextSession(peer)),
|
|
3275
3492
|
// #215: before the thread's first turn_start, so the planner can tell which turns belong to which thread.
|
|
3276
|
-
onThread: (thread, fresh) => {
|
|
3493
|
+
onThread: (thread, fresh) => { lastCodexThreads.set(peer, thread); event({ type: "native_thread", peer, thread, fresh }); },
|
|
3277
3494
|
onTurn: (native) => {
|
|
3278
3495
|
const open = turns.get(peer);
|
|
3279
3496
|
if (open) turnLog?.native(open.id, native);
|
|
@@ -3288,10 +3505,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3288
3505
|
factSession(peer, codex.thread);
|
|
3289
3506
|
if (item.type === "userMessage") {
|
|
3290
3507
|
const text = JSON.stringify(item.content ?? "");
|
|
3291
|
-
if (text.includes(FACTS_PREFIX)) for (const o of
|
|
3508
|
+
if (text.includes(FACTS_PREFIX)) for (const o of factsFor(peer).pending(peer)) if (text.includes(`[${o.id}]`)) acked(peer, o.id, "steer");
|
|
3292
3509
|
return;
|
|
3293
3510
|
}
|
|
3294
|
-
|
|
3511
|
+
factsFor(peer).codexItem(peer, item);
|
|
3295
3512
|
checkCapability(peer);
|
|
3296
3513
|
if (codexSteering) return; // one at a time: the next boundary offers what this one could not
|
|
3297
3514
|
const started = performance.now();
|
|
@@ -3304,12 +3521,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3304
3521
|
codexSteering = false;
|
|
3305
3522
|
// Refused, it never went in: not an unread offer, and the next boundary offers it again. Unanswered, it may
|
|
3306
3523
|
// have: its readback can still come.
|
|
3307
|
-
if (outcome === "refused")
|
|
3524
|
+
if (outcome === "refused") factsFor(peer).drop(peer, offered.id);
|
|
3308
3525
|
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
3526
|
});
|
|
3310
3527
|
},
|
|
3311
|
-
appPort: opts.codexAppPort,
|
|
3312
|
-
proxyPort: opts.codexProxyPort,
|
|
3528
|
+
appPort: declarations[peer] ? instanceAppPort(peer) : opts.codexAppPort,
|
|
3529
|
+
proxyPort: declarations[peer] ? 0 : opts.codexProxyPort,
|
|
3313
3530
|
bin: config.codex_bin,
|
|
3314
3531
|
// The hub spawns this app-server, so it can give Codex the task tools without touching ~/.codex/config.toml
|
|
3315
3532
|
// (verified: app-server honours -c mcp_servers.* and the server reaches "ready").
|
|
@@ -3322,7 +3539,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3322
3539
|
preamble: sessionRoleContract(peer),
|
|
3323
3540
|
onUsage: (rateLimits, hard) => budget.report(peer, codexWindows(rateLimits), hard),
|
|
3324
3541
|
usagePollMs: config.budget.poll_min * 60_000,
|
|
3325
|
-
cwd:
|
|
3542
|
+
cwd: executionCwd,
|
|
3326
3543
|
watchdogMs: config.watchdog_ms,
|
|
3327
3544
|
log,
|
|
3328
3545
|
}) }) as CodexPeer;
|
|
@@ -3335,31 +3552,36 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3335
3552
|
return { ok: true, proxyUrl: codex.proxyUrl };
|
|
3336
3553
|
}
|
|
3337
3554
|
if (adapter?.start.daemonPort === "pi-rpc") {
|
|
3338
|
-
if (!onRoster(
|
|
3555
|
+
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
3556
|
const mode = args.mode ?? "headless";
|
|
3340
3557
|
const backend = args.backend ?? config.pi.backend;
|
|
3341
3558
|
if (!["headless", "tui"].includes(mode) || !["auto", "dgx", "mlx"].includes(backend)) return { ok: false, error: "invalid Pi mode/backend" };
|
|
3342
3559
|
const problem = mlxLaunchProblem(config, { backend, model: args.model });
|
|
3343
3560
|
if (problem) return { ok: false, error: problem };
|
|
3344
|
-
modelRelay
|
|
3561
|
+
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
3562
|
routeSessionKey: () => { const session = bus.peers.get(peer)?.recoveryMetadata?.().sessionId; return typeof session === "string" ? session : undefined; },
|
|
3346
3563
|
staySwitch: () => activeRouting(),
|
|
3347
3564
|
onRoute: record => event({ type: "route", peer, ...record }),
|
|
3348
3565
|
efficientWaitMs: activeRouting().pi.efficient_wait_ms,
|
|
3349
|
-
underBudget: () => { const current = bus.peers.get(peer); return current instanceof PiPeer && tasks.budgetApplies(current.budgetEnvelopes,
|
|
3566
|
+
underBudget: () => { const current = bus.peers.get(peer); return current instanceof PiPeer && tasks.budgetApplies(current.budgetEnvelopes, peer); },
|
|
3350
3567
|
onCooldown: record => {
|
|
3351
3568
|
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
3569
|
event({ type: "cooldown", peer, ...record });
|
|
3353
3570
|
},
|
|
3354
3571
|
});
|
|
3355
|
-
|
|
3572
|
+
modelRelays.set(peer, modelRelay);
|
|
3573
|
+
if (!declarations[peer]) globalModelRelay(modelRelay);
|
|
3574
|
+
const nativeRoot = nativeStateDir(peer);
|
|
3575
|
+
mkdirSync(nativeRoot, { recursive: true });
|
|
3576
|
+
const piReceipts = piReceiptLedgers.get(peer) ?? new PiToolReceipts(join(nativeRoot, "hub.db"));
|
|
3577
|
+
piReceiptLedgers.set(peer, piReceipts);
|
|
3356
3578
|
let piReply: Envelope | undefined;
|
|
3357
3579
|
// Tools the person allowed "always" for this Pi start; a new start gets a new set (#209).
|
|
3358
3580
|
const piAlways = new Set<string>();
|
|
3359
3581
|
const piPermit = async (title: string, tool: string, signal?: AbortSignal, path?: string, valid: () => boolean = () => true, observe?: ApprovalObserver, originalPath?: string): Promise<ToolApproval> => {
|
|
3360
3582
|
if (stopping || signal?.aborted || !valid() || !pi.acceptingTools || bus.peers.get(peer) !== pi) return "aborted";
|
|
3361
3583
|
const selected = permissionMode(peer);
|
|
3362
|
-
if (selected === "never-ask" || (selected === "ask-when-needed" && PI_EDIT_TOOLS.has(tool) && !!path && grantablePath(realPath(
|
|
3584
|
+
if (selected === "never-ask" || (selected === "ask-when-needed" && PI_EDIT_TOOLS.has(tool) && !!path && grantablePath(realPath(executionCwd), path, originalPath, config.protect_instruction_files))) {
|
|
3363
3585
|
observe?.({ source: "automatic", answerEpoch: permissionAnswerEpoch(peer) });
|
|
3364
3586
|
return true;
|
|
3365
3587
|
}
|
|
@@ -3375,9 +3597,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3375
3597
|
return signal?.aborted || !valid() ? "aborted" : pi.acceptingTools && bus.peers.get(peer) === pi;
|
|
3376
3598
|
};
|
|
3377
3599
|
const ctx: ToolContext = {
|
|
3378
|
-
cwd:
|
|
3379
|
-
sandboxProfile: profile(
|
|
3380
|
-
sandboxEnv: { ...proxyEnv(sandboxNetwork), AGENTHUB_PEER_ID:
|
|
3600
|
+
cwd: executionCwd, deny: config.local.deny, readOnly: config.data?.readonly,
|
|
3601
|
+
sandboxProfile: profile(executionCwd, sandboxNetwork, config.local.read_allow, config.local.deny, config.data?.readonly),
|
|
3602
|
+
sandboxEnv: { ...proxyEnv(sandboxNetwork), AGENTHUB_PEER_ID: peer },
|
|
3381
3603
|
permit: async () => false, // Pi's calls ask through piPermit in executeTool, which knows the tool's name
|
|
3382
3604
|
send: (text, to) => {
|
|
3383
3605
|
if (to?.some((id) => !bus.peers.has(id) && id !== USER)) return "error: unknown peer";
|
|
@@ -3387,12 +3609,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3387
3609
|
};
|
|
3388
3610
|
const routing = activeRouting();
|
|
3389
3611
|
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:
|
|
3612
|
+
cwd: executionCwd, stateDir: nativeRoot, ...(declarations[peer] ? { hubStateDir: opts.stateDir } : {}), cmd: config.pi.cmd, mode, backend,
|
|
3391
3613
|
model: args.model,
|
|
3392
3614
|
sessionId: args.sessionId, sessionFile: args.sessionFile,
|
|
3393
3615
|
admitBudget: async (envs, unit) => unit === "model_calls" ? [] : tasks.admitExecutionEnvelopes(envs, peer, unit),
|
|
3394
3616
|
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 }))],
|
|
3617
|
+
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
3618
|
executeTool: async (name, raw, callId, sessionId, signal, context) => {
|
|
3397
3619
|
if (MEMORY_READ_TOOLS.has(name)) validateMemoryRead(name, raw as Record<string, unknown>);
|
|
3398
3620
|
if (signal?.aborted) return "error: turn cancelled before tool effects";
|
|
@@ -3402,7 +3624,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3402
3624
|
const result = await piReceipts!.execute(sessionId ?? "", callId, name, humanBash ? { origin: "verified-user-bash", args: raw } : raw, async () => {
|
|
3403
3625
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return "error: invalid tool arguments";
|
|
3404
3626
|
const nativeTurn = pi.observationTurn;
|
|
3405
|
-
const output =
|
|
3627
|
+
const output = TASK_TOOL_NAMES.has(name) || CONDUCTOR_TOOL_NAMES.has(name) ? await taskOp(peer, name, raw as Record<string, unknown>, true) : await runTool(name, JSON.stringify(raw), { ...ctx, signal,
|
|
3406
3628
|
...(humanBash ? { onProcessExit: (code: number | null) => { exitCode = code; } } : {}),
|
|
3407
3629
|
permit: async (title, tool, toolSignal, path, _observe, originalPath) => {
|
|
3408
3630
|
const picked = humanBash ? (toolSignal?.aborted || context?.isCurrent() !== true ? "aborted" : true) : await piPermit(title, tool ?? name, toolSignal, path, context?.isCurrent, context?.onApproval, originalPath);
|
|
@@ -3454,7 +3676,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3454
3676
|
else if (recoveryActive()) action = "recovery holds automatic restart; inspect ahub status";
|
|
3455
3677
|
else if (!exit.started || starting.has(peer)) action = "startup failed; inspect its session, then ahub pi";
|
|
3456
3678
|
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";
|
|
3679
|
+
else if (declarations[peer] || !piAutoStart()) action = "pi.auto_start is off; start it with ahub pi";
|
|
3458
3680
|
else if (mode !== "headless") action = "native terminal ended; start it with ahub pi";
|
|
3459
3681
|
else if (piAutoRestartPending || Date.now() - piAutoRestartAt < 60_000) action = "pi.auto_start restart limit (one in 60 s); start it with ahub pi";
|
|
3460
3682
|
else {
|
|
@@ -3467,7 +3689,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3467
3689
|
const saved = await pi.captureResume();
|
|
3468
3690
|
if (typeof saved.sessionId !== "string" || typeof saved.sessionFile !== "string") throw new Error("no verified persisted Pi session; refusing a fresh fallback");
|
|
3469
3691
|
if (stopping || recoveryActive() || bus.peers.get(peer) !== pi || starting.has(peer)) return;
|
|
3470
|
-
const result = await startPeer(
|
|
3692
|
+
const result = await startPeer(peer, { mode: "headless", backend, ...(args.model ? { model: args.model } : {}), sessionId: saved.sessionId, sessionFile: saved.sessionFile });
|
|
3471
3693
|
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
3694
|
})().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
3695
|
.finally(() => { piAutoRestartAt = Date.now(); piAutoRestartPending = false; });
|
|
@@ -3495,6 +3717,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3495
3717
|
}) }) as PiPeer;
|
|
3496
3718
|
recoveryTaskPreface(peer);
|
|
3497
3719
|
await ensurePreface(peer);
|
|
3720
|
+
executionOwners.set(peer, { owner: pi, cwd: executionCwd });
|
|
3498
3721
|
bus.add(pi);
|
|
3499
3722
|
// The start's own error is the one reported; a stop that fails too is logged beside it (#115).
|
|
3500
3723
|
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 +3747,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3524
3747
|
sidecar ??= new Sidecar({ routing, omni, stateDir: opts.stateDir, port: opts.switchyardPort, log, ...(opts.switchyardBin ? { bin: opts.switchyardBin } : {}) });
|
|
3525
3748
|
}
|
|
3526
3749
|
const capture = config.memory.enabled
|
|
3527
|
-
? new Capture(memory, { project: chain.at(-1)!, cwd:
|
|
3750
|
+
? new Capture(memory, { project: chain.at(-1)!, cwd: executionCwd, skip: skipTools(), deny: config.local.deny })
|
|
3528
3751
|
: undefined;
|
|
3529
3752
|
const permit = async (title: string, tool?: string, signal?: AbortSignal, path?: string, observe?: ApprovalObserver, originalPath?: string): Promise<ToolApproval> => {
|
|
3530
3753
|
if (stopping || signal?.aborted || bus.peers.get(peer) !== local) return "aborted";
|
|
3531
3754
|
const selected = permissionMode(peer);
|
|
3532
|
-
if (selected === "never-ask" || (selected === "ask-when-needed" && !!tool && PI_EDIT_TOOLS.has(tool) && !!path && grantablePath(realPath(
|
|
3755
|
+
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
3756
|
observe?.({ source: "automatic", answerEpoch: permissionAnswerEpoch(peer) });
|
|
3534
3757
|
return true;
|
|
3535
3758
|
}
|
|
@@ -3542,7 +3765,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3542
3765
|
return picked.optionId === "allow";
|
|
3543
3766
|
};
|
|
3544
3767
|
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:
|
|
3768
|
+
cwd: executionCwd,
|
|
3546
3769
|
omni,
|
|
3547
3770
|
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
3771
|
admitBudget: async (envs, unit) => tasks.admitExecutionEnvelopes(envs, peer, unit),
|
|
@@ -3561,6 +3784,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3561
3784
|
...(capture ? { capture } : {}),
|
|
3562
3785
|
profile: resolvedProfile,
|
|
3563
3786
|
sdlcStages: lifecycleConfig.stages,
|
|
3787
|
+
sdlcCriteria: lifecycleConfig.mode !== "off",
|
|
3564
3788
|
taskTool: (name, a, turn) => taskOp(peer, name, a, true, turn.pii),
|
|
3565
3789
|
turnPolicy: (envs) => {
|
|
3566
3790
|
const policy = tasks.turnPolicy(envs);
|
|
@@ -3574,17 +3798,38 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3574
3798
|
}) }) as LocalPeer;
|
|
3575
3799
|
recoveryTaskPreface(peer);
|
|
3576
3800
|
await ensurePreface(peer);
|
|
3801
|
+
executionOwners.set(peer, { owner: local, cwd: executionCwd });
|
|
3577
3802
|
bus.add(local);
|
|
3578
3803
|
await local.start();
|
|
3579
3804
|
return { ok: true, model: route ? `${route} (fallback ${routing.local.fixed_model})` : (args.model ?? routing.local.fixed_model) };
|
|
3580
3805
|
}
|
|
3806
|
+
if (adapter?.start.daemonPort === "claude-print") {
|
|
3807
|
+
// #223 R3: one fresh `claude -p` session per review request; idle with no process between reviews.
|
|
3808
|
+
if (existing?.state === "busy") return { ok: false, error: `${peer} is reviewing; retry when it is idle` };
|
|
3809
|
+
if (existing) { mute(existing); await existing.stop(); }
|
|
3810
|
+
const reviewer = adapter.start.create!({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: config, permission: permissionMode(peer), mcpServers: [], daemon: DAEMON_PORTS["claude-print"]({
|
|
3811
|
+
cwd: executionCwd,
|
|
3812
|
+
mcpConfig: JSON.stringify({ mcpServers: { "agent-hub": { command: process.execPath, args: ["run", SERVER_JS], env: toolEnv(peer, true) } } }),
|
|
3813
|
+
systemPrompt: sessionRoleContract(peer),
|
|
3814
|
+
watchdogMs: config.watchdog_ms,
|
|
3815
|
+
privatePaths: [...(config.pii?.paths ?? []), ...config.local.deny],
|
|
3816
|
+
facts: env => env.private ? undefined : reviewDiff(executionCwd, env.refs?.commit, { privatePath: path => isDenied(path, config.local.deny) || matchesPolicy(executionCwd, path, config.pii?.paths ?? [], true), nameable: tasks.nameable }),
|
|
3817
|
+
verdictSince: (task, since) => board.get(task)?.history.some(h => h.by === peer && h.at >= since && (h.event === "approved" || h.event === "changes_requested")) === true,
|
|
3818
|
+
onUsage: usage => void addTokens(peer, (usage.input_tokens ?? 0) + (usage.output_tokens ?? 0)),
|
|
3819
|
+
log,
|
|
3820
|
+
}) }) as ClaudePrintPeer;
|
|
3821
|
+
executionOwners.set(peer, { owner: reviewer, cwd: executionCwd });
|
|
3822
|
+
bus.add(reviewer);
|
|
3823
|
+
await reviewer.start();
|
|
3824
|
+
return { ok: true };
|
|
3825
|
+
}
|
|
3581
3826
|
return { ok: false, error: `unknown peer "${peer}"` };
|
|
3582
3827
|
}
|
|
3583
3828
|
|
|
3584
|
-
async function admitPiRequest(): Promise<{ allowed: boolean; reason?: string; remainingMs?: number }> {
|
|
3585
|
-
const current = bus.peers.get(
|
|
3829
|
+
async function admitPiRequest(peer = "pi"): Promise<{ allowed: boolean; reason?: string; remainingMs?: number }> {
|
|
3830
|
+
const current = bus.peers.get(peer);
|
|
3586
3831
|
if (!(current instanceof PiPeer)) return { allowed: false, reason: "Pi owner is not active" };
|
|
3587
|
-
const decisions = tasks.admitExecutionEnvelopes(current.budgetEnvelopes,
|
|
3832
|
+
const decisions = tasks.admitExecutionEnvelopes(current.budgetEnvelopes, peer, "model_calls");
|
|
3588
3833
|
const denied = decisions.find(d => !d.allowed);
|
|
3589
3834
|
const stop = denied ? current.recordBudgetStop(denied) : undefined;
|
|
3590
3835
|
const deadline = decisions.filter(d => d.unit === "elapsed_ms" && d.remaining !== null).map(d => d.remaining!);
|
|
@@ -3641,7 +3886,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3641
3886
|
}
|
|
3642
3887
|
|
|
3643
3888
|
const conductor = new Conductor(conductorHolds, {
|
|
3644
|
-
roles: () => config.roles, capabilities: () => config.capabilities,
|
|
3889
|
+
roles: () => config.roles, capabilities: () => config.capabilities, instances: () => declarations,
|
|
3645
3890
|
status: () => {
|
|
3646
3891
|
const quota = budget.status();
|
|
3647
3892
|
const ids = [...new Set([...bus.knownPeers(), ...Object.keys(quota), ...conductorHolds.list().map(h => h.peer)])];
|
|
@@ -3649,8 +3894,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3649
3894
|
peers: ids.map(peer => {
|
|
3650
3895
|
const summary = bus.queueSummary(peer);
|
|
3651
3896
|
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 })) } : {}) };
|
|
3897
|
+
manualHeld: manualPaused.has(peer), hold: conductorHolds.get(peer), budgetPause: quota[accountOf(peer)]?.paused ?? null,
|
|
3898
|
+
...(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
3899
|
}),
|
|
3655
3900
|
taskCounts: board.counts(), approvals: [...permissions.values()].map(p => ({ peer: p.peer, tool: p.tool, at: p.createdAt })),
|
|
3656
3901
|
};
|
|
@@ -3670,7 +3915,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3670
3915
|
if (result.ok !== true) throw new Error(String(result.error ?? "peer start failed"));
|
|
3671
3916
|
return result;
|
|
3672
3917
|
},
|
|
3673
|
-
known: peer => bus.knownPeers().includes(peer),
|
|
3918
|
+
known: peer => extraIds.includes(peer) || bus.knownPeers().includes(peer),
|
|
3674
3919
|
pause: peer => { pauseGeneration.set(peer, (pauseGeneration.get(peer) ?? 0) + 1); bus.pause(peer); writeStatus(); },
|
|
3675
3920
|
validateRelease: async peer => {
|
|
3676
3921
|
const owner = bus.peers.get(peer);
|
|
@@ -3793,7 +4038,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3793
4038
|
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
4039
|
}),
|
|
3795
4040
|
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))) }])),
|
|
4041
|
+
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
4042
|
permissions: [...permissions.values()].map(({ push }) => {
|
|
3798
4043
|
const req = JSON.parse(push);
|
|
3799
4044
|
// Local tool titles quote file contents and commands, including those of PII turns.
|
|
@@ -3842,7 +4087,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3842
4087
|
function roleHolders(candidate: HubConfig): { planners: string[]; conductors: string[] } {
|
|
3843
4088
|
// A valid transfer may briefly have two conductors while its leaves are planned. Validate the final map later.
|
|
3844
4089
|
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));
|
|
4090
|
+
const settled = resolveSettledRoles(policy, candidate.planner, peer => rosterAllows(candidate.roster, peer, candidate.pi.enabled, declarations));
|
|
3846
4091
|
return { planners: settled.planners, conductors: Object.keys(candidate.roles).filter(peer => candidate.roles[peer]!.includes("conductor")).sort() };
|
|
3847
4092
|
}
|
|
3848
4093
|
function prepareConfiguration(sources: ConfigSources, authority: SettingAuthority, scope?: string, confirmed: readonly string[] = []) {
|
|
@@ -3871,6 +4116,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3871
4116
|
const after = valueAt(route ? sources.routing : opts.config && !current ? embeddedConfig : fileConfig, path);
|
|
3872
4117
|
const rawAfter = current ? valueAt(current.raw, path) : undefined;
|
|
3873
4118
|
if (!rawChanged && JSON.stringify(before) === JSON.stringify(after)) continue;
|
|
4119
|
+
// Instance declarations are restart-bound. Their derived role/permission/start defaults cannot leak into
|
|
4120
|
+
// the live catalog while the old identities, paths and account callbacks still own the daemon.
|
|
4121
|
+
const boundId = ["roles", "permission_modes", "peers", "permission_default_sources"].includes(path[0] ?? "") ? path[1] : undefined;
|
|
4122
|
+
if (!route && boundId && JSON.stringify(desiredConfig.instances?.[boundId]) !== JSON.stringify(fileConfig.instances?.[boundId])) {
|
|
4123
|
+
rejected.push({ key, reason: "instance declaration changed; its derived defaults apply together after daemon restart" });
|
|
4124
|
+
continue;
|
|
4125
|
+
}
|
|
3874
4126
|
const machine = !route && isMachineKey(key);
|
|
3875
4127
|
const origin = current ?? prior;
|
|
3876
4128
|
let tracked = origin ? configTracked(opts.cwd, origin.name) : false;
|
|
@@ -4071,9 +4323,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4071
4323
|
let routingProblem: string | undefined;
|
|
4072
4324
|
try { parseRouting(routingText(opts.cwd), readOverlay(opts.cwd)); } catch (error) { routingProblem = (error as Error).message; }
|
|
4073
4325
|
const rows = settingRows({
|
|
4074
|
-
cwd: opts.cwd, routing: activeRouting(), ...(routingProblem ? { routingProblem } : {}), ...(stored?.ignored ? { ignored: stored.ignored } : {}),
|
|
4326
|
+
instances: declarations, cwd: opts.cwd, routing: activeRouting(), ...(routingProblem ? { routingProblem } : {}), ...(stored?.ignored ? { ignored: stored.ignored } : {}),
|
|
4075
4327
|
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,
|
|
4328
|
+
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
4329
|
roles: roleState,
|
|
4078
4330
|
mode: (peer) => {
|
|
4079
4331
|
const owner = bus.peers.get(peer), attached = !!owner && owner.state !== "offline";
|
|
@@ -4112,13 +4364,14 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4112
4364
|
if (owner?.state === "busy" && !now) return `${peer} is in a turn; retry when it is idle, or pass --now`;
|
|
4113
4365
|
return undefined;
|
|
4114
4366
|
}
|
|
4115
|
-
function codexResumeCommand(): string {
|
|
4116
|
-
const owner = bus.peers.get(
|
|
4117
|
-
|
|
4367
|
+
function codexResumeCommand(peer = "codex"): string {
|
|
4368
|
+
const owner = bus.peers.get(peer), thread = owner instanceof CodexPeer ? owner.thread || lastCodexThreads.get(peer) : lastCodexThreads.get(peer);
|
|
4369
|
+
const command = declarations[peer] ? `ahub codex --as ${peer} resume` : "ahub codex resume";
|
|
4370
|
+
return thread && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(thread) ? `${command} ${thread}` : command;
|
|
4118
4371
|
}
|
|
4119
4372
|
function retainedTerminalNotice(peer: string): string | undefined {
|
|
4120
4373
|
const owner = bus.peers.get(peer);
|
|
4121
|
-
return definitionOf(peer)?.cli.retainedTerminalNotice?.(peer, owner instanceof PiPeer ? owner.mode : undefined, codexResumeCommand());
|
|
4374
|
+
return definitionOf(peer)?.cli.retainedTerminalNotice?.(peer, owner instanceof PiPeer ? owner.mode : undefined, codexResumeCommand(peer));
|
|
4122
4375
|
}
|
|
4123
4376
|
|
|
4124
4377
|
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 +4381,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4128
4381
|
const terminalNotice = retainedTerminalNotice(peer);
|
|
4129
4382
|
let handoff: Awaited<ReturnType<Tasks["reassignForRoster"]>> = [];
|
|
4130
4383
|
try {
|
|
4131
|
-
const next = rosterCandidate(def, value, undo), before = onRoster(peer), after = rosterAllows(next, peer, config.pi.enabled);
|
|
4384
|
+
const next = rosterCandidate(def, value, undo), before = onRoster(peer), after = rosterAllows(next, peer, config.pi.enabled, declarations);
|
|
4132
4385
|
if (!after) {
|
|
4133
4386
|
const refusal = rosterRemovalRefusal(peer, now);
|
|
4134
4387
|
if (refusal) return { ok: false, error: refusal };
|
|
@@ -4168,7 +4421,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4168
4421
|
if (reloaded.ok !== true) return { ...reloaded, saved: true };
|
|
4169
4422
|
writeStatus();
|
|
4170
4423
|
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 } : {}) };
|
|
4424
|
+
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
4425
|
} catch (error) {
|
|
4173
4426
|
if (saved) writeStatus();
|
|
4174
4427
|
return { ok: false, error: sanitize((error as Error).message), ...(saved ? { saved: true } : {}) };
|
|
@@ -4277,14 +4530,14 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4277
4530
|
const { def, value } = input;
|
|
4278
4531
|
if (def.group === "Roster") {
|
|
4279
4532
|
try {
|
|
4280
|
-
const peer = def.path[1]!, next = rosterCandidate(def, value), on = rosterAllows(next, peer, config.pi.enabled);
|
|
4533
|
+
const peer = def.path[1]!, next = rosterCandidate(def, value), on = rosterAllows(next, peer, config.pi.enabled, declarations);
|
|
4281
4534
|
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
4535
|
} catch (error) { return { ok: false, error: sanitize((error as Error).message) }; }
|
|
4283
4536
|
}
|
|
4284
4537
|
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
4538
|
if (def.group === "Permissions") {
|
|
4286
4539
|
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 } : {}) };
|
|
4540
|
+
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
4541
|
}
|
|
4289
4542
|
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
4543
|
try {
|
|
@@ -4413,7 +4666,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4413
4666
|
...(taskOpsInFlight !== 0 ? ["a task command in flight"] : []),
|
|
4414
4667
|
...(tasks.checksPending() !== 0 ? ["completion checks queued or running"] : []),
|
|
4415
4668
|
...(checkPending.size !== 0 ? ["isolated checks queued or running"] : []),
|
|
4416
|
-
...((
|
|
4669
|
+
...(piCallsInFlight() !== 0 ? ["Pi tool calls in flight"] : []),
|
|
4417
4670
|
...(releasing ? ["ready tasks being released"] : []),
|
|
4418
4671
|
...([...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
4672
|
],
|
|
@@ -4434,11 +4687,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4434
4687
|
? `target lacks task_cancel; cancelled tasks: ${ids.map(id => `#${id}`).join(", ")}` : undefined;
|
|
4435
4688
|
};
|
|
4436
4689
|
const checkTarget = msg.op === "prepare" || msg.op === "commit" || msg.op === "inspect" && msg.targetFeatures !== undefined;
|
|
4437
|
-
if (checkTarget) {
|
|
4690
|
+
if (checkTarget) {
|
|
4691
|
+
const error = incompatibleCancellationTarget(); if (error) return recoveryError(error);
|
|
4692
|
+
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");
|
|
4693
|
+
}
|
|
4438
4694
|
|
|
4439
4695
|
if (msg.op === "inspect" || msg.op === "prepare" || msg.op === "commit") {
|
|
4440
|
-
const pi
|
|
4441
|
-
if (pi instanceof PiPeer && pi.state === "idle") {
|
|
4696
|
+
for (const pi of bus.peers.values()) if (pi instanceof PiPeer && pi.state === "idle") {
|
|
4442
4697
|
try { await pi.captureResume(); } catch (error) { return recoveryError((error as Error).message); }
|
|
4443
4698
|
if (checkTarget) { const error = incompatibleCancellationTarget(); if (error) return recoveryError(error); }
|
|
4444
4699
|
}
|
|
@@ -4499,7 +4754,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4499
4754
|
...saved,
|
|
4500
4755
|
queueIds: bus.queueIds(saved.id),
|
|
4501
4756
|
// Recorded at commit, not prepare: a first turn landing in between flips it (#64).
|
|
4502
|
-
...(definitionOf(saved.id)?.session.missingEvidence === "transcript" && saved.sessionId ? { sessionPersisted: claudeTranscriptPersisted(saved.sessionId) } : {}),
|
|
4757
|
+
...(definitionOf(saved.id)?.session.missingEvidence === "transcript" && saved.sessionId ? { sessionPersisted: claudeTranscriptPersisted(saved.id, saved.sessionId) } : {}),
|
|
4503
4758
|
}));
|
|
4504
4759
|
const snapshot: RestartSnapshot = {
|
|
4505
4760
|
schemaVersion: 1,
|
|
@@ -4547,9 +4802,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4547
4802
|
return recoveryError("unknown recovery operation");
|
|
4548
4803
|
}
|
|
4549
4804
|
|
|
4550
|
-
function claudePermissionLaunch(): { launchId: string; unattended: boolean; hookPurpose?: string } | undefined {
|
|
4805
|
+
function claudePermissionLaunch(peer = "claude"): { launchId: string; unattended: boolean; hookPurpose?: string } | undefined {
|
|
4551
4806
|
try {
|
|
4552
|
-
const row = JSON.parse(readFileSync(join(opts.stateDir, "claude-launch
|
|
4807
|
+
const row = JSON.parse(readFileSync(join(opts.stateDir, claudeStateFile("claude-launch", peer)), "utf8"));
|
|
4553
4808
|
if (row.instanceId !== instanceId || row.permissionHook !== true || typeof row.launchId !== "string" || !row.launchId) return undefined;
|
|
4554
4809
|
return { launchId: row.launchId, unattended: row.unattended === true, ...(typeof row.hookPurpose === "string" ? { hookPurpose: row.hookPurpose } : {}) };
|
|
4555
4810
|
} catch { return undefined; }
|
|
@@ -4578,7 +4833,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4578
4833
|
if (!isPermissionMode(mode)) return { ok: false, error: "mode must be ask, ask-when-needed or never-ask" };
|
|
4579
4834
|
if (mode === "never-ask" && confirmed !== true) return { ok: false, error: "never-ask needs --yes or console confirmation" };
|
|
4580
4835
|
if (definitionOf(peer)?.transport === "claude-channel") {
|
|
4581
|
-
const launch = claudePermissionLaunch();
|
|
4836
|
+
const launch = claudePermissionLaunch(peer);
|
|
4582
4837
|
if (!launch || !(owner instanceof WsPeer) || claudePermissionGenerations.get(peer) !== owner.sessionGeneration) return { ok: false, error: "Claude has no verified ahub claude permission hook; restart with ahub claude and run a tool first" };
|
|
4583
4838
|
if (launch.unattended) return { ok: false, error: "Claude started --unattended already skips prompts; run ahub permission claude ask after it stops to clear the hub choice, then restart without --unattended" };
|
|
4584
4839
|
} else if (owner instanceof CodexPeer) {
|
|
@@ -4646,6 +4901,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4646
4901
|
else await owner.setPermissionMode(selected);
|
|
4647
4902
|
}
|
|
4648
4903
|
// The bus may immediately drain at add(): do not expose an idle adapter until its current choice is applied.
|
|
4904
|
+
executionOwners.set(peer, { owner, cwd: executionRoots.get(peer) ?? opts.cwd });
|
|
4649
4905
|
bus.add(owner);
|
|
4650
4906
|
// start() finished before the bus installed its state hook: replay the current state to drain queued work and record it.
|
|
4651
4907
|
if (owner.state !== "offline") owner.onState?.(owner.state);
|
|
@@ -4746,7 +5002,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4746
5002
|
if (!c.peer || (c.role !== "peer" && c.role !== "tools")) return void reply({ ok: false, error: "held metadata requires a peer identity" });
|
|
4747
5003
|
try {
|
|
4748
5004
|
const held = bus.heldDeliveryHints(c.peer);
|
|
4749
|
-
return void reply(held ? { ok: true, held } : { ok: false, error: "held metadata unavailable" });
|
|
5005
|
+
return void reply(held ? { ok: true, held, queued: bus.queued(c.peer), important: bus.queuedImportant(c.peer) } : { ok: false, error: "held metadata unavailable" });
|
|
4750
5006
|
} catch { return void reply({ ok: false, error: "held metadata unavailable" }); }
|
|
4751
5007
|
}
|
|
4752
5008
|
case "delivery_receipt": {
|
|
@@ -4758,10 +5014,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4758
5014
|
return void reply({ ok: true });
|
|
4759
5015
|
}
|
|
4760
5016
|
case "inbox": {
|
|
4761
|
-
//
|
|
5017
|
+
// The attached session can explicitly claim queued work even during a native turn (#493).
|
|
5018
|
+
// Pull and push share holds and journal settlement; work read here is completed, never pushed again.
|
|
4762
5019
|
const peer = c.peer ? bus.peers.get(c.peer) : undefined;
|
|
4763
5020
|
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
5021
|
try {
|
|
4766
5022
|
const envs = bus.pull(peer.id);
|
|
4767
5023
|
if (!envs) return void reply({ ok: false, error: queueHold(peer.id) ?? (bus.isRecoveryHeld ? "recovery is holding deliveries" : pauseReason(peer.id)) });
|
|
@@ -4850,18 +5106,18 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4850
5106
|
let expectedLaunch: string | undefined;
|
|
4851
5107
|
try {
|
|
4852
5108
|
const rows = JSON.parse(readFileSync(join(opts.stateDir, "terminal-recovery.json"), "utf8"));
|
|
4853
|
-
expectedLaunch = Array.isArray(rows) ? rows.find(row =>
|
|
5109
|
+
expectedLaunch = Array.isArray(rows) ? rows.find(row => row?.peer === peer && definitionOf(row.peer)?.transport === "claude-channel" && row?.projectRoot === opts.cwd && row?.instanceId === instanceId)?.launchId : undefined;
|
|
4854
5110
|
} catch { /* an ordinary terminal has no Orca recovery identity */ }
|
|
4855
5111
|
try {
|
|
4856
|
-
const launch = JSON.parse(readFileSync(join(opts.stateDir, "claude-launch
|
|
5112
|
+
const launch = JSON.parse(readFileSync(join(opts.stateDir, claudeStateFile("claude-launch", peer)), "utf8"));
|
|
4857
5113
|
if (launch.instanceId === instanceId && typeof launch.launchId === "string") expectedLaunch = launch.launchId;
|
|
4858
5114
|
} catch { /* older/manual launches do not have a launcher marker */ }
|
|
4859
|
-
const current = claudeSession();
|
|
5115
|
+
const current = claudeSession(peer);
|
|
4860
5116
|
if ((phase === "stop" || phase === "post") && current.sessionId && current.sessionId !== sessionId) return void reply({ t: "facts", ok: false });
|
|
4861
5117
|
if (!expectedLaunch || msg.nativeLaunchId === expectedLaunch) {
|
|
4862
|
-
const transcript = claudeTranscript(sessionId, msg.transcriptPath);
|
|
5118
|
+
const transcript = claudeTranscript(sessionId, msg.transcriptPath, peer);
|
|
4863
5119
|
const launchId = typeof msg.nativeLaunchId === "string" ? msg.nativeLaunchId : undefined;
|
|
4864
|
-
const file = join(opts.stateDir, "claude-session
|
|
5120
|
+
const file = join(opts.stateDir, claudeStateFile("claude-session", peer));
|
|
4865
5121
|
writeFileSync(`${file}.hook.tmp`, JSON.stringify({ at: Date.now(), instanceId, sessionId, ...(launchId ? { launchId } : {}), ...(transcript ? { transcriptPath: transcript } : {}) }), { mode: 0o600 });
|
|
4866
5122
|
chmodSync(`${file}.hook.tmp`, 0o600); renameSync(`${file}.hook.tmp`, file);
|
|
4867
5123
|
nativeClaude = true;
|
|
@@ -4870,7 +5126,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4870
5126
|
}
|
|
4871
5127
|
if (definitionOf(peer)?.transport === "claude-channel" && msg.nativeInstanceId !== undefined && msg.nativeInstanceId !== instanceId) return void reply({ t: "facts", ok: false });
|
|
4872
5128
|
const nativePeer = nativeClaude ? bus.peers.get(peer) : undefined;
|
|
4873
|
-
const permissionLaunch = definitionOf(peer)?.transport === "claude-channel" ? claudePermissionLaunch() : undefined;
|
|
5129
|
+
const permissionLaunch = definitionOf(peer)?.transport === "claude-channel" ? claudePermissionLaunch(peer) : undefined;
|
|
4874
5130
|
const permission = nativeClaude && permissionLaunch && !permissionLaunch.unattended && msg.nativeLaunchId === permissionLaunch.launchId ? permissionMode(peer) : undefined;
|
|
4875
5131
|
if (permission !== undefined && nativePeer instanceof WsPeer) claudePermissionGenerations.set(peer, nativePeer.sessionGeneration);
|
|
4876
5132
|
const protectedWrite = scopedWriteBlocked(phase, permission, msg.tool, () => projectFileTool(msg.tool, msg.input, opts.cwd, config.protect_instruction_files, config.data?.readonly));
|
|
@@ -4882,10 +5138,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4882
5138
|
if (phase === "session") { recordSessions(); return void reply({ t: "facts", ok: true }); }
|
|
4883
5139
|
if ((phase === "start" || phase === "pre") && nativePeer instanceof WsPeer) {
|
|
4884
5140
|
const previous = nativeTurnStart.get(peer);
|
|
4885
|
-
const current = claudeSession();
|
|
5141
|
+
const current = claudeSession(peer);
|
|
4886
5142
|
if (phase === "start" || nativePeer.state !== "busy" || previous?.generation !== nativePeer.sessionGeneration || previous?.session !== sessionId || previous?.launch !== (current.launchId ?? "")) {
|
|
4887
|
-
const session = claudeSession();
|
|
4888
|
-
const baseline = session.sessionId && session.transcriptPath ?
|
|
5143
|
+
const session = claudeSession(peer);
|
|
5144
|
+
const baseline = session.sessionId && session.transcriptPath ? readClaudeTranscriptCompletion(session.sessionId, session.transcriptPath)?.id : undefined;
|
|
4889
5145
|
nativeTurnStart.set(peer, { at: Date.now(), ...(baseline ? { baseline } : {}), generation: nativePeer.sessionGeneration, session: sessionId!, launch: session.launchId ?? "" });
|
|
4890
5146
|
}
|
|
4891
5147
|
nativePeer.nativeStarted(sessionId!);
|
|
@@ -4904,12 +5160,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4904
5160
|
event({ type: "hook_stats", peer, n: st.n, startupMs: Math.round(st.startupMs), hubMs: Math.round(st.hubMs), maxStartupMs: Math.round(st.maxStartupMs) });
|
|
4905
5161
|
hookStats.delete(peer);
|
|
4906
5162
|
if (id && nativePeer instanceof WsPeer) nativePeer.nativeStopped(sessionId!);
|
|
4907
|
-
if (factsOn()) { factSession(peer, sessionId); readbacks(peer, claudeTranscript(sessionId, msg.transcriptPath)); }
|
|
5163
|
+
if (factsOn()) { factSession(peer, sessionId); readbacks(peer, claudeTranscript(sessionId, msg.transcriptPath, peer)); }
|
|
4908
5164
|
};
|
|
4909
5165
|
if (!nativeClaude) { finishStop(); reply({ t: "facts", ok: true }); return; } // legacy bookkeeping cannot certify native completion
|
|
4910
|
-
const session = claudeSession();
|
|
4911
|
-
let transcript = session.transcriptPath ?? claudeTranscript(sessionId, msg.transcriptPath);
|
|
4912
|
-
const readCompletion = () => { transcript ??= claudeTranscript(sessionId, msg.transcriptPath); return session.sessionId && transcript ?
|
|
5166
|
+
const session = claudeSession(peer);
|
|
5167
|
+
let transcript = session.transcriptPath ?? claudeTranscript(sessionId, msg.transcriptPath, peer);
|
|
5168
|
+
const readCompletion = () => { transcript ??= claudeTranscript(sessionId, msg.transcriptPath, peer); return session.sessionId && transcript ? readClaudeTranscriptCompletion(session.sessionId, transcript) : undefined; };
|
|
4913
5169
|
const completionId = (id: string) => createHash("sha256").update(`${sessionId}:${session.launchId ?? "legacy"}:${id}`).digest("hex");
|
|
4914
5170
|
const started = nativeTurnStart.get(peer);
|
|
4915
5171
|
const activeStart = !!started && started.session === sessionId && started.launch === (session.launchId ?? "") && nativePeer instanceof WsPeer && started.generation === nativePeer.sessionGeneration;
|
|
@@ -4920,7 +5176,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4920
5176
|
}
|
|
4921
5177
|
if (!started || !activeStart || !(nativePeer instanceof WsPeer)) { log("native Stop refused for claude: current native turn start unavailable"); return void reply({ t: "facts", ok: false }); }
|
|
4922
5178
|
const scopeCurrent = () => {
|
|
4923
|
-
const now = claudeSession();
|
|
5179
|
+
const now = claudeSession(peer);
|
|
4924
5180
|
return !stopping && bus.peers.get(peer) === nativePeer && nativePeer.sessionGeneration === started.generation && now.sessionId === started.session && (now.launchId ?? "") === started.launch;
|
|
4925
5181
|
};
|
|
4926
5182
|
const current = () => scopeCurrent() && nativeTurnStart.get(peer) === started;
|
|
@@ -4935,14 +5191,20 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4935
5191
|
if (nativeStopWaits.get(peer) === ownWait) nativeStopWaits.delete(peer);
|
|
4936
5192
|
if (!result.record) {
|
|
4937
5193
|
const reason = ({ unavailable: "completed transcript message unavailable", baseline: "completion still matches start baseline", "before-start": "completion timestamp precedes native start", superseded: "native start superseded during completion wait", verified: "completed transcript message unavailable" } as const)[result.reason];
|
|
4938
|
-
log(`native Stop refused for claude: ${reason}`);
|
|
5194
|
+
log(`native Stop refused for claude: ${reason}`);
|
|
5195
|
+
// #517: a peer left busy receives no pushed messages; say so where the person looks, once per turn.
|
|
5196
|
+
if (result.reason === "unavailable" && !stopUnconfirmedNoticed.has(started)) {
|
|
5197
|
+
stopUnconfirmedNoticed.add(started);
|
|
5198
|
+
notify(`${peer}: its turn end could not be confirmed from its transcript, so it stays busy and messages to it wait in its queue (hub_inbox reads them)`);
|
|
5199
|
+
}
|
|
5200
|
+
return;
|
|
4939
5201
|
}
|
|
4940
5202
|
const id = completionId(result.record.id);
|
|
4941
5203
|
if (scopeCurrent() && nativeStopsSeen.has(id)) return;
|
|
4942
5204
|
if (!current()) { log("native Stop refused for claude: native start superseded during completion wait"); return; }
|
|
4943
5205
|
nativeStopsSeen.add(id); nativeTurnStart.delete(peer); // consume before idle admits another native turn
|
|
4944
5206
|
if (transcript && !session.transcriptPath) {
|
|
4945
|
-
const file = join(opts.stateDir, "claude-session
|
|
5207
|
+
const file = join(opts.stateDir, claudeStateFile("claude-session", peer));
|
|
4946
5208
|
writeFileSync(`${file}.hook.tmp`, JSON.stringify({ at: Date.now(), instanceId, sessionId, launchId: session.launchId, transcriptPath: transcript }), { mode: 0o600 });
|
|
4947
5209
|
chmodSync(`${file}.hook.tmp`, 0o600); renameSync(`${file}.hook.tmp`, file);
|
|
4948
5210
|
}
|
|
@@ -4956,16 +5218,16 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4956
5218
|
if (!factsOn()) return void reply({ t: "facts", ok: true, ...permissionReply });
|
|
4957
5219
|
if (definitionOf(peer)?.transport === "claude-channel" && phase === "pre") observeProgress(peer, normalizeClaudeObservation(tool, input));
|
|
4958
5220
|
factSession(peer, sessionId);
|
|
4959
|
-
const transcript = claudeTranscript(sessionId, msg.transcriptPath);
|
|
5221
|
+
const transcript = claudeTranscript(sessionId, msg.transcriptPath, peer);
|
|
4960
5222
|
if (phase === "post") {
|
|
4961
|
-
|
|
5223
|
+
factsFor(peer).postTool(peer, toolUseId, tool, input);
|
|
4962
5224
|
readbacks(peer, transcript);
|
|
4963
5225
|
return void reply({ t: "facts", ok: true });
|
|
4964
5226
|
}
|
|
4965
5227
|
readbacks(peer, transcript);
|
|
4966
5228
|
checkCapability(peer);
|
|
4967
5229
|
const started = performance.now();
|
|
4968
|
-
|
|
5230
|
+
factsFor(peer).preTool(peer, toolUseId, tool, input);
|
|
4969
5231
|
// Without a transcript nothing offered could ever be read back: a verified path is lost, and nothing is offered
|
|
4970
5232
|
// (the same diff would otherwise go in at every tool call).
|
|
4971
5233
|
if (!transcript) {
|
|
@@ -5031,6 +5293,23 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5031
5293
|
if (msg.op === "export") {
|
|
5032
5294
|
try { return void reply({ ok: true, export: tasks.workflowExport(msg.id) }); } catch { return void reply({ ok: false, error: "workflow export unavailable" }); }
|
|
5033
5295
|
}
|
|
5296
|
+
if (msg.op === "accept") {
|
|
5297
|
+
// #481 S1: the person's spec acceptance, bound to the issue's body edit time; no body is read.
|
|
5298
|
+
if (!Number.isSafeInteger(msg.id) || msg.id <= 0 || !Number.isSafeInteger(msg.issue) || msg.issue <= 0 || (msg.repo !== undefined && typeof msg.repo !== "string")) return void reply({ ok: false, error: "usage: ahub sdlc accept <task> --issue <n> [--repo owner/repository]" });
|
|
5299
|
+
const task = board.get(msg.id);
|
|
5300
|
+
const repo = msg.repo ?? task?.sdlc?.repo;
|
|
5301
|
+
if (!task || typeof repo !== "string") return void reply({ ok: false, error: task ? "spec acceptance needs --repo owner/repository (the task's receipt names none)" : "invalid lifecycle task id" });
|
|
5302
|
+
const linked = `${task.sdlc?.repo ?? ""}#${task.sdlc?.issue ?? ""}`;
|
|
5303
|
+
void readSpecEdit(repo, msg.issue, (args, timeout, _cwd, bound) => sdlcGh(args, timeout, opts.cwd, bound)).then(edit => {
|
|
5304
|
+
if (!edit) return reply({ ok: false, error: "issue edit time unavailable; nothing was recorded" });
|
|
5305
|
+
// The readback is for the receipt read before it; a relink meanwhile makes it someone else's evidence.
|
|
5306
|
+
const now = board.get(msg.id)?.sdlc;
|
|
5307
|
+
if (`${now?.repo ?? ""}#${now?.issue ?? ""}` !== linked) return reply({ ok: false, error: "the task's spec link changed during the readback; nothing was recorded, run ahub sdlc accept again" });
|
|
5308
|
+
try { const next = tasks.acceptSpec(USER, msg.id, { repo, issue: msg.issue, edit }); reply({ ok: true, acceptance: next.sdlc?.acceptance }); }
|
|
5309
|
+
catch (error) { reply({ ok: false, error: (error as Error).message }); }
|
|
5310
|
+
}, () => reply({ ok: false, error: "issue edit time unavailable; nothing was recorded" }));
|
|
5311
|
+
return;
|
|
5312
|
+
}
|
|
5034
5313
|
if (msg.op !== "status" && msg.op !== "explain") return void reply({ ok: false, error: "unknown SDLC operation" });
|
|
5035
5314
|
const selected = msg.id === undefined ? board.list().filter(task => task.state !== "backlog" && (task.sdlc || lifecycleConfig.mode !== "off" || lifecycleConfig.workflow === "gsd-lite")) : [board.get(Number(msg.id))].filter((task): task is Task => !!task);
|
|
5036
5315
|
if (msg.id !== undefined && (!Number.isSafeInteger(msg.id) || msg.id <= 0 || !selected.length)) return void reply({ ok: false, error: "invalid lifecycle task id" });
|
|
@@ -5235,9 +5514,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5235
5514
|
// logged and shutdown finishes anyway, or the process ignores SIGTERM forever.
|
|
5236
5515
|
for (const exit of exits) if (exit.status === "rejected") log(`peer stop failed during shutdown: ${(exit.reason as Error)?.message ?? exit.reason}`);
|
|
5237
5516
|
await runnerStopped;
|
|
5238
|
-
await
|
|
5517
|
+
await Promise.all([...modelRelays.values()].map(relay => relay.close()));
|
|
5239
5518
|
await egress?.close();
|
|
5240
|
-
await
|
|
5519
|
+
await Promise.all([...piReceiptLedgers.values()].map(ledger => ledger.close()));
|
|
5241
5520
|
collectClaudeUsage();
|
|
5242
5521
|
for (const id of [...researchPending.keys()]) writeResearch(id); // the peers have stopped, so their turns have ended
|
|
5243
5522
|
budget.close();
|