@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.
- package/README.md +101 -85
- package/configs/opl-browser.json.sample +3 -2
- package/configs/opl-footer.json.sample +3 -2
- package/configs/opl-guardian.json.sample +8 -2
- package/configs/opl-modes.json.sample +5 -0
- package/configs/opl-webaccess.json.sample +7 -1
- package/extensions/opl-browser/README.md +81 -55
- package/extensions/opl-browser/browser.ts +244 -50
- package/extensions/opl-browser/config.ts +15 -7
- package/extensions/opl-browser/index.ts +5 -4
- package/extensions/opl-browser/package-lock.json +3 -3
- package/extensions/opl-browser/package.json +4 -4
- package/extensions/opl-browser/validate.ts +313 -60
- package/extensions/opl-configurator/README.md +40 -0
- package/extensions/opl-configurator/configurator.ts +151 -0
- package/extensions/opl-configurator/index.ts +12 -0
- package/extensions/opl-ctxtrim/README.md +45 -14
- package/extensions/opl-ctxtrim/index.ts +33 -13
- package/extensions/opl-footer/README.md +31 -6
- package/extensions/opl-footer/config.ts +11 -10
- package/extensions/opl-footer/configure.ts +159 -115
- package/extensions/opl-footer/git-status.ts +58 -1
- package/extensions/opl-footer/icons.ts +3 -0
- package/extensions/opl-footer/index.ts +209 -105
- package/extensions/opl-footer/segments/caveman.ts +9 -1
- package/extensions/opl-footer/segments/chat-mode.ts +8 -1
- package/extensions/opl-footer/segments/compactions.ts +13 -0
- package/extensions/opl-footer/segments/context.ts +6 -5
- package/extensions/opl-footer/segments/index.ts +2 -0
- package/extensions/opl-footer/segments/mode-switcher.ts +14 -1
- package/extensions/opl-footer/segments/path.ts +1 -1
- package/extensions/opl-footer/segments/plan-mode.ts +8 -1
- package/extensions/opl-footer/theme.ts +5 -2
- package/extensions/opl-footer/types.ts +4 -0
- package/extensions/opl-guardian/README.md +17 -5
- package/extensions/opl-guardian/config.ts +72 -6
- package/extensions/opl-guardian/guardian.ts +42 -10
- package/extensions/opl-guardian/index.ts +52 -17
- package/extensions/opl-guardian/policies.ts +52 -16
- package/extensions/opl-init/README.md +12 -4
- package/extensions/opl-init/index.ts +228 -27
- package/extensions/opl-input/README.md +17 -3
- package/extensions/opl-input/config.ts +59 -5
- package/extensions/opl-input/configure.ts +146 -0
- package/extensions/opl-input/index.ts +38 -30
- package/extensions/opl-input/mode-style.ts +6 -2
- package/extensions/opl-modes/README.md +22 -8
- package/extensions/opl-modes/config.ts +62 -8
- package/extensions/opl-modes/index.ts +87 -28
- package/extensions/opl-modes/state.ts +50 -2
- package/extensions/opl-modes/types.ts +6 -0
- package/extensions/opl-modes/utils.ts +18 -8
- package/extensions/opl-questionnaire/index.ts +10 -4
- package/extensions/opl-questionnaire/types.ts +4 -0
- package/extensions/opl-simplebench/README.md +21 -15
- package/extensions/opl-simplebench/artifact.ts +7 -4
- package/extensions/opl-simplebench/benchmark.ts +96 -35
- package/extensions/opl-simplebench/coding.ts +9 -4
- package/extensions/opl-simplebench/index.ts +19 -19
- package/extensions/opl-simplebench/llama-server.ts +3 -2
- package/extensions/opl-simplebench/report.ts +0 -6
- package/extensions/opl-simplebench/research.ts +5 -4
- package/extensions/opl-simplebench/scoring.ts +4 -2
- package/extensions/opl-simplebench/util/config.ts +15 -8
- package/extensions/opl-simplebench/util/exec-env.ts +36 -0
- package/extensions/opl-simplebench/util/format.ts +2 -2
- package/extensions/opl-simplebench/util/providers.ts +23 -194
- package/extensions/opl-todo/README.md +5 -5
- package/extensions/opl-todo/config.ts +7 -2
- package/extensions/opl-todo/index.ts +50 -9
- package/extensions/opl-webaccess/README.md +31 -5
- package/extensions/opl-webaccess/config.ts +36 -7
- package/extensions/opl-webaccess/extract.ts +37 -47
- package/extensions/opl-webaccess/http.ts +259 -0
- package/extensions/opl-webaccess/index.ts +12 -6
- package/extensions/opl-webaccess/package-lock.json +3 -3
- package/extensions/opl-webaccess/package.json +4 -9
- package/extensions/opl-webaccess/pdf.ts +4 -4
- package/extensions/opl-webaccess/search.ts +19 -8
- package/extensions/opl-webaccess/storage.ts +134 -2
- package/extensions/opl-webaccess/types.ts +4 -0
- package/extensions/opl-webaccess/utils.ts +221 -33
- package/package.json +19 -12
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
PLAN_DIR,
|
|
42
42
|
buildExecutePrompt,
|
|
43
43
|
buildRefinePrompt,
|
|
44
|
+
capPlan,
|
|
44
45
|
USER_CONFIG,
|
|
45
46
|
MODE_REGISTRY,
|
|
46
47
|
getModeDefinition,
|
|
@@ -57,6 +58,7 @@ import {
|
|
|
57
58
|
extractPlanText,
|
|
58
59
|
isPlanLike,
|
|
59
60
|
ensurePlanDir,
|
|
61
|
+
planFilePath,
|
|
60
62
|
titleFromFilename,
|
|
61
63
|
listPlanFiles,
|
|
62
64
|
sanitizePlanName,
|
|
@@ -78,6 +80,10 @@ import {
|
|
|
78
80
|
resetState,
|
|
79
81
|
getRefineCount,
|
|
80
82
|
incrementRefineCount,
|
|
83
|
+
publishModeGlobals,
|
|
84
|
+
clearModeGlobals,
|
|
85
|
+
getSessionCwd,
|
|
86
|
+
setSessionCwd,
|
|
81
87
|
} from "./state.js";
|
|
82
88
|
import { showSelectMenu } from "./menus.js";
|
|
83
89
|
|
|
@@ -105,7 +111,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
105
111
|
async execute(_toolCallId, params) {
|
|
106
112
|
const mode = getMode();
|
|
107
113
|
const modeDef = getModeDefinition(mode);
|
|
108
|
-
const modeAllowed = modeDef?.tools ?? null; //
|
|
114
|
+
const modeAllowed = modeDef?.tools ?? getModeDefinition("off")?.tools ?? null; // Unlisted modes inherit the pinned baseline, if any.
|
|
109
115
|
const current = pi.getActiveTools();
|
|
110
116
|
const enable = lazyToolsToEnable(params.tools as string[] | undefined, current, modeAllowed);
|
|
111
117
|
if (enable.length > 0) pi.setActiveTools([...current, ...enable]);
|
|
@@ -123,6 +129,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
123
129
|
// ─── Saved model for restoring ─────────────────────────────────────────────
|
|
124
130
|
let savedModel: Model<any> | null = null;
|
|
125
131
|
const queueModelChange = createLatestModelQueue();
|
|
132
|
+
let pendingPlanModel: Promise<void> | null = null;
|
|
126
133
|
// Consecutive restore failures for the same model, so an un-authable restore point is
|
|
127
134
|
// retried once and then released instead of blocking future captures forever.
|
|
128
135
|
let restoreAttempts = 0;
|
|
@@ -260,7 +267,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
260
267
|
function getPlanFilePath(): string | null {
|
|
261
268
|
const file = getActivePlanFile();
|
|
262
269
|
if (!file) return null;
|
|
263
|
-
return
|
|
270
|
+
return planFilePath(getSessionCwd(), file);
|
|
264
271
|
}
|
|
265
272
|
|
|
266
273
|
function getPlanDisplayTitle(): string | null {
|
|
@@ -269,7 +276,8 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
269
276
|
const filePath = getPlanFilePath();
|
|
270
277
|
if (filePath && existsSync(filePath)) {
|
|
271
278
|
const heading = readFileSync(filePath, "utf-8").match(/^# Plan:\s*(.+)$/m);
|
|
272
|
-
|
|
279
|
+
const captured = heading?.[1];
|
|
280
|
+
if (captured) return captured.trim();
|
|
273
281
|
}
|
|
274
282
|
return titleFromFilename(file);
|
|
275
283
|
}
|
|
@@ -356,7 +364,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
356
364
|
transition("plan", pi);
|
|
357
365
|
const modeDef = getModeDefinition("plan");
|
|
358
366
|
saveAndSetActiveTools(planModeTools());
|
|
359
|
-
applyModeModel(ctx, modeDef);
|
|
367
|
+
pendingPlanModel = applyModeModel(ctx, modeDef);
|
|
360
368
|
updateStatus(ctx);
|
|
361
369
|
if (ctx.hasUI && !USER_CONFIG.ui.hideNotify) {
|
|
362
370
|
const notify = modeDef?.labels?.notify ?? USER_CONFIG.labels.plan.notify;
|
|
@@ -367,10 +375,15 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
367
375
|
|
|
368
376
|
async function enterExecuteMode(ctx: ExtensionContext): Promise<void> {
|
|
369
377
|
transition("execute", pi);
|
|
370
|
-
const baseNames = savedToolNames ?? pi.getActiveTools();
|
|
371
|
-
pi.setActiveTools(applyLazyPolicy([...toolsWithoutPlanComplete(baseNames), "plan_complete"]));
|
|
372
|
-
savedToolNames = null;
|
|
373
378
|
const modeDef = getModeDefinition("execute");
|
|
379
|
+
if (modeDef?.tools) {
|
|
380
|
+
saveAndSetActiveTools(withPlanComplete("execute", toolsWithoutPlanComplete(modeDef.tools)));
|
|
381
|
+
} else {
|
|
382
|
+
const baseNames = savedToolNames ?? pi.getActiveTools();
|
|
383
|
+
pi.setActiveTools(applyLazyPolicy(withPlanComplete("execute", toolsWithoutPlanComplete(baseNames))));
|
|
384
|
+
savedToolNames = null;
|
|
385
|
+
}
|
|
386
|
+
await pendingPlanModel;
|
|
374
387
|
await applyModeModel(ctx, modeDef);
|
|
375
388
|
updateStatus(ctx);
|
|
376
389
|
const title = getPlanDisplayTitle();
|
|
@@ -390,6 +403,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
390
403
|
*/
|
|
391
404
|
function enterOffMode(ctx: ExtensionContext, message?: string, silent = false): void {
|
|
392
405
|
transition("off", pi);
|
|
406
|
+
pendingPlanModel = null;
|
|
393
407
|
const offModeDef = getModeDefinition("off");
|
|
394
408
|
// OFF gates tools only when the user declares modes.off.tools; otherwise it restores
|
|
395
409
|
// whatever was active before the mode.
|
|
@@ -444,7 +458,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
444
458
|
/** Open the mode picker. Handles all mode transitions including cross-mode switches. */
|
|
445
459
|
async function openModePicker(ctx: ExtensionContext): Promise<void> {
|
|
446
460
|
const current = getMode();
|
|
447
|
-
const planFiles = listPlanFiles();
|
|
461
|
+
const planFiles = listPlanFiles(getSessionCwd());
|
|
448
462
|
|
|
449
463
|
// Build mode list dynamically from registry (skip invisible/disabled modes)
|
|
450
464
|
const registryModes = Array.from(MODE_REGISTRY.entries())
|
|
@@ -504,7 +518,9 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
504
518
|
|
|
505
519
|
if (!restored) {
|
|
506
520
|
resetState();
|
|
507
|
-
|
|
521
|
+
const offTools = getModeDefinition("off")?.tools;
|
|
522
|
+
if (offTools) saveAndSetActiveTools(withPlanComplete("off", offTools), true);
|
|
523
|
+
else restoreAllTools();
|
|
508
524
|
applyOffModel(ctx);
|
|
509
525
|
updateStatus(ctx);
|
|
510
526
|
return;
|
|
@@ -521,13 +537,21 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
521
537
|
const mode = getMode();
|
|
522
538
|
const modeDef = getModeDefinition(mode);
|
|
523
539
|
|
|
540
|
+
// A restored plan or execution needs the pinned resting set as its inherited baseline,
|
|
541
|
+
// not the host tools active before this branch was restored.
|
|
542
|
+
const offTools = getModeDefinition("off")?.tools;
|
|
543
|
+
if ((mode === "plan" || mode === "execute") && offTools) {
|
|
544
|
+
saveAndSetActiveTools(withPlanComplete("off", offTools), true);
|
|
545
|
+
}
|
|
546
|
+
|
|
524
547
|
if (modeDef?.tools) {
|
|
525
548
|
// Honor allowPlanComplete here too, otherwise a resume drops the tool a mode was given.
|
|
526
549
|
// OFF's declared list is the resting baseline, so it must not keep a stale snapshot.
|
|
527
|
-
|
|
550
|
+
const tools = mode === "execute" ? toolsWithoutPlanComplete(modeDef.tools) : modeDef.tools;
|
|
551
|
+
saveAndSetActiveTools(withPlanComplete(mode, tools), mode === "off");
|
|
528
552
|
} else if (mode === "execute") {
|
|
529
553
|
const allNames = pi.getActiveTools();
|
|
530
|
-
pi.setActiveTools(applyLazyPolicy(
|
|
554
|
+
pi.setActiveTools(applyLazyPolicy(withPlanComplete(mode, toolsWithoutPlanComplete(allNames))));
|
|
531
555
|
} else {
|
|
532
556
|
// Default: the active set minus plan_complete, re-added when the mode allows it.
|
|
533
557
|
const allNames = pi.getActiveTools();
|
|
@@ -551,7 +575,14 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
551
575
|
// ─── Event: session_start ──────────────────────────────────────────────────────
|
|
552
576
|
|
|
553
577
|
pi.on("session_start", async (event, ctx) => {
|
|
578
|
+
setSessionCwd(ctx.cwd);
|
|
579
|
+
// Re-publish the footer seam: a previous session in this process cleared it on shutdown.
|
|
580
|
+
publishModeGlobals();
|
|
554
581
|
if (event.reason === "startup") {
|
|
582
|
+
if (getMode() === "off" && (pi.getFlag("chat") === true || pi.getFlag("plan") === true)) {
|
|
583
|
+
const offTools = getModeDefinition("off")?.tools;
|
|
584
|
+
if (offTools) saveAndSetActiveTools(withPlanComplete("off", offTools), true);
|
|
585
|
+
}
|
|
555
586
|
if (pi.getFlag("chat") === true && getMode() === "off") {
|
|
556
587
|
enterChatMode(ctx);
|
|
557
588
|
return;
|
|
@@ -565,6 +596,12 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
565
596
|
await syncStateFromBranch(ctx, event.reason === "resume" || event.reason === "fork");
|
|
566
597
|
});
|
|
567
598
|
|
|
599
|
+
pi.on("session_shutdown", async () => {
|
|
600
|
+
// Hand the seam back: an orphaned mode global would keep rendering a mode that no
|
|
601
|
+
// longer belongs to any live session.
|
|
602
|
+
clearModeGlobals();
|
|
603
|
+
});
|
|
604
|
+
|
|
568
605
|
// ─── Event: before_agent_start ──────────────────────────────────────────
|
|
569
606
|
|
|
570
607
|
pi.on("before_agent_start", (event: BeforeAgentStartEvent): BeforeAgentStartEventResult => {
|
|
@@ -578,7 +615,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
578
615
|
const filePath = getPlanFilePath();
|
|
579
616
|
if (filePath && existsSync(filePath)) {
|
|
580
617
|
const planContent = readFileSync(filePath, "utf-8");
|
|
581
|
-
return { systemPrompt: event.systemPrompt + "\n\n" + buildRefinePrompt(planContent) };
|
|
618
|
+
return { systemPrompt: event.systemPrompt + "\n\n" + buildRefinePrompt(planContent, filePath) };
|
|
582
619
|
}
|
|
583
620
|
}
|
|
584
621
|
|
|
@@ -590,12 +627,14 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
590
627
|
const customTemplate = modeDef.prompt;
|
|
591
628
|
if (typeof customTemplate === "string" && customTemplate.trim().length > 0) {
|
|
592
629
|
// User-configured template via modes.execute.prompt — {plan} is replaced with the plan file content.
|
|
630
|
+
// Capped here too: a custom template must not become a way around the injection budget.
|
|
631
|
+
const capped = capPlan(planContent, USER_CONFIG.plan.maxInjectBytes, filePath).text;
|
|
593
632
|
const filled = customTemplate.includes("{plan}")
|
|
594
|
-
? customTemplate.replace("{plan}",
|
|
595
|
-
: `${customTemplate}\n\nPlan:\n${
|
|
633
|
+
? customTemplate.replace("{plan}", capped)
|
|
634
|
+
: `${customTemplate}\n\nPlan:\n${capped}`;
|
|
596
635
|
return { systemPrompt: event.systemPrompt + "\n\n" + filled };
|
|
597
636
|
}
|
|
598
|
-
return { systemPrompt: event.systemPrompt + "\n\n" + buildExecutePrompt(planContent) };
|
|
637
|
+
return { systemPrompt: event.systemPrompt + "\n\n" + buildExecutePrompt(planContent, filePath) };
|
|
599
638
|
}
|
|
600
639
|
|
|
601
640
|
// Generic mode prompt from registry
|
|
@@ -628,7 +667,13 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
628
667
|
if (event.toolName !== "bash") return {};
|
|
629
668
|
if (!modeDef?.safePatterns && !modeDef?.destructivePatterns) return {};
|
|
630
669
|
|
|
631
|
-
|
|
670
|
+
if (typeof event.input.command !== "string") {
|
|
671
|
+
// Uninspectable commands are refused rather than coerced: String(["rm","-rf"]) would
|
|
672
|
+
// reach the pattern matchers as "rm,-rf" and slip past a segment-based safe list.
|
|
673
|
+
return { block: true, reason: `[mode-switcher] Blocked a bash tool call whose command was not a string [${mode} mode].` };
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
const command = event.input.command;
|
|
632
677
|
const reason = bashBlockReason(command, modeDef.safePatterns, modeDef.destructivePatterns);
|
|
633
678
|
if (reason) {
|
|
634
679
|
return { block: true, reason: `[mode-switcher] Command blocked — ${reason} [${mode} mode]` };
|
|
@@ -699,7 +744,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
699
744
|
setRefining(false);
|
|
700
745
|
|
|
701
746
|
if (planText) {
|
|
702
|
-
const planDir = ensurePlanDir();
|
|
747
|
+
const planDir = ensurePlanDir(getSessionCwd());
|
|
703
748
|
const activeFile = getActivePlanFile();
|
|
704
749
|
let filename = activeFile;
|
|
705
750
|
if (!filename) {
|
|
@@ -776,6 +821,8 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
776
821
|
const filename = `${PLAN_FILE_PREFIX}${sanitized}.md`;
|
|
777
822
|
enterPlanWithFile(filename, pi);
|
|
778
823
|
saveAndSetActiveTools(planModeTools());
|
|
824
|
+
pendingPlanModel = applyModeModel(ctx, getModeDefinition("plan"));
|
|
825
|
+
await pendingPlanModel;
|
|
779
826
|
updateStatus(ctx);
|
|
780
827
|
const createTitle = titleFromFilename(filename);
|
|
781
828
|
if (ctx.hasUI && !USER_CONFIG.ui.hideNotify) {
|
|
@@ -842,25 +889,30 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
842
889
|
}
|
|
843
890
|
|
|
844
891
|
/** Load an existing plan file and show the action menu. */
|
|
845
|
-
function loadPlanAndShowMenu(ctx: ExtensionContext, filename: string, displayName: string): void {
|
|
892
|
+
async function loadPlanAndShowMenu(ctx: ExtensionContext, filename: string, displayName: string): Promise<void> {
|
|
846
893
|
enterPlanWithFile(filename, pi);
|
|
847
894
|
saveAndSetActiveTools(planModeTools());
|
|
895
|
+
pendingPlanModel = applyModeModel(ctx, getModeDefinition("plan"));
|
|
896
|
+
await pendingPlanModel;
|
|
848
897
|
updateStatus(ctx);
|
|
849
898
|
if (ctx.hasUI && !USER_CONFIG.ui.hideNotify) {
|
|
850
899
|
ctx.ui.notify(USER_CONFIG.labels.plan.notifyLoaded.replace("{title}", displayName), "info");
|
|
851
900
|
}
|
|
852
901
|
|
|
853
|
-
const filePath =
|
|
902
|
+
const filePath = planFilePath(getSessionCwd(), filename);
|
|
854
903
|
if (existsSync(filePath)) {
|
|
855
904
|
const planContent = readFileSync(filePath, "utf-8");
|
|
856
|
-
|
|
905
|
+
// The entry copy is display/session only (plain custom entries never reach the
|
|
906
|
+
// model), but it is written to the session file forever, so it gets its own budget.
|
|
907
|
+
const entry = capPlan(planContent, USER_CONFIG.plan.maxEntryBytes, filePath);
|
|
908
|
+
pi.appendEntry("plan-mode", { title: displayName, plan: entry.text, truncated: entry.truncated, file: filePath });
|
|
857
909
|
}
|
|
858
910
|
}
|
|
859
911
|
|
|
860
912
|
// ─── Entry renderer for TUI-only plan cards (never sent to the model) ──────
|
|
861
913
|
|
|
862
914
|
pi.registerEntryRenderer("plan-mode", (entry, _options, theme) => {
|
|
863
|
-
const data = (entry.data ?? {}) as { title?: string; plan?: string };
|
|
915
|
+
const data = (entry.data ?? {}) as { title?: string; plan?: string; truncated?: boolean; file?: string };
|
|
864
916
|
const border = new DynamicBorder((s: string) => theme.fg("border", s));
|
|
865
917
|
const container = new Container();
|
|
866
918
|
container.addChild(border);
|
|
@@ -870,6 +922,9 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
870
922
|
container.addChild(new Spacer());
|
|
871
923
|
container.addChild(new Text(body));
|
|
872
924
|
}
|
|
925
|
+
if (data.truncated) {
|
|
926
|
+
container.addChild(new Text(theme.fg("muted", `✂ truncated copy — full plan: ${data.file ?? PLAN_DIR}`)));
|
|
927
|
+
}
|
|
873
928
|
container.addChild(border);
|
|
874
929
|
return container;
|
|
875
930
|
});
|
|
@@ -982,7 +1037,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
982
1037
|
if (!input) {
|
|
983
1038
|
const current = getMode();
|
|
984
1039
|
if (current === "off") {
|
|
985
|
-
const files = listPlanFiles();
|
|
1040
|
+
const files = listPlanFiles(getSessionCwd());
|
|
986
1041
|
if (files.length === 0) {
|
|
987
1042
|
await promptNameAndEnterPlanMode(ctx);
|
|
988
1043
|
} else {
|
|
@@ -995,7 +1050,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
995
1050
|
if (choice === "__new__") {
|
|
996
1051
|
await promptNameAndEnterPlanMode(ctx);
|
|
997
1052
|
} else {
|
|
998
|
-
loadPlanAndShowMenu(ctx, choice, titleFromFilename(choice));
|
|
1053
|
+
await loadPlanAndShowMenu(ctx, choice, titleFromFilename(choice));
|
|
999
1054
|
await showPlanMenu(ctx);
|
|
1000
1055
|
}
|
|
1001
1056
|
}
|
|
@@ -1025,11 +1080,11 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
1025
1080
|
return;
|
|
1026
1081
|
}
|
|
1027
1082
|
const filename = `${PLAN_FILE_PREFIX}${sanitized}.md`;
|
|
1028
|
-
const filePath =
|
|
1083
|
+
const filePath = planFilePath(getSessionCwd(), filename);
|
|
1029
1084
|
|
|
1030
1085
|
if (existsSync(filePath)) {
|
|
1031
1086
|
if (getMode() !== "off") enterOffMode(ctx, undefined, true);
|
|
1032
|
-
loadPlanAndShowMenu(ctx, filename, titleFromFilename(filename));
|
|
1087
|
+
await loadPlanAndShowMenu(ctx, filename, titleFromFilename(filename));
|
|
1033
1088
|
await showPlanMenu(ctx);
|
|
1034
1089
|
} else {
|
|
1035
1090
|
if (getMode() === "plan" || getMode() === "execute") {
|
|
@@ -1039,6 +1094,8 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
1039
1094
|
if (getMode() !== "off") enterOffMode(ctx, undefined, true);
|
|
1040
1095
|
enterPlanWithFile(filename, pi);
|
|
1041
1096
|
saveAndSetActiveTools(planModeTools());
|
|
1097
|
+
pendingPlanModel = applyModeModel(ctx, getModeDefinition("plan"));
|
|
1098
|
+
await pendingPlanModel;
|
|
1042
1099
|
updateStatus(ctx);
|
|
1043
1100
|
if (ctx.hasUI && !USER_CONFIG.ui.hideNotify) {
|
|
1044
1101
|
ctx.ui.notify(USER_CONFIG.labels.plan.notifyWithTitle.replace("{title}", titleFromFilename(filename)), USER_CONFIG.labels.plan.notifyType as "info" | "warning" | "error");
|
|
@@ -1078,13 +1135,14 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
1078
1135
|
pi.sendUserMessage("Execute the plan steps now.", { deliverAs: "followUp" });
|
|
1079
1136
|
return;
|
|
1080
1137
|
}
|
|
1081
|
-
const files = listPlanFiles();
|
|
1138
|
+
const files = listPlanFiles(getSessionCwd());
|
|
1082
1139
|
if (files.length === 0) {
|
|
1083
1140
|
if (ctx.hasUI) ctx.ui.notify("No plan files found. Run /plan first.", "warning");
|
|
1084
1141
|
return;
|
|
1085
1142
|
}
|
|
1086
1143
|
if (files.length === 1) {
|
|
1087
|
-
|
|
1144
|
+
const only = files[0];
|
|
1145
|
+
if (only) await startExecute(only.name);
|
|
1088
1146
|
return;
|
|
1089
1147
|
}
|
|
1090
1148
|
const items: SelectItem[] = files.map(f => ({ value: f.name, label: f.title }));
|
|
@@ -1100,7 +1158,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
1100
1158
|
return;
|
|
1101
1159
|
}
|
|
1102
1160
|
const filename = `${PLAN_FILE_PREFIX}${sanitized}.md`;
|
|
1103
|
-
const filePath =
|
|
1161
|
+
const filePath = planFilePath(getSessionCwd(), filename);
|
|
1104
1162
|
if (!existsSync(filePath)) {
|
|
1105
1163
|
if (ctx.hasUI) ctx.ui.notify(`Plan "${input}" not found. Check /plan for available plans.`, "warning");
|
|
1106
1164
|
return;
|
|
@@ -1124,6 +1182,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
1124
1182
|
|
|
1125
1183
|
const idx = order.indexOf(current);
|
|
1126
1184
|
const next = idx === -1 ? order[0] : order[(idx + 1) % order.length];
|
|
1185
|
+
if (!next) return; // order is non-empty above; this is what lets the branches below take a string
|
|
1127
1186
|
if (next === current) return;
|
|
1128
1187
|
|
|
1129
1188
|
if (next === "off") {
|
|
@@ -7,12 +7,17 @@ import type { AgentMode, AgentModeBlob, ModeAppearanceConfig, ModeModelConfig }
|
|
|
7
7
|
const state: {
|
|
8
8
|
mode: AgentMode;
|
|
9
9
|
activePlanFile: string | null;
|
|
10
|
+
sessionCwd: string;
|
|
10
11
|
restoringModel: ModeModelConfig | null;
|
|
11
12
|
refining: boolean;
|
|
12
13
|
refineCount: number;
|
|
13
14
|
} = {
|
|
14
15
|
mode: "off",
|
|
15
16
|
activePlanFile: null,
|
|
17
|
+
// Until the first `session_start` hands us the real one, the directory the harness was started
|
|
18
|
+
// in is the only candidate. Plans and the footer path must never be resolved from
|
|
19
|
+
// `process.cwd()` at the call site: a session opened elsewhere would write its plans there.
|
|
20
|
+
sessionCwd: process.cwd(),
|
|
16
21
|
restoringModel: null,
|
|
17
22
|
refining: false,
|
|
18
23
|
refineCount: 0,
|
|
@@ -23,6 +28,23 @@ const state: {
|
|
|
23
28
|
(globalThis as Record<string, unknown>).__planMode = { mode: "off" };
|
|
24
29
|
(globalThis as Record<string, unknown>).__chatMode = { mode: "off" };
|
|
25
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Cross-extension seam. `opl-footer` publishes `__footerRequestRender` while a footer is
|
|
33
|
+
* mounted; `__agentMode`, `__planMode` and `__chatMode` are read by the footer's mode
|
|
34
|
+
* segment and by `opl-input`. Extensions install one directory at a time and load in any
|
|
35
|
+
* combination, so nothing here may assume the other side exists or is healthy: the value can
|
|
36
|
+
* be missing, be something else entirely, or close over a TUI that was already torn down.
|
|
37
|
+
*/
|
|
38
|
+
function requestFooterRender(): void {
|
|
39
|
+
const requestRender = (globalThis as Record<string, unknown>).__footerRequestRender;
|
|
40
|
+
if (typeof requestRender !== "function") return;
|
|
41
|
+
try {
|
|
42
|
+
(requestRender as () => void)();
|
|
43
|
+
} catch {
|
|
44
|
+
// A dead footer must not fail a mode transition.
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
26
48
|
/** Derive and sync all globalThis snapshots from unified state. */
|
|
27
49
|
function syncGlobalThis(): void {
|
|
28
50
|
const m = state.mode;
|
|
@@ -35,8 +57,23 @@ function syncGlobalThis(): void {
|
|
|
35
57
|
// plan-family modes light up __planMode, so a custom mode cannot read as "Plan mode ON".
|
|
36
58
|
(globalThis as Record<string, unknown>).__planMode = { mode: m === "plan" || m === "execute" ? m : "off" };
|
|
37
59
|
(globalThis as Record<string, unknown>).__chatMode = { mode: m === "chat" ? "chat" : "off" };
|
|
38
|
-
|
|
39
|
-
|
|
60
|
+
requestFooterRender();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Re-publish the seam after a fresh session starts in this process. */
|
|
64
|
+
export function publishModeGlobals(): void {
|
|
65
|
+
syncGlobalThis();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Drop the seam when the session ends. A leftover value would let a footer in a later
|
|
70
|
+
* session render a mode that belongs to nobody, and the footer's own render trigger is
|
|
71
|
+
* removed with it (see `opl-footer`).
|
|
72
|
+
*/
|
|
73
|
+
export function clearModeGlobals(): void {
|
|
74
|
+
delete (globalThis as Record<string, unknown>).__agentMode;
|
|
75
|
+
delete (globalThis as Record<string, unknown>).__planMode;
|
|
76
|
+
delete (globalThis as Record<string, unknown>).__chatMode;
|
|
40
77
|
}
|
|
41
78
|
|
|
42
79
|
export function getMode(): AgentMode { return state.mode; }
|
|
@@ -126,6 +163,7 @@ export function restore(
|
|
|
126
163
|
): boolean {
|
|
127
164
|
for (let i = entries.length - 1; i >= 0; i--) {
|
|
128
165
|
const entry = entries[i];
|
|
166
|
+
if (!entry) continue; // a hole in the persisted array is not a restore candidate
|
|
129
167
|
if (entry.type !== "custom" || entry.customType !== ENTRY_TYPE) continue;
|
|
130
168
|
|
|
131
169
|
const data = entry.data as AgentModeBlob | undefined;
|
|
@@ -145,6 +183,7 @@ export function restore(
|
|
|
145
183
|
// Backward compat: restore from legacy entries written by the old extensions.
|
|
146
184
|
for (let i = entries.length - 1; i >= 0; i--) {
|
|
147
185
|
const entry = entries[i];
|
|
186
|
+
if (!entry) continue; // a hole in the persisted array is not a restore candidate
|
|
148
187
|
if (entry.type !== "custom") continue;
|
|
149
188
|
|
|
150
189
|
if (entry.customType === "plan-mode") {
|
|
@@ -181,3 +220,12 @@ export function resetState(): void {
|
|
|
181
220
|
state.refineCount = 0;
|
|
182
221
|
syncGlobalThis();
|
|
183
222
|
}
|
|
223
|
+
|
|
224
|
+
/** The directory the current session lives in (`.pi/plans/` is resolved against this). */
|
|
225
|
+
export function getSessionCwd(): string {
|
|
226
|
+
return state.sessionCwd;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function setSessionCwd(cwd: string): void {
|
|
230
|
+
if (cwd) state.sessionCwd = cwd;
|
|
231
|
+
}
|
|
@@ -154,6 +154,12 @@ export interface ModeSwitcherUserConfig {
|
|
|
154
154
|
shortcuts?: ShortcutsUserConfig;
|
|
155
155
|
/** @deprecated Use `modes` registry instead. Kept for backward compat. */
|
|
156
156
|
labels?: LabelsUserConfig;
|
|
157
|
+
plan?: {
|
|
158
|
+
/** Ceiling for a plan body injected into a system prompt, in bytes (default 24000). Larger plans truncate with a marker pointing at the file. */
|
|
159
|
+
maxInjectBytes?: number;
|
|
160
|
+
/** Ceiling for the plan copy kept in the `plan-mode` session entry, in bytes (default 4096). */
|
|
161
|
+
maxEntryBytes?: number;
|
|
162
|
+
};
|
|
157
163
|
bashPatterns?: BashPatternsUserConfig;
|
|
158
164
|
/** Replace-only: provide a list of tool names to replace the default CHAT mode tool set. Omit to keep defaults. */
|
|
159
165
|
chatAllowedTools?: string[];
|
|
@@ -52,6 +52,7 @@ function commandSegments(command: string): string[] {
|
|
|
52
52
|
for (let i = 0; i < command.length; i++) {
|
|
53
53
|
const ch = command[i];
|
|
54
54
|
const top = stack[stack.length - 1];
|
|
55
|
+
if (!top) break; // stack drained: the rest of the command is literal text
|
|
55
56
|
// Single quotes are literal: nothing expands, only the closing ' matters.
|
|
56
57
|
if (top.quote === "'") {
|
|
57
58
|
if (ch === "'") top.quote = "";
|
|
@@ -165,9 +166,9 @@ export function extractPlanText(message: string): string | null {
|
|
|
165
166
|
|
|
166
167
|
// ─── Plan File I/O ────────────────────────────────────────────────────────────
|
|
167
168
|
|
|
168
|
-
/** Ensure
|
|
169
|
-
export function ensurePlanDir(): string {
|
|
170
|
-
const dir = join(
|
|
169
|
+
/** Ensure <sessionCwd>/.pi/plans/ exists, return its absolute path. */
|
|
170
|
+
export function ensurePlanDir(cwd: string): string {
|
|
171
|
+
const dir = join(cwd, PLAN_DIR);
|
|
171
172
|
mkdirSync(dir, { recursive: true });
|
|
172
173
|
return dir;
|
|
173
174
|
}
|
|
@@ -181,7 +182,12 @@ export function titleFromFilename(filename: string): string {
|
|
|
181
182
|
// Detect timestamp pattern: YYYY-MM-DD-HH-MM
|
|
182
183
|
const tsMatch = stem.match(/^(\d{4})-(\d{2})-(\d{2})-(\d{2})-(\d{2})$/);
|
|
183
184
|
if (tsMatch) {
|
|
184
|
-
|
|
185
|
+
// Five digit groups by the shape test above; the explicit checks are what let the compiler see
|
|
186
|
+
// that, rather than a cast.
|
|
187
|
+
const [y, m, d, h, min] = tsMatch.slice(1);
|
|
188
|
+
if (y === undefined || m === undefined || d === undefined || h === undefined || min === undefined) {
|
|
189
|
+
return stem.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
190
|
+
}
|
|
185
191
|
const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
186
192
|
const month = months[parseInt(m, 10) - 1] ?? m;
|
|
187
193
|
return `${month} ${parseInt(d, 10)}, ${y} ${h}:${min}`;
|
|
@@ -203,8 +209,8 @@ export function sanitizePlanName(name: string): string | null {
|
|
|
203
209
|
}
|
|
204
210
|
|
|
205
211
|
/** List available plan files in .pi/plans/ with titles from # Plan: heading. */
|
|
206
|
-
export function listPlanFiles(): PlanFileSummary[] {
|
|
207
|
-
const dir = join(
|
|
212
|
+
export function listPlanFiles(cwd: string): PlanFileSummary[] {
|
|
213
|
+
const dir = join(cwd, PLAN_DIR);
|
|
208
214
|
if (!existsSync(dir)) return [];
|
|
209
215
|
|
|
210
216
|
const files = readdirSync(dir)
|
|
@@ -214,7 +220,7 @@ export function listPlanFiles(): PlanFileSummary[] {
|
|
|
214
220
|
return files.map((filename) => {
|
|
215
221
|
const content = readFileSync(join(dir, filename), "utf-8");
|
|
216
222
|
const titleMatch = content.match(/^# Plan:\s*(.+)$/m);
|
|
217
|
-
const title = titleMatch ? titleMatch[1].trim() : titleFromFilename(filename);
|
|
223
|
+
const title = titleMatch?.[1] ? titleMatch[1].trim() : titleFromFilename(filename);
|
|
218
224
|
return { name: filename, title };
|
|
219
225
|
});
|
|
220
226
|
}
|
|
@@ -265,4 +271,8 @@ export function applyLabelColor(theme: Theme, color: string, text: string): stri
|
|
|
265
271
|
} catch {
|
|
266
272
|
return text;
|
|
267
273
|
}
|
|
268
|
-
}
|
|
274
|
+
}
|
|
275
|
+
/** Absolute path of a plan file inside the session directory. */
|
|
276
|
+
export function planFilePath(cwd: string, filename: string): string {
|
|
277
|
+
return join(cwd, PLAN_DIR, filename);
|
|
278
|
+
}
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
import {
|
|
23
23
|
type Answer,
|
|
24
24
|
type Question,
|
|
25
|
+
type QuestionCheck,
|
|
25
26
|
QuestionnaireParams,
|
|
26
27
|
type QuestionnaireResult,
|
|
27
28
|
type RenderOption,
|
|
@@ -42,7 +43,7 @@ export function errorResult(
|
|
|
42
43
|
/** Validate the question list. Returns the first error message, or null when valid.
|
|
43
44
|
* Blank ids are rejected (answers are keyed by id and "" would produce an empty key),
|
|
44
45
|
* as are duplicate ids and questions with nothing selectable. */
|
|
45
|
-
export function validateQuestions(questions:
|
|
46
|
+
export function validateQuestions(questions: readonly QuestionCheck[]): string | null {
|
|
46
47
|
const seenIds = new Set<string>();
|
|
47
48
|
for (const q of questions) {
|
|
48
49
|
if (q.id.trim() === "") {
|
|
@@ -303,8 +304,10 @@ export default function questionnaire(pi: ExtensionAPI) {
|
|
|
303
304
|
const tabs: string[] = ["← "];
|
|
304
305
|
for (let i = 0; i < questions.length; i++) {
|
|
305
306
|
const isActive = i === currentTab;
|
|
306
|
-
const
|
|
307
|
-
|
|
307
|
+
const question = questions[i];
|
|
308
|
+
if (!question) continue;
|
|
309
|
+
const isAnswered = answers.has(question.id);
|
|
310
|
+
const lbl = question.label;
|
|
308
311
|
const box = isAnswered ? "■" : "□";
|
|
309
312
|
const color = isAnswered ? "success" : "muted";
|
|
310
313
|
const text = ` ${box} ${lbl} `;
|
|
@@ -326,6 +329,7 @@ export default function questionnaire(pi: ExtensionAPI) {
|
|
|
326
329
|
function renderOptions() {
|
|
327
330
|
for (let i = 0; i < opts.length; i++) {
|
|
328
331
|
const opt = opts[i];
|
|
332
|
+
if (!opt) continue; // a hole in the option list renders nothing rather than throwing mid-frame
|
|
329
333
|
const selected = i === optionIndex;
|
|
330
334
|
const isOther = opt.isOther === true;
|
|
331
335
|
const prefix = selected ? theme.fg("accent", "> ") : " ";
|
|
@@ -435,7 +439,9 @@ export default function questionnaire(pi: ExtensionAPI) {
|
|
|
435
439
|
},
|
|
436
440
|
|
|
437
441
|
renderResult(result, _options, theme, _context) {
|
|
438
|
-
|
|
442
|
+
// `isError` on tool results is newer than the declared floor: pi 0.87.x does not carry
|
|
443
|
+
// it in its types, and an older host leaves it undefined, which reads as "not an error".
|
|
444
|
+
if ((result as { isError?: boolean }).isError) {
|
|
439
445
|
const text = result.content[0];
|
|
440
446
|
return new Text(theme.fg("error", text?.type === "text" ? text.text : ""), 0, 0);
|
|
441
447
|
}
|
|
@@ -18,6 +18,10 @@ export interface Question {
|
|
|
18
18
|
allowOther: boolean;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/** The fields `validateQuestions` reads: the same shape on raw tool params (where `label`
|
|
22
|
+
* still has to be defaulted) and on normalized questions. */
|
|
23
|
+
export type QuestionCheck = Pick<Question, "id" | "options"> & Partial<Pick<Question, "allowOther">>;
|
|
24
|
+
|
|
21
25
|
export interface Answer {
|
|
22
26
|
id: string;
|
|
23
27
|
value: string;
|