@dex-ai/tasks-extension 0.1.4

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/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@dex-ai/tasks-extension",
3
+ "version": "0.1.4",
4
+ "description": "Task management extension — tools, skill, and UI helpers for structured task tracking.",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./src/index.ts",
9
+ "default": "./src/index.ts"
10
+ }
11
+ },
12
+ "files": [
13
+ "src",
14
+ "skill"
15
+ ],
16
+ "scripts": {
17
+ "typecheck": "tsc --noEmit",
18
+ "test": "bun test"
19
+ },
20
+ "dependencies": {
21
+ "@dex-ai/sdk": "^0.1.2",
22
+ "@dex-ai/vue-tui": "^0.1.10",
23
+ "zod": "^3.23.0"
24
+ },
25
+ "sideEffects": false,
26
+ "publishConfig": {
27
+ "access": "public",
28
+ "registry": "https://registry.npmjs.org/"
29
+ }
30
+ }
package/skill/SKILL.md ADDED
@@ -0,0 +1,34 @@
1
+ ---
2
+ name: task-management
3
+ description: "Structured task tracking — create, update, reorder, and delete tasks with a real-time info panel. Use for multi-step, parallel, or sequenced work."
4
+ ---
5
+
6
+ ## Task Management
7
+
8
+ The task system provides tools for tracking work items during a session.
9
+
10
+ ### When to Use
11
+
12
+ - **Breaking a complex task into sequenced steps** — create tasks and mark them `in_progress`/`completed` as you go
13
+ - **Parallel work** — create sibling tasks that can be done independently
14
+ - **Dependency tracking** — use `parentId` to build subtask hierarchies; use `blocked` status for tasks waiting on others
15
+ - **Session handoff** — before switching topics, ensure all tasks are properly statused so context is clear on resume
16
+
17
+ ### Available Tools
18
+
19
+ | Tool | Purpose |
20
+ |------|---------|
21
+ | `task_create` | Create or update tasks. Pass an array of 1..N items; if an item has an `id` that already exists, it's updated. |
22
+ | `task_update` | Change title, description, status, or priority |
23
+ | `task_list` | View all tasks, optionally filtered by status or parent |
24
+ | `task_delete` | Remove a task (optionally cascade to subtasks) |
25
+ | `task_reorder` | Reposition or reparent a task |
26
+
27
+ ### Best Practices
28
+
29
+ - **Create tasks proactively.** Before starting work, use `task_create` to define all the steps. This gives both you and the user visibility into the plan.
30
+ - **Update status as you go.** Mark a task `in_progress` when starting work on it, `completed` when done, `blocked` if something external is needed.
31
+ - **Upsert via `task_create`.** Pass existing task IDs back in the `tasks` array to update them in the same call as creating new ones.
32
+ - **Use subtasks for breakdowns.** Complex tasks get a parent + children. Parent tracks the overall goal; children are specific implementation steps.
33
+ - **Prioritize with `priority` field.** 0 = highest, 10 = lowest. Use lower numbers for critical-path items.
34
+ - **Use `task_list`** to review current state before making updates.
package/src/index.ts ADDED
@@ -0,0 +1,82 @@
1
+ /**
2
+ * @dex-ai/tasks-extension — task management for Dex agents.
3
+ *
4
+ * Provides:
5
+ * - 5 tools (task_create, task_update, task_list, task_delete, task_reorder)
6
+ * - 1 skill (task-management)
7
+ * - UI helpers for rendering task panels
8
+ * - Shared types for consumers
9
+ *
10
+ * Usage:
11
+ * import { tasksExtension } from '@dex-ai/tasks-extension';
12
+ *
13
+ * const agent = await Agent.create({
14
+ * extensions: [tasksExtension(), ...otherExtensions],
15
+ * });
16
+ */
17
+
18
+ import type { Extension, Skill } from "@dex-ai/sdk";
19
+ import { readFileSync } from "node:fs";
20
+ import { join } from "node:path";
21
+ import {
22
+ makeCreateTool,
23
+ makeUpdateTool,
24
+ makeListTool,
25
+ makeDeleteTool,
26
+ makeReorderTool,
27
+ } from "./tools";
28
+ import { createTaskPanel } from "./panel";
29
+
30
+ // Re-export everything
31
+ export type { Task, TaskStatus, TaskEvent } from "./types";
32
+ export { TASKS_STATE_KEY } from "./types";
33
+ export {
34
+ computeHeader,
35
+ computeVisibleItems,
36
+ statusIcon,
37
+ } from "./ui";
38
+ export type { TaskPanelHeader, TaskPanelItem, TaskPanelList } from "./ui";
39
+ export { createTaskPanel } from "./panel";
40
+
41
+ /* ------------------------------------------------------------------ */
42
+ /* Skill loader */
43
+ /* ------------------------------------------------------------------ */
44
+
45
+ function loadSkillContent(): string {
46
+ const skillPath = join(import.meta.dir, "..", "skill", "SKILL.md");
47
+ const raw = readFileSync(skillPath, "utf-8");
48
+ // Strip YAML frontmatter
49
+ return raw.replace(/^---[\s\S]*?---\s*/, "");
50
+ }
51
+
52
+ /* ------------------------------------------------------------------ */
53
+ /* Extension factory */
54
+ /* ------------------------------------------------------------------ */
55
+
56
+ /**
57
+ * Creates the task management extension with tools and skill.
58
+ */
59
+ export function tasksExtension(): Extension & {
60
+ panel: ReturnType<typeof createTaskPanel>;
61
+ } {
62
+ const skill: Skill = {
63
+ name: "task-management",
64
+ description:
65
+ "Structured task tracking — create, update, reorder, and delete tasks with a real-time info panel. Use for multi-step, parallel, or sequenced work.",
66
+ content: loadSkillContent(),
67
+ };
68
+
69
+ return {
70
+ name: "tasks",
71
+ description: "Task management tools for structured multi-step work.",
72
+ tools: [
73
+ makeCreateTool(),
74
+ makeUpdateTool(),
75
+ makeListTool(),
76
+ makeDeleteTool(),
77
+ makeReorderTool(),
78
+ ],
79
+ skills: [skill],
80
+ panel: createTaskPanel(),
81
+ };
82
+ }
package/src/panel.ts ADDED
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Task DynamicPanel — renders the task list in the ActivityPanel zone.
3
+ *
4
+ * Uses the WidgetNode/W builder from @dex-ai/vue-tui to produce a
5
+ * declarative UI tree that the TUI host renders.
6
+ */
7
+
8
+ import {
9
+ W,
10
+ type WidgetNode,
11
+ type DynamicPanel,
12
+ type PanelContext,
13
+ } from "@dex-ai/vue-tui";
14
+ import type { Task } from "./types";
15
+ import { TASKS_STATE_KEY } from "./types";
16
+ import { computeHeader, computeVisibleItems, statusIcon } from "./ui";
17
+
18
+ /**
19
+ * Create the tasks DynamicPanel.
20
+ * Displays active tasks in the activity zone with expand/collapse support.
21
+ */
22
+ export function createTaskPanel(): DynamicPanel {
23
+ return {
24
+ id: "tasks",
25
+ label: "Tasks",
26
+ priority: 10, // Higher than default — tasks take precedence
27
+
28
+ visible(ctx: PanelContext): boolean {
29
+ const tasks =
30
+ (ctx.state.get(TASKS_STATE_KEY) as Task[] | undefined) ?? [];
31
+ return tasks.length > 0 && !tasks.every((t) => t.status === "completed");
32
+ },
33
+
34
+ render(ctx: PanelContext): WidgetNode | null {
35
+ const tasks =
36
+ (ctx.state.get(TASKS_STATE_KEY) as Task[] | undefined) ?? [];
37
+ if (tasks.length === 0) return null;
38
+
39
+ const header = computeHeader(tasks);
40
+ const expanded = ctx.state.get("tasks:expanded") !== false;
41
+
42
+ const children: WidgetNode[] = [];
43
+
44
+ // Header line: ● Current task - N/M complete (ctrl+t)
45
+ if (header.allDone) {
46
+ children.push(
47
+ W.line([W.text("✓ all tasks complete", { color: "accent" })]),
48
+ );
49
+ } else {
50
+ children.push(
51
+ W.line([
52
+ W.text("●", { color: "accent" }),
53
+ W.text(` ${header.currentTitle}`, { color: "accent" }),
54
+ W.text(` - ${header.doneCount}/${header.totalCount} complete`, {
55
+ color: "accent",
56
+ }),
57
+ W.text(" (ctrl+t)", { dim: true }),
58
+ ]),
59
+ );
60
+ }
61
+
62
+ // Expanded item list
63
+ if (expanded && !header.allDone) {
64
+ const { items, useCompact } = computeVisibleItems(tasks);
65
+ const doneCount = header.doneCount;
66
+
67
+ // Separator
68
+ children.push(
69
+ W.line([
70
+ W.text(
71
+ "- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -",
72
+ { dim: true },
73
+ ),
74
+ ]),
75
+ );
76
+
77
+ // Compact mode: show done count
78
+ if (useCompact && doneCount > 0) {
79
+ children.push(
80
+ W.line([W.text(` ✓ ${doneCount} done`, { dim: true })]),
81
+ );
82
+ }
83
+
84
+ // Task items
85
+ for (const item of items) {
86
+ const icon = statusIcon(item.task.status);
87
+ const isActive = item.task.status === "in_progress";
88
+ const isDone = item.task.status === "completed";
89
+ const isFailed = item.task.status === "failed";
90
+ const isBlocked = item.task.status === "blocked";
91
+
92
+ const titleText = item.task.title + (isBlocked ? " (blocked)" : "");
93
+ const dim = isDone || item.task.status === "pending" || isFailed;
94
+
95
+ children.push(
96
+ W.line([
97
+ W.text(
98
+ " ".repeat(item.indent) + icon,
99
+ isActive ? { color: "accent" } : { dim: true },
100
+ ),
101
+ W.text(` ${titleText}`, dim ? { dim: true } : {}),
102
+ ]),
103
+ );
104
+ }
105
+ }
106
+
107
+ return W.stack(children);
108
+ },
109
+ };
110
+ }
@@ -0,0 +1,386 @@
1
+ /**
2
+ * Tests for the task management tools.
3
+ *
4
+ * We test the tool execute() functions directly by constructing a minimal
5
+ * GenerateContext with an agent context that has a Map for state.
6
+ */
7
+ import { describe, it, expect, beforeEach } from "bun:test";
8
+ import { tasksExtension } from "./index";
9
+
10
+ /* ------------------------------------------------------------------ */
11
+ /* Helpers: minimal context mocks */
12
+ /* ------------------------------------------------------------------ */
13
+
14
+ function mockGctx(state?: Map<string, unknown>) {
15
+ const agentState = state ?? new Map<string, unknown>();
16
+ return {
17
+ agent: {
18
+ state: agentState,
19
+ } as any,
20
+ state: new Map<string, unknown>(),
21
+ signal: undefined,
22
+ usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
23
+ stepCount: 0,
24
+ maxSteps: 10,
25
+ content: [],
26
+ } as any;
27
+ }
28
+
29
+ /* ------------------------------------------------------------------ */
30
+ /* Setup */
31
+ /* ------------------------------------------------------------------ */
32
+
33
+ let ext: ReturnType<typeof tasksExtension>;
34
+
35
+ beforeEach(() => {
36
+ ext = tasksExtension();
37
+ });
38
+
39
+ function tool(name: string) {
40
+ return (ext.tools! as any[]).find((t: any) => t.name === name)!;
41
+ }
42
+
43
+ /* ------------------------------------------------------------------ */
44
+ /* Tests: task_create (create + upsert) */
45
+ /* ------------------------------------------------------------------ */
46
+
47
+ describe("task_create", () => {
48
+ it("creates a single task", () => {
49
+ const t = tool("task_create");
50
+ const gctx = mockGctx();
51
+ const result = t.execute({ tasks: [{ title: "Write tests" }] }, gctx);
52
+ expect(result).toContain("Created 1");
53
+ expect(result).toContain("Write tests");
54
+
55
+ const tasks = gctx.agent.state.get("tasks") as any[];
56
+ expect(tasks).toHaveLength(1);
57
+ expect(tasks[0].title).toBe("Write tests");
58
+ expect(tasks[0].status).toBe("pending");
59
+ expect(tasks[0].priority).toBe(5);
60
+ });
61
+
62
+ it("creates multiple tasks", () => {
63
+ const t = tool("task_create");
64
+ const gctx = mockGctx();
65
+ const result = t.execute(
66
+ {
67
+ tasks: [
68
+ { title: "Task A" },
69
+ { title: "Task B", status: "in_progress" },
70
+ { title: "Task C", priority: 1 },
71
+ ],
72
+ },
73
+ gctx,
74
+ );
75
+ expect(result).toContain("Created 3");
76
+ const tasks = gctx.agent.state.get("tasks") as any[];
77
+ expect(tasks).toHaveLength(3);
78
+ expect(tasks[0].title).toBe("Task A");
79
+ expect(tasks[1].status).toBe("in_progress");
80
+ expect(tasks[2].priority).toBe(1);
81
+ });
82
+
83
+ it("generates unique IDs", () => {
84
+ const t = tool("task_create");
85
+ const gctx = mockGctx();
86
+ t.execute({ tasks: [{ title: "X" }, { title: "Y" }] }, gctx);
87
+ const tasks = gctx.agent.state.get("tasks") as any[];
88
+ expect(tasks[0].id).not.toBe(tasks[1].id);
89
+ });
90
+
91
+ it("updates existing task when id matches", () => {
92
+ const t = tool("task_create");
93
+ const gctx = mockGctx();
94
+ t.execute({ tasks: [{ title: "Original" }] }, gctx);
95
+ const [task] = gctx.agent.state.get("tasks") as any[];
96
+
97
+ const result = t.execute(
98
+ { tasks: [{ id: task.id, title: "Updated", status: "completed" }] },
99
+ gctx,
100
+ );
101
+ expect(result).toContain("Updated 1");
102
+
103
+ const tasks = gctx.agent.state.get("tasks") as any[];
104
+ expect(tasks).toHaveLength(1);
105
+ expect(tasks[0].title).toBe("Updated");
106
+ expect(tasks[0].status).toBe("completed");
107
+ });
108
+
109
+ it("handles mixed create and update in one call", () => {
110
+ const t = tool("task_create");
111
+ const gctx = mockGctx();
112
+ t.execute({ tasks: [{ title: "Existing" }] }, gctx);
113
+ const [existing] = gctx.agent.state.get("tasks") as any[];
114
+
115
+ const result = t.execute(
116
+ {
117
+ tasks: [
118
+ { id: existing.id, title: "Existing (done)", status: "completed" },
119
+ { title: "Brand New" },
120
+ ],
121
+ },
122
+ gctx,
123
+ );
124
+ expect(result).toContain("Created 1");
125
+ expect(result).toContain("Updated 1");
126
+
127
+ const tasks = gctx.agent.state.get("tasks") as any[];
128
+ expect(tasks).toHaveLength(2);
129
+ expect(tasks[0].title).toBe("Existing (done)");
130
+ expect(tasks[1].title).toBe("Brand New");
131
+ });
132
+ });
133
+
134
+ /* ------------------------------------------------------------------ */
135
+ /* Tests: task_update */
136
+ /* ------------------------------------------------------------------ */
137
+
138
+ describe("task_update", () => {
139
+ it("updates task fields", () => {
140
+ const t = tool("task_create");
141
+ const u = tool("task_update");
142
+ const gctx = mockGctx();
143
+ t.execute({ tasks: [{ title: "Original" }] }, gctx);
144
+ const [task] = gctx.agent.state.get("tasks") as any[];
145
+
146
+ const result = u.execute(
147
+ {
148
+ taskId: task.id,
149
+ title: "Updated",
150
+ status: "completed",
151
+ priority: 1,
152
+ },
153
+ gctx,
154
+ );
155
+ expect(result).toContain("Updated");
156
+ expect(result).toContain("completed");
157
+
158
+ const tasks = gctx.agent.state.get("tasks") as any[];
159
+ expect(tasks[0].title).toBe("Updated");
160
+ expect(tasks[0].status).toBe("completed");
161
+ expect(tasks[0].priority).toBe(1);
162
+ });
163
+
164
+ it("returns error for unknown task", () => {
165
+ const u = tool("task_update");
166
+ const result = u.execute({ taskId: "nonexistent" }, mockGctx());
167
+ expect(result).toContain("Error");
168
+ expect(result).toContain("not found");
169
+ });
170
+ });
171
+
172
+ /* ------------------------------------------------------------------ */
173
+ /* Tests: task_list */
174
+ /* ------------------------------------------------------------------ */
175
+
176
+ describe("task_list", () => {
177
+ it("returns 'no tasks' when empty", () => {
178
+ const l = tool("task_list");
179
+ const result = l.execute({}, mockGctx());
180
+ expect(result).toContain("No tasks");
181
+ });
182
+
183
+ it("lists tasks grouped by status", () => {
184
+ const c = tool("task_create");
185
+ const l = tool("task_list");
186
+ const gctx = mockGctx();
187
+ c.execute(
188
+ {
189
+ tasks: [
190
+ { title: "Task A", status: "completed" },
191
+ { title: "Task B", status: "in_progress" },
192
+ { title: "Task C", status: "pending" },
193
+ ],
194
+ },
195
+ gctx,
196
+ );
197
+
198
+ const result = l.execute({}, gctx);
199
+ expect(result).toContain("IN PROGRESS");
200
+ expect(result).toContain("PENDING");
201
+ expect(result).toContain("COMPLETED");
202
+ expect(result).toContain("Task A");
203
+ expect(result).toContain("Task B");
204
+ expect(result).toContain("Task C");
205
+ });
206
+
207
+ it("filters by status", () => {
208
+ const c = tool("task_create");
209
+ const l = tool("task_list");
210
+ const gctx = mockGctx();
211
+ c.execute(
212
+ {
213
+ tasks: [
214
+ { title: "One", status: "completed" },
215
+ { title: "Two", status: "pending" },
216
+ ],
217
+ },
218
+ gctx,
219
+ );
220
+ const result = l.execute({ status: "completed" }, gctx);
221
+ expect(result).toContain("One");
222
+ expect(result).not.toContain("Two");
223
+ });
224
+ });
225
+
226
+ /* ------------------------------------------------------------------ */
227
+ /* Tests: task_delete */
228
+ /* ------------------------------------------------------------------ */
229
+
230
+ describe("task_delete", () => {
231
+ it("deletes a single task", () => {
232
+ const c = tool("task_create");
233
+ const d = tool("task_delete");
234
+ const gctx = mockGctx();
235
+ c.execute({ tasks: [{ title: "Delete me" }] }, gctx);
236
+ const [task] = gctx.agent.state.get("tasks") as any[];
237
+
238
+ const result = d.execute({ taskId: task.id }, gctx);
239
+ expect(result).toContain("Deleted 1 task(s)");
240
+ expect(gctx.agent.state.get("tasks")).toHaveLength(0);
241
+ });
242
+
243
+ it("cascade deletes subtasks", () => {
244
+ const c = tool("task_create");
245
+ const d = tool("task_delete");
246
+ const gctx = mockGctx();
247
+ c.execute({ tasks: [{ title: "Parent" }] }, gctx);
248
+ const [parent] = gctx.agent.state.get("tasks") as any[];
249
+ c.execute({ tasks: [{ title: "Child", parentId: parent.id }] }, gctx);
250
+
251
+ const result = d.execute({ taskId: parent.id, cascade: true }, gctx);
252
+ expect(result).toContain("Deleted 2 task(s)");
253
+ expect(gctx.agent.state.get("tasks")).toHaveLength(0);
254
+ });
255
+ });
256
+
257
+ /* ------------------------------------------------------------------ */
258
+ /* Tests: task_reorder */
259
+ /* ------------------------------------------------------------------ */
260
+
261
+ describe("task_reorder", () => {
262
+ it("moves a task after another", () => {
263
+ const c = tool("task_create");
264
+ const r = tool("task_reorder");
265
+ const gctx = mockGctx();
266
+ c.execute(
267
+ { tasks: [{ title: "A" }, { title: "B" }, { title: "C" }] },
268
+ gctx,
269
+ );
270
+ const tasks = gctx.agent.state.get("tasks") as any[];
271
+
272
+ const result = r.execute(
273
+ { taskId: tasks[2].id, afterId: tasks[0].id },
274
+ gctx,
275
+ );
276
+ expect(result).toContain("Moved");
277
+
278
+ const reordered = gctx.agent.state.get("tasks") as any[];
279
+ expect(reordered[0].title).toBe("A");
280
+ expect(reordered[1].title).toBe("C");
281
+ expect(reordered[2].title).toBe("B");
282
+ });
283
+ });
284
+
285
+ /* ------------------------------------------------------------------ */
286
+ /* Tests: UI helpers */
287
+ /* ------------------------------------------------------------------ */
288
+
289
+ import { computeHeader, computeVisibleItems, statusIcon } from "./ui";
290
+ import type { Task } from "./types";
291
+
292
+ function makeTask(overrides: Partial<Task> = {}): Task {
293
+ return {
294
+ id: "t1",
295
+ title: "Test task",
296
+ description: "",
297
+ status: "pending",
298
+ priority: 5,
299
+ parentId: null,
300
+ position: 0,
301
+ createdAt: 0,
302
+ updatedAt: 0,
303
+ ...overrides,
304
+ };
305
+ }
306
+
307
+ describe("computeHeader", () => {
308
+ it("shows current in_progress task", () => {
309
+ const tasks = [
310
+ makeTask({
311
+ id: "t1",
312
+ title: "First",
313
+ status: "completed",
314
+ position: 0,
315
+ }),
316
+ makeTask({
317
+ id: "t2",
318
+ title: "Active",
319
+ status: "in_progress",
320
+ position: 1,
321
+ }),
322
+ makeTask({ id: "t3", title: "Later", status: "pending", position: 2 }),
323
+ ];
324
+ const header = computeHeader(tasks);
325
+ expect(header.currentTitle).toBe("Active");
326
+ expect(header.doneCount).toBe(1);
327
+ expect(header.totalCount).toBe(3);
328
+ expect(header.allDone).toBe(false);
329
+ });
330
+
331
+ it("shows first pending when none in_progress", () => {
332
+ const tasks = [
333
+ makeTask({ id: "t1", title: "A", status: "pending", position: 0 }),
334
+ makeTask({ id: "t2", title: "B", status: "pending", position: 1 }),
335
+ ];
336
+ const header = computeHeader(tasks);
337
+ expect(header.currentTitle).toBe("A");
338
+ });
339
+
340
+ it("shows allDone when all completed", () => {
341
+ const tasks = [
342
+ makeTask({ id: "t1", status: "completed", position: 0 }),
343
+ makeTask({ id: "t2", status: "completed", position: 1 }),
344
+ ];
345
+ const header = computeHeader(tasks);
346
+ expect(header.allDone).toBe(true);
347
+ expect(header.doneCount).toBe(2);
348
+ });
349
+ });
350
+
351
+ describe("computeVisibleItems", () => {
352
+ it("returns root tasks with children indented", () => {
353
+ const tasks = [
354
+ makeTask({ id: "p1", title: "Parent", position: 0 }),
355
+ makeTask({ id: "c1", title: "Child", position: 1, parentId: "p1" }),
356
+ ];
357
+ const { items } = computeVisibleItems(tasks);
358
+ expect(items).toHaveLength(2);
359
+ expect(items[0]!.indent).toBe(2);
360
+ expect(items[1]!.indent).toBe(4);
361
+ });
362
+
363
+ it("collapses completed in compact mode (>6 roots)", () => {
364
+ const tasks = Array.from({ length: 8 }, (_, i) =>
365
+ makeTask({
366
+ id: `t${i}`,
367
+ title: `Task ${i}`,
368
+ status: i < 3 ? "completed" : "pending",
369
+ position: i,
370
+ }),
371
+ );
372
+ const { useCompact, items } = computeVisibleItems(tasks);
373
+ expect(useCompact).toBe(true);
374
+ expect(items).toHaveLength(5);
375
+ });
376
+ });
377
+
378
+ describe("statusIcon", () => {
379
+ it("returns correct icons", () => {
380
+ expect(statusIcon("in_progress")).toBe("●");
381
+ expect(statusIcon("completed")).toBe("✓");
382
+ expect(statusIcon("failed")).toBe("x");
383
+ expect(statusIcon("pending")).toBe("○");
384
+ expect(statusIcon("blocked")).toBe("○");
385
+ });
386
+ });
package/src/tools.ts ADDED
@@ -0,0 +1,429 @@
1
+ /**
2
+ * Task Management Tools — create, update, list, delete, reorder tasks.
3
+ *
4
+ * Uses Zod schemas (same pattern as bash/read/write/edit tools) for:
5
+ * - Compile-time type inference
6
+ * - Runtime input validation
7
+ * - Proper JSON Schema generation via zod-to-json-schema
8
+ */
9
+
10
+ import type { GenerateContext } from "@dex-ai/sdk";
11
+ import type { AnyTool, ToolOutput } from "@dex-ai/sdk";
12
+ import { z } from "zod";
13
+ import type { Task, TaskStatus } from "./types";
14
+
15
+ /* ------------------------------------------------------------------ */
16
+ /* State helpers */
17
+ /* ------------------------------------------------------------------ */
18
+
19
+ const STATE_KEY = "tasks";
20
+
21
+ function getTasks(gctx: GenerateContext): Task[] {
22
+ return (gctx.agent.state.get(STATE_KEY) as Task[]) ?? [];
23
+ }
24
+
25
+ function setTasks(gctx: GenerateContext, tasks: Task[]): void {
26
+ // Always set a new array reference so reactive UI frameworks detect the change
27
+ gctx.agent.state.set(STATE_KEY, [...tasks]);
28
+ }
29
+
30
+ let taskIdCounter = 0;
31
+ function nextId(): string {
32
+ return `task_${++taskIdCounter}`;
33
+ }
34
+
35
+ /* ------------------------------------------------------------------ */
36
+ /* Shared enums */
37
+ /* ------------------------------------------------------------------ */
38
+
39
+ const statusEnum = z.enum([
40
+ "pending",
41
+ "in_progress",
42
+ "completed",
43
+ "failed",
44
+ "blocked",
45
+ ]);
46
+
47
+ /* ------------------------------------------------------------------ */
48
+ /* Tool: task_create */
49
+ /* ------------------------------------------------------------------ */
50
+
51
+ const createParams = z.object({
52
+ tasks: z
53
+ .array(
54
+ z.object({
55
+ id: z
56
+ .string()
57
+ .optional()
58
+ .describe(
59
+ "Task ID. If provided and exists, the task is updated instead of created.",
60
+ ),
61
+ title: z.string().min(1).describe("Task title"),
62
+ description: z.string().optional().describe("Optional description"),
63
+ status: statusEnum.optional(),
64
+ priority: z
65
+ .number()
66
+ .int()
67
+ .min(0)
68
+ .max(10)
69
+ .optional()
70
+ .describe("Priority 0-10 (0 = highest)"),
71
+ parentId: z.string().optional().describe("Parent task ID for subtasks"),
72
+ }),
73
+ )
74
+ .min(1)
75
+ .max(100)
76
+ .describe(
77
+ "Tasks to create or update. If an id matches an existing task, it is updated.",
78
+ ),
79
+ });
80
+
81
+ type CreateParams = z.infer<typeof createParams>;
82
+
83
+ export function makeCreateTool(): AnyTool {
84
+ return {
85
+ name: "task_create",
86
+ displayName: "Create Tasks",
87
+ visible: false,
88
+ description:
89
+ "Create one or more tasks. In 'single' mode returns the created task ID; in 'bulk' mode returns a summary. Use this to break work into manageable units.",
90
+ access: "write",
91
+ parameters: createParams,
92
+ execute(input: CreateParams, gctx) {
93
+ const existing = getTasks(gctx);
94
+ const created: Task[] = [];
95
+ const updated: Task[] = [];
96
+ const now = Date.now();
97
+ let maxPos = existing.reduce(
98
+ (m, t) => Math.max(m, t.position),
99
+ existing.length,
100
+ );
101
+
102
+ for (const t of input.tasks) {
103
+ // If id provided and exists, update in place
104
+ const existingIdx = t.id
105
+ ? existing.findIndex((e) => e.id === t.id)
106
+ : -1;
107
+
108
+ if (existingIdx !== -1) {
109
+ const task = { ...existing[existingIdx]! };
110
+ task.title = t.title;
111
+ if (t.description !== undefined) task.description = t.description;
112
+ if (t.status !== undefined) task.status = t.status as TaskStatus;
113
+ if (t.priority !== undefined) task.priority = t.priority;
114
+ if (t.parentId !== undefined) task.parentId = t.parentId;
115
+ task.updatedAt = now;
116
+ existing[existingIdx] = task;
117
+ updated.push(task);
118
+ } else {
119
+ maxPos++;
120
+ const task: Task = {
121
+ id: t.id ?? nextId(),
122
+ title: t.title,
123
+ description: t.description ?? "",
124
+ status: (t.status as TaskStatus) ?? "pending",
125
+ priority: t.priority ?? 5,
126
+ parentId: t.parentId ?? null,
127
+ position: maxPos,
128
+ createdAt: now,
129
+ updatedAt: now,
130
+ };
131
+ created.push(task);
132
+ existing.push(task);
133
+ }
134
+ }
135
+
136
+ setTasks(gctx, existing);
137
+
138
+ const parts: string[] = [];
139
+ if (created.length) {
140
+ parts.push(
141
+ `Created ${created.length}: ${created.map((t) => `${t.id} "${t.title}"`).join(", ")}`,
142
+ );
143
+ }
144
+ if (updated.length) {
145
+ parts.push(
146
+ `Updated ${updated.length}: ${updated.map((t) => `${t.id} "${t.title}"`).join(", ")}`,
147
+ );
148
+ }
149
+ return parts.join(". ");
150
+ },
151
+ };
152
+ }
153
+
154
+ /* ------------------------------------------------------------------ */
155
+ /* Tool: task_update */
156
+ /* ------------------------------------------------------------------ */
157
+
158
+ const updateParams = z.object({
159
+ taskId: z.string().min(1).describe("ID of the task to update"),
160
+ title: z.string().min(1).optional().describe("New title"),
161
+ description: z.string().optional().describe("New description"),
162
+ status: statusEnum.optional().describe("New status"),
163
+ priority: z
164
+ .number()
165
+ .int()
166
+ .min(0)
167
+ .max(10)
168
+ .optional()
169
+ .describe("New priority 0-10"),
170
+ });
171
+
172
+ type UpdateParams = z.infer<typeof updateParams>;
173
+
174
+ export function makeUpdateTool(): AnyTool {
175
+ return {
176
+ name: "task_update",
177
+ displayName: "Update Task",
178
+ visible: false,
179
+ description:
180
+ "Update one or more fields of an existing task (title, description, status, priority). Returns the updated task summary.",
181
+ access: "write",
182
+ parameters: updateParams,
183
+ execute(input: UpdateParams, gctx) {
184
+ const existing = getTasks(gctx);
185
+ const idx = existing.findIndex((t) => t.id === input.taskId);
186
+ if (idx === -1) {
187
+ return `Error: task "${input.taskId}" not found. Use task_list to see available tasks.`;
188
+ }
189
+
190
+ const task = { ...existing[idx]! };
191
+ const now = Date.now();
192
+ let changed = false;
193
+
194
+ if (input.title !== undefined) {
195
+ task.title = input.title;
196
+ changed = true;
197
+ }
198
+ if (input.description !== undefined) {
199
+ task.description = input.description;
200
+ changed = true;
201
+ }
202
+ if (input.status !== undefined) {
203
+ task.status = input.status as TaskStatus;
204
+ changed = true;
205
+ }
206
+ if (input.priority !== undefined) {
207
+ task.priority = input.priority;
208
+ changed = true;
209
+ }
210
+
211
+ if (changed) {
212
+ task.updatedAt = now;
213
+ existing[idx] = task;
214
+ setTasks(gctx, existing);
215
+ }
216
+
217
+ return `Updated ${task.id} — "${task.title}" [${task.status}]`;
218
+ },
219
+ };
220
+ }
221
+
222
+ /* ------------------------------------------------------------------ */
223
+ /* Tool: task_list */
224
+ /* ------------------------------------------------------------------ */
225
+
226
+ const listParams = z.object({
227
+ status: statusEnum.optional().describe("Filter by status"),
228
+ parentId: z
229
+ .string()
230
+ .optional()
231
+ .describe("Filter by parent. Use null for root tasks only, omit for all."),
232
+ });
233
+
234
+ type ListParams = z.infer<typeof listParams>;
235
+
236
+ export function makeListTool(): AnyTool {
237
+ return {
238
+ name: "task_list",
239
+ displayName: "List Tasks",
240
+ visible: false,
241
+ description:
242
+ "List all tasks, optionally filtered by status or parent. Returns a formatted task board grouped by status.",
243
+ access: "read",
244
+ parameters: listParams,
245
+ execute(input: ListParams, gctx) {
246
+ const all = getTasks(gctx);
247
+ if (all.length === 0) {
248
+ return "No tasks created yet. Use task_create to add tasks.";
249
+ }
250
+
251
+ let filtered = [...all];
252
+
253
+ if (input.status) {
254
+ filtered = filtered.filter((t) => t.status === input.status);
255
+ }
256
+
257
+ if (input.parentId !== undefined) {
258
+ filtered = filtered.filter((t) => t.parentId === input.parentId);
259
+ }
260
+
261
+ // Sort by status group then position
262
+ const statusOrder: Record<TaskStatus, number> = {
263
+ in_progress: 0,
264
+ blocked: 1,
265
+ pending: 2,
266
+ completed: 3,
267
+ failed: 4,
268
+ };
269
+
270
+ filtered.sort(
271
+ (a, b) =>
272
+ (statusOrder[a.status] ?? 99) - (statusOrder[b.status] ?? 99) ||
273
+ a.position - b.position,
274
+ );
275
+
276
+ const lines: string[] = [];
277
+ let currentStatus: TaskStatus | null = null;
278
+
279
+ for (const task of filtered) {
280
+ if (task.status !== currentStatus) {
281
+ currentStatus = task.status;
282
+ const label =
283
+ task.status === "in_progress"
284
+ ? "▶ IN PROGRESS"
285
+ : task.status === "pending"
286
+ ? "○ PENDING"
287
+ : task.status === "completed"
288
+ ? "✓ COMPLETED"
289
+ : task.status === "blocked"
290
+ ? "⚠ BLOCKED"
291
+ : "✗ FAILED";
292
+ lines.push(`\n── ${label} ──`);
293
+ }
294
+ const parent = task.parentId ? ` [subtask of ${task.parentId}]` : "";
295
+ const prio = task.priority < 5 ? " ⚡" : "";
296
+ lines.push(
297
+ ` ${task.id}: ${task.title}${prio}${parent} — ${task.description || "(no description)"}`,
298
+ );
299
+ }
300
+
301
+ if (lines.length === 0) {
302
+ return "No tasks match the filter.";
303
+ }
304
+
305
+ return lines.join("\n");
306
+ },
307
+ };
308
+ }
309
+
310
+ /* ------------------------------------------------------------------ */
311
+ /* Tool: task_delete */
312
+ /* ------------------------------------------------------------------ */
313
+
314
+ const deleteParams = z.object({
315
+ taskId: z.string().min(1).describe("ID of the task to delete"),
316
+ cascade: z.boolean().optional().describe("Also delete all subtasks"),
317
+ });
318
+
319
+ type DeleteParams = z.infer<typeof deleteParams>;
320
+
321
+ export function makeDeleteTool(): AnyTool {
322
+ return {
323
+ name: "task_delete",
324
+ displayName: "Delete Task",
325
+ visible: false,
326
+ description:
327
+ "Delete a task by ID. Use cascade=true to also delete all subtasks.",
328
+ access: "write",
329
+ parameters: deleteParams,
330
+ execute(input: DeleteParams, gctx) {
331
+ const existing = getTasks(gctx);
332
+ const idx = existing.findIndex((t) => t.id === input.taskId);
333
+ if (idx === -1) {
334
+ return `Error: task "${input.taskId}" not found.`;
335
+ }
336
+
337
+ const toRemove = new Set<string>([input.taskId]);
338
+
339
+ if (input.cascade) {
340
+ // Collect all descendants
341
+ const children = existing.filter((t) => t.parentId === input.taskId);
342
+ for (const child of children) {
343
+ toRemove.add(child.id);
344
+ const grandchildren = existing.filter((t) => t.parentId === child.id);
345
+ for (const gc of grandchildren) toRemove.add(gc.id);
346
+ }
347
+ }
348
+
349
+ const removed: Task[] = [];
350
+ const remaining = existing.filter((t) => {
351
+ if (toRemove.has(t.id)) {
352
+ removed.push(t);
353
+ return false;
354
+ }
355
+ return true;
356
+ });
357
+
358
+ setTasks(gctx, remaining);
359
+
360
+ return `Deleted ${removed.length} task(s): ${removed.map((t) => `${t.id} — "${t.title}"`).join(", ")}`;
361
+ },
362
+ };
363
+ }
364
+
365
+ /* ------------------------------------------------------------------ */
366
+ /* Tool: task_reorder */
367
+ /* ------------------------------------------------------------------ */
368
+
369
+ const reorderParams = z.object({
370
+ taskId: z.string().min(1).describe("ID of the task to move"),
371
+ afterId: z
372
+ .string()
373
+ .optional()
374
+ .describe("Place after this task ID. Omit to move to end."),
375
+ newParentId: z
376
+ .string()
377
+ .optional()
378
+ .describe("Move under a different parent (omit to keep current)."),
379
+ });
380
+
381
+ type ReorderParams = z.infer<typeof reorderParams>;
382
+
383
+ export function makeReorderTool(): AnyTool {
384
+ return {
385
+ name: "task_reorder",
386
+ displayName: "Reorder Task",
387
+ visible: false,
388
+ description:
389
+ "Change a task's position and/or parent. Use afterId to place after a specific task, newParentId to reparent.",
390
+ access: "write",
391
+ parameters: reorderParams,
392
+ execute(input: ReorderParams, gctx) {
393
+ const existing = getTasks(gctx);
394
+ const taskIdx = existing.findIndex((t) => t.id === input.taskId);
395
+ if (taskIdx === -1) {
396
+ return `Error: task "${input.taskId}" not found.`;
397
+ }
398
+
399
+ const task = { ...existing[taskIdx]! };
400
+
401
+ // Update parent if provided
402
+ if (input.newParentId !== undefined) {
403
+ task.parentId = input.newParentId;
404
+ }
405
+
406
+ // Remove from current position
407
+ const without = existing.filter((t) => t.id !== input.taskId);
408
+
409
+ // Find insertion point
410
+ let insertAt = without.length; // end by default
411
+ if (input.afterId) {
412
+ const afterIdx = without.findIndex((t) => t.id === input.afterId);
413
+ if (afterIdx !== -1) {
414
+ insertAt = afterIdx + 1;
415
+ }
416
+ }
417
+
418
+ without.splice(insertAt, 0, task);
419
+
420
+ // Reassign positions
421
+ const reindexed = without.map((t, i) => ({ ...t, position: i }));
422
+ task.updatedAt = Date.now();
423
+
424
+ setTasks(gctx, reindexed);
425
+
426
+ return `Moved "${task.title}" to position ${insertAt + 1}${task.parentId ? ` under "${task.parentId}"` : ""}`;
427
+ },
428
+ };
429
+ }
package/src/types.ts ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Task types — shared between tools, skill, and UI components.
3
+ */
4
+
5
+ export type TaskStatus =
6
+ | "pending"
7
+ | "in_progress"
8
+ | "completed"
9
+ | "failed"
10
+ | "blocked";
11
+
12
+ export interface Task {
13
+ id: string;
14
+ title: string;
15
+ description: string;
16
+ status: TaskStatus;
17
+ priority: number; // 0 = highest
18
+ parentId: string | null;
19
+ position: number;
20
+ createdAt: number;
21
+ updatedAt: number;
22
+ }
23
+
24
+ export interface TaskEvent {
25
+ type: "task-created" | "task-updated" | "task-deleted" | "task-reordered";
26
+ tasks: Task[];
27
+ }
28
+
29
+ /**
30
+ * The state key used to store tasks in agent.state.
31
+ * UI components should read from `agent.state.get(TASKS_STATE_KEY)`.
32
+ */
33
+ export const TASKS_STATE_KEY = "tasks";
package/src/ui.ts ADDED
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Task panel UI helpers — pure logic for rendering the ActivityPanel.
3
+ *
4
+ * These functions compute what to display without any framework dependency.
5
+ * The Vue component (or any other renderer) calls these to get display data.
6
+ */
7
+
8
+ import type { Task, TaskStatus } from "./types";
9
+
10
+ /* ------------------------------------------------------------------ */
11
+ /* Panel item for expanded list */
12
+ /* ------------------------------------------------------------------ */
13
+
14
+ export interface TaskPanelItem {
15
+ task: Task;
16
+ indent: number;
17
+ }
18
+
19
+ /* ------------------------------------------------------------------ */
20
+ /* Header computation */
21
+ /* ------------------------------------------------------------------ */
22
+
23
+ export interface TaskPanelHeader {
24
+ allDone: boolean;
25
+ currentTitle: string;
26
+ doneCount: number;
27
+ totalCount: number;
28
+ }
29
+
30
+ /**
31
+ * Compute the header line data for the tasks panel.
32
+ */
33
+ export function computeHeader(tasks: Task[]): TaskPanelHeader {
34
+ const doneCount = tasks.filter((t) => t.status === "completed").length;
35
+ const totalCount = tasks.length;
36
+ const allDone = doneCount === totalCount && totalCount > 0;
37
+
38
+ const sorted = [...tasks].sort((a, b) => a.position - b.position);
39
+ const current =
40
+ sorted.find((t) => t.status === "in_progress") ??
41
+ sorted.find((t) => t.status === "pending");
42
+
43
+ return {
44
+ allDone,
45
+ currentTitle: current?.title ?? "",
46
+ doneCount,
47
+ totalCount,
48
+ };
49
+ }
50
+
51
+ /* ------------------------------------------------------------------ */
52
+ /* Expanded list computation */
53
+ /* ------------------------------------------------------------------ */
54
+
55
+ const COMPACT_THRESHOLD = 6;
56
+
57
+ export interface TaskPanelList {
58
+ useCompact: boolean;
59
+ items: TaskPanelItem[];
60
+ }
61
+
62
+ /**
63
+ * Compute the visible items for the expanded task list.
64
+ * In compact mode (>6 root tasks), completed tasks are collapsed.
65
+ */
66
+ export function computeVisibleItems(tasks: Task[]): TaskPanelList {
67
+ const sorted = [...tasks].sort((a, b) => a.position - b.position);
68
+ const roots = sorted.filter((t) => !t.parentId);
69
+ const useCompact = roots.length > COMPACT_THRESHOLD;
70
+ const items: TaskPanelItem[] = [];
71
+
72
+ for (const root of roots) {
73
+ if (useCompact && root.status === "completed") continue;
74
+
75
+ items.push({ task: root, indent: 2 });
76
+
77
+ // Subtasks
78
+ const children = sorted.filter((t) => t.parentId === root.id);
79
+ for (const child of children) {
80
+ if (useCompact && child.status === "completed") continue;
81
+ items.push({ task: child, indent: 4 });
82
+ }
83
+ }
84
+
85
+ return { useCompact, items };
86
+ }
87
+
88
+ /* ------------------------------------------------------------------ */
89
+ /* Status icon helper */
90
+ /* ------------------------------------------------------------------ */
91
+
92
+ /**
93
+ * Returns the display icon for a given task status.
94
+ */
95
+ export function statusIcon(status: TaskStatus): string {
96
+ switch (status) {
97
+ case "in_progress":
98
+ return "●";
99
+ case "completed":
100
+ return "✓";
101
+ case "failed":
102
+ return "x";
103
+ case "pending":
104
+ case "blocked":
105
+ default:
106
+ return "○";
107
+ }
108
+ }