@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.
- package/CHANGELOG.md +60 -0
- package/README.md +36 -9
- package/docs/foundations.md +1 -1
- package/package.json +3 -1
- package/src/apps/cli.ts +4 -3
- package/src/apps/commands.ts +8 -0
- package/src/apps/goal-flow.ts +70 -0
- package/src/apps/render.ts +0 -2
- package/src/apps/session-hub.ts +6 -5
- package/src/apps/session-lifecycle.ts +64 -0
- package/src/apps/shell.ts +89 -34
- package/src/apps/startup.ts +32 -31
- package/src/apps/ui/app.ts +105 -13
- package/src/apps/ui/control-model.ts +18 -1
- package/src/apps/ui/surface.ts +2 -0
- package/src/apps/update-check.ts +1 -1
- package/src/core/acceptance.ts +41 -9
- package/src/core/blind-checks.ts +72 -0
- package/src/core/branch-receipt.ts +3 -1
- package/src/core/checkpoints.ts +21 -0
- package/src/core/churn.ts +2 -2
- package/src/core/completion-review.ts +18 -11
- package/src/core/continuation.ts +11 -10
- package/src/core/control-view.ts +5 -8
- package/src/core/deadline.ts +2 -1
- package/src/core/derive.ts +0 -7
- package/src/core/environment.ts +2 -2
- package/src/core/event-validation.ts +19 -6
- package/src/core/events.ts +41 -32
- package/src/core/evidence-ladder.ts +62 -0
- package/src/core/evidence-links.ts +0 -10
- package/src/core/evidence.ts +1 -2
- package/src/core/export.ts +6 -11
- package/src/core/goal-view.ts +64 -0
- package/src/core/goal.ts +200 -0
- package/src/core/history.ts +1 -11
- package/src/core/json-event.ts +0 -6
- package/src/core/outcome.ts +0 -1
- package/src/core/program-status.ts +11 -2
- package/src/core/progress.ts +14 -8
- package/src/core/project.ts +6 -16
- package/src/core/read-bounds.ts +11 -2
- package/src/core/read-observation.ts +24 -0
- package/src/core/request-budget.ts +13 -3
- package/src/core/request-state.ts +178 -19
- package/src/core/response-recovery.ts +15 -2
- package/src/core/session-entry.ts +8 -3
- package/src/core/session-exit.ts +19 -3
- package/src/core/session-format.ts +169 -0
- package/src/core/settings.ts +7 -27
- package/src/core/stall.ts +30 -7
- package/src/core/stop-report.ts +4 -3
- package/src/core/transcript.ts +22 -2
- package/src/runtime/blind-check.ts +36 -0
- package/src/runtime/counter-check.ts +74 -0
- package/src/runtime/exec-sessions.ts +11 -0
- package/src/runtime/foundation.ts +46 -8
- package/src/runtime/loop.ts +239 -430
- package/src/runtime/oauth.ts +1 -3
- package/src/runtime/openai.ts +2 -2
- package/src/runtime/pi-provider.ts +3 -3
- package/src/runtime/provider-request.ts +2 -1
- package/src/runtime/provider.ts +33 -3
- package/src/runtime/retry.ts +9 -2
- package/src/runtime/runtime-target.ts +2 -2
- package/src/runtime/session-control.ts +4 -4
- package/src/runtime/tools.ts +44 -42
- package/src/runtime/usage.ts +28 -0
- package/src/runtime/version.ts +12 -0
- package/src/runtime/workspace-lifecycle.ts +31 -0
- package/src/store/blind-checks.ts +32 -0
- package/src/store/checkpoint-history.ts +1 -12
- package/src/store/file-index.ts +88 -7
- package/src/store/log.ts +25 -27
- package/src/store/recovery.ts +1 -3
- package/src/store/replay.ts +2 -2
- package/src/store/restore.ts +10 -202
- package/src/store/search.ts +7 -5
- package/src/store/session-archive.ts +0 -1
- package/src/store/sessions.ts +31 -33
- package/src/store/workspace-clone.ts +42 -0
- package/src/store/workspace.ts +10 -157
- package/src/tui/ui/duration.ts +16 -0
- package/src/tui/ui/layout.ts +4 -4
- package/src/tui/ui/screen.ts +13 -7
- package/src/tui/ui/status.ts +99 -17
- package/src/core/benchmark.ts +0 -195
- package/src/core/evaluation-coverage.ts +0 -74
- package/src/core/experiment.ts +0 -173
- package/src/core/harness-comparison.ts +0 -90
- package/src/core/harness-diagnostics.ts +0 -206
- package/src/core/native-eval.ts +0 -25
- package/src/core/policy-evaluation.ts +0 -55
- package/src/core/query-eval-contract.ts +0 -14
- package/src/core/recall-analysis.ts +0 -90
- package/src/core/recall-trial.ts +0 -153
- package/src/core/recovery-corpus.ts +0 -17
- package/src/core/recovery-evaluation.ts +0 -60
- package/src/core/tool-mode-protocol.ts +0 -25
- package/src/core/trial-lifecycle.ts +0 -14
- package/src/core/window-trial.ts +0 -65
- package/src/index.ts +0 -10
- package/src/runtime/benchmark.ts +0 -187
- package/src/runtime/harness-comparison.ts +0 -376
- package/src/runtime/harness-eval.ts +0 -462
- package/src/runtime/native-eval-fixtures.ts +0 -33
- package/src/runtime/native-eval.ts +0 -146
- package/src/runtime/query-eval-verifier.ts +0 -28
- package/src/runtime/recall-trial.ts +0 -226
- package/src/runtime/recovery-coverage.ts +0 -71
- package/src/runtime/recovery-evidence.ts +0 -76
- package/src/runtime/recovery-fixtures.ts +0 -116
- package/src/runtime/tool-mode-eval.ts +0 -146
- package/src/runtime/trial-control.ts +0 -45
- package/src/runtime/window-trial.ts +0 -141
- package/src/store/git-branch.ts +0 -35
- package/src/tui/ui/testing.ts +0 -5
package/src/store/sessions.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
|
353
|
+
return { logDir, created: true };
|
|
373
354
|
}
|
|
374
355
|
case "open": {
|
|
375
|
-
if (entry.explicit) return { logDir: entry.target, created: false
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|
package/src/store/workspace.ts
CHANGED
|
@@ -1,32 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* A bounded content scan of a workspace: relative path -> digest, and one
|
|
3
|
+
* version digest over the whole set.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
* with
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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,
|
|
12
|
+
import { readdir, stat } from "node:fs/promises";
|
|
26
13
|
import type { Dirent } from "node:fs";
|
|
27
|
-
import { join,
|
|
28
|
-
import
|
|
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
|
+
}
|
package/src/tui/ui/layout.ts
CHANGED
|
@@ -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
|
}
|
package/src/tui/ui/screen.ts
CHANGED
|
@@ -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
|
|
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: ` ${
|
|
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.
|
|
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
|
-
|
|
430
|
-
const deadline = Math.max(1, Math.ceil(activity.timeoutMs / 1000));
|
|
431
|
-
return `${activity.label} · ${
|
|
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). */
|