pi-jar 0.1.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/LICENSE +21 -0
- package/README.md +288 -0
- package/docs/DESIGN.md +85 -0
- package/docs/DEVELOPMENT.md +95 -0
- package/docs/FLAME.md +54 -0
- package/docs/INTEGRATIONS.md +82 -0
- package/docs/PLAN.md +68 -0
- package/docs/SHOWCASE.md +22 -0
- package/docs/WORKFLOWS.md +141 -0
- package/extensions/index.ts +773 -0
- package/package.json +62 -0
- package/src/accent.ts +36 -0
- package/src/advisor.ts +194 -0
- package/src/animations.ts +23 -0
- package/src/ask-tool.ts +185 -0
- package/src/attachments.ts +79 -0
- package/src/changes.ts +146 -0
- package/src/commit.ts +61 -0
- package/src/compact-tools.ts +129 -0
- package/src/composer.ts +404 -0
- package/src/context-view.ts +141 -0
- package/src/delegate.ts +208 -0
- package/src/dialogs.ts +127 -0
- package/src/diff-view.ts +160 -0
- package/src/flame.ts +245 -0
- package/src/footer-settings.ts +32 -0
- package/src/footer.ts +147 -0
- package/src/goal-loop.ts +313 -0
- package/src/goals.ts +114 -0
- package/src/history-ui.ts +136 -0
- package/src/history.ts +168 -0
- package/src/info-panels.ts +75 -0
- package/src/mascot.ts +103 -0
- package/src/model-roles.ts +332 -0
- package/src/panel.ts +90 -0
- package/src/plan-utils.ts +191 -0
- package/src/plan-view.ts +149 -0
- package/src/plan.ts +460 -0
- package/src/prompt-search.ts +83 -0
- package/src/quota.ts +134 -0
- package/src/roles-ui.ts +190 -0
- package/src/roles.ts +25 -0
- package/src/session-gallery.ts +52 -0
- package/src/session-ui.ts +76 -0
- package/src/settings-ui.ts +154 -0
- package/src/settings.ts +96 -0
- package/src/shells.ts +319 -0
- package/src/side-model.ts +48 -0
- package/src/split-view.ts +74 -0
- package/src/status.ts +71 -0
- package/src/suggest.ts +89 -0
- package/src/task-tool.ts +135 -0
- package/src/tasks-ui.ts +73 -0
- package/src/tasks.ts +147 -0
- package/src/usage-view.ts +98 -0
- package/src/usage.ts +16 -0
- package/src/welcome.ts +260 -0
- package/src/working.ts +60 -0
- package/themes/pi-jar-dark-amber.json +84 -0
- package/themes/pi-jar-dark-azure.json +84 -0
- package/themes/pi-jar-dark-gray.json +84 -0
- package/themes/pi-jar-dark-pink.json +84 -0
- package/themes/pi-jar-dark-teal.json +84 -0
- package/themes/pi-jar-dark-violet.json +84 -0
- package/themes/pi-jar-dark.json +84 -0
package/src/plan-view.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { getMarkdownTheme, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Key, Markdown, matchesKey, truncateToWidth, visibleWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
3
|
+
import { parsePlanSections, type PlanSection } from "./plan-utils.ts";
|
|
4
|
+
import { contentRows, optionList, sidebarWidth, splitFrame } from "./split-view.ts";
|
|
5
|
+
|
|
6
|
+
export type PlanViewAction = "implement" | "compact" | "refine" | "edit" | "stop";
|
|
7
|
+
export interface PlanViewResult { action: PlanViewAction; role?: string }
|
|
8
|
+
export interface PlanViewInput { title: string; text: string; path?: string; roles?: readonly string[]; role?: string }
|
|
9
|
+
|
|
10
|
+
const ACTIONS: { action: PlanViewAction; label: string }[] = [
|
|
11
|
+
{ action: "implement", label: "▶ Approve & execute" },
|
|
12
|
+
{ action: "compact", label: "◇ Approve, compact & execute" },
|
|
13
|
+
{ action: "refine", label: "✎ Refine" },
|
|
14
|
+
{ action: "stop", label: "■ Stop" }
|
|
15
|
+
];
|
|
16
|
+
type Focus = "toc" | "body" | "actions";
|
|
17
|
+
|
|
18
|
+
export interface PlanEntry { title: string; level: number; start: number; end: number }
|
|
19
|
+
|
|
20
|
+
/** Table of contents: a lone H1 becomes the title; H2/H3 become entries; preamble becomes "Overview". */
|
|
21
|
+
export function planEntries(text: string): { title?: string; entries: PlanEntry[] } {
|
|
22
|
+
const lines = text.replace(/\r/g, "").split("\n");
|
|
23
|
+
const sections = parsePlanSections(text);
|
|
24
|
+
const h1 = sections.filter((section) => section.level === 1);
|
|
25
|
+
const title = h1.length === 1 ? h1[0]!.title : undefined;
|
|
26
|
+
const visible = sections.filter((section) => section.level <= 3 && !(title && section.level === 1));
|
|
27
|
+
const entries: PlanEntry[] = visible.map((section) => ({ title: section.title, level: section.level, start: section.start, end: subtreeEnd(sections, section, lines.length) }));
|
|
28
|
+
const firstStart = visible[0]?.start ?? lines.length;
|
|
29
|
+
const preambleStart = title ? h1[0]!.start + 1 : 0;
|
|
30
|
+
if (visible.length && lines.slice(preambleStart, firstStart).some((line) => line.trim())) {
|
|
31
|
+
entries.unshift({ title: "Overview", level: 2, start: preambleStart - (title ? 1 : 0), end: firstStart });
|
|
32
|
+
}
|
|
33
|
+
if (!entries.length) entries.push({ title: title ?? "Plan", level: 2, start: 0, end: lines.length });
|
|
34
|
+
return { ...(title ? { title } : {}), entries };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function subtreeEnd(sections: readonly PlanSection[], section: PlanSection, total: number): number {
|
|
38
|
+
const next = sections.find((candidate) => candidate.start > section.start && candidate.level <= section.level);
|
|
39
|
+
return next?.start ?? total;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Full-screen plan review: headings on the left, the selected section on the right, actions below. */
|
|
43
|
+
export async function openPlanView(ctx: ExtensionContext, input: PlanViewInput): Promise<PlanViewResult | undefined> {
|
|
44
|
+
if (!ctx.hasUI || ctx.mode !== "tui") return undefined;
|
|
45
|
+
const lines = input.text.replace(/\r/g, "").split("\n");
|
|
46
|
+
const { title, entries } = planEntries(input.text);
|
|
47
|
+
const roleChoices = ["current", ...(input.roles ?? [])];
|
|
48
|
+
return ctx.ui.custom<PlanViewResult | undefined>((tui, theme, _keys, done) => {
|
|
49
|
+
let selected = 0;
|
|
50
|
+
let focus: Focus = "toc";
|
|
51
|
+
let action = 0;
|
|
52
|
+
let role = Math.max(0, roleChoices.indexOf(input.role ?? "current"));
|
|
53
|
+
let scroll = 0;
|
|
54
|
+
let tocScroll = 0;
|
|
55
|
+
let width = 80;
|
|
56
|
+
let bodyRows = 0;
|
|
57
|
+
let rendered: { key: string; lines: string[] } | undefined;
|
|
58
|
+
let layout = { top: 1, rows: 0, leftWidth: 0, bodyX: 2, footerTop: 0 };
|
|
59
|
+
const finish = (value: PlanViewAction) => done({ action: value, ...(roleChoices[role] !== "current" ? { role: roleChoices[role] } : {}) });
|
|
60
|
+
const body = (bodyWidth: number) => {
|
|
61
|
+
const entry = entries[selected]!;
|
|
62
|
+
const key = `${selected}:${bodyWidth}`;
|
|
63
|
+
if (rendered?.key !== key) {
|
|
64
|
+
const markdown = new Markdown(lines.slice(entry.start, entry.end).join("\n"), 0, 0, getMarkdownTheme());
|
|
65
|
+
rendered = { key, lines: markdown.render(Math.max(1, bodyWidth)) };
|
|
66
|
+
}
|
|
67
|
+
return rendered.lines;
|
|
68
|
+
};
|
|
69
|
+
const selectEntry = (index: number) => { selected = Math.max(0, Math.min(entries.length - 1, index)); scroll = 0; };
|
|
70
|
+
const scrollBody = (delta: number) => { scroll = Math.max(0, Math.min(Math.max(0, (rendered?.lines.length ?? 0) - bodyRows), scroll + delta)); };
|
|
71
|
+
return {
|
|
72
|
+
invalidate() { rendered = undefined; },
|
|
73
|
+
handleInput(data: string) {
|
|
74
|
+
if (matchesKey(data, Key.escape) || data === "q") return finish("stop");
|
|
75
|
+
if (/^[1-4]$/.test(data)) return finish(ACTIONS[Number(data) - 1]!.action);
|
|
76
|
+
if (data === "e") return finish("edit");
|
|
77
|
+
if (data === "r") role = (role + 1) % roleChoices.length;
|
|
78
|
+
else if (matchesKey(data, Key.tab)) focus = focus === "toc" ? "body" : focus === "body" ? "actions" : "toc";
|
|
79
|
+
else if (matchesKey(data, Key.shift("tab"))) focus = focus === "toc" ? "actions" : focus === "body" ? "toc" : "body";
|
|
80
|
+
else if (matchesKey(data, Key.pageDown) || data === " ") scrollBody(Math.max(1, bodyRows - 2));
|
|
81
|
+
else if (matchesKey(data, Key.pageUp)) scrollBody(-Math.max(1, bodyRows - 2));
|
|
82
|
+
else if (data === "g") focus === "toc" ? selectEntry(0) : scrollBody(-Infinity);
|
|
83
|
+
else if (data === "G") focus === "toc" ? selectEntry(entries.length - 1) : scrollBody(Infinity);
|
|
84
|
+
else if (matchesKey(data, Key.enter)) {
|
|
85
|
+
if (focus === "actions") return finish(ACTIONS[action]!.action);
|
|
86
|
+
focus = focus === "toc" ? "body" : "actions";
|
|
87
|
+
} else if (matchesKey(data, Key.up) || data === "k") {
|
|
88
|
+
if (focus === "toc") selectEntry(selected - 1); else if (focus === "body") scrollBody(-1); else action = (action + ACTIONS.length - 1) % ACTIONS.length;
|
|
89
|
+
} else if (matchesKey(data, Key.down) || data === "j") {
|
|
90
|
+
if (focus === "toc") selectEntry(selected + 1); else if (focus === "body") scrollBody(1); else action = (action + 1) % ACTIONS.length;
|
|
91
|
+
} else if (matchesKey(data, Key.left) || data === "h") {
|
|
92
|
+
if (focus === "actions") action = (action + ACTIONS.length - 1) % ACTIONS.length; else focus = "toc";
|
|
93
|
+
} else if (matchesKey(data, Key.right) || data === "l") {
|
|
94
|
+
if (focus === "actions") action = (action + 1) % ACTIONS.length; else focus = "body";
|
|
95
|
+
}
|
|
96
|
+
tui.requestRender();
|
|
97
|
+
},
|
|
98
|
+
handleMouse(event: TuiMouseEvent) {
|
|
99
|
+
const row = event.y - layout.top;
|
|
100
|
+
const inContent = row >= 0 && row < layout.rows;
|
|
101
|
+
const inToc = layout.leftWidth > 0 && event.x < layout.leftWidth + 3;
|
|
102
|
+
if (event.type === "wheel" && event.wheelDelta) {
|
|
103
|
+
if (inContent && inToc) selectEntry(selected + Math.sign(event.wheelDelta));
|
|
104
|
+
else scrollBody(Math.sign(event.wheelDelta) * 3);
|
|
105
|
+
tui.requestRender(); return { handled: true };
|
|
106
|
+
}
|
|
107
|
+
// Leave press/drag/release to Pi so text selection (and copy-on-select) keeps working.
|
|
108
|
+
if (event.type !== "click" || event.button !== "left") return;
|
|
109
|
+
if (event.y === 0 && event.x >= width - 3) { finish("stop"); return { handled: true }; }
|
|
110
|
+
if (inContent && inToc) {
|
|
111
|
+
const index = tocScroll + row;
|
|
112
|
+
if (index < entries.length) { selectEntry(index); focus = "toc"; tui.requestRender(); return { handled: true, focus: true }; }
|
|
113
|
+
}
|
|
114
|
+
if (inContent) { focus = "body"; tui.requestRender(); return { handled: true, focus: true }; }
|
|
115
|
+
// Footer: one row per action, then the role row.
|
|
116
|
+
const footerRow = event.y - layout.footerTop;
|
|
117
|
+
if (footerRow >= 0 && footerRow < ACTIONS.length) { action = footerRow; finish(ACTIONS[footerRow]!.action); return { handled: true }; }
|
|
118
|
+
if (footerRow === ACTIONS.length) { role = (role + 1) % roleChoices.length; tui.requestRender(); return { handled: true, focus: true }; }
|
|
119
|
+
},
|
|
120
|
+
render(available: number): string[] {
|
|
121
|
+
width = Math.max(24, available);
|
|
122
|
+
bodyRows = contentRows(6 + ACTIONS.length, 6);
|
|
123
|
+
const tocWidth = sidebarWidth(width);
|
|
124
|
+
const bodyWidth = tocWidth ? width - tocWidth - 6 : width - 4;
|
|
125
|
+
const narrowHeader = tocWidth ? [] : [theme.fg("accent", `‹ ${selected + 1}/${entries.length} ${entries[selected]!.title} ›`), ""];
|
|
126
|
+
const content = [...narrowHeader, ...body(bodyWidth)];
|
|
127
|
+
const rows = bodyRows;
|
|
128
|
+
scroll = Math.max(0, Math.min(scroll, Math.max(0, content.length - rows)));
|
|
129
|
+
if (selected < tocScroll) tocScroll = selected;
|
|
130
|
+
if (selected >= tocScroll + rows) tocScroll = selected - rows + 1;
|
|
131
|
+
const toc = entries.slice(tocScroll, tocScroll + rows).map((entry, index) => {
|
|
132
|
+
const current = tocScroll + index === selected;
|
|
133
|
+
const indent = " ".repeat(Math.max(0, entry.level - 2));
|
|
134
|
+
const text = (current ? "▌" : " ") + indent + entry.title;
|
|
135
|
+
return theme.fg(current ? (focus === "toc" ? "accent" : "warning") : entry.level > 2 ? "dim" : "muted", truncateToWidth(text, tocWidth));
|
|
136
|
+
});
|
|
137
|
+
const window = content.slice(scroll, scroll + rows);
|
|
138
|
+
const position = content.length > rows ? ` ${scroll + 1}–${Math.min(content.length, scroll + rows)}/${content.length}` : "";
|
|
139
|
+
const actionRows = optionList(theme, ACTIONS.map((item) => item.label), focus === "actions" ? action : -1);
|
|
140
|
+
const roleText = `⟳ continue with: ${roleChoices[role]}`;
|
|
141
|
+
const hints = theme.fg("dim", " Tab focus · ↑↓ move · 1–4 choose · r role · e edit · Esc stop" + (input.path ? " · " + input.path : ""));
|
|
142
|
+
const split = splitFrame(theme, width, "◆ PLAN" + (title ? " · " + title : "") + " · read-only" + position, toc, window,
|
|
143
|
+
[...actionRows, theme.fg("accent", roleText) + hints], rows, tocWidth);
|
|
144
|
+
layout = split.layout;
|
|
145
|
+
return split.lines;
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
}, { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%" } });
|
|
149
|
+
}
|
package/src/plan.ts
ADDED
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir, tmpdir } from "node:os";
|
|
3
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
4
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { Key, Text } from "@earendil-works/pi-tui";
|
|
6
|
+
import { Type } from "typebox";
|
|
7
|
+
import { promptText } from "./dialogs.ts";
|
|
8
|
+
import { type ModelRoleManager } from "./model-roles.ts";
|
|
9
|
+
import { openPlanView } from "./plan-view.ts";
|
|
10
|
+
import { extractApproachSteps, isSafePlanCommand, PLAN_TEMPLATE, planTextFromSteps, validatePlanDocument } from "./plan-utils.ts";
|
|
11
|
+
import { cleanText } from "./status.ts";
|
|
12
|
+
import { type TodoStore } from "./tasks.ts";
|
|
13
|
+
|
|
14
|
+
export const PLAN_ENTRY = "pi-jar.plan";
|
|
15
|
+
export const PLAN_SUBMIT_TOOL = "jar_plan_submit";
|
|
16
|
+
type PlanState = { v: 2; enabled: boolean; steps: string[]; text?: string; path?: string; title?: string };
|
|
17
|
+
|
|
18
|
+
// Only tools with known read-only semantics are allowed. Name suffixes (for example
|
|
19
|
+
// "remote_get") do not prove that an extension or MCP tool is safe to invoke.
|
|
20
|
+
export const PLAN_SAFE_TOOLS = new Set(["read", "bash", "grep", "find", "ls", "jar_ask", "jar_advisor"]);
|
|
21
|
+
/** Allowed only for markdown files inside the session's plan directory. */
|
|
22
|
+
const PLAN_WRITE_TOOLS = new Set(["write", "edit"]);
|
|
23
|
+
const MAX_PLAN_BYTES = 64 * 1024;
|
|
24
|
+
const MAX_REMINDERS = 2;
|
|
25
|
+
|
|
26
|
+
export function safePlanText(value: string): string {
|
|
27
|
+
return value.slice(0, MAX_PLAN_BYTES)
|
|
28
|
+
.replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)?|\x1b\[[0-?]*[ -/]*[@-~]|\x1b./g, "")
|
|
29
|
+
.replace(/[\x00-\x09\x0b-\x1f\x7f-\x9f]/g, (char) => char === "\t" ? " " : "")
|
|
30
|
+
.trim();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Global, temporary home for plan documents: `$TMPDIR/pi-jar/plans/<session>`. */
|
|
34
|
+
export function planDirectory(sessionId: string | undefined, root = tmpdir()): string {
|
|
35
|
+
const safe = (sessionId ?? "").replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 64) || "session";
|
|
36
|
+
return join(root, "pi-jar", "plans", safe);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Resolve a tool path and return its real location only when it is a markdown file inside `root`.
|
|
41
|
+
* Symlinks are resolved on the nearest existing ancestor (and on the file itself when present).
|
|
42
|
+
*/
|
|
43
|
+
export function resolvePlanPath(root: string, raw: unknown, cwd: string): string | undefined {
|
|
44
|
+
if (typeof raw !== "string" || !raw.trim()) return undefined;
|
|
45
|
+
const expanded = raw.startsWith("~/") ? join(homedir(), raw.slice(2)) : raw.trim();
|
|
46
|
+
const absolute = resolve(cwd, expanded);
|
|
47
|
+
if (!absolute.toLowerCase().endsWith(".md")) return undefined;
|
|
48
|
+
let realRoot: string;
|
|
49
|
+
try { realRoot = realpathSync(root); } catch { return undefined; }
|
|
50
|
+
let probe = absolute;
|
|
51
|
+
const rest: string[] = [];
|
|
52
|
+
while (!existsSync(probe)) {
|
|
53
|
+
rest.unshift(basename(probe));
|
|
54
|
+
const parent = dirname(probe);
|
|
55
|
+
if (parent === probe) return undefined;
|
|
56
|
+
probe = parent;
|
|
57
|
+
}
|
|
58
|
+
let real: string;
|
|
59
|
+
try { real = join(realpathSync(probe), ...rest); } catch { return undefined; }
|
|
60
|
+
const inside = relative(realRoot, real);
|
|
61
|
+
if (!inside || inside.startsWith("..") || isAbsolute(inside)) return undefined;
|
|
62
|
+
return real;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export class PlanMode {
|
|
66
|
+
private enabled = false;
|
|
67
|
+
private steps: string[] = [];
|
|
68
|
+
private planText = "";
|
|
69
|
+
private planPath: string | undefined;
|
|
70
|
+
private planTitle: string | undefined;
|
|
71
|
+
private directory: string | undefined;
|
|
72
|
+
private toolsBefore: string[] | undefined;
|
|
73
|
+
private pendingReview = false;
|
|
74
|
+
private reminders = 0;
|
|
75
|
+
private reviewing = false;
|
|
76
|
+
private compacting = false;
|
|
77
|
+
private compactGeneration = 0;
|
|
78
|
+
private executionRestore: (() => Promise<void>) | undefined;
|
|
79
|
+
private executionRunning = false;
|
|
80
|
+
private restoreRole: (() => Promise<void>) | undefined;
|
|
81
|
+
private onEnter: ((ctx: ExtensionContext) => void) | undefined;
|
|
82
|
+
private readonly pi: ExtensionAPI;
|
|
83
|
+
private readonly todos: () => TodoStore | undefined;
|
|
84
|
+
private readonly roles: ModelRoleManager;
|
|
85
|
+
private readonly todosChanged: (ctx: ExtensionContext) => void;
|
|
86
|
+
private readonly root: string | undefined;
|
|
87
|
+
|
|
88
|
+
constructor(pi: ExtensionAPI, todos: () => TodoStore | undefined, roles: ModelRoleManager, todosChanged: (ctx: ExtensionContext) => void, root?: string) {
|
|
89
|
+
this.pi = pi;
|
|
90
|
+
this.todos = todos;
|
|
91
|
+
this.roles = roles;
|
|
92
|
+
this.todosChanged = todosChanged;
|
|
93
|
+
this.root = root;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
isEnabled(): boolean { return this.enabled; }
|
|
97
|
+
latestSteps(): string[] { return [...this.steps]; }
|
|
98
|
+
hasPlan(): boolean { return !!(this.planText || this.steps.length); }
|
|
99
|
+
summary(): { enabled: boolean; title?: string; steps: number } {
|
|
100
|
+
return { enabled: this.enabled, ...(this.planTitle ? { title: this.planTitle } : {}), steps: this.steps.length };
|
|
101
|
+
}
|
|
102
|
+
/** Called when plan mode starts, so goal automation can pause. */
|
|
103
|
+
setOnEnter(handler: (ctx: ExtensionContext) => void): void { this.onEnter = handler; }
|
|
104
|
+
|
|
105
|
+
private persist(): void {
|
|
106
|
+
this.pi.appendEntry(PLAN_ENTRY, { v: 2, enabled: this.enabled, steps: this.steps, text: this.planText,
|
|
107
|
+
...(this.planPath ? { path: this.planPath } : {}), ...(this.planTitle ? { title: this.planTitle } : {}) } satisfies PlanState);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private updateStatus(ctx: ExtensionContext): void {
|
|
111
|
+
try { ctx.ui.setStatus("pi-jar.plan", this.enabled ? ctx.ui.theme.fg("warning", "◆ PLAN · read-only") : undefined); }
|
|
112
|
+
catch { /* decorative */ }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private planTools(active: string[]): string[] {
|
|
116
|
+
const available = new Set(this.pi.getAllTools().map((tool) => tool.name));
|
|
117
|
+
const allowed = [...active.filter((name) => PLAN_SAFE_TOOLS.has(name)), ...PLAN_WRITE_TOOLS, PLAN_SUBMIT_TOOL];
|
|
118
|
+
return [...new Set(allowed.filter((name) => available.has(name)))];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private ensureDirectory(ctx: ExtensionContext): string {
|
|
122
|
+
const id = (ctx.sessionManager as { getSessionId?: () => string } | undefined)?.getSessionId?.();
|
|
123
|
+
const directory = planDirectory(id, this.root);
|
|
124
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
125
|
+
// On shared temp dirs, refuse a directory another user planted (or a symlink to elsewhere).
|
|
126
|
+
const info = lstatSync(directory);
|
|
127
|
+
const uid = process.getuid?.();
|
|
128
|
+
if (!info.isDirectory() || (uid !== undefined && info.uid !== uid)) throw new Error(directory + " is not a private directory");
|
|
129
|
+
this.directory = directory;
|
|
130
|
+
return directory;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private async enter(ctx: ExtensionContext, persist = true): Promise<void> {
|
|
134
|
+
if (this.enabled) return;
|
|
135
|
+
try { this.ensureDirectory(ctx); }
|
|
136
|
+
catch (error) { ctx.ui.notify("Plan mode unavailable: cannot create plan directory: " + (error as Error).message, "error"); return; }
|
|
137
|
+
this.enabled = true;
|
|
138
|
+
this.pendingReview = false;
|
|
139
|
+
this.reminders = 0;
|
|
140
|
+
this.toolsBefore = this.pi.getActiveTools();
|
|
141
|
+
this.pi.setActiveTools(this.planTools(this.toolsBefore));
|
|
142
|
+
this.onEnter?.(ctx);
|
|
143
|
+
this.restoreRole = await this.roles.activateTemporary("plan", ctx);
|
|
144
|
+
this.updateStatus(ctx);
|
|
145
|
+
if (persist) this.persist();
|
|
146
|
+
ctx.ui.notify("Plan mode · read-only. The agent writes a plan file, then you review it.", "info");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private async leave(ctx: ExtensionContext, persist = true): Promise<void> {
|
|
150
|
+
const wasEnabled = this.enabled;
|
|
151
|
+
this.enabled = false;
|
|
152
|
+
this.compacting = false;
|
|
153
|
+
this.pendingReview = false;
|
|
154
|
+
this.compactGeneration++;
|
|
155
|
+
if (this.toolsBefore) this.pi.setActiveTools(this.toolsBefore);
|
|
156
|
+
this.toolsBefore = undefined;
|
|
157
|
+
if (this.restoreRole) await this.restoreRole();
|
|
158
|
+
this.restoreRole = undefined;
|
|
159
|
+
this.updateStatus(ctx);
|
|
160
|
+
if (persist && wasEnabled) this.persist();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private seedTodos(ctx: ExtensionContext): void {
|
|
164
|
+
const store = this.todos();
|
|
165
|
+
if (!store) return;
|
|
166
|
+
const existing = new Set(store.all().map((item) => item.title.trim().toLowerCase()));
|
|
167
|
+
let changed = false;
|
|
168
|
+
for (const step of this.steps) {
|
|
169
|
+
const key = step.trim().toLowerCase();
|
|
170
|
+
if (!key || existing.has(key)) continue;
|
|
171
|
+
if (store.add(step, "Approved plan step")) { existing.add(key); changed = true; }
|
|
172
|
+
}
|
|
173
|
+
if (changed) this.todosChanged(ctx);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private executeMessage(): string {
|
|
177
|
+
const text = this.planText || planTextFromSteps(this.steps);
|
|
178
|
+
return [
|
|
179
|
+
"Implement the approved plan now.",
|
|
180
|
+
"Work through it step by step. Keep jar_todo synchronized: mark each step done as it completes and add any newly discovered work.",
|
|
181
|
+
"Verify each step as described in the plan before moving on. If the plan is wrong, say so and adjust rather than silently diverging.",
|
|
182
|
+
this.planPath ? "The plan file is " + this.planPath + "; re-read it only if this inline copy is lost to compaction." : "",
|
|
183
|
+
`<plan${this.planPath ? ` path="${this.planPath}"` : ""}>`,
|
|
184
|
+
text,
|
|
185
|
+
"</plan>"
|
|
186
|
+
].filter(Boolean).join("\n\n");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private async implement(ctx: ExtensionContext, role?: string): Promise<void> {
|
|
190
|
+
this.seedTodos(ctx);
|
|
191
|
+
await this.leave(ctx);
|
|
192
|
+
// A role picked in the plan view sticks; otherwise the run uses `implement` and then returns.
|
|
193
|
+
if (role) await this.roles.activate(role, ctx, true);
|
|
194
|
+
else if (this.roles.resolve("implement")) this.executionRestore = await this.roles.activateTemporary("implement", ctx);
|
|
195
|
+
this.pi.sendUserMessage(this.executeMessage(), { deliverAs: "followUp" });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private compactThenImplement(ctx: ExtensionContext, role?: string): void {
|
|
199
|
+
this.compacting = true;
|
|
200
|
+
const generation = ++this.compactGeneration;
|
|
201
|
+
ctx.ui.notify("Compacting context while preserving the approved plan…", "info");
|
|
202
|
+
ctx.compact({
|
|
203
|
+
customInstructions: "Preserve this approved implementation plan exactly enough to execute it after compaction:\n" + (this.planText || planTextFromSteps(this.steps)),
|
|
204
|
+
onComplete: () => {
|
|
205
|
+
if (!this.enabled || generation !== this.compactGeneration) return;
|
|
206
|
+
this.compacting = false;
|
|
207
|
+
ctx.ui.notify("Context compacted · starting approved plan", "info");
|
|
208
|
+
void this.implement(ctx, role);
|
|
209
|
+
},
|
|
210
|
+
onError: (error) => {
|
|
211
|
+
if (generation !== this.compactGeneration) return;
|
|
212
|
+
this.compacting = false;
|
|
213
|
+
ctx.ui.notify("Compaction failed; plan mode is still active: " + error.message, "error");
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
private writeBack(text: string): void {
|
|
219
|
+
if (!this.planPath) return;
|
|
220
|
+
writeFileSync(this.planPath, text.endsWith("\n") ? text : text + "\n", "utf8");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Open the plan view and act on the user's choice. */
|
|
224
|
+
async review(ctx: ExtensionContext): Promise<void> {
|
|
225
|
+
if (this.reviewing || this.compacting || !this.hasPlan()) return;
|
|
226
|
+
if (!ctx.hasUI || ctx.mode !== "tui") {
|
|
227
|
+
ctx.ui.notify("Plan ready for review" + (this.planPath ? ": " + this.planPath : "") + ". Run /plan review in the interactive TUI.", "info");
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
this.reviewing = true;
|
|
231
|
+
this.pendingReview = false;
|
|
232
|
+
try {
|
|
233
|
+
while (true) {
|
|
234
|
+
const roles = this.roles.cycleOrder().filter((role) => this.roles.resolve(role));
|
|
235
|
+
const result = await openPlanView(ctx, { title: this.planTitle ?? "Plan", text: this.planText || planTextFromSteps(this.steps),
|
|
236
|
+
...(this.planPath ? { path: this.planPath } : {}), roles });
|
|
237
|
+
const action = result?.action ?? "stop";
|
|
238
|
+
if (action === "edit") {
|
|
239
|
+
const edited = await ctx.ui.editor("Edit plan" + (this.planPath ? " · " + this.planPath : ""), this.planText);
|
|
240
|
+
if (edited != null && edited.trim() && edited !== this.planText) {
|
|
241
|
+
this.planText = safePlanText(edited);
|
|
242
|
+
this.planTitle = validatePlanDocument(this.planText).title ?? this.planTitle;
|
|
243
|
+
this.steps = extractApproachSteps(this.planText);
|
|
244
|
+
try { this.writeBack(this.planText); } catch (error) { ctx.ui.notify("Plan edited in memory but not saved: " + (error as Error).message, "warning"); }
|
|
245
|
+
this.persist();
|
|
246
|
+
}
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
if (action === "implement") { if (!this.enabled) await this.enter(ctx, false); await this.implement(ctx, result?.role); }
|
|
250
|
+
else if (action === "compact") { if (!this.enabled) await this.enter(ctx, false); this.compactThenImplement(ctx, result?.role); }
|
|
251
|
+
else if (action === "refine") {
|
|
252
|
+
const feedback = await promptText(ctx, "Refine plan", "What should change in the plan?");
|
|
253
|
+
if (feedback) {
|
|
254
|
+
if (!this.enabled) await this.enter(ctx);
|
|
255
|
+
this.pi.sendUserMessage("Revise the plan file with this feedback, then submit it again with " + PLAN_SUBMIT_TOOL + ":\n\n" + feedback, { deliverAs: "followUp" });
|
|
256
|
+
}
|
|
257
|
+
} else {
|
|
258
|
+
if (this.enabled) await this.leave(ctx);
|
|
259
|
+
ctx.ui.notify("Plan stopped · no implementation started", "info");
|
|
260
|
+
}
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
} finally {
|
|
264
|
+
this.reviewing = false;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private submit(raw: unknown, ctx: ExtensionContext): { ok: boolean; message: string } {
|
|
269
|
+
if (!this.enabled) return { ok: false, message: "Plan mode is not active; there is nothing to submit." };
|
|
270
|
+
const directory = this.directory ?? this.ensureDirectory(ctx);
|
|
271
|
+
const path = resolvePlanPath(directory, raw, ctx.cwd);
|
|
272
|
+
if (!path) return { ok: false, message: `Submit a markdown file inside ${directory}.` };
|
|
273
|
+
let text: string;
|
|
274
|
+
try {
|
|
275
|
+
if (statSync(path).size > MAX_PLAN_BYTES) return { ok: false, message: "Plan file is larger than 64 KB; tighten it." };
|
|
276
|
+
text = safePlanText(readFileSync(path, "utf8"));
|
|
277
|
+
} catch (error) {
|
|
278
|
+
return { ok: false, message: "Could not read the plan file: " + (error as Error).message };
|
|
279
|
+
}
|
|
280
|
+
const validation = validatePlanDocument(text);
|
|
281
|
+
if (!validation.ok) {
|
|
282
|
+
return { ok: false, message: ["The plan is incomplete. Fix it and submit again:",
|
|
283
|
+
...validation.missing.map((name) => `- add a \`## ${name}\` section`), ...validation.problems.map((problem) => "- " + problem),
|
|
284
|
+
"", "Required structure:", PLAN_TEMPLATE].join("\n") };
|
|
285
|
+
}
|
|
286
|
+
this.planText = text;
|
|
287
|
+
this.planPath = path;
|
|
288
|
+
this.planTitle = validation.title;
|
|
289
|
+
this.steps = extractApproachSteps(text);
|
|
290
|
+
this.pendingReview = true;
|
|
291
|
+
this.persist();
|
|
292
|
+
return { ok: true, message: `Plan "${validation.title}" submitted for review (${this.steps.length} steps). Stop now; the user reviews it in the plan view.` };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private restoreFrom(branch: readonly unknown[]): PlanState | undefined {
|
|
296
|
+
let state: PlanState | undefined;
|
|
297
|
+
for (const raw of branch) {
|
|
298
|
+
if (!raw || typeof raw !== "object") continue;
|
|
299
|
+
const entry = raw as Record<string, unknown>;
|
|
300
|
+
if (entry.type !== "custom" || entry.customType !== PLAN_ENTRY || !entry.data || typeof entry.data !== "object") continue;
|
|
301
|
+
const data = entry.data as Record<string, unknown>;
|
|
302
|
+
if ((data.v !== 1 && data.v !== 2) || typeof data.enabled !== "boolean" || !Array.isArray(data.steps)) continue;
|
|
303
|
+
state = {
|
|
304
|
+
v: 2, enabled: data.enabled,
|
|
305
|
+
steps: data.steps.filter((item): item is string => typeof item === "string").map((item) => cleanText(item, 240)).filter(Boolean).slice(0, 50),
|
|
306
|
+
text: typeof data.text === "string" ? safePlanText(data.text) : "",
|
|
307
|
+
...(typeof data.path === "string" ? { path: data.path } : {}),
|
|
308
|
+
...(typeof data.title === "string" ? { title: cleanText(data.title, 120) } : {})
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
return state;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
private async restore(branch: readonly unknown[], ctx: ExtensionContext): Promise<void> {
|
|
315
|
+
const state = this.restoreFrom(branch);
|
|
316
|
+
if (this.enabled) await this.leave(ctx, false);
|
|
317
|
+
this.steps = state?.steps ?? [];
|
|
318
|
+
this.planText = state?.text ?? "";
|
|
319
|
+
this.planPath = state?.path;
|
|
320
|
+
this.planTitle = state?.title;
|
|
321
|
+
if (state?.enabled) await this.enter(ctx, false);
|
|
322
|
+
else this.updateStatus(ctx);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
private async toggle(ctx: ExtensionContext): Promise<void> {
|
|
326
|
+
if (this.enabled) { await this.leave(ctx); ctx.ui.notify("Plan mode off", "info"); }
|
|
327
|
+
else await this.enter(ctx);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
private context(): string {
|
|
331
|
+
return [
|
|
332
|
+
"[PI-JAR PLAN MODE · READ ONLY]",
|
|
333
|
+
"You are planning, not implementing. Explore with read-only tools; do not modify the repository, remote services or any external state.",
|
|
334
|
+
`Write the plan as markdown to ${this.directory ?? planDirectory(undefined, this.root)}/<short-kebab-slug>-plan.md with write/edit (the only place writes are allowed).`,
|
|
335
|
+
"Make it decision-complete: someone new to the conversation can execute it without making design choices.",
|
|
336
|
+
"Use jar_ask for structured clarifying questions when a real decision is the user's to make.",
|
|
337
|
+
`Every turn must end with ${PLAN_SUBMIT_TOOL}({ path }) once the plan is ready. Never ask for approval in chat; the user approves in the plan view.`,
|
|
338
|
+
"Required structure:",
|
|
339
|
+
PLAN_TEMPLATE
|
|
340
|
+
].join("\n");
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
register(): void {
|
|
344
|
+
this.pi.registerTool?.({
|
|
345
|
+
name: PLAN_SUBMIT_TOOL,
|
|
346
|
+
label: "plan",
|
|
347
|
+
description: "Submit the finished plan markdown file for user review. Only available in pi-jar plan mode.",
|
|
348
|
+
promptSnippet: "In plan mode, write the plan file then call jar_plan_submit to request review.",
|
|
349
|
+
parameters: Type.Object({ path: Type.String({ description: "Path of the plan markdown file inside the plan directory." }) }),
|
|
350
|
+
execute: async (_id, params, _signal, _update, ctx) => {
|
|
351
|
+
const result = this.submit(params.path, ctx);
|
|
352
|
+
return { content: [{ type: "text", text: result.message }], details: { ok: result.ok, title: this.planTitle, steps: this.steps.length }, ...(result.ok ? { terminate: true } : {}) };
|
|
353
|
+
},
|
|
354
|
+
renderCall(args, theme) { return new Text(theme.fg("toolTitle", theme.bold("plan")) + " " + theme.fg("accent", "submit") + " " + theme.fg("muted", String(args.path ?? "")), 0, 0); },
|
|
355
|
+
renderResult(result, _options, theme) {
|
|
356
|
+
const details = result.details as { ok?: boolean; title?: string; steps?: number } | undefined;
|
|
357
|
+
return new Text(details?.ok ? theme.fg("success", `◆ ${details.title ?? "Plan"} · ${details.steps ?? 0} steps · awaiting review`) : theme.fg("warning", "Plan needs changes before review"), 0, 0);
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
this.pi.registerCommand("plan", {
|
|
362
|
+
description: "Plan mode: /plan [prompt] enters read-only planning, /plan review reopens the plan view, /plan off exits",
|
|
363
|
+
handler: async (args, ctx) => {
|
|
364
|
+
const text = args.trim();
|
|
365
|
+
if (/^(?:off|stop|exit)$/i.test(text)) {
|
|
366
|
+
await this.leave(ctx);
|
|
367
|
+
ctx.ui.notify("Plan mode off", "info");
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
if (/^(?:review|view|show)$/i.test(text)) {
|
|
371
|
+
if (!this.hasPlan()) { ctx.ui.notify("No plan yet. Use /plan <what to plan>.", "info"); return; }
|
|
372
|
+
await this.review(ctx);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
const wasEnabled = this.enabled;
|
|
376
|
+
if (!this.enabled) await this.enter(ctx);
|
|
377
|
+
if (!this.enabled) return;
|
|
378
|
+
if (text) {
|
|
379
|
+
this.pi.sendUserMessage(text, { deliverAs: "followUp" });
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
if (wasEnabled && this.hasPlan()) await this.review(ctx);
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
this.pi.registerShortcut?.(Key.ctrlAlt("p"), {
|
|
387
|
+
description: "Toggle pi-jar plan mode",
|
|
388
|
+
handler: async (ctx) => { await this.toggle(ctx); }
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
this.pi.on("tool_call", async (event, ctx) => {
|
|
392
|
+
if (!this.enabled) return;
|
|
393
|
+
const input = event.input && typeof event.input === "object" ? event.input as Record<string, unknown> : {};
|
|
394
|
+
if (event.toolName === "bash") {
|
|
395
|
+
if (!this.pi.getActiveTools().includes("bash")) return { block: true, reason: "Plan mode is read-only: bash is not active." };
|
|
396
|
+
if (typeof input.command !== "string" || !isSafePlanCommand(input.command)) {
|
|
397
|
+
return { block: true, reason: "Plan mode is read-only: this shell command is not on the read-only allowlist." };
|
|
398
|
+
}
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
if (PLAN_WRITE_TOOLS.has(event.toolName)) {
|
|
402
|
+
const directory = this.directory ?? this.ensureDirectory(ctx);
|
|
403
|
+
if (resolvePlanPath(directory, input.path ?? input.file_path, ctx.cwd)) return;
|
|
404
|
+
return { block: true, reason: `Plan mode: the working tree is read-only. Write your plan to ${directory}/<slug>-plan.md instead.` };
|
|
405
|
+
}
|
|
406
|
+
if (event.toolName === PLAN_SUBMIT_TOOL) return;
|
|
407
|
+
if (!PLAN_SAFE_TOOLS.has(event.toolName) || !this.pi.getActiveTools().includes(event.toolName)) {
|
|
408
|
+
return { block: true, reason: "Plan mode is read-only: tool " + event.toolName + " is disabled until the plan is approved." };
|
|
409
|
+
}
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
this.pi.on("before_agent_start", async () => {
|
|
413
|
+
if (!this.enabled) return;
|
|
414
|
+
return { message: { customType: "pi-jar.plan-context", content: this.context(), display: false } };
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
this.pi.on("context", async (event) => {
|
|
418
|
+
if (this.enabled) return;
|
|
419
|
+
return { messages: event.messages.filter((raw) => {
|
|
420
|
+
const message = raw as { customType?: string };
|
|
421
|
+
return message.customType !== "pi-jar.plan-context" && message.customType !== "pi-jar.plan-reminder";
|
|
422
|
+
}) };
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
this.pi.on("input", (event) => {
|
|
426
|
+
if (event.source === "interactive") this.reminders = 0;
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
// The final actionable boundary: nudge the agent to submit instead of ending with a chat-only plan.
|
|
430
|
+
this.pi.on("agent_before_settle", (event, ctx) => {
|
|
431
|
+
if (!this.enabled || this.pendingReview || this.reviewing || this.compacting || event.continue || event.outcome !== "completed") return;
|
|
432
|
+
if (this.reminders >= MAX_REMINDERS) {
|
|
433
|
+
if (this.reminders++ === MAX_REMINDERS) ctx.ui.notify("Plan mode: the agent has not submitted a plan file. Ask it to, or /plan off.", "warning");
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
this.reminders++;
|
|
437
|
+
return {
|
|
438
|
+
entries: [{ type: "custom_message", customType: "pi-jar.plan-reminder", display: false,
|
|
439
|
+
content: `[PI-JAR PLAN MODE] You ended the turn without ${PLAN_SUBMIT_TOOL}. If the plan is ready, write it to the plan directory and call ${PLAN_SUBMIT_TOOL}. If you need a decision from the user, use jar_ask. Do not ask for approval in chat.` }],
|
|
440
|
+
continue: true
|
|
441
|
+
};
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
// The approved plan's run ends: return from the temporary `implement` role.
|
|
445
|
+
this.pi.on("agent_start", () => { if (this.executionRestore) this.executionRunning = true; });
|
|
446
|
+
this.pi.on("agent_settled", async (_event, ctx) => {
|
|
447
|
+
if (this.executionRestore && this.executionRunning) {
|
|
448
|
+
const restore = this.executionRestore;
|
|
449
|
+
this.executionRestore = undefined;
|
|
450
|
+
this.executionRunning = false;
|
|
451
|
+
await restore();
|
|
452
|
+
}
|
|
453
|
+
if (this.enabled && this.pendingReview) await this.review(ctx);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
this.pi.on("session_start", async (_event, ctx) => { await this.restore(ctx.sessionManager.getBranch(), ctx); });
|
|
457
|
+
this.pi.on("session_tree", async (_event, ctx) => { await this.restore(ctx.sessionManager.getBranch(), ctx); });
|
|
458
|
+
this.pi.on("session_shutdown", async (_event, ctx) => { if (this.enabled) await this.leave(ctx, false); });
|
|
459
|
+
}
|
|
460
|
+
}
|