@visual-z/xeno 0.8.1 → 0.8.3

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 (69) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/docs/install.md +25 -0
  3. package/package.json +1 -1
  4. package/src/apps/cli.ts +4 -1
  5. package/src/apps/shell.ts +13 -3
  6. package/src/apps/startup.ts +31 -6
  7. package/src/apps/ui/app.ts +32 -14
  8. package/src/apps/ui/project.ts +1 -1
  9. package/src/apps/ui/standalone.ts +4 -2
  10. package/src/apps/ui/surface.ts +2 -0
  11. package/src/core/activity-status.ts +27 -3
  12. package/src/core/command-contract.ts +121 -5
  13. package/src/core/completion-review.ts +1 -1
  14. package/src/core/derive.ts +9 -1
  15. package/src/core/environment.ts +7 -0
  16. package/src/core/event-validation.ts +19 -3
  17. package/src/core/events.ts +38 -2
  18. package/src/core/evidence.ts +149 -7
  19. package/src/core/export.ts +2 -0
  20. package/src/core/external-effects.ts +488 -0
  21. package/src/core/handoff.ts +24 -3
  22. package/src/core/history-query.ts +1 -1
  23. package/src/core/history.ts +131 -21
  24. package/src/core/observation-view.ts +24 -0
  25. package/src/core/path-suggestions.ts +59 -0
  26. package/src/core/permissions.ts +64 -3
  27. package/src/core/prompt-policy.ts +11 -2
  28. package/src/core/regex-dialect.ts +25 -0
  29. package/src/core/ripgrep-release.ts +64 -0
  30. package/src/core/secrets.ts +188 -0
  31. package/src/core/stall.ts +63 -18
  32. package/src/core/stop-report.ts +46 -4
  33. package/src/core/tool-contract.ts +2 -0
  34. package/src/core/tool-failure.ts +25 -4
  35. package/src/core/transcript.ts +3 -0
  36. package/src/core/update-check.ts +2 -1
  37. package/src/core/work-state.ts +7 -2
  38. package/src/core/working-set.ts +34 -6
  39. package/src/runtime/connection-reuse.ts +40 -0
  40. package/src/runtime/exec-sessions.ts +69 -19
  41. package/src/runtime/history-tools.ts +12 -2
  42. package/src/runtime/loop.ts +196 -26
  43. package/src/runtime/open-image.ts +42 -0
  44. package/src/runtime/openai-responses.ts +15 -7
  45. package/src/runtime/openai.ts +15 -7
  46. package/src/runtime/permission-resources.ts +11 -4
  47. package/src/runtime/pi-provider.ts +34 -9
  48. package/src/runtime/provider.ts +19 -8
  49. package/src/runtime/retry.ts +152 -9
  50. package/src/runtime/secrets.ts +79 -0
  51. package/src/runtime/session-control.ts +9 -1
  52. package/src/runtime/stream-idle.ts +80 -0
  53. package/src/runtime/tools.ts +139 -31
  54. package/src/runtime/turn-effects.ts +31 -0
  55. package/src/store/command.ts +3 -1
  56. package/src/store/context-files.ts +5 -1
  57. package/src/store/file-checkpoints.ts +6 -3
  58. package/src/store/log.ts +20 -0
  59. package/src/store/replay.ts +4 -3
  60. package/src/store/search.ts +9 -5
  61. package/src/store/secrets.ts +44 -0
  62. package/src/store/sessions.ts +49 -6
  63. package/src/store/tool-binaries.ts +90 -0
  64. package/src/store/worktree.ts +55 -10
  65. package/src/tui/ui/image-size.ts +6 -2
  66. package/src/tui/ui/items.ts +3 -1
  67. package/src/tui/ui/pictures.ts +14 -1
  68. package/src/tui/ui/screen.ts +6 -3
  69. package/src/tui/ui/status.ts +16 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,71 @@
1
1
  # xeno changelog
2
2
 
3
+ ## 0.8.3 — 2026-10-12
4
+
5
+ Credentials stay out of the log and out of the model's context:
6
+ - A key, token or password in your message or in a tool's output is replaced by a placeholder such as `[[xeno-secret-1]]` before anything is written. Only high-confidence shapes are recognised (provider key prefixes such as `sk-`, `ghp_`, `tskey-`, `AKIA`; JWTs; private keys; `password=` / `token:` / `密码是` followed by a value); there is no entropy guessing.
7
+ - A short reply sent right after the assistant asked for a password or token is treated as that credential.
8
+ - The model can still use it: a placeholder in a command or file is turned back into the real value when the tool runs, and hidden again if the output repeats it. Values live in the session's `secrets.jsonl` (owner-only), never in an event, a handoff or an export.
9
+ - Credentials already in older logs are not rewritten; rotate any you pasted before this release.
10
+
11
+ Knowing what happened:
12
+ - A failed request records why: `HTTP 503 after 3 retries`, `connection lost`, `stream stalled`. Reopening the session still shows it. The provider's error text is still never written. An error inside xeno itself is recorded as an execution error, not a provider error.
13
+ - Each approval is logged: who allowed the call (you, an earlier choice, a configured rule, `--yolo`) and which rule matched.
14
+ - When a request ends, xeno lists what it did outside the workspace: writes, `kill`, system settings, `brew`/`npm -g`, non-GET `curl`, `git push`. Nothing is blocked; the list is there so a blanket approval is not silent.
15
+ - Session logs record the xeno version that wrote them.
16
+
17
+ Network:
18
+ - Dropped connections are retried up to ten times, backing off to 32s, and the retry line says `retry 3/10 · connection lost`. A socket Bun closed or a host it could not reach is now retried; certificate failures are not.
19
+ - After a connection dies, later requests open fresh connections instead of reusing a pooled dead socket.
20
+ - A stream that sends nothing for 300s is abandoned and sent again, at most twice, instead of waiting for Escape.
21
+
22
+ Verification and continuation:
23
+ - A check run as `build && test`, with its output redirected to a file, or as `make build test lint` (also `just` and `task`) counts as verification. The model is no longer asked to run it again on its own.
24
+ - A stop report lists a claim that was recorded but not verified as unverified, not as a failed tool, and no longer reads an uncapped request count as a limit that was hit.
25
+ - Switching models before anything has happened no longer starts a new segment, so the model does not open from an empty handoff and go looking for work.
26
+ - A next step written in an earlier segment no longer counts as a current plan; the boundary reminder asks the model to confirm or update it.
27
+ - New segments carry every one of your messages before other history, name the screenshots attached to them (`history(event=…)` brings an image back), and keep one copy of each next step.
28
+
29
+ Tools:
30
+ - `write_stdin` with Ctrl-C or Ctrl-D to a process that already exited returns its exit and unread output instead of an error.
31
+ - An empty `write_stdin` wait collects output until its window ends instead of waking for every line, so a chatty process no longer costs one request per line.
32
+ - When `read` or `edit` cannot find a path, the error names up to three closest existing paths.
33
+ - Paths outside the workspace skip instruction discovery, so the answer is the path rule's, not "instruction target escapes workspace".
34
+ - Refusing to write under `.git` or `.xeno` says it is not a permission problem and no approval can allow it.
35
+ - Exit 141 from `… | head` is read as the reader closing early, not a failure. Progress bars drawn with carriage returns reach the model as the line a terminal shows.
36
+
37
+ Interface:
38
+ - Pictures in the transcript may take half the screen (up to twenty rows); clicking one opens the original image.
39
+ - `-c`, the resume pickers and `/sessions` skip sessions where you never sent a message.
40
+ - A forked session shows `forked · HH:MM` and is timed from the fork, not from its parent's first message.
41
+
42
+ Fixes:
43
+ - Reasoning a gateway sent without a signature is replayed as reasoning, so Gemini through such a gateway stops copying its thinking into replies.
44
+
45
+ ## 0.8.2 — 2026-10-11
46
+
47
+ The stall watchdog no longer stops work that is merely long:
48
+ - A run is treated as stuck only when it repeats the same tool call, or the same cycle of up to five calls, three times without learning anything new. Re-reading files, rerunning a test after an edit, a failing test whose output changes, and waiting on a running process never count.
49
+ - The first time, the model is told to change its method. If the cycle comes back, an interactive session asks you: Continue, Stop, or type instructions for the agent. Without anyone to ask (headless), the request still ends as stalled.
50
+ - The old request-wide allowance of twelve exchanges without new evidence is gone. It stopped a long, finished task right after its final answer.
51
+
52
+ The input box after a turn:
53
+ - A finished turn reads `✓ Turn finished · 32m 34s` in the success colour, with how long the turn ran. The time stays until the next turn starts.
54
+ - A turn the harness stopped reads `■ Stopped · no progress` (or request limit, context budget, output limit, time limit) in the warning colour, instead of `Awaiting input`. `Awaiting input` now means a question is waiting for you. A failed turn reads `✗ Failed` in the error colour.
55
+ - A stop report names the tools that failed (`Failed tools: exec_command ×2, edit ×1`) instead of listing receipt ids, and says when the model had already given a final reply before the stop.
56
+
57
+ Tools:
58
+ - Where `rg` is missing, interactive sessions install a pinned ripgrep (14.1.1, checked against its SHA-256) into `~/.xeno/bin` in the background and add it to the end of the command PATH. Nothing is downloaded with `--offline`, in CI or from a source checkout. The environment tells the model whether `rg` is available.
59
+ - A new `worktree` tool lets the model create an isolated Git worktree under `~/.xeno/worktrees`, on a branch `xeno/<name>`, and move the session into it; leaving returns to the main checkout and keeps the worktree and branch. Resumed sessions add the tool after their existing tools.
60
+ - `exec_command` and `write_stdin` accept Codex's `max_output_tokens`. It lowers the inline preview only, and near spellings such as `max_output_chars` are pointed at the right name.
61
+ - `grep`, `rg`, `diff`, `cmp` and `test` exiting 1 as the last command is read as their answer (no match, files differ), not a failure. The receipt keeps the real exit code.
62
+ - A leading `(?i)` in a grep pattern means the same with or without ripgrep.
63
+
64
+ Fixes:
65
+ - A new segment no longer sends the model to `history` first when every user message is already in its handoff. Before, the first tool call of almost every session was that read.
66
+ - The completion check says that a verification command counts only when run on its own, without redirects, pipes, `$()`, `;` or `&&`. The read it asks for no longer counts toward the stall watchdog.
67
+ - Refusing an edit under `.git` or `.xeno` now says it is a fixed rule, instead of asking for a policy change that no setting can make.
68
+
3
69
  ## 0.8.1 — 2026-10-11
4
70
 
5
71
  The sidebar now follows your goal:
package/docs/install.md CHANGED
@@ -144,6 +144,31 @@ check described above is a separate action):
144
144
  - the run is not the interactive app: `-p`, `--mode json`, `--export`, `--list-models`, `models --refresh`, package and `config` commands, `--version`
145
145
  - xeno runs from a checkout: its package root is not under `node_modules`, or has a `.git` (`bun src/apps/cli.ts`, `bun run agent`, `bun link`)
146
146
 
147
+ ## ripgrep
148
+
149
+ Agents reach for `rg` in shell commands, and xeno's grep tool is faster with
150
+ it. When neither `PATH` nor `~/.xeno/bin` has one, the interactive app
151
+ installs ripgrep 14.1.1 into `~/.xeno/bin/rg` in the background:
152
+
153
+ - **Only that build.** The archive comes from the ripgrep GitHub release and
154
+ must match a sha256 pinned in xeno (`src/core/ripgrep-release.ts`).
155
+ Anything else is discarded before extraction; only the `rg` executable is
156
+ unpacked.
157
+ - **Yours wins.** `~/.xeno/bin` is appended to the end of the PATH of tool
158
+ commands, so an `rg` you install yourself is used instead.
159
+ - **What the model is told.** The session's environment section says whether
160
+ `rg` exists in the shell at session start. A copy that finishes
161
+ downloading during the first session appears in the next one's line, but
162
+ shell commands can use it immediately.
163
+ - **When not.** The same conditions as background update checks, except the
164
+ update settings: `--offline` / `XENO_OFFLINE`, `CI` /
165
+ `CONTINUOUS_INTEGRATION`, a non-interactive run, or running from a
166
+ checkout. `XENO_NO_TOOL_INSTALL` turns off only this download. Quitting
167
+ cancels it.
168
+
169
+ Platforms: macOS arm64 and x64, Linux x64 (static musl) and arm64 (glibc).
170
+ Elsewhere grep uses its built-in TypeScript search.
171
+
147
172
  ## Credentials
148
173
 
149
174
  Resolution order, first match wins:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@visual-z/xeno",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "license": "UNLICENSED",
5
5
  "type": "module",
6
6
  "description": "Segmented append-only agent log with pure context and handoff projections",
package/src/apps/cli.ts CHANGED
@@ -13,7 +13,7 @@
13
13
  */
14
14
 
15
15
  import {
16
- parseArgs, startup, readPipedStdin, mergePrintPrompt, isInteractiveSession, printCliHelp,
16
+ parseArgs, startup, readPipedStdin, mergePrintPrompt, isInteractiveSession, printCliHelp, provisionToolsInBackground,
17
17
  } from "./startup.ts";
18
18
  import { createStartupInput } from "./startup-input.ts";
19
19
  import { runPrompt, runRepl } from "./shell.ts";
@@ -37,6 +37,7 @@ const args = parseArgs(process.argv.slice(serverMode || acpMode || reviewMode ?
37
37
  if (serverMode || acpMode) { args.print = true; args.mode = "json"; if (args.prompt) throw Error("protocol servers receive prompts through their client"); }
38
38
  if (reviewMode) args.print = true;
39
39
  let closeControl: (() => Promise<void>) | undefined;
40
+ let stopToolInstall = (): void => {};
40
41
  const input = createStartupInput({ interactive: isInteractiveSession(args) && !args.packageCommand && !args.version && !args.exportFile && !args.listModels && !args.modelsCommand });
41
42
  process.once("exit", input.restore);
42
43
  try {
@@ -60,6 +61,7 @@ try {
60
61
  catch (error) { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = controller.signal.aborted ? 130 : 1; }
61
62
  finally { process.off("SIGINT", cancel); process.off("SIGTERM", cancel); }
62
63
  } else {
64
+ stopToolInstall = provisionToolsInBackground(args);
63
65
  const app = await startup(args, input);
64
66
  if (args.controlSocket) closeControl = await listenControl(args.controlSocket, () => sessionControl(app.session));
65
67
  if (serverMode) {
@@ -90,6 +92,7 @@ try {
90
92
  } catch (error) {
91
93
  console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1;
92
94
  } finally {
95
+ stopToolInstall();
93
96
  await closeControl?.();
94
97
  process.off("exit", input.restore);
95
98
  input.restore();
package/src/apps/shell.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import { criterionSummary, draftGoal, pickCriteria, type PendingGoal } from "./goal-flow.ts";
10
+ import { openImageFile } from "../runtime/open-image.ts";
10
11
  import { closeAppSession, switchAppSession } from "./session-lifecycle.ts";
11
12
  import { basename, dirname, join, resolve } from "node:path";
12
13
  import { tmpdir } from "node:os";
@@ -58,7 +59,7 @@ import { reloadAppResources } from "./resources.ts";
58
59
  import { publishModelCatalog } from "./model-catalog.ts";
59
60
  import { readCursor, writeCursor, listNodes, readNode, readManifest, setSessionLabel } from "../store/log.ts";
60
61
  import { registerSessionRoot } from "../store/session-browser.ts";
61
- import { createSession, findSessionByPrefix, listSessions, truncationNote, sessionsDir } from "../store/sessions.ts";
62
+ import { createSession, findSessionByPrefix, hasConversation, listSessions, truncationNote, sessionsDir } from "../store/sessions.ts";
62
63
  import { exportSessionArchive, importSessionArchive } from "../store/session-archive.ts";
63
64
  import { resolveTuiMode } from "../core/tui-mode.ts";
64
65
  import { modelPreferencePatch, thinkingPreferencePatch } from "../core/model-preferences.ts";
@@ -75,6 +76,8 @@ import { createStyle, IDENTITY } from "../tui/style.ts";
75
76
  import { lastAssistantText, toHtml } from "../core/export.ts";
76
77
  import { toJsonEvent } from "../core/json-event.ts";
77
78
  import { sessionControl } from "../runtime/session-control.ts";
79
+ import { hostExternalOptions, requestExternalEffects } from "../runtime/turn-effects.ts";
80
+ import { formatExternalEffects } from "../core/external-effects.ts";
78
81
  import { COMMANDS, parseCommand, type Command } from "./commands.ts";
79
82
  import { expandInput } from "./input.ts";
80
83
  import {
@@ -433,6 +436,8 @@ export const interactiveHooks = {
433
436
  effortSupported: (spec: ModelSpec, level: Effort) => effortSupported(spec, level),
434
437
  fileIndexFor: (app: App, refresh?: boolean) => fileIndexFor(app, refresh),
435
438
  dropFileIndex: (workspace: string) => { fileIndexes.delete(workspace); },
439
+ /** Show a stored image in the system viewer; resolves to why it could not, or null. */
440
+ openImage: (app: App, sha256: string) => openImageFile(app.blobDir, sha256),
436
441
  };
437
442
 
438
443
  type Outcome = "continue" | "quit" | { login: string };
@@ -1089,6 +1094,8 @@ async function printSessionList(app: App): Promise<void> {
1089
1094
  app.io.say(C.red(` ${s.id} unreadable ${s.error}`));
1090
1095
  continue;
1091
1096
  }
1097
+ // A session left without a word is noise here; the current one stays (fix-skip-empty-sessions).
1098
+ if (s.path !== app.logDir && !await hasConversation(s.path)) continue;
1092
1099
  const here = s.path === app.logDir ? C.cyan(" now ") : " ";
1093
1100
  const label = (s.name ?? s.id).padEnd(24);
1094
1101
  app.io.say(` ${here} ${label}${C.dim(`${s.events} events ${s.model ?? ""}`)}`);
@@ -1652,9 +1659,8 @@ export async function switchToModel(app: App, spec: ModelSpec, thinking?: Effort
1652
1659
  thinkingDisplay: app.providerConfig.thinkingDisplay,
1653
1660
  };
1654
1661
  const next = makeProvider(app, nextConfig, got.key);
1655
- app.io.say(C.dim(" new segment: the next model starts from a handoff, not this model's thinking"));
1656
1662
  try {
1657
- await app.session.switchModel(next);
1663
+ if (await app.session.switchModel(next)) app.io.say(C.dim(" new segment: the next model starts from a handoff, not this model's thinking"));
1658
1664
  } catch (err) {
1659
1665
  app.io.say(C.red(` cannot switch: ${describeError(err)}`));
1660
1666
  return;
@@ -2041,6 +2047,10 @@ export async function runTurn(
2041
2047
  app.io.say(line);
2042
2048
  }
2043
2049
  } } finally { await registry?.emit("agent_end", { messages: structuredClone(app.session.state.events) }); }
2050
+ // feat-external-effects-summary: one line, in every mode (print and json send it to stderr).
2051
+ const host = hostExternalOptions();
2052
+ const outside = formatExternalEffects(skill ? requestExternalEffects(app.session, host) : sessionControl(app.session).lastExternalEffects, host);
2053
+ if (outside) { if (tui?.notice) tui.notice(outside, "warning"); else app.io.say(app.io.palette.yellow(` ${outside}`)); }
2044
2054
  if (failure) throw Error(failure);
2045
2055
  if (completed) {
2046
2056
  const text = finalText.join("\n");
@@ -29,6 +29,10 @@ import {
29
29
  resolveCredential, envLabel, storedIsOAuth,
30
30
  type AuthFile, type ResolveEffects, type Resolved, describeMissingCredential } from "../runtime/auth.ts";
31
31
  import { authPath, configDir, readJsonFile } from "../store/config.ts";
32
+ import { findRipgrep, provisionRipgrep } from "../store/tool-binaries.ts";
33
+ import { packageLocation } from "../store/update-cache.ts";
34
+ import { installKind } from "../core/update-check.ts";
35
+ import { toolInstallAllowed } from "../core/ripgrep-release.ts";
32
36
  import { loadUiResources, loadTextResources, type UiResources } from "../store/resources.ts";
33
37
  import { discoverResources, type ResourceIndex } from "../store/resource-index.ts";
34
38
  import { pickProjectTrust, pickStartupSession, runResourceConfig } from "./ui/standalone.ts";
@@ -44,7 +48,7 @@ import { refreshRemoteCatalog } from "../runtime/models-fetch.ts";
44
48
  import { readCursor, writeCursor, type Cursor } from "../store/log.ts";
45
49
  import { getBlob } from "../store/blobs.ts";
46
50
  import { parseSessionContext } from "../core/session-format.ts";
47
- import { resolveLogDir, findSessionByPrefix, listSessions, sessionsDir, type ResolveLogOpts } from "../store/sessions.ts";
51
+ import { resolveLogDir, findSessionByPrefix, newestConversation, sessionsDir, type ResolveLogOpts } from "../store/sessions.ts";
48
52
  import { recoverCut } from "../store/recovery.ts";
49
53
  import { planSessionEntry, restoreSessionModel } from "../core/session-entry.ts";
50
54
  import { loadContext, assembleSystemPrompt, formatNotice, type LoadedContext } from "../store/context-files.ts";
@@ -108,9 +112,29 @@ export function sessionEnvironment(workspace: string): SessionEnvironment {
108
112
  cwd: workspace,
109
113
  date: `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`,
110
114
  git,
115
+ ripgrep: findRipgrep(configDir()) !== null,
111
116
  };
112
117
  }
113
118
 
119
+ /**
120
+ * Fills the rg gap in the background for interactive sessions only: scripts,
121
+ * protocol servers and test subprocesses must not reach the network on their
122
+ * own. Failure is silent because grep keeps its TypeScript engine; the next
123
+ * session's environment line reports what is actually there. The returned
124
+ * stop aborts the download, so quitting never waits for it.
125
+ */
126
+ export function provisionToolsInBackground(args: Args): () => void {
127
+ const location = packageLocation();
128
+ if (!isInteractiveSession(args) || !toolInstallAllowed({ env: process.env, offline: args.offline === true, install: installKind(location.root, location.git) })) return () => {};
129
+ const controller = new AbortController();
130
+ void provisionRipgrep({ configDir: configDir(), signal: controller.signal, fetchBytes: async (url, signal) => {
131
+ const response = await fetch(url, { ...(signal ? { signal } : {}), redirect: "follow" });
132
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
133
+ return new Uint8Array(await response.arrayBuffer());
134
+ } }).catch(() => { /* grep falls back to its own engine */ });
135
+ return () => controller.abort();
136
+ }
137
+
114
138
  /**
115
139
  * Session policy a CLI coding session differs from a benchmark run in.
116
140
  * Benchmarks construct their own SessionConfig and keep the core defaults.
@@ -366,8 +390,9 @@ export function parseArgs(argv: string[], options: { deferExtensions?: boolean;
366
390
  let updateExtensions = false;
367
391
  const args: Args = {
368
392
  cliFlags: argv.filter(value => value.startsWith("-")),
369
- // No default request cap (S.6 item 1): the stall watchdog ends a run that
370
- // stops producing evidence; --max-turns or an agent budget sets a hard cap.
393
+ // No default request cap (S.6 item 1): the stall watchdog catches a run
394
+ // repeating a cycle without evidence, and asks the user when one can answer
395
+ // (S.8); --max-turns or an agent budget sets a hard cap.
371
396
  workspace: process.cwd(), logDir: "", maxTurns: Infinity, prompt: "",
372
397
  // Overridable so a node boundary can be forced in a short session --
373
398
  // otherwise cutting is only reachable after tens of thousands of tokens,
@@ -1245,9 +1270,9 @@ export async function openSessionFor(opts: OpenSessionOptions): Promise<{
1245
1270
  session.config.tools.workflowMode = opts.args.workflow ?? "build";
1246
1271
  session.config.tools.deferredTools = restoredTools.deferred;
1247
1272
  if (restoring.id !== spec.id || restoring.provider !== spec.provider) {
1248
- await session.switchModel(createProvider(providerConfig, credential.ok ? credential.key : "", retryHandler(opts.io),
1273
+ const cut = await session.switchModel(createProvider(providerConfig, credential.ok ? credential.key : "", retryHandler(opts.io),
1249
1274
  { sessionId: providerSessionId(session.config.logDir), ...(oauth(spec, credential) ? { oauth: true } : {}), ...(opts.effects ? { effects: opts.effects } : {}) }));
1250
- opts.io.say(opts.io.palette.yellow(` switched ${restoring.id} → ${spec.id} at a model checkpoint`));
1275
+ opts.io.say(opts.io.palette.yellow(` switched ${restoring.id} → ${spec.id}${cut ? " at a model checkpoint" : ""}`));
1251
1276
  }
1252
1277
  resources.ui.settings = { ...resources.ui.settings, thinking: providerConfig.effort,
1253
1278
  ...(opts.args.modelsExplicit || opts.args.models ? { scopedModels: splitModelPatterns(opts.args.models) } : {}) };
@@ -1390,7 +1415,7 @@ export async function startup(args: Args, input?: StartupInput): Promise<App> {
1390
1415
  let resourceLog: string | undefined;
1391
1416
  if (target) { const found = await findSessionByPrefix(workspace, target, sessionDirFor(args, workspace)); if (found.ok) resourceLog = found.session.path; }
1392
1417
  else if (args.sessionId) resourceLog = join(sessionsDir(workspace, sessionDirFor(args, workspace)), args.sessionId);
1393
- else if (args.continueLatest) resourceLog = (await listSessions(workspace, sessionDirFor(args, workspace), Infinity)).sessions.find(session => session.ok)?.path;
1418
+ else if (args.continueLatest) resourceLog = (await newestConversation(workspace, sessionDirFor(args, workspace)))?.path;
1394
1419
  if (resourceLog) resourceWorkspace = (await readCursor(resourceLog))?.workspace ?? workspace;
1395
1420
  }
1396
1421
  input?.phase("Loading session resources");
@@ -37,6 +37,8 @@ import { DEFAULT_FULLSCREEN_EXIT_OUTPUT, resumeCommand, sessionExitStatus } from
37
37
  import { lastAssistantText } from "../../core/export.ts";
38
38
  import type { PermissionRequest } from "../../core/permissions.ts";
39
39
  import type { ApprovalChoice } from "../../runtime/tools.ts";
40
+ import { formatExternalEffects, type ExternalEffect } from "../../core/external-effects.ts";
41
+ import { hostExternalOptions } from "../../runtime/turn-effects.ts";
40
42
  import { attachImageBytes, attachImageFile } from "../../runtime/images.ts";
41
43
  import { readClipboard, writeClipboard, type ClipboardContent } from "../../runtime/clipboard.ts";
42
44
  import { highlightCode, transformMermaid, warmSyntaxLanguages } from "../../runtime/rich-text.ts";
@@ -49,12 +51,12 @@ import { updateNotice } from "../../core/update-check.ts";
49
51
  import { assertNever } from "../../core/exhaustive.ts";
50
52
  import { isUpgrade } from "../../core/version.ts";
51
53
  import type { Effort } from "../../runtime/model.ts";
52
- import { readNode, readCursor, setSessionLabel } from "../../store/log.ts";
54
+ import { readNode, readCursor, setSessionLabel, forkedAt } from "../../store/log.ts";
53
55
  import { readNodeHistory } from "../../store/restore.ts";
54
56
  import { sessionsDir } from "../../store/sessions.ts";
55
57
  import { readGitStatus } from "../../store/git-status.ts";
56
58
  import type { GitStatus } from "../../core/git-status.ts";
57
- import { activityStatus } from "../../core/activity-status.ts";
59
+ import { activityStatus, retryLine, type RetryFacts } from "../../core/activity-status.ts";
58
60
  import { goalStatus } from "../../core/goal-view.ts";
59
61
  import { generationSpeed } from "../../runtime/usage.ts";
60
62
  import { executionOutcome, type ExecutionOutcome } from "../../core/outcome.ts";
@@ -155,7 +157,7 @@ export class InteractiveApp implements Ui {
155
157
  private historyLength = -1;
156
158
  private notices: Notice[] = [];
157
159
  private noticeSerial = 0;
158
- private live: { thinking: string; text: string; tools: Map<string, { name: string; arguments: string }>; output: string; bash: string | null; retryUntil: number; retryAttempt: number } = emptyLive();
160
+ private live: { thinking: string; text: string; tools: Map<string, { name: string; arguments: string }>; output: string; bash: string | null; retryUntil: number; retry: RetryFacts | null } = emptyLive();
159
161
  private retryTimer: ReturnType<typeof setInterval> | undefined;
160
162
  private bashDecoder = new TextDecoder();
161
163
  private branch: string | null;
@@ -194,6 +196,8 @@ export class InteractiveApp implements Ui {
194
196
  private files: ReturnType<typeof fileChanges> = [];
195
197
  private prompt: string | null = null;
196
198
  private startedAt: string | undefined;
199
+ /** Set for a forked session: its clock starts at the fork, not at the copied parent history (fix-fork-start-time). */
200
+ private forkedAt: string | null = null;
197
201
  private budgetKey = "";
198
202
  private budgetCache: ContextBudget | undefined;
199
203
  /** An extension's own terminal title wins over ours; ours is written only when it changes. */
@@ -464,6 +468,7 @@ export class InteractiveApp implements Ui {
464
468
  this.clearStream();
465
469
  void this.refreshGit();
466
470
  this.sessionName = (await readCursor(dir))?.name ?? this.sessionName;
471
+ this.forkedAt = await forkedAt(dir).catch(() => null);
467
472
  this.thinkingExpanded = this.app.ui?.settings.hideThinkingBlock === false;
468
473
  void this.refreshFileIndex();
469
474
  }
@@ -481,7 +486,7 @@ export class InteractiveApp implements Ui {
481
486
  this.historyLength = physical.events.length;
482
487
  this.files = fileChanges(this.transcriptItems);
483
488
  this.prompt = firstPrompt(this.transcriptItems);
484
- this.startedAt = events.find(event => typeof event.at === "string")?.at ?? physical.header?.created_at;
489
+ this.startedAt = this.forkedAt ?? events.find(event => typeof event.at === "string")?.at ?? physical.header?.created_at;
485
490
  this.requestDerived();
486
491
  }
487
492
  this.paint();
@@ -498,7 +503,7 @@ export class InteractiveApp implements Ui {
498
503
  if (progress.kind === "retry") {
499
504
  this.stopRetry();
500
505
  this.live.retryUntil = Date.now() + progress.delayMs;
501
- this.live.retryAttempt = progress.attempt;
506
+ this.live.retry = { attempt: progress.attempt, seconds: 0, ...(progress.max === undefined ? {} : { max: progress.max }), ...(progress.reason === undefined ? {} : { reason: progress.reason }) };
502
507
  this.retryTimer = setInterval(() => this.paint(), 200);
503
508
  } else {
504
509
  this.stopRetry();
@@ -563,7 +568,7 @@ export class InteractiveApp implements Ui {
563
568
  });
564
569
  const home = !this.transcriptItems.some(item => item.kind === "user" || item.kind === "assistant" || item.kind === "tool" || item.kind === "shell" || item.kind === "custom") && !this.inflight;
565
570
  const clock = this.startedAt ? sessionClock(this.startedAt, new Date()) : undefined;
566
- const view = projectWithStart(items, clock);
571
+ const view = projectWithStart(items, clock, this.forkedAt !== null);
567
572
  this.screen.setGreeting(home ? {
568
573
  version: XENO_VERSION, model: app.credential.ok ? spec.name : null, thinking: app.providerConfig.effort,
569
574
  workspace: displayPath(app.workspace, homedir()), branch: this.branch,
@@ -594,7 +599,7 @@ export class InteractiveApp implements Ui {
594
599
  ...(pending ? { tool: pending.name } : this.executionKind === "bash" ? { tool: "bash" } : {}),
595
600
  ...(this.lastOutcome ? { outcome: this.lastOutcome } : this.controlSnapshot?.goal?.state.execution ? { outcome: this.controlSnapshot.goal.state.execution } : {}),
596
601
  ...(this.controlSnapshot?.goal ? { goal: this.controlSnapshot.goal.state.status } : {}),
597
- ...(this.live.retryUntil ? { retry: { attempt: this.live.retryAttempt, seconds: (this.live.retryUntil - Date.now()) / 1000 } } : {}) });
602
+ ...(this.live.retryUntil && this.live.retry ? { retry: { ...this.live.retry, seconds: (this.live.retryUntil - Date.now()) / 1000 } } : {}) });
598
603
  return {
599
604
  version: XENO_VERSION, title: this.sessionName ?? this.prompt, workspace: displayPath(app.workspace, homedir()),
600
605
  workspaceShort: shortPath(displayPath(app.workspace, homedir())), branch: this.branch,
@@ -681,7 +686,7 @@ export class InteractiveApp implements Ui {
681
686
  const l = this.live;
682
687
  if (!l.thinking && !l.text && !l.tools.size && !l.output && l.bash === null && !l.retryUntil) return undefined;
683
688
  return { thinking: l.thinking, text: l.text, tools: [...l.tools].map(([id, tool]) => ({ id, ...tool })), output: l.output, bash: l.bash,
684
- ...(l.retryUntil ? { retry: `retry ${l.retryAttempt} in ${Math.max(0, (l.retryUntil - Date.now()) / 1000).toFixed(1)}s · esc to cancel` } : {}) };
689
+ ...(l.retryUntil && l.retry ? { retry: retryLine({ ...l.retry, seconds: (l.retryUntil - Date.now()) / 1000 }) } : {}) };
685
690
  }
686
691
 
687
692
  /** The context budget changes only with the log or the model; it is not recounted on every paint. */
@@ -1139,7 +1144,13 @@ export class InteractiveApp implements Ui {
1139
1144
  const cell = resolution && this.renderer.terminalWidth > 0 && this.renderer.terminalHeight > 0
1140
1145
  ? { width: resolution.width / this.renderer.terminalWidth, height: resolution.height / this.renderer.terminalHeight } : DEFAULT_CELL;
1141
1146
  return { mode: show ? "picture" : "chip", protocol: effective, cell, widthCells: terminal?.imageWidthCells ?? 60,
1142
- ...(!show ? { reason } : {}), load: sha => this.images.loadResult(sha) };
1147
+ ...(!show ? { reason } : {}), load: sha => this.images.loadResult(sha), open: sha => this.openImage(sha) };
1148
+ }
1149
+ /** A click on a picture (feat-tui-image-full-size): the system viewer shows what the thumbnail cannot. */
1150
+ private openImage(sha256: string): void {
1151
+ void this.hooks.openImage(this.app, sha256)
1152
+ .then(reason => { if (reason) this.notice(reason, "warning"); })
1153
+ .catch(error => this.notice(describeError(error), "warning"));
1143
1154
  }
1144
1155
  private applyPictures(): void {
1145
1156
  if (this.disposed) return;
@@ -1211,6 +1222,9 @@ export class InteractiveApp implements Ui {
1211
1222
  if (notice.method === "turn/started") { this.inflight = true; this.executionKind = "model"; }
1212
1223
  if (notice.method === "turn/completed") {
1213
1224
  this.inflight = false; this.executionKind = "idle"; this.clearStream();
1225
+ // feat-external-effects-summary: a remote-driven turn shows the same line a local one does.
1226
+ const outside = formatExternalEffects((notice.params["externalEffects"] ?? []) as ExternalEffect[], hostExternalOptions());
1227
+ if (outside) this.notice(outside, "warning");
1214
1228
  const outcome = notice.params["outcome"] as import("../../core/outcome.ts").ExecutionOutcome | undefined;
1215
1229
  this.turn = this.continueRemoteTurn(control, outcome).catch(error => this.notice(describeError(error), "error"));
1216
1230
  }
@@ -1482,14 +1496,16 @@ export class InteractiveApp implements Ui {
1482
1496
  const scan = new AbortController();
1483
1497
  const items = (query = ""): ListItem[] => selectSessions(rows, query, sort, false, sessionFuzzy).map(row => ({
1484
1498
  value: row.path, label: " ".repeat(Math.min(8, row.depth)) + (row.depth ? "↳ " : "") + (row.name?.trim() || row.text?.split("\n").find(Boolean)?.slice(0, 90) || row.id),
1485
- description: [row.updated_at?.slice(0, 16).replace("T", " ") ?? "", row.model ?? "", scope === "all" ? row.workspace ?? "" : ""].filter(Boolean).join(" · "),
1499
+ description: [row.updated_at?.slice(0, 16).replace("T", " ") ?? "", row.forked_at ? `forked ${row.forked_at.slice(0, 16).replace("T", " ")}` : "",
1500
+ row.model ?? "", scope === "all" ? row.workspace ?? "" : ""].filter(Boolean).join(" · "),
1486
1501
  ...(row.path === this.app.logDir ? { hint: "● current" } : {}),
1487
1502
  }));
1488
1503
  const load = async () => {
1489
1504
  rows = [];
1490
1505
  const roots = scope === "current" ? [root] : await sessionRoots(root);
1491
1506
  for await (const batch of scanSessionRoots(roots, scan.signal)) {
1492
- rows.push(...batch.sessions.filter((row): row is Row => row.ok));
1507
+ // Empty sessions are hidden, except the one in use (fix-skip-empty-sessions).
1508
+ rows.push(...batch.sessions.filter((row): row is Row => row.ok && (row.conversation !== false || row.path === this.app.logDir)));
1493
1509
  if (this.screen.dialogs.top === spec) this.screen.dialogs.list?.setItems(items(this.screen.dialogs.list.query));
1494
1510
  }
1495
1511
  };
@@ -1600,7 +1616,7 @@ export function trustItems(workspace: string): ListItem[] {
1600
1616
  ];
1601
1617
  }
1602
1618
 
1603
- function emptyLive() { return { thinking: "", text: "", tools: new Map<string, { name: string; arguments: string }>(), output: "", bash: null as string | null, retryUntil: 0, retryAttempt: 0 }; }
1619
+ function emptyLive() { return { thinking: "", text: "", tools: new Map<string, { name: string; arguments: string }>(), output: "", bash: null as string | null, retryUntil: 0, retry: null as RetryFacts | null }; }
1604
1620
 
1605
1621
  function noticeOf(id: string, after: number, lines: string[], tone: Tone): Notice & { lines: string[]; tone: Tone } {
1606
1622
  const prefix = tone === "error" ? "\x1b[31m" : tone === "warning" ? "\x1b[33m" : tone === "success" ? "\x1b[32m" : "";
@@ -1672,8 +1688,10 @@ async function printExit(app: App): Promise<void> {
1672
1688
  }
1673
1689
 
1674
1690
  /** The session-start rule first, then the projection (refactor-tui-dock-layout DoD 7). */
1675
- function projectWithStart(items: ReturnType<typeof projectItems>, clock: string | undefined): ReturnType<typeof projectItems> {
1676
- return clock ? [{ kind: "segment", id: "session-start", rev: clock, label: `session · ${clock}` }, ...items] : items;
1691
+ function projectWithStart(items: ReturnType<typeof projectItems>, clock: string | undefined, forked = false): ReturnType<typeof projectItems> {
1692
+ // The label is part of rev: a view keyed on rev alone would keep "session" after switching into a fork.
1693
+ const label = `${forked ? "forked" : "session"} · ${clock}`;
1694
+ return clock ? [{ kind: "segment", id: "session-start", rev: label, label }, ...items] : items;
1677
1695
  }
1678
1696
 
1679
1697
  /** "~/Projects/workspace/xeno-tui" → "~/P/w/xeno-tui": every directory but the last cut to its first letter. */
@@ -123,5 +123,5 @@ function shortRef(ref: string): string { return ref.split("/").pop() ?? ref; }
123
123
 
124
124
  /** What the view needs of a logged image: its address, name and pixel size. Never the bytes (D8). */
125
125
  export function imageView(image: ImageRef): ImageView {
126
- return { sha256: image.sha256, name: image.name, width: image.width, height: image.height };
126
+ return { sha256: image.sha256, name: image.name, width: image.width, height: image.height, ...(image.original ? { full: image.original.sha256 } : {}) };
127
127
  }
@@ -102,13 +102,15 @@ export async function pickStartupSession(opts: { workspace: string; sessionDir?:
102
102
  let scope: "current" | "all" = "current";
103
103
  const items = (query = ""): ListItem[] => selectSessions(rows, query, "threaded", false, (q, text) => { const score = fuzzyMatch(text, q); return { matches: score !== null, score: score ?? Infinity }; })
104
104
  .map(row => ({ value: row.path, label: " ".repeat(Math.min(8, row.depth)) + (row.depth ? "↳ " : "") + (row.name?.trim() || row.text?.split("\n").find(Boolean)?.slice(0, 90) || row.id),
105
- description: [row.updated_at?.slice(0, 16).replace("T", " ") ?? "", row.model ?? "", scope === "all" ? row.workspace ?? "" : ""].filter(Boolean).join(" · ") }));
105
+ description: [row.updated_at?.slice(0, 16).replace("T", " ") ?? "", row.forked_at ? `forked ${row.forked_at.slice(0, 16).replace("T", " ")}` : "",
106
+ row.model ?? "", scope === "all" ? row.workspace ?? "" : ""].filter(Boolean).join(" · ") }));
106
107
  try {
107
108
  let spec: DialogSpec & { kind: "list" } | undefined;
108
109
  const load = async () => {
109
110
  rows = [];
110
111
  for await (const batch of scanSessionRoots(scope === "current" ? [root] : await sessionRoots(root), scan.signal)) {
111
- rows.push(...batch.sessions.filter((row): row is Session => row.ok));
112
+ // A session left without a word is not something to resume (fix-skip-empty-sessions).
113
+ rows.push(...batch.sessions.filter((row): row is Session => row.ok && row.conversation !== false));
112
114
  if (spec && host.dialogs.top === spec) host.dialogs.list?.setItems(items(host.dialogs.list.query));
113
115
  }
114
116
  };
@@ -20,6 +20,8 @@ export interface TurnSurface {
20
20
  pushProgress?(progress: NonNullable<LoopEvent["progress"]>): void;
21
21
  pullQueued?(phase: Phase): Promise<UserInput | null>;
22
22
  observeEvent?(event: LoopEvent): void;
23
+ /** A plain-text notice in the message stream (the interactive app); line mode prints instead. */
24
+ notice?(text: string, tone?: "info" | "success" | "warning" | "error"): void;
23
25
  }
24
26
 
25
27
  export interface Ui extends TurnSurface {
@@ -9,18 +9,42 @@ export interface ActivityFacts {
9
9
  readonly goal?: string;
10
10
  readonly phase?: "connecting" | "thinking" | "streaming" | "tool";
11
11
  readonly tool?: string;
12
- readonly retry?: { readonly attempt: number; readonly seconds: number };
12
+ readonly retry?: RetryFacts;
13
13
  readonly queued: number;
14
14
  }
15
+ /** One pending retry: which attempt, out of how many, how long until it, and why. */
16
+ export interface RetryFacts { readonly attempt: number; readonly max?: number; readonly seconds: number; readonly reason?: string }
17
+
18
+ const attemptText = (retry: RetryFacts): string => retry.max === undefined ? `${retry.attempt}` : `${retry.attempt}/${retry.max}`;
19
+
20
+ /**
21
+ * The stream-area retry line. The cause appears from the third attempt (or
22
+ * the last, for a smaller budget), as in Claude Code: one or two retries are
23
+ * noise, and naming them would send the user to check a network that is fine.
24
+ */
25
+ export function retryLine(retry: RetryFacts): string {
26
+ const named = retry.reason !== undefined && retry.attempt >= Math.min(3, retry.max ?? 3);
27
+ return `retry ${attemptText(retry)} in ${Math.max(0, retry.seconds).toFixed(1)}s · ${named ? `${retry.reason} · ` : ""}esc to cancel`;
28
+ }
29
+
15
30
  export interface ActivityStatus { readonly state: ProgramStatus["state"]; readonly label: string; readonly detail: string; readonly queued: number }
16
31
 
32
+ const STOP_CAUSE: Partial<Record<ExecutionOutcome["reason"], string>> = {
33
+ stalled: "no progress", no_progress: "no progress", max_turns: "request limit", context_budget: "context budget",
34
+ output_limit: "output limit", timeout: "time limit",
35
+ };
36
+
17
37
  export function activityStatus(facts: ActivityFacts): ActivityStatus {
18
38
  const status = programStatus(facts), queued = Math.max(0, Math.floor(facts.queued));
19
39
  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");
40
+ // A harness stop is not a question: saying "Awaiting input" left the user
41
+ // guessing what to answer (fix-finished-turn-status). The OSC state stays blocked.
42
+ if (!facts.running && !facts.waiting && facts.outcome?.status === "stopped") return view("Stopped", STOP_CAUSE[facts.outcome.reason] ?? facts.outcome.reason.replaceAll("_", " "));
43
+ if (status.state === "blocked") return view(status.kind === "auth" ? "Sign in required" : status.kind === "permission" ? "Awaiting approval"
44
+ : !facts.waiting && facts.goal === "awaiting_human" ? "Awaiting review" : "Awaiting input");
21
45
  if (status.state === "error") return view("Failed");
22
46
  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`);
47
+ if (facts.retry) return view("Retrying", `attempt ${attemptText(facts.retry)} · ${Math.max(0, Math.ceil(facts.retry.seconds))}s`);
24
48
  if (facts.phase === "tool") return view("Running tool", facts.tool ?? "Tool execution");
25
49
  if (facts.phase === "thinking") return view("Thinking");
26
50
  if (facts.phase === "streaming") return view("Responding");