@staix/agent-hub 0.12.34 → 0.12.35
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 +13 -0
- package/README.md +3 -3
- package/docs/agent-notes/adapters.md +2 -0
- package/docs/agent-notes/budget.md +3 -1
- package/docs/agent-notes/bus.md +2 -1
- package/docs/agent-notes/daemon.md +5 -1
- package/docs/agent-notes/local-worker.md +1 -1
- package/docs/agent-notes/tasks.md +4 -1
- package/docs/gsd-collaboration-analysis.md +5 -1
- package/docs/operations.md +67 -13
- package/docs/quickstart.md +8 -2
- package/docs/sdlc.md +59 -34
- package/docs/security.md +3 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +161 -6
- package/docs/verification/2026-10-18-0.12.35.md +49 -0
- package/docs/verification/2026-10-18-493-claude-busy-channel.md +91 -0
- package/docs/verified.json +21 -16
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +15438 -14863
- package/src/adapters/acp.ts +3 -1
- package/src/adapters/claude-channel.ts +59 -29
- package/src/adapters/codex-appserver.ts +1 -1
- package/src/adapters/definitions/native-cli-entry-codex.ts +13 -5
- package/src/adapters/definitions/native-cli-entry-jcode.ts +4 -1
- package/src/adapters/definitions/native-cli-entry-kimi.ts +16 -11
- package/src/adapters/definitions/native-cli-entry-local.ts +3 -2
- package/src/adapters/definitions/native-cli-entry-pi.ts +8 -6
- package/src/adapters/definitions/native-cli-entry-qwen.ts +3 -2
- package/src/adapters/definitions/native-cli-entry-types.ts +1 -0
- package/src/adapters/definitions/native-recovery.ts +2 -2
- package/src/adapters/definitions/qwen-native.ts +2 -2
- package/src/adapters/local-worker.ts +3 -2
- package/src/adapters/pi.ts +3 -1
- package/src/adapters/registry-types.ts +3 -0
- package/src/adapters/registry.ts +24 -8
- package/src/cli/identity.ts +3 -3
- package/src/cli/launch.ts +29 -4
- package/src/cli/main.ts +101 -28
- package/src/cli/preview.ts +16 -5
- package/src/cli/recovery-guidance.ts +39 -22
- package/src/cli/terminal-recovery.ts +68 -16
- package/src/cli/upgrade-restore-state.ts +16 -7
- package/src/cli/upgrade-runtime.ts +67 -39
- package/src/cli/upgrade.ts +43 -27
- package/src/hub/budget.ts +55 -3
- package/src/hub/bus.ts +1 -1
- package/src/hub/conductor.ts +16 -10
- package/src/hub/config-apply.ts +13 -2
- package/src/hub/config-reload.ts +11 -1
- package/src/hub/config-trust.ts +1 -1
- package/src/hub/control-client.ts +1 -1
- package/src/hub/crash.ts +14 -5
- package/src/hub/daemon.ts +296 -125
- package/src/hub/execution-budget.ts +3 -3
- package/src/hub/hub-tools.ts +17 -6
- package/src/hub/instance-budget.ts +73 -0
- package/src/hub/instance-config.ts +47 -0
- package/src/hub/instance-execution.ts +50 -0
- package/src/hub/instances.ts +100 -0
- package/src/hub/lifecycle-schema.ts +7 -3
- package/src/hub/permission-mode.ts +7 -7
- package/src/hub/roster.ts +7 -6
- package/src/hub/routing.ts +82 -18
- package/src/hub/sdlc-queue.ts +2 -2
- package/src/hub/sdlc.ts +84 -38
- package/src/hub/settings.ts +34 -6
- package/src/hub/snapshots.ts +40 -9
- package/src/hub/start-mode.ts +7 -4
- package/src/hub/status-roster.ts +8 -0
- package/src/hub/tasks.ts +53 -23
- package/src/hub/text-projection.ts +7 -3
- package/src/hub/workflow.ts +30 -52
- package/src/pi/launch.ts +4 -3
- package/src/ui/index.html +1 -1
- package/templates/AGENTS.block.md +2 -1
package/src/cli/upgrade.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { definitionOf } from "../adapters/registry.ts";
|
|
2
|
+
import { readInstanceConfig } from "../hub/instance-config.ts";
|
|
2
3
|
import { LIVE_TEST_PERSON_REFUSAL } from "./identity.ts";
|
|
3
4
|
import { checkedSessionPath } from "./recovery-session-file.ts";
|
|
4
5
|
import { canChooseFresh, decodeRestoreReceipt, restoreReceiptStatus } from "./upgrade-restore-state.ts";
|
|
@@ -14,6 +15,13 @@ import { resolveAcceptedBinding, piArchiveExit, recordedPeerGuidance, type Recov
|
|
|
14
15
|
import type { TerminalBinding } from "./terminal-recovery.ts";
|
|
15
16
|
import { RecoveryPresentationError, type RecoveryPresentationCause, type RecoveryWaitTiming } from "./upgrade-presentation-data.ts";
|
|
16
17
|
|
|
18
|
+
const projectDefinition = (peer: string, root?: string) => {
|
|
19
|
+
const builtin = definitionOf(peer);
|
|
20
|
+
if (builtin || !root) return builtin;
|
|
21
|
+
try { return definitionOf(peer, readInstanceConfig(root).instances); } catch { return undefined; }
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
|
|
17
25
|
export interface RecoveryPeer {
|
|
18
26
|
id: string;
|
|
19
27
|
state: string;
|
|
@@ -288,7 +296,8 @@ export function nextChoices(receipt: RecoveryOperation, runner?: number | "unkno
|
|
|
288
296
|
const stuck = resumeBlocked(op, live);
|
|
289
297
|
const selected = op.phase === "blocked" ? op.blockedReattachment : undefined;
|
|
290
298
|
const repair = selected && op.projects.find(project => project.id === selected.projectId)?.reattachments?.[selected.peer];
|
|
291
|
-
const reattach = selected && repair && (
|
|
299
|
+
const reattach = selected && repair && projectDefinition(selected.peer, op.plan.projects.find(project => project.project.id === selected.projectId)?.project.root)?.session.launchRecord === true &&
|
|
300
|
+
(repair.state === "ready" || repairWaiting(op, selected.projectId, selected.peer));
|
|
292
301
|
return [
|
|
293
302
|
...(stuck ? [] : [{ kind: "resume" as const, text: `${recoveryCommand(op, "resume")}${op.error ? " (after the next action in error)" : ""}${old ? ` (runs the coordinator that started this operation, which cannot re-prepare an expired hold: if it reports "source is no longer prepared", use ${abortable ? "abort or " : ""}stop-and-archive)` : ""}` }]),
|
|
294
303
|
...(abortable ? [{ kind: "abort" as const, text: recoveryCommand(op, "abort") }] : []),
|
|
@@ -323,12 +332,12 @@ export async function liveProjects(op: RecoveryOperation, inspect: (project: Pro
|
|
|
323
332
|
return Object.fromEntries(await Promise.all(open.map(async (p) => [p.project.id, await inspect(p.project).catch(() => undefined)] as const)));
|
|
324
333
|
}
|
|
325
334
|
|
|
326
|
-
function missingRepair(op: RecoveryOperation, progress: ProjectProgress, peer: string): boolean {
|
|
335
|
+
function missingRepair(op: RecoveryOperation, progress: ProjectProgress, peer: string, options: { authority?: "current" | "historical" } = {}): boolean {
|
|
327
336
|
const evidence = op.missingRecordedSession;
|
|
328
337
|
if (!evidence || evidence.projectId !== progress.id || evidence.peer !== peer || (!progress.repairedSessions?.[evidence.peer] && !Object.hasOwn(progress.reattachments ?? {}, peer))) return false;
|
|
329
338
|
const planned = op.plan.projects.find(project => project.project.id === progress.id);
|
|
330
339
|
if (!planned) return false;
|
|
331
|
-
try { return resolveAcceptedBinding(planned, progress, evidence.peer)?.sessionId === evidence.sessionId; } catch { return false; }
|
|
340
|
+
try { return resolveAcceptedBinding(planned, progress, evidence.peer, options)?.sessionId === evidence.sessionId; } catch { return false; }
|
|
332
341
|
}
|
|
333
342
|
/**
|
|
334
343
|
* Peers of a project whose restoration failed and for which a fresh session is a real choice: not Pi (a restored hub
|
|
@@ -337,8 +346,8 @@ function missingRepair(op: RecoveryOperation, progress: ProjectProgress, peer: s
|
|
|
337
346
|
*/
|
|
338
347
|
function failedPeers(op: RecoveryOperation, progress: ProjectProgress, live: Record<string, Inspection | undefined> = {}): string[] {
|
|
339
348
|
const planned = op.plan.projects.find((p) => p.project.id === progress.id);
|
|
340
|
-
return Object.entries(progress.terminals).filter(([key, value]) => key.startsWith("restored:") && canChooseFresh(value)).map(([key]) => key.slice("restored:".length))
|
|
341
|
-
.filter((peer) =>
|
|
349
|
+
return Object.entries(progress.terminals).filter(([key, value]) => key.startsWith("restored:") && (() => { try { return canChooseFresh(value); } catch { return false; } })()).map(([key]) => key.slice("restored:".length))
|
|
350
|
+
.filter((peer) => projectDefinition(peer, planned?.project.root)?.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));
|
|
342
351
|
}
|
|
343
352
|
|
|
344
353
|
/** Read-only scope classification; it confers no delegation authority. */
|
|
@@ -364,7 +373,7 @@ function plannedAttached(op: RecoveryOperation, progress: ProjectProgress, peer:
|
|
|
364
373
|
? resolveAcceptedBinding(planned, progress, peer as RecoveryNativePeer)
|
|
365
374
|
: (planned?.terminals as { peer: string; sessionId: string }[] | undefined)?.find((t) => t.peer === peer);
|
|
366
375
|
const current = state?.state === "running" ? state.peers.find((p) => p.id === peer && p.state !== "offline") : undefined;
|
|
367
|
-
return !!binding && current?.[
|
|
376
|
+
return !!binding && current?.[projectDefinition(peer, planned?.project.root)?.session.idField ?? "sessionId"] === binding.sessionId;
|
|
368
377
|
}
|
|
369
378
|
|
|
370
379
|
/** Registry reads for planning must not create a registry or run migrations. */
|
|
@@ -414,7 +423,10 @@ function abandoned(op: RecoveryOperation): boolean {
|
|
|
414
423
|
return true;
|
|
415
424
|
}
|
|
416
425
|
|
|
417
|
-
function validateReceipt(id: string, op: RecoveryOperation): void {
|
|
426
|
+
function validateReceipt(id: string, op: RecoveryOperation, historical = false): void {
|
|
427
|
+
// Cleanup validates owned historical facts; this mode never authorizes restore, fresh or reattach.
|
|
428
|
+
const options = historical ? { authority: "historical" as const } : {};
|
|
429
|
+
const decode = (value: unknown) => decodeRestoreReceipt(value, options);
|
|
418
430
|
if (!(op.schema === 1 || (op.schema === 2 && op.disposition)) || op.id !== id) throw new Error("unsupported operation receipt");
|
|
419
431
|
if (op.targetFeatures !== undefined && (!Array.isArray(op.targetFeatures) || !op.targetFeatures.every(feature => typeof feature === "string" && feature.length > 0) || !op.targetRoot || !op.targetDigest)) throw new Error("invalid target features");
|
|
420
432
|
const { fingerprint, ...reviewed } = op.plan;
|
|
@@ -425,13 +437,13 @@ function validateReceipt(id: string, op: RecoveryOperation): void {
|
|
|
425
437
|
if (progress.repairedSessions !== undefined) {
|
|
426
438
|
if (!object(progress.repairedSessions)) throw new Error("invalid repaired session provenance");
|
|
427
439
|
for (const [peer, protectedSession] of Object.entries(progress.repairedSessions)) {
|
|
428
|
-
const restored =
|
|
440
|
+
const restored = decode(progress.terminals[`restored:${peer}`]);
|
|
429
441
|
const bindings = [progress.reattachments?.[peer as RecoveryNativePeer]?.binding,
|
|
430
442
|
restored?.state === "done" || restored?.state === "waiting" ? restored.binding : undefined,
|
|
431
443
|
progress.terminals[`retired:${peer}`]];
|
|
432
444
|
if (protectedSession !== true || !bindings.some(binding => {
|
|
433
445
|
if (binding === undefined) return false;
|
|
434
|
-
const receipt =
|
|
446
|
+
const receipt = decode({ state: "done", binding });
|
|
435
447
|
return receipt?.state === "done" && receipt.binding.peer === peer && receipt.binding.projectRoot === op.plan.projects[index]?.project.root;
|
|
436
448
|
})) throw new Error("repaired session provenance does not match its retained peer and project");
|
|
437
449
|
}
|
|
@@ -440,14 +452,14 @@ function validateReceipt(id: string, op: RecoveryOperation): void {
|
|
|
440
452
|
if (choice.repaired === undefined) continue;
|
|
441
453
|
const planned = op.plan.projects[index]!;
|
|
442
454
|
if (choice.repaired !== true || !progress.repairedSessions?.[peer as RecoveryNativePeer] ||
|
|
443
|
-
resolveAcceptedBinding(planned, progress, peer as RecoveryNativePeer)?.sessionId !== choice.lost) throw new Error("invalid one-use repaired-session choice");
|
|
455
|
+
resolveAcceptedBinding(planned, progress, peer as RecoveryNativePeer, options)?.sessionId !== choice.lost) throw new Error("invalid one-use repaired-session choice");
|
|
444
456
|
}
|
|
445
457
|
if (progress.reattachments === undefined) continue;
|
|
446
458
|
if (!object(progress.reattachments)) throw new Error("invalid reattachment markers");
|
|
447
459
|
for (const [peer, marker] of Object.entries(progress.reattachments)) {
|
|
448
|
-
if (
|
|
460
|
+
if ((!historical && projectDefinition(peer, op.plan.projects[index]?.project.root)?.session.launchRecord !== true) || !object(marker) || Object.keys(marker).some(key => !["state", "at", "cause", "binding"].includes(key)) ||
|
|
449
461
|
!["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");
|
|
450
|
-
const receipt =
|
|
462
|
+
const receipt = decode({ state: "done", binding: marker.binding });
|
|
451
463
|
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");
|
|
452
464
|
}
|
|
453
465
|
}
|
|
@@ -456,7 +468,7 @@ function validateReceipt(id: string, op: RecoveryOperation): void {
|
|
|
456
468
|
if (!object(evidence) || Object.keys(evidence).some(key => !["projectId", "peer", "sessionId", "kind", "checkedPath"].includes(key)) ||
|
|
457
469
|
typeof evidence.projectId !== "string" || typeof evidence.peer !== "string" || typeof evidence.sessionId !== "string" ||
|
|
458
470
|
evidence.checkedPath !== undefined && (typeof evidence.checkedPath !== "string" || !evidence.checkedPath) ||
|
|
459
|
-
!["rollout", "transcript"].includes(evidence.kind) || !op.projects.some(progress => missingRepair(op, progress, evidence.peer))) throw new Error("invalid recorded-session missing evidence");
|
|
471
|
+
!["rollout", "transcript"].includes(evidence.kind) || !op.projects.some(progress => missingRepair(op, progress, evidence.peer, options))) throw new Error("invalid recorded-session missing evidence");
|
|
460
472
|
}
|
|
461
473
|
if (op.freshOverride !== undefined) {
|
|
462
474
|
const override = op.freshOverride, evidence = override.evidence;
|
|
@@ -467,14 +479,14 @@ function validateReceipt(id: string, op: RecoveryOperation): void {
|
|
|
467
479
|
}
|
|
468
480
|
if (op.blockedReattachment !== undefined) {
|
|
469
481
|
const selected = op.blockedReattachment;
|
|
470
|
-
if (!object(selected) || Object.keys(selected).some(key => !["projectId", "peer"].includes(key)) || typeof selected.projectId !== "string" ||
|
|
482
|
+
if (!object(selected) || Object.keys(selected).some(key => !["projectId", "peer"].includes(key)) || typeof selected.projectId !== "string" || typeof selected.peer !== "string" || (!historical && projectDefinition(selected.peer, op.plan.projects.find(project => project.project.id === selected.projectId)?.project.root)?.session.launchRecord !== true) ||
|
|
471
483
|
!op.projects.some(project => project.id === selected.projectId && Object.hasOwn(project.reattachments ?? {}, selected.peer))) throw new Error("invalid blocked reattachment selector");
|
|
472
484
|
}
|
|
473
485
|
}
|
|
474
486
|
|
|
475
487
|
export interface RecoveryDriver {
|
|
476
488
|
stage(op: RecoveryOperation): Promise<{ root: string; digest: string; targetFeatures?: readonly string[] }>;
|
|
477
|
-
inspect(project: Project, targetFeatures?: readonly string[]): Promise<Inspection>;
|
|
489
|
+
inspect(project: Project, targetFeatures?: readonly string[], purpose?: "cleanup"): Promise<Inspection>;
|
|
478
490
|
prepare(project: Project, operation: string, instance: string, targetFeatures?: readonly string[]): Promise<void>;
|
|
479
491
|
abort(project: Project, operation: string, instance: string): Promise<void>;
|
|
480
492
|
commit(project: Project, operation: string, instance: string, targetFeatures?: readonly string[]): Promise<void>;
|
|
@@ -766,7 +778,11 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
|
|
|
766
778
|
|
|
767
779
|
/** Receipts and runner state only (#215): ids, phases and effects, never task or message text. */
|
|
768
780
|
export function publicOperation(op: RecoveryOperation, runnerPid?: number | "unknown", live: Record<string, Inspection | undefined> = {}) {
|
|
769
|
-
const effect = (key: string, value: unknown) =>
|
|
781
|
+
const effect = (key: string, value: unknown) => {
|
|
782
|
+
if (!key.startsWith("restored:")) return value === "pending" || value === "failed" ? value : "done";
|
|
783
|
+
const receipt = decodeRestoreReceipt(value, { authority: "historical" });
|
|
784
|
+
return !receipt ? "unstarted" : receipt.state === "waiting" ? `waiting:${receipt.origin}` : receipt.state;
|
|
785
|
+
};
|
|
770
786
|
const choices = nextChoices(op, runnerPid, live), next = choices.map(choice => choice.text);
|
|
771
787
|
return { id: op.id, phase: op.phase, step: op.step, version: op.plan.version, updatedAt: op.updatedAt,
|
|
772
788
|
runner: runnerPid === "unknown" ? { state: "unknown" } : runnerPid ? { state: "running", pid: runnerPid } : { state: "none" },
|
|
@@ -795,7 +811,7 @@ export async function reattachRecovery(id: string, peer: string, driver: Recover
|
|
|
795
811
|
let op = readOperation<RecoveryOperation>(id, home);
|
|
796
812
|
validateReceipt(id, op);
|
|
797
813
|
const selection = () => {
|
|
798
|
-
if (
|
|
814
|
+
if (projectDefinition(peer, op.plan.projects.find(project => project.project.id === op.blockedReattachment?.projectId)?.project.root)?.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");
|
|
799
815
|
const progress = op.projects.find(project => project.id === op.blockedReattachment!.projectId);
|
|
800
816
|
const planned = op.plan.projects.find(project => project.project.id === progress?.id);
|
|
801
817
|
const native = peer as RecoveryNativePeer;
|
|
@@ -820,7 +836,7 @@ export async function reattachRecovery(id: string, peer: string, driver: Recover
|
|
|
820
836
|
};
|
|
821
837
|
await target();
|
|
822
838
|
attempted = true;
|
|
823
|
-
await driver.restore(planned, progress, op,
|
|
839
|
+
await driver.restore(planned, progress, op, projectDefinition(native, planned.project.root)?.transport === "claude-channel" ? "claude" : "native", save, native);
|
|
824
840
|
// A done receipt is written by the runtime only after authenticated attachment and exact settlement.
|
|
825
841
|
// Inspection peers may be unavailable; do not invent an attachment from a test or a stale status projection.
|
|
826
842
|
const restored = decodeRestoreReceipt(progress.terminals[`restored:${native}`]);
|
|
@@ -853,14 +869,14 @@ export async function reattachRecovery(id: string, peer: string, driver: Recover
|
|
|
853
869
|
/** Escape a blocked preflight without abandoning a stopped runtime or an uncertain terminal mutation. */
|
|
854
870
|
export async function abortRecovery(id: string, driver: RecoveryDriver, home = hubHome()): Promise<void> {
|
|
855
871
|
let op = readOperation<RecoveryOperation>(id, home);
|
|
856
|
-
validateReceipt(id, op);
|
|
872
|
+
validateReceipt(id, op, true);
|
|
857
873
|
acquireRecoveryLock(id, home);
|
|
858
874
|
const releaseRunner = claimRunner(id, home);
|
|
859
875
|
try {
|
|
860
876
|
// Re-read after the exclusive runner claim so cancellation cannot act on a
|
|
861
877
|
// stale pending/prepared receipt after another runner advanced it.
|
|
862
878
|
op = readOperation<RecoveryOperation>(id, home);
|
|
863
|
-
validateReceipt(id, op);
|
|
879
|
+
validateReceipt(id, op, true);
|
|
864
880
|
if (op.phase === "completed" || op.phase === "cancelled") {
|
|
865
881
|
if (recoveryLock(home) === id) releaseRecoveryLock(id, home);
|
|
866
882
|
return;
|
|
@@ -868,7 +884,7 @@ export async function abortRecovery(id: string, driver: RecoveryDriver, home = h
|
|
|
868
884
|
if (abandoned(op)) writeOperation(id, op, home);
|
|
869
885
|
const { fingerprint, ...body } = op.plan;
|
|
870
886
|
if (fingerprint !== planFingerprint(body)) throw new Error("operation plan changed");
|
|
871
|
-
const live = await liveProjects(op, driver.inspect);
|
|
887
|
+
const live = await liveProjects(op, project => driver.inspect(project, undefined, "cleanup"));
|
|
872
888
|
const refusal = abortRefusal(op, live);
|
|
873
889
|
if (refusal) throw new Error(`abort refused: ${refusal}; ${nextActionsText(op, live)}`);
|
|
874
890
|
// Only this operation's own uncommitted holds are aborted; a lapsed hold, a replacement daemon or another
|
|
@@ -893,7 +909,7 @@ export async function abortRecovery(id: string, driver: RecoveryDriver, home = h
|
|
|
893
909
|
*/
|
|
894
910
|
export async function disposeRecovery(id: string, choice: { fresh: string } | { stop: true }, reason: string, driver: RecoveryDriver, home = hubHome()): Promise<RecoveryOperation> {
|
|
895
911
|
let op: RecoveryOperation;
|
|
896
|
-
try { op = readOperation<RecoveryOperation>(id, home); validateReceipt(id, op); }
|
|
912
|
+
try { op = readOperation<RecoveryOperation>(id, home); validateReceipt(id, op, "stop" in choice); }
|
|
897
913
|
catch (error) { if (driver.liveTestPeer && "fresh" in choice) throw new Error(LIVE_TEST_PERSON_REFUSAL); throw error; }
|
|
898
914
|
if (driver.liveTestPeer && "fresh" in choice && repairedFreshSession(op, choice.fresh) !== false) throw new Error(LIVE_TEST_PERSON_REFUSAL);
|
|
899
915
|
// A finished operation owns no lock: never take one only to refuse.
|
|
@@ -901,7 +917,7 @@ export async function disposeRecovery(id: string, choice: { fresh: string } | {
|
|
|
901
917
|
acquireRecoveryLock(id, home);
|
|
902
918
|
const releaseRunner = claimRunner(id, home);
|
|
903
919
|
try {
|
|
904
|
-
try { op = readOperation<RecoveryOperation>(id, home); validateReceipt(id, op); }
|
|
920
|
+
try { op = readOperation<RecoveryOperation>(id, home); validateReceipt(id, op, "stop" in choice); }
|
|
905
921
|
catch (error) { if (driver.liveTestPeer && "fresh" in choice) throw new Error(LIVE_TEST_PERSON_REFUSAL); throw error; }
|
|
906
922
|
if (driver.liveTestPeer && "fresh" in choice && repairedFreshSession(op, choice.fresh) !== false) throw new Error(LIVE_TEST_PERSON_REFUSAL);
|
|
907
923
|
if (op.phase === "completed" || op.phase === "cancelled") {
|
|
@@ -912,11 +928,11 @@ export async function disposeRecovery(id: string, choice: { fresh: string } | {
|
|
|
912
928
|
if (abandoned(op)) writeOperation(id, op, home);
|
|
913
929
|
const at = driver.now();
|
|
914
930
|
// Read once, as status and the runner do: every choice and refusal below is decided on it.
|
|
915
|
-
const live = await liveProjects(op, driver.inspect);
|
|
931
|
+
const live = await liveProjects(op, project => driver.inspect(project, undefined, "stop" in choice ? "cleanup" : undefined));
|
|
916
932
|
const next = nextActionsText(op, live);
|
|
917
933
|
if ("fresh" in choice) {
|
|
918
934
|
// `ahub pi` sends no fresh flag, and a restored hub refills a Pi start from its recorded resume (daemon startPeer).
|
|
919
|
-
if (
|
|
935
|
+
if (op.plan.projects.some(planned => projectDefinition(choice.fresh, planned.project.root)?.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.filter(planned => projectDefinition(choice.fresh, planned.project.root)?.session.personFresh === false).map(planned => piArchiveExit(planned, recoveryCommand(op, "dispose", STOP), choice.fresh)).join("; ")}; ${next}`);
|
|
920
936
|
if (op.disposition) throw new Error(`a stop-and-archive of this operation is partway; ${next}`);
|
|
921
937
|
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}`);
|
|
922
938
|
// The new session is launched by resume, so it helps only where resume can get past what is live.
|
|
@@ -997,7 +1013,7 @@ export async function disposeRecovery(id: string, choice: { fresh: string } | {
|
|
|
997
1013
|
else if (act === "stopped" || act === "target stopped") {
|
|
998
1014
|
const outcome = await driver.stopAndArchive(planned.project, op, act === "target stopped" ? live!.instanceId : undefined);
|
|
999
1015
|
if (outcome && outcome.continuity) (disposition.continuity ??= {})[planned.project.id] = outcome.continuity;
|
|
1000
|
-
if ((await driver.inspect(planned.project)).state !== "stopped") throw new Error(`${planned.project.id}: shutdown of instance ${live!.instanceId ?? "unknown"} is not verified`);
|
|
1016
|
+
if ((await driver.inspect(planned.project, undefined, "cleanup")).state !== "stopped") throw new Error(`${planned.project.id}: shutdown of instance ${live!.instanceId ?? "unknown"} is not verified`);
|
|
1001
1017
|
}
|
|
1002
1018
|
disposition.projects[planned.project.id] ??= act;
|
|
1003
1019
|
op.updatedAt = driver.now(); writeOperation(id, op, home);
|
|
@@ -1005,7 +1021,7 @@ export async function disposeRecovery(id: string, choice: { fresh: string } | {
|
|
|
1005
1021
|
} catch (error) {
|
|
1006
1022
|
if (isDatabaseBusy(error)) { op.failure = { code: "database_busy", retryable: true, site: databaseBusySite(error) }; error = new RecoveryDatabaseBusy(); }
|
|
1007
1023
|
op.phase = "blocked"; op.updatedAt = driver.now();
|
|
1008
|
-
op.error = `stop-and-archive stopped partway and keeps the lock: ${error instanceof Error ? error.message : "disposition failed"} (a committed source stops by itself); ${nextActionsText(op, await liveProjects(op, driver.inspect))}`;
|
|
1024
|
+
op.error = `stop-and-archive stopped partway and keeps the lock: ${error instanceof Error ? error.message : "disposition failed"} (a committed source stops by itself); ${nextActionsText(op, await liveProjects(op, project => driver.inspect(project, undefined, "cleanup")))}`;
|
|
1009
1025
|
writeOperation(id, op, home);
|
|
1010
1026
|
throw error;
|
|
1011
1027
|
}
|
package/src/hub/budget.ts
CHANGED
|
@@ -46,6 +46,8 @@ export interface BudgetConfig {
|
|
|
46
46
|
export const DEFAULT_BUDGET: BudgetConfig = { gate: 0.9, stale_min: 30, poll_min: 10, checkpoint_timeout_s: 90, kimi_tokens_5h: 0, wait_max_min: 0 };
|
|
47
47
|
|
|
48
48
|
export interface BudgetHooks {
|
|
49
|
+
/** Stable/idempotent for this Budget lifetime. All lifecycle hooks receive this canonical account id. */
|
|
50
|
+
account?(peer: PeerId): PeerId;
|
|
49
51
|
pause(peer: PeerId): void;
|
|
50
52
|
/** Must leave a manual `ahub pause` in place. */
|
|
51
53
|
resume(peer: PeerId): void;
|
|
@@ -101,6 +103,43 @@ export class Budget {
|
|
|
101
103
|
this.db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
|
|
102
104
|
this.db.run(`CREATE TABLE IF NOT EXISTS budget_pauses (peer TEXT PRIMARY KEY, since INTEGER NOT NULL, resets_at INTEGER NOT NULL,
|
|
103
105
|
reason TEXT NOT NULL, summary TEXT, handed_off INTEGER NOT NULL DEFAULT 0, moved TEXT NOT NULL DEFAULT '[]')`);
|
|
106
|
+
try { this.normalizeStoredPauses(); } catch (error) { this.db.close(); throw error; }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private account(peer: PeerId): PeerId {
|
|
110
|
+
if (!this.hooks.account) return peer;
|
|
111
|
+
const account = this.hooks.account(peer);
|
|
112
|
+
if (typeof account !== "string" || !account.trim() || this.hooks.account(account) !== account) throw new Error("budget account resolver must return a canonical idempotent id");
|
|
113
|
+
return account;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** A newly declared alias must not erase an older custom peer's persisted pause. */
|
|
117
|
+
private normalizeStoredPauses(): void {
|
|
118
|
+
if (!this.hooks.account) return; // identity/default startup leaves existing storage byte-for-byte alone
|
|
119
|
+
this.db.transaction(() => {
|
|
120
|
+
const rows = (this.db.query("SELECT peer FROM budget_pauses ORDER BY peer").all() as { peer: string }[]).map(row => this.readRecord(row.peer)!);
|
|
121
|
+
const groups = new Map<PeerId, PauseRecord[]>();
|
|
122
|
+
for (const row of rows) {
|
|
123
|
+
const account = this.account(row.peer), group = groups.get(account) ?? [];
|
|
124
|
+
group.push(row); groups.set(account, group);
|
|
125
|
+
}
|
|
126
|
+
for (const [peer, group] of groups) {
|
|
127
|
+
if (group.length === 1 && group[0]!.peer === peer) continue;
|
|
128
|
+
// Conservative collision reduction: retain the longest pause, earliest start and every distinct move.
|
|
129
|
+
// Any unfinished handoff stays unfinished so the daemon's account fanout retries remaining open work.
|
|
130
|
+
const summaries = group.filter(row => row.summary !== null).map(row => `[${row.peer}]\n${row.summary}`);
|
|
131
|
+
const moved = [...new Map(group.flatMap(row => row.moved).map(move => [JSON.stringify(move), move])).values()];
|
|
132
|
+
const first = group[0]!;
|
|
133
|
+
const record = group.length === 1 ? { ...first, peer } : {
|
|
134
|
+
peer, since: Math.min(...group.map(row => row.since)), resetsAt: Math.max(...group.map(row => row.resetsAt)),
|
|
135
|
+
reason: [...new Set(group.map(row => row.reason))].join(" | "), summary: summaries.length ? summaries.join("\n\n") : null,
|
|
136
|
+
handedOff: group.every(row => row.handedOff), moved,
|
|
137
|
+
};
|
|
138
|
+
for (const row of group) this.db.query("DELETE FROM budget_pauses WHERE peer = ?").run(row.peer);
|
|
139
|
+
this.db.query("INSERT INTO budget_pauses (peer, since, resets_at, reason, summary, handed_off, moved) VALUES (?, ?, ?, ?, ?, ?, ?)")
|
|
140
|
+
.run(record.peer, record.since, record.resetsAt, record.reason, record.summary, record.handedOff ? 1 : 0, JSON.stringify(record.moved));
|
|
141
|
+
}
|
|
142
|
+
}).immediate();
|
|
104
143
|
}
|
|
105
144
|
|
|
106
145
|
/** Freeze pause/resume and handoff transitions while a controlled restart is fenced. Readings continue to update. */
|
|
@@ -121,6 +160,10 @@ export class Budget {
|
|
|
121
160
|
}
|
|
122
161
|
|
|
123
162
|
record(peer: PeerId): PauseRecord | undefined {
|
|
163
|
+
return this.readRecord(this.account(peer));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private readRecord(peer: PeerId): PauseRecord | undefined {
|
|
124
167
|
const r = this.db.query("SELECT * FROM budget_pauses WHERE peer = ?").get(peer) as Record<string, any> | null;
|
|
125
168
|
return r ? { peer: r.peer, since: r.since, resetsAt: r.resets_at, reason: r.reason, summary: r.summary, handedOff: !!r.handed_off, moved: JSON.parse(r.moved) } : undefined;
|
|
126
169
|
}
|
|
@@ -157,13 +200,15 @@ export class Budget {
|
|
|
157
200
|
report(peer: PeerId, windows: UsageWindow[], hard = false, at = this.now()): void {
|
|
158
201
|
// Execution-budget instrumentation is independent of native quota accounting.
|
|
159
202
|
// Explicit quota exemption or absent budget metadata exempts registered kinds; external ids keep accounting.
|
|
160
|
-
const
|
|
161
|
-
|
|
203
|
+
const reporter = peer;
|
|
204
|
+
peer = this.account(peer);
|
|
205
|
+
const adapter = definitionOf(reporter) ?? definitionOf(peer);
|
|
206
|
+
if (reporter === "user" || reporter === "hub" || peer === "user" || peer === "hub" || adapter && (!adapter.budget || adapter.budget.quotaExempt === true) || this.closed) return;
|
|
162
207
|
// An override ignores soft readings, not a provider that has actually refused another turn.
|
|
163
208
|
if (hard && this.ignoreUntil.delete(peer)) {
|
|
164
209
|
try { this.hooks.notify(`budget: ${peer} provider refused again; budget override ended`); } catch { /* a notice cannot prevent the hard pause */ }
|
|
165
210
|
}
|
|
166
|
-
try { this.hooks.reading?.(
|
|
211
|
+
try { this.hooks.reading?.(reporter, windows, hard, at); } catch { /* telemetry never costs a reading */ }
|
|
167
212
|
const mine = this.readings.get(peer) ?? new Map<string, Reading>();
|
|
168
213
|
this.readings.set(peer, mine);
|
|
169
214
|
for (const w of windows) mine.set(w.id, { ...w, used: Math.max(0, Math.min(1, w.used)), at });
|
|
@@ -173,12 +218,14 @@ export class Budget {
|
|
|
173
218
|
|
|
174
219
|
/** Readings that still say something: not stale, and not about a window that has reset since they were taken. */
|
|
175
220
|
private fresh(peer: PeerId): Reading[] {
|
|
221
|
+
peer = this.account(peer);
|
|
176
222
|
const staleMs = this.cfg.stale_min * 60_000;
|
|
177
223
|
return [...(this.readings.get(peer)?.values() ?? [])].filter((r) => this.now() - r.at <= staleMs && !(r.resetsAt && r.resetsAt <= this.now()));
|
|
178
224
|
}
|
|
179
225
|
|
|
180
226
|
/** The owner's override: lift the pause now and leave the peer alone until the window that caused it has reset. */
|
|
181
227
|
override(peer: PeerId): boolean {
|
|
228
|
+
peer = this.account(peer);
|
|
182
229
|
const open = this.record(peer);
|
|
183
230
|
if (!open) return false;
|
|
184
231
|
this.ignoreUntil.set(peer, open.resetsAt);
|
|
@@ -190,6 +237,7 @@ export class Budget {
|
|
|
190
237
|
}
|
|
191
238
|
|
|
192
239
|
private async evaluate(peer: PeerId, hard: boolean): Promise<void> {
|
|
240
|
+
peer = this.account(peer);
|
|
193
241
|
if (this.recoveryHeld) { this.deferred = true; if (hard) this.deferredHard.add(peer); return; }
|
|
194
242
|
const fresh = this.fresh(peer);
|
|
195
243
|
const over = fresh.filter((r) => r.used >= this.cfg.gate);
|
|
@@ -252,6 +300,7 @@ export class Budget {
|
|
|
252
300
|
}
|
|
253
301
|
|
|
254
302
|
private async finishHandoff(peer: PeerId, summary: string | undefined): Promise<void> {
|
|
303
|
+
peer = this.account(peer);
|
|
255
304
|
if (this.closed || this.recoveryHeld) { this.deferred = true; return; }
|
|
256
305
|
if (this.handingOff.has(peer) || !this.hooks.canHandOff(peer)) return; // retried by the next tick
|
|
257
306
|
this.handingOff.add(peer);
|
|
@@ -264,6 +313,7 @@ export class Budget {
|
|
|
264
313
|
}
|
|
265
314
|
|
|
266
315
|
private async handOff(peer: PeerId, summary: string | undefined): Promise<void> {
|
|
316
|
+
peer = this.account(peer);
|
|
267
317
|
const context = summary ?? (await this.hooks.platformContext(peer).catch(() => undefined));
|
|
268
318
|
// Wait or hand off (issue #36): a handoff costs the next peer the whole context, so a short wait can be cheaper.
|
|
269
319
|
const resetsAt = this.record(peer)?.resetsAt;
|
|
@@ -298,6 +348,7 @@ export class Budget {
|
|
|
298
348
|
|
|
299
349
|
/** `hub_checkpoint` arrived from a peer. Resolved by the daemon's requestCheckpoint wait; kept on the record for a restart. */
|
|
300
350
|
checkpointed(peer: PeerId, summary: string): void {
|
|
351
|
+
peer = this.account(peer);
|
|
301
352
|
if (this.closed) return; // a hub_checkpoint that lands during shutdown
|
|
302
353
|
this.db.query("UPDATE budget_pauses SET summary = ? WHERE peer = ?").run(summary, peer);
|
|
303
354
|
}
|
|
@@ -314,6 +365,7 @@ export class Budget {
|
|
|
314
365
|
}
|
|
315
366
|
|
|
316
367
|
private resume(peer: PeerId, why: string): void {
|
|
368
|
+
peer = this.account(peer);
|
|
317
369
|
const record = this.record(peer);
|
|
318
370
|
if (!record) return;
|
|
319
371
|
this.db.query("DELETE FROM budget_pauses WHERE peer = ?").run(peer);
|
package/src/hub/bus.ts
CHANGED
|
@@ -974,7 +974,7 @@ export class Bus {
|
|
|
974
974
|
}
|
|
975
975
|
|
|
976
976
|
/**
|
|
977
|
-
* What one push delivery would take (the preface, then `take`'s batch),
|
|
977
|
+
* What one push delivery would take (the preface, then `take`'s batch), explicitly pulled by its attached session and recorded as
|
|
978
978
|
* completed: the tool result that returns it is the readback, so nothing waits for settlement and nothing is ever
|
|
979
979
|
* `accepted` (issue #205). It is checkpointed like a push, so queued rows of the same envelopes (an operator retry)
|
|
980
980
|
* are grouped into it, and registered for `reply_to` like a push. Undefined while held. A failed journal write puts
|
package/src/hub/conductor.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { definitionOf,
|
|
1
|
+
import { definitionOf, type KindMap } from "../adapters/registry.ts";
|
|
2
2
|
import { Database } from "bun:sqlite";
|
|
3
3
|
import type { Task } from "./board.ts";
|
|
4
4
|
import { CONDUCTOR_TOOL_NAMES } from "./hub-tools.ts";
|
|
@@ -104,19 +104,21 @@ export class ConductorHolds {
|
|
|
104
104
|
close(): void { this.db.close(); }
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
type
|
|
108
|
-
|
|
109
|
-
export type ConductorStart = { peer: StartablePeer; mode: "headless" } | { peer: "claude" | "codex" | "pi"; mode: "tui"; command: string };
|
|
107
|
+
export type ConductorStart = { peer: string; mode: "headless" } | { peer: "claude" | "codex" | "pi"; mode: "tui"; command: string };
|
|
110
108
|
/**
|
|
111
109
|
* What a conductor's start of `peer` is (#269): the peer's own start mode, which a person sets. A `mode` argument may
|
|
112
110
|
* only repeat it. Preview commands come from the same launch planner the CLI uses, never caller-supplied shell text.
|
|
113
111
|
*/
|
|
114
|
-
export function conductorStart(peer: unknown, mode: unknown, preview: (peer: "claude" | "codex" | "pi") => string, preEffect: boolean, startMode: (peer:
|
|
112
|
+
export function conductorStart(peer: unknown, mode: unknown, preview: (peer: "claude" | "codex" | "pi") => string, preEffect: boolean, startMode: (peer: string) => StartMode, instances?: KindMap): ConductorStart {
|
|
115
113
|
const refuse = (text: string) => new (preEffect ? PreEffectToolRefusal : Error)(text);
|
|
116
114
|
if (mode !== undefined && mode !== "headless" && mode !== "tui") throw refuse("mode must be headless or tui");
|
|
117
|
-
|
|
118
|
-
|
|
115
|
+
const adapter = typeof peer === "string" ? definitionOf(peer, instances) : undefined;
|
|
116
|
+
if (typeof peer !== "string" || !adapter?.start.conductorStartable) throw refuse("only claude, codex, kimi, pi and local, plus opt-in qwen/jcode, may be started by the conductor");
|
|
117
|
+
const selected = peer;
|
|
118
|
+
const extra = adapter.kind !== selected;
|
|
119
|
+
if (extra && (!adapter.start.create || !adapter.start.forms.includes("headless") || !["env", "acp-session"].includes(adapter.identity.mcp))) throw refuse(`${peer}: this native transport does not support extra headless instances`);
|
|
119
120
|
const own = startMode(selected);
|
|
121
|
+
if (extra && own !== "headless") throw refuse(`${peer}: extra instances require headless mode in I2`);
|
|
120
122
|
if (mode !== undefined && mode !== own) throw refuse(`${peer} starts ${own} here; a person sets a start mode (ahub settings), the conductor cannot choose one`);
|
|
121
123
|
return own === "tui" ? { peer: peer as "claude" | "codex" | "pi", mode: "tui", command: preview(peer as "claude" | "codex" | "pi") } : { peer: selected, mode: "headless" };
|
|
122
124
|
}
|
|
@@ -164,9 +166,11 @@ export interface ConductorHooks {
|
|
|
164
166
|
escalate(actor: string, id: number): Promise<unknown>;
|
|
165
167
|
preview(peer: "claude" | "codex" | "pi"): string;
|
|
166
168
|
/** The mode a start of this peer takes: a person's setting. */
|
|
167
|
-
startMode(peer:
|
|
169
|
+
startMode(peer: string): StartMode;
|
|
168
170
|
/** Starts the peer in that mode: headless at once, or its fixed command in a terminal; throws with the command to run by hand when no terminal can be opened. */
|
|
169
|
-
start(peer:
|
|
171
|
+
start(peer: string): Promise<unknown>;
|
|
172
|
+
/** Explicit project declarations only; an undeclared suffix never acquires a native kind. */
|
|
173
|
+
instances?(): KindMap;
|
|
170
174
|
known(peer: string): boolean;
|
|
171
175
|
pause(peer: string): void;
|
|
172
176
|
validateRelease?(peer: string): Promise<void>;
|
|
@@ -231,7 +235,9 @@ export class Conductor {
|
|
|
231
235
|
return updated ? publicConductorTask(updated, this.hooks.publicView) : { id: task.id };
|
|
232
236
|
}
|
|
233
237
|
if (tool === "hub_peer_start") {
|
|
234
|
-
const
|
|
238
|
+
const instances = this.hooks.instances?.();
|
|
239
|
+
const start = conductorStart(args.peer, args.mode, this.hooks.preview, true, this.hooks.startMode, instances);
|
|
240
|
+
if (definitionOf(start.peer, instances)?.kind !== start.peer && !this.hooks.known(start.peer)) throw new PreEffectToolRefusal(`unknown peer: ${start.peer}`);
|
|
235
241
|
const result = await this.hooks.start(start.peer);
|
|
236
242
|
const opened = result && typeof result === "object" && typeof (result as { opened?: unknown }).opened === "string" ? { opened: (result as { opened: string }).opened } : {};
|
|
237
243
|
emit({ peer: start.peer }); return { ...start, ...opened };
|
package/src/hub/config-apply.ts
CHANGED
|
@@ -8,6 +8,8 @@ import { catalogAssignments } from "./roles.ts";
|
|
|
8
8
|
import { resolveProfile } from "./profiles.ts";
|
|
9
9
|
import { taskSweepConfig } from "./task-sweep.ts";
|
|
10
10
|
import { projectionConfig } from "./text-projection.ts";
|
|
11
|
+
import { validateInstances, type InstancesConfig } from "./instances.ts";
|
|
12
|
+
import { peerRosterConfig } from "./roster.ts";
|
|
11
13
|
|
|
12
14
|
export type ApplyClass = "live" | "next use" | "restart";
|
|
13
15
|
export interface ApplyRule { applies: ApplyClass; reason?: string }
|
|
@@ -108,6 +110,7 @@ const schema: Record<string, Check> = {
|
|
|
108
110
|
...Object.assign({}, ...ADAPTERS.map(adapter => adapter.configuration?.schema?.(nativeValidators) ?? {})),
|
|
109
111
|
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,
|
|
110
112
|
permission_modes: v => { permissionDefaults(v); }, protect_instruction_files: bool,
|
|
113
|
+
instances: v => { validateInstances(v); },
|
|
111
114
|
batch_max: number(1, 100_000, true), batch_ms: number(0, 2_147_483_647), queue_cap: number(1, 1_000_000, true),
|
|
112
115
|
memory: block({ enabled: bool, worker_url: text, inject_tokens: number(0, 1_000_000, true), brief_items: number(0, 10_000, true), native: bool,
|
|
113
116
|
short_ttl_days: number(1, 3650, true), sync_interval_min: number(0, 1440, true), suggest_edges: bool }),
|
|
@@ -138,7 +141,14 @@ const schema: Record<string, Check> = {
|
|
|
138
141
|
review: block({ adaptive: bool, min_reviews: number(1, Number.MAX_SAFE_INTEGER, true) }), recovery: block({ auto_resume_after_crash: bool }),
|
|
139
142
|
capabilities: map(strings), coordination: words(["advisory", "turn-free"]), experiments: block({ stale_notices: words(["drop", "deliver"]) }),
|
|
140
143
|
};
|
|
141
|
-
export function validateRawConfig(raw: unknown): asserts raw is Record<string, unknown> {
|
|
144
|
+
export function validateRawConfig(raw: unknown, declarations?: InstancesConfig): asserts raw is Record<string, unknown> {
|
|
145
|
+
const value = object(raw, "config");
|
|
146
|
+
const instances = validateInstances(value.instances ?? declarations);
|
|
147
|
+
if (!Object.keys(instances).length) return block(schema)(raw, "config");
|
|
148
|
+
block({ ...schema, permission_modes: v => { permissionDefaults(v, instances); },
|
|
149
|
+
roster: v => { peerRosterConfig(v, instances); }, peers: v => { peerStartConfig(v, instances); },
|
|
150
|
+
})(raw, "config");
|
|
151
|
+
}
|
|
142
152
|
export function validateEffectiveConfig(value: unknown): asserts value is HubConfig {
|
|
143
153
|
const raw = { ...object(value, "config") };
|
|
144
154
|
delete raw.ignored; delete raw.retired; delete raw.permission_default_sources;
|
|
@@ -149,6 +159,7 @@ export function validateEffectiveConfig(value: unknown): asserts value is HubCon
|
|
|
149
159
|
}
|
|
150
160
|
|
|
151
161
|
const restart: Record<string, string> = {
|
|
162
|
+
instances: "Native instance identity, worktree, ports and account bindings are fixed at daemon startup",
|
|
152
163
|
...Object.assign({}, ...ADAPTERS.map(adapter => adapter.configuration?.restartReasons ?? {})),
|
|
153
164
|
memory: "Open memory consumers, selected profile, client and sync timer must be quiesced together",
|
|
154
165
|
omniroute: "Credentials and gateway clients are bound to relay ownership", mlx: "Model runtime ownership and device resources belong to this daemon",
|
|
@@ -174,7 +185,7 @@ export function configApply(key: string): ApplyRule {
|
|
|
174
185
|
if (shape?.fields) { if (!Object.hasOwn(shape.fields, part)) return fail(key, "unknown configuration key"); check = shape.fields[part]; }
|
|
175
186
|
else if (shape?.item) { if (shape.keys && !shape.keys.includes(part)) return fail(key, "unknown configuration key"); check = shape.item; }
|
|
176
187
|
else if (index === 0 && (special[top]?.includes(part) || top === "checks" && /^[a-z][a-z0-9_]{0,47}$/.test(part))) break;
|
|
177
|
-
else if (["permission_modes", "catalog_roles", "profile_overrides", "text_projection", "peers"].includes(top)) break;
|
|
188
|
+
else if (["permission_modes", "catalog_roles", "profile_overrides", "text_projection", "peers", "instances"].includes(top)) break;
|
|
178
189
|
else return fail(key, "unknown configuration key");
|
|
179
190
|
}
|
|
180
191
|
}
|
package/src/hub/config-reload.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { join } from "node:path";
|
|
|
5
5
|
import { validateRawConfig, type ConfigLayer } from "./config-apply.ts";
|
|
6
6
|
import { parseOverlay, parseRouting, type Routing } from "./routing.ts";
|
|
7
7
|
import { CLASSES } from "./board.ts";
|
|
8
|
+
import { validateInstances } from "./instances.ts";
|
|
8
9
|
|
|
9
10
|
export const CONFIG_SOURCE_FILES = ["config.json", "config.local.json", "routing.toml", "routing.local.toml"] as const;
|
|
10
11
|
export type ConfigSourceName = typeof CONFIG_SOURCE_FILES[number];
|
|
@@ -90,6 +91,15 @@ function validateRoutingRaw(value: unknown): void {
|
|
|
90
91
|
/** Strict reloads never use routing's last-good fallback. Startup can retain its existing fallback explicitly. */
|
|
91
92
|
export function parseConfigSources(texts: ConfigSourceTexts, fallbackRouting?: Routing): ConfigSources {
|
|
92
93
|
const layers: ConfigLayer[] = [], trustLayers: ConfigSources["trustLayers"] = [];
|
|
94
|
+
let localRaw: Record<string, unknown> = {};
|
|
95
|
+
try { localRaw = JSON.parse(texts["config.local.json"] ?? "{}"); }
|
|
96
|
+
catch { if (!fallbackRouting) throw new Error(".agenthub/config.local.json: invalid JSON object"); }
|
|
97
|
+
let declared;
|
|
98
|
+
try { declared = validateInstances(localRaw?.instances); }
|
|
99
|
+
catch (error) {
|
|
100
|
+
if (!fallbackRouting) throw new Error(`.agenthub/config.local.json: ${(error as Error).message}`);
|
|
101
|
+
declared = validateInstances(undefined);
|
|
102
|
+
}
|
|
93
103
|
for (const name of CONFIG_SOURCE_FILES) {
|
|
94
104
|
const text = texts[name];
|
|
95
105
|
if (text === null) continue;
|
|
@@ -104,7 +114,7 @@ export function parseConfigSources(texts: ConfigSourceTexts, fallbackRouting?: R
|
|
|
104
114
|
}
|
|
105
115
|
if (name === "config.json" || name === "config.local.json") {
|
|
106
116
|
if (!fallbackRouting) {
|
|
107
|
-
try { validateRawConfig(raw); }
|
|
117
|
+
try { validateRawConfig(raw, declared); }
|
|
108
118
|
catch (error) { throw new Error(`.agenthub/${name}: ${(error as Error).message}`); }
|
|
109
119
|
}
|
|
110
120
|
layers.push({ name, raw });
|
package/src/hub/config-trust.ts
CHANGED
|
@@ -29,7 +29,7 @@ export const MACHINE_LOCAL = [
|
|
|
29
29
|
"terminal.open", // the command a hub-made TUI start runs to open a terminal (#269)
|
|
30
30
|
] as const;
|
|
31
31
|
/** Legacy exported field list stays compatible; new policies use the complete list below. */
|
|
32
|
-
export const ALL_MACHINE_LOCAL = [...MACHINE_LOCAL, "protect_instruction_files", "qwen_cmd", "jcode_cmd", "jcode_home", "jcode_provider_profile", "text_projection"] as const;
|
|
32
|
+
export const ALL_MACHINE_LOCAL = [...MACHINE_LOCAL, "protect_instruction_files", "qwen_cmd", "jcode_cmd", "jcode_home", "jcode_provider_profile", "text_projection", "instances"] as const;
|
|
33
33
|
|
|
34
34
|
/** A known repository, including an unreadable marker, must never be mistaken for a plain directory. */
|
|
35
35
|
export function configRepositoryPresent(cwd: string): boolean {
|
|
@@ -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", "recovery_deferred_hub"] as const;
|
|
11
|
+
export const DAEMON_FEATURES = ["task_cancel", "recovery_deferred_hub", "peer_instances"] 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
|
|
package/src/hub/crash.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { definitionOf } from "../adapters/registry.ts";
|
|
1
|
+
import { definitionOf, type KindMap } from "../adapters/registry.ts";
|
|
2
2
|
import { chmodSync, existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import type { JournalDelivery } from "./delivery-journal.ts";
|
|
@@ -56,17 +56,26 @@ const str = (v: unknown) => (typeof v === "string" && v ? v : undefined);
|
|
|
56
56
|
* What can come back after a crash. `resume` holds the start arguments for peers the hub launches itself (Kimi through
|
|
57
57
|
* ACP `session/load`, Pi through its session file, the local worker afresh); the others say what the user has to do.
|
|
58
58
|
*/
|
|
59
|
-
export function crashPlan(records: SessionRecord[]): { peer: string; resume?: Record<string, string>; how: string; fresh?: Record<string, string>; tui?: true }[] {
|
|
59
|
+
export function crashPlan(records: SessionRecord[], instances?: KindMap): { peer: string; resume?: Record<string, string>; how: string; fresh?: Record<string, string>; tui?: true }[] {
|
|
60
60
|
return records.map(({ peer, meta }) => {
|
|
61
61
|
const launch = (meta.launch && typeof meta.launch === "object" ? meta.launch : {}) as Record<string, unknown>;
|
|
62
62
|
const model = str(launch.model);
|
|
63
|
-
const adapter = definitionOf(peer);
|
|
63
|
+
const adapter = definitionOf(peer, instances);
|
|
64
|
+
const extra = adapter !== undefined && definitionOf(peer) === undefined;
|
|
65
|
+
const command = extra ? `ahub ${adapter.cli.command} --as ${peer}` : `ahub ${peer}`;
|
|
64
66
|
if (adapter?.session.resumeById) {
|
|
65
67
|
const sessionId = adapter.session.crashIdentity?.(meta).sessionId;
|
|
66
|
-
if (!sessionId) return { peer, how: `${peer}: saved ACP session identity unavailable; start it deliberately with
|
|
68
|
+
if (!sessionId) return { peer, how: `${peer}: saved ACP session identity unavailable; start it deliberately with ${command}` };
|
|
67
69
|
return { peer, resume: { sessionId, ...(model ? { model } : {}) }, how: `${peer}: the recorded conversation can be loaded again (ACP session/load)` };
|
|
68
70
|
}
|
|
69
|
-
|
|
71
|
+
const native = adapter?.session.crashPlan?.(meta);
|
|
72
|
+
if (!native) return { peer, how: `${peer}: reconnects by itself if its client is still running` };
|
|
73
|
+
if (!extra) return { peer, ...native };
|
|
74
|
+
const prefix = `${adapter.kind}:`;
|
|
75
|
+
let how = native.how.startsWith(prefix) ? `${peer}:${native.how.slice(prefix.length)}` : `${peer}: ${native.how}`;
|
|
76
|
+
// Manual launch guidance selects this instance. Recorded headless resume prose can contain an opaque id, never a command.
|
|
77
|
+
if (!native.resume || native.tui) how = how.replace(`ahub ${adapter.cli.command}`, command);
|
|
78
|
+
return { peer, ...native, how };
|
|
70
79
|
});
|
|
71
80
|
}
|
|
72
81
|
|