projectstore-claude 0.30.1 → 0.30.2

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,505 @@
1
+ // projectstore — models.mjs: the models a harness's agents can be started
2
+ // with (the ADR "Agent model lists are read from each harness's own catalog,
3
+ // declared in its manifest and cached for a day"). Every harness difference
4
+ // is the manifest's agent_translation.models block: what its spawn takes,
5
+ // the floor list, and the catalogs to read, each by field paths. Nothing
6
+ // here names a harness.
7
+ //
8
+ // modelSpec(manifest) → the block, or null
9
+ // knownModels(manifest, opts) → what local reads know: offered rows,
10
+ // every known name, the source and its
11
+ // age, and the live catalog a refresh
12
+ // would ask (decisions 1, 3 and 4)
13
+ // readLive(manifest, opts) → Promise of { at, source, entries } or
14
+ // { error }: one bounded read, cached on
15
+ // success, never throwing (decision 3)
16
+ // offListNote(manifest, name, known) → the PLAN's note for a name no list
17
+ // holds, or null (decision 5)
18
+ // modelsProblem(manifest) → what is wrong with the block, or null:
19
+ // the manifest schema test's check
20
+ //
21
+ // Local reads are synchronous and cheap: the manifest, a `file` catalog, and
22
+ // projectstore's own cache. A `command` or `http` catalog is read only by
23
+ // readLive, which a menu starts in the background. env, home, now, fetch,
24
+ // spawn and timers are parameters, so no test reads the machine.
25
+
26
+ import { spawn as spawnChild } from "node:child_process";
27
+ import { mkdirSync, readFileSync, statSync } from "node:fs";
28
+ import { homedir } from "node:os";
29
+ import { dirname, join } from "node:path";
30
+ import { cachePaths, harnessHome, whichOnPath } from "./harness.mjs";
31
+ import { writeFileAtomic } from "./lib.mjs";
32
+
33
+ // A known list is fresh for a day (decision 3); a live read is bounded by 3 s.
34
+ export const FRESH_MS = 24 * 60 * 60 * 1000;
35
+ export const LIVE_TIMEOUT_MS = 3000;
36
+ // A catalog's output beyond this is not a catalog: the read stops there.
37
+ const MAX_BYTES = 16 * 1024 * 1024;
38
+
39
+ export function modelSpec(manifest) {
40
+ const m = manifest?.agent_translation?.models;
41
+ return m && typeof m === "object" ? m : null;
42
+ }
43
+
44
+ // One field path, dotted: "data", "models", "fetched_at".
45
+ const at = (obj, path) =>
46
+ String(path || "")
47
+ .split(".")
48
+ .filter(Boolean)
49
+ .reduce((o, k) => (o && typeof o === "object" ? o[k] : undefined), obj);
50
+
51
+ // A condition { field: value }: every named field that the entry carries
52
+ // equals its value. A field the entry lacks does not count against it, so an
53
+ // older answer without the field still reads.
54
+ const matches = (entry, cond) =>
55
+ !cond || Object.entries(cond).every(([k, v]) => at(entry, k) === undefined || at(entry, k) === v);
56
+
57
+ // A catalog's fields: the block's shared ones, then the catalog's own.
58
+ const fieldsOf = (spec, catalog) => ({ ...(spec.fields || {}), ...(catalog.fields || {}) });
59
+
60
+ // A catalog's text as a terminal may draw it: control characters and
61
+ // escapes removed (the ADR, decision 4), so an answer cannot move the cursor.
62
+ // A line break or a tab reads as a space, so words never merge.
63
+ const clean = (s) =>
64
+ String(s)
65
+ .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "")
66
+ .replace(/[\t\n\r\v\f]+/g, " ")
67
+ .replace(/[\x00-\x1f\x7f-\x9f]/g, "");
68
+
69
+ // A catalog's answer as rows: { id, label, description, family, hidden },
70
+ // in the catalog's order. Entries without an id, and entries `keep_when`
71
+ // rejects, are dropped; `shown_when` only hides.
72
+ export function entriesOf(body, fields) {
73
+ const list = at(body, fields.list);
74
+ if (!Array.isArray(list)) return null;
75
+ const rows = [];
76
+ list.forEach((e, k) => {
77
+ if (!e || typeof e !== "object") return;
78
+ const raw = at(e, fields.id);
79
+ const id = typeof raw === "string" ? clean(raw) : "";
80
+ if (!id) return;
81
+ if (!matches(e, fields.keep_when)) return;
82
+ const text = (p) => {
83
+ const v = p ? at(e, p) : undefined;
84
+ return typeof v === "string" ? clean(v) : "";
85
+ };
86
+ rows.push({
87
+ id,
88
+ label: text(fields.label),
89
+ description: text(fields.description),
90
+ family: text(fields.family),
91
+ hidden: fields.shown_when ? !matches(e, fields.shown_when) : false,
92
+ order: fields.order ? at(e, fields.order) : k,
93
+ k,
94
+ });
95
+ });
96
+ const desc = Boolean(fields.order_desc);
97
+ rows.sort((a, b) => {
98
+ const x = a.order,
99
+ y = b.order;
100
+ if (x === y || x === undefined || y === undefined) return a.k - b.k;
101
+ return (x < y ? -1 : 1) * (desc ? -1 : 1);
102
+ });
103
+ return rows.map(({ id, label, description, family, hidden }) => ({ id, label, description, family, hidden }));
104
+ }
105
+
106
+ const timeOf = (v) => {
107
+ const t = typeof v === "string" ? Date.parse(v) : NaN;
108
+ return Number.isFinite(t) ? t : null;
109
+ };
110
+
111
+ // The cache file of one harness: <cache root>/models/<id>.json (decision 4).
112
+ export function modelCachePath(manifest, { env = process.env, home = homedir() } = {}) {
113
+ return cachePaths({ env, home }).models(manifest?.id);
114
+ }
115
+
116
+ function readCache(manifest, opts) {
117
+ const p = modelCachePath(manifest, opts);
118
+ if (!p) return null;
119
+ try {
120
+ const c = JSON.parse(readFileSync(p, "utf8"));
121
+ const t = timeOf(c?.at);
122
+ if (t === null || !Array.isArray(c.models)) return null;
123
+ // Cleaned again: the cache is a file anyone can edit.
124
+ const entries = c.models
125
+ .filter((x) => x && typeof x.id === "string" && clean(x.id))
126
+ .map((x) => ({
127
+ id: clean(x.id),
128
+ label: clean(x.label ?? ""),
129
+ description: clean(x.description ?? ""),
130
+ family: clean(x.family ?? ""),
131
+ hidden: Boolean(x.hidden),
132
+ }));
133
+ const source = typeof c.source === "string" ? c.source : "";
134
+ const from = (modelSpec(manifest)?.catalogs || []).find((x) => x.source === source);
135
+ return { at: t, source, caption: from?.caption || source, entries };
136
+ } catch {
137
+ return null;
138
+ }
139
+ }
140
+
141
+ function readFileCatalog(manifest, spec, catalog, opts) {
142
+ // Under the harness's home, as the harness itself resolves it.
143
+ const base = harnessHome(manifest, opts);
144
+ if (!base || !catalog.path) return null;
145
+ const p = join(base, catalog.path);
146
+ try {
147
+ const raw = readFileSync(p, "utf8");
148
+ const fields = fieldsOf(spec, catalog);
149
+ const body = JSON.parse(raw);
150
+ const entries = entriesOf(body, fields);
151
+ if (!entries) return null;
152
+ const t = timeOf(fields.at ? at(body, fields.at) : undefined) ?? statSync(p).mtimeMs;
153
+ return { at: t, source: catalog.source || "", caption: catalog.caption || catalog.source || "", entries };
154
+ } catch {
155
+ return null;
156
+ }
157
+ }
158
+
159
+ // Where an http catalog's key goes: the key's own base when the manifest
160
+ // names a base variable and it is set, else the manifest's URL (the ADR,
161
+ // decision 2). Never any other host.
162
+ function urlOf(catalog, env) {
163
+ const base = catalog.base_env && env?.[catalog.base_env] ? String(env[catalog.base_env]).replace(/\/+$/, "") : null;
164
+ return base && catalog.path ? `${base}${catalog.path}` : catalog.url;
165
+ }
166
+ function hostOf(catalog, env) {
167
+ try {
168
+ return new URL(urlOf(catalog, env)).host;
169
+ } catch {
170
+ return null;
171
+ }
172
+ }
173
+
174
+ // The catalog a refresh asks: the first `command` or `http` one that can run
175
+ // here — its key variable set, or its bin on PATH — or null, with why not.
176
+ function liveCatalog(spec, env) {
177
+ let why = null;
178
+ for (const c of spec.catalogs || []) {
179
+ if (c.kind === "http") {
180
+ if (c.key_env && !(env && env[c.key_env])) {
181
+ why = why || `no ${c.key_env}`;
182
+ continue;
183
+ }
184
+ return { catalog: c, why: null, host: hostOf(c, env) };
185
+ }
186
+ if (c.kind === "command") {
187
+ const bin = Array.isArray(c.argv) ? c.argv[0] : null;
188
+ if (!bin || !whichOnPath(bin, env || {})) {
189
+ why = why || `${bin || "the command"} not on PATH`;
190
+ continue;
191
+ }
192
+ return { catalog: c, why: null };
193
+ }
194
+ }
195
+ return { catalog: null, why };
196
+ }
197
+
198
+ // What the local reads know about one harness's models. `offered` is what a
199
+ // menu lists, in order; `known` every name any list holds, hidden ones too.
200
+ // spawn_takes "id": the newest catalog's shown entries, then static ones
201
+ // it lacks.
202
+ // spawn_takes "alias": the static aliases, each with the newest entry of its
203
+ // family in the catalog (`follows`), and nothing else.
204
+ export function knownModels(manifest, { env = process.env, home = homedir(), now = Date.now } = {}) {
205
+ const spec = modelSpec(manifest);
206
+ if (!spec) return null;
207
+ const opts = { env, home };
208
+ const candidates = [
209
+ readCache(manifest, opts),
210
+ ...(spec.catalogs || []).filter((c) => c.kind === "file").map((c) => readFileCatalog(manifest, spec, c, opts)),
211
+ ].filter(Boolean);
212
+ // A list dated in the future (a clock that moved back) is used only when
213
+ // nothing else is known, and it is stale.
214
+ const t0 = now();
215
+ const past = candidates.filter((x) => x.at <= t0);
216
+ const newest = (past.length ? past : candidates).reduce((a, b) => (!a || b.at > a.at ? b : a), null);
217
+ const entries = newest ? newest.entries : [];
218
+ const statics = (spec.static || []).filter((s) => s && typeof s.id === "string" && s.id);
219
+ // What the spawn can be given: an alias harness's aliases only — an exact
220
+ // id a catalog lists is not one of them — and every catalog id otherwise.
221
+ const known = new Set(statics.map((s) => s.id));
222
+ let offered;
223
+ if (spec.spawn_takes === "alias") {
224
+ const followed = new Map();
225
+ for (const e of entries) if (e.family && !followed.has(e.family)) followed.set(e.family, e);
226
+ offered = statics.map((s) => ({
227
+ id: s.id,
228
+ label: s.label || "",
229
+ description: s.description || "",
230
+ family: s.family || s.id,
231
+ follows: followed.get(s.family || s.id) || null,
232
+ }));
233
+ // A line the catalog reports that `static` lacks is never offered and
234
+ // never known: the spawn takes the static aliases only (decision 1).
235
+ } else {
236
+ for (const e of entries) known.add(e.id);
237
+ offered = entries.filter((e) => !e.hidden).map((e) => ({ ...e }));
238
+ for (const s of statics)
239
+ if (!offered.some((o) => o.id === s.id))
240
+ offered.push({ id: s.id, label: s.label || "", description: s.description || "" });
241
+ }
242
+ const t = t0;
243
+ const live = liveCatalog(spec, env);
244
+ return {
245
+ spawnTakes: spec.spawn_takes === "alias" ? "alias" : "id",
246
+ aliases: spec.spawn_takes === "alias" ? statics.map((s) => s.id) : [],
247
+ offered,
248
+ known,
249
+ hidden: new Set(entries.filter((e) => e.hidden).map((e) => e.id)),
250
+ source: newest ? newest.source : "",
251
+ caption: newest ? newest.caption || newest.source : "",
252
+ at: newest ? newest.at : null,
253
+ stale: !newest || newest.at > t || t - newest.at >= FRESH_MS,
254
+ live: live.catalog
255
+ ? {
256
+ source: live.catalog.source || "",
257
+ caption: live.catalog.caption || live.catalog.source || "",
258
+ kind: live.catalog.kind,
259
+ ...(live.host ? { host: live.host } : {}),
260
+ }
261
+ : null,
262
+ why: live.why,
263
+ };
264
+ }
265
+
266
+ // The PLAN's note for a model no list holds (decision 5), or null when one
267
+ // does. `known` is knownModels' answer.
268
+ // alias harness: the name is not one of its aliases, and its spawn refuses it;
269
+ // id harness: the name is not in its newest list, as of that list's date;
270
+ // no list: nothing is known to compare with.
271
+ export function offListNote(manifest, name, known) {
272
+ const display = manifest?.display_name || manifest?.id || "this harness";
273
+ if (!name) return null;
274
+ if (!known) return `no list of ${display}'s models is known; written as typed`;
275
+ if (known.known.has(name)) return null;
276
+ if (known.spawnTakes === "alias" && known.aliases.length) {
277
+ const list = known.aliases.join(", ");
278
+ return `${name} is not one of ${display}'s aliases (${list}); written as typed, and ${display} refuses it at spawn`;
279
+ }
280
+ if (known.source && known.at) return `not in ${known.source}'s list as of ${localDate(known.at)}; written as typed`;
281
+ if (known.known.size) return `not in ${display}'s list; written as typed`;
282
+ return `no list of ${display}'s models is known; written as typed`;
283
+ }
284
+
285
+ // A date as the person's clock reads it: 2026-10-13.
286
+ const localDate = (t) => {
287
+ const d = new Date(t);
288
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
289
+ };
290
+
291
+ // What is wrong with a manifest's models block, or null (the manifest schema
292
+ // test, generation contract 1). A manifest without the block is fine: its
293
+ // agents' models are typed by name.
294
+ export function modelsProblem(manifest) {
295
+ const spec = modelSpec(manifest);
296
+ if (!spec) return manifest?.agent_translation?.models === undefined ? null : "models is not an object";
297
+ if (!["alias", "id"].includes(spec.spawn_takes)) return `spawn_takes is ${spec.spawn_takes}, not alias or id`;
298
+ if (typeof spec.spawn_takes_reason !== "string" || !spec.spawn_takes_reason)
299
+ return "spawn_takes carries no spawn_takes_reason";
300
+ if (!Array.isArray(spec.static)) return "static is not a list";
301
+ for (const x of spec.static) if (!x || typeof x.id !== "string" || !x.id) return "a static entry has no id";
302
+ if (spec.spawn_takes === "alias" && !spec.static.length) return "an alias harness lists no alias";
303
+ if (!Array.isArray(spec.catalogs)) return "catalogs is not a list";
304
+ for (const c of spec.catalogs) {
305
+ const fields = fieldsOf(spec, c);
306
+ if (!["file", "command", "http"].includes(c?.kind)) return `a catalog's kind is ${c?.kind}`;
307
+ if (typeof c.source !== "string" || !c.source) return `a ${c.kind} catalog names no source`;
308
+ if (!fields.list || !fields.id) return `the ${c.source} catalog names no list or id field`;
309
+ if (typeof c.reason !== "string" || !c.reason) return `the ${c.source} catalog carries no reason`;
310
+ if (c.kind === "file" && (typeof c.path !== "string" || !c.path || c.path.split("/").includes("..")))
311
+ return `the ${c.source} file catalog's path is not under the harness home`;
312
+ if (c.kind === "command" && (!Array.isArray(c.argv) || !c.argv.length || c.argv.some((a) => typeof a !== "string")))
313
+ return `the ${c.source} command catalog has no argv`;
314
+ if (c.kind === "http") {
315
+ if (!/^https:\/\//.test(String(c.url))) return `the ${c.source} http catalog's url is not https`;
316
+ if (!c.key_env || !c.key_header) return `the ${c.source} http catalog names no key variable and header`;
317
+ if (c.base_env && !String(c.path || "").startsWith("/"))
318
+ return `the ${c.source} http catalog's path is not absolute`;
319
+ }
320
+ }
321
+ return null;
322
+ }
323
+
324
+ // One bounded fetch, settled by a timer whatever the request does after: the
325
+ // pattern of convergence.mjs's request(). The key goes in its header and
326
+ // nowhere else; nothing of the request is kept.
327
+ function readHttp(catalog, { env, fetch, timers, timeoutMs, signal }) {
328
+ return new Promise((settle) => {
329
+ if (typeof fetch !== "function") return settle({ error: "no fetch in this Node" });
330
+ let done = false,
331
+ timer = null;
332
+ const ac = typeof AbortController === "function" ? new AbortController() : null;
333
+ const finish = (r) => {
334
+ if (done) return;
335
+ done = true;
336
+ if (timer !== null)
337
+ try {
338
+ timers.clearTimeout(timer);
339
+ } catch {}
340
+ settle(r);
341
+ };
342
+ timer = timers.setTimeout(() => {
343
+ timer = null;
344
+ try {
345
+ ac?.abort();
346
+ } catch {}
347
+ finish({ error: `no answer within ${timeoutMs / 1000} s` });
348
+ }, timeoutMs);
349
+ try {
350
+ timer?.unref?.();
351
+ } catch {}
352
+ signal?.addEventListener?.("abort", () => {
353
+ try {
354
+ ac?.abort();
355
+ } catch {}
356
+ finish({ error: "stopped" });
357
+ });
358
+ const url = urlOf(catalog, env);
359
+ const headers = { accept: "application/json", ...(catalog.headers || {}) };
360
+ if (catalog.key_env && catalog.key_header) headers[catalog.key_header] = String(env?.[catalog.key_env] || "");
361
+ Promise.resolve()
362
+ .then(() => fetch(url, { headers, ...(ac ? { signal: ac.signal } : {}) }))
363
+ .then(async (res) => {
364
+ if (!res || !res.ok) {
365
+ try {
366
+ Promise.resolve(res?.body?.cancel?.()).catch(() => {});
367
+ } catch {}
368
+ const status = res?.status ?? "error";
369
+ return finish({
370
+ error: status === 401 || status === 403 ? `the key was refused (HTTP ${status})` : `HTTP ${status}`,
371
+ });
372
+ }
373
+ const size = Number(res.headers?.get?.("content-length"));
374
+ if (Number.isFinite(size) && size > MAX_BYTES) {
375
+ try {
376
+ Promise.resolve(res?.body?.cancel?.()).catch(() => {});
377
+ } catch {}
378
+ return finish({ error: "the answer is too large" });
379
+ }
380
+ try {
381
+ finish({ body: await res.json() });
382
+ } catch {
383
+ finish({ error: "the answer is not JSON" });
384
+ }
385
+ })
386
+ .catch((e) => finish({ error: String(e?.cause?.code || e?.code || e?.message || "the request failed") }));
387
+ });
388
+ }
389
+
390
+ // One bounded run of a catalog command: its stdout as JSON. Asynchronous, so
391
+ // a menu keeps drawing while it runs; killed at the bound.
392
+ function readCommand(catalog, { env, spawn, timers, timeoutMs, signal }) {
393
+ return new Promise((settle) => {
394
+ let done = false,
395
+ timer = null,
396
+ child = null;
397
+ const chunks = [];
398
+ let bytes = 0;
399
+ const finish = (r) => {
400
+ if (done) return;
401
+ done = true;
402
+ if (timer !== null)
403
+ try {
404
+ timers.clearTimeout(timer);
405
+ } catch {}
406
+ settle(r);
407
+ };
408
+ const [bin, ...args] = catalog.argv;
409
+ try {
410
+ child = spawn(bin, args, { env, stdio: ["ignore", "pipe", "ignore"] });
411
+ } catch (e) {
412
+ return finish({ error: String(e?.code || e?.message || "could not start") });
413
+ }
414
+ timer = timers.setTimeout(() => {
415
+ timer = null;
416
+ try {
417
+ child.kill("SIGTERM");
418
+ } catch {}
419
+ finish({ error: `no answer within ${timeoutMs / 1000} s` });
420
+ }, timeoutMs);
421
+ try {
422
+ timer?.unref?.();
423
+ } catch {}
424
+ signal?.addEventListener?.("abort", () => {
425
+ try {
426
+ child.kill("SIGTERM");
427
+ } catch {}
428
+ finish({ error: "stopped" });
429
+ });
430
+ child.on("error", (e) =>
431
+ finish({ error: e?.code === "ENOENT" ? `${bin} not on PATH` : String(e?.code || e?.message) }),
432
+ );
433
+ child.stdout?.on("data", (c) => {
434
+ bytes += c.length;
435
+ if (bytes > MAX_BYTES) {
436
+ try {
437
+ child.kill("SIGTERM");
438
+ } catch {}
439
+ return finish({ error: "the answer is too large" });
440
+ }
441
+ chunks.push(c);
442
+ });
443
+ child.on("close", (code, signal) => {
444
+ if (code !== 0)
445
+ return finish({
446
+ error: `${catalog.source || bin} could not refresh its list (${signal ? `killed by ${signal}` : `exited ${code}`})`,
447
+ });
448
+ try {
449
+ finish({ body: JSON.parse(Buffer.concat(chunks).toString("utf8")) });
450
+ } catch {
451
+ finish({ error: "the answer is not JSON" });
452
+ }
453
+ });
454
+ });
455
+ }
456
+
457
+ // The live read (decision 3): the first catalog that can run, once, within
458
+ // the bound. On success the rows are written to projectstore's cache — names,
459
+ // labels, descriptions and families only, never a key or a header (decision
460
+ // 4) — and returned; a failure is returned as { error } and never cached.
461
+ export async function readLive(
462
+ manifest,
463
+ {
464
+ env = process.env,
465
+ home = homedir(),
466
+ now = Date.now,
467
+ // eslint-disable-next-line n/no-unsupported-features/node-builtins -- the global fetch is experimental at Node 20 and stable from 21; without it the read answers "no fetch in this Node"
468
+ fetch = globalThis.fetch,
469
+ spawn = spawnChild,
470
+ timers = globalThis,
471
+ timeoutMs = LIVE_TIMEOUT_MS,
472
+ signal = null,
473
+ } = {},
474
+ ) {
475
+ const spec = modelSpec(manifest);
476
+ if (!spec) return { error: "no model list for this harness" };
477
+ const { catalog, why } = liveCatalog(spec, env);
478
+ if (!catalog) return { error: why || "no catalog to ask" };
479
+ let r;
480
+ try {
481
+ r =
482
+ catalog.kind === "http"
483
+ ? await readHttp(catalog, { env, fetch, timers, timeoutMs, signal })
484
+ : await readCommand(catalog, { env, spawn, timers, timeoutMs, signal });
485
+ } catch (e) {
486
+ r = { error: String(e?.message || e) };
487
+ }
488
+ if (r.error) return { error: r.error, source: catalog.source || "" };
489
+ if (signal?.aborted) return { error: "stopped", source: catalog.source || "" };
490
+ const entries = entriesOf(r.body, fieldsOf(spec, catalog));
491
+ if (!entries) return { error: "the answer holds no list", source: catalog.source || "" };
492
+ const t = now();
493
+ const p = modelCachePath(manifest, { env, home });
494
+ if (p)
495
+ try {
496
+ mkdirSync(dirname(p), { recursive: true });
497
+ writeFileAtomic(
498
+ p,
499
+ JSON.stringify({ at: new Date(t).toISOString(), source: catalog.source || "", models: entries }, null, 2) +
500
+ "\n",
501
+ { sweep: false },
502
+ );
503
+ } catch {}
504
+ return { at: t, source: catalog.source || "", entries };
505
+ }
@@ -137,6 +137,17 @@ export const GLYPHS = Object.freeze({
137
137
  // A move or a state change in a step's text: `a → b` (decided 2026-10-10,
138
138
  // added to the spec's table at the story's close-out).
139
139
  transition: Object.freeze(["→", "->"]),
140
+ // The settings menu's frames and marks (contract 14, added 2026-10-13): a
141
+ // frame's corners (its sides are the rail and the rule), the cursor on the
142
+ // row the keys act on, the rows a frame hides above and below, and the
143
+ // path line's separator.
144
+ frameTopLeft: Object.freeze(["╭", "+"]),
145
+ frameTopRight: Object.freeze(["╮", "+"]),
146
+ frameBottomLeft: Object.freeze(["╰", "+"]),
147
+ frameBottomRight: Object.freeze(["╯", "+"]),
148
+ cursor: Object.freeze(["›", ">"]),
149
+ moreAbove: Object.freeze(["↑", "^"]),
150
+ moreBelow: Object.freeze(["↓", "v"]),
140
151
  });
141
152
 
142
153
  // The other names a glyph answers to: the spec's shared rows (fail / issue,
@@ -160,6 +171,8 @@ export const GLYPH_ALIASES = Object.freeze({
160
171
  // The question rail's checklist cursor: the row it is on is marked by a
161
172
  // glyph, not by colour alone (contract 3).
162
173
  pointer: "transition",
174
+ // The menu's path line: Settings › Agents · Claude Code › critic.
175
+ pathSeparator: "cursor",
163
176
  });
164
177
 
165
178
  // The spinner (presentation spec contract 4): braille frames, one every 80 ms.
@@ -1090,6 +1103,160 @@ export function openRail({
1090
1103
  };
1091
1104
  }
1092
1105
 
1106
+ // A full screen for the settings menu (presentation spec contract 14): the
1107
+ // terminal's alternate screen, raw keys and a hidden cursor for the screen's
1108
+ // whole life, each draw from the top-left, and the terminal restored on
1109
+ // every path — close(), `exit`, SIGTERM / SIGHUP / SIGINT — as the rail
1110
+ // restores it. What the screen showed is gone from the scrollback when it
1111
+ // closes; the caller prints what should stay after that.
1112
+ //
1113
+ // screen.size() → { columns, rows }
1114
+ // screen.draw(lines) → the lines, each line's rest cleared, then the rest
1115
+ // screen.next() → the next event: a key name (up, down, left, right,
1116
+ // space, enter, escape, backspace, cancel), { char },
1117
+ // "resize", or what wake() was given
1118
+ // screen.wake(event) → an event for next(), from outside the keys (a
1119
+ // background read that answered)
1120
+ // screen.drain(turns) → the keys queued so far dropped, and those arriving in
1121
+ // the next `turns` turns of the loop (the review screen)
1122
+ // screen.close() → the terminal is the caller's again
1123
+ //
1124
+ // Ctrl+C and the end of input are "cancel": an answer, never process.exit.
1125
+ export const ALT_ON = "\x1b[?1049h";
1126
+ export const ALT_OFF = "\x1b[?1049l";
1127
+ export function openScreen({
1128
+ stdin = process.stdin,
1129
+ stdout = process.stdout,
1130
+ caps: c = caps(stdout),
1131
+ proc = process,
1132
+ escapeMs = ESCAPE_MS,
1133
+ } = {}) {
1134
+ if (!railAvailable(stdin, c))
1135
+ throw new Error("no screen: stdin must be a terminal with setRawMode and stdout a live one (railAvailable)");
1136
+ const write = (s) => stdout.write(s);
1137
+ const queue = [];
1138
+ let waiter = null;
1139
+ const push = (e) => {
1140
+ queue.push(e);
1141
+ if (waiter) {
1142
+ const w = waiter;
1143
+ waiter = null;
1144
+ w();
1145
+ }
1146
+ };
1147
+ // A lone Esc is reported after the escape timeout as { name: "escape",
1148
+ // meta: true }: it is ←, never a modifier (the rail reads it the same way).
1149
+ const nameOf = (str, k = {}) => {
1150
+ if (k.ctrl && k.name === "c") return "cancel";
1151
+ if (k.name === "escape") return "escape";
1152
+ if (k.ctrl || k.meta) return null;
1153
+ if (["up", "down", "left", "right", "space", "escape", "backspace"].includes(k.name)) return k.name;
1154
+ if (isEnter(k)) return "enter";
1155
+ return printable({ ...k, str }) ? { char: str } : null;
1156
+ };
1157
+ // While a drain lasts, keys are dropped as they arrive (drain() below).
1158
+ let dropping = false;
1159
+ const onKey = (str, key) => {
1160
+ const e = nameOf(str, key || {});
1161
+ if (e && !(dropping && e !== "cancel")) push(e);
1162
+ };
1163
+ const onEnd = () => push("cancel");
1164
+ const onResize = () => push("resize");
1165
+ let open = true;
1166
+ const restore = () => {
1167
+ try {
1168
+ stdin.setRawMode(false);
1169
+ } catch {}
1170
+ try {
1171
+ write(`${SHOW_CURSOR}${ALT_OFF}`);
1172
+ } catch {}
1173
+ };
1174
+ const onExit = () => restore();
1175
+ const onSignal = {};
1176
+ const release = () => {
1177
+ proc.removeListener("exit", onExit);
1178
+ for (const [sig, f] of Object.entries(onSignal)) proc.removeListener(sig, f);
1179
+ };
1180
+ for (const sig of ["SIGTERM", "SIGHUP", "SIGINT"]) {
1181
+ onSignal[sig] = () => {
1182
+ if (open) restore();
1183
+ open = false;
1184
+ release();
1185
+ try {
1186
+ proc.kill(proc.pid, sig);
1187
+ } catch {}
1188
+ };
1189
+ }
1190
+ proc.once("exit", onExit);
1191
+ for (const [sig, f] of Object.entries(onSignal)) proc.on(sig, f);
1192
+ emitKeypressEvents(stdin, { escapeCodeTimeout: escapeMs });
1193
+ stdin.on("keypress", onKey);
1194
+ stdin.on("end", onEnd);
1195
+ stdin.on("error", onEnd);
1196
+ if (typeof stdout.on === "function") stdout.on("resize", onResize);
1197
+ if (stdin.readableEnded) queue.push("cancel");
1198
+ try {
1199
+ stdin.setRawMode(true);
1200
+ write(`${ALT_ON}${HIDE_CURSOR}`);
1201
+ stdin.resume();
1202
+ } catch (e) {
1203
+ restore();
1204
+ release();
1205
+ throw e;
1206
+ }
1207
+ return {
1208
+ size: () => ({
1209
+ columns: Number(stdout.columns) > 0 ? Number(stdout.columns) : c.columns || 100,
1210
+ rows: Number(stdout.rows) > 0 ? Number(stdout.rows) : 24,
1211
+ }),
1212
+ // Each line after an erase of its row, at most `rows` of them, and no
1213
+ // newline after the last, so the alternate screen never scrolls.
1214
+ draw(lines) {
1215
+ if (!open) return;
1216
+ const rows = Number(stdout.rows) > 0 ? Number(stdout.rows) : 24;
1217
+ write(
1218
+ `\x1b[H${lines
1219
+ .slice(0, rows)
1220
+ .map((l) => `\x1b[2K${l}`)
1221
+ .join("\r\n")}\x1b[J`,
1222
+ );
1223
+ },
1224
+ // The keys queued so far dropped, and those that arrive in the next
1225
+ // `turns` turns of the event loop too: what was in the terminal's buffer
1226
+ // when the review was drawn was typed before it was seen, as drainInput
1227
+ // reasons for the line-mode confirm. Ctrl+C is never dropped.
1228
+ drain: (turns = 0) => {
1229
+ for (let k = queue.length - 1; k >= 0; k--) if (queue[k] !== "cancel") queue.splice(k, 1);
1230
+ if (!turns) return;
1231
+ dropping = true;
1232
+ let n = 0;
1233
+ const step = () => (++n >= turns ? (dropping = false) : setImmediate(step));
1234
+ setImmediate(step);
1235
+ },
1236
+ next: () =>
1237
+ queue.length
1238
+ ? Promise.resolve(queue.shift())
1239
+ : new Promise((settle) => {
1240
+ waiter = () => settle(queue.shift());
1241
+ }),
1242
+ wake: (e) => push(e),
1243
+ close() {
1244
+ if (!open) return;
1245
+ open = false;
1246
+ stdin.removeListener("keypress", onKey);
1247
+ stdin.removeListener("end", onEnd);
1248
+ stdin.removeListener("error", onEnd);
1249
+ if (typeof stdout.removeListener === "function") stdout.removeListener("resize", onResize);
1250
+ queue.length = 0;
1251
+ try {
1252
+ stdin.pause();
1253
+ } catch {}
1254
+ restore();
1255
+ release();
1256
+ },
1257
+ };
1258
+ }
1259
+
1093
1260
  // What was typed ahead of a line-mode question, discarded (the setup story,
1094
1261
  // rule 2: the rail's raw mode ends at `└`, and the confirm after it is
1095
1262
  // askLine's). Keys typed while the run fetched, read the registry and drew