klyro 1.0.18 → 1.0.21

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 (40) hide show
  1. package/dist/agent/anthropic-adapter.js +9 -1
  2. package/dist/agent/child-worker.js +21 -7
  3. package/dist/agent/orchestrator.js +27 -4
  4. package/dist/agent/provider-adapter.js +5 -0
  5. package/dist/agent/retry.js +6 -3
  6. package/dist/agent/runtime.js +15 -6
  7. package/dist/agent/worktree-manager.d.ts +2 -0
  8. package/dist/agent/worktree-manager.js +9 -2
  9. package/dist/checkpoints/store.d.ts +13 -0
  10. package/dist/checkpoints/store.js +65 -2
  11. package/dist/cli/dotenv.js +1 -1
  12. package/dist/cli/repl.js +22 -5
  13. package/dist/eval/harness.js +8 -3
  14. package/dist/mcp/remote.js +31 -7
  15. package/dist/mcp/sse.js +0 -1
  16. package/dist/persistence/audit.d.ts +0 -6
  17. package/dist/persistence/audit.js +11 -18
  18. package/dist/persistence/store.js +19 -13
  19. package/dist/policy/engine.js +43 -7
  20. package/dist/providers/model-info.d.ts +4 -1
  21. package/dist/providers/model-info.js +10 -2
  22. package/dist/repl.js +4 -2
  23. package/dist/tools/git/run-git.js +23 -4
  24. package/dist/tools/lsp/diagnostics.d.ts +50 -0
  25. package/dist/tools/lsp/diagnostics.js +109 -3
  26. package/dist/tools/registry.js +3 -1
  27. package/dist/tools/shell/background.js +52 -23
  28. package/dist/tools/verify/run-verify.js +11 -1
  29. package/dist/tui/app.d.ts +5 -0
  30. package/dist/tui/app.js +156 -51
  31. package/dist/tui/app.test.js +21 -0
  32. package/dist/tui/diff-parser.js +28 -5
  33. package/dist/tui/markdown.js +1 -1
  34. package/dist/tui/measure.js +4 -1
  35. package/dist/tui/scroll-model.d.ts +2 -0
  36. package/dist/tui/scroll-model.js +5 -0
  37. package/dist/tui/status.d.ts +3 -0
  38. package/dist/verification/engine.js +35 -2
  39. package/dist/verification/scoped.js +20 -5
  40. package/package.json +1 -1
@@ -17,6 +17,7 @@
17
17
  */
18
18
  import { assertSafeBaseURL } from '../chat.js';
19
19
  import { parseRetryAfterMs } from './provider-adapter.js';
20
+ import { redact } from '../policy/secret-redactor.js';
20
21
  import { estimateTokens } from '../context/tokenizer.js';
21
22
  import { proxiedFetch } from '../shared/proxy.js';
22
23
  const DEFAULT_VERSION = '2023-06-01';
@@ -174,7 +175,9 @@ async function* streamAnthropic(req, opts) {
174
175
  yield {
175
176
  kind: 'error',
176
177
  code: isOverflow ? 'REQUEST_TOO_LARGE' : `http_${resp.status}`,
177
- message: `Anthropic API returned ${resp.status}: ${text.slice(0, 500)}`,
178
+ // Redact before slicing (mirrors the OpenAI adapter): a 400 body can
179
+ // echo the prompt, leaking secrets into transcript/logs/trace.
180
+ message: `Anthropic API returned ${resp.status}: ${redact(text).slice(0, 500)}`,
178
181
  retryable,
179
182
  status: String(resp.status),
180
183
  ...(retryAfterMs !== undefined ? { retryAfterMs } : {}),
@@ -204,6 +207,11 @@ async function* streamAnthropic(req, opts) {
204
207
  if (done)
205
208
  break;
206
209
  buf += decoder.decode(value, { stream: true });
210
+ // SSE spec: CRLF/CR line endings normalize to LF (same as the
211
+ // OpenAI adapter) — otherwise '\n\n' never matches and the stream
212
+ // stalls to timeout.
213
+ if (buf.includes('\r'))
214
+ buf = buf.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
207
215
  // Split on SSE event boundary.
208
216
  const events = [];
209
217
  let idx = 0;
@@ -130,20 +130,34 @@ export async function forkChild(entry, payload, opts = {}) {
130
130
  });
131
131
  child.on('close', (code, signal) => {
132
132
  cleanup();
133
- const first = stdoutCap.text().split('\n').find((l) => l.trim().length > 0);
134
- if (code === 0 && first) {
133
+ // The child may print progress/log lines before the JSON result —
134
+ // scan from the LAST non-empty line back so an early log line can't
135
+ // mask the result (previously the first line was parsed instead).
136
+ const lines = stdoutCap.text().split('\n').map((l) => l.trim()).filter((l) => l.length > 0);
137
+ let parsed = null;
138
+ for (let i = lines.length - 1; i >= 0; i--) {
135
139
  try {
136
- resolve(JSON.parse(first));
137
- }
138
- catch {
139
- reject(new ChildCrashError('child exited 0 but emitted malformed ChildResult', 'exit', stderr.slice(-1000)));
140
+ const cand = JSON.parse(lines[i]);
141
+ if (cand && typeof cand === 'object' && typeof cand.status === 'string') {
142
+ parsed = cand;
143
+ break;
144
+ }
140
145
  }
146
+ catch { /* not JSON — keep scanning */ }
147
+ }
148
+ if (code === 0 && parsed) {
149
+ resolve(parsed);
141
150
  return;
142
151
  }
143
152
  const cause = signal === 'SIGKILL' || signal === 'SIGTERM'
144
153
  ? (killedForTimeout ? 'timeout' : 'oom')
145
154
  : 'exit';
146
- reject(new ChildCrashError(`child exited ${code ?? signal ?? '??'} without a ChildResult` + (stderr ? `: ${stderr.slice(-500)}` : ''), cause, stderr.slice(-1000)));
155
+ // Exit 0 with no parseable result keeps the historical "malformed"
156
+ // message (pinned by forkChild tests); other exits name the cause.
157
+ const what = code === 0
158
+ ? 'child exited 0 but emitted malformed ChildResult'
159
+ : `child exited ${code ?? signal ?? '??'} without a ChildResult`;
160
+ reject(new ChildCrashError(what + (stderr ? `: ${stderr.slice(-500)}` : ''), cause, stderr.slice(-1000)));
147
161
  });
148
162
  });
149
163
  }
@@ -666,13 +666,36 @@ export class AgentOrchestrator {
666
666
  return {
667
667
  ok: false,
668
668
  error: {
669
- code: 'MERGE_CONFLICT',
670
- message: `merge of ${meta.worktree.branch} conflicted`,
671
- details: { conflictFiles: merged.conflictFiles },
669
+ code: merged.error ? 'MERGE_FAILED' : 'MERGE_CONFLICT',
670
+ message: merged.error
671
+ ? `merge of ${meta.worktree.branch} failed: ${merged.error}`
672
+ : `merge of ${meta.worktree.branch} conflicted`,
673
+ details: { conflictFiles: merged.conflictFiles, ...(merged.error ? { error: merged.error } : {}) },
672
674
  },
673
675
  };
674
676
  }
675
- await removeWorktree({ repoCwd: meta.repoCwd, worktreePath: meta.worktree.worktreePath }).catch(() => undefined);
677
+ // Post-merge the branch content is committed, so a dirty worktree must
678
+ // not strand it: try a clean remove first, fall back to force, and
679
+ // only delete the branch once the worktree is really gone. Never
680
+ // report success while orphaning `.klyro/worktrees/<taskId>`.
681
+ try {
682
+ await removeWorktree({ repoCwd: meta.repoCwd, worktreePath: meta.worktree.worktreePath });
683
+ }
684
+ catch {
685
+ try {
686
+ await removeWorktree({ repoCwd: meta.repoCwd, worktreePath: meta.worktree.worktreePath, force: true });
687
+ }
688
+ catch (err) {
689
+ return {
690
+ ok: false,
691
+ error: {
692
+ code: 'WORKTREE_CLEANUP',
693
+ message: `merged ${meta.worktree.branch} but could not remove worktree ${meta.worktree.worktreePath}`,
694
+ details: { worktreePath: meta.worktree.worktreePath, error: err instanceof Error ? err.message : String(err) },
695
+ },
696
+ };
697
+ }
698
+ }
676
699
  await deleteBranch({ repoCwd: meta.repoCwd, branch: meta.worktree.branch });
677
700
  didMerge = true;
678
701
  }
@@ -293,6 +293,11 @@ async function* streamChatCompletions(url, opts, req, fetchImpl) {
293
293
  if (done)
294
294
  break;
295
295
  buf += decoder.decode(value, { stream: true });
296
+ // SSE spec: CRLF/CR line endings normalize to LF. Without this a
297
+ // CRLF stream never contains '\n\n', so no event boundary splits
298
+ // and the stream stalls to timeout.
299
+ if (buf.includes('\r'))
300
+ buf = buf.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
296
301
  // SSE: events separated by \n\n
297
302
  let idx;
298
303
  while ((idx = buf.indexOf('\n\n')) !== -1) {
@@ -210,9 +210,12 @@ export function retryingAdapter(inner, opts = {}) {
210
210
  ? retryAfterRaw
211
211
  : undefined;
212
212
  opts.onRetry?.({ attempt: attempt + 1, status, ...(retryAfterMs !== undefined ? { retryAfterMs } : {}) });
213
- // Honor a server-provided Retry-After delay when present; otherwise
214
- // fall back to exponential backoff with jitter.
215
- const delay = retryAfterMs ?? computeBackoff(attempt, cfg.baseMs, cfg.maxMs);
213
+ // Honor a server-provided Retry-After delay when present, capped at
214
+ // maxMs: an unbounded server delay (malicious or buggy endpoint)
215
+ // could otherwise park the agent for hours per attempt.
216
+ const delay = retryAfterMs !== undefined
217
+ ? Math.min(retryAfterMs, cfg.maxMs)
218
+ : computeBackoff(attempt, cfg.baseMs, cfg.maxMs);
216
219
  // Abort-aware backoff: Ctrl+C during the sleep must stop promptly
217
220
  // instead of stalling up to maxMs before noticing.
218
221
  if (delay > 0)
@@ -106,11 +106,16 @@ export async function run(opts, deps) {
106
106
  return [{ role: 'user', content: [text(opts.task)] }];
107
107
  })();
108
108
  const usage = { input: 0, output: 0 };
109
+ // Effective model for cost accounting: the request actually sends
110
+ // parentContext.model when an orchestrator overrides it, so budget
111
+ // warnings and the max-cost guard must rate against the same model —
112
+ // otherwise a cheap-model budget supervises expensive-model spend.
113
+ const effectiveModel = opts.parentContext?.model ?? opts.model;
109
114
  /** Emit one `budget_warning` per threshold the cost ratio has crossed. */
110
115
  const checkBudgetWarnings = () => {
111
116
  if (maxCost === undefined || maxCost <= 0)
112
117
  return;
113
- const ratio = estimateCost(opts.model, usage) / maxCost;
118
+ const ratio = estimateCost(effectiveModel, usage) / maxCost;
114
119
  for (const threshold of BUDGET_WARNING_THRESHOLDS) {
115
120
  if (ratio >= threshold && !firedBudgetWarnings.has(threshold)) {
116
121
  firedBudgetWarnings.add(threshold);
@@ -309,7 +314,7 @@ export async function run(opts, deps) {
309
314
  outer: while (steps < maxSteps) {
310
315
  // 5.1 limits: max-cost, max-time
311
316
  if (maxCost !== undefined) {
312
- const cost = estimateCost(opts.model, usage);
317
+ const cost = estimateCost(effectiveModel, usage);
313
318
  if (cost >= maxCost) {
314
319
  setPhase('limit');
315
320
  await closeTracer();
@@ -512,10 +517,12 @@ export async function run(opts, deps) {
512
517
  }
513
518
  // L15 failover: a terminal provider error swaps to the next chained
514
519
  // adapter and re-issues the step (bounded by chain length). Context
515
- // overflow is excluded — it owns its own recovery above. By the time
516
- // an error reaches the runtime, per-adapter retries are exhausted,
517
- // so any provider error here is terminal for the active adapter.
518
- if (failoverQueue.length > 0) {
520
+ // overflow is excluded — it owns its own recovery above, and a
521
+ // second overflow must abort, not re-spray the oversized transcript
522
+ // at the next provider. By the time an error reaches the runtime,
523
+ // per-adapter retries are exhausted, so any provider error here is
524
+ // terminal for the active adapter.
525
+ if (failoverQueue.length > 0 && ev.code !== 'REQUEST_TOO_LARGE') {
519
526
  const next = failoverQueue.shift();
520
527
  failoverPending = true;
521
528
  failoverFrom = activeAdapter.id;
@@ -1082,6 +1089,7 @@ export async function run(opts, deps) {
1082
1089
  await snapshot(opts.cwd, [...fileEditCounts.keys()].slice(-20), {
1083
1090
  ...(sessionId !== undefined ? { sessionId } : {}),
1084
1091
  eventId: call.id,
1092
+ transcript,
1085
1093
  });
1086
1094
  }
1087
1095
  catch { /* ignore */ }
@@ -1176,6 +1184,7 @@ export async function run(opts, deps) {
1176
1184
  await snapshot(opts.cwd, [fileChanged.path], {
1177
1185
  ...(sessionId !== undefined ? { sessionId } : {}),
1178
1186
  eventId: call.id,
1187
+ transcript,
1179
1188
  });
1180
1189
  }
1181
1190
  catch { /* ignore */ }
@@ -30,6 +30,8 @@ export interface WorktreeInfo {
30
30
  export interface MergeResult {
31
31
  merged: boolean;
32
32
  conflictFiles: string[];
33
+ /** Present when the merge failed for a non-conflict reason (bad branch, dirty tree, git error). */
34
+ error?: string;
33
35
  }
34
36
  /** True when `cwd` is inside a git working tree. Never throws — false on any failure. */
35
37
  export declare function ensureGitRepo(cwd: string): Promise<boolean>;
@@ -86,7 +86,8 @@ export async function mergeWorktree(opts) {
86
86
  await git(opts.repoCwd, ['merge', '--no-ff', '--no-edit', opts.branch]);
87
87
  return { merged: true, conflictFiles: [] };
88
88
  }
89
- catch {
89
+ catch (err) {
90
+ const errText = err instanceof Error ? err.message : String(err);
90
91
  const conflictFiles = await unmergedFiles(opts.repoCwd);
91
92
  try {
92
93
  await git(opts.repoCwd, ['merge', '--abort']);
@@ -94,7 +95,13 @@ export async function mergeWorktree(opts) {
94
95
  catch {
95
96
  /* best-effort: leave the repo as-is if abort itself fails */
96
97
  }
97
- return { merged: false, conflictFiles };
98
+ // Only call it a conflict when unmerged paths exist; anything else
99
+ // (unknown branch, dirty tree, git missing) is a hard error, not an
100
+ // empty MERGE_CONFLICT the caller can't act on.
101
+ if (conflictFiles.length > 0 || /CONFLICT/i.test(errText)) {
102
+ return { merged: false, conflictFiles };
103
+ }
104
+ return { merged: false, conflictFiles, error: errText };
98
105
  }
99
106
  }
100
107
  async function unmergedFiles(repoCwd) {
@@ -14,6 +14,7 @@
14
14
  export interface SnapshotOptions {
15
15
  sessionId?: string;
16
16
  eventId?: string;
17
+ transcript?: unknown[];
17
18
  }
18
19
  /** Checkpoint meta on disk — sessionId/eventId are absent on old metas. */
19
20
  export interface CheckpointMeta {
@@ -23,6 +24,7 @@ export interface CheckpointMeta {
23
24
  ts: number;
24
25
  sessionId?: string;
25
26
  eventId?: string;
27
+ transcriptHash?: string;
26
28
  }
27
29
  export declare function snapshot(cwd: string, files: string[], opts?: SnapshotOptions): Promise<string>;
28
30
  export declare function listCheckpoints(cwd: string): Promise<string[]>;
@@ -37,6 +39,17 @@ export interface CheckpointInfo {
37
39
  export declare function listCheckpointInfo(cwd: string): Promise<CheckpointInfo[]>;
38
40
  /** File paths a snapshot would restore (for `/rewind <n> preview`). */
39
41
  export declare function snapshotFiles(cwd: string, id: string): Promise<string[]>;
42
+ /**
43
+ * Transcript captured at a checkpoint, or null when the snapshot predates
44
+ * transcript capture or the payload is missing/corrupt.
45
+ *
46
+ * The stored hash is verified on read: a checkpoint is only ever trusted for
47
+ * restoring the conversation when its transcript.json still matches the
48
+ * transcriptHash recorded in .meta.json. A mismatch (partial write, manual
49
+ * edit, tampering) yields null so the caller can report "conversation
50
+ * untouched" instead of rewinding onto unverified content.
51
+ */
52
+ export declare function readSnapshotTranscript(cwd: string, id: string): Promise<unknown[] | null>;
40
53
  export declare function diff(cwd: string, id?: string): Promise<string>;
41
54
  export declare function undo(cwd: string, n?: number): Promise<void>;
42
55
  export declare function rewind(cwd: string): Promise<void>;
@@ -97,6 +97,14 @@ export async function snapshot(cwd, files, opts) {
97
97
  meta.sessionId = opts.sessionId;
98
98
  if (opts?.eventId !== undefined)
99
99
  meta.eventId = opts.eventId;
100
+ if (opts?.transcript) {
101
+ const transcriptPath = path.join(dest, 'transcript.json');
102
+ await fs.writeFile(transcriptPath, JSON.stringify(opts.transcript), 'utf-8');
103
+ lockDown(transcriptPath, 0o600);
104
+ await fsyncFile(transcriptPath);
105
+ const hash = crypto.createHash('sha256').update(JSON.stringify(opts.transcript)).digest('hex');
106
+ meta.transcriptHash = hash;
107
+ }
100
108
  await fs.writeFile(metaTmp, JSON.stringify(meta, null, 2));
101
109
  lockDown(metaTmp, 0o600);
102
110
  await fsyncFile(metaTmp);
@@ -208,6 +216,35 @@ export async function snapshotFiles(cwd, id) {
208
216
  return [];
209
217
  }
210
218
  }
219
+ /**
220
+ * Transcript captured at a checkpoint, or null when the snapshot predates
221
+ * transcript capture or the payload is missing/corrupt.
222
+ *
223
+ * The stored hash is verified on read: a checkpoint is only ever trusted for
224
+ * restoring the conversation when its transcript.json still matches the
225
+ * transcriptHash recorded in .meta.json. A mismatch (partial write, manual
226
+ * edit, tampering) yields null so the caller can report "conversation
227
+ * untouched" instead of rewinding onto unverified content.
228
+ */
229
+ export async function readSnapshotTranscript(cwd, id) {
230
+ try {
231
+ const dir = path.join(ckptDir(cwd), id);
232
+ const meta = JSON.parse(await fs.readFile(path.join(dir, '.meta.json'), 'utf-8'));
233
+ const raw = await fs.readFile(path.join(dir, 'transcript.json'), 'utf-8');
234
+ const parsed = JSON.parse(raw);
235
+ if (!Array.isArray(parsed))
236
+ return null;
237
+ if (typeof meta.transcriptHash === 'string') {
238
+ const actual = crypto.createHash('sha256').update(raw).digest('hex');
239
+ if (actual !== meta.transcriptHash)
240
+ return null;
241
+ }
242
+ return parsed;
243
+ }
244
+ catch {
245
+ return null;
246
+ }
247
+ }
211
248
  export async function diff(cwd, id) {
212
249
  const ckpts = await listCheckpoints(cwd);
213
250
  const target = id ?? ckpts[ckpts.length - 1];
@@ -218,9 +255,33 @@ export async function diff(cwd, id) {
218
255
  return new Promise((resolve) => {
219
256
  const child = spawn('git', ['diff', '--stat'], { cwd, shell: false, windowsHide: true });
220
257
  const sink = cappedOutput(64 * 1024);
258
+ let done = false;
259
+ // A wedged git (locked index) must not hang undo diagnostics forever.
260
+ const t = setTimeout(() => {
261
+ if (done)
262
+ return;
263
+ done = true;
264
+ try {
265
+ child.kill('SIGKILL');
266
+ }
267
+ catch { /* ignore */ }
268
+ resolve('No diff (git timed out)');
269
+ }, 10_000);
221
270
  child.stdout.on('data', (b) => sink.push(b));
222
- child.on('close', () => resolve(sink.text() || 'No diff'));
223
- child.on('error', () => resolve('No git diff available'));
271
+ child.on('close', () => {
272
+ if (done)
273
+ return;
274
+ done = true;
275
+ clearTimeout(t);
276
+ resolve(sink.text() || 'No diff');
277
+ });
278
+ child.on('error', () => {
279
+ if (done)
280
+ return;
281
+ done = true;
282
+ clearTimeout(t);
283
+ resolve('No git diff available');
284
+ });
224
285
  });
225
286
  }
226
287
  export async function undo(cwd, n = 1) {
@@ -232,6 +293,8 @@ export async function undo(cwd, n = 1) {
232
293
  const metaRaw = await fs.readFile(path.join(srcDir, '.meta.json'), 'utf-8');
233
294
  const meta = JSON.parse(metaRaw);
234
295
  // Restore modified/created files to their snapshotted content…
296
+ // Transcript restore is handled by caller via transcript snapshot — read via
297
+ // readSnapshotTranscript() if the caller wants conversation rewind.
235
298
  for (const f of meta.files) {
236
299
  const src = containedPath(cwd, srcDir, f);
237
300
  const dest = src ? containedPath(cwd, cwd, f) : null;
@@ -89,7 +89,7 @@ const BLOCKED_DOTENV_SECURITY_RELAXATIONS = new Set([
89
89
  'KLYRO_WORKER',
90
90
  ]);
91
91
  /** npm reads `npm_config_*` from the environment — a repo must not pick the registry. */
92
- const BLOCKED_DOTENV_PREFIXES = ['NPM_CONFIG_'];
92
+ const BLOCKED_DOTENV_PREFIXES = ['NPM_CONFIG_', 'KLYRO_SANDBOX']; // KLYRO_SANDBOX=0 / KLYRO_SANDBOX_NET=1 de-harden the shell sandbox (see shell-exec.ts)
93
93
  /** True when a `.env` key would change process behaviour or loosen a boundary. */
94
94
  export function isBlockedDotenvKey(key) {
95
95
  const k = key.toUpperCase();
package/dist/cli/repl.js CHANGED
@@ -711,8 +711,6 @@ export async function startRepl(opts = {}) {
711
711
  directHooks.clearTranscript();
712
712
  else
713
713
  pendingQueue.length = 0;
714
- if (isMounted && directHooks)
715
- directHooks.clearTranscript();
716
714
  }
717
715
  // Update nudge (best-effort, cached 24h, silent fail): a single stderr
718
716
  // line when a newer version exists. No prompt, no blocking — the check
@@ -956,7 +954,12 @@ export async function startRepl(opts = {}) {
956
954
  // streamingId is closed by status change; no extra handling needed
957
955
  }
958
956
  else if (ev.kind === 'usage') {
959
- queuedStatus({ usageInput: ev.input, usageOutput: ev.output });
957
+ queuedStatus({
958
+ usageInput: ev.input,
959
+ usageOutput: ev.output,
960
+ usageCacheRead: ev.cacheRead ?? 0,
961
+ usageCacheWrite: ev.cacheWrite ?? 0,
962
+ });
960
963
  }
961
964
  else if (ev.kind === 'plan_update') {
962
965
  queuedPlan(ev.plan);
@@ -1261,6 +1264,20 @@ export async function startRepl(opts = {}) {
1261
1264
  }
1262
1265
  await undo(cwd, n);
1263
1266
  let text = `Rewound to checkpoint ${n} (${target.id.slice(0, 12)}, ${target.files} file(s))`;
1267
+ // If transcript was captured for this checkpoint, restore it into the
1268
+ // active REPL session so /rewind full also reverts the conversation.
1269
+ const { readSnapshotTranscript } = await import('../checkpoints/store.js');
1270
+ const raw = await readSnapshotTranscript(cwd, target.id);
1271
+ // readSnapshotTranscript returns `unknown[]`; verify minimal Message shape
1272
+ // before giving it to adoptTranscript to avoid runtime type errors.
1273
+ const isMessage = (v) => !!v && typeof v === 'object' && 'role' in v && 'content' in v;
1274
+ if (Array.isArray(raw) && raw.length > 0 && raw.every(isMessage)) {
1275
+ sessionMessages = adoptTranscript(raw);
1276
+ text += '\nConversation restored from checkpoint transcript.';
1277
+ }
1278
+ else if (mode === 'full') {
1279
+ text += '\nConversation rewind skipped — no transcript captured for this checkpoint.';
1280
+ }
1264
1281
  if (mode === 'summary' || mode === 'full') {
1265
1282
  const files = before.split('\n').map((l) => l.trim()).filter(Boolean).slice(0, 20);
1266
1283
  text += files.length > 0 ? `\nReverted working-tree changes:\n${files.map((f) => ` ${f}`).join('\n')}` : '\nWorking tree was clean before restore.';
@@ -2074,8 +2091,8 @@ export async function startRepl(opts = {}) {
2074
2091
  queuedAppend({ id: `ckpt-${Date.now()}`, kind: 'text', text: 'Working tree clean — nothing to checkpoint.', role: 'assistant' });
2075
2092
  }
2076
2093
  else {
2077
- const id = await snapshot(cwd, files.slice(0, 50));
2078
- queuedAppend({ id: `ckpt2-${Date.now()}`, kind: 'text', text: `checkpoint ${String(id).slice(0, 8)} — ${files.length} file(s)`, role: 'assistant' });
2094
+ const id = await snapshot(cwd, files.slice(0, 50), { transcript: sessionMessages });
2095
+ queuedAppend({ id: `ckpt2-${Date.now()}`, kind: 'text', text: `checkpoint ${String(id).slice(0, 8)} — ${files.length} file(s)` + ` (conversation archived)`, role: 'assistant' });
2079
2096
  }
2080
2097
  }
2081
2098
  catch (err) {
@@ -23,7 +23,7 @@ import { globTool } from '../tools/search/glob.js';
23
23
  import { grepTool } from '../tools/search/grep.js';
24
24
  import { gitStatusTool } from '../tools/git/git-status.js';
25
25
  import { gitDiffTool } from '../tools/git/git-diff.js';
26
- import { PolicyEngine, builtinRules, DEFAULT_POLICY_CONFIG } from '../policy/engine.js';
26
+ import { PolicyEngine, builtinRules, clonePolicyConfig } from '../policy/engine.js';
27
27
  import { DenyAllApprovalPrompt } from '../policy/approval.js';
28
28
  import { defaultSystemPrompt } from '../agent/runtime.js';
29
29
  import { verify } from '../verification/engine.js';
@@ -59,7 +59,9 @@ export async function runTask(t, opts = {}) {
59
59
  .register(listDirTool).register(globTool).register(grepTool)
60
60
  .register(shellExecTool).register(runVerifyTool)
61
61
  .register(gitStatusTool).register(gitDiffTool);
62
- const policy = new PolicyEngine(builtinRules(), DEFAULT_POLICY_CONFIG);
62
+ // Clone: registerMcpServers mutates the config via addAsk — sharing the
63
+ // global DEFAULT_POLICY_CONFIG would leak ask-rules across tasks/runs.
64
+ const policy = new PolicyEngine(builtinRules(), clonePolicyConfig());
63
65
  try {
64
66
  const result = await run({ task: t.task, cwd, model: 'mock', maxSteps: 12, nonInteractive: true }, { adapter: scriptedAdapter(t.script), registry: reg, policy, approval: new DenyAllApprovalPrompt(), systemPrompt: defaultSystemPrompt });
65
67
  let observedStatus = result.status;
@@ -72,7 +74,10 @@ export async function runTask(t, opts = {}) {
72
74
  }
73
75
  }
74
76
  let details = '';
75
- let pass = result.status === t.expectStatus;
77
+ // Grade the *observed* status (agent result possibly overridden by the
78
+ // verify step above) — a failed verifyCommand must fail the task even
79
+ // when the agent itself reported 'complete'.
80
+ let pass = observedStatus === t.expectStatus;
76
81
  if (t.expectToolCalls !== undefined && result.toolCalls !== t.expectToolCalls) {
77
82
  details += ` toolCalls=${result.toolCalls} expected=${t.expectToolCalls};`;
78
83
  pass = false;
@@ -15,18 +15,42 @@ export function isLoopback(url) {
15
15
  /** Parse a Streamable-HTTP SSE body (`data: {...}` lines) into JSON payloads. */
16
16
  export function parseSseBody(text) {
17
17
  const out = [];
18
+ // Consecutive data: lines belong to ONE event per the SSE spec (joined
19
+ // with \n). Parse the joined chunk first; if that fails, fall back to
20
+ // per-line parsing so servers that (against spec) emit bare JSON per
21
+ // line without blank separators keep working.
22
+ let parts = [];
23
+ const flush = () => {
24
+ if (parts.length === 0)
25
+ return;
26
+ const joined = parts.join('\n').trim();
27
+ parts = [];
28
+ if (joined === '[DONE]')
29
+ return;
30
+ try {
31
+ out.push(JSON.parse(joined));
32
+ return;
33
+ }
34
+ catch { /* try per-line below */ }
35
+ for (const part of joined.split('\n')) {
36
+ const t = part.trim();
37
+ if (!t || t === '[DONE]')
38
+ continue;
39
+ try {
40
+ out.push(JSON.parse(t));
41
+ }
42
+ catch { /* ignore malformed lines */ }
43
+ }
44
+ };
18
45
  for (const line of text.split('\n')) {
19
46
  const t = line.trim();
20
- if (!t.startsWith('data:'))
21
- continue;
22
- const payload = t.slice(5).trim();
23
- if (payload === '[DONE]')
47
+ if (!t.startsWith('data:')) {
48
+ flush();
24
49
  continue;
25
- try {
26
- out.push(JSON.parse(payload));
27
50
  }
28
- catch { /* ignore malformed lines */ }
51
+ parts.push(t.slice(5).trim());
29
52
  }
53
+ flush();
30
54
  return out;
31
55
  }
32
56
  export class RemoteMcpClient {
package/dist/mcp/sse.js CHANGED
@@ -163,7 +163,6 @@ export class SseMcpClient {
163
163
  return;
164
164
  this.pending.delete(m.id);
165
165
  clearTimeout(p.timer);
166
- clearTimeout(p.timer);
167
166
  if (m.error)
168
167
  p.reject(new McpError(`mcp server "${this.name}" error: ${m.error.message ?? 'unknown'}`, 'SERVER_ERROR', m.error));
169
168
  else
@@ -93,12 +93,6 @@ export declare class AuditLog {
93
93
  private readonly keepSegments;
94
94
  /** Serializes chained appends so concurrent writes can't fork the chain. */
95
95
  private chain;
96
- /**
97
- * Cached last-written hash: avoids re-reading the whole file (O(n) per
98
- * write) on every append. Null = not loaded yet — first append reads the
99
- * file (picks up an existing chain), then the cache takes over.
100
- */
101
- private lastHash;
102
96
  constructor(filePath: string, maxBytes?: number, keepSegments?: number);
103
97
  write(event: AuditEvent): Promise<void>;
104
98
  private appendChained;
@@ -64,9 +64,9 @@ export const AUDIT_ROTATE_KEEP = 5;
64
64
  * Rotate an audit log once it exceeds `maxBytes`: shift the newest segment
65
65
  * over the oldest (deleted), leave a fresh live file. Each segment carries
66
66
  * its own GENESIS-rooted chain, so rotation is tamper-evident per segment.
67
- * Returns true when a rotation actually happened (the caller must reset its
68
- * in-memory chain tip to GENESIS — the next live record starts a fresh
69
- * chain). Best-effort — rotation failures never break appends.
67
+ * Callers re-read the tail after rotating, so the next live record starts
68
+ * a fresh chain with no in-memory state to reset. Best-effort — rotation
69
+ * failures never break appends.
70
70
  */
71
71
  async function rotateAuditLog(filePath, maxBytes, keep) {
72
72
  try {
@@ -97,12 +97,6 @@ export class AuditLog {
97
97
  keepSegments;
98
98
  /** Serializes chained appends so concurrent writes can't fork the chain. */
99
99
  chain = Promise.resolve();
100
- /**
101
- * Cached last-written hash: avoids re-reading the whole file (O(n) per
102
- * write) on every append. Null = not loaded yet — first append reads the
103
- * file (picks up an existing chain), then the cache takes over.
104
- */
105
- lastHash = null;
106
100
  constructor(filePath, maxBytes = AUDIT_MAX_BYTES, keepSegments = AUDIT_ROTATE_KEEP) {
107
101
  this.filePath = filePath;
108
102
  this.maxBytes = maxBytes;
@@ -115,17 +109,16 @@ export class AuditLog {
115
109
  return task;
116
110
  }
117
111
  async appendChained(event) {
118
- // Rotation leaves a fresh live file and resets the in-memory tip: the
119
- // next live record restarts at GENESIS (each segment is its own chain).
120
- const rotated = await rotateAuditLog(this.filePath, this.maxBytes, this.keepSegments);
121
- if (rotated)
122
- this.lastHash = AUDIT_GENESIS;
123
- if (this.lastHash === null)
124
- this.lastHash = await prevHashFor(this.filePath);
125
- const prevHash = this.lastHash;
112
+ // Rotation leaves a fresh live file; the tail read below then naturally
113
+ // restarts at GENESIS (each segment is its own chain).
114
+ await rotateAuditLog(this.filePath, this.maxBytes, this.keepSegments);
115
+ // Always re-read the tail instead of caching the tip: a cached tip forks
116
+ // the chain whenever another instance or process appends in between
117
+ // (e.g. a long-lived TUI plus a one-shot CLI on the same project).
118
+ // Audit volume is low and segments are size-bounded, so the read is cheap.
119
+ const prevHash = await prevHashFor(this.filePath);
126
120
  const body = { ...event, prevHash };
127
121
  const record = { ...body, prevHash, hash: sha256Hex(canonicalJson(body)) };
128
- this.lastHash = record.hash;
129
122
  await SessionStore.appendJsonl(this.filePath, record);
130
123
  }
131
124
  }
@@ -297,20 +297,26 @@ export class SessionStore {
297
297
  await fs.unlink(tmp).catch(() => undefined);
298
298
  throw new Error(`Failed to write session ${id}`);
299
299
  }
300
- // Update index with simple retry for concurrent writers (optimistic)
301
- for (let attempt = 0; attempt < 3; attempt++) {
302
- try {
303
- const idx = await this.readIndex();
304
- idx[id] = data.record;
305
- await this.writeIndex(idx);
306
- return;
307
- }
308
- catch (err) {
309
- if (attempt === 2)
310
- throw err;
311
- await new Promise((r) => setTimeout(r, 10 * (attempt + 1)));
300
+ // Update index under the shared 'index' lock (same key create/delete
301
+ // use): the read-modify-write below races under concurrent writers on
302
+ // different sessions, and last-writer-wins would silently drop entries.
303
+ // Deadlock-free: no caller holds 'index' while calling writeSession
304
+ // (create/delete write the index inline instead).
305
+ await this.withLock('index', async () => {
306
+ for (let attempt = 0; attempt < 3; attempt++) {
307
+ try {
308
+ const idx = await this.readIndex();
309
+ idx[id] = data.record;
310
+ await this.writeIndex(idx);
311
+ return;
312
+ }
313
+ catch (err) {
314
+ if (attempt === 2)
315
+ throw err;
316
+ await new Promise((r) => setTimeout(r, 10 * (attempt + 1)));
317
+ }
312
318
  }
313
- }
319
+ });
314
320
  }
315
321
  async appendMessage(id, message) {
316
322
  return this.withLock(id, async () => {