@xynogen/pix-todo 0.1.1 → 0.1.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-todo",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Pi tool — durable execution checklist (todo)",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/once.ts ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Per-instance idempotency guard for extension activation.
3
+ *
4
+ * pix-core (the meta-package) invokes this package's factory, and a standalone
5
+ * install makes Pi invoke it again — sometimes against the SAME `pi`. We must
6
+ * dedupe that. But Pi rebuilds the extension runtime on /new, /resume, /fork,
7
+ * and /reload, handing the factory a BRAND-NEW `pi`; that must re-register.
8
+ *
9
+ * Keying the registry on the `pi` instance satisfies both: same instance =>
10
+ * skip, new instance => run. The registry lives on globalThis because jiti
11
+ * (`moduleCache: false`) re-evaluates this module on every load pass, so a
12
+ * module-scoped WeakMap would not be shared between the aggregator pass and the
13
+ * standalone pass within a single session.
14
+ */
15
+ export function once(pi: object, key: string, fn: () => void): void {
16
+ const g = globalThis as { __pixOnce?: WeakMap<object, Set<string>> };
17
+ const registry = (g.__pixOnce ??= new WeakMap<object, Set<string>>());
18
+ let loaded = registry.get(pi);
19
+ if (!loaded) {
20
+ loaded = new Set<string>();
21
+ registry.set(pi, loaded);
22
+ }
23
+ if (loaded.has(key)) return;
24
+ loaded.add(key);
25
+ fn();
26
+ }
package/src/todo.test.ts CHANGED
@@ -1,6 +1,14 @@
1
- import { describe, expect, test } from "bun:test";
1
+ import { beforeEach, describe, expect, test } from "bun:test";
2
2
  import registerTodo, { renderTodoLines, type TodoItem } from "./todo.ts";
3
3
 
4
+ // registerTodo wraps its body in once(pi, "pix-todo") — a per-instance
5
+ // WeakMap guard that dedupes activation across pix-core + a standalone install.
6
+ // Tests re-register a fresh host per case. Clear the registry between tests so
7
+ // that the same pi object can be re-used without cross-test interference.
8
+ beforeEach(() => {
9
+ delete (globalThis as { __pixOnce?: WeakMap<object, Set<string>> }).__pixOnce;
10
+ });
11
+
4
12
  // Stub theme tags each fragment with its color/bold so assertions can verify
5
13
  // which status got which tint, without depending on real ANSI codes.
6
14
  const tagTheme = {
package/src/todo.ts CHANGED
@@ -13,6 +13,8 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
13
  import { Text } from "@earendil-works/pi-tui";
14
14
  import { Type } from "typebox";
15
15
 
16
+ import { once } from "./once.ts";
17
+
16
18
  export type TodoStatus = "pending" | "in_progress" | "done" | "blocked";
17
19
 
18
20
  export interface TodoItem {
@@ -67,152 +69,154 @@ const parseItems = (raw: string): string[] =>
67
69
  .filter(Boolean);
68
70
 
69
71
  export default function registerTodo(pi: ExtensionAPI): void {
70
- let todos: TodoItem[] = [];
71
- let nextTodoId = 1;
72
-
73
- function persistTodos() {
74
- pi.appendEntry("todo-state", { todos, nextTodoId });
75
- }
76
-
77
- function todoSummary(): string {
78
- if (!todos.length) return "(no todos)";
79
- const done = todos.filter((t) => t.status === "done").length;
80
- const lines = todos.map(
81
- (t) => `${TODO_GLYPH[t.status]} ${t.id}. ${t.text}`,
82
- );
83
- return `Todos ${done}/${todos.length} done:\n${lines.join("\n")}`;
84
- }
85
-
86
- // Durable execution checklist for BUILD mode. Survives context compaction
87
- // and session restore. Workflows like plan instruct the model to seed it
88
- // from a plan's "Implementation Phases" so it stays anchored to plan.md.
89
- pi.registerTool({
90
- name: "todo",
91
- label: "Todo",
92
- description:
93
- "Track BUILD-phase execution progress. Durable across context compaction. Actions: list, set (replace all items from newline/numbered text), add, update (change one item's status), clear.",
94
- promptSnippet:
95
- "todo(action, items?, id?, status?, text?) — action: list|set|add|update|clear. Use to track implementation progress, especially when executing a plan.",
96
- promptGuidelines: [
97
- "When you start executing a multi-step plan in BUILD mode, seed the todo list with `todo(action:'set', items: <plan Implementation Phases>)`.",
98
- "Mark each item in_progress before working it and done when finished via `todo(action:'update', id, status)`.",
99
- "Call `todo(action:'list')` to recover your place after long runs or context compaction.",
100
- ],
101
- parameters: Type.Object({
102
- action: Type.Union(
103
- [
104
- Type.Literal("list"),
105
- Type.Literal("set"),
106
- Type.Literal("add"),
107
- Type.Literal("update"),
108
- Type.Literal("clear"),
109
- ],
110
- { description: "Operation to perform" },
111
- ),
112
- items: Type.Optional(
113
- Type.String({
114
- description:
115
- "For set/add: newline-separated or numbered list of todo texts.",
116
- }),
117
- ),
118
- id: Type.Optional(
119
- Type.Number({ description: "For update: target todo id." }),
120
- ),
121
- status: Type.Optional(
122
- Type.Union(
72
+ once(pi, "pix-todo", () => {
73
+ let todos: TodoItem[] = [];
74
+ let nextTodoId = 1;
75
+
76
+ function persistTodos() {
77
+ pi.appendEntry("todo-state", { todos, nextTodoId });
78
+ }
79
+
80
+ function todoSummary(): string {
81
+ if (!todos.length) return "(no todos)";
82
+ const done = todos.filter((t) => t.status === "done").length;
83
+ const lines = todos.map(
84
+ (t) => `${TODO_GLYPH[t.status]} ${t.id}. ${t.text}`,
85
+ );
86
+ return `Todos ${done}/${todos.length} done:\n${lines.join("\n")}`;
87
+ }
88
+
89
+ // Durable execution checklist for BUILD mode. Survives context compaction
90
+ // and session restore. Workflows like plan instruct the model to seed it
91
+ // from a plan's "Implementation Phases" so it stays anchored to plan.md.
92
+ pi.registerTool({
93
+ name: "todo",
94
+ label: "Todo",
95
+ description:
96
+ "Track BUILD-phase execution progress. Durable across context compaction. Actions: list, set (replace all items from newline/numbered text), add, update (change one item's status), clear.",
97
+ promptSnippet:
98
+ "todo(action, items?, id?, status?, text?) — action: list|set|add|update|clear. Use to track implementation progress, especially when executing a plan.",
99
+ promptGuidelines: [
100
+ "When you start executing a multi-step plan in BUILD mode, seed the todo list with `todo(action:'set', items: <plan Implementation Phases>)`.",
101
+ "Mark each item in_progress before working it and done when finished via `todo(action:'update', id, status)`.",
102
+ "Call `todo(action:'list')` to recover your place after long runs or context compaction.",
103
+ ],
104
+ parameters: Type.Object({
105
+ action: Type.Union(
123
106
  [
124
- Type.Literal("pending"),
125
- Type.Literal("in_progress"),
126
- Type.Literal("done"),
127
- Type.Literal("blocked"),
107
+ Type.Literal("list"),
108
+ Type.Literal("set"),
109
+ Type.Literal("add"),
110
+ Type.Literal("update"),
111
+ Type.Literal("clear"),
128
112
  ],
129
- { description: "For update: new status." },
113
+ { description: "Operation to perform" },
130
114
  ),
131
- ),
132
- text: Type.Optional(
133
- Type.String({
134
- description: "For update: replacement text (optional).",
135
- }),
136
- ),
137
- }),
138
- renderResult(_result, _options, theme) {
139
- return new Text(renderTodoLines(todos, theme as TodoTheme), 0, 0);
140
- },
141
-
142
- async execute(_id, params) {
143
- // AgentToolResult now requires a `details` field. These todo results have
144
- // no structured details, so emit `undefined` via small local helpers.
145
- const ok = (text: string) => ({
146
- content: [{ type: "text" as const, text }],
147
- details: undefined,
148
- });
149
- const fail = (text: string) => ({
150
- content: [{ type: "text" as const, text }],
151
- details: undefined,
152
- isError: true,
153
- });
154
- switch (params.action) {
155
- case "list":
156
- return ok(todoSummary());
157
-
158
- case "set": {
159
- const texts = parseItems(params.items ?? "");
160
- if (!texts.length) return fail("set requires non-empty `items`.");
161
- nextTodoId = 1;
162
- todos = texts.map((text) => ({
163
- id: nextTodoId++,
164
- text,
165
- status: "pending" as TodoStatus,
166
- }));
167
- persistTodos();
168
- return ok(todoSummary());
169
- }
170
-
171
- case "add": {
172
- const texts = parseItems(params.items ?? "");
173
- if (!texts.length) return fail("add requires non-empty `items`.");
174
- for (const text of texts)
175
- todos.push({ id: nextTodoId++, text, status: "pending" });
176
- persistTodos();
177
- return ok(todoSummary());
178
- }
179
-
180
- case "update": {
181
- const t = todos.find((x) => x.id === params.id);
182
- if (!t) return fail(`No todo with id ${params.id}.`);
183
- if (params.status) t.status = params.status;
184
- if (params.text) t.text = params.text;
185
- persistTodos();
186
- return ok(todoSummary());
115
+ items: Type.Optional(
116
+ Type.String({
117
+ description:
118
+ "For set/add: newline-separated or numbered list of todo texts.",
119
+ }),
120
+ ),
121
+ id: Type.Optional(
122
+ Type.Number({ description: "For update: target todo id." }),
123
+ ),
124
+ status: Type.Optional(
125
+ Type.Union(
126
+ [
127
+ Type.Literal("pending"),
128
+ Type.Literal("in_progress"),
129
+ Type.Literal("done"),
130
+ Type.Literal("blocked"),
131
+ ],
132
+ { description: "For update: new status." },
133
+ ),
134
+ ),
135
+ text: Type.Optional(
136
+ Type.String({
137
+ description: "For update: replacement text (optional).",
138
+ }),
139
+ ),
140
+ }),
141
+ renderResult(_result, _options, theme) {
142
+ return new Text(renderTodoLines(todos, theme as TodoTheme), 0, 0);
143
+ },
144
+
145
+ async execute(_id, params) {
146
+ // AgentToolResult now requires a `details` field. These todo results have
147
+ // no structured details, so emit `undefined` via small local helpers.
148
+ const ok = (text: string) => ({
149
+ content: [{ type: "text" as const, text }],
150
+ details: undefined,
151
+ });
152
+ const fail = (text: string) => ({
153
+ content: [{ type: "text" as const, text }],
154
+ details: undefined,
155
+ isError: true,
156
+ });
157
+ switch (params.action) {
158
+ case "list":
159
+ return ok(todoSummary());
160
+
161
+ case "set": {
162
+ const texts = parseItems(params.items ?? "");
163
+ if (!texts.length) return fail("set requires non-empty `items`.");
164
+ nextTodoId = 1;
165
+ todos = texts.map((text) => ({
166
+ id: nextTodoId++,
167
+ text,
168
+ status: "pending" as TodoStatus,
169
+ }));
170
+ persistTodos();
171
+ return ok(todoSummary());
172
+ }
173
+
174
+ case "add": {
175
+ const texts = parseItems(params.items ?? "");
176
+ if (!texts.length) return fail("add requires non-empty `items`.");
177
+ for (const text of texts)
178
+ todos.push({ id: nextTodoId++, text, status: "pending" });
179
+ persistTodos();
180
+ return ok(todoSummary());
181
+ }
182
+
183
+ case "update": {
184
+ const t = todos.find((x) => x.id === params.id);
185
+ if (!t) return fail(`No todo with id ${params.id}.`);
186
+ if (params.status) t.status = params.status;
187
+ if (params.text) t.text = params.text;
188
+ persistTodos();
189
+ return ok(todoSummary());
190
+ }
191
+
192
+ case "clear":
193
+ todos = [];
194
+ nextTodoId = 1;
195
+ persistTodos();
196
+ return ok("Todos cleared.");
197
+
198
+ default:
199
+ return fail(`Unknown action: ${String(params.action)}`);
187
200
  }
188
-
189
- case "clear":
190
- todos = [];
191
- nextTodoId = 1;
192
- persistTodos();
193
- return ok("Todos cleared.");
194
-
195
- default:
196
- return fail(`Unknown action: ${String(params.action)}`);
201
+ },
202
+ });
203
+
204
+ // Restore the checklist from session entries so it survives restart.
205
+ pi.on("session_start", async (_event, ctx) => {
206
+ const entries = ctx.sessionManager.getEntries() as Array<{
207
+ type: string;
208
+ customType?: string;
209
+ data?: { todos?: TodoItem[]; nextTodoId?: number };
210
+ }>;
211
+ const lastTodo = entries
212
+ .filter((e) => e.type === "custom" && e.customType === "todo-state")
213
+ .pop();
214
+ if (Array.isArray(lastTodo?.data?.todos)) {
215
+ todos = lastTodo.data.todos;
216
+ nextTodoId =
217
+ lastTodo.data.nextTodoId ??
218
+ todos.reduce((m, t) => Math.max(m, t.id + 1), 1);
197
219
  }
198
- },
199
- });
200
-
201
- // Restore the checklist from session entries so it survives restart.
202
- pi.on("session_start", async (_event, ctx) => {
203
- const entries = ctx.sessionManager.getEntries() as Array<{
204
- type: string;
205
- customType?: string;
206
- data?: { todos?: TodoItem[]; nextTodoId?: number };
207
- }>;
208
- const lastTodo = entries
209
- .filter((e) => e.type === "custom" && e.customType === "todo-state")
210
- .pop();
211
- if (Array.isArray(lastTodo?.data?.todos)) {
212
- todos = lastTodo.data.todos;
213
- nextTodoId =
214
- lastTodo.data.nextTodoId ??
215
- todos.reduce((m, t) => Math.max(m, t.id + 1), 1);
216
- }
220
+ });
217
221
  });
218
222
  }