@pi-archimedes/todo 2.10.4 → 2.11.1

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 CHANGED
@@ -44,7 +44,7 @@ Two operations, `write` and `read`:
44
44
  "todoList": [
45
45
  { "content": "Parse config files", "description": "Read and validate settings", "status": "completed" },
46
46
  { "content": "Build state manager", "description": "Implement reactive store", "status": "in_progress" },
47
- { "content": "Connect UI widget", "description": "Bind to core bus events", "status": "pending" }
47
+ { "content": "Connect UI widget", "description": "Bind to the shared event channels", "status": "pending" }
48
48
  ]
49
49
  }
50
50
  ```
@@ -68,6 +68,6 @@ When every task reaches `completed`, the widget shows a brief confirmation, then
68
68
 
69
69
  ## Part of the suite
70
70
 
71
- With subagents running alongside in the [full suite](https://github.com/danielcherubini/pi-archimedes), each child gets its own named column to the right of yours — it appears on the child's first non-empty todo update over core's bus (empty updates are ignored; it does not appear when the worker merely starts), and it is removed when the child exits. The 2-second auto-clear is local to whatever list completed — a child clearing its own list does not dismiss the column in your session. On/off is managed by the suite: toggle via `/plugins` (`archimedes.todo.enabled`, default on).
71
+ With subagents running alongside in the [full suite](https://github.com/danielcherubini/pi-archimedes), each child gets its own named column to the right of yours — it appears on the child's first non-empty todo update over the host's event bus, `pi.events` (empty updates are ignored; it does not appear when the worker merely starts), and it is removed when the child exits. The 2-second auto-clear is local to whatever list completed — a child clearing its own list does not dismiss the column in your session. On/off is managed by the suite: toggle via `/plugins` (`archimedes.todo.enabled`, default on).
72
72
 
73
73
  ← [Back to pi-archimedes](https://github.com/danielcherubini/pi-archimedes)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-archimedes/todo",
3
- "version": "2.10.4",
3
+ "version": "2.11.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/danielcherubini/pi-archimedes.git"
@@ -19,17 +19,19 @@
19
19
  "./prepare-args": "./src/prepare-args.ts"
20
20
  },
21
21
  "dependencies": {
22
- "@pi-archimedes/core": "2.10.4"
22
+ "@pi-archimedes/core": "2.11.1"
23
23
  },
24
24
  "peerDependencies": {
25
- "@earendil-works/pi-ai": ">=0.1.0",
26
- "@earendil-works/pi-coding-agent": ">=0.1.0",
27
- "@earendil-works/pi-tui": ">=0.1.0"
25
+ "@earendil-works/pi-ai": ">=0.99.0",
26
+ "@earendil-works/pi-coding-agent": ">=0.99.0",
27
+ "@earendil-works/pi-tui": ">=0.99.0",
28
+ "typebox": ">=1.1.0"
28
29
  },
29
30
  "devDependencies": {
30
- "@earendil-works/pi-ai": "^0.87.0",
31
- "@earendil-works/pi-coding-agent": "^0.87.0",
32
- "@earendil-works/pi-tui": "^0.87.0",
31
+ "@earendil-works/pi-ai": "1.2.0",
32
+ "@earendil-works/pi-coding-agent": "1.2.0",
33
+ "@earendil-works/pi-tui": "1.2.0",
34
+ "typebox": "^1.1.38",
33
35
  "typescript": "^6.0.0"
34
36
  },
35
37
  "pi": {
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Cross-package transport: `subagent` emits, `todo` receives — on ONE host
3
+ * event bus, which is the property the retired `globalThis` bus used to
4
+ * provide by being process-wide.
5
+ *
6
+ * This is the unit-level shape of the headless proof in the migration report:
7
+ * two packages, two `ExtensionAPI` handles, one delivery. The bus object here
8
+ * stands in for the host's `createEventBus()`; what it must not be is a third
9
+ * package — nothing may own the transport any more.
10
+ *
11
+ * Red-first note: before the migration this failed because both sides talked to
12
+ * `getBus()` (the `globalThis` singleton) and ignored the handle handed to
13
+ * them, so the shared bus below saw nothing.
14
+ */
15
+ import { describe, it, expect } from "vitest";
16
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
17
+ import { registerTodo } from "./index.js";
18
+
19
+ /** The channel literal — the wire contract, not an import, on purpose. */
20
+ const TODOS_UPDATE = "archimedes:todos_update";
21
+ const TODOS_CLEAR = "archimedes:todos_clear";
22
+
23
+ function fakeBus() {
24
+ const listeners = new Map<string, Array<(data: unknown) => void>>();
25
+ return {
26
+ emit(channel: string, data: unknown): void {
27
+ for (const handler of [...(listeners.get(channel) ?? [])]) handler(data);
28
+ },
29
+ on(channel: string, handler: (data: unknown) => void): () => void {
30
+ const subs = listeners.get(channel) ?? [];
31
+ subs.push(handler);
32
+ listeners.set(channel, subs);
33
+ return () => {
34
+ const current = listeners.get(channel) ?? [];
35
+ const idx = current.indexOf(handler);
36
+ if (idx !== -1) current.splice(idx, 1);
37
+ };
38
+ },
39
+ listenerCount(channel: string): number {
40
+ return (listeners.get(channel) ?? []).length;
41
+ },
42
+ };
43
+ }
44
+
45
+ function fakePi(bus: ReturnType<typeof fakeBus>) {
46
+ const handlers = new Map<string, Array<(e: unknown, ctx: unknown) => unknown>>();
47
+ const captured: { widget?: unknown; tools: Record<string, any> } = { tools: {} };
48
+ const ctx = {
49
+ ui: { setWidget: (_id: string, factory: unknown) => { captured.widget = factory; } },
50
+ sessionManager: { getBranch: () => [] },
51
+ };
52
+ const pi = {
53
+ on(event: string, handler: (e: unknown, ctx: unknown) => unknown) {
54
+ const list = handlers.get(event) ?? [];
55
+ list.push(handler);
56
+ handlers.set(event, list);
57
+ return () => {
58
+ const current = handlers.get(event) ?? [];
59
+ const idx = current.indexOf(handler);
60
+ if (idx !== -1) current.splice(idx, 1);
61
+ };
62
+ },
63
+ registerTool(tool: any) {
64
+ captured.tools[tool.name] = tool;
65
+ },
66
+ registerCommand() {},
67
+ events: bus,
68
+ };
69
+ const fire = async (event: string, e: unknown = { type: event }) => {
70
+ for (const handler of [...(handlers.get(event) ?? [])]) await handler(e, ctx);
71
+ };
72
+ return { pi: pi as unknown as ExtensionAPI, fire, ctx, captured };
73
+ }
74
+
75
+ const theme: any = { fg: (_c: unknown, s: unknown) => String(s), strikethrough: (s: unknown) => String(s) };
76
+
77
+ function widgetLines(widget: unknown): string[] {
78
+ expect(typeof widget).toBe("function");
79
+ return (widget as (tui: unknown, t: unknown) => { render(w: number): string[] })(null, theme).render(200);
80
+ }
81
+
82
+ describe("todo receives subagent todos over the host bus", () => {
83
+ it("a subscriber registered before the emitter receives the update", async () => {
84
+ const bus = fakeBus();
85
+ const todo = fakePi(bus);
86
+
87
+ // todo subscribes FIRST (registration time, as it does today) ...
88
+ registerTodo(todo.pi);
89
+ await todo.fire("session_start");
90
+ expect(bus.listenerCount(TODOS_UPDATE)).toBe(1);
91
+
92
+ // ... and only then does a producer emit, from another package's handle.
93
+ bus.emit(TODOS_UPDATE, {
94
+ source: "subagent:explorer",
95
+ todos: [{ content: "Read the bridge", status: "in_progress" }],
96
+ });
97
+
98
+ const lines = widgetLines(todo.captured.widget).join("\n");
99
+ expect(lines).toContain("Read the bridge");
100
+ // The widget renders the agent NAME for a subagent column, not the raw
101
+ // "subagent:<name>" source — that mapping is the todo package's contract.
102
+ expect(lines).toContain("subagent (explorer)");
103
+ });
104
+
105
+ it("TODOS_CLEAR drops the column", async () => {
106
+ const bus = fakeBus();
107
+ const todo = fakePi(bus);
108
+ registerTodo(todo.pi);
109
+ await todo.fire("session_start");
110
+ // A main todo keeps the widget installed, so the assertion below is about
111
+ // the subagent column disappearing and not about the widget going away.
112
+ await todo.captured.tools.manage_todo_list.execute(
113
+ "c1",
114
+ { operation: "write", todoList: [{ content: "Keep the bar up", status: "pending" }] },
115
+ undefined,
116
+ undefined,
117
+ todo.ctx as any,
118
+ );
119
+
120
+ bus.emit(TODOS_UPDATE, { source: "subagent:explorer", todos: [{ content: "Temp", status: "pending" }] });
121
+ expect(widgetLines(todo.captured.widget).join("\n")).toContain("Temp");
122
+
123
+ bus.emit(TODOS_CLEAR, { source: "subagent:explorer" });
124
+ const after = widgetLines(todo.captured.widget).join("\n");
125
+ expect(after).not.toContain("Temp");
126
+ expect(after).toContain("Keep the bar up");
127
+ });
128
+
129
+ /**
130
+ * The AGENTS.md accumulation rule, made observable. Two `session_start`s in
131
+ * one session must not stack a second subscription — that is how a per-session
132
+ * `events.on()` double-delivers.
133
+ */
134
+ it("two session_starts do not cause two deliveries of one event", async () => {
135
+ const bus = fakeBus();
136
+ const todo = fakePi(bus);
137
+ registerTodo(todo.pi);
138
+ await todo.fire("session_start");
139
+ await todo.fire("session_start");
140
+ expect(bus.listenerCount(TODOS_UPDATE)).toBe(1);
141
+
142
+ const seen: unknown[] = [];
143
+ bus.on(TODOS_UPDATE, (p) => seen.push(p));
144
+ bus.emit(TODOS_UPDATE, { source: "subagent:x", todos: [{ content: "Once", status: "pending" }] });
145
+ expect(seen).toHaveLength(1);
146
+ });
147
+
148
+ it("session_shutdown unsubscribes, so a dead session stops receiving", async () => {
149
+ const bus = fakeBus();
150
+ const todo = fakePi(bus);
151
+ registerTodo(todo.pi);
152
+ await todo.fire("session_start");
153
+ await todo.fire("session_shutdown", { type: "session_shutdown", reason: "new" });
154
+ expect(bus.listenerCount(TODOS_UPDATE)).toBe(0);
155
+ expect(bus.listenerCount(TODOS_CLEAR)).toBe(0);
156
+ });
157
+
158
+ /**
159
+ * Three session generations on one handle, the pessimistic shape: pi gives
160
+ * each session its own bus today (a fresh resource loader per session), but a
161
+ * `session_start` also fires twice per replacement in RPC mode, and a dropped
162
+ * unsubscribe is invisible until something accumulates. Each generation gets a
163
+ * fresh `registerTodo` (pi re-runs the factory), two `session_start`s, and one
164
+ * emit — and must see exactly one delivery, with nothing left alive at the end.
165
+ */
166
+ it("three generations on one bus: one delivery each, nothing left subscribed", async () => {
167
+ const bus = fakeBus();
168
+ for (let generation = 1; generation <= 3; generation += 1) {
169
+ const todo = fakePi(bus);
170
+ registerTodo(todo.pi);
171
+ await todo.fire("session_start");
172
+ await todo.fire("session_start");
173
+ expect(bus.listenerCount(TODOS_UPDATE)).toBe(1);
174
+ expect(bus.listenerCount(TODOS_CLEAR)).toBe(1);
175
+
176
+ const seen: unknown[] = [];
177
+ const unsub = bus.on(TODOS_UPDATE, (p) => seen.push(p));
178
+ bus.emit(TODOS_UPDATE, {
179
+ source: `subagent:gen${generation}`,
180
+ todos: [{ content: `gen${generation}`, status: "pending" }],
181
+ });
182
+ expect(seen).toHaveLength(1);
183
+ unsub();
184
+
185
+ await todo.fire("session_shutdown", { type: "session_shutdown", reason: "new" });
186
+ expect(bus.listenerCount(TODOS_UPDATE)).toBe(0);
187
+ expect(bus.listenerCount(TODOS_CLEAR)).toBe(0);
188
+ }
189
+ });
190
+ });
191
+
192
+ describe("todo's own writes reach the bus a subscriber already holds", () => {
193
+ it("the tool emits TODOS_UPDATE on the handle it was given", async () => {
194
+ const bus = fakeBus();
195
+ const todo = fakePi(bus);
196
+ registerTodo(todo.pi);
197
+ await todo.fire("session_start");
198
+
199
+ const seen: unknown[] = [];
200
+ bus.on(TODOS_UPDATE, (p) => seen.push(p));
201
+
202
+ await todo.captured.tools.manage_todo_list.execute(
203
+ "c1",
204
+ { operation: "write", todoList: [{ content: "Write the bus out", status: "pending" }] },
205
+ undefined,
206
+ undefined,
207
+ todo.ctx as any,
208
+ );
209
+
210
+ expect(seen).toHaveLength(1);
211
+ expect((seen[0] as { source: string }).source).toBe("main");
212
+ expect((seen[0] as { todos: unknown[] }).todos).toEqual([
213
+ { content: "Write the bus out", status: "pending" },
214
+ ]);
215
+ });
216
+ });
package/src/index.test.ts CHANGED
@@ -9,14 +9,37 @@
9
9
  */
10
10
  import { afterAll, beforeAll, describe, expect, it } from "vitest";
11
11
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
- import { getBus, Events } from "@pi-archimedes/core/bus";
12
+ import { Events } from "@pi-archimedes/core/events";
13
13
  import { registerTodo } from "./index.js";
14
14
 
15
+ /** Stands in for the host's `createEventBus()` (the `pi.events` contract). */
16
+ function fakeBus() {
17
+ const listeners = new Map<string, Array<(data: unknown) => void>>();
18
+ return {
19
+ emit(channel: string, data: unknown): void {
20
+ for (const handler of [...(listeners.get(channel) ?? [])]) handler(data);
21
+ },
22
+ on(channel: string, handler: (data: unknown) => void): () => void {
23
+ const subs = listeners.get(channel) ?? [];
24
+ subs.push(handler);
25
+ listeners.set(channel, subs);
26
+ return () => {
27
+ const current = listeners.get(channel) ?? [];
28
+ const idx = current.indexOf(handler);
29
+ if (idx !== -1) current.splice(idx, 1);
30
+ };
31
+ },
32
+ };
33
+ }
34
+
15
35
  describe("todo index — bus consumer normalization", () => {
16
36
  const captured: Record<string, unknown> = {};
17
37
  const handlers: Record<string, Function> = {};
18
38
  const registered: { tool?: any; commands: Record<string, unknown> } = { commands: {} };
19
39
 
40
+ /** The handle `registerTodo` subscribes on and the tool emits on. */
41
+ const bus = fakeBus();
42
+
20
43
  const pi = {
21
44
  on(evt: string, fn: Function) {
22
45
  handlers[evt] = fn;
@@ -27,6 +50,7 @@ describe("todo index — bus consumer normalization", () => {
27
50
  registerCommand(name: string, def: unknown) {
28
51
  registered.commands[name] = def;
29
52
  },
53
+ events: bus,
30
54
  } as unknown as ExtensionAPI;
31
55
 
32
56
  const ctx = {
@@ -81,7 +105,7 @@ describe("todo index — bus consumer normalization", () => {
81
105
  expect(result.isError).toBeFalsy();
82
106
 
83
107
  // Legacy (old-schema) subagent payload: id/title/not-started.
84
- getBus().emit(Events.TODOS_UPDATE, {
108
+ bus.emit(Events.TODOS_UPDATE, {
85
109
  source: "subagent:fake1",
86
110
  todos: [{ id: 1, title: "Old shape task", status: "not-started" }],
87
111
  });
@@ -96,8 +120,8 @@ describe("todo index — bus consumer normalization", () => {
96
120
  it("ignores unrecoverable payloads without crashing (no phantom column)", () => {
97
121
  // State from the previous test persists (same registered instance).
98
122
  expect(() => {
99
- getBus().emit(Events.TODOS_UPDATE, { source: "subagent:fake2", todos: "garbage" });
100
- getBus().emit(Events.TODOS_UPDATE, {
123
+ bus.emit(Events.TODOS_UPDATE, { source: "subagent:fake2", todos: "garbage" });
124
+ bus.emit(Events.TODOS_UPDATE, {
101
125
  source: "subagent:fake2",
102
126
  todos: [{ status: "pending" }],
103
127
  });
@@ -110,7 +134,7 @@ describe("todo index — bus consumer normalization", () => {
110
134
  });
111
135
 
112
136
  it("TODOS_CLEAR removes the subagent column", () => {
113
- getBus().emit(Events.TODOS_CLEAR, { source: "subagent:fake1" });
137
+ bus.emit(Events.TODOS_CLEAR, { source: "subagent:fake1" });
114
138
 
115
139
  const lines = renderLines();
116
140
  const joined = lines.join("\n");
@@ -138,7 +162,7 @@ describe("todo index — bus consumer normalization", () => {
138
162
 
139
163
  // Per-child source now carries a suffix; the header must still render
140
164
  // just the agent name, not the uuid.
141
- getBus().emit(Events.TODOS_UPDATE, {
165
+ bus.emit(Events.TODOS_UPDATE, {
142
166
  source: "subagent:fake1:aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
143
167
  todos: [{ id: 1, title: "Old shape task", status: "not-started" }],
144
168
  });
@@ -152,7 +176,7 @@ describe("todo index — bus consumer normalization", () => {
152
176
  });
153
177
 
154
178
  afterAll(() => {
155
- // Unsubscribes the bus listeners registered by registerTodo and
179
+ // Unsubscribes the handle listeners registered by registerTodo and
156
180
  // clears state (fixture uses a pending item, so no auto-clear timer
157
181
  // is pending, but cancel defensively).
158
182
  handlers.session_shutdown?.(undefined, ctx as any);
package/src/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
- import { getBus, Events } from "@pi-archimedes/core/bus";
2
+ import { Events } from "@pi-archimedes/core/events";
3
3
  import { TodoStateManager } from "./state-manager.js";
4
4
  import { createManageTodoListTool } from "./tool.js";
5
5
  import { normalizeTodoItems } from "./prepare-args.js";
@@ -22,8 +22,13 @@ export function registerTodo(pi: ExtensionAPI): void {
22
22
  }
23
23
  };
24
24
 
25
- // Subscribe to bus events for subagent todos
26
- const unsubTodosUpdate = getBus().on(Events.TODOS_UPDATE, (payload: unknown) => {
25
+ // Subscribe on the host handle for subagent todos. Registered here, at
26
+ // extension-load time, not inside a session handler: the host drops these
27
+ // subscriptions when the runtime is invalidated, and the factories run again
28
+ // for the next session, so this is exactly once per session — and it keeps
29
+ // the subscription live before the first turn, which is when a child can
30
+ // first report todos. `session_shutdown` below is its counterpart.
31
+ const unsubTodosUpdate = pi.events.on(Events.TODOS_UPDATE, (payload: unknown) => {
27
32
  const data = payload as { source: string; todos: TodoItem[] };
28
33
  if (data.source === "main") return; // main handled locally
29
34
  // The consumer is the guarantee: producers (subagent stream, older
@@ -40,7 +45,7 @@ export function registerTodo(pi: ExtensionAPI): void {
40
45
  });
41
46
  unsubscribes.push(unsubTodosUpdate);
42
47
 
43
- const unsubTodosClear = getBus().on(Events.TODOS_CLEAR, (payload: unknown) => {
48
+ const unsubTodosClear = pi.events.on(Events.TODOS_CLEAR, (payload: unknown) => {
44
49
  const data = payload as { source: string };
45
50
  subagentTodos.delete(data.source);
46
51
  refreshWidget();
@@ -80,7 +85,7 @@ export function registerTodo(pi: ExtensionAPI): void {
80
85
  });
81
86
 
82
87
  // Register the manage_todo_list tool
83
- const tool = createManageTodoListTool(state, refreshWidget);
88
+ const tool = createManageTodoListTool(state, refreshWidget, pi.events);
84
89
  pi.registerTool(tool);
85
90
 
86
91
  // Register /todos command
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Task 11 item 2 — the machine-readable half of `manage_todo_list`.
3
+ *
4
+ * pi 1.2.0 hands a codemode script the tool's `structuredContent` instead of its
5
+ * text, but only when the tool declares an `outputSchema`
6
+ * (`pi: dist/extensions/codemode/execute.js`, `toScriptValue()`), and it never
7
+ * validates one against the other. So the schema is a promise with no runtime
8
+ * referee: these tests are the referee. Every payload below is produced by
9
+ * running the real handler, and checked against the schema the same registered
10
+ * tool declares — not against a hand-copied shape.
11
+ *
12
+ * The failure paths are asserted too, and one of them is a decision rather than
13
+ * a fact: a failure result carries NO structured content, so a script keeps
14
+ * rejecting with the text it rejects with today. See `tool.ts`.
15
+ */
16
+
17
+ import { describe, it, expect } from "vitest";
18
+ import { Value } from "typebox/value";
19
+ import { createManageTodoListTool } from "./tool.js";
20
+ import { TodoStateManager } from "./state-manager.js";
21
+
22
+ const noopEvents = { emit: () => {}, on: () => () => {} } as never;
23
+
24
+ function harness() {
25
+ const tool = createManageTodoListTool(new TodoStateManager(), () => {}, noopEvents);
26
+ return {
27
+ tool,
28
+ call: (params: Record<string, unknown>) =>
29
+ tool.execute("call-1", params as never, undefined, undefined, {} as never),
30
+ };
31
+ }
32
+
33
+ /** Validate a real result's structured content against the schema on the tool. */
34
+ function check(tool: { outputSchema?: unknown }, value: unknown): boolean {
35
+ expect(tool.outputSchema, "tool declares no outputSchema").toBeDefined();
36
+ return Value.Check(tool.outputSchema as never, value);
37
+ }
38
+
39
+ describe("manage_todo_list outputSchema declaration", () => {
40
+ it("declares an output schema shaped like the structured content", () => {
41
+ const { tool } = harness();
42
+ expect(tool.outputSchema).toBeDefined();
43
+ expect((tool.outputSchema as { properties?: Record<string, unknown> }).properties).toHaveProperty("todos");
44
+ });
45
+ });
46
+
47
+ describe("manage_todo_list structuredContent", () => {
48
+ it("read with an empty list → { operation: read, todos: [] }, valid against the schema", async () => {
49
+ const { tool, call } = harness();
50
+ const result = await call({ operation: "read" });
51
+ expect(result.structuredContent).toEqual({ operation: "read", todos: [] });
52
+ expect(check(tool, result.structuredContent)).toBe(true);
53
+ });
54
+
55
+ it("read after a write → the stored list, items exactly as the model wrote them", async () => {
56
+ const { tool, call } = harness();
57
+ await call({
58
+ operation: "write",
59
+ todoList: [
60
+ { content: "One", status: "pending" },
61
+ { content: "Two", status: "in_progress", description: "with context" },
62
+ ],
63
+ });
64
+ const result = await call({ operation: "read" });
65
+ expect(result.structuredContent).toEqual({
66
+ operation: "read",
67
+ todos: [
68
+ { content: "One", status: "pending" },
69
+ { content: "Two", status: "in_progress", description: "with context" },
70
+ ],
71
+ });
72
+ expect(check(tool, result.structuredContent)).toBe(true);
73
+ });
74
+
75
+ it("write success → { operation: write, todos } matching the schema, content text untouched", async () => {
76
+ const { tool, call } = harness();
77
+ const result = await call({
78
+ operation: "write",
79
+ todoList: [{ content: "Ship it", status: "completed" }],
80
+ });
81
+ expect(result.isError).toBeUndefined();
82
+ expect(result.structuredContent).toEqual({
83
+ operation: "write",
84
+ todos: [{ content: "Ship it", status: "completed" }],
85
+ });
86
+ expect(check(tool, result.structuredContent)).toBe(true);
87
+ // The model-facing text is unchanged: models and this suite's other tests read it.
88
+ expect(result.content[0]!.text).toContain("1/1 completed");
89
+ });
90
+
91
+ it("write without a todoList → isError, and NO structured content (scripts keep rejecting with the text)", async () => {
92
+ const { call } = harness();
93
+ const result = await call({ operation: "write" });
94
+ expect(result.isError).toBe(true);
95
+ expect(result.content[0]!.text).toBe("Error: todoList is required for write operation.");
96
+ expect("structuredContent" in result).toBe(false);
97
+ });
98
+
99
+ it("write that fails validation → isError, no structured content, content text untouched", async () => {
100
+ const { call } = harness();
101
+ const result = await call({
102
+ operation: "write",
103
+ todoList: [{ content: " ", status: "pending" }],
104
+ });
105
+ expect(result.isError).toBe(true);
106
+ expect(result.content[0]!.text).toContain("Validation failed:");
107
+ expect("structuredContent" in result).toBe(false);
108
+ });
109
+
110
+ it("write of an empty list → { operation: write, todos: [] }, valid (a cleared list is an answer)", async () => {
111
+ const { tool, call } = harness();
112
+ const result = await call({ operation: "write", todoList: [] });
113
+ expect(result.isError).toBeUndefined();
114
+ expect(result.structuredContent).toEqual({ operation: "write", todos: [] });
115
+ expect(check(tool, result.structuredContent)).toBe(true);
116
+ });
117
+ });
package/src/tool.test.ts CHANGED
@@ -22,7 +22,12 @@ const theme = {
22
22
  strikethrough: (text: unknown) => `[s]${text}`,
23
23
  } as unknown as Theme;
24
24
 
25
- const tool = createManageTodoListTool(new TodoStateManager(), () => {});
25
+ // The tool emits on the handle it is handed; this one has no listeners, so the
26
+ // emits are inert here (the emit contract lives in `host-bus.test.ts`).
27
+ const tool = createManageTodoListTool(new TodoStateManager(), () => {}, {
28
+ emit: () => {},
29
+ on: () => () => {},
30
+ });
26
31
 
27
32
  function renderLines(result: AgentToolResult<TodoDetails | undefined>, expanded: boolean): string {
28
33
  const view = tool.renderResult(result, { expanded, isPartial: false }, theme) as {
package/src/tool.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  import { type Static, StringEnum, Type } from "@earendil-works/pi-ai";
2
- import type { AgentToolResult, ExtensionContext, Theme, ToolRenderResultOptions } from "@earendil-works/pi-coding-agent";
2
+ import type { AgentToolResult, EventBus, ExtensionContext, Theme, ToolRenderResultOptions } from "@earendil-works/pi-coding-agent";
3
3
  import { Text } from "@earendil-works/pi-tui";
4
- import { getBus, Events } from "@pi-archimedes/core/bus";
4
+ import { Events, emitOn } from "@pi-archimedes/core/events";
5
5
  import { renderToolHeader, renderStatusLabel } from "@pi-archimedes/core/tool-render";
6
6
  import { prepareTodoArguments } from "./prepare-args.js";
7
7
  import type { TodoStateManager } from "./state-manager.js";
8
8
  import { STATUS_ICONS } from "./types.js";
9
- import type { TodoDetails } from "./types.js";
9
+ import type { TodoDetails, TodoItem, TodoStatus } from "./types.js";
10
10
 
11
11
  const TodoItemSchema = Type.Object({
12
12
  content: Type.String({
@@ -33,6 +33,55 @@ export const ManageTodoListParams = Type.Object({
33
33
 
34
34
  export type ManageTodoListInput = Static<typeof ManageTodoListParams>;
35
35
 
36
+ /**
37
+ * The machine-readable result, for programmatic callers.
38
+ *
39
+ * `structuredContent` mirrors `details` on success — the list as it now stands,
40
+ * which is the whole answer for both operations. pi hands it to a codemode
41
+ * script *instead of* the text, but only when the tool declares this schema, and
42
+ * it never checks one against the other (`pi: dist/extensions/codemode/execute.js`,
43
+ * `toScriptValue()`), so `tool.output-schema.test.ts` is the referee: it runs the
44
+ * handler and validates the payload against this schema.
45
+ *
46
+ * Failure results carry none of it. pi's own `bash` does attach structured
47
+ * content to a non-zero exit, because there the failing command *is* the
48
+ * observation; here every failure is a call that produced no observation (no
49
+ * list supplied, or a list the validator rejected), and pi's codemode script
50
+ * keeps rejecting with the text — same as before this schema existed.
51
+ */
52
+ const TodoItemOutputSchema = Type.Object({
53
+ content: Type.String(),
54
+ status: StringEnum(["pending", "in_progress", "completed"] as const),
55
+ description: Type.Optional(Type.String()),
56
+ });
57
+
58
+ export const ManageTodoListOutput = Type.Object({
59
+ /** Which operation ran; the shape is the same for both. */
60
+ operation: StringEnum(["read", "write"] as const),
61
+ /** The list as it stands after the call. */
62
+ todos: Type.Array(TodoItemOutputSchema),
63
+ });
64
+
65
+ /**
66
+ * The JSON-shaped copy of a stored item. `TodoItem` is an interface, which
67
+ * TypeScript will not match against `JsonValue`'s index signature, and copying
68
+ * also pins the payload to exactly the three fields the schema declares.
69
+ */
70
+ type TodoItemOutput = { content: string; status: TodoStatus; description?: string };
71
+
72
+ function toOutputTodos(todos: TodoItem[]): TodoItemOutput[] {
73
+ return todos.map((item) => ({
74
+ content: item.content,
75
+ status: item.status,
76
+ ...(item.description !== undefined && { description: item.description }),
77
+ }));
78
+ }
79
+
80
+ /** `{ operation, todos }` — the successful result of either operation. */
81
+ function todoOutput(operation: "read" | "write", todos: TodoItem[]): { operation: "read" | "write"; todos: TodoItemOutput[] } {
82
+ return { operation, todos: toOutputTodos(todos) };
83
+ }
84
+
36
85
  export const TOOL_DESCRIPTION = `Manage a structured todo list to track progress and plan tasks throughout your coding session. Use this tool VERY frequently to ensure task visibility and proper planning.
37
86
 
38
87
  When to use this tool:
@@ -69,12 +118,22 @@ Todo states:
69
118
  IMPORTANT: Mark todos completed as soon as they are done. Do not batch completions.
70
119
  When all todos are completed, the list auto-clears after a brief delay.`;
71
120
 
72
- export function createManageTodoListTool(state: TodoStateManager, onUpdate: () => void) {
121
+ /**
122
+ * Build the `manage_todo_list` tool.
123
+ *
124
+ * @param events the caller's host handle (`pi.events`). The tool is a factory,
125
+ * not an extension, so it has no `pi` of its own — the handle is
126
+ * injected rather than reached for, which is what keeps the suite
127
+ * free of a `globalThis` bus. A child pi runs the same tool with
128
+ * its OWN handle, and its writes stay inside the child.
129
+ */
130
+ export function createManageTodoListTool(state: TodoStateManager, onUpdate: () => void, events: EventBus) {
73
131
  return {
74
132
  name: "manage_todo_list",
75
133
  label: "Todo List",
76
134
  description: TOOL_DESCRIPTION,
77
135
  parameters: ManageTodoListParams,
136
+ outputSchema: ManageTodoListOutput,
78
137
 
79
138
  // Repair common model mistakes (stringified arrays, missing
80
139
  // title/status, bare-string items) BEFORE schema validation. See
@@ -100,6 +159,7 @@ export function createManageTodoListTool(state: TodoStateManager, onUpdate: () =
100
159
  },
101
160
  ],
102
161
  details: { operation: "read", todos } as TodoDetails,
162
+ structuredContent: todoOutput("read", todos),
103
163
  };
104
164
  }
105
165
 
@@ -125,12 +185,11 @@ export function createManageTodoListTool(state: TodoStateManager, onUpdate: () =
125
185
  state.write(todoList);
126
186
  onUpdate();
127
187
 
128
- // Emit bus event for widget update
129
- try {
130
- getBus().emit(Events.TODOS_UPDATE, { source: "main", todos: state.read() });
131
- } catch {
132
- // Bus may not be initialized in subagent child process — ignore
133
- }
188
+ // Announce the new list on the handle: the bridge relays it to the
189
+ // desktop as a `todos_update` frame (and any other in-process listener,
190
+ // e.g. a second todo instance, sees the same payload). `onUpdate()` above
191
+ // is the local widget path; this is the outward one.
192
+ emitOn(events, Events.TODOS_UPDATE, { source: "main", todos: state.read() });
134
193
 
135
194
  const stats = state.getStats();
136
195
  const todos = state.read();
@@ -144,6 +203,7 @@ export function createManageTodoListTool(state: TodoStateManager, onUpdate: () =
144
203
  return {
145
204
  content: [{ type: "text" as const, text: message }],
146
205
  details: { operation: "write", todos } as TodoDetails,
206
+ structuredContent: todoOutput("write", todos),
147
207
  };
148
208
  },
149
209