pi-ultracode 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.
@@ -0,0 +1,381 @@
1
+ /**
2
+ * Git worktree isolation for workflow subagents.
3
+ *
4
+ * When an agent() call requests `isolation: 'worktree'`, the subagent runs in a
5
+ * throwaway git worktree on a detached branch so parallel file-mutating agents
6
+ * don't clobber each other. After the agent finishes we capture a diff; a
7
+ * worktree with no changes is removed immediately ("auto-removed if unchanged").
8
+ */
9
+
10
+ import { execFileSync } from "node:child_process";
11
+ import { randomBytes } from "node:crypto";
12
+ import * as fs from "node:fs";
13
+ import * as os from "node:os";
14
+ import * as path from "node:path";
15
+
16
+ export interface Worktree {
17
+ path: string;
18
+ /** cwd the subagent should use (worktree root joined with the original relative cwd). */
19
+ agentCwd: string;
20
+ branch: string;
21
+ baseCommit: string;
22
+ }
23
+
24
+ export interface WorktreeDiff {
25
+ filesChanged: number;
26
+ insertions: number;
27
+ deletions: number;
28
+ diffStat: string;
29
+ patch: string;
30
+ }
31
+
32
+ function git(cwd: string, args: string[]): string {
33
+ return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
34
+ }
35
+
36
+ function tryGit(cwd: string, args: string[]): string | undefined {
37
+ try {
38
+ return git(cwd, args);
39
+ } catch {
40
+ return undefined;
41
+ }
42
+ }
43
+
44
+ /** Like tryGit but does NOT `.trim()` — required for `git diff --binary` output,
45
+ * whose trailing blank lines are part of the patch format and must not be stripped. */
46
+ function tryGitRaw(cwd: string, args: string[]): string | undefined {
47
+ try {
48
+ return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
49
+ } catch {
50
+ return undefined;
51
+ }
52
+ }
53
+
54
+ export function isGitRepo(cwd: string): boolean {
55
+ return tryGit(cwd, ["rev-parse", "--is-inside-work-tree"]) === "true";
56
+ }
57
+
58
+ /**
59
+ * Create an isolated worktree for one subagent. Throws if `cwd` is not inside a
60
+ * git repository (the caller falls back to a shared cwd in that case).
61
+ */
62
+ export function createWorktree(cwd: string, runId: string, index: number): Worktree {
63
+ const toplevel = tryGit(cwd, ["rev-parse", "--show-toplevel"]);
64
+ if (!toplevel) throw new Error("isolation: 'worktree' requires the working directory to be inside a git repository");
65
+
66
+ const baseCommit = tryGit(cwd, ["rev-parse", "HEAD"]) ?? "";
67
+ if (!baseCommit) {
68
+ throw new Error("isolation: 'worktree' requires at least one commit in the repository");
69
+ }
70
+
71
+ const safeRun = runId.replace(/[^A-Za-z0-9_-]/g, "").slice(0, 24) || "run";
72
+ const branch = `ultracode/${safeRun}-${index}`;
73
+ const worktreePath = path.join(os.tmpdir(), `ultracode-wt-${safeRun}-${index}`);
74
+
75
+ // Best-effort GC of orphaned + stale ultracode worktrees from earlier runs.
76
+ // Rate-limited so a fleet of worktree agents doesn't hammer tmpdir on every create.
77
+ const _now = Date.now();
78
+ if (_now - lastReapAt > REAP_INTERVAL_MS) {
79
+ lastReapAt = _now;
80
+ reapStaleWorktrees(toplevel);
81
+ }
82
+
83
+ // Clean up any stale worktree from a crashed prior run.
84
+ removeWorktreeQuiet(toplevel, worktreePath, branch);
85
+
86
+ git(toplevel, ["worktree", "add", "--detach", worktreePath, baseCommit]);
87
+ // Move onto a named branch so the diff has a stable ref and cleanup is unambiguous.
88
+ tryGit(worktreePath, ["checkout", "-B", branch]);
89
+
90
+ linkNodeModules(toplevel, worktreePath);
91
+
92
+ const relativeCwd = path.relative(toplevel, path.resolve(cwd));
93
+ const agentCwd = relativeCwd && !relativeCwd.startsWith("..") ? path.join(worktreePath, relativeCwd) : worktreePath;
94
+
95
+ return { path: worktreePath, agentCwd, branch, baseCommit };
96
+ }
97
+
98
+ /** Stage everything and capture the diff vs the base commit. */
99
+ export function captureWorktreeDiff(worktree: Worktree): WorktreeDiff {
100
+ tryGit(worktree.path, ["add", "-A"]);
101
+ // core.quotepath=false keeps non-ASCII paths unquoted in the patch text (so
102
+ // applyPatch's path parser targets the real file); --binary carries literal
103
+ // binary patch data so binary changes are applicable + recoverable.
104
+ const numstat = tryGit(worktree.path, ["-c", "core.quotepath=false", "diff", "--cached", "--numstat", worktree.baseCommit]) ?? "";
105
+ const diffStat = tryGit(worktree.path, ["-c", "core.quotepath=false", "diff", "--cached", "--stat", worktree.baseCommit]) ?? "";
106
+ const patch = tryGitRaw(worktree.path, ["-c", "core.quotepath=false", "diff", "--cached", "--binary", worktree.baseCommit]) ?? "";
107
+
108
+ let filesChanged = 0;
109
+ let insertions = 0;
110
+ let deletions = 0;
111
+ for (const line of numstat.split("\n")) {
112
+ if (!line.trim()) continue;
113
+ filesChanged++;
114
+ const [add, del] = line.split("\t");
115
+ if (add && add !== "-") insertions += Number(add) || 0;
116
+ if (del && del !== "-") deletions += Number(del) || 0;
117
+ }
118
+ return { filesChanged, insertions, deletions, diffStat, patch };
119
+ }
120
+
121
+ export function hasChanges(diff: WorktreeDiff): boolean {
122
+ return diff.filesChanged > 0;
123
+ }
124
+
125
+ // GC cadence + staleness threshold for ultracode worktrees + patch files in tmpdir.
126
+ const REAP_INTERVAL_MS = 60_000; // reap at most once per minute per process
127
+ const STALE_WORKTREE_MAX_AGE_MS = 24 * 60 * 60 * 1000; // entries older than this are reaped
128
+ let lastReapAt = 0;
129
+
130
+ /** Generate a unique tmp path for a patch file. Uses crypto-strong randomness
131
+ * (not a per-realm counter) so the name is collision-proof across calls,
132
+ * milliseconds, processes, AND worker threads (which share a pid but have
133
+ * separate module realms — a per-realm counter would collide on call 0). */
134
+ export function patchTmpPath(): string {
135
+ return path.join(os.tmpdir(), `ultracode-patch-${process.pid}-${randomBytes(8).toString("hex")}.patch`);
136
+ }
137
+
138
+ /** Apply a captured patch back onto the original working tree. */
139
+ export function applyPatch(cwd: string, patch: string): boolean {
140
+ if (!patch.trim()) return false;
141
+ const paths = patchedFiles(patch);
142
+ // Snapshot the pre-apply working-tree content of every path the patch touches,
143
+ // so a failed apply (which leaves conflict markers + an unmerged index) can be
144
+ // reverted to exactly this state, preserving any pre-existing uncommitted edits.
145
+ const before = new Map<string, Buffer | null>();
146
+ for (const p of paths) {
147
+ try {
148
+ before.set(p, fs.readFileSync(path.join(cwd, p)));
149
+ } catch {
150
+ before.set(p, null); // path does not exist yet (the patch adds it)
151
+ }
152
+ }
153
+ // Unique per call (see patchTmpPath): previously the name only depended on pid
154
+ // + cwd.length, so concurrent applies (worker threads sharing a pid, or a
155
+ // future async apply) with equal-length cwds collided on the same tmp file.
156
+ const tmp = patchTmpPath();
157
+ try {
158
+ fs.writeFileSync(tmp, patch.endsWith("\n") ? patch : `${patch}\n`);
159
+ git(cwd, ["apply", "--3way", tmp]);
160
+ return true;
161
+ } catch {
162
+ // `git apply --3way` on a real conflict writes conflict markers into the file
163
+ // and leaves an unmerged (UU) index entry; `git checkout --` then refuses with
164
+ // "path is unmerged". Reset the index entry and restore the pre-apply content.
165
+ revertPatchedPaths(cwd, paths, before);
166
+ return false;
167
+ } finally {
168
+ try {
169
+ fs.unlinkSync(tmp);
170
+ } catch {
171
+ // ignore
172
+ }
173
+ }
174
+ }
175
+
176
+ /** Every path a patch touches (adds, modifies, deletes, renames). Parses the
177
+ * `+++ b/<path>`, `--- a/<path>`, and `diff --git a/<x> b/<y>` headers, skipping
178
+ * `/dev/null`. Deletions (`+++ /dev/null`) are captured via their `--- a/` side
179
+ * so a failed apply can restore the deleted file too. */
180
+ function patchedFiles(patch: string): string[] {
181
+ const out: string[] = [];
182
+ const seen = new Set<string>();
183
+ const push = (p: string) => {
184
+ if (p && p !== "/dev/null" && !seen.has(p)) {
185
+ seen.add(p);
186
+ out.push(p);
187
+ }
188
+ };
189
+ for (const line of patch.split("\n")) {
190
+ let m = line.match(/^\+\+\+ b\/(.+)$/);
191
+ if (m) {
192
+ push(m[1]);
193
+ continue;
194
+ }
195
+ m = line.match(/^--- a\/(.+)$/);
196
+ if (m) {
197
+ push(m[1]);
198
+ continue;
199
+ }
200
+ m = line.match(/^diff --git a\/(.+) b\/(.+)$/);
201
+ if (m) {
202
+ push(m[1]);
203
+ push(m[2]);
204
+ }
205
+ }
206
+ return out;
207
+ }
208
+
209
+ /** Restore patched paths to their pre-apply state, clearing conflict markers + UU index. */
210
+ function revertPatchedPaths(
211
+ cwd: string,
212
+ paths: string[],
213
+ before: Map<string, Buffer | null>,
214
+ ): void {
215
+ for (const p of paths) {
216
+ // Clear any unmerged index entry left by `git apply --3way` (idempotent).
217
+ tryGit(cwd, ["reset", "HEAD", "--", p]);
218
+ const prev = before.get(p);
219
+ if (prev == null) {
220
+ // Path did not exist before apply; remove any marker file the conflict wrote.
221
+ try {
222
+ fs.rmSync(path.join(cwd, p), { force: true });
223
+ } catch {
224
+ // ignore
225
+ }
226
+ } else {
227
+ try {
228
+ fs.writeFileSync(path.join(cwd, p), prev);
229
+ } catch {
230
+ // best-effort revert
231
+ }
232
+ }
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Persist a patch that could not be auto-applied (3-way conflict) to a durable
238
+ * file under `<dir>/<runId>-<id>-<label>.patch`. Called before the worktree is
239
+ * force-removed so the agent's changes are recoverable instead of lost. Includes
240
+ * the agent sequence id so two same-label agents in one run don't overwrite each
241
+ * other's rescue patch.
242
+ */
243
+ export function writeRescuePatch(
244
+ dir: string,
245
+ runId: string,
246
+ id: number,
247
+ label: string,
248
+ patch: string,
249
+ ): string {
250
+ fs.mkdirSync(dir, { recursive: true });
251
+ const safeRun = runId.replace(/[^A-Za-z0-9_-]/g, "").slice(0, 24) || "run";
252
+ const safeLabel = label.replace(/[^A-Za-z0-9_-]/g, "").slice(0, 40) || "agent";
253
+ const file = path.join(dir, `${safeRun}-${id}-${safeLabel}.patch`);
254
+ fs.writeFileSync(file, patch.endsWith("\n") ? patch : `${patch}\n`);
255
+ return file;
256
+ }
257
+
258
+ export function removeWorktree(worktree: Worktree): void {
259
+ const toplevel = tryGit(worktree.path, ["rev-parse", "--show-toplevel"]);
260
+ const repo = toplevel ?? path.dirname(worktree.path);
261
+ removeWorktreeQuiet(repo, worktree.path, worktree.branch);
262
+ }
263
+
264
+ function removeWorktreeQuiet(repo: string, worktreePath: string, branch: string): void {
265
+ tryGit(repo, ["worktree", "remove", "--force", worktreePath]);
266
+ // worktree remove may fail if the dir was deleted manually; prune + rmrf to be safe.
267
+ tryGit(repo, ["worktree", "prune"]);
268
+ try {
269
+ fs.rmSync(worktreePath, { recursive: true, force: true });
270
+ } catch {
271
+ // ignore
272
+ }
273
+ tryGit(repo, ["branch", "-D", branch]);
274
+ }
275
+
276
+ /**
277
+ * Reap stale ultracode worktrees + leaked patch files from tmpdir. Entries
278
+ * older than the staleness threshold (24h default) are removed: tracked
279
+ * worktrees (registered with `toplevel`) via full `git worktree remove` + branch
280
+ * delete; untracked `ultracode-wt-*` dirs via rmSync; `ultracode-patch-*` files
281
+ * via unlink.
282
+ *
283
+ * Limitation: `git worktree list` is repo-scoped, so a worktree owned by a
284
+ * DIFFERENT repo is "untracked" here and is rmSync'd after the threshold (its
285
+ * branch in the owning repo is NOT cleaned — there is no safe way to find the
286
+ * owner from the dir alone). The same 24h threshold applies to untracked dirs as
287
+ * to tracked worktrees, so cross-repo in-flight and kept worktrees survive as
288
+ * long as same-repo ones. If git can't list worktrees, the reap bails safe
289
+ * rather than treating every entry as orphaned.
290
+ *
291
+ * Exported for tests; createWorktree calls this rate-limited.
292
+ */
293
+ export function reapStaleWorktrees(
294
+ toplevel: string,
295
+ opts: { maxAgeMs?: number } = {},
296
+ ): void {
297
+ const maxAgeMs = opts.maxAgeMs ?? STALE_WORKTREE_MAX_AGE_MS;
298
+ // Drop git's tracking of worktrees whose dirs are already gone.
299
+ tryGit(toplevel, ["worktree", "prune"]);
300
+ // Bail safe if we can't list worktrees: treating every entry as orphaned would
301
+ // destroy in-flight/kept worktrees from other repos (git worktree list is
302
+ // repo-scoped, so only the current repo's worktrees are `tracked` below).
303
+ const list = tryGit(toplevel, ["worktree", "list", "--porcelain"]);
304
+ if (list == null) return;
305
+ const canonicalPath = (value: string): string => {
306
+ try {
307
+ return fs.realpathSync(value);
308
+ } catch {
309
+ return path.resolve(value);
310
+ }
311
+ };
312
+ const tracked = new Set(
313
+ list
314
+ .split("\n")
315
+ .filter((l) => l.startsWith("worktree "))
316
+ .map((l) => canonicalPath(l.slice("worktree ".length).trim())),
317
+ );
318
+ let entries: string[];
319
+ try {
320
+ entries = fs.readdirSync(os.tmpdir());
321
+ } catch {
322
+ return;
323
+ }
324
+ const now = Date.now();
325
+ for (const name of entries) {
326
+ if (!name.startsWith("ultracode-wt-")) continue;
327
+ const wtPath = path.join(os.tmpdir(), name);
328
+ let mtime: number;
329
+ try {
330
+ mtime = fs.statSync(wtPath).mtimeMs;
331
+ } catch {
332
+ continue;
333
+ }
334
+ if (now - mtime <= maxAgeMs) continue; // recent: in-flight or within the recovery window
335
+ if (tracked.has(canonicalPath(wtPath))) {
336
+ // Tracked + stale (kept from an old run in THIS repo): full remove + branch delete.
337
+ const branch = `ultracode/${name.slice("ultracode-wt-".length)}`;
338
+ removeWorktreeQuiet(toplevel, wtPath, branch);
339
+ } else {
340
+ // Untracked by this repo: could be a crash orphan, OR a worktree owned by a
341
+ // DIFFERENT repo (git worktree list is repo-scoped, so we can't tell). The
342
+ // 24h threshold matches the same-repo kept-worktree window, so cross-repo
343
+ // in-flight/kept worktrees survive as long as same-repo ones. The owning
344
+ // repo's branch is NOT cleaned here (no safe way to find it).
345
+ try {
346
+ fs.rmSync(wtPath, { recursive: true, force: true });
347
+ } catch {
348
+ // ignore
349
+ }
350
+ }
351
+ }
352
+ // Also reap crash-leaked patch tmp files (ultracode-patch-*), same threshold.
353
+ for (const name of entries) {
354
+ if (!name.startsWith("ultracode-patch-")) continue;
355
+ const fp = path.join(os.tmpdir(), name);
356
+ let mtime: number;
357
+ try {
358
+ mtime = fs.statSync(fp).mtimeMs;
359
+ } catch {
360
+ continue;
361
+ }
362
+ if (now - mtime > maxAgeMs) {
363
+ try {
364
+ fs.unlinkSync(fp);
365
+ } catch {
366
+ // ignore
367
+ }
368
+ }
369
+ }
370
+ }
371
+
372
+ function linkNodeModules(toplevel: string, worktreePath: string): void {
373
+ const src = path.join(toplevel, "node_modules");
374
+ const dest = path.join(worktreePath, "node_modules");
375
+ if (!fs.existsSync(src) || fs.existsSync(dest)) return;
376
+ try {
377
+ fs.symlinkSync(src, dest, "dir");
378
+ } catch {
379
+ // unsupported filesystem; subagents just won't have node_modules linked
380
+ }
381
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Ambient globals available inside pi-ultracode workflow scripts.
3
+ *
4
+ * Add this to a saved workflow file for editor IntelliSense:
5
+ *
6
+ * /// <reference types="pi-ultracode/workflow" />
7
+ */
8
+
9
+ export {};
10
+
11
+ declare global {
12
+ interface WorkflowMeta {
13
+ name: string;
14
+ description: string;
15
+ whenToUse?: string;
16
+ phases?: Array<{ title: string; detail?: string; model?: string }>;
17
+ }
18
+
19
+ interface JsonSchema {
20
+ type?: string | string[];
21
+ properties?: Record<string, JsonSchema>;
22
+ items?: JsonSchema | JsonSchema[];
23
+ required?: string[];
24
+ additionalProperties?: boolean | JsonSchema;
25
+ enum?: unknown[];
26
+ const?: unknown;
27
+ anyOf?: JsonSchema[];
28
+ oneOf?: JsonSchema[];
29
+ description?: string;
30
+ [key: string]: unknown;
31
+ }
32
+
33
+ interface WorkflowAgentOptions {
34
+ /** Short label shown in live progress (2-5 words). */
35
+ label?: string;
36
+ /** Assign this agent to a progress group explicitly. */
37
+ phase?: string;
38
+ /** JSON Schema for structured output; agent() then returns the validated object. */
39
+ schema?: JsonSchema;
40
+ /** Override the subagent model by pattern, e.g. "sonnet" or "anthropic/...:high". */
41
+ model?: string;
42
+ /** Run the agent in an isolated git worktree (for parallel file mutation). */
43
+ isolation?: "worktree";
44
+ /** Use a custom subagent role/system-prompt (built-in or discovered). */
45
+ agentType?: string;
46
+ }
47
+
48
+ interface WorkflowBudget {
49
+ total: number | null;
50
+ spent(): number;
51
+ remaining(): number;
52
+ }
53
+
54
+ /** Spawn a subagent. Returns final text, or a validated object when opts.schema is set. */
55
+ function agent<T = unknown>(prompt: string, options?: WorkflowAgentOptions): Promise<T>;
56
+
57
+ /** Run independent tasks concurrently (a barrier). Pass functions, not promises. */
58
+ function parallel<T = unknown>(thunks: Array<() => Promise<T>>): Promise<T[]>;
59
+
60
+ /** Run each item through sequential stages while items fan out (no barrier). */
61
+ function pipeline<TItem = unknown, TResult = unknown>(
62
+ items: TItem[],
63
+ ...stages: Array<(previous: unknown, original: TItem, index: number) => TResult | Promise<TResult>>
64
+ ): Promise<TResult[]>;
65
+
66
+ /** Run a saved workflow (by name) or { scriptPath } inline; one level of nesting. */
67
+ function workflow<T = unknown>(nameOrRef: string | { scriptPath: string }, args?: unknown): Promise<T>;
68
+
69
+ /** Mark the current phase for progress grouping. */
70
+ function phase(title: string): void;
71
+
72
+ /** Append a workflow-level log line. */
73
+ function log(message: unknown): void;
74
+
75
+ /** JSON value passed via the tool's `args` parameter. */
76
+ const args: unknown;
77
+
78
+ /** Working directory for the workflow and its subagents. */
79
+ const cwd: string;
80
+
81
+ /** Deterministic process shim exposing only cwd(). */
82
+ const process: { cwd(): string };
83
+
84
+ /** Real output-token budget tracker for the run. */
85
+ const budget: WorkflowBudget;
86
+ }