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.
@@ -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 { getRegistry } from "./registry.ts";
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({ description: "Name of a saved workflow (under .pi/ultracode/workflows/) to run." }),
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 ultracode effort level to forward to every workflow subagent as its
91
- * default thinking level (`max` when ultracode is on, so each subagent's own
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 on before using it.");
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 `max` request so each subagent session clamps it against
158
- // that subagent's model. Undefined when ultracode is off.
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({ content: [{ type: "text", text: renderWorkflowText(snapshot) }], details: snapshot });
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
- journal?.recordResult({
441
- ok: true,
442
- result: result.result,
443
- agentCount: result.agentCount,
444
- durationMs: result.durationMs,
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
- ctx.ui?.notify(
450
- `Workflow ${displayName} completed: ${agentsUsed}/${result.maxAgents ?? effectiveMaxAgents} lifetime agent slot(s), ~${result.spentTokens} output tokens.`,
451
- "info",
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: { ...snapshot, runId, scriptPath, source: sourceLabel, maxAgents: result.maxAgents ?? effectiveMaxAgents },
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
- emitUpdate();
513
- ctx.ui?.notify(
514
- `Workflow ${displayName} ${aborted ? "was aborted" : "failed"}${aborted ? "" : `: ${errorText}`}`,
515
- aborted ? "warning" : "error",
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 (error) {
539
- journalCloseError = error;
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 roots = [cwd, os.homedir()];
586
- for (const root of roots) {
587
- const dir = path.join(root, ".pi", "ultracode", "workflows");
588
- for (const candidate of [`${params.name}.workflow.js`, `${params.name}.js`]) {
589
- const full = path.join(dir, candidate);
590
- if (artifactPathExists(full)) {
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 under .pi/ultracode/workflows/`);
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: { sessionManager?: { getSessionDir?: () => string }; cwd: string }): string {
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
- const sessionDir = ctx.sessionManager?.getSessionDir?.();
606
- if (sessionDir) return path.join(sessionDir, "ultracode-runs");
650
+ sessionDir = ctx.sessionManager?.getSessionDir?.();
651
+ sessionId = ctx.sessionManager?.getSessionId?.();
607
652
  } catch {
608
- // fall through
653
+ // Fall through to the standalone artifact root.
609
654
  }
610
- return standaloneWorkflowRunsDir(ctx.cwd);
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
- let overlayOpen = false;
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 (overlayOpen) {
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
- overlayOpen = true;
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
- overlayOpen = false;
105
+ OPEN_OVERLAYS.delete(registry);
106
106
  }
107
107
  }
108
108