@fitchmultz/pi-mcp-adapter 5.0.0 → 7.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.
Files changed (129) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/OAUTH.md +3 -3
  3. package/README.md +160 -88
  4. package/dist/app-bridge.bundle.js +75 -33
  5. package/dist/commands.js +9 -6
  6. package/dist/config.js +41 -16
  7. package/dist/direct-tools.d.ts +2 -4
  8. package/dist/direct-tools.js +21 -319
  9. package/dist/gateway-arguments.d.ts +33 -0
  10. package/dist/gateway-arguments.js +63 -0
  11. package/dist/index.js +181 -276
  12. package/dist/init.js +6 -20
  13. package/dist/lifecycle.d.ts +2 -2
  14. package/dist/lifecycle.js +2 -2
  15. package/dist/mcp-auth-flow.js +2 -2
  16. package/dist/mcp-auth.d.ts +1 -1
  17. package/dist/mcp-auth.js +3 -3
  18. package/dist/mcp-code.d.ts +7 -6
  19. package/dist/mcp-code.js +98 -51
  20. package/dist/mcp-oauth-provider.d.ts +5 -5
  21. package/dist/mcp-oauth-provider.js +5 -5
  22. package/dist/mcp-output-guard.d.ts +57 -2
  23. package/dist/mcp-output-guard.js +129 -24
  24. package/dist/mcp-panel.d.ts +3 -3
  25. package/dist/mcp-panel.js +36 -99
  26. package/dist/mcp-script-worker.mjs +5 -2
  27. package/dist/mcp-setup-panel.d.ts +5 -4
  28. package/dist/mcp-setup-panel.js +15 -7
  29. package/dist/mcp-status.js +2 -1
  30. package/dist/mcp-trace.d.ts +1 -1
  31. package/dist/mcp-trace.js +1 -1
  32. package/dist/metadata-cache.d.ts +1 -1
  33. package/dist/metadata-cache.js +25 -88
  34. package/dist/npx-resolver.js +8 -4
  35. package/dist/prompts.d.ts +1 -1
  36. package/dist/prompts.js +1 -1
  37. package/dist/proxy-modes.d.ts +41 -7
  38. package/dist/proxy-modes.js +275 -490
  39. package/dist/resource-tools.d.ts +15 -0
  40. package/dist/resource-tools.js +26 -1
  41. package/dist/sampling-handler.js +5 -1
  42. package/dist/search-ranking.d.ts +0 -2
  43. package/dist/search-ranking.js +70 -137
  44. package/dist/server-manager.d.ts +2 -2
  45. package/dist/server-manager.js +44 -27
  46. package/dist/session-recovery.d.ts +7 -4
  47. package/dist/session-recovery.js +76 -10
  48. package/dist/state.d.ts +1 -1
  49. package/dist/tool-loader.d.ts +32 -0
  50. package/dist/tool-loader.js +152 -0
  51. package/dist/tool-metadata.d.ts +67 -0
  52. package/dist/tool-metadata.js +23 -17
  53. package/dist/tool-registrar.d.ts +4 -4
  54. package/dist/tool-registrar.js +18 -13
  55. package/dist/tool-result-renderer.d.ts +6 -3
  56. package/dist/tool-result-renderer.js +11 -6
  57. package/dist/types.d.ts +11 -36
  58. package/dist/ui-resource-handler.d.ts +2 -2
  59. package/dist/ui-resource-handler.js +2 -2
  60. package/dist/ui-server.js +4 -3
  61. package/dist/ui-stream-types.d.ts +8 -8
  62. package/dist/unix-socket-transport.d.ts +1 -1
  63. package/dist/unix-socket-transport.js +1 -1
  64. package/dist/utils.d.ts +1 -0
  65. package/dist/utils.js +16 -4
  66. package/package.json +37 -96
  67. package/skills/mcp-scripting/SKILL.md +67 -20
  68. package/abort.ts +0 -34
  69. package/agent-dir.ts +0 -24
  70. package/app-bridge.bundle.js +0 -67
  71. package/banner.png +0 -0
  72. package/checkpoint.ts +0 -55
  73. package/commands.ts +0 -619
  74. package/config.ts +0 -1204
  75. package/consent-manager.ts +0 -25
  76. package/direct-tools.ts +0 -408
  77. package/dist/ts-shape.d.ts +0 -2
  78. package/dist/ts-shape.js +0 -147
  79. package/elicitation-handler.ts +0 -349
  80. package/error-signal.ts +0 -21
  81. package/glimpse-ui.ts +0 -80
  82. package/host-html-template.ts +0 -456
  83. package/index.ts +0 -900
  84. package/init.ts +0 -627
  85. package/legacy-migration.ts +0 -47
  86. package/lifecycle.ts +0 -165
  87. package/logger.ts +0 -127
  88. package/mcp-auth-flow.ts +0 -975
  89. package/mcp-auth.ts +0 -793
  90. package/mcp-callback-server.ts +0 -450
  91. package/mcp-code.ts +0 -386
  92. package/mcp-keyring-helper.cjs +0 -89
  93. package/mcp-oauth-provider.ts +0 -774
  94. package/mcp-output-guard.ts +0 -412
  95. package/mcp-panel.ts +0 -978
  96. package/mcp-script-worker.mjs +0 -128
  97. package/mcp-setup-panel.ts +0 -666
  98. package/mcp-status.ts +0 -109
  99. package/mcp-trace.ts +0 -295
  100. package/metadata-cache.ts +0 -333
  101. package/npx-resolver.ts +0 -488
  102. package/onboarding-state.ts +0 -58
  103. package/panel-keys.ts +0 -37
  104. package/prompts.ts +0 -337
  105. package/proxy-modes.ts +0 -1361
  106. package/resource-tools.ts +0 -17
  107. package/runtime-owner.ts +0 -123
  108. package/sampling-handler.ts +0 -273
  109. package/scripts/build.mjs +0 -131
  110. package/scripts/prepare.mjs +0 -102
  111. package/search-ranking.ts +0 -196
  112. package/server-manager.ts +0 -1320
  113. package/session-recovery.ts +0 -316
  114. package/state.ts +0 -64
  115. package/tool-approval.ts +0 -78
  116. package/tool-metadata.ts +0 -235
  117. package/tool-registrar.ts +0 -69
  118. package/tool-result-renderer.ts +0 -286
  119. package/ts-shape.ts +0 -145
  120. package/tsconfig.build.json +0 -13
  121. package/tsconfig.json +0 -16
  122. package/types.ts +0 -810
  123. package/ui-resource-handler.ts +0 -249
  124. package/ui-server.ts +0 -808
  125. package/ui-session.ts +0 -568
  126. package/ui-stream-types.ts +0 -89
  127. package/ui-tool-visibility.ts +0 -25
  128. package/unix-socket-transport.ts +0 -85
  129. package/utils.ts +0 -309
package/resource-tools.ts DELETED
@@ -1,17 +0,0 @@
1
- // resource-tools.ts - MCP resource name utilities
2
-
3
- export function resourceNameToToolName(name: string): string {
4
- let result = name
5
- .replace(/[^a-zA-Z0-9]/g, "_")
6
- .replace(/_+/g, "_")
7
- .replace(/^_+/, "") // Remove leading underscores
8
- .replace(/_+$/, "") // Remove trailing underscores
9
- .toLowerCase();
10
-
11
- // Ensure we have a valid name
12
- if (!result || /^\d/.test(result)) {
13
- result = "resource" + (result ? "_" + result : "");
14
- }
15
-
16
- return result;
17
- }
package/runtime-owner.ts DELETED
@@ -1,123 +0,0 @@
1
- import type { McpCheckpointEvent } from "./checkpoint.ts";
2
- import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
3
- import { formatTerminalError } from "./utils.ts";
4
-
5
- export interface McpRuntimeOwner {
6
- readonly signal: AbortSignal;
7
- isActive(): boolean;
8
- addCleanup(cleanup: () => void | Promise<void>): void;
9
- stop(reason?: string): Promise<void>;
10
- throwIfInactive(): void;
11
- beforeActivity(): void;
12
- holdCheckpoint(event: McpCheckpointEvent): () => void;
13
- runCallback<T>(kind: "beforeExecute" | "onToolCall", run: () => Promise<T>): Promise<T>;
14
- getCheckpointBlocker(): string | undefined;
15
- }
16
-
17
- export function createMcpRuntimeOwner(): McpRuntimeOwner {
18
- const controller = new AbortController();
19
- const cleanups: Array<() => void | Promise<void>> = [];
20
- let stopPromise: Promise<void> | undefined;
21
- let checkpoint: McpCheckpointEvent | undefined;
22
- const beforeActivity = () => checkpoint?.invalidate();
23
- // These two host hooks can outlive abortable() at their call sites. Count their underlying
24
- // promises through settlement, without cancelling, serializing, or replaying callback work.
25
- const callbacks = { beforeExecute: 0, onToolCall: 0 };
26
- const getCheckpointBlocker = () => {
27
- if (callbacks.beforeExecute) return "MCP beforeExecute callback is pending";
28
- if (callbacks.onToolCall) return "MCP onToolCall callback is pending";
29
- return undefined;
30
- };
31
-
32
- const reportCleanupFailure = (error: unknown, late: boolean) => {
33
- console.error(`MCP: ${late ? "late " : ""}runtime cleanup failed: ${formatTerminalError(error)}`);
34
- };
35
-
36
- return {
37
- signal: controller.signal,
38
- beforeActivity,
39
- getCheckpointBlocker,
40
- runCallback: async (kind, run) => {
41
- beforeActivity();
42
- // Invocation/cancellation policy stays at the call site. In particular, an accepted
43
- // tool's outcome callback may still need to persist its result during shutdown.
44
- callbacks[kind]++;
45
- try { return await run(); } finally { callbacks[kind]--; }
46
- },
47
- holdCheckpoint: event => {
48
- if (checkpoint) throw new Error("MCP checkpoint is already held");
49
- checkpoint = event;
50
- return () => { if (checkpoint === event) checkpoint = undefined; };
51
- },
52
- isActive: () => !controller.signal.aborted,
53
- addCleanup: cleanup => {
54
- if (controller.signal.aborted) {
55
- void Promise.resolve().then(cleanup).catch(error => reportCleanupFailure(error, true));
56
- return;
57
- }
58
- cleanups.push(cleanup);
59
- },
60
- stop: (reason = "MCP extension runtime stopped") => {
61
- if (stopPromise) return stopPromise;
62
- beforeActivity();
63
- controller.abort(new Error(reason));
64
- const pendingCleanups = cleanups.splice(0).reverse().map(cleanup =>
65
- Promise.resolve().then(cleanup),
66
- );
67
- stopPromise = Promise.allSettled(pendingCleanups).then(results => {
68
- const failures = results.flatMap(result => result.status === "rejected" ? [result.reason] : []);
69
- const pendingCallback = getCheckpointBlocker();
70
- if (pendingCallback) failures.push(new Error(pendingCallback));
71
- if (failures.length > 0) {
72
- const aggregate = new AggregateError(failures, "MCP runtime cleanup failed");
73
- console.error(`MCP: runtime cleanup failed: ${formatTerminalError(aggregate)}`);
74
- throw aggregate;
75
- }
76
- });
77
- return stopPromise;
78
- },
79
- throwIfInactive: () => { beforeActivity(); controller.signal.throwIfAborted(); },
80
- };
81
- }
82
-
83
- export function combineAbortSignals(...signals: Array<AbortSignal | undefined>): AbortSignal | undefined {
84
- const active = signals.filter((signal): signal is AbortSignal => signal !== undefined);
85
- if (active.length === 0) return undefined;
86
- if (active.length === 1) return active[0];
87
- return AbortSignal.any(active);
88
- }
89
-
90
- /** Fence session-bound UI calls after the owning extension runtime stops. */
91
- export function createOwnedUi(ui: ExtensionUIContext, owner: McpRuntimeOwner): ExtensionUIContext {
92
- const proxies = new WeakMap<object, object>();
93
- const wrap = (value: unknown): unknown => {
94
- if ((typeof value !== "object" || value === null) && typeof value !== "function") {
95
- return value;
96
- }
97
- const object = value as object;
98
- const existing = proxies.get(object);
99
- if (existing) return existing;
100
-
101
- const proxy = new Proxy(object, {
102
- get(target, property, receiver) {
103
- if (!owner.isActive()) return undefined;
104
- const member = Reflect.get(target, property, receiver);
105
- if (typeof member === "function") {
106
- return (...args: unknown[]) => {
107
- if (!owner.isActive()) return undefined;
108
- return Reflect.apply(member, target, args);
109
- };
110
- }
111
- return owner.isActive() ? wrap(member) : undefined;
112
- },
113
- });
114
- proxies.set(object, proxy);
115
- return proxy;
116
- };
117
- return wrap(ui) as ExtensionUIContext;
118
- }
119
-
120
- export function isAbortError(error: unknown, signal?: AbortSignal): boolean {
121
- if (signal?.aborted) return true;
122
- return error instanceof Error && (error.name === "AbortError" || error.message === "MCP extension runtime stopped");
123
- }
@@ -1,273 +0,0 @@
1
- import type { Api, AssistantMessage, Message, Model, TextContent } from "@earendil-works/pi-ai";
2
- import { truncateAtWord } from "./utils.ts";
3
- import { throwIfAborted } from "./abort.ts";
4
- import type { ExtensionUIContext, ModelRegistry } from "@earendil-works/pi-coding-agent";
5
- import type { Client } from "@modelcontextprotocol/client";
6
- import {
7
- type CreateMessageRequest,
8
- type CreateMessageResult,
9
- type ModelPreferences,
10
- type SamplingMessage,
11
- type SamplingMessageContentBlock,
12
- } from "@modelcontextprotocol/client";
13
-
14
- export interface SamplingHandlerOptions {
15
- serverName: string;
16
- autoApprove: boolean;
17
- ui?: ExtensionUIContext;
18
- modelRegistry: ModelRegistry;
19
- getCurrentModel: () => Model<Api> | undefined;
20
- getSignal: () => AbortSignal | undefined;
21
- }
22
-
23
- export type ServerSamplingConfig = Omit<SamplingHandlerOptions, "serverName">;
24
-
25
- export function registerSamplingHandler(
26
- client: Client,
27
- options: SamplingHandlerOptions,
28
- own: (run: () => Promise<CreateMessageResult>) => Promise<CreateMessageResult> = run => run(),
29
- ): void {
30
- // Own the handler's real promise, not the SDK caller's cancellable response facade.
31
- client.setRequestHandler("sampling/createMessage", request => own(() => handleSamplingRequest(options, request)));
32
- }
33
-
34
- export async function handleSamplingRequest(
35
- options: SamplingHandlerOptions,
36
- request: CreateMessageRequest,
37
- ): Promise<CreateMessageResult> {
38
- const params = request.params;
39
- const signal = options.getSignal();
40
- throwIfAborted(signal);
41
-
42
- if ("task" in params && params.task) {
43
- throw new Error("MCP sampling tasks are not supported");
44
- }
45
- if (params.includeContext && params.includeContext !== "none") {
46
- throw new Error("MCP sampling context inclusion is not supported");
47
- }
48
- if (params.tools?.length) {
49
- throw new Error("MCP sampling tool use is not supported");
50
- }
51
- if (params.toolChoice) {
52
- throw new Error("MCP sampling tool choice is not supported");
53
- }
54
- if (params.stopSequences?.length) {
55
- throw new Error("MCP sampling stop sequences are not supported");
56
- }
57
-
58
- const messages = params.messages.map(convertSamplingMessage);
59
- const model = await resolveSamplingModel(options, params.modelPreferences);
60
- throwIfAborted(signal);
61
- await confirmSampling(
62
- options,
63
- "Approve MCP sampling request",
64
- formatRequestApproval(options.serverName, `${model.provider}/${model.id}`, params.systemPrompt, messages),
65
- );
66
- throwIfAborted(signal);
67
-
68
- const result = await options.modelRegistry.complete(
69
- model,
70
- {
71
- ...(params.systemPrompt !== undefined ? { systemPrompt: params.systemPrompt } : {}),
72
- messages,
73
- },
74
- {
75
- maxTokens: params.maxTokens,
76
- ...(params.temperature !== undefined ? { temperature: params.temperature } : {}),
77
- ...(params.metadata !== undefined ? { metadata: params.metadata as Record<string, unknown> } : {}),
78
- ...(signal ? { signal } : {}),
79
- },
80
- );
81
-
82
- const converted = convertAssistantResult(result);
83
- throwIfAborted(signal);
84
- await confirmSampling(
85
- options,
86
- "Return MCP sampling response",
87
- formatResponseApproval(options.serverName, converted),
88
- );
89
- return converted;
90
- }
91
-
92
- function formatRequestApproval(
93
- serverName: string,
94
- modelName: string,
95
- systemPrompt: string | undefined,
96
- messages: Message[],
97
- ): string {
98
- const lines = [`${serverName} wants to sample ${messages.length} message${messages.length === 1 ? "" : "s"} with ${modelName}.`];
99
- if (systemPrompt) {
100
- lines.push(`System: ${truncateAtWord(systemPrompt, 400)}`);
101
- }
102
- for (const [index, message] of messages.entries()) {
103
- lines.push(`${index + 1}. ${message.role}: ${truncateAtWord(messageText(message), 400)}`);
104
- }
105
- return lines.join("\n\n");
106
- }
107
-
108
- function formatResponseApproval(serverName: string, response: CreateMessageResult): string {
109
- const text = response.content.type === "text" ? response.content.text : `[${response.content.type} content]`;
110
- return `${serverName} will receive this response from ${response.model}:\n\n${truncateAtWord(text, 1000)}`;
111
- }
112
-
113
- function messageText(message: Message): string {
114
- if (typeof message.content === "string") return message.content;
115
- return message.content.map((block) => {
116
- if (block.type === "text") return block.text;
117
- if (block.type === "image") return `[image: ${block.mimeType}]`;
118
- if (block.type === "thinking") return "[thinking]";
119
- if (block.type === "toolCall") return `[tool call: ${block.name}]`;
120
- return "[content]";
121
- }).join("\n");
122
- }
123
-
124
- async function resolveSamplingModel(
125
- options: SamplingHandlerOptions,
126
- modelPreferences: ModelPreferences | undefined,
127
- ): Promise<Model<Api>> {
128
- const candidates: Model<Api>[] = [];
129
- const availableModels = options.modelRegistry.getAvailable();
130
-
131
- for (const hint of modelPreferences?.hints ?? []) {
132
- const normalizedHint = hint.name?.trim().toLowerCase();
133
- if (!normalizedHint) continue;
134
- for (const model of availableModels) {
135
- const searchableNames = [`${model.provider}/${model.id}`, model.id, model.name];
136
- if (searchableNames.some((name) => name.toLowerCase().includes(normalizedHint))) {
137
- addSamplingCandidate(candidates, model);
138
- }
139
- }
140
- }
141
-
142
- const currentModel = options.getCurrentModel();
143
- if (currentModel) addSamplingCandidate(candidates, currentModel);
144
-
145
- for (const model of availableModels) {
146
- addSamplingCandidate(candidates, model);
147
- }
148
-
149
- const errors: string[] = [];
150
- const signal = options.getSignal();
151
- for (const model of candidates) {
152
- throwIfAborted(signal);
153
- const auth = await options.modelRegistry.getApiKeyAndHeaders(model);
154
- throwIfAborted(signal);
155
- if (auth.ok === false) {
156
- errors.push(`${model.provider}/${model.id}: ${auth.error}`);
157
- continue;
158
- }
159
- return model;
160
- }
161
-
162
- if (errors.length > 0) {
163
- throw new Error(`No configured auth for MCP sampling model. ${errors.join("; ")}`);
164
- }
165
- throw new Error("No Pi model is available for MCP sampling");
166
- }
167
-
168
- function addSamplingCandidate(candidates: Model<Api>[], model: Model<Api>): void {
169
- if (!candidates.some((candidate) => candidate.provider === model.provider && candidate.id === model.id)) {
170
- candidates.push(model);
171
- }
172
- }
173
-
174
- async function confirmSampling(options: SamplingHandlerOptions, title: string, message: string): Promise<void> {
175
- if (options.autoApprove) return;
176
- if (!options.ui) {
177
- throw new Error("MCP sampling requires interactive approval. Set settings.samplingAutoApprove to true to allow it without UI.");
178
- }
179
- const approved = await options.ui.confirm(title, message);
180
- if (!approved) {
181
- throw new Error("MCP sampling request was declined");
182
- }
183
- }
184
-
185
- function convertSamplingMessage(message: SamplingMessage): Message {
186
- const blocks = Array.isArray(message.content) ? message.content : [message.content];
187
- if (message.role === "user") {
188
- return {
189
- role: "user",
190
- content: blocks.map(convertUserContent),
191
- timestamp: Date.now(),
192
- };
193
- }
194
-
195
- return {
196
- role: "assistant",
197
- content: blocks.map(convertAssistantContent),
198
- api: "mcp-sampling",
199
- provider: "mcp",
200
- model: "sampling-request",
201
- usage: zeroUsage(),
202
- stopReason: "stop",
203
- timestamp: Date.now(),
204
- };
205
- }
206
-
207
- function convertUserContent(block: SamplingMessageContentBlock): TextContent {
208
- if (block.type === "text") {
209
- return { type: "text", text: block.text };
210
- }
211
- throw new Error(`MCP sampling ${block.type} content is not supported`);
212
- }
213
-
214
- function convertAssistantContent(block: SamplingMessageContentBlock): TextContent {
215
- if (block.type === "text") {
216
- return { type: "text", text: block.text };
217
- }
218
- throw new Error(`MCP sampling assistant ${block.type} content is not supported`);
219
- }
220
-
221
- function convertAssistantResult(message: AssistantMessage): CreateMessageResult {
222
- if (message.stopReason === "error") {
223
- throw new Error(message.errorMessage ?? "MCP sampling model call failed");
224
- }
225
- if (message.stopReason === "aborted") {
226
- throw new Error(message.errorMessage ?? "MCP sampling model call was aborted");
227
- }
228
-
229
- const text = message.content
230
- .map((block) => {
231
- if (block.type === "text") return block.text;
232
- if (block.type === "thinking") return undefined;
233
- throw new Error(`MCP sampling result ${block.type} content is not supported`);
234
- })
235
- .filter((value): value is string => value !== undefined)
236
- .join("\n\n")
237
- .trim();
238
-
239
- if (!text) {
240
- throw new Error("MCP sampling result did not contain text content");
241
- }
242
-
243
- return {
244
- role: "assistant",
245
- content: { type: "text", text },
246
- model: `${message.provider}/${message.model}`,
247
- stopReason: mapStopReason(message.stopReason),
248
- };
249
- }
250
-
251
- function mapStopReason(reason: AssistantMessage["stopReason"]): CreateMessageResult["stopReason"] {
252
- if (reason === "stop") return "endTurn";
253
- if (reason === "length") return "maxTokens";
254
- if (reason === "toolUse") return "toolUse";
255
- return reason;
256
- }
257
-
258
- function zeroUsage(): AssistantMessage["usage"] {
259
- return {
260
- input: 0,
261
- output: 0,
262
- cacheRead: 0,
263
- cacheWrite: 0,
264
- totalTokens: 0,
265
- cost: {
266
- input: 0,
267
- output: 0,
268
- cacheRead: 0,
269
- cacheWrite: 0,
270
- total: 0,
271
- },
272
- };
273
- }
package/scripts/build.mjs DELETED
@@ -1,131 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Purpose: Produce the compiled runtime files that the Pi extension manifest loads.
4
- * Responsibilities: Run TypeScript emit into a staging directory, copy runtime assets
5
- * that modules resolve as dist-relative siblings, then atomically swap it into dist/
6
- * so a failed TypeScript emit never destroys a previously working dist.
7
- * Usage: `npm run build`; also invoked by scripts/prepare.mjs during install lifecycles.
8
- * Invariants/Assumptions: `node_modules` provides `typescript`; deleting `dist/` is safe generated output.
9
- */
10
-
11
- import { execFile as execFileCallback } from "node:child_process";
12
- import { existsSync } from "node:fs";
13
- import { copyFile, readdir, rename, rm } from "node:fs/promises";
14
- import { join } from "node:path";
15
- import process from "node:process";
16
- import { setTimeout as delay } from "node:timers/promises";
17
- import { promisify } from "node:util";
18
-
19
- const execFile = promisify(execFileCallback);
20
- const RM_OPTIONS = { force: true, maxRetries: 5, recursive: true, retryDelay: 100 };
21
- const RENAME_RETRY_LIMIT = 50;
22
- const RENAME_RETRY_MS = 50;
23
- // Run tsc's JS entrypoint directly through the current node binary: no .cmd shim,
24
- // no shell, safe for install paths containing spaces on every platform.
25
- const tscPath = join(process.cwd(), "node_modules", "typescript", "bin", "tsc");
26
- // Runtime siblings resolved relative to the compiled module directory
27
- // (mcp-auth.ts, mcp-code.ts, ui-server.ts).
28
- const RUNTIME_ASSETS = ["mcp-keyring-helper.cjs", "mcp-script-worker.mjs", "app-bridge.bundle.js"];
29
-
30
- async function discardStaging(path) {
31
- try {
32
- await rm(path, RM_OPTIONS);
33
- } catch (error) {
34
- // Cleanup is always best-effort: it must not turn a successful race loss
35
- // into failure or replace the compiler/publish error that matters.
36
- console.warn(`could not remove staging ${path}: ${error?.message ?? error}`);
37
- }
38
- }
39
-
40
- function isOwnerGone(pid) {
41
- try {
42
- process.kill(pid, 0);
43
- return false;
44
- } catch (error) {
45
- return error?.code === "ESRCH";
46
- }
47
- }
48
-
49
- async function hasPublishedDist(path) {
50
- try {
51
- return (await readdir(path)).length > 0;
52
- } catch {
53
- return false;
54
- }
55
- }
56
-
57
- async function reapStrandedStaging(cwd) {
58
- // Signal 0 proves only that the owner was gone at probe time. Pid reuse
59
- // between this probe and removal is inherent to pid-based reaping.
60
- for (const entry of await readdir(cwd, { withFileTypes: true })) {
61
- const match = /^dist\.staging\.(\d+)$/.exec(entry.name);
62
- if (!match || !entry.isDirectory()) continue;
63
- const ownerPid = Number(match[1]);
64
- if (ownerPid === process.pid || !isOwnerGone(ownerPid)) continue;
65
- await discardStaging(join(cwd, entry.name));
66
- }
67
- }
68
-
69
- async function compileToStaging(cwd, stagingDir) {
70
- try {
71
- const { stderr, stdout } = await execFile(
72
- process.execPath,
73
- [tscPath, "-p", "tsconfig.build.json", "--outDir", stagingDir],
74
- { cwd, maxBuffer: 10 * 1024 * 1024 },
75
- );
76
- if (stdout) process.stdout.write(stdout);
77
- if (stderr) process.stderr.write(stderr);
78
- for (const asset of RUNTIME_ASSETS) await copyFile(join(cwd, asset), join(stagingDir, asset));
79
- } catch (error) {
80
- if (error?.stdout) process.stdout.write(error.stdout);
81
- if (error?.stderr) process.stderr.write(error.stderr);
82
- await discardStaging(stagingDir);
83
- throw error;
84
- }
85
- }
86
-
87
- async function publishStaging(stagingDir, distDir) {
88
- try {
89
- // A failed dist removal must fail loudly; keep it outside the retry loop.
90
- // Remove dist exactly once so retries never delete a concurrent winner.
91
- await rm(distDir, RM_OPTIONS);
92
- for (let retry = 0; ; retry++) {
93
- try {
94
- await rename(stagingDir, distDir);
95
- return;
96
- } catch (error) {
97
- // A completed staged build is non-empty; a bare directory is not proof
98
- // that a concurrent publisher won the race.
99
- if (await hasPublishedDist(distDir)) {
100
- console.warn("dist/ was published by a concurrent build; discarding this build's staging tree.");
101
- return;
102
- }
103
- // At most 50 retries (~2.5s) keep persistent filesystem errors bounded.
104
- if (existsSync(stagingDir) && retry < RENAME_RETRY_LIMIT) {
105
- await delay(RENAME_RETRY_MS);
106
- continue;
107
- }
108
- throw error;
109
- }
110
- }
111
- } finally {
112
- // Safe after success too: rename moved stagingDir, so force makes this a no-op.
113
- await discardStaging(stagingDir);
114
- }
115
- }
116
-
117
- async function main() {
118
- const cwd = process.cwd();
119
- if (!existsSync(tscPath)) throw new Error(`typescript is not installed at ${tscPath}; run npm install first.`);
120
- await reapStrandedStaging(cwd);
121
- // Pid-scoped staging isolates concurrent emits; only a complete tree publishes.
122
- const stagingDir = join(cwd, `dist.staging.${process.pid}`);
123
- await rm(stagingDir, RM_OPTIONS);
124
- await compileToStaging(cwd, stagingDir);
125
- await publishStaging(stagingDir, join(cwd, "dist"));
126
- }
127
-
128
- main().catch((error) => {
129
- console.error(error instanceof Error ? error.message : String(error));
130
- process.exitCode = 1;
131
- });
@@ -1,102 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Purpose: Build generated dist output for GitHub/source installs even when Pi invokes npm install --omit=dev.
4
- * Responsibilities: Detect missing source-build dependencies via the local node_modules tree, install dev
5
- * dependencies with lifecycle scripts disabled, run the canonical build, then prune the dev tree so installs
6
- * keep a runtime-only footprint.
7
- * Scope: Package install lifecycle only; runtime behavior remains owned by scripts/build.mjs.
8
- * Usage: package.json prepare script.
9
- */
10
-
11
- import { execFile as execFileCallback } from "node:child_process";
12
- import { existsSync } from "node:fs";
13
- import { join } from "node:path";
14
- import process from "node:process";
15
- import { promisify } from "node:util";
16
-
17
- const execFile = promisify(execFileCallback);
18
-
19
- // Checked as literal node_modules paths: require.resolve() is unusable here because
20
- // several @earendil-works packages expose import-only "exports" maps and throw
21
- // ERR_PACKAGE_PATH_NOT_EXPORTED even when installed.
22
- const REQUIRED_SOURCE_BUILD_MODULES = [
23
- "typescript",
24
- "typebox",
25
- "@earendil-works/pi-ai",
26
- "@earendil-works/pi-coding-agent",
27
- "@earendil-works/pi-tui",
28
- ];
29
-
30
- function hasBuildDependencies() {
31
- return REQUIRED_SOURCE_BUILD_MODULES.every((moduleName) =>
32
- existsSync(join(process.cwd(), "node_modules", ...moduleName.split("/"), "package.json")),
33
- );
34
- }
35
-
36
- async function runNpm(args) {
37
- const npmExecPath = process.env.npm_execpath;
38
- if (!npmExecPath) {
39
- throw new Error(
40
- `npm_execpath is not set; run "npm ${args.join(" ")}" manually in ${process.cwd()} and retry.`,
41
- );
42
- }
43
- // No shell: process.execPath runs npm's cli.js directly, which is safe for paths
44
- // containing spaces on every platform (shell:true concatenates args unescaped).
45
- await execFile(process.execPath, [npmExecPath, ...args], {
46
- cwd: process.cwd(),
47
- maxBuffer: 20 * 1024 * 1024,
48
- });
49
- }
50
-
51
- async function runBuild() {
52
- const { stderr, stdout } = await execFile(process.execPath, [join(process.cwd(), "scripts", "build.mjs")], {
53
- cwd: process.cwd(),
54
- maxBuffer: 20 * 1024 * 1024,
55
- });
56
- // Forward build output on success too: install-time diagnostics such as the
57
- // concurrent-swap race-loss warning are otherwise swallowed.
58
- if (stdout) process.stdout.write(stdout);
59
- if (stderr) process.stderr.write(stderr);
60
- }
61
-
62
- async function pruneDevDependencies() {
63
- await runNpm(["prune", "--omit=dev", "--ignore-scripts"]);
64
- }
65
-
66
- async function pruneQuietly() {
67
- try {
68
- await pruneDevDependencies();
69
- } catch (error) {
70
- console.warn(`npm prune failed after a failed build: ${error?.message ?? error}`);
71
- }
72
- }
73
-
74
- function reportError(error) {
75
- if (error?.stdout) process.stdout.write(error.stdout);
76
- if (error?.stderr) process.stderr.write(error.stderr);
77
- console.error(error instanceof Error ? error.message : String(error));
78
- }
79
-
80
- async function main() {
81
- if (hasBuildDependencies()) {
82
- await runBuild();
83
- return;
84
- }
85
- await runNpm(["install", "--include=dev", "--ignore-scripts"]);
86
- try {
87
- await runBuild();
88
- } catch (buildError) {
89
- // Report the build first; cleanup can warn afterward but never mask it.
90
- reportError(buildError);
91
- await pruneQuietly();
92
- process.exitCode = 1;
93
- return;
94
- }
95
- // A prune failure after a successful build still fails the install.
96
- await pruneDevDependencies();
97
- }
98
-
99
- main().catch((error) => {
100
- reportError(error);
101
- process.exitCode = 1;
102
- });