@cryer/star-cli 0.3.9 → 0.3.11

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.
@@ -12,7 +12,7 @@ import {
12
12
  toCoreMessages,
13
13
  toStarMessage,
14
14
  trackTree
15
- } from "./chunk-56ZIA57D.js";
15
+ } from "./chunk-N4MIQSNZ.js";
16
16
  import {
17
17
  createModel,
18
18
  streamActivity
@@ -24,7 +24,7 @@ import {
24
24
  ToolRegistry,
25
25
  createDefaultRegistry,
26
26
  debugStreamLog
27
- } from "./chunk-OIJYNQFZ.js";
27
+ } from "./chunk-Q5FJN4LN.js";
28
28
  import {
29
29
  TodoStore,
30
30
  beginTurn,
@@ -43,7 +43,7 @@ import {
43
43
  stripOversizedImages,
44
44
  undoTurnSnapshots,
45
45
  userMemoryPath
46
- } from "./chunk-A5PXPWHB.js";
46
+ } from "./chunk-LSYGHLRU.js";
47
47
 
48
48
  // src/agent/loop.ts
49
49
  import { tool as aiTool, generateText as generateText3 } from "ai";
@@ -53,9 +53,19 @@ import { generateText } from "ai";
53
53
 
54
54
  // src/context/tokens.ts
55
55
  import { zodSchema } from "ai";
56
+ import { encode } from "gpt-tokenizer/encoding/cl100k_base";
56
57
  var CHARS_PER_TOKEN = 4;
57
58
  var MESSAGE_OVERHEAD = 4;
58
59
  var IMAGE_PART_TOKENS = 1024;
60
+ var ENCODE_OPTIONS = { disallowedSpecial: /* @__PURE__ */ new Set() };
61
+ function countTextTokens(text) {
62
+ if (text.length === 0) return 0;
63
+ try {
64
+ return encode(text, ENCODE_OPTIONS).length;
65
+ } catch {
66
+ return Math.ceil(textWeight(text) / CHARS_PER_TOKEN);
67
+ }
68
+ }
59
69
  function isCjkCode(code) {
60
70
  return code >= 12288 && code <= 12543 || code >= 13312 && code <= 19903 || code >= 19968 && code <= 40959 || code >= 63744 && code <= 64255 || code >= 65280 && code <= 65519;
61
71
  }
@@ -66,26 +76,29 @@ function textWeight(text) {
66
76
  }
67
77
  return text.length + cjk * (CHARS_PER_TOKEN - 1);
68
78
  }
69
- function contentCharLength(message) {
79
+ function roughTextTokens(text) {
80
+ return Math.ceil(textWeight(text) / CHARS_PER_TOKEN);
81
+ }
82
+ function contentTokenLength(message) {
70
83
  if (message.role === "tool") {
71
- let chars2 = 0;
84
+ let tokens2 = 0;
72
85
  for (const part of message.content) {
73
- chars2 += textWeight(JSON.stringify(part.result ?? null));
86
+ tokens2 += countTextTokens(JSON.stringify(part.result ?? null));
74
87
  }
75
- return chars2;
88
+ return tokens2;
76
89
  }
77
90
  if (typeof message.content === "string") {
78
- return textWeight(message.content);
91
+ return countTextTokens(message.content);
79
92
  }
80
- let chars = 0;
93
+ let tokens = 0;
81
94
  for (const part of message.content) {
82
95
  if (part.type === "text" || part.type === "reasoning") {
83
- chars += textWeight(part.text);
96
+ tokens += countTextTokens(part.text);
84
97
  } else if (part.type === "tool-call") {
85
- chars += textWeight(part.toolName) + textWeight(JSON.stringify(part.args ?? null));
98
+ tokens += countTextTokens(part.toolName) + countTextTokens(JSON.stringify(part.args ?? null));
86
99
  }
87
100
  }
88
- return chars;
101
+ return tokens;
89
102
  }
90
103
  function imagePartCount(message) {
91
104
  if (typeof message.content === "string" || message.role === "tool") return 0;
@@ -95,7 +108,7 @@ var tokenCache = /* @__PURE__ */ new WeakMap();
95
108
  function estimateMessageTokens(message) {
96
109
  const cached = tokenCache.get(message);
97
110
  if (cached !== void 0) return cached;
98
- const tokens = Math.ceil(contentCharLength(message) / CHARS_PER_TOKEN) + MESSAGE_OVERHEAD + imagePartCount(message) * IMAGE_PART_TOKENS;
111
+ const tokens = contentTokenLength(message) + MESSAGE_OVERHEAD + imagePartCount(message) * IMAGE_PART_TOKENS;
99
112
  tokenCache.set(message, tokens);
100
113
  return tokens;
101
114
  }
@@ -123,14 +136,14 @@ function parametersWireJson(parameters) {
123
136
  }
124
137
  }
125
138
  function estimateToolSchemaTokens(tools) {
126
- let weight = 0;
139
+ let tokens = 0;
127
140
  for (const [name, tool] of Object.entries(tools)) {
128
- weight += textWeight(name);
141
+ tokens += countTextTokens(name);
129
142
  const description = tool.description;
130
- if (typeof description === "string") weight += textWeight(description);
131
- weight += textWeight(parametersWireJson(tool.parameters));
143
+ if (typeof description === "string") tokens += countTextTokens(description);
144
+ tokens += countTextTokens(parametersWireJson(tool.parameters));
132
145
  }
133
- return Math.ceil(weight / CHARS_PER_TOKEN);
146
+ return tokens;
134
147
  }
135
148
 
136
149
  // src/context/compaction.ts
@@ -231,6 +244,7 @@ ${transcript}`,
231
244
  }
232
245
  function compactMessages(messages, maxTokens, opts = {}) {
233
246
  const limit = opts.force ? 0 : maxTokens;
247
+ const minKept = opts.force ? 1 : MIN_KEPT_MESSAGES;
234
248
  const overhead = opts.overheadTokens ?? 0;
235
249
  const tokens = messages.map((star) => estimateMessageTokens(star.message));
236
250
  let total = overhead;
@@ -259,7 +273,7 @@ function compactMessages(messages, maxTokens, opts = {}) {
259
273
  let droppedCount = 0;
260
274
  let droppedTokens = 0;
261
275
  for (const turn of turns) {
262
- if (restCount - droppedCount - turn.length < MIN_KEPT_MESSAGES) {
276
+ if (restCount - droppedCount - turn.length < minKept) {
263
277
  break;
264
278
  }
265
279
  droppedCount += turn.length;
@@ -283,6 +297,69 @@ function compactMessages(messages, maxTokens, opts = {}) {
283
297
  };
284
298
  }
285
299
 
300
+ // src/context/elision.ts
301
+ var ACTIVATION_FRACTION = 0.5;
302
+ var KEEP_RECENT_MESSAGES = 24;
303
+ var MIN_ELISION_TOKENS = 250;
304
+ var ELIDED_TOOL_RESULT_TEXT = "[stale tool output elided to free context \u2014 it was shown in full earlier; re-run the tool if you need it again]";
305
+ var ELIDED_IMAGE_TEXT = "[image elided to free context \u2014 re-attach it if it is still needed]";
306
+ var ELIDED_TOOL_CALL_ARGS_TEXT = "arguments elided to free context \u2014 the call already ran; this placeholder is not valid tool input, re-read the file if you need the old content";
307
+ function elideStaleContent(messages, thresholdTokens, overheadTokens = 0) {
308
+ let total = overheadTokens;
309
+ for (const star of messages) total += estimateMessageTokens(star.message);
310
+ if (total < thresholdTokens * ACTIVATION_FRACTION) return null;
311
+ const cutoff = messages.length - KEEP_RECENT_MESSAGES;
312
+ if (cutoff <= 0) return null;
313
+ let elidedCount = 0;
314
+ const out = messages.slice();
315
+ for (let i = 0; i < cutoff; i++) {
316
+ const star = out[i];
317
+ if (!star || star.meta?.elided) continue;
318
+ const message = star.message;
319
+ if (message.role === "tool") {
320
+ if (estimateMessageTokens(message) < MIN_ELISION_TOKENS) continue;
321
+ out[i] = {
322
+ message: {
323
+ ...message,
324
+ content: message.content.map((part) => ({
325
+ ...part,
326
+ result: ELIDED_TOOL_RESULT_TEXT
327
+ }))
328
+ },
329
+ meta: { ...star.meta, elided: true }
330
+ };
331
+ elidedCount++;
332
+ } else if (message.role === "assistant" && Array.isArray(message.content)) {
333
+ if (!message.content.some((part) => part.type === "tool-call")) continue;
334
+ if (estimateMessageTokens(message) < MIN_ELISION_TOKENS) continue;
335
+ out[i] = {
336
+ message: {
337
+ ...message,
338
+ content: message.content.map(
339
+ (part) => part.type === "tool-call" ? { ...part, args: { elided: ELIDED_TOOL_CALL_ARGS_TEXT } } : part
340
+ )
341
+ },
342
+ meta: { ...star.meta, elided: true }
343
+ };
344
+ elidedCount++;
345
+ } else if (message.role === "user" && Array.isArray(message.content)) {
346
+ if (!message.content.some((part) => part.type === "image" || part.type === "file")) continue;
347
+ out[i] = {
348
+ message: {
349
+ ...message,
350
+ content: message.content.map(
351
+ (part) => part.type === "image" || part.type === "file" ? { type: "text", text: ELIDED_IMAGE_TEXT } : part
352
+ )
353
+ },
354
+ meta: { ...star.meta, elided: true }
355
+ };
356
+ elidedCount++;
357
+ }
358
+ }
359
+ if (elidedCount === 0) return null;
360
+ return { messages: out, elidedCount };
361
+ }
362
+
286
363
  // src/core/events.ts
287
364
  function usageTotalTokens(usage) {
288
365
  const summed = usage.promptTokens + usage.completionTokens;
@@ -650,10 +727,10 @@ function summarizeStreamError(error) {
650
727
  }
651
728
 
652
729
  // src/llm/stream.ts
653
- import { streamText } from "ai";
654
- var DEFAULT_IDLE_TIMEOUT_MS = 2e4;
730
+ import { InvalidToolArgumentsError, streamText } from "ai";
731
+ var DEFAULT_IDLE_TIMEOUT_MS = 6e4;
655
732
  var DEFAULT_FIRST_PART_TIMEOUT_MS = 12e4;
656
- var GENERATION_PART_TYPES = /* @__PURE__ */ new Set(["text-delta", "reasoning", "tool-call", "tool-call-delta"]);
733
+ var CONTENT_PART_TYPES = /* @__PURE__ */ new Set(["text-delta", "tool-call", "tool-call-delta"]);
657
734
  var MAX_KEEPALIVE_SILENCE_MS = 10 * 6e4;
658
735
  var CACHE_READ_KEYS = ["cacheReadInputTokens", "cache_read_input_tokens"];
659
736
  var CACHED_PROMPT_KEYS = ["cachedPromptTokens", "cached_prompt_tokens", "cachedTokens"];
@@ -732,6 +809,7 @@ async function* streamChat(opts) {
732
809
  let deltas = 0;
733
810
  let hasContent = false;
734
811
  const argsProgress = /* @__PURE__ */ new Map();
812
+ let invalidArgsCounter = 0;
735
813
  let lastPartAt = Date.now();
736
814
  debugStreamLog("request", {
737
815
  messages: opts.messages.length,
@@ -791,7 +869,7 @@ async function* streamChat(opts) {
791
869
  }
792
870
  const part = next.value;
793
871
  lastPartAt = Date.now();
794
- if (GENERATION_PART_TYPES.has(part.type)) seenContent = true;
872
+ if (CONTENT_PART_TYPES.has(part.type)) seenContent = true;
795
873
  switch (part.type) {
796
874
  case "text-delta":
797
875
  deltas++;
@@ -804,6 +882,7 @@ async function* streamChat(opts) {
804
882
  break;
805
883
  case "tool-call":
806
884
  hasContent = true;
885
+ argsProgress.delete(part.toolCallId);
807
886
  yield {
808
887
  type: "tool-call",
809
888
  id: part.toolCallId,
@@ -843,6 +922,22 @@ async function* streamChat(opts) {
843
922
  usage: part.usage,
844
923
  deltas
845
924
  });
925
+ for (const [id, progress] of argsProgress) {
926
+ debugStreamLog("truncated-tool-call", {
927
+ toolCallId: id,
928
+ toolName: progress.name,
929
+ chars: progress.chars,
930
+ finishReason: part.finishReason
931
+ });
932
+ yield {
933
+ type: "tool-call",
934
+ id,
935
+ name: progress.name,
936
+ args: {},
937
+ invalidArgs: `the arguments were cut off mid-stream after ${progress.chars} characters (finish reason: ${part.finishReason}) \u2014 the reply hit the output or context limit before the JSON completed. Reissue ${progress.name} with complete arguments; for large content, split it into several smaller calls.`
938
+ };
939
+ }
940
+ argsProgress.clear();
846
941
  yield {
847
942
  type: "finish",
848
943
  finishReason: part.finishReason,
@@ -857,6 +952,29 @@ async function* streamChat(opts) {
857
952
  }
858
953
  case "error": {
859
954
  if (opts.abortSignal?.aborted) return;
955
+ if (InvalidToolArgumentsError.isInstance(part.error)) {
956
+ const invalid = part.error;
957
+ let args = invalid.toolArgs;
958
+ try {
959
+ args = JSON.parse(invalid.toolArgs);
960
+ } catch {
961
+ }
962
+ const started = [...argsProgress.entries()].reverse().find(([, progress]) => progress.name === invalid.toolName);
963
+ debugStreamLog("invalid-tool-args", {
964
+ toolName: invalid.toolName,
965
+ recoveredId: started?.[0] ?? null
966
+ });
967
+ yield {
968
+ type: "tool-call",
969
+ id: started?.[0] ?? `invalid-args-${++invalidArgsCounter}`,
970
+ name: invalid.toolName,
971
+ args,
972
+ // The zod detail tells the model exactly which field to fix;
973
+ // capped because the message embeds the full rejected value.
974
+ invalidArgs: invalid.message.length > 2e3 ? `${invalid.message.slice(0, 2e3)}\u2026` : invalid.message
975
+ };
976
+ return;
977
+ }
860
978
  const error = toStreamErrorInfo(part.error);
861
979
  debugStreamLog("error-part", { seenContent, deltas, error: summarizeStreamError(error) });
862
980
  yield { type: "error", error };
@@ -1209,7 +1327,15 @@ function buildAllowRule(req) {
1209
1327
  }
1210
1328
 
1211
1329
  // src/permissions/gate.ts
1212
- var FILE_TOOLS = /* @__PURE__ */ new Set(["write_file", "edit_file", "read_file", "read_image", "grep", "glob"]);
1330
+ var FILE_TOOLS = /* @__PURE__ */ new Set([
1331
+ "write_file",
1332
+ "edit_file",
1333
+ "read_file",
1334
+ "read_image",
1335
+ "grep",
1336
+ "glob",
1337
+ "code_outline"
1338
+ ]);
1213
1339
  var FORK_BOMB = /:\(\)\s*\{\s*:\|:&\s*\}\s*;:/;
1214
1340
  var MAX_ANALYSIS_DEPTH = 8;
1215
1341
  var SH_FAMILY = /* @__PURE__ */ new Set(["sh", "bash", "zsh", "dash", "ash"]);
@@ -1981,7 +2107,8 @@ import { randomUUID } from "crypto";
1981
2107
  import { z as z2 } from "zod";
1982
2108
  var MAX_SUBAGENT_DEPTH = 1;
1983
2109
  var MAX_REPORT_CHARS = 2e4;
1984
- var SUBAGENT_PROMPT = "You are a subagent spawned to handle a focused subtask on behalf of the main agent. Work autonomously with the tools available to you, then finish with a concise report of what you found or did. Your final text is returned to the main agent as the tool result \u2014 make it self-contained.";
2110
+ var SUBAGENT_PROMPT = "You are a subagent spawned to handle a focused subtask on behalf of the main agent. Work autonomously with the tools available to you. Your final text is returned to the main agent as the tool result \u2014 make it a self-contained report: state what you found or did, cite exact file paths with line numbers for anything the main agent may need to open, and note explicitly anything left undone or unverified. Report outcomes, not process.";
2111
+ var READ_ONLY_PROMPT = "This subtask is read-only: your tools cannot modify files, run shell commands, or kill tasks. Investigate and report; do not attempt changes.";
1985
2112
  function convertUsagePricing(usage, from, to, config, names) {
1986
2113
  if (from === to) return usage;
1987
2114
  const fromCfg = findModelPricing(config, names?.fromName, from.modelId);
@@ -2019,7 +2146,7 @@ function findModelPricing(config, name, modelId) {
2019
2146
  return config.models.find((m) => m.model === modelId);
2020
2147
  }
2021
2148
  async function runSubagent(deps, args, cwd, signal, agentIdRef) {
2022
- const { AgentLoop: AgentLoop2 } = await import("./loop-PVOSF6LR.js");
2149
+ const { AgentLoop: AgentLoop2 } = await import("./loop-AY4PXRVP.js");
2023
2150
  const child = new AgentLoop2({
2024
2151
  model: deps.model,
2025
2152
  registry: createDefaultRegistry(),
@@ -2027,13 +2154,18 @@ async function runSubagent(deps, args, cwd, signal, agentIdRef) {
2027
2154
  cwd,
2028
2155
  system: deps.system ? `${deps.system}
2029
2156
 
2030
- ${SUBAGENT_PROMPT}` : SUBAGENT_PROMPT,
2157
+ ${SUBAGENT_PROMPT}${args.read_only ? `
2158
+
2159
+ ${READ_ONLY_PROMPT}` : ""}` : `${SUBAGENT_PROMPT}${args.read_only ? `
2160
+
2161
+ ${READ_ONLY_PROMPT}` : ""}`,
2031
2162
  providerMetadata: deps.providerMetadata,
2032
2163
  temperature: deps.temperature,
2033
2164
  streamIdleTimeoutSec: deps.streamIdleTimeoutSec,
2034
2165
  streamFirstChunkTimeoutSec: deps.streamFirstChunkTimeoutSec,
2035
2166
  vision: deps.vision,
2036
2167
  subagentDepth: deps.depth + 1,
2168
+ readOnly: args.read_only,
2037
2169
  modelName: deps.modelName,
2038
2170
  // Read after the dynamic-import await: defaultAgentTasks.start() has
2039
2171
  // returned by then, so a background spawn's ref.id is already assigned.
@@ -2083,8 +2215,11 @@ ${SUBAGENT_PROMPT}` : SUBAGENT_PROMPT,
2083
2215
  report = `${report.slice(0, MAX_REPORT_CHARS)}
2084
2216
  ... (truncated)`;
2085
2217
  }
2086
- const label = args.description ? ` "${args.description}"` : "";
2087
- const stats = `[subagent${label}: ${toolCalls} tool call(s)]`;
2218
+ const tags = [
2219
+ args.description ? `"${args.description}"` : null,
2220
+ args.read_only ? "read-only" : null
2221
+ ].filter(Boolean).join(" ");
2222
+ const stats = `[subagent${tags ? ` ${tags}` : ""}: ${toolCalls} tool call(s)]`;
2088
2223
  if (error) {
2089
2224
  throw new Error([`Subagent failed: ${error}`, stats, report].filter(Boolean).join("\n"));
2090
2225
  }
@@ -2094,11 +2229,16 @@ ${report || "(subagent produced no text output)"}`;
2094
2229
  function createSubagentTool(deps) {
2095
2230
  return {
2096
2231
  name: "subagent",
2097
- description: "Spawn a subagent with its own agent loop to handle a focused subtask (research, exploration, or an isolated change). The subagent has the same tools but cannot spawn further subagents. Returns the subagent's final report. Prefer this for self-contained work that would otherwise clutter the main conversation. Set run_in_background to start it without blocking: independent subtasks then run concurrently and their reports are delivered to you automatically when they finish (also inspectable via task_list/task_output/task_kill).",
2232
+ description: "Spawn a subagent with its own agent loop to handle a focused subtask (research, exploration, or an isolated change). The subagent has the same tools but cannot spawn further subagents, and it starts with zero context beyond what you write \u2014 brief it like a colleague who just walked in: state the goal, hand over what you already know (paths, identifiers, prior findings), and say exactly what the report should contain. Returns the subagent's final report. Prefer this for self-contained work that would otherwise clutter the main conversation. Set read_only for research, exploration, or review subtasks that must not modify anything. Set run_in_background to start it without blocking: independent subtasks then run concurrently and their reports are delivered to you automatically when they finish (also inspectable via task_list/task_output/task_kill).",
2098
2233
  permission: "exec",
2099
2234
  parameters: z2.object({
2100
- prompt: z2.string().describe("Complete, self-contained instructions for the subagent."),
2235
+ prompt: z2.string().describe(
2236
+ "Complete, self-contained instructions for the subagent: the goal, everything you already know that it needs, and the expected report."
2237
+ ),
2101
2238
  description: z2.string().optional().describe("Short label for the subtask, shown in the result header."),
2239
+ read_only: z2.boolean().optional().describe(
2240
+ "Restrict the subagent to read-level tools \u2014 no file writes, no shell commands, no task kills (default false). Use for research, exploration, and review subtasks."
2241
+ ),
2102
2242
  run_in_background: z2.boolean().optional().describe(
2103
2243
  "Run concurrently in the background and return a task id immediately (default false). Use for independent subtasks that can run in parallel."
2104
2244
  )
@@ -2203,6 +2343,7 @@ function finalNudgeWarning(autoContinues, maxAutoContinues) {
2203
2343
  return autoContinues >= maxAutoContinues ? " This is the final automatic continuation \u2014 if your next reply again has no tool calls, nudging stops and the turn is handed back to the user. Finish the work now or summarize what remains." : "";
2204
2344
  }
2205
2345
  var COMPLETION_CHECK_TIMEOUT_MS = 3e4;
2346
+ var TODO_STALE_NUDGE_STEPS = 20;
2206
2347
  function attachTurnContext(messages, context) {
2207
2348
  if (!context) return messages;
2208
2349
  for (let i = messages.length - 1; i >= 0; i--) {
@@ -2241,7 +2382,7 @@ ${request.slice(0, 2e3)}
2241
2382
  `<reply>
2242
2383
  ${finalReply.slice(0, 2e3)}
2243
2384
  </reply>`,
2244
- "Has the agent fully completed the task \u2014 every requested action actually performed, and the result verified by running a check (tests, build, or inspecting the produced output) rather than assumed? Or is it only describing, planning, or reporting progress with work still left? Answer with exactly one word: DONE or NOT_DONE."
2385
+ "Has the agent fully completed the task \u2014 every requested action actually performed, and the result verified by running a check (tests, build, or inspecting the produced output) rather than assumed? For a task that changed code, completion also requires tests covering the change when the project has a test setup, and a test run that actually passed \u2014 a reply that claims success without an observed green check is not complete. Or is it only describing, planning, or reporting progress with work still left? Answer with exactly one word: DONE or NOT_DONE."
2245
2386
  ].join("\n")
2246
2387
  });
2247
2388
  const verdict = text.trim().toUpperCase();
@@ -2341,6 +2482,29 @@ var AgentLoop = class {
2341
2482
  // (the tool map's share of the request window) is computed alongside since
2342
2483
  // it depends on the same inputs.
2343
2484
  aiToolsCache = null;
2485
+ // Server-calibration baseline for the context estimate: the latest finish
2486
+ // event's provider-reported promptTokens paired with the local estimate of
2487
+ // the exact request that produced it. Non-OpenAI tokenizers can count the
2488
+ // same history far higher than the local cl100k estimate (a qwen endpoint
2489
+ // counted ~30% more on a Chinese-heavy code session), which let the ctx%
2490
+ // display and the auto-compaction trigger lag the real window fill until
2491
+ // the provider itself started truncating replies. The delta refreshes on
2492
+ // every step's finish, so drift is bounded to one step's new content.
2493
+ serverTokenBaseline = null;
2494
+ // Text streamed by the in-flight reply (visible text + reasoning +
2495
+ // completed tool-call args). estimateContextTokens folds a rough token
2496
+ // estimate of it into the status-bar ctx% so the display tracks the
2497
+ // server's real-time occupancy — prompt PLUS generated-so-far, which is
2498
+ // what llama.cpp-style monitors show — instead of lagging it by the whole
2499
+ // in-flight generation (10k reasoning tokens ≈ 8% of a 128k window).
2500
+ // Reset when each stream attempt ends: the persisted reply is then counted
2501
+ // through the history estimate, and reasoning — never resent — drops out.
2502
+ // User steering prompts submitted while a turn is running (the REPL's
2503
+ // "insert now" choice). Drained into the history as synthetic user
2504
+ // messages at the next step boundary — the in-flight model reply and its
2505
+ // tool calls always finish first, so a steer never interrupts mid-step.
2506
+ pendingSteers = [];
2507
+ inflightStreamText = "";
2344
2508
  // Lazily resolved auxiliary model (config.smallModel) for cheap side calls
2345
2509
  // — compaction summaries, session titles, completion checks. Undefined =
2346
2510
  // use the main model; a resolution failure is surfaced once as a notice.
@@ -2391,6 +2555,9 @@ var AgentLoop = class {
2391
2555
  })
2392
2556
  );
2393
2557
  }
2558
+ if (opts.readOnly && depth > 0) {
2559
+ opts.registry?.retain((tool) => tool.permission === "read");
2560
+ }
2394
2561
  }
2395
2562
  // CoreMessage view of the history (meta stripped): what the model, the
2396
2563
  // display and the token estimator consume. Element identity is preserved,
@@ -2403,12 +2570,56 @@ var AgentLoop = class {
2403
2570
  getStarMessages() {
2404
2571
  return this.messages;
2405
2572
  }
2573
+ // Queue a user steering prompt for the running turn. Delivery happens at
2574
+ // the next step boundary (see drainSteers); if the turn ends first the
2575
+ // text stays queued and takeUndeliveredSteers hands it back to the caller.
2576
+ injectUserMessage(text) {
2577
+ const trimmed = text.trim();
2578
+ if (trimmed.length > 0) this.pendingSteers.push(trimmed);
2579
+ }
2580
+ // Steers that never reached a step boundary (turn ended, aborted or
2581
+ // errored first). The caller decides their fate — the REPL re-queues them
2582
+ // as ordinary prompts so user input is never dropped.
2583
+ takeUndeliveredSteers() {
2584
+ return this.pendingSteers.splice(0);
2585
+ }
2586
+ // Append queued steers to the history as synthetic user messages: they
2587
+ // belong to the turn in progress, so /undo, /rewind and compaction must
2588
+ // not treat them as turn boundaries. Returns the number delivered.
2589
+ async drainSteers() {
2590
+ const steers = this.pendingSteers.splice(0);
2591
+ for (const text of steers) {
2592
+ const message = { role: "user", content: text };
2593
+ const meta = { synthetic: "steer" };
2594
+ this.messages.push({ message, meta });
2595
+ await this.persist(message, meta);
2596
+ }
2597
+ return steers.length;
2598
+ }
2406
2599
  // Wire-schema token overhead of the active tool set (memoized by
2407
2600
  // buildAiTools). The auto-compact budget includes it; surfaced here so the
2408
2601
  // status bar ctx% can show the same total the compaction decision uses.
2409
2602
  getToolSchemaTokens() {
2410
2603
  return this.buildAiTools().schemaTokens;
2411
2604
  }
2605
+ // Additive correction from serverTokenBaseline, clamped ≥ 0: an
2606
+ // under-reading provider estimate must pull compaction earlier (the bug
2607
+ // this fixes), but an over-reading one must never push it later than the
2608
+ // local estimate alone would — compacting early is safe, late is fatal.
2609
+ calibrationDelta() {
2610
+ const baseline = this.serverTokenBaseline;
2611
+ if (!baseline) return 0;
2612
+ return Math.max(0, baseline.serverTokens - baseline.localTokens);
2613
+ }
2614
+ // Effective context-window usage in tokens: local BPE estimate + tool
2615
+ // schemas + the server-calibration delta + the in-flight reply's streamed
2616
+ // output. This is the status bar's live ctx% view; the elision/compaction
2617
+ // budgets deliberately use only the first three (history + schemas +
2618
+ // calibration), since in-flight reasoning is never resent and must not
2619
+ // trigger a compaction the next request won't need.
2620
+ estimateContextTokens() {
2621
+ return estimateTokens(this.getMessages()) + this.getToolSchemaTokens() + this.calibrationDelta() + (this.inflightStreamText.length > 0 ? roughTextTokens(this.inflightStreamText) : 0);
2622
+ }
2412
2623
  // Auxiliary model for cheap side calls: config.smallModel, resolved once.
2413
2624
  // An unresolvable name/key degrades to the main model instead of breaking
2414
2625
  // turns; the failure rides out once as a notice at the next turn start.
@@ -2748,6 +2959,9 @@ var AgentLoop = class {
2748
2959
  let usedToolsThisTurn = false;
2749
2960
  let streamCutContinues = 0;
2750
2961
  let openTodos = [];
2962
+ let todoRevision = this.opts.registry.todos.revision;
2963
+ let stepsSinceTodoChange = 0;
2964
+ const todoStaleNudgeSteps = this.opts.todoStaleNudgeSteps ?? TODO_STALE_NUDGE_STEPS;
2751
2965
  const maxSteps = config.maxSteps;
2752
2966
  let step = 0;
2753
2967
  let toolExecutedSinceCheckpoint = false;
@@ -2784,9 +2998,47 @@ ${finished.result}`
2784
2998
  await this.persist(note, meta);
2785
2999
  }
2786
3000
  }
3001
+ const steered = await this.drainSteers();
3002
+ if (steered > 0) {
3003
+ yield {
3004
+ type: "notice",
3005
+ message: `${steered} steering message(s) delivered to the model; taking effect from this step.`
3006
+ };
3007
+ }
3008
+ const currentTodoRevision = registry.todos.revision;
3009
+ if (currentTodoRevision !== todoRevision) {
3010
+ todoRevision = currentTodoRevision;
3011
+ stepsSinceTodoChange = 0;
3012
+ } else if (stepsSinceTodoChange >= todoStaleNudgeSteps) {
3013
+ const open = registry.todos.list().filter((t) => t.status !== "done");
3014
+ if (open.length > 0) {
3015
+ stepsSinceTodoChange = 0;
3016
+ yield {
3017
+ type: "notice",
3018
+ message: `todo list unchanged for ${todoStaleNudgeSteps} steps with ${open.length} open item(s); reminding the model to update it.`
3019
+ };
3020
+ const reminder = {
3021
+ role: "user",
3022
+ content: `[todo reminder] Your todo list still has ${open.length} open item(s), unchanged for ${todoStaleNudgeSteps} steps: ${open.slice(0, 5).map((t) => `"${t.title}"`).join(
3023
+ ", "
3024
+ )}${open.length > 5 ? ", \u2026" : ""}. Update the list with todo_write so it reflects what is done and what you are working on now, then continue working.`
3025
+ };
3026
+ const meta = { synthetic: "nudge" };
3027
+ this.messages.push({ message: reminder, meta });
3028
+ await this.persist(reminder, meta);
3029
+ }
3030
+ }
2787
3031
  const maxTokens = this.opts.contextMaxTokens ?? config.contextMaxTokens;
2788
- const compacted = compactMessages(this.messages, resolveCompactThreshold(config, maxTokens), {
2789
- overheadTokens: toolSchemaTokens
3032
+ const threshold = resolveCompactThreshold(config, maxTokens);
3033
+ const calibration = this.calibrationDelta();
3034
+ const elided = elideStaleContent(this.messages, threshold, toolSchemaTokens + calibration);
3035
+ if (elided) {
3036
+ this.messages = elided.messages;
3037
+ await this.opts.sessionStore?.replaceMessages([...this.messages]);
3038
+ this.opts.registry?.resetVolatileState();
3039
+ }
3040
+ const compacted = compactMessages(this.messages, threshold, {
3041
+ overheadTokens: toolSchemaTokens + calibration
2790
3042
  });
2791
3043
  if (compacted.compacted) {
2792
3044
  this.messages = await this.applyCompactionSummary(compacted, signal);
@@ -2794,6 +3046,7 @@ ${finished.result}`
2794
3046
  this.turnMarkers = [];
2795
3047
  this.opts.registry?.resetVolatileState();
2796
3048
  }
3049
+ const requestLocalTokens = estimateTokens(this.getMessages()) + toolSchemaTokens;
2797
3050
  const maxRetries = Math.max(0, config.streamMaxRetries);
2798
3051
  const baseDelay = this.opts.retryDelayMs ?? STREAM_RETRY_BASE_DELAY_MS;
2799
3052
  let text = "";
@@ -2814,22 +3067,37 @@ ${finished.result}`
2814
3067
  lastUsage = void 0;
2815
3068
  sawReasoning = false;
2816
3069
  wasTruncated = false;
3070
+ this.inflightStreamText = "";
2817
3071
  const timeoutScale = Math.min(attempt + 1, 3);
2818
3072
  try {
2819
3073
  for await (const event of this.streamOnce(aiTools, signal, timeoutScale)) {
2820
3074
  if (event.type === "text-delta") {
2821
3075
  text += event.text;
3076
+ this.inflightStreamText += event.text;
2822
3077
  yield event;
2823
3078
  } else if (event.type === "reasoning") {
2824
3079
  sawReasoning = true;
3080
+ this.inflightStreamText += event.text;
2825
3081
  yield event;
2826
3082
  } else if (event.type === "tool-call") {
2827
- toolCalls.push({ id: event.id, name: event.name, args: event.args });
3083
+ this.inflightStreamText += JSON.stringify(event.args ?? null);
3084
+ toolCalls.push({
3085
+ id: event.id,
3086
+ name: event.name,
3087
+ args: event.args,
3088
+ ...event.invalidArgs !== void 0 ? { invalidArgs: event.invalidArgs } : {}
3089
+ });
2828
3090
  yield event;
2829
3091
  } else if (event.type === "finish") {
2830
3092
  lastFinishReason = event.finishReason;
2831
3093
  if (event.truncated) wasTruncated = true;
2832
3094
  lastUsage = event.usage;
3095
+ if (event.usage && event.usage.promptTokens > 0) {
3096
+ this.serverTokenBaseline = {
3097
+ localTokens: requestLocalTokens,
3098
+ serverTokens: event.usage.promptTokens
3099
+ };
3100
+ }
2833
3101
  yield this.foldSubagentUsage(event);
2834
3102
  } else if (event.type === "error") {
2835
3103
  failure = event.error;
@@ -2842,6 +3110,7 @@ ${finished.result}`
2842
3110
  failure = toStreamErrorInfo(error);
2843
3111
  }
2844
3112
  }
3113
+ this.inflightStreamText = "";
2845
3114
  if (signal.aborted) {
2846
3115
  yield* this.persistInterrupted(text, toolCalls);
2847
3116
  await this.runStopHooks("aborted");
@@ -2894,6 +3163,14 @@ ${finished.result}`
2894
3163
  return;
2895
3164
  }
2896
3165
  if (!hasOutput()) {
3166
+ const steered2 = await this.drainSteers();
3167
+ if (steered2 > 0) {
3168
+ yield {
3169
+ type: "notice",
3170
+ message: `${steered2} steering message(s) delivered to the model after an empty reply.`
3171
+ };
3172
+ continue;
3173
+ }
2897
3174
  if (isSteerableEmptyFinish(lastFinishReason) && config.permissionMode !== "plan" && autoContinues < config.maxAutoContinues) {
2898
3175
  autoContinues++;
2899
3176
  lastWasNudge = true;
@@ -2935,6 +3212,16 @@ ${finished.result}`
2935
3212
  this.messages.push({ message: assistantMessage });
2936
3213
  await this.persist(assistantMessage);
2937
3214
  if (toolCalls.length === 0) {
3215
+ const steered2 = await this.drainSteers();
3216
+ if (steered2 > 0) {
3217
+ autoContinues = 0;
3218
+ lastWasNudge = false;
3219
+ yield {
3220
+ type: "notice",
3221
+ message: `${steered2} steering message(s) delivered to the model; the turn continues.`
3222
+ };
3223
+ continue;
3224
+ }
2938
3225
  if (wasTruncated) {
2939
3226
  if (config.permissionMode !== "plan" && streamCutContinues < config.streamMaxRetries) {
2940
3227
  streamCutContinues++;
@@ -3019,10 +3306,12 @@ ${finished.result}`
3019
3306
  turnTreeReady = null;
3020
3307
  }
3021
3308
  const answered = /* @__PURE__ */ new Set();
3309
+ const pendingImages = [];
3022
3310
  try {
3023
3311
  for (const call of toolCalls) {
3024
3312
  if (signal.aborted) break;
3025
- const refusal = this.doomLoopRefusal(call);
3313
+ const refusal = call.invalidArgs !== void 0 ? `Tool call not executed \u2014 ${call.invalidArgs}
3314
+ Reissue ${call.name} with valid arguments.` : this.doomLoopRefusal(call);
3026
3315
  if (refusal !== null) {
3027
3316
  yield { type: "notice", message: refusal };
3028
3317
  } else {
@@ -3058,9 +3347,7 @@ ${finished.result}`
3058
3347
  }))
3059
3348
  ]
3060
3349
  };
3061
- const meta = { synthetic: "tool-image" };
3062
- this.messages.push({ message: imageMessage, meta });
3063
- await this.persist(imageMessage, meta);
3350
+ pendingImages.push({ message: imageMessage, meta: { synthetic: "tool-image" } });
3064
3351
  }
3065
3352
  if (call.name === "todo_write" && !result.isError) {
3066
3353
  openTodos = pendingTodoTitles(call.args);
@@ -3093,12 +3380,18 @@ ${finished.result}`
3093
3380
  });
3094
3381
  yield { type: "tool-result", id: call.id, name: call.name, content, isError: true };
3095
3382
  }
3383
+ for (const { message, meta } of pendingImages) {
3384
+ this.messages.push({ message, meta });
3385
+ await this.persist(message, meta).catch(() => {
3386
+ });
3387
+ }
3096
3388
  }
3097
3389
  if (signal.aborted) {
3098
3390
  await this.runStopHooks("aborted");
3099
3391
  return;
3100
3392
  }
3101
3393
  step++;
3394
+ stepsSinceTodoChange++;
3102
3395
  }
3103
3396
  }
3104
3397
  // The permission mode can change at runtime, so the system message is
@@ -3412,4 +3705,4 @@ export {
3412
3705
  attachTurnContext,
3413
3706
  AgentLoop
3414
3707
  };
3415
- //# sourceMappingURL=chunk-RE3NOULF.js.map
3708
+ //# sourceMappingURL=chunk-63CGUF3F.js.map