Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ https://github.com/user-attachments/assets/1d0ee87a-e0a5-4bfa-a9b9-2f9144cb905b

## Features

- **7 LLM-callable tools** — `TaskCreate`, `TaskList`, `TaskGet`, `TaskUpdate`, `TaskOutput`, `TaskStop`, `TaskExecute` — matching Claude Code's exact tool specs and descriptions
- **8 LLM-callable tools** — `TaskCreate`, `TaskCreateMany`, `TaskList`, `TaskGet`, `TaskUpdate`, `TaskOutput`, `TaskStop`, `TaskExecute` — matching Claude Code's exact tool specs and descriptions
- **Persistent widget** — live task list above the editor with `✔`/`◼`/`◻` status icons, task numbers (`#1`, `#2`, …), strikethrough for completed tasks, star spinner (`✳✽`) for active tasks with elapsed time and token counts
- **System-reminder injection** — periodic `<system-reminder>` nudges appended to tool results when task tools haven't been used recently (matches Claude Code's behavior exactly)
- **Prompt guidelines** — workflow contract encoded in tool descriptions, nudging the LLM at the point of tool use
Expand Down Expand Up @@ -71,6 +71,26 @@ Create a structured task. Used proactively for complex multi-step work.
→ Task #1 created successfully: Fix authentication bug
```

### `TaskCreateMany`

Create multiple structured tasks in a single call. More efficient than repeated `TaskCreate` calls when all tasks are known upfront.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `tasks` | array | yes | Array of task objects (min 1) |
| `tasks[].subject` | string | yes | Brief imperative title |
| `tasks[].description` | string | yes | Detailed context and acceptance criteria |
| `tasks[].activeForm` | string | no | Present continuous form for spinner |
| `tasks[].agentType` | string | no | Agent type for subagent execution |
| `tasks[].metadata` | object | no | Arbitrary key-value pairs |

```
→ Created 3 tasks:
#1 Design the API
#2 Implement the handler
#3 Write tests
```

### `TaskList`

List all tasks with status, owner, and blocked-by info.
Expand Down
89 changes: 81 additions & 8 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
* @tintinweb/pi-tasks — A pi extension providing Claude Code-style task tracking and coordination.
*
* Tools:
* TaskCreate — Create a structured task
* TaskCreate — Create a structured task
* TaskCreateMany — Create multiple tasks in one call
* TaskList — List all tasks with status
* TaskGet — Get full task details
* TaskUpdate — Update task fields, status, dependencies
Expand Down Expand Up @@ -39,7 +40,7 @@ function textResult(msg: string) {
}

/** Task tool names — used to detect task tool usage for reminder suppression. */
const TASK_TOOL_NAMES = new Set(["TaskCreate", "TaskList", "TaskGet", "TaskUpdate", "TaskOutput", "TaskStop", "TaskExecute"]);
const TASK_TOOL_NAMES = new Set(["TaskCreate", "TaskCreateMany", "TaskList", "TaskGet", "TaskUpdate", "TaskOutput", "TaskStop", "TaskExecute"]);

/** How many turns without task tool usage before injecting a reminder. */
const REMINDER_INTERVAL = 4;
Expand Down Expand Up @@ -463,7 +464,79 @@ All tasks are created with status \`pending\`.
});

// ──────────────────────────────────────────────────
// Tool 2: TaskList
// Tool 2: TaskCreateMany
// ──────────────────────────────────────────────────

pi.registerTool({
name: "TaskCreateMany",
label: "TaskCreateMany",
description: `Use this tool to create multiple structured tasks in a single call. Prefer this over repeated TaskCreate calls when you know all tasks upfront.

## When to Use This Tool

- When planning a multi-step effort and all tasks are known at once
- When the user provides a list of things to be done (numbered or comma-separated)
- When you want to batch-create tasks without repeated round-trips

## When NOT to Use This Tool

Skip using this tool when:
- You only have one task to create (use TaskCreate instead)
- The task list depends on the output of prior work

## Task Fields (per item)

- **subject**: A brief, actionable title in imperative form
- **description**: Detailed description of what needs to be done
- **activeForm** (optional): Present continuous form for the spinner (e.g., "Fixing bug")
- **agentType** (optional): Agent type for subagent execution via TaskExecute
- **metadata** (optional): Arbitrary key-value pairs

## Tips

- Use TaskUpdate afterwards to set up dependencies (blocks/blockedBy) between the created tasks
- IDs are assigned sequentially in the order tasks appear in the array`,
promptGuidelines: [
"Use TaskCreateMany when you have multiple tasks to create at once — it is more efficient than calling TaskCreate in a loop.",
"After bulk creation, use TaskUpdate to wire up any blocks/blockedBy dependencies between tasks.",
],
parameters: Type.Object({
tasks: Type.Array(
Type.Object({
subject: Type.String({ description: "A brief title for the task" }),
description: Type.String({ description: "A detailed description of what needs to be done" }),
activeForm: Type.Optional(Type.String({ description: "Present continuous form shown in spinner when in_progress" })),
agentType: Type.Optional(Type.String({ description: "Agent type for subagent execution via TaskExecute" })),
metadata: Type.Optional(Type.Record(Type.String(), Type.Any(), { description: "Arbitrary metadata to attach to the task" })),
}),
{ description: "Array of tasks to create", minItems: 1 },
),
}),

execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
autoClear.resetBatchCountdown();
const items = params.tasks.map(t => {
const meta = t.metadata ?? {};
if (t.agentType) meta.agentType = t.agentType;
return {
subject: t.subject,
description: t.description,
activeForm: t.activeForm,
metadata: Object.keys(meta).length > 0 ? meta : undefined,
};
});
const created = store.createMany(items);
widget.update();
const lines = [`Created ${created.length} task${created.length === 1 ? "" : "s"}:`];
for (const task of created) {
lines.push(` #${task.id} ${task.subject}`);
}
return Promise.resolve(textResult(lines.join("\n")));
},
});

// ──────────────────────────────────────────────────
// Tool 3: TaskList
// ──────────────────────────────────────────────────

pi.registerTool({
Expand Down Expand Up @@ -529,7 +602,7 @@ Use TaskGet with a specific task ID to view full details including description a
});

// ──────────────────────────────────────────────────
// Tool 3: TaskGet
// Tool 4: TaskGet
// ──────────────────────────────────────────────────

pi.registerTool({
Expand Down Expand Up @@ -600,7 +673,7 @@ Returns full task details:
});

// ──────────────────────────────────────────────────
// Tool 4: TaskUpdate
// Tool 5: TaskUpdate
// ──────────────────────────────────────────────────

pi.registerTool({
Expand Down Expand Up @@ -729,7 +802,7 @@ Set up task dependencies:
});

// ──────────────────────────────────────────────────
// Tool 5: TaskOutput
// Tool 6: TaskOutput
// ──────────────────────────────────────────────────

pi.registerTool({
Expand Down Expand Up @@ -805,7 +878,7 @@ Set up task dependencies:
});

// ──────────────────────────────────────────────────
// Tool 6: TaskStop
// Tool 7: TaskStop
// ──────────────────────────────────────────────────

pi.registerTool({
Expand Down Expand Up @@ -856,7 +929,7 @@ Set up task dependencies:
});

// ──────────────────────────────────────────────────
// Tool 7: TaskExecute
// Tool 8: TaskExecute
// ──────────────────────────────────────────────────

pi.registerTool({
Expand Down
25 changes: 25 additions & 0 deletions src/task-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,31 @@ export class TaskStore {
}
}

createMany(items: Array<{ subject: string; description: string; activeForm?: string; metadata?: Record<string, any> }>): Task[] {
return this.withLock(() => {
const now = Date.now();
const created: Task[] = [];
for (const item of items) {
const task: Task = {
id: String(this.nextId++),
subject: item.subject,
description: item.description,
status: "pending",
activeForm: item.activeForm,
owner: undefined,
metadata: item.metadata ?? {},
blocks: [],
blockedBy: [],
createdAt: now,
updatedAt: now,
};
this.tasks.set(task.id, task);
created.push(task);
}
return created;
});
}

create(subject: string, description: string, activeForm?: string, metadata?: Record<string, any>): Task {
return this.withLock(() => {
const now = Date.now();
Expand Down
84 changes: 84 additions & 0 deletions test/subagent-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -994,3 +994,87 @@ describe("Cascade data injection (buildTaskPrompt)", () => {
expect(bPrompt).not.toContain("Prerequisite task results");
});
});

// ──────────────────────────────────────────────────────────────────────────────
// TaskCreateMany tool tests
// ──────────────────────────────────────────────────────────────────────────────

describe("TaskCreateMany", () => {
let mock: ReturnType<typeof mockPi>;

beforeEach(() => {
mock = mockPi();
initExtension(mock.pi as any);
});

it("is registered as a tool", () => {
expect(mock.tools.has("TaskCreateMany")).toBe(true);
});

it("creates multiple tasks and returns a summary", async () => {
const result = await mock.executeTool("TaskCreateMany", {
tasks: [
{ subject: "Step one", description: "Do the first thing" },
{ subject: "Step two", description: "Do the second thing" },
{ subject: "Step three", description: "Do the third thing" },
],
});

const text = result.content[0].text as string;
expect(text).toContain("Created 3 tasks");
expect(text).toContain("#1 Step one");
expect(text).toContain("#2 Step two");
expect(text).toContain("#3 Step three");
});

it("created tasks appear in TaskList", async () => {
await mock.executeTool("TaskCreateMany", {
tasks: [
{ subject: "Alpha", description: "A" },
{ subject: "Beta", description: "B" },
],
});

const list = await mock.executeTool("TaskList", {});
const text = list.content[0].text as string;
expect(text).toContain("Alpha");
expect(text).toContain("Beta");
});

it("supports agentType stored in metadata", async () => {
await mock.executeTool("TaskCreateMany", {
tasks: [
{ subject: "Agent task", description: "Run me", agentType: "general-purpose" },
],
});

const details = await mock.executeTool("TaskGet", { taskId: "1" });
expect(details.content[0].text).toContain("general-purpose");
});

it("IDs are sequential after prior TaskCreate calls", async () => {
await mock.executeTool("TaskCreate", {
subject: "Existing",
description: "Already here",
});

const result = await mock.executeTool("TaskCreateMany", {
tasks: [
{ subject: "Bulk A", description: "D" },
{ subject: "Bulk B", description: "D" },
],
});

const text = result.content[0].text as string;
expect(text).toContain("#2 Bulk A");
expect(text).toContain("#3 Bulk B");
});

it("returns singular wording for a single task", async () => {
const result = await mock.executeTool("TaskCreateMany", {
tasks: [{ subject: "Just one", description: "Lonely task" }],
});

expect(result.content[0].text).toContain("Created 1 task:");
});
});
79 changes: 79 additions & 0 deletions test/task-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -414,3 +414,82 @@ describe("TaskStore (absolute path)", () => {
expect(raw.tasks).toHaveLength(2);
});
});

describe("TaskStore createMany", () => {
let store: TaskStore;

beforeEach(() => {
store = new TaskStore();
});

it("creates multiple tasks with sequential IDs", () => {
const created = store.createMany([
{ subject: "Task A", description: "Desc A" },
{ subject: "Task B", description: "Desc B" },
{ subject: "Task C", description: "Desc C" },
]);

expect(created).toHaveLength(3);
expect(created.map(t => t.id)).toEqual(["1", "2", "3"]);
expect(created.map(t => t.subject)).toEqual(["Task A", "Task B", "Task C"]);
expect(store.list()).toHaveLength(3);
});

it("sets status to pending for all created tasks", () => {
const created = store.createMany([
{ subject: "A", description: "D" },
{ subject: "B", description: "D" },
]);
expect(created.every(t => t.status === "pending")).toBe(true);
});

it("preserves activeForm and metadata per task", () => {
const created = store.createMany([
{ subject: "A", description: "D", activeForm: "Doing A", metadata: { key: "val" } },
{ subject: "B", description: "D" },
]);
expect(created[0].activeForm).toBe("Doing A");
expect(created[0].metadata).toEqual({ key: "val" });
expect(created[1].activeForm).toBeUndefined();
expect(created[1].metadata).toEqual({});
});

it("continues ID counter from existing tasks", () => {
store.create("Existing", "Desc");
const created = store.createMany([
{ subject: "Bulk A", description: "D" },
{ subject: "Bulk B", description: "D" },
]);
expect(created.map(t => t.id)).toEqual(["2", "3"]);
});

it("returns empty array for empty input", () => {
const created = store.createMany([]);
expect(created).toEqual([]);
expect(store.list()).toHaveLength(0);
});

it("persists bulk-created tasks in file-backed mode", () => {
const testId = `test-bulk-${Date.now()}`;
const tasksDir = join(homedir(), ".pi", "tasks");
const filePath = join(tasksDir, `${testId}.json`);

try {
const store1 = new TaskStore(testId);
store1.createMany([
{ subject: "Persisted A", description: "D" },
{ subject: "Persisted B", description: "D" },
]);

const store2 = new TaskStore(testId);
const tasks = store2.list();
expect(tasks).toHaveLength(2);
expect(tasks[0].subject).toBe("Persisted A");
expect(tasks[1].subject).toBe("Persisted B");
} finally {
try { rmSync(filePath); } catch { /* */ }
try { rmSync(filePath + ".lock"); } catch { /* */ }
try { rmSync(filePath + ".tmp"); } catch { /* */ }
}
});
});
Loading