@plannotator/pi-extension 0.28.50 → 0.28.51

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.
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import { childEnv } from "./child-env.ts";
10
- import { join } from "path";
10
+ import { isAbsolute, join, resolve } from "path";
11
11
  import { getPlannotatorDataDir } from "./data-dir.ts";
12
12
  import {
13
13
  readFileSync,
@@ -559,6 +559,20 @@ export interface PlannotatorConfig {
559
559
  * a tagged machine, which has no owner. Config only. Default: none.
560
560
  */
561
561
  inboxTailscaleAllow?: string[];
562
+ /**
563
+ * Launch agents from the Inbox (compose's "New Claude Code session";
564
+ * packages/server/inbox-launch.ts). `enabled`: config only, default false,
565
+ * off, compose, the thread and every route are as before.
566
+ * `fullPermissions`: Settings → Agents' "Start new sessions with full
567
+ * permissions" (bypass mode); never set, a launch follows the person's
568
+ * login shell (CLAUDE_CODE_EVEN_MORE_DANGEROUSLY_SKIP_PERMISSIONS). `model`: config only, a model name such as
569
+ * `haiku` for each launch's settings file (the manual smoke); anything that
570
+ * is not a plain model name is ignored. `scratchDir`: Settings → Agents'
571
+ * "Scratch folders", where "New scratch folder" makes each new folder (an
572
+ * absolute path; default `<dataDir>/scratch`). Moving it applies to new
573
+ * folders only.
574
+ */
575
+ inboxLaunch?: { enabled?: boolean; fullPermissions?: boolean; model?: string; scratchDir?: string };
562
576
  /**
563
577
  * Inject a Plannotator Flavored Markdown reminder into every EnterPlanMode
564
578
  * call so the agent is aware it can enrich plans with code-file links,
@@ -1218,6 +1232,24 @@ export function resolveDefaultDiffTypeFor(
1218
1232
  return v === 'jj-current' || v === 'jj-last' || v === 'jj-line' || v === 'jj-evolog' || v === 'jj-all' ? v : 'jj-current';
1219
1233
  }
1220
1234
 
1235
+ /** A model name as Claude Code takes it (`haiku`, `claude-opus-4-1`, `opus[1m]`): letters, digits, `.`, `_`, `-`, `[`, `]`. */
1236
+ const LAUNCH_MODEL = /^[\w.\-[\]]{1,64}$/;
1237
+
1238
+ /**
1239
+ * config.inboxLaunch, coerced: off unless `enabled` is true; `model` only when it is a plain model name;
1240
+ * `scratchDir` only when it is an absolute path other than `/` (null: the default, `<dataDir>/scratch`).
1241
+ */
1242
+ export function resolveInboxLaunch(config: PlannotatorConfig): { enabled: boolean; fullPermissions: boolean | null; model: string | null; scratchDir: string | null } {
1243
+ const value = config.inboxLaunch && typeof config.inboxLaunch === "object" ? config.inboxLaunch : {};
1244
+ return {
1245
+ enabled: coerceConfigBoolean(value.enabled, false),
1246
+ // null: the person never set the switch (a launch follows their login shell).
1247
+ fullPermissions: parseConfigBoolean(value.fullPermissions) ?? null,
1248
+ model: typeof value.model === "string" && LAUNCH_MODEL.test(value.model) ? value.model : null,
1249
+ scratchDir: typeof value.scratchDir === "string" && isAbsolute(value.scratchDir) && resolve(value.scratchDir) !== resolve("/") ? resolve(value.scratchDir) : null,
1250
+ };
1251
+ }
1252
+
1221
1253
  /**
1222
1254
  * Coerce a config.json value that should be a boolean. JSON parsing preserves
1223
1255
  * whatever type the user typed, so a hand-edited `"false"` (quoted) arrives as
@@ -113,6 +113,19 @@ export function parseDiffToFiles(rawPatch: string): DiffFile[] {
113
113
  return files;
114
114
  }
115
115
 
116
+ /** The two blobs a file's patch runs between (its `index` line). */
117
+ export interface PatchBlobIds {
118
+ oldId: string;
119
+ newId: string;
120
+ }
121
+
122
+ /** A file's `index` blob ids; null when there is no such line (GitLab's rebuilt patches) or a side does not exist (all zeros). */
123
+ export function patchBlobIds(patch: string): PatchBlobIds | null {
124
+ const ids = /^index ([0-9a-f]{7,64})\.\.([0-9a-f]{7,64})/m.exec(patch);
125
+ if (!ids || /^0+$/.test(ids[1]!) || /^0+$/.test(ids[2]!)) return null;
126
+ return { oldId: ids[1]!, newId: ids[2]! };
127
+ }
128
+
116
129
  /** One line of a hunk, numbered on each side it exists on. `text` drops the ` `/`+`/`-` prefix. */
117
130
  export type DiffHunkLine =
118
131
  | { kind: "context"; oldLine: number; newLine: number; text: string }
@@ -6,10 +6,10 @@
6
6
  import type { GuideViewerAssets } from "./guide-format.ts";
7
7
 
8
8
  export const GUIDE_VIEWER_MANIFEST: Omit<GuideViewerAssets, "baseUrl"> = {
9
- js: "viewer.DOAD0J-z.js",
10
- css: "viewer.BNAWy9fE.css",
11
- jsIntegrity: "sha384-gTRUeyKROuoVEcrx0xREkS1ITbCZwah9MkDjsRfVMrCEgtGa2ZXGd3emZa+oeVXh",
12
- cssIntegrity: "sha384-X4c5I/iPKAqFtOuCQJJxlH1IZ5rDhT9nNBxQJ9JwjJ1W8ryzDZPb52INO+uGg9Cx",
9
+ js: "viewer.Bh38x7eb.js",
10
+ css: "viewer.DQlAZIQO.css",
11
+ jsIntegrity: "sha384-kTcI5/tmk5lJy6WIKj+Q8x8Revf/kzEzg/GObKZdig0kdL2tV/wbGyy1KaNm1aG5",
12
+ cssIntegrity: "sha384-GCY67iUks9B4xUXsjYkdSroZmJDmLMOtQFnvhshUgakVdrt9dCHOGgKaEw2UNxE7",
13
13
  langs: {
14
14
  "astro": "chunks/astro.Ts5EKq2l.js",
15
15
  "c": "chunks/c.BIGW1oBm.js",
@@ -42,6 +42,8 @@ import {
42
42
  INBOX_BRIDGE_EVENT_PATH,
43
43
  INBOX_BRIDGE_POLL_PATH,
44
44
  INBOX_SEND_NOW_POLL_FIELD,
45
+ INBOX_STEER_POLLS_POLL_FIELD,
46
+ inboxClaimName,
45
47
  INBOX_TAGS_POLL_FIELD,
46
48
  INBOX_TOOL_NAME,
47
49
  inboxHealthPeeks,
@@ -94,13 +96,12 @@ function rememberedToolsFileOf(dataDir: string): string {
94
96
  }
95
97
 
96
98
  /**
97
- * The claim folder of one delivery. A reply that tags agents goes to several
98
- * sessions, which may share this data dir (two Pi sessions, Pi and OpenCode):
99
- * a tagged session's claim names its session too, so each is delivered once.
99
+ * The claim folder of one delivery. A reply can go to several sessions, which may share this data dir (two Pi
100
+ * sessions, Pi and OpenCode): a reply that tags agents, or one moved after its session stopped holding the claim. So
101
+ * the claim names the session too, and each is delivered once.
100
102
  */
101
- function claimDirOf(dataDir: string, replyId: string, taggedSession?: string | null): string {
102
- const name = taggedSession ? `${replyId}.${taggedSession.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 120)}` : replyId;
103
- return join(dataDir, "inbox", "claims", name);
103
+ function claimDirOf(dataDir: string, replyId: string, session: string): string {
104
+ return join(dataDir, "inbox", "claims", inboxClaimName(replyId, session));
104
105
  }
105
106
 
106
107
  /** One session's lease on one host: which of its processes polls and delivers. */
@@ -659,11 +660,13 @@ export class InboxWake {
659
660
  (this.steerable || busy === false);
660
661
  if (!this.delivering && this.pending.size > 0 && (this.idleTicks >= 2 || urgent)) {
661
662
  this.delivering = true;
662
- try {
663
- await this.deliverNext(this.idleTicks < 2 && urgent);
664
- } finally {
665
- this.delivering = false;
666
- }
663
+ // Not awaited: a steer resolves only when the message enters the run, which a long tool call can hold for
664
+ // minutes, and the tick has to go on polling meanwhile, or the session reads as ended (the Claude Code mod's way).
665
+ void this.deliverNext(this.idleTicks < 2 && urgent)
666
+ .catch(() => undefined)
667
+ .finally(() => {
668
+ this.delivering = false;
669
+ });
667
670
  }
668
671
  }
669
672
 
@@ -733,6 +736,7 @@ export class InboxWake {
733
736
  started_at: this.startedAt,
734
737
  ...(this.busy === null ? {} : { busy: this.busy, idle_since: this.idleSince }),
735
738
  ...(this.steerable ? { [INBOX_SEND_NOW_POLL_FIELD]: true } : {}),
739
+ [INBOX_STEER_POLLS_POLL_FIELD]: true,
736
740
  // Tags (approval "inbox-tag-agents"): this connection words a reply it was tagged in truthfully.
737
741
  [INBOX_TAGS_POLL_FIELD]: true,
738
742
  // What this process loaded (additive): the Inbox says when the session runs an older one.
@@ -757,6 +761,9 @@ export class InboxWake {
757
761
  }
758
762
  this.backoffMs = 0;
759
763
  this.retryAt = 0;
764
+ // What the Inbox stopped handing out is done with here: handed out again (the reply moved back), it is new.
765
+ const handedOut = new Set(commands.map((command) => command.id));
766
+ for (const id of this.settled) if (!handedOut.has(id)) this.settled.delete(id);
760
767
  for (const command of commands) {
761
768
  // A later poll may carry `now` (the person pressed Send now): the newest command wins.
762
769
  if (!this.settled.has(command.id)) this.pending.set(command.id, command);
@@ -777,14 +784,17 @@ export class InboxWake {
777
784
  * running turn when one runs (`steer`), else as a turn at once.
778
785
  */
779
786
  private async deliverNext(urgent = false): Promise<void> {
780
- const command = this.pending.values().next().value as InboxReplyCommand | undefined;
781
- if (!command) return;
787
+ const waiting = this.pending.values().next().value as InboxReplyCommand | undefined;
788
+ if (!waiting) return;
782
789
  // Still waiting? An agent may have read it through the tool meanwhile, or
783
790
  // another process delivered it.
784
791
  const fresh = await this.pendingNow(0);
785
792
  if (fresh === null || this.disposed) return;
786
- if (!fresh.some((candidate) => candidate.id === command.id)) {
787
- this.settle(command.id);
793
+ // What the Inbox says now is what goes in: a reply can have moved here since it was first handed out.
794
+ const command = fresh.find((candidate) => candidate.id === waiting.id);
795
+ if (!command) {
796
+ // Not settled: nothing went in here, so if the reply moves back before the next poll, it goes in.
797
+ this.pending.delete(waiting.id);
788
798
  return;
789
799
  }
790
800
  const busy = await this.target.isBusy();
@@ -795,7 +805,12 @@ export class InboxWake {
795
805
  }
796
806
  // The person may have moved to another process since the last tick.
797
807
  if (this.disposed || !this.holdLease(Date.now())) return;
798
- const claim = claimDirOf(this.connection.dataDir, command.id, command.tagged ? this.sessionId : null);
808
+ // An older version's claim, by the reply id alone: that process took it (the Inbox removes it when it moves the reply).
809
+ if (!command.tagged && existsSync(join(this.connection.dataDir, "inbox", "claims", command.id))) {
810
+ this.settle(command.id);
811
+ return;
812
+ }
813
+ const claim = claimDirOf(this.connection.dataDir, command.id, this.sessionId);
799
814
  try {
800
815
  mkdirSync(join(claim, ".."), { recursive: true, mode: 0o700 });
801
816
  mkdirSync(claim, { mode: 0o700 });
@@ -31,6 +31,25 @@
31
31
  * paste). The server imports the path constants from here.
32
32
  */
33
33
 
34
+ /** The folder name of one session's claim on one delivery (`inbox/claims/`): a moved reply is a new delivery for the next session. */
35
+ export function inboxClaimName(replyId: string, session: string): string {
36
+ return `${replyId}.${session.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 120)}`;
37
+ }
38
+
39
+ /**
40
+ * The stop notice of a thread handed to another session goes by its own id, not the reply's. A connection only keeps the
41
+ * id and reports it, so what any connection or wake keeps per id (settled, claims, a T3 request), and its report, can
42
+ * never stand for the person's words when the reply moves back to that session. Older connections included.
43
+ */
44
+ export function inboxStopNoticeId(replyId: string): string {
45
+ return `${replyId}-stop`;
46
+ }
47
+
48
+ /** The reply a stop notice's id belongs to; null for any other id. */
49
+ export function inboxStopNoticeReply(id: string): string | null {
50
+ return id.length > '-stop'.length && id.endsWith('-stop') ? id.slice(0, -'-stop'.length) : null;
51
+ }
52
+
34
53
  // --- CONTRACT (copied byte for byte into apps/hook/hooks/mod/inbox-contract.ts; edit packages/shared/inbox/connection.ts, then paste) ---
35
54
 
36
55
  /** The agent tool every connection registers. */
@@ -1076,6 +1095,13 @@ function wakeName(value: unknown): string {
1076
1095
  */
1077
1096
  export const INBOX_SEND_NOW_POLL_FIELD = 'send_now'
1078
1097
 
1098
+ /**
1099
+ * On a poll, the connection's tick goes on polling while a steer waits for the running turn. A connection without it
1100
+ * (from before that) stops polling until the steer goes in, so the Inbox counts a session it handed one as there.
1101
+ * An older Inbox ignores it.
1102
+ */
1103
+ export const INBOX_STEER_POLLS_POLL_FIELD = 'steer_polls'
1104
+
1079
1105
  /**
1080
1106
  * The poll field (`true`) by which a connection says it can answer Ask AI
1081
1107
  * live. An older connection never sends it, so a question never waits for it.
@@ -38,6 +38,12 @@ export interface OpenInApp {
38
38
  win?: { bin: string };
39
39
  /** Linux PATH binary. */
40
40
  linux?: { bin: string };
41
+ /**
42
+ * How it opens a file at a line. `mac`: the app's URL scheme, `<scheme>://file/<path>:<line>`
43
+ * through `open -a`. `cli`: `--goto <path>:<line>` or `<path>:<line>` on its Windows and Linux
44
+ * binary. Absent: the file opens at its top.
45
+ */
46
+ line?: { mac?: string; cli?: 'goto' | 'suffix' };
41
47
  }
42
48
 
43
49
  /**
@@ -62,6 +68,7 @@ export const OPEN_IN_APPS: OpenInApp[] = [
62
68
  mac: { appName: 'Visual Studio Code' },
63
69
  win: { bin: 'code' },
64
70
  linux: { bin: 'code' },
71
+ line: { mac: 'vscode', cli: 'goto' },
65
72
  },
66
73
  {
67
74
  id: 'cursor',
@@ -71,6 +78,7 @@ export const OPEN_IN_APPS: OpenInApp[] = [
71
78
  mac: { appName: 'Cursor' },
72
79
  win: { bin: 'cursor' },
73
80
  linux: { bin: 'cursor' },
81
+ line: { mac: 'cursor', cli: 'goto' },
74
82
  },
75
83
  {
76
84
  id: 'zed',
@@ -80,6 +88,7 @@ export const OPEN_IN_APPS: OpenInApp[] = [
80
88
  mac: { appName: 'Zed' },
81
89
  win: { bin: 'zed' },
82
90
  linux: { bin: 'zed' },
91
+ line: { cli: 'suffix' },
83
92
  },
84
93
  {
85
94
  id: 'zed-preview',
@@ -99,6 +108,7 @@ export const OPEN_IN_APPS: OpenInApp[] = [
99
108
  mac: { appName: 'Sublime Text' },
100
109
  win: { bin: 'subl' },
101
110
  linux: { bin: 'subl' },
111
+ line: { cli: 'suffix' },
102
112
  },
103
113
  {
104
114
  id: 'textmate',
@@ -265,6 +265,11 @@ const CHOICE_IMAGE_RE = /!\[([^\]\n]*)\]\(([^)\n]+)\)/;
265
265
  // What may follow a recommendation that names the start of a choice label
266
266
  // (`Recommended: Unpin it` for `Unpin it: the toast says…`).
267
267
  const LABEL_HEAD_SEP_RE = /^(?:\s*[:—–(]|\s+-\s)/;
268
+ // A recommendation that is only a label's leading token, a letter or a
269
+ // number (`Recommended: A` for `A · Yes`), names the label when the token is
270
+ // followed by a middle dot.
271
+ const LABEL_TOKEN_RE = /^(?:[a-z]|\d+)$/;
272
+ const LABEL_TOKEN_SEP_RE = /^ · /;
268
273
  // At the start of the line (no indentation), so an indented line under a
269
274
  // choice stays that choice's continuation.
270
275
  const DECISION_RE = /^decision\s*:\s*(.*?)\s*$/i;
@@ -485,7 +490,9 @@ const QUESTION_KEY_RE = /^q-[0-9a-f]{8}(?:-\d{1,3})?$/;
485
490
  * The one choice a recommendation names by the start of its label: the
486
491
  * choice whose label (or any of its other names, such as a task list's full
487
492
  * text) begins with the text and then a separator (`:` `—` `–` ` - ` `(`),
488
- * compared case-insensitively with emphasis and spacing folded
493
+ * or whose leading letter or number the text is, followed by ` · `
494
+ * (`Recommended: A` names `A · Yes`), compared case-insensitively with
495
+ * emphasis and spacing folded
489
496
  * (`Recommended: Unpin it` names `Unpin it: the toast says…`). -1 when no
490
497
  * choice or several do. `names` holds each choice's names, label first.
491
498
  */
@@ -498,7 +505,9 @@ export const recommendationHeadChoice = (
498
505
  const begins = (value: string | undefined): boolean => {
499
506
  if (!value) return false;
500
507
  const folded = value.replace(/[*_`]+/g, '').replace(/\s+/g, ' ').trim().toLowerCase();
501
- return folded.startsWith(target) && LABEL_HEAD_SEP_RE.test(folded.slice(target.length));
508
+ if (!folded.startsWith(target)) return false;
509
+ const rest = folded.slice(target.length);
510
+ return LABEL_HEAD_SEP_RE.test(rest) || (LABEL_TOKEN_RE.test(target) && LABEL_TOKEN_SEP_RE.test(rest));
502
511
  };
503
512
  const hits = names.flatMap((entry, i) => ((typeof entry === 'string' ? [entry] : entry).some(begins) ? [i] : []));
504
513
  return hits.length === 1 ? hits[0] : -1;
@@ -2520,6 +2520,37 @@ function isPathInside(root: string, child: string): boolean {
2520
2520
  return c === r || c.startsWith(`${r}/`);
2521
2521
  }
2522
2522
 
2523
+ /**
2524
+ * One blob's bytes by any spec git takes (`<rev>:<path>`, or a blob id, whole
2525
+ * or abbreviated), capped at `maxBytes`: the size is read first, and the blob
2526
+ * stream stops at the cap. The spec goes on stdin, never argv.
2527
+ */
2528
+ export async function readGitObjectBytes(
2529
+ runtime: ReviewGitRuntime,
2530
+ spec: string,
2531
+ maxBytes: number,
2532
+ cwd?: string,
2533
+ ): Promise<FileBytesRead> {
2534
+ if (!runtime.runGitBytes) return { kind: "unavailable" };
2535
+ if (/[\r\n]/.test(spec)) return { kind: "missing" };
2536
+ const check = await runtime.runGit(
2537
+ ["cat-file", "--batch-check=%(objectname) %(objecttype) %(objectsize)"],
2538
+ { cwd, stdin: `${spec}\n` },
2539
+ );
2540
+ if (check.exitCode !== 0) return { kind: "missing" };
2541
+ const match = check.stdout.trim().match(/^([0-9a-f]{40,64}) (\S+) (\d+)$/);
2542
+ if (!match || match[2] !== "blob") return { kind: "missing" };
2543
+ const size = Number(match[3]);
2544
+ if (size > maxBytes) return { kind: "too-large", size };
2545
+ const blob = await runtime.runGitBytes(["cat-file", "blob", match[1]], {
2546
+ cwd,
2547
+ maxOutputBytes: maxBytes,
2548
+ });
2549
+ if (blob.truncated) return { kind: "too-large", size };
2550
+ if (blob.exitCode !== 0) return { kind: "missing" };
2551
+ return { kind: "ok", bytes: blob.stdout, etag: `"${match[1]}"` };
2552
+ }
2553
+
2523
2554
  /**
2524
2555
  * Read one side as raw bytes for the image preview, capped at `maxBytes`.
2525
2556
  *
@@ -2540,25 +2571,7 @@ export async function readDiffSideBytes(
2540
2571
  cwd?: string,
2541
2572
  ): Promise<FileBytesRead> {
2542
2573
  if (source.kind === "object") {
2543
- if (!runtime.runGitBytes) return { kind: "unavailable" };
2544
- const spec = `${source.rev}:${source.path}`;
2545
- if (/[\r\n]/.test(spec)) return { kind: "missing" };
2546
- const check = await runtime.runGit(
2547
- ["cat-file", "--batch-check=%(objectname) %(objecttype) %(objectsize)"],
2548
- { cwd, stdin: `${spec}\n` },
2549
- );
2550
- if (check.exitCode !== 0) return { kind: "missing" };
2551
- const match = check.stdout.trim().match(/^([0-9a-f]{40,64}) (\S+) (\d+)$/);
2552
- if (!match || match[2] !== "blob") return { kind: "missing" };
2553
- const size = Number(match[3]);
2554
- if (size > maxBytes) return { kind: "too-large", size };
2555
- const blob = await runtime.runGitBytes(["cat-file", "blob", match[1]], {
2556
- cwd,
2557
- maxOutputBytes: maxBytes,
2558
- });
2559
- if (blob.truncated) return { kind: "too-large", size };
2560
- if (blob.exitCode !== 0) return { kind: "missing" };
2561
- return { kind: "ok", bytes: blob.stdout, etag: `"${match[1]}"` };
2574
+ return readGitObjectBytes(runtime, `${source.rev}:${source.path}`, maxBytes, cwd);
2562
2575
  }
2563
2576
 
2564
2577
  if (!runtime.readFileBytes || !runtime.realPath) return { kind: "unavailable" };
@@ -33,8 +33,8 @@ import {
33
33
  statSync,
34
34
  writeFileSync,
35
35
  } from "node:fs";
36
- import { homedir } from "node:os";
37
- import { join } from "node:path";
36
+ import { homedir, tmpdir } from "node:os";
37
+ import { join, sep } from "node:path";
38
38
  import { getPlannotatorDataDir } from "./data-dir.ts";
39
39
  import {
40
40
  BUILTIN_DEFAULT_PROFILE,
@@ -69,6 +69,26 @@ export interface DiscoveredSkill {
69
69
  // Root resolution
70
70
  // ---------------------------------------------------------------------------
71
71
 
72
+ /**
73
+ * True when `dir` (already resolved) sits inside a temp folder. A test run
74
+ * (PLANNOTATOR_TEST_SANDBOX) reads only such folders, so a test that left HOME
75
+ * or CLAUDE_CONFIG_DIR alone never reads the person's real skills, while a test
76
+ * that seeds skills under a scratch home still finds them. Like
77
+ * `agentHomeAllowed` in inbox-agents.ts, which this file cannot import: it is
78
+ * vendored to Pi.
79
+ */
80
+ function isScratchDir(dir: string): boolean {
81
+ return [tmpdir(), "/tmp", "/private/tmp"].some((root) => {
82
+ let base = root;
83
+ try {
84
+ base = realpathSync(root);
85
+ } catch {
86
+ // No such temp root here (/private/tmp off macOS); compare it as written.
87
+ }
88
+ return dir.startsWith(`${base.replace(/[\\/]+$/, "")}${sep}`);
89
+ });
90
+ }
91
+
72
92
  /**
73
93
  * The ordered global skill roots, honoring env overrides. First-seen wins on a
74
94
  * cross-root name clash, so order matters: Claude → Codex → universal.
@@ -110,6 +130,7 @@ export function resolveGlobalSkillRoots(): Array<{ dir: string; root: SkillRoot
110
130
  // non-existent root still dedupes against an identical literal.
111
131
  key = candidate.dir;
112
132
  }
133
+ if (process.env.PLANNOTATOR_TEST_SANDBOX && !isScratchDir(key)) continue;
113
134
  if (seen.has(key)) continue;
114
135
  seen.add(key);
115
136
  roots.push(candidate);