@nmzpy/pi-ember-stack 0.2.1 → 0.2.2

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 (30) hide show
  1. package/README.md +83 -83
  2. package/package.json +63 -59
  3. package/plugins/devin-auth/extensions/index.ts +23 -0
  4. package/plugins/devin-auth/src/cloud-direct/auth.ts +246 -246
  5. package/plugins/devin-auth/src/cloud-direct/catalog.ts +246 -246
  6. package/plugins/devin-auth/src/cloud-direct/chat.ts +1096 -1091
  7. package/plugins/devin-auth/src/cloud-direct/index.ts +41 -41
  8. package/plugins/devin-auth/src/cloud-direct/metadata.ts +78 -78
  9. package/plugins/devin-auth/src/cloud-direct/wire.ts +202 -202
  10. package/plugins/devin-auth/src/oauth/register-user.ts +174 -174
  11. package/plugins/devin-auth/src/oauth/types.ts +71 -71
  12. package/plugins/devin-auth/src/stream.ts +1 -1
  13. package/plugins/pi-compact-tools/index.ts +19 -1
  14. package/plugins/pi-compact-tools/renderer.ts +231 -61
  15. package/plugins/pi-custom-agents/index.ts +310 -102
  16. package/plugins/pi-custom-agents/questionnaire-tool.ts +14 -5
  17. package/plugins/pi-custom-agents/subagent/extensions/agents.ts +18 -1
  18. package/plugins/pi-custom-agents/subagent/extensions/index.ts +116 -224
  19. package/plugins/pi-custom-agents/subagent/extensions/model.ts +96 -96
  20. package/plugins/pi-custom-agents/subagent/extensions/render.ts +205 -1
  21. package/plugins/pi-custom-agents/subagent/extensions/runner.ts +241 -177
  22. package/plugins/pi-custom-agents/subagent/extensions/test/render.test.ts +145 -0
  23. package/plugins/pi-ember-fff/index.ts +275 -17
  24. package/plugins/pi-ember-fff/query.ts +170 -10
  25. package/plugins/pi-ember-fff/test/query.test.ts +157 -1
  26. package/plugins/pi-ember-fff/test/renderer.test.ts +367 -16
  27. package/plugins/pi-ember-tps/index.ts +27 -5
  28. package/plugins/pi-ember-ui/ember.json +3 -0
  29. package/plugins/pi-ember-ui/index.ts +223 -36
  30. package/plugins/pi-ember-ui/mode-colors.ts +36 -8
@@ -21,8 +21,14 @@ import type {
21
21
  } from "@ff-labs/fff-node";
22
22
  import { FileFinder } from "@ff-labs/fff-node";
23
23
  import { Type } from "@sinclair/typebox";
24
- import { buildQuery } from "./query.ts";
25
- import { getSharedRenderer } from "../pi-compact-tools/index.ts";
24
+ import {
25
+ buildExternalAllowlist,
26
+ buildQuery,
27
+ resolveExternalTarget,
28
+ type ExternalAllowlist,
29
+ type ExternalTarget,
30
+ } from "./query.ts";
31
+ import { getSharedRenderer, bashGrepInfo } from "../pi-compact-tools/index.ts";
26
32
 
27
33
  // ---------------------------------------------------------------------------
28
34
  // Constants
@@ -37,12 +43,17 @@ const MENTION_MAX_RESULTS = 20;
37
43
  // Cursor store — simple bounded Map for pagination cursors
38
44
  // ---------------------------------------------------------------------------
39
45
 
40
- const cursorCache = new Map<string, GrepCursor>();
46
+ interface CachedGrepCursor {
47
+ cursor: GrepCursor;
48
+ externalDir?: string;
49
+ }
50
+
51
+ const cursorCache = new Map<string, CachedGrepCursor>();
41
52
  let cursorCounter = 0;
42
53
 
43
- function storeCursor(cursor: GrepCursor): string {
54
+ function storeCursor(cursor: GrepCursor, externalDir?: string): string {
44
55
  const id = `fff_c${++cursorCounter}`;
45
- cursorCache.set(id, cursor);
56
+ cursorCache.set(id, { cursor, externalDir });
46
57
  if (cursorCache.size > 200) {
47
58
  const first = cursorCache.keys().next().value;
48
59
  if (first) cursorCache.delete(first);
@@ -50,7 +61,7 @@ function storeCursor(cursor: GrepCursor): string {
50
61
  return id;
51
62
  }
52
63
 
53
- function getCursor(id: string): GrepCursor | undefined {
64
+ function getCursor(id: string): CachedGrepCursor | undefined {
54
65
  return cursorCache.get(id);
55
66
  }
56
67
 
@@ -59,6 +70,7 @@ interface FindCursor {
59
70
  pattern: string;
60
71
  pageSize: number;
61
72
  nextPageIndex: number;
73
+ externalDir?: string;
62
74
  }
63
75
 
64
76
  const findCursorCache = new Map<string, FindCursor>();
@@ -231,6 +243,11 @@ export default function emberFffExtension(pi: ExtensionAPI) {
231
243
  let finderPromise: Promise<FileFinder> | null = null;
232
244
  let activeCwd = process.cwd();
233
245
 
246
+ let externalFinder: FileFinder | null = null;
247
+ let externalFinderDir: string | null = null;
248
+ let externalFinderPromise: Promise<FileFinder> | null = null;
249
+ let externalAllowlist: ExternalAllowlist = buildExternalAllowlist();
250
+
234
251
  const frecencyDbPath =
235
252
  (pi.getFlag("fff-frecency-db") as string | undefined) ??
236
253
  process.env.FFF_FRECENCY_DB ??
@@ -252,6 +269,15 @@ export default function emberFffExtension(pi: ExtensionAPI) {
252
269
  "FFF_ENABLE_ROOT_SCAN",
253
270
  );
254
271
 
272
+ const enableExternalAllow = (() => {
273
+ const flag = pi.getFlag("fff-external-allow");
274
+ if (typeof flag === "boolean") return flag;
275
+ if (typeof flag === "string") return flag === "true" || flag === "1";
276
+ const env = process.env.FFF_EXTERNAL_ALLOW;
277
+ if (env !== undefined) return env === "1" || env === "true";
278
+ return true; // default ON
279
+ })();
280
+
255
281
  function ensureFinder(cwd: string): Promise<FileFinder> {
256
282
  if (finder && !finder.isDestroyed && finderCwd === cwd)
257
283
  return Promise.resolve(finder);
@@ -295,6 +321,76 @@ export default function emberFffExtension(pi: ExtensionAPI) {
295
321
  }
296
322
  }
297
323
 
324
+ function ensureExternalFinder(dir: string): Promise<FileFinder> {
325
+ if (externalFinder && !externalFinder.isDestroyed && externalFinderDir === dir)
326
+ return Promise.resolve(externalFinder);
327
+ if (externalFinderPromise) return externalFinderPromise;
328
+
329
+ externalFinderPromise = (async () => {
330
+ if (externalFinder && !externalFinder.isDestroyed) {
331
+ externalFinder.destroy();
332
+ externalFinder = null;
333
+ externalFinderDir = null;
334
+ }
335
+
336
+ const result = FileFinder.create({
337
+ basePath: dir,
338
+ aiMode: true,
339
+ enableHomeDirScanning: false,
340
+ enableFsRootScanning: false,
341
+ });
342
+
343
+ if (!result.ok)
344
+ throw new Error(`Failed to create external FFF file finder: ${result.error}`);
345
+
346
+ externalFinder = result.value;
347
+ externalFinderDir = dir;
348
+ await externalFinder.waitForScan(15000);
349
+ return externalFinder;
350
+ })().finally(() => {
351
+ externalFinderPromise = null;
352
+ });
353
+
354
+ return externalFinderPromise;
355
+ }
356
+
357
+ function destroyExternalFinder() {
358
+ if (externalFinder && !externalFinder.isDestroyed) {
359
+ externalFinder.destroy();
360
+ externalFinder = null;
361
+ externalFinderDir = null;
362
+ }
363
+ }
364
+
365
+ /**
366
+ * If params.path targets an allowlisted external directory, return the
367
+ * external finder and the query scoped to that dir. Otherwise return
368
+ * the workspace finder and workspace query.
369
+ */
370
+ async function resolveFinderAndQuery(
371
+ pathParam: string | undefined,
372
+ pattern: string,
373
+ exclude: string | string[] | undefined,
374
+ ): Promise<{ finder: FileFinder; query: string }> {
375
+ if (enableExternalAllow && externalAllowlist.entries.length > 0) {
376
+ const target = resolveExternalTarget(pathParam, externalAllowlist);
377
+ if (target) {
378
+ const f = await ensureExternalFinder(target.entry.dir);
379
+ const query = buildQuery(
380
+ target.relativePath || undefined,
381
+ pattern,
382
+ exclude,
383
+ target.entry.dir,
384
+ externalAllowlist,
385
+ );
386
+ return { finder: f, query };
387
+ }
388
+ }
389
+ const f = await ensureFinder(activeCwd);
390
+ const query = buildQuery(pathParam, pattern, exclude, activeCwd, externalAllowlist);
391
+ return { finder: f, query };
392
+ }
393
+
298
394
  async function getMentionItems(
299
395
  query: string,
300
396
  signal: AbortSignal,
@@ -379,6 +475,12 @@ export default function emberFffExtension(pi: ExtensionAPI) {
379
475
  type: "boolean",
380
476
  });
381
477
 
478
+ pi.registerFlag("fff-external-allow", {
479
+ description:
480
+ "Allow grep/find to search the auto-detected @earendil-works/pi-coding-agent package directory via the ./pi-coding-agent alias (also: FFF_EXTERNAL_ALLOW env; default: true)",
481
+ type: "boolean",
482
+ });
483
+
382
484
  pi.on("session_start", async (_event, ctx) => {
383
485
  try {
384
486
  activeCwd = ctx.cwd;
@@ -394,6 +496,134 @@ export default function emberFffExtension(pi: ExtensionAPI) {
394
496
 
395
497
  pi.on("session_shutdown", async () => {
396
498
  destroyFinder();
499
+ destroyExternalFinder();
500
+ });
501
+
502
+ // --- bash grep → ripgrep rewrite ---
503
+
504
+ /**
505
+ * Rewrite a bash grep command to an equivalent rg (ripgrep) command.
506
+ * Returns the rewritten command, or undefined if the command is not a
507
+ * simple grep invocation that can be safely converted.
508
+ */
509
+ function rewriteGrepToRg(command: string): string | undefined {
510
+ const cdMatch = /^(\s*cd\s+([^\s&]+)\s*&&\s*)(.*)$/.exec(command);
511
+ const prefix = cdMatch?.[1] ?? "";
512
+ const body = cdMatch?.[3] ?? command;
513
+ if (!/^\s*grep\b/.test(body)) return undefined;
514
+ const beforePipe = body.split(/\s+\|/)[0].trim();
515
+ const afterGrep = beforePipe.replace(/^\s*grep\s+/, "");
516
+ // Strip stderr redirects (2>/dev/null, 2>&1, etc.) so they don't
517
+ // become false path arguments.
518
+ const cleaned = afterGrep.replace(/\s+2>(?:&\d+|\/dev\/null|\S+)/g, "").trim();
519
+ const tokens = cleaned.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g);
520
+ if (!tokens) return undefined;
521
+
522
+ const rgArgs: string[] = ["rg"];
523
+ let pattern: string | undefined;
524
+ let paths: string[] = [];
525
+ let includeGlobs: string[] = [];
526
+ let excludeGlobs: string[] = [];
527
+ let caseInsensitive = false;
528
+ let fixedStrings = false;
529
+ let wordRegex = false;
530
+ let countOnly = false;
531
+ let filesOnly = false;
532
+ let invertMatch = false;
533
+ let contextAfter = 0;
534
+ let contextBefore = 0;
535
+ let contextBoth = 0;
536
+ let lineNumber = false;
537
+ let noFilename = false;
538
+
539
+ for (let i = 0; i < tokens.length; i++) {
540
+ const tok = tokens[i];
541
+ if (tok === "-i" || tok === "--ignore-case") { caseInsensitive = true; continue; }
542
+ if (tok === "-F" || tok === "--fixed-strings") { fixedStrings = true; continue; }
543
+ if (tok === "-w" || tok === "--word-regexp") { wordRegex = true; continue; }
544
+ if (tok === "-c" || tok === "--count") { countOnly = true; continue; }
545
+ if (tok === "-l" || tok === "--files-with-matches") { filesOnly = true; continue; }
546
+ if (tok === "-v" || tok === "--invert-match") { invertMatch = true; continue; }
547
+ if (tok === "-n" || tok === "--line-number") { lineNumber = true; continue; }
548
+ if (tok === "-h" || tok === "--no-filename") { noFilename = true; continue; }
549
+ if (tok === "-E" || tok === "--extended-regexp") { continue; }
550
+ if (tok === "-r" || tok === "-R" || tok === "--recursive") { continue; }
551
+ if (tok === "-s" || tok === "--no-messages") { continue; }
552
+ if (tok === "-A") { contextAfter = parseInt(tokens[++i] ?? "0", 10) || 0; continue; }
553
+ if (tok === "-B") { contextBefore = parseInt(tokens[++i] ?? "0", 10) || 0; continue; }
554
+ if (tok === "-C") { contextBoth = parseInt(tokens[++i] ?? "0", 10) || 0; continue; }
555
+ if (tok.startsWith("-A")) { contextAfter = parseInt(tok.slice(2), 10) || 0; continue; }
556
+ if (tok.startsWith("-B")) { contextBefore = parseInt(tok.slice(2), 10) || 0; continue; }
557
+ if (tok.startsWith("-C")) { contextBoth = parseInt(tok.slice(2), 10) || 0; continue; }
558
+ if (tok === "--include") { includeGlobs.push(tokens[++i] ?? ""); continue; }
559
+ if (tok.startsWith("--include=")) { includeGlobs.push(tok.slice(10)); continue; }
560
+ if (tok === "--exclude") { excludeGlobs.push(tokens[++i] ?? ""); continue; }
561
+ if (tok.startsWith("--exclude=")) { excludeGlobs.push(tok.slice(10)); continue; }
562
+ if (tok === "--exclude-dir") { excludeGlobs.push(`${tokens[++i] ?? ""}/`); continue; }
563
+ if (tok.startsWith("--exclude-dir=")) { excludeGlobs.push(`${tok.slice(13)}/`); continue; }
564
+ // Handle combined short flags like -rn, -in, -rnI, etc.
565
+ if (/^-[a-zA-Z]{2,}$/.test(tok)) {
566
+ let bail = false;
567
+ for (const ch of tok.slice(1)) {
568
+ switch (ch) {
569
+ case "i": caseInsensitive = true; break;
570
+ case "F": fixedStrings = true; break;
571
+ case "w": wordRegex = true; break;
572
+ case "c": countOnly = true; break;
573
+ case "l": filesOnly = true; break;
574
+ case "v": invertMatch = true; break;
575
+ case "n": lineNumber = true; break;
576
+ case "h": noFilename = true; break;
577
+ case "E": case "r": case "R": case "s": break;
578
+ default: bail = true; break;
579
+ }
580
+ if (bail) break;
581
+ }
582
+ if (bail) return undefined;
583
+ continue;
584
+ }
585
+ if (tok.startsWith("-")) {
586
+ // Unknown flag — bail to be safe.
587
+ return undefined;
588
+ }
589
+ if (pattern === undefined) {
590
+ pattern = tok.replace(/^["']|["']$/g, "");
591
+ } else {
592
+ paths.push(tok.replace(/^["']|["']$/g, ""));
593
+ }
594
+ }
595
+ if (pattern === undefined) return undefined;
596
+
597
+ if (caseInsensitive) rgArgs.push("-i");
598
+ if (fixedStrings) rgArgs.push("-F");
599
+ if (wordRegex) rgArgs.push("-w");
600
+ if (countOnly) rgArgs.push("-c");
601
+ if (filesOnly) rgArgs.push("-l");
602
+ if (invertMatch) rgArgs.push("-v");
603
+ if (lineNumber || noFilename) rgArgs.push("-n");
604
+ if (noFilename) rgArgs.push("--no-filename");
605
+ if (contextAfter > 0) rgArgs.push("-A", String(contextAfter));
606
+ if (contextBefore > 0) rgArgs.push("-B", String(contextBefore));
607
+ if (contextBoth > 0) rgArgs.push("-C", String(contextBoth));
608
+ for (const g of includeGlobs) rgArgs.push("-g", g);
609
+ for (const g of excludeGlobs) rgArgs.push("-g", `!${g}`);
610
+ // rg is recursive by default; add -- to separate pattern from paths.
611
+ rgArgs.push("--", pattern);
612
+ for (const p of paths) rgArgs.push(p);
613
+
614
+ const rgCmd = rgArgs.map((a) => {
615
+ return /[\s'"!]/.test(a) ? `'${a.replace(/'/g, "'\\''")}'` : a;
616
+ }).join(" ");
617
+ return prefix + rgCmd;
618
+ }
619
+
620
+ pi.on("tool_call", (event: any) => {
621
+ if (event.toolName !== "bash") return;
622
+ const command = event.input?.command;
623
+ if (typeof command !== "string") return;
624
+ if (!bashGrepInfo(command)) return;
625
+ const rewritten = rewriteGrepToRg(command);
626
+ if (rewritten) event.input.command = rewritten;
397
627
  });
398
628
 
399
629
  // --- grep tool ---
@@ -405,7 +635,7 @@ export default function emberFffExtension(pi: ExtensionAPI) {
405
635
  path: Type.Optional(
406
636
  Type.String({
407
637
  description:
408
- "Repo-relative path constraint. Directory prefix (src/ or src/foo/), bare filename with extension (main.rs), or glob (*.ts, src/**/*.cc, {src,lib}/**). Applied to the full repo-relative path.",
638
+ "Repo-relative path constraint. Directory prefix (src/ or src/foo/), bare filename with extension (main.rs), or glob (*.ts, src/**/*.cc, {src,lib}/**). Applied to the full repo-relative path. Use ./pi-coding-agent to search the installed @earendil-works/pi-coding-agent package docs and examples.",
409
639
  }),
410
640
  ),
411
641
  exclude: Type.Optional(
@@ -450,9 +680,24 @@ export default function emberFffExtension(pi: ExtensionAPI) {
450
680
  async execute(_toolCallId, params, signal) {
451
681
  if (signal?.aborted) throw new Error("Operation aborted");
452
682
 
453
- const f = await ensureFinder(activeCwd);
683
+ const cachedGrepCursor = params.cursor ? getCursor(params.cursor) : undefined;
684
+ let f: FileFinder;
685
+ let query: string;
686
+ if (cachedGrepCursor) {
687
+ f = cachedGrepCursor.externalDir
688
+ ? await ensureExternalFinder(cachedGrepCursor.externalDir)
689
+ : await ensureFinder(activeCwd);
690
+ query = buildQuery(params.path, params.pattern, params.exclude, activeCwd, externalAllowlist);
691
+ } else {
692
+ const resolved = await resolveFinderAndQuery(
693
+ params.path,
694
+ params.pattern,
695
+ params.exclude,
696
+ );
697
+ f = resolved.finder;
698
+ query = resolved.query;
699
+ }
454
700
  const effectiveLimit = Math.max(1, params.limit ?? DEFAULT_GREP_LIMIT);
455
- const query = buildQuery(params.path, params.pattern, params.exclude, activeCwd);
456
701
  const hasRegexSyntax =
457
702
  params.pattern !== params.pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
458
703
  let mode: GrepMode = hasRegexSyntax ? "regex" : "plain";
@@ -489,7 +734,7 @@ export default function emberFffExtension(pi: ExtensionAPI) {
489
734
  mode,
490
735
  smartCase,
491
736
  maxMatchesPerFile: Math.min(effectiveLimit, 50),
492
- cursor: (params.cursor ? getCursor(params.cursor) : null) ?? null,
737
+ cursor: cachedGrepCursor?.cursor ?? null,
493
738
  beforeContext: params.context ?? 0,
494
739
  afterContext: params.context ?? 0,
495
740
  classifyDefinitions: true,
@@ -523,7 +768,7 @@ export default function emberFffExtension(pi: ExtensionAPI) {
523
768
  notices.push(`Invalid regex: ${result.regexFallbackError}, used literal match`);
524
769
  }
525
770
  if (result.nextCursor) {
526
- notices.push(`Continue with cursor="${storeCursor(result.nextCursor)}"`);
771
+ notices.push(`Continue with cursor="${storeCursor(result.nextCursor, f === externalFinder ? externalFinderDir ?? undefined : undefined)}"`);
527
772
  }
528
773
 
529
774
  if (notices.length > 0) output += `\n\n[${notices.join(". ")}]`;
@@ -557,7 +802,7 @@ export default function emberFffExtension(pi: ExtensionAPI) {
557
802
  path: Type.Optional(
558
803
  Type.String({
559
804
  description:
560
- "Repo-relative path constraint. Directory prefix (src/ or src/foo/), bare filename with extension (main.rs), or glob (*.ts, src/**/*.cc, {src,lib}/**). Applied to the full repo-relative path.",
805
+ "Repo-relative path constraint. Directory prefix (src/ or src/foo/), bare filename with extension (main.rs), or glob (*.ts, src/**/*.cc, {src,lib}/**). Applied to the full repo-relative path. Use ./pi-coding-agent to search the installed @earendil-works/pi-coding-agent package docs and examples.",
561
806
  }),
562
807
  ),
563
808
  exclude: Type.Optional(
@@ -595,15 +840,27 @@ export default function emberFffExtension(pi: ExtensionAPI) {
595
840
  async execute(_toolCallId, params, signal) {
596
841
  if (signal?.aborted) throw new Error("Operation aborted");
597
842
 
598
- const f = await ensureFinder(activeCwd);
599
-
600
843
  const resumed = params.cursor ? getFindCursor(params.cursor) : undefined;
601
844
  const effectiveLimit = resumed
602
845
  ? resumed.pageSize
603
846
  : Math.max(1, params.limit ?? DEFAULT_FIND_LIMIT);
604
- const query = resumed
605
- ? resumed.query
606
- : buildQuery(params.path, params.pattern, params.exclude, activeCwd);
847
+
848
+ let f: FileFinder;
849
+ let query: string;
850
+ if (resumed) {
851
+ f = resumed.externalDir
852
+ ? await ensureExternalFinder(resumed.externalDir)
853
+ : await ensureFinder(activeCwd);
854
+ query = resumed.query;
855
+ } else {
856
+ const resolved = await resolveFinderAndQuery(
857
+ params.path,
858
+ params.pattern,
859
+ params.exclude,
860
+ );
861
+ f = resolved.finder;
862
+ query = resolved.query;
863
+ }
607
864
  const pattern = resumed ? resumed.pattern : params.pattern;
608
865
  const pageIndex = resumed?.nextPageIndex ?? 0;
609
866
 
@@ -634,6 +891,7 @@ export default function emberFffExtension(pi: ExtensionAPI) {
634
891
  pattern,
635
892
  pageSize: effectiveLimit,
636
893
  nextPageIndex: pageIndex + 1,
894
+ externalDir: f === externalFinder ? externalFinderDir ?? undefined : undefined,
637
895
  });
638
896
  notices.push(
639
897
  `${remaining} more match${remaining === 1 ? "" : "es"} available. cursor="${cursorId}" to continue`,
@@ -1,21 +1,179 @@
1
1
  import path from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // External allowlist — auto-detected pi-coding-agent package directory
6
+ // ---------------------------------------------------------------------------
7
+
8
+ /** The default alias for the pi-coding-agent package. */
9
+ export const PI_CODING_AGENT_ALIAS = "pi-coding-agent";
10
+
11
+ export type ExternalAllowlistEntry = {
12
+ /** User-facing alias, e.g. "pi-coding-agent" (no leading ./). */
13
+ alias: string;
14
+ /** Auto-detected absolute directory. */
15
+ dir: string;
16
+ };
17
+
18
+ export type ExternalAllowlist = {
19
+ entries: readonly ExternalAllowlistEntry[];
20
+ /** Resolve a user-facing path constraint to an allowlisted dir, or undefined. */
21
+ resolve(pathConstraint: string): ExternalAllowlistEntry | undefined;
22
+ /** True if an absolute path is inside an allowlisted dir. */
23
+ covers(absolutePath: string): boolean;
24
+ };
25
+
26
+ export type ExternalTarget = {
27
+ entry: ExternalAllowlistEntry;
28
+ /** Path relative to entry.dir, forward-slashed, no leading ./, or "" for the whole dir. */
29
+ relativePath: string;
30
+ };
31
+
32
+ /**
33
+ * Auto-detect the installed @earendil-works/pi-coding-agent package root.
34
+ * Returns undefined if detection fails (package not installed or
35
+ * import.meta.resolve unavailable).
36
+ */
37
+ function detect_pi_package_dir(): string | undefined {
38
+ try {
39
+ const resolved = import.meta.resolve("@earendil-works/pi-coding-agent");
40
+ const entryPath = fileURLToPath(resolved); // .../dist/index.js
41
+ return path.dirname(path.dirname(entryPath)); // package root (parent of dist/)
42
+ } catch {
43
+ return undefined;
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Build the external allowlist by auto-detecting the installed
49
+ * @earendil-works/pi-coding-agent package directory. Returns an empty
50
+ * allowlist if detection fails (fail-safe: no external access).
51
+ */
52
+ export function buildExternalAllowlist(): ExternalAllowlist {
53
+ const dir = detect_pi_package_dir();
54
+ const entries: ExternalAllowlistEntry[] = [];
55
+ if (dir) {
56
+ entries.push({ alias: PI_CODING_AGENT_ALIAS, dir });
57
+ }
58
+ return {
59
+ entries,
60
+ resolve(pathConstraint: string): ExternalAllowlistEntry | undefined {
61
+ let trimmed = pathConstraint.trim();
62
+ if (!trimmed) return undefined;
63
+ if (trimmed.startsWith("./")) trimmed = trimmed.slice(2);
64
+ for (const entry of entries) {
65
+ if (trimmed === entry.alias) return entry;
66
+ if (
67
+ trimmed.startsWith(entry.alias + "/") ||
68
+ trimmed.startsWith(entry.alias + path.sep)
69
+ ) {
70
+ return entry;
71
+ }
72
+ }
73
+ return undefined;
74
+ },
75
+ covers(absolutePath: string): boolean {
76
+ for (const entry of entries) {
77
+ const rel = path.relative(entry.dir, absolutePath);
78
+ if (rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel))) {
79
+ return true;
80
+ }
81
+ }
82
+ return false;
83
+ },
84
+ };
85
+ }
86
+
87
+ /**
88
+ * If `pathConstraint` targets an allowlisted external directory, return
89
+ * the resolved entry and the sub-path relative to that dir. Otherwise
90
+ * undefined. This lets the tool layer route to a secondary FileFinder
91
+ * before normalization.
92
+ */
93
+ export function resolveExternalTarget(
94
+ pathConstraint: string | undefined,
95
+ allowlist: ExternalAllowlist,
96
+ ): ExternalTarget | undefined {
97
+ if (!pathConstraint) return undefined;
98
+ let trimmed = pathConstraint.trim();
99
+ if (!trimmed) return undefined;
100
+
101
+ // Absolute path — check if it's under an allowlisted dir.
102
+ if (path.isAbsolute(trimmed)) {
103
+ if (!allowlist.covers(trimmed)) return undefined;
104
+ for (const entry of allowlist.entries) {
105
+ const rel = path.relative(entry.dir, trimmed).replaceAll(path.sep, "/");
106
+ if (rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel))) {
107
+ return { entry, relativePath: rel };
108
+ }
109
+ }
110
+ return undefined;
111
+ }
112
+
113
+ // Strip leading ./
114
+ if (trimmed.startsWith("./")) trimmed = trimmed.slice(2);
115
+
116
+ // Alias-prefixed relative path
117
+ for (const entry of allowlist.entries) {
118
+ if (trimmed === entry.alias) {
119
+ return { entry, relativePath: "" };
120
+ }
121
+ // Check alias/ or alias\ as a prefix (cross-platform)
122
+ const aliasPrefixFwd = entry.alias + "/";
123
+ const aliasPrefixSep = entry.alias + path.sep;
124
+ if (trimmed.startsWith(aliasPrefixFwd) || trimmed.startsWith(aliasPrefixSep)) {
125
+ // Normalize to forward slashes for FFF
126
+ const remainder = trimmed.startsWith(aliasPrefixFwd)
127
+ ? trimmed.slice(aliasPrefixFwd.length)
128
+ : trimmed.slice(aliasPrefixSep.length).replaceAll(path.sep, "/");
129
+ return { entry, relativePath: remainder };
130
+ }
131
+ }
132
+
133
+ return undefined;
134
+ }
135
+
136
+ // ---------------------------------------------------------------------------
137
+ // Path constraint normalization
138
+ // ---------------------------------------------------------------------------
2
139
 
3
140
  export function normalizePathConstraint(
4
141
  pathConstraint: string,
5
142
  cwd = process.cwd(),
143
+ allowlist?: ExternalAllowlist,
6
144
  ): string | null {
7
145
  let trimmed = pathConstraint.trim();
8
146
  if (!trimmed) return trimmed;
9
147
 
10
148
  if (path.isAbsolute(trimmed)) {
11
- const relative = path.relative(cwd, trimmed).replaceAll(path.sep, "/");
12
- if (relative === "") return null;
13
- if (relative.startsWith("../") || relative === ".." || path.isAbsolute(relative)) {
14
- throw new Error(
15
- `Path constraint must be relative to the workspace: ${pathConstraint}`,
16
- );
149
+ // Allowlisted external absolute path — do not throw.
150
+ if (allowlist?.covers(trimmed)) {
151
+ const target = resolveExternalTarget(trimmed, allowlist);
152
+ if (target) {
153
+ // Return the relative path within the allowlisted dir.
154
+ // If bare dir, return null (whole dir). Otherwise return the
155
+ // relative subpath forward-slashed.
156
+ if (!target.relativePath) return null;
157
+ trimmed = target.relativePath;
158
+ // Jump to the post-absolute-path normalization logic below.
159
+ }
160
+ } else {
161
+ const relative = path.relative(cwd, trimmed).replaceAll(path.sep, "/");
162
+ if (relative === "") return null;
163
+ if (relative.startsWith("../") || relative === ".." || path.isAbsolute(relative)) {
164
+ throw new Error(
165
+ `Path constraint must be relative to the workspace: ${pathConstraint}`,
166
+ );
167
+ }
168
+ trimmed = relative;
169
+ }
170
+ } else if (allowlist) {
171
+ // Check for alias-prefixed relative path (e.g. ./pi-coding-agent/docs).
172
+ const target = resolveExternalTarget(trimmed, allowlist);
173
+ if (target) {
174
+ if (!target.relativePath) return null;
175
+ trimmed = target.relativePath;
17
176
  }
18
- trimmed = relative;
19
177
  }
20
178
 
21
179
  if (trimmed === "." || trimmed === "./") return null;
@@ -52,6 +210,7 @@ export function normalizePathConstraint(
52
210
  export function normalizeExcludes(
53
211
  exclude: string | string[] | undefined,
54
212
  cwd = process.cwd(),
213
+ allowlist?: ExternalAllowlist,
55
214
  ): string[] {
56
215
  if (!exclude) return [];
57
216
  const list = Array.isArray(exclude) ? exclude : [exclude];
@@ -63,7 +222,7 @@ export function normalizeExcludes(
63
222
  .filter(Boolean);
64
223
  for (const p of parts) {
65
224
  const stripped = p.startsWith("!") ? p.slice(1) : p;
66
- const normalized = normalizePathConstraint(stripped, cwd);
225
+ const normalized = normalizePathConstraint(stripped, cwd, allowlist);
67
226
  if (normalized) out.push(`!${normalized}`);
68
227
  }
69
228
  }
@@ -75,13 +234,14 @@ export function buildQuery(
75
234
  pattern: string,
76
235
  exclude?: string | string[],
77
236
  cwd = process.cwd(),
237
+ allowlist?: ExternalAllowlist,
78
238
  ): string {
79
239
  const parts: string[] = [];
80
240
  if (path) {
81
- const pathConstraint = normalizePathConstraint(path, cwd);
241
+ const pathConstraint = normalizePathConstraint(path, cwd, allowlist);
82
242
  if (pathConstraint) parts.push(pathConstraint);
83
243
  }
84
- parts.push(...normalizeExcludes(exclude, cwd));
244
+ parts.push(...normalizeExcludes(exclude, cwd, allowlist));
85
245
  parts.push(pattern);
86
246
  return parts.join(" ");
87
247
  }