@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 +138 -0
- package/package.json +40 -0
- package/src/index.ts +353 -0
- package/src/store.ts +763 -0
- package/src/tasks-ui.ts +228 -0
- package/src/types.ts +57 -0
- package/src/widget.ts +341 -0
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
|
+
}
|