dsh-agent-toolkit 0.2.8 → 0.3.0

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/lib/index.js CHANGED
@@ -5,14 +5,15 @@ import { access, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
5
5
  import { join, resolve } from "node:path";
6
6
  import yaml from "js-yaml";
7
7
  import { expandHomePath, resolveDshHome } from "@deepseek-ai/dsh-home-paths";
8
- import { defineTool } from "@deepseek-ai/dsh-tools";
8
+ import { RUN_CODE_NAME, defineTool } from "@deepseek-ai/dsh-tools";
9
+ import { bindScopeParent, createScope, scopeOf } from "@deepseek-ai/dsh-scope";
9
10
  import { createUserMessage } from "@deepseek-ai/dsh-llm";
10
11
  import { existsSync } from "node:fs";
11
12
  import { SessionId } from "@deepseek-ai/dsh-session";
12
13
  import { credentialRef } from "@deepseek-ai/dsh-credentials";
13
14
  import * as lark from "@larksuiteoapi/node-sdk";
14
15
  import { randomBytes, randomUUID } from "node:crypto";
15
- import { bindScopeParent, createScope, scopeOf } from "@deepseek-ai/dsh-scope";
16
+ import { Cron } from "croner";
16
17
  import { setupUsage } from "@dsh-agent-toolkit/token-usage";
17
18
  //#region src/agents/store.ts
18
19
  /** agents 注册表存储域声明:记录 schema + domain 布局的单一来源。 */
@@ -34,7 +35,8 @@ const AgentRecordSchema = z$1.object({
34
35
  model: z$1.string()
35
36
  }).optional(),
36
37
  tools: z$1.object({ allow: z$1.array(z$1.string()).min(1) }).optional(),
37
- builtin: z$1.boolean().optional()
38
+ builtin: z$1.boolean().optional(),
39
+ visibleInTeam: z$1.boolean().optional()
38
40
  });
39
41
  /** domain 名/表名受 UNIT_NAME_RE 约束(^[a-z][a-z0-9_]*$),不允许连字符。 */
40
42
  const agentToolkitDomain = defineDomain({
@@ -60,12 +62,19 @@ function migrateAgentRecord(record) {
60
62
  persona
61
63
  } : rest;
62
64
  }
65
+ /** 团队可见性判定:缺省(undefined)与 true 均可见;仅显式 false 隐藏。 */
66
+ function isTeamVisible(role) {
67
+ return role.visibleInTeam !== false;
68
+ }
63
69
  //#endregion
64
70
  //#region src/channels/basic-tools.ts
65
71
  const BASIC_TOOLS = [
66
72
  {
67
73
  id: "@deepseek-ai/dsh-persona",
68
- config: { text: "You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}." }
74
+ config: {
75
+ prefix: "You are a coding agent powered by the {{model}} model. If you need information or a decision from the user, ask directly in your reply and wait for their next message.",
76
+ suffix: "Your working directory is {{cwd}}."
77
+ }
69
78
  },
70
79
  {
71
80
  id: "@deepseek-ai/dsh-agent-instructions",
@@ -78,10 +87,10 @@ const BASIC_TOOLS = [
78
87
  config: { sampleOverCapGlobResults: false }
79
88
  }
80
89
  ];
81
- /** 原生工具名(白名单 UI 与存量迁移用):与 BASIC_TOOLS 挂载插件注册的工具名一一对应。
82
- * 名字来源(摘自 deepseek-harness 源码):dsh-tool-pwsh/dsh-tool-bash → 'pwsh'/'bash'(平台互斥);
83
- * dsh-tool-fs 'read'/'write'/'edit'/'read_image';dsh-tool-fs-search → 'glob'/'grep'。
84
- * 这些工具 scoped 挂载在 agentCtx,不出现在顶层 ctx.tools.schemas(),故需显式常量。 */
90
+ /** 内置工具名常量(兜底名单):agentPresets 缺席或 standing 枚举失败时,Agents 面板名册
91
+ * 与存量迁移回退到这份常量。语义已降级为"兜底",不再承诺是完整原生工具面——完整面 =
92
+ * 团队 preset 动态枚举(agents/tool-catalog.ts)。explorer 只读白名单仍从本常量派生
93
+ * (刻意的最小集,不追求完整)。 */
85
94
  const NATIVE_TOOL_NAMES = [
86
95
  process.platform === "win32" ? "pwsh" : "bash",
87
96
  "read",
@@ -93,10 +102,50 @@ const NATIVE_TOOL_NAMES = [
93
102
  ];
94
103
  //#endregion
95
104
  //#region src/agents/builtin.ts
96
- /** 内置保底 Agent 记录:main + explorer(只读白名单)/ general(不限制)。 */
97
- /** explorer 默认白名单:原生工具去掉写类(write/edit)。shell 名平台互斥(win32=pwsh、其余=bash),
98
- * 必须从 NATIVE_TOOL_NAMES 派生不可写死——宿主 tools.restrict 对未知名响亮失败。 */
99
- const EXPLORER_READONLY_ALLOW = NATIVE_TOOL_NAMES.filter((n) => n !== "write" && n !== "edit");
105
+ /** 内置保底 Agent 记录:main + explorer(只读白名单 10 个)/ general(preset 面全量 20 个、禁二级委派)。 */
106
+ const SHELL_NAME = process.platform === "win32" ? "pwsh" : "bash";
107
+ /** 旧版 explorer 默认白名单(5 个):存量条件式迁移的等值比对基准。
108
+ * 独立于 EXPLORER_READONLY_ALLOW 保留旧派生式——比对基准不可随新名单漂移。
109
+ * shell 名平台互斥(win32=pwsh、其余=bash),必须从 NATIVE_TOOL_NAMES 派生不可写死。 */
110
+ const LEGACY_EXPLORER_ALLOW = NATIVE_TOOL_NAMES.filter((n) => n !== "write" && n !== "edit");
111
+ /** explorer 默认白名单(10 个):只读基础五件(shell/read/read_image/glob/grep,旧派生不变)
112
+ * + preset 面只读安全五件(web_search/todo_write/job_list/job_output/skill——skill 加载的是
113
+ * 指令文本,本身只读,用户决策默认可用)。
114
+ * 编排类(ralph/workflow)、写文件类(write/edit)、job_kill、ask_user_question、
115
+ * goal 三件套、exit_plan_mode 不进只读名单。 */
116
+ const EXPLORER_READONLY_ALLOW = [
117
+ ...LEGACY_EXPLORER_ALLOW,
118
+ "web_search",
119
+ "todo_write",
120
+ "job_list",
121
+ "job_output",
122
+ "skill"
123
+ ];
124
+ /** general 默认白名单(20 个):agent-team preset standing 面全量(2026-09-08 实测枚举),
125
+ * 不含 team_delegate(禁二级委派)与 run_code(宿主 Code Mode 保留名,restrict 拒收)。
126
+ * 静态名单:preset 面日后新增工具不自动进入,需人工再梳理(见 spec 非目标)。 */
127
+ const GENERAL_ALLOW = [
128
+ SHELL_NAME,
129
+ "read",
130
+ "write",
131
+ "edit",
132
+ "read_image",
133
+ "glob",
134
+ "grep",
135
+ "todo_write",
136
+ "web_search",
137
+ "ask_user_question",
138
+ "skill",
139
+ "exit_plan_mode",
140
+ "job_list",
141
+ "job_output",
142
+ "job_kill",
143
+ "create_goal",
144
+ "get_goal",
145
+ "update_goal",
146
+ "ralph",
147
+ "workflow"
148
+ ];
100
149
  const BUILTIN_AGENTS = [
101
150
  {
102
151
  id: "main",
@@ -120,7 +169,8 @@ const BUILTIN_AGENTS = [
120
169
  persona: `你是通用执行员。按任务书独立完成多步骤工作,可以读写文件、运行命令。
121
170
  动手前先阅读相关 AGENTS.md 并遵循项目约定;完成后运行与改动相关的检查
122
171
  (测试/类型检查)验证改动,并在最终输出中报告验证结果。`,
123
- builtin: true
172
+ builtin: true,
173
+ tools: { allow: [...GENERAL_ALLOW] }
124
174
  }
125
175
  ];
126
176
  //#endregion
@@ -237,11 +287,15 @@ async function markImported(ctx) {
237
287
  const TOOLS_NATIVE_MIGRATED_KEY = "tools_native_migrated";
238
288
  /** explorer 只读白名单一次性并入的 meta 表标记键。 */
239
289
  const EXPLORER_READONLY_MIGRATED_KEY = "explorer_readonly_migrated";
290
+ /** tools.allow 一次性并入「preset 面 − 原生常量」差集的 meta 表标记键。 */
291
+ const TOOLS_PRESET_MIGRATED_KEY = "tools_preset_catalog_migrated";
292
+ /** 内置角色工具名单重选(explorer 5→10 / general 无→20)一次性迁移的 meta 表标记键。 */
293
+ const BUILTIN_TOOLS_RECATALOG_MIGRATED_KEY = "builtin_tools_recatalog_migrated";
240
294
  /**
241
- * 打开 dsh_agent_toolkit 域 → 首启 YAML 导入 → 旧记录迁移(promptLayers/原生并入/explorer 只读)→
295
+ * 打开 dsh_agent_toolkit 域 → 首启 YAML 导入 → 旧记录迁移(promptLayers/原生并入/preset 差集并入/explorer 只读/内置名单重选)→
242
296
  * 缺 main/explorer/general 时种入内置 → 构建内存缓存。域由 apply 统一 open(storage-domain 同名单开),此处只消费表句柄。
243
297
  */
244
- async function createRegistry(warn, tables) {
298
+ async function createRegistry(warn, tables, listPresetTools) {
245
299
  const { agents, meta } = tables;
246
300
  await importRolesYaml({
247
301
  agents,
@@ -262,6 +316,25 @@ async function createRegistry(warn, tables) {
262
316
  if (next !== record) await agents.put(id, next);
263
317
  }
264
318
  if (!nativeMigrated) await meta.put(TOOLS_NATIVE_MIGRATED_KEY, { value: "1" });
319
+ if (meta.get("tools_preset_catalog_migrated") === void 0 && listPresetTools !== void 0) {
320
+ let extra;
321
+ try {
322
+ extra = (await listPresetTools()).filter((n) => !NATIVE_TOOL_NAMES.includes(n));
323
+ } catch {
324
+ extra = void 0;
325
+ }
326
+ if (extra !== void 0 && extra.length > 0) {
327
+ for (const [id, record] of agents.entries()) {
328
+ if (record.builtin === true || record.tools === void 0) continue;
329
+ const missing = extra.filter((n) => !record.tools.allow.includes(n));
330
+ if (missing.length > 0) await agents.put(id, {
331
+ ...record,
332
+ tools: { allow: [...record.tools.allow, ...missing] }
333
+ });
334
+ }
335
+ await meta.put(TOOLS_PRESET_MIGRATED_KEY, { value: "1" });
336
+ }
337
+ }
265
338
  if (!(meta.get("explorer_readonly_migrated") !== void 0)) {
266
339
  const explorer = agents.get("explorer");
267
340
  if (explorer !== void 0 && explorer.tools === void 0) await agents.put("explorer", {
@@ -270,6 +343,24 @@ async function createRegistry(warn, tables) {
270
343
  });
271
344
  await meta.put(EXPLORER_READONLY_MIGRATED_KEY, { value: "1" });
272
345
  }
346
+ if (meta.get("builtin_tools_recatalog_migrated") === void 0) {
347
+ const explorer = agents.get("explorer");
348
+ const legacyShapes = [LEGACY_EXPLORER_ALLOW, [
349
+ ...LEGACY_EXPLORER_ALLOW,
350
+ "write",
351
+ "edit"
352
+ ]];
353
+ if (explorer?.builtin === true && explorer.tools !== void 0 && legacyShapes.some((shape) => explorer.tools.allow.length === shape.length && explorer.tools.allow.every((n, i) => n === shape[i]))) await agents.put("explorer", {
354
+ ...explorer,
355
+ tools: { allow: [...EXPLORER_READONLY_ALLOW] }
356
+ });
357
+ const general = agents.get("general");
358
+ if (general?.builtin === true && general.tools === void 0) await agents.put("general", {
359
+ ...general,
360
+ tools: { allow: [...GENERAL_ALLOW] }
361
+ });
362
+ await meta.put(BUILTIN_TOOLS_RECATALOG_MIGRATED_KEY, { value: "1" });
363
+ }
273
364
  await seedBuiltins(agents);
274
365
  const cache = /* @__PURE__ */ new Map();
275
366
  for (const [id, record] of agents.entries()) cache.set(id, record);
@@ -319,6 +410,27 @@ async function seedBuiltins(agents) {
319
410
  for (const builtin of BUILTIN_AGENTS) if (agents.get(builtin.id) === void 0) await agents.put(builtin.id, builtin);
320
411
  }
321
412
  //#endregion
413
+ //#region src/agents/tool-catalog.ts
414
+ function createToolCatalog(ctx, presetId) {
415
+ return {
416
+ async listPresetTools() {
417
+ const presets = ctx.get("agentPresets", false);
418
+ if (presets === void 0) return [...NATIVE_TOOL_NAMES];
419
+ try {
420
+ const key = await presets.standingKeyFor(presetId);
421
+ const global = new Set(ctx.tools.schemas().map((s) => s.name));
422
+ return ctx.tools.schemas(key).map((s) => s.name).filter((n) => !global.has(n) && n !== RUN_CODE_NAME).sort();
423
+ } catch (error) {
424
+ ctx.logger.warn(`dsh-agent-toolkit: 枚举 preset "${presetId}" 工具面失败,回退内置常量:${error instanceof Error ? error.message : String(error)}`);
425
+ return [...NATIVE_TOOL_NAMES];
426
+ }
427
+ },
428
+ listGlobalTools() {
429
+ return ctx.tools.schemas().map((s) => s.name).filter((n) => n !== RUN_CODE_NAME);
430
+ }
431
+ };
432
+ }
433
+ //#endregion
322
434
  //#region src/shared/storage.ts
323
435
  function openDomainSafely(ctx, domain, warn, beforeClose) {
324
436
  const ready = ctx.storageDomain.open(domain);
@@ -1313,10 +1425,19 @@ function createDelegateTool(toolName, deps) {
1313
1425
  async execute(args, exec) {
1314
1426
  const parent = exec.agent;
1315
1427
  if (!parent) throw new Error("team_delegate 需要调用方 agent(exec.agent 为空)");
1316
- const roster = deps.roster().filter((r) => r.id !== "main");
1428
+ const roster = deps.roster().filter((r) => r.id !== "main" && isTeamVisible(r));
1317
1429
  const role = roster.find((r) => r.id === args.role);
1318
1430
  if (!role) throw new Error(`未知角色 "${args.role}"。可用角色:${roster.map((r) => r.id).join(", ")}`);
1319
1431
  const persona = deps.buildPersona(role);
1432
+ let toolFilter;
1433
+ if (role.tools !== void 0) {
1434
+ const visible = new Set(deps.visibleSurface(parent).filter((n) => n !== RUN_CODE_NAME));
1435
+ const effective = role.tools.allow.filter((n) => visible.has(n));
1436
+ const dropped = role.tools.allow.filter((n) => !visible.has(n));
1437
+ if (dropped.length > 0) deps.warn(`dsh-agent-toolkit: 角色 ${role.id} 白名单含本会话不可见工具,委派时忽略:${dropped.join(", ")}`);
1438
+ if (effective.length === 0) throw new Error(`dsh-agent-toolkit: 角色 ${role.id} 工具白名单求交后为空(原 ${role.tools.allow.length} 个均不可见):${role.tools.allow.join(", ")}`);
1439
+ toolFilter = { allow: effective };
1440
+ }
1320
1441
  const request = {
1321
1442
  label: `role:${role.id}: ${args.description}`,
1322
1443
  prompt: [{
@@ -1331,7 +1452,7 @@ function createDelegateTool(toolName, deps) {
1331
1452
  provider: role.model.provider,
1332
1453
  model: role.model.model
1333
1454
  } } : {},
1334
- ...role.tools !== void 0 ? { toolFilter: { allow: [...role.tools.allow] } } : {}
1455
+ ...toolFilter !== void 0 ? { toolFilter } : {}
1335
1456
  };
1336
1457
  const route = (role.model !== void 0 && role.model.provider !== "" && role.model.model !== "" ? role.model : void 0) ?? (typeof parent.options.provider === "string" && parent.options.provider !== "" && typeof parent.options.model === "string" && parent.options.model !== "" ? {
1337
1458
  provider: parent.options.provider,
@@ -1351,8 +1472,6 @@ function createDelegateTool(toolName, deps) {
1351
1472
  }
1352
1473
  //#endregion
1353
1474
  //#region src/delegate/index.ts
1354
- /** 团队名册段在 prompt 中的位置:紧随内置 subagent 段(116.5)之后。 */
1355
- const TEAM_SECTION_ORDER = 116.6;
1356
1475
  /**
1357
1476
  * 团队名册段文本:仅当该 agent scope 的可见工具里确有委派工具时渲染,否则空
1358
1477
  * (空段渲染时被丢弃)。工具白名单 restrict 只作用于 tools 视图,名册段必须
@@ -1360,7 +1479,7 @@ const TEAM_SECTION_ORDER = 116.6;
1360
1479
  */
1361
1480
  function teamSectionText(toolName, roles, toolVisible) {
1362
1481
  if (!toolVisible) return "";
1363
- return `你有一组可委派的成员:用 ${toolName} 把自包含的子任务委派给合适的成员,成员结果会作为工具返回值回到本对话。\n可用成员:\n${roles.filter((r) => r.id !== "main").map((r) => `${r.id}: ${r.description ?? r.name}`).join("\n")}`;
1482
+ return `你有一组可委派的成员:用 ${toolName} 把自包含的子任务委派给合适的成员,成员结果会作为工具返回值回到本对话。\n可用成员:\n${roles.filter((r) => r.id !== "main" && isTeamVisible(r)).map((r) => `${r.id}: ${r.description ?? r.name}`).join("\n")}`;
1364
1483
  }
1365
1484
  /**
1366
1485
  * 挂载 team_delegate 工具与函数式团队提示段。
@@ -1383,7 +1502,11 @@ function setupDelegate(ctx, config, registry, channels) {
1383
1502
  buildPersona: (role) => buildAgentPersona({ rules: config.rules }, role, role.model),
1384
1503
  startRun: (pr, request) => ctx.subagents.start(pr, request),
1385
1504
  active: channels.active,
1386
- recordRoute: channels.recordRoute
1505
+ recordRoute: channels.recordRoute,
1506
+ visibleSurface: (agent) => agent.ctx.tools.schemas(scopeOf(agent.ctx)).map((s) => s.name),
1507
+ warn: (msg) => {
1508
+ ctx.logger.warn(msg);
1509
+ }
1387
1510
  }));
1388
1511
  };
1389
1512
  ctx.on("subagent/provider-added", (p) => {
@@ -1408,7 +1531,7 @@ function setupDelegate(ctx, config, registry, channels) {
1408
1531
  else ctx.logger.info(`dsh-agent-toolkit: subagent provider "${provider}" 尚未注册;team_delegate 将等它出现时挂载`);
1409
1532
  ctx.systemPrompt.section({
1410
1533
  name: "plugin:dsh-agent-toolkit:team",
1411
- order: TEAM_SECTION_ORDER,
1534
+ order: ctx.systemPrompt.getSectionOrder("TOOL_SUBAGENT") + .1,
1412
1535
  text: (context) => teamSectionText(config.toolName, registry.list(), ctx.tools.get(config.toolName, context.scope) !== void 0)
1413
1536
  });
1414
1537
  }
@@ -1504,7 +1627,7 @@ function createAgentsApiHandler(deps) {
1504
1627
  }
1505
1628
  if (sub === "/tools" && method === "GET") {
1506
1629
  json$1(res, 200, {
1507
- native: [...NATIVE_TOOL_NAMES],
1630
+ preset: await deps.listPresetTools(),
1508
1631
  global: deps.listTools()
1509
1632
  });
1510
1633
  return;
@@ -1631,7 +1754,7 @@ function buildCreateAgentGuidance(input) {
1631
1754
  "3. 迭代:用户有修改意见时按意见修订名称、描述、个性和工具后再次确认,直到用户明确确认。"
1632
1755
  ];
1633
1756
  if (input.requirement !== "") lines.push("", "## 用户初始需求", `用户已在命令中提供初始需求:「${input.requirement}」。请据此减少提问轮次,仅就不明确的点提问。`);
1634
- lines.push("", "## 现有 Agent id(不可复用)", input.agentIds.join(", "), "id 规则:小写字母开头,仅含小写字母/数字/连字符([a-z0-9-]),最长 32 字符。", "", "## 可用工具清单", `原生工具:${NATIVE_TOOL_NAMES.join(", ")}`, `全局工具:${input.globalTools.join(", ")}`, "省略 tools 字段表示不限制(Agent 可使用全部工具)。一旦给出白名单,该 Agent 只有列出的工具可用:通常应保留原生工具,否则失去读文件/搜索/执行命令等基本能力(最终取舍按需求判断,如只读角色可去掉 write/edit)。");
1757
+ lines.push("", "## 现有 Agent id(不可复用)", input.agentIds.join(", "), "id 规则:小写字母开头,仅含小写字母/数字/连字符([a-z0-9-]),最长 32 字符。", "", "## 可用工具清单", `团队 preset 工具:${input.presetTools.join(", ")}`, `全局工具:${input.globalTools.join(", ")}`, "省略 tools 字段表示不限制(Agent 可使用全部工具)。一旦给出白名单,该 Agent 只有列出的工具可用:通常应保留 read/glob/grep/shell 等基础工具,否则失去读文件/搜索/执行命令等基本能力(最终取舍按需求判断,如只读角色可去掉 write/edit)。");
1635
1758
  if (input.origin === void 0) lines.push("", "## 落库", "当前宿主无 web 服务,无法自动落库。用户确认推荐后,请把最终配置完整输出给用户,并提示其打开 Agents 面板按推荐内容手动创建。");
1636
1759
  else lines.push("", "## 落库(用户明确确认后执行)", "用你的 shell 工具调用 Agents 面板同一 HTTP 端点完成创建:", `1. 先 GET ${input.origin}/dsh-agent-toolkit/api/agents 复核所选 id 仍未被占用;`, `2. 再 PUT ${input.origin}/dsh-agent-toolkit/api/agents/<id>,请求体为 JSON(不要在 body 中携带 id 或 builtin 字段):`, " {\"name\":\"...\",\"description\":\"...\",\"persona\":\"...\",\"tools\":{\"allow\":[\"...\"]}}", " (description/persona/tools 均可省略;省略 tools 表示不限制)", "3. curl 示例(Windows 的 pwsh 里用 curl.exe):", ` curl.exe -s -X PUT "${input.origin}/dsh-agent-toolkit/api/agents/<id>" -H "Content-Type: application/json" -d "{\\"name\\":\\"...\\"}"`, `4. 返回 200 后必须再 GET ${input.origin}/dsh-agent-toolkit/api/agents,在返回列表中找到该 id 的记录,把它的 name/description/persona/tools 关键字段展示给用户,作为落库证据;`, "5. 落库成功后告知用户可在 Agents 面板查看、并可被 team_delegate 委派;任一步返回 4xx 则把错误信息展示给用户,修正后重试。");
1637
1760
  return lines.join("\n");
@@ -1648,12 +1771,13 @@ function setupCreateAgentCommand(ctx, deps) {
1648
1771
  name: "create-agent",
1649
1772
  description: "交互式创建 Agent 团队成员:访谈澄清需求 → 推荐配置 → 确认后经面板 API 落库",
1650
1773
  input: { hint: "初始需求描述,可空" },
1651
- handler: ({ rawInput, agent }) => {
1774
+ handler: async ({ rawInput, agent }) => {
1652
1775
  const webServer = ctx.get("webServer");
1653
1776
  const origin = webServer === void 0 ? void 0 : `http://127.0.0.1:${webServer.port}`;
1654
1777
  const text = buildCreateAgentGuidance({
1655
1778
  requirement: rawInput.trim(),
1656
1779
  agentIds: deps.registry.list().map((agent) => agent.id),
1780
+ presetTools: await deps.listPresetTools(),
1657
1781
  globalTools: deps.listTools(),
1658
1782
  origin
1659
1783
  });
@@ -1682,8 +1806,8 @@ const TOOLKIT_PERSONA_SECTION = "prompt-stack:persona";
1682
1806
  * (global + 祖先 scope 层),own 层(agentCtx 直接挂载)的名字既不可 restrict 也
1683
1807
  * 不受 restrict 影响,直接挂进 agentCtx 的白名单会抛 unknown global tools。
1684
1808
  */
1685
- async function setupAgentScope(agentCtx, hooks, toolsScope) {
1686
- await toolsScope.join(agentCtx);
1809
+ async function setupAgentScope(agentCtx, hooks, joiner) {
1810
+ await joiner.join(agentCtx);
1687
1811
  if (hooks.persona !== void 0) agentCtx.systemPrompt.section({
1688
1812
  name: TOOLKIT_PERSONA_SECTION,
1689
1813
  order: 10,
@@ -1694,10 +1818,56 @@ async function setupAgentScope(agentCtx, hooks, toolsScope) {
1694
1818
  order: section.order,
1695
1819
  text: section.text
1696
1820
  });
1697
- if (hooks.tools !== void 0) agentCtx.tools.restrict({ allow: hooks.tools });
1821
+ if (hooks.tools !== void 0) {
1822
+ const visible = new Set(agentCtx.tools.schemas(scopeOf(agentCtx)).map((s) => s.name).filter((n) => n !== RUN_CODE_NAME));
1823
+ const effective = hooks.tools.filter((n) => visible.has(n));
1824
+ const dropped = hooks.tools.filter((n) => !visible.has(n));
1825
+ if (dropped.length > 0) agentCtx.logger.warn(`dsh-agent-toolkit: 工具白名单含本会话不可见工具,已忽略:${dropped.join(", ")}`);
1826
+ if (effective.length === 0) throw new Error(`dsh-agent-toolkit: 工具白名单求交后为空(原 ${hooks.tools.length} 个均不可见):${hooks.tools.join(", ")}`);
1827
+ agentCtx.tools.restrict({ allow: effective });
1828
+ }
1829
+ }
1830
+ //#endregion
1831
+ //#region src/channels/agents-port.ts
1832
+ function createAgentsPort(ctx, joiner, ownedSessions) {
1833
+ function adaptAgent(handle) {
1834
+ const { agent } = handle;
1835
+ return {
1836
+ sessionId: String(agent.id),
1837
+ followup: (message) => agent.followup(message),
1838
+ cancel: () => agent.cancel({ kind: "user" }),
1839
+ whenIdle: () => agent.whenIdle()
1840
+ };
1841
+ }
1842
+ return {
1843
+ async create(input) {
1844
+ ownedSessions?.add(input.sessionId);
1845
+ return adaptAgent(await ctx.agents.create({
1846
+ sessionId: SessionId(input.sessionId),
1847
+ meta: { cwd: input.cwd },
1848
+ ...input.agentOptions !== void 0 ? { agentOptions: input.agentOptions } : {},
1849
+ setup: (agentCtx) => setupAgentScope(agentCtx, input.hooks, joiner)
1850
+ }));
1851
+ },
1852
+ async resume(input) {
1853
+ ownedSessions?.add(input.sessionId);
1854
+ return adaptAgent(await ctx.agents.resume({
1855
+ resumeSessionId: SessionId(input.sessionId),
1856
+ ...input.agentOptions !== void 0 ? { agentOptions: input.agentOptions } : {},
1857
+ setup: (agentCtx) => setupAgentScope(agentCtx, input.hooks, joiner)
1858
+ }));
1859
+ }
1860
+ };
1698
1861
  }
1699
1862
  //#endregion
1700
1863
  //#region src/channels/feishu/api.ts
1864
+ /** 从 lark SDK 抛出的 axios 错误提取飞书业务错误码(无则 undefined)。 */
1865
+ function feishuErrorCode(error) {
1866
+ const data = error?.response?.data;
1867
+ if (typeof data !== "object" || data === null) return void 0;
1868
+ const code = data.code;
1869
+ return typeof code === "number" ? code : void 0;
1870
+ }
1701
1871
  /** 附件服务接受的图片媒体类型(与宿主 attachment v1 一致)。 */
1702
1872
  const IMAGE_MEDIA_TYPES = [
1703
1873
  "image/png",
@@ -1973,27 +2143,10 @@ const STATUS_FINAL = {
1973
2143
  error: "❌ 输出出错",
1974
2144
  cancelled: "⏹ 已取消"
1975
2145
  };
1976
- /** 新卡固定开销字节数(状态行 + 结构,粗算进预算)。 */
1977
- const CARD_FIXED_BYTES = 64;
2146
+ /** 拆卡定格状态行文案(旧卡内容已接续到下一张卡片)。 */
2147
+ const STATUS_CONTINUED = "📦 内容较长,已接续到下一张卡片";
1978
2148
  /** 单卡组件数安全上限(飞书硬上限 200;面板按 2 计:面板 + 内嵌 markdown)。 */
1979
2149
  const CARD_ELEMENT_LIMIT = 190;
1980
- /** 按 UTF-8 字节上限截头(保留头部),不劈开多字节字符与代理对。 */
1981
- function sliceByBytes(text, maxBytes) {
1982
- if (Buffer.byteLength(text, "utf8") <= maxBytes) return text;
1983
- let lo = 0;
1984
- let hi = text.length;
1985
- while (lo < hi) {
1986
- const mid = Math.ceil((lo + hi) / 2);
1987
- if (Buffer.byteLength(text.slice(0, mid), "utf8") <= maxBytes) lo = mid;
1988
- else hi = mid - 1;
1989
- }
1990
- let cut = lo;
1991
- if (cut > 0) {
1992
- const code = text.charCodeAt(cut - 1);
1993
- if (code >= 55296 && code <= 56319) cut -= 1;
1994
- }
1995
- return text.slice(0, cut);
1996
- }
1997
2150
  /** 按 UTF-8 字节上限截尾(保留尾部),不劈开多字节字符与代理对;截断时头部加省略标记。 */
1998
2151
  function sliceTailByBytes(text, maxBytes) {
1999
2152
  if (Buffer.byteLength(text, "utf8") <= maxBytes) return text;
@@ -2013,8 +2166,29 @@ function sliceTailByBytes(text, maxBytes) {
2013
2166
  }
2014
2167
  return PROCESS_OMITTED + text.slice(cut);
2015
2168
  }
2169
+ /** JSON 串内内容的转义后字节数(去首尾引号);卡片 DSL 真实字节记账用。 */
2170
+ function escapedLen(s) {
2171
+ return Buffer.byteLength(JSON.stringify(s), "utf8") - 2;
2172
+ }
2173
+ /** 按转义后字节上限截头(保留头部),不劈开多字节字符与代理对。 */
2174
+ function sliceByEscapedBytes(text, maxBytes) {
2175
+ if (escapedLen(text) <= maxBytes) return text;
2176
+ let lo = 0;
2177
+ let hi = text.length;
2178
+ while (lo < hi) {
2179
+ const mid = Math.ceil((lo + hi) / 2);
2180
+ if (escapedLen(text.slice(0, mid)) <= maxBytes) lo = mid;
2181
+ else hi = mid - 1;
2182
+ }
2183
+ let cut = lo;
2184
+ if (cut > 0) {
2185
+ const code = text.charCodeAt(cut - 1);
2186
+ if (code >= 55296 && code <= 56319) cut -= 1;
2187
+ }
2188
+ return text.slice(0, cut);
2189
+ }
2016
2190
  /** 新卡:仅状态行的流式卡;段后续经插入组件 API 动态加入。 */
2017
- function buildCardJson() {
2191
+ function buildCardJson(printStep) {
2018
2192
  return JSON.stringify({
2019
2193
  schema: "2.0",
2020
2194
  config: {
@@ -2022,7 +2196,7 @@ function buildCardJson() {
2022
2196
  summary: { content: "生成中…" },
2023
2197
  streaming_config: {
2024
2198
  print_frequency_ms: { default: 70 },
2025
- print_step: { default: 1 },
2199
+ print_step: { default: printStep },
2026
2200
  print_strategy: "fast"
2027
2201
  }
2028
2202
  },
@@ -2065,30 +2239,56 @@ const initialStreamState = () => ({
2065
2239
  carry: void 0
2066
2240
  });
2067
2241
  /** 把段序列的新增部分同步到卡片;新段 insert 到状态行之前,尾段增长走元素 update,满卡关流开续卡。 */
2068
- function planSync(state, segments, maxBytes, processMaxBytes) {
2242
+ function planSync(state, segments, maxBytes, processMaxBytes, printStep) {
2069
2243
  const ops = [];
2070
2244
  let { cardId, seq, cardBytes, cardElements, segCounter, closedSegCount, tail, carry } = state;
2071
- const ensureCard = () => {
2072
- if (cardId !== null) return;
2245
+ /** 约定:先改规划局部变量再 push——commit 捕获该 op 完成后的状态快照。 */
2246
+ const push = (op) => {
2247
+ const snap = {
2248
+ cardId,
2249
+ seq,
2250
+ cardBytes,
2251
+ cardElements,
2252
+ segCounter,
2253
+ closedSegCount,
2254
+ tail,
2255
+ carry
2256
+ };
2073
2257
  ops.push({
2074
- type: "create",
2075
- cardJson: buildCardJson()
2258
+ op,
2259
+ commit: () => ({ ...snap })
2076
2260
  });
2077
- ops.push({ type: "send" });
2261
+ };
2262
+ const ensureCard = () => {
2263
+ if (cardId !== null) return;
2264
+ const cardJson = buildCardJson(printStep);
2078
2265
  cardId = PENDING_CARD_ID;
2079
2266
  seq = 0;
2080
- cardBytes = CARD_FIXED_BYTES;
2267
+ cardBytes = Buffer.byteLength(cardJson, "utf8");
2081
2268
  cardElements = 1;
2269
+ push({
2270
+ type: "create",
2271
+ cardJson
2272
+ });
2273
+ push({ type: "send" });
2082
2274
  };
2083
2275
  const closeCard = () => {
2084
2276
  seq += 1;
2085
- ops.push({
2086
- type: "settings",
2087
- streaming: false,
2277
+ push({
2278
+ type: "update",
2279
+ elementId: STATUS_ELEMENT_ID,
2280
+ content: STATUS_CONTINUED,
2088
2281
  sequence: seq
2089
2282
  });
2283
+ seq += 1;
2090
2284
  cardId = null;
2091
2285
  tail = void 0;
2286
+ push({
2287
+ type: "settings",
2288
+ streaming: false,
2289
+ sequence: seq,
2290
+ summary: STATUS_CONTINUED
2291
+ });
2092
2292
  };
2093
2293
  let i = tail?.segIndex ?? closedSegCount;
2094
2294
  while (i < segments.length) {
@@ -2098,35 +2298,35 @@ function planSync(state, segments, maxBytes, processMaxBytes) {
2098
2298
  const elementContent = seg.kind === "text" ? content.slice(base) : content;
2099
2299
  if (tail !== void 0 && tail.segIndex === i) {
2100
2300
  if (elementContent !== tail.shownText) {
2101
- const delta = Buffer.byteLength(elementContent, "utf8") - Buffer.byteLength(tail.shownText, "utf8");
2301
+ const delta = escapedLen(elementContent) - escapedLen(tail.shownText);
2102
2302
  if (cardBytes + delta <= maxBytes) {
2103
2303
  seq += 1;
2104
- ops.push({
2105
- type: "update",
2106
- elementId: tail.elementId,
2107
- content: elementContent,
2108
- sequence: seq
2109
- });
2110
2304
  cardBytes += delta;
2111
2305
  tail = {
2112
2306
  ...tail,
2113
2307
  shownText: elementContent
2114
2308
  };
2309
+ push({
2310
+ type: "update",
2311
+ elementId: tail.elementId,
2312
+ content: elementContent,
2313
+ sequence: seq
2314
+ });
2115
2315
  } else if (seg.kind === "text") {
2116
- const piece = sliceByBytes(elementContent, Buffer.byteLength(tail.shownText, "utf8") + (maxBytes - cardBytes));
2316
+ const piece = sliceByEscapedBytes(elementContent, escapedLen(tail.shownText) + (maxBytes - cardBytes));
2117
2317
  if (piece.length > tail.shownText.length) {
2118
2318
  seq += 1;
2119
- ops.push({
2319
+ cardBytes += escapedLen(piece) - escapedLen(tail.shownText);
2320
+ tail = {
2321
+ ...tail,
2322
+ shownText: piece
2323
+ };
2324
+ push({
2120
2325
  type: "update",
2121
2326
  elementId: tail.elementId,
2122
2327
  content: piece,
2123
2328
  sequence: seq
2124
2329
  });
2125
- cardBytes += Buffer.byteLength(piece, "utf8") - Buffer.byteLength(tail.shownText, "utf8");
2126
- tail = {
2127
- ...tail,
2128
- shownText: piece
2129
- };
2130
2330
  }
2131
2331
  carry = {
2132
2332
  segIndex: i,
@@ -2147,21 +2347,17 @@ function planSync(state, segments, maxBytes, processMaxBytes) {
2147
2347
  continue;
2148
2348
  }
2149
2349
  if (seg.kind === "process") {
2150
- const windowBytes = Buffer.byteLength(elementContent, "utf8");
2151
- if (cardId !== null && (cardBytes + windowBytes > maxBytes || cardElements + 2 > CARD_ELEMENT_LIMIT)) {
2350
+ const elementId = `seg_${segCounter + 1}`;
2351
+ const elementJson = buildSegmentJson("process", elementId, elementContent);
2352
+ const elBytes = Buffer.byteLength(elementJson, "utf8");
2353
+ if (cardId !== null && (cardBytes + elBytes > maxBytes || cardElements + 2 > CARD_ELEMENT_LIMIT)) {
2152
2354
  closeCard();
2153
2355
  continue;
2154
2356
  }
2155
2357
  ensureCard();
2156
2358
  segCounter += 1;
2157
- const elementId = `seg_${segCounter}`;
2158
2359
  seq += 1;
2159
- ops.push({
2160
- type: "insert",
2161
- elementJson: buildSegmentJson("process", elementId, elementContent),
2162
- sequence: seq
2163
- });
2164
- cardBytes += windowBytes;
2360
+ cardBytes += elBytes;
2165
2361
  cardElements += 2;
2166
2362
  tail = {
2167
2363
  segIndex: i,
@@ -2169,6 +2365,11 @@ function planSync(state, segments, maxBytes, processMaxBytes) {
2169
2365
  base: 0,
2170
2366
  shownText: elementContent
2171
2367
  };
2368
+ push({
2369
+ type: "insert",
2370
+ elementJson,
2371
+ sequence: seq
2372
+ });
2172
2373
  } else {
2173
2374
  if (elementContent.length === 0) {
2174
2375
  closedSegCount = i + 1;
@@ -2181,17 +2382,14 @@ function planSync(state, segments, maxBytes, processMaxBytes) {
2181
2382
  continue;
2182
2383
  }
2183
2384
  ensureCard();
2184
- const piece = sliceByBytes(elementContent, maxBytes - cardBytes);
2185
- if (piece.length === 0) throw new Error(`cardMaxBytes=${maxBytes} 过小,扣固定开销后连一个字符都容纳不了`);
2385
+ const elementId = `seg_${segCounter + 1}`;
2386
+ const overhead = Buffer.byteLength(buildSegmentJson("text", elementId, ""), "utf8");
2387
+ const piece = sliceByEscapedBytes(elementContent, maxBytes - cardBytes - overhead);
2388
+ if (piece.length === 0) throw new Error(`cardMaxBytes=${maxBytes} 过小,扣基础卡与元素开销后连一个字符都容纳不了`);
2186
2389
  segCounter += 1;
2187
- const elementId = `seg_${segCounter}`;
2390
+ const elementJson = buildSegmentJson("text", elementId, piece);
2188
2391
  seq += 1;
2189
- ops.push({
2190
- type: "insert",
2191
- elementJson: buildSegmentJson("text", elementId, piece),
2192
- sequence: seq
2193
- });
2194
- cardBytes += Buffer.byteLength(piece, "utf8");
2392
+ cardBytes += Buffer.byteLength(elementJson, "utf8");
2195
2393
  cardElements += 1;
2196
2394
  tail = {
2197
2395
  segIndex: i,
@@ -2199,6 +2397,11 @@ function planSync(state, segments, maxBytes, processMaxBytes) {
2199
2397
  base,
2200
2398
  shownText: piece
2201
2399
  };
2400
+ push({
2401
+ type: "insert",
2402
+ elementJson,
2403
+ sequence: seq
2404
+ });
2202
2405
  if (piece.length < elementContent.length) {
2203
2406
  carry = {
2204
2407
  segIndex: i,
@@ -2214,19 +2417,21 @@ function planSync(state, segments, maxBytes, processMaxBytes) {
2214
2417
  closedSegCount = i + 1;
2215
2418
  i += 1;
2216
2419
  }
2217
- return {
2218
- state: {
2219
- cardId,
2220
- seq,
2221
- cardBytes,
2222
- cardElements,
2223
- segCounter,
2224
- closedSegCount,
2225
- tail,
2226
- carry
2227
- },
2228
- ops
2420
+ const snap = {
2421
+ cardId,
2422
+ seq,
2423
+ cardBytes,
2424
+ cardElements,
2425
+ segCounter,
2426
+ closedSegCount,
2427
+ tail,
2428
+ carry
2229
2429
  };
2430
+ ops.push({
2431
+ op: { type: "noop" },
2432
+ commit: () => ({ ...snap })
2433
+ });
2434
+ return { ops };
2230
2435
  }
2231
2436
  /** 定格:先 update 状态行(流式还开着),再关闭 + summary。 */
2232
2437
  function planFinalize(state, status) {
@@ -2256,6 +2461,10 @@ async function withRetry(fn, attempts = 3, baseDelayMs = 300) {
2256
2461
  }
2257
2462
  throw lastError;
2258
2463
  }
2464
+ /** 卡片输出异常时的用户提示(仅真实废弃一张已发卡时发送)。 */
2465
+ const ABANDON_NOTICE = "⚠️ 卡片输出异常,已在新卡片继续;如有内容缺失请重发。";
2466
+ /** 单次 flush 内连续废弃换卡的上限(防异常死循环;超限抛给出站链日志)。 */
2467
+ const MAX_ABANDON_PER_FLUSH = 3;
2259
2468
  var FeishuReplyHandle = class {
2260
2469
  api;
2261
2470
  chatId;
@@ -2265,6 +2474,7 @@ var FeishuReplyHandle = class {
2265
2474
  segments = [];
2266
2475
  tail = Promise.resolve();
2267
2476
  timer;
2477
+ planQueued = false;
2268
2478
  finalized = false;
2269
2479
  constructor(api, chatId, tunables, log) {
2270
2480
  this.api = api;
@@ -2272,7 +2482,6 @@ var FeishuReplyHandle = class {
2272
2482
  this.tunables = tunables;
2273
2483
  this.log = log;
2274
2484
  }
2275
- /** 惰性建卡:无文本输出的 turn 不产生空卡片。 */
2276
2485
  beginTurn() {
2277
2486
  return Promise.resolve();
2278
2487
  }
@@ -2297,49 +2506,204 @@ var FeishuReplyHandle = class {
2297
2506
  }
2298
2507
  this.flush();
2299
2508
  await this.tail;
2509
+ const hadCard = this.state.cardId !== null;
2300
2510
  const { ops } = planFinalize(this.state, status);
2301
- this.enqueue(() => this.exec(ops));
2302
- if (this.state.cardId === null && detail !== void 0) this.enqueue(() => withRetry(() => this.api.sendText(this.chatId, detail)).then(() => void 0));
2511
+ this.enqueue(async () => {
2512
+ for (const op of ops) if (await this.execOne({
2513
+ op,
2514
+ commit: (s) => s
2515
+ }) === "abandoned") return;
2516
+ });
2517
+ if (!hadCard && detail !== void 0) this.enqueue(() => withRetry(() => this.api.sendText(this.chatId, detail)).then(() => void 0));
2303
2518
  await this.tail;
2304
2519
  }
2305
2520
  notice(text) {
2306
2521
  this.enqueue(() => withRetry(() => this.api.sendText(this.chatId, text)).then(() => void 0));
2307
2522
  return this.tail.then(() => void 0);
2308
2523
  }
2524
+ /**
2525
+ * 规划入串行链:planSync 在执行点读最新已确认状态与最新 segments,
2526
+ * 在飞期间到达的 flush 只标位不重复规划(杜绝重复建卡/重复 insert)。
2527
+ */
2309
2528
  flush() {
2310
- const planned = planSync(this.state, this.segments, this.tunables.cardMaxBytes, this.tunables.processMaxBytes);
2311
- if (planned.ops.length === 0) return;
2312
- this.state = planned.state;
2313
- this.enqueue(() => this.exec(planned.ops));
2529
+ if (this.planQueued) return;
2530
+ this.planQueued = true;
2531
+ this.enqueue(async () => {
2532
+ this.planQueued = false;
2533
+ const { ops } = planSync(this.state, this.segments, this.tunables.cardMaxBytes, this.tunables.processMaxBytes, this.tunables.cardPrintStep);
2534
+ await this.exec(ops);
2535
+ });
2536
+ }
2537
+ /** 逐 op 确认执行;遇废弃从已确认状态重新规划续写(上限 MAX_ABANDON_PER_FLUSH 次)。 */
2538
+ async exec(ops) {
2539
+ let pending = ops;
2540
+ for (let attempts = 0;; attempts++) {
2541
+ let abandoned = false;
2542
+ for (const planned of pending) if (await this.execOne(planned) === "abandoned") {
2543
+ abandoned = true;
2544
+ break;
2545
+ }
2546
+ if (!abandoned) return;
2547
+ if (attempts >= MAX_ABANDON_PER_FLUSH) throw new Error("卡片连续废弃超限,本批输出放弃(下一 flush 继续)");
2548
+ pending = planSync(this.state, this.segments, this.tunables.cardMaxBytes, this.tunables.processMaxBytes, this.tunables.cardPrintStep).ops;
2549
+ }
2550
+ }
2551
+ /**
2552
+ * 单个 op:成功(或 insert 300301 视同成功)才 commit;
2553
+ * 失败按错误码分类:流式超时重激活重放 / 200860 废弃续写 / 未知错误 seq+2 重放一次。
2554
+ */
2555
+ async execOne(planned) {
2556
+ const { op } = planned;
2557
+ if (op.type === "noop") {
2558
+ this.commit(planned);
2559
+ return "ok";
2560
+ }
2561
+ const liveCard = this.state.cardId !== null && this.state.cardId !== "__pending__";
2562
+ try {
2563
+ await this.invokeThenCommit(planned);
2564
+ return "ok";
2565
+ } catch (error) {
2566
+ if (!liveCard) throw error;
2567
+ const code = feishuErrorCode(error);
2568
+ if (op.type === "insert" && code === 300301) {
2569
+ this.commit(planned);
2570
+ return "ok";
2571
+ }
2572
+ if (code === 200850 || code === 200510) {
2573
+ if (await this.reactivate()) try {
2574
+ await this.invokeThenCommit(planned, this.state.seq + 1);
2575
+ return "ok";
2576
+ } catch {
2577
+ return this.abandon("流式超时重激活后重放失败", true);
2578
+ }
2579
+ return this.abandon("流式超时且重激活失败", true);
2580
+ }
2581
+ if (code === 200860) return this.abandon("卡片超出平台大小上限", false);
2582
+ try {
2583
+ const retrySeq = (op.type === "insert" || op.type === "update" || op.type === "settings" ? op.sequence : this.state.seq) + 2;
2584
+ await this.invokeThenCommit(planned, retrySeq);
2585
+ return "ok";
2586
+ } catch (retryError) {
2587
+ if (op.type === "insert" && feishuErrorCode(retryError) === 300301) {
2588
+ this.commit(planned);
2589
+ return "ok";
2590
+ }
2591
+ return this.abandon("卡片操作重放失败", true);
2592
+ }
2593
+ }
2594
+ }
2595
+ /**
2596
+ * 执行 API 并在成功后 commit;seqOverride 用于重放(create 的真实 cardId 在此覆盖进状态)。
2597
+ * 带序号 op 的发送/提交序号一律取 max(本次序号, 已确认 seq+1):重放/重激活会推高已确认 seq,
2598
+ * 批内后续 op 的规划序号可能落后,照发会以非递增序号碰撞(平台可能视作幂等 no-op 静默丢 op →
2599
+ * 定格/关流 op 消失 → 卡死「输出中」)。
2600
+ */
2601
+ async invokeThenCommit(planned, seqOverride) {
2602
+ const effectiveSeq = effectiveSeqOf(planned.op, seqOverride, this.state.seq);
2603
+ const op = withSeq(planned.op, effectiveSeq);
2604
+ if (op.type === "create") {
2605
+ const id = await withRetry(() => this.api.createCard(op.cardJson));
2606
+ this.state = {
2607
+ ...planned.commit(this.state),
2608
+ cardId: id
2609
+ };
2610
+ return;
2611
+ }
2612
+ if (op.type === "send") await withRetry(() => this.api.sendCardMessage(this.chatId, this.state.cardId));
2613
+ else if (op.type === "insert") await this.api.insertElement(this.state.cardId, op.elementJson, STATUS_ELEMENT_ID, op.sequence);
2614
+ else if (op.type === "update") await this.api.updateCardElement(this.state.cardId, op.elementId, op.content, op.sequence);
2615
+ else if (op.type === "settings") await this.api.setCardStreaming(this.state.cardId, op.streaming, op.sequence, op.summary);
2616
+ this.commit(planned, effectiveSeq);
2617
+ }
2618
+ commit(planned, seqOverride) {
2619
+ const next = planned.commit(this.state);
2620
+ const current = this.state.cardId;
2621
+ const cardId = next.cardId === "__pending__" && current !== null && current !== "__pending__" ? current : next.cardId;
2622
+ this.state = seqOverride !== void 0 ? {
2623
+ ...next,
2624
+ seq: seqOverride,
2625
+ cardId
2626
+ } : {
2627
+ ...next,
2628
+ seq: Math.max(next.seq, this.state.seq),
2629
+ cardId
2630
+ };
2631
+ }
2632
+ /** 流式超时后的官方恢复路径:settings 重设 streaming_mode:true(占一个 sequence)。 */
2633
+ async reactivate() {
2634
+ const { cardId, seq } = this.state;
2635
+ if (cardId === null || cardId === "__pending__") return false;
2636
+ try {
2637
+ await this.api.setCardStreaming(cardId, true, seq + 1);
2638
+ this.state = {
2639
+ ...this.state,
2640
+ seq: seq + 1
2641
+ };
2642
+ return true;
2643
+ } catch {
2644
+ return false;
2645
+ }
2646
+ }
2647
+ /**
2648
+ * 废弃当前卡:尽力关流 → cardId 归零、尾段回卷为 carry → 调用方重新规划续写。
2649
+ * reshowTail=true(未知失败,op 可能已执行)时从尾段 base 重演(少量重复优于丢失);
2650
+ * reshowTail=false(200860 确定性未应用)时跳过已显示部分(零重复)。
2651
+ */
2652
+ async abandon(reason, reshowTail) {
2653
+ const { cardId, tail, seq } = this.state;
2654
+ const hadRealCard = cardId !== null && cardId !== "__pending__";
2655
+ if (hadRealCard) await this.api.setCardStreaming(cardId, false, seq + 1).catch(() => void 0);
2656
+ if (tail !== void 0) {
2657
+ const base = this.segments[tail.segIndex]?.kind === "process" ? 0 : reshowTail ? tail.base : tail.base + tail.shownText.length;
2658
+ this.state = {
2659
+ ...this.state,
2660
+ cardId: null,
2661
+ tail: void 0,
2662
+ closedSegCount: tail.segIndex,
2663
+ carry: {
2664
+ segIndex: tail.segIndex,
2665
+ base
2666
+ }
2667
+ };
2668
+ } else this.state = {
2669
+ ...this.state,
2670
+ cardId: null
2671
+ };
2672
+ this.log(`[project-bot] 卡片输出异常(${reason}),已废弃当前卡并在新卡继续`);
2673
+ if (hadRealCard) await withRetry(() => this.api.sendText(this.chatId, ABANDON_NOTICE)).catch(() => void 0);
2674
+ return "abandoned";
2314
2675
  }
2315
2676
  enqueue(task) {
2316
2677
  this.tail = this.tail.then(task).catch((error) => {
2317
- if (this.state.cardId === "__pending__") {
2318
- const t = this.state.tail;
2319
- this.state = {
2320
- ...this.state,
2321
- cardId: null,
2322
- ...t !== void 0 ? {
2323
- tail: void 0,
2324
- carry: {
2325
- segIndex: t.segIndex,
2326
- base: t.base
2327
- },
2328
- closedSegCount: t.segIndex
2329
- } : {}
2330
- };
2331
- }
2332
2678
  this.log(`[project-bot] 卡片操作失败:${error instanceof Error ? error.message : String(error)}`);
2333
2679
  });
2334
2680
  }
2335
- async exec(ops) {
2336
- for (const op of ops) if (op.type === "create") this.state.cardId = await withRetry(() => this.api.createCard(op.cardJson));
2337
- else if (op.type === "send") await withRetry(() => this.api.sendCardMessage(this.chatId, this.state.cardId));
2338
- else if (op.type === "insert") await withRetry(() => this.api.insertElement(this.state.cardId, op.elementJson, STATUS_ELEMENT_ID, op.sequence));
2339
- else if (op.type === "update") await withRetry(() => this.api.updateCardElement(this.state.cardId, op.elementId, op.content, op.sequence));
2340
- else await withRetry(() => this.api.setCardStreaming(this.state.cardId, op.streaming, op.sequence, op.summary));
2341
- }
2342
2681
  };
2682
+ /** 重放用:仅带 sequence 语义的 op 替换序号(create/send 无序号)。 */
2683
+ function withSeq(op, sequence) {
2684
+ if (sequence === void 0) return op;
2685
+ if (op.type === "insert") return {
2686
+ ...op,
2687
+ sequence
2688
+ };
2689
+ if (op.type === "update") return {
2690
+ ...op,
2691
+ sequence
2692
+ };
2693
+ if (op.type === "settings") return {
2694
+ ...op,
2695
+ sequence
2696
+ };
2697
+ return op;
2698
+ }
2699
+ /**
2700
+ * 带序号 op 的生效 sequence(create/send 返回 undefined = 不占序号):
2701
+ * 重放/重激活推高已确认 seq 后,规划或重放序号落后时抬升到已确认 seq+1,保证单调不碰撞。
2702
+ */
2703
+ function effectiveSeqOf(op, override, confirmedSeq) {
2704
+ if (op.type !== "insert" && op.type !== "update" && op.type !== "settings") return void 0;
2705
+ return Math.max(override ?? op.sequence, confirmedSeq + 1);
2706
+ }
2343
2707
  /** 「处理中」表情:加上后返回删除 disposer;加/删失败都静默(表情残留无害)。 */
2344
2708
  function makeAck(api, messageId, emojiType) {
2345
2709
  return async () => {
@@ -2354,45 +2718,165 @@ function makeAck(api, messageId, emojiType) {
2354
2718
  };
2355
2719
  }
2356
2720
  //#endregion
2357
- //#region src/channels/feishu/index.ts
2358
- /** 飞书渠道:WSClient 长连接收事件 → 解析 → 核心;出站走 FeishuReplyHandle。 */
2359
- const feishuChannel = {
2360
- type: "feishu",
2361
- async start(bot, io, tunables, log) {
2362
- const { appId } = bot.record.feishu;
2363
- const api = createFeishuApi(new lark.Client({
2364
- appId,
2365
- appSecret: bot.secret
2366
- }));
2367
- const botOpenId = await api.getBotOpenId();
2368
- const dedup = new MessageDedup();
2369
- const dispatcher = new lark.EventDispatcher({}).register({
2370
- "im.message.message_read_v1": async () => void 0,
2371
- "im.message.receive_v1": async (data) => {
2372
- const parsed = parseMessageEvent(data, botOpenId);
2373
- if (parsed === null || !dedup.check(parsed.messageId)) return;
2374
- const reply = new FeishuReplyHandle(api, parsed.chatId, tunables, log);
2375
- const loadImages = parsed.imageKeys.length > 0 ? async () => Promise.all(parsed.imageKeys.map(async (key) => api.downloadImage(parsed.messageId, key))) : void 0;
2376
- io.onMessage({
2377
- botId: bot.record.id,
2378
- chatId: parsed.chatId,
2379
- userId: parsed.userId,
2380
- messageId: parsed.messageId,
2381
- text: parsed.text,
2382
- ...loadImages !== void 0 ? { loadImages } : {},
2383
- reply,
2384
- ackProcessing: makeAck(api, parsed.messageId, tunables.processingReactionEmoji)
2385
- });
2721
+ //#region src/channels/approval/feishu.ts
2722
+ const STATUS_TEXT = {
2723
+ allowed: "✅ 已允许",
2724
+ rejected: "❌ 已拒绝",
2725
+ cancelled: "⏹ 已取消"
2726
+ };
2727
+ function bodyMarkdown(prompt) {
2728
+ const lines = [`**Bot**:${prompt.botName}`, `**工具**:\`${prompt.toolName}\``];
2729
+ if (prompt.reason !== void 0) lines.push(`**理由**:${prompt.reason}`);
2730
+ return lines.join("\n");
2731
+ }
2732
+ function button(text, type, key, decision) {
2733
+ return {
2734
+ tag: "button",
2735
+ text: {
2736
+ tag: "plain_text",
2737
+ content: text
2738
+ },
2739
+ type,
2740
+ behaviors: [{
2741
+ type: "callback",
2742
+ value: {
2743
+ key,
2744
+ decision
2386
2745
  }
2387
- });
2388
- const ws = new lark.WSClient({
2389
- appId,
2390
- appSecret: bot.secret,
2391
- loggerLevel: lark.LoggerLevel.warn
2392
- });
2393
- await ws.start({ eventDispatcher: dispatcher });
2394
- return {
2395
- close: () => {
2746
+ }]
2747
+ };
2748
+ }
2749
+ /** 审批卡(带按钮);summary 供会话列表/推送预览。 */
2750
+ function buildApprovalCardJson(prompt) {
2751
+ return JSON.stringify({
2752
+ schema: "2.0",
2753
+ config: { summary: { content: `权限申请:${prompt.toolName}` } },
2754
+ header: {
2755
+ title: {
2756
+ tag: "plain_text",
2757
+ content: "权限申请"
2758
+ },
2759
+ template: "orange"
2760
+ },
2761
+ body: { elements: [{
2762
+ tag: "markdown",
2763
+ content: bodyMarkdown(prompt)
2764
+ }, {
2765
+ tag: "action",
2766
+ actions: [button("允许", "primary", prompt.key, "allow"), button("拒绝", "danger", prompt.key, "reject")]
2767
+ }] }
2768
+ });
2769
+ }
2770
+ /** 终态卡(无按钮):定格审批结果;operatorName 仅允许/拒绝时有。 */
2771
+ function buildApprovalFinalCardJson(prompt, status, operatorName) {
2772
+ const statusLine = STATUS_TEXT[status] + (operatorName !== void 0 ? ` · ${operatorName}` : "");
2773
+ const template = status === "allowed" ? "green" : status === "rejected" ? "red" : "grey";
2774
+ return JSON.stringify({
2775
+ schema: "2.0",
2776
+ config: { summary: { content: `权限申请${STATUS_TEXT[status].slice(2).trim()}:${prompt.toolName}` } },
2777
+ header: {
2778
+ title: {
2779
+ tag: "plain_text",
2780
+ content: `权限申请 · ${statusLine}`
2781
+ },
2782
+ template
2783
+ },
2784
+ body: { elements: [{
2785
+ tag: "markdown",
2786
+ content: bodyMarkdown(prompt)
2787
+ }] }
2788
+ });
2789
+ }
2790
+ /** 飞书审批能力:present = 建卡 + 发消息;finalize = replaceCard 定格(sequence 从 1 起,create/send 不占)。 */
2791
+ var FeishuApprovalPresenter = class {
2792
+ api;
2793
+ log;
2794
+ constructor(api, log) {
2795
+ this.api = api;
2796
+ this.log = log;
2797
+ }
2798
+ async present(prompt) {
2799
+ const cardId = await withRetry(() => this.api.createCard(buildApprovalCardJson(prompt)));
2800
+ await withRetry(() => this.api.sendCardMessage(prompt.chatId, cardId));
2801
+ return { finalize: async (status, operatorName) => {
2802
+ try {
2803
+ await withRetry(() => this.api.replaceCard(cardId, buildApprovalFinalCardJson(prompt, status, operatorName), 1));
2804
+ } catch (error) {
2805
+ this.log(`[project-bot] 审批卡片定格失败(card ${cardId}):${error instanceof Error ? error.message : String(error)}`);
2806
+ }
2807
+ } };
2808
+ }
2809
+ };
2810
+ //#endregion
2811
+ //#region src/channels/feishu/card-action.ts
2812
+ /** card.action.trigger 回调解析:SDK normalizeCardAction → 渠道无关 CardActionInput;toast 应答帧负载。 */
2813
+ /** raw → CardActionInput;normalize 失败(畸形/缺字段)→ undefined。 */
2814
+ function toCardActionInput(raw) {
2815
+ if (raw === null || typeof raw !== "object") return void 0;
2816
+ const evt = lark.normalizeCardAction(raw);
2817
+ if (evt === null) return void 0;
2818
+ return {
2819
+ chatId: evt.chatId,
2820
+ operatorOpenId: evt.operator.openId,
2821
+ ...evt.operator.name !== void 0 ? { operatorName: evt.operator.name } : {},
2822
+ value: evt.action.value
2823
+ };
2824
+ }
2825
+ /** WS 应答帧负载:handler 返回值经 WSClient 回传(lib/index.js handleEventData:truthy result → respPayload.data)。 */
2826
+ function toastResponse(ack) {
2827
+ if (ack?.toast === void 0) return void 0;
2828
+ return { toast: {
2829
+ type: "info",
2830
+ content: ack.toast
2831
+ } };
2832
+ }
2833
+ //#endregion
2834
+ //#region src/channels/feishu/index.ts
2835
+ /** 飞书渠道:WSClient 长连接收事件 → 解析 → 核心;出站走 FeishuReplyHandle。 */
2836
+ const feishuChannel = {
2837
+ type: "feishu",
2838
+ async start(bot, io, tunables, log) {
2839
+ const { appId } = bot.record.feishu;
2840
+ const api = createFeishuApi(new lark.Client({
2841
+ appId,
2842
+ appSecret: bot.secret
2843
+ }));
2844
+ const botOpenId = await api.getBotOpenId();
2845
+ const dedup = new MessageDedup();
2846
+ const dispatcher = new lark.EventDispatcher({}).register({
2847
+ "im.message.message_read_v1": async () => void 0,
2848
+ "im.message.receive_v1": async (data) => {
2849
+ const parsed = parseMessageEvent(data, botOpenId);
2850
+ if (parsed === null || !dedup.check(parsed.messageId)) return;
2851
+ const reply = new FeishuReplyHandle(api, parsed.chatId, tunables, log);
2852
+ const loadImages = parsed.imageKeys.length > 0 ? async () => Promise.all(parsed.imageKeys.map(async (key) => api.downloadImage(parsed.messageId, key))) : void 0;
2853
+ io.onMessage({
2854
+ botId: bot.record.id,
2855
+ chatId: parsed.chatId,
2856
+ userId: parsed.userId,
2857
+ messageId: parsed.messageId,
2858
+ text: parsed.text,
2859
+ ...loadImages !== void 0 ? { loadImages } : {},
2860
+ reply,
2861
+ ackProcessing: makeAck(api, parsed.messageId, tunables.processingReactionEmoji)
2862
+ });
2863
+ },
2864
+ "card.action.trigger": (raw) => {
2865
+ if (io.onCardAction === void 0) return void 0;
2866
+ const action = toCardActionInput(raw);
2867
+ if (action === void 0) return void 0;
2868
+ return toastResponse(io.onCardAction(action)) ?? void 0;
2869
+ }
2870
+ });
2871
+ const ws = new lark.WSClient({
2872
+ appId,
2873
+ appSecret: bot.secret,
2874
+ loggerLevel: lark.LoggerLevel.warn
2875
+ });
2876
+ await ws.start({ eventDispatcher: dispatcher });
2877
+ return {
2878
+ approval: new FeishuApprovalPresenter(api, log),
2879
+ close: () => {
2396
2880
  ws.close({ force: true });
2397
2881
  return Promise.resolve();
2398
2882
  },
@@ -2448,6 +2932,13 @@ const projectBotDomain = defineDomain({
2448
2932
  function bindingKey(botId, chatId) {
2449
2933
  return `${botId}:${chatId}`;
2450
2934
  }
2935
+ //#endregion
2936
+ //#region src/channels/outbound.ts
2937
+ /** 从 assistant 消息内容块取最后一个 text 块的纯文本;无 text 块返回 ''(非 undefined)。 */
2938
+ function lastTextOf(content) {
2939
+ const texts = content.filter((b) => b.type === "text" && typeof b.text === "string").map((b) => b.text);
2940
+ return texts.length === 0 ? "" : texts[texts.length - 1];
2941
+ }
2451
2942
  /** 向段序列追加内容:与尾段同类则合并,异类开新段。 */
2452
2943
  function appendToSegments(segments, kind, text) {
2453
2944
  const tail = segments[segments.length - 1];
@@ -2457,6 +2948,30 @@ function appendToSegments(segments, kind, text) {
2457
2948
  content: text
2458
2949
  });
2459
2950
  }
2951
+ /** 向段序列应用一个流式 chunk:text-delta → text、reasoning-delta → process、reasoning block-end → 过程段段落分隔;其余类型不消费返回 false。 */
2952
+ function applyStreamChunk(segments, chunk) {
2953
+ if (chunk.type === "text-delta") appendToSegments(segments, "text", chunk.text);
2954
+ else if (chunk.type === "reasoning-delta") appendToSegments(segments, "process", chunk.text);
2955
+ else if (chunk.type === "block-end" && chunk.block?.type === "reasoning" && segments[segments.length - 1]?.kind === "process") appendToSegments(segments, "process", "\n\n");
2956
+ else return false;
2957
+ return true;
2958
+ }
2959
+ /**
2960
+ * 对账尾 text 段:把最后一个 text 段的内容替换为权威全文(丢帧补齐)。
2961
+ * 前置条件:调用方已保证尾 text 段归属本结算 step(lastTextStep 命中结算 step)。
2962
+ * 返回是否发生替换;authoritative 为空、无 text 段或内容已相等时返回 false。
2963
+ */
2964
+ function reconcileTrailingText(segments, authoritative) {
2965
+ if (authoritative === "") return false;
2966
+ let tail;
2967
+ for (let i = segments.length - 1; i >= 0; i--) if (segments[i].kind === "text") {
2968
+ tail = segments[i];
2969
+ break;
2970
+ }
2971
+ if (tail === void 0 || tail.content === authoritative) return false;
2972
+ tail.content = authoritative;
2973
+ return true;
2974
+ }
2460
2975
  function mapTurnEnd(reason) {
2461
2976
  if (reason.kind === "completed") return "done";
2462
2977
  if (reason.kind === "aborted" || reason.kind === "interrupted") return "cancelled";
@@ -2492,14 +3007,12 @@ var Outbound = class {
2492
3007
  };
2493
3008
  return;
2494
3009
  }
2495
- if (event.type === "assistant/chunk") {
3010
+ if (event.type === "assistant/message") {
2496
3011
  const turn = rt.turn;
2497
3012
  if (turn === void 0 || turn.n !== event.data.turn) return;
2498
- const chunk = event.data.chunk;
2499
- if (chunk.type === "text-delta" && typeof chunk.text === "string") appendToSegments(turn.segments, "text", chunk.text);
2500
- else if (chunk.type === "reasoning-delta" && typeof chunk.text === "string") appendToSegments(turn.segments, "process", chunk.text);
2501
- else if (chunk.type === "block-end" && chunk.block?.type === "reasoning" && turn.segments[turn.segments.length - 1]?.kind === "process") appendToSegments(turn.segments, "process", "\n\n");
2502
- else return;
3013
+ if (turn.lastTextStep !== event.data.step) return;
3014
+ const authoritative = lastTextOf(event.data.message.content ?? []);
3015
+ if (!reconcileTrailingText(turn.segments, authoritative)) return;
2503
3016
  const snapshot = turn.segments.map((s) => ({ ...s }));
2504
3017
  this.enqueue(rt, async () => {
2505
3018
  if (rt.reply === void 0) return;
@@ -2544,6 +3057,36 @@ var Outbound = class {
2544
3057
  }
2545
3058
  }
2546
3059
  /**
3060
+ * 出站:瞬态流式帧 → 段构建 + 打字机。chunk 帧不携带 turn/step,由 start 帧建 attempt 基线
3061
+ * (turn/step,宿主 accumulator 同款取数);end 帧不消费;生命周期仍由 turn/start、turn/end 驱动。
3062
+ * 帧为 fire-and-forget,丢帧后无重传,正文由 assistant/message 结算对账有界恢复。
3063
+ */
3064
+ handleAssistantFrame(sessionId, frame) {
3065
+ const rt = this.sessions.get(sessionId);
3066
+ if (rt === void 0) return;
3067
+ const turn = rt.turn;
3068
+ if (turn === void 0) return;
3069
+ if (frame.type === "start") {
3070
+ if (turn.n !== frame.turn) return;
3071
+ turn.attemptStep = frame.step;
3072
+ return;
3073
+ }
3074
+ if (frame.type !== "chunk") return;
3075
+ const step = turn.attemptStep;
3076
+ if (step === void 0) return;
3077
+ if (!applyStreamChunk(turn.segments, frame.chunk)) return;
3078
+ if (frame.chunk.type === "text-delta") turn.lastTextStep = step;
3079
+ const snapshot = turn.segments.map((s) => ({ ...s }));
3080
+ this.enqueue(rt, async () => {
3081
+ if (rt.reply === void 0) return;
3082
+ if (!turn.began) {
3083
+ await rt.reply.beginTurn();
3084
+ turn.began = true;
3085
+ }
3086
+ await rt.reply.update(snapshot);
3087
+ });
3088
+ }
3089
+ /**
2547
3090
  * turn 外错误(agent/error:resume/驱动边界失败等没有 turn/end 的场景):
2548
3091
  * notice 错误摘要并释放 inflight 槽 + 删除表情;turn 进行中的错误由 turn/end 报告,跳过防双发。
2549
3092
  */
@@ -2607,6 +3150,7 @@ var Inbound = class {
2607
3150
  return;
2608
3151
  }
2609
3152
  rt.inflight = { ack: void 0 };
3153
+ rt.reply = msg.reply;
2610
3154
  rt.inflight.ack = await msg.ackProcessing().catch(() => void 0) ?? void 0;
2611
3155
  const imageRefs = [];
2612
3156
  if (msg.loadImages !== void 0) {
@@ -2647,6 +3191,26 @@ function hooksOf(bot) {
2647
3191
  };
2648
3192
  }
2649
3193
  //#endregion
3194
+ //#region src/channels/role-assembly.ts
3195
+ /** 角色 persona 的 scoped 段名(与 Router 既有产出一致,逐字段不可改)。 */
3196
+ const ROLE_PERSONA_SECTION = "dsh-agent-toolkit:agent:persona";
3197
+ /** 角色形态创作期注入:persona 非空 → 单 section(order 0);tools 白名单存在 → 带上(由 setupAgentScope restrict)。 */
3198
+ function roleHooks(role) {
3199
+ const sections = role.persona === void 0 || role.persona.trim().length === 0 ? [] : [{
3200
+ name: ROLE_PERSONA_SECTION,
3201
+ order: 0,
3202
+ text: role.persona
3203
+ }];
3204
+ return {
3205
+ ...sections.length > 0 ? { sections } : {},
3206
+ ...role.tools !== void 0 ? { tools: role.tools.allow } : {}
3207
+ };
3208
+ }
3209
+ /** 角色模型:自配优先,缺省回退宿主默认模型(与 Router 语义一致)。 */
3210
+ function roleAgentOptions(role, defaultModel) {
3211
+ return role.model ?? defaultModel();
3212
+ }
3213
+ //#endregion
2650
3214
  //#region src/channels/router.ts
2651
3215
  /** 绑定路由:(botId, chatId) → 长期会话;create / resume / reset。 */
2652
3216
  /** 发起人提示段名:bot 会话声明来源渠道与发起人 open_id。 */
@@ -2674,21 +3238,22 @@ var Router = class {
2674
3238
  this.registry = registry;
2675
3239
  this.injectSender = injectSender;
2676
3240
  }
2677
- /** 取(或建/恢复)该 chat 的会话 runtime;reply 刷新为最近一次入站携带的句柄。 */
3241
+ /**
3242
+ * 取(或建/恢复)该 chat 的会话 runtime。存量活跃会话保持其 reply——运行中 turn 的出站
3243
+ * 必须留在原句柄收尾,reply 刷新由 Inbound 在 in-flight 准入通过后执行;
3244
+ * retiring(已 cancel、收尾中)的会话不可复用,重绑窗口内恢复时 resume + adopt 重建。
3245
+ */
2678
3246
  async ensure(bot, chatId, reply, userId) {
2679
3247
  const bound = this.bindings.get(bot.id, chatId);
2680
3248
  if (bound !== void 0) {
2681
3249
  const existing = this.sessions.get(bound);
2682
- if (existing !== void 0) {
2683
- existing.reply = reply;
2684
- return existing;
2685
- }
3250
+ if (existing !== void 0 && !existing.retiring) return existing;
2686
3251
  const agent = await this.agents.resume({
2687
3252
  sessionId: bound,
2688
3253
  ...this.resolveSession(bot, userId)
2689
3254
  });
2690
3255
  await this.attach(bot.project, bound);
2691
- return this.adopt(bot.id, chatId, bound, agent, reply);
3256
+ return this.adopt(bot.id, chatId, userId, bound, agent, reply);
2692
3257
  }
2693
3258
  const sessionId = randomUUID();
2694
3259
  const agent = await this.agents.create({
@@ -2698,7 +3263,7 @@ var Router = class {
2698
3263
  });
2699
3264
  await this.bindings.set(bot.id, chatId, sessionId);
2700
3265
  await this.attach(bot.project, sessionId);
2701
- return this.adopt(bot.id, chatId, sessionId, agent, reply);
3266
+ return this.adopt(bot.id, chatId, userId, sessionId, agent, reply);
2702
3267
  }
2703
3268
  /** attach 失败仅告警(会话降级为未分组),不阻塞消息处理。 */
2704
3269
  async attach(cwd, sessionId) {
@@ -2737,49 +3302,130 @@ var Router = class {
2737
3302
  hooks: this.withSenderSection(hooksOf(bot), bot, userId)
2738
3303
  };
2739
3304
  }
2740
- const sections = role.persona === void 0 || role.persona.trim().length === 0 ? [] : [{
2741
- name: "dsh-agent-toolkit:agent:persona",
2742
- order: 0,
2743
- text: role.persona
2744
- }];
2745
3305
  return {
2746
- agentOptions: role.model ?? this.defaultModel(),
2747
- hooks: this.withSenderSection({
2748
- ...sections.length > 0 ? { sections } : {},
2749
- ...role.tools !== void 0 ? { tools: role.tools.allow } : {}
2750
- }, bot, userId)
3306
+ agentOptions: roleAgentOptions(role, this.defaultModel),
3307
+ hooks: this.withSenderSection(roleHooks(role), bot, userId)
2751
3308
  };
2752
3309
  }
2753
- /** /new:取消旧会话、清绑定、开新会话。 */
3310
+ /** /new:取消旧会话;等 turn/end 落定(旧卡在旧句柄 finalize)后再摘出 sessions。 */
2754
3311
  async reset(bot, chatId, reply, userId) {
2755
3312
  const bound = this.bindings.get(bot.id, chatId);
2756
3313
  if (bound !== void 0) {
2757
- this.sessions.get(bound)?.agent.cancel();
2758
- this.sessions.delete(bound);
3314
+ const old = this.sessions.get(bound);
3315
+ if (old !== void 0) this.retire(bound, old);
2759
3316
  await this.bindings.delete(bot.id, chatId);
2760
3317
  }
2761
3318
  return this.ensure(bot, chatId, reply, userId);
2762
3319
  }
3320
+ /** 取消会话并等出站链落定后摘出 sessions(让在飞 turn 的 turn/end 正常 finalize 旧卡)。 */
3321
+ retire(sessionId, rt) {
3322
+ rt.retiring = true;
3323
+ rt.agent.cancel();
3324
+ (async () => {
3325
+ await rt.agent.whenIdle().catch(() => void 0);
3326
+ await rt.tail.catch(() => void 0);
3327
+ if (this.sessions.get(sessionId) === rt) this.sessions.delete(sessionId);
3328
+ })();
3329
+ }
2763
3330
  lookup(botId, chatId) {
2764
3331
  const bound = this.bindings.get(botId, chatId);
2765
3332
  return bound === void 0 ? void 0 : this.sessions.get(bound);
2766
3333
  }
2767
- adopt(botId, chatId, sessionId, agent, reply) {
3334
+ adopt(botId, chatId, userId, sessionId, agent, reply) {
2768
3335
  const rt = {
2769
3336
  botId,
2770
3337
  chatId,
2771
3338
  sessionId,
3339
+ initiatorOpenId: userId,
2772
3340
  agent,
2773
3341
  reply,
2774
3342
  inflight: void 0,
2775
3343
  tail: Promise.resolve(),
2776
- turn: void 0
3344
+ turn: void 0,
3345
+ retiring: false
2777
3346
  };
2778
3347
  this.sessions.set(sessionId, rt);
2779
3348
  return rt;
2780
3349
  }
2781
3350
  };
2782
3351
  //#endregion
3352
+ //#region src/channels/approval/center.ts
3353
+ /** 审批中心(渠道无关):自有 bot 会话的 approval ask → 渠道审批卡片挂起 → 卡片回调 resolve。
3354
+ * spec: docs/superpowers/specs/archive/2026-09-08-feishu-approval-card-design.md(飞书独占 / 仅发起人 / 不超时)。 */
3355
+ var ApprovalCenter = class {
3356
+ sessions;
3357
+ channelFor;
3358
+ warn;
3359
+ newId;
3360
+ pending = /* @__PURE__ */ new Map();
3361
+ constructor(sessions, channelFor, warn, newId = randomUUID) {
3362
+ this.sessions = sessions;
3363
+ this.channelFor = channelFor;
3364
+ this.warn = warn;
3365
+ this.newId = newId;
3366
+ }
3367
+ async handleRequest(req) {
3368
+ const sessionId = String(req.agent.session.id);
3369
+ const rt = this.sessions.get(sessionId);
3370
+ if (rt === void 0) return void 0;
3371
+ if (req.signal?.aborted === true) return "cancelled";
3372
+ const channel = this.channelFor(rt.botId);
3373
+ if (channel === void 0) {
3374
+ this.warn(`[project-bot] bot "${rt.botId}" 的渠道无审批能力,回退其他审批通道`);
3375
+ return;
3376
+ }
3377
+ const key = this.newId();
3378
+ let presentation;
3379
+ try {
3380
+ presentation = await channel.presenter.present({
3381
+ key,
3382
+ chatId: rt.chatId,
3383
+ botName: channel.botName,
3384
+ toolName: req.toolName,
3385
+ ...req.reason !== void 0 ? { reason: req.reason } : {}
3386
+ });
3387
+ } catch (error) {
3388
+ this.warn(`[project-bot] 审批卡片发送失败,回退其他审批通道:${error instanceof Error ? error.message : String(error)}`);
3389
+ return;
3390
+ }
3391
+ return new Promise((resolve) => {
3392
+ const entry = {
3393
+ sessionId,
3394
+ resolve,
3395
+ presentation,
3396
+ signal: req.signal,
3397
+ onAbort: () => this.settle(key, "cancelled")
3398
+ };
3399
+ this.pending.set(key, entry);
3400
+ req.signal?.addEventListener("abort", entry.onAbort, { once: true });
3401
+ });
3402
+ }
3403
+ handleCardAction(action) {
3404
+ const value = action.value;
3405
+ if (value === null || typeof value !== "object" || typeof value.key !== "string" || value.decision !== "allow" && value.decision !== "reject") return void 0;
3406
+ const entry = this.pending.get(value.key);
3407
+ if (entry === void 0) return { toast: "该申请已处理或已失效" };
3408
+ const rt = this.sessions.get(entry.sessionId);
3409
+ if (rt === void 0 || rt.initiatorOpenId !== action.operatorOpenId) return { toast: "仅会话发起人可审批" };
3410
+ this.settle(value.key, value.decision === "allow" ? "allowed-once" : "rejected", action.operatorName);
3411
+ return { toast: value.decision === "allow" ? "已允许" : "已拒绝" };
3412
+ }
3413
+ dispose() {
3414
+ for (const key of [...this.pending.keys()]) this.settle(key, "cancelled");
3415
+ }
3416
+ settle(key, outcome, operatorName) {
3417
+ const entry = this.pending.get(key);
3418
+ if (entry === void 0) return;
3419
+ this.pending.delete(key);
3420
+ entry.signal?.removeEventListener("abort", entry.onAbort);
3421
+ entry.resolve(outcome);
3422
+ const status = outcome === "allowed-once" ? "allowed" : outcome === "rejected" ? "rejected" : "cancelled";
3423
+ entry.presentation.finalize(status, operatorName).catch((error) => {
3424
+ this.warn(`[project-bot] 审批卡片定格失败:${error instanceof Error ? error.message : String(error)}`);
3425
+ });
3426
+ }
3427
+ };
3428
+ //#endregion
2783
3429
  //#region src/channels/runtime.ts
2784
3430
  var BotRuntime = class {
2785
3431
  deps;
@@ -2787,6 +3433,7 @@ var BotRuntime = class {
2787
3433
  router;
2788
3434
  inbound;
2789
3435
  outbound;
3436
+ approval;
2790
3437
  handles = /* @__PURE__ */ new Map();
2791
3438
  constructor(deps) {
2792
3439
  this.deps = deps;
@@ -2800,6 +3447,14 @@ var BotRuntime = class {
2800
3447
  onError: (m) => deps.log.warn(m)
2801
3448
  });
2802
3449
  this.outbound = new Outbound(this.sessions, (m) => deps.log.warn(m), deps.maxErrorDetailChars);
3450
+ this.approval = new ApprovalCenter(this.sessions, (botId) => {
3451
+ const presenter = this.handles.get(botId)?.approval;
3452
+ if (presenter === void 0) return void 0;
3453
+ return {
3454
+ presenter,
3455
+ botName: this.deps.bots.get(botId)?.name ?? botId
3456
+ };
3457
+ }, (m) => deps.log.warn(m));
2803
3458
  }
2804
3459
  async startAll() {
2805
3460
  for (const botId of [...this.deps.bots.keys()]) await this.reconcile(botId);
@@ -2828,7 +3483,10 @@ var BotRuntime = class {
2828
3483
  const handle = await channel.start({
2829
3484
  record,
2830
3485
  secret
2831
- }, { onMessage: (msg) => this.inbound.onMessage(msg) }, this.deps.tunables, (m) => this.deps.log.warn(m));
3486
+ }, {
3487
+ onMessage: (msg) => this.inbound.onMessage(msg),
3488
+ onCardAction: (action) => this.approval.handleCardAction(action)
3489
+ }, this.deps.tunables, (m) => this.deps.log.warn(m));
2832
3490
  this.handles.set(botId, handle);
2833
3491
  } catch (error) {
2834
3492
  this.deps.log.warn(`[project-bot] bot "${botId}" 渠道启动失败:${error instanceof Error ? error.message : String(error)}`);
@@ -2837,19 +3495,23 @@ var BotRuntime = class {
2837
3495
  /** 删除 bot:停渠道、取消会话、清绑定。 */
2838
3496
  async stopBot(botId) {
2839
3497
  await this.stopChannel(botId);
2840
- for (const [sessionId, rt] of [...this.sessions]) if (rt.botId === botId) {
2841
- rt.agent.cancel();
2842
- this.sessions.delete(sessionId);
2843
- }
3498
+ for (const [sessionId, rt] of [...this.sessions]) if (rt.botId === botId) this.retire(sessionId, rt);
2844
3499
  await this.bindingStore().deleteBot(botId);
2845
3500
  }
2846
3501
  /** 解绑渠道:停渠道、取消在飞会话;绑定表与持久会话保留(重绑后 resume 接续)。 */
2847
3502
  async unbindBot(botId) {
2848
3503
  await this.stopChannel(botId);
2849
- for (const [sessionId, rt] of [...this.sessions]) if (rt.botId === botId) {
2850
- rt.agent.cancel();
2851
- this.sessions.delete(sessionId);
2852
- }
3504
+ for (const [sessionId, rt] of [...this.sessions]) if (rt.botId === botId) this.retire(sessionId, rt);
3505
+ }
3506
+ /** 取消会话并等出站链落定后摘出 sessions(让在飞 turn 的 turn/end 正常 finalize 旧卡)。 */
3507
+ retire(sessionId, rt) {
3508
+ rt.retiring = true;
3509
+ rt.agent.cancel();
3510
+ (async () => {
3511
+ await rt.agent.whenIdle().catch(() => void 0);
3512
+ await rt.tail.catch(() => void 0);
3513
+ if (this.sessions.get(sessionId) === rt) this.sessions.delete(sessionId);
3514
+ })();
2853
3515
  }
2854
3516
  statusOf(botId) {
2855
3517
  const record = this.deps.bots.get(botId);
@@ -2865,6 +3527,7 @@ var BotRuntime = class {
2865
3527
  }));
2866
3528
  await Promise.allSettled([...this.handles.values()].map((h) => h.close()));
2867
3529
  this.handles.clear();
3530
+ this.approval.dispose();
2868
3531
  }
2869
3532
  async stopChannel(botId) {
2870
3533
  const handle = this.handles.get(botId);
@@ -2891,6 +3554,20 @@ var BotRuntime = class {
2891
3554
  }
2892
3555
  };
2893
3556
  //#endregion
3557
+ //#region src/channels/scope-joiner.ts
3558
+ function createScopeJoiner(ctx, presetId, fallback, warn) {
3559
+ return { async join(agentCtx) {
3560
+ const agentPresets = ctx.get("agentPresets", false);
3561
+ if (agentPresets !== void 0) try {
3562
+ await agentPresets.mount(agentCtx, presetId);
3563
+ return;
3564
+ } catch (error) {
3565
+ warn(`dsh-agent-toolkit: bot 会话挂载 preset "${presetId}" 失败,回退基础工具 standing scope(此后该会话委派子会话将看不到基础工具):${error instanceof Error ? error.message : String(error)}`);
3566
+ }
3567
+ await fallback.join(agentCtx);
3568
+ } };
3569
+ }
3570
+ //#endregion
2894
3571
  //#region src/channels/tool-scope.ts
2895
3572
  /** 默认加载器:复刻 preset mount 的模块解析——loader 的 unwrapExports 取 `default ?? 命名导出模块`。 */
2896
3573
  async function loadToolModule(specifier) {
@@ -2940,10 +3617,17 @@ function createToolsScope(ctx, loadTool = loadToolModule) {
2940
3617
  };
2941
3618
  }
2942
3619
  //#endregion
3620
+ //#region src/channels/approval/answerer.ts
3621
+ function createApprovalAnswerer(centerOf) {
3622
+ return async (req, next) => {
3623
+ return await centerOf()?.handleRequest(req) ?? next();
3624
+ };
3625
+ }
3626
+ //#endregion
2943
3627
  //#region src/bots/api.ts
2944
3628
  /** 浏览器半 RPC:单前缀路由 /dsh-agent-toolkit/api/bots + 内部路径分发。 */
2945
3629
  const MAX_BODY_BYTES = 65536;
2946
- const CreateBodySchema = z$1.object({
3630
+ const CreateBodySchema$1 = z$1.object({
2947
3631
  /** 缺省时后端自动生成(bot-<8 位随机小写字母数字>)。 */
2948
3632
  id: z$1.string().regex(BOT_ID_RE).optional(),
2949
3633
  name: z$1.string().min(1).max(64),
@@ -2964,7 +3648,7 @@ const CreateBodySchema = z$1.object({
2964
3648
  appSecretRef: z$1.string().regex(CREDENTIAL_REF_RE).optional()
2965
3649
  })
2966
3650
  });
2967
- const UpdateBodySchema = z$1.object({
3651
+ const UpdateBodySchema$1 = z$1.object({
2968
3652
  name: z$1.string().min(1).max(64).optional(),
2969
3653
  project: z$1.string().min(1).optional(),
2970
3654
  persona: z$1.string().max(8e3).nullable().optional(),
@@ -3030,7 +3714,7 @@ function createApiHandler(deps) {
3030
3714
  if (sub === "/bots" && method === "POST") {
3031
3715
  const body = await readJsonBody(req, res);
3032
3716
  if (body === void 0) return;
3033
- const parsed = CreateBodySchema.safeParse(body);
3717
+ const parsed = CreateBodySchema$1.safeParse(body);
3034
3718
  if (!parsed.success) {
3035
3719
  json(res, 400, { error: parsed.error.issues[0]?.message ?? "invalid body" });
3036
3720
  return;
@@ -3090,7 +3774,7 @@ function createApiHandler(deps) {
3090
3774
  }
3091
3775
  const body = await readJsonBody(req, res);
3092
3776
  if (body === void 0) return;
3093
- const parsed = UpdateBodySchema.safeParse(body);
3777
+ const parsed = UpdateBodySchema$1.safeParse(body);
3094
3778
  if (!parsed.success) {
3095
3779
  json(res, 400, { error: parsed.error.issues[0]?.message ?? "invalid body" });
3096
3780
  return;
@@ -3209,7 +3893,7 @@ function createApiHandler(deps) {
3209
3893
  //#endregion
3210
3894
  //#region src/bots/register-app.ts
3211
3895
  /** 扫码一键创建飞书应用:lark.registerApp(OAuth 2.0 Device Authorization Grant)的状态机封装。 */
3212
- /** 扫码创建应用时申请的权限/事件(流式卡片 + 收发消息 + 表情 + 通讯录基础信息)。
3896
+ /** 扫码创建应用时申请的权限/事件/回调(流式卡片 + 收发消息 + 表情 + 通讯录基础信息 + 审批卡片回传)。
3213
3897
  * 故意不加 as const:readonly 元组不可赋值给 SDK AppAddons 的 mutable string[],
3214
3898
  * 否则 bots/index.ts 的 lark.registerApp(options) 透传会 typecheck 失败。 */
3215
3899
  const FEISHU_REGISTER_APP_ADDONS = {
@@ -3219,7 +3903,8 @@ const FEISHU_REGISTER_APP_ADDONS = {
3219
3903
  "cardkit:card:write",
3220
3904
  "contact:user.base:readonly"
3221
3905
  ] },
3222
- events: { items: { tenant: ["im.message.receive_v1"] } }
3906
+ events: { items: { tenant: ["im.message.receive_v1"] } },
3907
+ callbacks: { items: ["card.action.trigger"] }
3223
3908
  };
3224
3909
  var RegisterAppService = class {
3225
3910
  deps;
@@ -3293,6 +3978,7 @@ function setupBots(ctx, config, deps) {
3293
3978
  cardUpdateThrottleMs: config.cardUpdateThrottleMs,
3294
3979
  cardMaxBytes: config.cardMaxBytes,
3295
3980
  processMaxBytes: config.processMaxBytes,
3981
+ cardPrintStep: config.cardPrintStep,
3296
3982
  processingReactionEmoji: config.processingReactionEmoji
3297
3983
  };
3298
3984
  const storeSecret = async (key, secret) => {
@@ -3300,34 +3986,10 @@ function setupBots(ctx, config, deps) {
3300
3986
  await ctx.credentials.set(credentialRef(ref), secret);
3301
3987
  return ref;
3302
3988
  };
3303
- /** 创作期注入已迁至 agent-setup.ts + tool-scope.ts(基础工具行 standing scope 挂载 + persona/tools),preset 机制整体移除。 */
3989
+ /** 创作期注入已迁至 agent-setup.ts + tool-scope.ts(基础工具行 standing scope 挂载 + persona/tools)。
3990
+ * preset 优先 joiner(agent-bot 组合):mount 成功后委派子会话 composeFrom 认父(spec: docs/superpowers/specs/archive/2026-09-07-bot-delegation-preset-mount-design.md)。 */
3304
3991
  const toolsScope = createToolsScope(ctx);
3305
- const agentsPort = {
3306
- async create(input) {
3307
- return adaptAgent(await ctx.agents.create({
3308
- sessionId: SessionId(input.sessionId),
3309
- meta: { cwd: input.cwd },
3310
- ...input.agentOptions !== void 0 ? { agentOptions: input.agentOptions } : {},
3311
- setup: (agentCtx) => setupAgentScope(agentCtx, input.hooks, toolsScope)
3312
- }));
3313
- },
3314
- async resume(input) {
3315
- return adaptAgent(await ctx.agents.resume({
3316
- resumeSessionId: SessionId(input.sessionId),
3317
- ...input.agentOptions !== void 0 ? { agentOptions: input.agentOptions } : {},
3318
- setup: (agentCtx) => setupAgentScope(agentCtx, input.hooks, toolsScope)
3319
- }));
3320
- }
3321
- };
3322
- function adaptAgent(handle) {
3323
- const { agent } = handle;
3324
- return {
3325
- sessionId: String(agent.id),
3326
- followup: (message) => agent.followup(message),
3327
- cancel: () => agent.cancel({ kind: "user" }),
3328
- whenIdle: () => agent.whenIdle()
3329
- };
3330
- }
3992
+ const agentsPort = createAgentsPort(ctx, deps.botPresetId !== void 0 ? createScopeJoiner(ctx, deps.botPresetId, toolsScope, log.warn) : toolsScope, deps.ownedSessions);
3331
3993
  const workspaceRegistry = ctx.get("workspaceRegistry", false);
3332
3994
  const workspacePort = { async attach(cwd, sessionId) {
3333
3995
  if (workspaceRegistry === void 0) throw new Error("workspaceRegistry 服务不可用");
@@ -3393,6 +4055,10 @@ function setupBots(ctx, config, deps) {
3393
4055
  ctx.on("session/event", (session, event) => {
3394
4056
  runtime?.outbound.handleSessionEvent(String(session.header.id), event);
3395
4057
  });
4058
+ ctx.on("agent/assistant-stream", ({ agent, frame }) => {
4059
+ runtime?.outbound.handleAssistantFrame(String(agent.session.id), frame);
4060
+ });
4061
+ if (config.approval) ctx.on("approval/request", createApprovalAnswerer(() => runtime?.approval), { prepend: true });
3396
4062
  ctx.on("agent/error", ({ agent, error }) => {
3397
4063
  const text = error instanceof Error ? error.message : String(error?.message ?? error);
3398
4064
  runtime?.outbound.handleAgentError(String(agent.session.id), text);
@@ -3441,11 +4107,42 @@ function setupBots(ctx, config, deps) {
3441
4107
  });
3442
4108
  }
3443
4109
  //#endregion
4110
+ //#region src/agents/bot-preset.ts
4111
+ /** agent-bot preset 组合序列化:BASIC_TOOLS → preset 行(bot 会话挂载的最小组合,
4112
+ * 委派子会话 composeFrom 认父的前提;spec: docs/superpowers/specs/archive/2026-09-07-bot-delegation-preset-mount-design.md)。 */
4113
+ /** BASIC_TOOLS 插件包名 → preset 行 id(与 standard/agent-team 的行 id 对齐)。新增 BASIC_TOOLS 行必须在此登记。 */
4114
+ const ROW_IDS = {
4115
+ "@deepseek-ai/dsh-persona": "persona",
4116
+ "@deepseek-ai/dsh-agent-instructions": "agent-instructions",
4117
+ "@deepseek-ai/dsh-tool-pwsh": "tool-pwsh",
4118
+ "@deepseek-ai/dsh-tool-bash": "tool-bash",
4119
+ "@deepseek-ai/dsh-tool-fs": "tool-fs",
4120
+ "@deepseek-ai/dsh-tool-fs-search": "tool-fs-search"
4121
+ };
4122
+ const BOT_PRESET_NAME = "Bot 会话";
4123
+ const BOT_PRESET_DESCRIPTION = "飞书 bot 会话组合:基础工具行(persona/instructions/shell/fs/fs-search);委派子会话经 composeFrom 继承同一组合";
4124
+ /** BASIC_TOOLS → preset 行对象数组(未登记映射的包名抛错,防新增工具行静默丢 id)。 */
4125
+ function serializeBotRows(tools) {
4126
+ return tools.map((tool) => {
4127
+ const rowId = ROW_IDS[tool.id];
4128
+ if (rowId === void 0) throw new Error(`bot-preset: BASIC_TOOLS 行 ${tool.id} 未登记 preset 行 id 映射`);
4129
+ return {
4130
+ id: rowId,
4131
+ name: tool.id,
4132
+ ...tool.config !== void 0 ? { config: tool.config } : {}
4133
+ };
4134
+ });
4135
+ }
4136
+ /** agent-bot composition 文本(平台相关 shell 行已由 BASIC_TOOLS 选定,不用 !!js)。 */
4137
+ function botPresetComposition() {
4138
+ return yaml.dump(serializeBotRows(BASIC_TOOLS), { lineWidth: -1 });
4139
+ }
4140
+ //#endregion
3444
4141
  //#region src/agents/team-preset.ts
3445
4142
  /**
3446
4143
  * Agent 团队 preset 自动生成:派生宿主当前 shipped standard composition,
3447
4144
  * 文本级禁用 subagent 工具族 4 个行,写入首个 trust=user 的 preset root。
3448
- * 设计:docs/superpowers/specs/2026-09-02-agent-team-preset-design.md
4145
+ * 设计:docs/superpowers/specs/archive/2026-09-02-agent-team-preset-design.md
3449
4146
  */
3450
4147
  /** 禁用目标行:覆盖与 team_delegate 竞争/配套的 5 个模型可见工具所属的 4 个行。 */
3451
4148
  const TEAM_PRESET_DISABLED_ROWS = [
@@ -3501,9 +4198,38 @@ const MARKER_CONTENT = "dsh-agent-toolkit";
3501
4198
  const PRESET_ID = /^[a-z0-9][a-z0-9-]*$/;
3502
4199
  const GENERATED_HEADER = "# 本文件由 dsh-agent-toolkit 自动生成,勿手改(每次启动重写)。\n";
3503
4200
  /**
3504
- * 启动时生成/刷新 agent-team preset。所有失败路径 warn 降级,不影响插件其余功能。
3505
- * 不设为默认 preset、卸载不删目录(可能有会话在用;composition 不引用 toolkit 行,
3506
- * 残留 preset 自身仍可用)。每次启动重写:standing mount 按文件代际,重写只影响新会话。
4201
+ * 写一个生成 preset 目录:无标记的同名用户目录不覆盖(warn 返回 false);marker-first 写入。
4202
+ * composition / metadata 写失败(含半途失败)留下的带标记目录,下次启动会被正常重写自愈。
4203
+ */
4204
+ async function writeGeneratedPreset(dir, composition, metadata, warn) {
4205
+ const markerPath = join(dir, MARKER_FILE);
4206
+ let dirExists = true;
4207
+ try {
4208
+ await access(dir);
4209
+ } catch {
4210
+ dirExists = false;
4211
+ }
4212
+ if (dirExists) {
4213
+ let marked = false;
4214
+ try {
4215
+ marked = (await readFile(markerPath, "utf8")).trim() === MARKER_CONTENT;
4216
+ } catch {}
4217
+ if (!marked) {
4218
+ warn(`dsh-agent-toolkit: ${dir} 已存在且非本插件生成,不覆盖,跳过生成`);
4219
+ return false;
4220
+ }
4221
+ }
4222
+ await mkdir(dir, { recursive: true });
4223
+ await writeFile(markerPath, `${MARKER_CONTENT}\n`, "utf8");
4224
+ await writeFile(join(dir, COMPOSITION_FILE), composition, "utf8");
4225
+ await writeFile(join(dir, METADATA_FILE), yaml.dump(metadata, { lineWidth: -1 }), "utf8");
4226
+ return true;
4227
+ }
4228
+ /**
4229
+ * 启动时生成/刷新 agent-team 与 agent-bot 两个 preset。所有失败路径 warn 降级,不影响插件其余功能。
4230
+ * 不设为默认 preset、卸载不删目录(可能有会话在用;composition 不引用 toolkit 行,残留 preset 自身
4231
+ * 仍可用)。每次启动重写:standing mount 按文件代际,重写只影响新会话。agent-bot 内容来自 BASIC_TOOLS,
4232
+ * 不依赖源 preset 读取(read 失败只跳过 agent-team);两块独立 try/catch、独立 marker 保护。
3507
4233
  */
3508
4234
  async function setupAgentTeamPreset(ctx, config) {
3509
4235
  if (!config.enabled) return;
@@ -3512,52 +4238,979 @@ async function setupAgentTeamPreset(ctx, config) {
3512
4238
  };
3513
4239
  const agentPresets = ctx.get("agentPresets", false);
3514
4240
  if (agentPresets === void 0) return;
3515
- if (!PRESET_ID.test(config.id)) {
3516
- warn(`dsh-agent-toolkit: agentTeamPreset.id "${config.id}" 不是合法 preset id,跳过 agent-team 生成`);
3517
- return;
3518
- }
3519
- let source;
3520
- try {
3521
- source = await agentPresets.read(config.source);
3522
- } catch (error) {
3523
- warn(`dsh-agent-toolkit: 读取源 preset "${config.source}" 失败,跳过 agent-team 生成:${error instanceof Error ? error.message : String(error)}`);
3524
- return;
3525
- }
3526
4241
  const root = agentPresets.roots.find((r) => r.trust === "user");
3527
4242
  if (root === void 0) {
3528
- warn("dsh-agent-toolkit: preset roots 中无 trust=user 的目录,跳过 agent-team 生成");
4243
+ warn("dsh-agent-toolkit: preset roots 中无 trust=user 的目录,跳过 agent-team / agent-bot 生成");
3529
4244
  return;
3530
4245
  }
3531
- const composition = GENERATED_HEADER + disableSubagentRows(source, warn);
3532
- const dir = join(resolve(expandHomePath(root.path)), config.id);
3533
- try {
3534
- const markerPath = join(dir, MARKER_FILE);
3535
- let dirExists = true;
4246
+ const presetDir = (id) => join(resolve(expandHomePath(root.path)), id);
4247
+ if (!PRESET_ID.test(config.id)) warn(`dsh-agent-toolkit: agentTeamPreset.id "${config.id}" 不是合法 preset id,跳过 agent-team 生成`);
4248
+ else {
4249
+ let source;
3536
4250
  try {
3537
- await access(dir);
3538
- } catch {
3539
- dirExists = false;
4251
+ source = await agentPresets.read(config.source);
4252
+ } catch (error) {
4253
+ warn(`dsh-agent-toolkit: 读取源 preset "${config.source}" 失败,跳过 agent-team 生成:${error instanceof Error ? error.message : String(error)}`);
3540
4254
  }
3541
- if (dirExists) {
3542
- let marked = false;
4255
+ if (source !== void 0) {
4256
+ const dir = presetDir(config.id);
3543
4257
  try {
3544
- marked = (await readFile(markerPath, "utf8")).trim() === MARKER_CONTENT;
3545
- } catch {}
3546
- if (!marked) {
3547
- warn(`dsh-agent-toolkit: ${dir} 已存在且非本插件生成,不覆盖,跳过 agent-team 生成`);
3548
- return;
4258
+ await writeGeneratedPreset(dir, GENERATED_HEADER + disableSubagentRows(source, warn), {
4259
+ name: config.name,
4260
+ description: config.description
4261
+ }, warn);
4262
+ } catch (error) {
4263
+ warn(`dsh-agent-toolkit: 写入 agent-team preset 失败(${dir}):${error instanceof Error ? error.message : String(error)}`);
3549
4264
  }
3550
4265
  }
3551
- await mkdir(dir, { recursive: true });
3552
- await writeFile(markerPath, `${MARKER_CONTENT}\n`, "utf8");
3553
- await writeFile(join(dir, COMPOSITION_FILE), composition, "utf8");
3554
- await writeFile(join(dir, METADATA_FILE), yaml.dump({
3555
- name: config.name,
3556
- description: config.description
3557
- }, { lineWidth: -1 }), "utf8");
4266
+ }
4267
+ if (config.id === config.botsId) warn(`dsh-agent-toolkit: agentTeamPreset.id 与 agentTeamPreset.botsId 相同(均为 "${config.id}"),跳过 agent-bot 生成`);
4268
+ else if (!PRESET_ID.test(config.botsId)) warn(`dsh-agent-toolkit: agentTeamPreset.botsId "${config.botsId}" 不是合法 preset id,跳过 agent-bot 生成`);
4269
+ else try {
4270
+ await writeGeneratedPreset(presetDir(config.botsId), GENERATED_HEADER + botPresetComposition(), {
4271
+ name: BOT_PRESET_NAME,
4272
+ description: BOT_PRESET_DESCRIPTION
4273
+ }, warn);
3558
4274
  } catch (error) {
3559
- warn(`dsh-agent-toolkit: 写入 agent-team preset 失败(${dir}):${error instanceof Error ? error.message : String(error)}`);
4275
+ warn(`dsh-agent-toolkit: 写入 agent-bot preset 失败:${error instanceof Error ? error.message : String(error)}`);
4276
+ }
4277
+ }
4278
+ //#endregion
4279
+ //#region src/schedule/store.ts
4280
+ /** schedule 模块存储域声明:CronTask/CronRun 记录 schema + domain 布局的单一来源。*/
4281
+ /** 调度规则三选一:cron(5 字段,可选 IANA 时区,省略 = 进程时区)/ at(RFC 3339 一次性)/ every(≥60s,创建时间为锚)。*/
4282
+ const CronScheduleSchema = z$1.discriminatedUnion("kind", [
4283
+ z$1.object({
4284
+ kind: z$1.literal("cron"),
4285
+ expr: z$1.string().min(1),
4286
+ timeZone: z$1.string().min(1).optional()
4287
+ }),
4288
+ z$1.object({
4289
+ kind: z$1.literal("at"),
4290
+ at: z$1.string().min(1)
4291
+ }),
4292
+ z$1.object({
4293
+ kind: z$1.literal("every"),
4294
+ seconds: z$1.number().int().min(60)
4295
+ })
4296
+ ]);
4297
+ /** 执行目标:主 Agent(宿主默认模型,无 persona/restrict)或注册表角色(persona/model/工具白名单随角色)。*/
4298
+ const CronTargetSchema = z$1.discriminatedUnion("kind", [z$1.object({ kind: z$1.literal("main") }), z$1.object({
4299
+ kind: z$1.literal("role"),
4300
+ roleId: z$1.string().min(1)
4301
+ })]);
4302
+ const CronTaskSchema = z$1.object({
4303
+ id: z$1.string().min(1),
4304
+ name: z$1.string().min(1).max(64),
4305
+ prompt: z$1.string().min(1).max(8e3),
4306
+ cwd: z$1.string().min(1),
4307
+ schedule: CronScheduleSchema,
4308
+ target: CronTargetSchema,
4309
+ catchup: z$1.boolean(),
4310
+ enabled: z$1.boolean(),
4311
+ /** 调度器维护的持久缓存(重启 rearm 依据),UTC ISO 串。*/
4312
+ nextRunAt: z$1.string().nullable(),
4313
+ createdAt: z$1.string(),
4314
+ updatedAt: z$1.string()
4315
+ });
4316
+ const CronRunSchema = z$1.object({
4317
+ id: z$1.string().min(1),
4318
+ taskId: z$1.string().min(1),
4319
+ triggeredAt: z$1.string(),
4320
+ finishedAt: z$1.string().optional(),
4321
+ status: z$1.enum([
4322
+ "running",
4323
+ "ok",
4324
+ "error",
4325
+ "skipped-overlap"
4326
+ ]),
4327
+ /** 本次执行新建的会话 id(skipped-overlap 无会话)。*/
4328
+ sessionId: z$1.string().optional(),
4329
+ /** 错误摘要(截断)。*/
4330
+ error: z$1.string().optional()
4331
+ });
4332
+ /** domain 名/表名均受 UNIT_NAME_RE 约束(^[a-z][a-z0-9_]*$),不允许连字符。*/
4333
+ const scheduleDomain = defineDomain({
4334
+ name: "dsh_agent_toolkit_schedule",
4335
+ version: 1,
4336
+ tables: {
4337
+ tasks: domainTable(CronTaskSchema),
4338
+ runs: domainTable(CronRunSchema),
4339
+ meta: domainTable(z$1.object({ value: z$1.string() }))
4340
+ }
4341
+ });
4342
+ /** 某任务的 run 列表(新 → 旧;同刻按 id 倒序稳定化)。*/
4343
+ function listRuns(runs, taskId) {
4344
+ return [...runs.entries()].map(([, run]) => run).filter((run) => run.taskId === taskId).sort((a, b) => b.triggeredAt.localeCompare(a.triggeredAt) || b.id.localeCompare(a.id));
4345
+ }
4346
+ /** 环形保留某任务最近 limit 条 run,超出删最旧。*/
4347
+ async function trimRuns(runs, taskId, limit) {
4348
+ const mine = listRuns(runs, taskId);
4349
+ for (const run of mine.slice(limit)) await runs.delete(run.id);
4350
+ }
4351
+ /** 删除任务连带清运行历史。*/
4352
+ async function deleteRunsOf(runs, taskId) {
4353
+ for (const run of listRuns(runs, taskId)) await runs.delete(run.id);
4354
+ }
4355
+ //#endregion
4356
+ //#region src/schedule/api.ts
4357
+ const CreateBodySchema = z$1.object({
4358
+ name: z$1.string().min(1).max(64),
4359
+ prompt: z$1.string().min(1).max(8e3),
4360
+ cwd: z$1.string().min(1),
4361
+ schedule: CronScheduleSchema,
4362
+ target: CronTargetSchema,
4363
+ catchup: z$1.boolean(),
4364
+ enabled: z$1.boolean()
4365
+ });
4366
+ const UpdateBodySchema = CreateBodySchema.partial();
4367
+ function createCronApiHandler(deps) {
4368
+ return async (req, res) => {
4369
+ const sub = new URL(req.url ?? "/", "http://127.0.0.1").pathname.replace(/^\/dsh-agent-toolkit\/api\/cron/, "") || "/";
4370
+ const method = req.method ?? "GET";
4371
+ const taskMatch = /^\/tasks\/([^/]+)$/.exec(sub);
4372
+ const triggerMatch = /^\/tasks\/([^/]+)\/trigger$/.exec(sub);
4373
+ const runsMatch = /^\/tasks\/([^/]+)\/runs$/.exec(sub);
4374
+ if (sub === "/tasks" && method === "GET") {
4375
+ json$1(res, 200, { tasks: deps.service.list() });
4376
+ return;
4377
+ }
4378
+ if (sub === "/tasks" && method === "POST") {
4379
+ const body = await readJsonBody$1(req, res);
4380
+ if (body === void 0) return;
4381
+ const parsed = CreateBodySchema.safeParse(body);
4382
+ if (!parsed.success) {
4383
+ json$1(res, 400, { error: parsed.error.issues[0]?.message ?? "invalid body" });
4384
+ return;
4385
+ }
4386
+ const result = await deps.service.create(parsed.data);
4387
+ if (!result.ok) {
4388
+ json$1(res, 400, { error: result.error });
4389
+ return;
4390
+ }
4391
+ json$1(res, 200, { task: result.value });
4392
+ return;
4393
+ }
4394
+ if (taskMatch !== null && method === "PUT") {
4395
+ const id = decodeURIComponent(taskMatch[1]);
4396
+ if (deps.service.get(id) === void 0) {
4397
+ json$1(res, 404, { error: `定时任务 "${id}" 不存在` });
4398
+ return;
4399
+ }
4400
+ const body = await readJsonBody$1(req, res);
4401
+ if (body === void 0) return;
4402
+ const parsed = UpdateBodySchema.safeParse(body);
4403
+ if (!parsed.success) {
4404
+ json$1(res, 400, { error: parsed.error.issues[0]?.message ?? "invalid body" });
4405
+ return;
4406
+ }
4407
+ const result = await deps.service.update(id, parsed.data);
4408
+ if (!result.ok) {
4409
+ json$1(res, 400, { error: result.error });
4410
+ return;
4411
+ }
4412
+ json$1(res, 200, { task: result.value });
4413
+ return;
4414
+ }
4415
+ if (taskMatch !== null && method === "DELETE") {
4416
+ const id = decodeURIComponent(taskMatch[1]);
4417
+ if (deps.service.get(id) === void 0) {
4418
+ json$1(res, 404, { error: `定时任务 "${id}" 不存在` });
4419
+ return;
4420
+ }
4421
+ const result = await deps.service.remove(id);
4422
+ if (!result.ok) {
4423
+ json$1(res, 400, { error: result.error });
4424
+ return;
4425
+ }
4426
+ json$1(res, 200, { ok: true });
4427
+ return;
4428
+ }
4429
+ if (triggerMatch !== null && method === "POST") {
4430
+ const id = decodeURIComponent(triggerMatch[1]);
4431
+ if (deps.service.get(id) === void 0) {
4432
+ json$1(res, 404, { error: `定时任务 "${id}" 不存在` });
4433
+ return;
4434
+ }
4435
+ const result = await deps.service.trigger(id);
4436
+ if (!result.ok) {
4437
+ json$1(res, /正在运行/.test(result.error) ? 409 : 400, { error: result.error });
4438
+ return;
4439
+ }
4440
+ json$1(res, 200, { run: result.value });
4441
+ return;
4442
+ }
4443
+ if (runsMatch !== null && method === "GET") {
4444
+ const id = decodeURIComponent(runsMatch[1]);
4445
+ if (deps.service.get(id) === void 0) {
4446
+ json$1(res, 404, { error: `定时任务 "${id}" 不存在` });
4447
+ return;
4448
+ }
4449
+ json$1(res, 200, { runs: deps.service.runsOf(id, deps.runHistoryLimit) });
4450
+ return;
4451
+ }
4452
+ if (sub === "/projects" && method === "GET") {
4453
+ json$1(res, 200, { projects: deps.listProjects() });
4454
+ return;
4455
+ }
4456
+ if (sub === "/tasks" || taskMatch !== null || triggerMatch !== null || runsMatch !== null || sub === "/projects") {
4457
+ json$1(res, 405, { error: "method not allowed" });
4458
+ return;
4459
+ }
4460
+ json$1(res, 404, { error: "not found" });
4461
+ };
4462
+ }
4463
+ //#endregion
4464
+ //#region src/schedule/executor.ts
4465
+ /** 定时任务执行器:输入一条 task,输出一条 run 记录(建会话 → followup → whenIdle/超时 → 落库)。 */
4466
+ /** 来源段名:声明本会话由定时任务触发(order 20,与渠道 sender 段同位)。 */
4467
+ const TASK_SECTION_NAME = "dsh-agent-toolkit:schedule:task";
4468
+ function taskSectionText(task, triggeredAt) {
4469
+ return `本会话由定时任务「${task.name}」(id: ${task.id})于 ${triggeredAt} 触发。`;
4470
+ }
4471
+ /** 错误摘要最大字符数(对齐 feishu.errorDetailMaxChars 默认 500)。 */
4472
+ const ERROR_MAX_CHARS = 500;
4473
+ var RunTimeoutError = class extends Error {
4474
+ constructor() {
4475
+ super("timeout");
4476
+ }
4477
+ };
4478
+ function createExecutor(deps) {
4479
+ async function persist(run) {
4480
+ await deps.runs.put(run.id, run);
4481
+ await trimRuns(deps.runs, run.taskId, deps.runHistoryLimit);
3560
4482
  }
4483
+ return { async trigger(task) {
4484
+ const triggeredAt = new Date(deps.now()).toISOString();
4485
+ const sessionId = deps.newSessionId();
4486
+ const run = {
4487
+ id: deps.newRunId(),
4488
+ taskId: task.id,
4489
+ triggeredAt,
4490
+ status: "running",
4491
+ sessionId
4492
+ };
4493
+ await persist(run);
4494
+ const finish = async (status, error) => {
4495
+ const done = {
4496
+ ...run,
4497
+ status,
4498
+ finishedAt: new Date(deps.now()).toISOString(),
4499
+ ...error !== void 0 ? { error: error.slice(0, ERROR_MAX_CHARS) } : {}
4500
+ };
4501
+ await persist(done);
4502
+ return done;
4503
+ };
4504
+ const source = {
4505
+ name: TASK_SECTION_NAME,
4506
+ order: 20,
4507
+ text: taskSectionText(task, triggeredAt)
4508
+ };
4509
+ let agentOptions;
4510
+ let hooks;
4511
+ const roleId = task.target.kind === "role" ? task.target.roleId : "main";
4512
+ const role = roleId === "main" ? void 0 : deps.registry.get(roleId);
4513
+ if (roleId === "main" || role === void 0) {
4514
+ if (roleId !== "main" && role === void 0) deps.warn(`[schedule] 定时任务 "${task.id}" 的角色 "${roleId}" 不存在,降级主 Agent 形态`);
4515
+ agentOptions = deps.defaultModel();
4516
+ hooks = { sections: [source] };
4517
+ } else {
4518
+ const base = roleHooks(role);
4519
+ agentOptions = roleAgentOptions(role, deps.defaultModel);
4520
+ hooks = {
4521
+ ...base,
4522
+ sections: [...base.sections ?? [], source]
4523
+ };
4524
+ }
4525
+ let agent;
4526
+ try {
4527
+ agent = await deps.agents.create({
4528
+ sessionId,
4529
+ cwd: task.cwd,
4530
+ agentOptions,
4531
+ hooks
4532
+ });
4533
+ } catch (error) {
4534
+ return finish("error", error instanceof Error ? error.message : String(error));
4535
+ }
4536
+ try {
4537
+ await deps.workspace.attach(task.cwd, sessionId);
4538
+ } catch (error) {
4539
+ deps.warn(`[schedule] 会话 ${sessionId} 挂载 workspace 失败:${error instanceof Error ? error.message : String(error)}`);
4540
+ }
4541
+ agent.followup(createUserMessage({
4542
+ content: [{
4543
+ type: "text",
4544
+ text: task.prompt
4545
+ }],
4546
+ source: { kind: "user" }
4547
+ }));
4548
+ const idle = agent.whenIdle();
4549
+ idle.catch(() => void 0);
4550
+ const timeout = deps.delay(deps.runTimeoutMs).then(() => {
4551
+ throw new RunTimeoutError();
4552
+ });
4553
+ timeout.catch(() => void 0);
4554
+ try {
4555
+ await Promise.race([idle, timeout]);
4556
+ return await finish("ok");
4557
+ } catch (error) {
4558
+ if (error instanceof RunTimeoutError) {
4559
+ agent.cancel();
4560
+ return await finish("error", `timeout(超过 ${Math.round(deps.runTimeoutMs / 6e4)} 分钟未空闲,已取消)`);
4561
+ }
4562
+ return await finish("error", error instanceof Error ? error.message : String(error));
4563
+ }
4564
+ } };
4565
+ }
4566
+ //#endregion
4567
+ //#region src/schedule/timing.ts
4568
+ /** cron/at/every 纯时间运算:校验、下一触发点、预览、启动 rearm。全部注入 now,无隐藏时钟。*/
4569
+ /** croner 选项:paused 仅做解析与预测,不注册真实定时器(计时由调度器 tick 驱动)。*/
4570
+ function cronerOptions(schedule) {
4571
+ return {
4572
+ paused: true,
4573
+ ...schedule.timeZone !== void 0 ? { timezone: schedule.timeZone } : {}
4574
+ };
4575
+ }
4576
+ /** 创建/更新即校验;合法返回 undefined,非法返回错误消息(不入库由调用方保证)。*/
4577
+ function validateSchedule(schedule, nowMs) {
4578
+ switch (schedule.kind) {
4579
+ case "cron": {
4580
+ const fields = schedule.expr.trim().split(/\s+/);
4581
+ if (fields.length !== 5) return `cron 表达式须为 5 字段(分 时 日 月 周),收到 ${fields.length} 字段;秒级/年字段暂不开放`;
4582
+ if (schedule.timeZone !== void 0) try {
4583
+ new Intl.DateTimeFormat("en-US", { timeZone: schedule.timeZone });
4584
+ } catch {
4585
+ return `未知时区:${schedule.timeZone}`;
4586
+ }
4587
+ try {
4588
+ new Cron(schedule.expr, cronerOptions(schedule));
4589
+ } catch (error) {
4590
+ return `非法 cron 表达式"${schedule.expr}":${error instanceof Error ? error.message : String(error)}`;
4591
+ }
4592
+ return;
4593
+ }
4594
+ case "at": {
4595
+ const t = Date.parse(schedule.at);
4596
+ if (Number.isNaN(t)) return `非法 at 时间(须 RFC 3339):${schedule.at}`;
4597
+ if (t <= nowMs) return `at 时间须在未来:${schedule.at}`;
4598
+ return;
4599
+ }
4600
+ case "every": return;
4601
+ }
4602
+ }
4603
+ /** 下一触发点(epoch ms);无未来触发点(at 过期等)返回 null。*/
4604
+ function nextOccurrence(schedule, createdAtMs, nowMs) {
4605
+ switch (schedule.kind) {
4606
+ case "cron": {
4607
+ const next = new Cron(schedule.expr, cronerOptions(schedule)).nextRun(new Date(nowMs));
4608
+ return next === null ? null : next.getTime();
4609
+ }
4610
+ case "at": {
4611
+ const t = Date.parse(schedule.at);
4612
+ return t > nowMs ? t : null;
4613
+ }
4614
+ case "every": {
4615
+ const intervalMs = schedule.seconds * 1e3;
4616
+ if (nowMs < createdAtMs) return createdAtMs;
4617
+ return createdAtMs + (Math.floor((nowMs - createdAtMs) / intervalMs) + 1) * intervalMs;
4618
+ }
4619
+ }
4620
+ }
4621
+ /**
4622
+ * 启动 rearm:对一条持久化任务重算 enabled/nextRunAt(无变化返回原引用,调用方按引用决定是否写回)。
4623
+ * - nextRunAt 仍在未来 → 保持;
4624
+ * - 已过期且 catchup → 设为「立即」(首个 tick 补跑一次);
4625
+ * - 已过期且非 catchup → 下一未来触发点;没有(at 一次性)→ enabled=false、nextRunAt=null(过期即作废)。
4626
+ */
4627
+ function rearmTask(task, nowMs) {
4628
+ if (!task.enabled) return task.nextRunAt === null ? task : {
4629
+ ...task,
4630
+ nextRunAt: null
4631
+ };
4632
+ const persisted = task.nextRunAt === null ? null : Date.parse(task.nextRunAt);
4633
+ if (persisted !== null && persisted > nowMs) return task;
4634
+ if (persisted !== null && task.catchup) return {
4635
+ ...task,
4636
+ nextRunAt: new Date(nowMs).toISOString()
4637
+ };
4638
+ const next = nextOccurrence(task.schedule, Date.parse(task.createdAt), nowMs);
4639
+ return next === null ? {
4640
+ ...task,
4641
+ enabled: false,
4642
+ nextRunAt: null
4643
+ } : {
4644
+ ...task,
4645
+ nextRunAt: new Date(next).toISOString()
4646
+ };
4647
+ }
4648
+ /** 创建/更新后的 fresh 重算(catchup 是停机补跑语义,不适用于编辑路径)。*/
4649
+ function recomputeTask(task, nowMs) {
4650
+ if (!task.enabled) return {
4651
+ ...task,
4652
+ nextRunAt: null
4653
+ };
4654
+ const next = nextOccurrence(task.schedule, Date.parse(task.createdAt), nowMs);
4655
+ return next === null ? {
4656
+ ...task,
4657
+ enabled: false,
4658
+ nextRunAt: null
4659
+ } : {
4660
+ ...task,
4661
+ nextRunAt: new Date(next).toISOString()
4662
+ };
4663
+ }
4664
+ /** 触发/跳过后推进:cron/every 取 now 之后下一触发点;at 一次性作废。*/
4665
+ function advanceAfterTrigger(task, nowMs) {
4666
+ if (task.schedule.kind === "at") return {
4667
+ ...task,
4668
+ enabled: false,
4669
+ nextRunAt: null
4670
+ };
4671
+ const next = nextOccurrence(task.schedule, Date.parse(task.createdAt), nowMs);
4672
+ return next === null ? {
4673
+ ...task,
4674
+ enabled: false,
4675
+ nextRunAt: null
4676
+ } : {
4677
+ ...task,
4678
+ nextRunAt: new Date(next).toISOString()
4679
+ };
4680
+ }
4681
+ //#endregion
4682
+ //#region src/schedule/scheduler.ts
4683
+ function createScheduler(deps) {
4684
+ /** 内存重叠锁:同任务上次未结束则本次记 skipped-overlap,不并发执行。 */
4685
+ const running = /* @__PURE__ */ new Set();
4686
+ async function recordSkipped(task) {
4687
+ const nowIso = new Date(deps.now()).toISOString();
4688
+ const run = {
4689
+ id: deps.newRunId(),
4690
+ taskId: task.id,
4691
+ triggeredAt: nowIso,
4692
+ finishedAt: nowIso,
4693
+ status: "skipped-overlap"
4694
+ };
4695
+ await deps.runs.put(run.id, run);
4696
+ await trimRuns(deps.runs, task.id, deps.runHistoryLimit);
4697
+ }
4698
+ function launch(task) {
4699
+ running.add(task.id);
4700
+ deps.execute(task).catch((error) => deps.warn(`[schedule] 任务 "${task.id}" 执行器异常:${error instanceof Error ? error.message : String(error)}`)).finally(() => {
4701
+ running.delete(task.id);
4702
+ const current = deps.tasks.get(task.id);
4703
+ if (current === void 0 || !current.enabled) return;
4704
+ deps.tasks.put(task.id, advanceAfterTrigger(current, deps.now()));
4705
+ });
4706
+ }
4707
+ return {
4708
+ async rearmAll() {
4709
+ for (const [id, task] of deps.tasks.entries()) {
4710
+ const rearmed = rearmTask(task, deps.now());
4711
+ if (rearmed !== task) await deps.tasks.put(id, rearmed);
4712
+ }
4713
+ },
4714
+ async tick() {
4715
+ for (const [, task] of deps.tasks.entries()) {
4716
+ if (!task.enabled || task.nextRunAt === null) continue;
4717
+ if (Date.parse(task.nextRunAt) > deps.now()) continue;
4718
+ if (running.has(task.id)) {
4719
+ await recordSkipped(task);
4720
+ await deps.tasks.put(task.id, advanceAfterTrigger(task, deps.now()));
4721
+ continue;
4722
+ }
4723
+ launch(task);
4724
+ }
4725
+ },
4726
+ recompute: (task) => recomputeTask(task, deps.now()),
4727
+ async triggerManual(taskId) {
4728
+ const task = deps.tasks.get(taskId);
4729
+ if (task === void 0) throw new Error(`定时任务 "${taskId}" 不存在`);
4730
+ if (running.has(taskId)) throw new Error(`定时任务 "${task.name}" 正在运行中,本次手动触发已跳过`);
4731
+ running.add(taskId);
4732
+ try {
4733
+ return await deps.execute(task);
4734
+ } finally {
4735
+ running.delete(taskId);
4736
+ }
4737
+ },
4738
+ isRunning: (taskId) => running.has(taskId)
4739
+ };
4740
+ }
4741
+ //#endregion
4742
+ //#region src/schedule/service.ts
4743
+ function createCronService(deps) {
4744
+ /** 创建/更新共用校验:schedule 语法与时值 → cwd → roleId 存在性。 */
4745
+ const validateIO = (input) => {
4746
+ const scheduleError = validateSchedule(input.schedule, deps.now());
4747
+ if (scheduleError !== void 0) return scheduleError;
4748
+ if (!deps.validateProject(input.cwd)) return `项目路径不可用:${input.cwd}`;
4749
+ if (input.target.kind === "role" && deps.registry.get(input.target.roleId) === void 0) return `角色 "${input.target.roleId}" 不存在`;
4750
+ };
4751
+ return {
4752
+ list() {
4753
+ return [...deps.tasks.entries()].map(([, task]) => {
4754
+ const last = listRuns(deps.runs, task.id)[0];
4755
+ return last === void 0 ? task : {
4756
+ ...task,
4757
+ lastRun: {
4758
+ status: last.status,
4759
+ triggeredAt: last.triggeredAt
4760
+ }
4761
+ };
4762
+ });
4763
+ },
4764
+ get: (id) => deps.tasks.get(id),
4765
+ async create(input) {
4766
+ const invalid = validateIO(input);
4767
+ if (invalid !== void 0) return {
4768
+ ok: false,
4769
+ error: invalid
4770
+ };
4771
+ const nowIso = new Date(deps.now()).toISOString();
4772
+ const parsed = CronTaskSchema.safeParse({
4773
+ id: deps.newTaskId(),
4774
+ ...input,
4775
+ nextRunAt: null,
4776
+ createdAt: nowIso,
4777
+ updatedAt: nowIso
4778
+ });
4779
+ if (!parsed.success) return {
4780
+ ok: false,
4781
+ error: parsed.error.issues[0]?.message ?? "invalid task"
4782
+ };
4783
+ const task = deps.scheduler.recompute(parsed.data);
4784
+ await deps.tasks.put(task.id, task);
4785
+ return {
4786
+ ok: true,
4787
+ value: task
4788
+ };
4789
+ },
4790
+ async update(id, patch) {
4791
+ const existing = deps.tasks.get(id);
4792
+ if (existing === void 0) return {
4793
+ ok: false,
4794
+ error: `定时任务 "${id}" 不存在`
4795
+ };
4796
+ const merged = {
4797
+ ...existing,
4798
+ ...Object.fromEntries(Object.entries(patch).filter(([, v]) => v !== void 0)),
4799
+ id: existing.id,
4800
+ updatedAt: new Date(deps.now()).toISOString()
4801
+ };
4802
+ const invalid = validateIO(merged);
4803
+ if (invalid !== void 0) return {
4804
+ ok: false,
4805
+ error: invalid
4806
+ };
4807
+ const parsed = CronTaskSchema.safeParse(merged);
4808
+ if (!parsed.success) return {
4809
+ ok: false,
4810
+ error: parsed.error.issues[0]?.message ?? "invalid task"
4811
+ };
4812
+ const task = deps.scheduler.recompute(parsed.data);
4813
+ await deps.tasks.put(id, task);
4814
+ return {
4815
+ ok: true,
4816
+ value: task
4817
+ };
4818
+ },
4819
+ async remove(id) {
4820
+ if (deps.tasks.get(id) === void 0) return {
4821
+ ok: false,
4822
+ error: `定时任务 "${id}" 不存在`
4823
+ };
4824
+ await deps.tasks.delete(id);
4825
+ await deleteRunsOf(deps.runs, id);
4826
+ return {
4827
+ ok: true,
4828
+ value: null
4829
+ };
4830
+ },
4831
+ async trigger(id) {
4832
+ try {
4833
+ return {
4834
+ ok: true,
4835
+ value: await deps.scheduler.triggerManual(id)
4836
+ };
4837
+ } catch (error) {
4838
+ return {
4839
+ ok: false,
4840
+ error: error instanceof Error ? error.message : String(error)
4841
+ };
4842
+ }
4843
+ },
4844
+ runsOf: (id, limit) => listRuns(deps.runs, id).slice(0, limit)
4845
+ };
4846
+ }
4847
+ //#endregion
4848
+ //#region src/schedule/tools.ts
4849
+ /** 互斥探测目标:宿主 @deepseek-ai/dsh-schedule 的会话内提醒工具。 */
4850
+ const HOST_SCHEDULE_TOOL = "schedule_create";
4851
+ /** 扁平参数 → CronSchedule(缺 kind 对应字段抛错——模型可读的自描述消息)。 */
4852
+ function scheduleOf(args) {
4853
+ const kind = args.scheduleKind;
4854
+ if (kind === "cron") {
4855
+ if (typeof args.cronExpr !== "string" || args.cronExpr.trim() === "") throw new Error("scheduleKind=cron 需要 cronExpr(5 字段 cron 表达式)");
4856
+ return {
4857
+ kind: "cron",
4858
+ expr: args.cronExpr,
4859
+ ...typeof args.timeZone === "string" && args.timeZone !== "" ? { timeZone: args.timeZone } : {}
4860
+ };
4861
+ }
4862
+ if (kind === "at") {
4863
+ if (typeof args.atTime !== "string" || args.atTime === "") throw new Error("scheduleKind=at 需要 atTime(RFC 3339 时间)");
4864
+ return {
4865
+ kind: "at",
4866
+ at: args.atTime
4867
+ };
4868
+ }
4869
+ if (kind === "every") {
4870
+ if (typeof args.everySeconds !== "number") throw new Error("scheduleKind=every 需要 everySeconds(≥60 的秒数)");
4871
+ return {
4872
+ kind: "every",
4873
+ seconds: args.everySeconds
4874
+ };
4875
+ }
4876
+ throw new Error(`未知 scheduleKind:${String(kind)}(可选 cron / at / every)`);
4877
+ }
4878
+ function targetOf(args) {
4879
+ if (args.targetKind === "role") {
4880
+ if (typeof args.roleId !== "string" || args.roleId === "") throw new Error("targetKind=role 需要 roleId");
4881
+ return {
4882
+ kind: "role",
4883
+ roleId: args.roleId
4884
+ };
4885
+ }
4886
+ return { kind: "main" };
4887
+ }
4888
+ /** 服务结果展开:ok:false → 抛错(defineTool 约定:抛错即工具错误结果)。 */
4889
+ function unwrap(result) {
4890
+ if (!result.ok) throw new Error(result.error);
4891
+ return result.value;
4892
+ }
4893
+ const JSON_OUTPUT = {
4894
+ schema: { type: "json" },
4895
+ render: (_args, value) => [{
4896
+ type: "text",
4897
+ text: JSON.stringify(value, null, 2)
4898
+ }]
4899
+ };
4900
+ const SCHEDULE_PARAMS_REQUIRED = { scheduleKind: {
4901
+ type: "string",
4902
+ required: true,
4903
+ description: "One of: cron / at / every"
4904
+ } };
4905
+ const SCHEDULE_PARAMS_OPTIONAL = { scheduleKind: {
4906
+ type: "string",
4907
+ description: "Replace schedule kind: cron / at / every (provide the matching fields too)"
4908
+ } };
4909
+ const SCHEDULE_FIELD_PARAMS = {
4910
+ cronExpr: {
4911
+ type: "string",
4912
+ description: "Required when scheduleKind=cron: 5-field cron expression \"minute hour day-of-month month day-of-week\" (seconds/years not supported)"
4913
+ },
4914
+ timeZone: {
4915
+ type: "string",
4916
+ description: "Optional IANA time zone for cron (e.g. \"Asia/Shanghai\"); default: server process time zone"
4917
+ },
4918
+ atTime: {
4919
+ type: "string",
4920
+ description: "Required when scheduleKind=at: one-shot RFC 3339 time (e.g. \"2026-09-08T09:00:00+08:00\")"
4921
+ },
4922
+ everySeconds: {
4923
+ type: "number",
4924
+ description: "Required when scheduleKind=every: interval seconds (>= 60), anchored at creation time"
4925
+ }
4926
+ };
4927
+ const TARGET_PARAMS_REQUIRED = { targetKind: {
4928
+ type: "string",
4929
+ required: true,
4930
+ description: "One of: main (main agent, default model) / role (registry role with its persona/model/tool allowlist)"
4931
+ } };
4932
+ const TARGET_PARAMS_OPTIONAL = { targetKind: {
4933
+ type: "string",
4934
+ description: "Replace target: main / role"
4935
+ } };
4936
+ const ROLE_ID_PARAM = { roleId: {
4937
+ type: "string",
4938
+ description: "Required when targetKind=role: registry role id"
4939
+ } };
4940
+ function createCronTools(service) {
4941
+ return [
4942
+ defineTool({
4943
+ name: "cron_task_create",
4944
+ description: "Create a scheduled task that runs a prompt in a NEW standalone session (main agent or a registry role) on a cron/at/every schedule. The task survives restarts; a run missed while stopped is caught up once when catchup=true. Use cron_task_list to see existing tasks.",
4945
+ parameters: {
4946
+ name: {
4947
+ type: "string",
4948
+ required: true,
4949
+ description: "Task name (display)"
4950
+ },
4951
+ prompt: {
4952
+ type: "string",
4953
+ required: true,
4954
+ description: "The prompt sent as the user message when the task fires. Self-contained: the session has no other context."
4955
+ },
4956
+ cwd: {
4957
+ type: "string",
4958
+ required: true,
4959
+ description: "Working directory of the new session (must be a valid project path)"
4960
+ },
4961
+ ...SCHEDULE_PARAMS_REQUIRED,
4962
+ ...SCHEDULE_FIELD_PARAMS,
4963
+ ...TARGET_PARAMS_REQUIRED,
4964
+ ...ROLE_ID_PARAM,
4965
+ catchup: {
4966
+ type: "boolean",
4967
+ required: true,
4968
+ description: "true: run once immediately after restart if a run was missed while stopped; false: skip to the next future occurrence"
4969
+ },
4970
+ enabled: {
4971
+ type: "boolean",
4972
+ required: true,
4973
+ description: "false: create paused (nextRunAt stays null until enabled via cron_task_update)"
4974
+ }
4975
+ },
4976
+ output: JSON_OUTPUT,
4977
+ isConcurrencySafe: () => true,
4978
+ async execute(rawArgs) {
4979
+ const args = rawArgs;
4980
+ const input = {
4981
+ name: args.name,
4982
+ prompt: args.prompt,
4983
+ cwd: args.cwd,
4984
+ schedule: scheduleOf(args),
4985
+ target: targetOf(args),
4986
+ catchup: args.catchup,
4987
+ enabled: args.enabled
4988
+ };
4989
+ return unwrap(await service.create(input));
4990
+ }
4991
+ }),
4992
+ defineTool({
4993
+ name: "cron_task_list",
4994
+ description: "List all scheduled tasks with enabled flag, next run time and last run status.",
4995
+ parameters: {},
4996
+ output: JSON_OUTPUT,
4997
+ isConcurrencySafe: () => true,
4998
+ async execute() {
4999
+ return service.list();
5000
+ }
5001
+ }),
5002
+ defineTool({
5003
+ name: "cron_task_update",
5004
+ description: "Update any fields of a scheduled task by id. nextRunAt is recomputed.",
5005
+ parameters: {
5006
+ id: {
5007
+ type: "string",
5008
+ required: true,
5009
+ description: "Task id"
5010
+ },
5011
+ name: {
5012
+ type: "string",
5013
+ description: "New name"
5014
+ },
5015
+ prompt: {
5016
+ type: "string",
5017
+ description: "New prompt"
5018
+ },
5019
+ cwd: {
5020
+ type: "string",
5021
+ description: "New working directory"
5022
+ },
5023
+ ...SCHEDULE_PARAMS_OPTIONAL,
5024
+ ...SCHEDULE_FIELD_PARAMS,
5025
+ ...TARGET_PARAMS_OPTIONAL,
5026
+ ...ROLE_ID_PARAM,
5027
+ catchup: {
5028
+ type: "boolean",
5029
+ description: "New catchup flag"
5030
+ },
5031
+ enabled: {
5032
+ type: "boolean",
5033
+ description: "Enable/disable the task"
5034
+ }
5035
+ },
5036
+ output: JSON_OUTPUT,
5037
+ isConcurrencySafe: () => true,
5038
+ async execute(rawArgs) {
5039
+ const args = rawArgs;
5040
+ const patch = {};
5041
+ if (args.name !== void 0) patch.name = args.name;
5042
+ if (args.prompt !== void 0) patch.prompt = args.prompt;
5043
+ if (args.cwd !== void 0) patch.cwd = args.cwd;
5044
+ if (args.scheduleKind !== void 0) patch.schedule = scheduleOf(args);
5045
+ if (args.targetKind !== void 0) patch.target = targetOf(args);
5046
+ if (args.catchup !== void 0) patch.catchup = args.catchup;
5047
+ if (args.enabled !== void 0) patch.enabled = args.enabled;
5048
+ return unwrap(await service.update(args.id, patch));
5049
+ }
5050
+ }),
5051
+ defineTool({
5052
+ name: "cron_task_delete",
5053
+ description: "Delete a scheduled task by id, including its run history.",
5054
+ parameters: { id: {
5055
+ type: "string",
5056
+ required: true,
5057
+ description: "Task id"
5058
+ } },
5059
+ output: JSON_OUTPUT,
5060
+ isConcurrencySafe: () => true,
5061
+ async execute(rawArgs) {
5062
+ return unwrap(await service.remove(rawArgs.id));
5063
+ }
5064
+ }),
5065
+ defineTool({
5066
+ name: "cron_task_trigger",
5067
+ description: "Trigger a scheduled task once right now (works even when disabled; fails with an error if the task is already running).",
5068
+ parameters: { id: {
5069
+ type: "string",
5070
+ required: true,
5071
+ description: "Task id"
5072
+ } },
5073
+ output: JSON_OUTPUT,
5074
+ isConcurrencySafe: () => true,
5075
+ async execute(rawArgs) {
5076
+ return unwrap(await service.trigger(rawArgs.id));
5077
+ }
5078
+ })
5079
+ ];
5080
+ }
5081
+ /**
5082
+ * 注册 cron_* 工具到主 Agent scope 并探测宿主 schedule 互斥(spec §1/§5)。
5083
+ * 门控:跳过 subagent(session.header.origin === 'subagent')与插件自有会话(ownedSessions:
5084
+ * bot/schedule 会话)——dsh-schedule src/index.ts:45-49 同款 agent/created 模式,
5085
+ * 外加存量 roots 立即注册(HMR 重挂后当前主会话不丢工具)。
5086
+ * HMR 安全:镜像 dsh-schedule src/index.ts:44-76——attached 身份表防同一 agent 二次挂载,
5087
+ * toolkit 级 ctx.effect 卸载时 Promise.allSettled 摘下所有 agent 上挂的工具
5088
+ * (agent.ctx 属宿主、长于 toolkit fiber,仅靠 agent.ctx.effect 会在重挂时残留)。
5089
+ */
5090
+ function setupCronTools(ctx, tools, ownedSessions) {
5091
+ const attached = /* @__PURE__ */ new Map();
5092
+ let stopping = false;
5093
+ ctx.effect(() => {
5094
+ const stopCreated = ctx.on("agent/created", ({ agent }) => {
5095
+ if (stopping || attached.has(agent)) return;
5096
+ attach(agent);
5097
+ });
5098
+ for (const agent of ctx.agents.roots()) attach(agent);
5099
+ return async () => {
5100
+ stopping = true;
5101
+ stopCreated();
5102
+ const cleanups = [...attached.values()];
5103
+ attached.clear();
5104
+ await Promise.allSettled(cleanups.map((cleanup) => Promise.resolve(cleanup())));
5105
+ };
5106
+ }, "dsh-agent-toolkit.cron-tools()");
5107
+ function attach(agent) {
5108
+ if (attached.has(agent)) return;
5109
+ if (agent.session.header.origin === "subagent") return;
5110
+ if (ownedSessions.has(String(agent.session.id))) return;
5111
+ const cleanup = agent.ctx.effect(() => {
5112
+ const scope = scopeOf(agent.ctx);
5113
+ if (scope !== void 0 ? ctx.tools.get(HOST_SCHEDULE_TOOL, scope) !== void 0 : ctx.tools.get(HOST_SCHEDULE_TOOL) !== void 0) ctx.logger.warn("dsh-agent-toolkit: 检测到宿主 @deepseek-ai/dsh-schedule 的 schedule_create 与 cron_* 并存——两套调度工具并存会显著提高模型误选率,且会话内提醒在会话冷时静默失败;请从组合中移除 @deepseek-ai/dsh-schedule(二选一)");
5114
+ const disposers = tools.map((tool) => agent.ctx.tools.register(tool));
5115
+ return () => {
5116
+ for (const dispose of disposers) dispose();
5117
+ if (attached.get(agent) === cleanup) attached.delete(agent);
5118
+ };
5119
+ }, "dsh-agent-toolkit.cron-tools.attach()");
5120
+ attached.set(agent, cleanup);
5121
+ }
5122
+ }
5123
+ //#endregion
5124
+ //#region src/schedule/index.ts
5125
+ /** schedule 模块接线:存储域 → executor/scheduler/service → cron_* 工具 + HTTP API + 30s tick。 */
5126
+ /** tick 间隔固定 30s(spec §8:不进 Config——YAGNI)。 */
5127
+ const TICK_MS = 3e4;
5128
+ function setupSchedule(ctx, config, deps) {
5129
+ const warn = (m) => ctx.logger.warn(m);
5130
+ const toolsScope = createToolsScope(ctx);
5131
+ const agentsPort = createAgentsPort(ctx, deps.botPresetId !== void 0 ? createScopeJoiner(ctx, deps.botPresetId, toolsScope, warn) : toolsScope, deps.ownedSessions);
5132
+ const workspaceRegistryOf = () => ctx.get("workspaceRegistry", false);
5133
+ const workspacePort = { async attach(cwd, sessionId) {
5134
+ const registry = workspaceRegistryOf();
5135
+ if (registry === void 0) throw new Error("workspaceRegistry 服务不可用");
5136
+ await (await registry.create(cwd)).attachSession(SessionId(sessionId));
5137
+ } };
5138
+ let scheduler;
5139
+ const started = openDomainSafely(ctx, scheduleDomain, warn).then(async (domain) => {
5140
+ const tasks = domain.table("tasks");
5141
+ const runs = domain.table("runs");
5142
+ const executor = createExecutor({
5143
+ agents: agentsPort,
5144
+ registry: deps.registry,
5145
+ defaultModel: () => {
5146
+ const selection = ctx.agentDefaultModel.currentSelection();
5147
+ return {
5148
+ provider: selection.provider,
5149
+ model: selection.model
5150
+ };
5151
+ },
5152
+ workspace: workspacePort,
5153
+ runs,
5154
+ runHistoryLimit: config.runHistoryLimit,
5155
+ runTimeoutMs: config.runTimeoutMinutes * 6e4,
5156
+ warn,
5157
+ now: () => Date.now(),
5158
+ newSessionId: () => randomUUID(),
5159
+ newRunId: () => randomUUID(),
5160
+ delay: (ms) => new Promise((resolve) => setTimeout(resolve, ms))
5161
+ });
5162
+ scheduler = createScheduler({
5163
+ tasks,
5164
+ runs,
5165
+ runHistoryLimit: config.runHistoryLimit,
5166
+ execute: (task) => executor.trigger(task),
5167
+ now: () => Date.now(),
5168
+ newRunId: () => randomUUID(),
5169
+ warn
5170
+ });
5171
+ const service = createCronService({
5172
+ tasks,
5173
+ runs,
5174
+ scheduler,
5175
+ registry: deps.registry,
5176
+ validateProject: (path) => existsSync(path),
5177
+ now: () => Date.now(),
5178
+ newTaskId: () => randomUUID()
5179
+ });
5180
+ setupCronTools(ctx, createCronTools(service), deps.ownedSessions);
5181
+ registerOptionalRoutes(ctx, (webCtx) => {
5182
+ const dispose = webCtx.webServer.register({
5183
+ kind: "prefix",
5184
+ path: "/dsh-agent-toolkit/api/cron",
5185
+ handler: async (req, res) => {
5186
+ try {
5187
+ await started;
5188
+ await createCronApiHandler({
5189
+ service,
5190
+ listProjects: () => workspaceRegistryOf()?.list().map((w) => w.path) ?? [],
5191
+ runHistoryLimit: config.runHistoryLimit
5192
+ })(req, res);
5193
+ } catch (error) {
5194
+ res.writeHead(500, { "content-type": "application/json" }).end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
5195
+ }
5196
+ }
5197
+ });
5198
+ return () => dispose();
5199
+ });
5200
+ await scheduler.rearmAll();
5201
+ });
5202
+ started.catch((error) => {
5203
+ warn(`[schedule] 启动失败:${error instanceof Error ? error.message : String(error)}`);
5204
+ });
5205
+ ctx.effect(() => {
5206
+ const timer = setInterval(() => {
5207
+ started.then(() => scheduler?.tick());
5208
+ }, TICK_MS);
5209
+ return () => clearInterval(timer);
5210
+ });
5211
+ ctx.effect(() => async () => {
5212
+ await toolsScope.dispose();
5213
+ });
3561
5214
  }
3562
5215
  //#endregion
3563
5216
  //#region src/index.ts
@@ -3602,33 +5255,46 @@ const Config = z.object({
3602
5255
  toolName: z.string().default("team_delegate"),
3603
5256
  feishu: z.object({
3604
5257
  cardUpdateThrottleMs: z.number().default(500),
3605
- cardMaxBytes: z.number().default(28e3),
5258
+ cardMaxBytes: z.number().default(26e3),
5259
+ cardPrintStep: z.number().default(5),
3606
5260
  processMaxBytes: z.number().default(8e3),
3607
5261
  registerAppTimeoutMs: z.number().default(6e5),
3608
5262
  processingReactionEmoji: z.string().default("OneSecond"),
3609
5263
  errorDetailMaxChars: z.number().default(500),
3610
- injectSender: z.boolean().default(true)
5264
+ injectSender: z.boolean().default(true),
5265
+ approval: z.boolean().default(true)
3611
5266
  }).default({
3612
5267
  cardUpdateThrottleMs: 500,
3613
- cardMaxBytes: 28e3,
5268
+ cardMaxBytes: 26e3,
5269
+ cardPrintStep: 5,
3614
5270
  processMaxBytes: 8e3,
3615
5271
  registerAppTimeoutMs: 6e5,
3616
5272
  processingReactionEmoji: "OneSecond",
3617
5273
  errorDetailMaxChars: 500,
3618
- injectSender: true
5274
+ injectSender: true,
5275
+ approval: true
3619
5276
  }),
3620
5277
  agentTeamPreset: z.object({
3621
5278
  enabled: z.boolean().default(true),
3622
5279
  id: z.string().default("agent-team"),
3623
5280
  source: z.string().default("standard"),
3624
5281
  name: z.string().default("Agent 团队"),
3625
- description: z.string().default("Agent 团队模式:禁用原生 subagent 工具族,委派统一走 team_delegate 团队角色")
5282
+ description: z.string().default("Agent 团队模式:禁用原生 subagent 工具族,委派统一走 team_delegate 团队角色"),
5283
+ botsId: z.string().default("agent-bot")
3626
5284
  }).default({
3627
5285
  enabled: true,
3628
5286
  id: "agent-team",
3629
5287
  source: "standard",
3630
5288
  name: "Agent 团队",
3631
- description: "Agent 团队模式:禁用原生 subagent 工具族,委派统一走 team_delegate 团队角色"
5289
+ description: "Agent 团队模式:禁用原生 subagent 工具族,委派统一走 team_delegate 团队角色",
5290
+ botsId: "agent-bot"
5291
+ }),
5292
+ schedule: z.object({
5293
+ runTimeoutMinutes: z.number().min(1).default(60),
5294
+ runHistoryLimit: z.natural().min(1).default(20)
5295
+ }).default({
5296
+ runTimeoutMinutes: 60,
5297
+ runHistoryLimit: 20
3632
5298
  })
3633
5299
  });
3634
5300
  async function apply(ctx, config) {
@@ -3643,10 +5309,12 @@ async function apply(ctx, config) {
3643
5309
  meta: domain.table("meta"),
3644
5310
  promptLayers: domain.table("prompt_layers")
3645
5311
  };
5312
+ await setupAgentTeamPreset(ctx, config.agentTeamPreset);
5313
+ const toolCatalog = createToolCatalog(ctx, config.agentTeamPreset.id);
3646
5314
  const registry = await createRegistry(warn, {
3647
5315
  agents: tables.agents,
3648
5316
  meta: tables.meta
3649
- });
5317
+ }, toolCatalog.listPresetTools);
3650
5318
  const layerSource = await openLayerSource({
3651
5319
  promptLayers: tables.promptLayers,
3652
5320
  meta: tables.meta
@@ -3675,10 +5343,10 @@ async function apply(ctx, config) {
3675
5343
  active: activeRoutes,
3676
5344
  routes: routesTable
3677
5345
  });
3678
- const listTools = () => ctx.tools.schemas().map((s) => s.name);
3679
5346
  setupAgentsApi(ctx, {
3680
5347
  registry,
3681
- listTools,
5348
+ listTools: toolCatalog.listGlobalTools,
5349
+ listPresetTools: toolCatalog.listPresetTools,
3682
5350
  listProviders: () => ctx.llm.listProviders().map(({ id, name }) => ({
3683
5351
  id,
3684
5352
  name
@@ -3690,7 +5358,8 @@ async function apply(ctx, config) {
3690
5358
  });
3691
5359
  setupCreateAgentCommand(ctx, {
3692
5360
  registry,
3693
- listTools
5361
+ listTools: toolCatalog.listGlobalTools,
5362
+ listPresetTools: toolCatalog.listPresetTools
3694
5363
  });
3695
5364
  setupPromptLayersApi(ctx, {
3696
5365
  source: layerSource,
@@ -3710,9 +5379,18 @@ async function apply(ctx, config) {
3710
5379
  };
3711
5380
  }
3712
5381
  });
3713
- await setupAgentTeamPreset(ctx, config.agentTeamPreset);
3714
- if (config.modules.feishu) setupBots(ctx, config.feishu, { registry });
5382
+ const ownedSessions = /* @__PURE__ */ new Set();
5383
+ if (config.modules.feishu) setupBots(ctx, config.feishu, {
5384
+ registry,
5385
+ botPresetId: config.agentTeamPreset.enabled ? config.agentTeamPreset.botsId : void 0,
5386
+ ownedSessions
5387
+ });
3715
5388
  if (config.modules.usage) setupUsage(ctx, { timezone: config.timezone }, name);
5389
+ setupSchedule(ctx, config.schedule, {
5390
+ registry,
5391
+ botPresetId: config.agentTeamPreset.enabled ? config.agentTeamPreset.botsId : void 0,
5392
+ ownedSessions
5393
+ });
3716
5394
  }
3717
5395
  //#endregion
3718
5396
  export { Config, apply, inject, name };