@openlines/opl-pi-sht 0.1.21 → 0.2.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.
@@ -20,56 +20,68 @@ const MANIFESTS = new Set([
20
20
  const MAX_DEPTH = 3;
21
21
  const MAX_MEMBER_DEPTH = 3;
22
22
  const MAX_TREE_LINES = 300;
23
- const MAX_MANIFEST_BYTES = 2048;
23
+ const MAX_MANIFEST_PARSE_BYTES = 262144;
24
24
  const MAX_DIR_ENTRIES = 40;
25
- const MAX_RULE_SOURCE_BYTES = 2048;
26
-
27
- // Instruction sources other coding agents use (see research/harness-init-comparison.md).
28
- // AGENTS.md is deliberately absent — it is this command's output.
29
- const RULE_SOURCES = [
30
- ".cursorrules",
31
- ".cursor/rules",
32
- ".github/copilot-instructions.md",
33
- "CLAUDE.md",
34
- "CLAUDE.local.md",
35
- ".claude/rules",
36
- ".windsurfrules",
37
- ".windsurf/rules",
38
- ".clinerules",
39
- ".devin/rules",
40
- ];
25
+ // Bumping this constant is how generator upgrades invalidate every
26
+ // previously-written guide: it is a fingerprint input.
27
+ const GUIDE_SCHEMA_VERSION = 1;
41
28
 
42
29
  type Crawl = {
43
30
  tree: string[];
44
31
  extCounts: Map<string, number>;
45
32
  manifests: { path: string; content: string }[];
46
33
  workspaceMembers: string[];
34
+ seen: Set<string>;
47
35
  };
48
36
 
49
37
  // Marker the model appends to AGENTS.md so future runs can detect staleness exactly.
50
38
  const FP_MARKER = /<!-- opl-init:fp (\S+) -->/;
51
39
 
52
40
  // Exact snapshot of git-tracked + untracked state. Respects .gitignore for free.
41
+ // Content-based: HEAD + per-path content hashes of every dirty tracked file
42
+ // (vs HEAD, so staged changes count) and every untracked file. Only the root
43
+ // AGENTS.md is excluded, so writing the guide does not make it stale;
44
+ // subdirectory AGENTS.md files are repository facts.
53
45
  // Returns null for non-git directories (caller falls back to fingerprintFallback).
54
46
  function fingerprintGit(root: string): string | null {
47
+ const head = gitOutput(root, ["rev-parse", "HEAD"]);
48
+ if (head === null) return null;
49
+ const hash = createHash("sha256").update(`schema:${GUIDE_SCHEMA_VERSION}\0head:${head}\0`);
50
+ // --relative keeps diff paths cwd-relative and cwd-scoped, matching
51
+ // ls-files --others and the crawl; without it a subdirectory session would
52
+ // resolve every dirty path to a nonexistent file and hash "DELETED".
53
+ 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");
56
+ }
57
+ 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");
60
+ }
61
+ return hash.digest("hex").slice(0, 16);
62
+ }
63
+
64
+ function gitOutput(root: string, args: string[]): string | null {
55
65
  try {
56
- const head = execFileSync("git", ["rev-parse", "HEAD"], {
66
+ return execFileSync("git", args, {
57
67
  cwd: root,
58
68
  encoding: "utf8",
59
69
  stdio: ["ignore", "pipe", "ignore"],
70
+ maxBuffer: 64 * 1024 * 1024,
60
71
  });
61
- const status = execFileSync(
62
- "git",
63
- ["status", "--porcelain=v1", "--untracked-files=all"],
64
- { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] },
65
- );
66
- const relevantStatus = status.split("\n").filter(line => !line.endsWith("AGENTS.md")).join("\n");
67
- return createHash("sha256").update(head).update(relevantStatus).digest("hex").slice(0, 16);
68
72
  } catch {
69
73
  return null;
70
74
  }
71
75
  }
72
76
 
77
+ function hashFile(path: string): string {
78
+ try {
79
+ return createHash("sha256").update(readFileSync(path)).digest("hex");
80
+ } catch {
81
+ return "DELETED";
82
+ }
83
+ }
84
+
73
85
  // ponytail: stat-only (path|size|mtime), not content hashing. Catches add/delete/
74
86
  // rename but shares the touch-without-change blind spot; only used for non-git dirs.
75
87
  function fingerprintFallback(root: string): string {
@@ -101,7 +113,7 @@ function fingerprintFallback(root: string): string {
101
113
 
102
114
  walk(root);
103
115
  parts.sort();
104
- return createHash("sha256").update(parts.join("\n")).digest("hex").slice(0, 16);
116
+ return createHash("sha256").update(`schema:${GUIDE_SCHEMA_VERSION}\n`).update(parts.join("\n")).digest("hex").slice(0, 16);
105
117
  }
106
118
 
107
119
  function fingerprint(root: string): string {
@@ -114,6 +126,7 @@ function crawl(root: string): Crawl {
114
126
  extCounts: new Map(),
115
127
  manifests: [],
116
128
  workspaceMembers: [],
129
+ seen: new Set(),
117
130
  };
118
131
 
119
132
  function walk(dir: string, depth: number, prefix: string, maxDepth: number) {
@@ -144,17 +157,23 @@ function crawl(root: string): Crawl {
144
157
  }
145
158
 
146
159
  files.push(entry);
147
- const extension = extname(entry) || entry;
148
- result.extCounts.set(extension, (result.extCounts.get(extension) ?? 0) + 1);
149
-
150
- if (MANIFESTS.has(entry)) {
151
- try {
152
- result.manifests.push({
153
- path: relative(root, path),
154
- content: readFileSync(path, "utf8").slice(0, MAX_MANIFEST_BYTES),
155
- });
156
- } catch {
157
- // Skip unreadable manifests.
160
+ // Dedupe by absolute path: declared-member re-walks overlap the root
161
+ // walk, and the inventory must not double-count. Tree lines still
162
+ // accept shallow overlap for completeness.
163
+ if (!result.seen.has(path)) {
164
+ result.seen.add(path);
165
+ const extension = extname(entry) || entry;
166
+ result.extCounts.set(extension, (result.extCounts.get(extension) ?? 0) + 1);
167
+
168
+ if (MANIFESTS.has(entry)) {
169
+ try {
170
+ result.manifests.push({
171
+ path: relative(root, path),
172
+ content: readFileSync(path, "utf8").slice(0, MAX_MANIFEST_PARSE_BYTES),
173
+ });
174
+ } catch {
175
+ // Skip unreadable manifests.
176
+ }
158
177
  }
159
178
  }
160
179
  }
@@ -295,157 +314,194 @@ function result_tree_paths(root: string): string[] {
295
314
  return paths;
296
315
  }
297
316
 
298
- function packageScripts(content: string): string | null {
299
- try {
300
- const packageJson = JSON.parse(content);
301
- if (!packageJson.scripts || !Object.keys(packageJson.scripts).length) return null;
302
- return Object.entries(packageJson.scripts)
303
- .map(([name, command]) => ` ${name}: ${command}`)
304
- .join("\n");
305
- } catch {
306
- return null;
307
- }
308
- }
309
-
310
- function findRuleSources(root: string): { path: string; detail: string }[] {
311
- const found: { path: string; detail: string }[] = [];
312
- for (const name of RULE_SOURCES) {
313
- const path = join(root, name);
314
- let stats;
317
+ // Root-first, per-package-labeled script lines, bounded at 30 entries total.
318
+ function aggregateScripts(manifests: { path: string; content: string }[]): string | null {
319
+ const lines: string[] = [];
320
+ const sorted = [...manifests].sort((a, b) =>
321
+ a.path === "package.json" ? -1 : b.path === "package.json" ? 1 : a.path.localeCompare(b.path));
322
+ for (const manifest of sorted) {
323
+ if (!manifest.path.endsWith("package.json") || lines.length >= 30) continue;
324
+ let parsed;
315
325
  try {
316
- stats = statSync(path);
326
+ parsed = JSON.parse(manifest.content);
317
327
  } catch {
318
328
  continue;
319
329
  }
320
- if (stats.isDirectory()) {
321
- // ponytail: filenames only for rule dirs; model can read entries itself.
322
- let entries: string[] = [];
323
- try {
324
- entries = readdirSync(path).slice(0, 20);
325
- } catch {
326
- continue;
327
- }
328
- found.push({ path: `${name}/ (${entries.length} file${entries.length === 1 ? "" : "s"})`, detail: entries.join(", ") });
329
- } else {
330
- try {
331
- found.push({ path: name, detail: readFileSync(path, "utf8").slice(0, MAX_RULE_SOURCE_BYTES) });
332
- } catch {
333
- found.push({ path: name, detail: "(unreadable)" });
334
- }
330
+ const scripts = parsed?.scripts;
331
+ if (!scripts || typeof scripts !== "object") continue;
332
+ const label = manifest.path === "package.json" ? "" : `${manifest.path.replace(/\/package\.json$/, "")}/ `;
333
+ for (const [name, command] of Object.entries(scripts)) {
334
+ if (lines.length >= 30) break;
335
+ lines.push(` ${label}${name}: ${command}`);
335
336
  }
336
337
  }
337
- return found;
338
+ return lines.length ? lines.join("\n") : null;
338
339
  }
339
340
 
340
- function buildContext(root: string, crawlResult: Crawl): string {
341
- const extensions = [...crawlResult.extCounts.entries()]
342
- .sort((a, b) => b[1] - a[1])
343
- .map(([extension, count]) => `${extension} (${count})`)
344
- .join(", ");
345
- const sections = [
346
- `## Repository crawl for: ${root}`,
347
- `### Directory tree (depth ${MAX_DEPTH}; declared workspace members get their own depth-${MAX_MEMBER_DEPTH} walk; per-directory entries capped at ${MAX_DIR_ENTRIES})\n\`\`\`\n${crawlResult.tree.join("\n")}\n\`\`\``,
348
- `### File counts by extension\n${extensions || "(none)"}`,
349
- ];
350
-
351
- if (crawlResult.workspaceMembers.length) {
352
- sections.push(`### Declared workspace members (expanded above)\n${crawlResult.workspaceMembers.join(", ")}`);
353
- }
341
+ function buildGuide(root: string, crawlResult: Crawl, marker: string): string {
342
+ const topLevel = crawlResult.tree.filter(line => !line.startsWith(" ")).slice(0, 30).join(", ");
343
+ const scripts = aggregateScripts(crawlResult.manifests);
344
+ const commandBlock = scripts ? "Package scripts:\n```\n" + scripts + "\n```" : "No package scripts were detected by the repository crawl.";
345
+ return `# Repository Guide\n\n## What this is\n\nRepository at \`${root}\`. Use the repository files as the source of truth; the top-level inventory includes ${topLevel || "(not available)"}.\n\n## Commands\n\n${commandBlock}\n\n## Repository inventory\n\n- File types: ${[...crawlResult.extCounts.entries()].map(([extension, count]) => `${extension} (${count})`).join(", ") || "none detected"}.\n- Inspect specific files before changing behavior; this guide is a starting point, not a substitute for reading the code.\n\n## Agent workflow\n\nKeep changes focused on the requested behavior, preserve existing interfaces, and run the narrowest relevant test before the full suite. Keep secrets and generated output out of tracked configuration.\n${marker}\n`;
346
+ }
354
347
 
355
- if (crawlResult.manifests.length) {
356
- // ponytail: send paths + package.json scripts only; the model can read a
357
- // manifest itself if it needs the raw body. Keeps the prompt small and stable.
358
- sections.push("### Manifests (read these files directly if you need more detail)");
359
- for (const manifest of crawlResult.manifests) {
360
- sections.push(`**${manifest.path}**`);
361
- if (manifest.path.endsWith("package.json")) {
362
- const scripts = packageScripts(manifest.content);
363
- if (scripts) sections.push(`Scripts:\n\`\`\`\n${scripts}\n\`\`\``);
364
- }
348
+ const MAX_EVIDENCE_BYTES = 24576;
349
+ const MAX_README_HEAD_BYTES = 2048;
350
+
351
+ // Evidence the model cannot fetch itself: the deterministic stand-in for the
352
+ // tools the refine call does not have, under a hard byte budget. The spec's
353
+ // three parts: baseline guide, full per-package scripts blocks, readme heads.
354
+ function evidencePacket(
355
+ root: string,
356
+ baseline: string,
357
+ crawlResult: { manifests: { path: string; content: string }[]; workspaceMembers: string[] },
358
+ ): string {
359
+ const parts = [baseline];
360
+ let used = Buffer.byteLength(baseline, "utf8");
361
+ const push = (block: string): boolean => {
362
+ const bytes = Buffer.byteLength(block, "utf8") + 1;
363
+ if (used + bytes > MAX_EVIDENCE_BYTES) {
364
+ parts.push("(evidence truncated)");
365
+ return false;
366
+ }
367
+ used += bytes;
368
+ parts.push(block);
369
+ return true;
370
+ };
371
+ for (const manifest of crawlResult.manifests) {
372
+ if (!manifest.path.endsWith("package.json")) continue;
373
+ let parsed;
374
+ try {
375
+ parsed = JSON.parse(manifest.content);
376
+ } catch {
377
+ continue;
365
378
  }
379
+ const scripts = parsed?.scripts;
380
+ if (!scripts || typeof scripts !== "object") continue;
381
+ const block = `=== scripts: ${manifest.path} ===\n${Object.entries(scripts).map(([name, command]) => `${name}: ${command}`).join("\n")}`;
382
+ if (!push(block)) return parts.join("\n");
366
383
  }
367
-
368
- const ruleSources = findRuleSources(root);
369
- if (ruleSources.length) {
370
- sections.push("### Existing agent rule sources (from other coding agents)");
371
- for (const source of ruleSources) {
372
- sections.push(`**${source.path}**\n\`\`\`\n${source.detail}\n\`\`\``);
384
+ const candidates = ["README.md", "readme.md", "README", "CLAUDE.md"];
385
+ const paths = [...candidates, ...crawlResult.workspaceMembers.flatMap((m) => candidates.map((c) => `${m}/${c}`))];
386
+ for (const rel of paths) {
387
+ let head: string;
388
+ try {
389
+ head = readFileSync(join(root, rel), "utf8").slice(0, MAX_README_HEAD_BYTES);
390
+ } catch {
391
+ continue;
373
392
  }
393
+ if (!push(`=== ${rel} ===\n${head}`)) break;
374
394
  }
375
-
376
- return sections.join("\n\n");
395
+ return parts.join("\n");
377
396
  }
378
397
 
379
- function fallbackGuide(root: string, crawlResult: Crawl, marker: string): string {
380
- const topLevel = crawlResult.tree.filter(line => !line.startsWith(" ")).slice(0, 30).join(", ");
381
- const scripts = crawlResult.manifests
382
- .map(manifest => manifest.path.endsWith("package.json") ? packageScripts(manifest.content) : null)
383
- .find(Boolean);
384
- const commandBlock = scripts ? "Package scripts:\n```\n" + scripts + "\n```" : "No package scripts were detected by the repository crawl.";
385
- return `# Repository Guide\n\n## What this is\n\nRepository at \`${root}\`. Use the repository files as the source of truth; the top-level inventory includes ${topLevel || "(not available)"}.\n\n## Commands\n\n${commandBlock}\n\n## Repository inventory\n\n- File types: ${[...crawlResult.extCounts.entries()].map(([extension, count]) => `${extension} (${count})`).join(", ") || "none detected"}.\n- The full crawl is available in the init task context. Inspect specific files before changing behavior.\n\n## Agent workflow\n\nKeep changes focused on the requested behavior, preserve existing interfaces, and run the narrowest relevant test before the full suite. Keep secrets and generated output out of tracked configuration.\n${marker}\n`;
398
+ const REFINE_SYSTEM_PROMPT = [
399
+ "You are rewriting a repository guide for an AI coding agent.",
400
+ "Input: a draft guide generated from a deterministic repository crawl, plus bounded file excerpts.",
401
+ "Return ONLY the final Markdown document: no preamble, no code fence around the document, no HTML comments.",
402
+ "Do not invent facts unsupported by the input; keep the factual inventory (commands, file types, workspace members).",
403
+ 'Prefer these sections when evidence supports them: "## What this is", "## Commands", "## Architecture", "## Configuration and installation", "## Testing and operational quirks", "## Key files". Omit unsupported sections.',
404
+ "Usually 250-700 words. Avoid generic contribution, Git, or pull-request advice.",
405
+ ].join("\n");
406
+
407
+ // The model never owns the marker line: every opl-init:fp comment is stripped
408
+ // and the extension's exact marker is appended as the final line.
409
+ function finalizeRefinedGuide(text: string, marker: string): string {
410
+ let body = text.trim();
411
+ // Strip a wrapper fence only when it opens *and* closes; a guide ending in
412
+ // a legitimate code block keeps its closing fence.
413
+ if (/^```[a-zA-Z]*\s*\n/.test(body)) {
414
+ body = body.replace(/^```[a-zA-Z]*\s*\n/, "").replace(/\n```\s*$/, "");
415
+ }
416
+ body = body.replace(/<!-- opl-init:fp \S+ -->/g, "").trimEnd();
417
+ return `${body}\n${marker}\n`;
386
418
  }
387
419
 
388
- const PROMPT = `Generate or update AGENTS.md as an agent guide for this repository. This is an execution task, not a request for prose in chat. You MUST use the write tool to write the guide to the repository's AGENTS.md path; a response containing Markdown without writing the file is a failure. After writing, use the read tool to verify the file exists and ends with the exact fingerprint marker. Do not modify, refactor, or create any other file.
389
- If AGENTS.md already exists and the repository crawl below says it is current, do not overwrite or modify it. If it is marked stale, update it. If it does not exist, create it.
390
-
391
- The repository crawl below (directory tree, file counts, manifests, and any existing rule sources) is the primary source of truth. It was produced for you, so do not re-crawl, re-list, or re-scan the tree, and do not spawn subagents or task lists for this. Read a small number of specific files directly only when you must confirm a repository-specific fact the crawl does not already answer.
392
-
393
- Produce a concise, useful Markdown guide, usually 250-700 words:
394
- - Start with a project-specific title such as "# Repository Guide" or "# <project>".
395
- - Prefer these sections when supported by evidence: "## What this is", "## Commands", "## Architecture", "## Configuration and installation", "## Testing and operational quirks", and "## Key files".
396
- - State meaningful absences when known, such as no build, lint, or typecheck command.
397
- - Capture non-obvious constraints, state, deployment, security, or test-order invariants when they materially affect contributors.
398
- - Avoid generic contribution, Git, or pull-request advice unless the repository supplies specific facts for it.
399
- - Omit unsupported sections rather than inventing details.
400
- - If existing agent rule sources are listed below, incorporate their relevant project-specific content into the guide. Do not copy them verbatim and do not treat them as authoritative over evidence from this repository.
401
- - IMPORTANT: when you write or update AGENTS.md, make the very last line exactly the fingerprint marker given below (verbatim, no changes). This lets future runs detect staleness.
420
+ // One out-of-band completion on the current model. No tools, no session
421
+ // message: any failure returns null and the caller writes the baseline.
422
+ // Bounded so a stalled provider cannot leave /init hanging with no UI signal
423
+ // (the old injected turn was Esc-cancellable; this replaces that escape hatch).
424
+ const REFINE_TIMEOUT_MS = 120000;
402
425
 
403
- ---
404
- `;
426
+ async function refineGuide(ctx: any, evidence: string, marker: string): Promise<string | null> {
427
+ const model = ctx.model;
428
+ if (!model || typeof ctx.modelRegistry?.streamSimple !== "function") return null;
429
+ ctx.ui?.notify?.("opl-init: refining the guide with the current model...", "info");
430
+ try {
431
+ const stream = ctx.modelRegistry.streamSimple(
432
+ model,
433
+ { systemPrompt: REFINE_SYSTEM_PROMPT, messages: [{ role: "user", content: evidence, timestamp: Date.now() }], tools: [] },
434
+ { reasoning: false, signal: AbortSignal.timeout(REFINE_TIMEOUT_MS) },
435
+ );
436
+ const result = await stream.result();
437
+ if (!result || result.stopReason === "error" || result.stopReason === "aborted") return null;
438
+ const text = (result.content ?? [])
439
+ .filter((part: any) => part.type === "text")
440
+ .map((part: any) => part.text)
441
+ .join("\n");
442
+ return text.trim() ? finalizeRefinedGuide(text, marker) : null;
443
+ } catch {
444
+ return null;
445
+ }
446
+ }
405
447
 
406
- // Named export for fixture tests (tests/opl-init-crawl.test.mjs).
407
- export { crawl };
448
+ // Named exports for fixture tests (tests/opl-init-*.test.mjs).
449
+ export { crawl, fingerprint, evidencePacket, finalizeRefinedGuide, refineGuide };
408
450
 
409
451
  export default function (pi: ExtensionAPI) {
410
452
  pi.registerCommand("init", {
411
- description: "[opl-init] Crawl the repository and generate an AGENTS.md contributor guide",
453
+ description: "[opl-init] Crawl + refine AGENTS.md out-of-band; zero model calls when current",
412
454
  handler: async (_args, ctx) => {
413
455
  const root = ctx.cwd;
414
456
  const agentsPath = join(root, "AGENTS.md");
415
- const currentFp = fingerprint(root);
416
- const exists = existsSync(agentsPath);
417
-
418
- let storedFp: string | null = null;
419
- if (exists) {
457
+ const storedFp = () => {
420
458
  try {
421
- storedFp = readFileSync(agentsPath, "utf8").match(FP_MARKER)?.[1] ?? null;
459
+ return readFileSync(agentsPath, "utf8").match(FP_MARKER)?.[1] ?? null;
422
460
  } catch {
423
- storedFp = null;
461
+ return null;
424
462
  }
425
- }
463
+ };
426
464
 
427
- if (exists && storedFp === currentFp) {
465
+ let currentFp = fingerprint(root);
466
+ if (existsSync(agentsPath) && storedFp() === currentFp) {
428
467
  ctx.ui.notify("AGENTS.md is current; /init will not modify it.", "info");
429
468
  return;
430
469
  }
431
470
 
432
- const status = !exists
433
- ? "AGENTS.md status: missing; create it."
434
- : storedFp === null
435
- ? "AGENTS.md status: no fingerprint marker found; treat as stale and update it."
436
- : "AGENTS.md status: stale (fingerprint mismatch); update it.";
471
+ if (!ctx.isIdle()) {
472
+ ctx.ui.notify("opl-init: waiting for the current agent run to settle before writing AGENTS.md.", "info");
473
+ await ctx.waitForIdle();
474
+ // The settling run may have moved the tree; recompute before crawling.
475
+ currentFp = fingerprint(root);
476
+ if (existsSync(agentsPath) && storedFp() === currentFp) {
477
+ ctx.ui.notify("AGENTS.md is current; /init will not modify it.", "info");
478
+ return;
479
+ }
480
+ }
481
+
437
482
  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.
438
485
  const crawlResult = crawl(root);
486
+ const baseline = buildGuide(root, crawlResult, marker);
487
+ const refined = await refineGuide(ctx, evidencePacket(root, baseline, crawlResult), marker);
488
+
439
489
  try {
440
- writeFileSync(agentsPath, fallbackGuide(root, crawlResult, marker), "utf8");
441
- ctx.ui.notify("AGENTS.md baseline written; the agent will refine it from the crawl.", "info");
490
+ writeFileSync(agentsPath, refined ?? baseline, "utf8");
442
491
  } catch (error: any) {
443
- ctx.ui.notify(`Could not write AGENTS.md baseline: ${error?.message || error}`, "error");
492
+ ctx.ui.notify(`Could not write AGENTS.md: ${error?.message || error}`, "error");
493
+ return;
444
494
  }
445
495
 
446
- pi.sendUserMessage(
447
- `${PROMPT}\n${status}\nFingerprint marker to append as the last line: ${marker}\n\n${buildContext(root, crawlResult)}`,
496
+ ctx.ui.notify(
497
+ refined
498
+ ? "AGENTS.md written from the repository crawl (model-refined)."
499
+ : "refine failed; wrote deterministic AGENTS.md from the repository crawl.",
500
+ "info",
448
501
  );
502
+ // Terminal: pi docs say code after ctx.reload() runs in the old frame.
503
+ await ctx.reload();
504
+ return;
449
505
  },
450
506
  });
451
507
  }
@@ -15,11 +15,12 @@ No commands, flags, or shortcuts. It replaces the standard editor at session sta
15
15
  - **Scroll-aware borders**: Editor scroll indicators are embedded in the top or bottom border when the input has more content than fits.
16
16
  - **Slash-menu placement**: The slash menu is rendered below the input with configurable gap and indentation.
17
17
  - **Responsive rendering**: Narrow terminals fall back to the native editor rendering; the companion is hidden below 40 columns.
18
+ - **Shared idle heartbeat**: this component owns the bundle's only render timer. With the companion disabled it slows to one repaint per second instead of ten, which still advances the footer's elapsed/time-per-second cells while Pi is idle.
18
19
  - **Hex and theme colors**: Color settings accept Pi theme tokens or six-digit hex colors.
19
20
 
20
21
  ## Mode styling precedence
21
22
 
22
- Exactly one style applies at a time, in this order: Bash (`!` input) > active `opl-modes` appearance > hardcoded mode fallback. The compiled fallbacks are normal: `❯`/`accent`/`border`; chat: `»`/`chatModeBorder`; and plan/execute: `⏸`/`customMessageLabel`.
23
+ Exactly one style applies at a time, in this order: Bash (`!` input) > active `opl-modes` appearance > hardcoded mode fallback. The compiled fallbacks are normal: `❯`/`accent`/`border`; chat: `»`/`borderAccent`; and plan/execute: `⏸`/`customMessageLabel`.
23
24
 
24
25
  ## Configuration
25
26
 
@@ -54,7 +55,7 @@ Create `~/.pi/agent/configs/opl-input.json` or copy [`configs/opl-input.json.sam
54
55
 
55
56
  | Field | Type | Default | Description |
56
57
  |---|---|---:|---|
57
- | `companion.enabled` | boolean | `false` | Show the animated companion above the input. |
58
+ | `companion.enabled` | boolean | `false` | Show the animated companion above the input. The idle repaint that drives it runs every 100 ms when enabled and every 1 s when disabled (the footer's time-based cells ride the same tick). |
58
59
  | `companion.color` | color | `"accent"` | Companion color. |
59
60
  | `companion.type` | string | unset | Select a named entry from `companion.types`; built-in `dog` also has a fallback shape (`"cat"` uses the default ears). |
60
61
  | `companion.ears` | string | cat ears | Directly override the companion's top line; wins over `type`. |
@@ -74,9 +75,10 @@ Every color option accepts either:
74
75
  - Syntax: `syntaxComment`, `syntaxKeyword`, `syntaxFunction`, `syntaxVariable`, `syntaxString`, `syntaxNumber`, `syntaxType`, `syntaxOperator`, `syntaxPunctuation`
75
76
  - Thinking borders: `thinkingOff`, `thinkingMinimal`, `thinkingLow`, `thinkingMedium`, `thinkingHigh`, `thinkingXhigh`, `thinkingMax`
76
77
  - Special: `bashMode`
77
- - **A six-digit hex color** — e.g. `"#c07898"` (rendered as ANSI truecolor, downgraded automatically on 256-color terminals).
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
79
 
79
80
  Invalid theme tokens fall back to the theme's `border` token; invalid hex renders uncolored rather than crashing.
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.
80
82
 
81
83
  ## Architecture
82
84
 
@@ -1,10 +1,10 @@
1
1
  import { CustomEditor, type ExtensionAPI, type Theme } from "@earendil-works/pi-coding-agent";
2
2
  import type { TUI, EditorTheme } from "@earendil-works/pi-tui";
3
3
  import type { KeybindingsManager } from "@earendil-works/pi-coding-agent";
4
- import { visibleWidth } from "@earendil-works/pi-tui";
4
+ import { visibleWidth, truncateToWidth } from "@earendil-works/pi-tui";
5
5
  import { CONFIG, COMPANION_PADDING, MIN_WIDTH_FOR_COMPANION } from "./config.js";
6
6
  import { resolveModeStyle, type ModeAppearance } from "./mode-style.js";
7
- import { applyColor, CompanionAnimator, startRenderTimer } from "./utils.js";
7
+ import { applyColor, CompanionAnimator, COMPANION_TICK_MS, IDLE_REPAINT_MS, startRenderTimer } from "./utils.js";
8
8
 
9
9
  // ─── Helpers ──────────────────────────────────────────────────────────────
10
10
  const ANSI_RE = /\x1b\[[0-9;]*m|\x1b\[0?m/g;
@@ -43,10 +43,15 @@ class ChatInput extends CustomEditor {
43
43
  this.inputTheme = theme;
44
44
 
45
45
  // Animate companion even when idle — tick drives state machine
46
- this.stopCompanionTimer = startRenderTimer(() => {
47
- this.animator.tick(Date.now());
48
- this.tui.requestRender();
49
- });
46
+ // With the companion hidden this component still owns the only idle repaint in the
47
+ // bundle (footer time-based cells read it), so the tick slows down instead of stopping.
48
+ this.stopCompanionTimer = startRenderTimer(
49
+ () => {
50
+ this.animator.tick(Date.now());
51
+ this.tui.requestRender();
52
+ },
53
+ CONFIG.COMPANION_ENABLED ? COMPANION_TICK_MS : IDLE_REPAINT_MS,
54
+ );
50
55
  }
51
56
 
52
57
  dispose(): void {
@@ -74,7 +79,9 @@ class ChatInput extends CustomEditor {
74
79
  const style = resolveModeStyle({ bash: isBash, mode: mode.mode, appearance: mode.appearance });
75
80
  const border = (s: string) => applyColor(this.uiTheme, style.borderColor, s);
76
81
  const accent = (s: string) => applyColor(this.uiTheme, style.prefixColor, s);
77
- const prefix = style.prefix;
82
+ // The layout reserves exactly one cell for the prefix (continuation lines pad with " "),
83
+ // so a wider configured prefix would push the box border past `width`.
84
+ const prefix = truncateToWidth(style.prefix, 1);
78
85
 
79
86
  if (CONFIG.BOXED_VIEW) {
80
87
  return this.renderBoxed(stock, contentWidth, width, border, accent, prefix);
@@ -21,7 +21,7 @@ export interface ResolvedModeStyle {
21
21
 
22
22
  const MODE_DEFAULTS: Record<string, Required<ModeAppearance>> = {
23
23
  off: { prefix: "❯", prefixColor: "accent", borderColor: "border" },
24
- chat: { prefix: "»", prefixColor: "chatModeBorder", borderColor: "chatModeBorder" },
24
+ chat: { prefix: "»", prefixColor: "borderAccent", borderColor: "borderAccent" },
25
25
  plan: { prefix: "⏸", prefixColor: "customMessageLabel", borderColor: "customMessageLabel" },
26
26
  execute: { prefix: "⏸", prefixColor: "customMessageLabel", borderColor: "customMessageLabel" },
27
27
  };
@@ -48,7 +48,9 @@ function isHexColor(color: string): boolean {
48
48
  }
49
49
 
50
50
  function hexToAnsi(hex: string): string {
51
- const h = hex.replace("#", "");
51
+ // Accept the #abc shorthand so a three-digit value renders the same as #aabbcc.
52
+ let h = hex.replace("#", "");
53
+ if (/^[0-9a-fA-F]{3}$/.test(h)) h = h.split("").map((c) => c + c).join("");
52
54
  if (!/^[0-9a-fA-F]{6}$/.test(h)) return "";
53
55
  const r = parseInt(h.slice(0, 2), 16);
54
56
  const g = parseInt(h.slice(2, 4), 16);
@@ -56,14 +58,23 @@ function hexToAnsi(hex: string): string {
56
58
  return `\x1b[38;2;${r};${g};${b}m`;
57
59
  }
58
60
 
59
- export function startRenderTimer(render: () => void): () => void {
60
- const timer = setInterval(render, 100);
61
+ /** Animation cadence while the companion is visible. */
62
+ export const COMPANION_TICK_MS = 100;
63
+ /** Idle repaint cadence with the companion off: keeps time-based footer cells advancing without 10 repaints/second. */
64
+ export const IDLE_REPAINT_MS = 1000;
65
+
66
+ export function startRenderTimer(render: () => void, periodMs: number = COMPANION_TICK_MS): () => void {
67
+ const timer = setInterval(render, periodMs);
61
68
  return () => clearInterval(timer);
62
69
  }
63
70
 
64
71
  export function applyColor(theme: Theme, color: string, text: string): string {
65
72
  if (isHexColor(color)) {
66
- return `${hexToAnsi(color)}${text}\x1b[0m`;
73
+ // A malformed hex yields "": render the text with no escape rather than an empty
74
+ // color plus a stray reset.
75
+ const ansi = hexToAnsi(color);
76
+ if (!ansi) return text;
77
+ return `${ansi}${text}\x1b[0m`;
67
78
  }
68
79
  try {
69
80
  return theme.fg(color as ThemeColor, text);
@@ -86,7 +97,7 @@ export interface CompanionState {
86
97
 
87
98
  type Phase = "face" | "ears" | "full" | "none";
88
99
 
89
- const TICK_MS = 100; // matches setInterval in ChatInput
100
+ const TICK_MS = COMPANION_TICK_MS; // companion animation frame budget
90
101
  const R = (min: number, max: number) => min + Math.random() * (max - min);
91
102
 
92
103
  export class CompanionAnimator {