@xvzc/pi-tasks 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,138 @@
1
+ # pi-tasks
2
+
3
+ Local Pi extension for per-main-session task tracking.
4
+
5
+ ## Stores
6
+
7
+ One file per main session: `<cwd>/.pi/tasks/tasks-{sanitizedSessionId}.json`
8
+ with envelope `{ version: 1, nextId, tasks, totalActiveMs, activeSince? }`. Session IDs are encoded as a
9
+ filename-safe readable prefix plus a deterministic SHA-256 digest. IDs allocate monotonically from
10
+ `nextId` and deleted IDs are never reused. Writes are atomic (temp file +
11
+ rename).
12
+
13
+ Timing state persists in the same envelope so reloads neither reset running
14
+ timers nor count idle gaps: each task carries `startedAt?` (start of the
15
+ current `in_progress` attempt) and `tookMs?` (frozen duration of the last
16
+ completed attempt); the envelope carries `totalActiveMs` (finished
17
+ wall-clock union time while at least one task was `in_progress`) and
18
+ `activeSince?` (start of the running period, if any). Reloading preserves
19
+ running values; legacy files without timing start counting from load time
20
+ (a legacy `in_progress` task without `startedAt` starts its attempt at the
21
+ load timestamp, matching the global `activeSince` load-time behavior)
22
+ and legacy completed tasks fall back to frozen `createdAt`→`updatedAt` spans.
23
+
24
+ Turn lifecycle (`turn_start`, initialized from `ctx.sessionManager.getSessionId()`
25
+ and the current cwd): never deletes anything, so an all-completed list stays
26
+ on disk and visible across turns. `TaskCreate` atomically resets first: if the
27
+ store holds ≥ 1 tasks that are all completed, the new task is validated
28
+ against a fresh state and committed as task #1 in a single temp-file+rename
29
+ write that replaces the old envelope. Failed validation or persistence leaves
30
+ the old completed file untouched. A list with any pending/in-progress task is
31
+ kept and appended with the existing `nextId`.
32
+
33
+ Note: concurrent writers across processes rely on the atomic rename but take
34
+ no interprocess lock; overlapping writes may lose one update (last rename
35
+ wins). This is a pre-existing limitation.
36
+
37
+ ## Tools
38
+
39
+ Exactly five tools; no convenience, dependency, or subagent tools:
40
+
41
+ - `TaskCreate { subject, description, assignee?, color?, blockedBy?, metadata?, maxAttempts? }`
42
+ creates a `pending` task with `attempt` 0 and `maxAttempts` defaulting to 9.
43
+ `blockedBy` entries must exist (`TaskUpdate` follows the same rule).
44
+ `maxAttempts` is set once at creation and cannot be updated later.
45
+ - `TaskUpdate { id, subject?, description?, assignee?|null, color?|null, status?, blockedBy?, metadata?, appendLog? }`
46
+ patches a task. `metadata` shallow-merges; `appendLog` accepts a string and
47
+ adds `{ timestamp, message }` to the task's append-only `log`. It is intended for rework,
48
+ validation, blocker, and handoff notes instead of rewriting `description`.
49
+ `assignee: null` / `color: null` remove those fields; `blockedBy` replaces the whole list; every successful
50
+ update refreshes `updatedAt` and never touches `createdAt`. Entering
51
+ `in_progress` increments `attempt` and requires all dependencies completed;
52
+ entry is refused once `attempt` reaches `maxAttempts`. The per-attempt timer
53
+ (`startedAt`) starts at zero only on a real non-`in_progress` →
54
+ `in_progress` transition and is preserved by `in_progress` → `in_progress`
55
+ updates. Transitioning to `completed` freezes the attempt into `tookMs`;
56
+ rework clears it so the new attempt starts at zero and the next completion
57
+ overwrites it. `attempt` and
58
+ `maxAttempts` cannot be updated directly. Completed tasks may
59
+ return to `pending`.
60
+ - `TaskGet { id }` returns one task as JSON.
61
+ - `TaskList { status? }` returns tasks (optionally filtered) as JSON.
62
+ - `TaskDelete { id }` deletes a task; refused while other tasks depend on it.
63
+ Deleting the final remaining task resets the global union timing to
64
+ `totalActiveMs` 0 with no `activeSince` (IDs and `nextId` are preserved);
65
+ deleting while tasks remain keeps the current union timing behavior.
66
+
67
+ Dependency invariants everywhere: references must exist, no self-reference,
68
+ no cycles. Invalid and not-found operations return clear error tool results.
69
+
70
+ Bulk `clearCompleted`/`clearAll` commit in a single temp-file+rename write
71
+ (never repeated deletes): `clearCompleted` also strips the removed completed
72
+ IDs from remaining tasks' `blockedBy` lists; `clearAll` resets active timing
73
+ while preserving `nextId`. Either is a no-op without writing when there is
74
+ nothing to remove, and persistence failure leaves in-memory state unchanged.
75
+
76
+ ## Command
77
+
78
+ `/tasks` loads the current session store and shows an inline `Tasks` selector
79
+ with live counts:
80
+
81
+ - `View all tasks (N)` — centered overlay with the task list on the left and
82
+ full details for the selected task on the right (status, id/attempts,
83
+ assignee, description, blockedBy, timestamps/timing, metadata, log). The
84
+ outer border renders in the theme's `border` color (plain when theming is
85
+ unavailable). Keys: the configured `tui.editor.cursorUp` /
86
+ `tui.editor.cursorDown` bindings change selection, PageUp/PageDown scrolls
87
+ details, Escape or Ctrl+C closes. Terminal-only: other modes get a
88
+ notification instead.
89
+ - `Clear completed (M)` — confirms, then atomically removes all completed
90
+ tasks (see above).
91
+ - `Clear all (N)` — confirms, then atomically removes all tasks (see above).
92
+
93
+ Zero-count clears notify and do nothing; declining a confirmation cancels
94
+ cleanly. Successful clears refresh (or remove, when empty) the persistent
95
+ widget immediately and notify; failures preserve state and report an error.
96
+ There is no Create-task command: tasks are created via `TaskCreate` only.
97
+
98
+ ## Widget
99
+
100
+ A persistent `tasks` widget renders numeric ID, the `(<attempt>/<maxAttempts>)`
101
+ counter, optional `[assignee]`,
102
+ subject, and per-attempt timing with a filled `■` status glyph for pending,
103
+ in-progress, and completed tasks. Pending glyphs always render gray, even when
104
+ `color` is set; in-progress and
105
+ completed glyphs render green by default. Subjects render in the default text
106
+ color (white) while pending, green and bold while in progress, and gray with a
107
+ strikethrough when completed. The optional `[assignee]` always uses the same
108
+ color, bold weight, and strikethrough decoration as the subject. Optional task
109
+ `color` maps onto known theme accents for the in-progress and completed
110
+ status glyphs only. `in_progress` lines append the running attempt duration
111
+ from `startedAt` to now (`0s` at zero); `completed` lines append only the frozen
112
+ `<duration>` (`0s` when zero); `pending` lines show no duration.
113
+ The header shows the total count and only the done count (`● N task(s)
114
+ (M done)`, including `(0 done)`). After any task has entered `in_progress`, it
115
+ also shows the global accumulated active (wall-clock union) time (`0s` when the
116
+ measured total is still below one second); before then, the total is omitted.
117
+ The total runs while at least one task is `in_progress`, excludes idle gaps
118
+ without double-counting concurrency, and resumes across restarts and rework.
119
+ Themed total, elapsed, and completed duration text renders dim/gray, and the plain fallback
120
+ includes the same text without styling.
121
+ Pure rendering accepts an explicit current time (plus optional union timing)
122
+ for deterministic output. The
123
+ in-progress glyph blinks by alternating
124
+ with a same-width blank every 250 ms; the blink timer runs only while an
125
+ in-progress task is shown. A separate 1 s timer requests redraws only while
126
+ an in-progress task is shown so elapsed text stays current. All timers stop when the widget is replaced or removed.
127
+ Rendering
128
+ is presentational only (`src/widget.ts` never touches store state).
129
+
130
+ ## Subagents
131
+
132
+ The tools register in the host/main Pi extension context only. There is no
133
+ shared or subagent store; subagents must not use these tools.
134
+
135
+ ## Checks
136
+
137
+ - `npm test` — Vitest suite (store, lifecycle, widget, tools).
138
+ - `npm run typecheck` — `tsc --noEmit`.
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@xvzc/pi-tasks",
3
+ "version": "1.0.0",
4
+ "description": "Local Pi extension for per-session task tracking with dependency invariants and a persistent task widget.",
5
+ "type": "module",
6
+ "files": [
7
+ "src"
8
+ ],
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/xvzc/pi-tasks.git"
12
+ },
13
+ "publishConfig": {
14
+ "access": "public"
15
+ },
16
+ "scripts": {
17
+ "commitlint": "commitlint",
18
+ "test": "vitest run",
19
+ "typecheck": "tsc --noEmit"
20
+ },
21
+ "pi": {
22
+ "extensions": [
23
+ "./src/index.ts"
24
+ ]
25
+ },
26
+ "peerDependencies": {
27
+ "@earendil-works/pi-coding-agent": "*"
28
+ },
29
+ "dependencies": {
30
+ "@earendil-works/pi-tui": "^0.85.1",
31
+ "typebox": "^1.1.24"
32
+ },
33
+ "devDependencies": {
34
+ "@commitlint/cli": "^21.2.2",
35
+ "@commitlint/config-conventional": "^21.2.2",
36
+ "@types/node": "^22.0.0",
37
+ "typescript": "^7.0.2",
38
+ "vitest": "^4.0.18"
39
+ }
40
+ }
package/src/index.ts ADDED
@@ -0,0 +1,353 @@
1
+ /**
2
+ * pi-tasks — per-main-session task tracking for Pi.
3
+ *
4
+ * Tools (exactly these five; no convenience, dependency, or subagent tools):
5
+ * TaskCreate, TaskUpdate, TaskGet, TaskList, TaskDelete
6
+ *
7
+ * The tools are registered by the host/main Pi extension context only. This
8
+ * extension implements no shared or subagent stores: every store is a
9
+ * per-main-session file under `<cwd>/.pi/tasks/`, and subagents must not use
10
+ * these tools.
11
+ */
12
+
13
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
14
+ import { Type, type Static } from "typebox";
15
+ import { TaskError, TaskStore, taskFilePath, turnStartStore } from "./store.js";
16
+ import { createTasksViewer } from "./tasks-ui.js";
17
+ import { isTaskStatus, type Task } from "./types.js";
18
+ import { createTaskWidget, type ThemeLike, type TuiWidthLike } from "./widget.js";
19
+
20
+ const WIDGET_KEY = "tasks";
21
+
22
+ const TaskId = Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER, description: "Numeric task ID." });
23
+ const BlockedBy = Type.Array(TaskId, { description: "Task IDs this task depends on." });
24
+ const Metadata = Type.Record(Type.String(), Type.Unknown(), { description: "Free-form metadata object." });
25
+ const Status = Type.Union([Type.Literal("pending"), Type.Literal("in_progress"), Type.Literal("completed")]);
26
+
27
+ const TaskCreateParams = Type.Object({
28
+ subject: Type.String({ description: "Short task title." }),
29
+ description: Type.String({ description: "Longer task detail." }),
30
+ assignee: Type.Optional(Type.String({ description: "Assigned agent type shown as [assignee]." })),
31
+ color: Type.Optional(Type.String({ description: "Accent color name for the status glyph. Do not set unless the user explicitly requests a color." })),
32
+ blockedBy: Type.Optional(BlockedBy),
33
+ metadata: Type.Optional(Metadata),
34
+ maxAttempts: Type.Optional(Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER, description: "Per-task attempt cap. Defaults to 9." })),
35
+ });
36
+
37
+ const TaskUpdateParams = Type.Object({
38
+ id: TaskId,
39
+ subject: Type.Optional(Type.String({ description: "New title." })),
40
+ description: Type.Optional(Type.String({ description: "New detail text. Do not use for progress, rework, validation, blocker, or handoff notes; use appendLog instead." })),
41
+ assignee: Type.Optional(Type.Union([Type.String(), Type.Null()], { description: "Assigned agent type shown as [assignee], or null to remove it." })),
42
+ color: Type.Optional(Type.Union([Type.String(), Type.Null()], { description: "New status glyph color, or null to remove it. Do not set or change it unless the user explicitly requests a color." })),
43
+ status: Type.Optional(Status),
44
+ blockedBy: Type.Optional(BlockedBy),
45
+ metadata: Type.Optional(Metadata),
46
+ appendLog: Type.Optional(Type.String({ minLength: 1, description: "Reason for the status change." })),
47
+ });
48
+
49
+ const TaskGetParams = Type.Object({
50
+ id: TaskId,
51
+ });
52
+
53
+ const TaskListParams = Type.Object({
54
+ status: Type.Optional(Status),
55
+ });
56
+
57
+ const TaskDeleteParams = Type.Object({
58
+ id: TaskId,
59
+ });
60
+
61
+ type ErrorResult = {
62
+ content: [{ type: "text"; text: string }];
63
+ details: undefined;
64
+ isError: true;
65
+ };
66
+
67
+ function textResult(text: string) {
68
+ return { content: [{ type: "text" as const, text }], details: undefined as never };
69
+ }
70
+
71
+ function errorResult(text: string): ErrorResult {
72
+ return { content: [{ type: "text" as const, text }], details: undefined, isError: true as const };
73
+ }
74
+
75
+ function invalidIdResult(id: unknown): ErrorResult {
76
+ return errorResult(`Invalid task id: ${String(id)}. Expected a positive safe integer.`);
77
+ }
78
+
79
+ const PREFIX_RENAMED_MESSAGE = "`prefix` was renamed to `assignee`; use `assignee`.";
80
+
81
+ function hasPrefixParam(params: Record<string, unknown>): boolean {
82
+ return "prefix" in params;
83
+ }
84
+
85
+ function isValidId(id: unknown): id is number {
86
+ return typeof id === "number" && Number.isSafeInteger(id) && id > 0;
87
+ }
88
+
89
+ /** Minimal UI surface used for the persistent task widget. */
90
+ interface WidgetUI {
91
+ setWidget(key: string, content: unknown, options?: unknown): void;
92
+ }
93
+
94
+ function refreshWidget(ctx: ExtensionContext, store: TaskStore): void {
95
+ try {
96
+ const ui = ctx.ui as unknown as WidgetUI;
97
+ if (!ui || typeof ui.setWidget !== "function") return;
98
+ const tasks = store.list();
99
+ if (tasks.length === 0) {
100
+ ui.setWidget(WIDGET_KEY, undefined);
101
+ return;
102
+ }
103
+ const snapshot = tasks;
104
+ const timing = store.activeTiming();
105
+ ui.setWidget(
106
+ WIDGET_KEY,
107
+ (tui: unknown, theme: ThemeLike) => createTaskWidget(snapshot, tui as TuiWidthLike, theme, undefined, timing),
108
+ { placement: "aboveEditor" },
109
+ );
110
+ } catch {
111
+ // Widget updates must never break tool execution or turn handling.
112
+ }
113
+ }
114
+
115
+ function sessionIdOf(ctx: ExtensionContext): string {
116
+ return ctx.sessionManager.getSessionId();
117
+ }
118
+
119
+ /** Centered overlay sizing for the `/tasks` viewer. */
120
+ export const TASKS_OVERLAY_OPTIONS = {
121
+ width: "80%",
122
+ maxHeight: "80%",
123
+ anchor: "center",
124
+ } as const;
125
+
126
+ /** Inline `/tasks` menu labels with live counts. */
127
+ export function tasksMenuLabels(tasks: Task[]): [string, string, string] {
128
+ const completed = tasks.filter((task) => task.status === "completed").length;
129
+ return [`View all tasks (${tasks.length})`, `Clear completed (${completed})`, `Clear all (${tasks.length})`];
130
+ }
131
+
132
+ export default function (pi: ExtensionAPI) {
133
+ pi.on("turn_start", async (_event, ctx) => {
134
+ const { store } = await turnStartStore(ctx.cwd, sessionIdOf(ctx));
135
+ refreshWidget(ctx, store);
136
+ });
137
+
138
+ pi.registerCommand("tasks", {
139
+ description: "View and clear session tasks.",
140
+ handler: async (_args, ctx) => {
141
+ const store = await TaskStore.load(taskFilePath(ctx.cwd, sessionIdOf(ctx)));
142
+ const [viewLabel, clearCompletedLabel, clearAllLabel] = tasksMenuLabels(store.list());
143
+ const choice = await ctx.ui.select("Tasks", [viewLabel, clearCompletedLabel, clearAllLabel]);
144
+ if (choice === undefined) return;
145
+ if (choice === viewLabel) {
146
+ if (ctx.mode !== "tui") {
147
+ ctx.ui.notify("Task viewer requires an interactive terminal session.", "warning");
148
+ return;
149
+ }
150
+ try {
151
+ await ctx.ui.custom<void>(
152
+ (tui, theme, keybindings, done) =>
153
+ createTasksViewer(store.list(), { done: () => done(undefined as never), theme, tui, keybindings }),
154
+ { overlay: true, overlayOptions: { ...TASKS_OVERLAY_OPTIONS } },
155
+ );
156
+ } catch (error) {
157
+ ctx.ui.notify(`Task viewer failed: ${error instanceof Error ? error.message : String(error)}.`, "error");
158
+ }
159
+ return;
160
+ }
161
+ if (choice === clearCompletedLabel) {
162
+ const completed = store.list("completed").length;
163
+ if (completed === 0) {
164
+ ctx.ui.notify("No completed tasks to clear.");
165
+ return;
166
+ }
167
+ const confirmed = await ctx.ui.confirm(
168
+ "Clear completed tasks",
169
+ `Delete ${completed} completed task(s)? This cannot be undone.`,
170
+ );
171
+ if (!confirmed) return;
172
+ try {
173
+ const removed = await store.clearCompleted();
174
+ refreshWidget(ctx, store);
175
+ ctx.ui.notify(`Cleared ${removed.length} completed task(s).`);
176
+ } catch (error) {
177
+ ctx.ui.notify(
178
+ `Failed to clear completed tasks: ${error instanceof Error ? error.message : String(error)}.`,
179
+ "error",
180
+ );
181
+ }
182
+ return;
183
+ }
184
+ if (choice === clearAllLabel) {
185
+ const total = store.list().length;
186
+ if (total === 0) {
187
+ ctx.ui.notify("No tasks to clear.");
188
+ return;
189
+ }
190
+ const confirmed = await ctx.ui.confirm(
191
+ "Clear all tasks",
192
+ `Delete all ${total} task(s)? This cannot be undone.`,
193
+ );
194
+ if (!confirmed) return;
195
+ try {
196
+ const removed = await store.clearAll();
197
+ refreshWidget(ctx, store);
198
+ ctx.ui.notify(`Cleared all ${removed} task(s).`);
199
+ } catch (error) {
200
+ ctx.ui.notify(
201
+ `Failed to clear tasks: ${error instanceof Error ? error.message : String(error)}.`,
202
+ "error",
203
+ );
204
+ }
205
+ }
206
+ },
207
+ });
208
+
209
+ pi.registerTool({
210
+ name: "TaskCreate",
211
+ label: "Create task",
212
+ description: "Create a task in the current session's task list. Dependencies in blockedBy must already exist.",
213
+ promptSnippet: "Track multi-step work with the TaskCreate/TaskUpdate/TaskGet/TaskList/TaskDelete tools.",
214
+ parameters: TaskCreateParams,
215
+ executionMode: "sequential",
216
+ async execute(_toolCallId, params: Static<typeof TaskCreateParams>, _signal, _onUpdate, ctx) {
217
+ if (hasPrefixParam(params as unknown as Record<string, unknown>)) {
218
+ return errorResult(PREFIX_RENAMED_MESSAGE);
219
+ }
220
+ const filePath = taskFilePath(ctx.cwd, sessionIdOf(ctx));
221
+ const store = await TaskStore.load(filePath);
222
+ try {
223
+ const task = await store.create({
224
+ subject: params.subject,
225
+ description: params.description,
226
+ assignee: params.assignee,
227
+ color: params.color,
228
+ blockedBy: params.blockedBy,
229
+ metadata: params.metadata as Record<string, unknown> | undefined,
230
+ maxAttempts: params.maxAttempts,
231
+ });
232
+ refreshWidget(ctx, store);
233
+ return textResult(JSON.stringify(task, null, 2));
234
+ } catch (error) {
235
+ return errorResult(error instanceof TaskError ? error.message : String(error));
236
+ }
237
+ },
238
+ });
239
+
240
+ pi.registerTool({
241
+ name: "TaskUpdate",
242
+ label: "Update task",
243
+ description:
244
+ "Patch a task by numeric ID. metadata shallow-merges; appendLog appends a timestamped execution note and should be used instead of rewriting description for rework, validation, blockers, or handoffs. assignee/color accept null to remove. Entering in_progress requires completed dependencies.",
245
+ parameters: TaskUpdateParams,
246
+ executionMode: "sequential",
247
+ async execute(_toolCallId, params: Static<typeof TaskUpdateParams>, _signal, _onUpdate, ctx) {
248
+ if (!isValidId(params.id)) return invalidIdResult(params.id);
249
+ if ("attempt" in (params as Record<string, unknown>)) {
250
+ return errorResult("attempt cannot be updated; it increments only on entry into in_progress.");
251
+ }
252
+ if ("maxAttempts" in (params as Record<string, unknown>)) {
253
+ return errorResult("maxAttempts cannot be updated; it is set once at creation.");
254
+ }
255
+ if (hasPrefixParam(params as unknown as Record<string, unknown>)) {
256
+ return errorResult(PREFIX_RENAMED_MESSAGE);
257
+ }
258
+ const store = await TaskStore.load(taskFilePath(ctx.cwd, sessionIdOf(ctx)));
259
+ try {
260
+ const status = params.status !== undefined && isTaskStatus(params.status) ? params.status : undefined;
261
+ if (params.status !== undefined && status === undefined) {
262
+ return errorResult(`Invalid status: ${String(params.status)}.`);
263
+ }
264
+ const previousStatus = store.get(params.id)?.status;
265
+ const updated = await store.update(params.id, {
266
+ subject: params.subject,
267
+ description: params.description,
268
+ assignee: params.assignee,
269
+ color: params.color,
270
+ status,
271
+ blockedBy: params.blockedBy,
272
+ metadata: params.metadata as Record<string, unknown> | undefined,
273
+ appendLog: params.appendLog,
274
+ });
275
+ refreshWidget(ctx, store);
276
+ if (previousStatus !== "in_progress" && updated.status === "in_progress" && updated.attempt === updated.maxAttempts) {
277
+ const warning = `Task #${updated.id} is running its final attempt (${updated.attempt}/${updated.maxAttempts}). No retries remain after this run.`;
278
+ try {
279
+ pi.sendMessage(
280
+ {
281
+ customType: "pi-tasks-final-attempt",
282
+ content: warning,
283
+ display: false,
284
+ },
285
+ { deliverAs: "steer", triggerTurn: false },
286
+ );
287
+ } catch {
288
+ // Context injection failures must not fail a persisted update.
289
+ }
290
+ try {
291
+ const notify = (ctx.ui as unknown as { notify?: unknown }).notify;
292
+ if (typeof notify === "function") {
293
+ (notify as (message: string, level: string) => unknown).call(ctx.ui, warning, "warning");
294
+ }
295
+ } catch {
296
+ // Notification failures must not fail a persisted update.
297
+ }
298
+ }
299
+ return textResult(JSON.stringify(params, null, 2));
300
+ } catch (error) {
301
+ return errorResult(error instanceof TaskError ? error.message : String(error));
302
+ }
303
+ },
304
+ });
305
+
306
+ pi.registerTool({
307
+ name: "TaskGet",
308
+ label: "Get task",
309
+ description: "Return the full details of one task by numeric ID.",
310
+ parameters: TaskGetParams,
311
+ async execute(_toolCallId, params: Static<typeof TaskGetParams>, _signal, _onUpdate, _ctx) {
312
+ if (!isValidId(params.id)) return invalidIdResult(params.id);
313
+ const store = await TaskStore.load(taskFilePath(_ctx.cwd, sessionIdOf(_ctx)));
314
+ const task = store.get(params.id);
315
+ if (!task) return errorResult(`Task #${params.id} does not exist.`);
316
+ return textResult(JSON.stringify(task, null, 2));
317
+ },
318
+ });
319
+
320
+ pi.registerTool({
321
+ name: "TaskList",
322
+ label: "List tasks",
323
+ description: "List tasks in the current session's task list, optionally filtered by status.",
324
+ parameters: TaskListParams,
325
+ async execute(_toolCallId, params: Static<typeof TaskListParams>, _signal, _onUpdate, ctx) {
326
+ const status = params.status !== undefined && isTaskStatus(params.status) ? params.status : undefined;
327
+ if (params.status !== undefined && status === undefined) {
328
+ return errorResult(`Invalid status: ${String(params.status)}.`);
329
+ }
330
+ const store = await TaskStore.load(taskFilePath(ctx.cwd, sessionIdOf(ctx)));
331
+ return textResult(JSON.stringify(store.list(status), null, 2));
332
+ },
333
+ });
334
+
335
+ pi.registerTool({
336
+ name: "TaskDelete",
337
+ label: "Delete task",
338
+ description: "Delete a task by numeric ID. Refused while other tasks depend on it.",
339
+ parameters: TaskDeleteParams,
340
+ executionMode: "sequential",
341
+ async execute(_toolCallId, params: Static<typeof TaskDeleteParams>, _signal, _onUpdate, ctx) {
342
+ if (!isValidId(params.id)) return invalidIdResult(params.id);
343
+ const store = await TaskStore.load(taskFilePath(ctx.cwd, sessionIdOf(ctx)));
344
+ try {
345
+ await store.delete(params.id);
346
+ refreshWidget(ctx, store);
347
+ return textResult(JSON.stringify(params, null, 2));
348
+ } catch (error) {
349
+ return errorResult(error instanceof TaskError ? error.message : String(error));
350
+ }
351
+ },
352
+ });
353
+ }