@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.
@@ -35,10 +35,12 @@ import type { Message, ContentBlock, FrozenPrefix } from "../core/project.ts";
35
35
  import { imageData } from "./images.ts";
36
36
  import type { AssistantBlock } from "../core/events.ts";
37
37
  import { assertNever } from "../core/exhaustive.ts";
38
+ import { isToolStopWithoutCall } from "../core/response-recovery.ts";
38
39
  import type { UsageSample } from "./usage.ts";
39
40
  import { type Effort, type ModelSpec, CLAUDE_OPUS_5 } from "./model.ts";
40
41
  import { withRetry, DEFAULT_RETRY, type RetryPolicy } from "./retry.ts";
41
42
  import { anthropicOAuthHeaders } from "./oauth.ts";
43
+ import { XENO_VERSION } from "./version.ts";
42
44
  import type { ProviderSendOptions } from "./provider-tool-policy.ts";
43
45
 
44
46
  export interface ToolSchema {
@@ -159,6 +161,27 @@ export class FailedResponseError extends Error {
159
161
  }
160
162
  }
161
163
 
164
+ /**
165
+ * A 200 response that contradicts itself, such as a tool stop with no tool
166
+ * call. retry.ts treats it like a transient stream failure: a fresh sample is
167
+ * the remedy, and exhausting retries must surface as a provider error rather
168
+ * than reach the loop as a silent final answer. The output token count tells
169
+ * "generated but lost in transit" apart from "nothing generated".
170
+ */
171
+ export class MalformedResponseError extends FailedResponseError {
172
+ constructor(detail: string, usage?: UsageSample) {
173
+ super(`malformed provider response: ${detail}${usage ? ` (${usage.output_tokens} output tokens)` : ""}`, usage);
174
+ this.name = "MalformedResponseError";
175
+ }
176
+ }
177
+
178
+ /** Every adapter returns through this, so no dialect can hand the loop a contradictory turn. */
179
+ export function coherentTurn(turn: Turn, usageKnown = true): Turn {
180
+ if (isToolStopWithoutCall(turn.stopReason, turn.blocks))
181
+ throw new MalformedResponseError(`stop_reason ${JSON.stringify(turn.stopReason)} without a tool call`, usageKnown ? turn.usage : undefined);
182
+ return turn;
183
+ }
184
+
162
185
  /** Thrown for a refusal, which arrives as HTTP 200 rather than an error. */
163
186
  export class RefusalError extends FailedResponseError {
164
187
  readonly category: string | null;
@@ -176,6 +199,12 @@ function buildAnthropicClient(
176
199
  apiKey: string | undefined,
177
200
  options: { fetch?: typeof fetch; oauth?: boolean; baseUrl?: string },
178
201
  ): AnthropicTransport {
202
+ // D18: the User-Agent, not SDK headers, identifies xeno on the wire. Left alone
203
+ // the SDK sends `Anthropic/JS <version>`, which model gateways fold into "custom
204
+ // client" attribution; provider-request.ts already stamps xeno's UA on the Pi
205
+ // path. Constructor defaultHeaders merge after the SDK's built-in UA, so this
206
+ // entry wins without touching per-request headers.
207
+ const identity = { "User-Agent": `xeno/${XENO_VERSION}` };
179
208
  if (options.oauth === true && apiKey) {
180
209
  // authToken is Bearer. Passing apiKey here would send x-api-key, which
181
210
  // an OAuth token is not accepted as.
@@ -183,7 +212,7 @@ function buildAnthropicClient(
183
212
  maxRetries: 0,
184
213
  ...(options.baseUrl ? { baseURL: options.baseUrl } : {}),
185
214
  authToken: apiKey,
186
- defaultHeaders: anthropicOAuthHeaders(),
215
+ defaultHeaders: { ...anthropicOAuthHeaders(), ...identity },
187
216
  ...(options.fetch ? { fetch: options.fetch } : {}),
188
217
  });
189
218
  }
@@ -191,6 +220,7 @@ function buildAnthropicClient(
191
220
  maxRetries: 0,
192
221
  ...(options.baseUrl ? { baseURL: options.baseUrl } : {}),
193
222
  ...(apiKey ? { apiKey } : {}),
223
+ defaultHeaders: identity,
194
224
  ...(options.fetch ? { fetch: options.fetch } : {}),
195
225
  });
196
226
  }
@@ -319,11 +349,11 @@ export class AnthropicProvider implements Provider {
319
349
  throw new RefusalError(details?.category ?? null, details?.explanation ?? null, assembled.usageKnown ? usage : undefined);
320
350
  }
321
351
 
322
- return {
352
+ return coherentTurn({
323
353
  blocks: assembled.blocks,
324
354
  stopReason: assembled.stopReason,
325
355
  usage,
326
- };
356
+ }, assembled.usageKnown);
327
357
  }
328
358
  }
329
359
 
@@ -33,6 +33,11 @@ function isStreamFailure(error: unknown): error is Error & { code?: string } {
33
33
  return error instanceof Error && error.name === "StreamError";
34
34
  }
35
35
 
36
+ /** provider.ts MalformedResponseError: a self-contradictory 200, structural for the same reason. */
37
+ function isMalformedResponse(error: unknown): boolean {
38
+ return error instanceof Error && error.name === "MalformedResponseError";
39
+ }
40
+
36
41
  export interface RetryPolicy {
37
42
  /**
38
43
  * Attempts after the first. Four means a request can take about 15s of
@@ -140,8 +145,10 @@ export function retryDecision(
140
145
  // A failure reported inside a 200 stream carries no status. Relays and
141
146
  // Anthropic's own `overloaded_error` use it for exactly the conditions a
142
147
  // 5xx would signal; anything else in a stream is a real rejection.
143
- if (status === undefined && isStreamFailure(error)) {
144
- if (!TRANSIENT_STREAM.test(`${error.code ?? ""} ${error.message}`)) {
148
+ if (status === undefined && (isStreamFailure(error) || isMalformedResponse(error))) {
149
+ // A contradictory response is a bad sample, not a rejection of the request:
150
+ // resend it like a dropped stream, and let the bound turn it into an error.
151
+ if (isStreamFailure(error) && !TRANSIENT_STREAM.test(`${error.code ?? ""} ${error.message}`)) {
145
152
  return { retry: false, reason: "the stream reported a non-transient error" };
146
153
  }
147
154
  const base = Math.min(0.5 * 2 ** attempt, 8) * 1000;
@@ -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
  /**
@@ -88,6 +88,34 @@ export function totalUsage(samples: readonly UsageSample[]): UsageTotals {
88
88
  return t;
89
89
  }
90
90
 
91
+ /**
92
+ * Below this a request's speed is not shown: a response that returns at once
93
+ * (a test double, a local cache) would divide into thousands of tokens per
94
+ * second. Assumed, not measured.
95
+ */
96
+ export const SPEED_MIN_MS = 250;
97
+
98
+ /**
99
+ * Output tokens per second over a set of model requests, or undefined when
100
+ * none can be measured (feat-sidebar-goal-and-speed). OpenCode's built-in
101
+ * turnTokensPerSecond uses the same definition: output over the time the
102
+ * requests took, each request counted once. Not output over the time since
103
+ * the first visible output -- output counts include reasoning that providers
104
+ * do not always stream (OpenAI summaries arrive late, Anthropic can omit
105
+ * them), so that would keep reasoning tokens and drop reasoning time, and
106
+ * overstate. Whole request time never overstates; an overloaded upstream
107
+ * reads slow, which is what the user is waiting on.
108
+ */
109
+ export function generationSpeed(samples: readonly UsageSample[]): number | undefined {
110
+ let tokens = 0, ms = 0;
111
+ for (const sample of samples) {
112
+ if (sample.output_tokens <= 0 || !(sample.ms >= SPEED_MIN_MS)) continue;
113
+ tokens += sample.output_tokens;
114
+ ms += sample.ms;
115
+ }
116
+ return ms > 0 ? tokens / (ms / 1000) : undefined;
117
+ }
118
+
91
119
  /**
92
120
  * Cost in equivalent base-input tokens, the unit appendix A's model uses.
93
121
  *
@@ -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
+ }
@@ -20,8 +20,10 @@
20
20
  * is for cooperative branches, not adversarial ones.
21
21
  */
22
22
 
23
- import { mkdtemp, rm, realpath } from "node:fs/promises";
24
- import { join } from "node:path";
23
+ import { mkdir, mkdtemp, rm, realpath } from "node:fs/promises";
24
+ import { existsSync } from "node:fs";
25
+ import { createHash } from "node:crypto";
26
+ import { basename, join } from "node:path";
25
27
  import { tmpdir } from "node:os";
26
28
 
27
29
  /** Branch prefix, so a stale worktree is identifiable months later. */
@@ -37,6 +39,37 @@ export type CreateResult =
37
39
  | { ok: true; path: string; branch: string; snapshot: { head: string; tree: string } }
38
40
  | { ok: false; error: string };
39
41
 
42
+ export interface CreateOptions {
43
+ /**
44
+ * Where the checkout goes. Default: a fresh temp directory, which suits a
45
+ * writing branch that lives for one subtask. A model-entered worktree is
46
+ * kept across sessions, so it names a stable parent and its own leaf.
47
+ */
48
+ location?: { parent: string; leaf: string };
49
+ /** Carry the parent's uncommitted files (default, as a branch needs). */
50
+ includeChanges?: boolean;
51
+ }
52
+
53
+ /**
54
+ * The stable home of model-entered worktrees for one repository.
55
+ *
56
+ * Under xeno's config directory rather than the project: inside `.xeno` it
57
+ * would collide with the rule that session state is never edited, and a
58
+ * sibling directory leaves xeno's checkouts in the user's project tree (Codex
59
+ * keeps its worktrees under its own home for the same reason). The digest of
60
+ * the repository path keeps two clones with the same name apart.
61
+ */
62
+ export function persistentWorktreeParent(configDir: string, repo: string): string {
63
+ const digest = createHash("sha256").update(repo).digest("hex").slice(0, 8);
64
+ return join(configDir, "worktrees", `${basename(repo)}-${digest}`);
65
+ }
66
+
67
+ /** The repository's main checkout: `git worktree list` always lists it first. */
68
+ export async function mainWorktree(dir: string): Promise<string | null> {
69
+ const listed = await listWorktrees(dir).catch(() => []);
70
+ return listed[0]?.path ?? null;
71
+ }
72
+
40
73
  export type RemoveResult =
41
74
  | { ok: true }
42
75
  | { ok: false; error: string; dirty: boolean };
@@ -61,7 +94,7 @@ export async function isGitRepo(dir: string): Promise<boolean> {
61
94
  return git(dir, ["rev-parse", "--git-dir"]).code === 0;
62
95
  }
63
96
 
64
- export async function createWorktree(repo: string, name: string): Promise<CreateResult> {
97
+ export async function createWorktree(repo: string, name: string, options: CreateOptions = {}): Promise<CreateResult> {
65
98
  // Refused rather than degraded. Falling back to the shared directory is the
66
99
  // exact failure isolation exists to prevent: parallel writing branches
67
100
  // would overwrite each other with nothing to indicate isolation was never
@@ -107,13 +140,24 @@ export async function createWorktree(repo: string, name: string): Promise<Create
107
140
  } finally { await rm(scratch, { recursive: true, force: true }); }
108
141
 
109
142
  const branch = `${WORKTREE_BRANCH_PREFIX}${name}`;
110
- const created = await mkdtemp(join(tmpdir(), `xeno-wt-${name}-`));
111
- // Resolved through symlinks, because git reports the real path and a
112
- // caller comparing the two would never match. On macOS /var is a symlink
113
- // to /private/var, so every temp path differs by default.
114
- const path = await realpath(created);
115
- // mkdtemp created it; git worktree add needs the path not to exist.
116
- await rm(path, { recursive: true, force: true });
143
+ if (git(repo, ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`]).code === 0) {
144
+ return { ok: false, error: `branch ${branch} already exists; choose another name or work in its existing checkout` };
145
+ }
146
+ let path: string;
147
+ if (options.location) {
148
+ await mkdir(options.location.parent, { recursive: true });
149
+ // Resolved for the same reason as the temp path below.
150
+ path = join(await realpath(options.location.parent), options.location.leaf);
151
+ if (existsSync(path)) return { ok: false, error: `${path} already exists; choose another name` };
152
+ } else {
153
+ const created = await mkdtemp(join(tmpdir(), `xeno-wt-${name}-`));
154
+ // Resolved through symlinks, because git reports the real path and a
155
+ // caller comparing the two would never match. On macOS /var is a symlink
156
+ // to /private/var, so every temp path differs by default.
157
+ path = await realpath(created);
158
+ // mkdtemp created it; git worktree add needs the path not to exist.
159
+ await rm(path, { recursive: true, force: true });
160
+ }
117
161
 
118
162
  // --lock in the same call: locking afterwards leaves a window in which a
119
163
  // concurrent prune can remove the worktree that was just created.
@@ -121,6 +165,7 @@ export async function createWorktree(repo: string, name: string): Promise<Create
121
165
  if (add.code !== 0) {
122
166
  return { ok: false, error: `git worktree add failed: ${add.stderr.trim()}` };
123
167
  }
168
+ if (options.includeChanges === false) return { ok: true, path, branch, snapshot: { head: head.stdout.trim(), tree: git(path, ["rev-parse", "HEAD^{tree}"]).stdout.trim() } };
124
169
  // Only this freshly created disposable checkout is reset. The child index
125
170
  // carries the parent's uncommitted input, so normal diff/inspection still sees it.
126
171
  const hydrate = git(path, ["read-tree", "--reset", "-u", tree]);
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Elapsed time as people read it (feat-sidebar-goal-and-speed): `59s`, then
3
+ * `1m 00s`, then `1h 01m 01s`. The steps are Codex's fmt_elapsed_compact; a
4
+ * running turn used to read `3725s`.
5
+ *
6
+ * Every field after the first is two digits, so a running clock keeps its
7
+ * width within a unit instead of jittering the labels beside it. Parts of a
8
+ * second never round up: the clock shows seconds that have fully passed.
9
+ */
10
+ export function formatElapsed(ms: number): string {
11
+ const total = Number.isFinite(ms) && ms > 0 ? Math.floor(ms / 1000) : 0;
12
+ if (total < 60) return `${total}s`;
13
+ const seconds = String(total % 60).padStart(2, "0");
14
+ if (total < 3600) return `${Math.floor(total / 60)}m ${seconds}s`;
15
+ return `${Math.floor(total / 3600)}h ${String(Math.floor(total % 3600 / 60)).padStart(2, "0")}m ${seconds}s`;
16
+ }
@@ -48,7 +48,7 @@ export function screenLayout(cols: number, rows: number, sidebarWanted = true):
48
48
  return { tier: "bare", sidebarWidth: 0, mainWidth: c, topbar: false };
49
49
  }
50
50
 
51
- export const STATUS_FIELDS = ["mode", "provider", "activity", "git", "model", "thinking", "context", "title", "workspace", "branch", "usage", "cost", "handoff", "tasks", "files", "background", "resources", "extension"] as const;
51
+ export const STATUS_FIELDS = ["mode", "provider", "activity", "git", "model", "thinking", "context", "goal", "title", "workspace", "branch", "usage", "cost", "handoff", "tasks", "files", "background", "resources", "extension"] as const;
52
52
  export type StatusField = (typeof STATUS_FIELDS)[number];
53
53
  /** Screen surfaces only; the terminal title is outside the frame and listed separately. */
54
54
  export type Place = "sidebar" | "topbar" | "topEdge" | "bottomEdge" | "hidden";
@@ -61,13 +61,13 @@ export type Place = "sidebar" | "topbar" | "topEdge" | "bottomEdge" | "hidden";
61
61
  export function placement(tier: Tier): Readonly<Record<StatusField, Place>> {
62
62
  switch (tier) {
63
63
  case "sidebar":
64
- return { mode: "bottomEdge", provider: "topbar", activity: "topEdge", git: "sidebar", model: "bottomEdge", thinking: "bottomEdge", context: "sidebar", title: "sidebar", workspace: "sidebar", branch: "sidebar",
64
+ return { mode: "bottomEdge", provider: "topbar", activity: "topEdge", git: "sidebar", model: "bottomEdge", thinking: "bottomEdge", context: "sidebar", goal: "sidebar", title: "sidebar", workspace: "sidebar", branch: "sidebar",
65
65
  usage: "sidebar", cost: "sidebar", handoff: "sidebar", tasks: "sidebar", files: "sidebar", background: "sidebar", resources: "sidebar", extension: "sidebar" };
66
66
  case "topbar":
67
- return { mode: "bottomEdge", provider: "topbar", activity: "topEdge", git: "hidden", model: "bottomEdge", thinking: "bottomEdge", context: "bottomEdge", title: "topbar", workspace: "topbar", branch: "topbar",
67
+ return { mode: "bottomEdge", provider: "topbar", activity: "topEdge", git: "hidden", model: "bottomEdge", thinking: "bottomEdge", context: "bottomEdge", goal: "bottomEdge", title: "topbar", workspace: "topbar", branch: "topbar",
68
68
  usage: "hidden", cost: "topbar", handoff: "topbar", tasks: "hidden", files: "hidden", background: "topbar", resources: "hidden", extension: "topbar" };
69
69
  case "bare":
70
- return { mode: "bottomEdge", provider: "hidden", activity: "topEdge", git: "hidden", model: "bottomEdge", thinking: "bottomEdge", context: "bottomEdge", title: "hidden", workspace: "hidden", branch: "hidden",
70
+ return { mode: "bottomEdge", provider: "hidden", activity: "topEdge", git: "hidden", model: "bottomEdge", thinking: "bottomEdge", context: "bottomEdge", goal: "bottomEdge", title: "hidden", workspace: "hidden", branch: "hidden",
71
71
  usage: "hidden", cost: "hidden", handoff: "hidden", tasks: "hidden", files: "hidden", background: "hidden", resources: "hidden", extension: "hidden" };
72
72
  default: return assertNever(tier, "placement");
73
73
  }