@agnishc/edb-todo 0.10.8 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.12.0] - 2026-05-22
4
+
5
+ ## [0.10.9] - 2026-05-18
6
+
7
+ ### Added
8
+ - **Subtask support** — `TaskCreate` gains optional `parentId` param; tasks with a `parentId` render as indented subtasks in the widget
9
+ - **Parallel group support** — `TaskCreate` gains optional `groupId` param; tasks in the same group are treated as parallel; a downstream task can set `blockedByGroup` to wait until all tasks in the group complete
10
+ - **`blocked` status** — new task lifecycle state for in-progress tasks waiting for a supervisor answer; widget renders ⏸ with question preview
11
+ - **`blockQuestion` / `blockMessageId` / `blockedAt` fields** — store the pending question text and bridge message ID when a task is blocked
12
+ - **Attribution display** — tasks where `owner` is set show `[agent-id]` in the widget; orchestrator-created tasks (no owner) show no annotation
13
+ - **Tree widget rendering** — widget now shows tasks followed by their subtasks (indented), replacing the flat list
14
+ - **`blocked` count in widget header** — summary line shows blocked task count in warning colour
15
+ - **edb-bridge integration** — listens for `bridge:task_updated` events and refreshes widget when a sub-agent writes to the shared store; emits `bridge:task_updated` after every task mutation so the parent session widget stays in sync
16
+ - **`todo:store_path` event** — emits the active store file path so edb-subagents can inject it into sub-agent system prompts
17
+ - **System prompt store injection** — reads `<task_store_path>` XML tag from the system prompt (injected by edb-subagents at spawn time) to point sub-agent sessions at the parent's shared task store
18
+ - **`isGroupComplete()` / `getReadyTasks()`** — new store helpers for group-join resolution
19
+
20
+ ### Changed
21
+ - `TaskList` output now includes subtask annotation (`[subtask of #id]`), owner, blocked question preview, and group wait status
22
+ - `TaskGet` output now includes `parentId`, `groupId`, `blockedByGroup`, and blocked question details
23
+ - `TaskUpdate` handles `blocked` status transitions: sets `blockedAt`, clears `blockQuestion`/`blockMessageId` on unblock
24
+ - Widget `MAX_VISIBLE_TASKS` raised from 10 to 12
25
+ - `statusOrder` in `TaskList` updated to include `blocked` (ordered after `in_progress`)
26
+
3
27
  ## [0.10.8] - 2026-05-18
4
28
 
5
29
  ## [0.10.6] - 2026-05-15
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agnishc/edb-todo",
3
- "version": "0.10.8",
3
+ "version": "0.12.0",
4
4
  "description": "Pi extension: structured task list with live widget and system-prompt injection to prevent goal drift",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/component.ts CHANGED
@@ -191,6 +191,7 @@ export class TodoViewComponent {
191
191
  }
192
192
 
193
193
  const idHint = th.fg("dim", ` [${task.id}]`);
194
+ const ownerHint = task.owner ? th.fg("dim", ` [${task.owner}]`) : "";
194
195
  const cursor = isFocused ? th.fg("accent", "❯") : " ";
195
196
 
196
197
  // Dependency hint
@@ -208,7 +209,7 @@ export class TodoViewComponent {
208
209
  depHint += th.fg("dim", ` → blocks ${task.blocks.map((id) => `#${id}`).join(", ")}`);
209
210
  }
210
211
 
211
- return [truncateToWidth(` ${cursor} ${icon} ${pLabel} ${contentText}${idHint}${depHint}`, width)];
212
+ return [truncateToWidth(` ${cursor} ${icon} ${pLabel} ${contentText}${idHint}${ownerHint}${depHint}`, width)];
212
213
  }
213
214
 
214
215
  private rebuildFlatTasks(): void {
package/src/file-store.ts CHANGED
@@ -63,6 +63,16 @@ export class FileTaskStore {
63
63
  private filePath: string | undefined;
64
64
  private lockPath: string | undefined;
65
65
 
66
+ /** The file path this store persists to. Undefined for in-memory stores. */
67
+ get path(): string | undefined {
68
+ return this.filePath;
69
+ }
70
+
71
+ /** Force-reload from disk (useful when another process may have written to the store). */
72
+ reload(): void {
73
+ this.load();
74
+ }
75
+
66
76
  private nextId = 1;
67
77
  private tasks = new Map<string, Task>();
68
78
 
@@ -87,6 +97,7 @@ export class FileTaskStore {
87
97
  if (!t.blocks) t.blocks = [];
88
98
  if (!t.blockedBy) t.blockedBy = [];
89
99
  if (!t.updatedAt) t.updatedAt = t.createdAt;
100
+ // New fields — default to undefined (no migration needed, optional)
90
101
  this.tasks.set(t.id, t);
91
102
  }
92
103
  } catch {
@@ -94,6 +105,35 @@ export class FileTaskStore {
94
105
  }
95
106
  }
96
107
 
108
+ /**
109
+ * Check if a parallel group is fully complete (all tasks with that groupId are completed).
110
+ * Used to resolve blockedByGroup dependencies.
111
+ */
112
+ isGroupComplete(groupId: string): boolean {
113
+ const groupTasks = Array.from(this.tasks.values()).filter((t) => t.groupId === groupId);
114
+ if (groupTasks.length === 0) return true; // no tasks in group — treat as complete
115
+ return groupTasks.every((t) => t.status === "completed");
116
+ }
117
+
118
+ /**
119
+ * Get all tasks that are ready to start:
120
+ * - status === "pending"
121
+ * - no open blockedBy tasks
122
+ * - blockedByGroup is resolved (if set)
123
+ */
124
+ getReadyTasks(): Task[] {
125
+ return this.list().filter((t) => {
126
+ if (t.status !== "pending") return false;
127
+ const hasOpenBlocker = t.blockedBy.some((bid) => {
128
+ const b = this.tasks.get(bid);
129
+ return b && b.status !== "completed";
130
+ });
131
+ if (hasOpenBlocker) return false;
132
+ if (t.blockedByGroup && !this.isGroupComplete(t.blockedByGroup)) return false;
133
+ return true;
134
+ });
135
+ }
136
+
97
137
  private save(): void {
98
138
  if (!this.filePath) return;
99
139
  const data: TaskStoreData = {
@@ -141,6 +181,9 @@ export class FileTaskStore {
141
181
  description?: string;
142
182
  priority?: TaskPriority;
143
183
  activeForm?: string;
184
+ owner?: string;
185
+ parentId?: string;
186
+ groupId?: string;
144
187
  metadata?: Record<string, any>;
145
188
  },
146
189
  ): Task {
@@ -153,7 +196,9 @@ export class FileTaskStore {
153
196
  status: "pending",
154
197
  priority: opts?.priority ?? "medium",
155
198
  activeForm: opts?.activeForm,
156
- owner: undefined,
199
+ owner: opts?.owner,
200
+ parentId: opts?.parentId,
201
+ groupId: opts?.groupId,
157
202
  metadata: opts?.metadata ?? {},
158
203
  blocks: [],
159
204
  blockedBy: [],
@@ -198,6 +243,11 @@ export class FileTaskStore {
198
243
  priority?: TaskPriority;
199
244
  activeForm?: string;
200
245
  owner?: string;
246
+ parentId?: string;
247
+ groupId?: string;
248
+ blockedByGroup?: string;
249
+ blockQuestion?: string;
250
+ blockMessageId?: string;
201
251
  metadata?: Record<string, any>;
202
252
  addBlocks?: string[];
203
253
  addBlockedBy?: string[];
@@ -223,6 +273,15 @@ export class FileTaskStore {
223
273
  const now = Date.now();
224
274
 
225
275
  if (fields.status !== undefined) {
276
+ // blockedByGroup enforcement: reject in_progress if the group is not yet complete
277
+ if (fields.status === "in_progress" && task.blockedByGroup && !this.isGroupComplete(task.blockedByGroup)) {
278
+ warnings.push(
279
+ `Cannot set status to in_progress: task is waiting for group [${task.blockedByGroup}] which is not yet complete. ` +
280
+ `Complete all tasks in that group first, or remove blockedByGroup from this task.`,
281
+ );
282
+ // Return early — do not apply any changes
283
+ return { task, changedFields: [], warnings };
284
+ }
226
285
  // Timestamp transitions
227
286
  if (task.status !== "in_progress" && fields.status === "in_progress") task.startedAt = now;
228
287
  if (task.status !== "completed" && fields.status === "completed") {
@@ -232,6 +291,14 @@ export class FileTaskStore {
232
291
  if (task.status === "completed" && fields.status !== "completed") {
233
292
  task.completedAt = undefined;
234
293
  }
294
+ if (fields.status === "blocked") {
295
+ task.blockedAt = now;
296
+ } else if ((task.status as string) === "blocked") {
297
+ // Was blocked, now transitioning to a different status — clear block metadata
298
+ task.blockedAt = undefined;
299
+ if (!fields.blockQuestion) task.blockQuestion = undefined;
300
+ if (!fields.blockMessageId) task.blockMessageId = undefined;
301
+ }
235
302
  task.status = fields.status;
236
303
  changedFields.push("status");
237
304
  }
@@ -255,6 +322,26 @@ export class FileTaskStore {
255
322
  task.owner = fields.owner;
256
323
  changedFields.push("owner");
257
324
  }
325
+ if (fields.parentId !== undefined) {
326
+ task.parentId = fields.parentId;
327
+ changedFields.push("parentId");
328
+ }
329
+ if (fields.groupId !== undefined) {
330
+ task.groupId = fields.groupId;
331
+ changedFields.push("groupId");
332
+ }
333
+ if (fields.blockedByGroup !== undefined) {
334
+ task.blockedByGroup = fields.blockedByGroup;
335
+ changedFields.push("blockedByGroup");
336
+ }
337
+ if (fields.blockQuestion !== undefined) {
338
+ task.blockQuestion = fields.blockQuestion;
339
+ changedFields.push("blockQuestion");
340
+ }
341
+ if (fields.blockMessageId !== undefined) {
342
+ task.blockMessageId = fields.blockMessageId;
343
+ changedFields.push("blockMessageId");
344
+ }
258
345
 
259
346
  if (fields.metadata !== undefined) {
260
347
  for (const [key, value] of Object.entries(fields.metadata)) {
package/src/index.ts CHANGED
@@ -36,6 +36,11 @@ const SYSTEM_REMINDER = `<system-reminder>
36
36
  The task tools haven't been used recently. If you're working on tasks that would benefit from tracking progress, consider using TaskCreate to add new tasks and TaskUpdate to update task status (set to in_progress when starting, completed when done). Also consider cleaning up the task list if it has become stale. Only use these if relevant to the current work. This is just a gentle reminder - ignore if not applicable. Make sure that you NEVER mention this reminder to the user
37
37
  </system-reminder>`;
38
38
 
39
+ // Internal pi.events: edb-bridge emits this when a task_updated message arrives (from sub-agent)
40
+ const EV_BRIDGE_TASK_UPDATED = "bridge:task_updated";
41
+ // We emit this so edb-subagents can read the store path
42
+ const EV_TODO_STORE_PATH = "todo:store_path";
43
+
39
44
  // ── Extension ──────────────────────────────────────────────────────────────────
40
45
 
41
46
  export default function todoExtension(pi: ExtensionAPI): void {
@@ -66,6 +71,112 @@ export default function todoExtension(pi: ExtensionAPI): void {
66
71
  () => cfg.autoClearCompleted ?? "on_list_complete",
67
72
  AUTO_CLEAR_DELAY,
68
73
  );
74
+ /** The pi session ID of the current (or most recently started) session. */
75
+ let currentSessionId: string | null = null;
76
+
77
+ // Expose store path to other extensions (edb-subagents reads this to inject PI_TODO into sub-agents)
78
+ function emitStorePath() {
79
+ const p = store.path;
80
+ if (p) pi.events.emit(EV_TODO_STORE_PATH, { path: p });
81
+ }
82
+
83
+ // Listen for bridge:task_updated (emitted by edb-bridge when a sub-agent updates a task)
84
+ // Re-reads the store and refreshes the widget
85
+ pi.events.on(EV_BRIDGE_TASK_UPDATED, () => {
86
+ // Force a re-read from disk (sub-agent may have written to the shared file)
87
+ if (store.path) {
88
+ try {
89
+ store.reload();
90
+ } catch {
91
+ /* ignore */
92
+ }
93
+ }
94
+ widget.update();
95
+ });
96
+
97
+ // Listen for bridge:notify_parent — sub-agent progress update with optional task_id
98
+ // When task_id is provided, update the task's activeForm so it shows in the spinner
99
+ pi.events.on("bridge:notify_parent", (payload: unknown) => {
100
+ const p = payload as { taskId?: string; message?: string; agentId?: string } | undefined;
101
+ if (!p?.taskId || !p.message) return;
102
+ try {
103
+ // Update activeForm on the task so the widget spinner shows the progress message
104
+ store.update(p.taskId, { activeForm: p.message.slice(0, 80) });
105
+ widget.update();
106
+ } catch {
107
+ /* ignore */
108
+ }
109
+ });
110
+
111
+ // Listen for bridge:ask_supervisor — sub-agent called ask_supervisor with a task_id
112
+ // Auto-block the linked task so the widget shows ⏸ with the question text
113
+ pi.events.on("bridge:ask_supervisor", (payload: unknown) => {
114
+ const p = payload as { taskId?: string; question?: string; messageId?: string } | undefined;
115
+ if (!p?.taskId || !p.question) return;
116
+ try {
117
+ store.update(p.taskId, {
118
+ status: "blocked",
119
+ blockQuestion: p.question,
120
+ blockMessageId: p.messageId,
121
+ });
122
+ widget.update();
123
+ } catch {
124
+ /* ignore */
125
+ }
126
+ });
127
+
128
+ // Listen for bridge:supervisor_answered — orchestrator answered, unblock the task
129
+ pi.events.on("bridge:supervisor_answered", (payload: unknown) => {
130
+ const p = payload as { taskId?: string } | undefined;
131
+ if (!p?.taskId) return;
132
+ try {
133
+ // Transition back to in_progress and clear block metadata
134
+ store.update(p.taskId, { status: "in_progress" });
135
+ widget.update();
136
+ } catch {
137
+ /* ignore */
138
+ }
139
+ });
140
+
141
+ // Listen for todo:update_task — edb-subagents requests a task status update
142
+ // This avoids edb-subagents duplicating FileTaskStore write logic
143
+ pi.events.on("todo:update_task", (payload: unknown) => {
144
+ const p = payload as { taskId?: string; fields?: { status?: string; owner?: string } } | undefined;
145
+ if (!p?.taskId || !p.fields) return;
146
+ try {
147
+ store.update(p.taskId, p.fields as any);
148
+ widget.update();
149
+ // Notify bridge to propagate refresh to parent if we're in a sub-agent store
150
+ notifyBridgeOnChange();
151
+ } catch {
152
+ /* ignore */
153
+ }
154
+ });
155
+
156
+ // Parse task store path from system prompt (for sub-agent sessions)
157
+ let storePathFromPromptParsed = false;
158
+
159
+ function maybeOverrideStoreFromPrompt(systemPrompt: string, _sessionId: string) {
160
+ if (storePathFromPromptParsed) return;
161
+ storePathFromPromptParsed = true;
162
+ const match = systemPrompt.match(/<task_store_path>(.*?)<\/task_store_path>/s);
163
+ if (!match) return;
164
+ const path = match[1]!.trim();
165
+ if (path && path !== store.path) {
166
+ store = new FileTaskStore(path);
167
+ widget.setStore(store);
168
+ autoClear.getStore = () => store;
169
+ storeUpgraded = true; // prevent upgradeStoreIfNeeded from overriding
170
+ emitStorePath();
171
+ }
172
+ }
173
+
174
+ // Helper to emit task_updated through bridge (so parent session widget refreshes)
175
+ function notifyBridgeOnChange() {
176
+ // Only route to parent when this is a sub-agent session (store was overridden from system prompt)
177
+ if (!storeUpgraded) return;
178
+ pi.events.emit(EV_BRIDGE_TASK_UPDATED, { storePath: store.path, sessionId: currentSessionId });
179
+ }
69
180
 
70
181
  let storeUpgraded = false;
71
182
  let persistedTasksShown = false;
@@ -81,6 +192,7 @@ export default function todoExtension(pi: ExtensionAPI): void {
81
192
  }
82
193
  }
83
194
  storeUpgraded = true;
195
+ emitStorePath();
84
196
  }
85
197
 
86
198
  function showPersistedTasks(isResume = false) {
@@ -132,7 +244,10 @@ export default function todoExtension(pi: ExtensionAPI): void {
132
244
  pi.on("before_agent_start", async (event, ctx) => {
133
245
  cwd = ctx.cwd;
134
246
  cfg = loadTodoConfig(cwd);
247
+ currentSessionId = ctx.sessionManager.getSessionId();
135
248
  widget.setUICtx(ctx.ui);
249
+ // For sub-agent sessions: read store path from system prompt (injected by edb-subagents)
250
+ maybeOverrideStoreFromPrompt(event.systemPrompt, ctx.sessionManager.getSessionId());
136
251
  upgradeStoreIfNeeded(ctx.sessionManager.getSessionId());
137
252
  showPersistedTasks();
138
253
  const block = buildSystemPromptBlock(store);
@@ -150,8 +265,10 @@ export default function todoExtension(pi: ExtensionAPI): void {
150
265
  const isResume = event.reason === "resume";
151
266
  cwd = ctx.cwd;
152
267
  cfg = loadTodoConfig(cwd);
268
+ currentSessionId = ctx.sessionManager.getSessionId();
153
269
  storeUpgraded = false;
154
270
  persistedTasksShown = false;
271
+ storePathFromPromptParsed = false;
155
272
  currentTurn = 0;
156
273
  lastTaskToolUseTurn = 0;
157
274
  reminderInjectedThisCycle = false;
@@ -224,8 +341,11 @@ All tasks are created with status \`pending\`.
224
341
  description: params.description,
225
342
  priority: params.priority as TaskPriority | undefined,
226
343
  activeForm: params.activeForm,
344
+ parentId: params.parentId as string | undefined,
345
+ groupId: params.groupId as string | undefined,
227
346
  metadata: params.metadata,
228
347
  });
348
+ notifyBridgeOnChange();
229
349
  widget.setUICtx(ctx.ui);
230
350
  widget.update();
231
351
  return {
@@ -286,7 +406,7 @@ Use TaskGet with a specific task ID to view full details including description.`
286
406
  details: { tasks: [] } satisfies TaskDetails,
287
407
  };
288
408
 
289
- const statusOrder: Record<string, number> = { pending: 0, in_progress: 1, completed: 2 };
409
+ const statusOrder: Record<string, number> = { pending: 0, in_progress: 1, blocked: 2, completed: 3 };
290
410
  const sorted = [...tasks].sort((a, b) => {
291
411
  const so = (statusOrder[a.status] ?? 0) - (statusOrder[b.status] ?? 0);
292
412
  if (so !== 0) return so;
@@ -295,11 +415,19 @@ Use TaskGet with a specific task ID to view full details including description.`
295
415
 
296
416
  const lines = sorted.map((task) => {
297
417
  let line = `[${task.status}] [${task.priority}] #${task.id} ${task.content}`;
418
+ if (task.parentId) line += ` [subtask of #${task.parentId}]`;
419
+ if (task.owner) line += ` [owner: ${task.owner}]`;
420
+ if (task.status === "blocked" && task.blockQuestion) {
421
+ line += ` [blocked: "${task.blockQuestion.slice(0, 60)}"]`;
422
+ }
298
423
  const openBlockers = task.blockedBy.filter((bid) => {
299
424
  const b = store.get(bid);
300
425
  return b && b.status !== "completed";
301
426
  });
302
427
  if (openBlockers.length > 0) line += ` [blocked by ${openBlockers.map((id) => `#${id}`).join(", ")}]`;
428
+ if (task.blockedByGroup && !store.isGroupComplete(task.blockedByGroup)) {
429
+ line += ` [waiting for group: ${task.blockedByGroup}]`;
430
+ }
303
431
  return line;
304
432
  });
305
433
 
@@ -363,6 +491,15 @@ Returns full task details:
363
491
  `Priority: ${task.priority}`,
364
492
  ];
365
493
  if (task.owner) lines.push(`Owner: ${task.owner}`);
494
+ if (task.parentId) lines.push(`Subtask of: #${task.parentId}`);
495
+ if (task.groupId) lines.push(`Parallel group: ${task.groupId}`);
496
+ if (task.blockedByGroup) {
497
+ const groupDone = store.isGroupComplete(task.blockedByGroup);
498
+ lines.push(`Blocked by group: ${task.blockedByGroup} (${groupDone ? "resolved" : "waiting"})`);
499
+ }
500
+ if (task.status === "blocked" && task.blockQuestion) {
501
+ lines.push(`Blocked waiting for answer: "${task.blockQuestion}"`);
502
+ }
366
503
  lines.push(`Description: ${desc}`);
367
504
 
368
505
  const openBlockers = task.blockedBy.filter((bid) => {
@@ -471,6 +608,14 @@ Set dependencies:
471
608
  };
472
609
  }
473
610
 
611
+ // Early return from enforcement (e.g. blockedByGroup)
612
+ if (changedFields.length === 0 && warnings.length > 0) {
613
+ return {
614
+ content: [{ type: "text", text: `⚠ Not updated: ${warnings.join("; ")}` }],
615
+ details: { tasks: [...store.list()] } satisfies TaskDetails,
616
+ };
617
+ }
618
+
474
619
  if (fields.status === "in_progress") {
475
620
  widget.setActiveTask(id);
476
621
  autoClear.resetBatchCountdown();
@@ -481,8 +626,11 @@ Set dependencies:
481
626
  autoClear.trackCompletion(id, currentTurn);
482
627
  } else if (fields.status === "deleted") {
483
628
  widget.setActiveTask(id, false);
629
+ } else if (fields.status === "blocked") {
630
+ widget.setActiveTask(id, false); // stop spinner while blocked
484
631
  }
485
632
 
633
+ notifyBridgeOnChange();
486
634
  widget.setUICtx(ctx.ui);
487
635
  widget.update();
488
636
 
package/src/schemas.ts CHANGED
@@ -21,6 +21,18 @@ export const TodoCreateParams = Type.Object({
21
21
  "Present continuous form shown in the spinner when in_progress (e.g., 'Fixing authentication bug').",
22
22
  }),
23
23
  ),
24
+ parentId: Type.Optional(
25
+ Type.String({
26
+ description: "Parent task ID. Creates this as a subtask of the specified task.",
27
+ }),
28
+ ),
29
+ groupId: Type.Optional(
30
+ Type.String({
31
+ description:
32
+ "Parallel group ID. Tasks with the same groupId run concurrently. " +
33
+ "A task with blockedByGroup pointing to this groupId starts only when ALL group tasks complete.",
34
+ }),
35
+ ),
24
36
  metadata: Type.Optional(
25
37
  Type.Record(Type.String(), Type.Any(), { description: "Arbitrary key-value metadata to attach to the task." }),
26
38
  ),
@@ -37,10 +49,13 @@ export const TodoGetParams = Type.Object({
37
49
  export const TodoUpdateParams = Type.Object({
38
50
  id: Type.String({ description: "The ID of the task to update." }),
39
51
  status: Type.Optional(
40
- Type.Unsafe<"pending" | "in_progress" | "completed" | "deleted">({
52
+ Type.Unsafe<"pending" | "in_progress" | "completed" | "blocked" | "failed" | "deleted">({
41
53
  type: "string",
42
- enum: ["pending", "in_progress", "completed", "deleted"],
43
- description: "New status. Use 'deleted' to permanently remove the task.",
54
+ enum: ["pending", "in_progress", "completed", "blocked", "failed", "deleted"],
55
+ description:
56
+ "New status. Use 'deleted' to permanently remove the task. " +
57
+ "Use 'blocked' when the task is waiting for an answer from the supervisor (set blockQuestion too). " +
58
+ "Use 'failed' when the task errored or the assigned sub-agent aborted.",
44
59
  }),
45
60
  ),
46
61
  content: Type.Optional(Type.String({ description: "New task title." })),
@@ -48,6 +63,14 @@ export const TodoUpdateParams = Type.Object({
48
63
  priority: Type.Optional(StringEnum(["high", "medium", "low"] as const, { description: "New priority." })),
49
64
  activeForm: Type.Optional(Type.String({ description: "Spinner text shown when in_progress." })),
50
65
  owner: Type.Optional(Type.String({ description: "Owner / agent name." })),
66
+ blockQuestion: Type.Optional(
67
+ Type.String({ description: "Question text when setting status to 'blocked' (waiting for supervisor answer)." }),
68
+ ),
69
+ blockedByGroup: Type.Optional(
70
+ Type.String({
71
+ description: "Group ID to wait for. Task starts only when ALL tasks in this group are completed.",
72
+ }),
73
+ ),
51
74
  metadata: Type.Optional(
52
75
  Type.Record(Type.String(), Type.Any(), { description: "Metadata to merge. Set a key to null to delete it." }),
53
76
  ),
package/src/state.ts CHANGED
@@ -6,7 +6,7 @@ import type { Task, TaskPriority, TaskStatus } from "./types.js";
6
6
  // ── Spinner ───────────────────────────────────────────────────────────────────
7
7
 
8
8
  const SPINNER = ["✳", "✴", "✵", "✶", "✷", "✸", "✹", "✺", "✻", "✼", "✽"];
9
- const MAX_VISIBLE_TASKS = 10;
9
+ const MAX_VISIBLE_TASKS = 12;
10
10
 
11
11
  function formatDuration(ms: number): string {
12
12
  const totalSec = Math.floor(ms / 1000);
@@ -63,71 +63,130 @@ export class TodoWidget {
63
63
  }
64
64
  }
65
65
 
66
+ /** Render a single task row. indent=0 for top-level, indent=1 for subtasks. */
67
+ private renderTask(
68
+ task: Task,
69
+ isActive: boolean,
70
+ spinnerChar: string,
71
+ indent: number,
72
+ theme: any,
73
+ w: number,
74
+ ): string {
75
+ const truncate = (line: string) => truncateToWidth(line, w);
76
+ const pad = ` ${" ".repeat(indent)}`;
77
+
78
+ let icon: string;
79
+ if (isActive) {
80
+ icon = theme.fg("accent", spinnerChar);
81
+ } else if (task.status === "completed") {
82
+ icon = theme.fg("success", "✔");
83
+ } else if (task.status === "in_progress") {
84
+ icon = theme.fg("accent", "◼");
85
+ } else if (task.status === "blocked") {
86
+ icon = theme.fg("warning", "⏸");
87
+ } else if (task.status === "failed") {
88
+ icon = theme.fg("error", "✗");
89
+ } else {
90
+ icon = "◻";
91
+ }
92
+
93
+ // Attribution: show [owner] only when set (i.e. updated by a sub-agent)
94
+ const ownerTag = task.owner ? theme.fg("dim", ` [${task.owner}]`) : "";
95
+
96
+ let suffix = "";
97
+
98
+ if (task.status === "blocked" && task.blockQuestion) {
99
+ const preview = task.blockQuestion.slice(0, 50);
100
+ suffix = theme.fg("warning", ` waiting: "${preview}${task.blockQuestion.length > 50 ? "…" : ""}"`);
101
+ } else if (task.status === "pending") {
102
+ // Sequential blockedBy
103
+ const openBlockers = task.blockedBy.filter((bid) => {
104
+ const blocker = this.store.get(bid);
105
+ return blocker && blocker.status !== "completed";
106
+ });
107
+ if (openBlockers.length > 0) {
108
+ suffix = theme.fg("dim", ` › blocked by ${openBlockers.map((id) => `#${id}`).join(", ")}`);
109
+ }
110
+ // Group blocker
111
+ if (!suffix && task.blockedByGroup && !this.store.isGroupComplete(task.blockedByGroup)) {
112
+ suffix = theme.fg("dim", ` › waiting for group [${task.blockedByGroup}]`);
113
+ }
114
+ }
115
+
116
+ let text: string;
117
+ if (isActive) {
118
+ const form = task.activeForm || task.content;
119
+ const startedAt = this.taskStartedAt.get(task.id) ?? Date.now();
120
+ const elapsed = formatDuration(Date.now() - startedAt);
121
+ const stats = theme.fg("dim", `(${elapsed})`);
122
+ text = `${pad}${icon} ${theme.fg("accent", `${form}…`)} ${stats}${ownerTag}`;
123
+ } else if (task.status === "completed") {
124
+ text = `${pad}${icon} ${theme.fg("dim", theme.strikethrough(task.content))}${ownerTag}`;
125
+ } else if (task.status === "blocked") {
126
+ text = `${pad}${icon} ${theme.fg("muted", task.content)}${ownerTag}`;
127
+ } else if (task.status === "failed") {
128
+ text = `${pad}${icon} ${theme.fg("error", task.content)}${ownerTag}`;
129
+ } else {
130
+ text = `${pad}${icon} ${task.content}${ownerTag}`;
131
+ }
132
+
133
+ return truncate(text + suffix);
134
+ }
135
+
66
136
  private renderWidget(tui: any, theme: any): string[] {
67
137
  const tasks = this.store.list();
68
138
  const w: number = tui.terminal?.columns ?? 80;
69
- const truncate = (line: string) => truncateToWidth(line, w);
70
139
 
71
140
  if (tasks.length === 0) return [];
72
141
 
73
142
  const completed = tasks.filter((t) => t.status === "completed");
74
143
  const inProgress = tasks.filter((t) => t.status === "in_progress");
144
+ const blocked = tasks.filter((t) => t.status === "blocked");
145
+ const failed = tasks.filter((t) => t.status === "failed");
75
146
  const pending = tasks.filter((t) => t.status === "pending");
76
147
 
77
148
  const parts: string[] = [];
78
149
  if (completed.length > 0) parts.push(`${completed.length} done`);
79
150
  if (inProgress.length > 0) parts.push(`${inProgress.length} in progress`);
151
+ if (blocked.length > 0) parts.push(theme.fg("warning", `${blocked.length} blocked`));
152
+ if (failed.length > 0) parts.push(theme.fg("error", `${failed.length} failed`));
80
153
  if (pending.length > 0) parts.push(`${pending.length} open`);
81
154
  const statusText = `${tasks.length} task${tasks.length !== 1 ? "s" : ""} (${parts.join(", ")})`;
82
155
 
83
- const spinnerChar = SPINNER[this.widgetFrame % SPINNER.length];
84
- const lines: string[] = [truncate(`${theme.fg("accent", "●")} ${theme.fg("accent", statusText)}`)];
85
-
86
- const visible = tasks.slice(0, MAX_VISIBLE_TASKS);
87
- for (const task of visible) {
88
- const isActive = this.activeTaskIds.has(task.id) && task.status === "in_progress";
156
+ const spinnerChar = SPINNER[this.widgetFrame % SPINNER.length]!;
157
+ const lines: string[] = [truncateToWidth(`${theme.fg("accent", "●")} ${theme.fg("accent", statusText)}`, w)];
89
158
 
90
- let icon: string;
91
- if (isActive) {
92
- icon = theme.fg("accent", spinnerChar);
93
- } else if (task.status === "completed") {
94
- icon = theme.fg("success", "✔");
95
- } else if (task.status === "in_progress") {
96
- icon = theme.fg("accent", "◼");
97
- } else {
98
- icon = "◻";
159
+ // Build subtask map for tree rendering
160
+ const childrenOf = new Map<string, Task[]>();
161
+ for (const t of tasks) {
162
+ if (t.parentId) {
163
+ const arr = childrenOf.get(t.parentId) ?? [];
164
+ arr.push(t);
165
+ childrenOf.set(t.parentId, arr);
99
166
  }
167
+ }
100
168
 
101
- // Blocked-by suffix
102
- let suffix = "";
103
- if (task.status === "pending" && task.blockedBy.length > 0) {
104
- const openBlockers = task.blockedBy.filter((bid) => {
105
- const blocker = this.store.get(bid);
106
- return blocker && blocker.status !== "completed";
107
- });
108
- if (openBlockers.length > 0) {
109
- suffix = theme.fg("dim", ` › blocked by ${openBlockers.map((id) => `#${id}`).join(", ")}`);
110
- }
111
- }
169
+ // Render top-level tasks followed by their subtasks
170
+ const topLevel = tasks.filter((t) => !t.parentId);
171
+ let rendered = 0;
112
172
 
113
- let text: string;
114
- if (isActive) {
115
- const form = task.activeForm || task.content;
116
- const startedAt = this.taskStartedAt.get(task.id) ?? Date.now();
117
- const elapsed = formatDuration(Date.now() - startedAt);
118
- const stats = theme.fg("dim", `(${elapsed})`);
119
- text = ` ${icon} ${theme.fg("accent", `${form}…`)} ${stats}`;
120
- } else if (task.status === "completed") {
121
- text = ` ${icon} ${theme.fg("dim", theme.strikethrough(task.content))}`;
122
- } else {
123
- text = ` ${icon} ${task.content}`;
173
+ for (const task of topLevel) {
174
+ if (rendered >= MAX_VISIBLE_TASKS) break;
175
+ const isActive = this.activeTaskIds.has(task.id) && task.status === "in_progress";
176
+ lines.push(this.renderTask(task, isActive, spinnerChar, 0, theme, w));
177
+ rendered++;
178
+
179
+ const children = childrenOf.get(task.id) ?? [];
180
+ for (const child of children) {
181
+ if (rendered >= MAX_VISIBLE_TASKS) break;
182
+ const childActive = this.activeTaskIds.has(child.id) && child.status === "in_progress";
183
+ lines.push(this.renderTask(child, childActive, spinnerChar, 1, theme, w));
184
+ rendered++;
124
185
  }
125
-
126
- lines.push(truncate(text + suffix));
127
186
  }
128
187
 
129
188
  if (tasks.length > MAX_VISIBLE_TASKS) {
130
- lines.push(truncate(theme.fg("dim", ` … and ${tasks.length - MAX_VISIBLE_TASKS} more`)));
189
+ lines.push(truncateToWidth(theme.fg("dim", ` … and ${tasks.length - MAX_VISIBLE_TASKS} more`), w));
131
190
  }
132
191
 
133
192
  return lines;
@@ -218,6 +277,7 @@ export function priorityLabel(p: TaskPriority): string {
218
277
  export function statusIcon(status: TaskStatus): string {
219
278
  if (status === "completed") return "✓";
220
279
  if (status === "in_progress") return "●";
280
+ if (status === "blocked") return "⏸";
221
281
  return "○";
222
282
  }
223
283
 
@@ -228,10 +288,14 @@ export function renderTaskListResult(tasks: Task[], expanded: boolean, theme: an
228
288
 
229
289
  const doneCount = tasks.filter((t) => t.status === "completed").length;
230
290
  const inProgCount = tasks.filter((t) => t.status === "in_progress").length;
291
+ const blockedCount = tasks.filter((t) => t.status === "blocked").length;
292
+ const failedCount = tasks.filter((t) => t.status === "failed").length;
231
293
  const total = tasks.length;
232
294
 
233
295
  const parts: string[] = [];
234
296
  if (inProgCount > 0) parts.push(theme.fg("accent", `● ${inProgCount} active`));
297
+ if (blockedCount > 0) parts.push(theme.fg("warning", `⏸ ${blockedCount} blocked`));
298
+ if (failedCount > 0) parts.push(theme.fg("error", `✗ ${failedCount} failed`));
235
299
  parts.push(theme.fg("success", `✓ ${doneCount}/${total} done`));
236
300
  let output = parts.join(" ");
237
301
 
@@ -242,16 +306,26 @@ export function renderTaskListResult(tasks: Task[], expanded: boolean, theme: an
242
306
  ? theme.fg("success", "✓")
243
307
  : t.status === "in_progress"
244
308
  ? theme.fg("accent", "●")
245
- : theme.fg("dim", "");
309
+ : t.status === "blocked"
310
+ ? theme.fg("warning", "⏸")
311
+ : t.status === "failed"
312
+ ? theme.fg("error", "✗")
313
+ : theme.fg("dim", "○");
246
314
  const pColor = priorityColor(t.priority);
247
315
  const pLabel = theme.fg(pColor, priorityLabel(t.priority));
316
+ const ownerTag = t.owner ? theme.fg("dim", ` [${t.owner}]`) : "";
317
+ const subtaskIndent = t.parentId ? " " : "";
248
318
  const content =
249
319
  t.status === "completed"
250
320
  ? theme.fg("dim", theme.strikethrough(t.content))
251
321
  : t.status === "in_progress"
252
322
  ? theme.fg("text", theme.bold(t.content))
253
- : theme.fg("muted", t.content);
254
- output += `\n${icon} ${pLabel} ${content}`;
323
+ : t.status === "blocked"
324
+ ? theme.fg("muted", t.content)
325
+ : t.status === "failed"
326
+ ? theme.fg("error", t.content)
327
+ : theme.fg("muted", t.content);
328
+ output += `\n${subtaskIndent}${icon} ${pLabel} ${content}${ownerTag}`;
255
329
  }
256
330
 
257
331
  if (!expanded && tasks.length > 5) {
package/src/types.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // ── Types ──────────────────────────────────────────────────────────────────────
2
2
 
3
- export type TaskStatus = "pending" | "in_progress" | "completed";
3
+ export type TaskStatus = "pending" | "in_progress" | "completed" | "blocked" | "failed";
4
4
  export type TaskPriority = "high" | "medium" | "low";
5
5
 
6
6
  export interface Task {
@@ -10,7 +10,12 @@ export interface Task {
10
10
  status: TaskStatus;
11
11
  priority: TaskPriority;
12
12
  activeForm?: string; // spinner text when in_progress (e.g. "Running tests")
13
- owner?: string; // agent name / owner
13
+ owner?: string; // agent name / owner — shown in widget only when set by a sub-agent
14
+ parentId?: string; // parent task ID (for subtasks)
15
+ groupId?: string; // parallel group ID — tasks in the same group run concurrently
16
+ blockedByGroup?: string; // groupId to wait for — unblocks when all group tasks are completed
17
+ blockQuestion?: string; // question text when status === "blocked" (waiting for supervisor answer)
18
+ blockMessageId?: string; // bridge message ID for the pending ask (resolved by answer_subagent)
14
19
  metadata: Record<string, any>;
15
20
  blocks: string[]; // task IDs this task blocks
16
21
  blockedBy: string[]; // task IDs that block this task
@@ -18,6 +23,7 @@ export interface Task {
18
23
  updatedAt: number;
19
24
  startedAt?: number;
20
25
  completedAt?: number;
26
+ blockedAt?: number; // when status changed to "blocked"
21
27
  }
22
28
 
23
29
  export interface TaskDetails {
@@ -36,6 +42,8 @@ export const STATUS_ICON: Record<TaskStatus, string> = {
36
42
  pending: "○",
37
43
  in_progress: "●",
38
44
  completed: "✓",
45
+ blocked: "⏸",
46
+ failed: "✗",
39
47
  };
40
48
 
41
49
  export const PRIORITY_ORDER: Record<TaskPriority, number> = {