@timmyjl12/pi-todo

Persistent, visible todo tracking for the pi coding agent — Claude Code-style task tools (TaskCreate/TaskGet/TaskList/TaskUpdate) with dependency edges, an above-editor widget, a statusline pill, and a /todo menu overlay.

Packages

Package details

extension

Install @timmyjl12/pi-todo from npm and Pi will load the resources declared by the package manifest.

$ pi install npm:@timmyjl12/pi-todo
Package
@timmyjl12/pi-todo
Version
0.4.0
Published
Nov 8, 2026
Downloads
355/mo · 26/wk
Author
timmyjl12
License
MIT
Types
extension
Size
191.4 KB
Dependencies
0 dependencies · 5 peers
Pi manifest JSON
{
  "extensions": [
    "./index.ts"
  ]
}

Security note

Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.

README

@timmyjl12/pi-todo

Persistent, visible todo tracking for the pi coding agent — Claude Code-style task tools, an above-editor widget, a statusline pill, and a /todo menu overlay.

The agent gets four tools it already knows from Claude Code (TaskCreate, TaskGet, TaskList, TaskUpdate) with dependency edges, and you get to see the plan live: a task list above the editor, counts in the footer, and a full-screen menu for hands-on management.

Todos (1/3)
◼ #2 Running the test suite
◻ #3 Ship the release › blocked by #2

Install

Requires Pi 1.4.0 or later. This checkout is developed and tested against the current 1.4.0 extension/package/TUI contracts.

pi install npm:@timmyjl12/pi-todo

Or load directly for development:

pi -e ./index.ts

Tools

Tool Purpose
TaskCreate Create a task with subject, description, optional activeForm, owner, metadata, and dependency edges. Starts pending.
TaskGet Full detail for one task — status, description, owner, all edges, metadata.
TaskList One line per task: #id [status] subject (owner) [blocked by #…], pending first.
TaskUpdate Change fields, status, phase, edges, owner, metadata. status: "blocked" takes a blockedReason; "abandoned" closes a task that won't be done; "deleted" removes the task.
→ Task #1 created successfully: Add repository layer with tests
→ #1 [pending] Add repository layer with tests
→ Updated task #1 status

Phases: TaskCreate/TaskUpdate take an optional phase ("Foundation", "Release"…). Tasks with phases are grouped in the widget under roman-numeral headers (I. Foundation (1/3)), in the order phases first appear.

Lifecycle: pending → in_progress → completed, plus blocked (with a reason) and abandoned (closed, never blocks others); status: "deleted" on update performs a hard delete (edges cleaned, ids never reused). Mark a task in_progress when you start it and completed the moment it's done — the tool descriptions coach the model on this.

Dependencies: addBlocks / addBlockedBy maintain both sides automatically. Cycles, self-references, and dangling ids are rejected with errors naming the offender, and a rejected update leaves no partial state.

The widget and the pill

  • Above-editor widget — unfinished work only: ◼ in progress (spinner while the agent works), ◻ pending, ⊘ blocked with its reason. Completed/abandoned task rows disappear immediately; no history is deleted. The Todos (done/total) header includes retained history. Phase headings use roman numerals and full phase counts. In compact mode, maxVisible budgets body lines including phase headings; title and one overflow/history summary add at most two more lines. Tiny budgets favor a task row over a heading. showAll expands unfinished work only. An empty store renders nothing; an all-closed store shows counts/history summary. animations: false disables the spinner.
  • Footer pill — ✓ 2/5 todos (or ◼ 1 active · 1/2 todos) via pi's keyed status API. Renders under pi-statusline and any footer that shows extension statuses; cleared on shutdown.

/todo command

A full-screen menu overlay (same pattern as the family's pickers): groups for Tasks, Actions (create, clear completed, clear all), and Settings, with / search, Tab group cycling, and inline task actions:

  • s start · c complete · d delete · B block · A abandon — on the selected task (A/B are capitals, so a lowercase first letter still starts a search)
  • Enter on Actions/Settings activates or cycles the value
  • Esc backs out of search, then closes

Compact terminals (< 40 columns or < 16 rows) get a single-column fallback.

Storage

Tasks are session-scoped by default and live in a JSON file keyed by scope. Tools and menu actions stage a private mutation, persist it atomically (temp + rename with retry), and only then publish it to the widget/pill. A failed save leaves committed state unchanged; retrying does not duplicate an unsaved create. Task tools require explicit status updates: the extension does not infer completion from assistant prose.

Legacy explicit scope overrides remain available for compatibility:

Scope File Behavior
session (default) ~/.pi/agent/todo/sessions/<project>/<session-id>.json Per-session; survives /reload and resume; new/forked sessions start fresh
project <workspace>/.pi/todo/tasks.json Reused sequentially across project sessions; refreshed at turn boundaries, not continuously synchronized
global ~/.pi/agent/todo/global.json Reused sequentially across projects; refreshed at turn boundaries, not continuously synchronized
memory — In-memory only; nothing written

Use one active Pi process per session/store. Every read surface re-reads the current file without writing: TaskGet/TaskList, goal snapshots, /todo (overlay and non-TUI fallback), and the widget/pill at agent_start and agent_settled. Another writer's committed change is therefore visible by the next turn boundary, not only after a task tool call. A failed boundary read keeps the last committed state instead of publishing bad bytes; TaskGet/TaskList still fail explicitly on a malformed or unsupported file. There is still no background file watcher, merge or cross-process locking. A boundary refresh updates the store's conflict baseline, so a mutation made after it builds on the current file instead of being rejected as stale (with autoClearCompleted: on_list_complete, an idle settlement can therefore also clear work another process completed). A write that was already in flight when another writer landed is still rejected: read the fresh state with TaskGet/TaskList and issue a NEW change (or /reload). An old write is never refreshed or retried automatically, and Pi’s in-process file mutation queue is not a cross-process lock.

A scope change that cannot open its destination keeps the previous store live, rolls the setting back and reports the error, so the pill never keeps showing the old scope's counts. A write already queued when a scope change succeeds still lands in the previous scope's file: the tool reports success there, but the task will not appear in the newly selected scope. Wait for the tool result before cycling Scope.

Every store also has a durable opaque scopeId (persisted as optional storeId, format version stays 1) because numeric task ids are unique only inside one store generation. A new store gets a UUID, reopening the same file keeps it, and a deleted/recreated file gets a new one. Opening a legacy file writes nothing; the identity is saved by the next normal successful commit (including a guarded link). Older readers ignore the extra field, but an older writer that rewrites the file can drop it and break durable goal links — reconcile or update such writers.

Ids are monotonic (highWaterMark) — never reused, even after the list empties and reopens. A session that ends with incomplete tasks leaves its file behind; session stores sweep sibling files untouched for 14+ days so dead sessions never accumulate. A corrupt store starts empty rather than crashing the session.

Configuration

Optional. Global defaults in ~/.pi/agent/pi-todo.json, project overrides in <workspace>/.pi/pi-todo.json (project wins key by key; the /todo Settings menu writes project overrides only). A missing or malformed file falls back to defaults.

{
  "scope": "session",
  "maxVisible": 10,
  "showAll": false,
  "autoClearCompleted": "never",
  "reminderTurns": null,
  "animations": true
}
Setting Values Default Behavior
scope memory / session / project / global session Where task state lives (see Storage)
maxVisible 1–100 10 Body-line budget, including phase headings; title/summary are extra
showAll bool false Ignore the body budget, show every unfinished task
autoClearCompleted never / on_list_complete never Opt-in removal after the whole list closes and two successful idle settled runs pass; task activity/aborts reset the count
reminderTurns null / 1–200 null Request-local hidden reminder nudging TaskList after this many model turns without task-tool use
animations boolean true In-progress spinner. Also in /todo → Settings

Upgrading a local checkout to 0.3.0

This installation can load a local package path directly: npm updates do not replace it. After updating this checkout/dependencies, run /reload or restart Pi to load the new code. No task files or personal configuration need to be deleted or migrated; the stored format remains version 1.

Widget behavior changes: closed rows move out of the widget into /todo history, and existing maxVisible values now budget body lines (phase headings included), not task rows. Session scope remains the default. TaskList/TaskGet and /todo still expose completed and abandoned tasks. Existing explicit project/global/memory overrides are not silently reset.

Goal orchestration provider (optional)

pi-todo can act as a read-only task provider for a goal-orchestration extension (for example @timmyjl12/pi-goal). Nothing here is required for normal use: without a consumer, the bridge registers no listeners and the tools behave exactly as documented above.

  • Provider id pi-todo. Listeners are installed only in session_start, after the task store is open, and removed synchronously at session_shutdown or before a scope-driven store replacement. A rebind starts a new revision generation; old revisions and listeners are invalid immediately. No discovery is broadcast from the factory.
  • Discovery on goal:tasks:discover replies { version: 1, sessionId, requestId, providerId: "pi-todo", label, capabilities: ["snapshot", "guarded-task-tools"], toolNames: ["TaskCreate", "TaskGet", "TaskList", "TaskUpdate"], mutationBinding: { kind: "task-tools-v1", createTool: "TaskCreate", updateTool: "TaskUpdate" } }.
  • Requests on goal:tasks:request accept operation: "snapshot" only. Every other operation gets { success: false, … } and changes nothing — the event bus never mutates tasks.
  • Snapshot reply { success: true, version: 1, sessionId, requestId, providerId, scopeId, scopeKind?, revision, tasks: [{ id, subject, description, status, goalId?, metadata?, owner?, updatedAt? }] }. requestId echoes the request (the goal client rejects replies without it, including failures). scopeId is the durable task-store identity: numeric task ids are unique only inside it, so a link must capture it and a different scope is a different task. scopeKind (memory/session/project/global) is included when this session's own configuration is known. The provider waits for a read-only refresh of current disk state before replying; if that file is malformed, unreadable or unsupported (including bytes already loaded earlier), the request fails explicitly instead of serving cached content. Tasks are plain bounded clones (long descriptions carry an explicit …[truncated] marker; oversized rosters add truncated: true). goalId copies metadata.goalId; metadata is a bounded clone of ONLY the goal bookkeeping keys goalId/goalActionId, so a consumer can reconcile a guarded create without receiving unrelated task metadata.
  • Revision is g<instance UUID>.<generation>:<content digest>: a unique provider-instance identity prevents fresh-reload collisions, while the binding generation (bumped on every bind and every committed store notification, including native tool and menu commits) plus a SHA-256 digest of the current in-memory content and the store scopeId. A revision captured before a queued mutation is therefore stale when the mutation lands, and two task generations with identical text can never share a revision.
  • Advisory hints goal:tasks:ready and goal:tasks:changed carry { version: 1, sessionId, providerId, revision }. They only tell a consumer to refresh; they never contain work and never block startup.
  • Malformed/foreign requests are handled per the family envelope: another session, provider id or protocol version is ignored; a matching but malformed request (missing request id, unsupported operation, aborted signal) gets a failure reply.

Guarded task tools

The ORIGINAL TaskCreate and TaskUpdate accept an optional goalGuard { version: 1, sessionId, goalId, expectedScopeId, expectedRevision, operation: "create" | "update" | "link", operationId }. The guard is validated by pi-todo inside the same synchronous TaskStore.commit draft callback that applies the mutation — after the write queue and after pi's permission hooks — and only while a provider binding is live in that exact session. expectedScopeId is checked BEFORE any dedupe ledger or revision match, so an action captured for one store generation can never mutate or deduplicate a different one with the same numeric task id. A malformed guard, an unavailable provider, a foreign session/scope, a stale revision, an aborted caller signal or a disallowed field throws a real tool error and saves nothing. A committed guarded call acknowledges details.goalGuard = { operation, goalId, operationId, scopeId, deduped } alongside the structured task target; scopeId is the scope the action actually committed in, even if the provider rebinds afterwards. Ordinary calls that omit goalGuard keep their original behavior and result shape.

Operation Allowed through the guard Refused
create (TaskCreate) subject, description; pi-todo stamps metadata.goalId and metadata.goalActionId phase, owner, activeForm, caller metadata, dependency arrays
update (TaskUpdate) subject, description, status (pending/in_progress/completed/blocked), blockedReason; the task must already carry the same metadata.goalId delete, abandoned, phase, owner, metadata, dependencies
link (TaskUpdate) an explicit link of an existing task that is unlinked or already linked to the same goal; adds goal metadata stealing a task linked to another goal, any other field

operationId is idempotent per goal and scope: a repeated create/link is deduplicated (persisted in metadata.goalActionId, so it survives reload); a repeated update is deduplicated within the live binding. A persisted marker written before the operation kind was recorded (legacy metadata with goalActionId but no goalActionOperation) only deduplicates an explicit create retry with the identical sanitized subject/description — it is never reinterpreted as an update or link. A persisted failure is reported and leaves the last committed state and the snapshot revision untouched — there is no phantom mutation.

Design boundaries

  • No subagent execution (TaskExecute/TaskOutput/TaskStop) — agent orchestration belongs to pi-subagents / pi-fusion in this family; task tracking stays decoupled from it.
  • No shared team lists — one writer per store; multi-session coordination over a single file is a locking surface deliberately left out.
  • English only — no i18n layer.

Development

npm install
npm run typecheck
npm test
npm pack --dry-run

Current API guidance: Extensions, Packages, and TUI. Mutations use withFileMutationQueue; reminders use turn_end/context_with_system; opt-in cleanup uses agent_settled; session resources drain in idempotent session_shutdown. Host-provided Pi packages stay wildcard peers, not bundled runtime dependencies.

License

MIT — see LICENSE.