@staix/agent-hub 0.12.16 → 0.12.18
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 +14 -0
- package/README.md +4 -4
- package/docs/agent-notes/adapters.md +1 -0
- package/docs/agent-notes/bus.md +2 -0
- package/docs/agent-notes/tasks.md +3 -1
- package/docs/agent-notes/tests.md +1 -0
- package/docs/events.md +37 -3
- package/docs/operations.md +149 -10
- package/docs/quickstart.md +3 -3
- package/docs/security.md +28 -1
- package/docs/smoke.md +93 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +176 -1
- package/docs/verification/2026-10-09-agent-shell-t0.md +123 -0
- package/docs/verified.json +26 -17
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +17 -5
- package/src/adapters/acp.ts +2 -2
- package/src/adapters/claude-channel.ts +3 -2
- package/src/adapters/codex-appserver.ts +3 -2
- package/src/adapters/local-worker.ts +16 -12
- package/src/cli/console-state.ts +279 -0
- package/src/cli/console.ts +224 -0
- package/src/cli/facts-hook.ts +8 -1
- package/src/cli/identity-audit.ts +66 -0
- package/src/cli/identity.ts +54 -0
- package/src/cli/launch.ts +15 -2
- package/src/cli/main.ts +75 -32
- package/src/cli/tail-render.ts +17 -0
- package/src/cli/upgrade-runtime.ts +1 -1
- package/src/hub/attribution.ts +20 -0
- package/src/hub/board.ts +6 -2
- package/src/hub/bus.ts +88 -10
- package/src/hub/child-process.ts +11 -0
- package/src/hub/conductor.ts +196 -0
- package/src/hub/control-client.ts +3 -3
- package/src/hub/daemon.ts +388 -51
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +10 -4
- package/src/hub/hub-tools.ts +13 -0
- package/src/hub/report.ts +162 -4
- package/src/hub/supervision.ts +152 -0
- package/src/hub/tasks.ts +30 -15
- package/src/hub/usage.ts +37 -2
- package/src/pi/launch.ts +2 -1
package/src/hub/bus.ts
CHANGED
|
@@ -17,6 +17,8 @@ export type BusEvent =
|
|
|
17
17
|
| { t: "state"; peer: PeerId; state: PeerState };
|
|
18
18
|
|
|
19
19
|
export interface BusOptions {
|
|
20
|
+
/** Observe original envelopes immediately before a transport starts its turn. */
|
|
21
|
+
onDeliver?: (peer: PeerId, originals: Envelope[]) => void;
|
|
20
22
|
retryMs: number;
|
|
21
23
|
/** A queue this long is delivered without waiting for the batch window. */
|
|
22
24
|
batchMax: number;
|
|
@@ -82,6 +84,8 @@ function resolveTo(to: PeerId[], originals: Envelope[] | undefined): PeerId[] {
|
|
|
82
84
|
|
|
83
85
|
const SEEN_CAP = 2048;
|
|
84
86
|
const MAX_ATTEMPTS = 3;
|
|
87
|
+
// ponytail: one digest carries at most ten originals; larger supervision windows split into bounded deliveries.
|
|
88
|
+
// Revisit this ceiling after measuring native supervision cost, rather than bypassing the delivery limit.
|
|
85
89
|
const DIGEST_MAX = 10;
|
|
86
90
|
const NOTES_KEPT = 10;
|
|
87
91
|
|
|
@@ -100,6 +104,8 @@ export class Bus {
|
|
|
100
104
|
private readonly manualPaused = new Set<PeerId>();
|
|
101
105
|
private readonly timers = new Map<PeerId, ReturnType<typeof setTimeout>>();
|
|
102
106
|
private readonly draining = new Set<PeerId>();
|
|
107
|
+
/** Still pending while an asynchronous condensation runs; never includes native dispatches. */
|
|
108
|
+
private readonly preparing = new Map<PeerId, Envelope[]>();
|
|
103
109
|
private readonly seen = new Map<string, Envelope>();
|
|
104
110
|
private readonly taps = new Set<(e: BusEvent) => void>();
|
|
105
111
|
private readonly withdrawn = new Set<string>();
|
|
@@ -119,6 +125,12 @@ export class Bus {
|
|
|
119
125
|
* and is never corrected once the queue empties (measured: status.json kept `queued 4` on an empty queue).
|
|
120
126
|
*/
|
|
121
127
|
onQueues?: () => void;
|
|
128
|
+
/** Actual transport admission, using originals even when a digest was condensed. Never an enqueue metric. */
|
|
129
|
+
onDelivered?: (peer: PeerId, originals: Envelope[]) => void;
|
|
130
|
+
/** Before deliver's synchronous busy transition, with originals rather than a condensed digest. */
|
|
131
|
+
onDeliver?: (peer: PeerId, originals: Envelope[]) => void;
|
|
132
|
+
/** Synchronous native failure observation, before queue callbacks can finish turn metrics. */
|
|
133
|
+
onDeliveryFailed?: (peer: PeerId) => void;
|
|
122
134
|
private recoveryHeld = false;
|
|
123
135
|
private steering = 0;
|
|
124
136
|
private condensing = 0;
|
|
@@ -127,6 +139,7 @@ export class Bus {
|
|
|
127
139
|
|
|
128
140
|
constructor(opts: Partial<BusOptions> = {}) {
|
|
129
141
|
this.opts = { ...DEFAULT_BUS, ...opts };
|
|
142
|
+
this.onDeliver = opts.onDeliver;
|
|
130
143
|
this.journal = opts.journal;
|
|
131
144
|
if (this.journal) {
|
|
132
145
|
const state = this.journal.snapshot();
|
|
@@ -389,7 +402,9 @@ export class Bus {
|
|
|
389
402
|
const id = receipt.id;
|
|
390
403
|
const before = this.journal.get(id);
|
|
391
404
|
const state: JournalDeliveryState = receipt.state === "accepted" ? "accepted" : receipt.state === "completed" ? "completed" : receipt.state === "failed_safe" ? "failed" : "needs_review";
|
|
405
|
+
if (state === "failed" || state === "needs_review") this.deliveryFailed(peer);
|
|
392
406
|
this.journal.transition(id, state, receipt.reason);
|
|
407
|
+
if (before?.state === "dispatching" && (state === "accepted" || state === "completed")) this.delivered(peer, before.originals);
|
|
393
408
|
if (state === "failed") {
|
|
394
409
|
if (before) this.failed(peer, before.originals, before.originals.some((e) => e.private) ? "private delivery failed" : receipt.reason);
|
|
395
410
|
}
|
|
@@ -525,7 +540,8 @@ export class Bus {
|
|
|
525
540
|
this.activeDeliveries.set(deliveryId, id);
|
|
526
541
|
if (this.journal) this.durableHandoff(id, deliveryId, [env], [env]);
|
|
527
542
|
const steerResult = this.journal ? peer.steer([env], deliveryId) : peer.steer([env]);
|
|
528
|
-
steerResult.then(() => {}).catch(() => {
|
|
543
|
+
steerResult.then(() => { this.delivered(id, [env]); }).catch(() => {
|
|
544
|
+
this.deliveryFailed(id);
|
|
529
545
|
if (this.journal) this.journal.transition(deliveryId, "needs_review", "adapter steering outcome is uncertain");
|
|
530
546
|
this.activeDeliveries.delete(deliveryId);
|
|
531
547
|
if (!this.journal) this.enqueue(id, env, true);
|
|
@@ -570,8 +586,7 @@ export class Bus {
|
|
|
570
586
|
* dropped if the refused steer tries to queue it later.
|
|
571
587
|
*/
|
|
572
588
|
withdraw(envelopeId: string): boolean {
|
|
573
|
-
this.
|
|
574
|
-
if (this.withdrawn.size > 256) this.withdrawn.delete(this.withdrawn.values().next().value as string);
|
|
589
|
+
this.markWithdrawn(envelopeId);
|
|
575
590
|
let removed = false;
|
|
576
591
|
for (const queue of this.queues.values()) {
|
|
577
592
|
const i = queue.findIndex((e) => e.id === envelopeId);
|
|
@@ -582,6 +597,49 @@ export class Bus {
|
|
|
582
597
|
return removed;
|
|
583
598
|
}
|
|
584
599
|
|
|
600
|
+
/** Revoke pending feed copies only; accepted/dispatching journal records and native turns are untouched. */
|
|
601
|
+
revokeSupervision(peer: PeerId): number {
|
|
602
|
+
const queue = this.queues.get(peer);
|
|
603
|
+
if (!queue && !this.preparing.has(peer)) return 0;
|
|
604
|
+
let removed = 0;
|
|
605
|
+
const discarded = new Set<string>();
|
|
606
|
+
for (let i = (queue?.length ?? 0) - 1; i >= 0; i--) {
|
|
607
|
+
const env = queue![i]!;
|
|
608
|
+
if (env.from !== HUB || !env.refs?.supervision) continue;
|
|
609
|
+
queue!.splice(i, 1);
|
|
610
|
+
removed++;
|
|
611
|
+
discarded.add(env.id);
|
|
612
|
+
this.markWithdrawn(env.id);
|
|
613
|
+
this.discardQueued(peer, env, "supervision role or feed revoked");
|
|
614
|
+
}
|
|
615
|
+
for (const env of this.preparing.get(peer) ?? []) {
|
|
616
|
+
if (env.from !== HUB || !env.refs?.supervision || discarded.has(env.id)) continue;
|
|
617
|
+
this.markWithdrawn(env.id);
|
|
618
|
+
this.discardQueued(peer, env, "supervision role or feed revoked during preparation");
|
|
619
|
+
removed++;
|
|
620
|
+
}
|
|
621
|
+
if (removed) { this.persist(); this.onQueues?.(); }
|
|
622
|
+
return removed;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
private discardQueued(peer: PeerId, env: Envelope, reason: string): void {
|
|
626
|
+
if (this.journal) this.pendingOutcomes.push({ id: crypto.randomUUID(), peer, state: "discarded", createdAt: Date.now(), originals: [env], out: [], reason });
|
|
627
|
+
this.emit({ t: "stale", env, peer, reason });
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
private delivered(peer: PeerId, originals: Envelope[]): void {
|
|
631
|
+
try { this.onDelivered?.(peer, originals); } catch { /* observation must never affect delivery settlement */ }
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
private deliveryFailed(peer: PeerId): void {
|
|
635
|
+
try { this.onDeliveryFailed?.(peer); } catch { /* observation must never affect delivery settlement */ }
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
private markWithdrawn(id: string): void {
|
|
639
|
+
this.withdrawn.add(id);
|
|
640
|
+
if (this.withdrawn.size > 256) this.withdrawn.delete(this.withdrawn.values().next().value as string);
|
|
641
|
+
}
|
|
642
|
+
|
|
585
643
|
queued(id: PeerId): number {
|
|
586
644
|
return this.queues.get(id)?.length ?? 0;
|
|
587
645
|
}
|
|
@@ -599,8 +657,23 @@ export class Bus {
|
|
|
599
657
|
if (this.withdrawn.has(env.id)) return;
|
|
600
658
|
const queue = this.queues.get(id) ?? [];
|
|
601
659
|
this.queues.set(id, queue);
|
|
602
|
-
|
|
603
|
-
|
|
660
|
+
const key = env.from === HUB && env.refs?.supervision ? env.refs.supervisionKey : undefined;
|
|
661
|
+
const replace = !front && key ? queue.findIndex((old) => old.from === HUB && old.refs?.supervision && old.refs.supervisionKey === key && !this.attempts.has(`${id}:${old.id}`)) : -1;
|
|
662
|
+
const preparing = !front && key ? this.preparing.get(id)?.find((old) => old.from === HUB && old.refs?.supervision && old.refs.supervisionKey === key && !this.attempts.has(`${id}:${old.id}`) && !this.withdrawn.has(old.id)) : undefined;
|
|
663
|
+
let queuedEnv = preparing ? { ...env, ts: Math.min(preparing.ts, env.ts) } : env;
|
|
664
|
+
if (replace >= 0) {
|
|
665
|
+
const old = queue[replace]!;
|
|
666
|
+
// Keep the first milestone's deadline, so continuous updates cannot create another batching window.
|
|
667
|
+
queuedEnv = { ...queuedEnv, ts: Math.min(old.ts, queuedEnv.ts) };
|
|
668
|
+
queue[replace] = queuedEnv;
|
|
669
|
+
this.markWithdrawn(old.id);
|
|
670
|
+
this.discardQueued(id, old, "superseded supervision milestone");
|
|
671
|
+
} else if (front) queue.unshift(queuedEnv);
|
|
672
|
+
else queue.push(queuedEnv);
|
|
673
|
+
if (preparing && !this.withdrawn.has(preparing.id)) {
|
|
674
|
+
this.markWithdrawn(preparing.id);
|
|
675
|
+
this.discardQueued(id, preparing, "superseded supervision milestone during preparation");
|
|
676
|
+
}
|
|
604
677
|
if (queue.length > this.opts.queueCap) {
|
|
605
678
|
const victim = queue.findIndex((e) => e.priority !== "important");
|
|
606
679
|
const [lost] = queue.splice(victim === -1 ? 0 : victim, 1);
|
|
@@ -614,7 +687,8 @@ export class Bus {
|
|
|
614
687
|
|
|
615
688
|
/** 0 = deliver now; otherwise how long the oldest envelope still has to wait for company. */
|
|
616
689
|
private wait(queue: Envelope[]): number {
|
|
617
|
-
|
|
690
|
+
const supervisionOnly = queue.every((e) => e.from === HUB && e.refs?.supervision && e.priority === "status");
|
|
691
|
+
if ((!supervisionOnly && queue.length >= this.opts.batchMax) || queue.some((e) => e.priority === "important")) return 0;
|
|
618
692
|
return Math.max(0, queue[0]!.ts + this.opts.batchMs - Date.now());
|
|
619
693
|
}
|
|
620
694
|
|
|
@@ -656,6 +730,7 @@ export class Bus {
|
|
|
656
730
|
// may checkpoint during that wait; it must not checkpoint the batch out of existence.
|
|
657
731
|
if (!this.journal) this.prefaces.delete(id);
|
|
658
732
|
const batch = this.take(id, queue, !this.journal);
|
|
733
|
+
this.preparing.set(id, batch);
|
|
659
734
|
const delivery = preface ? [preface, ...batch] : batch;
|
|
660
735
|
const mayCondense = this.opts.condense && !delivery.some((e) => e.priority === "important");
|
|
661
736
|
this.condensing += mayCondense ? 1 : 0;
|
|
@@ -679,11 +754,13 @@ export class Bus {
|
|
|
679
754
|
for (const e of out) if (!this.seen.has(e.id)) this.seen.set(e.id, e);
|
|
680
755
|
while (this.seen.size > SEEN_CAP) this.seen.delete(this.seen.keys().next().value as string);
|
|
681
756
|
this.lastDelivery.set(id, { out, originals: delivery });
|
|
757
|
+
this.preparing.delete(id); // from here on the transport/journal owns settlement
|
|
682
758
|
const deliveryId = crypto.randomUUID();
|
|
683
759
|
if (this.journal) { this.durableHandoff(id, deliveryId, delivery, out); this.activeDeliveries.set(deliveryId, id); }
|
|
684
760
|
try {
|
|
761
|
+
try { this.onDeliver?.(id, delivery); } catch { /* observation must never affect delivery */ }
|
|
685
762
|
if (this.journal) await peer.deliver(out, deliveryId);
|
|
686
|
-
else await peer.deliver(out);
|
|
763
|
+
else { await peer.deliver(out); this.delivered(id, delivery); }
|
|
687
764
|
} catch {
|
|
688
765
|
if (this.journal) this.uncertain(id, deliveryId, "adapter delivery outcome is uncertain");
|
|
689
766
|
else this.failed(id, delivery);
|
|
@@ -691,11 +768,11 @@ export class Bus {
|
|
|
691
768
|
}
|
|
692
769
|
}
|
|
693
770
|
} catch { this.storageError = "delivery journal unavailable"; }
|
|
694
|
-
finally { this.draining.delete(id); this.onQueues?.(); }
|
|
771
|
+
finally { this.preparing.delete(id); this.draining.delete(id); this.onQueues?.(); }
|
|
695
772
|
}
|
|
696
773
|
|
|
697
774
|
private isRelevant(id: PeerId, env: Envelope): boolean {
|
|
698
|
-
return this.opts.relevant?.(id, env) ?? true;
|
|
775
|
+
return !this.withdrawn.has(env.id) && (this.opts.relevant?.(id, env) ?? true);
|
|
699
776
|
}
|
|
700
777
|
|
|
701
778
|
/**
|
|
@@ -704,7 +781,7 @@ export class Bus {
|
|
|
704
781
|
* this recipient's copy goes: the other queues keep theirs, and no other delivery record changes.
|
|
705
782
|
*/
|
|
706
783
|
private dropIrrelevant(id: PeerId, queue: Envelope[]): boolean {
|
|
707
|
-
if (!this.opts.relevant) return false;
|
|
784
|
+
if (!this.opts.relevant && !this.withdrawn.size) return false;
|
|
708
785
|
let dropped = false;
|
|
709
786
|
for (let i = queue.length - 1; i >= 0; i--) {
|
|
710
787
|
const env = queue[i]!;
|
|
@@ -757,6 +834,7 @@ export class Bus {
|
|
|
757
834
|
}
|
|
758
835
|
|
|
759
836
|
private failed(id: PeerId, envs: Envelope[], reason = "delivery failed without an error detail"): void {
|
|
837
|
+
if (!this.journal) this.deliveryFailed(id);
|
|
760
838
|
this.lastFailure.set(id, reason.replace(/\s+/g, " ").slice(0, 300));
|
|
761
839
|
if (envs.some((env) => !(env.from === HUB && env.kind === "presence") && (this.attempts.get(`${id}:${env.id}`) ?? 0) + 1 >= MAX_ATTEMPTS)) {
|
|
762
840
|
this.failureStreak.set(id, (this.failureStreak.get(id) ?? 0) + 1);
|
package/src/hub/child-process.ts
CHANGED
|
@@ -11,6 +11,17 @@ export function childEnv(source: NodeJS.ProcessEnv = process.env): NodeJS.Proces
|
|
|
11
11
|
return env;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
/** Every native child owns its marker; caller vendor markers must not leak across peers (#193). */
|
|
15
|
+
export function peerChildEnv(peer: string, source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
|
16
|
+
const env = childEnv(source);
|
|
17
|
+
delete env.CLAUDECODE;
|
|
18
|
+
delete env.CLAUDE_CODE_SESSION_ID;
|
|
19
|
+
delete env.CODEX_THREAD_ID;
|
|
20
|
+
delete env.AGENTHUB_PEER_ID;
|
|
21
|
+
env.AGENTHUB_PEER_ID = peer;
|
|
22
|
+
return env;
|
|
23
|
+
}
|
|
24
|
+
|
|
14
25
|
/** One process: its identity is the pid with its start time (`lstart`, which exec keeps); group and command are evidence. */
|
|
15
26
|
export interface ProcRow {
|
|
16
27
|
pid: number;
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import type { Task } from "./board.ts";
|
|
3
|
+
import { CONDUCTOR_TOOL_NAMES } from "./hub-tools.ts";
|
|
4
|
+
import type { HubEvent } from "./events.ts";
|
|
5
|
+
import type { SupervisionFeed } from "./supervision.ts";
|
|
6
|
+
import type { Budget } from "./budget.ts";
|
|
7
|
+
|
|
8
|
+
/** The production ProgressObserver sink forwards structured verdicts, never its observations/reasoning. */
|
|
9
|
+
export function conductorProgressSink(record: (event: HubEvent) => void, feed: Pick<SupervisionFeed, "milestone">): (event: HubEvent) => void {
|
|
10
|
+
return event => { record(event); if (event.type === "stuck") feed.milestone(event.task, "stuck", { reason: event.category }); };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function publicPeerBudget(status: ReturnType<Budget["status"]>, nameable: (text: string) => boolean): Record<string, unknown> {
|
|
14
|
+
return Object.fromEntries(Object.entries(status).map(([peer, value]) => [peer, {
|
|
15
|
+
windows: value.windows.map(window => ({ id: window.id, used: window.used, at: window.at, stale: window.stale,
|
|
16
|
+
...(window.resetsAt === undefined ? {} : { resetsAt: window.resetsAt }),
|
|
17
|
+
...(window.windowMins === undefined ? {} : { windowMins: window.windowMins }),
|
|
18
|
+
source: nameable(window.source) ? window.source : "[quota source withheld]",
|
|
19
|
+
})),
|
|
20
|
+
...(value.paused ? { paused: { since: value.paused.since, resetsAt: value.paused.resetsAt, reason: "quota" } } : {}),
|
|
21
|
+
}]));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const PEER = /^[a-z][a-z0-9-]{0,31}$/;
|
|
25
|
+
const peerId = (value: unknown): string => {
|
|
26
|
+
if (typeof value !== "string" || /\s/.test(value) || !PEER.test(value) || ["user", "hub", "digest"].includes(value)) throw new Error("peer must be a valid agent peer id");
|
|
27
|
+
return value;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** Validate before listening; missing configuration grants nobody the conductor role. */
|
|
31
|
+
export function conductorPeer(roles: unknown): string | null {
|
|
32
|
+
if (roles === undefined) return null;
|
|
33
|
+
if (!roles || typeof roles !== "object" || Array.isArray(roles)) throw new Error("roles must be an object of peer role lists");
|
|
34
|
+
let conductor: string | null = null;
|
|
35
|
+
for (const [peer, list] of Object.entries(roles)) {
|
|
36
|
+
peerId(peer);
|
|
37
|
+
if (!Array.isArray(list) || list.some((r) => typeof r !== "string")) throw new Error(`roles.${peer} must be a list of role names`);
|
|
38
|
+
if (!list.includes("conductor")) continue;
|
|
39
|
+
if (conductor !== null) throw new Error("roles may name at most one conductor peer");
|
|
40
|
+
conductor = peer;
|
|
41
|
+
}
|
|
42
|
+
return conductor;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The role check always comes first; default-allow capabilities cannot grant a role. */
|
|
46
|
+
export function requireConductor(peer: string, roles: unknown, capabilities: Record<string, unknown> = {}, assign = false): void {
|
|
47
|
+
if (conductorPeer(roles) !== peer) throw new Error("this operation requires the explicit conductor role");
|
|
48
|
+
if (assign && Object.hasOwn(capabilities, peer)) {
|
|
49
|
+
const caps = capabilities[peer];
|
|
50
|
+
if (!Array.isArray(caps) || !caps.includes("assign")) throw new Error(`${peer} requires assign capability for this operation`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ConductorHold { peer: string; actor: string; since: number }
|
|
55
|
+
|
|
56
|
+
/** Actor ownership survives restart. No titles, plans, summaries or memory writes. */
|
|
57
|
+
export class ConductorHolds {
|
|
58
|
+
private readonly db: Database;
|
|
59
|
+
constructor(dbPath: string, private readonly now: () => number = Date.now) {
|
|
60
|
+
this.db = new Database(dbPath, { create: true });
|
|
61
|
+
this.db.run("CREATE TABLE IF NOT EXISTS conductor_holds (peer TEXT PRIMARY KEY, actor TEXT NOT NULL, since INTEGER NOT NULL)");
|
|
62
|
+
this.db.run("CREATE TABLE IF NOT EXISTS supervision_rounds (peer TEXT PRIMARY KEY, signature TEXT NOT NULL)");
|
|
63
|
+
}
|
|
64
|
+
get(peer: string): ConductorHold | null {
|
|
65
|
+
return this.db.query("SELECT peer, actor, since FROM conductor_holds WHERE peer = ?").get(peer) as ConductorHold | null;
|
|
66
|
+
}
|
|
67
|
+
has(peer: string): boolean { return this.get(peer) !== null; }
|
|
68
|
+
list(): ConductorHold[] { return this.db.query("SELECT peer, actor, since FROM conductor_holds ORDER BY peer").all() as ConductorHold[]; }
|
|
69
|
+
readRound(peer: string): string | undefined { return (this.db.query("SELECT signature FROM supervision_rounds WHERE peer = ?").get(peer) as { signature: string } | null)?.signature; }
|
|
70
|
+
writeRound(peer: string, signature: string): void { this.db.query("INSERT INTO supervision_rounds (peer, signature) VALUES (?, ?) ON CONFLICT(peer) DO UPDATE SET signature = excluded.signature").run(peer, signature); }
|
|
71
|
+
hold(peer: string, actor: string): ConductorHold {
|
|
72
|
+
peerId(peer); peerId(actor);
|
|
73
|
+
// INSERT OR IGNORE cannot take ownership of an earlier conductor's hold.
|
|
74
|
+
this.db.query("INSERT OR IGNORE INTO conductor_holds (peer, actor, since) VALUES (?, ?, ?)").run(peer, actor, this.now());
|
|
75
|
+
const hold = this.get(peer)!;
|
|
76
|
+
if (hold.actor !== actor) throw new Error(`peer ${peer} is held by another conductor`);
|
|
77
|
+
return hold;
|
|
78
|
+
}
|
|
79
|
+
release(peer: string, actor: string): ConductorHold {
|
|
80
|
+
peerId(peer); peerId(actor);
|
|
81
|
+
const hold = this.get(peer);
|
|
82
|
+
if (!hold || hold.actor !== actor) throw new Error("you may release only a conductor hold you placed");
|
|
83
|
+
this.db.query("DELETE FROM conductor_holds WHERE peer = ? AND actor = ?").run(peer, actor);
|
|
84
|
+
return hold;
|
|
85
|
+
}
|
|
86
|
+
/** The daemon must authenticate a human console and check budget/local constraints first. */
|
|
87
|
+
releaseByOperator(peer: string): ConductorHold | null {
|
|
88
|
+
peerId(peer);
|
|
89
|
+
const hold = this.get(peer);
|
|
90
|
+
if (hold) this.db.query("DELETE FROM conductor_holds WHERE peer = ?").run(peer);
|
|
91
|
+
return hold;
|
|
92
|
+
}
|
|
93
|
+
close(): void { this.db.close(); }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export type ConductorStart = { peer: "local" | "kimi" | "pi"; mode: "headless" } | { peer: "claude" | "codex" | "pi"; mode: "tui"; command: string };
|
|
97
|
+
/** Preview commands come from the same launch planner the CLI uses, never caller-supplied shell text. */
|
|
98
|
+
export function conductorStart(peer: unknown, mode: unknown, preview: (peer: "claude" | "codex" | "pi") => string): ConductorStart {
|
|
99
|
+
if (mode !== undefined && mode !== "headless" && mode !== "tui") throw new Error("mode must be headless or tui");
|
|
100
|
+
if (peer === "claude" || peer === "codex" || (peer === "pi" && mode === "tui")) return { peer, mode: "tui", command: preview(peer) };
|
|
101
|
+
if ((peer === "local" || peer === "kimi" || peer === "pi") && mode !== "tui") return { peer, mode: "headless" };
|
|
102
|
+
throw new Error("only local, kimi and headless pi may be started by the conductor");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const numberOrNull = (v: unknown): number | null => typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : null;
|
|
106
|
+
export interface ConductorStatusInput {
|
|
107
|
+
peers: Array<{ peer: string; state?: string; attached?: boolean; queued?: number; needsReview?: number; manualHeld?: boolean; hold?: ConductorHold | null; budgetPause?: { since?: number; resetsAt?: number } | null; windows?: Array<{ id: string; used?: number; resetsAt?: number; at?: number; source?: string }> }>;
|
|
108
|
+
taskCounts: Record<string, number>;
|
|
109
|
+
approvals: Array<{ peer: string; tool?: string; at?: number }>;
|
|
110
|
+
}
|
|
111
|
+
/** Explicit projection, never spreads a queue, permission request or quota pause containing private text. */
|
|
112
|
+
export function publicConductorStatus(input: ConductorStatusInput, now = Date.now()): Record<string, unknown> {
|
|
113
|
+
return {
|
|
114
|
+
peers: input.peers.map((p) => ({
|
|
115
|
+
peer: p.peer, state: p.state ?? "unknown", attached: p.attached ?? null,
|
|
116
|
+
queued: numberOrNull(p.queued), needsReview: numberOrNull(p.needsReview),
|
|
117
|
+
manualHold: p.manualHeld === undefined ? null : { held: p.manualHeld, actor: p.manualHeld ? "user" : null },
|
|
118
|
+
conductorHold: p.hold ? { actor: p.hold.actor, since: numberOrNull(p.hold.since) } : null,
|
|
119
|
+
budgetPause: p.budgetPause ? { since: numberOrNull(p.budgetPause.since), resetsAt: numberOrNull(p.budgetPause.resetsAt) } : null,
|
|
120
|
+
windows: p.windows === undefined ? null : p.windows.map((w) => ({ id: w.id, used: numberOrNull(w.used), resetsAt: numberOrNull(w.resetsAt), at: numberOrNull(w.at), source: w.source ?? null })),
|
|
121
|
+
})),
|
|
122
|
+
taskCounts: Object.fromEntries(Object.entries(input.taskCounts).map(([state, count]) => [state, numberOrNull(count)])),
|
|
123
|
+
approvals: input.approvals.map((a) => ({ peer: a.peer, tool: a.tool ?? null, ageMs: a.at === undefined || !Number.isFinite(a.at) ? null : Math.max(0, now - a.at) })),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** publicView must use Tasks.publicView(task, true), including its screened history. */
|
|
128
|
+
export function publicConductorTask(task: Task, publicView: (task: Task) => Record<string, unknown>): Record<string, unknown> {
|
|
129
|
+
const view = publicView(task);
|
|
130
|
+
if (view.title === "[pii]") return { id: task.id, title: "[pii]", detail: "[pii]", class: task.class, state: task.state, owner: task.owner, reviewer: task.reviewer, history: [] };
|
|
131
|
+
return { ...view, history: Array.isArray(view.history) ? view.history : [] };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface ConductEvent {
|
|
135
|
+
kind: "conduct"; actor: string; action: string; task?: number; peer?: string;
|
|
136
|
+
}
|
|
137
|
+
export interface ConductorHooks {
|
|
138
|
+
roles(): unknown;
|
|
139
|
+
capabilities(): Record<string, unknown>;
|
|
140
|
+
status(): ConductorStatusInput;
|
|
141
|
+
task(id: number): Task | undefined;
|
|
142
|
+
publicView(task: Task): Record<string, unknown>;
|
|
143
|
+
/** These callbacks must call Tasks, never Board.update. */
|
|
144
|
+
assign(actor: string, id: number, peer: string): Promise<unknown>;
|
|
145
|
+
escalate(actor: string, id: number): Promise<unknown>;
|
|
146
|
+
preview(peer: "claude" | "codex" | "pi"): string;
|
|
147
|
+
start(peer: "local" | "kimi" | "pi"): Promise<unknown>;
|
|
148
|
+
known(peer: string): boolean;
|
|
149
|
+
pause(peer: string): void;
|
|
150
|
+
validateRelease?(peer: string): Promise<void>;
|
|
151
|
+
/** Re-check manual, budget, recovery and remaining conductor holds before Bus.resume. */
|
|
152
|
+
release(peer: string): void;
|
|
153
|
+
/** Log and console notice plus events.jsonl, using this ids-only payload. Must not throw. */
|
|
154
|
+
audit(event: ConductEvent): void;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export class Conductor {
|
|
158
|
+
constructor(private readonly holds: ConductorHolds, private readonly hooks: ConductorHooks) {}
|
|
159
|
+
async execute(actor: string, tool: string, args: Record<string, unknown>): Promise<unknown> {
|
|
160
|
+
if (!CONDUCTOR_TOOL_NAMES.has(tool)) throw new Error("unknown conductor tool");
|
|
161
|
+
requireConductor(actor, this.hooks.roles(), this.hooks.capabilities(), tool === "hub_task_assign" || tool === "hub_task_escalate");
|
|
162
|
+
const action = tool.slice(4);
|
|
163
|
+
const emit = (extra: Pick<ConductEvent, "task" | "peer"> = {}) => {
|
|
164
|
+
try { this.hooks.audit({ kind: "conduct", actor, action, ...extra }); } catch { /* never throw after a task or hold write */ }
|
|
165
|
+
};
|
|
166
|
+
if (tool === "hub_status") { const result = publicConductorStatus(this.hooks.status()); emit(); return result; }
|
|
167
|
+
if (tool.startsWith("hub_task_")) {
|
|
168
|
+
if (typeof args.id !== "number" || !Number.isSafeInteger(args.id) || args.id <= 0) throw new Error("id must be a positive integer");
|
|
169
|
+
const task = this.hooks.task(args.id);
|
|
170
|
+
if (!task) throw new Error(`no task #${args.id}`);
|
|
171
|
+
if (tool === "hub_task_show") { const result = publicConductorTask(task, this.hooks.publicView); emit({ task: task.id }); return result; }
|
|
172
|
+
if (tool === "hub_task_assign") {
|
|
173
|
+
const peer = peerId(args.peer);
|
|
174
|
+
await this.hooks.assign(actor, task.id, peer); emit({ task: task.id, peer });
|
|
175
|
+
} else { await this.hooks.escalate(actor, task.id); emit({ task: task.id }); }
|
|
176
|
+
// Task callbacks may return raw objects; always re-read and apply the public view.
|
|
177
|
+
const updated = this.hooks.task(task.id);
|
|
178
|
+
return updated ? publicConductorTask(updated, this.hooks.publicView) : { id: task.id };
|
|
179
|
+
}
|
|
180
|
+
if (tool === "hub_peer_start") {
|
|
181
|
+
const start = conductorStart(args.peer, args.mode, this.hooks.preview);
|
|
182
|
+
if (start.mode === "headless") await this.hooks.start(start.peer);
|
|
183
|
+
emit({ peer: start.peer }); return start;
|
|
184
|
+
}
|
|
185
|
+
const peer = peerId(args.peer);
|
|
186
|
+
if (!this.hooks.known(peer)) throw new Error(`unknown peer: ${peer}`);
|
|
187
|
+
if (tool === "hub_peer_hold") { this.holds.hold(peer, actor); this.hooks.pause(peer); }
|
|
188
|
+
else {
|
|
189
|
+
if (this.holds.get(peer)?.actor !== actor) throw new Error("you may release only a conductor hold you placed");
|
|
190
|
+
await this.hooks.validateRelease?.(peer);
|
|
191
|
+
requireConductor(actor, this.hooks.roles(), this.hooks.capabilities());
|
|
192
|
+
this.holds.release(peer, actor); this.hooks.release(peer);
|
|
193
|
+
}
|
|
194
|
+
emit({ peer }); return { peer, held: this.holds.has(peer) };
|
|
195
|
+
}
|
|
196
|
+
}
|
|
@@ -6,10 +6,10 @@ export function stateDirFor(cwd: string): string {
|
|
|
6
6
|
return projectContext(cwd).stateDir;
|
|
7
7
|
}
|
|
8
8
|
|
|
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). The plugin is installed apart from the daemon, so they can drift. */
|
|
10
|
-
export const PROTOCOL =
|
|
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). The plugin is installed apart from the daemon, so they can drift. */
|
|
10
|
+
export const PROTOCOL = 15;
|
|
11
11
|
/** Protocols a current coordinator may authenticate while upgrading a running source. */
|
|
12
|
-
export const RECOVERY_SOURCE_PROTOCOLS = [9, 10, 11, 12, 13, PROTOCOL] as const;
|
|
12
|
+
export const RECOVERY_SOURCE_PROTOCOLS = [9, 10, 11, 12, 13, 14, PROTOCOL] as const;
|
|
13
13
|
|
|
14
14
|
export interface Hello {
|
|
15
15
|
/** `tools`: acts for `peer` (task tools, hub_send) without being a delivery target: the MCP server Kimi and Codex run. */
|