@cryer/star-cli 0.3.7 → 0.3.9

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
@@ -17,7 +17,7 @@ Multi-model LLM access · streaming terminal UI · tool calling · permission co
17
17
 
18
18
  </div>
19
19
 
20
- 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.
20
+ 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 sent as a per-turn request-scoped reminder so it never breaks the prompt cache) · prompt caching on Anthropic providers (cache breakpoints on the stable prefix and the history tail; OpenAI-style protocols cache server-side automatically) with a status-bar hit rate · token thrift: re-reading an unchanged file answers with a one-line note, and auxiliary calls (summaries, titles) can run on a cheaper `smallModel` · 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.
21
21
 
22
22
  ## Requirements
23
23
 
@@ -50,6 +50,9 @@ Config file: `~/.star-cli/config.toml` (set the `STAR_HOME` environment variable
50
50
 
51
51
  ```toml
52
52
  defaultModel = "gpt" # name of the [[models]] block used by default
53
+ # smallModel = "haiku" # optional [[models]] block for cheap auxiliary calls
54
+ # (compaction summaries, session titles, completion checks);
55
+ # unset = the active main model does them
53
56
  permissionMode = "ask" # ask | auto | readonly | yolo | plan
54
57
  contextMaxTokens = 100000 # context window in tokens (per-model override below)
55
58
  # compactThresholdTokens = 80000 # optional auto-compaction trigger in tokens; defaults to the
@@ -113,6 +116,11 @@ model = "gpt-4o" # model id sent to the API
113
116
  # provider (low/medium/high are common; some add minimal/max...).
114
117
  # Unset = server default. Ignored for anthropic. The /model
115
118
  # picker also offers these levels after a model pick.
119
+ # vision = false # mark a text-only model: read_image/screenshot then decline with
120
+ # a text error instead of attaching images the endpoint would
121
+ # reject. Unset = images allowed. (Without it, a 4xx rejecting
122
+ # images outright still self-heals: the images are stripped from
123
+ # the history and the request retried once.)
116
124
  # streamIdleTimeoutSec = 60 # per-model overrides of the global stream watchdog timeouts
117
125
  # streamFirstChunkTimeoutSec = 600
118
126
  # optional per-model pricing in USD per 1M tokens — enables the $ estimate in /cost and /usage.
@@ -348,7 +356,7 @@ A `.starignore` file in the working directory hides paths from the agent: the `g
348
356
 
349
357
  ## Built-in tools
350
358
 
351
- `read_file`, `read_image` (attach an image file to the conversation mid-session — screenshots, mockups; png/jpg/gif/webp, auto-downsampled past 2000px), `write_file`, `edit_file`, `glob`, `grep`, `bash`, `web_fetch`, `web_search` (DuckDuckGo, no API key needed), `todo_read`, `todo_write`, `task_list`, `task_output`, `task_kill`, `subagent`, `skill`, `remember` — each declares a permission level (`read` / `write` / `exec`) enforced by the permission gate. Hard safety rules (dangerous shell commands, paths outside the working directory, secret files like `.env` / private keys) are denied in `ask` / `auto` / `readonly` and cannot be overridden by allow-rules.
359
+ `read_file`, `read_image` (attach an image file to the conversation mid-session — screenshots, mockups; png/jpg/gif/webp, auto-downsampled past 2000px), `screenshot` (capture the primary display and see it — visual verification of a running program, e.g. a dev server's rendered page; platform-native capture with no dependencies, DPI-aware on Windows so scaled displays come out pixel-exact, headless sessions get a clear error), `write_file`, `edit_file`, `glob`, `grep`, `bash`, `web_fetch`, `web_search` (DuckDuckGo, no API key needed), `todo_read`, `todo_write`, `task_list`, `task_output`, `task_kill`, `subagent`, `skill`, `remember` — each declares a permission level (`read` / `write` / `exec`) enforced by the permission gate. Hard safety rules (dangerous shell commands, paths outside the working directory, secret files like `.env` / private keys) are denied in `ask` / `auto` / `readonly` and cannot be overridden by allow-rules.
352
360
 
353
361
  The `subagent` tool (`exec` level, so it is hidden in plan mode and denied in readonly) spawns a child agent loop with the same built-in tools to handle a focused, self-contained subtask — research, exploration, or an isolated change — and returns the child's final report as the tool result. Subagents run one level deep (a subagent cannot spawn further subagents), share the parent's permission mode and confirmation prompt, and their conversation is not persisted to the session.
354
362
 
package/README.zh-CN.md CHANGED
@@ -17,7 +17,7 @@
17
17
 
18
18
  </div>
19
19
 
20
- 功能特性:带斜杠命令(+ 自动补全)的流式 REPL · OpenAI / Anthropic / OpenAI 兼容服务商,配交互式 `/connect` 接入向导 · 内置 fs / bash / web 工具,带权限门禁 · git 集成(`/commit` 起草 Conventional Commits 提交信息,`/diff` 显示彩色工作区 diff,仓库状态注入系统提示词)· 计划模式:只读调研 + 计划批准 · 带暗色推理预览的思考动画 · 写入/编辑批准时的 diff 预览 · `@file` 引用 · `!cmd` shell 直通 · 用 Markdown 文件自定义斜杠命令 · 对话压缩(`/compact`)· 会话持久化与恢复,自动生成标题(`/resume`、`star -r`)· 子代理委派,处理专注的子任务 · 生命周期钩子(来自配置的 `PreToolUse`/`PostToolUse`/`Stop` shell 命令)· 文件写入快照 `/undo` 与检查点回滚 `/rewind` · TODO 任务跟踪 · 状态栏可见的后台 shell 任务(`/tasks`)· 长回合与后台任务完成时的终端响铃 · Markdown 会话导出(`/export`)· `/init` + `/doctor` 项目脚手架与环境检查 · 成本估算 · 更新提醒 · 便于脚本化的 `--json` NDJSON 输出。
20
+ 功能特性:带斜杠命令(+ 自动补全)的流式 REPL · OpenAI / Anthropic / OpenAI 兼容服务商,配交互式 `/connect` 接入向导 · 内置 fs / bash / web 工具,带权限门禁 · git 集成(`/commit` 起草 Conventional Commits 提交信息,`/diff` 显示彩色工作区 diff,仓库状态以每回合请求级 reminder 发送,不会破坏提示词缓存)· Anthropic 服务商的提示词缓存(在稳定前缀与历史尾部打缓存断点;OpenAI 系协议由服务端自动缓存),状态栏显示命中率 · token 节俭:重复读取未变化的文件只回一行说明,压缩摘要、标题等辅助调用可配置更便宜的 `smallModel` · 计划模式:只读调研 + 计划批准 · 带暗色推理预览的思考动画 · 写入/编辑批准时的 diff 预览 · `@file` 引用 · `!cmd` shell 直通 · 用 Markdown 文件自定义斜杠命令 · 对话压缩(`/compact`)· 会话持久化与恢复,自动生成标题(`/resume`、`star -r`)· 子代理委派,处理专注的子任务 · 生命周期钩子(来自配置的 `PreToolUse`/`PostToolUse`/`Stop` shell 命令)· 文件写入快照 `/undo` 与检查点回滚 `/rewind` · TODO 任务跟踪 · 状态栏可见的后台 shell 任务(`/tasks`)· 长回合与后台任务完成时的终端响铃 · Markdown 会话导出(`/export`)· `/init` + `/doctor` 项目脚手架与环境检查 · 成本估算 · 更新提醒 · 便于脚本化的 `--json` NDJSON 输出。
21
21
 
22
22
  ## 环境要求
23
23
 
@@ -50,6 +50,9 @@ star -p "hi" # non-interactive print mode
50
50
 
51
51
  ```toml
52
52
  defaultModel = "gpt" # 默认使用的 [[models]] 块名称
53
+ # smallModel = "haiku" # 可选的 [[models]] 块,用于低价的辅助调用
54
+ #(压缩摘要、会话标题、完成度检查);
55
+ # 不设置 = 由当前主模型执行
53
56
  permissionMode = "ask" # ask | auto | readonly | yolo | plan
54
57
  contextMaxTokens = 100000 # 上下文窗口(token 数,下方可按模型覆盖)
55
58
  # compactThresholdTokens = 80000 # 可选的自动压缩触发阈值(token 数);默认为上下文窗口本身,
@@ -111,6 +114,10 @@ model = "gpt-4o" # 发送给 API 的模型 id
111
114
  # minimal/max 等),因此接受任意字符串,由服务端校验。
112
115
  # 不设置 = 服务端默认。anthropic 协议下忽略。
113
116
  # /model 选完模型后也会接着让你选择推理强度。
117
+ # vision = false # 标记纯语言模型:read_image/screenshot 会直接返回文本错误,
118
+ # 不再把图片塞进历史导致端点报 4xx。不设置 = 允许图片。(即使
119
+ # 不设置,端点明确拒绝图片的 4xx 也会自愈:剥掉历史中的图片后
120
+ # 自动重试一次。)
114
121
  # streamIdleTimeoutSec = 60 # 按模型覆盖全局的流看门狗超时
115
122
  # streamFirstChunkTimeoutSec = 600
116
123
  # 可选的按模型定价(美元/1M token)——启用 /cost 和 /usage 中的美元估算。
@@ -344,7 +351,7 @@ star -p "what's wrong in this UI?" --image screenshot.png --image mockup.jpg
344
351
 
345
352
  ## 内置工具
346
353
 
347
- `read_file`、`read_image`(会话中把图片文件带给模型——截图、设计稿等;支持 png/jpg/gif/webp,超过 2000px 自动降采样)、`write_file`、`edit_file`、`glob`、`grep`、`bash`、`web_fetch`、`web_search`(DuckDuckGo,无需 API 密钥)、`todo_read`、`todo_write`、`task_list`、`task_output`、`task_kill`、`subagent`、`skill`、`remember`——每个都声明一个权限级别(`read` / `write` / `exec`),由权限门禁强制执行。硬性安全规则(危险 shell 命令、工作目录之外的路径、`.env` / 私钥等机密文件)在 `ask` / `auto` / `readonly` 下被拒绝,且无法被 allow 规则覆盖。
354
+ `read_file`、`read_image`(会话中把图片文件带给模型——截图、设计稿等;支持 png/jpg/gif/webp,超过 2000px 自动降采样)、`screenshot`(截取主显示器画面给模型看——用于核对运行中程序的真实视觉效果,比如 dev server 渲染的页面;平台原生截图、零依赖,Windows 上 DPI 感知保证缩放下像素精确,无显示器的 headless 会话返回明确错误)、`write_file`、`edit_file`、`glob`、`grep`、`bash`、`web_fetch`、`web_search`(DuckDuckGo,无需 API 密钥)、`todo_read`、`todo_write`、`task_list`、`task_output`、`task_kill`、`subagent`、`skill`、`remember`——每个都声明一个权限级别(`read` / `write` / `exec`),由权限门禁强制执行。硬性安全规则(危险 shell 命令、工作目录之外的路径、`.env` / 私钥等机密文件)在 `ask` / `auto` / `readonly` 下被拒绝,且无法被 allow 规则覆盖。
348
355
 
349
356
  `subagent` 工具(`exec` 级别,因此在计划模式下隐藏,在 readonly 下被拒绝)派生一个带相同内置工具的子代理循环,处理专注、自包含的子任务——调研、探索或一处孤立的改动——并把子代理的最终报告作为工具结果返回。子代理只运行一层(子代理不能再派生子代理),共享父代理的权限模式与确认提示,其对话不持久化到会话。
350
357
 
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/llm/registry.ts
4
+ function listModels(config) {
5
+ return config.models;
6
+ }
7
+ function resolveModelConfig(config, modelName) {
8
+ const name = modelName ?? config.defaultModel;
9
+ const model = config.models.find((m) => m.name === name);
10
+ if (!model) {
11
+ const available = config.models.map((m) => m.name).join(", ") || "(none)";
12
+ throw new Error(`Model "${name}" not found. Available models: ${available}`);
13
+ }
14
+ return model;
15
+ }
16
+ function resolveStartupModel(config, sessionModel) {
17
+ if (sessionModel) {
18
+ if (config.models.some((m) => m.name === sessionModel)) {
19
+ return { name: sessionModel, notice: null };
20
+ }
21
+ return {
22
+ name: config.defaultModel,
23
+ notice: `Session model "${sessionModel}" not found in config \u2014 using default model.`
24
+ };
25
+ }
26
+ return { name: config.defaultModel, notice: null };
27
+ }
28
+
29
+ export {
30
+ listModels,
31
+ resolveModelConfig,
32
+ resolveStartupModel
33
+ };
34
+ //# sourceMappingURL=chunk-4IRY44TG.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/llm/registry.ts"],"sourcesContent":["import type { ModelConfig, StarConfig } from \"../config/schema\";\n\nexport function listModels(config: StarConfig): ModelConfig[] {\n return config.models;\n}\n\nexport function resolveModelConfig(config: StarConfig, modelName?: string): ModelConfig {\n const name = modelName ?? config.defaultModel;\n const model = config.models.find((m) => m.name === name);\n if (!model) {\n const available = config.models.map((m) => m.name).join(\", \") || \"(none)\";\n throw new Error(`Model \"${name}\" not found. Available models: ${available}`);\n }\n return model;\n}\n\n// Pick the model at startup. An explicit --model already sits in\n// config.defaultModel (loader override), so it wins naturally; otherwise a\n// resumed session's recorded model is restored when it still exists in the\n// config. A stale recorded model falls back to the default with a notice.\nexport function resolveStartupModel(\n config: StarConfig,\n sessionModel?: string | null,\n): { name: string | undefined; notice: string | null } {\n if (sessionModel) {\n if (config.models.some((m) => m.name === sessionModel)) {\n return { name: sessionModel, notice: null };\n }\n return {\n name: config.defaultModel,\n notice: `Session model \"${sessionModel}\" not found in config — using default model.`,\n };\n }\n return { name: config.defaultModel, notice: null };\n}\n"],"mappings":";;;AAEO,SAAS,WAAW,QAAmC;AAC5D,SAAO,OAAO;AAChB;AAEO,SAAS,mBAAmB,QAAoB,WAAiC;AACtF,QAAM,OAAO,aAAa,OAAO;AACjC,QAAM,QAAQ,OAAO,OAAO,KAAK,CAAC,MAAM,EAAE,SAAS,IAAI;AACvD,MAAI,CAAC,OAAO;AACV,UAAM,YAAY,OAAO,OAAO,IAAI,CAAC,MAAM,EAAE,IAAI,EAAE,KAAK,IAAI,KAAK;AACjE,UAAM,IAAI,MAAM,UAAU,IAAI,kCAAkC,SAAS,EAAE;AAAA,EAC7E;AACA,SAAO;AACT;AAMO,SAAS,oBACd,QACA,cACqD;AACrD,MAAI,cAAc;AAChB,QAAI,OAAO,OAAO,KAAK,CAAC,MAAM,EAAE,SAAS,YAAY,GAAG;AACtD,aAAO,EAAE,MAAM,cAAc,QAAQ,KAAK;AAAA,IAC5C;AACA,WAAO;AAAA,MACL,MAAM,OAAO;AAAA,MACb,QAAQ,kBAAkB,YAAY;AAAA,IACxC;AAAA,EACF;AACA,SAAO,EAAE,MAAM,OAAO,cAAc,QAAQ,KAAK;AACnD;","names":[]}
@@ -0,0 +1,493 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ gitTreesDir,
4
+ isSensitivePath
5
+ } from "./chunk-A5PXPWHB.js";
6
+
7
+ // src/core/messages.ts
8
+ function toStarMessage(message) {
9
+ return "role" in message ? { message } : message;
10
+ }
11
+ function toCoreMessages(messages) {
12
+ return messages.map((star) => star.message);
13
+ }
14
+ function isSyntheticUserMessage(star) {
15
+ return star.message.role === "user" && star.meta?.synthetic !== void 0;
16
+ }
17
+ function coreMessageText(message) {
18
+ if (!message) return "";
19
+ const content = message.content;
20
+ if (typeof content === "string") return content;
21
+ if (Array.isArray(content)) {
22
+ return content.filter((part) => part.type === "text").map((part) => "text" in part ? part.text : "").join(" ");
23
+ }
24
+ return "";
25
+ }
26
+ var MISSING_TOOL_RESULT_TEXT = "tool result missing (session was interrupted)";
27
+ var INTERRUPTED_TOOL_RESULT_TEXT = "Tool execution interrupted by user.";
28
+ var UNFINISHED_TOOL_RESULT_TEXT = "Tool execution interrupted before a result was produced.";
29
+ function reconcileStarMessages(messages) {
30
+ const result = [];
31
+ let pending = [];
32
+ const flushPending = () => {
33
+ for (const call of pending) {
34
+ result.push({
35
+ message: {
36
+ role: "tool",
37
+ content: [
38
+ {
39
+ type: "tool-result",
40
+ toolCallId: call.toolCallId,
41
+ toolName: call.toolName,
42
+ result: MISSING_TOOL_RESULT_TEXT
43
+ }
44
+ ]
45
+ }
46
+ });
47
+ }
48
+ pending = [];
49
+ };
50
+ for (const star of messages) {
51
+ const message = star.message;
52
+ if (message.role === "assistant" && Array.isArray(message.content)) {
53
+ flushPending();
54
+ pending = message.content.filter((part) => part.type === "tool-call").map((part) => ({ toolCallId: part.toolCallId, toolName: part.toolName }));
55
+ } else if (message.role === "tool" && Array.isArray(message.content) && pending.length > 0) {
56
+ const answered = new Set(message.content.map((part) => part.toolCallId));
57
+ pending = pending.filter((call) => !answered.has(call.toolCallId));
58
+ } else {
59
+ flushPending();
60
+ }
61
+ result.push(star);
62
+ }
63
+ flushPending();
64
+ return result;
65
+ }
66
+ function retractLastTurn(messages) {
67
+ for (let i = messages.length - 1; i >= 0; i--) {
68
+ const star = messages[i];
69
+ if (star && star.message.role === "user" && !isSyntheticUserMessage(star)) {
70
+ return { messages: messages.slice(0, i), removed: messages.length - i };
71
+ }
72
+ }
73
+ return { messages, removed: 0 };
74
+ }
75
+
76
+ // src/config/schema.ts
77
+ import { z } from "zod";
78
+ var ProviderConfigSchema = z.object({
79
+ name: z.string(),
80
+ // openai-responses talks to the Responses API (/responses) instead of chat
81
+ // completions — the wire format codex uses, and the native API of
82
+ // reasoning-model relays that otherwise hide reasoning behind empty chat
83
+ // completions. streamChat sends strictSchemas:false + store:false with it.
84
+ protocol: z.enum(["openai-compatible", "anthropic", "openai-responses"]).default("openai-compatible"),
85
+ baseURL: z.string(),
86
+ apiKeyEnv: z.string().optional(),
87
+ apiKey: z.string().optional(),
88
+ headers: z.record(z.string()).optional()
89
+ });
90
+ var ModelConfigSchema = z.object({
91
+ name: z.string(),
92
+ provider: z.string(),
93
+ model: z.string(),
94
+ maxTokens: z.number().int().positive().optional(),
95
+ // Per-model context window; overrides the top-level contextMaxTokens for
96
+ // compaction and the status bar when set.
97
+ contextMaxTokens: z.number().int().positive().optional(),
98
+ // Reasoning intensity for thinking models, forwarded verbatim as
99
+ // OpenAI-style reasoning_effort on every request (llm/provider.ts
100
+ // reasoningEffortMetadata) — level naming varies by model/provider
101
+ // (low|medium|high are common; some add minimal/max/xhigh/none), so any
102
+ // non-empty string is accepted and the server validates it. Unset = the
103
+ // request carries no effort field and the server-side default applies.
104
+ // Anthropic maps effort to a thinking budget instead — ignored there.
105
+ reasoningEffort: z.string().min(1).optional(),
106
+ // Sampling temperature forwarded on every request. Unset = the AI SDK's
107
+ // own default applies (ai@4 sends 0 — it does NOT omit the field). Some
108
+ // endpoints reject anything but one explicit value (e.g. kimi-for-coding:
109
+ // "invalid temperature: only 1 is allowed") — set it here. No upper
110
+ // bound: ranges vary by provider and the server validates.
111
+ temperature: z.number().min(0).optional(),
112
+ // Per-model overrides of the top-level stream watchdog timeouts (unset =
113
+ // the global values apply). Raise streamIdleTimeoutSec for relays that
114
+ // buffer long generations and stall mid-stream between flushes.
115
+ streamIdleTimeoutSec: z.number().positive().optional(),
116
+ streamFirstChunkTimeoutSec: z.number().positive().optional(),
117
+ // Text-only models: set false so image-producing tools (read_image,
118
+ // screenshot) decline with a text error instead of attaching images the
119
+ // endpoint would reject with an unrecoverable 4xx. Unset = images allowed.
120
+ vision: z.boolean().optional(),
121
+ // Cost estimation needs BOTH prices (USD per 1M tokens); with only one set
122
+ // the model is treated as unpriced.
123
+ promptPrice: z.number().nonnegative().optional(),
124
+ completionPrice: z.number().nonnegative().optional(),
125
+ // Optional price (USD per 1M tokens) for cache-read prompt tokens. When
126
+ // unset, OpenAI-style cached tokens bill at promptPrice and Anthropic-style
127
+ // cache reads are not billed — set this for accurate cache pricing.
128
+ cacheReadPrice: z.number().nonnegative().optional()
129
+ });
130
+ var PermissionsConfigSchema = z.object({
131
+ allow: z.array(z.string()).default([]),
132
+ deny: z.array(z.string()).default([]),
133
+ // Rules that force a confirmation prompt in auto mode (same tool(glob)
134
+ // syntax as allow/deny) — e.g. ask = ["bash(git push *)"]. Priority:
135
+ // deny > ask > allow > mode default. Only meaningful in auto mode (ask
136
+ // mode already prompts; readonly/plan are unaffected; yolo bypasses all).
137
+ ask: z.array(z.string()).default([]),
138
+ // Extra likely-secret file globs extending core/sensitive.ts's hardcoded
139
+ // list (same single-* glob semantics as rules, matched case-insensitively
140
+ // against the basename and the full normalized path). Global config only —
141
+ // the project-config sandbox drops the whole [permissions] table.
142
+ sensitive: z.array(z.string()).default([])
143
+ });
144
+ var HookConfigSchema = z.object({
145
+ event: z.enum(["PreToolUse", "PostToolUse", "Stop"]),
146
+ matcher: z.string().refine(
147
+ (pattern) => {
148
+ try {
149
+ new RegExp(pattern);
150
+ return true;
151
+ } catch {
152
+ return false;
153
+ }
154
+ },
155
+ { message: "invalid regular expression" }
156
+ ).optional(),
157
+ command: z.string().min(1),
158
+ timeoutSec: z.number().positive().default(30)
159
+ });
160
+ var ConfigSchema = z.object({
161
+ defaultModel: z.string().default(""),
162
+ // Optional [[models]] name for cheap auxiliary LLM calls (compaction
163
+ // summaries, session titles, turn completion checks) — Claude Code routes
164
+ // these to a Haiku-class model. Unset = the active main model does them.
165
+ smallModel: z.string().optional(),
166
+ permissionMode: z.enum(["auto", "ask", "readonly", "yolo", "plan"]).default("ask"),
167
+ providers: z.array(ProviderConfigSchema).default([]),
168
+ models: z.array(ModelConfigSchema).default([]),
169
+ // Cap on model round-trips ("steps") per turn between progress checkpoints.
170
+ // One step is a streamed reply plus every tool call it asked for — stream
171
+ // retries and auto-continue nudges do not consume steps. Reaching the cap
172
+ // while tools still execute resets the budget with a notice; the turn stops
173
+ // only when no tool ran since the last checkpoint (the doom-loop
174
+ // signature). 0 disables the cap entirely (opencode/codex have none).
175
+ maxSteps: z.number().int().min(0).default(100),
176
+ contextMaxTokens: z.number().int().positive().default(1e5),
177
+ // Token threshold at which the agent loop auto-compacts the history.
178
+ // Defaults to the effective context window (compact only when full); set a
179
+ // lower value to compact earlier and keep headroom. Manual /compact ignores
180
+ // this — it always compacts.
181
+ compactThresholdTokens: z.number().int().positive().optional(),
182
+ // Seconds without any stream part before a stalled response is ended
183
+ // gracefully (some relays never send the terminal chunks). Applies once
184
+ // 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),
187
+ // Seconds to wait for the very first content part before giving up; slow
188
+ // thinking-model endpoints can stay silent for minutes after accepting
189
+ // the request.
190
+ streamFirstChunkTimeoutSec: z.number().positive().default(300),
191
+ // Extra attempts per model request when a stream fails transiently
192
+ // (network error, 429/5xx, idle watchdog cutoff) or comes back empty, so a
193
+ // relay hiccup does not silently end a half-finished turn. 0 disables.
194
+ // The wait between attempts honors Retry-After headers and otherwise backs
195
+ // off exponentially with jitter (llm/retry.ts); retries after a
196
+ // timeout/empty failure also scale both stream timeouts up (attempt number
197
+ // ×, capped at 3x) since the relay is likely overloaded. Default 5 matches
198
+ // opencode/Codex retry budgets and rides out flaky relays (中转站) better
199
+ // than a minimal budget.
200
+ streamMaxRetries: z.number().int().min(0).default(5),
201
+ // How many consecutive unproductive replies may be nudged before the turn
202
+ // is handed back: a text-only reply that announces pending work ("我将…",
203
+ // "I will…") or answers a nudge with more words, or an empty reply that
204
+ // survived the stream retry budget (steered with a nudge — a changed
205
+ // request — since identical resends already failed). Tool calls reset the
206
+ // count, so a productive task keeps going; exhaustion surfaces a notice
207
+ // instead of stopping silently. Never fires in plan mode. 0 disables.
208
+ maxAutoContinues: z.number().int().min(0).default(2),
209
+ contextCompaction: z.enum(["summary", "truncate"]).default("summary"),
210
+ // Optional per-session dollar budget; when set, the REPL stops the session
211
+ // once the estimated cost for the session reaches this amount.
212
+ sessionBudgetUsd: z.number().positive().optional(),
213
+ // Terminal bell (REPL only): ring when a turn takes longer than
214
+ // notifyBellThresholdSec, and when a background task finishes.
215
+ // STAR_NO_NOTIFY=1 disables without touching the config.
216
+ notifyBell: z.boolean().default(true),
217
+ notifyBellThresholdSec: z.number().positive().default(10),
218
+ permissions: PermissionsConfigSchema.default({ allow: [], deny: [] }),
219
+ hooks: z.array(HookConfigSchema).default([]),
220
+ // Consecutive identical tool calls (same tool + same arguments) allowed
221
+ // before the loop refuses to execute the repeat and tells the model to
222
+ // change approach — a guard against agents stuck retrying a failing call
223
+ // (opencode's doom_loop). 0 disables.
224
+ doomLoopThreshold: z.number().int().min(0).default(3),
225
+ // Track the whole working tree with an internal git repo (outside the
226
+ // user's .git) at every turn boundary, so /undo and /redo can restore
227
+ // changes made by ANY tool (bash included), not just write_file/edit_file.
228
+ // Falls back silently to per-file snapshots when git is unavailable.
229
+ gitSnapshots: z.boolean().default(true),
230
+ // web_fetch SSRF guard escape hatch: by default target hosts are resolved
231
+ // before connecting and private/reserved ranges are refused (loopback,
232
+ // RFC1918, link-local incl. the cloud metadata address 169.254.169.254,
233
+ // ULA, and localhost/.local/.internal names; redirect hops re-checked).
234
+ // true disables the guard for intranet use. Global config only — not in
235
+ // the project-config sandbox whitelist.
236
+ webFetchAllowPrivateHosts: z.boolean().default(false)
237
+ });
238
+ function contextWindowTokens(config, modelName) {
239
+ return config.models.find((m) => m.name === modelName)?.contextMaxTokens ?? config.contextMaxTokens;
240
+ }
241
+ function resolveCompactThreshold(config, windowTokens) {
242
+ return Math.min(config.compactThresholdTokens ?? windowTokens, windowTokens);
243
+ }
244
+
245
+ // src/snapshot/git-tree.ts
246
+ import { execFile } from "child_process";
247
+ import { randomBytes } from "crypto";
248
+ import { existsSync } from "fs";
249
+ import { mkdir, readdir, rm, writeFile } from "fs/promises";
250
+ import os from "os";
251
+ import path from "path";
252
+ var GIT_TIMEOUT_MS = 1e4;
253
+ var GC_TIMEOUT_MS = 6e4;
254
+ var GIT_MAX_BUFFER = 8 * 1024 * 1024;
255
+ var GC_INTERVAL = 50;
256
+ var TRACK_FAILURE_TTL_MS = 5 * 60 * 1e3;
257
+ var MAX_PINNED_REFS = 50;
258
+ var PINNED_REF_PREFIX = "refs/star/";
259
+ var SENSITIVE_EXCLUDE_PATTERNS = [
260
+ ".env",
261
+ ".env.*",
262
+ "!.env.example",
263
+ "!.env.sample",
264
+ "!.env.template",
265
+ "id_rsa",
266
+ "id_dsa",
267
+ "id_ecdsa",
268
+ "id_ed25519",
269
+ ".npmrc",
270
+ ".netrc",
271
+ ".pgpass",
272
+ "*.pem",
273
+ "*.key",
274
+ "*.p12",
275
+ "*.pfx",
276
+ "**/.aws/credentials",
277
+ "**/.kube/config"
278
+ ];
279
+ var GIT_CONFIG_ARGS = [
280
+ "-c",
281
+ "core.autocrlf=false",
282
+ "-c",
283
+ "core.eol=native",
284
+ "-c",
285
+ "filter.lfs.process=",
286
+ "-c",
287
+ "filter.lfs.smudge=",
288
+ "-c",
289
+ "filter.lfs.clean="
290
+ ];
291
+ function gitEnv() {
292
+ const env = {};
293
+ for (const [key, value] of Object.entries(process.env)) {
294
+ if (value !== void 0 && !key.toUpperCase().startsWith("GIT_")) env[key] = value;
295
+ }
296
+ return env;
297
+ }
298
+ function runGit(args, timeoutMs = GIT_TIMEOUT_MS) {
299
+ return new Promise((resolve) => {
300
+ execFile(
301
+ "git",
302
+ [...GIT_CONFIG_ARGS, ...args],
303
+ { timeout: timeoutMs, maxBuffer: GIT_MAX_BUFFER, encoding: "utf8", env: gitEnv() },
304
+ (error, stdout) => {
305
+ resolve(error ? null : stdout.trim());
306
+ }
307
+ );
308
+ });
309
+ }
310
+ var gitAvailable = null;
311
+ var trackFailureUntil = /* @__PURE__ */ new Map();
312
+ var trackCounts = /* @__PURE__ */ new Map();
313
+ var scrubbedRepos = /* @__PURE__ */ new Set();
314
+ async function isGitAvailable() {
315
+ if (gitAvailable === null) {
316
+ gitAvailable = await runGit(["--version"]) !== null;
317
+ }
318
+ return gitAvailable;
319
+ }
320
+ function treeRepoDir(cwd) {
321
+ const key = Buffer.from(path.resolve(cwd), "utf8").toString("base64url");
322
+ return path.join(gitTreesDir(), key);
323
+ }
324
+ async function removeTreeRepo(cwd) {
325
+ const dir = treeRepoDir(cwd);
326
+ trackFailureUntil.delete(path.resolve(cwd));
327
+ trackCounts.delete(dir);
328
+ scrubbedRepos.delete(dir);
329
+ await rm(dir, { recursive: true, force: true }).catch(() => {
330
+ });
331
+ }
332
+ async function pruneTreeReposExcept(remainingCwds) {
333
+ let entries;
334
+ try {
335
+ entries = await readdir(gitTreesDir());
336
+ } catch {
337
+ return;
338
+ }
339
+ const keep = /* @__PURE__ */ new Set();
340
+ for (const cwd of remainingCwds) keep.add(path.basename(treeRepoDir(cwd)));
341
+ await Promise.all(
342
+ entries.filter((entry) => !keep.has(entry)).map(
343
+ (entry) => rm(path.join(gitTreesDir(), entry), { recursive: true, force: true }).catch(() => {
344
+ })
345
+ )
346
+ );
347
+ }
348
+ function isGuardedDir(cwd) {
349
+ const resolved = path.resolve(cwd);
350
+ const home = path.resolve(os.homedir());
351
+ const same = (a, b) => process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
352
+ return same(resolved, home) || same(resolved, path.parse(resolved).root);
353
+ }
354
+ async function ensureTreeRepo(cwd) {
355
+ const dir = treeRepoDir(cwd);
356
+ try {
357
+ await mkdir(dir, { recursive: true, mode: 448 });
358
+ if (!existsSync(path.join(dir, "HEAD"))) {
359
+ const out = await runGit(["init", "--bare", dir]);
360
+ if (out === null) return null;
361
+ await runGit([`--git-dir=${dir}`, "config", "core.autocrlf", "false"]);
362
+ }
363
+ await mkdir(path.join(dir, "info"), { recursive: true });
364
+ await writeFile(
365
+ path.join(dir, "info", "exclude"),
366
+ `${SENSITIVE_EXCLUDE_PATTERNS.join("\n")}
367
+ `,
368
+ "utf8"
369
+ );
370
+ } catch {
371
+ return null;
372
+ }
373
+ return dir;
374
+ }
375
+ function repoArgs(dir, cwd) {
376
+ return [`--git-dir=${dir}`, `--work-tree=${cwd}`];
377
+ }
378
+ async function untrackExcluded(dir, cwd) {
379
+ const base = repoArgs(dir, cwd);
380
+ const listed = await runGit([...base, "ls-files", "-z", "-c", "-i", "--exclude-standard"]);
381
+ if (listed === null) return false;
382
+ const files = listed.split("\0").filter(Boolean);
383
+ if (files.length === 0) return true;
384
+ const removed = await runGit([
385
+ ...base,
386
+ "rm",
387
+ "--cached",
388
+ "--ignore-unmatch",
389
+ "-q",
390
+ "--",
391
+ ...files.map((file) => `:(literal)${file}`)
392
+ ]);
393
+ return removed !== null;
394
+ }
395
+ function trackTree(cwd) {
396
+ const key = path.resolve(cwd);
397
+ const next = (trackChains.get(key) ?? Promise.resolve(null)).then(() => trackTreeGuarded(cwd));
398
+ trackChains.set(key, next);
399
+ return next;
400
+ }
401
+ var trackChains = /* @__PURE__ */ new Map();
402
+ async function trackTreeGuarded(cwd) {
403
+ if (isGuardedDir(cwd)) return null;
404
+ const key = path.resolve(cwd);
405
+ const failedUntil = trackFailureUntil.get(key);
406
+ if (failedUntil !== void 0 && Date.now() < failedUntil) return null;
407
+ const tree = await trackTreeUncached(cwd);
408
+ if (tree === null) {
409
+ trackFailureUntil.set(key, Date.now() + TRACK_FAILURE_TTL_MS);
410
+ return null;
411
+ }
412
+ trackFailureUntil.delete(key);
413
+ return tree;
414
+ }
415
+ async function pinTree(base, tree) {
416
+ const ref = `${PINNED_REF_PREFIX}${Date.now()}-${randomBytes(4).toString("hex")}`;
417
+ if (await runGit([...base, "update-ref", ref, tree]) === null) return;
418
+ const listed = await runGit([...base, "for-each-ref", "--format=%(refname)", PINNED_REF_PREFIX]);
419
+ if (listed === null) return;
420
+ const refs = listed.split("\n").filter(Boolean);
421
+ const stale = refs.slice(0, Math.max(0, refs.length - MAX_PINNED_REFS));
422
+ await Promise.all(stale.map((name) => runGit([...base, "update-ref", "-d", name])));
423
+ }
424
+ async function trackTreeUncached(cwd) {
425
+ if (!await isGitAvailable()) return null;
426
+ const dir = await ensureTreeRepo(cwd);
427
+ if (!dir) return null;
428
+ const base = repoArgs(dir, cwd);
429
+ if (!scrubbedRepos.has(dir)) {
430
+ if (!await untrackExcluded(dir, cwd)) return null;
431
+ scrubbedRepos.add(dir);
432
+ }
433
+ if (await runGit([...base, "add", "-A"]) === null) return null;
434
+ const tree = await runGit([...base, "write-tree"]);
435
+ if (tree === null) return null;
436
+ await pinTree(base, tree);
437
+ const count = (trackCounts.get(dir) ?? 0) + 1;
438
+ trackCounts.set(dir, count);
439
+ if (count % GC_INTERVAL === 0) {
440
+ await runGit([`--git-dir=${dir}`, "gc", "--auto"], GC_TIMEOUT_MS);
441
+ }
442
+ return tree;
443
+ }
444
+ async function restoreTree(cwd, hash) {
445
+ if (isGuardedDir(cwd)) return false;
446
+ if (!await isGitAvailable()) return false;
447
+ const dir = await ensureTreeRepo(cwd);
448
+ if (!dir) return false;
449
+ const base = repoArgs(dir, cwd);
450
+ if (await runGit([...base, "read-tree", hash]) === null) return false;
451
+ if (!await untrackExcluded(dir, cwd)) return false;
452
+ if (await runGit([...base, "checkout-index", "-f", "-a"]) === null) return false;
453
+ if (await runGit([...base, "clean", "-fd"]) === null) return false;
454
+ return true;
455
+ }
456
+ async function diffTreeNames(cwd, hash) {
457
+ if (!await isGitAvailable()) return null;
458
+ const dir = treeRepoDir(cwd);
459
+ if (!existsSync(path.join(dir, "HEAD"))) return null;
460
+ const base = repoArgs(dir, cwd);
461
+ const changed = await runGit([...base, "diff", "--name-only", hash]);
462
+ const untracked = await runGit([...base, "ls-files", "--others", "--exclude-standard"]);
463
+ if (changed === null || untracked === null) return null;
464
+ const names = /* @__PURE__ */ new Set();
465
+ for (const line of [...changed.split("\n"), ...untracked.split("\n")]) {
466
+ if (line !== "" && !isSensitivePath(line)) names.add(line);
467
+ }
468
+ return [...names];
469
+ }
470
+
471
+ export {
472
+ toStarMessage,
473
+ toCoreMessages,
474
+ isSyntheticUserMessage,
475
+ coreMessageText,
476
+ INTERRUPTED_TOOL_RESULT_TEXT,
477
+ UNFINISHED_TOOL_RESULT_TEXT,
478
+ reconcileStarMessages,
479
+ retractLastTurn,
480
+ ProviderConfigSchema,
481
+ ModelConfigSchema,
482
+ PermissionsConfigSchema,
483
+ HookConfigSchema,
484
+ ConfigSchema,
485
+ contextWindowTokens,
486
+ resolveCompactThreshold,
487
+ removeTreeRepo,
488
+ pruneTreeReposExcept,
489
+ trackTree,
490
+ restoreTree,
491
+ diffTreeNames
492
+ };
493
+ //# sourceMappingURL=chunk-56ZIA57D.js.map