@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/core/stop-report.ts
CHANGED
|
@@ -19,8 +19,28 @@ export function stopReport(events: readonly LogEvent[], requests: number, limit:
|
|
|
19
19
|
return [
|
|
20
20
|
`Stopped: ${exitLine(cause)} (${limit === Infinity ? requests : `${requests}/${limit}`} model requests).`,
|
|
21
21
|
`Recorded evidence: ${results.length} tool receipts; ${exits} successful command exits; ${failures.length} failed tool receipts. Task completion is not verified by this stop.`,
|
|
22
|
-
...(failures.length ? [`
|
|
22
|
+
...(failures.length ? [`Failed tools: ${failedTools(current, failures)}.`] : []),
|
|
23
|
+
...(finalReplyGiven(current) ? ["A final reply was given before this stop; read it above — this stop does not mark it wrong."] : []),
|
|
23
24
|
...(running.length ? [`Last observed running: ${running.slice(0, 3).join(", ")}${running.length > 3 ? ` (+${running.length - 3})` : ""}; inspect the process receipt before replaying a command.`] : []),
|
|
24
25
|
"Next: inspect /state or the recorded tool output, then provide a focused continuation; completed commands should not be replayed just to recover their output.",
|
|
25
26
|
].join("\n");
|
|
26
27
|
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The user reads this line; no command looks a receipt up by id, so ids told
|
|
31
|
+
* them nothing (fix-stop-report-readable). Tool names say where it went wrong.
|
|
32
|
+
*/
|
|
33
|
+
function failedTools(events: readonly LogEvent[], failures: readonly Extract<LogEvent, { t: "tool_result" }>[]): string {
|
|
34
|
+
const names = new Map<string, string>();
|
|
35
|
+
for (const event of events) if (event.t === "assistant") for (const block of event.blocks) if (block.type === "tool_use") names.set(block.call_id, block.name);
|
|
36
|
+
const counts = new Map<string, number>();
|
|
37
|
+
for (const failure of failures) { const name = names.get(failure.call_id) ?? "unknown"; counts.set(name, (counts.get(name) ?? 0) + 1); }
|
|
38
|
+
const ranked = [...counts].sort(([a, x], [b, y]) => y - x || (a < b ? -1 : a > b ? 1 : 0));
|
|
39
|
+
return ranked.slice(0, 3).map(([name, count]) => `${name} ×${count}`).join(", ") + (ranked.length > 3 ? ` (+${ranked.length - 3} more)` : "");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A stop after the model already answered must not read as "the task failed". */
|
|
43
|
+
function finalReplyGiven(events: readonly LogEvent[]): boolean {
|
|
44
|
+
return events.some(event => event.t === "assistant" && !event.blocks.some(block => block.type === "tool_use") &&
|
|
45
|
+
event.blocks.some(block => block.type === "text" && block.text.trim().length > 0));
|
|
46
|
+
}
|
|
@@ -86,6 +86,8 @@ const SYNONYMS: Readonly<Record<string, readonly string[]>> = {
|
|
|
86
86
|
cmd: ["command"], script: ["command"],
|
|
87
87
|
text: ["content"], contents: ["content"], old_str: ["old_string"], new_str: ["new_string"],
|
|
88
88
|
query: ["pattern"], regex: ["pattern"], timeout_ms: ["timeout"], offset: ["start"], limit: ["end", "limit"],
|
|
89
|
+
// Different units from max_output_tokens, so a hint rather than a silent conversion.
|
|
90
|
+
max_output_chars: ["max_output_tokens"], max_output_bytes: ["max_output_tokens"], max_tokens: ["max_output_tokens"],
|
|
89
91
|
};
|
|
90
92
|
|
|
91
93
|
/** The candidate nearest to `name`, when it is near enough to be the likely intent. */
|
package/src/core/tool-failure.ts
CHANGED
|
@@ -21,26 +21,39 @@ const guidance: Record<ToolDiagnostic["code"], string> = {
|
|
|
21
21
|
command_failed: "Use the actual exit and output to diagnose the command; a nonzero exit alone does not identify its cause.",
|
|
22
22
|
};
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* The refusal for `.git` and `.xeno` paths. It is a fixed rule protecting the
|
|
26
|
+
* session's own evidence, not a user policy, so the generic denial ("a user
|
|
27
|
+
* policy change is required") would send the model to ask for a setting that
|
|
28
|
+
* does not exist -- which is what a real session did before this text.
|
|
29
|
+
*/
|
|
30
|
+
export const STATE_PATH_GUIDANCE = "This path is repository or xeno session state, which edit tools never change; no setting changes this. " +
|
|
31
|
+
"To work in an isolated checkout, use the worktree tool instead of creating one under .git or .xeno.";
|
|
32
|
+
const STATE_PATH_GUIDANCE_WITHOUT_WORKTREE = "This path is repository or xeno session state, which edit tools never change; no setting changes this. " +
|
|
33
|
+
"Edit files outside .git and .xeno.";
|
|
34
|
+
|
|
24
35
|
export function failureDiagnostic(code: ToolDiagnostic["code"], message: string, facts: FailureFacts,
|
|
25
|
-
extra: Pick<ToolDiagnostic, "recover" | "example"> = {}): ToolDiagnostic {
|
|
36
|
+
extra: Pick<ToolDiagnostic, "recover" | "example" | "next"> = {}): ToolDiagnostic {
|
|
26
37
|
const retry = code === "permission_denied" || code === "cancelled" ? "never"
|
|
27
38
|
: code === "unknown_effect" || facts.effects === "possible" ? "after_inspection"
|
|
28
39
|
: code === "transient" ? "backoff" : "after_change";
|
|
29
|
-
|
|
40
|
+
const { next = guidance[code], ...rest } = extra;
|
|
41
|
+
return { code, message, ...facts, retry, next, ...(code === "permission_denied" ? {} : rest) };
|
|
30
42
|
}
|
|
31
43
|
|
|
32
44
|
/** R10: only new advice is scoped; execution facts and historical records are never rewritten. */
|
|
33
45
|
export function scopeFailureGuidance(diagnostic: ToolDiagnostic, tools: readonly string[], recoverAllowed = true): ToolDiagnostic {
|
|
34
46
|
const { recover, ...facts } = diagnostic;
|
|
35
47
|
const next = diagnostic.execution !== undefined && diagnostic.code === "environment_missing" && !tools.includes("environment")
|
|
36
|
-
? { next: "Verify the working directory and required executable in the current executor using permitted observations. If the needed capability is unavailable, report that evidence gap before retrying." }
|
|
48
|
+
? { next: "Verify the working directory and required executable in the current executor using permitted observations. If the needed capability is unavailable, report that evidence gap before retrying." }
|
|
49
|
+
: diagnostic.next === STATE_PATH_GUIDANCE && !tools.includes("worktree") ? { next: STATE_PATH_GUIDANCE_WITHOUT_WORKTREE } : {};
|
|
37
50
|
return { ...facts, ...next, ...(recover && recoverAllowed && tools.includes(recover.tool) && diagnostic.code !== "permission_denied" ? { recover } : {}) };
|
|
38
51
|
}
|
|
39
52
|
|
|
40
53
|
/** Runtime and store throw only facts they own; the outer boundary must retain these facts. */
|
|
41
54
|
export class ToolFault extends Error {
|
|
42
55
|
readonly diagnostic: ToolDiagnostic;
|
|
43
|
-
constructor(code: ToolDiagnostic["code"], message: string, facts: FailureFacts, extra: Pick<ToolDiagnostic, "recover"> = {}) {
|
|
56
|
+
constructor(code: ToolDiagnostic["code"], message: string, facts: FailureFacts, extra: Pick<ToolDiagnostic, "recover" | "next"> = {}) {
|
|
44
57
|
super(message); this.name = "ToolFault";
|
|
45
58
|
this.diagnostic = failureDiagnostic(code, message, facts, extra);
|
|
46
59
|
}
|
package/src/core/update-check.ts
CHANGED
|
@@ -46,7 +46,8 @@ export interface UpdatePlan {
|
|
|
46
46
|
readonly notice?: string;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
/** Shared by every background network opt-out, so one variable means the same everywhere. */
|
|
50
|
+
export function truthy(value: string | undefined): boolean {
|
|
50
51
|
return value !== undefined && value !== "" && value !== "0" && value.toLowerCase() !== "false";
|
|
51
52
|
}
|
|
52
53
|
|
|
@@ -23,6 +23,8 @@ import { join } from "node:path";
|
|
|
23
23
|
import { randomUUID } from "node:crypto";
|
|
24
24
|
import { commandArgv, commandEnvironment, COMMAND_TERMINATION_GRACE_MS } from "../core/command-contract.ts";
|
|
25
25
|
import { archiveObservation, type ArchivedObservation } from "../store/observations.ts";
|
|
26
|
+
import { configDir } from "../store/config.ts";
|
|
27
|
+
import { xenoBinDir } from "../store/tool-binaries.ts";
|
|
26
28
|
import { ToolFault, NOT_STARTED } from "../core/tool-failure.ts";
|
|
27
29
|
import { processReceipt, retainProcessReceipts, type ProcessReceipt, type ProcessSource } from "../core/process-receipt.ts";
|
|
28
30
|
import type { CommandArtifactObserver } from "./command-artifacts.ts";
|
|
@@ -170,7 +172,7 @@ export async function startSession(input: {
|
|
|
170
172
|
await mkdir(input.spoolDir, { recursive: true });
|
|
171
173
|
const spoolPath = join(input.spoolDir, `.${randomUUID()}.session`);
|
|
172
174
|
const spool = await open(spoolPath, "w+", 0o600);
|
|
173
|
-
const env = commandEnvironment(process.env);
|
|
175
|
+
const env = commandEnvironment(process.env, xenoBinDir(configDir()));
|
|
174
176
|
let resolveExit!: () => void;
|
|
175
177
|
const exited = new Promise<void>((resolve) => { resolveExit = resolve; });
|
|
176
178
|
const session = {
|
package/src/runtime/loop.ts
CHANGED
|
@@ -42,8 +42,9 @@ import {
|
|
|
42
42
|
} from "../core/context-policy.ts";
|
|
43
43
|
import { retainedContext, selectRecentContext } from "../core/context-window.ts";
|
|
44
44
|
import { advanceStall, emptyStallState, stallDecision, DEFAULT_STALL_LIMIT, type StallState } from "../core/stall.ts";
|
|
45
|
+
import { questionRequest, validateQuestionReply } from "../core/question.ts";
|
|
45
46
|
import { DEFAULT_OUTPUT_RECOVERY_LIMIT, isCompleteResponse, incompleteToolText, knownUnexecutedTool, outputRecoveryNotice, validateOutputRecoveryLimit } from "../core/response-recovery.ts";
|
|
46
|
-
import { join, resolve } from "node:path";
|
|
47
|
+
import { join, relative, resolve } from "node:path";
|
|
47
48
|
import { randomUUID } from "node:crypto";
|
|
48
49
|
import { pendingToolCalls } from "../core/execution.ts";
|
|
49
50
|
import { declaredToolNames, recordedDeclarations } from "../core/tool-discovery.ts";
|
|
@@ -91,7 +92,7 @@ import { FailedResponseError, type Provider, type Turn, type StreamDelta } from
|
|
|
91
92
|
import { putBlob, getBlob } from "../store/blobs.ts";
|
|
92
93
|
import { hydrateProcessReceipts } from "../store/process-receipts.ts";
|
|
93
94
|
import { readNodeHistory } from "../store/restore.ts";
|
|
94
|
-
import { createWorktree, removeWorktree } from "../store/worktree.ts";
|
|
95
|
+
import { createWorktree, listWorktrees, mainWorktree, persistentWorktreeParent, removeWorktree } from "../store/worktree.ts";
|
|
95
96
|
import {
|
|
96
97
|
parseNote, matchGate, branchTools, finishToolResult, prepareNormalizedTool, presentObservation, toolDefinition, modelToolSchema, adviceTools,
|
|
97
98
|
BRANCH_TOOL_NAMES, WRITING_BRANCH_TOOL_NAMES,
|
|
@@ -113,6 +114,7 @@ import { changedPaths, invertFileChanges, sameVersion, type CheckpointEvent, typ
|
|
|
113
114
|
import type { GitCheckpoint } from "../store/git-checkpoints.ts";
|
|
114
115
|
import { readEditableFile, preflightChanges, applyFileChanges } from "../store/file-checkpoints.ts";
|
|
115
116
|
import { planCheckpointRestore } from "../store/checkpoint-history.ts";
|
|
117
|
+
import { configDir } from "../store/config.ts";
|
|
116
118
|
|
|
117
119
|
/** Ids and timestamps come from here so the loop stays the only impure part. */
|
|
118
120
|
export interface Clock {
|
|
@@ -380,6 +382,8 @@ export class Session {
|
|
|
380
382
|
private recovery: RecoveryState | null = null;
|
|
381
383
|
private processWake = new AbortController();
|
|
382
384
|
private pendingAdoption: number | null = null;
|
|
385
|
+
/** A worktree enter/exit moves the root only once its tool round is paired, like adoption. */
|
|
386
|
+
private pendingRoot: string | null = null;
|
|
383
387
|
private readonly allocatedEventIds = new Set<string>();
|
|
384
388
|
private recordTail: Promise<void> = Promise.resolve();
|
|
385
389
|
private readonly recordListeners = new Set<(item: CommittedItem) => void>();
|
|
@@ -1337,8 +1341,8 @@ export class Session {
|
|
|
1337
1341
|
return;
|
|
1338
1342
|
}
|
|
1339
1343
|
|
|
1340
|
-
if (yield* this.checkStall()) {
|
|
1341
|
-
yield { kind: "done", reason: await report("
|
|
1344
|
+
if (yield* this.checkStall(request, signal)) {
|
|
1345
|
+
yield { kind: "done", reason: await report("repeated tool cycle without new evidence", turn), outcome: executionOutcome("stalled") };
|
|
1342
1346
|
return;
|
|
1343
1347
|
}
|
|
1344
1348
|
if (phase === "finalize" && !supportsToolDisabling(this.provider.config.spec.api)) {
|
|
@@ -1633,6 +1637,7 @@ export class Session {
|
|
|
1633
1637
|
// granularity the research warns against.
|
|
1634
1638
|
const declarationNode = this.state.node;
|
|
1635
1639
|
if (!signal?.aborted && await this.applyDiscoveredTools()) yield { kind: "cut", node: declarationNode, reason: "model_checkpoint" };
|
|
1640
|
+
if (this.pendingRoot !== null) await this.applyPendingRoot();
|
|
1636
1641
|
if (this.pendingAdoption !== null) await this.adoptPendingBranch();
|
|
1637
1642
|
|
|
1638
1643
|
// Between complete exchanges is the only safe place to cut.
|
|
@@ -1931,7 +1936,7 @@ export class Session {
|
|
|
1931
1936
|
const forkSkill = call.name === "read" && typeof call.input === "object" && call.input !== null ? this.config.skills?.find(skill =>
|
|
1932
1937
|
skill.context === "fork" && skill.filePath === resolve(ctx.root, String((call.input as Record<string, unknown>)["path"]))) : undefined;
|
|
1933
1938
|
const discovery = call.name === DISCOVERY_TOOL_NAME && ctx.extensions?.get(call.name) === DISCOVERY_TOOL;
|
|
1934
|
-
if (!forkSkill && !discovery && !["note", "branch", "resolve", "history", "target"].includes(call.name)) return prepareNormalizedTool(ctx, call.name, call.input);
|
|
1939
|
+
if (!forkSkill && !discovery && !["note", "branch", "resolve", "history", "target", "worktree"].includes(call.name)) return prepareNormalizedTool(ctx, call.name, call.input);
|
|
1935
1940
|
const branchIndex = forkSkill || call.name === "branch" ? nextBranch() : 0;
|
|
1936
1941
|
return prepareNormalizedTool(ctx, call.name, call.input, async () => {
|
|
1937
1942
|
if (discovery) return async () => searchDeferredTools(ctx, call.input as Record<string, unknown>, this.allowedNames());
|
|
@@ -1950,6 +1955,7 @@ export class Session {
|
|
|
1950
1955
|
return { ...prepared.out, ...(prepared.annotation ? { annotation: prepared.annotation } : {}) };
|
|
1951
1956
|
};
|
|
1952
1957
|
if (call.name === "resolve") return () => this.runResolve(call.input);
|
|
1958
|
+
if (call.name === "worktree") return () => this.runWorktree(call.input, ctx.pathRoot);
|
|
1953
1959
|
if (forkSkill?.disableModelInvocation) return async () => ({ ok: false, content: "this skill requires explicit user invocation" });
|
|
1954
1960
|
if (forkSkill) await this.recordSkill(forkSkill, "fork", "model");
|
|
1955
1961
|
const task = this.state.events.findLast(event => event.t === "user");
|
|
@@ -2163,6 +2169,50 @@ export class Session {
|
|
|
2163
2169
|
};
|
|
2164
2170
|
}
|
|
2165
2171
|
|
|
2172
|
+
private async runWorktree(input: unknown, pathRoot?: string): Promise<ToolResult> {
|
|
2173
|
+
const args = (typeof input === "object" && input !== null ? input : {}) as Record<string, unknown>;
|
|
2174
|
+
const root = this.config.tools.root;
|
|
2175
|
+
// A branch already runs in its own checkout and never owns the parent's root.
|
|
2176
|
+
if ((this.config.branchDepth ?? 0) > 0) return { content: "worktree is unavailable inside a branch; the branch already has its own checkout", ok: false };
|
|
2177
|
+
if (this.pendingRoot !== null) return { content: "one worktree switch per tool round; the previous one applies after this round", ok: false };
|
|
2178
|
+
const main = await mainWorktree(root);
|
|
2179
|
+
if (main === null) return { content: `${root} is not inside a git repository, so there is nothing to make a worktree of`, ok: false };
|
|
2180
|
+
const outside = (path: string) => pathRoot !== undefined && relative(pathRoot, path).startsWith("..");
|
|
2181
|
+
if (args["action"] === "exit") {
|
|
2182
|
+
if (main === root) return { content: `already in the main checkout ${main}; nothing to exit`, ok: false };
|
|
2183
|
+
if (outside(main)) return { content: `the main checkout ${main} is outside --path-root ${pathRoot}`, ok: false };
|
|
2184
|
+
const branch = (await listWorktrees(main)).find(wt => wt.path === root)?.branch ?? null;
|
|
2185
|
+
this.pendingRoot = main;
|
|
2186
|
+
return { ok: true, content: [`main ${main}`, `kept ${root}${branch ? ` on ${branch.replace(/^refs\/heads\//, "")}` : ""}`,
|
|
2187
|
+
"The session root moves to the main checkout after this tool round. Nothing was merged or deleted; " +
|
|
2188
|
+
`remove the worktree yourself with \`git worktree remove ${root}\` once its branch is merged or no longer needed.`].join("\n") };
|
|
2189
|
+
}
|
|
2190
|
+
if (args["action"] !== "enter") return { content: "worktree action must be enter or exit", ok: false };
|
|
2191
|
+
const name = typeof args["name"] === "string" ? args["name"] : "";
|
|
2192
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(name) || name.includes("..")) {
|
|
2193
|
+
return { content: "worktree name must be 1-64 letters, digits, dot, dash or underscore, starting with a letter or digit", ok: false };
|
|
2194
|
+
}
|
|
2195
|
+
const parent = persistentWorktreeParent(configDir(), main);
|
|
2196
|
+
if (outside(parent)) return { content: `xeno's worktree directory ${parent} is outside --path-root ${pathRoot}`, ok: false };
|
|
2197
|
+
const created = await createWorktree(root, name, { location: { parent, leaf: name }, includeChanges: args["include_changes"] === true });
|
|
2198
|
+
if (!created.ok) return { content: `refused: ${created.error}`, ok: false };
|
|
2199
|
+
this.pendingRoot = created.path;
|
|
2200
|
+
return { ok: true, content: [`worktree ${created.path}`, `branch ${created.branch}`, `from ${created.snapshot.head}`,
|
|
2201
|
+
`The session root moves there after this tool round; relative paths, edits and commands then resolve in it. ` +
|
|
2202
|
+
`The working directory in the system prompt still names ${root}. ` +
|
|
2203
|
+
(args["include_changes"] === true ? "It starts from the current uncommitted files." : "It starts from HEAD; uncommitted files in the main checkout were not copied.")].join("\n"),
|
|
2204
|
+
data: { path: created.path, branch: created.branch, head: created.snapshot.head } };
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
private async applyPendingRoot(): Promise<void> {
|
|
2208
|
+
const root = this.pendingRoot;
|
|
2209
|
+
this.pendingRoot = null;
|
|
2210
|
+
if (root === null) return;
|
|
2211
|
+
this.config.tools.root = root;
|
|
2212
|
+
this.installWorkspace();
|
|
2213
|
+
await this.saveCursor();
|
|
2214
|
+
}
|
|
2215
|
+
|
|
2166
2216
|
private async runResolve(input: unknown): Promise<ToolResult> {
|
|
2167
2217
|
const args = (typeof input === "object" && input !== null ? input : {}) as Record<string, unknown>;
|
|
2168
2218
|
const node = Number(args["node"]);
|
|
@@ -2555,7 +2605,14 @@ export class Session {
|
|
|
2555
2605
|
return recoveryReview(this.recovery, tools);
|
|
2556
2606
|
}
|
|
2557
2607
|
|
|
2558
|
-
|
|
2608
|
+
/**
|
|
2609
|
+
* S.8: a repeated cycle is pointed out to the model once; the next one is
|
|
2610
|
+
* the user's call when a question client can answer -- continue, stop, or
|
|
2611
|
+
* new instructions -- and ends the request only without one (headless) or
|
|
2612
|
+
* when the user stops it. The answer is recorded as an event, never kept
|
|
2613
|
+
* as loop state: a continue is a `stall` guidance, instructions are steering.
|
|
2614
|
+
*/
|
|
2615
|
+
private async *checkStall(request: string, signal?: AbortSignal): AsyncGenerator<LoopEvent, boolean> {
|
|
2559
2616
|
const limit = this.config.stallLimit ?? DEFAULT_STALL_LIMIT;
|
|
2560
2617
|
if (limit === 0) return false;
|
|
2561
2618
|
if (this.stall === null) {
|
|
@@ -2564,13 +2621,42 @@ export class Session {
|
|
|
2564
2621
|
this.stall = advanceStall(emptyStallState(), history.nodes.flatMap(({ node, events }) => events.map((event) => ({ node, event }))));
|
|
2565
2622
|
}
|
|
2566
2623
|
const decision = stallDecision(this.stall, limit);
|
|
2567
|
-
if (decision.
|
|
2568
|
-
|
|
2569
|
-
|
|
2624
|
+
if (decision.cycle === null) return false;
|
|
2625
|
+
const pattern = decision.cycle === 1 ? "the same tool call" : `a cycle of ${decision.cycle} tool calls`;
|
|
2626
|
+
const facts = `${pattern} repeated ${decision.repeats} times without new evidence (${decision.failures} failed receipts; ${decision.emptyWaits} empty waits)`;
|
|
2627
|
+
if (decision.warn) {
|
|
2628
|
+
await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), reason: "stall", sources: [],
|
|
2629
|
+
text: `Harness: ${facts}. Change the method, run a meaningful check, or give the final result with unresolved limits. If the pattern repeats again, the user decides whether the request continues.` });
|
|
2630
|
+
return false;
|
|
2570
2631
|
}
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2632
|
+
const answer = await this.askAboutCycle(facts, signal);
|
|
2633
|
+
if (answer?.kind === "continue") {
|
|
2634
|
+
await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), reason: "stall", sources: [],
|
|
2635
|
+
text: `Harness: the user reviewed this (${facts}) and chose to continue. Do not repeat the same calls expecting a different result; change the method or finish with the result and its limits.` });
|
|
2636
|
+
return false;
|
|
2637
|
+
}
|
|
2638
|
+
if (answer?.kind === "steer") {
|
|
2639
|
+
await this.record({ t: "user", id: this.clock.nextId(), at: this.clock.now(), request, text: answer.text });
|
|
2640
|
+
return false;
|
|
2641
|
+
}
|
|
2642
|
+
yield { kind: "stop_and_ask", reason: `${facts}; change the approach or provide new input` };
|
|
2643
|
+
return true;
|
|
2644
|
+
}
|
|
2645
|
+
|
|
2646
|
+
/** Undefined means nobody answered: no question client (headless), cancelled, or an invalid reply. */
|
|
2647
|
+
private async askAboutCycle(facts: string, signal?: AbortSignal): Promise<{ kind: "continue" } | { kind: "stop" } | { kind: "steer"; text: string } | undefined> {
|
|
2648
|
+
const slot = this.config.tools.questionSlot, adapter = slot?.current, turn = slot?.activeTurn;
|
|
2649
|
+
if (!adapter || !turn || signal?.aborted) return undefined;
|
|
2650
|
+
const request = questionRequest(turn, `stall-${this.clock.nextId()}`, [{ id: "stall", header: "Repeated tool calls",
|
|
2651
|
+
question: `The agent ${facts}. Continue, stop, or type instructions for it?`, multiple: false, custom: true,
|
|
2652
|
+
options: [{ id: "continue", label: "Continue", description: "Let the agent keep working; it is told to change its method." },
|
|
2653
|
+
{ id: "stop", label: "Stop", description: "End this request; the log and evidence stay." }] }]);
|
|
2654
|
+
try {
|
|
2655
|
+
const reply = validateQuestionReply(request, await adapter(structuredClone(request), signal ?? new AbortController().signal));
|
|
2656
|
+
const [answer] = reply.answers, text = answer?.text?.trim();
|
|
2657
|
+
if (text) return { kind: "steer", text };
|
|
2658
|
+
return answer?.choices[0] === "continue" ? { kind: "continue" } : answer?.choices[0] === "stop" ? { kind: "stop" } : undefined;
|
|
2659
|
+
} catch { return undefined; }
|
|
2574
2660
|
}
|
|
2575
2661
|
|
|
2576
2662
|
/** A steering signal releases only the wait; cancellation owns process termination. */
|
package/src/runtime/tools.ts
CHANGED
|
@@ -33,7 +33,7 @@ import {
|
|
|
33
33
|
import { recoverProcessReceipt, type ProcessReceipt, type latestProcessReceipt } from "../core/process-receipt.ts";
|
|
34
34
|
import { observeCommand, observeArgvCommand } from "../store/command.ts";
|
|
35
35
|
import { executionOutcome, type ExecutionOutcome } from "../core/outcome.ts";
|
|
36
|
-
import { commandCompletion, commandTimeout } from "../core/command-contract.ts";
|
|
36
|
+
import { commandCompletion, commandTimeout, negativeAnswer, outputBytesForTokens } from "../core/command-contract.ts";
|
|
37
37
|
import { linesWithin, previewReadObservation, readObservationText, type ObservationReceipt } from "../core/read-observation.ts";
|
|
38
38
|
import { checkFilePrecondition, isFileDigest, isFileReadDescriptor, type FileReadDescriptor, type FilePrecondition } from "../core/file-precondition.ts";
|
|
39
39
|
import { previewSearchObservation, searchObservationText } from "../core/search-observation.ts";
|
|
@@ -571,6 +571,34 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
|
571
571
|
required: ["node", "resolution"],
|
|
572
572
|
},
|
|
573
573
|
},
|
|
574
|
+
{
|
|
575
|
+
/**
|
|
576
|
+
* The model's way into an isolated checkout of this repository.
|
|
577
|
+
*
|
|
578
|
+
* Edits are confined to the session root, so isolation the model builds
|
|
579
|
+
* itself with `git worktree add` is unusable: outside the root is refused,
|
|
580
|
+
* and inside `.xeno` is session state. This creates the checkout where
|
|
581
|
+
* xeno keeps them and moves the root after the tool round, the way an
|
|
582
|
+
* adopted branch does; the cursor records the root, so resume stays there.
|
|
583
|
+
*/
|
|
584
|
+
name: "worktree",
|
|
585
|
+
access: "state", execution: "exclusive", branch: "none",
|
|
586
|
+
example: { action: "enter", name: "fix-parser" },
|
|
587
|
+
description:
|
|
588
|
+
"enter: create a git worktree of this repository on a new branch xeno/<name> from HEAD, kept under xeno's config directory, " +
|
|
589
|
+
"and move this session's root there after the current tool round, so later edits and commands run in the isolated checkout. " +
|
|
590
|
+
"Use it when the shared working tree has changes by others or must stay untouched. include_changes copies the current uncommitted files. " +
|
|
591
|
+
"exit: move the root back to the repository's main checkout. The worktree and its branch are kept; nothing is merged or deleted.",
|
|
592
|
+
input_schema: {
|
|
593
|
+
type: "object",
|
|
594
|
+
properties: {
|
|
595
|
+
action: { type: "string", enum: ["enter", "exit"] },
|
|
596
|
+
name: { type: "string", description: "enter: letters, digits, dot, dash or underscore; becomes the branch xeno/<name>" },
|
|
597
|
+
include_changes: { type: "boolean", description: "enter: start from the current uncommitted files instead of HEAD; default false" },
|
|
598
|
+
},
|
|
599
|
+
required: ["action"],
|
|
600
|
+
},
|
|
601
|
+
},
|
|
574
602
|
{
|
|
575
603
|
name: "bash",
|
|
576
604
|
example: { command: "bun test", workdir: ".", timeout: 300 },
|
|
@@ -631,6 +659,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
|
631
659
|
workdir: { type: "string", description: "Directory to run in; default the workspace root" },
|
|
632
660
|
tty: { type: "boolean", description: "Run in a pseudo-terminal; default false" },
|
|
633
661
|
yield_time_ms: { type: "integer", description: "Milliseconds to wait before returning; default 10000; effective range 100-30000, values outside are clamped" },
|
|
662
|
+
max_output_tokens: { type: "integer", minimum: 1, description: "Lower this call's inline output preview to about this many tokens; full output stays recoverable" },
|
|
634
663
|
timeout: { type: "integer", minimum: 1, maximum: 3600, description: "Seconds before the session is killed; default 300" },
|
|
635
664
|
},
|
|
636
665
|
required: ["cmd"],
|
|
@@ -651,6 +680,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
|
651
680
|
session_id: { type: "integer", minimum: 1 },
|
|
652
681
|
chars: { type: "string", description: "Characters to write; empty to poll" },
|
|
653
682
|
yield_time_ms: { type: "integer", description: "Bounded wait when sending input or using low-level polling; effective range 100-30000, values outside are clamped; agent empty waits are owned until an observation changes" },
|
|
683
|
+
max_output_tokens: { type: "integer", minimum: 1, description: "Lower this call's inline output preview to about this many tokens; full output stays recoverable" },
|
|
654
684
|
},
|
|
655
685
|
required: ["session_id"],
|
|
656
686
|
},
|
|
@@ -663,7 +693,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
|
663
693
|
description:
|
|
664
694
|
"Search file contents for a regex. Stays inside the workspace, does " +
|
|
665
695
|
"not follow symlinks, and skips .git, node_modules and .xeno. Uses " +
|
|
666
|
-
"ripgrep when it is
|
|
696
|
+
"ripgrep when it is on PATH or in xeno's bin directory, otherwise a TypeScript walk. " +
|
|
667
697
|
"Reports scan completeness separately from preview truncation. Long matches and omitted results remain in an immutable history artifact; use its sha256 and byte offset to recover them. Unknown or partial coverage cannot prove absence.",
|
|
668
698
|
input_schema: {
|
|
669
699
|
type: "object",
|
|
@@ -1040,7 +1070,7 @@ async function toolBash(ctx: ToolContext, input: Record<string, unknown>): Promi
|
|
|
1040
1070
|
const observation = await commandFingerprint(ctx);
|
|
1041
1071
|
const header = commandHeader({ exit, wallMs: captured.wallMs, timeoutMs,
|
|
1042
1072
|
...(requestedMs !== undefined ? { requestedMs } : {}),
|
|
1043
|
-
...(captured.stopped ? { stopped: captured.stopped } : {}) });
|
|
1073
|
+
...(captured.stopped ? { stopped: captured.stopped } : {}) }) + answerLabel(command, exit, captured.stopped);
|
|
1044
1074
|
const artifactSummary = commandArtifactSummary(artifacts);
|
|
1045
1075
|
|
|
1046
1076
|
return {
|
|
@@ -1049,7 +1079,7 @@ async function toolBash(ctx: ToolContext, input: Record<string, unknown>): Promi
|
|
|
1049
1079
|
content: header + artifactSummary + "\n" + observationView(captured,
|
|
1050
1080
|
ctx.maxOutputBytes - utf8Length(header + artifactSummary) - 1, captured.prefixBytes, adviceTools(ctx)),
|
|
1051
1081
|
content_ref: captured.ref,
|
|
1052
|
-
...commandCompletion(exit, captured.stopped, captured.cleanup),
|
|
1082
|
+
...commandCompletion(exit, captured.stopped, captured.cleanup, command),
|
|
1053
1083
|
data: { exit, output: captured.ref, ...(captured.cleanup ? { cleanup: "unknown" } : {}), ...(artifacts ? { artifacts } : {}) },
|
|
1054
1084
|
effect: {
|
|
1055
1085
|
kind: "command",
|
|
@@ -1090,7 +1120,8 @@ async function toolExec(ctx: ToolContext, input: Record<string, unknown>): Promi
|
|
|
1090
1120
|
...(ctx.execOwner === undefined ? {} : { owner: ctx.execOwner }) });
|
|
1091
1121
|
if ("error" in session) return failure("execution_failed", session.error);
|
|
1092
1122
|
const wait = yieldWait(input["yield_time_ms"]);
|
|
1093
|
-
|
|
1123
|
+
const view = { ...ctx, maxOutputBytes: outputBytesForTokens(input["max_output_tokens"], ctx.maxOutputBytes) };
|
|
1124
|
+
return sessionResult(view, command, timeoutMs, await pollSession(session, outputStore, view.maxOutputBytes, wait.ms, "", ctx.signal), wait.note);
|
|
1094
1125
|
}
|
|
1095
1126
|
|
|
1096
1127
|
async function toolWriteStdin(ctx: ToolContext, input: Record<string, unknown>): Promise<ToolResult> {
|
|
@@ -1109,7 +1140,8 @@ async function toolWriteStdin(ctx: ToolContext, input: Record<string, unknown>):
|
|
|
1109
1140
|
}
|
|
1110
1141
|
const outputStore = ctx.outputStore ?? join(ctx.root, ".xeno", "observations");
|
|
1111
1142
|
const wait = yieldWait(input["yield_time_ms"]);
|
|
1112
|
-
|
|
1143
|
+
const view = { ...ctx, maxOutputBytes: outputBytesForTokens(input["max_output_tokens"], ctx.maxOutputBytes) };
|
|
1144
|
+
return sessionResult(view, session.command, session.timeoutMs, await pollSession(session, outputStore, view.maxOutputBytes, wait.ms, chars, ctx.signal,
|
|
1113
1145
|
ctx.processWait && chars.length === 0 ? { wake: ctx.processWait.wake(), onWaiting: active => ctx.onProcessWait?.(id, active) } : undefined), wait.note);
|
|
1114
1146
|
}
|
|
1115
1147
|
|
|
@@ -1139,7 +1171,7 @@ export async function sessionResult(ctx: ToolContext, command: string, timeoutMs
|
|
|
1139
1171
|
? `running · session ${id} · ${(taken.wallMs / 1000).toFixed(1)}s elapsed; ` + (adviceTools(ctx).includes("write_stdin")
|
|
1140
1172
|
? `poll or send input with write_stdin(session_id=${id})`
|
|
1141
1173
|
: "process-control capability unavailable; execution remains unfinished; do not replay")
|
|
1142
|
-
: commandHeader({ exit, wallMs: taken.wallMs, timeoutMs, managed: true, ...(taken.stopped ? { stopped: taken.stopped } : {}) }) + ` · session ${id} closed
|
|
1174
|
+
: commandHeader({ exit, wallMs: taken.wallMs, timeoutMs, managed: true, ...(taken.stopped ? { stopped: taken.stopped } : {}) }) + ` · session ${id} closed` + answerLabel(command, exit, taken.stopped);
|
|
1143
1175
|
const header = shown + waitNote;
|
|
1144
1176
|
const artifactSummary = commandArtifactSummary(taken.receipt.artifacts);
|
|
1145
1177
|
const content = header + artifactSummary + "\n" + observationView(stored, ctx.maxOutputBytes - utf8Length(header + artifactSummary) - 1, 0, adviceTools(ctx));
|
|
@@ -1150,7 +1182,7 @@ export async function sessionResult(ctx: ToolContext, command: string, timeoutMs
|
|
|
1150
1182
|
const gate = matchGate(ctx, command);
|
|
1151
1183
|
const observation = await commandFingerprint(ctx);
|
|
1152
1184
|
return {
|
|
1153
|
-
...commandCompletion(exit, taken.stopped),
|
|
1185
|
+
...commandCompletion(exit, taken.stopped, undefined, command),
|
|
1154
1186
|
content, content_ref: stored.ref, content_sha256: stored.ref.sha256,
|
|
1155
1187
|
data: taken.receipt, ...(warnings.length ? { warnings } : {}),
|
|
1156
1188
|
effect: { kind: "command", cmd: command, exit, ...(gate !== undefined ? { gate } : {}),
|
|
@@ -1158,6 +1190,12 @@ export async function sessionResult(ctx: ToolContext, command: string, timeoutMs
|
|
|
1158
1190
|
};
|
|
1159
1191
|
}
|
|
1160
1192
|
|
|
1193
|
+
/** The program's own meaning of exit 1 (grep: no match), appended to the header; the exit itself stays. */
|
|
1194
|
+
function answerLabel(command: string, exit: number, stopped?: "cancelled" | "timeout"): string {
|
|
1195
|
+
const meaning = stopped === undefined ? negativeAnswer(command, exit) : null;
|
|
1196
|
+
return meaning === null ? "" : ` · ${meaning}`;
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1161
1199
|
const utf8Length = (text: string) => new TextEncoder().encode(text).length;
|
|
1162
1200
|
|
|
1163
1201
|
/**
|
package/src/store/command.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/** O.1: own the process group, preserve partial observations, and drain before returning. */
|
|
2
2
|
import { captureCommandObservation } from "./observations.ts";
|
|
3
|
+
import { configDir } from "./config.ts";
|
|
4
|
+
import { xenoBinDir } from "./tool-binaries.ts";
|
|
3
5
|
import { commandArgv, commandEnvironment, COMMAND_TERMINATION_GRACE_MS } from "../core/command-contract.ts";
|
|
4
6
|
import { ToolFault, NOT_STARTED, failureDiagnostic } from "../core/tool-failure.ts";
|
|
5
7
|
|
|
@@ -44,7 +46,7 @@ async function executeCaptured(input: CommandCaptureInput, argv: string[], nativ
|
|
|
44
46
|
cwd: input.cwd, stdin: "ignore", stdout: "pipe", stderr: "pipe", detached: true,
|
|
45
47
|
// Preserve the trusted native API's inherited environment; shell tools
|
|
46
48
|
// retain their explicit noninteractive policy and login-shell startup.
|
|
47
|
-
env: native ? process.env : commandEnvironment(process.env),
|
|
49
|
+
env: native ? process.env : commandEnvironment(process.env, xenoBinDir(configDir())),
|
|
48
50
|
}); } catch (error) {
|
|
49
51
|
const os = (error as { code?: string }).code;
|
|
50
52
|
throw new ToolFault(os === "ENOENT" || os === "ENOTDIR" ? "environment_missing" : os === "EACCES" || os === "EPERM" ? "permission_denied" : "execution_failed", String(error), NOT_STARTED);
|
|
@@ -4,12 +4,15 @@ import { resolve, relative, dirname, join, sep } from "node:path";
|
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
5
|
import { blobAddress, putBlob, getBlobBytes, verifyBlob } from "./blobs.ts";
|
|
6
6
|
import { sameVersion, changedPaths, type FileVersion, type FileChanges } from "../core/checkpoints.ts";
|
|
7
|
-
import { ToolFault, NOT_STARTED, READ_ATTEMPTED, failureFromError } from "../core/tool-failure.ts";
|
|
7
|
+
import { ToolFault, NOT_STARTED, READ_ATTEMPTED, STATE_PATH_GUIDANCE, failureFromError } from "../core/tool-failure.ts";
|
|
8
8
|
|
|
9
9
|
export async function editablePath(root: string, path: string): Promise<string> {
|
|
10
10
|
const base = resolve(root), absolute = resolve(base, path), rel = relative(base, absolute);
|
|
11
|
-
if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || resolve(rel) === rel
|
|
12
|
-
|
|
11
|
+
if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || resolve(rel) === rel) throw new ToolFault("permission_denied", `unsafe file checkpoint path: ${path}`, NOT_STARTED);
|
|
12
|
+
// Session logs and blobs are the evidence; an edit there could rewrite it.
|
|
13
|
+
if (rel.split(sep).some(part => part === ".git" || part === ".xeno")) {
|
|
14
|
+
throw new ToolFault("permission_denied", `unsafe file checkpoint path: ${path} is inside .git or .xeno, which edit tools never change`, NOT_STARTED, { next: STATE_PATH_GUIDANCE });
|
|
15
|
+
}
|
|
13
16
|
let current = base;
|
|
14
17
|
for (const part of rel.split(sep)) {
|
|
15
18
|
current = join(current, part);
|
package/src/store/search.ts
CHANGED
|
@@ -18,9 +18,12 @@ import { lstat, readdir, readFile } from "node:fs/promises";
|
|
|
18
18
|
import { isAbsolute, join, relative, sep } from "node:path";
|
|
19
19
|
import { Worker } from "node:worker_threads";
|
|
20
20
|
import { assertNever } from "../core/exhaustive.ts";
|
|
21
|
+
import { jsPatternFromRipgrep } from "../core/regex-dialect.ts";
|
|
21
22
|
import { buildFileIndex, gitIgnored } from "./file-index.ts";
|
|
22
23
|
import { DEFAULT_IGNORE } from "./workspace.ts";
|
|
23
24
|
import { blobAddress } from "./blobs.ts";
|
|
25
|
+
import { configDir } from "./config.ts";
|
|
26
|
+
import { findRipgrep } from "./tool-binaries.ts";
|
|
24
27
|
import { previewSearchObservation, type SearchObservation } from "../core/search-observation.ts";
|
|
25
28
|
import { failureDiagnostic, failureFromError, READ_ATTEMPTED, NOT_STARTED } from "../core/tool-failure.ts";
|
|
26
29
|
import type { ToolDiagnostic } from "../core/tool-contract.ts";
|
|
@@ -459,7 +462,7 @@ function rgText(value: Record<string, unknown>): string {
|
|
|
459
462
|
}
|
|
460
463
|
|
|
461
464
|
async function grepRipgrep(opts: GrepOpts, abs: string, limit: number): Promise<SearchResult | null> {
|
|
462
|
-
const rg =
|
|
465
|
+
const rg = findRipgrep(configDir());
|
|
463
466
|
if (rg === null) return null;
|
|
464
467
|
const args = [
|
|
465
468
|
"--json",
|
|
@@ -491,8 +494,9 @@ function compareMatch(a: MatchLine, b: MatchLine): number {
|
|
|
491
494
|
}
|
|
492
495
|
|
|
493
496
|
async function grepWalk(opts: GrepOpts, abs: string, kind: "file" | "dir", rel: string, limit: number): Promise<SearchResult> {
|
|
497
|
+
const pattern = jsPatternFromRipgrep(opts.pattern);
|
|
494
498
|
try {
|
|
495
|
-
new RegExp(
|
|
499
|
+
new RegExp(pattern);
|
|
496
500
|
} catch (err) {
|
|
497
501
|
return searchFailure("invalid_input", `invalid regex: ${(err as Error).message}`);
|
|
498
502
|
}
|
|
@@ -538,7 +542,7 @@ async function grepWalk(opts: GrepOpts, abs: string, kind: "file" | "dir", rel:
|
|
|
538
542
|
// Reserve the worst-case formatted hits before matching, never discard already observed hits.
|
|
539
543
|
const reservation = Buffer.byteLength(text) + lines.length * (Buffer.byteLength(fileRel) + String(MAX_FILE_BYTES).length + 3);
|
|
540
544
|
if (capturedBytes + reservation > budget) { reasons.push({ code: "capture_budget", path: fileRel }); break; }
|
|
541
|
-
const matched = await matcher.match(
|
|
545
|
+
const matched = await matcher.match(pattern, "", lines, deadline - performance.now(), opts.signal);
|
|
542
546
|
if (matched === "cancelled") { reasons.push({ code: "cancelled", path: fileRel }); break; }
|
|
543
547
|
if (matched === "timeout") {
|
|
544
548
|
reasons.push({ code: "match_timeout", path: fileRel });
|
|
@@ -580,11 +584,11 @@ export async function grep(opts: GrepOpts): Promise<SearchResult> {
|
|
|
580
584
|
return grepWalk(opts, opts.path, target.kind, target.rel, limit);
|
|
581
585
|
case "ripgrep": {
|
|
582
586
|
const got = await grepRipgrep(opts, opts.path, limit);
|
|
583
|
-
if (got === null) return searchFailure("environment_missing", "ripgrep is not on PATH");
|
|
587
|
+
if (got === null) return searchFailure("environment_missing", "ripgrep is not on PATH or in xeno's bin directory");
|
|
584
588
|
return got;
|
|
585
589
|
}
|
|
586
590
|
case "auto": {
|
|
587
|
-
if (
|
|
591
|
+
if (findRipgrep(configDir()) !== null) {
|
|
588
592
|
const got = await grepRipgrep(opts, opts.path, limit);
|
|
589
593
|
if (got !== null) return got;
|
|
590
594
|
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* xeno's own copy of ripgrep, for machines that have none.
|
|
3
|
+
*
|
|
4
|
+
* Where it lives: `<configDir>/bin`, next to settings and auth, so one
|
|
5
|
+
* install serves every workspace. Who sees it: the grep tool (after PATH)
|
|
6
|
+
* and shell commands, whose PATH gets the directory appended -- appended so a
|
|
7
|
+
* user who later installs rg keeps theirs (core/command-contract.ts).
|
|
8
|
+
*
|
|
9
|
+
* The network fetch is injected: store owns the filesystem, the caller owns
|
|
10
|
+
* the request (as runtime/update-check.ts does for the registry).
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync } from "node:fs";
|
|
13
|
+
import { chmod, lstat, mkdir, mkdtemp, rename, rm, writeFile } from "node:fs/promises";
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { ripgrepAsset, type RipgrepAsset } from "../core/ripgrep-release.ts";
|
|
17
|
+
|
|
18
|
+
export function xenoBinDir(configDir: string): string {
|
|
19
|
+
return join(configDir, "bin");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function onPath(name: string, pathEnv: string): string | null {
|
|
23
|
+
for (const dir of pathEnv.split(process.platform === "win32" ? ";" : ":")) {
|
|
24
|
+
if (dir.length === 0) continue;
|
|
25
|
+
const candidate = join(dir, name);
|
|
26
|
+
if (existsSync(candidate)) return candidate;
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The user's rg first, then xeno's; null when neither exists. */
|
|
32
|
+
export function findRipgrep(configDir: string, pathEnv = process.env["PATH"] ?? ""): string | null {
|
|
33
|
+
const own = join(xenoBinDir(configDir), "rg");
|
|
34
|
+
return onPath("rg", pathEnv) ?? (existsSync(own) ? own : null);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type ProvisionResult =
|
|
38
|
+
| { ok: true; path: string; installed: boolean }
|
|
39
|
+
| { ok: false; error: string };
|
|
40
|
+
|
|
41
|
+
export interface ProvisionOptions {
|
|
42
|
+
configDir: string;
|
|
43
|
+
fetchBytes: (url: string, signal?: AbortSignal) => Promise<Uint8Array>;
|
|
44
|
+
/** Defaults to the user's PATH; injected so tests are not decided by the machine. */
|
|
45
|
+
path?: string;
|
|
46
|
+
/** Defaults to the pinned build for this platform. */
|
|
47
|
+
asset?: RipgrepAsset | null;
|
|
48
|
+
signal?: AbortSignal;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Installs the pinned ripgrep unless one is already reachable.
|
|
53
|
+
*
|
|
54
|
+
* Nothing becomes visible until it is proven: the archive is hashed before
|
|
55
|
+
* extraction, only the one member is extracted, and the executable reaches
|
|
56
|
+
* `bin/rg` by rename, so a crash or a mismatch never leaves a partial or
|
|
57
|
+
* unverified binary where the shell would find it.
|
|
58
|
+
*/
|
|
59
|
+
export async function provisionRipgrep(options: ProvisionOptions): Promise<ProvisionResult> {
|
|
60
|
+
const existing = findRipgrep(options.configDir, options.path);
|
|
61
|
+
if (existing !== null) return { ok: true, path: existing, installed: false };
|
|
62
|
+
const asset = options.asset === undefined ? ripgrepAsset(process.platform, process.arch) : options.asset;
|
|
63
|
+
if (asset === null) return { ok: false, error: `no pinned ripgrep build for ${process.platform} ${process.arch}` };
|
|
64
|
+
|
|
65
|
+
const bin = xenoBinDir(options.configDir);
|
|
66
|
+
await mkdir(bin, { recursive: true });
|
|
67
|
+
// Inside bin, so the final rename stays on one filesystem and is atomic.
|
|
68
|
+
const stage = await mkdtemp(join(bin, ".rg-install-"));
|
|
69
|
+
try {
|
|
70
|
+
const bytes = await options.fetchBytes(asset.url, options.signal);
|
|
71
|
+
const digest = createHash("sha256").update(bytes).digest("hex");
|
|
72
|
+
if (digest !== asset.sha256) return { ok: false, error: `ripgrep archive sha256 ${digest} does not match pinned ${asset.sha256}` };
|
|
73
|
+
const archive = join(stage, "rg.tar.gz");
|
|
74
|
+
await writeFile(archive, bytes);
|
|
75
|
+
const extracted = Bun.spawnSync(["tar", "-xzf", archive, "-C", stage, asset.member], { stdout: "ignore", stderr: "pipe" });
|
|
76
|
+
if (extracted.exitCode !== 0) return { ok: false, error: `cannot extract ripgrep: ${extracted.stderr.toString().trim()}` };
|
|
77
|
+
const executable = join(stage, asset.member);
|
|
78
|
+
// The digest pins the archive, but chmod and rename follow what tar
|
|
79
|
+
// wrote; a member that is not a plain file is refused before either.
|
|
80
|
+
if (!(await lstat(executable)).isFile()) return { ok: false, error: "ripgrep archive member is not a regular file" };
|
|
81
|
+
await chmod(executable, 0o755);
|
|
82
|
+
const target = join(bin, "rg");
|
|
83
|
+
await rename(executable, target);
|
|
84
|
+
return { ok: true, path: target, installed: true };
|
|
85
|
+
} catch (error) {
|
|
86
|
+
return { ok: false, error: `cannot install ripgrep: ${error instanceof Error ? error.message : String(error)}` };
|
|
87
|
+
} finally {
|
|
88
|
+
await rm(stage, { recursive: true, force: true });
|
|
89
|
+
}
|
|
90
|
+
}
|