@phi-code-admin/phi-code 0.82.3 → 0.84.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.
Files changed (49) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/docs/usage.md +1 -1
  3. package/extensions/phi/btw/LICENSE +21 -0
  4. package/extensions/phi/btw/btw-ui.ts +238 -0
  5. package/extensions/phi/btw/btw.ts +363 -0
  6. package/extensions/phi/btw/index.ts +17 -0
  7. package/extensions/phi/btw/prompts/btw-system.txt +9 -0
  8. package/extensions/phi/chrome/LICENSE +21 -0
  9. package/extensions/phi/chrome/browser-extension/manifest.json +26 -0
  10. package/extensions/phi/chrome/browser-extension/service_worker.js +2388 -0
  11. package/extensions/phi/chrome/browser-extension/snapshot_injected.js +677 -0
  12. package/extensions/phi/chrome/index.ts +1799 -0
  13. package/extensions/phi/goal/LICENSE +21 -0
  14. package/extensions/phi/goal/index.ts +784 -0
  15. package/extensions/phi/mcp/LICENSE +21 -0
  16. package/extensions/phi/mcp/callback-server.ts +263 -0
  17. package/extensions/phi/mcp/config.ts +195 -0
  18. package/extensions/phi/mcp/errors.ts +35 -0
  19. package/extensions/phi/mcp/index.ts +376 -0
  20. package/extensions/phi/mcp/oauth-provider.ts +367 -0
  21. package/extensions/phi/mcp/server-manager.ts +464 -0
  22. package/extensions/phi/mcp/tool-bridge.ts +494 -0
  23. package/extensions/phi/todo/LICENSE +21 -0
  24. package/extensions/phi/todo/config.ts +14 -0
  25. package/extensions/phi/todo/index.ts +113 -0
  26. package/extensions/phi/todo/locales/de.json +17 -0
  27. package/extensions/phi/todo/locales/en.json +15 -0
  28. package/extensions/phi/todo/locales/es.json +17 -0
  29. package/extensions/phi/todo/locales/fr.json +17 -0
  30. package/extensions/phi/todo/locales/pt-BR.json +17 -0
  31. package/extensions/phi/todo/locales/pt.json +17 -0
  32. package/extensions/phi/todo/locales/ru.json +17 -0
  33. package/extensions/phi/todo/locales/uk.json +17 -0
  34. package/extensions/phi/todo/rpiv-config/config.ts +223 -0
  35. package/extensions/phi/todo/rpiv-config/index.ts +12 -0
  36. package/extensions/phi/todo/state/i18n-bridge.ts +65 -0
  37. package/extensions/phi/todo/state/invariants.ts +20 -0
  38. package/extensions/phi/todo/state/replay.ts +38 -0
  39. package/extensions/phi/todo/state/selectors.ts +107 -0
  40. package/extensions/phi/todo/state/state-reducer.ts +187 -0
  41. package/extensions/phi/todo/state/state.ts +18 -0
  42. package/extensions/phi/todo/state/store.ts +54 -0
  43. package/extensions/phi/todo/state/task-graph.ts +57 -0
  44. package/extensions/phi/todo/todo-overlay.ts +194 -0
  45. package/extensions/phi/todo/todo.ts +146 -0
  46. package/extensions/phi/todo/tool/response-envelope.ts +94 -0
  47. package/extensions/phi/todo/tool/types.ts +128 -0
  48. package/extensions/phi/todo/view/format.ts +162 -0
  49. package/package.json +4 -2
@@ -0,0 +1,494 @@
1
+ /**
2
+ * Tool bridge for pi-mcp.
3
+ *
4
+ * Converts MCP tools to Pi tools and manages their lifecycle:
5
+ * - Paginated tools/list (cursor loop per spec)
6
+ * - JSON Schema → TypeBox conversion (common types + Type.Any() fallback)
7
+ * - Tool name sanitization (Pi-compatible identifiers)
8
+ * - Tool annotations → description hints
9
+ * - AbortSignal → SDK's built-in cancellation (notifications/cancelled)
10
+ * - Protocol error vs tool execution error distinction
11
+ * - Activate/deactivate pattern (register once, toggle on server state change)
12
+ * - Image/audio/resource content → text description passthrough
13
+ */
14
+
15
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
16
+ import { CallToolResultSchema, ListToolsResultSchema } from "@modelcontextprotocol/sdk/types.js";
17
+ import * as Type from "typebox";
18
+ import type { TSchema } from "typebox";
19
+ import type { ExtensionAPI } from "phi-code";
20
+ import { McpError } from "./errors.js";
21
+ import type { Settings } from "./config.js";
22
+
23
+ // ─── Types ────────────────────────────────────────────────────────────────────
24
+
25
+ /** Subset of Pi's ExtensionAPI used by the bridge. */
26
+ export type PiExtensionAPI = Pick<ExtensionAPI, "registerTool" | "getActiveTools" | "setActiveTools">;
27
+
28
+
29
+ // ─── Schema Conversion ────────────────────────────────────────────────────────
30
+
31
+ /**
32
+ * Convert a JSON Schema object to a TypeBox schema.
33
+ * Handles the common subset used by real-world MCP servers:
34
+ * - Primitives (string, number, integer, boolean, null)
35
+ * - Arrays, objects (with required/optional/additionalProperties)
36
+ * - Enums (string enums → Union of Literals)
37
+ * - Nullable types ("type": ["string", "null"])
38
+ * - $ref (local #/$defs/ and #/definitions/ references)
39
+ * - oneOf / anyOf → TypeBox Union
40
+ * - allOf → TypeBox Intersect
41
+ * Falls back to Type.Any() for unresolvable $ref or missing type.
42
+ */
43
+ export function convertJsonSchemaToTypebox(
44
+ schema: unknown,
45
+ depth = 0,
46
+ defs?: Record<string, unknown>,
47
+ ): TSchema {
48
+ // Guard against infinite recursion and malformed schemas
49
+ if (!schema || typeof schema !== "object" || Array.isArray(schema) || depth > 10) {
50
+ return Type.Any();
51
+ }
52
+
53
+ const s = schema as Record<string, unknown>;
54
+ const description = typeof s["description"] === "string" ? s["description"] : undefined;
55
+ const opts = description ? { description } : {};
56
+
57
+ // Extract $defs / definitions for $ref resolution (carried through recursive calls)
58
+ const resolvedDefs: Record<string, unknown> = {
59
+ ...((s["$defs"] ?? s["definitions"]) as Record<string, unknown> | undefined),
60
+ ...defs,
61
+ };
62
+
63
+ // ── Handle $ref ──────────────────────────────────────────────────────────
64
+ if (typeof s["$ref"] === "string") {
65
+ const ref = s["$ref"] as string;
66
+ let resolved: unknown;
67
+
68
+ // Local references: #/$defs/Foo, #/definitions/Foo
69
+ if (ref.startsWith("#/")) {
70
+ const parts = ref.slice(2).split("/");
71
+ if (parts[0] === "$defs" || parts[0] === "definitions") {
72
+ const key = parts.slice(1).join("/");
73
+ resolved = resolvedDefs[key];
74
+ } else {
75
+ // Fallback: try walking the defs map by the last part
76
+ const key = parts[parts.length - 1]!;
77
+ resolved = resolvedDefs[key];
78
+ }
79
+ } else {
80
+ // External $ref — cannot resolve, fall back
81
+ console.warn(
82
+ `[pi-mcp] Cannot resolve external $ref "${ref}", using Type.Any()`,
83
+ );
84
+ return Type.Any(opts);
85
+ }
86
+
87
+ if (!resolved) {
88
+ console.warn(
89
+ `[pi-mcp] Could not resolve $ref "${ref}", using Type.Any()`,
90
+ );
91
+ return Type.Any(opts);
92
+ }
93
+
94
+ // Merge description from referencing schema into resolved schema
95
+ const merged = { ...(resolved as Record<string, unknown>) };
96
+ if (description && !merged["description"]) {
97
+ merged["description"] = description;
98
+ }
99
+ return convertJsonSchemaToTypebox(merged, depth + 1, resolvedDefs);
100
+ }
101
+
102
+ // ── Handle oneOf / anyOf → TypeBox Union ─────────────────────────────────
103
+ if (Array.isArray(s["oneOf"])) {
104
+ const members = (s["oneOf"] as unknown[])
105
+ .map((sub) => convertJsonSchemaToTypebox(sub, depth + 1, resolvedDefs));
106
+ return members.length === 1 ? members[0]! : Type.Union(members, opts);
107
+ }
108
+
109
+ if (Array.isArray(s["anyOf"])) {
110
+ const members = (s["anyOf"] as unknown[])
111
+ .map((sub) => convertJsonSchemaToTypebox(sub, depth + 1, resolvedDefs));
112
+ return members.length === 1 ? members[0]! : Type.Union(members, opts);
113
+ }
114
+
115
+ // ── Handle allOf → TypeBox Intersect ─────────────────────────────────────
116
+ if (Array.isArray(s["allOf"])) {
117
+ const members = (s["allOf"] as unknown[])
118
+ .map((sub) => convertJsonSchemaToTypebox(sub, depth + 1, resolvedDefs));
119
+ return members.length === 1 ? members[0]! : Type.Intersect(members, opts);
120
+ }
121
+
122
+ // Handle nullable types: { "type": ["string", "null"] }
123
+ const rawType = s["type"];
124
+ const type = Array.isArray(rawType)
125
+ ? rawType.find((t) => t !== "null") as string | undefined
126
+ : typeof rawType === "string" ? rawType : undefined;
127
+
128
+ const isNullable = Array.isArray(rawType) && rawType.includes("null");
129
+
130
+ let base: TSchema;
131
+
132
+ switch (type) {
133
+ case "string": {
134
+ const enumVals = s["enum"];
135
+ if (Array.isArray(enumVals) && enumVals.every((v) => typeof v === "string")) {
136
+ // TypeBox doesn't have a built-in StringEnum — use Union of Literals
137
+ base = Type.Union(
138
+ (enumVals as string[]).map((v) => Type.Literal(v)),
139
+ opts,
140
+ );
141
+ } else {
142
+ base = Type.String(opts);
143
+ }
144
+ break;
145
+ }
146
+ case "number":
147
+ case "integer":
148
+ base = Type.Number(opts);
149
+ break;
150
+ case "boolean":
151
+ base = Type.Boolean(opts);
152
+ break;
153
+ case "null":
154
+ base = Type.Null(opts);
155
+ break;
156
+ case "array": {
157
+ const items = s["items"];
158
+ base = Type.Array(
159
+ items ? convertJsonSchemaToTypebox(items, depth + 1, resolvedDefs) : Type.Unknown(),
160
+ opts,
161
+ );
162
+ break;
163
+ }
164
+ case "object": {
165
+ const properties = s["properties"] as Record<string, unknown> | undefined;
166
+ const required = new Set<string>(
167
+ Array.isArray(s["required"]) ? (s["required"] as string[]) : [],
168
+ );
169
+ const additionalProperties = s["additionalProperties"];
170
+
171
+ if (!properties) {
172
+ // Open object — passthrough as Any to avoid over-constraining
173
+ base = Type.Record(Type.String(), Type.Unknown(), opts);
174
+ break;
175
+ }
176
+
177
+ const props: Record<string, TSchema> = {};
178
+ for (const [key, value] of Object.entries(properties)) {
179
+ const converted = convertJsonSchemaToTypebox(value, depth + 1, resolvedDefs);
180
+ props[key] = required.has(key) ? converted : Type.Optional(converted);
181
+ }
182
+
183
+ const objOpts: Record<string, unknown> = { ...opts };
184
+ if (additionalProperties === false) {
185
+ objOpts["additionalProperties"] = false;
186
+ }
187
+
188
+ base = Type.Object(props, objOpts as any);
189
+ break;
190
+ }
191
+ default: {
192
+ // Truly unsupported or missing type field
193
+ base = Type.Any(opts);
194
+ break;
195
+ }
196
+ }
197
+
198
+ return isNullable ? Type.Union([base, Type.Null()]) : base;
199
+ }
200
+
201
+ // ─── Tool Name Sanitization ───────────────────────────────────────────────────
202
+
203
+ const MAX_TOOL_NAME_LEN = 64;
204
+
205
+ /**
206
+ * Build a Pi-compatible tool name.
207
+ * Format: <prefix>_<server>_<tool>
208
+ * Rules: [a-zA-Z0-9_], max 64 chars.
209
+ * If truncation is needed, the last 8 chars are replaced with a hash to avoid collisions.
210
+ */
211
+ export function buildToolName(prefix: string, serverName: string, toolName: string): string {
212
+ const raw = `${prefix}_${serverName}_${toolName}`;
213
+ const safe = raw.replace(/[^a-zA-Z0-9_]/g, "_");
214
+ if (safe.length <= MAX_TOOL_NAME_LEN) return safe;
215
+ // Truncate with hash suffix to prevent collisions on long names
216
+ const hash = Math.abs(
217
+ safe.split("").reduce((acc, c) => ((acc << 5) - acc + c.charCodeAt(0)) | 0, 0),
218
+ )
219
+ .toString(36)
220
+ .slice(0, 8);
221
+ return safe.slice(0, MAX_TOOL_NAME_LEN - 9) + "_" + hash;
222
+ }
223
+
224
+ // ─── Content Conversion ───────────────────────────────────────────────────────
225
+
226
+ type PiTextContent = { type: "text"; text: string };
227
+
228
+ function convertMcpContent(items: unknown[]): PiTextContent[] {
229
+ return items.map((item: any) => {
230
+ if (!item || typeof item !== "object") {
231
+ return { type: "text", text: String(item) };
232
+ }
233
+ switch (item.type) {
234
+ case "text":
235
+ return { type: "text", text: String(item.text ?? "") };
236
+ case "image":
237
+ return {
238
+ type: "text",
239
+ text: `[Image: ${item.mimeType ?? "unknown"}, base64 encoded]`,
240
+ };
241
+ case "audio":
242
+ return {
243
+ type: "text",
244
+ text: `[Audio: ${item.mimeType ?? "unknown"}, base64 encoded]`,
245
+ };
246
+ case "resource": {
247
+ const r = item.resource;
248
+ if (r?.text) return { type: "text", text: r.text };
249
+ if (r?.blob) return { type: "text", text: `[Resource blob: ${r.uri}]` };
250
+ return { type: "text", text: `[Resource: ${r?.uri ?? "unknown"}]` };
251
+ }
252
+ default:
253
+ return { type: "text", text: JSON.stringify(item) };
254
+ }
255
+ });
256
+ }
257
+
258
+ // ─── Tool Listing ─────────────────────────────────────────────────────────────
259
+
260
+ export interface McpToolDefinition {
261
+ name: string;
262
+ description?: string;
263
+ inputSchema: Record<string, unknown>;
264
+ annotations?: {
265
+ readOnlyHint?: boolean;
266
+ destructiveHint?: boolean;
267
+ idempotentHint?: boolean;
268
+ openWorldHint?: boolean;
269
+ title?: string;
270
+ };
271
+ }
272
+
273
+ /**
274
+ * Fetch all tools from a server using cursor-based pagination.
275
+ * The MCP spec mandates clients follow nextCursor until exhausted.
276
+ * Includes a max-page guard to prevent infinite loops from broken servers.
277
+ */
278
+ export async function listAllTools(
279
+ client: Client,
280
+ requestTimeoutMs: number,
281
+ ): Promise<McpToolDefinition[]> {
282
+ const tools: McpToolDefinition[] = [];
283
+ let cursor: string | undefined;
284
+ const MAX_PAGES = 100;
285
+ let pageCount = 0;
286
+
287
+ do {
288
+ if (pageCount >= MAX_PAGES) {
289
+ console.warn(
290
+ `[pi-mcp] tools/list pagination exceeded ${MAX_PAGES} pages, stopping. The server may be malfunctioning.`,
291
+ );
292
+ break;
293
+ }
294
+ const result = await client.request(
295
+ { method: "tools/list", params: cursor ? { cursor } : {} },
296
+ ListToolsResultSchema,
297
+ { timeout: requestTimeoutMs },
298
+ );
299
+ tools.push(...(result.tools as McpToolDefinition[]));
300
+ cursor = result.nextCursor;
301
+ pageCount++;
302
+ } while (cursor);
303
+
304
+ return tools;
305
+ }
306
+
307
+ // ─── Tool Bridge ──────────────────────────────────────────────────────────────
308
+
309
+ /**
310
+ * Manages MCP tools as Pi tools for a set of servers.
311
+ * Tools are registered once and activated/deactivated as servers connect/disconnect.
312
+ */
313
+ export class ToolBridge {
314
+ private readonly settings: Settings;
315
+ private readonly pi: PiExtensionAPI;
316
+ /** Tracks which Pi tool names belong to which MCP server. */
317
+ private readonly serverToolNames = new Map<string, Set<string>>();
318
+
319
+ constructor(settings: Settings, pi: PiExtensionAPI) {
320
+ this.settings = settings;
321
+ this.pi = pi;
322
+ }
323
+
324
+ /**
325
+ * Refresh tools for a server — called on initial connect and on list_changed.
326
+ * Always re-registers tools with the current client reference so that
327
+ * tool execute closures capture the latest client after reconnection.
328
+ * Deactivates tools that are no longer in the server's list.
329
+ * Note: Pi's registerTool() overwrites by name (Map.set), so re-registration is safe.
330
+ */
331
+ async refreshTools(serverName: string, client: Client): Promise<void> {
332
+ const timeoutMs = this.settings.requestTimeoutMs;
333
+
334
+ let tools: McpToolDefinition[];
335
+ try {
336
+ tools = await listAllTools(client, timeoutMs);
337
+ } catch (err) {
338
+ throw new McpError(
339
+ `Failed to list tools from ${serverName}: ${err instanceof Error ? err.message : String(err)}`,
340
+ serverName,
341
+ "protocol",
342
+ err,
343
+ );
344
+ }
345
+
346
+ const registeredForServer = this.serverToolNames.get(serverName) ?? new Set<string>();
347
+
348
+ // Build the set of currently valid Pi tool names for this server
349
+ const currentToolNames = new Set<string>();
350
+
351
+ for (const tool of tools) {
352
+ const piName = buildToolName(this.settings.toolPrefix, serverName, tool.name);
353
+ // Detect collision: two different MCP tools mapping to the same Pi name
354
+ // (e.g. "my-tool" and "my_tool" both sanitize to "my_tool")
355
+ if (currentToolNames.has(piName)) {
356
+ console.warn(
357
+ `[pi-mcp] Tool name collision: "${tool.name}" maps to "${piName}" which is already taken. ` +
358
+ `The later tool definition will overwrite the earlier one.`,
359
+ );
360
+ }
361
+ currentToolNames.add(piName);
362
+ // Always re-register — on reconnect the client reference changes and
363
+ // Pi's registerTool overwrites by name, so this is idempotent.
364
+ this._registerTool(piName, serverName, tool, client);
365
+ }
366
+
367
+ // Deactivate tools that were removed from the server (no longer in tools/list)
368
+ for (const existingName of registeredForServer) {
369
+ if (!currentToolNames.has(existingName)) {
370
+ this._deactivateServerTool(existingName);
371
+ }
372
+ }
373
+
374
+ this.serverToolNames.set(serverName, currentToolNames);
375
+
376
+ // Activate all current tools for this server
377
+ this._activateServerTools(serverName);
378
+ }
379
+
380
+ /** Deactivate all Pi tools belonging to a server (called on disconnect). */
381
+ deactivateServer(serverName: string): void {
382
+ this._deactivateServerTools(serverName);
383
+ }
384
+
385
+ /** Remove all tracking data for a server (called when config changes remove a server). */
386
+ removeServer(serverName: string): void {
387
+ this._deactivateServerTools(serverName);
388
+ this.serverToolNames.delete(serverName);
389
+ }
390
+
391
+ /** Re-activate all Pi tools belonging to a server (called on reconnect). */
392
+ activateServer(serverName: string): void {
393
+ this._activateServerTools(serverName);
394
+ }
395
+
396
+ // ─── Internal ───────────────────────────────────────────────────────────────
397
+
398
+ private _registerTool(
399
+ piName: string,
400
+ serverName: string,
401
+ tool: McpToolDefinition,
402
+ client: Client,
403
+ ): void {
404
+ // Build description with annotation hints for LLM guidance
405
+ let description = tool.description ?? `MCP tool: ${tool.name}`;
406
+ const ann = tool.annotations;
407
+ if (ann) {
408
+ const hints: string[] = [];
409
+ if (ann.readOnlyHint) hints.push("read-only");
410
+ if (ann.destructiveHint) hints.push("⚠️ destructive");
411
+ if (ann.idempotentHint) hints.push("idempotent");
412
+ if (ann.openWorldHint) hints.push("may have side effects");
413
+ if (hints.length > 0) description += ` [${hints.join(", ")}]`;
414
+ }
415
+
416
+ const schema = convertJsonSchemaToTypebox(tool.inputSchema);
417
+ const timeoutMs = this.settings.requestTimeoutMs;
418
+
419
+ this.pi.registerTool({
420
+ name: piName,
421
+ label: ann?.title ?? tool.name,
422
+ description,
423
+ promptSnippet: description.slice(0, 120),
424
+ parameters: schema,
425
+
426
+ async execute(_toolCallId, params, signal, _onUpdate, _ctx) {
427
+ if (signal?.aborted) {
428
+ return { content: [{ type: "text", text: "Cancelled" }], details: {} };
429
+ }
430
+
431
+ try {
432
+ const result = await client.request(
433
+ {
434
+ method: "tools/call",
435
+ params: { name: tool.name, arguments: params },
436
+ },
437
+ CallToolResultSchema,
438
+ // Pass AbortSignal to SDK — it will automatically send
439
+ // notifications/cancelled when the signal fires
440
+ { timeout: timeoutMs, ...(signal ? { signal } : {}) },
441
+ );
442
+
443
+ const content = convertMcpContent(result.content as unknown[]);
444
+
445
+ // Tool execution errors (isError: true) — distinct from protocol errors
446
+ if (result.isError) {
447
+ const errorText = content.map((c) => c.text).join("\n");
448
+ throw new McpError(
449
+ errorText || "Tool reported an error",
450
+ serverName,
451
+ "tool",
452
+ );
453
+ }
454
+
455
+ return { content, details: {} };
456
+ } catch (err) {
457
+ if (err instanceof McpError) throw err;
458
+ // Protocol-level errors (JSON-RPC error response, timeout, etc.)
459
+ throw new McpError(
460
+ err instanceof Error ? err.message : String(err),
461
+ serverName,
462
+ "protocol",
463
+ err,
464
+ );
465
+ }
466
+ },
467
+ });
468
+ }
469
+
470
+ private _activateServerTools(serverName: string): void {
471
+ const serverTools = this.serverToolNames.get(serverName);
472
+ if (!serverTools || serverTools.size === 0) return;
473
+
474
+ const currentActive = new Set(this.pi.getActiveTools());
475
+ for (const name of serverTools) currentActive.add(name);
476
+ this.pi.setActiveTools(Array.from(currentActive));
477
+ }
478
+
479
+ private _deactivateServerTools(serverName: string): void {
480
+ const serverTools = this.serverToolNames.get(serverName);
481
+ if (!serverTools || serverTools.size === 0) return;
482
+
483
+ const currentActive = this.pi.getActiveTools();
484
+ const remaining = currentActive.filter((n) => !serverTools.has(n));
485
+ this.pi.setActiveTools(remaining);
486
+ }
487
+
488
+ /** Deactivate a single tool by Pi name (used when a tool is removed on list_changed). */
489
+ private _deactivateServerTool(piName: string): void {
490
+ const currentActive = this.pi.getActiveTools();
491
+ const remaining = currentActive.filter((n) => n !== piName);
492
+ this.pi.setActiveTools(remaining);
493
+ }
494
+ }
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 juicesharp
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,14 @@
1
+ import type { GuidanceFields } from "./rpiv-config/index.js";
2
+ import { configPath, loadJsonConfig, validateGuidanceFields } from "./rpiv-config/index.js";
3
+
4
+ const CONFIG_PATH = configPath("rpiv-todo");
5
+
6
+ interface TodoConfig {
7
+ guidance?: GuidanceFields;
8
+ }
9
+
10
+ export function loadConfig(): TodoConfig {
11
+ return loadJsonConfig<TodoConfig>(CONFIG_PATH);
12
+ }
13
+
14
+ export { validateGuidanceFields };
@@ -0,0 +1,113 @@
1
+ /**
2
+ * rpiv-todo — Pi extension. Registers the `todo` tool, `/todos` slash
3
+ * command, and the persistent TodoOverlay widget.
4
+ *
5
+ * TUI chrome strings localize at render time via the i18n bridge. Strings are
6
+ * registered with rpiv-i18n here, once, at module init — but only when the
7
+ * SDK is actually installed. If `@juicesharp/rpiv-i18n` is missing (standalone
8
+ * install of just this package), the dynamic-load shim no-ops and the bridge's
9
+ * `t(key, fallback)` returns the inline English literal at every call site.
10
+ * The extension stays online either way.
11
+ *
12
+ * Adding a locale: drop `locales/<code>.json` next to en.json (mirroring the
13
+ * key set). No edit needed here — `registerLocalesFromDir` iterates
14
+ * `SUPPORTED_LOCALES` from the SDK. See `@juicesharp/rpiv-i18n` README →
15
+ * "Contributing translations" for the full convention.
16
+ *
17
+ * Extracted from rpiv-pi@7525a5d. Tool name "todo" and widget key
18
+ * "rpiv-todos" preserved verbatim so existing session history replays
19
+ * correctly after upgrade.
20
+ */
21
+
22
+ import type { ExtensionAPI } from "phi-code";
23
+ import { I18N_NAMESPACE } from "./state/i18n-bridge.js";
24
+ import { replayFromBranch } from "./state/replay.js";
25
+ import { replaceState } from "./state/store.js";
26
+ import { registerTodosCommand, registerTodoTool, TOOL_NAME } from "./todo.js";
27
+ import { TodoOverlay } from "./todo-overlay.js";
28
+
29
+ type I18nLoader = {
30
+ registerLocalesFromDir: (namespace: string, packageUrl: string, options?: { label?: string }) => void;
31
+ };
32
+
33
+ // Dynamic import keeps `@juicesharp/rpiv-i18n` a soft optional peer: when the
34
+ // SDK is installed alongside this package the strings register and
35
+ // `/languages` flips them live; when it isn't, the import rejects here, we
36
+ // no-op, and the bridge's English-fallback shim keeps the extension online.
37
+ //
38
+ // The `/loader` subpath is used instead of the SDK entry so the i18n-ui +
39
+ // pi-tui modules are not pulled into our load graph just to register strings.
40
+ try {
41
+ const i18nLoaderSpecifier: string = "@juicesharp/rpiv-i18n/loader";
42
+ const sdk = (await import(i18nLoaderSpecifier)) as I18nLoader;
43
+ sdk.registerLocalesFromDir(I18N_NAMESPACE, import.meta.url, { label: "rpiv-todo" });
44
+ } catch {
45
+ // SDK absent — extension still loads with English-only UI.
46
+ }
47
+
48
+ // pi-core's ExtensionRunner throws this exact phrase from an invalidated ctx
49
+ // proxy after session replacement/reload. Match the stable substring so genuine
50
+ // replay bugs still propagate instead of being silently swallowed.
51
+ function isStaleCtxError(e: unknown): boolean {
52
+ return /stale after session replacement/.test(String(e));
53
+ }
54
+
55
+ export default function (pi: ExtensionAPI) {
56
+ // Todo overlay widget — constructed lazily at the first session_start with UI.
57
+ let todoOverlay: TodoOverlay | undefined;
58
+
59
+ registerTodoTool(pi);
60
+ registerTodosCommand(pi);
61
+
62
+ pi.on("session_start", async (_event, ctx) => {
63
+ replaceState(replayFromBranch(ctx));
64
+ if (ctx.hasUI) {
65
+ todoOverlay ??= new TodoOverlay();
66
+ todoOverlay.setUICtx(ctx.ui);
67
+ todoOverlay.resetCompletedDisplayState();
68
+ todoOverlay.update();
69
+ }
70
+ });
71
+
72
+ pi.on("session_compact", async (_event, ctx) => {
73
+ // Auto-compaction races session disposal: pi-core invalidates the
74
+ // extension runner while still emitting session_compact, so `ctx` may be
75
+ // a dead proxy whose getters throw the stale error. The compacting session
76
+ // is being discarded — the replacement session's session_start replays
77
+ // state — so keep current state on a stale ctx. Other errors are real
78
+ // replay bugs and must propagate.
79
+ try {
80
+ replaceState(replayFromBranch(ctx));
81
+ } catch (e) {
82
+ if (!isStaleCtxError(e)) throw e;
83
+ }
84
+ todoOverlay?.resetCompletedDisplayState();
85
+ todoOverlay?.update();
86
+ });
87
+
88
+ pi.on("session_tree", async (_event, ctx) => {
89
+ try {
90
+ replaceState(replayFromBranch(ctx));
91
+ } catch (e) {
92
+ if (!isStaleCtxError(e)) throw e;
93
+ }
94
+ todoOverlay?.resetCompletedDisplayState();
95
+ todoOverlay?.update();
96
+ });
97
+
98
+ pi.on("session_shutdown", async () => {
99
+ todoOverlay?.dispose();
100
+ todoOverlay = undefined;
101
+ });
102
+
103
+ // Reads getTodos() at render time; do NOT call replayFromBranch here
104
+ // (branch is stale — message_end runs after tool_execution_end).
105
+ pi.on("tool_execution_end", async (event) => {
106
+ if (event.toolName !== TOOL_NAME || event.isError) return;
107
+ todoOverlay?.update();
108
+ });
109
+
110
+ pi.on("agent_start", async () => {
111
+ todoOverlay?.hideCompletedTasksFromPreviousTurn();
112
+ });
113
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "_meta.notes": "Auto-translated draft. Native-speaker review welcome via PR. Unicode box-drawing characters (──) stay untranslated. Note: 'overlay.more' renders as '+5 weitere' which is grammatically loose for plural agreement — accepted for badge brevity.",
3
+
4
+ "status.pending": "ausstehend",
5
+ "status.in_progress": "in Bearbeitung",
6
+ "status.completed": "erledigt",
7
+ "status.deleted": "gelöscht",
8
+
9
+ "overlay.heading": "Todos",
10
+ "overlay.more": "weitere",
11
+
12
+ "command.no_todos": "Noch keine Todos. Bitte den Agenten, welche hinzuzufügen!",
13
+ "command.requires_interactive": "/todos erfordert den interaktiven Modus",
14
+ "command.section.pending": "── Ausstehend ──",
15
+ "command.section.in_progress": "── In Bearbeitung ──",
16
+ "command.section.completed": "── Erledigt ──"
17
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "status.pending": "pending",
3
+ "status.in_progress": "in progress",
4
+ "status.completed": "completed",
5
+ "status.deleted": "deleted",
6
+
7
+ "overlay.heading": "Todos",
8
+ "overlay.more": "more",
9
+
10
+ "command.no_todos": "No todos yet. Ask the agent to add some!",
11
+ "command.requires_interactive": "/todos requires interactive mode",
12
+ "command.section.pending": "── Pending ──",
13
+ "command.section.in_progress": "── In Progress ──",
14
+ "command.section.completed": "── Completed ──"
15
+ }