@oisin-ee/pi-todo 0.3.0 → 0.5.0

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
@@ -42,6 +42,36 @@ Expansion stays open through task updates. Session changes and `/tree` navigatio
42
42
  When no task is active, a short status and the shortcut hint remain visible.
43
43
  Outside the terminal UI, `/todos` prints the list instead.
44
44
 
45
+ ## Shared work display
46
+
47
+ When a compatible pi-wrangle extension is active, its `WORK` strip replaces the standalone todo widget.
48
+ Tasks remain on the current Pi session branch. Wrangle receives read-only snapshots through `pi.events`.
49
+ `/todos` and Alt+T request the shared Todos view. `/todos list` still prints the full list.
50
+ When shared presentation ends, the standalone widget returns without changing task state.
51
+
52
+ The [version 1 display contract](https://github.com/oisin-ee/wrangle/blob/main/docs/pi-work.md)
53
+ defines the snapshot request, acknowledgement, release, and read-only view events.
54
+
55
+ ## Plannotator plans
56
+
57
+ With Plannotator external execution, pi-todo continues approved plans in the same session.
58
+ Add this setting to `~/.pi/agent/plannotator.json`:
59
+
60
+ ```json
61
+ {
62
+ "executionMode": "external"
63
+ }
64
+ ```
65
+
66
+ After approval, the agent reads the current todo list and adds the remaining approved work before implementation.
67
+ It preserves unrelated tasks and completed progress. Free-form plans do not need a special checklist format.
68
+ The todo tool owns progress, persistence, reminders, and the shared work display.
69
+ Plannotator returns to idle and does not maintain a second checklist or require `[DONE:n]` markers.
70
+
71
+ This integration uses the supported `plannotator:plan-approved` event, verified with Plannotator `0.28.19`.
72
+ It does not import Plannotator internals or change its code.
73
+ Malformed events, a different working directory, or another owner of the `todo` tool stop the handoff with an error.
74
+
45
75
  ## Reminder
46
76
 
47
77
  When the agent stops with open tasks, pi-todo adds a hidden reminder that lists them and continues the turn.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oisin-ee/pi-todo",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "A todo tool for pi with a Rust core: one active task, state on the session branch.",
5
5
  "keywords": [
6
6
  "pi-extension",
package/src/index.ts CHANGED
@@ -1,17 +1,26 @@
1
- import { ownsTodo, settle, show } from "./lifecycle.ts";
1
+ import { ownsTodo, settle } from "./lifecycle.ts";
2
2
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import { PlannotatorHandoff } from "./plannotator.ts";
3
4
  import { StringEnum } from "@earendil-works/pi-ai";
4
- import { TODO_SHORTCUT } from "./widget.ts";
5
5
  import { TodoPresentation } from "./presentation.ts";
6
+ import { TodoWork } from "./work.ts";
6
7
  import { Type } from "typebox";
8
+ import type { WorkHost } from "./work.ts";
7
9
  import { runTodo } from "./tool.ts";
8
10
 
9
- type TodoAPI = Readonly<
10
- Pick<
11
- ExtensionAPI,
12
- "appendEntry" | "getAllTools" | "on" | "registerCommand" | "registerShortcut" | "registerTool"
13
- >
14
- >;
11
+ type TodoAPI = WorkHost &
12
+ Readonly<
13
+ Pick<
14
+ ExtensionAPI,
15
+ | "appendEntry"
16
+ | "getAllTools"
17
+ | "on"
18
+ | "registerCommand"
19
+ | "registerShortcut"
20
+ | "registerTool"
21
+ | "sendMessage"
22
+ >
23
+ >;
15
24
 
16
25
  const items = Type.Immutable(
17
26
  Type.Array(Type.String(), { description: "For init or append: tasks, in order." }),
@@ -32,13 +41,10 @@ const items = Type.Immutable(
32
41
  * @param {TodoAPI} pi The extension API methods used here.
33
42
  */
34
43
  export default function piTodo(pi: TodoAPI): void {
35
- const presentation = new TodoPresentation();
36
- pi.registerShortcut(TODO_SHORTCUT, {
37
- description: "Expand or collapse upcoming and done todos.",
38
- handler(ctx) {
39
- presentation.toggle(pi, ctx);
40
- },
41
- });
44
+ const handoff = new PlannotatorHandoff(pi),
45
+ presentation = new TodoPresentation(),
46
+ work = new TodoWork(pi, presentation);
47
+ work.register(pi);
42
48
  pi.registerTool({
43
49
  description:
44
50
  "Keep the session todo list. Tasks are addressed by their exact content. One task is active; the next open task starts when it closes.",
@@ -49,6 +55,7 @@ export default function piTodo(pi: TodoAPI): void {
49
55
  },
50
56
  branch: () => ctx.sessionManager.getBranch(),
51
57
  });
58
+ work.publish(ctx);
52
59
  presentation.refresh(ctx);
53
60
  return result;
54
61
  },
@@ -59,23 +66,18 @@ export default function piTodo(pi: TodoAPI): void {
59
66
  });
60
67
  pi.on("session_start", (_event, ctx) => {
61
68
  presentation.start(pi, ctx);
69
+ work.start(ctx);
70
+ handoff.start(ctx);
71
+ });
72
+ pi.on("session_shutdown", () => {
73
+ handoff.stop();
74
+ work.stop();
62
75
  });
63
76
  pi.on("session_tree", (_event, ctx) => {
64
77
  if (ownsTodo(pi)) {
78
+ work.publish(ctx);
65
79
  presentation.reset(ctx);
66
80
  }
67
81
  });
68
82
  pi.on("agent_before_settle", (event, ctx) => settle(pi, event, ctx));
69
- pi.registerCommand("todos", {
70
- description: "Toggle todo details; /todos list prints the full branch list.",
71
- async handler(args, ctx) {
72
- if (ownsTodo(pi)) {
73
- if (args.trim() === "list" || ctx.mode !== "tui") {
74
- show(ctx);
75
- } else {
76
- presentation.toggle(pi, ctx);
77
- }
78
- }
79
- },
80
- });
81
83
  }
@@ -0,0 +1,107 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import type { TodoContext } from "./lifecycle.ts";
3
+ import type { WorkHost } from "./work.ts";
4
+ import { ownsTodo } from "./lifecycle.ts";
5
+
6
+ /** The supported Plannotator external-execution event. */
7
+ interface ApprovedPlan {
8
+ readonly cwd: string;
9
+ readonly planFilePath: string;
10
+ readonly planContent: string;
11
+ readonly feedback?: string;
12
+ }
13
+
14
+ type PlanHost = WorkHost & Readonly<Pick<ExtensionAPI, "sendMessage">>;
15
+ type PlanContext = TodoContext & { readonly cwd: string };
16
+
17
+ /**
18
+ * Validates the event before it enters the model context.
19
+ * @param {unknown} value The shared event payload.
20
+ * @returns {boolean} Whether the required handoff fields are present.
21
+ */
22
+ const isApprovedPlan = (value: unknown): value is ApprovedPlan =>
23
+ typeof value === "object" &&
24
+ value !== null &&
25
+ "cwd" in value &&
26
+ typeof value.cwd === "string" &&
27
+ "planFilePath" in value &&
28
+ typeof value.planFilePath === "string" &&
29
+ value.planFilePath.trim() !== "" &&
30
+ "planContent" in value &&
31
+ typeof value.planContent === "string" &&
32
+ value.planContent.trim() !== "" &&
33
+ (!("feedback" in value) || value.feedback === undefined || typeof value.feedback === "string"),
34
+ /**
35
+ * Continues the approved plan through the existing todo tool, not a second parser or store.
36
+ * @param {ApprovedPlan} plan The approved document and reviewer feedback.
37
+ * @returns {string} Instructions followed by the exact handoff data.
38
+ */
39
+ continuation = (plan: ApprovedPlan): string =>
40
+ [
41
+ "The user approved this plan in Plannotator. Continue execution in this session.",
42
+ "Use the todo tool as the only task tracker. Do not maintain Plannotator [DONE:n] markers.",
43
+ "First call todo with op=view to inspect the current branch list.",
44
+ "Before implementation, represent the approved plan's remaining work in that list.",
45
+ "If the list is empty, call todo with op=init and the remaining approved tasks in order.",
46
+ "Otherwise, reuse matching tasks and append missing tasks. Preserve unrelated work and completed progress.",
47
+ "If approved work remains, select its next open task with op=start. Never restart a completed task just because the plan was approved again.",
48
+ "If no approved work remains, report completion without changing tasks. Otherwise, update tasks with op=done or op=drop as work proceeds.",
49
+ "Resolve a relative planFilePath against cwd. The approved document and reviewer feedback follow as JSON:",
50
+ JSON.stringify({
51
+ cwd: plan.cwd,
52
+ feedback: plan.feedback,
53
+ planContent: plan.planContent,
54
+ planFilePath: plan.planFilePath,
55
+ }),
56
+ ].join("\n");
57
+
58
+ /** Owns the approval subscription, never the todo list or execution progress. */
59
+ class PlannotatorHandoff {
60
+ readonly #host: PlanHost;
61
+ #unsubscribe: (() => void) | undefined;
62
+
63
+ /**
64
+ * Keeps the supported event bus and conversation API.
65
+ * @param {PlanHost} host The Pi methods used by the handoff.
66
+ */
67
+ public constructor(host: PlanHost) {
68
+ this.#host = host;
69
+ }
70
+
71
+ /**
72
+ * Subscribes with the current session context after startup or replacement.
73
+ * @param {PlanContext} ctx The current branch and working directory.
74
+ */
75
+ public start(ctx: PlanContext): void {
76
+ this.stop();
77
+ this.#unsubscribe = this.#host.events.on(
78
+ "plannotator:plan-approved",
79
+ (value: unknown): void => {
80
+ if (!isApprovedPlan(value)) {
81
+ ctx.ui.notify("pi-todo: Invalid Plannotator approval. No execution started.", "error");
82
+ } else if (value.cwd !== ctx.cwd) {
83
+ ctx.ui.notify(
84
+ "pi-todo: The approved plan belongs to another working directory. No execution started.",
85
+ "error",
86
+ );
87
+ } else if (ownsTodo(this.#host)) {
88
+ this.#host.sendMessage(
89
+ { content: continuation(value), customType: "todo-plan-approved", display: true },
90
+ { deliverAs: "followUp", triggerTurn: true },
91
+ );
92
+ } else {
93
+ ctx.ui.notify("pi-todo: Another extension owns todo. No execution started.", "error");
94
+ }
95
+ },
96
+ );
97
+ }
98
+
99
+ /** Removes the previous session's listener before replacement or shutdown. */
100
+ public stop(): void {
101
+ this.#unsubscribe?.();
102
+ this.#unsubscribe = undefined;
103
+ }
104
+ }
105
+
106
+ export { PlannotatorHandoff };
107
+ export type { PlanContext, PlanHost };
@@ -4,13 +4,16 @@ import { ownsTodo, replay, start } from "./lifecycle.ts";
4
4
  /** Owns transient expansion for one extension instance, never task state. */
5
5
  class TodoPresentation {
6
6
  #expanded = false;
7
+ #external = false;
7
8
 
8
9
  /**
9
10
  * Replays current branch tasks without changing expansion.
10
11
  * @param {TodoContext} ctx Current branch and UI.
11
12
  */
12
13
  public refresh(ctx: TodoContext): void {
13
- replay(ctx, this.#expanded);
14
+ if (!this.#external) {
15
+ replay(ctx, this.#expanded);
16
+ }
14
17
  }
15
18
 
16
19
  /**
@@ -29,9 +32,28 @@ class TodoPresentation {
29
32
  */
30
33
  public start(pi: TodoHost, ctx: TodoContext): void {
31
34
  this.#expanded = false;
35
+ this.#external = false;
32
36
  start(pi, ctx);
33
37
  }
34
38
 
39
+ /**
40
+ * Yields only this extension's widget to an acknowledged work presenter.
41
+ * @param {TodoContext} ctx Current terminal UI.
42
+ */
43
+ public claim(ctx: TodoContext): void {
44
+ this.#external = true;
45
+ ctx.ui.setWidget("pi-todo", undefined);
46
+ }
47
+
48
+ /**
49
+ * Restores the standalone view without changing task state.
50
+ * @param {TodoContext} ctx Current terminal UI.
51
+ */
52
+ public release(ctx: TodoContext): void {
53
+ this.#external = false;
54
+ this.refresh(ctx);
55
+ }
56
+
35
57
  /**
36
58
  * Reveals or hides secondary groups without writing session state.
37
59
  * @param {TodoHost} pi Registered tool metadata.
package/src/work.ts ADDED
@@ -0,0 +1,230 @@
1
+ import type { TodoContext, TodoHost } from "./lifecycle.ts";
2
+ import { ownsTodo, show } from "./lifecycle.ts";
3
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
+ import { TODO_SHORTCUT } from "./widget.ts";
5
+ import type { TodoPresentation } from "./presentation.ts";
6
+ import { latestSnapshot } from "./state.ts";
7
+ import { parse } from "./wasm.ts";
8
+
9
+ interface WorkHost extends TodoHost {
10
+ readonly events: Readonly<ExtensionAPI["events"]>;
11
+ }
12
+
13
+ type WorkCommands = Readonly<Pick<ExtensionAPI, "registerCommand" | "registerShortcut">>;
14
+ const protocolVersion = 1,
15
+ initialRevision = 0;
16
+
17
+ interface WorkContext extends TodoContext {
18
+ readonly sessionManager: TodoContext["sessionManager"] & {
19
+ readonly getSessionId: () => string;
20
+ readonly getLeafId: () => string | null;
21
+ };
22
+ }
23
+
24
+ /** The identity of one work presenter requesting this provider's snapshot. */
25
+ interface Presenter {
26
+ readonly presenter: string;
27
+ readonly request: string;
28
+ readonly version: typeof protocolVersion;
29
+ }
30
+
31
+ /**
32
+ * Checks a presentation request without trusting an extension's event payload.
33
+ * @param {unknown} value Event payload.
34
+ * @returns {boolean} Whether the envelope uses the supported protocol.
35
+ */
36
+ const isPresenter = (value: unknown): value is Presenter =>
37
+ typeof value === "object" &&
38
+ value !== null &&
39
+ "version" in value &&
40
+ value.version === protocolVersion &&
41
+ "presenter" in value &&
42
+ typeof value.presenter === "string" &&
43
+ "request" in value &&
44
+ typeof value.request === "string";
45
+
46
+ /** Publishes validated branch data; presentation claims never write task state. */
47
+ class TodoWork {
48
+ readonly #host: WorkHost;
49
+ readonly #presentation: Readonly<TodoPresentation>;
50
+ #context: WorkContext | undefined;
51
+ #publisher = "";
52
+ #revision = initialRevision;
53
+ #presenter: Presenter | undefined;
54
+ #claimed = false;
55
+ #subscriptions: (() => void)[] = [];
56
+
57
+ /**
58
+ * Keeps the event bus and the existing standalone presentation owner.
59
+ * @param {WorkHost} host Pi event bus and tool ownership.
60
+ * @param {Readonly<TodoPresentation>} presentation Standalone display owner.
61
+ */
62
+ public constructor(host: WorkHost, presentation: Readonly<TodoPresentation>) {
63
+ this.#host = host;
64
+ this.#presentation = presentation;
65
+ }
66
+
67
+ /**
68
+ * Registers view entrypoints with a shared or standalone presentation target.
69
+ * @param {WorkCommands} commands Pi command registration.
70
+ */
71
+ public register(commands: WorkCommands): void {
72
+ commands.registerShortcut(TODO_SHORTCUT, {
73
+ description: "Show todo details.",
74
+ handler: (ctx): void => {
75
+ this.#toggle(ctx);
76
+ },
77
+ });
78
+ commands.registerCommand("todos", {
79
+ description: "Show todo details; /todos list prints the full branch list.",
80
+ handler: async (args, ctx): Promise<void> => {
81
+ if (ownsTodo(this.#host)) {
82
+ if (args.trim() === "list" || ctx.mode !== "tui") {
83
+ show(ctx);
84
+ } else {
85
+ this.#toggle(ctx);
86
+ }
87
+ }
88
+ },
89
+ });
90
+ }
91
+
92
+ /**
93
+ * Opens the shared view or toggles the existing standalone view.
94
+ * @param {TodoContext} ctx Current UI and branch.
95
+ */
96
+ #toggle(ctx: TodoContext): void {
97
+ if (!this.open()) {
98
+ this.#presentation.toggle(this.#host, ctx);
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Subscribes only when this extension owns the terminal todo tool.
104
+ * @param {WorkContext} ctx Current Pi session.
105
+ */
106
+ public start(ctx: WorkContext): void {
107
+ this.stop();
108
+ if (ctx.mode !== "tui" || !ownsTodo(this.#host)) {
109
+ return;
110
+ }
111
+ this.#context = ctx;
112
+ this.#publisher = crypto.randomUUID();
113
+ this.#revision = initialRevision;
114
+ this.#subscriptions = [
115
+ this.#host.events.on("oisin.work.request", (value: unknown): void => {
116
+ if (isPresenter(value)) {
117
+ this.#presenter = value;
118
+ if (this.#context !== undefined) {
119
+ this.publish(this.#context);
120
+ }
121
+ }
122
+ }),
123
+ this.#host.events.on("oisin.work.claim", (value: unknown): void => {
124
+ this.#claim(value);
125
+ }),
126
+ this.#host.events.on("oisin.work.release", (value: unknown): void => {
127
+ if (this.#matches(value)) {
128
+ this.#release();
129
+ }
130
+ }),
131
+ ];
132
+ this.#host.events.emit("oisin.todo.ready", { publisher: this.#publisher, version: 1 });
133
+ }
134
+
135
+ /**
136
+ * Publishes the actual branch snapshot after tool or branch changes.
137
+ * @param {WorkContext} ctx Current Pi session, never a stored file's last entry.
138
+ */
139
+ public publish(ctx: WorkContext): void {
140
+ if (this.#context === undefined || this.#presenter === undefined) {
141
+ return;
142
+ }
143
+ this.#context = ctx;
144
+ this.#revision += 1;
145
+ const list = parse(latestSnapshot(ctx.sessionManager.getBranch()));
146
+ this.#host.events.emit("oisin.todo.snapshot", {
147
+ branch: ctx.sessionManager.getLeafId(),
148
+ presenter: this.#presenter.presenter,
149
+ publisher: this.#publisher,
150
+ request: this.#presenter.request,
151
+ revision: this.#revision,
152
+ session: ctx.sessionManager.getSessionId(),
153
+ tasks: list.tasks,
154
+ version: 1,
155
+ });
156
+ }
157
+
158
+ /**
159
+ * Requests the read-only Todos view without changing expansion or task state.
160
+ * @returns {boolean} Whether a shared presenter owns the view.
161
+ */
162
+ public open(): boolean {
163
+ if (!this.#claimed || this.#presenter === undefined) {
164
+ return false;
165
+ }
166
+ this.#host.events.emit("oisin.work.open", {
167
+ presenter: this.#presenter.presenter,
168
+ request: this.#presenter.request,
169
+ version: 1,
170
+ view: "todos",
171
+ });
172
+ return true;
173
+ }
174
+
175
+ /** Releases subscriptions and tells the presenter to discard this provider. */
176
+ public stop(): void {
177
+ for (const unsubscribe of this.#subscriptions) {
178
+ unsubscribe();
179
+ }
180
+ this.#subscriptions = [];
181
+ if (this.#context !== undefined) {
182
+ this.#host.events.emit("oisin.todo.stop", { publisher: this.#publisher, version: 1 });
183
+ }
184
+ this.#release();
185
+ this.#context = undefined;
186
+ }
187
+
188
+ /**
189
+ * Accepts only an acknowledgement of the latest published snapshot.
190
+ * @param {unknown} value Claim envelope.
191
+ */
192
+ #claim(value: unknown): void {
193
+ if (
194
+ this.#matches(value) &&
195
+ "publisher" in value &&
196
+ value.publisher === this.#publisher &&
197
+ "revision" in value &&
198
+ value.revision === this.#revision &&
199
+ this.#context !== undefined
200
+ ) {
201
+ this.#claimed = true;
202
+ this.#presentation.claim(this.#context);
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Matches a live presenter, excluding delayed messages from replaced sessions.
208
+ * @param {unknown} value Event envelope.
209
+ * @returns {boolean} Whether the envelope belongs to the current presenter.
210
+ */
211
+ #matches(value: unknown): value is Presenter {
212
+ return (
213
+ isPresenter(value) &&
214
+ value.presenter === this.#presenter?.presenter &&
215
+ value.request === this.#presenter.request
216
+ );
217
+ }
218
+
219
+ /** Restores the standalone display when shared presentation ends. */
220
+ #release(): void {
221
+ this.#claimed = false;
222
+ this.#presenter = undefined;
223
+ if (this.#context !== undefined) {
224
+ this.#presentation.release(this.#context);
225
+ }
226
+ }
227
+ }
228
+
229
+ export { TodoWork };
230
+ export type { WorkContext, WorkHost };