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
package/dist/cli/repl.js CHANGED
@@ -103,6 +103,7 @@ export async function startRepl(opts = {}) {
103
103
  // sanitizeForPrompt in sibling-owned policy/approval.ts — intentionally
104
104
  // not imported; kept local to avoid coupling the REPL to that module).
105
105
  function sanitizeForPrompt(s) {
106
+ // eslint-disable-next-line no-control-regex -- intentional: stripping C0 controls + CSI sequences
106
107
  return s.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]|\x1b\[[0-9;]*[A-Za-z]/g, '').slice(0, 120);
107
108
  }
108
109
  async function loadMcpTools() {
@@ -190,7 +191,7 @@ export async function startRepl(opts = {}) {
190
191
  // become fallback adapters for agent runs in this session. Live
191
192
  // /provider switches rebuild a single adapter (buildAdapter) and bypass
192
193
  // the chain — documented, not a bug.
193
- let replFailoverAdapters = [];
194
+ const replFailoverAdapters = [];
194
195
  try {
195
196
  const { resolveProviderChain } = await import('./config.js');
196
197
  const chain = await resolveProviderChain(cwd);
@@ -372,11 +373,12 @@ export async function startRepl(opts = {}) {
372
373
  // ── Full-screen takeover like OpenCode — always when klyro in TTY (user explicitly wants it)
373
374
  // Scroll now works correctly via internal viewport, not native terminal scroll
374
375
  const isAltScreen = useTui && !!process.stdout.isTTY && process.env.KLYRO_NO_ALT !== '1';
375
- // Mouse reporting is opt-in (KLYRO_MOUSE=1): when on, the terminal sends
376
- // clicks/wheel to the app (wheel scrolls ±3 lines) but native text
377
- // selection and right-click paste stop working. Default off so select to
378
- // copy and right-click paste work out of the box; bracketed paste
379
- // (keyboard paste) is unaffected and always enabled below.
376
+ // Mouse reporting is ON by default (opencode parity): the wheel scrolls
377
+ // the chat, clicks are swallowed by the app. That means plain mouse
378
+ // selection/right-click go to the app, not the terminal — use Shift+drag
379
+ // to select and Shift+right-click to paste, or set KLYRO_MOUSE=0 for fully
380
+ // native selection (then scroll with PgUp/PgDn, Ctrl+U/D, Shift+↑/↓,
381
+ // Home/End, Space). Bracketed (keyboard) paste is unaffected and always on.
380
382
  const mouseReporting = isAltScreen && isMouseReportingEnabled();
381
383
  const enterAlt = () => {
382
384
  if (!isAltScreen)
@@ -501,8 +503,9 @@ export async function startRepl(opts = {}) {
501
503
  if (consoleRing.length > 200)
502
504
  consoleRing.splice(0, consoleRing.length - 200);
503
505
  try {
506
+ // KLYRO_LOG_DIR relocates the debug log (1.5); default is ~/.klyro.
504
507
  const home = process.env.HOME ?? process.env.USERPROFILE ?? cwd;
505
- const dir = nodePath.join(home, '.klyro');
508
+ const dir = process.env.KLYRO_LOG_DIR ?? nodePath.join(home, '.klyro');
506
509
  fsSync.mkdirSync(dir, { recursive: true });
507
510
  const logFile = nodePath.join(dir, 'debug.log');
508
511
  // Rotation: never let the debug log grow without bound (2 MiB cap).
@@ -724,6 +727,7 @@ export async function startRepl(opts = {}) {
724
727
  }
725
728
  catch { /* silent */ }
726
729
  })();
730
+ // eslint-disable-next-line prefer-const -- single assignment, but `let` is required: declaration precedes this for TDZ-safe handler wiring.
727
731
  app = render(React.createElement(App, {
728
732
  initialModel: model,
729
733
  maxSteps: currentMaxSteps,
@@ -778,6 +782,7 @@ export async function startRepl(opts = {}) {
778
782
  }), { patchConsole: false });
779
783
  // Install SIGINT handler only after app exists (avoids TDZ) and use once
780
784
  // On Ctrl+C in alt-screen, leave alt before exit so shell is restored
785
+ // eslint-disable-next-line prefer-const -- see above: declaration order is load-bearing.
781
786
  sigintHandler = () => {
782
787
  ac.abort();
783
788
  queuedStatus({ status: 'aborted' });
@@ -861,9 +866,6 @@ export async function startRepl(opts = {}) {
861
866
  return;
862
867
  }
863
868
  }
864
- let activeCallId = null;
865
- let activeCallName = null;
866
- let activeCallArgs = '';
867
869
  let runEndStatus = 'error';
868
870
  try {
869
871
  const result = await run({
@@ -902,14 +904,6 @@ export async function startRepl(opts = {}) {
902
904
  else if (ev.kind === 'checkpoint_saved') {
903
905
  queuedStatus({ status: 'running' });
904
906
  }
905
- else if (ev.kind === 'tool_call_start') {
906
- activeCallId = ev.id;
907
- activeCallName = ev.name;
908
- activeCallArgs = '';
909
- }
910
- else if (ev.kind === 'tool_call_delta') {
911
- activeCallArgs += ev.argsJson;
912
- }
913
907
  else if (ev.kind === 'tool_call_end') {
914
908
  pendingToolIds.add(ev.id);
915
909
  queuedAppend({
@@ -920,9 +914,6 @@ export async function startRepl(opts = {}) {
920
914
  args: JSON.stringify(ev.input, null, 2),
921
915
  status: 'running',
922
916
  });
923
- activeCallId = null;
924
- activeCallName = null;
925
- activeCallArgs = '';
926
917
  }
927
918
  else if (ev.kind === 'policy_decision') {
928
919
  queuedAppend({
@@ -1354,8 +1345,6 @@ export async function startRepl(opts = {}) {
1354
1345
  }
1355
1346
  case 'context': {
1356
1347
  const { renderContextBreakdown } = await import('./context.js');
1357
- // use last transcript via closure? approximate with empty
1358
- const { accounting } = await import('../context/accounting.js');
1359
1348
  const sys = ''; // system prompt approx
1360
1349
  const breakdown = renderContextBreakdown(sys, []);
1361
1350
  queuedAppend({ id: `ctx-${Date.now()}`, kind: 'text', text: breakdown, role: 'assistant' });
@@ -2689,7 +2678,7 @@ export async function startRepl(opts = {}) {
2689
2678
  ' Enter send · Shift+Enter newline · Tab complete slash · Esc drop queued / Esc×2 cancel run',
2690
2679
  ' Ctrl+C cancel (1st) / quit (2nd) · Ctrl+O expand last tool group · Ctrl+G jump bottom',
2691
2680
  ' PgUp/PgDn or Ctrl+U/Ctrl+D half-page · Ctrl+Home/End top/bottom · Home/End jump · Space jump to unread',
2692
- ' Ctrl+B/F page · Shift/Ctrl+↑/↓ line · ↑/↓ and Ctrl+P/N browse input history only · PgUp/Dn or Ctrl+U/D scroll · wheel scrolls with KLYRO_MOUSE=1',
2681
+ ' Ctrl+B/F page · Shift/Ctrl+↑/↓ line · ↑/↓ and Ctrl+P/N browse input history only · wheel scrolls · PgUp/Dn or Ctrl+U/D scroll · KLYRO_MOUSE=0 for native selection',
2693
2682
  ' Text selection/copy and right-click paste work natively · Shift+Enter newline · /vim toggles vim input mode · /keymap <note> saves a display note',
2694
2683
  ].join('\n'),
2695
2684
  });
package/dist/cli/run.d.ts CHANGED
@@ -15,9 +15,25 @@ export interface RunCliOptions {
15
15
  cwd: string;
16
16
  model: string;
17
17
  maxSteps?: number;
18
+ /** Alias for maxSteps (3.5) — when both are set, the smaller wins. */
19
+ maxTurns?: number;
18
20
  maxTokens?: number;
19
21
  temperature?: number;
22
+ /** Reasoning effort for reasoning models (OpenAI `reasoning_effort`). */
23
+ reasoningEffort?: 'low' | 'medium' | 'high';
20
24
  systemPrompt?: SystemPromptFn;
25
+ /** Replace the assembled system prompt wholesale (2.3). */
26
+ systemPromptText?: string;
27
+ /** Extra instructions appended after the assembled system prompt (2.3). */
28
+ appendSystemPrompt?: string;
29
+ /** Permission mode override (3.4): default|accept-edits|plan|auto. */
30
+ permissionMode?: import('../policy/engine.js').PermissionMode;
31
+ /** Tool names pre-approved for this run (added to allow rules). */
32
+ allowedTools?: string[] | string;
33
+ /** Tool names denied for this run (deny always wins). */
34
+ disallowedTools?: string[] | string;
35
+ /** Extra sandbox directories, as with /add-dir. */
36
+ addDir?: string[] | string;
21
37
  baseUrl?: string;
22
38
  apiKey?: string;
23
39
  timeoutMs?: number;
@@ -76,6 +92,22 @@ export interface RunCliOptions {
76
92
  agent?: string;
77
93
  maxDepth?: number;
78
94
  }
95
+ /**
96
+ * Split a comma-separated tool/dir list (string or string[]) into names.
97
+ * Exported for tests.
98
+ */
99
+ export declare function splitToolList(v: string[] | string | undefined): string[];
100
+ /** Smaller of two optional caps (--max-turns aliases --max-steps). Exported for tests. */
101
+ export declare function minDefined(a: number | undefined, b: number | undefined): number | undefined;
102
+ /**
103
+ * Layer --system-prompt/--append-system-prompt over the assembled prompt
104
+ * (2.3): replace wins wholesale, append rides after the assembled system
105
+ * (preserving any suffix the base carries). Exported for tests.
106
+ */
107
+ export declare function withSystemPromptOverrides(base: SystemPromptFn, opts: {
108
+ replace?: string;
109
+ append?: string;
110
+ }): SystemPromptFn;
79
111
  /**
80
112
  * Double-Ctrl+C detector (pure, exported for tests): the second SIGINT
81
113
  * within 1500ms of the first forces `process.exit(130)`. The live handler
package/dist/cli/run.js CHANGED
@@ -7,6 +7,7 @@
7
7
  * even on a long task. Tool calls and policy decisions go to stderr so
8
8
  * they don't pollute the captured `finalText` if the user pipes.
9
9
  */
10
+ import * as path from 'node:path';
10
11
  import { stdout, stderr } from 'node:process';
11
12
  import { httpChatAdapter } from '../agent/provider-adapter.js';
12
13
  import { anthropicAdapter } from '../agent/anthropic-adapter.js';
@@ -20,8 +21,47 @@ import { redact } from '../policy/secret-redactor.js';
20
21
  import { buildLevel6Context } from '../context/level6.js';
21
22
  import { memoryBlock } from '../context/memory.js';
22
23
  import { estimateCost } from '../providers/model-info.js';
24
+ import { resolveModelAlias } from '../providers/model-info.js';
25
+ import { logDebug, logInfo } from '../util/log.js';
23
26
  import { resolveSessionId } from '../persistence/session.js';
24
27
  import * as fs from 'node:fs';
28
+ /**
29
+ * Split a comma-separated tool/dir list (string or string[]) into names.
30
+ * Exported for tests.
31
+ */
32
+ export function splitToolList(v) {
33
+ const arr = v === undefined ? [] : Array.isArray(v) ? v : [v];
34
+ return arr.flatMap((s) => String(s).split(',')).map((s) => s.trim()).filter((s) => s.length > 0);
35
+ }
36
+ /** Smaller of two optional caps (--max-turns aliases --max-steps). Exported for tests. */
37
+ export function minDefined(a, b) {
38
+ if (a === undefined)
39
+ return b;
40
+ if (b === undefined)
41
+ return a;
42
+ return Math.min(a, b);
43
+ }
44
+ /**
45
+ * Layer --system-prompt/--append-system-prompt over the assembled prompt
46
+ * (2.3): replace wins wholesale, append rides after the assembled system
47
+ * (preserving any suffix the base carries). Exported for tests.
48
+ */
49
+ export function withSystemPromptOverrides(base, opts) {
50
+ if (opts.replace === undefined && opts.append === undefined)
51
+ return base;
52
+ return (ctx) => {
53
+ if (opts.replace !== undefined) {
54
+ return opts.append ? `${opts.replace}\n\n${opts.append}` : opts.replace;
55
+ }
56
+ const r = base(ctx);
57
+ const system = typeof r === 'string' ? r : r.system;
58
+ const suffix = typeof r === 'string' ? undefined : r.suffix;
59
+ const out = { system: `${system}\n\n${opts.append}` };
60
+ if (suffix !== undefined)
61
+ out.suffix = suffix;
62
+ return out;
63
+ };
64
+ }
25
65
  /**
26
66
  * Double-Ctrl+C detector (pure, exported for tests): the second SIGINT
27
67
  * within 1500ms of the first forces `process.exit(130)`. The live handler
@@ -38,6 +78,9 @@ export async function runOnce(opts) {
38
78
  loadDotenv(opts.cwd);
39
79
  }
40
80
  catch { /* ignore */ }
81
+ // Model aliases (2.2: sonnet/opus/haiku/gpt/local) resolve once here so
82
+ // the session store, cost math, and every adapter agree on the id.
83
+ opts = { ...opts, model: resolveModelAlias(opts.model) };
41
84
  const output = opts.output ?? 'human';
42
85
  if (opts.dryRun) {
43
86
  return await dryRunReport(opts);
@@ -138,7 +181,18 @@ export async function runOnce(opts) {
138
181
  catch { /* best-effort — single-provider run proceeds */ }
139
182
  }
140
183
  const registry = builtinRegistry();
141
- const policy = new PolicyEngine(builtinRules(), clonePolicyConfig());
184
+ const policyConfig = clonePolicyConfig();
185
+ // Explicit CLI overrides (3.4). Persisted rules load next via applyRules;
186
+ // the engine's deny-first precedence keeps any denial terminal.
187
+ if (opts.permissionMode !== undefined)
188
+ policyConfig.mode = opts.permissionMode;
189
+ for (const t of splitToolList(opts.allowedTools))
190
+ policyConfig.allow.push(t);
191
+ for (const t of splitToolList(opts.disallowedTools))
192
+ policyConfig.deny.push(t);
193
+ for (const d of splitToolList(opts.addDir))
194
+ policyConfig.additionalDirs.push(path.resolve(opts.cwd, d));
195
+ const policy = new PolicyEngine(builtinRules(), policyConfig);
142
196
  // Persisted "always allow" patterns apply to one-shot runs too.
143
197
  try {
144
198
  const { loadPermissionRules } = await import('./config.js');
@@ -181,7 +235,7 @@ export async function runOnce(opts) {
181
235
  }
182
236
  catch { /* ignore — MCP is optional */ }
183
237
  }
184
- const systemPrompt = await makeRunSystemPrompt(opts.cwd, opts.systemPrompt ?? defaultRunSystemPrompt, opts.bare);
238
+ const systemPrompt = withSystemPromptOverrides(await makeRunSystemPrompt(opts.cwd, opts.systemPrompt ?? defaultRunSystemPrompt, opts.bare), { replace: opts.systemPromptText, append: opts.appendSystemPrompt });
185
239
  // Level 9 — session setup (create or resume). --bare skips persistence.
186
240
  const persistEnabled = opts.persist !== false && !opts.bare;
187
241
  let store;
@@ -219,9 +273,6 @@ export async function runOnce(opts) {
219
273
  stderr.write(`klyro: resuming session ${sessionId.slice(0, 8)} — task: "${rec.task}"\n`);
220
274
  }
221
275
  }
222
- else if (opts.resumePath) {
223
- // already handled
224
- }
225
276
  else {
226
277
  // Create new session
227
278
  const rec = await store.create({ cwd: opts.cwd, task: opts.task, config: { model: opts.model, maxSteps: opts.maxSteps ?? 30 } });
@@ -313,23 +364,34 @@ export async function runOnce(opts) {
313
364
  model: opts.model,
314
365
  });
315
366
  }
367
+ // 2.5 latency tracking: TTFT = first model event after run start.
368
+ const runStartMs = Date.now();
369
+ let ttftMs;
370
+ logDebug('run start', { model: opts.model, maxSteps: opts.maxSteps, bare: !!opts.bare });
316
371
  try {
317
372
  result = await run({
318
373
  task: opts.task,
319
374
  cwd: opts.cwd,
320
375
  model: opts.model,
321
376
  ...(opts.bare ? { bare: true } : {}),
322
- maxSteps: opts.maxSteps,
377
+ maxSteps: minDefined(opts.maxSteps, opts.maxTurns),
323
378
  maxTokens: opts.maxTokens,
324
379
  temperature: opts.temperature,
380
+ reasoningEffort: opts.reasoningEffort,
325
381
  signal: ac.signal,
326
382
  nonInteractive: true,
327
383
  initialTranscript,
384
+ // Sandbox roots for file tools: cwd plus --add-dir extras (resolved
385
+ // absolute). checkAllowedPaths narrows to exactly this set.
386
+ allowedPaths: [path.resolve(opts.cwd), ...(policyConfig.additionalDirs ?? [])],
328
387
  verify: verifyOpts,
329
388
  persist: store && sessionId ? { store, sessionId } : undefined,
330
389
  ...(agentBridge ? { agentBridge } : {}),
331
390
  ...(parentContext ? { parentContext } : {}),
332
391
  onEvent: (ev) => {
392
+ if (ttftMs === undefined && (ev.kind === 'text_delta' || ev.kind === 'tool_call_start')) {
393
+ ttftMs = Date.now() - runStartMs;
394
+ }
333
395
  if (output === 'json') {
334
396
  stdout.write(JSON.stringify(ev) + '\n');
335
397
  return;
@@ -436,9 +498,12 @@ export async function runOnce(opts) {
436
498
  // [budget] warnings. Human/silent → stderr one-liner; json → additive
437
499
  // cost_usd/usage fields on the final object (purely additive, no shape break).
438
500
  const finalCost = estimateCost(opts.model, result.usage.input, result.usage.output);
439
- const costLine = `$${finalCost.toFixed(4)} · ${result.usage.input} in / ${result.usage.output} out${result.usage.estimated ? ' (estimated)' : ''}`;
501
+ const totalMs = Date.now() - runStartMs;
502
+ logInfo('run finish', { status: result.status, steps: result.steps, toolCalls: result.toolCalls, totalMs });
503
+ const ttftPart = ttftMs !== undefined ? ` · ttft ${ttftMs}ms` : '';
504
+ const costLine = `$${finalCost.toFixed(4)} · ${result.usage.input} in / ${result.usage.output} out${result.usage.estimated ? ' (estimated)' : ''}${ttftPart} · total ${totalMs}ms`;
440
505
  if (output === 'json') {
441
- stdout.write(JSON.stringify({ kind: 'cost', cost_usd: finalCost, usage: result.usage }) + '\n');
506
+ stdout.write(JSON.stringify({ kind: 'cost', cost_usd: finalCost, usage: result.usage, ttft_ms: ttftMs ?? null, total_ms: totalMs }) + '\n');
442
507
  }
443
508
  else {
444
509
  stderr.write(`klyro: cost ${costLine}\n`);
@@ -457,6 +522,8 @@ export async function runOnce(opts) {
457
522
  toolCalls: result.toolCalls,
458
523
  cost_usd: finalCost,
459
524
  usage: result.usage,
525
+ ttft_ms: ttftMs ?? null,
526
+ total_ms: totalMs,
460
527
  ...(result.verification ? { verification: result.verification } : {}),
461
528
  ...(sessionId ? { session_id: sessionId } : {}),
462
529
  ...extra,
@@ -523,7 +590,7 @@ export async function runOnce(opts) {
523
590
  async function dryRunReport(opts) {
524
591
  // Assemble the REAL prompt (Level-6 context + KLYRO.md), not the bare base —
525
592
  // otherwise dry-run shows a different prompt than production runs use.
526
- const systemPromptFn = await makeRunSystemPrompt(opts.cwd, opts.systemPrompt ?? defaultRunSystemPrompt);
593
+ const systemPromptFn = withSystemPromptOverrides(await makeRunSystemPrompt(opts.cwd, opts.systemPrompt ?? defaultRunSystemPrompt), { replace: opts.systemPromptText, append: opts.appendSystemPrompt });
527
594
  const { resolveSystemPrompt } = await import('../agent/runtime.js');
528
595
  const { system, suffix } = resolveSystemPrompt(systemPromptFn, { cwd: opts.cwd });
529
596
  const systemPrompt = suffix ? `${system}\n\n${suffix}` : system;
@@ -536,6 +603,7 @@ async function dryRunReport(opts) {
536
603
  maxSteps: opts.maxSteps,
537
604
  maxTokens: opts.maxTokens,
538
605
  temperature: opts.temperature,
606
+ reasoningEffort: opts.reasoningEffort,
539
607
  // Secrets must never leak into a printable report: redact both the
540
608
  // assembled system prompt and the task before printing.
541
609
  systemPrompt: redact(systemPrompt),
@@ -13,6 +13,7 @@ import * as fs from 'node:fs/promises';
13
13
  import * as path from 'node:path';
14
14
  import * as os from 'node:os';
15
15
  import { createHash } from 'node:crypto';
16
+ import { proxiedFetch } from '../shared/proxy.js';
16
17
  const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
17
18
  const REGISTRY_BASE = 'https://registry.npmjs.org/klyro';
18
19
  const FETCH_TIMEOUT_MS = 3000;
@@ -64,7 +65,7 @@ async function fetchWithTimeout(url, timeoutMs = FETCH_TIMEOUT_MS) {
64
65
  const ctrl = new AbortController();
65
66
  const t = setTimeout(() => ctrl.abort(), timeoutMs);
66
67
  try {
67
- const res = await fetch(url, { signal: ctrl.signal });
68
+ const res = await proxiedFetch(url, { signal: ctrl.signal, timeoutMs });
68
69
  if (!res.ok)
69
70
  throw new Error(`HTTP ${res.status}`);
70
71
  return res;
@@ -95,6 +96,11 @@ async function verifyTarballIntegrity(dist) {
95
96
  export async function checkForUpdate(current) {
96
97
  if (process.env.KLYRO_NO_UPDATE_CHECK === '1')
97
98
  return null;
99
+ // Disabled in CI unless explicitly enabled (1.5): automated pipelines
100
+ // must never phone home or suggest interactive updates.
101
+ if ((process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true') && process.env.KLYRO_UPDATE_CHECK !== '1') {
102
+ return null;
103
+ }
98
104
  const cache = cachePath();
99
105
  try {
100
106
  const raw = await fs.readFile(cache, 'utf-8');
@@ -2,8 +2,8 @@
2
2
  * 8.1 — Accounting & stable assembly
3
3
  * Live token estimate, ctx%, compactAt, reserveOutput, toolResultMax
4
4
  */
5
- import { totalTokens } from './tokenizer.js';
6
5
  import { getModelInfo } from '../providers/model-info.js';
6
+ import { totalTokens } from './tokenizer.js';
7
7
  /**
8
8
  * Single output-reserve used by every budget in the harness (displayed
9
9
  * accounting, runtime enforcement, compaction elide). One constant so the
@@ -61,6 +61,10 @@ export declare class RuntimeTelemetry {
61
61
  private errorCount;
62
62
  private recent;
63
63
  constructor(opts?: Level7Options);
64
+ private muted;
65
+ /** Disable telemetry accumulation + reporting (also via KLYRO_TELEMETRY=0). */
66
+ mute(): void;
67
+ isMuted(): boolean;
64
68
  setMaxSteps(n: number): void;
65
69
  recordStepStart(step: number): void;
66
70
  recordUsage(input: number, output: number): void;
@@ -40,14 +40,29 @@ export class RuntimeTelemetry {
40
40
  maxRecentCalls: opts.maxRecentCalls ?? DEFAULT_MAX_RECENT,
41
41
  maxChars: opts.maxChars ?? DEFAULT_MAX_CHARS,
42
42
  };
43
+ // KLYRO_TELEMETRY=0 disables telemetry accumulation + reporting.
44
+ this.muted = process.env.KLYRO_TELEMETRY === '0';
45
+ }
46
+ muted = false;
47
+ /** Disable telemetry accumulation + reporting (also via KLYRO_TELEMETRY=0). */
48
+ mute() {
49
+ this.muted = true;
50
+ }
51
+ isMuted() {
52
+ return this.muted;
43
53
  }
44
54
  setMaxSteps(n) { this.maxSteps = n; }
45
- recordStepStart(step) { this.step = step; }
55
+ recordStepStart(step) { if (!this.muted)
56
+ this.step = step; }
46
57
  recordUsage(input, output) {
58
+ if (this.muted)
59
+ return;
47
60
  this.inputTokens += input;
48
61
  this.outputTokens += output;
49
62
  }
50
63
  recordToolCall(call, latencyMs, isError) {
64
+ if (this.muted)
65
+ return;
51
66
  this.toolCallCount++;
52
67
  if (isError)
53
68
  this.errorCount++;
@@ -63,6 +78,8 @@ export class RuntimeTelemetry {
63
78
  }
64
79
  }
65
80
  recordError(msg) {
81
+ if (this.muted)
82
+ return;
66
83
  this.lastError = msg;
67
84
  }
68
85
  /**
@@ -89,6 +106,8 @@ export class RuntimeTelemetry {
89
106
  };
90
107
  }
91
108
  format() {
109
+ if (this.muted)
110
+ return emptyTelemetryBlock();
92
111
  const s = this.snapshot();
93
112
  const lines = [];
94
113
  lines.push('# Runtime telemetry');
@@ -21,6 +21,7 @@
21
21
  * what to walk past.
22
22
  */
23
23
  import * as fs from 'node:fs/promises';
24
+ import { statSync, readFileSync } from 'node:fs';
24
25
  import * as path from 'node:path';
25
26
  export const IGNORED_DIRS = new Set([
26
27
  'node_modules', '.git', 'dist', 'build', '.next', '.turbo',
@@ -246,7 +247,7 @@ function detectPackageManager(root, pkg) {
246
247
  // Avoid the async cost by re-using the root listing we already do.
247
248
  // (Caller threads the listing into detectFrameworks; here we re-stat directly.)
248
249
  try {
249
- require('node:fs').statSync(path.join(root, lock));
250
+ statSync(path.join(root, lock));
250
251
  return pm;
251
252
  }
252
253
  catch { /* not present */ }
@@ -295,9 +296,9 @@ function extractBuildCommands(pkg, root) {
295
296
  // Makefile: pick the first `target:` block as a hint.
296
297
  // (Don't try to parse make fully — just surface the targets as a single hint.)
297
298
  try {
298
- const stat = require('node:fs').statSync(path.join(root, 'Makefile'));
299
+ const stat = statSync(path.join(root, 'Makefile'));
299
300
  if (stat.isFile() && stat.size < MAX_BYTES) {
300
- const raw = require('node:fs').readFileSync(path.join(root, 'Makefile'), 'utf-8');
301
+ const raw = readFileSync(path.join(root, 'Makefile'), 'utf-8');
301
302
  const targets = [...raw.matchAll(/^([a-zA-Z_][\w-]*)\s*:/gm)].map((m) => m[1]).slice(0, 6);
302
303
  if (targets.length)
303
304
  out.push(`make <target> # targets: ${targets.join(', ')}`);
@@ -368,11 +369,11 @@ async function gitHead(root) {
368
369
  }
369
370
  function lockfileHash(root) {
370
371
  const candidates = ['pnpm-lock.yaml', 'yarn.lock', 'package-lock.json', 'bun.lockb', 'Cargo.lock', 'go.sum', 'poetry.lock', 'uv.lock'];
371
- let h = crypto.createHash('sha1');
372
+ const h = crypto.createHash('sha1');
372
373
  let found = false;
373
374
  for (const f of candidates) {
374
375
  try {
375
- const d = require('node:fs').readFileSync(path.join(root, f));
376
+ const d = readFileSync(path.join(root, f));
376
377
  h.update(d);
377
378
  found = true;
378
379
  }
@@ -456,17 +457,17 @@ function detectMonorepo(root, rootFiles, rootDirs) {
456
457
  return true;
457
458
  if (rootDirs.includes('packages') || rootDirs.includes('apps')) {
458
459
  try {
459
- const s = require('node:fs').statSync(path.join(root, 'packages'));
460
+ const s = statSync(path.join(root, 'packages'));
460
461
  if (s.isDirectory())
461
462
  return true;
462
463
  }
463
- catch { }
464
+ catch { /* ignore — best-effort probe */ }
464
465
  try {
465
- const s2 = require('node:fs').statSync(path.join(root, 'apps'));
466
+ const s2 = statSync(path.join(root, 'apps'));
466
467
  if (s2.isDirectory())
467
468
  return true;
468
469
  }
469
- catch { }
470
+ catch { /* ignore — best-effort probe */ }
470
471
  }
471
472
  return false;
472
473
  }
@@ -477,20 +478,20 @@ async function detectRuntimeVersions(root) {
477
478
  if (v)
478
479
  out['node'] = v.trim();
479
480
  }
480
- catch { }
481
+ catch { /* ignore — best-effort probe */ }
481
482
  try {
482
483
  const pkg = await readJsonSafe(path.join(root, 'package.json'));
483
484
  const eng = pkg?.engines;
484
485
  if (eng?.node)
485
486
  out['node-eng'] = eng.node;
486
487
  }
487
- catch { }
488
+ catch { /* ignore — best-effort probe */ }
488
489
  try {
489
490
  const py = await readTextSafe(path.join(root, '.python-version'));
490
491
  if (py)
491
492
  out['python'] = py.trim();
492
493
  }
493
- catch { }
494
+ catch { /* ignore — best-effort probe */ }
494
495
  try {
495
496
  const go = await readTextSafe(path.join(root, 'go.mod'));
496
497
  if (go) {
@@ -499,7 +500,7 @@ async function detectRuntimeVersions(root) {
499
500
  out['go'] = m[1];
500
501
  }
501
502
  }
502
- catch { }
503
+ catch { /* ignore — best-effort probe */ }
503
504
  return out;
504
505
  }
505
506
  async function detectEntryPoints(root) {
@@ -77,7 +77,7 @@ export interface FileFixture {
77
77
  script?: StreamEvent[][];
78
78
  }
79
79
  export declare function loadFileFixture(dir: string): Promise<FileFixture>;
80
- export declare function runFileFixture(fixture: FileFixture, opts?: {
80
+ export declare function runFileFixture(fixture: FileFixture, _opts?: {
81
81
  runs?: number;
82
82
  parallel?: number;
83
83
  }): Promise<TaskResult>;
@@ -122,7 +122,7 @@ export async function runTask(t, opts = {}) {
122
122
  try {
123
123
  await fs.rm(cwd, { recursive: true, force: true });
124
124
  }
125
- catch { }
125
+ catch { /* ignore — best-effort tmp cleanup */ }
126
126
  }
127
127
  }
128
128
  }
@@ -177,7 +177,7 @@ export async function loadFileFixture(dir) {
177
177
  catch { /* no script → static fixture */ }
178
178
  return { dir, task: task.trim(), checkSh, meta, ...(repo ? { repo } : {}), ...(script ? { script } : {}) };
179
179
  }
180
- export async function runFileFixture(fixture, opts = {}) {
180
+ export async function runFileFixture(fixture, _opts = {}) {
181
181
  const start = Date.now();
182
182
  const tmp = path.join(os.tmpdir(), 'klyro-eval-file-' + Math.random().toString(36).slice(2));
183
183
  await fs.mkdir(tmp, { recursive: true });