loom-agent 1.3.3 → 1.6.0

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.
@@ -71,15 +71,34 @@ function isAbortError(err) {
71
71
  return /aborted|cancel(led|ed)/i.test(String(err.message || ''));
72
72
  }
73
73
 
74
+ // Rate-limit errors — transient (429 / overloaded): wait and retry rather
75
+ // than switching models. Checked BEFORE isQuotaError so a 429 never triggers
76
+ // a spurious model switch.
77
+ function isRateLimitError(err) {
78
+ if (!err) return false;
79
+ const status = Number(err.status || err.code || err.error?.status || 0);
80
+ if (status === 429 || status === 529) return true;
81
+ const msg = String(err.message || err.error?.message || '');
82
+ return /rate.?limit|too many requests|overloaded/i.test(msg);
83
+ }
84
+
74
85
  // Quota/credits exhausted errors — the model is out of tokens (free tiers,
75
86
  // daily limits, billing caps). These are switch-to-another-model triggers.
87
+ // Rate-limit wording is deliberately NOT matched here (see isRateLimitError).
76
88
  function isQuotaError(err) {
77
89
  if (!err) return false;
78
90
  const status = Number(err.status || err.code || err.error?.status || 0);
79
91
  if (status === 402) return true;
80
- if (status === 429) return true;
81
92
  const msg = String(err.message || err.error?.message || err.body?.error?.message || '');
82
- return /quota|exhausted|insufficient.{0,20}(balance|credits|quota)|out of (tokens|credits|balance)|no (more )?(tokens|credits|balance)|rate.?limit|billing limit|payment required|max.?quota|limit reached/i.test(msg);
93
+ return /quota|exhausted|insufficient.{0,20}(balance|credits|quota)|out of (tokens|credits|balance)|no (more )?(tokens|credits|balance)|billing limit|payment required|max.?quota|limit reached/i.test(msg);
94
+ }
95
+
96
+ // Context-overflow errors — the prompt exceeded the model window. Compacting
97
+ // the conversation and retrying is the correct recovery (not a model switch).
98
+ function isContextOverflowError(err) {
99
+ if (!err) return false;
100
+ const msg = String(err.message || err.error?.message || err.body?.error?.message || '');
101
+ return /prompt is too long|context_length_exceeded|input length.{0,40}maximum length|request_too_large|too many tokens|maximum context length|reduce the length/i.test(msg);
83
102
  }
84
103
 
85
104
  class Session {
@@ -93,6 +112,8 @@ class Session {
93
112
  this.tokensUsed = 0;
94
113
  this.tokensIn = 0;
95
114
  this.tokensOut = 0;
115
+ this.cacheReadTokens = 0;
116
+ this.cacheWriteTokens = 0;
96
117
  this.sessionCost = 0;
97
118
  this.permissions = new PermissionManager();
98
119
  // OpenCode-style permission tree (config.permission + config.agent.<id>.permission).
@@ -218,6 +239,7 @@ class Session {
218
239
  const plat = require('./platform').detect();
219
240
  const shell = plat.platform === 'win32' ? 'PowerShell 5.1' : 'bash/zsh';
220
241
  const hasWSL = plat.isWSL ? ' (WSL)' : '';
242
+ const hasTools = this.mode === 'build' || this.mode === 'plan';
221
243
  return `You are Loom, a terminal coding agent: fast, terse, action-first.
222
244
 
223
245
  ## Environment
@@ -228,42 +250,43 @@ class Session {
228
250
 
229
251
  ## Memory (from LOOM.md)
230
252
  ${memory}
231
-
253
+ ${hasTools ? `
232
254
  When the user states a durable preference, correction, or project fact ("we use bun", "never touch X", "deploy via Y"), persist it yourself: append ONE dated bullet under the "## Remembered" heading of ./LOOM.md using the edit tool (create the heading if missing). Keep it terse; don't ask permission for obvious preferences. Don't store secrets or one-off task details.
233
-
255
+ ` : ''}${this.mode === 'build' ? `
234
256
  ## Skills
235
257
  ${this.loadSkills()}
236
-
258
+ ` : ''}
237
259
  ## Behavior
238
260
  - Decide before acting: if the answer needs no external data, reply directly with ZERO tool calls. Simple or conversational questions are answered in one message, nothing runs.
239
- - When tools ARE needed, act first and batch independent calls (e.g., read a file, grep a symbol, glob files at once). Never call tools speculatively or one at a time when they can be batched.
261
+ ${hasTools ? `- When tools ARE needed, act first and batch independent calls (e.g., read a file, grep a symbol, glob files at once). Never call tools speculatively or one at a time when they can be batched.
240
262
  - Never call a time/date tool or a sequential-thinking tool — today's date is already in Environment, and you never need to "think" via a tool.
241
263
  - Extended thinking is ON when your model supports it: reason BEFORE you act and re-evaluate AFTER every tool result. For complex or multi-step tasks, think hard at each stage (plan → act → check) instead of rushing to the answer — this is what separates a good result from a guessed one.
242
- - Be terse in the final reply: say what changed and nothing else.
264
+ ` : ''}- Be terse in the final reply: say what changed and nothing else.
243
265
  - Never narrate ("I will now read the file…"). Just call the tool.
244
- - Prefer edits over full-file writes when the change is small.
266
+ ${this.mode === 'build' ? `- Prefer edits over full-file writes when the change is small.
245
267
  - After writing code, run any test/bench command the user has relied on if one exists.
246
-
268
+ ` : ''}
247
269
  ## Formatting
248
270
  - ALWAYS reply in Markdown. The terminal renders it with colors, so make every reply structured and readable:
249
271
  - Use **bold** for key terms and important results, \`inline code\` for file paths, symbols and commands, and fenced \`\`\` blocks for any code or config.
250
272
  - Use ## headings and - bullet lists to organize longer replies; keep code inside fenced blocks, never inline dumps.
251
273
  - Even one-line answers should still use markdown inline styling where it helps (e.g. "Done — **rewrote** \`src/app.ts\`: \`+12 -4\` lines, tests **pass**.").
252
274
  - Never reply with a wall of plain unstyled text — the user's UI highlights markdown, and raw text renders as a single flat color.
253
- - Multi-step work MUST be tracked with the todowrite tool (the UI mirrors it live in a sidebar and in the chat): call it up front with the task list, update item statuses (pending/in_progress/completed/cancelled) as you work, and call it one last time when done. Keep the list items terse ("Fix signup bug", not prose).
254
-
275
+ ${this.mode === 'build' ? `- Multi-step work MUST be tracked with the todowrite tool (the UI mirrors it live in a sidebar and in the chat): call it up front with the task list, update item statuses (pending/in_progress/completed/cancelled) as you work, and call it one last time when done. Keep the list items terse ("Fix signup bug", not prose).
276
+ ` : ''}
255
277
  ## Hard rules
256
- - Batch independent tool calls into a single response; never send one tool call at a time.
278
+ ${hasTools ? `- Batch independent tool calls into a single response; never send one tool call at a time.
257
279
  - You may only issue multiple tool calls if none of them depends on another's result.
258
280
  - Never guess a tool result — read it before your next call.
259
- - Never commit/push without explicit instruction. Never print API keys.
260
- - When a tool FAILS, do not retry the same call with the same arguments. After the first failure, change the approach: e.g. use read/grep to understand the state before trying an edit again, run a simpler variant of the command, or ask the user for the missing input (API key, path, permission). Three same-goal failures == stop and escalate: say what failed and suggest the fix instead of looping.
261
- - For long outputs (links, large files), stream whatever the tool gave you in one message — do not emit partial/truncated responses piecemeal.
262
-
281
+ ` : ''}- Never commit/push without explicit instruction. Never print API keys.
282
+ ${hasTools ? `- When a tool FAILS, do not retry the same call with the same arguments. After the first failure, change the approach: e.g. use read/grep to understand the state before trying an edit again, run a simpler variant of the command, or ask the user for the missing input (API key, path, permission). Three same-goal failures == stop and escalate: say what failed and suggest the fix instead of looping.
283
+ ` : ''}
284
+ ${this.mode === 'plan' ? `- For long outputs (links, large files), stream whatever the tool gave you in one message — do not emit partial/truncated responses piecemeal.
285
+ ` : ''}
263
286
  ## Mode
264
287
  You are in ${this.mode === 'build' ? 'BUILD MODE (full agent)' : this.mode === 'plan' ? 'PLAN MODE (read-only analysis)' : 'CHAT MODE (conversation only)'}.
265
288
  ${this.mode === 'build' ? `- You have all tools available: read, write, edit, bash, glob, grep, webfetch, and MCP tools.
266
- - bash runs on the user's machine. Only use the sandbox tool when isolation is genuinely needed (the user asked for it, or the command is risky/Linux-only); never for ordinary commands.
289
+ - bash runs on the user's machine. When isolation is genuinely needed (the user asked for the sandbox, or the command is risky/Linux-only), run it INSIDE Docker instead: the \`sandbox\` tool, or bash with sandbox:true — both execute via \`docker exec loom-sandbox sh -lc "<command>"\`. Never run a sandbox-intended command on the host, and never for ordinary commands.
267
290
  - Inspect, edit, and verify the result yourself end-to-end.
268
291
  - When done, summarize in 1-2 sentences.` : ''}${this.mode === 'plan' ? `- Read-only tools only: read, glob, grep, webfetch, todowrite. No MCP tools.
269
292
  - Investigate thoroughly, then output a "## Plan" with ordered steps (exact file path + what changes each step makes). No narration.` : ''}${this.mode === 'chat' ? `- No tools available; answer conversationally.
@@ -283,6 +306,19 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
283
306
  return '\n\n## Acting as the "' + agent.name + '" agent\n' + agent.prompt + '\n';
284
307
  }
285
308
 
309
+ // Dynamic Docker-sandbox line, resolved lazily per turn (like _primaryBlock)
310
+ // so /sandbox setup / on / off apply to the NEXT turn without rebuilding the
311
+ // base prompt. Config-only read — never spawns docker (no TUI freezes).
312
+ _sandboxBlock() {
313
+ if (this.mode !== 'build') return '';
314
+ let ready = false;
315
+ try { ready = require('./sandbox').shouldUseSandbox(); } catch { return ''; }
316
+ if (!ready) return '';
317
+ return '\n\n## Sandbox (Docker, ready)\n'
318
+ + '- The loom-sandbox container is set up. When the user asks to run something "in the sandbox", or isolation is genuinely needed, run the command through `docker exec loom-sandbox sh -lc "<command>"` (workdir /workspace) — i.e. the `sandbox` tool, or bash with sandbox:true. NEVER run a sandbox-intended command directly on the host instead.\n'
319
+ + '- Ordinary commands stay on the host via plain bash (or bash with sandbox omitted).\n';
320
+ }
321
+
286
322
  // Create ./LOOM.md with a template when missing, so memory exists from the
287
323
  // first turn (Claude Code behavior). Disable with LOOM_MEM_AUTO=0 (tests).
288
324
  ensureMemoryFile() {
@@ -325,14 +361,53 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
325
361
  if (!skills.length) return '(No skills installed. Run /skills to see options.)';
326
362
  // Compact index only — full instructions are injected per-turn by the
327
363
  // skill matcher when the user's message matches a skill's keywords.
364
+ // Descriptions are capped so one verbose skill can't bloat the system
365
+ // prompt (and its cache prefix) on every turn.
328
366
  return skills
329
- .map((s) => `- ${s.name} (${s.source}): ${s.description || 'no description'}`)
367
+ .map((s) => {
368
+ let d = s.description || 'no description';
369
+ if (d.length > 80) d = d.slice(0, 77).trimEnd() + '…';
370
+ return `- ${s.name}: ${d}`;
371
+ })
330
372
  .join('\n');
331
373
  } catch (e) {
332
374
  return '(Skills unavailable: ' + e.message + ')';
333
375
  }
334
376
  }
335
377
 
378
+ // Cache-stable system prompt: the per-turn skill block is the ONLY part of
379
+ // the system prefix that changes between turns. Keeping it out of the system
380
+ // string (injected into the last user message instead) means Anthropic's
381
+ // system-prompt cache_control breakpoint stays byte-identical across turns
382
+ // even when a different skill matches, so cache reads keep hitting.
383
+ // Resolves the lazy primary/sandbox blocks once and caches them on
384
+ // _resolvedPrimary/_resolvedSandbox so estimateTokens sees the same strings
385
+ // that go out on the request.
386
+ _systemPrefix() {
387
+ this._resolvedPrimary = this._primaryBlock();
388
+ this._resolvedSandbox = this._sandboxBlock();
389
+ return this.systemPrompt + (this._agentBlock || '') + this._resolvedPrimary + this._resolvedSandbox;
390
+ }
391
+
392
+ // Older tool results are elided to a head+tail slice before they reach the
393
+ // provider. Kept in-memory verbatim for the TUI; only the payload shrinks.
394
+ // Keep the most recent TOOL_RESULT_RECENT messages untouched so the cache
395
+ // prefix and the model's working set stay stable.
396
+ ageHistory(messages) {
397
+ const AGE_HEAD = 2000, AGE_TAIL = 400, RECENT = 12;
398
+ if (!messages || messages.length <= RECENT) return messages;
399
+ const cutoff = messages.length - RECENT;
400
+ return messages.map((m, i) => {
401
+ if (i >= cutoff || m.role !== 'tool') return m;
402
+ const text = typeof m.content === 'string' ? m.content : (m.content == null ? '' : JSON.stringify(m.content));
403
+ if (text.length <= AGE_HEAD + AGE_TAIL + 60) return m;
404
+ return Object.assign({}, m, {
405
+ content: text.slice(0, AGE_HEAD) + '\n[…older tool result elided — re-read the file if still needed…]\n' + text.slice(-AGE_TAIL),
406
+ _aged: true,
407
+ });
408
+ });
409
+ }
410
+
336
411
  interrupt() {
337
412
  this.interrupted = true;
338
413
  if (this.abortController) this.abortController.abort();
@@ -368,6 +443,9 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
368
443
  // trigger a refresh. Re-reading disk and re-init'ing the provider on every
369
444
  // message was noticeable overhead per turn.
370
445
  this.interrupted = false;
446
+ // Restored/compacted sessions may carry a history whose first row isn't
447
+ // a user message — Anthropic rejects that shape. Normalize once here.
448
+ this._normalizeHistory();
371
449
 
372
450
  // Skill auto-trigger: if the user's message mentions a skill keyword
373
451
  // (e.g. "slice", "gcode", "cad"), load the matched skill's instructions
@@ -392,6 +470,18 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
392
470
  this._skillBlock = '';
393
471
  }
394
472
 
473
+ // userPromptSubmit hook: may veto the prompt outright, or inject extra
474
+ // context (lint output, ticket data) appended to the user's message.
475
+ try {
476
+ const gate = await hooks.runHook('userPromptSubmit', { prompt: text });
477
+ if (gate.blocked) {
478
+ return { type: 'error', content: 'Prompt blocked by userPromptSubmit hook.' + (gate.reason ? ' ' + gate.reason : '') };
479
+ }
480
+ if (gate.additionalContext) {
481
+ text = text + '\n\n<user-prompt-submit-hook>\n' + gate.additionalContext + '\n</user-prompt-submit-hook>';
482
+ }
483
+ } catch {}
484
+
395
485
  this.addMessage({ role: 'user', content: text });
396
486
  this.turnCount++;
397
487
  const turnAgent = this.agent?.id || null;
@@ -468,13 +558,24 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
468
558
 
469
559
  // ─── Compaction ───
470
560
  // Rough token estimate from message text (chars/4) — used to decide when
471
- // the context window is getting full.
561
+ // the context window is getting full. Includes the system prompt and the
562
+ // serialized tool schemas: with many MCP tools those can be tens of
563
+ // thousands of tokens that a messages-only estimate ignores.
472
564
  estimateTokens() {
473
565
  let chars = 0;
474
566
  for (const m of this.messages) {
475
567
  chars += String(m.content || '').length;
476
568
  for (const tc of m.toolCalls || []) chars += JSON.stringify(tc.input || {}).length;
477
569
  }
570
+ chars += String(this.systemPrompt || '').length;
571
+ chars += this._toolSchemaChars || 0;
572
+ // Per-turn system-prompt suffixes (agent/primary/sandbox) + the skill
573
+ // block ride on top of this.systemPrompt — include them or the estimate
574
+ // under-counts exactly the parts that grow the request.
575
+ chars += (this._agentBlock || '').length;
576
+ chars += (this._resolvedPrimary || '').length;
577
+ chars += (this._resolvedSandbox || '').length;
578
+ chars += (this._skillBlock || '').length;
478
579
  return Math.ceil(chars / 4);
479
580
  }
480
581
 
@@ -483,15 +584,39 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
483
584
  return meta?.context || 200000;
484
585
  }
485
586
 
587
+ // Live context estimate for the /context TUI command: tokens (messages +
588
+ // system prompt + tool schemas), window size, and percent used. Recomputed
589
+ // on every call so it reflects the CURRENT conversation, not a startup
590
+ // snapshot.
591
+ contextEstimate() {
592
+ const tokens = this.estimateTokens();
593
+ const window = this.getContextWindow();
594
+ return { tokens, window, pct: Math.min(100, Math.round((tokens / window) * 100)) };
595
+ }
596
+
486
597
  shouldCompact() {
487
598
  if (this.messages.length < COMPACT_MIN_MESSAGES) return false;
488
599
  const threshold = this.config.compactThreshold ?? COMPACT_DEFAULT_THRESHOLD;
489
600
  return this.estimateTokens() > this.getContextWindow() * threshold;
490
601
  }
491
602
 
603
+ // Ensure a compacted/resumed history starts with a user message — Anthropic
604
+ // rejects conversations whose first row is assistant/tool. Called after
605
+ // compaction and at the start of each user turn (covers restored sessions).
606
+ _normalizeHistory() {
607
+ if (!this.messages.length || this.messages[0].role === 'user') return;
608
+ let start = 0;
609
+ while (start < this.messages.length && this.messages[start].role !== 'user') start++;
610
+ if (start >= this.messages.length) return; // no user row — leave alone
611
+ this.messages = this.messages.slice(start);
612
+ }
613
+
492
614
  // Real compaction: summarize the older messages with the model and keep the
493
615
  // most recent COMPACT_KEEP_MESSAGES verbatim. Falls back to truncation when
494
- // no provider/key is available or the summary call fails.
616
+ // no provider/key is available or the summary call fails. The summary is
617
+ // stored as a USER-role row (not system): openai-compat drops history rows
618
+ // with role 'system', which silently discarded the summary on every
619
+ // non-Anthropic provider.
495
620
  async compact(callbacks = {}) {
496
621
  if (this.messages.length <= COMPACT_KEEP_MESSAGES + 2) {
497
622
  return { compacted: false, reason: 'conversation too short', removed: 0, method: 'none' };
@@ -523,9 +648,10 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
523
648
 
524
649
  if (summary) {
525
650
  this.messages = [
526
- { role: 'system', content: '[Compacted — earlier conversation summarized. Ask for details if you need specifics.]\n\n' + summary.slice(0, 8000) },
651
+ { role: 'user', content: '[Compacted — earlier conversation summarized. Ask for details if you need specifics.]\n\n' + summary.slice(0, 8000) },
527
652
  ...keep,
528
653
  ];
654
+ this._normalizeHistory();
529
655
  this.compactCount = (this.compactCount || 0) + 1;
530
656
  this.lastCompact = { at: Date.now(), method: 'summary', removed: head.length };
531
657
  if (callbacks.onCompact) callbacks.onCompact({ method: 'summary', removed: head.length, summary });
@@ -534,9 +660,10 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
534
660
 
535
661
  // Fallback: drop the oldest messages, keep the recent tail verbatim.
536
662
  this.messages = [
537
- { role: 'system', content: `[Compacted — ${head.length} earlier messages truncated. Ask for details if you need specifics.]` },
663
+ { role: 'user', content: `[Compacted — ${head.length} earlier messages truncated. Ask for details if you need specifics.]` },
538
664
  ...keep,
539
665
  ];
666
+ this._normalizeHistory();
540
667
  this.compactCount = (this.compactCount || 0) + 1;
541
668
  this.lastCompact = { at: Date.now(), method: 'truncate', removed: head.length };
542
669
  if (callbacks.onCompact) callbacks.onCompact({ method: 'truncate', removed: head.length, summary: null });
@@ -569,7 +696,16 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
569
696
  if (!speed._firstTokenAt) speed._firstTokenAt = now;
570
697
  if (callbacks.onDelta) callbacks.onDelta(txt);
571
698
  }, onReasoning: (txt) => {
699
+ const now = Date.now();
572
700
  reasoning += txt;
701
+ // Thinking tokens are still model output: count them toward the
702
+ // live gauge so first-token latency ticks from the first THINKING
703
+ // token. Otherwise the sidebar reads "waiting…" for the entire
704
+ // thinking phase (measured: ~7s to first thinking delta, then +16s
705
+ // of thought before any visible text on nemotron-3-super) and the
706
+ // response looks hung while the model is actually working.
707
+ speed._liveTokens += txt.length / 4;
708
+ if (!speed._firstTokenAt) speed._firstTokenAt = now;
573
709
  if (callbacks.onReasoning) callbacks.onReasoning(txt);
574
710
  } }
575
711
  : callbacks;
@@ -608,21 +744,55 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
608
744
  let todowroteThisTurn = false;
609
745
  // Transient provider-error retry state.
610
746
  let providerRetries = 0;
747
+ // Reactive compaction already fired this turn (context-overflow 400).
748
+ let compactedThisTurn = false;
749
+ // Mid-turn compaction already fired this turn.
750
+ let compactedMidTurn = false;
611
751
  while (true) {
612
752
  if (this.interrupted) {
613
753
  return finishInterrupted();
614
754
  }
615
755
 
756
+ // Mid-turn compaction: a long tool loop can fill the window well past
757
+ // the threshold — compact before the next model call instead of dying
758
+ // with a context-overflow error.
759
+ if (ranTools && !compactedMidTurn && !this.interrupted && this.shouldCompact()) {
760
+ compactedMidTurn = true;
761
+ try {
762
+ const res = await this.compact(callbacks);
763
+ if (res.compacted && callbacks.onAutoCompact) callbacks.onAutoCompact(res);
764
+ } catch {}
765
+ }
766
+
616
767
  let resp;
617
768
  speed._turnStart = Date.now();
618
769
  speed._firstTokenAt = 0;
619
770
  speed._liveTokens = 0;
620
771
  try {
621
772
  resp = await this.getResponse(cb);
773
+ // Success: reset the transient-retry budget so one slow tool step
774
+ // doesn't consume a retry reserved for real failures.
775
+ providerRetries = 0;
622
776
  } catch (err) {
623
777
  if (isAbortError(err)) {
624
778
  return finishInterrupted();
625
779
  }
780
+ // Rate limit / overload: WAIT and retry (honor Retry-After when the
781
+ // provider sent one). Model switching on a 429 wastes the wait — the
782
+ // same limit usually applies across the account.
783
+ if (isRateLimitError(err)) {
784
+ if (providerRetries < PROVIDER_RETRIES) {
785
+ providerRetries++;
786
+ const waitMs = Math.min(
787
+ (err && err.retryAfterMs) || 1000 * Math.pow(2, providerRetries - 1),
788
+ 30000
789
+ );
790
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
791
+ if (this.interrupted) return finishInterrupted();
792
+ continue;
793
+ }
794
+ // Waited enough — fall through to a model switch as a last resort.
795
+ }
626
796
  // Model ran out of tokens → auto-switch to another model and retry once.
627
797
  if (isQuotaError(err)) {
628
798
  const fromKey = (this.provider.active?.name || '?') + '/' + (this.config.model?.[this.provider.active?.name] || '?');
@@ -634,6 +804,19 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
634
804
  continue;
635
805
  }
636
806
  }
807
+ // Context overflow (prompt too long): compact and retry once per
808
+ // turn. This is the recovery path when the auto-compact threshold
809
+ // was missed (huge tool results, MCP schema growth mid-session).
810
+ if (isContextOverflowError(err) && !compactedThisTurn && this.messages.length > 10) {
811
+ compactedThisTurn = true;
812
+ try {
813
+ const res = await this.compact(callbacks);
814
+ if (res.compacted) {
815
+ if (callbacks.onAutoCompact) callbacks.onAutoCompact(res);
816
+ continue;
817
+ }
818
+ } catch {}
819
+ }
637
820
  // Transient provider failure (network blip, 5xx, stream reset): retry
638
821
  // the model call a couple of times instead of killing the turn
639
822
  // mid-task. No tools ran for this iteration, so a retry is side-effect
@@ -664,7 +847,7 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
664
847
  // Finalize the speed snapshot for this model call (or tool step).
665
848
  if (resp && !resp.interrupted) {
666
849
  const dur = Date.now() - speed._turnStart;
667
- const tokens = (streamed.length || String(resp.content || '').length) / 4;
850
+ const tokens = ((streamed + reasoning).length || String(resp.content || '').length) / 4;
668
851
  speed.lastDurationMs = dur;
669
852
  speed.lastTokens = Math.round(tokens);
670
853
  speed.lastTokensPerSec = dur > 0 ? Math.round((tokens / dur) * 1000) : 0;
@@ -754,7 +937,7 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
754
937
  : [];
755
938
  // Safety net: after all continuation budgets are spent, a
756
939
  // finish_reason=length reply still gets the explanatory placeholder
757
- // instead of mystery silence.
940
+ // instead of mystery silence.
758
941
  const truncHint = resp.finishReason === 'length'
759
942
  ? '(the reply hit the max-tokens limit mid-stream — the model ran out of output budget before writing any text. Raise maxTokens in config, or /think off to free the thinking budget)'
760
943
  : '(the model ended the turn without any text)';
@@ -804,11 +987,15 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
804
987
  // results flow through the TUI popup; doom_loop/external_directory
805
988
  // are checked separately where they apply.
806
989
  const permKey = { read:'read', edit:'edit', write:'edit', glob:'glob', grep:'grep', bash:'bash',
807
- task:'task', skill:'skill', lsp:'lsp', question:'question', ask:'question', webfetch:'webfetch',
808
- websearch:'websearch', browser:'browser' }[tc.name];
990
+ sandbox:'bash', task:'task', skill:'skill', lsp:'lsp', question:'question', ask:'question', webfetch:'webfetch',
991
+ websearch:'websearch', browser:'browser', todoread:'read' }[tc.name]
992
+ || (tc.name.indexOf('mcp__') === 0 ? 'mcp' : undefined);
809
993
  const isMcpAdd = tc.name === 'mcp' && tc.input && tc.input.action === 'add';
810
994
  if (permKey || isMcpAdd) {
811
- const arg = this.permissions.permissionArg(tc.name, tc.input || {});
995
+ // MCP tool calls are gated by the 'mcp' permission key with the
996
+ // FULL tool name as the pattern arg (mcp__github__search), so the
997
+ // user can allow exactly one server/tool.
998
+ const arg = permKey === 'mcp' ? tc.name : this.permissions.permissionArg(tc.name, tc.input || {});
812
999
  let action = this.permissions.resolve(tc.name, arg);
813
1000
  // mcp add spawns arbitrary stdio servers with optional env secrets —
814
1001
  // always ask unless the user saved a rule for the exact command.
@@ -850,7 +1037,7 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
850
1037
  // The permission gate above is the interactive check; mark the command
851
1038
  // as approved so the tool-layer safety filter doesn't double-block
852
1039
  // commands the user explicitly allowed.
853
- const input = (tc.name === 'bash' || isMcpAdd) ? { ...tc.input, _approved: true } : tc.input;
1040
+ const input = (tc.name === 'bash' || tc.name === 'sandbox' || isMcpAdd) ? { ...tc.input, _approved: true } : tc.input;
854
1041
  // preToolUse hook: a user-configured command may veto this call.
855
1042
  try {
856
1043
  const gate = await hooks.runHook('preToolUse', { tool: tc.name, input: tc.input });
@@ -870,14 +1057,21 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
870
1057
  try { if (callbacks.onToolOutput) callbacks.onToolOutput(tc, chunk, kind); } catch {}
871
1058
  },
872
1059
  });
873
- // postToolUse hook (informational — never blocks).
874
- try { await hooks.runHook('postToolUse', { tool: tc.name, input: tc.input }); } catch {}
1060
+ // postToolUse hook (informational — never blocks). tool_response lets
1061
+ // audit hooks see what the tool actually returned (truncated to keep
1062
+ // the hook's stdin small).
1063
+ try {
1064
+ const respText = outcome.error ? `Error: ${outcome.error}` : String(outcome.result ?? '');
1065
+ await hooks.runHook('postToolUse', { tool: tc.name, input: tc.input, tool_response: respText.slice(0, 4000) });
1066
+ } catch {}
875
1067
  return { tc, outcome };
876
1068
  }));
877
1069
 
878
1070
  for (const { tc, outcome } of outcomes) {
879
1071
  const text = outcome.error ? `Error: ${outcome.error}` : String(outcome.result ?? '');
880
- this.addMessage({ role: 'tool', toolCallId: tc.id, content: text });
1072
+ // isError marks failed tool calls so Anthropic tool_results carry
1073
+ // is_error:true (the model treats the text as an error report).
1074
+ this.addMessage({ role: 'tool', toolCallId: tc.id, content: text, isError: !!outcome.error });
881
1075
  // Persist real todo state when the model uses the todowrite tool.
882
1076
  if (tc.name === 'todowrite' && !outcome.error) {
883
1077
  todowroteThisTurn = true;
@@ -895,6 +1089,9 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
895
1089
  const tools = await getAllToolDefinitions(this.mode);
896
1090
  // Agent-scoped turns (delegation) see only their agent's tool list.
897
1091
  const toolDefs = this.agent ? filterToolDefs(this.agent, tools) : tools;
1092
+ // Track schema size for estimateTokens — many MCP tools can add tens of
1093
+ // thousands of tokens that a messages-only estimate ignores.
1094
+ this._toolSchemaChars = toolDefs && toolDefs.length ? JSON.stringify(toolDefs).length : 0;
898
1095
 
899
1096
  // Budget router: when a level is active (free/cheap/best), pick the model
900
1097
  // for THIS call without rewriting the user's saved provider/model. "Free"
@@ -952,7 +1149,7 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
952
1149
  maxTokens: this.config.maxTokens || 8192,
953
1150
  temperature: this.config.temperature ?? 0.7,
954
1151
  tools: toolDefs,
955
- system: this.systemPrompt + (this._skillBlock || '') + (this._agentBlock || '') + this._primaryBlock(),
1152
+ system: this._systemPrefix(),
956
1153
  signal: this.abortController?.signal,
957
1154
  // Extended thinking: models tagged 'reasoning' reason BEFORE every
958
1155
  // reply — including after each tool result — so complex tasks get
@@ -971,11 +1168,28 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
971
1168
  this.abortController = new AbortController();
972
1169
  opts.signal = this.abortController.signal;
973
1170
 
1171
+ // Inject the per-turn skill block into the LAST user message of a wire
1172
+ // copy (never this.messages) — keeps the system prompt byte-stable for
1173
+ // prompt caching while still steering this one turn. Then age older tool
1174
+ // results so a huge read/grep output from 20 turns ago stops costing
1175
+ // full input price on every later call.
1176
+ let wire = this.messages;
1177
+ if (this._skillBlock) {
1178
+ wire = wire.slice();
1179
+ for (let i = wire.length - 1; i >= 0; i--) {
1180
+ if (wire[i].role === 'user') {
1181
+ wire[i] = Object.assign({}, wire[i], { content: String(wire[i].content || '') + this._skillBlock });
1182
+ break;
1183
+ }
1184
+ }
1185
+ }
1186
+ wire = this.ageHistory(wire);
1187
+
974
1188
  let resp;
975
1189
  if (callbacks.onDelta || callbacks.onReasoning) {
976
- resp = await provider.stream(this.messages, opts, callbacks.onDelta, callbacks.onReasoning);
1190
+ resp = await provider.stream(wire, opts, callbacks.onDelta, callbacks.onReasoning);
977
1191
  } else {
978
- resp = await provider.chat(this.messages, opts);
1192
+ resp = await provider.chat(wire, opts);
979
1193
  }
980
1194
 
981
1195
  if (this.interrupted || (opts.signal && opts.signal.aborted)) {
@@ -993,22 +1207,41 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
993
1207
 
994
1208
  // Normalize provider usage (OpenAI: prompt_tokens/completion_tokens, Anthropic: input_tokens/output_tokens)
995
1209
  // into input/output counts, then add them to the session counters and the persistent lifetime tracker.
1210
+ // Prompt-cache hits/writes are priced at ~0.1x/~1.25x the input rate (Anthropic's
1211
+ // published cache rates; OpenAI/OpenRouter use the same 0.1x read discount).
1212
+ // Anthropic's input_tokens EXCLUDES cached tokens (they arrive as separate
1213
+ // cache_read_input_tokens/cache_creation_input_tokens); OpenAI/OpenRouter's
1214
+ // prompt_tokens_details.cached_tokens is a SUBSET of prompt_tokens.
996
1215
  recordUsage(usage, modelId, providerName) {
997
1216
  if (!usage) return;
998
- const input = usage.prompt_tokens || usage.input_tokens || usage.promptTokenCount || 0;
1217
+ const rawIn = usage.prompt_tokens || usage.input_tokens || usage.promptTokenCount || 0;
999
1218
  const output = usage.completion_tokens || usage.output_tokens || usage.candidatesTokenCount || 0;
1219
+ const cacheRead = usage.cache_read_input_tokens
1220
+ || usage.prompt_tokens_details?.cached_tokens
1221
+ || usage.cachedContentTokenCount
1222
+ || 0;
1223
+ const cacheWrite = usage.cache_creation_input_tokens || 0;
1224
+ // Anthropic input excludes cache tokens; OpenAI input includes the cached subset.
1225
+ const input = providerName === 'anthropic' ? rawIn + cacheRead + cacheWrite : rawIn;
1226
+ const uncachedIn = providerName === 'anthropic' ? rawIn : Math.max(0, rawIn - cacheRead - cacheWrite);
1000
1227
  const total = usage.total_tokens || usage.totalTokens || (input + output);
1001
1228
  if (!input && !output && !total) return;
1002
1229
 
1003
1230
  this.tokensIn += input;
1004
1231
  this.tokensOut += output;
1005
1232
  this.tokensUsed += total;
1233
+ this.cacheReadTokens = (this.cacheReadTokens || 0) + cacheRead;
1234
+ this.cacheWriteTokens = (this.cacheWriteTokens || 0) + cacheWrite;
1006
1235
 
1007
1236
  const { getModelMeta } = require('../providers');
1008
1237
  const meta = getModelMeta(providerName, modelId);
1009
1238
  let cost = 0;
1010
1239
  if (meta && (meta.priceIn || meta.priceOut)) {
1011
- cost = (input / 1e6) * (meta.priceIn || 0) + (output / 1e6) * (meta.priceOut || 0);
1240
+ const priceIn = meta.priceIn || 0;
1241
+ cost = (uncachedIn / 1e6) * priceIn
1242
+ + (cacheRead / 1e6) * priceIn * 0.1
1243
+ + (cacheWrite / 1e6) * priceIn * 1.25
1244
+ + (output / 1e6) * (meta.priceOut || 0);
1012
1245
  this.sessionCost += cost;
1013
1246
  }
1014
1247
  const { recordUsage: persist } = require('./usage');
@@ -1048,6 +1281,8 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
1048
1281
  this.tokensUsed = 0;
1049
1282
  this.tokensIn = 0;
1050
1283
  this.tokensOut = 0;
1284
+ this.cacheReadTokens = 0;
1285
+ this.cacheWriteTokens = 0;
1051
1286
  this.sessionCost = 0;
1052
1287
  this.todos = [];
1053
1288
  this.compactCount = 0;
@@ -1057,4 +1292,4 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
1057
1292
  }
1058
1293
  }
1059
1294
 
1060
- module.exports = { Session, MEMORY_TEMPLATE, isQuotaError, isAbortError };
1295
+ module.exports = { Session, MEMORY_TEMPLATE, isQuotaError, isAbortError, isRateLimitError, isContextOverflowError };
@@ -0,0 +1,74 @@
1
+ // Transcript undo/redo — the conversation-level counterpart to /restore
2
+ // (which rewinds the FILE TREE). Each undoable step is one user turn: the
3
+ // full transcript (UI messages + session messages + todos) captured right
4
+ // before the turn ran, plus the restore-point id created for that same turn
5
+ // so an undo can also offer to roll the working tree back.
6
+ //
7
+ // Pure engine, no TUI/reactivity — the App owns the stacks and passes plain
8
+ // snapshots in, which keeps this unit-testable.
9
+
10
+ const MAX_UNDO = 50;
11
+
12
+ // A snapshot is an opaque object the caller builds (it knows how to clone its
13
+ // own UI + session state). We only ever store and hand back references.
14
+ // shape: { ui: any[], session: any[], todos: any[], restoreId?: string, label?: string }
15
+
16
+ function cloneSnap(snap) {
17
+ // structuredClone is available on Node 18+/Bun; fall back to JSON for the
18
+ // plain-JSON session data (never holds functions or DOM handles).
19
+ if (typeof structuredClone === 'function') {
20
+ try { return structuredClone(snap); } catch {}
21
+ }
22
+ return JSON.parse(JSON.stringify(snap));
23
+ }
24
+
25
+ class TranscriptUndo {
26
+ constructor(max = MAX_UNDO) {
27
+ this.max = max;
28
+ this.undoStack = [];
29
+ this.redoStack = [];
30
+ }
31
+
32
+ // Record the state as it was BEFORE a turn ran. Clears the redo stack (a
33
+ // fresh action diverges history, like any editor). Returns the step object
34
+ // so callers can attach late-bound data (e.g. a restore-point id created in
35
+ // a background setTimeout) without relying on a fragile stack index.
36
+ push(beforeSnap, label) {
37
+ const step = cloneSnap(beforeSnap);
38
+ step.label = label || '';
39
+ this.undoStack.push(step);
40
+ if (this.undoStack.length > this.max) this.undoStack.shift();
41
+ // A new action invalidates the future.
42
+ this.redoStack.length = 0;
43
+ return step;
44
+ }
45
+
46
+ // Move one turn backward. `currentSnap` is the state we are leaving — it
47
+ // goes onto the redo stack so the undo can be re-done. Returns the snapshot
48
+ // to restore, or null when there is nothing to undo.
49
+ undo(currentSnap) {
50
+ if (!this.undoStack.length) return null;
51
+ const target = this.undoStack.pop();
52
+ if (currentSnap) this.redoStack.push(cloneSnap(currentSnap));
53
+ if (this.redoStack.length > this.max) this.redoStack.shift();
54
+ return target;
55
+ }
56
+
57
+ // Move one turn forward again. Returns the snapshot to restore, or null.
58
+ redo(currentSnap) {
59
+ if (!this.redoStack.length) return null;
60
+ const target = this.redoStack.pop();
61
+ if (currentSnap) this.undoStack.push(cloneSnap(currentSnap));
62
+ if (this.undoStack.length > this.max) this.undoStack.shift();
63
+ return target;
64
+ }
65
+
66
+ canUndo() { return this.undoStack.length > 0; }
67
+ canRedo() { return this.redoStack.length > 0; }
68
+ peekUndo() { return this.undoStack[this.undoStack.length - 1] || null; }
69
+ peekRedo() { return this.redoStack[this.redoStack.length - 1] || null; }
70
+ depth() { return { undo: this.undoStack.length, redo: this.redoStack.length }; }
71
+ clear() { this.undoStack.length = 0; this.redoStack.length = 0; }
72
+ }
73
+
74
+ module.exports = { TranscriptUndo, MAX_UNDO };