dsh-lark-bot 0.8.5 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -195,6 +195,11 @@ run in that scope carries the role instructions, and the role model wins below t
195
195
  dsh 工具(SDK / ACP 两种 runtime 均可装配):agent 完成任务后可主动向其他群 / 话题发消息并
196
196
  @ 指定成员,桥接进程通过 127.0.0.1 本地回调端口 + 随机 token 校验,不暴露公网。
197
197
 
198
+ **任务中向你提问(问答卡)**:agent 需要你拍板、确认或补充缺失信息时,会通过
199
+ `lark_ask_user` 工具主动向当前会话弹一张**问答卡**(单选 / 多选 / 自由文本),
200
+ 你回答后任务自动继续——无需额外命令。问答卡等待期间任务不会被运行超时打断。
201
+ (与 `/ask` 的“你主动发结构化问题”方向相反:这是 agent 主动来问你。)
202
+
198
203
  **Outbound mentions & cross-session notify**: the outbound contract supports `mentions` and
199
204
  cross-chat/thread sends; `/notify <scope|chatId> <text>` pushes a report to another session
200
205
  (admin). The agent also gets a built-in `lark_notify` dsh tool (wired into both SDK and ACP
@@ -202,6 +207,12 @@ runtime profiles): after a task finishes it can push messages to other groups/to
202
207
  members. The bridge listens on 127.0.0.1 with a random per-boot token — nothing is exposed to the
203
208
  public network.
204
209
 
210
+ **Mid-task questions (question cards)**: when the agent needs a decision, confirmation, or
211
+ missing information, it proactively sends a **question card** to the current chat via the
212
+ `lark_ask_user` tool (single choice / multi choice / free text) and resumes automatically once
213
+ you answer — no extra command needed. The run-timeout watchdog pauses while a card is waiting.
214
+ (This is the opposite direction of `/ask`, which is you asking the agent.)
215
+
205
216
  **安全网守护(Safe-mode guardian)**:默认随 `setup` 一起安装的、独立于 dsh 进程、系统级常驻的
206
217
  最小守护进程(Linux systemd user unit / macOS LaunchAgent / Windows 启动项)。dsh 正常运行时守护保持静默;
207
218
  一旦 dsh 进程下线或无法 boot(例如某个第三方插件破坏了整个 profile 组合),守护自动接管飞书
@@ -504,7 +515,7 @@ This tool runs **locally**; before installing, be aware that it accesses:
504
515
  - **dsh 配置**:`/model` `/providers` `/provider` `/key` 命令按 dsh 官方存储协议读写
505
516
  `~/.dsh/settings.yaml` 与 `~/.dsh/.credentials.yaml`(仅管理员可写;settings 只存 `apiKeyEnv`
506
517
  引用,凭据文件权限 0600、目录 0700,字面密钥不进入 settings 或聊天记录)。
507
- - **安全网守护(可选)**:安装后为系统级常驻进程,读取 `~/.dsh-lark/config.json` 中的飞书
518
+ - **安全网守护(默认随 `setup` 安装)**:系统级常驻进程,读取 `~/.dsh-lark/config.json` 中的飞书
508
519
  凭据;dsh 下线时接管同一 bot 的飞书长连接并扫描本机进程(仅 `ps` 命令行,不读内存);
509
520
  `/safemode` 时在 `~/.dsh/profiles/<profile>-safe` 创建仅核心的 dsh profile 并逐条执行
510
521
  `dsh --profile <safe> "<prompt>"` 子进程。
@@ -559,11 +570,12 @@ Common issues:
559
570
  is reachable; with an existing App ID/Secret you can skip scanning via `--app-id` /
560
571
  `--app-secret`.
561
572
 
562
- 桥接引擎日志写入 `~/.dsh-lark/profiles/<profile>/logs/bot.log`(JSON Lines);dsh 宿主日志走
563
- dsh 自己的日志体系。
573
+ 桥接引擎日志以 JSON Lines 输出到 stderr(由 dsh 宿主进程捕获;`logs/bot.log` 0.6.0
574
+ 独立服务时代的遗留路径,0.7.0 起不再写入);dsh 宿主日志走 dsh 自己的日志体系。
564
575
 
565
- The bridge engine logs to `~/.dsh-lark/profiles/<profile>/logs/bot.log` (JSON Lines); the dsh
566
- host uses its own logging.
576
+ The bridge engine logs JSON Lines to stderr (captured by the dsh host; `logs/bot.log` is a
577
+ leftover path from the 0.6.0 standalone-service era and is no longer written since 0.7.0); the
578
+ dsh host uses its own logging.
567
579
 
568
580
  **回滚 / Rollback**:`dsh plugin --profile dsh-lark remove dsh-lark-bot` 后重装固定版本即可
569
581
  (如 `dsh plugin --profile dsh-lark add dsh-lark-bot@0.6.0`);`~/.dsh-lark` 状态独立于插件
@@ -580,6 +592,7 @@ pnpm install
580
592
  pnpm typecheck
581
593
  pnpm test
582
594
  pnpm build
595
+ pnpm check:publish-bundle # 校验 dist 与全部 exports/bin 入口一致(发布前防线)| verifies dist matches every export & the CLI entry (release gate)
583
596
  pnpm ci:local
584
597
  pnpm release:check # ci:local + 上游一致性检查 | ci:local + upstream consistency check
585
598
  pnpm compat:probe # 临时 DSH_HOME 安装锁定版 dsh,跑真实 SDK 握手 | installs pinned dsh into a temp DSH_HOME and runs a real SDK handshake
@@ -611,7 +624,7 @@ pnpm publish:dual:dry-run
611
624
  pnpm publish:dual
612
625
  ```
613
626
 
614
- `scripts/publish-dual-packages.mjs` 从根 `package.json` 生成两份仅 `name` / `bin` 不同的发布清单,避免两份源码漂移。GitHub tag `v*` 会触发 [`release.yml`](.github/workflows/release.yml) 自动发布两个 npm 包并创建 Release。
627
+ `scripts/publish-dual-packages.mjs` 从根 `package.json` 生成两份仅 `name` / `bin` 不同的发布清单,避免两份源码漂移。发布时整目录同步 `dist/`,并在发布前校验 `package.json` 每个 `exports` 子路径与 CLI 入口在产物中都存在——任何缺失(如 v0.9.0 的 `ask` 入口漏拷)都会直接中止发布。GitHub tag `v*` 会触发 [`release.yml`](.github/workflows/release.yml) 自动发布两个 npm 包并创建 Release。
615
628
 
616
629
  `scripts/publish-dual-packages.mjs` generates two publish manifests from the root
617
630
  `package.json`, differing only in `name` / `bin`, so the two copies never drift. A GitHub tag
package/SECURITY.md CHANGED
@@ -37,7 +37,7 @@
37
37
  `/invite admin <open_id>` 定义);查看类命令(`/model`、`/providers`、`/key list`)开放。
38
38
  10. **本地回调隔离**:`lark_notify` 工具的回调服务只绑定 `127.0.0.1`,每次启动生成随机
39
39
  token 鉴权(不落盘、不进日志),请求体限 1MB;`/notify` 与角色 / 配置写命令同为管理员操作。
40
- 11. **安全网守护(可选安装)**:
40
+ 11. **安全网守护(默认随 `setup` 安装)**:
41
41
  - 守护是独立于 dsh / Cordis 的最小进程,只读取本地状态与进程命令行(`ps`,不读内存),
42
42
  不导入任何 dsh 代码、不监听公网端口;
43
43
  - dsh 在线时守护**不连接飞书**(同 app 长连接仅允许单连接,避免抢占正常通道);仅在
@@ -54,7 +54,8 @@
54
54
  - 飞书凭据明文保存在本机配置文件;日志与卡片不输出真实密钥。
55
55
  - 桥接引擎在 dsh 宿主进程内运行,凭据与 `DSH_LARK_*` 环境直接随 dsh 进程提供,无独立服务
56
56
  环境快照文件;敏感值不进日志与卡片。
57
- - 后台运行日志写入 `~/.dsh-lark/profiles/<profile>/logs/bot.log`(JSON Lines,密钥字段脱敏后输出)。
57
+ - 桥接引擎日志以 JSON Lines 输出到 stderr(由 dsh 宿主进程捕获),密钥字段脱敏后输出;
58
+ `logs/bot.log` 是 0.6.0 独立服务时代的遗留路径,0.7.0 起不再写入。
58
59
  - 聊天命令管理的 dsh 配置按官方存储协议写入:`~/.dsh/settings.yaml`(只存 `apiKeyEnv`
59
60
  引用,不落字面密钥)与 `~/.dsh/.credentials.yaml`(目录 0700、文件 0600)。bot 永不回显
60
61
  密钥值;群聊中粘贴密钥会对群成员可见,建议私聊使用或改用环境变量 / dsh Web 页面录入。
package/dist/ask.d.ts ADDED
@@ -0,0 +1,25 @@
1
+ import { Context } from '@deepseek-ai/cordis';
2
+
3
+ /** Cordis plugin name; referenced by the runtime patch row. */
4
+ declare const name = "lark-ask";
5
+ /** Requires the dsh tool registry before registering. */
6
+ declare const inject: string[];
7
+ interface Config {
8
+ /** Localhost callback URL of the running bridge process (/ask). */
9
+ endpoint?: string;
10
+ /** Shared token authorizing the callback. */
11
+ token?: string;
12
+ }
13
+ /** The user may take a while to answer a card; keep the tool well above the
14
+ * default run-timeout so the question itself is not what kills the task. */
15
+ declare const ASK_TOOL_TIMEOUT_MS = 600000;
16
+ /**
17
+ * dsh tool that asks the user a question through a Feishu/Lark card when the
18
+ * agent needs a decision, confirmation, or missing information before
19
+ * proceeding. The bridge runs a localhost-only callback server; this plugin
20
+ * is the runtime side of that channel: it posts the question and blocks until
21
+ * the human answers the card.
22
+ */
23
+ declare function apply(ctx: Context, config?: Config): void;
24
+
25
+ export { ASK_TOOL_TIMEOUT_MS, type Config, apply, inject, name };
package/dist/ask.js ADDED
@@ -0,0 +1,95 @@
1
+ // src/notify/ask-tool.ts
2
+ import { defineTool } from "@deepseek-ai/dsh-tools";
3
+ var name = "lark-ask";
4
+ var inject = ["tools"];
5
+ var ASK_TOOL_TIMEOUT_MS = 6e5;
6
+ function apply(ctx, config = {}) {
7
+ ctx.tools.register(
8
+ defineTool({
9
+ name: "lark_ask_user",
10
+ description: "Ask the user a question through a Feishu/Lark card when you need a decision, confirmation, or missing information before proceeding. The tool blocks until the user answers. Use it sparingly and only for choices or facts only the user can provide; resolve everything discoverable by inspection yourself first.",
11
+ timeoutMs: ASK_TOOL_TIMEOUT_MS,
12
+ parameters: {
13
+ question: {
14
+ type: "string",
15
+ required: true,
16
+ description: "The question to ask the user."
17
+ },
18
+ kind: {
19
+ type: "string",
20
+ enum: ["single", "multi", "text"],
21
+ description: "single = one choice, multi = multiple choices, text = free text. Defaults to single when options are given, otherwise text."
22
+ },
23
+ options: {
24
+ type: "array",
25
+ items: { type: "string" },
26
+ description: "Choices for single / multi questions."
27
+ },
28
+ header: {
29
+ type: "string",
30
+ description: "Optional short heading shown above the question."
31
+ }
32
+ },
33
+ output: {
34
+ schema: {
35
+ type: "object",
36
+ additionalProperties: false,
37
+ properties: {
38
+ answered: { type: "boolean", required: true },
39
+ answer: { type: "json" },
40
+ error: { type: "string" }
41
+ }
42
+ },
43
+ render: (_args, value) => [
44
+ {
45
+ type: "text",
46
+ text: value.answered ? `User answered: ${JSON.stringify(value.answer)}` : `Question failed: ${value.error ?? "no answer"}`
47
+ }
48
+ ]
49
+ },
50
+ async execute(args, exec) {
51
+ const endpoint = config.endpoint ?? process.env.DSH_LARK_ASK_URL;
52
+ const token = config.token ?? process.env.DSH_LARK_NOTIFY_TOKEN;
53
+ if (!endpoint || !token) {
54
+ throw new Error("lark_ask_user is not configured (endpoint/token missing)");
55
+ }
56
+ const sessionId = exec?.agent?.session === void 0 ? void 0 : String(exec.agent.session.id);
57
+ if (!sessionId) {
58
+ throw new Error("lark_ask_user needs an active session to route the question");
59
+ }
60
+ const kind = args.kind ?? (args.options && args.options.length > 0 ? "single" : "text");
61
+ const response = await fetch(endpoint, {
62
+ method: "POST",
63
+ headers: { "content-type": "application/json" },
64
+ body: JSON.stringify({
65
+ token,
66
+ sessionId,
67
+ question: args.question,
68
+ kind,
69
+ ...args.options && args.options.length > 0 ? { options: args.options } : {},
70
+ ...args.header === void 0 ? {} : { header: args.header }
71
+ }),
72
+ ...exec?.signal === void 0 ? {} : { signal: exec.signal }
73
+ });
74
+ const body = await response.json();
75
+ if (!response.ok || body.ok !== true) {
76
+ return {
77
+ answered: false,
78
+ ...body.error === void 0 ? {} : { error: body.error }
79
+ };
80
+ }
81
+ return {
82
+ answered: true,
83
+ ...body.answer === void 0 ? {} : { answer: body.answer ?? null }
84
+ };
85
+ }
86
+ })
87
+ );
88
+ }
89
+ export {
90
+ ASK_TOOL_TIMEOUT_MS,
91
+ apply,
92
+ inject,
93
+ name
94
+ };
95
+ //# sourceMappingURL=ask.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/notify/ask-tool.ts"],"sourcesContent":["import type { Context } from '@deepseek-ai/cordis';\nimport { defineTool } from '@deepseek-ai/dsh-tools';\n\n/** Cordis plugin name; referenced by the runtime patch row. */\nexport const name = 'lark-ask';\n\n/** Requires the dsh tool registry before registering. */\nexport const inject = ['tools'];\n\nexport interface Config {\n /** Localhost callback URL of the running bridge process (/ask). */\n endpoint?: string;\n /** Shared token authorizing the callback. */\n token?: string;\n}\n\n/** The user may take a while to answer a card; keep the tool well above the\n * default run-timeout so the question itself is not what kills the task. */\nexport const ASK_TOOL_TIMEOUT_MS = 600_000;\n\ninterface AskToolExec {\n agent?: { session?: { id?: unknown } };\n signal?: AbortSignal;\n}\n\n/**\n * dsh tool that asks the user a question through a Feishu/Lark card when the\n * agent needs a decision, confirmation, or missing information before\n * proceeding. The bridge runs a localhost-only callback server; this plugin\n * is the runtime side of that channel: it posts the question and blocks until\n * the human answers the card.\n */\nexport function apply(ctx: Context, config: Config = {}) {\n ctx.tools.register(\n defineTool({\n name: 'lark_ask_user',\n description:\n 'Ask the user a question through a Feishu/Lark card when you need a decision, confirmation, or missing information before proceeding. The tool blocks until the user answers. Use it sparingly and only for choices or facts only the user can provide; resolve everything discoverable by inspection yourself first.',\n timeoutMs: ASK_TOOL_TIMEOUT_MS,\n parameters: {\n question: {\n type: 'string',\n required: true,\n description: 'The question to ask the user.',\n },\n kind: {\n type: 'string',\n enum: ['single', 'multi', 'text'],\n description:\n 'single = one choice, multi = multiple choices, text = free text. Defaults to single when options are given, otherwise text.',\n },\n options: {\n type: 'array',\n items: { type: 'string' },\n description: 'Choices for single / multi questions.',\n },\n header: {\n type: 'string',\n description: 'Optional short heading shown above the question.',\n },\n },\n output: {\n schema: {\n type: 'object',\n additionalProperties: false,\n properties: {\n answered: { type: 'boolean', required: true },\n answer: { type: 'json' },\n error: { type: 'string' },\n },\n },\n render: (_args, value) => [\n {\n type: 'text',\n text: value.answered\n ? `User answered: ${JSON.stringify(value.answer)}`\n : `Question failed: ${value.error ?? 'no answer'}`,\n },\n ],\n },\n async execute(args, exec: AskToolExec | undefined) {\n // The callback endpoint/token may be configured by the patch row or\n // injected at runtime: the bridge process sets the env vars when its\n // notify server starts, so read them lazily at execute time.\n const endpoint = config.endpoint ?? process.env.DSH_LARK_ASK_URL;\n const token = config.token ?? process.env.DSH_LARK_NOTIFY_TOKEN;\n if (!endpoint || !token) {\n throw new Error('lark_ask_user is not configured (endpoint/token missing)');\n }\n const sessionId =\n exec?.agent?.session === undefined\n ? undefined\n : String(exec.agent.session.id);\n if (!sessionId) {\n throw new Error('lark_ask_user needs an active session to route the question');\n }\n const kind =\n args.kind ?? (args.options && args.options.length > 0 ? 'single' : 'text');\n const response = await fetch(endpoint, {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({\n token,\n sessionId,\n question: args.question,\n kind,\n ...(args.options && args.options.length > 0 ? { options: args.options } : {}),\n ...(args.header === undefined ? {} : { header: args.header }),\n }),\n ...(exec?.signal === undefined ? {} : { signal: exec.signal }),\n });\n const body = (await response.json()) as {\n ok?: boolean;\n answer?: string | string[] | null;\n error?: string;\n };\n if (!response.ok || body.ok !== true) {\n return {\n answered: false,\n ...(body.error === undefined ? {} : { error: body.error }),\n };\n }\n return {\n answered: true,\n ...(body.answer === undefined ? {} : { answer: body.answer ?? null }),\n };\n },\n }),\n );\n}\n"],"mappings":";AACA,SAAS,kBAAkB;AAGpB,IAAM,OAAO;AAGb,IAAM,SAAS,CAAC,OAAO;AAWvB,IAAM,sBAAsB;AAc5B,SAAS,MAAM,KAAc,SAAiB,CAAC,GAAG;AACvD,MAAI,MAAM;AAAA,IACR,WAAW;AAAA,MACT,MAAM;AAAA,MACN,aACE;AAAA,MACF,WAAW;AAAA,MACX,YAAY;AAAA,QACV,UAAU;AAAA,UACR,MAAM;AAAA,UACN,UAAU;AAAA,UACV,aAAa;AAAA,QACf;AAAA,QACA,MAAM;AAAA,UACJ,MAAM;AAAA,UACN,MAAM,CAAC,UAAU,SAAS,MAAM;AAAA,UAChC,aACE;AAAA,QACJ;AAAA,QACA,SAAS;AAAA,UACP,MAAM;AAAA,UACN,OAAO,EAAE,MAAM,SAAS;AAAA,UACxB,aAAa;AAAA,QACf;AAAA,QACA,QAAQ;AAAA,UACN,MAAM;AAAA,UACN,aAAa;AAAA,QACf;AAAA,MACF;AAAA,MACA,QAAQ;AAAA,QACN,QAAQ;AAAA,UACN,MAAM;AAAA,UACN,sBAAsB;AAAA,UACtB,YAAY;AAAA,YACV,UAAU,EAAE,MAAM,WAAW,UAAU,KAAK;AAAA,YAC5C,QAAQ,EAAE,MAAM,OAAO;AAAA,YACvB,OAAO,EAAE,MAAM,SAAS;AAAA,UAC1B;AAAA,QACF;AAAA,QACA,QAAQ,CAAC,OAAO,UAAU;AAAA,UACxB;AAAA,YACE,MAAM;AAAA,YACN,MAAM,MAAM,WACR,kBAAkB,KAAK,UAAU,MAAM,MAAM,CAAC,KAC9C,oBAAoB,MAAM,SAAS,WAAW;AAAA,UACpD;AAAA,QACF;AAAA,MACF;AAAA,MACA,MAAM,QAAQ,MAAM,MAA+B;AAIjD,cAAM,WAAW,OAAO,YAAY,QAAQ,IAAI;AAChD,cAAM,QAAQ,OAAO,SAAS,QAAQ,IAAI;AAC1C,YAAI,CAAC,YAAY,CAAC,OAAO;AACvB,gBAAM,IAAI,MAAM,0DAA0D;AAAA,QAC5E;AACA,cAAM,YACJ,MAAM,OAAO,YAAY,SACrB,SACA,OAAO,KAAK,MAAM,QAAQ,EAAE;AAClC,YAAI,CAAC,WAAW;AACd,gBAAM,IAAI,MAAM,6DAA6D;AAAA,QAC/E;AACA,cAAM,OACJ,KAAK,SAAS,KAAK,WAAW,KAAK,QAAQ,SAAS,IAAI,WAAW;AACrE,cAAM,WAAW,MAAM,MAAM,UAAU;AAAA,UACrC,QAAQ;AAAA,UACR,SAAS,EAAE,gBAAgB,mBAAmB;AAAA,UAC9C,MAAM,KAAK,UAAU;AAAA,YACnB;AAAA,YACA;AAAA,YACA,UAAU,KAAK;AAAA,YACf;AAAA,YACA,GAAI,KAAK,WAAW,KAAK,QAAQ,SAAS,IAAI,EAAE,SAAS,KAAK,QAAQ,IAAI,CAAC;AAAA,YAC3E,GAAI,KAAK,WAAW,SAAY,CAAC,IAAI,EAAE,QAAQ,KAAK,OAAO;AAAA,UAC7D,CAAC;AAAA,UACD,GAAI,MAAM,WAAW,SAAY,CAAC,IAAI,EAAE,QAAQ,KAAK,OAAO;AAAA,QAC9D,CAAC;AACD,cAAM,OAAQ,MAAM,SAAS,KAAK;AAKlC,YAAI,CAAC,SAAS,MAAM,KAAK,OAAO,MAAM;AACpC,iBAAO;AAAA,YACL,UAAU;AAAA,YACV,GAAI,KAAK,UAAU,SAAY,CAAC,IAAI,EAAE,OAAO,KAAK,MAAM;AAAA,UAC1D;AAAA,QACF;AACA,eAAO;AAAA,UACL,UAAU;AAAA,UACV,GAAI,KAAK,WAAW,SAAY,CAAC,IAAI,EAAE,QAAQ,KAAK,UAAU,KAAK;AAAA,QACrE;AAAA,MACF;AAAA,IACF,CAAC;AAAA,EACH;AACF;","names":[]}
package/dist/cli.js CHANGED
@@ -226,6 +226,14 @@ function acpPatchYaml(provider, model) {
226
226
  " config:",
227
227
  " endpoint: !!js process.env.DSH_LARK_NOTIFY_URL",
228
228
  " token: !!js process.env.DSH_LARK_NOTIFY_TOKEN",
229
+ "",
230
+ // Question-card tool (same contract as the SDK runtime).
231
+ "- insert:",
232
+ " - id: lark-ask",
233
+ ` name: '${own.name}/ask'`,
234
+ " config:",
235
+ " endpoint: !!js process.env.DSH_LARK_ASK_URL",
236
+ " token: !!js process.env.DSH_LARK_NOTIFY_TOKEN",
229
237
  ""
230
238
  ].join("\n");
231
239
  }
@@ -238,14 +246,14 @@ function acpPluginInstalled(profileRoot) {
238
246
  }
239
247
  function isAcpProfileReady(profileRoot) {
240
248
  const own = ownPackageInfo();
241
- return existsSync4(join4(profileRoot, "package.json")) && existsSync4(join4(profileRoot, "cordis.yml")) && existsSync4(join4(profileRoot, "cordis.patch.yml")) && acpPluginInstalled(profileRoot) && ownPackageLinked2(profileRoot, own.name);
249
+ return existsSync4(join4(profileRoot, "package.json")) && existsSync4(join4(profileRoot, "cordis.yml")) && existsSync4(join4(profileRoot, "cordis.patch.yml")) && acpPluginInstalled(profileRoot) && ownPackageLinked2(profileRoot, own);
242
250
  }
243
- function ownPackageLinked2(profileRoot, ownName) {
244
- const linkPath = join4(profileRoot, "node_modules", ownName);
251
+ function ownPackageLinked2(profileRoot, own) {
252
+ const linkPath = join4(profileRoot, "node_modules", own.name);
245
253
  try {
246
254
  const real = realpathSync2(linkPath);
247
255
  const pkg = JSON.parse(readFileSync3(join4(real, "package.json"), "utf8"));
248
- return pkg.name === ownName && pkg.dsh?.bundle?.patch !== void 0;
256
+ return pkg.name === own.name && pkg.dsh?.bundle?.patch !== void 0 && real === realpathSync2(own.root);
249
257
  } catch {
250
258
  return false;
251
259
  }
@@ -1323,6 +1331,15 @@ function patchYamlFor() {
1323
1331
  " config:",
1324
1332
  " endpoint: !!js process.env.DSH_LARK_NOTIFY_URL",
1325
1333
  " token: !!js process.env.DSH_LARK_NOTIFY_TOKEN",
1334
+ "",
1335
+ // Question-card tool: the agent asks the user for decisions / missing
1336
+ // information; the bridge shows a card and returns the answer.
1337
+ "- insert:",
1338
+ " - id: lark-ask",
1339
+ ` name: '${own.name}/ask'`,
1340
+ " config:",
1341
+ " endpoint: !!js process.env.DSH_LARK_ASK_URL",
1342
+ " token: !!js process.env.DSH_LARK_NOTIFY_TOKEN",
1326
1343
  ""
1327
1344
  ].join("\n");
1328
1345
  }
@@ -1335,14 +1352,14 @@ function sdkServerInstalled(profileRoot) {
1335
1352
  }
1336
1353
  function isSdkProfileReady(profileRoot) {
1337
1354
  const own = ownPackageInfo();
1338
- return existsSync3(join3(profileRoot, "package.json")) && existsSync3(join3(profileRoot, "cordis.yml")) && existsSync3(join3(profileRoot, "cordis.patch.yml")) && sdkServerInstalled(profileRoot) && ownPackageLinked(profileRoot, own.name);
1355
+ return existsSync3(join3(profileRoot, "package.json")) && existsSync3(join3(profileRoot, "cordis.yml")) && existsSync3(join3(profileRoot, "cordis.patch.yml")) && sdkServerInstalled(profileRoot) && ownPackageLinked(profileRoot, own);
1339
1356
  }
1340
- function ownPackageLinked(profileRoot, ownName) {
1341
- const linkPath = join3(profileRoot, "node_modules", ownName);
1357
+ function ownPackageLinked(profileRoot, own) {
1358
+ const linkPath = join3(profileRoot, "node_modules", own.name);
1342
1359
  try {
1343
1360
  const real = realpathSync(linkPath);
1344
1361
  const pkg = JSON.parse(readFileSync2(join3(real, "package.json"), "utf8"));
1345
- return pkg.name === ownName && pkg.dsh?.bundle?.patch !== void 0;
1362
+ return pkg.name === own.name && pkg.dsh?.bundle?.patch !== void 0 && real === realpathSync(own.root);
1346
1363
  } catch {
1347
1364
  return false;
1348
1365
  }
@@ -2194,6 +2211,28 @@ var PendingQueue = class {
2194
2211
  import { randomUUID as randomUUID3 } from "crypto";
2195
2212
  var QuestionRegistry = class {
2196
2213
  pending = /* @__PURE__ */ new Map();
2214
+ settledListeners = /* @__PURE__ */ new Map();
2215
+ /** Number of question cards currently awaiting an answer in a scope. */
2216
+ pendingCount(scope) {
2217
+ let count = 0;
2218
+ for (const key of this.pending.keys()) {
2219
+ if (key.startsWith(`${scope}:`)) count += 1;
2220
+ }
2221
+ return count;
2222
+ }
2223
+ /** Subscribe to question settlements in a scope; returns an unsubscribe. */
2224
+ onSettled(scope, listener) {
2225
+ const listeners = this.settledListeners.get(scope) ?? /* @__PURE__ */ new Set();
2226
+ listeners.add(listener);
2227
+ this.settledListeners.set(scope, listeners);
2228
+ return () => {
2229
+ listeners.delete(listener);
2230
+ if (listeners.size === 0) this.settledListeners.delete(scope);
2231
+ };
2232
+ }
2233
+ notifySettled(scope) {
2234
+ for (const listener of this.settledListeners.get(scope) ?? []) listener();
2235
+ }
2197
2236
  register(scope, input) {
2198
2237
  const id = `question-${randomUUID3().replaceAll("-", "")}`;
2199
2238
  const full = { ...input, id };
@@ -2208,6 +2247,7 @@ var QuestionRegistry = class {
2208
2247
  pending.settled = true;
2209
2248
  this.pending.delete(`${scope}:${id}`);
2210
2249
  pending.resolve(answer);
2250
+ this.notifySettled(scope);
2211
2251
  return true;
2212
2252
  }
2213
2253
  settleAll(scope) {
@@ -2220,6 +2260,7 @@ var QuestionRegistry = class {
2220
2260
  pending.resolve(void 0);
2221
2261
  count += 1;
2222
2262
  }
2263
+ if (count > 0) this.notifySettled(scope);
2223
2264
  return count;
2224
2265
  }
2225
2266
  get(scope, id) {
@@ -2879,12 +2920,26 @@ async function runAgentBatch(input) {
2879
2920
  }
2880
2921
  };
2881
2922
  let timeoutTimer;
2923
+ let armTimeout;
2882
2924
  const timeoutPromise = timeoutMs > 0 ? new Promise((resolve5) => {
2883
- timeoutTimer = setTimeout(() => {
2884
- timedOut = true;
2885
- void run.stop();
2886
- resolve5();
2887
- }, timeoutMs);
2925
+ armTimeout = () => {
2926
+ timeoutTimer = setTimeout(() => {
2927
+ if (timedOut) return;
2928
+ if (input.questions?.pendingCount(input.scope)) {
2929
+ armTimeout?.();
2930
+ return;
2931
+ }
2932
+ timedOut = true;
2933
+ void run.stop();
2934
+ resolve5();
2935
+ }, timeoutMs);
2936
+ };
2937
+ armTimeout();
2938
+ }) : void 0;
2939
+ const unsubscribeSettled = timeoutPromise ? input.questions?.onSettled(input.scope, () => {
2940
+ if (timedOut) return;
2941
+ if (timeoutTimer !== void 0) clearTimeout(timeoutTimer);
2942
+ armTimeout?.();
2888
2943
  }) : void 0;
2889
2944
  try {
2890
2945
  if (timeoutPromise) {
@@ -2896,6 +2951,7 @@ async function runAgentBatch(input) {
2896
2951
  await controller.update(renderCard(state, density));
2897
2952
  } finally {
2898
2953
  if (timeoutTimer !== void 0) clearTimeout(timeoutTimer);
2954
+ unsubscribeSettled?.();
2899
2955
  }
2900
2956
  },
2901
2957
  replyOptions
@@ -4655,6 +4711,7 @@ var NotifyServer = class {
4655
4711
  token;
4656
4712
  deps;
4657
4713
  url;
4714
+ askUrl;
4658
4715
  constructor(deps) {
4659
4716
  this.deps = deps;
4660
4717
  this.token = deps.token;
@@ -4670,6 +4727,7 @@ var NotifyServer = class {
4670
4727
  server.listen(this.deps.port ?? 0, "127.0.0.1", () => {
4671
4728
  const address = server.address();
4672
4729
  this.url = `http://127.0.0.1:${String(address.port)}/notify`;
4730
+ this.askUrl = `http://127.0.0.1:${String(address.port)}/ask`;
4673
4731
  resolve5();
4674
4732
  });
4675
4733
  });
@@ -4690,12 +4748,38 @@ var NotifyServer = class {
4690
4748
  res.end(`${JSON.stringify(body)}
4691
4749
  `);
4692
4750
  };
4693
- if (req.method !== "POST" || req.url !== "/notify") {
4694
- respond(404, { ok: false, error: "not found" });
4695
- return;
4696
- }
4697
4751
  try {
4698
4752
  const body = await readBody(req);
4753
+ if (req.method !== "POST") {
4754
+ respond(404, { ok: false, error: "not found" });
4755
+ return;
4756
+ }
4757
+ if (req.url === "/ask") {
4758
+ if (!this.deps.ask) {
4759
+ respond(404, { ok: false, error: "ask channel is not wired" });
4760
+ return;
4761
+ }
4762
+ const payload = JSON.parse(body);
4763
+ if (payload.token !== this.token) {
4764
+ respond(401, { ok: false, error: "invalid token" });
4765
+ return;
4766
+ }
4767
+ if (!payload.sessionId || !payload.question?.trim()) {
4768
+ respond(400, { ok: false, error: "sessionId and question are required" });
4769
+ return;
4770
+ }
4771
+ const result = await this.deps.ask(payload);
4772
+ if (!result.ok) {
4773
+ respond(404, { ok: false, ...result.error === void 0 ? {} : { error: result.error } });
4774
+ return;
4775
+ }
4776
+ respond(200, { ok: true, ...result.answer === void 0 ? {} : { answer: result.answer } });
4777
+ return;
4778
+ }
4779
+ if (req.url !== "/notify") {
4780
+ respond(404, { ok: false, error: "not found" });
4781
+ return;
4782
+ }
4699
4783
  const message = JSON.parse(body);
4700
4784
  if (message.token !== this.token) {
4701
4785
  respond(401, { ok: false, error: "invalid token" });
@@ -4739,6 +4823,46 @@ function generateNotifyToken() {
4739
4823
  return `dsh-lark-${randomBytes2(18).toString("hex")}`;
4740
4824
  }
4741
4825
 
4826
+ // src/notify/ask-handler.ts
4827
+ function buildAskHandler(deps) {
4828
+ return async (payload) => {
4829
+ const scope = deps.sessions.scopeForSession(payload.sessionId);
4830
+ if (!scope) {
4831
+ return { ok: false, error: `unknown session: ${payload.sessionId}` };
4832
+ }
4833
+ const destination = deps.scopeDirectory.resolve(scope);
4834
+ if (!destination) {
4835
+ return { ok: false, error: `unknown scope: ${scope}` };
4836
+ }
4837
+ const kind = payload.kind ?? (payload.options && payload.options.length > 0 ? "single" : "text");
4838
+ const input = {
4839
+ kind,
4840
+ question: payload.question,
4841
+ ...payload.options && payload.options.length > 0 ? { options: payload.options } : {}
4842
+ };
4843
+ const { id, promise } = deps.questions.register(scope, input);
4844
+ try {
4845
+ await deps.channel.sendCard(
4846
+ destination.chatId,
4847
+ renderQuestionCard({ ...input, id }),
4848
+ destination.threadId ? { threadId: destination.threadId } : void 0
4849
+ );
4850
+ } catch (error) {
4851
+ log.fail("ask-card", error, { scope });
4852
+ deps.questions.settleAll(scope);
4853
+ return {
4854
+ ok: false,
4855
+ error: error instanceof Error ? error.message : String(error)
4856
+ };
4857
+ }
4858
+ const answer = await promise;
4859
+ if (answer === void 0) {
4860
+ return { ok: false, error: "question cancelled" };
4861
+ }
4862
+ return { ok: true, answer };
4863
+ };
4864
+ }
4865
+
4742
4866
  // src/config/access-manager.ts
4743
4867
  var AccessManager = class {
4744
4868
  constructor(store, profileName) {
@@ -4892,6 +5016,8 @@ ${safeContent}`
4892
5016
  import { readFile as readFile6 } from "fs/promises";
4893
5017
  var SessionStore = class {
4894
5018
  data = { chats: {} };
5019
+ /** Live session-id → scope index (used by the agent question-card router). */
5020
+ sessionScopes = /* @__PURE__ */ new Map();
4895
5021
  saving = Promise.resolve();
4896
5022
  pendingArchive = Promise.resolve();
4897
5023
  path;
@@ -4929,8 +5055,22 @@ var SessionStore = class {
4929
5055
  cwd,
4930
5056
  messages: existing?.messages ?? []
4931
5057
  };
5058
+ if (sessionId) this.sessionScopes.set(sessionId, scopeId);
4932
5059
  this.schedulePersist();
4933
5060
  }
5061
+ /**
5062
+ * Reverse lookup used by the bridge question-card router: map a live dsh
5063
+ * session id back to its bridge scope. Falls back to a scan of the loaded
5064
+ * records (e.g. after a fresh load before any live run registered).
5065
+ */
5066
+ scopeForSession(sessionId) {
5067
+ const direct = this.sessionScopes.get(sessionId);
5068
+ if (direct !== void 0) return direct;
5069
+ for (const [scope, record] of Object.entries(this.data.chats)) {
5070
+ if (record.sessionId === sessionId) return scope;
5071
+ }
5072
+ return void 0;
5073
+ }
4934
5074
  historyFor(scopeId, cwd) {
4935
5075
  const record = this.data.chats[scopeId];
4936
5076
  return record && record.cwd === cwd ? record.messages : [];
@@ -5599,7 +5739,19 @@ async function startBridgeEngine(options) {
5599
5739
  ...destination.threadId ? { threadId: destination.threadId } : {},
5600
5740
  ...payload.mentions ? { mentions: payload.mentions } : {}
5601
5741
  });
5602
- }
5742
+ },
5743
+ ask: buildAskHandler({
5744
+ sessions,
5745
+ scopeDirectory,
5746
+ questions,
5747
+ channel: {
5748
+ sendCard: async (chatId, card, options2) => {
5749
+ if (!streaming) throw new Error("bridge channel is not ready");
5750
+ if (!streaming.sendCard) throw new Error("bridge channel does not support cards");
5751
+ await streaming.sendCard(chatId, card, options2);
5752
+ }
5753
+ }
5754
+ })
5603
5755
  });
5604
5756
  process.env.DSH_LARK_NOTIFY_TOKEN = notifyToken;
5605
5757
  const pending = new PendingQueue(
@@ -5696,6 +5848,7 @@ async function startBridgeEngine(options) {
5696
5848
  larkChannel = bridge.channel;
5697
5849
  await notifyServer.start();
5698
5850
  process.env.DSH_LARK_NOTIFY_URL = notifyServer.url ?? "";
5851
+ process.env.DSH_LARK_ASK_URL = notifyServer.askUrl ?? "";
5699
5852
  const heartbeat = startHeartbeat(
5700
5853
  paths.profilePath(profileName, "guardian", "heartbeat.json"),
5701
5854
  process.pid,