@visual-z/xeno 0.6.0 → 0.7.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +13 -2
  3. package/docs/providers.md +1 -1
  4. package/package.json +1 -1
  5. package/src/apps/provider-connect.ts +3 -3
  6. package/src/apps/session-hub.ts +12 -1
  7. package/src/apps/shell.ts +1 -1
  8. package/src/apps/ui/app.ts +81 -19
  9. package/src/apps/ui/image-cache.ts +71 -28
  10. package/src/apps/update-check.ts +21 -10
  11. package/src/core/activity-status.ts +28 -0
  12. package/src/core/git-status.ts +29 -0
  13. package/src/core/image-limits.ts +72 -0
  14. package/src/core/progress.ts +9 -0
  15. package/src/core/request-budget.ts +13 -0
  16. package/src/core/session-exit.ts +26 -0
  17. package/src/core/settings.ts +1 -1
  18. package/src/core/stall.ts +18 -6
  19. package/src/core/stop-report.ts +25 -0
  20. package/src/runtime/exec-sessions.ts +40 -4
  21. package/src/runtime/images.ts +6 -2
  22. package/src/runtime/loop.ts +56 -11
  23. package/src/runtime/oauth.ts +19 -1
  24. package/src/runtime/openai-responses.ts +6 -4
  25. package/src/runtime/openai.ts +6 -1
  26. package/src/runtime/pi-provider.ts +11 -5
  27. package/src/runtime/provider-presets.ts +11 -0
  28. package/src/runtime/provider-request.ts +3 -1
  29. package/src/runtime/provider-tool-policy.ts +33 -0
  30. package/src/runtime/provider.ts +6 -1
  31. package/src/runtime/session-control.ts +1 -0
  32. package/src/runtime/tools.ts +7 -3
  33. package/src/store/blobs.ts +2 -1
  34. package/src/store/git-status.ts +15 -0
  35. package/src/tui/ui/dialog.ts +24 -5
  36. package/src/tui/ui/layout.ts +7 -7
  37. package/src/tui/ui/messages.ts +1 -1
  38. package/src/tui/ui/pictures.ts +26 -9
  39. package/src/tui/ui/screen.ts +30 -9
  40. package/src/tui/ui/status.ts +32 -6
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # xeno changelog
2
2
 
3
+ ## 0.7.1 — 2026-10-09
4
+
5
+ - 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.
6
+ - Send the Grok proxy's version-gate header so Grok account inference no longer fails with HTTP 426. A future 426 explains how a xeno user recovers.
7
+ - Show progress, elapsed time and a 3-second limit for a manual `/update` check, with failures styled as errors.
8
+
9
+ ## 0.7.0 — 2026-10-09
10
+
11
+ - Keep fullscreen exit output bounded to the execution state and resume command. Preserve full transcript export as an explicit choice.
12
+ - Wait for actual process output, exit or the configured timeout when the agent owns an empty-input process wait. Preserve steering, cancellation and process receipts without spending model requests on empty polling.
13
+ - Reserve the last available model request for a response with tools disabled, and report unfinished work when the budget is exhausted. Track cumulative unproductive activity separately from consecutive failures.
14
+ - Share in-flight session discovery so concurrent cold resumes resolve aliases to one execution owner.
15
+ - Show provider, approval/YOLO, Build/Plan, running/waiting/queued state, model budgets and Git status from their current owners. Label model request time explicitly.
16
+ - Preserve screenshot detail with a 4096-pixel long edge and an 8 Mi-pixel default limit, preflight decoding, bounded image caching and visible load errors.
17
+
3
18
  ## 0.6.0 — 2026-10-09
4
19
 
5
20
  - Add a foundation tool profile and Build/Plan agent roles, structured questions, resource and literal command-prefix permissions. Keep the ten-tool coding default and explicit tool selections intact across cold resumes.
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.6.0**. The on-disk session format will still change; `0.x` is that
20
+ Version **0.7.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
@@ -497,9 +497,20 @@ PgUp/PgDn scroll, and Ctrl+Home/End jump to the start or latest messages.
497
497
  Ctrl+O expands tools, Ctrl+T expands thinking, and Ctrl+B toggles the sidebar.
498
498
  Mouse selection copies plain text when `fullscreenCopyOnSelect` is enabled;
499
499
  Ctrl+X copies a selection or the last reply. Exiting restores the terminal and
500
- prints the conversation and a resume command. The composer stays at the bottom
500
+ prints a bounded execution status and a resume command. Set
501
+ `fullscreenExitOutput` to `transcript` in `/settings` for full replay on exit.
502
+ The composer stays at the bottom
501
503
  and grows with the draft; pickers and forms use the same input area.
502
504
 
505
+ An empty `write_stdin` in an agent session waits for new output, command exit,
506
+ hard timeout, cancellation or user steering. The process owner holds that wait;
507
+ quiet commands do not consume model requests through periodic polling.
508
+ Sending input and low-level process polling keep their bounded yield contract.
509
+ For `--max-turns N`, budgets above one reserve the last request for a text answer.
510
+ Tools stay frozen and are disabled for that request, with host enforcement.
511
+ Unsupported protocols or a failed final response use an evidence-based stop
512
+ report inside the same budget. `/usage` labels provider durations as model time.
513
+
503
514
  `/settings theme` previews built-in and custom themes from the config directory's
504
515
  `themes/`. Escape restores the prior theme. Custom files reload after valid edits;
505
516
  `auto` follows terminal light/dark changes. Built-in themes keep the terminal's
package/docs/providers.md CHANGED
@@ -25,7 +25,7 @@ xeno 内置 Pi 0.99.1 和 OpenCode 所用 Models.dev 的固定源并集,另加
25
25
 
26
26
  账户存为 `auth.json` 的 `xai-grok`(0600),模型身份为 `xai-grok/grok-build`,请求走 `https://cli-chat-proxy.grok.com/v1`。`xai` 仍表示 `XAI_API_KEY` 与 `https://api.x.ai/v1`;两个身份不共享凭据或计费账户。也可显式提供 `GROK_ACCESS_TOKEN`,但环境 token 没有 refresh token,不能自动刷新。账户代理地址不能覆盖到其它服务。
27
27
 
28
- 采用官方 RFC 8628 设备授权(`https://auth.x.ai/oauth2/device/code` 与 `/oauth2/token`);只申请推理及刷新所需 scopes,`referrer` 与 User-Agent 标识 xeno。协议所需 `X-XAI-Token-Auth` 与模型路由头遵循官方代理合同。依据:[Grok Build 企业认证](https://docs.x.ai/build/enterprise)、[官方授权源码](https://github.com/xai-org/grok-build/tree/main/crates/codegen/xai-grok-login/src)、[官方代理调用示例](https://github.com/xai-org/grok-build/blob/main/crates/codegen/xai-grok-shell/README.md)。
28
+ 采用官方 RFC 8628 设备授权(`https://auth.x.ai/oauth2/device/code` 与 `/oauth2/token`);只申请推理及刷新所需 scopes,`referrer` 与 User-Agent 标识 xeno。协议所需 `X-XAI-Token-Auth`、模型路由头与版本闸门头 `x-grok-client-version`(固定为 1.0.46,`x-grok-client-identifier: xeno`)遵循官方代理合同;代理最低版本越过它时返回 426,xeno 会提示更新 xeno 或暂用 `xai` + `XAI_API_KEY`。依据:[Grok Build 企业认证](https://docs.x.ai/build/enterprise)、[官方授权源码](https://github.com/xai-org/grok-build/tree/main/crates/codegen/xai-grok-login/src)、[官方代理调用示例](https://github.com/xai-org/grok-build/blob/main/crates/codegen/xai-grok-shell/README.md)。
29
29
 
30
30
  `grok-build` 是官方代理别名,不能把 API 模型的价格、上下文长度或可选 effort 套给它。当前保守暴露文本/工具调用,价格未知、effort 选择关闭;实际账户权限由官方服务决定。设备授权启动已实测返回 200,授权后的推理/刷新有受控 HTTP 验证;没有替用户完成真实账户批准或调用付费模型。
31
31
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@visual-z/xeno",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
4
4
  "license": "UNLICENSED",
5
5
  "type": "module",
6
6
  "description": "Segmented append-only agent log with pure context and handoff projections",
@@ -19,7 +19,7 @@ import type { ModelSpec } from "../runtime/model.ts";
19
19
  import { discoverModels, discoveryFamily, likelyChatModel, normalizeBaseUrl, type DiscoveredModel } from "../runtime/model-discovery.ts";
20
20
  import { SOURCE_PROVIDERS } from "../runtime/model-data.ts";
21
21
  import { applyConnect, applyDisconnect, catalogTwin } from "../runtime/provider-connect.ts";
22
- import { CUSTOM_PRESETS, presetById, suggestProviderId, validProviderId, type ProviderPreset } from "../runtime/provider-presets.ts";
22
+ import { CUSTOM_PRESETS, presetById, slugProviderId, suggestProviderId, validProviderId, type ProviderPreset } from "../runtime/provider-presets.ts";
23
23
  import { providerAccess } from "../runtime/provider-access.ts";
24
24
  import { writeAuthFile } from "../store/auth-store.ts";
25
25
  import { authPath } from "../store/config.ts";
@@ -154,8 +154,8 @@ function beginPreset(host: ConnectHost, preset: ProviderPreset): void {
154
154
  title: `${preset.label} · name`,
155
155
  label: "Provider ID",
156
156
  value: suggested,
157
- hint: "shown in /model as <id>/<model>; lowercase letters, digits, - and _",
158
- validate: id => !validProviderId(id) ? "Use lowercase letters, digits, - and _ (start with a letter or digit)"
157
+ hint: "shown in /model as <id>/<model>; lowercase a-z, digits, - and _",
158
+ validate: id => !validProviderId(id) ? `Use lowercase a-z, digits, - and _ (no spaces, capitals or non-Latin letters); try "${slugProviderId(id) || suggested}"`
159
159
  : SOURCE_PROVIDERS[id] !== undefined ? `"${id}" is a built-in provider; pick another id or choose it from the list` : undefined,
160
160
  onSubmit: id => {
161
161
  draft.id = id;
@@ -15,6 +15,17 @@ export function createAppSessionHub(initial: App): SessionHub {
15
15
  // Aliases resolve to one durable log before construction: two cold resumes
16
16
  // must never append through independent Session owners (D5/D6).
17
17
  const opening = new Map<string, { result: Promise<SessionControl>; configuration: string }>();
18
+ let lookup: ReturnType<typeof listSessions> | undefined;
19
+ // Q.26: cold aliases await one scan in ingress order before claiming their
20
+ // canonical owner. Keep only in-flight I/O; construction and live reloads stay independent.
21
+ const lookupSessions = (): ReturnType<typeof listSessions> => {
22
+ if (lookup) return lookup;
23
+ const current = listSessions(initial.workspace, sessionDirFor(initial.args, initial.workspace));
24
+ lookup = current;
25
+ const release = () => { if (lookup === current) lookup = undefined; };
26
+ void current.then(release, release);
27
+ return current;
28
+ };
18
29
  return new SessionHub(sessionControl(initial.session), {
19
30
  list: async () => ({ ...await listSessions(initial.workspace, sessionDirFor(initial.args, initial.workspace)), live: [...apps.keys()] }),
20
31
  open: async (kind, params) => {
@@ -31,7 +42,7 @@ export function createAppSessionHub(initial: App): SessionHub {
31
42
  });
32
43
  return sessionControl(live.session);
33
44
  }
34
- const found = (await listSessions(initial.workspace, sessionDirFor(initial.args, initial.workspace))).sessions
45
+ const found = (await lookupSessions()).sessions
35
46
  .find(item => item.ok && (createHash("sha256").update(item.path).digest("hex") === requested || item.path === requested || item.id === requested));
36
47
  if (!live && !found) throw Error("session must belong to this app-server workspace");
37
48
  source = live?.logDir ?? found!.path;
package/src/apps/shell.ts CHANGED
@@ -739,7 +739,7 @@ function printUsage(app: App): void {
739
739
  } else {
740
740
  return assertNever(dollars, "printUsage");
741
741
  }
742
- app.io.say(` wall clock ${(t.ms / 1000).toFixed(1)}s`);
742
+ app.io.say(` model time ${(t.ms / 1000).toFixed(1)}s`);
743
743
  app.io.say("");
744
744
  }
745
745
 
@@ -29,6 +29,7 @@ import { DISPLAY_SETTINGS, settingValue, settingValues } from "../../core/settin
29
29
  import { THINKING_LEVELS, asThinkingLevel, supportedThinkingLevels } from "../../core/thinking.ts";
30
30
  import { builtinTheme } from "../../core/theme.ts";
31
31
  import { projectTranscript, type TranscriptItem } from "../../core/transcript.ts";
32
+ import { DEFAULT_FULLSCREEN_EXIT_OUTPUT, sessionExitStatus } from "../../core/session-exit.ts";
32
33
  import { lastAssistantText } from "../../core/export.ts";
33
34
  import type { PermissionRequest } from "../../core/permissions.ts";
34
35
  import type { ApprovalChoice } from "../../runtime/tools.ts";
@@ -44,7 +45,10 @@ import { isUpgrade } from "../../core/version.ts";
44
45
  import type { Effort } from "../../runtime/model.ts";
45
46
  import { readNode, readCursor, setSessionLabel } from "../../store/log.ts";
46
47
  import { readNodeHistory } from "../../store/restore.ts";
47
- import { currentBranch } from "../../store/git-branch.ts";
48
+ import { readGitStatus } from "../../store/git-status.ts";
49
+ import type { GitStatus } from "../../core/git-status.ts";
50
+ import { activityStatus } from "../../core/activity-status.ts";
51
+ import { executionOutcome, type ExecutionOutcome } from "../../core/outcome.ts";
48
52
  import { projectTrust } from "../../store/trust.ts";
49
53
  import { discoverResources, setResourceOverride } from "../../store/resource-index.ts";
50
54
  import { nextResourceOverride } from "../../core/resource-selection.ts";
@@ -107,6 +111,11 @@ function samePalette(a: TerminalPalette, b: TerminalPalette | null): boolean {
107
111
  return b !== null && a.foreground === b.foreground && a.background === b.background && a.slots.every((slot, i) => slot === b.slots[i]);
108
112
  }
109
113
 
114
+ /** Only the endpoint host is chrome; credentials, query and path stay out of it. */
115
+ function endpointHost(baseUrl: string | undefined): string | null {
116
+ try { return baseUrl ? new URL(baseUrl).host : null; } catch { return null; }
117
+ }
118
+
110
119
  export class InteractiveApp implements Ui {
111
120
  readonly app: App;
112
121
  readonly screen: Screen;
@@ -141,6 +150,11 @@ export class InteractiveApp implements Ui {
141
150
  private retryTimer: ReturnType<typeof setInterval> | undefined;
142
151
  private bashDecoder = new TextDecoder();
143
152
  private branch: string | null;
153
+ private git: GitStatus | null = null;
154
+ private gitRun: Promise<void> | undefined;
155
+ private gitAgain = false;
156
+ private attention: ControlSnapshot["waiting"] = null;
157
+ private lastOutcome: ExecutionOutcome | undefined;
144
158
  private paintTimer: ReturnType<typeof setTimeout> | undefined;
145
159
  /** Handoff-derived status (sidebar / top bar), refreshed single-flight and debounced (DoD 9). */
146
160
  private derived: ReturnType<typeof handoffStatus> | undefined;
@@ -211,7 +225,7 @@ export class InteractiveApp implements Ui {
211
225
  this.program = program;
212
226
  this.stopOsc = program ? renderer.subscribeOsc(sequence => program.receive(sequence)) : undefined;
213
227
  this.closed = new Promise(resolve => { this.closedResolve = resolve; });
214
- this.branch = currentBranch(app.workspace);
228
+ this.branch = null;
215
229
  this.sessionName = app.args.sessionName || null;
216
230
  this.thinkingExpanded = app.ui?.settings.hideThinkingBlock === false;
217
231
  this.screen = new Screen(renderer, this.colors(), () => this.keymap().byAction, {
@@ -347,9 +361,19 @@ export class InteractiveApp implements Ui {
347
361
  checkForUpdate(): void {
348
362
  // The explicit action owns its result even when it joins a startup request.
349
363
  this.manualUpdateRequested = true;
350
- void this.updateChecks.manual({ offline: this.app.args.offline === true }, text => {
351
- if (!this.disposed) this.notice(text);
352
- }).catch(() => { if (!this.disposed) this.notice("Could not check for xeno updates. Try /update again.", "error"); });
364
+ void this.updateChecks.manual({ offline: this.app.args.offline === true }, (text, tone) => {
365
+ if (!this.disposed) this.notice(text, tone);
366
+ }, activity => {
367
+ 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);
371
+ }).catch(() => {
372
+ if (!this.disposed) {
373
+ this.screen.setTransientActivity(undefined);
374
+ this.notice("Could not check for xeno updates. Try /update again.", "error");
375
+ }
376
+ });
353
377
  }
354
378
 
355
379
  private emptySession(): boolean {
@@ -417,7 +441,7 @@ export class InteractiveApp implements Ui {
417
441
  // A different session: its notices, queue and draft attachments do not carry over.
418
442
  if (this.historyDir) { this.notices = []; this.queue = []; this.imageDraft.reset(); this.screen.dialogs.clear(); }
419
443
  this.clearStream();
420
- this.branch = currentBranch(this.app.workspace);
444
+ void this.refreshGit();
421
445
  this.sessionName = (await readCursor(dir))?.name ?? this.sessionName;
422
446
  this.thinkingExpanded = this.app.ui?.settings.hideThinkingBlock === false;
423
447
  void this.refreshFileIndex();
@@ -458,7 +482,8 @@ export class InteractiveApp implements Ui {
458
482
  } else {
459
483
  this.stopRetry();
460
484
  if (progress.kind === "tool") this.live.tools.set(progress.id, { name: progress.name, arguments: progress.arguments });
461
- else this.live.output = `${this.live.output}${progress.text}`.slice(-64 * 1024);
485
+ else if (progress.kind === "output") this.live.output = `${this.live.output}${progress.text}`.slice(-64 * 1024);
486
+ else this.live.output = progress.active ? `Waiting for process ${progress.session} · Esc to interrupt` : "";
462
487
  }
463
488
  this.paint();
464
489
  }
@@ -542,9 +567,19 @@ export class InteractiveApp implements Ui {
542
567
  const t = app.session.totals, cost = reportedCost(t, spec.price);
543
568
  const tools = items.filter(item => item.kind === "tool");
544
569
  const extension = [this.extensions.status, app.credential.ok ? "" : "no provider · /provider"].filter(Boolean).join(" · ");
570
+ const pending = tools.find(tool => tool.status === "pending");
571
+ const phase = this.executionKind === "bash" || pending ? "tool" : this.live.text ? "streaming" : this.live.thinking ? "thinking" : "connecting";
572
+ const activity = activityStatus({ running: this.inflight, waiting: this.attention ?? (!app.credential.ok ? "auth" : null), queued: this.queue.length, phase,
573
+ ...(pending ? { tool: pending.name } : this.executionKind === "bash" ? { tool: "bash" } : {}),
574
+ ...(this.lastOutcome ? { outcome: this.lastOutcome } : this.controlSnapshot?.goal?.state.execution ? { outcome: this.controlSnapshot.goal.state.execution } : {}),
575
+ ...(this.controlSnapshot?.goal ? { goal: this.controlSnapshot.goal.state.status } : {}),
576
+ ...(this.live.retryUntil ? { retry: { attempt: this.live.retryAttempt, seconds: (this.live.retryUntil - Date.now()) / 1000 } } : {}) });
545
577
  return {
546
578
  version: XENO_VERSION, title: this.sessionName ?? this.prompt, workspace: displayPath(app.workspace, homedir()),
547
579
  workspaceShort: shortPath(displayPath(app.workspace, homedir())), branch: this.branch,
580
+ mode: { yolo: app.args.yolo, workflow: app.session.config.tools.workflowMode ?? "build" },
581
+ provider: { name: spec.provider, api: spec.api, host: endpointHost(spec.baseUrl), role: app.args.role?.profile.name ?? app.args.agent ?? null, ready: app.credential.ok },
582
+ activity, git: this.git,
548
583
  ...(budget ? { context: { used: budget.used, trigger: budget.trigger, estimated: budget.estimated, segment: app.session.state.node, ...(spec.contextWindow ? { window: spec.contextWindow } : {}) } } : {}),
549
584
  ...(t.requests ? { usage: { input: t.prompt_tokens, output: t.output_tokens, cacheRate: t.cache_hit_rate,
550
585
  // Under half a cent reads as $0.00, which says nothing (OpenCode #15903; Grok's status line hides it the same way).
@@ -568,6 +603,18 @@ export class InteractiveApp implements Ui {
568
603
  this.renderer.setTerminalTitle(title);
569
604
  }
570
605
 
606
+ private async refreshGit(): Promise<void> {
607
+ if (this.disposed) return;
608
+ if (this.gitRun) { this.gitAgain = true; return; }
609
+ const workspace = this.app.workspace;
610
+ this.gitRun = (async () => {
611
+ const status = await readGitStatus(workspace);
612
+ if (!this.disposed && this.app.workspace === workspace) { this.git = status; this.branch = status?.branch ?? null; this.paint(); }
613
+ })();
614
+ try { await this.gitRun; }
615
+ finally { this.gitRun = undefined; if (this.gitAgain) { this.gitAgain = false; void this.refreshGit(); } }
616
+ }
617
+
571
618
  private liveStream(): LiveStream | undefined {
572
619
  const l = this.live;
573
620
  if (!l.thinking && !l.text && !l.tools.size && !l.output && l.bash === null && !l.retryUntil) return undefined;
@@ -793,7 +840,8 @@ export class InteractiveApp implements Ui {
793
840
  const shell = command.text.startsWith("!");
794
841
  const images = shell ? [] : input.images;
795
842
  if (shell) this.imageDraft.insertImages(input.images);
796
- if (this.inflight) { this.queue.push({ kind: shell ? "followup" : kind, text: command.text, ...(images.length ? { images } : {}) }); this.paint(); return; }
843
+ if (this.inflight) { this.queue.push({ kind: shell ? "followup" : kind, text: command.text, ...(images.length ? { images } : {}) });
844
+ if (!shell && kind === "steering") this.app.session.wakeProcessWaits(); this.paint(); return; }
797
845
  this.turn = this.runPromptTurn(command.text, images, command.skill).catch(error => this.notice(describeError(error), "error"));
798
846
  this.paint();
799
847
  }
@@ -801,6 +849,7 @@ export class InteractiveApp implements Ui {
801
849
  private async runPromptTurn(text: string, images: readonly ImageRef[] = [], skill?: import("../../core/skills.ts").Skill): Promise<void> {
802
850
  this.abort = new AbortController();
803
851
  this.inflight = true;
852
+ this.lastOutcome = undefined;
804
853
  this.programResult = false;
805
854
  this.program?.update(programStatus({ running: true }));
806
855
  this.executionKind = text.startsWith("!") ? "bash" : "model";
@@ -813,16 +862,18 @@ export class InteractiveApp implements Ui {
813
862
  if (sent && !this.abort.signal.aborted) await this.drainFollowUps();
814
863
  } catch (error) {
815
864
  this.programResult = true;
865
+ this.lastOutcome = executionOutcome("execution_error");
816
866
  this.program?.update({ state: "error", msg: "Turn failed; inspect the session" });
817
867
  throw error;
818
868
  } finally {
869
+ if (this.abort?.signal.aborted && !this.lastOutcome) this.lastOutcome = executionOutcome("cancelled");
819
870
  if (!this.programResult) this.program?.update(programStatus({ running: false }));
820
871
  await this.syncTranscript();
821
872
  this.abort = null;
822
873
  this.hooks.dropFileIndex(this.app.workspace);
823
874
  this.inflight = false;
824
875
  this.executionKind = "idle";
825
- this.branch = currentBranch(this.app.workspace);
876
+ void this.refreshGit();
826
877
  this.clearStream();
827
878
  this.requestDerived();
828
879
  this.paint();
@@ -954,7 +1005,7 @@ export class InteractiveApp implements Ui {
954
1005
  this.runExtensionAction(async () => { try { await action(); } finally { this.sessionTransition = false; } });
955
1006
  }
956
1007
  deliverExtension(text: string, kind: QueueKind): void {
957
- if (this.inflight) { this.queue.push({ kind, text }); this.paint(); }
1008
+ if (this.inflight) { this.queue.push({ kind, text }); if (kind === "steering") this.app.session.wakeProcessWaits(); this.paint(); }
958
1009
  else this.turn = this.runPromptTurn(text).catch(error => this.notice(describeError(error), "error"));
959
1010
  }
960
1011
 
@@ -993,10 +1044,12 @@ export class InteractiveApp implements Ui {
993
1044
  const requested = terminal?.imageProtocol ?? "auto";
994
1045
  const resolution = this.renderer.resolution;
995
1046
  const effective = resolveImageRenderProtocol(requested, this.renderer.capabilities, resolution !== null);
996
- const show = terminal?.showImages !== false && (requested !== "auto" || effective !== "blocks");
1047
+ const show = terminal?.showImages !== false && effective !== "blocks";
1048
+ const reason = terminal?.showImages === false ? "preview off" : requested === "sixel" && resolution === null ? "waiting for terminal resolution" : "terminal graphics unavailable";
997
1049
  const cell = resolution && this.renderer.terminalWidth > 0 && this.renderer.terminalHeight > 0
998
1050
  ? { width: resolution.width / this.renderer.terminalWidth, height: resolution.height / this.renderer.terminalHeight } : DEFAULT_CELL;
999
- return { mode: show ? "picture" : "chip", protocol: requested, cell, widthCells: terminal?.imageWidthCells ?? 60, load: sha => this.images.load(sha) };
1051
+ return { mode: show ? "picture" : "chip", protocol: effective, cell, widthCells: terminal?.imageWidthCells ?? 60,
1052
+ ...(!show ? { reason } : {}), load: sha => this.images.loadResult(sha) };
1000
1053
  }
1001
1054
  private applyPictures(): void {
1002
1055
  if (this.disposed) return;
@@ -1046,18 +1099,23 @@ export class InteractiveApp implements Ui {
1046
1099
  const control = sessionControl(this.app.session);
1047
1100
  if (this.control === control) return;
1048
1101
  this.stopControl?.(); this.control = control; this.controlSnapshot = undefined;
1102
+ this.attention = null; this.lastOutcome = undefined;
1049
1103
  this.screen.setControlModel(undefined);
1050
1104
  const detachInput = control.attachInput(phase => this.pullQueued(phase));
1051
1105
  const unsubscribe = control.subscribe(notice => {
1052
1106
  if (this.disposed) return;
1053
- if (notice.method === "turn/started") this.program?.update(programStatus({ running: true }));
1107
+ if (notice.method === "turn/started") { this.lastOutcome = undefined; this.program?.update(programStatus({ running: true })); }
1054
1108
  if (notice.method === "turn/error") {
1109
+ this.lastOutcome = executionOutcome("execution_error");
1055
1110
  this.programResult = true; this.program?.update({ state: "error", msg: "Turn failed; inspect the session" });
1056
1111
  this.track(() => this.syncTranscript());
1057
1112
  }
1058
1113
  if (notice.method === "approval/request") this.program?.update(programStatus({ running: true, waiting: "permission" }));
1059
- if (notice.method === "thread/statusChanged" && "waiting" in notice.params)
1114
+ if (notice.method === "thread/statusChanged" && "waiting" in notice.params) {
1115
+ this.attention = notice.params["waiting"] as ControlSnapshot["waiting"];
1060
1116
  this.program?.update(programStatus({ running: control.busy, waiting: notice.params["waiting"] as ControlSnapshot["waiting"] }));
1117
+ }
1118
+ if (notice.method === "turn/completed") this.lastOutcome = notice.params["outcome"] as ExecutionOutcome | undefined;
1061
1119
  // A remote client drives the same Session; local turns already have the shell's stream adapter.
1062
1120
  if (!this.abort) {
1063
1121
  if (notice.method === "turn/started") { this.inflight = true; this.executionKind = "model"; }
@@ -1093,6 +1151,7 @@ export class InteractiveApp implements Ui {
1093
1151
  }
1094
1152
 
1095
1153
  observeEvent(event: import("../../runtime/loop.ts").LoopEvent): void {
1154
+ if (event.outcome) this.lastOutcome = event.outcome;
1096
1155
  if (event.kind === "done" || event.kind === "stop_and_ask") this.programResult = true;
1097
1156
  if (event.kind === "done") this.program?.update(programStatus({ running: false,
1098
1157
  ...(event.outcome ? { outcome: event.outcome } : {}), ...(event.request ? { goal: event.request.status } : {}) }));
@@ -1501,20 +1560,23 @@ export async function runInteractive(app: App, hooks: Hooks, releaseEarlyTty?: (
1501
1560
  app.io.say = previousSay;
1502
1561
  process.off("SIGTERM", terminate); process.off("SIGHUP", hangup); process.off("uncaughtException", crash); process.off("unhandledRejection", crash); process.off("exit", lastResort);
1503
1562
  }
1504
- if (!renderer && fullscreen) printExit(app);
1563
+ if (!renderer && fullscreen) await printExit(app);
1505
1564
  }
1506
1565
  }
1507
1566
 
1508
- /** After leaving the alternate screen: the conversation as plain text, then how to come back. */
1509
- function printExit(app: App): void {
1510
- const mode = app.ui?.settings.fullscreenExitOutput ?? "transcript";
1567
+ /** Restore the shell with a bounded status and resume entry; full replay is explicit. */
1568
+ async function printExit(app: App): Promise<void> {
1569
+ const history = await readNodeHistory(app.logDir, app.session.state.node);
1570
+ const events = history.ok ? history.nodes.flatMap(part => part.events) : app.session.state.events;
1571
+ const mode = app.ui?.settings.fullscreenExitOutput ?? DEFAULT_FULLSCREEN_EXIT_OUTPUT;
1511
1572
  if (mode === "transcript") {
1512
- for (const item of projectTranscript(app.session.state.events)) {
1573
+ for (const item of projectTranscript(events)) {
1513
1574
  if (item.kind === "user") process.stdout.write(`\n❯ ${item.text}\n`);
1514
1575
  else if (item.kind === "assistant") process.stdout.write(`\n${item.text}\n`);
1515
1576
  else if (item.kind === "tool") process.stdout.write(` ⏺ ${item.name}${item.result ? item.result.ok ? "" : " (failed)" : " (pending)"}\n`);
1516
1577
  }
1517
1578
  }
1579
+ process.stdout.write(`\n ${history.ok ? sessionExitStatus(events) : "session saved · history unavailable; inspect before continuing"}\n`);
1518
1580
  process.stdout.write(`\n resume with: xeno --session ${JSON.stringify(app.logDir)}\n`);
1519
1581
  }
1520
1582
 
@@ -8,20 +8,28 @@
8
8
  * its pixels, and memory is returned once the last node goes.
9
9
  *
10
10
  * Bytes come from the blob store, which checks the hash; a missing or
11
- * corrupt blob, or one that will not decode, resolves to null and the view
12
- * names the picture instead of drawing it.
11
+ * corrupt blob, or one that will not decode, has a typed failure so the view
12
+ * retains the caption and shows the actual preparation error.
13
13
  */
14
- import { NativeImage } from "@opentui/core";
14
+ import { NativeImage, imageInfo } from "@opentui/core";
15
15
  import { getBlobBytes } from "../../store/blobs.ts";
16
+ import { modelImageDimensions, MAX_IMAGE_SOURCE_BYTES, MAX_IMAGE_SOURCE_PIXELS } from "../../core/image-limits.ts";
17
+ import type { PictureLoad } from "../../tui/ui/pictures.ts";
16
18
 
17
- /** Decoded RGBA kept for reuse. 64 MiB is in line with Grok's and pi's image budgets; assumed, not measured here. */
19
+ /** Two full previews fit; mounted ImageRenderables retain their own references. */
18
20
  export const IMAGE_CACHE_BYTES = 64 * 1024 * 1024;
19
-
20
- interface Entry { readonly image: Promise<NativeImage | null>; bytes: number }
21
+ /** Bounds failed entries and queued requests as well as successful pixels. */
22
+ export const IMAGE_CACHE_ENTRIES = 128;
23
+ /** Limit simultaneous native allocations; remaining admitted loads wait in the bounded cache. */
24
+ const IMAGE_DECODE_CONCURRENCY = 2;
25
+ interface Entry { readonly result: Promise<PictureLoad>; bytes: number; settled: boolean }
21
26
 
22
27
  export class ImageCache {
23
28
  private readonly entries = new Map<string, Entry>();
24
29
  private used = 0;
30
+ private active = 0;
31
+ private closed = false;
32
+ private readonly waiting: (() => void)[] = [];
25
33
  private readonly blobDir: () => string;
26
34
  private readonly budget: number;
27
35
 
@@ -34,45 +42,80 @@ export class ImageCache {
34
42
  get size(): number { return this.used; }
35
43
  get count(): number { return this.entries.size; }
36
44
 
37
- load(sha256: string): Promise<NativeImage | null> {
45
+ async load(sha256: string): Promise<NativeImage | null> {
46
+ const result = await this.loadResult(sha256);
47
+ return result.kind === "ready" ? result.image : null;
48
+ }
49
+
50
+ loadResult(sha256: string): Promise<PictureLoad> {
51
+ if (this.closed) return Promise.resolve({ kind: "error", reason: "closed" });
38
52
  const hit = this.entries.get(sha256);
39
53
  if (hit) {
40
54
  // Most recently used goes last; eviction starts from the front.
41
55
  this.entries.delete(sha256); this.entries.set(sha256, hit);
42
- return hit.image;
56
+ return hit.result;
43
57
  }
44
- const entry: Entry = { image: this.decode(sha256), bytes: 0 };
58
+ if (this.entries.size >= IMAGE_CACHE_ENTRIES) {
59
+ const oldest = [...this.entries].find(([, entry]) => entry.settled);
60
+ if (oldest) this.drop(oldest[0], oldest[1]);
61
+ else return Promise.resolve({ kind: "error", reason: "busy" });
62
+ }
63
+ // Capture the blob owner before awaiting: session switches cannot redirect an in-flight read.
64
+ const entry: Entry = { result: this.decode(this.blobDir(), sha256), bytes: 0, settled: false };
45
65
  this.entries.set(sha256, entry);
46
- void entry.image.then(native => {
47
- if (this.entries.get(sha256) !== entry) return;
48
- entry.bytes = native ? native.width * native.height * 4 : 0;
66
+ void entry.result.then(result => {
67
+ entry.settled = true;
68
+ if (this.entries.get(sha256) !== entry) { if (result.kind === "ready") result.image.dispose(); return; }
69
+ entry.bytes = result.kind === "ready" ? result.image.width * result.image.height * 4 : 0;
49
70
  this.used += entry.bytes;
50
- this.evict(sha256);
71
+ for (const [sha, old] of this.entries) {
72
+ if (this.used <= this.budget) break;
73
+ if (sha !== sha256 && old.settled) this.drop(sha, old);
74
+ }
51
75
  });
52
- return entry.image;
76
+ return entry.result;
53
77
  }
54
78
 
55
- private async decode(sha256: string): Promise<NativeImage | null> {
79
+ private async decode(dir: string, sha: string): Promise<PictureLoad> {
80
+ if (this.active >= IMAGE_DECODE_CONCURRENCY) await new Promise<void>(resolve => this.waiting.push(resolve));
81
+ else this.active++;
82
+ if (this.closed) return { kind: "error", reason: "closed" };
83
+ let native: NativeImage | undefined;
56
84
  try {
57
- const bytes = await getBlobBytes(this.blobDir(), sha256);
58
- return bytes ? await NativeImage.load(bytes) : null;
59
- } catch { return null; }
85
+ let bytes: Uint8Array | null;
86
+ try { bytes = await getBlobBytes(dir, sha, MAX_IMAGE_SOURCE_BYTES); }
87
+ catch (error) { return { kind: "error", reason: error instanceof RangeError ? "limit" : "corrupt" }; }
88
+ if (!bytes) return { kind: "error", reason: "missing" };
89
+ if (bytes.length > MAX_IMAGE_SOURCE_BYTES) return { kind: "error", reason: "limit" };
90
+ const info = imageInfo(bytes);
91
+ if (info.width * info.height > MAX_IMAGE_SOURCE_PIXELS) return { kind: "error", reason: "limit" };
92
+ native = await NativeImage.load(bytes);
93
+ const target = modelImageDimensions(native);
94
+ if (target.width !== native.width || target.height !== native.height) {
95
+ const resized = native.resize(target); native.dispose(); native = resized;
96
+ }
97
+ if (this.closed) return { kind: "error", reason: "closed" };
98
+ if (native.width * native.height * 4 > this.budget) return { kind: "error", reason: "limit" };
99
+ const image = native; native = undefined;
100
+ return { kind: "ready", image };
101
+ } catch { return { kind: "error", reason: "decode" }; }
102
+ finally {
103
+ native?.dispose();
104
+ const next = this.waiting.shift();
105
+ if (next) next(); else this.active--;
106
+ }
60
107
  }
61
108
 
62
- /** Drop least recently used entries until the budget holds, never the one just loaded. */
63
- private evict(keep: string): void {
64
- for (const [sha, entry] of this.entries) {
65
- if (this.used <= this.budget) break;
66
- if (sha === keep) continue;
67
- this.entries.delete(sha);
68
- this.used -= entry.bytes;
69
- void entry.image.then(native => native?.dispose());
70
- }
109
+ private drop(sha: string, entry: Entry): void {
110
+ this.entries.delete(sha); this.used -= entry.bytes;
111
+ void entry.result.then(result => { if (result.kind === "ready") result.image.dispose(); });
71
112
  }
72
113
 
73
114
  dispose(): void {
74
- for (const entry of this.entries.values()) void entry.image.then(native => native?.dispose());
115
+ this.closed = true;
116
+ for (const entry of this.entries.values()) if (entry.settled) void entry.result.then(result => { if (result.kind === "ready") result.image.dispose(); });
75
117
  this.entries.clear();
76
118
  this.used = 0;
119
+ this.waiting.splice(0).forEach(resolve => resolve());
77
120
  }
78
121
  }
@@ -1,7 +1,7 @@
1
1
  /** Shared lifecycle for automatic hints and explicit checks; policy remains in core (D16). */
2
2
  import { installKind, manualUpdateResult, updateDisabled, updatePlan, type UpdateInput } from "../core/update-check.ts";
3
3
  import { packageLocation, readUpdateCache, writeUpdateCache } from "../store/update-cache.ts";
4
- import { fetchLatestVersion } from "../runtime/update-check.ts";
4
+ import { fetchLatestVersion, UPDATE_TIMEOUT_MS } from "../runtime/update-check.ts";
5
5
  import { XENO_VERSION } from "../runtime/oauth.ts";
6
6
 
7
7
  export interface UpdateOptions {
@@ -9,6 +9,12 @@ export interface UpdateOptions {
9
9
  readonly enabled?: boolean | undefined;
10
10
  }
11
11
 
12
+ export type ManualUpdateActivity =
13
+ | { readonly state: "checking"; readonly current: string; readonly timeoutMs: number }
14
+ | { readonly state: "idle" };
15
+
16
+ type ManualUpdateReporter = (text: string, tone?: "info" | "error") => void;
17
+
12
18
  export class UpdateChecks {
13
19
  private readonly controller = new AbortController();
14
20
  private pending: Promise<string | null> | undefined;
@@ -36,14 +42,14 @@ export class UpdateChecks {
36
42
  return pending;
37
43
  }
38
44
 
39
- manual(options: UpdateOptions, say: (text: string) => void): Promise<number> {
45
+ manual(options: UpdateOptions, say: ManualUpdateReporter, onActivity?: (activity: ManualUpdateActivity) => void): Promise<number> {
40
46
  if (this.manualTask) return this.manualTask;
41
- const task = this.runManual(options, say).finally(() => { if (this.manualTask === task) this.manualTask = undefined; });
47
+ const task = this.runManual(options, say, onActivity).finally(() => { if (this.manualTask === task) this.manualTask = undefined; });
42
48
  this.manualTask = task;
43
49
  return task;
44
50
  }
45
51
 
46
- private async runManual(options: UpdateOptions, say: (text: string) => void): Promise<number> {
52
+ private async runManual(options: UpdateOptions, say: ManualUpdateReporter, onActivity?: (activity: ManualUpdateActivity) => void): Promise<number> {
47
53
  const { input, plan, disabled } = await this.prepare(options, true);
48
54
  if (this.controller.signal.aborted) return 130;
49
55
  if (!plan.check) {
@@ -51,12 +57,17 @@ export class UpdateChecks {
51
57
  : `Update check skipped: ${disabled ?? "check unavailable"} (xeno ${input.current}).`);
52
58
  return input.install === "checkout" ? 0 : 1;
53
59
  }
54
- say(`Checking for xeno updates (current ${input.current})...`);
55
- const latest = await this.refresh(input);
56
- if (this.controller.signal.aborted) return 130;
57
- const result = manualUpdateResult(latest, input.current, input.install);
58
- say(result.text);
59
- return result.status === "failed" ? 1 : 0;
60
+ if (onActivity) onActivity({ state: "checking", current: input.current, timeoutMs: UPDATE_TIMEOUT_MS });
61
+ else say(`Checking for xeno updates (current ${input.current})...`);
62
+ try {
63
+ const latest = await this.refresh(input);
64
+ if (this.controller.signal.aborted) return 130;
65
+ const result = manualUpdateResult(latest, input.current, input.install);
66
+ say(result.text, result.status === "failed" ? "error" : "info");
67
+ return result.status === "failed" ? 1 : 0;
68
+ } finally {
69
+ if (onActivity) onActivity({ state: "idle" });
70
+ }
60
71
  }
61
72
 
62
73
  cancel(): void { this.controller.abort(); }
@@ -0,0 +1,28 @@
1
+ /** A finer view of OSC status, derived from the same runtime facts (D11). */
2
+ import { programStatus, type ProgramStatus } from "./program-status.ts";
3
+ import type { ExecutionOutcome } from "./outcome.ts";
4
+
5
+ export interface ActivityFacts {
6
+ readonly running: boolean;
7
+ readonly waiting?: "permission" | "question" | "auth" | null;
8
+ readonly outcome?: ExecutionOutcome;
9
+ readonly goal?: string;
10
+ readonly phase?: "connecting" | "thinking" | "streaming" | "tool";
11
+ readonly tool?: string;
12
+ readonly retry?: { readonly attempt: number; readonly seconds: number };
13
+ readonly queued: number;
14
+ }
15
+ export interface ActivityStatus { readonly state: ProgramStatus["state"]; readonly label: string; readonly detail: string; readonly queued: number }
16
+
17
+ export function activityStatus(facts: ActivityFacts): ActivityStatus {
18
+ const status = programStatus(facts), queued = Math.max(0, Math.floor(facts.queued));
19
+ const view = (label: string, detail = status.msg ?? ""): ActivityStatus => ({ state: status.state, label, detail, queued });
20
+ if (status.state === "blocked") return view(status.kind === "auth" ? "Sign in required" : status.kind === "permission" ? "Awaiting approval" : "Awaiting input");
21
+ if (status.state === "error") return view("Failed");
22
+ if (!facts.running) return view(status.state === "done" ? "Turn finished" : facts.outcome?.status === "cancelled" ? "Cancelled" : "Ready");
23
+ if (facts.retry) return view("Retrying", `attempt ${facts.retry.attempt} · ${Math.max(0, Math.ceil(facts.retry.seconds))}s`);
24
+ if (facts.phase === "tool") return view("Running tool", facts.tool ?? "Tool execution");
25
+ if (facts.phase === "thinking") return view("Thinking");
26
+ if (facts.phase === "streaming") return view("Responding");
27
+ return view("Connecting", "Waiting for the first response");
28
+ }