@staix/agent-hub 0.12.39 → 0.12.40
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 +26 -0
- package/README.md +15 -10
- package/docs/agent-notes/adapters.md +4 -1
- package/docs/agent-notes/budget.md +2 -0
- package/docs/agent-notes/bus.md +2 -2
- package/docs/agent-notes/daemon.md +10 -6
- package/docs/agent-notes/models.md +4 -4
- package/docs/agent-notes/tasks.md +3 -0
- package/docs/agent-notes/tests.md +1 -1
- package/docs/memory.md +1 -1
- package/docs/operations.md +152 -28
- package/docs/quickstart.md +19 -5
- package/docs/security.md +19 -7
- package/docs/smoke.md +2 -2
- package/docs/specs/2026-09-19-agent-hub-design.md +158 -70
- package/docs/specs/2026-10-04-switchyard-source-port-design.md +12 -7
- package/docs/verification/2026-10-20-0.12.40.md +63 -0
- package/docs/verified.json +38 -19
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +413 -147
- package/src/adapters/acp.ts +34 -1
- package/src/adapters/claude-channel.ts +35 -4
- package/src/adapters/codex-appserver.ts +12 -3
- package/src/adapters/definitions/builtin-metadata.ts +2 -0
- package/src/adapters/definitions/native-cli-doctor.ts +8 -5
- package/src/adapters/definitions/native-cli-entry-claude.ts +7 -2
- package/src/adapters/definitions/native-cli-entry-codex.ts +13 -14
- package/src/adapters/definitions/native-cli-entry-jcode.ts +13 -3
- package/src/adapters/definitions/native-cli-entry-pi.ts +9 -7
- package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
- package/src/adapters/definitions/native-cli-jcode-mcp.ts +55 -1
- package/src/adapters/jcode.ts +110 -4
- package/src/adapters/local-worker.ts +21 -37
- package/src/adapters/pi.ts +7 -0
- package/src/cli/console-state.ts +1 -0
- package/src/cli/console.ts +11 -1
- package/src/cli/help.ts +13 -8
- package/src/cli/identity-audit.ts +33 -7
- package/src/cli/jcode-mcp.ts +1 -1
- package/src/cli/main.ts +134 -24
- package/src/cli/output.ts +5 -2
- package/src/cli/status-lines.ts +1 -1
- package/src/cli/terminal-recovery.ts +54 -4
- package/src/cli/upgrade-interactive.ts +5 -0
- package/src/cli/upgrade-runtime.ts +13 -1
- package/src/cli/upgrade.ts +67 -1
- package/src/hub/board.ts +4 -2
- package/src/hub/bus.ts +8 -3
- package/src/hub/checks.ts +15 -5
- package/src/hub/conductor.ts +2 -1
- package/src/hub/config-apply.ts +20 -2
- package/src/hub/config-reload.ts +2 -8
- package/src/hub/control-client.ts +26 -3
- package/src/hub/daemon.ts +155 -68
- package/src/hub/delivery-superseded.ts +19 -2
- package/src/hub/events.ts +4 -1
- package/src/hub/hub-tools.ts +4 -4
- package/src/hub/inference.ts +4 -8
- package/src/hub/lifecycle.ts +3 -3
- package/src/hub/main-checkout.ts +36 -0
- package/src/hub/memory-profile-check.ts +6 -2
- package/src/hub/memory-profile.ts +18 -1
- package/src/hub/peers.ts +2 -0
- package/src/hub/ports.ts +1 -0
- package/src/hub/routing.ts +24 -14
- package/src/hub/settings.ts +1 -1
- package/src/hub/task-repository.ts +136 -0
- package/src/hub/tasks.ts +101 -21
- package/src/hub/terminal-launch.ts +130 -0
- package/src/hub/usage.ts +44 -18
- package/src/models/route/config.ts +1 -1
- package/src/omniroute/client.ts +7 -19
- package/templates/AGENTS.block.md +1 -0
- package/templates/routing.toml +10 -44
- package/src/switchyard/config.ts +0 -45
- package/src/switchyard/sidecar.ts +0 -136
package/src/hub/daemon.ts
CHANGED
|
@@ -20,7 +20,7 @@ import { buildReviewBrief } from "./review-brief.ts";
|
|
|
20
20
|
import { profileName, projectProfile, resolveProfile, mergeProfileOverrides, type ProfileName } from "./profiles.ts";
|
|
21
21
|
import { catalogAssignments, loadRoleCatalog, selectedRoles, roleIdentity } from "./roles.ts";
|
|
22
22
|
import { ActivityRing } from "./activity.ts";
|
|
23
|
-
import { JcodePeer } from "../adapters/jcode.ts";
|
|
23
|
+
import { JcodeOptionMismatch, JcodePeer } from "../adapters/jcode.ts";
|
|
24
24
|
import { jcodeMcpReady } from "../cli/jcode-mcp.ts";
|
|
25
25
|
import { loadSdlc, readSpecEdit, sdlcGh } from "./sdlc.ts";
|
|
26
26
|
import { claudeStateFile, ownTerminalRow, pathEvidence, transcriptPersisted } from "./claude-state.ts";
|
|
@@ -45,7 +45,7 @@ import { childEnv } from "./child-process.ts";
|
|
|
45
45
|
import { runCheck } from "./checks.ts";
|
|
46
46
|
import { DEFAULT_TASK_SWEEP, taskSweepConfig, type TaskSweepConfig } from "./task-sweep.ts";
|
|
47
47
|
import { ALL_MACHINE_LOCAL, configRepositoryPresent, configTracked, stripUntrusted } from "./config-trust.ts";
|
|
48
|
-
import { authorizeConfigLeaf, configApply, configChanges, validateEffectiveConfig } from "./config-apply.ts";
|
|
48
|
+
import { authorizeConfigLeaf, configApply, configChanges, validateEffectiveConfig, validatePetConfig } from "./config-apply.ts";
|
|
49
49
|
import { resolveSettledRoles } from "./settled-roles.ts";
|
|
50
50
|
import { CONFIG_SOURCE_FILES, configFingerprint, configSourceTexts, parseConfigSources, type ConfigSources } from "./config-reload.ts";
|
|
51
51
|
import { eventLog, readEvents, tokenDeltas, type RoleIdentity } from "./events.ts";
|
|
@@ -71,7 +71,6 @@ import { reviewerPoolConfig } from "./reviewer-pool-config.ts";
|
|
|
71
71
|
import { reviewDiff } from "../adapters/claude-print.ts";
|
|
72
72
|
import { Capture, skipTools } from "../memory/capture.ts";
|
|
73
73
|
import { DEFAULT_OMNIROUTE, OmniRoute, type OmniRouteConfig } from "../omniroute/client.ts";
|
|
74
|
-
import { Sidecar } from "../switchyard/sidecar.ts";
|
|
75
74
|
import { Briefs } from "../memory/brief.ts";
|
|
76
75
|
import { Board, isClosedTaskState, taskProposalAt, taskHasStarted, CLASSES, type Task, type TaskClass } from "./board.ts";
|
|
77
76
|
import { Budget, claudeWindows, codexWindows, DEFAULT_BUDGET, type BudgetConfig } from "./budget.ts";
|
|
@@ -81,7 +80,8 @@ import { closeSync, constants as fsConstants, openSync, lstatSync, readSync, sta
|
|
|
81
80
|
import { basename, isAbsolute } from "node:path";
|
|
82
81
|
import { fileURLToPath } from "node:url";
|
|
83
82
|
import { realPath, hubHome } from "./project.ts";
|
|
84
|
-
import
|
|
83
|
+
import { mainCheckoutWarning } from "./main-checkout.ts";
|
|
84
|
+
import type { BusEvent, PublishResult } from "./bus.ts";
|
|
85
85
|
import { CONDUCTOR_TOOLS, CONDUCTOR_TOOL_NAMES, DEFAULT_ROLES, roleContract, TASK_TOOL_NAMES, TASK_TOOLS, BOUNDED_TASK_TOOLS, profileTaskTools } from "./hub-tools.ts";
|
|
86
86
|
import { Conductor, ConductorHolds, conductorPeer, conductorProgressSink, publicConductorTask, publicPeerBudget, type ConductEvent } from "./conductor.ts";
|
|
87
87
|
import { SupervisionFeed } from "./supervision.ts";
|
|
@@ -90,7 +90,7 @@ import { launcherPreview } from "../cli/preview.ts";
|
|
|
90
90
|
import { AssignmentUndeliveredError, ESCALATE_AFTER, Tasks } from "./tasks.ts";
|
|
91
91
|
import { DEFAULT_INFERENCE, DIGEST, Inference, screenPii, type InferenceConfig } from "./inference.ts";
|
|
92
92
|
import { ask, ASK_NOTE_TITLE, RUN_START } from "./ask.ts";
|
|
93
|
-
import { currentRouting, detectSignals, OVERLAY_FILE, overlayToml, parseRouting, routingText, type Routing } from "./routing.ts";
|
|
93
|
+
import { currentRouting, loadRouting, detectSignals, OVERLAY_FILE, overlayToml, parseRouting, routingText, type Routing } from "./routing.ts";
|
|
94
94
|
import { projectSettings, checkSettingValue, ignoredLine, pendingUndo, pendingUndoIdentity, readOverlay, restoredValue, routingCandidate, storedLocally, settingDef as instanceSettingDef, settingRefusal, settingRows, settingText, undoSetting, valueAt, writeConfigSetting, writeRoutingSetting, type SettingAuthority, type SettingDef, type SettingRow, type SettingValue, type UndoIdentity } from "./settings.ts";
|
|
95
95
|
import { Bus } from "./bus.ts";
|
|
96
96
|
import { attribute, deliveryTask, type TaskAttribution } from "./attribution.ts";
|
|
@@ -113,7 +113,8 @@ import { DEFAULT_LIMITS, Limiter, PROJECT_LIMITS, type LimitsConfig } from "./li
|
|
|
113
113
|
import { changedPaths, hasTree, repoOf, snapshot, Turns, type TurnRecord } from "./snapshots.ts";
|
|
114
114
|
import { archiveRestartSnapshot, readRecoveryWaivers, readRestartSnapshot, removeRestartSnapshot, restartPath, writeRestartSnapshot, type RecoveryPhase, type RestartPeerSnapshot, type RestartSnapshot } from "./restart.ts";
|
|
115
115
|
import { consumeRecoveryContinuity, readRecoveryContinuity, retireRecoveryContinuity } from "./recovery-continuity.ts";
|
|
116
|
-
import { shellQuote } from "../cli/terminal-recovery.ts";
|
|
116
|
+
import { shellQuote, publishTerminalLaunch, terminalLaunchCandidate } from "../cli/terminal-recovery.ts";
|
|
117
|
+
import { TerminalLaunches, terminalOwnerReads } from "./terminal-launch.ts";
|
|
117
118
|
|
|
118
119
|
import { peerStartConfig, PEER_START_RETRY_MS, startModeOf, terminalTemplate, type PeerStartConfig, type StartMode } from "./start-mode.ts";
|
|
119
120
|
import { hasKimiHubMcp } from "../cli/kimi-mcp.ts";
|
|
@@ -201,6 +202,8 @@ export interface HubConfig {
|
|
|
201
202
|
coordination: "advisory" | "turn-free";
|
|
202
203
|
/** Switches for controlled ablations only (issue #110): `stale_notices: "deliver"` turns #106's dropping off. */
|
|
203
204
|
experiments?: { stale_notices?: "drop" | "deliver" };
|
|
205
|
+
/** Console-local character and window presentation (#280, #483); grants no authority. */
|
|
206
|
+
pet?: false | Record<string, unknown>;
|
|
204
207
|
/** Machine-local fields a config file set but git could not vouch for, and why (issue #17). */
|
|
205
208
|
ignored?: string[];
|
|
206
209
|
/** Settings that were removed or are about to be (issue #83): what each does now, for `hub.log` and `ahub doctor`. */
|
|
@@ -254,7 +257,7 @@ const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
|
|
|
254
257
|
/** The shared project config, then the machine's own file, which overrides it block by block (issue #17). */
|
|
255
258
|
const CONFIG_FILES = INSTANCE_CONFIG_FILES;
|
|
256
259
|
export interface ConfigLayer { name: typeof CONFIG_FILES[number]; raw: Record<string, any> }
|
|
257
|
-
const CONFIG_BLOCKS = ["memory", "roster", "roles", "catalog_roles", "pii", "data", "conductor", "budget", "context", "inference", "omniroute", ...NATIVE_CONFIG_BLOCKS, "approvals", "tasks", "task_sweep", "checks", "snapshots", "research", "bench", "limits", "review", "recovery", "capabilities", "mlx", "permission_modes", "peers", "terminal"];
|
|
260
|
+
const CONFIG_BLOCKS = ["memory", "roster", "roles", "catalog_roles", "pii", "data", "conductor", "budget", "context", "inference", "omniroute", ...NATIVE_CONFIG_BLOCKS, "approvals", "tasks", "task_sweep", "checks", "snapshots", "research", "bench", "limits", "review", "recovery", "capabilities", "mlx", "permission_modes", "peers", "terminal", "pet"];
|
|
258
261
|
|
|
259
262
|
/** Connection-time policy refresh reads role/feed fields only, never launches or machine-local configuration. */
|
|
260
263
|
export function loadConductorPolicy(cwd: string, layers?: readonly ConfigLayer[]): { roles: Record<string, string[]>; conductor: HubConfig["conductor"]; planner?: string; notices?: string[] } {
|
|
@@ -309,7 +312,13 @@ function syncMinutes(v: unknown): number {
|
|
|
309
312
|
return v;
|
|
310
313
|
}
|
|
311
314
|
|
|
312
|
-
|
|
315
|
+
/** Routing diagnostics must not make independent config reads depend on valid routing. */
|
|
316
|
+
function retiredRoutingSettings(cwd: string): readonly string[] {
|
|
317
|
+
try { return loadRouting(cwd).retired ?? []; }
|
|
318
|
+
catch { return []; } // Routing admission and reload report parse errors using their own last-good state.
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routingRetired: readonly string[] = retiredRoutingSettings(cwd)): HubConfig {
|
|
313
322
|
const captured = layers ?? readInstanceConfigLayers(cwd);
|
|
314
323
|
const instanceConfig = readInstanceConfig(cwd, captured);
|
|
315
324
|
const instances = instanceConfig.instances;
|
|
@@ -344,13 +353,15 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[]): HubCon
|
|
|
344
353
|
peerStartConfig(file.peers, instances);
|
|
345
354
|
if (file.terminal != null && (typeof file.terminal !== "object" || Array.isArray(file.terminal))) throw new Error("terminal must be an object");
|
|
346
355
|
terminalTemplate(file.terminal?.open);
|
|
356
|
+
if (file.pet !== undefined) validatePetConfig(file.pet, "config.pet");
|
|
357
|
+
if (file.pet && typeof file.pet === "object" && !Object.keys(file.pet).length) delete file.pet;
|
|
347
358
|
delete file.permission_default_sources; // provenance is loader-owned, never a file claim
|
|
348
359
|
const why = stripUntrusted(file, DEFAULT_CONFIG, cwd, name);
|
|
349
360
|
if (why) ignored.push(why);
|
|
350
361
|
for (const [peer, mode] of Object.entries(permissionDefaults(file.permission_modes, instances))) appliedPermissionSources[peer] = { mode, source: `.agenthub/${name}` };
|
|
351
362
|
return [file];
|
|
352
363
|
});
|
|
353
|
-
if (!files.length) return DEFAULT_CONFIG;
|
|
364
|
+
if (!files.length) return routingRetired.length ? { ...DEFAULT_CONFIG, retired: [...routingRetired] } : DEFAULT_CONFIG;
|
|
354
365
|
const file = files.reduce((a, b) => ({ ...a, ...b, ...Object.fromEntries(CONFIG_BLOCKS.filter((k) => a[k] !== undefined && b[k] !== undefined).map((k) => [k, { ...a[k], ...b[k] }])) }));
|
|
355
366
|
const wipLayers = files.filter(layer => layer.tasks?.wip_limits !== undefined);
|
|
356
367
|
if (wipLayers.length) file.tasks = { ...file.tasks, wip_limits: wipLayers.reduce((prior, layer) => ({ ...prior, ...planningWipLimits(layer.tasks.wip_limits) }), {} as Record<string, number>) };
|
|
@@ -361,7 +372,7 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[]): HubCon
|
|
|
361
372
|
delete file.retired;
|
|
362
373
|
// Escape hatches with a stated end (issue #83). A removed one is ignored, never an error: an old config.local.json
|
|
363
374
|
// must not stop a hub from starting.
|
|
364
|
-
const retired: string[] = [];
|
|
375
|
+
const retired: string[] = [...routingRetired];
|
|
365
376
|
if (file.local?.sandbox !== undefined) {
|
|
366
377
|
if (file.local.sandbox === "allow-default") retired.push('local.sandbox "allow-default" was removed in 0.12.0: the deny-default sandbox applies (local.read_allow adds paths)');
|
|
367
378
|
file.local = { ...file.local };
|
|
@@ -463,9 +474,6 @@ export interface DaemonOptions {
|
|
|
463
474
|
controlPort: number;
|
|
464
475
|
codexAppPort: number;
|
|
465
476
|
codexProxyPort: number;
|
|
466
|
-
/** 0 disables the Switchyard sidecar. */
|
|
467
|
-
switchyardPort?: number;
|
|
468
|
-
switchyardBin?: string;
|
|
469
477
|
config?: HubConfig;
|
|
470
478
|
/** #269: the terminal providers for a TUI start the hub makes itself, built from `terminal.open`. Absent: none, and such a start is refused. */
|
|
471
479
|
terminal?: (template: string[]) => TerminalOpener;
|
|
@@ -496,6 +504,7 @@ interface Client {
|
|
|
496
504
|
authed: boolean;
|
|
497
505
|
role?: "peer" | "console" | "tools";
|
|
498
506
|
peer?: PeerId;
|
|
507
|
+
launchId?: string;
|
|
499
508
|
tail?: () => void;
|
|
500
509
|
watch?: () => void;
|
|
501
510
|
/** The one-launch Kimi TUI admission this socket attached under; its close clears only that admission. */
|
|
@@ -509,6 +518,10 @@ class WsPeer extends BasePeer {
|
|
|
509
518
|
// Checkpoints become stale at hello/claim, before asynchronous recall finishes and attach changes delivery generation.
|
|
510
519
|
private claimGeneration = crypto.randomUUID();
|
|
511
520
|
get sessionGeneration(): string { return this.claimGeneration; }
|
|
521
|
+
terminalOwner(launchId: string): { generation: string } | undefined {
|
|
522
|
+
return this.sock?.readyState === WebSocket.OPEN && this.sock.data.launchId === launchId && this.state !== "offline" && !this.claiming
|
|
523
|
+
? { generation: this.claimGeneration } : undefined;
|
|
524
|
+
}
|
|
512
525
|
private delivered = new Set<string>();
|
|
513
526
|
private sock: Sock | undefined;
|
|
514
527
|
private claimed: Sock | undefined;
|
|
@@ -539,6 +552,10 @@ class WsPeer extends BasePeer {
|
|
|
539
552
|
get channelHeld(): boolean {
|
|
540
553
|
return (!!this.sock && !this.pullOnly) || (this.claiming && !this.claimedPullOnly);
|
|
541
554
|
}
|
|
555
|
+
/** Status-file observations are advisory; a replaced client cannot evict a live socket or pending claim. */
|
|
556
|
+
standbyHeld(pullOnly: boolean): boolean {
|
|
557
|
+
return this.claiming || (!!this.sock && this.sock.readyState === WebSocket.OPEN && (pullOnly || !this.pullOnly));
|
|
558
|
+
}
|
|
542
559
|
/** A hello that has not finished its preface yet. The peer reads as offline until then, and a session standing by
|
|
543
560
|
* for the id must not take it from a claimant that is still arriving. */
|
|
544
561
|
get claiming(): boolean {
|
|
@@ -1199,14 +1216,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1199
1216
|
} finally { for (const listener of held) listener.stop(true); }
|
|
1200
1217
|
};
|
|
1201
1218
|
const piCallsInFlight = () => [...piReceiptLedgers.values()].reduce((n, ledger) => n + ledger.inFlight, 0);
|
|
1202
|
-
/**
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
*/
|
|
1206
|
-
const onCampus = async (): Promise<boolean> => (await omni.onCampus()) && !(sidecar?.upstream && omni.isAccessHost(sidecar.upstream));
|
|
1207
|
-
let sidecar: Sidecar | undefined; // L2, started by the first hub-owned model call, stopped with the hub
|
|
1208
|
-
let sidecarRouting = "";
|
|
1209
|
-
inference = new Inference(config.inference, { omni, sidecar: () => sidecar, route: "sy/fast", fixedModel: () => activeRouting().local.fixed_model, log });
|
|
1219
|
+
/** PII needs a positively confirmed campus gateway; unknown never counts as on campus. */
|
|
1220
|
+
const onCampus = (): Promise<boolean> => omni.onCampus();
|
|
1221
|
+
inference = new Inference(config.inference, { omni, fixedModel: () => activeRouting().local.fixed_model, log });
|
|
1210
1222
|
|
|
1211
1223
|
let dashboard: ReturnType<typeof startDashboard> | undefined;
|
|
1212
1224
|
const uiEvents: { seq: number; event: BusEvent }[] = [];
|
|
@@ -1960,6 +1972,24 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1960
1972
|
let claudeUsageSeen = 0;
|
|
1961
1973
|
const claudeContextSeen = new Map<string, string>();
|
|
1962
1974
|
const contextTailSeen = new Map<PeerId, string>();
|
|
1975
|
+
const retainedCliAudits = new Set<string>();
|
|
1976
|
+
function drainCliAuditsNow(): void {
|
|
1977
|
+
for (const audit of drainCliAudits(opts.stateDir)) {
|
|
1978
|
+
const delegated = audit.outcome === "delegated";
|
|
1979
|
+
const refused = audit.outcome !== "run" && !delegated;
|
|
1980
|
+
if (!event({ type: "agent_cli", peer: audit.peer, command: audit.command, refused, ...(delegated ? { delegated: true } : {}) })) {
|
|
1981
|
+
throw new Error("CLI audit event was not retained");
|
|
1982
|
+
}
|
|
1983
|
+
retainedCliAudits.add(audit.id); // Only after the event append succeeded.
|
|
1984
|
+
// ponytail: 4096 live-instance acknowledgements; eviction or restart fails closed. Durable keyed receipts can extend this window.
|
|
1985
|
+
if (retainedCliAudits.size > 4096) retainedCliAudits.delete(retainedCliAudits.values().next().value!);
|
|
1986
|
+
try {
|
|
1987
|
+
notify(refused ? `${audit.peer} refused ahub ${audit.command}` : delegated
|
|
1988
|
+
? `${audit.peer} ran ahub ${audit.command} with delegated live-test console authority`
|
|
1989
|
+
: `${audit.peer} ran ahub ${audit.command} as ${audit.peer}`);
|
|
1990
|
+
} catch { /* The audit event is retained even if its advisory notice cannot be delivered. */ }
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1963
1993
|
const intervals = [
|
|
1964
1994
|
setInterval(() => {
|
|
1965
1995
|
void refreshRunner().catch(() => {});
|
|
@@ -1970,14 +2000,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1970
2000
|
feedBudgetPauses();
|
|
1971
2001
|
supervision.checkRound();
|
|
1972
2002
|
try {
|
|
1973
|
-
|
|
1974
|
-
const delegated = audit.outcome === "delegated";
|
|
1975
|
-
const refused = audit.outcome !== "run" && !delegated;
|
|
1976
|
-
event({ type: "agent_cli", peer: audit.peer, command: audit.command, refused, ...(delegated ? { delegated: true } : {}) });
|
|
1977
|
-
notify(refused ? `${audit.peer} refused ahub ${audit.command}` : delegated
|
|
1978
|
-
? `${audit.peer} ran ahub ${audit.command} with delegated live-test console authority`
|
|
1979
|
-
: `${audit.peer} ran ahub ${audit.command} as ${audit.peer}`);
|
|
1980
|
-
}
|
|
2003
|
+
drainCliAuditsNow();
|
|
1981
2004
|
} catch { log("CLI audit outbox unavailable; inspect state before retrying an agent command"); }
|
|
1982
2005
|
for (const peer of bus.knownPeers()) {
|
|
1983
2006
|
contexts.retry(peer, contextSession(peer));
|
|
@@ -2121,7 +2144,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2121
2144
|
if (piiTurn && (MEMORY_READ_TOOLS.has(op) || op === "hub_memory_correct" || op === "hub_remember" || op === "hub_task_propose" || op === "hub_sdlc_record" || op === "hub_plan_review" || op === "hub_sdlc_costs" || op.startsWith("hub_release_") || op === "hub_backlog_return_untouched" || op === "hub_backlog_plan" || op === "hub_backlog_undo" || op === "hub_backlog_pull" || op === "hub_backlog_split" || op === "release_new")) throw new PreEffectToolRefusal(`${op} is not available while working on a PII task: its text must not leave this machine`);
|
|
2122
2145
|
// The same words attached to an ordinary task would reach its reviewer, its owner, overlapping owners (a plan, the
|
|
2123
2146
|
// completed-change notice) and claude-mem. A turn that holds a PII task acts on ordinary tasks in a turn of its own.
|
|
2124
|
-
if (piiTurn && (op === "hub_task_done" || op === "hub_review" || (op === "hub_task_accept" && a.plan != null))) {
|
|
2147
|
+
if (piiTurn && (op === "hub_task_done" || op === "hub_review" || (op === "hub_task_accept" && (a.plan != null || a.refs != null)))) {
|
|
2125
2148
|
const target = board.get(Number(a.id));
|
|
2126
2149
|
if (target && !tasks.isPii(target)) throw new PreEffectToolRefusal(`${op} on task #${target.id} is not available while working on a PII task: its text would reach other peers; do it in a turn without the PII task`);
|
|
2127
2150
|
}
|
|
@@ -2275,10 +2298,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2275
2298
|
}
|
|
2276
2299
|
case "hub_task_accept": {
|
|
2277
2300
|
// S1 spec acceptance and S9 plan review are checked against one task snapshot before acceptance.
|
|
2278
|
-
const t = await tasks.acceptReviewed(by, a.id, a.plan);
|
|
2301
|
+
const t = await tasks.acceptReviewed(by, a.id, a.plan, a.refs);
|
|
2279
2302
|
// A silent cohort (issue #107): the owners' plans come with every accept, as what the newcomer works from.
|
|
2280
2303
|
const silent = tasks.silentFor(t.id);
|
|
2281
|
-
const overlap = a.plan == null && !silent ? "" : tasks.overlaps(t);
|
|
2304
|
+
const overlap = a.plan == null && a.refs == null && !silent ? "" : tasks.overlaps(t);
|
|
2282
2305
|
if (silent && factsOn()) factsFor(by).sawPlans(by, tasks.overlapTasks(t)); // the plans in this answer need no fact later
|
|
2283
2306
|
return [line(t), overlap, tasks.planGuardText(t)].filter(Boolean).join("\n");
|
|
2284
2307
|
}
|
|
@@ -2291,7 +2314,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2291
2314
|
return JSON.stringify(tasks.publicView(await tasks.cancel(a.id, by, { reason: a.reason, supersededBy: a.superseded_by, expectedFlowRevision: a.revision })));
|
|
2292
2315
|
}
|
|
2293
2316
|
case "hub_task_decline":
|
|
2294
|
-
return line(await tasks.decline(by, a.id, a.reason));
|
|
2317
|
+
return line(await tasks.decline(by, a.id, a.reason, a.capacity === true));
|
|
2295
2318
|
case "hub_task_done": {
|
|
2296
2319
|
const t = await tasks.done(by, a.id, a.summary, a.refs);
|
|
2297
2320
|
const completion = t.history.findLast(h => h.by === by && (h.event === "done" || h.event === "done (checking)"));
|
|
@@ -2301,7 +2324,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2301
2324
|
// A silent cohort (issue #107): the member that integrates is asked to check its work first; no done yet.
|
|
2302
2325
|
const last = t.history.at(-1);
|
|
2303
2326
|
if (last?.event === "integration requested" && last.by === HUB) return last.note ?? "";
|
|
2304
|
-
const extra = tasks.takeDoneNote(t.id); //
|
|
2327
|
+
const extra = [tasks.takeDoneNote(t.id), tasks.checkoutNote()].filter(Boolean).join("\n"); // held silence notices, then the #572 checkout advisory
|
|
2305
2328
|
const result = tasks.isChecking(t.id) ? `${line(t)}; its check is queued or running, and the result comes as a task message` : line(t);
|
|
2306
2329
|
return extra ? `${result}\n${extra}` : result;
|
|
2307
2330
|
}
|
|
@@ -2475,11 +2498,41 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2475
2498
|
const claudePermissionGenerations = new Map<string, string>();
|
|
2476
2499
|
const permissions = new Map<string, { push: string; peer: string; tool?: string; createdAt: number; expiresAt: number; done: (optionId: string | undefined, surface?: "console" | "dashboard" | "terminal", reason?: "expired" | "cancelled") => boolean }>();
|
|
2477
2500
|
let stopping = false;
|
|
2501
|
+
const terminalReads = terminalOwnerReads();
|
|
2502
|
+
const terminalLaunches = new TerminalLaunches({
|
|
2503
|
+
owner: row => {
|
|
2504
|
+
const owner = bus.peers.get(row.peer), proof = owner?.terminalOwner?.(row.launchId);
|
|
2505
|
+
if (!proof) return undefined;
|
|
2506
|
+
return { ...proof, isCurrent: () => {
|
|
2507
|
+
if (stopping || !membershipAllowed(row.peer) || bus.peers.get(row.peer) !== owner) return false;
|
|
2508
|
+
const current = owner?.terminalOwner?.(row.launchId);
|
|
2509
|
+
return current?.generation === proof.generation && current?.nativePid === proof.nativePid;
|
|
2510
|
+
} };
|
|
2511
|
+
},
|
|
2512
|
+
publish: (row, current) => publishTerminalLaunch(opts.stateDir, row, current, { runner: terminalReads.run }),
|
|
2513
|
+
log,
|
|
2514
|
+
});
|
|
2515
|
+
const terminalLaunchTimer = setInterval(() => { void terminalLaunches.reconcile(); }, 1_000);
|
|
2516
|
+
terminalLaunchTimer.unref();
|
|
2517
|
+
intervals.push(terminalLaunchTimer);
|
|
2518
|
+
startupCleanup.push(() => terminalLaunches.clear());
|
|
2478
2519
|
|
|
2479
2520
|
const pausedNote = (id: PeerId) => {
|
|
2480
2521
|
const r = budget.record(id); // one read per peer: status.json is rewritten on every bus event
|
|
2481
2522
|
return r ? { paused: `budget: ${r.reason}, resets ${new Date(r.resetsAt).toLocaleTimeString()}` } : manualPaused.has(id) && bus.stateOf(id) === "offline" ? { paused: "manual" } : {};
|
|
2482
2523
|
};
|
|
2524
|
+
/**
|
|
2525
|
+
* #572 AC1: an ACP peer's turn cannot take a message mid-turn unless its adapter has a `steer` (jcode's does; headless Kimi
|
|
2526
|
+
* and Qwen do not). The hub never cancels a turn to deliver, so an `important` message waits in the queue; once it has
|
|
2527
|
+
* waited past `budget.stale_min` the status says so, and the person decides. Read-only.
|
|
2528
|
+
*/
|
|
2529
|
+
const importantWaitingStatus = (id: PeerId, peer: PeerAdapter | undefined): Record<string, unknown> => {
|
|
2530
|
+
if (!(peer instanceof AcpPeer) || (peer as PeerAdapter).steer || bus.stateOf(id) !== "busy") return {};
|
|
2531
|
+
const since = bus.oldestQueuedImportantAt(id);
|
|
2532
|
+
if (since === undefined || Date.now() - since < config.budget.stale_min * 60_000) return {};
|
|
2533
|
+
const count = bus.queuedImportant(id), minutes = Math.floor((Date.now() - since) / 60_000);
|
|
2534
|
+
return { importantWaitingSince: since, importantWaitingNote: `${count} important message${count === 1 ? "" : "s"} waiting on a busy ACP peer for ${minutes} min; its turn is never cancelled to deliver, so decide whether to stop it` };
|
|
2535
|
+
};
|
|
2483
2536
|
/** Why a paused peer gets nothing; the bus pause itself is the hold. */
|
|
2484
2537
|
const pauseReason = (id: PeerId) => {
|
|
2485
2538
|
const r = budget.record(id), hold = conductorHolds.get(id);
|
|
@@ -2693,6 +2746,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2693
2746
|
pluginBundle: SERVER_JS,
|
|
2694
2747
|
pendingRestart,
|
|
2695
2748
|
retainedConfig,
|
|
2749
|
+
...(() => { const warning = mainCheckoutWarning(opts.cwd, tasks.publicMetadataText); return warning ? { mainCheckout: warning } : {}; })(),
|
|
2696
2750
|
startModes: Object.fromEntries(START_MODE_PEERS.map(peer => [peer, startModeOf(config.peers, peer, declarations)])),
|
|
2697
2751
|
memoryProfile: memoryProfileStatus(includeAttentionItems),
|
|
2698
2752
|
lifecycleStages: [...lifecycleConfig.stages],
|
|
@@ -2711,14 +2765,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2711
2765
|
...(dashboard ? { uiOrigin: dashboard.origin } : {}),
|
|
2712
2766
|
codexProxyPort: opts.codexProxyPort,
|
|
2713
2767
|
peers: Object.fromEntries(
|
|
2714
|
-
peerIds.map((id) => { const p = bus.peers.get(id); const summary = bus.queueSummary(id); const context = contexts.view(id, contextSession(id), !!p && ["idle", "busy", "paused"].includes(bus.stateOf(id))); return [id, { state: bus.stateOf(id), ...(!membershipAllowed(id) ? { rostered: false, rosterNote: "off roster" } : {}), ...permissionStatus(id, p), ...(context.source !== null || context.measuredAt !== null ? { context } : {}), queued: bus.queued(id), ...(p ? {} : { attached: false }), ...(summary.needsReview ? { needsReview: summary.needsReview } : {}), ...(summary.liveAccepted ? { liveAccepted: summary.liveAccepted, settlementNote: "awaiting adapter completion (Claude: correlated reply or hub_delivery_done); task state is independent" } : {}), ...queueHoldStatus(id, summary.heldBy), ...(summary.oldestQueuedAt !== undefined ? { oldestQueuedAt: summary.oldestQueuedAt } : {}), ...(bus.queuedImportant(id) ? { queuedImportant: bus.queuedImportant(id) } : {}), ...pausedNote(id), ...(p instanceof LocalPeer && p.lastServedBy ? { servedBy: p.lastServedBy } : {}), ...(p instanceof WsPeer && p.claiming ? { claiming: true } : {}), ...(p instanceof WsPeer && p.pullOnly ? { pullOnly: true, toolsOnly: definitionOf(id)?.attach?.pullOnlyNotice ?? "tools-only: messages wait for hub_inbox; for pushes restart Claude with ahub claude", lastInboxAt: p.lastInboxAt ?? null } : {}), ...(p instanceof PiPeer ? { requestedModel: p.getRequestedModel(), backends: modelRelays.get(id)?.status().backends ?? [] } : {}) }]; }),
|
|
2768
|
+
peerIds.map((id) => { const p = bus.peers.get(id); const summary = bus.queueSummary(id); const context = contexts.view(id, contextSession(id), !!p && ["idle", "busy", "paused"].includes(bus.stateOf(id))); return [id, { state: bus.stateOf(id), ...(!membershipAllowed(id) ? { rostered: false, rosterNote: "off roster" } : {}), ...permissionStatus(id, p), ...(context.source !== null || context.measuredAt !== null ? { context } : {}), queued: bus.queued(id), ...(p ? {} : { attached: false }), ...(summary.needsReview ? { needsReview: summary.needsReview } : {}), ...(summary.liveAccepted ? { liveAccepted: summary.liveAccepted, settlementNote: "awaiting adapter completion (Claude: correlated reply or hub_delivery_done); task state is independent" } : {}), ...queueHoldStatus(id, summary.heldBy), ...(summary.oldestQueuedAt !== undefined ? { oldestQueuedAt: summary.oldestQueuedAt } : {}), ...(bus.queuedImportant(id) ? { queuedImportant: bus.queuedImportant(id) } : {}), ...importantWaitingStatus(id, p), ...pausedNote(id), ...(p instanceof LocalPeer && p.lastServedBy ? { servedBy: p.lastServedBy } : {}), ...(p instanceof WsPeer && p.claiming ? { claiming: true } : {}), ...(p instanceof WsPeer && p.pullOnly ? { pullOnly: true, toolsOnly: definitionOf(id)?.attach?.pullOnlyNotice ?? "tools-only: messages wait for hub_inbox; for pushes restart Claude with ahub claude", lastInboxAt: p.lastInboxAt ?? null } : {}), ...(p instanceof PiPeer ? { requestedModel: p.getRequestedModel(), backends: modelRelays.get(id)?.status().backends ?? [] } : {}) }]; }),
|
|
2715
2769
|
),
|
|
2716
2770
|
taskWip: Object.fromEntries(peerIds.map(id => [id, { wip: tasks.wip(id), wipLimit: tasks.wipLimit(id) }])),
|
|
2717
2771
|
boardReadCapabilities: { releaseFilter: true, columnMetadata: true },
|
|
2718
2772
|
roleInfo: Object.fromEntries(peerIds.map(id => [id, { flowRoles: config.roles[id] ?? [], catalogRoles: roleState(id), ...(config.roles[id]?.includes("planner") ? { planner: plannerSource } : {}) }])),
|
|
2719
2773
|
planner: { peers: plannerPeers(), source: plannerSource },
|
|
2720
2774
|
...(pendingPermissionDefaults.size ? { permissionDefaults: [...pendingPermissionDefaults].map(([peer, source]) => ({ peer, mode: "never-ask", source })) } : {}),
|
|
2721
|
-
...(sidecar ? { switchyard: sidecar.status } : {}),
|
|
2722
2775
|
...(modelRelay ? { models: modelRelay.status() } : {}),
|
|
2723
2776
|
isolatedChecks: { pending: checkPending.size, orphans: orphanChecks, unknownReceipts: checkRunner.unknownReceipts ?? [], maintenanceUnavailable: checkRunner.maintenanceUnavailable === true },
|
|
2724
2777
|
taskCapabilities: peerCapabilities(),
|
|
@@ -3322,7 +3375,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3322
3375
|
if (owner instanceof PiPeer && 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" };
|
|
3323
3376
|
return startPeer(peer, definitionOf(peer)?.start.explicitHeadlessMode ? { mode: "headless", ...(resume ?? {}) } : {});
|
|
3324
3377
|
}
|
|
3325
|
-
|
|
3378
|
+
// #559 N1: only the console's own start reopens a frontend on an attached owned-ACP peer. The conductor and the
|
|
3379
|
+
// dashboard answer `already`, so a repeated call never adds another TUI writer to the live session.
|
|
3380
|
+
if (owner && owner.state !== "offline" && !(definitionOf(peer)?.start.daemonPort === "owned-acp" && by === "the console")) return { ok: true, already: true, mode };
|
|
3326
3381
|
const words = [...startWords(peer, mode)];
|
|
3327
3382
|
if (resume) words.push(...(resume.sessionFile ? ["--session-file", resume.sessionFile] : ["--session-id", resume.sessionId]), ...(resume.backend ? ["--backend", resume.backend] : []), ...(resume.model ? ["--model", resume.model] : []));
|
|
3328
3383
|
const command = `ahub ${resume ? words.map(shellQuote).join(" ") : words.join(" ")}`;
|
|
@@ -3712,8 +3767,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3712
3767
|
permit: async () => false, // Pi's calls ask through piPermit in executeTool, which knows the tool's name
|
|
3713
3768
|
send: (text, to) => {
|
|
3714
3769
|
if (to?.some((id) => !bus.peers.has(id) && id !== USER)) return "error: unknown peer";
|
|
3715
|
-
const
|
|
3716
|
-
|
|
3770
|
+
const result = pi.onMessage?.(text, { inReplyTo: piReply, ...(to?.length ? { to } : {}) }) as PublishResult | string | undefined;
|
|
3771
|
+
if (typeof result === "string") return `not sent: ${result}`;
|
|
3772
|
+
if (result?.dropped === "hop") return `not delivered: reply chain reached the hop cap (${MAX_HOP}); send a new message without reply_to`;
|
|
3773
|
+
return "sent";
|
|
3717
3774
|
},
|
|
3718
3775
|
};
|
|
3719
3776
|
const routing = activeRouting();
|
|
@@ -3834,9 +3891,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3834
3891
|
}
|
|
3835
3892
|
if (adapter?.start.daemonPort === "local-worker") {
|
|
3836
3893
|
const routing = activeRouting();
|
|
3837
|
-
// `--model` pins a
|
|
3894
|
+
// `--model` pins a gateway alias; `--route` selects an in-process policy or a legacy fixed fallback.
|
|
3838
3895
|
const route = args.model ? undefined : (args.route ?? routing.local.route);
|
|
3839
|
-
if (route &&
|
|
3896
|
+
if (route && route.startsWith("hub/") && !routing.hub_routes?.[route]) return { ok: false, ...(existing && existing.state !== "offline" ? { already: true } : {}), error: `routing.toml has no route "${route}"` };
|
|
3840
3897
|
const fixedModel = args.model ?? routing.local.fixed_model;
|
|
3841
3898
|
// A manually paused recovery roster must be reconstructible while its gateway is unavailable.
|
|
3842
3899
|
// It stays held; holdPeer validates the restored choice before an operator can resume it.
|
|
@@ -3845,16 +3902,6 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3845
3902
|
// Validation can await network: a queued delivery may have started meanwhile.
|
|
3846
3903
|
if (existing?.state === "busy") return { ok: false, error: "local is busy; retry when it is idle" };
|
|
3847
3904
|
if (existing) { mute(existing); await existing.stop(); }
|
|
3848
|
-
// The sidecar serves the routes it was generated from: a changed routing.toml needs a new one.
|
|
3849
|
-
const routingKey = JSON.stringify([routing.targets, routing.routes]);
|
|
3850
|
-
if (sidecar && routingKey !== sidecarRouting) {
|
|
3851
|
-
await sidecar.stop();
|
|
3852
|
-
sidecar = undefined;
|
|
3853
|
-
}
|
|
3854
|
-
if (route && !route.startsWith("hub/") && opts.switchyardPort) {
|
|
3855
|
-
sidecarRouting = routingKey;
|
|
3856
|
-
sidecar ??= new Sidecar({ routing, omni, stateDir: opts.stateDir, port: opts.switchyardPort, log, ...(opts.switchyardBin ? { bin: opts.switchyardBin } : {}) });
|
|
3857
|
-
}
|
|
3858
3905
|
const capture = config.memory.enabled
|
|
3859
3906
|
? new Capture(memory, { project: chain.at(-1)!, cwd: executionCwd, skip: skipTools(), deny: config.local.deny })
|
|
3860
3907
|
: undefined;
|
|
@@ -3879,7 +3926,6 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3879
3926
|
onUsage: record => event({ type: "usage", peer, source: "omniroute", id: record.id, ...record.usage, ...taskAttribution(peer, record.task), requestedModel: record.requestedModel, ...(record.servedModel ? { servedModel: record.servedModel } : {}), ...(record.provider ? { provider: record.provider } : {}), measuredAt: record.at }),
|
|
3880
3927
|
admitBudget: async (envs, unit) => tasks.admitExecutionEnvelopes(envs, peer, unit),
|
|
3881
3928
|
...(route ? { route } : {}),
|
|
3882
|
-
...(sidecar && route && !route.startsWith("hub/") ? { sidecar } : {}),
|
|
3883
3929
|
hubRoutes: () => activeRouting().hub_routes ?? {},
|
|
3884
3930
|
staySwitch: () => activeRouting(),
|
|
3885
3931
|
onRoute: record => event({ type: "route", peer, ...record }),
|
|
@@ -3977,19 +4023,14 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3977
4023
|
|
|
3978
4024
|
async function validateLocalChoice(route: string | undefined, fixedModel: string): Promise<void> {
|
|
3979
4025
|
const routing = activeRouting();
|
|
3980
|
-
if (route &&
|
|
4026
|
+
if (route && route.startsWith("hub/") && !routing.hub_routes?.[route]) throw new Error(`routing.toml has no route "${route}"`);
|
|
3981
4027
|
const models = await omni.models();
|
|
3982
4028
|
const required = new Set([fixedModel]);
|
|
3983
|
-
const visit = (value: unknown): void => {
|
|
3984
|
-
if (typeof value === "string" && routing.targets[value]) required.add(routing.targets[value]!.id);
|
|
3985
|
-
else if (Array.isArray(value)) value.forEach(visit);
|
|
3986
|
-
else if (value && typeof value === "object") Object.values(value).forEach(visit);
|
|
3987
|
-
};
|
|
3988
4029
|
if (route?.startsWith("hub/")) {
|
|
3989
4030
|
const policy = routing.hub_routes![route]!;
|
|
3990
4031
|
required.add(policy.efficient ?? "fast"); required.add(policy.capable ?? "coding");
|
|
3991
4032
|
if (policy.judge) required.add(policy.judge);
|
|
3992
|
-
}
|
|
4033
|
+
}
|
|
3993
4034
|
for (const model of required) await omni.checkModel(model, models);
|
|
3994
4035
|
}
|
|
3995
4036
|
|
|
@@ -4201,12 +4242,18 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4201
4242
|
const parts = key.split(".");
|
|
4202
4243
|
if (parts.some(part => ["__proto__", "constructor", "prototype"].includes(part))) throw new Error("unsafe configuration path");
|
|
4203
4244
|
let node = target as Record<string, unknown>;
|
|
4245
|
+
const trail: [Record<string, unknown>, string][] = [];
|
|
4204
4246
|
for (const part of parts.slice(0, -1)) {
|
|
4205
4247
|
if (!Object.hasOwn(node, part) || !node[part] || typeof node[part] !== "object" || Array.isArray(node[part])) node[part] = {};
|
|
4248
|
+
trail.push([node, part]);
|
|
4206
4249
|
node = node[part] as Record<string, unknown>;
|
|
4207
4250
|
}
|
|
4208
|
-
if (value === undefined)
|
|
4209
|
-
|
|
4251
|
+
if (value === undefined) {
|
|
4252
|
+
delete node[parts.at(-1)!];
|
|
4253
|
+
for (const [parent, part] of trail.reverse()) {
|
|
4254
|
+
if (!Object.hasOwn(DEFAULT_CONFIG, part) && !Object.keys(parent[part] as object).length) delete parent[part];
|
|
4255
|
+
}
|
|
4256
|
+
} else node[parts.at(-1)!] = structuredClone(value);
|
|
4210
4257
|
}
|
|
4211
4258
|
function changedRawPaths(before: unknown, after: unknown, prefix = ""): string[] {
|
|
4212
4259
|
const object = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
|
|
@@ -4217,8 +4264,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4217
4264
|
return JSON.stringify(before) === JSON.stringify(after) ? [] : [prefix];
|
|
4218
4265
|
}
|
|
4219
4266
|
function declaredPaths(value: unknown, prefix = ""): string[] {
|
|
4220
|
-
//
|
|
4221
|
-
if (
|
|
4267
|
+
// Retired dictionaries never enter active publication; hub ids remain opaque keys.
|
|
4268
|
+
if (prefix === "routing.targets" || prefix === "routing.routes") return [];
|
|
4269
|
+
if (prefix === "routing.hub_routes") return [prefix];
|
|
4222
4270
|
if (value && typeof value === "object" && !Array.isArray(value)) return Object.entries(value).flatMap(([key, child]) => declaredPaths(child, prefix ? `${prefix}.${key}` : key));
|
|
4223
4271
|
return prefix ? [prefix] : [];
|
|
4224
4272
|
}
|
|
@@ -4230,7 +4278,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4230
4278
|
return { planners: settled.planners, conductors: Object.keys(candidate.roles).filter(peer => candidate.roles[peer]!.includes("conductor")).sort() };
|
|
4231
4279
|
}
|
|
4232
4280
|
function prepareConfiguration(sources: ConfigSources, authority: SettingAuthority, scope?: string, confirmed: readonly string[] = []) {
|
|
4233
|
-
const fileConfig = loadConfig(opts.cwd, sources.layers);
|
|
4281
|
+
const fileConfig = loadConfig(opts.cwd, sources.layers, sources.routing.retired ?? []);
|
|
4234
4282
|
validateEffectiveConfig(fileConfig);
|
|
4235
4283
|
const candidate = structuredClone(desiredConfig), routing = structuredClone(desiredRouting);
|
|
4236
4284
|
const accepted: string[] = [], rejected: { key: string; reason: string }[] = [], guarded: string[] = [];
|
|
@@ -4320,9 +4368,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4320
4368
|
for (const item of rejected) if (guarded.includes(item.key)) item.reason += "; combined confirmation preview exceeds its 128 KiB bound; split this edit";
|
|
4321
4369
|
guarded.length = 0; previews.length = 0;
|
|
4322
4370
|
}
|
|
4371
|
+
candidate.retired = fileConfig.retired;
|
|
4372
|
+
routing.retired = sources.routing.retired;
|
|
4323
4373
|
const changes = configChanges({ ...config, roles: policyRoles }, candidate);
|
|
4324
4374
|
const running = structuredClone({ ...config, roles: policyRoles });
|
|
4325
4375
|
for (const change of changes) if (change.applies !== "restart") putConfig(running, change.key, change.after);
|
|
4376
|
+
running.retired = fileConfig.retired;
|
|
4326
4377
|
validateEffectiveConfig(running);
|
|
4327
4378
|
conductorPeer(running.roles);
|
|
4328
4379
|
for (const peer of Object.keys(running.catalog_roles ?? {})) selectedRoles(peer, running.roles, { ...resolvedProfile.catalog_roles, ...catalogAssignments(running.catalog_roles) }, catalog);
|
|
@@ -4383,7 +4434,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4383
4434
|
if (confirm !== undefined && prepared.rejected.some(item => reloadConfirmation!.keys.includes(item.key))) return { ok: false, error: "configuration trust changed; run ahub settings reload again" };
|
|
4384
4435
|
if (scope && prepared.rejected.some(item => item.key === scope)) return { ok: false, error: prepared.rejected.find(item => item.key === scope)!.reason };
|
|
4385
4436
|
const { changes, candidate } = prepared;
|
|
4386
|
-
const routingChanges = changedRawPaths({ ...runningRouting, sources: undefined }, { ...prepared.routing, sources: undefined }, "routing");
|
|
4437
|
+
const routingChanges = changedRawPaths({ ...runningRouting, sources: undefined, retired: undefined }, { ...prepared.routing, sources: undefined, retired: undefined }, "routing");
|
|
4387
4438
|
if (prepared.running.snapshots.enabled && repo && !turnLog) turnLog = new Turns(join(opts.stateDir, "hub.db"));
|
|
4388
4439
|
const priorDesired = JSON.stringify([desiredConfig, desiredRouting, acceptedSources.trustLayers]);
|
|
4389
4440
|
const rolesChanged = changes.some(change => change.key === "roles" || change.key.startsWith("roles.") || change.key === "planner");
|
|
@@ -4397,6 +4448,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4397
4448
|
for (const change of changes.filter(change => change.key === "permission_modes" || change.key.startsWith("permission_modes."))) {
|
|
4398
4449
|
for (const peer of change.key === "permission_modes" ? SUPPORTED_PEERS : [change.key.split(".")[1]!]) changedPermissionDefaults.add(peer);
|
|
4399
4450
|
}
|
|
4451
|
+
const priorRetired = new Set(config.retired ?? []);
|
|
4452
|
+
config.retired = prepared.running.retired;
|
|
4453
|
+
for (const line of config.retired ?? []) if (!priorRetired.has(line)) log(`config: ${line}`);
|
|
4400
4454
|
config.permission_default_sources = candidate.permission_default_sources;
|
|
4401
4455
|
for (const peer of changedPermissionDefaults) applyPendingPermissionDefault(peer);
|
|
4402
4456
|
if (changes.some(change => change.key === "task_sweep" || change.key.startsWith("task_sweep."))) rescheduleSweep();
|
|
@@ -4572,7 +4626,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4572
4626
|
const def = settingDef(key);
|
|
4573
4627
|
if (typeof def === "string") return { error: def };
|
|
4574
4628
|
const routing = activeRouting();
|
|
4575
|
-
try { return { def, value: checkSettingValue(def, raw, def.type === "roles" ? compatibleRoles(def.path[1]!) :
|
|
4629
|
+
try { return { def, value: checkSettingValue(def, raw, def.type === "roles" ? compatibleRoles(def.path[1]!) : Object.keys(routing.hub_routes ?? {})) }; }
|
|
4576
4630
|
catch (error) { return { error: (error as Error).message }; }
|
|
4577
4631
|
}
|
|
4578
4632
|
const settingReason = (reason: unknown): string | undefined => {
|
|
@@ -5088,11 +5142,21 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5088
5142
|
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");
|
|
5089
5143
|
if (!membershipAllowed(c.peer)) return sock.close(4000, "peer is off roster; retry when a person adds it");
|
|
5090
5144
|
if (!recoveryPeerAllowed(c.peer)) return sock.close(4403, "peer id is not part of the recovery roster");
|
|
5145
|
+
if (definitionOf(c.peer)?.transport === "claude-channel" && msg.launchId !== undefined) {
|
|
5146
|
+
let currentLaunch: unknown;
|
|
5147
|
+
try {
|
|
5148
|
+
const launch = JSON.parse(readFileSync(join(opts.stateDir, claudeStateFile("claude-launch", c.peer)), "utf8"));
|
|
5149
|
+
if (launch.instanceId === instanceId && typeof launch.launchId === "string" && launch.launchId) currentLaunch = launch.launchId;
|
|
5150
|
+
} catch { /* No current launch evidence: retain plain/pre-daemon attachment compatibility. */ }
|
|
5151
|
+
if (currentLaunch !== undefined && msg.launchId !== currentLaunch) return sock.close(4430, "Claude launch ended; start a new session with ahub claude");
|
|
5152
|
+
if (typeof msg.launchId === "string" && msg.launchId.length <= 128) c.launchId = msg.launchId;
|
|
5153
|
+
}
|
|
5091
5154
|
let peer = bus.peers.get(c.peer);
|
|
5092
5155
|
if (kimiPull && peer instanceof AcpPeer && peer.state === "offline") peer = undefined;
|
|
5093
5156
|
if (!peer) bus.add((peer = new WsPeer(c.peer)));
|
|
5094
5157
|
if (!(peer instanceof WsPeer)) return sock.close(4409, "peer id is taken by a hub-managed adapter");
|
|
5095
5158
|
const ws = peer;
|
|
5159
|
+
if (msg.standby === true && ws.standbyHeld(msg.channel === false)) return sock.close(4000, "another session holds or is claiming this peer");
|
|
5096
5160
|
// Without pushes it stands by, as a replaced session does, and never strands a channel session's deliveries.
|
|
5097
5161
|
if (msg.channel === false && ws.channelHeld) return sock.close(4000, "a session with channel pushes holds this peer");
|
|
5098
5162
|
preparedSessionRoles.delete(c.peer); // an external transport has no receipt for this daemon's native preamble
|
|
@@ -5542,6 +5606,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5542
5606
|
}
|
|
5543
5607
|
case "status":
|
|
5544
5608
|
return void reply({ t: "status", status: status(c.role === "console") });
|
|
5609
|
+
case "cli_audit_ack": {
|
|
5610
|
+
if (c.role !== "console") return void reply({ ok: false, error: "cli_audit_ack is a console command" });
|
|
5611
|
+
if (typeof msg.auditId !== "string" || !/^[a-f0-9-]{36}$/.test(msg.auditId)) return void reply({ ok: false, error: "invalid audit id" });
|
|
5612
|
+
try { drainCliAuditsNow(); }
|
|
5613
|
+
catch { return void reply({ ok: false, error: "CLI audit was not retained" }); }
|
|
5614
|
+
return void reply({ ok: retainedCliAudits.has(msg.auditId), auditId: msg.auditId, instanceId });
|
|
5615
|
+
}
|
|
5545
5616
|
case "ui_snapshot":
|
|
5546
5617
|
if (c.role !== "console") return void reply({ ok: false, error: "ui_snapshot is a console command" });
|
|
5547
5618
|
if (!Number.isSafeInteger(msg.after ?? 0) || (msg.after ?? 0) < 0) return void reply({ ok: false, error: "invalid cursor" });
|
|
@@ -5579,6 +5650,14 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5579
5650
|
if (typeof msg.admissionId === "string" && msg.admissionId === kimiAdmissionId) clearKimiAdmission();
|
|
5580
5651
|
return void reply({ ok: true });
|
|
5581
5652
|
}
|
|
5653
|
+
case "terminal_launch": {
|
|
5654
|
+
if (c.role !== "console") return void reply({ ok: false, error: "terminal launch registration is console only" });
|
|
5655
|
+
if (stopping || recoveryActive() && !(recoveryPhase === "restored" && msg.operationId === recoveryOperationId)) return void reply({ ok: false, error: "recovery is holding terminal launches" });
|
|
5656
|
+
const row = terminalLaunchCandidate(msg.launch, opts.cwd, opts.stateDir, instanceId);
|
|
5657
|
+
if (!row || !membershipAllowed(row.peer) || !definitionOf(row.peer)?.session.launchRecord || recoveryActive() && !recoveryPeerAllowed(row.peer)) return void reply({ ok: false, error: "terminal launch identity unavailable" });
|
|
5658
|
+
try { terminalLaunches.register(row); return void reply({ ok: true }); }
|
|
5659
|
+
catch { return void reply({ ok: false, error: "terminal launch could not be registered; managed recovery is unavailable" }); }
|
|
5660
|
+
}
|
|
5582
5661
|
case "native_tui_launch": {
|
|
5583
5662
|
if (c.role !== "console") return void reply({ ok: false, error: "native TUI launch is console only" });
|
|
5584
5663
|
const peer = String(msg.peer), owner = bus.peers.get(peer);
|
|
@@ -5589,7 +5668,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5589
5668
|
const options = msg.args ?? {};
|
|
5590
5669
|
for (const key of ["model", "providerProfile", "sessionId"]) if (options[key] !== undefined && typeof options[key] !== "string") throw new Error("invalid option");
|
|
5591
5670
|
return void reply({ ok: true, ...owner.tuiLaunch(msg.binding, options) });
|
|
5592
|
-
} catch
|
|
5671
|
+
} catch (error) {
|
|
5672
|
+
if (error instanceof JcodeOptionMismatch) return void reply({ ok: false, option: error.option, error: `owned Jcode launch differs from its running session in ${error.option}; start without it or stop jcode first` });
|
|
5673
|
+
return void reply({ ok: false, error: "owned Jcode launch or saved session could not be verified; details withheld" });
|
|
5674
|
+
}
|
|
5593
5675
|
}
|
|
5594
5676
|
case "peer_start":
|
|
5595
5677
|
// #269: a start the hub makes in the peer's start mode, for a console child with no terminal of its own.
|
|
@@ -5719,6 +5801,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5719
5801
|
}
|
|
5720
5802
|
async function stopOnce(): Promise<void> {
|
|
5721
5803
|
stopping = true;
|
|
5804
|
+
terminalLaunches.clear();
|
|
5805
|
+
// Start cancellation immediately; a failed read cleanup must not strand the other owned peers or finalization.
|
|
5806
|
+
const terminalReadsStopped = terminalReads.stop().catch(() => { log("terminal owner query cleanup failed during shutdown"); });
|
|
5722
5807
|
runnerRefreshAbort.abort();
|
|
5723
5808
|
// A stop someone asked for is not a crash, even if it then runs past the shutdown deadline: forget the sessions now.
|
|
5724
5809
|
try { removeSessions(opts.stateDir, instanceId); } catch { /* the state dir is gone */ }
|
|
@@ -5733,6 +5818,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5733
5818
|
for (const pending of checkpointWaits.values()) pending.done(undefined);
|
|
5734
5819
|
for (const p of permissions.values()) p.done(undefined);
|
|
5735
5820
|
try {
|
|
5821
|
+
await terminalReadsStopped;
|
|
5822
|
+
await terminalLaunches.settled();
|
|
5736
5823
|
// A peer can still be inside memory recall or its native handshake when kill arrives.
|
|
5737
5824
|
// Drain those starts before taking the final owned-process snapshot.
|
|
5738
5825
|
// Fence/abort the runner now, but slow container teardown must not consume the peer-stop deadline.
|
|
@@ -5741,7 +5828,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5741
5828
|
// A fingerprint still being detected belongs in the research records written below; its peer must still be
|
|
5742
5829
|
// attached when it settles, so this comes before the peers stop (each detection is bounded by its probe deadline).
|
|
5743
5830
|
await Promise.allSettled([...detecting]);
|
|
5744
|
-
const exits = await Promise.allSettled([...bus.peers.values()].map((p) => p.stop())
|
|
5831
|
+
const exits = await Promise.allSettled([...bus.peers.values()].map((p) => p.stop()));
|
|
5745
5832
|
// A peer whose stop rejects must not strand the daemon (issue #56): its failure is
|
|
5746
5833
|
// logged and shutdown finishes anyway, or the process ignores SIGTERM forever.
|
|
5747
5834
|
for (const exit of exits) if (exit.status === "rejected") log(`peer stop failed during shutdown: ${(exit.reason as Error)?.message ?? exit.reason}`);
|
|
@@ -1,10 +1,25 @@
|
|
|
1
1
|
import type { Envelope } from "./envelope.ts";
|
|
2
2
|
import type { Task } from "./board.ts";
|
|
3
3
|
|
|
4
|
+
type DeliveryTask = Pick<Task, "id" | "state" | "owner" | "reviewer" | "cancelled"> & Partial<Pick<Task, "history">>;
|
|
5
|
+
|
|
6
|
+
/** No task text or timestamps: only this recipient's own acceptance in its current ownership span. */
|
|
7
|
+
function recipientAccepted(task: DeliveryTask, recipient: string): boolean {
|
|
8
|
+
const history = task.history;
|
|
9
|
+
if (!history) return false;
|
|
10
|
+
// ponytail: inspect at most 4096 recent entries. Older/unknown acceptance keeps the brief held; a persisted owner epoch can extend it.
|
|
11
|
+
for (let index = history.length - 1, first = Math.max(0, history.length - 4096); index >= first; index--) {
|
|
12
|
+
const entry = history[index]!;
|
|
13
|
+
if (entry.owner !== undefined || ["assigned", "reassigned", "escalated", "unassigned", "backlog returned untouched"].includes(entry.event)) return false;
|
|
14
|
+
if (entry.event === "accepted" && entry.by === recipient) return true;
|
|
15
|
+
}
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
|
|
4
19
|
/** Evidence only, never task text: a mixed/unknown/live row requires a person's decision. */
|
|
5
20
|
export function supersededDeliveryReason(
|
|
6
21
|
originals: readonly Envelope[],
|
|
7
|
-
board: { get(id: number):
|
|
22
|
+
board: { get(id: number): DeliveryTask | undefined },
|
|
8
23
|
recipient: string,
|
|
9
24
|
): string | undefined {
|
|
10
25
|
if (!originals.length) return undefined;
|
|
@@ -22,6 +37,8 @@ export function supersededDeliveryReason(
|
|
|
22
37
|
if (typeof env.refs.taskCancelled === "number" && env.refs.taskCancelled === task.cancelled?.flowRevision) return undefined;
|
|
23
38
|
reasons.add(`task #${task.id} cancelled`);
|
|
24
39
|
} else if (task.state === "approved") reasons.add(`task #${task.id} approved`);
|
|
40
|
+
else if (env.kind === "task" && env.refs.taskRecipient === "owner" && Number.isSafeInteger(env.refs.taskOffer) && env.refs.taskOffer! >= 1 &&
|
|
41
|
+
task.owner === recipient && ["in_progress", "in_review", "changes_requested"].includes(task.state) && recipientAccepted(task, recipient)) reasons.add(`task #${task.id} accepted`);
|
|
25
42
|
else if ((env.kind === "review" || env.refs.taskRecipient === "reviewer") && (task.state !== "in_review" || task.reviewer !== recipient)) reasons.add(`task #${task.id} review moved`);
|
|
26
43
|
else if (env.kind === "task" && env.refs.taskRecipient === "owner" && task.owner !== recipient) reasons.add(`task #${task.id} owner moved`);
|
|
27
44
|
else return undefined; // Untagged legacy task notices and planner coordination carry no proof of supersession.
|
|
@@ -33,7 +50,7 @@ export function supersededDeliveryReason(
|
|
|
33
50
|
/** Informational context for every referenced task, including peer/user and mixed rows. Never authorizes settlement. */
|
|
34
51
|
export function deliveryTaskContext(
|
|
35
52
|
originals: readonly Envelope[],
|
|
36
|
-
board: { get(id: number):
|
|
53
|
+
board: { get(id: number): DeliveryTask | undefined },
|
|
37
54
|
recipient: string,
|
|
38
55
|
): string | undefined {
|
|
39
56
|
const context = new Set<string>();
|