klyro 1.0.20 → 1.0.21

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.
@@ -17,22 +17,45 @@
17
17
  export function parseUnifiedDiff(raw) {
18
18
  const out = [];
19
19
  let current = null;
20
+ // Candidate path from the `--- ` side: used when the `+++ ` side is
21
+ // /dev/null (deleted file) and therefore names no real path.
22
+ let fromPath = null;
20
23
  for (const line of raw.split('\n')) {
21
24
  if (line.startsWith('diff --git ')) {
22
25
  if (current)
23
26
  out.push(current);
24
27
  current = null;
28
+ fromPath = null;
25
29
  continue;
26
30
  }
27
31
  if (line.startsWith('+++ ')) {
28
- const path = stripDiffPrefix(line.slice(4));
29
- if (path)
30
- current = { path, lines: [] };
32
+ const toRaw = line.slice(4).trim();
33
+ // A new file section without a `diff --git` header must not swallow
34
+ // the previous file's lines — flush first.
35
+ if (current && current.lines.length > 0) {
36
+ out.push(current);
37
+ current = null;
38
+ }
39
+ const toPath = stripDiffPrefix(toRaw);
40
+ if (toPath && toPath !== '/dev/null') {
41
+ current = { path: toPath, lines: [] };
42
+ }
43
+ else if (fromPath && fromPath !== '/dev/null') {
44
+ // Deleted file: `--- a/f` / `+++ /dev/null` — keep the real path,
45
+ // not `/dev/null`.
46
+ current = { path: fromPath, lines: [] };
47
+ }
48
+ else {
49
+ current = null;
50
+ }
51
+ fromPath = null;
31
52
  continue;
32
53
  }
33
54
  if (line.startsWith('--- ')) {
34
- // Path on the "from" side — we prefer the "to" path so the
35
- // heading matches the file the user is editing.
55
+ // Stash the "from" path (see `+++ ` handling above) — we still
56
+ // prefer the "to" path so the heading matches the edited file.
57
+ const cand = stripDiffPrefix(line.slice(4).trim());
58
+ fromPath = cand || null;
36
59
  continue;
37
60
  }
38
61
  if (line.startsWith('@@')) {
@@ -146,7 +146,7 @@ export function highlightCodeLine(line, lang) {
146
146
  return parts;
147
147
  }
148
148
  /** Detect `file:line` / `file:line:col` references and attach OSC-8 hrefs. */
149
- export const FILE_LINE_RE = /((?:\.{0,2}\/)?[\w./-]+\.[a-zA-Z]+\w*):(\d+)(?::(\d+))?/g;
149
+ export const FILE_LINE_RE = /(?<![:/\w])((?:\.{0,2}\/)?[\w./-]+\.[a-zA-Z]+\w*):(\d+)(?::(\d+))?/g;
150
150
  export function annotateFileLinks(parts) {
151
151
  const out = [];
152
152
  for (const p of parts) {
@@ -85,8 +85,11 @@ export function blockHeight(b, termWidth) {
85
85
  return 1 + 1;
86
86
  case 'diff': {
87
87
  let n = 1; // summary
88
+ // Render prepends a 2-col `+ `/`- `/` ` prefix per line (app.tsx),
89
+ // so measure against cw-2 — a bare-text wrap at full cw under-counts
90
+ // borderline lines by a row each and drifts the scroll anchor.
88
91
  for (const h of b.hunks)
89
- n += 1 + h.lines.reduce((s, l) => s + wrapCount(l, cw), 0);
92
+ n += 1 + h.lines.reduce((s, l) => s + wrapCount(l, cw - 2), 0);
90
93
  return n + 1;
91
94
  }
92
95
  case 'plan':
@@ -38,6 +38,8 @@ export type ScrollAction = {
38
38
  } | {
39
39
  type: 'CONTENT_GREW';
40
40
  lines: number;
41
+ } | {
42
+ type: 'PRUNE';
41
43
  } | {
42
44
  type: 'REFLOW';
43
45
  };
@@ -65,6 +65,11 @@ export function scrollReducer(s, a, ctx) {
65
65
  }
66
66
  void maxTop;
67
67
  return { ...s, newSinceUnstick: s.newSinceUnstick + a.lines };
68
+ case 'PRUNE':
69
+ // Anchored item was compacted away: re-stick to bottom with a clean
70
+ // counter. Without the reset, a stale newSinceUnstick inflates the
71
+ // `↓ N new` badge after the re-stick.
72
+ return stickBottom();
68
73
  case 'REFLOW':
69
74
  return { ...s };
70
75
  }
@@ -11,6 +11,9 @@ export interface StatusSnapshot {
11
11
  maxSteps: number;
12
12
  usageInput: number;
13
13
  usageOutput: number;
14
+ /** Prompt-cache counters (Anthropic-family): billed and context-consuming. */
15
+ usageCacheRead?: number;
16
+ usageCacheWrite?: number;
14
17
  repairs: number;
15
18
  status: 'idle' | 'running' | 'done' | 'error' | 'aborted';
16
19
  errorMessage?: string;
@@ -7,7 +7,7 @@
7
7
  * On failure: returns { ok: false, failure } with the most recent
8
8
  * structured Failure (and the model has had up to N attempts to fix).
9
9
  */
10
- import { spawn } from 'node:child_process';
10
+ import { spawn, spawnSync } from 'node:child_process';
11
11
  import { detect, summarize } from './detect.js';
12
12
  import { filteredVerifyEnv } from './registry.js';
13
13
  import { redact } from '../policy/secret-redactor.js';
@@ -84,10 +84,26 @@ export async function verify(opts) {
84
84
  const outCap = cappedOutput(MAX_VERIFY_BYTES);
85
85
  const errCap = cappedOutput(MAX_VERIFY_BYTES);
86
86
  let done = false;
87
+ const killTree = () => {
88
+ // Match run_verify/shell_exec: tree-kill so grandchildren die too.
89
+ // child.kill() alone only takes the /bin/sh (or cmd) wrapper.
90
+ if (process.platform === 'win32') {
91
+ try {
92
+ spawnSync('taskkill', ['/F', '/T', '/PID', String(child.pid)], { windowsHide: true });
93
+ }
94
+ catch { /* already gone */ }
95
+ }
96
+ else {
97
+ try {
98
+ child.kill('SIGKILL');
99
+ }
100
+ catch { /* noop */ }
101
+ }
102
+ };
87
103
  const timer = setTimeout(() => {
88
104
  if (done)
89
105
  return;
90
- child.kill();
106
+ killTree();
91
107
  done = true;
92
108
  const so = outCap.text();
93
109
  const se = errCap.text();
@@ -103,6 +119,23 @@ export async function verify(opts) {
103
119
  }, timeout);
104
120
  child.stdout.on('data', (b) => outCap.push(b));
105
121
  child.stderr.on('data', (b) => errCap.push(b));
122
+ // A bad cwd (or missing shell) emits 'error' with no 'close' — without
123
+ // this the promise never settles and the run hangs to its own timeout.
124
+ child.on('error', (err) => {
125
+ if (done)
126
+ return;
127
+ done = true;
128
+ clearTimeout(timer);
129
+ const raw = `verify spawn failed: ${err instanceof Error ? err.message : String(err)}`;
130
+ emitFailed('runtime');
131
+ resolve({
132
+ ok: false,
133
+ exitCode: -1,
134
+ stdout: outCap.text(),
135
+ stderr: raw,
136
+ failure: { type: 'runtime', files: [], raw, exitCode: -1 },
137
+ });
138
+ });
106
139
  child.on('close', (code) => {
107
140
  if (done)
108
141
  return;
@@ -39,13 +39,25 @@ export function findRelatedTests(cwd, editedFiles) {
39
39
  const dir = path.dirname(edited);
40
40
  for (const t of allTests) {
41
41
  const tb = path.basename(t);
42
+ const stripped = path.basename(t, path.extname(t)).replace(/\.test|\.spec|test_/g, '');
42
43
  // name match: foo.ts -> foo.test.ts, test_foo.py, foo_spec.ts, etc.
43
- if (tb.includes(base) || base.includes(path.basename(t, path.extname(t)).replace(/\.test|\.spec|test_/g, ''))) {
44
+ // Substring arms need a substantive core (>=4 chars): without the
45
+ // floor, `a.ts` matches nearly every test basename globally, and
46
+ // `catalog.ts` matches unrelated `cat.test.ts`. Exact stem equality
47
+ // always counts (covers single-char stems like `a.ts` -> `a.test.ts`).
48
+ if (stripped === base ||
49
+ (base.length >= 4 && tb.includes(base)) ||
50
+ (stripped.length >= 4 && base.includes(stripped))) {
44
51
  related.add(path.relative(cwd, t));
45
52
  }
46
- // same directory prefix also counts
47
- if (t.startsWith(dir) && tb.includes(base.slice(0, 4)))
53
+ // same-directory similarity (not a raw string prefix: `/repo/foo2/…`
54
+ // must not match edited dir `/repo/foo`). The 4-char slice needs a
55
+ // real core name behind it, same floor as above.
56
+ const rel = path.relative(dir, t);
57
+ const inDir = rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
58
+ if (inDir && base.length >= 4 && tb.toLowerCase().includes(base.slice(0, 4).toLowerCase())) {
48
59
  related.add(path.relative(cwd, t));
60
+ }
49
61
  }
50
62
  }
51
63
  return [...related];
@@ -222,6 +234,7 @@ export async function syntaxCheck(cwd, file) {
222
234
  }
223
235
  }
224
236
  if (ext === '.py') {
237
+ let spawnFailed = false;
225
238
  const ok = await new Promise((resolve) => {
226
239
  // S2: verify commands run with filtered env; servers needing keys must use explicit config.
227
240
  const child = spawn('python', ['-m', 'py_compile', full], { cwd, shell: false, env: filteredVerifyEnv() });
@@ -236,11 +249,13 @@ export async function syntaxCheck(cwd, file) {
236
249
  } }, 5000);
237
250
  child.on('close', (code) => { if (done)
238
251
  return; done = true; clearTimeout(t); resolve(code === 0); });
252
+ // A missing interpreter must not read as "syntax OK" — that false
253
+ // negative lets broken files pass scoped verification.
239
254
  child.on('error', () => { if (done)
240
- return; done = true; clearTimeout(t); resolve(true); });
255
+ return; done = true; clearTimeout(t); spawnFailed = true; resolve(false); });
241
256
  });
242
257
  if (!ok)
243
- return { ok: false, error: `syntax error in ${file} (py_compile)` };
258
+ return { ok: false, error: spawnFailed ? `python unavailable for py_compile (${file})` : `syntax error in ${file} (py_compile)` };
244
259
  }
245
260
  return { ok: true };
246
261
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klyro",
3
- "version": "1.0.20",
3
+ "version": "1.0.21",
4
4
  "description": "Klyro — autonomous coding harness CLI that streams from any OpenAI-compatible or Anthropic LLM endpoint.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",