klyro 1.0.9 → 1.0.11

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.
package/READ.md CHANGED
@@ -27,8 +27,8 @@
27
27
  | Test | `vitest 4.1` `fileParallelism:false` `10s timeout` | `node` env, deterministic mocks |
28
28
  | Build | `tsc` (not `tsup`) | `tsc --noEmit` `typecheck`, `tsc` `build` |
29
29
  | Providers | Native `fetch` (Node 20) | No SDK lock-in, 3 adapters |
30
- | Workspace | `pnpm-workspace.yaml` `packages/*` | `shared` `KlyroError` |
31
- | CI | `.github/workflows/ci.yml` `ubuntu/macos/windows × 20/22` | `pnpm install` `typecheck` `test` `build` `pack` |
30
+ | Workspace | `packages/shared` (private, legacy, unused) | Canonical npm + `package-lock.json`; `pnpm-workspace.yaml` ignored by npm |
31
+ | CI | `.github/workflows/ci.yml` `ubuntu/macos/windows × 20/22` | `npm ci` `typecheck` `test` `build` gating smoke + `release-check` + `pack` |
32
32
 
33
33
  **No Docker, no MCP, no browser in MVP** — deferred to post-1.0.
34
34
 
@@ -163,8 +163,7 @@ Eval: FileFixture {dir, task.md, check.sh, meta.json} loadFileFixture() src/eval
163
163
 
164
164
  ```
165
165
  klyro/
166
- ├── package.json # klyro 0.1.15, bin klyro/ky, files [dist], commander/ink/zod
167
- ├── pnpm-workspace.yaml # packages/*
166
+ ├── package.json # klyro 0.1.15, bin klyro/ky, files [dist], commander/ink/zod (canonical npm + package-lock.json; legacy pnpm workspace removed)
168
167
  ├── tsconfig.json # ES2022, NodeNext, strict, noUncheckedIndexedAccess
169
168
  ├── vitest.config.ts # include src/**/*.test, fileParallelism:false
170
169
  ├── .github/workflows/ci.yml # ubuntu/macos/windows × 20/22 → typecheck/test/build/eval smoke
@@ -287,6 +287,12 @@ export async function run(opts, deps) {
287
287
  // 5.2 — stuck detection state
288
288
  const callHistory = [];
289
289
  const fileEditCounts = new Map();
290
+ // Exactly-once: a provider transport retry (or a resume replay) must never
291
+ // re-execute a tool call whose side effect already completed. Completed
292
+ // call ids map to their recorded observations; a repeated id re-commits
293
+ // the cached observation without touching tools, hooks, or the audit log
294
+ // a second time.
295
+ const completedToolCalls = new Map();
290
296
  let stuckTriggers = 0;
291
297
  let stuckAbort = false;
292
298
  // Steerable stop: a stop hook's `{"continue":true}` verdict carries one
@@ -1081,7 +1087,7 @@ export async function run(opts, deps) {
1081
1087
  };
1082
1088
  // Commit phase: fold one execution result into the transcript, in original
1083
1089
  // call order. The only writer — call sequentially, never concurrently.
1084
- const commitResult = async (call, obs, latencyMs, hookContext = []) => {
1090
+ const commitResult = async (call, obs, latencyMs, hookContext = [], replay = false) => {
1085
1091
  const output = obs.ok ? redactOutput(obs.value) : redactOutput({ error: obs.error });
1086
1092
  const toolMsg = {
1087
1093
  role: 'tool',
@@ -1089,6 +1095,14 @@ export async function run(opts, deps) {
1089
1095
  };
1090
1096
  transcript.push(toolMsg);
1091
1097
  await checkpoint(toolMsg, { toolCallId: call.id, toolName: call.name, input: call.input, output, isError: !obs.ok });
1098
+ if (replay) {
1099
+ // Replay of an already-completed id (transport retry / resume):
1100
+ // transcript continuity only. No telemetry, audit, snapshots, stuck
1101
+ // accounting, or hooks — the side effect happened exactly once.
1102
+ emitKlyro({ type: 'tool.result', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', callId: call.id, name: call.name, output, isError: !obs.ok, latencyMs });
1103
+ emit?.({ kind: 'tool_result', id: call.id, name: call.name, output, isError: !obs.ok, latencyMs });
1104
+ return;
1105
+ }
1092
1106
  // Hook-injected context rides as its own user message right after the
1093
1107
  // tool result (uniform across output shapes — no result surgery).
1094
1108
  if (hookContext.length > 0) {
@@ -1177,9 +1191,18 @@ export async function run(opts, deps) {
1177
1191
  }
1178
1192
  }
1179
1193
  }
1194
+ // Exactly-once record: a later turn repeating this id (transport retry
1195
+ // replay or resume) re-commits the cached observation instead of
1196
+ // re-executing.
1197
+ completedToolCalls.set(call.id, { obs, latencyMs });
1180
1198
  };
1181
1199
  // Sequential path: gate → execute → commit per call, in order.
1182
1200
  const runOne = async (call) => {
1201
+ const cached = completedToolCalls.get(call.id);
1202
+ if (cached) {
1203
+ await commitResult(call, cached.obs, cached.latencyMs, [], true);
1204
+ return;
1205
+ }
1183
1206
  if (!(await gateCall(call)))
1184
1207
  return;
1185
1208
  const { obs, latencyMs, hookContext } = await execTool(call);
@@ -1194,6 +1217,12 @@ export async function run(opts, deps) {
1194
1217
  const approved = [];
1195
1218
  for (const call of finalizedCalls) {
1196
1219
  toolCallCount++;
1220
+ // Exactly-once: replay cached observation without gate/hooks/exec.
1221
+ const cached = completedToolCalls.get(call.id);
1222
+ if (cached) {
1223
+ await commitResult(call, cached.obs, cached.latencyMs, [], true);
1224
+ continue;
1225
+ }
1197
1226
  if (await gateCall(call))
1198
1227
  approved.push(call);
1199
1228
  if (opts.signal?.aborted)
package/dist/chat.d.ts CHANGED
@@ -1,4 +1,12 @@
1
1
  /**
2
+ * COMPATIBILITY-ONLY legacy one-shot chat. Prefer `klyro run` / `klyro tui`.
3
+ *
4
+ * Security contract (shared with all current paths — do NOT diverge):
5
+ * base-URL validation via assertSafeBaseURL, error-body caps via
6
+ * MAX_ERROR_BODY_BYTES, and credential handling owned by providers.ts.
7
+ * Covered by src/providers/contract.test.ts; any change here must update
8
+ * the contract tests first.
9
+ *
2
10
  * One-shot chat. POSTs to an OpenAI-compatible /v1/chat/completions endpoint
3
11
  * and streams the response to stdout.
4
12
  *
package/dist/chat.js CHANGED
@@ -1,4 +1,12 @@
1
1
  /**
2
+ * COMPATIBILITY-ONLY legacy one-shot chat. Prefer `klyro run` / `klyro tui`.
3
+ *
4
+ * Security contract (shared with all current paths — do NOT diverge):
5
+ * base-URL validation via assertSafeBaseURL, error-body caps via
6
+ * MAX_ERROR_BODY_BYTES, and credential handling owned by providers.ts.
7
+ * Covered by src/providers/contract.test.ts; any change here must update
8
+ * the contract tests first.
9
+ *
2
10
  * One-shot chat. POSTs to an OpenAI-compatible /v1/chat/completions endpoint
3
11
  * and streams the response to stdout.
4
12
  *
@@ -86,12 +86,21 @@ export async function snapshot(cwd, files) {
86
86
  missing.push(f);
87
87
  }
88
88
  }
89
- // Save meta (fsync before the checkpoint is visible — mirrors the
90
- // SessionStore.writeIndex atomic pattern).
89
+ // Save meta (tmp + fsync + rename before the checkpoint is visible —
90
+ // mirrors the SessionStore.writeIndex atomic pattern so a crash never
91
+ // leaves a truncated .meta.json that undo() then trusts).
91
92
  const metaPath = path.join(dest, '.meta.json');
92
- await fs.writeFile(metaPath, JSON.stringify({ id, files: kept, missing, ts: Date.now() }, null, 2));
93
- lockDown(metaPath, 0o600);
94
- await fsyncFile(metaPath);
93
+ const metaTmp = `${metaPath}.tmp-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
94
+ await fs.writeFile(metaTmp, JSON.stringify({ id, files: kept, missing, ts: Date.now() }, null, 2));
95
+ lockDown(metaTmp, 0o600);
96
+ await fsyncFile(metaTmp);
97
+ try {
98
+ await fs.rename(metaTmp, metaPath);
99
+ }
100
+ catch {
101
+ await fs.unlink(metaTmp).catch(() => undefined);
102
+ throw new Error(`Failed to write checkpoint meta ${id}`);
103
+ }
95
104
  // Best-effort last.diff for the repair guard (guardRepair reads it).
96
105
  try {
97
106
  const { spawn } = await import('node:child_process');
@@ -130,11 +139,21 @@ export async function snapshot(cwd, files) {
130
139
  });
131
140
  });
132
141
  if (diffText) {
133
- await fs.writeFile(path.join(dir, 'last.diff'), diffText, 'utf-8');
142
+ const atomicWrite = async (p, data) => {
143
+ const tmp = `${p}.tmp-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
144
+ await fs.writeFile(tmp, data, 'utf-8');
145
+ try {
146
+ await fs.rename(tmp, p);
147
+ }
148
+ catch {
149
+ await fs.unlink(tmp).catch(() => undefined);
150
+ }
151
+ };
152
+ await atomicWrite(path.join(dir, 'last.diff'), diffText);
134
153
  // Per-checkpoint diff file (best-effort); the repair guard keeps
135
154
  // reading last.diff, so its behavior is unchanged.
136
155
  try {
137
- await fs.writeFile(path.join(dir, `${id}.diff`), diffText, 'utf-8');
156
+ await atomicWrite(path.join(dir, `${id}.diff`), diffText);
138
157
  }
139
158
  catch { /* best-effort only */ }
140
159
  }
@@ -0,0 +1 @@
1
+ export declare function parsePositiveInt(name: string, v: string): number;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Shared CLI argument coercions (extracted from src/index.ts).
3
+ *
4
+ * Single source of truth for Commander option parsing so command modules
5
+ * registered from src/cli/* behave identically to the entrypoint.
6
+ */
7
+ import { InvalidArgumentError } from 'commander';
8
+ export function parsePositiveInt(name, v) {
9
+ const n = Number(v);
10
+ if (!Number.isFinite(n) || n <= 0) {
11
+ throw new InvalidArgumentError(`invalid ${name}: ${v}`);
12
+ }
13
+ return n;
14
+ }
@@ -79,6 +79,8 @@ export interface EvalResult {
79
79
  notes: string;
80
80
  skipped: boolean;
81
81
  };
82
+ /** Isolated workdir the scenario ran in (tmp unless --cwd). Debugging aid. */
83
+ workDir?: string;
82
84
  }
83
85
  export interface RunEvalOptions {
84
86
  inputPath: string;
@@ -90,10 +92,16 @@ export interface RunEvalOptions {
90
92
  model?: string;
91
93
  /** Live model id for grading `judge.rubric` (env endpoint + key required). */
92
94
  judgeModel?: string;
95
+ /**
96
+ * Shared workdir for JSONL scenarios. When omitted each scenario runs in
97
+ * a fresh tmp dir (deleted afterwards) so scripted tool calls can never
98
+ * touch the caller's directory. Pass explicitly to inspect artifacts.
99
+ */
100
+ cwd?: string;
93
101
  }
94
102
  export declare function runEval(opts: RunEvalOptions): Promise<number>;
95
103
  export declare function scriptedAdapterFromSpec(spec: Array<Array<unknown[]>> | undefined): ProviderAdapter;
96
104
  export declare function runScenario(sc: EvalScenario, judgeOpts?: {
97
105
  adapter: ProviderAdapter;
98
106
  model: string;
99
- }): Promise<EvalResult>;
107
+ }, workDir?: string): Promise<EvalResult>;
package/dist/cli/eval.js CHANGED
@@ -40,6 +40,9 @@
40
40
  * otherwise.
41
41
  */
42
42
  import * as fs from 'node:fs';
43
+ import * as fsp from 'node:fs/promises';
44
+ import * as os from 'node:os';
45
+ import * as path from 'node:path';
43
46
  import * as readline from 'node:readline/promises';
44
47
  import { stdin as input, stdout, stderr } from 'node:process';
45
48
  import { run } from '../agent/runtime.js';
@@ -153,7 +156,7 @@ export async function runEval(opts) {
153
156
  const results = [];
154
157
  for (const sc of scenarios) {
155
158
  const start = Date.now();
156
- const r = await runScenario(sc, judgeAdapter && opts.judgeModel ? { adapter: judgeAdapter, model: opts.judgeModel } : undefined);
159
+ const r = await runScenario(sc, judgeAdapter && opts.judgeModel ? { adapter: judgeAdapter, model: opts.judgeModel } : undefined, opts.cwd);
157
160
  r.durationMs = Date.now() - start;
158
161
  results.push(r);
159
162
  if (opts.output === 'json') {
@@ -242,35 +245,53 @@ function tupleToEvent(tuple) {
242
245
  throw new Error(`scriptedAdapterFromSpec: unknown event kind: ${kind}`);
243
246
  }
244
247
  }
245
- export async function runScenario(sc, judgeOpts) {
248
+ export async function runScenario(sc, judgeOpts, workDir) {
246
249
  const failures = [];
247
250
  const model = sc.model ?? 'mock';
248
251
  const adapter = scriptedAdapterFromSpec(sc.scripted_events);
249
252
  const registry = builtinRegistry();
250
253
  const policy = new PolicyEngine(builtinRules(), DEFAULT_POLICY_CONFIG);
251
- const result = await run({
252
- task: sc.task,
253
- cwd: process.cwd(),
254
- model,
255
- maxSteps: sc.maxSteps,
256
- maxTokens: sc.maxTokens,
257
- nonInteractive: true,
258
- ...(sc.verify
259
- ? {
260
- verify: {
261
- enabled: true,
262
- ...(sc.verify.command !== undefined ? { command: sc.verify.command } : {}),
263
- ...(sc.verify.mode !== undefined ? { mode: sc.verify.mode } : {}),
264
- },
254
+ // Isolation (fix: scripted tool calls must never run in the caller's
255
+ // directory — a JSONL scenario writing a.txt/b.txt used to pollute it).
256
+ // Explicit workDir is shared as-is (inspect artifacts); otherwise each
257
+ // scenario gets a fresh tmp dir that is removed afterwards.
258
+ const owned = !workDir;
259
+ const cwd = workDir ?? await fsp.mkdtemp(path.join(os.tmpdir(), 'klyro-eval-jsonl-'));
260
+ await fsp.mkdir(cwd, { recursive: true });
261
+ let result;
262
+ try {
263
+ result = await run({
264
+ task: sc.task,
265
+ cwd,
266
+ model,
267
+ maxSteps: sc.maxSteps,
268
+ maxTokens: sc.maxTokens,
269
+ nonInteractive: true,
270
+ ...(sc.verify
271
+ ? {
272
+ verify: {
273
+ enabled: true,
274
+ ...(sc.verify.command !== undefined ? { command: sc.verify.command } : {}),
275
+ ...(sc.verify.mode !== undefined ? { mode: sc.verify.mode } : {}),
276
+ },
277
+ }
278
+ : {}),
279
+ }, {
280
+ adapter,
281
+ registry,
282
+ policy,
283
+ approval: new DenyAllApprovalPrompt(),
284
+ systemPrompt: ({ cwd }) => `You are Klyro. cwd=${cwd}.`,
285
+ });
286
+ }
287
+ finally {
288
+ if (owned) {
289
+ try {
290
+ await fsp.rm(cwd, { recursive: true, force: true });
265
291
  }
266
- : {}),
267
- }, {
268
- adapter,
269
- registry,
270
- policy,
271
- approval: new DenyAllApprovalPrompt(),
272
- systemPrompt: ({ cwd }) => `You are Klyro. cwd=${cwd}.`,
273
- });
292
+ catch { /* ignore */ }
293
+ }
294
+ }
274
295
  const exp = sc.expect ?? {};
275
296
  if (exp.status !== undefined && result.status !== exp.status) {
276
297
  failures.push(`status: expected ${exp.status}, got ${result.status}`);
@@ -311,6 +332,7 @@ export async function runScenario(sc, judgeOpts) {
311
332
  toolCalls: result.toolCalls,
312
333
  text: result.finalText,
313
334
  durationMs: 0,
335
+ workDir: cwd,
314
336
  ...(judge ? { judge } : {}),
315
337
  };
316
338
  }
@@ -0,0 +1,9 @@
1
+ import type { Command } from 'commander';
2
+ /**
3
+ * Session command namespace (extracted from src/index.ts entrypoint).
4
+ *
5
+ * `session` and `sessions` accept the SAME subcommands
6
+ * (list/show/resume/export/import/fork/delete); `resume` is an alias for
7
+ * `session resume`. Handlers live here once and all groups delegate.
8
+ */
9
+ export declare function registerSessionCommands(program: Command): void;
@@ -0,0 +1,262 @@
1
+ import { runOnce } from './run.js';
2
+ import { parsePositiveInt } from './args.js';
3
+ /**
4
+ * Session command namespace (extracted from src/index.ts entrypoint).
5
+ *
6
+ * `session` and `sessions` accept the SAME subcommands
7
+ * (list/show/resume/export/import/fork/delete); `resume` is an alias for
8
+ * `session resume`. Handlers live here once and all groups delegate.
9
+ */
10
+ export function registerSessionCommands(program) {
11
+ // Level 9 — Session management.
12
+ // One namespace: `session` and `sessions` accept the SAME subcommands
13
+ // (list/show/resume/export/import/fork/delete). Handlers live here once
14
+ // and both command groups delegate to them.
15
+ async function sessionList(opts) {
16
+ const { getDefaultSessionStore, formatSession } = await import('../persistence/session.js');
17
+ const store = getDefaultSessionStore();
18
+ const all = await store.list(opts.status ? { status: opts.status } : undefined);
19
+ if (opts.json) {
20
+ process.stdout.write(JSON.stringify(all, null, 2) + '\n');
21
+ }
22
+ else {
23
+ if (all.length === 0) {
24
+ process.stdout.write('No sessions\n');
25
+ }
26
+ else {
27
+ for (const r of all.sort((a, b) => b.updatedAt - a.updatedAt)) {
28
+ process.stdout.write(formatSession(r) + '\n');
29
+ }
30
+ }
31
+ }
32
+ }
33
+ async function resolveOrExit(id) {
34
+ const { getDefaultSessionStore, resolveSessionId, matchSessionIds } = await import('../persistence/session.js');
35
+ const store = getDefaultSessionStore();
36
+ const full = await resolveSessionId(store, id);
37
+ if (full)
38
+ return full;
39
+ const matches = await matchSessionIds(store, id);
40
+ if (matches.length > 1) {
41
+ process.stderr.write(`ambiguous id "${id}" matches:\n${matches.map((r) => ` ${r.id.slice(0, 8)} ${r.task.slice(0, 50)}`).join('\n')}\n`);
42
+ }
43
+ else {
44
+ process.stderr.write(`session not found: ${id}\n`);
45
+ }
46
+ process.exit(2);
47
+ }
48
+ async function sessionShow(id, opts) {
49
+ const { getDefaultSessionStore } = await import('../persistence/session.js');
50
+ const store = getDefaultSessionStore();
51
+ const full = await resolveOrExit(id);
52
+ const rec = await store.get(full);
53
+ const msgs = await store.loadMessages(full);
54
+ const obs = await store.loadObservations(full);
55
+ if (opts.json) {
56
+ process.stdout.write(JSON.stringify({ record: rec, messages: msgs, observations: obs }, null, 2) + '\n');
57
+ }
58
+ else {
59
+ process.stdout.write(`Session ${rec?.id}\n task: ${rec?.task}\n status: ${rec?.status}\n cwd: ${rec?.cwd}\n created: ${new Date(rec?.createdAt ?? 0).toISOString()}\n`);
60
+ process.stdout.write(`\nMessages (${msgs.length}):\n`);
61
+ for (const m of msgs)
62
+ process.stdout.write(` [${m.role}] ${JSON.stringify(m.content).slice(0, 200)}\n`);
63
+ process.stdout.write(`\nObservations (${obs.length}):\n`);
64
+ for (const o of obs)
65
+ process.stdout.write(` ${o.toolName} -> ${o.isError ? 'ERR' : 'ok'} ${JSON.stringify(o.output).slice(0, 120)}\n`);
66
+ }
67
+ }
68
+ async function sessionResume(id, opts) {
69
+ const { getDefaultSessionStore } = await import('../persistence/session.js');
70
+ const store = getDefaultSessionStore();
71
+ const full = await resolveOrExit(id);
72
+ const rec = await store.get(full);
73
+ if (!rec) {
74
+ process.stderr.write(`session not found: ${id}\n`);
75
+ process.exit(2);
76
+ }
77
+ // Resume precondition (review §9): the session is bound to the cwd /
78
+ // worktree it was created in. Resuming from a different directory
79
+ // continues in the ORIGINAL cwd (authoritative) but warns loudly so a
80
+ // moved checkout or wrong terminal cannot silently continue elsewhere.
81
+ if (rec.cwd !== process.cwd()) {
82
+ process.stderr.write(`klyro: warning: session created in ${rec.cwd}, resuming there (current dir is ${process.cwd()})\n`);
83
+ }
84
+ const model = opts.model ?? rec.config.model ?? process.env.KLYRO_MODEL;
85
+ if (!model) {
86
+ process.stderr.write('klyro: KLYRO_MODEL is not set (or pass --model)\n');
87
+ process.exit(2);
88
+ }
89
+ const code = await runOnce({
90
+ task: rec.task,
91
+ cwd: rec.cwd,
92
+ model,
93
+ maxSteps: opts.maxSteps ?? rec.config.maxSteps,
94
+ sessionId: full,
95
+ verify: opts.verify,
96
+ verifyCommand: opts.verifyCommand,
97
+ });
98
+ process.exit(code);
99
+ }
100
+ async function sessionExport(id, file) {
101
+ const { getDefaultSessionStore } = await import('../persistence/session.js');
102
+ const store = getDefaultSessionStore();
103
+ const full = await resolveOrExit(id);
104
+ const rec = await store.get(full);
105
+ const msgs = await store.loadMessages(full);
106
+ const obs = await store.loadObservations(full);
107
+ const out = file ?? `${full}.export.json`;
108
+ await (await import('node:fs/promises')).writeFile(out, JSON.stringify({ record: rec, messages: msgs, observations: obs }, null, 2));
109
+ process.stdout.write(`exported ${full} → ${out}\n`);
110
+ }
111
+ async function sessionImport(file) {
112
+ let data;
113
+ try {
114
+ data = JSON.parse(await (await import('node:fs/promises')).readFile(file, 'utf-8'));
115
+ }
116
+ catch (err) {
117
+ process.stderr.write(`klyro: cannot import ${file}: ${err instanceof Error ? err.message : String(err)}\n`);
118
+ process.exit(2);
119
+ }
120
+ const rec = data.record ?? {};
121
+ const { getDefaultSessionStore } = await import('../persistence/session.js');
122
+ const store = getDefaultSessionStore();
123
+ // Validate config shape (model string, bounded maxSteps) — an imported
124
+ // file is untrusted input and must not inject arbitrary session config.
125
+ const rawCfg = (rec.config && typeof rec.config === 'object' ? rec.config : { model: 'imported', maxSteps: 30 });
126
+ const cfg = {
127
+ model: typeof rawCfg.model === 'string' && rawCfg.model.length > 0 && rawCfg.model.length <= 200 ? rawCfg.model : 'imported',
128
+ maxSteps: typeof rawCfg.maxSteps === 'number' && Number.isFinite(rawCfg.maxSteps) && rawCfg.maxSteps > 0 && rawCfg.maxSteps <= 500 ? Math.floor(rawCfg.maxSteps) : 30,
129
+ };
130
+ const taskStr = typeof rec.task === 'string' ? rec.task.slice(0, 20_000) : 'imported';
131
+ const created = await store.create({ cwd: typeof rec.cwd === 'string' ? rec.cwd : process.cwd(), task: taskStr, config: cfg });
132
+ // Restore the transcript — previously this was silently dropped (lossy
133
+ // import). Messages/observations go through append* so at-rest redaction
134
+ // still applies. Malformed entries fail loudly instead of half-importing.
135
+ // Caps: at most 5000 messages / 2000 observations — an import file is
136
+ // untrusted and must not exhaust memory or disk.
137
+ const d = data;
138
+ let restored = 0;
139
+ if (d.messages !== undefined) {
140
+ if (!Array.isArray(d.messages)) {
141
+ process.stderr.write(`klyro: import failed: "messages" is not an array in ${file}\n`);
142
+ process.exit(2);
143
+ }
144
+ if (d.messages.length > 5000) {
145
+ process.stderr.write(`klyro: import failed: too many messages (${d.messages.length} > 5000) in ${file}\n`);
146
+ process.exit(2);
147
+ }
148
+ for (const m of d.messages) {
149
+ const role = m?.role;
150
+ if (!m || typeof m !== 'object' || (role !== 'user' && role !== 'assistant' && role !== 'tool' && role !== 'system') || !('content' in m)) {
151
+ process.stderr.write(`klyro: import failed: malformed message entry in ${file}\n`);
152
+ process.exit(2);
153
+ }
154
+ await store.appendMessage(created.id, m);
155
+ restored++;
156
+ }
157
+ }
158
+ if (d.observations !== undefined) {
159
+ if (!Array.isArray(d.observations)) {
160
+ process.stderr.write(`klyro: import failed: "observations" is not an array in ${file}\n`);
161
+ process.exit(2);
162
+ }
163
+ if (d.observations.length > 2000) {
164
+ process.stderr.write(`klyro: import failed: too many observations (${d.observations.length} > 2000) in ${file}\n`);
165
+ process.exit(2);
166
+ }
167
+ for (const o of d.observations) {
168
+ if (!o || typeof o !== 'object') {
169
+ process.stderr.write(`klyro: import failed: malformed observation entry in ${file}\n`);
170
+ process.exit(2);
171
+ }
172
+ await store.appendObservation(created.id, o);
173
+ }
174
+ }
175
+ process.stdout.write(`imported → ${created.id} (${restored} messages restored)\n`);
176
+ }
177
+ async function sessionFork(id) {
178
+ const { getDefaultSessionStore, matchSessionIds } = await import('../persistence/session.js');
179
+ const store = getDefaultSessionStore();
180
+ const matches = await matchSessionIds(store, id);
181
+ if (matches.length === 0) {
182
+ process.stderr.write(`session not found: ${id}\n`);
183
+ process.exit(2);
184
+ }
185
+ if (matches.length > 1) {
186
+ process.stderr.write(`ambiguous id "${id}" matches:\n${matches.map((r) => ` ${r.id.slice(0, 8)} ${r.task.slice(0, 50)}`).join('\n')}\n`);
187
+ process.exit(2);
188
+ }
189
+ const full = matches[0].id;
190
+ const forked = await store.fork(full);
191
+ const msgs = await store.loadMessages(forked.id);
192
+ process.stdout.write(`forked ${full.slice(0, 8)} → ${forked.id.slice(0, 8)} (${msgs.length} messages carried over)\n`);
193
+ }
194
+ async function sessionDelete(id) {
195
+ const { getDefaultSessionStore, matchSessionIds } = await import('../persistence/session.js');
196
+ const store = getDefaultSessionStore();
197
+ const matches = await matchSessionIds(store, id);
198
+ if (matches.length === 0) {
199
+ process.stderr.write(`session not found: ${id}\n`);
200
+ process.exit(2);
201
+ }
202
+ if (matches.length > 1) {
203
+ process.stderr.write(`ambiguous id "${id}" matches:\n${matches.map((r) => ` ${r.id.slice(0, 8)} ${r.task.slice(0, 50)}`).join('\n')}\n`);
204
+ process.exit(2);
205
+ }
206
+ const full = matches[0].id;
207
+ await store.delete(full);
208
+ process.stdout.write(`deleted ${full.slice(0, 8)}\n`);
209
+ }
210
+ const session = program.command('session').description('Session persistence (Level 9)');
211
+ session
212
+ .command('list')
213
+ .description('List persisted sessions')
214
+ .option('--status <s>', 'Filter by status: open|complete|verify_failed|aborted|max_steps')
215
+ .option('--json', 'Output JSON')
216
+ .action(async (opts) => { await sessionList(opts); });
217
+ session
218
+ .command('show <id>')
219
+ .description('Show session transcript and observations')
220
+ .option('--json', 'Output JSON')
221
+ .action(async (id, opts) => { await sessionShow(id, opts); });
222
+ session
223
+ .command('resume <id>')
224
+ .description('Resume a persisted session (requires KLYRO_MODEL etc.)')
225
+ .option('-m, --model <id>', 'Model (default: from session or env)')
226
+ .option('--max-steps <n>', 'Max steps (default 30)', (v) => parsePositiveInt('--max-steps', v))
227
+ .option('--verify-command <cmd>', 'Override verification command')
228
+ .option('--verify', 'Enable verification (default: enabled)')
229
+ .action(async (id, opts) => { await sessionResume(id, opts); });
230
+ session
231
+ .command('export <id> [file]')
232
+ .description('Export session to file (9.4)')
233
+ .action(async (id, file) => { await sessionExport(id, file); });
234
+ session
235
+ .command('import <file>')
236
+ .description('Import session from file (restores record + messages + observations)')
237
+ .action(async (file) => { await sessionImport(file); });
238
+ session
239
+ .command('fork <id>')
240
+ .description('Fork session with full context (9.4)')
241
+ .action(async (id) => { await sessionFork(id); });
242
+ session
243
+ .command('delete <id>')
244
+ .description('Delete a session and its artifacts')
245
+ .action(async (id) => { await sessionDelete(id); });
246
+ // Alias: klyro resume <id> → klyro session resume <id>
247
+ program
248
+ .command('resume <id>')
249
+ .description('Alias for `klyro session resume <id>`')
250
+ .option('-m, --model <id>', 'Model')
251
+ .option('--max-steps <n>', 'Max steps', (v) => parsePositiveInt('--max-steps', v))
252
+ .action(async (id, opts) => { await sessionResume(id, opts); });
253
+ // 9.4 — same namespace as `session`: every subcommand works under both.
254
+ const sessions = program.command('sessions').description('Alias for session (same subcommands)');
255
+ sessions.command('list').description('List persisted sessions').option('--status <s>', 'Filter by status').option('--json', 'Output JSON').action(async (opts) => { await sessionList(opts); });
256
+ sessions.command('show <id>').description('Show session transcript and observations').option('--json', 'Output JSON').action(async (id, opts) => { await sessionShow(id, opts); });
257
+ sessions.command('resume <id>').description('Resume a persisted session').option('-m, --model <id>', 'Model').option('--max-steps <n>', 'Max steps', (v) => parsePositiveInt('--max-steps', v)).option('--verify-command <cmd>', 'Override verification command').option('--verify', 'Enable verification (default: enabled)').action(async (id, opts) => { await sessionResume(id, opts); });
258
+ sessions.command('export <id> [file]').description('Export session to file (9.4)').action(async (id, file) => { await sessionExport(id, file); });
259
+ sessions.command('import <file>').description('Import session from file (restores record + messages + observations)').action(async (file) => { await sessionImport(file); });
260
+ sessions.command('fork <id>').description('Fork session with full context (9.4)').action(async (id) => { await sessionFork(id); });
261
+ sessions.command('delete <id>').description('Delete a session and its artifacts').action(async (id) => { await sessionDelete(id); });
262
+ }
package/dist/index.js CHANGED
@@ -16,6 +16,8 @@ import { chat } from './chat.js';
16
16
  import { repl } from './repl.js';
17
17
  import { startRepl } from './cli/repl.js';
18
18
  import { runOnce } from './cli/run.js';
19
+ import { registerSessionCommands } from './cli/session-commands.js';
20
+ import { parsePositiveInt } from './cli/args.js';
19
21
  import { runEval } from './cli/eval.js';
20
22
  import { runConfig } from './cli/config.js';
21
23
  import { runDoctor } from './cli/doctor.js';
@@ -25,13 +27,6 @@ import { runLogin, runLogout } from './cli/auth.js';
25
27
  import { readVersion } from './version.js';
26
28
  import { verifyAuditChain } from './persistence/audit.js';
27
29
  const VERSION = readVersion();
28
- function parsePositiveInt(name, v) {
29
- const n = Number(v);
30
- if (!Number.isFinite(n) || n <= 0) {
31
- throw new InvalidArgumentError(`invalid ${name}: ${v}`);
32
- }
33
- return n;
34
- }
35
30
  function parseTemperature(v) {
36
31
  const n = Number(v);
37
32
  if (!Number.isFinite(n) || n < 0 || n > 2) {
@@ -395,6 +390,7 @@ async function main() {
395
390
  .option('--parallel <n>', 'Parallelism (default 1)', (v) => parsePositiveInt('--parallel', v))
396
391
  .option('--model <id>', 'Model for eval')
397
392
  .option('--judge-model <id>', 'Live model id for grading judge.rubric (needs endpoint + key)')
393
+ .option('--cwd <path>', 'Shared scenario workdir (default: isolated tmp per scenario)')
398
394
  .action(async (input, opts) => {
399
395
  const output = (opts.output ?? 'human');
400
396
  if (opts.suite) {
@@ -405,7 +401,7 @@ async function main() {
405
401
  process.stderr.write('klyro eval: missing input (provide <input> or --suite)\n');
406
402
  process.exit(2);
407
403
  }
408
- const code = await runEval({ inputPath: input, output, suite: opts.suite, filter: opts.filter, runs: opts.runs, parallel: opts.parallel, model: opts.model, judgeModel: opts.judgeModel });
404
+ const code = await runEval({ inputPath: input, output, suite: opts.suite, filter: opts.filter, runs: opts.runs, parallel: opts.parallel, model: opts.model, judgeModel: opts.judgeModel, cwd: opts.cwd });
409
405
  process.exit(code);
410
406
  });
411
407
  program
@@ -420,237 +416,13 @@ async function main() {
420
416
  process.stdout.write(out + '\n');
421
417
  process.exit(0);
422
418
  });
423
- // Level 9 — Session management.
424
- // One namespace: `session` and `sessions` accept the SAME subcommands
425
- // (list/show/resume/export/import/fork/delete). Handlers live here once
426
- // and both command groups delegate to them.
427
- async function sessionList(opts) {
428
- const { getDefaultSessionStore, formatSession } = await import('./persistence/session.js');
429
- const store = getDefaultSessionStore();
430
- const all = await store.list(opts.status ? { status: opts.status } : undefined);
431
- if (opts.json) {
432
- process.stdout.write(JSON.stringify(all, null, 2) + '\n');
433
- }
434
- else {
435
- if (all.length === 0) {
436
- process.stdout.write('No sessions\n');
437
- }
438
- else {
439
- for (const r of all.sort((a, b) => b.updatedAt - a.updatedAt)) {
440
- process.stdout.write(formatSession(r) + '\n');
441
- }
442
- }
443
- }
444
- }
445
- async function resolveOrExit(id) {
446
- const { getDefaultSessionStore, resolveSessionId, matchSessionIds } = await import('./persistence/session.js');
447
- const store = getDefaultSessionStore();
448
- const full = await resolveSessionId(store, id);
449
- if (full)
450
- return full;
451
- const matches = await matchSessionIds(store, id);
452
- if (matches.length > 1) {
453
- process.stderr.write(`ambiguous id "${id}" matches:\n${matches.map((r) => ` ${r.id.slice(0, 8)} ${r.task.slice(0, 50)}`).join('\n')}\n`);
454
- }
455
- else {
456
- process.stderr.write(`session not found: ${id}\n`);
457
- }
458
- process.exit(2);
459
- }
460
- async function sessionShow(id, opts) {
461
- const { getDefaultSessionStore } = await import('./persistence/session.js');
462
- const store = getDefaultSessionStore();
463
- const full = await resolveOrExit(id);
464
- const rec = await store.get(full);
465
- const msgs = await store.loadMessages(full);
466
- const obs = await store.loadObservations(full);
467
- if (opts.json) {
468
- process.stdout.write(JSON.stringify({ record: rec, messages: msgs, observations: obs }, null, 2) + '\n');
469
- }
470
- else {
471
- process.stdout.write(`Session ${rec?.id}\n task: ${rec?.task}\n status: ${rec?.status}\n cwd: ${rec?.cwd}\n created: ${new Date(rec?.createdAt ?? 0).toISOString()}\n`);
472
- process.stdout.write(`\nMessages (${msgs.length}):\n`);
473
- for (const m of msgs)
474
- process.stdout.write(` [${m.role}] ${JSON.stringify(m.content).slice(0, 200)}\n`);
475
- process.stdout.write(`\nObservations (${obs.length}):\n`);
476
- for (const o of obs)
477
- process.stdout.write(` ${o.toolName} -> ${o.isError ? 'ERR' : 'ok'} ${JSON.stringify(o.output).slice(0, 120)}\n`);
478
- }
479
- }
480
- async function sessionResume(id, opts) {
481
- const { getDefaultSessionStore } = await import('./persistence/session.js');
482
- const store = getDefaultSessionStore();
483
- const full = await resolveOrExit(id);
484
- const rec = await store.get(full);
485
- if (!rec) {
486
- process.stderr.write(`session not found: ${id}\n`);
487
- process.exit(2);
488
- }
489
- const model = opts.model ?? rec.config.model ?? process.env.KLYRO_MODEL;
490
- if (!model) {
491
- process.stderr.write('klyro: KLYRO_MODEL is not set (or pass --model)\n');
492
- process.exit(2);
493
- }
494
- const code = await runOnce({
495
- task: rec.task,
496
- cwd: rec.cwd,
497
- model,
498
- maxSteps: opts.maxSteps ?? rec.config.maxSteps,
499
- sessionId: full,
500
- verify: opts.verify,
501
- verifyCommand: opts.verifyCommand,
502
- });
503
- process.exit(code);
504
- }
505
- async function sessionExport(id, file) {
506
- const { getDefaultSessionStore } = await import('./persistence/session.js');
507
- const store = getDefaultSessionStore();
508
- const full = await resolveOrExit(id);
509
- const rec = await store.get(full);
510
- const msgs = await store.loadMessages(full);
511
- const obs = await store.loadObservations(full);
512
- const out = file ?? `${full}.export.json`;
513
- await (await import('node:fs/promises')).writeFile(out, JSON.stringify({ record: rec, messages: msgs, observations: obs }, null, 2));
514
- process.stdout.write(`exported ${full} → ${out}\n`);
515
- }
516
- async function sessionImport(file) {
517
- let data;
518
- try {
519
- data = JSON.parse(await (await import('node:fs/promises')).readFile(file, 'utf-8'));
520
- }
521
- catch (err) {
522
- process.stderr.write(`klyro: cannot import ${file}: ${err instanceof Error ? err.message : String(err)}\n`);
523
- process.exit(2);
524
- }
525
- const rec = data.record ?? {};
526
- const { getDefaultSessionStore } = await import('./persistence/session.js');
527
- const store = getDefaultSessionStore();
528
- const cfg = (rec.config && typeof rec.config === 'object' ? rec.config : { model: 'imported', maxSteps: 30 });
529
- const created = await store.create({ cwd: typeof rec.cwd === 'string' ? rec.cwd : process.cwd(), task: typeof rec.task === 'string' ? rec.task : 'imported', config: cfg });
530
- // Restore the transcript — previously this was silently dropped (lossy
531
- // import). Messages/observations go through append* so at-rest redaction
532
- // still applies. Malformed entries fail loudly instead of half-importing.
533
- const d = data;
534
- let restored = 0;
535
- if (d.messages !== undefined) {
536
- if (!Array.isArray(d.messages)) {
537
- process.stderr.write(`klyro: import failed: "messages" is not an array in ${file}\n`);
538
- process.exit(2);
539
- }
540
- for (const m of d.messages) {
541
- if (!m || typeof m !== 'object' || typeof m.role !== 'string' || !('content' in m)) {
542
- process.stderr.write(`klyro: import failed: malformed message entry in ${file}\n`);
543
- process.exit(2);
544
- }
545
- await store.appendMessage(created.id, m);
546
- restored++;
547
- }
548
- }
549
- if (d.observations !== undefined) {
550
- if (!Array.isArray(d.observations)) {
551
- process.stderr.write(`klyro: import failed: "observations" is not an array in ${file}\n`);
552
- process.exit(2);
553
- }
554
- for (const o of d.observations) {
555
- if (!o || typeof o !== 'object') {
556
- process.stderr.write(`klyro: import failed: malformed observation entry in ${file}\n`);
557
- process.exit(2);
558
- }
559
- await store.appendObservation(created.id, o);
560
- }
561
- }
562
- process.stdout.write(`imported → ${created.id} (${restored} messages restored)\n`);
563
- }
564
- async function sessionFork(id) {
565
- const { getDefaultSessionStore, matchSessionIds } = await import('./persistence/session.js');
566
- const store = getDefaultSessionStore();
567
- const matches = await matchSessionIds(store, id);
568
- if (matches.length === 0) {
569
- process.stderr.write(`session not found: ${id}\n`);
570
- process.exit(2);
571
- }
572
- if (matches.length > 1) {
573
- process.stderr.write(`ambiguous id "${id}" matches:\n${matches.map((r) => ` ${r.id.slice(0, 8)} ${r.task.slice(0, 50)}`).join('\n')}\n`);
574
- process.exit(2);
575
- }
576
- const full = matches[0].id;
577
- const forked = await store.fork(full);
578
- const msgs = await store.loadMessages(forked.id);
579
- process.stdout.write(`forked ${full.slice(0, 8)} → ${forked.id.slice(0, 8)} (${msgs.length} messages carried over)\n`);
580
- }
581
- async function sessionDelete(id) {
582
- const { getDefaultSessionStore, matchSessionIds } = await import('./persistence/session.js');
583
- const store = getDefaultSessionStore();
584
- const matches = await matchSessionIds(store, id);
585
- if (matches.length === 0) {
586
- process.stderr.write(`session not found: ${id}\n`);
587
- process.exit(2);
588
- }
589
- if (matches.length > 1) {
590
- process.stderr.write(`ambiguous id "${id}" matches:\n${matches.map((r) => ` ${r.id.slice(0, 8)} ${r.task.slice(0, 50)}`).join('\n')}\n`);
591
- process.exit(2);
592
- }
593
- const full = matches[0].id;
594
- await store.delete(full);
595
- process.stdout.write(`deleted ${full.slice(0, 8)}\n`);
596
- }
597
- const session = program.command('session').description('Session persistence (Level 9)');
598
- session
599
- .command('list')
600
- .description('List persisted sessions')
601
- .option('--status <s>', 'Filter by status: open|complete|verify_failed|aborted|max_steps')
602
- .option('--json', 'Output JSON')
603
- .action(async (opts) => { await sessionList(opts); });
604
- session
605
- .command('show <id>')
606
- .description('Show session transcript and observations')
607
- .option('--json', 'Output JSON')
608
- .action(async (id, opts) => { await sessionShow(id, opts); });
609
- session
610
- .command('resume <id>')
611
- .description('Resume a persisted session (requires KLYRO_MODEL etc.)')
612
- .option('-m, --model <id>', 'Model (default: from session or env)')
613
- .option('--max-steps <n>', 'Max steps (default 30)', (v) => parsePositiveInt('--max-steps', v))
614
- .option('--verify-command <cmd>', 'Override verification command')
615
- .option('--verify', 'Enable verification (default: enabled)')
616
- .action(async (id, opts) => { await sessionResume(id, opts); });
617
- session
618
- .command('export <id> [file]')
619
- .description('Export session to file (9.4)')
620
- .action(async (id, file) => { await sessionExport(id, file); });
621
- session
622
- .command('import <file>')
623
- .description('Import session from file (restores record + messages + observations)')
624
- .action(async (file) => { await sessionImport(file); });
625
- session
626
- .command('fork <id>')
627
- .description('Fork session with full context (9.4)')
628
- .action(async (id) => { await sessionFork(id); });
629
- session
630
- .command('delete <id>')
631
- .description('Delete a session and its artifacts')
632
- .action(async (id) => { await sessionDelete(id); });
633
- // Alias: klyro resume <id> → klyro session resume <id>
634
- program
635
- .command('resume <id>')
636
- .description('Alias for `klyro session resume <id>`')
637
- .option('-m, --model <id>', 'Model')
638
- .option('--max-steps <n>', 'Max steps', (v) => parsePositiveInt('--max-steps', v))
639
- .action(async (id, opts) => { await sessionResume(id, opts); });
419
+ // Session namespace lives in src/cli/session-commands.ts (entrypoint stays registration-only).
420
+ registerSessionCommands(program);
640
421
  program.command('scan').description('Scan project (7.1) — languages, frameworks, commands, 300ms cached').option('--json', 'JSON output').action(async (opts) => { const { runScan } = await import('./cli/scan.js'); process.exit(await runScan({ cwd: process.cwd(), json: !!opts.json })); });
641
422
  program.command('project').description('Alias for scan').option('--json', 'JSON output').action(async (opts) => { const { runProject } = await import('./cli/scan.js'); process.exit(await runProject({ cwd: process.cwd(), json: !!opts.json })); });
642
423
  // 9.2 — Continue / resume top-level flags (also handled via session resume)
643
424
  program.option('-c, --continue', 'Continue most recent session in cwd (9.2)');
644
425
  program.option('-r, --resume [id]', 'Resume session by id or pick most recent');
645
- // 9.4 — same namespace as `session`: every subcommand works under both.
646
- const sessions = program.command('sessions').description('Alias for session (same subcommands)');
647
- sessions.command('list').description('List persisted sessions').option('--status <s>', 'Filter by status').option('--json', 'Output JSON').action(async (opts) => { await sessionList(opts); });
648
- sessions.command('show <id>').description('Show session transcript and observations').option('--json', 'Output JSON').action(async (id, opts) => { await sessionShow(id, opts); });
649
- sessions.command('resume <id>').description('Resume a persisted session').option('-m, --model <id>', 'Model').option('--max-steps <n>', 'Max steps', (v) => parsePositiveInt('--max-steps', v)).option('--verify-command <cmd>', 'Override verification command').option('--verify', 'Enable verification (default: enabled)').action(async (id, opts) => { await sessionResume(id, opts); });
650
- sessions.command('export <id> [file]').description('Export session to file (9.4)').action(async (id, file) => { await sessionExport(id, file); });
651
- sessions.command('import <file>').description('Import session from file (restores record + messages + observations)').action(async (file) => { await sessionImport(file); });
652
- sessions.command('fork <id>').description('Fork session with full context (9.4)').action(async (id) => { await sessionFork(id); });
653
- sessions.command('delete <id>').description('Delete a session and its artifacts').action(async (id) => { await sessionDelete(id); });
654
426
  // 10.1 — MCP
655
427
  const mcp = program.command('mcp').description('MCP client/server (10.1)');
656
428
  mcp.command('list').description('List MCP servers').action(async () => {
package/dist/mcp/trust.js CHANGED
@@ -55,7 +55,21 @@ export class McpTrust {
55
55
  save() {
56
56
  try {
57
57
  fs.mkdirSync(path.dirname(this.storePath), { recursive: true });
58
- fs.writeFileSync(this.storePath, JSON.stringify(this.store, null, 2), 'utf-8');
58
+ // Atomic trust write (tmp + rename) so a crash never leaves a
59
+ // truncated mcp-trust.json that auto-approves the wrong spec.
60
+ const tmp = `${this.storePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
61
+ fs.writeFileSync(tmp, JSON.stringify(this.store, null, 2), 'utf-8');
62
+ try {
63
+ const fh = fs.openSync(tmp, 'r+');
64
+ try {
65
+ fs.fsyncSync(fh);
66
+ }
67
+ finally {
68
+ fs.closeSync(fh);
69
+ }
70
+ }
71
+ catch { /* ignore on Windows */ }
72
+ fs.renameSync(tmp, this.storePath);
59
73
  }
60
74
  catch {
61
75
  /* best-effort — trust stays in memory for the session */
@@ -10,6 +10,9 @@
10
10
  * SessionStore interface.
11
11
  */
12
12
  export type SessionStatus = 'open' | 'complete' | 'verify_failed' | 'aborted' | 'max_steps' | 'stuck';
13
+ /** Persisted session schema version. Bump when SessionRecord changes shape;
14
+ * readers migrate older records forward (missing version ⇒ v0). */
15
+ export declare const SESSION_SCHEMA_VERSION = 1;
13
16
  export interface SessionConfig {
14
17
  model: string;
15
18
  maxSteps: number;
@@ -24,7 +27,12 @@ export interface SessionRecord {
24
27
  updatedAt: number;
25
28
  config: SessionConfig;
26
29
  finalText?: string;
30
+ /** Schema version for migrations; absent on pre-v1 records (treated as v0). */
31
+ schemaVersion?: number;
27
32
  }
33
+ /** Migrate a persisted record forward. Unknown future versions pass through
34
+ * untouched so a newer CLI's sessions stay readable (forward-compatible). */
35
+ export declare function migrateSessionRecord(raw: SessionRecord): SessionRecord;
28
36
  export interface StoredMessage {
29
37
  role: 'user' | 'assistant' | 'tool' | 'system';
30
38
  content: unknown;
@@ -14,6 +14,17 @@ import * as fsSync from 'node:fs';
14
14
  import * as path from 'node:path';
15
15
  import { randomUUID } from 'node:crypto';
16
16
  import { redact } from '../policy/secret-redactor.js';
17
+ /** Persisted session schema version. Bump when SessionRecord changes shape;
18
+ * readers migrate older records forward (missing version ⇒ v0). */
19
+ export const SESSION_SCHEMA_VERSION = 1;
20
+ /** Migrate a persisted record forward. Unknown future versions pass through
21
+ * untouched so a newer CLI's sessions stay readable (forward-compatible). */
22
+ export function migrateSessionRecord(raw) {
23
+ const version = typeof raw.schemaVersion === 'number' ? raw.schemaVersion : 0;
24
+ if (version >= SESSION_SCHEMA_VERSION)
25
+ return raw;
26
+ return { ...raw, schemaVersion: SESSION_SCHEMA_VERSION };
27
+ }
17
28
  export class SessionStore {
18
29
  dir;
19
30
  indexPath;
@@ -133,9 +144,31 @@ export class SessionStore {
133
144
  createdAt: now,
134
145
  updatedAt: now,
135
146
  config: opts.config,
147
+ schemaVersion: SESSION_SCHEMA_VERSION,
136
148
  };
137
149
  record.title = this.titleFor(task);
138
- await fs.writeFile(path.join(this.dir, `${id}.json`), JSON.stringify({ record, messages: [], observations: [] }, null, 2));
150
+ // Atomic session create (tmp + fsync + rename) so a crash can never
151
+ // leave a truncated `${id}.json` that resume/import then trusts.
152
+ const target = path.join(this.dir, `${id}.json`);
153
+ const tmpCreate = `${target}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
154
+ await fs.writeFile(tmpCreate, JSON.stringify({ record, messages: [], observations: [] }, null, 2));
155
+ try {
156
+ const fh = await fs.open(tmpCreate, 'r+');
157
+ try {
158
+ await fh.sync();
159
+ }
160
+ finally {
161
+ await fh.close();
162
+ }
163
+ }
164
+ catch { /* ignore on Windows */ }
165
+ try {
166
+ await fs.rename(tmpCreate, target);
167
+ }
168
+ catch {
169
+ await fs.unlink(tmpCreate).catch(() => undefined);
170
+ throw new Error(`Failed to write session ${id}`);
171
+ }
139
172
  await this.appendJsonl(id, { type: 'session.create', record, ts: now });
140
173
  const idx = await this.readIndex();
141
174
  idx[id] = record;
@@ -238,7 +271,9 @@ export class SessionStore {
238
271
  }
239
272
  async readSession(id) {
240
273
  const raw = await fs.readFile(path.join(this.dir, `${id}.json`), 'utf-8');
241
- return JSON.parse(raw);
274
+ const parsed = JSON.parse(raw);
275
+ parsed.record = migrateSessionRecord(parsed.record);
276
+ return parsed;
242
277
  }
243
278
  async writeSession(id, data) {
244
279
  data.record.updatedAt = Date.now();
@@ -339,12 +374,13 @@ export class SessionStore {
339
374
  }
340
375
  async list(filter) {
341
376
  const idx = await this.readIndex();
342
- const all = Object.values(idx);
377
+ const all = Object.values(idx).map(migrateSessionRecord);
343
378
  return filter?.status ? all.filter((s) => s.status === filter.status) : all;
344
379
  }
345
380
  async get(id) {
346
381
  const idx = await this.readIndex();
347
- return idx[id] ?? null;
382
+ const rec = idx[id];
383
+ return rec ? migrateSessionRecord(rec) : null;
348
384
  }
349
385
  /** Atomic append + fsync — survives crashes; suitable for audit log. */
350
386
  static async appendJsonl(filePath, entry) {
@@ -3,32 +3,33 @@
3
3
  * No direct writes in core — this is the only place that writes to stdout/stderr for human.
4
4
  */
5
5
  import { renderMarkdown } from '../cli/markdown.js';
6
+ import { sanitizeTerminalText } from '../shared/sanitize.js';
6
7
  export class TerminalRenderer {
7
8
  handle(ev) {
8
9
  switch (ev.type) {
9
10
  case 'stream.delta':
10
- process.stdout.write(ev.text);
11
+ process.stdout.write(sanitizeTerminalText(ev.text));
11
12
  break;
12
13
  case 'tool.call':
13
- process.stderr.write(`\n[tool] ${ev.name} ${JSON.stringify(ev.input).slice(0, 200)}\n`);
14
+ process.stderr.write(`\n[tool] ${sanitizeTerminalText(ev.name)} ${sanitizeTerminalText(JSON.stringify(ev.input).slice(0, 200))}\n`);
14
15
  break;
15
16
  case 'tool.result':
16
17
  process.stderr.write(` -> ${ev.isError ? 'ERR' : 'ok'} (${ev.latencyMs}ms)\n`);
17
18
  break;
18
19
  case 'file.changed':
19
- process.stderr.write(` ✎ ${ev.path} (${ev.op})\n`);
20
+ process.stderr.write(` ✎ ${sanitizeTerminalText(ev.path)} (${ev.op})\n`);
20
21
  break;
21
22
  case 'phase.changed':
22
- process.stderr.write(`\n[phase] ${ev.phase}\n`);
23
+ process.stderr.write(`\n[phase] ${sanitizeTerminalText(ev.phase)}\n`);
23
24
  break;
24
25
  case 'verification.started':
25
- process.stderr.write(`[verify] ${ev.command}\n`);
26
+ process.stderr.write(`[verify] ${sanitizeTerminalText(ev.command)}\n`);
26
27
  break;
27
28
  case 'verification.failed':
28
- process.stderr.write(`[verify] failed: ${ev.reason.slice(0, 200)}\n`);
29
+ process.stderr.write(`[verify] failed: ${sanitizeTerminalText(ev.reason.slice(0, 200))}\n`);
29
30
  break;
30
31
  case 'error':
31
- process.stderr.write(`✖ ${ev.message}\n`);
32
+ process.stderr.write(`✖ ${sanitizeTerminalText(ev.message)}\n`);
32
33
  break;
33
34
  default:
34
35
  break;
@@ -36,6 +37,6 @@ export class TerminalRenderer {
36
37
  }
37
38
  renderMarkdown(text) {
38
39
  const out = renderMarkdown(text, { isTTY: !!process.stdout.isTTY });
39
- process.stdout.write(out);
40
+ process.stdout.write(sanitizeTerminalText(out));
40
41
  }
41
42
  }
package/dist/repl.d.ts CHANGED
@@ -1,4 +1,11 @@
1
1
  /**
2
+ * COMPATIBILITY-ONLY legacy interactive REPL. Prefer `klyro tui`.
3
+ *
4
+ * Shares the provider contract with chat.ts/providers.ts (assertSafeBaseURL,
5
+ * normalizeBaseURL) — covered by src/providers/contract.test.ts.
6
+ * History here is in-memory only; cross-turn persistence lives in the TUI
7
+ * REPL path (src/cli/repl.ts + session-history).
8
+ *
2
9
  * Interactive REPL. Reads prompts from stdin one line at a time.
3
10
  *
4
11
  * Conversation history lives in memory only; each new prompt is sent with the
package/dist/repl.js CHANGED
@@ -1,4 +1,11 @@
1
1
  /**
2
+ * COMPATIBILITY-ONLY legacy interactive REPL. Prefer `klyro tui`.
3
+ *
4
+ * Shares the provider contract with chat.ts/providers.ts (assertSafeBaseURL,
5
+ * normalizeBaseURL) — covered by src/providers/contract.test.ts.
6
+ * History here is in-memory only; cross-turn persistence lives in the TUI
7
+ * REPL path (src/cli/repl.ts + session-history).
8
+ *
2
9
  * Interactive REPL. Reads prompts from stdin one line at a time.
3
10
  *
4
11
  * Conversation history lives in memory only; each new prompt is sent with the
@@ -1,2 +1,3 @@
1
1
  export * from './errors.js';
2
2
  export * from './types.js';
3
+ export * from './sanitize.js';
@@ -1,2 +1,3 @@
1
1
  export * from './errors.js';
2
2
  export * from './types.js';
3
+ export * from './sanitize.js';
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Terminal output sanitization (review section 11).
3
+ *
4
+ * Model and tool output is untrusted: it must never emit raw terminal
5
+ * control sequences (OSC hyperlinks, CSI cursor/reporting, C0/C1 controls)
6
+ * that could rewrite the display or smuggle approval-like text. The
7
+ * human-mode TerminalRenderer writes directly to stdout/stderr, so all
8
+ * human output is passed through `sanitizeTerminalText` first.
9
+ *
10
+ * Strips OSC + CSI sequences and C0/C1 controls (except newline/tab);
11
+ * preserves printable Unicode, wide chars, and emoji.
12
+ */
13
+ export declare function sanitizeTerminalText(input: string): string;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Terminal output sanitization (review section 11).
3
+ *
4
+ * Model and tool output is untrusted: it must never emit raw terminal
5
+ * control sequences (OSC hyperlinks, CSI cursor/reporting, C0/C1 controls)
6
+ * that could rewrite the display or smuggle approval-like text. The
7
+ * human-mode TerminalRenderer writes directly to stdout/stderr, so all
8
+ * human output is passed through `sanitizeTerminalText` first.
9
+ *
10
+ * Strips OSC + CSI sequences and C0/C1 controls (except newline/tab);
11
+ * preserves printable Unicode, wide chars, and emoji.
12
+ */
13
+ export function sanitizeTerminalText(input) {
14
+ const ESC = String.fromCharCode(27);
15
+ const BEL = String.fromCharCode(7);
16
+ const out = input;
17
+ let cleaned = '';
18
+ let i = 0;
19
+ while (i < out.length) {
20
+ const ch = out[i];
21
+ const code = out.charCodeAt(i);
22
+ if (ch === ESC) {
23
+ const next = out[i + 1];
24
+ if (next === '[') {
25
+ let j = i + 2;
26
+ while (j < out.length && !/[@-~]/.test(out[j]))
27
+ j++;
28
+ i = j + 1;
29
+ continue;
30
+ }
31
+ if (next === ']') {
32
+ let j = i + 2;
33
+ while (j < out.length && out[j] !== BEL && !(out[j] === ESC && out[j + 1] === '\\'))
34
+ j++;
35
+ i = out[j] === BEL ? j + 1 : j + 2;
36
+ continue;
37
+ }
38
+ i += 1;
39
+ continue;
40
+ }
41
+ if ((code < 0x20 && code !== 0x0a && code !== 0x09) || (code >= 0x7f && code <= 0x9f)) {
42
+ i += 1;
43
+ continue;
44
+ }
45
+ cleaned += ch;
46
+ i += 1;
47
+ }
48
+ return cleaned;
49
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klyro",
3
- "version": "1.0.9",
3
+ "version": "1.0.11",
4
4
  "description": "Klyro — autonomous coding harness CLI that streams from any OpenAI-compatible or Anthropic LLM endpoint.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -27,6 +27,7 @@
27
27
  "test": "vitest run",
28
28
  "test:watch": "vitest",
29
29
  "prepublishOnly": "npm run typecheck && npm test && npm run build",
30
+ "release:check": "node scripts/release-check.mjs",
30
31
  "pack:dry": "npm pack --dry-run",
31
32
  "publish:public": "npm publish --access public"
32
33
  },