@cruxy/cli 0.27.0 → 0.28.1

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.
@@ -99,6 +99,12 @@ export class Session {
99
99
  * history is unaffected.
100
100
  */
101
101
  async send(userPrompt, renderer) {
102
+ // Per-turn tool lifecycle (C.13): re-arm any per-episode tool state (e.g. the
103
+ // run_tests consecutive-failure breaker) at the top of the turn, so a fresh
104
+ // user instruction starts clean. The breaker still latches across the many
105
+ // model iterations WITHIN this turn — it just never leaks into the next one.
106
+ for (const tool of this.args.registry.list())
107
+ tool.onTurnStart?.();
102
108
  this.messages.push({ role: "user", content: userPrompt });
103
109
  // Usage telemetry (C.22): one collector per run. `onReq` is threaded into
104
110
  // every real model request this turn drives — the main loop, compaction, and
@@ -1,7 +1,7 @@
1
1
  import { Command } from "commander";
2
2
  import { logger } from "../../utils/logger.js";
3
3
  import { loadConfig, resolveApiKey } from "../../config/index.js";
4
- import { authMissingKey, shouldUseColor, usageError, } from "../../errors/index.js";
4
+ import { agentIncomplete, authMissingKey, shouldUseColor, usageError, } from "../../errors/index.js";
5
5
  import { createRenderer } from "../../render/index.js";
6
6
  import { themeForColor } from "../../theme/index.js";
7
7
  import { summarizeRuns, renderSummary, } from "../../usage/index.js";
@@ -217,8 +217,9 @@ export function runCommand() {
217
217
  // Provider/network/auth failures propagate to the top-level boundary,
218
218
  // which classifies them (e.g. CRUXY_E_GATEWAY_UNREACHABLE) and exits with
219
219
  // the matching code — a one-shot run must fail non-zero on error.
220
+ let result;
220
221
  try {
221
- const result = await session.send(prompt, renderer);
222
+ result = await session.send(prompt, renderer);
222
223
  logger.debug(`agent finished: ${result.stop} after ${result.iterations} turn(s); ` +
223
224
  `tokens in/out ${result.usage.input_tokens}/${result.usage.output_tokens}`);
224
225
  }
@@ -235,12 +236,26 @@ export function runCommand() {
235
236
  await resetMcpServices();
236
237
  }
237
238
  // End-of-run usage summary (C.22): honest tokens + per-tier breakdown +
238
- // cost (only when priced). Printed after the live region is torn down.
239
- // Reaches here only on success a thrown run propagates past it and
240
- // never affects the run's outcome.
239
+ // cost (only when priced). Printed after the live region is torn down, for
240
+ // BOTH a completed run and one that gave up below the tokens burned are
241
+ // useful either way. A thrown run (provider error) propagates past it.
241
242
  if (config.usage.enabled && session.lastRun) {
242
243
  printRunUsage(session.lastRun, config);
243
244
  }
245
+ // Fail loud on a non-completed stop (#3/#5): a one-shot run that hit the
246
+ // iteration cap or a token budget (or was cancelled) MUST exit non-zero —
247
+ // otherwise CI reads a gave-up run as success. The partial history already
248
+ // streamed to stdout stands; the boundary prints the coded reason to stderr
249
+ // and exits with CRUXY_E_AGENT_INCOMPLETE's code. `result` is always set
250
+ // here — a thrown send would have propagated past this point.
251
+ if (result && result.stop !== "completed") {
252
+ throw agentIncomplete({
253
+ stop: result.stop,
254
+ iterations: result.iterations,
255
+ reason: result.stopReason,
256
+ maxIterations: config.agent.maxIterations,
257
+ });
258
+ }
244
259
  });
245
260
  }
246
261
  /** Render the just-finished run's usage as a single themed line (C.22). */
@@ -266,20 +266,20 @@ export declare const SubagentConfigSchema: z.ZodObject<{
266
266
  /** Optional wall-clock cap; unset means no time limit. */
267
267
  timeoutMs: z.ZodOptional<z.ZodNumber>;
268
268
  }, "strict", z.ZodTypeAny, {
269
- maxTokens: number;
270
269
  maxIterations: number;
270
+ maxTokens: number;
271
271
  timeoutMs?: number | undefined;
272
272
  }, {
273
273
  timeoutMs?: number | undefined;
274
- maxTokens?: number | undefined;
275
274
  maxIterations?: number | undefined;
275
+ maxTokens?: number | undefined;
276
276
  }>>;
277
277
  }, "strict", z.ZodTypeAny, {
278
278
  maxDepth: number;
279
279
  maxConcurrency: number;
280
280
  defaultBudget: {
281
- maxTokens: number;
282
281
  maxIterations: number;
282
+ maxTokens: number;
283
283
  timeoutMs?: number | undefined;
284
284
  };
285
285
  }, {
@@ -287,8 +287,8 @@ export declare const SubagentConfigSchema: z.ZodObject<{
287
287
  maxConcurrency?: number | undefined;
288
288
  defaultBudget?: {
289
289
  timeoutMs?: number | undefined;
290
- maxTokens?: number | undefined;
291
290
  maxIterations?: number | undefined;
291
+ maxTokens?: number | undefined;
292
292
  } | undefined;
293
293
  }>;
294
294
  /**
@@ -1217,20 +1217,20 @@ export declare const CruxyConfigSchema: z.ZodObject<{
1217
1217
  /** Optional wall-clock cap; unset means no time limit. */
1218
1218
  timeoutMs: z.ZodOptional<z.ZodNumber>;
1219
1219
  }, "strict", z.ZodTypeAny, {
1220
- maxTokens: number;
1221
1220
  maxIterations: number;
1221
+ maxTokens: number;
1222
1222
  timeoutMs?: number | undefined;
1223
1223
  }, {
1224
1224
  timeoutMs?: number | undefined;
1225
- maxTokens?: number | undefined;
1226
1225
  maxIterations?: number | undefined;
1226
+ maxTokens?: number | undefined;
1227
1227
  }>>;
1228
1228
  }, "strict", z.ZodTypeAny, {
1229
1229
  maxDepth: number;
1230
1230
  maxConcurrency: number;
1231
1231
  defaultBudget: {
1232
- maxTokens: number;
1233
1232
  maxIterations: number;
1233
+ maxTokens: number;
1234
1234
  timeoutMs?: number | undefined;
1235
1235
  };
1236
1236
  }, {
@@ -1238,8 +1238,8 @@ export declare const CruxyConfigSchema: z.ZodObject<{
1238
1238
  maxConcurrency?: number | undefined;
1239
1239
  defaultBudget?: {
1240
1240
  timeoutMs?: number | undefined;
1241
- maxTokens?: number | undefined;
1242
1241
  maxIterations?: number | undefined;
1242
+ maxTokens?: number | undefined;
1243
1243
  } | undefined;
1244
1244
  }>>;
1245
1245
  jobs: z.ZodDefault<z.ZodObject<{
@@ -1720,8 +1720,8 @@ export declare const CruxyConfigSchema: z.ZodObject<{
1720
1720
  maxDepth: number;
1721
1721
  maxConcurrency: number;
1722
1722
  defaultBudget: {
1723
- maxTokens: number;
1724
1723
  maxIterations: number;
1724
+ maxTokens: number;
1725
1725
  timeoutMs?: number | undefined;
1726
1726
  };
1727
1727
  };
@@ -1873,8 +1873,8 @@ export declare const CruxyConfigSchema: z.ZodObject<{
1873
1873
  maxConcurrency?: number | undefined;
1874
1874
  defaultBudget?: {
1875
1875
  timeoutMs?: number | undefined;
1876
- maxTokens?: number | undefined;
1877
1876
  maxIterations?: number | undefined;
1877
+ maxTokens?: number | undefined;
1878
1878
  } | undefined;
1879
1879
  } | undefined;
1880
1880
  model?: {
@@ -362,6 +362,22 @@ export declare function webFetchFailed(url: string, underlying?: unknown): Cruxy
362
362
  */
363
363
  export declare function webBlockedHost(url: string, reason: string): CruxyError;
364
364
  export declare function internal(underlying?: unknown): CruxyError;
365
+ /**
366
+ * A one-shot `cruxy run` ended without completing: the agent loop hit a hard stop
367
+ * — the iteration cap (`max_iterations`) or a token budget (`budget`) — or was
368
+ * cancelled (`aborted`). We fail loud with a non-zero exit so CI never reads a
369
+ * gave-up run as success; the partial work already streamed to stdout is preserved
370
+ * and the specific reason is named. `stop` is taken as a plain string so this
371
+ * (low-level) module needn't depend on the agent's result type.
372
+ */
373
+ export declare function agentIncomplete(info: {
374
+ stop: string;
375
+ iterations: number;
376
+ /** The loop's stopReason (the concrete budget message), when `stop === "budget"`. */
377
+ reason?: string;
378
+ /** The turn ceiling in force, surfaced for `max_iterations`. */
379
+ maxIterations?: number;
380
+ }): CruxyError;
365
381
  /**
366
382
  * Map a known provider/transport error (from `@cruxy/sdk`) to a typed
367
383
  * {@link CruxyError}, or `null` if it isn't one. Order matters: specific
@@ -1193,6 +1193,53 @@ export function internal(underlying) {
1193
1193
  underlying,
1194
1194
  });
1195
1195
  }
1196
+ // ── one-shot run outcome (exit 20) ───────────────────────────────────────────
1197
+ /**
1198
+ * A one-shot `cruxy run` ended without completing: the agent loop hit a hard stop
1199
+ * — the iteration cap (`max_iterations`) or a token budget (`budget`) — or was
1200
+ * cancelled (`aborted`). We fail loud with a non-zero exit so CI never reads a
1201
+ * gave-up run as success; the partial work already streamed to stdout is preserved
1202
+ * and the specific reason is named. `stop` is taken as a plain string so this
1203
+ * (low-level) module needn't depend on the agent's result type.
1204
+ */
1205
+ export function agentIncomplete(info) {
1206
+ const { stop, iterations, reason, maxIterations } = info;
1207
+ const turns = `${iterations} turn${iterations === 1 ? "" : "s"}`;
1208
+ const meta = { stop, iterations };
1209
+ if (stop === "budget") {
1210
+ return new CruxyError({
1211
+ code: ErrorCode.AgentIncomplete,
1212
+ title: "cruxy stopped: token budget reached before the task finished",
1213
+ cause: `${reason ?? "the token budget was exhausted"} (after ${turns}). The work so far is shown above.`,
1214
+ nextSteps: [
1215
+ "review the partial output above, then re-run with a narrower prompt",
1216
+ "raise or unset `agent.maxTokensPerTurn` if the task legitimately needs more",
1217
+ ],
1218
+ meta,
1219
+ });
1220
+ }
1221
+ if (stop === "aborted") {
1222
+ return new CruxyError({
1223
+ code: ErrorCode.AgentIncomplete,
1224
+ title: "cruxy stopped: the run was cancelled before the task finished",
1225
+ cause: `cancelled after ${turns}. The work so far is shown above.`,
1226
+ nextSteps: ["re-run to continue in a fresh session"],
1227
+ meta,
1228
+ });
1229
+ }
1230
+ // max_iterations (and any other non-completed stop, defensively).
1231
+ const cap = maxIterations !== undefined ? ` (${maxIterations})` : "";
1232
+ return new CruxyError({
1233
+ code: ErrorCode.AgentIncomplete,
1234
+ title: "cruxy stopped: iteration cap reached before the task finished",
1235
+ cause: `reached the turn limit${cap} after ${turns} without completing. The work so far is shown above.`,
1236
+ nextSteps: [
1237
+ "review the partial output above, then re-run with a narrower prompt",
1238
+ "raise `agent.maxIterations` if the task legitimately needs more turns",
1239
+ ],
1240
+ meta,
1241
+ });
1242
+ }
1196
1243
  /**
1197
1244
  * Map a known provider/transport error (from `@cruxy/sdk`) to a typed
1198
1245
  * {@link CruxyError}, or `null` if it isn't one. Order matters: specific
@@ -201,6 +201,11 @@ export declare const ErrorCode: {
201
201
  * `jobs.enabled` is false. The feature is opt-in; surfaced with how to enable it
202
202
  * rather than pretending there are simply no jobs. */
203
203
  readonly JobsDisabled: "CRUXY_E_JOBS_DISABLED";
204
+ /** A one-shot `cruxy run` ended WITHOUT completing the task: the agent loop hit
205
+ * a hard stop (the iteration cap or a token budget) or was cancelled, rather
206
+ * than finishing on its own. Fail loud with a non-zero exit so CI never reads a
207
+ * gave-up run as success — the partial work already streamed to stdout stands. */
208
+ readonly AgentIncomplete: "CRUXY_E_AGENT_INCOMPLETE";
204
209
  };
205
210
  export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
206
211
  /** The process exit code for an error code (defaults to 1 for safety). */
@@ -222,6 +222,12 @@ export const ErrorCode = {
222
222
  * `jobs.enabled` is false. The feature is opt-in; surfaced with how to enable it
223
223
  * rather than pretending there are simply no jobs. */
224
224
  JobsDisabled: "CRUXY_E_JOBS_DISABLED",
225
+ // one-shot run outcome (exit 20)
226
+ /** A one-shot `cruxy run` ended WITHOUT completing the task: the agent loop hit
227
+ * a hard stop (the iteration cap or a token budget) or was cancelled, rather
228
+ * than finishing on its own. Fail loud with a non-zero exit so CI never reads a
229
+ * gave-up run as success — the partial work already streamed to stdout stands. */
230
+ AgentIncomplete: "CRUXY_E_AGENT_INCOMPLETE",
225
231
  };
226
232
  /**
227
233
  * Category exit codes. Distinct per category so a caller (CI, a script) can
@@ -343,6 +349,10 @@ const EXIT_CODES = {
343
349
  [ErrorCode.JobLimit]: 2,
344
350
  [ErrorCode.JobNotFound]: 19,
345
351
  [ErrorCode.JobsDisabled]: 19,
352
+ // One-shot run outcome. A run that hit the iteration cap / token budget (or was
353
+ // cancelled) without completing gets its own greppable exit code, so CI can tell
354
+ // "the agent gave up" apart from a provider/auth/config failure.
355
+ [ErrorCode.AgentIncomplete]: 20,
346
356
  };
347
357
  /** The process exit code for an error code (defaults to 1 for safety). */
348
358
  export function exitCodeFor(code) {
@@ -19,14 +19,14 @@ declare const parameters: z.ZodObject<{
19
19
  }, "strip", z.ZodTypeAny, {
20
20
  task: string;
21
21
  root?: string | undefined;
22
- maxTokens?: number | undefined;
23
22
  maxIterations?: number | undefined;
23
+ maxTokens?: number | undefined;
24
24
  tools?: [string, ...string[]] | undefined;
25
25
  }, {
26
26
  task: string;
27
27
  root?: string | undefined;
28
- maxTokens?: number | undefined;
29
28
  maxIterations?: number | undefined;
29
+ maxTokens?: number | undefined;
30
30
  tools?: [string, ...string[]] | undefined;
31
31
  }>;
32
32
  /** Build the `run_in_background` tool bound to the session's job manager. */
@@ -15,13 +15,13 @@ declare const parameters: z.ZodObject<{
15
15
  maxTokens: z.ZodOptional<z.ZodNumber>;
16
16
  }, "strip", z.ZodTypeAny, {
17
17
  task: string;
18
- maxTokens?: number | undefined;
19
18
  maxIterations?: number | undefined;
19
+ maxTokens?: number | undefined;
20
20
  tools?: [string, ...string[]] | undefined;
21
21
  }, {
22
22
  task: string;
23
- maxTokens?: number | undefined;
24
23
  maxIterations?: number | undefined;
24
+ maxTokens?: number | undefined;
25
25
  tools?: [string, ...string[]] | undefined;
26
26
  }>;
27
27
  /** Build a `spawn_subagent` tool bound to `orchestrator` at `depth`. */
@@ -36,42 +36,42 @@ declare const batchParameters: z.ZodObject<{
36
36
  }, "strip", z.ZodTypeAny, {
37
37
  task: string;
38
38
  root?: string | undefined;
39
- maxTokens?: number | undefined;
40
39
  maxIterations?: number | undefined;
40
+ maxTokens?: number | undefined;
41
41
  tools?: [string, ...string[]] | undefined;
42
42
  }, {
43
43
  task: string;
44
44
  root?: string | undefined;
45
- maxTokens?: number | undefined;
46
45
  maxIterations?: number | undefined;
46
+ maxTokens?: number | undefined;
47
47
  tools?: [string, ...string[]] | undefined;
48
48
  }>, "atleastone">;
49
49
  }, "strip", z.ZodTypeAny, {
50
50
  tasks: [{
51
51
  task: string;
52
52
  root?: string | undefined;
53
- maxTokens?: number | undefined;
54
53
  maxIterations?: number | undefined;
54
+ maxTokens?: number | undefined;
55
55
  tools?: [string, ...string[]] | undefined;
56
56
  }, ...{
57
57
  task: string;
58
58
  root?: string | undefined;
59
- maxTokens?: number | undefined;
60
59
  maxIterations?: number | undefined;
60
+ maxTokens?: number | undefined;
61
61
  tools?: [string, ...string[]] | undefined;
62
62
  }[]];
63
63
  }, {
64
64
  tasks: [{
65
65
  task: string;
66
66
  root?: string | undefined;
67
- maxTokens?: number | undefined;
68
67
  maxIterations?: number | undefined;
68
+ maxTokens?: number | undefined;
69
69
  tools?: [string, ...string[]] | undefined;
70
70
  }, ...{
71
71
  task: string;
72
72
  root?: string | undefined;
73
- maxTokens?: number | undefined;
74
73
  maxIterations?: number | undefined;
74
+ maxTokens?: number | undefined;
75
75
  tools?: [string, ...string[]] | undefined;
76
76
  }[]];
77
77
  }>;
@@ -10,19 +10,37 @@ import type { TestCommand, TestRunner } from "./types.js";
10
10
  */
11
11
  /**
12
12
  * Counts consecutive FAILING test executions; a green run resets it. When the
13
- * count reaches the cap, the next attempt is refused with the coded
14
- * CRUXY_E_TEST_ITERATION_LIMIT result (nothing executes), and the counter
15
- * resets — so the episode ends loudly, but a deliberate later attempt (a new
16
- * user instruction) starts fresh rather than finding a permanently dead tool.
13
+ * count reaches the cap, `trip` LATCHES: every further attempt is refused with
14
+ * the coded CRUXY_E_TEST_ITERATION_LIMIT result (nothing executes) for the rest
15
+ * of the episode. Because a refused attempt never runs, {@link record} is never
16
+ * reached and the counter stays pinned at the cap the breaker cannot re-arm
17
+ * itself mid-episode. (This replaces a bug where `trip` zeroed the counter as it
18
+ * tripped, so the very next call ran again and the "cap" was a speed bump, not a
19
+ * stop: the model could burn the whole turn re-running a failing suite.)
20
+ *
21
+ * The latch is cleared by {@link reset}, called at the top of each user turn
22
+ * (see `Session.send`) — so a fresh instruction starts with the full budget,
23
+ * but the model cannot bypass the cap by simply calling the tool again within
24
+ * one episode. A green run mid-streak still re-arms the budget via {@link record}.
17
25
  * Per-turn work stays bounded regardless via `agent.maxIterations`.
18
26
  */
19
27
  export declare class TestIterationBudget {
20
28
  private failedRuns;
21
29
  /** Runs already spent in the current failing streak. */
22
30
  get spent(): number;
23
- /** True when the next run must be refused; resets the streak as it trips. */
31
+ /**
32
+ * True when the next run must be refused. Latches at the cap: once the streak
33
+ * reaches `maxIterations` this keeps returning true (the refused call never
34
+ * executes, so {@link record} never runs and the counter stays pinned) until
35
+ * {@link reset} clears it at the next user turn.
36
+ */
24
37
  trip(maxIterations: number): boolean;
25
38
  record(passed: boolean): void;
39
+ /**
40
+ * Clear the failing streak, re-arming the breaker. Called at the start of each
41
+ * user turn so a new instruction starts fresh; within one turn the latch holds.
42
+ */
43
+ reset(): void;
26
44
  }
27
45
  declare const parameters: z.ZodObject<{
28
46
  command: z.ZodOptional<z.ZodString>;
@@ -12,10 +12,18 @@ import { SandboxTestRunner } from "./sandbox-runner.js";
12
12
  */
13
13
  /**
14
14
  * Counts consecutive FAILING test executions; a green run resets it. When the
15
- * count reaches the cap, the next attempt is refused with the coded
16
- * CRUXY_E_TEST_ITERATION_LIMIT result (nothing executes), and the counter
17
- * resets — so the episode ends loudly, but a deliberate later attempt (a new
18
- * user instruction) starts fresh rather than finding a permanently dead tool.
15
+ * count reaches the cap, `trip` LATCHES: every further attempt is refused with
16
+ * the coded CRUXY_E_TEST_ITERATION_LIMIT result (nothing executes) for the rest
17
+ * of the episode. Because a refused attempt never runs, {@link record} is never
18
+ * reached and the counter stays pinned at the cap the breaker cannot re-arm
19
+ * itself mid-episode. (This replaces a bug where `trip` zeroed the counter as it
20
+ * tripped, so the very next call ran again and the "cap" was a speed bump, not a
21
+ * stop: the model could burn the whole turn re-running a failing suite.)
22
+ *
23
+ * The latch is cleared by {@link reset}, called at the top of each user turn
24
+ * (see `Session.send`) — so a fresh instruction starts with the full budget,
25
+ * but the model cannot bypass the cap by simply calling the tool again within
26
+ * one episode. A green run mid-streak still re-arms the budget via {@link record}.
19
27
  * Per-turn work stays bounded regardless via `agent.maxIterations`.
20
28
  */
21
29
  export class TestIterationBudget {
@@ -24,16 +32,25 @@ export class TestIterationBudget {
24
32
  get spent() {
25
33
  return this.failedRuns;
26
34
  }
27
- /** True when the next run must be refused; resets the streak as it trips. */
35
+ /**
36
+ * True when the next run must be refused. Latches at the cap: once the streak
37
+ * reaches `maxIterations` this keeps returning true (the refused call never
38
+ * executes, so {@link record} never runs and the counter stays pinned) until
39
+ * {@link reset} clears it at the next user turn.
40
+ */
28
41
  trip(maxIterations) {
29
- if (this.failedRuns < maxIterations)
30
- return false;
31
- this.failedRuns = 0;
32
- return true;
42
+ return this.failedRuns >= maxIterations;
33
43
  }
34
44
  record(passed) {
35
45
  this.failedRuns = passed ? 0 : this.failedRuns + 1;
36
46
  }
47
+ /**
48
+ * Clear the failing streak, re-arming the breaker. Called at the start of each
49
+ * user turn so a new instruction starts fresh; within one turn the latch holds.
50
+ */
51
+ reset() {
52
+ this.failedRuns = 0;
53
+ }
37
54
  }
38
55
  const parameters = z.object({
39
56
  command: z
@@ -69,6 +86,12 @@ export function makeRunTestsTool(deps = {}) {
69
86
  "Use it to verify changes: run, read the failures, fix, re-run. The edit→re-run loop is " +
70
87
  "capped — when the iteration limit trips, stop, summarize the remaining failures, and ask the user.",
71
88
  parameters,
89
+ // Per-turn lifecycle (C.13): a new user instruction re-arms the failing-run
90
+ // breaker. Within one turn the latch holds (the model can't bypass the cap by
91
+ // re-calling); across turns a deliberate retry starts fresh.
92
+ onTurnStart() {
93
+ budget.reset();
94
+ },
72
95
  async execute(input, ctx) {
73
96
  // Resolve the command first: detection failure needs no approval and
74
97
  // must be a coded, actionable error — never an invented command.
@@ -2,7 +2,7 @@ import { promises as fs } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { z } from "zod";
4
4
  import { resolveToolPath } from "./paths.js";
5
- import { countOccurrences } from "./edit-file.js";
5
+ import { applyEol, detectEol, findMatch, tierLabel } from "./match.js";
6
6
  /** How many leading lines of a created file the approval preview shows. */
7
7
  const PREVIEW_LINES = 20;
8
8
  const HunkSchema = z.object({
@@ -143,25 +143,29 @@ async function planOp(i, op, abs, ctx) {
143
143
  }
144
144
  return { ok: false, error: opError(i, op, err.message) };
145
145
  }
146
+ // Detect the file's line ending once, from the original bytes, so every hunk
147
+ // re-encodes newStr to the same convention as content mutates across hunks.
148
+ const fileEol = detectEol(content);
146
149
  for (let h = 0; h < op.hunks.length; h++) {
147
150
  const { oldStr, newStr } = op.hunks[h];
148
- const matches = countOccurrences(content, oldStr);
149
- if (matches === 0) {
151
+ const match = findMatch(content, oldStr);
152
+ if (match.kind === "none") {
150
153
  return {
151
154
  ok: false,
152
155
  error: opError(i, op, `hunk ${h + 1}: oldStr not found`),
153
156
  };
154
157
  }
155
- if (matches > 1) {
158
+ if (match.kind === "ambiguous") {
156
159
  return {
157
160
  ok: false,
158
- error: opError(i, op, `hunk ${h + 1}: oldStr not unique (${matches} matches)`),
161
+ error: opError(i, op, `hunk ${h + 1}: oldStr not unique (${match.count} matches${tierLabel(match.tier)})`),
159
162
  };
160
163
  }
161
- // Replace by index so `$` patterns in newStr aren't interpreted.
162
- const idx = content.indexOf(oldStr);
164
+ // Splice by offset so `$` patterns in newStr aren't interpreted.
163
165
  content =
164
- content.slice(0, idx) + newStr + content.slice(idx + oldStr.length);
166
+ content.slice(0, match.start) +
167
+ applyEol(newStr, fileEol) +
168
+ content.slice(match.end);
165
169
  }
166
170
  return {
167
171
  ok: true,
@@ -1,7 +1,5 @@
1
1
  import { z } from "zod";
2
2
  import type { Tool } from "../types.js";
3
- /** Count non-overlapping exact occurrences of `needle` in `haystack`. */
4
- export declare function countOccurrences(haystack: string, needle: string): number;
5
3
  /**
6
4
  * Replace one exact, unique occurrence of `old_str` with `new_str` in a file.
7
5
  * The uniqueness requirement is checked before approval so the model can fix an
@@ -1,16 +1,7 @@
1
1
  import { promises as fs } from "node:fs";
2
2
  import { z } from "zod";
3
3
  import { resolveToolPath } from "./paths.js";
4
- /** Count non-overlapping exact occurrences of `needle` in `haystack`. */
5
- export function countOccurrences(haystack, needle) {
6
- let count = 0;
7
- let i = haystack.indexOf(needle);
8
- while (i !== -1) {
9
- count++;
10
- i = haystack.indexOf(needle, i + needle.length);
11
- }
12
- return count;
13
- }
4
+ import { applyEol, detectEol, findMatch, tierLabel } from "./match.js";
14
5
  /**
15
6
  * Replace one exact, unique occurrence of `old_str` with `new_str` in a file.
16
7
  * The uniqueness requirement is checked before approval so the model can fix an
@@ -47,14 +38,14 @@ export const editFileTool = {
47
38
  }
48
39
  return { ok: false, error: err.message };
49
40
  }
50
- const matches = countOccurrences(content, input.old_str);
51
- if (matches === 0) {
41
+ const match = findMatch(content, input.old_str);
42
+ if (match.kind === "none") {
52
43
  return { ok: false, error: `old_str not found in ${input.path}` };
53
44
  }
54
- if (matches > 1) {
45
+ if (match.kind === "ambiguous") {
55
46
  return {
56
47
  ok: false,
57
- error: `old_str not unique (${matches} matches); add surrounding context to disambiguate`,
48
+ error: `old_str not unique (${match.count} matches${tierLabel(match.tier)}); add surrounding context to disambiguate`,
58
49
  };
59
50
  }
60
51
  const decision = await ctx.requestApproval({
@@ -68,11 +59,11 @@ export const editFileTool = {
68
59
  error: decision.feedback ?? `edit to ${input.path} denied`,
69
60
  };
70
61
  }
71
- // Replace the single occurrence by index to avoid `$`-pattern interpretation.
72
- const idx = content.indexOf(input.old_str);
73
- const updated = content.slice(0, idx) +
74
- input.new_str +
75
- content.slice(idx + input.old_str.length);
62
+ // Splice the matched span by offset (avoids `$`-pattern interpretation) and
63
+ // re-encode new_str to the file's line ending so a CRLF file stays CRLF.
64
+ const updated = content.slice(0, match.start) +
65
+ applyEol(input.new_str, detectEol(content)) +
66
+ content.slice(match.end);
76
67
  try {
77
68
  await fs.writeFile(abs, updated, "utf8");
78
69
  return { ok: true, output: `edited ${input.path}` };
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Locating `old_str` inside a file for edit_file / apply_patch.
3
+ *
4
+ * Models emit `\n` line endings, but a file on disk may be `\r\n` (Windows
5
+ * checkouts/editors) — a byte-exact `indexOf` then never matches and the model
6
+ * can't replace existing code. We match in ordered tiers, tightest first, and
7
+ * stop at the first tier that finds anything:
8
+ *
9
+ * 0. exact — raw bytes; every currently-working edit takes this path
10
+ * 1. eol — CRLF and lone CR treated as LF (comparison only)
11
+ * 2. eol+trailws — tier 1, plus trailing whitespace ignored per line
12
+ *
13
+ * Looser tiers compare against a normalized copy but return offsets into the
14
+ * ORIGINAL string, so the splice preserves every unmatched byte verbatim (the
15
+ * file is never wholesale re-encoded). Ambiguity is never resolved by guessing:
16
+ * more than one match at a tier is a hard error, and we do not fall through to a
17
+ * looser tier (looser can only be more ambiguous, never less).
18
+ */
19
+ export type Tier = "exact" | "eol" | "eol+trailws";
20
+ export type MatchResult = {
21
+ kind: "found";
22
+ start: number;
23
+ end: number;
24
+ } | {
25
+ kind: "ambiguous";
26
+ count: number;
27
+ tier: Tier;
28
+ } | {
29
+ kind: "none";
30
+ };
31
+ /**
32
+ * Find the single occurrence of `oldStr` in `content`, tolerating line-ending
33
+ * and trailing-whitespace differences. Returns original-string byte offsets on
34
+ * a unique match, `ambiguous` if the tightest matching tier had >1 hit, or
35
+ * `none` if no tier matched.
36
+ */
37
+ export declare function findMatch(content: string, oldStr: string): MatchResult;
38
+ /** The file's line ending: CRLF if the first newline is `\r\n`, else LF. */
39
+ export declare function detectEol(content: string): "\r\n" | "\n";
40
+ /** Re-encode `text`'s line endings to `eol` so an edit matches the file. */
41
+ export declare function applyEol(text: string, eol: "\r\n" | "\n"): string;
42
+ /** Human phrase for the tier named in an ambiguity error. */
43
+ export declare function tierLabel(tier: Tier): string;
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Locating `old_str` inside a file for edit_file / apply_patch.
3
+ *
4
+ * Models emit `\n` line endings, but a file on disk may be `\r\n` (Windows
5
+ * checkouts/editors) — a byte-exact `indexOf` then never matches and the model
6
+ * can't replace existing code. We match in ordered tiers, tightest first, and
7
+ * stop at the first tier that finds anything:
8
+ *
9
+ * 0. exact — raw bytes; every currently-working edit takes this path
10
+ * 1. eol — CRLF and lone CR treated as LF (comparison only)
11
+ * 2. eol+trailws — tier 1, plus trailing whitespace ignored per line
12
+ *
13
+ * Looser tiers compare against a normalized copy but return offsets into the
14
+ * ORIGINAL string, so the splice preserves every unmatched byte verbatim (the
15
+ * file is never wholesale re-encoded). Ambiguity is never resolved by guessing:
16
+ * more than one match at a tier is a hard error, and we do not fall through to a
17
+ * looser tier (looser can only be more ambiguous, never less).
18
+ */
19
+ const TIERS = ["exact", "eol", "eol+trailws"];
20
+ /**
21
+ * Find the single occurrence of `oldStr` in `content`, tolerating line-ending
22
+ * and trailing-whitespace differences. Returns original-string byte offsets on
23
+ * a unique match, `ambiguous` if the tightest matching tier had >1 hit, or
24
+ * `none` if no tier matched.
25
+ */
26
+ export function findMatch(content, oldStr) {
27
+ for (const tier of TIERS) {
28
+ const occ = occurrences(content, oldStr, tier);
29
+ if (occ.length === 1) {
30
+ return { kind: "found", start: occ[0].start, end: occ[0].end };
31
+ }
32
+ if (occ.length > 1) {
33
+ return { kind: "ambiguous", count: occ.length, tier };
34
+ }
35
+ // 0 matches → try the next, looser tier.
36
+ }
37
+ return { kind: "none" };
38
+ }
39
+ /** Non-overlapping occurrences of `oldStr` under `tier`, as original offsets. */
40
+ function occurrences(content, oldStr, tier) {
41
+ const out = [];
42
+ if (tier === "exact") {
43
+ let i = content.indexOf(oldStr);
44
+ while (i !== -1) {
45
+ out.push({ start: i, end: i + oldStr.length });
46
+ i = content.indexOf(oldStr, i + oldStr.length);
47
+ }
48
+ return out;
49
+ }
50
+ const { norm, map } = normalize(content, tier);
51
+ const { norm: needle } = normalize(oldStr, tier);
52
+ // An all-whitespace old_str can normalize to empty under eol+trailws; refuse
53
+ // to "match everywhere" rather than delete at an arbitrary point.
54
+ if (needle.length === 0)
55
+ return out;
56
+ let i = norm.indexOf(needle);
57
+ while (i !== -1) {
58
+ out.push({ start: map[i], end: map[i + needle.length] });
59
+ i = norm.indexOf(needle, i + needle.length);
60
+ }
61
+ return out;
62
+ }
63
+ /**
64
+ * Build a normalized view of `s` plus `map`, where `map[k]` is the original
65
+ * offset of the k-th normalized char and `map[norm.length]` is a sentinel
66
+ * (`s.length`). This lets a match found in normalized space splice back into
67
+ * the original bytes exactly.
68
+ */
69
+ function normalize(s, tier) {
70
+ const stripTrail = tier === "eol+trailws";
71
+ let norm = "";
72
+ const map = [];
73
+ // Whitespace whose trailing-vs-not status isn't known yet: flushed when real
74
+ // content follows on the line, dropped when a newline / EOF follows.
75
+ const pending = [];
76
+ const emit = (ch, off) => {
77
+ norm += ch;
78
+ map.push(off);
79
+ };
80
+ const flushPending = () => {
81
+ for (const p of pending)
82
+ emit(p.ch, p.off);
83
+ pending.length = 0;
84
+ };
85
+ let i = 0;
86
+ while (i < s.length) {
87
+ const c = s[i];
88
+ if (c === "\r" || c === "\n") {
89
+ pending.length = 0; // any pending whitespace was trailing → drop it
90
+ emit("\n", i);
91
+ i += c === "\r" && s[i + 1] === "\n" ? 2 : 1;
92
+ continue;
93
+ }
94
+ if (stripTrail && (c === " " || c === "\t")) {
95
+ pending.push({ ch: c, off: i });
96
+ i += 1;
97
+ continue;
98
+ }
99
+ flushPending();
100
+ emit(c, i);
101
+ i += 1;
102
+ }
103
+ // Trailing whitespace at end-of-string (in `pending`) is intentionally dropped.
104
+ map.push(s.length);
105
+ return { norm, map };
106
+ }
107
+ /** The file's line ending: CRLF if the first newline is `\r\n`, else LF. */
108
+ export function detectEol(content) {
109
+ const i = content.indexOf("\n");
110
+ return i > 0 && content[i - 1] === "\r" ? "\r\n" : "\n";
111
+ }
112
+ /** Re-encode `text`'s line endings to `eol` so an edit matches the file. */
113
+ export function applyEol(text, eol) {
114
+ const lf = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
115
+ return eol === "\r\n" ? lf.replace(/\n/g, "\r\n") : lf;
116
+ }
117
+ /** Human phrase for the tier named in an ambiguity error. */
118
+ export function tierLabel(tier) {
119
+ switch (tier) {
120
+ case "exact":
121
+ return "";
122
+ case "eol":
123
+ return " after end-of-line normalization";
124
+ case "eol+trailws":
125
+ return " after end-of-line + trailing-whitespace normalization";
126
+ }
127
+ }
@@ -241,6 +241,16 @@ export interface Tool<Schema extends ZodTypeAny = ZodTypeAny> {
241
241
  * leave it unset and are advertised from their zod schema as before.
242
242
  */
243
243
  rawInputSchema?: Record<string, unknown>;
244
+ /**
245
+ * Optional per-turn lifecycle hook (C.13). {@link Session.send} calls it on
246
+ * every tool in the session registry at the start of each user turn, before the
247
+ * model runs. A tool that carries per-episode state — e.g. `run_tests`' consecutive-
248
+ * failure breaker — resets it here so a fresh instruction starts clean, while that
249
+ * state still latches across the many model iterations *within* one turn. Tools
250
+ * with no per-turn state omit it (a subagent/one-shot run is a single episode, so
251
+ * a missing hook simply means the state lives for that whole run).
252
+ */
253
+ onTurnStart?(): void;
244
254
  /** Run the tool against validated `input` and the ambient `ctx`. */
245
255
  execute(input: z.infer<Schema>, ctx: ToolContext): Promise<ToolResult>;
246
256
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cruxy/cli",
3
- "version": "0.27.0",
3
+ "version": "0.28.1",
4
4
  "description": "an agentic coding CLI",
5
5
  "type": "module",
6
6
  "bin": {