pi-extensible-workflows 0.3.0

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 (47) hide show
  1. package/README.md +72 -0
  2. package/dist/src/agent-execution.d.ts +213 -0
  3. package/dist/src/agent-execution.js +537 -0
  4. package/dist/src/ambient-workflow-evals.d.ts +112 -0
  5. package/dist/src/ambient-workflow-evals.js +375 -0
  6. package/dist/src/cli.d.ts +6 -0
  7. package/dist/src/cli.js +26 -0
  8. package/dist/src/doctor.d.ts +59 -0
  9. package/dist/src/doctor.js +269 -0
  10. package/dist/src/eval-capture-extension.d.ts +5 -0
  11. package/dist/src/eval-capture-extension.js +48 -0
  12. package/dist/src/index.d.ts +362 -0
  13. package/dist/src/index.js +2839 -0
  14. package/dist/src/persistence.d.ts +90 -0
  15. package/dist/src/persistence.js +530 -0
  16. package/dist/src/session-inspector.d.ts +59 -0
  17. package/dist/src/session-inspector.js +396 -0
  18. package/dist/src/workflow-evals-child.d.ts +1 -0
  19. package/dist/src/workflow-evals-child.js +13 -0
  20. package/dist/src/workflow-evals.d.ts +290 -0
  21. package/dist/src/workflow-evals.js +1221 -0
  22. package/evals/cases/custom-model-read.yaml +15 -0
  23. package/evals/cases/direct-answer.yaml +6 -0
  24. package/evals/cases/mixed-parallel-pipeline.yaml +18 -0
  25. package/evals/cases/output-schema.yaml +22 -0
  26. package/evals/cases/parallel.yaml +15 -0
  27. package/evals/cases/pipeline.yaml +10 -0
  28. package/evals/cases/ready-for-agent-parallel-merge.yaml +32 -0
  29. package/evals/cases/required-role.yaml +14 -0
  30. package/evals/cases/role-model-mixed.yaml +18 -0
  31. package/evals/cases/two-agents.yaml +10 -0
  32. package/package.json +65 -0
  33. package/skills/pi-extensible-workflows/SKILL.md +109 -0
  34. package/src/agent-execution.ts +529 -0
  35. package/src/ambient-workflow-evals.ts +452 -0
  36. package/src/cli.ts +23 -0
  37. package/src/doctor.ts +285 -0
  38. package/src/eval-capture-extension.ts +54 -0
  39. package/src/index.ts +2519 -0
  40. package/src/persistence.ts +470 -0
  41. package/src/session-inspector.ts +370 -0
  42. package/src/workflow-evals-child.ts +15 -0
  43. package/src/workflow-evals.ts +876 -0
  44. package/test/fixtures/ready-for-agent-tasks.md +9 -0
  45. package/test/fixtures/workflow-eval-roles/developer.md +18 -0
  46. package/test/fixtures/workflow-eval-roles/reviewer.md +18 -0
  47. package/test/fixtures/workflow-eval-roles/scout.md +17 -0
@@ -0,0 +1,370 @@
1
+ import { existsSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { emitKeypressEvents } from "node:readline";
4
+ import { createInterface } from "node:readline/promises";
5
+ import { stdin, stdout } from "node:process";
6
+ import { highlightCode, initTheme, SessionManager, truncateToVisualLines, type SessionEntry, type SessionInfo } from "@earendil-works/pi-coding-agent";
7
+ import { inspectWorkflowScript, type ModelSpec, type StaticWorkflowCall } from "./index.js";
8
+ import { listRunIds, RunStore, type PersistedRun } from "./persistence.js";
9
+
10
+ export interface ModelUsage { model: string; cost: number }
11
+ export interface AttemptReport { attempt: number; prompt: string; model: string; thinking?: ModelSpec["thinking"]; cost: number; models: readonly ModelUsage[]; error?: string }
12
+ export interface AgentReport { name: string; label?: string; state: string; role?: string; model: string; thinking?: ModelSpec["thinking"]; cost: number; attempts: readonly AttemptReport[] }
13
+ export interface WorkflowReport { name: string; description?: string; status: string; runId?: string; script?: string; calls: readonly StaticWorkflowCall[]; parseError?: string; cost: number; models: readonly ModelUsage[]; agents: readonly AgentReport[] }
14
+ export interface SessionReport { id: string; cwd: string; path: string; cost: number; models: readonly ModelUsage[]; workflows: readonly WorkflowReport[]; totalCost: number; totalModels: readonly ModelUsage[] }
15
+ export interface InspectorViewState { view: "list" | "detail" | "script"; selected: number; scroll: number }
16
+
17
+ type TranscriptSummary = { prompt?: string; cost: number; models: readonly ModelUsage[]; model?: string; thinking?: ModelSpec["thinking"] };
18
+ type ToolResult = { toolCallId: string; isError: boolean; content: unknown; details?: unknown };
19
+ type WorkflowCall = { id: string; arguments: Record<string, unknown> };
20
+ type LoadedRun = { run: PersistedRun; snapshot: Readonly<{ script: string; metadata: { description?: string } }> };
21
+
22
+ function text(content: unknown): string {
23
+ if (typeof content === "string") return content;
24
+ if (!Array.isArray(content)) return "";
25
+ const parts: unknown[] = content;
26
+ return parts.flatMap((part) => typeof part === "object" && part !== null && "type" in part && part.type === "text" && "text" in part && typeof part.text === "string" ? [part.text] : []).join("");
27
+ }
28
+
29
+ function transcriptPartLines(part: unknown): string[] {
30
+ if (typeof part !== "object" || part === null || !("type" in part)) return [];
31
+ const value = part as Record<string, unknown>;
32
+ if (value.type === "text" && typeof value.text === "string") return value.text.split("\n");
33
+ if (value.type === "thinking" && typeof value.thinking === "string") return ["Thinking:", ...value.thinking.split("\n")];
34
+ if (value.type === "toolCall" && typeof value.name === "string") return [`Tool call: ${value.name}`, JSON.stringify(value.arguments, null, 2)];
35
+ if (value.type === "image") return ["[image]"];
36
+ return [];
37
+ }
38
+
39
+ function transcriptMessageLines(message: unknown): string[] {
40
+ if (typeof message !== "object" || message === null) return ["(invalid message)"];
41
+ const value = message as Record<string, unknown>;
42
+ const role = typeof value.role === "string" ? value.role : "message";
43
+ const label = role === "toolResult" && typeof value.toolName === "string" ? `${role}: ${value.toolName}` : role === "custom" && typeof value.customType === "string" ? `${role}: ${value.customType}` : role;
44
+ const content = Array.isArray(value.content) ? value.content.flatMap(transcriptPartLines) : typeof value.content === "string" ? value.content.split("\n") : [];
45
+ return [`[${label}]`, ...(content.length ? content : ["(empty)"])];
46
+ }
47
+
48
+ export function transcriptLines(entries: readonly SessionEntry[]): string[] {
49
+ if (!entries.length) return ["(no active transcript entries)"];
50
+ return entries.flatMap((entry, index) => {
51
+ const lines = entry.type === "message" ? transcriptMessageLines(entry.message) : entry.type === "model_change" ? [`[model] ${entry.provider}/${entry.modelId}`] : entry.type === "thinking_level_change" ? [`[thinking] ${entry.thinkingLevel}`] : entry.type === "compaction" ? ["[compaction]", ...entry.summary.split("\n")] : entry.type === "branch_summary" ? ["[branch summary]", ...entry.summary.split("\n")] : entry.type === "custom_message" ? [`[custom_message: ${entry.customType}]`, ...(typeof entry.content === "string" ? entry.content.split("\n") : entry.content.flatMap(transcriptPartLines))] : entry.type === "custom" ? [`[custom: ${entry.customType}]`] : entry.type === "label" ? [`[label] ${entry.label ?? ""}`] : [`[session info] ${entry.name ?? ""}`];
52
+ return index ? ["", ...lines] : lines;
53
+ });
54
+ }
55
+
56
+ function mergedModels(groups: readonly (readonly ModelUsage[])[]): ModelUsage[] {
57
+ const totals = new Map<string, number>();
58
+ for (const group of groups) for (const item of group) totals.set(item.model, (totals.get(item.model) ?? 0) + item.cost);
59
+ return [...totals].map(([model, cost]) => ({ model, cost })).sort((a, b) => b.cost - a.cost || a.model.localeCompare(b.model));
60
+ }
61
+
62
+ const thinkingLevels = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
63
+ function parsedThinking(value: unknown): ModelSpec["thinking"] | undefined {
64
+ return typeof value === "string" && thinkingLevels.includes(value as (typeof thinkingLevels)[number]) ? value as ModelSpec["thinking"] : undefined;
65
+ }
66
+
67
+ function modelName(provider: unknown, model: unknown): string | undefined {
68
+ return typeof provider === "string" && provider && typeof model === "string" && model ? `${provider}/${model}` : undefined;
69
+ }
70
+
71
+ function transcript(manager: SessionManager): TranscriptSummary {
72
+ const models = new Map<string, number>();
73
+ let cost = 0;
74
+ let prompt: string | undefined;
75
+ let model: string | undefined;
76
+ let thinking: ModelSpec["thinking"] | undefined;
77
+ for (const entry of manager.getEntries()) {
78
+ if (entry.type === "model_change") {
79
+ model = modelName(entry.provider, entry.modelId);
80
+ if (!model) throw new Error("Invalid model policy");
81
+ continue;
82
+ }
83
+ if (entry.type === "thinking_level_change") {
84
+ thinking = parsedThinking(entry.thinkingLevel);
85
+ if (thinking === undefined) throw new Error("Invalid thinking policy");
86
+ continue;
87
+ }
88
+ if (entry.type !== "message") continue;
89
+ const message = entry.message;
90
+ if (message.role === "user" && prompt === undefined) {
91
+ const full = text(message.content);
92
+ const marker = "\n\nTask:\n";
93
+ prompt = full.includes(marker) ? full.slice(full.indexOf(marker) + marker.length) : full;
94
+ }
95
+ if (message.role === "assistant") {
96
+ const actualModel = modelName(message.provider, message.model);
97
+ const messageCost = message.usage.cost.total;
98
+ if (!actualModel || typeof messageCost !== "number" || !Number.isFinite(messageCost)) throw new Error("Invalid assistant policy");
99
+ model = actualModel;
100
+ cost += messageCost;
101
+ models.set(actualModel, (models.get(actualModel) ?? 0) + messageCost);
102
+ }
103
+ }
104
+ return { ...(prompt !== undefined ? { prompt } : {}), cost, models: [...models].map(([model, modelCost]) => ({ model, cost: modelCost })), ...(model !== undefined ? { model } : {}), ...(thinking !== undefined ? { thinking } : {}) };
105
+ }
106
+
107
+ function readTranscript(path: string): TranscriptSummary | undefined {
108
+ try {
109
+ if (!existsSync(path) || !statSync(path).isFile() || statSync(path).size === 0) return undefined;
110
+ const manager = SessionManager.open(path);
111
+ if (!manager.getHeader()) return undefined;
112
+ const summary = transcript(manager);
113
+ return summary.model === undefined ? undefined : summary;
114
+ } catch { return undefined; }
115
+ }
116
+
117
+ function resultRunId(result: ToolResult | undefined): string | undefined {
118
+ if (!result) return undefined;
119
+ if (typeof result.details === "object" && result.details !== null && "runId" in result.details && typeof result.details.runId === "string") return result.details.runId;
120
+ const raw = text(result.content);
121
+ try {
122
+ const parsed: unknown = JSON.parse(raw);
123
+ return typeof parsed === "object" && parsed !== null && "runId" in parsed && typeof parsed.runId === "string" ? parsed.runId : undefined;
124
+ } catch { return undefined; }
125
+ }
126
+
127
+ function workflowEntries(manager: SessionManager): { calls: WorkflowCall[]; results: Map<string, ToolResult> } {
128
+ const calls: WorkflowCall[] = [];
129
+ const results = new Map<string, ToolResult>();
130
+ for (const entry of manager.getEntries()) {
131
+ if (entry.type !== "message") continue;
132
+ const message = entry.message;
133
+ if (message.role === "assistant") for (const part of message.content) {
134
+ if (part.type === "toolCall" && part.name === "workflow") calls.push({ id: part.id, arguments: part.arguments });
135
+ }
136
+ if (message.role === "toolResult" && message.toolName === "workflow") results.set(message.toolCallId, { toolCallId: message.toolCallId, isError: message.isError, content: message.content, details: message.details as unknown });
137
+ }
138
+ return { calls, results };
139
+ }
140
+
141
+ async function loadRuns(cwd: string, sessionId: string, home: string): Promise<Map<string, LoadedRun>> {
142
+ const runs = new Map<string, LoadedRun>();
143
+ for (const runId of await listRunIds(cwd, sessionId, home)) {
144
+ try { runs.set(runId, await new RunStore(cwd, sessionId, runId, home).load()); } catch { /* Ignore corrupt or concurrently removed runs. */ }
145
+ }
146
+ return runs;
147
+ }
148
+
149
+ async function agentReport(agent: PersistedRun["agents"][number]): Promise<AgentReport> {
150
+ const fallbackModel = `${agent.model.provider}/${agent.model.model}`;
151
+ const fallbackThinking = agent.model.thinking;
152
+ const attempts: AttemptReport[] = [];
153
+ for (const attempt of agent.attemptDetails ?? []) {
154
+ const log = readTranscript(attempt.sessionFile);
155
+ if (log) {
156
+ const model = log.model ?? fallbackModel;
157
+ const cost = log.cost;
158
+ attempts.push({
159
+ attempt: attempt.attempt,
160
+ prompt: log.prompt ?? "(transcript unavailable)",
161
+ model,
162
+ ...(log.thinking !== undefined ? { thinking: log.thinking } : {}),
163
+ cost,
164
+ models: log.models.length ? log.models : [{ model, cost }],
165
+ ...(attempt.error ? { error: `${attempt.error.code}: ${attempt.error.message}` } : {}),
166
+ });
167
+ continue;
168
+ }
169
+ const cost = attempt.accounting.cost;
170
+ attempts.push({
171
+ attempt: attempt.attempt,
172
+ prompt: "(transcript unavailable)",
173
+ model: fallbackModel,
174
+ ...(fallbackThinking !== undefined ? { thinking: fallbackThinking } : {}),
175
+ cost,
176
+ models: [{ model: fallbackModel, cost }],
177
+ ...(attempt.error ? { error: `${attempt.error.code}: ${attempt.error.message}` } : {}),
178
+ });
179
+ }
180
+ if (!attempts.length) {
181
+ const cost = agent.accounting?.cost ?? 0;
182
+ attempts.push({ attempt: 1, prompt: "(transcript unavailable)", model: fallbackModel, ...(fallbackThinking !== undefined ? { thinking: fallbackThinking } : {}), cost, models: [{ model: fallbackModel, cost }] });
183
+ }
184
+ const latest = attempts[attempts.length - 1];
185
+ return { name: agent.name, ...(agent.label ? { label: agent.label } : {}), state: agent.state, ...(agent.role ? { role: agent.role } : {}), model: latest?.model ?? fallbackModel, ...(latest?.thinking !== undefined ? { thinking: latest.thinking } : {}), cost: attempts.reduce((sum, attempt) => sum + attempt.cost, 0), attempts };
186
+ }
187
+
188
+ export function matchSession(query: string, sessions: readonly SessionInfo[]): SessionInfo {
189
+ const exact = sessions.filter(({ id }) => id === query);
190
+ if (exact[0]) return exact[0];
191
+ const partial = sessions.filter(({ id }) => id.startsWith(query));
192
+ if (partial.length === 1 && partial[0]) return partial[0];
193
+ if (!partial.length) throw new Error(`Session not found: ${query}`);
194
+ throw new Error(`Session ID is ambiguous: ${query}`);
195
+ }
196
+
197
+ export async function loadSessionReport(path: string, home = homedir()): Promise<SessionReport> {
198
+ const manager = SessionManager.open(path);
199
+ const header = manager.getHeader();
200
+ if (!header) throw new Error(`Invalid session file: ${path}`);
201
+ const parent = transcript(manager);
202
+ const { calls, results } = workflowEntries(manager);
203
+ const runs = await loadRuns(header.cwd, header.id, home);
204
+ const workflows: WorkflowReport[] = [];
205
+ for (const call of calls) {
206
+ const result = results.get(call.id);
207
+ const runId = resultRunId(result);
208
+ const loaded = runId ? runs.get(runId) : undefined;
209
+ const args = call.arguments;
210
+ const agents = loaded ? await Promise.all(loaded.run.agents.map(agentReport)) : [];
211
+ const models = mergedModels(agents.flatMap(({ attempts }) => attempts.map(({ models: attemptModels }) => attemptModels)));
212
+ const name = typeof args.name === "string" ? args.name : typeof args.workflow === "string" ? args.workflow : loaded?.run.workflowName ?? "workflow";
213
+ const description = typeof args.description === "string" ? args.description : loaded?.snapshot.metadata.description;
214
+ const script = typeof args.script === "string" && args.script.trim() ? args.script : loaded?.snapshot.script;
215
+ let staticCalls: StaticWorkflowCall[] = [];
216
+ let parseError: string | undefined;
217
+ if (script) {
218
+ try { staticCalls = inspectWorkflowScript(script); }
219
+ catch (error) { parseError = error instanceof Error ? error.message : String(error); }
220
+ }
221
+ workflows.push({
222
+ name,
223
+ ...(description ? { description } : {}),
224
+ status: loaded?.run.state ?? (result ? result.isError ? "failed" : "completed" : "pending"),
225
+ ...(runId ? { runId } : {}),
226
+ ...(script ? { script } : {}),
227
+ calls: staticCalls,
228
+ ...(parseError ? { parseError } : {}),
229
+ cost: agents.reduce((sum, agent) => sum + agent.cost, 0),
230
+ models,
231
+ agents,
232
+ });
233
+ }
234
+ const workflowCost = workflows.reduce((sum, workflow) => sum + workflow.cost, 0);
235
+ return {
236
+ id: header.id, cwd: header.cwd, path, cost: parent.cost, models: parent.models, workflows,
237
+ totalCost: parent.cost + workflowCost,
238
+ totalModels: mergedModels([parent.models, ...workflows.map(({ models }) => models)]),
239
+ };
240
+ }
241
+
242
+ const ansi = { reset: "\x1b[0m", bold: "\x1b[1m", dim: "\x1b[2m", cyan: "\x1b[36m", green: "\x1b[32m", red: "\x1b[31m", yellow: "\x1b[33m", inverse: "\x1b[7m" };
243
+ const style = (code: string, value: string) => `${code}${value}${ansi.reset}`;
244
+ const money = (cost: number) => `$${cost < 0.01 && cost > 0 ? cost.toFixed(4) : cost.toFixed(2)}`;
245
+ const modelSummary = (models: readonly ModelUsage[]) => models.length ? models.map(({ model, cost }) => `${model} ${money(cost)}`).join(" · ") : "(none)";
246
+
247
+ function wrapped(lines: readonly string[], width: number): string[] {
248
+ return lines.flatMap((line) => line ? truncateToVisualLines(line, Number.MAX_SAFE_INTEGER, Math.max(1, width), 0).visualLines : [""]);
249
+ }
250
+
251
+ function detailLines(workflow: WorkflowReport): string[] {
252
+ const lines = [
253
+ style(ansi.bold + ansi.cyan, workflow.name),
254
+ `${workflow.status} · ${money(workflow.cost)}${workflow.runId ? ` · ${workflow.runId}` : ""}`,
255
+ workflow.description ?? "",
256
+ "",
257
+ style(ansi.bold, "Models"),
258
+ modelSummary(workflow.models),
259
+ "",
260
+ style(ansi.bold, "Static workflow calls"),
261
+ ...(workflow.parseError ? [style(ansi.red, `Parse error: ${workflow.parseError}`)] : workflow.calls.length ? workflow.calls.map((call, index) => {
262
+ const fields = [call.name ? `name=${JSON.stringify(call.name)}` : "", call.prompt ? `prompt=${JSON.stringify(call.prompt)}` : call.kind === "agent" || call.kind === "checkpoint" ? "prompt=<dynamic>" : "", call.label ? `label=${call.label}` : "", call.role ? `role=${call.role}` : "", call.model ? `model=${call.model}` : ""].filter(Boolean);
263
+ return `${String(index + 1)}. ${call.kind}${fields.length ? ` · ${fields.join(" · ")}` : ""}`;
264
+ }) : ["(none)"]),
265
+ "",
266
+ style(ansi.bold, "Agents and runtime prompts"),
267
+ ];
268
+ if (!workflow.agents.length) lines.push("(no agent run was persisted)");
269
+ for (const agent of workflow.agents) {
270
+ lines.push("", style(agent.state === "completed" ? ansi.green : agent.state === "failed" ? ansi.red : ansi.yellow, `${agent.label ?? agent.name} [${agent.state}]`), `${agent.role ? `role=${agent.role} · ` : ""}${agent.model}${agent.thinking !== undefined ? `:${agent.thinking}` : ""} · ${money(agent.cost)}`);
271
+ for (const attempt of agent.attempts) {
272
+ lines.push(`Attempt ${String(attempt.attempt)} · ${attempt.model}${attempt.thinking !== undefined ? `:${attempt.thinking}` : ""} · ${money(attempt.cost)}${attempt.error ? ` · ${attempt.error}` : ""}`, `Prompt: ${attempt.prompt}`);
273
+ }
274
+ }
275
+ return lines.filter((line, index) => line || index !== 2);
276
+ }
277
+
278
+ let themeReady = false;
279
+ function highlighted(script: string): string[] {
280
+ if (!themeReady) { initTheme(undefined, false); themeReady = true; }
281
+ return highlightCode(script, "javascript");
282
+ }
283
+
284
+ export function renderInspector(report: SessionReport, state: InspectorViewState, width = 80, height = 24, highlighter: (script: string) => string[] = highlighted): string[] {
285
+ const usableWidth = Math.max(1, width);
286
+ const selected = report.workflows[state.selected];
287
+ if (state.view === "list") {
288
+ const header = wrapped([
289
+ style(ansi.bold + ansi.cyan, "Pi workflow session inspector"),
290
+ `${report.id} · ${report.cwd}`,
291
+ `Total ${money(report.totalCost)} · parent ${money(report.cost)}`,
292
+ modelSummary(report.totalModels),
293
+ "",
294
+ style(ansi.bold, `Workflows (${String(report.workflows.length)})`),
295
+ ], usableWidth);
296
+ const rows = report.workflows.length ? report.workflows.map((workflow, index) => `${index === state.selected ? style(ansi.inverse, ">") : " "} ${workflow.name} · ${workflow.status} · ${money(workflow.cost)} · ${String(workflow.agents.length)} agents`) : ["No workflow calls found."];
297
+ const footer = wrapped(["", style(ansi.dim, "↑↓ select · enter details · q quit")], usableWidth);
298
+ const room = Math.max(1, height - header.length - footer.length);
299
+ const start = Math.max(0, Math.min(state.selected - Math.floor(room / 2), rows.length - room));
300
+ return [...header, ...rows.slice(start, start + room).map((line) => wrapped([line], usableWidth)[0] ?? ""), ...footer].slice(0, height);
301
+ }
302
+ if (!selected) return wrapped(["No workflow selected.", style(ansi.dim, "esc back · q quit")], usableWidth).slice(0, height);
303
+ const title = state.view === "script" ? `${selected.name} · script` : `${selected.name} · details`;
304
+ const body = state.view === "script" ? selected.script ? highlighter(selected.script) : ["Script unavailable."] : detailLines(selected);
305
+ const fitted = wrapped(body, usableWidth);
306
+ const header = wrapped([style(ansi.bold + ansi.cyan, title)], usableWidth);
307
+ const hint = state.view === "script" ? "↑↓/pgup/pgdn scroll · esc details · q quit" : "↑↓/pgup/pgdn scroll · s script · esc workflows · q quit";
308
+ const footer = wrapped([style(ansi.dim, hint)], usableWidth);
309
+ const room = Math.max(1, height - header.length - footer.length);
310
+ const scroll = Math.max(0, Math.min(state.scroll, Math.max(0, fitted.length - room)));
311
+ return [...header, ...fitted.slice(scroll, scroll + room), ...footer].slice(0, height);
312
+ }
313
+
314
+ function nextState(current: InspectorViewState, key: string, workflowCount: number): InspectorViewState {
315
+ if (current.view === "list") {
316
+ if (key === "up") return { ...current, selected: Math.max(0, current.selected - 1) };
317
+ if (key === "down") return { ...current, selected: Math.min(Math.max(0, workflowCount - 1), current.selected + 1) };
318
+ if (key === "return" && workflowCount) return { ...current, view: "detail", scroll: 0 };
319
+ return current;
320
+ }
321
+ if (key === "escape" || key === "left") return { ...current, view: current.view === "script" ? "detail" : "list", scroll: 0 };
322
+ if ((key === "s" || key === "tab") && current.view === "detail") return { ...current, view: "script", scroll: 0 };
323
+ const delta = key === "up" ? -1 : key === "down" ? 1 : key === "pageup" ? -10 : key === "pagedown" ? 10 : 0;
324
+ return delta ? { ...current, scroll: Math.max(0, current.scroll + delta) } : current;
325
+ }
326
+
327
+ export async function showSessionInspector(report: SessionReport): Promise<void> {
328
+ if (!stdin.isTTY || !stdout.isTTY) throw new Error("The session inspector requires an interactive terminal.");
329
+ let state: InspectorViewState = { view: "list", selected: 0, scroll: 0 };
330
+ const render = () => { stdout.write(`\x1b[H\x1b[2J${renderInspector(report, state, stdout.columns || 80, stdout.rows || 24).join("\n")}`); };
331
+ await new Promise<void>((resolve) => {
332
+ const wasRaw = stdin.isRaw;
333
+ const done = () => {
334
+ stdin.off("keypress", onKey);
335
+ stdout.off("resize", render);
336
+ stdin.setRawMode(wasRaw);
337
+ if (!wasRaw) stdin.pause();
338
+ stdout.write("\x1b[?25h\x1b[?1049l");
339
+ resolve();
340
+ };
341
+ const onKey = (value: string, key: { name?: string; ctrl?: boolean }) => {
342
+ if ((key.ctrl && key.name === "c") || value === "q") { done(); return; }
343
+ state = nextState(state, value === "s" ? "s" : key.name ?? value, report.workflows.length);
344
+ render();
345
+ };
346
+ emitKeypressEvents(stdin);
347
+ stdin.setRawMode(true);
348
+ stdin.resume();
349
+ stdin.on("keypress", onKey);
350
+ stdout.on("resize", render);
351
+ stdout.write("\x1b[?1049h\x1b[?25l");
352
+ render();
353
+ });
354
+ }
355
+
356
+ async function askSessionId(): Promise<string> {
357
+ if (!stdin.isTTY || !stdout.isTTY) throw new Error("Pass a session ID when stdin is not interactive.");
358
+ const prompt = createInterface({ input: stdin, output: stdout });
359
+ try { return (await prompt.question("Session ID: ")).trim(); } finally { prompt.close(); }
360
+ }
361
+ export async function resolveSession(query: string, sessionDir = process.env.PI_CODING_AGENT_SESSION_DIR): Promise<SessionInfo> {
362
+ return matchSession(query, await SessionManager.listAll(sessionDir));
363
+ }
364
+
365
+ export async function runSessionInspector(sessionId?: string): Promise<void> {
366
+ const query = sessionId?.trim() || await askSessionId();
367
+ if (!query) throw new Error("Session ID is required.");
368
+ const session = await resolveSession(query);
369
+ await showSessionInspector(await loadSessionReport(session.path));
370
+ }
@@ -0,0 +1,15 @@
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+ import { captureEvalCase, type CaptureCaseInput } from "./workflow-evals.js";
4
+
5
+ interface ChildInput { payload: CaptureCaseInput; outputPath: string }
6
+
7
+ async function main(): Promise<void> {
8
+ const inputPath = process.argv[2];
9
+ if (!inputPath) throw new Error("Missing eval child input path");
10
+ const input = JSON.parse(readFileSync(inputPath, "utf8")) as ChildInput;
11
+ const result = await captureEvalCase(input.payload);
12
+ writeFileSync(input.outputPath, `${JSON.stringify(result)}\n`, { mode: 0o600 });
13
+ }
14
+
15
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) void main().catch((error: unknown) => { process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`); process.exitCode = 1; });