@staix/agent-hub 0.12.32 → 0.12.34
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 -0
- package/README.md +6 -5
- package/docs/agent-notes/adapters.md +7 -3
- package/docs/agent-notes/budget.md +5 -3
- package/docs/agent-notes/bus.md +2 -0
- package/docs/agent-notes/daemon.md +7 -5
- package/docs/agent-notes/tasks.md +3 -2
- package/docs/agent-notes/tests.md +2 -1
- package/docs/operations.md +48 -14
- package/docs/quickstart.md +5 -3
- package/docs/security.md +10 -2
- package/docs/smoke.md +1 -1
- package/docs/specs/2026-09-19-agent-hub-design.md +246 -4
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +20 -0
- package/docs/verification/2026-10-18-0.12.33.md +42 -0
- package/docs/verification/2026-10-18-0.12.34.md +48 -0
- package/docs/verified.json +33 -19
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +22882 -16496
- package/src/adapters/codex-appserver.ts +81 -11
- package/src/adapters/definitions/builtin-metadata.ts +101 -7
- package/src/adapters/definitions/native-cli-claude-launch.ts +90 -0
- package/src/adapters/definitions/native-cli-claude-setup.ts +80 -0
- package/src/adapters/definitions/native-cli-codex-launch.ts +14 -0
- package/src/adapters/definitions/native-cli-doctor.ts +46 -0
- package/src/adapters/definitions/native-cli-entry-claude.ts +52 -0
- package/src/adapters/definitions/native-cli-entry-codex.ts +26 -0
- package/src/adapters/definitions/native-cli-entry-jcode.ts +24 -0
- package/src/adapters/definitions/native-cli-entry-kimi.ts +52 -0
- package/src/adapters/definitions/native-cli-entry-local.ts +14 -0
- package/src/adapters/definitions/native-cli-entry-pi.ts +51 -0
- package/src/adapters/definitions/native-cli-entry-qwen.ts +20 -0
- package/src/adapters/definitions/native-cli-entry-types.ts +40 -0
- package/src/adapters/definitions/native-cli-entry.ts +19 -0
- package/src/adapters/definitions/native-cli-jcode-mcp.ts +58 -0
- package/src/adapters/definitions/native-cli-launch-common.ts +17 -0
- package/src/adapters/definitions/native-cli-pi.ts +33 -0
- package/src/adapters/definitions/native-cli-preview-claude.ts +37 -0
- package/src/adapters/definitions/native-cli-preview-codex.ts +20 -0
- package/src/adapters/definitions/native-cli-preview-common.ts +57 -0
- package/src/adapters/definitions/native-cli-preview-jcode.ts +20 -0
- package/src/adapters/definitions/native-cli-preview-kimi.ts +28 -0
- package/src/adapters/definitions/native-cli-preview-pi.ts +36 -0
- package/src/adapters/definitions/native-cli-preview-qwen.ts +22 -0
- package/src/adapters/definitions/native-cli-preview.ts +16 -0
- package/src/adapters/definitions/native-config-defaults.ts +19 -0
- package/src/adapters/definitions/native-config-policy.ts +35 -0
- package/src/adapters/definitions/native-context.ts +35 -0
- package/src/adapters/definitions/native-daemon-kimi.ts +5 -0
- package/src/adapters/definitions/native-daemon-policy.ts +18 -0
- package/src/adapters/definitions/native-display.ts +18 -0
- package/src/adapters/definitions/native-factories.ts +48 -0
- package/src/adapters/definitions/native-policy-aux.ts +130 -0
- package/src/adapters/definitions/native-quota.ts +29 -0
- package/src/adapters/definitions/native-recovery-continuity.ts +12 -0
- package/src/adapters/definitions/native-recovery.ts +88 -0
- package/src/adapters/definitions/qwen.ts +8 -4
- package/src/adapters/registry-types.ts +97 -10
- package/src/adapters/registry.ts +103 -3
- package/src/cli/console-state.ts +5 -5
- package/src/cli/identity.ts +6 -5
- package/src/cli/jcode-mcp.ts +2 -58
- package/src/cli/launch.ts +14 -100
- package/src/cli/main.ts +35 -272
- package/src/cli/output.ts +3 -2
- package/src/cli/preview.ts +14 -125
- package/src/cli/recovery-guidance.ts +17 -13
- package/src/cli/reset.ts +13 -1
- package/src/cli/setup.ts +2 -80
- package/src/cli/shell-quote.ts +3 -0
- package/src/cli/terminal-recovery.ts +17 -10
- package/src/cli/upgrade-interactive.ts +4 -2
- package/src/cli/upgrade-presentation.ts +11 -1
- package/src/cli/upgrade-restore-state.ts +2 -1
- package/src/cli/upgrade-runtime.ts +107 -146
- package/src/cli/upgrade.ts +10 -7
- package/src/hub/budget.ts +6 -30
- package/src/hub/bus.ts +133 -14
- package/src/hub/conductor.ts +8 -4
- package/src/hub/config-apply.ts +9 -13
- package/src/hub/config-reload.ts +7 -2
- package/src/hub/context-window.ts +3 -27
- package/src/hub/control-client.ts +1 -1
- package/src/hub/crash.ts +2 -35
- package/src/hub/daemon.ts +244 -241
- package/src/hub/deferred-hub.ts +60 -0
- package/src/hub/delivery-journal.ts +6 -0
- package/src/hub/execution-budget.ts +2 -1
- package/src/hub/hub-tools.ts +3 -3
- package/src/hub/peer-catalog.ts +4 -13
- package/src/hub/permission-mode.ts +9 -12
- package/src/hub/recovery-continuity.ts +6 -5
- package/src/hub/report.ts +9 -8
- package/src/hub/restart.ts +12 -1
- package/src/hub/roster.ts +6 -7
- package/src/hub/routing.ts +21 -10
- package/src/hub/settings.ts +6 -5
- package/src/hub/settled-roles.ts +1 -1
- package/src/hub/start-mode.ts +8 -6
- package/src/hub/tasks.ts +14 -11
- package/src/hub/tooling.ts +4 -60
- package/src/hub/ui.ts +3 -1
- package/src/memory/recall.ts +4 -5
- package/src/models/route/signals.ts +3 -1
- package/src/pi/launch.ts +2 -1
- package/src/ui/index.html +3 -2
- package/src/ui/office-model.ts +3 -1
- package/src/ui/peer-palette.ts +16 -6
- package/templates/routing.toml +2 -2
package/src/cli/upgrade.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { definitionOf } from "../adapters/registry.ts";
|
|
1
2
|
import { LIVE_TEST_PERSON_REFUSAL } from "./identity.ts";
|
|
2
3
|
import { checkedSessionPath } from "./recovery-session-file.ts";
|
|
3
4
|
import { canChooseFresh, decodeRestoreReceipt, restoreReceiptStatus } from "./upgrade-restore-state.ts";
|
|
@@ -32,6 +33,8 @@ export interface Inspection {
|
|
|
32
33
|
blockers: string[];
|
|
33
34
|
/** #215: for a stopped runtime, the operation whose committed restart snapshot its state dir holds (unreleased). */
|
|
34
35
|
snapshot?: string;
|
|
36
|
+
/** The runtime may be positively stopped while its restart snapshot remains unreadable; never claim ownership. */
|
|
37
|
+
snapshotError?: { code: "recovery_deferred_hub"; message: string };
|
|
35
38
|
}
|
|
36
39
|
export interface PlannedProject {
|
|
37
40
|
project: Project;
|
|
@@ -335,7 +338,7 @@ function missingRepair(op: RecoveryOperation, progress: ProjectProgress, peer: s
|
|
|
335
338
|
function failedPeers(op: RecoveryOperation, progress: ProjectProgress, live: Record<string, Inspection | undefined> = {}): string[] {
|
|
336
339
|
const planned = op.plan.projects.find((p) => p.project.id === progress.id);
|
|
337
340
|
return Object.entries(progress.terminals).filter(([key, value]) => key.startsWith("restored:") && canChooseFresh(value)).map(([key]) => key.slice("restored:".length))
|
|
338
|
-
.filter((peer) => peer
|
|
341
|
+
.filter((peer) => definitionOf(peer)?.session.personFresh === true && ((!Object.hasOwn(progress.reattachments ?? {}, peer) && !Object.hasOwn(progress.repairedSessions ?? {}, peer)) || missingRepair(op, progress, peer)) && !progress.fresh?.[peer] && (!planned?.freshStart?.includes(peer) || missingRepair(op, progress, peer)) && !plannedAttached(op, progress, peer, live));
|
|
339
342
|
}
|
|
340
343
|
|
|
341
344
|
/** Read-only scope classification; it confers no delegation authority. */
|
|
@@ -361,7 +364,7 @@ function plannedAttached(op: RecoveryOperation, progress: ProjectProgress, peer:
|
|
|
361
364
|
? resolveAcceptedBinding(planned, progress, peer as RecoveryNativePeer)
|
|
362
365
|
: (planned?.terminals as { peer: string; sessionId: string }[] | undefined)?.find((t) => t.peer === peer);
|
|
363
366
|
const current = state?.state === "running" ? state.peers.find((p) => p.id === peer && p.state !== "offline") : undefined;
|
|
364
|
-
return !!binding && (peer
|
|
367
|
+
return !!binding && current?.[definitionOf(peer)?.session.idField ?? "sessionId"] === binding.sessionId;
|
|
365
368
|
}
|
|
366
369
|
|
|
367
370
|
/** Registry reads for planning must not create a registry or run migrations. */
|
|
@@ -442,7 +445,7 @@ function validateReceipt(id: string, op: RecoveryOperation): void {
|
|
|
442
445
|
if (progress.reattachments === undefined) continue;
|
|
443
446
|
if (!object(progress.reattachments)) throw new Error("invalid reattachment markers");
|
|
444
447
|
for (const [peer, marker] of Object.entries(progress.reattachments)) {
|
|
445
|
-
if (
|
|
448
|
+
if (definitionOf(peer)?.session.launchRecord !== true || !object(marker) || Object.keys(marker).some(key => !["state", "at", "cause", "binding"].includes(key)) ||
|
|
446
449
|
!["ready", "attempted"].includes(String(marker.state)) || marker.cause !== "detached after restore" || typeof marker.at !== "number" || !Number.isFinite(marker.at) || marker.at < 0) throw new Error("invalid reattachment marker");
|
|
447
450
|
const receipt = decodeRestoreReceipt({ state: "done", binding: marker.binding });
|
|
448
451
|
if (receipt?.state !== "done" || receipt.binding.peer !== peer || receipt.binding.projectRoot !== op.plan.projects[index]?.project.root) throw new Error("reattachment binding does not match its peer and project");
|
|
@@ -464,7 +467,7 @@ function validateReceipt(id: string, op: RecoveryOperation): void {
|
|
|
464
467
|
}
|
|
465
468
|
if (op.blockedReattachment !== undefined) {
|
|
466
469
|
const selected = op.blockedReattachment;
|
|
467
|
-
if (!object(selected) || Object.keys(selected).some(key => !["projectId", "peer"].includes(key)) || typeof selected.projectId !== "string" ||
|
|
470
|
+
if (!object(selected) || Object.keys(selected).some(key => !["projectId", "peer"].includes(key)) || typeof selected.projectId !== "string" || definitionOf(String(selected.peer))?.session.launchRecord !== true ||
|
|
468
471
|
!op.projects.some(project => project.id === selected.projectId && Object.hasOwn(project.reattachments ?? {}, selected.peer))) throw new Error("invalid blocked reattachment selector");
|
|
469
472
|
}
|
|
470
473
|
}
|
|
@@ -792,7 +795,7 @@ export async function reattachRecovery(id: string, peer: string, driver: Recover
|
|
|
792
795
|
let op = readOperation<RecoveryOperation>(id, home);
|
|
793
796
|
validateReceipt(id, op);
|
|
794
797
|
const selection = () => {
|
|
795
|
-
if (
|
|
798
|
+
if (definitionOf(peer)?.session.launchRecord !== true || op.phase !== "blocked" || op.disposition || op.final || op.blockedReattachment?.peer !== peer) throw new Error("reattach is available only for the peer named by a blocked reattachment receipt");
|
|
796
799
|
const progress = op.projects.find(project => project.id === op.blockedReattachment!.projectId);
|
|
797
800
|
const planned = op.plan.projects.find(project => project.project.id === progress?.id);
|
|
798
801
|
const native = peer as RecoveryNativePeer;
|
|
@@ -817,7 +820,7 @@ export async function reattachRecovery(id: string, peer: string, driver: Recover
|
|
|
817
820
|
};
|
|
818
821
|
await target();
|
|
819
822
|
attempted = true;
|
|
820
|
-
await driver.restore(planned, progress, op, native === "claude" ? "claude" : "native", save, native);
|
|
823
|
+
await driver.restore(planned, progress, op, definitionOf(native)?.transport === "claude-channel" ? "claude" : "native", save, native);
|
|
821
824
|
// A done receipt is written by the runtime only after authenticated attachment and exact settlement.
|
|
822
825
|
// Inspection peers may be unavailable; do not invent an attachment from a test or a stale status projection.
|
|
823
826
|
const restored = decodeRestoreReceipt(progress.terminals[`restored:${native}`]);
|
|
@@ -913,7 +916,7 @@ export async function disposeRecovery(id: string, choice: { fresh: string } | {
|
|
|
913
916
|
const next = nextActionsText(op, live);
|
|
914
917
|
if ("fresh" in choice) {
|
|
915
918
|
// `ahub pi` sends no fresh flag, and a restored hub refills a Pi start from its recorded resume (daemon startPeer).
|
|
916
|
-
if (choice.fresh ===
|
|
919
|
+
if (definitionOf(choice.fresh)?.session.personFresh === false) throw new Error(`pi: --fresh-session is not supported: a restored hub resumes Pi's recorded session, so a fresh one cannot be guaranteed; ${op.plan.projects.map(planned => piArchiveExit(planned, recoveryCommand(op, "dispose", STOP))).join("; ")}; ${next}`);
|
|
917
920
|
if (op.disposition) throw new Error(`a stop-and-archive of this operation is partway; ${next}`);
|
|
918
921
|
if (!targetReadsWaivers(op)) throw new Error(`--fresh-session is not available: target ${op.plan.version} cannot read recovery waivers, so it could not release a new session; ${next}`);
|
|
919
922
|
// The new session is launched by resume, so it helps only where resume can get past what is live.
|
package/src/hub/budget.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { definitionOf } from "../adapters/registry.ts";
|
|
1
2
|
import { Database } from "bun:sqlite";
|
|
2
3
|
import type { PeerId } from "./envelope.ts";
|
|
3
4
|
import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
|
|
@@ -70,8 +71,6 @@ const WAIT_NOTE = "; keeps its work:";
|
|
|
70
71
|
const WAITED_NOTE = "; waited, then the reset moved to";
|
|
71
72
|
const waitedNote = (min: number, max: number) => `${WAITED_NOTE} ${min} min, beyond wait_max_min ${max}`;
|
|
72
73
|
const FALLBACK_WINDOW_MINS = 300;
|
|
73
|
-
/** Peers the coordinator never pauses: the local worker has no quota. */
|
|
74
|
-
const EXEMPT = new Set<PeerId>(["local", "user", "hub"]);
|
|
75
74
|
|
|
76
75
|
/**
|
|
77
76
|
* Budget relay. Readings come from the adapters (Codex rate limits, Claude's status line, Kimi tokens) or from
|
|
@@ -156,7 +155,10 @@ export class Budget {
|
|
|
156
155
|
/** `hard`: the peer cannot run another turn (its provider refused one), so there is nobody to ask for a checkpoint. */
|
|
157
156
|
/** `at`: when the numbers were measured, for sources that arrive through a file (a leftover file is not a fresh reading). */
|
|
158
157
|
report(peer: PeerId, windows: UsageWindow[], hard = false, at = this.now()): void {
|
|
159
|
-
|
|
158
|
+
// Execution-budget instrumentation is independent of native quota accounting.
|
|
159
|
+
// Explicit quota exemption or absent budget metadata exempts registered kinds; external ids keep accounting.
|
|
160
|
+
const adapter = definitionOf(peer);
|
|
161
|
+
if (peer === "user" || peer === "hub" || adapter && (!adapter.budget || adapter.budget.quotaExempt === true) || this.closed) return;
|
|
160
162
|
// An override ignores soft readings, not a provider that has actually refused another turn.
|
|
161
163
|
if (hard && this.ignoreUntil.delete(peer)) {
|
|
162
164
|
try { this.hooks.notify(`budget: ${peer} provider refused again; budget override ended`); } catch { /* a notice cannot prevent the hard pause */ }
|
|
@@ -356,30 +358,4 @@ export class Budget {
|
|
|
356
358
|
}
|
|
357
359
|
}
|
|
358
360
|
|
|
359
|
-
|
|
360
|
-
export function codexWindows(rateLimits: any): UsageWindow[] {
|
|
361
|
-
const out: UsageWindow[] = [];
|
|
362
|
-
// The slot decides (primary = short window, secondary = long one) and the duration overrides it when present:
|
|
363
|
-
// readings are keyed by window id, so two windows must never both come out as "5h".
|
|
364
|
-
for (const [w, slot] of [[rateLimits?.primary, "5h"], [rateLimits?.secondary, "week"]] as const) {
|
|
365
|
-
if (!w || typeof w.usedPercent !== "number") continue;
|
|
366
|
-
const mins = typeof w.windowDurationMins === "number" ? w.windowDurationMins : undefined;
|
|
367
|
-
const id = mins === undefined ? slot : mins > 600 ? "week" : "5h";
|
|
368
|
-
if (out.some((o) => o.id === id)) continue; // both slots claim the same window: keep the first, never overwrite
|
|
369
|
-
out.push({ id, used: w.usedPercent / 100, ...(w.resetsAt ? { resetsAt: w.resetsAt * 1000 } : {}), ...(mins ? { windowMins: mins } : {}), source: "codex rateLimits" });
|
|
370
|
-
}
|
|
371
|
-
if (rateLimits?.rateLimitReachedType && !out.some((w) => w.used >= 1)) out.push({ id: "5h", used: 1, source: `codex ${rateLimits.rateLimitReachedType}` });
|
|
372
|
-
return out;
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
/** Claude Code status line `rate_limits` -> windows. */
|
|
376
|
-
export function claudeWindows(rateLimits: any): UsageWindow[] {
|
|
377
|
-
const out: UsageWindow[] = [];
|
|
378
|
-
for (const [key, id, mins] of [["five_hour", "5h", 300], ["seven_day", "week", 10_080]] as const) {
|
|
379
|
-
const w = rateLimits?.[key];
|
|
380
|
-
if (!w || typeof w.used_percentage !== "number") continue;
|
|
381
|
-
const resets = Number(w.resets_at);
|
|
382
|
-
out.push({ id, used: w.used_percentage / 100, ...(resets ? { resetsAt: resets < 1e12 ? resets * 1000 : resets } : {}), windowMins: mins, source: "claude status line" });
|
|
383
|
-
}
|
|
384
|
-
return out;
|
|
385
|
-
}
|
|
361
|
+
export { codexWindows, claudeWindows } from "../adapters/definitions/native-quota.ts";
|
package/src/hub/bus.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { appendNote, DIGEST, HUB, keepNotes, MAX_HOP, mergePrefaces, newEnvelope
|
|
|
2
2
|
import type { PeerAdapter, DeliveryReceipt } from "./peers.ts";
|
|
3
3
|
import { DeliveryJournal, MAX_ENVELOPE_BODY, type JournalDelivery, type JournalDeliveryState } from "./delivery-journal.ts";
|
|
4
4
|
import { DELIVERY_RETRY_WARNING } from "./delivery-hints.ts";
|
|
5
|
+
import { MAX_DEFERRED_HUB, RecoveryDeferredHubError, validateDeferredHub } from "./deferred-hub.ts";
|
|
5
6
|
import { PreEffectToolRefusal } from "./tool-refusal.ts";
|
|
6
7
|
|
|
7
8
|
export type { DeliveryReceipt } from "./peers.ts";
|
|
@@ -64,6 +65,8 @@ export interface BusSnapshot {
|
|
|
64
65
|
attempts: Record<string, number>;
|
|
65
66
|
withdrawn: string[];
|
|
66
67
|
manualPaused?: string[];
|
|
68
|
+
/** Hub notices accepted during recovery, durable but outside the carried delivery queues. */
|
|
69
|
+
deferredHub?: Envelope[];
|
|
67
70
|
journal?: ReturnType<DeliveryJournal["snapshot"]>;
|
|
68
71
|
}
|
|
69
72
|
|
|
@@ -142,6 +145,12 @@ export class Bus {
|
|
|
142
145
|
/** Synchronous native failure observation, before queue callbacks can finish turn metrics. */
|
|
143
146
|
onDeliveryFailed?: (peer: PeerId) => void;
|
|
144
147
|
private recoveryHeld = false;
|
|
148
|
+
private deferHub = false;
|
|
149
|
+
private readonly deferredHub = new Map<string, Envelope>();
|
|
150
|
+
private flushingHub = false;
|
|
151
|
+
private replayingHub = false;
|
|
152
|
+
private deferredEvents: BusEvent[] = [];
|
|
153
|
+
private deferredNudges: (() => void)[] = [];
|
|
145
154
|
private steering = 0;
|
|
146
155
|
private condensing = 0;
|
|
147
156
|
private closed = false;
|
|
@@ -273,10 +282,61 @@ export class Bus {
|
|
|
273
282
|
}
|
|
274
283
|
|
|
275
284
|
/** Stop new deliveries while a coordinator takes a stable snapshot. Existing adapter turns are left alone. */
|
|
276
|
-
setRecoveryHold(held: boolean): void {
|
|
277
|
-
this.
|
|
278
|
-
if (
|
|
279
|
-
|
|
285
|
+
setRecoveryHold(held: boolean, deferHub = false): void {
|
|
286
|
+
if (this.replayingHub) throw new RecoveryDeferredHubError("recovery notice release is already in progress");
|
|
287
|
+
if (held) {
|
|
288
|
+
this.recoveryHeld = true;
|
|
289
|
+
this.deferHub = deferHub;
|
|
290
|
+
// An older target cannot carry this additive field: materialize before commit's expected integrity.
|
|
291
|
+
if (!deferHub) this.flushDeferredHub();
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
// Persist each deferred -> queued transition while deliveries are still fenced. A failed checkpoint
|
|
295
|
+
// leaves the hold and the original pending head intact; a crash after it resumes from the journal.
|
|
296
|
+
this.flushDeferredHub();
|
|
297
|
+
this.recoveryHeld = false;
|
|
298
|
+
this.onQueues?.();
|
|
299
|
+
for (const id of this.peers.keys()) void this.drain(id);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
private flushDeferredHub(): void {
|
|
303
|
+
if (this.flushingHub) return;
|
|
304
|
+
if (this.storageError) this.persist();
|
|
305
|
+
this.replayingHub = true;
|
|
306
|
+
try { while (this.deferredHub.size) {
|
|
307
|
+
const env = this.deferredHub.values().next().value!;
|
|
308
|
+
const beforeDeferred = [...this.deferredHub], beforeSeen = [...this.seen], beforeWithdrawn = [...this.withdrawn];
|
|
309
|
+
const affected = new Map(this.audience(env).map(peer => {
|
|
310
|
+
const queue = this.queues.get(peer);
|
|
311
|
+
return [peer, queue ? { queue, contents: [...queue] } : undefined] as const;
|
|
312
|
+
}));
|
|
313
|
+
const outcomes = this.pendingOutcomes.length;
|
|
314
|
+
this.flushingHub = true;
|
|
315
|
+
this.deferredEvents = []; this.deferredNudges = [];
|
|
316
|
+
try {
|
|
317
|
+
this.deferredHub.delete(env.id);
|
|
318
|
+
this.publishNow(env);
|
|
319
|
+
} catch (error) {
|
|
320
|
+
// Only publication state is transactional. A live peer's preface, pauses, attempts and
|
|
321
|
+
// recovery mode are independent state, not something a failed checkpoint may reload.
|
|
322
|
+
this.deferredHub.clear(); for (const [id, pending] of beforeDeferred) this.deferredHub.set(id, pending);
|
|
323
|
+
this.seen.clear(); for (const [id, seen] of beforeSeen) this.seen.set(id, seen);
|
|
324
|
+
this.withdrawn.clear(); for (const id of beforeWithdrawn) this.withdrawn.add(id);
|
|
325
|
+
for (const [peer, queue] of affected) {
|
|
326
|
+
if (queue) { queue.queue.splice(0, queue.queue.length, ...queue.contents); this.queues.set(peer, queue.queue); }
|
|
327
|
+
else this.queues.delete(peer);
|
|
328
|
+
}
|
|
329
|
+
this.pendingOutcomes.length = outcomes;
|
|
330
|
+
this.deferredEvents = []; this.deferredNudges = [];
|
|
331
|
+
throw new RecoveryDeferredHubError("deferred hub notice checkpoint failed; recovery remains held; restore delivery storage, then retry release or abort");
|
|
332
|
+
} finally { this.flushingHub = false; }
|
|
333
|
+
// Publication observers run only after the durable transition. Reentrant notices join the FIFO tail.
|
|
334
|
+
const events = this.deferredEvents; this.deferredEvents = [];
|
|
335
|
+
for (const event of events) this.emit(event);
|
|
336
|
+
const nudges = this.deferredNudges; this.deferredNudges = [];
|
|
337
|
+
for (const nudge of nudges) nudge();
|
|
338
|
+
this.onQueues?.();
|
|
339
|
+
} } finally { this.replayingHub = false; }
|
|
280
340
|
}
|
|
281
341
|
|
|
282
342
|
get isRecoveryHeld(): boolean {
|
|
@@ -296,7 +356,7 @@ export class Bus {
|
|
|
296
356
|
let delay = 1;
|
|
297
357
|
while (this.draining.size || this.steering || this.condensing) {
|
|
298
358
|
if (Date.now() >= deadline) {
|
|
299
|
-
this.
|
|
359
|
+
if (!heldBefore) this.setRecoveryHold(false);
|
|
300
360
|
throw new Error(`recovery fence timed out after ${timeoutMs} ms with ${this.draining.size + this.steering + this.condensing} deliveries still in flight; retry the operation`);
|
|
301
361
|
}
|
|
302
362
|
await Bun.sleep(delay);
|
|
@@ -314,6 +374,7 @@ export class Bus {
|
|
|
314
374
|
attempts: Object.fromEntries(this.attempts),
|
|
315
375
|
withdrawn: [...this.withdrawn],
|
|
316
376
|
manualPaused: [...this.manualPaused],
|
|
377
|
+
...(this.deferredHub.size ? { deferredHub: structuredClone([...this.deferredHub.values()]) } : {}),
|
|
317
378
|
};
|
|
318
379
|
if (includeJournal && this.journal) out.journal = this.journal.snapshot();
|
|
319
380
|
return out;
|
|
@@ -341,6 +402,10 @@ export class Bus {
|
|
|
341
402
|
}
|
|
342
403
|
|
|
343
404
|
private loadSnapshot(snapshot: BusSnapshot): void {
|
|
405
|
+
const deferred = validateDeferredHub(snapshot.deferredHub, undefined, new Set([...snapshot.seen.map(e => e.id), ...Object.values(snapshot.queues).flat().map(e => e.id)]));
|
|
406
|
+
if (deferred.length) { this.recoveryHeld = true; this.deferHub = true; }
|
|
407
|
+
this.deferredHub.clear();
|
|
408
|
+
for (const env of deferred) this.deferredHub.set(env.id, structuredClone(env));
|
|
344
409
|
this.queues.clear();
|
|
345
410
|
for (const [id, queue] of Object.entries(snapshot.queues ?? {})) this.queues.set(id, queue.map((e) => ({ ...e, ...(e.to ? { to: [...e.to] } : {}) })));
|
|
346
411
|
this.prefaces.clear();
|
|
@@ -532,7 +597,7 @@ export class Bus {
|
|
|
532
597
|
|
|
533
598
|
queuedBacklogIds(id: PeerId): Set<number> {
|
|
534
599
|
const ids = new Set<number>();
|
|
535
|
-
const retained = [...
|
|
600
|
+
const retained = [...this.pendingEnvelopes(id), ...(this.journal?.unresolved(id).filter(row => row.state !== "queued").flatMap(row => row.originals) ?? [])];
|
|
536
601
|
for (const env of retained) {
|
|
537
602
|
const backlog = env.refs?.backlog;
|
|
538
603
|
if (env.from !== HUB || env.kind !== "task" || !Array.isArray(backlog) || backlog.length > 64) continue;
|
|
@@ -602,8 +667,52 @@ export class Bus {
|
|
|
602
667
|
this.prefaces.set(id, !now ? { ...env, body: uniqueMemoryLines(env.body) } : now.id === env.id ? { ...now, body: uniqueMemoryLines(now.body) } : { ...env, body: keepNotes(mergePrefaces(env.body, now.body), NOTES_KEPT) });
|
|
603
668
|
}
|
|
604
669
|
|
|
605
|
-
/**
|
|
670
|
+
/** Stopped-runtime reset only: audit every deferred recipient without publishing or releasing work. */
|
|
671
|
+
discardDeferredHub(reason: string): string[] {
|
|
672
|
+
if (this.peers.size || this.flushingHub || this.replayingHub || !this.journal || this.storageError) throw new Error("deferred reset requires a stopped, readable delivery journal");
|
|
673
|
+
const pending = [...this.deferredHub];
|
|
674
|
+
const settled: string[] = [];
|
|
675
|
+
try {
|
|
676
|
+
this.journal.transaction(() => {
|
|
677
|
+
for (const [, env] of pending) for (const peer of new Set(env.to ?? [])) {
|
|
678
|
+
const id = `q:${peer}:${env.id}`;
|
|
679
|
+
this.journal!.createDelivery({ id, peer, state: "queued", createdAt: env.ts, originals: [env], out: [env] });
|
|
680
|
+
const row = this.journal!.get(id)!;
|
|
681
|
+
this.journal!.resolve(id, row.revision, "discard", reason, row.revision, "user");
|
|
682
|
+
settled.push(id);
|
|
683
|
+
}
|
|
684
|
+
this.deferredHub.clear();
|
|
685
|
+
this.persist();
|
|
686
|
+
});
|
|
687
|
+
} catch (error) {
|
|
688
|
+
this.deferredHub.clear(); for (const [id, env] of pending) this.deferredHub.set(id, env);
|
|
689
|
+
throw error;
|
|
690
|
+
}
|
|
691
|
+
return settled;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/** Accepted pending work, including notices withheld from the recovery integrity view. */
|
|
695
|
+
pendingEnvelopes(peer: PeerId): Envelope[] {
|
|
696
|
+
return [...(this.queues.get(peer) ?? []), ...[...this.deferredHub.values()].filter(e => e.to?.includes(peer))];
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/** Returns the peers the envelope was accepted for, including durable deferred notices. */
|
|
606
700
|
publish(env: Envelope): PeerId[] {
|
|
701
|
+
if (this.storageError) throw new Error("delivery journal unavailable");
|
|
702
|
+
if (this.seen.has(env.id) || this.deferredHub.has(env.id)) return [];
|
|
703
|
+
if (this.recoveryHeld && (this.deferHub || this.replayingHub) && env.from === HUB && env.priority !== "fyi" && env.hop <= MAX_HOP) {
|
|
704
|
+
if (this.deferredHub.size >= MAX_DEFERRED_HUB) throw new RecoveryDeferredHubError();
|
|
705
|
+
const targets = this.audience(env);
|
|
706
|
+
const copy = structuredClone({ ...env, to: targets });
|
|
707
|
+
this.deferredHub.set(copy.id, copy);
|
|
708
|
+
try { this.persist(); }
|
|
709
|
+
catch (error) { this.deferredHub.delete(copy.id); throw error; }
|
|
710
|
+
return targets;
|
|
711
|
+
}
|
|
712
|
+
return this.publishNow(env);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
private publishNow(env: Envelope): PeerId[] {
|
|
607
716
|
if (this.storageError) throw new Error("delivery journal unavailable");
|
|
608
717
|
if (this.seen.has(env.id)) return [];
|
|
609
718
|
this.seen.set(env.id, env);
|
|
@@ -638,7 +747,7 @@ export class Bus {
|
|
|
638
747
|
} } finally { this.suppressDrain = false; }
|
|
639
748
|
if (this.journal) {
|
|
640
749
|
this.persist();
|
|
641
|
-
this.onQueues?.();
|
|
750
|
+
if (!this.flushingHub) this.onQueues?.();
|
|
642
751
|
for (const id of targets) {
|
|
643
752
|
const peer = this.peers.get(id);
|
|
644
753
|
if (!this.held(id) && env.priority === "important" && peer?.steer && this.stateOf(id) === "busy") void this.steerQueued(id, env);
|
|
@@ -681,7 +790,7 @@ export class Bus {
|
|
|
681
790
|
|
|
682
791
|
/** A recently published envelope, for resolving `reply_to`. */
|
|
683
792
|
get(id: string): Envelope | undefined {
|
|
684
|
-
return this.seen.get(id);
|
|
793
|
+
return this.seen.get(id) ?? this.deferredHub.get(id);
|
|
685
794
|
}
|
|
686
795
|
|
|
687
796
|
/** The envelopes most recently handed to a peer, used to distinguish a received request from an unread queue. */
|
|
@@ -696,10 +805,10 @@ export class Bus {
|
|
|
696
805
|
*/
|
|
697
806
|
withdraw(envelopeId: string): boolean {
|
|
698
807
|
this.markWithdrawn(envelopeId);
|
|
699
|
-
let removed =
|
|
808
|
+
let removed = this.deferredHub.delete(envelopeId);
|
|
700
809
|
for (const queue of this.queues.values()) {
|
|
701
810
|
const i = queue.findIndex((e) => e.id === envelopeId);
|
|
702
|
-
if (i !== -1)
|
|
811
|
+
if (i !== -1) { queue.splice(i, 1); removed = true; }
|
|
703
812
|
}
|
|
704
813
|
if (removed) this.onQueues?.();
|
|
705
814
|
this.persist();
|
|
@@ -709,8 +818,15 @@ export class Bus {
|
|
|
709
818
|
/** Revoke pending feed copies only; accepted/dispatching journal records and native turns are untouched. */
|
|
710
819
|
revokeSupervision(peer: PeerId): number {
|
|
711
820
|
const queue = this.queues.get(peer);
|
|
712
|
-
if (!queue && !this.preparing.has(peer)) return 0;
|
|
713
821
|
let removed = 0;
|
|
822
|
+
for (const [id, env] of this.deferredHub) {
|
|
823
|
+
if (!env.refs?.supervision || !env.to?.includes(peer)) continue;
|
|
824
|
+
const remaining = env.to.filter(target => target !== peer);
|
|
825
|
+
if (remaining.length) this.deferredHub.set(id, { ...env, to: remaining });
|
|
826
|
+
else this.deferredHub.delete(id);
|
|
827
|
+
removed++;
|
|
828
|
+
}
|
|
829
|
+
if (!queue && !this.preparing.has(peer)) { if (removed) this.persist(); return removed; }
|
|
714
830
|
const discarded = new Set<string>();
|
|
715
831
|
for (let i = (queue?.length ?? 0) - 1; i >= 0; i--) {
|
|
716
832
|
const env = queue![i]!;
|
|
@@ -796,9 +912,11 @@ export class Bus {
|
|
|
796
912
|
}
|
|
797
913
|
if (queuedEnv.priority === "important" && this.peers.get(id)?.pullOnly) {
|
|
798
914
|
// Advisory only (issue #398): the envelope stays queued for `pull`; the hook must never affect it.
|
|
799
|
-
|
|
915
|
+
const counts = { queued: queue.length, important: queue.filter((e) => e.priority === "important").length };
|
|
916
|
+
const nudge = () => { try { this.opts.onImportantQueued?.(id, counts); } catch { /* a nudge never stops an enqueue */ } };
|
|
917
|
+
if (this.flushingHub) this.deferredNudges.push(nudge); else nudge();
|
|
800
918
|
}
|
|
801
|
-
this.onQueues?.();
|
|
919
|
+
if (!this.flushingHub) this.onQueues?.();
|
|
802
920
|
if (!this.suppressDrain) this.persist();
|
|
803
921
|
if (!this.suppressDrain) void this.drain(id);
|
|
804
922
|
}
|
|
@@ -1060,6 +1178,7 @@ export class Bus {
|
|
|
1060
1178
|
}
|
|
1061
1179
|
|
|
1062
1180
|
private emit(e: BusEvent): void {
|
|
1181
|
+
if (this.flushingHub) { this.deferredEvents.push(e); return; }
|
|
1063
1182
|
for (const tap of this.taps) tap(e);
|
|
1064
1183
|
}
|
|
1065
1184
|
}
|
package/src/hub/conductor.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
import { definitionOf, SUPPORTED_PEERS } from "../adapters/registry.ts";
|
|
1
2
|
import { Database } from "bun:sqlite";
|
|
2
3
|
import type { Task } from "./board.ts";
|
|
3
4
|
import { CONDUCTOR_TOOL_NAMES } from "./hub-tools.ts";
|
|
4
5
|
import { HUB, USER } from "./envelope.ts";
|
|
5
6
|
import { OWNERSHIP_EVENTS, type TaskAssignmentRole } from "./tasks.ts";
|
|
6
7
|
import { PreEffectToolRefusal } from "./tool-refusal.ts";
|
|
7
|
-
import
|
|
8
|
+
import type { StartMode } from "./start-mode.ts";
|
|
8
9
|
import type { HubEvent } from "./events.ts";
|
|
9
10
|
import type { SupervisionFeed } from "./supervision.ts";
|
|
10
11
|
import type { Budget } from "./budget.ts";
|
|
@@ -103,6 +104,8 @@ export class ConductorHolds {
|
|
|
103
104
|
close(): void { this.db.close(); }
|
|
104
105
|
}
|
|
105
106
|
|
|
107
|
+
type StartablePeer = typeof SUPPORTED_PEERS[number];
|
|
108
|
+
|
|
106
109
|
export type ConductorStart = { peer: StartablePeer; mode: "headless" } | { peer: "claude" | "codex" | "pi"; mode: "tui"; command: string };
|
|
107
110
|
/**
|
|
108
111
|
* What a conductor's start of `peer` is (#269): the peer's own start mode, which a person sets. A `mode` argument may
|
|
@@ -111,10 +114,11 @@ export type ConductorStart = { peer: StartablePeer; mode: "headless" } | { peer:
|
|
|
111
114
|
export function conductorStart(peer: unknown, mode: unknown, preview: (peer: "claude" | "codex" | "pi") => string, preEffect: boolean, startMode: (peer: StartablePeer) => StartMode): ConductorStart {
|
|
112
115
|
const refuse = (text: string) => new (preEffect ? PreEffectToolRefusal : Error)(text);
|
|
113
116
|
if (mode !== undefined && mode !== "headless" && mode !== "tui") throw refuse("mode must be headless or tui");
|
|
114
|
-
if (!
|
|
115
|
-
const
|
|
117
|
+
if (!(typeof peer === "string" && definitionOf(peer)?.start.conductorStartable)) throw refuse("only claude, codex, kimi, pi and local, plus opt-in qwen/jcode, may be started by the conductor");
|
|
118
|
+
const selected = peer as StartablePeer;
|
|
119
|
+
const own = startMode(selected);
|
|
116
120
|
if (mode !== undefined && mode !== own) throw refuse(`${peer} starts ${own} here; a person sets a start mode (ahub settings), the conductor cannot choose one`);
|
|
117
|
-
return own === "tui" ? { peer: peer as "claude" | "codex" | "pi", mode: "tui", command: preview(peer as "claude" | "codex" | "pi") } : { peer, mode: "headless" };
|
|
121
|
+
return own === "tui" ? { peer: peer as "claude" | "codex" | "pi", mode: "tui", command: preview(peer as "claude" | "codex" | "pi") } : { peer: selected, mode: "headless" };
|
|
118
122
|
}
|
|
119
123
|
|
|
120
124
|
const numberOrNull = (v: unknown): number | null => typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : null;
|
package/src/hub/config-apply.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { HubConfig } from "./daemon.ts";
|
|
2
2
|
import { ALL_MACHINE_LOCAL, configRepositoryPresent, configTracked } from "./config-trust.ts";
|
|
3
|
-
import { SUPPORTED_PEERS, PLANNER_PEERS } from "
|
|
3
|
+
import { ADAPTERS, SUPPORTED_PEERS, PLANNER_PEERS, START_MODE_PEERS } from "../adapters/registry.ts";
|
|
4
4
|
import { CLASSES } from "./board.ts";
|
|
5
5
|
import { permissionDefaults } from "./permission-mode.ts";
|
|
6
6
|
import { peerStartConfig, terminalTemplate } from "./start-mode.ts";
|
|
@@ -103,7 +103,9 @@ const block = (schema: Record<string, Check>): Check => {
|
|
|
103
103
|
};
|
|
104
104
|
const peerMap = (check: Check) => map(check, SUPPORTED_PEERS);
|
|
105
105
|
const timerSeconds = number(1, 2_147_483_647 / 1000), timerMinutes = number(1, 2_147_483_647 / 60_000);
|
|
106
|
+
const nativeValidators = { bool, text, strings, argv, number, words, block };
|
|
106
107
|
const schema: Record<string, Check> = {
|
|
108
|
+
...Object.assign({}, ...ADAPTERS.map(adapter => adapter.configuration?.schema?.(nativeValidators) ?? {})),
|
|
107
109
|
watchdog_ms: number(1, 2_147_483_647), kimi_cmd: argv, qwen_cmd: argv, jcode_cmd: argv, codex_bin: text, jcode_home: text, jcode_provider_profile: text,
|
|
108
110
|
permission_modes: v => { permissionDefaults(v); }, protect_instruction_files: bool,
|
|
109
111
|
batch_max: number(1, 100_000, true), batch_ms: number(0, 2_147_483_647), queue_cap: number(1, 1_000_000, true),
|
|
@@ -119,11 +121,9 @@ const schema: Record<string, Check> = {
|
|
|
119
121
|
context: block({ gate: number(0, 1), stale_min: timerMinutes }),
|
|
120
122
|
inference: block({ enabled: bool, digest_min_items: number(1, 100_000, true), digest_min_chars: number(1, 10_000_000, true), triage: bool }),
|
|
121
123
|
omniroute: block({ urls: strings, api_key_file: text, access_hosts: strings, cf_client_id_file: text, cf_client_secret_file: text }),
|
|
122
|
-
pi: block({ enabled: bool, auto_start: bool, cmd: argv, backend: words(["auto", "dgx", "mlx"]), dgx_coding: text, dgx_fast: text, max_steps: number(1, 1_000_000, true) }),
|
|
123
124
|
mlx: block({ enabled: bool, provider: words(["legacy", "ollama"]), host: text, runtimeDir: text, modelPath: text, bin: text, port: number(1, 65535, true), model: text, sourceModel: text,
|
|
124
125
|
contextWindow: number(1), maxInputTokens: number(1), maxTokens: number(1), maxConcurrency: number(1, 1024, true) }),
|
|
125
|
-
|
|
126
|
-
approvals: block({ timeout_s: timerSeconds, notify: bool }), tasks: block({ release_after_min: number(0), wip_limits: map(number(0, 32, true)) }),
|
|
126
|
+
approvals: block({ timeout_s: timerSeconds, notify: bool }), tasks: block({ release_after_min: number(0), wip_limits: map(number(0, 32, true)), escalate_after: number(1, 10, true) }),
|
|
127
127
|
task_sweep: (v, k) => { block({ enabled: bool, auto_reassign: bool, interval_s: timerSeconds, unaccepted_min: timerMinutes, idle_min: timerMinutes, review_min: timerMinutes, ready_min: timerMinutes, ladder_min: timerMinutes })(v, k); taskSweepConfig(v); },
|
|
128
128
|
checks: (v, k) => { for (const [key, value] of Object.entries(object(v, k))) {
|
|
129
129
|
if (value === undefined) continue;
|
|
@@ -132,7 +132,7 @@ const schema: Record<string, Check> = {
|
|
|
132
132
|
else if (/^[a-z][a-z0-9_]{0,47}$/.test(key)) text(value, `${k}.${key}`); else fail(`${k}.${key}`, "unknown key");
|
|
133
133
|
} },
|
|
134
134
|
snapshots: block({ enabled: bool, keep: number(1, 1_000_000, true) }), research: block({ enabled: bool }), bench: block({ enabled: bool }),
|
|
135
|
-
peers: (v, k) => { map(block({ start_mode: words(["tui", "headless"]) }),
|
|
135
|
+
peers: (v, k) => { map(block({ start_mode: words(["tui", "headless"]) }), START_MODE_PEERS)(v, k); peerStartConfig(v); },
|
|
136
136
|
terminal: (v, k) => { block({ open: strings })(v, k); terminalTemplate(object(v, k).open); },
|
|
137
137
|
limits: block({ sender_per_min: number(0), pair_per_min: number(0), important_per_hour: number(0), repeat_window_s: number(0) }),
|
|
138
138
|
review: block({ adaptive: bool, min_reviews: number(1, Number.MAX_SAFE_INTEGER, true) }), recovery: block({ auto_resume_after_crash: bool }),
|
|
@@ -145,23 +145,19 @@ export function validateEffectiveConfig(value: unknown): asserts value is HubCon
|
|
|
145
145
|
validateRawConfig(raw);
|
|
146
146
|
const profile = resolveProfile(raw.profile, raw.profile_overrides);
|
|
147
147
|
for (const key of Object.keys(object(raw.checks, "checks"))) if (!["timeout_s", "runner", ...CLASSES, ...Object.keys(profile.classes)].includes(key)) fail(`checks.${key}`, "unknown task class");
|
|
148
|
-
|
|
148
|
+
for (const adapter of ADAPTERS) adapter.configuration?.validateEffective?.(raw, fail);
|
|
149
149
|
}
|
|
150
150
|
|
|
151
151
|
const restart: Record<string, string> = {
|
|
152
|
-
|
|
153
|
-
"pi.dgx_fast": "Published Pi relay model catalogue requires a new daemon relay",
|
|
154
|
-
"routing.pi": "Pi relay context and model publication are bound to the running relay",
|
|
152
|
+
...Object.assign({}, ...ADAPTERS.map(adapter => adapter.configuration?.restartReasons ?? {})),
|
|
155
153
|
memory: "Open memory consumers, selected profile, client and sync timer must be quiesced together",
|
|
156
154
|
omniroute: "Credentials and gateway clients are bound to relay ownership", mlx: "Model runtime ownership and device resources belong to this daemon",
|
|
157
155
|
checks: "Bounded runner and completion-check contracts are constructed together",
|
|
158
156
|
pii: "Existing native sessions retain their startup privacy boundary", data: "Existing native sessions retain their startup read-only policy",
|
|
159
|
-
"local.deny": "Existing native sessions retain their sandbox deny policy", "local.read_allow": "Existing native sessions retain their sandbox read grants",
|
|
160
|
-
"local.bash_network": "Existing sandbox and egress proxy contracts require a new instance", "local.network_allow": "Existing egress proxy ownership requires a new instance",
|
|
161
157
|
profile: "Session tool schemas and profile contracts are bound at startup", profile_overrides: "Session tool schemas and profile contracts are bound at startup",
|
|
162
158
|
text_projection: "Converter and cache ownership are bound at startup", terminal: "Terminal provider is supplied by the daemon lifecycle", bench: "Benchmark execution environment is selected before work starts",
|
|
163
159
|
};
|
|
164
|
-
const live = new Set(["permission", "roster", "roles", "planner", "conductor", "protect_instruction_files", "task_sweep", "capabilities", "research", "snapshots", "tasks.wip_limits", "approvals.notify"]);
|
|
160
|
+
const live = new Set(["permission", "roster", "roles", "planner", "conductor", "protect_instruction_files", "task_sweep", "capabilities", "research", "snapshots", "tasks.wip_limits", "tasks.escalate_after", "approvals.notify"]);
|
|
165
161
|
const nextMemory = new Set(["memory.inject_tokens", "memory.brief_items", "memory.suggest_edges"]);
|
|
166
162
|
/** Closed top-level schema; leaf overrides retain the existing session/runtime boundaries. */
|
|
167
163
|
export function configApply(key: string): ApplyRule {
|
|
@@ -182,7 +178,7 @@ export function configApply(key: string): ApplyRule {
|
|
|
182
178
|
else return fail(key, "unknown configuration key");
|
|
183
179
|
}
|
|
184
180
|
}
|
|
185
|
-
if (top === "routing" && !
|
|
181
|
+
if (top === "routing" && !ADAPTERS.some(adapter => Object.keys(adapter.configuration?.restartReasons ?? {}).some(prefix => prefix.startsWith("routing.") && key.startsWith(prefix))) && key !== "routing") {
|
|
186
182
|
return { applies: ["classes", "stay_switch", "signals", "constraints", "hub_routes", "max_switch_prefill_tokens"].includes(key.split(".")[1]!) ? "live" : "next use" };
|
|
187
183
|
}
|
|
188
184
|
if (nextMemory.has(key)) return { applies: "next use" };
|
package/src/hub/config-reload.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ADAPTERS } from "../adapters/registry.ts";
|
|
1
2
|
import { createHash } from "node:crypto";
|
|
2
3
|
import { readFileSync } from "node:fs";
|
|
3
4
|
import { join } from "node:path";
|
|
@@ -66,15 +67,19 @@ function validateRoutingRaw(value: unknown): void {
|
|
|
66
67
|
const entry = table(item, `${key}.${name}`); text(entry[required], `${key}.${name}.${required}`); openValue(entry, `${key}.${name}`);
|
|
67
68
|
}
|
|
68
69
|
};
|
|
70
|
+
const nativeValidators = { bool, text, strings, argv: strings,
|
|
71
|
+
number: (min = 1, max = Number.MAX_SAFE_INTEGER, _integer = true) => (v: unknown, k: string) => numeric(v, k, min, max),
|
|
72
|
+
words: (values: readonly string[]) => (v: unknown, k: string) => choice(v, k, values),
|
|
73
|
+
block: (fields: Record<string, (v: unknown, k: string) => void>) => (v: unknown, k: string) => closed(v, k, fields),
|
|
74
|
+
};
|
|
69
75
|
closed(value, "routing", {
|
|
70
|
-
|
|
76
|
+
...Object.assign({}, ...ADAPTERS.map(adapter => adapter.configuration?.routingSchema?.(nativeValidators) ?? {})),
|
|
71
77
|
targets: (v, k) => switchyard(v, k, "id"), routes: (v, k) => switchyard(v, k, "type"),
|
|
72
78
|
// parseRouting delegates these tables to the existing parseHubRoutes validator.
|
|
73
79
|
hub_routes: (v, k) => { table(v, k); },
|
|
74
80
|
stay_switch: (v, k) => choice(v, k, ["off", "shadow", "enforce"]), max_switch_prefill_tokens: numeric,
|
|
75
81
|
signals: (v, k) => closed(v, k, { pii_patterns: strings, long_context_tokens: numeric, pii_screen: (x, p) => choice(x, p, ["off", "local"]) }),
|
|
76
82
|
constraints: (v, k) => closed(v, k, { pii: (x, p) => choice(x, p, ["local_only", "off"]), long_context: (x, p) => choice(x, p, ["skip_local", "off"]), budget_paused: (x, p) => choice(x, p, ["skip_peer", "off"]) }),
|
|
77
|
-
pi: (v, k) => closed(v, k, { dgx_max_context_tokens: numeric, mlx_max_context_tokens: numeric, efficient_wait_ms: (x, p) => numeric(x, p, 0, 119999) }),
|
|
78
83
|
classes: (v, k) => { for (const [name, item] of Object.entries(table(v, k))) {
|
|
79
84
|
if (!(CLASSES as readonly string[]).includes(name)) bad(`${k}.${name}`);
|
|
80
85
|
closed(item, `${k}.${name}`, { peers, escalate_to: peers, route: text, fixed_model: text, local_allowed: bool, pi_backend: (x, p) => choice(x, p, ["dgx", "mlx"]) });
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { nonnegative, positive } from "../adapters/definitions/native-context.ts";
|
|
2
|
+
export { claudeContext, codexContext, acpContext } from "../adapters/definitions/native-context.ts";
|
|
3
|
+
|
|
1
4
|
/** Native active-context occupancy, separate from accumulated billable usage (#185). */
|
|
2
5
|
export interface ContextReading {
|
|
3
6
|
source: "claude_statusline" | "codex_token_usage" | "acp_usage_update";
|
|
@@ -17,33 +20,6 @@ export interface ContextView {
|
|
|
17
20
|
}
|
|
18
21
|
export interface ContextConfig { gate: number; stale_min: number }
|
|
19
22
|
export const DEFAULT_CONTEXT: ContextConfig = { gate: 0, stale_min: 30 };
|
|
20
|
-
const nonnegative = (n: unknown): n is number => typeof n === "number" && Number.isFinite(n) && n >= 0;
|
|
21
|
-
const positive = (n: unknown): n is number => nonnegative(n) && n > 0;
|
|
22
|
-
const record = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
23
|
-
const object = (v: unknown): Record<string, unknown> => record(v) ? v : {};
|
|
24
|
-
export function claudeContext(value: unknown, sessionId: string, measuredAt: number): ContextReading {
|
|
25
|
-
const c = object(value);
|
|
26
|
-
const current = object(c.current_usage);
|
|
27
|
-
const parts = [current.input_tokens, current.cache_creation_input_tokens, current.cache_read_input_tokens];
|
|
28
|
-
const window = positive(c.context_window_size) ? c.context_window_size : null;
|
|
29
|
-
const sum = c.current_usage != null && parts.every(nonnegative) ? parts.reduce<number>((sum, n) => sum + (n as number), 0) : null;
|
|
30
|
-
const tokens = nonnegative(sum) ? sum : null;
|
|
31
|
-
const used = record(c.current_usage) && tokens !== null && nonnegative(c.used_percentage) && c.used_percentage <= 100 && window !== null ? c.used_percentage / 100 : null;
|
|
32
|
-
return { source: "claude_statusline", sessionId, measuredAt, tokens, window, used };
|
|
33
|
-
}
|
|
34
|
-
export function codexContext(value: unknown, sessionId: string, measuredAt: number): ContextReading {
|
|
35
|
-
const c = object(value), last = object(c.last);
|
|
36
|
-
const window = positive(c.modelContextWindow) ? c.modelContextWindow : null;
|
|
37
|
-
const tokens = nonnegative(last.totalTokens) ? last.totalTokens : null;
|
|
38
|
-
return { source: "codex_token_usage", sessionId, measuredAt, tokens, window, used: tokens !== null && window !== null ? Math.min(1, tokens / window) : null };
|
|
39
|
-
}
|
|
40
|
-
/** An ACP `usage_update` of the `{ used, size }` shape (Kimi 2.x): context occupancy, not consumption (#167, #285). */
|
|
41
|
-
export function acpContext(usage: { used: number; size: number }, sessionId: string, measuredAt: number): ContextReading {
|
|
42
|
-
const window = positive(usage.size) ? usage.size : null;
|
|
43
|
-
const tokens = nonnegative(usage.used) ? usage.used : null;
|
|
44
|
-
// If a report ever shows used > size, the fraction clamps so a bar renders; tokens keeps the reported number.
|
|
45
|
-
return { source: "acp_usage_update", sessionId, measuredAt, tokens, window, used: tokens !== null && window !== null ? Math.min(1, tokens / window) : null };
|
|
46
|
-
}
|
|
47
23
|
export const unknownContext = (): ContextView => ({ source: null, measuredAt: null, tokens: null, window: null, used: null, freshness: "unknown" });
|
|
48
24
|
/** Invalid/stale readings hide the value but never rearm a threshold already crossed. */
|
|
49
25
|
export class ContextWindows {
|
|
@@ -8,7 +8,7 @@ export function stateDirFor(cwd: string): string {
|
|
|
8
8
|
|
|
9
9
|
/** Control WS wire version. 2 = `deliver` carries `envs` (digests); 3 = `tools` role and task messages; 4 = budget messages and `hub_checkpoint`; 5 = `ask`; 6 = console-only `ui` session bootstrap; 8 = controlled recovery; 9 = Pi bridge metadata; 10 = durable delivery receipts; 11 = queue hold diagnostics; 12 = generation-bound channel settlement and execution budgets; 13 = turn-free facts (`facts` with tool, session and transcript binding, `silenced`) and per-recipient send results (issue #108). 14 = native context-window status and session-bound context checkpoint request ids (issue #185). 15 = conductor tools and permission deadlines/closed events (issues #190, #194). 16 = a Claude session without channel evidence: hello `channel: false` and `inbox` (issue #205). The plugin is installed apart from the daemon, so they can drift. */
|
|
10
10
|
export const PROTOCOL = 16;
|
|
11
|
-
export const DAEMON_FEATURES = ["task_cancel"] as const;
|
|
11
|
+
export const DAEMON_FEATURES = ["task_cancel", "recovery_deferred_hub"] as const;
|
|
12
12
|
/** Protocols a current coordinator may authenticate while upgrading a running source. */
|
|
13
13
|
export const RECOVERY_SOURCE_PROTOCOLS = [9, 10, 11, 12, 13, 14, 15, PROTOCOL] as const;
|
|
14
14
|
|