@staix/agent-hub 0.12.43 → 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 +16 -3
- package/README.md +3 -3
- package/docs/adding-an-ai-tool.md +140 -0
- package/docs/agent-notes/README.md +5 -0
- package/docs/agent-notes/adapters.md +8 -3
- package/docs/agent-notes/budget.md +2 -2
- package/docs/agent-notes/daemon.md +9 -5
- package/docs/agent-notes/tasks.md +6 -2
- package/docs/agent-notes/tests.md +3 -1
- package/docs/operations.md +64 -5
- package/docs/quickstart.md +6 -2
- package/docs/security.md +5 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +160 -4
- package/docs/verification/2026-10-21-0.12.44.md +54 -0
- package/docs/verification/2026-10-21-grok-config-acp-t0.md +30 -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 +743 -229
- 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/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 +4 -0
- 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/configured-acp-entry.ts +58 -0
- package/src/cli/console-state.ts +9 -4
- package/src/cli/facts-hook.ts +2 -1
- package/src/cli/help.ts +6 -0
- package/src/cli/identity.ts +7 -6
- package/src/cli/launch.ts +5 -4
- package/src/cli/main.ts +78 -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 +16 -13
- 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 +341 -313
- package/src/hub/hub-tools.ts +36 -0
- 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/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 +50 -16
- package/src/hub/snapshots.ts +26 -2
- package/src/hub/start-mode.ts +10 -8
- package/src/hub/task-sweep.ts +8 -2
- package/src/hub/tasks.ts +85 -17
- 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,7 +75,7 @@ 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";
|
|
@@ -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, knownChangedPaths, hasTree, headState, headMoveCursor, incomingHeadPaths, repoOf, snapshot, SnapshotBudgetExceeded, Turns, type SnapshotBudget, type HeadMoveCursor, type Repo, 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 },
|
|
@@ -516,135 +524,6 @@ interface Client {
|
|
|
516
524
|
}
|
|
517
525
|
type Sock = ServerWebSocket<Client>;
|
|
518
526
|
|
|
519
|
-
/** A peer that lives in another process and attaches over the control WS (the Claude channel plugin). */
|
|
520
|
-
class WsPeer extends BasePeer {
|
|
521
|
-
private generation = crypto.randomUUID();
|
|
522
|
-
// Checkpoints become stale at hello/claim, before asynchronous recall finishes and attach changes delivery generation.
|
|
523
|
-
private claimGeneration = crypto.randomUUID();
|
|
524
|
-
get sessionGeneration(): string { return this.claimGeneration; }
|
|
525
|
-
terminalOwner(launchId: string): { generation: string } | undefined {
|
|
526
|
-
return this.sock?.readyState === WebSocket.OPEN && this.sock.data.launchId === launchId && this.state !== "offline" && !this.claiming
|
|
527
|
-
? { generation: this.claimGeneration } : undefined;
|
|
528
|
-
}
|
|
529
|
-
private delivered = new Set<string>();
|
|
530
|
-
private sock: Sock | undefined;
|
|
531
|
-
private claimed: Sock | undefined;
|
|
532
|
-
private nativeSession: string | undefined;
|
|
533
|
-
private nativeActive = false;
|
|
534
|
-
/** The attached session said it cannot show channel pushes (issue #205): the bus keeps its queue for `inbox`. */
|
|
535
|
-
pullOnly = false;
|
|
536
|
-
steer: PeerAdapter["steer"] = undefined;
|
|
537
|
-
lastInboxAt: number | undefined;
|
|
538
|
-
private claimedPullOnly = false;
|
|
539
|
-
private readonly attachmentWaiters: { generation: string; resolve: (attached: boolean) => void }[] = [];
|
|
540
|
-
private settleAttachmentWaiters(attached: boolean): void {
|
|
541
|
-
for (const waiter of this.attachmentWaiters.splice(0)) waiter.resolve(attached && waiter.generation === this.claimGeneration);
|
|
542
|
-
}
|
|
543
|
-
/** Bounded native-hook observations wait for this exact claim, never a later socket. */
|
|
544
|
-
waitForAttach(generation: string): Promise<boolean> {
|
|
545
|
-
if (generation !== this.claimGeneration || !this.claiming || this.attachmentWaiters.length >= 64) return Promise.resolve(false);
|
|
546
|
-
return new Promise(resolve => { this.attachmentWaiters.push({ generation, resolve }); });
|
|
547
|
-
}
|
|
548
|
-
/** Called at hello, before the async preface: the newest hello wins even if an older one's recall finishes last. */
|
|
549
|
-
claim(sock: Sock, pullOnly = false): void {
|
|
550
|
-
this.settleAttachmentWaiters(false);
|
|
551
|
-
this.claimGeneration = crypto.randomUUID();
|
|
552
|
-
this.claimed = sock;
|
|
553
|
-
this.claimedPullOnly = pullOnly;
|
|
554
|
-
}
|
|
555
|
-
/** A session that shows pushes holds the peer or is arriving: a session without them must not take it (issue #205). */
|
|
556
|
-
get channelHeld(): boolean {
|
|
557
|
-
return (!!this.sock && !this.pullOnly) || (this.claiming && !this.claimedPullOnly);
|
|
558
|
-
}
|
|
559
|
-
/** Status-file observations are advisory; a replaced client cannot evict a live socket or pending claim. */
|
|
560
|
-
standbyHeld(pullOnly: boolean): boolean {
|
|
561
|
-
return this.claiming || (!!this.sock && this.sock.readyState === WebSocket.OPEN && (pullOnly || !this.pullOnly));
|
|
562
|
-
}
|
|
563
|
-
/** A hello that has not finished its preface yet. The peer reads as offline until then, and a session standing by
|
|
564
|
-
* for the id must not take it from a claimant that is still arriving. */
|
|
565
|
-
get claiming(): boolean {
|
|
566
|
-
return !!this.claimed && this.claimed !== this.sock && this.claimed.readyState === WebSocket.OPEN;
|
|
567
|
-
}
|
|
568
|
-
attach(sock: Sock): void {
|
|
569
|
-
if (sock !== this.claimed) return void sock.close(4000, "replaced"); // a newer session said hello meanwhile
|
|
570
|
-
if (this.sock) this.setState("offline"); // unresolved work belongs to the previous connection
|
|
571
|
-
this.sock?.close(4000, "replaced");
|
|
572
|
-
this.sock = sock;
|
|
573
|
-
this.generation = crypto.randomUUID();
|
|
574
|
-
this.delivered.clear();
|
|
575
|
-
this.nativeSession = undefined;
|
|
576
|
-
this.nativeActive = false;
|
|
577
|
-
this.pullOnly = this.claimedPullOnly; // before idle: the idle transition drains
|
|
578
|
-
// Native TUI readback in #493 proves important channel input can be consumed before the turn ends.
|
|
579
|
-
// Pull-only transports expose no steer capability; the bus keeps their important work queued.
|
|
580
|
-
this.steer = this.pullOnly ? undefined : async (envs, deliveryId) => {
|
|
581
|
-
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");
|
|
582
|
-
await this.deliver(envs, deliveryId);
|
|
583
|
-
};
|
|
584
|
-
this.lastInboxAt = undefined;
|
|
585
|
-
this.setState("idle");
|
|
586
|
-
this.settleAttachmentWaiters(true);
|
|
587
|
-
}
|
|
588
|
-
detach(sock: Sock): void {
|
|
589
|
-
if (this.claimed === sock) { this.claimed = undefined; this.settleAttachmentWaiters(false); }
|
|
590
|
-
if (this.sock !== sock) return;
|
|
591
|
-
this.sock = undefined;
|
|
592
|
-
this.steer = undefined;
|
|
593
|
-
this.nativeActive = false;
|
|
594
|
-
this.pullOnly = false;
|
|
595
|
-
this.setState("offline");
|
|
596
|
-
}
|
|
597
|
-
detachForRoster(): void {
|
|
598
|
-
this.settleAttachmentWaiters(false);
|
|
599
|
-
this.claimed?.close(4000, "peer is off roster; retry when a person adds it");
|
|
600
|
-
this.sock?.close(4000, "peer is off roster; retry when a person adds it");
|
|
601
|
-
this.claimed = undefined;
|
|
602
|
-
this.sock = undefined;
|
|
603
|
-
this.steer = undefined;
|
|
604
|
-
this.nativeActive = false;
|
|
605
|
-
this.pullOnly = false;
|
|
606
|
-
this.setState("offline");
|
|
607
|
-
}
|
|
608
|
-
async deliver(envs: Envelope[], deliveryId?: string): Promise<void> {
|
|
609
|
-
if (!this.sock) throw new Error(`${this.id} is offline`);
|
|
610
|
-
if (deliveryId) this.delivered.add(deliveryId);
|
|
611
|
-
this.sock.send(JSON.stringify({ t: "deliver", envs, generation: this.generation, ...(deliveryId ? { deliveryId } : {}) }));
|
|
612
|
-
}
|
|
613
|
-
/** Advisory nudge (issue #398): important work waits in the pull queue. Not a delivery: nothing to settle or journal. */
|
|
614
|
-
notifyQueued(counts: { queued: number; important: number }): void {
|
|
615
|
-
if (!this.sock || !this.pullOnly) return;
|
|
616
|
-
this.sock.send(JSON.stringify({ t: "queued", queued: counts.queued, important: counts.important }));
|
|
617
|
-
}
|
|
618
|
-
owns(sock: Sock): boolean { return this.sock === sock; }
|
|
619
|
-
nativeStarted(session: string): void {
|
|
620
|
-
if (!this.sock) return;
|
|
621
|
-
this.nativeSession = session;
|
|
622
|
-
this.nativeActive = true;
|
|
623
|
-
this.setState("busy");
|
|
624
|
-
}
|
|
625
|
-
nativeStopped(session: string): boolean {
|
|
626
|
-
if (!this.sock || (this.nativeSession !== undefined && this.nativeSession !== session)) return false;
|
|
627
|
-
this.nativeSession = session;
|
|
628
|
-
this.nativeActive = false;
|
|
629
|
-
this.setState("idle");
|
|
630
|
-
return true;
|
|
631
|
-
}
|
|
632
|
-
protected override onWatchdog(): void {
|
|
633
|
-
// A silent native model/approval wait does not prove Stop. Retain the observed active turn until Stop/detach.
|
|
634
|
-
if (this.nativeActive) { this.touch(); return; }
|
|
635
|
-
super.onWatchdog();
|
|
636
|
-
}
|
|
637
|
-
ownsDelivery(sock: Sock, generation: unknown, id: string): boolean { return this.owns(sock) && generation === this.generation && this.delivered.has(id); }
|
|
638
|
-
async start(): Promise<void> {}
|
|
639
|
-
async stop(): Promise<void> {
|
|
640
|
-
this.settleAttachmentWaiters(false);
|
|
641
|
-
this.sock?.close();
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
recoveryMetadata(): Record<string, unknown> {
|
|
645
|
-
return { launch: definitionOf(this.id)?.attach?.recoveryLaunch?.(this.id) ?? { kind: "claude-channel", peer: this.id } };
|
|
646
|
-
}
|
|
647
|
-
}
|
|
648
527
|
|
|
649
528
|
export async function startDaemon(opts: DaemonOptions) {
|
|
650
529
|
const projectId = opts.projectId ?? randomUUID();
|
|
@@ -657,21 +536,25 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
657
536
|
const lifecycleConfig = loadSdlc(opts.cwd, VERSION);
|
|
658
537
|
// Preserve embedding-owned policy references, but never publish reloads into shared defaults.
|
|
659
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;
|
|
660
|
-
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);
|
|
661
542
|
const extraIds = Object.keys(declarations);
|
|
662
543
|
// #450 I3: channel extras (Claude) attach from the person's terminal; the hub never starts them and their hello is admitted.
|
|
663
|
-
const channelExtras = extraIds.filter(id =>
|
|
664
|
-
//
|
|
665
|
-
|
|
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")];
|
|
666
549
|
const executionRoots = new Map<string, string>();
|
|
667
550
|
const executionOwners = new Map<string, { owner: PeerAdapter; cwd: string }>();
|
|
668
551
|
const reviewerUnavailable = new Map<string, string>();
|
|
669
|
-
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 };
|
|
670
553
|
const permissionModesFromInstances: Record<string, PermissionMode> = {};
|
|
671
554
|
const startModesFromInstances: PeerStartConfig = {};
|
|
672
555
|
const instancePermissionSources: NonNullable<HubConfig["permission_default_sources"]> = {};
|
|
673
556
|
for (const [id, declaration] of Object.entries(declarations)) {
|
|
674
|
-
roles[id] = effectiveInstanceRoles(id, declarations, loadedConfig.roles);
|
|
557
|
+
roles[id] = effectiveInstanceRoles(id, declarations, loadedConfig.roles, adapterCatalog);
|
|
675
558
|
if (!channelExtras.includes(id)) startModesFromInstances[id] = { start_mode: "headless" };
|
|
676
559
|
if (declaration.permission_mode !== undefined) {
|
|
677
560
|
permissionModesFromInstances[id] = declaration.permission_mode;
|
|
@@ -679,19 +562,19 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
679
562
|
}
|
|
680
563
|
}
|
|
681
564
|
const config: HubConfig = { ...ownedConfig, ...(extraIds.length ? { instances: declarations } : {}),
|
|
682
|
-
roster: peerRosterConfig(loadedConfig.roster, declarations),
|
|
683
|
-
permission_modes: { ...permissionModesFromInstances, ...permissionDefaults(loadedConfig.permission_modes, declarations) },
|
|
684
|
-
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) },
|
|
685
568
|
...(extraIds.length ? { permission_default_sources: { ...instancePermissionSources, ...loadedConfig.permission_default_sources } } : {}),
|
|
686
569
|
roles, conductor: { ...DEFAULT_CONFIG.conductor, ...loadedConfig.conductor } };
|
|
687
570
|
// The resolver and catalogs are bound to this daemon, never published into the process-global builtin registry.
|
|
688
|
-
const definitionOf = (peer: string) =>
|
|
689
|
-
const KNOWN_PEERS: readonly string[] = [...BASE_KNOWN_PEERS, ...extraIds];
|
|
690
|
-
const SUPPORTED_PEERS: readonly string[] = [...BASE_SUPPORTED_PEERS, ...extraIds];
|
|
691
|
-
const STARTABLE_PEERS: readonly string[] = [...BASE_STARTABLE_PEERS, ...extraIds.filter(id => !channelExtras.includes(id))];
|
|
692
|
-
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);
|
|
693
576
|
const isStartablePeer = (peer: unknown): peer is string => typeof peer === "string" && !channelExtras.includes(peer) && definitionOf(peer)?.start.conductorStartable === true;
|
|
694
|
-
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))]);
|
|
695
578
|
// #223 R2: a declared instance whose effective roles are exactly reviewer gets review requests and its own task notices only.
|
|
696
579
|
const reviewOnlyPeer = (peer: string): boolean => Object.hasOwn(declarations, peer) && config.roles[peer]?.length === 1 && config.roles[peer]![0] === "reviewer";
|
|
697
580
|
/** #520: a task under review shows its review brief to whoever may read it (owner, reviewer, conductor). */
|
|
@@ -700,8 +583,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
700
583
|
const reviewerView = (peer: string, view: Record<string, unknown>): Record<string, unknown> => !reviewOnlyPeer(peer) || !Array.isArray(view.history) ? view
|
|
701
584
|
: { ...view, history: (view.history as { event?: unknown; note?: unknown }[]).map(({ note, ...h }) => h.event === "approved" || h.event === "changes_requested" ? { ...h, note } : h) };
|
|
702
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));
|
|
703
|
-
const
|
|
704
|
-
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);
|
|
705
589
|
const accountOf = (peer: string) => definitionOf(peer)?.budget?.account ?? peer;
|
|
706
590
|
const accountMembers = (account: string) => SUPPORTED_PEERS.filter(peer => accountOf(peer) === account);
|
|
707
591
|
let rosterChanging: string | undefined;
|
|
@@ -709,7 +593,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
709
593
|
let kimiAdmissionId: string | undefined;
|
|
710
594
|
let kimiAdmissionExpiry: ReturnType<typeof setTimeout> | undefined;
|
|
711
595
|
const clearKimiAdmission = () => { clearTimeout(kimiAdmissionExpiry); kimiAdmissionExpiry = undefined; kimiSessionMode = undefined; kimiAdmissionId = undefined; };
|
|
712
|
-
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);
|
|
713
597
|
// pi.enabled historically gates starts, not retained queues or tools sockets. Only an explicit roster.pi changes those.
|
|
714
598
|
const membershipAllowed = (peer: string) => definitionOf(peer)?.enabledSwitchStartOnly && config.roster[peer] === undefined ? true : onRoster(peer);
|
|
715
599
|
conductorPeer(config.roles); // reject ambiguous leadership before opening a control listener
|
|
@@ -720,7 +604,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
720
604
|
// The state dir can vanish under a running hub (issue #56): a log line must never take a handler down with it.
|
|
721
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 */ } };
|
|
722
606
|
let runningRouting = currentRouting(opts.cwd, log);
|
|
723
|
-
let acceptedSources = parseConfigSources(configSourceTexts(opts.cwd), runningRouting);
|
|
607
|
+
let acceptedSources = parseConfigSources(configSourceTexts(opts.cwd), runningRouting, opts.cwd);
|
|
724
608
|
const startupSources = structuredClone(acceptedSources.trustLayers);
|
|
725
609
|
const embeddedConfig = structuredClone(config);
|
|
726
610
|
let desiredConfig = structuredClone(config);
|
|
@@ -889,7 +773,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
889
773
|
sock.data.tail?.();
|
|
890
774
|
sock.data.watch?.();
|
|
891
775
|
const peer = sock.data.peer ? bus.peers.get(sock.data.peer) : undefined;
|
|
892
|
-
if (peer
|
|
776
|
+
if (isWsPeer(peer)) {
|
|
893
777
|
peer.detach(sock);
|
|
894
778
|
if (!!sock.data.peer && !!definitionOf(sock.data.peer)?.attach?.admission && peer.state === "offline" && !peer.claiming && sock.data.kimiAdmission === kimiAdmissionId) clearKimiAdmission();
|
|
895
779
|
}
|
|
@@ -925,7 +809,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
925
809
|
// a record left now would make the next ordinary start look like one.
|
|
926
810
|
if (recoveryOperation || restartFilePresent) try { removeSessions(opts.stateDir); } catch { /* nothing to remove */ }
|
|
927
811
|
const autoResume = () => config.recovery.auto_resume_after_crash === true; // a string "false" is not a yes
|
|
928
|
-
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;
|
|
929
815
|
const startedAt = Date.now();
|
|
930
816
|
/** What crash recovery did or asks the user to do, for `ahub status`. */
|
|
931
817
|
const crashReport: string[] = [];
|
|
@@ -958,7 +844,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
958
844
|
// silence without facts would leave overlapping owners blind. Facts and silence ask the same question, so both switch
|
|
959
845
|
// together. Cached until the board changes: it is asked per recipient of every message.
|
|
960
846
|
let turnFreeNow: boolean | undefined;
|
|
961
|
-
const turns = new Map<PeerId, { capture?: boolean; id: string; start: number; tokens: number; tree?: string; head?: string | null; reflog?: HeadMoveCursor; 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 }>();
|
|
962
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)));
|
|
963
850
|
/**
|
|
964
851
|
* Why `to` does not get an agent's message (issue #107): sender and recipient are in one silent cohort, and the sender
|
|
@@ -1009,7 +896,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1009
896
|
if (staleOff()) log("experiment: stale notices are delivered as before 0.12.4 (the issue #106 ablation)");
|
|
1010
897
|
const requestedPeerStops = new Set<string>();
|
|
1011
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,
|
|
1012
|
-
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); } });
|
|
1013
900
|
startupCleanup.push(() => bus.closeJournal());
|
|
1014
901
|
const manualPaused = new Set<PeerId>(bus.manualPausedPeers()); // recovery never lifts an operator's pause
|
|
1015
902
|
const conductorHolds = new ConductorHolds(join(opts.stateDir, "hub.db"));
|
|
@@ -1035,11 +922,18 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1035
922
|
bus.setRecoveryHold(false);
|
|
1036
923
|
}
|
|
1037
924
|
const recoveryPeerAllowed = (id: PeerId) => !recoveryActive() || !recoveryPeerSnapshot || recoveryPeerSnapshot.some((peer) => peer.id === id);
|
|
1038
|
-
|
|
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;
|
|
1039
929
|
clearTimeout(recoveryLeaseTimer);
|
|
1040
930
|
recoveryLeaseTimer = setTimeout(() => {
|
|
1041
|
-
if (!recoveryOperationId || recoveryCommitted || (recoveryPhase !== "preparing" && recoveryPhase !== "prepared")) return;
|
|
1042
|
-
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;
|
|
1043
937
|
log(`recovery lease expired for ${recoveryOperationId}; hold aborted`);
|
|
1044
938
|
recoveryOperationId = undefined;
|
|
1045
939
|
recoveryPhase = undefined;
|
|
@@ -1299,8 +1193,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1299
1193
|
if (!peer || peer.state === "offline") return undefined;
|
|
1300
1194
|
let token = checkConnections.get(peer);
|
|
1301
1195
|
if (!token) { token = randomUUID(); checkConnections.set(peer, token); }
|
|
1302
|
-
const native = peer
|
|
1303
|
-
if (peer
|
|
1196
|
+
const native = isWsPeer(peer) || isCodexPeer(peer) || isPiPeer(peer) ? peer.sessionGeneration : peer.recoveryMetadata?.().sessionId;
|
|
1197
|
+
if (isPiPeer(peer) && native === undefined) return undefined;
|
|
1304
1198
|
return `${instanceId}:${token}:${String(native ?? "")}`;
|
|
1305
1199
|
};
|
|
1306
1200
|
const currentCheck = (receipt: { task: number; owner: string; updated: number | string; generation: number | string; revision?: number }) => {
|
|
@@ -1400,7 +1294,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1400
1294
|
// Bind both legacy print transports and task-MCP calls to the child's original review request.
|
|
1401
1295
|
const runningReviewAuthority = (by: PeerId, task: number): (() => string | undefined) | undefined => {
|
|
1402
1296
|
if (!definitionOf(by)?.reviewer) return undefined;
|
|
1403
|
-
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);
|
|
1404
1298
|
return () => token!==undefined && binding!==undefined && bus.peers.get(by)===peer && peer?.reviewToken(task)===token && tasks.reviewBindingCurrent(by,task,binding)
|
|
1405
1299
|
? undefined : `no review of task #${task} is running for ${by} at its bound head and revision`;
|
|
1406
1300
|
};
|
|
@@ -1414,6 +1308,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1414
1308
|
return [peer, codes];
|
|
1415
1309
|
})),
|
|
1416
1310
|
instances: () => declarations,
|
|
1311
|
+
adapterCatalog,
|
|
1417
1312
|
reviewOnly: reviewOnlyPeer,
|
|
1418
1313
|
// #223: a headless reviewer's verdict holds only while the same child it started with still runs for that task.
|
|
1419
1314
|
reviewAuthority: runningReviewAuthority,
|
|
@@ -1434,6 +1329,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1434
1329
|
catalog,
|
|
1435
1330
|
sessionRoles: peer => bus.peers.has(peer) && bus.stateOf(peer) !== "offline" ? preparedSessionRoles.get(peer) ?? [] : [],
|
|
1436
1331
|
projectedReview: task => reviewProjection(task),
|
|
1332
|
+
ownerWrites: (task, since) => ownerWriteEvidence(task, since),
|
|
1333
|
+
ownerAccepted: task => captureOwnerWriteStart(task),
|
|
1437
1334
|
reviewBrief: task => {
|
|
1438
1335
|
const root = executionRoot(task.owner ?? "");
|
|
1439
1336
|
return buildReviewBrief(root, task.refs.commit, { privatePath: path => isDenied(path, config.local.deny) || matchesPolicy(root, path, config.pii?.paths ?? [], true), nameable: tasks.nameable });
|
|
@@ -1491,7 +1388,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1491
1388
|
sweepHeld: () => stopping || recoveryActive(),
|
|
1492
1389
|
cancellationHeld: () => stopping || recoveryActive(),
|
|
1493
1390
|
offerRead: (peer, envelopeId) => journal.offerRead(peer, envelopeId),
|
|
1494
|
-
offerPullOnly: (id) => { const peer = bus.peers.get(id); return peer
|
|
1391
|
+
offerPullOnly: (id) => { const peer = bus.peers.get(id); return isWsPeer(peer) && peer.pullOnly; },
|
|
1495
1392
|
stallNotice: stall => { supervision.taskStalled(stall); },
|
|
1496
1393
|
roles: config.roles,
|
|
1497
1394
|
turnFree,
|
|
@@ -1691,7 +1588,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1691
1588
|
} catch { /* no transcript yet: no readback */ }
|
|
1692
1589
|
};
|
|
1693
1590
|
/** Claude's transcript for this session: the one the status line reported, or the hook's own, inside Claude's projects. */
|
|
1694
|
-
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
|
|
1695
1593
|
const reported = claudeSession(peer);
|
|
1696
1594
|
if (reported.transcriptPath && (!sessionId || reported.sessionId === sessionId)) return reported.transcriptPath;
|
|
1697
1595
|
if (!sessionId || typeof fromHook !== "string" || !isAbsolute(fromHook) || basename(fromHook) !== `${sessionId}.jsonl`) return undefined;
|
|
@@ -1736,6 +1634,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1736
1634
|
scopes = new Map();
|
|
1737
1635
|
progress.prune();
|
|
1738
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);
|
|
1739
1638
|
if ((h.event === "integration requested" || h.event === "integration unresolved") && /has not stopped/.test(h.note ?? "")) {
|
|
1740
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)}`);
|
|
1741
1640
|
}
|
|
@@ -1759,10 +1658,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1759
1658
|
const checkpointWaits = new Map<PeerId, { reason: "quota" | "context"; requestId: string; session: string | undefined; valid: () => boolean; done: (summary: string | undefined) => void }>();
|
|
1760
1659
|
const contextSession = (peer: PeerId): string | undefined => {
|
|
1761
1660
|
const p = bus.peers.get(peer);
|
|
1762
|
-
if (p
|
|
1763
|
-
if (definitionOf(peer)?.
|
|
1764
|
-
if (p
|
|
1765
|
-
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; }
|
|
1766
1665
|
return undefined;
|
|
1767
1666
|
};
|
|
1768
1667
|
const activityRings: Record<string, ActivityRing> = Object.fromEntries(SUPPORTED_PEERS.filter(peer => definitionOf(peer)?.capabilities?.watch).map(peer => [peer, new ActivityRing()]));
|
|
@@ -1773,8 +1672,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1773
1672
|
const owner = bus.peers.get(peer);
|
|
1774
1673
|
const state = bus.stateOf(peer);
|
|
1775
1674
|
const session = contextSession(peer);
|
|
1776
|
-
const generation = owner
|
|
1777
|
-
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);
|
|
1778
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));
|
|
1779
1678
|
if (!openWork && !bus.queued(peer) && !bus.hasInFlight(peer) && state !== "busy") {
|
|
1780
1679
|
if (reason === "quota") log(`budget ${peer}: no open work, no checkpoint`);
|
|
@@ -1786,7 +1685,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1786
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.`;
|
|
1787
1686
|
const ask = newEnvelope(HUB, body, { to: [peer], kind: "budget", priority: "important" });
|
|
1788
1687
|
return new Promise((resolve) => {
|
|
1789
|
-
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;
|
|
1790
1689
|
const done = (summary: string | undefined) => {
|
|
1791
1690
|
clearTimeout(timer);
|
|
1792
1691
|
checkpointWaits.delete(peer);
|
|
@@ -1814,6 +1713,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1814
1713
|
}
|
|
1815
1714
|
};
|
|
1816
1715
|
const budget = new Budget(join(opts.stateDir, "hub.db"), config.budget, instanceBudgetHooks({
|
|
1716
|
+
definitionOf,
|
|
1817
1717
|
pause: (peer) => bus.pause(peer),
|
|
1818
1718
|
resume: (peer) => {
|
|
1819
1719
|
if (!manualPaused.has(peer) && !conductorHolds.has(peer)) bus.resume(peer);
|
|
@@ -1926,28 +1826,71 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1926
1826
|
snapshotBudgetNoticed = true;
|
|
1927
1827
|
} else log(`snapshot failed at ${what}: ${(error as Error).message}`);
|
|
1928
1828
|
};
|
|
1929
|
-
const snap = (what: string, capture = config.snapshots.enabled, peer?: string): { tree?: string; head?: string | null; reflog?: HeadMoveCursor; ms: number; budget?: SnapshotBudget; repo?: Repo } => {
|
|
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 } => {
|
|
1930
1830
|
if (!capture) return { ms: 0 };
|
|
1931
1831
|
const t0 = performance.now(), budget = reserveSnapshotBudget();
|
|
1932
|
-
let tree: string | undefined, head: string | null | undefined, repo: Repo | undefined, reflog: HeadMoveCursor | undefined;
|
|
1832
|
+
let tree: string | undefined, head: string | null | undefined, commit: string | null | undefined, repo: Repo | undefined, reflog: HeadMoveCursor | undefined;
|
|
1933
1833
|
try {
|
|
1934
1834
|
repo = peer ? repoFor(peer, budget) : factsRepo;
|
|
1935
1835
|
const before = repo && headState(repo, budget);
|
|
1936
1836
|
tree = repo && (opts.snapshot ?? snapshot)(repo, budget);
|
|
1937
1837
|
const after = repo && headState(repo, budget);
|
|
1938
|
-
head = after?.tree;
|
|
1838
|
+
head = after?.tree; commit = after?.head;
|
|
1939
1839
|
if (!before || !after || before.tree !== after.tree) tree = undefined;
|
|
1940
1840
|
if (tree && repo && head !== undefined && after) reflog = headMoveCursor(repo, head, budget, after.head);
|
|
1941
1841
|
if (repo && !tree) log(`snapshot failed at ${what}`);
|
|
1942
1842
|
} catch (error) {
|
|
1943
|
-
tree = undefined; head = undefined; reflog = undefined;
|
|
1843
|
+
tree = undefined; head = undefined; commit = undefined; reflog = undefined;
|
|
1944
1844
|
snapshotFailure(what, error);
|
|
1945
1845
|
}
|
|
1946
|
-
return { tree, head, reflog, budget, repo, ms: Math.round(performance.now() - t0) };
|
|
1846
|
+
return { tree, head, commit, reflog, budget, repo, ms: Math.round(performance.now() - t0) };
|
|
1947
1847
|
};
|
|
1948
1848
|
function privateSnapshotWindow(): boolean {
|
|
1949
1849
|
return [...turns.values()].some(turn => turn.private) || board.list().some(task => !isClosedTaskState(task.state) && tasks.isPii(task));
|
|
1950
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
|
+
}
|
|
1951
1894
|
function reviewProjection(task: Task): string | undefined {
|
|
1952
1895
|
const owner = task.owner ?? "", root = executionRoot(owner);
|
|
1953
1896
|
const repo = repoFor(owner), projections = projectionsFor(owner);
|
|
@@ -1982,7 +1925,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1982
1925
|
}
|
|
1983
1926
|
return n;
|
|
1984
1927
|
};
|
|
1985
|
-
const onKimiTokens = (total: number, session: string, peer
|
|
1928
|
+
const onKimiTokens = (total: number, session: string, peer: string) => {
|
|
1986
1929
|
const n = addTokens(peer, delta(peer, total, session));
|
|
1987
1930
|
if (!config.budget.kimi_tokens_5h || !n) return;
|
|
1988
1931
|
const now = Date.now();
|
|
@@ -2045,7 +1988,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2045
1988
|
if (c.instanceId !== instanceId || !session.sessionId || c.sessionId !== session.sessionId || c.launchId !== session.launchId || !["idle", "busy", "paused"].includes(bus.stateOf(claudePeer))) continue;
|
|
2046
1989
|
claudeContextSeen.set(claudePeer, payload);
|
|
2047
1990
|
const bound = contextSession(claudePeer);
|
|
2048
|
-
|
|
1991
|
+
const reading = bound ? readNativeContext(definitionOf(claudePeer), c.context, bound, c.at) : undefined;
|
|
1992
|
+
if (reading) contexts.report(claudePeer, reading, bound);
|
|
2049
1993
|
} catch { /* absent or unreadable native telemetry is unknown */ }
|
|
2050
1994
|
}, 1000),
|
|
2051
1995
|
setInterval(() => budget.tick(), 30_000),
|
|
@@ -2065,7 +2009,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2065
2009
|
claudeUsageSeen = mtime;
|
|
2066
2010
|
const usage = JSON.parse(readFileSync(file, "utf8"));
|
|
2067
2011
|
// The file's own timestamp, not "now": a file left behind by yesterday's session is a stale reading, not a fresh one.
|
|
2068
|
-
|
|
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
|
+
}
|
|
2069
2016
|
} catch {
|
|
2070
2017
|
// no tee in this session, or a half-written file: next round
|
|
2071
2018
|
}
|
|
@@ -2112,6 +2059,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2112
2059
|
const conductedMutations = new Set(["hub_task_propose", "hub_task_accept", "hub_task_decline", "hub_task_done", "hub_review", "hub_checkpoint", "hub_remember"]);
|
|
2113
2060
|
const taskOp = async (...args: Parameters<typeof taskOpBody>): Promise<string> => {
|
|
2114
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;
|
|
2115
2066
|
const conducted = conductorPeer(config.roles) === by && conductedMutations.has(op);
|
|
2116
2067
|
const admittedId = typeof input?.id === "number" && Number.isSafeInteger(input.id) && input.id > 0 ? input.id : undefined;
|
|
2117
2068
|
taskOpsInFlight++;
|
|
@@ -2119,6 +2070,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2119
2070
|
const readAuthority=op==="hub_task_show"?runningReviewAuthority(by,Number(input?.id)):undefined;
|
|
2120
2071
|
const before=readAuthority?.();if(before)throw new PreEffectToolRefusal(before);
|
|
2121
2072
|
const text = await taskOpBody(...args);
|
|
2073
|
+
if (nativeTurn && effectful && turns.get(by) === nativeTurn) nativeTurn.taskEffect = true;
|
|
2122
2074
|
const after=readAuthority?.();if(after)throw new PreEffectToolRefusal(after);
|
|
2123
2075
|
if (conducted) {
|
|
2124
2076
|
const proposed = op === "hub_task_propose" ? Number(text.match(/task #(\d+):/)?.[1]) : undefined;
|
|
@@ -2128,16 +2080,18 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2128
2080
|
const hint = args[3] ? ownHeldHint(by) : "";
|
|
2129
2081
|
return hint ? `${text}\n\n${hint}` : text;
|
|
2130
2082
|
} catch (error) {
|
|
2083
|
+
if (nativeTurn && effectful && !(error instanceof PreEffectToolRefusal) && turns.get(by) === nativeTurn) nativeTurn.taskEffect = true;
|
|
2131
2084
|
// Keep proven pre-effect refusal identity/code for native receipts; only add body-free metadata.
|
|
2132
2085
|
const hint = args[3] ? ownHeldHint(by) : "";
|
|
2133
2086
|
if (hint && error instanceof Error) error.message += `\n\n${hint}`;
|
|
2134
2087
|
throw error;
|
|
2135
2088
|
} finally {
|
|
2089
|
+
if (nativeTurn && effectful) nativeTurn.effectsPending = Math.max(0, (nativeTurn.effectsPending ?? 1) - 1);
|
|
2136
2090
|
taskOpsInFlight--;
|
|
2137
2091
|
}
|
|
2138
2092
|
};
|
|
2139
2093
|
function ownHeldHint(peer: PeerId): string {
|
|
2140
|
-
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)))); }
|
|
2141
2095
|
catch { return ""; } // unavailable telemetry is not a zero count or a reason to fail the tool itself
|
|
2142
2096
|
}
|
|
2143
2097
|
/** A model-written peer argument: a peer id, or nothing for absent, null or "". Anything else is refused. */
|
|
@@ -2271,7 +2225,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2271
2225
|
switch (op) {
|
|
2272
2226
|
case "hub_delivery_retry": {
|
|
2273
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");
|
|
2274
|
-
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");
|
|
2275
2229
|
if (stopping || recoveryActive()) throw new PreEffectToolRefusal("recovery or shutdown is holding delivery mutations");
|
|
2276
2230
|
bus.retryDelivery(by, a.delivery_id);
|
|
2277
2231
|
return `retry queued for delivery ${a.delivery_id}; check for earlier effects before acting`;
|
|
@@ -2460,12 +2414,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2460
2414
|
if (!definitionOf(turn.peer)?.session.nativeRevert || !turn.native) throw new Error(`turn ${turn.id} has no Codex conversation turn to revert`);
|
|
2461
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`);
|
|
2462
2416
|
const codex = bus.peers.get(turn.peer);
|
|
2463
|
-
if (!(codex
|
|
2417
|
+
if (!isCodexPeer(codex)) throw new Error("codex is not attached");
|
|
2464
2418
|
const workspace = turnLog!.workspace(turn.id);
|
|
2465
2419
|
if (workspace !== undefined) {
|
|
2466
|
-
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);
|
|
2467
2421
|
if (!current.instances?.[turn.peer] || adapter?.kind !== definitionOf(turn.peer)?.kind) throw new Error("turn workspace declaration changed; inspect before reverting");
|
|
2468
|
-
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);
|
|
2469
2423
|
const bound = executionOwners.get(turn.peer);
|
|
2470
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");
|
|
2471
2425
|
}
|
|
@@ -2550,7 +2504,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2550
2504
|
* waited past `budget.stale_min` the status says so, and the person decides. Read-only.
|
|
2551
2505
|
*/
|
|
2552
2506
|
const importantWaitingStatus = (id: PeerId, peer: PeerAdapter | undefined): Record<string, unknown> => {
|
|
2553
|
-
if (!(peer
|
|
2507
|
+
if (!isAcpPeer(peer) || (peer as PeerAdapter).steer || bus.stateOf(id) !== "busy") return {};
|
|
2554
2508
|
const since = bus.oldestQueuedImportantAt(id);
|
|
2555
2509
|
if (since === undefined || Date.now() - since < config.budget.stale_min * 60_000) return {};
|
|
2556
2510
|
const count = bus.queuedImportant(id), minutes = Math.floor((Date.now() - since) / 60_000);
|
|
@@ -2563,7 +2517,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2563
2517
|
};
|
|
2564
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
|
|
2565
2519
|
const recoveryReady = () => {
|
|
2566
|
-
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;
|
|
2567
2521
|
if (checkPending.size !== 0) return false;
|
|
2568
2522
|
if (!recoveryPeerSnapshot) return true;
|
|
2569
2523
|
const current = recoveryPeers();
|
|
@@ -2593,7 +2547,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2593
2547
|
});
|
|
2594
2548
|
};
|
|
2595
2549
|
/** #450 I3: each Claude peer (claude and every claude-<name> instance) has its own session and launch files. */
|
|
2596
|
-
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
|
|
2597
2552
|
try {
|
|
2598
2553
|
const value = JSON.parse(readFileSync(join(opts.stateDir, claudeStateFile("claude-session", peer)), "utf8"));
|
|
2599
2554
|
if (value.instanceId !== instanceId) return {};
|
|
@@ -2624,7 +2579,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2624
2579
|
const claudeVersions = new Map<string, string>();
|
|
2625
2580
|
const claudeStamps = new Map<string, string>(); // the transcript's size and mtime when its version was read (#115)
|
|
2626
2581
|
const claudeVersion = (): string | undefined => {
|
|
2627
|
-
const path = claudeSession().transcriptPath;
|
|
2582
|
+
const path = claudeSession(defaultChannelPeer(undefined)).transcriptPath;
|
|
2628
2583
|
if (!path) return undefined;
|
|
2629
2584
|
let fd: number | undefined;
|
|
2630
2585
|
try {
|
|
@@ -2728,9 +2683,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2728
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"}`);
|
|
2729
2684
|
};
|
|
2730
2685
|
const permissionDisplay = (id: string, peer: PeerAdapter | undefined): string => {
|
|
2731
|
-
if (
|
|
2732
|
-
if (
|
|
2733
|
-
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") {
|
|
2734
2690
|
const launch = claudePermissionLaunch(id);
|
|
2735
2691
|
if (!launch || claudePermissionGenerations.get(id) !== peer.sessionGeneration) return "unverified";
|
|
2736
2692
|
}
|
|
@@ -2738,7 +2694,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2738
2694
|
};
|
|
2739
2695
|
const permissionStatus = (id: string, peer: PeerAdapter | undefined): Record<string, unknown> => {
|
|
2740
2696
|
const note = definitionOf(id)?.permission.unmanagedStatusNote;
|
|
2741
|
-
if (note && peer
|
|
2697
|
+
if (note && isWsPeer(peer) && peer.pullOnly) return { permissionNote: note };
|
|
2742
2698
|
const mode = permissionDisplay(id, peer);
|
|
2743
2699
|
return mode === "ask" ? {} : { permissionMode: mode };
|
|
2744
2700
|
};
|
|
@@ -2752,6 +2708,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2752
2708
|
const statusPeerIds = (open: ReturnType<Board["statusTasks"]>) => {
|
|
2753
2709
|
const reservations = board.statusReservations();
|
|
2754
2710
|
const ids = new Set([...bus.knownPeers(), ...extraIds, ...reservations]);
|
|
2711
|
+
for (const peer of configuredIds) if (onRoster(peer)) ids.add(peer);
|
|
2755
2712
|
if (!Object.keys(config.roster).some(peer => !membershipAllowed(peer))) return [...ids];
|
|
2756
2713
|
for (const task of open) for (const peer of [task.owner, task.reviewer]) if (peer && !membershipAllowed(peer)) ids.add(peer);
|
|
2757
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));
|
|
@@ -2778,7 +2735,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2778
2735
|
pendingRestart,
|
|
2779
2736
|
retainedConfig,
|
|
2780
2737
|
...(() => { const warning = mainCheckoutWarning(opts.cwd, tasks.publicMetadataText, checkoutBranch); return warning ? { mainCheckout: warning } : {}; })(),
|
|
2781
|
-
startModes: Object.fromEntries(START_MODE_PEERS.map(peer => [peer, startModeOf(config.peers, peer, declarations)])),
|
|
2738
|
+
startModes: Object.fromEntries([...START_MODE_PEERS, ...configuredIds].map(peer => [peer, startModeOf(config.peers, peer, declarations, adapterCatalog)])),
|
|
2782
2739
|
memoryProfile: memoryProfileStatus(includeAttentionItems),
|
|
2783
2740
|
lifecycleStages: [...lifecycleConfig.stages],
|
|
2784
2741
|
// ponytail: off keeps the #257 phase schema until RETIRED_WORKFLOW_FROM, when the alias and this flag go together.
|
|
@@ -2787,6 +2744,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2787
2744
|
roster: [...KNOWN_PEERS, ...OPTIONAL_PEERS].map(peer => ({ peer, on: onRoster(peer) })),
|
|
2788
2745
|
protocol: PROTOCOL,
|
|
2789
2746
|
features: DAEMON_FEATURES,
|
|
2747
|
+
configuredAdapters: configuredIds,
|
|
2748
|
+
startablePeers: SUPPORTED_PEERS.filter(isStartablePeer),
|
|
2749
|
+
readOnlyPeers: extraIds.filter(readOnlyPeer),
|
|
2790
2750
|
// #450 I3: the instance ids this daemon bound at startup; a launcher selects only one of these.
|
|
2791
2751
|
activeInstances: extraIds,
|
|
2792
2752
|
stopping,
|
|
@@ -2796,7 +2756,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2796
2756
|
...(dashboard ? { uiOrigin: dashboard.origin } : {}),
|
|
2797
2757
|
codexProxyPort: opts.codexProxyPort,
|
|
2798
2758
|
peers: Object.fromEntries(
|
|
2799
|
-
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 ?? [] } : {}) }]; }),
|
|
2800
2760
|
),
|
|
2801
2761
|
taskWip: Object.fromEntries(peerIds.map(id => [id, { wip: tasks.wip(id), wipLimit: tasks.wipLimit(id) }])),
|
|
2802
2762
|
boardReadCapabilities: { releaseFilter: true, columnMetadata: true },
|
|
@@ -3042,8 +3002,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3042
3002
|
let piSettlementFailure: { key: string; error: string; retryAfter: number } | undefined;
|
|
3043
3003
|
function settlePiContinuity(): void {
|
|
3044
3004
|
if (stopping || recoveryActive() || piContinuity.state !== "ready") return;
|
|
3045
|
-
const peer = bus.peers.get(
|
|
3046
|
-
if (!(peer
|
|
3005
|
+
const peer = bus.peers.get(continuityKind);
|
|
3006
|
+
if (!isPiPeer(peer) || peer.state === "offline" || !peer.sessionGeneration || !peer.recoveryReady) return;
|
|
3047
3007
|
const saved = piContinuity.bookmark, actual = peer.recoveryMetadata();
|
|
3048
3008
|
if (typeof actual.sessionId !== "string" || !actual.sessionId) return;
|
|
3049
3009
|
const key = JSON.stringify([piContinuity.digest, peer.sessionGeneration, actual]);
|
|
@@ -3072,8 +3032,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3072
3032
|
}
|
|
3073
3033
|
const recordSessions = () => {
|
|
3074
3034
|
if (stopping) return;
|
|
3075
|
-
const pi = bus.peers.get(
|
|
3076
|
-
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)) {
|
|
3077
3037
|
piContinuity = readRecoveryContinuity(opts.stateDir, { projectId, projectRoot: opts.cwd });
|
|
3078
3038
|
}
|
|
3079
3039
|
const peers = [...bus.peers.values()].filter((p) => p.state !== "offline").map((p) => {
|
|
@@ -3095,19 +3055,19 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3095
3055
|
const lostCount = [...lost.values()].reduce((n, l) => n + l.length, 0);
|
|
3096
3056
|
report(`the previous hub run stopped without shutting down${lostCount ? `; ${lostCount} deliveries it had in flight are in needs_review (ahub queue list)` : ""}`);
|
|
3097
3057
|
const start = (peer: string, args: Parameters<typeof startPeer>[1]) => startPeer(peer, args).catch((e: Error) => ({ ok: false, error: e.message }));
|
|
3098
|
-
for (const step of crashPlan(prev.peers, declarations)) {
|
|
3058
|
+
for (const step of crashPlan(prev.peers, declarations, adapterCatalog)) {
|
|
3099
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; }
|
|
3100
3060
|
// `pi.auto_start` already asks for Pi (#66): its recorded headless session comes back, auto-resume or not, and a
|
|
3101
3061
|
// fresh one starts when that fails or there is nothing headless to resume. Pi runs on-prem: no cloud quota.
|
|
3102
3062
|
if (!declarations[step.peer] && definitionOf(step.peer)?.session.restoreOnStart && piAutoStart()) {
|
|
3103
3063
|
if (step.resume) {
|
|
3104
|
-
const r = await start(
|
|
3064
|
+
const r = await start(step.peer, step.resume as Parameters<typeof startPeer>[1]);
|
|
3105
3065
|
if (r.ok) { report(`pi resumed (pi.auto_start): ${step.how}`); continue; }
|
|
3106
3066
|
report(`pi not resumed (${String(r.error)}); ${step.how}`);
|
|
3107
3067
|
} else report(step.how);
|
|
3108
3068
|
// #269: a Pi that ran in a terminal is not replaced by a headless one unless headless is its start mode.
|
|
3109
|
-
if (step.tui && startMode(
|
|
3110
|
-
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 });
|
|
3111
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` : "";
|
|
3112
3072
|
report(fresh.ok ? `pi.auto_start started a fresh session${back}` : `pi.auto_start could not start Pi either (${String(fresh.error)})`);
|
|
3113
3073
|
continue;
|
|
@@ -3161,13 +3121,20 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3161
3121
|
const capture = config.snapshots.enabled && !!turnLog;
|
|
3162
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
|
|
3163
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); }
|
|
3164
3130
|
const preparedRoles = preparedSessionRoleIdentities.get(e.peer)?.map(role => ({ ...role })) ?? null;
|
|
3165
|
-
turns.set(e.peer, { id, capture, start: Date.now(), tokens: 0, deliveryTask: delivery, attribution, preparedRoles, ...(start ? { tree: start.tree, head: start.head, reflog: start.reflog, 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 } : {}) });
|
|
3166
3132
|
event({ type: "turn_start", peer: e.peer, turn: id });
|
|
3167
3133
|
} else if (!busy && open) {
|
|
3168
3134
|
turns.delete(e.peer);
|
|
3169
3135
|
let afterTurn: (() => void) | undefined;
|
|
3170
3136
|
let files: number | undefined;
|
|
3137
|
+
let writeProgress: boolean | undefined;
|
|
3171
3138
|
let snapshotMs = open.snapshotMs;
|
|
3172
3139
|
if (open.capture && turnLog && (open.private || holdsPii(e.peer))) {
|
|
3173
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}`); }
|
|
@@ -3216,6 +3183,21 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3216
3183
|
}
|
|
3217
3184
|
}
|
|
3218
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
|
+
}
|
|
3219
3201
|
snapshotMs = (snapshotMs ?? 0) + end.ms;
|
|
3220
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}`); }
|
|
3221
3203
|
// After the turn_end event: a notice delivered at once starts the peer's next turn, which must come after it.
|
|
@@ -3229,6 +3211,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3229
3211
|
tasks.cohorts.turnEnded(e.peer);
|
|
3230
3212
|
}
|
|
3231
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
|
+
}
|
|
3232
3220
|
}
|
|
3233
3221
|
if (e.state === "offline") { offlineSince.set(e.peer, offlineSince.get(e.peer) ?? Date.now()); supervision.peerOffline(e.peer); supervisionTurns.delete(e.peer); }
|
|
3234
3222
|
else offlineSince.delete(e.peer);
|
|
@@ -3376,7 +3364,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3376
3364
|
const replacingPi = new Set<PiPeer>();
|
|
3377
3365
|
const piReplacementAuthority = Symbol("console Pi replacement");
|
|
3378
3366
|
/** pi.auto_start at hub start (#269): in Pi's start mode, and said out loud when it could not be done. */
|
|
3379
|
-
const autoStartPi = () => void hubStart(
|
|
3367
|
+
const autoStartPi = () => void hubStart(autoStartAdapter!.kind, "pi.auto_start").then(
|
|
3380
3368
|
(result) => { if (result.ok !== true) notify(`pi.auto_start did not start Pi: ${String(result.error)}`); },
|
|
3381
3369
|
(error) => log(`Pi auto-start failed: ${(error as Error).message}${piContinuity.state === "ready" ? `. ${continuityCleanup()}` : ""}`));
|
|
3382
3370
|
async function stopPiForReplacement(pi: PiPeer): Promise<void> {
|
|
@@ -3417,7 +3405,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3417
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" }) };
|
|
3418
3406
|
const CLI_ENTRY = fileURLToPath(new URL("../cli/main.ts", import.meta.url));
|
|
3419
3407
|
/** `config.peers` follows a settings write (`settingsSet`), so a changed mode applies at the peer's next start. */
|
|
3420
|
-
const startMode = (peer: string): StartMode => startModeOf(config.peers, peer, declarations);
|
|
3408
|
+
const startMode = (peer: string): StartMode => startModeOf(config.peers, peer, declarations, adapterCatalog);
|
|
3421
3409
|
const startWords = (peer: string, mode: StartMode): string[] => {
|
|
3422
3410
|
const words = definitionOf(peer)?.cli.startWords(mode) ?? [peer];
|
|
3423
3411
|
return declarations[peer] ? [words[0]!, "--as", peer, ...words.slice(1)] : words;
|
|
@@ -3426,6 +3414,29 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3426
3414
|
// ponytail: one terminal per peer per 30 s, by the clock. The hub cannot see whether the terminal it opened still
|
|
3427
3415
|
// exists, so a peer that never attaches can be opened again after that; track the provider's handle to do better.
|
|
3428
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
|
+
}
|
|
3429
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. */
|
|
3430
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.
|
|
3431
3442
|
const startPlans = () => STARTABLE_PEERS.filter(startable).map((peer) => {
|
|
@@ -3473,8 +3484,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3473
3484
|
// ponytail: an attached terminal Pi only. A terminal launch that the TUI has not claimed yet reads as offline,
|
|
3474
3485
|
// like one whose terminal was closed, so a headless start in that window replaces it; record when a launch was
|
|
3475
3486
|
// issued to tell the two apart.
|
|
3476
|
-
if (owner
|
|
3477
|
-
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 ?? {}) } : {}));
|
|
3478
3489
|
}
|
|
3479
3490
|
// #559 N1: only the console's own start reopens a frontend on an attached owned-ACP peer. The conductor and the
|
|
3480
3491
|
// dashboard answer `already`, so a repeated call never adds another TUI writer to the live session.
|
|
@@ -3507,11 +3518,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3507
3518
|
if (stopping || recoveryActive()) return { ok: false, error: "recovery or shutdown is holding peer stops; finish the operation before retrying" };
|
|
3508
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" };
|
|
3509
3520
|
const owner = bus.peers.get(peer);
|
|
3510
|
-
const stopNotice = definitionOf(peer)?.cli.stopNotice?.(owner
|
|
3521
|
+
const stopNotice = definitionOf(peer)?.cli.stopNotice?.(isWsPeer(owner) ? "tui" : !owner ? startMode(peer) : "headless", owner);
|
|
3511
3522
|
if (stopNotice) return { ok: false, error: stopNotice };
|
|
3512
|
-
if (!(owner
|
|
3513
|
-
if (owner
|
|
3514
|
-
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` };
|
|
3515
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" };
|
|
3516
3527
|
requestedPeerStops.add(peer); // fence delivery, starts and recovery before the first asynchronous stop step
|
|
3517
3528
|
try {
|
|
@@ -3519,7 +3530,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3519
3530
|
if (stopping || recoveryActive() || bus.peers.get(peer) !== owner) return { ok: false, error: "peer or recovery changed; refresh ahub status before retrying" };
|
|
3520
3531
|
const reason = `requested stop: ahub stop ${peer}`; // ids only, never task or message text
|
|
3521
3532
|
// Pi persists a session only after its first message: take live empty-session proof before stopping.
|
|
3522
|
-
if (owner
|
|
3533
|
+
if (isPiPeer(owner) && owner.state === "idle") {
|
|
3523
3534
|
try { await owner.captureResume(); }
|
|
3524
3535
|
catch { log(`requested stop ${peer}: resume evidence unavailable; later start requires verified persisted history`); }
|
|
3525
3536
|
}
|
|
@@ -3559,7 +3570,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3559
3570
|
try {
|
|
3560
3571
|
if (declarations[peer] && args.mode !== undefined && args.mode !== "headless") return { ok: false, error: "declared instances support headless starts only" };
|
|
3561
3572
|
let executionCwd: string;
|
|
3562
|
-
try { executionCwd = resolveInstanceExecutionCwd(opts.cwd, peer, declarations, config.roles[peer] ?? []); }
|
|
3573
|
+
try { executionCwd = resolveInstanceExecutionCwd(opts.cwd, peer, declarations, config.roles[peer] ?? [], adapterCatalog); }
|
|
3563
3574
|
catch (error) { return { ok: false, error: (error as Error).message }; }
|
|
3564
3575
|
executionRoots.set(peer, executionCwd);
|
|
3565
3576
|
applyPendingPermissionDefault(peer);
|
|
@@ -3573,6 +3584,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3573
3584
|
|
|
3574
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>> {
|
|
3575
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" };
|
|
3576
3594
|
if (adapter?.reviewer?.unavailable) return { ok: false, error: adapter.reviewer.unavailable };
|
|
3577
3595
|
const preflight = adapter?.start.preflight?.({ instance: peer, form: args.mode });
|
|
3578
3596
|
if (preflight) return { ok: false, error: preflight };
|
|
@@ -3581,7 +3599,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3581
3599
|
const owner = bus.peers.get(peer);
|
|
3582
3600
|
if ((args.mode ?? startMode(peer)) === "tui") {
|
|
3583
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" };
|
|
3584
|
-
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" };
|
|
3585
3603
|
clearKimiAdmission();
|
|
3586
3604
|
kimiAdmissionId = crypto.randomUUID();
|
|
3587
3605
|
kimiSessionMode = "tui"; // scoped to this pending launch and its attached session
|
|
@@ -3589,7 +3607,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3589
3607
|
kimiAdmissionExpiry = setTimeout(clearKimiAdmission, 30_000); kimiAdmissionExpiry.unref();
|
|
3590
3608
|
return { ok: true, mode: "tui", pullOnly: true, command: "ahub kimi", admissionId: kimiAdmissionId };
|
|
3591
3609
|
}
|
|
3592
|
-
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" };
|
|
3593
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" };
|
|
3594
3612
|
clearKimiAdmission();
|
|
3595
3613
|
}
|
|
@@ -3604,7 +3622,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3604
3622
|
if (args.replaceSession) {
|
|
3605
3623
|
if (recoveryActive()) return { ok: false, error: "recovery is holding session replacement" };
|
|
3606
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" };
|
|
3607
|
-
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" };
|
|
3608
3626
|
const selected = validatePiSession(nativeStateDir(peer), executionCwd, args.sessionFile);
|
|
3609
3627
|
const previous = existing.recoveryMetadata();
|
|
3610
3628
|
recordPiReplacement(nativeStateDir(peer), instanceId, previous, selected);
|
|
@@ -3612,7 +3630,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3612
3630
|
mute(existing);
|
|
3613
3631
|
await stopPiForReplacement(existing);
|
|
3614
3632
|
}
|
|
3615
|
-
if (existing
|
|
3633
|
+
if (isPiPeer(existing) && !args.replaceSession) {
|
|
3616
3634
|
let saved = existing.recoveryMetadata();
|
|
3617
3635
|
const launch = saved.launch as Record<string, unknown>;
|
|
3618
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" };
|
|
@@ -3645,10 +3663,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3645
3663
|
if (adapter?.start.reconfigureWhileAttached && existing?.state !== "offline" && existing && (args.model || args.route)) {
|
|
3646
3664
|
if (existing.state === "busy") return { ok: false, error: "local is busy; retry when it is idle" };
|
|
3647
3665
|
} else if (existing && existing.state !== "offline") {
|
|
3648
|
-
|
|
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 } : {}) };
|
|
3649
3670
|
}
|
|
3650
3671
|
if (!adapter?.start.reconfigureWhileAttached) {
|
|
3651
|
-
if (existing
|
|
3672
|
+
if (isPiPeer(existing)) await stopPiForReplacement(existing);
|
|
3652
3673
|
else await existing?.stop();
|
|
3653
3674
|
}
|
|
3654
3675
|
if (adapter?.start.daemonPort === "owned-acp") {
|
|
@@ -3664,7 +3685,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3664
3685
|
if (adapter?.start.acpCommand) {
|
|
3665
3686
|
const cmd = adapter.start.acpCommand(config.kimi_cmd, args.model);
|
|
3666
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"]({
|
|
3667
|
-
onActivity: item => activityRings[peer]
|
|
3688
|
+
onActivity: item => activityRings[peer]?.append(item),
|
|
3668
3689
|
isPrivateTurn: () => !!turns.get(peer)?.private || contextPii(peer),
|
|
3669
3690
|
cmd, kind: adapter.kind,
|
|
3670
3691
|
permissionMode: permissionMode(peer),
|
|
@@ -3693,11 +3714,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3693
3714
|
if (observation.source !== "usage_update" || observation.shape !== "context-used") return;
|
|
3694
3715
|
const current = contextSession(peer);
|
|
3695
3716
|
if (!current) return;
|
|
3696
|
-
|
|
3697
|
-
observation.contextUsed
|
|
3698
|
-
|
|
3699
|
-
: { source: "acp_usage_update", sessionId, measuredAt: Date.now(), tokens: null, window: null, used: null },
|
|
3700
|
-
current);
|
|
3717
|
+
const reading = readNativeContext(adapter,
|
|
3718
|
+
{ used: observation.contextUsed, size: observation.contextCapacity }, sessionId, Date.now());
|
|
3719
|
+
if (reading) contexts.report(peer, reading, current);
|
|
3701
3720
|
},
|
|
3702
3721
|
onTurnFailure: () => { supervisionTurns.delete(peer); },
|
|
3703
3722
|
mcpServers: [{ name: "agent-hub", command: "bun", args: ["run", SERVER_JS], env: Object.entries(toolEnv(peer, true)).map(([name, value]) => ({ name, value })) }],
|
|
@@ -3711,36 +3730,38 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3711
3730
|
} catch (error) { await kimi.stop().catch(stop => log(`kimi stop after a failed start: ${(stop as Error).message}`)); throw error; }
|
|
3712
3731
|
return { ok: true };
|
|
3713
3732
|
}
|
|
3714
|
-
if (adapter?.transport === "acp" && adapter.start.create) {
|
|
3715
|
-
//
|
|
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.
|
|
3716
3735
|
const launch = adapter.start.launch({ instance: peer, form: "headless", projectRoot: opts.cwd, stateDir: opts.stateDir, entrypoint: CLI_ENTRY,
|
|
3717
|
-
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 });
|
|
3718
3737
|
const mcpServers = [{ name: "agent-hub", command: "bun", args: ["run", SERVER_JS], env: Object.entries(toolEnv(peer, true)).map(([name, value]) => ({ name, value })) }];
|
|
3719
|
-
const permission = permissionMode(peer);
|
|
3738
|
+
const permission = readOnlyPeer(peer) ? "read-only" as const : permissionMode(peer);
|
|
3720
3739
|
const owner = adapter.start.create({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: undefined, permission, mcpServers, daemon: { acp: {
|
|
3721
|
-
onActivity: item => activityRings[peer]
|
|
3740
|
+
onActivity: item => activityRings[peer]?.append(item),
|
|
3722
3741
|
isPrivateTurn: () => !!turns.get(peer)?.private || contextPii(peer),
|
|
3723
|
-
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,
|
|
3724
3743
|
...(args.model ? { launchModel: args.model } : {}), ...(args.sessionId ? { resumeSessionId: args.sessionId } : {}),
|
|
3725
3744
|
cwd: executionCwd, watchdogMs: config.watchdog_ms, onPermission,
|
|
3726
3745
|
autoApprove: title => !stopping && HUB_TOOL_TITLES.has(title),
|
|
3727
|
-
log: () => log(
|
|
3746
|
+
log: () => log(`${peer}: ACP runtime diagnostic: details withheld`),
|
|
3728
3747
|
onUsageDiagnostic: (observation, sessionId) => {
|
|
3729
3748
|
if (observation.source !== "usage_update" || observation.shape !== "context-used") return;
|
|
3730
3749
|
const current = contextSession(peer); if (!current) return;
|
|
3731
|
-
|
|
3732
|
-
|
|
3733
|
-
|
|
3750
|
+
const reading = readNativeContext(adapter,
|
|
3751
|
+
{ used: observation.contextUsed, size: observation.contextCapacity }, sessionId, Date.now());
|
|
3752
|
+
if (reading) contexts.report(peer, reading, current);
|
|
3734
3753
|
},
|
|
3735
3754
|
onTurnFailure: () => { supervisionTurns.delete(peer); },
|
|
3736
3755
|
mcpServers,
|
|
3737
3756
|
preamble: sessionRoleContract(peer),
|
|
3738
3757
|
} } });
|
|
3739
|
-
if (!(owner
|
|
3758
|
+
if (!isAcpPeer(owner)) throw new Error("ACP factory did not return its native transport");
|
|
3740
3759
|
recoveryTaskPreface(peer); await ensurePreface(peer);
|
|
3741
3760
|
try { await owner.start(); await reconcileNativeStart(peer, owner); }
|
|
3742
3761
|
catch (error) {
|
|
3743
|
-
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)`);
|
|
3744
3765
|
if (error instanceof QwenIdentityError) throw error;
|
|
3745
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)");
|
|
3746
3767
|
}
|
|
@@ -3753,7 +3774,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3753
3774
|
admitAttach: () => membershipAllowed(peer),
|
|
3754
3775
|
permissionMode: permissionMode(peer),
|
|
3755
3776
|
onTokens: (added) => void addTokens(peer, added),
|
|
3756
|
-
|
|
3777
|
+
contextReader: adapter.budget?.context,
|
|
3778
|
+
onContext: adapter.budget?.context ? (reading) => contexts.report(peer, reading, contextSession(peer)) : undefined,
|
|
3757
3779
|
// #215: before the thread's first turn_start, so the planner can tell which turns belong to which thread.
|
|
3758
3780
|
onThread: (thread, fresh) => { lastCodexThreads.set(peer, thread); event({ type: "native_thread", peer, thread, fresh }); },
|
|
3759
3781
|
onTurn: (native) => {
|
|
@@ -3802,7 +3824,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3802
3824
|
...HUB_TOOLS.map((name) => [`tools.${name}.approval_mode`, '"approve"']),
|
|
3803
3825
|
].flatMap(([k, v]) => ["-c", `mcp_servers.agent-hub.${k}=${v}`]),
|
|
3804
3826
|
preamble: sessionRoleContract(peer),
|
|
3805
|
-
onUsage: (rateLimits, hard) => budget.report(peer,
|
|
3827
|
+
onUsage: (rateLimits, hard) => { const windows = readNativeQuota(adapter, rateLimits); if (windows) budget.report(peer, windows, hard); },
|
|
3806
3828
|
usagePollMs: config.budget.poll_min * 60_000,
|
|
3807
3829
|
cwd: executionCwd,
|
|
3808
3830
|
watchdogMs: config.watchdog_ms,
|
|
@@ -3828,7 +3850,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3828
3850
|
staySwitch: () => activeRouting(),
|
|
3829
3851
|
onRoute: record => event({ type: "route", peer, ...record }),
|
|
3830
3852
|
efficientWaitMs: activeRouting().pi.efficient_wait_ms,
|
|
3831
|
-
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); },
|
|
3832
3854
|
onCooldown: record => {
|
|
3833
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"}`);
|
|
3834
3856
|
event({ type: "cooldown", peer, ...record });
|
|
@@ -4112,9 +4134,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4112
4134
|
return { ok: false, error: `unknown peer "${peer}"` };
|
|
4113
4135
|
}
|
|
4114
4136
|
|
|
4115
|
-
async function admitPiRequest(peer
|
|
4137
|
+
async function admitPiRequest(peer: string): Promise<{ allowed: boolean; reason?: string; remainingMs?: number }> {
|
|
4116
4138
|
const current = bus.peers.get(peer);
|
|
4117
|
-
if (!(current
|
|
4139
|
+
if (!isPiPeer(current)) return { allowed: false, reason: "Pi owner is not active" };
|
|
4118
4140
|
const decisions = tasks.admitExecutionEnvelopes(current.budgetEnvelopes, peer, "model_calls");
|
|
4119
4141
|
const denied = decisions.find(d => !d.allowed);
|
|
4120
4142
|
const stop = denied ? current.recordBudgetStop(denied) : undefined;
|
|
@@ -4147,7 +4169,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4147
4169
|
} else {
|
|
4148
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}` };
|
|
4149
4171
|
const peer = bus.peers.get(id);
|
|
4150
|
-
if (peer
|
|
4172
|
+
if (isLocalPeer(peer)) {
|
|
4151
4173
|
const generation = pauseGeneration.get(id);
|
|
4152
4174
|
const launch = peer.recoveryMetadata().launch as { route?: string; model: string };
|
|
4153
4175
|
try { await validateLocalChoice(launch.route, launch.model); }
|
|
@@ -4168,6 +4190,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4168
4190
|
|
|
4169
4191
|
const conductor = new Conductor(conductorHolds, {
|
|
4170
4192
|
roles: () => config.roles, capabilities: () => config.capabilities, instances: () => declarations,
|
|
4193
|
+
adapterCatalog,
|
|
4171
4194
|
status: () => {
|
|
4172
4195
|
const quota = budget.status();
|
|
4173
4196
|
const ids = [...new Set([...bus.knownPeers(), ...Object.keys(quota), ...conductorHolds.list().map(h => h.peer)])];
|
|
@@ -4200,7 +4223,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4200
4223
|
pause: peer => { pauseGeneration.set(peer, (pauseGeneration.get(peer) ?? 0) + 1); bus.pause(peer); writeStatus(); },
|
|
4201
4224
|
validateRelease: async peer => {
|
|
4202
4225
|
const owner = bus.peers.get(peer);
|
|
4203
|
-
if (!(owner
|
|
4226
|
+
if (!isLocalPeer(owner)) return;
|
|
4204
4227
|
const generation = pauseGeneration.get(peer);
|
|
4205
4228
|
const launch = owner.recoveryMetadata().launch as { route?: string; model: string };
|
|
4206
4229
|
await validateLocalChoice(launch.route, launch.model);
|
|
@@ -4375,7 +4398,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4375
4398
|
function roleHolders(candidate: HubConfig): { planners: string[]; conductors: string[] } {
|
|
4376
4399
|
// A valid transfer may briefly have two conductors while its leaves are planned. Validate the final map later.
|
|
4377
4400
|
const policy = Object.fromEntries(Object.entries(candidate.roles).map(([peer, roles]) => [peer, roles.filter(role => role !== "conductor")]));
|
|
4378
|
-
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));
|
|
4379
4402
|
return { planners: settled.planners, conductors: Object.keys(candidate.roles).filter(peer => candidate.roles[peer]!.includes("conductor")).sort() };
|
|
4380
4403
|
}
|
|
4381
4404
|
function prepareConfiguration(sources: ConfigSources, authority: SettingAuthority, scope?: string, confirmed: readonly string[] = []) {
|
|
@@ -4389,7 +4412,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4389
4412
|
const prefix = layer.name.endsWith(".toml") ? "routing" : "";
|
|
4390
4413
|
for (const path of declaredPaths(layer.raw, prefix)) paths.add(path);
|
|
4391
4414
|
}
|
|
4392
|
-
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);
|
|
4393
4416
|
for (const key of paths) {
|
|
4394
4417
|
if (scope && key !== scope) continue;
|
|
4395
4418
|
const route = key.startsWith("routing."), path = (route ? key.slice(8) : key).split(".");
|
|
@@ -4404,10 +4427,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4404
4427
|
const after = valueAt(route ? sources.routing : opts.config && !current ? embeddedConfig : fileConfig, path);
|
|
4405
4428
|
const rawAfter = current ? valueAt(current.raw, path) : undefined;
|
|
4406
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.
|
|
4407
4431
|
// Instance declarations are restart-bound. Their derived role/permission/start defaults cannot leak into
|
|
4408
4432
|
// the live catalog while the old identities, paths and account callbacks still own the daemon.
|
|
4409
4433
|
const boundId = ["roles", "permission_modes", "peers", "permission_default_sources"].includes(path[0] ?? "") ? path[1] : undefined;
|
|
4410
|
-
|
|
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))) {
|
|
4411
4436
|
rejected.push({ key, reason: "instance declaration changed; its derived defaults apply together after daemon restart" });
|
|
4412
4437
|
continue;
|
|
4413
4438
|
}
|
|
@@ -4471,7 +4496,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4471
4496
|
}
|
|
4472
4497
|
candidate.retired = fileConfig.retired;
|
|
4473
4498
|
routing.retired = sources.routing.retired;
|
|
4474
|
-
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)]);
|
|
4475
4500
|
const running = structuredClone({ ...config, roles: policyRoles });
|
|
4476
4501
|
for (const change of changes) if (change.applies !== "restart") putConfig(running, change.key, change.after);
|
|
4477
4502
|
running.retired = fileConfig.retired;
|
|
@@ -4505,7 +4530,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4505
4530
|
const texts = configSourceTexts(opts.cwd), next = configSourceTexts(scratch);
|
|
4506
4531
|
texts["config.json"] = next["config.json"];
|
|
4507
4532
|
texts["config.local.json"] = next["config.local.json"];
|
|
4508
|
-
const prepared = prepareConfiguration(parseConfigSources(texts), authority, scope);
|
|
4533
|
+
const prepared = prepareConfiguration(parseConfigSources(texts, undefined, opts.cwd), authority, scope);
|
|
4509
4534
|
const refusal = prepared.rejected.find(item => item.key === scope);
|
|
4510
4535
|
if (refusal) throw new Error(`${refusal.key}: ${refusal.reason}`);
|
|
4511
4536
|
return prepared.fileConfig;
|
|
@@ -4513,7 +4538,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4513
4538
|
function routingWriteCheck(authority: SettingAuthority, overlay: import("./routing.ts").RoutingOverlay | undefined, scope?: string): void {
|
|
4514
4539
|
const texts = configSourceTexts(opts.cwd);
|
|
4515
4540
|
texts["routing.local.toml"] = overlay === undefined ? null : overlayToml(overlay);
|
|
4516
|
-
const prepared = prepareConfiguration(parseConfigSources(texts), authority, scope);
|
|
4541
|
+
const prepared = prepareConfiguration(parseConfigSources(texts, undefined, opts.cwd), authority, scope);
|
|
4517
4542
|
const refusal = prepared.rejected.find(item => item.key === scope);
|
|
4518
4543
|
if (refusal) throw new Error(`${refusal.key}: ${refusal.reason}`);
|
|
4519
4544
|
}
|
|
@@ -4530,7 +4555,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4530
4555
|
const texts = configSourceTexts(opts.cwd);
|
|
4531
4556
|
fingerprint = configFingerprint(texts);
|
|
4532
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" };
|
|
4533
|
-
const sources = parseConfigSources(texts);
|
|
4558
|
+
const sources = parseConfigSources(texts, undefined, opts.cwd);
|
|
4534
4559
|
const prepared = prepareConfiguration(sources, authority, scope, confirm === undefined ? [] : reloadConfirmation!.keys);
|
|
4535
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" };
|
|
4536
4561
|
if (scope && prepared.rejected.some(item => item.key === scope)) return { ok: false, error: prepared.rejected.find(item => item.key === scope)!.reason };
|
|
@@ -4564,7 +4589,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4564
4589
|
desiredRouting = structuredClone(prepared.routing);
|
|
4565
4590
|
acknowledgeConfiguration(sources, prepared.accepted);
|
|
4566
4591
|
if (priorDesired !== JSON.stringify([desiredConfig, desiredRouting, acceptedSources.trustLayers])) configRevision++;
|
|
4567
|
-
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! }));
|
|
4568
4593
|
pendingRestart.push(...routingChanges.filter(key => configApply(key).applies === "restart").map(key => ({ key, reason: configApply(key).reason! })));
|
|
4569
4594
|
retainedConfig = retainedConfigAfterReload(sources, prepared.fileConfig, prepared.rejected, scope);
|
|
4570
4595
|
storedConfigCache = undefined;
|
|
@@ -4618,9 +4643,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4618
4643
|
let routingProblem: string | undefined;
|
|
4619
4644
|
try { parseRouting(routingText(opts.cwd), readOverlay(opts.cwd)); } catch (error) { routingProblem = (error as Error).message; }
|
|
4620
4645
|
const rows = settingRows({
|
|
4621
|
-
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 } : {}),
|
|
4622
4647
|
running: (path) => path[0] === "roster" ? onRoster(path[1]!) : path[0] === "tasks" && path[1] === "wip_limits" ? tasks.wipLimit(path[2]!) : valueAt(config, path),
|
|
4623
|
-
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,
|
|
4624
4649
|
roles: roleState,
|
|
4625
4650
|
mode: (peer) => {
|
|
4626
4651
|
const owner = bus.peers.get(peer), attached = !!owner && owner.state !== "offline";
|
|
@@ -4659,14 +4684,14 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4659
4684
|
if (owner?.state === "busy" && !now) return `${peer} is in a turn; retry when it is idle, or pass --now`;
|
|
4660
4685
|
return undefined;
|
|
4661
4686
|
}
|
|
4662
|
-
function codexResumeCommand(peer
|
|
4663
|
-
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);
|
|
4664
4689
|
const command = declarations[peer] ? `ahub codex --as ${peer} resume` : "ahub codex resume";
|
|
4665
4690
|
return thread && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(thread) ? `${command} ${thread}` : command;
|
|
4666
4691
|
}
|
|
4667
4692
|
function retainedTerminalNotice(peer: string): string | undefined {
|
|
4668
4693
|
const owner = bus.peers.get(peer);
|
|
4669
|
-
return definitionOf(peer)?.cli.retainedTerminalNotice?.(peer, owner
|
|
4694
|
+
return definitionOf(peer)?.cli.retainedTerminalNotice?.(peer, isPiPeer(owner) ? owner.mode : undefined, codexResumeCommand(peer));
|
|
4670
4695
|
}
|
|
4671
4696
|
|
|
4672
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>> {
|
|
@@ -4676,7 +4701,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4676
4701
|
const terminalNotice = retainedTerminalNotice(peer);
|
|
4677
4702
|
let handoff: Awaited<ReturnType<Tasks["reassignForRoster"]>> = [];
|
|
4678
4703
|
try {
|
|
4679
|
-
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);
|
|
4680
4705
|
if (!after) {
|
|
4681
4706
|
const refusal = rosterRemovalRefusal(peer, now);
|
|
4682
4707
|
if (refusal) return { ok: false, error: refusal };
|
|
@@ -4695,9 +4720,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4695
4720
|
if (!after) {
|
|
4696
4721
|
for (const pending of permissions.values()) if (pending.peer === peer) pending.done(undefined);
|
|
4697
4722
|
const owner = bus.peers.get(peer);
|
|
4698
|
-
if (owner
|
|
4699
|
-
else if (owner
|
|
4700
|
-
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();
|
|
4701
4726
|
else if (owner && owner.state !== "offline") {
|
|
4702
4727
|
const result = await stopPeer(peer, true);
|
|
4703
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`);
|
|
@@ -4710,7 +4735,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4710
4735
|
handoff = await tasks.reassignForRoster(peer);
|
|
4711
4736
|
} else {
|
|
4712
4737
|
const owner = bus.peers.get(peer);
|
|
4713
|
-
if (owner
|
|
4738
|
+
if (isPiPeer(owner) && owner.mode === "tui") await owner.resumeFromRoster();
|
|
4714
4739
|
}
|
|
4715
4740
|
const reloaded = await reloadConfig(authority, `roster.${peer}`);
|
|
4716
4741
|
if (reloaded.ok !== true) return { ...reloaded, saved: true };
|
|
@@ -4825,14 +4850,14 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4825
4850
|
const { def, value } = input;
|
|
4826
4851
|
if (def.group === "Roster") {
|
|
4827
4852
|
try {
|
|
4828
|
-
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);
|
|
4829
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 }) };
|
|
4830
4855
|
} catch (error) { return { ok: false, error: sanitize((error as Error).message) }; }
|
|
4831
4856
|
}
|
|
4832
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."] };
|
|
4833
4858
|
if (def.group === "Permissions") {
|
|
4834
4859
|
const peer = def.path.at(-1)!;
|
|
4835
|
-
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 } : {}) };
|
|
4836
4861
|
}
|
|
4837
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"}`] };
|
|
4838
4863
|
try {
|
|
@@ -4927,7 +4952,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4927
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.
|
|
4928
4953
|
if (!peer()) return bad;
|
|
4929
4954
|
const result = await hubStart(a.peer, "the dashboard");
|
|
4930
|
-
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)}` };
|
|
4931
4956
|
// A refusal the hub worded itself carries the command to run by hand; an adapter's own failure text stays in the log.
|
|
4932
4957
|
if (typeof result.command === "string" || typeof a.peer === "string" && !membershipAllowed(a.peer)) return { ok: false, error: sanitize(String(result.error)) };
|
|
4933
4958
|
log(`dashboard start ${a.peer} failed: ${String(result.error)}`);
|
|
@@ -4963,7 +4988,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4963
4988
|
...(checkPending.size !== 0 ? ["isolated checks queued or running"] : []),
|
|
4964
4989
|
...(piCallsInFlight() !== 0 ? ["Pi tool calls in flight"] : []),
|
|
4965
4990
|
...(releasing ? ["ready tasks being released"] : []),
|
|
4966
|
-
...([...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`)),
|
|
4967
4992
|
],
|
|
4968
4993
|
integrity: { current: integrityAs(restored?.integrity), ...(restored?.integrity ? { expected: restored.integrity } : {}) },
|
|
4969
4994
|
peers: Object.fromEntries([...(recoveryPeerSnapshot ?? []), ...Object.values(recoveryPeers()).filter((peer) => !(recoveryPeerSnapshot ?? []).some((saved) => saved.id === peer.id))].map((peer) => {
|
|
@@ -4984,6 +5009,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4984
5009
|
const incompatibleRecoveryTarget = () => {
|
|
4985
5010
|
const cancelled = incompatibleCancellationTarget();
|
|
4986
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";
|
|
4987
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";
|
|
4988
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";
|
|
4989
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";
|
|
@@ -4995,7 +5021,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4995
5021
|
}
|
|
4996
5022
|
|
|
4997
5023
|
if (msg.op === "inspect" || msg.op === "prepare" || msg.op === "commit") {
|
|
4998
|
-
for (const pi of bus.peers.values()) if (pi
|
|
5024
|
+
for (const pi of bus.peers.values()) if (isPiPeer(pi) && pi.state === "idle") {
|
|
4999
5025
|
try { await pi.captureResume(); } catch (error) { return recoveryError((error as Error).message); }
|
|
5000
5026
|
if (checkTarget) { const error = incompatibleRecoveryTarget(); if (error) return recoveryError(error); }
|
|
5001
5027
|
}
|
|
@@ -5105,7 +5131,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5105
5131
|
return recoveryError("unknown recovery operation");
|
|
5106
5132
|
}
|
|
5107
5133
|
|
|
5108
|
-
function claudePermissionLaunch(peer
|
|
5134
|
+
function claudePermissionLaunch(peer: string): { launchId: string; unattended: boolean; hookPurpose?: string } | undefined {
|
|
5109
5135
|
try {
|
|
5110
5136
|
const row = JSON.parse(readFileSync(join(opts.stateDir, claudeStateFile("claude-launch", peer)), "utf8"));
|
|
5111
5137
|
if (row.instanceId !== instanceId || row.permissionHook !== true || typeof row.launchId !== "string" || !row.launchId) return undefined;
|
|
@@ -5114,15 +5140,16 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5114
5140
|
}
|
|
5115
5141
|
async function changePermission(peer: unknown, mode: unknown, confirmed: unknown): Promise<Record<string, unknown>> {
|
|
5116
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" };
|
|
5117
5144
|
if (definitionOf(peer)?.permission.unmanaged) return { ok: false, error: definitionOf(peer)!.permission.unmanaged };
|
|
5118
|
-
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 };
|
|
5119
5146
|
if (requestedPeerStops.has(peer)) return { ok: false, error: "peer stop is in progress; wait for ahub status before changing its mode" };
|
|
5120
5147
|
const owner = bus.peers.get(peer);
|
|
5121
5148
|
if (mode === "ask" && (!owner || owner.state === "offline")) {
|
|
5122
5149
|
// A Codex proxy outlives its TUI and keeps its own copy of the mode: clear that too, or the next TUI that attaches
|
|
5123
5150
|
// to it would run a mode the status no longer shows.
|
|
5124
5151
|
// It is never refused here: a leftover non-ask mode must always be undoable while the peer is away.
|
|
5125
|
-
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;
|
|
5126
5153
|
if (note) log(`permission mode codex: ${note}`);
|
|
5127
5154
|
const from = permissionMode(peer);
|
|
5128
5155
|
permissionModes.set(peer, "ask"); permissionModeSources.set(peer, "human runtime command"); pendingPermissionDefaults.delete(peer);
|
|
@@ -5130,22 +5157,22 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5130
5157
|
log(`permission mode ${peer}: ${from} -> ask (offline hub choice cleared)`); writeStatus();
|
|
5131
5158
|
return { ok: true, peer, permissionMode: "ask", ...(note ? { note } : {}) };
|
|
5132
5159
|
}
|
|
5133
|
-
if (owner
|
|
5160
|
+
if (isCodexPeer(owner) && !owner.proxyAttached) return { ok: false, error: "Codex TUI is not behind the hub proxy; reconnect with ahub codex" };
|
|
5134
5161
|
if (!owner || owner.state === "offline") return { ok: false, error: `${peer} is not attached; start it through ahub first` };
|
|
5135
5162
|
if (mode === undefined) return { ok: true, peer, permissionMode: permissionDisplay(peer, owner) };
|
|
5136
5163
|
if (!isPermissionMode(mode)) return { ok: false, error: "mode must be ask, ask-when-needed or never-ask" };
|
|
5137
5164
|
if (mode === "never-ask" && confirmed !== true) return { ok: false, error: "never-ask needs --yes or console confirmation" };
|
|
5138
5165
|
if (definitionOf(peer)?.transport === "claude-channel") {
|
|
5139
5166
|
const launch = claudePermissionLaunch(peer);
|
|
5140
|
-
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" };
|
|
5141
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" };
|
|
5142
|
-
} else if (owner
|
|
5169
|
+
} else if (isCodexPeer(owner)) {
|
|
5143
5170
|
if (!owner.proxyAttached) return { ok: false, error: "Codex TUI is not behind the hub proxy; reconnect with ahub codex" };
|
|
5144
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" };
|
|
5145
5172
|
}
|
|
5146
5173
|
const from = permissionMode(peer);
|
|
5147
5174
|
try {
|
|
5148
|
-
if (owner
|
|
5175
|
+
if (isAcpPeer(owner) || isCodexPeer(owner)) await owner.setPermissionMode(mode);
|
|
5149
5176
|
} catch (error) { return { ok: false, error: (error as Error).message }; }
|
|
5150
5177
|
if (bus.peers.get(peer) !== owner || bus.stateOf(peer) === "offline") return { ok: false, error: `${peer} session changed; inspect status and retry` };
|
|
5151
5178
|
permissionModes.set(peer, mode);
|
|
@@ -5158,6 +5185,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5158
5185
|
}
|
|
5159
5186
|
|
|
5160
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" };
|
|
5161
5189
|
if (typeof peer !== "string" || !pendingPermissionDefaults.has(peer)) return { ok: false, error: "no pending permission default for this peer" };
|
|
5162
5190
|
const source = pendingPermissionDefaults.get(peer)!;
|
|
5163
5191
|
if (confirmed !== true) {
|
|
@@ -5167,7 +5195,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5167
5195
|
}
|
|
5168
5196
|
const owner = bus.peers.get(peer);
|
|
5169
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" };
|
|
5170
|
-
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" };
|
|
5171
5199
|
if (!starting.has(peer) && owner && owner.state !== "offline") {
|
|
5172
5200
|
const result = await changePermission(peer, "never-ask", true);
|
|
5173
5201
|
if (!result.ok) return result;
|
|
@@ -5198,9 +5226,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5198
5226
|
const result = await permissionChange(peer, async () => {
|
|
5199
5227
|
if (stopping) return { ok: false, error: "hub is stopping" };
|
|
5200
5228
|
const selected = permissionMode(peer);
|
|
5201
|
-
if (owner
|
|
5202
|
-
if (!(
|
|
5203
|
-
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);
|
|
5204
5232
|
else await owner.setPermissionMode(selected);
|
|
5205
5233
|
}
|
|
5206
5234
|
// The bus may immediately drain at add(): do not expose an idle adapter until its current choice is applied.
|
|
@@ -5237,7 +5265,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5237
5265
|
if (!PEER_ID.test(c.peer) || c.peer === USER || c.peer === "hub") return sock.close(4403, "peer id is reserved or malformed");
|
|
5238
5266
|
if (!membershipAllowed(c.peer)) return sock.close(4000, "peer is off roster; retry when a person adds it");
|
|
5239
5267
|
} else {
|
|
5240
|
-
c.peer = String(msg.peer ??
|
|
5268
|
+
c.peer = String(msg.peer ?? defaultChannelPeer(undefined));
|
|
5241
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");
|
|
5242
5270
|
const kimiPull = !!definitionOf(c.peer)?.attach?.admission && (kimiSessionMode ?? startMode(c.peer)) === "tui" && msg.channel === false;
|
|
5243
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");
|
|
@@ -5253,9 +5281,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5253
5281
|
if (typeof msg.launchId === "string" && msg.launchId.length <= 128) c.launchId = msg.launchId;
|
|
5254
5282
|
}
|
|
5255
5283
|
let peer = bus.peers.get(c.peer);
|
|
5256
|
-
if (kimiPull && peer
|
|
5284
|
+
if (kimiPull && isAcpPeer(peer) && peer.state === "offline") peer = undefined;
|
|
5257
5285
|
if (!peer) bus.add((peer = new WsPeer(c.peer)));
|
|
5258
|
-
if (!(peer
|
|
5286
|
+
if (!isWsPeer(peer)) return sock.close(4409, "peer id is taken by a hub-managed adapter");
|
|
5259
5287
|
const ws = peer;
|
|
5260
5288
|
if (msg.standby === true && ws.standbyHeld(msg.channel === false)) return sock.close(4000, "another session holds or is claiming this peer");
|
|
5261
5289
|
// Without pushes it stands by, as a replaced session does, and never strands a channel session's deliveries.
|
|
@@ -5321,7 +5349,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5321
5349
|
}
|
|
5322
5350
|
case "delivery_receipt": {
|
|
5323
5351
|
const peer = c.peer ? bus.peers.get(c.peer) : undefined;
|
|
5324
|
-
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" });
|
|
5325
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" });
|
|
5326
5354
|
const reason = typeof msg.reason === "string" ? sanitize(msg.reason).replace(/\s+/g, " ").trim().slice(0, 300) : "";
|
|
5327
5355
|
bus.deliveryReceipt(peer.id, { id: msg.deliveryId, state: msg.state, ...(msg.state !== "accepted" ? { reason: reason || "Claude bridge could not confirm notification delivery" } : {}) });
|
|
@@ -5331,7 +5359,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5331
5359
|
// The attached session can explicitly claim queued work even during a native turn (#493).
|
|
5332
5360
|
// Pull and push share holds and journal settlement; work read here is completed, never pushed again.
|
|
5333
5361
|
const peer = c.peer ? bus.peers.get(c.peer) : undefined;
|
|
5334
|
-
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" });
|
|
5335
5363
|
try {
|
|
5336
5364
|
const envs = bus.pull(peer.id);
|
|
5337
5365
|
if (!envs) return void reply({ ok: false, error: queueHold(peer.id) ?? (bus.isRecoveryHeld ? "recovery is holding deliveries" : pauseReason(peer.id)) });
|
|
@@ -5343,7 +5371,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5343
5371
|
}
|
|
5344
5372
|
case "delivery_complete": {
|
|
5345
5373
|
const peer = c.peer ? bus.peers.get(c.peer) : undefined;
|
|
5346
|
-
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" });
|
|
5347
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" });
|
|
5348
5376
|
}
|
|
5349
5377
|
case "execution_budget": {
|
|
@@ -5419,7 +5447,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5419
5447
|
if (launch.session && launch.session !== native.sessionId) return void reply({ ok: false });
|
|
5420
5448
|
launch.session = native.sessionId;
|
|
5421
5449
|
const owner = bus.peers.get(peer);
|
|
5422
|
-
if (!(owner
|
|
5450
|
+
if (!isWsPeer(owner) || owner.state === "offline" && !owner.claiming) return void reply({ ok: false });
|
|
5423
5451
|
if (launch.generation && launch.generation !== owner.sessionGeneration) return void reply({ ok: false });
|
|
5424
5452
|
launch.generation = owner.sessionGeneration;
|
|
5425
5453
|
const current = () => !stopping && nativeHookLaunches.get(peer, msg.nativeLaunchId) === launch && bus.peers.get(peer) === owner && owner.sessionGeneration === launch.generation && owner.state !== "offline";
|
|
@@ -5517,15 +5545,15 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5517
5545
|
const nativePeer = nativeClaude ? bus.peers.get(peer) : undefined;
|
|
5518
5546
|
const permissionLaunch = definitionOf(peer)?.transport === "claude-channel" ? claudePermissionLaunch(peer) : undefined;
|
|
5519
5547
|
const permission = nativeClaude && permissionLaunch && !permissionLaunch.unattended && msg.nativeLaunchId === permissionLaunch.launchId ? permissionMode(peer) : undefined;
|
|
5520
|
-
if (permission !== undefined && nativePeer
|
|
5548
|
+
if (permission !== undefined && isWsPeer(nativePeer)) claudePermissionGenerations.set(peer, nativePeer.sessionGeneration);
|
|
5521
5549
|
const protectedWrite = scopedWriteBlocked(phase, permission, msg.tool, () => projectFileTool(msg.tool, msg.input, opts.cwd, config.protect_instruction_files, config.data?.readonly));
|
|
5522
5550
|
const permissionReply = phase === "pre" && permission !== undefined ? { permission: permission === "ask-when-needed" && protectedWrite ? "ask" : permission, projectRoot: opts.cwd, protectInstructionFiles: config.protect_instruction_files } : {};
|
|
5523
5551
|
// Permission-only hooks carry no native-turn evidence and must never drive busy state.
|
|
5524
5552
|
const purpose = permissionLaunch?.hookPurpose ?? msg.hookPurpose;
|
|
5525
|
-
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")));
|
|
5526
5554
|
if (nativeClaude && !observesNative) return void reply({ t: "facts", ok: true, ...permissionReply });
|
|
5527
5555
|
if (phase === "session") { recordSessions(); return void reply({ t: "facts", ok: true }); }
|
|
5528
|
-
if ((phase === "start" || phase === "pre") && nativePeer
|
|
5556
|
+
if ((phase === "start" || phase === "pre") && isWsPeer(nativePeer)) {
|
|
5529
5557
|
const previous = nativeTurnStart.get(peer);
|
|
5530
5558
|
const current = claudeSession(peer);
|
|
5531
5559
|
if (phase === "start" || nativePeer.state !== "busy" || previous?.generation !== nativePeer.sessionGeneration || previous?.session !== sessionId || previous?.launch !== (current.launchId ?? "")) {
|
|
@@ -5548,7 +5576,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5548
5576
|
const st = hookStats.get(peer)!;
|
|
5549
5577
|
event({ type: "hook_stats", peer, n: st.n, startupMs: Math.round(st.startupMs), hubMs: Math.round(st.hubMs), maxStartupMs: Math.round(st.maxStartupMs) });
|
|
5550
5578
|
hookStats.delete(peer);
|
|
5551
|
-
if (id && nativePeer
|
|
5579
|
+
if (id && isWsPeer(nativePeer)) nativePeer.nativeStopped(sessionId!);
|
|
5552
5580
|
if (factsOn()) { factSession(peer, sessionId); readbacks(peer, claudeTranscript(sessionId, msg.transcriptPath, peer)); }
|
|
5553
5581
|
};
|
|
5554
5582
|
if (!nativeClaude) { finishStop(); reply({ t: "facts", ok: true }); return; } // legacy bookkeeping cannot certify native completion
|
|
@@ -5557,13 +5585,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5557
5585
|
const readCompletion = () => { transcript ??= claudeTranscript(sessionId, msg.transcriptPath, peer); return session.sessionId && transcript ? readClaudeTranscriptCompletion(session.sessionId, transcript) : undefined; };
|
|
5558
5586
|
const completionId = (id: string) => createHash("sha256").update(`${sessionId}:${session.launchId ?? "legacy"}:${id}`).digest("hex");
|
|
5559
5587
|
const started = nativeTurnStart.get(peer);
|
|
5560
|
-
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;
|
|
5561
5589
|
// A seen previous completion is a duplicate only outside an active start. During B it is B's baseline.
|
|
5562
5590
|
if (!activeStart) {
|
|
5563
5591
|
const latest = readCompletion();
|
|
5564
5592
|
if (latest?.at && nativeStopsSeen.has(completionId(latest.id))) return void reply({ t: "facts", ok: true });
|
|
5565
5593
|
}
|
|
5566
|
-
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 }); }
|
|
5567
5595
|
const scopeCurrent = () => {
|
|
5568
5596
|
const now = claudeSession(peer);
|
|
5569
5597
|
return !stopping && bus.peers.get(peer) === nativePeer && nativePeer.sessionGeneration === started.generation && now.sessionId === started.session && (now.launchId ?? "") === started.launch;
|
|
@@ -5644,7 +5672,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5644
5672
|
// Judged by the live owner: a headless Kimi started by --mode headless is watchable under a TUI start mode.
|
|
5645
5673
|
const owner = bus.peers.get(peer);
|
|
5646
5674
|
const admission = definitionOf(peer)?.attach?.admission;
|
|
5647
|
-
if (admission && !(owner
|
|
5675
|
+
if (admission && !isAcpPeer(owner) && (isWsPeer(owner) || (kimiSessionMode ?? startMode(peer)) === "tui")) return void reply({ ok: false, error: admission.watchRefusal });
|
|
5648
5676
|
if (!membershipAllowed(peer)) return void reply({ ok: false, error: `${peer} is off roster` });
|
|
5649
5677
|
c.watch?.();
|
|
5650
5678
|
c.watch = activityRings[peer]!.subscribe(item => {
|
|
@@ -5767,7 +5795,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5767
5795
|
if (c.role !== "console") return void reply({ ok: false, error: "native TUI launch is console only" });
|
|
5768
5796
|
const peer = String(msg.peer), owner = bus.peers.get(peer);
|
|
5769
5797
|
if (stopping || recoveryActive() || !membershipAllowed(peer) || rosterChanging === peer || requestedPeerStops.has(peer) ||
|
|
5770
|
-
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" });
|
|
5771
5799
|
try {
|
|
5772
5800
|
if (msg.binding !== undefined && (!msg.binding || typeof msg.binding.launchId !== "string" || typeof msg.binding.sessionId !== "string")) throw new Error("invalid binding");
|
|
5773
5801
|
const options = msg.args ?? {};
|