@agnishc/edb-todo 0.8.2 → 0.10.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/src/index.ts CHANGED
@@ -1,111 +1,249 @@
1
1
  /**
2
2
  * edb-todo
3
3
  *
4
- * Task management extension that prevents "goal drift" the tendency for
5
- * agents to lose track of the original plan as context grows.
4
+ * Task management extension with pi-tasks tool names, descriptions, and behavior.
6
5
  *
7
- * How it works:
8
- * 1. The agent uses `todo_write` to plan multi-step work as structured tasks
9
- * 2. Before every agent turn, active tasks are injected into the system prompt
10
- * 3. A live widget above the editor shows the task list to the user
11
- * 4. State is stored in tool-result details and reconstructed from the session
12
- * branch, so /tree navigation and forking work correctly
6
+ * Tools:
7
+ * TaskCreate — Create a structured task
8
+ * TaskList — List all tasks with status and blocked-by info
9
+ * TaskGet — Get full task details, description, dependencies
10
+ * TaskUpdate — Update status, fields, dependencies; status:"deleted" removes
13
11
  *
14
- * Tools: todo_write — replace the entire task list (atomic update)
15
- * todo_read — read the current task list
16
- * todo_remove — remove tasks by ID (permanent deletion)
17
- * Command: /todos — open interactive full-screen task viewer
12
+ * Command: /todos interactive task manager with settings panel
18
13
  */
19
14
 
15
+ import { join, resolve } from "node:path";
20
16
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
21
17
  import { Text } from "@earendil-works/pi-tui";
22
18
  import { Type } from "typebox";
23
- import { TodoViewComponent } from "./component";
24
- import { buildSystemPromptBlock, formatListForLLM } from "./prompt";
25
- import { TodoRemoveParams, TodoWriteParams } from "./schemas";
26
- import { reconstructState, store, updateWidget } from "./state";
27
- import type { TaskDetails, TaskPriority, TaskStatus } from "./types";
19
+ import { AutoClearManager } from "./auto-clear.js";
20
+ import { openTodosMenu, TodoViewComponent } from "./component.js";
21
+ import { loadTodoConfig } from "./config.js";
22
+ import { FileTaskStore } from "./file-store.js";
23
+ import { ProcessTracker } from "./process-tracker.js";
24
+ import { buildSystemPromptBlock, formatListForLLM } from "./prompt.js";
25
+ import { TodoCreateParams, TodoGetParams, TodoUpdateParams } from "./schemas.js";
26
+ import { priorityColor, priorityLabel, renderTaskListResult, TodoWidget } from "./state.js";
27
+ import type { TaskDetails, TaskPriority } from "./types.js";
28
+
29
+ // ── Constants ──────────────────────────────────────────────────────────────────
30
+
31
+ const TASK_TOOL_NAMES = new Set(["TaskCreate", "TaskList", "TaskGet", "TaskUpdate", "TaskOutput", "TaskStop"]);
32
+ const REMINDER_INTERVAL = 4;
33
+ const AUTO_CLEAR_DELAY = 4;
34
+
35
+ const SYSTEM_REMINDER = `<system-reminder>
36
+ The task tools haven't been used recently. If you're working on tasks that would benefit from tracking progress, consider using TaskCreate to add new tasks and TaskUpdate to update task status (set to in_progress when starting, completed when done). Also consider cleaning up the task list if it has become stale. Only use these if relevant to the current work. This is just a gentle reminder - ignore if not applicable. Make sure that you NEVER mention this reminder to the user
37
+ </system-reminder>`;
28
38
 
29
39
  // ── Extension ──────────────────────────────────────────────────────────────────
30
40
 
31
41
  export default function todoExtension(pi: ExtensionAPI): void {
32
- // ── Session lifecycle ──────────────────────────────────────────────────
33
- pi.on("session_start", async (_event, ctx) => {
34
- reconstructState(ctx);
35
- updateWidget(ctx);
42
+ let cwd = process.cwd();
43
+ let cfg = loadTodoConfig(cwd);
44
+ const taskScope = cfg.taskScope ?? "session";
45
+
46
+ function resolveStorePath(sessionId?: string): string | undefined {
47
+ const envVal = process.env.PI_TODO;
48
+ if (envVal === "off") return undefined;
49
+ if (envVal?.startsWith("/")) return envVal;
50
+ if (envVal?.startsWith(".")) return resolve(envVal);
51
+ if (envVal) return join(process.env.HOME ?? "~", ".pi", "tasks", `${envVal}.json`);
52
+
53
+ if (taskScope === "memory") return undefined;
54
+ if (taskScope === "session" && sessionId) {
55
+ return join(cwd, ".pi", "tasks", `tasks-${sessionId}.json`);
56
+ }
57
+ if (taskScope === "session") return undefined;
58
+ return join(cwd, ".pi", "tasks", "tasks.json");
59
+ }
60
+
61
+ let store = new FileTaskStore(resolveStorePath());
62
+ const tracker = new ProcessTracker();
63
+ const widget = new TodoWidget(store);
64
+ const autoClear = new AutoClearManager(
65
+ () => store,
66
+ () => cfg.autoClearCompleted ?? "on_list_complete",
67
+ AUTO_CLEAR_DELAY,
68
+ );
69
+
70
+ let storeUpgraded = false;
71
+ let persistedTasksShown = false;
72
+
73
+ function upgradeStoreIfNeeded(sessionId?: string) {
74
+ if (storeUpgraded) return;
75
+ if (taskScope === "session" && !process.env.PI_TODO) {
76
+ const path = resolveStorePath(sessionId);
77
+ if (path) {
78
+ store = new FileTaskStore(path);
79
+ widget.setStore(store);
80
+ autoClear.getStore = () => store;
81
+ }
82
+ }
83
+ storeUpgraded = true;
84
+ }
85
+
86
+ function showPersistedTasks(isResume = false) {
87
+ if (persistedTasksShown) return;
88
+ persistedTasksShown = true;
89
+ const tasks = store.list();
90
+ if (tasks.length > 0) {
91
+ if (!isResume && tasks.every((t) => t.status === "completed")) {
92
+ store.clearCompleted();
93
+ if (taskScope === "session") store.deleteFileIfEmpty();
94
+ } else {
95
+ widget.update();
96
+ }
97
+ }
98
+ }
99
+
100
+ // ── Turn tracking ──────────────────────────────────────────────────────────
101
+ let currentTurn = 0;
102
+ let lastTaskToolUseTurn = 0;
103
+ let reminderInjectedThisCycle = false;
104
+
105
+ pi.on("turn_start", async (_event, ctx) => {
106
+ currentTurn++;
107
+ cwd = ctx.cwd;
108
+ cfg = loadTodoConfig(cwd);
109
+ widget.setUICtx(ctx.ui);
110
+ upgradeStoreIfNeeded(ctx.sessionManager.getSessionId());
111
+ if (autoClear.onTurnStart(currentTurn)) widget.update();
36
112
  });
37
113
 
38
- pi.on("session_tree", async (_event, ctx) => {
39
- reconstructState(ctx);
40
- updateWidget(ctx);
114
+ // ── System-reminder injection ──────────────────────────────────────────────
115
+ pi.on("tool_result", async (event) => {
116
+ if (TASK_TOOL_NAMES.has(event.toolName)) {
117
+ lastTaskToolUseTurn = currentTurn;
118
+ reminderInjectedThisCycle = false;
119
+ return {};
120
+ }
121
+ if (currentTurn - lastTaskToolUseTurn < REMINDER_INTERVAL) return {};
122
+ if (reminderInjectedThisCycle) return {};
123
+ if (store.list().length === 0) return {};
124
+ reminderInjectedThisCycle = true;
125
+ lastTaskToolUseTurn = currentTurn;
126
+ return {
127
+ content: [...event.content, { type: "text" as const, text: SYSTEM_REMINDER }],
128
+ };
41
129
  });
42
130
 
43
- // ── System-prompt injection ────────────────────────────────────────────
44
- pi.on("before_agent_start", async (event, _ctx) => {
45
- const block = buildSystemPromptBlock();
131
+ // ── System-prompt injection ────────────────────────────────────────────────
132
+ pi.on("before_agent_start", async (event, ctx) => {
133
+ cwd = ctx.cwd;
134
+ cfg = loadTodoConfig(cwd);
135
+ widget.setUICtx(ctx.ui);
136
+ upgradeStoreIfNeeded(ctx.sessionManager.getSessionId());
137
+ showPersistedTasks();
138
+ const block = buildSystemPromptBlock(store);
46
139
  if (!block) return;
47
140
  return { systemPrompt: `${event.systemPrompt}\n\n${block}` };
48
141
  });
49
142
 
50
- // ── Post-turn widget refresh ───────────────────────────────────────────
51
143
  pi.on("agent_end", async (_event, ctx) => {
52
- updateWidget(ctx);
144
+ widget.setUICtx(ctx.ui);
145
+ widget.update();
146
+ });
147
+
148
+ // ── Session lifecycle ──────────────────────────────────────────────────────
149
+ pi.on("session_start", async (event, ctx) => {
150
+ const isResume = event.reason === "resume";
151
+ cwd = ctx.cwd;
152
+ cfg = loadTodoConfig(cwd);
153
+ storeUpgraded = false;
154
+ persistedTasksShown = false;
155
+ currentTurn = 0;
156
+ lastTaskToolUseTurn = 0;
157
+ reminderInjectedThisCycle = false;
158
+ autoClear.reset();
159
+ if (!isResume && taskScope === "memory") store.clearAll();
160
+ upgradeStoreIfNeeded(ctx.sessionManager.getSessionId());
161
+ widget.setUICtx(ctx.ui);
162
+ showPersistedTasks(isResume);
163
+ });
164
+
165
+ pi.on("session_tree", async (_event, ctx) => {
166
+ widget.setUICtx(ctx.ui);
167
+ widget.update();
53
168
  });
54
169
 
55
- // ── Tool: todo_write ───────────────────────────────────────────────────
170
+ // ── Tool: TaskCreate ───────────────────────────────────────────────────────
56
171
  pi.registerTool({
57
- name: "todo_write",
58
- label: "Tasks",
59
- description:
60
- "Write and manage your task list for complex, multi-step work. " +
61
- "Provide the COMPLETE updated list — this REPLACES the current list entirely.",
62
- promptSnippet:
63
- "Create and update a structured task list with statuses (pending / in_progress / completed) and priorities",
172
+ name: "TaskCreate",
173
+ label: "TaskCreate",
174
+ description: `Use this tool to create a structured task list for your current coding session. This helps you track progress, organize complex tasks, and demonstrate thoroughness to the user.
175
+ It also helps the user understand the progress of the task and overall progress of their requests.
176
+
177
+ ## When to Use This Tool
178
+
179
+ Use this tool proactively in these scenarios:
180
+
181
+ - Complex multi-step tasks - When a task requires 3 or more distinct steps or actions
182
+ - Non-trivial and complex tasks - Tasks that require careful planning or multiple operations
183
+ - User explicitly requests todo list - When the user directly asks you to use the todo list
184
+ - User provides multiple tasks - When users provide a list of things to be done (numbered or comma-separated)
185
+ - After receiving new instructions - Immediately capture user requirements as tasks
186
+ - When you start working on a task - Mark it as in_progress BEFORE beginning work
187
+ - After completing a task - Mark it as completed and add any new follow-up tasks discovered during implementation
188
+
189
+ ## When NOT to Use This Tool
190
+
191
+ Skip using this tool when:
192
+ - There is only a single, straightforward task
193
+ - The task is trivial and tracking it provides no organizational benefit
194
+ - The task can be completed in less than 3 trivial steps
195
+ - The task is purely conversational or informational
196
+
197
+ NOTE that you should not use this tool if there is only one trivial task to do. In this case you are better off just doing the task directly.
198
+
199
+ ## Task Fields
200
+
201
+ - **content**: A brief, actionable title in imperative form (e.g., "Fix authentication bug in login flow")
202
+ - **description**: Detailed description of what needs to be done, including context and acceptance criteria
203
+ - **activeForm** (optional): Present continuous form shown in the spinner when in_progress (e.g., "Fixing authentication bug"). If omitted, the spinner shows the content instead.
204
+ - **priority**: high / medium / low
205
+
206
+ All tasks are created with status \`pending\`.
207
+
208
+ ## Tips
209
+
210
+ - Create tasks with clear, specific content that describes the outcome
211
+ - Include enough detail in the description for another agent to understand and complete the task
212
+ - After creating tasks, use TaskUpdate to set up dependencies (blocks/blockedBy) if needed
213
+ - Check TaskList first to avoid creating duplicate tasks`,
64
214
  promptGuidelines: [
65
- "Use todo_write at the start of any complex, multi-step task to break the work into a clear plan. " +
66
- "Tasks should be specific and atomic (one concrete action each).",
67
- "Before starting a task, call todo_write to set it to 'in_progress'. " +
68
- "Only one task should be 'in_progress' at a time unless tasks are genuinely parallel.",
69
- "Immediately after completing a task, call todo_write to mark it 'completed'. " +
70
- "Do not batch completions — mark each task done as soon as it is finished. " +
71
- "Completed tasks remain visible in the list; they are never automatically deleted.",
72
- "todo_write REPLACES the entire list. Always include ALL tasks (both changed and unchanged) in every call.",
73
- "To permanently remove tasks, use todo_remove instead of omitting them from todo_write.",
215
+ "When working on complex multi-step tasks, use TaskCreate to track progress and TaskUpdate to update status.",
216
+ "Mark tasks as in_progress before starting work and completed when done.",
217
+ "Use TaskList to check for available work after completing a task.",
74
218
  ],
75
- parameters: TodoWriteParams,
219
+ parameters: TodoCreateParams,
76
220
 
77
221
  async execute(_id, params, _signal, _onUpdate, ctx) {
78
- const now = Date.now();
79
- const updated = params.tasks.map((t) => ({
80
- id: t.id ?? store.generateId(),
81
- content: t.content,
82
- status: t.status as TaskStatus,
83
- priority: t.priority as TaskPriority,
84
- createdAt: now,
85
- }));
86
-
87
- store.applyStatusTransitions(updated);
88
- store.setTasks(updated);
89
- store.syncIdCounter();
90
- updateWidget(ctx);
91
-
222
+ autoClear.resetBatchCountdown();
223
+ const task = store.create(params.content, {
224
+ description: params.description,
225
+ priority: params.priority as TaskPriority | undefined,
226
+ activeForm: params.activeForm,
227
+ metadata: params.metadata,
228
+ });
229
+ widget.setUICtx(ctx.ui);
230
+ widget.update();
92
231
  return {
93
- content: [{ type: "text", text: formatListForLLM() }],
94
- details: { tasks: [...store.tasks] } satisfies TaskDetails,
232
+ content: [{ type: "text", text: `Task #${task.id} created successfully: ${task.content}` }],
233
+ details: { tasks: [...store.list()] } satisfies TaskDetails,
95
234
  };
96
235
  },
97
236
 
98
237
  renderCall(args, theme) {
99
- const list = (args.tasks as any[]) ?? [];
100
- const inProg = list.filter((t: any) => t.status === "in_progress").length;
101
- const done = list.filter((t: any) => t.status === "completed").length;
102
- const total = list.length;
103
-
104
- let text = theme.fg("toolTitle", theme.bold("todo_write "));
105
- text += theme.fg("muted", `${total} task${total !== 1 ? "s" : ""}`);
106
- if (inProg > 0) text += ` ${theme.fg("accent", `● ${inProg} active`)}`;
107
- if (done > 0) text += ` ${theme.fg("success", `✓ ${done} done`)}`;
108
- return new Text(text, 0, 0);
238
+ const content = (args.content as string) ?? "";
239
+ const priority = (args.priority as string) ?? "medium";
240
+ const pColor = priorityColor(priority as TaskPriority);
241
+ const pLabel = theme.fg(pColor, priorityLabel(priority as TaskPriority));
242
+ return new Text(
243
+ `${theme.fg("toolTitle", theme.bold("TaskCreate ")) + pLabel} ${theme.fg("muted", content)}`,
244
+ 0,
245
+ 0,
246
+ );
109
247
  },
110
248
 
111
249
  renderResult(result, { expanded }, theme) {
@@ -113,23 +251,65 @@ export default function todoExtension(pi: ExtensionAPI): void {
113
251
  },
114
252
  });
115
253
 
116
- // ── Tool: todo_read ────────────────────────────────────────────────────
254
+ // ── Tool: TaskList ─────────────────────────────────────────────────────────
117
255
  pi.registerTool({
118
- name: "todo_read",
119
- label: "Tasks",
120
- description: "Read the current task list. Use this to check your tasks and their statuses.",
121
- promptSnippet: "Read the current task list and statuses",
256
+ name: "TaskList",
257
+ label: "TaskList",
258
+ description: `Use this tool to list all tasks in the task list.
259
+
260
+ ## When to Use This Tool
261
+
262
+ - To see what tasks are available to work on (status: pending, not blocked)
263
+ - To check overall progress on the project
264
+ - To find tasks that are blocked and need dependencies resolved
265
+ - After completing a task, to check for newly unblocked work
266
+ - **Prefer working on tasks in ID order** (lowest ID first) when multiple tasks are available
267
+
268
+ ## Output
269
+
270
+ Returns a summary of each task:
271
+ - **id**: Task identifier (use with TaskGet, TaskUpdate)
272
+ - **content**: Brief description of the task
273
+ - **status**: pending, in_progress, or completed
274
+ - **priority**: high, medium, or low
275
+ - **blockedBy**: Open task IDs that must be resolved first
276
+
277
+ Use TaskGet with a specific task ID to view full details including description.`,
122
278
  parameters: Type.Object({}),
123
279
 
124
280
  async execute() {
281
+ const tasks = store.list();
282
+ if (tasks.length === 0)
283
+ return {
284
+ content: [{ type: "text", text: "No tasks found" }],
285
+ details: { tasks: [] } satisfies TaskDetails,
286
+ };
287
+
288
+ const statusOrder: Record<string, number> = { pending: 0, in_progress: 1, completed: 2 };
289
+ const sorted = [...tasks].sort((a, b) => {
290
+ const so = (statusOrder[a.status] ?? 0) - (statusOrder[b.status] ?? 0);
291
+ if (so !== 0) return so;
292
+ return a.id.localeCompare(b.id);
293
+ });
294
+
295
+ const lines = sorted.map((task) => {
296
+ let line = `[${task.status}] [${task.priority}] #${task.id} ${task.content}`;
297
+ const openBlockers = task.blockedBy.filter((bid) => {
298
+ const b = store.get(bid);
299
+ return b && b.status !== "completed";
300
+ });
301
+ if (openBlockers.length > 0) line += ` [blocked by ${openBlockers.map((id) => `#${id}`).join(", ")}]`;
302
+ return line;
303
+ });
304
+
125
305
  return {
126
- content: [{ type: "text", text: formatListForLLM() }],
127
- details: { tasks: [...store.tasks] } satisfies TaskDetails,
306
+ content: [{ type: "text", text: lines.join("\n") }],
307
+ details: { tasks: sorted } satisfies TaskDetails,
128
308
  };
129
309
  },
130
310
 
131
311
  renderCall(_args, theme) {
132
- return new Text(theme.fg("toolTitle", theme.bold("todo_read")), 0, 0);
312
+ return new Text(theme.fg("toolTitle", theme.bold("TaskList")), 0, 0);
133
313
  },
134
314
 
135
315
  renderResult(result, { expanded }, theme) {
@@ -137,67 +317,333 @@ export default function todoExtension(pi: ExtensionAPI): void {
137
317
  },
138
318
  });
139
319
 
140
- // ── Tool: todo_remove ──────────────────────────────────────────────────
320
+ // ── Tool: TaskGet ──────────────────────────────────────────────────────────
141
321
  pi.registerTool({
142
- name: "todo_remove",
143
- label: "Tasks",
144
- description:
145
- "Permanently remove tasks by ID. Use this to clean up tasks that are no longer relevant. " +
146
- "Completed tasks normally stay in the list for visibility — only remove if truly unwanted.",
147
- promptSnippet: "Remove tasks by ID",
322
+ name: "TaskGet",
323
+ label: "TaskGet",
324
+ description: `Use this tool to retrieve a task by its ID from the task list.
325
+
326
+ ## When to Use This Tool
327
+
328
+ - When you need the full description and context before starting work on a task
329
+ - To understand task dependencies (what it blocks, what blocks it)
330
+ - After being assigned a task, to get complete requirements
331
+
332
+ ## Output
333
+
334
+ Returns full task details:
335
+ - **content**: Task title
336
+ - **description**: Detailed requirements and context
337
+ - **status**: pending, in_progress, or completed
338
+ - **priority**: high, medium, or low
339
+ - **blocks**: Tasks waiting on this one to complete
340
+ - **blockedBy**: Tasks that must complete before this one can start
341
+
342
+ ## Tips
343
+
344
+ - After fetching a task, verify its blockedBy list is empty before beginning work.
345
+ - Use TaskList to see all tasks in summary form.`,
346
+ parameters: TodoGetParams,
347
+
348
+ async execute(_id, params) {
349
+ const task = store.get(params.id);
350
+ if (!task) {
351
+ return {
352
+ content: [{ type: "text", text: `Task not found` }],
353
+ details: undefined,
354
+ };
355
+ }
356
+
357
+ const desc = task.description?.replace(/\\n/g, "\n") ?? "(no description)";
358
+ const lines: string[] = [
359
+ `Task #${task.id}: ${task.content}`,
360
+ `Status: ${task.status}`,
361
+ `Priority: ${task.priority}`,
362
+ ];
363
+ if (task.owner) lines.push(`Owner: ${task.owner}`);
364
+ lines.push(`Description: ${desc}`);
365
+
366
+ const openBlockers = task.blockedBy.filter((bid) => {
367
+ const b = store.get(bid);
368
+ return b && b.status !== "completed";
369
+ });
370
+ if (openBlockers.length > 0) lines.push(`Blocked by: ${openBlockers.map((id) => `#${id}`).join(", ")}`);
371
+ if (task.blocks.length > 0) lines.push(`Blocks: ${task.blocks.map((id) => `#${id}`).join(", ")}`);
372
+ const metaKeys = Object.keys(task.metadata);
373
+ if (metaKeys.length > 0) lines.push(`Metadata: ${JSON.stringify(task.metadata)}`);
374
+
375
+ return {
376
+ content: [{ type: "text", text: lines.join("\n") }],
377
+ details: { tasks: [task] } satisfies TaskDetails,
378
+ };
379
+ },
380
+
381
+ renderCall(args, theme) {
382
+ return new Text(theme.fg("toolTitle", theme.bold("TaskGet ")) + theme.fg("muted", `#${args.id}`), 0, 0);
383
+ },
384
+
385
+ renderResult(result, { expanded }, theme) {
386
+ return renderTaskListResult((result.details as TaskDetails | undefined)?.tasks ?? [], expanded, theme);
387
+ },
388
+ });
389
+
390
+ // ── Tool: TaskUpdate ───────────────────────────────────────────────────────
391
+ pi.registerTool({
392
+ name: "TaskUpdate",
393
+ label: "TaskUpdate",
394
+ description: `Use this tool to update a task in the task list.
395
+
396
+ ## When to Use This Tool
397
+
398
+ **Before starting work on a task:**
399
+ - Mark it in_progress BEFORE beginning — do not start work without updating status first
400
+
401
+ **Mark tasks as completed:**
402
+ - When you have completed the work described in a task
403
+ - IMPORTANT: Always mark your tasks as completed when you finish them
404
+ - After completing, call TaskList to find your next task
405
+
406
+ - ONLY mark a task as completed when you have FULLY accomplished it
407
+ - If you encounter errors, blockers, or cannot finish, keep the task as in_progress
408
+ - When blocked, create a new task describing what needs to be resolved
409
+ - Never mark a task as completed if:
410
+ - Tests are failing
411
+ - Implementation is partial
412
+ - You encountered unresolved errors
413
+ - You couldn't find necessary files or dependencies
414
+
415
+ **Delete tasks:**
416
+ - When a task is no longer relevant or was created in error
417
+ - Setting status to \`deleted\` permanently removes the task
418
+
419
+ **Update task details:**
420
+ - When requirements change or become clearer
421
+ - When establishing dependencies between tasks
422
+
423
+ ## Fields You Can Update
424
+
425
+ - **status**: pending → in_progress → completed (or deleted to remove)
426
+ - **content**: Change the task title
427
+ - **description**: Change the task description
428
+ - **activeForm**: Spinner text when in_progress (e.g., "Running tests")
429
+ - **priority**: high, medium, or low
430
+ - **owner**: Agent name or owner
431
+ - **metadata**: Merge metadata keys (set a key to null to delete it)
432
+ - **addBlocks**: Task IDs that cannot start until this one completes
433
+ - **addBlockedBy**: Task IDs that must complete before this one can start
434
+
435
+ ## Status Workflow
436
+
437
+ \`pending\` → \`in_progress\` → \`completed\`
438
+
439
+ Use \`deleted\` to permanently remove a task.
440
+
441
+ ## Examples
442
+
443
+ Mark as in progress:
444
+ \`{ "id": "t1", "status": "in_progress" }\`
445
+
446
+ Mark as completed:
447
+ \`{ "id": "t1", "status": "completed" }\`
448
+
449
+ Delete a task:
450
+ \`{ "id": "t1", "status": "deleted" }\`
451
+
452
+ Set dependencies:
453
+ \`{ "id": "t2", "addBlockedBy": ["t1"] }\``,
148
454
  promptGuidelines: [
149
- "Use todo_remove to permanently delete tasks that are no longer needed. " +
150
- "Completed tasks remain visible by defaultonly remove if they clutter the list.",
455
+ "Mark tasks in_progress BEFORE starting work, completed immediately after finishing. Never batch completions.",
456
+ "ONLY mark completed when fully donenot when tests are failing or implementation is partial.",
151
457
  ],
152
- parameters: TodoRemoveParams,
458
+ parameters: TodoUpdateParams,
153
459
 
154
460
  async execute(_id, params, _signal, _onUpdate, ctx) {
155
- const removed = store.removeByIds(params.ids);
156
- updateWidget(ctx);
461
+ const { id, ...fields } = params;
462
+ const { task, changedFields, warnings } = store.update(id, fields as any);
463
+
464
+ if (changedFields.length === 0 && !task) {
465
+ return {
466
+ content: [{ type: "text", text: `Task #${id} not found` }],
467
+ details: { tasks: [...store.list()] } satisfies TaskDetails,
468
+ };
469
+ }
470
+
471
+ if (fields.status === "in_progress") {
472
+ widget.setActiveTask(id);
473
+ autoClear.resetBatchCountdown();
474
+ } else if (fields.status === "pending") {
475
+ autoClear.resetBatchCountdown();
476
+ } else if (fields.status === "completed") {
477
+ widget.setActiveTask(id, false);
478
+ autoClear.trackCompletion(id, currentTurn);
479
+ } else if (fields.status === "deleted") {
480
+ widget.setActiveTask(id, false);
481
+ }
482
+
483
+ widget.setUICtx(ctx.ui);
484
+ widget.update();
485
+
486
+ let msg: string;
487
+ if (changedFields.includes("deleted")) {
488
+ msg = `→ Updated task #${id} deleted`;
489
+ } else {
490
+ msg = `→ Updated task #${id} ${changedFields.join(", ")}`;
491
+ }
492
+ if (warnings.length > 0) msg += ` (warning: ${warnings.join("; ")})`;
157
493
 
158
- if (removed.length === 0) {
494
+ return {
495
+ content: [{ type: "text", text: msg }],
496
+ details: { tasks: [...store.list()] } satisfies TaskDetails,
497
+ };
498
+ },
499
+
500
+ renderCall(args, theme) {
501
+ const id = args.id as string;
502
+ const status = args.status as string | undefined;
503
+ let extra = "";
504
+ if (status) extra = ` ${theme.fg("muted", `→ ${status}`)}`;
505
+ return new Text(theme.fg("toolTitle", theme.bold("TaskUpdate ")) + theme.fg("accent", `#${id}`) + extra, 0, 0);
506
+ },
507
+
508
+ renderResult(result, { expanded }, theme) {
509
+ return TodoViewComponent.renderTaskResult(result.details as TaskDetails | undefined, expanded, theme);
510
+ },
511
+ });
512
+
513
+ // ── Tool: TaskOutput ────────────────────────────────────────────
514
+ pi.registerTool({
515
+ name: "TaskOutput",
516
+ label: "TaskOutput",
517
+ description:
518
+ "Retrieves output from a running or completed background task process.\n" +
519
+ "- Takes a task_id parameter identifying the task\n" +
520
+ "- Returns the task output along with status information\n" +
521
+ "- Use block=true (default) to wait for task completion\n" +
522
+ "- Use block=false for a non-blocking check of current status\n" +
523
+ "- Task IDs can be found using TaskList",
524
+ parameters: Type.Object({
525
+ task_id: Type.String({ description: "The task ID to get output from" }),
526
+ block: Type.Optional(Type.Boolean({ description: "Whether to wait for completion (default: true)" })),
527
+ timeout: Type.Optional(
528
+ Type.Number({
529
+ description: "Max wait time in ms (default: 30000, max: 600000)",
530
+ minimum: 0,
531
+ maximum: 600000,
532
+ }),
533
+ ),
534
+ }),
535
+
536
+ async execute(_id, params, signal) {
537
+ const { task_id, block = true, timeout = 30000 } = params;
538
+ const processOutput = tracker.getOutput(task_id);
539
+
540
+ if (!processOutput) {
541
+ const task = store.get(task_id);
542
+ if (!task) {
543
+ return {
544
+ content: [{ type: "text", text: `No task or process found with ID ${task_id}` }],
545
+ details: undefined,
546
+ };
547
+ }
159
548
  return {
160
- content: [{ type: "text", text: "No matching tasks found." }],
161
- details: { tasks: [...store.tasks] } satisfies TaskDetails,
549
+ content: [{ type: "text", text: `Task #${task_id} [${task.status}] — no background process attached` }],
550
+ details: undefined,
162
551
  };
163
552
  }
164
553
 
554
+ if (block && processOutput.status === "running") {
555
+ const result = await tracker.waitForCompletion(task_id, timeout, signal ?? undefined);
556
+ if (result) {
557
+ return {
558
+ content: [
559
+ {
560
+ type: "text",
561
+ text: `Task #${task_id} (${result.status})${result.exitCode !== undefined ? ` exit ${result.exitCode}` : ""}\n\n${result.output}`,
562
+ },
563
+ ],
564
+ details: undefined,
565
+ };
566
+ }
567
+ }
568
+
165
569
  return {
166
570
  content: [
167
571
  {
168
572
  type: "text",
169
- text: `Removed ${removed.length} task${removed.length !== 1 ? "s" : ""}: ${removed.join(", ")}\n\n${formatListForLLM()}`,
573
+ text: `Task #${task_id} (${processOutput.status})${processOutput.exitCode !== undefined ? ` exit ${processOutput.exitCode}` : ""}\n\n${processOutput.output}`,
170
574
  },
171
575
  ],
172
- details: { tasks: [...store.tasks] } satisfies TaskDetails,
576
+ details: undefined,
173
577
  };
174
578
  },
175
579
 
176
580
  renderCall(args, theme) {
177
- const ids = (args.ids as string[]) ?? [];
178
- const idStr = ids.map((id: string) => theme.fg("accent", id)).join(", ");
179
581
  return new Text(
180
- theme.fg("toolTitle", theme.bold("todo_remove ")) + theme.fg("muted", `remove ${idStr}`),
582
+ theme.fg("toolTitle", theme.bold("TaskOutput ")) + theme.fg("muted", `#${args.task_id}`),
181
583
  0,
182
584
  0,
183
585
  );
184
586
  },
587
+ });
185
588
 
186
- renderResult(result, { expanded }, theme) {
187
- return TodoViewComponent.renderTaskResult(result.details as TaskDetails | undefined, expanded, theme);
589
+ // ── Tool: TaskStop ──────────────────────────────────────────────
590
+ pi.registerTool({
591
+ name: "TaskStop",
592
+ label: "TaskStop",
593
+ description:
594
+ "Stops a running background task process.\n" +
595
+ "- Sends SIGTERM, waits 5 seconds, then SIGKILL if still running\n" +
596
+ "- Marks the task as completed after stopping\n" +
597
+ "- Use this tool when you need to terminate a long-running task",
598
+ parameters: Type.Object({
599
+ task_id: Type.String({ description: "The task ID of the background process to stop" }),
600
+ }),
601
+
602
+ async execute(_id, params, _signal, _onUpdate, ctx) {
603
+ const { task_id } = params;
604
+ const stopped = await tracker.stop(task_id);
605
+
606
+ if (!stopped) {
607
+ const task = store.get(task_id);
608
+ if (!task)
609
+ return {
610
+ content: [{ type: "text", text: `No running background process for task ${task_id}` }],
611
+ details: undefined,
612
+ };
613
+ return {
614
+ content: [
615
+ { type: "text", text: `Task #${task_id} has no running background process (status: ${task.status})` },
616
+ ],
617
+ details: undefined,
618
+ };
619
+ }
620
+
621
+ store.update(task_id, { status: "completed" });
622
+ autoClear.trackCompletion(task_id, currentTurn);
623
+ widget.setActiveTask(task_id, false);
624
+ widget.setUICtx(ctx.ui);
625
+ widget.update();
626
+
627
+ return { content: [{ type: "text", text: `Task #${task_id} stopped successfully` }], details: undefined };
628
+ },
629
+
630
+ renderCall(args, theme) {
631
+ return new Text(theme.fg("toolTitle", theme.bold("TaskStop ")) + theme.fg("accent", `#${args.task_id}`), 0, 0);
188
632
  },
189
633
  });
190
634
 
191
- // ── Command: /todos ────────────────────────────────────────────────────
635
+ // ── Command: /todos ────────────────────────────────────────────────────────
192
636
  pi.registerCommand("todos", {
193
- description: "Open the interactive task viewer",
637
+ description: "Open the interactive task viewer and manager",
194
638
  handler: async (_args, ctx) => {
195
639
  if (!ctx.hasUI) {
196
- ctx.ui.notify(store.tasks.length === 0 ? "No tasks yet." : formatListForLLM(), "info");
640
+ ctx.ui.notify(store.list().length === 0 ? "No tasks yet." : formatListForLLM(store), "info");
197
641
  return;
198
642
  }
199
- await ctx.ui.custom<void>((_tui, theme, _kb, done) => {
200
- return new TodoViewComponent(store.tasks, theme, () => done());
643
+ await openTodosMenu(ctx.ui, store, cfg, cwd, (taskId, status) => {
644
+ if (status === "in_progress") widget.setActiveTask(taskId);
645
+ else if (status) widget.setActiveTask(taskId, false);
646
+ widget.update();
201
647
  });
202
648
  },
203
649
  });