@pencere/cli 0.0.0-stage → 0.1.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,137 @@
1
+ /**
2
+ * Which tool's view to show, when the person did not say.
3
+ *
4
+ * The server is started once, for as long as it takes to ask it: `initialize` and
5
+ * `tools/list`, as any MCP client does. The tool that names a view is the one
6
+ * whose `_meta.ui.resourceUri` does (MCP Apps, Resource Discovery; the flat
7
+ * `_meta["ui/resourceUri"]` of the first drafts, which the extension's own
8
+ * libraries still read, counts too). One such tool is the answer. None, or several,
9
+ * is for the person to settle, and the answer says which tools there were. The list is read page
10
+ * by page, as far as the terminal reads it ({@link MAX_TOOL_PAGES}).
11
+ */
12
+
13
+ import { Client } from "@modelcontextprotocol/client";
14
+
15
+ import { ServerProcess, StartError } from "./server-process.js";
16
+ import type { CloseTiming, ServerCommand } from "./server-process.js";
17
+ import { plain } from "./tail.js";
18
+ import type { Tail } from "./tail.js";
19
+
20
+ /**
21
+ * The most pages of tools that are read: what the terminal reads of the same server, so that a
22
+ * tool the terminal would not find is not the tool this finds, and a server that never stops
23
+ * listing does not keep this asking.
24
+ */
25
+ export const MAX_TOOL_PAGES = 16;
26
+
27
+ /** What the server has with it. */
28
+ export type Discovery =
29
+ | { readonly kind: "found"; readonly tool: string }
30
+ /** No tool names a view. `tools` is every tool the server has. */
31
+ | { readonly kind: "none"; readonly tools: readonly string[] }
32
+ /** Several do: their names. */
33
+ | { readonly kind: "many"; readonly tools: readonly string[] };
34
+
35
+ export interface DiscoverOptions {
36
+ /** The version `pencere` calls itself, in the handshake. */
37
+ readonly version: string;
38
+ /** How long the server has to answer each question, in milliseconds. */
39
+ readonly timeout: number;
40
+ /** The person interrupted: the server is let go and the question ends. */
41
+ readonly signal?: AbortSignal;
42
+ readonly timing?: CloseTiming;
43
+ }
44
+
45
+ /** The server was started and did not answer as an MCP server does. */
46
+ export class DiscoveryError extends Error {
47
+ constructor(message: string, options?: ErrorOptions) {
48
+ super(message, options);
49
+ this.name = "DiscoveryError";
50
+ }
51
+ }
52
+
53
+ /** The person interrupted while the server was being asked. */
54
+ export class Interrupted extends Error {
55
+ constructor() {
56
+ super("interrupted");
57
+ this.name = "Interrupted";
58
+ }
59
+ }
60
+
61
+ /**
62
+ * The `ui://` resource a tool names, in either of the two places MCP Apps has had it. An address
63
+ * of another scheme is no view: the terminal reads `ui://` resources only, and a tool it would
64
+ * refuse is not a tool to show.
65
+ */
66
+ export function viewOf(tool: { readonly _meta?: unknown }): string | undefined {
67
+ const meta = tool._meta;
68
+ if (typeof meta !== "object" || meta === null) return undefined;
69
+ const record = meta as Record<string, unknown>;
70
+ const ui = record.ui;
71
+ if (typeof ui === "object" && ui !== null) {
72
+ const uri = (ui as Record<string, unknown>).resourceUri;
73
+ if (typeof uri === "string" && uri.startsWith("ui://")) return uri;
74
+ }
75
+ const flat = record["ui/resourceUri"];
76
+ return typeof flat === "string" && flat.startsWith("ui://") ? flat : undefined;
77
+ }
78
+
79
+ /**
80
+ * @throws {StartError} when the command cannot be started
81
+ * @throws {DiscoveryError} when it started and did not answer as an MCP server
82
+ * @throws {Interrupted} when `signal` aborted
83
+ */
84
+ export async function discoverTool(
85
+ spec: ServerCommand,
86
+ tail: Tail,
87
+ options: DiscoverOptions,
88
+ ): Promise<Discovery> {
89
+ const { signal, timeout } = options;
90
+ if (signal?.aborted) throw new Interrupted();
91
+ const server = new ServerProcess(spec, tail, options.timing);
92
+ const client = new Client({ name: "pencere", version: options.version });
93
+ // Closing the process fails whatever the client is waiting for.
94
+ const abort = (): void => void server.close();
95
+ signal?.addEventListener("abort", abort, { once: true });
96
+ try {
97
+ await client.connect(server, { timeout });
98
+ const tools: { readonly name: string; readonly _meta?: unknown }[] = [];
99
+ let cursor: string | undefined;
100
+ // A server that says it has no tools is asked for none, as `listTools` would not ask it.
101
+ const asking = client.getServerCapabilities()?.tools !== undefined;
102
+ for (let pages = 1; asking; pages += 1) {
103
+ // A page at a time, as the terminal reads them: `listTools` without a cursor reads
104
+ // them all, to a limit of its own, which is not the terminal's.
105
+ const page = await client.request(
106
+ { method: "tools/list", params: cursor === undefined ? {} : { cursor } },
107
+ { timeout },
108
+ );
109
+ tools.push(...page.tools);
110
+ cursor = page.nextCursor;
111
+ if (cursor === undefined) break;
112
+ if (pages >= MAX_TOOL_PAGES)
113
+ throw new DiscoveryError(`the server has more than ${MAX_TOOL_PAGES} pages of tools`);
114
+ }
115
+ const withView = tools.filter((tool) => viewOf(tool) !== undefined).map((tool) => tool.name);
116
+ const [first, ...others] = withView;
117
+ if (first === undefined) return { kind: "none", tools: tools.map((tool) => tool.name) };
118
+ return others.length === 0 ? { kind: "found", tool: first } : { kind: "many", tools: withView };
119
+ } catch (error) {
120
+ if (signal?.aborted) throw new Interrupted();
121
+ // A process that could not be started says so itself, wherever the client put it.
122
+ if (error instanceof StartError) throw error;
123
+ if (error instanceof Error && error.cause instanceof StartError) throw error.cause;
124
+ if (error instanceof DiscoveryError) throw error;
125
+ // What the server said is shown as text: an error message is its to write.
126
+ throw new DiscoveryError(
127
+ `"${spec.command}" did not answer as an MCP server that speaks stdio does: ${plain(
128
+ error instanceof Error ? error.message : String(error),
129
+ )}`,
130
+ { cause: error },
131
+ );
132
+ } finally {
133
+ signal?.removeEventListener("abort", abort);
134
+ await client.close().catch(() => {});
135
+ await server.close();
136
+ }
137
+ }
package/src/main.ts ADDED
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env node
2
+ /** The `pencere` command. */
3
+
4
+ import { run, systemEnvironment } from "./run.js";
5
+
6
+ // A terminal that is gone (ssh dropped, a window closed) fails what is written to it and read
7
+ // from it, as an `error` of the stream. Nothing listens for it by default, and an `error` nobody
8
+ // listens for ends the process in the middle of letting go of the server, which is then left running.
9
+ for (const stream of [process.stdin, process.stdout, process.stderr]) stream.on("error", () => {});
10
+
11
+ const finish = await run(process.argv.slice(2), systemEnvironment());
12
+ if ("signal" in finish) {
13
+ // What the person sent (or the terminal, going away) ended the view and the server, and now
14
+ // ends this process as it would have ended any other: the shell reads the signal in the
15
+ // status (130, 143, 129).
16
+ process.kill(process.pid, finish.signal);
17
+ // The signal is delivered at once; this is for a platform where it is not.
18
+ await new Promise((resolve) => setTimeout(resolve, 1000));
19
+ process.exit({ SIGINT: 130, SIGTERM: 143, SIGHUP: 129 }[finish.signal]);
20
+ }
21
+ // A server's pipes or a stream the terminal left open must not keep the process.
22
+ process.exit(finish.code);
package/src/run.ts ADDED
@@ -0,0 +1,538 @@
1
+ /**
2
+ * `pencere run` and `pencere dev`: an MCP server written in any language as a
3
+ * pencere program.
4
+ *
5
+ * What a program does to show its view is `serve` of the SDK, and everything of
6
+ * that is the SDK's: the question to the terminal, the token, the bundle, ⌃C and
7
+ * `bye`, the host starting the view again, the fallback, and closing the view to
8
+ * show it again. What is this file's is what a program of someone else's language
9
+ * cannot do for itself: choosing the tool, making the server a process the SDK can
10
+ * connect to (`connector.ts`), keeping what the server says on stderr for the
11
+ * screen the person gets back, saying what happened, in an exit status as well as
12
+ * in words, and, for `dev`, handing the SDK a control and the changes of the
13
+ * files to reopen the view with (`dev.ts`).
14
+ */
15
+
16
+ import { BundleError, ViewControl, bundleFromDirectory, serve } from "@pencere/sdk";
17
+ import type {
18
+ Bundle,
19
+ Connectable,
20
+ ReopenOptions,
21
+ ReopenResult,
22
+ ServeOptions,
23
+ ServeResult,
24
+ ToolCall,
25
+ } from "@pencere/sdk";
26
+ import { readFileSync } from "node:fs";
27
+ import { stat } from "node:fs/promises";
28
+ import { resolve } from "node:path";
29
+
30
+ import { USAGE, UsageError, parse } from "./args.js";
31
+ import type { Command } from "./args.js";
32
+ import { ProcessConnector } from "./connector.js";
33
+ import { Reloader } from "./dev.js";
34
+ import type { ReloadTiming, Reopener } from "./dev.js";
35
+ import { DiscoveryError, Interrupted, discoverTool } from "./discover.js";
36
+ import { StartError } from "./server-process.js";
37
+ import type { CloseTiming, Exit, ServerCommand } from "./server-process.js";
38
+ import { Tail, plain } from "./tail.js";
39
+ import { WatchError, fingerprintOf, watchRoots } from "./watch.js";
40
+ import type { Root, Watch, Watching } from "./watch.js";
41
+
42
+ /** The exit statuses of `pencere run` (the signals aside, which end the process as they would any other). */
43
+ export const EXIT = {
44
+ /** The view ran and closed. */
45
+ ok: 0,
46
+ /** It could not run: the server did not start or ended, the terminal refused or ended the view. */
47
+ failed: 1,
48
+ /** A mistake in the command line. */
49
+ usage: 2,
50
+ /** This terminal does not show pencere views. */
51
+ noView: 3,
52
+ } as const;
53
+
54
+ type Signal = "SIGINT" | "SIGTERM" | "SIGHUP";
55
+
56
+ /** How `pencere` ended: with a status, or with the signal the person sent, which the process then raises itself. */
57
+ export type Finish = { readonly code: number } | { readonly signal: Signal };
58
+
59
+ /** What `run` takes from the process it is in, so that a test can lend others. */
60
+ export interface Environment {
61
+ readonly stdin: { readonly isTTY?: boolean | undefined };
62
+ readonly stdout: { readonly isTTY?: boolean | undefined; write(text: string): unknown };
63
+ readonly stderr: { write(text: string): unknown };
64
+ readonly process: {
65
+ on(event: Signal, listener: () => void): unknown;
66
+ removeListener(event: Signal, listener: () => void): unknown;
67
+ };
68
+ readonly serve: (server: Connectable, options: ServeOptions) => Promise<ServeResult>;
69
+ readonly version: string;
70
+ /** How long the server has to answer a question of the discovery, in milliseconds. */
71
+ readonly discoveryTimeout: number;
72
+ readonly timing?: CloseTiming | undefined;
73
+ /** How `pencere dev` waits for the files to be quiet, and asks again for a view that is not up. */
74
+ readonly reload: ReloadTiming;
75
+ /** Watches files for `pencere dev`. */
76
+ readonly watch: Watch;
77
+ }
78
+
79
+ /** The environment of this process. */
80
+ export function systemEnvironment(): Environment {
81
+ const manifest = JSON.parse(
82
+ readFileSync(new URL("../package.json", import.meta.url), "utf8"),
83
+ ) as { version: string };
84
+ return {
85
+ stdin: process.stdin,
86
+ stdout: process.stdout,
87
+ stderr: process.stderr,
88
+ process,
89
+ serve,
90
+ version: manifest.version,
91
+ discoveryTimeout: 30_000,
92
+ reload: { settle: 150, retry: 250, tries: 20 },
93
+ watch: watchRoots,
94
+ };
95
+ }
96
+
97
+ /** Runs the command line `argv` (without `node` and the script) and says how it ended. */
98
+ export async function run(argv: readonly string[], env: Environment): Promise<Finish> {
99
+ let command: Command;
100
+ try {
101
+ command = parse(argv);
102
+ } catch (error) {
103
+ if (!(error instanceof UsageError)) throw error;
104
+ env.stderr.write(`pencere: ${error.message}\nTry "pencere --help".\n`);
105
+ return { code: EXIT.usage };
106
+ }
107
+ switch (command.kind) {
108
+ case "help":
109
+ env.stdout.write(USAGE);
110
+ return { code: EXIT.ok };
111
+ case "version":
112
+ env.stdout.write(`pencere ${env.version}\n`);
113
+ return { code: EXIT.ok };
114
+ case "run":
115
+ case "dev":
116
+ return runServer(command, env);
117
+ }
118
+ }
119
+
120
+ type RunCommand = Extract<Command, { kind: "run" | "dev" }>;
121
+
122
+ /** The command as the person would type it, to say which server a message is about. */
123
+ export function shown(spec: ServerCommand): string {
124
+ const word = (text: string): string =>
125
+ /^[\w@%+=:,./-]+$/.test(text) ? text : `'${text.replaceAll("'", "'\\''")}'`;
126
+ return [spec.command, ...spec.args].map(word).join(" ");
127
+ }
128
+
129
+ function describeExit(exit: Exit): string {
130
+ if (exit.signal !== null) return `killed by ${exit.signal}`;
131
+ return `exit status ${exit.code ?? "unknown"}`;
132
+ }
133
+
134
+ async function runServer(command: RunCommand, env: Environment): Promise<Finish> {
135
+ const spec: ServerCommand = { command: command.command, args: command.args };
136
+ const say = (text: string): void => void env.stderr.write(`${text}\n`);
137
+ if (env.stdin.isTTY !== true || env.stdout.isTTY !== true) {
138
+ say(
139
+ `pencere: ${command.kind} shows the view in the terminal it runs in, and its input or output is not a terminal here.`,
140
+ );
141
+ say(
142
+ `${shown(spec)} is an MCP server: an MCP client (a chat app, an editor) takes its own command, not pencere's.`,
143
+ );
144
+ return { code: EXIT.noView };
145
+ }
146
+ if (command.kind === "dev") {
147
+ for (const path of command.watch) {
148
+ try {
149
+ await stat(path);
150
+ } catch (error) {
151
+ say(`pencere: --watch ${path}: ${pathProblem(error)}`);
152
+ return { code: EXIT.usage };
153
+ }
154
+ }
155
+ }
156
+
157
+ // ⌃C, SIGTERM and a hangup end this process in the end, and the server first: the SDK takes
158
+ // ⌃C and SIGTERM while a view is up, and this is for the rest of the time, and for the hangup,
159
+ // which it does not take.
160
+ let interrupted: Signal | undefined;
161
+ const interrupt = new AbortController();
162
+ /** Ends what `serve` is doing, when something other than the SDK's own signals must. */
163
+ let abort = new AbortController();
164
+ const handlers = (["SIGINT", "SIGTERM", "SIGHUP"] as const).map((signal) => {
165
+ const handler = (): void => {
166
+ interrupted ??= signal;
167
+ interrupt.abort();
168
+ // A terminal that is gone is not one a view is shown in: `serve` is told to let it go.
169
+ if (signal === "SIGHUP") abort.abort();
170
+ };
171
+ env.process.on(signal, handler);
172
+ return { signal, handler };
173
+ });
174
+ const tail = new Tail();
175
+ const failure: { exit?: Exit | undefined } = {};
176
+ const connector = new ProcessConnector(spec, {
177
+ tail,
178
+ ...(env.timing === undefined ? {} : { timing: env.timing }),
179
+ // The view has nothing to talk to any more: it is ended, and what happened is told after.
180
+ unexpectedExit: (exit) => {
181
+ failure.exit ??= exit;
182
+ abort.abort();
183
+ },
184
+ });
185
+ /** What the server said before it failed, on the screen the person has now. */
186
+ const said = (from: Tail = tail): void => {
187
+ if (from.empty) return;
188
+ say("The end of what it wrote to stderr:");
189
+ for (const line of from.text().replace(/\n$/, "").split("\n")) say(` ${line}`);
190
+ };
191
+ const interruptedBy = (): Finish | undefined =>
192
+ interrupted === undefined ? undefined : { signal: interrupted };
193
+ let reloading: Reloading | undefined;
194
+
195
+ try {
196
+ const chosen = await choose(command, spec, env, interrupt.signal);
197
+ if (!("tool" in chosen)) {
198
+ if (chosen.message !== "") say(chosen.message);
199
+ if (chosen.said !== undefined) said(chosen.said);
200
+ return interruptedBy() ?? { code: chosen.code };
201
+ }
202
+ let bundle: Bundle | undefined;
203
+ if (command.view !== undefined) {
204
+ try {
205
+ bundle = await bundleFromDirectory(command.view);
206
+ } catch (error) {
207
+ say(`pencere: --view ${command.view}: ${viewProblem(error)}`);
208
+ return interruptedBy() ?? { code: EXIT.usage };
209
+ }
210
+ }
211
+ const early = interruptedBy();
212
+ if (early !== undefined) return early;
213
+
214
+ // `pencere dev` and the server that falls: while there is no server there is no view, and
215
+ // what is waited for is the change that is worth another try (see `Standby`).
216
+ const control = command.kind === "dev" ? new ViewControl() : undefined;
217
+ const standby = new Standby(control, interrupt.signal);
218
+ // The files are watched before the view is asked for, so that a change that comes while it opens is not missed.
219
+ if (command.kind === "dev") {
220
+ try {
221
+ reloading = await startReloading(command, bundle, standby, env);
222
+ } catch (error) {
223
+ if (!(error instanceof WatchError)) throw error;
224
+ say(`pencere: ${error.message}`);
225
+ return interruptedBy() ?? { code: error.missing ? EXIT.usage : EXIT.failed };
226
+ }
227
+ // What the files hold now, once they are watched, is what the first view shows.
228
+ bundle = reloading.bundle ?? bundle;
229
+ }
230
+
231
+ let result: ServeResult | undefined;
232
+ let thrown: unknown;
233
+ for (;;) {
234
+ failure.exit = undefined;
235
+ abort = new AbortController();
236
+ result = undefined;
237
+ thrown = undefined;
238
+ try {
239
+ result = await env.serve(connector, {
240
+ tool: chosen.tool,
241
+ ...(bundle === undefined ? {} : { bundle }),
242
+ ...(control === undefined ? {} : { control }),
243
+ signal: abort.signal,
244
+ });
245
+ } catch (error) {
246
+ thrown = error;
247
+ }
248
+ // `serve` lets go of a server without waiting for it; leaving is not for a server that lingers.
249
+ await connector.drain();
250
+ const fell = failure.exit;
251
+ if (command.kind !== "dev" || fell === undefined || thrown !== undefined) break;
252
+ if (interruptedBy() !== undefined) break;
253
+ // The server fell and the view with it, and the terminal is the person's again: say what
254
+ // happened where it can be read, and try again at the next change, as a save that fixes it.
255
+ say(
256
+ `pencere: the server ${shown(spec)} ended (${describeExit(fell)}) while its view was ${
257
+ result?.kind === "closed" ? "open" : "opening"
258
+ }.`,
259
+ );
260
+ said();
261
+ tail.clear();
262
+ say("Waiting for a change to try again; ⌃C ends pencere dev.");
263
+ const next = await standby.wait();
264
+ if (next === undefined) break;
265
+ bundle = next.bundle ?? bundle;
266
+ }
267
+ // The view is over: what the files do from here is nothing, and what was learned of them is told now,
268
+ // on the screen the person has back.
269
+ reloading?.close();
270
+ for (const note of reloading?.reloader.notes ?? []) say(`pencere: ${note}`);
271
+
272
+ const finish = interruptedBy();
273
+ if (finish !== undefined) return finish;
274
+ if (failure.exit !== undefined) {
275
+ say(
276
+ `pencere: the server ${shown(spec)} ended (${describeExit(failure.exit)}) while its view was ${
277
+ result?.kind === "closed" ? "open" : "opening"
278
+ }.`,
279
+ );
280
+ said();
281
+ return { code: EXIT.failed };
282
+ }
283
+ if (thrown !== undefined) {
284
+ if (thrown instanceof StartError) {
285
+ say(`pencere: ${thrown.message}`);
286
+ return { code: EXIT.failed };
287
+ }
288
+ say(`pencere: ${plain(thrown instanceof Error ? thrown.message : String(thrown))}`);
289
+ said();
290
+ return { code: EXIT.failed };
291
+ }
292
+ if (result === undefined) return { code: EXIT.failed };
293
+ return finishedWith(result, spec, said, say);
294
+ } finally {
295
+ reloading?.close();
296
+ for (const { signal, handler } of handlers) env.process.removeListener(signal, handler);
297
+ }
298
+ }
299
+
300
+ /**
301
+ * What `pencere dev` does with a change while its view is up, and while it is not because the
302
+ * server fell. The reloader is given this in place of the SDK's control: with a view up the
303
+ * change is a reopening, and with none it is the end of the wait for one.
304
+ */
305
+ class Standby implements Reopener {
306
+ readonly #control: ViewControl | undefined;
307
+ readonly #interrupt: AbortSignal;
308
+ #waiting: ((next: ReopenOptions) => void) | undefined;
309
+
310
+ constructor(control: ViewControl | undefined, interrupt: AbortSignal) {
311
+ this.#control = control;
312
+ this.#interrupt = interrupt;
313
+ }
314
+
315
+ async reopen(next: ReopenOptions): Promise<ReopenResult> {
316
+ const waiting = this.#waiting;
317
+ if (waiting === undefined) return this.#control?.reopen(next) ?? { kind: "unavailable" };
318
+ // The server is down and there is no view to close: the change is what the next try is made of.
319
+ this.#waiting = undefined;
320
+ waiting(next);
321
+ return { kind: "reopened" };
322
+ }
323
+
324
+ /** Resolves with the change that is worth another try, or with `undefined` when the person ended it. */
325
+ wait(): Promise<ReopenOptions | undefined> {
326
+ if (this.#interrupt.aborted) return Promise.resolve(undefined);
327
+ return new Promise((resolve) => {
328
+ this.#waiting = resolve;
329
+ this.#interrupt.addEventListener(
330
+ "abort",
331
+ () => {
332
+ this.#waiting = undefined;
333
+ resolve(undefined);
334
+ },
335
+ { once: true },
336
+ );
337
+ });
338
+ }
339
+ }
340
+
341
+ /** What `pencere dev` watches the files with, and what it does about a change. */
342
+ interface Reloading {
343
+ readonly reloader: Reloader;
344
+ /** What the view's directory holds now that it is watched, for the first view. */
345
+ readonly bundle: Bundle | undefined;
346
+ /** Ends the watching and the acting on changes. */
347
+ close(): void;
348
+ }
349
+
350
+ /** @throws {WatchError} when a path cannot be watched. */
351
+ async function startReloading(
352
+ command: Extract<Command, { kind: "dev" }>,
353
+ bundle: Bundle | undefined,
354
+ reopener: Reopener,
355
+ env: Environment,
356
+ ): Promise<Reloading> {
357
+ const view = command.view === undefined ? undefined : resolve(command.view);
358
+ const server = command.watch.map((path) => resolve(path));
359
+ const reloader = new Reloader({
360
+ control: reopener,
361
+ ...(view === undefined ? {} : { view }),
362
+ ...(bundle === undefined ? {} : { bundle }),
363
+ ...(server.length === 0 ? {} : { fingerprint: () => fingerprintOf(server) }),
364
+ timing: env.reload,
365
+ });
366
+ const roots: Root[] = [
367
+ ...(view === undefined ? [] : [{ path: view, role: "view" as const }]),
368
+ ...server.map((path) => ({ path, role: "server" as const })),
369
+ ];
370
+ const watching: Watching = env.watch(
371
+ roots,
372
+ (role) => (role === "view" ? reloader.viewChanged() : reloader.serverChanged()),
373
+ (message) => reloader.warn(message),
374
+ );
375
+ const close = (): void => {
376
+ watching.close();
377
+ reloader.stop();
378
+ };
379
+ try {
380
+ // What the files hold now is what the view that opens next is made with, and it is read after
381
+ // the watching has begun: a save before is in it, and a save after tells of itself.
382
+ return { reloader, bundle: await reloader.start(), close };
383
+ } catch (error) {
384
+ close();
385
+ throw error;
386
+ }
387
+ }
388
+
389
+ /** The tool to show, or what to say about why there is none. */
390
+ async function choose(
391
+ command: RunCommand,
392
+ spec: ServerCommand,
393
+ env: Environment,
394
+ signal: AbortSignal,
395
+ ): Promise<
396
+ | { readonly tool: ToolCall }
397
+ | {
398
+ readonly message: string;
399
+ readonly code: number;
400
+ /** What the server wrote while it was asked, to show with the message. */
401
+ readonly said?: Tail;
402
+ }
403
+ > {
404
+ const call = (name: string): { readonly tool: ToolCall } => ({
405
+ tool: { name, ...(command.arguments === undefined ? {} : { arguments: command.arguments }) },
406
+ });
407
+ if (command.tool !== undefined) return call(command.tool);
408
+ // The asked server's stderr is its own: the one of the server that runs the view is not mixed with it.
409
+ const asked = new Tail();
410
+ let found: Awaited<ReturnType<typeof discoverTool>>;
411
+ try {
412
+ found = await discoverTool(spec, asked, {
413
+ version: env.version,
414
+ timeout: env.discoveryTimeout,
415
+ signal,
416
+ ...(env.timing === undefined ? {} : { timing: env.timing }),
417
+ });
418
+ } catch (error) {
419
+ // The caller sees the signal, and says nothing of the question it interrupted.
420
+ if (error instanceof Interrupted) return { message: "", code: EXIT.failed };
421
+ if (error instanceof StartError) {
422
+ return { message: `pencere: ${error.message}`, code: EXIT.failed };
423
+ }
424
+ if (error instanceof DiscoveryError) {
425
+ return { message: `pencere: ${plain(error.message)}`, code: EXIT.failed, said: asked };
426
+ }
427
+ throw error;
428
+ }
429
+ switch (found.kind) {
430
+ case "found":
431
+ return call(found.tool);
432
+ case "none":
433
+ return {
434
+ message:
435
+ `pencere: no tool of ${shown(spec)} names a view (a tool whose _meta.ui.resourceUri is a ui:// resource)` +
436
+ (found.tools.length === 0
437
+ ? ": the server has no tools."
438
+ : `: its tools are ${found.tools.map(plain).join(", ")}.`),
439
+ code: EXIT.failed,
440
+ };
441
+ case "many":
442
+ return {
443
+ message:
444
+ `pencere: ${shown(spec)} has several tools that name a view: ${found.tools.map(plain).join(", ")}.\n` +
445
+ "Choose one with --tool <name>.",
446
+ code: EXIT.usage,
447
+ };
448
+ }
449
+ }
450
+
451
+ function viewProblem(error: unknown): string {
452
+ if (error instanceof BundleError) return error.message;
453
+ const code = (error as NodeJS.ErrnoException | undefined)?.code;
454
+ if (code === "ENOENT") return "no such directory";
455
+ if (code === "ENOTDIR") return "not a directory";
456
+ if (code === "EACCES") return "permission denied";
457
+ return error instanceof Error ? error.message : String(error);
458
+ }
459
+
460
+ function pathProblem(error: unknown): string {
461
+ const code = (error as NodeJS.ErrnoException | undefined)?.code;
462
+ if (code === "ENOENT") return "no such file or directory";
463
+ if (code === "EACCES") return "permission denied";
464
+ return error instanceof Error ? error.message : String(error);
465
+ }
466
+
467
+ /** What the terminal said when it refused a view, or ended it, in a few words. */
468
+ function terminalSaid(reason: "refused" | "failed", code: string | undefined): string {
469
+ switch (code) {
470
+ case "busy":
471
+ return "this pane already shows a view";
472
+ case "policy":
473
+ return "the terminal's policy, or your answer to it, did not allow the view";
474
+ case "limit":
475
+ case "manifest":
476
+ return "the view's files are not what the terminal takes (--view: check the directory)";
477
+ case "bundle":
478
+ return "the terminal could not verify the view's files";
479
+ case "mcp":
480
+ return "the terminal could not start talking to the server";
481
+ case "timeout":
482
+ return "the terminal gave up waiting for the view to start";
483
+ default:
484
+ return reason === "refused"
485
+ ? `the terminal refused to show the view${code === undefined ? "" : ` (${code})`}`
486
+ : `the terminal could not start the view${code === undefined ? "" : ` (${code})`}`;
487
+ }
488
+ }
489
+
490
+ function finishedWith(
491
+ result: ServeResult,
492
+ spec: ServerCommand,
493
+ said: () => void,
494
+ say: (text: string) => void,
495
+ ): Finish {
496
+ switch (result.kind) {
497
+ case "closed":
498
+ if (result.by === "error") {
499
+ say("pencere: the terminal ended the view with an error.");
500
+ said();
501
+ return { code: EXIT.failed };
502
+ }
503
+ return { code: EXIT.ok };
504
+ case "stdio":
505
+ // `run` asked for a terminal first; this is a terminal that went away in between.
506
+ say("pencere: the terminal went away.");
507
+ return { code: EXIT.noView };
508
+ case "no-view": {
509
+ const { reason, code } = result;
510
+ switch (reason) {
511
+ case "no-host":
512
+ say("pencere: this terminal does not show pencere views.");
513
+ say(
514
+ `${shown(spec)} is an MCP server: add it to an MCP client as it is, or run it with pencere in a terminal that supports pencere.`,
515
+ );
516
+ return { code: EXIT.noView };
517
+ case "no-common-version":
518
+ say(
519
+ "pencere: this terminal and this pencere share no version of the pencere protocol: update the older one.",
520
+ );
521
+ return { code: EXIT.noView };
522
+ case "timeout":
523
+ // A terminal that answered before: the view was being replaced and it went quiet.
524
+ say(
525
+ "pencere: the terminal did not answer in time while the view was being shown again, and the view is gone.",
526
+ );
527
+ return { code: EXIT.failed };
528
+ case "refused":
529
+ case "failed":
530
+ say(`pencere: ${terminalSaid(reason, code)}.`);
531
+ said();
532
+ return { code: EXIT.failed };
533
+ case "aborted":
534
+ return { code: EXIT.failed };
535
+ }
536
+ }
537
+ }
538
+ }