@movevom/9t 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli-9t/index.mjs +3579 -168
- package/cli-9t/movevom.mjs +16 -0
- package/config/provider.json +2 -3
- package/package.json +7 -3
- package/prompts/system/AUDIT.md +19 -0
- package/prompts/system/EXECUTION.md +16 -0
- package/prompts/system/MEMORY.md +44 -0
- package/prompts/system/SANDBOX.md +15 -0
- package/prompts/system/SECURITY.md +21 -0
- package/prompts/system/SESSION.md +34 -0
- package/prompts/system/SYSTEM_PROMPT.md +41 -0
- package/prompts/system/TASK_MANAGER.md +29 -0
- package/prompts/system/TOOLS.md +103 -0
- package/docs/001-9t-plan.md +0 -124
- package/docs/002-9t-phase0.md +0 -26
- package/docs/plan/003-doc-npm.md +0 -147
- package/docs/plan/004-gateway.md +0 -61
package/cli-9t/index.mjs
CHANGED
|
@@ -1,84 +1,1742 @@
|
|
|
1
|
-
|
|
2
|
-
import {
|
|
3
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
readFileSync,
|
|
4
|
+
writeFileSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
renameSync,
|
|
8
|
+
statSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
existsSync,
|
|
11
|
+
realpathSync
|
|
12
|
+
} from "fs";
|
|
13
|
+
import { join, resolve, dirname, isAbsolute, sep, basename, extname } from "path";
|
|
14
|
+
import { exec, execFile, spawn } from "child_process";
|
|
4
15
|
import readline from "readline";
|
|
5
16
|
import { homedir } from "os";
|
|
6
17
|
|
|
7
18
|
const rootDir = resolve(dirname(new URL(import.meta.url).pathname), "..");
|
|
8
19
|
const legacyProviderPath = join(rootDir, "config", "provider.json");
|
|
9
|
-
|
|
20
|
+
let workspaceRoot = join(rootDir, "workspace");
|
|
10
21
|
|
|
11
22
|
const toolsMeta = [
|
|
12
23
|
{ name: "read", args: "{ path, offset?, limit? }", desc: "读取文件" },
|
|
13
24
|
{ name: "edit", args: "{ path, find, replace }", desc: "局部替换" },
|
|
14
25
|
{ name: "create", args: '{ path, type?: "file"|"dir", content? }', desc: "创建文件/目录" },
|
|
15
26
|
{ name: "delete", args: "{ path }", desc: "删除文件/目录" },
|
|
27
|
+
{ name: "move", args: "{ from, to }", desc: "移动/重命名" },
|
|
28
|
+
{ name: "rename", args: "{ from, to }", desc: "移动/重命名" },
|
|
29
|
+
{ name: "stat", args: "{ path }", desc: "文件信息" },
|
|
30
|
+
{ name: "diff", args: "{ path_a, path_b }", desc: "文本差异" },
|
|
31
|
+
{ name: "patch", args: "{ path, diff }", desc: "应用补丁" },
|
|
16
32
|
{ name: "ls", args: "{ path? }", desc: "列目录" },
|
|
17
33
|
{ name: "grep", args: "{ pattern, path? }", desc: "搜索内容" },
|
|
18
34
|
{ name: "glob", args: "{ pattern, path? }", desc: "按模式匹配文件" },
|
|
19
|
-
{ name: "execute", args: "{ cmd, cwd? }", desc: "执行命令" },
|
|
20
|
-
{
|
|
35
|
+
{ name: "execute", args: "{ cmd, cwd?, timeout_ms?, max_output_chars?, pty? }", desc: "执行命令" },
|
|
36
|
+
{
|
|
37
|
+
name: "execute_detached",
|
|
38
|
+
args: "{ cmd, cwd?, task_id?, log_path, output_path, max_output_chars?, pty? }",
|
|
39
|
+
desc: "后台执行命令"
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: "process",
|
|
43
|
+
args: '{ action: "list"|"poll"|"log"|"send"|"kill"|"remove", session_id?, offset?, limit?, input? }',
|
|
44
|
+
desc: "进程管理/输出回放"
|
|
45
|
+
},
|
|
46
|
+
{ name: "wait_process", args: "{ session_id }", desc: "查询后台进程状态" },
|
|
47
|
+
{ name: "read_process_log", args: "{ session_id, offset?, limit? }", desc: "读取进程日志" },
|
|
48
|
+
{ name: "read_process_output", args: "{ session_id?, output_path? }", desc: "读取进程结果文件" },
|
|
49
|
+
{ name: "web_fetch", args: "{ url, extract_mode?, max_chars? }", desc: "抓取网页" },
|
|
50
|
+
{ name: "web_search", args: "{ query, count?, country?, search_lang?, ui_lang?, freshness? }", desc: "网页搜索" },
|
|
51
|
+
{ name: "ask_user", args: "{ question, options? }", desc: "向用户提问" },
|
|
52
|
+
{ name: "browser", args: '{ action: "open", url, output_path }', desc: "浏览器执行" },
|
|
53
|
+
{ name: "open", args: "{ path }", desc: "打开文件" },
|
|
54
|
+
{ name: "open_url", args: "{ url }", desc: "打开URL" },
|
|
55
|
+
{ name: "task_create", args: "{ title, parent_id?, depends_on?, status? }", desc: "创建任务" },
|
|
56
|
+
{
|
|
57
|
+
name: "task_update",
|
|
58
|
+
args: "{ task_id, title?, status?, parent_id?, depends_on?, inputs?, outputs?, last_error? }",
|
|
59
|
+
desc: "更新任务"
|
|
60
|
+
},
|
|
61
|
+
{ name: "task_get", args: "{ task_id }", desc: "获取任务" },
|
|
62
|
+
{ name: "task_list", args: "{ status?, parent_id? }", desc: "列出任务" },
|
|
63
|
+
{ name: "task_tree", args: "{ root_id? }", desc: "任务树" },
|
|
64
|
+
{ name: "task_log_list", args: "{ task_id?, limit? }", desc: "任务日志" },
|
|
65
|
+
{ name: "task_replay", args: "{ }", desc: "任务回放" },
|
|
66
|
+
{ name: "todo_list", args: "{ scope?, status? }", desc: "列出待办" },
|
|
67
|
+
{ name: "todo_add", args: "{ text, scope?, source? }", desc: "新增待办" },
|
|
68
|
+
{ name: "todo_start", args: "{ todo_id }", desc: "开始待办" },
|
|
69
|
+
{ name: "todo_done", args: "{ todo_id }", desc: "完成待办" },
|
|
70
|
+
{ name: "todo_patch", args: "{ todo_id, text, scope?, source? }", desc: "创建补丁待办" },
|
|
71
|
+
{ name: "todo_clear", args: "{ scope? }", desc: "清理待办" }
|
|
21
72
|
];
|
|
22
73
|
|
|
23
|
-
const systemToolsText = toolsMeta.map((t) => `${t.name}: ${t.args}`).join("\n");
|
|
24
74
|
const helpToolsText = toolsMeta.map((t) => `${t.name} ${t.desc}`).join(";");
|
|
25
75
|
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
76
|
+
const systemPromptPath = join(rootDir, "prompts", "system", "SYSTEM_PROMPT.md");
|
|
77
|
+
const toolDocPath = join(rootDir, "prompts", "system", "TOOLS.md");
|
|
78
|
+
const taskDocPath = join(rootDir, "prompts", "system", "TASK_MANAGER.md");
|
|
79
|
+
const memoryDocPath = join(rootDir, "prompts", "system", "MEMORY.md");
|
|
80
|
+
const sessionDocPath = join(rootDir, "prompts", "system", "SESSION.md");
|
|
81
|
+
const securityDocPath = join(rootDir, "prompts", "system", "SECURITY.md");
|
|
82
|
+
const auditDocPath = join(rootDir, "prompts", "system", "AUDIT.md");
|
|
83
|
+
const executionDocPath = join(rootDir, "prompts", "system", "EXECUTION.md");
|
|
84
|
+
const sandboxDocPath = join(rootDir, "prompts", "system", "SANDBOX.md");
|
|
85
|
+
|
|
86
|
+
const getSystemPrompt = () => {
|
|
87
|
+
const base = readFileSync(systemPromptPath, "utf-8").trim();
|
|
88
|
+
const indexText = [
|
|
89
|
+
`1. 9t tool -> ${toolDocPath}`,
|
|
90
|
+
`2. 9t task manager -> ${taskDocPath}`,
|
|
91
|
+
`3. 9t memory -> ${memoryDocPath}`,
|
|
92
|
+
`4. 9t session -> ${sessionDocPath}`,
|
|
93
|
+
`5. 9t security -> ${securityDocPath}`,
|
|
94
|
+
`6. 9t audit -> ${auditDocPath}`,
|
|
95
|
+
`7. 9t execution -> ${executionDocPath}`,
|
|
96
|
+
`8. 9t sandbox -> ${sandboxDocPath}`
|
|
97
|
+
].join("\n");
|
|
98
|
+
return `${base}\n\n系统内置文档路径索引:\n${indexText}`;
|
|
99
|
+
};
|
|
32
100
|
|
|
33
101
|
const MAX_STEPS = 8;
|
|
102
|
+
const DEFAULT_EXEC_TIMEOUT_MS = 60_000;
|
|
103
|
+
const MAX_EXEC_TIMEOUT_MS = 10 * 60_000;
|
|
104
|
+
const DEFAULT_EXEC_MAX_OUTPUT_CHARS = 20_000;
|
|
105
|
+
const MAX_EXEC_OUTPUT_CHARS = 200_000;
|
|
106
|
+
const EXEC_MAX_BUFFER_BYTES = 2 * 1024 * 1024;
|
|
107
|
+
const DEFAULT_WEB_FETCH_MAX_CHARS = 50_000;
|
|
108
|
+
const MAX_WEB_FETCH_MAX_CHARS = 200_000;
|
|
109
|
+
const DEFAULT_WEB_FETCH_TIMEOUT_MS = 30_000;
|
|
110
|
+
const DEFAULT_WEB_SEARCH_TIMEOUT_MS = 30_000;
|
|
111
|
+
const DEFAULT_WEB_SEARCH_COUNT = 5;
|
|
112
|
+
const MAX_WEB_SEARCH_COUNT = 10;
|
|
113
|
+
const DEFAULT_LOOP_EVERY_MS = 60_000;
|
|
114
|
+
const DEFAULT_LOOP_MAX_RUNS = 0;
|
|
115
|
+
const DEFAULT_LOOP_MAX_DURATION_MS = 0;
|
|
116
|
+
const DEFAULT_LOOP_MAX_RETRIES = 2;
|
|
117
|
+
const DEFAULT_LOOP_RETRY_DELAY_MS = 5_000;
|
|
118
|
+
const DEFAULT_LOOP_MAX_RETRY_DELAY_MS = 60_000;
|
|
119
|
+
const DEFAULT_LOOP_BACKOFF = "fixed";
|
|
120
|
+
const testPrompt = "使用9T工具创建文件夹 demo ,在其中创建 test.md,内容为:山高月小,水落石出。";
|
|
121
|
+
const toolHelpText = `可用工具与用途:${helpToolsText}`;
|
|
122
|
+
const aboutText =
|
|
123
|
+
"9T-Movevom 是一个最小可用的 CLI 工具代理,支持可切换模型提供商,通过工具调用完成文件与命令操作。默认工作区为 Movevom/workspace,可通过配置覆盖。";
|
|
124
|
+
const helpText =
|
|
125
|
+
"可用指令:/help /about /tools /? /exit /keystatus /provider /model /task /tasks /todo。交互用法:进入交互模式后直接输入 /help /tools 等斜杠命令;也可输入 --test 触发测试提示词。命令参数:--interactive/-i --test --key-status --mode strict|allow|open --allow-mode strict|allow|open --roots \"<abs1,abs2>\" --workspace <path> --allow-risk delete,execute,open --allow-risk-ttl-hours <n> --execute-allowlist \"<cmd1,cmd2>\" --open-url-allowlist \"<host1,host2>\" --open-file-allowlist \"<ext1,ext2>\" --deny-file-names \"<name1,name2>\" --deny-file-exts \"<ext1,ext2>\" --deny-dirs \"<abs1,abs2>\" --sandbox off|os|container --loop \"<prompt>\" --schedule-loop \"<prompt>\" --task-loop \"<prompt>\" --daemon-loop \"<prompt>\" --loop-every <ms> --loop-max-runs <n> --loop-max-duration <ms> --loop-retry <n> --loop-retry-delay <ms> --loop-max-retry-delay <ms> --loop-backoff fixed|linear|exponential --loop-stop-on-fail。模式说明:strict 仅 workspace 且风险工具拒绝;allow workspace + allowlist 且风险工具需确认或显式允许;open workspace + allowlist 且风险工具直接执行。/provider 列出可用 provider 与模型并标记当前;/model <provider> [model] 切换 provider(可选覆盖 model)。";
|
|
126
|
+
|
|
127
|
+
const getConfigDir = () => {
|
|
128
|
+
const custom = process.env["9T_CONFIG_HOME"];
|
|
129
|
+
if (custom) return custom;
|
|
130
|
+
const home = homedir();
|
|
131
|
+
if (process.platform === "darwin") return join(home, "Library", "Application Support", "9T");
|
|
132
|
+
if (process.platform === "win32") {
|
|
133
|
+
const appData = process.env["APPDATA"] || join(home, "AppData", "Roaming");
|
|
134
|
+
return join(appData, "9T");
|
|
135
|
+
}
|
|
136
|
+
return join(process.env["XDG_CONFIG_HOME"] || join(home, ".config"), "9t");
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const providerPath = join(getConfigDir(), "provider.json");
|
|
140
|
+
const accessConfigPath = join(getConfigDir(), "config.json");
|
|
141
|
+
const tasksConfigPath = join(getConfigDir(), "tasks.json");
|
|
142
|
+
const tasksLogPath = join(getConfigDir(), "tasks-log.jsonl");
|
|
143
|
+
const sessionLogPath = join(getConfigDir(), "sessions.jsonl");
|
|
144
|
+
const evidenceLogPath = join(getConfigDir(), "evidence-log.jsonl");
|
|
145
|
+
|
|
146
|
+
const providerPresets = {
|
|
147
|
+
chatglm: {
|
|
148
|
+
base_url: "https://open.bigmodel.cn/api/anthropic",
|
|
149
|
+
model: "glm-5",
|
|
150
|
+
format: "anthropic",
|
|
151
|
+
api_key_env: "9T_CHATGLM_API_KEY"
|
|
152
|
+
},
|
|
153
|
+
deepseek: {
|
|
154
|
+
base_url: "https://api.deepseek.com/v1",
|
|
155
|
+
model: "deepseek-chat",
|
|
156
|
+
format: "openai",
|
|
157
|
+
api_key_env: "9T_DEEPSEEK_API_KEY"
|
|
158
|
+
},
|
|
159
|
+
kimi: {
|
|
160
|
+
base_url: "https://api.moonshot.cn/v1",
|
|
161
|
+
model: "moonshot-v1-8k",
|
|
162
|
+
format: "openai",
|
|
163
|
+
api_key_env: "9T_KIMI_API_KEY"
|
|
164
|
+
},
|
|
165
|
+
minimax: {
|
|
166
|
+
base_url: "https://api.minimax.chat/v1",
|
|
167
|
+
model: "abab6.5s",
|
|
168
|
+
format: "openai",
|
|
169
|
+
api_key_env: "9T_MINIMAX_API_KEY"
|
|
170
|
+
},
|
|
171
|
+
qwen: {
|
|
172
|
+
base_url: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
|
173
|
+
model: "qwen-plus",
|
|
174
|
+
format: "openai",
|
|
175
|
+
api_key_env: "9T_QWEN_API_KEY"
|
|
176
|
+
},
|
|
177
|
+
anthropic: {
|
|
178
|
+
base_url: "https://api.anthropic.com",
|
|
179
|
+
model: "claude-3-5-sonnet-20241022",
|
|
180
|
+
format: "anthropic",
|
|
181
|
+
api_key_env: "9T_ANTHROPIC_API_KEY"
|
|
182
|
+
},
|
|
183
|
+
gemini: {
|
|
184
|
+
base_url: "https://generativelanguage.googleapis.com/v1beta",
|
|
185
|
+
model: "gemini-1.5-pro",
|
|
186
|
+
format: "gemini",
|
|
187
|
+
api_key_env: "9T_GEMINI_API_KEY"
|
|
188
|
+
},
|
|
189
|
+
grok: {
|
|
190
|
+
base_url: "https://api.x.ai/v1",
|
|
191
|
+
model: "grok-2",
|
|
192
|
+
format: "openai",
|
|
193
|
+
api_key_env: "9T_GROK_API_KEY"
|
|
194
|
+
},
|
|
195
|
+
openai: {
|
|
196
|
+
base_url: "https://api.openai.com/v1",
|
|
197
|
+
model: "gpt-4o-mini",
|
|
198
|
+
format: "openai",
|
|
199
|
+
api_key_env: "9T_OPENAI_API_KEY"
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const defaultProviderName = "chatglm";
|
|
204
|
+
const defaultProvider = providerPresets[defaultProviderName];
|
|
205
|
+
|
|
206
|
+
const normalizeProviderName = (value) => String(value || "").trim().toLowerCase();
|
|
207
|
+
|
|
208
|
+
const getProviderPreset = (name) => providerPresets[normalizeProviderName(name)] || null;
|
|
209
|
+
|
|
210
|
+
const listProviderNames = () => Object.keys(providerPresets);
|
|
211
|
+
|
|
212
|
+
const loadProviderConfig = () => {
|
|
213
|
+
let rawCfg = {};
|
|
214
|
+
if (existsSync(providerPath)) {
|
|
215
|
+
const raw = readFileSync(providerPath, "utf-8");
|
|
216
|
+
rawCfg = JSON.parse(raw);
|
|
217
|
+
} else if (existsSync(legacyProviderPath)) {
|
|
218
|
+
const raw = readFileSync(legacyProviderPath, "utf-8");
|
|
219
|
+
rawCfg = JSON.parse(raw);
|
|
220
|
+
}
|
|
221
|
+
const provider = normalizeProviderName(rawCfg.provider) || defaultProviderName;
|
|
222
|
+
const preset = getProviderPreset(provider);
|
|
223
|
+
const base_url = String(rawCfg.base_url || preset?.base_url || defaultProvider.base_url).trim();
|
|
224
|
+
const model = String(rawCfg.model || preset?.model || defaultProvider.model).trim();
|
|
225
|
+
const format = String(rawCfg.format || preset?.format || defaultProvider.format || "anthropic").trim();
|
|
226
|
+
return { provider, base_url, model, format };
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const saveProviderConfig = (cfg) => {
|
|
230
|
+
mkdirSync(dirname(providerPath), { recursive: true });
|
|
231
|
+
const safe = {
|
|
232
|
+
provider: normalizeProviderName(cfg.provider),
|
|
233
|
+
base_url: cfg.base_url,
|
|
234
|
+
model: cfg.model,
|
|
235
|
+
format: cfg.format
|
|
236
|
+
};
|
|
237
|
+
writeFileSync(providerPath, JSON.stringify(safe, null, 2));
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const loadAccessConfig = () => {
|
|
241
|
+
if (!existsSync(accessConfigPath)) return {};
|
|
242
|
+
const raw = readFileSync(accessConfigPath, "utf-8");
|
|
243
|
+
return JSON.parse(raw);
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const saveAccessConfig = (patch) => {
|
|
247
|
+
const current = loadAccessConfig();
|
|
248
|
+
const merged = { ...current, ...patch };
|
|
249
|
+
if (current.allow_risk_until || patch.allow_risk_until) {
|
|
250
|
+
merged.allow_risk_until = {
|
|
251
|
+
...(current.allow_risk_until || {}),
|
|
252
|
+
...(patch.allow_risk_until || {})
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
mkdirSync(dirname(accessConfigPath), { recursive: true });
|
|
256
|
+
writeFileSync(accessConfigPath, JSON.stringify(merged, null, 2));
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const parseCsvList = (value) => {
|
|
260
|
+
if (!value) return [];
|
|
261
|
+
if (Array.isArray(value)) {
|
|
262
|
+
return value.map((v) => String(v).trim()).filter(Boolean);
|
|
263
|
+
}
|
|
264
|
+
return String(value)
|
|
265
|
+
.split(",")
|
|
266
|
+
.map((v) => v.trim())
|
|
267
|
+
.filter(Boolean);
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const parseNonNegativeInt = (value, fallback) => {
|
|
271
|
+
if (value === "" || value == null) return fallback;
|
|
272
|
+
const num = Number.parseInt(String(value), 10);
|
|
273
|
+
return Number.isFinite(num) && num >= 0 ? num : fallback;
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const normalizeLoopBackoff = (value) => {
|
|
277
|
+
const v = String(value || "").trim().toLowerCase();
|
|
278
|
+
if (v === "fixed" || v === "linear" || v === "exponential") return v;
|
|
279
|
+
return "";
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const buildLoopOptions = (args, overrides) => {
|
|
283
|
+
const base = overrides || {};
|
|
284
|
+
const everyMs = parseNonNegativeInt(getArgValue(args, "--loop-every"), base.everyMs ?? DEFAULT_LOOP_EVERY_MS);
|
|
285
|
+
const maxRuns = parseNonNegativeInt(getArgValue(args, "--loop-max-runs"), base.maxRuns ?? DEFAULT_LOOP_MAX_RUNS);
|
|
286
|
+
const maxDurationMs = parseNonNegativeInt(
|
|
287
|
+
getArgValue(args, "--loop-max-duration"),
|
|
288
|
+
base.maxDurationMs ?? DEFAULT_LOOP_MAX_DURATION_MS
|
|
289
|
+
);
|
|
290
|
+
const maxRetries = parseNonNegativeInt(
|
|
291
|
+
getArgValue(args, "--loop-retry"),
|
|
292
|
+
base.maxRetries ?? DEFAULT_LOOP_MAX_RETRIES
|
|
293
|
+
);
|
|
294
|
+
const retryDelayMs = parseNonNegativeInt(
|
|
295
|
+
getArgValue(args, "--loop-retry-delay"),
|
|
296
|
+
base.retryDelayMs ?? DEFAULT_LOOP_RETRY_DELAY_MS
|
|
297
|
+
);
|
|
298
|
+
const maxRetryDelayMs = parseNonNegativeInt(
|
|
299
|
+
getArgValue(args, "--loop-max-retry-delay"),
|
|
300
|
+
base.maxRetryDelayMs ?? DEFAULT_LOOP_MAX_RETRY_DELAY_MS
|
|
301
|
+
);
|
|
302
|
+
const backoff = normalizeLoopBackoff(getArgValue(args, "--loop-backoff")) || DEFAULT_LOOP_BACKOFF;
|
|
303
|
+
const stopOnFail = hasFlag(args, "--loop-stop-on-fail");
|
|
304
|
+
return {
|
|
305
|
+
everyMs,
|
|
306
|
+
maxRuns,
|
|
307
|
+
maxDurationMs,
|
|
308
|
+
maxRetries,
|
|
309
|
+
retryDelayMs,
|
|
310
|
+
maxRetryDelayMs,
|
|
311
|
+
backoff,
|
|
312
|
+
stopOnFail
|
|
313
|
+
};
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
const normalizeMode = (value) => {
|
|
317
|
+
const v = String(value || "").trim();
|
|
318
|
+
if (v === "supervised") return "allow";
|
|
319
|
+
if (v === "strict" || v === "allow" || v === "open") return v;
|
|
320
|
+
return "";
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
const expandHome = (value) => {
|
|
324
|
+
const v = String(value || "").trim();
|
|
325
|
+
if (!v) return "";
|
|
326
|
+
if (v === "~") return homedir();
|
|
327
|
+
if (v.startsWith("~/") || v.startsWith("~\\")) return join(homedir(), v.slice(2));
|
|
328
|
+
return v;
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
const normalizeAbsolute = (value) => {
|
|
332
|
+
const expanded = expandHome(value);
|
|
333
|
+
if (!expanded) return "";
|
|
334
|
+
return isAbsolute(expanded) ? expanded : resolve(expanded);
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
const normalizeRoot = (value) => {
|
|
338
|
+
const abs = normalizeAbsolute(value);
|
|
339
|
+
if (!abs) return "";
|
|
340
|
+
if (existsSync(abs)) return realpathSync(abs);
|
|
341
|
+
return resolve(abs);
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
const normalizeSandboxMode = (value) => {
|
|
345
|
+
const v = String(value || "").trim();
|
|
346
|
+
if (v === "off" || v === "os" || v === "container") return v;
|
|
347
|
+
return "";
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
const defaultSandboxMode = () => {
|
|
351
|
+
if (process.platform === "darwin") {
|
|
352
|
+
return existsSync("/usr/bin/sandbox-exec") ? "os" : "off";
|
|
353
|
+
}
|
|
354
|
+
if (process.platform === "linux") {
|
|
355
|
+
const bwrap = findInPath("bwrap") || "/usr/bin/bwrap";
|
|
356
|
+
return existsSync(bwrap) ? "os" : "off";
|
|
357
|
+
}
|
|
358
|
+
return "off";
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
const findInPath = (name) => {
|
|
362
|
+
const envPath = process.env["PATH"] || "";
|
|
363
|
+
const parts = envPath.split(process.platform === "win32" ? ";" : ":");
|
|
364
|
+
for (const p of parts) {
|
|
365
|
+
const full = join(p, name);
|
|
366
|
+
if (existsSync(full)) return full;
|
|
367
|
+
}
|
|
368
|
+
return "";
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
const escapeForSingleQuotes = (value) => String(value || "").replace(/'/g, "'\"'\"'");
|
|
372
|
+
|
|
373
|
+
const buildMacSandboxProfile = (roots) => {
|
|
374
|
+
const allowed = roots.map((r) => `(subpath "${r}")`).join(" ");
|
|
375
|
+
const lines = [
|
|
376
|
+
"(version 1)",
|
|
377
|
+
"(deny default)",
|
|
378
|
+
"(allow process*)",
|
|
379
|
+
"(allow file-read* (subpath \"/System\") (subpath \"/usr\") (subpath \"/bin\") (subpath \"/sbin\") (subpath \"/private\") (subpath \"/Library\"))",
|
|
380
|
+
allowed ? `(allow file-read* ${allowed})` : "",
|
|
381
|
+
allowed ? `(allow file-write* ${allowed})` : ""
|
|
382
|
+
].filter(Boolean);
|
|
383
|
+
return lines.join("\n");
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
const buildLinuxSandboxCommand = (cmd, cwd, roots) => {
|
|
387
|
+
const bwrap = findInPath("bwrap") || "/usr/bin/bwrap";
|
|
388
|
+
if (!existsSync(bwrap)) throw new Error("sandbox not available");
|
|
389
|
+
const binds = roots.map((r) => `--bind "${r}" "${r}"`).join(" ");
|
|
390
|
+
const escapedCmd = escapeForSingleQuotes(cmd);
|
|
391
|
+
return `${bwrap} --ro-bind / / --dev /dev --proc /proc --die-with-parent --unshare-all --chdir "${cwd}" ${binds} -- /bin/sh -c '${escapedCmd}'`;
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
const buildContainerSandboxCommand = (cmd, cwd, roots) => {
|
|
395
|
+
if (process.platform === "win32") throw new Error("container sandbox not supported");
|
|
396
|
+
const runtime = findInPath("docker") || findInPath("podman");
|
|
397
|
+
if (!runtime) throw new Error("container runtime not available");
|
|
398
|
+
const image = "alpine:3.20";
|
|
399
|
+
const mounts = roots.map((r) => `-v "${r}:${r}"`).join(" ");
|
|
400
|
+
const escapedCmd = escapeForSingleQuotes(cmd);
|
|
401
|
+
return `${runtime} run --rm --network none -w "${cwd}" ${mounts} ${image} /bin/sh -c '${escapedCmd}'`;
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
const buildSandboxedCommand = (cmd, cwd, roots) => {
|
|
405
|
+
if (sandboxMode === "off") return cmd;
|
|
406
|
+
if (sandboxMode === "container") {
|
|
407
|
+
return buildContainerSandboxCommand(cmd, cwd, roots);
|
|
408
|
+
}
|
|
409
|
+
if (process.platform === "darwin") {
|
|
410
|
+
const sandboxExec = existsSync("/usr/bin/sandbox-exec") ? "/usr/bin/sandbox-exec" : "";
|
|
411
|
+
if (!sandboxExec) throw new Error("sandbox not available");
|
|
412
|
+
const profile = buildMacSandboxProfile(roots);
|
|
413
|
+
const escapedProfile = escapeForSingleQuotes(profile);
|
|
414
|
+
const escapedCmd = escapeForSingleQuotes(cmd);
|
|
415
|
+
return `${sandboxExec} -p '${escapedProfile}' /bin/sh -c '${escapedCmd}'`;
|
|
416
|
+
}
|
|
417
|
+
if (process.platform === "linux") {
|
|
418
|
+
return buildLinuxSandboxCommand(cmd, cwd, roots);
|
|
419
|
+
}
|
|
420
|
+
return cmd;
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
const defaultExecuteAllowlist = [
|
|
424
|
+
"ls",
|
|
425
|
+
"cat",
|
|
426
|
+
"pwd",
|
|
427
|
+
"whoami",
|
|
428
|
+
"git",
|
|
429
|
+
"node",
|
|
430
|
+
"npm",
|
|
431
|
+
"pnpm",
|
|
432
|
+
"yarn",
|
|
433
|
+
"python",
|
|
434
|
+
"python3"
|
|
435
|
+
];
|
|
436
|
+
|
|
437
|
+
const defaultOpenUrlAllowlist = ["localhost", "127.0.0.1"];
|
|
438
|
+
const defaultOpenFileAllowlist = [];
|
|
439
|
+
|
|
440
|
+
const defaultDenyFileNames = [
|
|
441
|
+
".env",
|
|
442
|
+
".npmrc",
|
|
443
|
+
".bashrc",
|
|
444
|
+
".zshrc",
|
|
445
|
+
".ssh",
|
|
446
|
+
"id_rsa",
|
|
447
|
+
"id_ed25519",
|
|
448
|
+
"known_hosts",
|
|
449
|
+
"authorized_keys"
|
|
450
|
+
];
|
|
451
|
+
|
|
452
|
+
const defaultDenyFileExts = [
|
|
453
|
+
".pem",
|
|
454
|
+
".key",
|
|
455
|
+
".p12",
|
|
456
|
+
".pfx",
|
|
457
|
+
".crt",
|
|
458
|
+
".cer",
|
|
459
|
+
".der",
|
|
460
|
+
".kdbx",
|
|
461
|
+
".sqlite",
|
|
462
|
+
".db"
|
|
463
|
+
];
|
|
464
|
+
|
|
465
|
+
const getDefaultDenyDirsAbs = () => {
|
|
466
|
+
const home = homedir();
|
|
467
|
+
if (process.platform === "darwin") {
|
|
468
|
+
return [
|
|
469
|
+
"/System",
|
|
470
|
+
"/Library",
|
|
471
|
+
"/Applications",
|
|
472
|
+
"/private",
|
|
473
|
+
"/etc",
|
|
474
|
+
"/bin",
|
|
475
|
+
"/sbin",
|
|
476
|
+
"/usr",
|
|
477
|
+
"/var",
|
|
478
|
+
join(home, "Library", "Keychains")
|
|
479
|
+
];
|
|
480
|
+
}
|
|
481
|
+
if (process.platform === "win32") {
|
|
482
|
+
const systemRoot = process.env["SystemRoot"] || "C:\\Windows";
|
|
483
|
+
return [
|
|
484
|
+
systemRoot,
|
|
485
|
+
"C:\\Windows",
|
|
486
|
+
"C:\\Program Files",
|
|
487
|
+
"C:\\Program Files (x86)"
|
|
488
|
+
];
|
|
489
|
+
}
|
|
490
|
+
return ["/etc", "/bin", "/sbin", "/usr", "/var", "/proc", "/sys", "/dev"];
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
const defaultDenyDirSegments = [".ssh", ".gnupg", ".aws", ".kube", ".config"];
|
|
494
|
+
|
|
495
|
+
const allowedRiskSet = new Set(["delete", "execute", "open"]);
|
|
496
|
+
|
|
497
|
+
const parseAllowRisk = (value) =>
|
|
498
|
+
new Set(
|
|
499
|
+
parseCsvList(value)
|
|
500
|
+
.map((v) => String(v).trim().toLowerCase())
|
|
501
|
+
.filter((v) => allowedRiskSet.has(v))
|
|
502
|
+
);
|
|
503
|
+
|
|
504
|
+
const parseHours = (value) => {
|
|
505
|
+
if (value == null || value === "") return 0;
|
|
506
|
+
const num = Number(value);
|
|
507
|
+
if (!Number.isFinite(num) || num <= 0) return 0;
|
|
508
|
+
return num;
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
const parseAllowRiskUntil = (value) => {
|
|
512
|
+
if (!value || typeof value !== "object") return {};
|
|
513
|
+
const now = Date.now();
|
|
514
|
+
const out = {};
|
|
515
|
+
for (const [tool, raw] of Object.entries(value)) {
|
|
516
|
+
if (!allowedRiskSet.has(tool)) continue;
|
|
517
|
+
let ts = 0;
|
|
518
|
+
if (typeof raw === "number") ts = raw;
|
|
519
|
+
else {
|
|
520
|
+
const parsed = Date.parse(String(raw));
|
|
521
|
+
ts = Number.isFinite(parsed) ? parsed : 0;
|
|
522
|
+
}
|
|
523
|
+
if (ts > now) out[tool] = ts;
|
|
524
|
+
}
|
|
525
|
+
return out;
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
const normalizeListLower = (items) =>
|
|
529
|
+
parseCsvList(items)
|
|
530
|
+
.map((v) => String(v).trim().toLowerCase())
|
|
531
|
+
.filter(Boolean);
|
|
532
|
+
|
|
533
|
+
const normalizeExtList = (items) =>
|
|
534
|
+
parseCsvList(items)
|
|
535
|
+
.map((v) => {
|
|
536
|
+
const raw = String(v).trim().toLowerCase();
|
|
537
|
+
if (!raw) return "";
|
|
538
|
+
return raw.startsWith(".") ? raw : `.${raw}`;
|
|
539
|
+
})
|
|
540
|
+
.filter(Boolean);
|
|
541
|
+
|
|
542
|
+
const normalizeAbsList = (items) =>
|
|
543
|
+
parseCsvList(items)
|
|
544
|
+
.map(normalizeRoot)
|
|
545
|
+
.filter(Boolean);
|
|
546
|
+
|
|
547
|
+
const clampNumber = (value, min, max, fallback) => {
|
|
548
|
+
const num = Number(value);
|
|
549
|
+
if (!Number.isFinite(num)) return fallback;
|
|
550
|
+
return Math.max(min, Math.min(max, num));
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
const truncateText = (text, maxChars) => {
|
|
554
|
+
const raw = String(text ?? "");
|
|
555
|
+
if (raw.length <= maxChars) return { text: raw, truncated: false, total: raw.length };
|
|
556
|
+
return { text: raw.slice(0, maxChars), truncated: true, total: raw.length };
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
let cachedPtySpawn = null;
|
|
560
|
+
let cachedPtySpawnChecked = false;
|
|
561
|
+
|
|
562
|
+
const loadPtySpawn = async () => {
|
|
563
|
+
if (cachedPtySpawnChecked) return cachedPtySpawn;
|
|
564
|
+
cachedPtySpawnChecked = true;
|
|
565
|
+
try {
|
|
566
|
+
const module = await import("@lydell/node-pty");
|
|
567
|
+
cachedPtySpawn = module?.spawn || module?.default?.spawn || null;
|
|
568
|
+
} catch {
|
|
569
|
+
cachedPtySpawn = null;
|
|
570
|
+
}
|
|
571
|
+
return cachedPtySpawn;
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
const resolvePtyCommand = (cmd) => {
|
|
575
|
+
if (process.platform === "win32") {
|
|
576
|
+
const shell = process.env["ComSpec"] || "cmd.exe";
|
|
577
|
+
return { file: shell, args: ["/d", "/s", "/c", cmd] };
|
|
578
|
+
}
|
|
579
|
+
const shell = process.env["SHELL"] || "/bin/sh";
|
|
580
|
+
return { file: shell, args: ["-lc", cmd] };
|
|
581
|
+
};
|
|
582
|
+
|
|
583
|
+
const createToolError = (type, message, meta) => {
|
|
584
|
+
const err = new Error(message || "tool error");
|
|
585
|
+
err.code = type;
|
|
586
|
+
err.meta = meta;
|
|
587
|
+
return err;
|
|
588
|
+
};
|
|
589
|
+
|
|
590
|
+
const normalizeToolError = (err) => {
|
|
591
|
+
const rawMessage = String(err?.message || err || "tool error");
|
|
592
|
+
const message = rawMessage.trim() || "tool error";
|
|
593
|
+
const type = String(err?.code || "").toUpperCase();
|
|
594
|
+
if (type === "EXEC_TIMEOUT") {
|
|
595
|
+
return { code: 408, error_type: "exec_timeout", message, meta: err?.meta || null };
|
|
596
|
+
}
|
|
597
|
+
if (type === "EXEC_DENIED") {
|
|
598
|
+
return { code: 403, error_type: "exec_denied", message, meta: err?.meta || null };
|
|
599
|
+
}
|
|
600
|
+
if (type === "EXEC_OUTPUT_LIMIT") {
|
|
601
|
+
return { code: 413, error_type: "exec_output_limit", message, meta: err?.meta || null };
|
|
602
|
+
}
|
|
603
|
+
if (type === "EXEC_FAILED") {
|
|
604
|
+
const exitCode = Number(err?.meta?.exit_code || 0) || 500;
|
|
605
|
+
return { code: exitCode, error_type: "exec_failed", message, meta: err?.meta || null };
|
|
606
|
+
}
|
|
607
|
+
if (type === "INVALID_ARGS") {
|
|
608
|
+
return { code: 400, error_type: "invalid_args", message, meta: err?.meta || null };
|
|
609
|
+
}
|
|
610
|
+
if (type === "NOT_FOUND") {
|
|
611
|
+
return { code: 404, error_type: "not_found", message, meta: err?.meta || null };
|
|
612
|
+
}
|
|
613
|
+
const lower = message.toLowerCase();
|
|
614
|
+
if (lower.includes("not found")) return { code: 404, error_type: "not_found", message, meta: null };
|
|
615
|
+
if (lower.includes("required") || lower.includes("invalid")) {
|
|
616
|
+
return { code: 400, error_type: "invalid_args", message, meta: null };
|
|
617
|
+
}
|
|
618
|
+
if (
|
|
619
|
+
lower.includes("denied") ||
|
|
620
|
+
lower.includes("not allowed") ||
|
|
621
|
+
lower.includes("command not allowed") ||
|
|
622
|
+
lower.includes("risk tool")
|
|
623
|
+
) {
|
|
624
|
+
return { code: 403, error_type: "forbidden", message, meta: null };
|
|
625
|
+
}
|
|
626
|
+
return { code: 500, error_type: "tool_error", message, meta: null };
|
|
627
|
+
};
|
|
628
|
+
|
|
629
|
+
const buildToolResult = ({ tool, ok, code, data, error, meta }) => ({
|
|
630
|
+
ok: Boolean(ok),
|
|
631
|
+
tool: String(tool || ""),
|
|
632
|
+
code: Number(code || 0),
|
|
633
|
+
data: data ?? null,
|
|
634
|
+
error: error || null,
|
|
635
|
+
meta: meta || null
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
const appendAuditEvent = (event) => {
|
|
639
|
+
mkdirSync(dirname(tasksLogPath), { recursive: true });
|
|
640
|
+
const line = JSON.stringify({ ts: nowIso(), type: "tool", ...event });
|
|
641
|
+
writeFileSync(tasksLogPath, line + "\n", { flag: "a" });
|
|
642
|
+
};
|
|
643
|
+
|
|
644
|
+
let currentSessionId = "";
|
|
645
|
+
|
|
646
|
+
const createSessionId = () => `s-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
647
|
+
|
|
648
|
+
let sessionState = {
|
|
649
|
+
date: "",
|
|
650
|
+
dir: "",
|
|
651
|
+
recordPath: "",
|
|
652
|
+
round: 0
|
|
653
|
+
};
|
|
654
|
+
|
|
655
|
+
const formatDate = (d) => {
|
|
656
|
+
const year = d.getFullYear();
|
|
657
|
+
const month = String(d.getMonth() + 1).padStart(2, "0");
|
|
658
|
+
const day = String(d.getDate()).padStart(2, "0");
|
|
659
|
+
return `${year}-${month}-${day}`;
|
|
660
|
+
};
|
|
661
|
+
|
|
662
|
+
const initSessionState = () => {
|
|
663
|
+
const date = formatDate(new Date());
|
|
664
|
+
const dir = join(workspaceRoot, "session", date, currentSessionId);
|
|
665
|
+
mkdirSync(dir, { recursive: true });
|
|
666
|
+
sessionState = { date, dir, recordPath: "", round: 0 };
|
|
667
|
+
};
|
|
668
|
+
|
|
669
|
+
const buildSessionRecordFile = (round) =>
|
|
670
|
+
join(sessionState.dir, `${currentSessionId}-qa${round}-record.json`);
|
|
671
|
+
|
|
672
|
+
const listSessionRecordFiles = () => {
|
|
673
|
+
const dir = sessionState.dir;
|
|
674
|
+
if (!dir || !existsSync(dir)) return [];
|
|
675
|
+
const pattern = new RegExp(`^${currentSessionId}-qa(\\d+)-record\\.json$`);
|
|
676
|
+
return readdirSync(dir)
|
|
677
|
+
.map((name) => {
|
|
678
|
+
const match = name.match(pattern);
|
|
679
|
+
if (!match) return null;
|
|
680
|
+
return { name, round: Number(match[1]) };
|
|
681
|
+
})
|
|
682
|
+
.filter(Boolean)
|
|
683
|
+
.sort((a, b) => a.round - b.round)
|
|
684
|
+
.map((entry) => entry.name);
|
|
685
|
+
};
|
|
686
|
+
|
|
687
|
+
const loadSessionRecords = () => {
|
|
688
|
+
const dir = sessionState.dir;
|
|
689
|
+
if (!dir || !existsSync(dir)) return [];
|
|
690
|
+
const files = listSessionRecordFiles();
|
|
691
|
+
const all = [];
|
|
692
|
+
for (const name of files) {
|
|
693
|
+
const full = join(dir, name);
|
|
694
|
+
if (!existsSync(full)) continue;
|
|
695
|
+
try {
|
|
696
|
+
const raw = readFileSync(full, "utf-8");
|
|
697
|
+
const parsed = JSON.parse(raw);
|
|
698
|
+
if (Array.isArray(parsed)) all.push(...parsed);
|
|
699
|
+
else if (parsed) all.push(parsed);
|
|
700
|
+
} catch {}
|
|
701
|
+
}
|
|
702
|
+
return all;
|
|
703
|
+
};
|
|
704
|
+
|
|
705
|
+
const saveSessionRecords = (round, records) => {
|
|
706
|
+
const recordPath = buildSessionRecordFile(round);
|
|
707
|
+
mkdirSync(dirname(recordPath), { recursive: true });
|
|
708
|
+
writeFileSync(recordPath, JSON.stringify(records, null, 2));
|
|
709
|
+
};
|
|
710
|
+
|
|
711
|
+
const listSessionSummaryFiles = () => {
|
|
712
|
+
const dir = sessionState.dir;
|
|
713
|
+
if (!dir || !existsSync(dir)) return [];
|
|
714
|
+
const prefix = `${currentSessionId}-`;
|
|
715
|
+
const files = readdirSync(dir).filter(
|
|
716
|
+
(f) => f.startsWith(prefix) && f.endsWith("-summary.md")
|
|
717
|
+
);
|
|
718
|
+
const rangeFiles = [];
|
|
719
|
+
const finalFiles = [];
|
|
720
|
+
for (const f of files) {
|
|
721
|
+
const name = f.slice(prefix.length);
|
|
722
|
+
const parts = name.split("-");
|
|
723
|
+
if (parts.length === 2 && parts[1] === "summary.md") {
|
|
724
|
+
finalFiles.push(f);
|
|
725
|
+
continue;
|
|
726
|
+
}
|
|
727
|
+
rangeFiles.push(f);
|
|
728
|
+
}
|
|
729
|
+
const toStart = (f) => {
|
|
730
|
+
const base = f.slice(prefix.length, -"-summary.md".length);
|
|
731
|
+
const segs = base.split("-");
|
|
732
|
+
const token = segs[0] || "";
|
|
733
|
+
const match = token.match(/^(\d+)to(\d+)$/) || token.match(/^(\d+)$/);
|
|
734
|
+
return match ? Number(match[1]) : 0;
|
|
735
|
+
};
|
|
736
|
+
rangeFiles.sort((a, b) => toStart(a) - toStart(b));
|
|
737
|
+
return rangeFiles.length ? rangeFiles : finalFiles;
|
|
738
|
+
};
|
|
739
|
+
|
|
740
|
+
const loadSessionSummaries = () => {
|
|
741
|
+
const files = listSessionSummaryFiles();
|
|
742
|
+
if (!files.length) return [];
|
|
743
|
+
return files
|
|
744
|
+
.map((f) => {
|
|
745
|
+
const full = join(sessionState.dir, f);
|
|
746
|
+
if (!existsSync(full)) return "";
|
|
747
|
+
return readFileSync(full, "utf-8").trim();
|
|
748
|
+
})
|
|
749
|
+
.filter(Boolean);
|
|
750
|
+
};
|
|
751
|
+
|
|
752
|
+
const buildSummaryMessage = (summaries) => {
|
|
753
|
+
const header =
|
|
754
|
+
"以下内容是历史对话的简要总结,如需细节请读取对应 session_id-qa序号-record.json。";
|
|
755
|
+
const body = summaries.join("\n\n");
|
|
756
|
+
const files = listSessionRecordFiles();
|
|
757
|
+
const list = files.length ? files.join(", ") : "无";
|
|
758
|
+
return `${header}\n\n记录文件:${list}\n\n${body}`;
|
|
759
|
+
};
|
|
760
|
+
|
|
761
|
+
const buildRecordsMessage = (records) => {
|
|
762
|
+
const header = "以下内容是历史对话记录摘要,请结合当前问题。";
|
|
763
|
+
const files = listSessionRecordFiles();
|
|
764
|
+
const list = files.length ? files.join(", ") : "无";
|
|
765
|
+
const body = JSON.stringify(records, null, 2);
|
|
766
|
+
return `${header}\n\n记录文件:${list}\n\n${body}`;
|
|
767
|
+
};
|
|
768
|
+
|
|
769
|
+
const appendSessionEvent = (event) => {
|
|
770
|
+
mkdirSync(dirname(sessionLogPath), { recursive: true });
|
|
771
|
+
const payload = {
|
|
772
|
+
ts: nowIso(),
|
|
773
|
+
type: "session",
|
|
774
|
+
session_id: currentSessionId || event?.session_id || "",
|
|
775
|
+
...event
|
|
776
|
+
};
|
|
777
|
+
writeFileSync(sessionLogPath, JSON.stringify(payload) + "\n", { flag: "a" });
|
|
778
|
+
};
|
|
779
|
+
|
|
780
|
+
const startSession = (mode) => {
|
|
781
|
+
currentSessionId = createSessionId();
|
|
782
|
+
initSessionState();
|
|
783
|
+
appendSessionEvent({ event: "start", mode });
|
|
784
|
+
return currentSessionId;
|
|
785
|
+
};
|
|
786
|
+
|
|
787
|
+
let evidenceSeq = 0;
|
|
788
|
+
|
|
789
|
+
const nextEvidenceId = () => {
|
|
790
|
+
evidenceSeq += 1;
|
|
791
|
+
return `e-${Date.now()}-${evidenceSeq}`;
|
|
792
|
+
};
|
|
793
|
+
|
|
794
|
+
const appendEvidenceLog = (record) => {
|
|
795
|
+
mkdirSync(dirname(evidenceLogPath), { recursive: true });
|
|
796
|
+
writeFileSync(evidenceLogPath, JSON.stringify(record) + "\n", { flag: "a" });
|
|
797
|
+
};
|
|
798
|
+
|
|
799
|
+
const buildEvidenceRecord = (input) => ({
|
|
800
|
+
evidence_id: input.evidence_id || nextEvidenceId(),
|
|
801
|
+
ts: input.ts || nowIso(),
|
|
802
|
+
tool: input.tool || "",
|
|
803
|
+
action: input.action || "",
|
|
804
|
+
target: input.target || "",
|
|
805
|
+
status: input.status || "",
|
|
806
|
+
output_path: input.output_path || "",
|
|
807
|
+
error: input.error || "",
|
|
808
|
+
meta: input.meta || null
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
const writeEvidenceFile = (outputPath, record) => {
|
|
812
|
+
const resolved = resolveWorkspacePath(outputPath);
|
|
813
|
+
mkdirSync(dirname(resolved), { recursive: true });
|
|
814
|
+
writeFileSync(resolved, JSON.stringify(record, null, 2));
|
|
815
|
+
return resolved;
|
|
816
|
+
};
|
|
817
|
+
|
|
818
|
+
const summarizeToolResult = (tool, data) => {
|
|
819
|
+
if (tool === "execute") {
|
|
820
|
+
return {
|
|
821
|
+
code: data?.code ?? null,
|
|
822
|
+
stdout_len: typeof data?.stdout === "string" ? data.stdout.length : 0,
|
|
823
|
+
stderr_len: typeof data?.stderr === "string" ? data.stderr.length : 0,
|
|
824
|
+
truncated: Boolean(data?.truncated),
|
|
825
|
+
timed_out: Boolean(data?.timed_out)
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
if (tool === "execute_detached") {
|
|
829
|
+
return {
|
|
830
|
+
session_id: data?.session_id ?? null,
|
|
831
|
+
status: data?.status ?? null,
|
|
832
|
+
pid: data?.pid ?? null
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
if (tool === "process") {
|
|
836
|
+
return {
|
|
837
|
+
action: data?.action ?? null,
|
|
838
|
+
session_id: data?.session_id ?? null,
|
|
839
|
+
status: data?.status ?? null
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
if (tool === "wait_process") {
|
|
843
|
+
return {
|
|
844
|
+
session_id: data?.session_id ?? null,
|
|
845
|
+
status: data?.status ?? null,
|
|
846
|
+
exit_code: data?.exit_code ?? null
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
if (tool === "read_process_log") {
|
|
850
|
+
return {
|
|
851
|
+
session_id: data?.session_id ?? null,
|
|
852
|
+
total: data?.total ?? null
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
if (tool === "read_process_output") {
|
|
856
|
+
return {
|
|
857
|
+
session_id: data?.session_id ?? null,
|
|
858
|
+
status: data?.output?.status ?? null
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
if (tool === "move" || tool === "rename") {
|
|
862
|
+
return { from: data?.from ?? null, to: data?.to ?? null };
|
|
863
|
+
}
|
|
864
|
+
if (tool === "stat") {
|
|
865
|
+
return { path: data?.path ?? null, type: data?.type ?? null };
|
|
866
|
+
}
|
|
867
|
+
if (tool === "diff") {
|
|
868
|
+
return { identical: Boolean(data?.identical), diff_len: data?.diff?.length ?? 0 };
|
|
869
|
+
}
|
|
870
|
+
if (tool === "patch") {
|
|
871
|
+
return { ok: Boolean(data?.ok), path: data?.path ?? null };
|
|
872
|
+
}
|
|
873
|
+
if (tool === "web_fetch") {
|
|
874
|
+
return { status: data?.status ?? null, truncated: Boolean(data?.truncated) };
|
|
875
|
+
}
|
|
876
|
+
if (tool === "web_search") {
|
|
877
|
+
return { query: data?.query ?? null, count: data?.count ?? null };
|
|
878
|
+
}
|
|
879
|
+
if (tool === "ask_user") {
|
|
880
|
+
return { question: data?.question ?? null };
|
|
881
|
+
}
|
|
882
|
+
if (tool === "browser") {
|
|
883
|
+
return {
|
|
884
|
+
action: data?.action ?? null,
|
|
885
|
+
status: data?.status ?? null
|
|
886
|
+
};
|
|
887
|
+
}
|
|
888
|
+
if (tool === "read") {
|
|
889
|
+
return { lines: data?.lines ?? null, offset: data?.offset ?? null };
|
|
890
|
+
}
|
|
891
|
+
if (tool === "grep" || tool === "glob") {
|
|
892
|
+
return { matches: Array.isArray(data?.matches) ? data.matches.length : 0 };
|
|
893
|
+
}
|
|
894
|
+
return null;
|
|
895
|
+
};
|
|
896
|
+
|
|
897
|
+
const processRegistry = new Map();
|
|
898
|
+
let processLastId = 0;
|
|
899
|
+
|
|
900
|
+
const nextProcessId = () => {
|
|
901
|
+
processLastId += 1;
|
|
902
|
+
return `p-${processLastId}`;
|
|
903
|
+
};
|
|
904
|
+
|
|
905
|
+
const appendProcessLog = (logPath, payload) => {
|
|
906
|
+
mkdirSync(dirname(logPath), { recursive: true });
|
|
907
|
+
writeFileSync(logPath, JSON.stringify(payload) + "\n", { flag: "a" });
|
|
908
|
+
};
|
|
909
|
+
|
|
910
|
+
const readProcessLogEntries = (logPath) => {
|
|
911
|
+
if (!existsSync(logPath)) return [];
|
|
912
|
+
const raw = readFileSync(logPath, "utf-8");
|
|
913
|
+
return raw
|
|
914
|
+
.split(/\r?\n/)
|
|
915
|
+
.map((line) => line.trim())
|
|
916
|
+
.filter(Boolean)
|
|
917
|
+
.map((line) => {
|
|
918
|
+
try {
|
|
919
|
+
return JSON.parse(line);
|
|
920
|
+
} catch {
|
|
921
|
+
return null;
|
|
922
|
+
}
|
|
923
|
+
})
|
|
924
|
+
.filter(Boolean);
|
|
925
|
+
};
|
|
926
|
+
|
|
927
|
+
const keepTail = (current, next, maxChars) => {
|
|
928
|
+
const combined = String(current || "") + String(next || "");
|
|
929
|
+
if (combined.length <= maxChars) return combined;
|
|
930
|
+
return combined.slice(combined.length - maxChars);
|
|
931
|
+
};
|
|
932
|
+
|
|
933
|
+
const buildProcessOutput = (session) => ({
|
|
934
|
+
session_id: session.session_id,
|
|
935
|
+
task_id: session.task_id || null,
|
|
936
|
+
cmd: session.cmd,
|
|
937
|
+
cwd: session.cwd,
|
|
938
|
+
is_pty: Boolean(session.is_pty),
|
|
939
|
+
status: session.status,
|
|
940
|
+
pid: session.pid,
|
|
941
|
+
started_at: session.started_at,
|
|
942
|
+
ended_at: session.ended_at || null,
|
|
943
|
+
duration_ms: session.ended_at ? session.ended_at_ms - session.started_at_ms : null,
|
|
944
|
+
exit_code: session.exit_code ?? null,
|
|
945
|
+
signal: session.signal || null,
|
|
946
|
+
log_path: session.log_path,
|
|
947
|
+
output_path: session.output_path,
|
|
948
|
+
stdout_len: session.stdout_len || 0,
|
|
949
|
+
stderr_len: session.stderr_len || 0,
|
|
950
|
+
stdout_truncated: Boolean(session.stdout_truncated),
|
|
951
|
+
stderr_truncated: Boolean(session.stderr_truncated)
|
|
952
|
+
});
|
|
953
|
+
|
|
954
|
+
const writeProcessOutput = (session) => {
|
|
955
|
+
mkdirSync(dirname(session.output_path), { recursive: true });
|
|
956
|
+
writeFileSync(session.output_path, JSON.stringify(buildProcessOutput(session), null, 2));
|
|
957
|
+
};
|
|
958
|
+
|
|
959
|
+
const updateTaskForProcess = (taskId, updates) => {
|
|
960
|
+
if (!taskId) return null;
|
|
961
|
+
try {
|
|
962
|
+
const store = loadTaskStore();
|
|
963
|
+
const task = updateTask(store, { task_id: taskId, ...updates });
|
|
964
|
+
recordTaskUpdate(store, task, { source: "process" });
|
|
965
|
+
return task;
|
|
966
|
+
} catch {
|
|
967
|
+
return null;
|
|
968
|
+
}
|
|
969
|
+
};
|
|
970
|
+
|
|
971
|
+
const finalizeProcessSession = (session, status, code, signal, errorMessage) => {
|
|
972
|
+
if (session.status !== "running") return;
|
|
973
|
+
session.status = status;
|
|
974
|
+
session.exit_code = code;
|
|
975
|
+
session.signal = signal || "";
|
|
976
|
+
session.ended_at_ms = Date.now();
|
|
977
|
+
session.ended_at = nowIso();
|
|
978
|
+
appendProcessLog(session.log_path, {
|
|
979
|
+
ts: session.ended_at,
|
|
980
|
+
stream: "meta",
|
|
981
|
+
event: "exit",
|
|
982
|
+
session_id: session.session_id,
|
|
983
|
+
status: session.status,
|
|
984
|
+
exit_code: session.exit_code ?? null,
|
|
985
|
+
signal: session.signal || null,
|
|
986
|
+
error: errorMessage || null
|
|
987
|
+
});
|
|
988
|
+
writeProcessOutput(session);
|
|
989
|
+
if (session.task_id) {
|
|
990
|
+
const taskStatus = session.exit_code === 0 ? "completed" : "failed";
|
|
991
|
+
updateTaskForProcess(session.task_id, {
|
|
992
|
+
status: taskStatus,
|
|
993
|
+
last_error: taskStatus === "failed" ? errorMessage || "process failed" : "",
|
|
994
|
+
outputs: {
|
|
995
|
+
session_id: session.session_id,
|
|
996
|
+
log_path: session.log_path,
|
|
997
|
+
output_path: session.output_path,
|
|
998
|
+
exit_code: session.exit_code,
|
|
999
|
+
signal: session.signal || null,
|
|
1000
|
+
status: session.status
|
|
1001
|
+
}
|
|
1002
|
+
});
|
|
1003
|
+
}
|
|
1004
|
+
if (session.removed) {
|
|
1005
|
+
processRegistry.delete(session.session_id);
|
|
1006
|
+
}
|
|
1007
|
+
};
|
|
1008
|
+
|
|
1009
|
+
const getArgValue = (args, flag) => {
|
|
1010
|
+
const index = args.indexOf(flag);
|
|
1011
|
+
if (index === -1) return "";
|
|
1012
|
+
return args[index + 1] || "";
|
|
1013
|
+
};
|
|
1014
|
+
|
|
1015
|
+
const normalizeTaskStatus = (value) => {
|
|
1016
|
+
const v = String(value || "").trim();
|
|
1017
|
+
if (
|
|
1018
|
+
v === "draft" ||
|
|
1019
|
+
v === "queued" ||
|
|
1020
|
+
v === "running" ||
|
|
1021
|
+
v === "blocked" ||
|
|
1022
|
+
v === "completed" ||
|
|
1023
|
+
v === "cancelled" ||
|
|
1024
|
+
v === "failed" ||
|
|
1025
|
+
v === "retrying"
|
|
1026
|
+
) {
|
|
1027
|
+
return v;
|
|
1028
|
+
}
|
|
1029
|
+
return "";
|
|
1030
|
+
};
|
|
1031
|
+
|
|
1032
|
+
const normalizeTaskKind = (value) => {
|
|
1033
|
+
const v = String(value || "").trim();
|
|
1034
|
+
if (!v) return "";
|
|
1035
|
+
return v;
|
|
1036
|
+
};
|
|
1037
|
+
|
|
1038
|
+
const normalizeTodoSource = (value) => {
|
|
1039
|
+
const v = String(value || "").trim().toLowerCase();
|
|
1040
|
+
if (v === "user" || v === "ai") return v;
|
|
1041
|
+
return "";
|
|
1042
|
+
};
|
|
1043
|
+
|
|
1044
|
+
const normalizeTodoScope = (value) => {
|
|
1045
|
+
const v = String(value || "").trim();
|
|
1046
|
+
return v || "project";
|
|
1047
|
+
};
|
|
1048
|
+
|
|
1049
|
+
const taskTransitions = {
|
|
1050
|
+
draft: ["queued", "cancelled"],
|
|
1051
|
+
queued: ["running", "cancelled"],
|
|
1052
|
+
running: ["blocked", "completed", "failed", "retrying", "cancelled"],
|
|
1053
|
+
blocked: ["running", "cancelled"],
|
|
1054
|
+
failed: ["retrying", "cancelled"],
|
|
1055
|
+
retrying: ["running", "cancelled"],
|
|
1056
|
+
completed: [],
|
|
1057
|
+
cancelled: []
|
|
1058
|
+
};
|
|
1059
|
+
|
|
1060
|
+
const nowIso = () => new Date().toISOString();
|
|
1061
|
+
|
|
1062
|
+
const loadTaskStore = () => {
|
|
1063
|
+
if (!existsSync(tasksConfigPath)) {
|
|
1064
|
+
return { last_id: 0, tasks: [] };
|
|
1065
|
+
}
|
|
1066
|
+
const raw = readFileSync(tasksConfigPath, "utf-8");
|
|
1067
|
+
const parsed = JSON.parse(raw);
|
|
1068
|
+
const tasks = Array.isArray(parsed.tasks) ? parsed.tasks : [];
|
|
1069
|
+
const lastId = Number(parsed.last_id || 0) || 0;
|
|
1070
|
+
return { last_id: lastId, tasks };
|
|
1071
|
+
};
|
|
1072
|
+
|
|
1073
|
+
const saveTaskStore = (store) => {
|
|
1074
|
+
mkdirSync(dirname(tasksConfigPath), { recursive: true });
|
|
1075
|
+
const safe = { last_id: store.last_id || 0, tasks: store.tasks || [] };
|
|
1076
|
+
writeFileSync(tasksConfigPath, JSON.stringify(safe, null, 2));
|
|
1077
|
+
};
|
|
1078
|
+
|
|
1079
|
+
const appendTaskLog = (entry) => {
|
|
1080
|
+
mkdirSync(dirname(tasksLogPath), { recursive: true });
|
|
1081
|
+
const payload = {
|
|
1082
|
+
ts: entry?.ts || nowIso(),
|
|
1083
|
+
type: "task",
|
|
1084
|
+
action: entry?.action || "update",
|
|
1085
|
+
task: entry?.task || null,
|
|
1086
|
+
meta: entry?.meta || null
|
|
1087
|
+
};
|
|
1088
|
+
const line = JSON.stringify(payload);
|
|
1089
|
+
writeFileSync(tasksLogPath, line + "\n", { flag: "a" });
|
|
1090
|
+
};
|
|
1091
|
+
|
|
1092
|
+
const loadTaskLogs = () => {
|
|
1093
|
+
if (!existsSync(tasksLogPath)) return [];
|
|
1094
|
+
const raw = readFileSync(tasksLogPath, "utf-8");
|
|
1095
|
+
return raw
|
|
1096
|
+
.split(/\r?\n/)
|
|
1097
|
+
.map((line) => line.trim())
|
|
1098
|
+
.filter(Boolean)
|
|
1099
|
+
.map((line) => {
|
|
1100
|
+
try {
|
|
1101
|
+
return JSON.parse(line);
|
|
1102
|
+
} catch {
|
|
1103
|
+
return null;
|
|
1104
|
+
}
|
|
1105
|
+
})
|
|
1106
|
+
.filter(Boolean);
|
|
1107
|
+
};
|
|
1108
|
+
|
|
1109
|
+
const getTaskIdNumber = (taskId) => {
|
|
1110
|
+
const match = String(taskId || "").match(/^t-(\d+)$/);
|
|
1111
|
+
if (!match) return 0;
|
|
1112
|
+
return Number(match[1]) || 0;
|
|
1113
|
+
};
|
|
1114
|
+
|
|
1115
|
+
const replayTaskLogs = (logs) => {
|
|
1116
|
+
const map = new Map();
|
|
1117
|
+
for (const log of logs) {
|
|
1118
|
+
if (!log || !log.task || !log.task.task_id) continue;
|
|
1119
|
+
map.set(log.task.task_id, log.task);
|
|
1120
|
+
}
|
|
1121
|
+
const tasks = Array.from(map.values()).sort((a, b) => getTaskIdNumber(a.task_id) - getTaskIdNumber(b.task_id));
|
|
1122
|
+
const lastId = tasks.reduce((maxId, t) => Math.max(maxId, getTaskIdNumber(t.task_id)), 0);
|
|
1123
|
+
const store = { last_id: lastId, tasks };
|
|
1124
|
+
saveTaskStore(store);
|
|
1125
|
+
return { tasks: tasks.length };
|
|
1126
|
+
};
|
|
1127
|
+
|
|
1128
|
+
const getTaskById = (store, taskId) => store.tasks.find((t) => t.task_id === taskId);
|
|
1129
|
+
|
|
1130
|
+
const ensureTaskExists = (store, taskId) => {
|
|
1131
|
+
const task = getTaskById(store, taskId);
|
|
1132
|
+
if (!task) throw new Error("task not found");
|
|
1133
|
+
return task;
|
|
1134
|
+
};
|
|
1135
|
+
|
|
1136
|
+
const normalizeDependencyType = (value) => (value === "soft" || value === "hard" ? value : "hard");
|
|
1137
|
+
|
|
1138
|
+
const normalizeDependencyInput = (item) => {
|
|
1139
|
+
if (typeof item === "string") {
|
|
1140
|
+
const taskId = item.trim();
|
|
1141
|
+
if (!taskId) return null;
|
|
1142
|
+
return { task_id: taskId, type: "hard" };
|
|
1143
|
+
}
|
|
1144
|
+
if (item && typeof item === "object") {
|
|
1145
|
+
const taskId = String(item.task_id || item.id || "").trim();
|
|
1146
|
+
if (!taskId) return null;
|
|
1147
|
+
return { task_id: taskId, type: normalizeDependencyType(item.type) };
|
|
1148
|
+
}
|
|
1149
|
+
return null;
|
|
1150
|
+
};
|
|
1151
|
+
|
|
1152
|
+
const normalizeTaskDependencies = (store, dependsOn) => {
|
|
1153
|
+
const list = Array.isArray(dependsOn) ? dependsOn : parseCsvList(dependsOn);
|
|
1154
|
+
const uniq = new Map();
|
|
1155
|
+
for (const item of list) {
|
|
1156
|
+
const dep = normalizeDependencyInput(item);
|
|
1157
|
+
if (!dep) continue;
|
|
1158
|
+
ensureTaskExists(store, dep.task_id);
|
|
1159
|
+
if (!uniq.has(dep.task_id)) uniq.set(dep.task_id, dep);
|
|
1160
|
+
}
|
|
1161
|
+
return Array.from(uniq.values());
|
|
1162
|
+
};
|
|
1163
|
+
|
|
1164
|
+
const ensureNoCycle = (store, taskId, parentId) => {
|
|
1165
|
+
if (!parentId) return;
|
|
1166
|
+
if (taskId === parentId) throw new Error("parent loop");
|
|
1167
|
+
let current = ensureTaskExists(store, parentId);
|
|
1168
|
+
const seen = new Set([taskId]);
|
|
1169
|
+
while (current) {
|
|
1170
|
+
if (seen.has(current.task_id)) throw new Error("parent loop");
|
|
1171
|
+
seen.add(current.task_id);
|
|
1172
|
+
if (!current.parent_id) break;
|
|
1173
|
+
current = getTaskById(store, current.parent_id);
|
|
1174
|
+
if (!current) break;
|
|
1175
|
+
}
|
|
1176
|
+
};
|
|
1177
|
+
|
|
1178
|
+
const canTransition = (fromStatus, toStatus) => {
|
|
1179
|
+
if (fromStatus === toStatus) return true;
|
|
1180
|
+
const allowed = taskTransitions[fromStatus] || [];
|
|
1181
|
+
return allowed.includes(toStatus);
|
|
1182
|
+
};
|
|
1183
|
+
|
|
1184
|
+
const normalizeStoredDep = (dep) => {
|
|
1185
|
+
if (typeof dep === "string") return { task_id: dep, type: "hard" };
|
|
1186
|
+
if (dep && typeof dep === "object") return dep;
|
|
1187
|
+
return null;
|
|
1188
|
+
};
|
|
1189
|
+
|
|
1190
|
+
const getHardDeps = (dependsOn) =>
|
|
1191
|
+
(Array.isArray(dependsOn) ? dependsOn : [])
|
|
1192
|
+
.map((dep) => normalizeStoredDep(dep))
|
|
1193
|
+
.filter((d) => d && d.type !== "soft");
|
|
1194
|
+
|
|
1195
|
+
const ensureDepsCompleted = (store, dependsOn) => {
|
|
1196
|
+
const pending = getHardDeps(dependsOn)
|
|
1197
|
+
.map((dep) => dep.task_id)
|
|
1198
|
+
.filter((id) => {
|
|
1199
|
+
const t = getTaskById(store, id);
|
|
1200
|
+
return t && t.status !== "completed";
|
|
1201
|
+
});
|
|
1202
|
+
if (pending.length) throw new Error(`hard dependencies not completed: ${pending.join(",")}`);
|
|
1203
|
+
};
|
|
1204
|
+
|
|
1205
|
+
const propagateDependencyFailure = (store, task) => {
|
|
1206
|
+
if (!task || !["failed", "cancelled"].includes(task.status)) return [];
|
|
1207
|
+
const reason = `hard dependency ${task.task_id} ${task.status}`;
|
|
1208
|
+
const affected = [];
|
|
1209
|
+
for (const t of store.tasks) {
|
|
1210
|
+
if (!t || t.task_id === task.task_id) continue;
|
|
1211
|
+
const deps = Array.isArray(t.depends_on) ? t.depends_on : [];
|
|
1212
|
+
const hasHard = deps.some((dep) => dep && dep.task_id === task.task_id && dep.type !== "soft");
|
|
1213
|
+
if (!hasHard) continue;
|
|
1214
|
+
let changed = false;
|
|
1215
|
+
if (t.status === "running" && canTransition(t.status, "blocked")) {
|
|
1216
|
+
t.status = "blocked";
|
|
1217
|
+
changed = true;
|
|
1218
|
+
}
|
|
1219
|
+
if (!["completed", "cancelled"].includes(t.status)) {
|
|
1220
|
+
t.last_error = reason;
|
|
1221
|
+
changed = true;
|
|
1222
|
+
}
|
|
1223
|
+
if (changed) {
|
|
1224
|
+
t.updated_at = nowIso();
|
|
1225
|
+
affected.push(t);
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
return affected;
|
|
1229
|
+
};
|
|
1230
|
+
|
|
1231
|
+
const recordTaskUpdate = (store, task, meta) => {
|
|
1232
|
+
const affected = propagateDependencyFailure(store, task);
|
|
1233
|
+
saveTaskStore(store);
|
|
1234
|
+
appendTaskLog({ ts: nowIso(), action: "update", task, meta: meta || null });
|
|
1235
|
+
for (const depTask of affected) {
|
|
1236
|
+
appendTaskLog({
|
|
1237
|
+
ts: nowIso(),
|
|
1238
|
+
action: "update",
|
|
1239
|
+
task: depTask,
|
|
1240
|
+
meta: { source: "deps", caused_by: task.task_id }
|
|
1241
|
+
});
|
|
1242
|
+
}
|
|
1243
|
+
return affected;
|
|
1244
|
+
};
|
|
1245
|
+
|
|
1246
|
+
const createTask = (store, input) => {
|
|
1247
|
+
const title = String(input.title || "").trim();
|
|
1248
|
+
if (!title) throw new Error("title required");
|
|
1249
|
+
const status = normalizeTaskStatus(input.status) || "draft";
|
|
1250
|
+
const kind = normalizeTaskKind(input.kind) || "";
|
|
1251
|
+
const nextId = store.last_id + 1;
|
|
1252
|
+
const taskId = `t-${nextId}`;
|
|
1253
|
+
const parentId = input.parent_id ? String(input.parent_id) : "";
|
|
1254
|
+
if (parentId) ensureTaskExists(store, parentId);
|
|
1255
|
+
const dependsOn = normalizeTaskDependencies(store, input.depends_on);
|
|
1256
|
+
if (["running", "completed"].includes(status)) {
|
|
1257
|
+
ensureDepsCompleted(store, dependsOn);
|
|
1258
|
+
}
|
|
1259
|
+
const now = nowIso();
|
|
1260
|
+
const task = {
|
|
1261
|
+
task_id: taskId,
|
|
1262
|
+
title,
|
|
1263
|
+
status,
|
|
1264
|
+
kind: kind || null,
|
|
1265
|
+
parent_id: parentId || null,
|
|
1266
|
+
depends_on: dependsOn,
|
|
1267
|
+
created_at: now,
|
|
1268
|
+
updated_at: now,
|
|
1269
|
+
inputs: input.inputs || null,
|
|
1270
|
+
outputs: input.outputs || null,
|
|
1271
|
+
retries: 0,
|
|
1272
|
+
last_error: ""
|
|
1273
|
+
};
|
|
1274
|
+
store.last_id = nextId;
|
|
1275
|
+
store.tasks.push(task);
|
|
1276
|
+
return task;
|
|
1277
|
+
};
|
|
1278
|
+
|
|
1279
|
+
const updateTask = (store, input) => {
|
|
1280
|
+
const taskId = String(input.task_id || "").trim();
|
|
1281
|
+
if (!taskId) throw new Error("task_id required");
|
|
1282
|
+
const task = ensureTaskExists(store, taskId);
|
|
1283
|
+
if (input.title != null) {
|
|
1284
|
+
const title = String(input.title || "").trim();
|
|
1285
|
+
if (!title) throw new Error("title required");
|
|
1286
|
+
task.title = title;
|
|
1287
|
+
}
|
|
1288
|
+
if (input.parent_id !== undefined) {
|
|
1289
|
+
const parentId = input.parent_id ? String(input.parent_id) : "";
|
|
1290
|
+
if (parentId) ensureTaskExists(store, parentId);
|
|
1291
|
+
ensureNoCycle(store, taskId, parentId);
|
|
1292
|
+
task.parent_id = parentId || null;
|
|
1293
|
+
}
|
|
1294
|
+
if (input.depends_on !== undefined) {
|
|
1295
|
+
task.depends_on = normalizeTaskDependencies(store, input.depends_on);
|
|
1296
|
+
}
|
|
1297
|
+
if (input.kind !== undefined) {
|
|
1298
|
+
const kind = normalizeTaskKind(input.kind);
|
|
1299
|
+
task.kind = kind || null;
|
|
1300
|
+
}
|
|
1301
|
+
if (input.status) {
|
|
1302
|
+
const next = normalizeTaskStatus(input.status);
|
|
1303
|
+
if (!next) throw new Error("invalid status");
|
|
1304
|
+
if (!canTransition(task.status, next)) throw new Error("status transition not allowed");
|
|
1305
|
+
if (["running", "completed"].includes(next)) {
|
|
1306
|
+
ensureDepsCompleted(store, task.depends_on || []);
|
|
1307
|
+
}
|
|
1308
|
+
if (next === "retrying") {
|
|
1309
|
+
task.retries = Number(task.retries || 0) + 1;
|
|
1310
|
+
}
|
|
1311
|
+
task.status = next;
|
|
1312
|
+
}
|
|
1313
|
+
if (input.inputs !== undefined) {
|
|
1314
|
+
task.inputs = input.inputs || null;
|
|
1315
|
+
}
|
|
1316
|
+
if (input.outputs !== undefined) {
|
|
1317
|
+
task.outputs = input.outputs || null;
|
|
1318
|
+
}
|
|
1319
|
+
if (input.last_error !== undefined) {
|
|
1320
|
+
task.last_error = String(input.last_error || "");
|
|
1321
|
+
}
|
|
1322
|
+
task.updated_at = nowIso();
|
|
1323
|
+
return task;
|
|
1324
|
+
};
|
|
1325
|
+
|
|
1326
|
+
const listTasks = (store, filters) => {
|
|
1327
|
+
const status = normalizeTaskStatus(filters?.status);
|
|
1328
|
+
const parentId = filters?.parent_id ? String(filters.parent_id) : "";
|
|
1329
|
+
return store.tasks.filter((t) => {
|
|
1330
|
+
if (status && t.status !== status) return false;
|
|
1331
|
+
if (parentId && String(t.parent_id || "") !== parentId) return false;
|
|
1332
|
+
return true;
|
|
1333
|
+
});
|
|
1334
|
+
};
|
|
1335
|
+
|
|
1336
|
+
const buildTaskTree = (store, rootId) => {
|
|
1337
|
+
const byParent = new Map();
|
|
1338
|
+
for (const task of store.tasks) {
|
|
1339
|
+
const key = task.parent_id || "";
|
|
1340
|
+
const list = byParent.get(key) || [];
|
|
1341
|
+
list.push(task);
|
|
1342
|
+
byParent.set(key, list);
|
|
1343
|
+
}
|
|
1344
|
+
const buildNode = (task) => ({
|
|
1345
|
+
...task,
|
|
1346
|
+
children: (byParent.get(task.task_id) || []).map(buildNode)
|
|
1347
|
+
});
|
|
1348
|
+
if (rootId) {
|
|
1349
|
+
const root = ensureTaskExists(store, rootId);
|
|
1350
|
+
return buildNode(root);
|
|
1351
|
+
}
|
|
1352
|
+
return (byParent.get("") || []).map(buildNode);
|
|
1353
|
+
};
|
|
1354
|
+
|
|
1355
|
+
const formatTaskDeps = (deps) =>
|
|
1356
|
+
(Array.isArray(deps) ? deps : [])
|
|
1357
|
+
.map((dep) => normalizeStoredDep(dep))
|
|
1358
|
+
.filter(Boolean)
|
|
1359
|
+
.map((dep) => (dep.type === "soft" ? `${dep.task_id}:soft` : dep.task_id))
|
|
1360
|
+
.join(",");
|
|
1361
|
+
|
|
1362
|
+
const formatTaskLine = (task) =>
|
|
1363
|
+
`${task.task_id} ${task.status} ${task.title}${task.parent_id ? ` parent=${task.parent_id}` : ""}${
|
|
1364
|
+
task.depends_on?.length ? ` deps=${formatTaskDeps(task.depends_on)}` : ""
|
|
1365
|
+
}`;
|
|
1366
|
+
|
|
1367
|
+
const getTodoScope = (task) => {
|
|
1368
|
+
const scope = task?.inputs?.scope ? String(task.inputs.scope) : "";
|
|
1369
|
+
return normalizeTodoScope(scope);
|
|
1370
|
+
};
|
|
1371
|
+
|
|
1372
|
+
const getTodoSource = (task) => {
|
|
1373
|
+
const source = task?.inputs?.source ? String(task.inputs.source) : "";
|
|
1374
|
+
return normalizeTodoSource(source) || "ai";
|
|
1375
|
+
};
|
|
1376
|
+
|
|
1377
|
+
const isTodoTask = (task) => task && task.kind === "todo";
|
|
1378
|
+
|
|
1379
|
+
const listTodos = (store, filters) => {
|
|
1380
|
+
const status = normalizeTaskStatus(filters?.status);
|
|
1381
|
+
const scopeRaw = filters?.scope ? String(filters.scope).trim() : "";
|
|
1382
|
+
return store.tasks.filter((task) => {
|
|
1383
|
+
if (!isTodoTask(task)) return false;
|
|
1384
|
+
if (status && task.status !== status) return false;
|
|
1385
|
+
if (scopeRaw && getTodoScope(task) !== scopeRaw) return false;
|
|
1386
|
+
return true;
|
|
1387
|
+
});
|
|
1388
|
+
};
|
|
1389
|
+
|
|
1390
|
+
const findRunningTodo = (store, scope, exceptId) =>
|
|
1391
|
+
store.tasks.find((task) => {
|
|
1392
|
+
if (!isTodoTask(task)) return false;
|
|
1393
|
+
if (task.status !== "running") return false;
|
|
1394
|
+
if (scope && getTodoScope(task) !== scope) return false;
|
|
1395
|
+
if (exceptId && task.task_id === exceptId) return false;
|
|
1396
|
+
return true;
|
|
1397
|
+
});
|
|
1398
|
+
|
|
1399
|
+
const formatTodoLine = (task) =>
|
|
1400
|
+
`${task.task_id} ${task.status} ${task.title} scope=${getTodoScope(task)} source=${getTodoSource(task)}`;
|
|
1401
|
+
|
|
1402
|
+
const parseTodoArgs = (tokens) => {
|
|
1403
|
+
const result = { scope: "", source: "", status: "", text: "" };
|
|
1404
|
+
const textParts = [];
|
|
1405
|
+
for (const token of tokens) {
|
|
1406
|
+
if (token.startsWith("scope=")) {
|
|
1407
|
+
result.scope = token.slice("scope=".length);
|
|
1408
|
+
continue;
|
|
1409
|
+
}
|
|
1410
|
+
if (token.startsWith("source=")) {
|
|
1411
|
+
result.source = token.slice("source=".length);
|
|
1412
|
+
continue;
|
|
1413
|
+
}
|
|
1414
|
+
if (token.startsWith("status=")) {
|
|
1415
|
+
result.status = token.slice("status=".length);
|
|
1416
|
+
continue;
|
|
1417
|
+
}
|
|
1418
|
+
textParts.push(token);
|
|
1419
|
+
}
|
|
1420
|
+
result.text = textParts.join(" ").trim();
|
|
1421
|
+
return result;
|
|
1422
|
+
};
|
|
34
1423
|
|
|
35
|
-
const
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
|
|
1424
|
+
const createTodo = (store, input) => {
|
|
1425
|
+
const text = String(input.text || "").trim();
|
|
1426
|
+
if (!text) throw new Error("text required");
|
|
1427
|
+
const scope = normalizeTodoScope(input.scope);
|
|
1428
|
+
const source = normalizeTodoSource(input.source) || input.defaultSource || "ai";
|
|
1429
|
+
const task = createTask(store, {
|
|
1430
|
+
title: text,
|
|
1431
|
+
status: "draft",
|
|
1432
|
+
kind: "todo",
|
|
1433
|
+
inputs: { source, scope }
|
|
1434
|
+
});
|
|
1435
|
+
saveTaskStore(store);
|
|
1436
|
+
appendTaskLog({ ts: nowIso(), action: "create", task, meta: { source: "todo" } });
|
|
1437
|
+
return task;
|
|
1438
|
+
};
|
|
41
1439
|
|
|
42
|
-
const
|
|
43
|
-
const
|
|
44
|
-
if (
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
1440
|
+
const startTodo = (store, taskId, meta) => {
|
|
1441
|
+
const task = ensureTaskExists(store, taskId);
|
|
1442
|
+
if (!isTodoTask(task)) throw new Error("not a todo");
|
|
1443
|
+
if (task.status === "completed") throw new Error("todo already completed");
|
|
1444
|
+
const scope = getTodoScope(task);
|
|
1445
|
+
const running = findRunningTodo(store, scope, taskId);
|
|
1446
|
+
if (running) throw new Error(`running todo exists: ${running.task_id}`);
|
|
1447
|
+
if (task.status === "draft") {
|
|
1448
|
+
const queued = updateTask(store, { task_id: taskId, status: "queued" });
|
|
1449
|
+
recordTaskUpdate(store, queued, meta);
|
|
50
1450
|
}
|
|
51
|
-
|
|
1451
|
+
if (task.status !== "running") {
|
|
1452
|
+
const runningTask = updateTask(store, { task_id: taskId, status: "running" });
|
|
1453
|
+
recordTaskUpdate(store, runningTask, meta);
|
|
1454
|
+
return runningTask;
|
|
1455
|
+
}
|
|
1456
|
+
return task;
|
|
52
1457
|
};
|
|
53
1458
|
|
|
54
|
-
const
|
|
1459
|
+
const doneTodo = (store, taskId, meta) => {
|
|
1460
|
+
const task = ensureTaskExists(store, taskId);
|
|
1461
|
+
if (!isTodoTask(task)) throw new Error("not a todo");
|
|
1462
|
+
if (!["running", "blocked"].includes(task.status)) throw new Error("todo not running");
|
|
1463
|
+
const completed = updateTask(store, { task_id: taskId, status: "completed" });
|
|
1464
|
+
recordTaskUpdate(store, completed, meta);
|
|
1465
|
+
return completed;
|
|
1466
|
+
};
|
|
55
1467
|
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
1468
|
+
const patchTodo = (store, taskId, input) => {
|
|
1469
|
+
const task = ensureTaskExists(store, taskId);
|
|
1470
|
+
if (!isTodoTask(task)) throw new Error("not a todo");
|
|
1471
|
+
const text = String(input.text || "").trim();
|
|
1472
|
+
if (!text) throw new Error("text required");
|
|
1473
|
+
const scope = normalizeTodoScope(input.scope || getTodoScope(task));
|
|
1474
|
+
const source = normalizeTodoSource(input.source) || input.defaultSource || getTodoSource(task);
|
|
1475
|
+
const patchTask = createTask(store, {
|
|
1476
|
+
title: text,
|
|
1477
|
+
status: "draft",
|
|
1478
|
+
kind: "todo",
|
|
1479
|
+
parent_id: taskId,
|
|
1480
|
+
inputs: { source, scope, patch_of: taskId }
|
|
1481
|
+
});
|
|
1482
|
+
saveTaskStore(store);
|
|
1483
|
+
appendTaskLog({ ts: nowIso(), action: "create", task: patchTask, meta: { source: "todo" } });
|
|
1484
|
+
return patchTask;
|
|
59
1485
|
};
|
|
60
1486
|
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
1487
|
+
const clearTodos = (store, scope, meta) => {
|
|
1488
|
+
const scopeRaw = scope ? String(scope).trim() : "";
|
|
1489
|
+
const targets = store.tasks.filter((task) => {
|
|
1490
|
+
if (!isTodoTask(task)) return false;
|
|
1491
|
+
if (scopeRaw && getTodoScope(task) !== scopeRaw) return false;
|
|
1492
|
+
return !["completed", "cancelled"].includes(task.status);
|
|
1493
|
+
});
|
|
1494
|
+
for (const task of targets) {
|
|
1495
|
+
const updated = updateTask(store, { task_id: task.task_id, status: "cancelled" });
|
|
1496
|
+
recordTaskUpdate(store, updated, meta);
|
|
65
1497
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
1498
|
+
return targets.length;
|
|
1499
|
+
};
|
|
1500
|
+
|
|
1501
|
+
const handleTaskCli = (input) => {
|
|
1502
|
+
const trimmed = String(input || "").trim();
|
|
1503
|
+
if (trimmed === "/tasks" || trimmed.startsWith("/tasks ")) {
|
|
1504
|
+
const status = trimmed.split(/\s+/)[1] || "";
|
|
1505
|
+
const store = loadTaskStore();
|
|
1506
|
+
const tasks = listTasks(store, { status });
|
|
1507
|
+
return tasks.length ? tasks.map(formatTaskLine).join("\n") : "no tasks";
|
|
1508
|
+
}
|
|
1509
|
+
if (trimmed === "/task" || trimmed.startsWith("/task ")) {
|
|
1510
|
+
const parts = trimmed.split(/\s+/);
|
|
1511
|
+
const action = parts[1] || "";
|
|
1512
|
+
const store = loadTaskStore();
|
|
1513
|
+
if (action === "new") {
|
|
1514
|
+
const title = trimmed.slice(trimmed.indexOf("new") + 4).trim();
|
|
1515
|
+
const task = createTask(store, { title });
|
|
1516
|
+
saveTaskStore(store);
|
|
1517
|
+
appendTaskLog({ ts: nowIso(), action: "create", task });
|
|
1518
|
+
return formatTaskLine(task);
|
|
1519
|
+
}
|
|
1520
|
+
if (action === "show") {
|
|
1521
|
+
const taskId = parts[2] || "";
|
|
1522
|
+
if (!taskId) throw new Error("task_id required");
|
|
1523
|
+
const task = ensureTaskExists(store, taskId);
|
|
1524
|
+
return JSON.stringify(task, null, 2);
|
|
1525
|
+
}
|
|
1526
|
+
if (action === "status") {
|
|
1527
|
+
const taskId = parts[2] || "";
|
|
1528
|
+
const status = parts[3] || "";
|
|
1529
|
+
const task = updateTask(store, { task_id: taskId, status });
|
|
1530
|
+
recordTaskUpdate(store, task, null);
|
|
1531
|
+
return formatTaskLine(task);
|
|
1532
|
+
}
|
|
1533
|
+
if (action === "deps") {
|
|
1534
|
+
const taskId = parts[2] || "";
|
|
1535
|
+
const deps = parts[3] || "";
|
|
1536
|
+
const task = updateTask(store, { task_id: taskId, depends_on: deps });
|
|
1537
|
+
recordTaskUpdate(store, task, null);
|
|
1538
|
+
return formatTaskLine(task);
|
|
1539
|
+
}
|
|
1540
|
+
if (action === "parent") {
|
|
1541
|
+
const taskId = parts[2] || "";
|
|
1542
|
+
const parentId = parts[3] || "";
|
|
1543
|
+
const task = updateTask(store, { task_id: taskId, parent_id: parentId || null });
|
|
1544
|
+
recordTaskUpdate(store, task, null);
|
|
1545
|
+
return formatTaskLine(task);
|
|
1546
|
+
}
|
|
1547
|
+
if (action === "log") {
|
|
1548
|
+
const taskId = parts[2] || "";
|
|
1549
|
+
const limit = Number(parts[3] || 50) || 50;
|
|
1550
|
+
const logs = loadTaskLogs()
|
|
1551
|
+
.filter((l) => (taskId ? l.task?.task_id === taskId : true))
|
|
1552
|
+
.slice(-limit);
|
|
1553
|
+
return logs.length ? JSON.stringify(logs, null, 2) : "no logs";
|
|
1554
|
+
}
|
|
1555
|
+
if (action === "replay") {
|
|
1556
|
+
const logs = loadTaskLogs();
|
|
1557
|
+
if (!logs.length) return "no logs";
|
|
1558
|
+
const result = replayTaskLogs(logs);
|
|
1559
|
+
return `replayed ${result.tasks} tasks`;
|
|
1560
|
+
}
|
|
1561
|
+
if (action === "tree") {
|
|
1562
|
+
const rootId = parts[2] || "";
|
|
1563
|
+
const tree = buildTaskTree(store, rootId);
|
|
1564
|
+
return JSON.stringify(tree, null, 2);
|
|
1565
|
+
}
|
|
1566
|
+
return "task commands: /task new <title> | /task show <id> | /task status <id> <status> | /task deps <id> <dep1,dep2> | /task parent <id> <parentId> | /task log [id] [limit] | /task replay | /task tree [rootId]";
|
|
1567
|
+
}
|
|
1568
|
+
if (trimmed === "/todo" || trimmed.startsWith("/todo ")) {
|
|
1569
|
+
const parts = trimmed.split(/\s+/);
|
|
1570
|
+
const action = parts[1] || "list";
|
|
1571
|
+
const store = loadTaskStore();
|
|
1572
|
+
if (action === "list") {
|
|
1573
|
+
const params = parseTodoArgs(parts.slice(2));
|
|
1574
|
+
const todos = listTodos(store, { scope: params.scope || undefined, status: params.status || undefined });
|
|
1575
|
+
return todos.length ? todos.map(formatTodoLine).join("\n") : "no todos";
|
|
1576
|
+
}
|
|
1577
|
+
if (action === "add") {
|
|
1578
|
+
const params = parseTodoArgs(parts.slice(2));
|
|
1579
|
+
const task = createTodo(store, { text: params.text, scope: params.scope, source: params.source, defaultSource: "user" });
|
|
1580
|
+
return formatTodoLine(task);
|
|
1581
|
+
}
|
|
1582
|
+
if (action === "start") {
|
|
1583
|
+
const todoId = parts[2] || "";
|
|
1584
|
+
if (!todoId) throw new Error("todo_id required");
|
|
1585
|
+
const task = startTodo(store, todoId, { source: "todo" });
|
|
1586
|
+
return formatTodoLine(task);
|
|
1587
|
+
}
|
|
1588
|
+
if (action === "done") {
|
|
1589
|
+
const todoId = parts[2] || "";
|
|
1590
|
+
if (!todoId) throw new Error("todo_id required");
|
|
1591
|
+
const task = doneTodo(store, todoId, { source: "todo" });
|
|
1592
|
+
return formatTodoLine(task);
|
|
1593
|
+
}
|
|
1594
|
+
if (action === "patch") {
|
|
1595
|
+
const todoId = parts[2] || "";
|
|
1596
|
+
if (!todoId) throw new Error("todo_id required");
|
|
1597
|
+
const params = parseTodoArgs(parts.slice(3));
|
|
1598
|
+
const task = patchTodo(store, todoId, {
|
|
1599
|
+
text: params.text,
|
|
1600
|
+
scope: params.scope,
|
|
1601
|
+
source: params.source,
|
|
1602
|
+
defaultSource: "user"
|
|
1603
|
+
});
|
|
1604
|
+
return formatTodoLine(task);
|
|
1605
|
+
}
|
|
1606
|
+
if (action === "clear") {
|
|
1607
|
+
const params = parseTodoArgs(parts.slice(2));
|
|
1608
|
+
const count = clearTodos(store, params.scope, { source: "todo" });
|
|
1609
|
+
return `cleared ${count} todos`;
|
|
1610
|
+
}
|
|
1611
|
+
return "todo commands: /todo list [scope=<scope>] [status=<status>] | /todo add <text> [scope=<scope>] [source=user|ai] | /todo start <id> | /todo done <id> | /todo patch <id> <text> [scope=<scope>] [source=user|ai] | /todo clear [scope=<scope>]";
|
|
69
1612
|
}
|
|
70
|
-
return
|
|
1613
|
+
return "";
|
|
71
1614
|
};
|
|
72
1615
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
1616
|
+
let accessMode = "strict";
|
|
1617
|
+
let allowRisk = new Set();
|
|
1618
|
+
let allowedRoots = [];
|
|
1619
|
+
let allowRiskUntil = {};
|
|
1620
|
+
let allowRiskTtlHours = 0;
|
|
1621
|
+
let executeAllowlist = [];
|
|
1622
|
+
let openUrlAllowlist = [];
|
|
1623
|
+
let openFileAllowlist = [];
|
|
1624
|
+
let denyFileNames = [];
|
|
1625
|
+
let denyFileExts = [];
|
|
1626
|
+
let denyDirsAbs = [];
|
|
1627
|
+
let denyDirSegments = [];
|
|
1628
|
+
let sandboxMode = "off";
|
|
1629
|
+
|
|
1630
|
+
const buildAccessConfig = (args) => {
|
|
1631
|
+
const fileCfg = loadAccessConfig();
|
|
1632
|
+
const envCfg = {
|
|
1633
|
+
mode: process.env["9T_MODE"],
|
|
1634
|
+
allowed_dirs: process.env["9T_ALLOWED_DIRS"],
|
|
1635
|
+
workspace: process.env["9T_WORKSPACE"],
|
|
1636
|
+
allow_risk: process.env["9T_ALLOW_RISK"],
|
|
1637
|
+
allow_risk_ttl_hours: process.env["9T_ALLOW_RISK_TTL_HOURS"],
|
|
1638
|
+
execute_allowlist: process.env["9T_EXECUTE_ALLOWLIST"],
|
|
1639
|
+
open_url_allowlist: process.env["9T_OPEN_URL_ALLOWLIST"],
|
|
1640
|
+
open_file_allowlist: process.env["9T_OPEN_FILE_ALLOWLIST"],
|
|
1641
|
+
deny_file_names: process.env["9T_DENY_FILE_NAMES"],
|
|
1642
|
+
deny_file_exts: process.env["9T_DENY_FILE_EXTS"],
|
|
1643
|
+
deny_dirs: process.env["9T_DENY_DIRS"],
|
|
1644
|
+
sandbox: process.env["9T_SANDBOX"]
|
|
1645
|
+
};
|
|
1646
|
+
const cliCfg = {
|
|
1647
|
+
mode: getArgValue(args, "--mode") || getArgValue(args, "--allow-mode"),
|
|
1648
|
+
roots: getArgValue(args, "--roots"),
|
|
1649
|
+
workspace: getArgValue(args, "--workspace"),
|
|
1650
|
+
allow_risk: getArgValue(args, "--allow-risk"),
|
|
1651
|
+
allow_risk_ttl_hours: getArgValue(args, "--allow-risk-ttl-hours"),
|
|
1652
|
+
execute_allowlist: getArgValue(args, "--execute-allowlist"),
|
|
1653
|
+
open_url_allowlist: getArgValue(args, "--open-url-allowlist"),
|
|
1654
|
+
open_file_allowlist: getArgValue(args, "--open-file-allowlist"),
|
|
1655
|
+
deny_file_names: getArgValue(args, "--deny-file-names"),
|
|
1656
|
+
deny_file_exts: getArgValue(args, "--deny-file-exts"),
|
|
1657
|
+
deny_dirs: getArgValue(args, "--deny-dirs"),
|
|
1658
|
+
sandbox: getArgValue(args, "--sandbox")
|
|
1659
|
+
};
|
|
1660
|
+
const mode =
|
|
1661
|
+
normalizeMode(cliCfg.mode) ||
|
|
1662
|
+
normalizeMode(envCfg.mode) ||
|
|
1663
|
+
normalizeMode(fileCfg.mode) ||
|
|
1664
|
+
"allow";
|
|
1665
|
+
const workspace =
|
|
1666
|
+
normalizeAbsolute(cliCfg.workspace) ||
|
|
1667
|
+
normalizeAbsolute(envCfg.workspace) ||
|
|
1668
|
+
normalizeAbsolute(fileCfg.workspace) ||
|
|
1669
|
+
workspaceRoot;
|
|
1670
|
+
const roots =
|
|
1671
|
+
parseCsvList(cliCfg.roots || envCfg.allowed_dirs || fileCfg.allowed_dirs).map(normalizeAbsolute);
|
|
1672
|
+
const risk = parseAllowRisk(cliCfg.allow_risk || envCfg.allow_risk || fileCfg.allow_risk);
|
|
1673
|
+
const ttlHours = parseHours(
|
|
1674
|
+
cliCfg.allow_risk_ttl_hours || envCfg.allow_risk_ttl_hours || fileCfg.allow_risk_ttl_hours
|
|
1675
|
+
);
|
|
1676
|
+
const grants = parseAllowRiskUntil(fileCfg.allow_risk_until);
|
|
1677
|
+
if (fileCfg.allow_risk_until && Object.keys(grants).length !== Object.keys(fileCfg.allow_risk_until).length) {
|
|
1678
|
+
saveAccessConfig({ allow_risk_until: grants });
|
|
1679
|
+
}
|
|
1680
|
+
const execAllowlist = normalizeListLower(
|
|
1681
|
+
cliCfg.execute_allowlist || envCfg.execute_allowlist || fileCfg.execute_allowlist
|
|
1682
|
+
);
|
|
1683
|
+
const urlAllowlist = normalizeListLower(
|
|
1684
|
+
cliCfg.open_url_allowlist || envCfg.open_url_allowlist || fileCfg.open_url_allowlist
|
|
1685
|
+
);
|
|
1686
|
+
const fileOpenAllowlist = normalizeExtList(
|
|
1687
|
+
cliCfg.open_file_allowlist || envCfg.open_file_allowlist || fileCfg.open_file_allowlist
|
|
1688
|
+
);
|
|
1689
|
+
const denyNames = normalizeListLower(
|
|
1690
|
+
cliCfg.deny_file_names || envCfg.deny_file_names || fileCfg.deny_file_names
|
|
1691
|
+
);
|
|
1692
|
+
const denyExts = normalizeExtList(
|
|
1693
|
+
cliCfg.deny_file_exts || envCfg.deny_file_exts || fileCfg.deny_file_exts
|
|
1694
|
+
);
|
|
1695
|
+
const denyDirs = normalizeAbsList(cliCfg.deny_dirs || envCfg.deny_dirs || fileCfg.deny_dirs);
|
|
1696
|
+
const sandbox =
|
|
1697
|
+
normalizeSandboxMode(cliCfg.sandbox) ||
|
|
1698
|
+
normalizeSandboxMode(envCfg.sandbox) ||
|
|
1699
|
+
normalizeSandboxMode(fileCfg.sandbox) ||
|
|
1700
|
+
defaultSandboxMode();
|
|
1701
|
+
return {
|
|
1702
|
+
mode,
|
|
1703
|
+
workspace,
|
|
1704
|
+
roots: roots.filter(Boolean),
|
|
1705
|
+
risk,
|
|
1706
|
+
ttlHours,
|
|
1707
|
+
grants,
|
|
1708
|
+
execAllowlist,
|
|
1709
|
+
urlAllowlist,
|
|
1710
|
+
fileOpenAllowlist,
|
|
1711
|
+
denyNames,
|
|
1712
|
+
denyExts,
|
|
1713
|
+
denyDirs,
|
|
1714
|
+
sandbox,
|
|
1715
|
+
cliProvided: {
|
|
1716
|
+
mode: Boolean(cliCfg.mode),
|
|
1717
|
+
roots: Boolean(cliCfg.roots),
|
|
1718
|
+
workspace: Boolean(cliCfg.workspace),
|
|
1719
|
+
allowRisk: Boolean(cliCfg.allow_risk),
|
|
1720
|
+
allowRiskTtlHours: Boolean(cliCfg.allow_risk_ttl_hours),
|
|
1721
|
+
executeAllowlist: Boolean(cliCfg.execute_allowlist),
|
|
1722
|
+
openUrlAllowlist: Boolean(cliCfg.open_url_allowlist),
|
|
1723
|
+
openFileAllowlist: Boolean(cliCfg.open_file_allowlist),
|
|
1724
|
+
denyFileNames: Boolean(cliCfg.deny_file_names),
|
|
1725
|
+
denyFileExts: Boolean(cliCfg.deny_file_exts),
|
|
1726
|
+
denyDirs: Boolean(cliCfg.deny_dirs),
|
|
1727
|
+
sandbox: Boolean(cliCfg.sandbox)
|
|
1728
|
+
}
|
|
1729
|
+
};
|
|
77
1730
|
};
|
|
78
1731
|
|
|
79
1732
|
const config = loadProviderConfig();
|
|
80
|
-
|
|
81
|
-
const
|
|
1733
|
+
let providerName = normalizeProviderName(config.provider) || defaultProviderName;
|
|
1734
|
+
const initialPreset = getProviderPreset(providerName);
|
|
1735
|
+
let providerFormat = String(config.format || initialPreset?.format || defaultProvider.format || "anthropic").trim();
|
|
1736
|
+
let baseUrl = String(config.base_url || initialPreset?.base_url || defaultProvider.base_url)
|
|
1737
|
+
.trim()
|
|
1738
|
+
.replace(/\/$/, "");
|
|
1739
|
+
let model = String(config.model || initialPreset?.model || defaultProvider.model).trim();
|
|
82
1740
|
let apiKey = "";
|
|
83
1741
|
|
|
84
1742
|
if (!baseUrl || !model) {
|
|
@@ -86,6 +1744,28 @@ if (!baseUrl || !model) {
|
|
|
86
1744
|
process.exit(1);
|
|
87
1745
|
}
|
|
88
1746
|
|
|
1747
|
+
const formatProviderLine = (name) => {
|
|
1748
|
+
const preset = getProviderPreset(name);
|
|
1749
|
+
if (!preset) return "";
|
|
1750
|
+
const marker = name === providerName ? "*" : " ";
|
|
1751
|
+
return `${marker} ${name} ${preset.format} ${preset.base_url} ${preset.model}`;
|
|
1752
|
+
};
|
|
1753
|
+
|
|
1754
|
+
const formatProvidersList = () => listProviderNames().map(formatProviderLine).filter(Boolean).join("\n");
|
|
1755
|
+
|
|
1756
|
+
const formatCurrentProvider = () => `current ${providerName} ${providerFormat} ${baseUrl} ${model}`;
|
|
1757
|
+
|
|
1758
|
+
const applyProviderPreset = (name, modelOverride) => {
|
|
1759
|
+
const preset = getProviderPreset(name);
|
|
1760
|
+
if (!preset) throw new Error("provider not found");
|
|
1761
|
+
providerName = normalizeProviderName(name);
|
|
1762
|
+
providerFormat = preset.format;
|
|
1763
|
+
baseUrl = String(preset.base_url || "").trim().replace(/\/$/, "");
|
|
1764
|
+
model = String(modelOverride || preset.model || "").trim();
|
|
1765
|
+
if (!baseUrl || !model) throw new Error("provider config invalid");
|
|
1766
|
+
saveProviderConfig({ provider: providerName, base_url: baseUrl, model, format: providerFormat });
|
|
1767
|
+
};
|
|
1768
|
+
|
|
89
1769
|
const keychainService = "9T-Movevom";
|
|
90
1770
|
const keychainAccount = "default";
|
|
91
1771
|
const isDarwin = process.platform === "darwin";
|
|
@@ -104,14 +1784,14 @@ const execFileAsync = (cmd, args, input) =>
|
|
|
104
1784
|
}
|
|
105
1785
|
});
|
|
106
1786
|
|
|
107
|
-
const getKeychainApiKeyDarwin = async () => {
|
|
1787
|
+
const getKeychainApiKeyDarwin = async (service) => {
|
|
108
1788
|
try {
|
|
109
1789
|
return await execFileAsync("security", [
|
|
110
1790
|
"find-generic-password",
|
|
111
1791
|
"-a",
|
|
112
1792
|
keychainAccount,
|
|
113
1793
|
"-s",
|
|
114
|
-
|
|
1794
|
+
service,
|
|
115
1795
|
"-w"
|
|
116
1796
|
]);
|
|
117
1797
|
} catch {
|
|
@@ -119,25 +1799,25 @@ const getKeychainApiKeyDarwin = async () => {
|
|
|
119
1799
|
}
|
|
120
1800
|
};
|
|
121
1801
|
|
|
122
|
-
const saveKeychainApiKeyDarwin = async (key) => {
|
|
1802
|
+
const saveKeychainApiKeyDarwin = async (service, key) => {
|
|
123
1803
|
await execFileAsync("security", [
|
|
124
1804
|
"add-generic-password",
|
|
125
1805
|
"-a",
|
|
126
1806
|
keychainAccount,
|
|
127
1807
|
"-s",
|
|
128
|
-
|
|
1808
|
+
service,
|
|
129
1809
|
"-w",
|
|
130
1810
|
key,
|
|
131
1811
|
"-U"
|
|
132
1812
|
]);
|
|
133
1813
|
};
|
|
134
1814
|
|
|
135
|
-
const getKeychainApiKeyLinux = async () => {
|
|
1815
|
+
const getKeychainApiKeyLinux = async (service) => {
|
|
136
1816
|
try {
|
|
137
1817
|
return await execFileAsync("secret-tool", [
|
|
138
1818
|
"lookup",
|
|
139
1819
|
"service",
|
|
140
|
-
|
|
1820
|
+
service,
|
|
141
1821
|
"account",
|
|
142
1822
|
keychainAccount
|
|
143
1823
|
]);
|
|
@@ -146,15 +1826,15 @@ const getKeychainApiKeyLinux = async () => {
|
|
|
146
1826
|
}
|
|
147
1827
|
};
|
|
148
1828
|
|
|
149
|
-
const saveKeychainApiKeyLinux = async (key) => {
|
|
1829
|
+
const saveKeychainApiKeyLinux = async (service, key) => {
|
|
150
1830
|
await execFileAsync(
|
|
151
1831
|
"secret-tool",
|
|
152
|
-
["store", "--label",
|
|
1832
|
+
["store", "--label", service, "service", service, "account", keychainAccount],
|
|
153
1833
|
key
|
|
154
1834
|
);
|
|
155
1835
|
};
|
|
156
1836
|
|
|
157
|
-
const getKeychainApiKeyWindows = async () => {
|
|
1837
|
+
const getKeychainApiKeyWindows = async (service) => {
|
|
158
1838
|
const script = `
|
|
159
1839
|
Add-Type -TypeDefinition @"
|
|
160
1840
|
using System;
|
|
@@ -181,7 +1861,7 @@ public class CredMan {
|
|
|
181
1861
|
public static extern bool CredFree(IntPtr credPtr);
|
|
182
1862
|
}
|
|
183
1863
|
"@;
|
|
184
|
-
$target = "${
|
|
1864
|
+
$target = "${service}";
|
|
185
1865
|
$credPtr = [IntPtr]::Zero;
|
|
186
1866
|
if ([CredMan]::CredRead($target, 1, 0, [ref]$credPtr)) {
|
|
187
1867
|
$cred = [Runtime.InteropServices.Marshal]::PtrToStructure($credPtr, [type]::GetType("CredMan+CREDENTIAL"));
|
|
@@ -196,7 +1876,7 @@ if ([CredMan]::CredRead($target, 1, 0, [ref]$credPtr)) {
|
|
|
196
1876
|
}
|
|
197
1877
|
};
|
|
198
1878
|
|
|
199
|
-
const saveKeychainApiKeyWindows = async (key) => {
|
|
1879
|
+
const saveKeychainApiKeyWindows = async (service, key) => {
|
|
200
1880
|
const script = `
|
|
201
1881
|
Add-Type -TypeDefinition @"
|
|
202
1882
|
using System;
|
|
@@ -226,7 +1906,7 @@ $secret = $secret.Trim();
|
|
|
226
1906
|
$bytes = [Text.Encoding]::Unicode.GetBytes($secret);
|
|
227
1907
|
$cred = New-Object CredMan+CREDENTIAL;
|
|
228
1908
|
$cred.Type = 1;
|
|
229
|
-
$cred.TargetName = "${
|
|
1909
|
+
$cred.TargetName = "${service}";
|
|
230
1910
|
$cred.UserName = "${keychainAccount}";
|
|
231
1911
|
$cred.CredentialBlobSize = $bytes.Length;
|
|
232
1912
|
$cred.CredentialBlob = [Runtime.InteropServices.Marshal]::AllocHGlobal($bytes.Length);
|
|
@@ -238,17 +1918,17 @@ $cred.Persist = 2;
|
|
|
238
1918
|
await execFileAsync("powershell", ["-NoProfile", "-Command", script], key);
|
|
239
1919
|
};
|
|
240
1920
|
|
|
241
|
-
const getStoredApiKey = async () => {
|
|
242
|
-
if (isDarwin) return await getKeychainApiKeyDarwin();
|
|
243
|
-
if (isLinux) return await getKeychainApiKeyLinux();
|
|
244
|
-
if (isWindows) return await getKeychainApiKeyWindows();
|
|
1921
|
+
const getStoredApiKey = async (service) => {
|
|
1922
|
+
if (isDarwin) return await getKeychainApiKeyDarwin(service);
|
|
1923
|
+
if (isLinux) return await getKeychainApiKeyLinux(service);
|
|
1924
|
+
if (isWindows) return await getKeychainApiKeyWindows(service);
|
|
245
1925
|
return "";
|
|
246
1926
|
};
|
|
247
1927
|
|
|
248
|
-
const saveStoredApiKey = async (key) => {
|
|
249
|
-
if (isDarwin) return await saveKeychainApiKeyDarwin(key);
|
|
250
|
-
if (isLinux) return await saveKeychainApiKeyLinux(key);
|
|
251
|
-
if (isWindows) return await saveKeychainApiKeyWindows(key);
|
|
1928
|
+
const saveStoredApiKey = async (service, key) => {
|
|
1929
|
+
if (isDarwin) return await saveKeychainApiKeyDarwin(service, key);
|
|
1930
|
+
if (isLinux) return await saveKeychainApiKeyLinux(service, key);
|
|
1931
|
+
if (isWindows) return await saveKeychainApiKeyWindows(service, key);
|
|
252
1932
|
};
|
|
253
1933
|
|
|
254
1934
|
const promptHidden = (label) =>
|
|
@@ -285,17 +1965,36 @@ const promptHidden = (label) =>
|
|
|
285
1965
|
stdin.on("data", onData);
|
|
286
1966
|
});
|
|
287
1967
|
|
|
288
|
-
const
|
|
289
|
-
|
|
1968
|
+
const buildKeychainService = (provider) =>
|
|
1969
|
+
provider ? `${keychainService}-${normalizeProviderName(provider)}` : keychainService;
|
|
1970
|
+
|
|
1971
|
+
const resolveProviderApiKeyEnv = (provider) => {
|
|
1972
|
+
const preset = getProviderPreset(provider);
|
|
1973
|
+
const envName = preset?.api_key_env || "";
|
|
1974
|
+
if (envName) {
|
|
1975
|
+
const value = String(process.env[envName] || "").trim();
|
|
1976
|
+
if (value) return value;
|
|
1977
|
+
}
|
|
1978
|
+
const fallback = String(process.env["9T_API_KEY"] || "").trim();
|
|
1979
|
+
return fallback;
|
|
1980
|
+
};
|
|
1981
|
+
|
|
1982
|
+
const ensureApiKey = async (provider) => {
|
|
1983
|
+
const envKey = resolveProviderApiKeyEnv(provider);
|
|
290
1984
|
if (envKey) return envKey;
|
|
291
|
-
const
|
|
1985
|
+
const service = buildKeychainService(provider);
|
|
1986
|
+
const keychainKey = await getStoredApiKey(service);
|
|
292
1987
|
if (keychainKey) return keychainKey;
|
|
293
|
-
|
|
1988
|
+
if (provider) {
|
|
1989
|
+
const fallback = await getStoredApiKey(keychainService);
|
|
1990
|
+
if (fallback) return fallback;
|
|
1991
|
+
}
|
|
1992
|
+
const input = await promptHidden(`apikey(${provider || "default"}): `);
|
|
294
1993
|
if (!input) {
|
|
295
1994
|
throw new Error("api_key 不能为空");
|
|
296
1995
|
}
|
|
297
1996
|
try {
|
|
298
|
-
await saveStoredApiKey(input);
|
|
1997
|
+
await saveStoredApiKey(service, input);
|
|
299
1998
|
} catch (e) {
|
|
300
1999
|
console.error("无法安全存储 api_key,请安装系统密钥存储后重试");
|
|
301
2000
|
}
|
|
@@ -305,24 +2004,222 @@ const ensureApiKey = async () => {
|
|
|
305
2004
|
const migratePlaintextApiKey = async () => {
|
|
306
2005
|
const fromFile = String(config.api_key || "").trim();
|
|
307
2006
|
if (!fromFile) return;
|
|
308
|
-
|
|
2007
|
+
const service = buildKeychainService(providerName);
|
|
2008
|
+
await saveStoredApiKey(service, fromFile);
|
|
309
2009
|
delete config.api_key;
|
|
310
2010
|
saveProviderConfig(config);
|
|
311
2011
|
};
|
|
312
2012
|
|
|
313
|
-
|
|
314
|
-
|
|
2013
|
+
saveProviderConfig({ provider: providerName, base_url: baseUrl, model, format: providerFormat });
|
|
2014
|
+
|
|
2015
|
+
const normalizeTargetForCheck = (full) => {
|
|
2016
|
+
if (existsSync(full)) return realpathSync(full);
|
|
2017
|
+
const parent = dirname(full);
|
|
2018
|
+
if (existsSync(parent)) {
|
|
2019
|
+
const parentReal = realpathSync(parent);
|
|
2020
|
+
return join(parentReal, full.slice(parent.length + 1));
|
|
2021
|
+
}
|
|
2022
|
+
return resolve(full);
|
|
2023
|
+
};
|
|
2024
|
+
|
|
2025
|
+
const normalizeForCompare = (value) => {
|
|
2026
|
+
const v = String(value || "");
|
|
2027
|
+
return process.platform === "win32" ? v.toLowerCase() : v;
|
|
2028
|
+
};
|
|
2029
|
+
|
|
2030
|
+
const isWithinRoot = (target, root) => {
|
|
2031
|
+
const t = normalizeForCompare(target);
|
|
2032
|
+
const r = normalizeForCompare(root);
|
|
2033
|
+
return t === r || t.startsWith(r + sep);
|
|
2034
|
+
};
|
|
2035
|
+
|
|
2036
|
+
const isDeniedByDir = (normalized) => {
|
|
2037
|
+
if (denyDirsAbs.some((d) => isWithinRoot(normalized, d))) return true;
|
|
2038
|
+
if (!denyDirSegments.length) return false;
|
|
2039
|
+
const segments = normalizeForCompare(normalized).split(/[\\/]+/);
|
|
2040
|
+
return segments.some((seg) => denyDirSegments.includes(seg));
|
|
2041
|
+
};
|
|
2042
|
+
|
|
2043
|
+
const isDeniedByFile = (full) => {
|
|
2044
|
+
const name = normalizeForCompare(basename(full));
|
|
2045
|
+
if (denyFileNames.includes(name)) return true;
|
|
2046
|
+
const ext = normalizeForCompare(extname(full));
|
|
2047
|
+
if (denyFileExts.includes(ext)) return true;
|
|
2048
|
+
return false;
|
|
2049
|
+
};
|
|
315
2050
|
|
|
316
2051
|
const resolveWorkspacePath = (p) => {
|
|
317
2052
|
if (!p || typeof p !== "string") throw new Error("path required");
|
|
318
|
-
|
|
319
|
-
const
|
|
320
|
-
if (
|
|
2053
|
+
const full = isAbsolute(p) ? resolve(p) : resolve(workspaceRoot, p);
|
|
2054
|
+
const normalized = normalizeTargetForCheck(full);
|
|
2055
|
+
if (isDeniedByDir(normalized) || isDeniedByFile(full)) {
|
|
2056
|
+
throw new Error("path denied");
|
|
2057
|
+
}
|
|
2058
|
+
if (!allowedRoots.some((root) => isWithinRoot(normalized, root))) {
|
|
321
2059
|
throw new Error("path escape not allowed");
|
|
322
2060
|
}
|
|
323
2061
|
return full;
|
|
324
2062
|
};
|
|
325
2063
|
|
|
2064
|
+
const confirmRiskAction = (label) =>
|
|
2065
|
+
new Promise((resolvePromise) => {
|
|
2066
|
+
if (!process.stdin.isTTY) return resolvePromise(false);
|
|
2067
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
2068
|
+
rl.question(label, (answer) => {
|
|
2069
|
+
rl.close();
|
|
2070
|
+
resolvePromise(answer.trim().toLowerCase() === "y");
|
|
2071
|
+
});
|
|
2072
|
+
});
|
|
2073
|
+
|
|
2074
|
+
const ensureRiskAllowed = async (toolName, target) => {
|
|
2075
|
+
if (!allowedRiskSet.has(toolName)) return;
|
|
2076
|
+
if (allowRiskUntil[toolName] && allowRiskUntil[toolName] > Date.now()) return;
|
|
2077
|
+
if (accessMode === "open") return;
|
|
2078
|
+
if (allowRisk.has(toolName)) return;
|
|
2079
|
+
if (accessMode === "strict") throw new Error("risk tool not allowed in strict mode");
|
|
2080
|
+
const label = target ? `确认执行 ${toolName} ${target} ? (y/N) ` : `确认执行 ${toolName} ? (y/N) `;
|
|
2081
|
+
const ok = await confirmRiskAction(label);
|
|
2082
|
+
if (!ok) throw new Error("risk tool denied");
|
|
2083
|
+
if (allowRiskTtlHours > 0) {
|
|
2084
|
+
const next = Date.now() + allowRiskTtlHours * 60 * 60 * 1000;
|
|
2085
|
+
allowRiskUntil = { ...allowRiskUntil, [toolName]: next };
|
|
2086
|
+
saveAccessConfig({ allow_risk_until: allowRiskUntil, allow_risk_ttl_hours: allowRiskTtlHours });
|
|
2087
|
+
}
|
|
2088
|
+
};
|
|
2089
|
+
|
|
2090
|
+
const getCommandName = (cmd) => {
|
|
2091
|
+
const match = String(cmd || "").trim().match(/^([^\s]+)/);
|
|
2092
|
+
return match ? match[1] : "";
|
|
2093
|
+
};
|
|
2094
|
+
|
|
2095
|
+
const hasShellOperators = (cmd) => /[;&|]/.test(String(cmd || ""));
|
|
2096
|
+
|
|
2097
|
+
const ensureExecuteAllowed = (cmd) => {
|
|
2098
|
+
if (!cmd) throw new Error("cmd required");
|
|
2099
|
+
if (hasShellOperators(cmd)) throw new Error("command chaining not allowed");
|
|
2100
|
+
const name = normalizeForCompare(basename(getCommandName(cmd)));
|
|
2101
|
+
if (!name) throw new Error("command not allowed");
|
|
2102
|
+
const list = executeAllowlist.length ? executeAllowlist : defaultExecuteAllowlist;
|
|
2103
|
+
if (!list.includes(name)) throw new Error("command not allowed");
|
|
2104
|
+
};
|
|
2105
|
+
|
|
2106
|
+
const isUrlAllowed = (target) => {
|
|
2107
|
+
const list = openUrlAllowlist.length ? openUrlAllowlist : defaultOpenUrlAllowlist;
|
|
2108
|
+
if (list.includes("*")) return true;
|
|
2109
|
+
try {
|
|
2110
|
+
const url = new URL(target);
|
|
2111
|
+
const host = normalizeForCompare(url.hostname);
|
|
2112
|
+
return list.some((item) => host === item || host.endsWith(`.${item}`));
|
|
2113
|
+
} catch {
|
|
2114
|
+
return false;
|
|
2115
|
+
}
|
|
2116
|
+
};
|
|
2117
|
+
|
|
2118
|
+
const isOpenFileAllowed = (fullPath) => {
|
|
2119
|
+
const list = openFileAllowlist.length ? openFileAllowlist : defaultOpenFileAllowlist;
|
|
2120
|
+
if (!list.length) return true;
|
|
2121
|
+
if (list.includes("*")) return true;
|
|
2122
|
+
try {
|
|
2123
|
+
const stat = statSync(fullPath);
|
|
2124
|
+
if (stat.isDirectory()) return true;
|
|
2125
|
+
} catch {}
|
|
2126
|
+
const ext = normalizeExtList(extname(fullPath))[0] || "";
|
|
2127
|
+
if (!ext) return false;
|
|
2128
|
+
return list.includes(ext);
|
|
2129
|
+
};
|
|
2130
|
+
|
|
2131
|
+
const formatOutputPath = (fullPath, baseRoot) => {
|
|
2132
|
+
if (fullPath === baseRoot) return ".";
|
|
2133
|
+
if (fullPath.startsWith(baseRoot + sep)) return fullPath.slice(baseRoot.length + 1);
|
|
2134
|
+
if (fullPath === workspaceRoot) return ".";
|
|
2135
|
+
if (fullPath.startsWith(workspaceRoot + sep)) return fullPath.slice(workspaceRoot.length + 1);
|
|
2136
|
+
return fullPath;
|
|
2137
|
+
};
|
|
2138
|
+
|
|
2139
|
+
const normalizeWebText = (text) => String(text || "").replace(/\r\n/g, "\n");
|
|
2140
|
+
|
|
2141
|
+
const stripHtml = (html) => {
|
|
2142
|
+
let text = String(html || "");
|
|
2143
|
+
text = text.replace(/<script[\s\S]*?<\/script>/gi, "");
|
|
2144
|
+
text = text.replace(/<style[\s\S]*?<\/style>/gi, "");
|
|
2145
|
+
text = text.replace(/<\/p>/gi, "\n");
|
|
2146
|
+
text = text.replace(/<br\s*\/?>/gi, "\n");
|
|
2147
|
+
text = text.replace(/<\/h[1-6]>/gi, "\n");
|
|
2148
|
+
text = text.replace(/<li[^>]*>/gi, "- ");
|
|
2149
|
+
text = text.replace(/<\/li>/gi, "\n");
|
|
2150
|
+
text = text.replace(/<\/(div|section|article|header|footer|main|ul|ol)>/gi, "\n");
|
|
2151
|
+
text = text.replace(/<[^>]+>/g, "");
|
|
2152
|
+
text = text.replace(/ /gi, " ");
|
|
2153
|
+
text = text.replace(/&/gi, "&");
|
|
2154
|
+
text = text.replace(/</gi, "<");
|
|
2155
|
+
text = text.replace(/>/gi, ">");
|
|
2156
|
+
return normalizeWebText(text).replace(/\n{3,}/g, "\n\n").trim();
|
|
2157
|
+
};
|
|
2158
|
+
|
|
2159
|
+
const fetchWithTimeout = async (url, init, timeoutMs) => {
|
|
2160
|
+
const controller = new AbortController();
|
|
2161
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
2162
|
+
try {
|
|
2163
|
+
return await fetch(url, { ...init, signal: controller.signal });
|
|
2164
|
+
} finally {
|
|
2165
|
+
clearTimeout(timeout);
|
|
2166
|
+
}
|
|
2167
|
+
};
|
|
2168
|
+
|
|
2169
|
+
const buildSimpleDiff = (beforeText, afterText) => {
|
|
2170
|
+
const beforeLines = normalizeWebText(beforeText).split("\n");
|
|
2171
|
+
const afterLines = normalizeWebText(afterText).split("\n");
|
|
2172
|
+
let start = 0;
|
|
2173
|
+
while (start < beforeLines.length && start < afterLines.length) {
|
|
2174
|
+
if (beforeLines[start] !== afterLines[start]) break;
|
|
2175
|
+
start += 1;
|
|
2176
|
+
}
|
|
2177
|
+
let endBefore = beforeLines.length - 1;
|
|
2178
|
+
let endAfter = afterLines.length - 1;
|
|
2179
|
+
while (endBefore >= start && endAfter >= start) {
|
|
2180
|
+
if (beforeLines[endBefore] !== afterLines[endAfter]) break;
|
|
2181
|
+
endBefore -= 1;
|
|
2182
|
+
endAfter -= 1;
|
|
2183
|
+
}
|
|
2184
|
+
const removed = beforeLines.slice(start, endBefore + 1);
|
|
2185
|
+
const added = afterLines.slice(start, endAfter + 1);
|
|
2186
|
+
if (!removed.length && !added.length) {
|
|
2187
|
+
return { diff: "", identical: true };
|
|
2188
|
+
}
|
|
2189
|
+
const header = `@@ -${start + 1},${removed.length} +${start + 1},${added.length} @@`;
|
|
2190
|
+
const body = [
|
|
2191
|
+
header,
|
|
2192
|
+
...removed.map((line) => `-${line}`),
|
|
2193
|
+
...added.map((line) => `+${line}`)
|
|
2194
|
+
].join("\n");
|
|
2195
|
+
return { diff: body, identical: false };
|
|
2196
|
+
};
|
|
2197
|
+
|
|
2198
|
+
const applySimplePatch = (fileText, diffText) => {
|
|
2199
|
+
const lines = normalizeWebText(fileText).split("\n");
|
|
2200
|
+
const parts = normalizeWebText(diffText).split("\n");
|
|
2201
|
+
const hunkIndex = parts.findIndex((line) => line.startsWith("@@"));
|
|
2202
|
+
if (hunkIndex === -1) throw new Error("diff hunk missing");
|
|
2203
|
+
const hunkHeader = parts[hunkIndex];
|
|
2204
|
+
const match = hunkHeader.match(/@@\s*-(\d+),(\d+)\s+\+(\d+),(\d+)\s*@@/);
|
|
2205
|
+
if (!match) throw new Error("diff header invalid");
|
|
2206
|
+
const start = Number(match[1]) - 1;
|
|
2207
|
+
const removeCount = Number(match[2]);
|
|
2208
|
+
const hunkLines = parts.slice(hunkIndex + 1);
|
|
2209
|
+
const removed = [];
|
|
2210
|
+
const added = [];
|
|
2211
|
+
for (const line of hunkLines) {
|
|
2212
|
+
if (line.startsWith("-")) removed.push(line.slice(1));
|
|
2213
|
+
else if (line.startsWith("+")) added.push(line.slice(1));
|
|
2214
|
+
}
|
|
2215
|
+
const current = lines.slice(start, start + removeCount);
|
|
2216
|
+
if (removeCount && current.join("\n") !== removed.join("\n")) {
|
|
2217
|
+
throw new Error("patch mismatch");
|
|
2218
|
+
}
|
|
2219
|
+
const next = [...lines.slice(0, start), ...added, ...lines.slice(start + removeCount)];
|
|
2220
|
+
return normalizeWebText(next.join("\n"));
|
|
2221
|
+
};
|
|
2222
|
+
|
|
326
2223
|
const toolRead = (args) => {
|
|
327
2224
|
const filePath = resolveWorkspacePath(args.path);
|
|
328
2225
|
const content = readFileSync(filePath, "utf-8");
|
|
@@ -358,12 +2255,63 @@ const toolCreate = (args) => {
|
|
|
358
2255
|
return { ok: true, type };
|
|
359
2256
|
};
|
|
360
2257
|
|
|
361
|
-
const toolDelete = (args) => {
|
|
2258
|
+
const toolDelete = async (args) => {
|
|
2259
|
+
await ensureRiskAllowed("delete", args.path);
|
|
362
2260
|
const target = resolveWorkspacePath(args.path);
|
|
363
2261
|
rmSync(target, { recursive: true, force: true });
|
|
364
2262
|
return { ok: true };
|
|
365
2263
|
};
|
|
366
2264
|
|
|
2265
|
+
const toolMove = (args) => {
|
|
2266
|
+
const fromRaw = args?.from || args?.src || args?.path;
|
|
2267
|
+
const toRaw = args?.to || args?.dest || args?.target;
|
|
2268
|
+
if (!fromRaw || !toRaw) throw new Error("from/to required");
|
|
2269
|
+
const fromPath = resolveWorkspacePath(String(fromRaw));
|
|
2270
|
+
const toPath = resolveWorkspacePath(String(toRaw));
|
|
2271
|
+
mkdirSync(dirname(toPath), { recursive: true });
|
|
2272
|
+
renameSync(fromPath, toPath);
|
|
2273
|
+
return {
|
|
2274
|
+
ok: true,
|
|
2275
|
+
from: formatOutputPath(fromPath, workspaceRoot),
|
|
2276
|
+
to: formatOutputPath(toPath, workspaceRoot)
|
|
2277
|
+
};
|
|
2278
|
+
};
|
|
2279
|
+
|
|
2280
|
+
const toolStat = (args) => {
|
|
2281
|
+
const target = resolveWorkspacePath(args.path);
|
|
2282
|
+
const st = statSync(target);
|
|
2283
|
+
return {
|
|
2284
|
+
path: formatOutputPath(target, workspaceRoot),
|
|
2285
|
+
type: st.isDirectory() ? "dir" : "file",
|
|
2286
|
+
size: st.size,
|
|
2287
|
+
mtime: st.mtime.toISOString(),
|
|
2288
|
+
ctime: st.ctime.toISOString()
|
|
2289
|
+
};
|
|
2290
|
+
};
|
|
2291
|
+
|
|
2292
|
+
const toolDiff = (args) => {
|
|
2293
|
+
const pathA = resolveWorkspacePath(args.path_a || args.a || args.from);
|
|
2294
|
+
const pathB = resolveWorkspacePath(args.path_b || args.b || args.to);
|
|
2295
|
+
const beforeText = readFileSync(pathA, "utf-8");
|
|
2296
|
+
const afterText = readFileSync(pathB, "utf-8");
|
|
2297
|
+
const result = buildSimpleDiff(beforeText, afterText);
|
|
2298
|
+
return {
|
|
2299
|
+
path_a: formatOutputPath(pathA, workspaceRoot),
|
|
2300
|
+
path_b: formatOutputPath(pathB, workspaceRoot),
|
|
2301
|
+
...result
|
|
2302
|
+
};
|
|
2303
|
+
};
|
|
2304
|
+
|
|
2305
|
+
const toolPatch = (args) => {
|
|
2306
|
+
const target = resolveWorkspacePath(args.path);
|
|
2307
|
+
const diff = String(args.diff || "");
|
|
2308
|
+
if (!diff) throw new Error("diff required");
|
|
2309
|
+
const current = readFileSync(target, "utf-8");
|
|
2310
|
+
const next = applySimplePatch(current, diff);
|
|
2311
|
+
writeFileSync(target, next);
|
|
2312
|
+
return { ok: true, path: formatOutputPath(target, workspaceRoot) };
|
|
2313
|
+
};
|
|
2314
|
+
|
|
367
2315
|
const toolLs = (args) => {
|
|
368
2316
|
const target = resolveWorkspacePath(args?.path || ".");
|
|
369
2317
|
const entries = readdirSync(target, { withFileTypes: true }).map((e) => {
|
|
@@ -378,107 +2326,918 @@ const toolLs = (args) => {
|
|
|
378
2326
|
return { entries };
|
|
379
2327
|
};
|
|
380
2328
|
|
|
381
|
-
const walkFiles = (dir, out) => {
|
|
382
|
-
const items = readdirSync(dir, { withFileTypes: true });
|
|
383
|
-
for (const item of items) {
|
|
384
|
-
const full = join(dir, item.name);
|
|
385
|
-
if (item.isDirectory()) {
|
|
386
|
-
walkFiles(full, out);
|
|
387
|
-
} else {
|
|
388
|
-
out.push(full);
|
|
389
|
-
}
|
|
2329
|
+
const walkFiles = (dir, out) => {
|
|
2330
|
+
const items = readdirSync(dir, { withFileTypes: true });
|
|
2331
|
+
for (const item of items) {
|
|
2332
|
+
const full = join(dir, item.name);
|
|
2333
|
+
if (item.isDirectory()) {
|
|
2334
|
+
walkFiles(full, out);
|
|
2335
|
+
} else {
|
|
2336
|
+
out.push(full);
|
|
2337
|
+
}
|
|
2338
|
+
}
|
|
2339
|
+
};
|
|
2340
|
+
|
|
2341
|
+
const toolGrep = (args) => {
|
|
2342
|
+
const base = resolveWorkspacePath(args?.path || ".");
|
|
2343
|
+
const pattern = String(args.pattern || "");
|
|
2344
|
+
if (!pattern) throw new Error("pattern required");
|
|
2345
|
+
let regex;
|
|
2346
|
+
try {
|
|
2347
|
+
regex = new RegExp(pattern, "g");
|
|
2348
|
+
} catch {
|
|
2349
|
+
const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2350
|
+
regex = new RegExp(escaped, "g");
|
|
2351
|
+
}
|
|
2352
|
+
const files = [];
|
|
2353
|
+
walkFiles(base, files);
|
|
2354
|
+
const matches = [];
|
|
2355
|
+
for (const f of files) {
|
|
2356
|
+
const content = readFileSync(f, "utf-8");
|
|
2357
|
+
const lines = content.split(/\r?\n/);
|
|
2358
|
+
lines.forEach((line, i) => {
|
|
2359
|
+
if (regex.test(line)) {
|
|
2360
|
+
matches.push({ file: formatOutputPath(f, base), line: i + 1, text: line });
|
|
2361
|
+
}
|
|
2362
|
+
regex.lastIndex = 0;
|
|
2363
|
+
});
|
|
2364
|
+
}
|
|
2365
|
+
return { matches };
|
|
2366
|
+
};
|
|
2367
|
+
|
|
2368
|
+
const globToRegex = (pattern) => {
|
|
2369
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
2370
|
+
const regex = escaped
|
|
2371
|
+
.replace(/\*\*/g, "###DOUBLESTAR###")
|
|
2372
|
+
.replace(/\*/g, "[^/]*")
|
|
2373
|
+
.replace(/\?/g, ".");
|
|
2374
|
+
const withDouble = regex.replace(/###DOUBLESTAR###/g, ".*");
|
|
2375
|
+
return new RegExp("^" + withDouble + "$");
|
|
2376
|
+
};
|
|
2377
|
+
|
|
2378
|
+
const toolGlob = (args) => {
|
|
2379
|
+
const base = resolveWorkspacePath(args?.path || ".");
|
|
2380
|
+
const pattern = String(args.pattern || "");
|
|
2381
|
+
if (!pattern) throw new Error("pattern required");
|
|
2382
|
+
const files = [];
|
|
2383
|
+
walkFiles(base, files);
|
|
2384
|
+
const regex = globToRegex(pattern);
|
|
2385
|
+
const matches = files
|
|
2386
|
+
.map((f) => formatOutputPath(f, base))
|
|
2387
|
+
.filter((p) => regex.test(p));
|
|
2388
|
+
return { matches };
|
|
2389
|
+
};
|
|
2390
|
+
|
|
2391
|
+
const runPtyExecute = async ({ cmd, cwd, timeoutMs, maxOutputChars }) => {
|
|
2392
|
+
const spawn = await loadPtySpawn();
|
|
2393
|
+
if (!spawn) throw createToolError("EXEC_FAILED", "pty unavailable");
|
|
2394
|
+
const { file, args } = resolvePtyCommand(cmd);
|
|
2395
|
+
return new Promise((resolvePromise, reject) => {
|
|
2396
|
+
let stdout = "";
|
|
2397
|
+
let stdoutLen = 0;
|
|
2398
|
+
let truncated = false;
|
|
2399
|
+
let timedOut = false;
|
|
2400
|
+
let finished = false;
|
|
2401
|
+
let timeoutId;
|
|
2402
|
+
let pty;
|
|
2403
|
+
try {
|
|
2404
|
+
pty = spawn(file, args, {
|
|
2405
|
+
cwd,
|
|
2406
|
+
env: process.env,
|
|
2407
|
+
name: process.env.TERM || "xterm-256color",
|
|
2408
|
+
cols: 120,
|
|
2409
|
+
rows: 30
|
|
2410
|
+
});
|
|
2411
|
+
} catch (e) {
|
|
2412
|
+
return reject(
|
|
2413
|
+
createToolError("EXEC_FAILED", "pty spawn failed", {
|
|
2414
|
+
exit_code: 1,
|
|
2415
|
+
stdout: "",
|
|
2416
|
+
stderr: String(e?.message || e),
|
|
2417
|
+
truncated: false
|
|
2418
|
+
})
|
|
2419
|
+
);
|
|
2420
|
+
}
|
|
2421
|
+
pty.onData((chunk) => {
|
|
2422
|
+
const text = String(chunk || "");
|
|
2423
|
+
if (!text) return;
|
|
2424
|
+
stdoutLen += text.length;
|
|
2425
|
+
stdout = keepTail(stdout, text, maxOutputChars);
|
|
2426
|
+
if (stdoutLen > maxOutputChars) truncated = true;
|
|
2427
|
+
});
|
|
2428
|
+
timeoutId = setTimeout(() => {
|
|
2429
|
+
timedOut = true;
|
|
2430
|
+
try {
|
|
2431
|
+
pty.kill("SIGTERM");
|
|
2432
|
+
} catch {}
|
|
2433
|
+
setTimeout(() => {
|
|
2434
|
+
try {
|
|
2435
|
+
pty.kill("SIGKILL");
|
|
2436
|
+
} catch {}
|
|
2437
|
+
}, 2000);
|
|
2438
|
+
}, timeoutMs);
|
|
2439
|
+
pty.onExit((event) => {
|
|
2440
|
+
if (finished) return;
|
|
2441
|
+
finished = true;
|
|
2442
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
2443
|
+
if (timedOut) {
|
|
2444
|
+
return reject(
|
|
2445
|
+
createToolError("EXEC_TIMEOUT", "execute timeout", {
|
|
2446
|
+
stdout,
|
|
2447
|
+
stderr: "",
|
|
2448
|
+
truncated,
|
|
2449
|
+
timed_out: true
|
|
2450
|
+
})
|
|
2451
|
+
);
|
|
2452
|
+
}
|
|
2453
|
+
const exitCode = typeof event?.exitCode === "number" ? event.exitCode : 1;
|
|
2454
|
+
if (exitCode === 0) {
|
|
2455
|
+
return resolvePromise({
|
|
2456
|
+
code: 0,
|
|
2457
|
+
stdout,
|
|
2458
|
+
stderr: "",
|
|
2459
|
+
truncated,
|
|
2460
|
+
timed_out: false
|
|
2461
|
+
});
|
|
2462
|
+
}
|
|
2463
|
+
return reject(
|
|
2464
|
+
createToolError("EXEC_FAILED", "execute failed", {
|
|
2465
|
+
exit_code: exitCode || 1,
|
|
2466
|
+
stdout,
|
|
2467
|
+
stderr: "",
|
|
2468
|
+
truncated
|
|
2469
|
+
})
|
|
2470
|
+
);
|
|
2471
|
+
});
|
|
2472
|
+
});
|
|
2473
|
+
};
|
|
2474
|
+
|
|
2475
|
+
const spawnPtyDetached = async ({ cmd, cwd, session, maxOutputChars }) => {
|
|
2476
|
+
const spawn = await loadPtySpawn();
|
|
2477
|
+
if (!spawn) throw createToolError("EXEC_FAILED", "pty unavailable");
|
|
2478
|
+
const { file, args } = resolvePtyCommand(cmd);
|
|
2479
|
+
let pty;
|
|
2480
|
+
try {
|
|
2481
|
+
pty = spawn(file, args, {
|
|
2482
|
+
cwd,
|
|
2483
|
+
env: process.env,
|
|
2484
|
+
name: process.env.TERM || "xterm-256color",
|
|
2485
|
+
cols: 120,
|
|
2486
|
+
rows: 30
|
|
2487
|
+
});
|
|
2488
|
+
} catch (e) {
|
|
2489
|
+
throw createToolError("EXEC_FAILED", "pty spawn failed", {
|
|
2490
|
+
exit_code: 1,
|
|
2491
|
+
stdout: "",
|
|
2492
|
+
stderr: String(e?.message || e),
|
|
2493
|
+
truncated: false
|
|
2494
|
+
});
|
|
2495
|
+
}
|
|
2496
|
+
session.is_pty = true;
|
|
2497
|
+
session.pty = pty;
|
|
2498
|
+
session.pid = pty.pid || null;
|
|
2499
|
+
pty.onData((chunk) => {
|
|
2500
|
+
const text = String(chunk || "");
|
|
2501
|
+
if (!text) return;
|
|
2502
|
+
session.stdout_len += text.length;
|
|
2503
|
+
session.stdout_tail = keepTail(session.stdout_tail, text, maxOutputChars);
|
|
2504
|
+
session.stdout_truncated = session.stdout_len > maxOutputChars;
|
|
2505
|
+
appendProcessLog(session.log_path, { ts: nowIso(), stream: "stdout", text });
|
|
2506
|
+
});
|
|
2507
|
+
pty.onExit((event) => {
|
|
2508
|
+
const exitCode = typeof event?.exitCode === "number" ? event.exitCode : null;
|
|
2509
|
+
const status =
|
|
2510
|
+
session.kill_requested ? "killed" : exitCode === 0 && !event?.signal ? "completed" : "failed";
|
|
2511
|
+
finalizeProcessSession(session, status, exitCode, event?.signal || "", "");
|
|
2512
|
+
});
|
|
2513
|
+
return pty;
|
|
2514
|
+
};
|
|
2515
|
+
|
|
2516
|
+
const toolExecute = async (args) => {
|
|
2517
|
+
const cmd = String(args.cmd || "");
|
|
2518
|
+
if (!cmd) throw createToolError("INVALID_ARGS", "cmd required");
|
|
2519
|
+
ensureExecuteAllowed(cmd);
|
|
2520
|
+
await ensureRiskAllowed("execute", args.cmd);
|
|
2521
|
+
const timeoutMs = clampNumber(
|
|
2522
|
+
args?.timeout_ms,
|
|
2523
|
+
1000,
|
|
2524
|
+
MAX_EXEC_TIMEOUT_MS,
|
|
2525
|
+
DEFAULT_EXEC_TIMEOUT_MS
|
|
2526
|
+
);
|
|
2527
|
+
const maxOutputChars = clampNumber(
|
|
2528
|
+
args?.max_output_chars,
|
|
2529
|
+
1000,
|
|
2530
|
+
MAX_EXEC_OUTPUT_CHARS,
|
|
2531
|
+
DEFAULT_EXEC_MAX_OUTPUT_CHARS
|
|
2532
|
+
);
|
|
2533
|
+
const cwd = args.cwd ? resolveWorkspacePath(args.cwd) : workspaceRoot;
|
|
2534
|
+
const roots = allowedRoots.length ? allowedRoots : [workspaceRoot];
|
|
2535
|
+
let execCmd = cmd;
|
|
2536
|
+
try {
|
|
2537
|
+
execCmd = buildSandboxedCommand(cmd, cwd, roots);
|
|
2538
|
+
} catch (e) {
|
|
2539
|
+
throw createToolError("EXEC_DENIED", String(e?.message || e));
|
|
2540
|
+
}
|
|
2541
|
+
const usePty = Boolean(args?.pty);
|
|
2542
|
+
if (usePty) {
|
|
2543
|
+
return runPtyExecute({ cmd: execCmd, cwd, timeoutMs, maxOutputChars });
|
|
2544
|
+
}
|
|
2545
|
+
return new Promise((resolvePromise, reject) => {
|
|
2546
|
+
exec(
|
|
2547
|
+
execCmd,
|
|
2548
|
+
{ cwd, maxBuffer: EXEC_MAX_BUFFER_BYTES, timeout: timeoutMs },
|
|
2549
|
+
(err, stdout, stderr) => {
|
|
2550
|
+
const out = truncateText(stdout || "", maxOutputChars);
|
|
2551
|
+
const errOut = truncateText(stderr || "", maxOutputChars);
|
|
2552
|
+
const truncated = out.truncated || errOut.truncated;
|
|
2553
|
+
if (err) {
|
|
2554
|
+
const rawMessage = String(err?.message || "");
|
|
2555
|
+
const isTimeout =
|
|
2556
|
+
err?.killed ||
|
|
2557
|
+
err?.signal === "SIGTERM" ||
|
|
2558
|
+
rawMessage.toLowerCase().includes("timed out");
|
|
2559
|
+
if (isTimeout) {
|
|
2560
|
+
return reject(
|
|
2561
|
+
createToolError("EXEC_TIMEOUT", "execute timeout", {
|
|
2562
|
+
stdout: out.text,
|
|
2563
|
+
stderr: errOut.text,
|
|
2564
|
+
truncated: truncated,
|
|
2565
|
+
timed_out: true
|
|
2566
|
+
})
|
|
2567
|
+
);
|
|
2568
|
+
}
|
|
2569
|
+
if (err?.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") {
|
|
2570
|
+
return reject(
|
|
2571
|
+
createToolError("EXEC_OUTPUT_LIMIT", "execute output limit", {
|
|
2572
|
+
stdout: out.text,
|
|
2573
|
+
stderr: errOut.text,
|
|
2574
|
+
truncated: true
|
|
2575
|
+
})
|
|
2576
|
+
);
|
|
2577
|
+
}
|
|
2578
|
+
return reject(
|
|
2579
|
+
createToolError("EXEC_FAILED", "execute failed", {
|
|
2580
|
+
exit_code: err?.code || 1,
|
|
2581
|
+
stdout: out.text,
|
|
2582
|
+
stderr: errOut.text,
|
|
2583
|
+
truncated: truncated
|
|
2584
|
+
})
|
|
2585
|
+
);
|
|
2586
|
+
}
|
|
2587
|
+
resolvePromise({
|
|
2588
|
+
code: 0,
|
|
2589
|
+
stdout: out.text,
|
|
2590
|
+
stderr: errOut.text,
|
|
2591
|
+
truncated: truncated,
|
|
2592
|
+
timed_out: false
|
|
2593
|
+
});
|
|
2594
|
+
}
|
|
2595
|
+
);
|
|
2596
|
+
});
|
|
2597
|
+
};
|
|
2598
|
+
|
|
2599
|
+
const toolExecuteDetached = async (args) => {
|
|
2600
|
+
const cmd = String(args.cmd || "");
|
|
2601
|
+
if (!cmd) throw createToolError("INVALID_ARGS", "cmd required");
|
|
2602
|
+
const logPathRaw = String(args.log_path || "");
|
|
2603
|
+
const outputPathRaw = String(args.output_path || "");
|
|
2604
|
+
if (!logPathRaw) throw createToolError("INVALID_ARGS", "log_path required");
|
|
2605
|
+
if (!outputPathRaw) throw createToolError("INVALID_ARGS", "output_path required");
|
|
2606
|
+
ensureExecuteAllowed(cmd);
|
|
2607
|
+
await ensureRiskAllowed("execute", args.cmd);
|
|
2608
|
+
const cwd = args.cwd ? resolveWorkspacePath(args.cwd) : workspaceRoot;
|
|
2609
|
+
const logPath = resolveWorkspacePath(logPathRaw);
|
|
2610
|
+
const outputPath = resolveWorkspacePath(outputPathRaw);
|
|
2611
|
+
const roots = allowedRoots.length ? allowedRoots : [workspaceRoot];
|
|
2612
|
+
const taskId = args.task_id ? String(args.task_id) : "";
|
|
2613
|
+
const maxOutputChars = clampNumber(
|
|
2614
|
+
args?.max_output_chars,
|
|
2615
|
+
1000,
|
|
2616
|
+
MAX_EXEC_OUTPUT_CHARS,
|
|
2617
|
+
DEFAULT_EXEC_MAX_OUTPUT_CHARS
|
|
2618
|
+
);
|
|
2619
|
+
let execCmd = cmd;
|
|
2620
|
+
try {
|
|
2621
|
+
execCmd = buildSandboxedCommand(cmd, cwd, roots);
|
|
2622
|
+
} catch (e) {
|
|
2623
|
+
throw createToolError("EXEC_DENIED", String(e?.message || e));
|
|
2624
|
+
}
|
|
2625
|
+
const session_id = nextProcessId();
|
|
2626
|
+
const started_at_ms = Date.now();
|
|
2627
|
+
const session = {
|
|
2628
|
+
session_id,
|
|
2629
|
+
cmd,
|
|
2630
|
+
cwd,
|
|
2631
|
+
task_id: taskId || null,
|
|
2632
|
+
log_path: logPath,
|
|
2633
|
+
output_path: outputPath,
|
|
2634
|
+
status: "running",
|
|
2635
|
+
pid: null,
|
|
2636
|
+
is_pty: false,
|
|
2637
|
+
started_at: nowIso(),
|
|
2638
|
+
started_at_ms,
|
|
2639
|
+
ended_at: "",
|
|
2640
|
+
ended_at_ms: 0,
|
|
2641
|
+
exit_code: null,
|
|
2642
|
+
signal: "",
|
|
2643
|
+
stdout_tail: "",
|
|
2644
|
+
stderr_tail: "",
|
|
2645
|
+
stdout_len: 0,
|
|
2646
|
+
stderr_len: 0,
|
|
2647
|
+
stdout_truncated: false,
|
|
2648
|
+
stderr_truncated: false,
|
|
2649
|
+
max_output_chars: maxOutputChars,
|
|
2650
|
+
removed: false,
|
|
2651
|
+
kill_requested: false,
|
|
2652
|
+
child: null,
|
|
2653
|
+
pty: null
|
|
2654
|
+
};
|
|
2655
|
+
processRegistry.set(session_id, session);
|
|
2656
|
+
appendProcessLog(logPath, {
|
|
2657
|
+
ts: session.started_at,
|
|
2658
|
+
stream: "meta",
|
|
2659
|
+
event: "start",
|
|
2660
|
+
session_id,
|
|
2661
|
+
cmd,
|
|
2662
|
+
cwd,
|
|
2663
|
+
task_id: session.task_id
|
|
2664
|
+
});
|
|
2665
|
+
writeProcessOutput(session);
|
|
2666
|
+
if (taskId) {
|
|
2667
|
+
updateTaskForProcess(taskId, {
|
|
2668
|
+
status: "running",
|
|
2669
|
+
inputs: { cmd, cwd },
|
|
2670
|
+
outputs: {
|
|
2671
|
+
session_id,
|
|
2672
|
+
log_path: logPath,
|
|
2673
|
+
output_path: outputPath,
|
|
2674
|
+
status: session.status
|
|
2675
|
+
}
|
|
2676
|
+
});
|
|
2677
|
+
}
|
|
2678
|
+
const usePty = Boolean(args?.pty);
|
|
2679
|
+
if (usePty) {
|
|
2680
|
+
try {
|
|
2681
|
+
await spawnPtyDetached({ cmd: execCmd, cwd, session, maxOutputChars });
|
|
2682
|
+
} catch (e) {
|
|
2683
|
+
finalizeProcessSession(session, "failed", 1, "", String(e?.message || e));
|
|
2684
|
+
}
|
|
2685
|
+
return { session_id, status: session.status, pid: session.pid, log_path: logPath, output_path: outputPath };
|
|
2686
|
+
}
|
|
2687
|
+
let child;
|
|
2688
|
+
try {
|
|
2689
|
+
child = spawn(execCmd, { cwd, shell: true, detached: true });
|
|
2690
|
+
} catch (e) {
|
|
2691
|
+
finalizeProcessSession(session, "failed", 1, "", String(e?.message || e));
|
|
2692
|
+
return { session_id, status: session.status, pid: session.pid, log_path: logPath, output_path: outputPath };
|
|
2693
|
+
}
|
|
2694
|
+
session.child = child;
|
|
2695
|
+
session.pid = child.pid || null;
|
|
2696
|
+
if (child.stdout) {
|
|
2697
|
+
child.stdout.setEncoding("utf8");
|
|
2698
|
+
child.stdout.on("data", (chunk) => {
|
|
2699
|
+
const text = String(chunk || "");
|
|
2700
|
+
if (!text) return;
|
|
2701
|
+
session.stdout_len += text.length;
|
|
2702
|
+
session.stdout_tail = keepTail(session.stdout_tail, text, maxOutputChars);
|
|
2703
|
+
session.stdout_truncated = session.stdout_len > maxOutputChars;
|
|
2704
|
+
appendProcessLog(logPath, { ts: nowIso(), stream: "stdout", text });
|
|
2705
|
+
});
|
|
2706
|
+
}
|
|
2707
|
+
if (child.stderr) {
|
|
2708
|
+
child.stderr.setEncoding("utf8");
|
|
2709
|
+
child.stderr.on("data", (chunk) => {
|
|
2710
|
+
const text = String(chunk || "");
|
|
2711
|
+
if (!text) return;
|
|
2712
|
+
session.stderr_len += text.length;
|
|
2713
|
+
session.stderr_tail = keepTail(session.stderr_tail, text, maxOutputChars);
|
|
2714
|
+
session.stderr_truncated = session.stderr_len > maxOutputChars;
|
|
2715
|
+
appendProcessLog(logPath, { ts: nowIso(), stream: "stderr", text });
|
|
2716
|
+
});
|
|
2717
|
+
}
|
|
2718
|
+
child.on("error", (err) => {
|
|
2719
|
+
finalizeProcessSession(session, "failed", 1, "", String(err?.message || err));
|
|
2720
|
+
});
|
|
2721
|
+
child.on("exit", (code, signal) => {
|
|
2722
|
+
const exitCode = typeof code === "number" ? code : null;
|
|
2723
|
+
const status =
|
|
2724
|
+
session.kill_requested ? "killed" : exitCode === 0 && !signal ? "completed" : "failed";
|
|
2725
|
+
finalizeProcessSession(session, status, exitCode, signal, "");
|
|
2726
|
+
});
|
|
2727
|
+
child.unref();
|
|
2728
|
+
return { session_id, status: session.status, pid: session.pid, log_path: logPath, output_path: outputPath };
|
|
2729
|
+
};
|
|
2730
|
+
|
|
2731
|
+
const toolProcess = (args) => {
|
|
2732
|
+
const action = String(args?.action || "").trim().toLowerCase();
|
|
2733
|
+
if (!action) throw new Error("action required");
|
|
2734
|
+
if (action === "list") {
|
|
2735
|
+
const sessions = Array.from(processRegistry.values()).map((s) => ({
|
|
2736
|
+
session_id: s.session_id,
|
|
2737
|
+
status: s.status,
|
|
2738
|
+
pid: s.pid,
|
|
2739
|
+
is_pty: Boolean(s.is_pty),
|
|
2740
|
+
cmd: s.cmd,
|
|
2741
|
+
cwd: s.cwd,
|
|
2742
|
+
task_id: s.task_id || null,
|
|
2743
|
+
started_at: s.started_at,
|
|
2744
|
+
ended_at: s.ended_at || null
|
|
2745
|
+
}));
|
|
2746
|
+
return { action, sessions };
|
|
2747
|
+
}
|
|
2748
|
+
const sessionId = String(args?.session_id || "").trim();
|
|
2749
|
+
if (!sessionId) throw new Error("session_id required");
|
|
2750
|
+
const session = processRegistry.get(sessionId);
|
|
2751
|
+
if (!session) throw new Error("session not found");
|
|
2752
|
+
if (action === "poll") {
|
|
2753
|
+
return {
|
|
2754
|
+
action,
|
|
2755
|
+
session_id: session.session_id,
|
|
2756
|
+
status: session.status,
|
|
2757
|
+
pid: session.pid,
|
|
2758
|
+
is_pty: Boolean(session.is_pty),
|
|
2759
|
+
task_id: session.task_id || null,
|
|
2760
|
+
exit_code: session.exit_code ?? null,
|
|
2761
|
+
signal: session.signal || null,
|
|
2762
|
+
stdout_tail: session.stdout_tail,
|
|
2763
|
+
stderr_tail: session.stderr_tail,
|
|
2764
|
+
stdout_truncated: Boolean(session.stdout_truncated),
|
|
2765
|
+
stderr_truncated: Boolean(session.stderr_truncated),
|
|
2766
|
+
log_path: session.log_path,
|
|
2767
|
+
output_path: session.output_path
|
|
2768
|
+
};
|
|
2769
|
+
}
|
|
2770
|
+
if (action === "log") {
|
|
2771
|
+
const offset = Math.max(0, Number(args?.offset || 0));
|
|
2772
|
+
const limit = Math.max(1, Number(args?.limit || 200));
|
|
2773
|
+
const entries = readProcessLogEntries(session.log_path);
|
|
2774
|
+
const slice = entries.slice(offset, offset + limit);
|
|
2775
|
+
return {
|
|
2776
|
+
action,
|
|
2777
|
+
session_id: session.session_id,
|
|
2778
|
+
offset,
|
|
2779
|
+
limit,
|
|
2780
|
+
total: entries.length,
|
|
2781
|
+
logs: slice
|
|
2782
|
+
};
|
|
2783
|
+
}
|
|
2784
|
+
if (action === "send") {
|
|
2785
|
+
const input = String(args?.input ?? args?.data ?? args?.text ?? "");
|
|
2786
|
+
if (!input) throw new Error("input required");
|
|
2787
|
+
if (session.pty) {
|
|
2788
|
+
try {
|
|
2789
|
+
session.pty.write(input);
|
|
2790
|
+
} catch {}
|
|
2791
|
+
} else if (session.child?.stdin) {
|
|
2792
|
+
try {
|
|
2793
|
+
session.child.stdin.write(input);
|
|
2794
|
+
} catch {}
|
|
2795
|
+
}
|
|
2796
|
+
appendProcessLog(session.log_path, {
|
|
2797
|
+
ts: nowIso(),
|
|
2798
|
+
stream: "stdin",
|
|
2799
|
+
text: input
|
|
2800
|
+
});
|
|
2801
|
+
return { action, session_id: session.session_id, status: session.status };
|
|
2802
|
+
}
|
|
2803
|
+
if (action === "kill") {
|
|
2804
|
+
session.kill_requested = true;
|
|
2805
|
+
if (session.pty) {
|
|
2806
|
+
try {
|
|
2807
|
+
session.pty.kill("SIGTERM");
|
|
2808
|
+
} catch {}
|
|
2809
|
+
} else if (session.child) {
|
|
2810
|
+
try {
|
|
2811
|
+
session.child.kill("SIGTERM");
|
|
2812
|
+
} catch {}
|
|
2813
|
+
}
|
|
2814
|
+
appendProcessLog(session.log_path, {
|
|
2815
|
+
ts: nowIso(),
|
|
2816
|
+
stream: "meta",
|
|
2817
|
+
event: "kill",
|
|
2818
|
+
session_id: session.session_id
|
|
2819
|
+
});
|
|
2820
|
+
writeProcessOutput(session);
|
|
2821
|
+
return { action, session_id: session.session_id, status: session.status };
|
|
2822
|
+
}
|
|
2823
|
+
if (action === "remove") {
|
|
2824
|
+
if (session.status === "running") {
|
|
2825
|
+
session.removed = true;
|
|
2826
|
+
session.kill_requested = true;
|
|
2827
|
+
if (session.pty) {
|
|
2828
|
+
try {
|
|
2829
|
+
session.pty.kill("SIGTERM");
|
|
2830
|
+
} catch {}
|
|
2831
|
+
} else if (session.child) {
|
|
2832
|
+
try {
|
|
2833
|
+
session.child.kill("SIGTERM");
|
|
2834
|
+
} catch {}
|
|
2835
|
+
}
|
|
2836
|
+
appendProcessLog(session.log_path, {
|
|
2837
|
+
ts: nowIso(),
|
|
2838
|
+
stream: "meta",
|
|
2839
|
+
event: "remove_requested",
|
|
2840
|
+
session_id: session.session_id
|
|
2841
|
+
});
|
|
2842
|
+
writeProcessOutput(session);
|
|
2843
|
+
return { action, session_id: session.session_id, status: session.status };
|
|
2844
|
+
}
|
|
2845
|
+
processRegistry.delete(session.session_id);
|
|
2846
|
+
return { action, session_id: session.session_id, status: session.status };
|
|
2847
|
+
}
|
|
2848
|
+
throw new Error("unknown action");
|
|
2849
|
+
};
|
|
2850
|
+
|
|
2851
|
+
const toolWaitProcess = (args) => {
|
|
2852
|
+
const sessionId = String(args?.session_id || "").trim();
|
|
2853
|
+
if (!sessionId) throw new Error("session_id required");
|
|
2854
|
+
return toolProcess({ action: "poll", session_id: sessionId });
|
|
2855
|
+
};
|
|
2856
|
+
|
|
2857
|
+
const toolReadProcessLog = (args) => {
|
|
2858
|
+
const sessionId = String(args?.session_id || "").trim();
|
|
2859
|
+
if (!sessionId) throw new Error("session_id required");
|
|
2860
|
+
const offset = Math.max(0, Number(args?.offset || 0));
|
|
2861
|
+
const limit = Math.max(1, Number(args?.limit || 200));
|
|
2862
|
+
return toolProcess({ action: "log", session_id: sessionId, offset, limit });
|
|
2863
|
+
};
|
|
2864
|
+
|
|
2865
|
+
const toolReadProcessOutput = (args) => {
|
|
2866
|
+
const sessionId = String(args?.session_id || "").trim();
|
|
2867
|
+
let outputPath = args?.output_path ? String(args.output_path || "") : "";
|
|
2868
|
+
if (!outputPath && sessionId) {
|
|
2869
|
+
const session = processRegistry.get(sessionId);
|
|
2870
|
+
if (!session) throw new Error("session not found");
|
|
2871
|
+
outputPath = session.output_path;
|
|
2872
|
+
}
|
|
2873
|
+
if (!outputPath) throw new Error("output_path required");
|
|
2874
|
+
const resolved = resolveWorkspacePath(outputPath);
|
|
2875
|
+
if (!existsSync(resolved)) throw new Error("output file not found");
|
|
2876
|
+
const raw = readFileSync(resolved, "utf-8");
|
|
2877
|
+
let output;
|
|
2878
|
+
try {
|
|
2879
|
+
output = JSON.parse(raw);
|
|
2880
|
+
} catch {
|
|
2881
|
+
output = { raw };
|
|
390
2882
|
}
|
|
2883
|
+
return { session_id: sessionId || null, output };
|
|
391
2884
|
};
|
|
392
2885
|
|
|
393
|
-
const
|
|
394
|
-
const
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
let regex;
|
|
2886
|
+
const toolWebFetch = async (args) => {
|
|
2887
|
+
const url = String(args?.url || "").trim();
|
|
2888
|
+
if (!url) throw new Error("url required");
|
|
2889
|
+
let parsed;
|
|
398
2890
|
try {
|
|
399
|
-
|
|
2891
|
+
parsed = new URL(url);
|
|
400
2892
|
} catch {
|
|
401
|
-
|
|
402
|
-
regex = new RegExp(escaped, "g");
|
|
2893
|
+
throw new Error("url invalid");
|
|
403
2894
|
}
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
2895
|
+
if (!["http:", "https:"].includes(parsed.protocol)) throw new Error("url not allowed");
|
|
2896
|
+
if (!isUrlAllowed(url)) throw new Error("url not allowed");
|
|
2897
|
+
await ensureRiskAllowed("open", url);
|
|
2898
|
+
const maxChars = clampNumber(
|
|
2899
|
+
args?.max_chars ?? args?.maxChars,
|
|
2900
|
+
100,
|
|
2901
|
+
MAX_WEB_FETCH_MAX_CHARS,
|
|
2902
|
+
DEFAULT_WEB_FETCH_MAX_CHARS
|
|
2903
|
+
);
|
|
2904
|
+
const timeoutMs = clampNumber(args?.timeout_ms, 1000, 120_000, DEFAULT_WEB_FETCH_TIMEOUT_MS);
|
|
2905
|
+
const extractMode = String(args?.extract_mode || args?.extractMode || "markdown")
|
|
2906
|
+
.trim()
|
|
2907
|
+
.toLowerCase();
|
|
2908
|
+
const res = await fetchWithTimeout(
|
|
2909
|
+
url,
|
|
2910
|
+
{
|
|
2911
|
+
method: "GET",
|
|
2912
|
+
headers: {
|
|
2913
|
+
"user-agent":
|
|
2914
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
|
|
2915
|
+
accept: "text/markdown, text/html;q=0.9, text/plain;q=0.8, */*;q=0.1"
|
|
413
2916
|
}
|
|
414
|
-
|
|
415
|
-
|
|
2917
|
+
},
|
|
2918
|
+
timeoutMs
|
|
2919
|
+
);
|
|
2920
|
+
if (!res.ok) {
|
|
2921
|
+
throw createToolError("WEB_FETCH_FAILED", "web_fetch failed", { status: res.status });
|
|
416
2922
|
}
|
|
417
|
-
|
|
2923
|
+
const contentType = String(res.headers.get("content-type") || "");
|
|
2924
|
+
const raw = await res.text();
|
|
2925
|
+
let content = raw;
|
|
2926
|
+
if (contentType.includes("text/html")) {
|
|
2927
|
+
content = stripHtml(raw);
|
|
2928
|
+
} else {
|
|
2929
|
+
content = normalizeWebText(raw);
|
|
2930
|
+
}
|
|
2931
|
+
if (extractMode === "text") {
|
|
2932
|
+
content = stripHtml(content);
|
|
2933
|
+
}
|
|
2934
|
+
let truncated = false;
|
|
2935
|
+
if (content.length > maxChars) {
|
|
2936
|
+
content = content.slice(0, maxChars);
|
|
2937
|
+
truncated = true;
|
|
2938
|
+
}
|
|
2939
|
+
return {
|
|
2940
|
+
url,
|
|
2941
|
+
final_url: res.url || url,
|
|
2942
|
+
status: res.status,
|
|
2943
|
+
content_type: contentType,
|
|
2944
|
+
content,
|
|
2945
|
+
truncated
|
|
2946
|
+
};
|
|
418
2947
|
};
|
|
419
2948
|
|
|
420
|
-
const
|
|
421
|
-
const
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
2949
|
+
const toolWebSearch = async (args) => {
|
|
2950
|
+
const query = String(args?.query || "").trim();
|
|
2951
|
+
if (!query) throw new Error("query required");
|
|
2952
|
+
const count = clampNumber(
|
|
2953
|
+
args?.count,
|
|
2954
|
+
1,
|
|
2955
|
+
MAX_WEB_SEARCH_COUNT,
|
|
2956
|
+
DEFAULT_WEB_SEARCH_COUNT
|
|
2957
|
+
);
|
|
2958
|
+
const apiKey = String(process.env["BRAVE_API_KEY"] || process.env["9T_BRAVE_API_KEY"] || "");
|
|
2959
|
+
if (!apiKey) throw createToolError("MISSING_KEY", "BRAVE_API_KEY required");
|
|
2960
|
+
await ensureRiskAllowed("open", "https://api.search.brave.com");
|
|
2961
|
+
const params = new URLSearchParams({ q: query, count: String(count) });
|
|
2962
|
+
if (args?.country) params.set("country", String(args.country));
|
|
2963
|
+
if (args?.search_lang) params.set("search_lang", String(args.search_lang));
|
|
2964
|
+
if (args?.ui_lang) params.set("ui_lang", String(args.ui_lang));
|
|
2965
|
+
if (args?.freshness) params.set("freshness", String(args.freshness));
|
|
2966
|
+
const url = `https://api.search.brave.com/res/v1/web/search?${params.toString()}`;
|
|
2967
|
+
const res = await fetchWithTimeout(
|
|
2968
|
+
url,
|
|
2969
|
+
{
|
|
2970
|
+
method: "GET",
|
|
2971
|
+
headers: {
|
|
2972
|
+
"x-subscription-token": apiKey,
|
|
2973
|
+
accept: "application/json"
|
|
2974
|
+
}
|
|
2975
|
+
},
|
|
2976
|
+
DEFAULT_WEB_SEARCH_TIMEOUT_MS
|
|
2977
|
+
);
|
|
2978
|
+
if (!res.ok) {
|
|
2979
|
+
throw createToolError("WEB_SEARCH_FAILED", "web_search failed", { status: res.status });
|
|
2980
|
+
}
|
|
2981
|
+
const data = await res.json();
|
|
2982
|
+
const results = Array.isArray(data?.web?.results)
|
|
2983
|
+
? data.web.results.map((item) => ({
|
|
2984
|
+
title: item?.title || "",
|
|
2985
|
+
url: item?.url || "",
|
|
2986
|
+
snippet: item?.description || item?.snippet || ""
|
|
2987
|
+
}))
|
|
2988
|
+
: [];
|
|
2989
|
+
return { query, count, results, source: "brave" };
|
|
428
2990
|
};
|
|
429
2991
|
|
|
430
|
-
const
|
|
431
|
-
const
|
|
432
|
-
|
|
433
|
-
if (!
|
|
434
|
-
const
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
2992
|
+
const toolAskUser = async (args) => {
|
|
2993
|
+
const question = String(args?.question || "").trim();
|
|
2994
|
+
if (!question) throw new Error("question required");
|
|
2995
|
+
if (!process.stdin.isTTY) throw new Error("ask_user requires TTY");
|
|
2996
|
+
const options = Array.isArray(args?.options) ? args.options.map((o) => String(o)) : [];
|
|
2997
|
+
if (options.length) {
|
|
2998
|
+
options.forEach((opt, i) => {
|
|
2999
|
+
console.log(`[${i + 1}] ${opt}`);
|
|
3000
|
+
});
|
|
3001
|
+
}
|
|
3002
|
+
const answer = await new Promise((resolvePromise) => {
|
|
3003
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
3004
|
+
rl.question(`${question} `, (input) => {
|
|
3005
|
+
rl.close();
|
|
3006
|
+
resolvePromise(String(input || "").trim());
|
|
3007
|
+
});
|
|
3008
|
+
});
|
|
3009
|
+
if (!options.length) return { question, answer };
|
|
3010
|
+
const index = Number.parseInt(answer, 10);
|
|
3011
|
+
if (!Number.isNaN(index) && index >= 1 && index <= options.length) {
|
|
3012
|
+
return { question, answer: options[index - 1], choice: index - 1 };
|
|
3013
|
+
}
|
|
3014
|
+
return { question, answer, choice: null };
|
|
441
3015
|
};
|
|
442
3016
|
|
|
443
|
-
const
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
3017
|
+
const toolBrowser = async (args) => {
|
|
3018
|
+
const action = String(args?.action || "").trim().toLowerCase();
|
|
3019
|
+
if (action !== "open") throw new Error("invalid action");
|
|
3020
|
+
const url = String(args?.url || "").trim();
|
|
3021
|
+
if (!url) throw new Error("url required");
|
|
3022
|
+
if (!/^https?:\/\//i.test(url)) throw new Error("url not allowed");
|
|
3023
|
+
const outputPathRaw = String(args?.output_path || "").trim();
|
|
3024
|
+
if (!outputPathRaw) throw new Error("output_path required");
|
|
3025
|
+
let status = "completed";
|
|
3026
|
+
let error = "";
|
|
3027
|
+
try {
|
|
3028
|
+
const result = await toolOpen({ target: url });
|
|
3029
|
+
status = result?.ok ? "completed" : "failed";
|
|
3030
|
+
if (!result?.ok) {
|
|
3031
|
+
error = String(result?.stderr || "open failed");
|
|
3032
|
+
}
|
|
3033
|
+
} catch (e) {
|
|
3034
|
+
status = "denied";
|
|
3035
|
+
error = String(e?.message || e);
|
|
3036
|
+
const record = buildEvidenceRecord({
|
|
3037
|
+
tool: "browser",
|
|
3038
|
+
action,
|
|
3039
|
+
target: url,
|
|
3040
|
+
status,
|
|
3041
|
+
error,
|
|
3042
|
+
output_path: outputPathRaw
|
|
454
3043
|
});
|
|
3044
|
+
appendEvidenceLog(record);
|
|
3045
|
+
writeEvidenceFile(outputPathRaw, record);
|
|
3046
|
+
throw e;
|
|
3047
|
+
}
|
|
3048
|
+
const record = buildEvidenceRecord({
|
|
3049
|
+
tool: "browser",
|
|
3050
|
+
action,
|
|
3051
|
+
target: url,
|
|
3052
|
+
status,
|
|
3053
|
+
error,
|
|
3054
|
+
output_path: outputPathRaw
|
|
455
3055
|
});
|
|
3056
|
+
appendEvidenceLog(record);
|
|
3057
|
+
const resolved = writeEvidenceFile(outputPathRaw, record);
|
|
3058
|
+
return { action, url, status, output_path: resolved };
|
|
3059
|
+
};
|
|
456
3060
|
|
|
457
|
-
const
|
|
3061
|
+
const runOpenCommand = (cmd, cwd, useSandbox) =>
|
|
458
3062
|
new Promise((resolvePromise, reject) => {
|
|
459
|
-
|
|
460
|
-
if (
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
3063
|
+
let execCmd = cmd;
|
|
3064
|
+
if (useSandbox) {
|
|
3065
|
+
try {
|
|
3066
|
+
const roots = allowedRoots.length ? allowedRoots : [workspaceRoot];
|
|
3067
|
+
execCmd = buildSandboxedCommand(cmd, cwd, roots);
|
|
3068
|
+
} catch (e) {
|
|
3069
|
+
return reject(new Error(String(e?.message || e)));
|
|
3070
|
+
}
|
|
3071
|
+
}
|
|
3072
|
+
exec(execCmd, { cwd }, (err, stdout, stderr) => {
|
|
467
3073
|
if (err) resolvePromise({ ok: false, stdout, stderr });
|
|
468
3074
|
else resolvePromise({ ok: true, stdout, stderr });
|
|
469
3075
|
});
|
|
470
3076
|
});
|
|
471
3077
|
|
|
3078
|
+
const toolOpenUrl = async (args) => {
|
|
3079
|
+
const url = String(args?.url || "").trim();
|
|
3080
|
+
if (!url) throw new Error("url required");
|
|
3081
|
+
if (!/^https?:\/\//i.test(url)) throw new Error("url not allowed");
|
|
3082
|
+
if (!isUrlAllowed(url)) throw new Error("url not allowed");
|
|
3083
|
+
await ensureRiskAllowed("open", url);
|
|
3084
|
+
const platform = process.platform;
|
|
3085
|
+
let cmd = "";
|
|
3086
|
+
if (platform === "darwin") cmd = `open "${url}"`;
|
|
3087
|
+
else if (platform === "win32") cmd = `start "" "${url}"`;
|
|
3088
|
+
else cmd = `xdg-open "${url}"`;
|
|
3089
|
+
return await runOpenCommand(cmd, workspaceRoot, false);
|
|
3090
|
+
};
|
|
3091
|
+
|
|
3092
|
+
const toolOpen = async (args) => {
|
|
3093
|
+
const raw = String(args?.path || args?.target || "").trim();
|
|
3094
|
+
if (!raw) throw new Error("path required");
|
|
3095
|
+
if (/^https?:\/\//i.test(raw)) throw new Error("use open_url for url");
|
|
3096
|
+
const openTarget = resolveWorkspacePath(raw);
|
|
3097
|
+
if (!isOpenFileAllowed(openTarget)) {
|
|
3098
|
+
throw new Error("file type not allowed");
|
|
3099
|
+
}
|
|
3100
|
+
await ensureRiskAllowed("open", openTarget);
|
|
3101
|
+
const platform = process.platform;
|
|
3102
|
+
let cmd = "";
|
|
3103
|
+
if (platform === "darwin") cmd = `open "${openTarget}"`;
|
|
3104
|
+
else if (platform === "win32") cmd = `start "" "${openTarget}"`;
|
|
3105
|
+
else cmd = `xdg-open "${openTarget}"`;
|
|
3106
|
+
const useSandbox = sandboxMode !== "off";
|
|
3107
|
+
return await runOpenCommand(cmd, dirname(openTarget), useSandbox);
|
|
3108
|
+
};
|
|
3109
|
+
|
|
3110
|
+
const toolTaskCreate = (args) => {
|
|
3111
|
+
const store = loadTaskStore();
|
|
3112
|
+
const task = createTask(store, args || {});
|
|
3113
|
+
saveTaskStore(store);
|
|
3114
|
+
appendTaskLog({ ts: nowIso(), action: "create", task });
|
|
3115
|
+
return { task };
|
|
3116
|
+
};
|
|
3117
|
+
|
|
3118
|
+
const toolTaskUpdate = (args) => {
|
|
3119
|
+
const store = loadTaskStore();
|
|
3120
|
+
const task = updateTask(store, args || {});
|
|
3121
|
+
recordTaskUpdate(store, task, null);
|
|
3122
|
+
return { task };
|
|
3123
|
+
};
|
|
3124
|
+
|
|
3125
|
+
const toolTaskGet = (args) => {
|
|
3126
|
+
const store = loadTaskStore();
|
|
3127
|
+
const taskId = String(args.task_id || "").trim();
|
|
3128
|
+
if (!taskId) throw new Error("task_id required");
|
|
3129
|
+
const task = ensureTaskExists(store, taskId);
|
|
3130
|
+
return { task };
|
|
3131
|
+
};
|
|
3132
|
+
|
|
3133
|
+
const toolTaskList = (args) => {
|
|
3134
|
+
const store = loadTaskStore();
|
|
3135
|
+
const tasks = listTasks(store, args || {});
|
|
3136
|
+
return { tasks };
|
|
3137
|
+
};
|
|
3138
|
+
|
|
3139
|
+
const toolTaskTree = (args) => {
|
|
3140
|
+
const store = loadTaskStore();
|
|
3141
|
+
const rootId = args?.root_id ? String(args.root_id) : "";
|
|
3142
|
+
const tree = buildTaskTree(store, rootId || "");
|
|
3143
|
+
return { tree };
|
|
3144
|
+
};
|
|
3145
|
+
|
|
3146
|
+
const toolTaskLogList = (args) => {
|
|
3147
|
+
const taskId = args?.task_id ? String(args.task_id) : "";
|
|
3148
|
+
const limit = Math.max(1, Number(args?.limit || 50));
|
|
3149
|
+
const logs = loadTaskLogs().filter((l) => (taskId ? l.task?.task_id === taskId : true));
|
|
3150
|
+
const sliced = logs.slice(-limit);
|
|
3151
|
+
return { logs: sliced };
|
|
3152
|
+
};
|
|
3153
|
+
|
|
3154
|
+
const toolTaskReplay = () => {
|
|
3155
|
+
const logs = loadTaskLogs();
|
|
3156
|
+
if (!logs.length) throw new Error("no task logs");
|
|
3157
|
+
const result = replayTaskLogs(logs);
|
|
3158
|
+
return { ok: true, ...result };
|
|
3159
|
+
};
|
|
3160
|
+
|
|
3161
|
+
const toolTodoList = (args) => {
|
|
3162
|
+
const store = loadTaskStore();
|
|
3163
|
+
const todos = listTodos(store, args || {});
|
|
3164
|
+
return { todos };
|
|
3165
|
+
};
|
|
3166
|
+
|
|
3167
|
+
const toolTodoAdd = (args) => {
|
|
3168
|
+
const store = loadTaskStore();
|
|
3169
|
+
const task = createTodo(store, { ...(args || {}), defaultSource: "ai" });
|
|
3170
|
+
return { todo: task };
|
|
3171
|
+
};
|
|
3172
|
+
|
|
3173
|
+
const toolTodoStart = (args) => {
|
|
3174
|
+
const todoId = String(args?.todo_id || "").trim();
|
|
3175
|
+
if (!todoId) throw new Error("todo_id required");
|
|
3176
|
+
const store = loadTaskStore();
|
|
3177
|
+
const task = startTodo(store, todoId, { source: "todo" });
|
|
3178
|
+
return { todo: task };
|
|
3179
|
+
};
|
|
3180
|
+
|
|
3181
|
+
const toolTodoDone = (args) => {
|
|
3182
|
+
const todoId = String(args?.todo_id || "").trim();
|
|
3183
|
+
if (!todoId) throw new Error("todo_id required");
|
|
3184
|
+
const store = loadTaskStore();
|
|
3185
|
+
const task = doneTodo(store, todoId, { source: "todo" });
|
|
3186
|
+
return { todo: task };
|
|
3187
|
+
};
|
|
3188
|
+
|
|
3189
|
+
const toolTodoPatch = (args) => {
|
|
3190
|
+
const todoId = String(args?.todo_id || "").trim();
|
|
3191
|
+
if (!todoId) throw new Error("todo_id required");
|
|
3192
|
+
const store = loadTaskStore();
|
|
3193
|
+
const task = patchTodo(store, todoId, { ...(args || {}), defaultSource: "ai" });
|
|
3194
|
+
return { todo: task };
|
|
3195
|
+
};
|
|
3196
|
+
|
|
3197
|
+
const toolTodoClear = (args) => {
|
|
3198
|
+
const store = loadTaskStore();
|
|
3199
|
+
const count = clearTodos(store, args?.scope, { source: "todo" });
|
|
3200
|
+
return { cleared: count };
|
|
3201
|
+
};
|
|
3202
|
+
|
|
472
3203
|
const toolMap = {
|
|
473
3204
|
read: toolRead,
|
|
474
3205
|
edit: toolEdit,
|
|
475
3206
|
create: toolCreate,
|
|
476
3207
|
delete: toolDelete,
|
|
3208
|
+
move: toolMove,
|
|
3209
|
+
rename: toolMove,
|
|
3210
|
+
stat: toolStat,
|
|
3211
|
+
diff: toolDiff,
|
|
3212
|
+
patch: toolPatch,
|
|
477
3213
|
ls: toolLs,
|
|
478
3214
|
grep: toolGrep,
|
|
479
3215
|
glob: toolGlob,
|
|
480
3216
|
execute: toolExecute,
|
|
481
|
-
|
|
3217
|
+
execute_detached: toolExecuteDetached,
|
|
3218
|
+
process: toolProcess,
|
|
3219
|
+
wait_process: toolWaitProcess,
|
|
3220
|
+
read_process_log: toolReadProcessLog,
|
|
3221
|
+
read_process_output: toolReadProcessOutput,
|
|
3222
|
+
web_fetch: toolWebFetch,
|
|
3223
|
+
web_search: toolWebSearch,
|
|
3224
|
+
ask_user: toolAskUser,
|
|
3225
|
+
browser: toolBrowser,
|
|
3226
|
+
open: toolOpen,
|
|
3227
|
+
open_url: toolOpenUrl,
|
|
3228
|
+
task_create: toolTaskCreate,
|
|
3229
|
+
task_update: toolTaskUpdate,
|
|
3230
|
+
task_get: toolTaskGet,
|
|
3231
|
+
task_list: toolTaskList,
|
|
3232
|
+
task_tree: toolTaskTree,
|
|
3233
|
+
task_log_list: toolTaskLogList,
|
|
3234
|
+
task_replay: toolTaskReplay,
|
|
3235
|
+
todo_list: toolTodoList,
|
|
3236
|
+
todo_add: toolTodoAdd,
|
|
3237
|
+
todo_start: toolTodoStart,
|
|
3238
|
+
todo_done: toolTodoDone,
|
|
3239
|
+
todo_patch: toolTodoPatch,
|
|
3240
|
+
todo_clear: toolTodoClear
|
|
482
3241
|
};
|
|
483
3242
|
|
|
484
3243
|
const extractJson = (text) => {
|
|
@@ -498,7 +3257,22 @@ const extractJson = (text) => {
|
|
|
498
3257
|
throw new Error("no json found");
|
|
499
3258
|
};
|
|
500
3259
|
|
|
501
|
-
const
|
|
3260
|
+
const buildOpenAIMessages = (systemPrompt, messages) => {
|
|
3261
|
+
const base = [{ role: "system", content: systemPrompt }];
|
|
3262
|
+
const mapped = messages.map((m) => ({
|
|
3263
|
+
role: m.role === "assistant" ? "assistant" : "user",
|
|
3264
|
+
content: String(m.content || "")
|
|
3265
|
+
}));
|
|
3266
|
+
return base.concat(mapped);
|
|
3267
|
+
};
|
|
3268
|
+
|
|
3269
|
+
const buildGeminiContents = (messages) =>
|
|
3270
|
+
messages.map((m) => ({
|
|
3271
|
+
role: m.role === "assistant" ? "model" : "user",
|
|
3272
|
+
parts: [{ text: String(m.content || "") }]
|
|
3273
|
+
}));
|
|
3274
|
+
|
|
3275
|
+
const callAnthropicModel = async (systemPrompt, messages, maxTokens) => {
|
|
502
3276
|
const res = await fetch(`${baseUrl}/v1/messages`, {
|
|
503
3277
|
method: "POST",
|
|
504
3278
|
headers: {
|
|
@@ -508,9 +3282,9 @@ const callModel = async (messages) => {
|
|
|
508
3282
|
},
|
|
509
3283
|
body: JSON.stringify({
|
|
510
3284
|
model,
|
|
511
|
-
max_tokens:
|
|
3285
|
+
max_tokens: maxTokens,
|
|
512
3286
|
temperature: 0,
|
|
513
|
-
system:
|
|
3287
|
+
system: systemPrompt,
|
|
514
3288
|
messages
|
|
515
3289
|
})
|
|
516
3290
|
});
|
|
@@ -520,15 +3294,309 @@ const callModel = async (messages) => {
|
|
|
520
3294
|
throw new Error(msg);
|
|
521
3295
|
}
|
|
522
3296
|
const content = Array.isArray(data.content) ? data.content : [];
|
|
523
|
-
|
|
524
|
-
|
|
3297
|
+
return content.map((c) => c.text || "").join("").trim();
|
|
3298
|
+
};
|
|
3299
|
+
|
|
3300
|
+
const callOpenAIModel = async (systemPrompt, messages, maxTokens) => {
|
|
3301
|
+
const payload = {
|
|
3302
|
+
model,
|
|
3303
|
+
max_tokens: maxTokens,
|
|
3304
|
+
temperature: 0,
|
|
3305
|
+
messages: buildOpenAIMessages(systemPrompt, messages)
|
|
3306
|
+
};
|
|
3307
|
+
const res = await fetch(`${baseUrl}/chat/completions`, {
|
|
3308
|
+
method: "POST",
|
|
3309
|
+
headers: {
|
|
3310
|
+
"content-type": "application/json",
|
|
3311
|
+
authorization: `Bearer ${apiKey}`
|
|
3312
|
+
},
|
|
3313
|
+
body: JSON.stringify(payload)
|
|
3314
|
+
});
|
|
3315
|
+
const data = await res.json();
|
|
3316
|
+
if (!res.ok) {
|
|
3317
|
+
const msg = data?.error?.message || "request failed";
|
|
3318
|
+
throw new Error(msg);
|
|
3319
|
+
}
|
|
3320
|
+
return String(data?.choices?.[0]?.message?.content || "").trim();
|
|
3321
|
+
};
|
|
3322
|
+
|
|
3323
|
+
const callGeminiModel = async (systemPrompt, messages, maxTokens) => {
|
|
3324
|
+
const payload = {
|
|
3325
|
+
system_instruction: {
|
|
3326
|
+
role: "system",
|
|
3327
|
+
parts: [{ text: systemPrompt }]
|
|
3328
|
+
},
|
|
3329
|
+
contents: buildGeminiContents(messages),
|
|
3330
|
+
generationConfig: {
|
|
3331
|
+
temperature: 0,
|
|
3332
|
+
maxOutputTokens: maxTokens
|
|
3333
|
+
}
|
|
3334
|
+
};
|
|
3335
|
+
const url = `${baseUrl}/models/${encodeURIComponent(model)}:generateContent?key=${encodeURIComponent(
|
|
3336
|
+
apiKey
|
|
3337
|
+
)}`;
|
|
3338
|
+
const res = await fetch(url, {
|
|
3339
|
+
method: "POST",
|
|
3340
|
+
headers: { "content-type": "application/json" },
|
|
3341
|
+
body: JSON.stringify(payload)
|
|
3342
|
+
});
|
|
3343
|
+
const data = await res.json();
|
|
3344
|
+
if (!res.ok) {
|
|
3345
|
+
const msg = data?.error?.message || "request failed";
|
|
3346
|
+
throw new Error(msg);
|
|
3347
|
+
}
|
|
3348
|
+
const parts = data?.candidates?.[0]?.content?.parts || [];
|
|
3349
|
+
return parts.map((p) => p.text || "").join("").trim();
|
|
3350
|
+
};
|
|
3351
|
+
|
|
3352
|
+
const callLlm = async (systemPrompt, messages, maxTokens = 1024) => {
|
|
3353
|
+
if (!apiKey) {
|
|
3354
|
+
apiKey = await ensureApiKey(providerName);
|
|
3355
|
+
}
|
|
3356
|
+
if (providerFormat === "openai") {
|
|
3357
|
+
return await callOpenAIModel(systemPrompt, messages, maxTokens);
|
|
3358
|
+
}
|
|
3359
|
+
if (providerFormat === "gemini") {
|
|
3360
|
+
return await callGeminiModel(systemPrompt, messages, maxTokens);
|
|
3361
|
+
}
|
|
3362
|
+
return await callAnthropicModel(systemPrompt, messages, maxTokens);
|
|
3363
|
+
};
|
|
3364
|
+
|
|
3365
|
+
const callModel = async (messages) => {
|
|
3366
|
+
const systemPrompt = getSystemPrompt();
|
|
3367
|
+
return await callLlm(systemPrompt, messages, 1024);
|
|
3368
|
+
};
|
|
3369
|
+
|
|
3370
|
+
const callSummaryModel = async (records, rangeLabel) => {
|
|
3371
|
+
const systemPrompt = "你是会话摘要器,只输出 Markdown,不调用工具。";
|
|
3372
|
+
const recordFiles = listSessionRecordFiles();
|
|
3373
|
+
const recordList = recordFiles.length ? recordFiles.join(", ") : "无";
|
|
3374
|
+
const userContent = [
|
|
3375
|
+
`摘要范围:${rangeLabel}`,
|
|
3376
|
+
`记录文件:${recordList}`,
|
|
3377
|
+
"请生成中文 Markdown 摘要,必须包含以下条目:",
|
|
3378
|
+
"讨论主题、简要内容、关键词、提及术语、脚本与执行行为、文件变更清单(含路径)、访问与搜索的 URL 与简要描述。",
|
|
3379
|
+
"若无相关内容,请写“无”。",
|
|
3380
|
+
"记录内容:",
|
|
3381
|
+
JSON.stringify(records, null, 2)
|
|
3382
|
+
].join("\n");
|
|
3383
|
+
return await callLlm(systemPrompt, [{ role: "user", content: userContent }], 1024);
|
|
3384
|
+
};
|
|
3385
|
+
|
|
3386
|
+
const normalizeRecordPath = (input) => {
|
|
3387
|
+
if (!input) return "";
|
|
3388
|
+
try {
|
|
3389
|
+
const full = resolveWorkspacePath(String(input));
|
|
3390
|
+
return formatOutputPath(full, workspaceRoot);
|
|
3391
|
+
} catch {
|
|
3392
|
+
return String(input);
|
|
3393
|
+
}
|
|
3394
|
+
};
|
|
3395
|
+
|
|
3396
|
+
const buildSessionSummaryFile = (startRound, endRound) => {
|
|
3397
|
+
const base = `${currentSessionId}`;
|
|
3398
|
+
if (startRound === endRound) {
|
|
3399
|
+
return join(sessionState.dir, `${base}-${startRound}-summary.md`);
|
|
3400
|
+
}
|
|
3401
|
+
return join(sessionState.dir, `${base}-${startRound}to${endRound}-summary.md`);
|
|
3402
|
+
};
|
|
3403
|
+
|
|
3404
|
+
const buildSessionFinalSummaryFile = () => {
|
|
3405
|
+
return join(sessionState.dir, `${currentSessionId}-summary.md`);
|
|
3406
|
+
};
|
|
3407
|
+
|
|
3408
|
+
const filterRecordsByRound = (records, startRound, endRound) =>
|
|
3409
|
+
records.filter((r) => Number(r?.round || 0) >= startRound && Number(r?.round || 0) <= endRound);
|
|
3410
|
+
|
|
3411
|
+
const writeSessionSummary = async (startRound, endRound) => {
|
|
3412
|
+
const records = loadSessionRecords();
|
|
3413
|
+
const slice = filterRecordsByRound(records, startRound, endRound);
|
|
3414
|
+
if (!slice.length) return;
|
|
3415
|
+
const rangeLabel = startRound === endRound ? `${startRound}` : `${startRound}-${endRound}`;
|
|
3416
|
+
const text = await callSummaryModel(slice, rangeLabel);
|
|
3417
|
+
const path = buildSessionSummaryFile(startRound, endRound);
|
|
3418
|
+
if (existsSync(path)) return;
|
|
3419
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
3420
|
+
writeFileSync(path, text || "");
|
|
3421
|
+
};
|
|
3422
|
+
|
|
3423
|
+
const writeSessionFinalSummary = async () => {
|
|
3424
|
+
const records = loadSessionRecords();
|
|
3425
|
+
if (!records.length) return;
|
|
3426
|
+
const text = await callSummaryModel(records, "full");
|
|
3427
|
+
const path = buildSessionFinalSummaryFile();
|
|
3428
|
+
if (existsSync(path)) return;
|
|
3429
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
3430
|
+
writeFileSync(path, text || "");
|
|
3431
|
+
};
|
|
3432
|
+
|
|
3433
|
+
const finalizeSessionSummaries = async () => {
|
|
3434
|
+
const round = sessionState.round || 0;
|
|
3435
|
+
if (!round) return;
|
|
3436
|
+
const remainder = round % 3;
|
|
3437
|
+
if (remainder !== 0) {
|
|
3438
|
+
const start = round - remainder + 1;
|
|
3439
|
+
await writeSessionSummary(start, round);
|
|
3440
|
+
}
|
|
3441
|
+
await writeSessionFinalSummary();
|
|
3442
|
+
};
|
|
3443
|
+
|
|
3444
|
+
const sleep = (ms) => new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
|
|
3445
|
+
|
|
3446
|
+
const computeBackoffDelay = (policy, baseDelay, attempt, maxDelay) => {
|
|
3447
|
+
const cappedBase = Math.max(0, Number(baseDelay || 0));
|
|
3448
|
+
const cappedMax = Math.max(cappedBase, Number(maxDelay || 0));
|
|
3449
|
+
if (policy === "linear") return Math.min(cappedMax, cappedBase * attempt);
|
|
3450
|
+
if (policy === "exponential") return Math.min(cappedMax, cappedBase * Math.pow(2, attempt - 1));
|
|
3451
|
+
return Math.min(cappedMax, cappedBase);
|
|
3452
|
+
};
|
|
3453
|
+
|
|
3454
|
+
const runLoop = async (prompt, options, hooks) => {
|
|
3455
|
+
const messages = [];
|
|
3456
|
+
const startAt = Date.now();
|
|
3457
|
+
let runIndex = 0;
|
|
3458
|
+
let failureCount = 0;
|
|
3459
|
+
const handlers = hooks || {};
|
|
3460
|
+
while (true) {
|
|
3461
|
+
if (options.maxRuns > 0 && runIndex >= options.maxRuns) break;
|
|
3462
|
+
if (options.maxDurationMs > 0 && Date.now() - startAt >= options.maxDurationMs) break;
|
|
3463
|
+
appendSessionEvent({ event: "loop_run_start", run: runIndex + 1 });
|
|
3464
|
+
if (handlers.onRunStart) await handlers.onRunStart({ run: runIndex + 1 });
|
|
3465
|
+
let attempt = 0;
|
|
3466
|
+
let lastError = "";
|
|
3467
|
+
while (true) {
|
|
3468
|
+
try {
|
|
3469
|
+
const out = await runPrompt(messages, prompt);
|
|
3470
|
+
if (out !== undefined && out !== null) console.log(out);
|
|
3471
|
+
appendSessionEvent({ event: "loop_run_end", run: runIndex + 1, ok: true });
|
|
3472
|
+
if (handlers.onRunSuccess) await handlers.onRunSuccess({ run: runIndex + 1, output: out });
|
|
3473
|
+
failureCount = 0;
|
|
3474
|
+
lastError = "";
|
|
3475
|
+
break;
|
|
3476
|
+
} catch (e) {
|
|
3477
|
+
attempt += 1;
|
|
3478
|
+
failureCount += 1;
|
|
3479
|
+
lastError = String(e?.message || e);
|
|
3480
|
+
const willRetry = attempt <= options.maxRetries;
|
|
3481
|
+
appendSessionEvent({
|
|
3482
|
+
event: "loop_run_error",
|
|
3483
|
+
run: runIndex + 1,
|
|
3484
|
+
attempt,
|
|
3485
|
+
error: lastError
|
|
3486
|
+
});
|
|
3487
|
+
if (handlers.onRunError) {
|
|
3488
|
+
await handlers.onRunError({ run: runIndex + 1, attempt, error: lastError, willRetry });
|
|
3489
|
+
}
|
|
3490
|
+
if (!willRetry) {
|
|
3491
|
+
appendSessionEvent({
|
|
3492
|
+
event: "loop_run_end",
|
|
3493
|
+
run: runIndex + 1,
|
|
3494
|
+
ok: false,
|
|
3495
|
+
error: lastError
|
|
3496
|
+
});
|
|
3497
|
+
break;
|
|
3498
|
+
}
|
|
3499
|
+
const delay = computeBackoffDelay(
|
|
3500
|
+
options.backoff,
|
|
3501
|
+
options.retryDelayMs,
|
|
3502
|
+
attempt,
|
|
3503
|
+
options.maxRetryDelayMs
|
|
3504
|
+
);
|
|
3505
|
+
if (delay > 0) await sleep(delay);
|
|
3506
|
+
if (handlers.onRetryStart) {
|
|
3507
|
+
await handlers.onRetryStart({ run: runIndex + 1, attempt: attempt + 1 });
|
|
3508
|
+
}
|
|
3509
|
+
}
|
|
3510
|
+
}
|
|
3511
|
+
if (handlers.onRunEnd) {
|
|
3512
|
+
await handlers.onRunEnd({ run: runIndex + 1, ok: !lastError, error: lastError });
|
|
3513
|
+
}
|
|
3514
|
+
runIndex += 1;
|
|
3515
|
+
if (lastError && options.stopOnFail) break;
|
|
3516
|
+
if (options.everyMs > 0) {
|
|
3517
|
+
await sleep(options.everyMs);
|
|
3518
|
+
} else {
|
|
3519
|
+
break;
|
|
3520
|
+
}
|
|
3521
|
+
}
|
|
3522
|
+
appendSessionEvent({ event: "loop_end", runs: runIndex, failures: failureCount });
|
|
3523
|
+
await finalizeSessionSummaries();
|
|
3524
|
+
appendSessionEvent({ event: "end" });
|
|
3525
|
+
};
|
|
3526
|
+
|
|
3527
|
+
const runTaskLoop = async (prompt, options) => {
|
|
3528
|
+
const store = loadTaskStore();
|
|
3529
|
+
const title = String(options.title || prompt).trim();
|
|
3530
|
+
if (!title) throw new Error("task title required");
|
|
3531
|
+
const task = createTask(store, {
|
|
3532
|
+
title,
|
|
3533
|
+
status: "queued",
|
|
3534
|
+
inputs: { prompt, loop: "task" }
|
|
3535
|
+
});
|
|
3536
|
+
recordTaskUpdate(store, task, { source: "loop", mode: "task" });
|
|
3537
|
+
const taskId = task.task_id;
|
|
3538
|
+
const updateStatus = (status, patch, meta) => {
|
|
3539
|
+
const payload = { task_id: taskId, status };
|
|
3540
|
+
if (patch) Object.assign(payload, patch);
|
|
3541
|
+
const latest = loadTaskStore();
|
|
3542
|
+
const updated = updateTask(latest, payload);
|
|
3543
|
+
recordTaskUpdate(latest, updated, meta);
|
|
3544
|
+
};
|
|
3545
|
+
const hooks = {
|
|
3546
|
+
onRunStart: ({ run }) => {
|
|
3547
|
+
updateStatus("running", null, { source: "loop", mode: "task", run });
|
|
3548
|
+
},
|
|
3549
|
+
onRunSuccess: ({ run, output }) => {
|
|
3550
|
+
updateStatus(
|
|
3551
|
+
"completed",
|
|
3552
|
+
{ outputs: { last_result: output, run, session_id: currentSessionId } },
|
|
3553
|
+
{ source: "loop", mode: "task", run, ok: true }
|
|
3554
|
+
);
|
|
3555
|
+
},
|
|
3556
|
+
onRunError: ({ run, attempt, error, willRetry }) => {
|
|
3557
|
+
if (willRetry) {
|
|
3558
|
+
updateStatus(
|
|
3559
|
+
"retrying",
|
|
3560
|
+
{ last_error: error },
|
|
3561
|
+
{ source: "loop", mode: "task", run, attempt, retry: true }
|
|
3562
|
+
);
|
|
3563
|
+
} else {
|
|
3564
|
+
updateStatus(
|
|
3565
|
+
"failed",
|
|
3566
|
+
{ last_error: error },
|
|
3567
|
+
{ source: "loop", mode: "task", run, attempt, retry: false }
|
|
3568
|
+
);
|
|
3569
|
+
}
|
|
3570
|
+
},
|
|
3571
|
+
onRetryStart: ({ run, attempt }) => {
|
|
3572
|
+
updateStatus("running", null, { source: "loop", mode: "task", run, attempt });
|
|
3573
|
+
}
|
|
3574
|
+
};
|
|
3575
|
+
await runLoop(prompt, options, hooks);
|
|
525
3576
|
};
|
|
526
3577
|
|
|
527
3578
|
const runPrompt = async (messages, prompt) => {
|
|
528
|
-
|
|
3579
|
+
const summaries = loadSessionSummaries();
|
|
3580
|
+
const convo = [];
|
|
3581
|
+
if (summaries.length > 0) {
|
|
3582
|
+
convo.push({ role: "user", content: buildSummaryMessage(summaries) });
|
|
3583
|
+
} else {
|
|
3584
|
+
const records = loadSessionRecords();
|
|
3585
|
+
if (records.length) {
|
|
3586
|
+
convo.push({ role: "user", content: buildRecordsMessage(records) });
|
|
3587
|
+
}
|
|
3588
|
+
}
|
|
3589
|
+
convo.push({ role: "user", content: prompt });
|
|
3590
|
+
appendSessionEvent({ role: "user", content: prompt });
|
|
3591
|
+
const round = sessionState.round + 1;
|
|
3592
|
+
const roundTools = [];
|
|
3593
|
+
const roundFiles = [];
|
|
3594
|
+
const roundUrls = [];
|
|
3595
|
+
const roundApiCalls = [];
|
|
529
3596
|
for (let step = 0; step < MAX_STEPS; step++) {
|
|
530
|
-
const text = await callModel(
|
|
531
|
-
|
|
3597
|
+
const text = await callModel(convo);
|
|
3598
|
+
convo.push({ role: "assistant", content: text });
|
|
3599
|
+
appendSessionEvent({ role: "assistant", content: text, step });
|
|
532
3600
|
let parsed;
|
|
533
3601
|
try {
|
|
534
3602
|
parsed = extractJson(text);
|
|
@@ -536,6 +3604,31 @@ const runPrompt = async (messages, prompt) => {
|
|
|
536
3604
|
throw new Error(`invalid model json: ${String(e.message || e)}`);
|
|
537
3605
|
}
|
|
538
3606
|
if (parsed.final) {
|
|
3607
|
+
const records = [
|
|
3608
|
+
{
|
|
3609
|
+
ts: nowIso(),
|
|
3610
|
+
session_id: currentSessionId,
|
|
3611
|
+
round,
|
|
3612
|
+
role: "user",
|
|
3613
|
+
content: prompt
|
|
3614
|
+
},
|
|
3615
|
+
{
|
|
3616
|
+
ts: nowIso(),
|
|
3617
|
+
session_id: currentSessionId,
|
|
3618
|
+
round,
|
|
3619
|
+
role: "assistant",
|
|
3620
|
+
content: parsed.final,
|
|
3621
|
+
tools: roundTools,
|
|
3622
|
+
files: roundFiles,
|
|
3623
|
+
urls: roundUrls,
|
|
3624
|
+
api_calls: roundApiCalls
|
|
3625
|
+
}
|
|
3626
|
+
];
|
|
3627
|
+
saveSessionRecords(round, records);
|
|
3628
|
+
sessionState.round = round;
|
|
3629
|
+
if (round % 3 === 0) {
|
|
3630
|
+
await writeSessionSummary(round - 2, round);
|
|
3631
|
+
}
|
|
539
3632
|
return parsed.final;
|
|
540
3633
|
}
|
|
541
3634
|
const calls = parsed.tools || (parsed.tool ? [parsed] : []);
|
|
@@ -546,14 +3639,124 @@ const runPrompt = async (messages, prompt) => {
|
|
|
546
3639
|
const name = call.tool;
|
|
547
3640
|
const args = call.args || {};
|
|
548
3641
|
const fn = toolMap[name];
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
3642
|
+
let result;
|
|
3643
|
+
if (!fn) {
|
|
3644
|
+
const errInfo = normalizeToolError(createToolError("NOT_FOUND", `unknown tool: ${name}`));
|
|
3645
|
+
result = buildToolResult({
|
|
3646
|
+
tool: name,
|
|
3647
|
+
ok: false,
|
|
3648
|
+
code: errInfo.code,
|
|
3649
|
+
error: errInfo.message,
|
|
3650
|
+
meta: { error_type: errInfo.error_type, details: errInfo.meta || null }
|
|
3651
|
+
});
|
|
3652
|
+
} else {
|
|
3653
|
+
try {
|
|
3654
|
+
const data = await fn(args);
|
|
3655
|
+
result = buildToolResult({ tool: name, ok: true, code: 0, data });
|
|
3656
|
+
} catch (e) {
|
|
3657
|
+
const errInfo = normalizeToolError(e);
|
|
3658
|
+
result = buildToolResult({
|
|
3659
|
+
tool: name,
|
|
3660
|
+
ok: false,
|
|
3661
|
+
code: errInfo.code,
|
|
3662
|
+
error: errInfo.message,
|
|
3663
|
+
meta: { error_type: errInfo.error_type, details: errInfo.meta || null }
|
|
3664
|
+
});
|
|
3665
|
+
}
|
|
3666
|
+
}
|
|
3667
|
+
appendAuditEvent({
|
|
3668
|
+
tool: name,
|
|
3669
|
+
ok: result.ok,
|
|
3670
|
+
code: result.code,
|
|
3671
|
+
error: result.error,
|
|
3672
|
+
args,
|
|
3673
|
+
summary: summarizeToolResult(name, result.data)
|
|
3674
|
+
});
|
|
3675
|
+
appendSessionEvent({
|
|
3676
|
+
role: "tool",
|
|
3677
|
+
tool: name,
|
|
3678
|
+
ok: result.ok,
|
|
3679
|
+
code: result.code,
|
|
3680
|
+
summary: summarizeToolResult(name, result.data)
|
|
3681
|
+
});
|
|
3682
|
+
roundTools.push({
|
|
3683
|
+
tool: name,
|
|
3684
|
+
ok: result.ok,
|
|
3685
|
+
code: result.code,
|
|
3686
|
+
args,
|
|
3687
|
+
summary: summarizeToolResult(name, result.data),
|
|
3688
|
+
error: result.error || null
|
|
3689
|
+
});
|
|
3690
|
+
if (name === "create" || name === "edit" || name === "patch" || name === "delete") {
|
|
3691
|
+
const path = normalizeRecordPath(args?.path);
|
|
3692
|
+
if (path) roundFiles.push({ action: name, path });
|
|
3693
|
+
}
|
|
3694
|
+
if (name === "move" || name === "rename") {
|
|
3695
|
+
const from = normalizeRecordPath(args?.from || args?.src || args?.path);
|
|
3696
|
+
const to = normalizeRecordPath(args?.to || args?.dest || args?.target);
|
|
3697
|
+
if (from || to) roundFiles.push({ action: name, from, to });
|
|
3698
|
+
}
|
|
3699
|
+
if (name === "execute" || name === "execute_detached") {
|
|
3700
|
+
const cmd = String(args?.cmd || "");
|
|
3701
|
+
if (cmd) roundApiCalls.push({ name, summary: cmd });
|
|
3702
|
+
}
|
|
3703
|
+
if (name === "web_fetch") {
|
|
3704
|
+
const url = String(args?.url || "");
|
|
3705
|
+
if (url) roundUrls.push({ url, summary: `status=${result.data?.status ?? ""}` });
|
|
3706
|
+
}
|
|
3707
|
+
if (name === "web_search") {
|
|
3708
|
+
const query = String(args?.query || "");
|
|
3709
|
+
if (query) roundApiCalls.push({ name, summary: `query=${query}` });
|
|
3710
|
+
const results = Array.isArray(result.data?.results) ? result.data.results : [];
|
|
3711
|
+
results.slice(0, 5).forEach((item) => {
|
|
3712
|
+
if (item?.url) {
|
|
3713
|
+
roundUrls.push({ url: item.url, summary: item?.snippet || "" });
|
|
3714
|
+
}
|
|
3715
|
+
});
|
|
3716
|
+
}
|
|
3717
|
+
if (name === "open") {
|
|
3718
|
+
const path = String(args?.path || args?.target || "");
|
|
3719
|
+
if (path) roundFiles.push({ action: name, path });
|
|
3720
|
+
}
|
|
3721
|
+
if (name === "open_url") {
|
|
3722
|
+
const url = String(args?.url || "");
|
|
3723
|
+
if (url) roundUrls.push({ url, summary: "open_url" });
|
|
3724
|
+
}
|
|
3725
|
+
if (name === "browser") {
|
|
3726
|
+
const url = String(args?.url || "");
|
|
3727
|
+
if (url) roundUrls.push({ url, summary: "browser" });
|
|
3728
|
+
}
|
|
3729
|
+
convo.push({
|
|
552
3730
|
role: "user",
|
|
553
3731
|
content: `TOOL_RESULT ${name} ${JSON.stringify(result)}`
|
|
554
3732
|
});
|
|
555
3733
|
}
|
|
556
3734
|
}
|
|
3735
|
+
const records = [
|
|
3736
|
+
{
|
|
3737
|
+
ts: nowIso(),
|
|
3738
|
+
session_id: currentSessionId,
|
|
3739
|
+
round,
|
|
3740
|
+
role: "user",
|
|
3741
|
+
content: prompt
|
|
3742
|
+
},
|
|
3743
|
+
{
|
|
3744
|
+
ts: nowIso(),
|
|
3745
|
+
session_id: currentSessionId,
|
|
3746
|
+
round,
|
|
3747
|
+
role: "assistant",
|
|
3748
|
+
content: "max steps reached",
|
|
3749
|
+
tools: roundTools,
|
|
3750
|
+
files: roundFiles,
|
|
3751
|
+
urls: roundUrls,
|
|
3752
|
+
api_calls: roundApiCalls
|
|
3753
|
+
}
|
|
3754
|
+
];
|
|
3755
|
+
saveSessionRecords(round, records);
|
|
3756
|
+
sessionState.round = round;
|
|
3757
|
+
if (round % 3 === 0) {
|
|
3758
|
+
await writeSessionSummary(round - 2, round);
|
|
3759
|
+
}
|
|
557
3760
|
return "max steps reached";
|
|
558
3761
|
};
|
|
559
3762
|
|
|
@@ -567,10 +3770,20 @@ const runInteractive = async () => {
|
|
|
567
3770
|
const messages = [];
|
|
568
3771
|
const question = (q) => new Promise((resolve) => rl.question(q, resolve));
|
|
569
3772
|
console.log("9T 交互模式,输入 /exit 退出");
|
|
3773
|
+
startSession("interactive");
|
|
570
3774
|
while (true) {
|
|
571
3775
|
const input = String(await question("> ")).trim();
|
|
572
3776
|
if (!input) continue;
|
|
573
3777
|
if (input === "/exit" || input === "/quit") break;
|
|
3778
|
+
if (input.startsWith("/task") || input.startsWith("/tasks") || input.startsWith("/todo")) {
|
|
3779
|
+
try {
|
|
3780
|
+
const out = handleTaskCli(input);
|
|
3781
|
+
if (out) console.log(out);
|
|
3782
|
+
} catch (e) {
|
|
3783
|
+
console.error(String(e?.message || e));
|
|
3784
|
+
}
|
|
3785
|
+
continue;
|
|
3786
|
+
}
|
|
574
3787
|
if (input === "/tools" || input === "/?" || isToolHelpQuery(input)) {
|
|
575
3788
|
console.log(toolHelpText);
|
|
576
3789
|
continue;
|
|
@@ -583,14 +3796,44 @@ const runInteractive = async () => {
|
|
|
583
3796
|
console.log(aboutText);
|
|
584
3797
|
continue;
|
|
585
3798
|
}
|
|
3799
|
+
if (input === "/provider") {
|
|
3800
|
+
const currentLine = `current ${providerName} ${providerFormat} ${baseUrl} ${model}`;
|
|
3801
|
+
const list = formatProvidersList();
|
|
3802
|
+
console.log([currentLine, list].filter(Boolean).join("\n"));
|
|
3803
|
+
continue;
|
|
3804
|
+
}
|
|
3805
|
+
if (input === "/model" || input.startsWith("/model ")) {
|
|
3806
|
+
const parts = input.split(/\s+/).filter(Boolean);
|
|
3807
|
+
const name = normalizeProviderName(parts[1] || "");
|
|
3808
|
+
const nextModel = parts.slice(2).join(" ").trim();
|
|
3809
|
+
if (!name) throw new Error("provider required");
|
|
3810
|
+
applyProviderPreset(name, nextModel || "");
|
|
3811
|
+
apiKey = await ensureApiKey(providerName);
|
|
3812
|
+
console.log(formatCurrentProvider());
|
|
3813
|
+
continue;
|
|
3814
|
+
}
|
|
586
3815
|
if (input === "/keystatus") {
|
|
587
|
-
const envKey =
|
|
3816
|
+
const envKey = resolveProviderApiKeyEnv(providerName);
|
|
588
3817
|
if (envKey) {
|
|
589
3818
|
console.log("api_key 已通过环境变量提供");
|
|
590
3819
|
continue;
|
|
591
3820
|
}
|
|
592
|
-
const stored = await getStoredApiKey();
|
|
593
|
-
|
|
3821
|
+
const stored = await getStoredApiKey(buildKeychainService(providerName));
|
|
3822
|
+
if (stored) {
|
|
3823
|
+
console.log("api_key 已安全存储");
|
|
3824
|
+
continue;
|
|
3825
|
+
}
|
|
3826
|
+
const fallbackStored = await getStoredApiKey(keychainService);
|
|
3827
|
+
console.log(fallbackStored ? "api_key 已安全存储" : "api_key 未存储");
|
|
3828
|
+
continue;
|
|
3829
|
+
}
|
|
3830
|
+
if (input === "--test") {
|
|
3831
|
+
try {
|
|
3832
|
+
const out = await runPrompt(messages, testPrompt);
|
|
3833
|
+
if (out !== undefined && out !== null) console.log(out);
|
|
3834
|
+
} catch (e) {
|
|
3835
|
+
console.error(String(e?.message || e));
|
|
3836
|
+
}
|
|
594
3837
|
continue;
|
|
595
3838
|
}
|
|
596
3839
|
try {
|
|
@@ -601,33 +3844,182 @@ const runInteractive = async () => {
|
|
|
601
3844
|
}
|
|
602
3845
|
}
|
|
603
3846
|
rl.close();
|
|
3847
|
+
await finalizeSessionSummaries();
|
|
3848
|
+
appendSessionEvent({ event: "end" });
|
|
604
3849
|
};
|
|
605
3850
|
|
|
606
3851
|
const run = async () => {
|
|
607
3852
|
const args = process.argv.slice(2);
|
|
3853
|
+
const access = buildAccessConfig(args);
|
|
3854
|
+
accessMode = access.mode;
|
|
3855
|
+
workspaceRoot = normalizeRoot(access.workspace) || workspaceRoot;
|
|
3856
|
+
mkdirSync(workspaceRoot, { recursive: true });
|
|
3857
|
+
allowedRoots = [workspaceRoot];
|
|
3858
|
+
if (accessMode !== "strict") {
|
|
3859
|
+
allowedRoots = allowedRoots.concat(access.roots.map(normalizeRoot).filter(Boolean));
|
|
3860
|
+
}
|
|
3861
|
+
allowRisk = access.risk;
|
|
3862
|
+
allowRiskUntil = access.grants || {};
|
|
3863
|
+
allowRiskTtlHours = access.ttlHours || 0;
|
|
3864
|
+
executeAllowlist = access.execAllowlist.length
|
|
3865
|
+
? access.execAllowlist
|
|
3866
|
+
: normalizeListLower(defaultExecuteAllowlist);
|
|
3867
|
+
openUrlAllowlist = access.urlAllowlist.length
|
|
3868
|
+
? access.urlAllowlist
|
|
3869
|
+
: normalizeListLower(defaultOpenUrlAllowlist);
|
|
3870
|
+
openFileAllowlist = access.fileOpenAllowlist.length
|
|
3871
|
+
? access.fileOpenAllowlist
|
|
3872
|
+
: normalizeExtList(defaultOpenFileAllowlist);
|
|
3873
|
+
denyFileNames = access.denyNames.length
|
|
3874
|
+
? access.denyNames
|
|
3875
|
+
: normalizeListLower(defaultDenyFileNames);
|
|
3876
|
+
denyFileExts = access.denyExts.length
|
|
3877
|
+
? access.denyExts
|
|
3878
|
+
: normalizeExtList(defaultDenyFileExts);
|
|
3879
|
+
denyDirsAbs = normalizeAbsList(getDefaultDenyDirsAbs().concat(access.denyDirs || []));
|
|
3880
|
+
denyDirSegments = normalizeListLower(defaultDenyDirSegments);
|
|
3881
|
+
sandboxMode = access.sandbox || defaultSandboxMode();
|
|
3882
|
+
|
|
3883
|
+
if (
|
|
3884
|
+
access.cliProvided.mode ||
|
|
3885
|
+
access.cliProvided.roots ||
|
|
3886
|
+
access.cliProvided.workspace ||
|
|
3887
|
+
access.cliProvided.allowRisk ||
|
|
3888
|
+
access.cliProvided.allowRiskTtlHours ||
|
|
3889
|
+
access.cliProvided.executeAllowlist ||
|
|
3890
|
+
access.cliProvided.openUrlAllowlist ||
|
|
3891
|
+
access.cliProvided.openFileAllowlist ||
|
|
3892
|
+
access.cliProvided.denyFileNames ||
|
|
3893
|
+
access.cliProvided.denyFileExts ||
|
|
3894
|
+
access.cliProvided.denyDirs ||
|
|
3895
|
+
access.cliProvided.sandbox
|
|
3896
|
+
) {
|
|
3897
|
+
const rootsText = accessMode === "strict" ? [] : access.roots;
|
|
3898
|
+
const summary = [
|
|
3899
|
+
`mode=${accessMode}`,
|
|
3900
|
+
`workspace=${workspaceRoot}`,
|
|
3901
|
+
`roots=${rootsText.length ? rootsText.join(",") : "[]"}`,
|
|
3902
|
+
`allowRisk=${allowRisk.size ? Array.from(allowRisk).join(",") : "[]"}`,
|
|
3903
|
+
`allowRiskTtlHours=${allowRiskTtlHours || 0}`,
|
|
3904
|
+
`executeAllowlist=${executeAllowlist.join(",") || "[]"}`,
|
|
3905
|
+
`openUrlAllowlist=${openUrlAllowlist.join(",") || "[]"}`,
|
|
3906
|
+
`openFileAllowlist=${openFileAllowlist.join(",") || "[]"}`,
|
|
3907
|
+
`sandbox=${sandboxMode}`
|
|
3908
|
+
].join(" ");
|
|
3909
|
+
console.log(summary);
|
|
3910
|
+
}
|
|
608
3911
|
const interactive = hasFlag(args, "--interactive") || hasFlag(args, "-i");
|
|
609
3912
|
const testMode = hasFlag(args, "--test");
|
|
610
3913
|
const keyStatusMode = hasFlag(args, "--key-status");
|
|
611
|
-
const
|
|
612
|
-
|
|
613
|
-
);
|
|
3914
|
+
const taskLoopMode = hasFlag(args, "--task-loop");
|
|
3915
|
+
const scheduleLoopMode = hasFlag(args, "--schedule-loop");
|
|
3916
|
+
const daemonLoopMode = hasFlag(args, "--daemon-loop");
|
|
3917
|
+
const loopMode = hasFlag(args, "--loop");
|
|
3918
|
+
const filteredArgs = [];
|
|
3919
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
3920
|
+
const arg = args[i];
|
|
3921
|
+
if (arg === "--interactive" || arg === "-i" || arg === "--test" || arg === "--key-status") {
|
|
3922
|
+
continue;
|
|
3923
|
+
}
|
|
3924
|
+
if (
|
|
3925
|
+
arg === "--loop" ||
|
|
3926
|
+
arg === "--task-loop" ||
|
|
3927
|
+
arg === "--schedule-loop" ||
|
|
3928
|
+
arg === "--daemon-loop" ||
|
|
3929
|
+
arg === "--loop-every" ||
|
|
3930
|
+
arg === "--loop-max-runs" ||
|
|
3931
|
+
arg === "--loop-max-duration" ||
|
|
3932
|
+
arg === "--loop-retry" ||
|
|
3933
|
+
arg === "--loop-retry-delay" ||
|
|
3934
|
+
arg === "--loop-max-retry-delay" ||
|
|
3935
|
+
arg === "--loop-backoff" ||
|
|
3936
|
+
arg === "--mode" ||
|
|
3937
|
+
arg === "--allow-mode" ||
|
|
3938
|
+
arg === "--roots" ||
|
|
3939
|
+
arg === "--workspace" ||
|
|
3940
|
+
arg === "--allow-risk" ||
|
|
3941
|
+
arg === "--allow-risk-ttl-hours" ||
|
|
3942
|
+
arg === "--execute-allowlist" ||
|
|
3943
|
+
arg === "--open-url-allowlist" ||
|
|
3944
|
+
arg === "--open-file-allowlist" ||
|
|
3945
|
+
arg === "--deny-file-names" ||
|
|
3946
|
+
arg === "--deny-file-exts" ||
|
|
3947
|
+
arg === "--deny-dirs" ||
|
|
3948
|
+
arg === "--sandbox"
|
|
3949
|
+
) {
|
|
3950
|
+
i += 1;
|
|
3951
|
+
continue;
|
|
3952
|
+
}
|
|
3953
|
+
if (arg === "--loop-stop-on-fail") {
|
|
3954
|
+
continue;
|
|
3955
|
+
}
|
|
3956
|
+
filteredArgs.push(arg);
|
|
3957
|
+
}
|
|
614
3958
|
await migratePlaintextApiKey();
|
|
615
3959
|
if (keyStatusMode) {
|
|
616
|
-
const envKey =
|
|
3960
|
+
const envKey = resolveProviderApiKeyEnv(providerName);
|
|
617
3961
|
if (envKey) {
|
|
618
3962
|
console.log("api_key 已通过环境变量提供");
|
|
619
3963
|
return;
|
|
620
3964
|
}
|
|
621
|
-
const stored = await getStoredApiKey();
|
|
622
|
-
|
|
3965
|
+
const stored = await getStoredApiKey(buildKeychainService(providerName));
|
|
3966
|
+
if (stored) {
|
|
3967
|
+
console.log("api_key 已安全存储");
|
|
3968
|
+
return;
|
|
3969
|
+
}
|
|
3970
|
+
const fallbackStored = await getStoredApiKey(keychainService);
|
|
3971
|
+
console.log(fallbackStored ? "api_key 已安全存储" : "api_key 未存储");
|
|
3972
|
+
return;
|
|
3973
|
+
}
|
|
3974
|
+
|
|
3975
|
+
const resolveLoopPrompt = (flag) => {
|
|
3976
|
+
const raw = getArgValue(args, flag);
|
|
3977
|
+
return (raw || filteredArgs.join(" ")).trim();
|
|
3978
|
+
};
|
|
3979
|
+
|
|
3980
|
+
if (taskLoopMode) {
|
|
3981
|
+
const loopPrompt = resolveLoopPrompt("--task-loop");
|
|
3982
|
+
if (!loopPrompt) throw new Error("loop prompt required");
|
|
3983
|
+
const options = buildLoopOptions(args);
|
|
3984
|
+
startSession("task_loop");
|
|
3985
|
+
await runTaskLoop(loopPrompt, { ...options, title: loopPrompt });
|
|
3986
|
+
return;
|
|
3987
|
+
}
|
|
3988
|
+
|
|
3989
|
+
if (scheduleLoopMode) {
|
|
3990
|
+
const loopPrompt = resolveLoopPrompt("--schedule-loop");
|
|
3991
|
+
if (!loopPrompt) throw new Error("loop prompt required");
|
|
3992
|
+
const options = buildLoopOptions(args);
|
|
3993
|
+
startSession("schedule_loop");
|
|
3994
|
+
await runLoop(loopPrompt, options);
|
|
3995
|
+
return;
|
|
3996
|
+
}
|
|
3997
|
+
|
|
3998
|
+
if (daemonLoopMode) {
|
|
3999
|
+
const loopPrompt = resolveLoopPrompt("--daemon-loop");
|
|
4000
|
+
if (!loopPrompt) throw new Error("loop prompt required");
|
|
4001
|
+
const options = buildLoopOptions(args);
|
|
4002
|
+
startSession("daemon_loop");
|
|
4003
|
+
await runLoop(loopPrompt, options);
|
|
4004
|
+
return;
|
|
4005
|
+
}
|
|
4006
|
+
|
|
4007
|
+
if (loopMode) {
|
|
4008
|
+
const loopPrompt = resolveLoopPrompt("--loop");
|
|
4009
|
+
if (!loopPrompt) throw new Error("loop prompt required");
|
|
4010
|
+
const options = buildLoopOptions(args);
|
|
4011
|
+
startSession("loop");
|
|
4012
|
+
await runLoop(loopPrompt, options);
|
|
623
4013
|
return;
|
|
624
4014
|
}
|
|
625
|
-
apiKey = await ensureApiKey();
|
|
626
4015
|
|
|
627
4016
|
if (testMode) {
|
|
4017
|
+
startSession("test");
|
|
628
4018
|
const messages = [];
|
|
629
4019
|
const out = await runPrompt(messages, testPrompt);
|
|
630
4020
|
if (out !== undefined && out !== null) console.log(out);
|
|
4021
|
+
await finalizeSessionSummaries();
|
|
4022
|
+
appendSessionEvent({ event: "end" });
|
|
631
4023
|
return;
|
|
632
4024
|
}
|
|
633
4025
|
|
|
@@ -641,9 +4033,28 @@ const run = async () => {
|
|
|
641
4033
|
await runInteractive();
|
|
642
4034
|
return;
|
|
643
4035
|
}
|
|
4036
|
+
if (prompt === "/provider") {
|
|
4037
|
+
const currentLine = `current ${providerName} ${providerFormat} ${baseUrl} ${model}`;
|
|
4038
|
+
const list = formatProvidersList();
|
|
4039
|
+
console.log([currentLine, list].filter(Boolean).join("\n"));
|
|
4040
|
+
return;
|
|
4041
|
+
}
|
|
4042
|
+
if (prompt === "/model" || prompt.startsWith("/model ")) {
|
|
4043
|
+
const parts = prompt.split(/\s+/).filter(Boolean);
|
|
4044
|
+
const name = normalizeProviderName(parts[1] || "");
|
|
4045
|
+
const nextModel = parts.slice(2).join(" ").trim();
|
|
4046
|
+
if (!name) throw new Error("provider required");
|
|
4047
|
+
applyProviderPreset(name, nextModel || "");
|
|
4048
|
+
apiKey = await ensureApiKey(providerName);
|
|
4049
|
+
console.log(formatCurrentProvider());
|
|
4050
|
+
return;
|
|
4051
|
+
}
|
|
644
4052
|
const messages = [];
|
|
4053
|
+
startSession("single");
|
|
645
4054
|
const out = await runPrompt(messages, prompt);
|
|
646
4055
|
if (out !== undefined && out !== null) console.log(out);
|
|
4056
|
+
await finalizeSessionSummaries();
|
|
4057
|
+
appendSessionEvent({ event: "end" });
|
|
647
4058
|
};
|
|
648
4059
|
|
|
649
4060
|
run().catch((e) => {
|