mirror of
https://github.com/wassname/pi-lgtm.git
synced 2026-06-27 14:16:01 +08:00
v0.1.0
This commit is contained in:
+35
@@ -0,0 +1,35 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
*.tsbuildinfo
|
||||
plan/
|
||||
docs/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Editor directories
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
.temp/
|
||||
.pi/readcache
|
||||
.claude
|
||||
.vscode
|
||||
|
||||
.pi
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
# Development files
|
||||
tsconfig.json
|
||||
.vscode/
|
||||
.git/
|
||||
.gitignore
|
||||
|
||||
# Documentation
|
||||
PLAN.md
|
||||
SUMMARY.md
|
||||
plan/
|
||||
media/
|
||||
|
||||
# Build artifacts
|
||||
*.log
|
||||
npm-debug.log*
|
||||
.DS_Store
|
||||
|
||||
# Tests (if any)
|
||||
test/
|
||||
tests/
|
||||
*.test.ts
|
||||
*.spec.ts
|
||||
|
||||
.pi
|
||||
.claude
|
||||
.vscode
|
||||
@@ -0,0 +1,27 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.1.0] - 2026-03-12
|
||||
|
||||
Initial release — Claude Code-style task tracking and coordination for pi.
|
||||
|
||||
### Added
|
||||
- **6 LLM-callable tools** — `TaskCreate`, `TaskList`, `TaskGet`, `TaskUpdate`, `TaskOutput`, `TaskStop` — matching Claude Code's exact tool specs, descriptions, and schemas.
|
||||
- **System-reminder injection** — periodic `<system-reminder>` nudges appended to non-task tool results when tasks exist but task tools haven't been used for 4+ turns. Matches Claude Code's host-level reminder mechanism.
|
||||
- **Prompt guidelines** — `promptGuidelines` on TaskCreate injects persistent guidance into the system prompt, nudging the LLM to use task tools for complex work.
|
||||
- **Task state in system prompt** — `before_agent_start` event appends current task state to the system prompt on every agent loop, ensuring task awareness survives context compaction.
|
||||
- **Persistent widget** — live task list above editor with `✔` (completed, strikethrough + dim), `◼` (in-progress), `◻` (pending), animated star spinner (`✳✽`) for active tasks with elapsed time and token counts (e.g., `✳ Running tests… (2m 49s · ↑ 4.1k ↓ 1.2k)`).
|
||||
- **Multiple parallel active tasks** — widget supports multiple simultaneous spinners.
|
||||
- **`/tasks` command** — interactive menu: view tasks with actions (start, complete, delete), create tasks, clear completed.
|
||||
- **Bidirectional dependency management** — `addBlocks`/`addBlockedBy` maintain both sides automatically. Edges cleaned up on task deletion.
|
||||
- **Dependency warnings** — cycles, self-dependencies, and dangling references produce warnings in TaskUpdate responses. Edges are still stored, matching Claude Code's permissive behavior.
|
||||
- **File-backed shared storage** — set `PI_TASK_LIST_ID` env var for multi-session coordination at `~/.pi/tasks/<id>.json`. File locking with stale-lock detection prevents race conditions.
|
||||
- **In-memory session-scoped mode** — default when no env var is set, zero disk I/O.
|
||||
- **Background process tracker** — output buffering (stdout + stderr), waiter notification, graceful stop with timeout escalation (SIGTERM → 5s → SIGKILL).
|
||||
- **78 unit tests** — task store CRUD, dependencies, warnings, file persistence; widget rendering, icons, spinners, token/duration formatting; process tracker lifecycle.
|
||||
|
||||
[0.1.0]: https://github.com/tintinweb/pi-tasks/releases/tag/v0.1.0
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 tintinweb
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,210 @@
|
||||
# pi-chonky-tasks
|
||||
|
||||
A [pi](https://pi.dev) extension that brings **Claude Code-style task tracking and coordination** to pi. Track multi-step work with structured tasks, dependency management, and a persistent visual widget.
|
||||
|
||||
> **Status:** Early release.
|
||||
|
||||
## Features
|
||||
|
||||
- **6 LLM-callable tools** — `TaskCreate`, `TaskList`, `TaskGet`, `TaskUpdate`, `TaskOutput`, `TaskStop` — matching Claude Code's exact tool specs and descriptions
|
||||
- **Persistent widget** — live task list above the editor with `✔`/`◼`/`◻` status icons, 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)
|
||||
- **Task state persistence** — current task state injected into system prompt on every agent loop, surviving context compaction
|
||||
- **Prompt guidelines** — system prompt guidelines nudge the LLM to use task tools for complex work
|
||||
- **Dependency management** — bidirectional `blocks`/`blockedBy` relationships with warnings for cycles, self-deps, and dangling references
|
||||
- **Shared task lists** — multiple pi sessions can share a file-backed task list for agent team coordination
|
||||
- **File locking** — concurrent access is safe when multiple sessions share a task list
|
||||
- **Background process tracking** — track spawned processes with output buffering, blocking wait, and graceful stop
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pi install npm:@tintinweb/pi-tasks
|
||||
```
|
||||
|
||||
Or load directly for development:
|
||||
|
||||
```bash
|
||||
pi -e ./src/index.ts
|
||||
```
|
||||
|
||||
## Widget
|
||||
|
||||
The extension renders a persistent widget above the editor:
|
||||
|
||||
```
|
||||
● 4 tasks (1 done, 1 in progress, 2 open)
|
||||
✔ Design the flux capacitor
|
||||
✳ Acquiring plutonium… (2m 49s · ↑ 4.1k ↓ 1.2k)
|
||||
◻ Install flux capacitor in DeLorean › blocked by #1
|
||||
◻ Test time travel at 88 mph › blocked by #2, #3
|
||||
```
|
||||
|
||||
| Icon | Meaning |
|
||||
|------|---------|
|
||||
| `✔` | Completed (strikethrough + dim) |
|
||||
| `◼` | In-progress (not actively executing) |
|
||||
| `◻` | Pending |
|
||||
| `✳`/`✽` | Animated star spinner — actively executing task (shows `activeForm` text, elapsed time, token counts) |
|
||||
|
||||
## Tools
|
||||
|
||||
### `TaskCreate`
|
||||
|
||||
Create a structured task. Used proactively for complex multi-step work.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `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") |
|
||||
| `metadata` | object | no | Arbitrary key-value pairs |
|
||||
|
||||
```
|
||||
→ Task #1 created successfully: Fix authentication bug
|
||||
```
|
||||
|
||||
### `TaskList`
|
||||
|
||||
List all tasks with status, owner, and blocked-by info.
|
||||
|
||||
```
|
||||
#1 [pending] Fix authentication bug
|
||||
#2 [in_progress] Write unit tests (agent-1)
|
||||
#3 [pending] Update docs [blocked by #1, #2]
|
||||
```
|
||||
|
||||
Sort order: pending first, then in-progress, then completed (each group by ID).
|
||||
|
||||
### `TaskGet`
|
||||
|
||||
Get full details for a specific task.
|
||||
|
||||
```
|
||||
Task #2: Write unit tests
|
||||
Status: in_progress
|
||||
Owner: agent-1
|
||||
Description: Add tests for the auth module
|
||||
Blocked by: #1
|
||||
Blocks: #3
|
||||
```
|
||||
|
||||
Shows owner (if set) and ALL dependency edges (including completed blockers) — raw data.
|
||||
|
||||
### `TaskUpdate`
|
||||
|
||||
Update task fields, status, metadata, and dependencies.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `taskId` | string | Task ID (required) |
|
||||
| `status` | `pending` / `in_progress` / `completed` / `deleted` | New status |
|
||||
| `subject` | string | New title |
|
||||
| `description` | string | New description |
|
||||
| `activeForm` | string | Spinner text |
|
||||
| `owner` | string | Agent name |
|
||||
| `metadata` | object | Shallow merge (null values delete keys) |
|
||||
| `addBlocks` | string[] | Task IDs this task blocks |
|
||||
| `addBlockedBy` | string[] | Task IDs that block this task |
|
||||
|
||||
```
|
||||
→ Updated task #1 status
|
||||
→ Updated task #2 owner, status
|
||||
→ Updated task #3 blocks
|
||||
→ Updated task #3 blocks (warning: cycle: #3 and #1 block each other)
|
||||
→ Updated task #1 deleted
|
||||
```
|
||||
|
||||
Setting `status: "deleted"` permanently removes the task.
|
||||
|
||||
Dependencies are bidirectional: `addBlocks: ["3"]` on task 1 also adds `blockedBy: ["1"]` to task 3.
|
||||
|
||||
### `TaskOutput`
|
||||
|
||||
Retrieve output from a background task process.
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `task_id` | string | — | Task ID (required) |
|
||||
| `block` | boolean | `true` | Wait for completion |
|
||||
| `timeout` | number | `30000` | Max wait time in ms (max 600000) |
|
||||
|
||||
### `TaskStop`
|
||||
|
||||
Stop a running background task process. Sends SIGTERM, waits 5 seconds, then SIGKILL.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `task_id` | string | Task ID to stop |
|
||||
|
||||
## Task Lifecycle
|
||||
|
||||
```
|
||||
pending → in_progress → completed
|
||||
→ deleted (permanently removed)
|
||||
```
|
||||
|
||||
Tasks are created as `pending`. Mark `in_progress` before starting work, `completed` when done. `deleted` removes entirely — IDs never reset.
|
||||
|
||||
## Dependency Management
|
||||
|
||||
- **Bidirectional edges:** `addBlocks`/`addBlockedBy` maintain both sides automatically
|
||||
- **Dependency warnings:** cycles, self-dependencies, and references to non-existent tasks are stored but produce warnings in the tool response
|
||||
- **Display-time filtering:** `TaskList` only shows non-completed blockers in `[blocked by ...]`
|
||||
- **Raw data preserved:** `TaskGet` shows ALL edges, including completed blockers
|
||||
- **Cleanup on deletion:** removing a task cleans up all edges pointing to it
|
||||
|
||||
## Shared Task Lists
|
||||
|
||||
Set `PI_TASK_LIST_ID` to enable file-backed storage for agent team coordination:
|
||||
|
||||
```bash
|
||||
PI_TASK_LIST_ID=my-project pi
|
||||
```
|
||||
|
||||
Tasks persist at `~/.pi/tasks/my-project.json`. Multiple sessions sharing the same ID read/write the same list with file locking (`.lock` files with stale-lock detection).
|
||||
|
||||
Without the env var, tasks are session-scoped (in-memory only).
|
||||
|
||||
## `/tasks` Command
|
||||
|
||||
Interactive menu:
|
||||
|
||||
```
|
||||
Tasks
|
||||
├─ View all tasks (4)
|
||||
├─ Create task
|
||||
└─ Clear completed (1)
|
||||
```
|
||||
|
||||
- **View all tasks** — select a task to see details and take actions (start, complete, delete)
|
||||
- **Create task** — input prompts for subject and description
|
||||
- **Clear completed** — remove all completed tasks
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
src/
|
||||
├── index.ts # Extension entry: 6 tools + /tasks command + widget
|
||||
├── types.ts # Task, TaskStatus, BackgroundProcess types
|
||||
├── task-store.ts # File-backed store with CRUD, dependencies, locking
|
||||
├── process-tracker.ts # Background process output buffering and stop
|
||||
└── ui/
|
||||
└── task-widget.ts # Persistent widget with status icons and spinner
|
||||
```
|
||||
|
||||
## Future Work
|
||||
|
||||
- **Background Bash auto-task creation** — Claude Code auto-creates tasks when `Bash` runs with `run_in_background: true`. Pi's bash tool currently lacks a `run_in_background` parameter (only `command` + `timeout`), so there's nothing to hook into. Once pi adds background execution support to its bash tool, we can use the `tool_call` event to detect it and auto-create tasks via `TaskStore`/`ProcessTracker`.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run typecheck # TypeScript validation
|
||||
npm test # Run unit tests (27 tests)
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT — [tintinweb](https://github.com/tintinweb)
|
||||
Generated
+5343
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "@tintinweb/pi-tasks",
|
||||
"version": "0.1.0",
|
||||
"description": "A pi extension that brings Claude Code-style task tracking and coordination to pi.",
|
||||
"author": "tintinweb",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/tintinweb/pi-tasks.git"
|
||||
},
|
||||
"homepage": "https://github.com/tintinweb/pi-tasks#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/tintinweb/pi-tasks/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"pi-package",
|
||||
"pi",
|
||||
"pi-extension",
|
||||
"task",
|
||||
"tasks",
|
||||
"todo",
|
||||
"coordination"
|
||||
],
|
||||
"dependencies": {
|
||||
"@mariozechner/pi-coding-agent": "^0.57.1",
|
||||
"@mariozechner/pi-tui": "^0.57.1",
|
||||
"@sinclair/typebox": "latest"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"pi": {
|
||||
"extensions": [
|
||||
"./src/index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
+628
@@ -0,0 +1,628 @@
|
||||
/**
|
||||
* pi-chonky-tasks — A pi extension providing Claude Code-style task tracking and coordination.
|
||||
*
|
||||
* Tools:
|
||||
* TaskCreate — Create a structured task
|
||||
* TaskList — List all tasks with status
|
||||
* TaskGet — Get full task details
|
||||
* TaskUpdate — Update task fields, status, dependencies
|
||||
* TaskOutput — Get output from a background task process
|
||||
* TaskStop — Stop a running background task process
|
||||
*
|
||||
* Commands:
|
||||
* /tasks — Interactive task management menu
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { TaskStore } from "./task-store.js";
|
||||
import { ProcessTracker } from "./process-tracker.js";
|
||||
import { TaskWidget, type UICtx } from "./ui/task-widget.js";
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
function textResult(msg: string) {
|
||||
return { content: [{ type: "text" as const, text: msg }], details: undefined as any };
|
||||
}
|
||||
|
||||
/** Task tool names — used to detect task tool usage for reminder suppression. */
|
||||
const TASK_TOOL_NAMES = new Set(["TaskCreate", "TaskList", "TaskGet", "TaskUpdate", "TaskOutput", "TaskStop"]);
|
||||
|
||||
/** How many turns without task tool usage before injecting a reminder. */
|
||||
const REMINDER_INTERVAL = 4;
|
||||
|
||||
const SYSTEM_REMINDER = `<system-reminder>
|
||||
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
|
||||
</system-reminder>`;
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
// Initialize store: use PI_TASK_LIST_ID for shared/file-backed mode
|
||||
const listId = process.env.PI_TASK_LIST_ID;
|
||||
const store = new TaskStore(listId);
|
||||
const tracker = new ProcessTracker();
|
||||
const widget = new TaskWidget(store);
|
||||
|
||||
// ── Turn tracking for system-reminder injection ──
|
||||
let currentTurn = 0;
|
||||
let lastTaskToolUseTurn = 0;
|
||||
let reminderInjectedThisCycle = false;
|
||||
|
||||
pi.on("turn_start", async () => {
|
||||
currentTurn++;
|
||||
});
|
||||
|
||||
// ── Token usage tracking ──
|
||||
// Feed per-turn token counts from assistant messages into the widget.
|
||||
pi.on("turn_end", async (event) => {
|
||||
const msg = event.message as any;
|
||||
if (msg?.role === "assistant" && msg.usage) {
|
||||
widget.addTokenUsage(msg.usage.input ?? 0, msg.usage.output ?? 0);
|
||||
}
|
||||
});
|
||||
|
||||
// ── System-reminder injection via tool_result event ──
|
||||
// Appends a <system-reminder> nudge to non-task tool results when tasks exist
|
||||
// but task tools haven't been used recently (mimics Claude Code's behavior).
|
||||
pi.on("tool_result", async (event) => {
|
||||
// Task tool usage resets the reminder timer
|
||||
if (TASK_TOOL_NAMES.has(event.toolName)) {
|
||||
lastTaskToolUseTurn = currentTurn;
|
||||
reminderInjectedThisCycle = false;
|
||||
return {};
|
||||
}
|
||||
|
||||
// Cheap checks first — avoid store.list() disk I/O when possible
|
||||
if (currentTurn - lastTaskToolUseTurn < REMINDER_INTERVAL) return {};
|
||||
if (reminderInjectedThisCycle) return {};
|
||||
|
||||
const tasks = store.list();
|
||||
if (tasks.length === 0) return {};
|
||||
|
||||
// Append system-reminder to tool result content.
|
||||
// Reset the baseline so the next reminder fires REMINDER_INTERVAL turns later.
|
||||
reminderInjectedThisCycle = true;
|
||||
lastTaskToolUseTurn = currentTurn;
|
||||
return {
|
||||
content: [...event.content, { type: "text" as const, text: SYSTEM_REMINDER }],
|
||||
};
|
||||
});
|
||||
|
||||
// ── Task state in system prompt ──
|
||||
// Appends current task state to the system prompt on every agent loop.
|
||||
// Ensures the LLM always has task awareness — especially important after
|
||||
// context compaction, when prior task tool results may have been dropped.
|
||||
pi.on("before_agent_start", async (event) => {
|
||||
const tasks = store.list();
|
||||
if (tasks.length === 0) return {};
|
||||
|
||||
const taskSummary = tasks.map(t => {
|
||||
let line = `#${t.id} [${t.status}] ${t.subject}`;
|
||||
if (t.owner) line += ` (${t.owner})`;
|
||||
if (t.blockedBy.length > 0) {
|
||||
const openBlockers = t.blockedBy.filter(bid => {
|
||||
const blocker = store.get(bid);
|
||||
return blocker && blocker.status !== "completed";
|
||||
});
|
||||
if (openBlockers.length > 0) {
|
||||
line += ` [blocked by ${openBlockers.map(id => "#" + id).join(", ")}]`;
|
||||
}
|
||||
}
|
||||
return line;
|
||||
}).join("\n");
|
||||
|
||||
return {
|
||||
systemPrompt: event.systemPrompt + `\n\n<task-state>\nCurrent tasks:\n${taskSummary}\n</task-state>`,
|
||||
};
|
||||
});
|
||||
|
||||
// Grab UI context from first tool execution
|
||||
pi.on("tool_execution_start", async (_event, ctx) => {
|
||||
widget.setUICtx(ctx.ui as UICtx);
|
||||
widget.update();
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// Tool 1: TaskCreate
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
pi.registerTool({
|
||||
name: "TaskCreate",
|
||||
label: "TaskCreate",
|
||||
description: `Use this tool to create a structured task list for your current coding session. This helps you track progress, organize complex tasks, and demonstrate thoroughness to the user.
|
||||
It also helps the user understand the progress of the task and overall progress of their requests.
|
||||
|
||||
## When to Use This Tool
|
||||
|
||||
Use this tool proactively in these scenarios:
|
||||
|
||||
- Complex multi-step tasks - When a task requires 3 or more distinct steps or actions
|
||||
- Non-trivial and complex tasks - Tasks that require careful planning or multiple operations
|
||||
- Plan mode - When using plan mode, create a task list to track the work
|
||||
- User explicitly requests todo list - When the user directly asks you to use the todo list
|
||||
- User provides multiple tasks - When users provide a list of things to be done (numbered or comma-separated)
|
||||
- After receiving new instructions - Immediately capture user requirements as tasks
|
||||
- When you start working on a task - Mark it as in_progress BEFORE beginning work
|
||||
- After completing a task - Mark it as completed and add any new follow-up tasks discovered during implementation
|
||||
|
||||
## When NOT to Use This Tool
|
||||
|
||||
Skip using this tool when:
|
||||
- There is only a single, straightforward task
|
||||
- The task is trivial and tracking it provides no organizational benefit
|
||||
- The task can be completed in less than 3 trivial steps
|
||||
- The task is purely conversational or informational
|
||||
|
||||
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")
|
||||
- **description**: Detailed description of what needs to be done, including context and acceptance criteria
|
||||
- **activeForm** (optional): Present continuous form shown in the spinner when the task is in_progress (e.g., "Fixing authentication bug"). If omitted, the spinner shows the subject instead.
|
||||
|
||||
All tasks are created with status \`pending\`.
|
||||
|
||||
## Tips
|
||||
|
||||
- Create tasks with clear, specific subjects that describe the outcome
|
||||
- Include enough detail in the description for another agent to understand and complete the task
|
||||
- After creating tasks, use TaskUpdate to set up dependencies (blocks/blockedBy) if needed
|
||||
- Check TaskList first to avoid creating duplicate tasks`,
|
||||
promptGuidelines: [
|
||||
"When working on 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.",
|
||||
],
|
||||
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" }),
|
||||
activeForm: Type.Optional(Type.String({ description: "Present continuous form shown in spinner when in_progress (e.g., 'Running tests')" })),
|
||||
metadata: Type.Optional(Type.Record(Type.String(), Type.Any(), { description: "Arbitrary metadata to attach to the task" })),
|
||||
}),
|
||||
|
||||
execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
||||
const task = store.create(params.subject, params.description, params.activeForm, params.metadata);
|
||||
widget.update();
|
||||
return Promise.resolve(textResult(`Task #${task.id} created successfully: ${task.subject}`));
|
||||
},
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// Tool 2: TaskList
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
pi.registerTool({
|
||||
name: "TaskList",
|
||||
label: "TaskList",
|
||||
description: `Use this tool to list all tasks in the task list.
|
||||
|
||||
## When to Use This Tool
|
||||
|
||||
- To see what tasks are available to work on (status: 'pending', no owner, not blocked)
|
||||
- To check overall progress on the project
|
||||
- To find tasks that are blocked and need dependencies resolved
|
||||
- After completing a task, to check for newly unblocked work or claim the next available task
|
||||
- **Prefer working on tasks in ID order** (lowest ID first) when multiple tasks are available, as earlier tasks often set up context for later ones
|
||||
|
||||
## Output
|
||||
|
||||
Returns a summary of each task:
|
||||
- **id**: Task identifier (use with TaskGet, TaskUpdate)
|
||||
- **subject**: Brief description of the task
|
||||
- **status**: 'pending', 'in_progress', or 'completed'
|
||||
- **owner**: Agent ID if assigned, empty if available
|
||||
- **blockedBy**: List of open task IDs that must be resolved first (tasks with blockedBy cannot be claimed until dependencies resolve)
|
||||
|
||||
Use TaskGet with a specific task ID to view full details including description and comments.`,
|
||||
parameters: Type.Object({}),
|
||||
|
||||
execute(_toolCallId, _params, _signal, _onUpdate, _ctx) {
|
||||
const tasks = store.list();
|
||||
if (tasks.length === 0) return Promise.resolve(textResult("No tasks found"));
|
||||
|
||||
// Sort: pending first (by ID), then in_progress (by ID), then completed (by ID)
|
||||
const statusOrder: Record<string, number> = { pending: 0, in_progress: 1, completed: 2 };
|
||||
const sorted = [...tasks].sort((a, b) => {
|
||||
const so = (statusOrder[a.status] ?? 0) - (statusOrder[b.status] ?? 0);
|
||||
if (so !== 0) return so;
|
||||
return Number(a.id) - Number(b.id);
|
||||
});
|
||||
|
||||
const lines = sorted.map(task => {
|
||||
let line = `#${task.id} [${task.status}] ${task.subject}`;
|
||||
|
||||
if (task.owner) {
|
||||
line += ` (${task.owner})`;
|
||||
}
|
||||
|
||||
// Only show non-completed blockers
|
||||
if (task.blockedBy.length > 0) {
|
||||
const openBlockers = task.blockedBy.filter(bid => {
|
||||
const blocker = store.get(bid);
|
||||
return blocker && blocker.status !== "completed";
|
||||
});
|
||||
if (openBlockers.length > 0) {
|
||||
line += ` [blocked by ${openBlockers.map(id => "#" + id).join(", ")}]`;
|
||||
}
|
||||
}
|
||||
|
||||
return line;
|
||||
});
|
||||
|
||||
return Promise.resolve(textResult(lines.join("\n")));
|
||||
},
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// Tool 3: TaskGet
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
pi.registerTool({
|
||||
name: "TaskGet",
|
||||
label: "TaskGet",
|
||||
description: `Use this tool to retrieve a task by its ID from the task list.
|
||||
|
||||
## When to Use This Tool
|
||||
|
||||
- When you need the full description and context before starting work on a task
|
||||
- To understand task dependencies (what it blocks, what blocks it)
|
||||
- After being assigned a task, to get complete requirements
|
||||
|
||||
## Output
|
||||
|
||||
Returns full task details:
|
||||
- **subject**: Task title
|
||||
- **description**: Detailed requirements and context
|
||||
- **status**: 'pending', 'in_progress', or 'completed'
|
||||
- **blocks**: Tasks waiting on this one to complete
|
||||
- **blockedBy**: Tasks that must complete before this one can start
|
||||
|
||||
## Tips
|
||||
|
||||
- After fetching a task, verify its blockedBy list is empty before beginning work.
|
||||
- Use TaskList to see all tasks in summary form.`,
|
||||
parameters: Type.Object({
|
||||
taskId: Type.String({ description: "The ID of the task to retrieve" }),
|
||||
}),
|
||||
|
||||
execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
||||
const task = store.get(params.taskId);
|
||||
if (!task) return Promise.resolve(textResult(`Task not found`));
|
||||
|
||||
// Unescape literal \n sequences the LLM may have double-escaped in JSON
|
||||
const desc = task.description.replace(/\\n/g, "\n");
|
||||
|
||||
const lines: string[] = [
|
||||
`Task #${task.id}: ${task.subject}`,
|
||||
`Status: ${task.status}`,
|
||||
];
|
||||
if (task.owner) {
|
||||
lines.push(`Owner: ${task.owner}`);
|
||||
}
|
||||
lines.push(`Description: ${desc}`);
|
||||
|
||||
if (task.blockedBy.length > 0) {
|
||||
lines.push(`Blocked by: ${task.blockedBy.map(id => "#" + id).join(", ")}`);
|
||||
}
|
||||
if (task.blocks.length > 0) {
|
||||
lines.push(`Blocks: ${task.blocks.map(id => "#" + id).join(", ")}`);
|
||||
}
|
||||
|
||||
return Promise.resolve(textResult(lines.join("\n")));
|
||||
},
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// Tool 4: TaskUpdate
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
pi.registerTool({
|
||||
name: "TaskUpdate",
|
||||
label: "TaskUpdate",
|
||||
description: `Use this tool to update a task in the task list.
|
||||
|
||||
## When to Use This Tool
|
||||
|
||||
**Mark tasks as resolved:**
|
||||
- When you have completed the work described in a task
|
||||
- When a task is no longer needed or has been superseded
|
||||
- IMPORTANT: Always mark your assigned tasks as resolved when you finish them
|
||||
- After resolving, call TaskList to find your next task
|
||||
|
||||
- ONLY mark a task as completed when you have FULLY accomplished it
|
||||
- If you encounter errors, blockers, or cannot finish, keep the task as in_progress
|
||||
- When blocked, create a new task describing what needs to be resolved
|
||||
- Never mark a task as completed if:
|
||||
- Tests are failing
|
||||
- Implementation is partial
|
||||
- You encountered unresolved errors
|
||||
- You couldn't find necessary files or dependencies
|
||||
|
||||
**Delete tasks:**
|
||||
- When a task is no longer relevant or was created in error
|
||||
- Setting status to \`deleted\` permanently removes the task
|
||||
|
||||
**Update task details:**
|
||||
- When requirements change or become clearer
|
||||
- When establishing dependencies between tasks
|
||||
|
||||
## Fields You Can Update
|
||||
|
||||
- **status**: The task status (see Status Workflow below)
|
||||
- **subject**: Change the task title (imperative form, e.g., "Run tests")
|
||||
- **description**: Change the task description
|
||||
- **activeForm**: Present continuous form shown in spinner when in_progress (e.g., "Running tests")
|
||||
- **owner**: Change the task owner (agent name)
|
||||
- **metadata**: Merge metadata keys into the task (set a key to null to delete it)
|
||||
- **addBlocks**: Mark tasks that cannot start until this one completes
|
||||
- **addBlockedBy**: Mark tasks that must complete before this one can start
|
||||
|
||||
## Status Workflow
|
||||
|
||||
Status progresses: \`pending\` → \`in_progress\` → \`completed\`
|
||||
|
||||
Use \`deleted\` to permanently remove a task.
|
||||
|
||||
## Staleness
|
||||
|
||||
Make sure to read a task's latest state using \`TaskGet\` before updating it.
|
||||
|
||||
## Examples
|
||||
|
||||
Mark task as in progress when starting work:
|
||||
\`\`\`json
|
||||
{"taskId": "1", "status": "in_progress"}
|
||||
\`\`\`
|
||||
|
||||
Mark task as completed after finishing work:
|
||||
\`\`\`json
|
||||
{"taskId": "1", "status": "completed"}
|
||||
\`\`\`
|
||||
|
||||
Delete a task:
|
||||
\`\`\`json
|
||||
{"taskId": "1", "status": "deleted"}
|
||||
\`\`\`
|
||||
|
||||
Claim a task by setting owner:
|
||||
\`\`\`json
|
||||
{"taskId": "1", "owner": "my-name"}
|
||||
\`\`\`
|
||||
|
||||
Set up task dependencies:
|
||||
\`\`\`json
|
||||
{"taskId": "2", "addBlockedBy": ["1"]}
|
||||
\`\`\``,
|
||||
parameters: Type.Object({
|
||||
taskId: Type.String({ description: "The ID of the task to update" }),
|
||||
status: Type.Optional(Type.Unsafe<"pending" | "in_progress" | "completed" | "deleted">({
|
||||
anyOf: [
|
||||
{ type: "string", enum: ["pending", "in_progress", "completed"] },
|
||||
{ type: "string", const: "deleted" },
|
||||
],
|
||||
description: "New status for the task",
|
||||
})),
|
||||
subject: Type.Optional(Type.String({ description: "New subject for the task" })),
|
||||
description: Type.Optional(Type.String({ description: "New description for the task" })),
|
||||
activeForm: Type.Optional(Type.String({ description: "Present continuous form shown in spinner when in_progress" })),
|
||||
owner: Type.Optional(Type.String({ description: "New owner for the task" })),
|
||||
metadata: Type.Optional(Type.Record(Type.String(), Type.Any(), { description: "Metadata keys to merge into the task. Set a key to null to delete it." })),
|
||||
addBlocks: Type.Optional(Type.Array(Type.String(), { description: "Task IDs that this task blocks" })),
|
||||
addBlockedBy: Type.Optional(Type.Array(Type.String(), { description: "Task IDs that block this task" })),
|
||||
}),
|
||||
|
||||
execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
||||
const { taskId, ...fields } = params;
|
||||
const { task, changedFields, warnings } = store.update(taskId, fields);
|
||||
|
||||
if (changedFields.length === 0 && !task) {
|
||||
return Promise.resolve(textResult(`Task #${taskId} not found`));
|
||||
}
|
||||
|
||||
// Update widget active task tracking
|
||||
if (fields.status === "in_progress") {
|
||||
widget.setActiveTask(taskId);
|
||||
} else if (fields.status === "completed" || fields.status === "deleted") {
|
||||
widget.setActiveTask(taskId, false);
|
||||
}
|
||||
|
||||
widget.update();
|
||||
let msg = `Updated task #${taskId} ${changedFields.join(", ")}`;
|
||||
if (warnings.length > 0) {
|
||||
msg += ` (warning: ${warnings.join("; ")})`;
|
||||
}
|
||||
return Promise.resolve(textResult(msg));
|
||||
},
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// Tool 5: TaskOutput
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
pi.registerTool({
|
||||
name: "TaskOutput",
|
||||
label: "TaskOutput",
|
||||
description: `- Retrieves output from a running or completed task (background shell, agent, or remote session)
|
||||
- Takes a task_id parameter identifying the task
|
||||
- Returns the task output along with status information
|
||||
- Use block=true (default) to wait for task completion
|
||||
- Use block=false for non-blocking check of current status
|
||||
- Task IDs can be found using the /tasks command
|
||||
- Works with all task types: background shells, async agents, and remote sessions`,
|
||||
parameters: Type.Object({
|
||||
task_id: Type.String({ description: "The task ID to get output from" }),
|
||||
block: Type.Boolean({ description: "Whether to wait for completion", default: true }),
|
||||
timeout: Type.Number({ description: "Max wait time in ms", default: 30000, minimum: 0, maximum: 600000 }),
|
||||
}),
|
||||
|
||||
async execute(_toolCallId, params, signal, _onUpdate, _ctx) {
|
||||
const { task_id, block, timeout } = params;
|
||||
|
||||
const processOutput = tracker.getOutput(task_id);
|
||||
if (!processOutput) {
|
||||
throw new Error(`No background process for task ${task_id}`);
|
||||
}
|
||||
|
||||
if (block && processOutput.status === "running") {
|
||||
const result = await tracker.waitForCompletion(task_id, timeout ?? 30000, signal ?? undefined);
|
||||
if (result) {
|
||||
return textResult(
|
||||
`Task #${task_id} (${result.status})${result.exitCode !== undefined ? ` exit code: ${result.exitCode}` : ""}\n\n${result.output}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return textResult(
|
||||
`Task #${task_id} (${processOutput.status})${processOutput.exitCode !== undefined ? ` exit code: ${processOutput.exitCode}` : ""}\n\n${processOutput.output}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// Tool 6: TaskStop
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
pi.registerTool({
|
||||
name: "TaskStop",
|
||||
label: "TaskStop",
|
||||
description: `
|
||||
- Stops a running background task by its ID
|
||||
- Takes a task_id parameter identifying the task to stop
|
||||
- Returns a success or failure status
|
||||
- Use this tool when you need to terminate a long-running task`,
|
||||
parameters: Type.Object({
|
||||
task_id: Type.Optional(Type.String({ description: "The ID of the background task to stop" })),
|
||||
shell_id: Type.Optional(Type.String({ description: "Deprecated: use task_id instead" })),
|
||||
}),
|
||||
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
||||
const taskId = params.task_id ?? params.shell_id;
|
||||
if (!taskId) throw new Error("task_id is required");
|
||||
|
||||
const stopped = await tracker.stop(taskId);
|
||||
if (!stopped) {
|
||||
throw new Error(`No running background process for task ${taskId}`);
|
||||
}
|
||||
|
||||
store.update(taskId, { status: "completed" });
|
||||
widget.setActiveTask(taskId, false);
|
||||
widget.update();
|
||||
return textResult(`Task #${taskId} stopped successfully`);
|
||||
},
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// /tasks command
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
pi.registerCommand("tasks", {
|
||||
description: "Manage tasks — view, create, clear completed",
|
||||
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
||||
const ui = ctx.ui;
|
||||
|
||||
const mainMenu = async (): Promise<void> => {
|
||||
const tasks = store.list();
|
||||
const taskCount = tasks.length;
|
||||
const completedCount = tasks.filter(t => t.status === "completed").length;
|
||||
|
||||
const choices: string[] = [
|
||||
`View all tasks (${taskCount})`,
|
||||
"Create task",
|
||||
];
|
||||
if (completedCount > 0) choices.push(`Clear completed (${completedCount})`);
|
||||
|
||||
const choice = await ui.select("Tasks", choices);
|
||||
if (!choice) return;
|
||||
|
||||
if (choice.startsWith("View")) {
|
||||
await viewTasks();
|
||||
} else if (choice === "Create task") {
|
||||
await createTask();
|
||||
} else if (choice.startsWith("Clear")) {
|
||||
store.clearCompleted();
|
||||
widget.update();
|
||||
await mainMenu();
|
||||
}
|
||||
};
|
||||
|
||||
const viewTasks = async (): Promise<void> => {
|
||||
const tasks = store.list();
|
||||
if (tasks.length === 0) {
|
||||
await ui.select("No tasks", ["← Back"]);
|
||||
return mainMenu();
|
||||
}
|
||||
|
||||
const statusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case "completed": return "✔";
|
||||
case "in_progress": return "◼";
|
||||
default: return "◻";
|
||||
}
|
||||
};
|
||||
|
||||
const choices = tasks.map(t =>
|
||||
`${statusIcon(t.status)} #${t.id} [${t.status}] ${t.subject}`
|
||||
);
|
||||
choices.push("← Back");
|
||||
|
||||
const selected = await ui.select("Tasks", choices);
|
||||
if (!selected || selected === "← Back") return mainMenu();
|
||||
|
||||
// Extract task ID from selection
|
||||
const match = selected.match(/#(\d+)/);
|
||||
if (match) await viewTaskDetail(match[1]);
|
||||
else return viewTasks();
|
||||
};
|
||||
|
||||
const viewTaskDetail = async (taskId: string): Promise<void> => {
|
||||
const task = store.get(taskId);
|
||||
if (!task) return viewTasks();
|
||||
|
||||
const actions: string[] = [];
|
||||
|
||||
if (task.status === "pending") {
|
||||
actions.push("▸ Start (in_progress)");
|
||||
}
|
||||
if (task.status === "in_progress") {
|
||||
actions.push("✓ Complete");
|
||||
}
|
||||
actions.push("✗ Delete");
|
||||
actions.push("← Back");
|
||||
|
||||
const title = `#${task.id} [${task.status}] ${task.subject}\n${task.description}`;
|
||||
const action = await ui.select(title, actions);
|
||||
|
||||
if (action === "▸ Start (in_progress)") {
|
||||
store.update(taskId, { status: "in_progress" });
|
||||
widget.setActiveTask(taskId);
|
||||
widget.update();
|
||||
return viewTasks();
|
||||
} else if (action === "✓ Complete") {
|
||||
store.update(taskId, { status: "completed" });
|
||||
widget.setActiveTask(taskId, false);
|
||||
widget.update();
|
||||
return viewTasks();
|
||||
} else if (action === "✗ Delete") {
|
||||
store.update(taskId, { status: "deleted" });
|
||||
widget.setActiveTask(taskId, false);
|
||||
widget.update();
|
||||
return viewTasks();
|
||||
}
|
||||
return viewTasks();
|
||||
};
|
||||
|
||||
const createTask = async (): Promise<void> => {
|
||||
const subject = await ui.input("Task subject");
|
||||
if (!subject) return mainMenu();
|
||||
const description = await ui.input("Task description");
|
||||
if (!description) return mainMenu();
|
||||
|
||||
store.create(subject, description);
|
||||
widget.update();
|
||||
return mainMenu();
|
||||
};
|
||||
|
||||
await mainMenu();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* process-tracker.ts — Background process management for tasks.
|
||||
*
|
||||
* Tracks spawned child processes, buffers their output, and supports
|
||||
* blocking wait and graceful stop (SIGTERM → 5s → SIGKILL).
|
||||
*/
|
||||
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import type { BackgroundProcess } from "./types.js";
|
||||
|
||||
export interface ProcessOutput {
|
||||
output: string;
|
||||
status: BackgroundProcess["status"];
|
||||
exitCode?: number;
|
||||
startedAt: number;
|
||||
completedAt?: number;
|
||||
command?: string;
|
||||
}
|
||||
|
||||
export class ProcessTracker {
|
||||
private processes = new Map<string, BackgroundProcess>();
|
||||
|
||||
/** Register a spawned process for a task. */
|
||||
track(taskId: string, proc: ChildProcess, command?: string): void {
|
||||
const bp: BackgroundProcess = {
|
||||
taskId,
|
||||
pid: proc.pid!,
|
||||
command,
|
||||
output: [],
|
||||
status: "running",
|
||||
startedAt: Date.now(),
|
||||
proc,
|
||||
abortController: new AbortController(),
|
||||
waiters: [],
|
||||
};
|
||||
|
||||
// Buffer stdout
|
||||
proc.stdout?.on("data", (data: Buffer) => {
|
||||
bp.output.push(data.toString());
|
||||
});
|
||||
|
||||
// Buffer stderr
|
||||
proc.stderr?.on("data", (data: Buffer) => {
|
||||
bp.output.push(data.toString());
|
||||
});
|
||||
|
||||
// Handle process exit
|
||||
proc.on("close", (code, signal) => {
|
||||
if (bp.status === "running") {
|
||||
bp.status = code === 0 ? "completed" : "error";
|
||||
}
|
||||
bp.exitCode = code ?? undefined;
|
||||
bp.completedAt = Date.now();
|
||||
// Notify all waiters
|
||||
for (const resolve of bp.waiters) resolve();
|
||||
bp.waiters = [];
|
||||
});
|
||||
|
||||
proc.on("error", (err) => {
|
||||
if (bp.status === "running") {
|
||||
bp.status = "error";
|
||||
bp.output.push(`Process error: ${err.message}`);
|
||||
bp.completedAt = Date.now();
|
||||
for (const resolve of bp.waiters) resolve();
|
||||
bp.waiters = [];
|
||||
}
|
||||
});
|
||||
|
||||
this.processes.set(taskId, bp);
|
||||
}
|
||||
|
||||
/** Get current output and status for a task's process. */
|
||||
getOutput(taskId: string): ProcessOutput | undefined {
|
||||
const bp = this.processes.get(taskId);
|
||||
if (!bp) return undefined;
|
||||
return {
|
||||
output: bp.output.join(""),
|
||||
status: bp.status,
|
||||
exitCode: bp.exitCode,
|
||||
startedAt: bp.startedAt,
|
||||
completedAt: bp.completedAt,
|
||||
command: bp.command,
|
||||
};
|
||||
}
|
||||
|
||||
/** Wait for a task's process to complete, with timeout. */
|
||||
waitForCompletion(taskId: string, timeout: number, signal?: AbortSignal): Promise<ProcessOutput | undefined> {
|
||||
const bp = this.processes.get(taskId);
|
||||
if (!bp) return Promise.resolve(undefined);
|
||||
if (bp.status !== "running") return Promise.resolve(this.getOutput(taskId));
|
||||
|
||||
return new Promise<ProcessOutput | undefined>((resolve) => {
|
||||
let settled = false;
|
||||
const timer = setTimeout(finish, timeout);
|
||||
|
||||
function finish() {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve(self.getOutput(taskId));
|
||||
}
|
||||
|
||||
const self = this;
|
||||
bp.waiters.push(finish);
|
||||
signal?.addEventListener("abort", finish, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
/** Stop a task's background process. SIGTERM → 5s → SIGKILL. */
|
||||
async stop(taskId: string): Promise<boolean> {
|
||||
const bp = this.processes.get(taskId);
|
||||
if (!bp || bp.status !== "running") return false;
|
||||
|
||||
bp.status = "stopped";
|
||||
bp.proc.kill("SIGTERM");
|
||||
|
||||
// Wait up to 5s for graceful exit
|
||||
await new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
try { bp.proc.kill("SIGKILL"); } catch { /* already dead */ }
|
||||
resolve();
|
||||
}, 5000);
|
||||
|
||||
bp.proc.on("close", () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
bp.completedAt = Date.now();
|
||||
for (const resolve of bp.waiters) resolve();
|
||||
bp.waiters = [];
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Get the process record for a task. */
|
||||
getProcess(taskId: string): BackgroundProcess | undefined {
|
||||
return this.processes.get(taskId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* task-store.ts — File-backed task store with CRUD, dependency management, and file locking.
|
||||
*
|
||||
* Session-scoped (default): in-memory Map — no disk I/O.
|
||||
* Shared (PI_TASK_LIST_ID set): ~/.pi/tasks/<listId>.json with file locking.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, renameSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import type { Task, TaskStatus, TaskStoreData } from "./types.js";
|
||||
|
||||
const TASKS_DIR = join(homedir(), ".pi", "tasks");
|
||||
const LOCK_RETRY_MS = 50;
|
||||
const LOCK_MAX_RETRIES = 100; // 5s max
|
||||
|
||||
/** Simple file-based locking. */
|
||||
function acquireLock(lockPath: string): void {
|
||||
for (let i = 0; i < LOCK_MAX_RETRIES; i++) {
|
||||
try {
|
||||
// O_EXCL: fail if file exists
|
||||
writeFileSync(lockPath, `${process.pid}`, { flag: "wx" });
|
||||
return;
|
||||
} catch (e: any) {
|
||||
if (e.code === "EEXIST") {
|
||||
// Check for stale lock (process no longer running)
|
||||
try {
|
||||
const pid = parseInt(readFileSync(lockPath, "utf-8"), 10);
|
||||
if (pid && !isProcessRunning(pid)) {
|
||||
unlinkSync(lockPath);
|
||||
continue;
|
||||
}
|
||||
} catch { /* ignore read errors */ }
|
||||
// Wait and retry
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < LOCK_RETRY_MS) { /* busy wait */ }
|
||||
continue;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
throw new Error(`Failed to acquire lock: ${lockPath}`);
|
||||
}
|
||||
|
||||
function releaseLock(lockPath: string): void {
|
||||
try { unlinkSync(lockPath); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function isProcessRunning(pid: number): boolean {
|
||||
try { process.kill(pid, 0); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
export class TaskStore {
|
||||
private listId: string | undefined;
|
||||
private filePath: string | undefined;
|
||||
private lockPath: string | undefined;
|
||||
|
||||
// In-memory state (always kept in sync)
|
||||
private nextId = 1;
|
||||
private tasks = new Map<string, Task>();
|
||||
|
||||
constructor(listId?: string) {
|
||||
this.listId = listId;
|
||||
if (listId) {
|
||||
mkdirSync(TASKS_DIR, { recursive: true });
|
||||
this.filePath = join(TASKS_DIR, `${listId}.json`);
|
||||
this.lockPath = this.filePath + ".lock";
|
||||
this.load();
|
||||
}
|
||||
}
|
||||
|
||||
/** Read store from disk (file-backed mode only). */
|
||||
private load(): void {
|
||||
if (!this.filePath) return;
|
||||
if (!existsSync(this.filePath)) return;
|
||||
try {
|
||||
const data: TaskStoreData = JSON.parse(readFileSync(this.filePath, "utf-8"));
|
||||
this.nextId = data.nextId;
|
||||
this.tasks.clear();
|
||||
for (const t of data.tasks) {
|
||||
this.tasks.set(t.id, t);
|
||||
}
|
||||
} catch { /* corrupt file — start fresh */ }
|
||||
}
|
||||
|
||||
/** Write store to disk atomically (file-backed mode only). */
|
||||
private save(): void {
|
||||
if (!this.filePath) return;
|
||||
const data: TaskStoreData = {
|
||||
nextId: this.nextId,
|
||||
tasks: Array.from(this.tasks.values()),
|
||||
};
|
||||
const tmpPath = this.filePath + ".tmp";
|
||||
writeFileSync(tmpPath, JSON.stringify(data, null, 2));
|
||||
renameSync(tmpPath, this.filePath);
|
||||
}
|
||||
|
||||
/** Execute a mutation with file locking (if file-backed). */
|
||||
private withLock<T>(fn: () => T): T {
|
||||
if (!this.lockPath) return fn();
|
||||
acquireLock(this.lockPath);
|
||||
try {
|
||||
this.load(); // Re-read latest state
|
||||
const result = fn();
|
||||
this.save();
|
||||
return result;
|
||||
} finally {
|
||||
releaseLock(this.lockPath);
|
||||
}
|
||||
}
|
||||
|
||||
create(subject: string, description: string, activeForm?: string, metadata?: Record<string, any>): Task {
|
||||
return this.withLock(() => {
|
||||
const now = Date.now();
|
||||
const task: Task = {
|
||||
id: String(this.nextId++),
|
||||
subject,
|
||||
description,
|
||||
status: "pending",
|
||||
activeForm,
|
||||
owner: undefined,
|
||||
metadata: metadata ?? {},
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
this.tasks.set(task.id, task);
|
||||
return task;
|
||||
});
|
||||
}
|
||||
|
||||
get(id: string): Task | undefined {
|
||||
if (this.filePath) this.load();
|
||||
return this.tasks.get(id);
|
||||
}
|
||||
|
||||
/** List all tasks sorted by ID ascending. */
|
||||
list(): Task[] {
|
||||
if (this.filePath) this.load();
|
||||
return Array.from(this.tasks.values()).sort((a, b) => Number(a.id) - Number(b.id));
|
||||
}
|
||||
|
||||
update(id: string, fields: {
|
||||
status?: TaskStatus | "deleted";
|
||||
subject?: string;
|
||||
description?: string;
|
||||
activeForm?: string;
|
||||
owner?: string;
|
||||
metadata?: Record<string, any>;
|
||||
addBlocks?: string[];
|
||||
addBlockedBy?: string[];
|
||||
}): { task: Task | undefined; changedFields: string[]; warnings: string[] } {
|
||||
return this.withLock(() => {
|
||||
const task = this.tasks.get(id);
|
||||
if (!task) return { task: undefined, changedFields: [], warnings: [] };
|
||||
|
||||
const changedFields: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Handle deletion
|
||||
if (fields.status === "deleted") {
|
||||
this.tasks.delete(id);
|
||||
// Clean up dependency edges pointing to this task
|
||||
for (const t of this.tasks.values()) {
|
||||
t.blocks = t.blocks.filter(bid => bid !== id);
|
||||
t.blockedBy = t.blockedBy.filter(bid => bid !== id);
|
||||
}
|
||||
return { task: undefined, changedFields: ["deleted"], warnings: [] };
|
||||
}
|
||||
|
||||
if (fields.status !== undefined) {
|
||||
task.status = fields.status;
|
||||
changedFields.push("status");
|
||||
}
|
||||
if (fields.subject !== undefined) {
|
||||
task.subject = fields.subject;
|
||||
changedFields.push("subject");
|
||||
}
|
||||
if (fields.description !== undefined) {
|
||||
task.description = fields.description;
|
||||
changedFields.push("description");
|
||||
}
|
||||
if (fields.activeForm !== undefined) {
|
||||
task.activeForm = fields.activeForm;
|
||||
changedFields.push("activeForm");
|
||||
}
|
||||
if (fields.owner !== undefined) {
|
||||
task.owner = fields.owner;
|
||||
changedFields.push("owner");
|
||||
}
|
||||
|
||||
// Metadata: shallow merge, null deletes keys
|
||||
if (fields.metadata !== undefined) {
|
||||
for (const [key, value] of Object.entries(fields.metadata)) {
|
||||
if (value === null) {
|
||||
delete task.metadata[key];
|
||||
} else {
|
||||
task.metadata[key] = value;
|
||||
}
|
||||
}
|
||||
changedFields.push("metadata");
|
||||
}
|
||||
|
||||
// Bidirectional dependency edges
|
||||
if (fields.addBlocks && fields.addBlocks.length > 0) {
|
||||
for (const targetId of fields.addBlocks) {
|
||||
if (!task.blocks.includes(targetId)) {
|
||||
task.blocks.push(targetId);
|
||||
}
|
||||
const target = this.tasks.get(targetId);
|
||||
if (target && !target.blockedBy.includes(id)) {
|
||||
target.blockedBy.push(id);
|
||||
target.updatedAt = Date.now();
|
||||
}
|
||||
// Warnings for problematic edges
|
||||
if (targetId === id) {
|
||||
warnings.push(`#${id} blocks itself`);
|
||||
} else if (!target) {
|
||||
warnings.push(`#${targetId} does not exist`);
|
||||
} else if (target.blocks.includes(id)) {
|
||||
warnings.push(`cycle: #${id} and #${targetId} block each other`);
|
||||
}
|
||||
}
|
||||
changedFields.push("blocks");
|
||||
}
|
||||
|
||||
if (fields.addBlockedBy && fields.addBlockedBy.length > 0) {
|
||||
for (const targetId of fields.addBlockedBy) {
|
||||
if (!task.blockedBy.includes(targetId)) {
|
||||
task.blockedBy.push(targetId);
|
||||
}
|
||||
const target = this.tasks.get(targetId);
|
||||
if (target && !target.blocks.includes(id)) {
|
||||
target.blocks.push(id);
|
||||
target.updatedAt = Date.now();
|
||||
}
|
||||
// Warnings for problematic edges
|
||||
if (targetId === id) {
|
||||
warnings.push(`#${id} blocks itself`);
|
||||
} else if (!target) {
|
||||
warnings.push(`#${targetId} does not exist`);
|
||||
} else if (task.blocks.includes(targetId)) {
|
||||
warnings.push(`cycle: #${id} and #${targetId} block each other`);
|
||||
}
|
||||
}
|
||||
changedFields.push("blockedBy");
|
||||
}
|
||||
|
||||
task.updatedAt = Date.now();
|
||||
return { task, changedFields, warnings };
|
||||
});
|
||||
}
|
||||
|
||||
/** Delete a task by ID. Returns true if deleted. */
|
||||
delete(id: string): boolean {
|
||||
return this.withLock(() => {
|
||||
if (!this.tasks.has(id)) return false;
|
||||
this.tasks.delete(id);
|
||||
// Clean up dependency edges
|
||||
for (const t of this.tasks.values()) {
|
||||
t.blocks = t.blocks.filter(bid => bid !== id);
|
||||
t.blockedBy = t.blockedBy.filter(bid => bid !== id);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/** Remove all completed tasks. */
|
||||
clearCompleted(): number {
|
||||
return this.withLock(() => {
|
||||
let count = 0;
|
||||
for (const [id, task] of this.tasks) {
|
||||
if (task.status === "completed") {
|
||||
this.tasks.delete(id);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
// Clean up dependency edges for deleted tasks
|
||||
if (count > 0) {
|
||||
const validIds = new Set(this.tasks.keys());
|
||||
for (const t of this.tasks.values()) {
|
||||
t.blocks = t.blocks.filter(bid => validIds.has(bid));
|
||||
t.blockedBy = t.blockedBy.filter(bid => validIds.has(bid));
|
||||
}
|
||||
}
|
||||
return count;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* types.ts — Type definitions for the task management system.
|
||||
*/
|
||||
|
||||
export type TaskStatus = "pending" | "in_progress" | "completed";
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
subject: string;
|
||||
description: string;
|
||||
status: TaskStatus;
|
||||
activeForm?: string;
|
||||
owner?: string;
|
||||
metadata: Record<string, any>;
|
||||
blocks: string[];
|
||||
blockedBy: string[];
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/** Serialized store format on disk. */
|
||||
export interface TaskStoreData {
|
||||
nextId: number;
|
||||
tasks: Task[];
|
||||
}
|
||||
|
||||
/** Background process associated with a task. */
|
||||
export interface BackgroundProcess {
|
||||
taskId: string;
|
||||
pid: number;
|
||||
command?: string;
|
||||
output: string[];
|
||||
status: "running" | "completed" | "error" | "stopped";
|
||||
exitCode?: number;
|
||||
startedAt: number;
|
||||
completedAt?: number;
|
||||
proc: import("node:child_process").ChildProcess;
|
||||
abortController: AbortController;
|
||||
waiters: Array<() => void>;
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* task-widget.ts — Persistent widget showing task list with status icons and progress.
|
||||
*
|
||||
* Display style matches Claude Code's task list:
|
||||
* ✔ completed tasks (strikethrough + dim)
|
||||
* ◼ in_progress tasks
|
||||
* ◻ pending tasks
|
||||
* ✳/✽ actively executing task (star spinner with activeForm text)
|
||||
*/
|
||||
|
||||
import { truncateToWidth } from "@mariozechner/pi-tui";
|
||||
import type { TaskStore } from "../task-store.js";
|
||||
|
||||
// ---- Types ----
|
||||
|
||||
export type Theme = {
|
||||
fg(color: string, text: string): string;
|
||||
bold(text: string): string;
|
||||
strikethrough(text: string): string;
|
||||
};
|
||||
|
||||
export type UICtx = {
|
||||
setStatus(key: string, text: string | undefined): void;
|
||||
setWidget(
|
||||
key: string,
|
||||
content: undefined | ((tui: any, theme: Theme) => { render(): string[]; invalidate(): void }),
|
||||
options?: { placement?: "aboveEditor" | "belowEditor" },
|
||||
): void;
|
||||
};
|
||||
|
||||
/** Star spinner frames for animated active task indicator (matches Claude Code). */
|
||||
const SPINNER = ["✳", "✴", "✵", "✶", "✷", "✸", "✹", "✺", "✻", "✼", "✽"];
|
||||
|
||||
const MAX_VISIBLE_TASKS = 10;
|
||||
|
||||
/** Per-task runtime metrics (elapsed time, token usage). */
|
||||
export interface TaskMetrics {
|
||||
startedAt: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
}
|
||||
|
||||
/** Format milliseconds as a human-readable duration (e.g., "2m 49s", "1h 3m"). */
|
||||
function formatDuration(ms: number): string {
|
||||
const totalSec = Math.floor(ms / 1000);
|
||||
if (totalSec < 60) return `${totalSec}s`;
|
||||
const min = Math.floor(totalSec / 60);
|
||||
const sec = totalSec % 60;
|
||||
if (min < 60) return sec > 0 ? `${min}m ${sec}s` : `${min}m`;
|
||||
const hr = Math.floor(min / 60);
|
||||
const remMin = min % 60;
|
||||
return remMin > 0 ? `${hr}h ${remMin}m` : `${hr}h`;
|
||||
}
|
||||
|
||||
/** Format token count with k suffix (e.g., "4.1k", "850"). */
|
||||
function formatTokens(n: number): string {
|
||||
if (n < 1000) return String(n);
|
||||
return (n / 1000).toFixed(1).replace(/\.0$/, "") + "k";
|
||||
}
|
||||
|
||||
// ---- Widget ----
|
||||
|
||||
export class TaskWidget {
|
||||
private uiCtx: UICtx | undefined;
|
||||
private widgetFrame = 0;
|
||||
private widgetInterval: ReturnType<typeof setInterval> | undefined;
|
||||
/** IDs of tasks currently being actively executed (show spinner). */
|
||||
private activeTaskIds = new Set<string>();
|
||||
/** Per-task runtime metrics keyed by task ID. */
|
||||
private metrics = new Map<string, TaskMetrics>();
|
||||
|
||||
constructor(private store: TaskStore) {}
|
||||
|
||||
setUICtx(ctx: UICtx) {
|
||||
this.uiCtx = ctx;
|
||||
}
|
||||
|
||||
/** Add or remove a task from the active spinner set. */
|
||||
setActiveTask(taskId: string | undefined, active = true) {
|
||||
if (taskId && active) {
|
||||
this.activeTaskIds.add(taskId);
|
||||
if (!this.metrics.has(taskId)) {
|
||||
this.metrics.set(taskId, { startedAt: Date.now(), inputTokens: 0, outputTokens: 0 });
|
||||
}
|
||||
this.ensureTimer();
|
||||
} else if (taskId) {
|
||||
this.activeTaskIds.delete(taskId);
|
||||
}
|
||||
this.update();
|
||||
}
|
||||
|
||||
/** Record token usage for the currently active task(s). */
|
||||
addTokenUsage(inputTokens: number, outputTokens: number) {
|
||||
// Distribute to all currently active tasks
|
||||
for (const id of this.activeTaskIds) {
|
||||
const m = this.metrics.get(id);
|
||||
if (m) {
|
||||
m.inputTokens += inputTokens;
|
||||
m.outputTokens += outputTokens;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Ensure the widget update timer is running. */
|
||||
ensureTimer() {
|
||||
if (!this.widgetInterval) {
|
||||
this.widgetInterval = setInterval(() => this.update(), 80);
|
||||
}
|
||||
}
|
||||
|
||||
/** Force an immediate widget update. */
|
||||
update() {
|
||||
if (!this.uiCtx) return;
|
||||
const tasks = this.store.list();
|
||||
|
||||
if (tasks.length === 0) {
|
||||
this.uiCtx.setWidget("tasks", undefined);
|
||||
if (this.widgetInterval) {
|
||||
clearInterval(this.widgetInterval);
|
||||
this.widgetInterval = undefined;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const completed = tasks.filter(t => t.status === "completed");
|
||||
const inProgress = tasks.filter(t => t.status === "in_progress");
|
||||
const pending = tasks.filter(t => t.status === "pending");
|
||||
|
||||
// Status summary (widget header only, not status bar)
|
||||
const parts: string[] = [];
|
||||
if (completed.length > 0) parts.push(`${completed.length} done`);
|
||||
if (inProgress.length > 0) parts.push(`${inProgress.length} in progress`);
|
||||
if (pending.length > 0) parts.push(`${pending.length} open`);
|
||||
const statusText = `${tasks.length} tasks (${parts.join(", ")})`;
|
||||
|
||||
// Prune stale active IDs (deleted or no longer in_progress)
|
||||
for (const id of this.activeTaskIds) {
|
||||
const t = this.store.get(id);
|
||||
if (!t || t.status !== "in_progress") {
|
||||
this.activeTaskIds.delete(id);
|
||||
this.metrics.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if any task needs animation
|
||||
const hasActiveSpinner = tasks.some(t => this.activeTaskIds.has(t.id) && t.status === "in_progress");
|
||||
if (hasActiveSpinner) {
|
||||
this.ensureTimer();
|
||||
} else if (!hasActiveSpinner && this.widgetInterval) {
|
||||
clearInterval(this.widgetInterval);
|
||||
this.widgetInterval = undefined;
|
||||
}
|
||||
|
||||
this.widgetFrame++;
|
||||
const spinnerChar = SPINNER[this.widgetFrame % SPINNER.length];
|
||||
|
||||
this.uiCtx.setWidget("tasks", (tui, theme) => {
|
||||
const w = tui.terminal.columns;
|
||||
const truncate = (line: string) => truncateToWidth(line, w);
|
||||
|
||||
const lines: string[] = [truncate(theme.fg("accent", "●") + " " + theme.fg("accent", statusText))];
|
||||
|
||||
const visible = tasks.slice(0, MAX_VISIBLE_TASKS);
|
||||
for (let i = 0; i < visible.length; i++) {
|
||||
const task = visible[i];
|
||||
const isActive = this.activeTaskIds.has(task.id) && task.status === "in_progress";
|
||||
|
||||
let icon: string;
|
||||
if (isActive) {
|
||||
icon = theme.fg("accent", spinnerChar);
|
||||
} else if (task.status === "completed") {
|
||||
icon = "✔";
|
||||
} else if (task.status === "in_progress") {
|
||||
icon = "◼";
|
||||
} else {
|
||||
icon = "◻";
|
||||
}
|
||||
|
||||
let suffix = "";
|
||||
// Show blocked-by info for pending tasks (only non-completed blockers)
|
||||
if (task.status === "pending" && task.blockedBy.length > 0) {
|
||||
const openBlockers = task.blockedBy.filter(bid => {
|
||||
const blocker = this.store.get(bid);
|
||||
return blocker && blocker.status !== "completed";
|
||||
});
|
||||
if (openBlockers.length > 0) {
|
||||
suffix = theme.fg("dim", ` › blocked by ${openBlockers.map(id => "#" + id).join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
let text: string;
|
||||
if (isActive) {
|
||||
const form = task.activeForm || task.subject;
|
||||
const m = this.metrics.get(task.id);
|
||||
let stats = "";
|
||||
if (m) {
|
||||
const elapsed = formatDuration(Date.now() - m.startedAt);
|
||||
const tokenParts: string[] = [];
|
||||
if (m.inputTokens > 0) tokenParts.push(`↑ ${formatTokens(m.inputTokens)}`);
|
||||
if (m.outputTokens > 0) tokenParts.push(`↓ ${formatTokens(m.outputTokens)}`);
|
||||
stats = tokenParts.length > 0
|
||||
? ` ${theme.fg("dim", `(${elapsed} · ${tokenParts.join(" ")})`)}`
|
||||
: ` ${theme.fg("dim", `(${elapsed})`)}`;
|
||||
}
|
||||
text = ` ${icon} ${theme.fg("accent", form + "…")}${stats}`;
|
||||
} else if (task.status === "completed") {
|
||||
text = ` ${theme.fg("dim", icon)} ${theme.fg("dim", theme.strikethrough(task.subject))}`;
|
||||
} else {
|
||||
text = ` ${icon} ${task.subject}`;
|
||||
}
|
||||
|
||||
lines.push(truncate(text + suffix));
|
||||
}
|
||||
|
||||
if (tasks.length > MAX_VISIBLE_TASKS) {
|
||||
lines.push(truncate(theme.fg("dim", ` … and ${tasks.length - MAX_VISIBLE_TASKS} more`)));
|
||||
}
|
||||
|
||||
return { render: () => lines, invalidate: () => {} };
|
||||
}, { placement: "aboveEditor" });
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this.widgetInterval) {
|
||||
clearInterval(this.widgetInterval);
|
||||
this.widgetInterval = undefined;
|
||||
}
|
||||
if (this.uiCtx) {
|
||||
this.uiCtx.setWidget("tasks", undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { ProcessTracker } from "../src/process-tracker.js";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
describe("ProcessTracker", () => {
|
||||
let tracker: ProcessTracker;
|
||||
|
||||
beforeEach(() => {
|
||||
tracker = new ProcessTracker();
|
||||
});
|
||||
|
||||
it("returns undefined for untracked task", () => {
|
||||
expect(tracker.getOutput("999")).toBeUndefined();
|
||||
expect(tracker.getProcess("999")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("tracks a process and captures stdout", async () => {
|
||||
const proc = spawn("echo", ["hello world"]);
|
||||
tracker.track("1", proc, "echo hello world");
|
||||
|
||||
await new Promise<void>((r) => proc.on("close", r));
|
||||
// Small delay for event processing
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
const out = tracker.getOutput("1");
|
||||
expect(out).toBeDefined();
|
||||
expect(out!.output).toContain("hello world");
|
||||
expect(out!.status).toBe("completed");
|
||||
expect(out!.exitCode).toBe(0);
|
||||
expect(out!.command).toBe("echo hello world");
|
||||
expect(out!.startedAt).toBeGreaterThan(0);
|
||||
expect(out!.completedAt).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("tracks a process and captures stderr", async () => {
|
||||
const proc = spawn("sh", ["-c", "echo errdata >&2"]);
|
||||
tracker.track("1", proc);
|
||||
|
||||
await new Promise<void>((r) => proc.on("close", r));
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
const out = tracker.getOutput("1");
|
||||
expect(out!.output).toContain("errdata");
|
||||
});
|
||||
|
||||
it("reports error status for non-zero exit", async () => {
|
||||
const proc = spawn("sh", ["-c", "exit 42"]);
|
||||
tracker.track("1", proc);
|
||||
|
||||
await new Promise<void>((r) => proc.on("close", r));
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
const out = tracker.getOutput("1");
|
||||
expect(out!.status).toBe("error");
|
||||
expect(out!.exitCode).toBe(42);
|
||||
});
|
||||
|
||||
it("waitForCompletion returns immediately for already-completed process", async () => {
|
||||
const proc = spawn("echo", ["done"]);
|
||||
tracker.track("1", proc);
|
||||
|
||||
await new Promise<void>((r) => proc.on("close", r));
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
const out = await tracker.waitForCompletion("1", 1000);
|
||||
expect(out).toBeDefined();
|
||||
expect(out!.status).toBe("completed");
|
||||
});
|
||||
|
||||
it("waitForCompletion returns undefined for untracked task", async () => {
|
||||
const out = await tracker.waitForCompletion("999", 1000);
|
||||
expect(out).toBeUndefined();
|
||||
});
|
||||
|
||||
it("waitForCompletion waits for process to finish", async () => {
|
||||
const proc = spawn("sh", ["-c", "sleep 0.1 && echo waited"]);
|
||||
tracker.track("1", proc);
|
||||
|
||||
const out = await tracker.waitForCompletion("1", 5000);
|
||||
expect(out).toBeDefined();
|
||||
expect(out!.output).toContain("waited");
|
||||
expect(out!.status).toBe("completed");
|
||||
});
|
||||
|
||||
it("waitForCompletion times out if process takes too long", async () => {
|
||||
const proc = spawn("sleep", ["10"]);
|
||||
tracker.track("1", proc);
|
||||
|
||||
const out = await tracker.waitForCompletion("1", 200);
|
||||
expect(out).toBeDefined();
|
||||
expect(out!.status).toBe("running");
|
||||
|
||||
// Cleanup
|
||||
proc.kill("SIGKILL");
|
||||
});
|
||||
|
||||
it("stop sends SIGTERM and marks process stopped", async () => {
|
||||
const proc = spawn("sleep", ["10"]);
|
||||
tracker.track("1", proc);
|
||||
|
||||
// Small delay to let process start
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
const stopped = await tracker.stop("1");
|
||||
expect(stopped).toBe(true);
|
||||
|
||||
const out = tracker.getOutput("1");
|
||||
expect(out!.status).toBe("stopped");
|
||||
expect(out!.completedAt).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("stop returns false for untracked task", async () => {
|
||||
expect(await tracker.stop("999")).toBe(false);
|
||||
});
|
||||
|
||||
it("stop returns false for already-completed process", async () => {
|
||||
const proc = spawn("echo", ["quick"]);
|
||||
tracker.track("1", proc);
|
||||
|
||||
await new Promise<void>((r) => proc.on("close", r));
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
expect(await tracker.stop("1")).toBe(false);
|
||||
});
|
||||
|
||||
it("getProcess returns the background process record", () => {
|
||||
const proc = spawn("echo", ["test"]);
|
||||
tracker.track("1", proc, "echo test");
|
||||
|
||||
const bp = tracker.getProcess("1");
|
||||
expect(bp).toBeDefined();
|
||||
expect(bp!.taskId).toBe("1");
|
||||
expect(bp!.command).toBe("echo test");
|
||||
expect(bp!.status).toBe("running");
|
||||
expect(bp!.pid).toBeGreaterThan(0);
|
||||
|
||||
proc.kill("SIGKILL");
|
||||
});
|
||||
|
||||
it("handles process error event", async () => {
|
||||
const proc = spawn("nonexistent-binary-that-does-not-exist-xyz");
|
||||
tracker.track("1", proc);
|
||||
|
||||
await new Promise<void>((r) => proc.on("error", () => r()));
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
const out = tracker.getOutput("1");
|
||||
expect(out!.status).toBe("error");
|
||||
expect(out!.output).toContain("Process error:");
|
||||
});
|
||||
|
||||
it("waitForCompletion respects abort signal", async () => {
|
||||
const proc = spawn("sleep", ["10"]);
|
||||
tracker.track("1", proc);
|
||||
|
||||
const ac = new AbortController();
|
||||
setTimeout(() => ac.abort(), 100);
|
||||
|
||||
const out = await tracker.waitForCompletion("1", 60000, ac.signal);
|
||||
expect(out).toBeDefined();
|
||||
expect(out!.status).toBe("running");
|
||||
|
||||
proc.kill("SIGKILL");
|
||||
});
|
||||
|
||||
it("notifies waiters when process completes", async () => {
|
||||
const proc = spawn("sh", ["-c", "sleep 0.1"]);
|
||||
tracker.track("1", proc);
|
||||
|
||||
const [r1, r2] = await Promise.all([
|
||||
tracker.waitForCompletion("1", 5000),
|
||||
tracker.waitForCompletion("1", 5000),
|
||||
]);
|
||||
|
||||
expect(r1!.status).toBe("completed");
|
||||
expect(r2!.status).toBe("completed");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,358 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { TaskStore } from "../src/task-store.js";
|
||||
import { existsSync, rmSync, mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
|
||||
describe("TaskStore (in-memory)", () => {
|
||||
let store: TaskStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = new TaskStore(); // no listId = in-memory
|
||||
});
|
||||
|
||||
it("creates tasks with auto-incrementing IDs", () => {
|
||||
const t1 = store.create("First task", "Description 1");
|
||||
const t2 = store.create("Second task", "Description 2");
|
||||
|
||||
expect(t1.id).toBe("1");
|
||||
expect(t2.id).toBe("2");
|
||||
expect(t1.status).toBe("pending");
|
||||
expect(t1.subject).toBe("First task");
|
||||
expect(t1.description).toBe("Description 1");
|
||||
});
|
||||
|
||||
it("creates tasks with optional fields", () => {
|
||||
const t = store.create("Task", "Desc", "Running task", { key: "value" });
|
||||
|
||||
expect(t.activeForm).toBe("Running task");
|
||||
expect(t.metadata).toEqual({ key: "value" });
|
||||
});
|
||||
|
||||
it("gets a task by ID", () => {
|
||||
store.create("Test", "Desc");
|
||||
const task = store.get("1");
|
||||
|
||||
expect(task).toBeDefined();
|
||||
expect(task!.subject).toBe("Test");
|
||||
});
|
||||
|
||||
it("returns undefined for non-existent task", () => {
|
||||
expect(store.get("999")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("lists all tasks sorted by ID", () => {
|
||||
store.create("Task 3", "Desc");
|
||||
store.create("Task 1", "Desc");
|
||||
store.create("Task 2", "Desc");
|
||||
|
||||
const tasks = store.list();
|
||||
expect(tasks.map(t => t.id)).toEqual(["1", "2", "3"]);
|
||||
});
|
||||
|
||||
it("updates task status", () => {
|
||||
store.create("Test", "Desc");
|
||||
const { task, changedFields } = store.update("1", { status: "in_progress" });
|
||||
|
||||
expect(task!.status).toBe("in_progress");
|
||||
expect(changedFields).toEqual(["status"]);
|
||||
});
|
||||
|
||||
it("updates multiple fields at once", () => {
|
||||
store.create("Test", "Desc");
|
||||
const { changedFields } = store.update("1", {
|
||||
subject: "Updated subject",
|
||||
description: "Updated desc",
|
||||
owner: "agent-1",
|
||||
});
|
||||
|
||||
expect(changedFields).toContain("subject");
|
||||
expect(changedFields).toContain("description");
|
||||
expect(changedFields).toContain("owner");
|
||||
|
||||
const task = store.get("1")!;
|
||||
expect(task.subject).toBe("Updated subject");
|
||||
expect(task.owner).toBe("agent-1");
|
||||
});
|
||||
|
||||
it("deletes a task with status: deleted", () => {
|
||||
store.create("Test", "Desc");
|
||||
const { changedFields } = store.update("1", { status: "deleted" });
|
||||
|
||||
expect(changedFields).toEqual(["deleted"]);
|
||||
expect(store.get("1")).toBeUndefined();
|
||||
expect(store.list()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("preserves ID counter after deletion", () => {
|
||||
store.create("Task 1", "Desc");
|
||||
store.create("Task 2", "Desc");
|
||||
store.update("1", { status: "deleted" });
|
||||
|
||||
const t3 = store.create("Task 3", "Desc");
|
||||
expect(t3.id).toBe("3"); // Not "1" — counter continues
|
||||
});
|
||||
|
||||
it("merges metadata with null key deletion", () => {
|
||||
store.create("Test", "Desc", undefined, { a: 1, b: 2, c: 3 });
|
||||
store.update("1", { metadata: { b: null, d: 4 } });
|
||||
|
||||
const task = store.get("1")!;
|
||||
expect(task.metadata).toEqual({ a: 1, c: 3, d: 4 });
|
||||
});
|
||||
|
||||
it("sets up bidirectional blocks via addBlocks", () => {
|
||||
store.create("Blocker", "Desc");
|
||||
store.create("Blocked", "Desc");
|
||||
|
||||
store.update("1", { addBlocks: ["2"] });
|
||||
|
||||
const t1 = store.get("1")!;
|
||||
const t2 = store.get("2")!;
|
||||
expect(t1.blocks).toContain("2");
|
||||
expect(t2.blockedBy).toContain("1");
|
||||
});
|
||||
|
||||
it("sets up bidirectional blocks via addBlockedBy", () => {
|
||||
store.create("Blocker", "Desc");
|
||||
store.create("Blocked", "Desc");
|
||||
|
||||
store.update("2", { addBlockedBy: ["1"] });
|
||||
|
||||
const t1 = store.get("1")!;
|
||||
const t2 = store.get("2")!;
|
||||
expect(t1.blocks).toContain("2");
|
||||
expect(t2.blockedBy).toContain("1");
|
||||
});
|
||||
|
||||
it("does not duplicate dependency edges", () => {
|
||||
store.create("A", "Desc");
|
||||
store.create("B", "Desc");
|
||||
|
||||
store.update("1", { addBlocks: ["2"] });
|
||||
store.update("1", { addBlocks: ["2"] }); // duplicate
|
||||
|
||||
const t1 = store.get("1")!;
|
||||
expect(t1.blocks.filter(id => id === "2")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("cleans up dependency edges on deletion", () => {
|
||||
store.create("A", "Desc");
|
||||
store.create("B", "Desc");
|
||||
store.update("1", { addBlocks: ["2"] });
|
||||
|
||||
store.update("1", { status: "deleted" });
|
||||
|
||||
const t2 = store.get("2")!;
|
||||
expect(t2.blockedBy).toEqual([]);
|
||||
});
|
||||
|
||||
it("clears completed tasks", () => {
|
||||
store.create("Completed", "Desc");
|
||||
store.create("Pending", "Desc");
|
||||
store.update("1", { status: "completed" });
|
||||
|
||||
const count = store.clearCompleted();
|
||||
|
||||
expect(count).toBe(1);
|
||||
expect(store.list()).toHaveLength(1);
|
||||
expect(store.list()[0].id).toBe("2");
|
||||
});
|
||||
|
||||
it("returns not found for update on non-existent task", () => {
|
||||
const { task, changedFields } = store.update("999", { status: "completed" });
|
||||
expect(task).toBeUndefined();
|
||||
expect(changedFields).toEqual([]);
|
||||
});
|
||||
|
||||
it("delete method works", () => {
|
||||
store.create("Test", "Desc");
|
||||
expect(store.delete("1")).toBe(true);
|
||||
expect(store.delete("1")).toBe(false); // already deleted
|
||||
expect(store.list()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("creates tasks with metadata via TaskCreate", () => {
|
||||
const t = store.create("With meta", "Desc", undefined, { pr: "123", reviewer: "alice" });
|
||||
expect(t.metadata).toEqual({ pr: "123", reviewer: "alice" });
|
||||
|
||||
const retrieved = store.get("1")!;
|
||||
expect(retrieved.metadata).toEqual({ pr: "123", reviewer: "alice" });
|
||||
});
|
||||
|
||||
it("allows circular dependencies with warning", () => {
|
||||
store.create("A", "Desc");
|
||||
store.create("B", "Desc");
|
||||
store.update("1", { addBlocks: ["2"] });
|
||||
const { warnings } = store.update("2", { addBlocks: ["1"] });
|
||||
|
||||
expect(store.get("1")!.blocks).toContain("2");
|
||||
expect(store.get("2")!.blocks).toContain("1");
|
||||
expect(warnings).toContain("cycle: #2 and #1 block each other");
|
||||
});
|
||||
|
||||
it("allows self-dependency with warning", () => {
|
||||
store.create("Self", "Desc");
|
||||
const { warnings } = store.update("1", { addBlocks: ["1"] });
|
||||
expect(store.get("1")!.blocks).toContain("1");
|
||||
expect(warnings).toContain("#1 blocks itself");
|
||||
});
|
||||
|
||||
it("stores dangling edge IDs with warning", () => {
|
||||
store.create("Real", "Desc");
|
||||
const { warnings } = store.update("1", { addBlocks: ["9999"] });
|
||||
expect(store.get("1")!.blocks).toContain("9999");
|
||||
expect(warnings).toContain("#9999 does not exist");
|
||||
});
|
||||
|
||||
it("returns no warnings for valid dependencies", () => {
|
||||
store.create("A", "Desc");
|
||||
store.create("B", "Desc");
|
||||
const { warnings } = store.update("1", { addBlocks: ["2"] });
|
||||
expect(warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it("accepts whitespace-only subjects (matches Claude Code)", () => {
|
||||
const t = store.create(" ", "Desc");
|
||||
expect(t.subject).toBe(" ");
|
||||
});
|
||||
|
||||
it("updates activeForm field", () => {
|
||||
store.create("Test", "Desc");
|
||||
const { changedFields } = store.update("1", { activeForm: "Running tests" });
|
||||
expect(changedFields).toContain("activeForm");
|
||||
expect(store.get("1")!.activeForm).toBe("Running tests");
|
||||
});
|
||||
|
||||
it("updates description field", () => {
|
||||
store.create("Test", "Original desc");
|
||||
const { changedFields } = store.update("1", { description: "Updated desc" });
|
||||
expect(changedFields).toContain("description");
|
||||
expect(store.get("1")!.description).toBe("Updated desc");
|
||||
});
|
||||
|
||||
it("returns empty changedFields when updating non-existent task", () => {
|
||||
const { task, changedFields, warnings } = store.update("999", { status: "completed" });
|
||||
expect(task).toBeUndefined();
|
||||
expect(changedFields).toEqual([]);
|
||||
expect(warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it("clearCompleted cleans up dependency edges", () => {
|
||||
store.create("Blocker", "Desc");
|
||||
store.create("Blocked", "Desc");
|
||||
store.update("1", { addBlocks: ["2"] });
|
||||
store.update("1", { status: "completed" });
|
||||
|
||||
store.clearCompleted();
|
||||
|
||||
const t2 = store.get("2")!;
|
||||
expect(t2.blockedBy).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles multiple addBlocks in one call", () => {
|
||||
store.create("Blocker", "Desc");
|
||||
store.create("B1", "Desc");
|
||||
store.create("B2", "Desc");
|
||||
|
||||
store.update("1", { addBlocks: ["2", "3"] });
|
||||
|
||||
expect(store.get("1")!.blocks).toEqual(["2", "3"]);
|
||||
expect(store.get("2")!.blockedBy).toContain("1");
|
||||
expect(store.get("3")!.blockedBy).toContain("1");
|
||||
});
|
||||
|
||||
it("addBlockedBy warns on self-dependency", () => {
|
||||
store.create("Self", "Desc");
|
||||
const { warnings } = store.update("1", { addBlockedBy: ["1"] });
|
||||
expect(store.get("1")!.blockedBy).toContain("1");
|
||||
expect(warnings).toContain("#1 blocks itself");
|
||||
});
|
||||
|
||||
it("addBlockedBy warns on dangling ref", () => {
|
||||
store.create("Real", "Desc");
|
||||
const { warnings } = store.update("1", { addBlockedBy: ["9999"] });
|
||||
expect(store.get("1")!.blockedBy).toContain("9999");
|
||||
expect(warnings).toContain("#9999 does not exist");
|
||||
});
|
||||
|
||||
it("addBlockedBy warns on cycle", () => {
|
||||
store.create("A", "Desc");
|
||||
store.create("B", "Desc");
|
||||
store.update("1", { addBlocks: ["2"] });
|
||||
const { warnings } = store.update("1", { addBlockedBy: ["2"] });
|
||||
expect(warnings).toContain("cycle: #1 and #2 block each other");
|
||||
});
|
||||
|
||||
it("clearCompleted returns 0 when no completed tasks", () => {
|
||||
store.create("Pending", "Desc");
|
||||
expect(store.clearCompleted()).toBe(0);
|
||||
});
|
||||
|
||||
it("list sorts pending → in_progress → completed with all three present", () => {
|
||||
store.create("Pending task", "Desc");
|
||||
store.create("Completed task", "Desc");
|
||||
store.create("In-progress task", "Desc");
|
||||
store.create("Another pending", "Desc");
|
||||
|
||||
store.update("2", { status: "completed" });
|
||||
store.update("3", { status: "in_progress" });
|
||||
|
||||
const tasks = store.list();
|
||||
// Store returns by ID; TaskList tool sorts by status group
|
||||
// Here we verify the raw list order (by ID), then test status-grouped sort
|
||||
const statusOrder: Record<string, number> = { pending: 0, in_progress: 1, completed: 2 };
|
||||
const sorted = [...tasks].sort((a, b) => {
|
||||
const so = (statusOrder[a.status] ?? 0) - (statusOrder[b.status] ?? 0);
|
||||
if (so !== 0) return so;
|
||||
return Number(a.id) - Number(b.id);
|
||||
});
|
||||
|
||||
expect(sorted.map(t => t.id)).toEqual(["1", "4", "3", "2"]);
|
||||
expect(sorted.map(t => t.status)).toEqual(["pending", "pending", "in_progress", "completed"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskStore (file-backed)", () => {
|
||||
const testListId = `test-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const tasksDir = join(homedir(), ".pi", "tasks");
|
||||
const filePath = join(tasksDir, `${testListId}.json`);
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up test file
|
||||
try { rmSync(filePath); } catch { /* */ }
|
||||
try { rmSync(filePath + ".lock"); } catch { /* */ }
|
||||
try { rmSync(filePath + ".tmp"); } catch { /* */ }
|
||||
});
|
||||
|
||||
it("persists tasks to disk", () => {
|
||||
const store1 = new TaskStore(testListId);
|
||||
store1.create("Persistent task", "Should survive reload");
|
||||
|
||||
// Create a new store instance pointing to same file
|
||||
const store2 = new TaskStore(testListId);
|
||||
const tasks = store2.list();
|
||||
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].subject).toBe("Persistent task");
|
||||
});
|
||||
|
||||
it("persists updates to disk", () => {
|
||||
const store1 = new TaskStore(testListId);
|
||||
store1.create("Task", "Desc");
|
||||
store1.update("1", { status: "completed" });
|
||||
|
||||
const store2 = new TaskStore(testListId);
|
||||
expect(store2.get("1")!.status).toBe("completed");
|
||||
});
|
||||
|
||||
it("persists ID counter across instances", () => {
|
||||
const store1 = new TaskStore(testListId);
|
||||
store1.create("Task 1", "Desc");
|
||||
store1.create("Task 2", "Desc");
|
||||
|
||||
const store2 = new TaskStore(testListId);
|
||||
const t3 = store2.create("Task 3", "Desc");
|
||||
expect(t3.id).toBe("3");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,411 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { TaskWidget, type UICtx, type Theme, type TaskMetrics } from "../src/ui/task-widget.js";
|
||||
import { TaskStore } from "../src/task-store.js";
|
||||
|
||||
/** Create a mock theme that returns raw text (no ANSI escapes). */
|
||||
function mockTheme(): Theme {
|
||||
return {
|
||||
fg: (_color: string, text: string) => text,
|
||||
bold: (text: string) => text,
|
||||
strikethrough: (text: string) => `~~${text}~~`,
|
||||
};
|
||||
}
|
||||
|
||||
/** Create a mock UICtx that captures setWidget calls. */
|
||||
function mockUICtx() {
|
||||
const state: {
|
||||
widgets: Map<string, any>;
|
||||
statuses: Map<string, string | undefined>;
|
||||
} = {
|
||||
widgets: new Map(),
|
||||
statuses: new Map(),
|
||||
};
|
||||
|
||||
const ctx: UICtx = {
|
||||
setWidget(key, content, options) {
|
||||
state.widgets.set(key, { content, options });
|
||||
},
|
||||
setStatus(key, text) {
|
||||
state.statuses.set(key, text);
|
||||
},
|
||||
};
|
||||
|
||||
return { ctx, state };
|
||||
}
|
||||
|
||||
/** Render the widget and return its lines. */
|
||||
function renderWidget(state: ReturnType<typeof mockUICtx>["state"]): string[] {
|
||||
const entry = state.widgets.get("tasks");
|
||||
if (!entry?.content) return [];
|
||||
const theme = mockTheme();
|
||||
const tui = { terminal: { columns: 200 } };
|
||||
const result = entry.content(tui, theme);
|
||||
return result.render();
|
||||
}
|
||||
|
||||
describe("TaskWidget", () => {
|
||||
let store: TaskStore;
|
||||
let widget: TaskWidget;
|
||||
let ui: ReturnType<typeof mockUICtx>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
store = new TaskStore();
|
||||
widget = new TaskWidget(store);
|
||||
ui = mockUICtx();
|
||||
widget.setUICtx(ui.ctx);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
widget.dispose();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("shows nothing when no tasks exist", () => {
|
||||
widget.update();
|
||||
const entry = ui.state.widgets.get("tasks");
|
||||
expect(entry?.content).toBeUndefined();
|
||||
});
|
||||
|
||||
it("renders pending tasks with ◻ icon", () => {
|
||||
store.create("Do something", "Desc");
|
||||
widget.update();
|
||||
|
||||
const lines = renderWidget(ui.state);
|
||||
expect(lines).toHaveLength(2); // header + 1 task
|
||||
expect(lines[0]).toContain("1 tasks");
|
||||
expect(lines[0]).toContain("1 open");
|
||||
expect(lines[1]).toContain("◻");
|
||||
expect(lines[1]).toContain("Do something");
|
||||
});
|
||||
|
||||
it("renders in-progress tasks with ◼ icon", () => {
|
||||
store.create("Working on it", "Desc");
|
||||
store.update("1", { status: "in_progress" });
|
||||
widget.update();
|
||||
|
||||
const lines = renderWidget(ui.state);
|
||||
expect(lines[1]).toContain("◼");
|
||||
expect(lines[1]).toContain("Working on it");
|
||||
});
|
||||
|
||||
it("renders completed tasks with ✔ icon and strikethrough", () => {
|
||||
store.create("Done task", "Desc");
|
||||
store.update("1", { status: "completed" });
|
||||
widget.update();
|
||||
|
||||
const lines = renderWidget(ui.state);
|
||||
expect(lines[1]).toContain("✔");
|
||||
expect(lines[1]).toContain("~~Done task~~");
|
||||
});
|
||||
|
||||
it("renders active tasks with spinner icon", () => {
|
||||
store.create("Running thing", "Desc", "Processing data");
|
||||
store.update("1", { status: "in_progress" });
|
||||
widget.setActiveTask("1", true);
|
||||
|
||||
const lines = renderWidget(ui.state);
|
||||
// Should show activeForm text with "…" suffix
|
||||
expect(lines[1]).toContain("Processing data…");
|
||||
// Should NOT show ◼ for active task
|
||||
expect(lines[1]).not.toContain("◼");
|
||||
});
|
||||
|
||||
it("shows blocked-by info for pending tasks", () => {
|
||||
store.create("Blocker", "Desc");
|
||||
store.create("Blocked", "Desc");
|
||||
store.update("2", { addBlockedBy: ["1"] });
|
||||
widget.update();
|
||||
|
||||
const lines = renderWidget(ui.state);
|
||||
const blockedLine = lines.find(l => l.includes("Blocked"));
|
||||
expect(blockedLine).toContain("blocked by #1");
|
||||
});
|
||||
|
||||
it("hides completed blockers in blocked-by suffix", () => {
|
||||
store.create("Blocker", "Desc");
|
||||
store.create("Blocked", "Desc");
|
||||
store.update("2", { addBlockedBy: ["1"] });
|
||||
store.update("1", { status: "completed" });
|
||||
widget.update();
|
||||
|
||||
const lines = renderWidget(ui.state);
|
||||
const blockedLine = lines.find(l => l.includes("Blocked"));
|
||||
expect(blockedLine).not.toContain("blocked by");
|
||||
});
|
||||
|
||||
it("shows status summary in header", () => {
|
||||
store.create("Task A", "Desc");
|
||||
store.create("Task B", "Desc");
|
||||
store.create("Task C", "Desc");
|
||||
store.update("1", { status: "completed" });
|
||||
store.update("2", { status: "in_progress" });
|
||||
widget.update();
|
||||
|
||||
const lines = renderWidget(ui.state);
|
||||
expect(lines[0]).toContain("3 tasks");
|
||||
expect(lines[0]).toContain("1 done");
|
||||
expect(lines[0]).toContain("1 in progress");
|
||||
expect(lines[0]).toContain("1 open");
|
||||
});
|
||||
|
||||
it("clears widget when all tasks are deleted", () => {
|
||||
store.create("Task", "Desc");
|
||||
widget.update();
|
||||
expect(ui.state.widgets.get("tasks")?.content).toBeDefined();
|
||||
|
||||
store.update("1", { status: "deleted" });
|
||||
widget.update();
|
||||
expect(ui.state.widgets.get("tasks")?.content).toBeUndefined();
|
||||
});
|
||||
|
||||
it("limits visible tasks to MAX_VISIBLE_TASKS", () => {
|
||||
for (let i = 0; i < 15; i++) {
|
||||
store.create(`Task ${i + 1}`, "Desc");
|
||||
}
|
||||
widget.update();
|
||||
|
||||
const lines = renderWidget(ui.state);
|
||||
// header + 10 tasks + "… and 5 more"
|
||||
expect(lines).toHaveLength(12);
|
||||
expect(lines[11]).toContain("5 more");
|
||||
});
|
||||
|
||||
it("tracks token usage for active tasks", () => {
|
||||
store.create("Active task", "Desc", "Running");
|
||||
store.update("1", { status: "in_progress" });
|
||||
widget.setActiveTask("1", true);
|
||||
|
||||
widget.addTokenUsage(1000, 500);
|
||||
widget.addTokenUsage(500, 300);
|
||||
|
||||
const lines = renderWidget(ui.state);
|
||||
const activeLine = lines.find(l => l.includes("Running…"));
|
||||
expect(activeLine).toContain("↑ 1.5k");
|
||||
expect(activeLine).toContain("↓ 800");
|
||||
});
|
||||
|
||||
it("deactivates a task with setActiveTask(id, false)", () => {
|
||||
store.create("Task", "Desc", "Doing work");
|
||||
store.update("1", { status: "in_progress" });
|
||||
widget.setActiveTask("1", true);
|
||||
|
||||
// Should be active (spinner)
|
||||
let lines = renderWidget(ui.state);
|
||||
expect(lines[1]).toContain("Doing work…");
|
||||
|
||||
widget.setActiveTask("1", false);
|
||||
lines = renderWidget(ui.state);
|
||||
// Should now show as regular in_progress (◼)
|
||||
expect(lines[1]).toContain("◼");
|
||||
expect(lines[1]).not.toContain("Doing work…");
|
||||
});
|
||||
|
||||
it("prunes stale active IDs on update", () => {
|
||||
store.create("Task", "Desc");
|
||||
store.update("1", { status: "in_progress" });
|
||||
widget.setActiveTask("1", true);
|
||||
|
||||
// Complete the task externally
|
||||
store.update("1", { status: "completed" });
|
||||
widget.update();
|
||||
|
||||
// Should render as completed, not active
|
||||
const lines = renderWidget(ui.state);
|
||||
expect(lines[1]).toContain("✔");
|
||||
expect(lines[1]).toContain("~~Task~~");
|
||||
});
|
||||
|
||||
it("supports multiple active tasks simultaneously", () => {
|
||||
store.create("Task A", "Desc", "Processing A");
|
||||
store.create("Task B", "Desc", "Processing B");
|
||||
store.update("1", { status: "in_progress" });
|
||||
store.update("2", { status: "in_progress" });
|
||||
widget.setActiveTask("1", true);
|
||||
widget.setActiveTask("2", true);
|
||||
|
||||
const lines = renderWidget(ui.state);
|
||||
expect(lines[1]).toContain("Processing A…");
|
||||
expect(lines[2]).toContain("Processing B…");
|
||||
});
|
||||
|
||||
it("distributes token usage across all active tasks", () => {
|
||||
store.create("Task A", "Desc", "A");
|
||||
store.create("Task B", "Desc", "B");
|
||||
store.update("1", { status: "in_progress" });
|
||||
store.update("2", { status: "in_progress" });
|
||||
widget.setActiveTask("1", true);
|
||||
widget.setActiveTask("2", true);
|
||||
|
||||
widget.addTokenUsage(100, 50);
|
||||
|
||||
const lines = renderWidget(ui.state);
|
||||
// Both tasks should have the same token counts
|
||||
expect(lines[1]).toContain("↑ 100");
|
||||
expect(lines[2]).toContain("↑ 100");
|
||||
});
|
||||
|
||||
it("dispose clears widget and timer", () => {
|
||||
store.create("Task", "Desc");
|
||||
store.update("1", { status: "in_progress" });
|
||||
widget.setActiveTask("1", true);
|
||||
|
||||
widget.dispose();
|
||||
expect(ui.state.widgets.get("tasks")?.content).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses subject as fallback when no activeForm", () => {
|
||||
store.create("My Subject", "Desc");
|
||||
store.update("1", { status: "in_progress" });
|
||||
widget.setActiveTask("1", true);
|
||||
|
||||
const lines = renderWidget(ui.state);
|
||||
expect(lines[1]).toContain("My Subject…");
|
||||
});
|
||||
|
||||
it("shows elapsed time but no token arrows when tokens are zero", () => {
|
||||
store.create("No tokens", "Desc", "Working");
|
||||
store.update("1", { status: "in_progress" });
|
||||
widget.setActiveTask("1", true);
|
||||
|
||||
// No addTokenUsage calls — tokens stay at 0
|
||||
vi.advanceTimersByTime(5000);
|
||||
widget.update();
|
||||
|
||||
const lines = renderWidget(ui.state);
|
||||
const activeLine = lines.find(l => l.includes("Working…"));
|
||||
expect(activeLine).toContain("5s");
|
||||
expect(activeLine).not.toContain("↑");
|
||||
expect(activeLine).not.toContain("↓");
|
||||
});
|
||||
|
||||
it("cleans up metrics when stale active IDs are pruned", () => {
|
||||
store.create("Task", "Desc", "Running");
|
||||
store.update("1", { status: "in_progress" });
|
||||
widget.setActiveTask("1", true);
|
||||
widget.addTokenUsage(100, 50);
|
||||
|
||||
// Delete task externally
|
||||
store.update("1", { status: "deleted" });
|
||||
widget.update();
|
||||
|
||||
// Reactivate with same ID (new task) — should get fresh metrics
|
||||
store.create("Task 2", "Desc", "Running"); // ID 2
|
||||
store.update("2", { status: "in_progress" });
|
||||
widget.setActiveTask("2", true);
|
||||
|
||||
const lines = renderWidget(ui.state);
|
||||
// Should not carry over old tokens
|
||||
expect(lines[1]).not.toContain("↑ 100");
|
||||
});
|
||||
|
||||
it("indents task lines under header", () => {
|
||||
store.create("Indented task", "Desc");
|
||||
widget.update();
|
||||
|
||||
const lines = renderWidget(ui.state);
|
||||
// Task line should start with 2 spaces
|
||||
expect(lines[1]).toMatch(/^\s{2}/);
|
||||
});
|
||||
|
||||
it("widget is placed aboveEditor", () => {
|
||||
store.create("Task", "Desc");
|
||||
widget.update();
|
||||
|
||||
const entry = ui.state.widgets.get("tasks");
|
||||
expect(entry?.options?.placement).toBe("aboveEditor");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatDuration (via widget rendering)", () => {
|
||||
let store: TaskStore;
|
||||
let widget: TaskWidget;
|
||||
let ui: ReturnType<typeof mockUICtx>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
store = new TaskStore();
|
||||
widget = new TaskWidget(store);
|
||||
ui = mockUICtx();
|
||||
widget.setUICtx(ui.ctx);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
widget.dispose();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("shows seconds for short durations", () => {
|
||||
store.create("Quick", "Desc", "Working");
|
||||
store.update("1", { status: "in_progress" });
|
||||
widget.setActiveTask("1", true);
|
||||
|
||||
vi.advanceTimersByTime(30_000); // 30s
|
||||
widget.update();
|
||||
|
||||
const lines = renderWidget(ui.state);
|
||||
expect(lines[1]).toContain("30s");
|
||||
});
|
||||
|
||||
it("shows hours for long durations", () => {
|
||||
store.create("Long", "Desc", "Working");
|
||||
store.update("1", { status: "in_progress" });
|
||||
widget.setActiveTask("1", true);
|
||||
|
||||
vi.advanceTimersByTime(3_723_000); // 1h 2m 3s → "1h 2m"
|
||||
widget.update();
|
||||
|
||||
const lines = renderWidget(ui.state);
|
||||
expect(lines[1]).toContain("1h 2m");
|
||||
});
|
||||
|
||||
it("shows exact hours without minutes", () => {
|
||||
store.create("Exact", "Desc", "Working");
|
||||
store.update("1", { status: "in_progress" });
|
||||
widget.setActiveTask("1", true);
|
||||
|
||||
vi.advanceTimersByTime(7_200_000); // 2h exactly
|
||||
widget.update();
|
||||
|
||||
const lines = renderWidget(ui.state);
|
||||
expect(lines[1]).toContain("2h)");
|
||||
});
|
||||
|
||||
it("shows minutes and seconds", () => {
|
||||
store.create("Medium", "Desc", "Working");
|
||||
store.update("1", { status: "in_progress" });
|
||||
widget.setActiveTask("1", true);
|
||||
|
||||
vi.advanceTimersByTime(169_000); // 2m 49s
|
||||
widget.update();
|
||||
|
||||
const lines = renderWidget(ui.state);
|
||||
expect(lines[1]).toContain("2m 49s");
|
||||
});
|
||||
|
||||
it("formats small token counts without k suffix", () => {
|
||||
store.create("Small", "Desc", "Working");
|
||||
store.update("1", { status: "in_progress" });
|
||||
widget.setActiveTask("1", true);
|
||||
|
||||
widget.addTokenUsage(500, 200);
|
||||
widget.update();
|
||||
|
||||
const lines = renderWidget(ui.state);
|
||||
expect(lines[1]).toContain("↑ 500");
|
||||
expect(lines[1]).toContain("↓ 200");
|
||||
});
|
||||
|
||||
it("formats token counts with k suffix and removes .0", () => {
|
||||
store.create("Large", "Desc", "Working");
|
||||
store.update("1", { status: "in_progress" });
|
||||
widget.setActiveTask("1", true);
|
||||
|
||||
widget.addTokenUsage(2000, 4100);
|
||||
widget.update();
|
||||
|
||||
const lines = renderWidget(ui.state);
|
||||
expect(lines[1]).toContain("↑ 2k"); // 2000 → "2k" (not "2.0k")
|
||||
expect(lines[1]).toContain("↓ 4.1k"); // 4100 → "4.1k"
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user