@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.
package/package.json CHANGED
@@ -1,6 +1,44 @@
1
1
  {
2
2
  "name": "@pencere/cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Run an MCP server written in any language as a pencere program, so its MCP App view opens in the terminal pane",
5
+ "license": "Apache-2.0",
6
+ "homepage": "https://github.com/bateri/pencere/tree/main/cli#readme",
7
+ "bugs": "https://github.com/bateri/pencere/issues",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/bateri/pencere.git",
11
+ "directory": "cli"
12
+ },
13
+ "keywords": [
14
+ "mcp",
15
+ "mcp-apps",
16
+ "terminal",
17
+ "pencere",
18
+ "cli"
19
+ ],
20
+ "type": "module",
21
+ "bin": {
22
+ "pencere": "./dist/main.js"
23
+ },
24
+ "files": [
25
+ "dist",
26
+ "src",
27
+ "CHANGELOG.md",
28
+ "NOTICE"
29
+ ],
30
+ "scripts": {
31
+ "prepack": "rm -rf dist tsconfig.tsbuildinfo && tsc -b"
32
+ },
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "engines": {
37
+ "node": ">=22"
38
+ },
39
+ "dependencies": {
40
+ "@modelcontextprotocol/client": "^2.3.1",
41
+ "@modelcontextprotocol/server": "^2.3.1",
42
+ "@pencere/sdk": "^0.1.0"
43
+ }
44
+ }
package/src/args.ts ADDED
@@ -0,0 +1,158 @@
1
+ /**
2
+ * The command line: `pencere run [options] -- <command> [args...]` and
3
+ * `pencere dev [options] -- <command> [args...]`.
4
+ *
5
+ * Everything after the first `--` is the server's command, taken as it is: a
6
+ * server's own flags are not pencere's to read. What comes before it is read with
7
+ * `node:util`'s `parseArgs`, strictly, so a mistyped option is an error and not a
8
+ * flag handed to nobody.
9
+ */
10
+
11
+ import { parseArgs } from "node:util";
12
+
13
+ export const USAGE = `\
14
+ usage: pencere run [options] -- <command> [args...]
15
+ pencere dev [options] -- <command> [args...]
16
+
17
+ run: runs <command>, an MCP server that speaks stdio and is written in any
18
+ language, and shows its view in this terminal pane. The server is started as an
19
+ MCP client would start it, so it is the same server an MCP Apps host runs.
20
+
21
+ pencere run -- python server.py
22
+ pencere run --tool list_containers --view ./view -- node dist/server.js
23
+
24
+ dev: the same, and the view is shown again when its files change (--view) or
25
+ when the server's own files do (--watch): the view closes, the server is
26
+ started again and the view opens again, with only the files that changed sent.
27
+
28
+ pencere dev --view dist/view -- node dist/server.js
29
+ pencere dev --watch server.py -- python server.py
30
+
31
+ A server that ends while dev runs (a mistake saved in it) is told of, with the end
32
+ of what it wrote to stderr, and tried again at the next change; ^C ends dev.
33
+
34
+ options:
35
+ --tool <name> the tool whose view to show; without it, the one tool of the
36
+ server that names a view (_meta.ui.resourceUri)
37
+ --args <json> the arguments the tool is called with, a JSON object
38
+ --view <dir> show the view from the files in <dir> (sent to the terminal
39
+ once, then kept by content) instead of the server's own
40
+ ui:// resource
41
+ --watch <path> dev only, repeatable: a file or directory whose change starts
42
+ the server and the view again; --view's directory is watched
43
+ already, and one of --view and --watch is needed
44
+ -h, --help this text
45
+ -v, --version the version of pencere
46
+
47
+ exit status: 0 the view ran and closed; 1 it could not run (the server failed to
48
+ start or ended, the terminal refused or ended the view, or stopped answering
49
+ while the view was shown again); 2 a mistake in the command line; 3 this terminal
50
+ does not show pencere views; 130, 143 and 129 after ^C, SIGTERM and a hangup.
51
+ `;
52
+
53
+ /** The server to run and the view to show, which `run` and `dev` are both given. */
54
+ export interface Launch {
55
+ readonly command: string;
56
+ readonly args: readonly string[];
57
+ readonly tool?: string;
58
+ readonly arguments?: Record<string, unknown>;
59
+ readonly view?: string;
60
+ }
61
+
62
+ /** What the command line asks for. */
63
+ export type Command =
64
+ | { readonly kind: "help" }
65
+ | { readonly kind: "version" }
66
+ | ({ readonly kind: "run" } & Launch)
67
+ | ({
68
+ readonly kind: "dev";
69
+ /** The files whose change starts the server and the view again, besides the view's directory. */
70
+ readonly watch: readonly string[];
71
+ } & Launch);
72
+
73
+ /** A mistake in the command line: what it is, so that the person can fix it. */
74
+ export class UsageError extends Error {
75
+ constructor(message: string) {
76
+ super(message);
77
+ this.name = "UsageError";
78
+ }
79
+ }
80
+
81
+ /** The arguments of a tool call: one JSON object. */
82
+ function toolArguments(text: string): Record<string, unknown> {
83
+ let value: unknown;
84
+ try {
85
+ value = JSON.parse(text);
86
+ } catch (error) {
87
+ throw new UsageError(`--args is not JSON: ${error instanceof Error ? error.message : error}`);
88
+ }
89
+ if (typeof value !== "object" || value === null || Array.isArray(value))
90
+ throw new UsageError("--args must be a JSON object");
91
+ return value as Record<string, unknown>;
92
+ }
93
+
94
+ const COMMANDS = "use `pencere run [options] -- <command> [args...]` or `pencere dev ...`";
95
+
96
+ /** @throws {UsageError} for anything that is not a command of pencere. */
97
+ export function parse(argv: readonly string[]): Command {
98
+ const [name, ...rest] = argv;
99
+ if (name === undefined) throw new UsageError(`no command: ${COMMANDS}`);
100
+ if (name === "-h" || name === "--help" || name === "help") return { kind: "help" };
101
+ if (name === "-v" || name === "--version") return { kind: "version" };
102
+ if (name !== "run" && name !== "dev")
103
+ throw new UsageError(`unknown command "${name}": ${COMMANDS}`);
104
+
105
+ // The server's command is everything after the first `--`.
106
+ const dash = rest.indexOf("--");
107
+ const own = dash === -1 ? rest : rest.slice(0, dash);
108
+ let parsed: ReturnType<typeof parseOwn>;
109
+ try {
110
+ parsed = parseOwn(own);
111
+ } catch (error) {
112
+ throw new UsageError(error instanceof Error ? error.message : String(error));
113
+ }
114
+ const { values, positionals } = parsed;
115
+ if (values.help === true) return { kind: "help" };
116
+ const [stray] = positionals;
117
+ if (stray !== undefined)
118
+ throw new UsageError(`unexpected "${stray}": the server's command goes after --`);
119
+ if (dash === -1) throw new UsageError("no command: put the server's command after --");
120
+ const [command, ...args] = rest.slice(dash + 1);
121
+ if (command === undefined || command === "") throw new UsageError("no command after --");
122
+ for (const option of ["tool", "view"] as const)
123
+ if (values[option] === "") throw new UsageError(`--${option} needs a value`);
124
+ const watch = values.watch ?? [];
125
+ if (watch.includes("")) throw new UsageError("--watch needs a value");
126
+ const launch: Launch = {
127
+ command,
128
+ args,
129
+ ...(values.tool === undefined ? {} : { tool: values.tool }),
130
+ ...(values.args === undefined ? {} : { arguments: toolArguments(values.args) }),
131
+ ...(values.view === undefined ? {} : { view: values.view }),
132
+ };
133
+ if (name === "run") {
134
+ if (values.watch !== undefined)
135
+ throw new UsageError("--watch is for `pencere dev`: `pencere run` shows the view once");
136
+ return { kind: "run", ...launch };
137
+ }
138
+ if (launch.view === undefined && watch.length === 0)
139
+ throw new UsageError(
140
+ "nothing to watch: give --view <dir> for the view's files, or --watch <path> for the server's own",
141
+ );
142
+ return { kind: "dev", watch, ...launch };
143
+ }
144
+
145
+ function parseOwn(args: readonly string[]) {
146
+ return parseArgs({
147
+ args: [...args],
148
+ options: {
149
+ tool: { type: "string" },
150
+ args: { type: "string" },
151
+ view: { type: "string" },
152
+ watch: { type: "string", multiple: true },
153
+ help: { type: "boolean", short: "h" },
154
+ },
155
+ allowPositionals: true,
156
+ strict: true,
157
+ });
158
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * The program's MCP server, stood in for by a process.
3
+ *
4
+ * `serve` of the SDK takes anything that can `connect` a transport and `close`
5
+ * the session, and does everything else of a pencere program: the question to the
6
+ * terminal, the token, the bundle, ⌃C and `bye`, the host starting the view again
7
+ * (§10.7) and the program reopening it (§10.8). It connects once for each view it
8
+ * opens. This is that server for a program that has none of its own: it starts the
9
+ * command for each connection and carries the JSON-RPC messages between the
10
+ * terminal's stream and the command's stdio, line by line, untouched. A server
11
+ * that has been connected to a view once has done its `initialize`; the next view
12
+ * is a new session, and so a new process, which is also what a stdio server
13
+ * expects: one `initialize` for the life of the process.
14
+ */
15
+
16
+ import type { Transport } from "@modelcontextprotocol/server";
17
+ import type { Connectable } from "@pencere/sdk";
18
+
19
+ import { CLOSE_TIMING, ServerProcess } from "./server-process.js";
20
+ import type { CloseTiming, Exit, ServerCommand } from "./server-process.js";
21
+ import type { Tail } from "./tail.js";
22
+
23
+ export interface ConnectorOptions {
24
+ /** What the server writes to stderr goes here, as do the connector's own notes on the stream. */
25
+ readonly tail: Tail;
26
+ /**
27
+ * The server's process ended, and the connector had not asked it to. The view
28
+ * has nothing to talk to any more: the caller ends it.
29
+ */
30
+ readonly unexpectedExit: (exit: Exit) => void;
31
+ readonly timing?: CloseTiming;
32
+ }
33
+
34
+ export class ProcessConnector implements Connectable {
35
+ readonly #spec: ServerCommand;
36
+ readonly #options: ConnectorOptions;
37
+ #current: { readonly server: ServerProcess; readonly transport: Transport } | undefined;
38
+ readonly #ending = new Set<Promise<void>>();
39
+ #starts = 0;
40
+
41
+ constructor(spec: ServerCommand, options: ConnectorOptions) {
42
+ this.#spec = spec;
43
+ this.#options = options;
44
+ }
45
+
46
+ /** How many processes this connector has started: one for each view that was opened. */
47
+ get starts(): number {
48
+ return this.#starts;
49
+ }
50
+
51
+ /** @throws {StartError} when the command cannot be started. */
52
+ async connect(transport: Transport): Promise<void> {
53
+ // One process for one session: the last is gone before the next begins.
54
+ await this.close();
55
+ const { tail, unexpectedExit, timing } = this.#options;
56
+ const server = new ServerProcess(this.#spec, tail, timing ?? CLOSE_TIMING);
57
+ server.onmessage = (message) => {
58
+ // A view that is gone has no use for the answer, and says so by failing here.
59
+ transport.send(message).catch(() => {});
60
+ };
61
+ server.onerror = (error) => tail.push(`pencere: ${error.message}\n`);
62
+ server.onexit = (exit) => {
63
+ if (!exit.expected) unexpectedExit(exit);
64
+ };
65
+ transport.onmessage = (message) => {
66
+ // A server that is gone cannot be written to, and says so by ending (`unexpectedExit`).
67
+ server.send(message).catch(() => {});
68
+ };
69
+ transport.onclose = () => {
70
+ void server.close();
71
+ };
72
+ try {
73
+ await transport.start();
74
+ await server.start();
75
+ this.#starts += 1;
76
+ } catch (error) {
77
+ transport.onmessage = undefined;
78
+ transport.onclose = undefined;
79
+ throw error;
80
+ }
81
+ this.#current = { server, transport };
82
+ }
83
+
84
+ /** Ends the process of the session that is connected, if there is one, and resolves when it is gone. */
85
+ async close(): Promise<void> {
86
+ const current = this.#current;
87
+ this.#current = undefined;
88
+ if (current === undefined) return;
89
+ const ending = current.server.close().then(() => current.transport.close());
90
+ this.#ending.add(ending);
91
+ try {
92
+ await ending;
93
+ } finally {
94
+ this.#ending.delete(ending);
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Resolves when every process this connector started has ended. `serve` lets go of
100
+ * a session without waiting for it (a signal must not wait for a stubborn server),
101
+ * and a program that is about to leave has to.
102
+ */
103
+ async drain(): Promise<void> {
104
+ await this.close();
105
+ while (this.#ending.size > 0) await Promise.all([...this.#ending]);
106
+ }
107
+ }
package/src/dev.ts ADDED
@@ -0,0 +1,303 @@
1
+ /**
2
+ * `pencere dev`: what a change to the files comes to.
3
+ *
4
+ * `pencere run` shows a view once. `pencere dev` watches the files of the view and
5
+ * the files the server reads, and when they change it has the SDK close the view
6
+ * and open it again (`ViewControl.reopen`, specification §10.8): the old view and
7
+ * the server of its session are gone, the server is started again as for any view,
8
+ * and the new view's files are checked against what the terminal already holds,
9
+ * so only the files that changed cross the stream. Nothing of that is this file's:
10
+ * what is, is deciding *when*.
11
+ *
12
+ * - **A burst is one reopening.** A build writes many files; the change is acted
13
+ * on once the files have been quiet for a moment (`settle`), and a change that
14
+ * comes while a reopening is under way is acted on after it, again after a
15
+ * moment of quiet.
16
+ * - **Only what changed.** A change to the view's directory is a reopening when
17
+ * the files it holds are different from what the view shows (a file saved as it
18
+ * was, or one that is not part of a view, changes nothing). A change to
19
+ * `--watch`'s paths is a reopening when what the paths hold has moved (their
20
+ * size or modification time): the events of one write that arrive late are not
21
+ * a second write.
22
+ * - **A half-built view waits.** A build that has emptied its output directory
23
+ * leaves something that is not a view; that is not shown, and the next change,
24
+ * which is the build writing more, is looked at in its turn.
25
+ * - **A view that is not there yet waits too.** A change that comes before the
26
+ * first view is up is asked again a few times, a moment apart.
27
+ *
28
+ * What the person gets to know is said after the view has closed (`notes`): the
29
+ * screen is the view's while it is up. A `serve` that ended because the program ended it
30
+ * (its server fell) is not the end of the reloading: the program starts `serve` again, and
31
+ * the next change is what it waits for.
32
+ */
33
+
34
+ import { BundleError, bundleFromDirectory } from "@pencere/sdk";
35
+ import type { Bundle, ReopenOptions, ReopenResult } from "@pencere/sdk";
36
+
37
+ /** How long `Reloader` waits, and how often it asks again. */
38
+ export interface ReloadTiming {
39
+ /** The quiet, in milliseconds, after which the changes so far are acted on. */
40
+ readonly settle: number;
41
+ /** How long to wait, in milliseconds, before asking again for a view that is not up yet. */
42
+ readonly retry: number;
43
+ /** How many times to ask again before leaving the change for the next one to carry. */
44
+ readonly tries: number;
45
+ }
46
+
47
+ /** What `Reloader` needs of the program's hand on its view: `ViewControl`'s `reopen`. */
48
+ export interface Reopener {
49
+ reopen(next: ReopenOptions): Promise<ReopenResult>;
50
+ }
51
+
52
+ export interface ReloaderOptions {
53
+ readonly control: Reopener;
54
+ /** The directory the view's files are in (`--view`), absent when the terminal reads the server's own resource. */
55
+ readonly view?: string;
56
+ /** What the view was first opened with. */
57
+ readonly bundle?: Bundle;
58
+ /** What the server's own files (`--watch`) hold, in a form that changes when they do. Without it every change to them counts. */
59
+ readonly fingerprint?: () => Promise<string>;
60
+ readonly timing: ReloadTiming;
61
+ /** Reads the view's files. `bundleFromDirectory`, unless a test lends another. */
62
+ readonly load?: (directory: string) => Promise<Bundle>;
63
+ }
64
+
65
+ /** What has changed since the last reopening. */
66
+ interface Change {
67
+ /** Something under the view's directory. */
68
+ readonly view: boolean;
69
+ /** Something of the server's own files. */
70
+ readonly server: boolean;
71
+ }
72
+
73
+ function merged(a: Change | undefined, b: Change): Change {
74
+ return a === undefined ? b : { view: a.view || b.view, server: a.server || b.server };
75
+ }
76
+
77
+ /** Whether `bundle` holds the files `shown` does, byte for byte, under the same entry. */
78
+ function sameFiles(bundle: Bundle, shown: Bundle | undefined): boolean {
79
+ return shown !== undefined && JSON.stringify(bundle.manifest) === JSON.stringify(shown.manifest);
80
+ }
81
+
82
+ function why(error: unknown): string {
83
+ if (error instanceof BundleError) return error.message;
84
+ const code = (error as NodeJS.ErrnoException | undefined)?.code;
85
+ if (code === "ENOENT") return "a file went away while it was read";
86
+ return error instanceof Error ? error.message : String(error);
87
+ }
88
+
89
+ /** Closes the view and opens it again when what it is made of, or the server, changed. */
90
+ export class Reloader {
91
+ readonly #options: ReloaderOptions;
92
+ readonly #notes: string[] = [];
93
+ readonly #waiting: (() => void)[] = [];
94
+ /** The files the view shows now. */
95
+ #shown: Bundle | undefined;
96
+ /** What the server's files held when the view was last opened. */
97
+ #serverPrint: string | undefined;
98
+ /** Changes to act on once the files are quiet. */
99
+ #pending: Change | undefined;
100
+ /** Changes that could not be acted on, which the next change carries along. */
101
+ #held: Change | undefined;
102
+ #timer: ReturnType<typeof setTimeout> | undefined;
103
+ #running: Promise<void> | undefined;
104
+ #stopped = false;
105
+ #tries = 0;
106
+ /** Why the view's files could not be read at the last change, while that is so. */
107
+ #problem: string | undefined;
108
+ #reopened = 0;
109
+
110
+ constructor(options: ReloaderOptions) {
111
+ this.#options = options;
112
+ this.#shown = options.bundle;
113
+ }
114
+
115
+ /**
116
+ * Notes what the view's files and the server's own hold now, as the view that is about to open
117
+ * sees them, and says which view's files those are. Call once the files are being watched: what
118
+ * was saved before is in what is read, and what is saved after tells of itself, but a save in
119
+ * between, with the files read first and the watching begun after, would be neither. A view
120
+ * directory that cannot be read just then (a build that is half done) leaves what the reloader
121
+ * was given, and the change that made it unreadable is looked at in its turn.
122
+ */
123
+ async start(): Promise<Bundle | undefined> {
124
+ this.#serverPrint = await this.#options.fingerprint?.();
125
+ const { view } = this.#options;
126
+ if (view !== undefined) {
127
+ try {
128
+ this.#shown = await (this.#options.load ?? bundleFromDirectory)(view);
129
+ } catch {
130
+ // What it was given stays, and the next change looks again.
131
+ }
132
+ }
133
+ return this.#shown;
134
+ }
135
+
136
+ /** How many times the view has been reopened. */
137
+ get reopened(): number {
138
+ return this.#reopened;
139
+ }
140
+
141
+ /** What to tell the person once the view has closed. */
142
+ get notes(): readonly string[] {
143
+ const notes = [...this.#notes];
144
+ if (this.#problem !== undefined)
145
+ notes.push(
146
+ `the last change to the view's files could not be shown (${this.#problem}); the view is as the change before it left it`,
147
+ );
148
+ else if (this.#held !== undefined)
149
+ notes.push("the last change was not shown, because there was no view to show it in");
150
+ return notes;
151
+ }
152
+
153
+ /** Something under the view's directory changed. */
154
+ viewChanged(): void {
155
+ this.#note({ view: true, server: false });
156
+ }
157
+
158
+ /** Something of the server's own files changed. */
159
+ serverChanged(): void {
160
+ this.#note({ view: false, server: true });
161
+ }
162
+
163
+ /** Something to tell the person afterwards that is not a change: a watcher that failed. */
164
+ warn(text: string): void {
165
+ this.#notes.push(text);
166
+ }
167
+
168
+ /** No more reopenings: the view has ended, or cannot be reopened. */
169
+ stop(): void {
170
+ this.#stopped = true;
171
+ this.#disarm();
172
+ this.#pending = undefined;
173
+ this.#release();
174
+ }
175
+
176
+ /** Resolves when nothing is waiting to be acted on and nothing is being acted on. */
177
+ settled(): Promise<void> {
178
+ if (this.#idle()) return Promise.resolve();
179
+ return new Promise((resolve) => this.#waiting.push(resolve));
180
+ }
181
+
182
+ #idle(): boolean {
183
+ return this.#timer === undefined && this.#running === undefined && this.#pending === undefined;
184
+ }
185
+
186
+ #release(): void {
187
+ if (!this.#idle()) return;
188
+ for (const resolve of this.#waiting.splice(0)) resolve();
189
+ }
190
+
191
+ #note(change: Change): void {
192
+ if (this.#stopped) return;
193
+ // What could not be acted on before is carried along with this one.
194
+ const carried = merged(this.#pending, change);
195
+ this.#pending = this.#held === undefined ? carried : merged(carried, this.#held);
196
+ this.#held = undefined;
197
+ this.#tries = 0;
198
+ this.#arm(this.#options.timing.settle);
199
+ }
200
+
201
+ #arm(delay: number): void {
202
+ this.#disarm();
203
+ this.#timer = setTimeout(() => {
204
+ this.#timer = undefined;
205
+ this.#kick();
206
+ }, delay);
207
+ }
208
+
209
+ #disarm(): void {
210
+ if (this.#timer !== undefined) clearTimeout(this.#timer);
211
+ this.#timer = undefined;
212
+ }
213
+
214
+ #kick(): void {
215
+ if (this.#running !== undefined || this.#stopped) return;
216
+ this.#running = this.#pass().finally(() => {
217
+ this.#running = undefined;
218
+ // What came while this ran has had its moment of quiet only if nothing came since.
219
+ if (!this.#stopped && this.#pending !== undefined && this.#timer === undefined)
220
+ this.#arm(this.#options.timing.settle);
221
+ this.#release();
222
+ });
223
+ }
224
+
225
+ async #pass(): Promise<void> {
226
+ const change = this.#pending;
227
+ this.#pending = undefined;
228
+ if (change === undefined) return;
229
+ try {
230
+ await this.#act(change);
231
+ } catch (error) {
232
+ // Looking at the files must not end the view the person is looking at.
233
+ this.#notes.push(`could not look at the files: ${why(error)}`);
234
+ }
235
+ }
236
+
237
+ async #act(change: Change): Promise<void> {
238
+ const { view, fingerprint } = this.#options;
239
+
240
+ let serverPrint: string | undefined;
241
+ let serverMoved = false;
242
+ if (change.server) {
243
+ serverPrint = await fingerprint?.();
244
+ serverMoved = serverPrint === undefined || serverPrint !== this.#serverPrint;
245
+ }
246
+
247
+ let bundle: Bundle | undefined;
248
+ let viewMoved = false;
249
+ if (change.view && view !== undefined) {
250
+ try {
251
+ bundle = await (this.#options.load ?? bundleFromDirectory)(view);
252
+ } catch (error) {
253
+ // A build that is half done: the next change is the rest of it.
254
+ this.#problem = why(error);
255
+ this.#held = merged(this.#held, change);
256
+ return;
257
+ }
258
+ this.#problem = undefined;
259
+ viewMoved = !sameFiles(bundle, this.#shown);
260
+ }
261
+ if (!viewMoved && !serverMoved) return;
262
+
263
+ const result = await this.#options.control.reopen(
264
+ viewMoved && bundle !== undefined ? { bundle } : {},
265
+ );
266
+ switch (result.kind) {
267
+ case "reopened":
268
+ this.#reopened += 1;
269
+ if (viewMoved) this.#shown = bundle;
270
+ if (serverMoved) this.#serverPrint = serverPrint;
271
+ return;
272
+ case "unsupported":
273
+ this.#notes.push(
274
+ "this terminal does not reopen a view when asked, so `pencere dev` ran the view as `pencere run` does and the changes were not shown: close it and run it again to see them",
275
+ );
276
+ this.stop();
277
+ return;
278
+ case "no-view":
279
+ if (result.reason === "aborted") {
280
+ // `serve` was ended by the program's own hand, which is not the terminal refusing:
281
+ // the server fell, and what comes next is the program's to decide. The files that
282
+ // were asked for are what came of it, and the same files are not tried again.
283
+ if (viewMoved) this.#shown = bundle;
284
+ if (serverMoved) this.#serverPrint = serverPrint;
285
+ return;
286
+ }
287
+ // The new view was refused or could not start: `serve` says why as it ends.
288
+ this.stop();
289
+ return;
290
+ case "unavailable": {
291
+ const { retry, tries } = this.#options.timing;
292
+ if (this.#tries < tries) {
293
+ this.#tries += 1;
294
+ this.#pending = merged(this.#pending, change);
295
+ this.#arm(retry);
296
+ } else {
297
+ this.#held = merged(this.#held, change);
298
+ }
299
+ return;
300
+ }
301
+ }
302
+ }
303
+ }