@pentoshi/clai 3.8.20 → 3.8.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 (144) hide show
  1. package/README.md +8 -5
  2. package/dist/agent/context-breakdown.d.ts +37 -0
  3. package/dist/agent/context-breakdown.js +145 -0
  4. package/dist/agent/context-breakdown.js.map +1 -0
  5. package/dist/agent/reliability-policy.d.ts +85 -0
  6. package/dist/agent/reliability-policy.js +158 -0
  7. package/dist/agent/reliability-policy.js.map +1 -0
  8. package/dist/agent/runner.js +235 -136
  9. package/dist/agent/runner.js.map +1 -1
  10. package/dist/agent/session-state.d.ts +11 -1
  11. package/dist/agent/session-state.js +22 -15
  12. package/dist/agent/session-state.js.map +1 -1
  13. package/dist/agent/task-evidence.js +13 -0
  14. package/dist/agent/task-evidence.js.map +1 -1
  15. package/dist/agent/tool-history.d.ts +12 -0
  16. package/dist/agent/tool-history.js +159 -0
  17. package/dist/agent/tool-history.js.map +1 -1
  18. package/dist/agent/tool-output-formatting.d.ts +4 -0
  19. package/dist/agent/tool-output-formatting.js +13 -4
  20. package/dist/agent/tool-output-formatting.js.map +1 -1
  21. package/dist/app/controllers/session-controller.d.ts +6 -0
  22. package/dist/app/controllers/session-controller.js +21 -1
  23. package/dist/app/controllers/session-controller.js.map +1 -1
  24. package/dist/commands/providers.js +57 -26
  25. package/dist/commands/providers.js.map +1 -1
  26. package/dist/index.js +3 -3
  27. package/dist/index.js.map +1 -1
  28. package/dist/llm/gemini.js +4 -1
  29. package/dist/llm/gemini.js.map +1 -1
  30. package/dist/llm/key-rotation.d.ts +52 -0
  31. package/dist/llm/key-rotation.js +122 -0
  32. package/dist/llm/key-rotation.js.map +1 -0
  33. package/dist/llm/provider.d.ts +2 -0
  34. package/dist/llm/provider.js +8 -0
  35. package/dist/llm/provider.js.map +1 -1
  36. package/dist/llm/router.d.ts +15 -2
  37. package/dist/llm/router.js +318 -125
  38. package/dist/llm/router.js.map +1 -1
  39. package/dist/prompts/embedded.js +1 -1
  40. package/dist/prompts/embedded.js.map +1 -1
  41. package/dist/prompts/index.d.ts +25 -0
  42. package/dist/prompts/index.js +55 -7
  43. package/dist/prompts/index.js.map +1 -1
  44. package/dist/prompts/system.agent.md +1 -1
  45. package/dist/repl/slash-commands.js +14 -8
  46. package/dist/repl/slash-commands.js.map +1 -1
  47. package/dist/repl.d.ts +4 -1
  48. package/dist/repl.js +12 -2
  49. package/dist/repl.js.map +1 -1
  50. package/dist/store/config.d.ts +18 -0
  51. package/dist/store/config.js +9 -0
  52. package/dist/store/config.js.map +1 -1
  53. package/dist/store/history.d.ts +8 -0
  54. package/dist/store/history.js +38 -0
  55. package/dist/store/history.js.map +1 -1
  56. package/dist/store/keys.d.ts +55 -0
  57. package/dist/store/keys.js +226 -21
  58. package/dist/store/keys.js.map +1 -1
  59. package/dist/store/logs.js +35 -12
  60. package/dist/store/logs.js.map +1 -1
  61. package/dist/store/paths.d.ts +13 -0
  62. package/dist/store/paths.js +23 -1
  63. package/dist/store/paths.js.map +1 -1
  64. package/dist/store/session-workspace.d.ts +79 -0
  65. package/dist/store/session-workspace.js +199 -0
  66. package/dist/store/session-workspace.js.map +1 -0
  67. package/dist/tools/definitions.js +9 -1
  68. package/dist/tools/definitions.js.map +1 -1
  69. package/dist/tools/http.d.ts +8 -0
  70. package/dist/tools/http.js +86 -2
  71. package/dist/tools/http.js.map +1 -1
  72. package/dist/tools/pentest-workflows.js +38 -19
  73. package/dist/tools/pentest-workflows.js.map +1 -1
  74. package/dist/tools/registry.js +25 -2
  75. package/dist/tools/registry.js.map +1 -1
  76. package/dist/tools/shell.d.ts +1 -1
  77. package/dist/tui/format-keys.js +29 -6
  78. package/dist/tui/format-keys.js.map +1 -1
  79. package/dist/tui-v2/app/command-handlers.js +1 -2
  80. package/dist/tui-v2/app/command-handlers.js.map +1 -1
  81. package/dist/tui-v2/app/commands/config-commands.d.ts +1 -2
  82. package/dist/tui-v2/app/commands/config-commands.js +1 -11
  83. package/dist/tui-v2/app/commands/config-commands.js.map +1 -1
  84. package/dist/tui-v2/app/commands/key-commands.d.ts +1 -1
  85. package/dist/tui-v2/app/commands/key-commands.js +148 -63
  86. package/dist/tui-v2/app/commands/key-commands.js.map +1 -1
  87. package/dist/tui-v2/app/commands/picker-commands.js +6 -0
  88. package/dist/tui-v2/app/commands/picker-commands.js.map +1 -1
  89. package/dist/tui-v2/bootstrap/composition-root.js +8 -5
  90. package/dist/tui-v2/bootstrap/composition-root.js.map +1 -1
  91. package/dist/tui-v2/components/modal/keys-modal.d.ts +19 -0
  92. package/dist/tui-v2/components/modal/keys-modal.js +203 -0
  93. package/dist/tui-v2/components/modal/keys-modal.js.map +1 -0
  94. package/dist/tui-v2/components/overlay/overlay-host.js +6 -2
  95. package/dist/tui-v2/components/overlay/overlay-host.js.map +1 -1
  96. package/dist/tui-v2/components/pager/pager-line.js +5 -2
  97. package/dist/tui-v2/components/pager/pager-line.js.map +1 -1
  98. package/dist/tui-v2/components/pager/pager.js +6 -3
  99. package/dist/tui-v2/components/pager/pager.js.map +1 -1
  100. package/dist/tui-v2/components/plan/use-pane-presence.d.ts +6 -4
  101. package/dist/tui-v2/components/plan/use-pane-presence.js +8 -45
  102. package/dist/tui-v2/components/plan/use-pane-presence.js.map +1 -1
  103. package/dist/tui-v2/components/transcript/assistant-message.js +4 -1
  104. package/dist/tui-v2/components/transcript/assistant-message.js.map +1 -1
  105. package/dist/tui-v2/components/transcript/compacted-row.js +1 -1
  106. package/dist/tui-v2/components/transcript/compacted-row.js.map +1 -1
  107. package/dist/tui-v2/components/transcript/file-diff-card.js +3 -23
  108. package/dist/tui-v2/components/transcript/file-diff-card.js.map +1 -1
  109. package/dist/tui-v2/components/transcript/linkable-text.js +9 -2
  110. package/dist/tui-v2/components/transcript/linkable-text.js.map +1 -1
  111. package/dist/tui-v2/components/transcript/selectable-line.d.ts +22 -0
  112. package/dist/tui-v2/components/transcript/selectable-line.js +24 -0
  113. package/dist/tui-v2/components/transcript/selectable-line.js.map +1 -0
  114. package/dist/tui-v2/components/transcript/thinking-block.js +3 -1
  115. package/dist/tui-v2/components/transcript/thinking-block.js.map +1 -1
  116. package/dist/tui-v2/components/transcript/tool-card.d.ts +5 -3
  117. package/dist/tui-v2/components/transcript/tool-card.js +39 -33
  118. package/dist/tui-v2/components/transcript/tool-card.js.map +1 -1
  119. package/dist/tui-v2/components/transcript/use-click-without-drag.js +4 -1
  120. package/dist/tui-v2/components/transcript/use-click-without-drag.js.map +1 -1
  121. package/dist/tui-v2/components/transcript/user-message.js +1 -1
  122. package/dist/tui-v2/components/transcript/user-message.js.map +1 -1
  123. package/dist/tui-v2/controllers/overlay-controller.d.ts +36 -0
  124. package/dist/tui-v2/controllers/overlay-controller.js +28 -0
  125. package/dist/tui-v2/controllers/overlay-controller.js.map +1 -1
  126. package/dist/tui-v2/rendering/batch-sections.d.ts +15 -4
  127. package/dist/tui-v2/rendering/batch-sections.js +157 -64
  128. package/dist/tui-v2/rendering/batch-sections.js.map +1 -1
  129. package/dist/tui-v2/rendering/format-help.js +1 -1
  130. package/dist/tui-v2/rendering/format-help.js.map +1 -1
  131. package/dist/tui-v2/rendering/open-tool-output.js +10 -7
  132. package/dist/tui-v2/rendering/open-tool-output.js.map +1 -1
  133. package/dist/tui-v2/rendering/pager-markdown.d.ts +9 -0
  134. package/dist/tui-v2/rendering/pager-markdown.js +46 -0
  135. package/dist/tui-v2/rendering/pager-markdown.js.map +1 -1
  136. package/dist/tui-v2/rendering/pager-view-policy.d.ts +6 -3
  137. package/dist/tui-v2/rendering/pager-view-policy.js +9 -13
  138. package/dist/tui-v2/rendering/pager-view-policy.js.map +1 -1
  139. package/dist/tui-v2/rendering/sanitize-display.js +3 -1
  140. package/dist/tui-v2/rendering/sanitize-display.js.map +1 -1
  141. package/dist/types.d.ts +7 -0
  142. package/dist/version.generated.d.ts +2 -2
  143. package/dist/version.generated.js +2 -2
  144. package/package.json +1 -1
@@ -8,13 +8,16 @@ import { randomUUID } from "node:crypto";
8
8
  import { jobManager } from "../tools/jobs.js";
9
9
  import { agentModeDirective, planModeDirective, renderAgentSystemPrompt, renderCompactAgentSystemPrompt, scratchDirFor, toolNudge, } from "../prompts/index.js";
10
10
  import { getConfig } from "../store/config.js";
11
+ import { beginSessionWorkspace, getActiveSessionWorkspace, } from "../store/session-workspace.js";
11
12
  import { groqInputTokenBudget } from "../llm/groq.js";
12
13
  import { classifyToolCall, isPentestToolCall, scopeHint, scopeTargetForToolCall, } from "../safety/classifier.js";
13
14
  import { availableToolNames, normalizeToolCall, runToolCall, BATCH_SAFE_TOOLS, } from "../tools/registry.js";
14
15
  import { getToolDefinitions, getCompactToolDefinitions, PLAN_TOOL_NAMES, } from "../tools/definitions.js";
15
- import { appendAssistantWithTools, appendToolResult, assertValidToolProtocol, fillMissingToolResults, } from "./tool-history.js";
16
+ import { appendAssistantWithTools, appendToolResult, assertValidToolProtocol, fillMissingToolResults, repairToolProtocol, } from "./tool-history.js";
16
17
  import { formatViewportHint, registerViewport } from "../ui/output-pane.js";
17
- import { compactMessagesWithSummary, estimateMessagesTokens, AUTO_COMPACT_TOKEN_BUDGET, shouldApplyAutoCompact, COMPACTION_MEMORY_PREFIX, PLAN_IMPLEMENT_MEMORY_PREFIX, isCompactionMemoryMessage, } from "./context-manager.js";
18
+ import { compactMessagesWithSummary, estimateMessagesTokens, shouldApplyAutoCompact, COMPACTION_MEMORY_PREFIX, PLAN_IMPLEMENT_MEMORY_PREFIX, isCompactionMemoryMessage, } from "./context-manager.js";
19
+ import { buildContextBreakdown, contextBreakdownAuditPayload, } from "./context-breakdown.js";
20
+ import { autoCompactTriggerTokens, dedupeToolContextOutput, freeTierGuardNotices, getReliabilityPolicy, resolveStepMaxTokens, } from "./reliability-policy.js";
18
21
  import { auditLog } from "../store/logs.js";
19
22
  import { loadProjectContext } from "../store/project.js";
20
23
  import { loadScope, isScopeActive } from "../store/scope.js";
@@ -85,9 +88,17 @@ export async function runAgentTurn(prompt, options = {}) {
85
88
  if (/\/output\b|open full output|Ctrl\+O or|\.clai\/outputs/i.test(cleaned)) {
86
89
  return;
87
90
  }
88
- if (cleaned.length > 64) {
89
- const short = cleaned.match(/^[\w./-]+/);
90
- cleaned = short ? short[0] : cleaned.slice(0, 61) + "…";
91
+ // API key rotation / retry lines need more room than tool-name chips.
92
+ const keyLine = /^(using |switching |⏳ |all .+ API keys)/i.test(cleaned);
93
+ const maxLen = keyLine ? 96 : 64;
94
+ if (cleaned.length > maxLen) {
95
+ if (keyLine) {
96
+ cleaned = cleaned.slice(0, maxLen - 1) + "…";
97
+ }
98
+ else {
99
+ const short = cleaned.match(/^[\w./-]+/);
100
+ cleaned = short ? short[0] : cleaned.slice(0, maxLen - 3) + "…";
101
+ }
91
102
  }
92
103
  emit({ type: "status", text: cleaned || "working" });
93
104
  if (writesDirectly)
@@ -278,6 +289,11 @@ export async function runAgentTurn(prompt, options = {}) {
278
289
  };
279
290
  let lastAnswer = "";
280
291
  const session = options.session ?? createSessionPolicy();
292
+ // One-shot CLI / tests that never entered TUI/REPL still need an isolated
293
+ // scratch+output workspace. No-op when a session already bound one.
294
+ if (!getActiveSessionWorkspace()) {
295
+ beginSessionWorkspace();
296
+ }
281
297
  // Active plan context
282
298
  // If this session already has a plan, inject it so the model keeps it in
283
299
  // context. When the user has approved it (via /implement) we instruct the
@@ -323,11 +339,16 @@ export async function runAgentTurn(prompt, options = {}) {
323
339
  setActiveProjectRootIfValid(discoveredProjects[0]);
324
340
  }
325
341
  const buildSystemContent = (native) => {
342
+ const reliability = getReliabilityPolicy();
326
343
  const sections = [
327
344
  (useCompactSystemPrompt
328
345
  ? renderCompactAgentSystemPrompt
329
346
  : renderAgentSystemPrompt)(toolNames.join(", "), {
330
347
  nativeTools: native,
348
+ // E6: slim native constitution when API tool schemas are attached.
349
+ ...(native
350
+ ? { slimNative: reliability.slimNativePrompt }
351
+ : {}),
331
352
  }),
332
353
  ];
333
354
  if (projectContext) {
@@ -1911,6 +1932,11 @@ export async function runAgentTurn(prompt, options = {}) {
1911
1932
  // Align with /compact default: small recency + dense memory (not keepRecent=6 fat tails).
1912
1933
  const AUTO_COMPACT_KEEP_RECENT = 2;
1913
1934
  let lastCompactionMsgCount = 0;
1935
+ /** E5: identical tool bodies within this turn → pointer instead of re-append. */
1936
+ const toolResultHashes = new Map();
1937
+ /** E4: consecutive free-tier stream failures this turn. */
1938
+ let freeTierConsecutiveFailures = 0;
1939
+ let freeTierLargeContextWarned = false;
1914
1940
  const summarizeForCompaction = async (summaryPrompt) => {
1915
1941
  const response = await completeWithProvider({
1916
1942
  provider,
@@ -1928,7 +1954,9 @@ export async function runAgentTurn(prompt, options = {}) {
1928
1954
  };
1929
1955
  async function maybeAutoCompact(reason, force = false) {
1930
1956
  const beforeTokens = estimateMessagesTokens(messages);
1931
- if (!force && beforeTokens < AUTO_COMPACT_TOKEN_BUDGET)
1957
+ // E1: soft early compact (default 70k) while hard ceiling remains 100k.
1958
+ const compactTrigger = autoCompactTriggerTokens();
1959
+ if (!force && beforeTokens < compactTrigger)
1932
1960
  return;
1933
1961
  if (messages.length <= AUTO_COMPACT_KEEP_RECENT + 2)
1934
1962
  return;
@@ -2057,159 +2085,222 @@ export async function runAgentTurn(prompt, options = {}) {
2057
2085
  ({ dialect: toolDialect, native: nativeToolsActive } =
2058
2086
  resolveNativeTools(provider, model));
2059
2087
  if (messages[0]?.role === "system") {
2060
- messages[0] = {
2061
- role: "system",
2062
- content: composeCurrentSystemPrompt(nativeToolsActive),
2063
- };
2088
+ // Recompose only when content actually changes (hour-stable env clock
2089
+ // keeps the constitution prefix identical across steps, which helps
2090
+ // provider prompt caching and avoids needless object churn).
2091
+ const nextSystem = composeCurrentSystemPrompt(nativeToolsActive);
2092
+ if (messages[0].content !== nextSystem) {
2093
+ messages[0] = {
2094
+ role: "system",
2095
+ content: nextSystem,
2096
+ };
2097
+ }
2064
2098
  }
2065
2099
  const turnTools = selectToolDefs(nativeToolsActive, useCompactSystemPrompt);
2066
2100
  toolsAttached = Boolean(turnTools?.length);
2101
+ const contextBreakdown = buildContextBreakdown(messages, toolsAttached ? turnTools : undefined);
2102
+ // E4: advisory only — never blocks free-tier users.
2103
+ if (!freeTierLargeContextWarned) {
2104
+ const notices = freeTierGuardNotices({
2105
+ provider,
2106
+ estimatedInputTokens: contextBreakdown.estimatedTotalTokens,
2107
+ consecutiveFailures: freeTierConsecutiveFailures,
2108
+ });
2109
+ for (const notice of notices) {
2110
+ if (notice.includes("Large context")) {
2111
+ freeTierLargeContextWarned = true;
2112
+ }
2113
+ writeNotice("info", notice, chalk.dim(` ℹ ${notice}\n`));
2114
+ }
2115
+ }
2067
2116
  await auditLog("agent.turn", {
2068
2117
  provider,
2069
2118
  model,
2070
2119
  tool_protocol: toolsAttached ? "native" : "text",
2071
2120
  dialect: toolDialect,
2072
2121
  step,
2122
+ // Metadata-only composition metrics (no prompt/tool text).
2123
+ ...contextBreakdownAuditPayload(contextBreakdown),
2124
+ compactTriggerTokens: autoCompactTriggerTokens(),
2125
+ maxTokensBudget: resolveStepMaxTokens({
2126
+ nativeToolsActive,
2127
+ toolsAttached,
2128
+ recoveryNudge: retryWithoutThinking,
2129
+ }),
2073
2130
  });
2131
+ // Resume / mid-turn abort can leave orphan tool rows or a user
2132
+ // "continue" before tool results. Heal first so multi-key retry and
2133
+ // history reloads don't hard-fail on protocol asserts.
2134
+ // Heal once per step if needed; silent (no toast) — placeholders are
2135
+ // ok=true so the model doesn't thrash on fake exit=130 failures.
2136
+ repairToolProtocol(messages);
2074
2137
  assertValidToolProtocol(messages);
2075
- completion = await streamWithProvider({
2076
- provider,
2077
- model,
2078
- allowModelFallback: false,
2079
- messages,
2080
- temperature: /minimax-m3/i.test(model) ? 1.0 : 0.2,
2081
- maxTokens: 32_768,
2082
- signal: options.signal,
2083
- thinking: retryWithoutThinking
2084
- ? { ...config.thinking, enabled: false, effort: "low" }
2085
- : config.thinking,
2086
- ...(toolsAttached
2087
- ? {
2088
- tools: turnTools,
2089
- toolChoice: freshWebSearchRequired && !sawFreshWebSearch
2090
- ? { type: "function", name: "web.search" }
2091
- : "auto",
2092
- parallelToolCalls: true,
2093
- // P2-3: emit tool cards as soon as the function name arrives.
2094
- onToolCallDelta: (delta) => {
2095
- if (!delta.name)
2096
- return;
2097
- const name = fromWireName(delta.name) ?? delta.name;
2098
- const existing = deferredToolCalls[delta.index];
2099
- if (existing) {
2100
- if (delta.argumentsBytes &&
2101
- delta.argumentsBytes >= 4096 &&
2102
- !writesDirectly) {
2103
- emit({
2104
- type: "status",
2105
- text: `${name} (${Math.round(delta.argumentsBytes / 1024)}KB args)`,
2138
+ // E3: adaptive completion budget (still large enough for writes).
2139
+ const stepMaxTokens = resolveStepMaxTokens({
2140
+ nativeToolsActive,
2141
+ toolsAttached,
2142
+ recoveryNudge: retryWithoutThinking,
2143
+ });
2144
+ try {
2145
+ completion = await streamWithProvider({
2146
+ provider,
2147
+ model,
2148
+ allowModelFallback: false,
2149
+ messages,
2150
+ temperature: /minimax-m3/i.test(model) ? 1.0 : 0.2,
2151
+ maxTokens: stepMaxTokens,
2152
+ signal: options.signal,
2153
+ thinking: retryWithoutThinking
2154
+ ? { ...config.thinking, enabled: false, effort: "low" }
2155
+ : config.thinking,
2156
+ ...(toolsAttached
2157
+ ? {
2158
+ tools: turnTools,
2159
+ toolChoice: freshWebSearchRequired && !sawFreshWebSearch
2160
+ ? { type: "function", name: "web.search" }
2161
+ : "auto",
2162
+ parallelToolCalls: true,
2163
+ // P2-3: emit tool cards as soon as the function name arrives.
2164
+ onToolCallDelta: (delta) => {
2165
+ if (!delta.name)
2166
+ return;
2167
+ const name = fromWireName(delta.name) ?? delta.name;
2168
+ const existing = deferredToolCalls[delta.index];
2169
+ if (existing) {
2170
+ if (delta.argumentsBytes &&
2171
+ delta.argumentsBytes >= 4096 &&
2172
+ !writesDirectly) {
2173
+ emit({
2174
+ type: "status",
2175
+ text: `${name} (${Math.round(delta.argumentsBytes / 1024)}KB args)`,
2176
+ });
2177
+ }
2178
+ return;
2179
+ }
2180
+ // Ensure slots are dense so index maps to deferredToolCalls[i].
2181
+ while (deferredToolCalls.length < delta.index) {
2182
+ deferredToolCalls.push({
2183
+ eventId: `tool-${++nextToolEventId}`,
2184
+ call: { name: "…", args: {} },
2185
+ rendered: "",
2106
2186
  });
2107
2187
  }
2108
- return;
2188
+ const call = normalizeToolCall({
2189
+ name,
2190
+ args: {},
2191
+ });
2192
+ const eventId = `tool-${++nextToolEventId}`;
2193
+ callIds.push(eventId);
2194
+ alreadyPrintedIds.add(eventId);
2195
+ const toolCallLine = chalk.cyan(` ▶ ${call.name}`) +
2196
+ chalk.gray(` ${formatToolArgs(call)}`);
2197
+ const entry = {
2198
+ eventId,
2199
+ call,
2200
+ rendered: styleToolChatter(call, toolCallLine) + "\n",
2201
+ };
2202
+ if (deferredToolCalls.length === delta.index) {
2203
+ deferredToolCalls.push(entry);
2204
+ }
2205
+ else {
2206
+ deferredToolCalls[delta.index] = entry;
2207
+ }
2208
+ streamedCallsCount = Math.max(streamedCallsCount, deferredToolCalls.length);
2209
+ if (!writesDirectly) {
2210
+ emit({ type: "status", text: call.name });
2211
+ }
2212
+ else {
2213
+ spinner.stop();
2214
+ spinner = startThinkingSpinner(`tool ${call.name}…`, options.signal);
2215
+ }
2216
+ },
2217
+ }
2218
+ : {}),
2219
+ }, (token) => {
2220
+ deltaParser?.push(token);
2221
+ generatedTokens += 1;
2222
+ accumulatedText += token;
2223
+ // Early UI cards from text fences only when native tools are off
2224
+ // (native args stream as structured deltas, not prose).
2225
+ if (!toolsAttached) {
2226
+ const parsedCalls = parseAllToolCalls(accumulatedText);
2227
+ if (parsedCalls.length > streamedCallsCount) {
2228
+ if (writesDirectly) {
2229
+ spinner.stop();
2109
2230
  }
2110
- // Ensure slots are dense so index maps to deferredToolCalls[i].
2111
- while (deferredToolCalls.length < delta.index) {
2231
+ while (streamedCallsCount < parsedCalls.length) {
2232
+ const call = parsedCalls[streamedCallsCount];
2233
+ const eventId = `tool-${++nextToolEventId}`;
2234
+ callIds.push(eventId);
2235
+ alreadyPrintedIds.add(eventId);
2236
+ const toolCallLine = chalk.cyan(` ▶ ${call.name}`) +
2237
+ chalk.gray(` ${formatToolArgs(call)}`);
2112
2238
  deferredToolCalls.push({
2113
- eventId: `tool-${++nextToolEventId}`,
2114
- call: { name: "…", args: {} },
2115
- rendered: "",
2239
+ eventId,
2240
+ call,
2241
+ rendered: styleToolChatter(call, toolCallLine) + "\n",
2116
2242
  });
2243
+ if (!writesDirectly) {
2244
+ emit({ type: "status", text: call.name });
2245
+ }
2246
+ streamedCallsCount += 1;
2117
2247
  }
2118
- const call = normalizeToolCall({
2119
- name,
2120
- args: {},
2121
- });
2122
- const eventId = `tool-${++nextToolEventId}`;
2123
- callIds.push(eventId);
2124
- alreadyPrintedIds.add(eventId);
2125
- const toolCallLine = chalk.cyan(` ▶ ${call.name}`) +
2126
- chalk.gray(` ${formatToolArgs(call)}`);
2127
- const entry = {
2128
- eventId,
2129
- call,
2130
- rendered: styleToolChatter(call, toolCallLine) + "\n",
2131
- };
2132
- if (deferredToolCalls.length === delta.index) {
2133
- deferredToolCalls.push(entry);
2134
- }
2135
- else {
2136
- deferredToolCalls[delta.index] = entry;
2137
- }
2138
- streamedCallsCount = Math.max(streamedCallsCount, deferredToolCalls.length);
2139
- if (!writesDirectly) {
2140
- emit({ type: "status", text: call.name });
2141
- }
2142
- else {
2143
- spinner.stop();
2144
- spinner = startThinkingSpinner(`tool ${call.name}…`, options.signal);
2145
- }
2146
- },
2147
- }
2148
- : {}),
2149
- }, (token) => {
2150
- deltaParser?.push(token);
2151
- generatedTokens += 1;
2152
- accumulatedText += token;
2153
- // Early UI cards from text fences only when native tools are off
2154
- // (native args stream as structured deltas, not prose).
2155
- if (!toolsAttached) {
2156
- const parsedCalls = parseAllToolCalls(accumulatedText);
2157
- if (parsedCalls.length > streamedCallsCount) {
2158
- if (writesDirectly) {
2159
- spinner.stop();
2160
- }
2161
- while (streamedCallsCount < parsedCalls.length) {
2162
- const call = parsedCalls[streamedCallsCount];
2163
- const eventId = `tool-${++nextToolEventId}`;
2164
- callIds.push(eventId);
2165
- alreadyPrintedIds.add(eventId);
2166
- const toolCallLine = chalk.cyan(` ▶ ${call.name}`) +
2167
- chalk.gray(` ${formatToolArgs(call)}`);
2168
- deferredToolCalls.push({
2169
- eventId,
2170
- call,
2171
- rendered: styleToolChatter(call, toolCallLine) + "\n",
2172
- });
2173
- if (!writesDirectly) {
2174
- emit({ type: "status", text: call.name });
2248
+ if (writesDirectly) {
2249
+ spinner = startThinkingSpinner(`generating response (${generatedTokens} tokens)`, options.signal);
2175
2250
  }
2176
- streamedCallsCount += 1;
2177
2251
  }
2178
- if (writesDirectly) {
2179
- spinner = startThinkingSpinner(`generating response (${generatedTokens} tokens)`, options.signal);
2252
+ }
2253
+ if (!sawReasoning && /<think/i.test(token)) {
2254
+ sawReasoning = true;
2255
+ inThinking = true;
2256
+ spinner.setLabel("thinking");
2257
+ if (!writesDirectly)
2258
+ emit({ type: "status", text: "thinking" });
2259
+ }
2260
+ if (/<\/think>/i.test(token)) {
2261
+ inThinking = false;
2262
+ spinner.setLabel("generating response (0 tokens)");
2263
+ generatedTokens = 0;
2264
+ }
2265
+ if (inThinking) {
2266
+ const cleaned = token.replace(/<\/?think[^>]*>/gi, "");
2267
+ if (cleaned) {
2268
+ spinner.pushPreview(cleaned);
2269
+ const approx = cleaned.split(/\s+/).filter(Boolean).length;
2270
+ if (approx > 0)
2271
+ spinner.bumpReasoning(approx);
2180
2272
  }
2181
2273
  }
2182
- }
2183
- if (!sawReasoning && /<think/i.test(token)) {
2184
- sawReasoning = true;
2185
- inThinking = true;
2186
- spinner.setLabel("thinking");
2187
- if (!writesDirectly)
2188
- emit({ type: "status", text: "thinking" });
2189
- }
2190
- if (/<\/think>/i.test(token)) {
2191
- inThinking = false;
2192
- spinner.setLabel("generating response (0 tokens)");
2193
- generatedTokens = 0;
2194
- }
2195
- if (inThinking) {
2196
- const cleaned = token.replace(/<\/?think[^>]*>/gi, "");
2197
- if (cleaned) {
2198
- spinner.pushPreview(cleaned);
2199
- const approx = cleaned.split(/\s+/).filter(Boolean).length;
2200
- if (approx > 0)
2201
- spinner.bumpReasoning(approx);
2274
+ else {
2275
+ if (generatedTokens % 10 === 0) {
2276
+ spinner.setLabel(`generating response (${generatedTokens} tokens)`);
2277
+ }
2202
2278
  }
2203
- }
2204
- else {
2205
- if (generatedTokens % 10 === 0) {
2206
- spinner.setLabel(`generating response (${generatedTokens} tokens)`);
2279
+ }, (status) => {
2280
+ spinner.stop();
2281
+ writeStatus(status, chalk.dim(status));
2282
+ // Toast only on key *switch* after a failure — never on sticky
2283
+ // "using" or retry countdown ticks (those stay in composer status).
2284
+ if (/^switching /i.test(status.trim())) {
2285
+ writeNotice("warn", status.trim(), chalk.yellow(` ${status.trim()}\n`));
2207
2286
  }
2287
+ });
2288
+ freeTierConsecutiveFailures = 0;
2289
+ }
2290
+ catch (streamError) {
2291
+ // E4: track free-tier failures for advisory notices (never blocks).
2292
+ freeTierConsecutiveFailures += 1;
2293
+ for (const notice of freeTierGuardNotices({
2294
+ provider,
2295
+ estimatedInputTokens: contextBreakdown.estimatedTotalTokens,
2296
+ consecutiveFailures: freeTierConsecutiveFailures,
2297
+ })) {
2298
+ if (notice.includes("Large context"))
2299
+ continue; // already shown above
2300
+ writeNotice("warn", notice, chalk.yellow(` ⚠ ${notice}\n`));
2208
2301
  }
2209
- }, (status) => {
2210
- spinner.stop();
2211
- writeStatus(status, chalk.dim(status));
2212
- });
2302
+ throw streamError;
2303
+ }
2213
2304
  }
2214
2305
  finally {
2215
2306
  // Always clear the spinner — abort, network error, or success.
@@ -2977,9 +3068,17 @@ export async function runAgentTurn(prompt, options = {}) {
2977
3068
  planCreatedThisTurn = true;
2978
3069
  }
2979
3070
  productiveSteps += 1;
3071
+ // E5: collapse identical large tool bodies within this turn to a pointer.
3072
+ const deduped = dedupeToolContextOutput({
3073
+ content: res.contextOutput,
3074
+ toolName: res.call.name,
3075
+ artifactPath: res.result.outputPath,
3076
+ seenHashes: toolResultHashes,
3077
+ });
3078
+ const contextForHistory = deduped.content;
2980
3079
  // Soft plan-mode note on tool payloads only (never a user message).
2981
3080
  // Stop once a plan with tasks exists so we don't nag after plan.create.
2982
- let toolContent = `Tool ${res.call.name} result (exit=${res.result.exitCode ?? 0}, ok=${res.result.ok}):\n${res.contextOutput}`;
3081
+ let toolContent = `Tool ${res.call.name} result (exit=${res.result.exitCode ?? 0}, ok=${res.result.ok}):\n${contextForHistory}`;
2983
3082
  const reminded = maybeAppendPlanModeReminder(toolContent, {
2984
3083
  isPlanMode,
2985
3084
  planApproved: session.planApproved.value,