dsh-agent-toolkit 0.2.8 → 0.2.9

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