moonpi 0.4.2

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/src/guards.ts ADDED
@@ -0,0 +1,101 @@
1
+ import { existsSync, realpathSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { isAbsolute, relative, resolve } from "node:path";
4
+ import type { ExtensionAPI, ToolCallEvent, ToolResultEvent } from "@mariozechner/pi-coding-agent";
5
+ import type { MoonpiController } from "./modes.js";
6
+
7
+ function isRecord(value: unknown): value is Record<string, unknown> {
8
+ return typeof value === "object" && value !== null;
9
+ }
10
+
11
+ function expandPath(filePath: string): string {
12
+ if (filePath === "~") return homedir();
13
+ if (filePath.startsWith("~/")) return resolve(homedir(), filePath.slice(2));
14
+ return filePath.startsWith("@") ? filePath.slice(1) : filePath;
15
+ }
16
+
17
+ function normalizePath(filePath: string, cwd: string): string {
18
+ const expanded = expandPath(filePath);
19
+ const absolute = isAbsolute(expanded) ? resolve(expanded) : resolve(cwd, expanded);
20
+ return existsSync(absolute) ? realpathSync(absolute) : absolute;
21
+ }
22
+
23
+ function isInsideDir(filePath: string, dir: string): boolean {
24
+ const dirReal = existsSync(dir) ? realpathSync(dir) : resolve(dir);
25
+ const target = normalizePath(filePath, dirReal);
26
+ const rel = relative(dirReal, target);
27
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
28
+ }
29
+
30
+ function isInsideCwd(filePath: string, cwd: string): boolean {
31
+ return isInsideDir(filePath, cwd);
32
+ }
33
+
34
+ function isInAllowedPath(filePath: string, cwd: string, allowedPaths: string[]): boolean {
35
+ for (const allowed of allowedPaths) {
36
+ const expanded = expandPath(allowed);
37
+ const absolute = isAbsolute(expanded) ? resolve(expanded) : resolve(cwd, expanded);
38
+ if (isInsideDir(filePath, absolute)) return true;
39
+ }
40
+ return false;
41
+ }
42
+
43
+ function pathFromToolCall(event: ToolCallEvent): string | undefined {
44
+ const input: Record<string, unknown> = isRecord(event.input) ? (event.input as Record<string, unknown>) : {};
45
+ const value = input.path;
46
+ if (typeof value === "string") return value;
47
+ return undefined;
48
+ }
49
+
50
+ function shouldCheckPath(toolName: string): boolean {
51
+ return toolName === "read" || toolName === "write" || toolName === "edit" || toolName === "grep" || toolName === "find" || toolName === "ls";
52
+ }
53
+
54
+ function shouldRequirePriorRead(toolName: string): boolean {
55
+ return toolName === "write" || toolName === "edit";
56
+ }
57
+
58
+ function shouldBlockInPlanPhase(toolName: string): boolean {
59
+ return toolName === "bash" || toolName === "write" || toolName === "edit";
60
+ }
61
+
62
+ export function installGuards(pi: ExtensionAPI, controller: MoonpiController): void {
63
+ pi.on("tool_call", async (event, ctx) => {
64
+ if (controller.isPlanPhase() && shouldBlockInPlanPhase(event.toolName)) {
65
+ const allowedTools = controller.isQuestionAllowed() ? "read, grep, find, ls, todo, or question" : "read, grep, find, ls, or todo";
66
+ return {
67
+ block: true,
68
+ reason: `moonpi blocked ${event.toolName}: the current Moonpi phase is read-only. Use ${allowedTools} instead.`,
69
+ };
70
+ }
71
+
72
+ if (!shouldCheckPath(event.toolName)) return undefined;
73
+ const rawPath = pathFromToolCall(event) ?? ".";
74
+
75
+ if (controller.config.guards.cwdOnly && !isInsideCwd(rawPath, ctx.cwd) && !isInAllowedPath(rawPath, ctx.cwd, controller.config.guards.allowedPaths)) {
76
+ return {
77
+ block: true,
78
+ reason: `moonpi blocked ${event.toolName}: path is outside the current working directory and allowed paths: ${rawPath}`,
79
+ };
80
+ }
81
+
82
+ if (!controller.config.guards.readBeforeWrite || !shouldRequirePriorRead(event.toolName)) return undefined;
83
+ const absolute = normalizePath(rawPath, ctx.cwd);
84
+ if (!existsSync(absolute)) return undefined;
85
+ if (controller.state.hasRead(absolute)) return undefined;
86
+
87
+ return {
88
+ block: true,
89
+ reason: `moonpi blocked ${event.toolName}: read the file first before modifying it: ${rawPath}`,
90
+ };
91
+ });
92
+
93
+ pi.on("tool_result", async (event: ToolResultEvent, ctx) => {
94
+ if (event.toolName !== "read" || event.isError) return undefined;
95
+ const rawPath = typeof event.input.path === "string" ? event.input.path : undefined;
96
+ if (!rawPath) return undefined;
97
+ controller.state.markRead(normalizePath(rawPath, ctx.cwd));
98
+ controller.persist();
99
+ return undefined;
100
+ });
101
+ }
package/src/index.ts ADDED
@@ -0,0 +1,145 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { formatConfig } from "./config.js";
3
+ import { installContextFiles } from "./context-files.js";
4
+ import { installGuards } from "./guards.js";
5
+ import { MoonpiController } from "./modes.js";
6
+ import { formatTodoList } from "./state.js";
7
+ import { installSprintWorkflow } from "./sprint.js";
8
+ import { installSynthetic } from "./synthetic.js";
9
+ import { installMoonpiTools } from "./tools.js";
10
+ import type { MoonpiMode } from "./types.js";
11
+
12
+ const MODES: MoonpiMode[] = ["plan", "act", "auto", "fast"];
13
+
14
+ function isMoonpiMode(value: string): value is MoonpiMode {
15
+ return value === "plan" || value === "act" || value === "auto" || value === "fast" || value === "sprint:plan" || value === "sprint:act";
16
+ }
17
+
18
+ export default async function moonpi(pi: ExtensionAPI): Promise<void> {
19
+ const controller = new MoonpiController(pi);
20
+
21
+ installMoonpiTools(pi, controller);
22
+ installGuards(pi, controller);
23
+ installContextFiles(pi, controller);
24
+ installSprintWorkflow(pi, controller);
25
+
26
+ pi.registerCommand("moonpi:mode", {
27
+ description: "Switch moonpi mode: plan, act, auto, fast",
28
+ getArgumentCompletions: (prefix) => {
29
+ return MODES.filter((mode) => mode.startsWith(prefix)).map((mode) => ({ label: mode, value: mode }));
30
+ },
31
+ handler: async (args, ctx) => {
32
+ const requested = args.trim();
33
+ if (!requested) {
34
+ const selectable = MODES; // MODES only contains user-selectable modes, not sprint:plan/sprint:act
35
+ const selected = await ctx.ui.select("moonpi mode", [...selectable]);
36
+ if (selected && isMoonpiMode(selected)) controller.setMode(ctx, selected);
37
+ return;
38
+ }
39
+ if (!isMoonpiMode(requested)) {
40
+ ctx.ui.notify(`Unknown moonpi mode: ${requested}`, "error");
41
+ return;
42
+ }
43
+ if (requested === "sprint:plan" || requested === "sprint:act") {
44
+ ctx.ui.notify(`Mode ${requested} cannot be set manually. It is managed by the sprint loop.`, "error");
45
+ return;
46
+ }
47
+ controller.setMode(ctx, requested);
48
+ },
49
+ });
50
+
51
+ pi.registerCommand("moonpi:settings", {
52
+ description: "Show effective moonpi settings",
53
+ handler: async (_args, ctx) => {
54
+ ctx.ui.notify(formatConfig(controller.config), "info");
55
+ },
56
+ });
57
+
58
+ pi.on("session_start", async (_event, ctx) => {
59
+ controller.restoreFromSession(ctx);
60
+ controller.installUi(ctx);
61
+ controller.applyMode(ctx);
62
+ });
63
+
64
+ pi.on("session_tree", async (_event, ctx) => {
65
+ controller.restoreFromSession(ctx);
66
+ controller.applyMode(ctx);
67
+ });
68
+
69
+ pi.on("session_shutdown", async () => {
70
+ controller.disposeUi();
71
+ });
72
+
73
+ pi.on("input", async (event, ctx) => {
74
+ if (event.source !== "extension") controller.resetForUserPrompt(ctx);
75
+ return { action: "continue" };
76
+ });
77
+
78
+ pi.on("before_agent_start", async (event) => {
79
+ return {
80
+ systemPrompt: `${event.systemPrompt}
81
+
82
+ You are moonpi.
83
+
84
+ ${controller.buildModePrompt()}`,
85
+ };
86
+ });
87
+
88
+ pi.on("agent_end", async (_event, ctx) => {
89
+ controller.updateUi(ctx);
90
+
91
+ const isPlanMode = controller.state.mode === "plan" || controller.state.mode === "sprint:plan";
92
+ if (isPlanMode && controller.state.todos.length === 0) {
93
+ setImmediate(() => {
94
+ pi.sendUserMessage(
95
+ "Moonpi Plan mode requires a TODO list before the turn can finish. Use todo to create the plan now.",
96
+ );
97
+ });
98
+ return;
99
+ }
100
+
101
+ // sprint:act completed → back to sprint:plan
102
+ if (controller.state.mode === "sprint:act") {
103
+ controller.state.setMode("sprint:plan");
104
+ controller.state.todos = [];
105
+ controller.state.nextTodoId = 1;
106
+ controller.applyMode(ctx);
107
+ controller.persist();
108
+ return;
109
+ }
110
+
111
+ // sprint:plan completed with TODOs → switch to sprint:act
112
+ if (controller.state.mode === "sprint:plan" && controller.state.todos.length > 0) {
113
+ controller.state.setMode("sprint:act");
114
+ controller.applyMode(ctx);
115
+ controller.persist();
116
+ setImmediate(() => {
117
+ pi.sendUserMessage("Moonpi Sprint mode is switching to Act phase. Execute the TODO list now.");
118
+ });
119
+ return;
120
+ }
121
+
122
+ if (controller.state.mode !== "auto" || controller.state.autoPhase !== "plan") return;
123
+ if (controller.state.endConversationRequested) {
124
+ controller.state.endConversationRequested = false;
125
+ controller.persist();
126
+ return;
127
+ }
128
+ if (controller.state.todos.length === 0) return;
129
+
130
+ controller.switchAutoToAct(ctx);
131
+ setImmediate(() => {
132
+ const todoList = formatTodoList(controller.state.todos);
133
+ pi.sendUserMessage(`Auto mode is switching to Act phase. Execute the TODO list now.
134
+
135
+ ${todoList}`);
136
+ });
137
+ });
138
+
139
+ // Synthetic is optional; keep core Moonpi mode hooks installed even if provider setup fails.
140
+ try {
141
+ await installSynthetic(pi);
142
+ } catch {
143
+ // Ignore optional provider setup failures.
144
+ }
145
+ }
package/src/modes.ts ADDED
@@ -0,0 +1,233 @@
1
+ import type { ExtensionAPI, ExtensionContext, SessionEntry } from "@mariozechner/pi-coding-agent";
2
+ import type { KeyId } from "@mariozechner/pi-tui";
3
+ import { matchesKey } from "@mariozechner/pi-tui";
4
+ import { loadMoonpiConfig } from "./config.js";
5
+ import { MoonpiState, formatTodoList } from "./state.js";
6
+ import type { MoonpiConfig, MoonpiMode, MoonpiSnapshot } from "./types.js";
7
+ import { installMoonpiEditor, installMoonpiHeader } from "./ui.js";
8
+
9
+ const MODE_ORDER: MoonpiMode[] = ["plan", "act", "auto", "fast"];
10
+ const STABLE_MOONPI_TOOLS = [
11
+ "read",
12
+ "grep",
13
+ "find",
14
+ "ls",
15
+ "bash",
16
+ "edit",
17
+ "write",
18
+ "todo",
19
+ "question",
20
+ "end_conversation",
21
+ "end_phase",
22
+ ];
23
+ const MOONPI_TOOL_NAMES = new Set(STABLE_MOONPI_TOOLS);
24
+ type Direction = "next" | "previous";
25
+
26
+ function entryHasMoonpiSnapshot(entry: SessionEntry): entry is SessionEntry & { customType: "moonpi-state"; data: MoonpiSnapshot } {
27
+ if (entry.type !== "custom") return false;
28
+ const candidate = entry as SessionEntry & { customType?: string; data?: unknown };
29
+ return candidate.customType === "moonpi-state" && typeof candidate.data === "object" && candidate.data !== null;
30
+ }
31
+
32
+ function latestSnapshot(entries: SessionEntry[]): MoonpiSnapshot | undefined {
33
+ for (let i = entries.length - 1; i >= 0; i -= 1) {
34
+ const entry = entries[i];
35
+ if (entry && entryHasMoonpiSnapshot(entry)) return entry.data;
36
+ }
37
+ return undefined;
38
+ }
39
+
40
+ export class MoonpiController {
41
+ readonly state = new MoonpiState();
42
+ config: MoonpiConfig = loadMoonpiConfig(process.cwd());
43
+ private terminalInputUnsubscribe: (() => void) | undefined;
44
+
45
+ constructor(private readonly pi: ExtensionAPI) {}
46
+
47
+ restoreFromSession(ctx: ExtensionContext): void {
48
+ this.config = loadMoonpiConfig(ctx.cwd);
49
+ this.state.mode = this.config.defaultMode;
50
+ this.state.restore(latestSnapshot(ctx.sessionManager.getEntries()));
51
+ }
52
+
53
+ persist(): void {
54
+ this.pi.appendEntry("moonpi-state", this.state.snapshot());
55
+ }
56
+
57
+ setMode(ctx: ExtensionContext, mode: MoonpiMode): void {
58
+ this.state.setMode(mode);
59
+ this.applyMode(ctx);
60
+ this.persist();
61
+ ctx.ui.notify(`moonpi mode: ${mode}`, "info");
62
+ }
63
+
64
+ cycleMode(ctx: ExtensionContext, direction: Direction): void {
65
+ const currentIndex = MODE_ORDER.indexOf(this.state.mode);
66
+ const offset = direction === "next" ? 1 : -1;
67
+ const nextIndex = (currentIndex + offset + MODE_ORDER.length) % MODE_ORDER.length;
68
+ const nextMode = MODE_ORDER[nextIndex] ?? "auto";
69
+ this.setMode(ctx, nextMode);
70
+ }
71
+
72
+ resetForUserPrompt(ctx: ExtensionContext): void {
73
+ this.state.resetForUserPrompt();
74
+ this.applyMode(ctx);
75
+ this.persist();
76
+ }
77
+
78
+ markEndConversationRequested(): void {
79
+ this.state.endConversationRequested = true;
80
+ this.persist();
81
+ }
82
+
83
+ switchAutoToAct(ctx: ExtensionContext): void {
84
+ this.state.autoPhase = "act";
85
+ this.applyMode(ctx);
86
+ this.persist();
87
+ }
88
+
89
+ applyMode(ctx: ExtensionContext): void {
90
+ this.pi.setActiveTools(this.getToolsForCurrentMode());
91
+ this.updateUi(ctx);
92
+ }
93
+
94
+ isPlanPhase(): boolean {
95
+ return this.state.mode === "plan" || this.state.mode === "sprint:plan" || (this.state.mode === "auto" && this.state.autoPhase === "plan");
96
+ }
97
+
98
+ isQuestionAllowed(): boolean {
99
+ return this.state.mode !== "fast" && this.state.mode !== "sprint:plan" && this.state.mode !== "sprint:act";
100
+ }
101
+
102
+ isEndConversationAllowed(): boolean {
103
+ return this.state.mode === "auto" && this.state.autoPhase === "plan";
104
+ }
105
+
106
+ installUi(ctx: ExtensionContext): void {
107
+ installMoonpiHeader(ctx);
108
+ if (this.config.customEditor) {
109
+ installMoonpiEditor(ctx, () => this.state.mode);
110
+ }
111
+ this.terminalInputUnsubscribe?.();
112
+ this.terminalInputUnsubscribe = ctx.ui.onTerminalInput((data) => {
113
+ if (ctx.ui.getEditorText().length > 0) return undefined;
114
+ if (matchesKey(data, this.config.keybindings.cycleNext as KeyId)) {
115
+ this.cycleMode(ctx, "next");
116
+ return { consume: true };
117
+ }
118
+ if (matchesKey(data, this.config.keybindings.cyclePrevious as KeyId)) {
119
+ this.cycleMode(ctx, "previous");
120
+ return { consume: true };
121
+ }
122
+ return undefined;
123
+ });
124
+ }
125
+
126
+ disposeUi(): void {
127
+ this.terminalInputUnsubscribe?.();
128
+ this.terminalInputUnsubscribe = undefined;
129
+ }
130
+
131
+ updateUi(ctx: ExtensionContext): void {
132
+ const isSprint = this.state.mode === "sprint:plan" || this.state.mode === "sprint:act";
133
+ const phase = this.state.mode === "auto" ? `:${this.state.autoPhase}` : isSprint ? `:${this.state.mode === "sprint:act" ? "act" : "plan"}` : "";
134
+ const modeLabel = isSprint ? "sprint" : this.state.mode;
135
+ const total = this.state.todos.length;
136
+ const done = this.state.todos.filter((todo) => todo.status === "done").length;
137
+ ctx.ui.setStatus("moonpi", ctx.ui.theme.fg("accent", `moonpi ${modeLabel}${phase} ${done}/${total}`));
138
+
139
+ if (this.state.mode === "fast" || total === 0) {
140
+ ctx.ui.setWidget("moonpi-todos", undefined);
141
+ return;
142
+ }
143
+ ctx.ui.setWidget("moonpi-todos", formatTodoList(this.state.todos).split("\n"), { placement: "aboveEditor" });
144
+ }
145
+
146
+ getToolsForCurrentMode(): string[] {
147
+ if (!this.config.preserveExternalTools) return STABLE_MOONPI_TOOLS;
148
+ const externalTools = this.pi.getActiveTools().filter((toolName) => !MOONPI_TOOL_NAMES.has(toolName));
149
+ return [...new Set([...STABLE_MOONPI_TOOLS, ...externalTools])];
150
+ }
151
+
152
+ buildModePrompt(): string {
153
+ if (this.state.mode === "auto") return AUTO_MODE_PROMPT;
154
+ if (this.state.mode === "plan") return PLAN_MODE_PROMPT;
155
+ if (this.state.mode === "act") return ACT_MODE_PROMPT;
156
+ if (this.state.mode === "fast") return FAST_MODE_PROMPT;
157
+ if (this.state.mode === "sprint:plan") return SPRINT_PLAN_MODE_PROMPT;
158
+ return SPRINT_ACT_MODE_PROMPT;
159
+ }
160
+ }
161
+
162
+ const PLAN_MODE_PROMPT = `## Plan Mode
163
+
164
+ You are in Plan mode. This mode is for investigation and planning before implementation.
165
+
166
+ - Use read, grep, find, and ls to inspect the project.
167
+ - Do not use bash, edit, or write; The system blocks those tools in this mode.
168
+ - Ask concise clarifying questions with question when a user decision is required.
169
+ - Create or update a concrete TODO list with todo before ending the turn.
170
+ - Do not implement changes in Plan mode. The TODO list should make the next Act-mode work explicit.`;
171
+
172
+ const ACT_MODE_PROMPT = `## Act Mode
173
+
174
+ You are in Act mode. This mode is for implementation.
175
+
176
+ - Use read, grep, find, ls, bash, edit, and write as needed.
177
+ - Use todo when it helps track progress, especially when executing a plan created earlier.
178
+ - Ask questions only when blocked by a real user decision.
179
+ - Keep changes scoped to the user's request and verify the result when practical.`;
180
+
181
+ const AUTO_MODE_PROMPT = `## Auto Mode
182
+
183
+ Auto mode uses this same system prompt for both Plan and Act phases. The system prompt must not change when Auto advances from Plan to Act, so the conversation can keep provider prompt-cache affinity.
184
+
185
+ Auto begins in Plan phase. In Plan phase:
186
+
187
+ - Use read, grep, find, and ls to inspect the project.
188
+ - Do not use bash, edit, or write; The system blocks those tools until Act phase.
189
+ - If the request needs implementation, create a concrete non-empty TODO list with todo.
190
+ - If the user only asked a question or no action is needed, call end_conversation instead of creating TODOs.
191
+
192
+ Creating or updating a non-empty TODO list ends Auto planning. The system then retains the conversation, switches runtime to Act phase, and sends this user message:
193
+
194
+ Auto mode is switching to Act phase. Execute the TODO list now.
195
+
196
+ #1 [ ] First task
197
+ #2 [ ] Second task
198
+ ...
199
+
200
+ That user message is the phase-change signal. After you see it, you are in Act phase:
201
+
202
+ - Execute the TODO list.
203
+ - Use read, grep, find, ls, bash, edit, and write as needed.
204
+ - Update TODO statuses with todo as work progresses.
205
+ - Ask questions only when blocked by a real user decision.`;
206
+
207
+ const FAST_MODE_PROMPT = `## Fast Mode
208
+
209
+ You are in Fast mode. Work directly.
210
+
211
+ - Use read, grep, find, ls, bash, edit, and write as needed.
212
+ - Do not use todo, question, or end_conversation; The system disables those tools in Fast mode.
213
+ - Keep the response and edits proportional to the request.`;
214
+
215
+ const SPRINT_PLAN_MODE_PROMPT = `## Sprint Plan Mode
216
+
217
+ You are in Sprint Plan mode. The current sprint phase instructions are provided in the conversation, not in this system prompt.
218
+
219
+ - Work only on the current sprint phase.
220
+ - Use read, grep, find, and ls to inspect the project.
221
+ - Do not use bash, edit, or write; The system blocks those tools in this mode.
222
+ - The question tool is unavailable. Make a reasonable judgment and document assumptions in TODO items when needed.
223
+ - Create or update a concrete TODO list with todo before ending the turn.`;
224
+
225
+ const SPRINT_ACT_MODE_PROMPT = `## Sprint Act Mode
226
+
227
+ You are in Sprint Act mode. The current sprint phase instructions are provided in the conversation, not in this system prompt.
228
+
229
+ - Work only on the current sprint phase.
230
+ - Execute the TODO list and update TODO statuses with todo as work progresses.
231
+ - Use read, grep, find, ls, bash, edit, and write as needed.
232
+ - The question tool is unavailable. Make a reasonable judgment when blocked and document important assumptions.
233
+ - When the current phase is complete and verified, call end_phase with a concise summary.`;