@structupath/pi-tilldone 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 StructuPath LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,52 @@
1
+ # pi-tilldone
2
+
3
+ Task discipline for the [Pi coding agent](https://pi.dev): the agent **must declare what it's going to do before it's allowed to do it** — and gets nudged until it's actually done.
4
+
5
+ "todo" → "tilldone": work *till* it's *done*.
6
+
7
+ By [StructuPath](https://structupath.ai).
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pi install npm:@structupath/pi-tilldone
13
+ ```
14
+
15
+ ## Why
16
+
17
+ Todo-list extensions record what the agent plans to do. TillDone **enforces** it:
18
+
19
+ - **Hard gate** — write/execute tools are blocked until the agent creates a task list and marks a task in-progress. Read-only tools (read, grep, find, ls, glob) stay open so it can explore before planning.
20
+ - **Single focus** — only one task can be in-progress at a time; toggling a new one auto-pauses the old one.
21
+ - **Completion nudge** — if the agent stops with incomplete tasks, it gets pushed back to work: *"Don't stop until it's done!"* (once per user turn, so it can't loop forever).
22
+ - **Survives everything** — state is reconstructed from the session on start, switch, fork, and tree navigation.
23
+
24
+ ## UI
25
+
26
+ - **Footer** — live task list with title, progress `[done/total]`, and per-status counts
27
+ - **Widget** — prominent `● WORKING ON #3 <task>` banner below the editor
28
+ - **Status line** — compact summary
29
+ - **/tilldone** — full-screen overlay with every task
30
+
31
+ The agent drives it through a single `tilldone` tool: `new-list`, `add` (single or batch), `toggle` (cycles idle → inprogress → done), `update`, `remove`, `list`, `clear`. Destructive actions (`new-list` over an existing list, `clear`) ask **you** to confirm first.
32
+
33
+ ## Bonus: cmux integration
34
+
35
+ If you run Pi inside [cmux](https://cmux.io), the bundled `cmux-status` extension syncs task state to your workspace tabs — at a glance, across every parallel agent:
36
+
37
+ | Tab color | Meaning |
38
+ |---|---|
39
+ | 🟡 yellow | working — a task is in progress |
40
+ | 🟢 green | all tasks done (+ desktop notification) |
41
+ | 🟠 orange | blocked — tasks exist, none in progress |
42
+ | ⚪ gray | idle — no tasks yet |
43
+
44
+ Tab title shows `⚡ <list> [2/5]`, tab description shows the current task. Outside cmux it no-ops silently — zero overhead.
45
+
46
+ ## License
47
+
48
+ MIT
49
+
50
+ ---
51
+
52
+ More Pi packages from StructuPath: [`@structupath/pi-steel`](https://pi.dev/packages/@structupath/pi-steel) — structural steel estimating skills.
@@ -0,0 +1,7 @@
1
+ [INFO] 2026-07-20T01:12:06.965Z handleHookRunStop started
2
+ [INFO] 2026-07-20T01:12:06.966Z Hook input received
3
+ [INFO] 2026-07-20T01:12:06.966Z [session=a8a94862-1ef3-468c-a826-a2661f015a52] Session ID: a8a94862-1ef3-468c-a826-a2661f015a52
4
+ [INFO] 2026-07-20T01:12:06.966Z [session=a8a94862-1ef3-468c-a826-a2661f015a52] Resolved pluginRoot: /Users/vics/.claude/plugins/cache/a5c-ai/babysitter/4.0.157
5
+ [INFO] 2026-07-20T01:12:06.966Z [session=a8a94862-1ef3-468c-a826-a2661f015a52] Resolved stateDir: /Users/vics/.a5c/state
6
+ [INFO] 2026-07-20T01:12:06.966Z [session=a8a94862-1ef3-468c-a826-a2661f015a52] Checking session file at: /Users/vics/.a5c/state/a8a94862-1ef3-468c-a826-a2661f015a52.md
7
+ [INFO] 2026-07-20T01:12:06.966Z [session=a8a94862-1ef3-468c-a826-a2661f015a52] No active loop found for session a8a94862-1ef3-468c-a826-a2661f015a52 — allowing exit
@@ -0,0 +1,205 @@
1
+ /**
2
+ * cmux Status Extension — Colored Tabs & Progress
3
+ *
4
+ * Syncs agent task state to cmux workspace visuals:
5
+ * - Tab COLOR reflects status (idle/working/done/error)
6
+ * - Tab TITLE shows progress (emoji + task count)
7
+ * - Tab DESCRIPTION shows the current in-progress task
8
+ *
9
+ * Hooks into tilldone tool calls and agent lifecycle events.
10
+ * Only activates when running inside cmux (CMUX_WORKSPACE_ID is set).
11
+ *
12
+ * Usage: installed with @structupath/pi-tilldone; no-ops outside cmux
13
+ */
14
+
15
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
16
+
17
+ // ── Color Palette ──────────────────────────────────────────────────────
18
+
19
+ const COLORS = {
20
+ idle: "#6b7280", // gray — no tasks yet
21
+ working: "#eab308", // yellow — tasks in progress
22
+ done: "#22c55e", // green — all tasks complete
23
+ error: "#ef4444", // red — something went wrong
24
+ blocked: "#f97316", // orange — tasks exist but none in progress
25
+ } as const;
26
+
27
+ const STATUS_EMOJI = {
28
+ idle: "💤",
29
+ working: "⚡",
30
+ done: "✅",
31
+ error: "❌",
32
+ blocked: "⏸️",
33
+ } as const;
34
+
35
+ // ── Helpers ────────────────────────────────────────────────────────────
36
+
37
+ function isCmux(): boolean {
38
+ return !!process.env.CMUX_WORKSPACE_ID;
39
+ }
40
+
41
+ async function cmux(pi: ExtensionAPI, ...args: string[]): Promise<string> {
42
+ try {
43
+ const { stdout } = await pi.exec("cmux", args);
44
+ return stdout.trim();
45
+ } catch {
46
+ return "";
47
+ }
48
+ }
49
+
50
+ // ── State ──────────────────────────────────────────────────────────────
51
+
52
+ let originalTitle = "";
53
+ let originalTitleCaptured = false;
54
+
55
+ type Status = keyof typeof COLORS;
56
+
57
+ interface TaskSnapshot {
58
+ total: number;
59
+ done: number;
60
+ inprogress: number;
61
+ idle: number;
62
+ currentTask: string;
63
+ listTitle: string;
64
+ }
65
+
66
+ function deriveStatus(snap: TaskSnapshot): Status {
67
+ if (snap.total === 0) return "idle";
68
+ if (snap.done === snap.total) return "done";
69
+ if (snap.inprogress > 0) return "working";
70
+ if (snap.idle > 0) return "blocked";
71
+ return "idle";
72
+ }
73
+
74
+ function buildTitle(snap: TaskSnapshot, status: Status): string {
75
+ const emoji = STATUS_EMOJI[status];
76
+ if (snap.total === 0) return originalTitle || `${emoji} Ready`;
77
+
78
+ const progress = `${snap.done}/${snap.total}`;
79
+ const label = snap.listTitle || "Tasks";
80
+ return `${emoji} ${label} [${progress}]`;
81
+ }
82
+
83
+ function buildDescription(snap: TaskSnapshot, status: Status): string {
84
+ if (status === "done") return `All ${snap.total} tasks complete ✓`;
85
+ if (status === "idle") return "";
86
+ if (status === "blocked") return "Waiting — no task in progress";
87
+ return snap.currentTask ? `► ${snap.currentTask}` : "Working...";
88
+ }
89
+
90
+ // ── Tilldone tool result details (shared shape with tilldone.ts) ───────
91
+
92
+ interface TillDoneTask {
93
+ id: number;
94
+ text: string;
95
+ status: "idle" | "inprogress" | "done";
96
+ }
97
+
98
+ interface TillDoneDetails {
99
+ tasks: TillDoneTask[];
100
+ listTitle?: string;
101
+ }
102
+
103
+ function snapshotFromDetails(details: TillDoneDetails): TaskSnapshot {
104
+ const tasks = details.tasks;
105
+ return {
106
+ total: tasks.length,
107
+ done: tasks.filter((t) => t.status === "done").length,
108
+ inprogress: tasks.filter((t) => t.status === "inprogress").length,
109
+ idle: tasks.filter((t) => t.status === "idle").length,
110
+ currentTask: tasks.find((t) => t.status === "inprogress")?.text ?? "",
111
+ listTitle: details.listTitle ?? "",
112
+ };
113
+ }
114
+
115
+ // ── Main Extension ─────────────────────────────────────────────────────
116
+
117
+ export default function (pi: ExtensionAPI) {
118
+ if (!isCmux()) return; // silently skip if not in cmux
119
+
120
+ let lastSnap: TaskSnapshot = {
121
+ total: 0, done: 0, inprogress: 0, idle: 0,
122
+ currentTask: "", listTitle: "",
123
+ };
124
+ let lastStatus: Status = "idle";
125
+
126
+ async function captureOriginalTitle() {
127
+ if (originalTitleCaptured) return;
128
+ originalTitleCaptured = true;
129
+ // Get current workspace name before we modify it
130
+ const tree = await cmux(pi, "current-workspace");
131
+ const match = tree.match(/workspace:\d+\s+(.+?)(?:\s+\[selected\])?$/m);
132
+ if (match) {
133
+ originalTitle = match[1].trim();
134
+ }
135
+ }
136
+
137
+ async function updateCmux(snap: TaskSnapshot) {
138
+ const status = deriveStatus(snap);
139
+ const title = buildTitle(snap, status);
140
+ const desc = buildDescription(snap, status);
141
+
142
+ // Only update if something changed
143
+ const statusChanged = status !== lastStatus;
144
+ const snapChanged = JSON.stringify(snap) !== JSON.stringify(lastSnap);
145
+ if (!statusChanged && !snapChanged) return;
146
+
147
+ lastSnap = { ...snap };
148
+ lastStatus = status;
149
+
150
+ // Fire all updates in parallel
151
+ const updates: Promise<string>[] = [
152
+ cmux(pi, "workspace-action", "--action", "set-color", "--color", COLORS[status]),
153
+ cmux(pi, "rename-workspace", title),
154
+ ];
155
+ if (desc) {
156
+ updates.push(cmux(pi, "workspace-action", "--action", "set-description", "--description", desc));
157
+ } else {
158
+ updates.push(cmux(pi, "workspace-action", "--action", "clear-description"));
159
+ }
160
+
161
+ await Promise.all(updates);
162
+ }
163
+
164
+ // ── Hooks ──────────────────────────────────────────────────────────
165
+
166
+ pi.on("session_start", async () => {
167
+ await captureOriginalTitle();
168
+ });
169
+
170
+ // After every tilldone tool call, refresh cmux state from the
171
+ // structured details the tilldone tool attaches to its result
172
+ pi.on("tool_result", async (event) => {
173
+ await captureOriginalTitle();
174
+
175
+ if (event.toolName !== "tilldone") return;
176
+
177
+ const details = event.details as TillDoneDetails | undefined;
178
+ if (!details?.tasks) return;
179
+
180
+ await updateCmux(snapshotFromDetails(details));
181
+ });
182
+
183
+ // On agent end, do a final status update from the last known state
184
+ pi.on("agent_end", async () => {
185
+ if (lastSnap.total > 0) {
186
+ await updateCmux(lastSnap);
187
+ // If all done, send a notification too
188
+ if (lastSnap.done === lastSnap.total) {
189
+ await cmux(pi, "notify", "--title", "✅ All Tasks Complete",
190
+ "--body", `${lastSnap.listTitle}: ${lastSnap.total} tasks done`);
191
+ }
192
+ }
193
+ });
194
+
195
+ // Clean up on session shutdown — restore original title and clear color
196
+ pi.on("session_shutdown", async () => {
197
+ if (originalTitle) {
198
+ await Promise.all([
199
+ cmux(pi, "rename-workspace", originalTitle),
200
+ cmux(pi, "workspace-action", "--action", "clear-color"),
201
+ cmux(pi, "workspace-action", "--action", "clear-description"),
202
+ ]);
203
+ }
204
+ });
205
+ }
@@ -0,0 +1,733 @@
1
+ /**
2
+ * TillDone Extension — Work Till It's Done
3
+ *
4
+ * A task-driven discipline extension. The agent MUST define what it's going
5
+ * to do (via `tilldone add`) before it can use any other tools. On agent
6
+ * completion, if tasks remain incomplete, the agent gets nudged to continue
7
+ * or mark them done. Play on words: "todo" → "tilldone" (work till done).
8
+ *
9
+ * Three-state lifecycle: idle → inprogress → done
10
+ *
11
+ * Each list has a title and description that give the tasks a theme.
12
+ * Use `new-list` to start a fresh list. `clear` wipes tasks with user confirm.
13
+ *
14
+ * UI surfaces:
15
+ * - Footer: persistent task list with live progress + list title
16
+ * - Widget: prominent "current task" display (the inprogress task)
17
+ * - Status: compact summary in the status line
18
+ * - /tilldone: interactive overlay with full task details
19
+ *
20
+ * Usage: pi install npm:@structupath/pi-tilldone
21
+ */
22
+
23
+ import { StringEnum } from "@mariozechner/pi-ai";
24
+ import type { ExtensionAPI, ExtensionContext, Theme } from "@mariozechner/pi-coding-agent";
25
+ import { DynamicBorder } from "@mariozechner/pi-coding-agent";
26
+ import { Container, matchesKey, Text, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
27
+ import { Type } from "typebox";
28
+
29
+ // ── Types ──────────────────────────────────────────────────────────────
30
+
31
+ type TaskStatus = "idle" | "inprogress" | "done";
32
+
33
+ interface Task {
34
+ id: number;
35
+ text: string;
36
+ status: TaskStatus;
37
+ }
38
+
39
+ interface TillDoneDetails {
40
+ action: string;
41
+ tasks: Task[];
42
+ nextId: number;
43
+ listTitle?: string;
44
+ listDescription?: string;
45
+ error?: string;
46
+ }
47
+
48
+ const TillDoneParams = Type.Object({
49
+ action: StringEnum(["new-list", "add", "toggle", "remove", "update", "list", "clear"] as const),
50
+ text: Type.Optional(Type.String({ description: "Task text (for add/update), or list title (for new-list)" })),
51
+ texts: Type.Optional(Type.Array(Type.String(), { description: "Multiple task texts (for add). Use this to batch-add several tasks at once." })),
52
+ description: Type.Optional(Type.String({ description: "List description (for new-list)" })),
53
+ id: Type.Optional(Type.Number({ description: "Task ID (for toggle/remove/update)" })),
54
+ });
55
+
56
+ // ── Status helpers ─────────────────────────────────────────────────────
57
+
58
+ const STATUS_ICON: Record<TaskStatus, string> = { idle: "○", inprogress: "●", done: "✓" };
59
+ const NEXT_STATUS: Record<TaskStatus, TaskStatus> = { idle: "inprogress", inprogress: "done", done: "idle" };
60
+ const STATUS_LABEL: Record<TaskStatus, string> = { idle: "idle", inprogress: "in progress", done: "done" };
61
+
62
+ // ── /tilldone overlay component ────────────────────────────────────────
63
+
64
+ class TillDoneListComponent {
65
+ private tasks: Task[];
66
+ private title: string | undefined;
67
+ private desc: string | undefined;
68
+ private theme: Theme;
69
+ private onClose: () => void;
70
+ private cachedWidth?: number;
71
+ private cachedLines?: string[];
72
+
73
+ constructor(tasks: Task[], title: string | undefined, desc: string | undefined, theme: Theme, onClose: () => void) {
74
+ this.tasks = tasks;
75
+ this.title = title;
76
+ this.desc = desc;
77
+ this.theme = theme;
78
+ this.onClose = onClose;
79
+ }
80
+
81
+ handleInput(data: string): void {
82
+ if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
83
+ this.onClose();
84
+ }
85
+ }
86
+
87
+ render(width: number): string[] {
88
+ if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
89
+
90
+ const lines: string[] = [];
91
+ const th = this.theme;
92
+
93
+ lines.push("");
94
+ const heading = this.title
95
+ ? th.fg("accent", ` ${this.title} `)
96
+ : th.fg("accent", " TillDone ");
97
+ const headingLen = this.title ? this.title.length + 2 : 10;
98
+ lines.push(truncateToWidth(
99
+ th.fg("borderMuted", "─".repeat(3)) + heading +
100
+ th.fg("borderMuted", "─".repeat(Math.max(0, width - 3 - headingLen))),
101
+ width,
102
+ ));
103
+
104
+ if (this.desc) {
105
+ lines.push(truncateToWidth(` ${th.fg("muted", this.desc)}`, width));
106
+ }
107
+ lines.push("");
108
+
109
+ if (this.tasks.length === 0) {
110
+ lines.push(truncateToWidth(` ${th.fg("dim", "No tasks yet. Ask the agent to add some!")}`, width));
111
+ } else {
112
+ const done = this.tasks.filter((t) => t.status === "done").length;
113
+ const active = this.tasks.filter((t) => t.status === "inprogress").length;
114
+ const idle = this.tasks.filter((t) => t.status === "idle").length;
115
+
116
+ lines.push(truncateToWidth(
117
+ " " +
118
+ th.fg("success", `${done} done`) + th.fg("dim", " ") +
119
+ th.fg("accent", `${active} active`) + th.fg("dim", " ") +
120
+ th.fg("muted", `${idle} idle`),
121
+ width,
122
+ ));
123
+ lines.push("");
124
+
125
+ for (const task of this.tasks) {
126
+ const icon = task.status === "done"
127
+ ? th.fg("success", STATUS_ICON.done)
128
+ : task.status === "inprogress"
129
+ ? th.fg("accent", STATUS_ICON.inprogress)
130
+ : th.fg("dim", STATUS_ICON.idle);
131
+ const id = th.fg("accent", `#${task.id}`);
132
+ const text = task.status === "done"
133
+ ? th.fg("dim", task.text)
134
+ : task.status === "inprogress"
135
+ ? th.fg("success", task.text)
136
+ : th.fg("muted", task.text);
137
+ lines.push(truncateToWidth(` ${icon} ${id} ${text}`, width));
138
+ }
139
+ }
140
+
141
+ lines.push("");
142
+ lines.push(truncateToWidth(` ${th.fg("dim", "Press Escape to close")}`, width));
143
+ lines.push("");
144
+
145
+ this.cachedWidth = width;
146
+ this.cachedLines = lines;
147
+ return lines;
148
+ }
149
+
150
+ invalidate(): void {
151
+ this.cachedWidth = undefined;
152
+ this.cachedLines = undefined;
153
+ }
154
+ }
155
+
156
+ // ── Extension entry point ──────────────────────────────────────────────
157
+
158
+ export default function (pi: ExtensionAPI) {
159
+ let tasks: Task[] = [];
160
+ let nextId = 1;
161
+ let listTitle: string | undefined;
162
+ let listDescription: string | undefined;
163
+ let nudgedThisCycle = false;
164
+
165
+ // ── Snapshot for details ───────────────────────────────────────────
166
+
167
+ const makeDetails = (action: string, error?: string): TillDoneDetails => ({
168
+ action,
169
+ tasks: [...tasks],
170
+ nextId,
171
+ listTitle,
172
+ listDescription,
173
+ ...(error ? { error } : {}),
174
+ });
175
+
176
+ // ── UI refresh ─────────────────────────────────────────────────────
177
+
178
+ const refreshWidget = (ctx: ExtensionContext) => {
179
+ const current = tasks.find((t) => t.status === "inprogress");
180
+
181
+ if (!current) {
182
+ ctx.ui.setWidget("tilldone-current", undefined);
183
+ return;
184
+ }
185
+
186
+ ctx.ui.setWidget("tilldone-current", (_tui, theme) => {
187
+ const container = new Container();
188
+ const borderFn = (s: string) => theme.fg("dim", s);
189
+
190
+ container.addChild(new Text("", 0, 0));
191
+ container.addChild(new DynamicBorder(borderFn));
192
+ const content = new Text("", 1, 0);
193
+ container.addChild(content);
194
+ container.addChild(new DynamicBorder(borderFn));
195
+
196
+ return {
197
+ render(width: number): string[] {
198
+ const cur = tasks.find((t) => t.status === "inprogress");
199
+ if (!cur) return [];
200
+
201
+ const line =
202
+ theme.fg("accent", "● ") +
203
+ theme.fg("dim", "WORKING ON ") +
204
+ theme.fg("accent", `#${cur.id}`) +
205
+ theme.fg("dim", " ") +
206
+ theme.fg("success", cur.text);
207
+
208
+ content.setText(truncateToWidth(line, width - 4));
209
+ return container.render(width);
210
+ },
211
+ invalidate() { container.invalidate(); },
212
+ };
213
+ }, { placement: "belowEditor" });
214
+ };
215
+
216
+ const refreshFooter = (ctx: ExtensionContext) => {
217
+ ctx.ui.setFooter((tui, theme, footerData) => {
218
+ const unsub = footerData.onBranchChange(() => tui.requestRender());
219
+
220
+ return {
221
+ dispose: unsub,
222
+ invalidate() {},
223
+ render(width: number): string[] {
224
+ const done = tasks.filter((t) => t.status === "done").length;
225
+ const active = tasks.filter((t) => t.status === "inprogress").length;
226
+ const idle = tasks.filter((t) => t.status === "idle").length;
227
+ const total = tasks.length;
228
+
229
+ // ── Line 1: list title + progress (left), counts (right) ──
230
+ const titleDisplay = listTitle
231
+ ? theme.fg("accent", ` ${listTitle} `)
232
+ : theme.fg("dim", " TillDone ");
233
+
234
+ const l1Left = total === 0
235
+ ? titleDisplay + theme.fg("muted", "no tasks")
236
+ : titleDisplay +
237
+ theme.fg("warning", "[") +
238
+ theme.fg("success", `${done}`) +
239
+ theme.fg("dim", "/") +
240
+ theme.fg("success", `${total}`) +
241
+ theme.fg("warning", "]");
242
+
243
+ const l1Right = total === 0
244
+ ? ""
245
+ : theme.fg("dim", STATUS_ICON.idle + " ") + theme.fg("muted", `${idle}`) +
246
+ theme.fg("dim", " ") +
247
+ theme.fg("accent", STATUS_ICON.inprogress + " ") + theme.fg("accent", `${active}`) +
248
+ theme.fg("dim", " ") +
249
+ theme.fg("success", STATUS_ICON.done + " ") + theme.fg("success", `${done}`) +
250
+ theme.fg("dim", " ");
251
+
252
+ const pad1 = " ".repeat(Math.max(1, width - visibleWidth(l1Left) - visibleWidth(l1Right)));
253
+ const line1 = truncateToWidth(l1Left + pad1 + l1Right, width, "");
254
+
255
+ if (total === 0) return [line1];
256
+
257
+ // ── Rows: inprogress first, then most recent done, max 5 ──
258
+ const activeTasks = tasks.filter((t) => t.status === "inprogress");
259
+ const doneTasks = tasks.filter((t) => t.status === "done").reverse();
260
+ const visible = [...activeTasks, ...doneTasks].slice(0, 5);
261
+ const remaining = total - visible.length;
262
+
263
+ const rows = visible.map((t) => {
264
+ const icon = t.status === "done"
265
+ ? theme.fg("success", STATUS_ICON.done)
266
+ : theme.fg("accent", STATUS_ICON.inprogress);
267
+ const text = t.status === "done"
268
+ ? theme.fg("dim", t.text)
269
+ : theme.fg("success", t.text);
270
+ return truncateToWidth(` ${icon} ${text}`, width, "");
271
+ });
272
+
273
+ if (remaining > 0) {
274
+ rows.push(truncateToWidth(
275
+ ` ${theme.fg("dim", ` +${remaining} more`)}`,
276
+ width, "",
277
+ ));
278
+ }
279
+
280
+ return [line1, ...rows];
281
+ },
282
+ };
283
+ });
284
+ };
285
+
286
+ const refreshUI = (ctx: ExtensionContext) => {
287
+ if (tasks.length === 0) {
288
+ ctx.ui.setStatus("📋 TillDone: no tasks", "tilldone");
289
+ } else {
290
+ const remaining = tasks.filter((t) => t.status !== "done").length;
291
+ const label = listTitle ? `📋 ${listTitle}` : "📋 TillDone";
292
+ ctx.ui.setStatus(`${label}: ${tasks.length} tasks (${remaining} remaining)`, "tilldone");
293
+ }
294
+
295
+ refreshWidget(ctx);
296
+ refreshFooter(ctx);
297
+ };
298
+
299
+ // ── State reconstruction from session ──────────────────────────────
300
+
301
+ const reconstructState = (ctx: ExtensionContext) => {
302
+ tasks = [];
303
+ nextId = 1;
304
+ listTitle = undefined;
305
+ listDescription = undefined;
306
+
307
+ for (const entry of ctx.sessionManager.getBranch()) {
308
+ if (entry.type !== "message") continue;
309
+ const msg = entry.message;
310
+ if (msg.role !== "toolResult" || msg.toolName !== "tilldone") continue;
311
+
312
+ const details = msg.details as TillDoneDetails | undefined;
313
+ if (details) {
314
+ tasks = details.tasks;
315
+ nextId = details.nextId;
316
+ listTitle = details.listTitle;
317
+ listDescription = details.listDescription;
318
+ }
319
+ }
320
+
321
+ refreshUI(ctx);
322
+ };
323
+
324
+ // session_start covers startup, reload, new, resume, and fork
325
+ pi.on("session_start", async (_event, ctx) => reconstructState(ctx));
326
+ pi.on("session_tree", async (_event, ctx) => reconstructState(ctx));
327
+
328
+ // ── Blocking gate ──────────────────────────────────────────────────
329
+ // Only blocks write/execute tools. Read-only tools (read, grep, find,
330
+ // ls, glob) are always allowed so agents can explore before planning.
331
+ // Subagent tools and dispatch tools are also whitelisted.
332
+
333
+ const READ_ONLY_TOOLS = new Set([
334
+ "read", "grep", "find", "ls", "glob",
335
+ "query_experts",
336
+ "subagent_create", "subagent_continue", "subagent_list", "subagent_remove",
337
+ "dispatch_agent",
338
+ "run_chain",
339
+ ]);
340
+
341
+ pi.on("tool_call", async (event, _ctx) => {
342
+ if (event.toolName === "tilldone") return { block: false };
343
+ if (READ_ONLY_TOOLS.has(event.toolName)) return { block: false };
344
+
345
+ const pending = tasks.filter((t) => t.status !== "done");
346
+ const active = tasks.filter((t) => t.status === "inprogress");
347
+
348
+ if (tasks.length === 0) {
349
+ return {
350
+ block: true,
351
+ reason: "🚫 No TillDone tasks defined. Use `tilldone new-list` or `tilldone add` to define your tasks before using write/execute tools.",
352
+ };
353
+ }
354
+ if (pending.length === 0) {
355
+ return {
356
+ block: true,
357
+ reason: "🚫 All TillDone tasks are done. Use `tilldone add` for new tasks or `tilldone new-list` to start a fresh list.",
358
+ };
359
+ }
360
+ if (active.length === 0) {
361
+ return {
362
+ block: true,
363
+ reason: "🚫 No task is in progress. Use `tilldone toggle` to mark a task as inprogress before doing any work.",
364
+ };
365
+ }
366
+
367
+ return { block: false };
368
+ });
369
+
370
+ // ── Auto-nudge on agent_end ────────────────────────────────────────
371
+
372
+ pi.on("agent_end", async (_event, _ctx) => {
373
+ const incomplete = tasks.filter((t) => t.status !== "done");
374
+ if (incomplete.length === 0 || nudgedThisCycle) return;
375
+
376
+ nudgedThisCycle = true;
377
+
378
+ const taskList = incomplete
379
+ .map((t) => ` ${STATUS_ICON[t.status]} #${t.id} [${STATUS_LABEL[t.status]}]: ${t.text}`)
380
+ .join("\n");
381
+
382
+ pi.sendMessage(
383
+ {
384
+ customType: "tilldone-nudge",
385
+ content: `⚠️ You still have ${incomplete.length} incomplete task(s):\n\n${taskList}\n\nEither continue working on them or mark them done with \`tilldone toggle\`. Don't stop until it's done!`,
386
+ display: true,
387
+ },
388
+ { triggerTurn: true },
389
+ );
390
+ });
391
+
392
+ pi.on("input", async () => {
393
+ nudgedThisCycle = false;
394
+ return { action: "continue" as const };
395
+ });
396
+
397
+ // ── Register tilldone tool ─────────────────────────────────────────
398
+
399
+ pi.registerTool({
400
+ name: "tilldone",
401
+ label: "TillDone",
402
+ description:
403
+ "Manage your task list. You MUST add tasks before using any other tools. " +
404
+ "Actions: new-list (text=title, description), add (text or texts[] for batch), toggle (id) — cycles idle→inprogress→done, remove (id), update (id + text), list, clear. " +
405
+ "Always toggle a task to inprogress before starting work on it, and to done when finished. " +
406
+ "Use new-list to start a themed list with a title and description. " +
407
+ "IMPORTANT: If the user's new request does not fit the current list's theme, use clear to wipe the slate and new-list to start fresh.",
408
+ parameters: TillDoneParams,
409
+
410
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
411
+ switch (params.action) {
412
+ case "new-list": {
413
+ if (!params.text) {
414
+ return {
415
+ content: [{ type: "text" as const, text: "Error: text (title) required for new-list" }],
416
+ details: makeDetails("new-list", "text required"),
417
+ };
418
+ }
419
+
420
+ // If a list already exists, confirm before replacing
421
+ if (tasks.length > 0 || listTitle) {
422
+ const confirmed = await ctx.ui.confirm(
423
+ "Start a new list?",
424
+ `This will replace${listTitle ? ` "${listTitle}"` : " the current list"} (${tasks.length} task(s)). Continue?`,
425
+ { timeout: 30000 },
426
+ );
427
+ if (!confirmed) {
428
+ return {
429
+ content: [{ type: "text" as const, text: "New list cancelled by user." }],
430
+ details: makeDetails("new-list", "cancelled"),
431
+ };
432
+ }
433
+ }
434
+
435
+ tasks = [];
436
+ nextId = 1;
437
+ listTitle = params.text;
438
+ listDescription = params.description || undefined;
439
+
440
+ const result = {
441
+ content: [{
442
+ type: "text" as const,
443
+ text: `New list: "${listTitle}"${listDescription ? ` — ${listDescription}` : ""}`,
444
+ }],
445
+ details: makeDetails("new-list"),
446
+ };
447
+ refreshUI(ctx);
448
+ return result;
449
+ }
450
+
451
+ case "list": {
452
+ const header = listTitle ? `${listTitle}:` : "";
453
+ const result = {
454
+ content: [{
455
+ type: "text" as const,
456
+ text: tasks.length
457
+ ? (header ? header + "\n" : "") +
458
+ tasks.map((t) => `[${STATUS_ICON[t.status]}] #${t.id} (${t.status}): ${t.text}`).join("\n")
459
+ : "No tasks defined yet.",
460
+ }],
461
+ details: makeDetails("list"),
462
+ };
463
+ refreshUI(ctx);
464
+ return result;
465
+ }
466
+
467
+ case "add": {
468
+ const items = params.texts?.length ? params.texts : params.text ? [params.text] : [];
469
+ if (items.length === 0) {
470
+ return {
471
+ content: [{ type: "text" as const, text: "Error: text or texts required for add" }],
472
+ details: makeDetails("add", "text required"),
473
+ };
474
+ }
475
+ const added: Task[] = [];
476
+ for (const item of items) {
477
+ const t: Task = { id: nextId++, text: item, status: "idle" };
478
+ tasks.push(t);
479
+ added.push(t);
480
+ }
481
+ const msg = added.length === 1
482
+ ? `Added task #${added[0].id}: ${added[0].text}`
483
+ : `Added ${added.length} tasks: ${added.map((t) => `#${t.id}`).join(", ")}`;
484
+ const result = {
485
+ content: [{ type: "text" as const, text: msg }],
486
+ details: makeDetails("add"),
487
+ };
488
+ refreshUI(ctx);
489
+ return result;
490
+ }
491
+
492
+ case "toggle": {
493
+ if (params.id === undefined) {
494
+ return {
495
+ content: [{ type: "text" as const, text: "Error: id required for toggle" }],
496
+ details: makeDetails("toggle", "id required"),
497
+ };
498
+ }
499
+ const task = tasks.find((t) => t.id === params.id);
500
+ if (!task) {
501
+ return {
502
+ content: [{ type: "text" as const, text: `Task #${params.id} not found` }],
503
+ details: makeDetails("toggle", `#${params.id} not found`),
504
+ };
505
+ }
506
+ const prev = task.status;
507
+ task.status = NEXT_STATUS[task.status];
508
+
509
+ // Enforce single inprogress — demote any other active task
510
+ const demoted: Task[] = [];
511
+ if (task.status === "inprogress") {
512
+ for (const t of tasks) {
513
+ if (t.id !== task.id && t.status === "inprogress") {
514
+ t.status = "idle";
515
+ demoted.push(t);
516
+ }
517
+ }
518
+ }
519
+
520
+ let msg = `Task #${task.id}: ${prev} → ${task.status}`;
521
+ if (demoted.length > 0) {
522
+ msg += `\n(Auto-paused ${demoted.map((t) => `#${t.id}`).join(", ")} → idle. Only one task can be in progress at a time.)`;
523
+ }
524
+
525
+ const result = {
526
+ content: [{
527
+ type: "text" as const,
528
+ text: msg,
529
+ }],
530
+ details: makeDetails("toggle"),
531
+ };
532
+ refreshUI(ctx);
533
+ return result;
534
+ }
535
+
536
+ case "remove": {
537
+ if (params.id === undefined) {
538
+ return {
539
+ content: [{ type: "text" as const, text: "Error: id required for remove" }],
540
+ details: makeDetails("remove", "id required"),
541
+ };
542
+ }
543
+ const idx = tasks.findIndex((t) => t.id === params.id);
544
+ if (idx === -1) {
545
+ return {
546
+ content: [{ type: "text" as const, text: `Task #${params.id} not found` }],
547
+ details: makeDetails("remove", `#${params.id} not found`),
548
+ };
549
+ }
550
+ const removed = tasks.splice(idx, 1)[0];
551
+ const result = {
552
+ content: [{ type: "text" as const, text: `Removed task #${removed.id}: ${removed.text}` }],
553
+ details: makeDetails("remove"),
554
+ };
555
+ refreshUI(ctx);
556
+ return result;
557
+ }
558
+
559
+ case "update": {
560
+ if (params.id === undefined) {
561
+ return {
562
+ content: [{ type: "text" as const, text: "Error: id required for update" }],
563
+ details: makeDetails("update", "id required"),
564
+ };
565
+ }
566
+ if (!params.text) {
567
+ return {
568
+ content: [{ type: "text" as const, text: "Error: text required for update" }],
569
+ details: makeDetails("update", "text required"),
570
+ };
571
+ }
572
+ const toUpdate = tasks.find((t) => t.id === params.id);
573
+ if (!toUpdate) {
574
+ return {
575
+ content: [{ type: "text" as const, text: `Task #${params.id} not found` }],
576
+ details: makeDetails("update", `#${params.id} not found`),
577
+ };
578
+ }
579
+ const oldText = toUpdate.text;
580
+ toUpdate.text = params.text;
581
+ const result = {
582
+ content: [{ type: "text" as const, text: `Updated #${toUpdate.id}: "${oldText}" → "${toUpdate.text}"` }],
583
+ details: makeDetails("update"),
584
+ };
585
+ refreshUI(ctx);
586
+ return result;
587
+ }
588
+
589
+ case "clear": {
590
+ if (tasks.length > 0) {
591
+ const confirmed = await ctx.ui.confirm(
592
+ "Clear TillDone list?",
593
+ `This will remove all ${tasks.length} task(s)${listTitle ? ` from "${listTitle}"` : ""}. Continue?`,
594
+ { timeout: 30000 },
595
+ );
596
+ if (!confirmed) {
597
+ return {
598
+ content: [{ type: "text" as const, text: "Clear cancelled by user." }],
599
+ details: makeDetails("clear", "cancelled"),
600
+ };
601
+ }
602
+ }
603
+
604
+ const count = tasks.length;
605
+ tasks = [];
606
+ nextId = 1;
607
+ listTitle = undefined;
608
+ listDescription = undefined;
609
+
610
+ const result = {
611
+ content: [{ type: "text" as const, text: `Cleared ${count} task(s)` }],
612
+ details: makeDetails("clear"),
613
+ };
614
+ refreshUI(ctx);
615
+ return result;
616
+ }
617
+
618
+ default:
619
+ return {
620
+ content: [{ type: "text" as const, text: `Unknown action: ${params.action}` }],
621
+ details: makeDetails("list", `unknown action: ${params.action}`),
622
+ };
623
+ }
624
+ },
625
+
626
+ renderCall(args, theme) {
627
+ let text = theme.fg("toolTitle", theme.bold("tilldone ")) + theme.fg("muted", args.action);
628
+ if (args.texts?.length) text += ` ${theme.fg("dim", `${args.texts.length} tasks`)}`;
629
+ else if (args.text) text += ` ${theme.fg("dim", `"${args.text}"`)}`;
630
+ if (args.description) text += ` ${theme.fg("dim", `— ${args.description}`)}`;
631
+ if (args.id !== undefined) text += ` ${theme.fg("accent", `#${args.id}`)}`;
632
+ return new Text(text, 0, 0);
633
+ },
634
+
635
+ renderResult(result, { expanded }, theme) {
636
+ const details = result.details as TillDoneDetails | undefined;
637
+ if (!details) {
638
+ const text = result.content[0];
639
+ return new Text(text?.type === "text" ? text.text : "", 0, 0);
640
+ }
641
+
642
+ if (details.error) {
643
+ return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0);
644
+ }
645
+
646
+ const taskList = details.tasks;
647
+
648
+ switch (details.action) {
649
+ case "new-list": {
650
+ let msg = theme.fg("success", "✓ New list ") + theme.fg("accent", `"${details.listTitle}"`);
651
+ if (details.listDescription) {
652
+ msg += theme.fg("dim", ` — ${details.listDescription}`);
653
+ }
654
+ return new Text(msg, 0, 0);
655
+ }
656
+
657
+ case "list": {
658
+ if (taskList.length === 0) return new Text(theme.fg("dim", "No tasks"), 0, 0);
659
+
660
+ let listText = "";
661
+ if (details.listTitle) {
662
+ listText += theme.fg("accent", details.listTitle) + theme.fg("dim", " ");
663
+ }
664
+ listText += theme.fg("muted", `${taskList.length} task(s):`);
665
+ const display = expanded ? taskList : taskList.slice(0, 5);
666
+ for (const t of display) {
667
+ const icon = t.status === "done"
668
+ ? theme.fg("success", STATUS_ICON.done)
669
+ : t.status === "inprogress"
670
+ ? theme.fg("accent", STATUS_ICON.inprogress)
671
+ : theme.fg("dim", STATUS_ICON.idle);
672
+ const itemText = t.status === "done"
673
+ ? theme.fg("dim", t.text)
674
+ : t.status === "inprogress"
675
+ ? theme.fg("success", t.text)
676
+ : theme.fg("muted", t.text);
677
+ listText += `\n${icon} ${theme.fg("accent", `#${t.id}`)} ${itemText}`;
678
+ }
679
+ if (!expanded && taskList.length > 5) {
680
+ listText += `\n${theme.fg("dim", `... ${taskList.length - 5} more`)}`;
681
+ }
682
+ return new Text(listText, 0, 0);
683
+ }
684
+
685
+ case "add": {
686
+ const text = result.content[0];
687
+ const msg = text?.type === "text" ? text.text : "";
688
+ return new Text(theme.fg("success", "✓ ") + theme.fg("muted", msg), 0, 0);
689
+ }
690
+
691
+ case "toggle": {
692
+ const text = result.content[0];
693
+ const msg = text?.type === "text" ? text.text : "";
694
+ return new Text(theme.fg("accent", "⟳ ") + theme.fg("muted", msg), 0, 0);
695
+ }
696
+
697
+ case "remove": {
698
+ const text = result.content[0];
699
+ const msg = text?.type === "text" ? text.text : "";
700
+ return new Text(theme.fg("warning", "✕ ") + theme.fg("muted", msg), 0, 0);
701
+ }
702
+
703
+ case "update": {
704
+ const text = result.content[0];
705
+ const msg = text?.type === "text" ? text.text : "";
706
+ return new Text(theme.fg("success", "✓ ") + theme.fg("muted", msg), 0, 0);
707
+ }
708
+
709
+ case "clear":
710
+ return new Text(theme.fg("success", "✓ ") + theme.fg("muted", "Cleared all tasks"), 0, 0);
711
+
712
+ default:
713
+ return new Text(theme.fg("dim", "done"), 0, 0);
714
+ }
715
+ },
716
+ });
717
+
718
+ // ── /tilldone command ──────────────────────────────────────────────
719
+
720
+ pi.registerCommand("tilldone", {
721
+ description: "Show all TillDone tasks on the current branch",
722
+ handler: async (_args, ctx) => {
723
+ if (!ctx.hasUI) {
724
+ ctx.ui.notify("/tilldone requires interactive mode", "error");
725
+ return;
726
+ }
727
+
728
+ await ctx.ui.custom<void>((_tui, theme, _kb, done) => {
729
+ return new TillDoneListComponent(tasks, listTitle, listDescription, theme, () => done());
730
+ });
731
+ },
732
+ });
733
+ }
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@structupath/pi-tilldone",
3
+ "version": "0.1.0",
4
+ "description": "Task-discipline extension for the Pi coding agent — the agent must declare tasks before it can touch write/execute tools, and gets nudged until every task is done. Includes cmux tab-status integration.",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi-package",
8
+ "pi-extension",
9
+ "tasks",
10
+ "todo",
11
+ "discipline",
12
+ "planning",
13
+ "agent-workflow",
14
+ "cmux"
15
+ ],
16
+ "author": "StructuPath (https://structupath.ai)",
17
+ "license": "MIT",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/StructuPath/pi-tilldone.git"
21
+ },
22
+ "homepage": "https://structupath.ai",
23
+ "engines": {
24
+ "node": ">=20.6.0"
25
+ },
26
+ "pi": {
27
+ "extensions": [
28
+ "./extensions/tilldone.ts",
29
+ "./extensions/cmux-status.ts"
30
+ ]
31
+ },
32
+ "files": [
33
+ "extensions",
34
+ "README.md",
35
+ "LICENSE"
36
+ ],
37
+ "scripts": {
38
+ "typecheck": "tsc --noEmit"
39
+ },
40
+ "peerDependencies": {
41
+ "@mariozechner/pi-ai": "*",
42
+ "@mariozechner/pi-coding-agent": "*",
43
+ "@mariozechner/pi-tui": "*",
44
+ "typebox": "*"
45
+ },
46
+ "devDependencies": {
47
+ "@types/node": "^22.0.0",
48
+ "typescript": "^5.6.0"
49
+ }
50
+ }