@xynogen/pix-todo 0.1.9 → 0.1.12

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 CHANGED
@@ -6,6 +6,22 @@ Pi tool — durable execution checklist (`todo`).
6
6
 
7
7
  Registers the `todo` tool, which gives the agent a persistent task checklist that survives context compaction and session restore. The checklist is seeded by the model via the `set` action and tracks items through four statuses: `pending` (○), `in_progress` (◐), `done` (●), and `blocked` (⊘). State is persisted via Pi's `appendEntry("todo-state")` so the agent can recover its position after long runs or compaction events. The agent calls `todo(action:"list")` to resume where it left off. Actions: `list`, `set`, `add`, `update`, `clear`.
8
8
 
9
+ ## Auto-collapse
10
+
11
+ The checklist card auto-collapses after a configurable delay (default 10 seconds, previously hardcoded). The delay and the per-tool toggle are read from `~/.pi/agent/pix.json`:
12
+
13
+ ```jsonc
14
+ {
15
+ "collapse": {
16
+ "enabled": true,
17
+ "delayMs": 10000,
18
+ "tools": { "todo": true }
19
+ }
20
+ }
21
+ ```
22
+
23
+ Set `collapse.tools.todo: false` to keep the checklist always expanded. See `@xynogen/pix-data/collapse` for the full API.
24
+
9
25
  ## Install
10
26
 
11
27
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-todo",
3
- "version": "0.1.9",
3
+ "version": "0.1.12",
4
4
  "description": "Pi tool — durable execution checklist (todo)",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/todo.test.ts CHANGED
@@ -1,9 +1,5 @@
1
1
  import { beforeEach, describe, expect, test } from "bun:test";
2
- import registerTodo, {
3
- renderTodoLines,
4
- renderTodoSummaryLine,
5
- type TodoItem,
6
- } from "./todo.ts";
2
+ import registerTodo, { renderTodoLines, renderTodoSummaryLine, type TodoItem } from "./todo.ts";
7
3
 
8
4
  // registerTodo wraps its body in once(pi, "pix-todo") — a per-instance
9
5
  // WeakMap guard that dedupes activation across pix-core + a standalone install.
@@ -40,10 +36,7 @@ function makeHost(
40
36
  }>)
41
37
  | null = null;
42
38
  const appendCalls: Array<{ type: string; data: unknown }> = [];
43
- const handlers: Record<
44
- string,
45
- Array<(event: unknown, ctx?: unknown) => unknown>
46
- > = {};
39
+ const handlers: Record<string, Array<(event: unknown, ctx?: unknown) => unknown>> = {};
47
40
 
48
41
  let capturedRender:
49
42
  | ((
@@ -70,8 +63,13 @@ function makeHost(
70
63
  if (!handlers[ev]) handlers[ev] = [];
71
64
  handlers[ev].push(fn);
72
65
  },
73
- async emit(ev: string, event?: unknown, ctx?: unknown) {
74
- for (const fn of handlers[ev] ?? []) await fn(event, ctx);
66
+ async emit(ev: string, event?: unknown, ctx?: unknown): Promise<unknown> {
67
+ let last: unknown;
68
+ for (const fn of handlers[ev] ?? []) {
69
+ const result = await fn(event, ctx);
70
+ if (result !== undefined) last = result;
71
+ }
72
+ return last;
75
73
  },
76
74
  } as never;
77
75
 
@@ -93,8 +91,13 @@ function makeHost(
93
91
  return capturedRender;
94
92
  },
95
93
  appendCalls,
96
- async emit(ev: string, event?: unknown, ctx?: unknown) {
97
- for (const fn of handlers[ev] ?? []) await fn(event, ctx);
94
+ async emit(ev: string, event?: unknown, ctx?: unknown): Promise<unknown> {
95
+ let last: unknown;
96
+ for (const fn of handlers[ev] ?? []) {
97
+ const result = await fn(event, ctx);
98
+ if (result !== undefined) last = result;
99
+ }
100
+ return last;
98
101
  },
99
102
  };
100
103
  }
@@ -122,11 +125,7 @@ describe("todo actions", () => {
122
125
  test("list on empty returns (no todos)", async () => {
123
126
  const host = makeHost();
124
127
  registerTodo(host.pi);
125
- await host.emit(
126
- "session_start",
127
- {},
128
- { sessionManager: host.sessionManager },
129
- );
128
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
130
129
  const result = await run(host.execute, { action: "list" });
131
130
  expect(text(result)).toBe("(no todos)");
132
131
  });
@@ -134,11 +133,7 @@ describe("todo actions", () => {
134
133
  test("set creates items from newline text", async () => {
135
134
  const host = makeHost();
136
135
  registerTodo(host.pi);
137
- await host.emit(
138
- "session_start",
139
- {},
140
- { sessionManager: host.sessionManager },
141
- );
136
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
142
137
  const result = await run(host.execute, {
143
138
  action: "set",
144
139
  items: "alpha\nbravo\ncharlie",
@@ -153,11 +148,7 @@ describe("todo actions", () => {
153
148
  test("set creates items from numbered list", async () => {
154
149
  const host = makeHost();
155
150
  registerTodo(host.pi);
156
- await host.emit(
157
- "session_start",
158
- {},
159
- { sessionManager: host.sessionManager },
160
- );
151
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
161
152
  const result = await run(host.execute, {
162
153
  action: "set",
163
154
  items: "1. alpha\n2. bravo",
@@ -168,11 +159,7 @@ describe("todo actions", () => {
168
159
  test("set creates items from bullet list", async () => {
169
160
  const host = makeHost();
170
161
  registerTodo(host.pi);
171
- await host.emit(
172
- "session_start",
173
- {},
174
- { sessionManager: host.sessionManager },
175
- );
162
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
176
163
  const result = await run(host.execute, {
177
164
  action: "set",
178
165
  items: "- alpha\n* bravo",
@@ -184,11 +171,7 @@ describe("todo actions", () => {
184
171
  test("set ignores empty lines", async () => {
185
172
  const host = makeHost();
186
173
  registerTodo(host.pi);
187
- await host.emit(
188
- "session_start",
189
- {},
190
- { sessionManager: host.sessionManager },
191
- );
174
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
192
175
  const result = await run(host.execute, {
193
176
  action: "set",
194
177
  items: "alpha\n\nbravo\n \ncharlie",
@@ -199,11 +182,7 @@ describe("todo actions", () => {
199
182
  test("set with empty items returns error", async () => {
200
183
  const host = makeHost();
201
184
  registerTodo(host.pi);
202
- await host.emit(
203
- "session_start",
204
- {},
205
- { sessionManager: host.sessionManager },
206
- );
185
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
207
186
  const result = await run(host.execute, { action: "set", items: "" });
208
187
  expect(result.isError).toBe(true);
209
188
  expect(text(result)).toContain("non-empty");
@@ -212,11 +191,7 @@ describe("todo actions", () => {
212
191
  test("set with only whitespace returns error", async () => {
213
192
  const host = makeHost();
214
193
  registerTodo(host.pi);
215
- await host.emit(
216
- "session_start",
217
- {},
218
- { sessionManager: host.sessionManager },
219
- );
194
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
220
195
  const result = await run(host.execute, { action: "set", items: " \n " });
221
196
  expect(result.isError).toBe(true);
222
197
  });
@@ -224,11 +199,7 @@ describe("todo actions", () => {
224
199
  test("set resets ids on re-set", async () => {
225
200
  const host = makeHost();
226
201
  registerTodo(host.pi);
227
- await host.emit(
228
- "session_start",
229
- {},
230
- { sessionManager: host.sessionManager },
231
- );
202
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
232
203
  await run(host.execute, { action: "set", items: "first\nsecond" });
233
204
  const result = await run(host.execute, { action: "set", items: "new" });
234
205
  expect(text(result)).toContain("○ 1. new");
@@ -238,11 +209,7 @@ describe("todo actions", () => {
238
209
  test("add appends items", async () => {
239
210
  const host = makeHost();
240
211
  registerTodo(host.pi);
241
- await host.emit(
242
- "session_start",
243
- {},
244
- { sessionManager: host.sessionManager },
245
- );
212
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
246
213
  await run(host.execute, { action: "set", items: "alpha" });
247
214
  const result = await run(host.execute, {
248
215
  action: "add",
@@ -258,11 +225,7 @@ describe("todo actions", () => {
258
225
  test("add with ids continuing sequence", async () => {
259
226
  const host = makeHost();
260
227
  registerTodo(host.pi);
261
- await host.emit(
262
- "session_start",
263
- {},
264
- { sessionManager: host.sessionManager },
265
- );
228
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
266
229
  await run(host.execute, { action: "set", items: "first\nsecond\nthird" });
267
230
  const result = await run(host.execute, { action: "add", items: "fourth" });
268
231
  expect(text(result)).toContain("○ 4. fourth");
@@ -271,11 +234,7 @@ describe("todo actions", () => {
271
234
  test("add with empty items returns error", async () => {
272
235
  const host = makeHost();
273
236
  registerTodo(host.pi);
274
- await host.emit(
275
- "session_start",
276
- {},
277
- { sessionManager: host.sessionManager },
278
- );
237
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
279
238
  const result = await run(host.execute, { action: "add", items: "" });
280
239
  expect(result.isError).toBe(true);
281
240
  expect(text(result)).toContain("non-empty");
@@ -284,11 +243,7 @@ describe("todo actions", () => {
284
243
  test("update changes status", async () => {
285
244
  const host = makeHost();
286
245
  registerTodo(host.pi);
287
- await host.emit(
288
- "session_start",
289
- {},
290
- { sessionManager: host.sessionManager },
291
- );
246
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
292
247
  await run(host.execute, { action: "set", items: "alpha\nbravo" });
293
248
  const result = await run(host.execute, {
294
249
  action: "update",
@@ -304,11 +259,7 @@ describe("todo actions", () => {
304
259
  test("update changes text", async () => {
305
260
  const host = makeHost();
306
261
  registerTodo(host.pi);
307
- await host.emit(
308
- "session_start",
309
- {},
310
- { sessionManager: host.sessionManager },
311
- );
262
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
312
263
  await run(host.execute, { action: "set", items: "old name" });
313
264
  const result = await run(host.execute, {
314
265
  action: "update",
@@ -321,11 +272,7 @@ describe("todo actions", () => {
321
272
  test("update changes status and text together", async () => {
322
273
  const host = makeHost();
323
274
  registerTodo(host.pi);
324
- await host.emit(
325
- "session_start",
326
- {},
327
- { sessionManager: host.sessionManager },
328
- );
275
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
329
276
  await run(host.execute, { action: "set", items: "alpha" });
330
277
  const result = await run(host.execute, {
331
278
  action: "update",
@@ -341,11 +288,7 @@ describe("todo actions", () => {
341
288
  test("opening a new in_progress closes the previous one", async () => {
342
289
  const host = makeHost();
343
290
  registerTodo(host.pi);
344
- await host.emit(
345
- "session_start",
346
- {},
347
- { sessionManager: host.sessionManager },
348
- );
291
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
349
292
  await run(host.execute, { action: "set", items: "a\nb\nc" });
350
293
  await run(host.execute, { action: "update", id: 1, status: "in_progress" });
351
294
  const result = await run(host.execute, {
@@ -363,11 +306,7 @@ describe("todo actions", () => {
363
306
  test("opening a later item cascade-closes skipped pending items", async () => {
364
307
  const host = makeHost();
365
308
  registerTodo(host.pi);
366
- await host.emit(
367
- "session_start",
368
- {},
369
- { sessionManager: host.sessionManager },
370
- );
309
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
371
310
  await run(host.execute, { action: "set", items: "a\nb\nc\nd" });
372
311
  // Jump straight to id 4 without opening 1-3; they should all auto-close.
373
312
  const result = await run(host.execute, {
@@ -386,11 +325,7 @@ describe("todo actions", () => {
386
325
  test("cascade-close leaves a blocked earlier item untouched", async () => {
387
326
  const host = makeHost();
388
327
  registerTodo(host.pi);
389
- await host.emit(
390
- "session_start",
391
- {},
392
- { sessionManager: host.sessionManager },
393
- );
328
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
394
329
  await run(host.execute, { action: "set", items: "a\nb\nc" });
395
330
  await run(host.execute, { action: "update", id: 1, status: "blocked" });
396
331
  const result = await run(host.execute, {
@@ -407,11 +342,7 @@ describe("todo actions", () => {
407
342
  test("update unknown id returns error", async () => {
408
343
  const host = makeHost();
409
344
  registerTodo(host.pi);
410
- await host.emit(
411
- "session_start",
412
- {},
413
- { sessionManager: host.sessionManager },
414
- );
345
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
415
346
  const result = await run(host.execute, {
416
347
  action: "update",
417
348
  id: 999,
@@ -424,11 +355,7 @@ describe("todo actions", () => {
424
355
  test("update without status or text does nothing", async () => {
425
356
  const host = makeHost();
426
357
  registerTodo(host.pi);
427
- await host.emit(
428
- "session_start",
429
- {},
430
- { sessionManager: host.sessionManager },
431
- );
358
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
432
359
  await run(host.execute, { action: "set", items: "unchanged" });
433
360
  const result = await run(host.execute, { action: "update", id: 1 });
434
361
  expect(text(result)).toContain("○ 1. unchanged");
@@ -437,11 +364,7 @@ describe("todo actions", () => {
437
364
  test("clear empties list", async () => {
438
365
  const host = makeHost();
439
366
  registerTodo(host.pi);
440
- await host.emit(
441
- "session_start",
442
- {},
443
- { sessionManager: host.sessionManager },
444
- );
367
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
445
368
  await run(host.execute, { action: "set", items: "alpha\nbravo" });
446
369
  const result = await run(host.execute, { action: "clear" });
447
370
  expect(text(result)).toContain("Todos cleared");
@@ -453,11 +376,7 @@ describe("todo actions", () => {
453
376
  test("clear resets id counter", async () => {
454
377
  const host = makeHost();
455
378
  registerTodo(host.pi);
456
- await host.emit(
457
- "session_start",
458
- {},
459
- { sessionManager: host.sessionManager },
460
- );
379
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
461
380
  await run(host.execute, { action: "set", items: "alpha\nbravo\ncharlie" });
462
381
  await run(host.execute, { action: "clear" });
463
382
  const result = await run(host.execute, { action: "set", items: "new" });
@@ -467,11 +386,7 @@ describe("todo actions", () => {
467
386
  test("unknown action returns error", async () => {
468
387
  const host = makeHost();
469
388
  registerTodo(host.pi);
470
- await host.emit(
471
- "session_start",
472
- {},
473
- { sessionManager: host.sessionManager },
474
- );
389
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
475
390
  const result = await run(host.execute, { action: "bogus" });
476
391
  expect(result.isError).toBe(true);
477
392
  expect(text(result)).toContain("Unknown action");
@@ -480,11 +395,7 @@ describe("todo actions", () => {
480
395
  test("all status glyphs render correctly", async () => {
481
396
  const host = makeHost();
482
397
  registerTodo(host.pi);
483
- await host.emit(
484
- "session_start",
485
- {},
486
- { sessionManager: host.sessionManager },
487
- );
398
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
488
399
  await run(host.execute, { action: "set", items: "a\nb\nc\nd" });
489
400
  // Open id 2 first (cascade-closes nothing earlier we assert on), then set
490
401
  // the others directly so each glyph is exercised without cascade interfering.
@@ -508,11 +419,7 @@ describe("persistence", () => {
508
419
  test("set persists todos", async () => {
509
420
  const host = makeHost();
510
421
  registerTodo(host.pi);
511
- await host.emit(
512
- "session_start",
513
- {},
514
- { sessionManager: host.sessionManager },
515
- );
422
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
516
423
  host.appendCalls.length = 0;
517
424
  await run(host.execute, { action: "set", items: "alpha\nbravo" });
518
425
  expect(host.appendCalls.length).toBe(1);
@@ -529,11 +436,7 @@ describe("persistence", () => {
529
436
  test("add persists todos", async () => {
530
437
  const host = makeHost();
531
438
  registerTodo(host.pi);
532
- await host.emit(
533
- "session_start",
534
- {},
535
- { sessionManager: host.sessionManager },
536
- );
439
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
537
440
  await run(host.execute, { action: "set", items: "alpha" });
538
441
  host.appendCalls.length = 0;
539
442
  await run(host.execute, { action: "add", items: "bravo" });
@@ -543,11 +446,7 @@ describe("persistence", () => {
543
446
  test("update persists todos", async () => {
544
447
  const host = makeHost();
545
448
  registerTodo(host.pi);
546
- await host.emit(
547
- "session_start",
548
- {},
549
- { sessionManager: host.sessionManager },
550
- );
449
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
551
450
  await run(host.execute, { action: "set", items: "alpha" });
552
451
  host.appendCalls.length = 0;
553
452
  await run(host.execute, { action: "update", id: 1, status: "done" });
@@ -557,11 +456,7 @@ describe("persistence", () => {
557
456
  test("clear persists", async () => {
558
457
  const host = makeHost();
559
458
  registerTodo(host.pi);
560
- await host.emit(
561
- "session_start",
562
- {},
563
- { sessionManager: host.sessionManager },
564
- );
459
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
565
460
  await run(host.execute, { action: "set", items: "alpha" });
566
461
  host.appendCalls.length = 0;
567
462
  await run(host.execute, { action: "clear" });
@@ -590,11 +485,7 @@ describe("restore", () => {
590
485
  },
591
486
  ]);
592
487
  registerTodo(host.pi);
593
- await host.emit(
594
- "session_start",
595
- {},
596
- { sessionManager: host.sessionManager },
597
- );
488
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
598
489
  const result = await run(host.execute, { action: "list" });
599
490
  expect(text(result)).toContain("● 1. restored");
600
491
  expect(text(result)).toContain("Todos 1/1 done");
@@ -612,11 +503,7 @@ describe("restore", () => {
612
503
  },
613
504
  ]);
614
505
  registerTodo(host.pi);
615
- await host.emit(
616
- "session_start",
617
- {},
618
- { sessionManager: host.sessionManager },
619
- );
506
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
620
507
  const result = await run(host.execute, { action: "add", items: "new" });
621
508
  expect(text(result)).toContain("○ 6. new");
622
509
  });
@@ -635,11 +522,7 @@ describe("restore", () => {
635
522
  },
636
523
  ]);
637
524
  registerTodo(host.pi);
638
- await host.emit(
639
- "session_start",
640
- {},
641
- { sessionManager: host.sessionManager },
642
- );
525
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
643
526
  const result = await run(host.execute, { action: "add", items: "next" });
644
527
  expect(text(result)).toContain("○ 8. next");
645
528
  });
@@ -658,11 +541,7 @@ describe("restore", () => {
658
541
  },
659
542
  ]);
660
543
  registerTodo(host.pi);
661
- await host.emit(
662
- "session_start",
663
- {},
664
- { sessionManager: host.sessionManager },
665
- );
544
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
666
545
  const result = await run(host.execute, { action: "list" });
667
546
  expect(text(result)).toContain("○ 1. real");
668
547
  });
@@ -670,11 +549,7 @@ describe("restore", () => {
670
549
  test("no todo-state entries starts empty", async () => {
671
550
  const host = makeHost([{ type: "message", data: "hello" }]);
672
551
  registerTodo(host.pi);
673
- await host.emit(
674
- "session_start",
675
- {},
676
- { sessionManager: host.sessionManager },
677
- );
552
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
678
553
  const result = await run(host.execute, { action: "list" });
679
554
  expect(text(result)).toBe("(no todos)");
680
555
  });
@@ -682,11 +557,7 @@ describe("restore", () => {
682
557
  test("empty entries list starts empty", async () => {
683
558
  const host = makeHost([]);
684
559
  registerTodo(host.pi);
685
- await host.emit(
686
- "session_start",
687
- {},
688
- { sessionManager: host.sessionManager },
689
- );
560
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
690
561
  const result = await run(host.execute, { action: "list" });
691
562
  expect(text(result)).toBe("(no todos)");
692
563
  });
@@ -700,16 +571,139 @@ describe("restore", () => {
700
571
  },
701
572
  ]);
702
573
  registerTodo(host.pi);
703
- await host.emit(
704
- "session_start",
705
- {},
706
- { sessionManager: host.sessionManager },
707
- );
574
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
708
575
  const result = await run(host.execute, { action: "list" });
709
576
  expect(text(result)).toBe("(no todos)");
710
577
  });
711
578
  });
712
579
 
580
+ // ─── Skip-guard ─────────────────────────────────────────────────────────────────────
581
+
582
+ describe("skip-guard on marking done", () => {
583
+ test("warns when marking a later item done with earlier pending items", async () => {
584
+ const host = makeHost();
585
+ registerTodo(host.pi);
586
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
587
+ await run(host.execute, { action: "set", items: "a\nb\nc\nd" });
588
+ // Mark items 1 and 4 done, leaving 2 and 3 pending
589
+ await run(host.execute, { action: "update", id: 1, status: "done" });
590
+ const result = await run(host.execute, { action: "update", id: 4, status: "done" });
591
+ const out = text(result);
592
+ expect(out).toContain("\u26a0 Earlier items still incomplete");
593
+ expect(out).toContain("#2 (b)");
594
+ expect(out).toContain("#3 (c)");
595
+ expect(out).toContain("Mark each done or blocked before proceeding");
596
+ });
597
+
598
+ test("no warning when all earlier items are done", async () => {
599
+ const host = makeHost();
600
+ registerTodo(host.pi);
601
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
602
+ await run(host.execute, { action: "set", items: "a\nb\nc" });
603
+ await run(host.execute, { action: "update", id: 1, status: "done" });
604
+ await run(host.execute, { action: "update", id: 2, status: "done" });
605
+ const result = await run(host.execute, { action: "update", id: 3, status: "done" });
606
+ expect(text(result)).not.toContain("\u26a0");
607
+ });
608
+
609
+ test("no warning when marking the first item done", async () => {
610
+ const host = makeHost();
611
+ registerTodo(host.pi);
612
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
613
+ await run(host.execute, { action: "set", items: "a\nb" });
614
+ const result = await run(host.execute, { action: "update", id: 1, status: "done" });
615
+ expect(text(result)).not.toContain("\u26a0");
616
+ });
617
+
618
+ test("no warning when earlier items are blocked (only pending/in_progress trigger)", async () => {
619
+ const host = makeHost();
620
+ registerTodo(host.pi);
621
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
622
+ await run(host.execute, { action: "set", items: "a\nb\nc" });
623
+ await run(host.execute, { action: "update", id: 1, status: "blocked" });
624
+ await run(host.execute, { action: "update", id: 2, status: "done" });
625
+ const result = await run(host.execute, { action: "update", id: 3, status: "done" });
626
+ // blocked is an explicit decision, not incomplete — no warning
627
+ expect(text(result)).not.toContain("\u26a0");
628
+ });
629
+
630
+ test("warns about in_progress items too (not just pending)", async () => {
631
+ const host = makeHost();
632
+ registerTodo(host.pi);
633
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
634
+ await run(host.execute, { action: "set", items: "a\nb\nc" });
635
+ await run(host.execute, { action: "update", id: 1, status: "in_progress" });
636
+ // Mark item 3 done while item 1 is still in_progress
637
+ const result = await run(host.execute, { action: "update", id: 3, status: "done" });
638
+ const out = text(result);
639
+ expect(out).toContain("\u26a0");
640
+ expect(out).toContain("#1 (a)");
641
+ });
642
+
643
+ test("no skip-guard on in_progress (only on done)", async () => {
644
+ // in_progress uses cascade-close instead, which is different behavior
645
+ const host = makeHost();
646
+ registerTodo(host.pi);
647
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
648
+ await run(host.execute, { action: "set", items: "a\nb\nc" });
649
+ const result = await run(host.execute, { action: "update", id: 3, status: "in_progress" });
650
+ // Should cascade-close, not warn
651
+ expect(text(result)).not.toContain("\u26a0");
652
+ expect(text(result)).toContain("\u25cf 1. a"); // cascade-closed to done
653
+ expect(text(result)).toContain("\u25cf 2. b");
654
+ });
655
+ });
656
+
657
+ // ─── Turn-based reminder ────────────────────────────────────────────────────────────
658
+
659
+ describe("turn-based todo reminder", () => {
660
+ test("injects reminder every 10 turns when incomplete items exist", async () => {
661
+ const host = makeHost();
662
+ registerTodo(host.pi);
663
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
664
+ await run(host.execute, { action: "set", items: "a\nb" });
665
+
666
+ // Simulate 10 turns — only the 10th should inject
667
+ for (let i = 1; i <= 9; i++) {
668
+ const result = await host.emit("before_agent_start", { systemPrompt: "base" });
669
+ // before_agent_start returns undefined when no injection
670
+ expect(result).toBeUndefined();
671
+ }
672
+ // 10th turn should inject
673
+ const result = await host.emit("before_agent_start", { systemPrompt: "base" });
674
+ expect(result).toBeDefined();
675
+ const prompt = (result as { systemPrompt: string }).systemPrompt;
676
+ expect(prompt).toContain("base");
677
+ expect(prompt).toContain("Todo reminder");
678
+ expect(prompt).toContain("1. a");
679
+ expect(prompt).toContain("2. b");
680
+ });
681
+
682
+ test("does not inject when no todos exist", async () => {
683
+ const host = makeHost();
684
+ registerTodo(host.pi);
685
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
686
+
687
+ for (let i = 1; i <= 10; i++) {
688
+ const result = await host.emit("before_agent_start", { systemPrompt: "base" });
689
+ expect(result).toBeUndefined();
690
+ }
691
+ });
692
+
693
+ test("does not inject when all items are done", async () => {
694
+ const host = makeHost();
695
+ registerTodo(host.pi);
696
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
697
+ await run(host.execute, { action: "set", items: "a" });
698
+ await run(host.execute, { action: "update", id: 1, status: "done" });
699
+
700
+ for (let i = 1; i <= 10; i++) {
701
+ const result = await host.emit("before_agent_start", { systemPrompt: "base" });
702
+ expect(result).toBeUndefined();
703
+ }
704
+ });
705
+ });
706
+
713
707
  describe("renderTodoLines (colored TUI render)", () => {
714
708
  const items: TodoItem[] = [
715
709
  { id: 1, text: "alpha", status: "done" },
@@ -754,11 +748,7 @@ describe("renderResult snapshot isolation", () => {
754
748
  test("a rendered card keeps its state after todos mutate", async () => {
755
749
  const host = makeHost();
756
750
  registerTodo(host.pi);
757
- await host.emit(
758
- "session_start",
759
- {},
760
- { sessionManager: host.sessionManager },
761
- );
751
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
762
752
  await run(host.execute, { action: "set", items: "alpha\nbravo" });
763
753
 
764
754
  // Render once with a per-row state bag; snapshot is taken here.
@@ -789,8 +779,6 @@ describe("renderTodoSummaryLine (collapsed one-liner)", () => {
789
779
  { id: 1, text: "a", status: "done" },
790
780
  { id: 2, text: "b", status: "pending" },
791
781
  ];
792
- expect(renderTodoSummaryLine(items, tagTheme)).toBe(
793
- "[muted]Todos 1/2 done ✓[/]",
794
- );
782
+ expect(renderTodoSummaryLine(items, tagTheme)).toBe("[muted]Todos 1/2 done ✓[/]");
795
783
  });
796
784
  });
package/src/todo.ts CHANGED
@@ -9,14 +9,65 @@
9
9
  * checklist is seeded by the model via the tool's `set` action.
10
10
  */
11
11
 
12
+ import { existsSync, readFileSync } from "node:fs";
13
+ import { join } from "node:path";
12
14
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
15
  import { Text } from "@earendil-works/pi-tui";
14
16
  import { Type } from "typebox";
15
17
 
16
18
  import { once } from "./once.ts";
17
19
 
18
- /** Seconds before a todo card collapses to a one-line dim summary. */
19
- const COLLAPSE_AFTER_SEC = 10;
20
+ // ── Collapse config from ~/.pi/agent/pix.json ────────────────────────────────
21
+
22
+ interface CollapseConf {
23
+ enabled: boolean;
24
+ delaySec: number;
25
+ tools: Record<string, boolean | undefined>;
26
+ }
27
+
28
+ const DEFAULT_COLLAPSE: CollapseConf = {
29
+ enabled: true,
30
+ delaySec: 10,
31
+ tools: {},
32
+ };
33
+
34
+ function readCollapseConfig(): CollapseConf {
35
+ try {
36
+ const home = process.env.HOME ?? "";
37
+ if (!home) return DEFAULT_COLLAPSE;
38
+ const p = join(home, ".pi/agent", "pix.json");
39
+ if (!existsSync(p)) return DEFAULT_COLLAPSE;
40
+ const raw = JSON.parse(readFileSync(p, "utf-8")) as Record<string, unknown>;
41
+ const c = raw?.collapse as Record<string, unknown> | undefined;
42
+ if (!c || typeof c !== "object") return DEFAULT_COLLAPSE;
43
+ return {
44
+ enabled: typeof c.enabled === "boolean" ? c.enabled : true,
45
+ delaySec: typeof c.delaySec === "number" && c.delaySec > 0 ? c.delaySec : 10,
46
+ tools:
47
+ c.tools && typeof c.tools === "object"
48
+ ? (c.tools as Record<string, boolean | undefined>)
49
+ : {},
50
+ };
51
+ } catch {
52
+ return DEFAULT_COLLAPSE;
53
+ }
54
+ }
55
+
56
+ let collapseConf: CollapseConf | null = null;
57
+ function getCollapseConfig(): CollapseConf {
58
+ if (!collapseConf) collapseConf = readCollapseConfig();
59
+ return collapseConf;
60
+ }
61
+
62
+ function shouldCollapseTodo(): boolean {
63
+ const c = getCollapseConfig();
64
+ const perTool = c.tools.todo;
65
+ return typeof perTool === "boolean" ? perTool : c.enabled;
66
+ }
67
+
68
+ function collapseDelayMs(): number {
69
+ return getCollapseConfig().delaySec * 1000;
70
+ }
20
71
 
21
72
  export type TodoStatus = "pending" | "in_progress" | "done" | "blocked";
22
73
 
@@ -47,10 +98,7 @@ export type TodoTheme = {
47
98
  };
48
99
 
49
100
  /** One-line dim summary used once a card has collapsed. */
50
- export function renderTodoSummaryLine(
51
- items: TodoItem[],
52
- theme: TodoTheme,
53
- ): string {
101
+ export function renderTodoSummaryLine(items: TodoItem[], theme: TodoTheme): string {
54
102
  if (!items.length) return theme.fg("muted", "(no todos)");
55
103
  const done = items.filter((t) => t.status === "done").length;
56
104
  return theme.fg("muted", `Todos ${done}/${items.length} done ✓`);
@@ -75,6 +123,22 @@ export function renderTodoLines(items: TodoItem[], theme: TodoTheme): string {
75
123
  return `${head}\n${lines.join("\n")}`;
76
124
  }
77
125
 
126
+ /**
127
+ * Skip-guard: when marking an item done, check for earlier items still
128
+ * pending or in_progress. Returns a warning string or "" if none skipped.
129
+ */
130
+ function buildSkipWarning(items: TodoItem[], targetId: number): string {
131
+ const skipped = items.filter(
132
+ (o) => o.id < targetId && (o.status === "pending" || o.status === "in_progress"),
133
+ );
134
+ if (skipped.length === 0) return "";
135
+ const ids = skipped.map((s) => `#${s.id} (${s.text})`).join(", ");
136
+ return (
137
+ `\n\n\u26a0 Earlier items still incomplete: ${ids}. ` +
138
+ "Mark each done or blocked before proceeding."
139
+ );
140
+ }
141
+
78
142
  const parseItems = (raw: string): string[] =>
79
143
  raw
80
144
  .split("\n")
@@ -93,9 +157,7 @@ export default function registerTodo(pi: ExtensionAPI): void {
93
157
  function todoSummary(): string {
94
158
  if (!todos.length) return "(no todos)";
95
159
  const done = todos.filter((t) => t.status === "done").length;
96
- const lines = todos.map(
97
- (t) => `${TODO_GLYPH[t.status]} ${t.id}. ${t.text}`,
98
- );
160
+ const lines = todos.map((t) => `${TODO_GLYPH[t.status]} ${t.id}. ${t.text}`);
99
161
  return `Todos ${done}/${todos.length} done:\n${lines.join("\n")}`;
100
162
  }
101
163
 
@@ -112,6 +174,7 @@ export default function registerTodo(pi: ExtensionAPI): void {
112
174
  promptGuidelines: [
113
175
  "When you start executing a multi-step plan in BUILD mode, seed the todo list with `todo(action:'set', items: <plan Implementation Phases>)`.",
114
176
  "Mark each item in_progress before working it via `todo(action:'update', id, status)`; opening one auto-closes every earlier item, so just open the next and skipped steps mark done themselves.",
177
+ "When marking an item done, the tool checks for earlier incomplete items and warns you — resolve each skipped item (mark done or blocked) before moving on.",
115
178
  "Call `todo(action:'list')` to recover your place after long runs or context compaction.",
116
179
  ],
117
180
  parameters: Type.Object({
@@ -127,13 +190,10 @@ export default function registerTodo(pi: ExtensionAPI): void {
127
190
  ),
128
191
  items: Type.Optional(
129
192
  Type.String({
130
- description:
131
- "For set/add: newline-separated or numbered list of todo texts.",
193
+ description: "For set/add: newline-separated or numbered list of todo texts.",
132
194
  }),
133
195
  ),
134
- id: Type.Optional(
135
- Type.Number({ description: "For update: target todo id." }),
136
- ),
196
+ id: Type.Optional(Type.Number({ description: "For update: target todo id." })),
137
197
  status: Type.Optional(
138
198
  Type.Union(
139
199
  [
@@ -161,15 +221,14 @@ export default function registerTodo(pi: ExtensionAPI): void {
161
221
  };
162
222
  if (!state.snapshot) state.snapshot = todos.map((t) => ({ ...t }));
163
223
  // Start the collapse timer once per row; invalidate() triggers rerender.
164
- if (!state.collapsed && !state.timer) {
224
+ // Config-driven: reads from ~/.pi/agent/pix.json collapse section.
225
+ if (shouldCollapseTodo() && !state.collapsed && !state.timer) {
165
226
  state.timer = setTimeout(() => {
166
227
  state.collapsed = true;
167
228
  context.invalidate();
168
- }, COLLAPSE_AFTER_SEC * 1000);
229
+ }, collapseDelayMs());
169
230
  }
170
- const render = state.collapsed
171
- ? renderTodoSummaryLine
172
- : renderTodoLines;
231
+ const render = state.collapsed ? renderTodoSummaryLine : renderTodoLines;
173
232
  return new Text(render(state.snapshot, theme as TodoTheme), 0, 0);
174
233
  },
175
234
 
@@ -205,8 +264,7 @@ export default function registerTodo(pi: ExtensionAPI): void {
205
264
  case "add": {
206
265
  const texts = parseItems(params.items ?? "");
207
266
  if (!texts.length) return fail("add requires non-empty `items`.");
208
- for (const text of texts)
209
- todos.push({ id: nextTodoId++, text, status: "pending" });
267
+ for (const text of texts) todos.push({ id: nextTodoId++, text, status: "pending" });
210
268
  persistTodos();
211
269
  return ok(todoSummary());
212
270
  }
@@ -214,6 +272,7 @@ export default function registerTodo(pi: ExtensionAPI): void {
214
272
  case "update": {
215
273
  const t = todos.find((x) => x.id === params.id);
216
274
  if (!t) return fail(`No todo with id ${params.id}.`);
275
+ let skipWarning = "";
217
276
  if (params.status) {
218
277
  // Sequential-progress invariant: opening a task means everything
219
278
  // before it is finished. Cascade-close every earlier pending or
@@ -223,15 +282,17 @@ export default function registerTodo(pi: ExtensionAPI): void {
223
282
  for (const other of todos)
224
283
  if (
225
284
  other.id < t.id &&
226
- (other.status === "pending" ||
227
- other.status === "in_progress")
285
+ (other.status === "pending" || other.status === "in_progress")
228
286
  )
229
287
  other.status = "done";
288
+
289
+ if (params.status === "done") skipWarning = buildSkipWarning(todos, t.id);
290
+
230
291
  t.status = params.status;
231
292
  }
232
293
  if (params.text) t.text = params.text;
233
294
  persistTodos();
234
- return ok(todoSummary());
295
+ return ok(todoSummary() + skipWarning);
235
296
  }
236
297
 
237
298
  case "clear":
@@ -246,6 +307,32 @@ export default function registerTodo(pi: ExtensionAPI): void {
246
307
  },
247
308
  });
248
309
 
310
+ // ── Turn-based reminder ─────────────────────────────────────────────
311
+ // Every TODO_REMINDER_INTERVAL turns, inject the current todo summary
312
+ // into the system prompt so the model stays aware of pending work and
313
+ // can't hand-wave or ignore incomplete items.
314
+ const TODO_REMINDER_INTERVAL = 10;
315
+ let todoTurnCount = 0;
316
+
317
+ pi.on("before_agent_start", async (event) => {
318
+ todoTurnCount++;
319
+ // Only inject when there are active (non-empty) todos
320
+ if (todos.length === 0) return;
321
+ // Check if any items are still incomplete
322
+ const hasIncomplete = todos.some((t) => t.status === "pending" || t.status === "in_progress");
323
+ if (!hasIncomplete) return;
324
+ // Fire on every Nth turn
325
+ if (todoTurnCount % TODO_REMINDER_INTERVAL !== 0) return;
326
+
327
+ const reminder =
328
+ "Todo reminder — incomplete items remain:\n" +
329
+ todoSummary() +
330
+ "\nCall `todo(action:'list')` to review, then continue working through pending items.";
331
+
332
+ const existing = event.systemPrompt ?? "";
333
+ return { systemPrompt: existing ? `${existing}\n\n${reminder}` : reminder };
334
+ });
335
+
249
336
  // Restore the checklist from session entries so it survives restart.
250
337
  pi.on("session_start", async (_event, ctx) => {
251
338
  const entries = ctx.sessionManager.getEntries() as Array<{
@@ -258,9 +345,7 @@ export default function registerTodo(pi: ExtensionAPI): void {
258
345
  .pop();
259
346
  if (Array.isArray(lastTodo?.data?.todos)) {
260
347
  todos = lastTodo.data.todos;
261
- nextTodoId =
262
- lastTodo.data.nextTodoId ??
263
- todos.reduce((m, t) => Math.max(m, t.id + 1), 1);
348
+ nextTodoId = lastTodo.data.nextTodoId ?? todos.reduce((m, t) => Math.max(m, t.id + 1), 1);
264
349
  }
265
350
  });
266
351
  });