@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
@@ -14,7 +14,7 @@ export interface Envelope {
14
14
  kind: Kind;
15
15
  priority: Priority;
16
16
  body: string; // agent conclusions only, never tool noise
17
- refs?: { repo?: string; branch?: string; commit?: string; paths?: string[]; task?: string };
17
+ refs?: { repo?: string; branch?: string; commit?: string; paths?: string[]; task?: string; supervision?: boolean; supervisionKey?: string };
18
18
  /** The body must not be shown outside its recipients: console tail and log print a stub instead (PII tasks). */
19
19
  private?: boolean;
20
20
  ts: number;
package/src/hub/events.ts CHANGED
@@ -24,7 +24,12 @@ export type HubEvent =
24
24
  | { type: "state"; peer: string; state: string }
25
25
  | { type: "turn_start"; peer: string; turn: string }
26
26
  | { type: "turn_end"; peer: string; turn: string; ms: number; tokens?: number; files?: number; snapshotMs?: number }
27
- | { type: "native_turn_end"; peer: string }
27
+ | { type: "native_turn_end"; peer: string; id?: string }
28
+ | { type: "conduct"; peer: string; action: string; task?: number; target?: string }
29
+ | { type: "agent_cli"; peer: string; command: string; refused: boolean }
30
+ | { type: "permission"; id: string; peer: string; event: "requested" | "answered" | "expired" | "cancelled"; latencyMs?: number; surface?: "console" | "dashboard" | "terminal"; option?: "allow_once" | "allow_always" | "reject_once" | "reject_always" }
31
+ /** Completed native turns that received supervision, not feed enqueues or inferred costs. */
32
+ | { type: "supervision_turn"; peer: string; turn: string; tokens?: number; ms?: number }
28
33
  | { type: "hook_stats"; peer: string; n: number; startupMs: number; hubMs: number; maxStartupMs: number }
29
34
  | { type: "cohort"; id: number; event: "formed" | "joined" | "lifted"; silent: boolean; tasks: number[]; owners: string[] }
30
35
  | { type: "tokens"; peer: string; n: number }
@@ -47,8 +47,21 @@ export const TASK_TOOLS: HubTool[] = [
47
47
 
48
48
  export const TASK_TOOL_NAMES = new Set(TASK_TOOLS.map((t) => t.name));
49
49
 
50
+ /** Separately published: serving a schema grants no authority, only the daemon's explicit role does. */
51
+ export const CONDUCTOR_TOOLS: HubTool[] = [
52
+ tool("hub_status", "Inspect public team state, holds, quota windows, task counts and pending approval peer/tool/age. Only the configured conductor may use this tool.", {}),
53
+ tool("hub_task_show", "Read a task's public view and history. PII tasks remain stubs. Only the conductor may use this tool.", { id }, ["id"]),
54
+ tool("hub_task_assign", "Move a task to another peer, as the conductor. Requires assign capability when the conductor has an explicit capabilities list.", { id, peer: str }, ["id", "peer"]),
55
+ tool("hub_task_escalate", "Escalate a task through the normal task flow, as the conductor. Requires assign capability when explicitly listed.", { id }, ["id"]),
56
+ tool("hub_peer_start", "Start local, kimi or headless pi. Claude, Codex and Pi TUI requests return a command for the person to run, without launching a terminal.", { peer: { type: "string", enum: ["local", "kimi", "pi", "claude", "codex"] }, mode: { type: "string", enum: ["headless", "tui"] } }, ["peer"]),
57
+ tool("hub_peer_hold", "Hold a peer's deliveries as the conductor. This hold is separate from the person's hold and budget pauses.", { peer: str }, ["peer"]),
58
+ tool("hub_peer_release", "Release only the conductor hold you placed. Never lifts a person's hold or a budget pause.", { peer: str }, ["peer"]),
59
+ ];
60
+ export const CONDUCTOR_TOOL_NAMES = new Set(CONDUCTOR_TOOLS.map((t) => t.name));
61
+
50
62
  /** Role contracts, by role name. Shown to each peer for the roles `.agenthub/config.json` gives it. */
51
63
  export const ROLE_TEXT: Record<string, string> = {
64
+ conductor: "conductor: plan and split work into tasks with owners, watch the team with hub_status, move stalled work, and ensure every task is reviewed (review it yourself only if you also hold reviewer). Report results and open decisions to the person. Do not implement tasks you handed out. Never ask a peer to answer an approval; ask the person for human-only actions. You may release only holds you placed, never human holds or budget pauses.",
52
65
  planner: "planner: break work into tasks with hub_task_propose (one outcome each, the right class, paths in refs, and after: [ids] for work that must wait for other tasks) instead of doing everything yourself.",
53
66
  implementer: "implementer: accept tasks assigned to you, do them, and finish with hub_task_done (summary: what changed, why, and the check you ran with its result; refs). Decline what you cannot do. Before starting work nobody assigned you, claim it with hub_task_propose naming yourself as owner, with the paths in refs. With a claim or an accept, give a plan: the files, symbols and signatures you will change and where new code goes.",
54
67
  verifier: "verifier: run the checks a task names and report what passed and what did not in hub_task_done.",
package/src/hub/report.ts CHANGED
@@ -3,7 +3,7 @@ import type { StampedEvent } from "./events.ts";
3
3
  export interface Report {
4
4
  from?: string;
5
5
  to?: string;
6
- peers: Record<string, { turns: number; busyMinutes: number; tokens: number }>;
6
+ peers: Record<string, { turns: number | null; busyMinutes: number; tokens: number; turnSource?: "native-stop" | "logical-state" | "unknown" }>;
7
7
  usage: { peers: Record<string, UsageCoverage>; totals: UsageTotals; unknownPeers: string[]; completeCoverage: boolean; estimatedUsd?: number; measuredSpendUsd?: number };
8
8
  messages: { total: number; dropped: Record<string, number>; overflow: number; undeliverable: number; perTask: number };
9
9
  overlaps: { warnings: number; pairs: number };
@@ -11,6 +11,8 @@ export interface Report {
11
11
  conflicts: number;
12
12
  tasks: Record<string, number>;
13
13
  quota: { readings: number; hard: number };
14
+ conduct: Record<string, Record<string, number>>;
15
+ supervision: Record<string, { turns: number | null; tokens: number | null; measuredTokens: number | null; knownTokenTurns: number; unknownTokenTurns: number }>;
14
16
  }
15
17
 
16
18
  export interface UsageCoverage {
@@ -83,16 +85,39 @@ function formatUsage(usage: Report["usage"]): string[] {
83
85
 
84
86
  /** The numbers `ahub report` prints, from `events.jsonl` alone (issue #40). */
85
87
  export function summarize(events: StampedEvent[]): Report {
86
- const r: Report = { peers: {}, usage: { peers: {}, totals: { inputTokens: 0, inputRecords: 0, outputTokens: 0, outputRecords: 0, cacheReadTokens: 0, cacheReadRecords: 0, cacheWriteTokens: 0, cacheWriteRecords: 0, totalTokens: 0, totalRecords: 0 }, unknownPeers: [], completeCoverage: true }, messages: { total: 0, dropped: {}, overflow: 0, undeliverable: 0, perTask: 0 }, overlaps: { warnings: 0, pairs: 0 }, conflicts: 0, tasks: {}, quota: { readings: 0, hard: 0 } };
88
+ const r: Report = { peers: {}, conduct: {}, supervision: {}, usage: { peers: {}, totals: { inputTokens: 0, inputRecords: 0, outputTokens: 0, outputRecords: 0, cacheReadTokens: 0, cacheReadRecords: 0, cacheWriteTokens: 0, cacheWriteRecords: 0, totalTokens: 0, totalRecords: 0 }, unknownPeers: [], completeCoverage: true }, messages: { total: 0, dropped: {}, overflow: 0, undeliverable: 0, perTask: 0 }, overlaps: { warnings: 0, pairs: 0 }, conflicts: 0, tasks: {}, quota: { readings: 0, hard: 0 } };
89
+ const supervisor = (id: string) => (r.supervision[id] ??= { turns: null, tokens: null, measuredTokens: null, knownTokenTurns: 0, unknownTokenTurns: 0 });
87
90
  const peer = (id: string) => (r.peers[id] ??= { turns: 0, busyMinutes: 0, tokens: 0 });
91
+ const claudeNativeStops = events.some(event => event.type === "native_turn_end" && event.peer === "claude" && !!event.id);
92
+ const seenNativeStops = new Set<string>();
88
93
  const usagePeer = (id: string) => (r.usage.peers[id] ??= emptyUsage());
89
94
  const pairs = new Set<string>();
90
95
  const taskMessages = new Map<string, number>();
91
96
  const seenUsage = new Set<string>();
97
+ const seenSupervision = new Set<string>();
92
98
  for (const e of events) {
93
99
  r.from ??= e.at;
94
100
  r.to = e.at;
95
101
  switch (e.type) {
102
+ case "conduct": {
103
+ const actions = r.conduct[e.peer] ??= {};
104
+ actions[e.action] = (actions[e.action] ?? 0) + 1;
105
+ supervisor(e.peer);
106
+ break;
107
+ }
108
+ case "supervision_turn": {
109
+ const key = `${e.peer}\0${e.turn}`;
110
+ if (seenSupervision.has(key)) break;
111
+ seenSupervision.add(key);
112
+ const s = supervisor(e.peer);
113
+ s.turns = (s.turns ?? 0) + 1;
114
+ if (typeof e.tokens === "number" && Number.isSafeInteger(e.tokens) && e.tokens >= 0) {
115
+ s.measuredTokens = (s.measuredTokens ?? 0) + e.tokens;
116
+ s.tokens = s.unknownTokenTurns ? null : s.measuredTokens;
117
+ s.knownTokenTurns++;
118
+ } else { s.unknownTokenTurns++; s.tokens = null; }
119
+ break;
120
+ }
96
121
  case "envelope":
97
122
  r.messages.total++;
98
123
  if (e.dropped) r.messages.dropped[e.dropped] = (r.messages.dropped[e.dropped] ?? 0) + 1;
@@ -106,9 +131,16 @@ export function summarize(events: StampedEvent[]): Report {
106
131
  r.messages.dropped.stale = (r.messages.dropped.stale ?? 0) + 1;
107
132
  break;
108
133
  case "turn_end":
109
- peer(e.peer).turns++;
134
+ if (e.peer !== "claude" || !claudeNativeStops) peer(e.peer).turns = (peer(e.peer).turns ?? 0) + 1;
110
135
  peer(e.peer).busyMinutes += e.ms / 60_000;
111
136
  break;
137
+ case "native_turn_end":
138
+ if (e.peer === "claude" && e.id) {
139
+ if (seenNativeStops.has(e.id)) break;
140
+ seenNativeStops.add(e.id);
141
+ peer(e.peer).turns = (peer(e.peer).turns ?? 0) + 1;
142
+ }
143
+ break;
112
144
  case "state":
113
145
  peer(e.peer);
114
146
  break;
@@ -143,13 +175,30 @@ export function summarize(events: StampedEvent[]): Report {
143
175
  // Pricing is intentionally absent: a token count is neither an estimated price nor provider-reported spend.
144
176
  r.messages.perTask = taskMessages.size ? Number(([...taskMessages.values()].reduce((a, b) => a + b, 0) / taskMessages.size).toFixed(1)) : 0;
145
177
  for (const p of Object.values(r.peers)) p.busyMinutes = Number(p.busyMinutes.toFixed(1));
178
+ if (r.peers.claude) {
179
+ const logical = events.some(event => event.type === "turn_end" && event.peer === "claude");
180
+ r.peers.claude.turnSource = claudeNativeStops ? "native-stop" : logical ? "logical-state" : "unknown";
181
+ if (!claudeNativeStops && !logical) r.peers.claude.turns = null;
182
+ }
146
183
  return r;
147
184
  }
148
185
 
149
186
  export function formatReport(r: Report): string[] {
150
187
  const lines = [`period: ${r.from ?? "-"} .. ${r.to ?? "-"}`];
151
- for (const [id, p] of Object.entries(r.peers).sort(([a], [b]) => a.localeCompare(b))) lines.push(`peer ${id}: ${p.turns} turn${p.turns === 1 ? "" : "s"}, ${p.busyMinutes} busy minutes, ${p.tokens ? `${p.tokens} tokens` : id === "pi" ? "tokens not reported" : "- tokens"}`);
188
+ for (const [id, p] of Object.entries(r.peers).sort(([a], [b]) => a.localeCompare(b))) {
189
+ const turns = p.turns === null ? "turns unknown" : `${p.turns} turn${p.turns === 1 ? "" : "s"}${p.turnSource === "native-stop" ? " (native Stop)" : p.turnSource === "logical-state" ? " (logical state, native completion unobserved)" : ""}`;
190
+ lines.push(`peer ${id}: ${turns}, ${p.busyMinutes} busy minutes, ${p.tokens ? `${p.tokens} tokens` : id === "pi" ? "tokens not reported" : "- tokens"}`);
191
+ }
152
192
  if (Object.keys(r.usage.peers).length) lines.push(...formatUsage(r.usage));
193
+ for (const [id, s] of Object.entries(r.supervision).sort(([a], [b]) => a.localeCompare(b))) {
194
+ const turns = s.turns === null ? "turns unknown" : `${s.turns} completed native turns`;
195
+ const tokens = s.tokens === null ? "tokens unknown" : `${s.tokens} tokens`;
196
+ const measured = s.measuredTokens === null ? "measured subset unknown" : `${s.measuredTokens} measured tokens (${s.knownTokenTurns} known turns)`;
197
+ lines.push(`supervision ${id}: ${turns}; ${tokens}; ${measured}; ${s.unknownTokenTurns} completed turns with unknown tokens; whole native turns containing supervision, including other work; estimated price unknown; measured spend unknown`);
198
+ }
199
+ for (const [id, actions] of Object.entries(r.conduct).sort(([a], [b]) => a.localeCompare(b))) {
200
+ lines.push(`conductor ${id}: ${Object.entries(actions).sort(([a], [b]) => a.localeCompare(b)).map(([action, n]) => `${action} ${n}`).join(", ")}`);
201
+ }
153
202
  const dropped = Object.entries(r.messages.dropped).map(([k, n]) => `${n} ${k}`).join(", ");
154
203
  lines.push(`messages: ${r.messages.total} (dropped: ${dropped || "none"}; overflow ${r.messages.overflow}; undeliverable ${r.messages.undeliverable}); ${r.messages.perTask} per task that had any`);
155
204
  lines.push(`overlap warnings: ${r.overlaps.warnings}, task pairs: ${r.overlaps.pairs}; edit conflicts: ${r.conflicts}`);
@@ -0,0 +1,152 @@
1
+ import type { HistoryEntry, Task, TaskMoveReason } from "./board.ts";
2
+ import { sanitize, type Kind, type Priority } from "./envelope.ts";
3
+
4
+ export type SupervisionScope = "own" | "all" | "off";
5
+ export type SupervisionMilestone = "assigned" | "accepted" | "declined" | "done" | "review" | "approved" | "moved" | "escalated" | "stuck" | "sweep";
6
+ /** Codes, never free-form notes or check output. */
7
+ export type SupervisionReason = TaskMoveReason | "repetition" | "false_progress" | "drift" | "desperation" | "capability_gap";
8
+ export interface SupervisionFields {
9
+ by?: string;
10
+ owner?: string | null;
11
+ check?: "passed" | "failed" | "unknown";
12
+ verdict?: "approved" | "changes_requested";
13
+ reason?: SupervisionReason;
14
+ sweep?: "unaccepted-assignment" | "idle-owner" | "review-pending";
15
+ }
16
+ export interface SupervisionNotice {
17
+ peer: string;
18
+ /** Replace only queued entries with this key; accepted/in-flight deliveries stay authoritative. */
19
+ key: string;
20
+ body: string;
21
+ priority: Priority;
22
+ kind: Kind;
23
+ task?: number;
24
+ }
25
+ export interface SupervisionDeps {
26
+ /** Current role holder; undefined immediately revokes the feed. */
27
+ conductor: () => string | undefined;
28
+ scope: () => SupervisionScope;
29
+ tasks: () => Task[];
30
+ publicTitle: (task: Task) => string;
31
+ isPrivate: (task: Task) => boolean;
32
+ /** false means not admitted, so latches must remain retryable. No separate batching timer. */
33
+ emit: (notice: SupervisionNotice) => boolean | void;
34
+ approvalAgeMs?: number;
35
+ now?: () => number;
36
+ /** Durable round state belongs to the daemon's store, not the controller. */
37
+ readRound?: (peer: string) => string | undefined;
38
+ writeRound?: (peer: string, signature: string) => void;
39
+ }
40
+
41
+ /** Deterministic milestone producer. The Bus alone owns batching, retry and native turns. */
42
+ export class SupervisionFeed {
43
+ private readonly approvals = new Set<string>();
44
+ private readonly holds = new Set<string>();
45
+ private readonly rounds = new Map<string, string>();
46
+ constructor(private readonly d: SupervisionDeps) {}
47
+
48
+ /** Pending notices may have been withdrawn on role/off change; live requests can notify again on regrant. */
49
+ resetPending(peer: string): void {
50
+ for (const key of this.approvals) if (key.startsWith(`${peer}:`)) this.approvals.delete(key);
51
+ for (const key of this.holds) if (key.startsWith(`${peer}:`)) this.holds.delete(key);
52
+ }
53
+
54
+ private recipient(): string | undefined {
55
+ return this.d.scope() === "off" ? undefined : this.d.conductor();
56
+ }
57
+
58
+ includes(task: Task): boolean {
59
+ const peer = this.recipient();
60
+ return !!peer && (this.d.scope() === "all" || task.history.some((h) => h.by === peer && ["proposed", "assigned", "reassigned", "escalated"].includes(h.event)));
61
+ }
62
+
63
+ private send(key: string, body: string, priority: Priority = "status", task?: number, kind: Kind = "task"): boolean {
64
+ const peer = this.recipient();
65
+ if (!peer) return false;
66
+ try {
67
+ return this.d.emit({ peer, key: `supervision:${peer}:${key}`, body: sanitize(body), priority, kind, ...(task === undefined ? {} : { task }) }) !== false;
68
+ } catch { return false; } // a feed failure must not invalidate a successful board write
69
+ }
70
+
71
+ milestone(id: number, milestone: SupervisionMilestone, fields: SupervisionFields = {}): boolean {
72
+ const task = this.d.tasks().find((t) => t.id === id);
73
+ if (!task || !this.includes(task)) return false;
74
+ if (this.d.isPrivate(task)) return this.send(`task:${id}:${milestone}`, `#${id} [pii]: ${milestone}; ahub task show ${id}`, "status", id);
75
+ const bits = [this.d.publicTitle(task), milestone];
76
+ if (fields.by) bits.push(`by ${fields.by}`);
77
+ if ("owner" in fields) bits.push(`owner ${fields.owner ?? "none"}`);
78
+ if (fields.check) bits.push(`check ${fields.check}`);
79
+ if (fields.verdict) bits.push(`verdict ${fields.verdict}`);
80
+ if (fields.reason) bits.push(`reason ${fields.reason}`);
81
+ if (fields.sweep) bits.push(`finding ${fields.sweep}`);
82
+ return this.send(`task:${id}:${milestone}`, bits.join("; "), "status", id);
83
+ }
84
+
85
+ /** Only structured history metadata is read; notes may contain private content. */
86
+ taskChanged(task: Task, entry: HistoryEntry, metadata: SupervisionFields = {}): void {
87
+ const fields: SupervisionFields = { by: entry.by, owner: task.owner, ...(entry.reason ? { reason: entry.reason } : {}), ...metadata };
88
+ const event = entry.event;
89
+ if (event === "done" || event === "check failed") {
90
+ const latestCheck = task.history.findLast((h) => ["check passed", "check failed", "check interrupted", "done (checking)", "accepted", "reopened"].includes(h.event));
91
+ fields.check = event === "check failed" ? "failed" : metadata.check ?? (latestCheck?.event === "check passed" ? "passed" : "unknown");
92
+ this.milestone(task.id, "done", fields);
93
+ if (event === "done" && task.state === "approved") this.milestone(task.id, "approved", fields);
94
+ } else if (event === "changes_requested") this.milestone(task.id, "review", { ...fields, verdict: "changes_requested" });
95
+ else if (event === "approved") {
96
+ this.milestone(task.id, "review", { ...fields, verdict: "approved" });
97
+ this.milestone(task.id, "approved", fields);
98
+ } else if (event === "idle sweep" && entry.sweep) this.milestone(task.id, "sweep", { ...fields, sweep: entry.sweep.kind });
99
+ else if (event === "reassigned") this.milestone(task.id, "moved", fields);
100
+ else if (["assigned", "accepted", "declined", "escalated"].includes(event)) this.milestone(task.id, event as SupervisionMilestone, fields);
101
+ this.checkRound();
102
+ }
103
+
104
+ peerOffline(peer: string): boolean {
105
+ if (this.d.scope() !== "all" && !this.d.tasks().some((t) => this.includes(t) && t.state !== "approved" && (t.owner === peer || t.reviewer === peer))) return false;
106
+ return this.send(`peer:${peer}:offline`, `${peer}: offline`);
107
+ }
108
+
109
+ budgetPaused(peer: string, resetsAt?: number): boolean {
110
+ if (this.d.scope() !== "all" && !this.d.tasks().some((t) => this.includes(t) && t.state !== "approved" && (t.owner === peer || t.reviewer === peer))) return false;
111
+ const resetDate = new Date(resetsAt ?? NaN);
112
+ const reset = Number.isFinite(resetDate.getTime()) ? resetDate.toISOString() : "unknown";
113
+ return this.send(`peer:${peer}:budget`, `${peer}: budget paused; reset ${reset}`, "status", undefined, "budget");
114
+ }
115
+
116
+ approvalWaiting(request: { id: string; peer: string; tool: string; createdAt: number }): boolean {
117
+ const peer = this.recipient();
118
+ const age = (this.d.now?.() ?? Date.now()) - request.createdAt;
119
+ const key = `${peer}:${request.id}`;
120
+ if (!peer || !Number.isFinite(age) || age < (this.d.approvalAgeMs ?? 60_000) || this.approvals.has(key)) return false;
121
+ const sent = this.send(`approval:${request.id}`, `${request.peer}: approval waiting; tool ${request.tool}; age ${Math.floor(age / 1000)}s; ask the person to answer it in ahub console`, "important");
122
+ if (sent) this.approvals.add(key);
123
+ return sent;
124
+ }
125
+
126
+ needsReview(peer: string, deliveryId: string): boolean {
127
+ const conductor = this.recipient();
128
+ const key = `${conductor}:${peer}:${deliveryId}`;
129
+ if (!conductor || this.holds.has(key)) return false;
130
+ const sent = this.send(`hold:${peer}:${deliveryId}`, `${peer}: needs_review hold; delivery ${deliveryId}; ask the person to resolve it with ahub queue resolve`, "important");
131
+ if (sent) this.holds.add(key);
132
+ return sent;
133
+ }
134
+
135
+ checkRound(): boolean {
136
+ const peer = this.recipient();
137
+ if (!peer) return false;
138
+ const tasks = this.d.tasks().filter((t) => this.includes(t));
139
+ if (!tasks.length || tasks.some((t) => t.state !== "approved")) return false;
140
+ const signature = tasks.map((t) => t.id).sort((a, b) => a - b).join(",");
141
+ const previous = this.d.readRound?.(peer) ?? this.rounds.get(peer);
142
+ const completed = new Set(previous?.split(",") ?? []);
143
+ const newlyJoined = tasks.filter((t) => !completed.has(String(t.id)));
144
+ if (!newlyJoined.length) return false;
145
+ const sent = this.send("round", `Supervision round complete: ${newlyJoined.length} task${newlyJoined.length === 1 ? "" : "s"} approved; report the results and open decisions to the person.`);
146
+ if (sent) {
147
+ this.rounds.set(peer, signature);
148
+ this.d.writeRound?.(peer, signature);
149
+ }
150
+ return sent;
151
+ }
152
+ }
package/src/hub/tasks.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { isAbsolute, relative } from "node:path";
2
2
  import type { Briefs } from "../memory/brief.ts";
3
3
  import type { MemoryClient } from "../memory/client.ts";
4
- import { CLASSES, OUTCOMES_KEPT_MS, PLAN_KEYS, type Board, type Task, type TaskClass, type TaskPlan, type TaskRefs } from "./board.ts";
4
+ import { CLASSES, OUTCOMES_KEPT_MS, PLAN_KEYS, TASK_MOVE_REASONS, type Board, type Task, type TaskClass, type TaskMoveReason, type TaskPlan, type TaskRefs } from "./board.ts";
5
5
  import type { Bus } from "./bus.ts";
6
6
  import { HUB, newEnvelope, NOTE_KINDS, noteLine, USER, type Envelope, type PeerId, type PeerState } from "./envelope.ts";
7
7
  import { assign, detectSignals, LOCAL, PI, predictSplit, type Assignment, type Routing, type SplitObservation, type SplitPrediction } from "./routing.ts";
@@ -195,7 +195,7 @@ export class Tasks {
195
195
  const candidate = reviewer ? a.reviewer : a.owner;
196
196
  this.d.notify(`${message} ${reviewer ? "Reviewer" : "Owner"} reassignment suggestion: ${candidate ?? "no eligible peer"}.`);
197
197
  if (!reviewer && candidate && this.sweepConfig.auto_reassign && this.sweepAvailable(candidate)) {
198
- await this.assignOwner(this.d.board.get(task.id)!, HUB, { candidates: [candidate], exclude: [responsible], event: "reassigned", note: `idle sweep: ${finding.kind}; from ${responsible}` });
198
+ await this.assignOwner(this.d.board.get(task.id)!, HUB, { candidates: [candidate], exclude: [responsible], event: "reassigned", reason: "idle", note: `idle sweep: ${finding.kind}; from ${responsible}` });
199
199
  }
200
200
  }
201
201
  }
@@ -236,9 +236,19 @@ export class Tasks {
236
236
  publicTitle = (task: Task) => (this.isPii(task) ? `#${task.id} [pii]` : `#${task.id} ${task.title}`);
237
237
 
238
238
  /** A task as a cloud peer may see it. */
239
- publicView(task: Task): Record<string, unknown> {
239
+ publicView(task: Task, fullHistory = false): Record<string, unknown> {
240
240
  const { title, detail, history, ...rest } = task;
241
- return this.isPii(task) ? { ...rest, refs: {}, plan: {}, title: "[pii]", detail: "[pii]" } : { ...rest, title, detail, history: history.slice(-5) };
241
+ if (this.isPii(task)) return { ...rest, refs: {}, plan: {}, title: "[pii]", detail: "[pii]" };
242
+ const screenHistoryText = (text: string, field: string): string => this.nameable(text) ? text : `[history ${field} withheld: it matches a PII pattern; ahub task show ${task.id}]`;
243
+ const publicHistory = (fullHistory ? history : history.slice(-5)).map(h => ({
244
+ at: h.at, by: h.by, event: h.event,
245
+ ...(h.note === undefined ? {} : { note: screenHistoryText(h.note, "note") }),
246
+ ...(h.profile === undefined ? {} : { profile: screenHistoryText(h.profile, "profile") }),
247
+ ...(h.owner === undefined ? {} : { owner: h.owner }),
248
+ ...(h.reason && TASK_MOVE_REASONS.includes(h.reason) ? { reason: h.reason } : {}),
249
+ ...(h.sweep ? { sweep: { kind: h.sweep.kind, activity: h.sweep.activity, step: h.sweep.step, at: h.sweep.at } } : {}),
250
+ }));
251
+ return { ...rest, title, detail, history: publicHistory };
242
252
  }
243
253
 
244
254
  /** Console results expose direct approval and delivery holds, not just the chosen peer ids. */
@@ -641,7 +651,7 @@ export class Tasks {
641
651
  if (!assign(task, this.states(), this.d.routing(), { exclude: [...this.declined(task), peer], ...this.health() }).owner) continue;
642
652
  try {
643
653
  const back = task.state === "in_progress" ? this.d.board.update(task.id, HUB, "released", { state: "proposed" }, why) : task;
644
- const next = await this.assignOwner(back, HUB, { exclude: [peer], event: "reassigned", note: why });
654
+ const next = await this.assignOwner(back, HUB, { exclude: [peer], event: "reassigned", reason: "offline", note: why });
645
655
  moved.push({ id: task.id, title: this.publicTitle(task), to: next.owner });
646
656
  // The gone owner hears it on its next delivery, if it comes back mid-work.
647
657
  if (next.owner && next.owner !== peer) this.d.tell?.(peer, noteLine(HUB, "decision", `task #${task.id} moved to ${next.owner} while you were offline; stop working on it`));
@@ -669,6 +679,11 @@ export class Tasks {
669
679
  return assign({ class: target.class, signals: detectSignals(draft, routing, this.d.cwd) }, this.states(), routing, this.weights(target.class)).trace;
670
680
  }
671
681
 
682
+ /** Public read path: every generated line is screened, including profile/config-derived trace fields. */
683
+ publicExplain(target: number | { title: string; detail?: string; class: TaskClass; refs?: TaskRefs }): string[] {
684
+ return this.explain(target).map(line => this.nameable(line) ? line : "[routing explanation withheld: it matches a PII pattern]");
685
+ }
686
+
672
687
  private declined = (task: Task) => task.history.filter((h) => h.event === "declined").map((h) => h.by);
673
688
 
674
689
  /** Why the task will approve directly at done: no reviewer could be picked, and who was skipped (issue #92). */
@@ -686,7 +701,7 @@ export class Tasks {
686
701
  if (hold) this.d.notify(`task ${this.publicTitle(task)}: ${a.owner}'s queue is held (${hold}); the task arrives once the hold is resolved`);
687
702
  }
688
703
 
689
- private async assignOwner(task: Task, by: PeerId, opts: { candidates?: PeerId[]; event?: string; note?: string; clearOnFail?: boolean; exclude?: PeerId[]; context?: string; claim?: boolean } = {}): Promise<Task> {
704
+ private async assignOwner(task: Task, by: PeerId, opts: { candidates?: PeerId[]; event?: string; note?: string; reason?: TaskMoveReason; clearOnFail?: boolean; exclude?: PeerId[]; context?: string; claim?: boolean } = {}): Promise<Task> {
690
705
  const waits = this.waitsFor(task);
691
706
  const a = assign(task, this.states(), this.d.routing(), { exclude: [...this.declined(task), ...(opts.exclude ?? []), ...(opts.event === "escalated" && task.owner ? [task.owner] : [])], ...(opts.candidates ? { candidates: opts.candidates } : {}), waitsFor: waits, ...this.weights(task.class) });
692
707
 
@@ -704,7 +719,7 @@ export class Tasks {
704
719
  return task;
705
720
  }
706
721
  const profile = this.d.splitProfile?.(a.owner);
707
- const next = this.d.board.update(task.id, by, opts.event ?? "assigned", { owner: a.owner, reviewer: a.reviewer ?? null, ...(opts.event === "escalated" ? { rejections: 0 } : {}) }, opts.note ?? `to ${a.owner}`, profile ? { profile } : {});
722
+ const next = this.d.board.update(task.id, by, opts.event ?? "assigned", { owner: a.owner, reviewer: a.reviewer ?? null, ...(opts.event === "escalated" ? { rejections: 0 } : {}) }, opts.note ?? `to ${a.owner}`, { ...(profile ? { profile } : {}), ...(opts.reason ? { reason: opts.reason } : {}) });
708
723
  // What calibration reads (issue #109): routing chose the first owner (no single named candidate, no claim; not an
709
724
  // escalation, relay or reassignment of work already begun), and the work overlaps another owner's task not started
710
725
  // yet. For the record only. Work routed back to its proposer is left out, as its observations are (by === owner).
@@ -893,7 +908,7 @@ export class Tasks {
893
908
  this.mine(task, by, "owner");
894
909
  const back = this.d.board.update(task.id, by, "declined", task.state === "in_progress" ? { state: "proposed" } : {}, reason);
895
910
  this.d.notify(`task ${this.publicTitle(back)} declined by ${by}${reason && !this.isPii(back) ? `: ${reason}` : ""}`);
896
- return this.assignOwner(back, HUB, { event: "reassigned", clearOnFail: true });
911
+ return this.assignOwner(back, HUB, { event: "reassigned", reason: "declined", clearOnFail: true });
897
912
  }
898
913
 
899
914
  /** Tasks whose check is queued or running, and the owner it was started for. */
@@ -1182,7 +1197,7 @@ export class Tasks {
1182
1197
  this.contradict(rejected);
1183
1198
  this.note(rejected, by, "decision", `Task #${rejected.id} changes requested by ${by}: ${rejected.title}\n${note ?? ""}`);
1184
1199
  if (rejected.rejections >= ESCALATE_AFTER) {
1185
- const moved = await this.escalate(HUB, rejected.id, `${rejected.rejections} consecutive changes_requested`);
1200
+ const moved = await this.escalate(HUB, rejected.id, `${rejected.rejections} consecutive changes_requested`, "rejections");
1186
1201
  if (moved.owner !== rejected.owner) return moved;
1187
1202
  // Nobody to escalate to: the owner still has to hear the verdict and the note.
1188
1203
  this.tell(moved, `Task #${moved.id}: ${by} requests changes again.${note ? ` ${this.screen(moved, note, "review note", moved.owner)}` : ""} Nobody else can take it; fix it and call hub_task_done again.`, pii);
@@ -1194,7 +1209,7 @@ export class Tasks {
1194
1209
  }
1195
1210
 
1196
1211
  /** Task-level escalation: the next attached peer in the class's escalate_to takes over, with the history. */
1197
- async escalate(by: PeerId, id: unknown, why = "by hand"): Promise<Task> {
1212
+ async escalate(by: PeerId, id: unknown, why = "by hand", reason: TaskMoveReason | undefined = by === HUB ? undefined : "manual"): Promise<Task> {
1198
1213
  let task = this.need(id, true);
1199
1214
  const list = this.d.routing().classes[task.class]?.escalate_to ?? [];
1200
1215
  if (task.state === "changes_requested") task = this.d.board.update(task.id, HUB, "reopened", { state: "in_progress" });
@@ -1204,7 +1219,7 @@ export class Tasks {
1204
1219
  if (from && by !== HUB) this.d.board.recordOutcome(from, task.class, false);
1205
1220
  // Only a reviewer that asked for changes on this work saw it fail: not an escalation of unreviewed work (a Pi failure).
1206
1221
  if (from && task.reviewer && task.reviewer !== USER && this.requestedChanges(task).has(task.reviewer)) this.d.board.recordReview({ implementer: from, reviewer: task.reviewer, class: task.class, kind: "escalated", task: task.id });
1207
- const next = await this.assignOwner(task, by, { candidates: list, event: "escalated", note: `${why}; from ${from ?? "none"}`, context: why });
1222
+ const next = await this.assignOwner(task, by, { candidates: list, event: "escalated", reason, note: `${why}; from ${from ?? "none"}`, context: why });
1208
1223
  if (next.owner && next.owner !== from) {
1209
1224
  this.d.notify(`task ${this.publicTitle(next)} escalated from ${from} to ${next.owner} (${why})`);
1210
1225
  this.note(next, by, "decision", `Task #${next.id} escalated from ${from} to ${next.owner}: ${why}`);
@@ -1213,9 +1228,9 @@ export class Tasks {
1213
1228
  return this.d.board.get(next.id)!;
1214
1229
  }
1215
1230
 
1216
- /** Console only. */
1217
- async assignTo(id: unknown, peer: PeerId): Promise<Task> {
1218
- return this.assignOwner(this.need(id, true), USER, { candidates: [peer], event: "reassigned" });
1231
+ /** Caller authority is checked by the daemon; keep the real actor in task history. */
1232
+ async assignTo(id: unknown, peer: PeerId, by: PeerId = USER): Promise<Task> {
1233
+ return this.assignOwner(this.need(id, true), by, { candidates: [peer], event: "reassigned", reason: "manual" });
1219
1234
  }
1220
1235
 
1221
1236
  /**
@@ -1232,7 +1247,7 @@ export class Tasks {
1232
1247
  const pii = this.isPii(task);
1233
1248
  const candidates = [pii ? LOCAL : PI, pii ? undefined : LOCAL, ...(routing.classes[task.class]?.peers ?? []).filter((p) => p !== LOCAL && p !== PI)].filter((p): p is PeerId => !!p);
1234
1249
  const back = task.state === "in_progress" ? this.d.board.update(task.id, HUB, "released", { state: "proposed" }, `budget pause of ${peer}`) : task;
1235
- const next = await this.assignOwner(back, HUB, { candidates, exclude: [peer], event: "reassigned", note: `budget pause of ${peer}`, clearOnFail: true, ...(context ? { context } : {}) });
1250
+ const next = await this.assignOwner(back, HUB, { candidates, exclude: [peer], event: "reassigned", reason: "budget", note: `budget pause of ${peer}`, clearOnFail: true, ...(context ? { context } : {}) });
1236
1251
  moved.push({ id: task.id, title: this.publicTitle(task), to: next.owner, role: "owner" });
1237
1252
  } else if (task.reviewer === peer && task.state !== "approved") {
1238
1253
  // No owner candidates: only the reviewer is wanted, and it must be neither the paused peer nor the task's owner.
package/src/hub/usage.ts CHANGED
@@ -4,6 +4,8 @@ import { normalizeUsage, safeModelLabel, type NormalizedUsage } from "../omnirou
4
4
 
5
5
  export interface UsageRecord {
6
6
  id: string;
7
+ /** True only for a native assistant end_turn row, never a tool-use/compaction message. */
8
+ completedTurn?: boolean;
7
9
  at?: string;
8
10
  usage?: NormalizedUsage;
9
11
  requestedModel?: string;
@@ -11,6 +13,39 @@ export interface UsageRecord {
11
13
  provider?: string;
12
14
  }
13
15
 
16
+ /** Claude-native counters have separate input/output/cache categories, often no explicit total. */
17
+ export function claudeReportedTokens(usage: NormalizedUsage | undefined): number | undefined {
18
+ if (!usage) return undefined;
19
+ if (usage.totalTokens !== undefined) return usage.totalTokens;
20
+ if (usage.inputTokens === undefined || usage.outputTokens === undefined) return undefined;
21
+ const total = usage.inputTokens + usage.outputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0);
22
+ return Number.isSafeInteger(total) && total >= 0 ? total : undefined;
23
+ }
24
+
25
+ export type ClaudeCompletionWait = { record?: UsageRecord; reason: "verified" | "unavailable" | "baseline" | "before-start" | "superseded" };
26
+ /** After acknowledging Stop, observe its delayed transcript append within a bounded 1200 ms window. */
27
+ export async function waitForClaudeCompletion(read: () => UsageRecord | undefined, current: () => boolean, start: { at: number; baseline?: string }, timeoutMs = 1200): Promise<ClaudeCompletionWait> {
28
+ const budget = Number.isFinite(timeoutMs) ? Math.min(1200, Math.max(0, timeoutMs)) : 1200;
29
+ if (budget === 0) return { reason: "unavailable" };
30
+ const deadline = performance.now() + budget;
31
+ let reason: ClaudeCompletionWait["reason"] = "unavailable";
32
+ while (true) {
33
+ if (!current()) return { reason: "superseded" };
34
+ const record = read();
35
+ if (!current()) return { reason: "superseded" };
36
+ if (performance.now() > deadline) return { reason };
37
+ const at = typeof record?.at === "string" ? Date.parse(record.at) : NaN;
38
+ if (record?.completedTurn && Number.isFinite(at)) {
39
+ if (record.id === start.baseline) reason = "baseline";
40
+ else if (at < start.at) reason = "before-start";
41
+ else return { record, reason: "verified" };
42
+ } else reason = "unavailable";
43
+ const left = deadline - performance.now();
44
+ if (left <= 0) return { reason };
45
+ await Bun.sleep(Math.min(40, left));
46
+ }
47
+ }
48
+
14
49
  function opaqueId(sessionId: string, messageId: string): string {
15
50
  return createHash("sha256").update(sessionId).update("\0").update(messageId).digest("hex");
16
51
  }
@@ -30,7 +65,7 @@ export function readClaudeTranscriptUsage(sessionId: string, transcriptPath: str
30
65
  // Signal unavailable coverage with a stable opaque id; a repeated poll dedupes in the report.
31
66
  return [{ id: opaqueId(sessionId, "transcript-too-large") }];
32
67
  }
33
- const latest = new Map<string, { usage?: NormalizedUsage; at?: string; servedModel?: string }>();
68
+ const latest = new Map<string, { usage?: NormalizedUsage; at?: string; servedModel?: string; completedTurn?: boolean }>();
34
69
  for (const line of readFileSync(transcriptPath, "utf8").split("\n")) {
35
70
  if (!line.trim()) continue;
36
71
  try {
@@ -43,7 +78,7 @@ export function readClaudeTranscriptUsage(sessionId: string, transcriptPath: str
43
78
  const usage = supportedStopReason ? normalizeUsage(message.usage) : undefined;
44
79
  const at = typeof row.timestamp === "string" && Number.isFinite(Date.parse(row.timestamp)) ? new Date(row.timestamp).toISOString() : undefined;
45
80
  const servedModel = safeModelLabel(message.model);
46
- latest.set(message.id, { ...(usage ? { usage } : {}), ...(at ? { at } : {}), ...(servedModel ? { servedModel } : {}) });
81
+ latest.set(message.id, { ...(usage ? { usage } : {}), ...(at ? { at } : {}), ...(servedModel ? { servedModel } : {}), ...(message.stop_reason === "end_turn" ? { completedTurn: true } : {}) });
47
82
  } catch {
48
83
  // A streaming or crash-truncated line is not a record.
49
84
  }
package/src/pi/launch.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { join } from "node:path";
2
+ import { peerChildEnv } from "../hub/child-process.ts";
2
3
  import type { PiOptions, PiModelDescriptor, PiTuiLaunch } from "../adapters/pi.ts";
3
4
 
4
5
  const modelFor = (backend: PiOptions["backend"], models: PiModelDescriptor[]): string => {
@@ -12,7 +13,7 @@ const modelFor = (backend: PiOptions["backend"], models: PiModelDescriptor[]): s
12
13
 
13
14
  /** Pure native launch; bridge/relay setup and session validation remain with the adapter. */
14
15
  export function buildPiLaunch(opts: Pick<PiOptions, "stateDir" | "cmd" | "mode" | "backend" | "model" | "relay" | "tools" | "preamble" | "maxSteps" | "sessionFile" | "sessionId">, inherited: NodeJS.ProcessEnv, extension: string, bridgePort: number | string, bridgeToken: string): PiTuiLaunch {
15
- const env: NodeJS.ProcessEnv = { ...inherited, PI_CODING_AGENT_DIR: join(opts.stateDir, "pi"), AGENTHUB_PI_BRIDGE_URL: `http://127.0.0.1:${bridgePort}`, AGENTHUB_PI_BRIDGE_TOKEN: bridgeToken, AGENTHUB_PI_OWNER_TOKEN: bridgeToken, AGENTHUB_PI_RELAY_URL: opts.relay.url, AGENTHUB_PI_RELAY_TOKEN: opts.relay.token, AGENTHUB_PI_MODELS: JSON.stringify(opts.relay.models), AGENTHUB_PI_TOOLS: JSON.stringify(opts.tools), AGENTHUB_PI_MAX_STEPS: String(opts.maxSteps ?? 30) };
16
+ const env: NodeJS.ProcessEnv = { ...peerChildEnv("pi", inherited), PI_CODING_AGENT_DIR: join(opts.stateDir, "pi"), AGENTHUB_PI_BRIDGE_URL: `http://127.0.0.1:${bridgePort}`, AGENTHUB_PI_BRIDGE_TOKEN: bridgeToken, AGENTHUB_PI_OWNER_TOKEN: bridgeToken, AGENTHUB_PI_RELAY_URL: opts.relay.url, AGENTHUB_PI_RELAY_TOKEN: opts.relay.token, AGENTHUB_PI_MODELS: JSON.stringify(opts.relay.models), AGENTHUB_PI_TOOLS: JSON.stringify(opts.tools), AGENTHUB_PI_MAX_STEPS: String(opts.maxSteps ?? 30) };
16
17
  const args = [ ...(opts.mode === "headless" ? ["--mode", "rpc"] : []), "--provider", "agent-hub-local", "--model", opts.model ?? modelFor(opts.backend, opts.relay.models), "--models", opts.relay.models.map((model) => `agent-hub-local/${model.id}`).join(","), "--no-builtin-tools", "--no-skills", "--no-prompt-templates", "--no-extensions", "--extension", extension, ...(opts.preamble ? ["--append-system-prompt", opts.preamble] : []), "--session-dir", join(opts.stateDir, "pi-sessions")];
17
18
  if (opts.sessionFile) args.push("--session", opts.sessionFile); else if (opts.sessionId) args.push("--session-id", opts.sessionId);
18
19
  const command = opts.cmd ?? ["pi"];