@pentoshi/clai 2.0.52 → 2.0.54

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 (45) hide show
  1. package/README.md +6 -3
  2. package/dist/agent/runner.js +100 -69
  3. package/dist/agent/runner.js.map +1 -1
  4. package/dist/agent/tool-call-parser.js +6 -1
  5. package/dist/agent/tool-call-parser.js.map +1 -1
  6. package/dist/commands/providers.js +2 -0
  7. package/dist/commands/providers.js.map +1 -1
  8. package/dist/commands/update.js +1 -1
  9. package/dist/llm/capabilities.js +5 -2
  10. package/dist/llm/capabilities.js.map +1 -1
  11. package/dist/llm/gemini.js +74 -22
  12. package/dist/llm/gemini.js.map +1 -1
  13. package/dist/llm/groq.d.ts +1 -0
  14. package/dist/llm/groq.js +14 -0
  15. package/dist/llm/groq.js.map +1 -1
  16. package/dist/llm/http.d.ts +10 -2
  17. package/dist/llm/http.js +107 -45
  18. package/dist/llm/http.js.map +1 -1
  19. package/dist/llm/nvidia.js +5 -1
  20. package/dist/llm/nvidia.js.map +1 -1
  21. package/dist/llm/provider.js +7 -1
  22. package/dist/llm/provider.js.map +1 -1
  23. package/dist/llm/qwen-cloud.d.ts +2 -0
  24. package/dist/llm/qwen-cloud.js +81 -0
  25. package/dist/llm/qwen-cloud.js.map +1 -0
  26. package/dist/llm/router.js +9 -3
  27. package/dist/llm/router.js.map +1 -1
  28. package/dist/prompts/index.d.ts +6 -0
  29. package/dist/prompts/index.js +49 -0
  30. package/dist/prompts/index.js.map +1 -1
  31. package/dist/repl/slash-commands.js +7 -0
  32. package/dist/repl/slash-commands.js.map +1 -1
  33. package/dist/repl.js +35 -34
  34. package/dist/repl.js.map +1 -1
  35. package/dist/store/config.js +1 -0
  36. package/dist/store/config.js.map +1 -1
  37. package/dist/tui/App.js +126 -71
  38. package/dist/tui/App.js.map +1 -1
  39. package/dist/tui/hooks/useAgentRunner.d.ts +3 -1
  40. package/dist/tui/hooks/useAgentRunner.js +8 -3
  41. package/dist/tui/hooks/useAgentRunner.js.map +1 -1
  42. package/dist/types.d.ts +8 -1
  43. package/dist/types.js +1 -0
  44. package/dist/types.js.map +1 -1
  45. package/package.json +1 -1
package/README.md CHANGED
@@ -106,7 +106,7 @@ classic REPL automatically.
106
106
 
107
107
  - **`/ask` mode** — Read-only. AI explains, gives commands & step-by-step guidance, but does NOT execute anything.
108
108
  - **`/agent` mode** — Agentic. AI plans, waits for approval, then executes shell commands, edits files, installs missing tools, parses output, and continues until the goal is met. Tasks run on an approve/refine/discard plan workflow (`/implement`, free-text to refine, `/discard` to cancel).
109
- - **11 LLM providers** — Groq, Google Gemini, OpenRouter, OpenAI, Anthropic, NVIDIA NIM, AgentRouter, Kimchi, AWS Mantle, Ollama (local), and Bynara. All with streaming.
109
+ - **12 LLM providers** — Groq, Google Gemini, OpenRouter, OpenAI, Anthropic, NVIDIA NIM, AgentRouter, Kimchi, AWS Mantle, Ollama (local), Bynara, and Qwen Cloud. All with streaming.
110
110
  - **10 built-in tools** — `shell.exec`, `fs.read`, `fs.write`, `fs.list`, `fs.search`, `pkg.install`, `net.scan`, `http.fetch`, `sysinfo`, `pentest.recon`.
111
111
  - **Smart safety gate** — Read-only commands auto-execute; mutating commands require confirmation; destructive patterns are blocked.
112
112
  - **OS-aware & tool-frugal** — Picks the best approach for your OS, prefers tools already installed (installs only when nothing suitable exists), broadens its approach and escalates privileges as needed to finish the task.
@@ -122,7 +122,7 @@ classic REPL automatically.
122
122
 
123
123
  ## Provider Setup
124
124
 
125
- clai supports 11 LLM providers (9 with free tiers):
125
+ clai supports 12 LLM providers (9 with free tiers):
126
126
 
127
127
  | Provider | Default Model | Free? | API Key Prefix |
128
128
  |-------------|----------------------------------------------|-------|----------------|
@@ -137,6 +137,7 @@ clai supports 11 LLM providers (9 with free tiers):
137
137
  | AWS Mantle | `anthropic.claude-haiku-4-5` | — | `sk-ant-` |
138
138
  | Ollama | `llama3.1:8b` | ✓ | (local URL) |
139
139
  | Bynara | `mimo-v2.5-free` | ✓ | `sk_nry_` |
140
+ | Qwen Cloud | `qwen3.7-plus` | — | `sk-` |
140
141
 
141
142
  ```sh
142
143
  # Store an API key
@@ -178,6 +179,7 @@ export NVIDIA_API_KEY=nvapi-...
178
179
  export CASTAI_API_KEY=...
179
180
  export ANTHROPIC_WORKSPACE_ID=default # optional, for AWS Mantle
180
181
  export OLLAMA_HOST=http://localhost:11434
182
+ export DASHSCOPE_API_KEY=sk-...
181
183
  ```
182
184
 
183
185
  ## REPL Commands
@@ -434,7 +436,8 @@ clai/
434
436
  │ │ ├─ openai.ts # OpenAI provider (streaming)
435
437
  │ │ ├─ anthropic.ts # Anthropic provider (streaming)
436
438
  │ │ ├─ nvidia.ts # NVIDIA NIM provider (streaming)
437
- │ │ └─ openrouter.ts # OpenRouter provider (streaming)
439
+ │ │ ├─ openrouter.ts # OpenRouter provider (streaming)
440
+ │ │ └─ qwen-cloud.ts # Qwen Cloud provider (streaming + live models)
438
441
  │ ├─ tools/
439
442
  │ │ ├─ registry.ts # Tool dispatch table
440
443
  │ │ ├─ shell.ts # shell.exec via execa
@@ -4,13 +4,14 @@ import { join, relative, resolve } from "node:path";
4
4
  import { streamWithProvider, completeWithProvider } from "../llm/router.js";
5
5
  import { randomUUID } from "node:crypto";
6
6
  import { jobManager } from "../tools/jobs.js";
7
- import { renderAgentSystemPrompt, scratchDirFor, } from "../prompts/index.js";
7
+ import { renderAgentSystemPrompt, renderCompactAgentSystemPrompt, scratchDirFor, } from "../prompts/index.js";
8
8
  import { getConfig } from "../store/config.js";
9
+ import { groqInputTokenBudget } from "../llm/groq.js";
9
10
  import { classifyToolCall, isPentestToolCall, scopeHint, scopeTargetForToolCall, } from "../safety/classifier.js";
10
11
  import { availableToolNames, normalizeToolCall, runToolCall, BATCH_SAFE_TOOLS, } from "../tools/registry.js";
11
12
  import { looksInteractiveStdin } from "../tools/shell.js";
12
13
  import { formatViewportHint, registerViewport } from "../ui/output-pane.js";
13
- import { compactMessagesWithSummary, estimateMessagesTokens, COMPACTION_MEMORY_PREFIX, } from "./context-manager.js";
14
+ import { compactMessagesWithSummary, estimateTokens, estimateMessagesTokens, COMPACTION_MEMORY_PREFIX, } from "./context-manager.js";
14
15
  import { auditLog } from "../store/logs.js";
15
16
  import { loadProjectContext } from "../store/project.js";
16
17
  import { loadScope, isScopeActive, targetInScope } from "../store/scope.js";
@@ -248,16 +249,26 @@ export async function runAgentLoop(prompt, options = {}) {
248
249
  !pentestLikeTurn &&
249
250
  toolNames.includes("web.search") &&
250
251
  requiresFreshWebSearch(prompt);
251
- const systemSections = [renderAgentSystemPrompt(toolNames.join(", "))];
252
+ let provider = options.provider ?? config.defaultProvider;
253
+ await ensureProviderConfigured(provider);
254
+ let model = options.model ?? config.defaultModel;
255
+ // Some Groq free-tier models have a per-request/per-minute input budget
256
+ // below the normal agent prompt alone. Select a purpose-built compact
257
+ // instruction set before the request is made, rather than treating the
258
+ // provider's 413 as a context-window failure after the fact.
259
+ const inputTokenBudget = provider === "groq" ? groqInputTokenBudget(model) : undefined;
260
+ const useCompactSystemPrompt = inputTokenBudget !== undefined;
261
+ const systemSections = [
262
+ (useCompactSystemPrompt
263
+ ? renderCompactAgentSystemPrompt
264
+ : renderAgentSystemPrompt)(toolNames.join(", ")),
265
+ ];
252
266
  if (projectContext) {
253
267
  systemSections.push(`Project context from .clai/context.md:\n${projectContext}`);
254
268
  }
255
269
  if (freshWebSearchRequired) {
256
270
  systemSections.push(freshnessGuardMessage());
257
271
  }
258
- let provider = options.provider ?? config.defaultProvider;
259
- await ensureProviderConfigured(provider);
260
- let model = options.model ?? config.defaultModel;
261
272
  let lastAnswer = "";
262
273
  const session = options.session ?? createSessionPolicy();
263
274
  // Active plan context
@@ -296,7 +307,19 @@ export async function runAgentLoop(prompt, options = {}) {
296
307
  if (pentestLikeTurn && !activePlan && !informationalQuery) {
297
308
  systemSections.push(pentestWorkflowDirective());
298
309
  }
299
- const fullSystemPrompt = systemSections.join("\n\n");
310
+ const renderedSystemPrompt = systemSections.join("\n\n");
311
+ // Reserve most of a constrained model's input budget for the user message,
312
+ // recent conversation, tool results, and provider framing. Dynamic project
313
+ // context or a saved plan must not silently grow the compact base prompt
314
+ // back above the model's TPM ceiling.
315
+ const maxSystemTokens = inputTokenBudget
316
+ ? Math.min(2_000, Math.floor(inputTokenBudget * 0.4))
317
+ : undefined;
318
+ const systemTruncationNote = "\n\n[Additional system context omitted to fit the provider input budget.]";
319
+ const fullSystemPrompt = maxSystemTokens !== undefined &&
320
+ estimateTokens(renderedSystemPrompt) > maxSystemTokens
321
+ ? renderedSystemPrompt.slice(0, Math.max(0, Math.floor(maxSystemTokens * 3.3) - systemTruncationNote.length)) + systemTruncationNote
322
+ : renderedSystemPrompt;
300
323
  const userMessage = { role: "user", content: prompt };
301
324
  if (options.images && options.images.length > 0) {
302
325
  userMessage.images = options.images;
@@ -317,6 +340,20 @@ export async function runAgentLoop(prompt, options = {}) {
317
340
  }
318
341
  return message;
319
342
  };
343
+ // Every provider must receive a syntactically valid assistant turn between
344
+ // the original user prompt and a recovery nudge. In particular, Gemini
345
+ // serializes an empty assistant message as an empty `model` text part,
346
+ // which can cause every retry to return empty as well. Keep hidden thinking
347
+ // out of history, but record a compact non-empty sentinel when there was no
348
+ // visible output.
349
+ const pushAssistantHistory = (content) => {
350
+ messages.push({
351
+ role: "assistant",
352
+ content: content.trim()
353
+ ? content
354
+ : "[No visible assistant response was produced.]",
355
+ });
356
+ };
320
357
  // Track recent tool calls to detect models stuck in a loop calling the
321
358
  // same tool with the same arguments over and over (e.g. pentest.recon
322
359
  // called 3× on the same target without summarizing).
@@ -324,6 +361,10 @@ export async function runAgentLoop(prompt, options = {}) {
324
361
  // Track consecutive thinking-only responses so we can nudge the model
325
362
  // to actually act instead of silently returning an empty answer.
326
363
  let emptyVisibleRetries = 0;
364
+ // A model that spent an entire completion in hidden reasoning gets one
365
+ // visible-output retry with provider thinking disabled. This is per-turn
366
+ // only: a subsequent successful response restores the configured setting.
367
+ let retryWithoutThinking = false;
327
368
  // Track tool calls truncated by the token limit so we can ask the model
328
369
  // to retry in smaller pieces instead of leaking broken JSON as an answer.
329
370
  let truncatedToolRetries = 0;
@@ -689,12 +730,22 @@ export async function runAgentLoop(prompt, options = {}) {
689
730
  artifactPath: "",
690
731
  };
691
732
  jobManager.registerJob(jobId, backgroundJob, toolAc);
692
- const TOOL_STALL_WARNING_MS = 60_000; // 1 minute
693
- const stallTimer = setTimeout(() => {
694
- if (!toolAc.signal.aborted) {
695
- writeNotice("info", `${call.name} has been running for >60s — still waiting (ESC to abort)`, chalk.yellow(` ⏳ ${call.name} still running — ESC to abort\n`));
696
- }
697
- }, TOOL_STALL_WARNING_MS);
733
+ // Long-lived commands should use shell.start/background jobs. Reset this
734
+ // watchdog whenever a blocking tool emits output so only a genuinely
735
+ // stalled operation is cancelled.
736
+ const TOOL_STALL_ABORT_MS = 60_000; // 1 minute
737
+ let stallTimer;
738
+ const resetStallTimer = () => {
739
+ if (stallTimer)
740
+ clearTimeout(stallTimer);
741
+ stallTimer = setTimeout(() => {
742
+ if (!toolAc.signal.aborted) {
743
+ writeNotice("warn", `${call.name} has been running for >60s — cancelling stalled tool`, chalk.yellow(` ⏳ ${call.name} stalled for >60s — cancelling\n`));
744
+ toolAc.abort();
745
+ }
746
+ }, TOOL_STALL_ABORT_MS);
747
+ };
748
+ resetStallTimer();
698
749
  try {
699
750
  result = await runToolCall(call, {
700
751
  signal: toolAc.signal,
@@ -702,6 +753,7 @@ export async function runAgentLoop(prompt, options = {}) {
702
753
  onOutput: (chunk) => {
703
754
  if (toolAc.signal.aborted)
704
755
  return;
756
+ resetStallTimer();
705
757
  printLive(chunk);
706
758
  },
707
759
  confirmed: true,
@@ -728,7 +780,8 @@ export async function runAgentLoop(prompt, options = {}) {
728
780
  result = { ok: false, output: `Tool error: ${errMsg}`, exitCode: 1 };
729
781
  }
730
782
  finally {
731
- clearTimeout(stallTimer);
783
+ if (stallTimer)
784
+ clearTimeout(stallTimer);
732
785
  parentSignal.removeEventListener("abort", onParentAbort);
733
786
  }
734
787
  const output = result.output.trim();
@@ -1000,8 +1053,15 @@ export async function runAgentLoop(prompt, options = {}) {
1000
1053
  completion = await streamWithProvider({
1001
1054
  provider,
1002
1055
  model,
1056
+ // The selected model is the first choice, but an agent must not
1057
+ // end a turn with no answer if it only emits reasoning. Honor
1058
+ // the user's providerFallback setting for that recovery path.
1059
+ allowModelFallback: true,
1003
1060
  messages,
1004
- temperature: 0.2,
1061
+ // MiniMax M3 degenerates at the generic agent temperature. The
1062
+ // HTTP layer also applies its `top_p` override for both the
1063
+ // NVIDIA long ID and Kimchi's short `minimax-m3` ID.
1064
+ temperature: /minimax-m3/i.test(model) ? 1.0 : 0.2,
1005
1065
  // Reasoning models can spend a lot on hidden thinking; give
1006
1066
  // them headroom so the visible answer / tool call isn't
1007
1067
  // truncated to silence. The non-thinking budget must be large
@@ -1015,7 +1075,9 @@ export async function runAgentLoop(prompt, options = {}) {
1015
1075
  // substantial source file; providers with a lower limit clamp it.
1016
1076
  maxTokens: 32_768,
1017
1077
  signal: options.signal,
1018
- thinking: config.thinking,
1078
+ thinking: retryWithoutThinking
1079
+ ? { ...config.thinking, enabled: false }
1080
+ : config.thinking,
1019
1081
  }, (token) => {
1020
1082
  deltaParser?.push(token);
1021
1083
  generatedTokens += 1;
@@ -1148,16 +1210,17 @@ export async function runAgentLoop(prompt, options = {}) {
1148
1210
  else {
1149
1211
  writeNotice("warn", "model returned an empty response — nudging it to answer", chalk.yellow(" ⚠ model returned an empty response — nudging it to answer\n"));
1150
1212
  }
1151
- messages.push({
1152
- role: "assistant",
1153
- content: stripThinking(collapseRepeatedText(completion.text)).visible,
1154
- });
1213
+ if (assistantText.hasThinking)
1214
+ retryWithoutThinking = true;
1215
+ pushAssistantHistory(stripThinking(collapseRepeatedText(completion.text)).visible);
1155
1216
  // Keep nudges SHORT — cheap models lose the key instruction in long text.
1156
- const buildNudge = buildLikeTurn && !activePlan
1157
- ? "No visible output. Emit a ```tool block to call plan.create now. " +
1158
- "Do NOT hide tool calls in <think> tags — put them in the visible response."
1159
- : "No visible output. Emit a ```tool block or give your final answer. " +
1160
- "Do NOT hide tool calls in <think> tags — put them in the visible response.";
1217
+ const buildNudge = freshWebSearchRequired && !sawFreshWebSearch
1218
+ ? "No visible output. This is current or scheduled information: emit exactly one valid ```tool block for web.search now. Do NOT answer from memory or hide the tool call in <think> tags."
1219
+ : buildLikeTurn && !activePlan
1220
+ ? "No visible output. Emit a ```tool block to call plan.create now. " +
1221
+ "Do NOT hide tool calls in <think> tags — put them in the visible response."
1222
+ : "No visible output. Emit a ```tool block or give your final answer. " +
1223
+ "Do NOT hide tool calls in <think> tags — put them in the visible response.";
1161
1224
  messages.push(recoveryUserMessage(buildNudge));
1162
1225
  continue;
1163
1226
  }
@@ -1169,6 +1232,7 @@ export async function runAgentLoop(prompt, options = {}) {
1169
1232
  else {
1170
1233
  // Reset the counter on any successful visible output or recovered call.
1171
1234
  emptyVisibleRetries = 0;
1235
+ retryWithoutThinking = false;
1172
1236
  }
1173
1237
  // `call` was already extracted above (from visible text or thinking content).
1174
1238
  // Recovery: the model meant to call a tool but emitted a bare JSON object
@@ -1206,10 +1270,7 @@ export async function runAgentLoop(prompt, options = {}) {
1206
1270
  bareToolJsonRetries += 1;
1207
1271
  if (bareToolJsonRetries <= 3) {
1208
1272
  writeNotice("warn", "tool call missing its name/fence — asking the model to re-emit a proper ```tool block", chalk.yellow(" ⚠ tool call missing its name/fence — asking the model to re-emit a proper ```tool block\n"));
1209
- messages.push({
1210
- role: "assistant",
1211
- content: assistantText.visible,
1212
- });
1273
+ pushAssistantHistory(assistantText.visible);
1213
1274
  messages.push(recoveryUserMessage(buildLikeTurn && !activePlan
1214
1275
  ? "Your previous message was a bare JSON args object with no tool name and no ```tool fence, so NOTHING ran. " +
1215
1276
  "This is a BUILD/SCAFFOLD task with NO plan yet. " +
@@ -1231,10 +1292,7 @@ export async function runAgentLoop(prompt, options = {}) {
1231
1292
  // model to retry the tool call in a clean JSON format.
1232
1293
  if (/<\|tool_call(?:s_section)?_begin\|>|<\|tool_call_argument_begin\|>/i.test(assistantText.visible)) {
1233
1294
  writeNotice("warn", "tool call was malformed or cut off — asking the model to retry in JSON form", chalk.yellow(" ⚠ tool call was malformed or cut off — asking the model to retry in JSON form\n"));
1234
- messages.push({
1235
- role: "assistant",
1236
- content: assistantText.visible,
1237
- });
1295
+ pushAssistantHistory(assistantText.visible);
1238
1296
  messages.push(recoveryUserMessage("Your previous tool call was malformed or truncated. " +
1239
1297
  "Reply with ONLY a fenced ```tool block containing valid JSON " +
1240
1298
  'of the form `{"name": "<tool>", "args": { ... }}`. ' +
@@ -1262,10 +1320,7 @@ export async function runAgentLoop(prompt, options = {}) {
1262
1320
  if (writeResult.ok) {
1263
1321
  const lineCount = salvaged.content.split("\n").length;
1264
1322
  writeNotice("info", `tool call was truncated — salvaged ${lineCount} lines and wrote to ${salvaged.path}`, chalk.cyan(` ℹ tool call was truncated — salvaged ${lineCount} lines to ${salvaged.path}\n`));
1265
- messages.push({
1266
- role: "assistant",
1267
- content: stripThinking(assistantText.visible).visible,
1268
- });
1323
+ pushAssistantHistory(stripThinking(assistantText.visible).visible);
1269
1324
  messages.push({
1270
1325
  role: "user",
1271
1326
  content: `Your fs.write tool call was cut off at the token limit, but the system salvaged the partial content and wrote ${lineCount} lines to ${salvaged.path}. ` +
@@ -1285,10 +1340,7 @@ export async function runAgentLoop(prompt, options = {}) {
1285
1340
  }
1286
1341
  if (truncatedToolRetries <= 3) {
1287
1342
  writeNotice("warn", "tool call was cut off (output too long) — asking the model to retry safely", chalk.yellow(" ⚠ tool call was cut off (output too long) — asking the model to retry safely\n"));
1288
- messages.push({
1289
- role: "assistant",
1290
- content: stripThinking(assistantText.visible).visible,
1291
- });
1343
+ pushAssistantHistory(stripThinking(assistantText.visible).visible);
1292
1344
  messages.push({
1293
1345
  role: "user",
1294
1346
  content: "Your previous tool call was cut off before it finished — the JSON was incomplete, so NOTHING ran. " +
@@ -1326,10 +1378,7 @@ export async function runAgentLoop(prompt, options = {}) {
1326
1378
  if (writeResult.ok) {
1327
1379
  const lineCount = salvaged.content.split("\n").length;
1328
1380
  writeNotice("info", `malformed tool call salvaged — wrote ${lineCount} lines to ${salvaged.path}`, chalk.cyan(` ℹ malformed tool call salvaged — wrote ${lineCount} lines to ${salvaged.path}\n`));
1329
- messages.push({
1330
- role: "assistant",
1331
- content: stripThinking(assistantText.visible).visible,
1332
- });
1381
+ pushAssistantHistory(stripThinking(assistantText.visible).visible);
1333
1382
  messages.push({
1334
1383
  role: "user",
1335
1384
  content: `The system extracted and wrote ${lineCount} lines to ${salvaged.path} from your malformed tool call. ` +
@@ -1347,10 +1396,7 @@ export async function runAgentLoop(prompt, options = {}) {
1347
1396
  malformedFenceRetries += 1;
1348
1397
  if (malformedFenceRetries <= 3) {
1349
1398
  writeNotice("warn", "tool block present but its JSON didn't parse — asking the model to re-emit valid JSON", chalk.yellow(" ⚠ tool block present but its JSON didn't parse — asking the model to re-emit valid JSON\n"));
1350
- messages.push({
1351
- role: "assistant",
1352
- content: stripThinking(assistantText.visible).visible,
1353
- });
1399
+ pushAssistantHistory(stripThinking(assistantText.visible).visible);
1354
1400
  messages.push({
1355
1401
  role: "user",
1356
1402
  content: "Your previous message contained a ```tool block, but its JSON was INVALID, so NOTHING ran. " +
@@ -1441,10 +1487,7 @@ export async function runAgentLoop(prompt, options = {}) {
1441
1487
  }
1442
1488
  writeNotice("warn", "described an action but emitted no tool call — nudging it to run one", chalk.yellow(" ⚠ described an action but emitted no tool call — nudging it to run one\n"));
1443
1489
  }
1444
- messages.push({
1445
- role: "assistant",
1446
- content: assistantText.visible,
1447
- });
1490
+ pushAssistantHistory(assistantText.visible);
1448
1491
  messages.push(recoveryUserMessage(nudge));
1449
1492
  continue;
1450
1493
  }
@@ -1453,10 +1496,7 @@ export async function runAgentLoop(prompt, options = {}) {
1453
1496
  !freshnessRetryUsed) {
1454
1497
  freshnessRetryUsed = true;
1455
1498
  writeNotice("info", "current-info question detected — searching the web before answering", chalk.dim(" ℹ current-info question detected — searching the web before answering\n"));
1456
- messages.push({
1457
- role: "assistant",
1458
- content: assistantText.visible,
1459
- });
1499
+ pushAssistantHistory(assistantText.visible);
1460
1500
  messages.push({
1461
1501
  role: "user",
1462
1502
  content: freshnessGuardMessage() +
@@ -1477,10 +1517,7 @@ export async function runAgentLoop(prompt, options = {}) {
1477
1517
  runtimePlan.tasks.every((task) => task.state === "done" || task.state === "skipped"));
1478
1518
  if (tasksFinished) {
1479
1519
  runtimeVerificationRetries += 1;
1480
- messages.push({
1481
- role: "assistant",
1482
- content: assistantText.visible,
1483
- });
1520
+ pushAssistantHistory(assistantText.visible);
1484
1521
  const missing = [
1485
1522
  !sawServerStart ? "shell.start" : "",
1486
1523
  !sawServerTail ? "shell.tail" : "",
@@ -1508,10 +1545,7 @@ export async function runAgentLoop(prompt, options = {}) {
1508
1545
  prematureCompletionRetries += 1;
1509
1546
  const next = unfinished[0];
1510
1547
  writeNotice("warn", `${unfinished.length} plan task(s) still unfinished — not accepting a "done" claim; resuming execution`, chalk.yellow(` ⚠ ${unfinished.length} plan task(s) still unfinished — not accepting a "done" claim; resuming execution\n`));
1511
- messages.push({
1512
- role: "assistant",
1513
- content: assistantText.visible,
1514
- });
1548
+ pushAssistantHistory(assistantText.visible);
1515
1549
  let instruction = `Resume now with the NEXT task ${next.id} ("${next.title}"): `;
1516
1550
  if (next.state === "pending") {
1517
1551
  instruction += `call task.update {taskId:"${next.id}", state:"in_progress"}, then do the real work with a tool call (fs.writeMany / shell.exec / shell.start), VERIFY it, and mark it done. `;
@@ -1583,10 +1617,7 @@ export async function runAgentLoop(prompt, options = {}) {
1583
1617
  allCalls
1584
1618
  .map((c) => `\`\`\`tool\n${JSON.stringify(c)}\n\`\`\``)
1585
1619
  .join("\n\n");
1586
- messages.push({
1587
- role: "assistant",
1588
- content: standardizedContent,
1589
- });
1620
+ pushAssistantHistory(standardizedContent);
1590
1621
  if (allCalls.length > 1) {
1591
1622
  writeNotice("info", `${allCalls.length} tool calls in this message — running scoped (independent read-only lookups in parallel, everything else in order)`, chalk.dim(` ℹ ${allCalls.length} tool calls — read-only lookups in parallel, the rest in order\n`));
1592
1623
  }