动机
插件目前只在回合结束时通知。当 agent 在回合中途调用 ask_user_question 暂停等你回答时,不会有任何通知,在别的标签页或别的应用里的用户,直到切回来才会发现有提问在等待。
期望行为
ask_user_question 挂起时弹出浏览器通知:标题「DSH is asking you / DSH 正在向你提问」,正文为问题原文(从工具调用解析)。
- 遵循既有偏好:主开关、分结果开关、关键词规则、
requireInteraction、backgroundOnly(开=仅会话在眼前且页面可见时抑制;关=始终通知)。
- 子代理会话跳过;同一提问不重复通知;页面加载/重连时已挂起的旧提问不补通知。
为什么需要宿主侧小改动
提问等待期间 agent 循环阻塞在工具调用内:没有 turn/end、没有 turn reason、也不会触发 running→idle 完成提醒,客户端光靠会话列表无法得知提问挂起。插件已有宿主 session projection——扩展它是最自然的位置。
在本地的实现思路
1. 宿主投影折叠 src/projection.ts
- 折叠状态增加
openAsk: { turn, callId, body } | null。
tool/call 且 event.data.name === 'ask_user_question' 时置位;body 从 event.data.arguments(模型原始 JSON 字符串,形如 {"questions":[{"id","question",…}]})解析第一个非空 questions[].question,用既有 boundText(maxBodyChars) 截断;解析失败为 ''。
tool/result 且 event.data.message.source.callId === openAsk.callId 时清除(其他调用的 result 不影响)。
turn/end 时清除(中止/打断时提问可能仍挂起)。
view() 返回 { ...(last ?? EMPTY_PROJECTION), ask: openAsk ? { id: callId, turn, body } : null }。
- schema 增加
ask: z.object({ id: z.string(), turn: z.number().int().nonnegative(), body: z.string() }).nullable();stateVersion 1→2 使投影缓存失效。
2. 共享契约 src/contract.ts
NotificationReason 增加 'ask'。
NotificationProjectionValue 增加 ask: { id, turn, body } | null(id=工具调用 id,同一回合多次提问可区分)。
NotificationSettings 增加 notifyAsk: boolean。
3. 客户端 runner src/client/runner.ts
askAdvance(prev: { seeded, id } | undefined, projection):播种式折叠。首次观察只记录基线不通知(页面加载/重连时已挂起的提问不补);播种后,挂起提问的 id 与上次不同即 fresh;提问解决后保留上次 id,同一调用不重发、后续新提问仍会发。用 id 而非回合号 → 同一回合问两次也会各通知一次。
- 关键细节:播种状态必须区分「从未观察」与「已播种但无提问」——否则页面加载后第一个真正的新提问会被误当播种吞掉(我的首版实现就踩了这个坑)。
askFor(...):origin === 'subagent' 或无挂起提问返回 null;关键词规则主体=标题+问题文本;走正常 shouldNotify 门控;返回 tag 为 dsh-notification-ask-<sessionId> 的计划(独立槽位,提问通知与完成通知共存不互替)。
4. 客户端插件 src/client/index.ts
- 在现有 runner effect 中并行维护
observedAsk 映射;fresh 时:权限检查 → 既有 shouldShow 门控 → show(t(titleKey('ask')), bodyText(ask.body, t('notify.emptyAsk')), tag, requireInteraction)。
connection/reset 时与完成通知一样重置两个映射。
5. 标题/槽位 src/client/notifier.ts
titleKey('ask') → 'notify.titleAsk';新增 askTag(sessionId)。
6. 设置 SettingsSection.tsx / store.ts / locales.ts
- 新分结果开关「向你提问 / Asked a question」(
notifyAsk)。默认开(提问交互性强且有时效性;维护者也可按其他 opt-in 结果的惯例默认关)。
- 文案键:
settings.when.ask、notify.titleAsk、notify.emptyAsk(解析失败时的兜底正文)。
7. 测试
- 投影:挂起提问暴露解析后截断的问题文本;畸形
arguments → 空正文;匹配的 tool/result 清除、其他 result 保留、turn/end 清除;空闲时 view 含 ask: null;stateVersion 为 2。
- runner:播种(有无挂起提问两种);回归用例「无提问播种后第一个新提问必须触发」;新 id 触发、同 id 不触发、跨解决保留 id;无投影=无提问;
askFor 的子代理/开关/规则/主开关门控。
- decision/notifier/store:
reasonEnabled('ask')、标题键、ask tag、默认值。
行为说明 / 边界
tool/call 在工具执行前提交(已核对 harness 工具调度:appendToolCall 先于 dispatch),所以投影能在提问挂起期间观察到。
- 每个挂起提问通知一次;同一回合第二次提问会再次通知(id 去重)。
- 关键词包含/排除规则匹配问题文本。
backgroundOnly 语义不变:开=仅会话在眼前且页面可见时抑制;关=始终通知。
参考实现
完整可运行的实现(宿主+客户端+设置+测试全绿)在本人 fork 中,并已在 dsh 0.1.0-rc.6 上端到端验证(前台、后台标签页、聚焦其他应用均弹出)。可开 PR。
附注:build.mjs 末尾的声明生成步骤(execFileSync('node_modules/.bin/tsc'))在 Windows 上会失败——改用 process.execPath + node_modules/typescript/bin/tsc 即可。可并入本 PR 或单独提。
动机
插件目前只在回合结束时通知。当 agent 在回合中途调用
ask_user_question暂停等你回答时,不会有任何通知,在别的标签页或别的应用里的用户,直到切回来才会发现有提问在等待。期望行为
ask_user_question挂起时弹出浏览器通知:标题「DSH is asking you / DSH 正在向你提问」,正文为问题原文(从工具调用解析)。requireInteraction、backgroundOnly(开=仅会话在眼前且页面可见时抑制;关=始终通知)。为什么需要宿主侧小改动
提问等待期间 agent 循环阻塞在工具调用内:没有
turn/end、没有 turn reason、也不会触发 running→idle 完成提醒,客户端光靠会话列表无法得知提问挂起。插件已有宿主 session projection——扩展它是最自然的位置。在本地的实现思路
1. 宿主投影折叠
src/projection.tsopenAsk: { turn, callId, body } | null。tool/call且event.data.name === 'ask_user_question'时置位;body从event.data.arguments(模型原始 JSON 字符串,形如{"questions":[{"id","question",…}]})解析第一个非空questions[].question,用既有boundText(maxBodyChars)截断;解析失败为''。tool/result且event.data.message.source.callId === openAsk.callId时清除(其他调用的 result 不影响)。turn/end时清除(中止/打断时提问可能仍挂起)。view()返回{ ...(last ?? EMPTY_PROJECTION), ask: openAsk ? { id: callId, turn, body } : null }。ask: z.object({ id: z.string(), turn: z.number().int().nonnegative(), body: z.string() }).nullable();stateVersion1→2 使投影缓存失效。2. 共享契约
src/contract.tsNotificationReason增加'ask'。NotificationProjectionValue增加ask: { id, turn, body } | null(id=工具调用 id,同一回合多次提问可区分)。NotificationSettings增加notifyAsk: boolean。3. 客户端 runner
src/client/runner.tsaskAdvance(prev: { seeded, id } | undefined, projection):播种式折叠。首次观察只记录基线不通知(页面加载/重连时已挂起的提问不补);播种后,挂起提问的 id 与上次不同即 fresh;提问解决后保留上次 id,同一调用不重发、后续新提问仍会发。用 id 而非回合号 → 同一回合问两次也会各通知一次。askFor(...):origin === 'subagent'或无挂起提问返回 null;关键词规则主体=标题+问题文本;走正常shouldNotify门控;返回 tag 为dsh-notification-ask-<sessionId>的计划(独立槽位,提问通知与完成通知共存不互替)。4. 客户端插件
src/client/index.tsobservedAsk映射;fresh 时:权限检查 → 既有shouldShow门控 →show(t(titleKey('ask')), bodyText(ask.body, t('notify.emptyAsk')), tag, requireInteraction)。connection/reset时与完成通知一样重置两个映射。5. 标题/槽位
src/client/notifier.tstitleKey('ask')→'notify.titleAsk';新增askTag(sessionId)。6. 设置
SettingsSection.tsx/store.ts/locales.tsnotifyAsk)。默认开(提问交互性强且有时效性;维护者也可按其他 opt-in 结果的惯例默认关)。settings.when.ask、notify.titleAsk、notify.emptyAsk(解析失败时的兜底正文)。7. 测试
arguments→ 空正文;匹配的tool/result清除、其他 result 保留、turn/end清除;空闲时 view 含ask: null;stateVersion为 2。askFor的子代理/开关/规则/主开关门控。reasonEnabled('ask')、标题键、ask tag、默认值。行为说明 / 边界
tool/call在工具执行前提交(已核对 harness 工具调度:appendToolCall先于 dispatch),所以投影能在提问挂起期间观察到。backgroundOnly语义不变:开=仅会话在眼前且页面可见时抑制;关=始终通知。参考实现
完整可运行的实现(宿主+客户端+设置+测试全绿)在本人 fork 中,并已在 dsh
0.1.0-rc.6上端到端验证(前台、后台标签页、聚焦其他应用均弹出)。可开 PR。附注:
build.mjs末尾的声明生成步骤(execFileSync('node_modules/.bin/tsc'))在 Windows 上会失败——改用process.execPath+node_modules/typescript/bin/tsc即可。可并入本 PR 或单独提。