klyro 1.0.20 → 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.
@@ -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;
@@ -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) {
@@ -255,9 +255,33 @@ export async function diff(cwd, id) {
255
255
  return new Promise((resolve) => {
256
256
  const child = spawn('git', ['diff', '--stat'], { cwd, shell: false, windowsHide: true });
257
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);
258
270
  child.stdout.on('data', (b) => sink.push(b));
259
- child.on('close', () => resolve(sink.text() || 'No diff'));
260
- 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
+ });
261
285
  });
262
286
  }
263
287
  export async function undo(cwd, n = 1) {
@@ -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);
@@ -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 () => {
@@ -76,6 +76,18 @@ export class PolicyEngine {
76
76
  call.name === 'memory_write') {
77
77
  return { action: 'deny', reason: 'plan mode: writes blocked — use /permissions to allow or switch mode' };
78
78
  }
79
+ // plan mode is read-only investigation: shells run only allowlisted
80
+ // read-only commands, and nothing may escape to subagents or MCP
81
+ // servers (both can write through their own tool surfaces).
82
+ if (call.name === 'shell_exec' || call.name === 'run_verify') {
83
+ const cmd = asString(call.input.command);
84
+ if (!cmd || !startsWithAny(cmd, ctx.config.shellAllow)) {
85
+ return { action: 'deny', reason: 'plan mode: only allowlisted read-only commands — use /permissions to allow or switch mode' };
86
+ }
87
+ }
88
+ if (call.name === 'spawn_agent' || call.name.startsWith('mcp__')) {
89
+ return { action: 'deny', reason: 'plan mode: subagents and MCP tools blocked — use /permissions to allow or switch mode' };
90
+ }
79
91
  }
80
92
  else if (ctx.config.mode === 'accept-edits') {
81
93
  // accept-edits: auto-allow edit tools
@@ -196,7 +208,8 @@ function isEnvPath(p) {
196
208
  // not secrets — reads AND writes are allowed.
197
209
  if (/[.](example|sample|template|dist)$/.test(lower))
198
210
  return false;
199
- return /(^|\/)\.env(\.|$)/.test(lower) || lower.endsWith('.env');
211
+ // Separator class covers POSIX and Windows paths (C:\proj\.env.local).
212
+ return /(^|[/\\])\.env(\.|$)/.test(lower) || lower.endsWith('.env');
200
213
  }
201
214
  function extractPatchPaths(patch) {
202
215
  const out = [];
@@ -238,9 +251,25 @@ export function matchesGlobRule(call, rule) {
238
251
  }
239
252
  function startsWithAny(haystack, needles) {
240
253
  const h = haystack.trim().toLowerCase();
254
+ // Quote-stripped variant: `rm -rf "/"` is `rm -rf /` with camouflage.
255
+ // Stripping quotes can only add deny matches at position 0, never remove.
256
+ const variants = [h, h.replace(/['"]/g, '')];
241
257
  return needles.some((n) => {
242
258
  const nl = n.toLowerCase().replace(/\s+$/, ''); // strip trailing ws from needle
243
- return h === nl || h.startsWith(nl + ' ') || h.startsWith(nl + '\t');
259
+ return variants.some((hv) => {
260
+ if (hv === nl)
261
+ return true;
262
+ if (!hv.startsWith(nl))
263
+ return false;
264
+ // Boundary after the needle: end, whitespace, quote, or a path
265
+ // separator/glob/chain char. Without the separator class, `rm -rf /*`,
266
+ // `rm -rf ~/*`, `rm -rf ./*` and `rm -rf ./` all slip past the deny
267
+ // list. Note this intentionally also matches scoped forms like
268
+ // `rm -rf ./build` — destructive-prefix commands stay denied and the
269
+ // caller allowlists (or uses a bare relative path).
270
+ const c = hv[nl.length];
271
+ return c === undefined || /[\s/'"`*?\[\\;&|]/.test(c);
272
+ });
244
273
  });
245
274
  }
246
275
  /** Branches that `git push` must never target without an explicit opt-out. */
@@ -407,13 +436,20 @@ export const writeFileCwdRule = {
407
436
  if (!p)
408
437
  return null;
409
438
  if (p.includes('..') || path.isAbsolute(p)) {
410
- // Check if absolute is inside additionalDirs
439
+ // Absolute paths are fine inside the workspace roots (cwd or
440
+ // additionalDirs) — only true escapes are denied.
411
441
  if (path.isAbsolute(p)) {
412
- const allowed = (ctx.config.additionalDirs ?? []).some((dir) => {
413
- const rel = path.relative(path.resolve(dir), path.resolve(p));
414
- return !rel.startsWith('..') && !path.isAbsolute(rel);
442
+ const roots = [ctx.cwd, ...(ctx.config.additionalDirs ?? [])];
443
+ const inside = roots.some((dir) => {
444
+ try {
445
+ const rel = path.relative(path.resolve(dir), path.resolve(p));
446
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
447
+ }
448
+ catch {
449
+ return false; // different drive on Windows: not inside
450
+ }
415
451
  });
416
- if (allowed)
452
+ if (inside)
417
453
  return null;
418
454
  }
419
455
  return { action: 'deny', reason: 'path traversal or absolute path not allowed' };
@@ -39,4 +39,7 @@ export declare function ratesFor(modelId: string): {
39
39
  * other families keep ignoring those counters.
40
40
  */
41
41
  export declare function isAnthropicModel(modelId: string): boolean;
42
- export declare function estimateCost(modelId: string, inputTokens: number, outputTokens: number): number;
42
+ export declare function estimateCost(modelId: string, inputTokens: number, outputTokens: number, cache?: {
43
+ read?: number;
44
+ write?: number;
45
+ }): number;
@@ -70,9 +70,17 @@ export function ratesFor(modelId) {
70
70
  export function isAnthropicModel(modelId) {
71
71
  return /anthropic|claude/i.test(modelId);
72
72
  }
73
- export function estimateCost(modelId, inputTokens, outputTokens) {
73
+ export function estimateCost(modelId, inputTokens, outputTokens, cache) {
74
74
  // Single source: delegate to ratesFor so registry, family fallbacks, and
75
75
  // local-$0 rules live in exactly one place (no split-brain with getModelInfo).
76
76
  const { input, output } = ratesFor(modelId);
77
- return (inputTokens / 1000) * input + (outputTokens / 1000) * output;
77
+ let cost = (inputTokens / 1000) * input + (outputTokens / 1000) * output;
78
+ // Cache-aware (Anthropic billing): cacheRead at 0.1x input, cacheWrite at
79
+ // 1.25x input. Other families ignore cache counters. Mirrors the
80
+ // runtime's estimateCost — keep the two in sync.
81
+ if (isAnthropicModel(modelId)) {
82
+ cost += ((cache?.read ?? 0) / 1000) * input * 0.1;
83
+ cost += ((cache?.write ?? 0) / 1000) * input * 1.25;
84
+ }
85
+ return cost;
78
86
  }
package/dist/repl.js CHANGED
@@ -94,8 +94,10 @@ export async function repl(system) {
94
94
  // EOF on stdin (Ctrl-D) or close — exit cleanly.
95
95
  break;
96
96
  }
97
- if (!line)
98
- break; // empty line on EOF
97
+ // EOF on stdin (Ctrl-D) rejects the question and exits via the
98
+ // catch above. An empty line is just a stray Enter — keep going.
99
+ if (line === '')
100
+ continue;
99
101
  const step = splitContinuedLine(continued, line);
100
102
  continued = step.pending;
101
103
  if (step.complete === undefined)