@fitchmultz/pi-mcp-adapter 7.1.1 → 8.0.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.
@@ -0,0 +1,81 @@
1
+ import { appendFileSync, createReadStream, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join, resolve } from "node:path";
4
+ import { randomBytes } from "node:crypto";
5
+ import { createInterface } from "node:readline";
6
+ /** Private append-only history; failed writes stay owned in memory, never silently discarded. */
7
+ export function createUiMessageHistory(outputDirectory) {
8
+ const messages = { prompts: [], intents: [], notifications: [], contexts: [] };
9
+ let failed = false;
10
+ return {
11
+ messages,
12
+ append(category, value) {
13
+ try {
14
+ if (failed)
15
+ throw new Error(messages.historyError);
16
+ if (!messages.historyRef) {
17
+ const root = outputDirectory === undefined ? tmpdir() : resolve(outputDirectory);
18
+ if (outputDirectory !== undefined)
19
+ mkdirSync(root, { recursive: true, mode: 0o700 });
20
+ const dir = mkdtempSync(join(root, "pi-mcp-output-"));
21
+ const ref = join(dir, `output-${randomBytes(4).toString("hex")}.txt`);
22
+ writeFileSync(ref, "", { mode: 0o600 });
23
+ messages.historyRef = ref;
24
+ messages.historyBytes = 0;
25
+ messages.historyCount = 0;
26
+ }
27
+ const line = JSON.stringify({ category, value }) + "\n";
28
+ // ponytail: synchronous append owns acceptance order; upgrade to an awaited serial writer if local disk latency becomes material.
29
+ appendFileSync(messages.historyRef, line, { flush: true });
30
+ messages.historyBytes += Buffer.byteLength(line);
31
+ messages.historyCount++;
32
+ return undefined;
33
+ }
34
+ catch (error) {
35
+ failed = true;
36
+ messages.historyError = error instanceof Error ? error.message : String(error);
37
+ if (category === "intents")
38
+ messages.intents.push(value);
39
+ else
40
+ messages[category].push(value);
41
+ return `UI history could not be saved; messages remain in memory for recovery: ${messages.historyError}. Unsaved messages will be lost on reload or process exit.`;
42
+ }
43
+ },
44
+ };
45
+ }
46
+ export async function* uiMessages(messages, category) {
47
+ if (messages.historyRef && messages.historyBytes) {
48
+ // A failed partial append is not an accepted event; the in-memory tail owns it instead.
49
+ const input = createReadStream(messages.historyRef, { encoding: "utf8", end: messages.historyBytes - 1 });
50
+ let bytes = 0, count = 0;
51
+ input.on("data", chunk => { bytes += Buffer.byteLength(chunk); });
52
+ const lines = createInterface({ input, crlfDelay: Infinity });
53
+ try {
54
+ for await (const line of lines) {
55
+ const record = JSON.parse(line);
56
+ count++;
57
+ if (record.category === category)
58
+ yield record.value;
59
+ }
60
+ if (bytes !== messages.historyBytes || (messages.historyCount !== undefined && count !== messages.historyCount)) {
61
+ throw new Error("UI history is incomplete; accepted messages remain pending recovery");
62
+ }
63
+ }
64
+ finally {
65
+ lines.close();
66
+ input.destroy();
67
+ }
68
+ }
69
+ for (const value of messages[category])
70
+ yield value;
71
+ }
72
+ export async function materializeUiMessages(messages) {
73
+ const result = { prompts: [], intents: [], notifications: [], contexts: messages.contexts };
74
+ for await (const value of uiMessages(messages, "prompts"))
75
+ result.prompts.push(value);
76
+ for await (const value of uiMessages(messages, "intents"))
77
+ result.intents.push(value);
78
+ for await (const value of uiMessages(messages, "notifications"))
79
+ result.notifications.push(value);
80
+ return result;
81
+ }
package/dist/ui-server.js CHANGED
@@ -10,6 +10,7 @@ import { logger } from "./logger.js";
10
10
  import { SessionRecoveryAuthRequiredError, withSessionRecovery } from "./session-recovery.js";
11
11
  import { ensureToolCallApproved, isToolCallApprovalRequired } from "./tool-approval.js";
12
12
  import { extractUiToolVisibility, isUiToolCallableByApp } from "./ui-tool-visibility.js";
13
+ import { createUiMessageHistory } from "./ui-message-history.js";
13
14
  import { createUiModelContextUpdate, extractUiPromptText, getVisualizationStreamEnvelope, isServerDisabled, isToolAllowed, resolveToolPrefix, } from "./types.js";
14
15
  const MAX_BODY_SIZE = 2 * 1024 * 1024;
15
16
  const ABANDONED_GRACE_MS = 60_000;
@@ -38,12 +39,8 @@ export async function startUiServer(options) {
38
39
  const eventLog = [];
39
40
  let streamSummary;
40
41
  // Track messages from UI for retrieval
41
- const sessionMessages = {
42
- prompts: [],
43
- notifications: [],
44
- intents: [],
45
- contexts: [],
46
- };
42
+ const history = createUiMessageHistory(options.state?.outputDirectory);
43
+ const sessionMessages = history.messages;
47
44
  const hostContext = {
48
45
  displayMode: currentDisplayMode,
49
46
  availableDisplayModes: ["inline", "fullscreen", "pip"],
@@ -281,6 +278,11 @@ export async function startUiServer(options) {
281
278
  return;
282
279
  if (!validateTokenBody(body, sessionToken, res))
283
280
  return;
281
+ // A partial upload is not accepted until its authenticated body is complete.
282
+ if (completed && (url.pathname === "/proxy/ui/message" || url.pathname === "/proxy/ui/context")) {
283
+ sendJson(res, 409, { ok: false, error: "UI session is complete; message was not accepted" });
284
+ return;
285
+ }
284
286
  const params = body.params ?? {};
285
287
  touchHeartbeat();
286
288
  if (url.pathname === "/proxy/tools/call") {
@@ -376,14 +378,15 @@ export async function startUiServer(options) {
376
378
  const promptText = extractUiPromptText(msgParams);
377
379
  // Track messages by type (order: prompt → intent → notify)
378
380
  // Must match the order in index.ts onMessage handler
381
+ let warning;
379
382
  if (promptText) {
380
- sessionMessages.prompts.push(promptText);
383
+ warning = history.append("prompts", promptText);
381
384
  log.debug("UI prompt received", { prompt: promptText.slice(0, 100) });
382
385
  }
383
386
  else if (msgParams.type === "intent" || msgParams.intent) {
384
387
  const intentName = msgParams.intent ?? "";
385
388
  if (intentName) {
386
- sessionMessages.intents.push({
389
+ warning = history.append("intents", {
387
390
  intent: intentName,
388
391
  ...(msgParams.params !== undefined ? { params: msgParams.params } : {}),
389
392
  });
@@ -393,12 +396,16 @@ export async function startUiServer(options) {
393
396
  else if (msgParams.type === "notify" || msgParams.message) {
394
397
  const notifyText = msgParams.message ?? "";
395
398
  if (notifyText) {
396
- sessionMessages.notifications.push(notifyText);
399
+ warning = history.append("notifications", notifyText);
397
400
  log.debug("UI notification", { message: notifyText.slice(0, 100) });
398
401
  }
399
402
  }
400
403
  await options.onMessage?.(msgParams);
401
- sendJson(res, 200, { ok: true, result: {} });
404
+ if (warning) {
405
+ log.warn(warning);
406
+ options.state?.ui?.notify(warning, "warning");
407
+ }
408
+ sendJson(res, 200, { ok: true, result: {}, ...(warning ? { warning } : {}) });
402
409
  return;
403
410
  }
404
411
  if (url.pathname === "/proxy/ui/context") {
@@ -297,7 +297,8 @@ export async function maybeStartUiSession(state, request) {
297
297
  if (state.uiServer === handle) {
298
298
  const messages = handle.getSessionMessages();
299
299
  const stream = handle.getStreamSummary();
300
- const hasContent = messages.prompts.length > 0 ||
300
+ const hasContent = !!messages.historyCount ||
301
+ messages.prompts.length > 0 ||
301
302
  messages.intents.length > 0 ||
302
303
  messages.notifications.length > 0 ||
303
304
  messages.contexts.length > 0 ||
@@ -316,7 +317,7 @@ export async function maybeStartUiSession(state, request) {
316
317
  }
317
318
  log.debug("Session completed", {
318
319
  reason,
319
- prompts: messages.prompts.length,
320
+ prompts: messages.historyCount ?? messages.prompts.length,
320
321
  intents: messages.intents.length,
321
322
  notifications: messages.notifications.length,
322
323
  contexts: messages.contexts.length,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fitchmultz/pi-mcp-adapter",
3
- "version": "7.1.1",
3
+ "version": "8.0.0",
4
4
  "description": "Fitch's independently maintained MCP adapter for Pi coding agent",
5
5
  "type": "module",
6
6
  "types": "./dist/index.d.ts",
@@ -108,9 +108,9 @@
108
108
  }
109
109
  },
110
110
  "devDependencies": {
111
- "@earendil-works/pi-ai": "0.99.2",
112
- "@earendil-works/pi-coding-agent": "0.99.2",
113
- "@earendil-works/pi-tui": "0.99.2",
111
+ "@earendil-works/pi-ai": "1.0.0",
112
+ "@earendil-works/pi-coding-agent": "1.0.0",
113
+ "@earendil-works/pi-tui": "1.0.0",
114
114
  "@modelcontextprotocol/server": "2.2.0",
115
115
  "@types/cross-spawn": "6.0.6",
116
116
  "@types/node": "24.19.0",
@@ -1,18 +0,0 @@
1
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import type { McpExtensionState } from "./state.ts";
3
- /** Optional host event. Published Pi hosts without this event simply never emit it.
4
- * Keep its structural typing local; do not require a private host package/version. */
5
- export interface McpCheckpointEvent {
6
- readonly boundary: "turn" | "settled";
7
- readonly signal: AbortSignal;
8
- invalidate(): void;
9
- }
10
- export type McpCheckpointResult = {
11
- sleepReady: true;
12
- } | {
13
- sleepReady: false;
14
- reason: string;
15
- };
16
- export declare function onSessionCheckpoint(pi: ExtensionAPI, handler: (event: McpCheckpointEvent) => Promise<McpCheckpointResult>): void;
17
- /** No shutdown, credential copying, connection replay or permanent owner cancellation. */
18
- export declare function prepareMcpCheckpoint(state: McpExtensionState, event: McpCheckpointEvent): Promise<McpCheckpointResult>;
@@ -1,53 +0,0 @@
1
- import { flushMetadataCache } from "./init.js";
2
- import { getOAuthCheckpointBlocker, holdOAuthCheckpoint } from "./mcp-auth-flow.js";
3
- import { isServerDisabled } from "./types.js";
4
- export function onSessionCheckpoint(pi, handler) {
5
- pi.on("session_checkpoint", handler);
6
- }
7
- /** No shutdown, credential copying, connection replay or permanent owner cancellation. */
8
- export async function prepareMcpCheckpoint(state, event) {
9
- event.signal.throwIfAborted();
10
- const releases = [];
11
- const release = () => { for (const undo of releases.splice(0).reverse())
12
- undo(); };
13
- // Fence all owned sources synchronously, before the first await (including trace flush).
14
- event.signal.addEventListener("abort", release, { once: true });
15
- const undo = () => { release(); event.signal.removeEventListener("abort", release); };
16
- const blocked = (reason) => { undo(); return { sleepReady: false, reason }; };
17
- try {
18
- releases.push(state.owner.holdCheckpoint(event));
19
- releases.push(state.manager.holdCheckpoint(event));
20
- releases.push(holdOAuthCheckpoint(state.oauthRuntime, event));
21
- releases.push(state.lifecycle.pauseForCheckpoint());
22
- if (state.lifecycle.hasActiveHealthCheck())
23
- return blocked("MCP health check is active");
24
- for (const [name, definition] of Object.entries(state.config.mcpServers)) {
25
- if (!isServerDisabled(definition) && !definition.url)
26
- return blocked(`MCP ${name}: stdio/Unix server state is not reconstructible`);
27
- }
28
- const callback = state.owner.getCheckpointBlocker();
29
- if (callback)
30
- return blocked(callback);
31
- if (state.activeScripts)
32
- return blocked("MCP script is active");
33
- if (state.uiServer || state.completedUiSessions.length)
34
- return blocked("MCP UI session/messages require the running runtime");
35
- if (state.approvedToolCalls.size || state.consentManager.hasDecisions())
36
- return blocked("MCP session approvals are not persisted");
37
- const oauth = getOAuthCheckpointBlocker(state.oauthRuntime);
38
- if (oauth)
39
- return blocked(oauth);
40
- const manager = state.manager.getCheckpointBlocker();
41
- if (manager)
42
- return blocked(manager);
43
- flushMetadataCache(state);
44
- await state.manager.flushForCheckpoint();
45
- event.signal.throwIfAborted();
46
- // Release/cancellation is host-owned. Existing clients/timers remain usable afterwards.
47
- return { sleepReady: true };
48
- }
49
- catch (error) {
50
- undo();
51
- throw error;
52
- }
53
- }