@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.
- package/CHANGELOG.md +8 -0
- package/README.md +4 -4
- package/docs/agent-notes/adapters.md +1 -0
- package/docs/agent-notes/bus.md +2 -0
- package/docs/agent-notes/tasks.md +3 -1
- package/docs/agent-notes/tests.md +1 -0
- package/docs/operations.md +106 -9
- package/docs/quickstart.md +3 -3
- package/docs/security.md +24 -0
- package/docs/smoke.md +93 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +120 -1
- package/docs/verification/2026-10-09-agent-shell-t0.md +123 -0
- package/docs/verified.json +26 -17
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +17 -5
- package/src/adapters/acp.ts +2 -2
- package/src/adapters/claude-channel.ts +3 -2
- package/src/adapters/codex-appserver.ts +3 -2
- package/src/adapters/local-worker.ts +5 -5
- 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/launch.ts +15 -2
- package/src/cli/main.ts +62 -29
- package/src/cli/tail-render.ts +17 -0
- package/src/cli/upgrade-runtime.ts +1 -1
- package/src/hub/board.ts +6 -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/control-client.ts +3 -3
- package/src/hub/daemon.ts +367 -46
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +6 -1
- package/src/hub/hub-tools.ts +13 -0
- package/src/hub/report.ts +53 -4
- package/src/hub/supervision.ts +152 -0
- package/src/hub/tasks.ts +30 -15
- package/src/hub/usage.ts +37 -2
- package/src/pi/launch.ts +2 -1
package/src/hub/envelope.ts
CHANGED
|
@@ -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 }
|
package/src/hub/hub-tools.ts
CHANGED
|
@@ -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)))
|
|
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
|
-
|
|
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
|
-
/**
|
|
1217
|
-
async assignTo(id: unknown, peer: PeerId): Promise<Task> {
|
|
1218
|
-
return this.assignOwner(this.need(id, true),
|
|
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"];
|