mingdao-harness 0.2.4 → 0.2.5

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.
Files changed (61) hide show
  1. package/docs/ROADMAP-NEXT.md +5 -5
  2. package/package.json +1 -1
  3. package/src/agent.js +19 -19
  4. package/src/audit.js +2 -2
  5. package/src/batch.js +7 -7
  6. package/src/cachestats.js +9 -9
  7. package/src/cli.js +29 -679
  8. package/src/commands/desktop.js +1 -1
  9. package/src/commands/key.js +1 -1
  10. package/src/commands/repl.js +711 -0
  11. package/src/commands/schedule.js +7 -7
  12. package/src/commands/skill.js +18 -16
  13. package/src/commands/sync.js +5 -5
  14. package/src/commands/update.js +7 -6
  15. package/src/commands/workspace.js +2 -2
  16. package/src/compact.js +6 -6
  17. package/src/config.js +7 -4
  18. package/src/context.js +11 -11
  19. package/src/credentials.js +6 -0
  20. package/src/hooks.js +11 -11
  21. package/src/mcp-presets.js +5 -5
  22. package/src/mcp.js +19 -19
  23. package/src/memory.js +16 -16
  24. package/src/model-discovery.js +16 -15
  25. package/src/models.js +4 -4
  26. package/src/notify.js +2 -2
  27. package/src/permissions.js +7 -3
  28. package/src/pricing.js +35 -1
  29. package/src/prompts.js +1 -1
  30. package/src/providers/index.js +7 -7
  31. package/src/providers/openai-compatible.js +8 -8
  32. package/src/routing.js +5 -5
  33. package/src/schedule.js +28 -28
  34. package/src/session-index.js +5 -1
  35. package/src/session.js +11 -2
  36. package/src/skill-lib.js +62 -3
  37. package/src/skill-registry.js +10 -9
  38. package/src/skills.js +10 -10
  39. package/src/sync-server.js +86 -3
  40. package/src/sync.js +53 -20
  41. package/src/tasks/worker.js +126 -0
  42. package/src/tasks.js +13 -13
  43. package/src/titles.js +5 -5
  44. package/src/tokenizer.js +22 -4
  45. package/src/tools/bash.js +12 -12
  46. package/src/tools/fs-tools.js +66 -9
  47. package/src/tools/index.js +7 -7
  48. package/src/ui.js +83 -6
  49. package/src/update.js +8 -8
  50. package/src/web/attachments.js +1 -1
  51. package/src/web/routes/api.js +38 -786
  52. package/src/web/routes/domains/config.js +280 -0
  53. package/src/web/routes/domains/misc.js +128 -0
  54. package/src/web/routes/domains/schedule.js +127 -0
  55. package/src/web/routes/domains/sessions.js +105 -0
  56. package/src/web/routes/domains/skills.js +79 -0
  57. package/src/web/routes/domains/sync.js +101 -0
  58. package/src/web/routes/domains/workspace.js +113 -0
  59. package/src/web/server.js +29 -14
  60. package/src/web/web-io.js +54 -12
  61. package/src/workspace.js +14 -14
@@ -0,0 +1,79 @@
1
+ // 技能域(Phase C C1):/api/skills /api/skill-library /api/mcp-presets
2
+ // 技能列表/安装/卸载、内置技能库 + 线上注册表搜索、MCP 生态预设接入。
3
+ import { listSkills, tamperedSkillNames } from '../../../skills.js';
4
+ import { libraryList, installSkill, uninstallSkill, installedUserSkillNames } from '../../../skill-lib.js';
5
+ import { presetList, buildPreset } from '../../../mcp-presets.js';
6
+ import { saveConfig } from '../../../config.js';
7
+
8
+ /**
9
+ * 技能域路由。命中返回 true,未命中返回 false。
10
+ * @param {{req:any,res:any,method:any,p:any,url:any}} ctx
11
+ * @param {any} deps
12
+ * @param {{json:any,readBody:any,MAX_API_BODY:any}} shared
13
+ */
14
+ export async function handle({ req, res, method, p, url }, deps, shared) {
15
+ const { json, readBody, MAX_API_BODY } = shared;
16
+ const { cfg, state } = deps;
17
+
18
+ if (method === 'GET' && p === '/api/skills') {
19
+ json(res, 200, { ok: true, skills: listSkills(state.workingDir), tampered: tamperedSkillNames(state.workingDir) });
20
+ return true;
21
+ }
22
+
23
+ if (method === 'GET' && p === '/api/mcp-presets') {
24
+ json(res, 200, { ok: true, presets: presetList() });
25
+ return true;
26
+ }
27
+
28
+ if (method === 'POST' && p === '/api/mcp-presets') {
29
+ const body = await readBody(req, MAX_API_BODY);
30
+ const name = String(body.name || '').trim();
31
+ const r = buildPreset(name, body.arg, state.workingDir);
32
+ if (r.error) return json(res, 400, { error: r.error });
33
+ cfg.mcpServers = cfg.mcpServers || {};
34
+ cfg.mcpServers[name] = r.config;
35
+ saveConfig(cfg);
36
+ json(res, 200, { ok: true, name, note: '重启 mingdao web 后生效(/mcp 查看状态)' });
37
+ return true;
38
+ }
39
+
40
+ if (method === 'GET' && p === '/api/skill-library') {
41
+ const q = url.searchParams.get('q') || '';
42
+ const force = url.searchParams.get('refresh') === '1';
43
+ const local = q
44
+ ? libraryList().filter((s) => s.name.includes(q) || (s.description || '').includes(q))
45
+ : libraryList();
46
+ const { searchRegistry } = await import('../../../skill-registry.js');
47
+ const remote = await searchRegistry(q || '', { force, allowNetwork: force });
48
+ const localNames = new Set(local.map((s) => s.name));
49
+ const registryEntries = remote.skills
50
+ ? remote.skills.filter((/** @type {any} */ s) => !localNames.has(s.name)).map((/** @type {any} */ s) => ({ ...s, dir: null }))
51
+ : [];
52
+ json(res, 200, {
53
+ ok: true,
54
+ library: local.map((s) => ({ name: s.name, description: s.description, source: 'builtin-lib', installed: s.installed })).concat(
55
+ registryEntries.map((/** @type {any} */ s) => ({ name: s.name, description: s.description, source: 'registry', installed: s.installed }))
56
+ ),
57
+ installed: [...installedUserSkillNames()],
58
+ registry: remote.error ? { error: remote.error } : { host: remote.host, updatedAt: remote.updatedAt, stale: remote.stale || false },
59
+ });
60
+ return true;
61
+ }
62
+
63
+ if (method === 'POST' && p === '/api/skills') {
64
+ const body = await readBody(req, MAX_API_BODY);
65
+ if (body.action === 'install') {
66
+ const r = /** @type {any} */ (await installSkill(body.arg || body.name || ''));
67
+ if (r.error) return json(res, 400, { error: r.error });
68
+ return json(res, 200, { ok: true, name: r.name, names: r.names });
69
+ }
70
+ if (body.action === 'uninstall') {
71
+ const r = uninstallSkill(body.name);
72
+ if (r.error) return json(res, 400, { error: r.error });
73
+ return json(res, 200, { ok: true, name: r.name });
74
+ }
75
+ return json(res, 400, { error: '未知操作:install|uninstall' });
76
+ }
77
+
78
+ return false;
79
+ }
@@ -0,0 +1,101 @@
1
+ // 同步域(Phase C C1):/api/sync /api/sync-conflicts
2
+ // 云同步登录/登出/推拉/改密/分享与冲突处理。
3
+ import {
4
+ syncStatus,
5
+ syncLogin,
6
+ syncLogout,
7
+ syncPush,
8
+ syncPull,
9
+ syncRemoteList,
10
+ syncChangePassword,
11
+ syncShareCreate,
12
+ syncShareList,
13
+ syncShareAccept,
14
+ syncShareRevoke,
15
+ listSyncConflicts,
16
+ resolveSyncConflict,
17
+ } from '../../../sync.js';
18
+
19
+ /**
20
+ * 同步域路由。命中返回 true,未命中返回 false。
21
+ * @param {{req:any,res:any,method:any,p:any,url:any}} ctx
22
+ * @param {any} deps
23
+ * @param {{json:any,readBody:any,MAX_API_BODY:any,validateRemoteUrl:any}} shared
24
+ */
25
+ export async function handle({ req, res, method, p, url }, deps, shared) {
26
+ const { json, readBody, MAX_API_BODY, validateRemoteUrl } = shared;
27
+
28
+ if (method === 'GET' && p === '/api/sync') {
29
+ const st = syncStatus();
30
+ let remote = null;
31
+ if (st.loggedIn) {
32
+ const r = await syncRemoteList();
33
+ remote = r.ok ? { sessions: r.sessions.slice(0, 50) } : { error: r.error };
34
+ }
35
+ json(res, 200, { ok: true, ...st, remote });
36
+ return true;
37
+ }
38
+
39
+ if (method === 'POST' && p === '/api/sync') {
40
+ const body = await readBody(req, MAX_API_BODY);
41
+ if (body.action === 'login') {
42
+ const vurl = validateRemoteUrl(body.url); // 质检 S1:SSRF 防护(同步端点同样只允许公网)
43
+ if (vurl.error) return json(res, 400, { error: vurl.error });
44
+ const r = await syncLogin({ url: body.url, username: body.username, password: body.password, deviceName: body.deviceName });
45
+ if (r.error) return json(res, 400, { error: r.error });
46
+ return json(res, 200, { ok: true, ...r });
47
+ }
48
+ if (body.action === 'logout') {
49
+ syncLogout();
50
+ return json(res, 200, { ok: true });
51
+ }
52
+ if (body.action === 'push') {
53
+ const r = /** @type {any} */ (await syncPush(body.name));
54
+ if (r.error) return json(res, 400, { error: r.error });
55
+ return json(res, 200, { ok: true, pushed: r.pushed.length, conflicts: r.conflicts });
56
+ }
57
+ if (body.action === 'pull') {
58
+ const r = /** @type {any} */ (await syncPull(body.name));
59
+ if (r.error) return json(res, 400, { error: r.error });
60
+ return json(res, 200, { ok: true, pulled: r.pulled.length, conflicts: r.conflicts });
61
+ }
62
+ if (body.action === 'passwd') {
63
+ const r = await syncChangePassword({ oldPassword: body.oldPassword, newPassword: body.newPassword });
64
+ if (r.error) return json(res, 400, { error: r.error });
65
+ return json(res, 200, { ok: true });
66
+ }
67
+ if (body.action === 'share') {
68
+ const r = await syncShareCreate(body.name);
69
+ if (r.error) return json(res, 400, { error: r.error });
70
+ return json(res, 200, { ok: true, shareId: r.shareId, name: r.name });
71
+ }
72
+ if (body.action === 'shares') {
73
+ const r = await syncShareList();
74
+ if (r.error) return json(res, 400, { error: r.error });
75
+ return json(res, 200, { ok: true, mine: r.mine, accepted: r.accepted });
76
+ }
77
+ if (body.action === 'accept') {
78
+ const r = await syncShareAccept(body.shareId);
79
+ if (r.error) return json(res, 400, { error: r.error });
80
+ return json(res, 200, { ok: true, ...r });
81
+ }
82
+ if (body.action === 'unshare') {
83
+ const r = await syncShareRevoke(body.shareId);
84
+ if (r.error) return json(res, 400, { error: r.error });
85
+ return json(res, 200, { ok: true });
86
+ }
87
+ if (body.action === 'resolveConflict') {
88
+ const r = resolveSyncConflict(body.base, body.choice);
89
+ if (r.error) return json(res, 400, { error: r.error });
90
+ return json(res, 200, { ok: true, ...r });
91
+ }
92
+ return json(res, 400, { error: '未知操作:login|logout|push|pull|passwd|share|shares|accept|unshare|resolveConflict' });
93
+ }
94
+
95
+ if (method === 'GET' && p === '/api/sync-conflicts') {
96
+ json(res, 200, { ok: true, conflicts: listSyncConflicts() });
97
+ return true;
98
+ }
99
+
100
+ return false;
101
+ }
@@ -0,0 +1,113 @@
1
+ // 工作空间域(Phase C C1):/api/workspaces /api/fs-browse
2
+ // 工作空间登记/切换/重命名/删除;受限目录浏览(基目录白名单)。
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import {
7
+ currentWorkspace,
8
+ listWorkspaces,
9
+ addWorkspace,
10
+ removeWorkspace,
11
+ renameWorkspace,
12
+ setWorkspaceDir,
13
+ workspacePath,
14
+ touchWorkspace,
15
+ setSessionWorkspace,
16
+ } from '../../../workspace.js';
17
+
18
+ /**
19
+ * 工作空间域路由。命中返回 true,未命中返回 false。
20
+ * @param {{req:any,res:any,method:any,p:any,url:any}} ctx
21
+ * @param {any} deps
22
+ * @param {{json:any,readBody:any,MAX_API_BODY:any}} shared
23
+ */
24
+ export async function handle({ req, res, method, p, url }, deps, shared) {
25
+ const { json, readBody, MAX_API_BODY } = shared;
26
+ const { cfg, state, startupCwd } = deps;
27
+
28
+ if (method === 'GET' && p === '/api/workspaces') {
29
+ json(res, 200, { ok: true, workspaces: listWorkspaces(), current: currentWorkspace(state.workingDir)?.name || null, cwd: state.workingDir });
30
+ return true;
31
+ }
32
+
33
+ if (method === 'POST' && p === '/api/workspaces') {
34
+ const body = await readBody(req, MAX_API_BODY);
35
+ const name = String(body.name || '').trim();
36
+ if (body.action === 'add') {
37
+ if (!name) return json(res, 400, { error: '名称不能为空' });
38
+ // 目录为空/不存在时自动新建(默认开,create:false 关闭)
39
+ const target = path.resolve(body.dir || state.workingDir);
40
+ if (body.create !== false) {
41
+ try {
42
+ fs.mkdirSync(target, { recursive: true });
43
+ } catch (/** @type {any} */ e) {
44
+ return json(res, 400, { error: `无法创建目录:${target}(${e.message})` });
45
+ }
46
+ }
47
+ const r = addWorkspace(name, target);
48
+ if (r.error) return json(res, 400, { error: r.error });
49
+ return json(res, 200, { ok: true, name: r.name, dir: r.dir, created: body.create !== false });
50
+ }
51
+ if (body.action === 'rename') {
52
+ const r = renameWorkspace(name, body.newName);
53
+ if (r.error) return json(res, 400, { error: r.error });
54
+ return json(res, 200, { ok: true, name: r.name });
55
+ }
56
+ if (body.action === 'set') {
57
+ // 切换全局工作空间(新会话默认目录;可带 dir 修改目录);目录缺失自动重建。
58
+ // 携带 file 时同时把当前会话的工作空间切过去(P3-4:会话跟随显式切换)。
59
+ if (body.dir) {
60
+ const r = setWorkspaceDir(name, body.dir);
61
+ if (r.error) return json(res, 400, { error: r.error });
62
+ }
63
+ const dir = workspacePath(name);
64
+ if (!dir) return json(res, 400, { error: `工作空间 ${name} 不存在` });
65
+ try {
66
+ fs.mkdirSync(dir, { recursive: true });
67
+ } catch (/** @type {any} */ e) {
68
+ return json(res, 400, { error: `无法创建目录:${dir}(${e.message})` });
69
+ }
70
+ touchWorkspace(name);
71
+ state.workingDir = dir;
72
+ // 不再 process.chdir:运行中任务的 cwd 在创建时已固定,全局切换只影响新会话
73
+ if (body.file) setSessionWorkspace(String(body.file), dir, /** @type {any} */ (name));
74
+ return json(res, 200, { ok: true, name, dir, current: name });
75
+ }
76
+ if (body.action === 'remove') {
77
+ if (!name) return json(res, 400, { error: '缺少名称' });
78
+ return json(res, 200, { ok: removeWorkspace(name) });
79
+ }
80
+ return json(res, 400, { error: '未知操作:add|rename|set|remove' });
81
+ }
82
+
83
+ // 目录浏览器(「新建工作空间」选择电脑磁盘目录用):本机运行时即用户电脑的目录树;
84
+ // 只列子目录(不含隐藏目录),供前端逐级导航选择
85
+ if (method === 'GET' && p === '/api/fs-browse') {
86
+ let dir = String(url.searchParams.get('dir') || '').trim();
87
+ if (!path.isAbsolute(dir)) return json(res, 400, { error: '需要绝对路径' });
88
+ // 质检 A3:目录浏览限定基目录(默认家目录 + config.web.browseRoots 显式授权),
89
+ // 拒绝越界——此前可枚举服务器任意绝对路径(整机目录结构探测面)
90
+ const browseRoots = [os.homedir(), startupCwd, state.workingDir, ...(Array.isArray(cfg.web?.browseRoots) ? cfg.web.browseRoots : [])].map((r) => path.resolve(String(r)));
91
+ const inRoot = browseRoots.some((r) => dir === r || dir.startsWith(r + path.sep));
92
+ if (!inRoot) return json(res, 403, { error: '目录不在可浏览范围内(家目录或 web.browseRoots 授权目录)' });
93
+ try {
94
+ const st = fs.statSync(dir);
95
+ if (!st.isDirectory()) return json(res, 400, { error: '不是目录' });
96
+ const entries = fs
97
+ .readdirSync(dir, { withFileTypes: true })
98
+ .filter((e) => e.isDirectory() && !e.name.startsWith('.'))
99
+ .map((e) => ({ name: e.name, path: path.join(dir, e.name) }))
100
+ .sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'))
101
+ .slice(0, 300);
102
+ let parent = path.dirname(dir);
103
+ // 父目录同样不得越出基目录
104
+ if (!browseRoots.some((r) => parent === r || parent.startsWith(r + path.sep))) parent = /** @type {any} */ (null);
105
+ json(res, 200, { ok: true, path: dir, parent: parent === dir ? null : parent, entries });
106
+ } catch (/** @type {any} */ err) {
107
+ json(res, 400, { error: String(err?.message || err) });
108
+ }
109
+ return true;
110
+ }
111
+
112
+ return false;
113
+ }
package/src/web/server.js CHANGED
@@ -64,14 +64,17 @@ import { syncStatus, syncLogin, syncLogout, syncPush, syncPull, syncRemoteList,
64
64
 
65
65
  const INDEX_HTML = path.join(path.dirname(fileURLToPath(import.meta.url)), 'index.html');
66
66
 
67
+ /** @param {any} res @param {any} code @param {any} obj */
67
68
  function json(res, code, obj) {
68
69
  res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
69
70
  res.end(JSON.stringify(obj));
70
71
  }
71
72
 
73
+ /** @param {any} req */
72
74
  function readBody(req) {
73
75
  const MAX_BODY = 40 * 1024 * 1024; // 附件 base64 上限(4×5MB 图片 + 文本)留余量
74
76
  return new Promise((resolve, reject) => {
77
+ /** @type {any[]} */
75
78
  const chunks = [];
76
79
  let size = 0;
77
80
  // 审计 P2-6:慢速连接防护——60s 未传完请求体即断开,防占满 socket
@@ -83,7 +86,7 @@ function readBody(req) {
83
86
  }, 60000);
84
87
  req.on('end', () => clearTimeout(slowTimer));
85
88
  req.on('close', () => clearTimeout(slowTimer));
86
- req.on('data', (d) => {
89
+ req.on('data', (/** @type {any} */ d) => {
87
90
  size += d.length;
88
91
  if (size > MAX_BODY) {
89
92
  const err = /** @type {Error & { status?: number }} */ (new Error('请求体过大(>40MB)'));
@@ -117,6 +120,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
117
120
  }
118
121
  // 访问令牌(P1-3):非回环绑定时强制——配置的 token / 启动参数 / 环境变量优先,
119
122
  // 都没有则本次随机生成并打印带 token 的访问地址。回环绑定且未配置时保持无认证(本机信任)。
123
+ /** @param {any} h */
120
124
  const isLoopbackHost = (h) => h === '127.0.0.1' || h === 'localhost' || h === '::1';
121
125
  let webToken = String(authToken || cfg?.web?.token || '').trim() || null;
122
126
  let tokenGenerated = false;
@@ -125,6 +129,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
125
129
  tokenGenerated = true;
126
130
  }
127
131
  const authEnabled = Boolean(webToken);
132
+ /** @param {any} got */
128
133
  const tokenMatches = (got) => {
129
134
  if (!webToken) return true;
130
135
  if (!got) return false;
@@ -132,6 +137,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
132
137
  const b = Buffer.from(webToken);
133
138
  return a.length === b.length && crypto.timingSafeEqual(a, b);
134
139
  };
140
+ /** @param {any} req @param {any} url */
135
141
  const requestToken = (req, url) => {
136
142
  const q = url.searchParams.get('token');
137
143
  if (q) return q;
@@ -143,6 +149,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
143
149
  // Host 白名单(P1-4,防 DNS rebinding):只接受回环名 + 绑定地址;
144
150
  // 绑定 0.0.0.0 且启用 token 时放行任意 Host(此时 token 才是访问边界,攻击者页面拿不到)。
145
151
  let boundPort = port;
152
+ /** @param {any} reqHost */
146
153
  const trustedHost = (reqHost) => {
147
154
  const h = String(reqHost || '').toLowerCase();
148
155
  const names = new Set(['127.0.0.1', 'localhost', '::1']);
@@ -167,6 +174,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
167
174
  const startupCwd = workingDir; // 质检 A3:启动工作目录恒为合法浏览根(工作空间切换后仍可浏览原位)
168
175
  // Provider 按模型懒加载缓存:界面切换模型后即时生效
169
176
  const providerCache = new Map();
177
+ /** @param {any} m */
170
178
  async function getProviderFor(m) {
171
179
  if (!providerCache.has(m)) {
172
180
  providerCache.set(m, await createProvider(cfg, m));
@@ -177,6 +185,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
177
185
  const undoStore = { backups: new Map() };
178
186
 
179
187
  // MCP:后台启动,就绪后工具并入后续轮次
188
+ /** @type {any} */
180
189
  let mcpManager = null;
181
190
  if (cfg.mcpServers && Object.keys(cfg.mcpServers).length) {
182
191
  startMcpServers(cfg.mcpServers, workingDir)
@@ -187,8 +196,8 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
187
196
  }
188
197
  const mcpFacade = {
189
198
  toolSchemas: () => (mcpManager ? mcpManager.toolSchemas() : []),
190
- call: (n, a) => (mcpManager ? mcpManager.call(n, a) : Promise.reject(new Error('MCP 未就绪'))),
191
- isReadonly: (n) => (mcpManager ? mcpManager.isReadonly(n) : false),
199
+ call: (/** @type {any} */ n, /** @type {any} */ a) => (mcpManager ? mcpManager.call(n, a) : Promise.reject(new Error('MCP 未就绪'))),
200
+ isReadonly: (/** @type {any} */ n) => (mcpManager ? mcpManager.isReadonly(n) : false),
192
201
  status: () => (mcpManager ? mcpManager.status() : []),
193
202
  stop: () => {
194
203
  if (mcpManager) mcpManager.stop();
@@ -202,6 +211,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
202
211
  let taskSeq = 0;
203
212
  // 会话文件互斥(质检 C1/M3):同一 session 文件的 append 与 compact 整文件重写必须串行
204
213
  const sessionLocks = new Map();
214
+ /** @param {any} file @param {any} fn */
205
215
  function withSessionLock(file, fn) {
206
216
  const prev = sessionLocks.get(file) || Promise.resolve();
207
217
  const next = prev.then(fn, fn);
@@ -216,6 +226,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
216
226
  const srvlog = createLogWriter(path.join(mingdaoHome(), 'logs', 'web-server.log'));
217
227
 
218
228
  // —— SSRF 防护(质检 S1):远端地址校验 ——
229
+ /** @param {any} hostname */
219
230
  function isPrivateHost(hostname) {
220
231
  const h = String(hostname || '').toLowerCase();
221
232
  if (!h) return true;
@@ -226,6 +237,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
226
237
  const b = Number(m[2]);
227
238
  return a === 10 || a === 127 || a === 0 || a >= 224 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 100 && b >= 64 && b <= 127);
228
239
  }
240
+ /** @param {any} raw */
229
241
  function validateRemoteUrl(raw) {
230
242
  let u;
231
243
  try {
@@ -261,11 +273,13 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
261
273
  }
262
274
  }
263
275
 
276
+ /** @param {any} res @param {any} body */
264
277
  async function handleChat(res, body) {
265
278
  const taskId = `t${++taskSeq}`; // 服务端生成:客户端自选 taskId 可能覆盖他人任务
266
- const entry = { res, send: null, abortHandler: null, pendingAsk: null, session: null, startedAt: Date.now(), status: 'running', message: '', durationMs: 0 };
279
+ const entry = /** @type {any} */ ({ res, send: null, abortHandler: null, pendingAsk: null, session: null, startedAt: Date.now(), status: 'running', message: '', durationMs: 0 });
267
280
  srvlog('chat 开始 ' + taskId + ' session=' + (body.file || '新会话') + ' 消息长度=' + String(body.message || '').length);
268
281
  tasks.set(taskId, entry);
282
+ /** @param {any} obj */
269
283
  const send = (obj) => {
270
284
  try {
271
285
  res.write(`data: ${JSON.stringify({ ...obj, taskId })}\n\n`);
@@ -301,6 +315,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
301
315
  return;
302
316
  }
303
317
 
318
+ /** @type {any} */
304
319
  let session = null;
305
320
  const isNew = !body.file;
306
321
  if (body.file) {
@@ -355,7 +370,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
355
370
  let persistedBefore = messages.length;
356
371
 
357
372
  // 权限/选择类交互:发 ask 事件(带 taskId),等待 POST /api/permission 应答
358
- const askHandler = ({ question, hidden, options, label, confirm }) =>
373
+ const askHandler = (/** @type {any} */ { question, hidden, options, label, confirm }) =>
359
374
  new Promise((resolve) => {
360
375
  const id = Math.random().toString(36).slice(2);
361
376
  entry.pendingAsk = { id, resolve };
@@ -363,19 +378,19 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
363
378
  type: 'ask',
364
379
  id,
365
380
  question: question ?? label ?? '',
366
- options: options ? options.map((o) => ({ value: o.value, label: o.label })) : null,
381
+ options: options ? options.map((/** @type {any} */ o) => ({ value: o.value, label: o.label })) : null,
367
382
  confirm: Boolean(confirm ?? !options),
368
383
  hidden: Boolean(hidden),
369
384
  });
370
385
  });
371
386
 
372
- const io = createWebIO({
387
+ const io = /** @type {any} */ (createWebIO({
373
388
  send,
374
389
  askHandler,
375
- setAbortHandler: (fn) => {
390
+ setAbortHandler: (/** @type {any} */ fn) => {
376
391
  entry.abortHandler = fn;
377
392
  },
378
- });
393
+ }));
379
394
  const permission = createPermission(cfg.permission ?? 'ask', io);
380
395
  let providerNow;
381
396
  try {
@@ -384,7 +399,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
384
399
  throw new Error(`模型 ${runModel} 尚未配置 API Key:请点击右上角 ⚙ 设置 →「模型与 API Key」填入密钥。`);
385
400
  }
386
401
  providerNow = await getProviderFor(runModel); // 审计 P1-1:失败时清理任务占位,避免僵尸 running 耗尽并发
387
- } catch (err) {
402
+ } catch (/** @type {any} */ err) {
388
403
  entry.status = 'failed';
389
404
  entry.durationMs = Date.now() - entry.startedAt;
390
405
  send({ type: 'error', message: `模型 ${runModel} 不可用:${String(err?.message || err)}` });
@@ -403,7 +418,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
403
418
  undoStore,
404
419
  mcp: mcpFacade,
405
420
  // 自动压缩后重写会话文件(否则每次加载历史都会重新触发压缩),并同步落盘游标
406
- onCompact: (msgs) => {
421
+ onCompact: (/** @type {any} */ msgs) => {
407
422
  withSessionLock(session.file, () => rewriteSession(session.file, msgs)).catch(() => {}); // onCompact 为同步回调:入队串行即可
408
423
  persistedBefore = msgs.length;
409
424
  },
@@ -435,7 +450,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
435
450
  session.routeStats = { steps: st.steps + (io.stats().toolCount || 0), truncated: st.truncated + (r.truncated ? 1 : 0) };
436
451
  await withSessionLock(session.file, () => appendMessages(session.file, messages.slice(persistedBefore)));
437
452
  io.printUsageLine({ modelName: runModel, usage: r.usage, durationMs: r.durationMs });
438
- recordUsage(r.perf?.usedModel || runModel, r.usage, r.perf);
453
+ recordUsage(r.perf?.usedModel || runModel, r.usage, /** @type {any} */ (r.perf));
439
454
  maybeAutoSync().catch(() => {});
440
455
  // 新会话自动标题(可配置关闭)
441
456
  srvlog('chat 标题生成前 ' + taskId);
@@ -460,7 +475,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
460
475
  const { makeTokenCounter } = await import('../tokenizer.js');
461
476
  const { messageTokens } = await import('../context.js');
462
477
  const counter = makeTokenCounter(runModel);
463
- const used = messages.reduce((sum, m) => sum + messageTokens(m, counter), 0);
478
+ const used = messages.reduce((/** @type {any} */ sum, /** @type {any} */ m) => sum + messageTokens(m, counter), 0);
464
479
  budgetInfo = { used, total: cfg.contextBudget || modelPreset(runModel)?.budgetTokens || 128000 };
465
480
  } catch {}
466
481
  send({
@@ -476,7 +491,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
476
491
  stats: io.stats(),
477
492
  session: path.basename(session.file),
478
493
  });
479
- } catch (err) {
494
+ } catch (/** @type {any} */ err) {
480
495
  entry.status = 'failed';
481
496
  entry.durationMs = Date.now() - entry.startedAt;
482
497
  srvlog('chat 错误 ' + taskId + ' ' + String(err?.message || err));
package/src/web/web-io.js CHANGED
@@ -3,6 +3,9 @@
3
3
 
4
4
  import { estimateCostLabel } from '../pricing.js';
5
5
 
6
+ /**
7
+ * @param {{ send: any, askHandler: any, setAbortHandler: any }} param0
8
+ */
6
9
  export function createWebIO({ send, askHandler, setAbortHandler }) {
7
10
  const io = {
8
11
  isTTY: false,
@@ -10,12 +13,13 @@ export function createWebIO({ send, askHandler, setAbortHandler }) {
10
13
  _pending: null,
11
14
  _toolCount: 0,
12
15
  _taskCount: 0, // 子代理(task 工具)次数:进度心跳与轨迹面板用
13
- _deliverables: [],
14
- _activeTools: [], // {seq, key}:并行批次下 toolStart↔tool 按 name+args 配对(审计 P2-7)
16
+ _deliverables: /** @type {any[]} */ ([]),
17
+ _activeTools: /** @type {any[]} */ ([]), // {seq, key}:并行批次下 toolStart↔tool 按 name+args 配对(审计 P2-7)
15
18
  // 交付物与步数统计:写/编辑成功的文件路径(去重)
16
19
  stats() {
17
20
  return { toolCount: io._toolCount, taskCount: io._taskCount, deliverables: [...new Set(io._deliverables)] };
18
21
  },
22
+ /** @param {any} v */
19
23
  setShowReasoning(v) {
20
24
  io.showReasoning = !!v;
21
25
  },
@@ -26,6 +30,10 @@ export function createWebIO({ send, askHandler, setAbortHandler }) {
26
30
  print(text = '') {
27
31
  send({ type: 'banner', text });
28
32
  },
33
+ /**
34
+ * @param {any} title
35
+ * @param {any} lines
36
+ */
29
37
  box(title, lines) {
30
38
  send({ type: 'banner', title, lines });
31
39
  },
@@ -35,27 +43,39 @@ export function createWebIO({ send, askHandler, setAbortHandler }) {
35
43
  beginTurn() {},
36
44
  endTurn() {},
37
45
  startSpinner() {
38
- if (!io._turnActive) {
39
- io._turnActive = true;
46
+ if (!/** @type {any} */ (io)._turnActive) {
47
+ /** @type {any} */ (io)._turnActive = true;
40
48
  send({ type: 'turnStart', toolSteps: io._toolCount });
41
49
  }
42
50
  },
43
51
  stopSpinner() {
44
- if (io._turnActive) {
45
- io._turnActive = false;
52
+ if (/** @type {any} */ (io)._turnActive) {
53
+ /** @type {any} */ (io)._turnActive = false;
46
54
  send({ type: 'turnEnd', toolSteps: io._toolCount });
47
55
  }
48
56
  },
57
+ /** @param {any} t */
49
58
  writeText(t) {
50
59
  send({ type: 'text', delta: String(t) });
51
60
  },
61
+ /** @param {any} t */
52
62
  writeReasoning(t) {
53
63
  if (!io.showReasoning) return;
54
64
  send({ type: 'reasoning', delta: String(t) });
55
65
  },
66
+ /**
67
+ * @param {any} code
68
+ * @param {any} lang
69
+ */
56
70
  printCodeBlock(code, lang) {
57
71
  send({ type: 'code', code, lang });
58
72
  },
73
+ /**
74
+ * @param {any} name
75
+ * @param {any} args
76
+ * @param {any} result
77
+ * @param {any} durationMs
78
+ */
59
79
  renderTool(name, args, result, durationMs) {
60
80
  io._toolCount += 1;
61
81
  if (name === 'task') io._taskCount += 1;
@@ -64,16 +84,25 @@ export function createWebIO({ send, askHandler, setAbortHandler }) {
64
84
  }
65
85
  // 审计 P2-7:按 name+args 找回 toolStart 的 seq(并行批次下各卡片配对正确)
66
86
  const key = name + ':' + JSON.stringify(args || {});
67
- const idx = io._activeTools.findIndex((a) => a.key === key);
68
- const seq = idx !== -1 ? io._activeTools.splice(idx, 1)[0].seq : (io._toolSeq || 0) + 1;
87
+ const idx = io._activeTools.findIndex((/** @type {any} */ a) => a.key === key);
88
+ const seq = idx !== -1 ? io._activeTools.splice(idx, 1)[0].seq : (/** @type {any} */ (io)._toolSeq || 0) + 1;
69
89
  send({ type: 'tool', name, args, result, durationMs, seq });
70
90
  },
71
91
  // 工具开始执行:前端先渲染带旋转状态的卡片,完成后原地更新(seq 配对)
92
+ /**
93
+ * @param {any} name
94
+ * @param {any} args
95
+ */
72
96
  renderToolStart(name, args) {
73
- io._toolSeq = (io._toolSeq || 0) + 1;
74
- io._activeTools.push({ seq: io._toolSeq, key: name + ':' + JSON.stringify(args || {}) });
75
- send({ type: 'toolStart', name, args, seq: io._toolSeq });
76
- },
97
+ /** @type {any} */ (io)._toolSeq = (/** @type {any} */ (io)._toolSeq || 0) + 1;
98
+ io._activeTools.push({ seq: /** @type {any} */ (io)._toolSeq, key: name + ':' + JSON.stringify(args || {}) });
99
+ send({ type: 'toolStart', name, args, seq: /** @type {any} */ (io)._toolSeq });
100
+ },
101
+ /**
102
+ * @param {any} name
103
+ * @param {any} args
104
+ * @param {any} reason
105
+ */
77
106
  renderToolDenied(name, args, reason) {
78
107
  // 质检 L2:被拒的 toolStart 也要从 _activeTools 移除——否则条目无界增长、
79
108
  // 且后续同名同参工具可能 findIndex 命中陈旧 seq 导致卡片配对错位
@@ -82,9 +111,11 @@ export function createWebIO({ send, askHandler, setAbortHandler }) {
82
111
  if (idx !== -1) io._activeTools.splice(idx, 1);
83
112
  send({ type: 'toolDenied', name, args, reason: reason || '未授权' });
84
113
  },
114
+ /** @param {any} todos */
85
115
  renderTodo(todos) {
86
116
  send({ type: 'todo', todos });
87
117
  },
118
+ /** @param {{ modelName: any, usage: any, durationMs: any }} param0 */
88
119
  printUsageLine({ modelName, usage, durationMs }) {
89
120
  send({
90
121
  type: 'usage',
@@ -94,22 +125,33 @@ export function createWebIO({ send, askHandler, setAbortHandler }) {
94
125
  cost: estimateCostLabel(modelName, usage?.prompt_tokens ?? 0, usage?.completion_tokens ?? 0, usage),
95
126
  });
96
127
  },
128
+ /** @param {any} fn */
97
129
  onSigint(fn) {
98
130
  if (setAbortHandler) setAbortHandler(fn);
99
131
  return () => {
100
132
  if (setAbortHandler) setAbortHandler(null);
101
133
  };
102
134
  },
135
+ /**
136
+ * @param {any} question
137
+ * @param {any} opts
138
+ */
103
139
  ask(question, opts = {}) {
104
140
  return askHandler({ question, hidden: Boolean(opts.hidden) });
105
141
  },
142
+ /** @param {any} prompt */
106
143
  async askMultiline(prompt) {
107
144
  return askHandler({ question: prompt });
108
145
  },
146
+ /** @param {any} question */
109
147
  async confirm(question) {
110
148
  const a = await askHandler({ question: question + ' ', confirm: true });
111
149
  return /^y(es)?$/i.test(String(a));
112
150
  },
151
+ /**
152
+ * @param {any} label
153
+ * @param {any} options
154
+ */
113
155
  async choose(label, options) {
114
156
  return askHandler({ label, options });
115
157
  },