@visual-z/xeno 0.8.0 → 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.
@@ -1,5 +1,5 @@
1
1
  /** N.12: a returned response is durable evidence, not necessarily a completed action. */
2
- import type { GuidanceEvent, LogEvent } from "./events.ts";
2
+ import type { AssistantBlock, GuidanceEvent, LogEvent } from "./events.ts";
3
3
  import { currentRequestEvents } from "./request-state.ts";
4
4
 
5
5
  // One appended attempt is an operational assumption, not a measured optimum.
@@ -15,6 +15,18 @@ export function isCompleteResponse(reason: string | null | undefined): boolean {
15
15
  return reason !== undefined && reason !== null && ["stop", "end_turn", "stop_sequence", "tool_calls", "tool_use"].includes(reason);
16
16
  }
17
17
 
18
+ /**
19
+ * A stop that asks for tool execution must carry a tool call. One that does not
20
+ * is a malformed provider response, not the model's answer: taken as a final
21
+ * reply it ends the request with outcome unknown and nothing on screen
22
+ * (session mv28chr8-oc3x, an Anthropic-compatible gateway returning
23
+ * `stop_reason: tool_use` with no content). Adapters reject it inside their
24
+ * retry so the loop only sees a coherent turn or a definite failure.
25
+ */
26
+ export function isToolStopWithoutCall(stopReason: string | null | undefined, blocks: readonly AssistantBlock[]): boolean {
27
+ return (stopReason === "tool_use" || stopReason === "tool_calls") && !blocks.some(block => block.type === "tool_use");
28
+ }
29
+
18
30
  export function isOutputLimit(reason: string | null | undefined): boolean {
19
31
  return reason === "length" || reason === "max_tokens";
20
32
  }
@@ -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
  }
@@ -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. */
@@ -23,7 +23,7 @@ import type { Message, ContentBlock, FrozenPrefix } from "../core/project.ts";
23
23
  import { imageData } from "./images.ts";
24
24
  import type { AssistantBlock } from "../core/events.ts";
25
25
  import { assertNever } from "../core/exhaustive.ts";
26
- import type { Provider, ProviderConfig, Turn, ToolSchema, OnDelta } from "./provider.ts";
26
+ import { coherentTurn, type Provider, type ProviderConfig, type Turn, type ToolSchema, type OnDelta } from "./provider.ts";
27
27
  import { DEFAULT_COMPAT, type Compat, type Effort, type ModelSpec } from "./model.ts";
28
28
  import { withRetry, DEFAULT_RETRY, type RetryPolicy } from "./retry.ts";
29
29
  import type { ProviderSendOptions } from "./provider-tool-policy.ts";
@@ -502,7 +502,7 @@ export class OpenAICompletionsProvider implements Provider {
502
502
  }
503
503
 
504
504
  const assembled = await readCompletionsSse(response.body, onDelta);
505
- return parseResponse(assembled, node, Date.now() - started);
505
+ return coherentTurn(parseResponse(assembled, node, Date.now() - started), assembled.usage !== undefined);
506
506
  }
507
507
  }
508
508
 
@@ -20,7 +20,7 @@ import { providerRequest } from "./provider-request.ts";
20
20
  import { grokVersionHint } from "./oauth.ts";
21
21
  import type { ResolveEffects } from "./auth.ts";
22
22
  import { imageData } from "./images.ts";
23
- import { FailedResponseError, type OnDelta, type Provider, type ProviderConfig, type ToolSchema, type Turn } from "./provider.ts";
23
+ import { coherentTurn, FailedResponseError, type OnDelta, type Provider, type ProviderConfig, type ToolSchema, type Turn } from "./provider.ts";
24
24
  import { DEFAULT_RETRY, withRetry, type RetryPolicy } from "./retry.ts";
25
25
  import { HttpError, StreamError } from "./openai.ts";
26
26
  import type { UsageSample } from "./usage.ts";
@@ -392,7 +392,7 @@ export class PiProvider implements Provider {
392
392
  if (identityErrors.length) throw new ProviderIntegrityError(identityErrors, hasKnownUsage ? usage : undefined);
393
393
  // The SDK has already classified native STOP/end_turn/toolUse/length values. The loop consumes
394
394
  // xeno's closed completion vocabulary, never a raw native spelling that could skip tool execution.
395
- return { blocks: piBlocks(completed, originalArguments.values), stopReason: completed.stopReason === "length" ? "max_tokens" : completed.stopReason === "toolUse" ? "tool_use" : "end_turn",
396
- usage };
395
+ return coherentTurn({ blocks: piBlocks(completed, originalArguments.values), stopReason: completed.stopReason === "length" ? "max_tokens" : completed.stopReason === "toolUse" ? "tool_use" : "end_turn",
396
+ usage }, hasKnownUsage);
397
397
  }
398
398
  }