@visual-z/xeno 0.7.2 → 0.8.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 (117) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +36 -9
  3. package/docs/foundations.md +1 -1
  4. package/package.json +3 -1
  5. package/src/apps/cli.ts +4 -3
  6. package/src/apps/commands.ts +8 -0
  7. package/src/apps/goal-flow.ts +70 -0
  8. package/src/apps/render.ts +0 -2
  9. package/src/apps/session-hub.ts +6 -5
  10. package/src/apps/session-lifecycle.ts +64 -0
  11. package/src/apps/shell.ts +89 -34
  12. package/src/apps/startup.ts +32 -31
  13. package/src/apps/ui/app.ts +105 -13
  14. package/src/apps/ui/control-model.ts +18 -1
  15. package/src/apps/ui/surface.ts +2 -0
  16. package/src/apps/update-check.ts +1 -1
  17. package/src/core/acceptance.ts +41 -9
  18. package/src/core/blind-checks.ts +72 -0
  19. package/src/core/branch-receipt.ts +3 -1
  20. package/src/core/checkpoints.ts +21 -0
  21. package/src/core/churn.ts +2 -2
  22. package/src/core/completion-review.ts +18 -11
  23. package/src/core/continuation.ts +11 -10
  24. package/src/core/control-view.ts +5 -8
  25. package/src/core/deadline.ts +2 -1
  26. package/src/core/derive.ts +0 -7
  27. package/src/core/environment.ts +2 -2
  28. package/src/core/event-validation.ts +19 -6
  29. package/src/core/events.ts +41 -32
  30. package/src/core/evidence-ladder.ts +62 -0
  31. package/src/core/evidence-links.ts +0 -10
  32. package/src/core/evidence.ts +1 -2
  33. package/src/core/export.ts +6 -11
  34. package/src/core/goal-view.ts +64 -0
  35. package/src/core/goal.ts +200 -0
  36. package/src/core/history.ts +1 -11
  37. package/src/core/json-event.ts +0 -6
  38. package/src/core/outcome.ts +0 -1
  39. package/src/core/program-status.ts +11 -2
  40. package/src/core/progress.ts +14 -8
  41. package/src/core/project.ts +6 -16
  42. package/src/core/read-bounds.ts +11 -2
  43. package/src/core/read-observation.ts +24 -0
  44. package/src/core/request-budget.ts +13 -3
  45. package/src/core/request-state.ts +178 -19
  46. package/src/core/response-recovery.ts +15 -2
  47. package/src/core/session-entry.ts +8 -3
  48. package/src/core/session-exit.ts +19 -3
  49. package/src/core/session-format.ts +169 -0
  50. package/src/core/settings.ts +7 -27
  51. package/src/core/stall.ts +30 -7
  52. package/src/core/stop-report.ts +4 -3
  53. package/src/core/transcript.ts +22 -2
  54. package/src/runtime/blind-check.ts +36 -0
  55. package/src/runtime/counter-check.ts +74 -0
  56. package/src/runtime/exec-sessions.ts +11 -0
  57. package/src/runtime/foundation.ts +46 -8
  58. package/src/runtime/loop.ts +239 -430
  59. package/src/runtime/oauth.ts +1 -3
  60. package/src/runtime/openai.ts +2 -2
  61. package/src/runtime/pi-provider.ts +3 -3
  62. package/src/runtime/provider-request.ts +2 -1
  63. package/src/runtime/provider.ts +33 -3
  64. package/src/runtime/retry.ts +9 -2
  65. package/src/runtime/runtime-target.ts +2 -2
  66. package/src/runtime/session-control.ts +4 -4
  67. package/src/runtime/tools.ts +44 -42
  68. package/src/runtime/usage.ts +28 -0
  69. package/src/runtime/version.ts +12 -0
  70. package/src/runtime/workspace-lifecycle.ts +31 -0
  71. package/src/store/blind-checks.ts +32 -0
  72. package/src/store/checkpoint-history.ts +1 -12
  73. package/src/store/file-index.ts +88 -7
  74. package/src/store/log.ts +25 -27
  75. package/src/store/recovery.ts +1 -3
  76. package/src/store/replay.ts +2 -2
  77. package/src/store/restore.ts +10 -202
  78. package/src/store/search.ts +7 -5
  79. package/src/store/session-archive.ts +0 -1
  80. package/src/store/sessions.ts +31 -33
  81. package/src/store/workspace-clone.ts +42 -0
  82. package/src/store/workspace.ts +10 -157
  83. package/src/tui/ui/duration.ts +16 -0
  84. package/src/tui/ui/layout.ts +4 -4
  85. package/src/tui/ui/screen.ts +13 -7
  86. package/src/tui/ui/status.ts +99 -17
  87. package/src/core/benchmark.ts +0 -195
  88. package/src/core/evaluation-coverage.ts +0 -74
  89. package/src/core/experiment.ts +0 -173
  90. package/src/core/harness-comparison.ts +0 -90
  91. package/src/core/harness-diagnostics.ts +0 -206
  92. package/src/core/native-eval.ts +0 -25
  93. package/src/core/policy-evaluation.ts +0 -55
  94. package/src/core/query-eval-contract.ts +0 -14
  95. package/src/core/recall-analysis.ts +0 -90
  96. package/src/core/recall-trial.ts +0 -153
  97. package/src/core/recovery-corpus.ts +0 -17
  98. package/src/core/recovery-evaluation.ts +0 -60
  99. package/src/core/tool-mode-protocol.ts +0 -25
  100. package/src/core/trial-lifecycle.ts +0 -14
  101. package/src/core/window-trial.ts +0 -65
  102. package/src/index.ts +0 -10
  103. package/src/runtime/benchmark.ts +0 -187
  104. package/src/runtime/harness-comparison.ts +0 -376
  105. package/src/runtime/harness-eval.ts +0 -462
  106. package/src/runtime/native-eval-fixtures.ts +0 -33
  107. package/src/runtime/native-eval.ts +0 -146
  108. package/src/runtime/query-eval-verifier.ts +0 -28
  109. package/src/runtime/recall-trial.ts +0 -226
  110. package/src/runtime/recovery-coverage.ts +0 -71
  111. package/src/runtime/recovery-evidence.ts +0 -76
  112. package/src/runtime/recovery-fixtures.ts +0 -116
  113. package/src/runtime/tool-mode-eval.ts +0 -146
  114. package/src/runtime/trial-control.ts +0 -45
  115. package/src/runtime/window-trial.ts +0 -141
  116. package/src/store/git-branch.ts +0 -35
  117. package/src/tui/ui/testing.ts +0 -5
@@ -22,7 +22,7 @@
22
22
  * and silently truncate a log.
23
23
  */
24
24
 
25
- import { cp, lstat, mkdir, mkdtemp, open, readdir, rename, rm, stat } from "node:fs/promises";
25
+ import { cp, lstat, mkdir, mkdtemp, open, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
26
26
  import type { Dirent } from "node:fs";
27
27
  import { tmpdir } from "node:os";
28
28
  import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
@@ -112,10 +112,6 @@ export function sessionsDir(workspace: string, override?: string): string {
112
112
  return join(workspace, ".xeno", "sessions");
113
113
  }
114
114
 
115
- export function legacyLogDir(workspace: string): string {
116
- return join(workspace, ".xeno", "log");
117
- }
118
-
119
115
  /**
120
116
  * `<base36 ms>-<4 digit base36 random>`.
121
117
  *
@@ -141,6 +137,8 @@ export async function createSession(
141
137
  ): Promise<{ id: string; path: string }> {
142
138
  const dir = sessionsDir(workspace, sessionDir);
143
139
  await mkdir(dir, { recursive: true });
140
+ // The first session makes the state directory exist; keep it out of git from that moment.
141
+ await ignoreStateDir(dirname(dir));
144
142
  for (let attempt = 0; attempt < MAX_ID_ATTEMPTS; attempt++) {
145
143
  const id = formatSessionId(clock.nowMs(), clock.random());
146
144
  const path = join(dir, id);
@@ -193,21 +191,6 @@ export async function listSessions(workspace: string, sessionDir?: string, limit
193
191
  return { sessions, truncated };
194
192
  }
195
193
 
196
- /**
197
- * A leftover `.xeno/log` is not migrated. Moving someone else's data cannot
198
- * be a startup side effect; the person keeps using it by pointing `--log`
199
- * at it. Once `.xeno/sessions/` exists they are already in the new layout
200
- * and the leftover is just leftover, so the notice stays quiet.
201
- */
202
- export async function legacyLogNotice(workspace: string): Promise<string | null> {
203
- if (!(await isDirectory(legacyLogDir(workspace)))) return null;
204
- if (await isDirectory(sessionsDir(workspace))) return null;
205
- return (
206
- "This workspace still has a log at .xeno/log from before named sessions existed. " +
207
- "It has not been moved. Continue it with --log .xeno/log"
208
- );
209
- }
210
-
211
194
  export type SessionPrefixMatch =
212
195
  | { ok: true; session: Extract<ListedSession, { ok: true }> }
213
196
  | { ok: false; reason: string; candidates: string[] };
@@ -352,46 +335,44 @@ export interface ResolveLogOpts extends SessionEntryFlags {
352
335
  export async function resolveLogDir(
353
336
  workspace: string,
354
337
  opts: ResolveLogOpts = {},
355
- ): Promise<{ logDir: string; created: boolean; legacyNotice: string | null }> {
338
+ ): Promise<{ logDir: string; created: boolean }> {
356
339
  const entry = planSessionEntry(opts);
357
- // Computed first: createSession will mkdir `.xeno/sessions/`, after which
358
- // the leftover-log condition is no longer true.
359
- const legacyNotice = await legacyLogNotice(workspace);
360
340
  switch (entry.kind) {
361
341
  case "named": {
362
342
  const root = sessionsDir(workspace, opts.sessionDir), logDir = join(root, entry.id);
363
343
  await mkdir(root, { recursive: true });
364
- try { await mkdir(logDir); return { logDir, created: true, legacyNotice }; }
344
+ await ignoreStateDir(dirname(root));
345
+ try { await mkdir(logDir); return { logDir, created: true }; }
365
346
  catch (error) {
366
347
  if ((error as { code?: string }).code !== "EEXIST" || !(await lstat(logDir)).isDirectory()) throw error;
367
- return { logDir, created: false, legacyNotice };
348
+ return { logDir, created: false };
368
349
  }
369
350
  }
370
351
  case "ephemeral": {
371
352
  const logDir = await mkdtemp(join(tmpdir(), "xeno-nosession-"));
372
- return { logDir, created: true, legacyNotice };
353
+ return { logDir, created: true };
373
354
  }
374
355
  case "open": {
375
- if (entry.explicit) return { logDir: entry.target, created: false, legacyNotice };
356
+ if (entry.explicit) return { logDir: entry.target, created: false };
376
357
  const archive = resolve(workspace, entry.target);
377
358
  if (await stat(archive).then(info => info.isFile(), () => false)) {
378
- return { logDir: await importSessionArchive(archive, sessionsDir(workspace, opts.sessionDir), workspace), created: true, legacyNotice };
359
+ return { logDir: await importSessionArchive(archive, sessionsDir(workspace, opts.sessionDir), workspace), created: true };
379
360
  }
380
361
  const found = await findSessionByPrefix(workspace, entry.target, opts.sessionDir);
381
362
  if (!found.ok) throw new Error(found.reason);
382
- return { logDir: found.session.path, created: false, legacyNotice };
363
+ return { logDir: found.session.path, created: false };
383
364
  }
384
365
  case "fork": {
385
366
  const found = await findSessionByPrefix(workspace, entry.target, opts.sessionDir);
386
367
  if (!found.ok) throw new Error(found.reason);
387
368
  const created = await createSession(workspace, systemIdClock, opts.sessionDir);
388
369
  await copyAncestryTo(found.session.path, created.path, new Date().toISOString());
389
- return { logDir: created.path, created: true, legacyNotice };
370
+ return { logDir: created.path, created: true };
390
371
  }
391
372
  case "continue": {
392
373
  const { sessions } = await listSessions(workspace, opts.sessionDir, Infinity);
393
374
  const newest = sessions.find((s) => s.ok);
394
- if (newest !== undefined) return { logDir: newest.path, created: false, legacyNotice };
375
+ if (newest !== undefined) return { logDir: newest.path, created: false };
395
376
  break;
396
377
  }
397
378
  case "select":
@@ -400,7 +381,7 @@ export async function resolveLogDir(
400
381
  default: return assertNever(entry, "resolveLogDir");
401
382
  }
402
383
  const created = await createSession(workspace, systemIdClock, opts.sessionDir);
403
- return { logDir: created.path, created: true, legacyNotice };
384
+ return { logDir: created.path, created: true };
404
385
  }
405
386
 
406
387
  async function existingDir(workspace: string, prefix: string): Promise<string | null> {
@@ -482,3 +463,20 @@ async function isDirectory(path: string): Promise<boolean> {
482
463
  throw err;
483
464
  }
484
465
  }
466
+
467
+ /**
468
+ * Keeps a state directory out of `git status` the way pytest, ruff and mypy
469
+ * keep their caches out: a `.gitignore` holding `*` inside the directory
470
+ * itself. Checks people and models write ("only plan.md changed") read git,
471
+ * and a visible session directory made such a check fail forever
472
+ * (fix-state-dir-self-ignore). The user's own ignore files are never edited,
473
+ * and .git/info/exclude is avoided: worktrees do not read it, and a sandboxed
474
+ * agent could use it to hide files. An existing file is left alone; failure
475
+ * to write one never blocks a session.
476
+ */
477
+ export async function ignoreStateDir(stateRoot: string): Promise<void> {
478
+ try {
479
+ await mkdir(stateRoot, { recursive: true });
480
+ await writeFile(join(stateRoot, ".gitignore"), "# Created by xeno: session state is not project source.\n*\n", { flag: "wx" });
481
+ } catch { /* present already, or not writable: either way nothing to do */ }
482
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * A disposable copy of a workspace, for running a check somewhere it cannot
3
+ * touch the real files (feat-counter-check).
4
+ *
5
+ * Copy-on-write where the filesystem offers it -- APFS clonefile through
6
+ * `cp -c`, reflinks through `cp --reflink=auto` -- so a workspace with large
7
+ * dependency trees costs metadata, not bytes. Where it does not, cp falls back
8
+ * to a real copy, bounded by the deadline below.
9
+ */
10
+ import { mkdtemp, rm } from "node:fs/promises";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+
14
+ /**
15
+ * Assumed, not measured: a clone is metadata-only on APFS and reflink
16
+ * filesystems; a full copy that takes longer than this is better reported as
17
+ * unavailable than waited on.
18
+ */
19
+ export const CLONE_TIMEOUT_MS = 60_000;
20
+
21
+ export type WorkspaceClone = { ok: true; path: string; dispose: () => Promise<void> } | { ok: false; reason: string };
22
+
23
+ export async function cloneWorkspace(root: string, signal?: AbortSignal): Promise<WorkspaceClone> {
24
+ const parent = await mkdtemp(join(tmpdir(), "xeno-counter-check-")), path = join(parent, "workspace");
25
+ const dispose = () => rm(parent, { recursive: true, force: true });
26
+ const attempts = process.platform === "darwin" ? [["cp", "-cR", root, path], ["cp", "-R", root, path]] : [["cp", "-a", "--reflink=auto", root, path]];
27
+ let reason = "the workspace could not be copied";
28
+ for (const argv of attempts) {
29
+ if (signal?.aborted) { await dispose(); return { ok: false, reason: "cancelled" }; }
30
+ await rm(path, { recursive: true, force: true });
31
+ const child = Bun.spawn(argv, { stdout: "ignore", stderr: "pipe" });
32
+ const timer = setTimeout(() => child.kill(), CLONE_TIMEOUT_MS);
33
+ const abort = () => child.kill();
34
+ signal?.addEventListener("abort", abort, { once: true });
35
+ const [exit, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()]);
36
+ clearTimeout(timer); signal?.removeEventListener("abort", abort);
37
+ if (exit === 0) return { ok: true, path, dispose };
38
+ reason = child.signalCode ? `copying the workspace exceeded ${CLONE_TIMEOUT_MS / 1000}s` : `the workspace could not be copied: ${stderr.trim().split("\n").at(-1) ?? `exit ${exit}`}`;
39
+ }
40
+ await dispose();
41
+ return { ok: false, reason };
42
+ }
@@ -1,32 +1,18 @@
1
1
  /**
2
- * Workspace scanning, reconciliation and restore.
2
+ * A bounded content scan of a workspace: relative path -> digest, and one
3
+ * version digest over the whole set.
3
4
  *
4
- * The tree can move a cursor between nodes; this is what lets the files move
5
- * with it. Two properties shape everything here:
6
- *
7
- * TURN GRANULARITY. The workspace is scanned once per turn, not per tool
8
- * call. Per-call is too fine to be meaningful -- the documented criticism
9
- * of character-level versioning is that a single keystroke is not a change
10
- * -- and a turn is the same unit as "a message and the edit it produced".
11
- *
12
- * TWO INDEPENDENT SOURCES. A tool's effect says what the tool CLAIMED to
13
- * do; a scan says what ACTUALLY changed. bash can write anywhere and its
14
- * effect records only an exit code, so without the scan an edit outside
15
- * every declared path would be invisible -- and a restore would silently
16
- * fail to undo it. Comparing the two makes that detectable, which is the
17
- * same rule the handoff applies to the model: claims are checked, not
18
- * believed.
19
- *
20
- * A restore moves the workspace and nothing else. Migrations, sent requests
21
- * and started processes do not come back, and applyRestore says so rather
22
- * than implying it undid the world.
5
+ * Session file history no longer comes from per-turn scans. P.3 replaced them
6
+ * with journaled checkpoints and source fingerprints (workspace-observation.ts,
7
+ * WorkspaceLifecycle), and S.3 removed the compatibility mode that still
8
+ * scanned. What remains is the scanner itself and the default ignore list the
9
+ * search and file index share.
23
10
  */
24
11
 
25
- import { readdir, rm, stat, lstat } from "node:fs/promises";
12
+ import { readdir, stat } from "node:fs/promises";
26
13
  import type { Dirent } from "node:fs";
27
- import { join, dirname, relative, sep, isAbsolute } from "node:path";
28
- import type { Effect } from "../core/events.ts";
29
- import { blobAddress, digestFile, verifyBlob, restoreBlob } from "./blobs.ts";
14
+ import { join, relative, sep } from "node:path";
15
+ import { blobAddress, digestFile } from "./blobs.ts";
30
16
 
31
17
  export interface ScanLimits {
32
18
  maxFiles: number;
@@ -68,28 +54,6 @@ export type ScanResult =
68
54
  | { ok: true; files: FileSet }
69
55
  | { ok: false; error: string };
70
56
 
71
- export interface ScanDelta {
72
- added: FileSet;
73
- changed: FileSet;
74
- removed: string[];
75
- }
76
-
77
- export interface Reconciliation {
78
- /** Changed on disk with no tool claiming it -- usually a shell command. */
79
- undeclared: string[];
80
- /** A tool claimed an edit the disk does not show. */
81
- declaredButUnchanged: string[];
82
- }
83
-
84
- export interface RestoreResult {
85
- restored: string[];
86
- removed: string[];
87
- /** Target files whose content is no longer in the blob store. */
88
- unrecoverable: string[];
89
- /** What this restore did not and could not touch. */
90
- note: string;
91
- }
92
-
93
57
  function isIgnored(rel: string, ignore: readonly string[]): boolean {
94
58
  const parts = rel.split(sep);
95
59
  return ignore.some((i) => parts.includes(i));
@@ -149,114 +113,3 @@ export async function scanWorkspace(
149
113
  return error ? { ok: false, error } : { ok: true, files };
150
114
  } catch (error) { return { ok: false, error: `incomplete workspace scan: ${String(error)}` }; }
151
115
  }
152
-
153
- export function diffScans(before: FileSet, after: FileSet): ScanDelta {
154
- const added: FileSet = {};
155
- const changed: FileSet = {};
156
- const removed: string[] = [];
157
-
158
- for (const [path, sha] of Object.entries(after)) {
159
- const was = before[path];
160
- if (was === undefined) added[path] = sha;
161
- else if (was !== sha) changed[path] = sha;
162
- }
163
- for (const path of Object.keys(before)) {
164
- if (!(path in after)) removed.push(path);
165
- }
166
- return { added, changed, removed: removed.sort() };
167
- }
168
-
169
- export function reconcile(effects: readonly Effect[], observed: ScanDelta): Reconciliation {
170
- // Only edits claim to change the workspace. A read claims nothing, and a
171
- // command claims only its exit code -- which is exactly why the scan is
172
- // needed to see what a command did.
173
- const declared = new Set(
174
- effects.filter((e) => e.kind === "edit").map((e) => e.path),
175
- );
176
- const touched = [
177
- ...Object.keys(observed.added),
178
- ...Object.keys(observed.changed),
179
- ...observed.removed,
180
- ];
181
-
182
- return {
183
- undeclared: touched.filter((p) => !declared.has(p)).sort(),
184
- declaredButUnchanged: [...declared].filter((p) => !touched.includes(p)).sort(),
185
- };
186
- }
187
-
188
- /**
189
- * Replays deltas up to and including `toNode` to get the file set at that
190
- * point. Pure, so a restore can be planned and inspected before anything is
191
- * written.
192
- */
193
- export function planRestore(
194
- deltas: readonly (ScanDelta & { node: number })[],
195
- toNode: number,
196
- ): FileSet {
197
- const files: FileSet = {};
198
- for (const d of deltas) {
199
- if (d.node > toNode) break;
200
- for (const [path, sha] of Object.entries(d.added)) files[path] = sha;
201
- for (const [path, sha] of Object.entries(d.changed)) files[path] = sha;
202
- for (const path of d.removed) delete files[path];
203
- }
204
- return files;
205
- }
206
-
207
- export async function applyRestore(
208
- root: string,
209
- store: string,
210
- target: FileSet,
211
- ignore: readonly string[],
212
- ): Promise<RestoreResult> {
213
- const restored: string[] = [];
214
- const unrecoverable: string[] = [];
215
-
216
- // Validate the complete plan before its first write or deletion. Missing
217
- // objects and a failed scan cannot be interpreted as an empty target (H).
218
- const current = await scanWorkspace(root, ignore);
219
- if (!current.ok) throw new Error(current.error);
220
- for (const [path, sha] of Object.entries(target)) {
221
- if (isAbsolute(path) || path.split(/[\\/]/).some((p) => p === "..") || isIgnored(path, ignore)) {
222
- throw new Error(`unsafe restore path: ${path}`);
223
- }
224
- let parent = dirname(join(root, path));
225
- while (parent !== root && parent.startsWith(root + sep)) {
226
- try { if ((await lstat(parent)).isSymbolicLink()) throw new Error(`restore path crosses symlink: ${path}`); }
227
- catch (error) { if ((error as { code?: string }).code !== "ENOENT") throw error; }
228
- parent = dirname(parent);
229
- }
230
- if (!await verifyBlob(store, sha)) unrecoverable.push(path);
231
- }
232
- if (unrecoverable.length > 0) return {
233
- restored: [], removed: [], unrecoverable: unrecoverable.sort(),
234
- note: "Restore not applied: missing snapshot objects. Workspace and cursor remain unchanged.",
235
- };
236
-
237
- for (const [path, sha] of Object.entries(target)) {
238
- const abs = join(root, path);
239
- await restoreBlob(store, sha, abs);
240
- restored.push(path);
241
- }
242
-
243
- // Anything present but not in the target was created after this point.
244
- const removed: string[] = [];
245
- if (current.ok) {
246
- for (const path of Object.keys(current.files)) {
247
- if (path in target) continue;
248
- await rm(join(root, path), { force: true });
249
- removed.push(path);
250
- }
251
- }
252
-
253
- return {
254
- restored: restored.sort(),
255
- removed: removed.sort(),
256
- unrecoverable: unrecoverable.sort(),
257
- note:
258
- "Restored the workspace only. Anything outside it is not undone: " +
259
- "database migrations, requests already sent, processes already " +
260
- "started, and files outside the workspace root all remain as they are.",
261
- };
262
- }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Elapsed time as people read it (feat-sidebar-goal-and-speed): `59s`, then
3
+ * `1m 00s`, then `1h 01m 01s`. The steps are Codex's fmt_elapsed_compact; a
4
+ * running turn used to read `3725s`.
5
+ *
6
+ * Every field after the first is two digits, so a running clock keeps its
7
+ * width within a unit instead of jittering the labels beside it. Parts of a
8
+ * second never round up: the clock shows seconds that have fully passed.
9
+ */
10
+ export function formatElapsed(ms: number): string {
11
+ const total = Number.isFinite(ms) && ms > 0 ? Math.floor(ms / 1000) : 0;
12
+ if (total < 60) return `${total}s`;
13
+ const seconds = String(total % 60).padStart(2, "0");
14
+ if (total < 3600) return `${Math.floor(total / 60)}m ${seconds}s`;
15
+ return `${Math.floor(total / 3600)}h ${String(Math.floor(total % 3600 / 60)).padStart(2, "0")}m ${seconds}s`;
16
+ }
@@ -48,7 +48,7 @@ export function screenLayout(cols: number, rows: number, sidebarWanted = true):
48
48
  return { tier: "bare", sidebarWidth: 0, mainWidth: c, topbar: false };
49
49
  }
50
50
 
51
- export const STATUS_FIELDS = ["mode", "provider", "activity", "git", "model", "thinking", "context", "title", "workspace", "branch", "usage", "cost", "handoff", "tasks", "files", "background", "resources", "extension"] as const;
51
+ export const STATUS_FIELDS = ["mode", "provider", "activity", "git", "model", "thinking", "context", "goal", "title", "workspace", "branch", "usage", "cost", "handoff", "tasks", "files", "background", "resources", "extension"] as const;
52
52
  export type StatusField = (typeof STATUS_FIELDS)[number];
53
53
  /** Screen surfaces only; the terminal title is outside the frame and listed separately. */
54
54
  export type Place = "sidebar" | "topbar" | "topEdge" | "bottomEdge" | "hidden";
@@ -61,13 +61,13 @@ export type Place = "sidebar" | "topbar" | "topEdge" | "bottomEdge" | "hidden";
61
61
  export function placement(tier: Tier): Readonly<Record<StatusField, Place>> {
62
62
  switch (tier) {
63
63
  case "sidebar":
64
- return { mode: "bottomEdge", provider: "topbar", activity: "topEdge", git: "sidebar", model: "bottomEdge", thinking: "bottomEdge", context: "sidebar", title: "sidebar", workspace: "sidebar", branch: "sidebar",
64
+ return { mode: "bottomEdge", provider: "topbar", activity: "topEdge", git: "sidebar", model: "bottomEdge", thinking: "bottomEdge", context: "sidebar", goal: "sidebar", title: "sidebar", workspace: "sidebar", branch: "sidebar",
65
65
  usage: "sidebar", cost: "sidebar", handoff: "sidebar", tasks: "sidebar", files: "sidebar", background: "sidebar", resources: "sidebar", extension: "sidebar" };
66
66
  case "topbar":
67
- return { mode: "bottomEdge", provider: "topbar", activity: "topEdge", git: "hidden", model: "bottomEdge", thinking: "bottomEdge", context: "bottomEdge", title: "topbar", workspace: "topbar", branch: "topbar",
67
+ return { mode: "bottomEdge", provider: "topbar", activity: "topEdge", git: "hidden", model: "bottomEdge", thinking: "bottomEdge", context: "bottomEdge", goal: "bottomEdge", title: "topbar", workspace: "topbar", branch: "topbar",
68
68
  usage: "hidden", cost: "topbar", handoff: "topbar", tasks: "hidden", files: "hidden", background: "topbar", resources: "hidden", extension: "topbar" };
69
69
  case "bare":
70
- return { mode: "bottomEdge", provider: "hidden", activity: "topEdge", git: "hidden", model: "bottomEdge", thinking: "bottomEdge", context: "bottomEdge", title: "hidden", workspace: "hidden", branch: "hidden",
70
+ return { mode: "bottomEdge", provider: "hidden", activity: "topEdge", git: "hidden", model: "bottomEdge", thinking: "bottomEdge", context: "bottomEdge", goal: "bottomEdge", title: "hidden", workspace: "hidden", branch: "hidden",
71
71
  usage: "hidden", cost: "hidden", handoff: "hidden", tasks: "hidden", files: "hidden", background: "hidden", resources: "hidden", extension: "hidden" };
72
72
  default: return assertNever(tier, "placement");
73
73
  }
@@ -31,8 +31,9 @@ import { screenLayout, sidebarContentWidth, type ScreenLayout } from "./layout.t
31
31
  import { greetingLines, mascotFrame, MASCOT_FRAME_MS, MASCOT_FRAMES, type Greeting } from "./mascot.ts";
32
32
  import { MessageList, type MessageFeatures } from "./messages.ts";
33
33
  import { Motion } from "./motion.ts";
34
- import { contextLabel, modeLabels, sidebarLines, topbarRow, type Line, type StatusModel } from "./status.ts";
34
+ import { contextLabel, goalLabel, modeLabels, sidebarLines, speedText, topbarRow, type Line, type StatusModel } from "./status.ts";
35
35
  import { DEFAULT_CELL, stripImageRows } from "./image-size.ts";
36
+ import { formatElapsed } from "./duration.ts";
36
37
  import { Pictures, type PictureSource } from "./pictures.ts";
37
38
  import type { ImageView, ViewItem } from "./items.ts";
38
39
 
@@ -376,11 +377,14 @@ export class Screen {
376
377
  // to recover on the existing edge, without putting control content in it.
377
378
  left = [{ segs: [{ text: "Resize for control", role: "accent" }], priority: 0, min: 1 }];
378
379
  } else if (d.busy) {
379
- const seconds = Math.max(0, Math.floor((Date.now() - this.busySince) / 1000));
380
+ const elapsed = formatElapsed(Date.now() - this.busySince);
380
381
  const activity = this.status?.activity;
381
382
  const label = activity ? `${activity.label}${activity.label === "Running tool" || activity.label === "Retrying" ? ` · ${activity.detail}` : ""}${activity.queued ? ` · ${activity.queued} queued` : ""}` : d.activity;
382
383
  const transient = this.transientActivity ? ` · ${this.transientActivityText()}` : "";
383
- left = [{ segs: [{ text: "●", role: "accent", spin: activity?.state !== "blocked" }, { text: ` ${label}${transient}`, role: activity?.state === "blocked" ? "warning" : "muted" }, { text: ` ${seconds}s`, role: "muted" }], priority: 0, min: 1, shrink: 1 }];
384
+ left = [{ segs: [{ text: "●", role: "accent", spin: activity?.state !== "blocked" }, { text: ` ${label}${transient}`, role: activity?.state === "blocked" ? "warning" : "muted" }, { text: ` ${elapsed}`, role: "muted" }], priority: 0, min: 1, shrink: 1 }];
385
+ // The turn's speed so far, refreshed after each model request (runtime/usage.ts generationSpeed).
386
+ const speed = this.status?.usage?.speed;
387
+ if (speed !== undefined) right.push({ segs: [{ text: speedText(speed), role: "muted" }], priority: 3 });
384
388
  right.push({ segs: [{ text: "esc interrupt", role: "muted" }], priority: 4 });
385
389
  } else if (this.transientActivity) {
386
390
  left = [{ segs: [{ text: "●", role: "accent", spin: true }, { text: ` ${this.transientActivityText()}`, role: "muted" }], priority: 0, min: 1, shrink: 1 }];
@@ -396,8 +400,10 @@ export class Screen {
396
400
  { segs: [{ text: d.model, role: "text" as const, bold: true }], priority: 1, min: 8 },
397
401
  ...(d.thinking ? [{ segs: [{ text: d.thinking, role: "muted" as const }], priority: 2 }] : []),
398
402
  ] : [])];
403
+ // Without a sidebar the goal and the context share ride on this edge (layout.ts placement).
399
404
  const context = this.shape.tier !== "sidebar" && this.status ? contextLabel(this.status) : undefined;
400
- this.bottomEdge.setRows([edgeLine({ width, corners: ["╰", "╯"], role, left: bottomLeft, right: context ? [context] : [] })]);
405
+ const goal = this.shape.tier !== "sidebar" && this.status ? goalLabel(this.status) : undefined;
406
+ this.bottomEdge.setRows([edgeLine({ width, corners: ["╰", "╯"], role, left: bottomLeft, right: [...(goal ? [goal] : []), ...(context ? [context] : [])] })]);
401
407
  }
402
408
 
403
409
  /** A stream event arrived (delta, tool progress, shell output): the spinner returns to full speed. */
@@ -426,9 +432,9 @@ export class Screen {
426
432
 
427
433
  private transientActivityText(): string {
428
434
  const activity = this.transientActivity!;
429
- const elapsed = Math.max(0, Math.floor((Date.now() - activity.startedAt) / 1000));
430
- const deadline = Math.max(1, Math.ceil(activity.timeoutMs / 1000));
431
- return `${activity.label} · ${elapsed}s / ${deadline}s max${activity.detail ? ` · ${activity.detail}` : ""}`;
435
+ // The limit is a whole number of seconds, rounded up so it is never shown shorter than it is.
436
+ const deadline = Math.max(1, Math.ceil(activity.timeoutMs / 1000)) * 1000;
437
+ return `${activity.label} · ${formatElapsed(Date.now() - activity.startedAt)} / ${formatElapsed(deadline)} max${activity.detail ? ` · ${activity.detail}` : ""}`;
432
438
  }
433
439
 
434
440
  /** The spinner's current interval (tests). */