@aaroncql/pim-agent 0.0.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.
Files changed (155) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +212 -0
  3. package/bin/pim.ts +109 -0
  4. package/package.json +49 -0
  5. package/src/extensions/_init/index.ts +109 -0
  6. package/src/extensions/bash/capture.test.ts +126 -0
  7. package/src/extensions/bash/capture.ts +80 -0
  8. package/src/extensions/bash/format.test.ts +240 -0
  9. package/src/extensions/bash/format.ts +76 -0
  10. package/src/extensions/bash/index.ts +86 -0
  11. package/src/extensions/bash/run.test.ts +262 -0
  12. package/src/extensions/bash/run.ts +207 -0
  13. package/src/extensions/bash/schema.ts +54 -0
  14. package/src/extensions/command-picker/index.ts +52 -0
  15. package/src/extensions/command-picker/ranker.test.ts +46 -0
  16. package/src/extensions/command-picker/ranker.ts +17 -0
  17. package/src/extensions/edit/edit.test.ts +285 -0
  18. package/src/extensions/edit/edit.ts +382 -0
  19. package/src/extensions/edit/index.ts +54 -0
  20. package/src/extensions/edit/schema.ts +37 -0
  21. package/src/extensions/file-picker/catalog.test.ts +263 -0
  22. package/src/extensions/file-picker/catalog.ts +219 -0
  23. package/src/extensions/file-picker/index.test.ts +168 -0
  24. package/src/extensions/file-picker/index.ts +119 -0
  25. package/src/extensions/file-picker/ranker.test.ts +94 -0
  26. package/src/extensions/file-picker/ranker.ts +76 -0
  27. package/src/extensions/footer/git.test.ts +76 -0
  28. package/src/extensions/footer/git.ts +87 -0
  29. package/src/extensions/footer/index.test.ts +161 -0
  30. package/src/extensions/footer/index.ts +148 -0
  31. package/src/extensions/footer/powerline.ts +87 -0
  32. package/src/extensions/footer/segments.test.ts +164 -0
  33. package/src/extensions/footer/segments.ts +234 -0
  34. package/src/extensions/glob/glob.test.ts +171 -0
  35. package/src/extensions/glob/glob.ts +34 -0
  36. package/src/extensions/glob/index.test.ts +68 -0
  37. package/src/extensions/glob/index.ts +136 -0
  38. package/src/extensions/glob/render.test.ts +126 -0
  39. package/src/extensions/glob/render.ts +74 -0
  40. package/src/extensions/glob/schema.ts +52 -0
  41. package/src/extensions/grep/grep.test.ts +387 -0
  42. package/src/extensions/grep/grep.ts +215 -0
  43. package/src/extensions/grep/index.test.ts +68 -0
  44. package/src/extensions/grep/index.ts +158 -0
  45. package/src/extensions/grep/render.test.ts +269 -0
  46. package/src/extensions/grep/render.ts +243 -0
  47. package/src/extensions/grep/schema.ts +92 -0
  48. package/src/extensions/read/index.ts +84 -0
  49. package/src/extensions/read/read.test.ts +177 -0
  50. package/src/extensions/read/read.ts +206 -0
  51. package/src/extensions/read/render.test.ts +61 -0
  52. package/src/extensions/read/render.ts +33 -0
  53. package/src/extensions/read/schema.ts +27 -0
  54. package/src/extensions/subagent/index.test.ts +44 -0
  55. package/src/extensions/subagent/index.ts +30 -0
  56. package/src/extensions/subagent/render.test.ts +292 -0
  57. package/src/extensions/subagent/render.ts +359 -0
  58. package/src/extensions/subagent/schema.ts +9 -0
  59. package/src/extensions/subagent/subagent.test.ts +315 -0
  60. package/src/extensions/subagent/subagent.ts +418 -0
  61. package/src/extensions/system-prompt/index.ts +28 -0
  62. package/src/extensions/system-prompt/prompt.test.ts +64 -0
  63. package/src/extensions/system-prompt/prompt.ts +213 -0
  64. package/src/extensions/todo/index.test.ts +244 -0
  65. package/src/extensions/todo/index.ts +122 -0
  66. package/src/extensions/todo/render.test.ts +180 -0
  67. package/src/extensions/todo/render.ts +172 -0
  68. package/src/extensions/todo/schema.ts +24 -0
  69. package/src/extensions/todo/todo.test.ts +222 -0
  70. package/src/extensions/todo/todo.ts +188 -0
  71. package/src/extensions/tps/index.test.ts +254 -0
  72. package/src/extensions/tps/index.ts +136 -0
  73. package/src/extensions/web-fetch/JinaReaderClient.ts +230 -0
  74. package/src/extensions/web-fetch/WebViewFetchClient.ts +186 -0
  75. package/src/extensions/web-fetch/WebViewMarkdownSnapshot.test.ts +119 -0
  76. package/src/extensions/web-fetch/WebViewMarkdownSnapshot.ts +511 -0
  77. package/src/extensions/web-fetch/fetch.test.ts +244 -0
  78. package/src/extensions/web-fetch/fetch.ts +249 -0
  79. package/src/extensions/web-fetch/index.ts +107 -0
  80. package/src/extensions/web-fetch/render.test.ts +56 -0
  81. package/src/extensions/web-fetch/render.ts +39 -0
  82. package/src/extensions/web-fetch/schema.ts +23 -0
  83. package/src/extensions/web-search/ExaMcpClient.test.ts +143 -0
  84. package/src/extensions/web-search/ExaMcpClient.ts +258 -0
  85. package/src/extensions/web-search/index.ts +118 -0
  86. package/src/extensions/web-search/render.test.ts +21 -0
  87. package/src/extensions/web-search/render.ts +9 -0
  88. package/src/extensions/web-search/schema.ts +21 -0
  89. package/src/extensions/web-search/search.test.ts +53 -0
  90. package/src/extensions/web-search/search.ts +23 -0
  91. package/src/extensions/working-indicator/index.test.ts +21 -0
  92. package/src/extensions/working-indicator/index.ts +77 -0
  93. package/src/extensions/write/index.ts +76 -0
  94. package/src/extensions/write/render.test.ts +64 -0
  95. package/src/extensions/write/schema.ts +14 -0
  96. package/src/extensions/write/write.test.ts +108 -0
  97. package/src/extensions/write/write.ts +104 -0
  98. package/src/shared/DiffLines.test.ts +193 -0
  99. package/src/shared/DiffLines.ts +307 -0
  100. package/src/shared/DiffRenderer.test.ts +206 -0
  101. package/src/shared/DiffRenderer.ts +396 -0
  102. package/src/shared/DiffView.ts +199 -0
  103. package/src/shared/EditMatcher.test.ts +123 -0
  104. package/src/shared/EditMatcher.ts +826 -0
  105. package/src/shared/FileScanner.test.ts +158 -0
  106. package/src/shared/FileScanner.ts +41 -0
  107. package/src/shared/Fs.ts +46 -0
  108. package/src/shared/FsErrors.ts +72 -0
  109. package/src/shared/FuzzyMatcher.test.ts +114 -0
  110. package/src/shared/FuzzyMatcher.ts +73 -0
  111. package/src/shared/GitignoreFilter.test.ts +64 -0
  112. package/src/shared/GitignoreFilter.ts +142 -0
  113. package/src/shared/GlobExclusions.ts +23 -0
  114. package/src/shared/Levenshtein.ts +33 -0
  115. package/src/shared/Lines.test.ts +25 -0
  116. package/src/shared/Lines.ts +77 -0
  117. package/src/shared/McpClient.test.ts +235 -0
  118. package/src/shared/McpClient.ts +406 -0
  119. package/src/shared/OutputBudget.test.ts +99 -0
  120. package/src/shared/OutputBudget.ts +79 -0
  121. package/src/shared/Paths.test.ts +51 -0
  122. package/src/shared/Paths.ts +52 -0
  123. package/src/shared/PimSettings.test.ts +90 -0
  124. package/src/shared/PimSettings.ts +124 -0
  125. package/src/shared/Renderer.test.ts +190 -0
  126. package/src/shared/Renderer.ts +256 -0
  127. package/src/shared/SpillCache.test.ts +94 -0
  128. package/src/shared/SpillCache.ts +89 -0
  129. package/src/shared/Tools.test.ts +392 -0
  130. package/src/shared/Tools.ts +636 -0
  131. package/src/telegram/Bot.ts +198 -0
  132. package/src/telegram/Commands.ts +721 -0
  133. package/src/telegram/Config.test.ts +275 -0
  134. package/src/telegram/Config.ts +162 -0
  135. package/src/telegram/Markdown.test.ts +143 -0
  136. package/src/telegram/Markdown.ts +177 -0
  137. package/src/telegram/Message.ts +211 -0
  138. package/src/telegram/Renderer.test.ts +216 -0
  139. package/src/telegram/Renderer.ts +713 -0
  140. package/src/telegram/SendFileSchema.ts +19 -0
  141. package/src/telegram/SendFileTool.ts +94 -0
  142. package/src/telegram/Session.ts +579 -0
  143. package/src/telegram/SessionRegistry.test.ts +89 -0
  144. package/src/telegram/SessionRegistry.ts +170 -0
  145. package/src/telegram/Supervisor.ts +357 -0
  146. package/src/telegram/TaskScheduler.test.ts +278 -0
  147. package/src/telegram/TaskScheduler.ts +293 -0
  148. package/src/telegram/TaskSchema.ts +88 -0
  149. package/src/telegram/TaskStore.ts +73 -0
  150. package/src/telegram/TaskTool.test.ts +179 -0
  151. package/src/telegram/TaskTool.ts +159 -0
  152. package/src/telegram/TypingIndicator.ts +43 -0
  153. package/src/telegram/index.ts +32 -0
  154. package/src/themes/pim-dark.json +84 -0
  155. package/src/themes/pim-light.json +84 -0
@@ -0,0 +1,213 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ type BuildOptions = {
4
+ readonly model?: ExtensionContext["model"];
5
+ readonly cwd: string;
6
+ readonly contextFiles: ReadonlyArray<{
7
+ readonly path: string;
8
+ readonly content: string;
9
+ }>;
10
+ readonly skillsBlock: string;
11
+ readonly toolGuidelines: ReadonlyArray<string>;
12
+ readonly appendSystemPrompt?: string;
13
+ readonly customPrompt?: string;
14
+ readonly os?: string;
15
+ };
16
+
17
+ type RunCommand = (cmd: ReadonlyArray<string>) => string | undefined;
18
+
19
+ type OsDescriptionOptions = {
20
+ readonly platform?: typeof process.platform;
21
+ readonly runCommand?: RunCommand;
22
+ };
23
+
24
+ function dynamicGuidelines(): ReadonlyArray<string> {
25
+ const guidelines: string[] = [];
26
+ if (Bun.which("gh")) {
27
+ guidelines.push(
28
+ "Always prefer `gh` CLI instead of raw API calls when viewing GitHub content (eg. PRs, issues, comments)."
29
+ );
30
+ }
31
+ return guidelines;
32
+ }
33
+
34
+ export function buildSystemPrompt(opts: BuildOptions): string {
35
+ const sections: string[] = [];
36
+
37
+ if (opts.customPrompt && opts.customPrompt.trim().length > 0) {
38
+ sections.push(opts.customPrompt);
39
+ } else {
40
+ sections.push(
41
+ [
42
+ "<system_instructions>",
43
+ "You are pim (Pi IMproved), a Bun-native, opinionated extension pack for the [pi agent harness](https://pi.dev/).",
44
+ ...opts.toolGuidelines.map((g) => `- ${g}`),
45
+ ...dynamicGuidelines().map((g) => `- ${g}`),
46
+ "</system_instructions>",
47
+ ].join("\n")
48
+ );
49
+ }
50
+
51
+ const model = opts.model
52
+ ? `${opts.model.id} via ${opts.model.provider}`
53
+ : "unknown";
54
+ sections.push(
55
+ [
56
+ "<environment>",
57
+ `- cwd: ${opts.cwd}`,
58
+ `- os: ${opts.os ?? describeOs()}`,
59
+ `- model: ${model}`,
60
+ `- datetime: ${formatDatetime(new Date())}`,
61
+ "</environment>",
62
+ ].join("\n")
63
+ );
64
+
65
+ if (opts.contextFiles.length > 0) {
66
+ const files = opts.contextFiles
67
+ .map(
68
+ ({ path, content }) =>
69
+ `<file path="${escapeXmlAttr(path)}">\n${content}\n</file>`
70
+ )
71
+ .join("\n");
72
+ sections.push(`<project_instructions>\n${files}\n</project_instructions>`);
73
+ }
74
+
75
+ if (opts.skillsBlock) {
76
+ sections.push(opts.skillsBlock.trimStart());
77
+ }
78
+
79
+ if (opts.appendSystemPrompt && opts.appendSystemPrompt.trim().length > 0) {
80
+ sections.push(opts.appendSystemPrompt);
81
+ }
82
+
83
+ return sections.join("\n\n");
84
+ }
85
+
86
+ export function describeOs(options: OsDescriptionOptions = {}): string {
87
+ const platform = options.platform ?? process.platform;
88
+ const runCommand =
89
+ options.runCommand ??
90
+ ((cmd) => {
91
+ try {
92
+ const result = Bun.spawnSync({ cmd: [...cmd] });
93
+ if (result.exitCode !== 0) {
94
+ return undefined;
95
+ }
96
+
97
+ const output = result.stdout.toString().trim();
98
+ return output || undefined;
99
+ } catch {
100
+ return undefined;
101
+ }
102
+ });
103
+ const unixName = (): string | undefined => runCommand(["uname", "-sr"]);
104
+
105
+ if (platform === "linux") {
106
+ const osRelease = runCommand(["cat", "/etc/os-release"]);
107
+ if (osRelease) {
108
+ const fields = new Map<string, string>();
109
+ for (const line of osRelease.split(/\r?\n/)) {
110
+ const trimmed = line.trim();
111
+ if (!trimmed || trimmed.startsWith("#")) {
112
+ continue;
113
+ }
114
+
115
+ const equalsIndex = trimmed.indexOf("=");
116
+ if (equalsIndex <= 0) {
117
+ continue;
118
+ }
119
+
120
+ const key = trimmed.slice(0, equalsIndex);
121
+ if (/^[A-Z0-9_]+$/.test(key)) {
122
+ fields.set(key, unquoteValue(trimmed.slice(equalsIndex + 1)));
123
+ }
124
+ }
125
+
126
+ const prettyName = fields.get("PRETTY_NAME")?.trim();
127
+ if (prettyName) {
128
+ return prettyName;
129
+ }
130
+
131
+ const name = fields.get("NAME")?.trim();
132
+ const version =
133
+ fields.get("VERSION")?.trim() ?? fields.get("VERSION_ID")?.trim();
134
+ const described = [name, version].filter(Boolean).join(" ");
135
+ if (described) {
136
+ return described;
137
+ }
138
+ }
139
+
140
+ const lsbRelease = runCommand(["lsb_release", "-ds"]);
141
+ if (lsbRelease) {
142
+ return unquoteValue(lsbRelease.trim());
143
+ }
144
+
145
+ return unixName() ?? platform;
146
+ }
147
+
148
+ if (platform === "darwin") {
149
+ const swVers = runCommand(["sw_vers"]);
150
+ if (swVers) {
151
+ const fields = new Map<string, string>();
152
+ for (const line of swVers.split(/\r?\n/)) {
153
+ const match = line.match(/^([^:]+):\s*(.+)$/);
154
+ const key = match?.[1]?.trim();
155
+ const value = match?.[2]?.trim();
156
+ if (key && value) {
157
+ fields.set(key, value);
158
+ }
159
+ }
160
+
161
+ const name = fields.get("ProductName") ?? "macOS";
162
+ const version = fields.get("ProductVersion");
163
+ return [name, version].filter(Boolean).join(" ") || platform;
164
+ }
165
+
166
+ return unixName() ?? platform;
167
+ }
168
+
169
+ if (platform === "win32") {
170
+ const ver = runCommand(["cmd.exe", "/d", "/s", "/c", "ver"]);
171
+ return ver?.replace(/\s+/g, " ").trim() || platform;
172
+ }
173
+
174
+ return unixName() ?? platform;
175
+ }
176
+
177
+ function unquoteValue(value: string): string {
178
+ if (value.length < 2) {
179
+ return value;
180
+ }
181
+
182
+ const quote = value.charAt(0);
183
+ if (
184
+ (quote !== '"' && quote !== "'") ||
185
+ value.charAt(value.length - 1) !== quote
186
+ ) {
187
+ return value;
188
+ }
189
+
190
+ const unquoted = value.slice(1, -1);
191
+ return quote === "'" ? unquoted : unquoted.replace(/\\(["\\$`])/g, "$1");
192
+ }
193
+
194
+ function formatDatetime(d: Date): string {
195
+ const pad = (n: number): string => String(n).padStart(2, "0");
196
+ const offsetMinutes = -d.getTimezoneOffset();
197
+ const sign = offsetMinutes >= 0 ? "+" : "-";
198
+ const absMinutes = Math.abs(offsetMinutes);
199
+ const offset = `${sign}${pad(Math.floor(absMinutes / 60))}:${pad(absMinutes % 60)}`;
200
+ const iso =
201
+ `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +
202
+ `T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}${offset}`;
203
+ const day = d.toLocaleDateString("en-US", { weekday: "long" });
204
+ return `${iso} (${day})`;
205
+ }
206
+
207
+ function escapeXmlAttr(value: string): string {
208
+ return value
209
+ .replace(/&/g, "&amp;")
210
+ .replace(/"/g, "&quot;")
211
+ .replace(/</g, "&lt;")
212
+ .replace(/>/g, "&gt;");
213
+ }
@@ -0,0 +1,244 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type {
3
+ ExtensionAPI,
4
+ ExtensionContext,
5
+ Theme,
6
+ } from "@earendil-works/pi-coding-agent";
7
+ import type { TodoItem } from "./schema";
8
+ import registerTodo from "./index";
9
+ import { getCurrentItems } from "./todo";
10
+
11
+ type Handler = (event: unknown, ctx: ExtensionContext) => unknown;
12
+ type RegisteredTool = {
13
+ readonly executionMode?: string;
14
+ readonly execute: (...args: readonly unknown[]) => unknown;
15
+ };
16
+ type AppendedEntry = {
17
+ readonly customType: string;
18
+ readonly data: unknown;
19
+ };
20
+ type SentMessage = {
21
+ readonly message: unknown;
22
+ readonly options: unknown;
23
+ };
24
+ type MockPi = {
25
+ readonly api: ExtensionAPI;
26
+ readonly handlers: Map<string, Handler[]>;
27
+ readonly tools: RegisteredTool[];
28
+ readonly appendedEntries: AppendedEntry[];
29
+ readonly sentMessages: SentMessage[];
30
+ };
31
+ type WidgetUpdate = {
32
+ readonly id: string;
33
+ readonly lines: readonly string[] | undefined;
34
+ };
35
+
36
+ type MockContext = ExtensionContext & {
37
+ readonly widgetUpdates: WidgetUpdate[];
38
+ };
39
+
40
+ const stubTheme = {
41
+ fg: (color: string, text: string) => `<${color}>${text}</${color}>`,
42
+ bold: (text: string) => `**${text}**`,
43
+ strikethrough: (text: string) => `~~${text}~~`,
44
+ } as unknown as Theme;
45
+
46
+ describe("todo extension", () => {
47
+ test("clears an all-done widget on the next user input", async () => {
48
+ const pi = createPi();
49
+ const ctx = createContext();
50
+ registerTodo(pi.api);
51
+
52
+ await setTodos(pi, ctx, [
53
+ { content: "Ship it", status: "completed" },
54
+ { content: "Skip obsolete", status: "cancelled" },
55
+ ]);
56
+ await emit(pi, "turn_end", { type: "turn_end" }, ctx);
57
+ await flush();
58
+
59
+ expect(ctx.widgetUpdates.at(-1)?.lines).toEqual([
60
+ "**2 todos** (1 done, 1 cancelled)",
61
+ "<success>✔</success> <muted>Ship it</muted>",
62
+ "<muted>✘</muted> <muted>~~Skip obsolete~~</muted>",
63
+ ]);
64
+
65
+ const results = await emit(pi, "input", { type: "input" }, ctx);
66
+
67
+ expect(results).toEqual([{ action: "continue" }]);
68
+ expect(getCurrentItems(ctx.sessionManager)).toEqual([]);
69
+ expect(ctx.widgetUpdates.at(-1)).toEqual({
70
+ id: "pim-todo",
71
+ lines: undefined,
72
+ });
73
+ });
74
+
75
+ test("keeps the widget when any todo is still active", async () => {
76
+ const pi = createPi();
77
+ const ctx = createContext();
78
+ registerTodo(pi.api);
79
+
80
+ const todos: readonly TodoItem[] = [
81
+ { content: "Done", status: "completed" },
82
+ { content: "Next", status: "pending" },
83
+ ];
84
+ await setTodos(pi, ctx, todos);
85
+ await emit(pi, "turn_end", { type: "turn_end" }, ctx);
86
+ await flush();
87
+ const updatesBeforeInput = ctx.widgetUpdates.length;
88
+
89
+ const results = await emit(pi, "input", { type: "input" }, ctx);
90
+
91
+ expect(results).toEqual([{ action: "continue" }]);
92
+ expect(getCurrentItems(ctx.sessionManager)).toEqual(todos);
93
+ expect(ctx.widgetUpdates).toHaveLength(updatesBeforeInput);
94
+ });
95
+
96
+ test("checkpoints todo state on session_compact so the widget survives a reload", async () => {
97
+ const pi = createPi();
98
+ const ctx = createContext();
99
+ registerTodo(pi.api);
100
+
101
+ const todos: readonly TodoItem[] = [
102
+ { content: "Ship", status: "in_progress" },
103
+ { content: "Verify", status: "pending" },
104
+ ];
105
+ await setTodos(pi, ctx, todos);
106
+ await emit(pi, "session_compact", { type: "session_compact" }, ctx);
107
+
108
+ expect(pi.appendedEntries).toEqual([
109
+ { customType: "pim-todo-state", data: { todos } },
110
+ ]);
111
+ expect(pi.sentMessages).toEqual([
112
+ {
113
+ message: {
114
+ customType: "pim-todo-snapshot",
115
+ content: "Current todo list:\n[>] Ship\n[ ] Verify",
116
+ display: false,
117
+ },
118
+ options: { triggerTurn: false },
119
+ },
120
+ ]);
121
+ });
122
+
123
+ test("session_compact is a no-op when there are no items", async () => {
124
+ const pi = createPi();
125
+ const ctx = createContext();
126
+ registerTodo(pi.api);
127
+
128
+ await emit(pi, "session_compact", { type: "session_compact" }, ctx);
129
+
130
+ expect(pi.appendedEntries).toEqual([]);
131
+ expect(pi.sentMessages).toEqual([]);
132
+ });
133
+
134
+ test("subagent ctx mutating todos does not leak into the parent ctx", async () => {
135
+ const pi = createPi();
136
+ const parent = createContext();
137
+ const child = createContext();
138
+ registerTodo(pi.api);
139
+
140
+ await setTodos(pi, parent, [{ content: "parent", status: "pending" }]);
141
+ await setTodos(pi, child, [{ content: "child", status: "in_progress" }]);
142
+
143
+ expect(getCurrentItems(parent.sessionManager)).toEqual([
144
+ { content: "parent", status: "pending" },
145
+ ]);
146
+ expect(getCurrentItems(child.sessionManager)).toEqual([
147
+ { content: "child", status: "in_progress" },
148
+ ]);
149
+ });
150
+
151
+ test("todo tool executes sequentially and returns a compact update summary", async () => {
152
+ const pi = createPi();
153
+ const ctx = createContext();
154
+ registerTodo(pi.api);
155
+
156
+ expect(pi.tools[0]?.executionMode).toBe("sequential");
157
+ expect(await setTodos(pi, ctx, [])).toEqual({
158
+ content: [
159
+ {
160
+ type: "text",
161
+ text: "Todos cleared.",
162
+ },
163
+ ],
164
+ details: {
165
+ todos: [],
166
+ summary: {
167
+ pending: 0,
168
+ in_progress: 0,
169
+ completed: 0,
170
+ cancelled: 0,
171
+ },
172
+ },
173
+ });
174
+ });
175
+ });
176
+
177
+ function createPi(): MockPi {
178
+ const handlers = new Map<string, Handler[]>();
179
+ const tools: RegisteredTool[] = [];
180
+ const appendedEntries: AppendedEntry[] = [];
181
+ const sentMessages: SentMessage[] = [];
182
+ const api = {
183
+ on(event: string, handler: Handler): void {
184
+ const existing = handlers.get(event) ?? [];
185
+ existing.push(handler);
186
+ handlers.set(event, existing);
187
+ },
188
+ registerTool(tool: RegisteredTool): void {
189
+ tools.push(tool);
190
+ },
191
+ appendEntry(customType: string, data: unknown): void {
192
+ appendedEntries.push({ customType, data });
193
+ },
194
+ sendMessage(message: unknown, options: unknown): void {
195
+ sentMessages.push({ message, options });
196
+ },
197
+ } as unknown as ExtensionAPI;
198
+
199
+ return { api, handlers, tools, appendedEntries, sentMessages };
200
+ }
201
+
202
+ function createContext(): MockContext {
203
+ const widgetUpdates: WidgetUpdate[] = [];
204
+ return {
205
+ hasUI: true,
206
+ sessionManager: {},
207
+ ui: {
208
+ theme: stubTheme,
209
+ setWidget(id: string, lines: readonly string[] | undefined): void {
210
+ widgetUpdates.push({ id, lines });
211
+ },
212
+ },
213
+ widgetUpdates,
214
+ } as unknown as MockContext;
215
+ }
216
+
217
+ async function emit(
218
+ pi: MockPi,
219
+ event: string,
220
+ payload: unknown,
221
+ ctx: ExtensionContext
222
+ ): Promise<unknown[]> {
223
+ const results: unknown[] = [];
224
+ for (const handler of pi.handlers.get(event) ?? []) {
225
+ results.push(await handler(payload, ctx));
226
+ }
227
+ return results;
228
+ }
229
+
230
+ function flush(): Promise<void> {
231
+ return new Promise((resolve) => setImmediate(resolve));
232
+ }
233
+
234
+ async function setTodos(
235
+ pi: MockPi,
236
+ ctx: ExtensionContext,
237
+ todos: readonly TodoItem[]
238
+ ): Promise<unknown> {
239
+ const tool = pi.tools[0];
240
+ if (!tool) {
241
+ throw new Error("todo tool was not registered");
242
+ }
243
+ return await tool.execute("todo-call", { todos }, undefined, undefined, ctx);
244
+ }
@@ -0,0 +1,122 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionContext,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import { Tools } from "../../shared/Tools";
6
+ import { renderCall, renderResult, renderWidgetLines } from "./render";
7
+ import { todoSchema } from "./schema";
8
+ import {
9
+ formatChecklist,
10
+ formatUpdateSummary,
11
+ getCurrentItems,
12
+ hasActiveItems,
13
+ makeDetails,
14
+ reconstructFromBranch,
15
+ replaceItems,
16
+ resetItems,
17
+ TODO_STATE_CUSTOM_TYPE,
18
+ } from "./todo";
19
+
20
+ const WIDGET_ID = "pim-todo";
21
+
22
+ export default function (pi: ExtensionAPI): void {
23
+ let pendingRefresh: ReturnType<typeof setImmediate> | undefined;
24
+
25
+ const refreshWidget = (ctx: ExtensionContext): void => {
26
+ if (pendingRefresh !== undefined) {
27
+ clearImmediate(pendingRefresh);
28
+ pendingRefresh = undefined;
29
+ }
30
+ if (!ctx.hasUI) {
31
+ return;
32
+ }
33
+ const items = getCurrentItems(ctx.sessionManager);
34
+ if (items.length === 0) {
35
+ ctx.ui.setWidget(WIDGET_ID, undefined);
36
+ return;
37
+ }
38
+ // Defer so todo widget is always the last widget to show up (right above editor)
39
+ pendingRefresh = setImmediate(() => {
40
+ pendingRefresh = undefined;
41
+ ctx.ui.setWidget(WIDGET_ID, renderWidgetLines(items, ctx.ui.theme));
42
+ });
43
+ };
44
+
45
+ const reconstructAndRefresh = (ctx: ExtensionContext): void => {
46
+ reconstructFromBranch(ctx.sessionManager, ctx.sessionManager.getBranch());
47
+ refreshWidget(ctx);
48
+ };
49
+
50
+ const clearInactiveTodos = (ctx: ExtensionContext): void => {
51
+ const items = getCurrentItems(ctx.sessionManager);
52
+ if (items.length === 0 || hasActiveItems(items)) {
53
+ return;
54
+ }
55
+
56
+ resetItems(ctx.sessionManager);
57
+ refreshWidget(ctx);
58
+ };
59
+
60
+ Tools.register(pi, {
61
+ name: "todo",
62
+ label: "todo",
63
+ description:
64
+ "Manage your to-dos. ALWAYS use for tasks with 3+ steps; skip only for trivial one-step tasks. " +
65
+ "Each call replaces the entire list; include every item in priority order. " +
66
+ "Keep at most one item in_progress, mark items completed immediately after finishing, and preserve skipped work as cancelled.",
67
+ parameters: todoSchema,
68
+ renderShell: "self",
69
+ executionMode: "sequential",
70
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
71
+ const items = replaceItems(ctx.sessionManager, params.todos);
72
+ return {
73
+ content: [
74
+ {
75
+ type: "text",
76
+ text: formatUpdateSummary(items),
77
+ },
78
+ ],
79
+ details: makeDetails(items),
80
+ };
81
+ },
82
+ renderCall,
83
+ renderResult,
84
+ });
85
+
86
+ pi.on("session_start", (_event, ctx) => {
87
+ reconstructAndRefresh(ctx);
88
+ });
89
+
90
+ pi.on("session_tree", (_event, ctx) => {
91
+ reconstructAndRefresh(ctx);
92
+ });
93
+
94
+ pi.on("turn_end", (_event, ctx) => {
95
+ refreshWidget(ctx);
96
+ });
97
+
98
+ pi.on("input", (_event, ctx) => {
99
+ clearInactiveTodos(ctx);
100
+ return { action: "continue" };
101
+ });
102
+
103
+ pi.on("session_compact", (_event, ctx) => {
104
+ const items = getCurrentItems(ctx.sessionManager);
105
+ if (items.length === 0) {
106
+ return;
107
+ }
108
+ pi.appendEntry(TODO_STATE_CUSTOM_TYPE, { todos: items });
109
+ const snapshot = formatChecklist(items, { activeOnly: true });
110
+ if (!snapshot) {
111
+ return;
112
+ }
113
+ pi.sendMessage(
114
+ {
115
+ customType: "pim-todo-snapshot",
116
+ content: `Current todo list:\n${snapshot}`,
117
+ display: false,
118
+ },
119
+ { triggerTurn: false }
120
+ );
121
+ });
122
+ }