@visual-z/xeno 0.8.1 → 0.8.2
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 +24 -0
- package/docs/install.md +25 -0
- package/package.json +1 -1
- package/src/apps/cli.ts +4 -1
- package/src/apps/startup.ts +27 -2
- package/src/core/activity-status.ts +10 -1
- package/src/core/command-contract.ts +79 -5
- package/src/core/completion-review.ts +1 -1
- package/src/core/environment.ts +7 -0
- package/src/core/history.ts +7 -1
- package/src/core/permissions.ts +4 -3
- package/src/core/prompt-policy.ts +9 -2
- package/src/core/regex-dialect.ts +25 -0
- package/src/core/ripgrep-release.ts +64 -0
- package/src/core/stall.ts +63 -18
- package/src/core/stop-report.ts +21 -1
- package/src/core/tool-contract.ts +2 -0
- package/src/core/tool-failure.ts +17 -4
- package/src/core/update-check.ts +2 -1
- package/src/runtime/exec-sessions.ts +3 -1
- package/src/runtime/loop.ts +98 -12
- package/src/runtime/tools.ts +46 -8
- package/src/store/command.ts +3 -1
- package/src/store/file-checkpoints.ts +6 -3
- package/src/store/search.ts +9 -5
- package/src/store/tool-binaries.ts +90 -0
- package/src/store/worktree.ts +55 -10
- package/src/tui/ui/screen.ts +6 -3
- package/src/tui/ui/status.ts +16 -0
package/src/store/worktree.ts
CHANGED
|
@@ -20,8 +20,10 @@
|
|
|
20
20
|
* is for cooperative branches, not adversarial ones.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
import { mkdtemp, rm, realpath } from "node:fs/promises";
|
|
24
|
-
import {
|
|
23
|
+
import { mkdir, mkdtemp, rm, realpath } from "node:fs/promises";
|
|
24
|
+
import { existsSync } from "node:fs";
|
|
25
|
+
import { createHash } from "node:crypto";
|
|
26
|
+
import { basename, join } from "node:path";
|
|
25
27
|
import { tmpdir } from "node:os";
|
|
26
28
|
|
|
27
29
|
/** Branch prefix, so a stale worktree is identifiable months later. */
|
|
@@ -37,6 +39,37 @@ export type CreateResult =
|
|
|
37
39
|
| { ok: true; path: string; branch: string; snapshot: { head: string; tree: string } }
|
|
38
40
|
| { ok: false; error: string };
|
|
39
41
|
|
|
42
|
+
export interface CreateOptions {
|
|
43
|
+
/**
|
|
44
|
+
* Where the checkout goes. Default: a fresh temp directory, which suits a
|
|
45
|
+
* writing branch that lives for one subtask. A model-entered worktree is
|
|
46
|
+
* kept across sessions, so it names a stable parent and its own leaf.
|
|
47
|
+
*/
|
|
48
|
+
location?: { parent: string; leaf: string };
|
|
49
|
+
/** Carry the parent's uncommitted files (default, as a branch needs). */
|
|
50
|
+
includeChanges?: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The stable home of model-entered worktrees for one repository.
|
|
55
|
+
*
|
|
56
|
+
* Under xeno's config directory rather than the project: inside `.xeno` it
|
|
57
|
+
* would collide with the rule that session state is never edited, and a
|
|
58
|
+
* sibling directory leaves xeno's checkouts in the user's project tree (Codex
|
|
59
|
+
* keeps its worktrees under its own home for the same reason). The digest of
|
|
60
|
+
* the repository path keeps two clones with the same name apart.
|
|
61
|
+
*/
|
|
62
|
+
export function persistentWorktreeParent(configDir: string, repo: string): string {
|
|
63
|
+
const digest = createHash("sha256").update(repo).digest("hex").slice(0, 8);
|
|
64
|
+
return join(configDir, "worktrees", `${basename(repo)}-${digest}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The repository's main checkout: `git worktree list` always lists it first. */
|
|
68
|
+
export async function mainWorktree(dir: string): Promise<string | null> {
|
|
69
|
+
const listed = await listWorktrees(dir).catch(() => []);
|
|
70
|
+
return listed[0]?.path ?? null;
|
|
71
|
+
}
|
|
72
|
+
|
|
40
73
|
export type RemoveResult =
|
|
41
74
|
| { ok: true }
|
|
42
75
|
| { ok: false; error: string; dirty: boolean };
|
|
@@ -61,7 +94,7 @@ export async function isGitRepo(dir: string): Promise<boolean> {
|
|
|
61
94
|
return git(dir, ["rev-parse", "--git-dir"]).code === 0;
|
|
62
95
|
}
|
|
63
96
|
|
|
64
|
-
export async function createWorktree(repo: string, name: string): Promise<CreateResult> {
|
|
97
|
+
export async function createWorktree(repo: string, name: string, options: CreateOptions = {}): Promise<CreateResult> {
|
|
65
98
|
// Refused rather than degraded. Falling back to the shared directory is the
|
|
66
99
|
// exact failure isolation exists to prevent: parallel writing branches
|
|
67
100
|
// would overwrite each other with nothing to indicate isolation was never
|
|
@@ -107,13 +140,24 @@ export async function createWorktree(repo: string, name: string): Promise<Create
|
|
|
107
140
|
} finally { await rm(scratch, { recursive: true, force: true }); }
|
|
108
141
|
|
|
109
142
|
const branch = `${WORKTREE_BRANCH_PREFIX}${name}`;
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
143
|
+
if (git(repo, ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`]).code === 0) {
|
|
144
|
+
return { ok: false, error: `branch ${branch} already exists; choose another name or work in its existing checkout` };
|
|
145
|
+
}
|
|
146
|
+
let path: string;
|
|
147
|
+
if (options.location) {
|
|
148
|
+
await mkdir(options.location.parent, { recursive: true });
|
|
149
|
+
// Resolved for the same reason as the temp path below.
|
|
150
|
+
path = join(await realpath(options.location.parent), options.location.leaf);
|
|
151
|
+
if (existsSync(path)) return { ok: false, error: `${path} already exists; choose another name` };
|
|
152
|
+
} else {
|
|
153
|
+
const created = await mkdtemp(join(tmpdir(), `xeno-wt-${name}-`));
|
|
154
|
+
// Resolved through symlinks, because git reports the real path and a
|
|
155
|
+
// caller comparing the two would never match. On macOS /var is a symlink
|
|
156
|
+
// to /private/var, so every temp path differs by default.
|
|
157
|
+
path = await realpath(created);
|
|
158
|
+
// mkdtemp created it; git worktree add needs the path not to exist.
|
|
159
|
+
await rm(path, { recursive: true, force: true });
|
|
160
|
+
}
|
|
117
161
|
|
|
118
162
|
// --lock in the same call: locking afterwards leaves a window in which a
|
|
119
163
|
// concurrent prune can remove the worktree that was just created.
|
|
@@ -121,6 +165,7 @@ export async function createWorktree(repo: string, name: string): Promise<Create
|
|
|
121
165
|
if (add.code !== 0) {
|
|
122
166
|
return { ok: false, error: `git worktree add failed: ${add.stderr.trim()}` };
|
|
123
167
|
}
|
|
168
|
+
if (options.includeChanges === false) return { ok: true, path, branch, snapshot: { head: head.stdout.trim(), tree: git(path, ["rev-parse", "HEAD^{tree}"]).stdout.trim() } };
|
|
124
169
|
// Only this freshly created disposable checkout is reset. The child index
|
|
125
170
|
// carries the parent's uncommitted input, so normal diff/inspection still sees it.
|
|
126
171
|
const hydrate = git(path, ["read-tree", "--reset", "-u", tree]);
|
package/src/tui/ui/screen.ts
CHANGED
|
@@ -31,7 +31,7 @@ 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, goalLabel, modeLabels, sidebarLines, speedText, topbarRow, type Line, type StatusModel } from "./status.ts";
|
|
34
|
+
import { contextLabel, goalLabel, idleActivityLabel, modeLabels, sidebarLines, speedText, topbarRow, type Line, type StatusModel } from "./status.ts";
|
|
35
35
|
import { DEFAULT_CELL, stripImageRows } from "./image-size.ts";
|
|
36
36
|
import { formatElapsed } from "./duration.ts";
|
|
37
37
|
import { Pictures, type PictureSource } from "./pictures.ts";
|
|
@@ -105,6 +105,8 @@ export class Screen {
|
|
|
105
105
|
private readonly activity = new Motion();
|
|
106
106
|
private spin = 0;
|
|
107
107
|
private busySince = 0;
|
|
108
|
+
/** The last turn's wall time, shown beside its outcome until the next turn starts. */
|
|
109
|
+
private lastTurnMs: number | undefined;
|
|
108
110
|
private lastEvent = 0;
|
|
109
111
|
private activityMs = ACTIVITY_MS;
|
|
110
112
|
private animations = true;
|
|
@@ -337,7 +339,8 @@ export class Screen {
|
|
|
337
339
|
setDock(state: DockState): void {
|
|
338
340
|
const wasBusy = this.dockState.busy;
|
|
339
341
|
this.dockState = state;
|
|
340
|
-
if (state.busy && !wasBusy) this.busySince = Date.now();
|
|
342
|
+
if (state.busy && !wasBusy) { this.busySince = Date.now(); this.lastTurnMs = undefined; }
|
|
343
|
+
if (!state.busy && wasBusy) this.lastTurnMs = Date.now() - this.busySince;
|
|
341
344
|
this.syncActivity();
|
|
342
345
|
this.paintEdges();
|
|
343
346
|
}
|
|
@@ -393,7 +396,7 @@ export class Screen {
|
|
|
393
396
|
const { above, below } = this.composer.overflow;
|
|
394
397
|
if (above) left = [{ segs: [{ text: `↑ ${above} more`, role: "muted" }], priority: 1 }];
|
|
395
398
|
if (below && !this.toastText) right.push({ segs: [{ text: `↓ ${below} more`, role: "muted" }], priority: 2 });
|
|
396
|
-
if (!above && this.status?.activity) left = [
|
|
399
|
+
if (!above && this.status?.activity) left = [idleActivityLabel(this.status.activity, this.lastTurnMs)];
|
|
397
400
|
}
|
|
398
401
|
this.topEdge.setRows([edgeLine({ width, corners: ["╭", "╮"], role, left, right })]);
|
|
399
402
|
const bottomLeft: Label[] = [...(this.status ? modeLabels(this.status) : []), ...(d.model ? [
|
package/src/tui/ui/status.ts
CHANGED
|
@@ -385,3 +385,19 @@ export function wrap(text: string, width: number, lines: number): string[] {
|
|
|
385
385
|
kept[lines - 1] = `${displayWidth(last) + 1 <= w ? last : cells(last, w - 1).trimEnd()}…`;
|
|
386
386
|
return kept;
|
|
387
387
|
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* The input box's top-edge label while no turn runs: the outcome in its own
|
|
391
|
+
* colour, then how long the last turn took (Codex "Worked for 2m 5s", Claude
|
|
392
|
+
* Code "Cooked for 1m 6s"). A plain grey "Turn finished" read the same as
|
|
393
|
+
* "nothing happened" (fix-finished-turn-status).
|
|
394
|
+
*/
|
|
395
|
+
export function idleActivityLabel(activity: NonNullable<StatusModel["activity"]>, turnMs?: number): Label {
|
|
396
|
+
const tone = activity.state === "done" ? { glyph: "✓", role: "success" as const }
|
|
397
|
+
: activity.state === "error" ? { glyph: "✗", role: "error" as const }
|
|
398
|
+
: activity.state === "blocked" ? { glyph: activity.label === "Stopped" ? "■" : "", role: "warning" as const }
|
|
399
|
+
: { glyph: "", role: "muted" as const };
|
|
400
|
+
const text = activity.label === "Stopped" && activity.detail ? `Stopped · ${activity.detail}` : activity.label;
|
|
401
|
+
return { segs: [...(tone.glyph ? [{ text: tone.glyph, role: tone.role }, { text: " ", role: "muted" as const }] : []), { text, role: tone.role },
|
|
402
|
+
...(turnMs !== undefined ? [{ text: ` · ${formatElapsed(turnMs)}`, role: "muted" as const }] : [])], priority: 1, min: 4, shrink: tone.glyph ? 2 : 0 };
|
|
403
|
+
}
|