@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,464 @@
1
+ /**
2
+ * Server lifecycle manager for pi-mcp.
3
+ *
4
+ * Manages MCP server connections using the official SDK.
5
+ * Deliberately thin: the SDK handles protocol state, transport, and process lifecycle.
6
+ * This module handles:
7
+ * - 3-state lifecycle per server (stopped / starting / ready)
8
+ * - Retry with a fixed delay schedule
9
+ * - roots/list capability for the MCP handshake
10
+ * - notifications/tools/list_changed → tool refresh callback
11
+ * - Stderr capture (circular buffer)
12
+ * - PID tracking for safety-net SIGKILL on shutdown failure
13
+ */
14
+
15
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
16
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
17
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
18
+ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
19
+ import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
20
+ import {
21
+ ListRootsRequestSchema,
22
+ ToolListChangedNotificationSchema,
23
+ LoggingMessageNotificationSchema,
24
+ } from "@modelcontextprotocol/sdk/types.js";
25
+ import { McpError } from "./errors.js";
26
+ import type { McpConfig, ServerConfig, Settings, AuthConfig } from "./config.js";
27
+ import { McpOAuthProvider, getAuthStatus, resetAuth } from "./oauth-provider.js";
28
+ import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
29
+
30
+ // ─── Types ────────────────────────────────────────────────────────────────────
31
+
32
+ export type ServerState = "stopped" | "starting" | "ready";
33
+
34
+ /** Fixed retry delay schedule — predictable, no jitter math needed. */
35
+ const RETRY_DELAYS_MS = [1000, 3000, 5000, 10000, 30000] as const;
36
+
37
+ /** Maximum stderr lines stored per server (circular). */
38
+ const STDERR_BUFFER_SIZE = 100;
39
+
40
+ export interface ManagedServer {
41
+ name: string;
42
+ config: ServerConfig;
43
+ state: ServerState;
44
+ client: Client | null;
45
+ /** PID of the child process (stdio transport only). Used for safety-net cleanup. */
46
+ childPid: number | null;
47
+ retryCount: number;
48
+ lastError: Error | null;
49
+ /** Recent stderr lines from the server subprocess. */
50
+ stderrLog: string[];
51
+ healthCheckTimer: ReturnType<typeof setInterval> | null;
52
+ /** Pending retry timeout — cleared on shutdown to prevent ghost reconnects. */
53
+ retryTimer: ReturnType<typeof setTimeout> | null;
54
+ }
55
+
56
+ /** Called after tool list is refreshed for a server (e.g. on list_changed notification). */
57
+ export type ToolRefreshCallback = (serverName: string, client: Client) => Promise<void>;
58
+
59
+ export interface TransportAuthCallbacks {
60
+ /** Called when OAuth authorization is required (browser redirect needed). */
61
+ onAuthRequired: (serverName: string, authorizationUrl: URL) => void | Promise<void>;
62
+ }
63
+
64
+ // ─── Transport Factory ────────────────────────────────────────────────────────
65
+
66
+ function createTransport(
67
+ serverName: string,
68
+ config: ServerConfig,
69
+ onStderr: (line: string) => void,
70
+ authCallbacks?: TransportAuthCallbacks,
71
+ ): Transport {
72
+ // Build requestInit for static headers (API keys, etc.)
73
+ const requestInit: RequestInit | undefined = config.headers
74
+ ? { headers: config.headers }
75
+ : undefined;
76
+
77
+ // Build OAuth authProvider if auth config is present
78
+ let authProvider: OAuthClientProvider | undefined;
79
+ if (config.auth && config.transport !== "stdio") {
80
+ authProvider = new McpOAuthProvider(
81
+ serverName,
82
+ config.auth,
83
+ authCallbacks
84
+ ? (url: URL) => authCallbacks.onAuthRequired(serverName, url)
85
+ : undefined,
86
+ );
87
+ }
88
+
89
+ switch (config.transport) {
90
+ case "stdio": {
91
+ // Build clean env: process.env may contain undefined values,
92
+ // child_process.spawn silently drops them, but let's be explicit
93
+ const env: Record<string, string> = {};
94
+ for (const [key, value] of Object.entries(process.env)) {
95
+ if (value !== undefined) env[key] = value;
96
+ }
97
+ Object.assign(env, config.env ?? {});
98
+ const transport = new StdioClientTransport({
99
+ command: config.command!,
100
+ args: config.args,
101
+ env,
102
+ stderr: "pipe",
103
+ });
104
+ // Capture stderr lines into the circular buffer
105
+ transport.stderr?.on("data", (chunk: Buffer) => {
106
+ const lines = chunk.toString().split("\n").filter(Boolean);
107
+ for (const line of lines) onStderr(line);
108
+ });
109
+ return transport;
110
+ }
111
+ case "streamable-http":
112
+ return new StreamableHTTPClientTransport(
113
+ new URL(config.url!),
114
+ {
115
+ ...(requestInit && { requestInit }),
116
+ ...(authProvider && { authProvider }),
117
+ },
118
+ ) as unknown as Transport;
119
+ case "sse":
120
+ return new SSEClientTransport(
121
+ new URL(config.url!),
122
+ {
123
+ ...(requestInit && { requestInit }),
124
+ ...(authProvider && { authProvider }),
125
+ },
126
+ );
127
+ }
128
+ }
129
+
130
+ // ─── ServerManager ────────────────────────────────────────────────────────────
131
+
132
+ export class ServerManager {
133
+ private readonly servers = new Map<string, ManagedServer>();
134
+ private settings: Settings;
135
+ private onToolRefresh: ToolRefreshCallback | null = null;
136
+
137
+ private authCallbacks: TransportAuthCallbacks | undefined;
138
+
139
+ constructor(config: McpConfig, authCallbacks?: TransportAuthCallbacks) {
140
+ this.settings = config.settings;
141
+ this.authCallbacks = authCallbacks;
142
+ for (const [name, serverConfig] of Object.entries(config.mcpServers)) {
143
+ this.servers.set(name, {
144
+ name,
145
+ config: serverConfig,
146
+ state: "stopped",
147
+ client: null,
148
+ childPid: null,
149
+ retryCount: 0,
150
+ lastError: null,
151
+ stderrLog: [],
152
+ healthCheckTimer: null,
153
+ retryTimer: null,
154
+ });
155
+ }
156
+ }
157
+
158
+ /** Register callback invoked after tool list changes for a server. */
159
+ setToolRefreshCallback(cb: ToolRefreshCallback): void {
160
+ this.onToolRefresh = cb;
161
+ }
162
+
163
+ getServer(name: string): ManagedServer | undefined {
164
+ return this.servers.get(name);
165
+ }
166
+
167
+ getAllServers(): ManagedServer[] {
168
+ return Array.from(this.servers.values());
169
+ }
170
+
171
+ getReadyServers(): ManagedServer[] {
172
+ return this.getAllServers().filter((s) => s.state === "ready");
173
+ }
174
+
175
+ /** Status summary for /mcp command. */
176
+ getStatusSummary(): string {
177
+ const all = this.getAllServers();
178
+ if (all.length === 0) return "pi-mcp: No servers configured (create ~/.pi/agent/mcp.json)";
179
+ const lines = all.map((s) => {
180
+ const icon = s.state === "ready" ? "✓" : s.state === "starting" ? "⟳" : "✗";
181
+ const err = s.lastError ? ` — ${s.lastError.message}` : "";
182
+ return ` ${icon} ${s.name} (${s.state})${err}`;
183
+ });
184
+ const ready = all.filter((s) => s.state === "ready").length;
185
+ return [`MCP: ${ready}/${all.length} servers ready`, ...lines].join("\n");
186
+ }
187
+
188
+ /** Reset OAuth credentials for a server, forcing re-authorization on next connect. */
189
+ async resetServerAuth(name: string): Promise<void> {
190
+ await resetAuth(name);
191
+ }
192
+
193
+ /** Get auth status for a server. */
194
+ async getServerAuthStatus(name: string): Promise<{
195
+ hasTokens: boolean;
196
+ hasClientInfo: boolean;
197
+ savedAt: string | undefined;
198
+ scope: string | undefined;
199
+ } | null> {
200
+ return getAuthStatus(name);
201
+ }
202
+
203
+ /** Get recent stderr output for a server. */
204
+ getServerLogs(name: string): string {
205
+ const server = this.servers.get(name);
206
+ if (!server) return `No server named "${name}"`;
207
+ if (server.stderrLog.length === 0) return `(no stderr output from ${name})`;
208
+ return server.stderrLog.join("\n");
209
+ }
210
+
211
+ // ─── Lifecycle ──────────────────────────────────────────────────────────────
212
+
213
+ /**
214
+ * Start a server and connect to it.
215
+ * cwd is passed to roots/list — the workspace root exposed to the MCP server.
216
+ */
217
+ async startServer(name: string, cwd: string): Promise<void> {
218
+ const server = this.servers.get(name);
219
+ if (!server) {
220
+ throw new McpError(`Unknown server "${name}"`, name, "config");
221
+ }
222
+ if (server.state !== "stopped") return; // Already starting or ready
223
+ // Reset retry count on explicit start — allows /mcp:start after exhaustion
224
+ server.retryCount = 0;
225
+ await this._connect(server, cwd);
226
+ }
227
+
228
+ async stopServer(name: string): Promise<void> {
229
+ const server = this.servers.get(name);
230
+ if (!server || server.state === "stopped") return;
231
+ await this._shutdown(server);
232
+ }
233
+
234
+ async shutdownAll(): Promise<void> {
235
+ await Promise.allSettled(
236
+ Array.from(this.servers.values()).map((s) => this._shutdown(s)),
237
+ );
238
+ }
239
+
240
+ /**
241
+ * Rebuild the server map from a new config.
242
+ * Must only be called after shutdownAll() — old servers are discarded.
243
+ * New servers that didn't exist before are added; servers removed from
244
+ * config are dropped (their tools should already be deactivated).
245
+ */
246
+ rebuildServers(config: McpConfig): void {
247
+ this.settings = config.settings;
248
+ this.servers.clear();
249
+ for (const [name, serverConfig] of Object.entries(config.mcpServers)) {
250
+ this.servers.set(name, {
251
+ name,
252
+ config: serverConfig,
253
+ state: "stopped",
254
+ client: null,
255
+ childPid: null,
256
+ retryCount: 0,
257
+ lastError: null,
258
+ stderrLog: [],
259
+ healthCheckTimer: null,
260
+ retryTimer: null,
261
+ });
262
+ }
263
+ }
264
+
265
+ // ─── Internal ───────────────────────────────────────────────────────────────
266
+
267
+ private async _connect(server: ManagedServer, cwd: string): Promise<void> {
268
+ server.state = "starting";
269
+ server.lastError = null;
270
+ // Note: retryCount is NOT reset here — it's only reset after successful connect.
271
+ // This ensures the retry limit is respected across reconnection attempts.
272
+ // It IS reset when startServer() is called explicitly (e.g. /mcp:start)
273
+ // via the caller having already set retryCount = 0 before calling _connect.
274
+
275
+ const appendStderr = (line: string): void => {
276
+ server.stderrLog.push(line);
277
+ if (server.stderrLog.length > STDERR_BUFFER_SIZE) {
278
+ server.stderrLog.shift();
279
+ }
280
+ };
281
+
282
+ let transport: Transport;
283
+ try {
284
+ transport = createTransport(server.name, server.config, appendStderr, this.authCallbacks);
285
+ } catch (err) {
286
+ server.state = "stopped";
287
+ server.lastError = err instanceof Error ? err : new Error(String(err));
288
+ throw new McpError(
289
+ `Failed to create transport: ${server.lastError.message}`,
290
+ server.name,
291
+ "connection",
292
+ err,
293
+ );
294
+ }
295
+
296
+ const client = new Client(
297
+ { name: "pi-mcp", version: "1.0.0" },
298
+ {
299
+ capabilities: {
300
+ // Expose workspace root to MCP servers
301
+ roots: { listChanged: true },
302
+ // Sampling: explicitly NOT declared — not supported in v1
303
+ },
304
+ },
305
+ );
306
+
307
+ // Handle roots/list requests from the server
308
+ client.setRequestHandler(
309
+ ListRootsRequestSchema,
310
+ async () => ({
311
+ // file:// URI for the workspace root. On Windows this produces
312
+ // file://C:\... which is technically non-standard but functional
313
+ // for the common case (MCP servers use roots as hints, not strict paths).
314
+ roots: [{ uri: `file://${cwd}`, name: "workspace" }],
315
+ }),
316
+ );
317
+
318
+ // tools/list_changed: re-discover tools and update Pi registrations
319
+ client.setNotificationHandler(
320
+ ToolListChangedNotificationSchema,
321
+ async () => {
322
+ if (this.onToolRefresh && server.client) {
323
+ try {
324
+ await this.onToolRefresh(server.name, server.client);
325
+ } catch (err) {
326
+ console.error(
327
+ `[pi-mcp] Failed to refresh tools for ${server.name}:`,
328
+ err,
329
+ );
330
+ }
331
+ }
332
+ },
333
+ );
334
+
335
+ // notifications/message: structured logging from MCP servers
336
+ client.setNotificationHandler(
337
+ LoggingMessageNotificationSchema,
338
+ async (notification) => {
339
+ const { level = "info", logger = server.name, data } = notification.params ?? {};
340
+ const msg = typeof data === "string" ? data : JSON.stringify(data);
341
+ console.error(`[pi-mcp:${server.name}] [${level}] ${logger}: ${msg}`);
342
+ appendStderr(`[${level}] ${logger}: ${msg}`);
343
+ },
344
+ );
345
+
346
+ try {
347
+ await client.connect(transport);
348
+ } catch (err) {
349
+ server.state = "stopped";
350
+ server.lastError = err instanceof Error ? err : new Error(String(err));
351
+ // Clean up the transport we created (client.close() also closes transport)
352
+ try { await client.close(); } catch { /* best effort */ }
353
+ // Attempt retry if under the limit
354
+ await this._scheduleRetry(server, cwd);
355
+ return;
356
+ }
357
+
358
+ // Guard against shutdown being called while we were connecting.
359
+ // _shutdown sets state to "stopped" but can't close the local client variable.
360
+ if (server.state !== "starting") {
361
+ try { await client.close(); } catch { /* best effort */ }
362
+ return;
363
+ }
364
+
365
+ // Extract PID from stdio transport for safety-net cleanup
366
+ if (server.config.transport === "stdio") {
367
+ // StdioClientTransport exposes the underlying process
368
+ server.childPid = (transport as any).process?.pid ?? null;
369
+ }
370
+
371
+ server.client = client;
372
+ server.state = "ready";
373
+ server.retryCount = 0;
374
+ server.lastError = null;
375
+
376
+ // Start opt-in health check
377
+ if (server.config.healthCheckIntervalMs) {
378
+ server.healthCheckTimer = setInterval(async () => {
379
+ try {
380
+ await client.ping();
381
+ } catch {
382
+ clearInterval(server.healthCheckTimer!);
383
+ server.healthCheckTimer = null;
384
+ console.error(`[pi-mcp] Health check failed for ${server.name}, reconnecting`);
385
+ server.state = "stopped";
386
+ await this._scheduleRetry(server, cwd);
387
+ }
388
+ }, server.config.healthCheckIntervalMs);
389
+ }
390
+
391
+ // Trigger initial tool registration
392
+ if (this.onToolRefresh) {
393
+ try {
394
+ await this.onToolRefresh(server.name, client);
395
+ } catch (err) {
396
+ console.error(`[pi-mcp] Initial tool registration failed for ${server.name}:`, err);
397
+ }
398
+ }
399
+ }
400
+
401
+ private async _scheduleRetry(server: ManagedServer, cwd: string): Promise<void> {
402
+ const maxRetries = this.settings.maxRetries;
403
+ if (server.retryCount >= maxRetries) {
404
+ console.error(
405
+ `[pi-mcp] Server "${server.name}" failed after ${maxRetries} retries: ${server.lastError?.message}`,
406
+ );
407
+ return;
408
+ }
409
+
410
+ const delayMs = RETRY_DELAYS_MS[Math.min(server.retryCount, RETRY_DELAYS_MS.length - 1)] ?? 30000;
411
+ server.retryCount++;
412
+ console.error(
413
+ `[pi-mcp] Retrying "${server.name}" in ${delayMs}ms (attempt ${server.retryCount}/${maxRetries})`,
414
+ );
415
+
416
+ await new Promise<void>((resolve) => {
417
+ server.retryTimer = setTimeout(() => {
418
+ server.retryTimer = null;
419
+ resolve();
420
+ }, delayMs);
421
+ });
422
+ if (server.state !== "stopped") return; // May have been stopped externally
423
+ await this._connect(server, cwd);
424
+ }
425
+
426
+ private async _shutdown(server: ManagedServer): Promise<void> {
427
+ if (server.state === "stopped") return;
428
+
429
+ // Cancel pending retry
430
+ if (server.retryTimer) {
431
+ clearTimeout(server.retryTimer);
432
+ server.retryTimer = null;
433
+ }
434
+
435
+ // Stop health check
436
+ if (server.healthCheckTimer) {
437
+ clearInterval(server.healthCheckTimer);
438
+ server.healthCheckTimer = null;
439
+ }
440
+
441
+ server.state = "stopped";
442
+ server.lastError = null;
443
+ const client = server.client;
444
+ const pid = server.childPid;
445
+ server.client = null;
446
+ server.childPid = null;
447
+
448
+ try {
449
+ // SDK handles transport-specific cleanup:
450
+ // - stdio: closes stdin, waits for process exit, sends SIGTERM/SIGKILL
451
+ // - streamable-http/sse: closes HTTP connections
452
+ await client?.close();
453
+ } catch {
454
+ // If SDK cleanup fails, force kill the subprocess as a safety net
455
+ if (pid !== null) {
456
+ try {
457
+ process.kill(pid, "SIGKILL");
458
+ } catch {
459
+ // Process may already be dead
460
+ }
461
+ }
462
+ }
463
+ }
464
+ }