@visual-z/xeno 0.7.1 → 0.7.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # xeno changelog
2
2
 
3
+ ## 0.7.2 — 2026-10-10
4
+
5
+ - `/update` now installs the newer release when xeno was installed with bun or npm, instead of only printing the command. The status line shows the target version, elapsed time against a 3-minute limit and the package manager's latest output; success asks for a restart, and a failure gives the reason and the command to run by hand. Automatic checks still never install, and `xeno update --check` still only checks.
6
+
3
7
  ## 0.7.1 — 2026-10-09
4
8
 
5
9
  - Keep a dialog form editable after a validation error. A rejected provider id no longer destroys the input field (`EditBuffer is destroyed`), and the error suggests a valid id.
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.7.1**. The on-disk session format will still change; `0.x` is that
20
+ Version **0.7.2**. 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
@@ -192,7 +192,7 @@ Slash commands that exist in the session (from `/help`):
192
192
  /import <file> validate and open an archive as an independent session
193
193
  /debug [file] write local terminal and message diagnostics
194
194
  /changelog show bundled release notes
195
- /update check now for a newer xeno version without installing
195
+ /update check for a newer xeno and install it (restart to use)
196
196
  /trust [once|here|parent|never]
197
197
  /reload refresh local resources at a durable context checkpoint
198
198
  /config search resources and edit global or project overrides
@@ -479,10 +479,10 @@ unblocking affects new submissions, preserving historical request prefixes.
479
479
  - **`/back` does not restore symlinks.**
480
480
  - **`.xeno/` is not collected.** Snapshots and abandoned session branches
481
481
  stay until you delete them.
482
- - **No telemetry, `/share`, self-installing updates, or `/compact`.** Those
482
+ - **No telemetry, `/share`, automatic updates, or `/compact`.** Those
483
483
  are refusals (`docs/PI_PARITY.md` D15, D16), not backlog. xeno does say
484
484
  when a newer version is published, checking automatically at most once a day.
485
- `/update` or `xeno update --check` checks immediately without installing;
485
+ `/update` checks and installs when you ask; `xeno update --check` only checks;
486
486
  `docs/install.md` (Updates) lists what that request reveals and every
487
487
  way to turn it off.
488
488
 
package/docs/install.md CHANGED
@@ -74,8 +74,9 @@ and restores the terminal. Start inside the intended project or pass
74
74
 
75
75
  ## Updates
76
76
 
77
- xeno says when a newer version is published. It never installs one: the
78
- notice names the command, and you run it.
77
+ xeno says when a newer version is published. It never installs one on its
78
+ own: the notice names the command. Typing `/update` is how you ask it to
79
+ install (below).
79
80
 
80
81
  ```
81
82
  xeno 0.4.0 is available (you have 0.3.0)
@@ -87,7 +88,19 @@ The command follows where this copy is installed: `bun add -g` under
87
88
  (`…/lib/node_modules/`, or `…\npm\node_modules\` on Windows). Anywhere else
88
89
  the notice points to this file. A checkout updates with `git pull`.
89
90
 
90
- To check immediately, type `/update` in the TUI or run:
91
+ To update now, type `/update` in the TUI. It checks the registry and, when
92
+ a newer release exists and xeno was installed with bun or npm, runs the
93
+ command from the notice pinned to that exact version (for example
94
+ `bun add -g @visual-z/xeno@0.7.1`). The status line shows the target version,
95
+ elapsed time against a 3-minute limit, and the package manager's latest output
96
+ line; the editor stays usable. Success says `Updated xeno A → B. Restart xeno
97
+ to use it.` — the running session keeps the old code until you restart. A
98
+ failure says why (exit code or timeout, with the package manager's last lines)
99
+ and repeats the command to run yourself. An unwritable install directory is
100
+ refused before anything runs. Other install locations and checkouts are never
101
+ installed into; they get the notice or `git pull` advice.
102
+
103
+ To check without installing:
91
104
 
92
105
  ```sh
93
106
  xeno update --check
@@ -98,8 +111,8 @@ automatic-check environment switches, and CI suppression. It reports the
98
111
  current version and the result, including a failed query; a stale cache is
99
112
  never presented as a successful fresh check. A successful query refreshes
100
113
  the same cache used by startup. Repeated checks while a request is running
101
- share that request. No package is installed and no model credential or
102
- session is needed for the terminal command.
114
+ share that request. `xeno update --check` never installs, and needs no model
115
+ credential or session.
103
116
 
104
117
  `--offline` / `XENO_OFFLINE` still prevent network access. Source checkouts
105
118
  explain that they update with `git pull` instead of querying npm. Bare
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@visual-z/xeno",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
4
4
  "license": "UNLICENSED",
5
5
  "type": "module",
6
6
  "description": "Segmented append-only agent log with pure context and handoff projections",
@@ -89,7 +89,7 @@ export const COMMANDS = [
89
89
  { name: "import", description: "Import a portable JSONL session", argumentHint: "<file>", busy: "idle" },
90
90
  { name: "debug", description: "Write local terminal diagnostics", argumentHint: "[file]", busy: "run" },
91
91
  { name: "changelog", description: "Show local release notes", busy: "run" },
92
- { name: "update", description: "Check now for a newer xeno version without installing", busy: "run" },
92
+ { name: "update", description: "Check for a newer xeno and install it; restart to use it", busy: "run" },
93
93
  { name: "copy", description: "Copy the last assistant reply", busy: "run" },
94
94
  { name: "attachments", description: "Remove images from the current draft", busy: "run" },
95
95
  { name: "models", description: "Refresh the model catalog", argumentHint: "<refresh>", busy: "idle" },
package/src/apps/shell.ts CHANGED
@@ -656,7 +656,7 @@ async function execute(app: App, command: Command, tui: Ui | null): Promise<Outc
656
656
  }
657
657
  case "update":
658
658
  if (tui) tui.checkForUpdate();
659
- else await runManualUpdate({ offline: app.args.offline === true }, app.io.say);
659
+ else await runManualUpdate({ offline: app.args.offline === true, install: true }, app.io.say);
660
660
  return "continue";
661
661
  case "copy":
662
662
  await copyLastReply(app, tui ?? undefined);
@@ -1588,7 +1588,7 @@ export function printCliHelp(flags?: ReadonlyMap<string, ExtensionFlag>): void {
1588
1588
  " [-n|--name name] [--session-id id] [--version|-v]\n" +
1589
1589
  " [models --refresh]\n" +
1590
1590
  " [install|remove <source>... [--local]] [update [source] [--extensions]] [list]\n" +
1591
- " [update --check] Check for a newer xeno without installing\n" +
1591
+ " [update --check] Check for a newer xeno without installing (/update installs)\n" +
1592
1592
  " [config [--local]] [--export session [output.html]] [--offline]\n" +
1593
1593
  " [--system-prompt <text-or-file>] [--append-system-prompt <text>]\n" +
1594
1594
  " [--no-context-files|-nc]\n" +
@@ -41,6 +41,7 @@ import { SUBSCRIPTION_OPTIONS, subscriptionById, loginGitHubCopilot, startCallba
41
41
  import { reportedCost } from "../../runtime/usage.ts";
42
42
  import { UpdateChecks } from "../update-check.ts";
43
43
  import { updateNotice } from "../../core/update-check.ts";
44
+ import { assertNever } from "../../core/exhaustive.ts";
44
45
  import { isUpgrade } from "../../core/version.ts";
45
46
  import type { Effort } from "../../runtime/model.ts";
46
47
  import { readNode, readCursor, setSessionLabel } from "../../store/log.ts";
@@ -361,13 +362,17 @@ export class InteractiveApp implements Ui {
361
362
  checkForUpdate(): void {
362
363
  // The explicit action owns its result even when it joins a startup request.
363
364
  this.manualUpdateRequested = true;
364
- void this.updateChecks.manual({ offline: this.app.args.offline === true }, (text, tone) => {
365
+ void this.updateChecks.manual({ offline: this.app.args.offline === true, install: true }, (text, tone) => {
365
366
  if (!this.disposed) this.notice(text, tone);
366
367
  }, activity => {
367
368
  if (this.disposed) return;
368
- this.screen.setTransientActivity(activity.state === "checking" ? {
369
- label: `Checking for xeno updates (current ${activity.current})`, timeoutMs: activity.timeoutMs,
370
- } : undefined);
369
+ switch (activity.state) {
370
+ case "checking": this.screen.setTransientActivity({ label: `Checking for xeno updates (current ${activity.current})`, timeoutMs: activity.timeoutMs }); break;
371
+ case "installing": this.screen.setTransientActivity({ label: `Installing xeno ${activity.version}`, timeoutMs: activity.timeoutMs,
372
+ startedAt: activity.startedAt, ...(activity.detail ? { detail: activity.detail } : {}) }); break;
373
+ case "idle": this.screen.setTransientActivity(undefined); break;
374
+ default: assertNever(activity, "manual update activity");
375
+ }
371
376
  }).catch(() => {
372
377
  if (!this.disposed) {
373
378
  this.screen.setTransientActivity(undefined);
@@ -1,16 +1,21 @@
1
1
  /** Shared lifecycle for automatic hints and explicit checks; policy remains in core (D16). */
2
- import { installKind, manualUpdateResult, updateDisabled, updatePlan, type UpdateInput } from "../core/update-check.ts";
2
+ import { dirname } from "node:path";
3
+ import { installArgv, installKind, installResult, manualUpdateResult, updateDisabled, updatePlan, type UpdateInput } from "../core/update-check.ts";
3
4
  import { packageLocation, readUpdateCache, writeUpdateCache } from "../store/update-cache.ts";
4
5
  import { fetchLatestVersion, UPDATE_TIMEOUT_MS } from "../runtime/update-check.ts";
6
+ import { INSTALL_TIMEOUT_MS, runInstall } from "../runtime/self-update.ts";
5
7
  import { XENO_VERSION } from "../runtime/oauth.ts";
6
8
 
7
9
  export interface UpdateOptions {
8
10
  readonly offline?: boolean;
9
11
  readonly enabled?: boolean | undefined;
12
+ /** Install a newer release after the check: `/update` does, `xeno update --check` never does. */
13
+ readonly install?: boolean;
10
14
  }
11
15
 
12
16
  export type ManualUpdateActivity =
13
17
  | { readonly state: "checking"; readonly current: string; readonly timeoutMs: number }
18
+ | { readonly state: "installing"; readonly version: string; readonly timeoutMs: number; readonly startedAt: number; readonly detail?: string }
14
19
  | { readonly state: "idle" };
15
20
 
16
21
  type ManualUpdateReporter = (text: string, tone?: "info" | "error") => void;
@@ -19,6 +24,8 @@ export class UpdateChecks {
19
24
  private readonly controller = new AbortController();
20
25
  private pending: Promise<string | null> | undefined;
21
26
  private manualTask: Promise<number> | undefined;
27
+ /** The release this process already installed; the running code stays old until a restart. */
28
+ private installed: string | undefined;
22
29
 
23
30
  async prepare(options: UpdateOptions, manual = false) {
24
31
  const location = packageLocation();
@@ -50,6 +57,10 @@ export class UpdateChecks {
50
57
  }
51
58
 
52
59
  private async runManual(options: UpdateOptions, say: ManualUpdateReporter, onActivity?: (activity: ManualUpdateActivity) => void): Promise<number> {
60
+ if (options.install && this.installed) {
61
+ say(`xeno ${this.installed} is already installed. Restart xeno to use it.`);
62
+ return 0;
63
+ }
53
64
  const { input, plan, disabled } = await this.prepare(options, true);
54
65
  if (this.controller.signal.aborted) return 130;
55
66
  if (!plan.check) {
@@ -63,13 +74,32 @@ export class UpdateChecks {
63
74
  const latest = await this.refresh(input);
64
75
  if (this.controller.signal.aborted) return 130;
65
76
  const result = manualUpdateResult(latest, input.current, input.install);
66
- say(result.text, result.status === "failed" ? "error" : "info");
67
- return result.status === "failed" ? 1 : 0;
77
+ const argv = options.install && result.status === "available" && latest ? installArgv(input.install, latest) : undefined;
78
+ if (!argv || !latest) {
79
+ say(result.text, result.status === "failed" ? "error" : "info");
80
+ return result.status === "failed" ? 1 : 0;
81
+ }
82
+ return await this.install(argv, latest, input.current, say, onActivity);
68
83
  } finally {
69
84
  if (onActivity) onActivity({ state: "idle" });
70
85
  }
71
86
  }
72
87
 
88
+ private async install(argv: readonly string[], latest: string, current: string, say: ManualUpdateReporter, onActivity?: (activity: ManualUpdateActivity) => void): Promise<number> {
89
+ const startedAt = Date.now();
90
+ if (onActivity) onActivity({ state: "installing", version: latest, timeoutMs: INSTALL_TIMEOUT_MS, startedAt });
91
+ else say(`Installing xeno ${latest} with ${argv.join(" ")}...`);
92
+ const outcome = await runInstall(argv, {
93
+ signal: this.controller.signal, timeoutMs: INSTALL_TIMEOUT_MS, writableDir: dirname(packageLocation().root),
94
+ ...(onActivity ? { onLine: (detail: string) => onActivity({ state: "installing", version: latest, timeoutMs: INSTALL_TIMEOUT_MS, startedAt, detail }) } : {}),
95
+ });
96
+ if (outcome.kind === "cancelled") return 130;
97
+ const result = installResult(outcome, argv, current, latest);
98
+ if (result.ok) this.installed = latest;
99
+ say(result.text, result.ok ? "info" : "error");
100
+ return result.ok ? 0 : 1;
101
+ }
102
+
73
103
  cancel(): void { this.controller.abort(); }
74
104
  }
75
105
 
@@ -8,6 +8,8 @@
8
8
  * environment and the cached answer are all arguments, so every rule is a
9
9
  * plain function of its inputs. Explicit checks bypass automatic scheduling
10
10
  * and opt-outs, while retaining the offline and source-checkout boundaries.
11
+ * An explicit `/update` also installs (docs/tasks/fix-update-installs.md):
12
+ * typing the command is the consent automatic installation never has.
11
13
  */
12
14
  import { compareVersions, isUpgrade, parseVersion } from "./version.ts";
13
15
 
@@ -76,6 +78,44 @@ export function upgradeCommand(install: InstallKind): string | undefined {
76
78
  }
77
79
  }
78
80
 
81
+ /**
82
+ * What an explicit `/update` runs: the package manager the notice names,
83
+ * pinned to the version just checked so `latest` cannot move between the
84
+ * check and the install. Argv, never a shell string. Undefined where xeno
85
+ * cannot know the right command and only the notice applies.
86
+ */
87
+ export function installArgv(install: InstallKind, version: string): readonly string[] | undefined {
88
+ switch (install) {
89
+ case "bun": return ["bun", "add", "-g", `${PACKAGE_NAME}@${version}`];
90
+ case "npm": return ["npm", "i", "-g", `${PACKAGE_NAME}@${version}`];
91
+ case "other": case "checkout": return undefined;
92
+ }
93
+ }
94
+
95
+ export type InstallOutcome =
96
+ | { readonly kind: "installed" }
97
+ | { readonly kind: "exit"; readonly code: number; readonly output: readonly string[] }
98
+ | { readonly kind: "timeout"; readonly ms: number; readonly output: readonly string[] }
99
+ | { readonly kind: "unavailable"; readonly reason: string }
100
+ | { readonly kind: "cancelled" };
101
+
102
+ /**
103
+ * How an install ended, in words a person can act on. A bare exit code hides
104
+ * ENOTEMPTY and EACCES (codex #5015, #21897), so failures carry the package
105
+ * manager's last lines and always the command to run by hand.
106
+ */
107
+ export function installResult(outcome: InstallOutcome, argv: readonly string[], current: string, latest: string): { readonly ok: boolean; readonly text: string } {
108
+ const manual = `Run it yourself: ${argv.join(" ")}`;
109
+ const tail = (output: readonly string[]) => output.length ? `\n${output.map(line => ` ${line}`).join("\n")}` : "";
110
+ switch (outcome.kind) {
111
+ case "installed": return { ok: true, text: `Updated xeno ${current} → ${latest}. Restart xeno to use it.` };
112
+ case "exit": return { ok: false, text: `Could not update xeno: ${argv[0]} exited ${outcome.code}.${tail(outcome.output)}\n${manual}` };
113
+ case "timeout": return { ok: false, text: `Could not update xeno: ${argv[0]} did not finish within ${Math.round(outcome.ms / 1000)}s and was stopped.${tail(outcome.output)}\n${manual}` };
114
+ case "unavailable": return { ok: false, text: `Could not update xeno: ${outcome.reason}.\n${manual}` };
115
+ case "cancelled": return { ok: false, text: "xeno update cancelled." };
116
+ }
117
+ }
118
+
79
119
  /** Two lines: what is new, then the command on its own line so it can be copied whole. */
80
120
  export function updateNotice(latest: string, current: string, install: InstallKind): string {
81
121
  const command = upgradeCommand(install);
@@ -18,7 +18,7 @@ import { pollDeviceCode } from "./oauth-device.ts";
18
18
  import { parseDeviceAuthorization, parseOAuthTokens } from "../core/oauth-fields.ts";
19
19
 
20
20
  /** Matches package.json. Sent as User-Agent so the server can see who we are (D18). */
21
- export const XENO_VERSION = "0.7.1";
21
+ export const XENO_VERSION = "0.7.2";
22
22
 
23
23
  /**
24
24
  * Minutes subtracted from server expiry so a request issued at the edge
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Run the package manager for an explicit `/update` (docs/tasks/fix-update-installs.md).
3
+ *
4
+ * The argv comes from core's `installArgv`; this module only owns the process.
5
+ * Every competitor runs the install unbounded, and that is exactly what their
6
+ * hang reports are about (claude-code #80147, opencode #10921 `bun add`
7
+ * stuck forever), so the install has a deadline and streams its latest line
8
+ * to the caller as evidence that it is moving.
9
+ */
10
+ import { access, constants } from "node:fs/promises";
11
+ import type { InstallOutcome } from "../core/update-check.ts";
12
+
13
+ /** A global bun/npm install normally takes seconds; three minutes covers a slow registry. Assumed. */
14
+ export const INSTALL_TIMEOUT_MS = 180_000;
15
+ /** Lines of package-manager output kept for a failure message: enough for an npm error block. */
16
+ const TAIL_LINES = 6;
17
+
18
+ export interface InstallOptions {
19
+ readonly signal?: AbortSignal;
20
+ readonly timeoutMs?: number;
21
+ /** The package manager's newest non-empty output line. */
22
+ readonly onLine?: (line: string) => void;
23
+ /** Refuse before spawning when this directory cannot be written, as pi does. */
24
+ readonly writableDir?: string;
25
+ }
26
+
27
+ const ANSI = /\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07]*\x07|\r/g;
28
+
29
+ export async function runInstall(argv: readonly string[], options: InstallOptions = {}): Promise<InstallOutcome> {
30
+ if (options.signal?.aborted) return { kind: "cancelled" };
31
+ if (options.writableDir) {
32
+ try { await access(options.writableDir, constants.W_OK); }
33
+ catch { return { kind: "unavailable", reason: `${options.writableDir} is not writable by this user` }; }
34
+ }
35
+ // Resolve through PATH like the person's own shell would; a bun-run xeno can always fall back to its own bun.
36
+ const program = Bun.which(argv[0]!) ?? (argv[0] === "bun" ? process.execPath : null);
37
+ if (!program) return { kind: "unavailable", reason: `${argv[0]} was not found on PATH` };
38
+ let child: ReturnType<typeof Bun.spawn<"ignore", "pipe", "pipe">>;
39
+ try { child = Bun.spawn([program, ...argv.slice(1)], { stdin: "ignore", stdout: "pipe", stderr: "pipe" }); }
40
+ catch (error) { return { kind: "unavailable", reason: error instanceof Error ? error.message : String(error) }; }
41
+
42
+ const output: string[] = [];
43
+ const read = async (stream: ReadableStream<Uint8Array>) => {
44
+ const decoder = new TextDecoder();
45
+ let pending = "";
46
+ const take = (line: string) => {
47
+ const clean = line.replace(ANSI, "").trim();
48
+ if (!clean) return;
49
+ output.push(clean); if (output.length > TAIL_LINES) output.shift();
50
+ options.onLine?.(clean);
51
+ };
52
+ for await (const chunk of stream) {
53
+ const lines = (pending + decoder.decode(chunk, { stream: true })).split("\n");
54
+ pending = lines.pop()!;
55
+ lines.forEach(take);
56
+ }
57
+ take(pending + decoder.decode());
58
+ };
59
+
60
+ let stopped: "timeout" | "cancelled" | undefined;
61
+ const timeoutMs = options.timeoutMs ?? INSTALL_TIMEOUT_MS;
62
+ const timer = setTimeout(() => { stopped = "timeout"; child.kill(); }, timeoutMs);
63
+ const abort = () => { stopped ??= "cancelled"; child.kill(); };
64
+ options.signal?.addEventListener("abort", abort, { once: true });
65
+ try {
66
+ const [code] = await Promise.all([child.exited, read(child.stdout), read(child.stderr)]);
67
+ if (stopped === "timeout") return { kind: "timeout", ms: timeoutMs, output };
68
+ if (stopped === "cancelled") return { kind: "cancelled" };
69
+ return code === 0 ? { kind: "installed" } : { kind: "exit", code, output };
70
+ } finally {
71
+ clearTimeout(timer);
72
+ options.signal?.removeEventListener("abort", abort);
73
+ }
74
+ }
@@ -96,7 +96,7 @@ export class Screen {
96
96
  private sidebarWanted = true;
97
97
  private status: StatusModel | undefined;
98
98
  private dockState: DockState = { model: "", thinking: "", busy: false, activity: "" };
99
- private transientActivity: { readonly label: string; readonly startedAt: number; readonly timeoutMs: number } | undefined;
99
+ private transientActivity: { readonly label: string; readonly startedAt: number; readonly timeoutMs: number; readonly detail?: string } | undefined;
100
100
  private home: Greeting | undefined;
101
101
  private greetingPlayed = false;
102
102
  private greetingFrame = MASCOT_FRAMES;
@@ -349,8 +349,9 @@ export class Screen {
349
349
  this.paintEdges();
350
350
  }
351
351
 
352
- setTransientActivity(activity: { readonly label: string; readonly timeoutMs: number } | undefined): void {
353
- this.transientActivity = activity ? { ...activity, startedAt: Date.now() } : undefined;
352
+ /** `startedAt` keeps the elapsed clock running when a long activity only refreshes its detail line. */
353
+ setTransientActivity(activity: { readonly label: string; readonly timeoutMs: number; readonly startedAt?: number; readonly detail?: string } | undefined): void {
354
+ this.transientActivity = activity ? { ...activity, startedAt: activity.startedAt ?? Date.now() } : undefined;
354
355
  this.syncActivity();
355
356
  this.paintEdges();
356
357
  }
@@ -427,7 +428,7 @@ export class Screen {
427
428
  const activity = this.transientActivity!;
428
429
  const elapsed = Math.max(0, Math.floor((Date.now() - activity.startedAt) / 1000));
429
430
  const deadline = Math.max(1, Math.ceil(activity.timeoutMs / 1000));
430
- return `${activity.label} · ${elapsed}s / ${deadline}s max`;
431
+ return `${activity.label} · ${elapsed}s / ${deadline}s max${activity.detail ? ` · ${activity.detail}` : ""}`;
431
432
  }
432
433
 
433
434
  /** The spinner's current interval (tests). */