@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.
- package/CHANGELOG.md +45 -0
- package/docs/usage.md +1 -1
- package/extensions/phi/btw/LICENSE +21 -0
- package/extensions/phi/btw/btw-ui.ts +238 -0
- package/extensions/phi/btw/btw.ts +363 -0
- package/extensions/phi/btw/index.ts +17 -0
- package/extensions/phi/btw/prompts/btw-system.txt +9 -0
- package/extensions/phi/chrome/LICENSE +21 -0
- package/extensions/phi/chrome/browser-extension/manifest.json +26 -0
- package/extensions/phi/chrome/browser-extension/service_worker.js +2388 -0
- package/extensions/phi/chrome/browser-extension/snapshot_injected.js +677 -0
- package/extensions/phi/chrome/index.ts +1799 -0
- package/extensions/phi/goal/LICENSE +21 -0
- package/extensions/phi/goal/index.ts +784 -0
- package/extensions/phi/mcp/LICENSE +21 -0
- package/extensions/phi/mcp/callback-server.ts +263 -0
- package/extensions/phi/mcp/config.ts +195 -0
- package/extensions/phi/mcp/errors.ts +35 -0
- package/extensions/phi/mcp/index.ts +376 -0
- package/extensions/phi/mcp/oauth-provider.ts +367 -0
- package/extensions/phi/mcp/server-manager.ts +464 -0
- package/extensions/phi/mcp/tool-bridge.ts +494 -0
- package/extensions/phi/todo/LICENSE +21 -0
- package/extensions/phi/todo/config.ts +14 -0
- package/extensions/phi/todo/index.ts +113 -0
- package/extensions/phi/todo/locales/de.json +17 -0
- package/extensions/phi/todo/locales/en.json +15 -0
- package/extensions/phi/todo/locales/es.json +17 -0
- package/extensions/phi/todo/locales/fr.json +17 -0
- package/extensions/phi/todo/locales/pt-BR.json +17 -0
- package/extensions/phi/todo/locales/pt.json +17 -0
- package/extensions/phi/todo/locales/ru.json +17 -0
- package/extensions/phi/todo/locales/uk.json +17 -0
- package/extensions/phi/todo/rpiv-config/config.ts +223 -0
- package/extensions/phi/todo/rpiv-config/index.ts +12 -0
- package/extensions/phi/todo/state/i18n-bridge.ts +65 -0
- package/extensions/phi/todo/state/invariants.ts +20 -0
- package/extensions/phi/todo/state/replay.ts +38 -0
- package/extensions/phi/todo/state/selectors.ts +107 -0
- package/extensions/phi/todo/state/state-reducer.ts +187 -0
- package/extensions/phi/todo/state/state.ts +18 -0
- package/extensions/phi/todo/state/store.ts +54 -0
- package/extensions/phi/todo/state/task-graph.ts +57 -0
- package/extensions/phi/todo/todo-overlay.ts +194 -0
- package/extensions/phi/todo/todo.ts +146 -0
- package/extensions/phi/todo/tool/response-envelope.ts +94 -0
- package/extensions/phi/todo/tool/types.ts +128 -0
- package/extensions/phi/todo/view/format.ts +162 -0
- 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
|
+
}
|