@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.
@@ -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 ? [`Inspect failed receipts: ${failures.slice(-3).map(event => exitLine(event.id, 80)).join(", ")}.`] : []),
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. */
@@ -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
- return { code, message, ...facts, retry, next: guidance[code], ...(code === "permission_denied" ? {} : extra) };
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
  }
@@ -46,7 +46,8 @@ export interface UpdatePlan {
46
46
  readonly notice?: string;
47
47
  }
48
48
 
49
- function truthy(value: string | undefined): boolean {
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 = {
@@ -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("no-progress allowance exhausted", turn), outcome: executionOutcome("stalled") };
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
- private async *checkStall(): AsyncGenerator<LoopEvent, boolean> {
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.stop) {
2568
- yield { kind: "stop_and_ask", reason: `${decision.unproductive} total tool exchanges without new evidence (${decision.consecutive} consecutive; ${decision.failures} failed receipts; ${decision.emptyWaits} empty waits); change the approach or provide new input` };
2569
- return true;
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
- if (decision.warn) await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), reason: "stall", sources: [],
2572
- text: `Harness: ${decision.unproductive} total tool exchanges without new evidence or fresh verification (${decision.consecutive} consecutive; ${decision.failures} failed receipts; ${decision.emptyWaits} empty waits). Change the method, run a meaningful check, or give the final result with unresolved limits. The harness will stop at ${limit} total unproductive exchanges. Novel observations reset the streak, not the spent allowance. Notes and repeated observations do not count as progress.` });
2573
- return false;
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. */
@@ -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 already on PATH, otherwise a TypeScript walk. " +
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
- return sessionResult(ctx, command, timeoutMs, await pollSession(session, outputStore, ctx.maxOutputBytes, wait.ms, "", ctx.signal), wait.note);
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
- return sessionResult(ctx, session.command, session.timeoutMs, await pollSession(session, outputStore, ctx.maxOutputBytes, wait.ms, chars, ctx.signal,
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
  /**
@@ -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
- rel.split(sep).some(part => part === ".git" || part === ".xeno")) throw new ToolFault("permission_denied", `unsafe file checkpoint path: ${path}`, NOT_STARTED);
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);
@@ -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 = findOnPath("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(opts.pattern);
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(opts.pattern, "", lines, deadline - performance.now(), opts.signal);
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 (findOnPath("rg") !== null) {
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
+ }