@visual-z/xeno 0.2.0 → 0.2.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # xeno changelog
2
2
 
3
+ ## 0.2.1 — 2026-09-30
4
+
5
+ - Handle Ctrl+C and termination signals while startup awaits resources or workspace snapshots, restoring the terminal before exit.
6
+ - Show the current phase during slow initialization and retain early input until Pi's editor or startup selector owns it.
7
+ - Preserve pasted control characters, Unicode input and terminal state across startup failures and selector transitions.
8
+
3
9
  ## 0.2.0 — 2026-09-30
4
10
 
5
11
  - Restore saved model identity on session entry; cross-model history moves through a mechanical checkpoint.
package/README.md CHANGED
@@ -17,7 +17,7 @@ is ever summarized by a model, and nothing is ever rewritten in place.
17
17
  messages[] frozen system prefix + handoff + this node's events
18
18
  ```
19
19
 
20
- Version **0.2.0**. The on-disk session format will still change; `0.x` is that
20
+ Version **0.2.1**. The on-disk session format will still change; `0.x` is that
21
21
  statement. Published as `@visual-z/xeno`; the license is `UNLICENSED` (readable and installable, no reuse granted).
22
22
 
23
23
  ## Install
package/docs/install.md CHANGED
@@ -68,6 +68,11 @@ Each invocation starts a new session. `xeno -c` continues the newest one;
68
68
  and nothing is deleted. Resume restores the saved model. An explicit
69
69
  `--model` switches at a checkpoint while keeping the original log intact.
70
70
 
71
+ Startup loads resources and establishes a workspace snapshot before the editor
72
+ opens. Slow initialization shows its current stage; Ctrl+C cancels startup and
73
+ restores the terminal. Snapshots cover the selected workspace, so start inside
74
+ the intended project or pass `--workspace <path>`.
75
+
71
76
  ## Credentials
72
77
 
73
78
  Resolution order, first match wins:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@visual-z/xeno",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "license": "UNLICENSED",
5
5
  "type": "module",
6
6
  "description": "Segmented append-only agent log with pure context and handoff projections",
package/src/apps/cli.ts CHANGED
@@ -13,18 +13,16 @@
13
13
  */
14
14
 
15
15
  import {
16
- parseArgs, startup, readPipedStdin, mergePrintPrompt, silenceTtyEarly, printCliHelp,
16
+ parseArgs, startup, readPipedStdin, mergePrintPrompt, isInteractiveSession, printCliHelp,
17
17
  } from "./startup.ts";
18
+ import { createStartupInput } from "./startup-input.ts";
18
19
  import { runPrompt, runRepl } from "./shell.ts";
19
20
  import { runPackageCommand } from "../runtime/package-manager.ts";
20
21
  import { exportHtmlFile } from "./export-cli.ts";
21
22
 
22
23
  const args = parseArgs(process.argv.slice(2), { deferExtensions: true });
23
- // Before startup reads auth, scans context files and prints the header:
24
- // in cooked mode the tty would echo anything typed during that window
25
- // straight into the banner.
26
- const restoreTty = silenceTtyEarly({ interactive: !args.packageCommand && !args.help && !args.version && !args.exportFile && !args.print && !args.prompt });
27
- process.once("exit", restoreTty);
24
+ const input = createStartupInput({ interactive: isInteractiveSession(args) && !args.packageCommand && !args.version && !args.exportFile && !args.listModels && !args.modelsCommand });
25
+ process.once("exit", input.restore);
28
26
  try {
29
27
  if (args.help && (args.packageCommand || args.configCommand || args.exportFile || args.version || args.listModels || args.modelsCommand)) {
30
28
  printCliHelp();
@@ -44,22 +42,23 @@ try {
44
42
  catch (error) { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = controller.signal.aborted ? 130 : 1; }
45
43
  finally { process.off("SIGINT", cancel); process.off("SIGTERM", cancel); }
46
44
  } else {
47
- const app = await startup(args);
45
+ const app = await startup(args, input);
48
46
  if (args.print) {
49
47
  // With a prompt already given, stdin is optional context: wait briefly for it.
50
48
  args.prompt = mergePrintPrompt(args.prompt,
51
49
  await readPipedStdin(process.stdin, args.prompt.length > 0 ? { firstByteMs: 3_000 } : {}));
52
50
  }
53
51
  if (args.prompt) {
54
- restoreTty();
52
+ input.restore();
55
53
  await runPrompt(app, args.prompt);
56
54
  } else {
57
- await runRepl(app, restoreTty);
55
+ input.phase("Loading conversation");
56
+ await runRepl(app, input.handoff);
58
57
  }
59
58
  }
60
59
  } catch (error) {
61
60
  console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1;
62
61
  } finally {
63
- process.off("exit", restoreTty);
64
- restoreTty();
62
+ process.off("exit", input.restore);
63
+ input.restore();
65
64
  }
@@ -33,7 +33,7 @@ export class TrustSelector extends SelectList {
33
33
  .map(line => truncateToWidth(line, width, ""));
34
34
  }
35
35
  }
36
- export async function pickProjectTrust(workspace: string): Promise<boolean> {
36
+ export async function pickProjectTrust(workspace: string, handoff?: () => void): Promise<boolean> {
37
37
  const decision = await projectTrust(workspace);
38
38
  installBindings((await loadUiResources({ workspace, trusted: false })).keymap.byAction);
39
39
  const screen = createInteractiveTui();
@@ -42,7 +42,7 @@ export async function pickProjectTrust(workspace: string): Promise<boolean> {
42
42
  try {
43
43
  const choice = await new Promise<string | null>(resolve => {
44
44
  select.onInput = data => { const state = select.acceptInput(data); if (state.cancelled) resolve(null); else if (state.confirmed) resolve(state.confirmed.value); screen.requestRender(); };
45
- screen.start();
45
+ handoff?.(); screen.start();
46
46
  });
47
47
  return choice === null ? false : applyTrustChoice(workspace, choice as TrustChoice);
48
48
  } finally { await screen.terminal.drainInput(); screen.stop(); }
@@ -54,7 +54,7 @@ export class ResourceBrowser extends SelectList {
54
54
  }
55
55
 
56
56
  /** Standalone configuration does not allocate a conversation or require a model credential. */
57
- export async function runResourceConfig(options: ResourceOptions): Promise<void> {
57
+ export async function runResourceConfig(options: ResourceOptions, handoff?: () => void): Promise<void> {
58
58
  const index = await discoverResources(options);
59
59
  const ui = await loadUiResources(options, index);
60
60
  const screen = createInteractiveTui();
@@ -69,7 +69,7 @@ export async function runResourceConfig(options: ResourceOptions): Promise<void>
69
69
  if (browser.acceptInput(data).cancelled) resolve();
70
70
  screen.requestRender();
71
71
  }).catch(error => { browser.notice = String(error); screen.requestRender(); }); };
72
- screen.start();
72
+ handoff?.(); screen.start();
73
73
  });
74
74
  } finally { await screen.terminal.drainInput(); screen.stop(); }
75
75
  }
@@ -22,7 +22,7 @@ export async function pickStartupSession(opts: {
22
22
  style: Style;
23
23
  say: (line: string) => void;
24
24
  keymap?: Keymap;
25
- }): Promise<string | null> {
25
+ }, handoff?: () => void): Promise<string | null> {
26
26
  const root = { path: sessionsDir(opts.workspace, opts.sessionDir), workspace: opts.workspace };
27
27
  await registerSessionRoot(root).catch(error => opts.say(String(error)));
28
28
  const screen = createInteractiveTui();
@@ -40,7 +40,7 @@ export async function pickStartupSession(opts: {
40
40
  else if (state.confirmed) resolve(state.confirmed.value);
41
41
  else screen.requestRender();
42
42
  }).catch(error => { opts.say(String(error)); }); };
43
- screen.start();
43
+ handoff?.(); screen.start();
44
44
  void select.load().then(async () => {
45
45
  if (select.scope === "current" && !select.sessionCount && !select.notice && (await sessionRoots(root)).length === 1) {
46
46
  opts.say("No sessions to resume"); resolve(null);
@@ -0,0 +1,94 @@
1
+ /** Startup owns raw input until a Pi screen can own it (D19). */
2
+ import { StdinBuffer } from "../vendor/pi-tui/stdin-buffer.ts";
3
+ import { isKeyRelease, matchesKey } from "../vendor/pi-tui/keys.ts";
4
+ import { resolveEscapeTimeoutMs } from "../vendor/pi-tui/terminal.ts";
5
+
6
+ type StartupStdin = Pick<NodeJS.ReadStream, "isTTY" | "isRaw" | "setRawMode" | "setEncoding" | "on" | "off" | "resume" | "pause" | "unshift">;
7
+ export interface StartupInput {
8
+ phase(name: string): void;
9
+ /** Pause, restore the shell's mode and replay held bytes to the next input owner. */
10
+ handoff(): void;
11
+ /** Final cleanup discards unsubmitted input instead of leaking it to the shell. */
12
+ restore(): void;
13
+ runWithTerminal<T>(run: (handoff: () => void) => Promise<T>): Promise<T>;
14
+ }
15
+
16
+ export function createStartupInput(opts: {
17
+ interactive: boolean;
18
+ stdin?: StartupStdin;
19
+ signals?: NodeJS.EventEmitter;
20
+ report?: (message: string) => void;
21
+ exit?: (code: number) => void;
22
+ }): StartupInput {
23
+ const stdin = opts.stdin ?? process.stdin;
24
+ if (!opts.interactive || stdin.isTTY !== true) return {
25
+ phase() {}, handoff() {}, restore() {}, runWithTerminal: run => run(() => {}),
26
+ };
27
+ const signals = opts.signals ?? process;
28
+ const report = opts.report ?? (message => console.error(message));
29
+ const exit = opts.exit ?? (code => process.exit(code));
30
+ const wasRaw = stdin.isRaw === true;
31
+ let active = false, closed = false, announced = false;
32
+ let phase = "Reading configuration";
33
+ let pending: string[] = [];
34
+ let parser: StdinBuffer | undefined;
35
+ let timer: ReturnType<typeof setTimeout> | undefined;
36
+
37
+ const release = () => {
38
+ if (!active) return;
39
+ active = false;
40
+ clearTimeout(timer);
41
+ stdin.pause();
42
+ stdin.off("data", receive);
43
+ signals.off("SIGINT", interrupt); signals.off("SIGTERM", terminate); signals.off("SIGHUP", hangup);
44
+ parser?.destroy(); parser = undefined;
45
+ stdin.setRawMode(wasRaw);
46
+ };
47
+ const restore = () => { closed = true; release(); pending = []; };
48
+ const cancel = (code: number) => {
49
+ if (!active) return;
50
+ restore();
51
+ if (code === 130) report("Startup cancelled.");
52
+ exit(code);
53
+ };
54
+ const interrupt = () => cancel(130), terminate = () => cancel(143), hangup = () => cancel(129);
55
+ const receive = (data: string) => {
56
+ // Keep original chunks, including partial escapes/pastes, for Pi's own decoder.
57
+ // The temporary parser only recognizes cancellation outside bracketed paste.
58
+ pending.push(data);
59
+ parser?.process(data);
60
+ };
61
+ const announce = () => { if (active) { announced = true; report(` Starting xeno: ${phase} (Ctrl+C to cancel)`); } };
62
+ const capture = () => {
63
+ if (closed || active) return;
64
+ active = true; announced = false;
65
+ parser = new StdinBuffer({ escapeTimeout: resolveEscapeTimeoutMs() });
66
+ parser.on("data", (data: string) => { if (!isKeyRelease(data) && matchesKey(data, "ctrl+c")) interrupt(); });
67
+ stdin.setEncoding("utf8");
68
+ stdin.on("data", receive);
69
+ signals.on("SIGINT", interrupt); signals.on("SIGTERM", terminate); signals.on("SIGHUP", hangup);
70
+ stdin.setRawMode(true);
71
+ stdin.resume();
72
+ // Assumed UI grace period, not a work deadline: fast starts stay quiet;
73
+ // a slow stage remains visible without truncating or abandoning its work.
74
+ timer = setTimeout(announce, 750);
75
+ timer.unref();
76
+ };
77
+ const handoff = () => {
78
+ if (!active) return;
79
+ release();
80
+ const buffered = pending.join(""); pending = [];
81
+ if (buffered) stdin.unshift(buffered);
82
+ };
83
+ capture();
84
+ return {
85
+ phase(name) { if (name === phase) return; phase = name; if (announced) announce(); },
86
+ handoff, restore,
87
+ async runWithTerminal(run) {
88
+ // A selector can load settings/history before mounting. Keep owning input
89
+ // until its synchronous screen.start(), then reacquire only after stop().
90
+ try { return await run(handoff); }
91
+ finally { capture(); }
92
+ },
93
+ };
94
+ }
@@ -57,6 +57,7 @@ import { DEFAULT_CONTEXT_POLICY, isContextPolicy, type ContextPolicy } from "../
57
57
  import { windowBudget } from "../core/context-window.ts";
58
58
  import { colorEnabled, createPalette, describeError, stdinIsTty, stdoutIsTty, type Palette } from "./render.ts";
59
59
  import { type ActionId, type Keymap } from "../core/keymap.ts";
60
+ import type { StartupInput } from "./startup-input.ts";
60
61
 
61
62
  export const SYSTEM_PROMPT = systemPromptFor(TOOL_SCHEMAS.map((tool) => tool.name));
62
63
 
@@ -346,32 +347,6 @@ function rejectUnknownFlag(flag: string): never {
346
347
  process.exit(1);
347
348
  }
348
349
 
349
- /**
350
- * Stop the terminal echoing before startup does anything slow.
351
- *
352
- * Reading auth, scanning context files and printing the header all happen
353
- * before the TUI attaches. In cooked mode the tty echoes whatever is typed
354
- * during that window, so keystrokes land *between* the banner's own lines
355
- * and look like output the program produced. Raw mode is the TUI's anyway;
356
- * taking it at the first instruction just closes the gap.
357
- *
358
- * `stdin` is a parameter so the decision is testable without a real tty.
359
- * Returns an undo so a caller that bails out can put the terminal back.
360
- */
361
- export function silenceTtyEarly(opts: {
362
- interactive: boolean;
363
- stdin?: Pick<NodeJS.ReadStream, "isTTY" | "setRawMode" | "resume">;
364
- }): () => void {
365
- const stdin = opts.stdin ?? process.stdin;
366
- if (!opts.interactive || stdin.isTTY !== true) return () => {};
367
- stdin.setRawMode(true);
368
- // Leave input buffered until ProcessTerminal installs its listener. Resuming
369
- // here drops keystrokes typed while startup or transcript loading awaits I/O.
370
- return () => {
371
- if (stdin.isTTY === true) stdin.setRawMode(false);
372
- };
373
- }
374
-
375
350
  export function parseArgs(argv: string[], options: { deferExtensions?: boolean; flags?: ReadonlyMap<string, ExtensionFlag> } = {}): Args {
376
351
  let selectedTheme: string | undefined;
377
352
  const args: Args = {
@@ -961,6 +936,7 @@ export function sessionDirFor(args: Args, workspace: string): string | undefined
961
936
  }
962
937
 
963
938
  type OpenSessionOptions = Omit<Parameters<typeof createSessionFor>[0], "providerConfig" | "credential" | "oauth" | "system"> & {
939
+ startupPhase?: (name: string) => void;
964
940
  catalog: readonly ModelSpec[];
965
941
  auth: AuthFile;
966
942
  credentialOf: (spec: ModelSpec) => Resolved;
@@ -1021,6 +997,7 @@ export async function openSessionFor(opts: OpenSessionOptions): Promise<{
1021
997
  session: Session; priorCursor: Cursor | null; providerConfig: ProviderConfig; credential: Resolved;
1022
998
  workspace: string; blobDir: string; system: string; resources: Awaited<ReturnType<typeof loadSessionResources>>;
1023
999
  }> {
1000
+ opts.startupPhase?.("Reading session history");
1024
1001
  const recorded = await readCursor(opts.logDir);
1025
1002
  const priorCursor = recorded === null ? null : await recoverCut(opts.logDir, recorded);
1026
1003
  const workspace = priorCursor?.workspace ?? opts.workspace;
@@ -1029,6 +1006,7 @@ export async function openSessionFor(opts: OpenSessionOptions): Promise<{
1029
1006
  if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) throw new Error("saved workspace is outside --path-root");
1030
1007
  }
1031
1008
  if (opts.preparedResources && opts.preparedResources.workspace !== workspace) await opts.preparedResources.value.extensions.dispose();
1009
+ opts.startupPhase?.("Loading session resources");
1032
1010
  const resources = opts.preparedResources?.workspace === workspace ? opts.preparedResources.value
1033
1011
  : await loadSessionResources(opts.args, workspace, opts.io, workspace === opts.workspace ? opts.projectTrusted : undefined);
1034
1012
  const interactive = opts.interactive && !opts.args.print && !opts.args.prompt && opts.args.mode !== "json";
@@ -1042,6 +1020,7 @@ export async function openSessionFor(opts: OpenSessionOptions): Promise<{
1042
1020
  const explicit = flags.model.length > 0 || flags.provider.length > 0;
1043
1021
  const settings = opts.settings ?? {};
1044
1022
  const preferred = saved ?? opts.fallback?.spec;
1023
+ opts.startupPhase?.("Resolving model credentials");
1045
1024
  const selected = !explicit && preferred !== undefined
1046
1025
  ? { ok: true as const, spec: preferred }
1047
1026
  : selectModel(settings, flags, (spec) => opts.credentialOf(spec).ok, opts.catalog);
@@ -1086,6 +1065,7 @@ export async function openSessionFor(opts: OpenSessionOptions): Promise<{
1086
1065
  credential: restoringCredential,
1087
1066
  ...(oauth(restoring, restoringCredential) ? { oauth: true } : {}),
1088
1067
  });
1068
+ opts.startupPhase?.("Preparing workspace snapshot");
1089
1069
  await session.start();
1090
1070
  if (resources.extensions.loaded.length || session.config.frozen.tools.some(tool =>
1091
1071
  typeof tool === "object" && tool !== null && "name" in tool && typeof tool.name === "string" && unknownToolNames([tool.name]).length)) {
@@ -1103,7 +1083,8 @@ export async function openSessionFor(opts: OpenSessionOptions): Promise<{
1103
1083
  blobDir: session.config.blobDir ?? opts.blobDir, system: session.config.frozen.system, resources };
1104
1084
  }
1105
1085
 
1106
- export async function startup(args: Args): Promise<App> {
1086
+ export async function startup(args: Args, input?: StartupInput): Promise<App> {
1087
+ const withTerminal = <T>(run: (handoff: () => void) => Promise<T>): Promise<T> => input ? input.runWithTerminal(run) : run(() => {});
1107
1088
  if (args.extensionArgv && (args.listModels || args.modelsCommand || args.configCommand)) rejectUnknownFlag("--" + Object.keys(args.extensionInputs ?? {})[0]);
1108
1089
  const C = createPalette(colorEnabled());
1109
1090
  let printMode = isPrintMode(args);
@@ -1158,6 +1139,7 @@ export async function startup(args: Args): Promise<App> {
1158
1139
  }
1159
1140
 
1160
1141
  // File only. Passing fetch here would make the test of D16 un-runnable.
1142
+ input?.phase("Loading model catalog");
1161
1143
  const loaded = loadCatalog(await readModelSources());
1162
1144
  for (const warning of loaded.warnings) io.say(C.yellow(` ${warning}`));
1163
1145
  const catalog = loaded.models;
@@ -1187,6 +1169,7 @@ export async function startup(args: Args): Promise<App> {
1187
1169
  * It has to happen before model selection so a trusted `.xeno/settings.json`
1188
1170
  * can override the default model. Trust does not affect tools.
1189
1171
  */
1172
+ input?.phase("Opening workspace");
1190
1173
  let workspace: string;
1191
1174
  try {
1192
1175
  workspace = await workspaceRoot(args.workspace);
@@ -1206,6 +1189,7 @@ export async function startup(args: Args): Promise<App> {
1206
1189
  }
1207
1190
  }
1208
1191
 
1192
+ input?.phase("Preparing extensions and project settings");
1209
1193
  await prepareExtensionPackages(args,workspace);
1210
1194
  const trust = await projectTrust(workspace, args.approve);
1211
1195
  let loadProjectSettings = trust.trusted;
@@ -1213,11 +1197,11 @@ export async function startup(args: Args): Promise<App> {
1213
1197
  if (!stdinIsTty() || !stdoutIsTty()) {
1214
1198
  const index = await discoverResources({ ...args, workspace, trusted: loadProjectSettings });
1215
1199
  for (const entry of index.entries) io.say(`${entry.enabled ? "[x]" : "[ ]"} ${entry.kind} ${entry.scope}${entry.package?" · "+entry.package.source:""} ${entry.path}`);
1216
- } else await runResourceConfig({ ...args, workspace, trusted: loadProjectSettings });
1200
+ } else await withTerminal(handoff => runResourceConfig({ ...args, workspace, trusted: loadProjectSettings }, handoff));
1217
1201
  process.exit(0);
1218
1202
  }
1219
1203
  const hasProjectResources = (await Promise.all(["settings.json", "keybindings.json", "skills", "prompts", "themes", "extensions"].map(name => stat(join(workspace, ".xeno", name)).then(() => true, () => false)))).some(Boolean);
1220
- if (!args.help && hasProjectResources && args.approve === undefined && !trust.saved && trust.policy === "ask" && isInteractiveSession(args)) loadProjectSettings = await pickProjectTrust(workspace);
1204
+ if (!args.help && hasProjectResources && args.approve === undefined && !trust.saved && trust.policy === "ask" && isInteractiveSession(args)) loadProjectSettings = await withTerminal(handoff => pickProjectTrust(workspace, handoff));
1221
1205
  if (hasProjectResources && !loadProjectSettings) io.say(C.dim(" Project resources not loaded (project is not trusted). /trust changes resource trust; tool permissions are separate."));
1222
1206
 
1223
1207
  // Resolve the resource owner before factories run. Parsing flags must not load a different cwd first.
@@ -1231,6 +1215,7 @@ export async function startup(args: Args): Promise<App> {
1231
1215
  else if (args.continueLatest) resourceLog = (await listSessions(workspace, sessionDirFor(args, workspace), Infinity)).sessions.find(session => session.ok)?.path;
1232
1216
  if (resourceLog) resourceWorkspace = (await readCursor(resourceLog))?.workspace ?? workspace;
1233
1217
  }
1218
+ input?.phase("Loading session resources");
1234
1219
  const preparedResources = prepareFlags
1235
1220
  ? { workspace: resourceWorkspace, value: await loadSessionResources(args, resourceWorkspace, io, resourceWorkspace === workspace ? loadProjectSettings : undefined) } : undefined;
1236
1221
  if (args.help) { printCliHelp(preparedResources?.value.extensions.flags); await preparedResources?.value.extensions.dispose(); process.exit(0); }
@@ -1268,8 +1253,8 @@ export async function startup(args: Args): Promise<App> {
1268
1253
  if (printMode || jsonMode || !stdinIsTty() || !stdoutIsTty()) {
1269
1254
  throw new Error("--resume needs an interactive terminal. Pass -r <id> or --session <id> instead");
1270
1255
  }
1271
- const picked = await pickStartupSession({ workspace, style: ui.style, say: io.say, keymap: ui.keymap,
1272
- ...(sessionDir === undefined ? {} : { sessionDir }) });
1256
+ const picked = await withTerminal(handoff => pickStartupSession({ workspace, style: ui.style, say: io.say, keymap: ui.keymap,
1257
+ ...(sessionDir === undefined ? {} : { sessionDir }) }, handoff));
1273
1258
  if (picked === null) { io.say(C.dim("No session selected")); process.exit(0); }
1274
1259
  sessionOpts = { explicit: picked };
1275
1260
  }
@@ -1301,6 +1286,7 @@ export async function startup(args: Args): Promise<App> {
1301
1286
  settings: mergedSettings, modelFlags: { model: args.model, provider: args.provider }, projectTrusted: loadProjectSettings,
1302
1287
  ...(preparedResources ? { preparedResources } : {}),
1303
1288
  interactive: isInteractiveSession(args),
1289
+ ...(input ? { startupPhase: input.phase } : {}),
1304
1290
  });
1305
1291
  } catch (err) {
1306
1292
  const detail = err instanceof Error ? err.message : String(err);
@@ -17,7 +17,7 @@ import { createServer, type Server } from "node:http";
17
17
  import { pollDeviceCode } from "./oauth-device.ts";
18
18
 
19
19
  /** Matches package.json. Sent as User-Agent so the server can see who we are (D18). */
20
- export const XENO_VERSION = "0.2.0";
20
+ export const XENO_VERSION = "0.2.1";
21
21
 
22
22
  /**
23
23
  * Minutes subtracted from server expiry so a request issued at the edge