mitsupi 1.0.2 → 1.0.3

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.
@@ -1,13 +1,35 @@
1
1
  /**
2
- * Todo storage settings are kept in <todo-dir>/settings.json.
2
+ * This extension stores todo items as files under <todo-dir> (defaults to .pi/todos,
3
+ * or the path in PI_TODO_PATH). Each todo is a standalone markdown file named
4
+ * <id>.md and an optional <id>.lock file is used while a session is editing it.
5
+ *
6
+ * File format in .pi/todos:
7
+ * - The file starts with a JSON object (not YAML) containing the front matter:
8
+ * { id, title, tags, status, created_at, assigned_to_session }
9
+ * - After the JSON block comes optional markdown body text separated by a blank line.
10
+ * - Example:
11
+ * {
12
+ * "id": "deadbeef",
13
+ * "title": "Add tests",
14
+ * "tags": ["qa"],
15
+ * "status": "open",
16
+ * "created_at": "2026-01-25T17:00:00.000Z",
17
+ * "assigned_to_session": "session.json"
18
+ * }
19
+ *
20
+ * Notes about the work go here.
3
21
  *
22
+ * Todo storage settings are kept in <todo-dir>/settings.json.
4
23
  * Defaults:
5
24
  * {
6
25
  * "gc": true, // delete closed todos older than gcDays on startup
7
26
  * "gcDays": 7 // age threshold for GC (days since created_at)
8
27
  * }
28
+ *
29
+ * Use `/todos` to bring up the visual todo manager or just let the LLM use them
30
+ * naturally.
9
31
  */
10
- import { DynamicBorder, getMarkdownTheme, keyHint, type ExtensionAPI, type ExtensionContext, type Theme } from "@mariozechner/pi-coding-agent";
32
+ import { DynamicBorder, copyToClipboard, getMarkdownTheme, keyHint, type ExtensionAPI, type ExtensionContext, type Theme } from "@mariozechner/pi-coding-agent";
11
33
  import { StringEnum } from "@mariozechner/pi-ai";
12
34
  import { Type } from "@sinclair/typebox";
13
35
  import path from "node:path";
@@ -33,9 +55,10 @@ import {
33
55
  } from "@mariozechner/pi-tui";
34
56
 
35
57
  const TODO_DIR_NAME = ".pi/todos";
36
- const TODO_PATH_ENV = "PI_ISSUE_PATH";
58
+ const TODO_PATH_ENV = "PI_TODO_PATH";
37
59
  const TODO_SETTINGS_NAME = "settings.json";
38
60
  const TODO_ID_PREFIX = "TODO-";
61
+ const TODO_ID_PATTERN = /^[a-f0-9]{8}$/i;
39
62
  const DEFAULT_TODO_SETTINGS = {
40
63
  gc: true,
41
64
  gcDays: 7,
@@ -48,6 +71,7 @@ interface TodoFrontMatter {
48
71
  tags: string[];
49
72
  status: string;
50
73
  created_at: string;
74
+ assigned_to_session?: string;
51
75
  }
52
76
 
53
77
  interface TodoRecord extends TodoFrontMatter {
@@ -67,7 +91,17 @@ interface TodoSettings {
67
91
  }
68
92
 
69
93
  const TodoParams = Type.Object({
70
- action: StringEnum(["list", "list-all", "get", "create", "update", "append", "delete"] as const),
94
+ action: StringEnum([
95
+ "list",
96
+ "list-all",
97
+ "get",
98
+ "create",
99
+ "update",
100
+ "append",
101
+ "delete",
102
+ "claim",
103
+ "release",
104
+ ] as const),
71
105
  id: Type.Optional(
72
106
  Type.String({ description: "Todo id (TODO-<hex> or raw hex filename)" }),
73
107
  ),
@@ -77,17 +111,39 @@ const TodoParams = Type.Object({
77
111
  body: Type.Optional(
78
112
  Type.String({ description: "Long-form details (markdown). Update replaces; append adds." }),
79
113
  ),
114
+ force: Type.Optional(Type.Boolean({ description: "Override another session's assignment" })),
80
115
  });
81
116
 
82
- type TodoAction = "list" | "list-all" | "get" | "create" | "update" | "append" | "delete";
83
-
84
- type TodoOverlayAction = "work" | "refine" | "close" | "reopen" | "delete" | "cancel" | "actions";
85
-
86
- type TodoMenuAction = TodoOverlayAction | "copy-path" | "close-dialog" | "view";
117
+ type TodoAction =
118
+ | "list"
119
+ | "list-all"
120
+ | "get"
121
+ | "create"
122
+ | "update"
123
+ | "append"
124
+ | "delete"
125
+ | "claim"
126
+ | "release";
127
+
128
+ type TodoOverlayAction = "back" | "work";
129
+
130
+ type TodoMenuAction =
131
+ | "work"
132
+ | "refine"
133
+ | "close"
134
+ | "reopen"
135
+ | "release"
136
+ | "delete"
137
+ | "copy"
138
+ | "view";
87
139
 
88
140
  type TodoToolDetails =
89
- | { action: "list" | "list-all"; todos: TodoFrontMatter[]; error?: string }
90
- | { action: "get" | "create" | "update" | "append" | "delete"; todo: TodoRecord; error?: string };
141
+ | { action: "list" | "list-all"; todos: TodoFrontMatter[]; currentSessionId?: string; error?: string }
142
+ | {
143
+ action: "get" | "create" | "update" | "append" | "delete" | "claim" | "release";
144
+ todo: TodoRecord;
145
+ error?: string;
146
+ };
91
147
 
92
148
  function formatTodoId(id: string): string {
93
149
  return `${TODO_ID_PREFIX}${id}`;
@@ -104,6 +160,14 @@ function normalizeTodoId(id: string): string {
104
160
  return trimmed;
105
161
  }
106
162
 
163
+ function validateTodoId(id: string): { id: string } | { error: string } {
164
+ const normalized = normalizeTodoId(id);
165
+ if (!normalized || !TODO_ID_PATTERN.test(normalized)) {
166
+ return { error: "Invalid todo id. Expected TODO-<hex>." };
167
+ }
168
+ return { id: normalized.toLowerCase() };
169
+ }
170
+
107
171
  function displayTodoId(id: string): string {
108
172
  return formatTodoId(normalizeTodoId(id));
109
173
  }
@@ -112,18 +176,28 @@ function isTodoClosed(status: string): boolean {
112
176
  return ["closed", "done"].includes(status.toLowerCase());
113
177
  }
114
178
 
179
+ function clearAssignmentIfClosed(todo: TodoFrontMatter): void {
180
+ if (isTodoClosed(getTodoStatus(todo))) {
181
+ todo.assigned_to_session = undefined;
182
+ }
183
+ }
184
+
115
185
  function sortTodos(todos: TodoFrontMatter[]): TodoFrontMatter[] {
116
186
  return [...todos].sort((a, b) => {
117
187
  const aClosed = isTodoClosed(a.status);
118
188
  const bClosed = isTodoClosed(b.status);
119
189
  if (aClosed !== bClosed) return aClosed ? 1 : -1;
190
+ const aAssigned = !aClosed && Boolean(a.assigned_to_session);
191
+ const bAssigned = !bClosed && Boolean(b.assigned_to_session);
192
+ if (aAssigned !== bAssigned) return aAssigned ? -1 : 1;
120
193
  return (a.created_at || "").localeCompare(b.created_at || "");
121
194
  });
122
195
  }
123
196
 
124
197
  function buildTodoSearchText(todo: TodoFrontMatter): string {
125
198
  const tags = todo.tags.join(" ");
126
- return `${formatTodoId(todo.id)} ${todo.id} ${todo.title} ${tags} ${todo.status}`.trim();
199
+ const assignment = todo.assigned_to_session ? `assigned:${todo.assigned_to_session}` : "";
200
+ return `${formatTodoId(todo.id)} ${todo.id} ${todo.title} ${tags} ${todo.status} ${assignment}`.trim();
127
201
  }
128
202
 
129
203
  function filterTodos(todos: TodoFrontMatter[], query: string): TodoFrontMatter[] {
@@ -160,6 +234,9 @@ function filterTodos(todos: TodoFrontMatter[], query: string): TodoFrontMatter[]
160
234
  const aClosed = isTodoClosed(a.todo.status);
161
235
  const bClosed = isTodoClosed(b.todo.status);
162
236
  if (aClosed !== bClosed) return aClosed ? 1 : -1;
237
+ const aAssigned = !aClosed && Boolean(a.todo.assigned_to_session);
238
+ const bAssigned = !bClosed && Boolean(b.todo.assigned_to_session);
239
+ if (aAssigned !== bAssigned) return aAssigned ? -1 : 1;
163
240
  return a.score - b.score;
164
241
  })
165
242
  .map((match) => match.todo);
@@ -177,6 +254,7 @@ class TodoSelectorComponent extends Container implements Focusable {
177
254
  private theme: Theme;
178
255
  private headerText: Text;
179
256
  private hintText: Text;
257
+ private currentSessionId?: string;
180
258
 
181
259
  private _focused = false;
182
260
  get focused(): boolean {
@@ -194,11 +272,13 @@ class TodoSelectorComponent extends Container implements Focusable {
194
272
  onSelect: (todo: TodoFrontMatter) => void,
195
273
  onCancel: () => void,
196
274
  initialSearchInput?: string,
275
+ currentSessionId?: string,
197
276
  private onQuickAction?: (todo: TodoFrontMatter, action: "work" | "refine") => void,
198
277
  ) {
199
278
  super();
200
279
  this.tui = tui;
201
280
  this.theme = theme;
281
+ this.currentSessionId = currentSessionId;
202
282
  this.allTodos = todos;
203
283
  this.filteredTodos = todos;
204
284
  this.onSelectCallback = onSelect;
@@ -293,12 +373,14 @@ class TodoSelectorComponent extends Container implements Focusable {
293
373
  const titleColor = isSelected ? "accent" : closed ? "dim" : "text";
294
374
  const statusColor = closed ? "dim" : "success";
295
375
  const tagText = todo.tags.length ? ` [${todo.tags.join(", ")}]` : "";
376
+ const assignmentText = renderAssignmentSuffix(this.theme, todo, this.currentSessionId);
296
377
  const line =
297
378
  prefix +
298
379
  this.theme.fg("accent", formatTodoId(todo.id)) +
299
380
  " " +
300
381
  this.theme.fg(titleColor, todo.title || "(untitled)") +
301
382
  this.theme.fg("muted", tagText) +
383
+ assignmentText +
302
384
  " " +
303
385
  this.theme.fg(statusColor, `(${todo.status || "open"})`);
304
386
  this.listContainer.addChild(new Text(line, 0, 0));
@@ -358,6 +440,113 @@ class TodoSelectorComponent extends Container implements Focusable {
358
440
  }
359
441
  }
360
442
 
443
+ class TodoActionMenuComponent extends Container {
444
+ private selectList: SelectList;
445
+ private onSelectCallback: (action: TodoMenuAction) => void;
446
+ private onCancelCallback: () => void;
447
+
448
+ constructor(
449
+ theme: Theme,
450
+ todo: TodoRecord,
451
+ onSelect: (action: TodoMenuAction) => void,
452
+ onCancel: () => void,
453
+ ) {
454
+ super();
455
+ this.onSelectCallback = onSelect;
456
+ this.onCancelCallback = onCancel;
457
+
458
+ const closed = isTodoClosed(todo.status);
459
+ const title = todo.title || "(untitled)";
460
+ const options: SelectItem[] = [
461
+ { value: "view", label: "view", description: "View todo" },
462
+ { value: "work", label: "work", description: "Work on todo" },
463
+ { value: "refine", label: "refine", description: "Refine task" },
464
+ ...(closed
465
+ ? [{ value: "reopen", label: "reopen", description: "Reopen todo" }]
466
+ : [{ value: "close", label: "close", description: "Close todo" }]),
467
+ ...(todo.assigned_to_session
468
+ ? [{ value: "release", label: "release", description: "Release assignment" }]
469
+ : []),
470
+ { value: "copy", label: "copy", description: "Copy path to clipboard" },
471
+ { value: "delete", label: "delete", description: "Delete todo" },
472
+ ];
473
+
474
+ this.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
475
+ this.addChild(
476
+ new Text(
477
+ theme.fg(
478
+ "accent",
479
+ theme.bold(`Actions for ${formatTodoId(todo.id)} "${title}"`),
480
+ ),
481
+ ),
482
+ );
483
+
484
+ this.selectList = new SelectList(options, options.length, {
485
+ selectedPrefix: (text) => theme.fg("accent", text),
486
+ selectedText: (text) => theme.fg("accent", text),
487
+ description: (text) => theme.fg("muted", text),
488
+ scrollInfo: (text) => theme.fg("dim", text),
489
+ noMatch: (text) => theme.fg("warning", text),
490
+ });
491
+
492
+ this.selectList.onSelect = (item) => this.onSelectCallback(item.value as TodoMenuAction);
493
+ this.selectList.onCancel = () => this.onCancelCallback();
494
+
495
+ this.addChild(this.selectList);
496
+ this.addChild(new Text(theme.fg("dim", "Enter to confirm • Esc back")));
497
+ this.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
498
+ }
499
+
500
+ handleInput(keyData: string): void {
501
+ this.selectList.handleInput(keyData);
502
+ }
503
+
504
+ override invalidate(): void {
505
+ super.invalidate();
506
+ }
507
+ }
508
+
509
+ class TodoDeleteConfirmComponent extends Container {
510
+ private selectList: SelectList;
511
+ private onConfirm: (confirmed: boolean) => void;
512
+
513
+ constructor(theme: Theme, message: string, onConfirm: (confirmed: boolean) => void) {
514
+ super();
515
+ this.onConfirm = onConfirm;
516
+
517
+ const options: SelectItem[] = [
518
+ { value: "yes", label: "Yes" },
519
+ { value: "no", label: "No" },
520
+ ];
521
+
522
+ this.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
523
+ this.addChild(new Text(theme.fg("accent", message)));
524
+
525
+ this.selectList = new SelectList(options, options.length, {
526
+ selectedPrefix: (text) => theme.fg("accent", text),
527
+ selectedText: (text) => theme.fg("accent", text),
528
+ description: (text) => theme.fg("muted", text),
529
+ scrollInfo: (text) => theme.fg("dim", text),
530
+ noMatch: (text) => theme.fg("warning", text),
531
+ });
532
+
533
+ this.selectList.onSelect = (item) => this.onConfirm(item.value === "yes");
534
+ this.selectList.onCancel = () => this.onConfirm(false);
535
+
536
+ this.addChild(this.selectList);
537
+ this.addChild(new Text(theme.fg("dim", "Enter to confirm • Esc back")));
538
+ this.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
539
+ }
540
+
541
+ handleInput(keyData: string): void {
542
+ this.selectList.handleInput(keyData);
543
+ }
544
+
545
+ override invalidate(): void {
546
+ super.invalidate();
547
+ }
548
+ }
549
+
361
550
  class TodoDetailOverlayComponent {
362
551
  private todo: TodoRecord;
363
552
  private theme: Theme;
@@ -384,7 +573,11 @@ class TodoDetailOverlayComponent {
384
573
  handleInput(keyData: string): void {
385
574
  const kb = getEditorKeybindings();
386
575
  if (kb.matches(keyData, "selectCancel")) {
387
- this.onAction("cancel");
576
+ this.onAction("back");
577
+ return;
578
+ }
579
+ if (kb.matches(keyData, "selectConfirm")) {
580
+ this.onAction("work");
388
581
  return;
389
582
  }
390
583
  if (kb.matches(keyData, "selectUp")) {
@@ -403,30 +596,6 @@ class TodoDetailOverlayComponent {
403
596
  this.scrollBy(this.viewHeight || 1);
404
597
  return;
405
598
  }
406
- if (keyData === "r" || keyData === "R") {
407
- this.onAction("refine");
408
- return;
409
- }
410
- if (keyData === "c" || keyData === "C") {
411
- this.onAction("close");
412
- return;
413
- }
414
- if (keyData === "o" || keyData === "O") {
415
- this.onAction("reopen");
416
- return;
417
- }
418
- if (keyData === "w" || keyData === "W") {
419
- this.onAction("work");
420
- return;
421
- }
422
- if (keyData === "a" || keyData === "A") {
423
- this.onAction("actions");
424
- return;
425
- }
426
- if (keyData === "d" || keyData === "D") {
427
- this.onAction("delete");
428
- return;
429
- }
430
599
  }
431
600
 
432
601
  render(width: number): string[] {
@@ -512,16 +681,9 @@ class TodoDetailOverlayComponent {
512
681
  }
513
682
 
514
683
  private buildActionLine(width: number): string {
515
- const closed = isTodoClosed(this.todo.status);
516
- const refine = this.theme.fg("accent", "r") + this.theme.fg("muted", " refine task");
517
- const work = this.theme.fg("accent", "w") + this.theme.fg("muted", " work on todo");
518
- const close = this.theme.fg("accent", "c") + this.theme.fg("muted", " close task");
519
- const reopen = this.theme.fg("accent", "o") + this.theme.fg("muted", " reopen task");
520
- const statusAction = closed ? reopen : close;
521
- const actions = this.theme.fg("accent", "a") + this.theme.fg("muted", " actions");
522
- const del = this.theme.fg("error", "d") + this.theme.fg("muted", " delete todo");
684
+ const work = this.theme.fg("accent", "enter") + this.theme.fg("muted", " work on todo");
523
685
  const back = this.theme.fg("dim", "esc back");
524
- const pieces = [work, refine, statusAction, actions, del, back];
686
+ const pieces = [work, back];
525
687
 
526
688
  let line = pieces.join(this.theme.fg("muted", " • "));
527
689
  if (this.totalLines > this.viewHeight) {
@@ -548,6 +710,14 @@ function getTodosDir(cwd: string): string {
548
710
  return path.resolve(cwd, TODO_DIR_NAME);
549
711
  }
550
712
 
713
+ function getTodosDirLabel(cwd: string): string {
714
+ const overridePath = process.env[TODO_PATH_ENV];
715
+ if (overridePath && overridePath.trim()) {
716
+ return path.resolve(cwd, overridePath.trim());
717
+ }
718
+ return TODO_DIR_NAME;
719
+ }
720
+
551
721
  function getTodoSettingsPath(todosDir: string): string {
552
722
  return path.join(todosDir, TODO_SETTINGS_NAME);
553
723
  }
@@ -564,26 +734,15 @@ function normalizeTodoSettings(raw: Partial<TodoSettings>): TodoSettings {
564
734
  async function readTodoSettings(todosDir: string): Promise<TodoSettings> {
565
735
  const settingsPath = getTodoSettingsPath(todosDir);
566
736
  let data: Partial<TodoSettings> = {};
567
- let shouldWrite = false;
568
737
 
569
738
  try {
570
739
  const raw = await fs.readFile(settingsPath, "utf8");
571
740
  data = JSON.parse(raw) as Partial<TodoSettings>;
572
741
  } catch {
573
- shouldWrite = true;
742
+ data = {};
574
743
  }
575
744
 
576
- const normalized = normalizeTodoSettings(data);
577
- if (
578
- shouldWrite ||
579
- data.gc === undefined ||
580
- data.gcDays === undefined ||
581
- !Number.isFinite(data.gcDays)
582
- ) {
583
- await fs.writeFile(settingsPath, JSON.stringify(normalized, null, 2) + "\n", "utf8");
584
- }
585
-
586
- return normalized;
745
+ return normalizeTodoSettings(data);
587
746
  }
588
747
 
589
748
  async function garbageCollectTodos(todosDir: string, settings: TodoSettings): Promise<void> {
@@ -628,24 +787,6 @@ function getLockPath(todosDir: string, id: string): string {
628
787
  return path.join(todosDir, `${id}.lock`);
629
788
  }
630
789
 
631
- function stripQuotes(value: string): string {
632
- const trimmed = value.trim();
633
- if ((trimmed.startsWith("\"") && trimmed.endsWith("\"")) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
634
- return trimmed.slice(1, -1);
635
- }
636
- return trimmed;
637
- }
638
-
639
- function parseTagsInline(value: string): string[] {
640
- const inner = value.trim().slice(1, -1);
641
- if (!inner.trim()) return [];
642
- return inner
643
- .split(",")
644
- .map((item) => stripQuotes(item))
645
- .map((item) => item.trim())
646
- .filter(Boolean);
647
- }
648
-
649
790
  function parseFrontMatter(text: string, idFallback: string): TodoFrontMatter {
650
791
  const data: TodoFrontMatter = {
651
792
  id: idFallback,
@@ -653,67 +794,86 @@ function parseFrontMatter(text: string, idFallback: string): TodoFrontMatter {
653
794
  tags: [],
654
795
  status: "open",
655
796
  created_at: "",
797
+ assigned_to_session: undefined,
656
798
  };
657
799
 
658
- let currentKey: string | null = null;
659
- for (const rawLine of text.split(/\r?\n/)) {
660
- const line = rawLine.trim();
661
- if (!line) continue;
800
+ const trimmed = text.trim();
801
+ if (!trimmed) return data;
662
802
 
663
- const listMatch = currentKey === "tags" ? line.match(/^-\s*(.+)$/) : null;
664
- if (listMatch) {
665
- data.tags.push(stripQuotes(listMatch[1]));
666
- continue;
803
+ try {
804
+ const parsed = JSON.parse(trimmed) as Partial<TodoFrontMatter> | null;
805
+ if (!parsed || typeof parsed !== "object") return data;
806
+ if (typeof parsed.id === "string" && parsed.id) data.id = parsed.id;
807
+ if (typeof parsed.title === "string") data.title = parsed.title;
808
+ if (typeof parsed.status === "string" && parsed.status) data.status = parsed.status;
809
+ if (typeof parsed.created_at === "string") data.created_at = parsed.created_at;
810
+ if (typeof parsed.assigned_to_session === "string" && parsed.assigned_to_session.trim()) {
811
+ data.assigned_to_session = parsed.assigned_to_session;
812
+ }
813
+ if (Array.isArray(parsed.tags)) {
814
+ data.tags = parsed.tags.filter((tag): tag is string => typeof tag === "string");
667
815
  }
816
+ } catch {
817
+ return data;
818
+ }
819
+
820
+ return data;
821
+ }
668
822
 
669
- const match = line.match(/^(?<key>[a-zA-Z0-9_]+):\s*(?<value>.*)$/);
670
- if (!match?.groups) continue;
823
+ function findJsonObjectEnd(content: string): number {
824
+ let depth = 0;
825
+ let inString = false;
826
+ let escaped = false;
671
827
 
672
- const key = match.groups.key;
673
- const value = match.groups.value ?? "";
674
- currentKey = null;
828
+ for (let i = 0; i < content.length; i += 1) {
829
+ const char = content[i];
675
830
 
676
- if (key === "tags") {
677
- if (!value) {
678
- currentKey = "tags";
831
+ if (inString) {
832
+ if (escaped) {
833
+ escaped = false;
679
834
  continue;
680
835
  }
681
- if (value.startsWith("[") && value.endsWith("]")) {
682
- data.tags = parseTagsInline(value);
836
+ if (char === "\\") {
837
+ escaped = true;
683
838
  continue;
684
839
  }
685
- data.tags = [stripQuotes(value)].filter(Boolean);
840
+ if (char === "\"") {
841
+ inString = false;
842
+ }
686
843
  continue;
687
844
  }
688
845
 
689
- switch (key) {
690
- case "id":
691
- data.id = stripQuotes(value) || data.id;
692
- break;
693
- case "title":
694
- data.title = stripQuotes(value);
695
- break;
696
- case "status":
697
- data.status = stripQuotes(value) || data.status;
698
- break;
699
- case "created_at":
700
- data.created_at = stripQuotes(value);
701
- break;
702
- default:
703
- break;
846
+ if (char === "\"") {
847
+ inString = true;
848
+ continue;
849
+ }
850
+
851
+ if (char === "{") {
852
+ depth += 1;
853
+ continue;
854
+ }
855
+
856
+ if (char === "}") {
857
+ depth -= 1;
858
+ if (depth === 0) return i;
704
859
  }
705
860
  }
706
861
 
707
- return data;
862
+ return -1;
708
863
  }
709
864
 
710
865
  function splitFrontMatter(content: string): { frontMatter: string; body: string } {
711
- const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
712
- if (!match) {
866
+ if (!content.startsWith("{")) {
713
867
  return { frontMatter: "", body: content };
714
868
  }
715
- const frontMatter = match[1] ?? "";
716
- const body = content.slice(match[0].length);
869
+
870
+ const endIndex = findJsonObjectEnd(content);
871
+ if (endIndex === -1) {
872
+ return { frontMatter: "", body: content };
873
+ }
874
+
875
+ const frontMatter = content.slice(0, endIndex + 1);
876
+ const body = content.slice(endIndex + 1).replace(/^\r?\n+/, "");
717
877
  return { frontMatter, body };
718
878
  }
719
879
 
@@ -726,31 +886,29 @@ function parseTodoContent(content: string, idFallback: string): TodoRecord {
726
886
  tags: parsed.tags ?? [],
727
887
  status: parsed.status,
728
888
  created_at: parsed.created_at,
889
+ assigned_to_session: parsed.assigned_to_session,
729
890
  body: body ?? "",
730
891
  };
731
892
  }
732
893
 
733
- function escapeYaml(value: string): string {
734
- return value.replace(/\\/g, "\\\\").replace(/\"/g, "\\\"");
735
- }
736
-
737
894
  function serializeTodo(todo: TodoRecord): string {
738
- const tags = todo.tags ?? [];
739
- const lines = [
740
- "---",
741
- `id: \"${escapeYaml(todo.id)}\"`,
742
- `title: \"${escapeYaml(todo.title)}\"`,
743
- "tags:",
744
- ...tags.map((tag) => ` - \"${escapeYaml(tag)}\"`),
745
- `status: \"${escapeYaml(todo.status)}\"`,
746
- `created_at: \"${escapeYaml(todo.created_at)}\"`,
747
- "---",
748
- "",
749
- ];
895
+ const frontMatter = JSON.stringify(
896
+ {
897
+ id: todo.id,
898
+ title: todo.title,
899
+ tags: todo.tags ?? [],
900
+ status: todo.status,
901
+ created_at: todo.created_at,
902
+ assigned_to_session: todo.assigned_to_session || undefined,
903
+ },
904
+ null,
905
+ 2,
906
+ );
750
907
 
751
908
  const body = todo.body ?? "";
752
909
  const trimmedBody = body.replace(/^\n+/, "").replace(/\s+$/, "");
753
- return `${lines.join("\n")}${trimmedBody ? `${trimmedBody}\n` : ""}`;
910
+ if (!trimmedBody) return `${frontMatter}\n`;
911
+ return `${frontMatter}\n\n${trimmedBody}\n`;
754
912
  }
755
913
 
756
914
  async function ensureTodosDir(todosDir: string) {
@@ -877,6 +1035,7 @@ async function listTodos(todosDir: string): Promise<TodoFrontMatter[]> {
877
1035
  tags: parsed.tags ?? [],
878
1036
  status: parsed.status,
879
1037
  created_at: parsed.created_at,
1038
+ assigned_to_session: parsed.assigned_to_session,
880
1039
  });
881
1040
  } catch {
882
1041
  // ignore unreadable todo
@@ -909,6 +1068,7 @@ function listTodosSync(todosDir: string): TodoFrontMatter[] {
909
1068
  tags: parsed.tags ?? [],
910
1069
  status: parsed.status,
911
1070
  created_at: parsed.created_at,
1071
+ assigned_to_session: parsed.assigned_to_session,
912
1072
  });
913
1073
  } catch {
914
1074
  // ignore
@@ -926,36 +1086,61 @@ function getTodoStatus(todo: TodoFrontMatter): string {
926
1086
  return todo.status || "open";
927
1087
  }
928
1088
 
1089
+ function formatAssignmentSuffix(todo: TodoFrontMatter): string {
1090
+ return todo.assigned_to_session ? ` (assigned: ${todo.assigned_to_session})` : "";
1091
+ }
1092
+
1093
+ function renderAssignmentSuffix(
1094
+ theme: Theme,
1095
+ todo: TodoFrontMatter,
1096
+ currentSessionId?: string,
1097
+ ): string {
1098
+ if (!todo.assigned_to_session) return "";
1099
+ const isCurrent = todo.assigned_to_session === currentSessionId;
1100
+ const color = isCurrent ? "success" : "dim";
1101
+ const suffix = isCurrent ? ", current" : "";
1102
+ return theme.fg(color, ` (assigned: ${todo.assigned_to_session}${suffix})`);
1103
+ }
1104
+
929
1105
  function formatTodoHeading(todo: TodoFrontMatter): string {
930
1106
  const tagText = todo.tags.length ? ` [${todo.tags.join(", ")}]` : "";
931
- return `${formatTodoId(todo.id)} ${getTodoTitle(todo)}${tagText}`;
1107
+ return `${formatTodoId(todo.id)} ${getTodoTitle(todo)}${tagText}${formatAssignmentSuffix(todo)}`;
932
1108
  }
933
1109
 
934
1110
  function buildRefinePrompt(todoId: string, title: string): string {
935
1111
  return (
936
1112
  `let's refine task ${formatTodoId(todoId)} "${title}": ` +
937
- "Please rewrite the todo body with a thorough, structured description so an engineer or agent can work without extra investigation. " +
938
- "Include: Context, Goals, Scope/Non-scope, Checklist, Acceptance Criteria, and Risks/Open questions.\n\n"
1113
+ "Ask me for the missing details needed to refine the todo together. Do not rewrite the todo yet and do not make assumptions. " +
1114
+ "Ask clear, concrete questions and wait for my answers before drafting any structured description.\n\n"
939
1115
  );
940
1116
  }
941
1117
 
942
- function splitTodosByStatus(todos: TodoFrontMatter[]): { openTodos: TodoFrontMatter[]; closedTodos: TodoFrontMatter[] } {
1118
+ function splitTodosByAssignment(todos: TodoFrontMatter[]): {
1119
+ assignedTodos: TodoFrontMatter[];
1120
+ openTodos: TodoFrontMatter[];
1121
+ closedTodos: TodoFrontMatter[];
1122
+ } {
1123
+ const assignedTodos: TodoFrontMatter[] = [];
943
1124
  const openTodos: TodoFrontMatter[] = [];
944
1125
  const closedTodos: TodoFrontMatter[] = [];
945
1126
  for (const todo of todos) {
946
1127
  if (isTodoClosed(getTodoStatus(todo))) {
947
1128
  closedTodos.push(todo);
1129
+ continue;
1130
+ }
1131
+ if (todo.assigned_to_session) {
1132
+ assignedTodos.push(todo);
948
1133
  } else {
949
1134
  openTodos.push(todo);
950
1135
  }
951
1136
  }
952
- return { openTodos, closedTodos };
1137
+ return { assignedTodos, openTodos, closedTodos };
953
1138
  }
954
1139
 
955
1140
  function formatTodoList(todos: TodoFrontMatter[]): string {
956
1141
  if (!todos.length) return "No todos.";
957
1142
 
958
- const { openTodos } = splitTodosByStatus(todos);
1143
+ const { assignedTodos, openTodos, closedTodos } = splitTodosByAssignment(todos);
959
1144
  const lines: string[] = [];
960
1145
  const pushSection = (label: string, sectionTodos: TodoFrontMatter[]) => {
961
1146
  lines.push(`${label} (${sectionTodos.length}):`);
@@ -968,7 +1153,9 @@ function formatTodoList(todos: TodoFrontMatter[]): string {
968
1153
  }
969
1154
  };
970
1155
 
1156
+ pushSection("Assigned todos", assignedTodos);
971
1157
  pushSection("Open todos", openTodos);
1158
+ pushSection("Closed todos", closedTodos);
972
1159
  return lines.join("\n");
973
1160
  }
974
1161
 
@@ -978,10 +1165,11 @@ function serializeTodoForAgent(todo: TodoRecord): string {
978
1165
  }
979
1166
 
980
1167
  function serializeTodoListForAgent(todos: TodoFrontMatter[]): string {
981
- const { openTodos, closedTodos } = splitTodosByStatus(todos);
1168
+ const { assignedTodos, openTodos, closedTodos } = splitTodosByAssignment(todos);
982
1169
  const mapTodo = (todo: TodoFrontMatter) => ({ ...todo, id: formatTodoId(todo.id) });
983
1170
  return JSON.stringify(
984
1171
  {
1172
+ assigned: assignedTodos.map(mapTodo),
985
1173
  open: openTodos.map(mapTodo),
986
1174
  closed: closedTodos.map(mapTodo),
987
1175
  },
@@ -990,22 +1178,29 @@ function serializeTodoListForAgent(todos: TodoFrontMatter[]): string {
990
1178
  );
991
1179
  }
992
1180
 
993
- function renderTodoHeading(theme: Theme, todo: TodoFrontMatter): string {
1181
+ function renderTodoHeading(theme: Theme, todo: TodoFrontMatter, currentSessionId?: string): string {
994
1182
  const closed = isTodoClosed(getTodoStatus(todo));
995
1183
  const titleColor = closed ? "dim" : "text";
996
1184
  const tagText = todo.tags.length ? theme.fg("dim", ` [${todo.tags.join(", ")}]`) : "";
1185
+ const assignmentText = renderAssignmentSuffix(theme, todo, currentSessionId);
997
1186
  return (
998
1187
  theme.fg("accent", formatTodoId(todo.id)) +
999
1188
  " " +
1000
1189
  theme.fg(titleColor, getTodoTitle(todo)) +
1001
- tagText
1190
+ tagText +
1191
+ assignmentText
1002
1192
  );
1003
1193
  }
1004
1194
 
1005
- function renderTodoList(theme: Theme, todos: TodoFrontMatter[], expanded: boolean): string {
1195
+ function renderTodoList(
1196
+ theme: Theme,
1197
+ todos: TodoFrontMatter[],
1198
+ expanded: boolean,
1199
+ currentSessionId?: string,
1200
+ ): string {
1006
1201
  if (!todos.length) return theme.fg("dim", "No todos");
1007
1202
 
1008
- const { openTodos, closedTodos } = splitTodosByStatus(todos);
1203
+ const { assignedTodos, openTodos, closedTodos } = splitTodosByAssignment(todos);
1009
1204
  const lines: string[] = [];
1010
1205
  const pushSection = (label: string, sectionTodos: TodoFrontMatter[]) => {
1011
1206
  lines.push(theme.fg("muted", `${label} (${sectionTodos.length})`));
@@ -1015,18 +1210,24 @@ function renderTodoList(theme: Theme, todos: TodoFrontMatter[], expanded: boolea
1015
1210
  }
1016
1211
  const maxItems = expanded ? sectionTodos.length : Math.min(sectionTodos.length, 3);
1017
1212
  for (let i = 0; i < maxItems; i++) {
1018
- lines.push(` ${renderTodoHeading(theme, sectionTodos[i])}`);
1213
+ lines.push(` ${renderTodoHeading(theme, sectionTodos[i], currentSessionId)}`);
1019
1214
  }
1020
1215
  if (!expanded && sectionTodos.length > maxItems) {
1021
1216
  lines.push(theme.fg("dim", ` ... ${sectionTodos.length - maxItems} more`));
1022
1217
  }
1023
1218
  };
1024
1219
 
1025
- pushSection("Open todos", openTodos);
1026
- if (expanded && closedTodos.length) {
1027
- lines.push("");
1028
- pushSection("Closed todos", closedTodos);
1029
- }
1220
+ const sections: Array<{ label: string; todos: TodoFrontMatter[] }> = [
1221
+ { label: "Assigned todos", todos: assignedTodos },
1222
+ { label: "Open todos", todos: openTodos },
1223
+ { label: "Closed todos", todos: closedTodos },
1224
+ ];
1225
+
1226
+ sections.forEach((section, index) => {
1227
+ if (index > 0) lines.push("");
1228
+ pushSection(section.label, section.todos);
1229
+ });
1230
+
1030
1231
  return lines.join("\n");
1031
1232
  }
1032
1233
 
@@ -1074,7 +1275,11 @@ async function updateTodoStatus(
1074
1275
  status: string,
1075
1276
  ctx: ExtensionContext,
1076
1277
  ): Promise<TodoRecord | { error: string }> {
1077
- const normalizedId = normalizeTodoId(id);
1278
+ const validated = validateTodoId(id);
1279
+ if ("error" in validated) {
1280
+ return { error: validated.error };
1281
+ }
1282
+ const normalizedId = validated.id;
1078
1283
  const filePath = getTodoPath(todosDir, normalizedId);
1079
1284
  if (!existsSync(filePath)) {
1080
1285
  return { error: `Todo ${displayTodoId(id)} not found` };
@@ -1084,6 +1289,89 @@ async function updateTodoStatus(
1084
1289
  const existing = await ensureTodoExists(filePath, normalizedId);
1085
1290
  if (!existing) return { error: `Todo ${displayTodoId(id)} not found` } as const;
1086
1291
  existing.status = status;
1292
+ clearAssignmentIfClosed(existing);
1293
+ await writeTodoFile(filePath, existing);
1294
+ return existing;
1295
+ });
1296
+
1297
+ if (typeof result === "object" && "error" in result) {
1298
+ return { error: result.error };
1299
+ }
1300
+
1301
+ return result;
1302
+ }
1303
+
1304
+ async function claimTodoAssignment(
1305
+ todosDir: string,
1306
+ id: string,
1307
+ ctx: ExtensionContext,
1308
+ force = false,
1309
+ ): Promise<TodoRecord | { error: string }> {
1310
+ const validated = validateTodoId(id);
1311
+ if ("error" in validated) {
1312
+ return { error: validated.error };
1313
+ }
1314
+ const normalizedId = validated.id;
1315
+ const filePath = getTodoPath(todosDir, normalizedId);
1316
+ if (!existsSync(filePath)) {
1317
+ return { error: `Todo ${displayTodoId(id)} not found` };
1318
+ }
1319
+ const sessionId = ctx.sessionManager.getSessionId();
1320
+ const result = await withTodoLock(todosDir, normalizedId, ctx, async () => {
1321
+ const existing = await ensureTodoExists(filePath, normalizedId);
1322
+ if (!existing) return { error: `Todo ${displayTodoId(id)} not found` } as const;
1323
+ if (isTodoClosed(existing.status)) {
1324
+ return { error: `Todo ${displayTodoId(id)} is closed` } as const;
1325
+ }
1326
+ const assigned = existing.assigned_to_session;
1327
+ if (assigned && assigned !== sessionId && !force) {
1328
+ return {
1329
+ error: `Todo ${displayTodoId(id)} is already assigned to session ${assigned}. Use force to override.`,
1330
+ } as const;
1331
+ }
1332
+ if (assigned !== sessionId) {
1333
+ existing.assigned_to_session = sessionId;
1334
+ await writeTodoFile(filePath, existing);
1335
+ }
1336
+ return existing;
1337
+ });
1338
+
1339
+ if (typeof result === "object" && "error" in result) {
1340
+ return { error: result.error };
1341
+ }
1342
+
1343
+ return result;
1344
+ }
1345
+
1346
+ async function releaseTodoAssignment(
1347
+ todosDir: string,
1348
+ id: string,
1349
+ ctx: ExtensionContext,
1350
+ force = false,
1351
+ ): Promise<TodoRecord | { error: string }> {
1352
+ const validated = validateTodoId(id);
1353
+ if ("error" in validated) {
1354
+ return { error: validated.error };
1355
+ }
1356
+ const normalizedId = validated.id;
1357
+ const filePath = getTodoPath(todosDir, normalizedId);
1358
+ if (!existsSync(filePath)) {
1359
+ return { error: `Todo ${displayTodoId(id)} not found` };
1360
+ }
1361
+ const sessionId = ctx.sessionManager.getSessionId();
1362
+ const result = await withTodoLock(todosDir, normalizedId, ctx, async () => {
1363
+ const existing = await ensureTodoExists(filePath, normalizedId);
1364
+ if (!existing) return { error: `Todo ${displayTodoId(id)} not found` } as const;
1365
+ const assigned = existing.assigned_to_session;
1366
+ if (!assigned) {
1367
+ return existing;
1368
+ }
1369
+ if (assigned !== sessionId && !force) {
1370
+ return {
1371
+ error: `Todo ${displayTodoId(id)} is assigned to session ${assigned}. Use force to release.`,
1372
+ } as const;
1373
+ }
1374
+ existing.assigned_to_session = undefined;
1087
1375
  await writeTodoFile(filePath, existing);
1088
1376
  return existing;
1089
1377
  });
@@ -1100,7 +1388,11 @@ async function deleteTodo(
1100
1388
  id: string,
1101
1389
  ctx: ExtensionContext,
1102
1390
  ): Promise<TodoRecord | { error: string }> {
1103
- const normalizedId = normalizeTodoId(id);
1391
+ const validated = validateTodoId(id);
1392
+ if ("error" in validated) {
1393
+ return { error: validated.error };
1394
+ }
1395
+ const normalizedId = validated.id;
1104
1396
  const filePath = getTodoPath(todosDir, normalizedId);
1105
1397
  if (!existsSync(filePath)) {
1106
1398
  return { error: `Todo ${displayTodoId(id)} not found` };
@@ -1128,14 +1420,16 @@ export default function todosExtension(pi: ExtensionAPI) {
1128
1420
  await garbageCollectTodos(todosDir, settings);
1129
1421
  });
1130
1422
 
1423
+ const todosDirLabel = getTodosDirLabel(process.cwd());
1424
+
1131
1425
  pi.registerTool({
1132
1426
  name: "todo",
1133
1427
  label: "Todo",
1134
1428
  description:
1135
- "Manage file-based todos in .pi/todos (list, list-all, get, create, update, append, delete). " +
1429
+ `Manage file-based todos in ${todosDirLabel} (list, list-all, get, create, update, append, delete, claim, release). ` +
1136
1430
  "Title is the short summary; body is long-form markdown notes (update replaces, append adds). " +
1137
1431
  "Todo ids are shown as TODO-<hex>; id parameters accept TODO-<hex> or the raw hex filename. " +
1138
- "Close todos when the work is done. Set PI_ISSUE_PATH to override the todo directory.",
1432
+ "Close todos when the work is done.",
1139
1433
  parameters: TodoParams,
1140
1434
 
1141
1435
  async execute(_toolCallId, params, _onUpdate, ctx) {
@@ -1145,18 +1439,21 @@ export default function todosExtension(pi: ExtensionAPI) {
1145
1439
  switch (action) {
1146
1440
  case "list": {
1147
1441
  const todos = await listTodos(todosDir);
1148
- const { openTodos } = splitTodosByStatus(todos);
1442
+ const { assignedTodos, openTodos } = splitTodosByAssignment(todos);
1443
+ const listedTodos = [...assignedTodos, ...openTodos];
1444
+ const currentSessionId = ctx.sessionManager.getSessionId();
1149
1445
  return {
1150
- content: [{ type: "text", text: serializeTodoListForAgent(openTodos) }],
1151
- details: { action: "list", todos: openTodos },
1446
+ content: [{ type: "text", text: serializeTodoListForAgent(listedTodos) }],
1447
+ details: { action: "list", todos: listedTodos, currentSessionId },
1152
1448
  };
1153
1449
  }
1154
1450
 
1155
1451
  case "list-all": {
1156
1452
  const todos = await listTodos(todosDir);
1453
+ const currentSessionId = ctx.sessionManager.getSessionId();
1157
1454
  return {
1158
1455
  content: [{ type: "text", text: serializeTodoListForAgent(todos) }],
1159
- details: { action: "list-all", todos },
1456
+ details: { action: "list-all", todos, currentSessionId },
1160
1457
  };
1161
1458
  }
1162
1459
 
@@ -1167,8 +1464,15 @@ export default function todosExtension(pi: ExtensionAPI) {
1167
1464
  details: { action: "get", error: "id required" },
1168
1465
  };
1169
1466
  }
1170
- const normalizedId = normalizeTodoId(params.id);
1171
- const displayId = displayTodoId(params.id);
1467
+ const validated = validateTodoId(params.id);
1468
+ if ("error" in validated) {
1469
+ return {
1470
+ content: [{ type: "text", text: validated.error }],
1471
+ details: { action: "get", error: validated.error },
1472
+ };
1473
+ }
1474
+ const normalizedId = validated.id;
1475
+ const displayId = formatTodoId(normalizedId);
1172
1476
  const filePath = getTodoPath(todosDir, normalizedId);
1173
1477
  const todo = await ensureTodoExists(filePath, normalizedId);
1174
1478
  if (!todo) {
@@ -1227,8 +1531,15 @@ export default function todosExtension(pi: ExtensionAPI) {
1227
1531
  details: { action: "update", error: "id required" },
1228
1532
  };
1229
1533
  }
1230
- const normalizedId = normalizeTodoId(params.id);
1231
- const displayId = displayTodoId(params.id);
1534
+ const validated = validateTodoId(params.id);
1535
+ if ("error" in validated) {
1536
+ return {
1537
+ content: [{ type: "text", text: validated.error }],
1538
+ details: { action: "update", error: validated.error },
1539
+ };
1540
+ }
1541
+ const normalizedId = validated.id;
1542
+ const displayId = formatTodoId(normalizedId);
1232
1543
  const filePath = getTodoPath(todosDir, normalizedId);
1233
1544
  if (!existsSync(filePath)) {
1234
1545
  return {
@@ -1246,6 +1557,7 @@ export default function todosExtension(pi: ExtensionAPI) {
1246
1557
  if (params.tags !== undefined) existing.tags = params.tags;
1247
1558
  if (params.body !== undefined) existing.body = params.body;
1248
1559
  if (!existing.created_at) existing.created_at = new Date().toISOString();
1560
+ clearAssignmentIfClosed(existing);
1249
1561
 
1250
1562
  await writeTodoFile(filePath, existing);
1251
1563
  return existing;
@@ -1272,8 +1584,15 @@ export default function todosExtension(pi: ExtensionAPI) {
1272
1584
  details: { action: "append", error: "id required" },
1273
1585
  };
1274
1586
  }
1275
- const normalizedId = normalizeTodoId(params.id);
1276
- const displayId = displayTodoId(params.id);
1587
+ const validated = validateTodoId(params.id);
1588
+ if ("error" in validated) {
1589
+ return {
1590
+ content: [{ type: "text", text: validated.error }],
1591
+ details: { action: "append", error: validated.error },
1592
+ };
1593
+ }
1594
+ const normalizedId = validated.id;
1595
+ const displayId = formatTodoId(normalizedId);
1277
1596
  const filePath = getTodoPath(todosDir, normalizedId);
1278
1597
  if (!existsSync(filePath)) {
1279
1598
  return {
@@ -1305,6 +1624,58 @@ export default function todosExtension(pi: ExtensionAPI) {
1305
1624
  };
1306
1625
  }
1307
1626
 
1627
+ case "claim": {
1628
+ if (!params.id) {
1629
+ return {
1630
+ content: [{ type: "text", text: "Error: id required" }],
1631
+ details: { action: "claim", error: "id required" },
1632
+ };
1633
+ }
1634
+ const result = await claimTodoAssignment(
1635
+ todosDir,
1636
+ params.id,
1637
+ ctx,
1638
+ Boolean(params.force),
1639
+ );
1640
+ if (typeof result === "object" && "error" in result) {
1641
+ return {
1642
+ content: [{ type: "text", text: result.error }],
1643
+ details: { action: "claim", error: result.error },
1644
+ };
1645
+ }
1646
+ const updatedTodo = result as TodoRecord;
1647
+ return {
1648
+ content: [{ type: "text", text: serializeTodoForAgent(updatedTodo) }],
1649
+ details: { action: "claim", todo: updatedTodo },
1650
+ };
1651
+ }
1652
+
1653
+ case "release": {
1654
+ if (!params.id) {
1655
+ return {
1656
+ content: [{ type: "text", text: "Error: id required" }],
1657
+ details: { action: "release", error: "id required" },
1658
+ };
1659
+ }
1660
+ const result = await releaseTodoAssignment(
1661
+ todosDir,
1662
+ params.id,
1663
+ ctx,
1664
+ Boolean(params.force),
1665
+ );
1666
+ if (typeof result === "object" && "error" in result) {
1667
+ return {
1668
+ content: [{ type: "text", text: result.error }],
1669
+ details: { action: "release", error: result.error },
1670
+ };
1671
+ }
1672
+ const updatedTodo = result as TodoRecord;
1673
+ return {
1674
+ content: [{ type: "text", text: serializeTodoForAgent(updatedTodo) }],
1675
+ details: { action: "release", todo: updatedTodo },
1676
+ };
1677
+ }
1678
+
1308
1679
  case "delete": {
1309
1680
  if (!params.id) {
1310
1681
  return {
@@ -1313,8 +1684,14 @@ export default function todosExtension(pi: ExtensionAPI) {
1313
1684
  };
1314
1685
  }
1315
1686
 
1316
- const normalizedId = normalizeTodoId(params.id);
1317
- const result = await deleteTodo(todosDir, normalizedId, ctx);
1687
+ const validated = validateTodoId(params.id);
1688
+ if ("error" in validated) {
1689
+ return {
1690
+ content: [{ type: "text", text: validated.error }],
1691
+ details: { action: "delete", error: validated.error },
1692
+ };
1693
+ }
1694
+ const result = await deleteTodo(todosDir, validated.id, ctx);
1318
1695
  if (typeof result === "object" && "error" in result) {
1319
1696
  return {
1320
1697
  content: [{ type: "text", text: result.error }],
@@ -1361,9 +1738,9 @@ export default function todosExtension(pi: ExtensionAPI) {
1361
1738
  }
1362
1739
 
1363
1740
  if (details.action === "list" || details.action === "list-all") {
1364
- let text = renderTodoList(theme, details.todos, expanded);
1741
+ let text = renderTodoList(theme, details.todos, expanded, details.currentSessionId);
1365
1742
  if (!expanded) {
1366
- const { closedTodos } = splitTodosByStatus(details.todos);
1743
+ const { closedTodos } = splitTodosByAssignment(details.todos);
1367
1744
  if (closedTodos.length) {
1368
1745
  text = appendExpandHint(theme, text);
1369
1746
  }
@@ -1386,7 +1763,11 @@ export default function todosExtension(pi: ExtensionAPI) {
1386
1763
  ? "Appended to"
1387
1764
  : details.action === "delete"
1388
1765
  ? "Deleted"
1389
- : null;
1766
+ : details.action === "claim"
1767
+ ? "Claimed"
1768
+ : details.action === "release"
1769
+ ? "Released"
1770
+ : null;
1390
1771
  if (actionLabel) {
1391
1772
  const lines = text.split("\n");
1392
1773
  lines[0] = theme.fg("success", "✓ ") + theme.fg("muted", `${actionLabel} `) + lines[0];
@@ -1419,6 +1800,7 @@ export default function todosExtension(pi: ExtensionAPI) {
1419
1800
  handler: async (args, ctx) => {
1420
1801
  const todosDir = getTodosDir(ctx.cwd);
1421
1802
  const todos = await listTodos(todosDir);
1803
+ const currentSessionId = ctx.sessionManager.getSessionId();
1422
1804
  const searchTerm = (args ?? "").trim();
1423
1805
 
1424
1806
  if (!ctx.hasUI) {
@@ -1432,17 +1814,50 @@ export default function todosExtension(pi: ExtensionAPI) {
1432
1814
  await ctx.ui.custom<void>((tui, theme, _kb, done) => {
1433
1815
  rootTui = tui;
1434
1816
  let selector: TodoSelectorComponent | null = null;
1817
+ let actionMenu: TodoActionMenuComponent | null = null;
1818
+ let deleteConfirm: TodoDeleteConfirmComponent | null = null;
1819
+ let activeComponent:
1820
+ | {
1821
+ render: (width: number) => string[];
1822
+ invalidate: () => void;
1823
+ handleInput?: (data: string) => void;
1824
+ focused?: boolean;
1825
+ }
1826
+ | null = null;
1827
+ let wrapperFocused = false;
1828
+
1829
+ const setActiveComponent = (
1830
+ component:
1831
+ | {
1832
+ render: (width: number) => string[];
1833
+ invalidate: () => void;
1834
+ handleInput?: (data: string) => void;
1835
+ focused?: boolean;
1836
+ }
1837
+ | null,
1838
+ ) => {
1839
+ if (activeComponent && "focused" in activeComponent) {
1840
+ activeComponent.focused = false;
1841
+ }
1842
+ activeComponent = component;
1843
+ if (activeComponent && "focused" in activeComponent) {
1844
+ activeComponent.focused = wrapperFocused;
1845
+ }
1846
+ tui.requestRender();
1847
+ };
1435
1848
 
1436
- const addTodoPathToPrompt = (todoId: string) => {
1849
+ const copyTodoPathToClipboard = (todoId: string) => {
1437
1850
  const filePath = getTodoPath(todosDir, todoId);
1438
1851
  const relativePath = path.relative(ctx.cwd, filePath);
1439
1852
  const displayPath =
1440
1853
  relativePath && !relativePath.startsWith("..") ? relativePath : filePath;
1441
- const mention = `@${displayPath}`;
1442
- const current = ctx.ui.getEditorText();
1443
- const separator = current && !current.endsWith(" ") ? " " : "";
1444
- ctx.ui.setEditorText(`${current}${separator}${mention}`);
1445
- ctx.ui.notify(`Added ${mention} to prompt`, "info");
1854
+ try {
1855
+ copyToClipboard(displayPath);
1856
+ ctx.ui.notify(`Copied ${displayPath} to clipboard`, "info");
1857
+ } catch (error) {
1858
+ const message = error instanceof Error ? error.message : String(error);
1859
+ ctx.ui.notify(message, "error");
1860
+ }
1446
1861
  };
1447
1862
 
1448
1863
  const resolveTodoRecord = async (todo: TodoFrontMatter): Promise<TodoRecord | null> => {
@@ -1455,7 +1870,7 @@ export default function todosExtension(pi: ExtensionAPI) {
1455
1870
  return record;
1456
1871
  };
1457
1872
 
1458
- const openTodoOverlay = async (record: TodoRecord) => {
1873
+ const openTodoOverlay = async (record: TodoRecord): Promise<TodoOverlayAction> => {
1459
1874
  const action = await ctx.ui.custom<TodoOverlayAction>(
1460
1875
  (overlayTui, overlayTheme, _overlayKb, overlayDone) =>
1461
1876
  new TodoDetailOverlayComponent(overlayTui, overlayTheme, record, overlayDone),
@@ -1465,65 +1880,62 @@ export default function todosExtension(pi: ExtensionAPI) {
1465
1880
  },
1466
1881
  );
1467
1882
 
1468
- if (!action || action === "cancel") return;
1469
- if (action === "actions") {
1470
- await showActionMenu(record);
1471
- return;
1472
- }
1473
- await applyTodoAction(record, action);
1883
+ return action ?? "back";
1474
1884
  };
1475
1885
 
1476
- const applyTodoAction = async (record: TodoRecord, action: TodoMenuAction) => {
1477
- if (action === "cancel") return;
1478
- if (action === "close-dialog") {
1479
- done();
1480
- return;
1481
- }
1886
+ const applyTodoAction = async (
1887
+ record: TodoRecord,
1888
+ action: TodoMenuAction,
1889
+ ): Promise<"stay" | "exit"> => {
1482
1890
  if (action === "refine") {
1483
1891
  const title = record.title || "(untitled)";
1484
1892
  nextPrompt = buildRefinePrompt(record.id, title);
1485
1893
  done();
1486
- return;
1894
+ return "exit";
1487
1895
  }
1488
1896
  if (action === "work") {
1489
1897
  const title = record.title || "(untitled)";
1490
1898
  nextPrompt = `work on todo ${formatTodoId(record.id)} "${title}"`;
1491
1899
  done();
1492
- return;
1900
+ return "exit";
1493
1901
  }
1494
1902
  if (action === "view") {
1495
- await openTodoOverlay(record);
1496
- return;
1903
+ return "stay";
1497
1904
  }
1498
- if (action === "copy-path") {
1499
- addTodoPathToPrompt(record.id);
1500
- return;
1905
+ if (action === "copy") {
1906
+ copyTodoPathToClipboard(record.id);
1907
+ return "stay";
1501
1908
  }
1502
1909
 
1503
- if (action === "delete") {
1504
- const ok = await ctx.ui.confirm(
1505
- "Delete todo",
1506
- `Delete todo ${formatTodoId(record.id)}? This cannot be undone.`,
1507
- );
1508
- if (!ok) {
1509
- return;
1910
+ if (action === "release") {
1911
+ const result = await releaseTodoAssignment(todosDir, record.id, ctx, true);
1912
+ if ("error" in result) {
1913
+ ctx.ui.notify(result.error, "error");
1914
+ return "stay";
1510
1915
  }
1916
+ const updatedTodos = await listTodos(todosDir);
1917
+ selector?.setTodos(updatedTodos);
1918
+ ctx.ui.notify(`Released todo ${formatTodoId(record.id)}`, "info");
1919
+ return "stay";
1920
+ }
1921
+
1922
+ if (action === "delete") {
1511
1923
  const result = await deleteTodo(todosDir, record.id, ctx);
1512
1924
  if ("error" in result) {
1513
1925
  ctx.ui.notify(result.error, "error");
1514
- return;
1926
+ return "stay";
1515
1927
  }
1516
1928
  const updatedTodos = await listTodos(todosDir);
1517
1929
  selector?.setTodos(updatedTodos);
1518
1930
  ctx.ui.notify(`Deleted todo ${formatTodoId(record.id)}`, "info");
1519
- return;
1931
+ return "stay";
1520
1932
  }
1521
1933
 
1522
1934
  const nextStatus = action === "close" ? "closed" : "open";
1523
1935
  const result = await updateTodoStatus(todosDir, record.id, nextStatus, ctx);
1524
1936
  if ("error" in result) {
1525
1937
  ctx.ui.notify(result.error, "error");
1526
- return;
1938
+ return "stay";
1527
1939
  }
1528
1940
 
1529
1941
  const updatedTodos = await listTodos(todosDir);
@@ -1532,87 +1944,58 @@ export default function todosExtension(pi: ExtensionAPI) {
1532
1944
  `${action === "close" ? "Closed" : "Reopened"} todo ${formatTodoId(record.id)}`,
1533
1945
  "info",
1534
1946
  );
1947
+ return "stay";
1948
+ };
1949
+
1950
+ const handleActionSelection = async (record: TodoRecord, action: TodoMenuAction) => {
1951
+ if (action === "view") {
1952
+ const overlayAction = await openTodoOverlay(record);
1953
+ if (overlayAction === "work") {
1954
+ await applyTodoAction(record, "work");
1955
+ return;
1956
+ }
1957
+ if (actionMenu) {
1958
+ setActiveComponent(actionMenu);
1959
+ }
1960
+ return;
1961
+ }
1962
+
1963
+ if (action === "delete") {
1964
+ const message = `Delete todo ${formatTodoId(record.id)}? This cannot be undone.`;
1965
+ deleteConfirm = new TodoDeleteConfirmComponent(theme, message, (confirmed) => {
1966
+ if (!confirmed) {
1967
+ setActiveComponent(actionMenu);
1968
+ return;
1969
+ }
1970
+ void (async () => {
1971
+ await applyTodoAction(record, "delete");
1972
+ setActiveComponent(selector);
1973
+ })();
1974
+ });
1975
+ setActiveComponent(deleteConfirm);
1976
+ return;
1977
+ }
1978
+
1979
+ const result = await applyTodoAction(record, action);
1980
+ if (result === "stay") {
1981
+ setActiveComponent(selector);
1982
+ }
1535
1983
  };
1536
1984
 
1537
1985
  const showActionMenu = async (todo: TodoFrontMatter | TodoRecord) => {
1538
1986
  const record = "body" in todo ? todo : await resolveTodoRecord(todo);
1539
1987
  if (!record) return;
1540
- const options: SelectItem[] = [
1541
- { value: "view", label: "view", description: "View todo" },
1542
- { value: "work", label: "work", description: "Work on todo" },
1543
- { value: "refine", label: "refine", description: "Refine task" },
1544
- { value: "close", label: "close", description: "Close todo" },
1545
- { value: "reopen", label: "reopen", description: "Reopen todo" },
1546
- { value: "copy-path", label: "copy", description: "Copy todo path into prompt" },
1547
- { value: "delete", label: "delete", description: "Delete todo" },
1548
- ];
1549
- const title = record.title || "(untitled)";
1550
- const selection = await ctx.ui.custom<TodoMenuAction | null>(
1551
- (overlayTui, overlayTheme, _overlayKb, overlayDone) => {
1552
- const container = new Container();
1553
- container.addChild(
1554
- new Text(
1555
- overlayTheme.fg(
1556
- "accent",
1557
- overlayTheme.bold(`Actions for ${formatTodoId(record.id)} "${title}"`),
1558
- ),
1559
- ),
1560
- );
1561
- container.addChild(new Spacer(1));
1562
-
1563
- const selectList = new SelectList(options, options.length, {
1564
- selectedPrefix: (text) => overlayTheme.fg("accent", text),
1565
- selectedText: (text) => overlayTheme.fg("accent", text),
1566
- description: (text) => overlayTheme.fg("muted", text),
1567
- scrollInfo: (text) => overlayTheme.fg("dim", text),
1568
- noMatch: (text) => overlayTheme.fg("warning", text),
1569
- });
1570
-
1571
- selectList.onSelect = (item) => overlayDone(item.value as TodoMenuAction);
1572
- selectList.onCancel = () => overlayDone(null);
1573
-
1574
- container.addChild(selectList);
1575
- container.addChild(new Spacer(1));
1576
- container.addChild(
1577
- new Text(overlayTheme.fg("dim", "Press enter to confirm or esc to cancel")),
1578
- );
1579
-
1580
- return {
1581
- render(width: number) {
1582
- const innerWidth = Math.max(10, width - 2);
1583
- const contentLines = container.render(innerWidth);
1584
- const borderColor = (text: string) => overlayTheme.fg("accent", text);
1585
- const top = borderColor(`┌${"─".repeat(innerWidth)}┐`);
1586
- const bottom = borderColor(`└${"─".repeat(innerWidth)}┘`);
1587
- const framed = contentLines.map((line) => {
1588
- const truncated = truncateToWidth(line, innerWidth);
1589
- const padding = Math.max(0, innerWidth - visibleWidth(truncated));
1590
- return (
1591
- borderColor("│") + truncated + " ".repeat(padding) + borderColor("│")
1592
- );
1593
- });
1594
- return [top, ...framed, bottom].map((line) => truncateToWidth(line, width));
1595
- },
1596
- invalidate() {
1597
- container.invalidate();
1598
- },
1599
- handleInput(data: string) {
1600
- selectList.handleInput(data);
1601
- overlayTui.requestRender();
1602
- },
1603
- };
1988
+ actionMenu = new TodoActionMenuComponent(
1989
+ theme,
1990
+ record,
1991
+ (action) => {
1992
+ void handleActionSelection(record, action);
1604
1993
  },
1605
- {
1606
- overlay: true,
1607
- overlayOptions: { width: "70%", maxHeight: "60%", anchor: "center" },
1994
+ () => {
1995
+ setActiveComponent(selector);
1608
1996
  },
1609
1997
  );
1610
-
1611
- if (!selection) {
1612
- tui.requestRender();
1613
- return;
1614
- }
1615
- await applyTodoAction(record, selection);
1998
+ setActiveComponent(actionMenu);
1616
1999
  };
1617
2000
 
1618
2001
  const handleSelect = async (todo: TodoFrontMatter) => {
@@ -1628,6 +2011,7 @@ export default function todosExtension(pi: ExtensionAPI) {
1628
2011
  },
1629
2012
  () => done(),
1630
2013
  searchTerm || undefined,
2014
+ currentSessionId,
1631
2015
  (todo, action) => {
1632
2016
  const title = todo.title || "(untitled)";
1633
2017
  nextPrompt =
@@ -1638,7 +2022,30 @@ export default function todosExtension(pi: ExtensionAPI) {
1638
2022
  },
1639
2023
  );
1640
2024
 
1641
- return selector;
2025
+ setActiveComponent(selector);
2026
+
2027
+ const rootComponent = {
2028
+ get focused() {
2029
+ return wrapperFocused;
2030
+ },
2031
+ set focused(value: boolean) {
2032
+ wrapperFocused = value;
2033
+ if (activeComponent && "focused" in activeComponent) {
2034
+ activeComponent.focused = value;
2035
+ }
2036
+ },
2037
+ render(width: number) {
2038
+ return activeComponent ? activeComponent.render(width) : [];
2039
+ },
2040
+ invalidate() {
2041
+ activeComponent?.invalidate();
2042
+ },
2043
+ handleInput(data: string) {
2044
+ activeComponent?.handleInput?.(data);
2045
+ },
2046
+ };
2047
+
2048
+ return rootComponent;
1642
2049
  });
1643
2050
 
1644
2051
  if (nextPrompt) {