@staix/agent-hub 0.12.15 → 0.12.17
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 +17 -0
- package/README.md +18 -5
- package/docs/agent-notes/adapters.md +1 -0
- package/docs/agent-notes/budget.md +5 -1
- package/docs/agent-notes/bus.md +2 -0
- package/docs/agent-notes/tasks.md +3 -1
- package/docs/agent-notes/tests.md +3 -1
- package/docs/operations.md +277 -11
- package/docs/quickstart.md +4 -4
- package/docs/security.md +26 -1
- package/docs/smoke.md +93 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +223 -0
- package/docs/verification/2026-10-09-agent-shell-t0.md +123 -0
- package/docs/verified.json +168 -0
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +18 -6
- package/src/adapters/acp.ts +2 -2
- package/src/adapters/claude-channel.ts +3 -2
- package/src/adapters/codex-appserver.ts +10 -2
- package/src/adapters/local-worker.ts +5 -5
- package/src/adapters/pi.ts +4 -14
- package/src/cli/console-state.ts +213 -0
- package/src/cli/console.ts +201 -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/init.ts +40 -30
- package/src/cli/launch.ts +31 -1
- package/src/cli/main.ts +95 -39
- package/src/cli/preview.ts +80 -0
- package/src/cli/status-lines.ts +8 -2
- package/src/cli/statusline-tee.ts +14 -0
- package/src/cli/tail-render.ts +17 -0
- package/src/cli/upgrade-runtime.ts +1 -1
- package/src/hub/board.ts +9 -2
- package/src/hub/bus.ts +82 -10
- package/src/hub/child-process.ts +11 -0
- package/src/hub/conductor.ts +196 -0
- package/src/hub/context-window.ts +72 -0
- package/src/hub/control-client.ts +3 -3
- package/src/hub/daemon.ts +492 -77
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +7 -1
- package/src/hub/hub-tools.ts +14 -1
- package/src/hub/report.ts +53 -4
- package/src/hub/supervision.ts +152 -0
- package/src/hub/task-sweep.ts +59 -0
- package/src/hub/tasks.ts +86 -15
- package/src/hub/usage.ts +37 -2
- package/src/models/relay.ts +6 -1
- package/src/pi/launch.ts +21 -0
- package/src/ui/index.html +6 -0
package/src/hub/bus.ts
CHANGED
|
@@ -82,6 +82,8 @@ function resolveTo(to: PeerId[], originals: Envelope[] | undefined): PeerId[] {
|
|
|
82
82
|
|
|
83
83
|
const SEEN_CAP = 2048;
|
|
84
84
|
const MAX_ATTEMPTS = 3;
|
|
85
|
+
// ponytail: one digest carries at most ten originals; larger supervision windows split into bounded deliveries.
|
|
86
|
+
// Revisit this ceiling after measuring native supervision cost, rather than bypassing the delivery limit.
|
|
85
87
|
const DIGEST_MAX = 10;
|
|
86
88
|
const NOTES_KEPT = 10;
|
|
87
89
|
|
|
@@ -100,6 +102,8 @@ export class Bus {
|
|
|
100
102
|
private readonly manualPaused = new Set<PeerId>();
|
|
101
103
|
private readonly timers = new Map<PeerId, ReturnType<typeof setTimeout>>();
|
|
102
104
|
private readonly draining = new Set<PeerId>();
|
|
105
|
+
/** Still pending while an asynchronous condensation runs; never includes native dispatches. */
|
|
106
|
+
private readonly preparing = new Map<PeerId, Envelope[]>();
|
|
103
107
|
private readonly seen = new Map<string, Envelope>();
|
|
104
108
|
private readonly taps = new Set<(e: BusEvent) => void>();
|
|
105
109
|
private readonly withdrawn = new Set<string>();
|
|
@@ -119,6 +123,10 @@ export class Bus {
|
|
|
119
123
|
* and is never corrected once the queue empties (measured: status.json kept `queued 4` on an empty queue).
|
|
120
124
|
*/
|
|
121
125
|
onQueues?: () => void;
|
|
126
|
+
/** Actual transport admission, using originals even when a digest was condensed. Never an enqueue metric. */
|
|
127
|
+
onDelivered?: (peer: PeerId, originals: Envelope[]) => void;
|
|
128
|
+
/** Synchronous native failure observation, before queue callbacks can finish turn metrics. */
|
|
129
|
+
onDeliveryFailed?: (peer: PeerId) => void;
|
|
122
130
|
private recoveryHeld = false;
|
|
123
131
|
private steering = 0;
|
|
124
132
|
private condensing = 0;
|
|
@@ -389,7 +397,9 @@ export class Bus {
|
|
|
389
397
|
const id = receipt.id;
|
|
390
398
|
const before = this.journal.get(id);
|
|
391
399
|
const state: JournalDeliveryState = receipt.state === "accepted" ? "accepted" : receipt.state === "completed" ? "completed" : receipt.state === "failed_safe" ? "failed" : "needs_review";
|
|
400
|
+
if (state === "failed" || state === "needs_review") this.deliveryFailed(peer);
|
|
392
401
|
this.journal.transition(id, state, receipt.reason);
|
|
402
|
+
if (before?.state === "dispatching" && (state === "accepted" || state === "completed")) this.delivered(peer, before.originals);
|
|
393
403
|
if (state === "failed") {
|
|
394
404
|
if (before) this.failed(peer, before.originals, before.originals.some((e) => e.private) ? "private delivery failed" : receipt.reason);
|
|
395
405
|
}
|
|
@@ -525,7 +535,8 @@ export class Bus {
|
|
|
525
535
|
this.activeDeliveries.set(deliveryId, id);
|
|
526
536
|
if (this.journal) this.durableHandoff(id, deliveryId, [env], [env]);
|
|
527
537
|
const steerResult = this.journal ? peer.steer([env], deliveryId) : peer.steer([env]);
|
|
528
|
-
steerResult.then(() => {}).catch(() => {
|
|
538
|
+
steerResult.then(() => { this.delivered(id, [env]); }).catch(() => {
|
|
539
|
+
this.deliveryFailed(id);
|
|
529
540
|
if (this.journal) this.journal.transition(deliveryId, "needs_review", "adapter steering outcome is uncertain");
|
|
530
541
|
this.activeDeliveries.delete(deliveryId);
|
|
531
542
|
if (!this.journal) this.enqueue(id, env, true);
|
|
@@ -570,8 +581,7 @@ export class Bus {
|
|
|
570
581
|
* dropped if the refused steer tries to queue it later.
|
|
571
582
|
*/
|
|
572
583
|
withdraw(envelopeId: string): boolean {
|
|
573
|
-
this.
|
|
574
|
-
if (this.withdrawn.size > 256) this.withdrawn.delete(this.withdrawn.values().next().value as string);
|
|
584
|
+
this.markWithdrawn(envelopeId);
|
|
575
585
|
let removed = false;
|
|
576
586
|
for (const queue of this.queues.values()) {
|
|
577
587
|
const i = queue.findIndex((e) => e.id === envelopeId);
|
|
@@ -582,6 +592,49 @@ export class Bus {
|
|
|
582
592
|
return removed;
|
|
583
593
|
}
|
|
584
594
|
|
|
595
|
+
/** Revoke pending feed copies only; accepted/dispatching journal records and native turns are untouched. */
|
|
596
|
+
revokeSupervision(peer: PeerId): number {
|
|
597
|
+
const queue = this.queues.get(peer);
|
|
598
|
+
if (!queue && !this.preparing.has(peer)) return 0;
|
|
599
|
+
let removed = 0;
|
|
600
|
+
const discarded = new Set<string>();
|
|
601
|
+
for (let i = (queue?.length ?? 0) - 1; i >= 0; i--) {
|
|
602
|
+
const env = queue![i]!;
|
|
603
|
+
if (env.from !== HUB || !env.refs?.supervision) continue;
|
|
604
|
+
queue!.splice(i, 1);
|
|
605
|
+
removed++;
|
|
606
|
+
discarded.add(env.id);
|
|
607
|
+
this.markWithdrawn(env.id);
|
|
608
|
+
this.discardQueued(peer, env, "supervision role or feed revoked");
|
|
609
|
+
}
|
|
610
|
+
for (const env of this.preparing.get(peer) ?? []) {
|
|
611
|
+
if (env.from !== HUB || !env.refs?.supervision || discarded.has(env.id)) continue;
|
|
612
|
+
this.markWithdrawn(env.id);
|
|
613
|
+
this.discardQueued(peer, env, "supervision role or feed revoked during preparation");
|
|
614
|
+
removed++;
|
|
615
|
+
}
|
|
616
|
+
if (removed) { this.persist(); this.onQueues?.(); }
|
|
617
|
+
return removed;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
private discardQueued(peer: PeerId, env: Envelope, reason: string): void {
|
|
621
|
+
if (this.journal) this.pendingOutcomes.push({ id: crypto.randomUUID(), peer, state: "discarded", createdAt: Date.now(), originals: [env], out: [], reason });
|
|
622
|
+
this.emit({ t: "stale", env, peer, reason });
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
private delivered(peer: PeerId, originals: Envelope[]): void {
|
|
626
|
+
try { this.onDelivered?.(peer, originals); } catch { /* observation must never affect delivery settlement */ }
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
private deliveryFailed(peer: PeerId): void {
|
|
630
|
+
try { this.onDeliveryFailed?.(peer); } catch { /* observation must never affect delivery settlement */ }
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
private markWithdrawn(id: string): void {
|
|
634
|
+
this.withdrawn.add(id);
|
|
635
|
+
if (this.withdrawn.size > 256) this.withdrawn.delete(this.withdrawn.values().next().value as string);
|
|
636
|
+
}
|
|
637
|
+
|
|
585
638
|
queued(id: PeerId): number {
|
|
586
639
|
return this.queues.get(id)?.length ?? 0;
|
|
587
640
|
}
|
|
@@ -599,8 +652,23 @@ export class Bus {
|
|
|
599
652
|
if (this.withdrawn.has(env.id)) return;
|
|
600
653
|
const queue = this.queues.get(id) ?? [];
|
|
601
654
|
this.queues.set(id, queue);
|
|
602
|
-
|
|
603
|
-
|
|
655
|
+
const key = env.from === HUB && env.refs?.supervision ? env.refs.supervisionKey : undefined;
|
|
656
|
+
const replace = !front && key ? queue.findIndex((old) => old.from === HUB && old.refs?.supervision && old.refs.supervisionKey === key && !this.attempts.has(`${id}:${old.id}`)) : -1;
|
|
657
|
+
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;
|
|
658
|
+
let queuedEnv = preparing ? { ...env, ts: Math.min(preparing.ts, env.ts) } : env;
|
|
659
|
+
if (replace >= 0) {
|
|
660
|
+
const old = queue[replace]!;
|
|
661
|
+
// Keep the first milestone's deadline, so continuous updates cannot create another batching window.
|
|
662
|
+
queuedEnv = { ...queuedEnv, ts: Math.min(old.ts, queuedEnv.ts) };
|
|
663
|
+
queue[replace] = queuedEnv;
|
|
664
|
+
this.markWithdrawn(old.id);
|
|
665
|
+
this.discardQueued(id, old, "superseded supervision milestone");
|
|
666
|
+
} else if (front) queue.unshift(queuedEnv);
|
|
667
|
+
else queue.push(queuedEnv);
|
|
668
|
+
if (preparing && !this.withdrawn.has(preparing.id)) {
|
|
669
|
+
this.markWithdrawn(preparing.id);
|
|
670
|
+
this.discardQueued(id, preparing, "superseded supervision milestone during preparation");
|
|
671
|
+
}
|
|
604
672
|
if (queue.length > this.opts.queueCap) {
|
|
605
673
|
const victim = queue.findIndex((e) => e.priority !== "important");
|
|
606
674
|
const [lost] = queue.splice(victim === -1 ? 0 : victim, 1);
|
|
@@ -614,7 +682,8 @@ export class Bus {
|
|
|
614
682
|
|
|
615
683
|
/** 0 = deliver now; otherwise how long the oldest envelope still has to wait for company. */
|
|
616
684
|
private wait(queue: Envelope[]): number {
|
|
617
|
-
|
|
685
|
+
const supervisionOnly = queue.every((e) => e.from === HUB && e.refs?.supervision && e.priority === "status");
|
|
686
|
+
if ((!supervisionOnly && queue.length >= this.opts.batchMax) || queue.some((e) => e.priority === "important")) return 0;
|
|
618
687
|
return Math.max(0, queue[0]!.ts + this.opts.batchMs - Date.now());
|
|
619
688
|
}
|
|
620
689
|
|
|
@@ -656,6 +725,7 @@ export class Bus {
|
|
|
656
725
|
// may checkpoint during that wait; it must not checkpoint the batch out of existence.
|
|
657
726
|
if (!this.journal) this.prefaces.delete(id);
|
|
658
727
|
const batch = this.take(id, queue, !this.journal);
|
|
728
|
+
this.preparing.set(id, batch);
|
|
659
729
|
const delivery = preface ? [preface, ...batch] : batch;
|
|
660
730
|
const mayCondense = this.opts.condense && !delivery.some((e) => e.priority === "important");
|
|
661
731
|
this.condensing += mayCondense ? 1 : 0;
|
|
@@ -679,11 +749,12 @@ export class Bus {
|
|
|
679
749
|
for (const e of out) if (!this.seen.has(e.id)) this.seen.set(e.id, e);
|
|
680
750
|
while (this.seen.size > SEEN_CAP) this.seen.delete(this.seen.keys().next().value as string);
|
|
681
751
|
this.lastDelivery.set(id, { out, originals: delivery });
|
|
752
|
+
this.preparing.delete(id); // from here on the transport/journal owns settlement
|
|
682
753
|
const deliveryId = crypto.randomUUID();
|
|
683
754
|
if (this.journal) { this.durableHandoff(id, deliveryId, delivery, out); this.activeDeliveries.set(deliveryId, id); }
|
|
684
755
|
try {
|
|
685
756
|
if (this.journal) await peer.deliver(out, deliveryId);
|
|
686
|
-
else await peer.deliver(out);
|
|
757
|
+
else { await peer.deliver(out); this.delivered(id, delivery); }
|
|
687
758
|
} catch {
|
|
688
759
|
if (this.journal) this.uncertain(id, deliveryId, "adapter delivery outcome is uncertain");
|
|
689
760
|
else this.failed(id, delivery);
|
|
@@ -691,11 +762,11 @@ export class Bus {
|
|
|
691
762
|
}
|
|
692
763
|
}
|
|
693
764
|
} catch { this.storageError = "delivery journal unavailable"; }
|
|
694
|
-
finally { this.draining.delete(id); this.onQueues?.(); }
|
|
765
|
+
finally { this.preparing.delete(id); this.draining.delete(id); this.onQueues?.(); }
|
|
695
766
|
}
|
|
696
767
|
|
|
697
768
|
private isRelevant(id: PeerId, env: Envelope): boolean {
|
|
698
|
-
return this.opts.relevant?.(id, env) ?? true;
|
|
769
|
+
return !this.withdrawn.has(env.id) && (this.opts.relevant?.(id, env) ?? true);
|
|
699
770
|
}
|
|
700
771
|
|
|
701
772
|
/**
|
|
@@ -704,7 +775,7 @@ export class Bus {
|
|
|
704
775
|
* this recipient's copy goes: the other queues keep theirs, and no other delivery record changes.
|
|
705
776
|
*/
|
|
706
777
|
private dropIrrelevant(id: PeerId, queue: Envelope[]): boolean {
|
|
707
|
-
if (!this.opts.relevant) return false;
|
|
778
|
+
if (!this.opts.relevant && !this.withdrawn.size) return false;
|
|
708
779
|
let dropped = false;
|
|
709
780
|
for (let i = queue.length - 1; i >= 0; i--) {
|
|
710
781
|
const env = queue[i]!;
|
|
@@ -757,6 +828,7 @@ export class Bus {
|
|
|
757
828
|
}
|
|
758
829
|
|
|
759
830
|
private failed(id: PeerId, envs: Envelope[], reason = "delivery failed without an error detail"): void {
|
|
831
|
+
if (!this.journal) this.deliveryFailed(id);
|
|
760
832
|
this.lastFailure.set(id, reason.replace(/\s+/g, " ").slice(0, 300));
|
|
761
833
|
if (envs.some((env) => !(env.from === HUB && env.kind === "presence") && (this.attempts.get(`${id}:${env.id}`) ?? 0) + 1 >= MAX_ATTEMPTS)) {
|
|
762
834
|
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
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/** Native active-context occupancy, separate from accumulated billable usage (#185). */
|
|
2
|
+
export interface ContextReading {
|
|
3
|
+
source: "claude_statusline" | "codex_token_usage";
|
|
4
|
+
sessionId: string;
|
|
5
|
+
measuredAt: number;
|
|
6
|
+
tokens: number | null;
|
|
7
|
+
window: number | null;
|
|
8
|
+
used: number | null;
|
|
9
|
+
}
|
|
10
|
+
export interface ContextView {
|
|
11
|
+
source: string | null;
|
|
12
|
+
measuredAt: number | null;
|
|
13
|
+
tokens: number | null;
|
|
14
|
+
window: number | null;
|
|
15
|
+
used: number | null;
|
|
16
|
+
freshness: "fresh" | "stale" | "unknown";
|
|
17
|
+
}
|
|
18
|
+
export interface ContextConfig { gate: number; stale_min: number }
|
|
19
|
+
export const DEFAULT_CONTEXT: ContextConfig = { gate: 0, stale_min: 30 };
|
|
20
|
+
const nonnegative = (n: unknown): n is number => typeof n === "number" && Number.isFinite(n) && n >= 0;
|
|
21
|
+
const positive = (n: unknown): n is number => nonnegative(n) && n > 0;
|
|
22
|
+
const record = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
23
|
+
const object = (v: unknown): Record<string, unknown> => record(v) ? v : {};
|
|
24
|
+
export function claudeContext(value: unknown, sessionId: string, measuredAt: number): ContextReading {
|
|
25
|
+
const c = object(value);
|
|
26
|
+
const current = object(c.current_usage);
|
|
27
|
+
const parts = [current.input_tokens, current.cache_creation_input_tokens, current.cache_read_input_tokens];
|
|
28
|
+
const window = positive(c.context_window_size) ? c.context_window_size : null;
|
|
29
|
+
const sum = c.current_usage != null && parts.every(nonnegative) ? parts.reduce<number>((sum, n) => sum + (n as number), 0) : null;
|
|
30
|
+
const tokens = nonnegative(sum) ? sum : null;
|
|
31
|
+
const used = record(c.current_usage) && tokens !== null && nonnegative(c.used_percentage) && c.used_percentage <= 100 && window !== null ? c.used_percentage / 100 : null;
|
|
32
|
+
return { source: "claude_statusline", sessionId, measuredAt, tokens, window, used };
|
|
33
|
+
}
|
|
34
|
+
export function codexContext(value: unknown, sessionId: string, measuredAt: number): ContextReading {
|
|
35
|
+
const c = object(value), last = object(c.last);
|
|
36
|
+
const window = positive(c.modelContextWindow) ? c.modelContextWindow : null;
|
|
37
|
+
const tokens = nonnegative(last.totalTokens) ? last.totalTokens : null;
|
|
38
|
+
return { source: "codex_token_usage", sessionId, measuredAt, tokens, window, used: tokens !== null && window !== null ? Math.min(1, tokens / window) : null };
|
|
39
|
+
}
|
|
40
|
+
export const unknownContext = (): ContextView => ({ source: null, measuredAt: null, tokens: null, window: null, used: null, freshness: "unknown" });
|
|
41
|
+
/** Invalid/stale readings hide the value but never rearm a threshold already crossed. */
|
|
42
|
+
export class ContextWindows {
|
|
43
|
+
private readings = new Map<string, ContextReading>();
|
|
44
|
+
private high = new Map<string, { sessionId: string; crossed: boolean }>();
|
|
45
|
+
constructor(private cfg: ContextConfig, private crossing: (peer: string, reading: ContextReading) => unknown, private now: () => number = Date.now) {
|
|
46
|
+
if (!Number.isFinite(cfg.gate) || cfg.gate < 0 || cfg.gate > 1 || !Number.isFinite(cfg.stale_min) || cfg.stale_min <= 0) throw new Error("context: gate must be 0..1 and stale_min positive");
|
|
47
|
+
}
|
|
48
|
+
report(peer: string, reading: ContextReading, currentSession: string | undefined): void {
|
|
49
|
+
if (!currentSession || reading.sessionId !== currentSession) return;
|
|
50
|
+
const previous = this.readings.get(peer);
|
|
51
|
+
if (previous?.sessionId === reading.sessionId && positive(previous.measuredAt) && previous.measuredAt <= this.now() && positive(reading.measuredAt) && reading.measuredAt <= this.now() && previous.measuredAt > reading.measuredAt) return;
|
|
52
|
+
this.readings.set(peer, reading);
|
|
53
|
+
if (!positive(reading.measuredAt) || reading.measuredAt > this.now() || reading.used === null || !nonnegative(reading.used) || reading.used > 1 || this.now() - reading.measuredAt > this.cfg.stale_min * 60_000 || !this.cfg.gate) return;
|
|
54
|
+
const state = this.high.get(peer);
|
|
55
|
+
const crossed = state?.sessionId === reading.sessionId && state.crossed;
|
|
56
|
+
const over = reading.used >= this.cfg.gate;
|
|
57
|
+
if (over && !crossed && this.crossing(peer, reading) === false) return;
|
|
58
|
+
this.high.set(peer, { sessionId: reading.sessionId, crossed: over });
|
|
59
|
+
}
|
|
60
|
+
/** Reconsider a held crossing after release, using only the still-fresh reading and current native session. */
|
|
61
|
+
retry(peer: string, currentSession: string | undefined): void {
|
|
62
|
+
const reading = this.readings.get(peer);
|
|
63
|
+
if (reading) this.report(peer, reading, currentSession);
|
|
64
|
+
}
|
|
65
|
+
view(peer: string, currentSession: string | undefined, attached: boolean): ContextView {
|
|
66
|
+
const r = this.readings.get(peer);
|
|
67
|
+
if (!r) return unknownContext();
|
|
68
|
+
const stale = this.now() - r.measuredAt > this.cfg.stale_min * 60_000;
|
|
69
|
+
const valid = positive(r.measuredAt) && r.measuredAt <= this.now() && attached && !!currentSession && r.sessionId === currentSession && !stale;
|
|
70
|
+
return { source: r.source, measuredAt: positive(r.measuredAt) ? r.measuredAt : null, tokens: valid ? r.tokens : null, window: r.window, used: valid ? r.used : null, freshness: !valid ? stale ? "stale" : "unknown" : r.used === null ? "unknown" : "fresh" };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -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). 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, 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. */
|