@agnishc/edb-todo 0.10.9 → 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 +22 -0
- package/package.json +1 -1
- package/src/component.ts +2 -1
- package/src/file-store.ts +88 -1
- package/src/index.ts +149 -1
- package/src/schemas.ts +26 -3
- package/src/state.ts +119 -45
- package/src/types.ts +10 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,7 +1,29 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.12.0] - 2026-05-22
|
|
4
|
+
|
|
3
5
|
## [0.10.9] - 2026-05-18
|
|
4
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
|
+
|
|
5
27
|
## [0.10.8] - 2026-05-18
|
|
6
28
|
|
|
7
29
|
## [0.10.6] - 2026-05-15
|
package/package.json
CHANGED
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:
|
|
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,
|
|
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:
|
|
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 =
|
|
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[] = [
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
114
|
-
if (
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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(
|
|
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
|
-
:
|
|
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
|
-
:
|
|
254
|
-
|
|
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> = {
|