@cryer/star-cli 0.3.0 → 0.3.1

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.
package/README.md CHANGED
@@ -15,7 +15,7 @@ Multi-model LLM access · streaming terminal UI · tool calling · permission co
15
15
 
16
16
  </div>
17
17
 
18
- Features: streaming REPL with slash commands (+ autocomplete) · OpenAI / Anthropic / OpenAI-compatible providers · built-in fs / bash / web tools with a permission gate · git integration (`/commit` drafts Conventional Commits messages, `/diff` shows a colored working-tree diff, repo status injected into the system prompt) · plan mode with read-only research and plan approval · thinking spinner with dim reasoning preview · diff preview on write/edit approval · `@file` mentions · `!cmd` shell passthrough · custom slash commands from Markdown files · conversation compaction (`/compact`) · session persistence and resume with auto-generated titles (`/resume`, `star -r`) · subagent delegation for focused subtasks · lifecycle hooks (`PreToolUse`/`PostToolUse`/`Stop` shell commands from config) · file-write snapshots with `/undo` and checkpoint rollback with `/rewind` · TODO task tracking · background shell tasks with status-bar visibility (`/tasks`) · terminal bell on long turns and background-task completion · Markdown session export (`/export`) · `/init` + `/doctor` project scaffolding and environment checks · cost estimation · update notifier · `--json` NDJSON output for scripting.
18
+ Features: streaming REPL with slash commands (+ autocomplete) · OpenAI / Anthropic / OpenAI-compatible providers with an interactive `/connect` onboarding wizard · built-in fs / bash / web tools with a permission gate · git integration (`/commit` drafts Conventional Commits messages, `/diff` shows a colored working-tree diff, repo status injected into the system prompt) · plan mode with read-only research and plan approval · thinking spinner with dim reasoning preview · diff preview on write/edit approval · `@file` mentions · `!cmd` shell passthrough · custom slash commands from Markdown files · conversation compaction (`/compact`) · session persistence and resume with auto-generated titles (`/resume`, `star -r`) · subagent delegation for focused subtasks · lifecycle hooks (`PreToolUse`/`PostToolUse`/`Stop` shell commands from config) · file-write snapshots with `/undo` and checkpoint rollback with `/rewind` · TODO task tracking · background shell tasks with status-bar visibility (`/tasks`) · terminal bell on long turns and background-task completion · Markdown session export (`/export`) · `/init` + `/doctor` project scaffolding and environment checks · cost estimation · update notifier · `--json` NDJSON output for scripting.
19
19
 
20
20
  ## Requirements
21
21
 
@@ -105,14 +105,17 @@ command = "biome check --write ."
105
105
  # timeoutSec = 30 # optional per-hook timeout
106
106
  ```
107
107
 
108
- API keys resolve from the environment variable first (`apiKeyEnv`), then the `apiKey` field in the config file.
108
+ API keys resolve from the environment variable first (`apiKeyEnv`), then the `apiKey` field in the config file. On startup Star CLI also loads `~/.star-cli/.env` (dotenv-style `KEY=VALUE` lines) into the environment without overwriting variables that are already set — this is where `/connect` stores the keys it collects, so `config.toml` only ever references the variable name, never the key itself.
109
+
110
+ The fastest way to set up a provider is the `/connect` wizard inside the REPL: pick a preset (OpenAI, Anthropic, Kimi/Moonshot, DeepSeek) or a custom endpoint, paste the API key (masked while typing), name a model, and it appends the `[[providers]]`/`[[models]]` blocks to `config.toml` (existing content and comments preserved), writes the key to `~/.star-cli/.env` (as `STAR_API_KEY_<NAME>`, file mode 600 where the platform honors it), and optionally makes the new model the default — all without restarting.
111
+
109
112
 
110
113
  ## CLI flags
111
114
 
112
115
  ```
113
116
  star start the interactive REPL
114
117
  star -p "prompt" non-interactive print mode (pipe-friendly)
115
- star -p "prompt" --image x.png attach an image (png/jpg/jpeg/gif/webp, max 5MB; repeatable)
118
+ star -p "prompt" --image x.png attach an image (png/jpg/jpeg/gif/webp, max 5MB; repeatable; anything past 2000px on the longest side is downsampled)
116
119
  star -p "prompt" --json NDJSON event stream on stdout (text/tool/usage/error lines)
117
120
  star -m gpt pick a model
118
121
  star --permission-mode auto ask | auto | readonly | yolo | plan
@@ -140,11 +143,12 @@ star --clear-sessions all delete every stored session
140
143
  | `/usage` | token usage dashboard across all sessions: totals, per-day bar chart, per-model breakdown with $ estimate |
141
144
  | `/config` | show resolved config |
142
145
  | `/permission [mode]` | set the permission mode — no argument opens an interactive picker; `ask` / `auto` / `readonly` / `yolo` are saved to config, `plan` stays session-only |
146
+ | `/connect` | interactive provider onboarding wizard: pick a preset or custom endpoint, paste the API key (masked), name a model — appends `[[providers]]`/`[[models]]` to the config, stores the key in `~/.star-cli/.env` (never in `config.toml`), and can set the new model as default + switch to it immediately |
143
147
  | `/plan` | toggle plan mode: read-only research, then approve the generated plan before it executes (session-only) |
144
148
  | `/memory [add <text>]` | show the long-term memory file (`~/.star-cli/MEMORY.md`, injected into every session's system prompt), or append a line to it — see [Long-term memory](#long-term-memory) |
145
149
  | `/compact` | compact conversation history to free up context |
146
150
  | `/export [path]` | export the current session to a Markdown file |
147
- | `/undo` | undo the last conversation turn: revert its file changes (write_file/edit_file) and retract its messages — earlier turns are never touched |
151
+ | `/undo` | undo the last conversation turn: revert its file changes (write_file/edit_file) and retract its messages — earlier turns are never touched (previews the message count and per-file revert diffs, then asks for confirmation) |
148
152
  | `/rewind [n]` | list file-change checkpoints, or rewind to just before checkpoint `n`: restore every file changed since then and retract the matching conversation messages (asks for confirmation first) |
149
153
  | `/init [force]` | scan the project and generate an AGENTS.md (LLM-polished when a model is available) |
150
154
  | `/doctor` | environment self-check (Node, shell, config, API key status, sessions dir writability) |
@@ -156,11 +160,11 @@ star --clear-sessions all delete every stored session
156
160
  | `/exit` | quit |
157
161
  | `/q` | quit (alias of `/exit`) |
158
162
 
159
- Keys: `ESC` / `Ctrl+C` interrupts the current stream; on a permission prompt: `y` allow, `n` deny, `a` always allow — the generated allow-rule (e.g. `bash(npm test)`) is saved to `permissions.allow` in the config file and survives restarts. Write/edit prompts include a colored diff preview of the pending change. `Shift+Tab` cycles the permission mode for the session (`ask` → `auto` → `readonly` → `plan`; not saved to config). Input editing: arrow keys move the cursor, `Ctrl+A`/`Ctrl+E` jump to start/end, `Ctrl+U`/`Ctrl+K` delete before/after the cursor, `Ctrl+W` deletes the previous word, up/down recall history (persisted to `~/.star-cli/history` across sessions, consecutive duplicates collapsed). Multi-line input: `Shift+Enter` inserts a newline where the terminal reports it; on terminals that don't (e.g. Windows Terminal) use `Ctrl+J` or end the line with `\` and press `Enter` — pasted multi-line text is kept intact. Typing `/` shows slash-command suggestions with their usage — prefix matches first, then fuzzy subsequence matches; `↑`/`↓` to highlight, `Tab` (or `→` at end of input) to complete, `ESC` to dismiss, and an unknown command name gets a `Did you mean: …` hint. Typing `@` completes file paths relative to the working directory with the same keys; directories end in `/` so `Tab` descends, and `node_modules`, `.git`, `dist` and `.starignore` entries are skipped. `Alt+V` pastes an image from the clipboard as an attachment (each one shows as `[image attached: clipboard.png]` above the input; uses PowerShell on Windows, pngpaste/osascript on macOS, xclip on Linux). `Ctrl+V` also works where the terminal passes it through — Windows Terminal binds Ctrl+V to its own paste, so use `Alt+V` there. While a turn is streaming you can keep typing: submitted prompts (and `!` bangs) queue up as dimmed entries and auto-send in order once the turn finishes; `ESC` during a turn aborts it and clears the queue. Pressing `ESC` twice within half a second while idle retracts your last prompt and restores its text into the input for editing.
163
+ Keys: `ESC` / `Ctrl+C` interrupts the current stream — the partial reply generated so far stays on screen (and in the session history) with a dim `[interrupted]` marker; on a permission prompt: `y` allow, `n` deny, `a` always allow — the generated allow-rule (e.g. `bash(npm test)`) is saved to `permissions.allow` in the config file and survives restarts. Write/edit prompts include a colored diff preview of the pending change. `Shift+Tab` cycles the permission mode for the session (`ask` → `auto` → `readonly` → `plan`; not saved to config). Input editing: arrow keys move the cursor, `Ctrl+A`/`Ctrl+E` jump to start/end, `Ctrl+U`/`Ctrl+K` delete before/after the cursor, `Ctrl+W` deletes the previous word, up/down recall history (persisted to `~/.star-cli/history` across sessions, capped at 50 entries, consecutive duplicates collapsed, slash commands and key-repeat garbage never recorded); while a recalled entry is unmodified up/down keep switching entries, but once you edit the text — or are typing your own multi-line input — up/down move the cursor between lines instead. `Ctrl+R` starts a reverse history search: type to filter (case-insensitive, newest match first), `Ctrl+R`/`↑` for older matches, `↓` for newer, `Enter` to accept the match into the input, `ESC`/`Ctrl+C`/`Ctrl+G` to cancel and restore what you had. Multi-line input: use `Ctrl+J` (or `Alt+Enter`), or end the line with `\` and press `Enter` — pasted multi-line text is kept intact. Pasting a large block (10+ lines or 500+ characters in one go) collapses it into a dim `[pasted #N: L lines]` placeholder so the input box stays compact; the full text is restored into what gets submitted, and `Backspace` removes the placeholder as a unit. A huge paste that the terminal delivers in several chunks is reassembled (via bracketed paste where the terminal supports it) and still collapses into a single placeholder. Typing `/` shows slash-command suggestions with their usage — prefix matches first, then fuzzy subsequence matches; `↑`/`↓` to highlight, `Tab` (or `→` at end of input) to complete, `ESC` to dismiss, and an unknown command name gets a `Did you mean: …` hint. Typing `@` completes file paths relative to the working directory with the same keys; directories end in `/` so `Tab` descends, and `node_modules`, `.git`, `dist` and `.starignore` entries are skipped. `Alt+V` pastes an image from the clipboard as an attachment (each one shows as `[image attached: clipboard.png]` above the input; uses PowerShell on Windows, pngpaste/osascript on macOS, xclip on Linux). Images larger than 2000px on the longest side are automatically downsampled to fit (keeping the aspect ratio, via platform-native tools — no extra installs on Windows/macOS; ImageMagick or ffmpeg on Linux); when resizing isn't possible the original is kept and a warning is shown. `Ctrl+V` also works where the terminal passes it through — Windows Terminal binds Ctrl+V to its own paste, so use `Alt+V` there. While a turn is streaming you can keep typing: submitted prompts (and `!` bangs) queue up as dimmed entries and auto-send in order once the turn finishes; `ESC` during a turn aborts it and clears the queue (the partial reply is kept, marked `[interrupted]`). Pressing `ESC` twice within half a second while idle retracts your last prompt and restores its text into the input for editing.
160
164
 
161
165
  While the model is working, a spinner (`- \ | /`) shows `star is thinking…`; reasoning models also stream a dimmed tail of their thinking (last ~200 chars), which collapses to a one-line summary once the answer starts.
162
166
 
163
- The status bar shows the working directory (full path on wide terminals), the git branch, the model, the permission mode, context usage as a percentage of the model's context window (its own `contextMaxTokens` when set, else the top-level one), the session cost when the model has pricing configured, and total tokens.
167
+ The status bar shows the working directory (full path on wide terminals), the git branch, the model, the permission mode, context usage as a percentage of the model's context window (its own `contextMaxTokens` when set, else the top-level one), the session prompt-cache hit rate when the provider reports cache usage (`cache: 42%`, or `cache: Not provided` when it never does), the session cost when the model has pricing configured, and total tokens.
164
168
 
165
169
  ## !shell passthrough
166
170
 
@@ -256,7 +260,7 @@ Prefix a path with `@` in any prompt to attach its content (REPL and print mode
256
260
  star -p "summarize @README.md and @src/main.tsx"
257
261
  ```
258
262
 
259
- Image files (`@screenshot.png` — png, jpg, jpeg, gif, webp, up to 5MB) are sent to the model as image input instead of inlined text, in both the REPL and print mode. In print mode you can also attach images with the repeatable `--image <path>` flag:
263
+ Image files (`@screenshot.png` — png, jpg, jpeg, gif, webp, up to 5MB) are sent to the model as image input instead of inlined text, in both the REPL and print mode; anything over 2000px on the longest side is downsampled first. In print mode you can also attach images with the repeatable `--image <path>` flag:
260
264
 
261
265
  ```
262
266
  star -p "what's wrong in this UI?" --image screenshot.png --image mockup.jpg
@@ -264,6 +268,10 @@ star -p "what's wrong in this UI?" --image screenshot.png --image mockup.jpg
264
268
 
265
269
  Invalid `--image` paths (unsupported type, missing, unreadable, or oversized) abort print mode with a non-zero exit.
266
270
 
271
+ If a provider still rejects a request because an image is too large, the agent automatically removes the oversized images from the conversation (replaced with a `[image removed: too large for the model]` placeholder, also in the stored session) and retries the request once, with a notice in the UI.
272
+
273
+ A directory mention (`@src/agent/`, with or without the trailing slash) inlines an indented tree of the directory instead of file contents — handy for giving the model project structure without pasting `tree` output. Listings honor `.starignore` and skip sensitive files, and are capped at 200 entries / 10 levels deep with a `... (truncated, N more entries)` marker when larger.
274
+
267
275
  Missing, binary, oversized (>100KB for text, >5MB for images), or sensitive files (`.env`, private keys) are skipped with a note. The chat history keeps your original `@path` text, so resumed sessions don't carry the injected bulk.
268
276
 
269
277
  ## .starignore
@@ -278,7 +286,7 @@ The `subagent` tool (`exec` level, so it is hidden in plan mode and denied in re
278
286
 
279
287
  Permission modes: `ask` (reads allowed, writes/exec ask) · `auto` (everything allowed except the hard-denied rules above) · `readonly` (read-only) · `yolo` (allow everything, never ask — **all safety checks disabled**, use at your own risk) · `plan` (read-only research with a plan-approval flow, see above — session-only). Switch at runtime with `/permission` (persisted to the config file), per session with `/plan` or `Shift+Tab`, or at startup with `--permission-mode`. On top of the modes, the `[permissions]` config block holds persistent `allow` and `deny` rule lists (same `bash(npm test *)` syntax); deny rules are evaluated right after the hard safety rules and beat both `auto` mode and allow-rules, while `yolo` bypasses everything.
280
288
 
281
- Every successful `write_file` / `edit_file` first snapshots the file's previous content (in-memory, last 50 writes per session); `/undo` restores the most recent snapshot, deleting the file if it didn't exist before.
289
+ Every successful `write_file` / `edit_file` first snapshots the file's previous content (in-memory, last 50 writes per session); `/undo` reverts the last turn's snapshots — restoring old content, or deleting files the turn created — but only after showing a preview of the retraction and per-file revert diffs and getting a `y`/`n` confirmation.
282
290
 
283
291
  ## Checkpoints and /rewind
284
292
 
@@ -235,6 +235,133 @@ ${unstaged}`);
235
235
  return { status, diff: text, truncated };
236
236
  }
237
237
 
238
+ // src/core/image.ts
239
+ var MAX_IMAGE_DIMENSION = 2e3;
240
+ var OVERSIZED_IMAGE_FALLBACK_BYTES = 5 * 1024 * 1024;
241
+ var IMAGE_REMOVED_PLACEHOLDER = "[image removed: too large for the model]";
242
+ var PNG_SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10];
243
+ function probePng(buf) {
244
+ if (buf.length < 24) return null;
245
+ for (let i = 0; i < PNG_SIGNATURE.length; i++) {
246
+ if (buf[i] !== PNG_SIGNATURE[i]) return null;
247
+ }
248
+ if (buf.toString("ascii", 12, 16) !== "IHDR") return null;
249
+ return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
250
+ }
251
+ var JPEG_SOF_MARKERS = /* @__PURE__ */ new Set([
252
+ 192,
253
+ 193,
254
+ 194,
255
+ 195,
256
+ 197,
257
+ 198,
258
+ 199,
259
+ 201,
260
+ 202,
261
+ 203,
262
+ 205,
263
+ 206,
264
+ 207
265
+ ]);
266
+ function probeJpeg(buf) {
267
+ if (buf.length < 4 || buf[0] !== 255 || buf[1] !== 216) return null;
268
+ let offset = 2;
269
+ while (offset + 3 < buf.length) {
270
+ if (buf[offset] !== 255) return null;
271
+ const marker = buf[offset + 1];
272
+ if (marker === void 0) return null;
273
+ if (marker === 216 || marker === 1 || marker >= 208 && marker <= 215) {
274
+ offset += 2;
275
+ continue;
276
+ }
277
+ const length = buf.readUInt16BE(offset + 2);
278
+ if (length < 2) return null;
279
+ if (JPEG_SOF_MARKERS.has(marker)) {
280
+ if (offset + 9 > buf.length) return null;
281
+ return { height: buf.readUInt16BE(offset + 5), width: buf.readUInt16BE(offset + 7) };
282
+ }
283
+ offset += 2 + length;
284
+ }
285
+ return null;
286
+ }
287
+ function probeGif(buf) {
288
+ if (buf.length < 10) return null;
289
+ const signature = buf.toString("ascii", 0, 6);
290
+ if (signature !== "GIF87a" && signature !== "GIF89a") return null;
291
+ return { width: buf.readUInt16LE(6), height: buf.readUInt16LE(8) };
292
+ }
293
+ function probeWebp(buf) {
294
+ if (buf.length < 30) return null;
295
+ if (buf.toString("ascii", 0, 4) !== "RIFF" || buf.toString("ascii", 8, 12) !== "WEBP") {
296
+ return null;
297
+ }
298
+ const chunk = buf.toString("ascii", 12, 16);
299
+ if (chunk === "VP8X") {
300
+ return { width: 1 + buf.readUIntLE(24, 3), height: 1 + buf.readUIntLE(27, 3) };
301
+ }
302
+ if (chunk === "VP8 ") {
303
+ return {
304
+ width: buf.readUInt16LE(26) & 16383,
305
+ height: buf.readUInt16LE(28) & 16383
306
+ };
307
+ }
308
+ if (chunk === "VP8L") {
309
+ if (buf[20] !== 47) return null;
310
+ const b1 = buf[21];
311
+ const b2 = buf[22];
312
+ const b3 = buf[23];
313
+ const b4 = buf[24];
314
+ return {
315
+ width: 1 + ((b2 & 63) << 8 | b1),
316
+ height: 1 + ((b4 & 15) << 10 | b3 << 2 | (b2 & 192) >> 6)
317
+ };
318
+ }
319
+ return null;
320
+ }
321
+ function probeImageDimensions(buf) {
322
+ return probePng(buf) ?? probeJpeg(buf) ?? probeGif(buf) ?? probeWebp(buf);
323
+ }
324
+ var IMAGE_SIZE_KEYWORDS = /too[\s-]*(large|big)|exceeds?|dimensions?|resolution|size\s*limit|max(?:imum)?[\s-]*size/i;
325
+ function isOversizedImageError(error) {
326
+ const status = error.statusCode;
327
+ if (typeof status !== "number" || status < 400 || status >= 500) return false;
328
+ return /image/i.test(error.message) && IMAGE_SIZE_KEYWORDS.test(error.message);
329
+ }
330
+ function imagePartBytes(image) {
331
+ if (typeof image === "string") {
332
+ const base64 = image.startsWith("data:") ? image.slice(image.indexOf(",") + 1) : image;
333
+ try {
334
+ return Buffer.from(base64, "base64");
335
+ } catch {
336
+ return null;
337
+ }
338
+ }
339
+ if (image instanceof Uint8Array) return Buffer.from(image);
340
+ return null;
341
+ }
342
+ function isOversizedImageData(buf) {
343
+ const dims = probeImageDimensions(buf);
344
+ if (dims) return Math.max(dims.width, dims.height) > MAX_IMAGE_DIMENSION;
345
+ return buf.length > OVERSIZED_IMAGE_FALLBACK_BYTES;
346
+ }
347
+ function stripOversizedImages(messages) {
348
+ let removed = 0;
349
+ const result = messages.map((message) => {
350
+ if (!Array.isArray(message.content)) return message;
351
+ let changed = false;
352
+ const content = message.content.map((part) => {
353
+ if (part.type !== "image") return part;
354
+ const bytes = imagePartBytes(part.image);
355
+ if (!bytes || !isOversizedImageData(bytes)) return part;
356
+ changed = true;
357
+ removed++;
358
+ return { type: "text", text: IMAGE_REMOVED_PLACEHOLDER };
359
+ });
360
+ return changed ? { ...message, content } : message;
361
+ });
362
+ return { messages: removed > 0 ? result : messages, removed };
363
+ }
364
+
238
365
  // src/core/messages.ts
239
366
  var MISSING_TOOL_RESULT_TEXT = "tool result missing (session was interrupted)";
240
367
  function reconcileToolCalls(messages) {
@@ -664,6 +791,12 @@ async function revert(snapshot) {
664
791
  await rm(snapshot.path, { force: true });
665
792
  return `Deleted ${snapshot.path} (it did not exist before ${snapshot.toolName}).`;
666
793
  }
794
+ function listTurnSnapshots(turn) {
795
+ return stack.filter((snapshot) => snapshot.turn === turn).reverse();
796
+ }
797
+ function resolveSnapshotContent(snapshot) {
798
+ return resolveContent(snapshot);
799
+ }
667
800
  async function undoTurnSnapshots(turn) {
668
801
  const matches = [];
669
802
  for (let i = stack.length - 1; i >= 0; i--) {
@@ -778,6 +911,9 @@ async function loadTodos(cwd) {
778
911
  await defaultStore.load(cwd);
779
912
  return defaultStore.list();
780
913
  }
914
+ function resetTodos() {
915
+ defaultStore.replace([]);
916
+ }
781
917
  function createTodoTools(store = defaultStore) {
782
918
  const todoWrite = {
783
919
  name: "todo_write",
@@ -789,10 +925,11 @@ function createTodoTools(store = defaultStore) {
789
925
  if (!parsed.success) {
790
926
  return { content: `Invalid todos: ${parsed.error.message}`, isError: true };
791
927
  }
792
- await store.load(ctx.cwd);
793
928
  store.replace(parsed.data.todos);
794
929
  await store.save(ctx.cwd);
795
- return { content: formatTodos(store.list()) };
930
+ const items = store.list();
931
+ const done = items.filter((it) => it.status === "done").length;
932
+ return { content: `Todo list updated (${done}/${items.length} done).` };
796
933
  }
797
934
  };
798
935
  const todoRead = {
@@ -800,8 +937,7 @@ function createTodoTools(store = defaultStore) {
800
937
  description: "Read the current todo list.",
801
938
  permission: "read",
802
939
  parameters: readSchema,
803
- async execute(_args, ctx) {
804
- await store.load(ctx.cwd);
940
+ async execute() {
805
941
  return { content: formatTodos(store.list()) };
806
942
  }
807
943
  };
@@ -954,6 +1090,9 @@ function sessionsDir() {
954
1090
  function userMemoryPath() {
955
1091
  return path5.join(starHome(), "MEMORY.md");
956
1092
  }
1093
+ function envFilePath() {
1094
+ return path5.join(starHome(), ".env");
1095
+ }
957
1096
  function projectConfigPath(cwd) {
958
1097
  return path5.join(cwd, ".star", "config.toml");
959
1098
  }
@@ -2155,7 +2294,7 @@ var MAX_SUBAGENT_DEPTH = 1;
2155
2294
  var MAX_REPORT_CHARS = 2e4;
2156
2295
  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.";
2157
2296
  async function runSubagent(deps, args, cwd, signal) {
2158
- const { AgentLoop: AgentLoop2 } = await import("./loop-EIODKYPT.js");
2297
+ const { AgentLoop: AgentLoop2 } = await import("./loop-ED6AAMWV.js");
2159
2298
  const child = new AgentLoop2({
2160
2299
  model: deps.model,
2161
2300
  registry: createDefaultRegistry(),
@@ -2300,6 +2439,13 @@ function createRememberTool(deps = {}) {
2300
2439
  var PLAN_MODE_PROMPT = "\n\nYou are currently in PLAN MODE. Research the task using only the read-only tools available to you, then present a concrete, step-by-step implementation plan as your final answer. You must not modify files, run shell commands, or otherwise change the system \u2014 write/exec tools are unavailable in this mode. Do not ask the user to run commands for you; note manual steps in the plan instead. The user will review your plan and approve it before any execution begins.";
2301
2440
  var PENDING_WORK_PATTERN = /(?:我将|我会|我现在|接下来|下一步|下面(?:我|将)|稍后|随后|准备开始|现在开始|马上|即将|这就|待会|开始(?:执行|进行|处理|转换|动手|生成|写入|运行|创建)|(?:完成后|然后|接着)会|让我(?:们)?(?:来)?|I will|I'll|I am going to|I'm going to|I shall|let me|next,? I|I will now)/i;
2302
2441
  var AUTO_CONTINUE_NUDGE = "[auto-continue] You ended your turn with words instead of actions. Do not describe or restate the plan \u2014 continue the task NOW by calling tools. Reply with text only if the task is already fully complete.";
2442
+ var EMPTY_REPLY_NUDGE = "[auto-continue] Your previous reply was empty \u2014 no text and no tool calls. If the task is fully complete, reply with one short confirmation; otherwise continue NOW by calling tools.";
2443
+ function isSteerableEmptyFinish(finishReason) {
2444
+ return finishReason === "stop" || finishReason === "content-filter";
2445
+ }
2446
+ function finalNudgeWarning(autoContinues, maxAutoContinues) {
2447
+ 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." : "";
2448
+ }
2303
2449
  var COMPLETION_CHECK_TIMEOUT_MS = 3e4;
2304
2450
  async function checkTaskComplete(model, request, finalReply, signal) {
2305
2451
  const controller = new AbortController();
@@ -2434,6 +2580,18 @@ var AgentLoop = class {
2434
2580
  this.messages = reconcileToolCalls(messages);
2435
2581
  this.turnMarkers = [];
2436
2582
  }
2583
+ // Read-only counterpart of retractLastTurn: how many messages /undo would
2584
+ // drop and which snapshot turn's file changes it would revert (undefined =
2585
+ // no verified turn, so no file snapshots may be reverted). Mutates nothing,
2586
+ // so the REPL can show the preview before the user confirms.
2587
+ previewLastTurnRetraction() {
2588
+ const result = retractLastTurn([...this.messages]);
2589
+ if (result.removed === 0) return { removed: 0 };
2590
+ const userIndex = result.messages.length;
2591
+ const last = this.turnMarkers[this.turnMarkers.length - 1];
2592
+ const turn = last && last.userIndex === userIndex ? last.seq : void 0;
2593
+ return { removed: result.removed, turn };
2594
+ }
2437
2595
  // Drops the final user message and everything after it, and persists the
2438
2596
  // trimmed history so a later /resume does not bring the turn back.
2439
2597
  // `turn` is the retracted turn's snapshot seq when it could be verified
@@ -2487,6 +2645,16 @@ var AgentLoop = class {
2487
2645
  async persist(message) {
2488
2646
  await this.opts.sessionStore?.append(message);
2489
2647
  }
2648
+ // Replaces oversized image parts in the in-memory history with text
2649
+ // placeholders and rewrites the persisted session, so the failed request
2650
+ // can be resent and a later resume never carries the offending images.
2651
+ async stripOversizedImages() {
2652
+ const { messages, removed } = stripOversizedImages(this.messages);
2653
+ if (removed === 0) return 0;
2654
+ this.messages = messages;
2655
+ await this.opts.sessionStore?.replaceMessages([...this.messages]);
2656
+ return removed;
2657
+ }
2490
2658
  // After the first assistant reply, kick off background title generation
2491
2659
  // for the session. Runs once per loop; the store-level title check makes
2492
2660
  // resumed sessions that already have a title a no-op.
@@ -2497,6 +2665,47 @@ var AgentLoop = class {
2497
2665
  this.titleScheduled = true;
2498
2666
  scheduleSessionTitle(store, input, this.opts.model);
2499
2667
  }
2668
+ // A user interrupt (Esc) ends the turn mid-stream, before the normal
2669
+ // assistant-message persistence runs. Whatever text and tool calls already
2670
+ // arrived are still persisted — the text marked with "[interrupted]" so the
2671
+ // cut-off stays visible after a resume and the model can tell its reply was
2672
+ // cut short — and streamed tool calls are closed with synthetic results so
2673
+ // the stored history stays valid for the API.
2674
+ async *persistInterrupted(text, toolCalls) {
2675
+ if (text.length === 0 && toolCalls.length === 0) return;
2676
+ const assistantMessage = {
2677
+ role: "assistant",
2678
+ content: [
2679
+ ...text ? [{ type: "text", text: `${text} [interrupted]` }] : [],
2680
+ ...toolCalls.map((call) => ({
2681
+ type: "tool-call",
2682
+ toolCallId: call.id,
2683
+ toolName: call.name,
2684
+ args: call.args
2685
+ }))
2686
+ ]
2687
+ };
2688
+ this.messages.push(assistantMessage);
2689
+ await this.persist(assistantMessage);
2690
+ for (const call of toolCalls) {
2691
+ const content = "Tool execution interrupted by user.";
2692
+ const synthetic = {
2693
+ role: "tool",
2694
+ content: [
2695
+ {
2696
+ type: "tool-result",
2697
+ toolCallId: call.id,
2698
+ toolName: call.name,
2699
+ result: content
2700
+ }
2701
+ ]
2702
+ };
2703
+ this.messages.push(synthetic);
2704
+ await this.persist(synthetic).catch(() => {
2705
+ });
2706
+ yield { type: "tool-result", id: call.id, name: call.name, content, isError: true };
2707
+ }
2708
+ }
2500
2709
  async *stream(input, signal, opts) {
2501
2710
  this.syncSystemMessage();
2502
2711
  const inputText = typeof input === "string" ? input : input.text;
@@ -2547,11 +2756,13 @@ ${finished.result}`
2547
2756
  let text = "";
2548
2757
  const toolCalls = [];
2549
2758
  let failure = null;
2759
+ let lastFinishReason;
2760
+ let oversizedImagesStripped = false;
2550
2761
  for (let attempt = 0; ; attempt++) {
2551
2762
  text = "";
2552
2763
  toolCalls.length = 0;
2553
2764
  failure = null;
2554
- let finishReason;
2765
+ lastFinishReason = void 0;
2555
2766
  const timeoutScale = Math.min(attempt + 1, 3);
2556
2767
  try {
2557
2768
  for await (const event of this.streamOnce(aiTools, signal, timeoutScale)) {
@@ -2564,7 +2775,7 @@ ${finished.result}`
2564
2775
  toolCalls.push({ id: event.id, name: event.name, args: event.args });
2565
2776
  yield event;
2566
2777
  } else if (event.type === "finish") {
2567
- finishReason = event.finishReason;
2778
+ lastFinishReason = event.finishReason;
2568
2779
  yield event;
2569
2780
  } else if (event.type === "error") {
2570
2781
  failure = event.error;
@@ -2573,18 +2784,35 @@ ${finished.result}`
2573
2784
  }
2574
2785
  }
2575
2786
  } catch (error) {
2576
- if (signal.aborted) return;
2577
- failure = error instanceof Error ? error : new Error(String(error));
2787
+ if (!signal.aborted) {
2788
+ failure = error instanceof Error ? error : new Error(String(error));
2789
+ }
2790
+ }
2791
+ if (signal.aborted) {
2792
+ yield* this.persistInterrupted(text, toolCalls);
2793
+ return;
2578
2794
  }
2579
- if (signal.aborted) return;
2580
2795
  if (!failure && (text.length > 0 || toolCalls.length > 0)) break;
2796
+ if (failure && !oversizedImagesStripped && isOversizedImageError(failure)) {
2797
+ oversizedImagesStripped = true;
2798
+ const removed = await this.stripOversizedImages();
2799
+ if (removed > 0) {
2800
+ yield {
2801
+ type: "notice",
2802
+ message: `Removed ${removed} oversized image(s) the model rejected; retrying the request.`
2803
+ };
2804
+ continue;
2805
+ }
2806
+ }
2581
2807
  if (failure && !isRetryableStreamError(failure)) break;
2582
2808
  if (attempt >= maxRetries) break;
2809
+ if (!failure && text.length === 0 && toolCalls.length === 0 && attempt >= 1 && isSteerableEmptyFinish(lastFinishReason))
2810
+ break;
2583
2811
  yield {
2584
2812
  type: "retry",
2585
2813
  attempt: attempt + 2,
2586
2814
  maxAttempts: maxRetries + 1,
2587
- reason: failure ? failure.message : `empty response (finish: ${finishReason ?? "none"})`
2815
+ reason: failure ? failure.message : `empty response (finish: ${lastFinishReason ?? "none"})`
2588
2816
  };
2589
2817
  if (!await sleep(baseDelay * 2 ** attempt, signal)) return;
2590
2818
  }
@@ -2593,6 +2821,21 @@ ${finished.result}`
2593
2821
  return;
2594
2822
  }
2595
2823
  if (text.length === 0 && toolCalls.length === 0) {
2824
+ if (isSteerableEmptyFinish(lastFinishReason) && config.permissionMode !== "plan" && autoContinues < config.maxAutoContinues) {
2825
+ autoContinues++;
2826
+ lastWasNudge = true;
2827
+ yield {
2828
+ type: "notice",
2829
+ message: `the model returned an empty reply; asking the model to continue (${autoContinues}/${config.maxAutoContinues}).`
2830
+ };
2831
+ const nudge = {
2832
+ role: "user",
2833
+ content: EMPTY_REPLY_NUDGE + finalNudgeWarning(autoContinues, config.maxAutoContinues)
2834
+ };
2835
+ this.messages.push(nudge);
2836
+ await this.persist(nudge);
2837
+ continue;
2838
+ }
2596
2839
  yield {
2597
2840
  type: "error",
2598
2841
  error: new Error("The model returned an empty response after retries; ending the turn.")
@@ -2637,16 +2880,26 @@ ${finished.result}`
2637
2880
  type: "notice",
2638
2881
  message: `${reason}; asking the model to continue (${autoContinues}/${config.maxAutoContinues}).`
2639
2882
  };
2640
- const nudge = { role: "user", content: nudgeText };
2883
+ const nudge = {
2884
+ role: "user",
2885
+ content: nudgeText + finalNudgeWarning(autoContinues, config.maxAutoContinues)
2886
+ };
2641
2887
  this.messages.push(nudge);
2642
2888
  await this.persist(nudge);
2643
2889
  continue;
2644
2890
  }
2891
+ if (reason !== null && config.permissionMode !== "plan") {
2892
+ yield {
2893
+ type: "notice",
2894
+ message: `${reason}; auto-continue limit reached, handing the turn back \u2014 reply "continue" to keep going.`
2895
+ };
2896
+ }
2645
2897
  await this.runEventHooks("Stop");
2646
2898
  return;
2647
2899
  }
2648
2900
  lastWasNudge = false;
2649
2901
  usedToolsThisTurn = true;
2902
+ autoContinues = 0;
2650
2903
  const answered = /* @__PURE__ */ new Set();
2651
2904
  try {
2652
2905
  for (const call of toolCalls) {
@@ -2760,7 +3013,7 @@ ${summary}`
2760
3013
  }
2761
3014
  }
2762
3015
  async *streamOnce(aiTools, signal, timeoutScale = 1) {
2763
- const { streamChat } = await import("./stream-Y55NRF2S.js");
3016
+ const { streamChat } = await import("./stream-YQCRUBO3.js");
2764
3017
  yield* streamChat({
2765
3018
  model: this.opts.model,
2766
3019
  messages: this.messages,
@@ -2870,6 +3123,8 @@ export {
2870
3123
  collectCommitContext,
2871
3124
  buildCommitPrompt,
2872
3125
  collectWorkingDiff,
3126
+ MAX_IMAGE_DIMENSION,
3127
+ probeImageDimensions,
2873
3128
  reconcileToolCalls,
2874
3129
  isAllowedByRules,
2875
3130
  buildAllowRule,
@@ -2877,16 +3132,20 @@ export {
2877
3132
  listSnapshots,
2878
3133
  clearSnapshots,
2879
3134
  hydrateSnapshots,
3135
+ listTurnSnapshots,
3136
+ resolveSnapshotContent,
2880
3137
  undoTurnSnapshots,
2881
3138
  rewindToSnapshot,
2882
3139
  TodoStore,
2883
3140
  formatTodos,
2884
3141
  parseTodoArgs,
2885
3142
  loadTodos,
3143
+ resetTodos,
2886
3144
  starHome,
2887
3145
  globalConfigPath,
2888
3146
  sessionsDir,
2889
3147
  userMemoryPath,
3148
+ envFilePath,
2890
3149
  projectConfigPath,
2891
3150
  discoverSkills,
2892
3151
  defaultTaskManager,
@@ -2903,4 +3162,4 @@ export {
2903
3162
  PLAN_MODE_PROMPT,
2904
3163
  AgentLoop
2905
3164
  };
2906
- //# sourceMappingURL=chunk-DZWTIMJ2.js.map
3165
+ //# sourceMappingURL=chunk-K4SIXWCI.js.map