pi-dynamic-workflow 0.1.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.
- package/LICENSE +21 -0
- package/README.md +346 -0
- package/package.json +55 -0
- package/src/agents.ts +97 -0
- package/src/guide.ts +115 -0
- package/src/index.ts +122 -0
- package/src/journal.ts +182 -0
- package/src/registry.ts +101 -0
- package/src/render.ts +183 -0
- package/src/sandbox.ts +128 -0
- package/src/scheduler.ts +80 -0
- package/src/structured.ts +73 -0
- package/src/subagent.ts +304 -0
- package/src/tool.ts +558 -0
- package/src/types.ts +120 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-dynamic-workflow extension entry point.
|
|
3
|
+
*
|
|
4
|
+
* Registers the `/workflow` command and a compact renderer for the injected
|
|
5
|
+
* authoring brief. The `workflow` tool itself is registered lazily on first
|
|
6
|
+
* use so sessions that never invoke `/workflow` do not carry it in their
|
|
7
|
+
* system prompt.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
12
|
+
import { WORKFLOW_GUIDE } from "./guide.ts";
|
|
13
|
+
import { discoverWorkflows } from "./registry.ts";
|
|
14
|
+
import { createWorkflowTool, listActiveWorkflowRuns, stopWorkflowRun, WORKFLOW_COMPLETE_TYPE } from "./tool.ts";
|
|
15
|
+
|
|
16
|
+
const BRIEF_TYPE = "dynamic-workflow-brief";
|
|
17
|
+
|
|
18
|
+
export default function (pi: ExtensionAPI) {
|
|
19
|
+
const workflowTool = createWorkflowTool(pi);
|
|
20
|
+
let toolRegistered = false;
|
|
21
|
+
|
|
22
|
+
const ensureToolRegistered = () => {
|
|
23
|
+
if (toolRegistered) return;
|
|
24
|
+
toolRegistered = true;
|
|
25
|
+
pi.registerTool(workflowTool);
|
|
26
|
+
pi.setActiveTools([...new Set([...pi.getActiveTools(), "workflow"])]);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
pi.registerCommand("workflow", {
|
|
30
|
+
description: "Author and run a dynamic multi-agent workflow: /workflow <task>",
|
|
31
|
+
handler: async (args, ctx) => {
|
|
32
|
+
const task = args.trim();
|
|
33
|
+
if (!task) {
|
|
34
|
+
ctx.ui.notify("Usage: /workflow <task description>", "warning");
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
ensureToolRegistered();
|
|
38
|
+
const sections = [WORKFLOW_GUIDE];
|
|
39
|
+
const saved = discoverWorkflows(ctx.cwd);
|
|
40
|
+
if (saved.size > 0) {
|
|
41
|
+
const list = [...saved.values()]
|
|
42
|
+
.map((w) => `- ${w.name} (${w.source})${w.description ? `: ${w.description}` : ""}`)
|
|
43
|
+
.join("\n");
|
|
44
|
+
sections.push(
|
|
45
|
+
`## Saved workflows\n\nWhen one of these fits the task, invoke the tool with \`workflowName\` (plus \`args\`) instead of authoring a script:\n\n${list}`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
sections.push(`## Task\n\n${task}`);
|
|
49
|
+
pi.sendMessage(
|
|
50
|
+
{
|
|
51
|
+
customType: BRIEF_TYPE,
|
|
52
|
+
content: sections.join("\n\n"),
|
|
53
|
+
display: true,
|
|
54
|
+
details: { task },
|
|
55
|
+
},
|
|
56
|
+
{ triggerTurn: true },
|
|
57
|
+
);
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
pi.registerCommand("workflow-stop", {
|
|
62
|
+
description: "Stop a background workflow run: /workflow-stop <runId>",
|
|
63
|
+
handler: async (args, ctx) => {
|
|
64
|
+
const runId = args.trim();
|
|
65
|
+
if (!runId) {
|
|
66
|
+
const active = listActiveWorkflowRuns();
|
|
67
|
+
ctx.ui.notify(
|
|
68
|
+
active.length > 0
|
|
69
|
+
? `Active background runs: ${active.join(", ")}. Usage: /workflow-stop <runId>`
|
|
70
|
+
: "No background workflow runs are active.",
|
|
71
|
+
"info",
|
|
72
|
+
);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (stopWorkflowRun(runId)) {
|
|
76
|
+
ctx.ui.notify(`Aborting background workflow run ${runId}`, "info");
|
|
77
|
+
} else {
|
|
78
|
+
ctx.ui.notify(`No active background run "${runId}"`, "warning");
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
pi.registerMessageRenderer(WORKFLOW_COMPLETE_TYPE, (message, { expanded }, theme) => {
|
|
84
|
+
const content = String(message.content);
|
|
85
|
+
if (expanded) return new Text(theme.fg("muted", content), 0, 0);
|
|
86
|
+
const firstLine = content.split("\n")[0] ?? "";
|
|
87
|
+
return new Text(theme.fg("accent", "workflow complete ") + theme.fg("dim", firstLine), 0, 0);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// Background runs must not outlive the session that owns their subprocesses.
|
|
91
|
+
pi.on("session_shutdown", () => {
|
|
92
|
+
for (const runId of listActiveWorkflowRuns()) stopWorkflowRun(runId);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
pi.registerMessageRenderer<{ task?: string }>(BRIEF_TYPE, (message, { expanded }, theme) => {
|
|
96
|
+
if (expanded) {
|
|
97
|
+
return new Text(theme.fg("muted", String(message.content)), 0, 0);
|
|
98
|
+
}
|
|
99
|
+
const task = message.details?.task ?? "";
|
|
100
|
+
const preview = task.length > 80 ? `${task.slice(0, 80)}...` : task;
|
|
101
|
+
return new Text(
|
|
102
|
+
theme.fg("accent", "workflow brief ") + theme.fg("dim", preview || "(authoring guide injected)"),
|
|
103
|
+
0,
|
|
104
|
+
0,
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// Resumed sessions whose branch already contains workflow tool calls need
|
|
109
|
+
// the tool re-registered so prior results render and remain re-invocable.
|
|
110
|
+
pi.on("session_start", (_event, ctx) => {
|
|
111
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
112
|
+
if (
|
|
113
|
+
entry.type === "message" &&
|
|
114
|
+
entry.message.role === "toolResult" &&
|
|
115
|
+
entry.message.toolName === "workflow"
|
|
116
|
+
) {
|
|
117
|
+
ensureToolRegistered();
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
}
|
package/src/journal.ts
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-run journaling for resume: every run records each successful agent()
|
|
3
|
+
* call (keyed by a hash of prompt + behavioral options) to
|
|
4
|
+
* `<agentDir>/pi-dynamic-workflow/runs/<runId>.jsonl` — one meta line followed
|
|
5
|
+
* by one appended line per completed call, so a crash mid-run loses at most
|
|
6
|
+
* the final partial line. A later run passing `resumeFromRunId` replays cached
|
|
7
|
+
* results for matching calls and only spawns subagents for new or changed ones.
|
|
8
|
+
*
|
|
9
|
+
* Cache semantics are a multiset (hash → queue of results in completion
|
|
10
|
+
* order), not a strict sequence: parallel() completes in nondeterministic
|
|
11
|
+
* order, so position-based matching would spuriously bust the cache.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as crypto from "node:crypto";
|
|
15
|
+
import * as fs from "node:fs";
|
|
16
|
+
import * as path from "node:path";
|
|
17
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import type { AgentOptions, UsageStats } from "./types.ts";
|
|
19
|
+
|
|
20
|
+
/** Runs beyond this count are pruned oldest-first when a new journal is created. */
|
|
21
|
+
const MAX_KEPT_RUNS = 50;
|
|
22
|
+
|
|
23
|
+
export interface JournalEntry {
|
|
24
|
+
hash: string;
|
|
25
|
+
label: string;
|
|
26
|
+
outputText: string;
|
|
27
|
+
structured?: unknown;
|
|
28
|
+
model?: string;
|
|
29
|
+
usage: UsageStats;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface RunJournal {
|
|
33
|
+
runId: string;
|
|
34
|
+
name: string;
|
|
35
|
+
description: string;
|
|
36
|
+
script: string;
|
|
37
|
+
createdAt: number;
|
|
38
|
+
entries: JournalEntry[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function runsDir(): string {
|
|
42
|
+
return path.join(getAgentDir(), "pi-dynamic-workflow", "runs");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function journalPath(runId: string): string {
|
|
46
|
+
return path.join(runsDir(), `${runId}.jsonl`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function newRunId(name: string): string {
|
|
50
|
+
// Host-side code; the sandbox determinism guards do not apply here.
|
|
51
|
+
const slug = name.replace(/[^a-zA-Z0-9-]/g, "-").slice(0, 40) || "workflow";
|
|
52
|
+
return `${slug}-${Date.now().toString(36)}-${crypto.randomBytes(3).toString("hex")}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function stableStringify(value: unknown): string {
|
|
56
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "undefined";
|
|
57
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
|
|
58
|
+
const entries = Object.entries(value as Record<string, unknown>)
|
|
59
|
+
.filter(([, v]) => v !== undefined)
|
|
60
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
61
|
+
.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`);
|
|
62
|
+
return `{${entries.join(",")}}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Cache key for one agent() call. Display-only options (label, phase) are
|
|
67
|
+
* excluded so cosmetic edits to a script do not invalidate cached results.
|
|
68
|
+
* The resolved agent-type definition is behavioral: editing the `.md` between
|
|
69
|
+
* runs must invalidate the cache, not silently replay stale results.
|
|
70
|
+
*/
|
|
71
|
+
export function agentCallHash(
|
|
72
|
+
prompt: string,
|
|
73
|
+
opts: AgentOptions,
|
|
74
|
+
resolvedAgentType?: { systemPrompt: string; tools?: string[]; model?: string },
|
|
75
|
+
): string {
|
|
76
|
+
const behavioral = {
|
|
77
|
+
prompt,
|
|
78
|
+
model: opts.model,
|
|
79
|
+
tools: opts.tools,
|
|
80
|
+
cwd: opts.cwd,
|
|
81
|
+
schema: opts.schema,
|
|
82
|
+
systemPrompt: opts.systemPrompt,
|
|
83
|
+
appendSystemPrompt: opts.appendSystemPrompt,
|
|
84
|
+
agentType: opts.agentType,
|
|
85
|
+
agentTypeResolved: resolvedAgentType
|
|
86
|
+
? {
|
|
87
|
+
systemPrompt: resolvedAgentType.systemPrompt,
|
|
88
|
+
tools: resolvedAgentType.tools,
|
|
89
|
+
model: resolvedAgentType.model,
|
|
90
|
+
}
|
|
91
|
+
: undefined,
|
|
92
|
+
timeout: opts.timeout,
|
|
93
|
+
};
|
|
94
|
+
return crypto.createHash("sha256").update(stableStringify(behavioral)).digest("hex");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Load a run journal; returns null when missing or unreadable. */
|
|
98
|
+
export function loadJournal(runId: string): RunJournal | null {
|
|
99
|
+
try {
|
|
100
|
+
const lines = fs.readFileSync(journalPath(runId), "utf-8").split("\n");
|
|
101
|
+
const metaLine = lines.shift();
|
|
102
|
+
if (!metaLine?.trim()) return null;
|
|
103
|
+
const meta = JSON.parse(metaLine) as Omit<RunJournal, "entries">;
|
|
104
|
+
const entries: JournalEntry[] = [];
|
|
105
|
+
for (const line of lines) {
|
|
106
|
+
if (!line.trim()) continue;
|
|
107
|
+
try {
|
|
108
|
+
entries.push(JSON.parse(line) as JournalEntry);
|
|
109
|
+
} catch {
|
|
110
|
+
// A truncated tail line (crash mid-append) invalidates only itself.
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return { ...meta, entries };
|
|
114
|
+
} catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Group journal entries into hash → queue-of-results, preserving order. */
|
|
120
|
+
export function buildResumeCache(journal: RunJournal): Map<string, JournalEntry[]> {
|
|
121
|
+
const cache = new Map<string, JournalEntry[]>();
|
|
122
|
+
for (const entry of journal.entries) {
|
|
123
|
+
const queue = cache.get(entry.hash);
|
|
124
|
+
if (queue) queue.push(entry);
|
|
125
|
+
else cache.set(entry.hash, [entry]);
|
|
126
|
+
}
|
|
127
|
+
return cache;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function pruneOldRuns(dir: string): void {
|
|
131
|
+
let entries: Array<{ path: string; mtimeMs: number }>;
|
|
132
|
+
try {
|
|
133
|
+
entries = fs
|
|
134
|
+
.readdirSync(dir)
|
|
135
|
+
.filter((f) => f.endsWith(".jsonl"))
|
|
136
|
+
.map((f) => {
|
|
137
|
+
const p = path.join(dir, f);
|
|
138
|
+
return { path: p, mtimeMs: fs.statSync(p).mtimeMs };
|
|
139
|
+
});
|
|
140
|
+
} catch {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (entries.length <= MAX_KEPT_RUNS) return;
|
|
144
|
+
entries.sort((a, b) => a.mtimeMs - b.mtimeMs);
|
|
145
|
+
for (const stale of entries.slice(0, entries.length - MAX_KEPT_RUNS)) {
|
|
146
|
+
try {
|
|
147
|
+
fs.rmSync(stale.path, { force: true });
|
|
148
|
+
} catch {
|
|
149
|
+
// best-effort
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Journals a run as it progresses: a meta line at creation, then one appended
|
|
156
|
+
* line per recorded call — O(1) per entry, no full-file rewrites. All disk I/O
|
|
157
|
+
* is best-effort: a failed write must never take down the workflow it is
|
|
158
|
+
* recording.
|
|
159
|
+
*/
|
|
160
|
+
export class JournalWriter {
|
|
161
|
+
readonly filePath: string;
|
|
162
|
+
|
|
163
|
+
constructor(meta: { runId: string; name: string; description: string; script: string }) {
|
|
164
|
+
this.filePath = journalPath(meta.runId);
|
|
165
|
+
try {
|
|
166
|
+
fs.mkdirSync(runsDir(), { recursive: true });
|
|
167
|
+
fs.writeFileSync(this.filePath, `${JSON.stringify({ ...meta, createdAt: Date.now() })}\n`);
|
|
168
|
+
// Prune after writing so this run (newest mtime) survives the cut.
|
|
169
|
+
pruneOldRuns(runsDir());
|
|
170
|
+
} catch {
|
|
171
|
+
// best-effort
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
record(entry: JournalEntry): void {
|
|
176
|
+
try {
|
|
177
|
+
fs.appendFileSync(this.filePath, `${JSON.stringify(entry)}\n`);
|
|
178
|
+
} catch {
|
|
179
|
+
// best-effort
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
package/src/registry.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry of saved workflow scripts: `~/.pi/agent/workflows/*.js` (user) and
|
|
3
|
+
* the nearest `<project>/.pi/workflows/*.js` (project, overrides user on name
|
|
4
|
+
* collision). A file's name (minus extension) is the workflow name; a leading
|
|
5
|
+
* `//` comment line doubles as its description.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
|
|
12
|
+
export interface SavedWorkflow {
|
|
13
|
+
name: string;
|
|
14
|
+
description?: string;
|
|
15
|
+
filePath: string;
|
|
16
|
+
source: "user" | "project";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function firstCommentLine(source: string): string | undefined {
|
|
20
|
+
const line = source.split("\n", 1)[0]?.trim();
|
|
21
|
+
if (line?.startsWith("//")) return line.replace(/^\/\/\s*/, "");
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function loadWorkflowsFromDir(dir: string, source: "user" | "project"): SavedWorkflow[] {
|
|
26
|
+
const workflows: SavedWorkflow[] = [];
|
|
27
|
+
let entries: fs.Dirent[];
|
|
28
|
+
try {
|
|
29
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
30
|
+
} catch {
|
|
31
|
+
return workflows;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
for (const entry of entries) {
|
|
35
|
+
if (!entry.name.endsWith(".js")) continue;
|
|
36
|
+
if (!entry.isFile() && !entry.isSymbolicLink()) continue;
|
|
37
|
+
const filePath = path.join(dir, entry.name);
|
|
38
|
+
let description: string | undefined;
|
|
39
|
+
try {
|
|
40
|
+
description = firstCommentLine(fs.readFileSync(filePath, "utf-8"));
|
|
41
|
+
} catch {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
workflows.push({
|
|
45
|
+
name: entry.name.slice(0, -".js".length),
|
|
46
|
+
...(description ? { description } : {}),
|
|
47
|
+
filePath,
|
|
48
|
+
source,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
return workflows;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function findNearestProjectWorkflowsDir(cwd: string): string | null {
|
|
55
|
+
let currentDir = cwd;
|
|
56
|
+
while (true) {
|
|
57
|
+
const candidate = path.join(currentDir, CONFIG_DIR_NAME, "workflows");
|
|
58
|
+
try {
|
|
59
|
+
if (fs.statSync(candidate).isDirectory()) return candidate;
|
|
60
|
+
} catch {
|
|
61
|
+
// keep walking up
|
|
62
|
+
}
|
|
63
|
+
const parentDir = path.dirname(currentDir);
|
|
64
|
+
if (parentDir === currentDir) return null;
|
|
65
|
+
currentDir = parentDir;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Discover saved workflows; project entries override user entries by name. */
|
|
70
|
+
export function discoverWorkflows(cwd: string): Map<string, SavedWorkflow> {
|
|
71
|
+
const map = new Map<string, SavedWorkflow>();
|
|
72
|
+
for (const wf of loadWorkflowsFromDir(path.join(getAgentDir(), "workflows"), "user")) {
|
|
73
|
+
map.set(wf.name, wf);
|
|
74
|
+
}
|
|
75
|
+
const projectDir = findNearestProjectWorkflowsDir(cwd);
|
|
76
|
+
if (projectDir) {
|
|
77
|
+
for (const wf of loadWorkflowsFromDir(projectDir, "project")) {
|
|
78
|
+
map.set(wf.name, wf);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return map;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Load a workflow script body by saved name or by path (anything containing a
|
|
86
|
+
* path separator or ending in .js is treated as a path relative to `cwd`).
|
|
87
|
+
*/
|
|
88
|
+
export function loadWorkflowSource(nameOrPath: string, cwd: string): { source: string; filePath: string } {
|
|
89
|
+
const looksLikePath = nameOrPath.includes("/") || nameOrPath.includes(path.sep) || nameOrPath.endsWith(".js");
|
|
90
|
+
if (looksLikePath) {
|
|
91
|
+
const filePath = path.resolve(cwd, nameOrPath);
|
|
92
|
+
return { source: fs.readFileSync(filePath, "utf-8"), filePath };
|
|
93
|
+
}
|
|
94
|
+
const workflows = discoverWorkflows(cwd);
|
|
95
|
+
const saved = workflows.get(nameOrPath);
|
|
96
|
+
if (!saved) {
|
|
97
|
+
const available = [...workflows.keys()].join(", ") || "(none found)";
|
|
98
|
+
throw new Error(`Unknown workflow "${nameOrPath}". Saved workflows: ${available}`);
|
|
99
|
+
}
|
|
100
|
+
return { source: fs.readFileSync(saved.filePath, "utf-8"), filePath: saved.filePath };
|
|
101
|
+
}
|
package/src/render.ts
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/** TUI rendering for the `workflow` tool. */
|
|
2
|
+
|
|
3
|
+
import type { AgentToolResult } from "@earendil-works/pi-agent-core";
|
|
4
|
+
import { getMarkdownTheme, type Theme } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
|
|
6
|
+
import type { AgentRecord, UsageStats, WorkflowDetails } from "./types.ts";
|
|
7
|
+
|
|
8
|
+
const COLLAPSED_LOG_COUNT = 5;
|
|
9
|
+
|
|
10
|
+
function formatTokens(count: number): string {
|
|
11
|
+
if (count < 1000) return count.toString();
|
|
12
|
+
if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
|
|
13
|
+
if (count < 1000000) return `${Math.round(count / 1000)}k`;
|
|
14
|
+
return `${(count / 1000000).toFixed(1)}M`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function formatUsage(usage: UsageStats, model?: string): string {
|
|
18
|
+
const parts: string[] = [];
|
|
19
|
+
if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
|
|
20
|
+
if (usage.input) parts.push(`↑${formatTokens(usage.input)}`);
|
|
21
|
+
if (usage.output) parts.push(`↓${formatTokens(usage.output)}`);
|
|
22
|
+
if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
|
|
23
|
+
if (model) parts.push(model);
|
|
24
|
+
return parts.join(" ");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function aggregateUsage(agents: AgentRecord[]): UsageStats {
|
|
28
|
+
const total: UsageStats = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
|
|
29
|
+
for (const a of agents) {
|
|
30
|
+
total.input += a.usage.input;
|
|
31
|
+
total.output += a.usage.output;
|
|
32
|
+
total.cacheRead += a.usage.cacheRead;
|
|
33
|
+
total.cacheWrite += a.usage.cacheWrite;
|
|
34
|
+
total.cost += a.usage.cost;
|
|
35
|
+
total.turns += a.usage.turns;
|
|
36
|
+
}
|
|
37
|
+
return total;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function statusIcon(status: AgentRecord["status"], theme: Theme): string {
|
|
41
|
+
switch (status) {
|
|
42
|
+
case "queued":
|
|
43
|
+
return theme.fg("muted", "·");
|
|
44
|
+
case "running":
|
|
45
|
+
return theme.fg("warning", "⏳");
|
|
46
|
+
case "done":
|
|
47
|
+
return theme.fg("success", "✓");
|
|
48
|
+
case "error":
|
|
49
|
+
return theme.fg("error", "✗");
|
|
50
|
+
case "aborted":
|
|
51
|
+
return theme.fg("muted", "⊘");
|
|
52
|
+
case "cached":
|
|
53
|
+
return theme.fg("success", "↺");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Group agents by phase, preserving first-seen phase order. */
|
|
58
|
+
function groupByPhase(agents: AgentRecord[]): Map<string, AgentRecord[]> {
|
|
59
|
+
const groups = new Map<string, AgentRecord[]>();
|
|
60
|
+
for (const a of agents) {
|
|
61
|
+
const key = a.phase ?? "";
|
|
62
|
+
const list = groups.get(key);
|
|
63
|
+
if (list) list.push(a);
|
|
64
|
+
else groups.set(key, [a]);
|
|
65
|
+
}
|
|
66
|
+
return groups;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function renderWorkflowCall(
|
|
70
|
+
args: { name?: string; description?: string; phases?: string[] },
|
|
71
|
+
theme: Theme,
|
|
72
|
+
): Text {
|
|
73
|
+
let text = theme.fg("toolTitle", theme.bold("workflow ")) + theme.fg("accent", args.name ?? "...");
|
|
74
|
+
if (args.description) {
|
|
75
|
+
const firstLine = args.description.split("\n")[0] ?? "";
|
|
76
|
+
text += theme.fg("dim", ` ${firstLine}`);
|
|
77
|
+
}
|
|
78
|
+
if (args.phases && args.phases.length > 0) {
|
|
79
|
+
text += `\n ${theme.fg("muted", `phases: ${args.phases.join(" → ")}`)}`;
|
|
80
|
+
}
|
|
81
|
+
return new Text(text, 0, 0);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function renderWorkflowResult(
|
|
85
|
+
result: AgentToolResult<WorkflowDetails>,
|
|
86
|
+
options: { expanded: boolean; isPartial?: boolean },
|
|
87
|
+
theme: Theme,
|
|
88
|
+
): Container | Text {
|
|
89
|
+
const details = result.details;
|
|
90
|
+
if (!details) {
|
|
91
|
+
const first = result.content[0];
|
|
92
|
+
return new Text(first?.type === "text" ? first.text : "(no output)", 0, 0);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const { expanded } = options;
|
|
96
|
+
const running = details.agents.filter((a) => a.status === "running").length;
|
|
97
|
+
const queued = details.agents.filter((a) => a.status === "queued").length;
|
|
98
|
+
const failed = details.agents.filter((a) => a.status === "error").length;
|
|
99
|
+
const isRunning = running > 0 || queued > 0 || !details.finishedAt;
|
|
100
|
+
|
|
101
|
+
const headerIcon = details.scriptError
|
|
102
|
+
? theme.fg("error", "✗")
|
|
103
|
+
: details.aborted
|
|
104
|
+
? theme.fg("muted", "⊘")
|
|
105
|
+
: isRunning
|
|
106
|
+
? theme.fg("warning", "⏳")
|
|
107
|
+
: failed > 0
|
|
108
|
+
? theme.fg("warning", "◐")
|
|
109
|
+
: theme.fg("success", "✓");
|
|
110
|
+
|
|
111
|
+
const doneCount = details.agents.filter((a) => a.status === "done" || a.status === "cached").length;
|
|
112
|
+
const status = isRunning
|
|
113
|
+
? `${doneCount}/${details.agents.length} agents done, ${running} running${queued > 0 ? `, ${queued} queued` : ""}`
|
|
114
|
+
: `${doneCount}/${details.agents.length} agents`;
|
|
115
|
+
|
|
116
|
+
let header = `${headerIcon} ${theme.fg("toolTitle", theme.bold(details.name))} ${theme.fg("accent", status)}`;
|
|
117
|
+
if (details.aborted) header += ` ${theme.fg("muted", "[aborted]")}`;
|
|
118
|
+
|
|
119
|
+
const container = new Container();
|
|
120
|
+
container.addChild(new Text(header, 0, 0));
|
|
121
|
+
|
|
122
|
+
if (details.scriptError) {
|
|
123
|
+
container.addChild(new Text(theme.fg("error", details.scriptError), 0, 0));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
for (const [phaseName, agents] of groupByPhase(details.agents)) {
|
|
127
|
+
if (phaseName) {
|
|
128
|
+
container.addChild(new Text(theme.fg("muted", `─── ${phaseName} ───`), 0, 0));
|
|
129
|
+
}
|
|
130
|
+
for (const a of agents) {
|
|
131
|
+
let line = `${statusIcon(a.status, theme)} ${theme.fg("accent", a.label)}`;
|
|
132
|
+
const usageStr = formatUsage(a.usage, a.model);
|
|
133
|
+
if (usageStr) line += ` ${theme.fg("dim", usageStr)}`;
|
|
134
|
+
container.addChild(new Text(line, 0, 0));
|
|
135
|
+
if (a.status === "error" && a.error) {
|
|
136
|
+
container.addChild(new Text(theme.fg("error", ` ${a.error}`), 0, 0));
|
|
137
|
+
}
|
|
138
|
+
if (expanded && a.output) {
|
|
139
|
+
container.addChild(new Markdown(a.output.trim(), 0, 0, getMarkdownTheme()));
|
|
140
|
+
container.addChild(new Spacer(1));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (details.logs.length > 0) {
|
|
146
|
+
const logs = expanded ? details.logs : details.logs.slice(-COLLAPSED_LOG_COUNT);
|
|
147
|
+
const skipped = details.logs.length - logs.length;
|
|
148
|
+
container.addChild(new Text(theme.fg("muted", "─── log ───"), 0, 0));
|
|
149
|
+
if (skipped > 0) container.addChild(new Text(theme.fg("muted", `... ${skipped} earlier entries`), 0, 0));
|
|
150
|
+
for (const line of logs) {
|
|
151
|
+
container.addChild(new Text(theme.fg("dim", line), 0, 0));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (expanded && details.returnValue !== undefined) {
|
|
156
|
+
container.addChild(new Text(theme.fg("muted", "─── result ───"), 0, 0));
|
|
157
|
+
const text =
|
|
158
|
+
typeof details.returnValue === "string"
|
|
159
|
+
? details.returnValue
|
|
160
|
+
: JSON.stringify(details.returnValue, null, 2);
|
|
161
|
+
container.addChild(new Text(theme.fg("toolOutput", text), 0, 0));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const total = aggregateUsage(details.agents);
|
|
165
|
+
const totalStr = formatUsage(total);
|
|
166
|
+
if (totalStr) {
|
|
167
|
+
container.addChild(new Text(theme.fg("dim", `Total: ${totalStr}`), 0, 0));
|
|
168
|
+
}
|
|
169
|
+
if (details.budget) {
|
|
170
|
+
const parts: string[] = [];
|
|
171
|
+
if (details.budget.maxCost !== undefined) {
|
|
172
|
+
parts.push(`$${total.cost.toFixed(4)} / $${details.budget.maxCost}`);
|
|
173
|
+
}
|
|
174
|
+
if (details.budget.maxTokens !== undefined) {
|
|
175
|
+
parts.push(`${formatTokens(total.input + total.output)} / ${formatTokens(details.budget.maxTokens)} tokens`);
|
|
176
|
+
}
|
|
177
|
+
container.addChild(new Text(theme.fg("dim", `Budget: ${parts.join(", ")}`), 0, 0));
|
|
178
|
+
}
|
|
179
|
+
if (!expanded && !isRunning) {
|
|
180
|
+
container.addChild(new Text(theme.fg("muted", "(Ctrl+O to expand)"), 0, 0));
|
|
181
|
+
}
|
|
182
|
+
return container;
|
|
183
|
+
}
|