@tt-a1i/openpi 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/README.md +643 -0
- package/SETUP.md +74 -0
- package/THIRD_PARTY_NOTICES.md +16 -0
- package/assets/openpi-package.png +0 -0
- package/assets/readme-hero-mobile.svg +72 -0
- package/assets/readme-hero.svg +118 -0
- package/assets/readme-runtime-mobile.svg +91 -0
- package/assets/readme-runtime.svg +111 -0
- package/extensions/ask-user/handoff.ts +205 -0
- package/extensions/ask-user/index.ts +1110 -0
- package/extensions/ask-user/limits.ts +89 -0
- package/extensions/ask-user/prompt.ts +76 -0
- package/extensions/background-terminals/index.ts +653 -0
- package/extensions/background-terminals/src/domain.ts +99 -0
- package/extensions/background-terminals/src/manager.ts +989 -0
- package/extensions/background-terminals/src/output.ts +84 -0
- package/extensions/background-terminals/src/prompt.ts +195 -0
- package/extensions/background-terminals/src/result-delivery.ts +43 -0
- package/extensions/background-terminals/src/runtime.ts +36 -0
- package/extensions/background-terminals/src/ui/output-view.ts +55 -0
- package/extensions/background-terminals/src/ui/ps.ts +642 -0
- package/extensions/background-terminals/src/ui/tool-result.ts +146 -0
- package/extensions/background-terminals/src/watch.ts +192 -0
- package/extensions/context-pivot/index.ts +222 -0
- package/extensions/copy-all/index.ts +65 -0
- package/extensions/cron/index.ts +173 -0
- package/extensions/cron/schedule.ts +127 -0
- package/extensions/file-mutation-display/index.ts +105 -0
- package/extensions/file-mutation-display/render.ts +107 -0
- package/extensions/file-search/index.ts +515 -0
- package/extensions/file-search/src/args.ts +129 -0
- package/extensions/file-search/src/binaries.ts +419 -0
- package/extensions/file-search/src/output.ts +142 -0
- package/extensions/file-search/src/process.ts +309 -0
- package/extensions/file-search/src/prompt.ts +53 -0
- package/extensions/git-info/index.ts +272 -0
- package/extensions/git-info/src/changed-files-view.ts +414 -0
- package/extensions/git-info/src/process.ts +107 -0
- package/extensions/git-info/src/refresh-coordinator.ts +13 -0
- package/extensions/git-info/src/runtime.ts +28 -0
- package/extensions/goal/controller.ts +794 -0
- package/extensions/goal/index.ts +521 -0
- package/extensions/goal/prompts.ts +122 -0
- package/extensions/goal/state.ts +763 -0
- package/extensions/goal/ui.ts +158 -0
- package/extensions/model-info/index.ts +234 -0
- package/extensions/plan-mode/bash-policy.ts +313 -0
- package/extensions/plan-mode/index.ts +539 -0
- package/extensions/post-edit/index.ts +129 -0
- package/extensions/sessions/LICENSE.upstream +21 -0
- package/extensions/sessions/git-stats.ts +226 -0
- package/extensions/sessions/index.ts +1092 -0
- package/extensions/sessions/sessions.ts +385 -0
- package/extensions/setup/index.ts +408 -0
- package/extensions/shared/activity-status.ts +65 -0
- package/extensions/shared/below-editor-navigation.ts +343 -0
- package/extensions/shared/child-session.ts +352 -0
- package/extensions/shared/context-utilization.ts +47 -0
- package/extensions/shared/dashboard-state.ts +102 -0
- package/extensions/shared/plan-mode-state.ts +65 -0
- package/extensions/shared/setup-config.ts +971 -0
- package/extensions/shared/subagent-roles.ts +22 -0
- package/extensions/shared/terminal-text.ts +38 -0
- package/extensions/shared/tool-call-timeout.ts +104 -0
- package/extensions/shared/worktree.ts +526 -0
- package/extensions/subagents/index.ts +1225 -0
- package/extensions/subagents/navigation.ts +121 -0
- package/extensions/subagents/src/agent-types.ts +543 -0
- package/extensions/subagents/src/backend.ts +63 -0
- package/extensions/subagents/src/backends/pi.ts +493 -0
- package/extensions/subagents/src/backends/stub.ts +296 -0
- package/extensions/subagents/src/by-the-way.ts +21 -0
- package/extensions/subagents/src/domain.ts +271 -0
- package/extensions/subagents/src/format.ts +48 -0
- package/extensions/subagents/src/manager.ts +769 -0
- package/extensions/subagents/src/prompt.ts +190 -0
- package/extensions/subagents/src/result-delivery.ts +20 -0
- package/extensions/subagents/src/runtime.ts +51 -0
- package/extensions/subagents/src/ui/takeover.ts +615 -0
- package/extensions/subagents/src/ui/transcript.ts +293 -0
- package/extensions/subagents/src/ui/wait-result.ts +89 -0
- package/extensions/suggestions/index.ts +172 -0
- package/extensions/suggestions/src/config.ts +12 -0
- package/extensions/suggestions/src/predictor.ts +147 -0
- package/extensions/suggestions/src/prompt.ts +20 -0
- package/extensions/suggestions/src/transcript.ts +233 -0
- package/extensions/suggestions/src/ui.ts +224 -0
- package/extensions/tasks/index.ts +512 -0
- package/extensions/tasks/tasks.ts +649 -0
- package/extensions/tasks/ui.ts +421 -0
- package/extensions/turn-time/index.ts +61 -0
- package/extensions/ui-customization/footer.ts +512 -0
- package/extensions/ui-customization/index.ts +217 -0
- package/extensions/workflows/acceptance.ts +298 -0
- package/extensions/workflows/artifacts.ts +225 -0
- package/extensions/workflows/controller.ts +210 -0
- package/extensions/workflows/dashboard.ts +1226 -0
- package/extensions/workflows/index.ts +1884 -0
- package/extensions/workflows/journal.ts +188 -0
- package/extensions/workflows/meta.ts +250 -0
- package/extensions/workflows/model.ts +423 -0
- package/extensions/workflows/navigation.ts +93 -0
- package/extensions/workflows/prompt.ts +212 -0
- package/extensions/workflows/replay-safety.ts +577 -0
- package/extensions/workflows/runner.ts +786 -0
- package/extensions/workflows/sandbox-child.cjs +402 -0
- package/extensions/workflows/sandbox.ts +397 -0
- package/extensions/workflows/serialization.ts +162 -0
- package/extensions/workflows/worktree-handoff.ts +216 -0
- package/package.json +87 -0
- package/scripts/prepare-effect-tsgo.mjs +16 -0
- package/skills/background-terminals/SKILL.md +30 -0
- package/skills/subagents/SKILL.md +15 -0
- package/themes/github-dark-default.json +89 -0
|
@@ -0,0 +1,1884 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* workflows: model-authored multi-agent orchestration.
|
|
3
|
+
*
|
|
4
|
+
* A `workflow` tool that runs a JavaScript orchestration script written inline
|
|
5
|
+
* by the model. The script executes ordered phases, fanning work out to
|
|
6
|
+
* isolated subagents:
|
|
7
|
+
*
|
|
8
|
+
* export const meta = { name, description, phases: [{ title, detail? }] }
|
|
9
|
+
* phase(title) // mark runtime phase progression
|
|
10
|
+
* log(message) // narrate progress to the user and the report
|
|
11
|
+
* usage() // cumulative token spend so far (read-only)
|
|
12
|
+
* await agent(prompt, { agent_type?, label?, phase?, schema?, model?, provider?, effort? })
|
|
13
|
+
* await pipeline(items, stage1, stage2, ...) // per-item, no barrier between stages
|
|
14
|
+
* await parallel([() => agent(...), ...], { concurrency? }) // barrier
|
|
15
|
+
* args // parsed JSON args passed with the tool call
|
|
16
|
+
*
|
|
17
|
+
* `agent()` always resolves to `{ ok, output, structured?, error? }` — it
|
|
18
|
+
* never throws into the script. Scripts branch on `ok` explicitly.
|
|
19
|
+
*
|
|
20
|
+
* Runs are blocking by default (live progress in the tool block). Pass
|
|
21
|
+
* `background: true` to return immediately and get a follow-up message when
|
|
22
|
+
* the run finishes. Run artifacts (script, args, statuses, result) are saved
|
|
23
|
+
* under `~/.pi/agent/workflows/<runId>/` for inspection; result and bounded
|
|
24
|
+
* transcripts use separate artifacts.
|
|
25
|
+
*
|
|
26
|
+
* `resume_from_run_id` replays a prior run's cached agent results. Matching is
|
|
27
|
+
* by call CONTENT, not by ordinal: `pipeline()` issues calls in an order that
|
|
28
|
+
* depends on real agent latency, so an ordinal would drift between runs and
|
|
29
|
+
* hand one item's result to another. See `journal.ts`.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { randomBytes } from "node:crypto";
|
|
33
|
+
import * as fs from "node:fs";
|
|
34
|
+
import * as path from "node:path";
|
|
35
|
+
import {
|
|
36
|
+
CustomEditor,
|
|
37
|
+
getAgentDir,
|
|
38
|
+
getMarkdownTheme,
|
|
39
|
+
keyHint,
|
|
40
|
+
type ExtensionAPI,
|
|
41
|
+
type ExtensionContext,
|
|
42
|
+
} from "@earendil-works/pi-coding-agent";
|
|
43
|
+
import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
|
|
44
|
+
import { Type, type Static } from "typebox";
|
|
45
|
+
import { formatActivityStatus } from "../shared/activity-status.ts";
|
|
46
|
+
import { waitBounded } from "../shared/child-session.ts";
|
|
47
|
+
import { loadSetupConfig } from "../shared/setup-config.ts";
|
|
48
|
+
import {
|
|
49
|
+
loadAgentTypes,
|
|
50
|
+
resolveAgentModel,
|
|
51
|
+
roleModelForAgentType,
|
|
52
|
+
selectSubagentModel,
|
|
53
|
+
} from "../subagents/src/agent-types.ts";
|
|
54
|
+
import {
|
|
55
|
+
createWorktree,
|
|
56
|
+
reclaimWorktree,
|
|
57
|
+
type Worktree,
|
|
58
|
+
type WorktreeCleanup,
|
|
59
|
+
} from "../shared/worktree.ts";
|
|
60
|
+
import {
|
|
61
|
+
createWorkflowPersistence,
|
|
62
|
+
loadJournal,
|
|
63
|
+
persistWorkflowJson,
|
|
64
|
+
} from "./artifacts.ts";
|
|
65
|
+
import {
|
|
66
|
+
agentCallKey,
|
|
67
|
+
createReplayCache,
|
|
68
|
+
type JournalEntry,
|
|
69
|
+
type ReplayCache,
|
|
70
|
+
} from "./journal.ts";
|
|
71
|
+
import { RunController } from "./controller.ts";
|
|
72
|
+
import {
|
|
73
|
+
normalizePersistedWorkflowDetails,
|
|
74
|
+
sessionWorkflowRunIds,
|
|
75
|
+
showWorkflowDashboard,
|
|
76
|
+
} from "./dashboard.ts";
|
|
77
|
+
import {
|
|
78
|
+
extractMeta,
|
|
79
|
+
prepareWorkflowScript,
|
|
80
|
+
type WorkflowMeta,
|
|
81
|
+
} from "./meta.ts";
|
|
82
|
+
import {
|
|
83
|
+
agentContext,
|
|
84
|
+
aggregateUsage,
|
|
85
|
+
appendLog,
|
|
86
|
+
countStates,
|
|
87
|
+
emptyUsage,
|
|
88
|
+
formatElapsed,
|
|
89
|
+
formatUsage,
|
|
90
|
+
isWorkflowRunId,
|
|
91
|
+
phaseGroups,
|
|
92
|
+
resolveWorkflowRunTarget,
|
|
93
|
+
resultJson,
|
|
94
|
+
sanitizeLine,
|
|
95
|
+
sanitizeWorkflowDisplayLine,
|
|
96
|
+
sanitizeWorkflowDisplayText,
|
|
97
|
+
stateSquare,
|
|
98
|
+
statusColor,
|
|
99
|
+
statusWord,
|
|
100
|
+
createUsageReader,
|
|
101
|
+
SQUARE,
|
|
102
|
+
type AgentRecord,
|
|
103
|
+
type WorkflowDetails,
|
|
104
|
+
} from "./model.ts";
|
|
105
|
+
import {
|
|
106
|
+
buildBackgroundWorkflowFollowUp,
|
|
107
|
+
buildBackgroundWorkflowLaunchResult,
|
|
108
|
+
buildWorkflowAgentPrompt,
|
|
109
|
+
buildWorkflowResultMessage,
|
|
110
|
+
WORKFLOW_LIFECYCLE_PROMPT_SNIPPET,
|
|
111
|
+
WORKFLOW_PARAMETER_DESCRIPTIONS,
|
|
112
|
+
WORKFLOW_PROMPT_GUIDELINES,
|
|
113
|
+
WORKFLOW_PROMPT_SNIPPET,
|
|
114
|
+
WORKFLOW_STATUS_PARAMETER_DESCRIPTIONS,
|
|
115
|
+
WORKFLOW_STATUS_TOOL_DESCRIPTION,
|
|
116
|
+
WORKFLOW_STOP_PARAMETER_DESCRIPTIONS,
|
|
117
|
+
WORKFLOW_STOP_TOOL_DESCRIPTION,
|
|
118
|
+
WORKFLOW_TOOL_DESCRIPTION,
|
|
119
|
+
} from "./prompt.ts";
|
|
120
|
+
import {
|
|
121
|
+
WorkflowNavigationEditor,
|
|
122
|
+
WorkflowStripState,
|
|
123
|
+
WorkflowStripWidget,
|
|
124
|
+
type WorkflowStripEntry,
|
|
125
|
+
} from "./navigation.ts";
|
|
126
|
+
import {
|
|
127
|
+
createWorkflowResources,
|
|
128
|
+
runAgent,
|
|
129
|
+
type ThinkingLevel,
|
|
130
|
+
type WorkflowModel,
|
|
131
|
+
} from "./runner.ts";
|
|
132
|
+
import {
|
|
133
|
+
beginProcessReplayWorkspaceLease,
|
|
134
|
+
createReplayIdentity,
|
|
135
|
+
isReplaySafeAgentCall,
|
|
136
|
+
} from "./replay-safety.ts";
|
|
137
|
+
import { runWorkflowSandbox } from "./sandbox.ts";
|
|
138
|
+
import {
|
|
139
|
+
acceptanceInstruction,
|
|
140
|
+
acceptanceSchema,
|
|
141
|
+
applyAcceptance,
|
|
142
|
+
evaluateAcceptance,
|
|
143
|
+
parseAcceptanceContract,
|
|
144
|
+
} from "./acceptance.ts";
|
|
145
|
+
import {
|
|
146
|
+
finalizeWorktreeHandoff,
|
|
147
|
+
prepareWorktreeHandoff,
|
|
148
|
+
} from "./worktree-handoff.ts";
|
|
149
|
+
import { safeStringify, writeFileAtomic } from "./serialization.ts";
|
|
150
|
+
|
|
151
|
+
const PREVIEW_LENGTH = 200;
|
|
152
|
+
const EMIT_INTERVAL_MS = 120;
|
|
153
|
+
|
|
154
|
+
const THINKING_LEVELS = [
|
|
155
|
+
"off",
|
|
156
|
+
"minimal",
|
|
157
|
+
"low",
|
|
158
|
+
"medium",
|
|
159
|
+
"high",
|
|
160
|
+
"xhigh",
|
|
161
|
+
"max",
|
|
162
|
+
] as const;
|
|
163
|
+
|
|
164
|
+
/** What `agent()` resolves to inside the script. */
|
|
165
|
+
interface ScriptAgentResult {
|
|
166
|
+
ok: boolean;
|
|
167
|
+
output: string;
|
|
168
|
+
structured?: unknown;
|
|
169
|
+
acceptance?: AgentRecord["acceptance"];
|
|
170
|
+
error?: string;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
interface AgentCallOptions {
|
|
174
|
+
agent_type?: unknown;
|
|
175
|
+
label?: unknown;
|
|
176
|
+
phase?: unknown;
|
|
177
|
+
schema?: unknown;
|
|
178
|
+
acceptance?: unknown;
|
|
179
|
+
model?: unknown;
|
|
180
|
+
provider?: unknown;
|
|
181
|
+
effort?: unknown;
|
|
182
|
+
isolation?: unknown;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const WorkflowParams = Type.Object({
|
|
186
|
+
script: Type.String({
|
|
187
|
+
description: WORKFLOW_PARAMETER_DESCRIPTIONS.script,
|
|
188
|
+
}),
|
|
189
|
+
args: Type.Optional(
|
|
190
|
+
Type.String({
|
|
191
|
+
description: WORKFLOW_PARAMETER_DESCRIPTIONS.args,
|
|
192
|
+
}),
|
|
193
|
+
),
|
|
194
|
+
background: Type.Optional(
|
|
195
|
+
Type.Boolean({
|
|
196
|
+
description: WORKFLOW_PARAMETER_DESCRIPTIONS.background,
|
|
197
|
+
}),
|
|
198
|
+
),
|
|
199
|
+
resume_from_run_id: Type.Optional(
|
|
200
|
+
Type.String({
|
|
201
|
+
description: WORKFLOW_PARAMETER_DESCRIPTIONS.resumeFromRunId,
|
|
202
|
+
}),
|
|
203
|
+
),
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
type WorkflowInput = Static<typeof WorkflowParams>;
|
|
207
|
+
|
|
208
|
+
/** Resolve a persisted run without letting a suffix collision choose one. */
|
|
209
|
+
export function resolveRunDir(target: string) {
|
|
210
|
+
const base = path.join(getAgentDir(), "workflows");
|
|
211
|
+
let names: string[] = [];
|
|
212
|
+
try {
|
|
213
|
+
names = fs
|
|
214
|
+
.readdirSync(base, { withFileTypes: true })
|
|
215
|
+
.filter((entry) => entry.isDirectory())
|
|
216
|
+
.map((entry) => entry.name)
|
|
217
|
+
.filter(isWorkflowRunId);
|
|
218
|
+
} catch {
|
|
219
|
+
// The shared resolver reports the empty candidate set usefully.
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Shape validation happens before any target-derived path is built. This
|
|
223
|
+
// value is model-supplied, so traversal must never reach a planted journal.
|
|
224
|
+
const resolution = resolveWorkflowRunTarget(target, names);
|
|
225
|
+
return resolution.ok
|
|
226
|
+
? { ...resolution, runDir: path.join(base, resolution.runId) }
|
|
227
|
+
: resolution;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Backward-compatible directory lookup for callers that only need a hit. */
|
|
231
|
+
export function findRunDir(target: string) {
|
|
232
|
+
const resolution = resolveRunDir(target);
|
|
233
|
+
return resolution.ok ? resolution.runDir : undefined;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function errorText(error: unknown): string {
|
|
237
|
+
return sanitizeWorkflowDisplayLine(
|
|
238
|
+
error instanceof Error ? error.message : String(error),
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function summaryLine(details: WorkflowDetails): string {
|
|
243
|
+
const { done, failed } = countStates(details);
|
|
244
|
+
const settled = done + failed;
|
|
245
|
+
// The newest narrator line beats the phase title when there is one: the
|
|
246
|
+
// script wrote it precisely because it says more than the phase does.
|
|
247
|
+
const latest = details.logs?.[details.logs.length - 1]?.text;
|
|
248
|
+
return `workflow ${details.name ?? details.runId}: ${settled}/${details.agents.length} agents${
|
|
249
|
+
latest
|
|
250
|
+
? ` · ${latest}`
|
|
251
|
+
: details.currentPhase
|
|
252
|
+
? ` · ${details.currentPhase}`
|
|
253
|
+
: ""
|
|
254
|
+
}`;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function writeRunFile(runDir: string, name: string, content: string) {
|
|
258
|
+
writeFileAtomic(path.join(runDir, name), content);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function compactToolDetails(details: WorkflowDetails): WorkflowDetails {
|
|
262
|
+
return {
|
|
263
|
+
...details,
|
|
264
|
+
...(details.result !== undefined
|
|
265
|
+
? {
|
|
266
|
+
result: JSON.parse(
|
|
267
|
+
safeStringify(details.result, { maxBytes: 64 * 1024 }),
|
|
268
|
+
),
|
|
269
|
+
}
|
|
270
|
+
: {}),
|
|
271
|
+
agents: details.agents.map((agent) => ({ ...agent, transcript: [] })),
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export interface ActiveWorkflowRunLifecycle {
|
|
276
|
+
details: WorkflowDetails;
|
|
277
|
+
controller: Pick<RunController, "abort" | "settle">;
|
|
278
|
+
completion?: Promise<void>;
|
|
279
|
+
forceSettle(error: string): void;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Abort every live child and bound the whole session-shutdown barrier once. */
|
|
283
|
+
export async function shutdownActiveWorkflowRuns(
|
|
284
|
+
runs: readonly ActiveWorkflowRunLifecycle[],
|
|
285
|
+
timeoutMs = 8_000,
|
|
286
|
+
) {
|
|
287
|
+
for (const run of runs) run.controller.abort("Session is shutting down");
|
|
288
|
+
const completions = runs
|
|
289
|
+
.map((run) => run.completion)
|
|
290
|
+
.filter(
|
|
291
|
+
(completion): completion is Promise<void> => completion !== undefined,
|
|
292
|
+
);
|
|
293
|
+
const completed = await waitBounded(
|
|
294
|
+
Promise.allSettled([
|
|
295
|
+
...runs.map((run) => run.controller.settle({ abort: true })),
|
|
296
|
+
...completions,
|
|
297
|
+
]),
|
|
298
|
+
timeoutMs,
|
|
299
|
+
);
|
|
300
|
+
if (!completed) {
|
|
301
|
+
for (const run of runs) {
|
|
302
|
+
run.forceSettle("Session shutdown deadline exceeded");
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return completed;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
interface RunSummary {
|
|
309
|
+
runId: string;
|
|
310
|
+
name?: string;
|
|
311
|
+
status: string;
|
|
312
|
+
done: number;
|
|
313
|
+
total: number;
|
|
314
|
+
startedAt: number;
|
|
315
|
+
active: boolean;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function listRuns(
|
|
319
|
+
activeRuns: Map<string, WorkflowDetails>,
|
|
320
|
+
sessionId: string,
|
|
321
|
+
referencedRunIds: ReadonlySet<string>,
|
|
322
|
+
startedSince = 0,
|
|
323
|
+
): RunSummary[] {
|
|
324
|
+
const base = path.join(getAgentDir(), "workflows");
|
|
325
|
+
let names: string[] = [];
|
|
326
|
+
try {
|
|
327
|
+
names = fs.readdirSync(base).filter(isWorkflowRunId);
|
|
328
|
+
} catch {
|
|
329
|
+
// No runs yet.
|
|
330
|
+
}
|
|
331
|
+
const summaries: RunSummary[] = [];
|
|
332
|
+
for (const runId of names) {
|
|
333
|
+
const live = activeRuns.get(runId);
|
|
334
|
+
if (live) {
|
|
335
|
+
const { done, failed } = countStates(live);
|
|
336
|
+
summaries.push({
|
|
337
|
+
runId,
|
|
338
|
+
name: live.name,
|
|
339
|
+
status: live.status,
|
|
340
|
+
done: done + failed,
|
|
341
|
+
total: live.agents.length,
|
|
342
|
+
startedAt: live.startedAt,
|
|
343
|
+
active: true,
|
|
344
|
+
});
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
try {
|
|
348
|
+
const parsed = JSON.parse(
|
|
349
|
+
fs.readFileSync(path.join(base, runId, "workflow.json"), "utf8"),
|
|
350
|
+
) as Partial<WorkflowDetails>;
|
|
351
|
+
const startedAt = parsed.startedAt ?? 0;
|
|
352
|
+
const touchedAt = Math.max(startedAt, parsed.finishedAt ?? 0);
|
|
353
|
+
if (
|
|
354
|
+
touchedAt < startedSince ||
|
|
355
|
+
(parsed.sessionId !== sessionId && !referencedRunIds.has(runId))
|
|
356
|
+
) {
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
const agents = parsed.agents ?? [];
|
|
360
|
+
summaries.push({
|
|
361
|
+
runId,
|
|
362
|
+
name: parsed.name,
|
|
363
|
+
status:
|
|
364
|
+
parsed.status === "running"
|
|
365
|
+
? "aborted"
|
|
366
|
+
: (parsed.status ?? "unknown"),
|
|
367
|
+
done: agents.filter((agent) => agent.state !== "running").length,
|
|
368
|
+
total: agents.length,
|
|
369
|
+
startedAt: parsed.startedAt ?? 0,
|
|
370
|
+
active: false,
|
|
371
|
+
});
|
|
372
|
+
} catch {
|
|
373
|
+
// Ignore unreadable artifacts because their session cannot be verified.
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return summaries.sort((a, b) => b.startedAt - a.startedAt);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function runDetailText(
|
|
380
|
+
run: RunSummary,
|
|
381
|
+
activeRuns: Map<string, WorkflowDetails>,
|
|
382
|
+
): string {
|
|
383
|
+
const runDir = path.join(getAgentDir(), "workflows", run.runId);
|
|
384
|
+
const live = activeRuns.get(run.runId);
|
|
385
|
+
if (live) return buildWorkflowResultMessage(live, runDir);
|
|
386
|
+
try {
|
|
387
|
+
const parsed = JSON.parse(
|
|
388
|
+
fs.readFileSync(path.join(runDir, "workflow.json"), "utf8"),
|
|
389
|
+
) as WorkflowDetails;
|
|
390
|
+
return buildWorkflowResultMessage(parsed, runDir);
|
|
391
|
+
} catch {
|
|
392
|
+
return `Run ${run.runId} — ${run.status}`;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export default function workflows(pi: ExtensionAPI) {
|
|
397
|
+
/** Live background runs, for /workflows and shutdown cleanup. */
|
|
398
|
+
const activeRuns = new Map<string, ActiveWorkflowRunLifecycle>();
|
|
399
|
+
const activeDetails = () =>
|
|
400
|
+
new Map(
|
|
401
|
+
[...activeRuns].map(([runId, run]) => [runId, run.details] as const),
|
|
402
|
+
);
|
|
403
|
+
const settledRuns = new Map<string, WorkflowDetails>();
|
|
404
|
+
const stripState = new WorkflowStripState();
|
|
405
|
+
const widgetKey = "workflow-navigation";
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Finished counts are an unread notice: opening the dashboard or sending the
|
|
409
|
+
* next explicit request acknowledges them.
|
|
410
|
+
*/
|
|
411
|
+
let lastContext: ExtensionContext | undefined;
|
|
412
|
+
let completedRuns = 0;
|
|
413
|
+
let failedRuns = 0;
|
|
414
|
+
let widgetVisible = false;
|
|
415
|
+
let requestWidgetRender: (() => void) | undefined;
|
|
416
|
+
let dashboardOpen = false;
|
|
417
|
+
/**
|
|
418
|
+
* Start of the current request. The dashboard reports the work belonging to
|
|
419
|
+
* it, not the whole session's run history.
|
|
420
|
+
*/
|
|
421
|
+
let turnStartedAt = 0;
|
|
422
|
+
let agentTypes = loadAgentTypes({
|
|
423
|
+
agentDir: getAgentDir(),
|
|
424
|
+
cwd: process.cwd(),
|
|
425
|
+
projectTrusted: false,
|
|
426
|
+
}).agentTypes;
|
|
427
|
+
|
|
428
|
+
const newestEntry = (
|
|
429
|
+
entries: Iterable<readonly [string, WorkflowDetails]>,
|
|
430
|
+
): WorkflowStripEntry | undefined => {
|
|
431
|
+
let newest: WorkflowStripEntry | undefined;
|
|
432
|
+
for (const [runId, details] of entries) {
|
|
433
|
+
if (!newest || details.startedAt > newest.details.startedAt) {
|
|
434
|
+
newest = { runId, details };
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return newest;
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
const stripEntry = () => {
|
|
441
|
+
const running = newestEntry(
|
|
442
|
+
[...activeRuns].map(([runId, run]) => [runId, run.details] as const),
|
|
443
|
+
);
|
|
444
|
+
return running ?? newestEntry(settledRuns);
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
const updateWorkflowWidget = () => {
|
|
448
|
+
const ctx = lastContext;
|
|
449
|
+
if (!ctx || ctx.mode !== "tui") return;
|
|
450
|
+
const visible = Boolean(stripEntry());
|
|
451
|
+
if (visible === widgetVisible) return;
|
|
452
|
+
if (!visible) {
|
|
453
|
+
stripState.focused = false;
|
|
454
|
+
requestWidgetRender = undefined;
|
|
455
|
+
ctx.ui.setWidget(widgetKey, undefined);
|
|
456
|
+
widgetVisible = false;
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
ctx.ui.setWidget(
|
|
460
|
+
widgetKey,
|
|
461
|
+
(tui, theme) => {
|
|
462
|
+
requestWidgetRender = () => tui.requestRender();
|
|
463
|
+
return new WorkflowStripWidget(tui, theme, stripState, stripEntry);
|
|
464
|
+
},
|
|
465
|
+
{ placement: "belowEditor" },
|
|
466
|
+
);
|
|
467
|
+
widgetVisible = true;
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
const updateIndicator = () => {
|
|
471
|
+
const ctx = lastContext;
|
|
472
|
+
if (!ctx) return;
|
|
473
|
+
try {
|
|
474
|
+
const running = activeRuns.size;
|
|
475
|
+
if (running === 0 && completedRuns === 0 && failedRuns === 0) {
|
|
476
|
+
ctx.ui.setStatus("workflows", undefined);
|
|
477
|
+
} else {
|
|
478
|
+
ctx.ui.setStatus(
|
|
479
|
+
"workflows",
|
|
480
|
+
formatActivityStatus(ctx.ui.theme, "workflows", {
|
|
481
|
+
running,
|
|
482
|
+
done: completedRuns,
|
|
483
|
+
failed: failedRuns,
|
|
484
|
+
}),
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
updateWorkflowWidget();
|
|
488
|
+
} catch {
|
|
489
|
+
// UI may be unavailable.
|
|
490
|
+
}
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
const acknowledgeSettledRuns = () => {
|
|
494
|
+
completedRuns = 0;
|
|
495
|
+
failedRuns = 0;
|
|
496
|
+
settledRuns.clear();
|
|
497
|
+
};
|
|
498
|
+
|
|
499
|
+
const recordSettledRun = (details: WorkflowDetails) => {
|
|
500
|
+
settledRuns.set(details.runId, details);
|
|
501
|
+
if (details.status === "completed") completedRuns += 1;
|
|
502
|
+
else failedRuns += 1;
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
const stopRun = (runId: string) => {
|
|
506
|
+
const run = activeRuns.get(runId);
|
|
507
|
+
if (!run || run.details.status !== "running") return false;
|
|
508
|
+
run.controller.abort("Stopped by user");
|
|
509
|
+
return true;
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
const openDashboard = async (
|
|
513
|
+
ctx: ExtensionContext,
|
|
514
|
+
initialRunId?: string,
|
|
515
|
+
startedSince = turnStartedAt,
|
|
516
|
+
) => {
|
|
517
|
+
if (dashboardOpen || ctx.mode !== "tui") return;
|
|
518
|
+
dashboardOpen = true;
|
|
519
|
+
stripState.focused = false;
|
|
520
|
+
try {
|
|
521
|
+
await showWorkflowDashboard(
|
|
522
|
+
ctx,
|
|
523
|
+
activeDetails,
|
|
524
|
+
initialRunId,
|
|
525
|
+
startedSince,
|
|
526
|
+
stopRun,
|
|
527
|
+
);
|
|
528
|
+
acknowledgeSettledRuns();
|
|
529
|
+
} finally {
|
|
530
|
+
dashboardOpen = false;
|
|
531
|
+
updateIndicator();
|
|
532
|
+
}
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
const installWorkflowNavigation = (ctx: ExtensionContext) => {
|
|
536
|
+
if (ctx.mode !== "tui") return;
|
|
537
|
+
const previous = ctx.ui.getEditorComponent();
|
|
538
|
+
ctx.ui.setEditorComponent((tui, theme, keybindings) => {
|
|
539
|
+
const base =
|
|
540
|
+
previous?.(tui, theme, keybindings) ??
|
|
541
|
+
new CustomEditor(tui, theme, keybindings);
|
|
542
|
+
return new WorkflowNavigationEditor(
|
|
543
|
+
base,
|
|
544
|
+
keybindings,
|
|
545
|
+
stripState,
|
|
546
|
+
() => Boolean(stripEntry()),
|
|
547
|
+
() => {
|
|
548
|
+
const entry = stripEntry();
|
|
549
|
+
if (entry) void openDashboard(ctx, entry.runId);
|
|
550
|
+
},
|
|
551
|
+
() => {
|
|
552
|
+
requestWidgetRender?.();
|
|
553
|
+
tui.requestRender();
|
|
554
|
+
},
|
|
555
|
+
);
|
|
556
|
+
});
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
pi.on("session_start", (_event, ctx) => {
|
|
560
|
+
if (ctx.hasUI) lastContext = ctx;
|
|
561
|
+
agentTypes = loadAgentTypes({
|
|
562
|
+
agentDir: getAgentDir(),
|
|
563
|
+
cwd: ctx.cwd,
|
|
564
|
+
projectTrusted: ctx.isProjectTrusted(),
|
|
565
|
+
}).agentTypes;
|
|
566
|
+
turnStartedAt = 0;
|
|
567
|
+
completedRuns = 0;
|
|
568
|
+
failedRuns = 0;
|
|
569
|
+
settledRuns.clear();
|
|
570
|
+
installWorkflowNavigation(ctx);
|
|
571
|
+
updateIndicator();
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
pi.on("input", (event) => {
|
|
575
|
+
if (event.source === "extension") return;
|
|
576
|
+
turnStartedAt = Date.now();
|
|
577
|
+
acknowledgeSettledRuns();
|
|
578
|
+
updateIndicator();
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
pi.on("session_shutdown", async () => {
|
|
582
|
+
await shutdownActiveWorkflowRuns([...activeRuns.values()]);
|
|
583
|
+
try {
|
|
584
|
+
lastContext?.ui.setStatus("workflows", undefined);
|
|
585
|
+
lastContext?.ui.setWidget(widgetKey, undefined);
|
|
586
|
+
} catch {
|
|
587
|
+
// UI may already be disposed.
|
|
588
|
+
}
|
|
589
|
+
lastContext = undefined;
|
|
590
|
+
widgetVisible = false;
|
|
591
|
+
requestWidgetRender = undefined;
|
|
592
|
+
stripState.focused = false;
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
pi.registerCommand("workflows", {
|
|
596
|
+
description:
|
|
597
|
+
"List workflow runs (`/workflows <runId>` for detail, `/workflows <runId> stop` to cancel)",
|
|
598
|
+
handler: async (rawArgs, ctx) => {
|
|
599
|
+
const arg = rawArgs.trim();
|
|
600
|
+
|
|
601
|
+
// `/workflows <runId> stop` (or `stop <runId>`) cancels a running
|
|
602
|
+
// workflow. Background runs otherwise only stop at session shutdown.
|
|
603
|
+
const stopMatch = arg.match(/^(?:stop\s+(\S+)|(\S+)\s+stop)$/i);
|
|
604
|
+
if (stopMatch) {
|
|
605
|
+
const target = stopMatch[1] ?? stopMatch[2];
|
|
606
|
+
const running = [...activeRuns].filter(
|
|
607
|
+
([, run]) => run.details.status === "running",
|
|
608
|
+
);
|
|
609
|
+
const resolution = resolveWorkflowRunTarget(
|
|
610
|
+
target,
|
|
611
|
+
running.map(([runId]) => runId),
|
|
612
|
+
);
|
|
613
|
+
if (!resolution.ok) {
|
|
614
|
+
ctx.ui.notify(resolution.error, "warning");
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
activeRuns.get(resolution.runId)?.controller.abort("Stopped by user");
|
|
618
|
+
ctx.ui.notify(`Stopping workflow ${resolution.runId}…`, "info");
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// An explicit run id is a deliberate lookup, so it reaches session history.
|
|
623
|
+
const startedSince = arg ? 0 : turnStartedAt;
|
|
624
|
+
if (ctx.mode === "tui") {
|
|
625
|
+
lastContext = ctx;
|
|
626
|
+
await openDashboard(ctx, arg || undefined, startedSince);
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
// Non-TUI fallback: plain text listing.
|
|
630
|
+
const runs = listRuns(
|
|
631
|
+
activeDetails(),
|
|
632
|
+
ctx.sessionManager.getSessionId(),
|
|
633
|
+
sessionWorkflowRunIds(ctx),
|
|
634
|
+
startedSince,
|
|
635
|
+
);
|
|
636
|
+
if (arg) {
|
|
637
|
+
const resolution = resolveWorkflowRunTarget(
|
|
638
|
+
arg,
|
|
639
|
+
runs.map((run) => run.runId),
|
|
640
|
+
);
|
|
641
|
+
if (!resolution.ok) {
|
|
642
|
+
ctx.ui.notify(resolution.error, "warning");
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
const run = runs.find(
|
|
646
|
+
(candidate) => candidate.runId === resolution.runId,
|
|
647
|
+
);
|
|
648
|
+
if (run) ctx.ui.notify(runDetailText(run, activeDetails()), "info");
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
if (runs.length === 0) {
|
|
652
|
+
ctx.ui.notify("No workflow runs for this request.", "info");
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
const labels = runs.map(
|
|
656
|
+
(r) =>
|
|
657
|
+
`${r.active ? "* " : " "}${r.runId} ${r.status} ${r.name ?? ""} ${r.done}/${r.total}`,
|
|
658
|
+
);
|
|
659
|
+
if (!ctx.hasUI) {
|
|
660
|
+
ctx.ui.notify(labels.join("\n"), "info");
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
const choice = await ctx.ui.select("Workflow runs", labels);
|
|
664
|
+
if (!choice) return;
|
|
665
|
+
const run = runs[labels.indexOf(choice)];
|
|
666
|
+
if (run) ctx.ui.notify(runDetailText(run, activeDetails()), "info");
|
|
667
|
+
},
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
pi.registerTool({
|
|
671
|
+
name: "workflow",
|
|
672
|
+
label: "Workflow",
|
|
673
|
+
description: WORKFLOW_TOOL_DESCRIPTION,
|
|
674
|
+
promptSnippet: WORKFLOW_PROMPT_SNIPPET,
|
|
675
|
+
promptGuidelines: WORKFLOW_PROMPT_GUIDELINES,
|
|
676
|
+
parameters: WorkflowParams,
|
|
677
|
+
|
|
678
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
679
|
+
let prepared: ReturnType<typeof prepareWorkflowScript>;
|
|
680
|
+
try {
|
|
681
|
+
prepared = prepareWorkflowScript(params.script);
|
|
682
|
+
} catch (error) {
|
|
683
|
+
throw new Error(`Workflow script failed to parse: ${errorText(error)}`);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
let args: unknown;
|
|
687
|
+
if (params.args !== undefined) {
|
|
688
|
+
try {
|
|
689
|
+
args = JSON.parse(params.args);
|
|
690
|
+
} catch {
|
|
691
|
+
args = params.args;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
const meta = prepared.meta;
|
|
696
|
+
const runId = `wf_${randomBytes(6).toString("hex")}`;
|
|
697
|
+
const runDir = path.join(getAgentDir(), "workflows", runId);
|
|
698
|
+
const background = (params.background ?? false) && ctx.hasUI;
|
|
699
|
+
|
|
700
|
+
const details: WorkflowDetails = {
|
|
701
|
+
runId,
|
|
702
|
+
sessionId: ctx.sessionManager.getSessionId(),
|
|
703
|
+
name: meta.name,
|
|
704
|
+
description: meta.description,
|
|
705
|
+
background,
|
|
706
|
+
status: "running",
|
|
707
|
+
startedAt: Date.now(),
|
|
708
|
+
phases: [...meta.phases],
|
|
709
|
+
agents: [],
|
|
710
|
+
};
|
|
711
|
+
|
|
712
|
+
// Resume: replay cached results for calls whose content is unchanged.
|
|
713
|
+
// A missing or unreadable source degrades to a normal full run — resume
|
|
714
|
+
// is an optimization and must not become a new way to fail.
|
|
715
|
+
const journalEntries: JournalEntry[] = [];
|
|
716
|
+
let replay: ReplayCache | undefined;
|
|
717
|
+
if (params.resume_from_run_id) {
|
|
718
|
+
const source = resolveRunDir(params.resume_from_run_id);
|
|
719
|
+
const journal = source.ok ? loadJournal(source.runDir) : undefined;
|
|
720
|
+
if (source.ok && journal && journal.entries.length > 0) {
|
|
721
|
+
replay = createReplayCache(journal);
|
|
722
|
+
details.resumedFrom = source.runId;
|
|
723
|
+
} else {
|
|
724
|
+
details.resumeNote = source.ok
|
|
725
|
+
? `No replayable results found in ${source.runId}; ran everything fresh.`
|
|
726
|
+
: `${source.error}; ran everything fresh.`;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
writeRunFile(runDir, "script.js", params.script);
|
|
731
|
+
if (params.args !== undefined)
|
|
732
|
+
writeRunFile(runDir, "args.json", params.args);
|
|
733
|
+
persistWorkflowJson(runDir, details);
|
|
734
|
+
const persistence = createWorkflowPersistence(runDir, details, {
|
|
735
|
+
journal: () => journalEntries,
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
// Background runs survive Esc on the parent turn, but all runs are
|
|
739
|
+
// aborted and settled during session shutdown.
|
|
740
|
+
const workflowConfig = loadSetupConfig().workflows;
|
|
741
|
+
const projectTrusted = ctx.isProjectTrusted();
|
|
742
|
+
const runAgentTypes = agentTypes;
|
|
743
|
+
const controller = new RunController(
|
|
744
|
+
background ? undefined : signal,
|
|
745
|
+
workflowConfig.concurrency,
|
|
746
|
+
workflowConfig.maxAgentCalls,
|
|
747
|
+
);
|
|
748
|
+
|
|
749
|
+
// Each concurrent child gets its own extension runtime. Children use the
|
|
750
|
+
// parent's live trust decision; an isolated child gets its own cwd (its
|
|
751
|
+
// worktree) but keeps that decision, since it is the same project at the
|
|
752
|
+
// same commit.
|
|
753
|
+
const getResources = (
|
|
754
|
+
structured: boolean,
|
|
755
|
+
cwd: string,
|
|
756
|
+
agentTypePrompt?: string,
|
|
757
|
+
) =>
|
|
758
|
+
createWorkflowResources(
|
|
759
|
+
cwd,
|
|
760
|
+
structured ? "structured" : "plain",
|
|
761
|
+
projectTrusted,
|
|
762
|
+
agentTypePrompt,
|
|
763
|
+
);
|
|
764
|
+
|
|
765
|
+
// Throttled progress: tool-block updates when blocking. Background
|
|
766
|
+
// runs are covered by the below-editor indicator and /workflows.
|
|
767
|
+
let emitTimer: ReturnType<typeof setTimeout> | undefined;
|
|
768
|
+
let lastEmit = 0;
|
|
769
|
+
let runSettled = false;
|
|
770
|
+
const flush = (terminal = false) => {
|
|
771
|
+
emitTimer = undefined;
|
|
772
|
+
if (runSettled && !terminal) return;
|
|
773
|
+
lastEmit = Date.now();
|
|
774
|
+
if (background) return;
|
|
775
|
+
onUpdate?.({
|
|
776
|
+
content: [{ type: "text", text: summaryLine(details) }],
|
|
777
|
+
details: compactToolDetails(details),
|
|
778
|
+
});
|
|
779
|
+
};
|
|
780
|
+
const emit = (checkpoint = true) => {
|
|
781
|
+
if (runSettled) return;
|
|
782
|
+
if (checkpoint) persistence.checkpoint();
|
|
783
|
+
if (emitTimer) return;
|
|
784
|
+
emitTimer = setTimeout(
|
|
785
|
+
flush,
|
|
786
|
+
Math.max(0, EMIT_INTERVAL_MS - (Date.now() - lastEmit)),
|
|
787
|
+
);
|
|
788
|
+
};
|
|
789
|
+
const flushNow = (terminal = false) => {
|
|
790
|
+
if (emitTimer) clearTimeout(emitTimer);
|
|
791
|
+
flush(terminal);
|
|
792
|
+
};
|
|
793
|
+
|
|
794
|
+
const terminalize = (
|
|
795
|
+
status: WorkflowDetails["status"],
|
|
796
|
+
error?: string,
|
|
797
|
+
) => {
|
|
798
|
+
if (runSettled) return false;
|
|
799
|
+
runSettled = true;
|
|
800
|
+
if (emitTimer) {
|
|
801
|
+
clearTimeout(emitTimer);
|
|
802
|
+
emitTimer = undefined;
|
|
803
|
+
}
|
|
804
|
+
controller.abort(
|
|
805
|
+
error ??
|
|
806
|
+
(status === "completed"
|
|
807
|
+
? "Workflow completed"
|
|
808
|
+
: "Workflow was settled"),
|
|
809
|
+
);
|
|
810
|
+
for (const record of details.agents) {
|
|
811
|
+
if (record.state !== "running") continue;
|
|
812
|
+
record.state = "error";
|
|
813
|
+
record.error =
|
|
814
|
+
record.error ?? "Agent did not settle before run cleanup";
|
|
815
|
+
record.finishedAt = Date.now();
|
|
816
|
+
}
|
|
817
|
+
details.status = status;
|
|
818
|
+
details.finishedAt = Date.now();
|
|
819
|
+
if (error) details.error = sanitizeWorkflowDisplayLine(error);
|
|
820
|
+
return true;
|
|
821
|
+
};
|
|
822
|
+
|
|
823
|
+
const forceSettle = (error: string) => {
|
|
824
|
+
if (!terminalize("failed", error)) return;
|
|
825
|
+
try {
|
|
826
|
+
persistence.flush();
|
|
827
|
+
} catch (persistenceError) {
|
|
828
|
+
details.error = `${error}; artifact persistence failed: ${errorText(persistenceError)}`;
|
|
829
|
+
}
|
|
830
|
+
flushNow(true);
|
|
831
|
+
};
|
|
832
|
+
|
|
833
|
+
const phaseFn = (title: unknown) => {
|
|
834
|
+
if (runSettled) return;
|
|
835
|
+
const text = sanitizeLine(String(title), 160);
|
|
836
|
+
if (!text) return;
|
|
837
|
+
details.currentPhase = text;
|
|
838
|
+
if (!details.phases.some((p) => p.title === text))
|
|
839
|
+
details.phases.push({ title: text });
|
|
840
|
+
emit();
|
|
841
|
+
};
|
|
842
|
+
|
|
843
|
+
// The script's narrator. Unlike phase(), this is append-only progress
|
|
844
|
+
// text, so it never mutates the phase list a run is judged against.
|
|
845
|
+
const logFn = (text: string) => {
|
|
846
|
+
if (runSettled) return;
|
|
847
|
+
appendLog(details, text, Date.now());
|
|
848
|
+
emit();
|
|
849
|
+
};
|
|
850
|
+
|
|
851
|
+
// One reader per run: it carries a high-water mark, because per-agent
|
|
852
|
+
// usage is recomputed from a message list that compaction shrinks.
|
|
853
|
+
const readUsage = createUsageReader(details.agents);
|
|
854
|
+
|
|
855
|
+
let agentCounter = 0;
|
|
856
|
+
const agentFn = async (
|
|
857
|
+
promptValue: unknown,
|
|
858
|
+
optsValue: unknown = {},
|
|
859
|
+
invocationSignal?: AbortSignal,
|
|
860
|
+
): Promise<ScriptAgentResult> => {
|
|
861
|
+
const index = ++agentCounter;
|
|
862
|
+
const opts: AgentCallOptions =
|
|
863
|
+
optsValue && typeof optsValue === "object"
|
|
864
|
+
? (optsValue as AgentCallOptions)
|
|
865
|
+
: {};
|
|
866
|
+
const requestedLabel =
|
|
867
|
+
typeof opts.label === "string" ? sanitizeLine(opts.label, 160) : "";
|
|
868
|
+
const label = requestedLabel || `agent-${index}`;
|
|
869
|
+
|
|
870
|
+
if (runSettled || controller.signal.aborted) {
|
|
871
|
+
return {
|
|
872
|
+
ok: false,
|
|
873
|
+
output: "",
|
|
874
|
+
error: "Workflow was aborted before this agent started",
|
|
875
|
+
};
|
|
876
|
+
}
|
|
877
|
+
const record: AgentRecord = {
|
|
878
|
+
index,
|
|
879
|
+
label,
|
|
880
|
+
phase:
|
|
881
|
+
typeof opts.phase === "string"
|
|
882
|
+
? sanitizeLine(opts.phase, 160) || undefined
|
|
883
|
+
: details.currentPhase,
|
|
884
|
+
state: "running",
|
|
885
|
+
model: ctx.model?.id,
|
|
886
|
+
contextWindow: ctx.model?.contextWindow,
|
|
887
|
+
startedAt: Date.now(),
|
|
888
|
+
preview: "",
|
|
889
|
+
usage: emptyUsage(),
|
|
890
|
+
transcript: [],
|
|
891
|
+
};
|
|
892
|
+
details.agents.push(record);
|
|
893
|
+
persistence.checkpoint({ immediate: true });
|
|
894
|
+
emit(false);
|
|
895
|
+
|
|
896
|
+
const fail = (error: string): ScriptAgentResult => {
|
|
897
|
+
if (record.state === "running" && !runSettled) {
|
|
898
|
+
record.state = "error";
|
|
899
|
+
record.error = sanitizeWorkflowDisplayLine(error);
|
|
900
|
+
record.finishedAt = Date.now();
|
|
901
|
+
emit();
|
|
902
|
+
}
|
|
903
|
+
return { ok: false, output: "", error };
|
|
904
|
+
};
|
|
905
|
+
|
|
906
|
+
const basePrompt =
|
|
907
|
+
typeof promptValue === "string"
|
|
908
|
+
? promptValue
|
|
909
|
+
: String(promptValue ?? "");
|
|
910
|
+
if (!basePrompt.trim())
|
|
911
|
+
return fail("agent() requires a non-empty prompt string");
|
|
912
|
+
let acceptanceContract: ReturnType<typeof parseAcceptanceContract>;
|
|
913
|
+
let effectiveSchema: unknown;
|
|
914
|
+
try {
|
|
915
|
+
acceptanceContract = parseAcceptanceContract(opts.acceptance);
|
|
916
|
+
effectiveSchema = acceptanceContract
|
|
917
|
+
? acceptanceSchema(opts.schema, acceptanceContract)
|
|
918
|
+
: opts.schema;
|
|
919
|
+
} catch (error) {
|
|
920
|
+
return fail(`agent "${label}": ${errorText(error)}`);
|
|
921
|
+
}
|
|
922
|
+
const prompt = buildWorkflowAgentPrompt(
|
|
923
|
+
acceptanceContract
|
|
924
|
+
? `${basePrompt}\n\n${acceptanceInstruction(acceptanceContract)}`
|
|
925
|
+
: basePrompt,
|
|
926
|
+
);
|
|
927
|
+
if (controller.signal.aborted)
|
|
928
|
+
return fail("Workflow was aborted before this agent started");
|
|
929
|
+
|
|
930
|
+
const requestedType =
|
|
931
|
+
typeof opts.agent_type === "string"
|
|
932
|
+
? opts.agent_type.trim()
|
|
933
|
+
: undefined;
|
|
934
|
+
if (opts.agent_type !== undefined && !requestedType) {
|
|
935
|
+
return fail(
|
|
936
|
+
`agent "${label}": agent_type must be a non-empty string`,
|
|
937
|
+
);
|
|
938
|
+
}
|
|
939
|
+
const agentType = requestedType
|
|
940
|
+
? runAgentTypes.get(requestedType)
|
|
941
|
+
: undefined;
|
|
942
|
+
if (requestedType && !agentType) {
|
|
943
|
+
return fail(
|
|
944
|
+
`agent "${label}": unknown agent_type "${requestedType}" (available: ${[...runAgentTypes.keys()].join(", ")})`,
|
|
945
|
+
);
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
const explicitModel =
|
|
949
|
+
typeof opts.model === "string" && opts.model.trim()
|
|
950
|
+
? opts.model.trim()
|
|
951
|
+
: undefined;
|
|
952
|
+
const explicitProvider =
|
|
953
|
+
typeof opts.provider === "string" && opts.provider.trim()
|
|
954
|
+
? opts.provider.trim()
|
|
955
|
+
: undefined;
|
|
956
|
+
if (opts.model !== undefined && !explicitModel) {
|
|
957
|
+
return fail(`agent "${label}": model must be a non-empty string`);
|
|
958
|
+
}
|
|
959
|
+
if (opts.provider !== undefined && !explicitProvider) {
|
|
960
|
+
return fail(`agent "${label}": provider must be a non-empty string`);
|
|
961
|
+
}
|
|
962
|
+
if (explicitProvider && !explicitModel) {
|
|
963
|
+
return fail(
|
|
964
|
+
`agent "${label}": \`provider\` requires \`model\` as well`,
|
|
965
|
+
);
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
const explicitModelHint =
|
|
969
|
+
explicitModel && explicitProvider
|
|
970
|
+
? `${explicitProvider}/${explicitModel}`
|
|
971
|
+
: explicitModel;
|
|
972
|
+
const modelHint = selectSubagentModel(
|
|
973
|
+
explicitModelHint,
|
|
974
|
+
agentType,
|
|
975
|
+
roleModelForAgentType(
|
|
976
|
+
agentType,
|
|
977
|
+
loadSetupConfig().subagents.roleModels,
|
|
978
|
+
),
|
|
979
|
+
);
|
|
980
|
+
const explicitEffort =
|
|
981
|
+
typeof opts.effort === "string" && opts.effort.trim()
|
|
982
|
+
? opts.effort.trim()
|
|
983
|
+
: undefined;
|
|
984
|
+
if (opts.effort !== undefined && !explicitEffort) {
|
|
985
|
+
return fail(`agent "${label}": effort must be a non-empty string`);
|
|
986
|
+
}
|
|
987
|
+
// Resolve every output-affecting default before replay lookup.
|
|
988
|
+
let model: WorkflowModel | undefined = ctx.model;
|
|
989
|
+
if (modelHint !== undefined) {
|
|
990
|
+
try {
|
|
991
|
+
model = resolveAgentModel(
|
|
992
|
+
ctx.modelRegistry,
|
|
993
|
+
modelHint,
|
|
994
|
+
ctx.model
|
|
995
|
+
? { provider: ctx.model.provider, id: ctx.model.id }
|
|
996
|
+
: undefined,
|
|
997
|
+
);
|
|
998
|
+
} catch (error) {
|
|
999
|
+
return fail(`agent "${label}": ${errorText(error)}`);
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
const effectiveEffort = explicitEffort ?? agentType?.reasoningEffort;
|
|
1003
|
+
let thinkingLevel: ThinkingLevel = pi.getThinkingLevel();
|
|
1004
|
+
if (effectiveEffort !== undefined) {
|
|
1005
|
+
const effort = String(effectiveEffort);
|
|
1006
|
+
if (!(THINKING_LEVELS as readonly string[]).includes(effort)) {
|
|
1007
|
+
return fail(
|
|
1008
|
+
`agent "${label}": invalid effort "${effort}" (use ${THINKING_LEVELS.join("|")})`,
|
|
1009
|
+
);
|
|
1010
|
+
}
|
|
1011
|
+
thinkingLevel = effort as ThinkingLevel;
|
|
1012
|
+
}
|
|
1013
|
+
record.model = model?.id;
|
|
1014
|
+
record.contextWindow = model?.contextWindow;
|
|
1015
|
+
|
|
1016
|
+
// Replay is deliberately narrower than execution: only a named type
|
|
1017
|
+
// whose effective tool allowlist is entirely known read-only can be
|
|
1018
|
+
// cached. General-purpose children inherit bash/edit/write, custom
|
|
1019
|
+
// tools have unknown effects, and worktrees have state a string result
|
|
1020
|
+
// cannot restore.
|
|
1021
|
+
const replaySafe = isReplaySafeAgentCall({
|
|
1022
|
+
tools: agentType?.tools,
|
|
1023
|
+
isolation: opts.isolation,
|
|
1024
|
+
});
|
|
1025
|
+
const replayLease = beginProcessReplayWorkspaceLease(replaySafe);
|
|
1026
|
+
let replayResources:
|
|
1027
|
+
Awaited<ReturnType<typeof getResources>> | undefined;
|
|
1028
|
+
let replayIdentity: ReturnType<typeof createReplayIdentity> | undefined;
|
|
1029
|
+
if (replaySafe) {
|
|
1030
|
+
try {
|
|
1031
|
+
replayResources = await getResources(
|
|
1032
|
+
effectiveSchema !== undefined,
|
|
1033
|
+
ctx.cwd,
|
|
1034
|
+
agentType?.body,
|
|
1035
|
+
);
|
|
1036
|
+
replayIdentity = createReplayIdentity(
|
|
1037
|
+
ctx.cwd,
|
|
1038
|
+
replayResources.loader,
|
|
1039
|
+
projectTrusted,
|
|
1040
|
+
);
|
|
1041
|
+
} catch {
|
|
1042
|
+
// Fingerprinting is an optimization boundary. If resources cannot
|
|
1043
|
+
// be resolved, run normally and let the execution path report any
|
|
1044
|
+
// real resource error instead of trusting an unverifiable hit.
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
const replayKey = (
|
|
1048
|
+
identity: NonNullable<ReturnType<typeof createReplayIdentity>>,
|
|
1049
|
+
) =>
|
|
1050
|
+
agentCallKey(prompt, {
|
|
1051
|
+
...opts,
|
|
1052
|
+
execution: {
|
|
1053
|
+
agentType: agentType
|
|
1054
|
+
? {
|
|
1055
|
+
name: agentType.name,
|
|
1056
|
+
body: agentType.body,
|
|
1057
|
+
tools: agentType.tools,
|
|
1058
|
+
}
|
|
1059
|
+
: undefined,
|
|
1060
|
+
model: model ? `${model.provider}/${model.id}` : undefined,
|
|
1061
|
+
effort: thinkingLevel,
|
|
1062
|
+
acceptance: acceptanceContract,
|
|
1063
|
+
replayIdentity: identity,
|
|
1064
|
+
},
|
|
1065
|
+
});
|
|
1066
|
+
const callKey = replayIdentity ? replayKey(replayIdentity) : undefined;
|
|
1067
|
+
let replayBoundaryViolated = false;
|
|
1068
|
+
// Checked before controller.schedule on purpose: schedule() charges the
|
|
1069
|
+
// run's agent-call budget on entry, and a replayed call runs no agent.
|
|
1070
|
+
const cached =
|
|
1071
|
+
callKey && replayLease.canReplay ? replay?.take(callKey) : undefined;
|
|
1072
|
+
if (cached) {
|
|
1073
|
+
record.state = "done";
|
|
1074
|
+
record.replayed = true;
|
|
1075
|
+
record.finishedAt = Date.now();
|
|
1076
|
+
record.preview = sanitizeWorkflowDisplayText(
|
|
1077
|
+
cached.output,
|
|
1078
|
+
PREVIEW_LENGTH,
|
|
1079
|
+
);
|
|
1080
|
+
if (acceptanceContract) {
|
|
1081
|
+
record.acceptance = evaluateAcceptance(
|
|
1082
|
+
acceptanceContract,
|
|
1083
|
+
cached.structured,
|
|
1084
|
+
);
|
|
1085
|
+
}
|
|
1086
|
+
emit();
|
|
1087
|
+
// Re-journal so a chain of resumes keeps working: run C resuming from
|
|
1088
|
+
// B still finds what B replayed from A.
|
|
1089
|
+
journalEntries.push(cached);
|
|
1090
|
+
replayLease.end();
|
|
1091
|
+
return {
|
|
1092
|
+
ok: true,
|
|
1093
|
+
output: cached.output,
|
|
1094
|
+
...(cached.structured !== undefined
|
|
1095
|
+
? { structured: cached.structured }
|
|
1096
|
+
: {}),
|
|
1097
|
+
...(record.acceptance ? { acceptance: record.acceptance } : {}),
|
|
1098
|
+
};
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
return controller
|
|
1102
|
+
.schedule(async (runSignal) => {
|
|
1103
|
+
record.model = model?.id;
|
|
1104
|
+
record.contextWindow = model?.contextWindow;
|
|
1105
|
+
emit();
|
|
1106
|
+
|
|
1107
|
+
/**
|
|
1108
|
+
* Isolation is requested, not best-effort: the caller asks for a
|
|
1109
|
+
* worktree exactly because concurrent stages would otherwise
|
|
1110
|
+
* collide in one checkout, so silently sharing `ctx.cwd` would
|
|
1111
|
+
* hand back the hazard they were avoiding. Fail this one agent
|
|
1112
|
+
* with git's reason; siblings are unaffected.
|
|
1113
|
+
*/
|
|
1114
|
+
let worktree: Worktree | undefined;
|
|
1115
|
+
if (opts.isolation !== undefined) {
|
|
1116
|
+
if (opts.isolation !== "worktree") {
|
|
1117
|
+
return fail(
|
|
1118
|
+
`agent "${label}": invalid isolation "${String(opts.isolation)}" (the only value is "worktree")`,
|
|
1119
|
+
);
|
|
1120
|
+
}
|
|
1121
|
+
const created = await createWorktree({
|
|
1122
|
+
cwd: ctx.cwd,
|
|
1123
|
+
label,
|
|
1124
|
+
id: `${details.runId}-${record.index}`,
|
|
1125
|
+
});
|
|
1126
|
+
if (!created.ok) {
|
|
1127
|
+
return fail(
|
|
1128
|
+
`agent "${label}": isolation "worktree" requested but could not be created (${created.reason})`,
|
|
1129
|
+
);
|
|
1130
|
+
}
|
|
1131
|
+
worktree = created.worktree;
|
|
1132
|
+
if (!runSettled) record.worktreeBranch = worktree.branch;
|
|
1133
|
+
}
|
|
1134
|
+
if (runSignal.aborted || runSettled) {
|
|
1135
|
+
throw runSignal.reason instanceof Error
|
|
1136
|
+
? runSignal.reason
|
|
1137
|
+
: new Error("Workflow was aborted");
|
|
1138
|
+
}
|
|
1139
|
+
const agentCwd = worktree?.path ?? ctx.cwd;
|
|
1140
|
+
|
|
1141
|
+
// Inside the try, not before it: building resources can throw
|
|
1142
|
+
// (bad settings, an unreadable skills dir), and a throw out here
|
|
1143
|
+
// would skip the finally and leak the worktree permanently —
|
|
1144
|
+
// nothing sweeps `.git/pi-worktrees/` afterwards.
|
|
1145
|
+
try {
|
|
1146
|
+
let rejectResourceLoad: (() => void) | undefined;
|
|
1147
|
+
const resourceAbort = new Promise<never>((_resolve, reject) => {
|
|
1148
|
+
rejectResourceLoad = () =>
|
|
1149
|
+
reject(
|
|
1150
|
+
runSignal.reason instanceof Error
|
|
1151
|
+
? runSignal.reason
|
|
1152
|
+
: new Error("Workflow was aborted"),
|
|
1153
|
+
);
|
|
1154
|
+
runSignal.addEventListener("abort", rejectResourceLoad, {
|
|
1155
|
+
once: true,
|
|
1156
|
+
});
|
|
1157
|
+
if (runSignal.aborted) queueMicrotask(rejectResourceLoad);
|
|
1158
|
+
});
|
|
1159
|
+
const resources = await Promise.race([
|
|
1160
|
+
replayResources ??
|
|
1161
|
+
getResources(
|
|
1162
|
+
effectiveSchema !== undefined,
|
|
1163
|
+
agentCwd,
|
|
1164
|
+
agentType?.body,
|
|
1165
|
+
),
|
|
1166
|
+
resourceAbort,
|
|
1167
|
+
]).finally(() => {
|
|
1168
|
+
if (rejectResourceLoad) {
|
|
1169
|
+
runSignal.removeEventListener("abort", rejectResourceLoad);
|
|
1170
|
+
}
|
|
1171
|
+
});
|
|
1172
|
+
if (runSettled) {
|
|
1173
|
+
throw new Error("Workflow was settled before agent creation");
|
|
1174
|
+
}
|
|
1175
|
+
const outcome = await runAgent({
|
|
1176
|
+
prompt,
|
|
1177
|
+
schema: effectiveSchema,
|
|
1178
|
+
model,
|
|
1179
|
+
thinkingLevel,
|
|
1180
|
+
// Replay-safe calls use the same canonical cwd as the
|
|
1181
|
+
// identity and filesystem boundary, so a symlink spelling of
|
|
1182
|
+
// the checkout cannot retarget relative tool paths.
|
|
1183
|
+
cwd: replayIdentity?.cwd ?? agentCwd,
|
|
1184
|
+
loader: resources.loader,
|
|
1185
|
+
settingsManager: resources.settingsManager,
|
|
1186
|
+
modelRegistry: ctx.modelRegistry,
|
|
1187
|
+
...(agentType?.tools ? { tools: agentType.tools } : {}),
|
|
1188
|
+
...(replayIdentity
|
|
1189
|
+
? {
|
|
1190
|
+
replayFilesystemBoundary: {
|
|
1191
|
+
repositoryRoot: replayIdentity.repositoryRoot,
|
|
1192
|
+
cwd: replayIdentity.cwd,
|
|
1193
|
+
onViolation: () => {
|
|
1194
|
+
replayBoundaryViolated = true;
|
|
1195
|
+
},
|
|
1196
|
+
},
|
|
1197
|
+
}
|
|
1198
|
+
: {}),
|
|
1199
|
+
signal: runSignal,
|
|
1200
|
+
onProgress: (progress) => {
|
|
1201
|
+
if (runSettled || record.state !== "running") return;
|
|
1202
|
+
record.preview = sanitizeWorkflowDisplayText(
|
|
1203
|
+
progress.preview,
|
|
1204
|
+
PREVIEW_LENGTH,
|
|
1205
|
+
);
|
|
1206
|
+
record.usage = progress.usage;
|
|
1207
|
+
record.model = progress.model ?? record.model;
|
|
1208
|
+
record.contextWindow =
|
|
1209
|
+
progress.contextWindow ?? record.contextWindow;
|
|
1210
|
+
record.transcript = progress.transcript;
|
|
1211
|
+
emit();
|
|
1212
|
+
},
|
|
1213
|
+
});
|
|
1214
|
+
|
|
1215
|
+
if (runSettled || record.state !== "running") {
|
|
1216
|
+
return {
|
|
1217
|
+
ok: false,
|
|
1218
|
+
output: "",
|
|
1219
|
+
error: "Agent completed after workflow settlement",
|
|
1220
|
+
};
|
|
1221
|
+
}
|
|
1222
|
+
record.usage = outcome.usage;
|
|
1223
|
+
record.model = outcome.model ?? record.model;
|
|
1224
|
+
record.contextWindow =
|
|
1225
|
+
outcome.contextWindow ?? record.contextWindow;
|
|
1226
|
+
record.transcript = outcome.transcript;
|
|
1227
|
+
record.preview = sanitizeWorkflowDisplayText(
|
|
1228
|
+
outcome.output || record.preview,
|
|
1229
|
+
PREVIEW_LENGTH,
|
|
1230
|
+
);
|
|
1231
|
+
record.finishedAt = Date.now();
|
|
1232
|
+
const judged = applyAcceptance({
|
|
1233
|
+
contract: acceptanceContract,
|
|
1234
|
+
structured: outcome.structured,
|
|
1235
|
+
agentOk: outcome.ok,
|
|
1236
|
+
...(outcome.error ? { agentError: outcome.error } : {}),
|
|
1237
|
+
});
|
|
1238
|
+
const acceptance = judged.ledger;
|
|
1239
|
+
if (acceptance) record.acceptance = acceptance;
|
|
1240
|
+
const outcomeOk = judged.ok;
|
|
1241
|
+
record.state = outcomeOk ? "done" : "error";
|
|
1242
|
+
if (outcomeOk) delete record.error;
|
|
1243
|
+
else
|
|
1244
|
+
record.error = judged.error
|
|
1245
|
+
? sanitizeWorkflowDisplayLine(judged.error)
|
|
1246
|
+
: undefined;
|
|
1247
|
+
emit();
|
|
1248
|
+
|
|
1249
|
+
// Only provably read-only successes with a complete, stable
|
|
1250
|
+
// identity are journaled. Recheck after execution so a concurrent
|
|
1251
|
+
// writer cannot leave a result keyed to the state from before it
|
|
1252
|
+
// ran. Re-running a failure is usually why someone resumes;
|
|
1253
|
+
// writable, unrestricted, unknown-tool, isolated, changed, and
|
|
1254
|
+
// unfingerprintable calls always run for real.
|
|
1255
|
+
const completedIdentity = callKey
|
|
1256
|
+
? createReplayIdentity(
|
|
1257
|
+
ctx.cwd,
|
|
1258
|
+
resources.loader,
|
|
1259
|
+
projectTrusted,
|
|
1260
|
+
)
|
|
1261
|
+
: undefined;
|
|
1262
|
+
const completedKey = completedIdentity
|
|
1263
|
+
? replayKey(completedIdentity)
|
|
1264
|
+
: undefined;
|
|
1265
|
+
if (
|
|
1266
|
+
!runSettled &&
|
|
1267
|
+
outcomeOk &&
|
|
1268
|
+
completedKey !== undefined &&
|
|
1269
|
+
completedKey === callKey &&
|
|
1270
|
+
!replayBoundaryViolated &&
|
|
1271
|
+
replayLease.canJournal()
|
|
1272
|
+
) {
|
|
1273
|
+
journalEntries.push({
|
|
1274
|
+
key: completedKey,
|
|
1275
|
+
output: outcome.output,
|
|
1276
|
+
...(outcome.structured !== undefined
|
|
1277
|
+
? { structured: outcome.structured }
|
|
1278
|
+
: {}),
|
|
1279
|
+
});
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
return {
|
|
1283
|
+
ok: outcomeOk,
|
|
1284
|
+
output: outcome.output,
|
|
1285
|
+
...(outcome.structured !== undefined
|
|
1286
|
+
? { structured: outcome.structured }
|
|
1287
|
+
: {}),
|
|
1288
|
+
...(acceptance ? { acceptance } : {}),
|
|
1289
|
+
...(record.error !== undefined ? { error: record.error } : {}),
|
|
1290
|
+
};
|
|
1291
|
+
} finally {
|
|
1292
|
+
// Reclaim as this agent settles, not at run end: a pipeline can
|
|
1293
|
+
// hold many worktrees open at once. Cleanup must never turn a
|
|
1294
|
+
// finished agent into a failed one, so failures only downgrade
|
|
1295
|
+
// what we report about the worktree.
|
|
1296
|
+
if (worktree) {
|
|
1297
|
+
const prepared = prepareWorktreeHandoff({
|
|
1298
|
+
runDir,
|
|
1299
|
+
runId: details.runId,
|
|
1300
|
+
agentIndex: record.index,
|
|
1301
|
+
agentLabel: record.label,
|
|
1302
|
+
repoCwd: ctx.cwd,
|
|
1303
|
+
worktree,
|
|
1304
|
+
});
|
|
1305
|
+
let cleanup: WorktreeCleanup;
|
|
1306
|
+
if (!prepared.ok) {
|
|
1307
|
+
cleanup = {
|
|
1308
|
+
removed: false,
|
|
1309
|
+
branchDeleted: false,
|
|
1310
|
+
reason: `handoff capture failed; preserved checkout: ${prepared.reason}`,
|
|
1311
|
+
branch: worktree.branch,
|
|
1312
|
+
...(worktree.baseSha ? { baseSha: worktree.baseSha } : {}),
|
|
1313
|
+
detached: false,
|
|
1314
|
+
};
|
|
1315
|
+
} else {
|
|
1316
|
+
cleanup = await reclaimWorktree(ctx.cwd, worktree).catch(
|
|
1317
|
+
(error): WorktreeCleanup => ({
|
|
1318
|
+
removed: false,
|
|
1319
|
+
branchDeleted: false,
|
|
1320
|
+
reason: `worktree cleanup failed: ${errorText(error)}`,
|
|
1321
|
+
branch: worktree.branch,
|
|
1322
|
+
...(worktree.baseSha
|
|
1323
|
+
? { baseSha: worktree.baseSha }
|
|
1324
|
+
: {}),
|
|
1325
|
+
detached: false,
|
|
1326
|
+
}),
|
|
1327
|
+
);
|
|
1328
|
+
try {
|
|
1329
|
+
finalizeWorktreeHandoff(prepared, cleanup);
|
|
1330
|
+
record.worktreeHandoffArtifact = prepared.artifact;
|
|
1331
|
+
} catch (error) {
|
|
1332
|
+
cleanup = {
|
|
1333
|
+
...cleanup,
|
|
1334
|
+
reason: `handoff finalization failed: ${errorText(error)}${cleanup.reason ? `; ${cleanup.reason}` : ""}`,
|
|
1335
|
+
};
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
if (!runSettled) {
|
|
1339
|
+
record.worktreeCleanup = cleanup;
|
|
1340
|
+
if (cleanup.branchDeleted) delete record.worktreeBranch;
|
|
1341
|
+
else record.worktreeBranch = cleanup.branch;
|
|
1342
|
+
if (!cleanup.removed) record.worktreePath = worktree.path;
|
|
1343
|
+
emit();
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
}, invocationSignal)
|
|
1348
|
+
.catch((error) => fail(errorText(error)))
|
|
1349
|
+
.finally(() => replayLease.end());
|
|
1350
|
+
};
|
|
1351
|
+
|
|
1352
|
+
const runScript = async () => {
|
|
1353
|
+
let status: WorkflowDetails["status"] = "completed";
|
|
1354
|
+
try {
|
|
1355
|
+
const result = await runWorkflowSandbox({
|
|
1356
|
+
source: prepared.source,
|
|
1357
|
+
args,
|
|
1358
|
+
cwd: ctx.cwd,
|
|
1359
|
+
signal: controller.signal,
|
|
1360
|
+
onAgent: agentFn,
|
|
1361
|
+
onPhase: phaseFn,
|
|
1362
|
+
onLog: logFn,
|
|
1363
|
+
usageSnapshot: readUsage,
|
|
1364
|
+
maxConcurrency: workflowConfig.concurrency,
|
|
1365
|
+
maxAgentCalls: workflowConfig.maxAgentCalls,
|
|
1366
|
+
// Replays send an IPC message but spend no controller budget, so
|
|
1367
|
+
// the sandbox's backstop has to know how many to expect.
|
|
1368
|
+
extraAgentRequests: replay?.available ?? 0,
|
|
1369
|
+
});
|
|
1370
|
+
if (!runSettled) details.result = result;
|
|
1371
|
+
} catch (error) {
|
|
1372
|
+
if (!runSettled) {
|
|
1373
|
+
details.error = errorText(error);
|
|
1374
|
+
status = controller.signal.aborted ? "aborted" : "failed";
|
|
1375
|
+
controller.abort("Workflow script failed");
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
const settled = await controller.settle({
|
|
1380
|
+
abort: status !== "completed",
|
|
1381
|
+
});
|
|
1382
|
+
if (runSettled) return;
|
|
1383
|
+
if (!settled) {
|
|
1384
|
+
status = "failed";
|
|
1385
|
+
details.error = details.error
|
|
1386
|
+
? `${details.error}; agent shutdown deadline exceeded`
|
|
1387
|
+
: "Agent shutdown deadline exceeded";
|
|
1388
|
+
}
|
|
1389
|
+
if (runSettled) return;
|
|
1390
|
+
terminalize(status, details.error);
|
|
1391
|
+
try {
|
|
1392
|
+
persistence.flush();
|
|
1393
|
+
} catch (error) {
|
|
1394
|
+
details.status = "failed";
|
|
1395
|
+
details.error = `Artifact persistence failed: ${errorText(error)}`;
|
|
1396
|
+
throw new Error(details.error);
|
|
1397
|
+
} finally {
|
|
1398
|
+
flushNow(true);
|
|
1399
|
+
}
|
|
1400
|
+
};
|
|
1401
|
+
|
|
1402
|
+
// Registered for /workflows visibility and session_shutdown abort;
|
|
1403
|
+
// blocking runs are watchable live from the dashboard too.
|
|
1404
|
+
const activeRun: ActiveWorkflowRunLifecycle = {
|
|
1405
|
+
details,
|
|
1406
|
+
controller,
|
|
1407
|
+
forceSettle,
|
|
1408
|
+
};
|
|
1409
|
+
activeRuns.set(runId, activeRun);
|
|
1410
|
+
const completion = runScript();
|
|
1411
|
+
activeRun.completion = completion;
|
|
1412
|
+
if (ctx.hasUI) lastContext = ctx;
|
|
1413
|
+
updateIndicator();
|
|
1414
|
+
|
|
1415
|
+
if (background) {
|
|
1416
|
+
void completion
|
|
1417
|
+
.catch((error) => {
|
|
1418
|
+
details.status = "failed";
|
|
1419
|
+
details.finishedAt = Date.now();
|
|
1420
|
+
details.error = details.error ?? errorText(error);
|
|
1421
|
+
})
|
|
1422
|
+
.finally(() => {
|
|
1423
|
+
activeRuns.delete(runId);
|
|
1424
|
+
recordSettledRun(details);
|
|
1425
|
+
updateIndicator();
|
|
1426
|
+
try {
|
|
1427
|
+
// Deliver like the subagent/terminal families: a custom-typed
|
|
1428
|
+
// session message with a dedicated renderer, not a plain
|
|
1429
|
+
// user-provenance turn.
|
|
1430
|
+
//
|
|
1431
|
+
// Wake the model only if it is idle and therefore plausibly
|
|
1432
|
+
// waiting on this run. If it is busy with something else, the
|
|
1433
|
+
// result still enters context with the user's next message
|
|
1434
|
+
// (nextTurn) instead of forcing a turn it can only acknowledge.
|
|
1435
|
+
const wake = ctx.isIdle();
|
|
1436
|
+
pi.sendMessage(
|
|
1437
|
+
{
|
|
1438
|
+
customType: "workflow-result",
|
|
1439
|
+
content: buildBackgroundWorkflowFollowUp({
|
|
1440
|
+
runId,
|
|
1441
|
+
name: details.name,
|
|
1442
|
+
status: details.status,
|
|
1443
|
+
result: buildWorkflowResultMessage(details, runDir),
|
|
1444
|
+
}),
|
|
1445
|
+
display: true,
|
|
1446
|
+
details: compactToolDetails(details),
|
|
1447
|
+
},
|
|
1448
|
+
wake
|
|
1449
|
+
? { deliverAs: "followUp", triggerTurn: true }
|
|
1450
|
+
: { deliverAs: "nextTurn" },
|
|
1451
|
+
);
|
|
1452
|
+
} catch {
|
|
1453
|
+
// Session may be shutting down.
|
|
1454
|
+
}
|
|
1455
|
+
});
|
|
1456
|
+
return {
|
|
1457
|
+
content: [
|
|
1458
|
+
{
|
|
1459
|
+
type: "text",
|
|
1460
|
+
text: buildBackgroundWorkflowLaunchResult({
|
|
1461
|
+
runId,
|
|
1462
|
+
name: details.name,
|
|
1463
|
+
runDir,
|
|
1464
|
+
}),
|
|
1465
|
+
},
|
|
1466
|
+
],
|
|
1467
|
+
details: compactToolDetails(details),
|
|
1468
|
+
};
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
try {
|
|
1472
|
+
await completion;
|
|
1473
|
+
} finally {
|
|
1474
|
+
activeRuns.delete(runId);
|
|
1475
|
+
recordSettledRun(details);
|
|
1476
|
+
updateIndicator();
|
|
1477
|
+
}
|
|
1478
|
+
if (details.status !== "completed") {
|
|
1479
|
+
// Pi marks tool failures only when execute throws; returning isError is
|
|
1480
|
+
// ignored by the extension API.
|
|
1481
|
+
throw new Error(buildWorkflowResultMessage(details, runDir));
|
|
1482
|
+
}
|
|
1483
|
+
return {
|
|
1484
|
+
content: [
|
|
1485
|
+
{
|
|
1486
|
+
type: "text",
|
|
1487
|
+
text: buildWorkflowResultMessage(details, runDir),
|
|
1488
|
+
},
|
|
1489
|
+
],
|
|
1490
|
+
details: compactToolDetails(details),
|
|
1491
|
+
};
|
|
1492
|
+
},
|
|
1493
|
+
|
|
1494
|
+
renderCall(args: Partial<WorkflowInput>, theme) {
|
|
1495
|
+
const meta =
|
|
1496
|
+
typeof args.script === "string"
|
|
1497
|
+
? extractMeta(args.script)
|
|
1498
|
+
: { phases: [] };
|
|
1499
|
+
let text =
|
|
1500
|
+
theme.fg("toolTitle", theme.bold("workflow ")) +
|
|
1501
|
+
theme.fg("accent", (meta as WorkflowMeta).name ?? "(script)");
|
|
1502
|
+
if (args.background) text += theme.fg("dim", " (background)");
|
|
1503
|
+
const description = (meta as WorkflowMeta).description;
|
|
1504
|
+
if (description) text += `\n ${theme.fg("dim", description)}`;
|
|
1505
|
+
for (const phase of meta.phases.slice(0, 8)) {
|
|
1506
|
+
text += `\n ${theme.fg("dim", SQUARE)} ${theme.fg("accent", phase.title)}${
|
|
1507
|
+
phase.detail ? theme.fg("dim", ` — ${phase.detail}`) : ""
|
|
1508
|
+
}`;
|
|
1509
|
+
}
|
|
1510
|
+
return new Text(text, 0, 0);
|
|
1511
|
+
},
|
|
1512
|
+
|
|
1513
|
+
renderResult(result, { expanded }, theme) {
|
|
1514
|
+
const details = result.details as WorkflowDetails | undefined;
|
|
1515
|
+
if (!details) {
|
|
1516
|
+
const first = result.content[0];
|
|
1517
|
+
return new Text(
|
|
1518
|
+
first?.type === "text" ? first.text : "(no output)",
|
|
1519
|
+
0,
|
|
1520
|
+
0,
|
|
1521
|
+
);
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
const { done, failed } = countStates(details);
|
|
1525
|
+
const settled = done + failed;
|
|
1526
|
+
const elapsed = formatElapsed(details.startedAt, details.finishedAt);
|
|
1527
|
+
let header =
|
|
1528
|
+
`${theme.fg(statusColor(details.status), SQUARE)} ${theme.fg("toolTitle", theme.bold("workflow "))}` +
|
|
1529
|
+
`${theme.fg(
|
|
1530
|
+
"accent",
|
|
1531
|
+
sanitizeWorkflowDisplayLine(details.name ?? details.runId),
|
|
1532
|
+
)} ` +
|
|
1533
|
+
theme.fg(
|
|
1534
|
+
"dim",
|
|
1535
|
+
`${settled}/${details.agents.length} agents · ${elapsed} · `,
|
|
1536
|
+
) +
|
|
1537
|
+
theme.fg(statusColor(details.status), statusWord(details.status));
|
|
1538
|
+
if (failed) header += theme.fg("error", ` · ${failed} failed`);
|
|
1539
|
+
if (details.background) header += theme.fg("dim", " (background)");
|
|
1540
|
+
if (details.status === "running" && details.currentPhase) {
|
|
1541
|
+
header += theme.fg(
|
|
1542
|
+
"muted",
|
|
1543
|
+
` · ${sanitizeWorkflowDisplayLine(details.currentPhase)}`,
|
|
1544
|
+
);
|
|
1545
|
+
}
|
|
1546
|
+
const totals = formatUsage(aggregateUsage(details.agents));
|
|
1547
|
+
|
|
1548
|
+
if (!expanded) {
|
|
1549
|
+
let text = header;
|
|
1550
|
+
for (const agent of details.agents) {
|
|
1551
|
+
const context = agentContext(agent);
|
|
1552
|
+
text += `\n ${stateSquare(agent.state, theme)} ${theme.fg(
|
|
1553
|
+
"accent",
|
|
1554
|
+
sanitizeWorkflowDisplayLine(agent.label),
|
|
1555
|
+
)}${
|
|
1556
|
+
agent.phase
|
|
1557
|
+
? theme.fg(
|
|
1558
|
+
"dim",
|
|
1559
|
+
` (${sanitizeWorkflowDisplayLine(agent.phase)})`,
|
|
1560
|
+
)
|
|
1561
|
+
: ""
|
|
1562
|
+
}${theme.fg(
|
|
1563
|
+
"dim",
|
|
1564
|
+
`${context ? ` · ${context}` : ""} · ${formatElapsed(agent.startedAt, agent.finishedAt)}`,
|
|
1565
|
+
)}`;
|
|
1566
|
+
}
|
|
1567
|
+
// Only the tail collapsed: the newest lines are the ones that say
|
|
1568
|
+
// where the run is now.
|
|
1569
|
+
for (const entry of (details.logs ?? []).slice(-3)) {
|
|
1570
|
+
text += `\n ${theme.fg("muted", "›")} ${theme.fg(
|
|
1571
|
+
"dim",
|
|
1572
|
+
sanitizeWorkflowDisplayLine(entry.text),
|
|
1573
|
+
)}`;
|
|
1574
|
+
}
|
|
1575
|
+
if (totals) text += `\n ${theme.fg("dim", `Total: ${totals}`)}`;
|
|
1576
|
+
if (details.error)
|
|
1577
|
+
text += `\n ${theme.fg(
|
|
1578
|
+
"error",
|
|
1579
|
+
`Error: ${sanitizeWorkflowDisplayLine(details.error)}`,
|
|
1580
|
+
)}`;
|
|
1581
|
+
text += `\n${theme.fg("muted", `(${keyHint("app.tools.expand", "to expand")})`)}`;
|
|
1582
|
+
return new Text(text, 0, 0);
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
const container = new Container();
|
|
1586
|
+
container.addChild(new Text(header, 0, 0));
|
|
1587
|
+
if (details.description) {
|
|
1588
|
+
container.addChild(
|
|
1589
|
+
new Text(
|
|
1590
|
+
theme.fg("dim", sanitizeWorkflowDisplayLine(details.description)),
|
|
1591
|
+
0,
|
|
1592
|
+
0,
|
|
1593
|
+
),
|
|
1594
|
+
);
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
for (const group of phaseGroups(details)) {
|
|
1598
|
+
container.addChild(new Spacer(1));
|
|
1599
|
+
container.addChild(
|
|
1600
|
+
new Text(
|
|
1601
|
+
theme.fg(
|
|
1602
|
+
"muted",
|
|
1603
|
+
`─── ${sanitizeWorkflowDisplayLine(group.title)} ───`,
|
|
1604
|
+
),
|
|
1605
|
+
0,
|
|
1606
|
+
0,
|
|
1607
|
+
),
|
|
1608
|
+
);
|
|
1609
|
+
for (const agent of group.agents) {
|
|
1610
|
+
const usage = formatUsage(agent.usage, agent.model);
|
|
1611
|
+
const context = agentContext(agent);
|
|
1612
|
+
let line = `${stateSquare(agent.state, theme)} ${theme.fg(
|
|
1613
|
+
"accent",
|
|
1614
|
+
sanitizeWorkflowDisplayLine(agent.label),
|
|
1615
|
+
)} ${theme.fg(
|
|
1616
|
+
"dim",
|
|
1617
|
+
[context, formatElapsed(agent.startedAt, agent.finishedAt)]
|
|
1618
|
+
.filter(Boolean)
|
|
1619
|
+
.join(" · "),
|
|
1620
|
+
)}`;
|
|
1621
|
+
if (usage)
|
|
1622
|
+
line += ` ${theme.fg("dim", sanitizeWorkflowDisplayLine(usage))}`;
|
|
1623
|
+
container.addChild(new Text(line, 0, 0));
|
|
1624
|
+
if (agent.error) {
|
|
1625
|
+
container.addChild(
|
|
1626
|
+
new Text(
|
|
1627
|
+
` ${theme.fg("error", sanitizeWorkflowDisplayLine(agent.error))}`,
|
|
1628
|
+
0,
|
|
1629
|
+
0,
|
|
1630
|
+
),
|
|
1631
|
+
);
|
|
1632
|
+
} else if (agent.preview) {
|
|
1633
|
+
const preview = sanitizeWorkflowDisplayText(
|
|
1634
|
+
agent.preview,
|
|
1635
|
+
PREVIEW_LENGTH,
|
|
1636
|
+
)
|
|
1637
|
+
.split("\n")
|
|
1638
|
+
.slice(0, 2)
|
|
1639
|
+
.join(" ");
|
|
1640
|
+
container.addChild(new Text(` ${theme.fg("dim", preview)}`, 0, 0));
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
if (details.logs && details.logs.length > 0) {
|
|
1646
|
+
container.addChild(new Spacer(1));
|
|
1647
|
+
container.addChild(new Text(theme.fg("muted", "─── log ───"), 0, 0));
|
|
1648
|
+
if (details.logsDropped) {
|
|
1649
|
+
container.addChild(
|
|
1650
|
+
new Text(
|
|
1651
|
+
theme.fg(
|
|
1652
|
+
"dim",
|
|
1653
|
+
`(${details.logsDropped} earlier line(s) dropped)`,
|
|
1654
|
+
),
|
|
1655
|
+
0,
|
|
1656
|
+
0,
|
|
1657
|
+
),
|
|
1658
|
+
);
|
|
1659
|
+
}
|
|
1660
|
+
for (const entry of details.logs) {
|
|
1661
|
+
container.addChild(
|
|
1662
|
+
new Text(
|
|
1663
|
+
`${theme.fg("muted", "›")} ${theme.fg(
|
|
1664
|
+
"dim",
|
|
1665
|
+
sanitizeWorkflowDisplayLine(entry.text),
|
|
1666
|
+
)}`,
|
|
1667
|
+
0,
|
|
1668
|
+
0,
|
|
1669
|
+
),
|
|
1670
|
+
);
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
if (details.error) {
|
|
1675
|
+
container.addChild(new Spacer(1));
|
|
1676
|
+
container.addChild(
|
|
1677
|
+
new Text(
|
|
1678
|
+
theme.fg(
|
|
1679
|
+
"error",
|
|
1680
|
+
`Error: ${sanitizeWorkflowDisplayLine(details.error)}`,
|
|
1681
|
+
),
|
|
1682
|
+
0,
|
|
1683
|
+
0,
|
|
1684
|
+
),
|
|
1685
|
+
);
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
if (details.result !== undefined) {
|
|
1689
|
+
container.addChild(new Spacer(1));
|
|
1690
|
+
container.addChild(new Text(theme.fg("muted", "─── result ───"), 0, 0));
|
|
1691
|
+
container.addChild(
|
|
1692
|
+
new Markdown(
|
|
1693
|
+
`\`\`\`json\n${resultJson(details.result)}\n\`\`\``,
|
|
1694
|
+
0,
|
|
1695
|
+
0,
|
|
1696
|
+
getMarkdownTheme(),
|
|
1697
|
+
),
|
|
1698
|
+
);
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
if (totals) {
|
|
1702
|
+
container.addChild(new Spacer(1));
|
|
1703
|
+
container.addChild(new Text(theme.fg("dim", `Total: ${totals}`), 0, 0));
|
|
1704
|
+
}
|
|
1705
|
+
return container;
|
|
1706
|
+
},
|
|
1707
|
+
});
|
|
1708
|
+
|
|
1709
|
+
/** Resolve one run from live, settled, or persisted state. */
|
|
1710
|
+
const resolveRunDetails = (target: string) => {
|
|
1711
|
+
const base = path.join(getAgentDir(), "workflows");
|
|
1712
|
+
let persistedIds: string[] = [];
|
|
1713
|
+
try {
|
|
1714
|
+
persistedIds = fs.readdirSync(base).filter(isWorkflowRunId);
|
|
1715
|
+
} catch {
|
|
1716
|
+
// In-memory runs remain inspectable without the artifact directory.
|
|
1717
|
+
}
|
|
1718
|
+
const resolution = resolveWorkflowRunTarget(target, [
|
|
1719
|
+
...activeRuns.keys(),
|
|
1720
|
+
...settledRuns.keys(),
|
|
1721
|
+
...persistedIds,
|
|
1722
|
+
]);
|
|
1723
|
+
if (!resolution.ok) return resolution;
|
|
1724
|
+
|
|
1725
|
+
const active = activeRuns.get(resolution.runId);
|
|
1726
|
+
if (active) return { ok: true, details: active.details } as const;
|
|
1727
|
+
const settled = settledRuns.get(resolution.runId);
|
|
1728
|
+
if (settled) return { ok: true, details: settled } as const;
|
|
1729
|
+
|
|
1730
|
+
try {
|
|
1731
|
+
const parsed: unknown = JSON.parse(
|
|
1732
|
+
fs.readFileSync(
|
|
1733
|
+
path.join(base, resolution.runId, "workflow.json"),
|
|
1734
|
+
"utf8",
|
|
1735
|
+
),
|
|
1736
|
+
);
|
|
1737
|
+
const details = normalizePersistedWorkflowDetails(
|
|
1738
|
+
resolution.runId,
|
|
1739
|
+
parsed,
|
|
1740
|
+
);
|
|
1741
|
+
if (!details) throw new Error("invalid workflow details");
|
|
1742
|
+
// A run absent from activeRuns cannot still be running this session; a
|
|
1743
|
+
// persisted "running" is a run that was hard-killed or missed the
|
|
1744
|
+
// shutdown settle deadline.
|
|
1745
|
+
return {
|
|
1746
|
+
ok: true,
|
|
1747
|
+
details:
|
|
1748
|
+
details.status === "running"
|
|
1749
|
+
? { ...details, status: "aborted" as const }
|
|
1750
|
+
: details,
|
|
1751
|
+
} as const;
|
|
1752
|
+
} catch {
|
|
1753
|
+
return {
|
|
1754
|
+
ok: false,
|
|
1755
|
+
error: `Workflow run ${resolution.runId} could not be read.`,
|
|
1756
|
+
} as const;
|
|
1757
|
+
}
|
|
1758
|
+
};
|
|
1759
|
+
|
|
1760
|
+
pi.registerTool({
|
|
1761
|
+
name: "workflow_stop",
|
|
1762
|
+
label: "Stop Workflow",
|
|
1763
|
+
description: WORKFLOW_STOP_TOOL_DESCRIPTION,
|
|
1764
|
+
promptSnippet: WORKFLOW_LIFECYCLE_PROMPT_SNIPPET,
|
|
1765
|
+
parameters: Type.Object({
|
|
1766
|
+
runId: Type.String({
|
|
1767
|
+
description: WORKFLOW_STOP_PARAMETER_DESCRIPTIONS.runId,
|
|
1768
|
+
}),
|
|
1769
|
+
}),
|
|
1770
|
+
execute(_toolCallId, params) {
|
|
1771
|
+
const running = [...activeRuns].filter(
|
|
1772
|
+
([, run]) => run.details.status === "running",
|
|
1773
|
+
);
|
|
1774
|
+
const resolution = resolveWorkflowRunTarget(
|
|
1775
|
+
params.runId,
|
|
1776
|
+
running.map(([runId]) => runId),
|
|
1777
|
+
);
|
|
1778
|
+
if (!resolution.ok) throw new Error(resolution.error);
|
|
1779
|
+
stopRun(resolution.runId);
|
|
1780
|
+
return Promise.resolve({
|
|
1781
|
+
content: [
|
|
1782
|
+
{
|
|
1783
|
+
type: "text",
|
|
1784
|
+
text: `Stopping workflow ${resolution.runId}.`,
|
|
1785
|
+
},
|
|
1786
|
+
],
|
|
1787
|
+
details: { runId: resolution.runId, status: "aborting" },
|
|
1788
|
+
});
|
|
1789
|
+
},
|
|
1790
|
+
});
|
|
1791
|
+
|
|
1792
|
+
pi.registerTool({
|
|
1793
|
+
name: "workflow_status",
|
|
1794
|
+
label: "Workflow Status",
|
|
1795
|
+
description: WORKFLOW_STATUS_TOOL_DESCRIPTION,
|
|
1796
|
+
parameters: Type.Object({
|
|
1797
|
+
runId: Type.Optional(
|
|
1798
|
+
Type.String({
|
|
1799
|
+
description: WORKFLOW_STATUS_PARAMETER_DESCRIPTIONS.runId,
|
|
1800
|
+
}),
|
|
1801
|
+
),
|
|
1802
|
+
}),
|
|
1803
|
+
execute(_toolCallId, params) {
|
|
1804
|
+
// Details are a uniform run-summary array (one entry for a single-id peek)
|
|
1805
|
+
// so the tool has a single result shape; the text carries the detail.
|
|
1806
|
+
const summarize = (d: WorkflowDetails) => {
|
|
1807
|
+
const { done, failed } = countStates(d);
|
|
1808
|
+
return {
|
|
1809
|
+
runId: d.runId,
|
|
1810
|
+
name: d.name,
|
|
1811
|
+
status: d.status,
|
|
1812
|
+
done,
|
|
1813
|
+
failed,
|
|
1814
|
+
total: d.agents.length,
|
|
1815
|
+
};
|
|
1816
|
+
};
|
|
1817
|
+
if (params.runId) {
|
|
1818
|
+
const resolution = resolveRunDetails(params.runId);
|
|
1819
|
+
if (!resolution.ok) throw new Error(resolution.error);
|
|
1820
|
+
const details = resolution.details;
|
|
1821
|
+
const runDir = path.join(getAgentDir(), "workflows", details.runId);
|
|
1822
|
+
return Promise.resolve({
|
|
1823
|
+
content: [
|
|
1824
|
+
{ type: "text", text: buildWorkflowResultMessage(details, runDir) },
|
|
1825
|
+
],
|
|
1826
|
+
details: { runs: [summarize(details)] },
|
|
1827
|
+
});
|
|
1828
|
+
}
|
|
1829
|
+
const runs = [
|
|
1830
|
+
...[...activeRuns.values()].map((run) => run.details),
|
|
1831
|
+
...settledRuns.values(),
|
|
1832
|
+
];
|
|
1833
|
+
if (runs.length === 0) {
|
|
1834
|
+
return Promise.resolve({
|
|
1835
|
+
content: [
|
|
1836
|
+
{ type: "text", text: "No active or recently finished workflows." },
|
|
1837
|
+
],
|
|
1838
|
+
details: { runs: [] },
|
|
1839
|
+
});
|
|
1840
|
+
}
|
|
1841
|
+
const lines = runs.map((d) => {
|
|
1842
|
+
const { done, failed } = countStates(d);
|
|
1843
|
+
return `${d.runId}${d.name ? ` "${d.name}"` : ""} — ${statusWord(d.status)} · ${done + failed}/${d.agents.length} agents${failed ? `, ${failed} failed` : ""}`;
|
|
1844
|
+
});
|
|
1845
|
+
return Promise.resolve({
|
|
1846
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
1847
|
+
details: { runs: runs.map(summarize) },
|
|
1848
|
+
});
|
|
1849
|
+
},
|
|
1850
|
+
});
|
|
1851
|
+
|
|
1852
|
+
pi.registerMessageRenderer(
|
|
1853
|
+
"workflow-result",
|
|
1854
|
+
(message, { expanded }, theme) => {
|
|
1855
|
+
const details = message.details as WorkflowDetails | undefined;
|
|
1856
|
+
const body =
|
|
1857
|
+
typeof message.content === "string"
|
|
1858
|
+
? message.content
|
|
1859
|
+
: (message.content
|
|
1860
|
+
?.map((part) => (part.type === "text" ? part.text : ""))
|
|
1861
|
+
.join("") ?? "");
|
|
1862
|
+
const safeBody = sanitizeWorkflowDisplayText(body);
|
|
1863
|
+
if (!details) return new Text(safeBody, 0, 0);
|
|
1864
|
+
const { done, failed } = countStates(details);
|
|
1865
|
+
const settled = done + failed;
|
|
1866
|
+
let header =
|
|
1867
|
+
`${theme.fg(statusColor(details.status), SQUARE)} ${theme.fg("toolTitle", theme.bold("workflow "))}` +
|
|
1868
|
+
`${theme.fg(
|
|
1869
|
+
"accent",
|
|
1870
|
+
sanitizeWorkflowDisplayLine(details.name ?? details.runId),
|
|
1871
|
+
)} ` +
|
|
1872
|
+
theme.fg("dim", `${settled}/${details.agents.length} agents · `) +
|
|
1873
|
+
theme.fg(statusColor(details.status), statusWord(details.status));
|
|
1874
|
+
if (failed) header += theme.fg("error", ` · ${failed} failed`);
|
|
1875
|
+
if (expanded) return new Text(`${header}\n\n${safeBody}`, 0, 0);
|
|
1876
|
+
const preview = safeBody.split("\n").slice(0, 8).join("\n");
|
|
1877
|
+
return new Text(
|
|
1878
|
+
`${header}\n${preview}\n${theme.fg("muted", `(${keyHint("app.tools.expand", "to expand")})`)}`,
|
|
1879
|
+
0,
|
|
1880
|
+
0,
|
|
1881
|
+
);
|
|
1882
|
+
},
|
|
1883
|
+
);
|
|
1884
|
+
}
|