klyro 1.0.16 → 1.0.17

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.
Files changed (69) hide show
  1. package/dist/agent/anthropic-adapter.js +26 -1
  2. package/dist/agent/provider-adapter.d.ts +7 -0
  3. package/dist/agent/provider-adapter.js +26 -1
  4. package/dist/agent/runtime.d.ts +7 -0
  5. package/dist/agent/runtime.js +18 -21
  6. package/dist/chat.js +16 -56
  7. package/dist/cli/completion.js +0 -3
  8. package/dist/cli/doctor.d.ts +13 -0
  9. package/dist/cli/doctor.js +133 -1
  10. package/dist/cli/errors.js +1 -1
  11. package/dist/cli/eval.js +1 -3
  12. package/dist/cli/markdown.js +1 -1
  13. package/dist/cli/repl.js +13 -24
  14. package/dist/cli/run.d.ts +32 -0
  15. package/dist/cli/run.js +77 -9
  16. package/dist/cli/update.js +7 -1
  17. package/dist/context/accounting.js +1 -1
  18. package/dist/context/level7.d.ts +4 -0
  19. package/dist/context/level7.js +20 -1
  20. package/dist/context/project-map.js +14 -13
  21. package/dist/eval/harness.d.ts +1 -1
  22. package/dist/eval/harness.js +2 -2
  23. package/dist/index.js +52 -1
  24. package/dist/persistence/store.js +2 -2
  25. package/dist/policy/approval.js +3 -0
  26. package/dist/policy/path-guard.d.ts +9 -4
  27. package/dist/policy/path-guard.js +47 -23
  28. package/dist/providers/endpoints.js +8 -0
  29. package/dist/providers/model-info.d.ts +1 -0
  30. package/dist/providers/model-info.js +19 -0
  31. package/dist/providers.js +6 -2
  32. package/dist/renderers/terminal.d.ts +7 -0
  33. package/dist/renderers/terminal.js +52 -0
  34. package/dist/repl.d.ts +9 -0
  35. package/dist/repl.js +24 -5
  36. package/dist/shared/index.d.ts +1 -0
  37. package/dist/shared/index.js +1 -0
  38. package/dist/shared/proxy.d.ts +37 -0
  39. package/dist/shared/proxy.js +360 -0
  40. package/dist/tools/agent/task-list.d.ts +1 -1
  41. package/dist/tools/fs/apply-patch.js +4 -4
  42. package/dist/tools/fs/edit-file.js +2 -4
  43. package/dist/tools/fs/list-dir.js +1 -1
  44. package/dist/tools/fs/multi-edit.js +2 -2
  45. package/dist/tools/fs/read-file.js +1 -1
  46. package/dist/tools/fs/write-file.js +2 -2
  47. package/dist/tools/lsp/diagnostics.js +2 -3
  48. package/dist/tools/plan/todo-write.d.ts +1 -1
  49. package/dist/tools/search/dependencies.d.ts +4 -4
  50. package/dist/tools/search/dependencies.js +1 -1
  51. package/dist/tools/search/glob.js +1 -1
  52. package/dist/tools/search/grep.js +1 -1
  53. package/dist/tools/search/recent-files.js +1 -1
  54. package/dist/tools/search/search-files.js +1 -1
  55. package/dist/tools/web/web-fetch.js +2 -1
  56. package/dist/tools/web/web-search.js +3 -1
  57. package/dist/tui/app.js +80 -9
  58. package/dist/tui/app.test.js +48 -1
  59. package/dist/tui/measure.js +1 -0
  60. package/dist/tui/mouse.d.ts +7 -5
  61. package/dist/tui/mouse.js +9 -6
  62. package/dist/tui/scroll-flow.test.js +0 -1
  63. package/dist/util/log.d.ts +19 -10
  64. package/dist/util/log.js +87 -52
  65. package/dist/verification/classify.d.ts +1 -1
  66. package/dist/verification/classify.js +2 -2
  67. package/dist/verification/engine.js +0 -6
  68. package/dist/verification/scoped.js +6 -4
  69. package/package.json +6 -1
@@ -17,6 +17,8 @@
17
17
  */
18
18
  import { assertSafeBaseURL } from '../chat.js';
19
19
  import { parseRetryAfterMs } from './provider-adapter.js';
20
+ import { estimateTokens } from '../context/tokenizer.js';
21
+ import { proxiedFetch } from '../shared/proxy.js';
20
22
  const DEFAULT_VERSION = '2023-06-01';
21
23
  const DEFAULT_TIMEOUT_MS = 120_000;
22
24
  export const PROMPT_CACHING_BETA = 'prompt-caching-2024-07-31';
@@ -40,7 +42,7 @@ export function anthropicAdapter(opts) {
40
42
  const betas = [...(opts.betas ?? [])];
41
43
  if (promptCache && !betas.includes(PROMPT_CACHING_BETA))
42
44
  betas.push(PROMPT_CACHING_BETA);
43
- const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
45
+ const fetchImpl = opts.fetchImpl ?? proxiedFetch;
44
46
  if (!fetchImpl) {
45
47
  throw new Error('anthropicAdapter: no fetch available — pass opts.fetchImpl or run on Node 18+');
46
48
  }
@@ -52,6 +54,29 @@ export function anthropicAdapter(opts) {
52
54
  fetchImpl, version, authHeader, betas, promptCache,
53
55
  });
54
56
  },
57
+ // 2.1 — capability discovery via the Anthropic Models API.
58
+ // Returns [] when unavailable (callers fall back to configured ids).
59
+ async listModels() {
60
+ try {
61
+ const headers = { 'anthropic-version': version };
62
+ if (authHeader === 'x-api-key')
63
+ headers['x-api-key'] = opts.apiKey;
64
+ else
65
+ headers['Authorization'] = `Bearer ${opts.apiKey}`;
66
+ const res = await fetchImpl(`${baseURL}/v1/models`, { headers });
67
+ if (!res.ok)
68
+ return [];
69
+ const json = (await res.json());
70
+ const ids = Array.isArray(json.data) ? json.data.map((m) => m.id).filter((id) => typeof id === 'string' && id.length > 0) : [];
71
+ return [...new Set(ids)];
72
+ }
73
+ catch {
74
+ return [];
75
+ }
76
+ },
77
+ countTokens(text) {
78
+ return estimateTokens(text);
79
+ },
55
80
  };
56
81
  }
57
82
  /**
@@ -68,11 +68,17 @@ export interface CallRequest {
68
68
  tools: ToolDefinition[];
69
69
  maxTokens?: number;
70
70
  temperature?: number;
71
+ /** Reasoning effort for reasoning models ( OpenAI `reasoning_effort` ); adapters that lack the concept ignore it. */
72
+ reasoningEffort?: 'low' | 'medium' | 'high';
71
73
  signal?: AbortSignal;
72
74
  }
73
75
  export interface ProviderAdapter {
74
76
  readonly id: string;
75
77
  stream(req: CallRequest): AsyncIterable<StreamEvent>;
78
+ /** Optional capability discovery: model ids (2.1). Absent = unknown, use configured ids. */
79
+ listModels?(): Promise<string[]>;
80
+ /** Optional local token estimate (2.1). Absent = caller estimates. */
81
+ countTokens?(text: string): number | Promise<number>;
76
82
  }
77
83
  export interface HttpAdapterOptions {
78
84
  baseURL: string;
@@ -110,6 +116,7 @@ interface ChatCompletionsRequest {
110
116
  }>;
111
117
  max_tokens?: number;
112
118
  temperature?: number;
119
+ reasoning_effort?: 'low' | 'medium' | 'high';
113
120
  stream: true;
114
121
  }
115
122
  /**
@@ -11,6 +11,8 @@
11
11
  * runtime loop sees one shape regardless of provider quirks.
12
12
  */
13
13
  import { redact } from '../policy/secret-redactor.js';
14
+ import { estimateTokens } from '../context/tokenizer.js';
15
+ import { proxiedFetch } from '../shared/proxy.js';
14
16
  const DEFAULT_TIMEOUT_MS = 120_000;
15
17
  /**
16
18
  * Parse a `Retry-After` response header value into milliseconds.
@@ -157,6 +159,8 @@ export function buildChatCompletionsBody(req) {
157
159
  body.max_tokens = req.maxTokens;
158
160
  if (typeof req.temperature === 'number')
159
161
  body.temperature = req.temperature;
162
+ if (req.reasoningEffort)
163
+ body.reasoning_effort = req.reasoningEffort;
160
164
  if (req.tools.length) {
161
165
  body.tools = req.tools.map((t) => ({
162
166
  type: 'function',
@@ -166,13 +170,34 @@ export function buildChatCompletionsBody(req) {
166
170
  return body;
167
171
  }
168
172
  export function httpChatAdapter(opts) {
169
- const fetchImpl = opts.fetchImpl ?? fetch;
173
+ const fetchImpl = opts.fetchImpl ?? proxiedFetch;
170
174
  const url = `${opts.baseURL.replace(/\/+$/, '')}/chat/completions`;
171
175
  return {
172
176
  id: 'http-chat',
173
177
  stream(req) {
174
178
  return streamChatCompletions(url, opts, req, fetchImpl);
175
179
  },
180
+ // 2.1 — capability discovery: list model ids via OpenAI /models.
181
+ // Returns [] when the endpoint doesn't serve a model list (not an
182
+ // error: callers fall back to configured ids).
183
+ async listModels() {
184
+ try {
185
+ const res = await fetchImpl(`${opts.baseURL.replace(/\/+$/, '')}/models`, {
186
+ headers: opts.apiKey ? { Authorization: `Bearer ${opts.apiKey}` } : {},
187
+ });
188
+ if (!res.ok)
189
+ return [];
190
+ const json = (await res.json());
191
+ const ids = Array.isArray(json.data) ? json.data.map((m) => m.id).filter((id) => typeof id === 'string' && id.length > 0) : [];
192
+ return [...new Set(ids)];
193
+ }
194
+ catch {
195
+ return [];
196
+ }
197
+ },
198
+ countTokens(text) {
199
+ return estimateTokens(text);
200
+ },
176
201
  };
177
202
  }
178
203
  async function* streamChatCompletions(url, opts, req, fetchImpl) {
@@ -83,6 +83,7 @@ export interface RunOptions {
83
83
  maxTimeMs?: number;
84
84
  maxTokens?: number;
85
85
  temperature?: number;
86
+ reasoningEffort?: 'low' | 'medium' | 'high';
86
87
  signal?: AbortSignal;
87
88
  nonInteractive: boolean;
88
89
  /**
@@ -157,6 +158,12 @@ export interface RunOptions {
157
158
  allowedPaths?: readonly string[];
158
159
  model?: string;
159
160
  };
161
+ /**
162
+ * Root sandbox extensions (--add-dir): extra resolvable roots for file
163
+ * tools, merged with cwd. Child scopes (parentContext.allowedPaths) still
164
+ * narrow further when present.
165
+ */
166
+ allowedPaths?: readonly string[];
160
167
  /**
161
168
  * Delegation bridge (P0). Present on the root run so the model can call
162
169
  * spawn_agent / task_list / task_get. The tool layer reads it from the
@@ -223,18 +223,6 @@ export async function run(opts, deps) {
223
223
  const sessionId = opts.persist?.sessionId;
224
224
  // 6.1 baseline cache per HEAD — capture before first edit
225
225
  let baselinePrimed = false;
226
- async function primeBaseline() {
227
- if (baselinePrimed)
228
- return;
229
- baselinePrimed = true;
230
- const cmd = opts.verify?.command ?? detectVerifyCommand(opts.cwd);
231
- if (!cmd)
232
- return;
233
- try {
234
- await ensureBaseline(opts.cwd, cmd);
235
- }
236
- catch { /* ignore */ }
237
- }
238
226
  async function checkpoint(msg, obs) {
239
227
  if (!store || !sessionId)
240
228
  return;
@@ -319,6 +307,11 @@ export async function run(opts, deps) {
319
307
  // Abort cascade (fix: background shells must not outlive the run).
320
308
  const killed = killAllJobs();
321
309
  emitKlyro({ type: 'abort', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', reason: killed.length > 0 ? `aborted by operator (${killed.length} background job(s) killed)` : 'aborted by operator' });
310
+ // 2.4 — explicit interrupted marker: partial output stays labeled in
311
+ // the transcript so resume/replay never mistakes it for final text.
312
+ const interruptedNote = { role: 'user', content: [text('[system note] Interrupted by user — output above is partial and preserved.')] };
313
+ transcript.push(interruptedNote);
314
+ await checkpoint(interruptedNote);
322
315
  if (store && sessionId) {
323
316
  try {
324
317
  await store.setStatus(sessionId, 'aborted', finalText);
@@ -399,23 +392,22 @@ export async function run(opts, deps) {
399
392
  tools: toolDefinitions(deps.registry),
400
393
  ...(opts.maxTokens ? { maxTokens: opts.maxTokens } : {}),
401
394
  ...(typeof opts.temperature === 'number' ? { temperature: opts.temperature } : {}),
395
+ ...(opts.reasoningEffort ? { reasoningEffort: opts.reasoningEffort } : {}),
402
396
  ...(opts.signal ? { signal: opts.signal } : {}),
403
397
  };
404
398
  const events = activeAdapter.stream(req);
405
399
  let textBuf = '';
406
400
  // Thinking is ephemeral: streamed to the UI live, never stored in the
407
401
  // transcript, and cleared when the turn's answer completes.
408
- let thinkingBuf = '';
409
402
  const pendingToolCalls = new Map();
410
- let lastFinishReason;
411
403
  // Set when this step's request must be re-issued after overflow recovery.
412
404
  let overflowRetryPending = false;
413
405
  // Set when a terminal provider error consumed a failover adapter — the
414
406
  // step is re-issued against the next adapter without consuming budget.
415
407
  let failoverPending = false;
416
- let failoverFrom = '';
417
- let failoverTo = '';
418
- let failoverReason = '';
408
+ let failoverFrom;
409
+ let failoverTo;
410
+ let failoverReason;
419
411
  for await (const ev of events) {
420
412
  if (opts.signal?.aborted)
421
413
  break outer;
@@ -424,7 +416,6 @@ export async function run(opts, deps) {
424
416
  emit?.({ kind: 'text_delta', text: ev.text });
425
417
  }
426
418
  else if (ev.kind === 'thinking_delta') {
427
- thinkingBuf += ev.text;
428
419
  emit?.({ kind: 'thinking_delta', text: ev.text });
429
420
  }
430
421
  else if (ev.kind === 'tool_call_start') {
@@ -441,7 +432,6 @@ export async function run(opts, deps) {
441
432
  // tool_calls are accumulated; finalization happens after stream.
442
433
  }
443
434
  else if (ev.kind === 'message_end') {
444
- lastFinishReason = ev.finishReason;
445
435
  if (ev.usage) {
446
436
  usage.input += ev.usage.input;
447
437
  usage.output += ev.usage.output;
@@ -553,9 +543,7 @@ export async function run(opts, deps) {
553
543
  if (failoverPending) {
554
544
  failoverPending = false;
555
545
  textBuf = '';
556
- thinkingBuf = '';
557
546
  pendingToolCalls.clear();
558
- lastFinishReason = undefined;
559
547
  steps--;
560
548
  emit?.({ kind: 'step_end', step: steps + 1 });
561
549
  continue outer;
@@ -613,6 +601,10 @@ export async function run(opts, deps) {
613
601
  emit?.({ kind: 'aborted' });
614
602
  const killed = killAllJobs();
615
603
  emitKlyro({ type: 'abort', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', reason: killed.length > 0 ? `aborted by operator (${killed.length} background job(s) killed)` : 'aborted by operator' });
604
+ // 2.4 — explicit interrupted marker (see loop-top abort).
605
+ const interruptedNote = { role: 'user', content: [text('[system note] Interrupted by user — output above is partial and preserved.')] };
606
+ transcript.push(interruptedNote);
607
+ await checkpoint(interruptedNote);
616
608
  if (store && sessionId) {
617
609
  try {
618
610
  await store.setStatus(sessionId, 'aborted', finalText);
@@ -907,6 +899,7 @@ export async function run(opts, deps) {
907
899
  : {}),
908
900
  agentDepth: opts.parentContext?.depth ?? 0,
909
901
  agentMaxDepth: opts.parentContext?.maxDepth ?? 1,
902
+ ...(opts.allowedPaths ? { agentAllowedPaths: opts.allowedPaths } : {}),
910
903
  ...(opts.parentContext?.allowedTools ? { agentAllowedTools: opts.parentContext.allowedTools } : {}),
911
904
  ...(opts.parentContext?.allowedPaths ? { agentAllowedPaths: opts.parentContext.allowedPaths } : {}),
912
905
  ...(opts.parentContext?.model ?? opts.model ? { agentModel: opts.parentContext?.model ?? opts.model } : {}),
@@ -1308,6 +1301,10 @@ export async function run(opts, deps) {
1308
1301
  }
1309
1302
  if (opts.signal?.aborted) {
1310
1303
  emit?.({ kind: 'aborted' });
1304
+ // 2.4 — explicit interrupted marker (see loop-top abort).
1305
+ const interruptedNote = { role: 'user', content: [text('[system note] Interrupted by user — output above is partial and preserved.')] };
1306
+ transcript.push(interruptedNote);
1307
+ await checkpoint(interruptedNote);
1311
1308
  if (store && sessionId) {
1312
1309
  try {
1313
1310
  await store.setStatus(sessionId, 'aborted', finalText);
package/dist/chat.js CHANGED
@@ -20,6 +20,16 @@
20
20
  */
21
21
  /** Default request timeout: 60s. */
22
22
  const DEFAULT_TIMEOUT_MS = 60_000;
23
+ /**
24
+ * Legacy output flows through the shared terminal renderer (3.1): all
25
+ * human output leaves via the renderer module, which also sanitizes
26
+ * control sequences out of model text.
27
+ */
28
+ import { TerminalRenderer, writeStdoutDrained } from './renderers/terminal.js';
29
+ const renderer = new TerminalRenderer();
30
+ function frame(text) {
31
+ renderer.handle({ type: 'stream.delta', ts: Date.now(), sessionId: 'legacy-chat', text });
32
+ }
23
33
  /** Max bytes of an error response body we will print. */
24
34
  const MAX_ERROR_BODY_BYTES = 4_000;
25
35
  /** Strip a trailing slash so we can append /chat/completions cleanly. */
@@ -187,7 +197,7 @@ export async function streamToStdout(body, signal) {
187
197
  const reader = body.getReader();
188
198
  const decoder = new TextDecoder('utf-8');
189
199
  let buf = '';
190
- process.stdout.write('\n');
200
+ frame('\n');
191
201
  try {
192
202
  while (true) {
193
203
  if (signal.aborted)
@@ -209,7 +219,7 @@ export async function streamToStdout(body, signal) {
209
219
  continue;
210
220
  const data = line.slice(5).trim();
211
221
  if (data === '[DONE]') {
212
- process.stdout.write('\n');
222
+ frame('\n');
213
223
  return;
214
224
  }
215
225
  // Handle case where data was split across chunks and reassembled as event
@@ -224,7 +234,7 @@ export async function streamToStdout(body, signal) {
224
234
  }
225
235
  const text = parsed.choices?.[0]?.delta?.content;
226
236
  if (text) {
227
- if (!await writeWithBackpressure(text, signal)) {
237
+ if (!await writeStdoutDrained(text, signal)) {
228
238
  try {
229
239
  await reader.cancel();
230
240
  }
@@ -246,7 +256,7 @@ export async function streamToStdout(body, signal) {
246
256
  continue;
247
257
  const data = line.slice(5).trim();
248
258
  if (data === '[DONE]') {
249
- process.stdout.write('\n');
259
+ frame('\n');
250
260
  return;
251
261
  }
252
262
  if (data === '')
@@ -255,7 +265,7 @@ export async function streamToStdout(body, signal) {
255
265
  const parsed = JSON.parse(data);
256
266
  const text = parsed.choices?.[0]?.delta?.content;
257
267
  if (text) {
258
- if (!await writeWithBackpressure(text, signal)) {
268
+ if (!await writeStdoutDrained(text, signal)) {
259
269
  try {
260
270
  await reader.cancel();
261
271
  }
@@ -270,7 +280,7 @@ export async function streamToStdout(body, signal) {
270
280
  }
271
281
  }
272
282
  }
273
- process.stdout.write('\n');
283
+ frame('\n');
274
284
  }
275
285
  finally {
276
286
  try {
@@ -281,56 +291,6 @@ export async function streamToStdout(body, signal) {
281
291
  }
282
292
  }
283
293
  }
284
- /**
285
- * Write to stdout and wait for the drain event if the buffer is full.
286
- * Returns false if stdout has been closed (e.g. piped to `head`).
287
- * Respects abort signal — resolves false if aborted while waiting.
288
- */
289
- function writeWithBackpressure(chunk, signal) {
290
- return new Promise((resolve) => {
291
- if (signal?.aborted) {
292
- resolve(false);
293
- return;
294
- }
295
- if (!process.stdout.write(chunk)) {
296
- let settled = false;
297
- const cleanup = () => {
298
- process.stdout.off('drain', onDrain);
299
- process.stdout.off('error', onError);
300
- if (signal)
301
- signal.removeEventListener('abort', onAbort);
302
- };
303
- const onDrain = () => {
304
- if (settled)
305
- return;
306
- settled = true;
307
- cleanup();
308
- resolve(true);
309
- };
310
- const onError = () => {
311
- if (settled)
312
- return;
313
- settled = true;
314
- cleanup();
315
- resolve(false);
316
- };
317
- const onAbort = () => {
318
- if (settled)
319
- return;
320
- settled = true;
321
- cleanup();
322
- resolve(false);
323
- };
324
- process.stdout.once('drain', onDrain);
325
- process.stdout.once('error', onError);
326
- if (signal)
327
- signal.addEventListener('abort', onAbort, { once: true });
328
- }
329
- else {
330
- resolve(true);
331
- }
332
- });
333
- }
334
294
  /**
335
295
  * Read up to `max` bytes from a response body. Used for error responses where
336
296
  * we want to surface the cause without risking OOM on a misbehaving server.
@@ -19,9 +19,6 @@ const COMMAND_FLAGS = {
19
19
  completion: ['bash', 'zsh', 'fish', 'powershell'],
20
20
  resume: ['-m', '--model', '--max-steps'],
21
21
  };
22
- function flagsFor(cmd) {
23
- return [...GLOBAL_FLAGS, ...(COMMAND_FLAGS[cmd] ?? [])];
24
- }
25
22
  function bashScript() {
26
23
  const cmdCases = Object.entries(COMMAND_FLAGS)
27
24
  .map(([c, fs]) => ` ${c}) opts="${fs.join(' ')}" ;;`)
@@ -3,6 +3,19 @@
3
3
  * Runs quick diagnostics: node version, config, provider reachability,
4
4
  * persistence dir, git, tools.
5
5
  */
6
+ /**
7
+ * Pure byte interpreter for `doctor --keys` (unit-tested): names what a
8
+ * terminal delivered so broken arrow/paste/scroll input becomes provable
9
+ * fact instead of guesswork. Covers arrows, paging/home/end, Ctrl codes,
10
+ * bracketed-paste markers, and SGR/X10 mouse sequences.
11
+ */
12
+ export declare function describeKeyBytes(chunk: Buffer): string;
13
+ /**
14
+ * Interactive key probe: raw-mode stdin echo of every chunk with its
15
+ * meaning. Proves what THIS terminal delivers for arrows, Ctrl+P/N,
16
+ * paste, and wheel — run it when TUI input misbehaves.
17
+ */
18
+ export declare function runKeysProbe(): Promise<number>;
6
19
  export declare function runDoctor(opts?: {
7
20
  json?: boolean;
8
21
  cwd?: string;
@@ -6,7 +6,7 @@
6
6
  import * as fs from 'node:fs/promises';
7
7
  import * as fsSync from 'node:fs';
8
8
  import * as path from 'node:path';
9
- import { spawn } from 'node:child_process';
9
+ import { spawn, execFileSync } from 'node:child_process';
10
10
  import { getConfigPath, loadConfig } from './config.js';
11
11
  import { resolveProvider, providerHelp } from '../providers.js';
12
12
  import { getDefaultSessionsDir } from '../persistence/session.js';
@@ -120,6 +120,45 @@ async function checkSandbox() {
120
120
  return { name: 'Sandbox', ok: true, detail: 'none — policy+path guards only' };
121
121
  }
122
122
  }
123
+ function checkPackageManager() {
124
+ try {
125
+ const npm = execFileSync('npm', ['--version'], { encoding: 'utf-8', timeout: 5000 }).trim();
126
+ return { name: 'npm', ok: true, detail: `npm ${npm} ✓` };
127
+ }
128
+ catch {
129
+ return { name: 'npm', ok: false, detail: 'npm not on PATH — install Node ≥20' };
130
+ }
131
+ }
132
+ function checkPath() {
133
+ const pathVar = process.env.PATH ?? process.env.Path ?? '';
134
+ const nodeDir = process.execPath.includes('\\') || process.execPath.includes('/')
135
+ ? process.execPath.slice(0, Math.max(process.execPath.lastIndexOf('\\'), process.execPath.lastIndexOf('/')))
136
+ : '';
137
+ const onPath = !!pathVar && !!nodeDir && pathVar.toLowerCase().split(path.delimiter).some((p) => p.toLowerCase() === nodeDir.toLowerCase());
138
+ return { name: 'PATH', ok: true, detail: onPath ? `node dir on PATH ✓` : `node dir not on PATH (${nodeDir || 'unknown'}) — child shells may miss node` };
139
+ }
140
+ function checkRipgrep() {
141
+ try {
142
+ const v = execFileSync('rg', ['--version'], { encoding: 'utf-8', timeout: 5000 }).trim().split('\n')[0] ?? '';
143
+ return { name: 'ripgrep', ok: true, detail: `${v} ✓` };
144
+ }
145
+ catch {
146
+ return { name: 'ripgrep', ok: true, detail: 'not found — built-in grep tool is used instead' };
147
+ }
148
+ }
149
+ function checkTerminal() {
150
+ const isTTY = !!process.stdout.isTTY;
151
+ const term = process.env.TERM ?? '(unset)';
152
+ const cols = process.stdout.columns ?? 0;
153
+ return { name: 'Terminal', ok: true, detail: `${isTTY ? 'TTY' : 'non-TTY (headless/pipe mode)'}, TERM=${term}, cols=${cols}` };
154
+ }
155
+ function checkProxy() {
156
+ const vars = ['HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'http_proxy', 'https_proxy', 'no_proxy']
157
+ .filter((k) => process.env[k] && process.env[k].length > 0);
158
+ if (vars.length === 0)
159
+ return { name: 'Proxy', ok: true, detail: 'no proxy env — direct connections' };
160
+ return { name: 'Proxy', ok: true, detail: `${vars.join(', ')} set — honored for provider/web/update fetches` };
161
+ }
123
162
  async function checkMcp(cwd) {
124
163
  try {
125
164
  const { loadMcpServers } = await import('../mcp/config.js');
@@ -169,14 +208,107 @@ async function checkTrust() {
169
208
  return { name: 'Trust stores', ok: true, detail: 'none' };
170
209
  }
171
210
  }
211
+ /**
212
+ * Pure byte interpreter for `doctor --keys` (unit-tested): names what a
213
+ * terminal delivered so broken arrow/paste/scroll input becomes provable
214
+ * fact instead of guesswork. Covers arrows, paging/home/end, Ctrl codes,
215
+ * bracketed-paste markers, and SGR/X10 mouse sequences.
216
+ */
217
+ export function describeKeyBytes(chunk) {
218
+ const s = chunk.toString('latin1');
219
+ switch (s) {
220
+ case '\x1b[A': return 'Up arrow (history prev)';
221
+ case '\x1b[B': return 'Down arrow (history next)';
222
+ case '\x1b[C': return 'Right arrow';
223
+ case '\x1b[D': return 'Left arrow';
224
+ case '\x1b[5~': return 'PageUp (scroll half-page up)';
225
+ case '\x1b[6~': return 'PageDown (scroll half-page down)';
226
+ case '\x1b[H':
227
+ case '\x1b[1~': return 'Home (jump top)';
228
+ case '\x1b[F':
229
+ case '\x1b[4~': return 'End (jump bottom)';
230
+ case '\x1b[Z': return 'Shift+Tab';
231
+ case '\r':
232
+ case '\n': return 'Enter';
233
+ case '\x7f': return 'Backspace';
234
+ case '\x1b': return 'Escape (lone — arrow/paste sequences arrive joined; lone ESC means the terminal split them or you pressed Esc)';
235
+ case '\x1b[200~': return 'Bracketed-paste START (terminal supports bulk paste)';
236
+ case '\x1b[201~': return 'Bracketed-paste END';
237
+ case '\x03': return 'Ctrl+C';
238
+ case '\x10': return 'Ctrl+P (history prev, escape-free)';
239
+ case '\x0e': return 'Ctrl+N (history next, escape-free)';
240
+ case '\x15': return 'Ctrl+U (scroll half-page up)';
241
+ case '\x04': return 'Ctrl+D (scroll half-page down)';
242
+ case '\x09': return 'Tab';
243
+ }
244
+ // eslint-disable-next-line no-control-regex -- intentional: parsing SGR mouse sequences requires ESC matching
245
+ if (/^\x1b\[<\d+;\d+;\d+[Mm]$/.test(s)) {
246
+ const cb = Number(s.slice(3).split(';')[0]);
247
+ return (cb & 64) !== 0
248
+ ? `Mouse wheel ${(cb & 1) === 0 ? 'up' : 'down'} (reporting active)`
249
+ : 'Mouse click/drag (reporting active — plain selection needs Shift+drag)';
250
+ }
251
+ // eslint-disable-next-line no-control-regex -- intentional: parsing X10 mouse sequences requires ESC matching
252
+ if (/^\x1b\[M...$/.test(s))
253
+ return 'Mouse event, X10 encoding (reporting active)';
254
+ if (/^[\x20-\x7e]+$/.test(s))
255
+ return `Printable text (${s.length} chars — typing/paste-as-typing works)`;
256
+ return 'Unrecognized sequence (terminal-specific — paste the bytes line into a bug report)';
257
+ }
258
+ /**
259
+ * Interactive key probe: raw-mode stdin echo of every chunk with its
260
+ * meaning. Proves what THIS terminal delivers for arrows, Ctrl+P/N,
261
+ * paste, and wheel — run it when TUI input misbehaves.
262
+ */
263
+ export async function runKeysProbe() {
264
+ const stdin = process.stdin;
265
+ const stdout = process.stdout;
266
+ if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') {
267
+ process.stderr.write('klyro: doctor --keys needs an interactive terminal (stdin is not a TTY)\n');
268
+ return 2;
269
+ }
270
+ stdout.write('klyro doctor --keys — press keys, see exactly what your terminal sends.\n');
271
+ stdout.write('Try: ↑ ↓ PgUp PgDn Home End Ctrl+P Ctrl+N, then paste text, then scroll the wheel. Ctrl+C quits.\n');
272
+ stdin.setRawMode(true);
273
+ stdin.resume();
274
+ try {
275
+ await new Promise((resolve) => {
276
+ const onData = (chunk) => {
277
+ const bytes = [...chunk].map((b) => b.toString(16).padStart(2, '0')).join(' ');
278
+ stdout.write(`bytes: ${bytes} → ${describeKeyBytes(chunk)}\n`);
279
+ if (chunk.length === 1 && chunk[0] === 0x03) {
280
+ stdin.off('data', onData);
281
+ resolve();
282
+ }
283
+ };
284
+ stdin.on('data', onData);
285
+ });
286
+ }
287
+ finally {
288
+ try {
289
+ stdin.setRawMode(false);
290
+ }
291
+ catch { /* ignore */ }
292
+ try {
293
+ stdin.pause();
294
+ }
295
+ catch { /* ignore */ }
296
+ }
297
+ return 0;
298
+ }
172
299
  export async function runDoctor(opts = {}) {
173
300
  const cwd = opts.cwd ?? process.cwd();
174
301
  const checks = [];
175
302
  checks.push(checkNode());
303
+ checks.push(checkPackageManager());
304
+ checks.push(checkPath());
176
305
  checks.push(await checkConfig());
177
306
  checks.push(await checkProvider());
178
307
  checks.push(await checkSessions());
179
308
  checks.push(await checkGit());
309
+ checks.push(checkRipgrep());
310
+ checks.push(checkTerminal());
311
+ checks.push(checkProxy());
180
312
  checks.push(await checkTools());
181
313
  checks.push(checkPlatform());
182
314
  checks.push(await checkSandbox());
@@ -5,7 +5,7 @@ import { KlyroError } from '../shared/errors.js';
5
5
  export function handleFatal(err) {
6
6
  const isDebug = !!process.env.KLYRO_LOG_LEVEL || !!process.env.DEBUG || process.argv.includes('--debug');
7
7
  let code = 1;
8
- let message = err instanceof Error ? err.message : String(err);
8
+ const message = err instanceof Error ? err.message : String(err);
9
9
  let hint;
10
10
  if (err instanceof KlyroError) {
11
11
  code = err.exitCode;
package/dist/cli/eval.js CHANGED
@@ -65,13 +65,11 @@ export async function runEval(opts) {
65
65
  }
66
66
  // 5.4 — suite mode: load from evals/fixtures
67
67
  if (opts.suite) {
68
- const { runHarness, loadFileFixture } = await import('../eval/harness.js');
69
68
  const fs = await import('node:fs/promises');
70
69
  const path = await import('node:path');
71
- const suiteDir = path.join(process.cwd(), 'evals', 'fixtures', opts.suite === 'smoke' ? '' : opts.suite);
72
70
  // For smoke, use the 10 fixtures directly
73
71
  const fixturesDir = path.join(process.cwd(), 'evals', 'fixtures');
74
- let tasks = [];
72
+ const tasks = [];
75
73
  try {
76
74
  const entries = await fs.readdir(fixturesDir);
77
75
  for (const e of entries) {
@@ -38,7 +38,7 @@ export function renderMarkdown(md, opts = {}) {
38
38
  const lines = md.split('\n');
39
39
  let inCodeBlock = false;
40
40
  let fenceLang = '';
41
- for (let line of lines) {
41
+ for (const line of lines) {
42
42
  if (line.startsWith('```')) {
43
43
  if (!inCodeBlock)
44
44
  fenceLang = line.replace(/^```/, '').trim().toLowerCase();