@bdsqqq/pi 0.0.1

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.
Files changed (99) hide show
  1. package/dist/chunk-DsIazq42.js +26 -0
  2. package/dist/command-palette-B97yKqne.js +406 -0
  3. package/dist/core/agents-md.d.ts +22 -0
  4. package/dist/core/agents-md.js +174 -0
  5. package/dist/core/box-chrome.d.ts +56 -0
  6. package/dist/core/box-chrome.js +207 -0
  7. package/dist/core/box-format.d.ts +82 -0
  8. package/dist/core/box-format.js +381 -0
  9. package/dist/core/config.d.ts +14 -0
  10. package/dist/core/config.js +201 -0
  11. package/dist/core/file-tracker.d.ts +65 -0
  12. package/dist/core/file-tracker.js +439 -0
  13. package/dist/core/github-api.d.ts +45 -0
  14. package/dist/core/github-api.js +101 -0
  15. package/dist/core/html-to-md.d.ts +5 -0
  16. package/dist/core/html-to-md.js +289 -0
  17. package/dist/core/interpolate.d.ts +44 -0
  18. package/dist/core/interpolate.js +475 -0
  19. package/dist/core/mutex.d.ts +8 -0
  20. package/dist/core/mutex.js +142 -0
  21. package/dist/core/output-buffer.d.ts +80 -0
  22. package/dist/core/output-buffer.js +517 -0
  23. package/dist/core/permissions.d.ts +19 -0
  24. package/dist/core/permissions.js +140 -0
  25. package/dist/core/pi-spawn.d.ts +62 -0
  26. package/dist/core/pi-spawn.js +244 -0
  27. package/dist/core/prompt-patch.d.ts +11 -0
  28. package/dist/core/prompt-patch.js +16 -0
  29. package/dist/core/show-renderer.d.ts +10 -0
  30. package/dist/core/show-renderer.js +39 -0
  31. package/dist/core/show.d.ts +29 -0
  32. package/dist/core/show.js +288 -0
  33. package/dist/core/sub-agent-render.d.ts +56 -0
  34. package/dist/core/sub-agent-render.js +579 -0
  35. package/dist/core/tool-cost.d.ts +17 -0
  36. package/dist/core/tool-cost.js +58 -0
  37. package/dist/core/tui.d.ts +9 -0
  38. package/dist/core/tui.js +33 -0
  39. package/dist/editor-BljciPdx.js +537 -0
  40. package/dist/extensions/bash.d.ts +12 -0
  41. package/dist/extensions/bash.js +261 -0
  42. package/dist/extensions/code-review.d.ts +14 -0
  43. package/dist/extensions/code-review.js +175 -0
  44. package/dist/extensions/command-palette.d.ts +6 -0
  45. package/dist/extensions/command-palette.js +2 -0
  46. package/dist/extensions/create-file.d.ts +7 -0
  47. package/dist/extensions/create-file.js +88 -0
  48. package/dist/extensions/edit-file.d.ts +7 -0
  49. package/dist/extensions/edit-file.js +395 -0
  50. package/dist/extensions/editor.d.ts +6 -0
  51. package/dist/extensions/editor.js +2 -0
  52. package/dist/extensions/finder.d.ts +13 -0
  53. package/dist/extensions/finder.js +108 -0
  54. package/dist/extensions/format-file.d.ts +11 -0
  55. package/dist/extensions/format-file.js +145 -0
  56. package/dist/extensions/github.d.ts +13 -0
  57. package/dist/extensions/github.js +527 -0
  58. package/dist/extensions/glob.d.ts +10 -0
  59. package/dist/extensions/glob.js +163 -0
  60. package/dist/extensions/grep.d.ts +13 -0
  61. package/dist/extensions/grep.js +356 -0
  62. package/dist/extensions/handoff.d.ts +6 -0
  63. package/dist/extensions/handoff.js +277 -0
  64. package/dist/extensions/librarian.d.ts +13 -0
  65. package/dist/extensions/librarian.js +113 -0
  66. package/dist/extensions/look-at.d.ts +13 -0
  67. package/dist/extensions/look-at.js +121 -0
  68. package/dist/extensions/ls.d.ts +8 -0
  69. package/dist/extensions/ls.js +87 -0
  70. package/dist/extensions/mermaid.d.ts +20 -0
  71. package/dist/extensions/mermaid.js +2 -0
  72. package/dist/extensions/oracle.d.ts +13 -0
  73. package/dist/extensions/oracle.js +143 -0
  74. package/dist/extensions/read-session.d.ts +13 -0
  75. package/dist/extensions/read-session.js +265 -0
  76. package/dist/extensions/read-web-page.d.ts +11 -0
  77. package/dist/extensions/read-web-page.js +234 -0
  78. package/dist/extensions/read.d.ts +23 -0
  79. package/dist/extensions/read.js +323 -0
  80. package/dist/extensions/search-sessions.d.ts +29 -0
  81. package/dist/extensions/search-sessions.js +426 -0
  82. package/dist/extensions/session-name.d.ts +6 -0
  83. package/dist/extensions/session-name.js +54 -0
  84. package/dist/extensions/skill.d.ts +7 -0
  85. package/dist/extensions/skill.js +232 -0
  86. package/dist/extensions/system-prompt.d.ts +6 -0
  87. package/dist/extensions/system-prompt.js +31 -0
  88. package/dist/extensions/task.d.ts +11 -0
  89. package/dist/extensions/task.js +114 -0
  90. package/dist/extensions/tool-harness.d.ts +6 -0
  91. package/dist/extensions/tool-harness.js +37 -0
  92. package/dist/extensions/undo-edit.d.ts +7 -0
  93. package/dist/extensions/undo-edit.js +127 -0
  94. package/dist/extensions/web-search.d.ts +12 -0
  95. package/dist/extensions/web-search.js +254 -0
  96. package/dist/extensions.d.ts +29 -0
  97. package/dist/extensions.js +29 -0
  98. package/dist/mermaid-C7xCVBrw.js +95296 -0
  99. package/package.json +67 -0
@@ -0,0 +1,475 @@
1
+ import { n as __require } from "../chunk-DsIazq42.js";
2
+ import { clearConfigCache, getGlobalConfig, resolveConfigDir, setGlobalSettingsPath } from "./config.js";
3
+ import * as fs from "node:fs";
4
+ import * as path from "node:path";
5
+ import { execSync } from "node:child_process";
6
+ import * as os from "node:os";
7
+ //#region packages/core/interpolate/index.ts
8
+ /**
9
+ * template variable interpolation for subagent system prompts.
10
+ *
11
+ * variables and their resolution strategies are declarative — defined in
12
+ * bds-pi.json config or passed at call-time. resolver types: runtime,
13
+ * literal, alias, file, env, dangerously_evaluate_js, dangerously_evaluate_sh.
14
+ *
15
+ * resolution order follows a topological sort of inter-variable references,
16
+ * so {varA} can appear inside another variable's expression.
17
+ */
18
+ const DEFAULT_PROMPT_VARIABLES = {
19
+ cwd: { runtime: "cwd" },
20
+ roots: { runtime: "roots" },
21
+ wsroot: { alias: "roots" },
22
+ workingDir: { alias: "cwd" },
23
+ date: { runtime: "date" },
24
+ os: { runtime: "os" },
25
+ repo: { runtime: "repo" },
26
+ sessionId: { runtime: "sessionId" },
27
+ ls: { runtime: "ls" },
28
+ identity: {
29
+ runtime: "identity",
30
+ default: "Amp"
31
+ },
32
+ harness: {
33
+ runtime: "harness",
34
+ default: "pi"
35
+ },
36
+ harness_docs_section: { runtime: "harnessDocsSection" }
37
+ };
38
+ /** walk up from dir looking for .git to find the workspace root. falls back to dir itself. */
39
+ function findGitRoot(dir) {
40
+ let current = path.resolve(dir);
41
+ while (true) {
42
+ try {
43
+ const gitPath = path.join(current, ".git");
44
+ const stat = fs.statSync(gitPath);
45
+ if (stat.isDirectory() || stat.isFile()) return current;
46
+ } catch {}
47
+ const parent = path.dirname(current);
48
+ if (parent === current) return dir;
49
+ current = parent;
50
+ }
51
+ }
52
+ /** try to get the git remote origin URL for a directory. */
53
+ function getGitRemoteUrl(dir) {
54
+ try {
55
+ return execSync("git remote get-url origin", {
56
+ cwd: dir,
57
+ stdio: [
58
+ "ignore",
59
+ "pipe",
60
+ "ignore"
61
+ ]
62
+ }).toString().trim();
63
+ } catch {
64
+ return "";
65
+ }
66
+ }
67
+ /** compute the built-in runtime variables map from cwd + extra context. */
68
+ function computeRuntimeVars(cwd, extra) {
69
+ const roots = findGitRoot(cwd);
70
+ const date = (/* @__PURE__ */ new Date()).toLocaleDateString("en-US", {
71
+ weekday: "short",
72
+ year: "numeric",
73
+ month: "short",
74
+ day: "numeric"
75
+ });
76
+ const repo = extra?.repo ?? getGitRemoteUrl(roots);
77
+ const sessionId = extra?.sessionId ?? "";
78
+ let ls = "";
79
+ try {
80
+ ls = fs.readdirSync(roots).map((e) => {
81
+ const full = path.join(roots, e);
82
+ try {
83
+ return fs.statSync(full).isDirectory() ? `${full}/` : full;
84
+ } catch {
85
+ return full;
86
+ }
87
+ }).join("\n");
88
+ } catch {}
89
+ return {
90
+ cwd,
91
+ roots,
92
+ date,
93
+ os: `${os.platform()} (${os.release()}) on ${os.arch()}`,
94
+ repo,
95
+ sessionId,
96
+ ls,
97
+ identity: extra?.identity ?? "",
98
+ harness: extra?.harness ?? "",
99
+ harnessDocsSection: extra?.harnessDocsSection ?? ""
100
+ };
101
+ }
102
+ /** find {varName} patterns in a string that reference known variable names. */
103
+ function extractRefs(s, knownVars) {
104
+ if (!s) return [];
105
+ const refs = [];
106
+ const re = /\{(\w+)\}/g;
107
+ let m;
108
+ while ((m = re.exec(s)) !== null) if (knownVars.has(m[1])) refs.push(m[1]);
109
+ return refs;
110
+ }
111
+ /** collect all variable dependencies for a single definition. */
112
+ function getDeps(def, knownVars) {
113
+ const deps = /* @__PURE__ */ new Set();
114
+ if (def.alias !== void 0 && knownVars.has(def.alias)) deps.add(def.alias);
115
+ for (const ref of extractRefs(def.file, knownVars)) deps.add(ref);
116
+ for (const ref of extractRefs(def.dangerously_evaluate_js, knownVars)) deps.add(ref);
117
+ for (const ref of extractRefs(def.dangerously_evaluate_sh, knownVars)) deps.add(ref);
118
+ for (const ref of extractRefs(def.cwd, knownVars)) deps.add(ref);
119
+ for (const ref of extractRefs(def.default, knownVars)) deps.add(ref);
120
+ return [...deps];
121
+ }
122
+ /** topological sort of variable definitions. throws on cycles. */
123
+ function topoSort(defs) {
124
+ const names = Object.keys(defs);
125
+ const knownVars = new Set(names);
126
+ const visited = /* @__PURE__ */ new Set();
127
+ const visiting = /* @__PURE__ */ new Set();
128
+ const order = [];
129
+ function visit(name) {
130
+ if (visited.has(name)) return;
131
+ if (visiting.has(name)) throw new Error(`cycle detected in prompt variables: ${name}`);
132
+ visiting.add(name);
133
+ const def = defs[name];
134
+ if (def) for (const dep of getDeps(def, knownVars)) visit(dep);
135
+ visiting.delete(name);
136
+ visited.add(name);
137
+ order.push(name);
138
+ }
139
+ for (const name of names) visit(name);
140
+ return order;
141
+ }
142
+ /** substitute already-resolved vars into a string. */
143
+ function subVars(s, resolved) {
144
+ return s.replace(/\{(\w+)\}/g, (match, key) => key in resolved ? resolved[key] : match);
145
+ }
146
+ /** resolve a single variable definition to a string value. */
147
+ function resolveVariable(def, resolved, runtimeVars, configDir) {
148
+ let value = "";
149
+ if (def.runtime !== void 0) value = runtimeVars[def.runtime] ?? "";
150
+ else if (def.literal !== void 0) value = def.literal;
151
+ else if (def.alias !== void 0) value = resolved[def.alias] ?? "";
152
+ else if (def.file !== void 0) {
153
+ const relPath = subVars(def.file, resolved);
154
+ const base = configDir ?? ".";
155
+ const absPath = path.resolve(base, relPath);
156
+ try {
157
+ value = fs.readFileSync(absPath, "utf-8");
158
+ } catch {
159
+ value = "";
160
+ }
161
+ } else if (def.env !== void 0) value = process.env[def.env] ?? "";
162
+ else if (def.dangerously_evaluate_js !== void 0) {
163
+ const expr = subVars(def.dangerously_evaluate_js, resolved);
164
+ try {
165
+ const result = new Function("require", `return (${expr})`)(__require);
166
+ value = String(result ?? "");
167
+ } catch {
168
+ value = "";
169
+ }
170
+ } else if (def.dangerously_evaluate_sh !== void 0) {
171
+ const cmd = subVars(def.dangerously_evaluate_sh, resolved);
172
+ const shCwd = def.cwd ? subVars(def.cwd, resolved) : void 0;
173
+ try {
174
+ value = execSync(cmd, {
175
+ cwd: shCwd,
176
+ timeout: 5e3,
177
+ stdio: [
178
+ "ignore",
179
+ "pipe",
180
+ "ignore"
181
+ ]
182
+ }).toString().trim();
183
+ } catch {
184
+ value = "";
185
+ }
186
+ }
187
+ if (!value && def.default !== void 0) value = subVars(def.default, resolved);
188
+ return value;
189
+ }
190
+ /**
191
+ * resolve template variables in agent prompts (e.g. {cwd}, {roots}, {date}).
192
+ *
193
+ * when a value is unavailable and dropLineIfEmpty is true (default),
194
+ * the entire line containing the placeholder is removed rather than
195
+ * leaving an empty label like "Repository: ".
196
+ *
197
+ * merge order: DEFAULT_PROMPT_VARIABLES → global config promptVariables → variables arg.
198
+ */
199
+ function interpolatePromptVars(prompt, cwd, extra, variables) {
200
+ const configVars = variables === void 0 ? getGlobalConfig("promptVariables") ?? {} : {};
201
+ const merged = {
202
+ ...DEFAULT_PROMPT_VARIABLES,
203
+ ...configVars,
204
+ ...variables ?? {}
205
+ };
206
+ const runtimeVars = computeRuntimeVars(cwd, extra);
207
+ const configDir = resolveConfigDir();
208
+ const order = topoSort(merged);
209
+ const resolved = {};
210
+ for (const name of order) resolved[name] = resolveVariable(merged[name], resolved, runtimeVars, configDir);
211
+ const dropKeys = [];
212
+ const filled = {};
213
+ for (const [key, value] of Object.entries(resolved)) {
214
+ const drop = merged[key]?.dropLineIfEmpty ?? true;
215
+ if (!value && drop) dropKeys.push(key);
216
+ else filled[key] = value;
217
+ }
218
+ let result = prompt;
219
+ if (dropKeys.length > 0) result = result.replace(new RegExp(`^.*\\{(${dropKeys.join("|")})\\}.*\\n?`, "gm"), "");
220
+ const filledKeys = Object.keys(filled);
221
+ if (filledKeys.length > 0) result = result.replace(new RegExp(`\\{(${filledKeys.join("|")})\\}`, "g"), (match, key) => filled[key] ?? match);
222
+ return result;
223
+ }
224
+ if (import.meta.vitest) {
225
+ const { afterEach, describe, expect, test } = import.meta.vitest;
226
+ const cwd = "/home/user/project";
227
+ afterEach(() => {
228
+ clearConfigCache();
229
+ });
230
+ describe("interpolatePromptVars", () => {
231
+ test("resolves all basic vars", () => {
232
+ const result = interpolatePromptVars("cwd={cwd} roots={roots} wsroot={wsroot} workingDir={workingDir} date={date} os={os}", cwd, {
233
+ repo: "gh:test",
234
+ sessionId: "s-123"
235
+ });
236
+ expect(result).toContain(`cwd=${cwd}`);
237
+ expect(result).toContain("roots=");
238
+ expect(result).toContain("wsroot=");
239
+ expect(result).toContain(`workingDir=${cwd}`);
240
+ expect(result).toContain("os=");
241
+ expect(result).not.toContain("{cwd}");
242
+ expect(result).not.toContain("{roots}");
243
+ expect(result).not.toContain("{date}");
244
+ expect(result).not.toContain("{os}");
245
+ });
246
+ test("resolves repo and sessionId from extra context", () => {
247
+ const result = interpolatePromptVars("Repository: {repo}\nSession: {sessionId}", cwd, {
248
+ repo: "https://github.com/test/repo",
249
+ sessionId: "abc-123"
250
+ });
251
+ expect(result).toContain("Repository: https://github.com/test/repo");
252
+ expect(result).toContain("Session: abc-123");
253
+ });
254
+ test("drops entire line when value is empty", () => {
255
+ const result = interpolatePromptVars("Working directory: {cwd}\nRepository: {repo}\nSession ID: {sessionId}\nDone.", cwd, {
256
+ repo: "",
257
+ sessionId: ""
258
+ });
259
+ expect(result).toContain(`Working directory: ${cwd}`);
260
+ expect(result).not.toContain("Repository");
261
+ expect(result).not.toContain("Session ID");
262
+ expect(result).toContain("Done.");
263
+ });
264
+ test("drops line when extra context is omitted entirely", () => {
265
+ const result = interpolatePromptVars("Dir: {cwd}\nRepo: {repo}\nEnd.", cwd);
266
+ expect(result).toContain(`Dir: ${cwd}`);
267
+ expect(result).not.toContain("Repo");
268
+ expect(result).toContain("End.");
269
+ });
270
+ test("no double-interpolation when a value contains another var pattern", () => {
271
+ const result = interpolatePromptVars("Repo: {repo}\nDate: {date}", cwd, {
272
+ repo: "my-{date}-repo",
273
+ sessionId: ""
274
+ });
275
+ expect(result).toContain("Repo: my-{date}-repo");
276
+ expect(result).toMatch(/Date: \w+/);
277
+ });
278
+ test("replaces multiple occurrences of same var", () => {
279
+ expect(interpolatePromptVars("{cwd} and also {cwd}", cwd, {
280
+ repo: "x",
281
+ sessionId: "y"
282
+ })).toBe(`${cwd} and also ${cwd}`);
283
+ });
284
+ test("multiline ls expansion preserves surrounding content", () => {
285
+ expect(interpolatePromptVars("Files:\n{ls}\nEnd.", cwd, {
286
+ repo: "x",
287
+ sessionId: "y"
288
+ })).toContain("End.");
289
+ });
290
+ test("empty ls drops the line", () => {
291
+ const result = interpolatePromptVars("Before\n{ls}\nAfter", "/nonexistent/path/unlikely", {
292
+ repo: "x",
293
+ sessionId: "y"
294
+ });
295
+ expect(result).toContain("Before");
296
+ expect(result).toContain("After");
297
+ });
298
+ });
299
+ describe("findGitRoot", () => {
300
+ test("finds git root from cwd", () => {
301
+ const root = findGitRoot(process.cwd());
302
+ const { existsSync } = __require("node:fs");
303
+ const { join } = __require("node:path");
304
+ expect(existsSync(join(root, ".git"))).toBe(true);
305
+ });
306
+ test("falls back to dir when no git root exists", () => {
307
+ expect(findGitRoot("/tmp/nonexistent-no-git-here")).toBe("/tmp/nonexistent-no-git-here");
308
+ });
309
+ });
310
+ describe("literal resolver", () => {
311
+ test("returns static string", () => {
312
+ expect(interpolatePromptVars("val={myVar}", cwd, {
313
+ repo: "x",
314
+ sessionId: "y"
315
+ }, { myVar: { literal: "hello world" } })).toContain("val=hello world");
316
+ });
317
+ });
318
+ describe("alias resolver", () => {
319
+ test("resolves to another variable's value", () => {
320
+ const result = interpolatePromptVars("a={a} b={b}", cwd, {
321
+ repo: "x",
322
+ sessionId: "y"
323
+ }, {
324
+ a: { literal: "alpha" },
325
+ b: { alias: "a" }
326
+ });
327
+ expect(result).toContain("a=alpha");
328
+ expect(result).toContain("b=alpha");
329
+ });
330
+ });
331
+ describe("file resolver", () => {
332
+ test("reads file contents relative to config dir", () => {
333
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-interp-test-"));
334
+ const filePath = path.join(tmpDir, "test.txt");
335
+ fs.writeFileSync(filePath, "file content here");
336
+ setGlobalSettingsPath(path.join(tmpDir, "bds-pi.json"));
337
+ expect(interpolatePromptVars("content={f}", cwd, {
338
+ repo: "x",
339
+ sessionId: "y"
340
+ }, { f: { file: "test.txt" } })).toContain("content=file content here");
341
+ });
342
+ });
343
+ describe("env resolver", () => {
344
+ test("reads environment variable", () => {
345
+ const key = `PI_TEST_ENV_${Date.now()}`;
346
+ process.env[key] = "env_value";
347
+ try {
348
+ expect(interpolatePromptVars("e={e}", cwd, {
349
+ repo: "x",
350
+ sessionId: "y"
351
+ }, { e: { env: key } })).toContain("e=env_value");
352
+ } finally {
353
+ delete process.env[key];
354
+ }
355
+ });
356
+ });
357
+ describe("dangerously_evaluate_js resolver", () => {
358
+ test("evaluates JS expression", () => {
359
+ expect(interpolatePromptVars("n={n}", cwd, {
360
+ repo: "x",
361
+ sessionId: "y"
362
+ }, { n: { dangerously_evaluate_js: "2 + 2" } })).toContain("n=4");
363
+ });
364
+ test("returns empty on error", () => {
365
+ const result = interpolatePromptVars("n={n}\nend", cwd, {
366
+ repo: "x",
367
+ sessionId: "y"
368
+ }, { n: { dangerously_evaluate_js: "throw new Error('boom')" } });
369
+ expect(result).not.toContain("n=");
370
+ expect(result).toContain("end");
371
+ });
372
+ });
373
+ describe("dangerously_evaluate_sh resolver", () => {
374
+ test("captures stdout from shell command", () => {
375
+ expect(interpolatePromptVars("v={v}", cwd, {
376
+ repo: "x",
377
+ sessionId: "y"
378
+ }, { v: { dangerously_evaluate_sh: "echo hello_from_sh" } })).toContain("v=hello_from_sh");
379
+ });
380
+ test("cwd supports {var} refs", () => {
381
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-interp-sh-"));
382
+ expect(interpolatePromptVars("v={v}", cwd, {
383
+ repo: "x",
384
+ sessionId: "y"
385
+ }, {
386
+ dir: { literal: tmpDir },
387
+ v: {
388
+ dangerously_evaluate_sh: "pwd",
389
+ cwd: "{dir}"
390
+ }
391
+ })).toContain(tmpDir);
392
+ });
393
+ });
394
+ describe("default value", () => {
395
+ test("falls back to default when resolver returns empty", () => {
396
+ const key = `PI_TEST_MISSING_${Date.now()}`;
397
+ delete process.env[key];
398
+ expect(interpolatePromptVars("v={v}", cwd, {
399
+ repo: "x",
400
+ sessionId: "y"
401
+ }, { v: {
402
+ env: key,
403
+ default: "fallback"
404
+ } })).toContain("v=fallback");
405
+ });
406
+ test("default supports {var} refs", () => {
407
+ const key = `PI_TEST_MISSING2_${Date.now()}`;
408
+ delete process.env[key];
409
+ expect(interpolatePromptVars("v={v}", cwd, {
410
+ repo: "x",
411
+ sessionId: "y"
412
+ }, {
413
+ base: { literal: "hello" },
414
+ v: {
415
+ env: key,
416
+ default: "{base}-world"
417
+ }
418
+ })).toContain("v=hello-world");
419
+ });
420
+ });
421
+ describe("topo sort", () => {
422
+ test("resolves dependencies in correct order", () => {
423
+ const result = interpolatePromptVars("a={a} b={b} c={c}", cwd, {
424
+ repo: "x",
425
+ sessionId: "y"
426
+ }, {
427
+ c: { alias: "b" },
428
+ b: { alias: "a" },
429
+ a: { literal: "root" }
430
+ });
431
+ expect(result).toContain("a=root");
432
+ expect(result).toContain("b=root");
433
+ expect(result).toContain("c=root");
434
+ });
435
+ test("throws on cycle", () => {
436
+ expect(() => interpolatePromptVars("x={x}", cwd, {
437
+ repo: "x",
438
+ sessionId: "y"
439
+ }, {
440
+ x: { alias: "y" },
441
+ y: { alias: "x" }
442
+ })).toThrow(/cycle/i);
443
+ });
444
+ });
445
+ describe("dropLineIfEmpty", () => {
446
+ test("preserves line when dropLineIfEmpty is false", () => {
447
+ const key = `PI_TEST_DROP_${Date.now()}`;
448
+ delete process.env[key];
449
+ const result = interpolatePromptVars("Before\nLabel: {v}\nAfter", cwd, {
450
+ repo: "x",
451
+ sessionId: "y"
452
+ }, { v: {
453
+ env: key,
454
+ dropLineIfEmpty: false
455
+ } });
456
+ expect(result).toContain("Label: ");
457
+ expect(result).toContain("Before");
458
+ expect(result).toContain("After");
459
+ });
460
+ });
461
+ describe("config-driven variables", () => {
462
+ test("reads promptVariables from global config", () => {
463
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-interp-cfg-"));
464
+ const settingsPath = path.join(tmpDir, "bds-pi.json");
465
+ fs.writeFileSync(settingsPath, JSON.stringify({ promptVariables: { custom: { literal: "from config" } } }));
466
+ setGlobalSettingsPath(settingsPath);
467
+ expect(interpolatePromptVars("v={custom}", cwd, {
468
+ repo: "x",
469
+ sessionId: "y"
470
+ })).toContain("v=from config");
471
+ });
472
+ });
473
+ }
474
+ //#endregion
475
+ export { DEFAULT_PROMPT_VARIABLES, computeRuntimeVars, findGitRoot, getGitRemoteUrl, interpolatePromptVars };
@@ -0,0 +1,8 @@
1
+ //#region packages/core/mutex/index.d.ts
2
+ /**
3
+ * execute `fn` while holding an exclusive lock on `filePath`.
4
+ * concurrent calls for the same resolved path queue sequentially.
5
+ */
6
+ declare function withFileLock<T>(filePath: string, fn: () => Promise<T>): Promise<T>;
7
+ //#endregion
8
+ export { withFileLock };
@@ -0,0 +1,142 @@
1
+ import * as path from "node:path";
2
+ //#region packages/core/mutex/index.ts
3
+ /**
4
+ * per-path async mutex for file operations.
5
+ *
6
+ * serializes concurrent edits to the same file path to prevent
7
+ * partial writes and race conditions. pi's built-in edit tool doesn't.
8
+ * this mutex is keyed by resolved absolute path — two relative paths
9
+ * pointing to the same file share one lock.
10
+ */
11
+ const locks = /* @__PURE__ */ new Map();
12
+ /**
13
+ * execute `fn` while holding an exclusive lock on `filePath`.
14
+ * concurrent calls for the same resolved path queue sequentially.
15
+ */
16
+ async function withFileLock(filePath, fn) {
17
+ const key = path.resolve(filePath);
18
+ while (locks.has(key)) await locks.get(key);
19
+ let resolve;
20
+ const promise = new Promise((r) => {
21
+ resolve = r;
22
+ });
23
+ locks.set(key, promise);
24
+ try {
25
+ return await fn();
26
+ } finally {
27
+ locks.delete(key);
28
+ resolve();
29
+ }
30
+ }
31
+ if (import.meta.vitest) {
32
+ const { describe, it, expect } = import.meta.vitest;
33
+ describe("withFileLock", () => {
34
+ it("executes function and returns result", async () => {
35
+ expect(await withFileLock("/tmp/test.txt", async () => {
36
+ return "success";
37
+ })).toBe("success");
38
+ });
39
+ it("propagates errors from the function", async () => {
40
+ try {
41
+ await withFileLock("/tmp/error.txt", async () => {
42
+ throw new Error("test error");
43
+ });
44
+ expect(true).toBe(false);
45
+ } catch (e) {
46
+ expect(e.message).toBe("test error");
47
+ }
48
+ });
49
+ it("serializes concurrent calls for the same path", async () => {
50
+ const order = [];
51
+ const path = "/tmp/same-path.txt";
52
+ const makeOp = (id, delayMs) => withFileLock(path, async () => {
53
+ order.push(`${id}-start`);
54
+ await new Promise((r) => setTimeout(r, delayMs));
55
+ order.push(`${id}-end`);
56
+ return id;
57
+ });
58
+ expect(await Promise.all([
59
+ makeOp("a", 50),
60
+ makeOp("b", 30),
61
+ makeOp("c", 10)
62
+ ])).toEqual([
63
+ "a",
64
+ "b",
65
+ "c"
66
+ ]);
67
+ expect(order).toEqual([
68
+ "a-start",
69
+ "a-end",
70
+ "b-start",
71
+ "b-end",
72
+ "c-start",
73
+ "c-end"
74
+ ]);
75
+ });
76
+ it("allows parallel execution for different paths", async () => {
77
+ const order = [];
78
+ const makeOp = (path, id, delayMs) => withFileLock(path, async () => {
79
+ order.push(`${id}-start`);
80
+ await new Promise((r) => setTimeout(r, delayMs));
81
+ order.push(`${id}-end`);
82
+ return id;
83
+ });
84
+ const start = Date.now();
85
+ const results = await Promise.all([
86
+ makeOp("/tmp/path-a.txt", "a", 50),
87
+ makeOp("/tmp/path-b.txt", "b", 50),
88
+ makeOp("/tmp/path-c.txt", "c", 50)
89
+ ]);
90
+ const elapsed = Date.now() - start;
91
+ expect(results).toEqual([
92
+ "a",
93
+ "b",
94
+ "c"
95
+ ]);
96
+ expect(elapsed).toBeLessThan(120);
97
+ const starts = order.filter((o) => o.endsWith("-start"));
98
+ const ends = order.filter((o) => o.endsWith("-end"));
99
+ expect(starts).toHaveLength(3);
100
+ expect(ends).toHaveLength(3);
101
+ });
102
+ it("handles same path resolved differently", async () => {
103
+ const order = [];
104
+ expect(await Promise.all([withFileLock("/tmp/same-file.txt", async () => {
105
+ order.push("abs-start");
106
+ await new Promise((r) => setTimeout(r, 20));
107
+ order.push("abs-end");
108
+ return "abs";
109
+ })])).toEqual(["abs"]);
110
+ });
111
+ it("releases lock after error", async () => {
112
+ const path = "/tmp/error-release.txt";
113
+ try {
114
+ await withFileLock(path, async () => {
115
+ throw new Error("first fails");
116
+ });
117
+ expect(true).toBe(false);
118
+ } catch (e) {
119
+ expect(e.message).toBe("first fails");
120
+ }
121
+ expect(await withFileLock(path, async () => {
122
+ return "second succeeds";
123
+ })).toBe("second succeeds");
124
+ });
125
+ it("handles rapid sequential calls on same path", async () => {
126
+ const path = "/tmp/rapid.txt";
127
+ let counter = 0;
128
+ expect(await Promise.all([
129
+ withFileLock(path, async () => ++counter),
130
+ withFileLock(path, async () => ++counter),
131
+ withFileLock(path, async () => ++counter)
132
+ ])).toEqual([
133
+ 1,
134
+ 2,
135
+ 3
136
+ ]);
137
+ expect(counter).toBe(3);
138
+ });
139
+ });
140
+ }
141
+ //#endregion
142
+ export { withFileLock };
@@ -0,0 +1,80 @@
1
+ //#region packages/core/output-buffer/index.d.ts
2
+ /**
3
+ * truncate an array to head + tail, returning formatted result.
4
+ * simpler than OutputBuffer — for when you have all items upfront (not streaming).
5
+ *
6
+ * @param items - array to truncate
7
+ * @param maxItems - total items to show (split evenly between head/tail)
8
+ * @returns { head, tail, truncated, truncatedCount }
9
+ */
10
+ declare function headTail<T>(items: T[], maxItems?: number): {
11
+ head: T[];
12
+ tail: T[];
13
+ truncated: T[];
14
+ truncatedCount: number;
15
+ };
16
+ /**
17
+ * format head+tail arrays with truncation marker.
18
+ * returns a single string with items joined by newlines.
19
+ */
20
+ declare function formatHeadTail<T>(items: T[], maxItems?: number, truncatedMsg?: (count: number) => string): string;
21
+ /**
22
+ * truncate raw text to head + tail by characters.
23
+ * for when you have a single string (not lines) that needs truncation.
24
+ */
25
+ declare function headTailChars(text: string, maxChars?: number): {
26
+ text: string;
27
+ truncated: boolean;
28
+ totalChars: number;
29
+ };
30
+ declare class OutputBuffer {
31
+ private maxHead;
32
+ private maxTail;
33
+ private head;
34
+ private tail;
35
+ private headComplete;
36
+ private pendingLine;
37
+ totalLines: number;
38
+ constructor(maxHead?: number, maxTail?: number);
39
+ /**
40
+ * add a chunk of output. handles partial lines at boundaries.
41
+ * chunks may end mid-line, so we buffer the incomplete part.
42
+ */
43
+ add(chunk: string): void;
44
+ /**
45
+ * add a complete line to the appropriate buffer.
46
+ * fills head first, then rolls tail.
47
+ */
48
+ private addLine;
49
+ /**
50
+ * finalize and format the output.
51
+ * returns the formatted text and count of truncated lines.
52
+ *
53
+ * small output (<= head + tail): dedupes overlap, no truncation marker
54
+ * large output: head + marker + tail
55
+ */
56
+ format(): {
57
+ text: string;
58
+ truncatedLines: number;
59
+ };
60
+ /**
61
+ * deduplicate overlapping head/tail for small outputs.
62
+ *
63
+ * when total lines <= maxHead + maxTail, the tail buffer
64
+ * may contain lines already in head. we merge them using
65
+ * positional overlap (not value matching) to avoid dropping
66
+ * legitimate lines when content has duplicates.
67
+ */
68
+ private dedupe;
69
+ /**
70
+ * get current buffer state for debugging.
71
+ */
72
+ debug(): {
73
+ head: string[];
74
+ tail: string[];
75
+ totalLines: number;
76
+ pendingLine: string;
77
+ };
78
+ }
79
+ //#endregion
80
+ export { OutputBuffer, formatHeadTail, headTail, headTailChars };