@openlines/opl-pi-sht 0.2.12 → 0.3.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 (83) hide show
  1. package/README.md +101 -85
  2. package/configs/opl-browser.json.sample +3 -2
  3. package/configs/opl-footer.json.sample +3 -2
  4. package/configs/opl-guardian.json.sample +8 -2
  5. package/configs/opl-modes.json.sample +5 -0
  6. package/configs/opl-webaccess.json.sample +7 -1
  7. package/extensions/opl-browser/README.md +81 -55
  8. package/extensions/opl-browser/browser.ts +244 -50
  9. package/extensions/opl-browser/config.ts +15 -7
  10. package/extensions/opl-browser/index.ts +5 -4
  11. package/extensions/opl-browser/package-lock.json +3 -3
  12. package/extensions/opl-browser/package.json +4 -4
  13. package/extensions/opl-browser/validate.ts +313 -60
  14. package/extensions/opl-configurator/README.md +40 -0
  15. package/extensions/opl-configurator/configurator.ts +151 -0
  16. package/extensions/opl-configurator/index.ts +12 -0
  17. package/extensions/opl-ctxtrim/README.md +45 -14
  18. package/extensions/opl-ctxtrim/index.ts +33 -13
  19. package/extensions/opl-footer/README.md +31 -6
  20. package/extensions/opl-footer/config.ts +11 -10
  21. package/extensions/opl-footer/configure.ts +159 -115
  22. package/extensions/opl-footer/git-status.ts +58 -1
  23. package/extensions/opl-footer/icons.ts +3 -0
  24. package/extensions/opl-footer/index.ts +209 -105
  25. package/extensions/opl-footer/segments/caveman.ts +9 -1
  26. package/extensions/opl-footer/segments/chat-mode.ts +8 -1
  27. package/extensions/opl-footer/segments/compactions.ts +13 -0
  28. package/extensions/opl-footer/segments/context.ts +6 -5
  29. package/extensions/opl-footer/segments/index.ts +2 -0
  30. package/extensions/opl-footer/segments/mode-switcher.ts +14 -1
  31. package/extensions/opl-footer/segments/path.ts +1 -1
  32. package/extensions/opl-footer/segments/plan-mode.ts +8 -1
  33. package/extensions/opl-footer/theme.ts +5 -2
  34. package/extensions/opl-footer/types.ts +4 -0
  35. package/extensions/opl-guardian/README.md +17 -5
  36. package/extensions/opl-guardian/config.ts +72 -6
  37. package/extensions/opl-guardian/guardian.ts +42 -10
  38. package/extensions/opl-guardian/index.ts +52 -17
  39. package/extensions/opl-guardian/policies.ts +52 -16
  40. package/extensions/opl-init/README.md +12 -4
  41. package/extensions/opl-init/index.ts +228 -27
  42. package/extensions/opl-input/README.md +17 -3
  43. package/extensions/opl-input/config.ts +59 -5
  44. package/extensions/opl-input/configure.ts +146 -0
  45. package/extensions/opl-input/index.ts +38 -30
  46. package/extensions/opl-input/mode-style.ts +6 -2
  47. package/extensions/opl-modes/README.md +22 -8
  48. package/extensions/opl-modes/config.ts +62 -8
  49. package/extensions/opl-modes/index.ts +87 -28
  50. package/extensions/opl-modes/state.ts +50 -2
  51. package/extensions/opl-modes/types.ts +6 -0
  52. package/extensions/opl-modes/utils.ts +18 -8
  53. package/extensions/opl-questionnaire/index.ts +10 -4
  54. package/extensions/opl-questionnaire/types.ts +4 -0
  55. package/extensions/opl-simplebench/README.md +21 -15
  56. package/extensions/opl-simplebench/artifact.ts +7 -4
  57. package/extensions/opl-simplebench/benchmark.ts +96 -35
  58. package/extensions/opl-simplebench/coding.ts +9 -4
  59. package/extensions/opl-simplebench/index.ts +19 -19
  60. package/extensions/opl-simplebench/llama-server.ts +3 -2
  61. package/extensions/opl-simplebench/report.ts +0 -6
  62. package/extensions/opl-simplebench/research.ts +5 -4
  63. package/extensions/opl-simplebench/scoring.ts +4 -2
  64. package/extensions/opl-simplebench/util/config.ts +15 -8
  65. package/extensions/opl-simplebench/util/exec-env.ts +36 -0
  66. package/extensions/opl-simplebench/util/format.ts +2 -2
  67. package/extensions/opl-simplebench/util/providers.ts +23 -194
  68. package/extensions/opl-todo/README.md +5 -5
  69. package/extensions/opl-todo/config.ts +7 -2
  70. package/extensions/opl-todo/index.ts +50 -9
  71. package/extensions/opl-webaccess/README.md +31 -5
  72. package/extensions/opl-webaccess/config.ts +36 -7
  73. package/extensions/opl-webaccess/extract.ts +37 -47
  74. package/extensions/opl-webaccess/http.ts +259 -0
  75. package/extensions/opl-webaccess/index.ts +12 -6
  76. package/extensions/opl-webaccess/package-lock.json +3 -3
  77. package/extensions/opl-webaccess/package.json +4 -9
  78. package/extensions/opl-webaccess/pdf.ts +4 -4
  79. package/extensions/opl-webaccess/search.ts +19 -8
  80. package/extensions/opl-webaccess/storage.ts +134 -2
  81. package/extensions/opl-webaccess/types.ts +4 -0
  82. package/extensions/opl-webaccess/utils.ts +221 -33
  83. package/package.json +19 -12
@@ -31,6 +31,7 @@ type Crawl = {
31
31
  extCounts: Map<string, number>;
32
32
  manifests: { path: string; content: string }[];
33
33
  workspaceMembers: string[];
34
+ skippedGlobs: string[];
34
35
  seen: Set<string>;
35
36
  };
36
37
 
@@ -42,41 +43,147 @@ const FP_MARKER = /<!-- opl-init:fp (\S+) -->/;
42
43
  // (vs HEAD, so staged changes count) and every untracked file. Only the root
43
44
  // AGENTS.md is excluded, so writing the guide does not make it stale;
44
45
  // subdirectory AGENTS.md files are repository facts.
46
+ // /init computes the fingerprint synchronously on the command path, and a workspace can
47
+ // hold a dataset, model weights or a recording. Content-hashing everything cost 135 ms and
48
+ // 245 MB RSS for a single 200 MB file, so large files are represented by their metadata and
49
+ // the whole pass runs under a path and byte budget.
50
+ // Known blind spot, accepted deliberately: a rewrite that keeps size and mtime identical, or
51
+ // any change past the caps, is invisible until a change the fingerprint can see happens.
52
+ const FP_LIMITS = {
53
+ maxFileBytes: 2 * 1024 * 1024,
54
+ maxTotalBytes: 32 * 1024 * 1024,
55
+ maxPaths: 5000,
56
+ };
57
+
58
+ type FpLimits = typeof FP_LIMITS;
59
+ type FpBudget = { bytes: number; paths: number; capped: boolean };
60
+
45
61
  // Returns null for non-git directories (caller falls back to fingerprintFallback).
46
- function fingerprintGit(root: string): string | null {
62
+ function fingerprintGit(root: string, limits: FpLimits = FP_LIMITS): string | null {
47
63
  const head = gitOutput(root, ["rev-parse", "HEAD"]);
48
64
  if (head === null) return null;
49
65
  const hash = createHash("sha256").update(`schema:${GUIDE_SCHEMA_VERSION}\0head:${head}\0`);
66
+ const budget: FpBudget = { bytes: 0, paths: 0, capped: false };
50
67
  // --relative keeps diff paths cwd-relative and cwd-scoped, matching
51
68
  // ls-files --others and the crawl; without it a subdirectory session would
52
69
  // resolve every dirty path to a nonexistent file and hash "DELETED".
53
70
  for (const path of (gitOutput(root, ["diff", "HEAD", "--name-only", "-z", "--relative"]) ?? "").split("\0")) {
54
- if (!path || path === "AGENTS.md") continue;
55
- hash.update(`${path}\0`).update(hashFile(join(root, path))).update("\0");
71
+ hashFingerprintPath(hash, root, path, budget, limits);
56
72
  }
57
73
  for (const path of (gitOutput(root, ["ls-files", "--others", "--exclude-standard", "-z"]) ?? "").split("\0")) {
58
- if (!path || path === "AGENTS.md") continue;
59
- hash.update(`${path}\0`).update(hashFile(join(root, path))).update("\0");
74
+ hashFingerprintPath(hash, root, path, budget, limits);
60
75
  }
76
+ // Fold the budget state in so crossing a cap changes the digest once and then stays
77
+ // stable: an oscillating digest would make every /init run regenerate the guide.
78
+ hash.update(`budget:${budget.paths}:${budget.bytes}:${budget.capped ? 1 : 0}\0`);
61
79
  return hash.digest("hex").slice(0, 16);
62
80
  }
63
81
 
82
+ function hashFingerprintPath(
83
+ hash: ReturnType<typeof createHash>,
84
+ root: string,
85
+ path: string,
86
+ budget: FpBudget,
87
+ limits: FpLimits,
88
+ ): void {
89
+ if (!path || path === "AGENTS.md") return;
90
+ // The name is always hashed even past a cap, so adding or removing a file is still
91
+ // visible; only content stops being read.
92
+ hash.update(`${path}\0`).update(budget.paths >= limits.maxPaths ? "OVER_CAP\0" : hashFile(join(root, path), budget, limits)).update("\0");
93
+ budget.paths++;
94
+ if (budget.paths >= limits.maxPaths) budget.capped = true;
95
+ }
96
+
97
+ // BEGIN SHARED GIT GUARD
98
+ // A repository is attacker-chosen data: its local config, `.gitattributes` and system
99
+ // config can name programs that git runs while answering what looks like a read-only query.
100
+ // Measured on git 2.54: `git diff HEAD` executes a declared `[diff "x"] textconv` driver,
101
+ // while `--name-only`, `status --porcelain`, `ls-files` and `rev-parse` do not. Every call
102
+ // still goes through this guard so a future content-diff call cannot reintroduce the
103
+ // execution path. Duplicated per extension because install.sh installs one directory at a
104
+ // time; tests/net-guard-parity.test.mjs fails if the copies drift.
105
+ const SAFE_GIT_GLOBAL_ARGS = [
106
+ "--no-pager",
107
+ "-c",
108
+ "core.fsmonitor=false",
109
+ "-c",
110
+ "core.hooksPath=",
111
+ "-c",
112
+ "protocol.ext.allow=never",
113
+ "-c",
114
+ "credential.helper=",
115
+ ];
116
+
117
+ /** Refuse external content handlers. Diff-family subcommands only: plumbing rejects them. */
118
+ const SAFE_GIT_DIFF_FLAGS = ["--no-ext-diff", "--no-textconv"];
119
+ const GIT_DIFF_COMMANDS = new Set(["diff", "log", "show", "stash", "range-diff", "format-patch"]);
120
+
121
+ const SAFE_GIT_ENV: Record<string, string> = {
122
+ // /etc/gitconfig and the user's own config can name helpers; a repo probe must not read them.
123
+ GIT_CONFIG_NOSYSTEM: "1",
124
+ GIT_ATTR_NOSYSTEM: "1",
125
+ GIT_PAGER: "cat",
126
+ // A credential prompt would hang the caller instead of failing fast.
127
+ GIT_TERMINAL_PROMPT: "0",
128
+ GIT_ASKPASS: "",
129
+ SSH_ASKPASS: "",
130
+ // Read-only plumbing must not create or wait on index.lock.
131
+ GIT_OPTIONAL_LOCKS: "0",
132
+ };
133
+
134
+ export function safeGitInvocation(args: string[]): string[] {
135
+ const [subcommand, ...rest] = args;
136
+ const diffFlags = subcommand && GIT_DIFF_COMMANDS.has(subcommand) ? SAFE_GIT_DIFF_FLAGS : [];
137
+ // A missing subcommand must not reach execFileSync/spawn as an undefined argv slot.
138
+ return subcommand
139
+ ? [...SAFE_GIT_GLOBAL_ARGS, subcommand, ...diffFlags, ...rest]
140
+ : [...SAFE_GIT_GLOBAL_ARGS, ...rest];
141
+ }
142
+
143
+ export function gitGuardEnv(base: Record<string, string | undefined> = process.env): Record<string, string> {
144
+ const env: Record<string, string> = {};
145
+ for (const [key, value] of Object.entries(base)) {
146
+ if (typeof value === "string") env[key] = value;
147
+ }
148
+ return { ...env, ...SAFE_GIT_ENV };
149
+ }
150
+ // END SHARED GIT GUARD
151
+
64
152
  function gitOutput(root: string, args: string[]): string | null {
153
+ // Name-only/porcelain plumbing only; the guard above refuses patch content.
65
154
  try {
66
- return execFileSync("git", args, {
155
+ return execFileSync("git", safeGitInvocation(args), {
67
156
  cwd: root,
157
+ env: gitGuardEnv(),
68
158
  encoding: "utf8",
69
159
  stdio: ["ignore", "pipe", "ignore"],
70
- maxBuffer: 64 * 1024 * 1024,
160
+ // Name-only and porcelain output is path lists: 4 MB is on the order of 100k paths.
161
+ // A repo that needs more makes execFileSync throw, which reads as "not a git
162
+ // directory" and falls through to the stat-only fingerprintFallback.
163
+ maxBuffer: 4 * 1024 * 1024,
71
164
  });
72
165
  } catch {
73
166
  return null;
74
167
  }
75
168
  }
76
169
 
77
- function hashFile(path: string): string {
170
+ function hashFile(path: string, budget: FpBudget, limits: FpLimits): string {
171
+ let stats;
172
+ try {
173
+ stats = statSync(path);
174
+ } catch {
175
+ return "DELETED";
176
+ }
177
+ // Above either threshold the entry is represented by metadata instead of bytes: a
178
+ // change to size or mtime is still detected, and the pass stays bounded.
179
+ if (!stats.isFile() || stats.size > limits.maxFileBytes || budget.bytes + stats.size > limits.maxTotalBytes) {
180
+ budget.capped = true;
181
+ return `meta:${stats.size}:${Math.round(stats.mtimeMs)}`;
182
+ }
78
183
  try {
79
- return createHash("sha256").update(readFileSync(path)).digest("hex");
184
+ const body = readFileSync(path);
185
+ budget.bytes += body.length;
186
+ return createHash("sha256").update(body).digest("hex");
80
187
  } catch {
81
188
  return "DELETED";
82
189
  }
@@ -126,6 +233,7 @@ function crawl(root: string): Crawl {
126
233
  extCounts: new Map(),
127
234
  manifests: [],
128
235
  workspaceMembers: [],
236
+ skippedGlobs: [],
129
237
  seen: new Set(),
130
238
  };
131
239
 
@@ -190,6 +298,7 @@ function crawl(root: string): Crawl {
190
298
  break;
191
299
  }
192
300
  const entry = all[i];
301
+ if (entry === undefined) continue;
193
302
  result.tree.push(`${prefix}${entry}`);
194
303
  if (entry.endsWith("/") && depth < maxDepth) {
195
304
  walk(join(dir, entry.slice(0, -1)), depth + 1, `${prefix} `, maxDepth);
@@ -202,7 +311,7 @@ function crawl(root: string): Crawl {
202
311
  // Manifest-first monorepo expansion: enumerate declared workspace members
203
312
  // with a fresh depth budget so packages/foo/src is not penalized by its
204
313
  // grouping prefix.
205
- for (const member of workspaceMembers(root)) {
314
+ for (const member of workspaceMembers(root, result.skippedGlobs)) {
206
315
  if (result.tree.length >= MAX_TREE_LINES) break;
207
316
  const abs = join(root, member);
208
317
  let stats;
@@ -225,23 +334,42 @@ function crawl(root: string): Crawl {
225
334
  if (result.tree.length >= MAX_TREE_LINES) {
226
335
  result.tree.push(`(tree truncated at ${MAX_TREE_LINES} entries)`);
227
336
  }
337
+ if (result.skippedGlobs.length > 0) {
338
+ result.tree.push(
339
+ `(skipped workspace glob(s) that could not be compiled: ${result.skippedGlobs.join(", ")})`,
340
+ );
341
+ }
228
342
  return result;
229
343
  }
230
344
 
231
345
  // ponytail: naive glob matcher supporting only what workspace files use in
232
- // practice: `**` across segments, `*` within a segment, optional trailing "/".
233
- function globToRegExp(pattern: string): RegExp {
346
+ // practice: `**` across segments, `*` within a segment, `?` for one character,
347
+ // optional trailing "/". Everything else is escaped, so a directory name that is
348
+ // itself regex syntax (`libs/c++/*`) matches literally instead of throwing.
349
+ // Returns null for a pattern that cannot be compiled: workspace files are
350
+ // user-authored, and one bad line must not abort /init.
351
+ function globToRegExp(pattern: string): RegExp | null {
234
352
  const cleaned = pattern.replace(/\/$/, "");
235
- const source = cleaned
236
- .split("/**/")
237
- .map((seg) => seg.replace(/[*]/g, "[^/]*"))
238
- .join("(?:/.*)?");
239
- return new RegExp(`^${source}$`);
353
+ if (cleaned === "") return null;
354
+ try {
355
+ const source = cleaned
356
+ .split("/**/")
357
+ .map((segment) =>
358
+ segment
359
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
360
+ .replace(/\*/g, "[^/]*")
361
+ .replace(/\?/g, "[^/]"),
362
+ )
363
+ .join("(?:/.*)?");
364
+ return new RegExp(`^${source}$`);
365
+ } catch {
366
+ return null;
367
+ }
240
368
  }
241
369
 
242
370
  // ponytail: line-based subset parsing of pnpm-workspace.yaml and Cargo.toml
243
371
  // [workspace] members. Full YAML/TOML parsing not warranted for glob lists.
244
- function workspaceMembers(root: string): string[] {
372
+ function workspaceMembers(root: string, skipped: string[]): string[] {
245
373
  const globs: string[] = [];
246
374
 
247
375
  try {
@@ -254,7 +382,8 @@ function workspaceMembers(root: string): string[] {
254
382
  inPackages = false;
255
383
  } else if (inPackages) {
256
384
  const match = line.match(/^\s*-\s*["']?([^"'#]+)/);
257
- if (match) globs.push(match[1].trim());
385
+ const pkg = match?.[1];
386
+ if (pkg) globs.push(pkg.trim());
258
387
  }
259
388
  }
260
389
  } catch {
@@ -264,10 +393,14 @@ function workspaceMembers(root: string): string[] {
264
393
  try {
265
394
  const toml = readFileSync(join(root, "Cargo.toml"), "utf8");
266
395
  const section = toml.match(/\[workspace\]([\s\S]*?)(?:\n\[|\s*$)/);
267
- const membersBlock = section?.[1].match(/members\s*=\s*\[([^\]]*)\]/s);
396
+ const membersBlock = section?.[1]?.match(/members\s*=\s*\[([^\]]*)\]/s);
268
397
  if (membersBlock) {
269
- for (const m of membersBlock[1].matchAll(/["']([^"']+)["']/g)) {
270
- globs.push(m[1]);
398
+ const memberList = membersBlock[1];
399
+ if (memberList) {
400
+ for (const m of memberList.matchAll(/["']([^"']+)["']/g)) {
401
+ const name = m[1];
402
+ if (name) globs.push(name);
403
+ }
271
404
  }
272
405
  }
273
406
  } catch {
@@ -277,6 +410,10 @@ function workspaceMembers(root: string): string[] {
277
410
  const members = new Set<string>();
278
411
  for (const glob of globs) {
279
412
  const re = globToRegExp(glob);
413
+ if (!re) {
414
+ if (!skipped.includes(glob)) skipped.push(glob);
415
+ continue;
416
+ }
280
417
  // Match against shallow candidate paths from the tree we already walked,
281
418
  // plus one extra readdir of likely parent dirs. Simple approach: test every
282
419
  // tree dir line's relative path.
@@ -431,7 +568,9 @@ async function refineGuide(ctx: any, evidence: string, marker: string): Promise<
431
568
  const stream = ctx.modelRegistry.streamSimple(
432
569
  model,
433
570
  { systemPrompt: REFINE_SYSTEM_PROMPT, messages: [{ role: "user", content: evidence, timestamp: Date.now() }], tools: [] },
434
- { reasoning: false, signal: AbortSignal.timeout(REFINE_TIMEOUT_MS) },
571
+ // ThinkingLevel has no "off": "minimal" is the cheapest level the adapters accept.
572
+ // `false` was silently dropped as "unset", so the refinement paid the provider default.
573
+ { reasoning: "minimal", signal: AbortSignal.timeout(REFINE_TIMEOUT_MS) },
435
574
  );
436
575
  const result = await stream.result();
437
576
  if (!result || result.stopReason === "error" || result.stopReason === "aborted") return null;
@@ -445,8 +584,50 @@ async function refineGuide(ctx: any, evidence: string, marker: string): Promise<
445
584
  }
446
585
  }
447
586
 
587
+ // Offsets retain the user's original line endings and whitespace. Only ATX
588
+ // headings outside fenced code blocks delimit the owned section.
589
+ function operationalSections(text: string): { start: number; end: number }[] {
590
+ const sections: { start: number; end: number }[] = [];
591
+ let active: { start: number; end: number } | undefined;
592
+ let fence: { char: string; length: number } | undefined;
593
+ for (const match of text.matchAll(/[^\n]*(?:\n|$)/g)) {
594
+ const line = match[0].replace(/\r?\n$/, "");
595
+ const offset = match.index;
596
+ const fenced = line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/);
597
+ if (fence) {
598
+ if (fenced?.[1]?.[0] === fence.char && fenced[1].length >= fence.length && !fenced[2]?.trim()) fence = undefined;
599
+ continue;
600
+ }
601
+ if (fenced?.[1] && (fenced[1][0] !== "`" || !fenced[2]?.includes("`"))) {
602
+ fence = { char: fenced[1][0]!, length: fenced[1].length };
603
+ continue;
604
+ }
605
+ const heading = line.match(/^ {0,3}(#{1,2})(?:[ \t]+(.*)|[ \t]*)$/);
606
+ if (!heading) continue;
607
+ if (active) { active.end = offset; active = undefined; }
608
+ if (heading[1] === "##" && heading[2]?.replace(/[ \t]+#+[ \t]*$/, "").trim() === "Operational notes") {
609
+ active = { start: offset, end: text.length };
610
+ sections.push(active);
611
+ }
612
+ }
613
+ return sections;
614
+ }
615
+
616
+ function guideWithoutFinalMarker(text: string): string {
617
+ return text.replace(/(^|\n)<!-- opl-init:fp \S+ -->[ \t]*(?:\r?\n)?$/, "$1");
618
+ }
619
+
620
+ function mergeOperationalNotes(generated: string, owned: string, marker: string): string {
621
+ let body = guideWithoutFinalMarker(generated);
622
+ // Discard generated versions: the existing user section is authoritative.
623
+ for (const section of operationalSections(body).reverse()) {
624
+ body = body.slice(0, section.start) + body.slice(section.end);
625
+ }
626
+ return `${body.trimEnd()}\n\n${owned}${owned.endsWith("\n") ? "" : "\n"}${marker}\n`;
627
+ }
628
+
448
629
  // Named exports for fixture tests (tests/opl-init-*.test.mjs).
449
- export { crawl, fingerprint, evidencePacket, finalizeRefinedGuide, refineGuide };
630
+ export { crawl, fingerprint, fingerprintGit, FP_LIMITS, evidencePacket, finalizeRefinedGuide, refineGuide };
450
631
 
451
632
  export default function (pi: ExtensionAPI) {
452
633
  pi.registerCommand("init", {
@@ -480,14 +661,34 @@ export default function (pi: ExtensionAPI) {
480
661
  }
481
662
 
482
663
  const marker = `<!-- opl-init:fp ${currentFp} -->`;
483
- // Regenerating replaces the whole file, including hand-edited prose; the
484
- // repository's version control is the recovery path.
664
+ let original: string | null;
665
+ let owned: string | undefined;
666
+ try {
667
+ original = existsSync(agentsPath) ? readFileSync(agentsPath, "utf8") : null;
668
+ const body = guideWithoutFinalMarker(original ?? "");
669
+ const sections = operationalSections(body);
670
+ if (sections.length > 1) {
671
+ ctx.ui.notify("AGENTS.md has duplicate Operational notes sections; /init will not modify it.", "error");
672
+ return;
673
+ }
674
+ const section = sections[0];
675
+ if (section) owned = body.slice(section.start, section.end);
676
+ } catch (error: any) {
677
+ ctx.ui.notify(`Could not read AGENTS.md: ${error?.message || error}`, "error");
678
+ return;
679
+ }
485
680
  const crawlResult = crawl(root);
486
681
  const baseline = buildGuide(root, crawlResult, marker);
487
682
  const refined = await refineGuide(ctx, evidencePacket(root, baseline, crawlResult), marker);
488
683
 
489
684
  try {
490
- writeFileSync(agentsPath, refined ?? baseline, "utf8");
685
+ const latest = existsSync(agentsPath) ? readFileSync(agentsPath, "utf8") : null;
686
+ if (latest !== original) {
687
+ ctx.ui.notify("AGENTS.md changed during /init; update cancelled. Run /init again.", "error");
688
+ return;
689
+ }
690
+ const generated = refined ?? baseline;
691
+ writeFileSync(agentsPath, owned === undefined ? generated : mergeOperationalNotes(generated, owned, marker), "utf8");
491
692
  } catch (error: any) {
492
693
  ctx.ui.notify(`Could not write AGENTS.md: ${error?.message || error}`, "error");
493
694
  return;
@@ -4,7 +4,7 @@ Replaces Pi's default chat editor with a mode-aware custom editor. Native editor
4
4
 
5
5
  ## Commands, flags, and shortcuts
6
6
 
7
- No commands, flags, or shortcuts. It replaces the standard editor at session start.
7
+ No commands, flags, or shortcuts of its own. It replaces the standard editor at session start. Its settings are editable through the `/configurator` shell (Input tab, hotkey `i`); a save writes `opl-input.json` and reports that the change takes effect after `/reload`.
8
8
 
9
9
  ## Extension features
10
10
 
@@ -67,7 +67,7 @@ The companion requires at least 40 terminal columns and reserves three top-paddi
67
67
 
68
68
  Every color option accepts either:
69
69
 
70
- - **A Pi theme token** — one of the 45 tokens defined by Pi's `Theme`:
70
+ - **A Pi theme token** — a color name from Pi's theme schema (56 names at pi 0.87.0 and 1.1.0, 7 of them `*Bg` fills). The ones that matter here:
71
71
  - Core: `accent`, `border`, `borderAccent`, `borderMuted`, `success`, `error`, `warning`, `muted`, `dim`, `text`
72
72
  - Messages/content: `thinkingText`, `searchMatchText`, `userMessageText`, `customMessageText`, `customMessageLabel`
73
73
  - Tools: `toolTitle`, `toolOutput`, `toolDiffAdded`, `toolDiffRemoved`, `toolDiffContext`
@@ -75,7 +75,7 @@ Every color option accepts either:
75
75
  - Syntax: `syntaxComment`, `syntaxKeyword`, `syntaxFunction`, `syntaxVariable`, `syntaxString`, `syntaxNumber`, `syntaxType`, `syntaxOperator`, `syntaxPunctuation`
76
76
  - Thinking borders: `thinkingOff`, `thinkingMinimal`, `thinkingLow`, `thinkingMedium`, `thinkingHigh`, `thinkingXhigh`, `thinkingMax`
77
77
  - Special: `bashMode`
78
- - **A hex color** — `"#c07898"`, or the three-digit shorthand `"#abc"` (expanded to `#aabbcc`; rendered as ANSI truecolor, downgraded automatically on 256-color terminals).
78
+ - **A hex color** — `"#c07898"`, or the three-digit shorthand `"#abc"` (expanded to `#aabbcc`). Hex values are emitted as ANSI truecolor (`38;2;r;g;b`); there is no 256-color downgrade in this extension, so on a 256-only terminal a hex color may render approximately or not at all, while a theme token renders correctly.
79
79
 
80
80
  Invalid theme tokens fall back to the theme's `border` token; invalid hex renders uncolored rather than crashing.
81
81
  - A mode `prefix` is clamped to one terminal cell (continuation lines reserve a single space), so a wide or multi-character `appearance.prefix` such as `👀` is truncated to one cell rather than pushing the box border past the editor width.
@@ -83,3 +83,17 @@ Invalid theme tokens fall back to the theme's `border` token; invalid hex render
83
83
  ## Architecture
84
84
 
85
85
  `index.ts` installs the editor integration and disposes companion render timers on editor replacement or session shutdown; `mode-style.ts` resolves Bash > published mode appearance > compiled fallback; `config.ts` loads editor and companion settings; and `utils.ts` handles color and rendering helpers. `opl-modes` is the sole publisher of active mode appearance through `globalThis.__agentMode`.
86
+
87
+ Two couplings are worth knowing before editing this extension:
88
+
89
+ - **pi-tui's text output.** Re-framing the editor means recognising its horizontal borders and
90
+ its scroll indicator, and pi-tui exposes neither as an API: they are the literal strings `─`
91
+ and `─── ↑ 3 more `. Those markers are collected in one place (`isSolidBorder`,
92
+ `scrollIndicatorText`, `isBorderLike` in `index.ts`) and
93
+ `tests/opl-input-pi-tui-markers.test.mjs` asserts the installed pi-tui still emits them. They
94
+ were byte-identical in pi-tui 0.87.0, 0.99.1 and 1.1.0; a failure there means the format
95
+ changed and the re-framing needs updating, not that the test is wrong.
96
+ - **The bundle's idle heartbeat.** This component owns the only render timer, so the footer's
97
+ time-based cells advance because of it. Disabling `opl-input` freezes that clock; with
98
+ `companion.enabled: false` the tick slows to one repaint per `IDLE_REPAINT_MS` rather than
99
+ stopping.
@@ -1,6 +1,6 @@
1
- import { readFileSync } from "node:fs";
2
- import { homedir } from "node:os";
3
- import { join } from "node:path";
1
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
4
4
 
5
5
  export const DEFAULT_CONFIG = {
6
6
  BOX_PAD_X: 1,
@@ -29,11 +29,65 @@ interface ChatInputUserConfig {
29
29
  };
30
30
  }
31
31
 
32
- const CONFIG_PATH = join(homedir(), ".pi", "agent", "configs", "opl-input.json");
32
+ /** Resolved per call so a custom `PI_AGENT_DIR` (pi's own agent dir) is honoured. */
33
+ export function configPath(): string {
34
+ return join(getAgentDir(), "configs", "opl-input.json");
35
+ }
36
+
37
+ /** Subset of fields the configurator tab is allowed to edit. */
38
+ export interface EditableInputConfig {
39
+ boxedView?: boolean;
40
+ companion?: {
41
+ enabled?: boolean;
42
+ type?: string;
43
+ };
44
+ }
45
+
46
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
47
+ return typeof value === "object" && value !== null && !Array.isArray(value);
48
+ }
49
+
50
+ /** Read the live config as a loose record ({} on absence or malformed), preserving unknown keys. */
51
+ export function readInputConfig(): Record<string, unknown> {
52
+ try {
53
+ const parsed = JSON.parse(readFileSync(configPath(), "utf8"));
54
+ return isPlainObject(parsed) ? parsed : {};
55
+ } catch {
56
+ return {};
57
+ }
58
+ }
59
+
60
+ /** Merge only the editable fields into the live file. Returns false when nothing changed. */
61
+ export function saveInputConfig(patch: EditableInputConfig): boolean {
62
+ const current = readInputConfig();
63
+ const next: Record<string, unknown> = { ...current };
64
+
65
+ if (typeof patch.boxedView === "boolean") next.boxedView = patch.boxedView;
66
+
67
+ if (patch.companion) {
68
+ const enabled = patch.companion.enabled;
69
+ const type = patch.companion.type;
70
+ const hasEnabled = typeof enabled === "boolean";
71
+ const hasType = typeof type === "string" && type.length > 0;
72
+ if (hasEnabled || hasType) {
73
+ const companion = isPlainObject(next.companion) ? { ...next.companion } : {};
74
+ if (hasEnabled) companion.enabled = enabled;
75
+ if (hasType) companion.type = type;
76
+ next.companion = companion;
77
+ }
78
+ }
79
+
80
+ if (JSON.stringify(next) === JSON.stringify(current)) return false;
81
+
82
+ const file = configPath();
83
+ mkdirSync(dirname(file), { recursive: true });
84
+ writeFileSync(file, JSON.stringify(next, null, 2) + "\n", "utf8");
85
+ return true;
86
+ }
33
87
 
34
88
  function loadUserConfig(): ChatInputUserConfig {
35
89
  try {
36
- const raw = readFileSync(CONFIG_PATH, "utf8");
90
+ const raw = readFileSync(configPath(), "utf8");
37
91
  return JSON.parse(raw) as ChatInputUserConfig;
38
92
  } catch {
39
93
  return {};
@@ -0,0 +1,146 @@
1
+ import type { ExtensionContext, KeybindingsManager, Theme } from "@earendil-works/pi-coding-agent";
2
+ import { getSettingsListTheme } from "@earendil-works/pi-coding-agent";
3
+ import { SettingsList, truncateToWidth, type SettingItem } from "@earendil-works/pi-tui";
4
+
5
+ import { readInputConfig, saveInputConfig, type EditableInputConfig } from "./config.js";
6
+
7
+ // The configurator seam. opl-configurator owns the /configurator command and reads this
8
+ // globalThis array lazily; the entry shape is duplicated by hand on both sides because
9
+ // install.sh copies one extension directory at a time.
10
+ export const CONFIGURATOR_REGISTRY_KEY = "__oplConfiguratorTabs";
11
+
12
+ /** What the configurator shell hands to each tab's factory. */
13
+ export interface ConfiguratorTabApi {
14
+ ctx: ExtensionContext;
15
+ tui: { requestRender(): void };
16
+ theme: Theme;
17
+ keybindings: KeybindingsManager;
18
+ done: (result: void) => void;
19
+ }
20
+
21
+ export interface ConfiguratorTabComponent {
22
+ render(width: number): string[];
23
+ invalidate?(): void;
24
+ handleInput?(data: string): void;
25
+ }
26
+
27
+ interface ConfiguratorTab {
28
+ id: string;
29
+ hotkey: string;
30
+ label: string;
31
+ create: (api: ConfiguratorTabApi) => ConfiguratorTabComponent;
32
+ }
33
+
34
+ const BASE_COMPANION_TYPES = ["cat", "dog", "bunny"];
35
+
36
+ function unique(values: string[]): string[] {
37
+ return [...new Set(values)];
38
+ }
39
+
40
+ function declaredCompanionTypes(companion: Record<string, unknown>): string[] {
41
+ const types = companion.types;
42
+ if (!Array.isArray(types)) return [];
43
+ return unique(
44
+ types
45
+ .filter((entry): entry is Record<string, unknown> => typeof entry === "object" && entry !== null)
46
+ .map((entry) => entry.typeName)
47
+ .filter((name): name is string => typeof name === "string" && name.length > 0),
48
+ );
49
+ }
50
+
51
+ function companionRecord(config: Record<string, unknown>): Record<string, unknown> {
52
+ return typeof config.companion === "object" && config.companion !== null && !Array.isArray(config.companion)
53
+ ? (config.companion as Record<string, unknown>)
54
+ : {};
55
+ }
56
+
57
+ /** Built-ins plus any declared typeName and the live selection, in that order, deduped. */
58
+ export function companionTypeValues(config: Record<string, unknown>): string[] {
59
+ const companion = companionRecord(config);
60
+ const currentType = typeof companion.type === "string" && companion.type.length > 0 ? companion.type : "cat";
61
+ return unique([...BASE_COMPANION_TYPES, ...declaredCompanionTypes(companion), currentType]);
62
+ }
63
+
64
+ /** Map a setting item id to its write patch; null for an id the tab does not own. */
65
+ export function inputFieldPatch(id: string, value: string): EditableInputConfig | null {
66
+ if (id === "boxedView") return { boxedView: value === "true" };
67
+ if (id === "companion.enabled") return { companion: { enabled: value === "true" } };
68
+ if (id === "companion.type") return { companion: { type: value } };
69
+ return null;
70
+ }
71
+
72
+ /** Publish the input settings screen as a tab, once per extension load. */
73
+ export function registerInputConfiguratorTab(): void {
74
+ const tab: ConfiguratorTab = {
75
+ id: "opl-input",
76
+ hotkey: "i",
77
+ label: "Input",
78
+ create: (api) => createInputComponent(api),
79
+ };
80
+
81
+ const holder = globalThis as Record<string, unknown>;
82
+ const registry = holder[CONFIGURATOR_REGISTRY_KEY];
83
+ if (Array.isArray(registry)) {
84
+ const index = registry.findIndex((entry) => entry?.id === "opl-input");
85
+ if (index === -1) registry.push(tab);
86
+ else registry[index] = tab;
87
+ } else {
88
+ holder[CONFIGURATOR_REGISTRY_KEY] = [tab];
89
+ }
90
+ }
91
+
92
+ function createInputComponent(api: ConfiguratorTabApi): ConfiguratorTabComponent {
93
+ const { ctx, theme, done } = api;
94
+ let config = readInputConfig();
95
+ let settingsList: SettingsList | null = null;
96
+
97
+ const createSettingsList = (): SettingsList => {
98
+ const companion = companionRecord(config);
99
+ const boxedView = typeof config.boxedView === "boolean" ? config.boxedView : true;
100
+ const enabled = typeof companion.enabled === "boolean" ? companion.enabled : false;
101
+ const currentType = typeof companion.type === "string" && companion.type.length > 0 ? companion.type : "cat";
102
+
103
+ const typeValues = companionTypeValues(config);
104
+
105
+ const items: SettingItem[] = [
106
+ { id: "boxedView", label: "Boxed view", currentValue: boxedView ? "true" : "false", values: ["true", "false"] },
107
+ { id: "companion.enabled", label: "Companion", currentValue: enabled ? "true" : "false", values: ["true", "false"] },
108
+ { id: "companion.type", label: "Companion type", currentValue: currentType, values: typeValues },
109
+ ];
110
+
111
+ return new SettingsList(items, items.length, getSettingsListTheme(), save, () => done(undefined));
112
+ };
113
+
114
+ const save = (id: string, value: string) => {
115
+ const patch = inputFieldPatch(id, value);
116
+ if (!patch) return; // never misroute an unexpected id into companion.type
117
+
118
+ try {
119
+ const changed = saveInputConfig(patch);
120
+ if (changed) {
121
+ config = readInputConfig();
122
+ settingsList = createSettingsList();
123
+ settingsList.selectItem(id);
124
+ ctx.ui.notify("opl-input saved. Run /reload (or restart Pi) to apply.", "info");
125
+ }
126
+ } catch (error) {
127
+ const message = error instanceof Error ? error.message : String(error);
128
+ ctx.ui.notify(`Could not save opl-input config: ${message}`, "error");
129
+ done(undefined);
130
+ }
131
+ };
132
+
133
+ settingsList = createSettingsList();
134
+
135
+ return {
136
+ render(width: number) {
137
+ return [
138
+ truncateToWidth(theme.bold("Configure OPL Input"), width),
139
+ truncateToWidth(theme.fg("dim", "Reload (/reload) is required for changes to apply"), width),
140
+ ...(settingsList ?? createSettingsList()).render(width),
141
+ ];
142
+ },
143
+ invalidate() { settingsList?.invalidate(); },
144
+ handleInput(data: string) { settingsList?.handleInput?.(data); },
145
+ };
146
+ }