@staix/agent-hub 0.12.42 → 0.12.44
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/README.md +5 -4
- package/docs/adding-an-ai-tool.md +140 -0
- package/docs/agent-notes/README.md +5 -0
- package/docs/agent-notes/adapters.md +11 -4
- package/docs/agent-notes/budget.md +2 -2
- package/docs/agent-notes/bus.md +2 -0
- package/docs/agent-notes/daemon.md +15 -6
- package/docs/agent-notes/tasks.md +7 -2
- package/docs/agent-notes/tests.md +6 -2
- package/docs/operations.md +159 -26
- package/docs/quickstart.md +11 -3
- package/docs/sdlc.md +1 -1
- package/docs/security.md +7 -5
- package/docs/specs/2026-09-19-agent-hub-design.md +209 -13
- package/docs/verification/2026-10-21-0.12.43.md +50 -0
- package/docs/verification/2026-10-21-0.12.44.md +54 -0
- package/docs/verification/2026-10-21-agy-1.4.3-t0.md +65 -0
- package/docs/verification/2026-10-21-grok-config-acp-t0.md +30 -0
- package/docs/verification/2026-10-21-pii-screen.md +14 -0
- package/docs/verified.json +34 -19
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +808 -255
- package/src/adapters/acp-owned-group.ts +124 -0
- package/src/adapters/acp.ts +92 -25
- package/src/adapters/agy/definition.ts +1 -3
- package/src/adapters/agy/entry.ts +4 -1
- package/src/adapters/agy/hooks.ts +7 -2
- package/src/adapters/channel-peer.ts +143 -0
- package/src/adapters/claude-channel.ts +7 -7
- package/src/adapters/codex-appserver.ts +16 -2
- package/src/adapters/configured-acp.ts +124 -0
- package/src/adapters/definitions/builtin-metadata.ts +37 -33
- package/src/adapters/definitions/native-channel-state.ts +41 -0
- package/src/adapters/definitions/native-cli-entry-kimi.ts +3 -1
- package/src/adapters/definitions/native-cli-preview-qwen.ts +1 -1
- package/src/adapters/definitions/native-identity-defaults.ts +14 -0
- package/src/adapters/definitions/qwen.ts +3 -8
- package/src/adapters/definitions/reviewer-variants.ts +3 -7
- package/src/adapters/jcode.ts +4 -0
- package/src/adapters/local-worker.ts +4 -0
- package/src/adapters/pi.ts +50 -16
- package/src/adapters/registry-types.ts +18 -16
- package/src/adapters/registry.ts +68 -6
- package/src/adapters/reviewer-print.ts +4 -0
- package/src/cli/agy-mcp.ts +3 -2
- package/src/cli/completion.ts +173 -0
- package/src/cli/configured-acp-entry.ts +58 -0
- package/src/cli/console-state.ts +116 -24
- package/src/cli/console.ts +9 -6
- package/src/cli/facts-hook.ts +2 -1
- package/src/cli/help.ts +8 -1
- package/src/cli/identity.ts +8 -7
- package/src/cli/launch.ts +5 -4
- package/src/cli/main.js +5 -1
- package/src/cli/main.ts +84 -27
- package/src/cli/memory.ts +13 -10
- package/src/cli/output.ts +1 -0
- package/src/cli/preview.ts +9 -0
- package/src/cli/recovery-guidance.ts +1 -1
- package/src/cli/terminal-recovery.ts +33 -21
- package/src/cli/upgrade-restore-state.ts +3 -2
- package/src/cli/upgrade-runtime.ts +21 -6
- package/src/cli/upgrade.ts +3 -2
- package/src/hub/board.ts +9 -6
- package/src/hub/budget.ts +9 -2
- package/src/hub/bus.ts +6 -2
- package/src/hub/claude-state.ts +2 -37
- package/src/hub/conductor.ts +8 -6
- package/src/hub/config-apply.ts +26 -14
- package/src/hub/config-reload.ts +39 -11
- package/src/hub/config-trust.ts +1 -1
- package/src/hub/context-window.ts +6 -0
- package/src/hub/control-client.ts +1 -1
- package/src/hub/crash.ts +8 -6
- package/src/hub/daemon.ts +471 -338
- package/src/hub/hub-tools.ts +37 -1
- package/src/hub/instance-config.ts +20 -11
- package/src/hub/instance-execution.ts +4 -4
- package/src/hub/instances.ts +45 -21
- package/src/hub/lifecycle-schema.ts +5 -3
- package/src/hub/main-checkout.ts +2 -2
- package/src/hub/manager.ts +10 -2
- package/src/hub/memory-profile.ts +16 -3
- package/src/hub/no-writes.ts +50 -0
- package/src/hub/permission-mode.ts +9 -7
- package/src/hub/roster.ts +7 -4
- package/src/hub/routing.ts +21 -20
- package/src/hub/settings.ts +51 -17
- package/src/hub/snapshots.ts +276 -17
- package/src/hub/start-mode.ts +10 -8
- package/src/hub/task-sweep.ts +8 -2
- package/src/hub/tasks.ts +101 -19
- package/src/memory/capture.ts +2 -1
- package/src/ui/index.html +5 -0
- package/templates/AGENTS.block.md +1 -1
- package/templates/profiles/general.json +1 -1
- package/templates/profiles/research.json +1 -1
- package/templates/profiles/writing.json +1 -1
package/src/hub/daemon.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { WsPeer, isWsPeer } from "../adapters/channel-peer.ts";
|
|
2
|
+
import { ownerToolEffect, canonicalWriteUnknown } from "./no-writes.ts";
|
|
1
3
|
import { NativeHookLaunches } from "./native-hook-launch.ts";
|
|
2
4
|
import { readReleaseHostAdmission, type ReleaseHostAdmission } from "./release-admission.ts";
|
|
3
5
|
import { ContainerRunner, type RunnerOptions, type RunReceipt } from "./check-runner.ts";
|
|
@@ -20,17 +22,17 @@ import { buildReviewBrief } from "./review-brief.ts";
|
|
|
20
22
|
import { profileName, projectProfile, resolveProfile, mergeProfileOverrides, type ProfileName } from "./profiles.ts";
|
|
21
23
|
import { catalogAssignments, loadRoleCatalog, selectedRoles, roleIdentity } from "./roles.ts";
|
|
22
24
|
import { ActivityRing } from "./activity.ts";
|
|
23
|
-
import { JcodeOptionMismatch, JcodePeer } from "../adapters/jcode.ts";
|
|
25
|
+
import { JcodeOptionMismatch, isJcodePeer, type JcodePeer } from "../adapters/jcode.ts";
|
|
24
26
|
import { jcodeMcpReady } from "../cli/jcode-mcp.ts";
|
|
25
27
|
import { loadSdlc, readSpecEdit, sdlcGh } from "./sdlc.ts";
|
|
26
|
-
import { claudeStateFile, ownTerminalRow, pathEvidence, transcriptPersisted } from "./claude-state.ts";
|
|
28
|
+
import { claudePeer as defaultChannelPeer, claudeStateFile, ownTerminalRow, pathEvidence, transcriptPersisted } from "./claude-state.ts";
|
|
27
29
|
import { peerRosterConfig, rosterAllows, type PeerRoster } from "./roster.ts";
|
|
28
30
|
import { KNOWN_PEERS, OPTIONAL_PEERS, PLANNER_PEERS, SUPPORTED_PEERS, START_MODE_PEERS, isSupportedPeer, STARTABLE_PEERS, isStartablePeer } from "../adapters/registry.ts";
|
|
29
31
|
import { RecoveryDeferredHubError } from "./deferred-hub.ts";
|
|
30
32
|
import { databaseBusySite, isDatabaseBusy, RecoveryDatabaseBusy } from "./recovery-error.ts";
|
|
31
|
-
import { ADAPTERS, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS, QUOTA_HANDOFF_PLATFORMS, NATIVE_CONFIG_BLOCKS, nativeConfigurationDefaults, mergeNativeConfiguration, retainNativeRouting, DAEMON_PORTS, definitionOf } from "../adapters/registry.ts";
|
|
32
|
-
import { isSupportedPeer as registrySupportedPeer, definitionOf as
|
|
33
|
-
import { effectiveInstanceRoles, instancePeerIds, validateInstances, type InstancesConfig } from "./instances.ts";
|
|
33
|
+
import { ADAPTERS, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS, QUOTA_HANDOFF_PLATFORMS, NATIVE_CONFIG_BLOCKS, nativeConfigurationDefaults, mergeNativeConfiguration, retainNativeRouting, DAEMON_PORTS, definitionOf, createProjectAdapterCatalog } from "../adapters/registry.ts";
|
|
34
|
+
import { isSupportedPeer as registrySupportedPeer, definitionOf as builtinDefinitionOf, KNOWN_PEERS as BASE_KNOWN_PEERS, SUPPORTED_PEERS as BASE_SUPPORTED_PEERS, STARTABLE_PEERS as BASE_STARTABLE_PEERS } from "../adapters/registry.ts";
|
|
35
|
+
import { instanceDefinitionOf, effectiveInstanceRoles, instancePeerIds, validateInstances, type InstancesConfig } from "./instances.ts";
|
|
34
36
|
import { INSTANCE_CONFIG_FILES, readInstanceConfig, readInstanceConfigLayers, readInstanceRoleConfig } from "./instance-config.ts";
|
|
35
37
|
import { instanceBudgetHooks, projectBudgetHeadroom } from "./instance-budget.ts";
|
|
36
38
|
import { resolveInstanceExecutionCwd } from "./instance-execution.ts";
|
|
@@ -45,16 +47,16 @@ import { childEnv } from "./child-process.ts";
|
|
|
45
47
|
import { runCheck } from "./checks.ts";
|
|
46
48
|
import { DEFAULT_TASK_SWEEP, taskSweepConfig, type TaskSweepConfig } from "./task-sweep.ts";
|
|
47
49
|
import { ALL_MACHINE_LOCAL, configRepositoryPresent, configTracked, stripUntrusted } from "./config-trust.ts";
|
|
48
|
-
import { authorizeConfigLeaf, configApply, configChanges, validateEffectiveConfig, validatePetConfig } from "./config-apply.ts";
|
|
50
|
+
import { authorizeConfigLeaf, configApply, configIdentityScope, configChanges, validateEffectiveConfig, validatePetConfig } from "./config-apply.ts";
|
|
49
51
|
import { resolveSettledRoles } from "./settled-roles.ts";
|
|
50
52
|
import { CONFIG_SOURCE_FILES, configFingerprint, configSourceTexts, parseConfigSources, type ConfigSources } from "./config-reload.ts";
|
|
51
53
|
import { eventLog, readEvents, tokenDeltas, type RoleIdentity } from "./events.ts";
|
|
52
54
|
import { ExecutionBudget } from "./execution-budget.ts";
|
|
53
55
|
import { claudeReportedTokens, readClaudeTranscriptCompletion, readClaudeTranscriptUsage, waitForClaudeCompletion } from "./usage.ts";
|
|
54
56
|
import type { ServerWebSocket } from "bun";
|
|
55
|
-
import { AcpPeer, QwenIdentityError, type PermissionRequest } from "../adapters/acp.ts";
|
|
56
|
-
import { CodexPeer } from "../adapters/codex-appserver.ts";
|
|
57
|
-
import { PiPeer } from "../adapters/pi.ts";
|
|
57
|
+
import { isAcpPeer, type AcpPeer, QwenIdentityError, type PermissionRequest } from "../adapters/acp.ts";
|
|
58
|
+
import { isCodexPeer, type CodexPeer } from "../adapters/codex-appserver.ts";
|
|
59
|
+
import { isPiPeer, type PiPeer } from "../adapters/pi.ts";
|
|
58
60
|
import { startModelRelay, type ModelRelay } from "../models/relay.ts";
|
|
59
61
|
import { ensureMlx, type MlxOptions } from "../models/mlx.ts";
|
|
60
62
|
import { PiToolReceipts, packPiShellResult } from "../pi/tool-receipts.ts";
|
|
@@ -64,8 +66,8 @@ import { PiSessionBookmarkMismatch, PiSessionUnresumable, recordPiReplacement, v
|
|
|
64
66
|
import { profile, proxyEnv, type SandboxNetwork } from "../local/sandbox.ts";
|
|
65
67
|
import { DEFAULT_NETWORK_ALLOW, startEgressProxy, type EgressProxy } from "../local/proxy.ts";
|
|
66
68
|
import { runTool, toolResultFailed, TOOL_SCHEMAS, type ToolApproval, type ApprovalObserver, type ToolContext } from "../local/tools.ts";
|
|
67
|
-
import { LocalPeer } from "../adapters/local-worker.ts";
|
|
68
|
-
import { ReviewerPrintPeer } from "../adapters/reviewer-print.ts";
|
|
69
|
+
import { isLocalPeer, type LocalPeer } from "../adapters/local-worker.ts";
|
|
70
|
+
import { isReviewerPrintPeer, type ReviewerPrintPeer } from "../adapters/reviewer-print.ts";
|
|
69
71
|
import { createReviewerMcpSession } from "../adapters/reviewer-mcp.ts";
|
|
70
72
|
import { reviewerPoolConfig } from "./reviewer-pool-config.ts";
|
|
71
73
|
import { reviewDiff } from "../adapters/claude-print.ts";
|
|
@@ -73,14 +75,14 @@ import { Capture, skipTools } from "../memory/capture.ts";
|
|
|
73
75
|
import { DEFAULT_OMNIROUTE, OmniRoute, type OmniRouteConfig } from "../omniroute/client.ts";
|
|
74
76
|
import { Briefs } from "../memory/brief.ts";
|
|
75
77
|
import { Board, isClosedTaskState, taskProposalAt, taskHasStarted, CLASSES, type Task, type TaskClass } from "./board.ts";
|
|
76
|
-
import { Budget,
|
|
78
|
+
import { Budget, readNativeQuota, DEFAULT_BUDGET, type BudgetConfig } from "./budget.ts";
|
|
77
79
|
import { HUB } from "./envelope.ts";
|
|
78
80
|
import { recallMemoryLines, trimToTokens } from "../memory/recall.ts";
|
|
79
81
|
import { closeSync, constants as fsConstants, openSync, lstatSync, readSync, statSync } from "node:fs";
|
|
80
82
|
import { basename, isAbsolute } from "node:path";
|
|
81
83
|
import { fileURLToPath } from "node:url";
|
|
82
84
|
import { realPath, hubHome } from "./project.ts";
|
|
83
|
-
import { mainCheckoutWarning } from "./main-checkout.ts";
|
|
85
|
+
import { mainCheckoutBranch, mainCheckoutWarning } from "./main-checkout.ts";
|
|
84
86
|
import type { BusEvent, PublishResult } from "./bus.ts";
|
|
85
87
|
import { CONDUCTOR_TOOLS, CONDUCTOR_TOOL_NAMES, DEFAULT_ROLES, roleContract, TASK_TOOL_NAMES, TASK_TOOLS, BOUNDED_TASK_TOOLS, profileTaskTools } from "./hub-tools.ts";
|
|
86
88
|
import { Conductor, ConductorHolds, conductorPeer, conductorProgressSink, publicConductorTask, publicPeerBudget, type ConductEvent } from "./conductor.ts";
|
|
@@ -110,7 +112,7 @@ import { Facts, FACTS_PREFIX, type FactScope } from "./facts.ts";
|
|
|
110
112
|
import { crashPlan, lossNotice, readSessions, removeSessions, writeSessions, type SessionsFile } from "./crash.ts";
|
|
111
113
|
import type { JournalDelivery } from "./delivery-journal.ts";
|
|
112
114
|
import { DEFAULT_LIMITS, Limiter, PROJECT_LIMITS, type LimitsConfig } from "./limits.ts";
|
|
113
|
-
import { changedPaths, hasTree, repoOf, snapshot, Turns, type TurnRecord } from "./snapshots.ts";
|
|
115
|
+
import { soleWorktree, branchHeads, changedPaths, knownChangedPaths, hasTree, headState, headMoveCursor, incomingHeadPaths, repoOf, snapshot, SnapshotBudgetExceeded, Turns, type SnapshotBudget, type HeadMoveCursor, type Repo, type TurnRecord } from "./snapshots.ts";
|
|
114
116
|
import { archiveRestartSnapshot, readRecoveryWaivers, readRestartSnapshot, removeRestartSnapshot, restartPath, writeRestartSnapshot, type RecoveryPhase, type RestartPeerSnapshot, type RestartSnapshot } from "./restart.ts";
|
|
115
117
|
import { consumeRecoveryContinuity, readRecoveryContinuity, retireRecoveryContinuity } from "./recovery-continuity.ts";
|
|
116
118
|
import { shellQuote, publishTerminalLaunch, terminalLaunchCandidate } from "../cli/terminal-recovery.ts";
|
|
@@ -120,7 +122,7 @@ import { peerStartConfig, PEER_START_RETRY_MS, startModeOf, terminalTemplate, ty
|
|
|
120
122
|
import { hasKimiHubMcp } from "../cli/kimi-mcp.ts";
|
|
121
123
|
import type { TerminalOpener } from "./terminal-open.ts";
|
|
122
124
|
import { grantablePath, instructionProtection, isPermissionMode, permissionBoundary, permissionDefaults, permissionGrant, PI_EDIT_TOOLS, type PermissionMode } from "./permission-mode.ts";
|
|
123
|
-
import { ContextWindows, DEFAULT_CONTEXT,
|
|
125
|
+
import { ContextWindows, DEFAULT_CONTEXT, readNativeContext, type ContextConfig } from "./context-window.ts";
|
|
124
126
|
import { projectFileTool, scopedWriteBlocked } from "../cli/facts-hook.ts";
|
|
125
127
|
import { detectTooling } from "./tooling.ts";
|
|
126
128
|
import { looksSecret, DEFAULT_LEASE_MS, extractNodes, memoryHome, SUGGESTED, taskKey, type CandidateStore, type MemoryStore, type GoalView, type Suggested, type View as MemoryView } from "@staix/agent-mem";
|
|
@@ -132,6 +134,7 @@ import { nativeRecall } from "../memory/recall.ts";
|
|
|
132
134
|
import { promotionScreen, withoutPii, mentionsPrivatePath } from "./memory-policy.ts";
|
|
133
135
|
|
|
134
136
|
export interface HubConfig {
|
|
137
|
+
adapters?: Readonly<Record<string, import("../adapters/configured-acp.ts").ConfiguredAcpAdapterConfig>>;
|
|
135
138
|
/** Person-owned declarations, read only from an untracked machine-local file. Bound for this daemon lifetime. */
|
|
136
139
|
instances?: InstancesConfig;
|
|
137
140
|
watchdog_ms: number;
|
|
@@ -251,7 +254,8 @@ export const DEFAULT_CONFIG: HubConfig = {
|
|
|
251
254
|
export { stateDirFor };
|
|
252
255
|
|
|
253
256
|
/** Ids an external process may not claim: the console user and the adapters the daemon runs itself. */
|
|
254
|
-
const
|
|
257
|
+
const READ_ONLY_HUB_TOOLS = new Set(["hub_inbox", "hub_send", "hub_delivery_done", "hub_review", "hub_task_show", "hub_task_list", "hub_memory_search", "hub_memory_show", "hub_memory_graph", "hub_status"]);
|
|
258
|
+
const BASE_RESERVED_IDS = new Set<string>([USER, ...SUPPORTED_PEERS.filter(peer => definitionOf(peer)?.attach?.externalHello !== true), HUB, DIGEST]);
|
|
255
259
|
const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
|
|
256
260
|
|
|
257
261
|
/** The shared project config, then the machine's own file, which overrides it block by block (issue #17). */
|
|
@@ -322,6 +326,7 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
|
|
|
322
326
|
const captured = layers ?? readInstanceConfigLayers(cwd);
|
|
323
327
|
const instanceConfig = readInstanceConfig(cwd, captured);
|
|
324
328
|
const instances = instanceConfig.instances;
|
|
329
|
+
const adapterCatalog = instanceConfig.catalog;
|
|
325
330
|
const ignored: string[] = [...instanceConfig.ignored];
|
|
326
331
|
const appliedPermissionSources: NonNullable<HubConfig["permission_default_sources"]> = {};
|
|
327
332
|
const files = CONFIG_FILES.flatMap((name) => {
|
|
@@ -333,8 +338,9 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
|
|
|
333
338
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
334
339
|
throw error;
|
|
335
340
|
}
|
|
341
|
+
delete file.adapters; // Adapter provenance is compiled by the same project-local loader.
|
|
336
342
|
delete file.instances; // Declaration authority was checked against its exact captured origin above.
|
|
337
|
-
peerRosterConfig(file.roster, instances);
|
|
343
|
+
peerRosterConfig(file.roster, instances, adapterCatalog);
|
|
338
344
|
catalogAssignments(file.catalog_roles);
|
|
339
345
|
profileName(file.profile);
|
|
340
346
|
resolveProfile(file.profile, file.profile_overrides);
|
|
@@ -349,8 +355,8 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
|
|
|
349
355
|
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`);
|
|
350
356
|
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");
|
|
351
357
|
instructionProtection(file.protect_instruction_files);
|
|
352
|
-
permissionDefaults(file.permission_modes, instances); // malformed declarations fail even when trust would drop them
|
|
353
|
-
peerStartConfig(file.peers, instances);
|
|
358
|
+
permissionDefaults(file.permission_modes, instances, adapterCatalog); // malformed declarations fail even when trust would drop them
|
|
359
|
+
peerStartConfig(file.peers, instances, adapterCatalog);
|
|
354
360
|
if (file.terminal != null && (typeof file.terminal !== "object" || Array.isArray(file.terminal))) throw new Error("terminal must be an object");
|
|
355
361
|
terminalTemplate(file.terminal?.open);
|
|
356
362
|
if (file.pet !== undefined) validatePetConfig(file.pet, "config.pet");
|
|
@@ -358,7 +364,7 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
|
|
|
358
364
|
delete file.permission_default_sources; // provenance is loader-owned, never a file claim
|
|
359
365
|
const why = stripUntrusted(file, DEFAULT_CONFIG, cwd, name);
|
|
360
366
|
if (why) ignored.push(why);
|
|
361
|
-
for (const [peer, mode] of Object.entries(permissionDefaults(file.permission_modes, instances))) appliedPermissionSources[peer] = { mode, source: `.agenthub/${name}` };
|
|
367
|
+
for (const [peer, mode] of Object.entries(permissionDefaults(file.permission_modes, instances, adapterCatalog))) appliedPermissionSources[peer] = { mode, source: `.agenthub/${name}` };
|
|
362
368
|
return [file];
|
|
363
369
|
});
|
|
364
370
|
if (!files.length) return routingRetired.length ? { ...DEFAULT_CONFIG, retired: [...routingRetired] } : DEFAULT_CONFIG;
|
|
@@ -388,7 +394,8 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
|
|
|
388
394
|
const mlx = { ...(file.mlx?.provider === "legacy" ? { enabled: true, provider: "legacy" as const, maxInputTokens: 16_000, maxTokens: 2048 } : DEFAULT_CONFIG.mlx), ...file.mlx };
|
|
389
395
|
const conductorPolicy = loadConductorPolicy(cwd, captured);
|
|
390
396
|
retired.push(...conductorPolicy.notices ?? []);
|
|
391
|
-
const
|
|
397
|
+
const configuredRoles = Object.fromEntries(adapterCatalog.definitions.filter(d => !definitionOf(d.kind)).map(d => [d.kind, [...d.roles]]));
|
|
398
|
+
const extraRoles = Object.fromEntries(Object.keys(instances ?? {}).map(id => [id, effectiveInstanceRoles(id, instances, conductorPolicy.roles, adapterCatalog)]));
|
|
392
399
|
const instancePermissions: Record<string, PermissionMode> = {};
|
|
393
400
|
const instanceModes: PeerStartConfig = {};
|
|
394
401
|
for (const [id, declaration] of Object.entries(instances ?? {})) {
|
|
@@ -404,17 +411,18 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
|
|
|
404
411
|
return {
|
|
405
412
|
...DEFAULT_CONFIG,
|
|
406
413
|
...file,
|
|
414
|
+
...(instanceConfig.adapters ? { adapters: instanceConfig.adapters } : {}),
|
|
407
415
|
...(instances ? { instances } : {}),
|
|
408
416
|
...(file.text_projection !== undefined ? { text_projection: projectionConfig(file.text_projection) } : {}),
|
|
409
417
|
...(file.pii !== undefined ? { pii: { paths: policyPaths(file.pii.paths, "pii.paths") } } : {}),
|
|
410
418
|
...(file.data !== undefined ? { data: { readonly: policyPaths(file.data.readonly, "data.readonly") } } : {}),
|
|
411
|
-
permission_modes: { ...instancePermissions, ...permissionDefaults(file.permission_modes, instances) },
|
|
419
|
+
permission_modes: { ...instancePermissions, ...permissionDefaults(file.permission_modes, instances, adapterCatalog) },
|
|
412
420
|
protect_instruction_files: instructionProtection(file.protect_instruction_files),
|
|
413
421
|
permission_default_sources: appliedPermissionSources,
|
|
414
422
|
memory: { ...DEFAULT_CONFIG.memory, native: true, ...file.memory, ...(file.memory?.short_ttl_days !== undefined ? { short_ttl_days: ttlDays(file.memory.short_ttl_days) } : {}),
|
|
415
423
|
...(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)
|
|
416
|
-
roster: peerRosterConfig(file.roster, instances),
|
|
417
|
-
roles: { ...conductorPolicy.roles, ...extraRoles },
|
|
424
|
+
roster: peerRosterConfig(file.roster, instances, adapterCatalog),
|
|
425
|
+
roles: { ...configuredRoles, ...conductorPolicy.roles, ...extraRoles },
|
|
418
426
|
planner: conductorPolicy.planner,
|
|
419
427
|
conductor: conductorPolicy.conductor,
|
|
420
428
|
budget: { ...DEFAULT_CONFIG.budget, wait_max_min: 30, ...file.budget }, // on with any project config (issue #36)
|
|
@@ -432,7 +440,7 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
|
|
|
432
440
|
snapshots: { ...DEFAULT_CONFIG.snapshots, enabled: true, ...file.snapshots },
|
|
433
441
|
research: { enabled: file.research?.enabled === true }, // opt-in: anything but true is off
|
|
434
442
|
bench: { enabled: file.bench?.enabled === true }, // opt-in: a run resets this project's work tree
|
|
435
|
-
peers: { ...instanceModes, ...peerStartConfig(file.peers, instances) },
|
|
443
|
+
peers: { ...instanceModes, ...peerStartConfig(file.peers, instances, adapterCatalog) },
|
|
436
444
|
terminal: { open: terminalTemplate(file.terminal?.open) },
|
|
437
445
|
limits: { ...PROJECT_LIMITS, ...file.limits }, // on with any project config (issue #38)
|
|
438
446
|
review: { ...DEFAULT_CONFIG.review, ...file.review },
|
|
@@ -465,6 +473,10 @@ function mlxPinProblem(routing: Routing, project: boolean): string | undefined {
|
|
|
465
473
|
}
|
|
466
474
|
|
|
467
475
|
export interface DaemonOptions {
|
|
476
|
+
/** Host-only snapshot/probe seams; never read from project config or tool arguments. */
|
|
477
|
+
snapshot?: typeof snapshot;
|
|
478
|
+
snapshotBurstMs?: number;
|
|
479
|
+
checkoutBranch?: typeof mainCheckoutBranch;
|
|
468
480
|
/** Host-only admission transport; never selected by project config or tools. */
|
|
469
481
|
releaseHostAdmission?: (cwd: string) => Promise<ReleaseHostAdmission>;
|
|
470
482
|
projectId?: string;
|
|
@@ -512,135 +524,6 @@ interface Client {
|
|
|
512
524
|
}
|
|
513
525
|
type Sock = ServerWebSocket<Client>;
|
|
514
526
|
|
|
515
|
-
/** A peer that lives in another process and attaches over the control WS (the Claude channel plugin). */
|
|
516
|
-
class WsPeer extends BasePeer {
|
|
517
|
-
private generation = crypto.randomUUID();
|
|
518
|
-
// Checkpoints become stale at hello/claim, before asynchronous recall finishes and attach changes delivery generation.
|
|
519
|
-
private claimGeneration = crypto.randomUUID();
|
|
520
|
-
get sessionGeneration(): string { return this.claimGeneration; }
|
|
521
|
-
terminalOwner(launchId: string): { generation: string } | undefined {
|
|
522
|
-
return this.sock?.readyState === WebSocket.OPEN && this.sock.data.launchId === launchId && this.state !== "offline" && !this.claiming
|
|
523
|
-
? { generation: this.claimGeneration } : undefined;
|
|
524
|
-
}
|
|
525
|
-
private delivered = new Set<string>();
|
|
526
|
-
private sock: Sock | undefined;
|
|
527
|
-
private claimed: Sock | undefined;
|
|
528
|
-
private nativeSession: string | undefined;
|
|
529
|
-
private nativeActive = false;
|
|
530
|
-
/** The attached session said it cannot show channel pushes (issue #205): the bus keeps its queue for `inbox`. */
|
|
531
|
-
pullOnly = false;
|
|
532
|
-
steer: PeerAdapter["steer"] = undefined;
|
|
533
|
-
lastInboxAt: number | undefined;
|
|
534
|
-
private claimedPullOnly = false;
|
|
535
|
-
private readonly attachmentWaiters: { generation: string; resolve: (attached: boolean) => void }[] = [];
|
|
536
|
-
private settleAttachmentWaiters(attached: boolean): void {
|
|
537
|
-
for (const waiter of this.attachmentWaiters.splice(0)) waiter.resolve(attached && waiter.generation === this.claimGeneration);
|
|
538
|
-
}
|
|
539
|
-
/** Bounded native-hook observations wait for this exact claim, never a later socket. */
|
|
540
|
-
waitForAttach(generation: string): Promise<boolean> {
|
|
541
|
-
if (generation !== this.claimGeneration || !this.claiming || this.attachmentWaiters.length >= 64) return Promise.resolve(false);
|
|
542
|
-
return new Promise(resolve => { this.attachmentWaiters.push({ generation, resolve }); });
|
|
543
|
-
}
|
|
544
|
-
/** Called at hello, before the async preface: the newest hello wins even if an older one's recall finishes last. */
|
|
545
|
-
claim(sock: Sock, pullOnly = false): void {
|
|
546
|
-
this.settleAttachmentWaiters(false);
|
|
547
|
-
this.claimGeneration = crypto.randomUUID();
|
|
548
|
-
this.claimed = sock;
|
|
549
|
-
this.claimedPullOnly = pullOnly;
|
|
550
|
-
}
|
|
551
|
-
/** A session that shows pushes holds the peer or is arriving: a session without them must not take it (issue #205). */
|
|
552
|
-
get channelHeld(): boolean {
|
|
553
|
-
return (!!this.sock && !this.pullOnly) || (this.claiming && !this.claimedPullOnly);
|
|
554
|
-
}
|
|
555
|
-
/** Status-file observations are advisory; a replaced client cannot evict a live socket or pending claim. */
|
|
556
|
-
standbyHeld(pullOnly: boolean): boolean {
|
|
557
|
-
return this.claiming || (!!this.sock && this.sock.readyState === WebSocket.OPEN && (pullOnly || !this.pullOnly));
|
|
558
|
-
}
|
|
559
|
-
/** A hello that has not finished its preface yet. The peer reads as offline until then, and a session standing by
|
|
560
|
-
* for the id must not take it from a claimant that is still arriving. */
|
|
561
|
-
get claiming(): boolean {
|
|
562
|
-
return !!this.claimed && this.claimed !== this.sock && this.claimed.readyState === WebSocket.OPEN;
|
|
563
|
-
}
|
|
564
|
-
attach(sock: Sock): void {
|
|
565
|
-
if (sock !== this.claimed) return void sock.close(4000, "replaced"); // a newer session said hello meanwhile
|
|
566
|
-
if (this.sock) this.setState("offline"); // unresolved work belongs to the previous connection
|
|
567
|
-
this.sock?.close(4000, "replaced");
|
|
568
|
-
this.sock = sock;
|
|
569
|
-
this.generation = crypto.randomUUID();
|
|
570
|
-
this.delivered.clear();
|
|
571
|
-
this.nativeSession = undefined;
|
|
572
|
-
this.nativeActive = false;
|
|
573
|
-
this.pullOnly = this.claimedPullOnly; // before idle: the idle transition drains
|
|
574
|
-
// Native TUI readback in #493 proves important channel input can be consumed before the turn ends.
|
|
575
|
-
// Pull-only transports expose no steer capability; the bus keeps their important work queued.
|
|
576
|
-
this.steer = this.pullOnly ? undefined : async (envs, deliveryId) => {
|
|
577
|
-
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");
|
|
578
|
-
await this.deliver(envs, deliveryId);
|
|
579
|
-
};
|
|
580
|
-
this.lastInboxAt = undefined;
|
|
581
|
-
this.setState("idle");
|
|
582
|
-
this.settleAttachmentWaiters(true);
|
|
583
|
-
}
|
|
584
|
-
detach(sock: Sock): void {
|
|
585
|
-
if (this.claimed === sock) { this.claimed = undefined; this.settleAttachmentWaiters(false); }
|
|
586
|
-
if (this.sock !== sock) return;
|
|
587
|
-
this.sock = undefined;
|
|
588
|
-
this.steer = undefined;
|
|
589
|
-
this.nativeActive = false;
|
|
590
|
-
this.pullOnly = false;
|
|
591
|
-
this.setState("offline");
|
|
592
|
-
}
|
|
593
|
-
detachForRoster(): void {
|
|
594
|
-
this.settleAttachmentWaiters(false);
|
|
595
|
-
this.claimed?.close(4000, "peer is off roster; retry when a person adds it");
|
|
596
|
-
this.sock?.close(4000, "peer is off roster; retry when a person adds it");
|
|
597
|
-
this.claimed = undefined;
|
|
598
|
-
this.sock = undefined;
|
|
599
|
-
this.steer = undefined;
|
|
600
|
-
this.nativeActive = false;
|
|
601
|
-
this.pullOnly = false;
|
|
602
|
-
this.setState("offline");
|
|
603
|
-
}
|
|
604
|
-
async deliver(envs: Envelope[], deliveryId?: string): Promise<void> {
|
|
605
|
-
if (!this.sock) throw new Error(`${this.id} is offline`);
|
|
606
|
-
if (deliveryId) this.delivered.add(deliveryId);
|
|
607
|
-
this.sock.send(JSON.stringify({ t: "deliver", envs, generation: this.generation, ...(deliveryId ? { deliveryId } : {}) }));
|
|
608
|
-
}
|
|
609
|
-
/** Advisory nudge (issue #398): important work waits in the pull queue. Not a delivery: nothing to settle or journal. */
|
|
610
|
-
notifyQueued(counts: { queued: number; important: number }): void {
|
|
611
|
-
if (!this.sock || !this.pullOnly) return;
|
|
612
|
-
this.sock.send(JSON.stringify({ t: "queued", queued: counts.queued, important: counts.important }));
|
|
613
|
-
}
|
|
614
|
-
owns(sock: Sock): boolean { return this.sock === sock; }
|
|
615
|
-
nativeStarted(session: string): void {
|
|
616
|
-
if (!this.sock) return;
|
|
617
|
-
this.nativeSession = session;
|
|
618
|
-
this.nativeActive = true;
|
|
619
|
-
this.setState("busy");
|
|
620
|
-
}
|
|
621
|
-
nativeStopped(session: string): boolean {
|
|
622
|
-
if (!this.sock || (this.nativeSession !== undefined && this.nativeSession !== session)) return false;
|
|
623
|
-
this.nativeSession = session;
|
|
624
|
-
this.nativeActive = false;
|
|
625
|
-
this.setState("idle");
|
|
626
|
-
return true;
|
|
627
|
-
}
|
|
628
|
-
protected override onWatchdog(): void {
|
|
629
|
-
// A silent native model/approval wait does not prove Stop. Retain the observed active turn until Stop/detach.
|
|
630
|
-
if (this.nativeActive) { this.touch(); return; }
|
|
631
|
-
super.onWatchdog();
|
|
632
|
-
}
|
|
633
|
-
ownsDelivery(sock: Sock, generation: unknown, id: string): boolean { return this.owns(sock) && generation === this.generation && this.delivered.has(id); }
|
|
634
|
-
async start(): Promise<void> {}
|
|
635
|
-
async stop(): Promise<void> {
|
|
636
|
-
this.settleAttachmentWaiters(false);
|
|
637
|
-
this.sock?.close();
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
recoveryMetadata(): Record<string, unknown> {
|
|
641
|
-
return { launch: definitionOf(this.id)?.attach?.recoveryLaunch?.(this.id) ?? { kind: "claude-channel", peer: this.id } };
|
|
642
|
-
}
|
|
643
|
-
}
|
|
644
527
|
|
|
645
528
|
export async function startDaemon(opts: DaemonOptions) {
|
|
646
529
|
const projectId = opts.projectId ?? randomUUID();
|
|
@@ -653,21 +536,25 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
653
536
|
const lifecycleConfig = loadSdlc(opts.cwd, VERSION);
|
|
654
537
|
// Preserve embedding-owned policy references, but never publish reloads into shared defaults.
|
|
655
538
|
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;
|
|
656
|
-
const
|
|
539
|
+
const adapterCatalog = createProjectAdapterCatalog(loadedConfig.adapters);
|
|
540
|
+
const configuredIds = adapterCatalog.definitions.filter(d => !builtinDefinitionOf(d.kind)).map(d => d.kind);
|
|
541
|
+
const declarations = validateInstances(loadedConfig.instances, adapterCatalog);
|
|
657
542
|
const extraIds = Object.keys(declarations);
|
|
658
543
|
// #450 I3: channel extras (Claude) attach from the person's terminal; the hub never starts them and their hello is admitted.
|
|
659
|
-
const channelExtras = extraIds.filter(id =>
|
|
660
|
-
//
|
|
661
|
-
|
|
544
|
+
const channelExtras = extraIds.filter(id => adapterCatalog.definitionOf(id, declarations)?.attach?.externalHello === true);
|
|
545
|
+
// #624: every Claude peer whose own state files the daemon reads, keyed on the Claude transport, never on channel-plugin
|
|
546
|
+
// (agy is a channel-plugin peer with no Claude state): the builtin, then its declared instances.
|
|
547
|
+
const channelPeers = [...ADAPTERS.filter(definition => definition.transport === "claude-channel").map(definition => definition.kind),
|
|
548
|
+
...channelExtras.filter(id => adapterCatalog.definitionOf(id, declarations)?.transport === "claude-channel")];
|
|
662
549
|
const executionRoots = new Map<string, string>();
|
|
663
550
|
const executionOwners = new Map<string, { owner: PeerAdapter; cwd: string }>();
|
|
664
551
|
const reviewerUnavailable = new Map<string, string>();
|
|
665
|
-
const roles = { ...loadedConfig.roles };
|
|
552
|
+
const roles = { ...Object.fromEntries(adapterCatalog.definitions.filter(d => configuredIds.includes(d.kind)).map(d => [d.kind, [...d.roles]])), ...loadedConfig.roles };
|
|
666
553
|
const permissionModesFromInstances: Record<string, PermissionMode> = {};
|
|
667
554
|
const startModesFromInstances: PeerStartConfig = {};
|
|
668
555
|
const instancePermissionSources: NonNullable<HubConfig["permission_default_sources"]> = {};
|
|
669
556
|
for (const [id, declaration] of Object.entries(declarations)) {
|
|
670
|
-
roles[id] = effectiveInstanceRoles(id, declarations, loadedConfig.roles);
|
|
557
|
+
roles[id] = effectiveInstanceRoles(id, declarations, loadedConfig.roles, adapterCatalog);
|
|
671
558
|
if (!channelExtras.includes(id)) startModesFromInstances[id] = { start_mode: "headless" };
|
|
672
559
|
if (declaration.permission_mode !== undefined) {
|
|
673
560
|
permissionModesFromInstances[id] = declaration.permission_mode;
|
|
@@ -675,19 +562,19 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
675
562
|
}
|
|
676
563
|
}
|
|
677
564
|
const config: HubConfig = { ...ownedConfig, ...(extraIds.length ? { instances: declarations } : {}),
|
|
678
|
-
roster: peerRosterConfig(loadedConfig.roster, declarations),
|
|
679
|
-
permission_modes: { ...permissionModesFromInstances, ...permissionDefaults(loadedConfig.permission_modes, declarations) },
|
|
680
|
-
peers: { ...startModesFromInstances, ...peerStartConfig(loadedConfig.peers, declarations) },
|
|
565
|
+
roster: peerRosterConfig(loadedConfig.roster, declarations, adapterCatalog),
|
|
566
|
+
permission_modes: { ...permissionModesFromInstances, ...permissionDefaults(loadedConfig.permission_modes, declarations, adapterCatalog) },
|
|
567
|
+
peers: { ...startModesFromInstances, ...peerStartConfig(loadedConfig.peers, declarations, adapterCatalog) },
|
|
681
568
|
...(extraIds.length ? { permission_default_sources: { ...instancePermissionSources, ...loadedConfig.permission_default_sources } } : {}),
|
|
682
569
|
roles, conductor: { ...DEFAULT_CONFIG.conductor, ...loadedConfig.conductor } };
|
|
683
570
|
// The resolver and catalogs are bound to this daemon, never published into the process-global builtin registry.
|
|
684
|
-
const definitionOf = (peer: string) =>
|
|
685
|
-
const KNOWN_PEERS: readonly string[] = [...BASE_KNOWN_PEERS, ...extraIds];
|
|
686
|
-
const SUPPORTED_PEERS: readonly string[] = [...BASE_SUPPORTED_PEERS, ...extraIds];
|
|
687
|
-
const STARTABLE_PEERS: readonly string[] = [...BASE_STARTABLE_PEERS, ...extraIds.filter(id => !channelExtras.includes(id))];
|
|
688
|
-
const isSupportedPeer = (peer: unknown): peer is string =>
|
|
571
|
+
const definitionOf = (peer: string) => adapterCatalog.definitionOf(peer, declarations);
|
|
572
|
+
const KNOWN_PEERS: readonly string[] = [...BASE_KNOWN_PEERS, ...configuredIds, ...extraIds];
|
|
573
|
+
const SUPPORTED_PEERS: readonly string[] = [...BASE_SUPPORTED_PEERS, ...configuredIds, ...extraIds];
|
|
574
|
+
const STARTABLE_PEERS: readonly string[] = [...BASE_STARTABLE_PEERS, ...configuredIds, ...extraIds.filter(id => !channelExtras.includes(id))];
|
|
575
|
+
const isSupportedPeer = (peer: unknown): peer is string => adapterCatalog.isSupportedPeer(peer, declarations);
|
|
689
576
|
const isStartablePeer = (peer: unknown): peer is string => typeof peer === "string" && !channelExtras.includes(peer) && definitionOf(peer)?.start.conductorStartable === true;
|
|
690
|
-
const RESERVED_IDS = new Set([...BASE_RESERVED_IDS, ...extraIds.filter(id => !channelExtras.includes(id))]);
|
|
577
|
+
const RESERVED_IDS = new Set([...BASE_RESERVED_IDS, ...configuredIds, ...extraIds.filter(id => !channelExtras.includes(id))]);
|
|
691
578
|
// #223 R2: a declared instance whose effective roles are exactly reviewer gets review requests and its own task notices only.
|
|
692
579
|
const reviewOnlyPeer = (peer: string): boolean => Object.hasOwn(declarations, peer) && config.roles[peer]?.length === 1 && config.roles[peer]![0] === "reviewer";
|
|
693
580
|
/** #520: a task under review shows its review brief to whoever may read it (owner, reviewer, conductor). */
|
|
@@ -696,8 +583,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
696
583
|
const reviewerView = (peer: string, view: Record<string, unknown>): Record<string, unknown> => !reviewOnlyPeer(peer) || !Array.isArray(view.history) ? view
|
|
697
584
|
: { ...view, history: (view.history as { event?: unknown; note?: unknown }[]).map(({ note, ...h }) => h.event === "approved" || h.event === "changes_requested" ? { ...h, note } : h) };
|
|
698
585
|
const reviewOnlyItem = (peer: string, env: Envelope): boolean => env.from === USER || env.from === HUB && (env.kind === "review" || (env.kind === "task" && env.to?.includes(peer) === true));
|
|
699
|
-
const
|
|
700
|
-
const
|
|
586
|
+
const readOnlyPeer = (peer: string): boolean => declarations[peer]?.readOnly === true;
|
|
587
|
+
const SETTINGS = projectSettings(declarations, adapterCatalog);
|
|
588
|
+
const settingDef = (key: unknown) => instanceSettingDef(key, declarations, adapterCatalog);
|
|
701
589
|
const accountOf = (peer: string) => definitionOf(peer)?.budget?.account ?? peer;
|
|
702
590
|
const accountMembers = (account: string) => SUPPORTED_PEERS.filter(peer => accountOf(peer) === account);
|
|
703
591
|
let rosterChanging: string | undefined;
|
|
@@ -705,7 +593,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
705
593
|
let kimiAdmissionId: string | undefined;
|
|
706
594
|
let kimiAdmissionExpiry: ReturnType<typeof setTimeout> | undefined;
|
|
707
595
|
const clearKimiAdmission = () => { clearTimeout(kimiAdmissionExpiry); kimiAdmissionExpiry = undefined; kimiSessionMode = undefined; kimiAdmissionId = undefined; };
|
|
708
|
-
const onRoster = (peer: string) => rosterAllows(config.roster, peer, config.pi.enabled, declarations);
|
|
596
|
+
const onRoster = (peer: string) => rosterAllows(config.roster, peer, config.pi.enabled, declarations, adapterCatalog);
|
|
709
597
|
// pi.enabled historically gates starts, not retained queues or tools sockets. Only an explicit roster.pi changes those.
|
|
710
598
|
const membershipAllowed = (peer: string) => definitionOf(peer)?.enabledSwitchStartOnly && config.roster[peer] === undefined ? true : onRoster(peer);
|
|
711
599
|
conductorPeer(config.roles); // reject ambiguous leadership before opening a control listener
|
|
@@ -716,7 +604,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
716
604
|
// The state dir can vanish under a running hub (issue #56): a log line must never take a handler down with it.
|
|
717
605
|
const log = (line: string) => { try { appendFileSync(logFile, `${new Date().toISOString()} ${line}\n`); } catch { /* the state dir is gone; the watchdog is stopping the hub */ } };
|
|
718
606
|
let runningRouting = currentRouting(opts.cwd, log);
|
|
719
|
-
let acceptedSources = parseConfigSources(configSourceTexts(opts.cwd), runningRouting);
|
|
607
|
+
let acceptedSources = parseConfigSources(configSourceTexts(opts.cwd), runningRouting, opts.cwd);
|
|
720
608
|
const startupSources = structuredClone(acceptedSources.trustLayers);
|
|
721
609
|
const embeddedConfig = structuredClone(config);
|
|
722
610
|
let desiredConfig = structuredClone(config);
|
|
@@ -885,7 +773,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
885
773
|
sock.data.tail?.();
|
|
886
774
|
sock.data.watch?.();
|
|
887
775
|
const peer = sock.data.peer ? bus.peers.get(sock.data.peer) : undefined;
|
|
888
|
-
if (peer
|
|
776
|
+
if (isWsPeer(peer)) {
|
|
889
777
|
peer.detach(sock);
|
|
890
778
|
if (!!sock.data.peer && !!definitionOf(sock.data.peer)?.attach?.admission && peer.state === "offline" && !peer.claiming && sock.data.kimiAdmission === kimiAdmissionId) clearKimiAdmission();
|
|
891
779
|
}
|
|
@@ -921,7 +809,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
921
809
|
// a record left now would make the next ordinary start look like one.
|
|
922
810
|
if (recoveryOperation || restartFilePresent) try { removeSessions(opts.stateDir); } catch { /* nothing to remove */ }
|
|
923
811
|
const autoResume = () => config.recovery.auto_resume_after_crash === true; // a string "false" is not a yes
|
|
924
|
-
const
|
|
812
|
+
const autoStartAdapter = ADAPTERS.find(adapter => adapter.start.autoStart !== undefined);
|
|
813
|
+
const continuityKind = ADAPTERS.find(adapter => adapter.start.daemonPort === "pi-rpc")!.kind;
|
|
814
|
+
const piAutoStart = () => !!autoStartAdapter && onRoster(autoStartAdapter.kind) && config.pi.auto_start;
|
|
925
815
|
const startedAt = Date.now();
|
|
926
816
|
/** What crash recovery did or asks the user to do, for `ahub status`. */
|
|
927
817
|
const crashReport: string[] = [];
|
|
@@ -954,7 +844,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
954
844
|
// silence without facts would leave overlapping owners blind. Facts and silence ask the same question, so both switch
|
|
955
845
|
// together. Cached until the board changes: it is asked per recipient of every message.
|
|
956
846
|
let turnFreeNow: boolean | undefined;
|
|
957
|
-
const turns = new Map<PeerId, { capture?: boolean; id: string; start: number; tokens: number; tree?: string; snapshotMs?: number; private?: boolean; deliveryTask?: number; attribution: TaskAttribution; preparedRoles: RoleIdentity[] | null }>();
|
|
847
|
+
const turns = new Map<PeerId, { capture?: boolean; id: string; start: number; tokens: number; tree?: string; head?: string | null; commit?: string | null; branches?: string; writeScope?: boolean; reflog?: HeadMoveCursor; snapshotMs?: number; private?: boolean; deliveryTask?: number; attribution: TaskAttribution; preparedRoles: RoleIdentity[] | null; writeTasks: { id: number; acceptance: number }[]; taskEffect?: boolean; effectsPending?: number }>();
|
|
848
|
+
const ownerWriteStarts = new Map<number, { owner: string; acceptance: number; at: number; root: string; tree: string }>();
|
|
958
849
|
const turnFree = (): boolean => (config.coordination === "turn-free" || tasks.cohorts.list().some(cohort => cohort.silent)) && ![...turns.values()].some(turn => turn.private) && (turnFreeNow ??= !board.list().some((t) => !isClosedTaskState(t.state) && tasks.isPii(t)));
|
|
959
850
|
/**
|
|
960
851
|
* Why `to` does not get an agent's message (issue #107): sender and recipient are in one silent cohort, and the sender
|
|
@@ -1005,7 +896,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1005
896
|
if (staleOff()) log("experiment: stale notices are delivered as before 0.12.4 (the issue #106 ablation)");
|
|
1006
897
|
const requestedPeerStops = new Set<string>();
|
|
1007
898
|
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,
|
|
1008
|
-
onImportantQueued: (peer, counts) => { const p = bus.peers.get(peer); if (p
|
|
899
|
+
onImportantQueued: (peer, counts) => { const p = bus.peers.get(peer); if (isWsPeer(p)) p.notifyQueued(counts); } });
|
|
1009
900
|
startupCleanup.push(() => bus.closeJournal());
|
|
1010
901
|
const manualPaused = new Set<PeerId>(bus.manualPausedPeers()); // recovery never lifts an operator's pause
|
|
1011
902
|
const conductorHolds = new ConductorHolds(join(opts.stateDir, "hub.db"));
|
|
@@ -1031,11 +922,18 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1031
922
|
bus.setRecoveryHold(false);
|
|
1032
923
|
}
|
|
1033
924
|
const recoveryPeerAllowed = (id: PeerId) => !recoveryActive() || !recoveryPeerSnapshot || recoveryPeerSnapshot.some((peer) => peer.id === id);
|
|
1034
|
-
|
|
925
|
+
// One log line per failure streak: the 1 s retry itself is unchanged, only its repeated line is not.
|
|
926
|
+
let recoveryLeaseRetryLogged = false;
|
|
927
|
+
const armRecoveryLease = (delayMs = 10 * 60_000, retrying = false): void => {
|
|
928
|
+
if (!retrying) recoveryLeaseRetryLogged = false;
|
|
1035
929
|
clearTimeout(recoveryLeaseTimer);
|
|
1036
930
|
recoveryLeaseTimer = setTimeout(() => {
|
|
1037
|
-
if (!recoveryOperationId || recoveryCommitted || (recoveryPhase !== "preparing" && recoveryPhase !== "prepared")) return;
|
|
1038
|
-
try { bus.setRecoveryHold(false); } catch
|
|
931
|
+
if (!recoveryOperationId || recoveryCommitted || (recoveryPhase !== "preparing" && recoveryPhase !== "prepared")) { recoveryLeaseRetryLogged = false; return; }
|
|
932
|
+
try { bus.setRecoveryHold(false); } catch {
|
|
933
|
+
if (!recoveryLeaseRetryLogged) { recoveryLeaseRetryLogged = true; log("recovery lease release deferred; delivery storage must be restored before abort (retrying every second)"); }
|
|
934
|
+
armRecoveryLease(1000, true); return;
|
|
935
|
+
}
|
|
936
|
+
recoveryLeaseRetryLogged = false;
|
|
1039
937
|
log(`recovery lease expired for ${recoveryOperationId}; hold aborted`);
|
|
1040
938
|
recoveryOperationId = undefined;
|
|
1041
939
|
recoveryPhase = undefined;
|
|
@@ -1295,8 +1193,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1295
1193
|
if (!peer || peer.state === "offline") return undefined;
|
|
1296
1194
|
let token = checkConnections.get(peer);
|
|
1297
1195
|
if (!token) { token = randomUUID(); checkConnections.set(peer, token); }
|
|
1298
|
-
const native = peer
|
|
1299
|
-
if (peer
|
|
1196
|
+
const native = isWsPeer(peer) || isCodexPeer(peer) || isPiPeer(peer) ? peer.sessionGeneration : peer.recoveryMetadata?.().sessionId;
|
|
1197
|
+
if (isPiPeer(peer) && native === undefined) return undefined;
|
|
1300
1198
|
return `${instanceId}:${token}:${String(native ?? "")}`;
|
|
1301
1199
|
};
|
|
1302
1200
|
const currentCheck = (receipt: { task: number; owner: string; updated: number | string; generation: number | string; revision?: number }) => {
|
|
@@ -1396,7 +1294,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1396
1294
|
// Bind both legacy print transports and task-MCP calls to the child's original review request.
|
|
1397
1295
|
const runningReviewAuthority = (by: PeerId, task: number): (() => string | undefined) | undefined => {
|
|
1398
1296
|
if (!definitionOf(by)?.reviewer) return undefined;
|
|
1399
|
-
const attached=bus.peers.get(by), peer=attached
|
|
1297
|
+
const attached=bus.peers.get(by), peer=isReviewerPrintPeer(attached)?attached:undefined, token=peer?.reviewToken(task), binding=peer?.reviewBinding(task);
|
|
1400
1298
|
return () => token!==undefined && binding!==undefined && bus.peers.get(by)===peer && peer?.reviewToken(task)===token && tasks.reviewBindingCurrent(by,task,binding)
|
|
1401
1299
|
? undefined : `no review of task #${task} is running for ${by} at its bound head and revision`;
|
|
1402
1300
|
};
|
|
@@ -1410,6 +1308,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1410
1308
|
return [peer, codes];
|
|
1411
1309
|
})),
|
|
1412
1310
|
instances: () => declarations,
|
|
1311
|
+
adapterCatalog,
|
|
1413
1312
|
reviewOnly: reviewOnlyPeer,
|
|
1414
1313
|
// #223: a headless reviewer's verdict holds only while the same child it started with still runs for that task.
|
|
1415
1314
|
reviewAuthority: runningReviewAuthority,
|
|
@@ -1430,6 +1329,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1430
1329
|
catalog,
|
|
1431
1330
|
sessionRoles: peer => bus.peers.has(peer) && bus.stateOf(peer) !== "offline" ? preparedSessionRoles.get(peer) ?? [] : [],
|
|
1432
1331
|
projectedReview: task => reviewProjection(task),
|
|
1332
|
+
ownerWrites: (task, since) => ownerWriteEvidence(task, since),
|
|
1333
|
+
ownerAccepted: task => captureOwnerWriteStart(task),
|
|
1433
1334
|
reviewBrief: task => {
|
|
1434
1335
|
const root = executionRoot(task.owner ?? "");
|
|
1435
1336
|
return buildReviewBrief(root, task.refs.commit, { privatePath: path => isDenied(path, config.local.deny) || matchesPolicy(root, path, config.pii?.paths ?? [], true), nameable: tasks.nameable });
|
|
@@ -1487,7 +1388,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1487
1388
|
sweepHeld: () => stopping || recoveryActive(),
|
|
1488
1389
|
cancellationHeld: () => stopping || recoveryActive(),
|
|
1489
1390
|
offerRead: (peer, envelopeId) => journal.offerRead(peer, envelopeId),
|
|
1490
|
-
offerPullOnly: (id) => { const peer = bus.peers.get(id); return peer
|
|
1391
|
+
offerPullOnly: (id) => { const peer = bus.peers.get(id); return isWsPeer(peer) && peer.pullOnly; },
|
|
1491
1392
|
stallNotice: stall => { supervision.taskStalled(stall); },
|
|
1492
1393
|
roles: config.roles,
|
|
1493
1394
|
turnFree,
|
|
@@ -1551,7 +1452,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1551
1452
|
const worktreeFacts = new Map<string, Facts>();
|
|
1552
1453
|
const worktreeProjections = new Map<string, TextProjections>();
|
|
1553
1454
|
const executionRoot = (peer: string) => executionRoots.get(peer) ?? opts.cwd;
|
|
1554
|
-
const repoFor = (peer: string) => executionRoot(peer) === opts.cwd ? factsRepo : repoOf(executionRoot(peer));
|
|
1455
|
+
const repoFor = (peer: string, budget?: SnapshotBudget) => executionRoot(peer) === opts.cwd ? factsRepo : repoOf(executionRoot(peer), budget);
|
|
1555
1456
|
const projectionsFor = (peer: string): TextProjections => {
|
|
1556
1457
|
const root = executionRoot(peer);
|
|
1557
1458
|
if (root === opts.cwd) return projections;
|
|
@@ -1687,7 +1588,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1687
1588
|
} catch { /* no transcript yet: no readback */ }
|
|
1688
1589
|
};
|
|
1689
1590
|
/** Claude's transcript for this session: the one the status line reported, or the hook's own, inside Claude's projects. */
|
|
1690
|
-
const claudeTranscript = (sessionId: string | undefined, fromHook: unknown, peer
|
|
1591
|
+
const claudeTranscript = (sessionId: string | undefined, fromHook: unknown, peer: string): string | undefined => {
|
|
1592
|
+
if (definitionOf(peer)?.transport !== "claude-channel") return undefined; // #624: no Claude transcript for another kind
|
|
1691
1593
|
const reported = claudeSession(peer);
|
|
1692
1594
|
if (reported.transcriptPath && (!sessionId || reported.sessionId === sessionId)) return reported.transcriptPath;
|
|
1693
1595
|
if (!sessionId || typeof fromHook !== "string" || !isAbsolute(fromHook) || basename(fromHook) !== `${sessionId}.jsonl`) return undefined;
|
|
@@ -1732,6 +1634,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1732
1634
|
scopes = new Map();
|
|
1733
1635
|
progress.prune();
|
|
1734
1636
|
factsOn(); // a PII task opening or closing switches tracking at once, not at the next boundary (issue #108)
|
|
1637
|
+
if (t.state !== "in_progress" || ownerWriteStarts.get(t.id)?.owner !== t.owner) ownerWriteStarts.delete(t.id);
|
|
1735
1638
|
if ((h.event === "integration requested" || h.event === "integration unresolved") && /has not stopped/.test(h.note ?? "")) {
|
|
1736
1639
|
for (const m of tasks.cohorts.of(t.id)?.members.values() ?? []) if (m.task !== t.id) log(`turn-free: task #${t.id} waits on ${stopEvidence(m.owner)}`);
|
|
1737
1640
|
}
|
|
@@ -1755,10 +1658,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1755
1658
|
const checkpointWaits = new Map<PeerId, { reason: "quota" | "context"; requestId: string; session: string | undefined; valid: () => boolean; done: (summary: string | undefined) => void }>();
|
|
1756
1659
|
const contextSession = (peer: PeerId): string | undefined => {
|
|
1757
1660
|
const p = bus.peers.get(peer);
|
|
1758
|
-
if (p
|
|
1759
|
-
if (definitionOf(peer)?.
|
|
1760
|
-
if (p
|
|
1761
|
-
if (p
|
|
1661
|
+
if (isWsPeer(p) && p.claiming) return undefined;
|
|
1662
|
+
if (definitionOf(peer)?.transport === "claude-channel") { const c = claudeSession(peer); return c.sessionId ? `${c.sessionId}:${c.launchId ?? ""}` : undefined; }
|
|
1663
|
+
if (isCodexPeer(p)) return p.thread || undefined;
|
|
1664
|
+
if (isAcpPeer(p) || isPiPeer(p)) { const id = p.recoveryMetadata().sessionId; return typeof id === "string" ? id : undefined; }
|
|
1762
1665
|
return undefined;
|
|
1763
1666
|
};
|
|
1764
1667
|
const activityRings: Record<string, ActivityRing> = Object.fromEntries(SUPPORTED_PEERS.filter(peer => definitionOf(peer)?.capabilities?.watch).map(peer => [peer, new ActivityRing()]));
|
|
@@ -1769,8 +1672,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1769
1672
|
const owner = bus.peers.get(peer);
|
|
1770
1673
|
const state = bus.stateOf(peer);
|
|
1771
1674
|
const session = contextSession(peer);
|
|
1772
|
-
const generation = owner
|
|
1773
|
-
if (!owner || (owner
|
|
1675
|
+
const generation = isWsPeer(owner) || isCodexPeer(owner) ? owner.sessionGeneration : undefined;
|
|
1676
|
+
if (!owner || (isWsPeer(owner) && owner.claiming) || (state !== "idle" && state !== "busy") || (reason === "context" && (!session || turns.get(peer)?.private || contextPii(peer)))) return Promise.resolve(undefined);
|
|
1774
1677
|
const openWork = board.list().some((t) => (["proposed", "in_progress", "changes_requested", "in_review"].includes(t.state) && t.owner === peer) || (t.state === "in_review" && t.reviewer === peer));
|
|
1775
1678
|
if (!openWork && !bus.queued(peer) && !bus.hasInFlight(peer) && state !== "busy") {
|
|
1776
1679
|
if (reason === "quota") log(`budget ${peer}: no open work, no checkpoint`);
|
|
@@ -1782,7 +1685,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1782
1685
|
: `Checkpoint request: your native context window is near the configured threshold. Finish the step you are on and call hub_checkpoint {summary, request_id: "${requestId}"} with what you were doing, what is half done and what a fresh session would need. This only saves a checkpoint; keep your tasks and session. The operator decides whether to continue or restart. Do not include private task text.`;
|
|
1783
1686
|
const ask = newEnvelope(HUB, body, { to: [peer], kind: "budget", priority: "important" });
|
|
1784
1687
|
return new Promise((resolve) => {
|
|
1785
|
-
const valid = () => !stopping && !recoveryActive() && bus.peers.get(peer) === owner && (!(owner
|
|
1688
|
+
const valid = () => !stopping && !recoveryActive() && bus.peers.get(peer) === owner && (!(isWsPeer(owner) || isCodexPeer(owner)) || owner.sessionGeneration === generation) && ["idle", "busy"].includes(bus.stateOf(peer)) && contextSession(peer) === session;
|
|
1786
1689
|
const done = (summary: string | undefined) => {
|
|
1787
1690
|
clearTimeout(timer);
|
|
1788
1691
|
checkpointWaits.delete(peer);
|
|
@@ -1810,6 +1713,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1810
1713
|
}
|
|
1811
1714
|
};
|
|
1812
1715
|
const budget = new Budget(join(opts.stateDir, "hub.db"), config.budget, instanceBudgetHooks({
|
|
1716
|
+
definitionOf,
|
|
1813
1717
|
pause: (peer) => bus.pause(peer),
|
|
1814
1718
|
resume: (peer) => {
|
|
1815
1719
|
if (!manualPaused.has(peer) && !conductorHolds.has(peer)) bus.resume(peer);
|
|
@@ -1904,27 +1808,89 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1904
1808
|
if (!tasks.nameable(text) || looksSecret(text) || !screenMemory([{ title: null, text }]).length) return;
|
|
1905
1809
|
memoryObservations?.cite(peer, text, id, envs);
|
|
1906
1810
|
};
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1811
|
+
// Preserve real synchronous native boundaries, but never spend an unbounded sequence of Git waits
|
|
1812
|
+
// inside a single callback burst. Failed/slow captures are explicitly unknown, never stale baselines.
|
|
1813
|
+
let snapshotBudget: SnapshotBudget | undefined, snapshotBudgetNoticed = false;
|
|
1814
|
+
const reserveSnapshotBudget = (): SnapshotBudget => {
|
|
1815
|
+
if (!snapshotBudget) {
|
|
1816
|
+
const requested = opts.snapshotBurstMs ?? 250;
|
|
1817
|
+
const ms = Number.isFinite(requested) ? Math.max(1, Math.min(250, requested)) : 250;
|
|
1818
|
+
snapshotBudget = { deadline: performance.now() + ms };
|
|
1819
|
+
setImmediate(() => { snapshotBudget = undefined; snapshotBudgetNoticed = false; });
|
|
1820
|
+
}
|
|
1821
|
+
return snapshotBudget;
|
|
1822
|
+
};
|
|
1823
|
+
const snapshotFailure = (what: string, error: unknown) => {
|
|
1824
|
+
if (error instanceof SnapshotBudgetExceeded) {
|
|
1825
|
+
if (!snapshotBudgetNoticed) log(`snapshot unavailable: ${error.message}`);
|
|
1826
|
+
snapshotBudgetNoticed = true;
|
|
1827
|
+
} else log(`snapshot failed at ${what}: ${(error as Error).message}`);
|
|
1828
|
+
};
|
|
1829
|
+
const snap = (what: string, capture = config.snapshots.enabled, peer?: string): { tree?: string; head?: string | null; commit?: string | null; reflog?: HeadMoveCursor; ms: number; budget?: SnapshotBudget; repo?: Repo } => {
|
|
1914
1830
|
if (!capture) return { ms: 0 };
|
|
1915
|
-
const t0 = performance.now();
|
|
1916
|
-
let tree: string | undefined;
|
|
1831
|
+
const t0 = performance.now(), budget = reserveSnapshotBudget();
|
|
1832
|
+
let tree: string | undefined, head: string | null | undefined, commit: string | null | undefined, repo: Repo | undefined, reflog: HeadMoveCursor | undefined;
|
|
1917
1833
|
try {
|
|
1918
|
-
|
|
1834
|
+
repo = peer ? repoFor(peer, budget) : factsRepo;
|
|
1835
|
+
const before = repo && headState(repo, budget);
|
|
1836
|
+
tree = repo && (opts.snapshot ?? snapshot)(repo, budget);
|
|
1837
|
+
const after = repo && headState(repo, budget);
|
|
1838
|
+
head = after?.tree; commit = after?.head;
|
|
1839
|
+
if (!before || !after || before.tree !== after.tree) tree = undefined;
|
|
1840
|
+
if (tree && repo && head !== undefined && after) reflog = headMoveCursor(repo, head, budget, after.head);
|
|
1919
1841
|
if (repo && !tree) log(`snapshot failed at ${what}`);
|
|
1920
1842
|
} catch (error) {
|
|
1921
|
-
|
|
1843
|
+
tree = undefined; head = undefined; commit = undefined; reflog = undefined;
|
|
1844
|
+
snapshotFailure(what, error);
|
|
1922
1845
|
}
|
|
1923
|
-
return { tree, ms: Math.round(performance.now() - t0) };
|
|
1846
|
+
return { tree, head, commit, reflog, budget, repo, ms: Math.round(performance.now() - t0) };
|
|
1924
1847
|
};
|
|
1925
1848
|
function privateSnapshotWindow(): boolean {
|
|
1926
1849
|
return [...turns.values()].some(turn => turn.private) || board.list().some(task => !isClosedTaskState(task.state) && tasks.isPii(task));
|
|
1927
1850
|
}
|
|
1851
|
+
function ownerWriteRoot(peer: string): string | undefined {
|
|
1852
|
+
if (declarations[peer]) {
|
|
1853
|
+
const bound = executionOwners.get(peer);
|
|
1854
|
+
if (!bound || bound.owner !== bus.peers.get(peer) || bound.cwd !== executionRoots.get(peer)) return undefined;
|
|
1855
|
+
}
|
|
1856
|
+
return executionRoot(peer);
|
|
1857
|
+
}
|
|
1858
|
+
function canonicalWriteScope(repo: Repo, budget?: SnapshotBudget): boolean | undefined {
|
|
1859
|
+
// ponytail: D1 is inert for builtin owners while .worktrees exists (a deliberate false negative).
|
|
1860
|
+
// Bind each owner to its real execution worktree before narrowing this unknown scope.
|
|
1861
|
+
// The directory may contain a plain or still-uncommitted checkout, not just registered refs.
|
|
1862
|
+
if (lstatSync(join(opts.cwd, ".worktrees"), { throwIfNoEntry: false })) return false;
|
|
1863
|
+
return soleWorktree(repo, budget);
|
|
1864
|
+
}
|
|
1865
|
+
function captureOwnerWriteStart(task: Task): void {
|
|
1866
|
+
const accepted = tasks.ownerWriteAcceptance(task), prior = ownerWriteStarts.get(task.id);
|
|
1867
|
+
if (prior && prior.owner === task.owner && prior.acceptance === accepted?.index) return;
|
|
1868
|
+
ownerWriteStarts.delete(task.id);
|
|
1869
|
+
if (!accepted || accepted.index !== task.history.length - 1) return;
|
|
1870
|
+
if (!task.owner || !["implement", "bulk_edit"].includes(task.class) || privateSnapshotWindow()) return;
|
|
1871
|
+
const root = ownerWriteRoot(task.owner);
|
|
1872
|
+
if (root === undefined) return;
|
|
1873
|
+
const captured = snap(`task #${task.id} acceptance`, config.snapshots.enabled, task.owner);
|
|
1874
|
+
if (captured.tree) ownerWriteStarts.set(task.id, { owner: task.owner, acceptance: accepted.index, at: accepted.at, root, tree: captured.tree });
|
|
1875
|
+
}
|
|
1876
|
+
function ownerWriteEvidence(task: Task, since: number): boolean | undefined {
|
|
1877
|
+
if (!task.owner || !turnLog || !config.snapshots.enabled || privateSnapshotWindow()) return undefined;
|
|
1878
|
+
const baseline = ownerWriteStarts.get(task.id);
|
|
1879
|
+
const acceptance = tasks.ownerWriteAcceptance(task)?.index;
|
|
1880
|
+
if (!baseline || baseline.owner !== task.owner || baseline.at !== since || baseline.acceptance !== acceptance || baseline.root !== ownerWriteRoot(task.owner)) return undefined;
|
|
1881
|
+
try {
|
|
1882
|
+
if (turnLog.touchesFor([task.id]).some(touch => touch.peer === task.owner && touch.at >= since)) return true;
|
|
1883
|
+
const canonical = baseline.root === opts.cwd;
|
|
1884
|
+
if (canonical && canonicalWriteUnknown(task)) return undefined;
|
|
1885
|
+
const current = snap(`task #${task.id} done evidence`, true, task.owner);
|
|
1886
|
+
if (!current.repo || !current.tree) return undefined;
|
|
1887
|
+
const paths = knownChangedPaths(current.repo, baseline.tree, current.tree, current.budget);
|
|
1888
|
+
if (paths === undefined) return undefined;
|
|
1889
|
+
if (paths.length) return true;
|
|
1890
|
+
if (canonical && canonicalWriteScope(current.repo, current.budget) !== true) return undefined;
|
|
1891
|
+
return false;
|
|
1892
|
+
} catch { return undefined; } // Unknown is never evidence of no writes, including a missing/pruned tree.
|
|
1893
|
+
}
|
|
1928
1894
|
function reviewProjection(task: Task): string | undefined {
|
|
1929
1895
|
const owner = task.owner ?? "", root = executionRoot(owner);
|
|
1930
1896
|
const repo = repoFor(owner), projections = projectionsFor(owner);
|
|
@@ -1959,7 +1925,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1959
1925
|
}
|
|
1960
1926
|
return n;
|
|
1961
1927
|
};
|
|
1962
|
-
const onKimiTokens = (total: number, session: string, peer
|
|
1928
|
+
const onKimiTokens = (total: number, session: string, peer: string) => {
|
|
1963
1929
|
const n = addTokens(peer, delta(peer, total, session));
|
|
1964
1930
|
if (!config.budget.kimi_tokens_5h || !n) return;
|
|
1965
1931
|
const now = Date.now();
|
|
@@ -2022,7 +1988,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2022
1988
|
if (c.instanceId !== instanceId || !session.sessionId || c.sessionId !== session.sessionId || c.launchId !== session.launchId || !["idle", "busy", "paused"].includes(bus.stateOf(claudePeer))) continue;
|
|
2023
1989
|
claudeContextSeen.set(claudePeer, payload);
|
|
2024
1990
|
const bound = contextSession(claudePeer);
|
|
2025
|
-
|
|
1991
|
+
const reading = bound ? readNativeContext(definitionOf(claudePeer), c.context, bound, c.at) : undefined;
|
|
1992
|
+
if (reading) contexts.report(claudePeer, reading, bound);
|
|
2026
1993
|
} catch { /* absent or unreadable native telemetry is unknown */ }
|
|
2027
1994
|
}, 1000),
|
|
2028
1995
|
setInterval(() => budget.tick(), 30_000),
|
|
@@ -2042,7 +2009,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2042
2009
|
claudeUsageSeen = mtime;
|
|
2043
2010
|
const usage = JSON.parse(readFileSync(file, "utf8"));
|
|
2044
2011
|
// The file's own timestamp, not "now": a file left behind by yesterday's session is a stale reading, not a fresh one.
|
|
2045
|
-
|
|
2012
|
+
for (const adapter of ADAPTERS.filter(adapter => adapter.transport === "claude-channel")) {
|
|
2013
|
+
const windows = readNativeQuota(adapter, usage.rate_limits);
|
|
2014
|
+
if (windows) budget.report(adapter.kind, windows, false, Number(usage.at) || mtime);
|
|
2015
|
+
}
|
|
2046
2016
|
} catch {
|
|
2047
2017
|
// no tee in this session, or a half-written file: next round
|
|
2048
2018
|
}
|
|
@@ -2089,6 +2059,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2089
2059
|
const conductedMutations = new Set(["hub_task_propose", "hub_task_accept", "hub_task_decline", "hub_task_done", "hub_review", "hub_checkpoint", "hub_remember"]);
|
|
2090
2060
|
const taskOp = async (...args: Parameters<typeof taskOpBody>): Promise<string> => {
|
|
2091
2061
|
const [by, op, input] = args;
|
|
2062
|
+
if (readOnlyPeer(by) && !READ_ONLY_HUB_TOOLS.has(op)) throw new PreEffectToolRefusal("read-only instance cannot mutate hub state");
|
|
2063
|
+
const nativeTurn = turns.get(by);
|
|
2064
|
+
const effectful = ownerToolEffect(op, input);
|
|
2065
|
+
if (nativeTurn && effectful) nativeTurn.effectsPending = (nativeTurn.effectsPending ?? 0) + 1;
|
|
2092
2066
|
const conducted = conductorPeer(config.roles) === by && conductedMutations.has(op);
|
|
2093
2067
|
const admittedId = typeof input?.id === "number" && Number.isSafeInteger(input.id) && input.id > 0 ? input.id : undefined;
|
|
2094
2068
|
taskOpsInFlight++;
|
|
@@ -2096,6 +2070,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2096
2070
|
const readAuthority=op==="hub_task_show"?runningReviewAuthority(by,Number(input?.id)):undefined;
|
|
2097
2071
|
const before=readAuthority?.();if(before)throw new PreEffectToolRefusal(before);
|
|
2098
2072
|
const text = await taskOpBody(...args);
|
|
2073
|
+
if (nativeTurn && effectful && turns.get(by) === nativeTurn) nativeTurn.taskEffect = true;
|
|
2099
2074
|
const after=readAuthority?.();if(after)throw new PreEffectToolRefusal(after);
|
|
2100
2075
|
if (conducted) {
|
|
2101
2076
|
const proposed = op === "hub_task_propose" ? Number(text.match(/task #(\d+):/)?.[1]) : undefined;
|
|
@@ -2105,16 +2080,18 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2105
2080
|
const hint = args[3] ? ownHeldHint(by) : "";
|
|
2106
2081
|
return hint ? `${text}\n\n${hint}` : text;
|
|
2107
2082
|
} catch (error) {
|
|
2083
|
+
if (nativeTurn && effectful && !(error instanceof PreEffectToolRefusal) && turns.get(by) === nativeTurn) nativeTurn.taskEffect = true;
|
|
2108
2084
|
// Keep proven pre-effect refusal identity/code for native receipts; only add body-free metadata.
|
|
2109
2085
|
const hint = args[3] ? ownHeldHint(by) : "";
|
|
2110
2086
|
if (hint && error instanceof Error) error.message += `\n\n${hint}`;
|
|
2111
2087
|
throw error;
|
|
2112
2088
|
} finally {
|
|
2089
|
+
if (nativeTurn && effectful) nativeTurn.effectsPending = Math.max(0, (nativeTurn.effectsPending ?? 1) - 1);
|
|
2113
2090
|
taskOpsInFlight--;
|
|
2114
2091
|
}
|
|
2115
2092
|
};
|
|
2116
2093
|
function ownHeldHint(peer: PeerId): string {
|
|
2117
|
-
try { return renderHeldHint(bus.heldDeliveryHints(peer), Date.now(), !(bus.peers.get(peer)
|
|
2094
|
+
try { return renderHeldHint(bus.heldDeliveryHints(peer), Date.now(), !(isLocalPeer(bus.peers.get(peer)) || isAcpPeer(bus.peers.get(peer)))); }
|
|
2118
2095
|
catch { return ""; } // unavailable telemetry is not a zero count or a reason to fail the tool itself
|
|
2119
2096
|
}
|
|
2120
2097
|
/** A model-written peer argument: a peer id, or nothing for absent, null or "". Anything else is refused. */
|
|
@@ -2248,7 +2225,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2248
2225
|
switch (op) {
|
|
2249
2226
|
case "hub_delivery_retry": {
|
|
2250
2227
|
if (by === USER || by === HUB || !a || typeof a !== "object" || Array.isArray(a) || Object.keys(a).some(key => key !== "delivery_id") || typeof a.delivery_id !== "string" || a.delivery_id.length < 1 || a.delivery_id.length > 256) throw new PreEffectToolRefusal("hub_delivery_retry accepts only your delivery_id; completed and discard require the person");
|
|
2251
|
-
if (bus.peers.get(by)
|
|
2228
|
+
if (isLocalPeer(bus.peers.get(by)) || isAcpPeer(bus.peers.get(by))) throw new PreEffectToolRefusal("delivery-only peers require the person to resolve held deliveries");
|
|
2252
2229
|
if (stopping || recoveryActive()) throw new PreEffectToolRefusal("recovery or shutdown is holding delivery mutations");
|
|
2253
2230
|
bus.retryDelivery(by, a.delivery_id);
|
|
2254
2231
|
return `retry queued for delivery ${a.delivery_id}; check for earlier effects before acting`;
|
|
@@ -2294,7 +2271,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2294
2271
|
if (t.state === "backlog") return line(t);
|
|
2295
2272
|
const overlap = tasks.overlaps(t, t.owner === by);
|
|
2296
2273
|
if (t.owner === by && tasks.silentFor(t.id) && factsOn()) factsFor(by).sawPlans(by, tasks.overlapTasks(t)); // as for an accept
|
|
2297
|
-
return [line(t), overlap, tasks.claimRoleGuidance(t, by), t.owner === by ? tasks.planGuardText(t) : ""].filter(Boolean).join("\n");
|
|
2274
|
+
return [line(t), tasks.namedOwnerNote(t, a.owner as PeerId | undefined, by), overlap, tasks.claimRoleGuidance(t, by), t.owner === by ? tasks.planGuardText(t) : ""].filter(Boolean).join("\n");
|
|
2298
2275
|
}
|
|
2299
2276
|
case "hub_task_accept": {
|
|
2300
2277
|
// S1 spec acceptance and S9 plan review are checked against one task snapshot before acceptance.
|
|
@@ -2437,12 +2414,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2437
2414
|
if (!definitionOf(turn.peer)?.session.nativeRevert || !turn.native) throw new Error(`turn ${turn.id} has no Codex conversation turn to revert`);
|
|
2438
2415
|
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`);
|
|
2439
2416
|
const codex = bus.peers.get(turn.peer);
|
|
2440
|
-
if (!(codex
|
|
2417
|
+
if (!isCodexPeer(codex)) throw new Error("codex is not attached");
|
|
2441
2418
|
const workspace = turnLog!.workspace(turn.id);
|
|
2442
2419
|
if (workspace !== undefined) {
|
|
2443
|
-
const current = readInstanceRoleConfig(opts.cwd), adapter = instanceDefinitionOf(turn.peer, current.instances);
|
|
2420
|
+
const current = readInstanceRoleConfig(opts.cwd), adapter = instanceDefinitionOf(turn.peer, current.instances, current.catalog);
|
|
2444
2421
|
if (!current.instances?.[turn.peer] || adapter?.kind !== definitionOf(turn.peer)?.kind) throw new Error("turn workspace declaration changed; inspect before reverting");
|
|
2445
|
-
const validated = resolveInstanceExecutionCwd(opts.cwd, turn.peer, current.instances, effectiveInstanceRoles(turn.peer, current.instances, current.roles));
|
|
2422
|
+
const validated = resolveInstanceExecutionCwd(opts.cwd, turn.peer, current.instances, effectiveInstanceRoles(turn.peer, current.instances, current.roles, current.catalog), current.catalog);
|
|
2446
2423
|
const bound = executionOwners.get(turn.peer);
|
|
2447
2424
|
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");
|
|
2448
2425
|
}
|
|
@@ -2527,7 +2504,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2527
2504
|
* waited past `budget.stale_min` the status says so, and the person decides. Read-only.
|
|
2528
2505
|
*/
|
|
2529
2506
|
const importantWaitingStatus = (id: PeerId, peer: PeerAdapter | undefined): Record<string, unknown> => {
|
|
2530
|
-
if (!(peer
|
|
2507
|
+
if (!isAcpPeer(peer) || (peer as PeerAdapter).steer || bus.stateOf(id) !== "busy") return {};
|
|
2531
2508
|
const since = bus.oldestQueuedImportantAt(id);
|
|
2532
2509
|
if (since === undefined || Date.now() - since < config.budget.stale_min * 60_000) return {};
|
|
2533
2510
|
const count = bus.queuedImportant(id), minutes = Math.floor((Date.now() - since) / 60_000);
|
|
@@ -2540,7 +2517,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2540
2517
|
};
|
|
2541
2518
|
let releasing = false; // gone-owner release (#6) and the ready sweep (#34): one run at a time, and a recovery commit waits for it
|
|
2542
2519
|
const recoveryReady = () => {
|
|
2543
|
-
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
|
|
2520
|
+
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" || (isPiPeer(peer) && !peer.recoveryReady))) return false;
|
|
2544
2521
|
if (checkPending.size !== 0) return false;
|
|
2545
2522
|
if (!recoveryPeerSnapshot) return true;
|
|
2546
2523
|
const current = recoveryPeers();
|
|
@@ -2570,7 +2547,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2570
2547
|
});
|
|
2571
2548
|
};
|
|
2572
2549
|
/** #450 I3: each Claude peer (claude and every claude-<name> instance) has its own session and launch files. */
|
|
2573
|
-
const claudeSession = (peer
|
|
2550
|
+
const claudeSession = (peer: string): { sessionId?: string; transcriptPath?: string; launchId?: string } => {
|
|
2551
|
+
if (definitionOf(peer)?.transport !== "claude-channel") return {}; // #624: Claude state files belong to Claude peers only
|
|
2574
2552
|
try {
|
|
2575
2553
|
const value = JSON.parse(readFileSync(join(opts.stateDir, claudeStateFile("claude-session", peer)), "utf8"));
|
|
2576
2554
|
if (value.instanceId !== instanceId) return {};
|
|
@@ -2601,7 +2579,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2601
2579
|
const claudeVersions = new Map<string, string>();
|
|
2602
2580
|
const claudeStamps = new Map<string, string>(); // the transcript's size and mtime when its version was read (#115)
|
|
2603
2581
|
const claudeVersion = (): string | undefined => {
|
|
2604
|
-
const path = claudeSession().transcriptPath;
|
|
2582
|
+
const path = claudeSession(defaultChannelPeer(undefined)).transcriptPath;
|
|
2605
2583
|
if (!path) return undefined;
|
|
2606
2584
|
let fd: number | undefined;
|
|
2607
2585
|
try {
|
|
@@ -2705,9 +2683,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2705
2683
|
log(`WARNING permission start ${peer}: ${mode} from ${permissionModeSources.get(peer) ?? "human runtime command"}; effective ${effective}${effective === "unmanaged" ? "; native mode is unmanaged; requested mode not applied" : pending ? "; waits for a person's y in ahub console" : "; matching actions run without asking"}`);
|
|
2706
2684
|
};
|
|
2707
2685
|
const permissionDisplay = (id: string, peer: PeerAdapter | undefined): string => {
|
|
2708
|
-
if (
|
|
2709
|
-
if (
|
|
2710
|
-
if (
|
|
2686
|
+
if (readOnlyPeer(id)) return "read-only";
|
|
2687
|
+
if (isAcpPeer(peer) && ["unknown", "unmanaged"].includes(peer.permissionModeState)) return peer.permissionModeState;
|
|
2688
|
+
if (starting.has(id) && (isAcpPeer(peer) || isCodexPeer(peer))) return peer.getPermissionMode();
|
|
2689
|
+
if (definitionOf(id)?.transport === "claude-channel" && isWsPeer(peer) && peer.state !== "offline") {
|
|
2711
2690
|
const launch = claudePermissionLaunch(id);
|
|
2712
2691
|
if (!launch || claudePermissionGenerations.get(id) !== peer.sessionGeneration) return "unverified";
|
|
2713
2692
|
}
|
|
@@ -2715,7 +2694,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2715
2694
|
};
|
|
2716
2695
|
const permissionStatus = (id: string, peer: PeerAdapter | undefined): Record<string, unknown> => {
|
|
2717
2696
|
const note = definitionOf(id)?.permission.unmanagedStatusNote;
|
|
2718
|
-
if (note && peer
|
|
2697
|
+
if (note && isWsPeer(peer) && peer.pullOnly) return { permissionNote: note };
|
|
2719
2698
|
const mode = permissionDisplay(id, peer);
|
|
2720
2699
|
return mode === "ask" ? {} : { permissionMode: mode };
|
|
2721
2700
|
};
|
|
@@ -2729,10 +2708,19 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2729
2708
|
const statusPeerIds = (open: ReturnType<Board["statusTasks"]>) => {
|
|
2730
2709
|
const reservations = board.statusReservations();
|
|
2731
2710
|
const ids = new Set([...bus.knownPeers(), ...extraIds, ...reservations]);
|
|
2711
|
+
for (const peer of configuredIds) if (onRoster(peer)) ids.add(peer);
|
|
2732
2712
|
if (!Object.keys(config.roster).some(peer => !membershipAllowed(peer))) return [...ids];
|
|
2733
2713
|
for (const task of open) for (const peer of [task.owner, task.reviewer]) if (peer && !membershipAllowed(peer)) ids.add(peer);
|
|
2734
2714
|
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));
|
|
2735
2715
|
};
|
|
2716
|
+
let checkoutInTick: { branch: ReturnType<typeof mainCheckoutBranch> } | undefined;
|
|
2717
|
+
const checkoutBranch = () => {
|
|
2718
|
+
if (!checkoutInTick) {
|
|
2719
|
+
checkoutInTick = { branch: (opts.checkoutBranch ?? mainCheckoutBranch)(opts.cwd) };
|
|
2720
|
+
queueMicrotask(() => { checkoutInTick = undefined; });
|
|
2721
|
+
}
|
|
2722
|
+
return checkoutInTick.branch;
|
|
2723
|
+
};
|
|
2736
2724
|
const status = (includeAttentionItems = false, includeAttentionInput = false) => {
|
|
2737
2725
|
const taskNeeds = board.statusTasks();
|
|
2738
2726
|
const taskStalls = tasks.stalled();
|
|
@@ -2746,8 +2734,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2746
2734
|
pluginBundle: SERVER_JS,
|
|
2747
2735
|
pendingRestart,
|
|
2748
2736
|
retainedConfig,
|
|
2749
|
-
...(() => { const warning = mainCheckoutWarning(opts.cwd, tasks.publicMetadataText); return warning ? { mainCheckout: warning } : {}; })(),
|
|
2750
|
-
startModes: Object.fromEntries(START_MODE_PEERS.map(peer => [peer, startModeOf(config.peers, peer, declarations)])),
|
|
2737
|
+
...(() => { const warning = mainCheckoutWarning(opts.cwd, tasks.publicMetadataText, checkoutBranch); return warning ? { mainCheckout: warning } : {}; })(),
|
|
2738
|
+
startModes: Object.fromEntries([...START_MODE_PEERS, ...configuredIds].map(peer => [peer, startModeOf(config.peers, peer, declarations, adapterCatalog)])),
|
|
2751
2739
|
memoryProfile: memoryProfileStatus(includeAttentionItems),
|
|
2752
2740
|
lifecycleStages: [...lifecycleConfig.stages],
|
|
2753
2741
|
// ponytail: off keeps the #257 phase schema until RETIRED_WORKFLOW_FROM, when the alias and this flag go together.
|
|
@@ -2756,6 +2744,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2756
2744
|
roster: [...KNOWN_PEERS, ...OPTIONAL_PEERS].map(peer => ({ peer, on: onRoster(peer) })),
|
|
2757
2745
|
protocol: PROTOCOL,
|
|
2758
2746
|
features: DAEMON_FEATURES,
|
|
2747
|
+
configuredAdapters: configuredIds,
|
|
2748
|
+
startablePeers: SUPPORTED_PEERS.filter(isStartablePeer),
|
|
2749
|
+
readOnlyPeers: extraIds.filter(readOnlyPeer),
|
|
2759
2750
|
// #450 I3: the instance ids this daemon bound at startup; a launcher selects only one of these.
|
|
2760
2751
|
activeInstances: extraIds,
|
|
2761
2752
|
stopping,
|
|
@@ -2765,7 +2756,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2765
2756
|
...(dashboard ? { uiOrigin: dashboard.origin } : {}),
|
|
2766
2757
|
codexProxyPort: opts.codexProxyPort,
|
|
2767
2758
|
peers: Object.fromEntries(
|
|
2768
|
-
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) } : {}), ...importantWaitingStatus(id, p), ...pausedNote(id), ...(p
|
|
2759
|
+
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), ...(isCodexPeer(p) && p.statusNotice ? { statusNotice: p.statusNotice } : {}), ...(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) } : {}), ...importantWaitingStatus(id, p), ...pausedNote(id), ...(isLocalPeer(p) && p.lastServedBy ? { servedBy: p.lastServedBy } : {}), ...(isWsPeer(p) && p.claiming ? { claiming: true } : {}), ...(isWsPeer(p) && p.pullOnly ? { pullOnly: true, toolsOnly: definitionOf(id)?.attach?.pullOnlyNotice ?? "tools-only: messages wait for hub_inbox; for pushes restart Claude with ahub claude", lastInboxAt: p.lastInboxAt ?? null } : {}), ...(isPiPeer(p) ? { requestedModel: p.getRequestedModel(), backends: modelRelays.get(id)?.status().backends ?? [] } : {}) }]; }),
|
|
2769
2760
|
),
|
|
2770
2761
|
taskWip: Object.fromEntries(peerIds.map(id => [id, { wip: tasks.wip(id), wipLimit: tasks.wipLimit(id) }])),
|
|
2771
2762
|
boardReadCapabilities: { releaseFilter: true, columnMetadata: true },
|
|
@@ -2912,28 +2903,57 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2912
2903
|
/** Work a conflict notice is about: open tasks and tasks in review (#91); a notice for anything else is dropped (#106). */
|
|
2913
2904
|
const CONFLICT_STATES: Task["state"][] = ["proposed", "in_progress", "changes_requested", "in_review"];
|
|
2914
2905
|
const detectConcurrentConflicts = (record: TurnRecord, mine: Task[]) => {
|
|
2906
|
+
const hits: { ours: Task; theirs: Task; paths: string[]; turn: TurnRecord }[] = [];
|
|
2915
2907
|
for (const otherTurn of turnLog!.concurrentWith(record)) {
|
|
2916
2908
|
const otherTasks = (turnTasks.get(otherTurn.id) ?? []).flatMap((id) => { const task = board.get(id); return task ? [task] : []; });
|
|
2917
2909
|
if (otherTasks.some(tasks.isPii)) continue;
|
|
2918
|
-
const
|
|
2919
|
-
const
|
|
2920
|
-
for (const
|
|
2910
|
+
const theirs = new Set(otherTurn.changed);
|
|
2911
|
+
const shared = record.changed.filter(path => theirs.has(path));
|
|
2912
|
+
for (const ours of mine) for (const theirs of otherTasks) {
|
|
2921
2913
|
if (ours.id === theirs.id) continue;
|
|
2922
2914
|
const pair = [ours.id, theirs.id].sort((a, b) => a - b).join(":");
|
|
2923
|
-
const paths = shared.filter(
|
|
2915
|
+
const paths = shared.filter(path => !conflictSeen.has(`concurrent:${pair}:${path}`));
|
|
2924
2916
|
if (!paths.length) continue;
|
|
2925
2917
|
for (const path of paths) conflictSeen.add(`concurrent:${pair}:${path}`);
|
|
2926
|
-
|
|
2927
|
-
const files = [...named, ...(paths.length > named.length ? [`${paths.length - named.length} file(s) whose names are withheld (they match a PII pattern)`] : [])].join(", ");
|
|
2928
|
-
const together = tasks.silentFor(ours.id) && tasks.silentFor(theirs.id) ? "Check the working tree before continuing; do not message the other owner (you are in one turn-free cohort: the hub shows you its changes)." : "Check the working tree together before continuing.";
|
|
2929
|
-
const text = `Concurrent edit: ${record.id} (task #${ours.id}) and ${otherTurn.id} (task #${theirs.id}) both include changes to ${files}. These snapshots do not attribute the changes to either peer. ${together}`;
|
|
2930
|
-
notify(`conflict: ${text}`);
|
|
2931
|
-
event({ type: "conflict", peer: record.peer, task: ours.id, other: theirs.id, owner: otherTurn.peer, paths: named, concurrent: true, turns: [record.id, otherTurn.id] });
|
|
2932
|
-
recordConflictLink(ours.id, theirs.id, [record.peer, otherTurn.peer]);
|
|
2933
|
-
// Each owner hears it while its own task is open; once that task is closed the notice is dropped (issue #106).
|
|
2934
|
-
for (const [owner, task] of [[record.peer, ours.id], [otherTurn.peer, theirs.id]] as const) if (owner !== USER && owner !== HUB) tasks.whileOpen(owner, task, text, CONFLICT_STATES);
|
|
2918
|
+
hits.push({ ours, theirs, paths, turn: otherTurn });
|
|
2935
2919
|
}
|
|
2936
2920
|
}
|
|
2921
|
+
if (!hits.length) return;
|
|
2922
|
+
const paths = [...new Set(hits.flatMap(hit => hit.paths))];
|
|
2923
|
+
const named = paths.filter(tasks.nameable), visible = new Set(named);
|
|
2924
|
+
const files = (shared: string[]) => {
|
|
2925
|
+
const shown = shared.filter(path => visible.has(path));
|
|
2926
|
+
return [...shown, ...(shared.length > shown.length ? [`${shared.length - shown.length} file(s) whose names are withheld (they match a PII pattern)`] : [])].join(", ");
|
|
2927
|
+
};
|
|
2928
|
+
const affected = new Map<string, Map<number, Task>>();
|
|
2929
|
+
const add = (owner: string, task: Task) => {
|
|
2930
|
+
if (!affected.has(owner)) affected.set(owner, new Map());
|
|
2931
|
+
affected.get(owner)!.set(task.id, task);
|
|
2932
|
+
};
|
|
2933
|
+
for (const hit of hits) { add(record.peer, hit.ours); add(hit.turn.peer, hit.theirs); }
|
|
2934
|
+
const labels = (rows: Task[]) => `${rows.length === 1 ? "task" : "tasks"} ${rows.map(task => `#${task.id}`).join(", ")}`;
|
|
2935
|
+
const descriptions = [`${record.id} (${labels([...affected.get(record.peer)!.values()])}): ${files(paths)}`];
|
|
2936
|
+
const byTurn = new Map<string, { tasks: Map<number, Task>; paths: Set<string> }>();
|
|
2937
|
+
for (const hit of hits) {
|
|
2938
|
+
if (!byTurn.has(hit.turn.id)) byTurn.set(hit.turn.id, { tasks: new Map(), paths: new Set() });
|
|
2939
|
+
const group = byTurn.get(hit.turn.id)!;
|
|
2940
|
+
group.tasks.set(hit.theirs.id, hit.theirs);
|
|
2941
|
+
for (const path of hit.paths) group.paths.add(path);
|
|
2942
|
+
}
|
|
2943
|
+
for (const [id, group] of byTurn) descriptions.push(`${id} (${labels([...group.tasks.values()])}): ${files([...group.paths])}`);
|
|
2944
|
+
const rows = [...affected.values()].flatMap(group => [...group.values()]);
|
|
2945
|
+
const together = rows.every(task => tasks.silentFor(task.id)) ? "Check the working tree before continuing; do not message the other owner (you are in one turn-free cohort: the hub shows you its changes)." : rows.some(task => tasks.silentFor(task.id)) ? "Check the working tree before continuing. Do not message another owner for tasks in the same turn-free cohort; the hub shows their changes. Check other work together." : "Check the working tree together before continuing.";
|
|
2946
|
+
const text = `Concurrent edit: shared changes by turn: ${descriptions.join("; ")}. These snapshots do not attribute the changes to any peer. ${together}`;
|
|
2947
|
+
notify(`conflict: ${text}`);
|
|
2948
|
+
// Research retains task-pair evidence. Durable notices are computed once across all overlapping turns,
|
|
2949
|
+
// and each affected task gets only one owner-fenced notice for this change set.
|
|
2950
|
+
for (const hit of hits) {
|
|
2951
|
+
event({ type: "conflict", peer: record.peer, task: hit.ours.id, other: hit.theirs.id, owner: hit.turn.peer, paths: hit.paths.filter(path => visible.has(path)), concurrent: true, turns: [record.id, hit.turn.id] });
|
|
2952
|
+
recordConflictLink(hit.ours.id, hit.theirs.id, [record.peer, hit.turn.peer]);
|
|
2953
|
+
}
|
|
2954
|
+
for (const [owner, group] of affected) {
|
|
2955
|
+
if (owner !== USER && owner !== HUB) for (const task of group.values()) tasks.whileOpen(owner, task.id, text, CONFLICT_STATES);
|
|
2956
|
+
}
|
|
2937
2957
|
};
|
|
2938
2958
|
const detectConflicts = (peer: PeerId, turnId: string, since: number, changed: string[]) => {
|
|
2939
2959
|
const open = board.list().filter((t) => t.owner && CONFLICT_STATES.includes(t.state));
|
|
@@ -2982,8 +3002,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2982
3002
|
let piSettlementFailure: { key: string; error: string; retryAfter: number } | undefined;
|
|
2983
3003
|
function settlePiContinuity(): void {
|
|
2984
3004
|
if (stopping || recoveryActive() || piContinuity.state !== "ready") return;
|
|
2985
|
-
const peer = bus.peers.get(
|
|
2986
|
-
if (!(peer
|
|
3005
|
+
const peer = bus.peers.get(continuityKind);
|
|
3006
|
+
if (!isPiPeer(peer) || peer.state === "offline" || !peer.sessionGeneration || !peer.recoveryReady) return;
|
|
2987
3007
|
const saved = piContinuity.bookmark, actual = peer.recoveryMetadata();
|
|
2988
3008
|
if (typeof actual.sessionId !== "string" || !actual.sessionId) return;
|
|
2989
3009
|
const key = JSON.stringify([piContinuity.digest, peer.sessionGeneration, actual]);
|
|
@@ -3012,8 +3032,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3012
3032
|
}
|
|
3013
3033
|
const recordSessions = () => {
|
|
3014
3034
|
if (stopping) return;
|
|
3015
|
-
const pi = bus.peers.get(
|
|
3016
|
-
if (piContinuity.state === "ready" && (!(pi
|
|
3035
|
+
const pi = bus.peers.get(continuityKind);
|
|
3036
|
+
if (piContinuity.state === "ready" && (!isPiPeer(pi) || pi.state === "offline" || !pi.sessionGeneration || !pi.recoveryReady)) {
|
|
3017
3037
|
piContinuity = readRecoveryContinuity(opts.stateDir, { projectId, projectRoot: opts.cwd });
|
|
3018
3038
|
}
|
|
3019
3039
|
const peers = [...bus.peers.values()].filter((p) => p.state !== "offline").map((p) => {
|
|
@@ -3035,19 +3055,19 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3035
3055
|
const lostCount = [...lost.values()].reduce((n, l) => n + l.length, 0);
|
|
3036
3056
|
report(`the previous hub run stopped without shutting down${lostCount ? `; ${lostCount} deliveries it had in flight are in needs_review (ahub queue list)` : ""}`);
|
|
3037
3057
|
const start = (peer: string, args: Parameters<typeof startPeer>[1]) => startPeer(peer, args).catch((e: Error) => ({ ok: false, error: e.message }));
|
|
3038
|
-
for (const step of crashPlan(prev.peers, declarations)) {
|
|
3058
|
+
for (const step of crashPlan(prev.peers, declarations, adapterCatalog)) {
|
|
3039
3059
|
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; }
|
|
3040
3060
|
// `pi.auto_start` already asks for Pi (#66): its recorded headless session comes back, auto-resume or not, and a
|
|
3041
3061
|
// fresh one starts when that fails or there is nothing headless to resume. Pi runs on-prem: no cloud quota.
|
|
3042
3062
|
if (!declarations[step.peer] && definitionOf(step.peer)?.session.restoreOnStart && piAutoStart()) {
|
|
3043
3063
|
if (step.resume) {
|
|
3044
|
-
const r = await start(
|
|
3064
|
+
const r = await start(step.peer, step.resume as Parameters<typeof startPeer>[1]);
|
|
3045
3065
|
if (r.ok) { report(`pi resumed (pi.auto_start): ${step.how}`); continue; }
|
|
3046
3066
|
report(`pi not resumed (${String(r.error)}); ${step.how}`);
|
|
3047
3067
|
} else report(step.how);
|
|
3048
3068
|
// #269: a Pi that ran in a terminal is not replaced by a headless one unless headless is its start mode.
|
|
3049
|
-
if (step.tui && startMode(
|
|
3050
|
-
const fresh = await start(
|
|
3069
|
+
if (step.tui && startMode(step.peer) === "tui") { report("pi.auto_start starts no headless Pi in place of a terminal one: run the command above, or set peers.pi.start_mode to headless"); continue; }
|
|
3070
|
+
const fresh = await start(step.peer, { ...(step.fresh as Parameters<typeof startPeer>[1]), fresh: true });
|
|
3051
3071
|
const back = step.tui ? `; to go back to the recorded session, run ahub kill, start the hub without pi.auto_start, then the command above` : "";
|
|
3052
3072
|
report(fresh.ok ? `pi.auto_start started a fresh session${back}` : `pi.auto_start could not start Pi either (${String(fresh.error)})`);
|
|
3053
3073
|
continue;
|
|
@@ -3101,20 +3121,83 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3101
3121
|
const capture = config.snapshots.enabled && !!turnLog;
|
|
3102
3122
|
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
|
|
3103
3123
|
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}`); }
|
|
3124
|
+
const writeTasks = tasks.ownerTurnTasks(e.peer);
|
|
3125
|
+
let branches: string | undefined, writeScope: boolean | undefined;
|
|
3126
|
+
if (writeTasks.length && start?.repo) try {
|
|
3127
|
+
branches = branchHeads(start.repo, start.budget);
|
|
3128
|
+
writeScope = ownerWriteRoot(e.peer) === opts.cwd ? canonicalWriteScope(start.repo, start.budget) : true;
|
|
3129
|
+
} catch (error) { snapshotFailure(`the write scope of ${id}`, error); }
|
|
3104
3130
|
const preparedRoles = preparedSessionRoleIdentities.get(e.peer)?.map(role => ({ ...role })) ?? null;
|
|
3105
|
-
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 } : {}) });
|
|
3131
|
+
turns.set(e.peer, { id, capture, start: Date.now(), tokens: 0, writeTasks, branches, writeScope, deliveryTask: delivery, attribution, preparedRoles, ...(start ? { tree: start.tree, head: start.head, commit: start.commit, reflog: start.reflog, snapshotMs: start.ms } : {}), ...(pii ? { private: true } : {}) });
|
|
3106
3132
|
event({ type: "turn_start", peer: e.peer, turn: id });
|
|
3107
3133
|
} else if (!busy && open) {
|
|
3108
3134
|
turns.delete(e.peer);
|
|
3109
3135
|
let afterTurn: (() => void) | undefined;
|
|
3110
3136
|
let files: number | undefined;
|
|
3137
|
+
let writeProgress: boolean | undefined;
|
|
3111
3138
|
let snapshotMs = open.snapshotMs;
|
|
3112
3139
|
if (open.capture && turnLog && (open.private || holdsPii(e.peer))) {
|
|
3113
3140
|
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}`); }
|
|
3114
3141
|
} else if (open.capture && turnLog) {
|
|
3115
3142
|
const end = snap(`the end of ${open.id}`, true, e.peer);
|
|
3116
|
-
|
|
3143
|
+
let changed: string[] = [];
|
|
3144
|
+
if (open.tree && end.tree) {
|
|
3145
|
+
const turnRepo = end.repo!;
|
|
3146
|
+
try {
|
|
3147
|
+
const observed = knownChangedPaths(turnRepo, open.tree, end.tree, end.budget);
|
|
3148
|
+
if (!observed) throw new Error("turn diff evidence is unavailable");
|
|
3149
|
+
changed = observed;
|
|
3150
|
+
// HEAD movement alone does not identify incoming work: commits/amends/rebases/cherry-picks
|
|
3151
|
+
// are normal peer edits. Only a bounded, append-fenced reflog classifies an incoming move.
|
|
3152
|
+
if (open.head !== end.head || open.reflog || end.reflog) {
|
|
3153
|
+
const paths = open.reflog && end.reflog ? incomingHeadPaths(turnRepo, open.reflog, end.reflog, end.budget) : undefined;
|
|
3154
|
+
if (!paths) throw new Error("HEAD move evidence is ambiguous or unavailable");
|
|
3155
|
+
if (paths.length) {
|
|
3156
|
+
if (!end.head) throw new Error("end HEAD evidence is unavailable");
|
|
3157
|
+
const dirtyPaths = knownChangedPaths(turnRepo, end.head, end.tree, end.budget);
|
|
3158
|
+
if (!dirtyPaths) throw new Error("end worktree diff evidence is unavailable");
|
|
3159
|
+
const checkBudget = () => { if (end.budget && performance.now() >= end.budget.deadline) throw new SnapshotBudgetExceeded(); };
|
|
3160
|
+
const dirty = new Set(dirtyPaths), dirtyScope = new Set(dirtyPaths), incoming = new Set(paths);
|
|
3161
|
+
for (const path of dirtyPaths) {
|
|
3162
|
+
checkBudget();
|
|
3163
|
+
for (let at = path.lastIndexOf("/"); at > 0; at = path.lastIndexOf("/", at - 1)) {
|
|
3164
|
+
checkBudget(); dirtyScope.add(path.slice(0, at));
|
|
3165
|
+
}
|
|
3166
|
+
}
|
|
3167
|
+
// Autostash and checkout -m can carry uncommitted edits across an incoming move.
|
|
3168
|
+
// Preserve every still-dirty path (including file/directory overlaps), never subtract it.
|
|
3169
|
+
changed = changed.filter(path => {
|
|
3170
|
+
checkBudget();
|
|
3171
|
+
if (!incoming.has(path) || dirtyScope.has(path)) return true;
|
|
3172
|
+
for (let at = path.lastIndexOf("/"); at > 0; at = path.lastIndexOf("/", at - 1)) {
|
|
3173
|
+
checkBudget(); if (dirty.has(path.slice(0, at))) return true;
|
|
3174
|
+
}
|
|
3175
|
+
return false;
|
|
3176
|
+
});
|
|
3177
|
+
checkBudget();
|
|
3178
|
+
}
|
|
3179
|
+
}
|
|
3180
|
+
} catch (error) {
|
|
3181
|
+
end.tree = undefined; end.head = undefined; changed = [];
|
|
3182
|
+
snapshotFailure(`the diff of ${open.id}`, error);
|
|
3183
|
+
}
|
|
3184
|
+
}
|
|
3117
3185
|
if (open.tree && end.tree) files = changed.length; // unknown, not zero, when a snapshot failed
|
|
3186
|
+
if (open.writeTasks.length) {
|
|
3187
|
+
if ((files ?? 0) > 0 || open.commit !== undefined && end.commit !== undefined && open.commit !== end.commit) writeProgress = true;
|
|
3188
|
+
else if (open.commit !== undefined && end.commit !== undefined && open.branches !== undefined && end.repo) {
|
|
3189
|
+
try {
|
|
3190
|
+
const branches = branchHeads(end.repo, end.budget);
|
|
3191
|
+
if (branches !== undefined) {
|
|
3192
|
+
if (branches !== open.branches) writeProgress = true;
|
|
3193
|
+
else if (files === 0 && open.writeScope === true) {
|
|
3194
|
+
const scope = ownerWriteRoot(e.peer) === opts.cwd ? canonicalWriteScope(end.repo, end.budget) : true;
|
|
3195
|
+
if (scope === true) writeProgress = false;
|
|
3196
|
+
}
|
|
3197
|
+
}
|
|
3198
|
+
} catch (error) { snapshotFailure(`the branch refs of ${open.id}`, error); }
|
|
3199
|
+
}
|
|
3200
|
+
}
|
|
3118
3201
|
snapshotMs = (snapshotMs ?? 0) + end.ms;
|
|
3119
3202
|
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}`); }
|
|
3120
3203
|
// After the turn_end event: a notice delivered at once starts the peer's next turn, which must come after it.
|
|
@@ -3128,6 +3211,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3128
3211
|
tasks.cohorts.turnEnded(e.peer);
|
|
3129
3212
|
}
|
|
3130
3213
|
try { afterTurn?.(); } catch (error) { log(`conflict check after ${open.id}: ${(error as Error).message}`); }
|
|
3214
|
+
if (definitionOf(e.peer)?.turnEnd !== "native-hook" || (nativeCompletedAt.get(e.peer) ?? -1) >= open.start) {
|
|
3215
|
+
const root = ownerWriteRoot(e.peer);
|
|
3216
|
+
const elsewhere = root === opts.cwd && open.writeTasks.some(binding => { const task = board.get(binding.id); return task && canonicalWriteUnknown(task); });
|
|
3217
|
+
const changed = e.state === "idle" && !open.private && root !== undefined && !(writeProgress === false && elsewhere) ? writeProgress : undefined;
|
|
3218
|
+
try { tasks.recordOwnerTurn(e.peer, open.writeTasks, changed, !!open.taskEffect || !!open.effectsPending); } catch { /* advisory cannot fail native completion */ }
|
|
3219
|
+
}
|
|
3131
3220
|
}
|
|
3132
3221
|
if (e.state === "offline") { offlineSince.set(e.peer, offlineSince.get(e.peer) ?? Date.now()); supervision.peerOffline(e.peer); supervisionTurns.delete(e.peer); }
|
|
3133
3222
|
else offlineSince.delete(e.peer);
|
|
@@ -3275,7 +3364,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3275
3364
|
const replacingPi = new Set<PiPeer>();
|
|
3276
3365
|
const piReplacementAuthority = Symbol("console Pi replacement");
|
|
3277
3366
|
/** pi.auto_start at hub start (#269): in Pi's start mode, and said out loud when it could not be done. */
|
|
3278
|
-
const autoStartPi = () => void hubStart(
|
|
3367
|
+
const autoStartPi = () => void hubStart(autoStartAdapter!.kind, "pi.auto_start").then(
|
|
3279
3368
|
(result) => { if (result.ok !== true) notify(`pi.auto_start did not start Pi: ${String(result.error)}`); },
|
|
3280
3369
|
(error) => log(`Pi auto-start failed: ${(error as Error).message}${piContinuity.state === "ready" ? `. ${continuityCleanup()}` : ""}`));
|
|
3281
3370
|
async function stopPiForReplacement(pi: PiPeer): Promise<void> {
|
|
@@ -3316,7 +3405,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3316
3405
|
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" }) };
|
|
3317
3406
|
const CLI_ENTRY = fileURLToPath(new URL("../cli/main.ts", import.meta.url));
|
|
3318
3407
|
/** `config.peers` follows a settings write (`settingsSet`), so a changed mode applies at the peer's next start. */
|
|
3319
|
-
const startMode = (peer: string): StartMode => startModeOf(config.peers, peer, declarations);
|
|
3408
|
+
const startMode = (peer: string): StartMode => startModeOf(config.peers, peer, declarations, adapterCatalog);
|
|
3320
3409
|
const startWords = (peer: string, mode: StartMode): string[] => {
|
|
3321
3410
|
const words = definitionOf(peer)?.cli.startWords(mode) ?? [peer];
|
|
3322
3411
|
return declarations[peer] ? [words[0]!, "--as", peer, ...words.slice(1)] : words;
|
|
@@ -3325,6 +3414,29 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3325
3414
|
// ponytail: one terminal per peer per 30 s, by the clock. The hub cannot see whether the terminal it opened still
|
|
3326
3415
|
// exists, so a peer that never attaches can be opened again after that; track the provider's handle to do better.
|
|
3327
3416
|
const opening = new Map<string, number>();
|
|
3417
|
+
// #286 AC10: a peer with no native TUI (headless Kimi, Qwen) is watched through `ahub watch <peer>`. A start the hub made
|
|
3418
|
+
// itself that really started such a peer opens that view through the same provider, with fixed words and no caller text.
|
|
3419
|
+
// The headless start has already succeeded: a missing or failing provider never fails it, it only reports the command.
|
|
3420
|
+
const watchOpening = new Map<string, number>();
|
|
3421
|
+
const watchSentence = (watch: unknown): string => {
|
|
3422
|
+
const w = watch as { command?: string; opened?: string } | undefined;
|
|
3423
|
+
return w?.command ? (w.opened ? ` A watch pane is open (${w.command}).` : ` Watch it with ${w.command}.`) : "";
|
|
3424
|
+
};
|
|
3425
|
+
async function openWatchPane(peer: string, result: Record<string, unknown>): Promise<Record<string, unknown>> {
|
|
3426
|
+
if (result.ok !== true || result.already || declarations[peer] || !definitionOf(peer)?.capabilities?.watch) return result;
|
|
3427
|
+
const command = `ahub watch ${peer}`;
|
|
3428
|
+
if (Date.now() - (watchOpening.get(peer) ?? 0) < PEER_START_RETRY_MS) return { ...result, watch: { command } };
|
|
3429
|
+
watchOpening.set(peer, Date.now());
|
|
3430
|
+
const opened = await opener.open(`${peer} watch (agent-hub)`, [process.execPath, CLI_ENTRY, "--project", opts.cwd, "watch", peer]).catch((error: Error) => ({ ok: false as const, why: "the terminal provider failed", detail: error.message }));
|
|
3431
|
+
if (!opened.ok) {
|
|
3432
|
+
watchOpening.delete(peer);
|
|
3433
|
+
if (opened.detail) log(`terminal provider for ${peer} watch: ${opened.detail}`);
|
|
3434
|
+
try { notify(`${peer}: started headless; the hub opened no watch pane (${opened.why}). Watch it with ${command}`); } catch { /* the start stands */ }
|
|
3435
|
+
return { ...result, watch: { command } };
|
|
3436
|
+
}
|
|
3437
|
+
try { notify(`${peer}: started headless; the hub opened a watch pane running ${command} (${opened.via})`); } catch { /* the pane is open either way */ }
|
|
3438
|
+
return { ...result, watch: { command, opened: opened.via } };
|
|
3439
|
+
}
|
|
3328
3440
|
/** For the dashboard's Start control: what a start of each peer would do now, and for a TUI where its terminal comes from. */
|
|
3329
3441
|
// 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.
|
|
3330
3442
|
const startPlans = () => STARTABLE_PEERS.filter(startable).map((peer) => {
|
|
@@ -3372,8 +3484,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3372
3484
|
// ponytail: an attached terminal Pi only. A terminal launch that the TUI has not claimed yet reads as offline,
|
|
3373
3485
|
// like one whose terminal was closed, so a headless start in that window replaces it; record when a launch was
|
|
3374
3486
|
// issued to tell the two apart.
|
|
3375
|
-
if (owner
|
|
3376
|
-
return startPeer(peer, definitionOf(peer)?.start.explicitHeadlessMode ? { mode: "headless", ...(resume ?? {}) } : {});
|
|
3487
|
+
if (isPiPeer(owner) && owner.state !== "offline" && owner.mode === "tui") return { ok: false, error: "Pi runs in a terminal; end it there before starting a headless one", command: "ahub pi --headless" };
|
|
3488
|
+
return openWatchPane(peer, await startPeer(peer, definitionOf(peer)?.start.explicitHeadlessMode ? { mode: "headless", ...(resume ?? {}) } : {}));
|
|
3377
3489
|
}
|
|
3378
3490
|
// #559 N1: only the console's own start reopens a frontend on an attached owned-ACP peer. The conductor and the
|
|
3379
3491
|
// dashboard answer `already`, so a repeated call never adds another TUI writer to the live session.
|
|
@@ -3406,11 +3518,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3406
3518
|
if (stopping || recoveryActive()) return { ok: false, error: "recovery or shutdown is holding peer stops; finish the operation before retrying" };
|
|
3407
3519
|
if (typeof peer !== "string" || !PEER_ID.test(peer)) return { ok: false, error: "unknown peer; run ahub status and choose a hub-owned headless peer" };
|
|
3408
3520
|
const owner = bus.peers.get(peer);
|
|
3409
|
-
const stopNotice = definitionOf(peer)?.cli.stopNotice?.(owner
|
|
3521
|
+
const stopNotice = definitionOf(peer)?.cli.stopNotice?.(isWsPeer(owner) ? "tui" : !owner ? startMode(peer) : "headless", owner);
|
|
3410
3522
|
if (stopNotice) return { ok: false, error: stopNotice };
|
|
3411
|
-
if (!(owner
|
|
3412
|
-
if (owner
|
|
3413
|
-
if (owner.state === "offline") return { ok: false, error: `peer is already offline; start it with ${startCommand(peer)} before stopping it` };
|
|
3523
|
+
if (!(isAcpPeer(owner) || isPiPeer(owner) || isLocalPeer(owner) || isReviewerPrintPeer(owner))) return { ok: false, error: "unknown or non-owned peer; run ahub status and choose a hub-owned headless peer" };
|
|
3524
|
+
if (isPiPeer(owner) && owner.mode !== "headless") return { ok: false, error: "Pi belongs to a native TUI terminal; end it in its terminal" };
|
|
3525
|
+
if (owner.state === "offline" && !(isAcpPeer(owner) && owner.pendingOwnedProcess)) return { ok: false, error: `peer is already offline; start it with ${startCommand(peer)} before stopping it` };
|
|
3414
3526
|
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" };
|
|
3415
3527
|
requestedPeerStops.add(peer); // fence delivery, starts and recovery before the first asynchronous stop step
|
|
3416
3528
|
try {
|
|
@@ -3418,7 +3530,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3418
3530
|
if (stopping || recoveryActive() || bus.peers.get(peer) !== owner) return { ok: false, error: "peer or recovery changed; refresh ahub status before retrying" };
|
|
3419
3531
|
const reason = `requested stop: ahub stop ${peer}`; // ids only, never task or message text
|
|
3420
3532
|
// Pi persists a session only after its first message: take live empty-session proof before stopping.
|
|
3421
|
-
if (owner
|
|
3533
|
+
if (isPiPeer(owner) && owner.state === "idle") {
|
|
3422
3534
|
try { await owner.captureResume(); }
|
|
3423
3535
|
catch { log(`requested stop ${peer}: resume evidence unavailable; later start requires verified persisted history`); }
|
|
3424
3536
|
}
|
|
@@ -3458,7 +3570,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3458
3570
|
try {
|
|
3459
3571
|
if (declarations[peer] && args.mode !== undefined && args.mode !== "headless") return { ok: false, error: "declared instances support headless starts only" };
|
|
3460
3572
|
let executionCwd: string;
|
|
3461
|
-
try { executionCwd = resolveInstanceExecutionCwd(opts.cwd, peer, declarations, config.roles[peer] ?? []); }
|
|
3573
|
+
try { executionCwd = resolveInstanceExecutionCwd(opts.cwd, peer, declarations, config.roles[peer] ?? [], adapterCatalog); }
|
|
3462
3574
|
catch (error) { return { ok: false, error: (error as Error).message }; }
|
|
3463
3575
|
executionRoots.set(peer, executionCwd);
|
|
3464
3576
|
applyPendingPermissionDefault(peer);
|
|
@@ -3472,6 +3584,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3472
3584
|
|
|
3473
3585
|
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>> {
|
|
3474
3586
|
const adapter = definitionOf(peer);
|
|
3587
|
+
if (adapter && configuredIds.includes(adapter.kind) && args.sessionId !== undefined
|
|
3588
|
+
&& (typeof args.sessionId !== "string" || !args.sessionId.trim() || Buffer.byteLength(args.sessionId) > 4096 || /[\p{Cc}\p{Cf}]/u.test(args.sessionId))) {
|
|
3589
|
+
return { ok: false, error: "configured ACP session selector is invalid" };
|
|
3590
|
+
}
|
|
3591
|
+
const retainedOwner = bus.peers.get(peer);
|
|
3592
|
+
if (adapter?.permission.genericAcp && isAcpPeer(retainedOwner) && retainedOwner.pendingOwnedProcess && retainedOwner.state === "offline") return { ok: false, error: "configured ACP previous process teardown is unconfirmed; retry ahub stop before starting again" };
|
|
3593
|
+
if (adapter?.permission.genericAcp && (args.model || args.route || args.backend || args.unattended || args.providerProfile || args.sessionFile || args.fresh)) return { ok: false, error: "configured ACP accepts no native launch overrides" };
|
|
3475
3594
|
if (adapter?.reviewer?.unavailable) return { ok: false, error: adapter.reviewer.unavailable };
|
|
3476
3595
|
const preflight = adapter?.start.preflight?.({ instance: peer, form: args.mode });
|
|
3477
3596
|
if (preflight) return { ok: false, error: preflight };
|
|
@@ -3480,7 +3599,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3480
3599
|
const owner = bus.peers.get(peer);
|
|
3481
3600
|
if ((args.mode ?? startMode(peer)) === "tui") {
|
|
3482
3601
|
if (args.mode !== "tui") return { ok: false, error: "Kimi uses its own TUI (pull-only); run ahub kimi in a terminal, or ahub kimi --mode headless", command: "ahub kimi" };
|
|
3483
|
-
if (owner
|
|
3602
|
+
if (isAcpPeer(owner) && owner.state !== "offline") return { ok: false, error: "Kimi is running headless; run ahub stop kimi before starting its TUI" };
|
|
3484
3603
|
clearKimiAdmission();
|
|
3485
3604
|
kimiAdmissionId = crypto.randomUUID();
|
|
3486
3605
|
kimiSessionMode = "tui"; // scoped to this pending launch and its attached session
|
|
@@ -3488,7 +3607,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3488
3607
|
kimiAdmissionExpiry = setTimeout(clearKimiAdmission, 30_000); kimiAdmissionExpiry.unref();
|
|
3489
3608
|
return { ok: true, mode: "tui", pullOnly: true, command: "ahub kimi", admissionId: kimiAdmissionId };
|
|
3490
3609
|
}
|
|
3491
|
-
if (owner
|
|
3610
|
+
if (isWsPeer(owner) && (owner.state !== "offline" || owner.claiming)) return { ok: false, error: "Kimi runs in a terminal; end it there before starting a headless one" };
|
|
3492
3611
|
if (hasKimiHubMcp(opts.cwd)) return { ok: false, error: "Kimi's project MCP file still has agent-hub; a person must run ahub kimi --mode headless to remove it before ACP starts", command: "ahub kimi --mode headless" };
|
|
3493
3612
|
clearKimiAdmission();
|
|
3494
3613
|
}
|
|
@@ -3503,7 +3622,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3503
3622
|
if (args.replaceSession) {
|
|
3504
3623
|
if (recoveryActive()) return { ok: false, error: "recovery is holding session replacement" };
|
|
3505
3624
|
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" };
|
|
3506
|
-
if (!(existing
|
|
3625
|
+
if (!isPiPeer(existing) || !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" };
|
|
3507
3626
|
const selected = validatePiSession(nativeStateDir(peer), executionCwd, args.sessionFile);
|
|
3508
3627
|
const previous = existing.recoveryMetadata();
|
|
3509
3628
|
recordPiReplacement(nativeStateDir(peer), instanceId, previous, selected);
|
|
@@ -3511,7 +3630,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3511
3630
|
mute(existing);
|
|
3512
3631
|
await stopPiForReplacement(existing);
|
|
3513
3632
|
}
|
|
3514
|
-
if (existing
|
|
3633
|
+
if (isPiPeer(existing) && !args.replaceSession) {
|
|
3515
3634
|
let saved = existing.recoveryMetadata();
|
|
3516
3635
|
const launch = saved.launch as Record<string, unknown>;
|
|
3517
3636
|
if ((args.sessionId && saved.sessionId && args.sessionId !== saved.sessionId) || (args.sessionFile && saved.sessionFile && args.sessionFile !== saved.sessionFile)) return { ok: false, error: "Pi already owns a different session; refusing to replace its identity" };
|
|
@@ -3544,10 +3663,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3544
3663
|
if (adapter?.start.reconfigureWhileAttached && existing?.state !== "offline" && existing && (args.model || args.route)) {
|
|
3545
3664
|
if (existing.state === "busy") return { ok: false, error: "local is busy; retry when it is idle" };
|
|
3546
3665
|
} else if (existing && existing.state !== "offline") {
|
|
3547
|
-
|
|
3666
|
+
if (adapter && configuredIds.includes(adapter.kind) && args.sessionId !== undefined && args.sessionId !== adapter.session.currentId(existing)) {
|
|
3667
|
+
return { ok: false, error: "configured ACP is already running a different native session; stop it before selecting another" };
|
|
3668
|
+
}
|
|
3669
|
+
return { ok: true, already: true, ...(isCodexPeer(existing) ? { proxyUrl: existing.proxyUrl } : {}) };
|
|
3548
3670
|
}
|
|
3549
3671
|
if (!adapter?.start.reconfigureWhileAttached) {
|
|
3550
|
-
if (existing
|
|
3672
|
+
if (isPiPeer(existing)) await stopPiForReplacement(existing);
|
|
3551
3673
|
else await existing?.stop();
|
|
3552
3674
|
}
|
|
3553
3675
|
if (adapter?.start.daemonPort === "owned-acp") {
|
|
@@ -3563,7 +3685,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3563
3685
|
if (adapter?.start.acpCommand) {
|
|
3564
3686
|
const cmd = adapter.start.acpCommand(config.kimi_cmd, args.model);
|
|
3565
3687
|
const kimi: AcpPeer = adapter!.start.create!({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: config, permission: permissionMode(peer), mcpServers: [], daemon: DAEMON_PORTS["acp"]({
|
|
3566
|
-
onActivity: item => activityRings[peer]
|
|
3688
|
+
onActivity: item => activityRings[peer]?.append(item),
|
|
3567
3689
|
isPrivateTurn: () => !!turns.get(peer)?.private || contextPii(peer),
|
|
3568
3690
|
cmd, kind: adapter.kind,
|
|
3569
3691
|
permissionMode: permissionMode(peer),
|
|
@@ -3592,11 +3714,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3592
3714
|
if (observation.source !== "usage_update" || observation.shape !== "context-used") return;
|
|
3593
3715
|
const current = contextSession(peer);
|
|
3594
3716
|
if (!current) return;
|
|
3595
|
-
|
|
3596
|
-
observation.contextUsed
|
|
3597
|
-
|
|
3598
|
-
: { source: "acp_usage_update", sessionId, measuredAt: Date.now(), tokens: null, window: null, used: null },
|
|
3599
|
-
current);
|
|
3717
|
+
const reading = readNativeContext(adapter,
|
|
3718
|
+
{ used: observation.contextUsed, size: observation.contextCapacity }, sessionId, Date.now());
|
|
3719
|
+
if (reading) contexts.report(peer, reading, current);
|
|
3600
3720
|
},
|
|
3601
3721
|
onTurnFailure: () => { supervisionTurns.delete(peer); },
|
|
3602
3722
|
mcpServers: [{ name: "agent-hub", command: "bun", args: ["run", SERVER_JS], env: Object.entries(toolEnv(peer, true)).map(([name, value]) => ({ name, value })) }],
|
|
@@ -3610,36 +3730,38 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3610
3730
|
} catch (error) { await kimi.stop().catch(stop => log(`kimi stop after a failed start: ${(stop as Error).message}`)); throw error; }
|
|
3611
3731
|
return { ok: true };
|
|
3612
3732
|
}
|
|
3613
|
-
if (adapter?.transport === "acp" && adapter.start.create) {
|
|
3614
|
-
//
|
|
3733
|
+
if (adapter?.transport === "acp" && adapter.start.create && adapter.start.launch) {
|
|
3734
|
+
// Configured ACP definitions own immutable argv and policy; builtins retain their verified launch port.
|
|
3615
3735
|
const launch = adapter.start.launch({ instance: peer, form: "headless", projectRoot: opts.cwd, stateDir: opts.stateDir, entrypoint: CLI_ENTRY,
|
|
3616
|
-
userArgs: [], unattended: false, env: {}, nativeCommand: config.qwen_cmd, model: args.model });
|
|
3736
|
+
userArgs: [], unattended: false, env: {}, nativeCommand: configuredIds.includes(adapter.kind) ? undefined : config.qwen_cmd, model: args.model });
|
|
3617
3737
|
const mcpServers = [{ name: "agent-hub", command: "bun", args: ["run", SERVER_JS], env: Object.entries(toolEnv(peer, true)).map(([name, value]) => ({ name, value })) }];
|
|
3618
|
-
const permission = permissionMode(peer);
|
|
3738
|
+
const permission = readOnlyPeer(peer) ? "read-only" as const : permissionMode(peer);
|
|
3619
3739
|
const owner = adapter.start.create({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: undefined, permission, mcpServers, daemon: { acp: {
|
|
3620
|
-
onActivity: item => activityRings[peer]
|
|
3740
|
+
onActivity: item => activityRings[peer]?.append(item),
|
|
3621
3741
|
isPrivateTurn: () => !!turns.get(peer)?.private || contextPii(peer),
|
|
3622
|
-
kind: adapter.kind, cmd: [launch.cmd, ...launch.args], permissionMode: permission,
|
|
3742
|
+
kind: adapter.kind, cmd: [launch.cmd, ...launch.args], permissionMode: permission === "read-only" ? "ask" : permission,
|
|
3623
3743
|
...(args.model ? { launchModel: args.model } : {}), ...(args.sessionId ? { resumeSessionId: args.sessionId } : {}),
|
|
3624
3744
|
cwd: executionCwd, watchdogMs: config.watchdog_ms, onPermission,
|
|
3625
3745
|
autoApprove: title => !stopping && HUB_TOOL_TITLES.has(title),
|
|
3626
|
-
log: () => log(
|
|
3746
|
+
log: () => log(`${peer}: ACP runtime diagnostic: details withheld`),
|
|
3627
3747
|
onUsageDiagnostic: (observation, sessionId) => {
|
|
3628
3748
|
if (observation.source !== "usage_update" || observation.shape !== "context-used") return;
|
|
3629
3749
|
const current = contextSession(peer); if (!current) return;
|
|
3630
|
-
|
|
3631
|
-
|
|
3632
|
-
|
|
3750
|
+
const reading = readNativeContext(adapter,
|
|
3751
|
+
{ used: observation.contextUsed, size: observation.contextCapacity }, sessionId, Date.now());
|
|
3752
|
+
if (reading) contexts.report(peer, reading, current);
|
|
3633
3753
|
},
|
|
3634
3754
|
onTurnFailure: () => { supervisionTurns.delete(peer); },
|
|
3635
3755
|
mcpServers,
|
|
3636
3756
|
preamble: sessionRoleContract(peer),
|
|
3637
3757
|
} } });
|
|
3638
|
-
if (!(owner
|
|
3758
|
+
if (!isAcpPeer(owner)) throw new Error("ACP factory did not return its native transport");
|
|
3639
3759
|
recoveryTaskPreface(peer); await ensurePreface(peer);
|
|
3640
3760
|
try { await owner.start(); await reconcileNativeStart(peer, owner); }
|
|
3641
3761
|
catch (error) {
|
|
3642
|
-
await owner.stop().catch(() => log(
|
|
3762
|
+
await owner.stop().catch(() => log(`${peer}: ACP stop after failed startup was not confirmed`));
|
|
3763
|
+
if (configuredIds.includes(adapter.kind) && owner.pendingOwnedProcess) bus.add(owner); // Retain the failed owner for explicit stop retry; never replace its live child.
|
|
3764
|
+
if (configuredIds.includes(adapter.kind)) throw new Error(`${peer}: ACP startup identity, session, permission or owned-group verification failed (details withheld)`);
|
|
3643
3765
|
if (error instanceof QwenIdentityError) throw error;
|
|
3644
3766
|
throw new Error("Qwen could not reach its provider or complete its startup handshake; verify the provider connection and Qwen Code 0.25.0 (details withheld)");
|
|
3645
3767
|
}
|
|
@@ -3652,7 +3774,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3652
3774
|
admitAttach: () => membershipAllowed(peer),
|
|
3653
3775
|
permissionMode: permissionMode(peer),
|
|
3654
3776
|
onTokens: (added) => void addTokens(peer, added),
|
|
3655
|
-
|
|
3777
|
+
contextReader: adapter.budget?.context,
|
|
3778
|
+
onContext: adapter.budget?.context ? (reading) => contexts.report(peer, reading, contextSession(peer)) : undefined,
|
|
3656
3779
|
// #215: before the thread's first turn_start, so the planner can tell which turns belong to which thread.
|
|
3657
3780
|
onThread: (thread, fresh) => { lastCodexThreads.set(peer, thread); event({ type: "native_thread", peer, thread, fresh }); },
|
|
3658
3781
|
onTurn: (native) => {
|
|
@@ -3701,7 +3824,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3701
3824
|
...HUB_TOOLS.map((name) => [`tools.${name}.approval_mode`, '"approve"']),
|
|
3702
3825
|
].flatMap(([k, v]) => ["-c", `mcp_servers.agent-hub.${k}=${v}`]),
|
|
3703
3826
|
preamble: sessionRoleContract(peer),
|
|
3704
|
-
onUsage: (rateLimits, hard) => budget.report(peer,
|
|
3827
|
+
onUsage: (rateLimits, hard) => { const windows = readNativeQuota(adapter, rateLimits); if (windows) budget.report(peer, windows, hard); },
|
|
3705
3828
|
usagePollMs: config.budget.poll_min * 60_000,
|
|
3706
3829
|
cwd: executionCwd,
|
|
3707
3830
|
watchdogMs: config.watchdog_ms,
|
|
@@ -3727,7 +3850,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3727
3850
|
staySwitch: () => activeRouting(),
|
|
3728
3851
|
onRoute: record => event({ type: "route", peer, ...record }),
|
|
3729
3852
|
efficientWaitMs: activeRouting().pi.efficient_wait_ms,
|
|
3730
|
-
underBudget: () => { const current = bus.peers.get(peer); return current
|
|
3853
|
+
underBudget: () => { const current = bus.peers.get(peer); return isPiPeer(current) && tasks.budgetApplies(current.budgetEnvelopes, peer); },
|
|
3731
3854
|
onCooldown: record => {
|
|
3732
3855
|
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"}`);
|
|
3733
3856
|
event({ type: "cooldown", peer, ...record });
|
|
@@ -4011,9 +4134,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4011
4134
|
return { ok: false, error: `unknown peer "${peer}"` };
|
|
4012
4135
|
}
|
|
4013
4136
|
|
|
4014
|
-
async function admitPiRequest(peer
|
|
4137
|
+
async function admitPiRequest(peer: string): Promise<{ allowed: boolean; reason?: string; remainingMs?: number }> {
|
|
4015
4138
|
const current = bus.peers.get(peer);
|
|
4016
|
-
if (!(current
|
|
4139
|
+
if (!isPiPeer(current)) return { allowed: false, reason: "Pi owner is not active" };
|
|
4017
4140
|
const decisions = tasks.admitExecutionEnvelopes(current.budgetEnvelopes, peer, "model_calls");
|
|
4018
4141
|
const denied = decisions.find(d => !d.allowed);
|
|
4019
4142
|
const stop = denied ? current.recordBudgetStop(denied) : undefined;
|
|
@@ -4046,7 +4169,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4046
4169
|
} else {
|
|
4047
4170
|
if (budget.record(id)) return { ok: false, error: `${id} is paused by the budget coordinator until its window resets (ahub budget); to override: ahub budget resume ${id}` };
|
|
4048
4171
|
const peer = bus.peers.get(id);
|
|
4049
|
-
if (peer
|
|
4172
|
+
if (isLocalPeer(peer)) {
|
|
4050
4173
|
const generation = pauseGeneration.get(id);
|
|
4051
4174
|
const launch = peer.recoveryMetadata().launch as { route?: string; model: string };
|
|
4052
4175
|
try { await validateLocalChoice(launch.route, launch.model); }
|
|
@@ -4067,6 +4190,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4067
4190
|
|
|
4068
4191
|
const conductor = new Conductor(conductorHolds, {
|
|
4069
4192
|
roles: () => config.roles, capabilities: () => config.capabilities, instances: () => declarations,
|
|
4193
|
+
adapterCatalog,
|
|
4070
4194
|
status: () => {
|
|
4071
4195
|
const quota = budget.status();
|
|
4072
4196
|
const ids = [...new Set([...bus.knownPeers(), ...Object.keys(quota), ...conductorHolds.list().map(h => h.peer)])];
|
|
@@ -4099,7 +4223,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4099
4223
|
pause: peer => { pauseGeneration.set(peer, (pauseGeneration.get(peer) ?? 0) + 1); bus.pause(peer); writeStatus(); },
|
|
4100
4224
|
validateRelease: async peer => {
|
|
4101
4225
|
const owner = bus.peers.get(peer);
|
|
4102
|
-
if (!(owner
|
|
4226
|
+
if (!isLocalPeer(owner)) return;
|
|
4103
4227
|
const generation = pauseGeneration.get(peer);
|
|
4104
4228
|
const launch = owner.recoveryMetadata().launch as { route?: string; model: string };
|
|
4105
4229
|
await validateLocalChoice(launch.route, launch.model);
|
|
@@ -4274,7 +4398,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4274
4398
|
function roleHolders(candidate: HubConfig): { planners: string[]; conductors: string[] } {
|
|
4275
4399
|
// A valid transfer may briefly have two conductors while its leaves are planned. Validate the final map later.
|
|
4276
4400
|
const policy = Object.fromEntries(Object.entries(candidate.roles).map(([peer, roles]) => [peer, roles.filter(role => role !== "conductor")]));
|
|
4277
|
-
const settled = resolveSettledRoles(policy, candidate.planner, peer => rosterAllows(candidate.roster, peer, candidate.pi.enabled, declarations));
|
|
4401
|
+
const settled = resolveSettledRoles(policy, candidate.planner, peer => rosterAllows(candidate.roster, peer, candidate.pi.enabled, declarations, adapterCatalog));
|
|
4278
4402
|
return { planners: settled.planners, conductors: Object.keys(candidate.roles).filter(peer => candidate.roles[peer]!.includes("conductor")).sort() };
|
|
4279
4403
|
}
|
|
4280
4404
|
function prepareConfiguration(sources: ConfigSources, authority: SettingAuthority, scope?: string, confirmed: readonly string[] = []) {
|
|
@@ -4288,7 +4412,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4288
4412
|
const prefix = layer.name.endsWith(".toml") ? "routing" : "";
|
|
4289
4413
|
for (const path of declaredPaths(layer.raw, prefix)) paths.add(path);
|
|
4290
4414
|
}
|
|
4291
|
-
if (!opts.config) for (const change of configChanges(desiredConfig, fileConfig)) paths.add(change.key);
|
|
4415
|
+
if (!opts.config) for (const change of configChanges(desiredConfig, fileConfig, [{ catalog: adapterCatalog, instances: declarations }, configIdentityScope(acceptedSources.identity), configIdentityScope(sources.identity)])) paths.add(change.key);
|
|
4292
4416
|
for (const key of paths) {
|
|
4293
4417
|
if (scope && key !== scope) continue;
|
|
4294
4418
|
const route = key.startsWith("routing."), path = (route ? key.slice(8) : key).split(".");
|
|
@@ -4303,10 +4427,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4303
4427
|
const after = valueAt(route ? sources.routing : opts.config && !current ? embeddedConfig : fileConfig, path);
|
|
4304
4428
|
const rawAfter = current ? valueAt(current.raw, path) : undefined;
|
|
4305
4429
|
if (!rawChanged && JSON.stringify(before) === JSON.stringify(after)) continue;
|
|
4430
|
+
// Compare the running declaration, never a pending desired value accepted by an earlier reload.
|
|
4306
4431
|
// Instance declarations are restart-bound. Their derived role/permission/start defaults cannot leak into
|
|
4307
4432
|
// the live catalog while the old identities, paths and account callbacks still own the daemon.
|
|
4308
4433
|
const boundId = ["roles", "permission_modes", "peers", "permission_default_sources"].includes(path[0] ?? "") ? path[1] : undefined;
|
|
4309
|
-
|
|
4434
|
+
const boundKind = boundId ? declarations[boundId]?.kind ?? boundId : undefined;
|
|
4435
|
+
if (!route && boundId && (JSON.stringify(config.instances?.[boundId]) !== JSON.stringify(fileConfig.instances?.[boundId]) || JSON.stringify(boundKind ? config.adapters?.[boundKind] : undefined) !== JSON.stringify(boundKind ? fileConfig.adapters?.[boundKind] : undefined))) {
|
|
4310
4436
|
rejected.push({ key, reason: "instance declaration changed; its derived defaults apply together after daemon restart" });
|
|
4311
4437
|
continue;
|
|
4312
4438
|
}
|
|
@@ -4370,7 +4496,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4370
4496
|
}
|
|
4371
4497
|
candidate.retired = fileConfig.retired;
|
|
4372
4498
|
routing.retired = sources.routing.retired;
|
|
4373
|
-
const changes = configChanges({ ...config, roles: policyRoles }, candidate);
|
|
4499
|
+
const changes = configChanges({ ...config, roles: policyRoles }, candidate, [{ catalog: adapterCatalog, instances: declarations }, configIdentityScope(acceptedSources.identity), configIdentityScope(sources.identity)]);
|
|
4374
4500
|
const running = structuredClone({ ...config, roles: policyRoles });
|
|
4375
4501
|
for (const change of changes) if (change.applies !== "restart") putConfig(running, change.key, change.after);
|
|
4376
4502
|
running.retired = fileConfig.retired;
|
|
@@ -4404,7 +4530,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4404
4530
|
const texts = configSourceTexts(opts.cwd), next = configSourceTexts(scratch);
|
|
4405
4531
|
texts["config.json"] = next["config.json"];
|
|
4406
4532
|
texts["config.local.json"] = next["config.local.json"];
|
|
4407
|
-
const prepared = prepareConfiguration(parseConfigSources(texts), authority, scope);
|
|
4533
|
+
const prepared = prepareConfiguration(parseConfigSources(texts, undefined, opts.cwd), authority, scope);
|
|
4408
4534
|
const refusal = prepared.rejected.find(item => item.key === scope);
|
|
4409
4535
|
if (refusal) throw new Error(`${refusal.key}: ${refusal.reason}`);
|
|
4410
4536
|
return prepared.fileConfig;
|
|
@@ -4412,7 +4538,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4412
4538
|
function routingWriteCheck(authority: SettingAuthority, overlay: import("./routing.ts").RoutingOverlay | undefined, scope?: string): void {
|
|
4413
4539
|
const texts = configSourceTexts(opts.cwd);
|
|
4414
4540
|
texts["routing.local.toml"] = overlay === undefined ? null : overlayToml(overlay);
|
|
4415
|
-
const prepared = prepareConfiguration(parseConfigSources(texts), authority, scope);
|
|
4541
|
+
const prepared = prepareConfiguration(parseConfigSources(texts, undefined, opts.cwd), authority, scope);
|
|
4416
4542
|
const refusal = prepared.rejected.find(item => item.key === scope);
|
|
4417
4543
|
if (refusal) throw new Error(`${refusal.key}: ${refusal.reason}`);
|
|
4418
4544
|
}
|
|
@@ -4429,7 +4555,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4429
4555
|
const texts = configSourceTexts(opts.cwd);
|
|
4430
4556
|
fingerprint = configFingerprint(texts);
|
|
4431
4557
|
if (confirm !== undefined && (typeof confirm !== "string" || authority !== "terminal" || scope || !reloadConfirmation || reloadConfirmation.token !== confirm || reloadConfirmation.fingerprint !== fingerprint || reloadConfirmation.revision !== configRevision)) return { ok: false, error: "configuration confirmation is stale or changed; run ahub settings reload again" };
|
|
4432
|
-
const sources = parseConfigSources(texts);
|
|
4558
|
+
const sources = parseConfigSources(texts, undefined, opts.cwd);
|
|
4433
4559
|
const prepared = prepareConfiguration(sources, authority, scope, confirm === undefined ? [] : reloadConfirmation!.keys);
|
|
4434
4560
|
if (confirm !== undefined && prepared.rejected.some(item => reloadConfirmation!.keys.includes(item.key))) return { ok: false, error: "configuration trust changed; run ahub settings reload again" };
|
|
4435
4561
|
if (scope && prepared.rejected.some(item => item.key === scope)) return { ok: false, error: prepared.rejected.find(item => item.key === scope)!.reason };
|
|
@@ -4463,7 +4589,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4463
4589
|
desiredRouting = structuredClone(prepared.routing);
|
|
4464
4590
|
acknowledgeConfiguration(sources, prepared.accepted);
|
|
4465
4591
|
if (priorDesired !== JSON.stringify([desiredConfig, desiredRouting, acceptedSources.trustLayers])) configRevision++;
|
|
4466
|
-
pendingRestart = configChanges({ ...config, roles: policyRoles }, candidate).filter(change => change.applies === "restart").map(change => ({ key: change.key, reason: change.reason! }));
|
|
4592
|
+
pendingRestart = configChanges({ ...config, roles: policyRoles }, candidate, [{ catalog: adapterCatalog, instances: declarations }, configIdentityScope(acceptedSources.identity), configIdentityScope(sources.identity)]).filter(change => change.applies === "restart").map(change => ({ key: change.key, reason: change.reason! }));
|
|
4467
4593
|
pendingRestart.push(...routingChanges.filter(key => configApply(key).applies === "restart").map(key => ({ key, reason: configApply(key).reason! })));
|
|
4468
4594
|
retainedConfig = retainedConfigAfterReload(sources, prepared.fileConfig, prepared.rejected, scope);
|
|
4469
4595
|
storedConfigCache = undefined;
|
|
@@ -4517,9 +4643,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4517
4643
|
let routingProblem: string | undefined;
|
|
4518
4644
|
try { parseRouting(routingText(opts.cwd), readOverlay(opts.cwd)); } catch (error) { routingProblem = (error as Error).message; }
|
|
4519
4645
|
const rows = settingRows({
|
|
4520
|
-
instances: declarations, cwd: opts.cwd, routing: activeRouting(), ...(routingProblem ? { routingProblem } : {}), ...(stored?.ignored ? { ignored: stored.ignored } : {}),
|
|
4646
|
+
instances: declarations, adapterCatalog, cwd: opts.cwd, routing: activeRouting(), ...(routingProblem ? { routingProblem } : {}), ...(stored?.ignored ? { ignored: stored.ignored } : {}),
|
|
4521
4647
|
running: (path) => path[0] === "roster" ? onRoster(path[1]!) : path[0] === "tasks" && path[1] === "wip_limits" ? tasks.wipLimit(path[2]!) : valueAt(config, path),
|
|
4522
|
-
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,
|
|
4648
|
+
next: stored ? (path) => path[0] === "roster" ? rosterAllows(stored.roster, path[1]!, stored.pi.enabled, declarations, adapterCatalog) : 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,
|
|
4523
4649
|
roles: roleState,
|
|
4524
4650
|
mode: (peer) => {
|
|
4525
4651
|
const owner = bus.peers.get(peer), attached = !!owner && owner.state !== "offline";
|
|
@@ -4558,14 +4684,14 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4558
4684
|
if (owner?.state === "busy" && !now) return `${peer} is in a turn; retry when it is idle, or pass --now`;
|
|
4559
4685
|
return undefined;
|
|
4560
4686
|
}
|
|
4561
|
-
function codexResumeCommand(peer
|
|
4562
|
-
const owner = bus.peers.get(peer), thread = owner
|
|
4687
|
+
function codexResumeCommand(peer: string): string {
|
|
4688
|
+
const owner = bus.peers.get(peer), thread = isCodexPeer(owner) ? owner.thread || lastCodexThreads.get(peer) : lastCodexThreads.get(peer);
|
|
4563
4689
|
const command = declarations[peer] ? `ahub codex --as ${peer} resume` : "ahub codex resume";
|
|
4564
4690
|
return thread && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(thread) ? `${command} ${thread}` : command;
|
|
4565
4691
|
}
|
|
4566
4692
|
function retainedTerminalNotice(peer: string): string | undefined {
|
|
4567
4693
|
const owner = bus.peers.get(peer);
|
|
4568
|
-
return definitionOf(peer)?.cli.retainedTerminalNotice?.(peer, owner
|
|
4694
|
+
return definitionOf(peer)?.cli.retainedTerminalNotice?.(peer, isPiPeer(owner) ? owner.mode : undefined, codexResumeCommand(peer));
|
|
4569
4695
|
}
|
|
4570
4696
|
|
|
4571
4697
|
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>> {
|
|
@@ -4575,7 +4701,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4575
4701
|
const terminalNotice = retainedTerminalNotice(peer);
|
|
4576
4702
|
let handoff: Awaited<ReturnType<Tasks["reassignForRoster"]>> = [];
|
|
4577
4703
|
try {
|
|
4578
|
-
const next = rosterCandidate(def, value, undo), before = onRoster(peer), after = rosterAllows(next, peer, config.pi.enabled, declarations);
|
|
4704
|
+
const next = rosterCandidate(def, value, undo), before = onRoster(peer), after = rosterAllows(next, peer, config.pi.enabled, declarations, adapterCatalog);
|
|
4579
4705
|
if (!after) {
|
|
4580
4706
|
const refusal = rosterRemovalRefusal(peer, now);
|
|
4581
4707
|
if (refusal) return { ok: false, error: refusal };
|
|
@@ -4594,9 +4720,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4594
4720
|
if (!after) {
|
|
4595
4721
|
for (const pending of permissions.values()) if (pending.peer === peer) pending.done(undefined);
|
|
4596
4722
|
const owner = bus.peers.get(peer);
|
|
4597
|
-
if (owner
|
|
4598
|
-
else if (owner
|
|
4599
|
-
else if (owner
|
|
4723
|
+
if (isWsPeer(owner)) { owner.detachForRoster(); if (definitionOf(peer)?.attach?.admission) clearKimiAdmission(); }
|
|
4724
|
+
else if (isCodexPeer(owner)) owner.detachForRoster(config.permission_modes[peer] === "never-ask" ? "ask" : config.permission_modes[peer] ?? "ask");
|
|
4725
|
+
else if (isPiPeer(owner) && owner.mode === "tui") await owner.detachForRoster();
|
|
4600
4726
|
else if (owner && owner.state !== "offline") {
|
|
4601
4727
|
const result = await stopPeer(peer, true);
|
|
4602
4728
|
if (result.ok !== true) throw new Error(`roster saved; ${peer} stop is unconfirmed; permission reset and task handoff were not attempted; inspect ahub status and reapply ${def.key} false to retry`);
|
|
@@ -4609,7 +4735,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4609
4735
|
handoff = await tasks.reassignForRoster(peer);
|
|
4610
4736
|
} else {
|
|
4611
4737
|
const owner = bus.peers.get(peer);
|
|
4612
|
-
if (owner
|
|
4738
|
+
if (isPiPeer(owner) && owner.mode === "tui") await owner.resumeFromRoster();
|
|
4613
4739
|
}
|
|
4614
4740
|
const reloaded = await reloadConfig(authority, `roster.${peer}`);
|
|
4615
4741
|
if (reloaded.ok !== true) return { ...reloaded, saved: true };
|
|
@@ -4724,14 +4850,14 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4724
4850
|
const { def, value } = input;
|
|
4725
4851
|
if (def.group === "Roster") {
|
|
4726
4852
|
try {
|
|
4727
|
-
const peer = def.path[1]!, next = rosterCandidate(def, value), on = rosterAllows(next, peer, config.pi.enabled, declarations);
|
|
4853
|
+
const peer = def.path[1]!, next = rosterCandidate(def, value), on = rosterAllows(next, peer, config.pi.enabled, declarations, adapterCatalog);
|
|
4728
4854
|
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 }) };
|
|
4729
4855
|
} catch (error) { return { ok: false, error: sanitize((error as Error).message) }; }
|
|
4730
4856
|
}
|
|
4731
4857
|
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."] };
|
|
4732
4858
|
if (def.group === "Permissions") {
|
|
4733
4859
|
const peer = def.path.at(-1)!;
|
|
4734
|
-
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 } : {}) };
|
|
4860
|
+
return { ok: true, lines: [permissionBoundary(peer, declarations, adapterCatalog), ...(isPermissionMode(value) ? [`${value}: ${permissionGrant(peer, value, declarations, adapterCatalog)}`] : ["inherit: the shared file's default, or ask"])], ...(value === "never-ask" || value === null && restoredValue(opts.cwd, opts.stateDir, def, true) === "never-ask" ? { confirm: peer } : {}) };
|
|
4735
4861
|
}
|
|
4736
4862
|
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"}`] };
|
|
4737
4863
|
try {
|
|
@@ -4826,7 +4952,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4826
4952
|
// #269: the hub's own start of a peer from the closed list, in the mode a person set. No argument reaches a command.
|
|
4827
4953
|
if (!peer()) return bad;
|
|
4828
4954
|
const result = await hubStart(a.peer, "the dashboard");
|
|
4829
|
-
if (result.ok === true) return { ok: true, text: result.already ? `${a.peer} is already attached.` : result.opened ? `Opened a terminal running ${result.command}; ${a.peer} attaches when its TUI is ready.` : `${a.peer} started (headless)
|
|
4955
|
+
if (result.ok === true) return { ok: true, text: result.already ? `${a.peer} is already attached.` : result.opened ? `Opened a terminal running ${result.command}; ${a.peer} attaches when its TUI is ready.` : `${a.peer} started (headless).${watchSentence(result.watch)}` };
|
|
4830
4956
|
// A refusal the hub worded itself carries the command to run by hand; an adapter's own failure text stays in the log.
|
|
4831
4957
|
if (typeof result.command === "string" || typeof a.peer === "string" && !membershipAllowed(a.peer)) return { ok: false, error: sanitize(String(result.error)) };
|
|
4832
4958
|
log(`dashboard start ${a.peer} failed: ${String(result.error)}`);
|
|
@@ -4862,7 +4988,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4862
4988
|
...(checkPending.size !== 0 ? ["isolated checks queued or running"] : []),
|
|
4863
4989
|
...(piCallsInFlight() !== 0 ? ["Pi tool calls in flight"] : []),
|
|
4864
4990
|
...(releasing ? ["ready tasks being released"] : []),
|
|
4865
|
-
...([...bus.peers].filter(([, peer]) => peer
|
|
4991
|
+
...([...bus.peers].filter(([, peer]) => isPiPeer(peer) && peer.state !== "busy" && !peer.recoveryReady).map(([id, peer]) => !membershipAllowed(id) && isPiPeer(peer) && peer.mode === "tui" ? `${id} is not settled; end the retained Pi TUI, then prepare again` : `${id} is not settled`)),
|
|
4866
4992
|
],
|
|
4867
4993
|
integrity: { current: integrityAs(restored?.integrity), ...(restored?.integrity ? { expected: restored.integrity } : {}) },
|
|
4868
4994
|
peers: Object.fromEntries([...(recoveryPeerSnapshot ?? []), ...Object.values(recoveryPeers()).filter((peer) => !(recoveryPeerSnapshot ?? []).some((saved) => saved.id === peer.id))].map((peer) => {
|
|
@@ -4883,6 +5009,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4883
5009
|
const incompatibleRecoveryTarget = () => {
|
|
4884
5010
|
const cancelled = incompatibleCancellationTarget();
|
|
4885
5011
|
if (cancelled) return cancelled;
|
|
5012
|
+
if (configuredIds.length && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("configured_acp"))) return "target lacks configured_acp; project adapter definitions cannot be preserved by that daemon";
|
|
4886
5013
|
if (extraIds.length && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("peer_instances"))) return "target lacks peer_instances; declared instance identities cannot be restored by that daemon";
|
|
4887
5014
|
if (board.releases().some(release => release.train && ["active", "frozen"].includes(release.state)) && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("release_trains"))) return "target lacks release_trains; active or frozen train safeguards cannot be preserved";
|
|
4888
5015
|
if ((extraIds.some(id => !!definitionOf(id)?.reviewer) || config.review.pool !== undefined) && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("reviewer_pool"))) return "target lacks reviewer_pool; fresh review-only variants and pool policy cannot be preserved";
|
|
@@ -4894,7 +5021,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4894
5021
|
}
|
|
4895
5022
|
|
|
4896
5023
|
if (msg.op === "inspect" || msg.op === "prepare" || msg.op === "commit") {
|
|
4897
|
-
for (const pi of bus.peers.values()) if (pi
|
|
5024
|
+
for (const pi of bus.peers.values()) if (isPiPeer(pi) && pi.state === "idle") {
|
|
4898
5025
|
try { await pi.captureResume(); } catch (error) { return recoveryError((error as Error).message); }
|
|
4899
5026
|
if (checkTarget) { const error = incompatibleRecoveryTarget(); if (error) return recoveryError(error); }
|
|
4900
5027
|
}
|
|
@@ -5004,7 +5131,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5004
5131
|
return recoveryError("unknown recovery operation");
|
|
5005
5132
|
}
|
|
5006
5133
|
|
|
5007
|
-
function claudePermissionLaunch(peer
|
|
5134
|
+
function claudePermissionLaunch(peer: string): { launchId: string; unattended: boolean; hookPurpose?: string } | undefined {
|
|
5008
5135
|
try {
|
|
5009
5136
|
const row = JSON.parse(readFileSync(join(opts.stateDir, claudeStateFile("claude-launch", peer)), "utf8"));
|
|
5010
5137
|
if (row.instanceId !== instanceId || row.permissionHook !== true || typeof row.launchId !== "string" || !row.launchId) return undefined;
|
|
@@ -5013,15 +5140,16 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5013
5140
|
}
|
|
5014
5141
|
async function changePermission(peer: unknown, mode: unknown, confirmed: unknown): Promise<Record<string, unknown>> {
|
|
5015
5142
|
if (!isSupportedPeer(peer)) return { ok: false, error: "unknown permission peer" };
|
|
5143
|
+
if (readOnlyPeer(peer)) return { ok: false, error: "read-only ceiling is locked for this instance" };
|
|
5016
5144
|
if (definitionOf(peer)?.permission.unmanaged) return { ok: false, error: definitionOf(peer)!.permission.unmanaged };
|
|
5017
|
-
if (definitionOf(peer)?.permission.unmanagedForms?.includes("tui") && (bus.peers.get(peer)
|
|
5145
|
+
if (definitionOf(peer)?.permission.unmanagedForms?.includes("tui") && (isWsPeer(bus.peers.get(peer)) || (!bus.peers.has(peer) && startMode(peer) === "tui"))) return { ok: false, error: definitionOf(peer)!.permission.unmanagedNote };
|
|
5018
5146
|
if (requestedPeerStops.has(peer)) return { ok: false, error: "peer stop is in progress; wait for ahub status before changing its mode" };
|
|
5019
5147
|
const owner = bus.peers.get(peer);
|
|
5020
5148
|
if (mode === "ask" && (!owner || owner.state === "offline")) {
|
|
5021
5149
|
// A Codex proxy outlives its TUI and keeps its own copy of the mode: clear that too, or the next TUI that attaches
|
|
5022
5150
|
// to it would run a mode the status no longer shows.
|
|
5023
5151
|
// It is never refused here: a leftover non-ask mode must always be undoable while the peer is away.
|
|
5024
|
-
const note = owner
|
|
5152
|
+
const note = isCodexPeer(owner) && !owner.clearPermissionMode() ? "The detached session's own approval policy was never reported, so its thread keeps what it last got; a new ahub codex session starts in ask" : undefined;
|
|
5025
5153
|
if (note) log(`permission mode codex: ${note}`);
|
|
5026
5154
|
const from = permissionMode(peer);
|
|
5027
5155
|
permissionModes.set(peer, "ask"); permissionModeSources.set(peer, "human runtime command"); pendingPermissionDefaults.delete(peer);
|
|
@@ -5029,22 +5157,22 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5029
5157
|
log(`permission mode ${peer}: ${from} -> ask (offline hub choice cleared)`); writeStatus();
|
|
5030
5158
|
return { ok: true, peer, permissionMode: "ask", ...(note ? { note } : {}) };
|
|
5031
5159
|
}
|
|
5032
|
-
if (owner
|
|
5160
|
+
if (isCodexPeer(owner) && !owner.proxyAttached) return { ok: false, error: "Codex TUI is not behind the hub proxy; reconnect with ahub codex" };
|
|
5033
5161
|
if (!owner || owner.state === "offline") return { ok: false, error: `${peer} is not attached; start it through ahub first` };
|
|
5034
5162
|
if (mode === undefined) return { ok: true, peer, permissionMode: permissionDisplay(peer, owner) };
|
|
5035
5163
|
if (!isPermissionMode(mode)) return { ok: false, error: "mode must be ask, ask-when-needed or never-ask" };
|
|
5036
5164
|
if (mode === "never-ask" && confirmed !== true) return { ok: false, error: "never-ask needs --yes or console confirmation" };
|
|
5037
5165
|
if (definitionOf(peer)?.transport === "claude-channel") {
|
|
5038
5166
|
const launch = claudePermissionLaunch(peer);
|
|
5039
|
-
if (!launch || !(owner
|
|
5167
|
+
if (!launch || !isWsPeer(owner) || 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" };
|
|
5040
5168
|
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" };
|
|
5041
|
-
} else if (owner
|
|
5169
|
+
} else if (isCodexPeer(owner)) {
|
|
5042
5170
|
if (!owner.proxyAttached) return { ok: false, error: "Codex TUI is not behind the hub proxy; reconnect with ahub codex" };
|
|
5043
5171
|
if (nativeUnattended(peer)) return { ok: false, error: "Codex started --unattended already skips prompts; run ahub permission codex ask after it stops to clear the hub choice, then restart without --unattended" };
|
|
5044
5172
|
}
|
|
5045
5173
|
const from = permissionMode(peer);
|
|
5046
5174
|
try {
|
|
5047
|
-
if (owner
|
|
5175
|
+
if (isAcpPeer(owner) || isCodexPeer(owner)) await owner.setPermissionMode(mode);
|
|
5048
5176
|
} catch (error) { return { ok: false, error: (error as Error).message }; }
|
|
5049
5177
|
if (bus.peers.get(peer) !== owner || bus.stateOf(peer) === "offline") return { ok: false, error: `${peer} session changed; inspect status and retry` };
|
|
5050
5178
|
permissionModes.set(peer, mode);
|
|
@@ -5057,6 +5185,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5057
5185
|
}
|
|
5058
5186
|
|
|
5059
5187
|
async function confirmPermissionDefault(peer: unknown, confirmed: unknown): Promise<Record<string, unknown>> {
|
|
5188
|
+
if (typeof peer === "string" && readOnlyPeer(peer)) return { ok: false, error: "read-only ceiling is locked for this instance" };
|
|
5060
5189
|
if (typeof peer !== "string" || !pendingPermissionDefaults.has(peer)) return { ok: false, error: "no pending permission default for this peer" };
|
|
5061
5190
|
const source = pendingPermissionDefaults.get(peer)!;
|
|
5062
5191
|
if (confirmed !== true) {
|
|
@@ -5066,7 +5195,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5066
5195
|
}
|
|
5067
5196
|
const owner = bus.peers.get(peer);
|
|
5068
5197
|
if (definitionOf(peer)?.permission.trackUnattended && nativeUnattended(peer)) return { ok: false, error: "Codex started --unattended cannot confirm a permission default; run ahub permission codex ask after it stops, or restart without --unattended" };
|
|
5069
|
-
if (!starting.has(peer) && owner
|
|
5198
|
+
if (!starting.has(peer) && isCodexPeer(owner) && owner.state === "offline") return { ok: false, error: "connect the Codex TUI through the hub proxy before confirming its default" };
|
|
5070
5199
|
if (!starting.has(peer) && owner && owner.state !== "offline") {
|
|
5071
5200
|
const result = await changePermission(peer, "never-ask", true);
|
|
5072
5201
|
if (!result.ok) return result;
|
|
@@ -5097,9 +5226,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5097
5226
|
const result = await permissionChange(peer, async () => {
|
|
5098
5227
|
if (stopping) return { ok: false, error: "hub is stopping" };
|
|
5099
5228
|
const selected = permissionMode(peer);
|
|
5100
|
-
if (owner
|
|
5101
|
-
if (!(
|
|
5102
|
-
if (owner
|
|
5229
|
+
if (isCodexPeer(owner) && nativeUnattended(peer) && selected !== "ask") return { ok: false, error: "Codex --unattended cannot take a non-ask mode; run ahub permission codex ask, or restart without --unattended" };
|
|
5230
|
+
if (!readOnlyPeer(peer) && !(isAcpPeer(owner) && owner.permissionModeState === "unmanaged") && owner.getPermissionMode() !== selected) {
|
|
5231
|
+
if (isCodexPeer(owner) && !owner.proxyAttached) owner.setStartupPermissionMode(selected);
|
|
5103
5232
|
else await owner.setPermissionMode(selected);
|
|
5104
5233
|
}
|
|
5105
5234
|
// The bus may immediately drain at add(): do not expose an idle adapter until its current choice is applied.
|
|
@@ -5136,7 +5265,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5136
5265
|
if (!PEER_ID.test(c.peer) || c.peer === USER || c.peer === "hub") return sock.close(4403, "peer id is reserved or malformed");
|
|
5137
5266
|
if (!membershipAllowed(c.peer)) return sock.close(4000, "peer is off roster; retry when a person adds it");
|
|
5138
5267
|
} else {
|
|
5139
|
-
c.peer = String(msg.peer ??
|
|
5268
|
+
c.peer = String(msg.peer ?? defaultChannelPeer(undefined));
|
|
5140
5269
|
if (definitionOf(c.peer)?.transport === "ws-pull" && msg.channel !== false) return sock.close(4403, "this native peer supports pull-only attachment; use hub_inbox");
|
|
5141
5270
|
const kimiPull = !!definitionOf(c.peer)?.attach?.admission && (kimiSessionMode ?? startMode(c.peer)) === "tui" && msg.channel === false;
|
|
5142
5271
|
if (!PEER_ID.test(c.peer) || (RESERVED_IDS.has(c.peer) && !kimiPull)) return sock.close(4403, definitionOf(c.peer)?.attach?.admission?.reservedRefusal ?? "peer id is reserved or malformed");
|
|
@@ -5152,9 +5281,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5152
5281
|
if (typeof msg.launchId === "string" && msg.launchId.length <= 128) c.launchId = msg.launchId;
|
|
5153
5282
|
}
|
|
5154
5283
|
let peer = bus.peers.get(c.peer);
|
|
5155
|
-
if (kimiPull && peer
|
|
5284
|
+
if (kimiPull && isAcpPeer(peer) && peer.state === "offline") peer = undefined;
|
|
5156
5285
|
if (!peer) bus.add((peer = new WsPeer(c.peer)));
|
|
5157
|
-
if (!(peer
|
|
5286
|
+
if (!isWsPeer(peer)) return sock.close(4409, "peer id is taken by a hub-managed adapter");
|
|
5158
5287
|
const ws = peer;
|
|
5159
5288
|
if (msg.standby === true && ws.standbyHeld(msg.channel === false)) return sock.close(4000, "another session holds or is claiming this peer");
|
|
5160
5289
|
// Without pushes it stands by, as a replaced session does, and never strands a channel session's deliveries.
|
|
@@ -5220,7 +5349,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5220
5349
|
}
|
|
5221
5350
|
case "delivery_receipt": {
|
|
5222
5351
|
const peer = c.peer ? bus.peers.get(c.peer) : undefined;
|
|
5223
|
-
if (c.role !== "peer" || !(peer
|
|
5352
|
+
if (c.role !== "peer" || !isWsPeer(peer) || typeof msg.deliveryId !== "string" || !peer.ownsDelivery(sock, msg.generation, msg.deliveryId) || !["accepted", "needs_review", "failed_safe"].includes(msg.state)) return void reply({ ok: false, error: "invalid delivery receipt" });
|
|
5224
5353
|
if (msg.state === "failed_safe" && (!peer.pullOnly || bus.queueShow(msg.deliveryId)?.state !== "dispatching")) return void reply({ ok: false, error: "safe failure requires an unseen push to a pull-only session" });
|
|
5225
5354
|
const reason = typeof msg.reason === "string" ? sanitize(msg.reason).replace(/\s+/g, " ").trim().slice(0, 300) : "";
|
|
5226
5355
|
bus.deliveryReceipt(peer.id, { id: msg.deliveryId, state: msg.state, ...(msg.state !== "accepted" ? { reason: reason || "Claude bridge could not confirm notification delivery" } : {}) });
|
|
@@ -5230,7 +5359,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5230
5359
|
// The attached session can explicitly claim queued work even during a native turn (#493).
|
|
5231
5360
|
// Pull and push share holds and journal settlement; work read here is completed, never pushed again.
|
|
5232
5361
|
const peer = c.peer ? bus.peers.get(c.peer) : undefined;
|
|
5233
|
-
if (c.role !== "peer" || !(peer
|
|
5362
|
+
if (c.role !== "peer" || !isWsPeer(peer) || !peer.owns(sock)) return void reply({ ok: false, error: "not attached to the hub yet; retry hub_inbox in a moment" });
|
|
5234
5363
|
try {
|
|
5235
5364
|
const envs = bus.pull(peer.id);
|
|
5236
5365
|
if (!envs) return void reply({ ok: false, error: queueHold(peer.id) ?? (bus.isRecoveryHeld ? "recovery is holding deliveries" : pauseReason(peer.id)) });
|
|
@@ -5242,7 +5371,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5242
5371
|
}
|
|
5243
5372
|
case "delivery_complete": {
|
|
5244
5373
|
const peer = c.peer ? bus.peers.get(c.peer) : undefined;
|
|
5245
|
-
if (c.role !== "peer" || !(peer
|
|
5374
|
+
if (c.role !== "peer" || !isWsPeer(peer) || typeof msg.deliveryId !== "string" || !peer.ownsDelivery(sock, msg.generation, msg.deliveryId)) return void reply({ ok: false, error: "delivery does not belong to this connection generation" });
|
|
5246
5375
|
return void reply(bus.completeDelivery(peer.id, msg.deliveryId) ? { ok: true } : { ok: false, error: "delivery is not live and accepted; inspect ahub queue show before resolving" });
|
|
5247
5376
|
}
|
|
5248
5377
|
case "execution_budget": {
|
|
@@ -5318,7 +5447,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5318
5447
|
if (launch.session && launch.session !== native.sessionId) return void reply({ ok: false });
|
|
5319
5448
|
launch.session = native.sessionId;
|
|
5320
5449
|
const owner = bus.peers.get(peer);
|
|
5321
|
-
if (!(owner
|
|
5450
|
+
if (!isWsPeer(owner) || owner.state === "offline" && !owner.claiming) return void reply({ ok: false });
|
|
5322
5451
|
if (launch.generation && launch.generation !== owner.sessionGeneration) return void reply({ ok: false });
|
|
5323
5452
|
launch.generation = owner.sessionGeneration;
|
|
5324
5453
|
const current = () => !stopping && nativeHookLaunches.get(peer, msg.nativeLaunchId) === launch && bus.peers.get(peer) === owner && owner.sessionGeneration === launch.generation && owner.state !== "offline";
|
|
@@ -5356,6 +5485,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5356
5485
|
return;
|
|
5357
5486
|
}
|
|
5358
5487
|
const offered = factsOn() && (native.event === "PreInvocation" || native.event === "SessionStart") ? offerFor(peer, native.toolUseId ?? `${native.sessionId}:invocation`) : undefined;
|
|
5488
|
+
// The decoded native read (agy view_file or read_file, as `Read`) is a peer fact: its target is observed before it runs and the
|
|
5489
|
+
// view moves after it. Nothing is offered here: agy's hook encodes an offer only on SessionStart/PreInvocation.
|
|
5490
|
+
if (factsOn() && native.event === "PreToolUse") factsFor(peer).preTool(peer, native.toolUseId, native.tool, native.input);
|
|
5491
|
+
if (factsOn() && native.event === "PostToolUse") factsFor(peer).postTool(peer, native.toolUseId, native.tool, native.input);
|
|
5359
5492
|
return void reply({ ok: true, ...(offered ? { text: offered.text } : {}) });
|
|
5360
5493
|
};
|
|
5361
5494
|
if (owner.claiming) {
|
|
@@ -5412,15 +5545,15 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5412
5545
|
const nativePeer = nativeClaude ? bus.peers.get(peer) : undefined;
|
|
5413
5546
|
const permissionLaunch = definitionOf(peer)?.transport === "claude-channel" ? claudePermissionLaunch(peer) : undefined;
|
|
5414
5547
|
const permission = nativeClaude && permissionLaunch && !permissionLaunch.unattended && msg.nativeLaunchId === permissionLaunch.launchId ? permissionMode(peer) : undefined;
|
|
5415
|
-
if (permission !== undefined && nativePeer
|
|
5548
|
+
if (permission !== undefined && isWsPeer(nativePeer)) claudePermissionGenerations.set(peer, nativePeer.sessionGeneration);
|
|
5416
5549
|
const protectedWrite = scopedWriteBlocked(phase, permission, msg.tool, () => projectFileTool(msg.tool, msg.input, opts.cwd, config.protect_instruction_files, config.data?.readonly));
|
|
5417
5550
|
const permissionReply = phase === "pre" && permission !== undefined ? { permission: permission === "ask-when-needed" && protectedWrite ? "ask" : permission, projectRoot: opts.cwd, protectInstructionFiles: config.protect_instruction_files } : {};
|
|
5418
5551
|
// Permission-only hooks carry no native-turn evidence and must never drive busy state.
|
|
5419
5552
|
const purpose = permissionLaunch?.hookPurpose ?? msg.hookPurpose;
|
|
5420
|
-
const observesNative = purpose === "facts" || purpose === "idle" || purpose === "facts-and-idle" || (purpose === undefined && (config.coordination === "turn-free" || config.task_sweep.enabled || config.roles
|
|
5553
|
+
const observesNative = purpose === "facts" || purpose === "idle" || purpose === "facts-and-idle" || (purpose === undefined && (config.coordination === "turn-free" || config.task_sweep.enabled || config.roles[peer]?.includes("conductor")));
|
|
5421
5554
|
if (nativeClaude && !observesNative) return void reply({ t: "facts", ok: true, ...permissionReply });
|
|
5422
5555
|
if (phase === "session") { recordSessions(); return void reply({ t: "facts", ok: true }); }
|
|
5423
|
-
if ((phase === "start" || phase === "pre") && nativePeer
|
|
5556
|
+
if ((phase === "start" || phase === "pre") && isWsPeer(nativePeer)) {
|
|
5424
5557
|
const previous = nativeTurnStart.get(peer);
|
|
5425
5558
|
const current = claudeSession(peer);
|
|
5426
5559
|
if (phase === "start" || nativePeer.state !== "busy" || previous?.generation !== nativePeer.sessionGeneration || previous?.session !== sessionId || previous?.launch !== (current.launchId ?? "")) {
|
|
@@ -5443,7 +5576,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5443
5576
|
const st = hookStats.get(peer)!;
|
|
5444
5577
|
event({ type: "hook_stats", peer, n: st.n, startupMs: Math.round(st.startupMs), hubMs: Math.round(st.hubMs), maxStartupMs: Math.round(st.maxStartupMs) });
|
|
5445
5578
|
hookStats.delete(peer);
|
|
5446
|
-
if (id && nativePeer
|
|
5579
|
+
if (id && isWsPeer(nativePeer)) nativePeer.nativeStopped(sessionId!);
|
|
5447
5580
|
if (factsOn()) { factSession(peer, sessionId); readbacks(peer, claudeTranscript(sessionId, msg.transcriptPath, peer)); }
|
|
5448
5581
|
};
|
|
5449
5582
|
if (!nativeClaude) { finishStop(); reply({ t: "facts", ok: true }); return; } // legacy bookkeeping cannot certify native completion
|
|
@@ -5452,13 +5585,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5452
5585
|
const readCompletion = () => { transcript ??= claudeTranscript(sessionId, msg.transcriptPath, peer); return session.sessionId && transcript ? readClaudeTranscriptCompletion(session.sessionId, transcript) : undefined; };
|
|
5453
5586
|
const completionId = (id: string) => createHash("sha256").update(`${sessionId}:${session.launchId ?? "legacy"}:${id}`).digest("hex");
|
|
5454
5587
|
const started = nativeTurnStart.get(peer);
|
|
5455
|
-
const activeStart = !!started && started.session === sessionId && started.launch === (session.launchId ?? "") && nativePeer
|
|
5588
|
+
const activeStart = !!started && started.session === sessionId && started.launch === (session.launchId ?? "") && isWsPeer(nativePeer) && started.generation === nativePeer.sessionGeneration;
|
|
5456
5589
|
// A seen previous completion is a duplicate only outside an active start. During B it is B's baseline.
|
|
5457
5590
|
if (!activeStart) {
|
|
5458
5591
|
const latest = readCompletion();
|
|
5459
5592
|
if (latest?.at && nativeStopsSeen.has(completionId(latest.id))) return void reply({ t: "facts", ok: true });
|
|
5460
5593
|
}
|
|
5461
|
-
if (!started || !activeStart || !(nativePeer
|
|
5594
|
+
if (!started || !activeStart || !isWsPeer(nativePeer)) { log("native Stop refused for claude: current native turn start unavailable"); return void reply({ t: "facts", ok: false }); }
|
|
5462
5595
|
const scopeCurrent = () => {
|
|
5463
5596
|
const now = claudeSession(peer);
|
|
5464
5597
|
return !stopping && bus.peers.get(peer) === nativePeer && nativePeer.sessionGeneration === started.generation && now.sessionId === started.session && (now.launchId ?? "") === started.launch;
|
|
@@ -5539,7 +5672,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5539
5672
|
// Judged by the live owner: a headless Kimi started by --mode headless is watchable under a TUI start mode.
|
|
5540
5673
|
const owner = bus.peers.get(peer);
|
|
5541
5674
|
const admission = definitionOf(peer)?.attach?.admission;
|
|
5542
|
-
if (admission && !(owner
|
|
5675
|
+
if (admission && !isAcpPeer(owner) && (isWsPeer(owner) || (kimiSessionMode ?? startMode(peer)) === "tui")) return void reply({ ok: false, error: admission.watchRefusal });
|
|
5543
5676
|
if (!membershipAllowed(peer)) return void reply({ ok: false, error: `${peer} is off roster` });
|
|
5544
5677
|
c.watch?.();
|
|
5545
5678
|
c.watch = activityRings[peer]!.subscribe(item => {
|
|
@@ -5662,7 +5795,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5662
5795
|
if (c.role !== "console") return void reply({ ok: false, error: "native TUI launch is console only" });
|
|
5663
5796
|
const peer = String(msg.peer), owner = bus.peers.get(peer);
|
|
5664
5797
|
if (stopping || recoveryActive() || !membershipAllowed(peer) || rosterChanging === peer || requestedPeerStops.has(peer) ||
|
|
5665
|
-
peer !== definitionOf(peer)?.kind || !(owner
|
|
5798
|
+
peer !== definitionOf(peer)?.kind || !isJcodePeer(owner) || !jcodeMcpReady(opts.cwd, opts.stateDir, SERVER_JS)) return void reply({ ok: false, error: "owned native TUI launch is unavailable" });
|
|
5666
5799
|
try {
|
|
5667
5800
|
if (msg.binding !== undefined && (!msg.binding || typeof msg.binding.launchId !== "string" || typeof msg.binding.sessionId !== "string")) throw new Error("invalid binding");
|
|
5668
5801
|
const options = msg.args ?? {};
|