yoyo-pi 0.1.4
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 +91 -0
- package/README.zh-CN.md +91 -0
- package/docs/previews/agent-status.svg +83 -0
- package/docs/previews/filetree.png +0 -0
- package/docs/previews/multiple-choice.png +0 -0
- package/docs/previews/pi-tui-agent-status.html +379 -0
- package/docs/previews/pi-tui-status-bar.html +481 -0
- package/docs/previews/single-choice.png +0 -0
- package/docs/previews/status-bar.png +0 -0
- package/docs/previews/todo-sidebar.png +0 -0
- package/extensions/choice-picker.ts +1404 -0
- package/extensions/clear-context.ts +164 -0
- package/extensions/gr0k-hack/index.ts +1231 -0
- package/extensions/kenx-infra/index.ts +1601 -0
- package/extensions/kenx-infra/themes/dark.json +80 -0
- package/extensions/kenx-infra/themes/light.json +80 -0
- package/extensions/kenx-infra/themes/paper.json +80 -0
- package/extensions/plan-mode/README.md +58 -0
- package/extensions/plan-mode/agents/plan-agent.md +98 -0
- package/extensions/plan-mode/index.ts +1956 -0
- package/extensions/plan-mode/sandbox.ts +332 -0
- package/extensions/vim-mode.ts +561 -0
- package/package.json +58 -0
|
@@ -0,0 +1,1956 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan mode + plan-agent extension.
|
|
3
|
+
*
|
|
4
|
+
* /plan enters a read-only planning mode. In that mode the main agent can call
|
|
5
|
+
* the plan_agent tool, which spawns the bundled agents/plan-agent.md
|
|
6
|
+
* agent in an isolated pi process. The child process is guarded by sandbox.ts:
|
|
7
|
+
* repository read-only access plus write/edit/delete only under .plan/ for the markdown plan and todo JSONL.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { spawn } from "node:child_process";
|
|
11
|
+
import * as fs from "node:fs";
|
|
12
|
+
import * as fsp from "node:fs/promises";
|
|
13
|
+
import * as os from "node:os";
|
|
14
|
+
import * as path from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import type { Component, OverlayOptions, TUI } from "@earendil-works/pi-tui";
|
|
19
|
+
import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
20
|
+
import { Type } from "typebox";
|
|
21
|
+
|
|
22
|
+
const PLAN_AGENT_NAME = "plan-agent";
|
|
23
|
+
const PLAN_DIR = ".plan";
|
|
24
|
+
const TODO_PATH = `${PLAN_DIR}/todo.jsonl`;
|
|
25
|
+
const PLAN_MODE_TOOLS = ["read", "grep", "find", "ls", "bash", "plan_agent"];
|
|
26
|
+
const DEFAULT_PLAN_AGENT_TOOLS = [
|
|
27
|
+
"read",
|
|
28
|
+
"grep",
|
|
29
|
+
"find",
|
|
30
|
+
"ls",
|
|
31
|
+
"bash",
|
|
32
|
+
"write",
|
|
33
|
+
"edit",
|
|
34
|
+
"plan_web_search",
|
|
35
|
+
"plan_delete",
|
|
36
|
+
];
|
|
37
|
+
const MAX_PARENT_SYSTEM_PROMPT_CHARS = 100_000;
|
|
38
|
+
const MAX_STDERR_CHARS = 20_000;
|
|
39
|
+
const MAX_PLAN_PREVIEW_CHARS = 80_000;
|
|
40
|
+
const MAX_PLAN_HANDOFF_CHARS = 120_000;
|
|
41
|
+
const PLAN_PREVIEW_CUSTOM_TYPE = "plan-mode-exit-plan-preview";
|
|
42
|
+
const PLAN_HANDOFF_CUSTOM_TYPE = "plan-mode-exit-handoff";
|
|
43
|
+
const PLAN_CONTEXT_RESET_CUSTOM_TYPE = "plan-mode-context-reset";
|
|
44
|
+
const PLAN_EXIT_EXECUTE_OPTION = "plan没问题,允许退出plan mode,开始执行";
|
|
45
|
+
const PLAN_EXIT_SHELVE_OPTION = "允许退出plan mode,先搁置";
|
|
46
|
+
const PLAN_EXIT_REVISE_OPTION = "不允许退出,需要修改:{修改意见}";
|
|
47
|
+
|
|
48
|
+
const EXTENSION_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
49
|
+
const SANDBOX_EXTENSION_PATH = path.join(EXTENSION_DIR, "sandbox.ts");
|
|
50
|
+
const PLAN_AGENT_PATH = path.join(EXTENSION_DIR, "agents", `${PLAN_AGENT_NAME}.md`);
|
|
51
|
+
|
|
52
|
+
interface PlanAgentConfig {
|
|
53
|
+
name: string;
|
|
54
|
+
description: string;
|
|
55
|
+
tools: string[];
|
|
56
|
+
model?: string;
|
|
57
|
+
systemPrompt: string;
|
|
58
|
+
filePath: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface PlanAgentRunOptions {
|
|
62
|
+
prompt: string;
|
|
63
|
+
outputPath?: string;
|
|
64
|
+
signal?: AbortSignal;
|
|
65
|
+
onStatus?: (status: string) => void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface PlanAgentRunResult {
|
|
69
|
+
planPath: string;
|
|
70
|
+
absolutePlanPath: string;
|
|
71
|
+
planExists: boolean;
|
|
72
|
+
todoPath: string;
|
|
73
|
+
absoluteTodoPath: string;
|
|
74
|
+
todoExists: boolean;
|
|
75
|
+
exitCode: number;
|
|
76
|
+
finalOutput: string;
|
|
77
|
+
stderr: string;
|
|
78
|
+
model?: string;
|
|
79
|
+
stopReason?: string;
|
|
80
|
+
errorMessage?: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
type PlanReviewSource = "command" | "tool";
|
|
84
|
+
type PlanExitDecision = "execute" | "shelve" | "explicit";
|
|
85
|
+
|
|
86
|
+
interface PlanReviewRequest {
|
|
87
|
+
id: string;
|
|
88
|
+
source: PlanReviewSource;
|
|
89
|
+
prompt: string;
|
|
90
|
+
planPath: string;
|
|
91
|
+
absolutePlanPath: string;
|
|
92
|
+
todoPath: string;
|
|
93
|
+
absoluteTodoPath: string;
|
|
94
|
+
createdAt: string;
|
|
95
|
+
result: PlanAgentRunResult;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface PlanContentRead {
|
|
99
|
+
content: string;
|
|
100
|
+
truncated: boolean;
|
|
101
|
+
error?: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
interface PlanExitHandoff {
|
|
105
|
+
id: string;
|
|
106
|
+
decision: PlanExitDecision;
|
|
107
|
+
originalPrompt: string;
|
|
108
|
+
planPath: string;
|
|
109
|
+
absolutePlanPath: string;
|
|
110
|
+
todoPath: string;
|
|
111
|
+
absoluteTodoPath: string;
|
|
112
|
+
planContent: string;
|
|
113
|
+
planContentTruncated: boolean;
|
|
114
|
+
createdAt: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
type PlanTodoStatus = "pending" | "in_progress" | "done" | "blocked" | "skipped" | "cancelled" | "failed" | string;
|
|
118
|
+
|
|
119
|
+
interface PlanTodoItem {
|
|
120
|
+
type?: string;
|
|
121
|
+
schemaVersion?: number;
|
|
122
|
+
planPath?: string;
|
|
123
|
+
step: number;
|
|
124
|
+
title: string;
|
|
125
|
+
description?: string;
|
|
126
|
+
status: PlanTodoStatus;
|
|
127
|
+
priority?: string;
|
|
128
|
+
dependencies?: Array<string | number>;
|
|
129
|
+
validation?: string[];
|
|
130
|
+
createdAt?: string;
|
|
131
|
+
startedAt?: string;
|
|
132
|
+
updatedAt?: string;
|
|
133
|
+
completedAt?: string;
|
|
134
|
+
lineNumber: number;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
interface ParsedTodoFile {
|
|
138
|
+
path: string;
|
|
139
|
+
relativePath: string;
|
|
140
|
+
todos: PlanTodoItem[];
|
|
141
|
+
errors: string[];
|
|
142
|
+
mtimeMs: number;
|
|
143
|
+
loadedAt: number;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
type TodoSidebarResult = { type: "close" };
|
|
147
|
+
|
|
148
|
+
type KenxSidebarBridge = {
|
|
149
|
+
isFileTreeOpen(): boolean;
|
|
150
|
+
closeFileTree(): boolean;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const KENX_SIDEBAR_BRIDGE_KEY = Symbol.for("yoyo-pi.kenx-infra.sidebar");
|
|
154
|
+
const TODO_STATUS_ACTIVE = new Set(["active", "doing", "in-progress", "in_progress", "now", "running", "started"]);
|
|
155
|
+
const TODO_STATUS_DONE = new Set(["complete", "completed", "done", "success", "succeeded"]);
|
|
156
|
+
const TODO_STATUS_BLOCKED = new Set(["blocked", "error", "failed", "failure"]);
|
|
157
|
+
const TODO_STATUS_SKIPPED = new Set(["cancelled", "canceled", "skipped"]);
|
|
158
|
+
|
|
159
|
+
function isInside(child: string, root: string): boolean {
|
|
160
|
+
const relative = path.relative(root, child);
|
|
161
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function stripAtPrefix(value: string): string {
|
|
165
|
+
return value.startsWith("@") ? value.slice(1) : value;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function slugify(input: string): string {
|
|
169
|
+
const slug = input
|
|
170
|
+
.toLowerCase()
|
|
171
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
172
|
+
.replace(/^-+|-+$/g, "")
|
|
173
|
+
.slice(0, 54);
|
|
174
|
+
return slug || "plan";
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function timestampForFile(): string {
|
|
178
|
+
return new Date().toISOString().replace(/[:.]/g, "-");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function normalizePlanPath(cwd: string, prompt: string, requested?: string): { relative: string; absolute: string } {
|
|
182
|
+
const planRoot = path.resolve(cwd, PLAN_DIR);
|
|
183
|
+
let target: string;
|
|
184
|
+
|
|
185
|
+
if (requested?.trim()) {
|
|
186
|
+
const cleaned = stripAtPrefix(requested.trim());
|
|
187
|
+
target = path.isAbsolute(cleaned) ? path.resolve(cleaned) : path.resolve(cwd, cleaned);
|
|
188
|
+
if (!path.extname(target)) target += ".md";
|
|
189
|
+
} else {
|
|
190
|
+
target = path.join(planRoot, `${timestampForFile()}-${slugify(prompt)}.md`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (!isInside(target, planRoot)) {
|
|
194
|
+
throw new Error(`Plan output path must be inside ${PLAN_DIR}/ (got ${requested ?? target})`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return { relative: path.relative(cwd, target) || path.join(PLAN_DIR, path.basename(target)), absolute: target };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function truncateMiddle(text: string, maxChars: number): string {
|
|
201
|
+
if (text.length <= maxChars) return text;
|
|
202
|
+
const keep = Math.floor((maxChars - 120) / 2);
|
|
203
|
+
return `${text.slice(0, keep)}\n\n[... truncated ${text.length - keep * 2} characters ...]\n\n${text.slice(-keep)}`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function truncateTail(text: string, maxChars: number): string {
|
|
207
|
+
if (text.length <= maxChars) return text;
|
|
208
|
+
return `[... truncated ${text.length - maxChars} characters ...]\n${text.slice(-maxChars)}`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function loadPlanAgent(): PlanAgentConfig {
|
|
212
|
+
const filePath = PLAN_AGENT_PATH;
|
|
213
|
+
if (!fs.existsSync(filePath)) {
|
|
214
|
+
throw new Error(`Missing bundled agent: ${filePath}`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
218
|
+
const { frontmatter, body } = parseFrontmatter<Record<string, string>>(content);
|
|
219
|
+
const tools = frontmatter.tools
|
|
220
|
+
?.split(",")
|
|
221
|
+
.map((tool) => tool.trim())
|
|
222
|
+
.filter(Boolean);
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
name: frontmatter.name || PLAN_AGENT_NAME,
|
|
226
|
+
description: frontmatter.description || "Writes implementation plans under .plan/",
|
|
227
|
+
tools: tools && tools.length > 0 ? tools : DEFAULT_PLAN_AGENT_TOOLS,
|
|
228
|
+
model: frontmatter.model?.trim() || undefined,
|
|
229
|
+
systemPrompt: body.trim(),
|
|
230
|
+
filePath,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function getPiInvocation(args: string[]): { command: string; args: string[] } {
|
|
235
|
+
const currentScript = process.argv[1];
|
|
236
|
+
const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
|
|
237
|
+
if (currentScript && !isBunVirtualScript && fs.existsSync(currentScript)) {
|
|
238
|
+
return { command: process.execPath, args: [currentScript, ...args] };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const execName = path.basename(process.execPath).toLowerCase();
|
|
242
|
+
const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName);
|
|
243
|
+
if (!isGenericRuntime) return { command: process.execPath, args };
|
|
244
|
+
|
|
245
|
+
return { command: "pi", args };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function assistantText(message: any): string {
|
|
249
|
+
if (!message || message.role !== "assistant") return "";
|
|
250
|
+
if (typeof message.content === "string") return message.content;
|
|
251
|
+
if (!Array.isArray(message.content)) return "";
|
|
252
|
+
return message.content
|
|
253
|
+
.filter((part: any) => part?.type === "text" && typeof part.text === "string")
|
|
254
|
+
.map((part: any) => part.text)
|
|
255
|
+
.join("\n")
|
|
256
|
+
.trim();
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function getFinalOutput(messages: any[]): string {
|
|
260
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
261
|
+
const text = assistantText(messages[i]);
|
|
262
|
+
if (text) return text;
|
|
263
|
+
}
|
|
264
|
+
return "";
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function buildTaskPrompt(
|
|
268
|
+
userPrompt: string,
|
|
269
|
+
planPath: string,
|
|
270
|
+
parentSystemPrompt: string,
|
|
271
|
+
agent: PlanAgentConfig,
|
|
272
|
+
): string {
|
|
273
|
+
const truncatedSystemPrompt = truncateMiddle(parentSystemPrompt, MAX_PARENT_SYSTEM_PROMPT_CHARS);
|
|
274
|
+
const taskCreatedAt = new Date().toISOString();
|
|
275
|
+
return `# ${PLAN_AGENT_NAME} task
|
|
276
|
+
|
|
277
|
+
## User prompt
|
|
278
|
+
${userPrompt}
|
|
279
|
+
|
|
280
|
+
## Required output files
|
|
281
|
+
Create or update both outputs in the current repository:
|
|
282
|
+
|
|
283
|
+
- Markdown plan: \`${planPath}\`
|
|
284
|
+
- Todo JSONL file: \`${TODO_PATH}\`
|
|
285
|
+
|
|
286
|
+
## Todo output requirements
|
|
287
|
+
- Derive todos directly from the final markdown plan's \`## Plan\` steps.
|
|
288
|
+
- Rewrite \`${TODO_PATH}\` so it contains todos for this plan only; do not leave stale todos from older plans.
|
|
289
|
+
- Write one compact, valid JSON object per line. Do not wrap the JSONL in markdown fences.
|
|
290
|
+
- Keep todo \`step\` values in the same order as the markdown plan steps and start at 1.
|
|
291
|
+
- Use \`status: "pending"\` for every new todo.
|
|
292
|
+
- Use this task timestamp for every todo: \`${taskCreatedAt}\`.
|
|
293
|
+
- Required JSONL fields per line:
|
|
294
|
+
|
|
295
|
+
\`\`\`jsonl
|
|
296
|
+
{"type":"plan_todo","schemaVersion":1,"planPath":"${planPath}","step":1,"title":"Short actionable title","description":"Concrete implementation task tied to the plan step","status":"pending","priority":"medium","dependencies":[],"validation":["Check or test for this step"],"createdAt":"${taskCreatedAt}"}
|
|
297
|
+
\`\`\`
|
|
298
|
+
|
|
299
|
+
## Parent session system prompt
|
|
300
|
+
The parent pi session supplied this system prompt/context. Use it as authoritative higher-level guidance alongside your own plan-agent instructions and repository instructions.
|
|
301
|
+
|
|
302
|
+
\`\`\`text
|
|
303
|
+
${truncatedSystemPrompt || "(empty)"}
|
|
304
|
+
\`\`\`
|
|
305
|
+
|
|
306
|
+
## Repository instructions
|
|
307
|
+
Pi will load repository context files such as AGENTS.md and CLAUDE.md automatically. Also search for and read relevant guidance files when present, including AGENTS.md, agent.md, Agent.md, CLAUDE.md, and .github/copilot-instructions.md.
|
|
308
|
+
|
|
309
|
+
## Required workflow
|
|
310
|
+
1. Understand the user's request and any constraints from the parent system prompt.
|
|
311
|
+
2. Search the codebase with read-only tools (grep/find/ls/read, and read-only bash if useful).
|
|
312
|
+
3. Use plan_web_search for external docs, APIs, libraries, or error messages when that would improve the plan.
|
|
313
|
+
4. Do code checking by inspecting relevant code paths. If a runtime check would modify files outside ${PLAN_DIR}/, skip it and note that limitation.
|
|
314
|
+
5. Create or update the markdown plan at \`${planPath}\` using write/edit.
|
|
315
|
+
6. Create or replace the todo JSONL file at \`${TODO_PATH}\` using write/edit, following the schema above.
|
|
316
|
+
7. Do not change files outside ${PLAN_DIR}/. Within ${PLAN_DIR}/, only modify the requested plan file, \`${TODO_PATH}\`, or obsolete plan files that you explicitly delete with plan_delete.
|
|
317
|
+
8. Return a concise summary that includes the plan path, todo path, and the most important risks/next steps.
|
|
318
|
+
|
|
319
|
+
## Loaded bundled agent
|
|
320
|
+
- Agent file: ${agent.filePath}
|
|
321
|
+
- Agent name: ${agent.name}
|
|
322
|
+
- Allowed child tools: ${agent.tools.join(", ")}
|
|
323
|
+
`;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function runPlanAgent(ctx: ExtensionContext, options: PlanAgentRunOptions): Promise<PlanAgentRunResult> {
|
|
327
|
+
const agent = loadPlanAgent();
|
|
328
|
+
const { relative: planPath, absolute: absolutePlanPath } = normalizePlanPath(ctx.cwd, options.prompt, options.outputPath);
|
|
329
|
+
const todoPath = TODO_PATH;
|
|
330
|
+
const absoluteTodoPath = path.resolve(ctx.cwd, TODO_PATH);
|
|
331
|
+
const parentSystemPrompt = (() => {
|
|
332
|
+
try {
|
|
333
|
+
return ctx.getSystemPrompt();
|
|
334
|
+
} catch {
|
|
335
|
+
return "";
|
|
336
|
+
}
|
|
337
|
+
})();
|
|
338
|
+
|
|
339
|
+
if (!fs.existsSync(SANDBOX_EXTENSION_PATH)) {
|
|
340
|
+
throw new Error(`Missing plan-agent sandbox extension: ${SANDBOX_EXTENSION_PATH}`);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
let tmpDir: string | undefined;
|
|
344
|
+
const messages: any[] = [];
|
|
345
|
+
let stderr = "";
|
|
346
|
+
let model: string | undefined;
|
|
347
|
+
let stopReason: string | undefined;
|
|
348
|
+
let errorMessage: string | undefined;
|
|
349
|
+
|
|
350
|
+
try {
|
|
351
|
+
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "pi-plan-agent-"));
|
|
352
|
+
const taskPath = path.join(tmpDir, "task.md");
|
|
353
|
+
await fsp.writeFile(taskPath, buildTaskPrompt(options.prompt, planPath, parentSystemPrompt, agent), "utf-8");
|
|
354
|
+
|
|
355
|
+
const args = [
|
|
356
|
+
"--mode",
|
|
357
|
+
"json",
|
|
358
|
+
"-p",
|
|
359
|
+
"--no-session",
|
|
360
|
+
"--no-extensions",
|
|
361
|
+
"-e",
|
|
362
|
+
SANDBOX_EXTENSION_PATH,
|
|
363
|
+
"--tools",
|
|
364
|
+
agent.tools.join(","),
|
|
365
|
+
];
|
|
366
|
+
if (agent.model) args.push("--model", agent.model);
|
|
367
|
+
if (agent.systemPrompt) args.push("--append-system-prompt", agent.systemPrompt);
|
|
368
|
+
args.push(`@${taskPath}`);
|
|
369
|
+
|
|
370
|
+
options.onStatus?.(`spawning ${PLAN_AGENT_NAME}...`);
|
|
371
|
+
const invocation = getPiInvocation(args);
|
|
372
|
+
|
|
373
|
+
let stdoutBuffer = "";
|
|
374
|
+
let wasAborted = false;
|
|
375
|
+
const exitCode = await new Promise<number>((resolve) => {
|
|
376
|
+
const proc = spawn(invocation.command, invocation.args, {
|
|
377
|
+
cwd: ctx.cwd,
|
|
378
|
+
shell: false,
|
|
379
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
380
|
+
env: process.env,
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
const processLine = (line: string) => {
|
|
384
|
+
if (!line.trim()) return;
|
|
385
|
+
let event: any;
|
|
386
|
+
try {
|
|
387
|
+
event = JSON.parse(line);
|
|
388
|
+
} catch {
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (event.type === "message_end" && event.message) {
|
|
393
|
+
messages.push(event.message);
|
|
394
|
+
if (event.message.role === "assistant") {
|
|
395
|
+
model = event.message.model || model;
|
|
396
|
+
stopReason = event.message.stopReason || stopReason;
|
|
397
|
+
errorMessage = event.message.errorMessage || errorMessage;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (event.type === "tool_result_end" && event.message) {
|
|
402
|
+
messages.push(event.message);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (event.type === "agent_end" && Array.isArray(event.messages) && messages.length === 0) {
|
|
406
|
+
messages.push(...event.messages);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (event.type === "tool_execution_start") {
|
|
410
|
+
options.onStatus?.(`${PLAN_AGENT_NAME}: ${event.toolName || "tool"}...`);
|
|
411
|
+
}
|
|
412
|
+
if (event.type === "tool_execution_end") {
|
|
413
|
+
options.onStatus?.(`${PLAN_AGENT_NAME}: ${event.toolName || "tool"} done`);
|
|
414
|
+
}
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
proc.stdout?.on("data", (data) => {
|
|
418
|
+
stdoutBuffer += data.toString();
|
|
419
|
+
const lines = stdoutBuffer.split("\n");
|
|
420
|
+
stdoutBuffer = lines.pop() || "";
|
|
421
|
+
for (const line of lines) processLine(line);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
proc.stderr?.on("data", (data) => {
|
|
425
|
+
stderr += data.toString();
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
proc.on("close", (code) => {
|
|
429
|
+
if (stdoutBuffer.trim()) processLine(stdoutBuffer);
|
|
430
|
+
resolve(code ?? 0);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
proc.on("error", (error) => {
|
|
434
|
+
stderr += `\n${error.message}`;
|
|
435
|
+
resolve(1);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
if (options.signal) {
|
|
439
|
+
const killProc = () => {
|
|
440
|
+
wasAborted = true;
|
|
441
|
+
proc.kill("SIGTERM");
|
|
442
|
+
setTimeout(() => {
|
|
443
|
+
if (!proc.killed) proc.kill("SIGKILL");
|
|
444
|
+
}, 5000).unref?.();
|
|
445
|
+
};
|
|
446
|
+
if (options.signal.aborted) killProc();
|
|
447
|
+
else options.signal.addEventListener("abort", killProc, { once: true });
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
if (wasAborted) throw new Error(`${PLAN_AGENT_NAME} was aborted`);
|
|
452
|
+
|
|
453
|
+
const planExists = fs.existsSync(absolutePlanPath);
|
|
454
|
+
const todoExists = fs.existsSync(absoluteTodoPath);
|
|
455
|
+
return {
|
|
456
|
+
planPath,
|
|
457
|
+
absolutePlanPath,
|
|
458
|
+
planExists,
|
|
459
|
+
todoPath,
|
|
460
|
+
absoluteTodoPath,
|
|
461
|
+
todoExists,
|
|
462
|
+
exitCode,
|
|
463
|
+
finalOutput: getFinalOutput(messages),
|
|
464
|
+
stderr: truncateTail(stderr.trim(), MAX_STDERR_CHARS),
|
|
465
|
+
model,
|
|
466
|
+
stopReason,
|
|
467
|
+
errorMessage,
|
|
468
|
+
};
|
|
469
|
+
} finally {
|
|
470
|
+
if (tmpDir) await fsp.rm(tmpDir, { recursive: true, force: true });
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function isPlanAgentProcessOk(result: PlanAgentRunResult): boolean {
|
|
475
|
+
return result.exitCode === 0 && !result.errorMessage && result.stopReason !== "error" && result.stopReason !== "aborted";
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function isPlanAgentRunOk(result: PlanAgentRunResult): boolean {
|
|
479
|
+
return isPlanAgentProcessOk(result) && result.planExists && result.todoExists;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function formatPlanAgentResult(result: PlanAgentRunResult): string {
|
|
483
|
+
const processOk = isPlanAgentProcessOk(result);
|
|
484
|
+
const outputsOk = result.planExists && result.todoExists;
|
|
485
|
+
const ok = processOk && outputsOk;
|
|
486
|
+
const status = processOk
|
|
487
|
+
? outputsOk
|
|
488
|
+
? "Plan agent finished."
|
|
489
|
+
: "Plan agent finished with missing required outputs."
|
|
490
|
+
: `Plan agent failed (exit ${result.exitCode}).`;
|
|
491
|
+
const fileStatus = result.planExists ? `Plan file: ${result.planPath}` : `Plan file was not created: ${result.planPath}`;
|
|
492
|
+
const todoStatus = result.todoExists ? `Todo file: ${result.todoPath}` : `Todo file was not created: ${result.todoPath}`;
|
|
493
|
+
const parts = [status, fileStatus, todoStatus];
|
|
494
|
+
if (result.finalOutput) parts.push(result.finalOutput);
|
|
495
|
+
if (!ok && result.errorMessage) parts.push(`Error: ${result.errorMessage}`);
|
|
496
|
+
if (!ok && result.stderr) parts.push(`stderr:\n${result.stderr}`);
|
|
497
|
+
return parts.join("\n\n");
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function createPlanReview(prompt: string, result: PlanAgentRunResult, source: PlanReviewSource): PlanReviewRequest {
|
|
501
|
+
return {
|
|
502
|
+
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
503
|
+
source,
|
|
504
|
+
prompt,
|
|
505
|
+
planPath: result.planPath,
|
|
506
|
+
absolutePlanPath: result.absolutePlanPath,
|
|
507
|
+
todoPath: result.todoPath,
|
|
508
|
+
absoluteTodoPath: result.absoluteTodoPath,
|
|
509
|
+
createdAt: new Date().toISOString(),
|
|
510
|
+
result,
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function safeContextIsIdle(ctx: ExtensionContext): boolean {
|
|
515
|
+
try {
|
|
516
|
+
return ctx.isIdle();
|
|
517
|
+
} catch {
|
|
518
|
+
return true;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function sendPlanCustomMessage(
|
|
523
|
+
pi: ExtensionAPI,
|
|
524
|
+
_ctx: ExtensionContext,
|
|
525
|
+
message: { customType: string; content: string; display: boolean; details?: Record<string, unknown> },
|
|
526
|
+
options: { triggerTurn?: boolean } = {},
|
|
527
|
+
): void {
|
|
528
|
+
pi.sendMessage(message as any, { triggerTurn: options.triggerTurn ?? false } as any);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function sendPlanUserMessage(pi: ExtensionAPI, ctx: ExtensionContext, content: string): void {
|
|
532
|
+
if (safeContextIsIdle(ctx)) pi.sendUserMessage(content);
|
|
533
|
+
else pi.sendUserMessage(content, { deliverAs: "followUp" });
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
async function readPlanContent(filePath: string, maxChars: number): Promise<PlanContentRead> {
|
|
537
|
+
try {
|
|
538
|
+
const raw = await fsp.readFile(filePath, "utf-8");
|
|
539
|
+
if (raw.length <= maxChars) return { content: raw, truncated: false };
|
|
540
|
+
return {
|
|
541
|
+
content: `${raw.slice(0, maxChars)}\n\n[... truncated ${raw.length - maxChars} characters; open the plan file for the full content ...]`,
|
|
542
|
+
truncated: true,
|
|
543
|
+
};
|
|
544
|
+
} catch (error) {
|
|
545
|
+
return {
|
|
546
|
+
content: "",
|
|
547
|
+
truncated: false,
|
|
548
|
+
error: error instanceof Error ? error.message : String(error),
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function buildPlanPreviewContent(review: PlanReviewRequest, plan: PlanContentRead): string {
|
|
554
|
+
const header = `# Plan preview before exiting plan mode\n\nPlan file: \`${review.planPath}\`\nTodo file: \`${review.todoPath}\``;
|
|
555
|
+
if (plan.error) {
|
|
556
|
+
return `${header}\n\nCould not read the plan file before exit: ${plan.error}`;
|
|
557
|
+
}
|
|
558
|
+
const truncation = plan.truncated
|
|
559
|
+
? `\n\n> Preview truncated. Open \`${review.planPath}\` for the full plan.`
|
|
560
|
+
: "";
|
|
561
|
+
return `${header}${truncation}\n\n---\n\n${plan.content || "(plan file is empty)"}`;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function buildPlanHandoffContent(handoff: PlanExitHandoff): string {
|
|
565
|
+
const truncation = handoff.planContentTruncated
|
|
566
|
+
? "\n\nNote: The plan content below is truncated; use the plan file for the complete version."
|
|
567
|
+
: "";
|
|
568
|
+
return `[PLAN MODE EXIT HANDOFF]\nThe planning conversation before this handoff was intentionally removed from future LLM context. Use only this original prompt, plan, todo path, and newer messages.\n\n## Original user prompt\n${handoff.originalPrompt}\n\n## Plan file\n${handoff.planPath}\n\n## Todo file\n${handoff.todoPath}${truncation}\n\n## Plan content\n${handoff.planContent || "(plan file is empty or unavailable)"}`;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function buildPlanExecuteKickoffPrompt(handoff: PlanExitHandoff): string {
|
|
572
|
+
return `Execute the approved plan now.\n\nOriginal user prompt:\n${handoff.originalPrompt}\n\nPlan file: ${handoff.planPath}\nTodo JSONL: ${handoff.todoPath}\n\nUse the plan-mode exit handoff context as the source of truth. Keep ${handoff.todoPath} updated as you work: set each todo to in_progress before starting it, done with completedAt after finishing it, and blocked with an explanation if you cannot proceed.`;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function buildPlanRevisePrompt(review: PlanReviewRequest, feedback: string): string {
|
|
576
|
+
return `The user did not allow exiting plan mode and requested changes to the plan.\n\nOriginal planning prompt:\n${review.prompt}\n\nCurrent plan file: ${review.planPath}\nTodo JSONL: ${review.todoPath}\n\nUser modification feedback:\n${feedback}\n\nStay in plan mode. Call plan_agent again with outputPath \`${review.planPath}\`, incorporate the feedback, and rewrite ${review.todoPath} to match the revised plan. Do not implement the plan.`;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const PlanAgentParams = Type.Object({
|
|
580
|
+
prompt: Type.String({ description: "The user's planning request to delegate to plan-agent." }),
|
|
581
|
+
outputPath: Type.Optional(
|
|
582
|
+
Type.String({ description: "Optional markdown output path. Must be inside .plan/. Defaults to a timestamped file." }),
|
|
583
|
+
),
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
const DESTRUCTIVE_PATTERNS = [
|
|
587
|
+
/\brm\b/i,
|
|
588
|
+
/\brmdir\b/i,
|
|
589
|
+
/\bmv\b/i,
|
|
590
|
+
/\bcp\b/i,
|
|
591
|
+
/\bmkdir\b/i,
|
|
592
|
+
/\btouch\b/i,
|
|
593
|
+
/\bchmod\b/i,
|
|
594
|
+
/\bchown\b/i,
|
|
595
|
+
/\btee\b/i,
|
|
596
|
+
/\btruncate\b/i,
|
|
597
|
+
/\bdd\b/i,
|
|
598
|
+
/(^|[^<])>(?!>)/,
|
|
599
|
+
/>>/,
|
|
600
|
+
/\bnpm\s+(install|uninstall|update|ci|link|publish)/i,
|
|
601
|
+
/\byarn\s+(add|remove|install|publish)/i,
|
|
602
|
+
/\bpnpm\s+(add|remove|install|publish)/i,
|
|
603
|
+
/\bpip\s+(install|uninstall)/i,
|
|
604
|
+
/\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout|stash|cherry-pick|revert|tag|init|clone)/i,
|
|
605
|
+
/\bsudo\b/i,
|
|
606
|
+
/\bsu\b/i,
|
|
607
|
+
/\bkill\b/i,
|
|
608
|
+
/\bpkill\b/i,
|
|
609
|
+
/\bkillall\b/i,
|
|
610
|
+
/\b(vim?|nano|emacs|code|subl)\b/i,
|
|
611
|
+
];
|
|
612
|
+
|
|
613
|
+
const SAFE_READONLY_PATTERNS = [
|
|
614
|
+
/^\s*(cat|head|tail|less|more|grep|find|ls|pwd|echo|printf|wc|sort|uniq|diff|file|stat|du|df|tree|which|whereis|type|env|printenv|uname|whoami|id|date|ps|rg|fd|bat|eza)\b/i,
|
|
615
|
+
/^\s*git\s+(status|log|diff|show|branch|remote|config\s+--get|ls-files|grep)\b/i,
|
|
616
|
+
/^\s*npm\s+(list|ls|view|info|search|outdated|audit)\b/i,
|
|
617
|
+
/^\s*yarn\s+(list|info|why|audit)\b/i,
|
|
618
|
+
/^\s*pnpm\s+(list|view|info|why|audit)\b/i,
|
|
619
|
+
/^\s*node\s+--version\b/i,
|
|
620
|
+
/^\s*python3?\s+--version\b/i,
|
|
621
|
+
/^\s*jq\b/i,
|
|
622
|
+
/^\s*sed\s+-n\b/i,
|
|
623
|
+
/^\s*awk\b/i,
|
|
624
|
+
];
|
|
625
|
+
|
|
626
|
+
function shellTokens(command: string): string[] {
|
|
627
|
+
const tokens: string[] = [];
|
|
628
|
+
const regex = /"((?:\\.|[^"])*)"|'([^']*)'|(\S+)/g;
|
|
629
|
+
let match: RegExpExecArray | null;
|
|
630
|
+
while ((match = regex.exec(command))) tokens.push(match[1] ?? match[2] ?? match[3]);
|
|
631
|
+
return tokens;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function looksLikePath(token: string): boolean {
|
|
635
|
+
if (!token || token.startsWith("-")) return false;
|
|
636
|
+
if (/^[a-z]+:\/\//i.test(token)) return false;
|
|
637
|
+
return token.startsWith("/") || token.startsWith("./") || token.startsWith("../") || token.includes("/");
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function commandReferencesOutsideRepo(command: string, cwd: string): boolean {
|
|
641
|
+
const root = path.resolve(cwd);
|
|
642
|
+
for (const token of shellTokens(command)) {
|
|
643
|
+
if (!looksLikePath(token)) continue;
|
|
644
|
+
const cleaned = stripAtPrefix(token.replace(/[,:;]+$/g, ""));
|
|
645
|
+
const absolute = path.isAbsolute(cleaned) ? path.resolve(cleaned) : path.resolve(cwd, cleaned);
|
|
646
|
+
if (!isInside(absolute, root)) return true;
|
|
647
|
+
}
|
|
648
|
+
return false;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function isReadOnlyBash(command: string, cwd: string): { ok: boolean; reason?: string } {
|
|
652
|
+
if (!command.trim()) return { ok: false, reason: "empty command" };
|
|
653
|
+
if (DESTRUCTIVE_PATTERNS.some((pattern) => pattern.test(command))) {
|
|
654
|
+
return { ok: false, reason: "destructive or file-mutating command" };
|
|
655
|
+
}
|
|
656
|
+
if (/[`$]\(/.test(command)) return { ok: false, reason: "command substitution is not allowed in plan mode" };
|
|
657
|
+
if (commandReferencesOutsideRepo(command, cwd)) return { ok: false, reason: "command references a path outside the repo" };
|
|
658
|
+
|
|
659
|
+
const segments = command
|
|
660
|
+
.split(/\s*(?:&&|\|\||;|\n|\|)\s*/g)
|
|
661
|
+
.map((segment) => segment.trim())
|
|
662
|
+
.filter(Boolean);
|
|
663
|
+
if (segments.length === 0) return { ok: false, reason: "empty command" };
|
|
664
|
+
for (const segment of segments) {
|
|
665
|
+
if (!SAFE_READONLY_PATTERNS.some((pattern) => pattern.test(segment))) {
|
|
666
|
+
return { ok: false, reason: `not an allowlisted read-only command: ${segment}` };
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
return { ok: true };
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
let closeTodoSidebar: (() => void) | null = null;
|
|
673
|
+
let activeTodoSidebarPanel: TodoSidebarPanel | undefined;
|
|
674
|
+
let latestTodoSnapshot: ParsedTodoFile | undefined;
|
|
675
|
+
let todoWorkflowActive = false;
|
|
676
|
+
let todoSidebarWidth = 34;
|
|
677
|
+
let todoSidebarMaxHeight: OverlayOptions["maxHeight"] = 40;
|
|
678
|
+
|
|
679
|
+
function getKenxSidebarBridge(): KenxSidebarBridge | undefined {
|
|
680
|
+
return (globalThis as Record<symbol, KenxSidebarBridge | undefined>)[KENX_SIDEBAR_BRIDGE_KEY];
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function closeFileTreeSidebarIfOpen(): boolean {
|
|
684
|
+
try {
|
|
685
|
+
return getKenxSidebarBridge()?.closeFileTree() ?? false;
|
|
686
|
+
} catch {
|
|
687
|
+
return false;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function normalizeTodoStatus(status: unknown): string {
|
|
692
|
+
return String(status || "pending")
|
|
693
|
+
.trim()
|
|
694
|
+
.toLowerCase()
|
|
695
|
+
.replace(/[\s_]+/g, "_")
|
|
696
|
+
.replace(/-/g, "_");
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function statusMatches(status: string, values: Set<string>): boolean {
|
|
700
|
+
return values.has(status) || values.has(status.replace(/_/g, "-"));
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function isTodoDone(todo: PlanTodoItem): boolean {
|
|
704
|
+
return statusMatches(normalizeTodoStatus(todo.status), TODO_STATUS_DONE);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function isTodoSkipped(todo: PlanTodoItem): boolean {
|
|
708
|
+
return statusMatches(normalizeTodoStatus(todo.status), TODO_STATUS_SKIPPED);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function isTodoActive(todo: PlanTodoItem): boolean {
|
|
712
|
+
return statusMatches(normalizeTodoStatus(todo.status), TODO_STATUS_ACTIVE);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function isTodoBlocked(todo: PlanTodoItem): boolean {
|
|
716
|
+
return statusMatches(normalizeTodoStatus(todo.status), TODO_STATUS_BLOCKED);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function isTodoOpen(todo: PlanTodoItem): boolean {
|
|
720
|
+
return !isTodoDone(todo) && !isTodoSkipped(todo);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
function cleanTodoText(value: unknown): string | undefined {
|
|
724
|
+
if (typeof value !== "string") return undefined;
|
|
725
|
+
const cleaned = value.replace(/[\r\n\t]+/g, " ").replace(/\s+/g, " ").trim();
|
|
726
|
+
return cleaned || undefined;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function coerceStringArray(value: unknown): string[] | undefined {
|
|
730
|
+
if (!Array.isArray(value)) return undefined;
|
|
731
|
+
const items = value.map((item) => cleanTodoText(item)).filter((item): item is string => Boolean(item));
|
|
732
|
+
return items.length > 0 ? items : undefined;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function coerceDependencyArray(value: unknown): Array<string | number> | undefined {
|
|
736
|
+
if (!Array.isArray(value)) return undefined;
|
|
737
|
+
const items = value.filter((item): item is string | number => typeof item === "string" || typeof item === "number");
|
|
738
|
+
return items.length > 0 ? items : undefined;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
742
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function relativeDisplayPath(cwd: string, absolutePath: string): string {
|
|
746
|
+
const rel = path.relative(cwd, absolutePath).split(path.sep).join("/");
|
|
747
|
+
return rel || path.basename(absolutePath);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function parseTimestamp(value: unknown): number | undefined {
|
|
751
|
+
if (typeof value !== "string" || !value.trim()) return undefined;
|
|
752
|
+
const timestamp = Date.parse(value);
|
|
753
|
+
return Number.isFinite(timestamp) ? timestamp : undefined;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function formatClock(value: number | undefined = Date.now()): string {
|
|
757
|
+
const date = new Date(value);
|
|
758
|
+
if (!Number.isFinite(date.getTime())) return "--:--";
|
|
759
|
+
return `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function formatElapsed(ms: number): string {
|
|
763
|
+
if (!Number.isFinite(ms) || ms < 0) return "+0s";
|
|
764
|
+
const totalSeconds = Math.floor(ms / 1000);
|
|
765
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
766
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
767
|
+
const seconds = totalSeconds % 60;
|
|
768
|
+
if (hours > 0) return `+${hours}h ${String(minutes).padStart(2, "0")}m`;
|
|
769
|
+
if (minutes > 0) return `+${minutes}m ${String(seconds).padStart(2, "0")}s`;
|
|
770
|
+
return `+${seconds}s`;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function truncatePlain(value: string, width: number): string {
|
|
774
|
+
return truncateToWidth(value, width, "…", true);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function padAnsi(value: string, width: number): string {
|
|
778
|
+
const clipped = truncateToWidth(value, width, "…", true);
|
|
779
|
+
return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function clamp(value: number, min: number, max: number): number {
|
|
783
|
+
return Math.max(min, Math.min(max, value));
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
function readJsonObjectLine(line: string, lineNumber: number): { object?: Record<string, unknown>; error?: string } {
|
|
787
|
+
try {
|
|
788
|
+
const parsed = JSON.parse(line) as unknown;
|
|
789
|
+
if (!isRecord(parsed)) return { error: `line ${lineNumber}: not an object` };
|
|
790
|
+
return { object: parsed };
|
|
791
|
+
} catch (error) {
|
|
792
|
+
return { error: `line ${lineNumber}: ${error instanceof Error ? error.message : String(error)}` };
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
function coerceTodoObject(object: Record<string, unknown>, lineNumber: number, fallbackStep: number): PlanTodoItem | undefined {
|
|
797
|
+
const explicitType = cleanTodoText(object.type);
|
|
798
|
+
const title = cleanTodoText(object.title) ?? cleanTodoText(object.text) ?? cleanTodoText(object.description);
|
|
799
|
+
if (explicitType && explicitType !== "plan_todo" && !title) return undefined;
|
|
800
|
+
if (!title) return undefined;
|
|
801
|
+
|
|
802
|
+
const numericStep = Number(object.step);
|
|
803
|
+
const step = Number.isFinite(numericStep) && numericStep > 0 ? Math.floor(numericStep) : fallbackStep;
|
|
804
|
+
const status = cleanTodoText(object.status) ?? "pending";
|
|
805
|
+
|
|
806
|
+
return {
|
|
807
|
+
type: explicitType,
|
|
808
|
+
schemaVersion: Number.isFinite(Number(object.schemaVersion)) ? Number(object.schemaVersion) : undefined,
|
|
809
|
+
planPath: cleanTodoText(object.planPath),
|
|
810
|
+
step,
|
|
811
|
+
title,
|
|
812
|
+
description: cleanTodoText(object.description),
|
|
813
|
+
status,
|
|
814
|
+
priority: cleanTodoText(object.priority),
|
|
815
|
+
dependencies: coerceDependencyArray(object.dependencies),
|
|
816
|
+
validation: coerceStringArray(object.validation),
|
|
817
|
+
createdAt: cleanTodoText(object.createdAt),
|
|
818
|
+
startedAt: cleanTodoText(object.startedAt),
|
|
819
|
+
updatedAt: cleanTodoText(object.updatedAt),
|
|
820
|
+
completedAt: cleanTodoText(object.completedAt),
|
|
821
|
+
lineNumber,
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
async function readTodoJsonlFile(absolutePath: string, cwd: string): Promise<ParsedTodoFile> {
|
|
826
|
+
const stat = await fsp.stat(absolutePath);
|
|
827
|
+
const raw = await fsp.readFile(absolutePath, "utf8");
|
|
828
|
+
const todos: PlanTodoItem[] = [];
|
|
829
|
+
const errors: string[] = [];
|
|
830
|
+
let fallbackStep = 1;
|
|
831
|
+
|
|
832
|
+
const lines = raw.split(/\r?\n/g);
|
|
833
|
+
for (let index = 0; index < lines.length; index++) {
|
|
834
|
+
const line = lines[index]?.trim();
|
|
835
|
+
if (!line) continue;
|
|
836
|
+
const { object, error } = readJsonObjectLine(line, index + 1);
|
|
837
|
+
if (error) {
|
|
838
|
+
errors.push(error);
|
|
839
|
+
continue;
|
|
840
|
+
}
|
|
841
|
+
if (!object) continue;
|
|
842
|
+
const todo = coerceTodoObject(object, index + 1, fallbackStep);
|
|
843
|
+
if (!todo) continue;
|
|
844
|
+
todos.push(todo);
|
|
845
|
+
fallbackStep = Math.max(fallbackStep + 1, todo.step + 1);
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
todos.sort((a, b) => a.step - b.step || a.lineNumber - b.lineNumber);
|
|
849
|
+
return {
|
|
850
|
+
path: absolutePath,
|
|
851
|
+
relativePath: relativeDisplayPath(cwd, absolutePath),
|
|
852
|
+
todos,
|
|
853
|
+
errors,
|
|
854
|
+
mtimeMs: stat.mtimeMs,
|
|
855
|
+
loadedAt: Date.now(),
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
async function collectJsonlFiles(dir: string, out: string[], depth = 0): Promise<void> {
|
|
860
|
+
if (depth > 4) return;
|
|
861
|
+
let dirents: fs.Dirent[];
|
|
862
|
+
try {
|
|
863
|
+
dirents = await fsp.readdir(dir, { withFileTypes: true });
|
|
864
|
+
} catch {
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
for (const dirent of dirents) {
|
|
869
|
+
const child = path.join(dir, dirent.name);
|
|
870
|
+
if (dirent.isDirectory()) {
|
|
871
|
+
await collectJsonlFiles(child, out, depth + 1);
|
|
872
|
+
} else if (dirent.isFile() && dirent.name.toLowerCase().endsWith(".jsonl")) {
|
|
873
|
+
out.push(child);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
async function fileLooksLikePlanTodo(filePath: string): Promise<boolean> {
|
|
879
|
+
try {
|
|
880
|
+
const raw = await fsp.readFile(filePath, "utf8");
|
|
881
|
+
const sample = raw.slice(0, 24_000);
|
|
882
|
+
return /"type"\s*:\s*"plan_todo"/.test(sample) || (/"step"\s*:/.test(sample) && /"status"\s*:/.test(sample));
|
|
883
|
+
} catch {
|
|
884
|
+
return false;
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
async function findTodoJsonlFile(cwd: string): Promise<string | undefined> {
|
|
889
|
+
const planRoot = path.resolve(cwd, PLAN_DIR);
|
|
890
|
+
const directTodo = path.resolve(cwd, TODO_PATH);
|
|
891
|
+
if (fs.existsSync(directTodo)) return directTodo;
|
|
892
|
+
if (!fs.existsSync(planRoot)) return undefined;
|
|
893
|
+
|
|
894
|
+
const files: string[] = [];
|
|
895
|
+
await collectJsonlFiles(planRoot, files);
|
|
896
|
+
if (files.length === 0) return undefined;
|
|
897
|
+
|
|
898
|
+
const candidates: Array<{ file: string; score: number; mtimeMs: number }> = [];
|
|
899
|
+
for (const file of files) {
|
|
900
|
+
let stat: fs.Stats;
|
|
901
|
+
try {
|
|
902
|
+
stat = await fsp.stat(file);
|
|
903
|
+
} catch {
|
|
904
|
+
continue;
|
|
905
|
+
}
|
|
906
|
+
const base = path.basename(file).toLowerCase();
|
|
907
|
+
const looksLikeTodo = await fileLooksLikePlanTodo(file);
|
|
908
|
+
let score = looksLikeTodo ? 1_000 : 0;
|
|
909
|
+
if (base === "todo.jsonl") score += 500;
|
|
910
|
+
else if (base.includes("todo")) score += 100;
|
|
911
|
+
candidates.push({ file, score, mtimeMs: stat.mtimeMs });
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
candidates.sort((a, b) => b.score - a.score || b.mtimeMs - a.mtimeMs);
|
|
915
|
+
return candidates[0]?.file;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
function preferredTodoSidebarLayout(snapshot: ParsedTodoFile | undefined): { width: number; maxHeight: number } {
|
|
919
|
+
const todos = snapshot?.todos ?? [];
|
|
920
|
+
const longestTodo = todos.reduce((max, todo) => Math.max(max, visibleWidth(`${todo.step}. ${todo.title}`)), 0);
|
|
921
|
+
const longestPath = visibleWidth(snapshot?.relativePath ?? TODO_PATH);
|
|
922
|
+
const width = clamp(Math.max(34, longestTodo + 13, longestPath + 10), 34, 58);
|
|
923
|
+
// Include the fixed chrome/footer rows in the requested overlay height.
|
|
924
|
+
// If this is too small, pi's overlay compositor clips from the bottom and the closing border disappears.
|
|
925
|
+
const maxHeight = clamp(Math.min(40, Math.max(18, todos.length + 16)), 18, 40);
|
|
926
|
+
return { width, maxHeight };
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
function latestTodoTime(todos: PlanTodoItem[]): number | undefined {
|
|
930
|
+
const timestamps = todos
|
|
931
|
+
.flatMap((todo) => [todo.completedAt, todo.updatedAt, todo.startedAt, todo.createdAt])
|
|
932
|
+
.map(parseTimestamp)
|
|
933
|
+
.filter((value): value is number => value !== undefined);
|
|
934
|
+
return timestamps.length > 0 ? Math.max(...timestamps) : undefined;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
function firstTodoTime(todos: PlanTodoItem[]): number | undefined {
|
|
938
|
+
const timestamps = todos
|
|
939
|
+
.flatMap((todo) => [todo.createdAt, todo.startedAt, todo.updatedAt, todo.completedAt])
|
|
940
|
+
.map(parseTimestamp)
|
|
941
|
+
.filter((value): value is number => value !== undefined);
|
|
942
|
+
return timestamps.length > 0 ? Math.min(...timestamps) : undefined;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
function currentTodoIndex(todos: PlanTodoItem[]): number {
|
|
946
|
+
const active = todos.findIndex((todo) => isTodoActive(todo) || isTodoBlocked(todo));
|
|
947
|
+
if (active >= 0) return active;
|
|
948
|
+
const open = todos.findIndex(isTodoOpen);
|
|
949
|
+
return open >= 0 ? open : todos.length;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
function todoSnapshotContext(snapshot: ParsedTodoFile | undefined): string | undefined {
|
|
953
|
+
if (!snapshot || snapshot.todos.length === 0) return undefined;
|
|
954
|
+
const remaining = snapshot.todos.filter(isTodoOpen);
|
|
955
|
+
if (remaining.length === 0) return undefined;
|
|
956
|
+
const lines = remaining
|
|
957
|
+
.slice(0, 12)
|
|
958
|
+
.map((todo) => `${todo.step}. [${todo.status}] ${todo.title}${todo.description ? ` — ${todo.description}` : ""}`);
|
|
959
|
+
const omitted = remaining.length > lines.length ? `\n... ${remaining.length - lines.length} more remaining todo(s)` : "";
|
|
960
|
+
return `[TODO WORKFLOW ACTIVE]
|
|
961
|
+
The todo workflow is monitoring ${snapshot.relativePath}. Keep this JSONL file up to date as you work.
|
|
962
|
+
Use status values: pending, in_progress, done, blocked (or skipped/cancelled when appropriate).
|
|
963
|
+
Before starting a step, rewrite its JSONL object with status "in_progress" and updatedAt. After finishing it, rewrite status "done" with completedAt. If blocked, use status "blocked" and explain why in description.
|
|
964
|
+
|
|
965
|
+
Remaining todos:
|
|
966
|
+
${lines.join("\n")}${omitted}`;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
function buildTodoCommandPrompt(userPrompt: string): string {
|
|
970
|
+
const createdAt = new Date().toISOString();
|
|
971
|
+
const planPath = path.join(PLAN_DIR, `${timestampForFile()}-todo-${slugify(userPrompt)}.md`).split(path.sep).join("/");
|
|
972
|
+
return `# Todo-driven execution request
|
|
973
|
+
|
|
974
|
+
Goal: ${userPrompt}
|
|
975
|
+
|
|
976
|
+
Create a todo plan first, then execute it.
|
|
977
|
+
|
|
978
|
+
Required files:
|
|
979
|
+
- Markdown plan: \`${planPath}\`
|
|
980
|
+
- Todo JSONL: \`${TODO_PATH}\`
|
|
981
|
+
|
|
982
|
+
Todo JSONL requirements:
|
|
983
|
+
- Rewrite \`${TODO_PATH}\` with todos for this request only.
|
|
984
|
+
- Use one compact valid JSON object per line, no markdown fences.
|
|
985
|
+
- Use \`type: "plan_todo"\` and \`schemaVersion: 1\`.
|
|
986
|
+
- Keep \`step\` values ordered from 1.
|
|
987
|
+
- Use this timestamp for initial \`createdAt\`: \`${createdAt}\`.
|
|
988
|
+
- Required fields per line:
|
|
989
|
+
|
|
990
|
+
\`\`\`jsonl
|
|
991
|
+
{"type":"plan_todo","schemaVersion":1,"planPath":"${planPath}","step":1,"title":"Short actionable title","description":"Concrete implementation task","status":"pending","priority":"medium","dependencies":[],"validation":["Check or test for this step"],"createdAt":"${createdAt}"}
|
|
992
|
+
\`\`\`
|
|
993
|
+
|
|
994
|
+
Execution workflow:
|
|
995
|
+
1. Inspect the repo enough to decompose the goal into concrete steps.
|
|
996
|
+
2. Write the markdown plan and initial \`${TODO_PATH}\` before changing implementation files.
|
|
997
|
+
3. Execute steps in order. Before working on a step, update its JSONL status to \`in_progress\` and set \`updatedAt\`.
|
|
998
|
+
4. When a step is complete, update its status to \`done\` and set \`completedAt\`.
|
|
999
|
+
5. If a step cannot proceed, set status \`blocked\` and include the blocker in \`description\`.
|
|
1000
|
+
6. Keep the todo file compact and valid after every update so the sidebar can monitor progress.
|
|
1001
|
+
|
|
1002
|
+
Start now.`;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
class TodoSidebarPanel implements Component {
|
|
1006
|
+
private readonly tui: TUI;
|
|
1007
|
+
private readonly theme: Theme;
|
|
1008
|
+
private readonly cwd: string;
|
|
1009
|
+
private readonly done: (result: TodoSidebarResult) => void;
|
|
1010
|
+
private readonly onLayoutChange: (layout: { width: number; maxHeight: number }) => void;
|
|
1011
|
+
private readonly onSnapshotChange: (snapshot: ParsedTodoFile | undefined) => void;
|
|
1012
|
+
|
|
1013
|
+
private sourcePath: string | undefined;
|
|
1014
|
+
private snapshot: ParsedTodoFile | undefined;
|
|
1015
|
+
private loading = true;
|
|
1016
|
+
private error: string | undefined;
|
|
1017
|
+
private closed = false;
|
|
1018
|
+
private disposed = false;
|
|
1019
|
+
private refreshing = false;
|
|
1020
|
+
private refreshAgain = false;
|
|
1021
|
+
private pollTimer: ReturnType<typeof setInterval> | undefined;
|
|
1022
|
+
private dirWatcher: fs.FSWatcher | undefined;
|
|
1023
|
+
private fileWatcher: fs.FSWatcher | undefined;
|
|
1024
|
+
|
|
1025
|
+
constructor(options: {
|
|
1026
|
+
tui: TUI;
|
|
1027
|
+
theme: Theme;
|
|
1028
|
+
cwd: string;
|
|
1029
|
+
initialPath?: string;
|
|
1030
|
+
done: (result: TodoSidebarResult) => void;
|
|
1031
|
+
onLayoutChange: (layout: { width: number; maxHeight: number }) => void;
|
|
1032
|
+
onSnapshotChange: (snapshot: ParsedTodoFile | undefined) => void;
|
|
1033
|
+
}) {
|
|
1034
|
+
this.tui = options.tui;
|
|
1035
|
+
this.theme = options.theme;
|
|
1036
|
+
this.cwd = options.cwd;
|
|
1037
|
+
this.done = options.done;
|
|
1038
|
+
this.onLayoutChange = options.onLayoutChange;
|
|
1039
|
+
this.onSnapshotChange = options.onSnapshotChange;
|
|
1040
|
+
if (options.initialPath) this.setWatchedFile(options.initialPath);
|
|
1041
|
+
void this.start();
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
setSourcePath(filePath: string): void {
|
|
1045
|
+
this.setWatchedFile(filePath);
|
|
1046
|
+
void this.refresh(true);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
handleInput(data: string): void {
|
|
1050
|
+
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c") || matchesKey(data, "ctrl+shift+t")) {
|
|
1051
|
+
this.close();
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
render(width: number): string[] {
|
|
1056
|
+
const w = Math.max(30, width);
|
|
1057
|
+
const th = this.theme;
|
|
1058
|
+
const lines: string[] = [this.topBar(w)];
|
|
1059
|
+
lines.push(this.blank(w));
|
|
1060
|
+
|
|
1061
|
+
if (this.loading && !this.snapshot) {
|
|
1062
|
+
lines.push(this.row(w, ` ${th.fg("warning", "loading todos…")}`));
|
|
1063
|
+
lines.push(this.row(w, ` ${th.fg("dim", "watching .plan/*.jsonl")}`));
|
|
1064
|
+
} else if (!this.snapshot) {
|
|
1065
|
+
lines.push(this.row(w, ` ${th.fg("dim", "no todo JSONL found")}`));
|
|
1066
|
+
lines.push(this.row(w, ` ${th.fg("dim", "run /todo <prompt> to start")}`));
|
|
1067
|
+
if (this.error) lines.push(this.row(w, ` ${th.fg("warning", this.error)}`));
|
|
1068
|
+
} else if (this.snapshot.todos.length === 0) {
|
|
1069
|
+
lines.push(this.row(w, ` ${th.fg("warning", "no plan_todo entries")}`));
|
|
1070
|
+
lines.push(this.row(w, ` ${th.fg("dim", this.snapshot.relativePath)}`));
|
|
1071
|
+
if (this.error) lines.push(this.row(w, ` ${th.fg("warning", this.error)}`));
|
|
1072
|
+
} else {
|
|
1073
|
+
lines.push(...this.renderTimeline(w, this.snapshot));
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
const footer = [this.blank(w), this.botBar(w)];
|
|
1077
|
+
const maxRows = this.maxRenderRows();
|
|
1078
|
+
if (lines.length + footer.length > maxRows) {
|
|
1079
|
+
const cutAt = Math.max(1, maxRows - footer.length - 1);
|
|
1080
|
+
lines.splice(cutAt);
|
|
1081
|
+
lines.push(this.row(w, ` ${this.theme.fg("dim", "… clipped to fit terminal")}`));
|
|
1082
|
+
}
|
|
1083
|
+
lines.push(...footer);
|
|
1084
|
+
return lines;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
invalidate(): void {}
|
|
1088
|
+
|
|
1089
|
+
dispose(): void {
|
|
1090
|
+
this.disposed = true;
|
|
1091
|
+
if (this.pollTimer) clearInterval(this.pollTimer);
|
|
1092
|
+
this.pollTimer = undefined;
|
|
1093
|
+
this.dirWatcher?.close();
|
|
1094
|
+
this.dirWatcher = undefined;
|
|
1095
|
+
this.fileWatcher?.close();
|
|
1096
|
+
this.fileWatcher = undefined;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
private async start(): Promise<void> {
|
|
1100
|
+
this.watchPlanDirectory();
|
|
1101
|
+
this.pollTimer = setInterval(() => void this.refresh(false), 1_200);
|
|
1102
|
+
await this.refresh(true);
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
private watchPlanDirectory(): void {
|
|
1106
|
+
const planRoot = path.resolve(this.cwd, PLAN_DIR);
|
|
1107
|
+
if (!fs.existsSync(planRoot)) return;
|
|
1108
|
+
try {
|
|
1109
|
+
this.dirWatcher = fs.watch(planRoot, { persistent: false }, () => void this.refresh(true));
|
|
1110
|
+
} catch {
|
|
1111
|
+
this.dirWatcher = undefined;
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
private setWatchedFile(filePath: string | undefined): void {
|
|
1116
|
+
const absolute = filePath ? path.resolve(this.cwd, filePath) : undefined;
|
|
1117
|
+
if (absolute === this.sourcePath) return;
|
|
1118
|
+
this.fileWatcher?.close();
|
|
1119
|
+
this.fileWatcher = undefined;
|
|
1120
|
+
this.sourcePath = absolute;
|
|
1121
|
+
if (!absolute || !fs.existsSync(absolute)) return;
|
|
1122
|
+
try {
|
|
1123
|
+
this.fileWatcher = fs.watch(absolute, { persistent: false }, () => void this.refresh(true));
|
|
1124
|
+
} catch {
|
|
1125
|
+
this.fileWatcher = undefined;
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
private async refresh(force: boolean): Promise<void> {
|
|
1130
|
+
if (this.disposed) return;
|
|
1131
|
+
if (this.refreshing) {
|
|
1132
|
+
this.refreshAgain = this.refreshAgain || force;
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1135
|
+
this.refreshing = true;
|
|
1136
|
+
|
|
1137
|
+
try {
|
|
1138
|
+
if (!this.sourcePath || !fs.existsSync(this.sourcePath)) {
|
|
1139
|
+
const discovered = await findTodoJsonlFile(this.cwd);
|
|
1140
|
+
if (discovered) this.setWatchedFile(discovered);
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
if (!this.sourcePath) {
|
|
1144
|
+
this.loading = false;
|
|
1145
|
+
this.snapshot = undefined;
|
|
1146
|
+
this.error = undefined;
|
|
1147
|
+
this.publishSnapshot();
|
|
1148
|
+
this.tui.requestRender();
|
|
1149
|
+
return;
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
let stat: fs.Stats;
|
|
1153
|
+
try {
|
|
1154
|
+
stat = await fsp.stat(this.sourcePath);
|
|
1155
|
+
} catch {
|
|
1156
|
+
this.loading = false;
|
|
1157
|
+
this.error = `waiting for ${relativeDisplayPath(this.cwd, this.sourcePath)}`;
|
|
1158
|
+
this.snapshot = undefined;
|
|
1159
|
+
this.publishSnapshot();
|
|
1160
|
+
this.tui.requestRender();
|
|
1161
|
+
return;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
if (!force && this.snapshot?.path === this.sourcePath && this.snapshot.mtimeMs === stat.mtimeMs) return;
|
|
1165
|
+
|
|
1166
|
+
const snapshot = await readTodoJsonlFile(this.sourcePath, this.cwd);
|
|
1167
|
+
this.loading = false;
|
|
1168
|
+
this.snapshot = snapshot;
|
|
1169
|
+
this.error = snapshot.errors.length > 0 ? snapshot.errors.slice(0, 2).join(" · ") : undefined;
|
|
1170
|
+
this.publishSnapshot();
|
|
1171
|
+
this.tui.requestRender();
|
|
1172
|
+
} catch (error) {
|
|
1173
|
+
this.loading = false;
|
|
1174
|
+
this.error = error instanceof Error ? error.message : String(error);
|
|
1175
|
+
this.tui.requestRender();
|
|
1176
|
+
} finally {
|
|
1177
|
+
this.refreshing = false;
|
|
1178
|
+
if (this.refreshAgain) {
|
|
1179
|
+
const again = this.refreshAgain;
|
|
1180
|
+
this.refreshAgain = false;
|
|
1181
|
+
void this.refresh(again);
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
private publishSnapshot(): void {
|
|
1187
|
+
this.onSnapshotChange(this.snapshot);
|
|
1188
|
+
this.onLayoutChange(preferredTodoSidebarLayout(this.snapshot));
|
|
1189
|
+
if (todoWorkflowActive && this.snapshot && this.snapshot.todos.length > 0 && this.snapshot.todos.every((todo) => !isTodoOpen(todo))) {
|
|
1190
|
+
todoWorkflowActive = false;
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
private renderTimeline(width: number, snapshot: ParsedTodoFile): string[] {
|
|
1195
|
+
const th = this.theme;
|
|
1196
|
+
const todos = snapshot.todos;
|
|
1197
|
+
const current = currentTodoIndex(todos);
|
|
1198
|
+
const allDone = current >= todos.length;
|
|
1199
|
+
const timelineBudget = clamp(Math.floor((this.tui.terminal.rows || 30) * 0.7) - 10, 6, 16);
|
|
1200
|
+
const beforeBudget = allDone ? Math.min(timelineBudget, 9) : Math.max(1, Math.min(7, Math.floor((timelineBudget - 2) / 2)));
|
|
1201
|
+
const beforeStart = Math.max(0, current - beforeBudget);
|
|
1202
|
+
const before = todos.slice(beforeStart, current);
|
|
1203
|
+
const afterBudget = Math.max(0, timelineBudget - before.length - (allDone ? 0 : 1));
|
|
1204
|
+
const after = allDone ? [] : todos.slice(current + 1, current + 1 + afterBudget);
|
|
1205
|
+
const lines: string[] = [];
|
|
1206
|
+
|
|
1207
|
+
if (beforeStart > 0) {
|
|
1208
|
+
lines.push(this.timelineOverflowRow(width, beforeStart, "earlier"));
|
|
1209
|
+
}
|
|
1210
|
+
for (const todo of before) lines.push(this.timelineTodoRow(width, todo, false));
|
|
1211
|
+
if (before.length > 0 || beforeStart > 0) lines.push(this.blank(width));
|
|
1212
|
+
|
|
1213
|
+
lines.push(this.nowRule(width, allDone));
|
|
1214
|
+
lines.push(this.blank(width));
|
|
1215
|
+
|
|
1216
|
+
if (!allDone) {
|
|
1217
|
+
lines.push(this.timelineTodoRow(width, todos[current]!, true));
|
|
1218
|
+
for (const todo of after) lines.push(this.timelineTodoRow(width, todo, false));
|
|
1219
|
+
const remainingAfter = todos.length - (current + 1 + after.length);
|
|
1220
|
+
if (remainingAfter > 0) lines.push(this.timelineOverflowRow(width, remainingAfter, "queued"));
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
lines.push(this.blank(width));
|
|
1224
|
+
lines.push(this.ruleRow(width));
|
|
1225
|
+
lines.push(this.blank(width));
|
|
1226
|
+
lines.push(...this.logRows(width, snapshot));
|
|
1227
|
+
if (this.error) lines.push(this.row(width, ` ${th.fg("warning", truncatePlain(this.error, width - 6))}`));
|
|
1228
|
+
return lines;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
private logRows(width: number, snapshot: ParsedTodoFile): string[] {
|
|
1232
|
+
const th = this.theme;
|
|
1233
|
+
const todos = snapshot.todos;
|
|
1234
|
+
const done = todos.filter((todo) => isTodoDone(todo) || isTodoSkipped(todo)).length;
|
|
1235
|
+
const blocked = todos.filter(isTodoBlocked).length;
|
|
1236
|
+
const active = todos.filter(isTodoActive).length;
|
|
1237
|
+
const total = todos.length;
|
|
1238
|
+
const started = firstTodoTime(todos);
|
|
1239
|
+
const updated = latestTodoTime(todos) ?? snapshot.mtimeMs;
|
|
1240
|
+
const planPath = cleanTodoText(todos.find((todo) => todo.planPath)?.planPath) ?? snapshot.relativePath;
|
|
1241
|
+
return [
|
|
1242
|
+
this.row(width, ` ${th.fg("muted", th.bold("LOG"))} ${th.fg("dim", "· todo monitor")}`),
|
|
1243
|
+
this.row(width, ` ${th.fg("dim", "started")} ${formatClock(started)} ${th.fg("dim", "· ")}${formatElapsed(Date.now() - (started ?? Date.now()))}`),
|
|
1244
|
+
this.row(width, ` ${th.fg("dim", "todos")} ${th.fg("success", String(done))}/${total} ${th.fg("dim", "· ")}${th.fg(blocked > 0 ? "error" : "warning", `${active} active${blocked ? ` · ${blocked} blocked` : ""}`)}`),
|
|
1245
|
+
this.row(width, ` ${th.fg("dim", "updated")} ${formatClock(updated)} ${th.fg("dim", "· ")}${th.fg("accent", truncatePlain(path.basename(snapshot.relativePath), Math.max(8, width - 23)))}`),
|
|
1246
|
+
this.row(width, ` ${th.fg("dim", "plan")} ${th.fg("accent", truncatePlain(planPath, Math.max(8, width - 14)))}`),
|
|
1247
|
+
];
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
private timelineTodoRow(width: number, todo: PlanTodoItem, isCurrent: boolean): string {
|
|
1251
|
+
const th = this.theme;
|
|
1252
|
+
const time = isCurrent || (!isTodoDone(todo) && !isTodoSkipped(todo)) ? " ···" : this.todoTimeLabel(todo);
|
|
1253
|
+
const timeCol = truncateToWidth(time, 5, "").padEnd(5, " ");
|
|
1254
|
+
const glyph = this.todoGlyph(todo, isCurrent);
|
|
1255
|
+
const label = this.todoLabel(todo, isCurrent, width);
|
|
1256
|
+
return this.row(width, ` ${th.fg("dim", timeCol)} ${th.fg("dim", "│")} ${glyph} ${label}`);
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
private timelineOverflowRow(width: number, count: number, label: string): string {
|
|
1260
|
+
return this.row(width, ` ${this.theme.fg("dim", " … │")} ${this.theme.fg("dim", `${count} ${label}`)}`);
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
private todoTimeLabel(todo: PlanTodoItem): string {
|
|
1264
|
+
return formatClock(parseTimestamp(todo.completedAt) ?? parseTimestamp(todo.updatedAt) ?? parseTimestamp(todo.startedAt) ?? parseTimestamp(todo.createdAt));
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
private todoGlyph(todo: PlanTodoItem, isCurrent: boolean): string {
|
|
1268
|
+
const th = this.theme;
|
|
1269
|
+
if (isTodoBlocked(todo)) return th.fg("error", "●");
|
|
1270
|
+
if (isTodoDone(todo)) return th.fg("success", "✓");
|
|
1271
|
+
if (isTodoSkipped(todo)) return th.fg("muted", "◇");
|
|
1272
|
+
if (isCurrent || isTodoActive(todo)) return th.fg("warning", "◐");
|
|
1273
|
+
return th.fg("dim", "○");
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
private todoLabel(todo: PlanTodoItem, isCurrent: boolean, width: number): string {
|
|
1277
|
+
const th = this.theme;
|
|
1278
|
+
const highPriority = todo.priority && /^(high|urgent|p0|p1)$/i.test(todo.priority);
|
|
1279
|
+
// Size the label from the actual render width instead of the requested overlay width.
|
|
1280
|
+
// If pi clamps the overlay, row-level truncation can otherwise wrap/leave stale cells and make the timeline look crooked.
|
|
1281
|
+
const maxLabelWidth = Math.max(6, width - 15);
|
|
1282
|
+
const text = truncatePlain(`${highPriority ? "! " : ""}${todo.title}`, maxLabelWidth);
|
|
1283
|
+
if (isTodoBlocked(todo)) return th.fg("error", text);
|
|
1284
|
+
if (isTodoDone(todo) || isTodoSkipped(todo)) return th.fg("muted", text);
|
|
1285
|
+
if (isCurrent || isTodoActive(todo)) return th.fg("text", th.bold(text));
|
|
1286
|
+
return th.fg("dim", text);
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
private nowRule(width: number, allDone: boolean): string {
|
|
1290
|
+
const th = this.theme;
|
|
1291
|
+
const innerW = Math.max(1, width - 2);
|
|
1292
|
+
const label = allDone ? th.fg("success", th.bold("DONE")) : th.fg("accent", th.bold("NOW"));
|
|
1293
|
+
const stamp = th.fg("dim", formatClock());
|
|
1294
|
+
const core = `${th.fg("dim", "─ ─ ─")} ${label} ${stamp}`;
|
|
1295
|
+
const fillerWidth = Math.max(0, innerW - visibleWidth(core) - 1);
|
|
1296
|
+
const filler = "─ ".repeat(Math.ceil(fillerWidth / 2)).slice(0, fillerWidth);
|
|
1297
|
+
return this.row(width, `${core}${filler ? " " + th.fg("dim", filler) : ""}`);
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
private ruleRow(width: number): string {
|
|
1301
|
+
return this.row(width, ` ${this.theme.fg("dim", "─".repeat(Math.max(1, width - 5)))}`);
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
private topBar(width: number): string {
|
|
1305
|
+
const th = this.theme;
|
|
1306
|
+
const innerW = Math.max(1, width - 2);
|
|
1307
|
+
const title = `─ ${th.fg("accent", th.bold("TIMELINE"))} `;
|
|
1308
|
+
const rightText = this.snapshot?.todos.length ? formatElapsed(Date.now() - (firstTodoTime(this.snapshot.todos) ?? Date.now())) : "todo";
|
|
1309
|
+
const right = ` ${th.fg("dim", rightText)} ─`;
|
|
1310
|
+
const dashes = Math.max(1, innerW - visibleWidth(title) - visibleWidth(right));
|
|
1311
|
+
return th.fg("border", "╭") + title + th.fg("border", "─".repeat(dashes)) + right + th.fg("border", "╮");
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
private botBar(width: number): string {
|
|
1315
|
+
return this.theme.fg("border", `╰${"─".repeat(Math.max(1, width - 2))}╯`);
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
private maxRenderRows(): number {
|
|
1319
|
+
const terminalRows = this.tui.terminal.rows || 30;
|
|
1320
|
+
const configuredRows = typeof todoSidebarMaxHeight === "number" ? todoSidebarMaxHeight : Math.floor(terminalRows * 0.9);
|
|
1321
|
+
return clamp(Math.min(configuredRows, terminalRows), 8, Math.max(8, terminalRows));
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
private blank(width: number): string {
|
|
1325
|
+
return this.row(width, "");
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
private row(width: number, content: string): string {
|
|
1329
|
+
const innerW = Math.max(1, width - 2);
|
|
1330
|
+
return this.theme.fg("border", "│") + padAnsi(content, innerW) + this.theme.fg("border", "│");
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
private close(): void {
|
|
1334
|
+
if (this.closed) return;
|
|
1335
|
+
this.closed = true;
|
|
1336
|
+
this.done({ type: "close" });
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
async function openTodoSidebar(ctx: ExtensionContext, options: { initialPath?: string; toggle?: boolean } = {}): Promise<void> {
|
|
1341
|
+
if (!ctx.hasUI) return;
|
|
1342
|
+
|
|
1343
|
+
if (closeTodoSidebar) {
|
|
1344
|
+
if (options.toggle) {
|
|
1345
|
+
closeTodoSidebar();
|
|
1346
|
+
return;
|
|
1347
|
+
}
|
|
1348
|
+
if (options.initialPath) activeTodoSidebarPanel?.setSourcePath(options.initialPath);
|
|
1349
|
+
return;
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
closeFileTreeSidebarIfOpen();
|
|
1353
|
+
|
|
1354
|
+
const initialPath = options.initialPath ?? (await findTodoJsonlFile(ctx.cwd));
|
|
1355
|
+
const applyLayout = (layout: { width: number; maxHeight: number }) => {
|
|
1356
|
+
todoSidebarWidth = layout.width;
|
|
1357
|
+
todoSidebarMaxHeight = layout.maxHeight;
|
|
1358
|
+
};
|
|
1359
|
+
|
|
1360
|
+
// Preload once before creating the overlay. Pi currently sizes overlays at creation time,
|
|
1361
|
+
// so waiting for the panel's async watcher would leave the first render stuck at the
|
|
1362
|
+
// small empty-state size and clip the bottom border.
|
|
1363
|
+
let initialSnapshot = latestTodoSnapshot;
|
|
1364
|
+
if (initialPath) {
|
|
1365
|
+
try {
|
|
1366
|
+
initialSnapshot = await readTodoJsonlFile(path.resolve(ctx.cwd, initialPath), ctx.cwd);
|
|
1367
|
+
latestTodoSnapshot = initialSnapshot;
|
|
1368
|
+
} catch {
|
|
1369
|
+
// The watcher inside the panel will surface read errors after the overlay opens.
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
applyLayout(preferredTodoSidebarLayout(initialSnapshot));
|
|
1373
|
+
|
|
1374
|
+
try {
|
|
1375
|
+
const sidebarPromise = ctx.ui.custom<TodoSidebarResult>(
|
|
1376
|
+
(tui, theme, _keybindings, done) => {
|
|
1377
|
+
let closed = false;
|
|
1378
|
+
const finish = (result: TodoSidebarResult) => {
|
|
1379
|
+
if (closed) return;
|
|
1380
|
+
closed = true;
|
|
1381
|
+
done(result);
|
|
1382
|
+
};
|
|
1383
|
+
|
|
1384
|
+
const panel = new TodoSidebarPanel({
|
|
1385
|
+
tui,
|
|
1386
|
+
theme,
|
|
1387
|
+
cwd: ctx.cwd,
|
|
1388
|
+
initialPath,
|
|
1389
|
+
done: finish,
|
|
1390
|
+
onLayoutChange: (layout) => {
|
|
1391
|
+
applyLayout(layout);
|
|
1392
|
+
tui.requestRender();
|
|
1393
|
+
},
|
|
1394
|
+
onSnapshotChange: (snapshot) => {
|
|
1395
|
+
latestTodoSnapshot = snapshot;
|
|
1396
|
+
},
|
|
1397
|
+
});
|
|
1398
|
+
activeTodoSidebarPanel = panel;
|
|
1399
|
+
closeTodoSidebar = () => finish({ type: "close" });
|
|
1400
|
+
return panel;
|
|
1401
|
+
},
|
|
1402
|
+
{
|
|
1403
|
+
overlay: true,
|
|
1404
|
+
overlayOptions: () => ({
|
|
1405
|
+
anchor: "right-center",
|
|
1406
|
+
width: todoSidebarWidth,
|
|
1407
|
+
minWidth: 32,
|
|
1408
|
+
maxHeight: todoSidebarMaxHeight,
|
|
1409
|
+
margin: { right: 0 },
|
|
1410
|
+
nonCapturing: true,
|
|
1411
|
+
}),
|
|
1412
|
+
onHandle: (handle) => {
|
|
1413
|
+
handle.unfocus();
|
|
1414
|
+
},
|
|
1415
|
+
},
|
|
1416
|
+
);
|
|
1417
|
+
|
|
1418
|
+
void sidebarPromise
|
|
1419
|
+
.catch((error) => {
|
|
1420
|
+
if (ctx.hasUI) ctx.ui.notify(`Todo sidebar failed: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
1421
|
+
})
|
|
1422
|
+
.finally(() => {
|
|
1423
|
+
closeTodoSidebar = null;
|
|
1424
|
+
activeTodoSidebarPanel = undefined;
|
|
1425
|
+
});
|
|
1426
|
+
} catch (error) {
|
|
1427
|
+
if (ctx.hasUI) ctx.ui.notify(`Todo sidebar failed: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
function closeTodoSidebarIfOpen(): boolean {
|
|
1432
|
+
if (!closeTodoSidebar) return false;
|
|
1433
|
+
closeTodoSidebar();
|
|
1434
|
+
return true;
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
export default function planModeExtension(pi: ExtensionAPI): void {
|
|
1438
|
+
let planModeEnabled = false;
|
|
1439
|
+
let previousTools: string[] | null = null;
|
|
1440
|
+
let latestPlanReview: PlanReviewRequest | undefined;
|
|
1441
|
+
let pendingPlanReview: PlanReviewRequest | undefined;
|
|
1442
|
+
let planReviewInProgress = false;
|
|
1443
|
+
|
|
1444
|
+
pi.registerFlag("plan", {
|
|
1445
|
+
description: "Start in plan mode and expose the plan_agent tool",
|
|
1446
|
+
type: "boolean",
|
|
1447
|
+
default: false,
|
|
1448
|
+
});
|
|
1449
|
+
|
|
1450
|
+
function updateStatus(ctx: ExtensionContext): void {
|
|
1451
|
+
if (!ctx.hasUI) return;
|
|
1452
|
+
if (planModeEnabled) ctx.ui.setStatus("plan-mode", ctx.ui.theme.fg("warning", "⏸ plan"));
|
|
1453
|
+
else ctx.ui.setStatus("plan-mode", undefined);
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
function enterPlanMode(ctx: ExtensionContext, silent = false): void {
|
|
1457
|
+
if (!planModeEnabled) previousTools = pi.getActiveTools().filter((tool) => tool !== "plan_agent");
|
|
1458
|
+
planModeEnabled = true;
|
|
1459
|
+
pi.setActiveTools(PLAN_MODE_TOOLS);
|
|
1460
|
+
updateStatus(ctx);
|
|
1461
|
+
if (!silent && ctx.hasUI) {
|
|
1462
|
+
ctx.ui.notify(`Plan mode enabled. Main tools: ${PLAN_MODE_TOOLS.join(", ")}`, "info");
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
function clearPlanReviewState(): void {
|
|
1467
|
+
latestPlanReview = undefined;
|
|
1468
|
+
pendingPlanReview = undefined;
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
function rememberPlanReview(prompt: string, result: PlanAgentRunResult, source: PlanReviewSource): PlanReviewRequest | undefined {
|
|
1472
|
+
if (!isPlanAgentRunOk(result)) {
|
|
1473
|
+
clearPlanReviewState();
|
|
1474
|
+
return undefined;
|
|
1475
|
+
}
|
|
1476
|
+
const review = createPlanReview(prompt, result, source);
|
|
1477
|
+
latestPlanReview = review;
|
|
1478
|
+
return review;
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
function restorePlanModeState(ctx: ExtensionContext, silent = false): void {
|
|
1482
|
+
planModeEnabled = false;
|
|
1483
|
+
if (previousTools) pi.setActiveTools(previousTools);
|
|
1484
|
+
previousTools = null;
|
|
1485
|
+
updateStatus(ctx);
|
|
1486
|
+
if (!silent && ctx.hasUI) ctx.ui.notify("Plan mode disabled. Previous tools restored.", "info");
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
async function createPlanExitHandoff(
|
|
1490
|
+
review: PlanReviewRequest,
|
|
1491
|
+
decision: PlanExitDecision,
|
|
1492
|
+
): Promise<PlanExitHandoff> {
|
|
1493
|
+
const plan = await readPlanContent(review.absolutePlanPath, MAX_PLAN_HANDOFF_CHARS);
|
|
1494
|
+
return {
|
|
1495
|
+
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
1496
|
+
decision,
|
|
1497
|
+
originalPrompt: review.prompt,
|
|
1498
|
+
planPath: review.planPath,
|
|
1499
|
+
absolutePlanPath: review.absolutePlanPath,
|
|
1500
|
+
todoPath: review.todoPath,
|
|
1501
|
+
absoluteTodoPath: review.absoluteTodoPath,
|
|
1502
|
+
planContent: plan.error ? `(Could not read plan file: ${plan.error})` : plan.content,
|
|
1503
|
+
planContentTruncated: plan.truncated,
|
|
1504
|
+
createdAt: new Date().toISOString(),
|
|
1505
|
+
};
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
async function exitPlanMode(
|
|
1509
|
+
ctx: ExtensionContext,
|
|
1510
|
+
options: { silent?: boolean; openTodoSidebar?: boolean; initialTodoPath?: string; handoff?: PlanExitHandoff } = {},
|
|
1511
|
+
): Promise<void> {
|
|
1512
|
+
restorePlanModeState(ctx, options.silent);
|
|
1513
|
+
pendingPlanReview = undefined;
|
|
1514
|
+
latestPlanReview = undefined;
|
|
1515
|
+
if (options.handoff) {
|
|
1516
|
+
sendPlanCustomMessage(pi, ctx, {
|
|
1517
|
+
customType: PLAN_HANDOFF_CUSTOM_TYPE,
|
|
1518
|
+
content: buildPlanHandoffContent(options.handoff),
|
|
1519
|
+
display: false,
|
|
1520
|
+
details: {
|
|
1521
|
+
id: options.handoff.id,
|
|
1522
|
+
decision: options.handoff.decision,
|
|
1523
|
+
originalPrompt: options.handoff.originalPrompt,
|
|
1524
|
+
planPath: options.handoff.planPath,
|
|
1525
|
+
todoPath: options.handoff.todoPath,
|
|
1526
|
+
createdAt: options.handoff.createdAt,
|
|
1527
|
+
},
|
|
1528
|
+
});
|
|
1529
|
+
}
|
|
1530
|
+
if (options.openTodoSidebar !== false) {
|
|
1531
|
+
await openTodoSidebar(ctx, { initialPath: options.initialTodoPath ?? options.handoff?.absoluteTodoPath });
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
async function displayPlanForExitRequest(ctx: ExtensionContext, review: PlanReviewRequest): Promise<PlanContentRead> {
|
|
1536
|
+
const plan = await readPlanContent(review.absolutePlanPath, MAX_PLAN_PREVIEW_CHARS);
|
|
1537
|
+
sendPlanCustomMessage(pi, ctx, {
|
|
1538
|
+
customType: PLAN_PREVIEW_CUSTOM_TYPE,
|
|
1539
|
+
content: buildPlanPreviewContent(review, plan),
|
|
1540
|
+
display: true,
|
|
1541
|
+
details: {
|
|
1542
|
+
id: review.id,
|
|
1543
|
+
planPath: review.planPath,
|
|
1544
|
+
todoPath: review.todoPath,
|
|
1545
|
+
truncated: plan.truncated,
|
|
1546
|
+
error: plan.error,
|
|
1547
|
+
},
|
|
1548
|
+
});
|
|
1549
|
+
if (plan.error && ctx.hasUI) ctx.ui.notify(`Could not read plan before exit: ${plan.error}`, "warning");
|
|
1550
|
+
return plan;
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
async function exitPlanModeWithLatestPlan(
|
|
1554
|
+
ctx: ExtensionContext,
|
|
1555
|
+
options: { silent?: boolean; openTodoSidebar?: boolean } = {},
|
|
1556
|
+
): Promise<void> {
|
|
1557
|
+
const review = latestPlanReview;
|
|
1558
|
+
if (!review) {
|
|
1559
|
+
await exitPlanMode(ctx, options);
|
|
1560
|
+
return;
|
|
1561
|
+
}
|
|
1562
|
+
await displayPlanForExitRequest(ctx, review);
|
|
1563
|
+
const handoff = await createPlanExitHandoff(review, "explicit");
|
|
1564
|
+
await exitPlanMode(ctx, { ...options, initialTodoPath: review.absoluteTodoPath, handoff });
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
async function requestPlanExitDecision(ctx: ExtensionContext, review: PlanReviewRequest): Promise<void> {
|
|
1568
|
+
if (planReviewInProgress) return;
|
|
1569
|
+
planReviewInProgress = true;
|
|
1570
|
+
try {
|
|
1571
|
+
await displayPlanForExitRequest(ctx, review);
|
|
1572
|
+
if (!ctx.hasUI) {
|
|
1573
|
+
sendPlanCustomMessage(pi, ctx, {
|
|
1574
|
+
customType: "plan-mode-exit-decision-skipped",
|
|
1575
|
+
content: "Plan mode exit decision skipped because no interactive UI is available. Plan mode remains enabled.",
|
|
1576
|
+
display: true,
|
|
1577
|
+
details: { planPath: review.planPath, todoPath: review.todoPath },
|
|
1578
|
+
});
|
|
1579
|
+
return;
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
const choice = await ctx.ui.select("Plan 已生成,是否退出 plan mode?", [
|
|
1583
|
+
PLAN_EXIT_EXECUTE_OPTION,
|
|
1584
|
+
PLAN_EXIT_SHELVE_OPTION,
|
|
1585
|
+
PLAN_EXIT_REVISE_OPTION,
|
|
1586
|
+
]);
|
|
1587
|
+
|
|
1588
|
+
if (choice === PLAN_EXIT_EXECUTE_OPTION) {
|
|
1589
|
+
const handoff = await createPlanExitHandoff(review, "execute");
|
|
1590
|
+
todoWorkflowActive = true;
|
|
1591
|
+
await exitPlanMode(ctx, { initialTodoPath: review.absoluteTodoPath, handoff });
|
|
1592
|
+
sendPlanUserMessage(pi, ctx, buildPlanExecuteKickoffPrompt(handoff));
|
|
1593
|
+
return;
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
if (choice === PLAN_EXIT_SHELVE_OPTION) {
|
|
1597
|
+
const handoff = await createPlanExitHandoff(review, "shelve");
|
|
1598
|
+
todoWorkflowActive = false;
|
|
1599
|
+
await exitPlanMode(ctx, { initialTodoPath: review.absoluteTodoPath, handoff });
|
|
1600
|
+
return;
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
if (choice === PLAN_EXIT_REVISE_OPTION) {
|
|
1604
|
+
const feedback = (await ctx.ui.editor("请输入 plan 修改意见", ""))?.trim() ?? "";
|
|
1605
|
+
if (!feedback) {
|
|
1606
|
+
ctx.ui.notify("未输入修改意见,继续保持 plan mode。", "info");
|
|
1607
|
+
return;
|
|
1608
|
+
}
|
|
1609
|
+
pendingPlanReview = undefined;
|
|
1610
|
+
sendPlanUserMessage(pi, ctx, buildPlanRevisePrompt(review, feedback));
|
|
1611
|
+
return;
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
ctx.ui.notify("已取消退出 plan mode,继续保持 plan mode。", "info");
|
|
1615
|
+
} finally {
|
|
1616
|
+
planReviewInProgress = false;
|
|
1617
|
+
updateStatus(ctx);
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
async function spawnFromCommand(prompt: string, ctx: ExtensionContext, outputPath?: string): Promise<void> {
|
|
1622
|
+
clearPlanReviewState();
|
|
1623
|
+
try {
|
|
1624
|
+
if (ctx.hasUI) ctx.ui.setStatus("plan-agent", ctx.ui.theme.fg("accent", "plan-agent…"));
|
|
1625
|
+
const result = await runPlanAgent(ctx, {
|
|
1626
|
+
prompt,
|
|
1627
|
+
outputPath,
|
|
1628
|
+
signal: ctx.signal,
|
|
1629
|
+
onStatus: (status) => {
|
|
1630
|
+
if (ctx.hasUI) ctx.ui.setStatus("plan-agent", ctx.ui.theme.fg("accent", status));
|
|
1631
|
+
},
|
|
1632
|
+
});
|
|
1633
|
+
pi.sendMessage(
|
|
1634
|
+
{
|
|
1635
|
+
customType: "plan-agent-result",
|
|
1636
|
+
content: formatPlanAgentResult(result),
|
|
1637
|
+
display: true,
|
|
1638
|
+
details: {
|
|
1639
|
+
planPath: result.planPath,
|
|
1640
|
+
planExists: result.planExists,
|
|
1641
|
+
todoPath: result.todoPath,
|
|
1642
|
+
todoExists: result.todoExists,
|
|
1643
|
+
exitCode: result.exitCode,
|
|
1644
|
+
model: result.model,
|
|
1645
|
+
stopReason: result.stopReason,
|
|
1646
|
+
},
|
|
1647
|
+
},
|
|
1648
|
+
{ triggerTurn: false },
|
|
1649
|
+
);
|
|
1650
|
+
if (ctx.hasUI) {
|
|
1651
|
+
const outputsOk = result.planExists && result.todoExists;
|
|
1652
|
+
ctx.ui.notify(
|
|
1653
|
+
outputsOk
|
|
1654
|
+
? `Plan written: ${result.planPath}; todos: ${result.todoPath}`
|
|
1655
|
+
: `Plan agent finished but missed required output(s): ${result.planPath}, ${result.todoPath}`,
|
|
1656
|
+
outputsOk ? "info" : "warning",
|
|
1657
|
+
);
|
|
1658
|
+
}
|
|
1659
|
+
const review = rememberPlanReview(prompt, result, "command");
|
|
1660
|
+
if (result.todoExists) await openTodoSidebar(ctx, { initialPath: result.absoluteTodoPath });
|
|
1661
|
+
if (review) await requestPlanExitDecision(ctx, review);
|
|
1662
|
+
} catch (error) {
|
|
1663
|
+
clearPlanReviewState();
|
|
1664
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1665
|
+
pi.sendMessage(
|
|
1666
|
+
{ customType: "plan-agent-result", content: `Plan agent failed: ${message}`, display: true },
|
|
1667
|
+
{ triggerTurn: false },
|
|
1668
|
+
);
|
|
1669
|
+
if (ctx.hasUI) ctx.ui.notify(`Plan agent failed: ${message}`, "error");
|
|
1670
|
+
} finally {
|
|
1671
|
+
if (ctx.hasUI) ctx.ui.setStatus("plan-agent", undefined);
|
|
1672
|
+
updateStatus(ctx);
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
pi.registerCommand("plan", {
|
|
1677
|
+
description: "Enter plan mode; optionally spawn plan-agent: /plan <request>, /plan off",
|
|
1678
|
+
handler: async (args, ctx) => {
|
|
1679
|
+
const raw = args.trim();
|
|
1680
|
+
if (/^(off|exit|disable|stop)$/i.test(raw)) {
|
|
1681
|
+
await exitPlanModeWithLatestPlan(ctx);
|
|
1682
|
+
return;
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
if (/^status$/i.test(raw)) {
|
|
1686
|
+
ctx.ui.notify(planModeEnabled ? "Plan mode is enabled." : "Plan mode is disabled.", "info");
|
|
1687
|
+
return;
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
await ctx.waitForIdle();
|
|
1691
|
+
enterPlanMode(ctx);
|
|
1692
|
+
|
|
1693
|
+
let prompt = raw.replace(/^spawn\s+/i, "").trim();
|
|
1694
|
+
if (!prompt && ctx.hasUI) {
|
|
1695
|
+
const choice = await ctx.ui.select("Plan mode enabled", [
|
|
1696
|
+
"Spawn plan-agent now",
|
|
1697
|
+
"Just enter plan mode",
|
|
1698
|
+
"Exit plan mode",
|
|
1699
|
+
]);
|
|
1700
|
+
if (choice === "Exit plan mode") {
|
|
1701
|
+
await exitPlanModeWithLatestPlan(ctx);
|
|
1702
|
+
return;
|
|
1703
|
+
}
|
|
1704
|
+
if (choice === "Spawn plan-agent now") {
|
|
1705
|
+
const entered = await ctx.ui.editor("What should plan-agent plan?", "");
|
|
1706
|
+
prompt = entered?.trim() ?? "";
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
if (prompt) await spawnFromCommand(prompt, ctx);
|
|
1711
|
+
},
|
|
1712
|
+
});
|
|
1713
|
+
|
|
1714
|
+
pi.registerCommand("todo", {
|
|
1715
|
+
description: "Toggle todo sidebar or start todo-driven work: /todo <goal>, /todo off",
|
|
1716
|
+
getArgumentCompletions: (prefix: string) => {
|
|
1717
|
+
const p = prefix.trim().toLowerCase();
|
|
1718
|
+
const items = [
|
|
1719
|
+
{ value: "off", label: "off", description: "close the todo sidebar" },
|
|
1720
|
+
{ value: "show", label: "show", description: "open the latest .plan/*.jsonl todo sidebar" },
|
|
1721
|
+
{ value: "status", label: "status", description: "show the active todo file" },
|
|
1722
|
+
];
|
|
1723
|
+
const matches = items.filter((item) => item.value.startsWith(p));
|
|
1724
|
+
return matches.length > 0 ? matches : null;
|
|
1725
|
+
},
|
|
1726
|
+
handler: async (args, ctx) => {
|
|
1727
|
+
const raw = args.trim();
|
|
1728
|
+
|
|
1729
|
+
if (/^(off|close|hide|stop)$/i.test(raw)) {
|
|
1730
|
+
todoWorkflowActive = false;
|
|
1731
|
+
if (!closeTodoSidebarIfOpen() && ctx.hasUI) ctx.ui.notify("Todo sidebar is not open.", "info");
|
|
1732
|
+
return;
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
if (/^(show|open)$/i.test(raw)) {
|
|
1736
|
+
await openTodoSidebar(ctx);
|
|
1737
|
+
return;
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
if (/^status$/i.test(raw)) {
|
|
1741
|
+
const discovered = await findTodoJsonlFile(ctx.cwd);
|
|
1742
|
+
const file = latestTodoSnapshot?.relativePath ?? (discovered ? relativeDisplayPath(ctx.cwd, discovered) : undefined);
|
|
1743
|
+
ctx.ui.notify(file ? `Todo sidebar: ${file}` : "No .plan/*.jsonl todo file found.", "info");
|
|
1744
|
+
return;
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
if (!raw) {
|
|
1748
|
+
await openTodoSidebar(ctx, { toggle: true });
|
|
1749
|
+
return;
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
await ctx.waitForIdle();
|
|
1753
|
+
if (planModeEnabled) {
|
|
1754
|
+
restorePlanModeState(ctx, true);
|
|
1755
|
+
clearPlanReviewState();
|
|
1756
|
+
}
|
|
1757
|
+
sendPlanCustomMessage(pi, ctx, {
|
|
1758
|
+
customType: PLAN_CONTEXT_RESET_CUSTOM_TYPE,
|
|
1759
|
+
content: "Starting a new /todo workflow. Ignore any earlier plan-mode handoff context.",
|
|
1760
|
+
display: false,
|
|
1761
|
+
details: { reason: "todo-command", goal: raw, createdAt: new Date().toISOString() },
|
|
1762
|
+
});
|
|
1763
|
+
await fsp.mkdir(path.resolve(ctx.cwd, PLAN_DIR), { recursive: true });
|
|
1764
|
+
todoWorkflowActive = true;
|
|
1765
|
+
await openTodoSidebar(ctx);
|
|
1766
|
+
sendPlanUserMessage(pi, ctx, buildTodoCommandPrompt(raw));
|
|
1767
|
+
},
|
|
1768
|
+
});
|
|
1769
|
+
|
|
1770
|
+
pi.registerShortcut("ctrl+shift+t", {
|
|
1771
|
+
description: "Toggle the right-side todo timeline sidebar",
|
|
1772
|
+
handler: async (ctx) => {
|
|
1773
|
+
await openTodoSidebar(ctx, { toggle: true });
|
|
1774
|
+
},
|
|
1775
|
+
});
|
|
1776
|
+
|
|
1777
|
+
pi.registerTool({
|
|
1778
|
+
name: "plan_agent",
|
|
1779
|
+
label: "Plan Agent",
|
|
1780
|
+
description:
|
|
1781
|
+
"Spawn the global plan-agent in an isolated pi process. It can read the current repo and write/edit/delete only under .plan/.",
|
|
1782
|
+
promptSnippet: "Spawn plan-agent to research the repo/web and write a markdown plan plus .plan/todo.jsonl todos under .plan/.",
|
|
1783
|
+
promptGuidelines: [
|
|
1784
|
+
"Use plan_agent in plan mode when the user asks for a written implementation plan or when a planning task needs isolated research.",
|
|
1785
|
+
"The plan_agent tool writes markdown plans and .plan/todo.jsonl todos only under .plan/ and should not be used for implementation.",
|
|
1786
|
+
"After plan_agent succeeds, the parent plan-mode extension will display the plan, ask the user whether to execute, shelve, or revise it, and prune context to the original prompt plus plan when exit is approved.",
|
|
1787
|
+
"If the user asks to revise the plan after review, call plan_agent again with the same outputPath and do not implement until the parent extension exits plan mode.",
|
|
1788
|
+
],
|
|
1789
|
+
parameters: PlanAgentParams,
|
|
1790
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
1791
|
+
if (!planModeEnabled) {
|
|
1792
|
+
return {
|
|
1793
|
+
content: [{ type: "text", text: "Plan mode is not enabled. Ask the user to run /plan first." }],
|
|
1794
|
+
details: { enabled: false },
|
|
1795
|
+
};
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
clearPlanReviewState();
|
|
1799
|
+
const result = await runPlanAgent(ctx, {
|
|
1800
|
+
prompt: params.prompt,
|
|
1801
|
+
outputPath: params.outputPath,
|
|
1802
|
+
signal,
|
|
1803
|
+
onStatus: (status) =>
|
|
1804
|
+
onUpdate?.({
|
|
1805
|
+
content: [{ type: "text", text: status }],
|
|
1806
|
+
details: { status },
|
|
1807
|
+
}),
|
|
1808
|
+
});
|
|
1809
|
+
|
|
1810
|
+
const review = rememberPlanReview(params.prompt, result, "tool");
|
|
1811
|
+
pendingPlanReview = review;
|
|
1812
|
+
if (result.todoExists) await openTodoSidebar(ctx, { initialPath: result.absoluteTodoPath });
|
|
1813
|
+
|
|
1814
|
+
return {
|
|
1815
|
+
content: [{ type: "text", text: formatPlanAgentResult(result) }],
|
|
1816
|
+
details: {
|
|
1817
|
+
planPath: result.planPath,
|
|
1818
|
+
planExists: result.planExists,
|
|
1819
|
+
todoPath: result.todoPath,
|
|
1820
|
+
todoExists: result.todoExists,
|
|
1821
|
+
exitCode: result.exitCode,
|
|
1822
|
+
model: result.model,
|
|
1823
|
+
stopReason: result.stopReason,
|
|
1824
|
+
exitReviewPending: Boolean(review),
|
|
1825
|
+
},
|
|
1826
|
+
};
|
|
1827
|
+
},
|
|
1828
|
+
});
|
|
1829
|
+
|
|
1830
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
1831
|
+
if (!planModeEnabled) return undefined;
|
|
1832
|
+
|
|
1833
|
+
if (event.toolName === "write" || event.toolName === "edit") {
|
|
1834
|
+
return { block: true, reason: "Plan mode is read-only. Use plan_agent to write plans under .plan/." };
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
if (event.toolName === "bash") {
|
|
1838
|
+
const command = String((event.input as any).command ?? "");
|
|
1839
|
+
const check = isReadOnlyBash(command, ctx.cwd);
|
|
1840
|
+
if (!check.ok) {
|
|
1841
|
+
return {
|
|
1842
|
+
block: true,
|
|
1843
|
+
reason: `Plan mode blocked bash command: ${check.reason}. Use read/grep/find/ls or plan_agent.`,
|
|
1844
|
+
};
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
return undefined;
|
|
1849
|
+
});
|
|
1850
|
+
|
|
1851
|
+
pi.on("agent_end", async (_event, ctx) => {
|
|
1852
|
+
const review = pendingPlanReview;
|
|
1853
|
+
if (!review || !planModeEnabled) return;
|
|
1854
|
+
pendingPlanReview = undefined;
|
|
1855
|
+
await requestPlanExitDecision(ctx, review);
|
|
1856
|
+
});
|
|
1857
|
+
|
|
1858
|
+
pi.on("context", async (event) => {
|
|
1859
|
+
const shouldDropPlanUiMessage = (customType: unknown): boolean =>
|
|
1860
|
+
customType === "plan-mode-context" ||
|
|
1861
|
+
customType === PLAN_PREVIEW_CUSTOM_TYPE ||
|
|
1862
|
+
customType === PLAN_CONTEXT_RESET_CUSTOM_TYPE ||
|
|
1863
|
+
customType === "plan-mode-exit-decision-skipped";
|
|
1864
|
+
|
|
1865
|
+
if (planModeEnabled) {
|
|
1866
|
+
return {
|
|
1867
|
+
messages: event.messages.filter((message) => {
|
|
1868
|
+
const customType = (message as any).customType;
|
|
1869
|
+
if (customType === "todo-sidebar-context" || customType === PLAN_HANDOFF_CUSTOM_TYPE) return false;
|
|
1870
|
+
return !shouldDropPlanUiMessage(customType);
|
|
1871
|
+
}),
|
|
1872
|
+
};
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
let lastHandoffIndex = -1;
|
|
1876
|
+
let lastResetIndex = -1;
|
|
1877
|
+
for (let i = event.messages.length - 1; i >= 0; i--) {
|
|
1878
|
+
const customType = (event.messages[i] as any).customType;
|
|
1879
|
+
if (lastHandoffIndex < 0 && customType === PLAN_HANDOFF_CUSTOM_TYPE) lastHandoffIndex = i;
|
|
1880
|
+
if (lastResetIndex < 0 && customType === PLAN_CONTEXT_RESET_CUSTOM_TYPE) lastResetIndex = i;
|
|
1881
|
+
if (lastHandoffIndex >= 0 && lastResetIndex >= 0) break;
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
const startIndex = lastHandoffIndex >= 0 && lastHandoffIndex > lastResetIndex ? lastHandoffIndex : lastResetIndex >= 0 ? lastResetIndex + 1 : 0;
|
|
1885
|
+
const scopedMessages = startIndex > 0 ? event.messages.slice(startIndex) : event.messages;
|
|
1886
|
+
const keepTodoContext = (todoWorkflowActive || Boolean(closeTodoSidebar)) && Boolean(todoSnapshotContext(latestTodoSnapshot));
|
|
1887
|
+
let lastTodoContextIndex = -1;
|
|
1888
|
+
if (keepTodoContext) {
|
|
1889
|
+
for (let i = scopedMessages.length - 1; i >= 0; i--) {
|
|
1890
|
+
if ((scopedMessages[i] as any).customType === "todo-sidebar-context") {
|
|
1891
|
+
lastTodoContextIndex = i;
|
|
1892
|
+
break;
|
|
1893
|
+
}
|
|
1894
|
+
}
|
|
1895
|
+
}
|
|
1896
|
+
return {
|
|
1897
|
+
messages: scopedMessages.filter((message, index) => {
|
|
1898
|
+
const customType = (message as any).customType;
|
|
1899
|
+
if (shouldDropPlanUiMessage(customType)) return false;
|
|
1900
|
+
if (customType === "todo-sidebar-context") return keepTodoContext && index === lastTodoContextIndex;
|
|
1901
|
+
return true;
|
|
1902
|
+
}),
|
|
1903
|
+
};
|
|
1904
|
+
});
|
|
1905
|
+
|
|
1906
|
+
pi.on("before_agent_start", async () => {
|
|
1907
|
+
if (planModeEnabled) {
|
|
1908
|
+
return {
|
|
1909
|
+
message: {
|
|
1910
|
+
customType: "plan-mode-context",
|
|
1911
|
+
content: `[PLAN MODE ACTIVE]
|
|
1912
|
+
You are in plan mode. Do not implement or modify project files from the main agent.
|
|
1913
|
+
|
|
1914
|
+
Allowed main-agent actions:
|
|
1915
|
+
- Inspect the current repository with read, grep, find, ls, and read-only bash.
|
|
1916
|
+
- Ask clarifying questions.
|
|
1917
|
+
- Spawn the isolated plan-agent with the plan_agent tool.
|
|
1918
|
+
|
|
1919
|
+
Use plan_agent when the user wants a written plan. plan-agent receives the user's prompt, the parent system prompt, repository AGENTS/CLAUDE context, web search via plan_web_search, code searching/checking tools, and write/edit/delete permission only under .plan/. It must write both the markdown plan and .plan/todo.jsonl todos.
|
|
1920
|
+
|
|
1921
|
+
After plan_agent succeeds, the parent plan-mode extension will show the plan content, ask the user to choose execute / shelve / revise, and only exit plan mode after user approval. On approved exit, future LLM context is pruned to a handoff containing the user's original prompt and the plan.
|
|
1922
|
+
|
|
1923
|
+
If you call plan_agent, pass the user's request as the prompt. Do not use edit/write directly in plan mode. If the user requests revision, call plan_agent again with the same outputPath; do not implement until plan mode exits.`,
|
|
1924
|
+
display: false,
|
|
1925
|
+
},
|
|
1926
|
+
};
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
const todoContext = todoWorkflowActive || closeTodoSidebar ? todoSnapshotContext(latestTodoSnapshot) : undefined;
|
|
1930
|
+
if (!todoContext) return undefined;
|
|
1931
|
+
return {
|
|
1932
|
+
message: {
|
|
1933
|
+
customType: "todo-sidebar-context",
|
|
1934
|
+
content: todoContext,
|
|
1935
|
+
display: false,
|
|
1936
|
+
},
|
|
1937
|
+
};
|
|
1938
|
+
});
|
|
1939
|
+
|
|
1940
|
+
pi.on("session_shutdown", async () => {
|
|
1941
|
+
closeTodoSidebarIfOpen();
|
|
1942
|
+
latestTodoSnapshot = undefined;
|
|
1943
|
+
todoWorkflowActive = false;
|
|
1944
|
+
});
|
|
1945
|
+
|
|
1946
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
1947
|
+
if (pi.getFlag("plan") === true) {
|
|
1948
|
+
enterPlanMode(ctx, true);
|
|
1949
|
+
} else {
|
|
1950
|
+
// Keep plan_agent out of the normal tool set; /plan enables it explicitly.
|
|
1951
|
+
const activeTools = pi.getActiveTools();
|
|
1952
|
+
if (activeTools.includes("plan_agent")) pi.setActiveTools(activeTools.filter((tool) => tool !== "plan_agent"));
|
|
1953
|
+
updateStatus(ctx);
|
|
1954
|
+
}
|
|
1955
|
+
});
|
|
1956
|
+
}
|