@staix/agent-hub 0.12.39 → 0.12.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/README.md +15 -10
- package/docs/agent-notes/adapters.md +4 -1
- package/docs/agent-notes/budget.md +2 -0
- package/docs/agent-notes/bus.md +2 -2
- package/docs/agent-notes/daemon.md +10 -6
- package/docs/agent-notes/models.md +4 -4
- package/docs/agent-notes/tasks.md +3 -0
- package/docs/agent-notes/tests.md +1 -1
- package/docs/memory.md +1 -1
- package/docs/operations.md +152 -28
- package/docs/quickstart.md +19 -5
- package/docs/security.md +19 -7
- package/docs/smoke.md +2 -2
- package/docs/specs/2026-09-19-agent-hub-design.md +158 -70
- package/docs/specs/2026-10-04-switchyard-source-port-design.md +12 -7
- package/docs/verification/2026-10-20-0.12.40.md +63 -0
- package/docs/verified.json +38 -19
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +413 -147
- package/src/adapters/acp.ts +34 -1
- package/src/adapters/claude-channel.ts +35 -4
- package/src/adapters/codex-appserver.ts +12 -3
- package/src/adapters/definitions/builtin-metadata.ts +2 -0
- package/src/adapters/definitions/native-cli-doctor.ts +8 -5
- package/src/adapters/definitions/native-cli-entry-claude.ts +7 -2
- package/src/adapters/definitions/native-cli-entry-codex.ts +13 -14
- package/src/adapters/definitions/native-cli-entry-jcode.ts +13 -3
- package/src/adapters/definitions/native-cli-entry-pi.ts +9 -7
- package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
- package/src/adapters/definitions/native-cli-jcode-mcp.ts +55 -1
- package/src/adapters/jcode.ts +110 -4
- package/src/adapters/local-worker.ts +21 -37
- package/src/adapters/pi.ts +7 -0
- package/src/cli/console-state.ts +1 -0
- package/src/cli/console.ts +11 -1
- package/src/cli/help.ts +13 -8
- package/src/cli/identity-audit.ts +33 -7
- package/src/cli/jcode-mcp.ts +1 -1
- package/src/cli/main.ts +134 -24
- package/src/cli/output.ts +5 -2
- package/src/cli/status-lines.ts +1 -1
- package/src/cli/terminal-recovery.ts +54 -4
- package/src/cli/upgrade-interactive.ts +5 -0
- package/src/cli/upgrade-runtime.ts +13 -1
- package/src/cli/upgrade.ts +67 -1
- package/src/hub/board.ts +4 -2
- package/src/hub/bus.ts +8 -3
- package/src/hub/checks.ts +15 -5
- package/src/hub/conductor.ts +2 -1
- package/src/hub/config-apply.ts +20 -2
- package/src/hub/config-reload.ts +2 -8
- package/src/hub/control-client.ts +26 -3
- package/src/hub/daemon.ts +155 -68
- package/src/hub/delivery-superseded.ts +19 -2
- package/src/hub/events.ts +4 -1
- package/src/hub/hub-tools.ts +4 -4
- package/src/hub/inference.ts +4 -8
- package/src/hub/lifecycle.ts +3 -3
- package/src/hub/main-checkout.ts +36 -0
- package/src/hub/memory-profile-check.ts +6 -2
- package/src/hub/memory-profile.ts +18 -1
- package/src/hub/peers.ts +2 -0
- package/src/hub/ports.ts +1 -0
- package/src/hub/routing.ts +24 -14
- package/src/hub/settings.ts +1 -1
- package/src/hub/task-repository.ts +136 -0
- package/src/hub/tasks.ts +101 -21
- package/src/hub/terminal-launch.ts +130 -0
- package/src/hub/usage.ts +44 -18
- package/src/models/route/config.ts +1 -1
- package/src/omniroute/client.ts +7 -19
- package/templates/AGENTS.block.md +1 -0
- package/templates/routing.toml +10 -44
- package/src/switchyard/config.ts +0 -45
- package/src/switchyard/sidecar.ts +0 -136
package/src/cli/upgrade.ts
CHANGED
|
@@ -51,6 +51,8 @@ export interface PlannedProject {
|
|
|
51
51
|
blockers: string[];
|
|
52
52
|
/** Unmanaged sessions (#206): their plugin reattaches to the new daemon; no terminal is closed or relaunched and no session id is compared. */
|
|
53
53
|
reconnectOnly?: string[];
|
|
54
|
+
/** Attached native terminals with neither a current live launcher record nor an independently verified terminal binding. */
|
|
55
|
+
unrecordedTerminals?: string[];
|
|
54
56
|
/** #215: Codex threads with no rollout and no turn the hub recorded; they restart as a new session, nothing to lose. */
|
|
55
57
|
freshStart?: string[];
|
|
56
58
|
}
|
|
@@ -99,6 +101,15 @@ export interface ReattachmentMarker {
|
|
|
99
101
|
cause: "detached after restore";
|
|
100
102
|
binding: TerminalBinding;
|
|
101
103
|
}
|
|
104
|
+
/** Trusted per-invocation attribution from the CLI identity preflight, never authority read from a receipt. */
|
|
105
|
+
export interface RecoveryDelegation {
|
|
106
|
+
peer: string;
|
|
107
|
+
command: "upgrade" | "restart" | "recovery resume" | "recovery abort" | "recovery dispose" | "recovery-run";
|
|
108
|
+
}
|
|
109
|
+
type RecoveryDelegationAudit = RecoveryDelegation & { at: number; operationId: string };
|
|
110
|
+
const MAX_RECOVERY_DELEGATIONS = 256;
|
|
111
|
+
const RECOVERY_DELEGATION_COMMANDS: readonly string[] = ["upgrade", "restart", "recovery resume", "recovery abort", "recovery dispose", "recovery-run"];
|
|
112
|
+
|
|
102
113
|
export interface RecoveryOperation {
|
|
103
114
|
/**
|
|
104
115
|
* #227: 2 marks a stop-and-archive recorded on an operation whose coordinator predates #215. Every older runner and
|
|
@@ -112,6 +123,8 @@ export interface RecoveryOperation {
|
|
|
112
123
|
phase: "pending" | "running" | "blocked" | "completed" | "cancelled";
|
|
113
124
|
step: string;
|
|
114
125
|
sourceRoot: string;
|
|
126
|
+
/** Ids-only initiating and subsequent delegated invocations; retained across coordinator replacement. */
|
|
127
|
+
delegatedAudits?: RecoveryDelegationAudit[];
|
|
115
128
|
targetRoot?: string;
|
|
116
129
|
targetDigest?: string;
|
|
117
130
|
/** Verified capability evidence bound to targetRoot/targetDigest; absence is legacy unknown, not an empty list. */
|
|
@@ -395,7 +408,7 @@ export function planFingerprint(plan: Omit<UpgradePlan, "fingerprint">): string
|
|
|
395
408
|
return createHash("sha256").update(JSON.stringify(stable)).digest("hex");
|
|
396
409
|
}
|
|
397
410
|
|
|
398
|
-
export function createOperation(plan: UpgradePlan, sourceRoot: string, home = hubHome()): RecoveryOperation {
|
|
411
|
+
export function createOperation(plan: UpgradePlan, sourceRoot: string, home = hubHome(), delegation?: RecoveryDelegation): RecoveryOperation {
|
|
399
412
|
if (plan.blockers.length || plan.projects.some((p) => p.blockers.length)) throw new Error("upgrade plan has blockers; no runtimes were changed");
|
|
400
413
|
if (!plan.projects.length) throw new Error("no running projects to recover");
|
|
401
414
|
const { fingerprint, ...body } = plan;
|
|
@@ -405,6 +418,7 @@ export function createOperation(plan: UpgradePlan, sourceRoot: string, home = hu
|
|
|
405
418
|
schema: 1, id, plan, sourceRoot, createdAt: Date.now(), updatedAt: Date.now(), phase: "pending", step: "stage",
|
|
406
419
|
projects: plan.projects.map((p) => ({ id: p.project.id, phase: "pending", terminals: {} })),
|
|
407
420
|
};
|
|
421
|
+
if (delegation) appendRecoveryDelegation(op, delegation, op.createdAt);
|
|
408
422
|
writeOperation(id, op, home);
|
|
409
423
|
acquireRecoveryLock(id, home);
|
|
410
424
|
return op;
|
|
@@ -423,11 +437,49 @@ function abandoned(op: RecoveryOperation): boolean {
|
|
|
423
437
|
return true;
|
|
424
438
|
}
|
|
425
439
|
|
|
440
|
+
function validRecoveryDelegation(value: unknown): value is RecoveryDelegation {
|
|
441
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
442
|
+
const row = value as Record<string, unknown>;
|
|
443
|
+
return typeof row.peer === "string" && /^[a-z][a-z0-9-]{0,31}$/.test(row.peer)
|
|
444
|
+
&& typeof row.command === "string" && RECOVERY_DELEGATION_COMMANDS.includes(row.command);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function validateRecoveryDelegations(op: RecoveryOperation): void {
|
|
448
|
+
if (op.delegatedAudits === undefined) return;
|
|
449
|
+
if (!Array.isArray(op.delegatedAudits) || op.delegatedAudits.length > MAX_RECOVERY_DELEGATIONS || op.delegatedAudits.some(row =>
|
|
450
|
+
!validRecoveryDelegation(row) || Object.keys(row).some(key => !["at", "peer", "command", "operationId"].includes(key)) ||
|
|
451
|
+
!Number.isFinite(row.at) || row.at < 0 || row.operationId !== op.id)) throw new Error("invalid recovery delegation audit");
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function appendRecoveryDelegation(op: RecoveryOperation, delegation: RecoveryDelegation, at: number): void {
|
|
455
|
+
validateRecoveryDelegations(op);
|
|
456
|
+
if (!validRecoveryDelegation(delegation) || Object.keys(delegation).some(key => !["peer", "command"].includes(key)) ||
|
|
457
|
+
!Number.isFinite(at) || at < 0) throw new Error("invalid recovery delegation audit");
|
|
458
|
+
if ((op.delegatedAudits?.length ?? 0) >= MAX_RECOVERY_DELEGATIONS) throw new Error("recovery delegation audit is full; nothing was executed");
|
|
459
|
+
(op.delegatedAudits ??= []).push({ at, peer: delegation.peer, command: delegation.command, operationId: op.id });
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** Record an external resume before handing off to even an older retained coordinator. */
|
|
463
|
+
export function recordRecoveryDelegation(id: string, delegation: RecoveryDelegation, home = hubHome()): RecoveryOperation {
|
|
464
|
+
validateReceipt(id, readOperation<RecoveryOperation>(id, home));
|
|
465
|
+
const releaseRunner = claimRunner(id, home);
|
|
466
|
+
try {
|
|
467
|
+
const op = readOperation<RecoveryOperation>(id, home);
|
|
468
|
+
validateReceipt(id, op);
|
|
469
|
+
const at = Date.now();
|
|
470
|
+
appendRecoveryDelegation(op, delegation, at);
|
|
471
|
+
op.updatedAt = at;
|
|
472
|
+
writeOperation(id, op, home);
|
|
473
|
+
return op;
|
|
474
|
+
} finally { releaseRunner(); }
|
|
475
|
+
}
|
|
476
|
+
|
|
426
477
|
function validateReceipt(id: string, op: RecoveryOperation, historical = false): void {
|
|
427
478
|
// Cleanup validates owned historical facts; this mode never authorizes restore, fresh or reattach.
|
|
428
479
|
const options = historical ? { authority: "historical" as const } : {};
|
|
429
480
|
const decode = (value: unknown) => decodeRestoreReceipt(value, options);
|
|
430
481
|
if (!(op.schema === 1 || (op.schema === 2 && op.disposition)) || op.id !== id) throw new Error("unsupported operation receipt");
|
|
482
|
+
validateRecoveryDelegations(op);
|
|
431
483
|
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");
|
|
432
484
|
const { fingerprint, ...reviewed } = op.plan;
|
|
433
485
|
if (fingerprint !== planFingerprint(reviewed) || op.projects.length !== op.plan.projects.length ||
|
|
@@ -524,6 +576,10 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
|
|
|
524
576
|
releaseRunner();
|
|
525
577
|
return op;
|
|
526
578
|
}
|
|
579
|
+
if (driver.liveTestPeer) {
|
|
580
|
+
appendRecoveryDelegation(op, { peer: driver.liveTestPeer, command: "recovery-run" }, driver.now());
|
|
581
|
+
writeOperation(id, op, home);
|
|
582
|
+
}
|
|
527
583
|
} catch (error) { releaseRunner(); throw error; }
|
|
528
584
|
let targetFeatures: readonly string[] = [];
|
|
529
585
|
const save = () => { op.updatedAt = driver.now(); writeOperation(id, op, home); };
|
|
@@ -778,6 +834,7 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
|
|
|
778
834
|
|
|
779
835
|
/** Receipts and runner state only (#215): ids, phases and effects, never task or message text. */
|
|
780
836
|
export function publicOperation(op: RecoveryOperation, runnerPid?: number | "unknown", live: Record<string, Inspection | undefined> = {}) {
|
|
837
|
+
validateRecoveryDelegations(op);
|
|
781
838
|
const effect = (key: string, value: unknown) => {
|
|
782
839
|
if (!key.startsWith("restored:")) return value === "pending" || value === "failed" ? value : "done";
|
|
783
840
|
const receipt = decodeRestoreReceipt(value, { authority: "historical" });
|
|
@@ -798,6 +855,7 @@ export function publicOperation(op: RecoveryOperation, runnerPid?: number | "unk
|
|
|
798
855
|
...(p.restarts?.length ? { restarts: p.restarts.length } : {}) })),
|
|
799
856
|
pluginInstalled: !!op.pluginInstalled, globalInstalled: !!op.globalInstalled,
|
|
800
857
|
...(!targetReadsWaivers(op) && op.projects.some((p) => failedPeers(op, p).length) ? { freshSession: `not offered: target ${op.plan.version} cannot read recovery waivers, so it could not release a new session` } : {}),
|
|
858
|
+
...(op.delegatedAudits ? { delegatedAudits: op.delegatedAudits } : {}),
|
|
801
859
|
...(op.error ? { error: op.error } : {}), ...(op.failure ? { failure: op.failure } : {}), ...(op.blockedReattachment ? { blockedReattachment: op.blockedReattachment } : {}), ...(op.disposition ? { disposition: op.disposition } : {}), ...(op.audit ? { audit: op.audit } : {}), ...(op.freshOverride ? { freshOverride: op.freshOverride } : {}), ...(op.missingRecordedSession ? { missingRecordedSession: op.missingRecordedSession } : {}),
|
|
802
860
|
...(op.waits ? { waits: op.waits } : {}), ...(op.presentationCause ? { presentationCause: op.presentationCause } : {}), next };
|
|
803
861
|
}
|
|
@@ -881,6 +939,10 @@ export async function abortRecovery(id: string, driver: RecoveryDriver, home = h
|
|
|
881
939
|
if (recoveryLock(home) === id) releaseRecoveryLock(id, home);
|
|
882
940
|
return;
|
|
883
941
|
}
|
|
942
|
+
if (driver.liveTestPeer) {
|
|
943
|
+
appendRecoveryDelegation(op, { peer: driver.liveTestPeer, command: "recovery abort" }, driver.now());
|
|
944
|
+
writeOperation(id, op, home);
|
|
945
|
+
}
|
|
884
946
|
if (abandoned(op)) writeOperation(id, op, home);
|
|
885
947
|
const { fingerprint, ...body } = op.plan;
|
|
886
948
|
if (fingerprint !== planFingerprint(body)) throw new Error("operation plan changed");
|
|
@@ -925,6 +987,10 @@ export async function disposeRecovery(id: string, choice: { fresh: string } | {
|
|
|
925
987
|
if (recoveryLock(home) === id) releaseRecoveryLock(id, home);
|
|
926
988
|
throw new Error(`operation is already ${op.phase}`);
|
|
927
989
|
}
|
|
990
|
+
if (driver.liveTestPeer) {
|
|
991
|
+
appendRecoveryDelegation(op, { peer: driver.liveTestPeer, command: "recovery dispose" }, driver.now());
|
|
992
|
+
writeOperation(id, op, home);
|
|
993
|
+
}
|
|
928
994
|
if (abandoned(op)) writeOperation(id, op, home);
|
|
929
995
|
const at = driver.now();
|
|
930
996
|
// Read once, as status and the runner do: every choice and refusal below is decided on it.
|
package/src/hub/board.ts
CHANGED
|
@@ -45,6 +45,8 @@ export interface HistoryEntry {
|
|
|
45
45
|
owner?: PeerId | null;
|
|
46
46
|
/** On a decline or an escalation: the owner it was declined for or taken from (#207); later reroutes leave it out. */
|
|
47
47
|
from?: PeerId;
|
|
48
|
+
/** On a decline: the owner had no capacity right now (#531). Absent on older rows and on a plain refusal, which read as lasting. */
|
|
49
|
+
capacity?: true;
|
|
48
50
|
/** On a hand-over: the new owner's split profile then (#109); absent when it was unknown. */
|
|
49
51
|
profile?: string;
|
|
50
52
|
/** Structured cause recorded at the operation source, never inferred from note text. */
|
|
@@ -302,7 +304,7 @@ export class Board {
|
|
|
302
304
|
|
|
303
305
|
/** The only way a task changes. Validates the move, records who did what, returns the new row. */
|
|
304
306
|
/** A `plan` in the patch replaces the old one whole: a new plan is the owner's current intent, not an addition. */
|
|
305
|
-
update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals" | "sdlc" | "role" | "cancelled">>, note?: string, extra: Pick<HistoryEntry, "profile" | "sweep" | "reason" | "from" | "withheld" | "reviewCost" | "reviewContext" | keyof ReviewerHistory> = {}, expectedFlowRevision?: number): Task {
|
|
307
|
+
update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals" | "sdlc" | "role" | "cancelled">>, note?: string, extra: Pick<HistoryEntry, "profile" | "sweep" | "reason" | "from" | "capacity" | "withheld" | "reviewCost" | "reviewContext" | keyof ReviewerHistory> = {}, expectedFlowRevision?: number): Task {
|
|
306
308
|
const task = this.get(id);
|
|
307
309
|
if (!task) throw new Error(`no task #${id}`);
|
|
308
310
|
if (expectedFlowRevision !== undefined && (!Number.isSafeInteger(expectedFlowRevision) || expectedFlowRevision < 0 || task.history.length !== expectedFlowRevision)) throw new Error("task flow revision changed");
|
|
@@ -318,7 +320,7 @@ export class Board {
|
|
|
318
320
|
}
|
|
319
321
|
if (patch.state === "backlog" && taskHasStarted(task) && !untouchedReturn) throw new Error(`task #${id}: started work cannot return to backlog`);
|
|
320
322
|
const reviewMetadata = reviewerHistory(extra);
|
|
321
|
-
const history = [...task.history, { at: Date.now(), by, event, ...reviewMetadata, ...(note ? { note } : {}), ...(patch.cancelled?.supersededBy ? { supersededBy: patch.cancelled.supersededBy } : {}), ...("owner" in patch ? { owner: next.owner } : {}), ...(extra.profile ? { profile: extra.profile } : {}), ...(extra.sweep ? { sweep: extra.sweep } : {}), ...(extra.reason && TASK_MOVE_REASONS.includes(extra.reason) ? { reason: extra.reason } : {}), ...(extra.from ? { from: extra.from } : {}), ...(extra.withheld && note ? { withheld: true as const } : {}), ...(extra.reviewContext === "fresh" || extra.reviewContext === "continuing" ? { reviewContext: extra.reviewContext } : {}), ...(extra.reviewCost ? { reviewCost: extra.reviewCost } : {}) }];
|
|
323
|
+
const history = [...task.history, { at: Date.now(), by, event, ...reviewMetadata, ...(note ? { note } : {}), ...(patch.cancelled?.supersededBy ? { supersededBy: patch.cancelled.supersededBy } : {}), ...("owner" in patch ? { owner: next.owner } : {}), ...(extra.profile ? { profile: extra.profile } : {}), ...(extra.sweep ? { sweep: extra.sweep } : {}), ...(extra.reason && TASK_MOVE_REASONS.includes(extra.reason) ? { reason: extra.reason } : {}), ...(extra.from ? { from: extra.from } : {}), ...(extra.capacity === true ? { capacity: true as const } : {}), ...(extra.withheld && note ? { withheld: true as const } : {}), ...(extra.reviewContext === "fresh" || extra.reviewContext === "continuing" ? { reviewContext: extra.reviewContext } : {}), ...(extra.reviewCost ? { reviewCost: extra.reviewCost } : {}) }];
|
|
322
324
|
this.taskWrite(() => this.db
|
|
323
325
|
.query("UPDATE tasks SET state = ?, owner = ?, reviewer = ?, reserved = ?, refs = ?, plan = ?, rejections = ?, signals = ?, history = ?, sdlc = ?, task_role = ?, cancelled = ?, updated = ? WHERE id = ?")
|
|
324
326
|
.run(next.state, next.owner, next.reviewer, next.reserved ?? null, JSON.stringify(next.refs), JSON.stringify(next.plan ?? {}), next.rejections, JSON.stringify(next.signals), JSON.stringify(history), next.sdlc ? JSON.stringify(next.sdlc) : null, next.role ?? null, next.cancelled ? JSON.stringify(next.cancelled) : null, Date.now(), id));
|
package/src/hub/bus.ts
CHANGED
|
@@ -230,12 +230,12 @@ export class Bus {
|
|
|
230
230
|
this.note(peer.id, noteLine(HUB, "decision", `your message was not delivered: ${refused}`));
|
|
231
231
|
return refused;
|
|
232
232
|
}
|
|
233
|
-
this.
|
|
234
|
-
if (offRoster.length) this.note(peer.id, noteLine(HUB, "decision", `your message is retained off roster for ${offRoster.join(", ")}; delivered to the other recipients`));
|
|
233
|
+
const published = this.publishResult(env);
|
|
234
|
+
if (offRoster.length && published.dropped !== "hop") this.note(peer.id, noteLine(HUB, "decision", `your message is retained off roster for ${offRoster.join(", ")}; delivered to the other recipients`));
|
|
235
235
|
// The sender's result (issue #107): a turn answer has no caller, so it hears it on its next delivery. It counts as
|
|
236
236
|
// not sent only when nobody got it.
|
|
237
237
|
if (hushed.length) this.note(peer.id, noteLine(HUB, "decision", `your message was not delivered to ${hushed.map((h) => h.peer).join(", ")}: ${hushed[0]!.reason}`));
|
|
238
|
-
return heldBack ? hushed[0]!.reason : undefined;
|
|
238
|
+
return heldBack ? hushed[0]!.reason : published.dropped === "hop" ? published : undefined;
|
|
239
239
|
};
|
|
240
240
|
peer.onFailed = (envs, failure) => {
|
|
241
241
|
// The adapter got the condensed list; what has to come back is what that list replaced.
|
|
@@ -894,6 +894,11 @@ export class Bus {
|
|
|
894
894
|
return (this.queues.get(id) ?? []).filter((e) => e.priority === "important").length;
|
|
895
895
|
}
|
|
896
896
|
|
|
897
|
+
/** The oldest `important` envelope still waiting in a queue, for the busy-peer notice (#572 AC1); a read, never a delivery. */
|
|
898
|
+
oldestQueuedImportantAt(id: PeerId): number | undefined {
|
|
899
|
+
return (this.queues.get(id) ?? []).reduce<number | undefined>((oldest, e) => e.priority !== "important" ? oldest : oldest === undefined ? e.ts : Math.min(oldest, e.ts), undefined);
|
|
900
|
+
}
|
|
901
|
+
|
|
897
902
|
private enqueue(id: PeerId, env: Envelope, front = false): void {
|
|
898
903
|
if (this.withdrawn.has(env.id)) return;
|
|
899
904
|
const queue = this.queues.get(id) ?? [];
|
package/src/hub/checks.ts
CHANGED
|
@@ -15,16 +15,19 @@ const TAIL_CHARS = 4000;
|
|
|
15
15
|
const KEEP_CHARS = 64 * 1024;
|
|
16
16
|
/** How long output may still arrive after the command exited; a descendant that left the group can hold the pipes. */
|
|
17
17
|
const DRAIN_MS = 500;
|
|
18
|
+
/** How long a killed check may wait for its exit and close events before it settles without them (#558). */
|
|
19
|
+
const KILL_GRACE_MS = 1000;
|
|
18
20
|
|
|
19
21
|
/** Runs one check command in the project root, in its own process group so a timeout stops what it started. */
|
|
20
|
-
export function runCheck(command: string, cwd: string, timeoutMs: number, running?: Set<() => void
|
|
22
|
+
export function runCheck(command: string, cwd: string, timeoutMs: number, running?: Set<() => void>, spawnChild: typeof spawn = spawn): Promise<CheckResult> {
|
|
21
23
|
return new Promise((resolve) => {
|
|
22
24
|
let out = "";
|
|
23
25
|
let timedOut = false;
|
|
24
26
|
let interrupted = false;
|
|
25
27
|
let settled = false;
|
|
26
28
|
let exited = false;
|
|
27
|
-
const child =
|
|
29
|
+
const child = spawnChild("sh", ["-c", command], { cwd, env: childEnv(process.env), stdio: ["ignore", "pipe", "pipe"], detached: true });
|
|
30
|
+
let grace: ReturnType<typeof setTimeout> | undefined;
|
|
28
31
|
const killGroup = () => {
|
|
29
32
|
try {
|
|
30
33
|
if (child.pid) process.kill(-child.pid, "SIGKILL");
|
|
@@ -32,10 +35,15 @@ export function runCheck(command: string, cwd: string, timeoutMs: number, runnin
|
|
|
32
35
|
// already gone
|
|
33
36
|
}
|
|
34
37
|
};
|
|
38
|
+
/** A kill that is a verdict (timeout, hub stop) settles even if the child's events never come. */
|
|
39
|
+
const killAndSettle = () => {
|
|
40
|
+
killGroup();
|
|
41
|
+
grace ??= setTimeout(() => done(null), KILL_GRACE_MS);
|
|
42
|
+
};
|
|
35
43
|
const stop = () => {
|
|
36
|
-
if (exited) return; // finished before the hub stopped: its
|
|
44
|
+
if (exited || settled) return; // finished before the hub stopped, or already settled: its result stands and the group may be reused
|
|
37
45
|
interrupted = true;
|
|
38
|
-
|
|
46
|
+
killAndSettle();
|
|
39
47
|
};
|
|
40
48
|
running?.add(stop);
|
|
41
49
|
const keep = (chunk: string) => {
|
|
@@ -48,12 +56,13 @@ export function runCheck(command: string, cwd: string, timeoutMs: number, runnin
|
|
|
48
56
|
}
|
|
49
57
|
const timer = setTimeout(() => {
|
|
50
58
|
timedOut = true;
|
|
51
|
-
|
|
59
|
+
killAndSettle();
|
|
52
60
|
}, timeoutMs);
|
|
53
61
|
const done = (code: number | null) => {
|
|
54
62
|
if (settled) return;
|
|
55
63
|
settled = true;
|
|
56
64
|
clearTimeout(timer);
|
|
65
|
+
clearTimeout(grace);
|
|
57
66
|
running?.delete(stop);
|
|
58
67
|
child.stdout?.destroy();
|
|
59
68
|
child.stderr?.destroy();
|
|
@@ -65,6 +74,7 @@ export function runCheck(command: string, cwd: string, timeoutMs: number, runnin
|
|
|
65
74
|
done(null);
|
|
66
75
|
});
|
|
67
76
|
child.on("exit", (code) => {
|
|
77
|
+
if (settled) return; // the kill grace settled it: the process group id is no longer ours to signal
|
|
68
78
|
exited = true;
|
|
69
79
|
clearTimeout(timer); // it finished in time; only its output may still be on the way
|
|
70
80
|
killGroup(); // a background job it left behind (sh has no job control, so it is still in the group)
|
package/src/hub/conductor.ts
CHANGED
|
@@ -240,7 +240,8 @@ export class Conductor {
|
|
|
240
240
|
if (definitionOf(start.peer, instances)?.kind !== start.peer && !this.hooks.known(start.peer)) throw new PreEffectToolRefusal(`unknown peer: ${start.peer}`);
|
|
241
241
|
const result = await this.hooks.start(start.peer);
|
|
242
242
|
const opened = result && typeof result === "object" && typeof (result as { opened?: unknown }).opened === "string" ? { opened: (result as { opened: string }).opened } : {};
|
|
243
|
-
|
|
243
|
+
const already = result && typeof result === "object" && (result as { already?: unknown }).already === true ? { already: true } : {};
|
|
244
|
+
emit({ peer: start.peer }); return { ...start, ...opened, ...already };
|
|
244
245
|
}
|
|
245
246
|
const peer = peerId(args.peer, true);
|
|
246
247
|
if (!this.hooks.known(peer)) throw new PreEffectToolRefusal(`unknown peer: ${peer}`);
|
package/src/hub/config-apply.ts
CHANGED
|
@@ -11,6 +11,8 @@ import { taskSweepConfig } from "./task-sweep.ts";
|
|
|
11
11
|
import { projectionConfig } from "./text-projection.ts";
|
|
12
12
|
import { validateInstances, type InstancesConfig } from "./instances.ts";
|
|
13
13
|
import { peerRosterConfig } from "./roster.ts";
|
|
14
|
+
import { CHARACTER_IDS } from "../pet/characters.ts";
|
|
15
|
+
import { petPalette } from "../pet/config.ts";
|
|
14
16
|
|
|
15
17
|
export type ApplyClass = "live" | "next use" | "restart";
|
|
16
18
|
export interface ApplyRule { applies: ApplyClass; reason?: string }
|
|
@@ -63,6 +65,7 @@ export function authorizeConfigLeaf(input: ConfigAuthorizationInput): ConfigAuth
|
|
|
63
65
|
if (["inference.enabled", "inference.triage", "review.adaptive"].includes(key) && before === true && after === false) tightening = true;
|
|
64
66
|
if (key === "review.min_reviews" && numeric(before) && numeric(after) && after >= before) tightening = true;
|
|
65
67
|
if (tightening && !input.settledHolderChange) return result(true, false, "semantic tightening");
|
|
68
|
+
if (input.source === "config" && (key === "pet" || key.startsWith("pet."))) return result(true, false, "console-local presentation");
|
|
66
69
|
if (guard?.risk === "safe" && !machine) return result(true, false, "safe registered setting");
|
|
67
70
|
if (input.acceptedFallback && !machine) {
|
|
68
71
|
const person = input.authority === "terminal" || input.authority === "settings" && !!guard;
|
|
@@ -106,12 +109,26 @@ const block = (schema: Record<string, Check>): Check => {
|
|
|
106
109
|
};
|
|
107
110
|
const peerMap = (check: Check) => map(check, SUPPORTED_PEERS);
|
|
108
111
|
const timerSeconds = number(1, 2_147_483_647 / 1000), timerMinutes = number(1, 2_147_483_647 / 60_000);
|
|
112
|
+
const petFields: Record<string, Check> = {
|
|
113
|
+
character: words(CHARACTER_IDS),
|
|
114
|
+
size: words(["line", "mini", "full"]),
|
|
115
|
+
glyphs: words(["auto", "ascii", "blocks"]),
|
|
116
|
+
blink: bool,
|
|
117
|
+
bell: bool,
|
|
118
|
+
palette: (v, k) => { if (!petPalette(v)) fail(k, "unknown choice"); },
|
|
119
|
+
window: bool,
|
|
120
|
+
};
|
|
121
|
+
export const validatePetConfig: Check = (v, k) => {
|
|
122
|
+
if (v === false) return;
|
|
123
|
+
block(petFields)(v, k);
|
|
124
|
+
};
|
|
125
|
+
shapes.set(validatePetConfig, { fields: petFields });
|
|
109
126
|
const nativeValidators = { bool, text, strings, argv, number, words, block };
|
|
110
127
|
const schema: Record<string, Check> = {
|
|
111
128
|
...Object.assign({}, ...ADAPTERS.map(adapter => adapter.configuration?.schema?.(nativeValidators) ?? {})),
|
|
112
129
|
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,
|
|
113
130
|
permission_modes: v => { permissionDefaults(v); }, protect_instruction_files: bool,
|
|
114
|
-
instances: v => { validateInstances(v); },
|
|
131
|
+
instances: v => { validateInstances(v); }, pet: validatePetConfig,
|
|
115
132
|
batch_max: number(1, 100_000, true), batch_ms: number(0, 2_147_483_647), queue_cap: number(1, 1_000_000, true),
|
|
116
133
|
memory: block({ enabled: bool, worker_url: text, inject_tokens: number(0, 1_000_000, true), brief_items: number(0, 10_000, true), native: bool,
|
|
117
134
|
short_ttl_days: number(1, 3650, true), sync_interval_min: number(0, 1440, true), suggest_edges: bool }),
|
|
@@ -142,6 +159,7 @@ const schema: Record<string, Check> = {
|
|
|
142
159
|
review: block({ adaptive: bool, min_reviews: number(1, Number.MAX_SAFE_INTEGER, true), pool: v => { reviewerPoolConfig({ pool: v }, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS); }, rerotate: bool }), recovery: block({ auto_resume_after_crash: bool }),
|
|
143
160
|
capabilities: map(strings), coordination: words(["advisory", "turn-free"]), experiments: block({ stale_notices: words(["drop", "deliver"]) }),
|
|
144
161
|
};
|
|
162
|
+
export const SCHEMA_KEYS: readonly string[] = Object.freeze(Object.keys(schema));
|
|
145
163
|
export function validateRawConfig(raw: unknown, declarations?: InstancesConfig): asserts raw is Record<string, unknown> {
|
|
146
164
|
const value = object(raw, "config");
|
|
147
165
|
const instances = validateInstances(value.instances ?? declarations);
|
|
@@ -169,7 +187,7 @@ const restart: Record<string, string> = {
|
|
|
169
187
|
profile: "Session tool schemas and profile contracts are bound at startup", profile_overrides: "Session tool schemas and profile contracts are bound at startup",
|
|
170
188
|
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",
|
|
171
189
|
};
|
|
172
|
-
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"]);
|
|
190
|
+
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", "pet"]);
|
|
173
191
|
const nextMemory = new Set(["memory.inject_tokens", "memory.brief_items", "memory.suggest_edges"]);
|
|
174
192
|
/** Closed top-level schema; leaf overrides retain the existing session/runtime boundaries. */
|
|
175
193
|
export function configApply(key: string): ApplyRule {
|
package/src/hub/config-reload.ts
CHANGED
|
@@ -35,7 +35,7 @@ export function configSourceTexts(cwd: string): ConfigSourceTexts {
|
|
|
35
35
|
|
|
36
36
|
export const configFingerprint = (texts: ConfigSourceTexts): string => createHash("sha256").update(JSON.stringify(texts)).digest("hex");
|
|
37
37
|
|
|
38
|
-
/** Validate
|
|
38
|
+
/** Validate active routing; bounded legacy tables are ignored as retired settings. */
|
|
39
39
|
function validateRoutingRaw(value: unknown): void {
|
|
40
40
|
const bad = (key: string): never => { throw new Error(`.agenthub/routing.toml: ${key} is invalid or unknown`); };
|
|
41
41
|
const table = (v: unknown, key: string): Record<string, unknown> => {
|
|
@@ -62,12 +62,6 @@ function validateRoutingRaw(value: unknown): void {
|
|
|
62
62
|
else if (Array.isArray(v)) { if (v.length > 1024) bad(key); for (const item of v) openValue(item, key, depth + 1); }
|
|
63
63
|
else for (const item of Object.values(table(v, key))) openValue(item, key, depth + 1);
|
|
64
64
|
};
|
|
65
|
-
const switchyard = (v: unknown, key: string, required: "id" | "type") => {
|
|
66
|
-
for (const [name, item] of Object.entries(table(v, key))) {
|
|
67
|
-
if (!name || name.length > 128 || ["__proto__", "constructor", "prototype"].includes(name)) bad(key);
|
|
68
|
-
const entry = table(item, `${key}.${name}`); text(entry[required], `${key}.${name}.${required}`); openValue(entry, `${key}.${name}`);
|
|
69
|
-
}
|
|
70
|
-
};
|
|
71
65
|
const nativeValidators = { bool, text, strings, argv: strings,
|
|
72
66
|
number: (min = 1, max = Number.MAX_SAFE_INTEGER, _integer = true) => (v: unknown, k: string) => numeric(v, k, min, max),
|
|
73
67
|
words: (values: readonly string[]) => (v: unknown, k: string) => choice(v, k, values),
|
|
@@ -75,7 +69,7 @@ function validateRoutingRaw(value: unknown): void {
|
|
|
75
69
|
};
|
|
76
70
|
closed(value, "routing", {
|
|
77
71
|
...Object.assign({}, ...ADAPTERS.map(adapter => adapter.configuration?.routingSchema?.(nativeValidators) ?? {})),
|
|
78
|
-
targets:
|
|
72
|
+
targets: openValue, routes: openValue,
|
|
79
73
|
// parseRouting delegates these tables to the existing parseHubRoutes validator.
|
|
80
74
|
hub_routes: (v, k) => { table(v, k); },
|
|
81
75
|
stay_switch: (v, k) => choice(v, k, ["off", "shadow", "enforce"]), max_switch_prefill_tokens: numeric,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
3
|
import { projectContext } from "./project.ts";
|
|
4
4
|
|
|
5
5
|
export function stateDirFor(cwd: string): string {
|
|
@@ -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 = ["native_hook_launch", "task_cancel", "recovery_deferred_hub", "peer_instances", "release_trains", "reviewer_pool", "native_tui_launch"] as const;
|
|
11
|
+
export const DAEMON_FEATURES = ["native_hook_launch", "task_cancel", "recovery_deferred_hub", "peer_instances", "release_trains", "reviewer_pool", "native_tui_launch", "terminal_owner", "cli_audit_delegated"] 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
|
|
|
@@ -18,6 +18,10 @@ export interface Hello {
|
|
|
18
18
|
peer?: string;
|
|
19
19
|
/** `peer` only: false when the session cannot show channel pushes; its queue then waits for `inbox` (issue #205). */
|
|
20
20
|
channel?: boolean;
|
|
21
|
+
/** Managed Claude launch identity; absent for plain or pre-daemon sessions. */
|
|
22
|
+
launchId?: string;
|
|
23
|
+
/** Reclaim after replacement, never an intentional initial takeover. */
|
|
24
|
+
standby?: boolean;
|
|
21
25
|
projectId?: string;
|
|
22
26
|
instanceId?: string;
|
|
23
27
|
projectRoot?: string;
|
|
@@ -56,9 +60,15 @@ export function readControl(stateDir: string): ControlDescriptor | undefined {
|
|
|
56
60
|
}
|
|
57
61
|
}
|
|
58
62
|
|
|
63
|
+
const instancePins = new Map<string, { instanceId: string }>();
|
|
64
|
+
|
|
59
65
|
/** One authenticated control connection. `request` correlates replies by `rid`; everything else goes to `onPush`. */
|
|
60
66
|
export class ControlClient {
|
|
61
67
|
features: readonly string[] = [];
|
|
68
|
+
private welcomeInstanceId?: string;
|
|
69
|
+
/** Identity from the authenticated welcome, never the unauthenticated descriptor alone. */
|
|
70
|
+
get instanceId(): string | undefined { return this.welcomeInstanceId; }
|
|
71
|
+
get isOpen(): boolean { return this.ws.readyState === WebSocket.OPEN; }
|
|
62
72
|
private nextRid = 1;
|
|
63
73
|
private readonly pending = new Map<number, (msg: any) => void>();
|
|
64
74
|
onPush: (msg: any) => void = () => {};
|
|
@@ -67,11 +77,23 @@ export class ControlClient {
|
|
|
67
77
|
|
|
68
78
|
private constructor(private readonly ws: WebSocket) {}
|
|
69
79
|
|
|
80
|
+
/** Scope all helper-module connections in one CLI invocation to its authenticated audit consumer. */
|
|
81
|
+
static pinInstance(stateDir: string, instanceId: string): () => void {
|
|
82
|
+
const key = resolve(stateDir), pin = { instanceId };
|
|
83
|
+
if (!instanceId || instancePins.has(key)) throw new Error("control instance is already pinned or unavailable");
|
|
84
|
+
instancePins.set(key, pin);
|
|
85
|
+
return () => { if (instancePins.get(key) === pin) instancePins.delete(key); };
|
|
86
|
+
}
|
|
87
|
+
|
|
70
88
|
/** `protocol` is used only by the upgrade coordinator for an authenticated, explicitly supported source contract. */
|
|
71
89
|
static connect(stateDir: string, hello: Hello, timeoutMs = 3000, protocol = PROTOCOL): Promise<ControlClient> {
|
|
72
90
|
if (!RECOVERY_SOURCE_PROTOCOLS.includes(protocol as (typeof RECOVERY_SOURCE_PROTOCOLS)[number])) return Promise.reject(new Error(`unsupported recovery source protocol ${protocol}`));
|
|
73
91
|
const control = readControl(stateDir);
|
|
74
92
|
if (!control) return Promise.reject(new Error(`no hub running for ${stateDir} (run: ahub up)`));
|
|
93
|
+
const pin = instancePins.get(resolve(stateDir));
|
|
94
|
+
if (pin && (control.instanceId !== pin.instanceId || hello.instanceId !== undefined && hello.instanceId !== pin.instanceId)) {
|
|
95
|
+
return Promise.reject(new Error("delegated audit consumer changed; refusing a connection to its replacement"));
|
|
96
|
+
}
|
|
75
97
|
if (control.protocol !== undefined && control.protocol !== protocol) {
|
|
76
98
|
return Promise.reject(Object.assign(new Error(`wire version mismatch: hub speaks ${control.protocol}, CLI speaks ${protocol}; stop it with its matching CLI, then upgrade and restart`), { code: 4426 }));
|
|
77
99
|
}
|
|
@@ -80,7 +102,7 @@ export class ControlClient {
|
|
|
80
102
|
(hello.projectRoot && hello.projectRoot !== control.cwd)) {
|
|
81
103
|
return Promise.reject(Object.assign(new Error("hub project or instance does not match the selected project"), { code: 4404 }));
|
|
82
104
|
}
|
|
83
|
-
const expected = { projectId: hello.projectId ?? control.projectId, instanceId: hello.instanceId ?? control.instanceId,
|
|
105
|
+
const expected = { projectId: hello.projectId ?? control.projectId, instanceId: pin?.instanceId ?? hello.instanceId ?? control.instanceId,
|
|
84
106
|
projectRoot: hello.projectRoot ?? control.cwd };
|
|
85
107
|
return new Promise((resolve, reject) => {
|
|
86
108
|
const ws = new WebSocket(control.url);
|
|
@@ -113,6 +135,7 @@ export class ControlClient {
|
|
|
113
135
|
return refuse(Object.assign(new Error("connected hub has a different project or instance"), { code: 4404 }));
|
|
114
136
|
}
|
|
115
137
|
client.features = Object.freeze(Array.isArray(reply.features) ? reply.features.filter((feature: unknown): feature is string => typeof feature === "string") : []);
|
|
138
|
+
client.welcomeInstanceId = typeof reply.instanceId === "string" && reply.instanceId ? reply.instanceId : undefined;
|
|
116
139
|
clearTimeout(timer);
|
|
117
140
|
resolve(client);
|
|
118
141
|
});
|