klyro 1.0.4 → 1.0.5

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 (46) hide show
  1. package/README.md +29 -0
  2. package/dist/agent/runtime.js +11 -2
  3. package/dist/cli/auth.js +11 -3
  4. package/dist/cli/completion.js +63 -10
  5. package/dist/cli/doctor.js +13 -0
  6. package/dist/cli/repl.js +116 -10
  7. package/dist/cli/run.js +14 -0
  8. package/dist/cli/slash/parser.d.ts +7 -1
  9. package/dist/cli/slash/parser.js +37 -10
  10. package/dist/cli/update.d.ts +8 -4
  11. package/dist/cli/update.js +50 -7
  12. package/dist/context/accounting.d.ts +8 -0
  13. package/dist/context/accounting.js +18 -1
  14. package/dist/context/compaction.d.ts +1 -0
  15. package/dist/context/compaction.js +2 -1
  16. package/dist/index.js +78 -8
  17. package/dist/mcp/config.d.ts +7 -0
  18. package/dist/mcp/config.js +45 -0
  19. package/dist/mcp/registry.js +6 -4
  20. package/dist/mcp/serve.d.ts +23 -0
  21. package/dist/mcp/serve.js +111 -0
  22. package/dist/policy/engine.d.ts +11 -1
  23. package/dist/policy/engine.js +14 -1
  24. package/dist/policy/secret-redactor.js +4 -1
  25. package/dist/shared/error-map.d.ts +19 -0
  26. package/dist/shared/error-map.js +58 -0
  27. package/dist/tools/lsp/diagnostics.d.ts +35 -4
  28. package/dist/tools/lsp/diagnostics.js +88 -9
  29. package/dist/tools/normalize.d.ts +3 -0
  30. package/dist/tools/normalize.js +8 -5
  31. package/dist/tools/search/dependencies.d.ts +2 -2
  32. package/dist/tools/shell/background.d.ts +6 -0
  33. package/dist/tools/shell/background.js +17 -0
  34. package/dist/tools/symbols/find-symbol.d.ts +1 -1
  35. package/dist/tools/symbols/find-symbol.js +8 -6
  36. package/dist/tools/types.d.ts +8 -1
  37. package/dist/tui/app.d.ts +2 -0
  38. package/dist/tui/app.js +324 -50
  39. package/dist/tui/app.test.js +42 -3
  40. package/dist/tui/markdown.js +9 -0
  41. package/dist/tui/mouse.d.ts +26 -1
  42. package/dist/tui/mouse.js +104 -6
  43. package/dist/tui/scroll-flow.test.js +3 -1
  44. package/dist/tui/tokens.d.ts +6 -6
  45. package/dist/tui/tokens.js +9 -6
  46. package/package.json +1 -1
package/README.md CHANGED
@@ -26,6 +26,35 @@ node dist/index.js chat "Explain TypeScript in 2 sentences"
26
26
  node dist/index.js chat
27
27
  ```
28
28
 
29
+ ## Exit codes
30
+
31
+ | Code | Meaning |
32
+ |---|---|
33
+ | 0 | Success / complete |
34
+ | 1 | Unexpected failure (last-resort handler) |
35
+ | 2 | Usage / config error, policy refusal to commit, unknown command or option |
36
+ | 3 | Config invalid / not found |
37
+ | 4 | Provider error (auth, rate-limit, timeout) |
38
+ | 5 | No final answer from provider |
39
+ | 7 | Stopped: max steps, cost/time limit, or stuck |
40
+ | 8 | Verification failed (or `--require-verify` unsatisfied) |
41
+ | 130 | Aborted (Ctrl+C / Esc×2 / /cancel / SIGINT) |
42
+
43
+ ## Environment
44
+
45
+ | Variable | Scope |
46
+ |---|---|
47
+ | `KLYRO_BASE_URL`, `KLYRO_API_KEY`, `KLYRO_MODEL`, `KLYRO_PROVIDER` | Provider selection |
48
+ | `KLYRO_CONFIG` / `--config` | Config file override |
49
+ | `KLYRO_YES` / `--yes` | **Commit only** — auto-approves `klyro commit` prompts; nothing else reads it |
50
+ | `KLYRO_NO_UPDATE_CHECK=1` | Disables the 24h update check |
51
+ | `KLYRO_ALLOW_MAIN_PUSH=1` | Per-risk escape for protected-branch push |
52
+ | `KLYRO_CREDENTIALS_INSECURE_OK=1` | Warn (don't refuse) on group-readable credentials |
53
+ | `KLYRO_LSP=0` | Force language tools off |
54
+ | `KLYRO_SYMBOLS=0` | Force `find_symbol` off |
55
+ | `KLYRO_WORKER=0` | Disable subprocess isolation for subagents |
56
+ | `KLYRO_SESSIONS_DIR`, `KLYRO_UPDATE_CACHE`, `KLYRO_CREDENTIALS_FILE` | Relocatable state (tests + power users) |
57
+
29
58
  ## Documentation
30
59
 
31
60
  | Doc | Purpose |
@@ -23,11 +23,13 @@ import { verify, diagnosticForModel } from '../verification/engine.js';
23
23
  import { detectVerifyCommand } from '../verification/auto.js';
24
24
  import { ensureBaseline, getBaseline } from '../verification/baseline.js';
25
25
  import { compressTranscript, totalTokens, calibrateEstimate, transcriptCharLength } from '../context/tokenizer.js';
26
+ import { capForModel } from '../context/accounting.js';
26
27
  import { ratesFor, isAnthropicModel } from '../providers/model-info.js';
27
28
  import { classifyFailure, rerunOnce, gatherRepairContext, guardRepair } from '../verification/classify.js';
28
29
  import { findRelatedTests, buildScopedCommand, runScopedVerify, syntaxCheck, checkImports } from '../verification/scoped.js';
29
30
  import { globalBus } from '../events/bus.js';
30
31
  import { TraceWriter } from '../trace/writer.js';
32
+ import { killAllJobs } from '../tools/shell/background.js';
31
33
  import { loadHooks, runHook } from '../cli/hooks.js';
32
34
  /** Normalize either systemPrompt shape into {system, suffix}. */
33
35
  export function resolveSystemPrompt(fn, ctx) {
@@ -267,6 +269,9 @@ export async function run(opts, deps) {
267
269
  }
268
270
  if (opts.signal?.aborted) {
269
271
  emit?.({ kind: 'aborted' });
272
+ // Abort cascade (fix: background shells must not outlive the run).
273
+ const killed = killAllJobs();
274
+ 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' });
270
275
  if (store && sessionId) {
271
276
  try {
272
277
  await store.setStatus(sessionId, 'aborted', finalText);
@@ -297,7 +302,9 @@ export async function run(opts, deps) {
297
302
  // Budget accounting sees what the model sees (prefix + suffix); the
298
303
  // request itself keeps the halves split for cache-friendly adapters.
299
304
  const systemForBudget = telemetrySuffix ? `${stableSystem}\n\n${telemetrySuffix}` : stableSystem;
300
- const BUDGET = { total: 120_000, reservedOutput: 4000 };
305
+ // Window-aware ceiling (was a hardcoded 120k that overflowed 8k local
306
+ // models): size the input budget to the model's context window.
307
+ const BUDGET = { total: capForModel(opts.model, 4000), reservedOutput: 4000 };
301
308
  let reqMessages = transcript;
302
309
  let reqSystem = stableSystem;
303
310
  let reqSuffix = telemetrySuffix;
@@ -530,6 +537,8 @@ export async function run(opts, deps) {
530
537
  if (opts.signal?.aborted) {
531
538
  finalText = textBuf;
532
539
  emit?.({ kind: 'aborted' });
540
+ const killed = killAllJobs();
541
+ 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' });
533
542
  if (store && sessionId) {
534
543
  try {
535
544
  await store.setStatus(sessionId, 'aborted', finalText);
@@ -824,7 +833,7 @@ export async function run(opts, deps) {
824
833
  // results immediately — gate runs in call order so these stay ordered.
825
834
  // Returns true when the call is approved for execution.
826
835
  const gateCall = async (call) => {
827
- const decision = await deps.policy.evaluate({ name: call.name, input: call.input }, { cwd: opts.cwd, nonInteractive: opts.nonInteractive });
836
+ const decision = await deps.policy.evaluate({ name: call.name, input: call.input, permission: deps.registry.get(call.name)?.permission }, { cwd: opts.cwd, nonInteractive: opts.nonInteractive });
828
837
  emit?.({ kind: 'policy_decision', id: call.id, name: call.name, action: decision.action, ...(decision.action !== 'allow' ? { reason: decision.reason } : {}) });
829
838
  // Mirror to KlyroEvent bus
830
839
  if (decision.action === 'allow') {
package/dist/cli/auth.js CHANGED
@@ -132,13 +132,21 @@ export async function runLogout(provider) {
132
132
  }
133
133
  export function getStoredKey(provider) {
134
134
  try {
135
- // Warn (don't refuse) when the credentials file is group/other-readable.
136
- // Refusing would lock out existing users with old umasks; warn instead.
135
+ // Refuse (don't just warn) when the credentials file is
136
+ // group/other-readable: a key other users can read is compromised by
137
+ // definition. Repair hint included. KLYRO_CREDENTIALS_INSECURE_OK=1
138
+ // preserves the old warn-and-continue behavior for exotic setups.
137
139
  if (process.platform !== 'win32') {
138
140
  try {
139
141
  const st = fsSync.statSync(credPath());
140
142
  if ((st.mode & 0o077) !== 0) {
141
- process.stderr.write(`warning: credentials file ${credPath()} is group/other-readable (mode ${(st.mode & 0o777).toString(8)}) — run chmod 600 on it\n`);
143
+ if (process.env.KLYRO_CREDENTIALS_INSECURE_OK === '1') {
144
+ process.stderr.write(`warning: credentials file ${credPath()} is group/other-readable (mode ${(st.mode & 0o777).toString(8)}) — run chmod 600 on it\n`);
145
+ }
146
+ else {
147
+ process.stderr.write(`klyro: refusing to use group/other-readable credentials file ${credPath()} (mode ${(st.mode & 0o777).toString(8)}) — run: chmod 600 ${credPath()} (or set KLYRO_CREDENTIALS_INSECURE_OK=1 to override)\n`);
148
+ return undefined;
149
+ }
142
150
  }
143
151
  }
144
152
  catch { /* missing file → no warning */ }
@@ -2,40 +2,93 @@
2
2
  * 1.2 — klyro completion
3
3
  * Generates shell completion scripts for bash/zsh/fish/powershell.
4
4
  */
5
- const COMMANDS = ['tui', 'run', 'chat', 'config', 'doctor', 'completion', 'update', 'eval', 'session', 'resume', 'help', 'version'];
5
+ const COMMANDS = ['tui', 'run', 'chat', 'config', 'doctor', 'completion', 'update', 'eval', 'session', 'resume', 'help', 'version', 'scan', 'project', 'mcp', 'hooks', 'agents', 'commit', 'audit', 'benchmark', 'sessions', 'login', 'logout'];
6
+ /** Second-level completion: global flags + per-command flags. */
7
+ const GLOBAL_FLAGS = ['--cwd', '--config', '--debug', '--verbose', '--quiet', '--json', '--yes', '--no-color', '--print', '--output-format', '--no-stream', '--show-thinking', '--tui', '--chat', '--continue', '--resume', '--help', '--version'];
8
+ const COMMAND_FLAGS = {
9
+ run: ['-m', '--model', '--max-steps', '--max-tokens', '--temperature', '--timeout', '--base-url', '--api-key', '--output', '--provider', '--dry-run', '--resume', '--resume-session', '--verify', '--verify-command', '--verify-mode', '--max-repairs', '--persist', '--require-verify', '--agent', '--max-depth'],
10
+ chat: ['-s', '--system', '-m', '--model', '-t', '--timeout'],
11
+ eval: ['--output', '--suite', '--filter', '--runs', '--parallel', '--model'],
12
+ tui: ['-m', '--model', '--max-steps'],
13
+ doctor: ['--json'],
14
+ session: ['list', 'show', 'resume', 'fork', 'delete'],
15
+ sessions: ['export', 'import', 'fork', 'delete'],
16
+ mcp: ['list', 'add', 'remove', 'probe', 'serve'],
17
+ config: ['list', 'get', 'set', 'unset', 'path'],
18
+ commit: ['--dry-run', '--message', '--force-secret'],
19
+ completion: ['bash', 'zsh', 'fish', 'powershell'],
20
+ resume: ['-m', '--model', '--max-steps'],
21
+ };
22
+ function flagsFor(cmd) {
23
+ return [...GLOBAL_FLAGS, ...(COMMAND_FLAGS[cmd] ?? [])];
24
+ }
6
25
  function bashScript() {
7
- return `# klyro bash completion
26
+ const cmdCases = Object.entries(COMMAND_FLAGS)
27
+ .map(([c, fs]) => ` ${c}) opts="${fs.join(' ')}" ;;`)
28
+ .join('\n');
29
+ return `# klyro bash completion (commands + flags)
8
30
  _klyro_complete() {
9
31
  local cur="\${COMP_WORDS[COMP_CWORD]}"
32
+ local prev="\${COMP_WORDS[COMP_CWORD-1]}"
10
33
  local cmds="${COMMANDS.join(' ')}"
11
- COMPREPLY=( $(compgen -W "$cmds" -- "$cur") )
34
+ if [[ $COMP_CWORD -eq 1 ]]; then
35
+ COMPREPLY=( $(compgen -W "$cmds ${GLOBAL_FLAGS.join(' ')}" -- "$cur") )
36
+ return
37
+ fi
38
+ local first="\${COMP_WORDS[1]}"
39
+ local opts=""
40
+ case "$first" in
41
+ ${cmdCases}
42
+ *) opts="${GLOBAL_FLAGS.join(' ')}" ;;
43
+ esac
44
+ COMPREPLY=( $(compgen -W "$opts" -- "$cur") )
12
45
  }
13
46
  complete -F _klyro_complete klyro
14
47
  complete -F _klyro_complete ky
15
48
  `;
16
49
  }
17
50
  function zshScript() {
51
+ const cmdCases = Object.entries(COMMAND_FLAGS)
52
+ .map(([c, fs]) => ` ${c}) _values 'flags' ${fs.map((f) => `'${f}'`).join(' ')} ;;`)
53
+ .join('\n');
18
54
  return `#compdef klyro ky
19
55
  _klyro() {
20
- local -a completions
21
- completions=(${COMMANDS.map((c) => `'${c}'`).join(' ')})
22
- _describe 'klyro commands' completions
56
+ if (( CURRENT == 2 )); then
57
+ _describe 'klyro commands' ${COMMANDS.map((c) => `'${c}'`).join(' ')} ${GLOBAL_FLAGS.map((f) => `'${f}'`).join(' ')}
58
+ return
59
+ fi
60
+ case "$words[2]" in
61
+ ${cmdCases}
62
+ *) _values 'flags' ${GLOBAL_FLAGS.map((f) => `'${f}'`).join(' ')} ;;
63
+ esac
23
64
  }
24
65
  compdef _klyro klyro ky
25
66
  `;
26
67
  }
27
68
  function fishScript() {
28
- return `# klyro fish completion
29
- ${COMMANDS.map((c) => `complete -c klyro -f -a ${c}`).join('\n')}
69
+ const flagLines = Object.entries(COMMAND_FLAGS)
70
+ .flatMap(([c, fs]) => fs.map((f) => `complete -c klyro -f -n "__fish_seen_subcommand_from ${c}" -a ${f}`))
71
+ .join('\n');
72
+ return `# klyro fish completion (commands + flags)
73
+ ${COMMANDS.map((c) => `complete -c klyro -f -n __fish_use_subcommand -a ${c}`).join('\n')}
74
+ ${flagLines}
30
75
  complete -c ky -f -a "${COMMANDS.join(' ')}"
31
76
  `;
32
77
  }
33
78
  function powershellScript() {
34
- return `# klyro powershell completion
79
+ return `# klyro powershell completion (commands + flags)
35
80
  Register-ArgumentCompleter -Native -CommandName klyro,ky -ScriptBlock {
36
81
  param($wordToComplete, $commandAst, $cursorPosition)
37
82
  $cmds = @(${COMMANDS.map((c) => `'${c}'`).join(', ')})
38
- $cmds | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
83
+ $flags = @(${GLOBAL_FLAGS.map((f) => `'${f}'`).join(', ')})
84
+ $tokens = $commandAst.ToString() -split '\\s+'
85
+ if ($tokens.Count -le 2) { $cands = $cmds + $flags } else {
86
+ switch ($tokens[1]) {
87
+ ${Object.entries(COMMAND_FLAGS).map(([c, fs]) => ` '${c}' { $cands = @(${fs.map((f) => `'${f}'`).join(', ')}) }`).join('\n')}
88
+ default { $cands = $flags }
89
+ }
90
+ }
91
+ $cands | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
39
92
  [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
40
93
  }
41
94
  }
@@ -108,6 +108,18 @@ function checkPlatform() {
108
108
  const ok = ['win32', 'linux', 'darwin'].includes(process.platform);
109
109
  return { name: 'Platform', ok, detail: `${process.platform} ${process.arch} ${ok ? '✓' : '✗ unsupported'}` };
110
110
  }
111
+ async function checkSandbox() {
112
+ try {
113
+ const { detectSandbox } = await import('../tools/shell/sandbox.js');
114
+ const st = detectSandbox();
115
+ if (st.active)
116
+ return { name: 'Sandbox', ok: true, detail: `${st.backend} ✓` };
117
+ return { name: 'Sandbox', ok: true, detail: `none — ${st.reason ?? 'policy+path guards only'}` };
118
+ }
119
+ catch {
120
+ return { name: 'Sandbox', ok: true, detail: 'none — policy+path guards only' };
121
+ }
122
+ }
111
123
  async function checkMcp(cwd) {
112
124
  try {
113
125
  const { loadMcpServers } = await import('../mcp/config.js');
@@ -167,6 +179,7 @@ export async function runDoctor(opts = {}) {
167
179
  checks.push(await checkGit());
168
180
  checks.push(await checkTools());
169
181
  checks.push(checkPlatform());
182
+ checks.push(await checkSandbox());
170
183
  const mcpCheck = await checkMcp(cwd);
171
184
  const trustCheck = await checkTrust();
172
185
  checks.push(mcpCheck);
package/dist/cli/repl.js CHANGED
@@ -25,7 +25,7 @@ import { parseUnifiedDiff } from '../tui/diff-parser.js';
25
25
  import { parse } from './slash/parser.js';
26
26
  import { resolveProvider, providerHelp, lastProviderError } from '../providers.js';
27
27
  import { readVersion } from '../version.js';
28
- import { MouseFilter, MOUSE_ENABLE, MOUSE_DISABLE, createReadWrapper } from '../tui/mouse.js';
28
+ import { MouseFilter, MOUSE_ENABLE, MOUSE_DISABLE, PASTE_ENABLE, PASTE_DISABLE, PasteFilter, createReadWrapper } from '../tui/mouse.js';
29
29
  import { inferProviderFromBaseURL } from '../agent/registry.js';
30
30
  import { getDefaultSessionStore } from '../persistence/session.js';
31
31
  import { buildSystemPrompt, parseImageInput } from '../context/system-prompt.js';
@@ -291,6 +291,8 @@ export async function startRepl(opts = {}) {
291
291
  let lastStatus = null;
292
292
  const pendingQueue = [];
293
293
  let isMounted = false;
294
+ // Live vim input mode for the TUI (toggled by /vim, persisted to config).
295
+ let vimLive = 'insert';
294
296
  let directHooks;
295
297
  // Plain-text mirror for exit replay (scroll.md §1.2: session survives in
296
298
  // native scrollback after the alt screen is torn down). Cap 300 lines.
@@ -393,6 +395,7 @@ export async function startRepl(opts = {}) {
393
395
  // IMPORTANT: Ink 7 reads stdin via 'readable' + stdin.read() (paused mode),
394
396
  // never 'data' events — so the tap wraps read(), not emit().
395
397
  const mouseFilter = new MouseFilter();
398
+ const pasteFilter = new PasteFilter();
396
399
  const origStdinRead = process.stdin.read.bind(process.stdin);
397
400
  const origStdinEmit = process.stdin.emit.bind(process.stdin);
398
401
  let mouseTapInstalled = false;
@@ -405,18 +408,36 @@ export async function startRepl(opts = {}) {
405
408
  catch { /* ignore */ }
406
409
  }
407
410
  }
411
+ // Bracketed paste: bulk-insert into the input buffer instead of
412
+ // char-at-a-time typing (no autocomplete/history churn per char).
413
+ function dispatchPastes(pastes) {
414
+ for (const p of pastes) {
415
+ try {
416
+ if (isMounted && directHooks)
417
+ directHooks.pasteText(p);
418
+ }
419
+ catch { /* ignore */ }
420
+ }
421
+ }
408
422
  function installMouseTap() {
409
423
  if (!isAltScreen || mouseTapInstalled)
410
424
  return;
411
425
  mouseTapInstalled = true;
426
+ // Bracketed paste on: the terminal wraps pastes in ESC[200~ … ESC[201~.
427
+ try {
428
+ process.stdout.write(PASTE_ENABLE);
429
+ }
430
+ catch { /* ignore */ }
412
431
  const stdinAny = process.stdin;
413
432
  // Primary path: Ink's paused-mode read loop.
414
- stdinAny.read = createReadWrapper(origStdinRead, mouseFilter, (d) => dispatchWheels([d]));
433
+ stdinAny.read = createReadWrapper(origStdinRead, mouseFilter, (d) => dispatchWheels([d]), { onPaste: (p) => dispatchPastes([p]), pasteFilter });
415
434
  // Fallback path: flowing mode ('data' events), e.g. if any library
416
435
  // resumes the stream. Same split, same dispatch.
417
436
  stdinAny.emit = (...a) => {
418
437
  if (a[0] === 'data' && Buffer.isBuffer(a[1])) {
419
- const split = mouseFilter.push(a[1]);
438
+ const psplit = pasteFilter.push(a[1]);
439
+ dispatchPastes(psplit.pastes);
440
+ const split = mouseFilter.push(psplit.kept);
420
441
  dispatchWheels(split.wheels);
421
442
  if (split.kept.length === 0)
422
443
  return false;
@@ -430,6 +451,11 @@ export async function startRepl(opts = {}) {
430
451
  return;
431
452
  mouseTapInstalled = false;
432
453
  mouseFilter.reset();
454
+ pasteFilter.reset();
455
+ try {
456
+ process.stdout.write(PASTE_DISABLE);
457
+ }
458
+ catch { /* ignore */ }
433
459
  const stdinAny = process.stdin;
434
460
  stdinAny.read = origStdinRead;
435
461
  stdinAny.emit = origStdinEmit;
@@ -457,7 +483,9 @@ export async function startRepl(opts = {}) {
457
483
  if (!isAltScreen)
458
484
  return;
459
485
  const sink = (...args) => {
460
- const line = `[${new Date().toISOString()}] ${args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ')}`;
486
+ // Scrub secrets before the line reaches the ring buffer or disk —
487
+ // stray provider/tool logs must never persist credentials.
488
+ const line = redact(`[${new Date().toISOString()}] ${args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ')}`);
461
489
  consoleRing.push(line);
462
490
  if (consoleRing.length > 200)
463
491
  consoleRing.splice(0, consoleRing.length - 200);
@@ -700,6 +728,13 @@ export async function startRepl(opts = {}) {
700
728
  onMounted: (hooks) => {
701
729
  directHooks = hooks;
702
730
  isMounted = true;
731
+ // Apply a live vim mode toggled before mount.
732
+ if (vimLive !== 'insert') {
733
+ try {
734
+ hooks.setVimMode(vimLive);
735
+ }
736
+ catch { /* ignore */ }
737
+ }
703
738
  for (const ev of pendingQueue) {
704
739
  if (ev.kind === 'status')
705
740
  hooks.updateStatus(ev.patch);
@@ -1050,7 +1085,14 @@ export async function startRepl(opts = {}) {
1050
1085
  return;
1051
1086
  }
1052
1087
  case 'memory': {
1053
- queuedAppend({ id: `mem-${Date.now()}`, kind: 'text', text: 'Memory: .klyro/memory/session-notes.md (stub) — use /memory to view', role: 'assistant' });
1088
+ const { loadMemory } = await import('../context/memory.js');
1089
+ const notes = (await loadMemory(cwd)).trim();
1090
+ queuedAppend({
1091
+ id: `mem-${Date.now()}`,
1092
+ kind: 'text',
1093
+ text: notes ? `Memory (.klyro/memory/session-notes.md):\n${notes.slice(0, 4000)}` : 'Memory is empty — the agent records durable notes here via memory_write.',
1094
+ role: 'assistant',
1095
+ });
1054
1096
  return;
1055
1097
  }
1056
1098
  case 'jobs': {
@@ -1613,8 +1655,13 @@ export async function startRepl(opts = {}) {
1613
1655
  case 'cancel': {
1614
1656
  ac.abort();
1615
1657
  ac = new AbortController();
1658
+ // Immediate abort cascade: background shells must not outlive the
1659
+ // cancelled run (the runtime abort path also kills them, but the
1660
+ // controller swap above means we do it here for immediacy).
1661
+ const { killAllJobs } = await import('../tools/shell/background.js');
1662
+ const killed = killAllJobs();
1616
1663
  queuedStatus({ status: 'aborted' });
1617
- queuedAppend({ id: `cancel-${Date.now()}`, kind: 'text', text: 'cancelled current operation', role: 'assistant' });
1664
+ queuedAppend({ id: `cancel-${Date.now()}`, kind: 'text', text: killed.length > 0 ? `cancelled current operation (${killed.length} background job(s) killed)` : 'cancelled current operation', role: 'assistant' });
1618
1665
  return;
1619
1666
  }
1620
1667
  case 'shell': {
@@ -2468,13 +2515,72 @@ export async function startRepl(opts = {}) {
2468
2515
  }
2469
2516
  return;
2470
2517
  }
2471
- case 'keymap':
2472
- case 'vim':
2518
+ case 'keymap': {
2519
+ // Real binding table (mirrors src/tui/app.tsx useInput + mouse.ts).
2520
+ // A stored `klyro.keymap` override is shown, not applied — the TUI
2521
+ // has one compiled keymap; the value is kept for future remapping.
2522
+ const { runConfig } = await import('./config.js');
2523
+ const capture = async (args) => {
2524
+ const orig = process.stdout.write.bind(process.stdout);
2525
+ let out = '';
2526
+ process.stdout.write = ((c) => { out += String(c); return true; });
2527
+ try {
2528
+ await runConfig(args);
2529
+ }
2530
+ finally {
2531
+ process.stdout.write = orig;
2532
+ }
2533
+ return out;
2534
+ };
2535
+ const stored = cmd.name?.trim();
2536
+ if (stored) {
2537
+ await capture(['set', 'klyro.keymap', stored]);
2538
+ queuedAppend({ id: `keymap2-${Date.now()}`, kind: 'text', text: `keymap note saved: ${stored} (display-only — the TUI uses its compiled keymap below)`, role: 'assistant' });
2539
+ return;
2540
+ }
2541
+ queuedAppend({
2542
+ id: `keymap-${Date.now()}`,
2543
+ kind: 'text',
2544
+ role: 'assistant',
2545
+ text: [
2546
+ 'Keymap (TUI compiled bindings):',
2547
+ ' Enter send · Shift+Enter newline · Tab complete slash · Esc drop queued / Esc×2 cancel run',
2548
+ ' Ctrl+C cancel (1st) / quit (2nd) · Ctrl+O expand last tool group · Ctrl+G jump bottom',
2549
+ ' PgUp/PgDn or Ctrl+U/Ctrl+D half-page · Ctrl+Home/End top/bottom · Home/End jump · Space jump to unread',
2550
+ ' Ctrl+B/F page · Shift/Ctrl+↑/↓ line · ↑/↓ history (with text) else scroll · wheel ±3 lines',
2551
+ ' Shift+drag selects · /vim toggles vim input mode · /keymap <note> saves a display note',
2552
+ ].join('\n'),
2553
+ });
2554
+ return;
2555
+ }
2556
+ case 'vim': {
2557
+ // Live vim input mode (not a config stub): toggles the TUI between
2558
+ // insert and normal mode via directHooks; persists the choice too.
2559
+ const want = cmd.state?.trim().toLowerCase();
2560
+ const next = want === 'on' || want === 'normal' ? 'normal' : want === 'off' || want === 'insert' ? 'insert' : vimLive === 'insert' ? 'normal' : 'insert';
2561
+ vimLive = next;
2562
+ try {
2563
+ const { runConfig } = await import('./config.js');
2564
+ const orig = process.stdout.write.bind(process.stdout);
2565
+ process.stdout.write = (() => true);
2566
+ try {
2567
+ await runConfig(['set', 'klyro.vim', next]);
2568
+ }
2569
+ finally {
2570
+ process.stdout.write = orig;
2571
+ }
2572
+ }
2573
+ catch { /* persist best-effort */ }
2574
+ if (isMounted && directHooks)
2575
+ directHooks.setVimMode(next);
2576
+ queuedAppend({ id: `vim-${Date.now()}`, kind: 'text', text: `vim mode: ${next}${next === 'normal' ? ' (h/l move · i/a insert · x delete · 0/$ ends · j/k scroll)' : ''}`, role: 'assistant' });
2577
+ return;
2578
+ }
2473
2579
  case 'theme':
2474
2580
  case 'statusline':
2475
2581
  case 'output-style': {
2476
- const key = cmd.kind === 'keymap' ? 'klyro.keymap' : cmd.kind === 'vim' ? 'klyro.vim' : cmd.kind === 'theme' ? 'klyro.theme' : cmd.kind === 'statusline' ? 'klyro.statusline' : 'klyro.outputStyle';
2477
- const val = (cmd.kind === 'keymap' ? cmd.name : cmd.kind === 'vim' ? cmd.state : cmd.kind === 'theme' ? cmd.name : cmd.kind === 'statusline' ? cmd.format : cmd.style)?.trim();
2582
+ const key = cmd.kind === 'theme' ? 'klyro.theme' : cmd.kind === 'statusline' ? 'klyro.statusline' : 'klyro.outputStyle';
2583
+ const val = (cmd.kind === 'theme' ? cmd.name : cmd.kind === 'statusline' ? cmd.format : cmd.style)?.trim();
2478
2584
  const { runConfig } = await import('./config.js');
2479
2585
  const capture = async (args) => {
2480
2586
  const orig = process.stdout.write.bind(process.stdout);
package/dist/cli/run.js CHANGED
@@ -403,6 +403,20 @@ export async function runOnce(opts) {
403
403
  }
404
404
  if (output === 'human')
405
405
  stdout.write('\n');
406
+ // Final cost line: headless runs previously surfaced cost only through
407
+ // [budget] warnings. Human/silent → stderr one-liner; json → additive
408
+ // cost_usd/usage fields on the final object (purely additive, no shape break).
409
+ {
410
+ const { estimateCost } = await import('../providers/model-info.js');
411
+ const cost = estimateCost(opts.model, result.usage.input, result.usage.output);
412
+ const costLine = `$${cost.toFixed(4)} · ${result.usage.input} in / ${result.usage.output} out${result.usage.estimated ? ' (estimated)' : ''}`;
413
+ if (output === 'json') {
414
+ stdout.write(JSON.stringify({ kind: 'cost', cost_usd: cost, usage: result.usage }) + '\n');
415
+ }
416
+ else {
417
+ stderr.write(`klyro: cost ${costLine}\n`);
418
+ }
419
+ }
406
420
  if (result.status === 'max_steps') {
407
421
  if (output === 'json')
408
422
  stdout.write(JSON.stringify({ kind: 'final', status: result.status, steps: result.steps }) + '\n');
@@ -316,5 +316,11 @@ export interface CommandDef {
316
316
  }
317
317
  /** Curated canonical commands with one-line hints — drives /help, /commands and TUI autocomplete. */
318
318
  export declare const COMMAND_DEFS: CommandDef[];
319
- /** Prefix-match command names for TUI autocomplete — top `limit` (default 6). */
319
+ /**
320
+ * Fuzzy score for TUI autocomplete: subsequence match with bonuses for
321
+ * prefix (+100), word-boundary (+30), and consecutive (+15) matches, minus
322
+ * a gap penalty. Returns -Infinity when `query` is not a subsequence.
323
+ */
324
+ export declare function fuzzyScore(name: string, query: string): number;
325
+ /** Fuzzy-match command names for TUI autocomplete — top `limit` (default 6). */
320
326
  export declare function suggestCommands(prefix: string, limit?: number): CommandDef[];
@@ -293,18 +293,45 @@ export const COMMAND_DEFS = [
293
293
  { name: 'deps', hint: 'dependencies' },
294
294
  { name: 'install', hint: 'install deps' },
295
295
  ];
296
- /** Prefix-match command names for TUI autocomplete — top `limit` (default 6). */
296
+ /**
297
+ * Fuzzy score for TUI autocomplete: subsequence match with bonuses for
298
+ * prefix (+100), word-boundary (+30), and consecutive (+15) matches, minus
299
+ * a gap penalty. Returns -Infinity when `query` is not a subsequence.
300
+ */
301
+ export function fuzzyScore(name, query) {
302
+ const n = name.toLowerCase();
303
+ const q = query.toLowerCase();
304
+ if (!q)
305
+ return 0;
306
+ let score = 0;
307
+ let ni = 0;
308
+ let lastHit = -2;
309
+ for (let qi = 0; qi < q.length; qi++) {
310
+ const found = n.indexOf(q[qi], ni);
311
+ if (found === -1)
312
+ return -Infinity;
313
+ if (qi === 0 && found === 0)
314
+ score += 100; // prefix
315
+ if (found > 0 && (n[found - 1] === '-' || n[found - 1] === '_'))
316
+ score += 30; // word boundary
317
+ if (found === lastHit + 1)
318
+ score += 15; // consecutive
319
+ else
320
+ score -= (found - ni); // gap penalty
321
+ lastHit = found;
322
+ ni = found + 1;
323
+ }
324
+ score -= (n.length - q.length); // prefer shorter names
325
+ return score;
326
+ }
327
+ /** Fuzzy-match command names for TUI autocomplete — top `limit` (default 6). */
297
328
  export function suggestCommands(prefix, limit = 6) {
298
329
  const p = prefix.toLowerCase().replace(/^\//, '');
299
330
  if (!p)
300
331
  return COMMAND_DEFS.slice(0, limit);
301
- const starts = [];
302
- const contains = [];
303
- for (const d of COMMAND_DEFS) {
304
- if (d.name.startsWith(p))
305
- starts.push(d);
306
- else if (d.name.includes(p))
307
- contains.push(d);
308
- }
309
- return [...starts, ...contains].slice(0, limit);
332
+ return COMMAND_DEFS.map((d) => ({ d, s: fuzzyScore(d.name, p) + fuzzyScore(d.hint, p) * 0.25 }))
333
+ .filter((x) => x.s > -Infinity)
334
+ .sort((a, b) => b.s - a.s)
335
+ .slice(0, limit)
336
+ .map((x) => x.d);
310
337
  }
@@ -2,10 +2,14 @@
2
2
  * klyro update — check registry for newer version, cached 24h.
3
3
  * Env KLYRO_NO_UPDATE_CHECK=1 disables.
4
4
  *
5
- * Integrity: before recommending `npm i`, we verify the tarball's SRI hash
6
- * (sha512) against the registry's recorded `dist.integrity`. An install is
7
- * only recommended when the download hash matches, so a tampered CDN or
8
- * MITM registry response can't push a malicious binary to the operator.
5
+ * Integrity: before recommending `npm i`, we verify the tarball against
6
+ * BOTH the registry's SRI digest (sha512/sha256) AND the legacy sha1
7
+ * `dist.shasum` when present — a tampered CDN or MITM registry response
8
+ * must forge two independent digests to push a malicious binary.
9
+ * Downgrade protection: a registry `latest` that is not strictly newer
10
+ * than the running version (semver) is never recommended.
9
11
  */
12
+ /** Minimal semver compare for `x.y.z[-prerelease]`; null when unparseable. */
13
+ export declare function compareSemver(a: string, b: string): number | null;
10
14
  export declare function checkForUpdate(current: string): Promise<string | null>;
11
15
  export declare function runUpdate(): Promise<number>;