@capdiem/pi-todo 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -15
- package/index.min.js.map +1 -1
- package/package.json +7 -3
package/README.md
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
# pi-todo
|
|
2
2
|
|
|
3
|
-
A minimal, atomic todo tool for the [Pi coding agent](https://pi.dev/).
|
|
4
|
-
writes the complete task plan in one call instead of issuing one
|
|
5
|
-
per task.
|
|
3
|
+
A **lightweight**, minimal, atomic todo tool for the [Pi coding agent](https://pi.dev/).
|
|
4
|
+
The model writes the complete task plan in one call instead of issuing one
|
|
5
|
+
`create` call per task.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
It is deliberately small — only the essential plan-maintenance core, with no
|
|
8
|
+
dependencies, reminders, settings menus, or collapse/expand. The live plan
|
|
9
|
+
renders as a read-only widget above Pi's input box, and when hosted by
|
|
10
|
+
[pi-agent-desktop](https://github.com/abcwyc/pi-agent-desktop) it also appears
|
|
11
|
+
in the desktop's **left-nav sidebar** todo panel with zero extra configuration
|
|
12
|
+
(desktop-side support is upcoming — see
|
|
13
|
+
[Desktop integration](#desktop-integration)).
|
|
14
|
+
|
|
15
|
+
A good starting point if you want to understand or extend a todo tool without
|
|
16
|
+
the production hardening of a full extension.
|
|
11
17
|
|
|
12
18
|
## Features
|
|
13
19
|
|
|
@@ -22,6 +28,9 @@ without the production hardening of a full extension.
|
|
|
22
28
|
- State persists in tool-result details and survives `/reload` and `/tree`
|
|
23
29
|
- A read-only widget above Pi's input box using markdown-style glyphs
|
|
24
30
|
(`[ ]` pending, `[-]` in_progress, `[x]` completed); no collapse/expand
|
|
31
|
+
- When hosted by pi-agent-desktop, the live plan also renders in the desktop's
|
|
32
|
+
**left-nav sidebar** todo panel (the desktop-owned `pi-agent-desktop:todo`
|
|
33
|
+
widget extension point) — no extra configuration needed
|
|
25
34
|
|
|
26
35
|
## Install
|
|
27
36
|
|
|
@@ -115,15 +124,25 @@ are struck through. The widget appears only while a plan exists.
|
|
|
115
124
|
|
|
116
125
|
## Desktop integration
|
|
117
126
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
127
|
+
When hosted by [pi-agent-desktop](https://github.com/abcwyc/pi-agent-desktop)
|
|
128
|
+
(in `rpc` mode), the live plan renders in the desktop's **left-nav sidebar**
|
|
129
|
+
todo panel — a persistent checkbox list next to the chat that stays in view
|
|
130
|
+
while you work.
|
|
131
|
+
|
|
132
|
+
The plan is pushed over the desktop-owned **todo widget extension point**
|
|
133
|
+
(defined by pi-agent-desktop; see its `lib/todo-state.ts` and
|
|
121
134
|
`docs/todo-widget.md`): a `setWidget` request with the desktop-reserved key
|
|
122
|
-
`"pi-agent-desktop:todo"`, one JSON `TodoTask` per line. The desktop routes it
|
|
123
|
-
sidebar todo panel. It is fire-and-forget (not persisted);
|
|
124
|
-
opens a session, this extension's `session_start` restore
|
|
125
|
-
plan, so the panel shows the latest snapshot. The TUI
|
|
126
|
-
widget (see Rendering) and is unchanged.
|
|
135
|
+
`"pi-agent-desktop:todo"`, one JSON `TodoTask` per line. The desktop routes it
|
|
136
|
+
straight into its sidebar todo panel. It is fire-and-forget (not persisted);
|
|
137
|
+
when the desktop opens a session, this extension's `session_start` restore
|
|
138
|
+
re-emits the current plan, so the panel shows the latest snapshot. The TUI
|
|
139
|
+
keeps its own component widget (see Rendering) and is unchanged.
|
|
140
|
+
|
|
141
|
+
> **Status: the desktop-side panel is upcoming.** The todo-panel support
|
|
142
|
+
> currently lives on a feature branch in pi-agent-desktop and has not been
|
|
143
|
+
> merged into a release yet. `@capdiem/pi-todo` already conforms to the
|
|
144
|
+
> `pi-agent-desktop:todo` widget protocol, so once the panel ships you get the
|
|
145
|
+
> sidebar rendering with **zero extra configuration**.
|
|
127
146
|
|
|
128
147
|
## Persistence
|
|
129
148
|
|
package/index.min.js.map
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
|
-
"sources": ["
|
|
3
|
+
"sources": ["../../extensions/pi-todo/index.ts", "../../extensions/pi-todo/state.ts"],
|
|
4
4
|
"sourcesContent": [
|
|
5
5
|
"import { StringEnum } from \"@earendil-works/pi-ai\";\nimport type { ExtensionAPI, ExtensionContext, Theme } from \"@earendil-works/pi-coding-agent\";\nimport { Text, truncateToWidth, type TUI } from \"@earendil-works/pi-tui\";\nimport { Type } from \"typebox\";\nimport {\n MAX_TODO_TASKS,\n createEmptyTodoState,\n formatChange,\n isTodoStatus,\n isValidTodoState,\n replayTodoState,\n writeTodo,\n type TodoState,\n type TodoStatus,\n type TodoTask,\n} from \"./state.ts\";\n\nconst TODO_TOOL_NAME = \"todo\";\nconst WIDGET_KEY = \"pi-todo-widget\";\nconst STATE_CUSTOM_TYPE = \"pi-todo-state\";\n\n// Desktop-owned todo widget protocol (setWidget channel): the widget key and\n// line format are defined by pi-agent-desktop\n// (D:\\Code\\pi-agent-desktop\\lib\\todo-state.ts). This extension conforms by\n// emitting the same key and JSON-per-line payload, so the desktop can route\n// the `setWidget` request straight into its sidebar todo panel.\nconst TODO_WIDGET_KEY = \"pi-agent-desktop:todo\";\n\n/** Serialize a TodoState to widget lines (one task per JSON line) for the\n * desktop todo widget protocol. Mirrors the desktop's serializer. */\nfunction serializeTodoWidgetLines(state: TodoState): string[] {\n return state.tasks.map((task) => JSON.stringify(task));\n}\n\nconst TodoKeySchema = Type.String({\n description:\n \"Stable 1-40 character lowercase task key, e.g. inspect-api or write-tests\",\n minLength: 1,\n maxLength: 40,\n pattern: \"^[a-z0-9][a-z0-9._-]*$\",\n});\n\nconst TodoTaskSchema = Type.Object({\n key: TodoKeySchema,\n subject: Type.Optional(\n Type.String({\n description:\n \"Short imperative task subject; required for a new key, omitted to keep an existing value\",\n minLength: 1,\n maxLength: 160,\n }),\n ),\n status: Type.Optional(\n StringEnum([\"pending\", \"in_progress\", \"completed\"] as const, {\n description:\n \"Task status; required for a new key, omitted to keep an existing value\",\n }),\n ),\n});\n\nconst TodoParamsSchema = Type.Object({\n tasks: Type.Array(TodoTaskSchema, {\n description:\n \"Complete authoritative list of tasks to retain; omitted keys are deleted, existing tasks may omit unchanged fields, and new keys require subject and status\",\n maxItems: MAX_TODO_TASKS,\n }),\n baseVersion: Type.Optional(\n Type.Integer({\n description:\n \"Version shown in current todo context; rejects stale writes when provided\",\n minimum: 0,\n }),\n ),\n});\n\n/** Markdown-style glyphs: [ ] pending, [-] in_progress, [x] completed. */\nfunction todoGlyph(status: TodoStatus): string {\n return status === \"completed\" ? \"[x]\" : status === \"in_progress\" ? \"[-]\" : \"[ ]\";\n}\n\ninterface TodoDisplayTask {\n subject: string;\n status?: TodoStatus;\n}\n\nfunction renderTaskLine(task: TodoDisplayTask, theme: Theme): string {\n if (!task.status) return theme.fg(\"text\", task.subject);\n const glyph = theme.fg(\n task.status === \"completed\"\n ? \"success\"\n : task.status === \"in_progress\"\n ? \"warning\"\n : \"muted\",\n todoGlyph(task.status),\n );\n let subject = theme.fg(\n task.status === \"completed\" ? \"dim\" : \"text\",\n task.subject,\n );\n if (task.status === \"completed\") subject = theme.strikethrough(subject);\n else if (task.status === \"in_progress\") subject = theme.bold(subject);\n return `${glyph} ${subject}`;\n}\n\nfunction renderTodoWidget(state: TodoState, width: number, theme: Theme): string[] {\n if (state.tasks.length === 0) return [];\n const completed = state.tasks.filter((task) => task.status === \"completed\").length;\n const lines = [\n theme.fg(\n \"accent\",\n theme.bold(`Todo ${completed}/${state.tasks.length} completed`),\n ) + theme.fg(\"dim\", ` v${state.version}`),\n ];\n lines.push(...state.tasks.map((task) => renderTaskLine(task, theme)));\n return lines.map((line) => truncateToWidth(line, width, \"…\"));\n}\n\n/** Resolve a partially streamed `todo` call against current state for the live preview. */\nfunction resolveDraftTasks(rawTasks: unknown, state: TodoState): TodoDisplayTask[] {\n if (!Array.isArray(rawTasks)) return [];\n const currentByKey = new Map(state.tasks.map((task) => [task.key, task]));\n const tasks: TodoDisplayTask[] = [];\n for (const rawTask of rawTasks) {\n const draft =\n rawTask && typeof rawTask === \"object\"\n ? (rawTask as { key?: unknown; subject?: unknown; status?: unknown })\n : {};\n const key = typeof draft.key === \"string\" ? draft.key.trim() : \"\";\n const current = key ? currentByKey.get(key) : undefined;\n const subject =\n typeof draft.subject === \"string\" && draft.subject.trim()\n ? draft.subject.trim()\n : current?.subject;\n if (!key || !subject) continue;\n tasks.push({\n subject,\n status: isTodoStatus(draft.status) ? draft.status : current?.status,\n });\n }\n return tasks;\n}\n\nexport default function todoMiniExtension(pi: ExtensionAPI): void {\n let state = createEmptyTodoState();\n let uiContext: ExtensionContext | undefined;\n let widgetRegistered = false;\n let widgetTui: TUI | undefined;\n\n const clearWidget = (): void => {\n if (widgetRegistered && uiContext?.hasUI) {\n try {\n uiContext.ui.setWidget(WIDGET_KEY, undefined);\n } catch {}\n }\n widgetRegistered = false;\n widgetTui = undefined;\n };\n\n const updateWidget = (ctx?: ExtensionContext): void => {\n if (ctx) uiContext = ctx;\n // pi-agent-desktop todo widget protocol (RPC/desktop): push the live plan\n // over `setWidget` with the desktop-reserved key. Fire-and-forget — the\n // desktop parses the JSON lines into the sidebar panel. Cleared (undefined)\n // when the plan is empty so the panel hides. The TUI widget path below\n // stays separate and unchanged.\n if (!uiContext?.hasUI) return;\n if (uiContext.mode !== \"tui\") {\n // RPC / desktop host: live todo widget over the reserved key.\n try {\n uiContext.ui.setWidget(\n TODO_WIDGET_KEY,\n state.tasks.length > 0 ? serializeTodoWidgetLines(state) : undefined,\n );\n } catch {}\n return;\n }\n if (state.tasks.length === 0) {\n clearWidget();\n return;\n }\n if (!widgetRegistered) {\n uiContext.ui.setWidget(\n WIDGET_KEY,\n (tui: TUI, theme: Theme) => {\n widgetTui = tui;\n return {\n render: (width: number) => renderTodoWidget(state, width, theme),\n invalidate: () => {},\n dispose: () => {\n if (widgetTui === tui) widgetTui = undefined;\n },\n };\n },\n { placement: \"aboveEditor\" },\n );\n widgetRegistered = true;\n } else {\n widgetTui?.requestRender();\n }\n };\n\n pi.registerTool({\n name: TODO_TOOL_NAME,\n label: \"Todo\",\n description: `Maintain the task plan with one atomic update. Every call replaces the task list: include each key to keep, and omit a key to delete it. Existing tasks may omit unchanged fields; new tasks require subject and status. At most one task may be in_progress. Up to ${MAX_TODO_TASKS} tasks; an empty list clears the plan.`,\n promptSnippet: \"Maintain the task plan with one atomic update\",\n promptGuidelines: [\n \"When a task needs a plan of 3+ steps, define it yourself and call todo before beginning implementation or other substantive work.\",\n \"Organize the plan by work content: a coherent objective that may span multiple turns, and a plan can hold several work contents worked across different turns. Give each work content a stable slug and prefix its todos' keys with it (e.g. auth.validate-token, auth.refresh-token) so pruning can target a whole work content.\",\n \"Write task subjects in the user's language (e.g. Chinese when the user writes in Chinese). Task keys must stay lowercase ASCII slugs (a-z, 0-9, . _ -) — they are stable identities, not display text.\",\n \"The list is one flat ordered sequence, not grouped sections: order todos by execution/dependency order, and when work contents are related keep their todos in sequence in the same list rather than splitting them into separate blocks. The UI renders this as a single ordered list — there are no work-content groups or headers.\",\n \"Each todo call replaces the task list. Include every key to keep; omitted keys are deleted.\",\n \"Keep keys stable. Existing tasks may omit unchanged fields; new tasks require subject and status. Include baseVersion when available.\",\n \"Only one task may be in_progress at a time.\",\n \"Completed todos are never auto-removed. Keep them struck through as history while their work content is still open.\",\n \"Prune completed todos only when their whole work content is complete: once every todo under a work content is done, remove all of that content's todos together in the next todo call. Work contents are pruned independently — a completed work content is removed even when other work contents remain open. Turn boundaries are not cleanup points.\",\n \"While a todo plan exists, update todo immediately when task status, order, or scope changes, and reconcile actual progress before the final response. Do not issue a no-op todo call only to acknowledge a reminder.\",\n ],\n parameters: TodoParamsSchema,\n executionMode: \"sequential\",\n\n async execute(_toolCallId, params, _signal, _onUpdate, ctx) {\n state = writeTodo(state, params);\n updateWidget(ctx);\n return {\n content: [{ type: \"text\", text: formatChange(state) }],\n details: state,\n };\n },\n\n renderCall(args, theme, context) {\n const text =\n (context.lastComponent as Text | undefined) ?? new Text(\"\", 0, 0);\n const tasks = resolveDraftTasks(args.tasks, state);\n const hasTaskList = Array.isArray(args.tasks);\n const count = hasTaskList ? args.tasks.length : 0;\n const summary = hasTaskList\n ? theme.fg(\"accent\", `${count} task${count === 1 ? \"\" : \"s\"}`)\n : \"\";\n const lines = [\n [theme.fg(\"toolTitle\", theme.bold(\"todo\")), summary]\n .filter(Boolean)\n .join(\" \"),\n ];\n lines.push(...tasks.map((task) => renderTaskLine(task, theme)));\n text.setText(lines.join(\"\\n\"));\n return text;\n },\n\n renderResult(result, _opts, theme, context) {\n const text =\n (context.lastComponent as Text | undefined) ?? new Text(\"\", 0, 0);\n if (context.isError || !isValidTodoState(result.details)) {\n const output = result.content\n .filter(\n (item): item is { type: \"text\"; text: string } => item.type === \"text\",\n )\n .map((item) => item.text)\n .join(\"\\n\");\n text.setText(theme.fg(\"error\", output));\n return text;\n }\n // A successful write is already rendered by the tool call above; keep this empty.\n text.setText(\"\");\n return text;\n },\n });\n\n const restore = (ctx: ExtensionContext): void => {\n clearWidget();\n state = replayTodoState(\n { sessionManager: { getBranch: () => ctx.sessionManager.getBranch() } },\n TODO_TOOL_NAME,\n STATE_CUSTOM_TYPE,\n );\n uiContext = ctx;\n updateWidget(ctx);\n };\n\n pi.on(\"session_start\", async (_event, ctx) => restore(ctx));\n pi.on(\"session_tree\", async (_event, ctx) => restore(ctx));\n\n pi.on(\"session_shutdown\", async () => {\n clearWidget();\n uiContext = undefined;\n });\n}\n",
|
|
6
6
|
"export type TodoStatus = \"pending\" | \"in_progress\" | \"completed\";\n\nconst TODO_STATUSES: ReadonlySet<string> = new Set([\n \"pending\",\n \"in_progress\",\n \"completed\",\n]);\n\nexport const MAX_TODO_TASKS = 50;\nexport const MAX_TASK_SUBJECT_LENGTH = 160;\n\nexport interface TodoTask {\n key: string;\n subject: string;\n status: TodoStatus;\n}\n\nexport interface TodoState {\n version: number;\n tasks: TodoTask[];\n}\n\nexport interface TodoTaskInput {\n key: string;\n subject?: string;\n status?: TodoStatus;\n}\n\nexport interface TodoSnapshotInput {\n tasks: TodoTaskInput[];\n baseVersion?: number;\n}\n\nexport function isTodoStatus(value: unknown): value is TodoStatus {\n return typeof value === \"string\" && TODO_STATUSES.has(value);\n}\n\nexport function createEmptyTodoState(): TodoState {\n return { version: 0, tasks: [] };\n}\n\nexport function cloneTodoState(state: TodoState): TodoState {\n return { version: state.version, tasks: state.tasks.map((task) => ({ ...task })) };\n}\n\nfunction normalizeTaskKey(value: string, location: string): string {\n const key = value.trim();\n if (!/^[a-z0-9][a-z0-9._-]{0,39}$/.test(key)) {\n throw new Error(\n `${location} must be 1-40 lowercase ASCII letters, numbers, dots, underscores, or hyphens`,\n );\n }\n return key;\n}\n\nfunction tasksEqual(left: readonly TodoTask[], right: readonly TodoTask[]): boolean {\n if (left.length !== right.length) return false;\n for (let index = 0; index < left.length; index++) {\n const a = left[index];\n const b = right[index];\n if (a.key !== b.key || a.subject !== b.subject || a.status !== b.status) return false;\n }\n return true;\n}\n\n/**\n * Write the complete authoritative task list with one atomic update.\n *\n * Semantics:\n * - Every key present in `tasks` is retained; omitted current keys are permanently\n * deleted (there is no cancelled or archived state).\n * - Existing keys inherit omitted fields (`subject`, `status`) from their previous\n * value; a new key requires both.\n * - At most one task may be `in_progress` at a time.\n * - `baseVersion`, when provided, rejects stale writes: if it does not match the\n * current version the write fails without mutating state.\n * - All validation is all-or-nothing: a failed write never mutates state.\n * - A write that leaves the plan unchanged does not bump the version, so redundant\n * submissions do not invalidate the next write.\n */\nexport function writeTodo(state: TodoState, input: TodoSnapshotInput): TodoState {\n if (input.baseVersion !== undefined && input.baseVersion !== state.version) {\n throw new Error(\n `stale todo version: expected ${input.baseVersion}, current version is ${state.version}`,\n );\n }\n if (input.tasks.length > MAX_TODO_TASKS) {\n throw new Error(`tasks supports at most ${MAX_TODO_TASKS} items`);\n }\n\n const existingByKey = new Map(state.tasks.map((task) => [task.key, task]));\n const seenKeys = new Set<string>();\n const next: TodoTask[] = [];\n\n for (const [index, patch] of input.tasks.entries()) {\n const key = normalizeTaskKey(patch.key, `tasks[${index}].key`);\n if (seenKeys.has(key)) {\n throw new Error(`tasks[${index}].key is duplicated: ${key}`);\n }\n seenKeys.add(key);\n\n const existing = existingByKey.get(key);\n const subject = patch.subject ?? existing?.subject;\n if (subject === undefined) {\n throw new Error(`tasks[${index}].subject is required for new task ${key}`);\n }\n const trimmedSubject = subject.trim();\n if (!trimmedSubject) {\n throw new Error(`tasks[${index}].subject is required for new task ${key}`);\n }\n if (trimmedSubject.length > MAX_TASK_SUBJECT_LENGTH) {\n throw new Error(\n `tasks[${index}].subject must be at most ${MAX_TASK_SUBJECT_LENGTH} characters`,\n );\n }\n const status = patch.status ?? existing?.status;\n if (status === undefined) {\n throw new Error(`tasks[${index}].status is required for new task ${key}`);\n }\n if (!isTodoStatus(status)) {\n throw new Error(`tasks[${index}].status is invalid: ${String(status)}`);\n }\n\n next.push({ key, subject: trimmedSubject, status });\n }\n\n const active = next.filter((task) => task.status === \"in_progress\");\n if (active.length > 1) {\n throw new Error(\n `only one task may be in_progress at a time (got ${active.length}: ${active.map((task) => task.key).join(\", \")})`,\n );\n }\n\n return {\n version: tasksEqual(state.tasks, next) ? state.version : state.version + 1,\n tasks: next,\n };\n}\n\nexport function isValidTodoState(value: unknown): value is TodoState {\n if (!value || typeof value !== \"object\") return false;\n const candidate = value as { version?: unknown; tasks?: unknown };\n if (\n typeof candidate.version !== \"number\"\n || !Number.isSafeInteger(candidate.version)\n || candidate.version < 0\n ) {\n return false;\n }\n if (!Array.isArray(candidate.tasks) || candidate.tasks.length > MAX_TODO_TASKS) {\n return false;\n }\n const seen = new Set<string>();\n for (const rawTask of candidate.tasks) {\n if (!rawTask || typeof rawTask !== \"object\") return false;\n const task = rawTask as { key?: unknown; subject?: unknown; status?: unknown };\n if (\n typeof task.key !== \"string\"\n || typeof task.subject !== \"string\"\n || typeof task.subject.trim() !== \"string\"\n || !task.subject.trim()\n || task.subject.length > MAX_TASK_SUBJECT_LENGTH\n || !isTodoStatus(task.status)\n ) {\n return false;\n }\n let key: string;\n try {\n key = normalizeTaskKey(task.key, \"key\");\n } catch {\n return false;\n }\n if (seen.has(key)) return false;\n seen.add(key);\n }\n const active = (candidate.tasks as TodoTask[]).filter((task) => task.status === \"in_progress\");\n if (active.length > 1) return false;\n return true;\n}\n\n/**\n * Reconstruct the current state by replaying the branch. The latest valid tool\n * result (or a legacy hidden custom-message snapshot from an older version\n * that auto-removed completed tasks) for the `todo` tool wins.\n */\nexport function replayTodoState(\n ctx: { sessionManager: { getBranch(): Iterable<unknown> } },\n toolName: string,\n customType: string,\n): TodoState {\n let state = createEmptyTodoState();\n for (const rawEntry of ctx.sessionManager.getBranch()) {\n if (!rawEntry || typeof rawEntry !== \"object\") continue;\n const entry = rawEntry as {\n type?: unknown;\n customType?: unknown;\n details?: unknown;\n message?: { role?: unknown; toolName?: unknown; details?: unknown };\n };\n let restored: TodoState | undefined;\n\n if (\n entry.type === \"custom_message\"\n && entry.customType === customType\n && isValidTodoState(entry.details)\n ) {\n restored = entry.details;\n } else if (\n entry.type === \"message\"\n && entry.message\n && entry.message.role === \"toolResult\"\n && entry.message.toolName === toolName\n && isValidTodoState(entry.message.details)\n ) {\n restored = entry.message.details;\n }\n\n if (restored) state = cloneTodoState(restored);\n }\n return state;\n}\n\nexport function formatChange(state: TodoState): string {\n if (state.tasks.length === 0) {\n return `Todo plan cleared (version ${state.version}).`;\n }\n const lines = state.tasks.map(\n (task) => `[${task.status}] ${task.key}: ${task.subject}`,\n );\n return `Todo plan version ${state.version}:\\n${lines.join(\"\\n\")}`;\n}\n"
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@capdiem/pi-todo",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "A
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "A lightweight atomic todo tool for the Pi coding agent: one-call snapshot plans, three states, stale-write guard, plus a TUI widget and a pi-agent-desktop sidebar panel",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"pi": {
|
|
7
7
|
"extensions": [
|
|
@@ -13,7 +13,11 @@
|
|
|
13
13
|
"pi",
|
|
14
14
|
"coding-agent",
|
|
15
15
|
"todo",
|
|
16
|
-
"task-planning"
|
|
16
|
+
"task-planning",
|
|
17
|
+
"lightweight",
|
|
18
|
+
"widget",
|
|
19
|
+
"sidebar",
|
|
20
|
+
"desktop"
|
|
17
21
|
],
|
|
18
22
|
"author": "capdiem <capdiem@live.com>",
|
|
19
23
|
"license": "MIT",
|