@visual-z/xeno 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # xeno changelog
2
2
 
3
+ ## 0.7.0 — 2026-10-09
4
+
5
+ - Keep fullscreen exit output bounded to the execution state and resume command. Preserve full transcript export as an explicit choice.
6
+ - 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.
7
+ - 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.
8
+ - Share in-flight session discovery so concurrent cold resumes resolve aliases to one execution owner.
9
+ - Show provider, approval/YOLO, Build/Plan, running/waiting/queued state, model budgets and Git status from their current owners. Label model request time explicitly.
10
+ - 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.
11
+
3
12
  ## 0.6.0 — 2026-10-09
4
13
 
5
14
  - 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.0**. 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@visual-z/xeno",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "license": "UNLICENSED",
5
5
  "type": "module",
6
6
  "description": "Segmented append-only agent log with pure context and handoff projections",
@@ -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, {
@@ -417,7 +431,7 @@ export class InteractiveApp implements Ui {
417
431
  // A different session: its notices, queue and draft attachments do not carry over.
418
432
  if (this.historyDir) { this.notices = []; this.queue = []; this.imageDraft.reset(); this.screen.dialogs.clear(); }
419
433
  this.clearStream();
420
- this.branch = currentBranch(this.app.workspace);
434
+ void this.refreshGit();
421
435
  this.sessionName = (await readCursor(dir))?.name ?? this.sessionName;
422
436
  this.thinkingExpanded = this.app.ui?.settings.hideThinkingBlock === false;
423
437
  void this.refreshFileIndex();
@@ -458,7 +472,8 @@ export class InteractiveApp implements Ui {
458
472
  } else {
459
473
  this.stopRetry();
460
474
  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);
475
+ else if (progress.kind === "output") this.live.output = `${this.live.output}${progress.text}`.slice(-64 * 1024);
476
+ else this.live.output = progress.active ? `Waiting for process ${progress.session} · Esc to interrupt` : "";
462
477
  }
463
478
  this.paint();
464
479
  }
@@ -542,9 +557,19 @@ export class InteractiveApp implements Ui {
542
557
  const t = app.session.totals, cost = reportedCost(t, spec.price);
543
558
  const tools = items.filter(item => item.kind === "tool");
544
559
  const extension = [this.extensions.status, app.credential.ok ? "" : "no provider · /provider"].filter(Boolean).join(" · ");
560
+ const pending = tools.find(tool => tool.status === "pending");
561
+ const phase = this.executionKind === "bash" || pending ? "tool" : this.live.text ? "streaming" : this.live.thinking ? "thinking" : "connecting";
562
+ const activity = activityStatus({ running: this.inflight, waiting: this.attention ?? (!app.credential.ok ? "auth" : null), queued: this.queue.length, phase,
563
+ ...(pending ? { tool: pending.name } : this.executionKind === "bash" ? { tool: "bash" } : {}),
564
+ ...(this.lastOutcome ? { outcome: this.lastOutcome } : this.controlSnapshot?.goal?.state.execution ? { outcome: this.controlSnapshot.goal.state.execution } : {}),
565
+ ...(this.controlSnapshot?.goal ? { goal: this.controlSnapshot.goal.state.status } : {}),
566
+ ...(this.live.retryUntil ? { retry: { attempt: this.live.retryAttempt, seconds: (this.live.retryUntil - Date.now()) / 1000 } } : {}) });
545
567
  return {
546
568
  version: XENO_VERSION, title: this.sessionName ?? this.prompt, workspace: displayPath(app.workspace, homedir()),
547
569
  workspaceShort: shortPath(displayPath(app.workspace, homedir())), branch: this.branch,
570
+ mode: { yolo: app.args.yolo, workflow: app.session.config.tools.workflowMode ?? "build" },
571
+ 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 },
572
+ activity, git: this.git,
548
573
  ...(budget ? { context: { used: budget.used, trigger: budget.trigger, estimated: budget.estimated, segment: app.session.state.node, ...(spec.contextWindow ? { window: spec.contextWindow } : {}) } } : {}),
549
574
  ...(t.requests ? { usage: { input: t.prompt_tokens, output: t.output_tokens, cacheRate: t.cache_hit_rate,
550
575
  // Under half a cent reads as $0.00, which says nothing (OpenCode #15903; Grok's status line hides it the same way).
@@ -568,6 +593,18 @@ export class InteractiveApp implements Ui {
568
593
  this.renderer.setTerminalTitle(title);
569
594
  }
570
595
 
596
+ private async refreshGit(): Promise<void> {
597
+ if (this.disposed) return;
598
+ if (this.gitRun) { this.gitAgain = true; return; }
599
+ const workspace = this.app.workspace;
600
+ this.gitRun = (async () => {
601
+ const status = await readGitStatus(workspace);
602
+ if (!this.disposed && this.app.workspace === workspace) { this.git = status; this.branch = status?.branch ?? null; this.paint(); }
603
+ })();
604
+ try { await this.gitRun; }
605
+ finally { this.gitRun = undefined; if (this.gitAgain) { this.gitAgain = false; void this.refreshGit(); } }
606
+ }
607
+
571
608
  private liveStream(): LiveStream | undefined {
572
609
  const l = this.live;
573
610
  if (!l.thinking && !l.text && !l.tools.size && !l.output && l.bash === null && !l.retryUntil) return undefined;
@@ -793,7 +830,8 @@ export class InteractiveApp implements Ui {
793
830
  const shell = command.text.startsWith("!");
794
831
  const images = shell ? [] : input.images;
795
832
  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; }
833
+ if (this.inflight) { this.queue.push({ kind: shell ? "followup" : kind, text: command.text, ...(images.length ? { images } : {}) });
834
+ if (!shell && kind === "steering") this.app.session.wakeProcessWaits(); this.paint(); return; }
797
835
  this.turn = this.runPromptTurn(command.text, images, command.skill).catch(error => this.notice(describeError(error), "error"));
798
836
  this.paint();
799
837
  }
@@ -801,6 +839,7 @@ export class InteractiveApp implements Ui {
801
839
  private async runPromptTurn(text: string, images: readonly ImageRef[] = [], skill?: import("../../core/skills.ts").Skill): Promise<void> {
802
840
  this.abort = new AbortController();
803
841
  this.inflight = true;
842
+ this.lastOutcome = undefined;
804
843
  this.programResult = false;
805
844
  this.program?.update(programStatus({ running: true }));
806
845
  this.executionKind = text.startsWith("!") ? "bash" : "model";
@@ -813,16 +852,18 @@ export class InteractiveApp implements Ui {
813
852
  if (sent && !this.abort.signal.aborted) await this.drainFollowUps();
814
853
  } catch (error) {
815
854
  this.programResult = true;
855
+ this.lastOutcome = executionOutcome("execution_error");
816
856
  this.program?.update({ state: "error", msg: "Turn failed; inspect the session" });
817
857
  throw error;
818
858
  } finally {
859
+ if (this.abort?.signal.aborted && !this.lastOutcome) this.lastOutcome = executionOutcome("cancelled");
819
860
  if (!this.programResult) this.program?.update(programStatus({ running: false }));
820
861
  await this.syncTranscript();
821
862
  this.abort = null;
822
863
  this.hooks.dropFileIndex(this.app.workspace);
823
864
  this.inflight = false;
824
865
  this.executionKind = "idle";
825
- this.branch = currentBranch(this.app.workspace);
866
+ void this.refreshGit();
826
867
  this.clearStream();
827
868
  this.requestDerived();
828
869
  this.paint();
@@ -954,7 +995,7 @@ export class InteractiveApp implements Ui {
954
995
  this.runExtensionAction(async () => { try { await action(); } finally { this.sessionTransition = false; } });
955
996
  }
956
997
  deliverExtension(text: string, kind: QueueKind): void {
957
- if (this.inflight) { this.queue.push({ kind, text }); this.paint(); }
998
+ if (this.inflight) { this.queue.push({ kind, text }); if (kind === "steering") this.app.session.wakeProcessWaits(); this.paint(); }
958
999
  else this.turn = this.runPromptTurn(text).catch(error => this.notice(describeError(error), "error"));
959
1000
  }
960
1001
 
@@ -993,10 +1034,12 @@ export class InteractiveApp implements Ui {
993
1034
  const requested = terminal?.imageProtocol ?? "auto";
994
1035
  const resolution = this.renderer.resolution;
995
1036
  const effective = resolveImageRenderProtocol(requested, this.renderer.capabilities, resolution !== null);
996
- const show = terminal?.showImages !== false && (requested !== "auto" || effective !== "blocks");
1037
+ const show = terminal?.showImages !== false && effective !== "blocks";
1038
+ const reason = terminal?.showImages === false ? "preview off" : requested === "sixel" && resolution === null ? "waiting for terminal resolution" : "terminal graphics unavailable";
997
1039
  const cell = resolution && this.renderer.terminalWidth > 0 && this.renderer.terminalHeight > 0
998
1040
  ? { 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) };
1041
+ return { mode: show ? "picture" : "chip", protocol: effective, cell, widthCells: terminal?.imageWidthCells ?? 60,
1042
+ ...(!show ? { reason } : {}), load: sha => this.images.loadResult(sha) };
1000
1043
  }
1001
1044
  private applyPictures(): void {
1002
1045
  if (this.disposed) return;
@@ -1046,18 +1089,23 @@ export class InteractiveApp implements Ui {
1046
1089
  const control = sessionControl(this.app.session);
1047
1090
  if (this.control === control) return;
1048
1091
  this.stopControl?.(); this.control = control; this.controlSnapshot = undefined;
1092
+ this.attention = null; this.lastOutcome = undefined;
1049
1093
  this.screen.setControlModel(undefined);
1050
1094
  const detachInput = control.attachInput(phase => this.pullQueued(phase));
1051
1095
  const unsubscribe = control.subscribe(notice => {
1052
1096
  if (this.disposed) return;
1053
- if (notice.method === "turn/started") this.program?.update(programStatus({ running: true }));
1097
+ if (notice.method === "turn/started") { this.lastOutcome = undefined; this.program?.update(programStatus({ running: true })); }
1054
1098
  if (notice.method === "turn/error") {
1099
+ this.lastOutcome = executionOutcome("execution_error");
1055
1100
  this.programResult = true; this.program?.update({ state: "error", msg: "Turn failed; inspect the session" });
1056
1101
  this.track(() => this.syncTranscript());
1057
1102
  }
1058
1103
  if (notice.method === "approval/request") this.program?.update(programStatus({ running: true, waiting: "permission" }));
1059
- if (notice.method === "thread/statusChanged" && "waiting" in notice.params)
1104
+ if (notice.method === "thread/statusChanged" && "waiting" in notice.params) {
1105
+ this.attention = notice.params["waiting"] as ControlSnapshot["waiting"];
1060
1106
  this.program?.update(programStatus({ running: control.busy, waiting: notice.params["waiting"] as ControlSnapshot["waiting"] }));
1107
+ }
1108
+ if (notice.method === "turn/completed") this.lastOutcome = notice.params["outcome"] as ExecutionOutcome | undefined;
1061
1109
  // A remote client drives the same Session; local turns already have the shell's stream adapter.
1062
1110
  if (!this.abort) {
1063
1111
  if (notice.method === "turn/started") { this.inflight = true; this.executionKind = "model"; }
@@ -1093,6 +1141,7 @@ export class InteractiveApp implements Ui {
1093
1141
  }
1094
1142
 
1095
1143
  observeEvent(event: import("../../runtime/loop.ts").LoopEvent): void {
1144
+ if (event.outcome) this.lastOutcome = event.outcome;
1096
1145
  if (event.kind === "done" || event.kind === "stop_and_ask") this.programResult = true;
1097
1146
  if (event.kind === "done") this.program?.update(programStatus({ running: false,
1098
1147
  ...(event.outcome ? { outcome: event.outcome } : {}), ...(event.request ? { goal: event.request.status } : {}) }));
@@ -1501,20 +1550,23 @@ export async function runInteractive(app: App, hooks: Hooks, releaseEarlyTty?: (
1501
1550
  app.io.say = previousSay;
1502
1551
  process.off("SIGTERM", terminate); process.off("SIGHUP", hangup); process.off("uncaughtException", crash); process.off("unhandledRejection", crash); process.off("exit", lastResort);
1503
1552
  }
1504
- if (!renderer && fullscreen) printExit(app);
1553
+ if (!renderer && fullscreen) await printExit(app);
1505
1554
  }
1506
1555
  }
1507
1556
 
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";
1557
+ /** Restore the shell with a bounded status and resume entry; full replay is explicit. */
1558
+ async function printExit(app: App): Promise<void> {
1559
+ const history = await readNodeHistory(app.logDir, app.session.state.node);
1560
+ const events = history.ok ? history.nodes.flatMap(part => part.events) : app.session.state.events;
1561
+ const mode = app.ui?.settings.fullscreenExitOutput ?? DEFAULT_FULLSCREEN_EXIT_OUTPUT;
1511
1562
  if (mode === "transcript") {
1512
- for (const item of projectTranscript(app.session.state.events)) {
1563
+ for (const item of projectTranscript(events)) {
1513
1564
  if (item.kind === "user") process.stdout.write(`\n❯ ${item.text}\n`);
1514
1565
  else if (item.kind === "assistant") process.stdout.write(`\n${item.text}\n`);
1515
1566
  else if (item.kind === "tool") process.stdout.write(` ⏺ ${item.name}${item.result ? item.result.ok ? "" : " (failed)" : " (pending)"}\n`);
1516
1567
  }
1517
1568
  }
1569
+ process.stdout.write(`\n ${history.ok ? sessionExitStatus(events) : "session saved · history unavailable; inspect before continuing"}\n`);
1518
1570
  process.stdout.write(`\n resume with: xeno --session ${JSON.stringify(app.logDir)}\n`);
1519
1571
  }
1520
1572
 
@@ -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
  }
@@ -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
+ }
@@ -0,0 +1,29 @@
1
+ /** Counts from Git porcelain v2, distinct from agent tool effects. No path decoding needed. */
2
+ export interface GitStatus {
3
+ readonly branch: string | null;
4
+ readonly staged: number;
5
+ readonly changed: number;
6
+ readonly untracked: number;
7
+ readonly conflicted: number;
8
+ readonly ahead: number;
9
+ readonly behind: number;
10
+ }
11
+ export function parseGitStatus(output: string): GitStatus {
12
+ let branch: string | null = null, staged = 0, changed = 0, untracked = 0, conflicted = 0, ahead = 0, behind = 0;
13
+ const records = output.split("\0");
14
+ for (let index = 0; index < records.length; index++) {
15
+ const line = records[index]!;
16
+ if (line.startsWith("# branch.head ")) branch = line.slice(14).trim();
17
+ else if (line.startsWith("# branch.ab ")) {
18
+ const match = /^# branch.ab \+(\d+) -(\d+)$/.exec(line.trim());
19
+ if (match) { ahead = Number(match[1]); behind = Number(match[2]); }
20
+ } else if (line.startsWith("? ")) untracked++;
21
+ else if (/^[12u] [A-Z?.]{2} /.test(line)) {
22
+ if (line[2] !== ".") staged++;
23
+ if (line[3] !== ".") changed++;
24
+ if (line[0] === "u") conflicted++;
25
+ if (line[0] === "2") index++; // Rename's next NUL field is a path, never a status record.
26
+ }
27
+ }
28
+ return { branch, staged, changed, untracked, conflicted, ahead, behind };
29
+ }
@@ -0,0 +1,72 @@
1
+ /** Admission, model detail and preview memory are separate budgets (D8). */
2
+ export const MAX_IMAGE_SOURCE_BYTES = 64 * 1024 * 1024;
3
+ /** Matches the native decoder's area cap; inspect headers before WASM allocates. */
4
+ export const MAX_IMAGE_SOURCE_PIXELS = 25_000_000;
5
+ /** More readable screenshots than 2000px, with at most 32 MiB RGBA per preview. */
6
+ export const MAX_MODEL_IMAGE_SIDE = 4096;
7
+ export const MAX_MODEL_IMAGE_PIXELS = 8 * 1024 * 1024;
8
+ export interface ImageDimensions { readonly width: number; readonly height: number }
9
+
10
+ /** Header-only probe. Decoders still validate the complete image afterward. */
11
+ export function imageDimensions(bytes: Uint8Array, mime: string): ImageDimensions | undefined {
12
+ const v = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
13
+ const result = (width: number, height: number): ImageDimensions | undefined =>
14
+ Number.isSafeInteger(width) && Number.isSafeInteger(height) && width > 0 && height > 0 ? { width, height } : undefined;
15
+ if (mime === "image/png" && bytes.length >= 24) return result(v.getUint32(16), v.getUint32(20));
16
+ if (mime === "image/gif" && bytes.length >= 10) return result(v.getUint16(6, true), v.getUint16(8, true));
17
+ if (mime === "image/bmp" && bytes.length >= 26) {
18
+ const header = v.getUint32(14, true);
19
+ if (header === 12) return result(v.getUint16(18, true), v.getUint16(20, true));
20
+ if (header >= 40) return result(v.getInt32(18, true), Math.abs(v.getInt32(22, true)));
21
+ return undefined;
22
+ }
23
+ if (mime === "image/jpeg") {
24
+ let offset = 2;
25
+ while (offset + 3 < bytes.length) {
26
+ if (bytes[offset++] !== 255) return undefined;
27
+ while (bytes[offset] === 255) offset++;
28
+ const marker = bytes[offset++];
29
+ if (marker === undefined || marker === 0xda || marker === 0xd9) return undefined;
30
+ if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd8)) continue;
31
+ if (offset + 2 > bytes.length) return undefined;
32
+ const size = v.getUint16(offset);
33
+ if (size < 2 || offset + size > bytes.length) return undefined;
34
+ if (marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker)) {
35
+ return size >= 8 ? result(v.getUint16(offset + 5), v.getUint16(offset + 3)) : undefined;
36
+ }
37
+ offset += size;
38
+ }
39
+ }
40
+ if (mime === "image/webp") {
41
+ for (let offset = 12; offset + 8 <= bytes.length;) {
42
+ const size = v.getUint32(offset + 4, true), start = offset + 8;
43
+ if (start + size > bytes.length) return undefined;
44
+ const kind = String.fromCharCode(...bytes.subarray(offset, offset + 4));
45
+ const u24 = (at: number) => v.getUint8(at) | v.getUint8(at + 1) << 8 | v.getUint8(at + 2) << 16;
46
+ if (kind === "VP8X" && size >= 10) return result(u24(start + 4) + 1, u24(start + 7) + 1);
47
+ if (kind === "VP8L" && size >= 5 && bytes[start] === 0x2f) {
48
+ const packed = v.getUint32(start + 1, true);
49
+ return result((packed & 0x3fff) + 1, ((packed >>> 14) & 0x3fff) + 1);
50
+ }
51
+ if (kind === "VP8 " && size >= 10 && bytes[start + 3] === 0x9d && bytes[start + 4] === 1 && bytes[start + 5] === 0x2a)
52
+ return result(v.getUint16(start + 6, true) & 0x3fff, v.getUint16(start + 8, true) & 0x3fff);
53
+ offset = start + size + size % 2;
54
+ }
55
+ }
56
+ return undefined;
57
+ }
58
+
59
+ /** Floors keep the area bound exact; small inputs are never upscaled. */
60
+ export function modelImageDimensions(image: ImageDimensions): ImageDimensions {
61
+ const scale = Math.min(1, MAX_MODEL_IMAGE_SIDE / image.width, MAX_MODEL_IMAGE_SIDE / image.height,
62
+ Math.sqrt(MAX_MODEL_IMAGE_PIXELS / image.width / image.height));
63
+ return { width: Math.max(1, Math.floor(image.width * scale)), height: Math.max(1, Math.floor(image.height * scale)) };
64
+ }
65
+
66
+ /** Pi rotates EXIF before resizing and rounds the short side; symmetric bounds preserve both orientations. */
67
+ export function modelImageSide(image: ImageDimensions): number {
68
+ const long = Math.max(image.width, image.height), short = Math.min(image.width, image.height);
69
+ let side = Math.max(...Object.values(modelImageDimensions(image)));
70
+ while (side * Math.max(1, Math.round(short * side / long)) > MAX_MODEL_IMAGE_PIXELS) side--;
71
+ return side;
72
+ }
@@ -5,6 +5,7 @@
5
5
  * its carried handoff view may be paged like the other structural state (D20).
6
6
  */
7
7
  import type { LineRange, LogEvent } from "./events.ts";
8
+ import { isProcessReceipt } from "./process-receipt.ts";
8
9
 
9
10
  export type ProgressEvidence = (
10
11
  | { kind: "read"; sha256: string; ranges?: readonly LineRange[]; observation_sha256?: string }
@@ -123,6 +124,14 @@ export function deriveProgress(
123
124
  evidence.push({ kind: "observation", sha256: key, source });
124
125
  if (isAfter) progressed = true;
125
126
  }
127
+ } else if (isProcessReceipt(event.data) && !event.data.replayed && event.data.output !== undefined && event.data.output.bytes > 0 &&
128
+ !observations.has(event.data.output.sha256)) {
129
+ // A managed command can produce useful evidence before it exits. Credit
130
+ // raw captured bytes, never its elapsed header, changing ID or poll count.
131
+ const sha256 = event.data.output.sha256;
132
+ observations.add(sha256);
133
+ evidence.push({ kind: "observation", sha256, source });
134
+ progressed = true;
126
135
  } else if ((name === "grep" || name === "find" || name === "ls" || name === "query") &&
127
136
  observation !== undefined && (event.content_ref?.bytes ?? event.content.length) > 0 &&
128
137
  !observations.has(observation)) {
@@ -0,0 +1,13 @@
1
+ /** D6 / Q.26: finalization spends the existing budget and cannot admit new work. */
2
+ export function requestPhase(index: number, limit: number): "work" | "finalize" | "exhausted" {
3
+ if (!Number.isSafeInteger(limit) || limit < 1 || !Number.isSafeInteger(index) || index < 0)
4
+ throw new RangeError("request budget requires positive integer limit and nonnegative index");
5
+ if (index >= limit) return "exhausted";
6
+ // One request cannot both investigate and close. Preserve that work opportunity;
7
+ // its deterministic outcome is the exit, without a second paid request.
8
+ return limit > 1 && index === limit - 1 ? "finalize" : "work";
9
+ }
10
+
11
+ export function finalizationNotice(): string {
12
+ return "Harness: this is the final request in the existing budget. Tools are disabled by the host. Give the user a concise answer from the observations already recorded: what is established, what remains unresolved, and the next useful action. Do not claim unobserved execution or verified completion.";
13
+ }