@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 CHANGED
@@ -1,5 +1,29 @@
1
1
  # xeno changelog
2
2
 
3
+ ## 0.8.2 — 2026-10-11
4
+
5
+ The stall watchdog no longer stops work that is merely long:
6
+ - A run is treated as stuck only when it repeats the same tool call, or the same cycle of up to five calls, three times without learning anything new. Re-reading files, rerunning a test after an edit, a failing test whose output changes, and waiting on a running process never count.
7
+ - The first time, the model is told to change its method. If the cycle comes back, an interactive session asks you: Continue, Stop, or type instructions for the agent. Without anyone to ask (headless), the request still ends as stalled.
8
+ - The old request-wide allowance of twelve exchanges without new evidence is gone. It stopped a long, finished task right after its final answer.
9
+
10
+ The input box after a turn:
11
+ - A finished turn reads `✓ Turn finished · 32m 34s` in the success colour, with how long the turn ran. The time stays until the next turn starts.
12
+ - A turn the harness stopped reads `■ Stopped · no progress` (or request limit, context budget, output limit, time limit) in the warning colour, instead of `Awaiting input`. `Awaiting input` now means a question is waiting for you. A failed turn reads `✗ Failed` in the error colour.
13
+ - A stop report names the tools that failed (`Failed tools: exec_command ×2, edit ×1`) instead of listing receipt ids, and says when the model had already given a final reply before the stop.
14
+
15
+ Tools:
16
+ - Where `rg` is missing, interactive sessions install a pinned ripgrep (14.1.1, checked against its SHA-256) into `~/.xeno/bin` in the background and add it to the end of the command PATH. Nothing is downloaded with `--offline`, in CI or from a source checkout. The environment tells the model whether `rg` is available.
17
+ - A new `worktree` tool lets the model create an isolated Git worktree under `~/.xeno/worktrees`, on a branch `xeno/<name>`, and move the session into it; leaving returns to the main checkout and keeps the worktree and branch. Resumed sessions add the tool after their existing tools.
18
+ - `exec_command` and `write_stdin` accept Codex's `max_output_tokens`. It lowers the inline preview only, and near spellings such as `max_output_chars` are pointed at the right name.
19
+ - `grep`, `rg`, `diff`, `cmp` and `test` exiting 1 as the last command is read as their answer (no match, files differ), not a failure. The receipt keeps the real exit code.
20
+ - A leading `(?i)` in a grep pattern means the same with or without ripgrep.
21
+
22
+ Fixes:
23
+ - A new segment no longer sends the model to `history` first when every user message is already in its handoff. Before, the first tool call of almost every session was that read.
24
+ - The completion check says that a verification command counts only when run on its own, without redirects, pipes, `$()`, `;` or `&&`. The read it asks for no longer counts toward the stall watchdog.
25
+ - Refusing an edit under `.git` or `.xeno` now says it is a fixed rule, instead of asking for a policy change that no setting can make.
26
+
3
27
  ## 0.8.1 — 2026-10-11
4
28
 
5
29
  The sidebar now follows your goal:
package/docs/install.md CHANGED
@@ -144,6 +144,31 @@ check described above is a separate action):
144
144
  - the run is not the interactive app: `-p`, `--mode json`, `--export`, `--list-models`, `models --refresh`, package and `config` commands, `--version`
145
145
  - xeno runs from a checkout: its package root is not under `node_modules`, or has a `.git` (`bun src/apps/cli.ts`, `bun run agent`, `bun link`)
146
146
 
147
+ ## ripgrep
148
+
149
+ Agents reach for `rg` in shell commands, and xeno's grep tool is faster with
150
+ it. When neither `PATH` nor `~/.xeno/bin` has one, the interactive app
151
+ installs ripgrep 14.1.1 into `~/.xeno/bin/rg` in the background:
152
+
153
+ - **Only that build.** The archive comes from the ripgrep GitHub release and
154
+ must match a sha256 pinned in xeno (`src/core/ripgrep-release.ts`).
155
+ Anything else is discarded before extraction; only the `rg` executable is
156
+ unpacked.
157
+ - **Yours wins.** `~/.xeno/bin` is appended to the end of the PATH of tool
158
+ commands, so an `rg` you install yourself is used instead.
159
+ - **What the model is told.** The session's environment section says whether
160
+ `rg` exists in the shell at session start. A copy that finishes
161
+ downloading during the first session appears in the next one's line, but
162
+ shell commands can use it immediately.
163
+ - **When not.** The same conditions as background update checks, except the
164
+ update settings: `--offline` / `XENO_OFFLINE`, `CI` /
165
+ `CONTINUOUS_INTEGRATION`, a non-interactive run, or running from a
166
+ checkout. `XENO_NO_TOOL_INSTALL` turns off only this download. Quitting
167
+ cancels it.
168
+
169
+ Platforms: macOS arm64 and x64, Linux x64 (static musl) and arm64 (glibc).
170
+ Elsewhere grep uses its built-in TypeScript search.
171
+
147
172
  ## Credentials
148
173
 
149
174
  Resolution order, first match wins:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@visual-z/xeno",
3
- "version": "0.8.1",
3
+ "version": "0.8.2",
4
4
  "license": "UNLICENSED",
5
5
  "type": "module",
6
6
  "description": "Segmented append-only agent log with pure context and handoff projections",
package/src/apps/cli.ts CHANGED
@@ -13,7 +13,7 @@
13
13
  */
14
14
 
15
15
  import {
16
- parseArgs, startup, readPipedStdin, mergePrintPrompt, isInteractiveSession, printCliHelp,
16
+ parseArgs, startup, readPipedStdin, mergePrintPrompt, isInteractiveSession, printCliHelp, provisionToolsInBackground,
17
17
  } from "./startup.ts";
18
18
  import { createStartupInput } from "./startup-input.ts";
19
19
  import { runPrompt, runRepl } from "./shell.ts";
@@ -37,6 +37,7 @@ const args = parseArgs(process.argv.slice(serverMode || acpMode || reviewMode ?
37
37
  if (serverMode || acpMode) { args.print = true; args.mode = "json"; if (args.prompt) throw Error("protocol servers receive prompts through their client"); }
38
38
  if (reviewMode) args.print = true;
39
39
  let closeControl: (() => Promise<void>) | undefined;
40
+ let stopToolInstall = (): void => {};
40
41
  const input = createStartupInput({ interactive: isInteractiveSession(args) && !args.packageCommand && !args.version && !args.exportFile && !args.listModels && !args.modelsCommand });
41
42
  process.once("exit", input.restore);
42
43
  try {
@@ -60,6 +61,7 @@ try {
60
61
  catch (error) { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = controller.signal.aborted ? 130 : 1; }
61
62
  finally { process.off("SIGINT", cancel); process.off("SIGTERM", cancel); }
62
63
  } else {
64
+ stopToolInstall = provisionToolsInBackground(args);
63
65
  const app = await startup(args, input);
64
66
  if (args.controlSocket) closeControl = await listenControl(args.controlSocket, () => sessionControl(app.session));
65
67
  if (serverMode) {
@@ -90,6 +92,7 @@ try {
90
92
  } catch (error) {
91
93
  console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1;
92
94
  } finally {
95
+ stopToolInstall();
93
96
  await closeControl?.();
94
97
  process.off("exit", input.restore);
95
98
  input.restore();
@@ -29,6 +29,10 @@ import {
29
29
  resolveCredential, envLabel, storedIsOAuth,
30
30
  type AuthFile, type ResolveEffects, type Resolved, describeMissingCredential } from "../runtime/auth.ts";
31
31
  import { authPath, configDir, readJsonFile } from "../store/config.ts";
32
+ import { findRipgrep, provisionRipgrep } from "../store/tool-binaries.ts";
33
+ import { packageLocation } from "../store/update-cache.ts";
34
+ import { installKind } from "../core/update-check.ts";
35
+ import { toolInstallAllowed } from "../core/ripgrep-release.ts";
32
36
  import { loadUiResources, loadTextResources, type UiResources } from "../store/resources.ts";
33
37
  import { discoverResources, type ResourceIndex } from "../store/resource-index.ts";
34
38
  import { pickProjectTrust, pickStartupSession, runResourceConfig } from "./ui/standalone.ts";
@@ -108,9 +112,29 @@ export function sessionEnvironment(workspace: string): SessionEnvironment {
108
112
  cwd: workspace,
109
113
  date: `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`,
110
114
  git,
115
+ ripgrep: findRipgrep(configDir()) !== null,
111
116
  };
112
117
  }
113
118
 
119
+ /**
120
+ * Fills the rg gap in the background for interactive sessions only: scripts,
121
+ * protocol servers and test subprocesses must not reach the network on their
122
+ * own. Failure is silent because grep keeps its TypeScript engine; the next
123
+ * session's environment line reports what is actually there. The returned
124
+ * stop aborts the download, so quitting never waits for it.
125
+ */
126
+ export function provisionToolsInBackground(args: Args): () => void {
127
+ const location = packageLocation();
128
+ if (!isInteractiveSession(args) || !toolInstallAllowed({ env: process.env, offline: args.offline === true, install: installKind(location.root, location.git) })) return () => {};
129
+ const controller = new AbortController();
130
+ void provisionRipgrep({ configDir: configDir(), signal: controller.signal, fetchBytes: async (url, signal) => {
131
+ const response = await fetch(url, { ...(signal ? { signal } : {}), redirect: "follow" });
132
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
133
+ return new Uint8Array(await response.arrayBuffer());
134
+ } }).catch(() => { /* grep falls back to its own engine */ });
135
+ return () => controller.abort();
136
+ }
137
+
114
138
  /**
115
139
  * Session policy a CLI coding session differs from a benchmark run in.
116
140
  * Benchmarks construct their own SessionConfig and keep the core defaults.
@@ -366,8 +390,9 @@ export function parseArgs(argv: string[], options: { deferExtensions?: boolean;
366
390
  let updateExtensions = false;
367
391
  const args: Args = {
368
392
  cliFlags: argv.filter(value => value.startsWith("-")),
369
- // No default request cap (S.6 item 1): the stall watchdog ends a run that
370
- // stops producing evidence; --max-turns or an agent budget sets a hard cap.
393
+ // No default request cap (S.6 item 1): the stall watchdog catches a run
394
+ // repeating a cycle without evidence, and asks the user when one can answer
395
+ // (S.8); --max-turns or an agent budget sets a hard cap.
371
396
  workspace: process.cwd(), logDir: "", maxTurns: Infinity, prompt: "",
372
397
  // Overridable so a node boundary can be forced in a short session --
373
398
  // otherwise cutting is only reachable after tens of thousands of tokens,
@@ -14,10 +14,19 @@ export interface ActivityFacts {
14
14
  }
15
15
  export interface ActivityStatus { readonly state: ProgramStatus["state"]; readonly label: string; readonly detail: string; readonly queued: number }
16
16
 
17
+ const STOP_CAUSE: Partial<Record<ExecutionOutcome["reason"], string>> = {
18
+ stalled: "no progress", no_progress: "no progress", max_turns: "request limit", context_budget: "context budget",
19
+ output_limit: "output limit", timeout: "time limit",
20
+ };
21
+
17
22
  export function activityStatus(facts: ActivityFacts): ActivityStatus {
18
23
  const status = programStatus(facts), queued = Math.max(0, Math.floor(facts.queued));
19
24
  const view = (label: string, detail = status.msg ?? ""): ActivityStatus => ({ state: status.state, label, detail, queued });
20
- if (status.state === "blocked") return view(status.kind === "auth" ? "Sign in required" : status.kind === "permission" ? "Awaiting approval" : "Awaiting input");
25
+ // A harness stop is not a question: saying "Awaiting input" left the user
26
+ // guessing what to answer (fix-finished-turn-status). The OSC state stays blocked.
27
+ if (!facts.running && !facts.waiting && facts.outcome?.status === "stopped") return view("Stopped", STOP_CAUSE[facts.outcome.reason] ?? facts.outcome.reason.replaceAll("_", " "));
28
+ if (status.state === "blocked") return view(status.kind === "auth" ? "Sign in required" : status.kind === "permission" ? "Awaiting approval"
29
+ : !facts.waiting && facts.goal === "awaiting_human" ? "Awaiting review" : "Awaiting input");
21
30
  if (status.state === "error") return view("Failed");
22
31
  if (!facts.running) return view(status.state === "done" ? "Turn finished" : facts.outcome?.status === "cancelled" ? "Cancelled" : "Ready");
23
32
  if (facts.retry) return view("Retrying", `attempt ${facts.retry.attempt} · ${Math.max(0, Math.ceil(facts.retry.seconds))}s`);
@@ -10,10 +10,18 @@ export function commandArgv(command: string): string[] {
10
10
  return ["bash", "-o", "pipefail", "-lc", command];
11
11
  }
12
12
 
13
- /** Preserve inherited values except documented noninteractive defaults; never mutate the host. */
14
- export function commandEnvironment(base: Readonly<Record<string, string | undefined>>): Record<string, string> {
13
+ /**
14
+ * Preserve inherited values except documented noninteractive defaults; never mutate the host.
15
+ *
16
+ * `appendPath` is xeno's own tool directory (store/tool-binaries.ts). It goes
17
+ * last, so anything the user installed wins and xeno only fills a gap.
18
+ */
19
+ export function commandEnvironment(base: Readonly<Record<string, string | undefined>>, appendPath?: string): Record<string, string> {
15
20
  const env: Record<string, string> = {};
16
21
  for (const [key, value] of Object.entries(base)) if (value !== undefined) env[key] = value;
22
+ if (appendPath !== undefined && appendPath.length > 0 && !(env["PATH"] ?? "").split(":").includes(appendPath)) {
23
+ env["PATH"] = env["PATH"] ? `${env["PATH"]}:${appendPath}` : appendPath;
24
+ }
17
25
  Object.assign(env, {
18
26
  TERM: "dumb", PAGER: "cat", GIT_PAGER: "cat", NO_COLOR: "1",
19
27
  GIT_TERMINAL_PROMPT: "0", DEBIAN_FRONTEND: "noninteractive",
@@ -22,14 +30,80 @@ export function commandEnvironment(base: Readonly<Record<string, string | undefi
22
30
  return env;
23
31
  }
24
32
 
33
+ /**
34
+ * Codex's `max_output_tokens` as an inline byte budget for one call. It can
35
+ * only lower the session ceiling: full output is still captured and
36
+ * recoverable, so this shapes the preview, never the receipt.
37
+ *
38
+ * Assumed, not measured: 4 bytes per token rounds the ASCII estimate in
39
+ * tokens.ts (about 3.6) up, so the model never gets less than it asked for.
40
+ * The 1 KiB floor keeps room for the header and recovery hint (about 300
41
+ * bytes); below it a preview shows almost no output and forces a re-read.
42
+ */
43
+ export const OUTPUT_BYTES_PER_TOKEN = 4;
44
+ export const MIN_OUTPUT_BYTES = 1_024;
45
+ export function outputBytesForTokens(tokens: unknown, ceiling: number): number {
46
+ if (typeof tokens !== "number" || !Number.isFinite(tokens) || tokens <= 0) return ceiling;
47
+ return Math.min(ceiling, Math.max(MIN_OUTPUT_BYTES, Math.floor(tokens) * OUTPUT_BYTES_PER_TOKEN));
48
+ }
49
+
25
50
  /** Model seconds and host milliseconds meet once; a yield is never a command deadline. */
26
51
  export function commandTimeout(requestedSeconds: number | undefined, defaultMs: number, maximumMs = defaultMs): number {
27
52
  return Math.min(requestedSeconds === undefined ? defaultMs : requestedSeconds * 1000, maximumMs);
28
53
  }
29
54
 
55
+ /**
56
+ * Programs whose exit 1 is an answer rather than a fault (POSIX: grep's 1 is
57
+ * "no match" and >1 an error; diff and cmp use 1 for "differ"; test for
58
+ * false). Reporting them as failures tells the model to inspect and retry an
59
+ * answer it already holds -- the same reading Claude Code's command semantics
60
+ * give these programs.
61
+ */
62
+ const NEGATIVE_ANSWERS: Readonly<Record<string, string>> = {
63
+ grep: "no match", egrep: "no match", fgrep: "no match", rg: "no match",
64
+ diff: "files differ", cmp: "files differ", test: "condition false", "[": "condition false",
65
+ };
66
+
67
+ /**
68
+ * What exit 1 of the command's final simple command means, or null.
69
+ *
70
+ * Only the last segment after a top-level `;`, newline, `||` or `|` is read.
71
+ * After `&&` the 1 may come from the command that short-circuited, so it is
72
+ * not classified. Under pipefail an earlier pipeline stage can also set the
73
+ * status; the exit is kept in the receipt either way, and the label states
74
+ * the program's meaning rather than claiming success. Anything this scanner
75
+ * cannot read with certainty -- unbalanced quotes, subshells, compound
76
+ * statements, substitutions -- yields null.
77
+ */
78
+ export function negativeAnswer(command: string, exit: number): string | null {
79
+ if (exit !== 1) return null;
80
+ let quote: "'" | "\"" | null = null, start = 0, separator = "";
81
+ for (let i = 0; i < command.length; i++) {
82
+ const c = command[i]!;
83
+ if (quote !== null) {
84
+ if (c === "\\" && quote === "\"") i++;
85
+ else if (c === quote) quote = null;
86
+ continue;
87
+ }
88
+ if (c === "\\") { i++; continue; }
89
+ if (c === "'" || c === "\"") { quote = c; continue; }
90
+ if (c === "`" || c === "(" || c === ")" || c === "{" || c === "}" || (c === "$" && command[i + 1] === "(")) return null;
91
+ const two = command.slice(i, i + 2);
92
+ if (two === "&&" || two === "||") { separator = two; start = i + 2; i++; continue; }
93
+ if (c === ";" || c === "\n" || c === "|") { separator = c; start = i + 1; continue; }
94
+ if (c === "&" && command[i + 1] !== ">" && command[i - 1] !== ">") return null;
95
+ }
96
+ if (quote !== null || separator === "&&") return null;
97
+ const words = command.slice(start).trim().split(/\s+/).filter(word => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(word));
98
+ const program = (words[0] ?? "").replace(/^.*\//, "");
99
+ if (program === "git") return words[1] === "grep" ? "no match" : null;
100
+ return Object.hasOwn(NEGATIVE_ANSWERS, program) ? NEGATIVE_ANSWERS[program]! : null;
101
+ }
102
+
30
103
  /** A trap can exit zero after cancellation: successful exit alone does not erase the stop. */
31
- export function commandCompletion(exit: number, stopped?: "cancelled" | "timeout", cleanup?: ToolDiagnostic) {
32
- return { ok: stopped === undefined && exit === 0 && cleanup === undefined,
33
- outcome: executionOutcome(stopped ?? (exit === 0 && cleanup === undefined ? "tool_completed" : "tool_failed")),
104
+ export function commandCompletion(exit: number, stopped?: "cancelled" | "timeout", cleanup?: ToolDiagnostic, command?: string) {
105
+ const answered = exit === 0 || command !== undefined && negativeAnswer(command, exit) !== null;
106
+ return { ok: stopped === undefined && answered && cleanup === undefined,
107
+ outcome: executionOutcome(stopped ?? (answered && cleanup === undefined ? "tool_completed" : "tool_failed")),
34
108
  ...(cleanup ? { warnings: [cleanup] } : {}) };
35
109
  }
@@ -69,7 +69,7 @@ export function requestCompletion(state: RequestState, remainingRequests: number
69
69
  if (state.used_tools && text.length === 0) gaps.push("the final reply is empty");
70
70
  // Strict verification counts only configured gates (O.11); say so, since a successful `cat` would otherwise look like a check.
71
71
  if (freshCheck && freshCheck.status !== "pass") gaps.push(freshCheck.gate_only
72
- ? "files were changed but no configured verification gate (a test, typecheck, lint, build or check command) has passed since the last edit; reading or printing files does not count"
72
+ ? "files were changed but no configured verification gate (a test, typecheck, lint, build or check command) has passed since the last edit; reading or printing files does not count, and a gate counts only when run as its own plain command, without redirects, pipes, $(), ; or &&"
73
73
  : "files were changed but no command has succeeded since the last edit");
74
74
  if (acceptance?.status === "needs_work") gaps.push(`declared acceptance criteria need evidence: ${[
75
75
  ...missingCommands.map(item => `${item.id}=${item.status}`),
@@ -19,6 +19,12 @@ export interface SessionEnvironment {
19
19
  /** Local calendar date, YYYY-MM-DD. */
20
20
  date: string;
21
21
  git: boolean;
22
+ /**
23
+ * Whether `rg` resolves in tool shells at session start. Models reach for
24
+ * it by habit; without this line a missing one costs an exit-127 request.
25
+ * Optional so environments recorded before it existed still render.
26
+ */
27
+ ripgrep?: boolean;
22
28
  }
23
29
 
24
30
  export function environmentSection(env: SessionEnvironment): string {
@@ -29,6 +35,7 @@ export function environmentSection(env: SessionEnvironment): string {
29
35
  `- Working directory: ${env.cwd}`,
30
36
  `- Date: ${env.date}`,
31
37
  `- Git repository: ${env.git ? "yes" : "no"}`,
38
+ ...(env.ripgrep === undefined ? [] : [`- ripgrep (rg) in the shell: ${env.ripgrep ? "yes" : "no — use the grep and find tools"}`]),
32
39
  ].join("\n");
33
40
  }
34
41
 
@@ -2,6 +2,7 @@
2
2
  import { emitHandoff, type Handoff } from "./handoff.ts";
3
3
  import { estimateTokens } from "./tokens.ts";
4
4
  import { recoveryWorkingSet } from "./working-set.ts";
5
+ import { recoveryInstructions } from "./prompt-policy.ts";
5
6
  import type { RecoveryNodes } from "./recovery-sources.ts";
6
7
 
7
8
  // A carried handoff must leave room for another segment's work. This budget is
@@ -122,6 +123,11 @@ export function boundRecoveryHandoff(full: Handoff, nodes: RecoveryNodes, budget
122
123
  const legacy = boundHandoff(full, budget);
123
124
  const view = recoveryWorkingSet(full, nodes, budget * .4, tools);
124
125
  if (!view) return legacy;
125
- try { return boundHandoff({ ...full, working_set: view }, budget); }
126
+ let carried: Handoff;
127
+ try { carried = boundHandoff({ ...full, working_set: view }, budget); }
126
128
  catch (error) { if (error instanceof RangeError) return legacy; throw error; }
129
+ // Whether the user turns survived bounding is known only now. The inline
130
+ // wording is shorter, so swapping it in cannot push the handoff over budget.
131
+ const inline = tools?.includes("history") && carried.user_turns_truncated === undefined && (carried.history?.sections["user_turns"]?.offset ?? 0) === 0;
132
+ return inline && carried.working_set ? { ...carried, working_set: { ...carried.working_set, recovery: recoveryInstructions(tools!, true) } } : carried;
127
133
  }
@@ -45,9 +45,10 @@ export type ToolSource = "builtin" | "extension";
45
45
 
46
46
  /** Task capabilities are frozen once at session creation (O.7); not permissions. */
47
47
  export const TOOL_PROFILES = {
48
- coding: ["read", "grep", "find", "write", "edit", "environment", "exec_command", "write_stdin", "history", "note"],
49
- // An explicit preset preserves the measured legacy ten-tool coding contract.
50
- foundation: ["read", "grep", "find", "write", "edit", "environment", "exec_command", "write_stdin", "history", "note", "question", "apply_patch", "diff", "webfetch", "websearch", "format", "lsp"],
48
+ // worktree joined the measured ten (2026-10-11, feat-worktree-tool): without
49
+ // it a model needing isolation had no checkout it was allowed to edit.
50
+ coding: ["read", "grep", "find", "write", "edit", "environment", "exec_command", "write_stdin", "history", "note", "worktree"],
51
+ foundation: ["read", "grep", "find", "write", "edit", "environment", "exec_command", "write_stdin", "history", "note", "worktree", "question", "apply_patch", "diff", "webfetch", "websearch", "format", "lsp"],
51
52
  readonly: ["read", "grep", "find", "history", "note"],
52
53
  research: ["read", "grep", "find", "history", "note", "branch", "resolve"],
53
54
  data: ["read", "grep", "find", "history", "note", "query"],
@@ -23,9 +23,16 @@ export function toolDescription(description: string, guidance: readonly ToolGuid
23
23
  return [description, ...guidance.filter(item => item.requires.every(name => tools.includes(name))).map(item => item.text)].join(" ");
24
24
  }
25
25
 
26
- export function recoveryInstructions(tools: readonly string[]): string {
26
+ /**
27
+ * `usersInline`: the carried `user_turns` hold every user message verbatim.
28
+ * Telling the model to page them anyway costs a guaranteed first-call read
29
+ * that returns what it already has -- or an empty page after a model switch
30
+ * on a fresh session (Horizyn mv3nsske-x50u, fix-recovery-inline-users).
31
+ */
32
+ export function recoveryInstructions(tools: readonly string[], usersInline = false): string {
27
33
  return (tools.includes("history")
28
- ? "Read original user messages before continuing: history(node=source.node,recovery=users,offset=0). Other pages: failures, environment, next_actions; event=ID reads one original. history(work_id=ID) recovers work. "
34
+ ? (usersInline ? "User messages are verbatim in user_turns. Other history(node=source.node,recovery=...) pages: failures, environment, next_actions; " : "Read original user messages before continuing: history(node=source.node,recovery=users,offset=0). Other pages: failures, environment, next_actions; ") +
35
+ "event=ID reads one original. history(work_id=ID) recovers work. "
29
36
  : "Original user messages and evidence remain at the source references. The original-source recovery capability is unavailable; state any resulting evidence gap instead of guessing. ") +
30
37
  "Abandoned messages are cancelled; annotations are not user instructions. Observations are historical: recheck resource, host and current permissions before acting.";
31
38
  }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Brings the ripgrep regex dialect to JS RegExp where the two differ in
3
+ * spelling, not meaning.
4
+ *
5
+ * grep runs ripgrep when it is on PATH and a TypeScript walk otherwise, and
6
+ * the model writes for ripgrep (Rust regex), where a leading `(?i)` is the
7
+ * usual way to ask for case-insensitivity. JS rejects that spelling but
8
+ * accepts the scoped form `(?i:...)`, so a leading global group is rewritten
9
+ * into a scoped group around the rest of the pattern. Without this the same
10
+ * call works or fails depending on whether rg happens to be installed.
11
+ *
12
+ * Only the leading form and only flags JS modifiers support (i, m, s) are
13
+ * rewritten; anything else is passed through so RegExp reports it as it is.
14
+ */
15
+ const LEADING_FLAGS = /^\(\?([ims]+)\)/;
16
+
17
+ export function jsPatternFromRipgrep(pattern: string): string {
18
+ const match = LEADING_FLAGS.exec(pattern);
19
+ if (match === null) return pattern;
20
+ const flags = match[1]!;
21
+ // A repeated flag is a SyntaxError inside a modifier group; ripgrep
22
+ // tolerates it, so duplicates are dropped rather than surfaced.
23
+ const unique = [...new Set(flags)].join("");
24
+ return `(?${unique}:${pattern.slice(match[0].length)})`;
25
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The one ripgrep build xeno will install, per platform.
3
+ *
4
+ * Models write `rg` in shell commands because the harnesses they were trained
5
+ * in ship it (Claude Code bundles it; Pi downloads it on first use). On a
6
+ * machine without it every such command exits 127 and costs a request. xeno
7
+ * installs it only when the user has none, and only this exact archive:
8
+ * running a binary fetched from the network needs proof that it is the one
9
+ * reviewed, which a pinned digest gives and "latest" (Pi's choice) does not.
10
+ *
11
+ * Digests are the upstream release's published `.sha256` files for 14.1.1;
12
+ * the aarch64-apple-darwin archive was also downloaded and hashed locally on
13
+ * 2026-10-11 and matched. Upgrading means changing this table and releasing.
14
+ */
15
+ import { truthy } from "./update-check.ts";
16
+
17
+ export const RIPGREP_VERSION = "14.1.1";
18
+
19
+ export interface RipgrepAsset {
20
+ readonly url: string;
21
+ readonly sha256: string;
22
+ /** Archive member holding the executable; nothing else is extracted. */
23
+ readonly member: string;
24
+ }
25
+
26
+ const DIGESTS: Readonly<Record<string, string>> = {
27
+ "aarch64-apple-darwin": "24ad76777745fbff131c8fbc466742b011f925bfa4fffa2ded6def23b5b937be",
28
+ "x86_64-apple-darwin": "fc87e78f7cb3fea12d69072e7ef3b21509754717b746368fd40d88963630e2b3",
29
+ // musl is static, so it runs on glibc and musl distributions alike.
30
+ "x86_64-unknown-linux-musl": "4cf9f2741e6c465ffdb7c26f38056a59e2a2544b51f7cc128ef28337eeae4d8e",
31
+ // 14.1.1 publishes no aarch64 musl build; this one needs glibc.
32
+ "aarch64-unknown-linux-gnu": "c827481c4ff4ea10c9dc7a4022c8de5db34a5737cb74484d62eb94a95841ab2f",
33
+ };
34
+
35
+ function target(platform: string, arch: string): string | null {
36
+ if (platform === "darwin") return arch === "arm64" ? "aarch64-apple-darwin" : arch === "x64" ? "x86_64-apple-darwin" : null;
37
+ if (platform === "linux") return arch === "arm64" ? "aarch64-unknown-linux-gnu" : arch === "x64" ? "x86_64-unknown-linux-musl" : null;
38
+ return null;
39
+ }
40
+
41
+ /** Null for platforms without a pinned build: those keep the TypeScript search. */
42
+ export function ripgrepAsset(platform: string, arch: string): RipgrepAsset | null {
43
+ const triple = target(platform, arch);
44
+ if (triple === null) return null;
45
+ const name = `ripgrep-${RIPGREP_VERSION}-${triple}`;
46
+ return {
47
+ url: `https://github.com/BurntSushi/ripgrep/releases/download/${RIPGREP_VERSION}/${name}.tar.gz`,
48
+ sha256: DIGESTS[triple]!,
49
+ member: `${name}/rg`,
50
+ };
51
+ }
52
+
53
+ /**
54
+ * Whether xeno may fetch the binary on its own, in the background.
55
+ *
56
+ * The same occasions the update check stays off the network (core/update-check.ts):
57
+ * offline, CI, and a source checkout -- the last is also what keeps test
58
+ * subprocesses, which run the CLI from this repository, from downloading.
59
+ * XENO_NO_TOOL_INSTALL opts out explicitly; the user's own rg makes it moot.
60
+ */
61
+ export function toolInstallAllowed(input: { env: Readonly<Record<string, string | undefined>>; offline: boolean; install: string }): boolean {
62
+ const on = (name: string) => truthy(input.env[name]);
63
+ return !input.offline && input.install !== "checkout" && !on("XENO_OFFLINE") && !on("XENO_NO_TOOL_INSTALL") && !on("CI") && !on("CONTINUOUS_INTEGRATION");
64
+ }
package/src/core/stall.ts CHANGED
@@ -6,23 +6,60 @@ import { deriveProgress, type ProgressEvidence } from "./progress.ts";
6
6
  import { isProcessReceipt } from "./process-receipt.ts";
7
7
  import { canonicalValue } from "./continuation.ts";
8
8
 
9
- // A conservative hypothesis: enough room to change method after a mid-budget
10
- // notice, while bounding repeated paid requests. Not a task-quality classifier.
9
+ /**
10
+ * S.8: the watchdog is now an on/off switch -- 0 disables it, any other
11
+ * allowed value enables it. Sessions persist this field (S.3); a stored 12
12
+ * still means "on" and is never reread as "twelve repeats".
13
+ */
11
14
  export const DEFAULT_STALL_LIMIT = 12;
15
+ /**
16
+ * How many times a cycle must repeat. A hypothesis, not measured: OpenCode's
17
+ * doom loop, Cline and Roo use 3; OpenHands 4 counting the first, novel,
18
+ * observation; Gemini 5. Lower asks too often, higher pays for more repeats.
19
+ */
20
+ export const CYCLE_REPEATS = 3;
21
+ /** Longest cycle recognised; Gemini CLI's bound, and the user's (S.8). */
22
+ export const MAX_CYCLE = 5;
23
+
24
+ export interface StallExchange { readonly signature: string; readonly progressed: boolean }
12
25
  export interface StallState {
13
26
  evidence: readonly ProgressEvidence[];
14
27
  pending: readonly LogEvent[];
15
28
  pendingNode: number;
16
- consecutive: number;
17
- unproductive: number;
29
+ /** The request's latest exchanges, oldest first, at most MAX_CYCLE × CYCLE_REPEATS. */
30
+ recent: readonly StallExchange[];
18
31
  failures: number;
19
32
  emptyWaits: number;
20
- warned: boolean;
33
+ /** A cycle was already pointed out to the model; the next one goes to the user. */
34
+ nudged: boolean;
21
35
  /** The previous exchange's tool calls, canonical; empty after real user input. */
22
36
  previous: string;
37
+ /**
38
+ * A completion review just asked the model to re-read the request and
39
+ * verify. Its answer is the harness's own request, so that one exchange
40
+ * stays out of the cycle window (fix-review-induced-stall).
41
+ */
42
+ reviewed: boolean;
23
43
  }
24
44
  export function emptyStallState(): StallState {
25
- return { evidence: [], pending: [], pendingNode: 0, consecutive: 0, unproductive: 0, failures: 0, emptyWaits: 0, warned: false, previous: "" };
45
+ return { evidence: [], pending: [], pendingNode: 0, recent: [], failures: 0, emptyWaits: 0, nudged: false, previous: "", reviewed: false };
46
+ }
47
+
48
+ /**
49
+ * The shortest period k <= MAX_CYCLE such that the last k × repeats exchanges
50
+ * are one k-call cycle repeated, none of which brought new evidence -- or null.
51
+ * Distinct calls that each tell nothing new are not a loop: that is reading
52
+ * and re-checking, which a cumulative allowance wrongly punished (S.8).
53
+ */
54
+ export function repeatedCycle(recent: readonly StallExchange[], repeats = CYCLE_REPEATS): number | null {
55
+ for (let k = 1; k <= MAX_CYCLE; k++) {
56
+ const n = k * repeats;
57
+ if (recent.length < n) return null;
58
+ const window = recent.slice(-n);
59
+ if (window.some(item => item.progressed)) continue;
60
+ if (window.every((item, i) => i < k || item.signature === window[i - k]!.signature)) return k;
61
+ }
62
+ return null;
26
63
  }
27
64
 
28
65
  /** Name and arguments of every call, order-independent: what the model asked for, not what came back. */
@@ -38,7 +75,7 @@ export function finishStallExchange(state: StallState): StallState {
38
75
  const progress = deriveProgress(state.evidence, state.pending, state.pendingNode, checks);
39
76
  const signature = exchangeSignature(assistant);
40
77
  const waits = new Set(assistant.blocks.flatMap(block => block.type === "tool_use" && block.name === "write_stdin" ? [block.call_id] : []));
41
- // S.6 item 2: with no request cap this ledger is the only backstop. Asking
78
+ // S.6 item 2: asking
42
79
  // for exactly what the previous exchange asked for is a rerun on an
43
80
  // unchanged premise, so output that differs only by timing (a failing test's
44
81
  // "[123ms]") is not news. Reads and edits are content-addressed and keep
@@ -51,9 +88,12 @@ export function finishStallExchange(state: StallState): StallState {
51
88
  (event.effect?.kind !== "command" && !isProcessReceipt(event.data))), state.pendingNode, checks).progressed
52
89
  : progress.progressed;
53
90
  const results = state.pending.filter(event => event.t === "tool_result");
54
- return { ...state, pending: [], evidence: progress.evidence, previous: signature,
55
- consecutive: rerun ? 0 : state.consecutive + 1,
56
- unproductive: state.unproductive + (rerun ? 0 : 1),
91
+ // Waiting on a process that is still running is not another attempt: its
92
+ // own timeout bounds it and the user can interrupt (S.8). Only exchanges
93
+ // that try something enter the cycle window.
94
+ const waiting = results.length > 0 && results.every(result => waits.has(result.call_id) && isProcessReceipt(result.data) && result.data.running === true);
95
+ const recent = state.reviewed || waiting ? state.recent : [...state.recent, { signature, progressed: rerun }].slice(-MAX_CYCLE * CYCLE_REPEATS);
96
+ return { ...state, pending: [], evidence: progress.evidence, previous: signature, reviewed: false, recent,
57
97
  failures: state.failures + results.filter(result => !result.ok).length,
58
98
  emptyWaits: state.emptyWaits + results.filter(result => waits.has(result.call_id) && isProcessReceipt(result.data) &&
59
99
  result.data.running === true && result.data.cursor !== undefined && result.data.cursor.start === result.data.cursor.end).length };
@@ -63,11 +103,16 @@ export function finishStallExchange(state: StallState): StallState {
63
103
  export function advanceStall(state: StallState, entries: readonly { node: number; event: LogEvent }[]): StallState {
64
104
  let result = state;
65
105
  for (const { node, event } of entries) {
66
- if (event.t === "assistant" || event.t === "user" || event.t === "cut" || (event.t === "guidance" && event.reason === "stall")) {
106
+ const review = event.t === "guidance" && event.reason === "completion_review";
107
+ const nudge = event.t === "guidance" && event.reason === "stall";
108
+ if (event.t === "assistant" || event.t === "user" || event.t === "cut" || review || nudge) {
67
109
  result = finishStallExchange(result);
68
- if (event.t === "user") result = { ...result, pending: [], consecutive: 0, unproductive: 0, failures: 0, emptyWaits: 0, warned: false, previous: "" };
110
+ if (review) result = { ...result, reviewed: true };
111
+ // A nudge, or the user's "continue", starts a fresh window: only a new
112
+ // full cycle after it counts, never the one already reported.
113
+ if (nudge) result = { ...result, nudged: true, recent: [] };
114
+ if (event.t === "user") result = { ...result, pending: [], recent: [], failures: 0, emptyWaits: 0, nudged: false, previous: "", reviewed: false };
69
115
  if (event.t === "assistant") result = { ...result, pending: [event], pendingNode: node };
70
- if (event.t === "guidance") result = { ...result, warned: true };
71
116
  } else if (result.pending.length > 0) result = { ...result, pending: [...result.pending, event] };
72
117
  }
73
118
  return result;
@@ -75,9 +120,9 @@ export function advanceStall(state: StallState, entries: readonly { node: number
75
120
  export function stallDecision(state: StallState, limit: number) {
76
121
  if (!Number.isSafeInteger(limit) || limit < 0 || limit === 1) throw new RangeError("stall limit must be zero or an integer of at least two");
77
122
  const current = finishStallExchange(state);
78
- // Reuse the existing conservative allowance. Novelty resets a streak, not
79
- // the request's spent no-progress budget (research Q.26); failures are diagnostics.
80
- return { consecutive: current.consecutive, unproductive: current.unproductive, failures: current.failures, emptyWaits: current.emptyWaits,
81
- stop: limit > 0 && (current.consecutive >= limit || current.unproductive >= limit),
82
- warn: limit > 0 && current.unproductive >= Math.max(1, Math.floor(limit / 2)) && !current.warned };
123
+ const cycle = limit > 0 ? repeatedCycle(current.recent) : null;
124
+ // S.8: the first cycle is pointed out to the model; a second goes to the
125
+ // user when one can answer, and otherwise ends the request.
126
+ return { cycle, repeats: CYCLE_REPEATS, failures: current.failures, emptyWaits: current.emptyWaits,
127
+ warn: cycle !== null && !current.nudged, stop: cycle !== null && current.nudged };
83
128
  }