@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 CHANGED
@@ -1,84 +1,1742 @@
1
- import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, rmSync, existsSync } from "fs";
2
- import { join, resolve, dirname, isAbsolute, sep } from "path";
3
- import { exec, execFile } from "child_process";
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
- const workspaceRoot = join(rootDir, "workspace");
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
- { name: "open", args: "{ target }", desc: "打开文件/URL" }
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 SYSTEM_PROMPT = `你是 9T 工具代理。你只能输出 JSON,不要输出其它文字。
27
- 格式:
28
- {"tool":"create","args":{...}} 或 {"tools":[{"tool":"create","args":{...}}, ...]} 或 {"final":"..."}
29
- 可用工具与参数:
30
- ${systemToolsText}
31
- 所有 path 必须为相对 workspace 的路径,不要使用绝对路径或 ..`;
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 testPrompt = "使用9T工具创建文件夹 demo ,在其中创建 test.md,内容为:山高月小,水落石出。";
36
- const toolHelpText = `可用工具与用途:${helpToolsText}`;
37
- const aboutText =
38
- "9T-Movevom 是一个最小可用的 CLI 工具代理,使用 ChatGLM(glm-5)通过工具调用完成文件与命令操作。默认工作区为 Movevom/workspace。";
39
- const helpText =
40
- "可用指令:/help /about /tools /? /exit /keystatus。输入自然语言任务可让 9T 调用工具执行。";
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 getConfigDir = () => {
43
- const custom = process.env["9T_CONFIG_HOME"];
44
- if (custom) return custom;
45
- const home = homedir();
46
- if (process.platform === "darwin") return join(home, "Library", "Application Support", "9T");
47
- if (process.platform === "win32") {
48
- const appData = process.env["APPDATA"] || join(home, "AppData", "Roaming");
49
- return join(appData, "9T");
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
- return join(process.env["XDG_CONFIG_HOME"] || join(home, ".config"), "9t");
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 providerPath = join(getConfigDir(), "provider.json");
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 defaultProvider = {
57
- base_url: "https://open.bigmodel.cn/api/anthropic",
58
- model: "glm-5"
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 loadProviderConfig = () => {
62
- if (existsSync(providerPath)) {
63
- const raw = readFileSync(providerPath, "utf-8");
64
- return JSON.parse(raw);
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
- if (existsSync(legacyProviderPath)) {
67
- const raw = readFileSync(legacyProviderPath, "utf-8");
68
- return JSON.parse(raw);
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 { ...defaultProvider };
1613
+ return "";
71
1614
  };
72
1615
 
73
- const saveProviderConfig = (cfg) => {
74
- mkdirSync(dirname(providerPath), { recursive: true });
75
- const safe = { base_url: cfg.base_url, model: cfg.model };
76
- writeFileSync(providerPath, JSON.stringify(safe, null, 2));
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
- const baseUrl = String(config.base_url || defaultProvider.base_url).trim().replace(/\/$/, "");
81
- const model = String(config.model || defaultProvider.model).trim();
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
- keychainService,
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
- keychainService,
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
- keychainService,
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", keychainService, "service", keychainService, "account", keychainAccount],
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 = "${keychainService}";
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 = "${keychainService}";
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 ensureApiKey = async () => {
289
- const envKey = String(process.env["9T_API_KEY"] || "").trim();
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 keychainKey = await getStoredApiKey();
1985
+ const service = buildKeychainService(provider);
1986
+ const keychainKey = await getStoredApiKey(service);
292
1987
  if (keychainKey) return keychainKey;
293
- const input = await promptHidden("apikey: ");
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
- await saveStoredApiKey(fromFile);
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
- mkdirSync(workspaceRoot, { recursive: true });
314
- saveProviderConfig({ base_url: baseUrl, model });
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
- if (isAbsolute(p)) throw new Error("absolute path not allowed");
319
- const full = resolve(workspaceRoot, p);
320
- if (full !== workspaceRoot && !full.startsWith(workspaceRoot + sep)) {
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(/&nbsp;/gi, " ");
2153
+ text = text.replace(/&amp;/gi, "&");
2154
+ text = text.replace(/&lt;/gi, "<");
2155
+ text = text.replace(/&gt;/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 toolGrep = (args) => {
394
- const base = resolveWorkspacePath(args?.path || ".");
395
- const pattern = String(args.pattern || "");
396
- if (!pattern) throw new Error("pattern required");
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
- regex = new RegExp(pattern, "g");
2891
+ parsed = new URL(url);
400
2892
  } catch {
401
- const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
402
- regex = new RegExp(escaped, "g");
2893
+ throw new Error("url invalid");
403
2894
  }
404
- const files = [];
405
- walkFiles(base, files);
406
- const matches = [];
407
- for (const f of files) {
408
- const content = readFileSync(f, "utf-8");
409
- const lines = content.split(/\r?\n/);
410
- lines.forEach((line, i) => {
411
- if (regex.test(line)) {
412
- matches.push({ file: f.replace(workspaceRoot + sep, ""), line: i + 1, text: line });
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
- regex.lastIndex = 0;
415
- });
2917
+ },
2918
+ timeoutMs
2919
+ );
2920
+ if (!res.ok) {
2921
+ throw createToolError("WEB_FETCH_FAILED", "web_fetch failed", { status: res.status });
416
2922
  }
417
- return { matches };
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 globToRegex = (pattern) => {
421
- const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
422
- const regex = escaped
423
- .replace(/\*\*/g, "###DOUBLESTAR###")
424
- .replace(/\*/g, "[^/]*")
425
- .replace(/\?/g, ".");
426
- const withDouble = regex.replace(/###DOUBLESTAR###/g, ".*");
427
- return new RegExp("^" + withDouble + "$");
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 toolGlob = (args) => {
431
- const base = resolveWorkspacePath(args?.path || ".");
432
- const pattern = String(args.pattern || "");
433
- if (!pattern) throw new Error("pattern required");
434
- const files = [];
435
- walkFiles(base, files);
436
- const regex = globToRegex(pattern);
437
- const matches = files
438
- .map((f) => f.replace(workspaceRoot + sep, ""))
439
- .filter((p) => regex.test(p));
440
- return { matches };
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 toolExecute = (args) =>
444
- new Promise((resolvePromise, reject) => {
445
- const cmd = String(args.cmd || "");
446
- if (!cmd) return reject(new Error("cmd required"));
447
- const cwd = args.cwd ? resolveWorkspacePath(args.cwd) : workspaceRoot;
448
- exec(cmd, { cwd, maxBuffer: 1024 * 1024 }, (err, stdout, stderr) => {
449
- if (err) {
450
- resolvePromise({ ok: false, code: err.code || 1, stdout, stderr });
451
- } else {
452
- resolvePromise({ ok: true, code: 0, stdout, stderr });
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 toolOpen = (args) =>
3061
+ const runOpenCommand = (cmd, cwd, useSandbox) =>
458
3062
  new Promise((resolvePromise, reject) => {
459
- const target = String(args.target || "");
460
- if (!target) return reject(new Error("target required"));
461
- const platform = process.platform;
462
- let cmd = "";
463
- if (platform === "darwin") cmd = `open "${target}"`;
464
- else if (platform === "win32") cmd = `start "" "${target}"`;
465
- else cmd = `xdg-open "${target}"`;
466
- exec(cmd, (err, stdout, stderr) => {
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
- open: toolOpen
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 callModel = async (messages) => {
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: 1024,
3285
+ max_tokens: maxTokens,
512
3286
  temperature: 0,
513
- system: SYSTEM_PROMPT,
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
- const text = content.map((c) => c.text || "").join("").trim();
524
- return text;
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
- messages.push({ role: "user", content: prompt });
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(messages);
531
- messages.push({ role: "assistant", content: text });
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
- if (!fn) throw new Error(`unknown tool: ${name}`);
550
- const result = await fn(args);
551
- messages.push({
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 = String(process.env["9T_API_KEY"] || "").trim();
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
- console.log(stored ? "api_key 已安全存储" : "api_key 未存储");
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 filteredArgs = args.filter(
612
- (a) => a !== "--interactive" && a !== "-i" && a !== "--test" && a !== "--key-status"
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 = String(process.env["9T_API_KEY"] || "").trim();
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
- console.log(stored ? "api_key 已安全存储" : "api_key 未存储");
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) => {