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
45 changes: 40 additions & 5 deletions 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
- **7 LLM-callable tools** — `TaskCreate` (single + batch), `TaskList`, `TaskGet`, `TaskUpdate`, `TaskOutput`, `TaskStop`, `TaskExecute`
- **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 injected into the upcoming LLM request (via the `context` hook, transient and never persisted) when task tools haven't been used recently, or when a task is left stuck `in_progress` after a text-only turn. Shaped after Claude Code's todo reminders — an empty-list nudge or a JSON echo of the current list (capped at 10 tasks)
- **Prompt guidelines** — workflow contract encoded in tool descriptions, nudging the LLM at the point of tool use
Expand Down Expand Up @@ -90,20 +90,55 @@ See [Writing your own sort order](CUSTOMIZING.md#writing-your-own-sort-order) fo

### `TaskCreate`

Create a structured task. Used proactively for complex multi-step work.
Create one or more structured tasks. Pass `subject` and `description` for a single task, or a `batch` array for batch creation.

**Single task** (backward compatible):

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `subject` | string | yes | Brief imperative title |
| `description` | string | yes | Detailed context and acceptance criteria |
| `subject` | string | yes* | Brief imperative title |
| `description` | string | yes* | Detailed context and acceptance criteria |
| `activeForm` | string | no | Present continuous form for spinner (e.g., "Running tests") |
| `agentType` | string | no | Agent type for subagent execution (e.g., `"general-purpose"`, `"Explore"`) |
| `metadata` | object | no | Arbitrary key-value pairs |

*\*Required when not using `batch`.*

**Batch creation** (via `batch` array):

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

*\*Required when not using `subject`/`description`. You cannot mix `batch` with `subject`/`description`.*

```
→ Task #1 created successfully: Fix authentication bug
```

Batch example:
```json
{
"batch": [
{ "subject": "Design the API", "description": "Decide the shape" },
{ "subject": "Implement the handler", "description": "Write the code" },
{ "subject": "Write tests", "description": "Cover edge cases" }
]
}
```

```
→ 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 Expand Up @@ -357,7 +392,7 @@ If [`pi-subagents`](https://github.com/tintinweb/pi-subagents) is not installed,
src/
├── index.ts # Extension entry: 7 tools + /tasks command + widget + subagent integration
├── types.ts # Task, TaskStatus, BackgroundProcess types
├── task-store.ts # File-backed store with CRUD, dependencies, locking
├── task-store.ts # File-backed store with CRUD, batch creation, dependencies, locking
├── auto-clear.ts # Turn-based auto-clearing of completed tasks (AutoClearManager)
├── tasks-config.ts # Global defaults and project override persistence
├── process-tracker.ts # Background process output buffering and stop
Expand Down
57 changes: 52 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* @tintinweb/pi-tasks — A pi extension providing Claude Code-style task tracking and coordination.
*
* Tools:
* TaskCreate — Create a structured task
* TaskCreate — Create one or more structured tasks (single or batch)
* TaskList — List all tasks with status
* TaskGet — Get full task details
* TaskUpdate — Update task fields, status, dependencies
Expand Down Expand Up @@ -620,6 +620,7 @@ Skip using this tool when:

NOTE that you should not use this tool if there is only one trivial task to do. In this case you are better off just doing the task directly.


## Task Fields

- **subject**: A brief, actionable title in imperative form (e.g., "Fix authentication bug in login flow")
Expand All @@ -636,24 +637,70 @@ All tasks are created with status \`pending\`.
- Check TaskList first to avoid creating duplicate tasks
- Include \`agentType\` (e.g., "general-purpose", "Explore") to mark tasks for subagent execution via TaskExecute`,
promptGuidelines: [
"When working on complex multi-step tasks, use TaskCreate to track progress and TaskUpdate to update status.",
"When working with complex multi-step tasks, use TaskCreate to track progress and TaskUpdate to update status.",
"Mark tasks as in_progress before starting work and completed when done.",
"Use TaskList to check for available work after completing a task.",
"Prefer using the `batch` array to create multiple tasks at once instead of calling TaskCreate repeatedly.",
],
parameters: 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" }),
// Single-task parameters (backward compatible)
subject: Type.Optional(Type.String({ description: "A brief title for the task. Use this for a single task, or use `batch` for multiple." })),
description: Type.Optional(Type.String({ description: "A detailed description of what needs to be done. Use this for a single task, or use `batch` for multiple." })),
activeForm: Type.Optional(Type.String({ description: "Present continuous form shown in spinner when in_progress (e.g., 'Running tests')" })),
agentType: Type.Optional(Type.String({ description: "Agent type for subagent execution (e.g., 'general-purpose', 'Explore'). Tasks with agentType can be started via TaskExecute." })),
metadata: Type.Optional(Type.Record(Type.String(), Type.Any(), { description: "Arbitrary metadata to attach to the task" })),
// Batch parameter — pass an array to create multiple tasks in one call
batch: Type.Optional(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 task items to create in one call. Mutually exclusive with subject/description.", minItems: 1 },
)),
}),

execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
// A finished list must not collect the batch that follows it. The turn countdowns
// cannot be relied on for that: they only tick at `turn_start`, so a run that ends
// right after its last completion freezes one mid-count.
autoClear.startNewBatch();
const meta = params.metadata ?? {};

const hasBatch = params.batch && params.batch.length > 0;
const hasScalar = params.subject !== undefined || params.description !== undefined;

if (hasBatch && hasScalar) {
return Promise.resolve(textResult("Error: Cannot use `batch` array and `subject`/`description` together. Use one or the other."));
}

if (hasBatch) {
// Batch creation
const items = params.batch!.map(t => {
const meta = t.metadata ? { ...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,
} as { subject: string; description: string; activeForm?: string; metadata?: Record<string, any> };
});
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")));
}

// Single-task creation (backward compatible)
if (!params.subject || !params.description) {
return Promise.resolve(textResult("Error: `subject` and `description` are required when not using `batch`. Provide both, or use `batch` for batch creation."));
}
const meta = params.metadata ? { ...params.metadata } : {};
if (params.agentType) meta.agentType = params.agentType;
const task = store.create(params.subject, params.description, params.activeForm, Object.keys(meta).length > 0 ? meta : undefined);
widget.update();
Expand Down
26 changes: 26 additions & 0 deletions src/task-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,32 @@ export class TaskStore {
});
}

/** Create multiple tasks in a single atomic lock. Assigns sequential IDs. */
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;
});
}

get(id: string): Task | undefined {
if (this.filePath) this.load();
return this.tasks.get(id);
Expand Down
145 changes: 145 additions & 0 deletions test/subagent-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1159,3 +1159,148 @@ describe("Cascade data injection (buildTaskPrompt)", () => {
expect(bPrompt).not.toContain("Prerequisite task results");
});
});

// ──────────────────────────────────────────────────────────────────────────────
// TaskCreate batch tests
// ──────────────────────────────────────────────────────────────────────────────

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

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

it("creates multiple tasks via tasks array", async () => {
const result = await mock.executeTool("TaskCreate", {
batch: [
{ 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 batch tasks appear in TaskList", async () => {
await mock.executeTool("TaskCreate", {
batch: [
{ 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 for batch tasks", async () => {
await mock.executeTool("TaskCreate", {
batch: [
{ 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("batch IDs are sequential after prior TaskCreate calls", async () => {
await mock.executeTool("TaskCreate", {
subject: "Existing",
description: "Already here",
});

const result = await mock.executeTool("TaskCreate", {
batch: [
{ 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 in array", async () => {
const result = await mock.executeTool("TaskCreate", {
batch: [{ subject: "Just one", description: "Lonely task" }],
});

expect(result.content[0].text).toContain("Created 1 task:");
});

it("rejects mixing tasks array with subject/description", async () => {
const result = await mock.executeTool("TaskCreate", {
batch: [{ subject: "A", description: "D" }],
subject: "Conflicting",
description: "Should not work",
});

expect(result.content[0].text).toContain("Error");
expect(result.content[0].text).toContain("Cannot use");
});

it("requires subject and description when not using tasks array", async () => {
const result = await mock.executeTool("TaskCreate", {
subject: "Missing description",
});

expect(result.content[0].text).toContain("Error");
expect(result.content[0].text).toContain("required");
});

it("backward-compatible single task creation still works", async () => {
const result = await mock.executeTool("TaskCreate", {
subject: "Fix auth bug",
description: "Fix the login flow",
activeForm: "Fixing auth bug",
});

expect(result.content[0].text).toContain("Task #1 created successfully: Fix auth bug");
});

it("preserves activeForm and metadata in batch tasks", async () => {
await mock.executeTool("TaskCreate", {
batch: [
{ subject: "A", description: "D", activeForm: "Doing A", metadata: { priority: "high" } },
{ subject: "B", description: "D" },
],
});

const taskA = await mock.executeTool("TaskGet", { taskId: "1" });
expect(taskA.content[0].text).toContain("priority");
expect(taskA.content[0].text).toContain("high");

const taskB = await mock.executeTool("TaskGet", { taskId: "2" });
const taskBText = taskB.content[0].text as string;
expect(taskBText).toContain("B");
// Task B should not have priority metadata
expect(taskBText).not.toContain("priority");
});

it("batch creation is atomic — all tasks appear after call", async () => {
await mock.executeTool("TaskCreate", {
batch: [
{ subject: "One", description: "1" },
{ subject: "Two", description: "2" },
{ subject: "Three", description: "3" },
],
});

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