@openlines/opl-pi-sht 0.1.21 → 0.2.0
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.
- package/README.md +8 -4
- package/configs/opl-modes.json.sample +24 -28
- package/extensions/opl-browser/browser.ts +4 -4
- package/extensions/opl-footer/README.md +9 -2
- package/extensions/opl-footer/git-status.ts +42 -0
- package/extensions/opl-footer/index.ts +26 -8
- package/extensions/opl-footer/segments/cost.ts +2 -2
- package/extensions/opl-footer/segments/helpers.ts +0 -2
- package/extensions/opl-footer/segments/mode-switcher.ts +4 -2
- package/extensions/opl-footer/theme.ts +24 -5
- package/extensions/opl-init/README.md +23 -9
- package/extensions/opl-init/index.ts +38 -124
- package/extensions/opl-input/README.md +5 -3
- package/extensions/opl-input/index.ts +14 -7
- package/extensions/opl-input/mode-style.ts +1 -1
- package/extensions/opl-input/utils.ts +16 -5
- package/extensions/opl-modes/README.md +19 -13
- package/extensions/opl-modes/config.ts +64 -16
- package/extensions/opl-modes/index.ts +156 -70
- package/extensions/opl-modes/state.ts +40 -18
- package/extensions/opl-modes/types.ts +2 -0
- package/extensions/opl-modes/utils.ts +123 -12
- package/extensions/opl-simplebench/benchmark.ts +24 -7
- package/extensions/opl-simplebench/index.ts +8 -5
- package/extensions/opl-simplebench/research.ts +4 -1
- package/extensions/opl-simplebench/util/providers.ts +6 -1
- package/extensions/opl-todo/index.ts +2 -2
- package/package.json +2 -2
|
@@ -22,22 +22,6 @@ const MAX_MEMBER_DEPTH = 3;
|
|
|
22
22
|
const MAX_TREE_LINES = 300;
|
|
23
23
|
const MAX_MANIFEST_BYTES = 2048;
|
|
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
|
-
];
|
|
41
25
|
|
|
42
26
|
type Crawl = {
|
|
43
27
|
tree: string[];
|
|
@@ -307,109 +291,41 @@ function packageScripts(content: string): string | null {
|
|
|
307
291
|
}
|
|
308
292
|
}
|
|
309
293
|
|
|
310
|
-
function
|
|
311
|
-
const found: { path: string; detail: string }[] = [];
|
|
312
|
-
for (const name of RULE_SOURCES) {
|
|
313
|
-
const path = join(root, name);
|
|
314
|
-
let stats;
|
|
315
|
-
try {
|
|
316
|
-
stats = statSync(path);
|
|
317
|
-
} catch {
|
|
318
|
-
continue;
|
|
319
|
-
}
|
|
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
|
-
}
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
return found;
|
|
338
|
-
}
|
|
339
|
-
|
|
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
|
-
}
|
|
354
|
-
|
|
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
|
-
}
|
|
365
|
-
}
|
|
366
|
-
}
|
|
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\`\`\``);
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
return sections.join("\n\n");
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
function fallbackGuide(root: string, crawlResult: Crawl, marker: string): string {
|
|
294
|
+
function buildGuide(root: string, crawlResult: Crawl, marker: string): string {
|
|
380
295
|
const topLevel = crawlResult.tree.filter(line => !line.startsWith(" ")).slice(0, 30).join(", ");
|
|
381
296
|
const scripts = crawlResult.manifests
|
|
382
297
|
.map(manifest => manifest.path.endsWith("package.json") ? packageScripts(manifest.content) : null)
|
|
383
298
|
.find(Boolean);
|
|
384
299
|
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-
|
|
300
|
+
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`;
|
|
386
301
|
}
|
|
387
302
|
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
-
|
|
399
|
-
-
|
|
400
|
-
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
303
|
+
/**
|
|
304
|
+
* Opt-in refinement request for `/init --refine`. Deliberately small: the crawl inventory is
|
|
305
|
+
* already in the guide, so the model reads the file instead of receiving a second copy of the
|
|
306
|
+
* tree. This is the only injected user turn in the extension, and it never fires on its own.
|
|
307
|
+
*/
|
|
308
|
+
function refinePrompt(agentsPath: string, marker: string): string {
|
|
309
|
+
return [
|
|
310
|
+
`Refine the repository guide at ${agentsPath}, which /init generated from a repository crawl.`,
|
|
311
|
+
"Read the guide first, then open only the specific files you need to confirm repository-specific facts. Do not re-crawl the tree, spawn subagents, or create task lists.",
|
|
312
|
+
"Rewrite the guide in place with the write tool, concise and evidence-based (usually 250-700 words):",
|
|
313
|
+
'- Prefer these sections when evidence supports them: "## What this is", "## Commands", "## Architecture", "## Configuration and installation", "## Testing and operational quirks", "## Key files". Omit unsupported sections instead of inventing them, and state meaningful absences such as no build, lint, or typecheck command.',
|
|
314
|
+
"- Keep the crawl's factual inventory (commands, file types, workspace members) and avoid generic contribution, Git, or pull-request advice.",
|
|
315
|
+
"- Do not modify, create, or delete any other file.",
|
|
316
|
+
`- Make the very last line exactly: ${marker}`,
|
|
317
|
+
"A response containing Markdown without writing the file is a failure.",
|
|
318
|
+
].join("\n");
|
|
319
|
+
}
|
|
405
320
|
|
|
406
321
|
// Named export for fixture tests (tests/opl-init-crawl.test.mjs).
|
|
407
322
|
export { crawl };
|
|
408
323
|
|
|
409
324
|
export default function (pi: ExtensionAPI) {
|
|
410
325
|
pi.registerCommand("init", {
|
|
411
|
-
description: "[opl-init] Crawl the repository and
|
|
412
|
-
handler: async (
|
|
326
|
+
description: "[opl-init] Crawl the repository and write an AGENTS.md guide (--refine also asks the model to expand it)",
|
|
327
|
+
handler: async (args, ctx) => {
|
|
328
|
+
const refine = /(?:^|\s)--refine(?:\s|$)/.test(args.trim());
|
|
413
329
|
const root = ctx.cwd;
|
|
414
330
|
const agentsPath = join(root, "AGENTS.md");
|
|
415
331
|
const currentFp = fingerprint(root);
|
|
@@ -424,28 +340,26 @@ export default function (pi: ExtensionAPI) {
|
|
|
424
340
|
}
|
|
425
341
|
}
|
|
426
342
|
|
|
343
|
+
const marker = `<!-- opl-init:fp ${currentFp} -->`;
|
|
344
|
+
|
|
427
345
|
if (exists && storedFp === currentFp) {
|
|
428
346
|
ctx.ui.notify("AGENTS.md is current; /init will not modify it.", "info");
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
? "AGENTS.md
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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");
|
|
442
|
-
} catch (error: any) {
|
|
443
|
-
ctx.ui.notify(`Could not write AGENTS.md baseline: ${error?.message || error}`, "error");
|
|
347
|
+
} else {
|
|
348
|
+
// Regenerating replaces the whole file, including hand-edited prose; the repository's
|
|
349
|
+
// version control is the recovery path.
|
|
350
|
+
const crawlResult = crawl(root);
|
|
351
|
+
try {
|
|
352
|
+
writeFileSync(agentsPath, buildGuide(root, crawlResult, marker), "utf8");
|
|
353
|
+
ctx.ui.notify(exists ? "AGENTS.md overwritten from the repository crawl." : "AGENTS.md created.", "info");
|
|
354
|
+
} catch (error: any) {
|
|
355
|
+
ctx.ui.notify(`Could not write AGENTS.md: ${error?.message || error}`, "error");
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
444
358
|
}
|
|
445
359
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
);
|
|
360
|
+
// Queue, never steer: Pi throws "Agent is already processing" for an injection with no
|
|
361
|
+
// deliverAs while a turn is streaming, and steering would hijack the task in flight.
|
|
362
|
+
if (refine) pi.sendUserMessage(refinePrompt(agentsPath, marker), { deliverAs: "followUp" });
|
|
449
363
|
},
|
|
450
364
|
});
|
|
451
365
|
}
|
|
@@ -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: `»`/`
|
|
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
|
|
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
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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: "
|
|
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
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|
+
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 {
|
|
@@ -4,36 +4,40 @@ Unified mode manager for Pi. It provides normal, read-only chat, read-only plann
|
|
|
4
4
|
|
|
5
5
|
## Commands, flags, and shortcuts
|
|
6
6
|
|
|
7
|
-
- `/mode` opens the picker; `/mode chat`, `/mode plan`, and `/mode normal` select built-in modes.
|
|
7
|
+
- `/mode` opens the picker; `/mode chat`, `/mode plan`, and `/mode normal` select built-in modes, and `/mode <name>` selects any registered custom mode (for example `/mode review`). `execute` is excluded because it needs an active plan; use `/execute`.
|
|
8
8
|
- `/chat` toggles chat mode; `/chat off` exits it.
|
|
9
9
|
- `/plan` toggles plan mode, creates or loads a named plan, or accepts `/plan off`.
|
|
10
10
|
- `/execute` selects or executes an existing plan; `/execute <name>` runs a named plan.
|
|
11
11
|
- `--chat` and `--plan` start Pi in the corresponding read-only mode.
|
|
12
|
-
- The configured cycle shortcut rotates through enabled visible modes. Execute mode is excluded from cycling because it requires an active plan.
|
|
12
|
+
- The configured cycle shortcut rotates through enabled visible modes. Execute mode is excluded from cycling because it requires an active plan. The default `shift+tab` is also Pi's built-in `app.thinking.cycle`, so both fire on one press; rebind either action in `~/.pi/agent/keybindings.json` (or pick another `shortcuts.cycleMode`) if you want them separate.
|
|
13
13
|
|
|
14
|
-
Plans are Markdown files under `.pi/plans/` with the `plan-` filename prefix. `plan_complete` is available
|
|
14
|
+
Plans are Markdown files under `.pi/plans/` with the `plan-` filename prefix. A plan name must contain at least one letter or digit, so `/plan .` or `/plan --` is rejected instead of producing an untitled `plan-.md`. `plan_complete` is available in execute mode and in any mode that sets `allowPlanComplete`. On completion, the plan file is deleted when `cleanup.cleanupOnComplete` is enabled. If execution ends without `plan_complete`, execute mode is exited automatically — but only when a turn actually finished: an aborted (ESC) or errored turn keeps execute mode active so the plan can be resumed.
|
|
15
15
|
|
|
16
16
|
## Extension features
|
|
17
17
|
|
|
18
18
|
### Mode behavior
|
|
19
19
|
|
|
20
|
-
Chat and plan modes replace the active tools with configurable read-only tool lists and restrict Bash to safe inspection patterns. Destructive patterns are checked even when a command matches a safe pattern. Valid user-provided safe and destructive pattern arrays replace the built-in lists, rather than extending them. An explicit empty per-mode array replaces that policy with none; a malformed non-empty per-mode array is ignored and inherits the existing policy.
|
|
20
|
+
Chat and plan modes replace the active tools with configurable read-only tool lists and restrict Bash to safe inspection patterns. The allowlist is checked **per shell segment**, not just at the start of the command line: `&&`, `||`, a lone `&` (backgrounding), `|`, `;`, newlines, `$(...)`, backticks, and `<(`/`>(` each begin a segment, and every segment must match a safe pattern. Segment scanning is quote-aware, so `grep "a|b"` is one command, but a `$(...)` or backtick payload still runs inside double quotes and is split out and checked, so `echo "$(node -e ...)"` cannot hide behind the quoted argument. `cat f && node -e '...'`, `ls; python -c '...'`, and `cat x & rm -rf /` are blocked because their second segment is not safe-listed. Destructive patterns are still checked even when a command matches a safe pattern. The allowlist covers read-only inspection and text filters only: `awk`, `sed`, `xargs`, `perl`, `python`, and `env` are deliberately absent because they can write files, exec programs from their arguments, or dump API keys. Valid user-provided safe and destructive pattern arrays replace the built-in lists, rather than extending them. An explicit empty per-mode array replaces that policy with none; a malformed non-empty per-mode array is ignored and inherits the existing policy.
|
|
21
21
|
|
|
22
|
-
The shared destructive base
|
|
22
|
+
The shared destructive base blocks file mutation primitives (`rm`, `rmdir`, `mv`, `cp`, `mkdir`, `touch`, `chmod`, `chown`, `tee`, `dd`, `shred`, `truncate`), `find -delete`/`-exec`/`-execdir`/`--exec-batch` and find's file-writing predicates (`-fprint`, `-fprint0`, `-fprintf`, `-fls`), any `--output` file (`sort --output`, `git log/diff/show --output`), `sort -o`/`tree -o` in any argument position, `fd -x`/`-X` (and clusters like `-Hx`), `rg --pre`, `jq`'s environment access (`jq -n env`, `$ENV`), `npm`/`yarn`/`pip` install and update forms including `npm audit fix`, state-changing `git` subcommands (including `branch -d`/`-D`/`-m`/`-M`/`-c`/`-C`/`--delete`/`--unset-upstream` and the writing `git remote` subcommands), `sudo`/`su`, `kill`/`pkill`, shell spawns, editors, `uniq`'s output operand (`uniq -c in.txt out.txt`; flags are not operands, so `uniq -c sorted.txt` stays a read), and every redirect that names a file - while descriptor duplication (`2>&1`, `>&2`, `>&-`) and a redirect whose target is exactly `/dev/null` are not writes and stay allowed (`> /dev/null/../tmp/x` is a write, so a path that merely starts with that prefix is blocked). `env` and `printenv` are not safe-listed, since they could dump provider API keys into the model context. Program-name entries are anchored to command position and matched per segment, so a read-only command that merely *mentions* a dangerous word in an argument (`du -sh`, `find . -name '*.sh'`, `ls cp/`, `cat mv.sh`, `git log --grep=rm`, `grep -rn 'touch' src`) stays allowed, and descriptor duplication (`2>&1`, `>&2`, `>&-`) is not mistaken for a file write. Every destructive pattern is tested against the command and each of its segments, with and without quotes/backslashes stripped, so `r"m"` and `-del"ete"` cannot dodge the list. That blocklist split deliberately ignores quotes, so an operator inside a quoted string (`echo "a > b"`) is over-blocked rather than trusted. This is still a heuristic backstop, not a process-isolation boundary: `npx` and any command whose *name* comes from an expansion are outside what a pattern list can catch.
|
|
23
23
|
|
|
24
|
-
Custom modes can add or override modes with prompts, tool lists, Bash patterns, model overrides, visibility, enabled state, `plan_complete` permission, execute-handoff permission, labels, and `appearance`. Appearance is published to `opl-input` and `opl-footer`, so every mode owns its input prefix/border and footer identity color. A
|
|
24
|
+
Custom modes can add or override modes with prompts, tool lists, Bash patterns, model overrides, visibility, enabled state, `plan_complete` permission, execute-handoff permission, labels, and `appearance`. `visible: false` hides a mode from the picker and the cycle list but leaves it reachable through `/mode <name>`, on purpose: it is the escape hatch for a mode you want available without cluttering the menu. Appearance is published to `opl-input` and `opl-footer`, so every mode owns its input prefix/border and footer identity color. A mode that omits its own `safePatterns`/`destructivePatterns` inherits the whole shared base; a mode overrides a component with its own array or disables all gating with `unrestrictedBash: true` — which works for built-in overrides (`modes.chat`, `modes.plan`, ...) as well as new custom modes. Built-in `off` and `execute` stay unrestricted unless you give them their own `tools` list.
|
|
25
25
|
|
|
26
26
|
### Execute handoff
|
|
27
27
|
|
|
28
|
-
Any mode can start plan execution via the mode picker's `Execute:` entries or `/execute`, unless it sets `allowExecute: false`. When blocked, the picker hides the `Execute:` entries and `/execute` reports that execution is unavailable from the current mode. The plan-mode action menu (`Execute / Refine / Save & Exit / Discard & Exit`) is the designed plan-to-execute pipeline and is always available in plan mode regardless of this flag.
|
|
28
|
+
Any mode can start plan execution via the mode picker's `Execute:` entries or `/execute`, unless it sets `allowExecute: false`. When blocked, the picker hides the `Execute:` entries and `/execute` reports that execution is unavailable from the current mode. The plan-mode action menu (`Execute / Refine / Save & Exit / Discard & Exit`) is the designed plan-to-execute pipeline and is always available in plan mode regardless of this flag. The Refine entry counts cycles (`Refine (3 cycles so far)`) as information only; nothing caps how often you refine.
|
|
29
29
|
|
|
30
|
-
`allowPlanComplete: true` on a custom mode appends the `plan_complete` tool to that mode's tool list, letting a custom mode finish and exit through the same completion path as execute mode
|
|
30
|
+
`allowPlanComplete: true` on a custom mode appends the `plan_complete` tool to that mode's tool list, letting a custom mode finish and exit through the same completion path as execute mode: the `tool_call` gate, the tool body, and the result handler all use one `planCompleteAllowed()` predicate, so calling it from such a mode deletes the plan file (when cleanup is enabled) and reports `Plan implemented. Plan mode OFF.` exactly like execute mode.
|
|
31
31
|
|
|
32
32
|
### Tool inheritance warning
|
|
33
33
|
|
|
34
34
|
A custom mode with no `tools` array inherits **all** tools, including `write` and `edit` — it is write-capable by default. Always specify an explicit read-only tool list for restrictive modes.
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
`off` behaves the same way: it restores the tools that were active before the mode (never a wider set). Set `modes.off.tools` to pin the resting tool set instead — for example to keep `subagent`/`browser` out of normal mode entirely. Pinning replaces the snapshot taken on mode entry, so a later mode that lists no `tools` inherits the pinned set rather than the older, wider one. `load_tools` is offered in `off` because withheld lazy tools are otherwise unreachable there.
|
|
37
|
+
|
|
38
|
+
Model overrides are resolved through Pi's model registry when entering a mode. A blank or partial `model` (for example `{ "provider": "", "id": "" }`) is treated as **no override**, so the mode keeps the current model instead of warning `Model not found: /`. The previously active model is captured once per mode and restored on exit; the restore point is persisted in the mode session entry, so `/reload` and `/resume` inside a mode no longer lose it. If the restore target is gone from the registry it is dropped immediately; if it exists but has no credentials (before `/login`, say) the restore is retried once and then released, so a stale point cannot block future captures indefinitely. `Pi.setModel` is session-scoped: it never rewrites your configured `defaultProvider`/`defaultModel`, but it does append a model change to the session transcript, which is why the restore point is persisted alongside the mode. Changes are serialized, so a rapid mode exit cannot leave a superseded mode model active. Note that setting `modes.off.model` makes OFF a pinned baseline: it is applied on every mode exit *and* at session start, overriding `--model` and the configured default. Use Pi theme color tokens for widget label colors.
|
|
39
|
+
|
|
40
|
+
Mode state is persisted in session entries and restored on session resume or branch changes. Pi ignores tool names it does not know, so a `tools` list with a typo would silently lose tools: every mode entry checks the list against the registered tools and warns once per unknown name per session. The `mode-switcher` entry type and legacy chat/plan event identifiers are compatibility contracts. On resume, an unknown mode name in a session entry is ignored (falling back to normal), and plan filenames containing path separators or `..` are dropped, so a stale or foreign branch cannot leave the session unrestricted or point plan reads outside `.pi/plans/`.
|
|
37
41
|
|
|
38
42
|
## Configuration
|
|
39
43
|
|
|
@@ -82,8 +86,9 @@ Create `~/.pi/agent/configs/opl-modes.json` or copy [`configs/opl-modes.json.sam
|
|
|
82
86
|
| `defaultNotifyTemplate` | Notification template for custom modes; `{Name}` is capitalized mode name. |
|
|
83
87
|
| `modes.chat.tools` / `modes.plan.tools` | Replace the respective built-in read-only tool lists. |
|
|
84
88
|
| `chatAllowedTools` / `planAllowedTools` | **Deprecated compatibility aliases** for the built-in tool lists. They are used only when the corresponding `modes.<name>.tools` is omitted; migrate to `modes.chat.tools` or `modes.plan.tools`. |
|
|
85
|
-
| `bashPatterns.safePatterns` / `bashPatterns.destructivePatterns` | Shared Bash policy applied to every mode by default; a mode overrides with its own `modes.<name>.safePatterns`/`destructivePatterns` or disables gating with `modes.<name>.unrestrictedBash: true`. |
|
|
86
|
-
| `modes.<name>.unrestrictedBash` | Set `true` to exempt a
|
|
89
|
+
| `bashPatterns.safePatterns` / `bashPatterns.destructivePatterns` | Shared Bash policy applied to every mode by default; a mode overrides with its own `modes.<name>.safePatterns`/`destructivePatterns` or disables gating with `modes.<name>.unrestrictedBash: true`. Every segment of a command must satisfy the allowlist. |
|
|
90
|
+
| `modes.<name>.unrestrictedBash` | Set `true` to exempt a mode from the Bash policy entirely (no safe/destructive patterns). Works for custom modes and built-in overrides. |
|
|
91
|
+
| `modes.off.tools` | Optional: pin the resting tool set for OFF mode. Omit to restore whatever was active before the mode. |
|
|
87
92
|
| `lazyTools` | Tool names withheld from the active set at rest and enabled on demand (see Lazy tool loading below). |
|
|
88
93
|
| `modes.<name>` | Add or override a mode, including `model`, `tools`, patterns, `allowPlanComplete`, `allowExecute`, `visible`, `enabled`, `prompt`, `labels`, and `appearance`. |
|
|
89
94
|
|
|
@@ -106,7 +111,7 @@ Use `modes.<name>.appearance` to keep any mode's visual identity with its defini
|
|
|
106
111
|
}
|
|
107
112
|
```
|
|
108
113
|
|
|
109
|
-
`prefix`, `prefixColor`, and `borderColor` style `opl-input`; omitted fields use its compiled mode defaults. `modeColor` styles the value in `opl-footer`'s `mode_switcher` segment; if omitted, the footer uses hardcoded `muted`. Colors accept Pi theme tokens
|
|
114
|
+
`prefix`, `prefixColor`, and `borderColor` style `opl-input`; omitted fields use its compiled mode defaults. `modeColor` styles the value in `opl-footer`'s `mode_switcher` segment; if omitted, the footer uses hardcoded `muted`. Colors accept Pi theme tokens, six-digit hex, or the three-digit `#abc` shorthand (expanded to `#aabbcc`).
|
|
110
115
|
|
|
111
116
|
Model overrides are resolved through Pi's model registry when entering a mode and the previously active model is restored on exit when applicable. Changes are serialized, so a rapid mode exit cannot leave a superseded mode model active. Use Pi theme color tokens for widget label colors.
|
|
112
117
|
|
|
@@ -127,9 +132,10 @@ Behavior:
|
|
|
127
132
|
- `load_tools` is bounded by the current mode's tool policy: it can only enable a lazy tool the active mode already permits, so a read-only mode cannot enable a write-capable tool.
|
|
128
133
|
- Core built-ins (`read`, `edit`, `write`, `bash`, `powershell`, `grep`, `find`, `ls`), `plan_complete`, and `load_tools` itself are protected and silently ignored if listed in `lazyTools`.
|
|
129
134
|
- Switching modes re-applies the policy, so a lazy tool enabled earlier returns to inactive on the next mode change and must be re-enabled.
|
|
135
|
+
- A mode `prompt` that instructs the model to use a lazy tool must also tell it to call `load_tools` first, otherwise the instruction names a tool the model cannot see. The bundled `research` mode does exactly that.
|
|
130
136
|
|
|
131
137
|
Caching note: activating a lazy tool mid-session preserves the cached prefix on models with native deferred tool loading (Anthropic 4.5+, OpenAI gpt-5.4+) and otherwise triggers one prompt-cache rewrite from that point. It is most effective for tools you use occasionally (delegation, browser automation, benchmarking).
|
|
132
138
|
|
|
133
139
|
## Architecture
|
|
134
140
|
|
|
135
|
-
`config.ts` registers built-in modes, merges configured overrides, compiles Bash patterns, and publishes the registry. `state.ts` persists and restores mode/plan state and is the single publisher of `globalThis.__agentMode`. `index.ts` wires commands, picker, lifecycle hooks, tool replacement, Bash interception, and `plan_complete`; `utils.ts` handles plan files and shared helpers.
|
|
141
|
+
`config.ts` registers built-in modes, merges configured overrides, compiles Bash patterns, and publishes the registry. `state.ts` persists and restores mode/plan state (including the persisted model restore point) and is the single publisher of `globalThis.__agentMode`. `index.ts` wires commands, picker, lifecycle hooks, tool replacement, Bash interception, and `plan_complete`; `utils.ts` handles plan files, the per-segment Bash gate, and shared helpers.
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { readFileSync } from "node:fs";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
|
-
import type { ModeSwitcherUserConfig, ModeDefinition, PartialModeDefinition } from "./types.js";
|
|
6
|
+
import type { ModeSwitcherUserConfig, ModeDefinition, PartialModeDefinition, ModeModelConfig } from "./types.js";
|
|
7
7
|
|
|
8
8
|
// ─── Plan File Constants ────────────────────────────────────────────────────
|
|
9
9
|
|
|
@@ -50,32 +50,56 @@ export const DEFAULT_SAFE_PATTERNS: RegExp[] = [
|
|
|
50
50
|
/^\s*cat\b/, /^\s*head\b/, /^\s*tail\b/, /^\s*less\b/, /^\s*more\b/,
|
|
51
51
|
/^\s*grep\b/, /^\s*find\b/, /^\s*ls\b/, /^\s*pwd\b/, /^\s*cd\b/,
|
|
52
52
|
/^\s*echo\b/, /^\s*printf\b/, /^\s*wc\b/, /^\s*sort\b/,
|
|
53
|
+
// Pure stdout filters. uniq can write its second operand, so it is gated in the blocklist.
|
|
54
|
+
/^\s*uniq\b/, /^\s*tr\b/, /^\s*cut\b/,
|
|
53
55
|
/^\s*diff\b/, /^\s*file\b/, /^\s*stat\b/, /^\s*du\b/, /^\s*df\b/,
|
|
54
56
|
/^\s*tree\b/, /^\s*which\b/, /^\s*whereis\b/, /^\s*type\b/,
|
|
55
57
|
/^\s*uname\b/, /^\s*whoami\b/,
|
|
56
58
|
/^\s*date\b/, /^\s*uptime\b/, /^\s*ps\b/, /^\s*free\b/,
|
|
57
59
|
/^\s*rg\b/, /^\s*fd\b/, /^\s*bat\b/, /^\s*jq\b/,
|
|
58
|
-
/^\s*git\s+(status|log|diff|show|branch|remote)/i,
|
|
60
|
+
/^\s*git\s+(status|log|diff|show|branch|remote|rev-parse)/i,
|
|
59
61
|
/^\s*node\s+--version/i, /^\s*python\s+--version/i,
|
|
60
62
|
/^\s*(npx\s+)?tsc\b.*--noEmit/i,
|
|
61
63
|
/^\s*npm\s+(list|ls|view|info|outdated|audit)/i,
|
|
62
64
|
/^\s*yarn\s+(list|info|why|audit)/i,
|
|
63
65
|
];
|
|
64
66
|
|
|
65
|
-
/**
|
|
67
|
+
/** Destructive command patterns — always blocked in read-only modes, even if matching a safe pattern.
|
|
68
|
+
* Program names are anchored to command position (`^\s*`) and matched per shell segment, so a
|
|
69
|
+
* read-only command that merely mentions `rm`/`cp`/`sh` in an argument (`du -sh`, `ls cp/`,
|
|
70
|
+
* `git log --grep=rm`) stays allowed. Flag and redirect patterns stay unanchored on purpose. */
|
|
66
71
|
export const DEFAULT_DESTRUCTIVE_PATTERNS: RegExp[] = [
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
/\s-(delete|exec|execdir)\b/i,
|
|
71
|
-
|
|
72
|
-
|
|
72
|
+
/^\s*(rm|rmdir|mv|cp|mkdir|touch|chmod|chown|tee|dd|shred|truncate)\b/i,
|
|
73
|
+
// Write flags that ride a safe-listed command: find's file-writing predicates and --output
|
|
74
|
+
// on sort/git log/git diff/git show. `-print`/`-printf` stay allowed (they go to stdout).
|
|
75
|
+
/\s-{1,2}(delete|exec|execdir|exec-batch|fprint0?|fprintf|fls|output)\b/i,
|
|
76
|
+
// `fd -x`/`-X` (also inside a short-flag cluster like `-Hx`) execute a command per match.
|
|
77
|
+
// The long forms are already caught above; the anchor keeps `grep -x` and `cut -d` legal.
|
|
78
|
+
/^\s*fd\b[^\n]*\s-[A-Za-z]*[xX]\b/,
|
|
79
|
+
// `rg --pre CMD` runs CMD on every file before searching.
|
|
80
|
+
/^\s*rg\b[^\n]*\s--pre\b/,
|
|
81
|
+
// `sort -o`/`tree -o` write their output operand in any argument position.
|
|
82
|
+
/^\s*(sort|tree)\b[^\n]*\s-[A-Za-z]*o\b/i,
|
|
83
|
+
// jq can read the process environment, which is why `env`/`printenv` are not safe-listed.
|
|
84
|
+
// Over-blocks a JSON key literally named `env`; a leaked API key costs more.
|
|
85
|
+
/^\s*jq\b[^\n]*(\$ENV|\benv\b)/i,
|
|
86
|
+
// Any redirect that names a file. `>&<fd>`/`>&-` duplication is not a write, so 2>&1 survives.
|
|
87
|
+
// The /dev/null exception needs a token boundary: `> /dev/null/../tmp/pwn` and `> /dev/nullfoo`
|
|
88
|
+
// are writes to real paths that merely start with that prefix.
|
|
89
|
+
/(^|[^<])>(?!>)(?!&[0-9-])(?! *\/dev\/null(?![^\s;&|)]))/, />>(?! *\/dev\/null(?![^\s;&|)]))/,
|
|
90
|
+
// uniq writes its second operand (`uniq -c in.txt out.txt`); flags don't count, so
|
|
91
|
+
// `uniq -c sorted.txt` stays a read.
|
|
92
|
+
/^\s*uniq\b(?:\s+-\S+)*\s+[^-\s]\S*(?:\s+[^-\s]\S*)+/,
|
|
93
|
+
/\bnpm\s+(install|uninstall|update|upgrade|ci)/i,
|
|
94
|
+
/\bnpm\s+audit\b.*\bfix\b/i,
|
|
73
95
|
/\byarn\s+(add|remove|install)/i,
|
|
74
96
|
/\bpip\s+(install|uninstall)/i,
|
|
75
|
-
/\bgit\s+(add|commit|push|merge|rebase|reset|checkout|
|
|
76
|
-
|
|
77
|
-
/\
|
|
78
|
-
|
|
97
|
+
/\bgit\s+(add|commit|push|merge|rebase|reset|checkout|clean|update-ref|cherry-pick|revert|am|apply|branch\s+(-{1,2}[dDmMcC]|--(unset-upstream|edit-description))|tag\s+-)/i,
|
|
98
|
+
// Subcommands that hide behind the safe `git remote` prefix and still write .git.
|
|
99
|
+
/\bgit\s+remote\s+(add|remove|rename|set-url|set-head|setbranches|prune|update)\b/i,
|
|
100
|
+
/^\s*(sudo|su|kill|pkill)\b/i,
|
|
101
|
+
/^\s*(sh|bash|zsh)\b/i,
|
|
102
|
+
/^\s*(vim?|nano|emacs|code|subl)\b/i,
|
|
79
103
|
];
|
|
80
104
|
|
|
81
105
|
// ─── Prompt Templates ────────────────────────────────────────────────────────
|
|
@@ -394,13 +418,29 @@ export function resolveCustomPatterns(def: {
|
|
|
394
418
|
};
|
|
395
419
|
}
|
|
396
420
|
|
|
421
|
+
/**
|
|
422
|
+
* Normalize a configured model reference. A blank or partial `{ provider, id }` means
|
|
423
|
+
* "no override" — Pi's registry cannot resolve empty strings, so passing one through
|
|
424
|
+
* would only warn "Model not found: /" and leave the model untouched. Treating it as
|
|
425
|
+
* unset makes the mode keep (and later restore) the current model instead.
|
|
426
|
+
*/
|
|
427
|
+
export function resolveModeModel(model: unknown): ModeModelConfig | undefined {
|
|
428
|
+
if (!isRecord(model)) return undefined;
|
|
429
|
+
const provider = typeof model.provider === "string" ? model.provider.trim() : "";
|
|
430
|
+
const id = typeof model.id === "string" ? model.id.trim() : "";
|
|
431
|
+
return provider && id ? { provider, id } : undefined;
|
|
432
|
+
}
|
|
433
|
+
|
|
397
434
|
export function mergeModeDefinition(existing: ModeDefinition, def: PartialModeDefinition): ModeDefinition {
|
|
435
|
+
// unrestrictedBash is an opt-out for built-in overrides too, not just new modes.
|
|
436
|
+
const unrestricted = def.unrestrictedBash === true;
|
|
398
437
|
return {
|
|
399
438
|
...existing,
|
|
400
439
|
...def,
|
|
401
440
|
tools: def.tools ?? existing.tools,
|
|
402
|
-
|
|
403
|
-
|
|
441
|
+
model: "model" in def ? resolveModeModel(def.model) : existing.model,
|
|
442
|
+
safePatterns: unrestricted ? undefined : (compilePatterns(def.safePatterns) ?? existing.safePatterns),
|
|
443
|
+
destructivePatterns: unrestricted ? undefined : (compilePatterns(def.destructivePatterns) ?? existing.destructivePatterns),
|
|
404
444
|
labels: { ...existing.labels, ...def.labels },
|
|
405
445
|
appearance: def.appearance ?? existing.appearance,
|
|
406
446
|
};
|
|
@@ -429,6 +469,14 @@ export function withPlanComplete(mode: string, tools: string[], registry: Map<st
|
|
|
429
469
|
return tools;
|
|
430
470
|
}
|
|
431
471
|
|
|
472
|
+
/**
|
|
473
|
+
* Whether `plan_complete` may be called while `mode` is active: execute mode and any mode
|
|
474
|
+
* with allowPlanComplete. Same predicate that decides the tool's presence in the tool list.
|
|
475
|
+
*/
|
|
476
|
+
export function planCompleteAllowed(mode: string, registry: Map<string, ModeDefinition> = MODE_REGISTRY): boolean {
|
|
477
|
+
return registry.get(mode)?.allowPlanComplete === true;
|
|
478
|
+
}
|
|
479
|
+
|
|
432
480
|
/** Initialize MODE_REGISTRY with built-in defaults, then merge user-defined modes. */
|
|
433
481
|
function initModeRegistry(): void {
|
|
434
482
|
const customNotifyTemplate = userConfig.defaultNotifyTemplate ?? "✓ {Name} mode ON";
|
|
@@ -495,7 +543,7 @@ function initModeRegistry(): void {
|
|
|
495
543
|
allowExecute: def.allowExecute ?? true,
|
|
496
544
|
visible: def.visible ?? true,
|
|
497
545
|
enabled: def.enabled ?? true,
|
|
498
|
-
model: def.model,
|
|
546
|
+
model: resolveModeModel(def.model),
|
|
499
547
|
labels: {
|
|
500
548
|
notify: def.labels?.notify ?? customNotifyTemplate.replace("{Name}", name.charAt(0).toUpperCase() + name.slice(1)),
|
|
501
549
|
notifyType: def.labels?.notifyType ?? "info",
|