@cryer/star-cli 0.3.9 → 0.3.10

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
@@ -66,7 +66,7 @@ maxSteps = 100
66
66
  contextCompaction = "summary" # summary | truncate — how over-budget history is compacted
67
67
  streamMaxRetries = 5 # extra attempts per model request on transient failures
68
68
  # (network error, 429/5xx, idle cutoff, empty reply); 0 disables
69
- streamIdleTimeoutSec = 20 # seconds with no stream output before a stalled response is
69
+ streamIdleTimeoutSec = 60 # seconds with no stream output before a stalled response is
70
70
  # ended gracefully (some relays never close the stream)
71
71
  streamFirstChunkTimeoutSec = 300 # seconds to wait for the very first content part — slow
72
72
  # thinking-model endpoints can stay silent for minutes
@@ -177,7 +177,7 @@ model = "kimi-for-coding"
177
177
  temperature = 1 # mandatory here — the endpoint rejects any other value
178
178
  contextMaxTokens = 128000
179
179
  # raise these when the relay buffers long generations:
180
- # streamIdleTimeoutSec = 60 # default 20
180
+ # streamIdleTimeoutSec = 120 # default 60
181
181
  # streamFirstChunkTimeoutSec = 600 # default 300
182
182
  ```
183
183
 
@@ -389,3 +389,12 @@ pnpm test:pipeline # layered pipeline incl. optional live LLM smoke (needs an
389
389
  CI runs lint, typecheck, tests, and build on Ubuntu + Windows against Node 20/22 (`.github/workflows/ci.yml`).
390
390
 
391
391
  Architecture: `src/cli` (Ink UI), `src/agent` (main loop), `src/llm` (Vercel AI SDK provider layer), `src/tools`, `src/context` (token budget + compaction), `src/permissions`, `src/session`, `src/config`.
392
+
393
+ ## Benchmark
394
+
395
+ [Terminal-Bench 2.0](https://github.com/laude-institute/terminal-bench), 20 randomly sampled tasks, same model for both agents:
396
+
397
+ | Agent | Model | Pass rate | Avg tokens | Avg duration |
398
+ |---|---|---|---|---|
399
+ | Star CLI 0.3.9 | Kimi K3 | 17/20 (85%) | 129.2K | 414s |
400
+ | terminus-2 2.0.0 | Kimi K3 | 9/20 (45%) | 49.5K | 484s |
package/README.zh-CN.md CHANGED
@@ -65,7 +65,7 @@ maxSteps = 100
65
65
  contextCompaction = "summary" # summary | truncate —— 历史超出预算时的压缩方式
66
66
  streamMaxRetries = 5 # 单次模型请求在瞬时失败(网络错误、429/5xx、idle 截断、
67
67
  # 空回复)时的额外重试次数;0 表示禁用
68
- streamIdleTimeoutSec = 20 # 流没有任何输出的秒数,超过后将停滞的响应优雅结束
68
+ streamIdleTimeoutSec = 60 # 流没有任何输出的秒数,超过后将停滞的响应优雅结束
69
69
  #(有些中转站从不关闭流)
70
70
  streamFirstChunkTimeoutSec = 300 # 等待第一个内容分片的秒数——慢速思考模型端点在接受请求后
71
71
  # 可能静默数分钟
@@ -172,7 +172,7 @@ model = "kimi-for-coding"
172
172
  temperature = 1 # 此处必须设置——该端点拒绝任何其他取值
173
173
  contextMaxTokens = 128000
174
174
  # 中转站长时间缓冲整段生成时可调大:
175
- # streamIdleTimeoutSec = 60 # 默认 20
175
+ # streamIdleTimeoutSec = 120 # 默认 60
176
176
  # streamFirstChunkTimeoutSec = 600 # 默认 300
177
177
  ```
178
178
 
@@ -384,3 +384,12 @@ pnpm test:pipeline # layered pipeline incl. optional live LLM smoke (needs an
384
384
  CI 在 Ubuntu + Windows 上、Node 20/22 下运行 lint、typecheck、测试和构建(`.github/workflows/ci.yml`)。
385
385
 
386
386
  架构:`src/cli`(Ink UI)、`src/agent`(主循环)、`src/llm`(Vercel AI SDK 服务商层)、`src/tools`、`src/context`(token 预算 + 压缩)、`src/permissions`、`src/session`、`src/config`。
387
+
388
+ ## 基准测试
389
+
390
+ [Terminal-Bench 2.0](https://github.com/laude-institute/terminal-bench),随机抽取 20 个任务,两个 agent 使用相同模型:
391
+
392
+ | Agent | 模型 | 通过率 | 平均 token 消耗 | 平均耗时 |
393
+ |---|---|---|---|---|
394
+ | Star CLI 0.3.9 | Kimi K3 | 17/20 (85%) | 129.2K | 414s |
395
+ | terminus-2 2.0.0 | Kimi K3 | 9/20 (45%) | 49.5K | 484s |
@@ -29,6 +29,7 @@ var UNFINISHED_TOOL_RESULT_TEXT = "Tool execution interrupted before a result wa
29
29
  function reconcileStarMessages(messages) {
30
30
  const result = [];
31
31
  let pending = [];
32
+ let deferred = [];
32
33
  const flushPending = () => {
33
34
  for (const call of pending) {
34
35
  result.push({
@@ -46,19 +47,28 @@ function reconcileStarMessages(messages) {
46
47
  });
47
48
  }
48
49
  pending = [];
50
+ result.push(...deferred);
51
+ deferred = [];
49
52
  };
50
53
  for (const star of messages) {
51
54
  const message = star.message;
52
55
  if (message.role === "assistant" && Array.isArray(message.content)) {
53
56
  flushPending();
54
57
  pending = message.content.filter((part) => part.type === "tool-call").map((part) => ({ toolCallId: part.toolCallId, toolName: part.toolName }));
58
+ result.push(star);
55
59
  } else if (message.role === "tool" && Array.isArray(message.content) && pending.length > 0) {
56
60
  const answered = new Set(message.content.map((part) => part.toolCallId));
57
61
  pending = pending.filter((call) => !answered.has(call.toolCallId));
62
+ result.push(star);
63
+ if (pending.length === 0) {
64
+ result.push(...deferred);
65
+ deferred = [];
66
+ }
67
+ } else if (pending.length > 0) {
68
+ deferred.push(star);
58
69
  } else {
59
- flushPending();
70
+ result.push(star);
60
71
  }
61
- result.push(star);
62
72
  }
63
73
  flushPending();
64
74
  return result;
@@ -182,8 +192,9 @@ var ConfigSchema = z.object({
182
192
  // Seconds without any stream part before a stalled response is ended
183
193
  // gracefully (some relays never send the terminal chunks). Applies once
184
194
  // content has started streaming; the wait for the first content part gets
185
- // a longer allowance (control/metadata parts don't count as content).
186
- streamIdleTimeoutSec: z.number().positive().default(20),
195
+ // a longer allowance (control/metadata and reasoning parts don't count as
196
+ // content).
197
+ streamIdleTimeoutSec: z.number().positive().default(60),
187
198
  // Seconds to wait for the very first content part before giving up; slow
188
199
  // thinking-model endpoints can stay silent for minutes after accepting
189
200
  // the request.
@@ -490,4 +501,4 @@ export {
490
501
  restoreTree,
491
502
  diffTreeNames
492
503
  };
493
- //# sourceMappingURL=chunk-56ZIA57D.js.map
504
+ //# sourceMappingURL=chunk-IJGUW4PM.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/core/messages.ts","../src/config/schema.ts","../src/snapshot/git-tree.ts"],"sourcesContent":["import type { CoreMessage, ToolCallPart } from \"ai\";\n\nexport type { CoreMessage };\n\n// Side-band metadata the model never sees. It rides next to a persisted\n// message (the __starMeta jsonl key) so turn-boundary scans can tell a real\n// user turn from a loop-injected user message:\n// - \"nudge\": auto-continue / resume-after-cutoff steering injected mid-turn\n// - \"bg-report\": a finished background subagent's report, delivered mid-turn\n// - \"tool-image\": images a tool (read_image/screenshot) attached mid-turn\nexport interface MessageMeta {\n synthetic?: \"nudge\" | \"bg-report\" | \"tool-image\";\n // Set by context/elision.ts when the message's bulky content (a stale tool\n // result, an old attached image) was replaced with a placeholder to free\n // the window — idempotency marker and debugging aid; the model never sees it.\n elided?: boolean;\n}\n\nexport interface StarMessage {\n message: CoreMessage;\n meta?: MessageMeta;\n}\n\nexport function toStarMessage(message: CoreMessage | StarMessage): StarMessage {\n return \"role\" in message ? { message } : message;\n}\n\nexport function toCoreMessages(messages: readonly StarMessage[]): CoreMessage[] {\n return messages.map((star) => star.message);\n}\n\n// A synthetic user message belongs to the turn that precedes it: it is never\n// a turn boundary for /undo, /rewind or compaction turn splitting.\nexport function isSyntheticUserMessage(star: StarMessage): boolean {\n return star.message.role === \"user\" && star.meta?.synthetic !== undefined;\n}\n\n// Plain-text content of a message: string content as-is; for array content,\n// the text parts joined with a space (tool calls, images and tool results\n// contribute nothing). Shared by the agent loop (/redo labels) and the CLI\n// (history rendering, conversation copy) so the extraction rule lives once.\nexport function coreMessageText(message: CoreMessage | undefined): string {\n if (!message) return \"\";\n const content = message.content;\n if (typeof content === \"string\") return content;\n if (Array.isArray(content)) {\n return content\n .filter((part) => part.type === \"text\")\n .map((part) => (\"text\" in part ? part.text : \"\"))\n .join(\" \");\n }\n return \"\";\n}\n\nexport interface ImageInput {\n path: string;\n mimeType: string;\n /** Base64-encoded image bytes. */\n data: string;\n}\n\nexport interface MultimodalInput {\n text: string;\n images: ImageInput[];\n}\n\nexport type ChatInput = string | MultimodalInput;\n\nexport interface ConversationState {\n messages: CoreMessage[];\n}\n\n// A tool call that has no real execution result must still be closed with a\n// synthetic tool message, or the stored history can no longer be sent to the\n// API. Three scenarios, three wordings:\n// - MISSING_TOOL_RESULT_TEXT: reconcileToolCalls/reconcileStarMessages repair\n// a persisted history whose session ended before the results were written\n// (crash, kill, older version) — the call may never have run at all.\n// - INTERRUPTED_TOOL_RESULT_TEXT: the user pressed Esc while the call was\n// still streaming in or executing (agent loop abort paths).\n// - UNFINISHED_TOOL_RESULT_TEXT: execution stopped for any other reason\n// (e.g. an error mid-batch) before the call produced a result.\nexport const MISSING_TOOL_RESULT_TEXT = \"tool result missing (session was interrupted)\";\nexport const INTERRUPTED_TOOL_RESULT_TEXT = \"Tool execution interrupted by user.\";\nexport const UNFINISHED_TOOL_RESULT_TEXT =\n \"Tool execution interrupted before a result was produced.\";\n\nexport function reconcileToolCalls(messages: CoreMessage[]): CoreMessage[] {\n const result: CoreMessage[] = [];\n let pending: { toolCallId: string; toolName: string }[] = [];\n // Non-tool messages that arrived while a tool-call batch was still open\n // (e.g. tool-attached image messages persisted by a version that\n // interleaved them between sibling tool results) are held back and emitted\n // once the batch closes: strict providers reject a user message sitting\n // inside the tool-result block (\"an assistant message with 'tool_calls'\n // must be followed by tool messages\"), and flushing synthetic fillers at\n // that point would also duplicate the real results that follow.\n let deferred: CoreMessage[] = [];\n\n const flushPending = () => {\n for (const call of pending) {\n result.push({\n role: \"tool\",\n content: [\n {\n type: \"tool-result\",\n toolCallId: call.toolCallId,\n toolName: call.toolName,\n result: MISSING_TOOL_RESULT_TEXT,\n },\n ],\n });\n }\n pending = [];\n result.push(...deferred);\n deferred = [];\n };\n\n for (const message of messages) {\n if (message.role === \"assistant\" && Array.isArray(message.content)) {\n flushPending();\n pending = message.content\n .filter((part): part is ToolCallPart => part.type === \"tool-call\")\n .map((part) => ({ toolCallId: part.toolCallId, toolName: part.toolName }));\n result.push(message);\n } else if (message.role === \"tool\" && Array.isArray(message.content) && pending.length > 0) {\n const answered = new Set(message.content.map((part) => part.toolCallId));\n pending = pending.filter((call) => !answered.has(call.toolCallId));\n result.push(message);\n if (pending.length === 0) {\n result.push(...deferred);\n deferred = [];\n }\n } else if (pending.length > 0) {\n deferred.push(message);\n } else {\n result.push(message);\n }\n }\n flushPending();\n return result;\n}\n\n// StarMessage counterpart of reconcileToolCalls: same repair, preserving the\n// side-band meta of every surviving message. Synthetic filler results carry\n// no meta.\nexport function reconcileStarMessages(messages: readonly StarMessage[]): StarMessage[] {\n const result: StarMessage[] = [];\n let pending: { toolCallId: string; toolName: string }[] = [];\n let deferred: StarMessage[] = [];\n\n const flushPending = () => {\n for (const call of pending) {\n result.push({\n message: {\n role: \"tool\",\n content: [\n {\n type: \"tool-result\",\n toolCallId: call.toolCallId,\n toolName: call.toolName,\n result: MISSING_TOOL_RESULT_TEXT,\n },\n ],\n },\n });\n }\n pending = [];\n result.push(...deferred);\n deferred = [];\n };\n\n for (const star of messages) {\n const message = star.message;\n if (message.role === \"assistant\" && Array.isArray(message.content)) {\n flushPending();\n pending = message.content\n .filter((part): part is ToolCallPart => part.type === \"tool-call\")\n .map((part) => ({ toolCallId: part.toolCallId, toolName: part.toolName }));\n result.push(star);\n } else if (message.role === \"tool\" && Array.isArray(message.content) && pending.length > 0) {\n const answered = new Set(message.content.map((part) => part.toolCallId));\n pending = pending.filter((call) => !answered.has(call.toolCallId));\n result.push(star);\n if (pending.length === 0) {\n result.push(...deferred);\n deferred = [];\n }\n } else if (pending.length > 0) {\n deferred.push(star);\n } else {\n result.push(star);\n }\n }\n flushPending();\n return result;\n}\n\n// Removes the last conversation turn: the final user message and everything\n// after it (assistant text, tool calls and results belonging to that turn).\n// Synthetic user messages (auto-continue nudges, background reports, tool\n// images) belong to the turn they sit in, so the scan skips them and the\n// boundary lands on the real user message that opened the turn. A leading\n// system message is never touched. Returns the trimmed list and how many\n// messages were dropped (0 = nothing to retract).\nexport function retractLastTurn(messages: StarMessage[]): {\n messages: StarMessage[];\n removed: number;\n} {\n for (let i = messages.length - 1; i >= 0; i--) {\n const star = messages[i];\n if (star && star.message.role === \"user\" && !isSyntheticUserMessage(star)) {\n return { messages: messages.slice(0, i), removed: messages.length - i };\n }\n }\n return { messages, removed: 0 };\n}\n","import { z } from \"zod\";\n\nexport const ProviderConfigSchema = z.object({\n name: z.string(),\n // openai-responses talks to the Responses API (/responses) instead of chat\n // completions — the wire format codex uses, and the native API of\n // reasoning-model relays that otherwise hide reasoning behind empty chat\n // completions. streamChat sends strictSchemas:false + store:false with it.\n protocol: z\n .enum([\"openai-compatible\", \"anthropic\", \"openai-responses\"])\n .default(\"openai-compatible\"),\n baseURL: z.string(),\n apiKeyEnv: z.string().optional(),\n apiKey: z.string().optional(),\n headers: z.record(z.string()).optional(),\n});\n\nexport const ModelConfigSchema = z.object({\n name: z.string(),\n provider: z.string(),\n model: z.string(),\n maxTokens: z.number().int().positive().optional(),\n // Per-model context window; overrides the top-level contextMaxTokens for\n // compaction and the status bar when set.\n contextMaxTokens: z.number().int().positive().optional(),\n // Reasoning intensity for thinking models, forwarded verbatim as\n // OpenAI-style reasoning_effort on every request (llm/provider.ts\n // reasoningEffortMetadata) — level naming varies by model/provider\n // (low|medium|high are common; some add minimal/max/xhigh/none), so any\n // non-empty string is accepted and the server validates it. Unset = the\n // request carries no effort field and the server-side default applies.\n // Anthropic maps effort to a thinking budget instead — ignored there.\n reasoningEffort: z.string().min(1).optional(),\n // Sampling temperature forwarded on every request. Unset = the AI SDK's\n // own default applies (ai@4 sends 0 — it does NOT omit the field). Some\n // endpoints reject anything but one explicit value (e.g. kimi-for-coding:\n // \"invalid temperature: only 1 is allowed\") — set it here. No upper\n // bound: ranges vary by provider and the server validates.\n temperature: z.number().min(0).optional(),\n // Per-model overrides of the top-level stream watchdog timeouts (unset =\n // the global values apply). Raise streamIdleTimeoutSec for relays that\n // buffer long generations and stall mid-stream between flushes.\n streamIdleTimeoutSec: z.number().positive().optional(),\n streamFirstChunkTimeoutSec: z.number().positive().optional(),\n // Text-only models: set false so image-producing tools (read_image,\n // screenshot) decline with a text error instead of attaching images the\n // endpoint would reject with an unrecoverable 4xx. Unset = images allowed.\n vision: z.boolean().optional(),\n // Cost estimation needs BOTH prices (USD per 1M tokens); with only one set\n // the model is treated as unpriced.\n promptPrice: z.number().nonnegative().optional(),\n completionPrice: z.number().nonnegative().optional(),\n // Optional price (USD per 1M tokens) for cache-read prompt tokens. When\n // unset, OpenAI-style cached tokens bill at promptPrice and Anthropic-style\n // cache reads are not billed — set this for accurate cache pricing.\n cacheReadPrice: z.number().nonnegative().optional(),\n});\n\nexport const PermissionsConfigSchema = z.object({\n allow: z.array(z.string()).default([]),\n deny: z.array(z.string()).default([]),\n // Rules that force a confirmation prompt in auto mode (same tool(glob)\n // syntax as allow/deny) — e.g. ask = [\"bash(git push *)\"]. Priority:\n // deny > ask > allow > mode default. Only meaningful in auto mode (ask\n // mode already prompts; readonly/plan are unaffected; yolo bypasses all).\n ask: z.array(z.string()).default([]),\n // Extra likely-secret file globs extending core/sensitive.ts's hardcoded\n // list (same single-* glob semantics as rules, matched case-insensitively\n // against the basename and the full normalized path). Global config only —\n // the project-config sandbox drops the whole [permissions] table.\n sensitive: z.array(z.string()).default([]),\n});\n\nexport const HookConfigSchema = z.object({\n event: z.enum([\"PreToolUse\", \"PostToolUse\", \"Stop\"]),\n matcher: z\n .string()\n .refine(\n (pattern) => {\n try {\n new RegExp(pattern);\n return true;\n } catch {\n return false;\n }\n },\n { message: \"invalid regular expression\" },\n )\n .optional(),\n command: z.string().min(1),\n timeoutSec: z.number().positive().default(30),\n});\n\nexport const ConfigSchema = z.object({\n defaultModel: z.string().default(\"\"),\n // Optional [[models]] name for cheap auxiliary LLM calls (compaction\n // summaries, session titles, turn completion checks) — Claude Code routes\n // these to a Haiku-class model. Unset = the active main model does them.\n smallModel: z.string().optional(),\n permissionMode: z.enum([\"auto\", \"ask\", \"readonly\", \"yolo\", \"plan\"]).default(\"ask\"),\n providers: z.array(ProviderConfigSchema).default([]),\n models: z.array(ModelConfigSchema).default([]),\n // Cap on model round-trips (\"steps\") per turn between progress checkpoints.\n // One step is a streamed reply plus every tool call it asked for — stream\n // retries and auto-continue nudges do not consume steps. Reaching the cap\n // while tools still execute resets the budget with a notice; the turn stops\n // only when no tool ran since the last checkpoint (the doom-loop\n // signature). 0 disables the cap entirely (opencode/codex have none).\n maxSteps: z.number().int().min(0).default(100),\n contextMaxTokens: z.number().int().positive().default(100_000),\n // Token threshold at which the agent loop auto-compacts the history.\n // Defaults to the effective context window (compact only when full); set a\n // lower value to compact earlier and keep headroom. Manual /compact ignores\n // this — it always compacts.\n compactThresholdTokens: z.number().int().positive().optional(),\n // Seconds without any stream part before a stalled response is ended\n // gracefully (some relays never send the terminal chunks). Applies once\n // content has started streaming; the wait for the first content part gets\n // a longer allowance (control/metadata and reasoning parts don't count as\n // content).\n streamIdleTimeoutSec: z.number().positive().default(60),\n // Seconds to wait for the very first content part before giving up; slow\n // thinking-model endpoints can stay silent for minutes after accepting\n // the request.\n streamFirstChunkTimeoutSec: z.number().positive().default(300),\n // Extra attempts per model request when a stream fails transiently\n // (network error, 429/5xx, idle watchdog cutoff) or comes back empty, so a\n // relay hiccup does not silently end a half-finished turn. 0 disables.\n // The wait between attempts honors Retry-After headers and otherwise backs\n // off exponentially with jitter (llm/retry.ts); retries after a\n // timeout/empty failure also scale both stream timeouts up (attempt number\n // ×, capped at 3x) since the relay is likely overloaded. Default 5 matches\n // opencode/Codex retry budgets and rides out flaky relays (中转站) better\n // than a minimal budget.\n streamMaxRetries: z.number().int().min(0).default(5),\n // How many consecutive unproductive replies may be nudged before the turn\n // is handed back: a text-only reply that announces pending work (\"我将…\",\n // \"I will…\") or answers a nudge with more words, or an empty reply that\n // survived the stream retry budget (steered with a nudge — a changed\n // request — since identical resends already failed). Tool calls reset the\n // count, so a productive task keeps going; exhaustion surfaces a notice\n // instead of stopping silently. Never fires in plan mode. 0 disables.\n maxAutoContinues: z.number().int().min(0).default(2),\n contextCompaction: z.enum([\"summary\", \"truncate\"]).default(\"summary\"),\n // Optional per-session dollar budget; when set, the REPL stops the session\n // once the estimated cost for the session reaches this amount.\n sessionBudgetUsd: z.number().positive().optional(),\n // Terminal bell (REPL only): ring when a turn takes longer than\n // notifyBellThresholdSec, and when a background task finishes.\n // STAR_NO_NOTIFY=1 disables without touching the config.\n notifyBell: z.boolean().default(true),\n notifyBellThresholdSec: z.number().positive().default(10),\n permissions: PermissionsConfigSchema.default({ allow: [], deny: [] }),\n hooks: z.array(HookConfigSchema).default([]),\n // Consecutive identical tool calls (same tool + same arguments) allowed\n // before the loop refuses to execute the repeat and tells the model to\n // change approach — a guard against agents stuck retrying a failing call\n // (opencode's doom_loop). 0 disables.\n doomLoopThreshold: z.number().int().min(0).default(3),\n // Track the whole working tree with an internal git repo (outside the\n // user's .git) at every turn boundary, so /undo and /redo can restore\n // changes made by ANY tool (bash included), not just write_file/edit_file.\n // Falls back silently to per-file snapshots when git is unavailable.\n gitSnapshots: z.boolean().default(true),\n // web_fetch SSRF guard escape hatch: by default target hosts are resolved\n // before connecting and private/reserved ranges are refused (loopback,\n // RFC1918, link-local incl. the cloud metadata address 169.254.169.254,\n // ULA, and localhost/.local/.internal names; redirect hops re-checked).\n // true disables the guard for intranet use. Global config only — not in\n // the project-config sandbox whitelist.\n webFetchAllowPrivateHosts: z.boolean().default(false),\n});\n\nexport type ProviderConfig = z.infer<typeof ProviderConfigSchema>;\nexport type ModelConfig = z.infer<typeof ModelConfigSchema>;\nexport type PermissionsConfig = z.infer<typeof PermissionsConfigSchema>;\nexport type HookConfig = z.infer<typeof HookConfigSchema>;\nexport type StarConfig = z.infer<typeof ConfigSchema>;\n\n// Effective context window for a model: its own contextMaxTokens when set,\n// otherwise the top-level config.contextMaxTokens.\nexport function contextWindowTokens(config: StarConfig, modelName: string): number {\n return (\n config.models.find((m) => m.name === modelName)?.contextMaxTokens ?? config.contextMaxTokens\n );\n}\n\n// Token threshold that triggers automatic compaction in the agent loop:\n// config.compactThresholdTokens when set, clamped to the window so a stale\n// value (e.g. after switching to a smaller model) never pushes compaction\n// past it; otherwise the window itself (current behavior).\nexport function resolveCompactThreshold(config: StarConfig, windowTokens: number): number {\n return Math.min(config.compactThresholdTokens ?? windowTokens, windowTokens);\n}\n\nexport interface CliOverrides {\n model?: string;\n permissionMode?: \"auto\" | \"ask\" | \"readonly\" | \"yolo\" | \"plan\";\n}\n","import { execFile } from \"node:child_process\";\nimport { randomBytes } from \"node:crypto\";\nimport { existsSync } from \"node:fs\";\nimport { mkdir, readdir, rm, writeFile } from \"node:fs/promises\";\nimport os from \"node:os\";\nimport path from \"node:path\";\nimport { gitTreesDir } from \"../config/paths\";\nimport { isSensitivePath } from \"../core/sensitive\";\n\nconst GIT_TIMEOUT_MS = 10_000;\n// gc repacks the object store and legitimately takes longer than the budget\n// every other git invocation gets; it runs rarely, so it can afford to wait.\nconst GC_TIMEOUT_MS = 60_000;\nconst GIT_MAX_BUFFER = 8 * 1024 * 1024;\n// `git gc --auto` runs after this many successful tree captures per repo\n// (each capture adds a tree plus blobs, and nothing else ever prunes them).\nconst GC_INTERVAL = 50;\n// The agent loop calls trackTree at the start of every turn; without a\n// negative cache a directory whose tracking fails (huge tree hitting the git\n// timeout, unreadable repo) would burn that timeout on every single turn.\nconst TRACK_FAILURE_TTL_MS = 5 * 60 * 1000;\n// Every captured tree is pinned under refs/star/<timestamp>-<random> so a\n// `gc --auto` prune (default gc.pruneExpire of two weeks) can never drop a\n// live /undo target. The namespace is trimmed to the newest this many refs,\n// matching the per-file snapshot stack cap.\nconst MAX_PINNED_REFS = 50;\nconst PINNED_REF_PREFIX = \"refs/star/\";\n\n// Likely-secret files that must never enter the internal snapshot repos,\n// pinned into each repo's info/exclude so `git add -A` skips them even in\n// projects with no .gitignore and `git clean -fd` on restore leaves them\n// alone. Mirrors isSensitivePath (src/core/sensitive.ts), including the\n// template exemptions.\nconst SENSITIVE_EXCLUDE_PATTERNS = [\n \".env\",\n \".env.*\",\n \"!.env.example\",\n \"!.env.sample\",\n \"!.env.template\",\n \"id_rsa\",\n \"id_dsa\",\n \"id_ecdsa\",\n \"id_ed25519\",\n \".npmrc\",\n \".netrc\",\n \".pgpass\",\n \"*.pem\",\n \"*.key\",\n \"*.p12\",\n \"*.pfx\",\n \"**/.aws/credentials\",\n \"**/.kube/config\",\n];\n\n// Forced config for every invocation: no CRLF translation (a file captured\n// with LF endings must restore byte-identically even on a machine whose\n// global config sets core.autocrlf=true) and no LFS filters rewriting blob\n// contents. Command-line -c beats repo/global/system config, so repos\n// created before this fix are covered too.\nconst GIT_CONFIG_ARGS = [\n \"-c\",\n \"core.autocrlf=false\",\n \"-c\",\n \"core.eol=native\",\n \"-c\",\n \"filter.lfs.process=\",\n \"-c\",\n \"filter.lfs.smudge=\",\n \"-c\",\n \"filter.lfs.clean=\",\n];\n\n// The child gets the parent environment minus every GIT_* variable (matched\n// case-insensitively: Windows env keys are). A star launched from a git hook\n// or rebase inherits GIT_DIR/GIT_INDEX_FILE pointing at the user's repo, and\n// GIT_INDEX_FILE redirects index writes even past an explicit --git-dir.\nfunction gitEnv(): NodeJS.ProcessEnv {\n const env: NodeJS.ProcessEnv = {};\n for (const [key, value] of Object.entries(process.env)) {\n if (value !== undefined && !key.toUpperCase().startsWith(\"GIT_\")) env[key] = value;\n }\n return env;\n}\n\n// Every git invocation goes through here: array arguments (never a shell), a\n// per-call timeout, and any failure — git missing, repo corrupt, timeout —\n// resolves null so the snapshot feature degrades silently instead of breaking\n// the agent loop.\nfunction runGit(args: string[], timeoutMs = GIT_TIMEOUT_MS): Promise<string | null> {\n return new Promise((resolve) => {\n execFile(\n \"git\",\n [...GIT_CONFIG_ARGS, ...args],\n { timeout: timeoutMs, maxBuffer: GIT_MAX_BUFFER, encoding: \"utf8\", env: gitEnv() },\n (error, stdout) => {\n resolve(error ? null : stdout.trim());\n },\n );\n });\n}\n\nlet gitAvailable: boolean | null = null;\n\n// Expiry timestamps (Date.now) of the per-directory failure negative cache.\nconst trackFailureUntil = new Map<string, number>();\n// Successful captures per repo since process start, driving the periodic gc.\nconst trackCounts = new Map<string, number>();\n// Repos whose index was already scrubbed of excluded entries this process.\nconst scrubbedRepos = new Set<string>();\n\nexport async function isGitAvailable(): Promise<boolean> {\n if (gitAvailable === null) {\n gitAvailable = (await runGit([\"--version\"])) !== null;\n }\n return gitAvailable;\n}\n\n// Test hook: drop the cached availability probe so a mocked execFile takes\n// effect without reloading the module.\nexport function resetGitTreeAvailability(): void {\n gitAvailable = null;\n}\n\n// Test hook: drop every piece of cached state (availability probe, failure\n// negative cache, gc counters, scrub bookkeeping, capture chains).\nexport function resetGitTreeCaches(): void {\n gitAvailable = null;\n trackFailureUntil.clear();\n trackCounts.clear();\n scrubbedRepos.clear();\n trackChains.clear();\n}\n\n// One internal bare repo per project directory, named by the resolved path so\n// two directories never share a tree store.\nexport function treeRepoDir(cwd: string): string {\n const key = Buffer.from(path.resolve(cwd), \"utf8\").toString(\"base64url\");\n return path.join(gitTreesDir(), key);\n}\n\n// Removes a project's internal snapshot repo. Called when the project's last\n// session is cleared: the captured trees only make sense as /undo//redo\n// targets for session turns, and with no session left they are unreachable\n// disk usage. In-memory bookkeeping goes too, or a later session in the same\n// process would trust a failure cache / gc counter for a repo that no longer\n// exists.\nexport async function removeTreeRepo(cwd: string): Promise<void> {\n const dir = treeRepoDir(cwd);\n trackFailureUntil.delete(path.resolve(cwd));\n trackCounts.delete(dir);\n scrubbedRepos.delete(dir);\n await rm(dir, { recursive: true, force: true }).catch(() => {});\n}\n\n// Deletes every tree repo whose owning project has no surviving session.\n// remainingCwds are the cwd values of the sessions still on disk; repos are\n// keyed by resolved path (see treeRepoDir), so a basename compare keeps\n// exactly those.\nexport async function pruneTreeReposExcept(remainingCwds: Iterable<string>): Promise<void> {\n let entries: string[];\n try {\n entries = await readdir(gitTreesDir());\n } catch {\n return;\n }\n const keep = new Set<string>();\n for (const cwd of remainingCwds) keep.add(path.basename(treeRepoDir(cwd)));\n await Promise.all(\n entries\n .filter((entry) => !keep.has(entry))\n .map((entry) =>\n rm(path.join(gitTreesDir(), entry), { recursive: true, force: true }).catch(() => {}),\n ),\n );\n}\n\n// Snapshotting the home directory or a filesystem root would track an entire\n// machine — refuse rather than let a stray session there index everything.\nfunction isGuardedDir(cwd: string): boolean {\n const resolved = path.resolve(cwd);\n const home = path.resolve(os.homedir());\n const same = (a: string, b: string) =>\n process.platform === \"win32\" ? a.toLowerCase() === b.toLowerCase() : a === b;\n return same(resolved, home) || same(resolved, path.parse(resolved).root);\n}\n\nasync function ensureTreeRepo(cwd: string): Promise<string | null> {\n const dir = treeRepoDir(cwd);\n try {\n // Owner-only like the session directories: tree objects hold file\n // contents the user never committed anywhere else.\n await mkdir(dir, { recursive: true, mode: 0o700 });\n if (!existsSync(path.join(dir, \"HEAD\"))) {\n const out = await runGit([\"init\", \"--bare\", dir]);\n if (out === null) return null;\n // Belt and braces for fresh repos; runGit's forced -c flags already\n // cover repos created before the repo-local setting existed.\n await runGit([`--git-dir=${dir}`, \"config\", \"core.autocrlf\", \"false\"]);\n }\n // Rewritten on every call: repos created before the exclusion existed\n // pick it up here without any migration step.\n await mkdir(path.join(dir, \"info\"), { recursive: true });\n await writeFile(\n path.join(dir, \"info\", \"exclude\"),\n `${SENSITIVE_EXCLUDE_PATTERNS.join(\"\\n\")}\\n`,\n \"utf8\",\n );\n } catch {\n return null;\n }\n return dir;\n}\n\nfunction repoArgs(dir: string, cwd: string): string[] {\n return [`--git-dir=${dir}`, `--work-tree=${cwd}`];\n}\n\n// Drops index entries that match the exclude rules. Ignore patterns only keep\n// untracked files out of `git add`; a path already in the index — tracked by\n// a pre-exclusion star, or put back by read-tree of an old tree — stays until\n// explicitly removed, so both trackTree and restoreTree scrub it.\nasync function untrackExcluded(dir: string, cwd: string): Promise<boolean> {\n const base = repoArgs(dir, cwd);\n const listed = await runGit([...base, \"ls-files\", \"-z\", \"-c\", \"-i\", \"--exclude-standard\"]);\n if (listed === null) return false;\n const files = listed.split(\"\\0\").filter(Boolean);\n if (files.length === 0) return true;\n const removed = await runGit([\n ...base,\n \"rm\",\n \"--cached\",\n \"--ignore-unmatch\",\n \"-q\",\n \"--\",\n ...files.map((file) => `:(literal)${file}`),\n ]);\n return removed !== null;\n}\n\n// Captures the whole working tree (any change — write_file, edit_file, bash)\n// as a git tree object and returns its hash. The work tree's own .git is\n// skipped by git automatically; ignored files (node_modules) and excluded\n// likely-secret files never enter the tree, which is exactly what the restore\n// side relies on. A recent failure makes further attempts no-ops until\n// TRACK_FAILURE_TTL_MS has passed.\nexport function trackTree(cwd: string): Promise<string | null> {\n // Serialize captures per directory: the agent loop starts a turn's capture\n // concurrently and a quick second turn (or the /undo path) can arrive while\n // it still runs — two overlapping captures would race on the internal\n // repo's index.lock, and the loser's spurious failure would poison the\n // negative cache for five minutes. trackTreeUncached never rejects, so the\n // chain cannot wedge; each call still captures its own tree, in order.\n const key = path.resolve(cwd);\n const next = (trackChains.get(key) ?? Promise.resolve(null)).then(() => trackTreeGuarded(cwd));\n trackChains.set(key, next);\n return next;\n}\n\n// Per-cwd promise chains serializing trackTree (see above). Values stay\n// settled promises between captures; the map is keyed by directory, so it\n// stays tiny.\nconst trackChains = new Map<string, Promise<string | null>>();\n\nasync function trackTreeGuarded(cwd: string): Promise<string | null> {\n if (isGuardedDir(cwd)) return null;\n const key = path.resolve(cwd);\n const failedUntil = trackFailureUntil.get(key);\n if (failedUntil !== undefined && Date.now() < failedUntil) return null;\n const tree = await trackTreeUncached(cwd);\n if (tree === null) {\n trackFailureUntil.set(key, Date.now() + TRACK_FAILURE_TTL_MS);\n return null;\n }\n trackFailureUntil.delete(key);\n return tree;\n}\n\n// Pins a captured tree under refs/star/<timestamp>-<random> so gc --auto\n// never prunes a live /undo target, then trims the namespace to the newest\n// MAX_PINNED_REFS refs: for-each-ref sorts by refname, and equal-length\n// millisecond timestamps make that order chronological. Pure housekeeping —\n// a failure here never fails the capture.\nasync function pinTree(base: string[], tree: string): Promise<void> {\n const ref = `${PINNED_REF_PREFIX}${Date.now()}-${randomBytes(4).toString(\"hex\")}`;\n if ((await runGit([...base, \"update-ref\", ref, tree])) === null) return;\n const listed = await runGit([...base, \"for-each-ref\", \"--format=%(refname)\", PINNED_REF_PREFIX]);\n if (listed === null) return;\n const refs = listed.split(\"\\n\").filter(Boolean);\n const stale = refs.slice(0, Math.max(0, refs.length - MAX_PINNED_REFS));\n await Promise.all(stale.map((name) => runGit([...base, \"update-ref\", \"-d\", name])));\n}\n\nasync function trackTreeUncached(cwd: string): Promise<string | null> {\n if (!(await isGitAvailable())) return null;\n const dir = await ensureTreeRepo(cwd);\n if (!dir) return null;\n const base = repoArgs(dir, cwd);\n if (!scrubbedRepos.has(dir)) {\n if (!(await untrackExcluded(dir, cwd))) return null;\n scrubbedRepos.add(dir);\n }\n if ((await runGit([...base, \"add\", \"-A\"])) === null) return null;\n const tree = await runGit([...base, \"write-tree\"]);\n if (tree === null) return null;\n await pinTree(base, tree);\n const count = (trackCounts.get(dir) ?? 0) + 1;\n trackCounts.set(dir, count);\n if (count % GC_INTERVAL === 0) {\n // Pure housekeeping: the result is ignored so a gc failure never fails\n // the turn, matching the module's silent-degradation style.\n await runGit([`--git-dir=${dir}`, \"gc\", \"--auto\"], GC_TIMEOUT_MS);\n }\n return tree;\n}\n\n// Restores the working tree to a captured tree: index reset to the tree,\n// every index file checked out over the work tree, then untracked\n// (post-snapshot) files cleaned. `clean` keeps ignored files, so dependencies\n// and excluded secrets survive. checkout-index -a only writes paths present\n// in the index, so scrubbing excluded entries after read-tree guarantees a\n// tree captured before the exclusion existed still cannot overwrite the\n// user's current .env (or delete it via clean, which also skips it).\nexport async function restoreTree(cwd: string, hash: string): Promise<boolean> {\n if (isGuardedDir(cwd)) return false;\n if (!(await isGitAvailable())) return false;\n const dir = await ensureTreeRepo(cwd);\n if (!dir) return false;\n const base = repoArgs(dir, cwd);\n if ((await runGit([...base, \"read-tree\", hash])) === null) return false;\n if (!(await untrackExcluded(dir, cwd))) return false;\n if ((await runGit([...base, \"checkout-index\", \"-f\", \"-a\"])) === null) return false;\n if ((await runGit([...base, \"clean\", \"-fd\"])) === null) return false;\n return true;\n}\n\n// Read-only list of files restoring a tree would touch: tracked paths that\n// differ from the tree, plus untracked (non-ignored) paths the clean step\n// would remove. Backs the /undo and /redo previews.\nexport async function diffTreeNames(cwd: string, hash: string): Promise<string[] | null> {\n if (!(await isGitAvailable())) return null;\n const dir = treeRepoDir(cwd);\n if (!existsSync(path.join(dir, \"HEAD\"))) return null;\n const base = repoArgs(dir, cwd);\n const changed = await runGit([...base, \"diff\", \"--name-only\", hash]);\n const untracked = await runGit([...base, \"ls-files\", \"--others\", \"--exclude-standard\"]);\n if (changed === null || untracked === null) return null;\n const names = new Set<string>();\n for (const line of [...changed.split(\"\\n\"), ...untracked.split(\"\\n\")]) {\n // Restore never touches excluded likely-secret paths (trees captured\n // before the exclusion may still list them); keep the preview honest.\n if (line !== \"\" && !isSensitivePath(line)) names.add(line);\n }\n return [...names];\n}\n"],"mappings":"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","names":[]}
@@ -12,7 +12,7 @@ import {
12
12
  toCoreMessages,
13
13
  toStarMessage,
14
14
  trackTree
15
- } from "./chunk-56ZIA57D.js";
15
+ } from "./chunk-IJGUW4PM.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-URC7JXKX.js";
28
28
  import {
29
29
  TodoStore,
30
30
  beginTurn,
@@ -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,26 @@ function textWeight(text) {
66
76
  }
67
77
  return text.length + cjk * (CHARS_PER_TOKEN - 1);
68
78
  }
69
- function contentCharLength(message) {
79
+ function contentTokenLength(message) {
70
80
  if (message.role === "tool") {
71
- let chars2 = 0;
81
+ let tokens2 = 0;
72
82
  for (const part of message.content) {
73
- chars2 += textWeight(JSON.stringify(part.result ?? null));
83
+ tokens2 += countTextTokens(JSON.stringify(part.result ?? null));
74
84
  }
75
- return chars2;
85
+ return tokens2;
76
86
  }
77
87
  if (typeof message.content === "string") {
78
- return textWeight(message.content);
88
+ return countTextTokens(message.content);
79
89
  }
80
- let chars = 0;
90
+ let tokens = 0;
81
91
  for (const part of message.content) {
82
92
  if (part.type === "text" || part.type === "reasoning") {
83
- chars += textWeight(part.text);
93
+ tokens += countTextTokens(part.text);
84
94
  } else if (part.type === "tool-call") {
85
- chars += textWeight(part.toolName) + textWeight(JSON.stringify(part.args ?? null));
95
+ tokens += countTextTokens(part.toolName) + countTextTokens(JSON.stringify(part.args ?? null));
86
96
  }
87
97
  }
88
- return chars;
98
+ return tokens;
89
99
  }
90
100
  function imagePartCount(message) {
91
101
  if (typeof message.content === "string" || message.role === "tool") return 0;
@@ -95,7 +105,7 @@ var tokenCache = /* @__PURE__ */ new WeakMap();
95
105
  function estimateMessageTokens(message) {
96
106
  const cached = tokenCache.get(message);
97
107
  if (cached !== void 0) return cached;
98
- const tokens = Math.ceil(contentCharLength(message) / CHARS_PER_TOKEN) + MESSAGE_OVERHEAD + imagePartCount(message) * IMAGE_PART_TOKENS;
108
+ const tokens = contentTokenLength(message) + MESSAGE_OVERHEAD + imagePartCount(message) * IMAGE_PART_TOKENS;
99
109
  tokenCache.set(message, tokens);
100
110
  return tokens;
101
111
  }
@@ -123,14 +133,14 @@ function parametersWireJson(parameters) {
123
133
  }
124
134
  }
125
135
  function estimateToolSchemaTokens(tools) {
126
- let weight = 0;
136
+ let tokens = 0;
127
137
  for (const [name, tool] of Object.entries(tools)) {
128
- weight += textWeight(name);
138
+ tokens += countTextTokens(name);
129
139
  const description = tool.description;
130
- if (typeof description === "string") weight += textWeight(description);
131
- weight += textWeight(parametersWireJson(tool.parameters));
140
+ if (typeof description === "string") tokens += countTextTokens(description);
141
+ tokens += countTextTokens(parametersWireJson(tool.parameters));
132
142
  }
133
- return Math.ceil(weight / CHARS_PER_TOKEN);
143
+ return tokens;
134
144
  }
135
145
 
136
146
  // src/context/compaction.ts
@@ -283,6 +293,55 @@ function compactMessages(messages, maxTokens, opts = {}) {
283
293
  };
284
294
  }
285
295
 
296
+ // src/context/elision.ts
297
+ var ACTIVATION_FRACTION = 0.5;
298
+ var KEEP_RECENT_MESSAGES = 24;
299
+ var MIN_ELISION_TOKENS = 250;
300
+ 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]";
301
+ var ELIDED_IMAGE_TEXT = "[image elided to free context \u2014 re-attach it if it is still needed]";
302
+ function elideStaleContent(messages, thresholdTokens, overheadTokens = 0) {
303
+ let total = overheadTokens;
304
+ for (const star of messages) total += estimateMessageTokens(star.message);
305
+ if (total < thresholdTokens * ACTIVATION_FRACTION) return null;
306
+ const cutoff = messages.length - KEEP_RECENT_MESSAGES;
307
+ if (cutoff <= 0) return null;
308
+ let elidedCount = 0;
309
+ const out = messages.slice();
310
+ for (let i = 0; i < cutoff; i++) {
311
+ const star = out[i];
312
+ if (!star || star.meta?.elided) continue;
313
+ const message = star.message;
314
+ if (message.role === "tool") {
315
+ if (estimateMessageTokens(message) < MIN_ELISION_TOKENS) continue;
316
+ out[i] = {
317
+ message: {
318
+ ...message,
319
+ content: message.content.map((part) => ({
320
+ ...part,
321
+ result: ELIDED_TOOL_RESULT_TEXT
322
+ }))
323
+ },
324
+ meta: { ...star.meta, elided: true }
325
+ };
326
+ elidedCount++;
327
+ } else if (message.role === "user" && Array.isArray(message.content)) {
328
+ if (!message.content.some((part) => part.type === "image" || part.type === "file")) continue;
329
+ out[i] = {
330
+ message: {
331
+ ...message,
332
+ content: message.content.map(
333
+ (part) => part.type === "image" || part.type === "file" ? { type: "text", text: ELIDED_IMAGE_TEXT } : part
334
+ )
335
+ },
336
+ meta: { ...star.meta, elided: true }
337
+ };
338
+ elidedCount++;
339
+ }
340
+ }
341
+ if (elidedCount === 0) return null;
342
+ return { messages: out, elidedCount };
343
+ }
344
+
286
345
  // src/core/events.ts
287
346
  function usageTotalTokens(usage) {
288
347
  const summed = usage.promptTokens + usage.completionTokens;
@@ -651,9 +710,9 @@ function summarizeStreamError(error) {
651
710
 
652
711
  // src/llm/stream.ts
653
712
  import { streamText } from "ai";
654
- var DEFAULT_IDLE_TIMEOUT_MS = 2e4;
713
+ var DEFAULT_IDLE_TIMEOUT_MS = 6e4;
655
714
  var DEFAULT_FIRST_PART_TIMEOUT_MS = 12e4;
656
- var GENERATION_PART_TYPES = /* @__PURE__ */ new Set(["text-delta", "reasoning", "tool-call", "tool-call-delta"]);
715
+ var CONTENT_PART_TYPES = /* @__PURE__ */ new Set(["text-delta", "tool-call", "tool-call-delta"]);
657
716
  var MAX_KEEPALIVE_SILENCE_MS = 10 * 6e4;
658
717
  var CACHE_READ_KEYS = ["cacheReadInputTokens", "cache_read_input_tokens"];
659
718
  var CACHED_PROMPT_KEYS = ["cachedPromptTokens", "cached_prompt_tokens", "cachedTokens"];
@@ -791,7 +850,7 @@ async function* streamChat(opts) {
791
850
  }
792
851
  const part = next.value;
793
852
  lastPartAt = Date.now();
794
- if (GENERATION_PART_TYPES.has(part.type)) seenContent = true;
853
+ if (CONTENT_PART_TYPES.has(part.type)) seenContent = true;
795
854
  switch (part.type) {
796
855
  case "text-delta":
797
856
  deltas++;
@@ -1209,7 +1268,15 @@ function buildAllowRule(req) {
1209
1268
  }
1210
1269
 
1211
1270
  // src/permissions/gate.ts
1212
- var FILE_TOOLS = /* @__PURE__ */ new Set(["write_file", "edit_file", "read_file", "read_image", "grep", "glob"]);
1271
+ var FILE_TOOLS = /* @__PURE__ */ new Set([
1272
+ "write_file",
1273
+ "edit_file",
1274
+ "read_file",
1275
+ "read_image",
1276
+ "grep",
1277
+ "glob",
1278
+ "code_outline"
1279
+ ]);
1213
1280
  var FORK_BOMB = /:\(\)\s*\{\s*:\|:&\s*\}\s*;:/;
1214
1281
  var MAX_ANALYSIS_DEPTH = 8;
1215
1282
  var SH_FAMILY = /* @__PURE__ */ new Set(["sh", "bash", "zsh", "dash", "ash"]);
@@ -1981,7 +2048,8 @@ import { randomUUID } from "crypto";
1981
2048
  import { z as z2 } from "zod";
1982
2049
  var MAX_SUBAGENT_DEPTH = 1;
1983
2050
  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.";
2051
+ 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.";
2052
+ 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
2053
  function convertUsagePricing(usage, from, to, config, names) {
1986
2054
  if (from === to) return usage;
1987
2055
  const fromCfg = findModelPricing(config, names?.fromName, from.modelId);
@@ -2019,7 +2087,7 @@ function findModelPricing(config, name, modelId) {
2019
2087
  return config.models.find((m) => m.model === modelId);
2020
2088
  }
2021
2089
  async function runSubagent(deps, args, cwd, signal, agentIdRef) {
2022
- const { AgentLoop: AgentLoop2 } = await import("./loop-PVOSF6LR.js");
2090
+ const { AgentLoop: AgentLoop2 } = await import("./loop-TW2FHL7J.js");
2023
2091
  const child = new AgentLoop2({
2024
2092
  model: deps.model,
2025
2093
  registry: createDefaultRegistry(),
@@ -2027,13 +2095,18 @@ async function runSubagent(deps, args, cwd, signal, agentIdRef) {
2027
2095
  cwd,
2028
2096
  system: deps.system ? `${deps.system}
2029
2097
 
2030
- ${SUBAGENT_PROMPT}` : SUBAGENT_PROMPT,
2098
+ ${SUBAGENT_PROMPT}${args.read_only ? `
2099
+
2100
+ ${READ_ONLY_PROMPT}` : ""}` : `${SUBAGENT_PROMPT}${args.read_only ? `
2101
+
2102
+ ${READ_ONLY_PROMPT}` : ""}`,
2031
2103
  providerMetadata: deps.providerMetadata,
2032
2104
  temperature: deps.temperature,
2033
2105
  streamIdleTimeoutSec: deps.streamIdleTimeoutSec,
2034
2106
  streamFirstChunkTimeoutSec: deps.streamFirstChunkTimeoutSec,
2035
2107
  vision: deps.vision,
2036
2108
  subagentDepth: deps.depth + 1,
2109
+ readOnly: args.read_only,
2037
2110
  modelName: deps.modelName,
2038
2111
  // Read after the dynamic-import await: defaultAgentTasks.start() has
2039
2112
  // returned by then, so a background spawn's ref.id is already assigned.
@@ -2083,8 +2156,11 @@ ${SUBAGENT_PROMPT}` : SUBAGENT_PROMPT,
2083
2156
  report = `${report.slice(0, MAX_REPORT_CHARS)}
2084
2157
  ... (truncated)`;
2085
2158
  }
2086
- const label = args.description ? ` "${args.description}"` : "";
2087
- const stats = `[subagent${label}: ${toolCalls} tool call(s)]`;
2159
+ const tags = [
2160
+ args.description ? `"${args.description}"` : null,
2161
+ args.read_only ? "read-only" : null
2162
+ ].filter(Boolean).join(" ");
2163
+ const stats = `[subagent${tags ? ` ${tags}` : ""}: ${toolCalls} tool call(s)]`;
2088
2164
  if (error) {
2089
2165
  throw new Error([`Subagent failed: ${error}`, stats, report].filter(Boolean).join("\n"));
2090
2166
  }
@@ -2094,11 +2170,16 @@ ${report || "(subagent produced no text output)"}`;
2094
2170
  function createSubagentTool(deps) {
2095
2171
  return {
2096
2172
  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).",
2173
+ 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
2174
  permission: "exec",
2099
2175
  parameters: z2.object({
2100
- prompt: z2.string().describe("Complete, self-contained instructions for the subagent."),
2176
+ prompt: z2.string().describe(
2177
+ "Complete, self-contained instructions for the subagent: the goal, everything you already know that it needs, and the expected report."
2178
+ ),
2101
2179
  description: z2.string().optional().describe("Short label for the subtask, shown in the result header."),
2180
+ read_only: z2.boolean().optional().describe(
2181
+ "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."
2182
+ ),
2102
2183
  run_in_background: z2.boolean().optional().describe(
2103
2184
  "Run concurrently in the background and return a task id immediately (default false). Use for independent subtasks that can run in parallel."
2104
2185
  )
@@ -2241,7 +2322,7 @@ ${request.slice(0, 2e3)}
2241
2322
  `<reply>
2242
2323
  ${finalReply.slice(0, 2e3)}
2243
2324
  </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."
2325
+ "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
2326
  ].join("\n")
2246
2327
  });
2247
2328
  const verdict = text.trim().toUpperCase();
@@ -2391,6 +2472,9 @@ var AgentLoop = class {
2391
2472
  })
2392
2473
  );
2393
2474
  }
2475
+ if (opts.readOnly && depth > 0) {
2476
+ opts.registry?.retain((tool) => tool.permission === "read");
2477
+ }
2394
2478
  }
2395
2479
  // CoreMessage view of the history (meta stripped): what the model, the
2396
2480
  // display and the token estimator consume. Element identity is preserved,
@@ -2785,7 +2869,14 @@ ${finished.result}`
2785
2869
  }
2786
2870
  }
2787
2871
  const maxTokens = this.opts.contextMaxTokens ?? config.contextMaxTokens;
2788
- const compacted = compactMessages(this.messages, resolveCompactThreshold(config, maxTokens), {
2872
+ const threshold = resolveCompactThreshold(config, maxTokens);
2873
+ const elided = elideStaleContent(this.messages, threshold, toolSchemaTokens);
2874
+ if (elided) {
2875
+ this.messages = elided.messages;
2876
+ await this.opts.sessionStore?.replaceMessages([...this.messages]);
2877
+ this.opts.registry?.resetVolatileState();
2878
+ }
2879
+ const compacted = compactMessages(this.messages, threshold, {
2789
2880
  overheadTokens: toolSchemaTokens
2790
2881
  });
2791
2882
  if (compacted.compacted) {
@@ -3019,6 +3110,7 @@ ${finished.result}`
3019
3110
  turnTreeReady = null;
3020
3111
  }
3021
3112
  const answered = /* @__PURE__ */ new Set();
3113
+ const pendingImages = [];
3022
3114
  try {
3023
3115
  for (const call of toolCalls) {
3024
3116
  if (signal.aborted) break;
@@ -3058,9 +3150,7 @@ ${finished.result}`
3058
3150
  }))
3059
3151
  ]
3060
3152
  };
3061
- const meta = { synthetic: "tool-image" };
3062
- this.messages.push({ message: imageMessage, meta });
3063
- await this.persist(imageMessage, meta);
3153
+ pendingImages.push({ message: imageMessage, meta: { synthetic: "tool-image" } });
3064
3154
  }
3065
3155
  if (call.name === "todo_write" && !result.isError) {
3066
3156
  openTodos = pendingTodoTitles(call.args);
@@ -3093,6 +3183,11 @@ ${finished.result}`
3093
3183
  });
3094
3184
  yield { type: "tool-result", id: call.id, name: call.name, content, isError: true };
3095
3185
  }
3186
+ for (const { message, meta } of pendingImages) {
3187
+ this.messages.push({ message, meta });
3188
+ await this.persist(message, meta).catch(() => {
3189
+ });
3190
+ }
3096
3191
  }
3097
3192
  if (signal.aborted) {
3098
3193
  await this.runStopHooks("aborted");
@@ -3412,4 +3507,4 @@ export {
3412
3507
  attachTurnContext,
3413
3508
  AgentLoop
3414
3509
  };
3415
- //# sourceMappingURL=chunk-RE3NOULF.js.map
3510
+ //# sourceMappingURL=chunk-MNVCCOI2.js.map