@staix/agent-hub 0.12.16 → 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.
Files changed (43) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +4 -4
  3. package/docs/agent-notes/adapters.md +1 -0
  4. package/docs/agent-notes/bus.md +2 -0
  5. package/docs/agent-notes/tasks.md +3 -1
  6. package/docs/agent-notes/tests.md +1 -0
  7. package/docs/operations.md +106 -9
  8. package/docs/quickstart.md +3 -3
  9. package/docs/security.md +24 -0
  10. package/docs/smoke.md +93 -0
  11. package/docs/specs/2026-09-19-agent-hub-design.md +120 -1
  12. package/docs/verification/2026-10-09-agent-shell-t0.md +123 -0
  13. package/docs/verified.json +26 -17
  14. package/package.json +1 -1
  15. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  16. package/plugins/agent-hub/server.js +17 -5
  17. package/src/adapters/acp.ts +2 -2
  18. package/src/adapters/claude-channel.ts +3 -2
  19. package/src/adapters/codex-appserver.ts +3 -2
  20. package/src/adapters/local-worker.ts +5 -5
  21. package/src/cli/console-state.ts +213 -0
  22. package/src/cli/console.ts +201 -0
  23. package/src/cli/facts-hook.ts +8 -1
  24. package/src/cli/identity-audit.ts +66 -0
  25. package/src/cli/identity.ts +54 -0
  26. package/src/cli/launch.ts +15 -2
  27. package/src/cli/main.ts +62 -29
  28. package/src/cli/tail-render.ts +17 -0
  29. package/src/cli/upgrade-runtime.ts +1 -1
  30. package/src/hub/board.ts +6 -2
  31. package/src/hub/bus.ts +82 -10
  32. package/src/hub/child-process.ts +11 -0
  33. package/src/hub/conductor.ts +196 -0
  34. package/src/hub/control-client.ts +3 -3
  35. package/src/hub/daemon.ts +367 -46
  36. package/src/hub/envelope.ts +1 -1
  37. package/src/hub/events.ts +6 -1
  38. package/src/hub/hub-tools.ts +13 -0
  39. package/src/hub/report.ts +53 -4
  40. package/src/hub/supervision.ts +152 -0
  41. package/src/hub/tasks.ts +30 -15
  42. package/src/hub/usage.ts +37 -2
  43. package/src/pi/launch.ts +2 -1
@@ -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 = 14;
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. */