pi-ultracode 0.3.2 → 0.5.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 +32 -14
- package/README.zh-CN.md +32 -14
- package/extensions/ultracode.ts +11 -10
- package/package.json +17 -7
- package/src/commands.ts +21 -22
- package/src/depth.ts +38 -0
- package/src/index.ts +9 -1
- package/src/mode.ts +82 -58
- package/src/prompts.ts +65 -32
- package/src/thinking.ts +1 -1
- package/src/workflow/agent-runner.ts +23 -8
- package/src/workflow/agent-types.ts +19 -15
- package/src/workflow/hermetic-git.ts +31 -0
- package/src/workflow/journal.ts +134 -3
- package/src/workflow/parser.ts +254 -38
- package/src/workflow/registry.ts +44 -67
- package/src/workflow/repository-context.ts +76 -0
- package/src/workflow/run-artifacts.ts +60 -14
- package/src/workflow/runtime.ts +380 -180
- package/src/workflow/saved-workflow.ts +37 -0
- package/src/workflow/script-executor.ts +56 -10
- package/src/workflow/script-worker.mjs +65 -19
- package/src/workflow/tool.ts +108 -58
- package/src/workflow/workflow-overlay.ts +4 -4
- package/src/workflow/worktree.ts +1965 -244
- package/types/workflow.d.ts +2 -4
package/src/workflow/tool.ts
CHANGED
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import * as crypto from "node:crypto";
|
|
8
|
-
import * as os from "node:os";
|
|
9
8
|
import * as path from "node:path";
|
|
10
9
|
import { defineTool, type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
11
10
|
import { Text } from "@earendil-works/pi-tui";
|
|
@@ -26,7 +25,7 @@ import {
|
|
|
26
25
|
} from "./runtime.ts";
|
|
27
26
|
import type { ModelRuntimeLike, ThinkingLevel } from "./agent-runner.ts";
|
|
28
27
|
import { RunJournal, hashString, type JournalAgentRecord } from "./journal.ts";
|
|
29
|
-
import {
|
|
28
|
+
import { WorkflowRegistry } from "./registry.ts";
|
|
30
29
|
import { normalizeMaxAgents } from "./admission.ts";
|
|
31
30
|
import { acquireWorkflowLease, type WorkflowLease } from "./leases.ts";
|
|
32
31
|
import {
|
|
@@ -34,11 +33,13 @@ import {
|
|
|
34
33
|
assertRegularArtifactFile,
|
|
35
34
|
ensurePrivateArtifactDirectory,
|
|
36
35
|
readArtifactFile,
|
|
37
|
-
readContainedArtifactFile,
|
|
38
36
|
standaloneWorkflowRunsDir,
|
|
39
37
|
writeArtifactFile,
|
|
40
38
|
} from "./run-artifacts.ts";
|
|
41
39
|
import { assertWorkflowArgsLimit } from "./value-limits.ts";
|
|
40
|
+
import { readSavedWorkflowByName } from "./saved-workflow.ts";
|
|
41
|
+
import { resolveRepositoryContext } from "./repository-context.ts";
|
|
42
|
+
import { verifyAppliedPatch } from "./worktree.ts";
|
|
42
43
|
import {
|
|
43
44
|
WorkflowRunDetails,
|
|
44
45
|
normalizeTaskUsage,
|
|
@@ -63,7 +64,10 @@ const workflowToolSchema = Type.Object({
|
|
|
63
64
|
Type.String({ description: "Path to a workflow script file to run instead of an inline `script`." }),
|
|
64
65
|
),
|
|
65
66
|
name: Type.Optional(
|
|
66
|
-
Type.String({
|
|
67
|
+
Type.String({
|
|
68
|
+
description:
|
|
69
|
+
"Name of a saved workflow under project .pi/ultracode/workflows/ or user-scoped ~/.pi/ultracode/workflows/.",
|
|
70
|
+
}),
|
|
67
71
|
),
|
|
68
72
|
args: Type.Optional(
|
|
69
73
|
Type.Any({ description: "Optional JSON value exposed to the workflow script as the global `args`." }),
|
|
@@ -85,11 +89,12 @@ const workflowToolSchema = Type.Object({
|
|
|
85
89
|
}, { additionalProperties: false });
|
|
86
90
|
|
|
87
91
|
export interface WorkflowToolDeps {
|
|
92
|
+
/** Session-owned workflow registry shared with commands and overlays. */
|
|
93
|
+
registry?: WorkflowRegistry;
|
|
88
94
|
/** Canonical runtime supplied by an SDK host; shared by all child sessions. */
|
|
89
95
|
modelRuntime?: ModelRuntimeLike;
|
|
90
|
-
/** The
|
|
91
|
-
*
|
|
92
|
-
* session clamps it independently; undefined when off). A per-call
|
|
96
|
+
/** The configured Ultracode mode's raw default effort for workflow subagents.
|
|
97
|
+
* Each child session clamps it independently; undefined when off. A per-call
|
|
93
98
|
* `model: "X:level"` suffix or agentType `thinking:` override still wins. */
|
|
94
99
|
getThinkingLevel?: () => ThinkingLevel | undefined;
|
|
95
100
|
/** Optional execution gate for mode-scoped registrations. Omit for standalone use. */
|
|
@@ -116,6 +121,7 @@ function nextRunId(): string {
|
|
|
116
121
|
}
|
|
117
122
|
|
|
118
123
|
export function createWorkflowTool(deps: WorkflowToolDeps = {}): ToolDefinition<typeof workflowToolSchema, any> {
|
|
124
|
+
const registry = deps.registry ?? new WorkflowRegistry();
|
|
119
125
|
return defineTool({
|
|
120
126
|
name: "workflow",
|
|
121
127
|
label: "Workflow",
|
|
@@ -125,7 +131,7 @@ export function createWorkflowTool(deps: WorkflowToolDeps = {}): ToolDefinition<
|
|
|
125
131
|
parameters: workflowToolSchema,
|
|
126
132
|
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
127
133
|
if (deps.isExecutionAllowed?.() === false) {
|
|
128
|
-
throw new Error("The workflow tool is disabled. Run /ultracode
|
|
134
|
+
throw new Error("The workflow tool is disabled. Run /ultracode or select an Ultracode depth before using it.");
|
|
129
135
|
}
|
|
130
136
|
const controller = new AbortController();
|
|
131
137
|
let abortRequested = false;
|
|
@@ -139,9 +145,12 @@ export function createWorkflowTool(deps: WorkflowToolDeps = {}): ToolDefinition<
|
|
|
139
145
|
if (signal?.aborted || controller.signal.aborted) throw new Error("Workflow was aborted before it started");
|
|
140
146
|
|
|
141
147
|
const cwd = ctx.cwd;
|
|
148
|
+
const projectTrusted = ctx.isProjectTrusted?.() ?? false;
|
|
149
|
+
const repositoryContext = resolveRepositoryContext(cwd);
|
|
150
|
+
const targetIdentity = repositoryContext.identity;
|
|
142
151
|
assertWorkflowArgsLimit(params.args);
|
|
143
152
|
const requestedMaxAgents = params.maxAgents === undefined ? undefined : normalizeMaxAgents(params.maxAgents);
|
|
144
|
-
const resolvedScript = resolveScript(params, cwd);
|
|
153
|
+
const resolvedScript = resolveScript(params, cwd, projectTrusted);
|
|
145
154
|
const script = normalizeScript(resolvedScript.script);
|
|
146
155
|
const sourceLabel = resolvedScript.sourceLabel;
|
|
147
156
|
const parsed = parseWorkflowScript(script);
|
|
@@ -154,15 +163,14 @@ export function createWorkflowTool(deps: WorkflowToolDeps = {}): ToolDefinition<
|
|
|
154
163
|
if (resuming && !RunJournal.exists(runsDir, runId)) {
|
|
155
164
|
throw new Error(`workflow: resumeFromRunId ${runId} was not found in this session`);
|
|
156
165
|
}
|
|
157
|
-
// Forward the raw
|
|
158
|
-
// that subagent's model. Undefined when
|
|
166
|
+
// Forward the mode's raw effort request so each subagent session clamps it
|
|
167
|
+
// against that subagent's model. Undefined when Ultracode is off.
|
|
159
168
|
const thinkingLevel = deps.getThinkingLevel?.();
|
|
160
169
|
if (controller.signal.aborted) throw new Error("Workflow was aborted before it started");
|
|
161
170
|
const run = deps.runWorkflowFn ?? runWorkflow;
|
|
162
171
|
const workflowStartedAt = Date.now();
|
|
163
172
|
let lease: WorkflowLease | undefined;
|
|
164
173
|
let journal: RunJournal | undefined;
|
|
165
|
-
let toolSucceeded = false;
|
|
166
174
|
lease = acquireWorkflowLease(runsDir, runId);
|
|
167
175
|
try {
|
|
168
176
|
|
|
@@ -187,11 +195,27 @@ export function createWorkflowTool(deps: WorkflowToolDeps = {}): ToolDefinition<
|
|
|
187
195
|
name: parsed.meta.name,
|
|
188
196
|
scriptHash: hashString(script),
|
|
189
197
|
args: params.args,
|
|
198
|
+
projectTrusted,
|
|
199
|
+
targetIdentity,
|
|
190
200
|
startedAt: Date.now(),
|
|
191
201
|
maxAgents: requestedMaxAgents,
|
|
192
202
|
};
|
|
193
203
|
journal = resuming
|
|
194
|
-
? RunJournal.resume(runsDir, runId, journalMeta
|
|
204
|
+
? RunJournal.resume(runsDir, runId, journalMeta, {
|
|
205
|
+
validateDelivery(record) {
|
|
206
|
+
if (!record.deliveryPatchPath || !record.deliveryPatchHash || !repositoryContext.repoRoot) {
|
|
207
|
+
throw new Error(`workflow delivery at ${record.callPath} has no verifiable Git target`);
|
|
208
|
+
}
|
|
209
|
+
if (!verifyAppliedPatch(
|
|
210
|
+
repositoryContext.repoRoot,
|
|
211
|
+
record.deliveryPatchPath,
|
|
212
|
+
record.deliveryPatchHash,
|
|
213
|
+
path.join(runsDir, "patches"),
|
|
214
|
+
)) {
|
|
215
|
+
throw new Error(`workflow delivery at ${record.callPath} is no longer present; start a new run`);
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
})
|
|
195
219
|
: RunJournal.create(runsDir, journalMeta);
|
|
196
220
|
const activeJournal = journal;
|
|
197
221
|
const effectiveMaxAgents = activeJournal.effectiveMaxAgents;
|
|
@@ -215,14 +239,15 @@ export function createWorkflowTool(deps: WorkflowToolDeps = {}): ToolDefinition<
|
|
|
215
239
|
if (resuming) runDetails.beginGeneration();
|
|
216
240
|
snapshot.detailsManifestPath = runDetails.manifestPath;
|
|
217
241
|
if (controller.signal.aborted) throw new Error("Workflow was aborted before it started");
|
|
218
|
-
const registry = getRegistry();
|
|
219
|
-
registry.setScope(runsDir);
|
|
220
242
|
const handle = registry.register(runId, snapshot, requestAbort, runDetails);
|
|
221
243
|
let updateObserverEnabled = true;
|
|
222
244
|
const emitUpdate = () => {
|
|
223
245
|
if (!onUpdate || !updateObserverEnabled) return;
|
|
224
246
|
try {
|
|
225
|
-
onUpdate({
|
|
247
|
+
onUpdate({
|
|
248
|
+
content: [{ type: "text", text: renderWorkflowText(snapshot) }],
|
|
249
|
+
details: structuredClone(snapshot),
|
|
250
|
+
});
|
|
226
251
|
} catch (error) {
|
|
227
252
|
updateObserverEnabled = false;
|
|
228
253
|
throw error;
|
|
@@ -276,6 +301,8 @@ export function createWorkflowTool(deps: WorkflowToolDeps = {}): ToolDefinition<
|
|
|
276
301
|
const result = await run(script, {
|
|
277
302
|
cwd,
|
|
278
303
|
args: params.args,
|
|
304
|
+
projectTrusted,
|
|
305
|
+
targetIdentity,
|
|
279
306
|
signal: controller.signal,
|
|
280
307
|
thinkingLevel,
|
|
281
308
|
modelRegistry: ctx.modelRegistry as any,
|
|
@@ -419,6 +446,13 @@ export function createWorkflowTool(deps: WorkflowToolDeps = {}): ToolDefinition<
|
|
|
419
446
|
if (heartbeatFailed) throw heartbeatError;
|
|
420
447
|
if (abortRequested || controller.signal.aborted) throw new Error("Workflow execution ended after cancellation");
|
|
421
448
|
|
|
449
|
+
// The terminal journal record is the publication point for tool success.
|
|
450
|
+
journal?.recordResult({
|
|
451
|
+
ok: true,
|
|
452
|
+
result: result.result,
|
|
453
|
+
agentCount: result.agentCount,
|
|
454
|
+
durationMs: result.durationMs,
|
|
455
|
+
});
|
|
422
456
|
acceptingEvents = false;
|
|
423
457
|
for (const agent of snapshot.agents) clearAgentTransient(agent);
|
|
424
458
|
snapshot.result = result.result;
|
|
@@ -437,22 +471,23 @@ export function createWorkflowTool(deps: WorkflowToolDeps = {}): ToolDefinition<
|
|
|
437
471
|
// best-effort manifest close
|
|
438
472
|
}
|
|
439
473
|
registry.notify();
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
});
|
|
446
|
-
emitUpdate();
|
|
474
|
+
try {
|
|
475
|
+
emitUpdate();
|
|
476
|
+
} catch {
|
|
477
|
+
// The durable workflow result is authoritative; display observers are best-effort.
|
|
478
|
+
}
|
|
447
479
|
|
|
448
480
|
const agentsUsed = result.agentsUsed ?? activeJournal.agentsUsed;
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
481
|
+
try {
|
|
482
|
+
ctx.ui?.notify(
|
|
483
|
+
`Workflow ${displayName} completed: ${agentsUsed}/${result.maxAgents ?? effectiveMaxAgents} lifetime agent slot(s), ~${result.spentTokens} output tokens.`,
|
|
484
|
+
"info",
|
|
485
|
+
);
|
|
486
|
+
} catch {
|
|
487
|
+
// UI notification failure cannot reverse a committed result.
|
|
488
|
+
}
|
|
453
489
|
|
|
454
490
|
const cachedNote = result.cachedCount ? ` (${result.cachedCount} cached from resume)` : "";
|
|
455
|
-
toolSucceeded = true;
|
|
456
491
|
return {
|
|
457
492
|
content: [
|
|
458
493
|
{
|
|
@@ -464,7 +499,13 @@ export function createWorkflowTool(deps: WorkflowToolDeps = {}): ToolDefinition<
|
|
|
464
499
|
`Result:\n${safeJson(result.result)}`,
|
|
465
500
|
},
|
|
466
501
|
],
|
|
467
|
-
details: {
|
|
502
|
+
details: {
|
|
503
|
+
...structuredClone(snapshot),
|
|
504
|
+
runId,
|
|
505
|
+
scriptPath,
|
|
506
|
+
source: sourceLabel,
|
|
507
|
+
maxAgents: result.maxAgents ?? effectiveMaxAgents,
|
|
508
|
+
},
|
|
468
509
|
};
|
|
469
510
|
} catch (error) {
|
|
470
511
|
const failure = heartbeatFailed ? heartbeatError : error;
|
|
@@ -509,11 +550,19 @@ export function createWorkflowTool(deps: WorkflowToolDeps = {}): ToolDefinition<
|
|
|
509
550
|
// A poisoned journal must not append after a partial write; preserve
|
|
510
551
|
// the execution failure that brought us into this path.
|
|
511
552
|
}
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
553
|
+
try {
|
|
554
|
+
emitUpdate();
|
|
555
|
+
} catch {
|
|
556
|
+
// Failed-state observers cannot replace the primary workflow failure.
|
|
557
|
+
}
|
|
558
|
+
try {
|
|
559
|
+
ctx.ui?.notify(
|
|
560
|
+
`Workflow ${displayName} ${aborted ? "was aborted" : "failed"}${aborted ? "" : `: ${errorText}`}`,
|
|
561
|
+
aborted ? "warning" : "error",
|
|
562
|
+
);
|
|
563
|
+
} catch {
|
|
564
|
+
// UI notification is best-effort on failure paths.
|
|
565
|
+
}
|
|
517
566
|
if (aborted) {
|
|
518
567
|
throw new Error(`Workflow ${displayName} was aborted (runId: ${runId})`, { cause: failure });
|
|
519
568
|
}
|
|
@@ -532,20 +581,16 @@ export function createWorkflowTool(deps: WorkflowToolDeps = {}): ToolDefinition<
|
|
|
532
581
|
}
|
|
533
582
|
}
|
|
534
583
|
} finally {
|
|
535
|
-
let journalCloseError: unknown;
|
|
536
584
|
try {
|
|
537
585
|
journal?.close();
|
|
538
|
-
} catch
|
|
539
|
-
|
|
586
|
+
} catch {
|
|
587
|
+
// A close failure must not reverse durable success or mask a primary failure.
|
|
540
588
|
}
|
|
541
589
|
try {
|
|
542
590
|
lease?.release();
|
|
543
591
|
} catch {
|
|
544
592
|
// best-effort cleanup; active-run leases must never mask the original error
|
|
545
593
|
}
|
|
546
|
-
if (toolSucceeded && journalCloseError) {
|
|
547
|
-
throw new Error(`workflow journal close failed: ${statusText(journalCloseError)}`, { cause: journalCloseError });
|
|
548
|
-
}
|
|
549
594
|
}
|
|
550
595
|
} finally {
|
|
551
596
|
signal?.removeEventListener("abort", onOuterAbort);
|
|
@@ -572,6 +617,7 @@ export function createWorkflowTool(deps: WorkflowToolDeps = {}): ToolDefinition<
|
|
|
572
617
|
function resolveScript(
|
|
573
618
|
params: { script?: string; scriptPath?: string; name?: string },
|
|
574
619
|
cwd: string,
|
|
620
|
+
projectTrusted: boolean,
|
|
575
621
|
): { script: string; sourceLabel: string } {
|
|
576
622
|
if (params.script && params.script.trim()) {
|
|
577
623
|
return { script: normalizeScript(params.script), sourceLabel: "inline" };
|
|
@@ -582,32 +628,36 @@ function resolveScript(
|
|
|
582
628
|
}
|
|
583
629
|
if (params.name) {
|
|
584
630
|
requireSafeWorkflowName(params.name);
|
|
585
|
-
const
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
return {
|
|
592
|
-
script: readContainedArtifactFile(root, full, "saved workflow", 16 * 1024 * 1024),
|
|
593
|
-
sourceLabel: `name:${params.name}`,
|
|
594
|
-
};
|
|
595
|
-
}
|
|
596
|
-
}
|
|
631
|
+
const saved = readSavedWorkflowByName(params.name, cwd, projectTrusted);
|
|
632
|
+
if (saved) {
|
|
633
|
+
return {
|
|
634
|
+
script: saved.script,
|
|
635
|
+
sourceLabel: `name:${params.name}:${saved.scope}`,
|
|
636
|
+
};
|
|
597
637
|
}
|
|
598
|
-
throw new Error(`workflow: no saved workflow named "${params.name}" found
|
|
638
|
+
throw new Error(`workflow: no accessible saved workflow named "${params.name}" was found`);
|
|
599
639
|
}
|
|
600
640
|
throw new Error("workflow requires one of: `script`, `scriptPath`, or `name`.");
|
|
601
641
|
}
|
|
602
642
|
|
|
603
|
-
export function workflowRunsDir(ctx: {
|
|
643
|
+
export function workflowRunsDir(ctx: {
|
|
644
|
+
sessionManager?: { getSessionDir?: () => string; getSessionId?: () => string };
|
|
645
|
+
cwd: string;
|
|
646
|
+
}): string {
|
|
647
|
+
let sessionDir: string | undefined;
|
|
648
|
+
let sessionId: string | undefined;
|
|
604
649
|
try {
|
|
605
|
-
|
|
606
|
-
|
|
650
|
+
sessionDir = ctx.sessionManager?.getSessionDir?.();
|
|
651
|
+
sessionId = ctx.sessionManager?.getSessionId?.();
|
|
607
652
|
} catch {
|
|
608
|
-
//
|
|
653
|
+
// Fall through to the standalone artifact root.
|
|
609
654
|
}
|
|
610
|
-
|
|
655
|
+
const base = sessionDir
|
|
656
|
+
? path.join(sessionDir, "ultracode-runs")
|
|
657
|
+
: standaloneWorkflowRunsDir(ctx.cwd);
|
|
658
|
+
if (!sessionId) return base;
|
|
659
|
+
const scope = crypto.createHash("sha256").update(sessionId).digest("hex").slice(0, 32);
|
|
660
|
+
return path.join(base, `session-${scope}`);
|
|
611
661
|
}
|
|
612
662
|
|
|
613
663
|
function safeJson(value: unknown): string {
|
|
@@ -22,7 +22,7 @@ import type {
|
|
|
22
22
|
|
|
23
23
|
const MIN_SPLIT_WIDTH = 100;
|
|
24
24
|
const MAX_RENDER_FPS_INTERVAL_MS = 100;
|
|
25
|
-
|
|
25
|
+
const OPEN_OVERLAYS = new WeakSet<WorkflowRegistry>();
|
|
26
26
|
|
|
27
27
|
type OverlayNavigationKey = "pageUp" | "pageDown" | "end";
|
|
28
28
|
|
|
@@ -56,7 +56,7 @@ export async function openWorkflowOverlay(
|
|
|
56
56
|
);
|
|
57
57
|
return;
|
|
58
58
|
}
|
|
59
|
-
if (
|
|
59
|
+
if (OPEN_OVERLAYS.has(registry)) {
|
|
60
60
|
ctx.ui.notify("Workflow detail is already open.", "info");
|
|
61
61
|
return;
|
|
62
62
|
}
|
|
@@ -80,7 +80,7 @@ export async function openWorkflowOverlay(
|
|
|
80
80
|
preferredRunId = matches[0]!.snapshot.runId;
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
|
|
83
|
+
OPEN_OVERLAYS.add(registry);
|
|
84
84
|
try {
|
|
85
85
|
await ctx.ui.custom<void>(
|
|
86
86
|
(tui, theme, _keybindings, done) => new WorkflowOverlayComponent({
|
|
@@ -102,7 +102,7 @@ export async function openWorkflowOverlay(
|
|
|
102
102
|
},
|
|
103
103
|
);
|
|
104
104
|
} finally {
|
|
105
|
-
|
|
105
|
+
OPEN_OVERLAYS.delete(registry);
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
108
|
|