zen-gitsync 2.17.24 → 2.17.26

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 (79) hide show
  1. package/README.md +6 -0
  2. package/package.json +6 -3
  3. package/scripts/release.js +8 -10
  4. package/scripts/run-tests.cjs +5 -0
  5. package/src/cli/ai/agent.js +20 -0
  6. package/src/cli/ai/termui.js +73 -1
  7. package/src/cli/ai/termui.test.js +51 -0
  8. package/src/cli/ai/tools.js +116 -1
  9. package/src/cli/ai/tools.test.js +65 -1
  10. package/src/cli/ai/turn.js +3 -0
  11. package/src/memory/library.js +273 -0
  12. package/src/memory/library.test.js +187 -0
  13. package/src/memory/store.js +199 -0
  14. package/src/memory/store.test.js +137 -0
  15. package/src/memory/templates.js +113 -0
  16. package/src/paths.js +18 -0
  17. package/src/ui/public/assets/{AgentEngineSelector-CvIoGCLD.js → AgentEngineSelector-CEayBStS.js} +1 -1
  18. package/src/ui/public/assets/{AgentView-DnN-vCMi.css → AgentView-B3LhhRsy.css} +1 -1
  19. package/src/ui/public/assets/{AgentView-BrqS78Rt.js → AgentView-BhLlAvng.js} +1 -1
  20. package/src/ui/public/assets/{AppVersionBadge-BHxZm7-h.js → AppVersionBadge-btxeyJpH.js} +2 -2
  21. package/src/ui/public/assets/{BranchSelector-w5ybmWVv.js → BranchSelector-ERybqfLW.js} +1 -1
  22. package/src/ui/public/assets/{CommitForm-pRCnAfz6.js → CommitForm-DcXdYwKn.js} +1 -1
  23. package/src/ui/public/assets/{CommonDialog-qXAsFhNS.js → CommonDialog-5bwugMhs.js} +1 -1
  24. package/src/ui/public/assets/EditorView-BzxNDG8A.js +1 -0
  25. package/src/ui/public/assets/{EditorView-C6HFYeEM.css → EditorView-DqxBkn6c.css} +1 -1
  26. package/src/ui/public/assets/{FileDiffViewer-Bc8j9cQn.js → FileDiffViewer-BBOtL3bR.js} +1 -1
  27. package/src/ui/public/assets/{FlowExecutionViewer-CF4HkGu6.js → FlowExecutionViewer-BPMDn3Md.js} +1 -1
  28. package/src/ui/public/assets/{FlowOrchestrationWorkspace-a3Zk4BMK.js → FlowOrchestrationWorkspace-PtCM4LB1.js} +1 -1
  29. package/src/ui/public/assets/{IconButton-BjIHmE4p.js → IconButton-BELiW6cx.js} +1 -1
  30. package/src/ui/public/assets/{LogList-CqpSAW5T.js → LogList-BAD5dV_N.js} +1 -1
  31. package/src/ui/public/assets/{MindmapView-CgptM54l.js → MindmapView-BAwb7Gs1.js} +1 -1
  32. package/src/ui/public/assets/{MonitorView-qb0EPvQW.js → MonitorView-DIqKQfiM.js} +1 -1
  33. package/src/ui/public/assets/{ProjectStartupButton-CmankQtk.js → ProjectStartupButton-DnQyvZOG.js} +1 -1
  34. package/src/ui/public/assets/{RecentDirectoriesChat-CsHHckPP.js → RecentDirectoriesChat-BMZA0DgP.js} +1 -1
  35. package/src/ui/public/assets/RecentDirectoriesChat-o4xK1Koc.css +1 -0
  36. package/src/ui/public/assets/{RemoteManagerDialog-Cpg4g5KW.js → RemoteManagerDialog-BmdcIfwA.js} +1 -1
  37. package/src/ui/public/assets/{RemoteRepoCard-DCc-adLb.js → RemoteRepoCard-DqKIIcGh.js} +1 -1
  38. package/src/ui/public/assets/{SourceMapView-I41XvvZr.js → SourceMapView-Sd_871DD.js} +1 -1
  39. package/src/ui/public/assets/{SvgIcon-BB80ERhO.js → SvgIcon-DyQoHzNw.js} +1 -1
  40. package/src/ui/public/assets/{TemplateManager-BB_bEiBB.js → TemplateManager-BI1EXn7d.js} +1 -1
  41. package/src/ui/public/assets/{UserInputNode-y9gz0EKH.js → UserInputNode-3qtC_swg.js} +1 -1
  42. package/src/ui/public/assets/{WorkbenchView-Bkzbu5Sp.css → WorkbenchView-BNxybEqF.css} +1 -1
  43. package/src/ui/public/assets/WorkbenchView-wAdnj8Kl.js +6 -0
  44. package/src/ui/public/assets/_plugin-vue_export-helper-CGArUtKL.js +19 -0
  45. package/src/ui/public/assets/agentConversations-cmS8iux5.js +5 -0
  46. package/src/ui/public/assets/{configStore-BR8vEJCp.js → configStore-Vh8LZWlL.js} +1 -1
  47. package/src/ui/public/assets/{gitStore-Bz77W-S0.js → gitStore-B9gZQf2q.js} +1 -1
  48. package/src/ui/public/assets/index-BZFKDSGv.css +1 -0
  49. package/src/ui/public/assets/index-DE-31lcL.js +29 -0
  50. package/src/ui/public/assets/{previewSandbox-luPexzF4.js → previewSandbox-DmecmT9g.js} +1 -1
  51. package/src/ui/public/assets/{toolsStore-CeI7jM5o.js → toolsStore-DgCBDdhm.js} +1 -1
  52. package/src/ui/public/assets/vendor-BY5ZDlW1.css +1 -0
  53. package/src/ui/public/assets/{vendor-CyWsJd6S.js → vendor-BjomZKSS.js} +28 -120
  54. package/src/ui/public/index.html +14 -14
  55. package/src/ui/server/index.js +4 -0
  56. package/src/ui/server/routes/fileOpen.js +43 -7
  57. package/src/ui/server/routes/memory.js +125 -0
  58. package/src/ui/server/routes/workbench/agentChat.js +35 -3
  59. package/src/ui/server/routes/workbench/agentChatShared.test.js +49 -0
  60. package/src/ui/server/routes/workbench/dispatchInstruction.js +8 -6
  61. package/src/ui/server/routes/workbench/dispatchInstruction.test.js +29 -2
  62. package/src/ui/server/routes/workbench/index.js +41 -17
  63. package/src/ui/server/routes/workbench/memoryContext.js +125 -0
  64. package/src/ui/server/routes/workbench/memoryContext.test.js +79 -0
  65. package/src/ui/server/routes/workbench/orchestratorPrompt.test.js +47 -2
  66. package/src/ui/server/routes/workbench/orchestratorStore.js +29 -1
  67. package/src/ui/server/routes/workbench/runExternalTurn.js +6 -1
  68. package/src/ui/server/routes/workbench/shared.js +28 -2
  69. package/src/ui/server/routes/workbench/taskRunner.js +48 -5
  70. package/src/utils/selfUpdatePolicy.js +26 -4
  71. package/src/utils/selfUpdatePolicy.test.js +21 -2
  72. package/src/ui/public/assets/EditorView-DnuGnX3k.js +0 -1
  73. package/src/ui/public/assets/RecentDirectoriesChat-vHT8jMxW.css +0 -1
  74. package/src/ui/public/assets/WorkbenchView-DKqriLBP.js +0 -6
  75. package/src/ui/public/assets/_plugin-vue_export-helper-Dd_YmKEO.js +0 -19
  76. package/src/ui/public/assets/agentConversations-CT9E8eQr.js +0 -5
  77. package/src/ui/public/assets/index-CsLyZsXy.js +0 -29
  78. package/src/ui/public/assets/index-D2rf9QKB.css +0 -1
  79. package/src/ui/public/assets/vendor-BzMymUBi.css +0 -1
@@ -10,29 +10,29 @@
10
10
  <meta name="theme-color" content="#141820" media="(prefers-color-scheme: dark)" />
11
11
  <title>Zen GitSync</title>
12
12
  <!-- 字体改为自托管(@font-face 见 src/main.css),不再依赖 Google Fonts 外链 -->
13
- <script type="module" crossorigin src="/assets/index-CsLyZsXy.js"></script>
13
+ <script type="module" crossorigin src="/assets/index-DE-31lcL.js"></script>
14
14
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-C0FnF6B9.js">
15
15
  <link rel="modulepreload" crossorigin href="/assets/element-plus-Bx3j6vzG.js">
16
16
  <link rel="modulepreload" crossorigin href="/assets/monaco-DkJuNcCr.js">
17
- <link rel="modulepreload" crossorigin href="/assets/vendor-CyWsJd6S.js">
18
- <link rel="modulepreload" crossorigin href="/assets/_plugin-vue_export-helper-Dd_YmKEO.js">
19
- <link rel="modulepreload" crossorigin href="/assets/SvgIcon-BB80ERhO.js">
20
- <link rel="modulepreload" crossorigin href="/assets/IconButton-BjIHmE4p.js">
21
- <link rel="modulepreload" crossorigin href="/assets/configStore-BR8vEJCp.js">
17
+ <link rel="modulepreload" crossorigin href="/assets/vendor-BjomZKSS.js">
18
+ <link rel="modulepreload" crossorigin href="/assets/_plugin-vue_export-helper-CGArUtKL.js">
19
+ <link rel="modulepreload" crossorigin href="/assets/SvgIcon-DyQoHzNw.js">
20
+ <link rel="modulepreload" crossorigin href="/assets/IconButton-BELiW6cx.js">
21
+ <link rel="modulepreload" crossorigin href="/assets/configStore-Vh8LZWlL.js">
22
22
  <link rel="modulepreload" crossorigin href="/assets/socket-io-TBtw0msS.js">
23
- <link rel="modulepreload" crossorigin href="/assets/gitStore-Bz77W-S0.js">
23
+ <link rel="modulepreload" crossorigin href="/assets/gitStore-B9gZQf2q.js">
24
24
  <link rel="modulepreload" crossorigin href="/assets/fileIcon-B5p5pGN9.js">
25
25
  <link rel="modulepreload" crossorigin href="/assets/flow-mindmap-OPE_D4da.js">
26
- <link rel="modulepreload" crossorigin href="/assets/previewSandbox-luPexzF4.js">
26
+ <link rel="modulepreload" crossorigin href="/assets/previewSandbox-DmecmT9g.js">
27
27
  <link rel="modulepreload" crossorigin href="/assets/useThemeObserver-DYxFoHh1.js">
28
- <link rel="modulepreload" crossorigin href="/assets/CommonDialog-qXAsFhNS.js">
29
- <link rel="modulepreload" crossorigin href="/assets/FileDiffViewer-Bc8j9cQn.js">
30
- <link rel="modulepreload" crossorigin href="/assets/toolsStore-CeI7jM5o.js">
31
- <link rel="modulepreload" crossorigin href="/assets/TemplateManager-BB_bEiBB.js">
28
+ <link rel="modulepreload" crossorigin href="/assets/CommonDialog-5bwugMhs.js">
29
+ <link rel="modulepreload" crossorigin href="/assets/FileDiffViewer-BBOtL3bR.js">
30
+ <link rel="modulepreload" crossorigin href="/assets/toolsStore-DgCBDdhm.js">
31
+ <link rel="modulepreload" crossorigin href="/assets/TemplateManager-BI1EXn7d.js">
32
32
  <link rel="modulepreload" crossorigin href="/assets/vue-flow-fVVGZspa.js">
33
33
  <link rel="stylesheet" crossorigin href="/assets/element-plus-Qexo5hiM.css">
34
34
  <link rel="stylesheet" crossorigin href="/assets/monaco-B2lPvkrc.css">
35
- <link rel="stylesheet" crossorigin href="/assets/vendor-BzMymUBi.css">
35
+ <link rel="stylesheet" crossorigin href="/assets/vendor-BY5ZDlW1.css">
36
36
  <link rel="stylesheet" crossorigin href="/assets/SvgIcon-BQ6bR7l0.css">
37
37
  <link rel="stylesheet" crossorigin href="/assets/IconButton-W2X09OKH.css">
38
38
  <link rel="stylesheet" crossorigin href="/assets/flow-mindmap-BMpmHdqh.css">
@@ -41,7 +41,7 @@
41
41
  <link rel="stylesheet" crossorigin href="/assets/FileDiffViewer-B6zy6NiA.css">
42
42
  <link rel="stylesheet" crossorigin href="/assets/TemplateManager-DiM3vrbf.css">
43
43
  <link rel="stylesheet" crossorigin href="/assets/vue-flow-CavUTEzp.css">
44
- <link rel="stylesheet" crossorigin href="/assets/index-D2rf9QKB.css">
44
+ <link rel="stylesheet" crossorigin href="/assets/index-BZFKDSGv.css">
45
45
  </head>
46
46
  <body>
47
47
  <div id="app"></div>
@@ -52,6 +52,7 @@ import { registerCodeAnalysisRoutes } from './routes/codeAnalysis.js';
52
52
  import { registerInstancesRoutes } from './routes/instances.js';
53
53
  import { registerMonitorRoutes } from './routes/monitor.js';
54
54
  import { registerMindmapRoutes } from './routes/mindmap.js';
55
+ import { registerMemoryRoutes } from './routes/memory.js';
55
56
  import { registerAgentRoutes } from './routes/workbench/agentRoutes.js';
56
57
  import { createWorkspaceSnapshotter } from './routes/aiContext/wiring.js';
57
58
  import { createInstanceRegistry, getRegistryPath } from './utils/instanceRegistry.js';
@@ -478,6 +479,9 @@ async function startUIServer(noOpen = false, savePort = false) {
478
479
  // 思维导图:.mindmap.json 文件的列/读/写/建/删/重命名
479
480
  registerMindmapRoutes({ app });
480
481
 
482
+ // 记忆库:跨轮经验的浏览 / 读正文 / 删条目(设置 → 记忆库面板)
483
+ registerMemoryRoutes({ app });
484
+
481
485
  // 智能体:Web 端 AI 编码助手(含工具调用 + 会话持久化)
482
486
  // 快照生成器在上面已经建好(要走同一个实例),这里注入进去复用。
483
487
  registerAgentRoutes({
@@ -437,14 +437,46 @@ async function launchClaudeCode(dirPath, { permissionMode } = {}) {
437
437
  return launchInTerminal(dirPath, 'claude', cliArgs);
438
438
  }
439
439
 
440
- async function launchCodex(dirPath) {
441
- // OpenAI Codex CLI - 无 permissionMode 参数(与 claude 不同)
440
+ /**
441
+ * codex 的启动参数(按权限档位)。与 claude / opencode 的档位对应关系:
442
+ *
443
+ * 默认(不带 token) → 一个 flag 都不加,跟随 `~/.codex/config.toml` 的
444
+ * approval / sandbox 设置
445
+ * 'sandboxed' → `-a never -s workspace-write`:**从不问人,但命令仍在沙箱里跑**
446
+ * (写限工作区、默认无网络)。这是"不想被批准提示打断、又不想
447
+ * 完全交出去"的那一档,语义上对应 claude 的「批准文件编辑」。
448
+ * 'bypass' → `--dangerously-bypass-approvals-and-sandbox`:免批准 + 免沙箱。
449
+ * 官方帮助原文 "Skip all confirmation prompts and execute commands
450
+ * without sandboxing. EXTREMELY DANGEROUS."。**完全批准**就是它。
451
+ * 'bypassPermissions'→ 同上。只为让调用方(顶栏菜单与编排台项目菜单)跟 claude 那档
452
+ * 共用同一个字符串,不是第三种行为。
453
+ *
454
+ * ⚠️ codex 没有 claude 的 `--permission-mode`,两档都得自己拼 flag。
455
+ * ⚠️ 别只看 sandbox:本机 config.toml 写的是 `sandbox_mode = "danger-full-access"`,
456
+ * 也就是说这台机器的**默认档本来就没有沙箱**,完全批准档真正多出来的只是"不再弹批准"。
457
+ *
458
+ * 回归 `test/open-with-permission.test.mjs`(与 opencode 那份参数映射放一起验)。
459
+ */
460
+ const CODEX_MODE_FLAGS = new Map([
461
+ ['sandboxed', ['-a', 'never', '-s', 'workspace-write']],
462
+ ['bypass', ['--dangerously-bypass-approvals-and-sandbox']],
463
+ ['bypassPermissions', ['--dangerously-bypass-approvals-and-sandbox']],
464
+ ]);
465
+
466
+ export function buildCodexArgs(permissionMode) {
467
+ // Map.get 对非字符串返回 undefined → 未知 token 一律空数组,白名单式,不存在拼参数注入
468
+ return [...(CODEX_MODE_FLAGS.get(permissionMode) || [])];
469
+ }
470
+
471
+ async function launchCodex(dirPath, { permissionMode } = {}) {
472
+ // OpenAI Codex CLI - https://github.com/openai/codex
473
+ const cliArgs = buildCodexArgs(permissionMode);
442
474
  if (process.platform === 'win32') {
443
- return spawnDetached('cmd.exe', ['/c', 'start', '""', 'codex'], {
475
+ return spawnDetached('cmd.exe', ['/c', 'start', '""', 'codex', ...cliArgs], {
444
476
  cwd: dirPath
445
477
  });
446
478
  }
447
- return launchInTerminal(dirPath, 'codex');
479
+ return launchInTerminal(dirPath, 'codex', cliArgs);
448
480
  }
449
481
 
450
482
  /**
@@ -986,7 +1018,7 @@ export function registerFileOpenRoutes({
986
1018
  // 用 Codex 打开目录
987
1019
  app.post('/api/open-directory-with-codex', asyncRoute(async (req, res) => {
988
1020
  try {
989
- const { path: dirPath } = req.body || {};
1021
+ const { path: dirPath, permissionMode } = req.body || {};
990
1022
  if (!dirPath) {
991
1023
  throw new HttpError(400, '目录路径不能为空');
992
1024
  }
@@ -998,8 +1030,12 @@ export function registerFileOpenRoutes({
998
1030
  }
999
1031
 
1000
1032
  try {
1001
- await launchCodex(dirPath);
1002
- res.json({ success: true, message: '已用 Codex 打开目录' });
1033
+ await launchCodex(dirPath, { permissionMode });
1034
+ const codexArgs = buildCodexArgs(permissionMode);
1035
+ const message = codexArgs.length
1036
+ ? `已用 Codex 打开目录(${codexArgs.join(' ')})`
1037
+ : '已用 Codex 打开目录';
1038
+ res.json({ success: true, message });
1003
1039
  } catch (error) {
1004
1040
  res.status(400).json({
1005
1041
  success: false,
@@ -0,0 +1,125 @@
1
+ // Copyright 2026 xz333221
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+ //
15
+ // 记忆库的浏览 / 删除接口(设置 → 记忆库面板用)。
16
+ //
17
+ // 口径(与其它 workbench 路由一致,详见 routes/workbench/index.js 头部三条):
18
+ // - 非 SSE 路由统一 asyncRoute 包装,**handler 内不写 try/catch**
19
+ // - 定制状态码用 `throw new HttpError(4xx, msg)`
20
+ // - 危险操作(删)要求调用方显式传 `confirm: true`
21
+ //
22
+ // ⚠️ **本文件不导出"任意路径"接口。** 前端只传 `{scope, file}`,
23
+ // 绝对路径由 src/memory/library.js 从 scope 重新拼(见那个文件头注释)。
24
+ // 这是结构性防越界:不合法参数压根不参与路径拼接,
25
+ // 也就没有"前端传个 path 就删到别处"的可能。**别为了"省一次校验"改成传路径。**
26
+
27
+ import { asyncRoute, HttpError } from '../utils/asyncRoute.js';
28
+ import {
29
+ listScopes,
30
+ listEntries,
31
+ readEntry,
32
+ deleteEntry,
33
+ isValidScope,
34
+ isValidFile,
35
+ memoryDirExists,
36
+ SCOPE_GLOBAL,
37
+ SCOPE_GLOBAL_INDEX,
38
+ } from '../../../memory/library.js';
39
+
40
+ /** 批量删除的单次上限:再多就该分批,别让一个请求把整个库清空 */
41
+ const BATCH_MAX = 200;
42
+
43
+ function requireScope(scope) {
44
+ if (!isValidScope(scope)) throw new HttpError(400, 'scope 不合法');
45
+ return scope;
46
+ }
47
+
48
+ function requireFile(file) {
49
+ if (!isValidFile(file)) throw new HttpError(400, 'file 不合法');
50
+ return file;
51
+ }
52
+
53
+ export function registerMemoryRoutes({ app }) {
54
+ // ── 有哪些可看的范围(全局两篇 + 每个项目)──────────────────────────
55
+ app.get('/api/memory/scopes', asyncRoute(async (_req, res) => {
56
+ const available = await memoryDirExists();
57
+ if (!available) {
58
+ // 没就绪是**合法状态**(用户还没开过工作台),给空列表而不是 500:
59
+ // 面板据此显示"尚未初始化",而不是一个看不懂的报错
60
+ res.json({ success: true, available: false, scopes: [] });
61
+ return;
62
+ }
63
+ res.json({ success: true, available: true, scopes: await listScopes() });
64
+ }));
65
+
66
+ // ── 某个范围下有哪些条目 ────────────────────────────────────────────
67
+ app.get('/api/memory/entries', asyncRoute(async (req, res) => {
68
+ const scope = requireScope(String(req.query.scope || ''));
69
+ res.json({ success: true, scope, entries: await listEntries(scope) });
70
+ }));
71
+
72
+ // ── 读单条正文(展开看内容时按需拉)─────────────────────────────────
73
+ app.get('/api/memory/entry', asyncRoute(async (req, res) => {
74
+ const scope = requireScope(String(req.query.scope || ''));
75
+ const file = requireFile(String(req.query.file || ''));
76
+ const r = await readEntry(scope, file);
77
+ if (r.error === 'invalid') throw new HttpError(400, 'scope 或 file 不合法');
78
+ if (r.tooLarge) throw new HttpError(413, `内容过大(${Math.round(r.size / 1024)} KB),不予展示`);
79
+ if (r.missing) throw new HttpError(404, '条目不存在');
80
+ res.json({ success: true, scope, file, content: r.content, size: r.size, mtime: r.mtime });
81
+ }));
82
+
83
+ // ── 删一条(连带清掉 INDEX.md 里那行索引)───────────────────────────
84
+ app.delete('/api/memory/entry', asyncRoute(async (req, res) => {
85
+ if (req.body?.confirm !== true) throw new HttpError(400, '需要 confirm: true');
86
+ const scope = requireScope(String(req.body?.scope || ''));
87
+ const file = requireFile(String(req.body?.file || ''));
88
+ // 全局两篇是索引/规范本身,删了整套记忆就失去记账口径 —— 不给删
89
+ if (scope === SCOPE_GLOBAL || scope === SCOPE_GLOBAL_INDEX) {
90
+ throw new HttpError(400, '全局索引与规范文件不可删除');
91
+ }
92
+ const r = await deleteEntry(scope, file);
93
+ res.json({ success: true, ...r });
94
+ }));
95
+
96
+ // ── 批量删 ──────────────────────────────────────────────────────────
97
+ app.post('/api/memory/batch-delete', asyncRoute(async (req, res) => {
98
+ if (req.body?.confirm !== true) throw new HttpError(400, '需要 confirm: true');
99
+ const items = Array.isArray(req.body?.items) ? req.body.items : [];
100
+ if (items.length === 0) throw new HttpError(400, '没有选中任何条目');
101
+ if (items.length > BATCH_MAX) throw new HttpError(400, `一次最多删 ${BATCH_MAX} 条`);
102
+
103
+ let removed = 0;
104
+ let indexLinesRemoved = 0;
105
+ const failed = [];
106
+ for (const it of items) {
107
+ const scope = String(it?.scope || '');
108
+ const file = String(it?.file || '');
109
+ if (!isValidScope(scope) || !isValidFile(file)) { failed.push({ scope, file, reason: 'invalid' }); continue; }
110
+ if (scope === SCOPE_GLOBAL || scope === SCOPE_GLOBAL_INDEX) {
111
+ failed.push({ scope, file, reason: 'protected' });
112
+ continue;
113
+ }
114
+ try {
115
+ const r = await deleteEntry(scope, file);
116
+ if (r.removed) removed += 1;
117
+ if (r.indexLineRemoved) indexLinesRemoved += 1;
118
+ } catch (err) {
119
+ // 单条失败**不中断整批** —— 否则前几条已删、后几条报错,用户重试会重复删
120
+ failed.push({ scope, file, reason: err?.message || 'delete failed' });
121
+ }
122
+ }
123
+ res.json({ success: true, removed, indexLinesRemoved, failed });
124
+ }));
125
+ }
@@ -21,7 +21,10 @@
21
21
  // - { type: 'meta', sessionId, isNew, title }
22
22
  // - { type: 'thinking', delta } — 推理过程增量
23
23
  // - { type: 'content', delta } — 正文增量
24
- // - { type: 'tool_call_start', toolCallId, name, argsPreview }
24
+ // - { type: 'tool_call_start', toolCallId, name, argsPreview, arguments }
25
+ // arguments 只在计划类工具(update_plan 等)上出现:前端要拿原始 steps 渲染
26
+ // 计划清单,argsPreview 是截断过的摘要拆不出步骤。其他工具不带全文,免得
27
+ // write_file 那种几十 KB 的 content 灌进 SSE。
25
28
  // - { type: 'tool_output', toolCallId, chunk } — 命令执行中的增量输出(仅展示)
26
29
  // - { type: 'tool_result', toolCallId, name, result }
27
30
  // - { type: 'ask_user', interactionId, question, options, allowFreeText, multiple }
@@ -33,7 +36,7 @@ import os from 'os';
33
36
  import { logger } from './shared.js';
34
37
 
35
38
  // 从 CLI 侧导入工具定义、执行器与 LLM 传输层(同一 monorepo,路径可达)
36
- import { TOOL_DEFINITIONS, executeTool } from '../../../../cli/ai/tools.js';
39
+ import { TOOL_DEFINITIONS, executeTool, normalizePlanSteps, summarizePlan, isPlanToolName } from '../../../../cli/ai/tools.js';
37
40
  import { prepareRequestMessages } from '../../../../cli/ai/context.js';
38
41
  import { streamChatOnce } from '../../../../cli/ai/transport.js';
39
42
  import { checkDangerousCommand } from '../../../../cli/ai/safety.js';
@@ -131,6 +134,15 @@ ${isWin ? `- 当前是 Windows,以下 Unix 命令**不存在**,用了必定报"
131
134
  - run_command 默认就在工作目录执行,不要再加 cd 前缀;默认超时 120 秒,长任务加大 timeout_seconds(最大 600)
132
135
  - 命令在 ${shellDesc} 下执行,注意语法兼容
133
136
 
137
+ # 任务计划(多步任务必用)
138
+ - 任务需要多步时(改代码、排查、调研、多文件改动),**动手之前**先调一次 update_plan,把要做的事
139
+ 拆成 3-8 个可核对的步骤,让用户在你改任何东西之前就知道范围和验收标准
140
+ - 单步小事(读个文件、答一个问题)不要列计划,那是噪音
141
+ - 每次都传**完整**的当前计划(不是增量),顺序即执行顺序;同一时刻最多一个 in_progress
142
+ - 完成一步就更新一次状态,别攒到最后一次性刷;计划与实际不符时直接改计划,不要硬着头皮往下走
143
+ - 全部做完后把 steps 传空表示收尾
144
+ - update_plan 只是给人看的进度板,不要为了"更新计划"去改文件或跑命令
145
+
134
146
  # 与用户交互
135
147
  - 需要向用户确认、提问或汇报重要决策时,直接用普通文本输出
136
148
  - 需要暂停当前任务并等待用户决定或补充信息时,调用 ask_user,不要猜测或只在普通文本里提问
@@ -213,6 +225,17 @@ ${isWin ? `- This is Windows. The following Unix commands do NOT exist here:
213
225
  - run_command defaults to the working directory; default timeout 120s, max 600s
214
226
  - When the task must pause for a decision or missing detail, call ask_user and wait for the user's answer. The user may attach images to a message; they arrive as image_url parts in the user message. If the current model rejects images (no vision support), tell the user to switch to a vision-capable model
215
227
 
228
+ # Task plan (required for multi-step work)
229
+ - When a task takes several steps (code changes, debugging, research, multi-file edits), call update_plan
230
+ **before touching anything**: break it into 3-8 verifiable steps so the user knows the scope and the
231
+ acceptance criteria before you modify a single file
232
+ - Do not plan single trivial actions (read one file, answer one question) — that is noise
233
+ - Always send the **complete** current plan (not a delta), in execution order; at most one in_progress
234
+ - Update the status right after finishing a step instead of batching everything at the end; when the
235
+ plan no longer matches reality, rewrite the plan instead of pushing ahead
236
+ - Send an empty steps list once everything is done
237
+ - update_plan is a progress board for humans: never edit files or run commands just to "update the plan"
238
+
216
239
  # Output
217
240
  - Your text output is displayed in the user's Web UI
218
241
  - After completing a task, briefly summarize the result`;
@@ -384,7 +407,11 @@ export async function runAgentTurn({ session, model, userMessage, images = [], c
384
407
 
385
408
  // 工具参数预览(给前端展示)
386
409
  const argsPreview = summarizeArgs(name, args);
387
- send({ type: 'tool_call_start', toolCallId, name, argsPreview });
410
+ // 计划类工具额外发**完整参数**:前端要拿原始 steps 渲染清单,而 argsPreview
411
+ // 是截断过的摘要,拆不出步骤。只给计划类工具发完整参数 ——
412
+ // write_file 的 content 可能几十 KB,为它开一路全文等于给 SSE 带宽找麻烦。
413
+ const rawArgsForClient = isPlanToolName(name) ? rawArgs : undefined;
414
+ send({ type: 'tool_call_start', toolCallId, name, argsPreview, arguments: rawArgsForClient });
388
415
 
389
416
  const toolCtx = {
390
417
  ...ctx,
@@ -430,6 +457,11 @@ function summarizeArgs(name, args) {
430
457
  return String(args.path || '.');
431
458
  case 'search_text':
432
459
  return String(args.pattern || '');
460
+ case 'update_plan': {
461
+ const steps = normalizePlanSteps(args.steps ?? args.todos ?? args.plan);
462
+ const why = String(args.explanation || '').replace(/\s+/g, ' ').trim();
463
+ return [summarizePlan(steps), why].filter(Boolean).join(' — ').slice(0, 200);
464
+ }
433
465
  default:
434
466
  return JSON.stringify(args).slice(0, 200);
435
467
  }
@@ -179,3 +179,52 @@ test('400 且正文提到 tool/function 时提示换模型,而不是甩网关 JS
179
179
  assert.match(error.error, /不支持 function calling/)
180
180
  assert.match(error.error, /换用支持工具调用的模型/)
181
181
  })
182
+
183
+ // ── 计划工具的透传契约 ──────────────────────────────────────
184
+ // 前端要拿原始 steps 渲染计划清单,而它拿到的是 argsPreview(截断过的摘要)——
185
+ // 摘要里拆不出步骤,所以服务端必须为计划类工具额外带一份完整 arguments。
186
+ // 这条钉死了「只给计划类发全文」的口径:多发会让 write_file 的 content
187
+ // 几十 KB 灌进 SSE,少发则前端永远退化成普通工具块。
188
+ test('计划类工具的 tool_call_start 带完整 arguments,其他工具不带', async () => {
189
+ const planArgs = JSON.stringify({
190
+ steps: [
191
+ { content: '读代码', status: 'completed' },
192
+ { content: '改代码', status: 'in_progress' }
193
+ ],
194
+ explanation: '先改库再改宿主'
195
+ })
196
+ const round = [
197
+ sse([event({
198
+ choices: [{ delta: { tool_calls: [
199
+ { index: 0, id: 'p1', function: { name: 'update_plan', arguments: planArgs } },
200
+ { index: 1, id: 'p2', function: { name: 'write_file', arguments: JSON.stringify({ path: 'a.js', content: 'x'.repeat(50) }) } }
201
+ ] } }, { finish_reason: 'tool_calls' }],
202
+ }), 'data: [DONE]\n\n']),
203
+ okStream(),
204
+ ]
205
+ let turn = 0
206
+ const session = newSession('ag-sandbox-plan', withSystem())
207
+
208
+ const { events } = await runWeb({
209
+ session,
210
+ userMessage: '把这个功能做出来',
211
+ respond: () => round[turn++] || okStream(),
212
+ })
213
+
214
+ const starts = events.filter(e => e.type === 'tool_call_start')
215
+ assert.equal(starts.length, 2)
216
+ const planStart = starts.find(e => e.name === 'update_plan')
217
+ const writeStart = starts.find(e => e.name === 'write_file')
218
+
219
+ // 计划:全文必须一字不差地带到前端(前端靠它渲染清单)
220
+ assert.equal(planStart.arguments, planArgs)
221
+ assert.match(planStart.argsPreview, /1\/2|1 完成/)
222
+ // 其他工具:只给摘要,不灌全文
223
+ assert.equal(writeStart.arguments, undefined)
224
+
225
+ const planResult = events.find(e => e.type === 'tool_result' && e.name === 'update_plan')
226
+ assert.match(planResult.result, /计划已更新/)
227
+ // 计划也要进会话记录 —— 重新打开会话时前端靠历史里的 arguments 复原清单
228
+ const assistantWithPlan = session.messages.find(m => m.role === 'assistant' && m.tool_calls?.some(t => t.function.name === 'update_plan'))
229
+ assert.equal(assistantWithPlan.tool_calls[0].function.arguments, planArgs)
230
+ })
@@ -38,7 +38,7 @@ import {
38
38
  genId,
39
39
  TASKS_FILE,
40
40
  IMAGES_DIR,
41
- MAX_ATTACHMENTS_PER_TASK,
41
+ MAX_INSTRUCTION_CHARS,
42
42
  } from './shared.js';
43
43
  import { stagingPath, mimeForExt } from './attachmentUtils.js';
44
44
  import { readOrchestrator, resolveDispatchPrompt, appendInstruction } from './orchestratorStore.js';
@@ -109,7 +109,11 @@ export function createDispatcher({ configManager, getCurrentProjectPath, runTask
109
109
  async function dispatchInstruction(payload = {}) {
110
110
  const text = typeof payload.text === 'string' ? payload.text.trim() : '';
111
111
  if (!text) throw new HttpError(400, '指令内容不能为空');
112
- if (text.length > 4000) throw new HttpError(400, '指令过长(上限 4000 字)');
112
+ // 上限来自 shared.js,不在这里写死数字:写死过一次(4000),前端提示、
113
+ // 工具描述、这条校验各说各的,改的时候漏一处就变成"界面说能发、后端说不能"。
114
+ if (text.length > MAX_INSTRUCTION_CHARS) {
115
+ throw new HttpError(400, `指令过长(上限 ${MAX_INSTRUCTION_CHARS} 字)`);
116
+ }
113
117
 
114
118
  const fallbackPath = typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '';
115
119
  const bodyPath = typeof payload.projectPath === 'string' ? payload.projectPath.trim() : '';
@@ -182,11 +186,9 @@ export function createDispatcher({ configManager, getCurrentProjectPath, runTask
182
186
  // ── 附件:调用方只回传 { id, ext, originalName },服务端按 id 回暂存区找文件 ──
183
187
  // 路径完全由服务端拼(stagingPath 会同时校验 id 形状与 ext 白名单),
184
188
  // 所以不存在"调用方指定任意路径"这回事 —— 比"信任 absolutePath 再校验前缀"干净。
185
- // 数量与文件存在性在这里统一校验:上传时服务端是无状态的,压根不知道攒了几个。
189
+ // 这里只校验**每个附件自己**(形状 + 文件是否还在);数量不设上限,
190
+ // 真正的卡点只有下游读得动读不动。
186
191
  const rawAttachments = Array.isArray(payload.attachments) ? payload.attachments : [];
187
- if (rawAttachments.length > MAX_ATTACHMENTS_PER_TASK) {
188
- throw new HttpError(400, `附件最多 ${MAX_ATTACHMENTS_PER_TASK} 个`);
189
- }
190
192
  const staged = [];
191
193
  for (const item of rawAttachments) {
192
194
  const attId = typeof item?.id === 'string' ? item.id : '';
@@ -55,7 +55,7 @@ delete process.env.HOMEPATH;
55
55
  const load = p => import(pathToFileURL(path.join(projectRoot, p)).href);
56
56
  const { createDispatcher, resolveExecutor } = await load('src/ui/server/routes/workbench/dispatchInstruction.js');
57
57
  const { readOrchestrator, setOrchestratorActive, setDefaultPrompt } = await load('src/ui/server/routes/workbench/orchestratorStore.js');
58
- const { readJson, TASKS_FILE } = await load('src/ui/server/routes/workbench/shared.js');
58
+ const { readJson, TASKS_FILE, MAX_INSTRUCTION_CHARS } = await load('src/ui/server/routes/workbench/shared.js');
59
59
 
60
60
  // 两个项目目录:proj 是"选中/当前"的落点,other 用来证明显式指定优先于默认
61
61
  const proj = path.join(fakeHome, 'proj-alpha');
@@ -201,10 +201,37 @@ test('空指令 / 超长指令一律 400,不留下任务', async () => {
201
201
  const { dispatchInstruction } = makeDispatcher();
202
202
  const before = (await readTasks()).length;
203
203
  await assert.rejects(() => dispatchInstruction({ text: ' ' }), err => err.statusCode === 400 && /指令内容不能为空/.test(err.message));
204
- await assert.rejects(() => dispatchInstruction({ text: 'x'.repeat(4001) }), err => err.statusCode === 400 && /指令过长/.test(err.message));
204
+ // 用常量而不是写死数字:上限放宽过(4000 → 100000),钉死的用例会在放宽那天
205
+ // 悄悄变成"检查 4001 字"——它照样通过,却再也不测真正那道闸门了。
206
+ await assert.rejects(
207
+ () => dispatchInstruction({ text: 'x'.repeat(MAX_INSTRUCTION_CHARS + 1) }),
208
+ err => err.statusCode === 400 && /指令过长/.test(err.message),
209
+ );
205
210
  assert.equal((await readTasks()).length, before);
206
211
  });
207
212
 
213
+ test('刚好卡在上限的指令放行(闸门是 >,不是 >=)', async () => {
214
+ const { dispatchInstruction } = makeDispatcher();
215
+ const result = await dispatchInstruction({
216
+ text: 'x'.repeat(MAX_INSTRUCTION_CHARS),
217
+ autoRun: false,
218
+ });
219
+ // 整段正文原样落进任务:截断发生在"下发预览"那一层,不在派发这一层
220
+ assert.equal(result.task.desc.length, MAX_INSTRUCTION_CHARS);
221
+ });
222
+
223
+ test('指令正文原样进任务,不被悄悄截断', async () => {
224
+ const { dispatchInstruction } = makeDispatcher();
225
+ // 2026-09-29:用户粘一整段报错日志被 400 挡回来。除了放宽上限,还要钉住
226
+ // "整段进 desc" —— 因为 dispatchInstruction 是唯一拿得到原文的地方,
227
+ // 一旦这里截了,任务执行时模型看到的就是半截需求,而界面毫无异样。
228
+ const long = Array.from({ length: 3000 }, (_, i) => `第 ${i} 行:原始需求内容`).join('\n');
229
+ assert.ok(long.length > 20000 && long.length < MAX_INSTRUCTION_CHARS);
230
+ const result = await dispatchInstruction({ text: long, autoRun: false });
231
+ assert.equal(result.task.desc, long);
232
+ assert.equal(result.instruction.text, long, '指令流水那份也存完整原文(截断只发生在 5s 轮询下发时)');
233
+ });
234
+
208
235
  test('目标目录不存在:记一条 rejected 流水再抛 400', async () => {
209
236
  const { dispatchInstruction } = makeDispatcher();
210
237
  const missing = path.join(fakeHome, 'not-created-yet');
@@ -47,8 +47,9 @@ import {
47
47
  TRUTH_FILES,
48
48
  IMAGES_DIR,
49
49
  MAX_IMAGE_BYTES,
50
- MAX_ATTACHMENTS_PER_TASK,
51
50
  MAX_DEFAULT_PROMPT_CHARS,
51
+ MAX_INSTRUCTION_CHARS,
52
+ INSTRUCTION_PREVIEW_CHARS,
52
53
  readJson,
53
54
  writeJson,
54
55
  nowIso,
@@ -108,6 +109,7 @@ import {
108
109
  canonicalProjectPath,
109
110
  } from './projectRegistry.js';
110
111
  import { buildEnvContextBlock } from './envContext.js';
112
+ import { ensureMemoryStore } from '../../../../memory/store.js';
111
113
  import { createJobSettledRefresher } from '../aiContext/jobRefresh.js';
112
114
  import {
113
115
  createDispatcher,
@@ -124,6 +126,7 @@ import {
124
126
  claimReportSlot,
125
127
  readReports,
126
128
  appendReport,
129
+ withInstructionPreview,
127
130
  } from './orchestratorStore.js';
128
131
  import {
129
132
  buildRunningFacts,
@@ -966,7 +969,12 @@ ${subSummaries.map((s, i) => `\n### [${i + 1}] ${s.name} (${s.root})\n${s.summar
966
969
  }
967
970
 
968
971
  // 共享 helper:写入新附件
969
- async function writeAttachmentTo({ req, target, maxCount }) {
972
+ //
973
+ // 只负责把字节落盘 + 造出附件记录,**不碰 target.attachments** —— 由调用方决定挂到哪。
974
+ // 之前这里既 push 了一次、调用方又 push 一次,而两处指向同一个数组(target 是浅拷贝),
975
+ // 于是每次上传都在 tasks.json 里留下一条 id 完全相同的幽灵附件:附件数翻倍、
976
+ // 缩略图列表出现重复项,删一条另一条还在。
977
+ async function writeAttachmentTo({ req, target }) {
970
978
  if (!req.body || !(req.body instanceof Buffer) || req.body.length === 0) {
971
979
  throw new HttpError(400, '请求体为空');
972
980
  }
@@ -979,10 +987,6 @@ ${subSummaries.map((s, i) => `\n### [${i + 1}] ${s.name} (${s.root})\n${s.summar
979
987
  if (!ext) {
980
988
  throw new HttpError(400, `不支持的文件类型(仅允许 ${[...ALLOWED_EXTS].join(', ')})`);
981
989
  }
982
- if (!Array.isArray(target.attachments)) target.attachments = [];
983
- if (target.attachments.length >= maxCount) {
984
- throw new HttpError(400, `附件已达上限 ${maxCount} 个`);
985
- }
986
990
 
987
991
  const attId = genId();
988
992
  await fsp.mkdir(target.storageDir, { recursive: true });
@@ -990,7 +994,7 @@ ${subSummaries.map((s, i) => `\n### [${i + 1}] ${s.name} (${s.root})\n${s.summar
990
994
  const storedPath = path.join(target.storageDir, storedName);
991
995
  await fsp.writeFile(storedPath, req.body);
992
996
 
993
- const attachment = {
997
+ return {
994
998
  id: attId,
995
999
  originalName,
996
1000
  mimeType,
@@ -1000,9 +1004,6 @@ ${subSummaries.map((s, i) => `\n### [${i + 1}] ${s.name} (${s.root})\n${s.summar
1000
1004
  absolutePath: storedPath,
1001
1005
  createdAt: nowIso()
1002
1006
  };
1003
- target.attachments.push(attachment);
1004
- target.updatedAt = nowIso();
1005
- return attachment;
1006
1007
  }
1007
1008
 
1008
1009
  // 子任务附件
@@ -1013,7 +1014,7 @@ ${subSummaries.map((s, i) => `\n### [${i + 1}] ${s.name} (${s.root})\n${s.summar
1013
1014
  const task = (data.tasks || []).find(t => t.id === taskId);
1014
1015
  if (!task) throw new HttpError(404, '任务不存在');
1015
1016
  const target = { ...task, storageDir: path.join(IMAGES_DIR, '_task-' + taskId) };
1016
- const att = await writeAttachmentTo({ req, target, maxCount: MAX_ATTACHMENTS_PER_TASK });
1017
+ const att = await writeAttachmentTo({ req, target });
1017
1018
  task.attachments = Array.isArray(task.attachments) ? task.attachments : [];
1018
1019
  task.attachments.push(att);
1019
1020
  task.updatedAt = nowIso();
@@ -1157,6 +1158,23 @@ ${subSummaries.map((s, i) => `\n### [${i + 1}] ${s.name} (${s.root})\n${s.summar
1157
1158
  });
1158
1159
  });
1159
1160
 
1161
+ /**
1162
+ * 铺一次记忆库种子(幂等,only-if-missing)。
1163
+ *
1164
+ * **为什么在这里而不是 taskRunner 每次派发时**:目录不存在时若每次派发都尝试
1165
+ * 铺,等于把"有没有记忆"绑在"这次有没有干活"上;而且首次铺种子的 IO 抖动会
1166
+ * 落在派发的关键路径上。注册路由时铺一次,语义是"工作台起来了,记忆库就位"。
1167
+ *
1168
+ * 失败只记日志不抛:记忆库铺不出来不该让整个工作台起不来
1169
+ * (与上面 resolveEnvContext 同一原则)。真正注入时 taskRunner 拿不到项目索引
1170
+ * 会自动降级成"只有全局那层",Agent 照样能用。
1171
+ */
1172
+ ensureMemoryStore()
1173
+ .then(({ created }) => {
1174
+ if (created.length) logger.info(`[workbench] 已铺记忆库种子: ${created.join(', ')}`);
1175
+ })
1176
+ .catch((err) => logger.warn('[workbench] 铺记忆库种子失败,记忆功能降级:', err?.message || err));
1177
+
1160
1178
  /**
1161
1179
  * 「工作台任务执行结束」→ 定向刷新快照。
1162
1180
  *
@@ -1165,8 +1183,7 @@ ${subSummaries.map((s, i) => `\n### [${i + 1}] ${s.name} (${s.root})\n${s.summar
1165
1183
  *
1166
1184
  * `bus.on` 前先 `off` 掉上一个:bus 是模块级单例,重复注册(例如同一进程里
1167
1185
  * 再次装配路由)会让同一次 job 结束被刷好几遍。
1168
- */
1169
- if (jobSettledHandler) bus.off('event', jobSettledHandler);
1186
+ */ if (jobSettledHandler) bus.off('event', jobSettledHandler);
1170
1187
  jobSettledHandler = createJobSettledRefresher({
1171
1188
  getSnapshotter: getAiContextSnapshotter,
1172
1189
  onError: (err) => logger.warn('[workbench] 任务结束后刷新工作区快照失败:', err?.message || err),
@@ -1215,7 +1232,14 @@ ${subSummaries.map((s, i) => `\n### [${i + 1}] ${s.name} (${s.root})\n${s.summar
1215
1232
  success: true,
1216
1233
  active: state.active,
1217
1234
  updatedAt: state.updatedAt,
1218
- instructions: state.instructions,
1235
+ // ⚠️ 这个接口是 5s 轮询的。指令正文在**下发这一份**里被截到
1236
+ // INSTRUCTION_PREVIEW_CHARS(落盘那份不截,见 shared.js 的注释)。
1237
+ // 指令上限放宽到十万字之后不截就是 200 × 100000 = 20MB 一轮。
1238
+ instructions: state.instructions.map(withInstructionPreview),
1239
+ // 指令正文上限跟着状态一起下发:前端要显示"还剩多少字"就得知道这个数,
1240
+ // 而它归服务端定(派发校验也在这儿)。前端不再自己写一份数字 ——
1241
+ // 之前 maxlength 那种"界面说能发、后端 400"的分叉就是这么来的。
1242
+ maxInstructionChars: MAX_INSTRUCTION_CHARS,
1219
1243
  // 默认提示词跟着这份状态一起下发:控制台要拿它显示"当前会附带什么",
1220
1244
  // 设置弹窗打开时也不必再单独取一次(单条上限 8000 字,体积可控)
1221
1245
  defaultPrompt: state.defaultPrompt,
@@ -1463,12 +1487,12 @@ ${subSummaries.map((s, i) => `\n### [${i + 1}] ${s.name} (${s.root})\n${s.summar
1463
1487
  * 为什么需要这一层:§17 的上传端点都要求 taskId / subId 当挂载点,而派发这一刻
1464
1488
  * 任务还不存在。所以先写进 `_dispatch/`,**不写任何 JSON** —— 附件记录回给前端持有,
1465
1489
  * 派发时再按 id 搬进任务目录。
1466
- * 代价是服务端在这里无状态:它不知道前端一共攒了几个,附件数上限只能在派发时统一卡。
1490
+ * 代价是服务端在这里无状态:它不知道前端一共攒了几个(数量本来也不设上限,
1491
+ * 所以这份无状态不再有代价 —— 真要卡也只能卡在派发那一刻,而那时已经晚了)。
1467
1492
  */
1468
1493
  app.post('/api/workbench/orchestrator/attachments', rawAttachment, asyncRoute(async (req, res) => {
1469
1494
  await fsp.mkdir(DISPATCH_STAGING_DIR, { recursive: true });
1470
- const target = { attachments: [], storageDir: DISPATCH_STAGING_DIR };
1471
- const att = await writeAttachmentTo({ req, target, maxCount: MAX_ATTACHMENTS_PER_TASK });
1495
+ const att = await writeAttachmentTo({ req, target: { storageDir: DISPATCH_STAGING_DIR } });
1472
1496
  res.json({ success: true, attachment: att });
1473
1497
  }));
1474
1498