klyro 0.1.3 → 0.1.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 (80) hide show
  1. package/dist/agent/anthropic-adapter.d.ts +1 -1
  2. package/dist/agent/anthropic-adapter.js +22 -20
  3. package/dist/agent/provider-adapter.js +30 -7
  4. package/dist/agent/registry.js +24 -7
  5. package/dist/agent/retry.js +54 -9
  6. package/dist/agent/runtime.d.ts +45 -1
  7. package/dist/agent/runtime.js +236 -23
  8. package/dist/chat.d.ts +3 -3
  9. package/dist/chat.js +137 -39
  10. package/dist/cli/auth.d.ts +9 -0
  11. package/dist/cli/auth.js +84 -0
  12. package/dist/cli/completion.d.ts +6 -0
  13. package/dist/cli/completion.js +66 -0
  14. package/dist/cli/config.d.ts +44 -0
  15. package/dist/cli/config.js +428 -0
  16. package/dist/cli/doctor.d.ts +8 -0
  17. package/dist/cli/doctor.js +136 -0
  18. package/dist/cli/errors.d.ts +5 -0
  19. package/dist/cli/errors.js +37 -0
  20. package/dist/cli/markdown.d.ts +9 -0
  21. package/dist/cli/markdown.js +77 -0
  22. package/dist/cli/repl.d.ts +2 -0
  23. package/dist/cli/repl.js +280 -52
  24. package/dist/cli/run.d.ts +9 -0
  25. package/dist/cli/run.js +97 -2
  26. package/dist/cli/slash/parser.d.ts +10 -0
  27. package/dist/cli/slash/parser.js +6 -1
  28. package/dist/cli/trace.d.ts +7 -0
  29. package/dist/cli/trace.js +63 -0
  30. package/dist/cli/update.d.ts +6 -0
  31. package/dist/cli/update.js +70 -0
  32. package/dist/context/system-prompt.d.ts +14 -0
  33. package/dist/context/system-prompt.js +47 -0
  34. package/dist/events/bus.d.ts +16 -0
  35. package/dist/events/bus.js +28 -0
  36. package/dist/events/catalog.d.ts +136 -0
  37. package/dist/events/catalog.js +5 -0
  38. package/dist/index.js +369 -23
  39. package/dist/persistence/session.d.ts +12 -0
  40. package/dist/persistence/session.js +45 -0
  41. package/dist/policy/engine.d.ts +11 -1
  42. package/dist/policy/engine.js +85 -2
  43. package/dist/providers/model-info.d.ts +18 -0
  44. package/dist/providers/model-info.js +18 -0
  45. package/dist/providers.js +3 -2
  46. package/dist/renderers/json.d.ts +11 -0
  47. package/dist/renderers/json.js +14 -0
  48. package/dist/renderers/terminal.d.ts +9 -0
  49. package/dist/renderers/terminal.js +41 -0
  50. package/dist/repl.js +52 -15
  51. package/dist/shared/errors.d.ts +18 -0
  52. package/dist/shared/errors.js +33 -0
  53. package/dist/shared/index.d.ts +2 -0
  54. package/dist/shared/index.js +2 -0
  55. package/dist/shared/types.d.ts +19 -0
  56. package/dist/shared/types.js +11 -0
  57. package/dist/tools/fs/read-file.d.ts +1 -31
  58. package/dist/tools/fs/read-file.js +45 -17
  59. package/dist/tools/fs/read-history.d.ts +3 -0
  60. package/dist/tools/fs/read-history.js +13 -0
  61. package/dist/tools/fs/write-file.d.ts +1 -4
  62. package/dist/tools/fs/write-file.js +35 -1
  63. package/dist/tools/normalize.d.ts +1 -1
  64. package/dist/tools/shell/shell-exec.d.ts +1 -26
  65. package/dist/tools/shell/shell-exec.js +121 -17
  66. package/dist/tools/types.d.ts +25 -0
  67. package/dist/trace/writer.d.ts +13 -0
  68. package/dist/trace/writer.js +53 -0
  69. package/dist/tui/app.d.ts +7 -14
  70. package/dist/tui/app.js +228 -26
  71. package/dist/tui/approval.js +25 -4
  72. package/dist/tui/approval.test.js +1 -1
  73. package/dist/tui/snapshot.test.d.ts +6 -0
  74. package/dist/tui/snapshot.test.js +87 -0
  75. package/dist/tui/status.js +5 -1
  76. package/dist/util/log.d.ts +12 -0
  77. package/dist/util/log.js +75 -0
  78. package/dist/verification/auto.d.ts +8 -0
  79. package/dist/verification/auto.js +52 -0
  80. package/package.json +3 -2
package/dist/cli/repl.js CHANGED
@@ -9,6 +9,7 @@ import React from 'react';
9
9
  import { render } from 'ink';
10
10
  import { App } from '../tui/app.js';
11
11
  import { httpChatAdapter } from '../agent/provider-adapter.js';
12
+ import { anthropicAdapter } from '../agent/anthropic-adapter.js';
12
13
  import { run } from '../agent/runtime.js';
13
14
  import { builtinRegistry } from '../tools/registry.js';
14
15
  import { builtinRules, DEFAULT_POLICY_CONFIG, PolicyEngine } from '../policy/engine.js';
@@ -16,42 +17,90 @@ import { buildLevel6Context } from '../context/level6.js';
16
17
  import { DenyAllApprovalPrompt, StdinApprovalPrompt } from '../policy/approval.js';
17
18
  import { TuiApprovalBridge } from '../tui/approval.js';
18
19
  import { parseUnifiedDiff } from '../tui/diff-parser.js';
19
- function readEnv(name, fallback) {
20
- const v = process.env[name];
21
- return v && v.length > 0 ? v : fallback;
22
- }
20
+ import { resolveProvider, providerHelp } from '../providers.js';
21
+ import { inferProviderFromBaseURL } from '../agent/registry.js';
22
+ import { getDefaultSessionStore } from '../persistence/session.js';
23
+ import { buildSystemPrompt, parseImageInput } from '../context/system-prompt.js';
24
+ import { estimateCost } from '../providers/model-info.js';
23
25
  export async function startRepl(opts = {}) {
24
- const baseUrl = readEnv('KLYRO_BASE_URL');
25
- const apiKey = readEnv('KLYRO_API_KEY');
26
- const model = opts.model ?? readEnv('KLYRO_MODEL');
27
- if (!baseUrl || !apiKey || !model) {
28
- process.stderr.write('klyro: KLYRO_BASE_URL, KLYRO_API_KEY, and KLYRO_MODEL must be set\n');
26
+ // Reuse the same provider resolution as legacy repl.ts — probes local
27
+ // Ollama / LM Studio / vLLM when env is not fully set, so bare `klyro`
28
+ // works with a local model just like `klyro chat` does.
29
+ const resolved = await resolveProvider();
30
+ if (!resolved) {
31
+ process.stderr.write('klyro: no provider available.\n');
32
+ process.stderr.write(` ${providerHelp(null)}\n`);
33
+ process.stderr.write(' Set KLYRO_BASE_URL and KLYRO_API_KEY, or run a local server (Ollama, LM Studio, vLLM).\n');
34
+ process.stderr.write(' Examples:\n');
35
+ process.stderr.write(' set KLYRO_BASE_URL=https://api.openai.com/v1\n');
36
+ process.stderr.write(' set KLYRO_API_KEY=sk-...\n');
37
+ process.stderr.write(' ollama serve # then KLYRO_BASE_URL=http://localhost:11434/v1 KLYRO_MODEL=llama3.2\n');
29
38
  return 2;
30
39
  }
40
+ const baseUrl = resolved.baseURL;
41
+ const apiKey = resolved.apiKey;
42
+ let model = opts.model ?? resolved.model;
31
43
  const cwd = opts.cwd ?? process.cwd();
32
44
  const registry = builtinRegistry();
33
45
  const policy = new PolicyEngine(builtinRules(), DEFAULT_POLICY_CONFIG);
34
- const adapter = httpChatAdapter({ baseURL: baseUrl, apiKey, timeoutMs: 60_000 });
46
+ const providerKind = inferProviderFromBaseURL(baseUrl);
47
+ // Local Ollama exposes OpenAI-compat but hostname could contain "anthropic"
48
+ // via proxy — don't try anthropic adapter with empty key (would 401).
49
+ const effectiveProvider = providerKind === 'anthropic' && !apiKey ? 'openai' : providerKind;
50
+ if (providerKind === 'anthropic' && !apiKey) {
51
+ process.stderr.write('klyro: anthropic provider detected but KLYRO_API_KEY is empty — falling back to OpenAI-compatible adapter\n');
52
+ }
53
+ const adapter = effectiveProvider === 'anthropic'
54
+ ? anthropicAdapter({ baseURL: baseUrl, apiKey, timeoutMs: 60_000 })
55
+ : httpChatAdapter({ baseURL: baseUrl, apiKey, timeoutMs: 60_000 });
35
56
  const ctxBlock = await buildLevel6Context({ cwd });
36
57
  const ctxPrefix = ctxBlock.formatted ? `\n\n<context>\n${ctxBlock.formatted}\n</context>` : '';
58
+ // 2.3 layered system prompt
37
59
  const systemPromptFn = (_ctx) => {
38
- const base = opts.systemPrompt ?? 'You are Klyro, an autonomous coding harness. Solve the user\'s task using the available tools.';
60
+ const base = buildSystemPrompt({ cwd, model, extraSystem: opts.systemPrompt, appendSystem: ctxPrefix });
39
61
  const t = _ctx.telemetry ? '\n\n' + _ctx.telemetry : '';
40
- return base + ctxPrefix + t;
62
+ return base + t;
41
63
  };
42
64
  const ac = new AbortController();
43
- process.on('SIGINT', () => ac.abort());
44
65
  // When the TUI is mounted, use the inline Ink prompt. Otherwise
45
66
  // fall back to stdin readline. The bridge is shared between the
46
67
  // App and the runtime so the modal can resolve the runtime's ask().
47
68
  const tuiBridge = new TuiApprovalBridge();
69
+ const useTui = opts.forceTty || process.stdin.isTTY;
48
70
  const approval = opts.nonInteractive
49
71
  ? new DenyAllApprovalPrompt()
50
- : (process.stdin.isTTY ? tuiBridge : new StdinApprovalPrompt());
72
+ : (useTui ? tuiBridge : new StdinApprovalPrompt());
51
73
  let inflight = null;
52
- let transcriptRef = [];
53
74
  let lastStatus = null;
54
- const app = render(React.createElement(App, {
75
+ const pendingQueue = [];
76
+ let isMounted = false;
77
+ let directHooks;
78
+ function queuedAppend(item) {
79
+ if (isMounted && directHooks)
80
+ directHooks.append(item);
81
+ else
82
+ pendingQueue.push({ kind: 'append', item });
83
+ }
84
+ function queuedStatus(s) {
85
+ lastStatus = { ...(lastStatus ?? { model: model ?? '', step: 0, maxSteps: opts.maxSteps ?? 30, usageInput: 0, usageOutput: 0, repairs: 0, status: 'idle' }), ...s };
86
+ if (isMounted && directHooks)
87
+ directHooks.updateStatus(s);
88
+ else
89
+ pendingQueue.push({ kind: 'status', patch: s });
90
+ }
91
+ function queuedPlan(p) {
92
+ if (isMounted && directHooks)
93
+ directHooks.updatePlan(p);
94
+ else
95
+ pendingQueue.push({ kind: 'plan', plan: p });
96
+ }
97
+ // Declare app before handler to avoid TDZ; handler added after render
98
+ let app;
99
+ let sigintHandler;
100
+ // Level 9 — session store for TUI (one store per REPL)
101
+ const tuiStore = getDefaultSessionStore();
102
+ let tuiSessionId;
103
+ app = render(React.createElement(App, {
55
104
  initialModel: model,
56
105
  maxSteps: opts.maxSteps ?? 30,
57
106
  cwd,
@@ -65,32 +114,106 @@ export async function startRepl(opts = {}) {
65
114
  onSlash: async (cmd) => {
66
115
  await handleSlash(cmd);
67
116
  },
117
+ onMounted: (hooks) => {
118
+ directHooks = hooks;
119
+ isMounted = true;
120
+ for (const ev of pendingQueue) {
121
+ if (ev.kind === 'status')
122
+ hooks.updateStatus(ev.patch);
123
+ else if (ev.kind === 'plan')
124
+ hooks.updatePlan(ev.plan);
125
+ else
126
+ hooks.append(ev.item);
127
+ }
128
+ pendingQueue.length = 0;
129
+ },
68
130
  }));
69
- // Bridge: subscribes to global hooks installed by App.useEffect.
70
- // Every time the runtime emits, we translate to a transcript item or
71
- // a status update.
72
- const appG = globalThis;
131
+ // Install SIGINT handler only after app exists (avoids TDZ) and use once
132
+ sigintHandler = () => {
133
+ ac.abort();
134
+ queuedStatus({ status: 'aborted' });
135
+ try {
136
+ app?.unmount();
137
+ }
138
+ catch { /* ignore */ }
139
+ };
140
+ process.once('SIGINT', sigintHandler);
73
141
  async function runWithBridge(text) {
74
- appG.__klyroAppStatus?.({ status: 'running', step: 0 });
142
+ if (!model) {
143
+ queuedAppend({ id: `err-${Date.now()}`, kind: 'error', message: 'no model configured' });
144
+ queuedStatus({ status: 'error', errorMessage: 'no model configured' });
145
+ return;
146
+ }
147
+ // 2.3 image handling: extract @img refs
148
+ const { text: cleanText, images } = parseImageInput(text);
149
+ const taskText = images.length > 0 ? `${cleanText}\n\n[images: ${images.join(', ')}]` : cleanText;
150
+ // Level 9 — create session for this prompt if not already (one session per prompt)
151
+ let sessionId;
152
+ try {
153
+ const rec = await tuiStore.create({ cwd, task: taskText, config: { model, maxSteps: opts.maxSteps ?? 30 } });
154
+ sessionId = rec.id;
155
+ tuiSessionId = rec.id;
156
+ queuedAppend({ id: `sess-${Date.now()}`, kind: 'text', text: `session ${rec.id.slice(0, 8)} started`, role: 'assistant' });
157
+ }
158
+ catch {
159
+ // best-effort
160
+ }
161
+ queuedStatus({ status: 'running', step: 0, model });
75
162
  let textBuf = '';
163
+ let pendingTextId = null;
76
164
  let activeCallId = null;
77
165
  let activeCallName = null;
78
166
  let activeCallArgs = '';
79
167
  try {
80
168
  const result = await run({
81
- task: text,
169
+ task: taskText,
82
170
  cwd,
83
- model: model,
171
+ model,
84
172
  maxSteps: opts.maxSteps ?? 30,
85
173
  signal: ac.signal,
86
174
  nonInteractive: opts.nonInteractive ?? false,
175
+ verify: { enabled: true, maxRepairAttempts: 3 },
176
+ persist: sessionId ? { store: tuiStore, sessionId } : undefined,
87
177
  onEvent: (ev) => {
88
178
  if (ev.kind === 'step_start') {
89
- appG.__klyroAppStatus?.({ step: ev.step });
179
+ // Flush coalesced text before new step
180
+ pendingTextId = null;
181
+ queuedStatus({ step: ev.step });
90
182
  }
91
183
  else if (ev.kind === 'text_delta') {
92
184
  textBuf += ev.text;
93
- appG.__klyroAppAppend?.({ id: `text-${ev.kind}-${Date.now()}-${Math.random()}`, kind: 'text', text: ev.text, role: 'assistant' });
185
+ // Coalesce: reuse pending text item if still queued, otherwise create one.
186
+ // App.tsx also coalesces post-mount, so we only need to avoid queue bloat.
187
+ if (pendingTextId) {
188
+ const last = pendingQueue[pendingQueue.length - 1];
189
+ if (last?.kind === 'append' && last.item.kind === 'text' && last.item.id === pendingTextId) {
190
+ last.item.text += ev.text;
191
+ return;
192
+ }
193
+ }
194
+ // For mounted case, App will merge via its own coalescing (same id)
195
+ // so reuse pendingTextId to let App merge
196
+ if (pendingTextId && isMounted) {
197
+ queuedAppend({ id: pendingTextId, kind: 'text', text: ev.text, role: 'assistant' });
198
+ return;
199
+ }
200
+ const id = `text-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
201
+ pendingTextId = id;
202
+ queuedAppend({ id, kind: 'text', text: ev.text, role: 'assistant' });
203
+ }
204
+ else if (ev.kind === 'verification_started') {
205
+ queuedAppend({ id: `vrfy-${Date.now()}`, kind: 'text', text: `[verify] running \`${ev.command}\``, role: 'assistant' });
206
+ queuedStatus({ status: 'running' });
207
+ }
208
+ else if (ev.kind === 'verification_succeeded') {
209
+ queuedAppend({ id: `vrfy-ok-${Date.now()}`, kind: 'text', text: `[verify] passed (${ev.command})`, role: 'assistant' });
210
+ }
211
+ else if (ev.kind === 'repair_started') {
212
+ queuedAppend({ id: `repair-${Date.now()}`, kind: 'text', text: `[repair] attempt ${ev.attempt}/${ev.maxAttempts}: ${ev.reason.slice(0, 200)}`, role: 'assistant' });
213
+ queuedStatus({ status: 'running', errorMessage: undefined });
214
+ }
215
+ else if (ev.kind === 'checkpoint_saved') {
216
+ queuedStatus({ status: 'running' });
94
217
  }
95
218
  else if (ev.kind === 'tool_call_start') {
96
219
  activeCallId = ev.id;
@@ -101,7 +224,7 @@ export async function startRepl(opts = {}) {
101
224
  activeCallArgs += ev.argsJson;
102
225
  }
103
226
  else if (ev.kind === 'tool_call_end') {
104
- appG.__klyroAppAppend?.({
227
+ queuedAppend({
105
228
  id: `tool-${ev.id}-${Date.now()}`,
106
229
  kind: 'tool',
107
230
  name: ev.name,
@@ -114,7 +237,7 @@ export async function startRepl(opts = {}) {
114
237
  activeCallArgs = '';
115
238
  }
116
239
  else if (ev.kind === 'policy_decision') {
117
- appG.__klyroAppAppend?.({
240
+ queuedAppend({
118
241
  id: `pol-${ev.id}-${Date.now()}`,
119
242
  kind: 'policy',
120
243
  name: ev.name,
@@ -123,7 +246,7 @@ export async function startRepl(opts = {}) {
123
246
  });
124
247
  }
125
248
  else if (ev.kind === 'tool_result') {
126
- appG.__klyroAppAppend?.({
249
+ queuedAppend({
127
250
  id: `tres-${ev.id}-${Date.now()}`,
128
251
  kind: 'tool',
129
252
  name: ev.name,
@@ -136,13 +259,13 @@ export async function startRepl(opts = {}) {
136
259
  });
137
260
  }
138
261
  else if (ev.kind === 'usage') {
139
- appG.__klyroAppStatus?.({ usageInput: ev.input, usageOutput: ev.output });
262
+ queuedStatus({ usageInput: ev.input, usageOutput: ev.output });
140
263
  }
141
264
  else if (ev.kind === 'plan_update') {
142
- appG.__klyroAppPlan?.(ev.plan);
265
+ queuedPlan(ev.plan);
143
266
  }
144
267
  else if (ev.kind === 'file_changed') {
145
- appG.__klyroAppAppend?.({
268
+ queuedAppend({
146
269
  id: `fc-${ev.path}-${Date.now()}`,
147
270
  kind: 'file_changed',
148
271
  path: ev.path,
@@ -150,57 +273,140 @@ export async function startRepl(opts = {}) {
150
273
  });
151
274
  }
152
275
  else if (ev.kind === 'verification_failed') {
153
- appG.__klyroAppAppend?.({
276
+ queuedAppend({
154
277
  id: `vf-${ev.step}-${Date.now()}`,
155
278
  kind: 'error',
156
279
  message: `verification failed at ${ev.step}: ${ev.reason}`,
157
280
  });
158
281
  }
159
282
  else if (ev.kind === 'aborted') {
160
- appG.__klyroAppStatus?.({ status: 'aborted' });
283
+ queuedStatus({ status: 'aborted' });
161
284
  }
162
285
  },
163
286
  }, { adapter, registry, policy, approval, systemPrompt: systemPromptFn });
164
- appG.__klyroAppStatus?.({ status: 'complete' === result.status ? 'done' : 'error', repairs: result.repairs ?? 0 });
287
+ if (result.verification) {
288
+ const v = result.verification;
289
+ queuedAppend({
290
+ id: `vfin-${Date.now()}`,
291
+ kind: 'text',
292
+ text: `[verify] ${v.ok ? 'passed' : `failed (${v.failureType ?? 'unknown'})`} after ${v.attempts} attempt(s)${v.command ? ` — ${v.command}` : ''}`,
293
+ role: 'assistant',
294
+ });
295
+ if (!v.ok) {
296
+ queuedStatus({ status: 'error', errorMessage: `verification failed: ${v.failureType ?? 'unknown'}` });
297
+ }
298
+ }
299
+ if (result.status === 'verify_failed') {
300
+ queuedAppend({ id: `verr-${Date.now()}`, kind: 'error', message: `Verification failed after ${result.verification?.attempts ?? 3} attempts. Check output above.` });
301
+ queuedStatus({ status: 'error', errorMessage: 'verification failed' });
302
+ }
303
+ else {
304
+ queuedStatus({ status: 'complete' === result.status ? 'done' : 'error', repairs: result.repairs ?? 0 });
305
+ }
306
+ if (sessionId) {
307
+ queuedAppend({ id: `sess-end-${Date.now()}`, kind: 'text', text: `session ${sessionId.slice(0, 8)} ${result.status} — ${result.steps} steps, ${result.toolCalls} tool calls`, role: 'assistant' });
308
+ }
165
309
  }
166
310
  catch (err) {
167
311
  const message = err instanceof Error ? err.message : String(err);
168
- appG.__klyroAppAppend?.({ id: `err-${Date.now()}`, kind: 'error', message });
169
- appG.__klyroAppStatus?.({ status: 'error', errorMessage: message });
312
+ queuedAppend({ id: `err-${Date.now()}`, kind: 'error', message });
313
+ queuedStatus({ status: 'error', errorMessage: message });
170
314
  }
171
315
  }
172
316
  async function handleSlash(cmd) {
173
317
  switch (cmd.kind) {
174
318
  case 'quit':
175
- app.unmount();
176
- process.exit(0);
319
+ try {
320
+ app?.unmount();
321
+ }
322
+ catch { /* ignore */ }
323
+ // Listener cleanup is handled by the waitUntilExit resolver below
177
324
  return;
178
325
  case 'clear':
179
- // Bypass via the app's setTranscript by re-rendering is awkward;
180
- // for MVP, append a marker and rely on the user to scroll.
181
- // A real implementation would expose a clear() method.
182
- appG.__klyroAppAppend?.({ id: `sep-${Date.now()}`, kind: 'text', text: '--- cleared ---', role: 'assistant' });
326
+ queuedAppend({ id: `sep-${Date.now()}`, kind: 'text', text: '--- cleared ---', role: 'assistant' });
183
327
  return;
184
328
  case 'help': {
185
- const helpText = 'commands: /clear /compact /model <id> /diff /status /quit';
186
- appG.__klyroAppAppend?.({ id: `help-${Date.now()}`, kind: 'text', text: helpText, role: 'assistant' });
329
+ const helpText = [
330
+ 'commands:',
331
+ ' /clear — clear transcript marker',
332
+ ' /diff — show git diff',
333
+ ' /status — show session status',
334
+ ' /compact — (stub) context compaction',
335
+ ' /model <id> — switch model mid-session',
336
+ ' /config — show config path',
337
+ ' /doctor — run diagnostics',
338
+ ' /version — show version',
339
+ ' /quit (/exit) — exit',
340
+ `provider: ${effectiveProvider} model: ${model} cwd: ${cwd}`,
341
+ ].join('\n');
342
+ queuedAppend({ id: `help-${Date.now()}`, kind: 'text', text: helpText, role: 'assistant' });
343
+ return;
344
+ }
345
+ case 'config': {
346
+ const { getConfigPath } = await import('./config.js');
347
+ queuedAppend({ id: `cfg-${Date.now()}`, kind: 'text', text: `config: ${getConfigPath()}`, role: 'assistant' });
348
+ return;
349
+ }
350
+ case 'doctor': {
351
+ const { runDoctor } = await import('./doctor.js');
352
+ // Capture doctor output via temporary override
353
+ const origWrite = process.stdout.write.bind(process.stdout);
354
+ let out = '';
355
+ process.stdout.write = ((chunk) => { out += String(chunk); return true; });
356
+ await runDoctor({});
357
+ process.stdout.write = origWrite;
358
+ queuedAppend({ id: `doc-${Date.now()}`, kind: 'text', text: out, role: 'assistant' });
359
+ return;
360
+ }
361
+ case 'version': {
362
+ const { readFileSync } = await import('node:fs');
363
+ const { resolve, dirname } = await import('node:path');
364
+ const { fileURLToPath } = await import('node:url');
365
+ try {
366
+ const here = dirname(fileURLToPath(import.meta.url));
367
+ const pkg = JSON.parse(readFileSync(resolve(here, '../../package.json'), 'utf-8'));
368
+ queuedAppend({ id: `ver-${Date.now()}`, kind: 'text', text: `klyro ${pkg.version ?? '0.0.0'}`, role: 'assistant' });
369
+ }
370
+ catch {
371
+ queuedAppend({ id: `ver-${Date.now()}`, kind: 'text', text: 'klyro (version unknown)', role: 'assistant' });
372
+ }
373
+ return;
374
+ }
375
+ case 'cost': {
376
+ const cost = lastStatus ? estimateCost(lastStatus.model ?? model, lastStatus.usageInput ?? 0, lastStatus.usageOutput ?? 0) : 0;
377
+ const input = lastStatus?.usageInput ?? 0;
378
+ const output = lastStatus?.usageOutput ?? 0;
379
+ const total = input + output;
380
+ queuedAppend({
381
+ id: `cost-${Date.now()}`,
382
+ kind: 'text',
383
+ text: `Cost: $${cost.toFixed(4)} · ${input} in / ${output} out · ${total} total · model ${lastStatus?.model ?? model}`,
384
+ role: 'assistant',
385
+ });
386
+ return;
387
+ }
388
+ case 'thinking': {
389
+ queuedAppend({ id: `think-${Date.now()}`, kind: 'text', text: 'Thinking: collapsed (use --show-thinking to expand)', role: 'assistant' });
187
390
  return;
188
391
  }
189
392
  case 'status': {
190
393
  if (lastStatus) {
191
- appG.__klyroAppAppend?.({
394
+ queuedAppend({
192
395
  id: `stat-${Date.now()}`,
193
396
  kind: 'text',
194
397
  text: JSON.stringify(lastStatus, null, 2),
195
398
  role: 'assistant',
196
399
  });
197
400
  }
401
+ else {
402
+ queuedAppend({ id: `stat2-${Date.now()}`, kind: 'text', text: `model: ${model} provider: ${effectiveProvider} cwd: ${cwd}`, role: 'assistant' });
403
+ }
198
404
  return;
199
405
  }
200
406
  case 'diff': {
201
407
  const r = await registry.execute('git_diff', {}, { cwd, env: process.env, nonInteractive: true });
202
408
  if (!r.ok) {
203
- appG.__klyroAppAppend?.({
409
+ queuedAppend({
204
410
  id: `diff-err-${Date.now()}`,
205
411
  kind: 'error',
206
412
  message: `git_diff failed: ${r.error.message ?? r.error.code}`,
@@ -209,7 +415,7 @@ export async function startRepl(opts = {}) {
209
415
  }
210
416
  const out = r.value;
211
417
  const hunks = parseUnifiedDiff(out.diff);
212
- appG.__klyroAppAppend?.({
418
+ queuedAppend({
213
419
  id: `diff-${Date.now()}`,
214
420
  kind: 'diff',
215
421
  hunks,
@@ -218,16 +424,27 @@ export async function startRepl(opts = {}) {
218
424
  return;
219
425
  }
220
426
  case 'compact':
221
- case 'model':
222
- appG.__klyroAppAppend?.({
427
+ queuedAppend({
223
428
  id: `stub-${Date.now()}`,
224
429
  kind: 'text',
225
- text: `/${cmd.kind} is a stub in this build. (${cmd.kind === 'model' ? `requested: ${cmd.model}` : 'persistence integration pending'})`,
430
+ text: `/compact is a stub in this build. (persistence integration pending)`,
226
431
  role: 'assistant',
227
432
  });
228
433
  return;
434
+ case 'model': {
435
+ const next = cmd.model?.trim();
436
+ if (!next) {
437
+ queuedAppend({ id: `mdl-${Date.now()}`, kind: 'text', text: `current model: ${model}`, role: 'assistant' });
438
+ }
439
+ else {
440
+ queuedStatus({ model: next });
441
+ queuedAppend({ id: `mdl2-${Date.now()}`, kind: 'text', text: `model switched to ${next} (takes effect on next prompt)`, role: 'assistant' });
442
+ model = next;
443
+ }
444
+ return;
445
+ }
229
446
  case 'unknown':
230
- appG.__klyroAppAppend?.({
447
+ queuedAppend({
231
448
  id: `unk-${Date.now()}`,
232
449
  kind: 'error',
233
450
  message: `unknown command: ${cmd.raw} (try /help)`,
@@ -235,7 +452,18 @@ export async function startRepl(opts = {}) {
235
452
  return;
236
453
  }
237
454
  }
455
+ // Keep process alive until user quits; resolve on unmount or SIGINT.
456
+ // ac.aborted indicates SIGINT; return 130 (128+SIGINT) like shells do.
238
457
  return new Promise((resolve) => {
239
- process.on('exit', () => resolve(0));
458
+ const onExit = () => {
459
+ if (sigintHandler)
460
+ process.removeListener('SIGINT', sigintHandler);
461
+ resolve(ac.signal.aborted ? 130 : 0);
462
+ };
463
+ if (!app) {
464
+ resolve(1);
465
+ return;
466
+ }
467
+ app.waitUntilExit().then(onExit, onExit);
240
468
  });
241
469
  }
package/dist/cli/run.d.ts CHANGED
@@ -54,6 +54,15 @@ export interface RunCliOptions {
54
54
  * turn; if absent, `opts.task` is appended.
55
55
  */
56
56
  resumePath?: string;
57
+ /** Level 8 — verification */
58
+ verify?: boolean;
59
+ verifyCommand?: string;
60
+ maxRepairAttempts?: number;
61
+ verifyTimeoutMs?: number;
62
+ /** Level 9 — persistence */
63
+ persist?: boolean;
64
+ sessionId?: string;
65
+ sessionsDir?: string;
57
66
  }
58
67
  export declare function runOnce(opts: RunCliOptions): Promise<number>;
59
68
  /** Wrap a system-prompt fn to inject Level-6 context (project map etc.). */
package/dist/cli/run.js CHANGED
@@ -15,7 +15,7 @@ import { builtinRegistry } from '../tools/registry.js';
15
15
  import { builtinRules, DEFAULT_POLICY_CONFIG, PolicyEngine } from '../policy/engine.js';
16
16
  import { DenyAllApprovalPrompt } from '../policy/approval.js';
17
17
  import { buildLevel6Context } from '../context/level6.js';
18
- ;
18
+ import { resolveSessionId } from '../persistence/session.js';
19
19
  import * as fs from 'node:fs';
20
20
  function readEnv(name, fallback) {
21
21
  const v = process.env[name];
@@ -54,6 +54,48 @@ export async function runOnce(opts) {
54
54
  const registry = builtinRegistry();
55
55
  const policy = new PolicyEngine(builtinRules(), DEFAULT_POLICY_CONFIG);
56
56
  const systemPrompt = await makeRunSystemPrompt(opts.cwd, opts.systemPrompt ?? defaultRunSystemPrompt);
57
+ // Level 9 — session setup (create or resume)
58
+ const persistEnabled = opts.persist !== false;
59
+ let store;
60
+ let sessionId;
61
+ let initialTranscript;
62
+ if (persistEnabled) {
63
+ const { SessionStore } = await import('../persistence/store.js');
64
+ const { getDefaultSessionStore } = await import('../persistence/session.js');
65
+ store = opts.sessionsDir ? new SessionStore(opts.sessionsDir) : getDefaultSessionStore();
66
+ if (opts.resumePath) {
67
+ initialTranscript = loadTranscript(opts.resumePath);
68
+ }
69
+ else if (opts.sessionId) {
70
+ const full = await resolveSessionId(store, opts.sessionId);
71
+ if (!full) {
72
+ stderr.write(`klyro: session not found: ${opts.sessionId}\n`);
73
+ return 2;
74
+ }
75
+ sessionId = full;
76
+ const msgs = await store.loadMessages(sessionId);
77
+ // Convert StoredMessage to Message
78
+ initialTranscript = msgs.map((m) => ({ role: m.role, content: m.content }));
79
+ const rec = await store.get(sessionId);
80
+ if (rec) {
81
+ stderr.write(`klyro: resuming session ${sessionId.slice(0, 8)} — task: "${rec.task}"\n`);
82
+ }
83
+ }
84
+ else if (opts.resumePath) {
85
+ // already handled
86
+ }
87
+ else {
88
+ // Create new session
89
+ const rec = await store.create({ cwd: opts.cwd, task: opts.task, config: { model: opts.model, maxSteps: opts.maxSteps ?? 30 } });
90
+ sessionId = rec.id;
91
+ if (output !== 'silent' && output !== 'json') {
92
+ stderr.write(`klyro: session ${sessionId.slice(0, 8)} created\n`);
93
+ }
94
+ }
95
+ }
96
+ else if (opts.resumePath) {
97
+ initialTranscript = loadTranscript(opts.resumePath);
98
+ }
57
99
  const ac = new AbortController();
58
100
  if (opts.abortOnSigint !== false) {
59
101
  process.on('SIGINT', () => {
@@ -61,6 +103,14 @@ export async function runOnce(opts) {
61
103
  ac.abort();
62
104
  });
63
105
  }
106
+ const verifyOpts = opts.verify === false
107
+ ? { enabled: false }
108
+ : {
109
+ enabled: true,
110
+ command: opts.verifyCommand,
111
+ maxRepairAttempts: opts.maxRepairAttempts ?? 3,
112
+ timeoutMs: opts.verifyTimeoutMs,
113
+ };
64
114
  const result = await run({
65
115
  task: opts.task,
66
116
  cwd: opts.cwd,
@@ -70,7 +120,9 @@ export async function runOnce(opts) {
70
120
  temperature: opts.temperature,
71
121
  signal: ac.signal,
72
122
  nonInteractive: true,
73
- initialTranscript: opts.resumePath ? loadTranscript(opts.resumePath) : undefined,
123
+ initialTranscript,
124
+ verify: verifyOpts,
125
+ persist: store && sessionId ? { store, sessionId } : undefined,
74
126
  onEvent: (ev) => {
75
127
  if (output === 'json') {
76
128
  stdout.write(JSON.stringify(ev) + '\n');
@@ -98,8 +150,44 @@ export async function runOnce(opts) {
98
150
  else if (ev.kind === 'step_start') {
99
151
  stderr.write(`\n[step ${ev.step}]\n`);
100
152
  }
153
+ else if (ev.kind === 'verification_started') {
154
+ stderr.write(`\n[verify] running \`${ev.command}\`\n`);
155
+ }
156
+ else if (ev.kind === 'verification_succeeded') {
157
+ stderr.write(`[verify] passed\n`);
158
+ }
159
+ else if (ev.kind === 'verification_failed') {
160
+ stderr.write(`[verify] failed — ${ev.reason.slice(0, 200)}\n`);
161
+ }
162
+ else if (ev.kind === 'repair_started') {
163
+ stderr.write(`[repair] attempt ${ev.attempt}/${ev.maxAttempts}\n`);
164
+ }
165
+ else if (ev.kind === 'checkpoint_saved') {
166
+ if (output === 'human')
167
+ stderr.write(`[session ${ev.sessionId.slice(0, 8)} checkpoint]\n`);
168
+ }
101
169
  },
102
170
  }, { adapter, registry, policy, approval: new DenyAllApprovalPrompt(), systemPrompt });
171
+ if (store && sessionId) {
172
+ // Finalize session status
173
+ const statusMap = {
174
+ complete: 'complete',
175
+ max_steps: 'max_steps',
176
+ aborted: 'aborted',
177
+ no_final: 'aborted',
178
+ verify_failed: 'verify_failed',
179
+ };
180
+ try {
181
+ await store.setStatus(sessionId, statusMap[result.status] ?? 'complete', result.finalText);
182
+ }
183
+ catch { /* ignore */ }
184
+ if (output !== 'json' && output !== 'silent') {
185
+ stderr.write(`klyro: session ${sessionId.slice(0, 8)} ${result.status}`);
186
+ if (result.verification)
187
+ stderr.write(` verify: ${result.verification.ok ? 'ok' : 'failed'} (${result.verification.attempts} attempts)`);
188
+ stderr.write('\n');
189
+ }
190
+ }
103
191
  if (output !== 'json')
104
192
  stdout.write('\n');
105
193
  if (result.status === 'max_steps') {
@@ -117,6 +205,13 @@ export async function runOnce(opts) {
117
205
  stderr.write('klyro: provider error — no final answer\n');
118
206
  return 4;
119
207
  }
208
+ if (result.status === 'verify_failed') {
209
+ if (output === 'json')
210
+ stdout.write(JSON.stringify({ kind: 'final', status: 'verify_failed', failureType: result.verification?.failureType }) + '\n');
211
+ else
212
+ stderr.write(`klyro: verification failed after ${result.verification?.attempts ?? 3} repairs — see output above\n`);
213
+ return 5;
214
+ }
120
215
  if (output === 'json')
121
216
  stdout.write(JSON.stringify({ kind: 'final', status: 'ok', text: result.finalText }) + '\n');
122
217
  return 0;
@@ -31,6 +31,16 @@ export type SlashCommand = {
31
31
  kind: 'quit';
32
32
  } | {
33
33
  kind: 'help';
34
+ } | {
35
+ kind: 'config';
36
+ } | {
37
+ kind: 'doctor';
38
+ } | {
39
+ kind: 'version';
40
+ } | {
41
+ kind: 'cost';
42
+ } | {
43
+ kind: 'thinking';
34
44
  } | {
35
45
  kind: 'prompt';
36
46
  text: string;