diff --git a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml index eadf936a19..2eed4265fb 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md 2026-07-05-reconstructable-requests.md: 153d37a2faf2265134d5ff9e88f0bbfa275328e0 -2026-07-05-reconstructable-requests.zh.md: ec4f9f0fe20315b4ac73be4aa170361b91ffdd29 +2026-07-05-reconstructable-requests.zh.md: 25ce6bfdb56db80c92e8293206484a2de505d662 diff --git a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md index ec4f9f0fe2..25ce6bfdb5 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 每个 LLM(大语言模型)请求都可从会话日志重建 +# Agent Note: 每个 LLM 请求都可从会话日志重建 Status: implemented diff --git a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.i18n.yaml index 3d52da9dd1..2145468f1f 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md 2026-07-08-tool-output-spill-files.md: 7c0ca90452645d251559be25108d12883210d00e -2026-07-08-tool-output-spill-files.zh.md: 6c25b03fe23976b9a2f50edb0c3de3cdd782a4ff +2026-07-08-tool-output-spill-files.zh.md: a1bde1131374cd11215006b822dfc19aea707747 diff --git a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md index 6c25b03fe2..a1bde11313 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md @@ -174,7 +174,7 @@ ctx.tools.register(defineTool({ 默认策略只能看见最终格式化文本。它无法保留已经由提供方限制的内部内容,也无法保留从未成为结果一部分的运行时产物。第一版聚焦最终结果落盘而不是提前落盘,因此可以接受这一限制;由工具负责的提前落盘仍属于后续工作。 -本地后端返回真实路径,使 v1 保持简单并符合已经验证的 agent 工具行为;seam 本身只承诺一个不透明定位符加检索提示,所以远程后端可以返回非文件定位符。 +本地后端返回真实路径,使 v1 保持简单并符合已经验证的 agent(智能体)工具行为;seam 本身只承诺一个不透明定位符加检索提示,所以远程后端可以返回非文件定位符。 本地后端的价值取决于现有 `read`/`grep` 工具能否检查返回的本地路径,即使落盘目录位于会话 cwd 之外。目前这一条件成立,因为文件系统策略会记录观察结果并设置写保护,但不会把读取限制在工作区内。未来的工作区限制策略必须显式允许本地落盘路径,或改用检索提示指向受支持读取器的非文件落盘后端。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-22-pi-ai-transport-truncation-classification.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-22-pi-ai-transport-truncation-classification.i18n.yaml index e1acc6ed4d..4a4e6d71cc 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-22-pi-ai-transport-truncation-classification.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-22-pi-ai-transport-truncation-classification.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-22-pi-ai-transport-truncation-classification.md 2026-07-22-pi-ai-transport-truncation-classification.md: 119200a788c0b0521f385f4cf4e6adf05a0512f9 -2026-07-22-pi-ai-transport-truncation-classification.zh.md: e8bf9efc6c131f6ce9e22c94f29d53ef85329a6f +2026-07-22-pi-ai-transport-truncation-classification.zh.md: 0625d6041ccaeaff9d0d6f659c7b3856ab985ef6 diff --git a/.agents/notes/implemented/bug-fix/2026-07-22-pi-ai-transport-truncation-classification.zh.md b/.agents/notes/implemented/bug-fix/2026-07-22-pi-ai-transport-truncation-classification.zh.md index e8bf9efc6c..0625d6041c 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-22-pi-ai-transport-truncation-classification.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-22-pi-ai-transport-truncation-classification.zh.md @@ -8,7 +8,7 @@ Status: implemented 一次 TUI 运行的模型连接在流式输出中途断开,只浮现出一条 `terminated` 通知,而一个被截断的 Anthropic 响应则浮现出 `Anthropic stream ended before message_stop`。两者都是传输层截断——连接在提供方的终止 SSE(Server-Sent Events)事件之前就已断开——然而 `dsh-llm-pi-ai` 中的 `classifyPiAiError` 对两者都不匹配,最终落入兜底的 `PI_AI_ERROR`。由于 `PI_AI_ERROR` 不在 `llm-retry` 的 `DEFAULT_RETRYABLE_CODES`(`RATE_LIMIT`、`SERVER`、`TIMEOUT`、`TRANSPORT`)中,一次可恢复的断开被当作永久性失败处理,从未被重试。 -细节丢失发生在上游,且在适配器内无法恢复:pi-ai 在推送终止 `error` 事件之前,把捕获到的错误缩减为 `error.message`(`api/anthropic-messages.js`:`errorMessage = error instanceof Error ? error.message : JSON.stringify(error)`),丢弃了原始的 `Error` 及其 `cause` 链。undici 将可用于诊断的 `SocketError` 放在 `cause` 上,却只交给 fetch 包装层一个裸的 `terminated`;pi-ai 只保留了这个词。pi-ai 的 `SimpleStreamOptions` 没有暴露任何 fetch/dispatcher/client 钩子,让我们能在细节被扁平化之前自行捕获 `cause`。 +细节丢失发生在上游,且在适配器内无法恢复:pi-ai 在推送终止 `error` 事件之前,把捕获到的错误缩减为 `error.message`(`api/anthropic-messages.js`:`errorMessage = error instanceof Error ? error.message : JSON.stringify(error)`),丢弃了原始的 `Error` 及其 `cause` 链。undici 将可据以采取行动的 `SocketError` 放在 `cause` 上,却只交给 fetch 包装层一个裸的 `terminated`;pi-ai 只保留了这个词。pi-ai 的 `SimpleStreamOptions` 没有暴露任何 fetch/dispatcher/client 钩子,让我们能在细节被扁平化之前自行捕获 `cause`。 ## 决策 diff --git a/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.i18n.yaml index f5ee31e19c..26b8332442 100644 --- a/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.md 2026-07-19-fresh-agent-ralph-workflow-tool.md: 6fe96587c49ef0316d1618fda2ee26b015b1ce87 -2026-07-19-fresh-agent-ralph-workflow-tool.zh.md: 04b7db3f8e80fe16100836e06a26c5b43ccaeaa4 +2026-07-19-fresh-agent-ralph-workflow-tool.zh.md: 03e5e4a705674b49c14cbb456b06d82fbd38f013 diff --git a/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.zh.md b/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.zh.md index 04b7db3f8e..03e5e4a705 100644 --- a/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.zh.md +++ b/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.zh.md @@ -64,7 +64,7 @@ Ralph 插件的 `subagentProvider` 默认为 `spawn`。每次调用前,它要 - 宽裕的 Round 上限允许大量自治工作,而部署配置仍会限制子 agent 数量,并且每次交接始终受大小约束。 - 提供方路由与可降低的每次运行子 agent 上限成为显式的工作流启动关注点,但不扩展脚本或普通工作流工具表面。 -## 已知限制与推迟工作 +## 已知限制与暂缓事项 - 完成与阻塞状态由工作者自行声明。独立 evaluator、evaluator 驱动的反馈 Round、完成证书或对抗式 verifier 被有意推迟。 - 运行位于前台且只存在于进程内。后台收集、持久化/恢复、调度和重启恢复均不存在。 diff --git a/.agents/notes/implemented/feature/2026-07-19-human-goal-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.i18n.yaml index 5d3fc9a485..9592bc7741 100644 --- a/.agents/notes/implemented/feature/2026-07-19-human-goal-command.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-19-human-goal-command.md 2026-07-19-human-goal-command.md: ce5c37fd28f9432d8c9a8797cac32c632617e317 -2026-07-19-human-goal-command.zh.md: fb7b5f2e5b49eb3ac713dca43a1dab72c08e10dc +2026-07-19-human-goal-command.zh.md: 85b47f610d12d5a70a4978e2041c8b38cd2895b0 diff --git a/.agents/notes/implemented/feature/2026-07-19-human-goal-command.zh.md b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.zh.md index fb7b5f2e5b..85b47f610d 100644 --- a/.agents/notes/implemented/feature/2026-07-19-human-goal-command.zh.md +++ b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.zh.md @@ -63,7 +63,7 @@ TUI 应用包作出相反的产品选择。它默认让 `goals` 使用所有者 - 恢复后的会话等待人类决策;`/goal resume` 是字面命令路径,任何语言的普通提示词则可以授权模型工具路径。 - 无头组合保持单轮行为,除非明确选择加入目标并定义自己的长时间运行结束契约。 -## 已知限制与延期工作 +## 已知限制与暂缓事项 - 可移植命令契约没有模态编辑器或确认交互;在出现通用跨界面交互原语之前,行内编辑与明确清除是有意选择。 - `/goal` 不接受逐命令 Round 上限。部署配置拥有默认值;得到直接人类指示后,已授权模型工具可以编辑上限。 diff --git a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml index d2a71fd70c..e2482a2dc6 100644 --- a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md 2026-07-19-model-facing-goal-tools.md: 18235c484194f5daf10556ebfc13bdc2d672be2e -2026-07-19-model-facing-goal-tools.zh.md: 79e1f14636ecaab4eb28c633b565e6f33f191c74 +2026-07-19-model-facing-goal-tools.zh.md: c8bc49ef907e4055960074c6a7d1e852ba77c9ad diff --git a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md index 79e1f14636..c8bc49ef90 100644 --- a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md +++ b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md @@ -8,7 +8,7 @@ Status: implemented 持久目标领域有意把生命周期动词提供给插件,而不直接提供给模型。模型仍然需要一个小型控制面,用于发现当前目标、根据人类意图创建目标并改变其生命周期。仅靠提示词指导无法确定是谁授权了一次变更:子智能体、注入的插件消息、陈旧的模型轮次或恢复后的会话都可能产生相同的工具参数。 -该表面还需要保持持久状态与实时执行权限之间的分离。恢复或 fork(派生)后的会话可以回放活跃目标,但初始处于未激活状态;后续人类提出“继续”之类的请求时,模型应能重新激活目标,而无需用户使用字面命令。相反,已接纳的自主目标回合必须能够报告完成或持续阻塞,却不能因此获得编辑、暂停、恢复或替换人类目标的权限。 +该表面还需要保持持久状态与实时执行权限之间的分离。恢复或 fork(派生)后的会话可以回放活跃目标,但初始处于未激活状态;后续人类提出“继续”之类的请求时,模型应能重新激活目标,而无需用户使用字面命令。相反,已接纳的自主 Goal Round 必须能够报告完成或持续阻塞,却不能因此获得编辑、暂停、恢复或替换人类目标的权限。 ## 决策 @@ -16,13 +16,13 @@ Status: implemented ### 工具与模型契约 -`get_goal()` 返回当前目标或 `null`。非空结果包含用于比较并交换的 id 与修订号、目标描述、持久阶段、已接纳和最大目标回合数、可能存在的阻塞原因,以及进程本地激活态观察。`create_goal(objective, max_goal_rounds?)` 创建一个长时间运行的同会话目标。`update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` 支持 `edit`、`pause`、`resume`、`complete` 和 `blocked`;替换字段仅对 `edit` 有效,非空的 `blocked_reason` 仅在 `blocked` 时必填,并以稳定代码 `model-reported` 持久化。执行器把值恰好为空字符串的可选字段和值为 0 的 `max_goal_rounds` 视为严格 schema 占位值:这些值等同于省略;编辑时仍必须提供至少一个有实际意义的替换字段;所有非占位值仍受对应操作的限制。 +`get_goal()` 返回当前目标或 `null`。非空结果包含用于比较并交换的 id 与修订号、目标描述、持久阶段、已接纳和最大 Goal Round 数、可能存在的阻塞原因,以及进程本地激活态观察。`create_goal(objective, max_goal_rounds?)` 创建一个长时间运行的同会话目标。`update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` 支持 `edit`、`pause`、`resume`、`complete` 和 `blocked`;替换字段仅对 `edit` 有效,非空的 `blocked_reason` 仅在 `blocked` 时必填,并以稳定代码 `model-reported` 持久化。执行器把值恰好为空字符串的可选字段和值为 0 的 `max_goal_rounds` 视为严格 schema 占位值:这些值等同于省略;编辑时仍必须提供至少一个有实际意义的替换字段;所有非占位值仍受对应操作的限制。 提示词告诉模型:它可以从任何措辞或语言的直接人类请求中推断目标意图,但不应把常规单轮工作转换为目标。更新前必须读取当前目标,并复制准确的 id 和修订号。对于恢复或派生后处于活跃但未激活状态的目标,人类在语义上要求继续即可成为执行 `resume` 的依据。只有目标已经实现时才能标记完成,困难或不确定性本身不构成阻塞;阻塞报告必须说明具体条件。 三个工具都采用独占执行,使模型排序的批次可以观察此前变更及其新修订号。结果为紧凑 JSON。UI 展示是参数的纯函数,使用通用读取或变更卡片;变更卡片选择输入时,先取有实际意义的操作值,再取目标 id,因此允许的占位值不会使卡片输入留空。激活态仅作为实时观察返回,绝不会写入回放状态。 -自主目标回合成功报告完成或阻塞后,其工具结果会附带一条收尾指令,模型仍会在轮次经由常规无工具调用停止路径结束前向用户发言;原先在结果处终结轮次的做法已被[Goal Round 收尾决策](../bug-fix/2026-08-02-goal-round-wrapup-message.md)取代。直接人类发起的变更不会收到指令:agent 可以确认该变更,并且并发的人类 steering(中途引导)仍可参与普通的停止检查。 +自主 Goal Round 成功报告完成或阻塞后,其工具结果会附带一条收尾指令,模型仍会在轮次经由常规无工具调用停止路径结束前向用户发言;原先在结果处终结轮次的做法已被[Goal Round 收尾决策](../bug-fix/2026-08-02-goal-round-wrapup-message.md)取代。直接人类发起的变更不会收到指令:agent 可以确认该变更,并且并发的人类 steering(中途引导)仍可参与普通的停止检查。 ### 执行权限 @@ -30,15 +30,15 @@ Status: implemented 创建、编辑、暂停与恢复还要求运行时根 agent 的当前轮次已经接纳一条用户消息或用户 steering 事件。根所有权来自实时 agent 图,而非持久的 fork 祖先关系:恢复后的派生会话可以接收新的直接人类权限,实时子级则仍是 subagent,不能改变这些状态。用户来源是宿主的证明:每个 `Agent.send()` 或 `steer()` 输入都必须显式提供来源,因此宿主把直接人类内容标为 `{ kind: 'user' }`,非人类生产者则标注自己的来源信息。运行时证明来源,而不判断人类措辞在语义上是否足以创建或恢复目标;该解释仍由模型完成。 -完成与阻塞既接受直接人类权限,也接受准确的当前目标回合。目标回合权限要求存在一条来源为目标的 `user/message`,其中目标 id、修订号和回合都与折叠后的当前目标相等。它只授予这两种终止报告权限。直接人类权限可以立即停止目标。 +完成与阻塞既接受直接人类权限,也接受准确的当前 Goal Round。Goal Round 权限要求存在一条来源为目标的 `user/message`,其中目标 id、修订号和 Round 都与折叠后的当前目标相等。它只授予这两种终止报告权限。直接人类权限可以立即停止目标。 ### 阻塞阈值 -`blockedAfterConsecutiveRounds` 是经过校验的正安全整数配置,默认值为 `3`。自主目标回合调用 `blocked` 时,插件会机械地要求至少已经接纳该数量的回合并提供非空说明;配置值也会出现在模型指导中。运行时无法判断这些回合是否遇到了语义上相同的阻塞条件,因此语义等价性仍由模型判断。该计数特意与目标的宽裕继续执行上限分离。 +`blockedAfterConsecutiveRounds` 是经过校验的正安全整数配置,默认值为 `3`。自主 Goal Round 调用 `blocked` 时,插件会机械地要求至少已经接纳该数量的 Round 并提供非空说明;配置值也会出现在模型指导中。运行时无法判断这些 Round 是否遇到了语义上相同的阻塞条件,因此语义等价性仍由模型判断。该计数特意与目标的宽裕继续执行上限分离。 ## 测试 -单元测试固定注册与释放、独占调度、生成的提示词策略、可安全处理占位值的通用展示、非英语轮次中的直接人类创建、精确/陈旧/非运行中智能体与驱动检查、实时子智能体拒绝、恢复后派生根的权限、steering、发起者不匹配、读取/创建/部分字段编辑/暂停/恢复行为(包括严格 schema 占位值)、条件式阻塞说明、会话启动边沿后的重新激活、权限先于条件参数失败、准确目标回合的完成、仅自主回合触发终止、可配置阻塞阈值,以及人类立即阻塞。无密钥回放快照把目标领域和工具挂载到真实的 headless 单次运行应用中,通过随附循环与持久化栈驱动一次携带严格 schema 占位值的 `update_goal` 探测,以及对 `create_goal` 和 `get_goal` 的调用,固定 stream-json 转录,并检查外部持久化的目标变更。这里有意不把 echo-agent 测试夹具当作应用 UX 的替代品。 +单元测试固定注册与释放、独占调度、生成的提示词策略、可安全处理占位值的通用展示、非英语轮次中的直接人类创建、精确/陈旧/非运行中智能体与驱动检查、实时子智能体拒绝、恢复后派生根的权限、steering、发起者不匹配、读取/创建/部分字段编辑/暂停/恢复行为(包括严格 schema 占位值)、条件式阻塞说明、会话启动边沿后的重新激活、权限先于条件参数失败、准确 Goal Round 的完成、仅自主 Round 触发终止、可配置阻塞阈值,以及人类立即阻塞。无密钥回放快照把目标领域和工具挂载到真实的 headless 单次运行应用中,通过随附循环与持久化栈驱动一次携带严格 schema 占位值的 `update_goal` 探测,以及对 `create_goal` 和 `get_goal` 的调用,固定 stream-json 转录,并检查外部持久化的目标变更。这里有意不把 echo-agent 测试夹具当作应用 UX 的替代品。 ## 考虑过的替代方案 @@ -55,14 +55,14 @@ Status: implemented - 模型获得稳定而紧凑的生命周期表面,无需直接访问目标服务。 - 改变状态的调用同时受到实时运行时来源与持久比较并交换引用的约束。 - 人类可以通过普通自然语言请求创建和重新激活目标,而恢复后的会话在收到此类输入前保持静止。 -- 目标回合可以完成或报告重复阻塞,但不能自行扩大任务权限。 +- Goal Round 可以完成或报告重复阻塞,但不能自行扩大任务权限。 - 部署策略选择阻塞下限;同一个解析后的值同时控制执行与提示词指导。 - 系统可兼容采用严格 schema 的提供方所填入的占位值,同时不会放行有实际意义的跨操作更新。 -## 已知限制与延期工作 +## 已知限制与暂缓事项 - 是否属于重大目标、是否要求继续、目标是否完成以及阻塞条件是否相同,仍由模型进行语义分类。独立评估器或完成证书予以延期。 -- 这些工具会改变目标状态,但不调度目标回合、不分类异常驱动停止,也不取消活跃轮次;这些行为由同会话驱动器负责。 -- 除非另行挂载的继续执行驱动器接纳了目标来源的用户轮次,否则目标回合权限路径处于休眠状态;本工具包本身不会制造这种权限。 +- 这些工具会改变目标状态,但不调度 Goal Round、不分类异常驱动停止,也不取消活跃轮次;这些行为由同会话驱动器负责。 +- 除非另行挂载的继续执行驱动器接纳了目标来源的用户轮次,否则 Goal Round 权限路径处于休眠状态;本工具包本身不会制造这种权限。 - 面向人类的斜杠命令发现与渲染由独立的 [`dsh-command-goal`](../../../../packages/goal/command-goal/README.md) 插件负责。 - 若部署没有同时设定两个注册项的作用域,某个作用域可能隐藏工具注册,却保留独立注册的提示词段。 diff --git a/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.i18n.yaml index c61ad79930..15bdcd9a3d 100644 --- a/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md 2026-07-19-persisted-same-session-goal-domain.md: 00600b2c49646ebd3b692154ef945eb79a33b032 -2026-07-19-persisted-same-session-goal-domain.zh.md: 325dfb2c301024738da8a39774af02c2a13caddc +2026-07-19-persisted-same-session-goal-domain.zh.md: 00a75eebbad40b4551711c47fbf654684092a5e6 diff --git a/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.zh.md b/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.zh.md index 325dfb2c30..00a75eebba 100644 --- a/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.zh.md +++ b/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -位于 `packages/goal/goal/` 的 `@deepseek-ai/dsh-goal` 通过 `ctx.goals` 管理一个当前的同会话目标。目标包含带品牌的 id、目标描述、持久化阶段、比较并交换修订号和 `maxGoalRounds`。`defaultMaxGoalRounds` 是经过校验的部署配置,默认值为 `256`;`create()` 在变更前于内部将其解析为完整值,而不会把解析过程暴露为额外的服务方法。 +位于 `packages/goal/goal/` 的 `@deepseek-ai/dsh-goal` 通过 `ctx.goals` 管理一个当前的同会话目标。目标包含带品牌的 id、目标描述、持久化阶段、比较并交换修订号和 `maxGoalRounds`。`defaultMaxGoalRounds` 是经过校验的部署配置,默认值为 `256`;`create()` 在变更前于内部将其解析为完整值,而不会把解析过程暴露为额外的服务动词。 持久阶段包括 `active`、`paused`、`blocked` 和 `complete`。阻塞快照包含由策略定义的全小写 kebab-case 代码和规范化自由文本消息,因此用量限制、回合上限、执行失败和等待人工输入可以共享一个生命周期状态而不丢失原因。独立的实时激活态为 `armed` 或 `disarmed`。创建与显式恢复会激活目标;暂停、完成、阻塞和清除都会解除激活。编辑保留激活态及阻塞原因;恢复和完成会清除该原因。持久快照绝不包含激活态。 @@ -20,7 +20,7 @@ Status: implemented 每次非清除变更都通过 `Agent.inject()` 追加一条模型可见的 `context/message`,其中包含带版本的完整快照;会话会将其内容原样投射给模型。清除操作追加带修订号的墓碑。上下文来源为 `{ kind: 'goal', goalId, revision, round: 0 }`;元数据必须与渲染后的 `...` 内容完全一致。这个描述性分隔符沿用了仓库已有的 `` 约定,也符合 [Anthropic 关于用一致且描述明确的 XML 标签组织混合提示词内容的公开指南](https://platform.claude.com/docs/en/build-with-claude/prompt-engineering/claude-prompting-best-practices#structure-prompts-with-xml-tags)。这是公开的模型体验先例,并非对任何提供方专有训练语料的推断。会话日志是唯一的持久真源,因此持久化和 fork 会继承目标记录,而无需另设数据库或头字段。 -回放折叠会校验 JSON 形状、来源归属、渲染内容、从未出现过的 id、修订连续性、生命周期转换、计数器以及单个目标内单调递增的时间戳。目标回合是当前活跃修订上带正数且连续编号的 `user/message` 来源,且不能超过 `maxGoalRounds`;普通会话轮次不会影响该计数器。当前格式的畸形记录会使回放失败,而不会被忽略或修复。 +回放折叠会校验 JSON 形状、来源归属、渲染内容、从未出现过的 id、修订连续性、生命周期转换、计数器以及单个目标内单调递增的时间戳。Goal Round 是当前活跃修订上带正数且连续编号的 `user/message` 来源,且不能超过 `maxGoalRounds`;普通会话轮次不会影响该计数器。当前格式的畸形记录会使回放失败,而不会被忽略或修复。 当 `Agent.inject()` 在活跃工具批次中延迟变更时,服务会在进程内存中以覆盖层记录已接受的载荷,使后续变更可以使用新的修订号。FIFO 追加可见后,协调过程只移除完全匹配的载荷;重入的追加观察器对每次变更只投影一次。增量回放会在每个有效事件后推进游标,并停留在首个损坏事件处,因此后续读取会报告同一个持久故障。重启后仍以持久日志为准。 @@ -36,14 +36,14 @@ Status: implemented ## 测试 -单元测试固定创建默认值、精确实时 agent 校验、比较并交换拒绝、所有生命周期转换、阻塞原因校验与保留、恢复时的上限执行、清除与替换、种子回放和 `SessionStore.fork()` 继承、会话启动与生命周期所有者解除激活、活跃目标重新激活、FIFO 延迟变更协调、重入追加观察、注入拒绝回滚、损坏事件的稳定回放、服务与监听器销毁、监听器隔离、挂钟回拨校正、严格记录解码、生命周期连续性、来源与内容一致性,以及连续目标回合归属。无密钥 Loader/stdio 进程测试通过测试专用 `cordis.yml` 挂载服务与生命周期消费者,再从外部读取持久 JSONL,以验证模型可见快照以及不存在未经请求的目标回合。包源码受仓库逐文件 100% 覆盖率门禁约束。 +单元测试固定创建默认值、精确实时 agent 校验、比较并交换拒绝、所有生命周期转换、阻塞原因校验与保留、恢复时的上限执行、清除与替换、种子回放和 `SessionStore.fork()` 继承、会话启动与生命周期所有者解除激活、活跃目标重新激活、FIFO 延迟变更协调、重入追加观察、注入拒绝回滚、损坏事件的稳定回放、服务与监听器销毁、监听器隔离、挂钟回拨校正、严格记录解码、生命周期连续性、来源与内容一致性,以及连续 Goal Round 归属。无密钥 Loader/stdio 进程测试通过测试专用 `cordis.yml` 挂载服务与生命周期消费者,再从外部读取持久 JSONL,以验证模型可见快照以及不存在未经请求的 Goal Round。包源码受仓库逐文件 100% 覆盖率门禁约束。 ## 考虑过的替代方案 - **把目标存入独立数据库或会话头**——不予采纳,因为会话日志已经提供顺序、持久化、fork 前缀与可重建性;第二份存储会引入原子性和谱系问题。 - **使用模型不可见的纯日志事件**——不予采纳,因为会改变后续模型行为的持久状态必须满足仓库日志不变量,保持模型可见且可重建。 - **持久化激活态并自动重启**——不予采纳,因为打开或恢复会话时必须等待人类输入;持久阶段记录状态,而不是再次消耗资源的授权。 -- **把所有会话轮次都计为目标回合**——不予采纳,因为同一会话可以包含人类澄清、检查和无关工作;只有归属于目标的继续执行轮次才消耗该预算。 +- **把所有会话轮次都计为 Goal Round**——不予采纳,因为同一会话可以包含人类澄清、检查和无关工作;只有归属于目标的继续执行轮次才消耗该预算。 - **向 `dsh-agent-loop` 添加目标状态或通用循环抽象**——不予采纳,因为状态与继续执行策略可以通过现有插件、`Agent` 动词和事件组合,而无需赋予默认循环实现特权。 ## 后果 @@ -56,7 +56,7 @@ Status: implemented ## 已知限制与暂缓事项 -- 本领域记录状态,但不调度目标回合、不取消活跃轮次,也不分类异常停止。 +- 本领域记录状态,但不调度 Goal Round、不取消活跃轮次,也不分类异常停止。 - 记录 `complete` 或 `blocked` 的参与者具有最终权威;独立评估器或完成证书延期到策略消费者中实现。 - 每个会话只有一个当前目标;不存在并行目标图和跨会话目标存储。 - 插件共享同一个受信任的进程边界。直接写入会话的插件可以伪造目标记录;严格回放会检测不一致并在违规记录处使目标访问失败,但不会隔离插件或修复日志。 diff --git a/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.i18n.yaml index 4b917249da..f722287fbb 100644 --- a/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.md 2026-07-19-same-session-goal-round-driver.md: 0e6be9fe3109336d47867ab52c585dc267309fb4 -2026-07-19-same-session-goal-round-driver.zh.md: 7533fe968a9dd2b7a8d6ad183d1b6bf2dcda6715 +2026-07-19-same-session-goal-round-driver.zh.md: 9f535dd3b0bf49fff5ff962b04b89330d0b27a55 diff --git a/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.zh.md b/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.zh.md index 7533fe968a..9f535dd3b0 100644 --- a/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.zh.md +++ b/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 同会话目标回合驱动器 +# Agent Note: 同会话 Goal Round 驱动器 Status: implemented @@ -14,7 +14,7 @@ Status: implemented 位于 `packages/goal/goal-session/` 的 `@deepseek-ai/dsh-goal-session` 是构建在 `ctx.goals`、公共 `Agent` 接口和持久会话事件之上的策略插件。它不导入具体 agent-loop 实现。对于每个对应的同一个实时 `Agent` 对象,它维护进程内调度状态,并且最多保留一个自动回合预留。 -层次关系为目标(Goal)→ 目标回合(Goal Round)→ 轮次(Turn)→ 步骤(Step)。目标回合是外层继续执行策略的一次迭代;它会成为一个归属于目标的会话轮次,而该轮次可以包含任意数量的普通模型或工具步骤。同一会话中的人类轮次不是目标回合,也绝不会增加 `roundsStarted`。 +层次关系为目标(Goal)→ Goal Round → 轮次(Turn)→ 步骤(Step)。Goal Round 是外层继续执行策略的一次迭代;它会成为一个归属于目标的会话轮次,而该轮次可以包含任意数量的普通模型或工具步骤。同一会话中的人类轮次不是 Goal Round,也绝不会增加 `roundsStarted`。 该插件没有配置项。`maxGoalRounds` 由 `dsh-goal` 解析并持久化;“相同阻塞条件”的门槛由 `dsh-tool-goal` 解析并写入提示词。若驱动器重复声明这些可调值,一个策略就会出现多个所有者。 @@ -24,11 +24,11 @@ Status: implemented `agent/prompt-submit` waterfall(瀑布式事件)是接纳栅栏。正数目标来源只有在完全匹配驱动器待处理的身份和内容、实时目标仍具有相同 id 与修订号、激活态仍为 armed,并且该回合仍是下一个编号时才会获准。插件在委托下游监听器前检查一次,在下游返回后再检查一次。第二次检查防止异步钩子编辑或暂停目标后,旧提示词仍被接纳。 -只有最终产生的 `user/message` 才是已接纳目标回合,并推进目标折叠。陈旧预留会在轮次打开前被丢弃;驱动器会把它标记为陈旧,不消耗回合数。若下游策略拒绝并非由陈旧状态导致,目标会进入 blocked,而不会绕过该策略自动重试。 +只有最终产生的 `user/message` 才是已接纳 Goal Round,并推进目标折叠。陈旧预留会在轮次打开前被丢弃;驱动器会把它标记为陈旧,不消耗 Round 数。若下游策略拒绝并非由陈旧状态导致,目标会进入 blocked,而不会绕过该策略自动重试。 ### 人类工作与修订竞争 -`agent/queued` 会区分驱动器自己的完整已接受记录与其他所有提示词。预留之前已经排队的普通工作会阻止调度;自动提示词待处理时进入的普通工作会使该预留过期,因此混合批次只接纳人类提示词而拒绝自动提示词。目标回合已经接纳后到达的普通工作会保留在队列中,成为下一个独立轮次;只有 agent 再次空闲后才重新考虑继续执行。 +`agent/queued` 会区分驱动器自己的完整已接受记录与其他所有提示词。预留之前已经排队的普通工作会阻止调度;自动提示词待处理时进入的普通工作会使该预留过期,因此混合批次只接纳人类提示词而拒绝自动提示词。Goal Round 已经接纳后到达的普通工作会保留在队列中,成为下一个独立轮次;只有 agent 再次空闲后才重新考虑继续执行。 目标在回合内发生变更时会推进持久修订号。旧修订的结算不得覆盖该变更。驱动器会丢弃旧尝试的结果、读取新投影,并且只在新修订仍为 active 与 armed 时继续。因此,模型记录的完成、暂停、阻塞和编辑相对于物理轮次稍后的关闭原因具有最终权威。 @@ -39,7 +39,7 @@ Status: implemented | 轮次结果 | 动作 | |---|---| | 持久化的 `completed` | 目标仍 active/armed 且未到上限时继续 | -| 取消已预留/接纳的目标回合,或该回合产生 `aborted` 结果 | 暂停并解除激活 | +| 取消已预留/接纳的 Goal Round,或该 Round 产生 `aborted` 结果 | 暂停并解除激活 | | 代码为 `RATE_LIMIT` 或 `QUOTA` 的 `error` | 以 `usage-limited` 代码阻塞 | | 其他 `error` | 以 `turn-error` 代码阻塞 | | `max-tokens` | 以 `max-tokens` 代码阻塞 | @@ -76,7 +76,7 @@ Status: implemented ## 考虑过的替代方案 - **在 `dsh-agent-loop` 内添加目标循环**——不予采纳,因为公共队列、提示词、会话、取消和状态 seam 已经足够,具体循环分支还会赋予某种策略特权。 -- **使用 `agent/turn-continuation` 把每个回合变成另一个步骤**——不予采纳,因为目标回合是外层策略迭代,必须拥有自己的持久用户提示词、轮次边界、回合计数和失败结算。 +- **使用 `agent/turn-continuation` 把每个 Round 变成另一个步骤**——不予采纳,因为 Goal Round 是外层策略迭代,必须拥有自己的持久用户提示词、轮次边界、Round 计数和失败结算。 - **持久化待处理预留**——不予采纳,因为崩溃无法证明进程内队列已经达到接纳点;只有持久 `user/message` 才消耗回合。 - **自动重试提供方或持久化错误**——不予采纳,因为重试会消耗资源,需要显式授权;停止阶段加之后的人类恢复更简单,也可观察。 - **每回合 fork 对话历史或生成新 agent**——本包不采用,因为此目标明确属于同会话工作。新 agent 的 Ralph 执行仍是基于 subagent 与 workflow 原语的独立工作流插件。 diff --git a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.i18n.yaml index 1faf10a4c8..dade9b5d90 100644 --- a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.md -2026-07-27-native-workspace-directory-picker.md: 98f9dc9bed5358e816d4324462d5ea7657f9007f -2026-07-27-native-workspace-directory-picker.zh.md: ca765778fae734fd47a05652aea7021328ed4ab6 +2026-07-27-native-workspace-directory-picker.md: a36f7b239a9115fe5eb33472ec5084818a66e9f2 +2026-07-27-native-workspace-directory-picker.zh.md: bb3e2fc6f7c77c8ace97e53435297326f937e4e8 diff --git a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.md b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.md index 98f9dc9bed..a36f7b239a 100644 --- a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.md +++ b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.md @@ -27,10 +27,10 @@ The workspace manager must upsert the returned workspace before the selection ca The native dialog RPC is accepted only from a loopback socket with same-origin browser metadata. The RPC does not use the default 30-second request timeout because a system dialog may remain open indefinitely; caller and connection aborts still propagate to the platform process. -Platform adapters invoke native tools without a shell: +Platform adapters open the dialog without a shell — spawned native tools on POSIX, an in-process COM conversation on Windows: - macOS: `osascript` and the system folder chooser. -- Windows: PowerShell in STA mode and `FolderBrowserDialog`. +- Windows: the koffi `IFileOpenDialog` child process with the best thread DPI awareness the host accepts (per-monitor-v2 when available; PMv2-less hosts cascade to per-monitor or system-aware) ([in-process dialog note](2026-08-02-win32-in-process-folder-dialog.md)); the tier has no fallback — failures surface as-is ([PowerShell chain removal](../simplification/2026-08-04-drop-windows-powershell-picker-fallback.md)). - Linux: `zenity`, with `kdialog` as a fallback when Zenity is unavailable. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.zh.md b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.zh.md index ca765778fa..bb3e2fc6f7 100644 --- a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.zh.md @@ -27,10 +27,10 @@ Status: implemented 只有来自回环套接字、且携带同源浏览器元数据的请求才能调用原生对话框 RPC。该 RPC 不使用默认的 30 秒请求超时,因为系统对话框可能无限期保持打开;调用方中止或连接中止仍会传递至平台进程。 -平台适配器不经 shell,直接调用原生工具: +平台适配器不经 shell 打开对话框——POSIX 上 spawn 原生工具,Windows 上是子进程 COM 会话: - macOS:`osascript` 和系统文件夹选择器。 -- Windows:采用 STA 模式的 PowerShell 和 `FolderBrowserDialog`。 +- Windows:koffi `IFileOpenDialog` 子进程,使用宿主接受的最佳线程 DPI 感知(可用时为 per-monitor-v2;不支持 PMv2 的主机级联到 per-monitor 或 system-aware)(见[进程内对话框 Note](2026-08-02-win32-in-process-folder-dialog.md));该层无回退——失败原样上报(见[PowerShell 链删除](../simplification/2026-08-04-drop-windows-powershell-picker-fallback.md))。 - Linux:使用 `zenity`;Zenity 不可用时回退到 `kdialog`。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.i18n.yaml b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.i18n.yaml new file mode 100644 index 0000000000..d4ec255235 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md +2026-08-01-pwsh-tool-and-executor.md: 7206f8ffe6640f8499f8453c40ab5846b23112c6 +2026-08-01-pwsh-tool-and-executor.zh.md: 5a48adb79fed209d2d2ecb9514fd51538491f04c diff --git a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md new file mode 100644 index 0000000000..7206f8ffe6 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md @@ -0,0 +1,35 @@ +# Agent Note: PowerShell executor and pwsh tool + +Status: implemented + +English | [中文](2026-08-01-pwsh-tool-and-executor.zh.md) + +## Problem + +The harness spoke one shell dialect on every platform: `bash`. Windows hosts could run it only through WSL or Git-Bash shims, and the shipped `dsh-bash-local` executor is POSIX-only (`bash` hardcoded, process-group semantics POSIX). The Windows roadmap — defaulting hosts to `pwsh`, later pwsh TUI/GUI rendering — had no execution foundation: there was no PowerShell implementation of the bash executor seam and no model-facing tool that taught the PowerShell dialect. The bash tool itself is also far larger than a Windows-first profile needs: background tasks, sandbox escalation, and the persistent-PTY twin are all bash-shaped surface that a minimal `pwsh` tool should not carry. + +## Decision + +Two new packages under `packages/bash/`: + +- **`@deepseek-ai/dsh-pwsh-local`** — a local implementation of the `ctx.bash` executor seam over `ctx.subprocess`, mirroring `dsh-bash-local` call-for-call: `resolve()` defaults and caps from config, `run()` fuses the config-clamped timeout with the caller's signal through one deadline, `start()` returns a consuming background handle whose processes belong to the subprocess service. The command string rides as ONE argv element to `pwsh -NoLogo -NoProfile -NonInteractive -Command`, so PowerShell parses it and no shell-quoting layer exists. Executable resolution (`resolvePwshPath`) is a pure function of `(configured, env, platform)`: explicit config first, then Windows probes PowerShell 7's install, PATH entries (quotes stripped), and Windows PowerShell 5.1, else a bare `pwsh` via PATH. +- **`@deepseek-ai/dsh-tool-pwsh`** — the model-facing tool over `ctx.bash`, PowerShell-dialect by contract, mirroring `dsh-tool-bash` call-for-call minus the sandbox surface: foreground and `run_in_background` execution through the generic task runtime, managed `DSH_*` environment through the shared [`dsh-bash-env`](../feature/2026-08-02-pwsh-tool-bash-parity.md) registry, and the bash marker/truncation rendering story (a clean exit produces no marker). The parity decision supersedes this note's minimal-profile tool description. + +Windows vitest coverage is deliberately NOT part of this change: the repo's Windows CI lane owns build/static gates, and unit coverage runs on Linux, where both packages' suites run against a real `pwsh` (preinstalled on the GitHub-hosted runners) or self-skip when absent. The vitest `windowsUnsupportedPackages` exclusion narrows from `packages/bash/*` to the bash-requiring packages so the pwsh suites can also run natively on Windows dev machines. + +The roadmap beyond this decision — defaulting Windows hosts to `pwsh` (bash off), and pwsh TUI/GUI rendering — is recorded separately as [a proposal](../../proposed/feature/2026-08-01-windows-pwsh-default.md). + +## Alternatives considered + +**Extend `dsh-bash-local` with a pwsh mode.** Rejected: the executor's identity is the shell it spawns; a second dialect inside one package doubles its config surface (`shell` switches) and its test matrix, and the two dialects' quirks (signal facts on Windows, quoting domains) belong to their own packages' documentation. + +**Extend `dsh-tool-bash` with a dialect parameter.** Rejected: the bash tool's background/sandbox surface is bash-shaped; a `pwsh` mode would either hide it (conditional schema churn) or inherit it (surface the minimal profile explicitly rejects). The minimal twin keeps the model contract honest. + +**Wire the pwsh tool into the shipped CLI compositions now.** Rejected: mounting `tool-pwsh` + `pwsh-local` in `base.cordis.yml` would change the shipped roster before the Windows-default decision lands; this change ships the capability and its wiring points (`apps/cli` dependencies, tsconfig projects) without switching any default. + +## Consequences + +- The bash executor seam gains a second, Windows-native implementation with an identical request/spec contract, so model-facing consumers beyond `tool-pwsh` (hooks bridges, in-process plugins) can run PowerShell without dialect shims. +- `tool-pwsh` is the model-visible Windows-first shell tool: behaviorally interchangeable with the bash tool for foreground and background work (minus sandbox), with prompt guidance that states the marker contract precisely. +- Windows semantics differ where the platform differs: forced termination reports exit 1 with no signal (so `signal`/`killed` status facts are POSIX-only), and PowerShell writes CRLF, which tests normalize. +- The CLI gains two workspace dependencies and two tsconfig projects without mounting either plugin — the composition decision stays with the Windows-default proposal. diff --git a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.zh.md b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.zh.md new file mode 100644 index 0000000000..5a48adb79f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.zh.md @@ -0,0 +1,35 @@ +# Agent Note: PowerShell 执行器与 pwsh 工具 + +Status: implemented + +[English](2026-08-01-pwsh-tool-and-executor.md) | 中文 + +## 问题 + +harness 在每个平台只说一种 shell 方言:`bash`。Windows 主机只能通过 WSL 或 Git-Bash 垫片运行它,而交付的 `dsh-bash-local` 执行器仅限 POSIX(硬编码 `bash`,进程组语义是 POSIX 的)。Windows 路线图——让主机默认 `pwsh`,之后再做 pwsh TUI/GUI 渲染——没有执行基础:既没有 bash 执行器 seam 的 PowerShell 实现,也没有教模型 PowerShell 方言的面向模型工具。bash 工具本身也远大于 Windows 优先画像所需:后台任务、沙箱升级与持久 PTY 孪生都是 bash 形状的表面,最小化的 `pwsh` 工具不该背负。 + +## 决策 + +在 `packages/bash/` 下新增两个包: + +- **`@deepseek-ai/dsh-pwsh-local`** —— `ctx.bash` 执行器 seam 的本地实现,基于 `ctx.subprocess`,逐调用镜像 `dsh-bash-local`:`resolve()` 从配置默认化并设上限,`run()` 通过一个 deadline 融合配置夹取的超时与调用方信号,`start()` 返回消费式后台句柄,其进程归属于 subprocess 服务。命令字符串作为 ONE argv 元素传给 `pwsh -NoLogo -NoProfile -NonInteractive -Command`,由 PowerShell 解析,不存在 shell 引号层。可执行文件解析(`resolvePwshPath`)是 `(configured, env, platform)` 的纯函数:先显式配置,再在 Windows 上探测 PowerShell 7 安装位置、PATH 条目(剥离引号)与 Windows PowerShell 5.1,否则经 PATH 解析裸 `pwsh`。 +- **`@deepseek-ai/dsh-tool-pwsh`** —— 基于 `ctx.bash` 的面向模型工具,契约是 PowerShell 方言,逐调用镜像 `dsh-tool-bash`、减去 sandbox 面:经通用任务运行时执行前台与 `run_in_background`,经共享 [`dsh-bash-env`](../feature/2026-08-02-pwsh-tool-bash-parity.md) 注册表管理 `DSH_*` 环境,以及 bash 的 marker/截断渲染故事(干净退出不产生 marker)。parity 决策取代了本 note 的最小画像工具描述。 + +Windows vitest 覆盖率刻意不属本次改动:仓库的 Windows CI 通道负责构建/静态门禁,单元覆盖在 Linux 上运行,两个包的套件在那里以真实 `pwsh` 运行(GitHub 托管 runner 预装)或缺失时自行跳过。vitest 的 `windowsUnsupportedPackages` 排除从 `packages/bash/*` 收窄为真正需要 bash 的包,使 pwsh 套件也能在 Windows 开发机上原生运行。 + +本决策之后的路线图——让 Windows 主机默认 `pwsh`(关闭 bash)与 pwsh TUI/GUI 渲染——另行记录为[提案](../../proposed/feature/2026-08-01-windows-pwsh-default.md)。 + +## 备选方案 + +**给 `dsh-bash-local` 增加 pwsh 模式。** 否决:执行器的身份就是它 spawn 的 shell;在一个包内塞第二种方言会翻倍配置面(`shell` 开关)与测试矩阵,且两种方言的怪癖(Windows 上的信号实情、引号域)应各自归入自己包的文档。 + +**给 `dsh-tool-bash` 增加方言参数。** 否决:bash 工具的后台/沙箱表面是 bash 形状的;`pwsh` 模式要么隐藏它(条件 schema 翻动),要么继承它(把最小画像明确拒绝的表面带进来)。最小孪生让模型契约保持诚实。 + +**现在就接入交付的 CLI 组合。** 否决:在 Windows 默认决策落地前把 `tool-pwsh` + `pwsh-local` 挂进 `base.cordis.yml` 会改变交付清单;本改动交付能力与接线点(`apps/cli` 依赖、tsconfig 工程),不切换任何默认。 + +## 后果 + +- bash 执行器 seam 有了第二个、Windows 原生的实现,请求/规范契约一致,因此 `tool-pwsh` 之外的面向模型消费方(hooks 桥、进程内插件)无需方言垫片即可运行 PowerShell。 +- `tool-pwsh` 是模型可见的 Windows 优先 shell 工具:在前台与后台工作(减 sandbox)上与 bash 工具行为可互换,提示词指导精确陈述 marker 契约。 +- Windows 语义在平台差异处不同:强制终止报告退出码 1 且无信号(因此 `signal`/`killed` 状态实情仅限 POSIX),PowerShell 输出 CRLF,测试做归一化。 +- CLI 增加两个 workspace 依赖与两个 tsconfig 工程,但不挂载任一插件——组合决策留给 Windows 默认提案。 diff --git a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml new file mode 100644 index 0000000000..e17e4685e3 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md +2026-08-02-pwsh-tool-bash-parity.md: bf40c440b9f7f330412d8949f59c54d541152d45 +2026-08-02-pwsh-tool-bash-parity.zh.md: bc67dac29900eacf9e615e76fe947e3e642a3979 diff --git a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md new file mode 100644 index 0000000000..bf40c440b9 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md @@ -0,0 +1,36 @@ +# Agent Note: pwsh tool bash parity + +Status: implemented + +English | [中文](2026-08-02-pwsh-tool-bash-parity.zh.md) + +## Problem + +The first Windows-native foundation shipped `dsh-tool-pwsh` as a deliberately minimal profile — foreground only (a fresh process per call; no persistent PTY session), no managed-environment parity beyond three hardcoded `DSH_*` keys, and a marker story ("always `[exit code: N]`") that diverged from the bash tool's rendering without being declared. Review of that change found the model-visible contract drifting from the implementation: the description promised spill-path reporting the renderer never performed, the README claimed exports that did not exist and rendering the tool did not do, and the tool's own tests pinned the lossy behavior. The minimal profile also left the `DSH_*` contributor seam duplicated-by-absence: plugins contributing environment facts to `ctx.bashEnv` had no effect on pwsh calls. + +## Decision + +`dsh-tool-pwsh` now mirrors `dsh-tool-bash` call-for-call, minus the sandbox surface, and its model-visible text describes exactly that behavior: + +- **Rendering adopts the bash story verbatim**: stdout, a marked `[stderr]` section, truncation notices with spill paths, `(no output)` for an empty body, and exit markers only for non-zero exits — a clean exit produces no marker. The description and the `tool:pwsh` prompt section state this precisely ("Non-zero exits are reported as `[exit code: N]` markers"), deliberately not copying the bash prompt's "every result" phrasing, which its own renderer contradicts. +- **`run_in_background` is wired through the generic task runtime** exactly like the bash tool: preflight, owner registration, `task_output`/`task_kill` control, and the same outcome mapping. `pwsh-local`'s already-mirrored `start()` handle backs it. +- **The `DSH_*` environment is shared, not duplicated**: `BashEnvRegistry` moved out of `dsh-tool-bash` into a new tool-independent `@deepseek-ai/dsh-bash-env` package (`ctx.bashEnv` + built-ins + the session-persistence contributor), and both shell tools inject it. Contributors apply to pwsh calls exactly as they do to bash calls, resolving the bash tool's `FIXME(bash-env-ownership)`. +- **Windows reality is pinned where bash has no analog**: every command runs under a UTF-8 output preamble so the Windows PowerShell 5.1 fallback cannot garble non-ASCII output through the UTF-8-decoding collector, and the prompts teach that Windows forced termination settles as exit 1 without a signal marker. +- **Out of scope, unchanged**: sandbox escalation (waits for a Windows-confining executor), persistent PTY shells (backends are Linux/macOS-only; ConPTY is roadmap work), and pwsh-specific TUI/GUI presentation (generic/terminal cards stay; a PowerShell-aware terminal card with an exit pill is roadmap work). + +## Alternatives considered + +**Keep the minimal profile and fix only the claims.** Rejected: the review's core finding was that text contracts copied from bash drift without the corresponding implementation; a minimal tool plus accurate claims still leaves pwsh calls without background execution, without contributor parity, and with a divergent marker story that must be re-justified forever. + +**Reject a mismatched executor dialect at load.** Attempted and reverted before merge: a `ShellDialect` marker (`bash` | `powershell`) on `BashExecutor`, with both shell tools throwing when the mounted executor speaks another shell. It forced every executor implementation — including each test and example fake — to declare a dialect, adding noise to every shell-tool test for a guard with no in-repo or plausible deployment to catch (shipped compositions always pair tool-pwsh with `dsh-pwsh-local` and tool-bash with `dsh-bash-local`). The pairing contract stays documented in each tool's README instead. + +**Extract a fully shared tool implementation base (abstract shell dialect, two thin leaves).** Considered and deferred: the bash-env extraction and the structural mirror (`render.ts`/`background.ts` twins) are the foundation it would rest on; a full base waits until a third dialect or the persistent-PTY twin makes the abstraction's shape observable. + +## Consequences + +- The bash and pwsh tools are now behaviorally interchangeable for foreground and background shell work (minus sandbox), and the pwsh prompt/description sentences are each backed by the renderer — the reviewer's grep-against-code check passes. +- Parity ran BOTH ways once: the pwsh tool's structured foreground abort (`HarnessError('tool call aborted', TOOL_ABORTED)` with name `AbortError`) was backported to the bash tool, replacing its uncoded `Error('command aborted')` — a model-visible/logged change pinned by exact-shape tests on both sides and by the cancel-tool-calls fixture. +- `@deepseek-ai/dsh-bash-env` is a new shipped package; `dsh-tool-bash`'s `dshHome` config moved there, so compositions mounting the shell tools must also mount `bash-env` (the spine bundles do). +- Windows-only semantics (CRLF normalization, forced-termination exit-1/signal-null, POSIX-only self-signal) remain pinned by tests as before. +- The pwsh tool's per-file coverage gate rides on the scriptable fake-executor suite (`tests/tools.spec.ts`); the real-pwsh integration and Loader-composition suites self-skip where `pwsh` is absent, mirroring the bash suites' division of labor. +- The roadmap proposal's parity stage is delivered; its remaining stages are the Windows default composition and pwsh TUI/GUI rendering. diff --git a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md new file mode 100644 index 0000000000..bc67dac299 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md @@ -0,0 +1,36 @@ +# Agent Note: pwsh 工具与 bash 对齐 + +Status: implemented + +[English](2026-08-02-pwsh-tool-bash-parity.md) | 中文 + +## 问题 + +首个 Windows 原生基础交付的 `dsh-tool-pwsh` 是刻意最小的画像——仅前台、无后台任务、受管环境只有三个硬编码 `DSH_*` 键、以及一个未声明就偏离 bash 工具的 marker 故事("恒打 `[exit code: N]`")。对该变更的 review 发现模型可见契约与实现脱节:描述承诺了渲染器从未执行的 spill 路径报告,README 宣称了不存在的导出与工具未做的渲染,工具自己的测试还钉死了有损行为。最小画像还让 `DSH_*` contributor seam 因缺席而重复:向 `ctx.bashEnv` 贡献环境事实的插件对 pwsh 调用毫无作用。 + +## 决策 + +`dsh-tool-pwsh` 现在逐调用镜像 `dsh-tool-bash`,减去 sandbox 面,其模型可见文本精确描述这一行为: + +- **渲染完全采用 bash 故事**:stdout、带标记的 `[stderr]` 段、带 spill 路径的截断通知、空体渲染 `(no output)`、退出 marker 仅限非零退出——干净退出不产生 marker。描述与 `tool:pwsh` prompt section 精确陈述这一点("Non-zero exits are reported as `[exit code: N]` markers"),刻意不复制 bash prompt 中与其自身渲染矛盾的 "every result" 措辞。 +- **`run_in_background` 经通用任务运行时接线**,与 bash 工具完全一致:预检、owner 注册、`task_output`/`task_kill` 控制与相同的结果映射。其背后是 `pwsh-local` 早已镜像好的 `start()` 句柄。 +- **`DSH_*` 环境共享而非复制**:`BashEnvRegistry` 从 `dsh-tool-bash` 迁入新的工具无关包 `@deepseek-ai/dsh-bash-env`(`ctx.bashEnv` + 内置事实 + session-persistence contributor),两个 shell 工具都注入它。contributor 对 pwsh 调用与 bash 调用一视同仁,并消化了 bash 工具的 `FIXME(bash-env-ownership)`。 +- **Windows 现实在 bash 无对应处钉死**:每条命令都在 UTF-8 输出 preamble 下运行,使 Windows PowerShell 5.1 兜底无法经 UTF-8 解码的 collector 破坏非 ASCII 输出;prompt 教授 Windows 强制终止以无 signal 的 exit 1 结算。 +- **范围外,不变**:sandbox 升级(等待 Windows-confining 执行器)、持久 PTY shell(后端仅限 Linux/macOS;ConPTY 属路线图)、pwsh 专属 TUI/GUI 呈现(维持 generic/terminal 卡;带退出 pill 的 PowerShell 感知 terminal 卡属路线图)。 + +## 备选方案 + +**保留最小画像,只修声明。** 否决:review 的核心发现是"从 bash 复制的文本契约在缺少对应实现时会漂移";最小工具加准确声明仍让 pwsh 调用没有后台执行、没有 contributor 对等、并留下一个必须永远重新辩护的偏离 marker 故事。 + +**在加载时拒绝不匹配的执行器方言。** 合并前尝试过并撤回:在 `BashExecutor` 上加 `ShellDialect` 标记(`bash` | `powershell`),两个 shell 工具在挂载的执行器说另一种方言时抛错。它迫使每个执行器实现——包括每个测试与示例的 fake——都要声明 dialect,为一道仓内及合理部署中都没有目标可拦的护栏(交付组合总是把 tool-pwsh 配 `dsh-pwsh-local`、tool-bash 配 `dsh-bash-local`)给每个 shell 工具测试添噪。配对契约改由各工具 README 记录。 + +**提取完全共享的工具实现基座(抽象 shell 方言,两个薄叶子)。** 考虑后推迟:bash-env 提取与结构镜像(`render.ts`/`background.ts` 孪生)是它要立足的基础;在出现第三种方言或持久 PTY 孪生、让抽象的形态可观察之前,不做完整基座。 + +## 后果 + +- bash 与 pwsh 工具在前台与后台 shell 工作(减 sandbox)上行为可互换,pwsh 的 prompt/描述句每句都有渲染器背书——reviewer 的“拿代码 grep 对证”检查通过。 +- 对齐也反向发生过一次:pwsh 工具的结构化前台中止(`HarnessError('tool call aborted', TOOL_ABORTED)`,name 为 `AbortError`)被回移到 bash 工具,取代其无码的 `Error('command aborted')`——这是模型可见/入日志的变更,由两侧的精确形状测试与 cancel-tool-calls fixture 钉住。 +- `@deepseek-ai/dsh-bash-env` 成为新的交付包;`dsh-tool-bash` 的 `dshHome` 配置迁往那里,因此挂载 shell 工具的组合也必须挂载 `bash-env`(spine bundle 已如此)。 +- Windows 专属语义(CRLF 归一化、强制终止 exit-1/signal-null、仅 POSIX 的自信号)一如既往由测试钉住。 +- pwsh 工具的 per-file 覆盖门禁由可脚本化的 fake-executor 套件(`tests/tools.spec.ts`)承担;真实 pwsh 的集成与 Loader 组合套件在无 `pwsh` 的宿主自跳过,与 bash 套件的分工一致。 +- 路线图提案的 parity 阶段已交付;其余阶段是 Windows 默认组合与 pwsh TUI/GUI 渲染。 diff --git a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.i18n.yaml b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.i18n.yaml new file mode 100644 index 0000000000..2ec7925a3e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.md +2026-08-02-win32-in-process-folder-dialog.md: 91a1ed0d7b1c1938a5e038ce36f1ca90bf3c9e82 +2026-08-02-win32-in-process-folder-dialog.zh.md: 6b90dc1c5fa0042b3e2bcbea8ed554f1f0ea2acf diff --git a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.md b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.md new file mode 100644 index 0000000000..91a1ed0d7b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.md @@ -0,0 +1,27 @@ +# Agent Note: Win32 folder picker moves to koffi in a child process + +Status: implemented + +English | [中文](2026-08-02-win32-in-process-folder-dialog.zh.md) + +## Problem + +The Windows directory picker's primary tier was a spawned PowerShell script around WinForms `FolderBrowserDialog`: the modern dialog only where PowerShell 7 happens to be installed, a review-flagged regression where PowerShell 6 resolves but has no WinForms (exit 1 is not `ENOENT`, so the 5.1 fallback never ran), a `SetProcessDPIAware` ceiling of system DPI, and a picker whose behavior depended on which shells a machine ships rather than on Windows itself. + +## Decision + +`packages/host/directory-picker-native` now opens `IFileOpenDialog` (`FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_NOCHANGEDIR`) in-process through koffi — already a workspace dependency for the repo's other `win32.ts` surfaces — as the primary win32 tier. The COM conversation runs in a spawned child process so the modal `Show` never blocks the host event loop; the child posts its native thread id before blocking, and the driver services aborts by re-posting `WM_CLOSE` to that thread's windows (`EnumThreadWindows`), killing the child when the close budget is exhausted. The dialog is the child's first window, so Windows activates it without a foreground call. The child thread opts into the best thread DPI awareness the host accepts (`SetThreadDpiAwarenessContext`, cascading per-monitor-v2 → per-monitor → system-aware with the return value checked), a strict upgrade over the script's system-DPI ceiling; DPI stays a cosmetic best-effort — a host accepting none of them still gets the modern dialog rather than a downgrade. The module split keeps coverage honest on every host: `win32-dialog-logic.ts` (pure sequencing) and `win32-dialog.ts` (driver) test against fakes anywhere; `win32-dialog-bindings.ts` tests against a mocked `koffi` COM world (the `dsh-session-persistence-jsonl` technique); POSIX hosts run the real spawn plumbing to its koffi-load rejection; win32 hosts run a real open-and-abort-close smoke. The PowerShell chain that preceded this tier is gone (see the [chain removal](../simplification/2026-08-04-drop-windows-powershell-picker-fallback.md)): the tier has no fallback. + +## Alternatives considered + +- **A prebuilt native helper (`native/` family like `node-addon-landlock-run`).** Rejected: a mirror repository, an npm package family, MSVC provisioning, and a release handoff — all to ship ~150 lines of C the repository cannot exercise on CI (no real-Windows lane); koffi delivers the same COM surface with zero new supply chain. +- **An N-API in-process addon.** Rejected for the same CI/toolchain reasons plus owned C++ for STA threading and message pumping that a child process + koffi express in TypeScript. +- **Keep PowerShell primary and probe versions.** Rejected: the picker stays hostage to shell packaging (6 vs 7, Store aliases, profiles), and 5.1's legacy dialog remains the floor wherever pwsh is absent; the fallback-trigger widening alone was accepted into the fallback tier instead. +- **Blocking the main thread for the modal call.** Rejected outright: the web host must keep serving RPC while the dialog is open. + +## Consequences + +- Every Windows machine gets the modern dialog with the best DPI awareness it supports (per-monitor-v2 on 1703+), PowerShell installed or not. +- Real dialog rendering and the selection path stay a manual Windows check (the auto-close smoke proves open/abort/unwind). +- The COM vtable slots and GUIDs used are frozen Windows ABI (Vista); a koffi signature mistake risks a native access violation, contained to the dialog child process — the host Node process survives and the failure surfaces as-is (no fallback tier; see the [chain removal](../simplification/2026-08-04-drop-windows-powershell-picker-fallback.md)). The mocked-koffi ABI pins and the real win32 smoke exist to catch such mistakes before shipping. +- The packaged-binary arm — the packaged executable spawning itself as the dialog entry — is not exercised by any automated test: the source plane and the built `lib/worker.cjs` under plain node are covered, and the packaged spawn remains deferred to the Windows CI roadmap. diff --git a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.zh.md b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.zh.md new file mode 100644 index 0000000000..6b90dc1c5f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.zh.md @@ -0,0 +1,27 @@ +# Agent Note:Win32 文件夹选择器迁至 koffi 子进程 + +Status: implemented + +[English](2026-08-02-win32-in-process-folder-dialog.md) | 中文 + +## 问题 + +Windows 目录选择器的主层此前是围绕 WinForms `FolderBrowserDialog` 的外部 PowerShell 脚本:只有恰好安装了 PowerShell 7 的机器才有现代对话框;review 指出的回归——PowerShell 6 可解析却没有 WinForms(退出码 1 而非 `ENOENT`,5.1 回退永远不会触发);`SetProcessDPIAware` 只有系统 DPI 的上限;选择器的行为取决于机器装了哪些 shell,而不是取决于 Windows 本身。 + +## 决策 + +`packages/host/directory-picker-native` 现在经 koffi——它已是仓库其他 `win32.ts` 面的工作区依赖——在进程内打开 `IFileOpenDialog`(`FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_NOCHANGEDIR`),作为 win32 主层。COM 会话运行在 spawn 出的子进程中,模态 `Show` 永不阻塞宿主事件循环;子进程在阻塞前上报其原生线程 id,driver 通过向该线程的窗口反复投递 `WM_CLOSE`(`EnumThreadWindows`)来服务中止,关闭预算耗尽时 kill 子进程。对话框是子进程的第一个窗口,Windows 会自动激活它,无需手动前台调用。子进程线程启用宿主接受的最佳线程 DPI 感知(`SetThreadDpiAwarenessContext`,按 per-monitor-v2 → per-monitor → system-aware 级联并检查返回值),严格优于脚本的系统 DPI 上限;DPI 保持为纯外观的 best-effort——全部不被接受的宿主仍得到现代对话框,而不会降级。模块切分让覆盖率在任何主机上都诚实:`win32-dialog-logic.ts`(纯时序)与 `win32-dialog.ts`(driver)在任何平台对假件测试;`win32-dialog-bindings.ts` 对 mock 的 `koffi` COM 世界测试(`dsh-session-persistence-jsonl` 的技法);POSIX 主机把真实 spawn 管道跑到 koffi 加载失败的拒绝;win32 主机跑真实的"打开并中止关闭"冒烟。先于本层存在的 PowerShell 链已被删除(见[链删除](../simplification/2026-08-04-drop-windows-powershell-picker-fallback.md)):该层无回退。 + +## 考虑过的替代方案 + +- **预编译原生助手(`native/` 家族,如 `node-addon-landlock-run`)。** 否决:镜像仓库、npm 包家族、MSVC 供给和发布交接——只为交付约 150 行 CI 无法执行的 C(没有真 Windows 通道);koffi 以零新增供应链提供同一 COM 面。 +- **N-API 进程内插件。** 否决:同样的 CI/工具链原因,另加需要自有 C++ 处理 STA 线程与消息泵,而子进程 + koffi 用 TypeScript 就能表达。 +- **保留 PowerShell 为主层并探测版本。** 否决:选择器仍被 shell 打包形态挟持(6 与 7、Store 别名、profile),且没有 pwsh 的机器地板仍是 5.1 的旧版对话框;仅把回退触发条件的拓宽吸收进回退层。 +- **在主线程上阻塞模态调用。** 直接否决:对话框打开期间 web 宿主必须继续服务 RPC。 + +## 后果 + +- 每台 Windows 机器都得到带其所支持的最佳 DPI 感知(1703+ 为 per-monitor-v2)的现代对话框,无论是否安装 PowerShell。 +- 真实对话框渲染与选中路径仍是手动 Windows 检查(自动关闭冒烟证明打开/中止/收尾)。 +- 所用 COM vtable 槽位与 GUID 是冻结的 Windows ABI(Vista 起);koffi 签名错误可能引发原生访问冲突,但被限制在对话框子进程内——宿主 Node 进程存活,失败原样上报(无回退层;见[链删除](../simplification/2026-08-04-drop-windows-powershell-picker-fallback.md))。mocked-koffi 的 ABI 钉与真实 win32 冒烟正是为了在交付前捕获这类错误。 +- 打包二进制的臂——打包后的可执行文件以对话框入口形式自我 spawn——不受任何自动化测试覆盖:源码平面与普通 node 下构建出的 `lib/worker.cjs` 已被覆盖,打包 spawn 推迟到 Windows CI 路线图。 diff --git a/.agents/notes/implemented/process/2026-06-17-ts-build-config.i18n.yaml b/.agents/notes/implemented/process/2026-06-17-ts-build-config.i18n.yaml index 0bfd7a6dd6..fe42a92f75 100644 --- a/.agents/notes/implemented/process/2026-06-17-ts-build-config.i18n.yaml +++ b/.agents/notes/implemented/process/2026-06-17-ts-build-config.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-06-17-ts-build-config.md -2026-06-17-ts-build-config.md: 20dd3d8d0a11e01397c36388903cd112a170f0bf -2026-06-17-ts-build-config.zh.md: 90a9247bd2b942d5bf88d41989a36bfefe906787 +2026-06-17-ts-build-config.md: ec4c6a4aeb1074a69d45b2cf4b4c733b410ccceb +2026-06-17-ts-build-config.zh.md: 8115bb2557eea967d57eb6693e2bb288188792a8 diff --git a/.agents/notes/implemented/process/2026-06-17-ts-build-config.md b/.agents/notes/implemented/process/2026-06-17-ts-build-config.md index 20dd3d8d0a..ec4c6a4aeb 100644 --- a/.agents/notes/implemented/process/2026-06-17-ts-build-config.md +++ b/.agents/notes/implemented/process/2026-06-17-ts-build-config.md @@ -32,7 +32,7 @@ In-package relative imports use explicit `.ts` specifiers. `pnpm run build` is a two-stage build: -- Stage 1: `tsc -b` over the root solution emits per-module `.js`, declarations `.d.ts`, JS sourcemaps `.js.map`, and declaration sourcemaps `.d.ts.map` into each package's `lib/types`. This is the authoritative TypeScript compilation result. For publish we keep `.d.ts` / `.d.ts.map` and ignore `.js` / `.js.map`. +- Stage 1: `tsc -b` over the root solution emits per-module `.js`, declarations `.d.ts`, JS sourcemaps `.js.map`, and declaration sourcemaps `.d.ts.map` into each package's `lib/types`. This is the authoritative TypeScript compilation result. Publication keeps `.d.ts`; packages whose runtime exports explicitly point into the emitted tree also keep its `.js` files. `.js.map` and `.d.ts.map` remain in the local build tree. - The graph is the project-reference graph reachable from the root solution `tsconfig.json` through the two aggregates ([topology](2026-07-22-tsconfig-solution-root-two-aggregates.md)). It validates and emits package/vendor build results. - Stage 2: a bundler reads the emitted JS under `lib/types` and writes the bundled runtime entry as `lib/index.js` or `lib/index.mjs` (follow current behavior). This stage is bundling only. It must not read TypeScript source or emit declarations. @@ -77,9 +77,9 @@ Build responsibilities are clearer: - Each module under `packages//` and `vendor/*` has one local tsconfig for build, typecheck, and tools that run source directly, such as the `dsh` source loader, `tsx`, and `vitest`. - The `build` command drives the root solution graph. `tsc -b` owns the publishable per-module `.js` and `.d.ts` output, and the bundler owns only `lib/index.*`. - - `lib/types/*.d.ts` and `.d.ts.map` are the publish declaration output. + - `lib/types/*.d.ts` is the publish declaration output; `.d.ts.map` remains only as a local compilation artifact. - `lib/types/*.d.ts` uses explicit `.ts` relative specifiers, which TypeScript's NodeNext/Node16 resolver maps to sibling `.d.ts` files. - - `lib/types/*.js` is only a bundler input and must not be used as a runtime entry or public import target. + - `lib/types/*.js` is normally only a bundler input. It is published only when an explicit runtime export points into the emitted tree. - `lib/index.*` is the publish runtime output and is generated by the bundler, currently `tsdown`. - `pnpm run verify-node-next-types` scans built declarations for relative specifiers without file extensions, then typechecks a temporary external ESM consumer with `moduleResolution: "NodeNext"` against the built `types`/`exports` surface, so declaration specifier regressions fail before publish. - The `typecheck` command uses `tsconfig.json`. Examples, tests, and scripts are checked by the root no-emit project, while packages and vendor modules keep the same emit behavior as `build`. Package and vendor source stays behind project-reference boundaries. diff --git a/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md b/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md index 90a9247bd2..8115bb2557 100644 --- a/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md +++ b/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md @@ -11,7 +11,7 @@ Status: implemented 此前的 TypeScript 构建与类型检查配置存在以下问题: - `build` 使用 `tsc` 将 `packages//` 和 `vendor/*` 下的 `.ts` 转换为 `.d.ts` 文件,然后使用 `tsdown` 将 `.ts` 转换为打包后的 `.js` 文件。这导致两个工具各自执行 TypeScript 转换。 -- `typecheck` 倾向于通过一个根目录的类型检查配置来校验包、vendor 源码、示例、测试和脚本。 +- `typecheck` 倾向于通过一个根目录的类型检查配置来校验包(package)、vendor 源码、示例、测试和脚本。 目标是让构建与类型检查使用一致的 tsconfig 边界和 TypeScript 解析/转换行为。构建应通过单一编译器和配置生成 `.js`、`.d.ts`、`.js.map` 和 `.d.ts.map`,使发布产物与类型校验保持一致。 @@ -21,7 +21,7 @@ Status: implemented - `tsdown` 输出的打包 `.d.ts` 与 Cordis 内部的相对模块增强(module augmentation)结构冲突。 - tsc 的输出受 `allowImportingTsExtensions` 影响,因此需要确保生成的 `.js` 文件不会导入 `.ts` 文件,且生成的 `.d.ts` 文件保留 NodeNext/Node16 接受的显式相对说明符。为此,包内相对导入在 TypeScript 源码中使用显式 `.ts` 说明符,由 `rewriteRelativeImportExtensions` 在输出的 JS 中将其重写为 `.js`。 - `tsdown` 输出的打包 `.js` 与 `tsc -b` 逐文件输出的 `.js` 行为不同,例如装饰器转换行为。 -- `vendor/*/src`、示例、测试和脚本无法全部直接纳入一个根目录的严格程序。 +- `vendor/*/src`、示例、测试和脚本无法全部以 plain-include 方式纳入一个根目录的严格程序。 - 在根目录严格配置下直接对 `vendor/*/src` 做类型检查,会触发大量不属于本项目所有权范围的类型错误。 - `packages/*/*` 对 `vendor` 的包依赖解析到 `vendor/*/lib`,以适应不同的 tsconfig 严格度。 @@ -32,7 +32,7 @@ Status: implemented `pnpm run build` 是两阶段构建: -- 阶段 1:在根 solution 上执行 `tsc -b`,将逐模块的 `.js`、声明文件 `.d.ts`、JS sourcemap `.js.map` 和声明 sourcemap `.d.ts.map` 输出到各包的 `lib/types`。这是权威的 TypeScript 编译结果。发布时保留 `.d.ts` / `.d.ts.map`,忽略 `.js` / `.js.map`。 +- 阶段 1:在根 solution 上执行 `tsc -b`,将逐模块的 `.js`、声明文件 `.d.ts`、JS sourcemap `.js.map` 和声明 sourcemap `.d.ts.map` 输出到各包的 `lib/types`。这是权威的 TypeScript 编译结果。发布时保留 `.d.ts`;如果包的运行时 export 显式指向该输出树,也会保留其中的 `.js` 文件。`.js.map` 和 `.d.ts.map` 留在本地构建树中。 - 该图是从根 solution `tsconfig.json` 经两个聚合可达的 project-reference 图([拓扑](2026-07-22-tsconfig-solution-root-two-aggregates.md)),用于校验并输出包/vendor 的构建结果。 - 阶段 2:打包器读取 `lib/types` 下输出的 JS,将打包后的运行时入口写为 `lib/index.js` 或 `lib/index.mjs`(沿用当前行为)。此阶段仅做打包,禁止读取 TypeScript 源码或输出声明文件。 @@ -77,11 +77,11 @@ tsx scripts/clean.ts - `packages//` 和 `vendor/*` 下的每个模块有一份本地 tsconfig,同时服务于构建、类型检查和直接运行源码的工具(如 `dsh` 源码 loader、`tsx` 和 `vitest`)。 - `build` 命令驱动根 solution 图。`tsc -b` 负责可发布的逐模块 `.js` 和 `.d.ts` 输出,打包器仅负责 `lib/index.*`。 - - `lib/types/*.d.ts` 和 `.d.ts.map` 是发布用的声明输出。 + - `lib/types/*.d.ts` 是发布用的声明输出;`.d.ts.map` 只作为本地编译产物保留。 - `lib/types/*.d.ts` 使用显式 `.ts` 相对说明符,TypeScript 的 NodeNext/Node16 解析器会将其映射到同级的 `.d.ts` 文件。 - - `lib/types/*.js` 仅作为打包器输入,禁止用作运行时入口或公开导入目标。 + - `lib/types/*.js` 通常仅作为打包器输入。只有显式运行时 export 指向该输出树时,才会发布这些文件。 - `lib/index.*` 是发布用的运行时输出,由打包器(当前为 `tsdown`)生成。 -- `pnpm run verify-node-next-types` 扫描构建出的声明文件,检查是否存在缺少文件扩展名的相对说明符,然后以 `moduleResolution: "NodeNext"` 针对构建出的 `types`/`exports` 接口,对一个临时的外部 ESM 消费方进行类型检查,确保声明说明符的回归在发布前被捕获。 +- `pnpm run verify-node-next-types` 扫描构建出的声明文件,检查是否存在缺少文件扩展名的相对说明符,然后以 `moduleResolution: "NodeNext"` 对构建出的 `types`/`exports` 接口进行临时外部 ESM 消费方的类型检查,确保声明说明符的回归在发布前被捕获。 - `typecheck` 命令使用 `tsconfig.json`。示例、测试和脚本由根 no-emit 项目检查,包和 vendor 模块保持与 `build` 相同的输出行为。包和 vendor 源码始终处于 project-reference 边界之后。 - 切换分支或更新工作副本后,如果其中删除了包,贡献者可在重新构建前运行 `pnpm run clean`,删除残留的包目录。不含 `package.json` 的包目录如果存在未知文件,必须手动判定其类别,不能直接删除。 diff --git a/.agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.i18n.yaml index cdbc691c38..4c0e0c5b0d 100644 --- a/.agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md 2026-07-26-eventsource-parser-for-deepseek-sse.md: e7835bc738b3dec5aefd6011848525f6604e852e -2026-07-26-eventsource-parser-for-deepseek-sse.zh.md: 141fadd5bd43c7a945e73ce219ccd61f2bd9f332 +2026-07-26-eventsource-parser-for-deepseek-sse.zh.md: 7c746079aa7012115bea05ec0191d665c8f860d2 diff --git a/.agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.zh.md b/.agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.zh.md index 141fadd5bd..7c746079aa 100644 --- a/.agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 用 eventsource-parser 替换 llm-deepseek 中手写的 SSE(Server-Sent Events)解析器 +# Agent Note: 用 eventsource-parser 替换 llm-deepseek 中手写的 SSE 解析器 Status: implemented @@ -6,7 +6,7 @@ Status: implemented ## 问题 -`packages/llm/llm-deepseek/src/sse.ts` 曾手写实现 SSE 解析:一个流式 `TextDecoder`、按 `\r?\n\r?\n` 切分事件块、提取并拼接 `data:` 载荷、跳过注释与其他字段、`[DONE]` 哨兵、在未见哨兵即 EOF 时抛出 `STREAM_CLOSED` 错误,以及对最后一个未终结事件块的 flush。该文件约 67 行,另有约 108 行专属测试(`tests/sse.spec.ts`)重复验证 SSE 规范行为——UTF-8 字符被切分到多个分片、CRLF 处理、多条 `data:` 拼接、冒号后无空格——而这些行为,持续维护的解析器早已有保证。它唯一的消费方是 `adapter.ts`(`yield* translate(parseSse(response.body))`)。 +`packages/llm/llm-deepseek/src/sse.ts` 曾手写实现 SSE(Server-Sent Events)解析:一个流式 `TextDecoder`、按 `\r?\n\r?\n` 切分事件块、提取并拼接 `data:` 载荷、跳过注释与其他字段、`[DONE]` 哨兵、在未见哨兵即 EOF 时抛出 `STREAM_CLOSED` 错误,以及对最后一个未终结事件块的 flush。该文件约 67 行,另有约 108 行专属测试(`tests/sse.spec.ts`)重复验证 SSE 规范行为——UTF-8 字符被切分到多个分片、CRLF 处理、多条 `data:` 拼接、冒号后无空格——而这些行为,持续维护的解析器早已有保证。它唯一的消费方是 `adapter.ts`(`yield* translate(parseSse(response.body))`)。 这恰好是 `eventsource-parser` 负责的接口面:事实标准的 SSE 解析器(Vercel AI SDK 和 MCP SDK 都构建在它之上),零依赖,持续维护,并且已通过 `@modelcontextprotocol/sdk` 作为传递依赖出现在本仓库的 lockfile 中——因此直接采用它实际上不增加新的供应链接触面。 diff --git a/.agents/notes/implemented/simplification/2026-08-04-drop-windows-powershell-picker-fallback.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-04-drop-windows-powershell-picker-fallback.i18n.yaml new file mode 100644 index 0000000000..344dd2bf6c --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-04-drop-windows-powershell-picker-fallback.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-04-drop-windows-powershell-picker-fallback.md +2026-08-04-drop-windows-powershell-picker-fallback.md: 619afd31d9ec78cdb8565e29fa942b7db8749365 +2026-08-04-drop-windows-powershell-picker-fallback.zh.md: e14904db46a955d4cf40da195a39bf62cbef96ff diff --git a/.agents/notes/implemented/simplification/2026-08-04-drop-windows-powershell-picker-fallback.md b/.agents/notes/implemented/simplification/2026-08-04-drop-windows-powershell-picker-fallback.md new file mode 100644 index 0000000000..619afd31d9 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-04-drop-windows-powershell-picker-fallback.md @@ -0,0 +1,38 @@ +# Agent Note: Drop the Windows PowerShell picker fallback + +Status: implemented + +English | [中文](2026-08-04-drop-windows-powershell-picker-fallback.zh.md) + +## Problem + +The win32 branch of the native directory picker kept a two-tier PowerShell fallback under the koffi `IFileOpenDialog` child process: `pwsh.exe` first, then `powershell.exe` (Windows PowerShell 5.1), both running the same WinForms script with a `SetProcessDPIAware` opt-in. The chain existed to keep a working chooser when the koffi tier was "unavailable", but every trigger it plausibly protected was a failure of our own packaging or deployment, not of the operating system: + +- koffi's native binary ships as an ordinary optional dependency (`@koromix/koffi-win32-x64`, no install script); a host that installs the package at all has the binary, and a host that cannot install it fails the package install loudly — the fallback code never loads either. +- "Ancient Windows" cannot occur: the Node versions this repo supports run on Windows generations far newer than the Vista-era `IFileOpenDialog` ABI the dialog needs. +- A koffi/COM defect crashes only the dialog child process (crash isolation); the correct response to our own bug is a surfaced failure, not a silent downgrade to a legacy dialog. + +The chain also cost real complexity: two spawn tiers running one identical script, a fallback trigger widened from `ENOENT` to any pwsh failure to close the PowerShell 6 (no WinForms) regression, a triple-miss `AggregateError` carrying all three causes, and per-tier abort re-checks. The seam already owns the only fallback that matters — the `browse` backend at the composition level, chosen once at boot by `directory-picker-auto`. + +## Decision + +The win32 tier is exactly the koffi `IFileOpenDialog` child process; any failure surfaces as-is with no fallback. The PowerShell chain — the `pwsh` → Windows PowerShell 5.1 cascade, the DPI-corrected WinForms script, the `AggregateError` aggregation — is deleted, and `pickNativeDirectory`'s win32 branch is a single call. `dsh-native-command` remains a dependency for the POSIX tiers. + +The fallback criterion the rest of the package already followed now applies uniformly: a fallback tier exists only for tools the OS/desktop environment provides and may omit (`zenity` → `kdialog` on Linux, which the boot-time probe also samples); tools our own package ships (`koffi`) fail loud. macOS `osascript` stays fallback-free as before. + +This change consolidates and deletes the pwsh-first DPI picker-fix note: its decision is fully reversed here, and its preserved rationale no longer guides future work on a koffi-only tier. What it kept that was real: PowerShell 7 renders the modern `IFileDialog`-based folder picker where 5.1's `FolderBrowserDialog` is hardwired to the legacy `SHBrowseForFolder` tree; the script's `SetProcessDPIAware` corrected the spawn's system-DPI ceiling; the pwsh→5.1 hop existed because a resolvable PowerShell 6 has no WinForms (exit 1, not `ENOENT`). Its rejected alternatives (requiring PowerShell 7, importing `resolvePwshPath`, setting DPI awareness in the harness process) are moot with the chain gone. + +## Alternatives considered + +**Keep the chain but drop the pwsh quality tier (`koffi` → Windows PowerShell 5.1).** Rejected: the remaining tier still defends our own packaged dependency, still costs the script, the widened trigger, and the aggregation, and still hides our own vtable/COM defects behind a legacy dialog. The criterion "fallback only for externally provided tools" admits no Windows tier at all. + +**Keep the chain as-is.** Rejected: it was the only two-level runtime fallback in the picker surface, its triggers were deployment-side failures that fail loud anyway, and it degraded a failed pick into an `AggregateError` whose most actionable entry was a PowerShell host. + +**Fall back to `browse` at runtime when the native pick fails.** Rejected: the seam's flow holes are `single`-kind and the `-auto` composition already picks one backend at boot; a runtime cross-kind hop would double-mount both backends and blur the capability boundary. + +## Consequences + +- The win32 picker's failure surface is one error from one tier; callers see the real cause (koffi load failure, COM refusal, dialog crash) instead of a chain-aggregated error. +- `pwsh`/`powershell.exe` are no longer invoked by this package; the WinForms script, its `SetProcessDPIAware` correction, and the `-STA` flags are gone with them. +- Tests shrink accordingly: the pwsh/5.1 cascade and triple-miss cases are replaced by one "failure surfaces with no fallback" case; the default-adapter test now drives the Linux tier. +- Reintroduction condition: a future win32 mechanism outside our packaging chain (a system-provided dialog host we do not ship) would justify a single fallback tier under the same criterion. diff --git a/.agents/notes/implemented/simplification/2026-08-04-drop-windows-powershell-picker-fallback.zh.md b/.agents/notes/implemented/simplification/2026-08-04-drop-windows-powershell-picker-fallback.zh.md new file mode 100644 index 0000000000..e14904db46 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-04-drop-windows-powershell-picker-fallback.zh.md @@ -0,0 +1,38 @@ +# Agent Note:删除 Windows PowerShell 选择器回退 + +Status: implemented + +[English](2026-08-04-drop-windows-powershell-picker-fallback.md) | 中文 + +## Problem + +原生目录选择器的 win32 分支在 koffi `IFileOpenDialog` 子进程之下保留了一条两级 PowerShell 回退:先 `pwsh.exe`,再 `powershell.exe`(Windows PowerShell 5.1),两者运行同一个带 `SetProcessDPIAware` 开关的 WinForms 脚本。该链的存在是为了在 koffi 层"不可用"时仍能给出一个可用的选择器,但它可能保护的每一个触发条件都是我们自己打包或部署的失败,而不是操作系统的: + +- koffi 的原生二进制作为普通 optional 依赖(`@koromix/koffi-win32-x64`,无 install script)分发;能装上该包的宿主就一定有二进制,装不上的宿主会在安装期大声失败——回退代码同样不会加载。 +- "上古 Windows"不可能出现:本仓库支持的 Node 版本运行在远比 Vista 时代 `IFileOpenDialog` ABI 新的 Windows 世代上。 +- koffi/COM 缺陷只崩对话框子进程(crash isolation);对我们自己 bug 的正确反应是上报失败,而不是静默降级到旧版对话框。 + +这条链还付出了真实的复杂度:两个 spawn 层运行同一脚本、把回退触发从 `ENOENT` 拓宽为 pwsh 的任何失败以关闭 PowerShell 6(无 WinForms)回归、携带全部三个原因的三连败 `AggregateError`,以及每层的 abort 重检。seam 早已拥有唯一重要的回退——组合层面的 `browse` 后端,由 `directory-picker-auto` 在启动时选择一次。 + +## Decision + +win32 层恰好就是 koffi `IFileOpenDialog` 子进程;任何失败原样上报,无回退。PowerShell 链——`pwsh` → Windows PowerShell 5.1 级联、DPI 修正的 WinForms 脚本、`AggregateError` 聚合——被删除,`pickNativeDirectory` 的 win32 分支成为单次调用。`dsh-native-command` 仍为 POSIX 层保留依赖。 + +本包其余部分早已遵循的回退判据现在统一适用:回退层只存在于操作系统/桌面环境提供且可能缺失的工具(Linux 的 `zenity` → `kdialog`,启动探针同样采样它们);我们自己打包的工具(`koffi`)失败即大声报错。macOS `osascript` 与之前一样保持无回退。 + +本次变更合并并删除了 pwsh 优先的 DPI 选择器修复 Note:其决策在此被完全反转,其保留的 rationale 对只含 koffi 的层不再指导未来工作。其中真实的部分:PowerShell 7 呈现基于 `IFileDialog` 的现代文件夹选择器,而 5.1 的 `FolderBrowserDialog` 被硬连到旧版 `SHBrowseForFolder` 树;脚本的 `SetProcessDPIAware` 修正了 spawn 的系统 DPI 上限;pwsh→5.1 的跳转存在是因为可解析的 PowerShell 6 没有 WinForms(退出码 1,而非 `ENOENT`)。其被拒绝的替代方案(要求 PowerShell 7、导入 `resolvePwshPath`、在 harness 进程设置 DPI 感知)随链删除而失去意义。 + +## Alternatives considered + +**保留链但去掉 pwsh 质量层(`koffi` → Windows PowerShell 5.1)。** 拒绝:剩下的层仍在为我们自己打包的依赖辩护,仍要付出脚本、拓宽的触发与聚合的代价,仍会把我们自己的 vtable/COM 缺陷藏到旧版对话框后面。"仅对外部提供的工具回退"的判据不接受任何 Windows 层。 + +**原样保留链。** 拒绝:它是选择器面上唯一的二级运行时回退,其触发条件是本就大声失败的部署侧失败,并且它把失败的 pick 降级成一个最具可操作性的条目是 PowerShell 宿主的 `AggregateError`。 + +**原生 pick 失败时在运行时回退到 `browse`。** 拒绝:seam 的流程洞是 `single` kind,`-auto` 组合已在启动时选择一个后端;运行时跨 kind 跳转会双挂两个后端并模糊能力边界。 + +## Consequences + +- win32 选择器的失败面是来自单一层的一个错误;调用方看到真实原因(koffi 加载失败、COM 拒绝、对话框崩溃),而不是链式聚合的错误。 +- 本包不再调用 `pwsh`/`powershell.exe`;WinForms 脚本、其 `SetProcessDPIAware` 修正与 `-STA` 标志随之消失。 +- 测试相应缩减:pwsh/5.1 级联与三连败用例被一个"失败原样上报、无回退"用例取代;默认适配器测试改驱动 Linux 层。 +- 重新引入条件:未来出现在我们打包链之外的 win32 机制(我们不随包分发的系统提供的对话框宿主)才值得在同一判据下保留一层回退。 diff --git a/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.i18n.yaml b/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.i18n.yaml index 4b668b14af..5f6b9c16d8 100644 --- a/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.md 2026-07-25-scriptable-llm-wire-fault-server.md: b8e64d92db224c199d0f8c547f0caa588d0ca65e -2026-07-25-scriptable-llm-wire-fault-server.zh.md: 32b0608748c155253359b1fa9a18c467ff783b39 +2026-07-25-scriptable-llm-wire-fault-server.zh.md: 54604e2d8c019386d8f94d90d859bb08ce58b030 diff --git a/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.zh.md b/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.zh.md index 32b0608748..54604e2d8c 100644 --- a/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.zh.md +++ b/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 可脚本控制的 LLM(大语言模型)协议层故障服务器 +# Agent Note: 可脚本控制的 LLM 协议层故障服务器 Status: implemented diff --git a/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.i18n.yaml index 5c73a0c5e6..8552e86644 100644 --- a/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md 2026-07-07-claude-code-and-codex-subagent-backends.md: ee8576f97a9fdef8c88dcad3a73f28b63ca3ebe1 -2026-07-07-claude-code-and-codex-subagent-backends.zh.md: b68fab67b6b1893777324420b1007b5c54973fe1 +2026-07-07-claude-code-and-codex-subagent-backends.zh.md: bd76a8f34d86b704494b56331c47ea89bfc8a0aa diff --git a/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.zh.md index b68fab67b6..bd76a8f34d 100644 --- a/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.zh.md +++ b/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.zh.md @@ -6,7 +6,7 @@ Status: proposed ## 问题 -subagent seam([seam Agent Note](../../implemented/feature/2026-06-21-subagent-capability-seam.md))在 `ctx.subagents` 上托管多个命名提供方,ACP(Agent Client Protocol)后端([ACP 后端 Agent Note](../../implemented/feature/2026-06-22-acp-subagent-backend.md))证明了该 seam 能跨越进程边界泛化;其「未来提供方」一节明确将 Codex app-server 与 Claude Code Agent SDK 列为实现机制相似的同类方案。如今真正值得委派的就是这两个编码 agent(智能体)引擎:harness 的一个轮次应能把一个自包含任务交给真实的 Claude Code 或真实的 Codex——一个拥有自身模型、工具与沙箱的独立产品——并取回一个最终答案,同时父部署不向子进程泄漏密钥,子进程行为也不静默依赖宿主机上碰巧存在的 `~/.claude` / `~/.codex` 状态。 +subagent seam([seam Agent Note](../../implemented/feature/2026-06-21-subagent-capability-seam.md))在 `ctx.subagents` 上托管多个命名提供方,ACP(Agent Client Protocol)后端([ACP 后端 Agent Note](../../implemented/feature/2026-06-22-acp-subagent-backend.md))证明了该 seam 能跨越进程边界泛化;其「未来提供方」一节明确将 Codex app-server 与 Claude Code Agent SDK 列为实现机制相似的同类方案。如今真正值得委派的就是这两个引擎:harness 的一个轮次应能把一个自包含任务交给真实的 Claude Code 或真实的 Codex——一个拥有自身模型、工具与沙箱的独立产品——并取回一个最终答案,同时父部署不向子进程泄漏密钥,子进程行为也不静默依赖宿主机上碰巧存在的 `~/.claude` / `~/.codex` 状态。 ## 提案 diff --git a/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.i18n.yaml b/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.i18n.yaml new file mode 100644 index 0000000000..5e7f47f278 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.md +2026-08-01-windows-pwsh-default.md: a310174b6864bb880070280835ccfd8623e26342 +2026-08-01-windows-pwsh-default.zh.md: 079c1e3cac789a5e3fa4d0bb889026b3fb69f78c diff --git a/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.md b/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.md new file mode 100644 index 0000000000..a310174b68 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.md @@ -0,0 +1,39 @@ +# Agent Note: Windows defaults to pwsh (roadmap) + +Status: proposed + +English | [中文](2026-08-01-windows-pwsh-default.zh.md) + +## Problem + +The harness's shipped execution profile is bash-first on every platform. Windows hosts must install a bash shim (WSL or Git-Bash) or fall back to the POSIX-only `dsh-bash-local` behavior; the model-facing bash tool teaches the bash dialect, and the TUI/Web surfaces render terminal output in bash-shaped expectations. The first Windows-native foundation shipped in the [pwsh executor and tool decision](../../implemented/feature/2026-08-01-pwsh-tool-and-executor.md): a PowerShell implementation of the `ctx.bash` seam and a parity `pwsh` tool — but nothing yet defaults Windows hosts to them. + +## Proposal + +Two follow-up stages, each independently shippable. The former stage 2 (bash-tool parity twin) shipped with the [pwsh tool bash parity decision](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md): `tool-pwsh` now mirrors `tool-bash` for foreground and background work minus the sandbox surface, shares the `DSH_*` environment through `dsh-bash-env`, and carries a keyless application snapshot of its assembled surface. + +1. **Windows default composition** — the shipped CLI compositions mount `dsh-pwsh-local` as the `ctx.bash` executor and `dsh-tool-pwsh` as the model-facing shell tool on Windows hosts (bash unmounted there), while POSIX hosts keep the bash stack. This is a composition/roster decision in `base.cordis.yml` and the surface overlays, gated by platform; it makes the shipped Windows experience PowerShell-native end to end. +2. **pwsh TUI/GUI rendering** — the TUI and Web surfaces render pwsh output with PowerShell-aware presentation (native path display, `$env:` facts), the counterpart of the bash terminal cards. This is where terminal/console rendering conventions get a PowerShell twin. + +The stages are deliberately sequenced: composition first (a Windows user gets PowerShell without choosing), then rendering. Nothing in this proposal changes POSIX behavior. + +## Alternatives considered + +**Default Windows to pwsh inside `dsh-bash-local` (one executor, dialect switch).** Rejected for the same reason the executor decision rejected a mode switch: the executor's identity is the shell it spawns, and platform-gated composition is a deployment choice, not an executor config. + +**Ship the Windows default in the same change as the executor/tool.** Rejected: the roster change needs its own evidence (what breaks when the shipped Windows tree stops mounting bash, which tools depend on bash semantics), and it belongs to a composition decision with the approval/PTY surface visible. + +**Keep bash on Windows via a shim and skip PowerShell defaults.** Rejected: it perpetuates the install-tax and the dialect mismatch the roadmap exists to remove; the shim is a deployment requirement, not a product behavior. + +## Acceptance criteria + +- A Windows host running the shipped `dsh` TUI/Web gets `pwsh` as its shell tool and PowerShell as the `ctx.bash` executor without configuration, and `bash` is absent from the model-visible roster there. +- POSIX hosts are byte-for-byte unaffected (same roster, same executor). +- The shipped-composition e2es assert the platform-gated roster on both families. +- Stage 1 lands with the keyless pwsh-tool snapshot already in place from the parity change; stage 2 lands with TUI/Web rendering snapshots for pwsh output. + +## Risks + +- **Bash-dependent composition rows** — any shipped plugin that assumes `bash` semantics (hook bridges executing shell hooks, workspace tooling) must be audited per stage; the audit may force a staged rollout rather than one switch. +- **Windows CI coverage gap** — unit coverage runs on Linux; Windows-only regressions in the pwsh stack surface through the Windows build/static lane and e2es, which must be extended per stage rather than assumed. +- **Rendering conventions** — a PowerShell twin for terminal cards is a UI design decision with snapshot surface; deferring it (stage 2) keeps stage 1 shippable without UI churn. diff --git a/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.zh.md b/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.zh.md new file mode 100644 index 0000000000..079c1e3cac --- /dev/null +++ b/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.zh.md @@ -0,0 +1,39 @@ +# Agent Note: Windows 默认改用 pwsh(路线图) + +Status: proposed + +[English](2026-08-01-windows-pwsh-default.md) | 中文 + +## 问题 + +harness 交付的执行画像在每个平台都是 bash 优先。Windows 主机必须安装 bash 垫片(WSL 或 Git-Bash),或退回到仅 POSIX 的 `dsh-bash-local` 行为;面向模型的 bash 工具教的是 bash 方言,TUI/Web 表面以 bash 形状的预期渲染终端输出。第一块 Windows 原生基础已随 [pwsh 执行器与工具决策](../../implemented/feature/2026-08-01-pwsh-tool-and-executor.md) 交付:`ctx.bash` seam 的 PowerShell 实现与对等的 `pwsh` 工具——但还没有任何东西让 Windows 主机默认使用它们。 + +## 提案 + +两个阶段,各自可独立交付。原阶段 2(bash 工具对等孪生)已随 [pwsh 工具与 bash 对齐决策](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) 交付:`tool-pwsh` 现在在前台与后台工作(减 sandbox 面)上镜像 `tool-bash`,通过 `dsh-bash-env` 共享 `DSH_*` 环境,并携带其组装表面的 keyless 应用快照。 + +1. **Windows 默认组合**——交付的 CLI 组合在 Windows 主机上挂载 `dsh-pwsh-local` 作为 `ctx.bash` 执行器、`dsh-tool-pwsh` 作为面向模型的 shell 工具(那里不挂载 bash),POSIX 主机保持 bash 栈。这是 `base.cordis.yml` 与 surface 覆盖层里按平台门控的组合/清单决策;它让交付的 Windows 体验端到端 PowerShell 原生。 +2. **pwsh TUI/GUI 渲染**——TUI 与 Web 表面以 PowerShell 感知的呈现渲染 pwsh 输出(原生路径显示、`$env:` 实情),即 bash 终端卡片的对应物。这是终端/控制台渲染约定获得 PowerShell 孪生的地方。 + +各阶段刻意排序:先组合(Windows 用户无需选择即获得 PowerShell),再渲染。本提案不改变任何 POSIX 行为。 + +## 备选方案 + +**在 `dsh-bash-local` 内部让 Windows 默认 pwsh(一个执行器,方言开关)。** 否决,理由与执行器决策否决模式开关相同:执行器的身份就是它 spawn 的 shell,而按平台门控的组合是部署选择,不是执行器配置。 + +**把 Windows 默认与执行器/工具一起交付。** 否决:清单变更需要自己的证据(交付的 Windows 树停挂 bash 后什么会坏、哪些工具依赖 bash 语义),并且它属于带批准/PTY 表面可见的组合决策。 + +**用垫片在 Windows 上保留 bash,跳过 PowerShell 默认。** 否决:这延续了安装税与路线图要消除的方言错配;垫片是部署要求,不是产品行为。 + +## 验收标准 + +- 运行交付版 `dsh` TUI/Web 的 Windows 主机无需配置即获得 `pwsh` 作为其 shell 工具、PowerShell 作为 `ctx.bash` 执行器,且那里的模型可见清单中没有 `bash`。 +- POSIX 主机逐字节不受影响(清单相同,执行器相同)。 +- 交付组合 e2e 在两个平台族上断言按平台门控的清单。 +- 阶段 1 落地时,parity 变更带来的 keyless pwsh 工具快照已经就位;阶段 2 附带 pwsh 输出的 TUI/Web 渲染快照落地。 + +## 风险 + +- **依赖 bash 的组合行**——任何假设 bash 语义的交付插件(执行 shell hooks 的 hooks 桥、工作区工具)必须按阶段审计;审计可能迫使分阶段推出而非一次切换。 +- **Windows CI 覆盖缺口**——单元覆盖在 Linux 上运行;pwsh 栈里仅 Windows 的回归通过 Windows 构建/静态通道与 e2e 浮出,必须按阶段扩展而不是想当然。 +- **渲染约定**——终端卡片的 PowerShell 孪生是带快照表面的 UI 设计决策;把它延期(阶段 2)让阶段 1 无需 UI 翻动即可交付。 diff --git a/.agents/notes/proposed/process/2026-08-04-artifact-first-npm-baseline-publication.i18n.yaml b/.agents/notes/proposed/process/2026-08-04-artifact-first-npm-baseline-publication.i18n.yaml new file mode 100644 index 0000000000..5418a920e5 --- /dev/null +++ b/.agents/notes/proposed/process/2026-08-04-artifact-first-npm-baseline-publication.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/proposed/process/2026-08-04-artifact-first-npm-baseline-publication.md +2026-08-04-artifact-first-npm-baseline-publication.md: e79a988531ac266d72f7d2c036bdd10f3e4520a9 +2026-08-04-artifact-first-npm-baseline-publication.zh.md: d7322547c665700aeffce77ea0bb39f622220fe3 diff --git a/.agents/notes/proposed/process/2026-08-04-artifact-first-npm-baseline-publication.md b/.agents/notes/proposed/process/2026-08-04-artifact-first-npm-baseline-publication.md new file mode 100644 index 0000000000..e79a988531 --- /dev/null +++ b/.agents/notes/proposed/process/2026-08-04-artifact-first-npm-baseline-publication.md @@ -0,0 +1,114 @@ +# Agent Note: Artifact-first NPM baseline publication + +Status: proposed + +English | [中文](2026-08-04-artifact-first-npm-baseline-publication.zh.md) + +## Problem + +Runnable source in the monorepo does not prove that published packages are runnable. Workspace links, TypeScript paths, tsx source loading, and residual `lib/` files in the working tree can supply files or dependencies that are absent from a published tarball. Existing built-artifact tests still read `lib/` directly from the working tree, so they do not verify what `package.json#files` selects or the layout produced by package-manager installation. An execution that succeeds in development mode can therefore be published without a required bundle chunk, declaration, configuration file, or asset. + +Publishing many mutually dependent `@deepseek-ai` packages also creates a set-consistency problem. If a script publishes each package immediately after packing it, a later pack or validation failure leaves the first part of an unusable baseline in the registry. The npm registry has no cross-package transaction, so “publish once” cannot promise an atomic commit. It can promise that the complete publication set is packed and validated before any remote write, then published from that immutable set by one resumable orchestration command. + +The current baseline also requires a person to derive versions, authenticate, pack, publish, and retry from a local machine. A later GitHub Actions workflow must reuse the same release bundle and validation logic. It must not rebuild a different, untested set of tarballs after publication is approved. + +## Proposal + +The publication flow uses an immutable release bundle as its boundary. The pack phase builds every target package from one fixed Git commit, creates every tarball, validates tarball contents, and passes an installed-artifact integration test. The publish phase reads only those tarballs and their manifest and is forbidden from rebuilding or repacking. + +The target set contains only `@deepseek-ai/*` workspace packages discovered from `packages/*/*/package.json` and `apps/*/package.json`. The root project, `website/`, vendor, Python, and native workspaces are outside this NPM baseline. Discovery must reject duplicate names, mixed base versions, an unexpected publication privacy state, and unknown packages in the bundle instead of relying on another hand-maintained package-name list. + +The prerelease version consists of the package stable base version, a second-precision UTC timestamp captured when the command starts, and the target commit's 10-character short SHA: `--`. The dist-tag is derived as `dev-`. For example, base `0.0.1`, time `2026-08-04T00:32:00Z`, and commit `909292dd7b` produce version `0.0.1-20260804003200-909292dd7b` and tag `dev-0.0.1`. Retrying one release bundle must retain its version and manifest; repacking creates a version from the new command start time. + +The pack phase runs in this order: + +1. Resolve the ref to an immutable commit, capture the UTC timestamp, derive the version from that commit's root manifest, and display the commit, timestamp, version, tag, registry, and output path. Both `pack` and `release` wait for Enter at this point before expensive work; `--yes` skips this confirmation for automation. +2. Install the frozen lockfile in an isolated detached worktree and run source-manifest publication constraints before staging. Uncommitted files and old build output from the caller's working tree must not affect publication. +3. Stage every target manifest with the derived version, remove its publication-time `private` marker, and rewrite internal workspace dependencies in `dependencies`, `devDependencies`, `optionalDependencies`, and `peerDependencies` to the same exact version. +4. Build the target commit completely, then run publint and built-package invariants. +5. Pack every package in the target set without performing a registry write. +6. Inspect each tarball's package manifest, file inventory, internal dependency versions, name, and version, rejecting missing, duplicate, or extra tarballs. +7. Generate a release manifest and checksums containing the commit, version, tag, registry, and each package's tarball path, SHA-256, and npm integrity. +8. Install an isolated consumer from the local tarballs and run the installed-artifact probes available in the current implementation; expand those probes to the complete artifact-plane integration matrix defined below. +9. Print one directly executable publish command only after the complete set passes. The pack command itself always remains free of remote writes. + +The local `release` command composes pack and publish. It first uses the pack confirmation above to fix the expected timestamp and version, then waits for Enter again after a successful pack before publishing the same manifest; `release --yes` skips both confirmations. Separate `pack` and `publish --manifest` operations remain the primitives used by split CI jobs and recovery. + +## Current implementation boundary + +The checked-in pack command implements fixed-commit staging, exact internal dependency pins, static and tarball payload checks, the immutable manifest, and an isolated npm installation with every release tarball as a local top-level dependency. It runs the installed `dsh --version` and `dsh --dump-default-config` entries under plain Node, then starts the installed default TUI in a POSIX PTY, waits for its `main-session-` ready signal, and exits through `/exit` before printing the publish command. Publish supports integrity-based resumption, separates read-only registry verification from the authenticated identity check, and finishes with a complete remote integrity and dist-tag verification pass. + +Pull-request CI does not invoke the pack command; the installed-entry probes are local release checks rather than merge gates. Credential-free CI execution, package-owned probes for every other bin and public runtime entry, workflow-artifact transfer, and the protected publication job remain proposal scope. + +## Publication payload contract + +Published packages contain only build artifacts required by consumers. `package.json#files` must not include `src` or `lib/types/**/*.d.ts.map`; an independent tarball-content gate must also confirm that no `package/src/**` or `package/**/*.d.ts.map` entry exists, preventing manifest patterns or pack behavior from bypassing the static constraint. Runtime JavaScript, `.d.ts` declarations, configuration, assets, worker files, and dynamic bundle chunks must cover the actual entrypoint closure. + +Source manifests may retain `exports["./src/*"]` for this repository's source-plane resolution. That export does not place source in the publication payload and is not a consumer contract of the published package. Static gates must check the source plane and publication payload separately: deleting the source export must not hide broken workspace resolution, and publishing `src` must not repair missing build artifacts. + +Every tarball must be free of `workspace:` specifiers, and every internal dependency and peer dependency that points into the publication set must equal the exact derived version; `^`, `~`, and other semver ranges must not cross commit baselines. Except for `exports["./src/*"]`, which is explicitly source-plane-only, every consumer entry declared by the package manifest must point to a file present in the tarball. Dynamic imports, runtime-computed paths, and non-exported assets cannot be validated solely from the manifest and require installed execution. + +## Artifact-plane integration test + +The integration test runs after all tarballs exist and before any publish. It creates a fresh temporary project outside the monorepo, installs the declared dependency closure through local `.tgz` files from the release manifest, and executes from that installation. The test must use plain Node and package-manager-produced `node_modules`; tsx, tsconfig paths, workspace links, repository source paths, working-tree `lib/`, and the same version from the published registry are forbidden resolution inputs. The test also asserts that critical modules and bins resolve to real paths inside the temporary consumer. + +Installation uses the client behavior selected for this publication. Registry uploads must use the `npm` CLI because the private registry accepts only the npm client; pnpm may still orchestrate builds. Tarball tests must neither publish these packages to the real registry first nor repack after testing. + +The test covers at least these execution surfaces: + +- Installed `@deepseek-ai/dsh` runs `dsh --version` and `dsh --dump-default-config` successfully under plain Node, covering the static CLI entry and one dynamic mode entry. +- Installed default `dsh` completes one keyless TUI startup in a PTY and exits under test control after reaching a defined ready signal. This path must load the real TUI dynamic chunk, so a missing publication file such as `lib/tui-*.js` fails the gate. +- Every other published `bin` defines a package-owned smoke command that neither reaches a real service nor modifies user state. Different CLIs are not forced to share `--help`; the test runs the actual installed entry and checks its agreed exit or ready signal. +- Node-compatible public runtime entries load from the installation. Browser, worker, or host-protocol-only entries use matching isolated fixtures, but their inputs must still be the current tarballs exclusively. + +These tests prove executability; they do not replace unit tests, snapshots, real-API e2e, or publint. Test fixtures should reuse behavior assertions from existing built-bin and PTY scenarios while changing the entry to the tarball installation. A test that runs working-tree `lib/bin.js` directly does not satisfy this gate. + +## Publication and recovery + +The publish command validates the release manifest, every local checksum, the target registry, `npm ping`, and `npm whoami` before uploading tarballs in a deterministic order. It accepts only a manifest created by the pack phase, never workspace directories. The default registry is `https://registry.npm.harnessment.com/`; every publish passes the registry and derived tag explicitly so a user-level `.npmrc` cannot redirect the operation. + +npm provides no multi-package atomic transaction, so uploads still occur package by package. The orchestrator reduces the failure surface through idempotent recovery: upload when remote `@` does not exist; skip when it exists with the release manifest's integrity; fail immediately when it exists with different content. Dist-tag inspection reads tag assignments without resolving a default tag's target, so an unrelated dangling tag cannot block recovery. At completion it must confirm every package version's integrity and every dist-tag against the release version. The workflow reports success only when the complete set passes final verification. + +If pack, tarball inspection, or installed-artifact integration fails, the registry must receive zero writes. If publish fails after a partial upload, the operator reruns publish with the same release manifest and must not repack into another timestamped version instead of recovering. Only a code or build-input change that requires different tarballs reruns the complete pack and test flow. + +## GitHub Actions integration + +GitHub Actions separates a credential-free pack-and-test job from a protected publish job. The first checks out the exact commit, invokes the same pack entry used locally, runs tarball-consumer tests, and uploads the complete release bundle as a workflow artifact. The second depends on the first, downloads that workflow artifact, revalidates the manifest and checksums, and invokes the same publish entry. It cannot rebuild after checkout. + +Pull requests and ordinary pushes may run the credential-free pack-and-test signal so payload regressions surface before merge. Actual private-registry publication starts as `workflow_dispatch` with only a target ref as input; the pack job creates the UTC timestamp, while the base version, short SHA, tag, registry, and package inventory derive from repository state or version-controlled configuration. Stable-release triggering is outside this baseline proposal. + +The registry token is injected only into the publish job, which uses a protected GitHub Environment to govern human approval, allowed branches or tags, and concurrency. The pack-and-test job cannot read publication credentials. The workflow artifact may have a short retention period, but the publish job must use the bundle produced by the same workflow run instead of locating tarballs from an untrusted source by version. + +## Alternatives considered + +**Publish recursively from the workspace.** Rejected because it interleaves packing and registry writes, cannot prove the complete set before the first write, and allows workspace resolution and caller working-tree state to influence publication. + +**Test only built `lib/` in the working tree.** Rejected because that validates the build tree rather than the tarball selected by `package.json#files`. A dynamic chunk present in the working tree but omitted from the tarball is exactly the failure this proposal must catch. + +**Run only `dsh --help`.** Rejected because Commander can print help and exit before loading the TUI, Web, or headless dynamic entry. It does not prove the default production startup path is complete. + +**Publish `src` and declaration maps to reduce missing-file risk.** Rejected because the source plane is not a production-runtime fallback. Expanding the payload hides bundle-closure errors and turns local debugging outputs into accidental publication contracts. + +**Require truly atomic cross-package publication.** Rejected because the npm registry has no such transaction. An immutable release bundle, complete pre-publication validation, integrity comparison, and idempotent recovery provide an implementable boundary while retaining the explicit limitation that partial uploads can be briefly visible. + +**Rebuild in the publish job after approval.** Rejected because the tested and uploaded tarballs would lose content identity. The workflow artifact and checksums must carry the test inputs directly into publication. + +## Acceptance criteria + +- One pack entry discovers every target under `packages/*/*` and `apps/*` from a fixed commit, derives and displays a version from the UTC second and short commit before waiting for Enter, generates the complete release bundle before any registry write, and prints one copyable publish command; `release` waits again after packing, while `--yes` skips both confirmations. +- Static manifest and tarball-content gates both reject published `src` and `.d.ts.map` while source manifests retain `exports["./src/*"]`. +- The release bundle records the complete package set, commit, derived version, tag, registry, and per-tarball integrity; every internal dependency is pinned exactly to that version, and publish consumes only that bundle without rebuilding. +- An isolated integration test installs from local tarballs and starts the installed default `dsh` TUI under plain Node; deleting any required dynamic chunk makes the test fail deterministically. +- Every published bin and applicable public runtime entry has post-tarball-install execution coverage, with resolution paths proving that no monorepo fallback occurred. +- Publish safely resumes from the same manifest after partial success: matching integrity is skipped, conflicting integrity is rejected, and final verification requires every version and tag to agree. +- A credential-free GitHub Actions job creates and tests the bundle, a protected job uploads the identical bundle, and the publication token exists only in the latter. + +## Risks + +Full packing, installation, and startup add CI time and workflow-artifact volume. The implementation should cache external dependencies and the pnpm store, but it must not cache or reuse installed workspace output for target packages. Safe consumer probes can run in parallel to reduce latency. + +Installing every tarball as a temporary project's top-level dependency can hide an undeclared internal dependency. The test generator should install each tested application's declared recursive closure and retain existing dependency gates. For `@deepseek-ai/dsh`, whose dependency surface approaches the full set, package-manifest and static graph checks remain necessary to detect undeclared edges. + +Platform-specific optional dependencies, native addons, PTYs, and browser entries may require platform-owned probes. The first phase must cover primary `dsh` startup on the publication Linux runner and one local macOS path, then expand the matrix with the actual publication platforms. Skipping an unstable probe must not move a production path outside the gate. + +Recovery cannot remove npm's partial visibility. During a failed publication, the registry may briefly contain only some package versions from the bundle. Operators and automation must treat the final bundle verification, not one successful `npm publish`, as the baseline-availability signal. diff --git a/.agents/notes/proposed/process/2026-08-04-artifact-first-npm-baseline-publication.zh.md b/.agents/notes/proposed/process/2026-08-04-artifact-first-npm-baseline-publication.zh.md new file mode 100644 index 0000000000..d7322547c6 --- /dev/null +++ b/.agents/notes/proposed/process/2026-08-04-artifact-first-npm-baseline-publication.zh.md @@ -0,0 +1,114 @@ +# Agent Note: 以产物为先的 NPM 基线发布 + +Status: proposed + +[English](2026-08-04-artifact-first-npm-baseline-publication.md) | 中文 + +## 问题 + +monorepo 中可运行的源码并不能证明发布后的包可运行。workspace link、TypeScript paths、tsx 源码加载和工作树里残留的 `lib/` 都可能补上发布 tarball 中缺失的文件或依赖。即使现有构建产物测试使用普通 Node,它仍直接读取工作树中的 `lib/`,没有验证 `package.json#files` 最终选中了什么,也没有验证包管理器安装后的文件布局。一次开发模式正常的执行因此可能发布为缺少 bundle chunk、声明文件、配置或资源的包。 + +发布多个互相依赖的 `@deepseek-ai` 包还会产生集合一致性问题。如果脚本每 pack 一个包就立即 publish,那么后续 pack 或验证失败时,注册表中已经存在无法作为完整基线使用的前半组版本。npm 注册表没有跨包事务,因此这里的「一次性发布」不能承诺原子提交,只能承诺在任何远端写入前完整生成并验证发布集合,再由一个可恢复的编排命令发布这个不可变集合。 + +当前基线还需要人工在本机完成版本派生、认证、pack、发布和重试。后续 GitHub Actions 工作流必须复用同一套发布包与验证逻辑,不能在批准发布后重新构建另一组未经消费方测试的 tarball。 + +## 提案 + +发布流程以一个不可变的 release bundle(发布包集合)为边界。pack 阶段从一个确定的 Git commit 构建全部目标包、生成全部 tarball、检查 tarball 内容,并通过安装后集成测试;publish 阶段只读取这组 tarball 及其 manifest(元数据清单),禁止重建或重新 pack。 + +目标集合只包含 `packages/*/*/package.json` 与 `apps/*/package.json` 中命名为 `@deepseek-ai/*` 的 workspace 包。根项目、`website/`、vendor、Python 与 native workspace 不属于该 NPM 基线。发现机制必须拒绝重复包名、不同基础版本、意外的 `private` 发布状态以及集合中的未知包,而不是维护另一份手工包名列表。 + +预发布版本由包的稳定基础版本、命令启动时精确到秒的 UTC 时间戳和目标 commit 的 10 位短 SHA 组成:`--`。dist-tag 由基础版本派生为 `dev-`。例如,基础版本 `0.0.1`、时间 `2026-08-04T00:32:00Z` 和 commit `909292dd7b` 生成版本 `0.0.1-20260804003200-909292dd7b` 与 tag `dev-0.0.1`。同一 release bundle 的重试必须沿用原版本和 manifest;重新 pack 会按新的命令启动时间生成新版本。 + +pack 阶段按以下顺序执行: + +1. 将 ref 解析成不可变 commit,采集 UTC 时间戳,从该 commit 的根 manifest 派生版本,并显示 commit、时间戳、版本、tag、注册表和输出路径。`pack` 与 `release` 此时都会在昂贵操作开始前等待 Enter;自动化可用 `--yes` 跳过该确认。 +2. 在隔离的 detached worktree 中安装 frozen lockfile,并在暂存发布 manifest 之前运行源码 manifest 发布约束;调用方工作树中的未提交文件和旧构建输出不得参与发布。 +3. 将所有目标 manifest 暂存为派生版本,移除发布时的 `private` 标记,并把 `dependencies`、`devDependencies`、`optionalDependencies` 与 `peerDependencies` 中的内部 workspace 依赖全部改写为同一精确版本。 +4. 完整构建目标 commit,再运行 publint 和已构建包不变式。 +5. 为目标集合中的每个包执行 pack,但不执行任何注册表写入。 +6. 检查 tarball 内的 package manifest、文件清单、内部依赖版本、包名和版本,并拒绝缺失、重复或额外的 tarball。 +7. 生成包含 commit、版本、tag、注册表、每个包的 tarball 路径、SHA-256 与 npm integrity 的 release manifest 和校验和文件。 +8. 从本地 tarball 安装一个隔离消费方,运行当前实现已有的安装态产物探测,并将这些探测扩展为下文定义的完整产物平面集成测试矩阵。 +9. 仅当整个集合通过时输出一个可直接执行的 publish 命令;pack 命令本身始终保持无远端写入。 + +本地 `release` 命令组合 pack 与 publish。它先通过上述 pack 确认确定预期时间戳和版本,再在 pack 成功后等待第二次 Enter,随后发布同一 manifest;`release --yes` 跳过两次确认。独立的 `pack` 与 `publish --manifest` 仍是 CI 分 job 和断点恢复使用的基础操作。 + +## 当前实现边界 + +已提交的 pack 命令实现了固定 commit 暂存、内部依赖精确固化、静态与 tarball payload 检查、不可变 manifest,以及把每个发布 tarball 都作为本地顶层依赖的隔离 npm 安装。它在输出 publish 命令前,用普通 Node 运行安装后的 `dsh --version` 与 `dsh --dump-default-config` 入口,再在 POSIX PTY 中启动安装后的默认 TUI,等待其 `main-session-` 就绪信号,并通过 `/exit` 退出。Publish 支持按 integrity 恢复,将只读注册表验证与认证身份检查分离,并以完整的远端 integrity 和 dist-tag 验证结束。 + +拉取请求 CI 不会调用 pack 命令;安装态入口探测属于本地发布检查,而不是合并门禁。免凭据 CI 执行、其他每个 bin 与公开运行时入口的包自有探测、workflow artifact 传递及受保护 publish job 仍属于提案范围。 + +## 发布 payload 契约 + +发布包只携带消费方需要的构建产物。`package.json#files` 禁止包含 `src` 和 `lib/types/**/*.d.ts.map`;tarball 内容门禁还要独立确认不存在任何 `package/src/**` 与 `package/**/*.d.ts.map`,避免 manifest pattern 或 pack 行为绕过静态约束。运行时 JS、声明文件 `.d.ts`、配置、资源、worker 文件和 bundle 动态 chunk 必须按实际入口闭包收齐。 + +源码 manifest 可以保留 `exports["./src/*"]`,供本仓库的源码平面解析使用;该 export 不代表源码会进入发布 payload,也不属于已发布包的消费方契约。静态门禁必须分别检查源码平面与发布 payload,不能通过删除 source export 来掩盖错误的 workspace 解析,也不能通过发布 `src` 来修补缺失的构建产物。 + +每个 tarball 必须不含 `workspace:` specifier,并且所有指向本次发布集合的内部依赖与对等依赖(peer dependency)都必须精确等于本次派生版本,禁止使用 `^`、`~` 或其他 semver 范围跨越 commit 基线。除了明确仅供源码平面使用的 `exports["./src/*"]`,package manifest 中声明的每个消费方入口都必须指向 tarball 内存在的文件;动态 import、运行时拼接路径和非 export 资源不能只靠 manifest 检查,必须由安装后执行覆盖。 + +## 产物平面集成测试 + +集成测试在全部 tarball 生成后、任何 publish 之前运行。它在 monorepo 外创建一个全新临时项目,通过本次 release manifest 中的本地 `.tgz` 文件安装声明依赖闭包,并从安装目录执行。测试必须使用普通 Node 与包管理器生成的 `node_modules`;禁止 tsx、tsconfig paths、workspace link、仓库源码路径、工作树 `lib/` 和已发布注册表中的同版本包参与解析。测试还要断言关键模块与 bin 的真实路径位于临时消费方内。 + +安装使用本次发布选择的客户端行为。注册表上传必须使用 `npm` CLI,以满足私有注册表只接受 npm 客户端的策略;构建编排仍可使用 pnpm。tarball 测试不得先把这些包发布到真实注册表,也不得在测试后重新 pack。 + +测试至少覆盖以下执行面: + +- `@deepseek-ai/dsh` 安装后的 `dsh --version` 与 `dsh --dump-default-config` 在普通 Node 下成功,分别覆盖静态 CLI 入口和一个动态模式入口。 +- 安装后的默认 `dsh` 在 PTY 中完成一次无密钥 TUI 启动,到达既定 ready 信号后由测试受控退出。这条路径必须加载真实 TUI 动态 chunk,因此缺少类似 `lib/tui-*.js` 的发布文件会使门禁失败。 +- 每个其他已发布 `bin` 都定义一个不会访问真实服务或修改用户状态的包级冒烟命令。不同 CLI 不强制共用 `--help`;测试必须运行其真实安装入口并检查约定的退出或 ready 信号。 +- Node 兼容的公开运行时入口从安装目录加载;浏览器、worker 或必须由宿主协议驱动的入口使用对应的隔离 fixture(测试前置数据),但输入仍只能是本次 tarball。 + +这些测试验证可执行性,不替代单元测试、快照、真实 API e2e 或 publint。测试 fixture 应复用现有 built-bin 和 PTY 场景的行为断言,但必须把入口改为 tarball 安装结果;直接运行工作树 `lib/bin.js` 的测试不能算作本门禁。 + +## 发布与恢复 + +publish 命令先验证 release manifest、所有本地校验和、目标注册表、`npm ping` 和 `npm whoami`,再按确定顺序上传 tarball。命令只接受 pack 阶段生成的 manifest,不接受 workspace 目录作为发布输入。默认注册表为 `https://registry.npm.harnessment.com/`,每次 publish 都显式传入注册表和派生 tag,避免用户级 `.npmrc` 改变目标。 + +npm 不提供多包原子事务,上传仍会逐包发生。编排器通过幂等恢复缩小失败面:若远端不存在 `@` 就上传;若已存在且 integrity 与 release manifest 相同就跳过;若已存在但内容不同就立即失败。dist-tag 检查只读取 tag 映射,不解析默认 tag 指向的版本,因此即使无关 tag 指向的版本已不存在,也不会阻断恢复。完成后必须逐包确认版本 integrity 和 dist-tag 都指向本次版本,只有整个集合通过最终验证,工作流才报告发布成功。 + +如果 pack、tarball 检查或安装后集成测试失败,注册表必须保持零写入。如果 publish 在部分上传后失败,操作者使用同一 release manifest 重跑 publish 命令来恢复,不得重新 pack 并生成另一个时间戳版本来代替恢复。修复代码或改变构建输入后需要不同 tarball 时,才重新执行完整 pack 与测试。 + +## GitHub Actions 集成 + +GitHub Actions 分为无凭据的 pack-and-test job 与受保护的 publish job。前者检出精确 commit,调用与本地相同的 pack 入口,运行 tarball 消费方测试,并上传完整 release bundle 作为 workflow artifact。后者依赖前者成功,从 workflow artifact 下载 bundle、重新校验 manifest 和校验和,再调用同一个 publish 入口;它不能检出后重新构建。 + +PR 与普通 push 可以运行无凭据的 pack-and-test 信号,从而在合并前发现 payload 回归。实际私有注册表发布先通过 `workflow_dispatch` 提供,输入只包括目标 ref;UTC 时间戳由 pack job 生成,基础版本、短 SHA、tag、注册表和包清单都由仓库状态或受版本控制的配置派生。稳定发布触发方式不在本基线提案范围内。 + +注册表 token 只注入 publish job,并由受保护 GitHub Environment 控制人工批准、允许的分支或 tag 以及并发。pack-and-test job 不得读取发布凭据。workflow artifact 的保留期可以较短,但 publish job 必须使用同一 workflow run 生成的 bundle,不能按版本号从不受信任的位置寻找 tarball。 + +## Alternatives considered + +**从 workspace 直接递归 publish。** 不采用,因为命令会把 pack 与注册表写入交错,无法在第一次写入前证明整个集合完整,也容易让 workspace 解析与调用方工作树状态影响发布结果。 + +**只测试工作树中构建后的 `lib/`。** 不采用,因为这验证的是构建树,不是 `package.json#files` 选出的 tarball。工作树中存在而 tarball 中漏掉的动态 chunk 正是本提案必须捕获的失败。 + +**只运行 `dsh --help`。** 不采用,因为 Commander 可以在加载 TUI、Web 或 headless 动态入口之前输出帮助并退出。它无法证明默认生产启动路径完整。 + +**把 `src` 和声明映射一起发布以降低漏文件风险。** 不采用,因为源码平面不是生产运行时的后备路径;扩大 payload 会掩盖 bundle 闭包错误,并把本地调试产物变成无意的发布契约。 + +**要求真正的跨包原子发布。** 不采用,因为 npm 注册表没有相应事务。不可变 release bundle、发布前全量验证、integrity 比对与幂等恢复提供可实现的边界,同时明确保留部分上传短暂可见的限制。 + +**在批准后由发布 job 重新构建。** 不采用,因为测试通过的 tarball 与实际上传的 tarball 将不再具有内容身份。workflow artifact 与校验和必须把测试输入直接传给发布步骤。 + +## Acceptance criteria + +- 一个 pack 入口从确定 commit 发现 `packages/*/*` 和 `apps/*` 的全部目标包,以 UTC 秒级时间戳与短 commit 生成并显示版本,再等待 Enter;它在任何注册表写入前生成完整 release bundle,并输出一个可复制的 publish 命令;`release` 在 pack 后再次等待,`--yes` 跳过两次确认。 +- 静态 manifest 门禁和 tarball 内容门禁都拒绝发布 `src` 与 `.d.ts.map`,同时保留源码 manifest 中的 `exports["./src/*"]`。 +- release bundle 记录完整包集合、commit、派生版本、tag、注册表和逐 tarball integrity;所有内部依赖都精确固化到该版本,publish 只消费该 bundle,绝不重建。 +- 一个隔离集成测试从本地 tarball 安装消费方,并用普通 Node 启动安装后的默认 `dsh` TUI;删除任一所需动态 chunk 会使该测试稳定失败。 +- 所有已发布 bin 和适用的公开运行时入口都有 tarball 安装后的执行覆盖,且解析路径证明没有回退到 monorepo。 +- publish 可在部分成功后用同一 manifest 安全重跑;相同 integrity 被跳过,不同 integrity 被拒绝,最终验证要求所有版本与 tag 一致。 +- GitHub Actions 的无凭据 job 生成并测试 bundle,受保护 job 上传完全相同的 bundle,发布 token 只存在于后者。 + +## Risks + +全量 pack、安装和启动会增加 CI 时间与 workflow artifact 体积。实现应缓存外部依赖和 pnpm store,但不得缓存或复用目标包的已安装 workspace 输出;并行执行安全的消费方 probe 可以降低时延。 + +把所有 tarball 都安装为临时项目的顶层依赖可能掩盖未声明的内部依赖。测试生成器应按被测应用的声明式递归闭包安装,并结合现有依赖门禁;对依赖面接近全集的 `@deepseek-ai/dsh`,仍需依靠 package manifest 与静态图检查发现未声明边。 + +不同平台的 optional dependency、native addon、PTY 与浏览器入口可能需要平台专属 probe。第一阶段至少在发布所用 Linux runner 和一个本地 macOS 路径上覆盖主 `dsh` 启动,后续矩阵按实际发布平台扩展;不能用跳过不稳定 probe 的方式把生产路径移出门禁。 + +恢复机制不能消除 npm 的部分可见性。发布失败期间,注册表可能短暂含有本次版本的一部分包;操作者与自动化必须以最终 bundle 验证结果而非单个 `npm publish` 的成功作为基线可用信号。 diff --git a/AGENTS.md b/AGENTS.md index e03b128876..a42f39084a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ packages/ @deepseek-ai/dsh- workspaces at packages/// core/ product API spine: session, system-prompt, tools, agent, agent-loop typert/ type graph generator, loader, and runtime registry llm/ LLM seam + DeepSeek adapters (direct-fetch + pi-ai design twin) - bash/ bash executor seam + local impl + model-facing bash tools + bash/ bash executor seam + local/pwsh impls + model-facing shell tools subprocess/ subprocess seam + local process-tree impl pty/ persistent PTY seam/backend/tools fs/ filesystem seam + local impl + policy gate + read/write/edit tools diff --git a/apps/cli/composition.md b/apps/cli/composition.md index 0bede25716..28f58bcf4d 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -54,6 +54,8 @@ flowchart LR cfg --> plugin_dsh_base_approval plugin_dsh_base_permission["permission
@deepseek-ai/dsh-permission"] cfg --> plugin_dsh_base_permission + plugin_dsh_base_bash_env["bash-env
@deepseek-ai/dsh-bash-env"] + cfg --> plugin_dsh_base_bash_env plugin_dsh_base_tool_bash["tool-bash
@deepseek-ai/dsh-tool-bash"] cfg --> plugin_dsh_base_tool_bash plugin_dsh_base_tool_tasks["tool-tasks
@deepseek-ai/dsh-tool-tasks"] @@ -171,6 +173,7 @@ flowchart LR | `bash-sandbox` | `@deepseek-ai/dsh-bash-sandbox` | | `approval` | `@deepseek-ai/dsh-user-approval` | | `permission` | `@deepseek-ai/dsh-permission` | +| `bash-env` | `@deepseek-ai/dsh-bash-env` | | `tool-bash` | `@deepseek-ai/dsh-tool-bash` | | `tool-tasks` | `@deepseek-ai/dsh-tool-tasks` | | `fs-policy` | `@deepseek-ai/dsh-fs-policy` | diff --git a/apps/cli/config/base.cordis.yml b/apps/cli/config/base.cordis.yml index 26f59fe46c..dddf2fc1b5 100644 --- a/apps/cli/config/base.cordis.yml +++ b/apps/cli/config/base.cordis.yml @@ -173,6 +173,9 @@ sandbox: danger-full-access approval: never +- id: bash-env + name: '@deepseek-ai/dsh-bash-env' + - id: tool-bash name: '@deepseek-ai/dsh-tool-bash' diff --git a/apps/cli/package.json b/apps/cli/package.json index 0ccf4b2197..4ba1da7e86 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -8,9 +8,8 @@ "dsh": "lib/bin.js" }, "files": [ - "lib/bin.js", - "config", - "src" + "lib/*.js", + "config" ], "license": "BSD-3-Clause", "dependencies": { @@ -22,6 +21,7 @@ "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", + "@deepseek-ai/dsh-bash-env": "workspace:^", "@deepseek-ai/dsh-bash-sandbox": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-hmr": "workspace:^", @@ -75,6 +75,7 @@ "@deepseek-ai/dsh-repeat-tool-guard": "workspace:^", "@deepseek-ai/dsh-pty": "workspace:^", "@deepseek-ai/dsh-pty-local": "workspace:^", + "@deepseek-ai/dsh-pwsh-local": "workspace:^", "@deepseek-ai/dsh-repository-plugin": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", @@ -115,6 +116,7 @@ "@deepseek-ai/dsh-tool-skill": "workspace:^", "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^", "@deepseek-ai/dsh-tool-subagent": "workspace:^", + "@deepseek-ai/dsh-tool-pwsh": "workspace:^", "@deepseek-ai/dsh-tool-subagent-control": "workspace:^", "@deepseek-ai/dsh-tool-subagent-report": "workspace:^", "@deepseek-ai/dsh-tool-tasks": "workspace:^", @@ -131,7 +133,8 @@ "@deepseek-ai/dsh-workspace-context": "workspace:^", "commander": "^15.0.0", "cordis": "^4.0.0-rc.7", - "js-yaml": "^4.2.0" + "js-yaml": "^4.2.0", + "node-addon-require-builtin": "^0.1.4" }, "devDependencies": { "@types/js-yaml": "^4.0.9", diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index 08b1c323ba..d2186e097a 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -11,7 +11,7 @@ import type { Context } from 'cordis' import { addHarnessSourceSection, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' import type {} from '@deepseek-ai/dsh-host-webserver' import type {} from '@deepseek-ai/dsh-system-prompt' -import type {} from '@deepseek-ai/dsh-tool-bash' +import type {} from '@deepseek-ai/dsh-bash-env' import { AppCLIEntry } from './app-cli-entry.ts' import { createProcessShutdown } from './process-shutdown.ts' diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index b9b2eeea1f..44730e9f37 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../../packages/ui/app-boot" }, + { + "path": "../../packages/bash/bash-env" + }, { "path": "../../packages/bash/tool-bash" }, diff --git a/apps/web/package.json b/apps/web/package.json index 3c8f90b6a0..39862701b3 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -8,6 +8,9 @@ "./dist/*": "./dist/*", "./package.json": "./package.json" }, + "files": [ + "dist" + ], "scripts": { "build": "vite build", "dev": "vite", diff --git a/apps/web/tests/bash-abort-row.e2e.ts b/apps/web/tests/bash-abort-row.e2e.ts index 717cebdd36..3d48442747 100644 --- a/apps/web/tests/bash-abort-row.e2e.ts +++ b/apps/web/tests/bash-abort-row.e2e.ts @@ -56,7 +56,7 @@ describe.skipIf(MODE === 'record')('web e2e: cancelled Bash row disclosure', () const row = page.locator('[data-sample="bash"]').first() const call = row.locator('xpath=..') await expect.poll(() => row.getAttribute('aria-expanded')).toBe('false') - await expect.poll(() => call.getByText('Error: command aborted', { exact: true }).count()).toBe(1) + await expect.poll(() => call.getByText('Error: tool call aborted', { exact: true }).count()).toBe(1) await row.click() await expect.poll(() => row.getAttribute('aria-expanded')).toBe('true') @@ -64,7 +64,7 @@ describe.skipIf(MODE === 'record')('web e2e: cancelled Bash row disclosure', () await call.getByText('OUT', { exact: true }).waitFor() await call.getByText('Wait until cancellation', { exact: false }).waitFor() await call.getByText('setInterval(() => {}, 1000)', { exact: false }).waitFor() - await expect.poll(() => call.getByText('Error: command aborted', { exact: true }).count()).toBe(2) + await expect.poll(() => call.getByText('Error: tool call aborted', { exact: true }).count()).toBe(2) const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) // The borrowed fixture's UTC date is still the previous day in PDT; diff --git a/apps/web/tests/snapshots/bash-abort-row/ui.expected.md b/apps/web/tests/snapshots/bash-abort-row/ui.expected.md index 8f09d36efd..c95c6b8de6 100644 --- a/apps/web/tests/snapshots/bash-abort-row/ui.expected.md +++ b/apps/web/tests/snapshots/bash-abort-row/ui.expected.md @@ -14,10 +14,10 @@ - img - img - text: Context injection -- 'button "Failed Bash Error: command aborted" [expanded]': +- 'button "Failed Bash Error: tool call aborted" [expanded]': - img - - text: "Failed Bash Error: command aborted" -- text: "IN { \"command\": \"node -e \\\"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\\\"\", \"description\": \"Wait until cancellation\" } OUT Error: command aborted" + - text: "Failed Bash Error: tool call aborted" +- text: "IN { \"command\": \"node -e \\\"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\\\"\", \"description\": \"Wait until cancellation\" } OUT Error: tool call aborted" - button "Inspect" - 'button "Failed Bash Error: tool call aborted before dispatch"': - img diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 3976c1fb24..cb24cee7d5 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -105,6 +105,9 @@ flowchart LR pkg_subagent_acp["subagent-acp"] pkg_bash["bash"] svc_bash["ctx.bash
Bash executor seam"] + pkg_pwsh_local["pwsh-local"] + pkg_tool_pwsh["tool-pwsh"] + pkg_bash_env["bash-env"] svc_bashEnv["ctx.bashEnv
Managed bash environment registry"] pkg_pty["pty"] svc_pty["ctx.pty
Persistent PTY session registry"] @@ -167,6 +170,7 @@ flowchart LR pkg_agent_loop --> svc_agentLoop pkg_approval --> svc_approval pkg_bash --> svc_bash + pkg_bash_env --> svc_bashEnv pkg_bash_local --> svc_bash pkg_bash_sandbox --> svc_bash pkg_code_runtime --> svc_codeRuntime @@ -194,6 +198,7 @@ flowchart LR pkg_plan_mode --> svc_planMode pkg_pty --> svc_pty pkg_pty_local --> svc_pty + pkg_pwsh_local --> svc_bash pkg_sandbox --> svc_sandbox pkg_sandbox_local --> svc_sandbox pkg_sandbox_policy --> svc_sandboxPolicy @@ -231,7 +236,6 @@ flowchart LR pkg_tasks --> svc_tasks pkg_tasks_local --> svc_tasks pkg_token_meter --> svc_tokenMeter - pkg_tool_bash --> svc_bashEnv pkg_tools --> svc_tools pkg_typert_registry --> svc_typert pkg_user_interaction --> svc_userInteraction @@ -254,6 +258,9 @@ flowchart LR svc_bash --> pkg_hooks_claude svc_bash --> pkg_hooks_codex svc_bash --> pkg_tool_bash + svc_bash --> pkg_tool_pwsh + svc_bashEnv --> pkg_tool_bash + svc_bashEnv --> pkg_tool_pwsh svc_clientModuleHost --> pkg_hmr svc_codeRuntime --> pkg_tools svc_compact --> pkg_compact_basic @@ -371,8 +378,8 @@ flowchart LR | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. | | `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | Folds revisioned objective state from the session log and keeps live continuation activation process-local. | | `ctx.subprocess` | `seam` | [`subprocess`](../packages/subprocess/subprocess) | [`subprocess-local`](../packages/subprocess/subprocess-local) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox), [`lsp-local`](../packages/lsp/lsp-local), [`subagent-acp`](../packages/subagent/subagent-acp) | - | The bash executors, the LSP host, and the ACP subagent backend spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation. | -| `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox) | [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors replace bash-local without touching them. | -| `ctx.bashEnv` | `core` | [`tool-bash`](../packages/bash/tool-bash) | - | - | - | Plugins declare effect-scoped DSH_* facts; tool-bash collects one trusted snapshot per execution and the executor rebuilds the namespace. | +| `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox), [`pwsh-local`](../packages/bash/pwsh-local) | [`tool-bash`](../packages/bash/tool-bash), [`tool-pwsh`](../packages/bash/tool-pwsh), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing shell tools and hook bridges consume this seam; sandboxed, remote, or PowerShell executors replace bash-local without touching them. | +| `ctx.bashEnv` | `core` | [`bash-env`](../packages/bash/bash-env) | - | [`tool-bash`](../packages/bash/tool-bash), [`tool-pwsh`](../packages/bash/tool-pwsh) | - | Plugins declare effect-scoped DSH_* facts; each shell tool collects one trusted snapshot per execution and its executor rebuilds the namespace. | | `ctx.pty` | `seam` | [`pty`](../packages/pty/pty) | [`pty-local`](../packages/pty/pty-local) | [`tool-pty`](../packages/pty/tool-pty) | - | The registry owns exact-Agent session identity and cleanup; backends own terminal mechanics, while tool-pty exposes the owner-scoped model surface. | | `ctx.sandbox` | `seam` | [`sandbox`](../packages/sandbox/sandbox) | [`sandbox-local`](../packages/sandbox/sandbox-local) | [`bash-sandbox`](../packages/bash/bash-sandbox), [`pty-local`](../packages/pty/pty-local) | - | Consumers hand over the exact argv they are about to spawn; same-world backends wrap it under a per-call policy and report enforcement. | | `ctx.sandboxPolicy` | `core` | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | - | [`bash-sandbox`](../packages/bash/bash-sandbox), [`fs-sandbox`](../packages/fs/fs-sandbox), [`pty-local`](../packages/pty/pty-local) | - | The one home for the deployment default mode + workspace root; only the sandboxed executor and provider read the service (the tool layers use the pure `sandbox/mode` fold it also exports). Both enforcing families read it so bash and fs cannot confine to different roots. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 0c6d58384e..5d238761d9 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -192,7 +192,19 @@ export interface GoalConfig { Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`GoalDomainConfig`](#deepseek-aidsh-goal) · [`InvariantConfig`](#deepseek-aidsh-invariants) · [`SessionTitleConfig`](#deepseek-aidsh-session-title) · [`SkillLocal`](../packages/skill/skill-local/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`toolBash`](../packages/bash/tool-bash/src/index.ts) · [`toolGoal`](../packages/goal/tool-goal/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`toolTasks`](../packages/tasks/tool-tasks/src/index.ts) · [`workspaceContext`](../packages/context/workspace-context/src/index.ts) -Source: [`packages/examples/agent-spine-demo/src/index.ts:89`](../packages/examples/agent-spine-demo/src/index.ts) +Source: [`packages/examples/agent-spine-demo/src/index.ts:90`](../packages/examples/agent-spine-demo/src/index.ts) + +## `@deepseek-ai/dsh-bash-env` + +```ts config-catalog +/** Plugin config (all optional — the built-in facts resolve without defaults). */ +export interface Config { + /** DeepSeek Harness home directory exposed as `DSH_HOME`; defaults to `$DSH_HOME` or `~/.dsh`. */ + dshHome?: string +} +``` + +Source: [`packages/bash/bash-env/src/index.ts:29`](../packages/bash/bash-env/src/index.ts) ## `@deepseek-ai/dsh-bash-local` @@ -963,6 +975,37 @@ export interface Config { Source: [`packages/pty/pty-local/src/config.ts:6`](../packages/pty/pty-local/src/config.ts) +## `@deepseek-ai/dsh-pwsh-local` + +Requires: `subprocess` + +```ts config-catalog +/** Plugin config (all optional — `static Config` supplies the defaults). */ +export interface Config { + /** Default working directory for commands (default: process.cwd()). */ + cwd?: string + /** Default foreground timeout in milliseconds. */ + timeoutMs?: number + /** Upper bound for per-call timeout overrides. */ + maxTimeoutMs?: number + /** Per-stream in-memory output cap; overflow spills to a temp file. */ + maxOutputBytes?: number + /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */ + maxSpillBytes?: number + /** Grace period for kill escalation and for inherited pipes after shell exit. */ + graceMs?: number + /** + * Explicit pwsh executable. When omitted, well-known Windows install + * locations and PATH entries are probed in order (PowerShell 7 install, + * PATH entries such as the Microsoft Store install, then Windows + * PowerShell 5.1), falling back to a bare `pwsh` resolved through PATH. + */ + pwshPath?: string +} +``` + +Source: [`packages/bash/pwsh-local/src/index.ts:54`](../packages/bash/pwsh-local/src/index.ts) + ## `@deepseek-ai/dsh-repeat-tool-guard` ```ts config-catalog @@ -1663,19 +1706,17 @@ Source: [`packages/llm/token-meter/src/types.ts:12`](../packages/llm/token-meter ## `@deepseek-ai/dsh-tool-bash` -Requires: `tools` · `bash` · `systemPrompt` +Requires: `tools` · `bash` · `systemPrompt` · `bashEnv` ```ts config-catalog -/** Configuration for the bash tool and its managed child environment. */ +/** Configuration for the bash tool. */ export interface Config { /** Expose `run_in_background` (default true); disabled calls are also rejected. */ enableRunInBackground?: boolean - /** DeepSeek Harness home directory exposed as `DSH_HOME`; defaults to `$DSH_HOME` or `~/.dsh`. */ - dshHome?: string } ``` -Source: [`packages/bash/tool-bash/src/index.ts:41`](../packages/bash/tool-bash/src/index.ts) +Source: [`packages/bash/tool-bash/src/index.ts:34`](../packages/bash/tool-bash/src/index.ts) ## `@deepseek-ai/dsh-tool-bash-persistent` @@ -1813,6 +1854,20 @@ export interface Config { Source: [`packages/pty/tool-pty/src/index.ts:35`](../packages/pty/tool-pty/src/index.ts) +## `@deepseek-ai/dsh-tool-pwsh` + +Requires: `tools` · `bash` · `systemPrompt` · `bashEnv` + +```ts config-catalog +/** Configuration for the pwsh tool. */ +export interface Config { + /** Expose `run_in_background` (default true); disabled calls are also rejected. */ + enableRunInBackground?: boolean +} +``` + +Source: [`packages/bash/tool-pwsh/src/index.ts:41`](../packages/bash/tool-pwsh/src/index.ts) + ## `@deepseek-ai/dsh-tool-ralph` Requires: `tools` · `workflows` · `subagents` · `systemPrompt` diff --git a/docs/cookbook/adding-a-package.i18n.yaml b/docs/cookbook/adding-a-package.i18n.yaml index e0f03807df..6232e2155d 100644 --- a/docs/cookbook/adding-a-package.i18n.yaml +++ b/docs/cookbook/adding-a-package.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cookbook/adding-a-package.md -adding-a-package.md: 2dd9165c4b5a7e04ecc7af0507f364fe89b294bb -adding-a-package.zh.md: 2a1c25c3ac4dc2ead06ce685746bc2beb752e3e2 +adding-a-package.md: 5df22b23a69e2a33a5d963970c771088ed7f3b5d +adding-a-package.zh.md: e573657988bd67f5ff9b9191550c10bc654ef116 diff --git a/docs/cookbook/adding-a-package.md b/docs/cookbook/adding-a-package.md index 2dd9165c4b..5df22b23a6 100644 --- a/docs/cookbook/adding-a-package.md +++ b/docs/cookbook/adding-a-package.md @@ -23,7 +23,7 @@ packages/// Choose an existing group when one matches the package's role (`core`, `llm`, `bash`, `compact`, `subagent`, `todo`, `session-persistence`, `ui`, `util`, or `support`). A new group is allowed, but it is a pure container: no `package.json`, no source files, and packages still sit exactly one level below it. -package.json invariants (enforced by `pnpm run constraints` / `scripts/check-workspace-constraints.ts`): `private: true`, a `version` matching the root `package.json`, `type: module`, `main: "lib/index.js"`, `types: "lib/types/index.d.ts"`, `exports["."].types: "./lib/types/index.d.ts"`, `exports["."].default: "./lib/index.js"`, `cordis` in BOTH peerDependencies and devDependencies (same range). Mirror every dsh peer dependency in devDependencies. `schemastery` goes in `dependencies` (it is a runtime validator), matching agent-loop. The `files` list is precise: `lib/index.js`, `lib/types/**/*.d.ts`, `lib/types/**/*.d.ts.map`, and `src`; do not publish `lib/types` JS or JS-map intermediates or stale root declaration files. CLI app packages with a package `bin` include `lib/bin.js` immediately after `lib/index.js` in `files`. +package.json invariants (enforced by `pnpm run constraints` / `scripts/check-workspace-constraints.ts`): `private: true`, a `version` matching the root `package.json`, `type: module`, `main: "lib/index.js"`, `types: "lib/types/index.d.ts"`, `exports["."].types: "./lib/types/index.d.ts"`, `exports["."].default: "./lib/index.js"`, `cordis` in BOTH peerDependencies and devDependencies (same range). Mirror every dsh peer dependency in devDependencies. `schemastery` goes in `dependencies` (it is a runtime validator), matching agent-loop. The `files` list contains exactly `lib/index.js`, `lib/invariant.js`, `lib/types/**/*.d.ts`, and package-specific runtime artifacts recognized by the gate; a package whose runtime export points into the emitted tree also includes `lib/types/**/*.js`. Do not publish `src`, declaration maps, JS maps, or stale root declaration files. CLI app packages with a package `bin` include `lib/bin.js` immediately after `lib/index.js` in `files`. In-package relative imports use explicit `.ts` specifiers in source (for example, `export * from './types.ts'`). The compiler rewrites those to `.js` in emitted JS and leaves explicit `.ts` specifiers in declarations, which standard NodeNext/Node16 TypeScript consumers resolve to the sibling `.d.ts` files. diff --git a/docs/cookbook/adding-a-package.zh.md b/docs/cookbook/adding-a-package.zh.md index 2a1c25c3ac..e573657988 100644 --- a/docs/cookbook/adding-a-package.zh.md +++ b/docs/cookbook/adding-a-package.zh.md @@ -1,8 +1,8 @@ -# 实操手册:添加 workspace 包 +# 实操手册:添加 workspace 包(package) [English](adding-a-package.md) | 中文 -为新建 `@deepseek-ai/dsh-` 包提供的逐文件清单。本清单以 bash 和适配器这两个包为模板进行验证;如果清单与模板有出入,请在此修正。 +为新建 `@deepseek-ai/dsh-` 包提供的逐文件清单。本清单以 bash 和 adapter 这两个包为模板进行验证;如果清单与模板有出入,请在此修正。 ## 1. 创建包 @@ -23,7 +23,7 @@ packages/// 当已有分组与包的角色匹配时,选择该分组(`core`、`llm`、`bash`、`compact`、`subagent`、`todo`、`session-persistence`、`ui`、`util` 或 `support`)。允许新建分组,但分组只是纯容器:没有 `package.json`,没有源文件,包仍然恰好位于其下一层。 -package.json 不变式(由 `pnpm run constraints` / `scripts/check-workspace-constraints.ts` 强制执行):`private: true`,`version` 与根 `package.json` 一致,`type: module`,`main: "lib/index.js"`,`types: "lib/types/index.d.ts"`,`exports["."].types: "./lib/types/index.d.ts"`,`exports["."].default: "./lib/index.js"`,`cordis` 同时出现在 peerDependencies 和 devDependencies 中(相同范围)。每个 dsh 对等依赖(peer dependency)都要在 devDependencies 中镜像。`schemastery` 放在 `dependencies` 中(它是运行时校验器),与 agent-loop 保持一致。`files` 列表要精确:`lib/index.js`、`lib/types/**/*.d.ts`、`lib/types/**/*.d.ts.map` 和 `src`;不要发布 `lib/types` 下的 JS 或 JS-map 中间产物,也不要发布陈旧的根声明文件。带有 `bin` 的 CLI 应用包在 `files` 中将 `lib/bin.js` 紧跟在 `lib/index.js` 之后。 +package.json 不变式(由 `pnpm run constraints` / `scripts/check-workspace-constraints.ts` 强制执行):`private: true`,`version` 与根 `package.json` 一致,`type: module`,`main: "lib/index.js"`,`types: "lib/types/index.d.ts"`,`exports["."].types: "./lib/types/index.d.ts"`,`exports["."].default: "./lib/index.js"`,`cordis` 同时出现在 peerDependencies 和 devDependencies 中(相同范围)。每个 dsh 对等依赖(peer dependency)都要在 devDependencies 中镜像。`schemastery` 放在 `dependencies` 中(它是运行时校验器),与 agent-loop 保持一致。`files` 列表精确包含 `lib/index.js`、`lib/invariant.js`、`lib/types/**/*.d.ts` 以及门禁认可的包专用运行时产物;如果包的运行时 export 指向输出树,还要包含 `lib/types/**/*.js`。不要发布 `src`、声明映射、JS map 或陈旧的根声明文件。带有 `bin` 的 CLI 应用包在 `files` 中将 `lib/bin.js` 紧跟在 `lib/index.js` 之后。 包内的相对导入在源码中使用显式 `.ts` 后缀(例如 `export * from './types.ts'`)。编译器在输出的 JS 中将其重写为 `.js`,在声明文件中保留显式 `.ts` 后缀;标准的 NodeNext/Node16 TypeScript 消费方会将其解析到同目录的 `.d.ts` 文件。 @@ -75,9 +75,9 @@ Append-only, prefix-stable, replacing, or independent behavior, including the ex - **Consumer-visible gap** — exact boundary, consequence, or maintainer constraint. ```` -根据实现填写 Model Experience。每个直接、条件、上限、生命周期或辅助模型的 surface 使用一个 H3,包含上述三个有序 H4 字段,每个字段下有一个正文段落。引用包拥有的稳定文本:系统提示词放在引出它的字段下,用带标题的 H5 加 `markdown` 围栏表示,通常归入 `What the model sees`;其他短文本以命名占位符内联,其他长文本使用相同的嵌套形式。仅概述数据依赖或提供方拥有的文本。tool-schema surface 链接到生成的[工具目录](../tool-catalog.md)中对应的锚定章节,仅说明该处缺失的差异。当作用域可以隐藏 prompt 或 schema 其中之一而不影响另一个时,将二者分开。填写 `KV Cache effect` 时,应区分仅追加增长、稳定重复的前缀、替换既有请求 token 和独立模型请求,并列出由本包拥有、可能使缓存复用失效的变化。“不使缓存失效”仅表示本包保留了已有的可复用前缀;缓存是否可用以及何时淘汰不属于本包契约。[行文标准](../../.agents/skills/dsh-prose-standard/SKILL.md)约束完整性与归属;验证器强制执行机械形状。 +根据实现填写 Model Experience。每个直接、条件、上限、生命周期或辅助模型的 surface 使用一个 H3,包含上述三个有序 H4 字段,每个字段下有一个正文段落。引用包拥有的稳定文本:系统提示词放在引出它的字段下,用带标题的 H5 加 `markdown` 围栏表示,通常归入 `What the model sees`;其他短文本以命名占位符内联,其他长文本使用相同的嵌套形式。仅概述数据依赖或提供方拥有的文本。tool-schema surface 链接到生成的[工具目录](../tool-catalog.md)中对应的锚定章节,仅说明该处缺失的差异。当作用域可以隐藏 prompt 或 schema 其中之一而不影响另一个时,将二者分开。填写 `KV Cache effect` 时,应区分仅追加增长、稳定重复的前缀、替换既有请求 token 和独立模型请求,并列出会使缓存复用失效、且由本包拥有的变化。“不使缓存失效”仅表示本包保留了已有的可复用前缀;缓存是否可用以及何时淘汰不属于本包契约。[行文标准](../../.agents/skills/dsh-prose-standard/SKILL.md)约束完整性与归属;验证器强制执行机械形状。 -没有上下文效果或仅有一条由消费方拥有的路径的包使用 [`SENTENCE_MODEL_EXPERIENCE`](../../scripts/verify-package-readme-model-experience.ts) 中经过审计的 `None, as ` 或 `Indirectly, through ` 语句,随后添加 `KV Cache effect` H4 和一个非空正文段落;与模型无关的通用包可以改为加入 `NO_MODEL_EXPERIENCE_SECTION`。两种情况都不要展开为对另一个包工作的描述。limitations [allowlist](../../scripts/verify-package-readme-limitations.ts) 独立管理。[Model Experience Agent Note](../../.agents/notes/implemented/process/2026-07-12-package-model-experience-contract.md) 记录了设计动机。 +没有上下文效果或仅有消费方拥有路径的包使用 [`SENTENCE_MODEL_EXPERIENCE`](../../scripts/verify-package-readme-model-experience.ts) 中经过审计的 `None, as ` 或 `Indirectly, through ` 语句,随后添加 `KV Cache effect` H4 和一个非空正文段落;与模型无关的通用包可以改为加入 `NO_MODEL_EXPERIENCE_SECTION`。两种情况都不要展开为对另一个包工作的描述。limitations [allowlist](../../scripts/verify-package-readme-limitations.ts) 独立管理。[Model Experience Agent Note](../../.agents/notes/implemented/process/2026-07-12-package-model-experience-contract.md) 记录了设计动机。 ## 5. 验证 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index cb8cb3b3b2..93d6d267a4 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -297,7 +297,7 @@ Source: [`packages/bash/bash/src/index.ts:51`](../../packages/bash/bash/src/inde ## `ctx.bashEnv` — `BashEnvRegistry` -Registry (`ctx.bashEnv`) for trusted, per-execution `DSH_*` variables. The namespace is rebuilt for every model bash call: ambient `DSH_*` values are discarded by the executor, then the registry's current snapshot is injected. Built-in shell facts remain owned by the registry itself while plugins can register additional, enumerable facts with effect-scoped disposal. +Registry (`ctx.bashEnv`) for trusted, per-execution `DSH_*` variables. The namespace is rebuilt for every model shell call: ambient `DSH_*` values are discarded by the executor, then the registry's current snapshot is injected. Built-in shell facts remain owned by the registry itself while plugins can register additional, enumerable facts with effect-scoped disposal. ```ts cordis-catalog /** @@ -309,7 +309,7 @@ Registry (`ctx.bashEnv`) for trusted, per-execution `DSH_*` variables. The names register(contributor: BashEnvContributor): () => void /** - * Build the trusted `DSH_*` snapshot for one bash tool execution. + * Build the trusted `DSH_*` snapshot for one shell tool execution. * @param execution - the current tool execution. * @returns an immutable environment overlay containing built-ins and current contributions. */ @@ -324,7 +324,7 @@ list(): BashEnvVariableInfo[] Types: [DshEnvironment](../core-data-structures/subprocess.md) · [ToolExecution](../core-data-structures/tools.md) -Source: [`packages/bash/tool-bash/src/index.ts:104`](../../packages/bash/tool-bash/src/index.ts) +Source: [`packages/bash/bash-env/src/index.ts:89`](../../packages/bash/bash-env/src/index.ts) ## `ctx.clientModuleHost` — `ClientModuleHostService` @@ -2105,7 +2105,7 @@ abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle Types: [SubprocessHandle](../core-data-structures/subprocess.md) · [SubprocessSpawnSpec](../core-data-structures/subprocess.md) -Source: [`packages/subprocess/subprocess/src/index.ts:88`](../../packages/subprocess/subprocess/src/index.ts) +Source: [`packages/subprocess/subprocess/src/index.ts:91`](../../packages/subprocess/subprocess/src/index.ts) ## `ctx.systemPrompt` — `SystemPrompt` diff --git a/docs/module-graph.md b/docs/module-graph.md index b2a70d7ab7..aae611eff5 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -38,9 +38,12 @@ flowchart TD end subgraph group_bash["packages/bash"] pkg_bash["bash"] + pkg_bash_env["bash-env"] pkg_bash_local["bash-local"] pkg_bash_sandbox["bash-sandbox"] + pkg_pwsh_local["pwsh-local"] pkg_tool_bash["tool-bash"] + pkg_tool_pwsh["tool-pwsh"] end subgraph group_fs["packages/fs"] pkg_fs["fs"] @@ -503,6 +506,10 @@ flowchart TD pkg_bash_local --> pkg_invariants pkg_bash_local --> pkg_subprocess pkg_bash_local --> pkg_timeout + pkg_pwsh_local --> pkg_bash + pkg_pwsh_local --> pkg_invariants + pkg_pwsh_local --> pkg_subprocess + pkg_pwsh_local --> pkg_timeout pkg_fs_local --> pkg_fs pkg_fs_local --> pkg_invariants pkg_fs_policy --> pkg_fs @@ -690,18 +697,11 @@ flowchart TD pkg_tool_goal --> pkg_session pkg_tool_goal --> pkg_system_prompt pkg_tool_goal --> pkg_tools - pkg_tool_bash --> pkg_agent - pkg_tool_bash --> pkg_bash - pkg_tool_bash --> pkg_invariants - pkg_tool_bash --> pkg_llm - pkg_tool_bash --> pkg_paths - pkg_tool_bash --> pkg_sandbox - pkg_tool_bash --> pkg_sandbox_policy - pkg_tool_bash --> pkg_session_persistence - pkg_tool_bash --> pkg_system_prompt - pkg_tool_bash --> pkg_tasks - pkg_tool_bash --> pkg_tools - pkg_tool_bash --> pkg_user_approval + pkg_bash_env --> pkg_bash + pkg_bash_env --> pkg_invariants + pkg_bash_env --> pkg_paths + pkg_bash_env --> pkg_session_persistence + pkg_bash_env --> pkg_tools pkg_tool_fs --> pkg_fs pkg_tool_fs --> pkg_invariants pkg_tool_fs --> pkg_llm @@ -885,6 +885,25 @@ flowchart TD pkg_tool_workflow --> pkg_system_prompt pkg_tool_workflow --> pkg_tools pkg_tool_workflow --> pkg_workflow + pkg_tool_bash --> pkg_agent + pkg_tool_bash --> pkg_bash + pkg_tool_bash --> pkg_bash_env + pkg_tool_bash --> pkg_invariants + pkg_tool_bash --> pkg_llm + pkg_tool_bash --> pkg_sandbox + pkg_tool_bash --> pkg_sandbox_policy + pkg_tool_bash --> pkg_system_prompt + pkg_tool_bash --> pkg_tasks + pkg_tool_bash --> pkg_tools + pkg_tool_bash --> pkg_user_approval + pkg_tool_pwsh --> pkg_agent + pkg_tool_pwsh --> pkg_bash + pkg_tool_pwsh --> pkg_bash_env + pkg_tool_pwsh --> pkg_invariants + pkg_tool_pwsh --> pkg_llm + pkg_tool_pwsh --> pkg_system_prompt + pkg_tool_pwsh --> pkg_tasks + pkg_tool_pwsh --> pkg_tools pkg_subagent_acp --> pkg_agent pkg_subagent_acp --> pkg_invariants pkg_subagent_acp --> pkg_llm @@ -964,27 +983,6 @@ flowchart TD pkg_client_ui_subagent --> pkg_invariants pkg_client_ui_subagent --> pkg_subagent pkg_client_ui_subagent --> pkg_token_meter - pkg_agent_spine_demo --> pkg_agent - pkg_agent_spine_demo --> pkg_agent_loop - pkg_agent_spine_demo --> pkg_goal - pkg_agent_spine_demo --> pkg_goal_session - pkg_agent_spine_demo --> pkg_invariants - pkg_agent_spine_demo --> pkg_llm - pkg_agent_spine_demo --> pkg_llm_retry - pkg_agent_spine_demo --> pkg_paths - pkg_agent_spine_demo --> pkg_scope - pkg_agent_spine_demo --> pkg_session - pkg_agent_spine_demo --> pkg_session_title - pkg_agent_spine_demo --> pkg_skill - pkg_agent_spine_demo --> pkg_skill_local - pkg_agent_spine_demo --> pkg_system_prompt - pkg_agent_spine_demo --> pkg_tasks_local - pkg_agent_spine_demo --> pkg_tool_bash - pkg_agent_spine_demo --> pkg_tool_goal - pkg_agent_spine_demo --> pkg_tool_skill - pkg_agent_spine_demo --> pkg_tool_tasks - pkg_agent_spine_demo --> pkg_tools - pkg_agent_spine_demo --> pkg_workspace_context pkg_sdk_protocol --> pkg_invariants pkg_sdk_protocol --> pkg_llm pkg_sdk_protocol --> pkg_session @@ -1020,6 +1018,39 @@ flowchart TD pkg_jsonrpc --> pkg_sdk_protocol pkg_jsonrpc --> pkg_session pkg_jsonrpc --> pkg_subagent + pkg_agent_spine_demo --> pkg_agent + pkg_agent_spine_demo --> pkg_agent_loop + pkg_agent_spine_demo --> pkg_bash_env + pkg_agent_spine_demo --> pkg_goal + pkg_agent_spine_demo --> pkg_goal_session + pkg_agent_spine_demo --> pkg_invariants + pkg_agent_spine_demo --> pkg_llm + pkg_agent_spine_demo --> pkg_llm_retry + pkg_agent_spine_demo --> pkg_paths + pkg_agent_spine_demo --> pkg_scope + pkg_agent_spine_demo --> pkg_session + pkg_agent_spine_demo --> pkg_session_title + pkg_agent_spine_demo --> pkg_skill + pkg_agent_spine_demo --> pkg_skill_local + pkg_agent_spine_demo --> pkg_system_prompt + pkg_agent_spine_demo --> pkg_tasks_local + pkg_agent_spine_demo --> pkg_tool_bash + pkg_agent_spine_demo --> pkg_tool_goal + pkg_agent_spine_demo --> pkg_tool_skill + pkg_agent_spine_demo --> pkg_tool_tasks + pkg_agent_spine_demo --> pkg_tools + pkg_agent_spine_demo --> pkg_workspace_context + pkg_sdk_client --> pkg_invariants + pkg_sdk_client --> pkg_llm + pkg_sdk_client --> pkg_sdk_protocol + pkg_sdk_client --> pkg_session + pkg_subagent_dsh_sdk --> pkg_agent + pkg_subagent_dsh_sdk --> pkg_invariants + pkg_subagent_dsh_sdk --> pkg_llm + pkg_subagent_dsh_sdk --> pkg_sdk_client + pkg_subagent_dsh_sdk --> pkg_session + pkg_subagent_dsh_sdk --> pkg_subagent + pkg_subagent_dsh_sdk --> pkg_subprocess pkg_acp_demo --> pkg_acp pkg_acp_demo --> pkg_agent_spine_demo pkg_acp_demo --> pkg_app_boot @@ -1040,17 +1071,6 @@ flowchart TD pkg_cli_demo --> pkg_session_persistence_jsonl pkg_cli_demo --> pkg_tools pkg_cli_demo --> pkg_workspace_context - pkg_sdk_client --> pkg_invariants - pkg_sdk_client --> pkg_llm - pkg_sdk_client --> pkg_sdk_protocol - pkg_sdk_client --> pkg_session - pkg_subagent_dsh_sdk --> pkg_agent - pkg_subagent_dsh_sdk --> pkg_invariants - pkg_subagent_dsh_sdk --> pkg_llm - pkg_subagent_dsh_sdk --> pkg_sdk_client - pkg_subagent_dsh_sdk --> pkg_session - pkg_subagent_dsh_sdk --> pkg_subagent - pkg_subagent_dsh_sdk --> pkg_subprocess ``` | Package | Group | Depends on | @@ -1139,6 +1159,7 @@ flowchart TD | [`token-meter`](../packages/llm/token-meter) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection) | | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection) | | [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`pwsh-local`](../packages/bash/pwsh-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | | [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | | [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`skill`](../packages/skill/skill) | @@ -1179,7 +1200,7 @@ flowchart TD | [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | `telemetry` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`session-telemetry`](../packages/telemetry/session-telemetry) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | +| [`bash-env`](../packages/bash/bash-env) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | @@ -1210,6 +1231,8 @@ flowchart TD | [`tool-pty`](../packages/pty/tool-pty) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`pty`](../packages/pty/pty), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | +| [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | +| [`tool-pwsh`](../packages/bash/tool-pwsh) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | @@ -1221,14 +1244,14 @@ flowchart TD | [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-command`](../packages/client/ui-command), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`permission`](../packages/ui/permission) | | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | -| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`sdk-protocol`](../packages/sdk/sdk-protocol) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | -| [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/acp/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | -| [`cli-demo`](../packages/examples/cli-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | +| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`bash-env`](../packages/bash/bash-env), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`sdk-client`](../packages/sdk/sdk-client) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/sdk-client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | +| [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/acp/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | +| [`cli-demo`](../packages/examples/cli-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | diff --git a/docs/postmortem/0001-acp-default-export-drops-inject.i18n.yaml b/docs/postmortem/0001-acp-default-export-drops-inject.i18n.yaml index 06cf832803..e538f244b9 100644 --- a/docs/postmortem/0001-acp-default-export-drops-inject.i18n.yaml +++ b/docs/postmortem/0001-acp-default-export-drops-inject.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/postmortem/0001-acp-default-export-drops-inject.md 0001-acp-default-export-drops-inject.md: 2d36f24fa54814e39345d7fe68792023c2cf0194 -0001-acp-default-export-drops-inject.zh.md: 3a855cb6467b561e9380241a8966317b9508ad37 +0001-acp-default-export-drops-inject.zh.md: 6ae7d45f58e09f205a5653f7c6d306014d4d393e diff --git a/docs/postmortem/0001-acp-default-export-drops-inject.zh.md b/docs/postmortem/0001-acp-default-export-drops-inject.zh.md index 3a855cb646..6ae7d45f58 100644 --- a/docs/postmortem/0001-acp-default-export-drops-inject.zh.md +++ b/docs/postmortem/0001-acp-default-export-drops-inject.zh.md @@ -2,7 +2,7 @@ [English](0001-acp-default-export-drops-inject.md) | 中文 -状态:已解决(修复见 PR(Pull Request) #41 `feat/acp-2-bridge`) +Status: resolved (fix in PR(Pull Request) #41 `feat/acp-2-bridge`) ## 摘要 diff --git a/docs/postmortem/0002-js-expression-disabled-filesystem-tools.i18n.yaml b/docs/postmortem/0002-js-expression-disabled-filesystem-tools.i18n.yaml index c39a21097a..b10d882b59 100644 --- a/docs/postmortem/0002-js-expression-disabled-filesystem-tools.i18n.yaml +++ b/docs/postmortem/0002-js-expression-disabled-filesystem-tools.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/postmortem/0002-js-expression-disabled-filesystem-tools.md 0002-js-expression-disabled-filesystem-tools.md: 30ff9d920821a8d55c4bea5f120f1aeeca6634b3 -0002-js-expression-disabled-filesystem-tools.zh.md: 29fedbf0031ee4c0bc3d0e6af84e85b51589ac6e +0002-js-expression-disabled-filesystem-tools.zh.md: 3c18a48d3b7e925a6e75c2d3edb3ec642e72e1b3 diff --git a/docs/postmortem/0002-js-expression-disabled-filesystem-tools.zh.md b/docs/postmortem/0002-js-expression-disabled-filesystem-tools.zh.md index 29fedbf003..3c18a48d3b 100644 --- a/docs/postmortem/0002-js-expression-disabled-filesystem-tools.zh.md +++ b/docs/postmortem/0002-js-expression-disabled-filesystem-tools.zh.md @@ -2,7 +2,7 @@ [English](0002-js-expression-disabled-filesystem-tools.md) | 中文 -状态:已解决 +Status: resolved ## 摘要 diff --git a/docs/testing.i18n.yaml b/docs/testing.i18n.yaml index e91b6e3c34..81929ba150 100644 --- a/docs/testing.i18n.yaml +++ b/docs/testing.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/testing.md -testing.md: 89d495e05c7521becea052ad67ac602ba28c22ef -testing.zh.md: 8e19734d09d5b0bdbeffe9426bed7c12f23fbc41 +testing.md: 728c65f1911cbaa098f653af9436a92336fbe068 +testing.zh.md: 72b7c3bcfa69f4c65fc128dbcbb19c080fb0fbc4 diff --git a/docs/testing.md b/docs/testing.md index 89d495e05c..728c65f191 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -7,9 +7,9 @@ How this repo tests, tier by tier, and the rules that keep a green suite meaning ## Tiers - **Unit** (`pnpm run test`): vitest over package and example specs under their `tests/**` directories plus repository script specs under `scripts/**/*.spec.ts`; tests stay with the code area they exercise. Every registry gets an HMR-safety test (dispose the contributing fiber, assert cleanup). Prefer edge cases, error paths, event ordering, concurrency races, and permanent contract regressions (see `packages/core/agent-loop/tests/contract-regressions.spec.ts`). -- **Coverage gate** (`pnpm run test:coverage`): the gating run, per-file 100% on `packages/*/*/src`. An uncovered line is often dead code the gate is correctly flagging for deletion, not a missing test to bolt on. Line coverage is necessary, never sufficient — it proves lines ran, not that the feature works as shipped. +- **Coverage gate** (`pnpm run test:coverage`): the gating run, per-file 100% on `packages/*/*/src`. An uncovered line is often dead code the gate is correctly flagging for deletion, not a missing test to bolt on. Line coverage is necessary, never sufficient — it proves lines ran, not that the feature works as shipped. Per-file 100% on `packages/bash/pwsh-local/src` needs a real `pwsh`: without one its executor suites self-skip and `vitest.config.ts` exempts the file so pwsh-less hosts stay green, while CI runners ship pwsh and enforce the full bar. - **Real-API e2e** (`pnpm run test:e2e`): with-key tests against live provider APIs — the DeepSeek model plus provider-specific smokes that gate on their own keys (`EXA_API_KEY`, `PERPLEXITY_API_KEY`, …); each suite self-skips without its key so keyless CI stays green ([real-API e2e Agent Note](../.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md)). -- **Snapshot** (`pnpm run test:snapshot`): keyless expected outputs cover external behavior — transport contracts and presentation, while persisted logs pin assembled backend behavior. ACP boots the real automation-server example, replays a recorded session, and diffs normalized JSON-RPC plus the re-persisted log ([ACP snapshot Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md)); headless pins `stream-json` through its real one-shot process. Use `pnpm run test:snapshot:record` when a model transcript changes and `pnpm run test:snapshot:refresh` when replay input remains valid; review every JSONL and expected-output diff. One ACP scenario (`text-turn`) pins full system-prompt/tool-schema content; other fixtures tokenize it so an edit churns one line ([pinned-header Agent Note](../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). +- **Snapshot** (`pnpm run test:snapshot`): keyless expected outputs cover external behavior — transport contracts and presentation, while persisted logs pin assembled backend behavior. ACP boots the real automation-server example, replays a recorded session, and diffs normalized JSON-RPC plus the re-persisted log ([ACP snapshot Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md)); headless pins `stream-json` through its real one-shot process. TUI journeys replay primary/child JSONL through the real loop and tools, then project ANSI into semantic terminal-state outputs; package snapshots retain transient states and a real PTY covers the process boundary ([TUI snapshot Agent Note](../.agents/notes/archived/testing/2026-07-18-tui-terminal-state-snapshots.md)). Use `pnpm run test:snapshot:record` when a model transcript changes and `pnpm run test:snapshot:refresh` when replay input remains valid; review every JSONL and expected-output diff. One ACP scenario (`text-turn`) pins full system-prompt/tool-schema content; other fixtures tokenize it so an edit churns one line ([pinned-header Agent Note](../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). - **Web browser snapshot** (`pnpm run test:web`; required Linux PR gate): Chromium compares replayed browser output with `apps/web/tests/snapshots/`. CI forces read-only `DSH_SNAPSHOT=replay`, never writing expected outputs; record/refresh stay local and every diff is reviewed ([web e2e lane](../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md), [CI gate decision](../.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md)). `test:web` [builds first](../.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md) for plugin CSS. Committed session-format JSONL uses the canonical packed-row layout, and the keyless snapshot gate discovers every such fixture by its `session` header. In-flight branches carrying older fixture edits merge current `master` and run the [temporary migrator](../scripts/migrate-packed-session-fixtures.ts) through `pnpm run migrate:packed-session-fixtures`; the [removal proposal](../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md) retires that command and these links after all affected branches converge. @@ -46,4 +46,4 @@ An e2e assertion re-runs the command or re-reads the file externally; a keyword ## When a snapshot test is required -Every non-trivial model-, protocol-, or human-visible change adds or updates a keyless scenario in the same PR through a runnable example's owning snapshot suite. Package tests, e2e assertions, mock/test-only compositions, and PR rationale do not replace the assembled transcript; extend the harness when needed. ACP automation scenarios use `examples//tests/snapshots/`, a scenario table over the [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) suite factory (`examples/acp-agent` is primary); `examples/headless-agent` owns the `stream-json` snapshot and replay fixtures. Browser-rendered web GUI journeys use `apps/web/tests/snapshots/`. New capability seams, lifecycle shapes, or transcript surfaces name every coverage tier at plan time and verify the harness can express it before implementation. +Every non-trivial model-, protocol-, or human-visible change adds or updates a keyless scenario in the same PR through a runnable example's owning snapshot suite. Package tests, e2e assertions, mock/test-only compositions, and PR rationale do not replace the assembled transcript; extend the harness when needed. ACP automation scenarios use `examples//tests/snapshots/`, a scenario table over the [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) suite factory (`examples/acp-agent` is primary); `examples/headless-agent` owns the `stream-json` snapshot and replay fixtures. The `pwsh-tool-turn` ACP scenario boots real `pwsh` and skips where it is absent. Completed interactive-terminal journeys use JSONL-driven scenarios under `apps/cli/tests/snapshots/`; transient presentation uses the package-local semantic matrix, with a PTY case when input, Loader selection, or terminal teardown changes. Browser-rendered web GUI journeys use `apps/web/tests/snapshots/`. New capability seams, lifecycle shapes, or transcript surfaces name every coverage tier at plan time and verify the harness can express it before implementation. diff --git a/docs/testing.zh.md b/docs/testing.zh.md index 8e19734d09..72b7c3bcfa 100644 --- a/docs/testing.zh.md +++ b/docs/testing.zh.md @@ -7,9 +7,9 @@ ## 层级 - **单元测试**(`pnpm run test`):vitest 运行包(package)和示例各自的 `tests/**` 目录下的测试,以及匹配 `scripts/**/*.spec.ts` 的仓库脚本测试;测试文件与其所覆盖的代码区域放在一起。每个注册表都有一个 HMR(热模块替换)安全测试(dispose(资源释放)贡献的 fiber,断言清理完成)。优先覆盖边界情况、错误路径、事件顺序、并发竞态,以及永久性契约回归(见 `packages/core/agent-loop/tests/contract-regressions.spec.ts`)。 -- **覆盖率门禁**(`pnpm run test:coverage`):门禁级运行,对 `packages/*/*/src` 按文件 100% 覆盖。未覆盖的行往往是门禁正确标记出的死代码(应删除),而非需要补写的测试。行覆盖率是必要条件,但永远不是充分条件:它证明行被执行过,不证明功能按交付预期工作。 +- **覆盖率门禁**(`pnpm run test:coverage`):门禁级运行,对 `packages/*/*/src` 按文件 100% 覆盖。未覆盖的行往往是门禁正确标记出的死代码(应删除),而非需要补写的测试。行覆盖率是必要条件,但永远不是充分条件:它证明行被执行过,不证明功能按交付预期工作。`packages/bash/pwsh-local/src` 的按文件 100% 覆盖需要真实的 `pwsh`:缺少它时其 executor 套件会自动跳过,`vitest.config.ts` 会豁免该文件以使无 pwsh 的主机保持绿色,而 CI runner 自带 pwsh,仍按完整标准执行门禁。 - **真实 API e2e**(`pnpm run test:e2e`):带密钥测试调用真实提供方 API,包括 DeepSeek 模型以及各提供方特有的冒烟测试;这些测试各自由自己的密钥控制(`EXA_API_KEY`、`PERPLEXITY_API_KEY` 等),缺少密钥时套件会自动跳过,使 keyless CI 保持绿色([真实 API e2e Agent Note](../.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md))。 -- **快照**(`pnpm run test:snapshot`):无密钥预期输出覆盖对外行为(传输契约与呈现),持久化日志则固定组装后的后端行为。ACP 启动真实的自动化服务器示例、回放录制会话,并对归一化 JSON-RPC 与重新持久化的日志执行 diff([ACP 快照 Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md));headless 通过真实单次运行进程固定 `stream-json`。当模型 transcript(文本记录)发生变化时使用 `pnpm run test:snapshot:record`,回放输入仍然有效时使用 `pnpm run test:snapshot:refresh`;请审查每一处 JSONL 与预期输出差异。一个 ACP 场景(`text-turn`)固定完整的系统提示词与工具 schema 内容;其他 fixture(测试前置数据)将其 token 化,因此修改只会扰动一行([pinned-header Agent Note](../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md))。 +- **快照**(`pnpm run test:snapshot`):无密钥预期输出覆盖对外行为(传输契约与呈现),持久化日志则固定组装后的后端行为。ACP 启动真实的自动化服务器示例、回放录制会话,并对归一化 JSON-RPC 与重新持久化的日志执行 diff([ACP 快照 Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md));headless 通过真实单次运行进程固定 `stream-json`。TUI 旅程通过真实循环与工具回放主会话与子会话 JSONL,再将 ANSI 投影为语义化终端状态输出;包级快照保留瞬态状态,真实 PTY 覆盖进程边界([TUI 快照 Agent Note](../.agents/notes/archived/testing/2026-07-18-tui-terminal-state-snapshots.md))。当模型 transcript(文本记录)发生变化时使用 `pnpm run test:snapshot:record`,回放输入仍然有效时使用 `pnpm run test:snapshot:refresh`;请审查每一处 JSONL 与预期输出差异。一个 ACP 场景(`text-turn`)固定完整的系统提示词与工具 schema 内容;其他 fixture(测试前置数据)将其 token 化,因此修改只会扰动一行([pinned-header Agent Note](../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md))。 - **Web 浏览器快照**(`pnpm run test:web`;必需的 Linux PR(Pull Request)门禁):Chromium 将回放后的浏览器输出与 `apps/web/tests/snapshots/` 比较。CI 强制只读的 `DSH_SNAPSHOT=replay`,绝不写入预期输出;record/refresh 留在本地,每处 diff 都须评审([web e2e 车道](../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md)、[CI 门禁决策](../.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md))。`test:web` 会[先构建](../.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md)以交付插件 CSS。 签入仓库的会话格式 JSONL 使用规范打包行布局,无密钥快照门禁会通过 `session` header 发现每一份此类 fixture。仍携带旧版 fixture 改动的在途分支应合并当前 `master`,并通过 `pnpm run migrate:packed-session-fixtures` 运行[临时迁移器](../scripts/migrate-packed-session-fixtures.ts);待所有受影响分支收敛后,[移除提案](../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md)会移除该命令及这些链接。 @@ -46,4 +46,4 @@ e2e 断言应重新运行命令或从外部重新读取文件;对 agent 自身 ## 何时需要快照测试 -每项非平凡的模型可见、协议可见或人类可见变更,都必须在同一 PR 中,通过可运行示例所属的快照套件添加或更新无密钥场景。包测试、e2e 断言、mock 与仅测试组合、PR 理由都不能取代组装后的 transcript;必要时应扩展 harness。ACP 自动化场景使用 `examples//tests/snapshots/`,即基于 [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) 套件工厂的场景表(`examples/acp-agent` 为主套件);`examples/headless-agent` 拥有 `stream-json` 快照与回放 fixture。浏览器渲染的 Web GUI 旅程使用 `apps/web/tests/snapshots/`。新的能力 seam、生命周期形态或 transcript 呈现接口在计划阶段就要列出每个覆盖层级,并在实现前验证 harness 能够表达它们。 +每项非平凡的模型可见、协议可见或人类可见变更,都必须在同一 PR 中,通过可运行示例所属的快照套件添加或更新无密钥场景。包测试、e2e 断言、mock 与仅测试组合、PR 理由都不能取代组装后的 transcript;必要时应扩展 harness。ACP 自动化场景使用 `examples//tests/snapshots/`,即基于 [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) 套件工厂的场景表(`examples/acp-agent` 为主套件);`examples/headless-agent` 拥有 `stream-json` 快照与回放 fixture。`pwsh-tool-turn` ACP 场景启动真实 `pwsh`,在无 `pwsh` 的主机上跳过。已完成的交互式终端旅程使用 `apps/cli/tests/snapshots/` 下由 JSONL 驱动的场景;瞬态呈现使用包内语义矩阵,输入、Loader 选择或终端清理发生变化时还要添加 PTY 用例。新的能力 seam、生命周期形态或 transcript 呈现接口在计划阶段就要列出每个覆盖层级,并在实现前验证 harness 能够表达它们。 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index f93aedd658..38125bcd4a 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -18,7 +18,8 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-ask-user` | `ask_user_question` | `ctx.tools`, `ctx.userInteraction` | `tool/call`, `tool/result after a UI/provider answers the question` | - | ask_user_question pauses the tool call until the active UI provider returns a human answer. | | `@deepseek-ai/dsh-tools` | `run_code` | `ctx.tools`, `ctx.codeRuntime (execution time)`, `ctx.systemPrompt` | `tool/call`, `one tool/code-dispatch-start + tool/code-dispatch pair per bridged sub-call`, `tool/result` | - | Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: code` / `mode: both` (see the Code Mode Agent Note). Under `code` it is the registry's only wire contribution; the other visible capabilities are declared in a generated TypeScript SDK section, and a program calls them through bindings scheduled under the native concurrency contract (submission-ordered starts and policy; concurrency-safe bodies overlap up to `maxParallelSubCalls`) that re-enter the complete guarded tool pipeline and link each nested execution to this outer result. | | `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`, `ctx.systemPrompt`, `ctx.userInteraction (execution time, opportunistic)` | `tool/call`, `plan/mode inactive on an approved review`, `tool/result` | - | exit_plan_mode stays in the model-facing schema while planning is inactive so transitions add no tool-catalog churn on top of the plan-policy change. Its execute path rejects calls outside plan mode; in plan mode it presents the plan over the user-interaction seam (approve / keep planning with feedback), and approval logs plan mode inactive at the step boundary. | -| `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`, `ctx.bash`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled. | +| `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt`, `ctx.bashEnv`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled. | +| `@deepseek-ai/dsh-tool-pwsh` | `pwsh` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt`, `ctx.bashEnv`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The pwsh tool is the PowerShell-dialect consumer of the bash executor seam for Windows compositions (a PowerShell executor such as `@deepseek-ai/dsh-pwsh-local` backs `ctx.bash`); it mirrors the bash tool call-for-call minus the sandbox surface — `run_in_background` runs register with the generic `ctx.tasks` runtime and are collected/stopped through the `task_*` tools, and the managed `DSH_*` environment comes from `@deepseek-ai/dsh-bash-env`. Each call runs in a fresh process (no persistent PTY session; ConPTY is roadmap work), with native `C:\...` paths and `$env:NAME` variables. | | `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_mount`, `cordis_unmount` | `ctx.tools` | `tool/call`, `tool/result`, `process-local temporary Plugin lifecycle` | - | Not in any shipped tree (a deliberate opt-in — temporary Plugin code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins created by cordis_mount may register ADDITIONAL model-visible tools until unmounted or DSH restarts; a full changed request header logs those tool-set changes. | | `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.tools`, `ctx.pty`, `an owning Agent at execution time` | `tool/call`, `PTY shell state`, `tool/result` | - | One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description. | | `@deepseek-ai/dsh-tool-str-replace-editor` | `str_replace_editor` | `ctx.tools`, `ctx.fs` | `tool/call`, `fs/observed after successful file operations`, `tool/result` | - | Standalone view/create/unique literal replace/line insert tool over the filesystem seam; it composes with any shell or terminal surface. | @@ -207,6 +208,48 @@ Source: [`packages/bash/tool-bash/src/index.ts`](../packages/bash/tool-bash/src/ The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled. +## `@deepseek-ai/dsh-tool-pwsh` + +### `pwsh` + +Execute a PowerShell command (`pwsh -Command`) and return its stdout/stderr. Each call runs in a fresh pwsh process: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Paths use native Windows form (`C:\...`); read environment variables with `$env:NAME`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$env:DSH_*` variables; inspect them when needed. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. On Windows a force-killed command settles as `[exit code: 1]` without a signal marker — treat it as an interruption, not a command failure. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. + +```json +{ + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The PowerShell command to execute." + }, + "description": { + "type": "string", + "description": "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"Get-Process\" → \"List running processes\"." + }, + "timeoutMs": { + "type": "number", + "description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry." + }, + "workdir": { + "type": "string", + "description": "Working directory for this command. Defaults to the session workspace; a relative path is resolved against it." + }, + "run_in_background": { + "type": "boolean", + "description": "Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies." + } + }, + "required": [ + "command", + "description" + ] +} +``` + +Source: [`packages/bash/tool-pwsh/src/index.ts`](../packages/bash/tool-pwsh/src/index.ts) + +The pwsh tool is the PowerShell-dialect consumer of the bash executor seam for Windows compositions (a PowerShell executor such as `@deepseek-ai/dsh-pwsh-local` backs `ctx.bash`); it mirrors the bash tool call-for-call minus the sandbox surface — `run_in_background` runs register with the generic `ctx.tasks` runtime and are collected/stopped through the `task_*` tools, and the managed `DSH_*` environment comes from `@deepseek-ai/dsh-bash-env`. Each call runs in a fresh process (no persistent PTY session; ConPTY is roadmap work), with native `C:\...` paths and `$env:NAME` variables. + ## `@deepseek-ai/dsh-tool-cordis` ### `cordis_inspect` diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 198daf015d..c107a1e2b0 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -1,10 +1,12 @@ import { fileURLToPath } from 'node:url' import { readFileSync } from 'node:fs' +import { spawnSync } from 'node:child_process' import { mkdir, utimes, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { homedir } from 'node:os' import { expect, it } from 'vitest' import { defineAcpSnapshotSuite, type Scenario, type SnapshotSuiteOptions } from '@deepseek-ai/dsh-acp-snapshot' +import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local' import { decodeStorageRecord } from '@deepseek-ai/dsh-session' /** @@ -47,6 +49,7 @@ const SUBAGENT_DURABILITY_FAILURE_CONFIG = fileURLToPath( const LSP_CONFIG = fileURLToPath(new URL('./lsp.cordis.yml', import.meta.url)) const WEB_CONFIG = fileURLToPath(new URL('../web.cordis.yml', import.meta.url)) const FS_SEARCH_CONFIG = fileURLToPath(new URL('./fs-search.cordis.yml', import.meta.url)) +const PWSH_CONFIG = fileURLToPath(new URL('./pwsh.cordis.yml', import.meta.url)) const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots') const PACKED_CHUNKS_SOURCE = 'hook-cc-pretool-deny' @@ -157,6 +160,22 @@ const SCENARIOS: Scenario[] = [ configPath: PTY_CONFIG, }, { name: 'bash-tool-turn', hasModelTurn: true, recorded: true }, + // The pwsh overlay (pwsh.cordis.yml / pwsh.cordis.snapshot.yml) swaps the + // bundle's bash tool for the PowerShell twin, so its header class pins its + // own prompt/tool sidecars and a recorded transcript. + { + name: 'pwsh-tool-turn', + hasModelTurn: true, + recorded: true, + pinsHeader: true, + headerClass: 'pwsh', + configPath: PWSH_CONFIG, + // The composition boots the real pwsh executor; hosts without a `pwsh` + // binary skip the run (fixtures stay guarded). The recorded turn writes + // PWSH_OK via [Console]::Out.Write so the fixture carries no platform + // newline and one recording replays on every host. + pwshOnly: true, + }, { name: 'todo-write', hasModelTurn: true, recorded: true }, { name: 'skill-load', @@ -419,11 +438,17 @@ const SCENARIOS: Scenario[] = [ }, ] +// Hosts without a usable PowerShell skip the pwsh-tool-turn run (its fixtures +// stay guarded); the probe follows the executor's own resolution so a Windows +// host with only an install-location pwsh still runs the scenario. +const hasPwsh = spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0 + defineAcpSnapshotSuite({ agent: AGENT, snapshotsDir: SNAPSHOTS_DIR, scenarios: SCENARIOS, mode: snapshotModeFromEnv(process.env.DSH_SNAPSHOT), + hasPwsh, }) it('packed ACP fixture retains every chunk row kind without changing the logical session', () => { diff --git a/examples/acp-agent/tests/fixtures/bash/tool-pwsh/cordis.yml b/examples/acp-agent/tests/fixtures/bash/tool-pwsh/cordis.yml new file mode 100644 index 0000000000..c152b6ca67 --- /dev/null +++ b/examples/acp-agent/tests/fixtures/bash/tool-pwsh/cordis.yml @@ -0,0 +1,27 @@ +# Minimal tool-pwsh composition: real app boot path, real pwsh executor, real +# foreground + background tool calls; driven by the package's loader.spec.ts. +- id: system-prompt + name: '@deepseek-ai/dsh-system-prompt' + +- id: tools + name: '@deepseek-ai/dsh-tools' + +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + +- id: bash + name: '@deepseek-ai/dsh-pwsh-local' + config: + graceMs: 200 + +- id: bash-env + name: '@deepseek-ai/dsh-bash-env' + +- id: tasks + name: '@deepseek-ai/dsh-tasks-local' + +- id: tool-tasks + name: '@deepseek-ai/dsh-tool-tasks' + +- id: tool-pwsh + name: '@deepseek-ai/dsh-tool-pwsh' diff --git a/examples/acp-agent/tests/fixtures/bash/tool-pwsh/driver.ts b/examples/acp-agent/tests/fixtures/bash/tool-pwsh/driver.ts new file mode 100644 index 0000000000..9899bfcd17 --- /dev/null +++ b/examples/acp-agent/tests/fixtures/bash/tool-pwsh/driver.ts @@ -0,0 +1,67 @@ +#!/usr/bin/env node +/** + * Test driver: boot the tool-pwsh Loader composition, execute one real + * foreground and one real background pwsh command through the tool registry, + * and persist the observed model-visible output to `./pwsh-loader-report.json` + * for the package spec's inspect step. + */ + +import { writeFile } from 'node:fs/promises' +import { boot, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' +import { CallId } from '@deepseek-ai/dsh-llm' + +const configPath = process.argv[2] +if (configPath === undefined) throw new Error('tool-pwsh driver requires a config path') + +const ctx = await boot('tool-pwsh-loader-smoke', resolveConfigPath(configPath, undefined)) +try { + const schema = ctx.tools.schemas().find(tool => tool.name === 'pwsh') + if (schema === undefined) throw new Error('pwsh tool not registered by the composition') + const prompt = (await ctx.systemPrompt.assemble()).sections.find(section => section.name === 'tool:pwsh') + + const foreground = await ctx.tools.execute({ + signal: new AbortController().signal, + callId: CallId('loader-fg'), + name: 'pwsh', + arguments: { command: 'Write-Output loader-ok', description: 'loader foreground' }, + }) + const foregroundText = foreground.content.filter(block => block.type === 'text').map(block => block.text).join('') + + const background = await ctx.tools.execute({ + signal: new AbortController().signal, + callId: CallId('loader-bg'), + name: 'pwsh', + arguments: { + command: 'Start-Sleep -Milliseconds 200; Write-Output loader-bg-ok', + description: 'loader background', + run_in_background: true, + }, + }) + const taskId = (background.value as { taskId: string }).taskId + + // The output delta and the terminal status can land in separate reads + // (Windows flushes the child pipe at exit), so accumulate both. + let backgroundText = '' + const deadline = Date.now() + 10_000 + while (Date.now() < deadline) { + const read = await ctx.tools.execute({ + signal: new AbortController().signal, + callId: CallId('loader-bg-read'), + name: 'task_output', + arguments: { task_id: taskId }, + }) + backgroundText += read.content.filter(block => block.type === 'text').map(block => block.text).join('') + if (backgroundText.includes('loader-bg-ok') && backgroundText.includes('[status: completed')) break + await new Promise(resolve => setTimeout(resolve, 50)) + } + + await writeFile('./pwsh-loader-report.json', JSON.stringify({ + schemaHasRunInBackground: Object.hasOwn(schema.parameters.properties as object, 'run_in_background'), + promptHasMarkerSection: prompt?.text.includes('Non-zero exits are reported as `[exit code: N]` markers') === true, + // Normalize PowerShell's platform line endings (CRLF on Windows, LF elsewhere). + foregroundText: foregroundText.replace(/\r\n/g, '\n'), + backgroundText: backgroundText.replace(/\r\n/g, '\n'), + })) +} finally { + await ctx.fiber.dispose() +} diff --git a/examples/acp-agent/tests/pwsh.cordis.snapshot.yml b/examples/acp-agent/tests/pwsh.cordis.snapshot.yml new file mode 100644 index 0000000000..9daab43aff --- /dev/null +++ b/examples/acp-agent/tests/pwsh.cordis.snapshot.yml @@ -0,0 +1,38 @@ +# Minimal keyless composition: real app, pwsh executor, and pwsh tool; replayed model. +- id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek-official + name: DeepSeek + models: + - id: deepseek-v4-pro + +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + +- id: bash + name: '@deepseek-ai/dsh-pwsh-local' + +- id: bash-env + name: '@deepseek-ai/dsh-bash-env' + +- id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: deepseek-official + model: deepseek-v4-pro + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: none + workspaceContext: false + skills: + enabled: false + # task_output/task_kill stay mounted (the bundle's toolTasks default) so + # background pwsh runs are readable and killable. + goals: false + # The pwsh tool replaces the bundle's bash tool in this composition. + toolBash: false + persona: You are a concise snapshot agent working in {{cwd}}. + +- id: tool-pwsh + name: '@deepseek-ai/dsh-tool-pwsh' diff --git a/examples/acp-agent/tests/pwsh.cordis.yml b/examples/acp-agent/tests/pwsh.cordis.yml new file mode 100644 index 0000000000..7021ae2116 --- /dev/null +++ b/examples/acp-agent/tests/pwsh.cordis.yml @@ -0,0 +1,37 @@ +# Minimal live counterpart for the pwsh-tool-turn snapshot composition. +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY + baseURL: !!js process.env.DEEPSEEK_BASE_URL + models: + - id: deepseek-v4-pro + +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + +- id: bash + name: '@deepseek-ai/dsh-pwsh-local' + +- id: bash-env + name: '@deepseek-ai/dsh-bash-env' + +- id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: deepseek-official + model: deepseek-v4-pro + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'" + workspaceContext: false + skills: + enabled: false + # task_output/task_kill stay mounted (the bundle's toolTasks default) so + # background pwsh runs are readable and killable. + goals: false + # The pwsh tool replaces the bundle's bash tool in this composition. + toolBash: false + persona: You are a concise snapshot agent working in {{cwd}}. + +- id: tool-pwsh + name: '@deepseek-ai/dsh-tool-pwsh' diff --git a/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl b/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl index 5fe449ae48..d66020b594 100644 --- a/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl @@ -16,7 +16,7 @@ {"type":"assistant/chunk","seq":14,"time":1785487611319,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":15,"time":1785487611319,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_wait","name":"bash","arguments":"{\"command\":\"node -e \\\"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}"},{"type":"tool-call","id":"call_skipped","name":"bash","arguments":"{\"command\":\"printf skipped > skipped.txt\",\"description\":\"Write skipped marker\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"bd630f41-b45d-4183-a785-1ff6e7049b62"},"usage":{"inputTokens":10,"outputTokens":10}},"sourceEventSeqs":[7,8,9,10,11,12,13,14],"surfaceOp":"append"} {"type":"tool/call","seq":16,"time":1785487611319,"data":{"turn":1,"step":1,"callId":"call_wait","name":"bash","arguments":"{\"command\":\"node -e \\\"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}"}} -{"type":"tool/result","seq":17,"time":1785487611378,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_wait"},"content":[{"type":"tool-result","toolCallId":"call_wait","content":[{"type":"text","text":"Error: command aborted"}],"isError":true}],"role":"user","id":"252903b2-b4e1-4a33-81d8-d5befefcb27e"}},"sourceEventSeqs":[16],"surfaceOp":"append"} +{"type":"tool/result","seq":17,"time":1785487611378,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_wait"},"content":[{"type":"tool-result","toolCallId":"call_wait","content":[{"type":"text","text":"Error: tool call aborted"}],"isError":true}],"role":"user","id":"252903b2-b4e1-4a33-81d8-d5befefcb27e"},"error":{"name":"AbortError","code":"ABORTED"}},"sourceEventSeqs":[16],"surfaceOp":"append"} {"type":"tool/call","seq":18,"time":1785487611378,"data":{"turn":1,"step":1,"callId":"call_skipped","name":"bash","arguments":"{\"command\":\"printf skipped > skipped.txt\",\"description\":\"Write skipped marker\"}"}} {"type":"tool/result","seq":19,"time":1785487611378,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_skipped"},"content":[{"type":"tool-result","toolCallId":"call_skipped","content":[{"type":"text","text":"Error: tool call aborted before dispatch"}],"isError":true}],"role":"user","id":"282c5c8c-7296-4545-8014-e6393b351436"},"error":{"name":"AbortError","code":"ABORTED_BEFORE_DISPATCH"}},"sourceEventSeqs":[18],"surfaceOp":"append"} {"type":"step/end","seq":20,"time":1785487611378,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/pwsh-tool-turn/input.json b/examples/acp-agent/tests/snapshots/pwsh-tool-turn/input.json new file mode 100644 index 0000000000..653e9a346c --- /dev/null +++ b/examples/acp-agent/tests/snapshots/pwsh-tool-turn/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Use the pwsh tool to run exactly: [Console]::Out.Write('PWSH_OK'). Then reply with the single word DONE and stop." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/pwsh-tool-turn/session.jsonl b/examples/acp-agent/tests/snapshots/pwsh-tool-turn/session.jsonl new file mode 100644 index 0000000000..9dc17ef799 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/pwsh-tool-turn/session.jsonl @@ -0,0 +1,32 @@ +{"type":"session","version":0,"id":"0b7ff6ab-2486-4b2f-a43e-0fa29a1a46ed","createdAt":1785678162241,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"turn/start","seq":0,"time":1785678162244,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1785678162245,"data":{"content":[{"type":"text","text":"Use the pwsh tool to run exactly: [Console]::Out.Write('PWSH_OK'). Then reply with the single word DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"6efbdc24-7abe-4f34-ac6d-15f93d49ad9a"},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":1785678162246,"data":{"title":"Use the pwsh tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":1785678162261,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":1785678162261,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-pro"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":5,"time":1785678162262,"data":{"provider":"deepseek-official","model":"deepseek-v4-pro"}} +{"type":"assistant/chunk","seq":6,"time":1785678162968,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":7,"time0":1785678162968,"data":{"turn":1,"step":1,"index":0,"dt":[393,0,0,0,1,0,0,0,0,0,17,0,0,0,0,0,0,0,1,290,0],"texts":["The"," user"," wants"," me"," to"," run"," a"," PowerShell"," command"," and"," then"," reply"," with"," \"","D","ONE","\"."," Let"," me"," execute"," it","."]}} +{"type":"assistant/chunk","seq":29,"time":1785678163671,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":30,"time0":1785678163671,"data":{"turn":1,"step":1,"index":1,"dt":[0,0,0,0,0,0,17,0,0,0,0,0,109,0,0,0,0,0,0,22,0,0,0,0,275,0,1,0,0,0,0,0,0,0],"id":"call_00_e0MSVSocL0o4UWjOdG4c2072","name":"pwsh","args":["","{","\"","command","\"",": ","\"","[","Console","]","::","Out",".Write","('","P","WS","H","_OK","')","\"",", ","\"","description","\"",": ","\"","Write"," P","WS","H","_OK"," to"," console","\"","}"]}} +{"type":"assistant/chunk","seq":65,"time":1785678164124,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a PowerShell command and then reply with \"DONE\". Let me execute it."}}}} +{"type":"assistant/chunk","seq":66,"time":1785678164124,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_e0MSVSocL0o4UWjOdG4c2072","name":"pwsh","arguments":"{\"command\": \"[Console]::Out.Write('PWSH_OK')\", \"description\": \"Write PWSH_OK to console\"}"}}}} +{"type":"assistant/chunk","seq":67,"time":1785678164124,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1255,"outputTokens":99,"cacheReadTokens":0,"reasoningTokens":22}}}} +{"type":"assistant/chunk","seq":68,"time":1785678164124,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":69,"time":1785678164126,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to run a PowerShell command and then reply with \"DONE\". Let me execute it."},{"type":"tool-call","id":"call_00_e0MSVSocL0o4UWjOdG4c2072","name":"pwsh","arguments":"{\"command\": \"[Console]::Out.Write('PWSH_OK')\", \"description\": \"Write PWSH_OK to console\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"6e968eea-46b8-4489-8005-5e898d53c1a9"},"usage":{"inputTokens":1255,"outputTokens":99,"cacheReadTokens":0,"reasoningTokens":22}},"sourceEventSeqs":[6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68],"surfaceOp":"append"} +{"type":"tool/call","seq":70,"time":1785678164127,"data":{"turn":1,"step":1,"callId":"call_00_e0MSVSocL0o4UWjOdG4c2072","name":"pwsh","arguments":"{\"command\": \"[Console]::Out.Write('PWSH_OK')\", \"description\": \"Write PWSH_OK to console\"}"}} +{"type":"tool/result","seq":71,"time":1785678164405,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_e0MSVSocL0o4UWjOdG4c2072"},"content":[{"type":"tool-result","toolCallId":"call_00_e0MSVSocL0o4UWjOdG4c2072","content":[{"type":"text","text":"PWSH_OK"}],"isError":false}],"role":"user","id":"964dfad5-651e-47f0-90a5-fe5bc711a3ff"}},"sourceEventSeqs":[70],"surfaceOp":"append"} +{"type":"step/end","seq":72,"time":1785678164405,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":73,"time":1785678164410,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":74,"time":1785678165135,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":75,"time0":1785678165136,"data":{"turn":1,"step":2,"index":0,"dt":[176,44,56,0,0,42,0,0,0,48,0,0,0,48,0,0,60,0,0,0,0,39,0,0],"texts":["The"," command"," executed"," successfully"," and"," printed"," \"","P","WS","H","_OK","\"."," Now"," I"," need"," to"," reply"," with"," \"","D","ONE","\""," and"," stop","."]}} +{"type":"assistant/chunk","seq":100,"time":1785678165649,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":101,"time":1785678165649,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":102,"time":1785678165649,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":103,"time":1785678165693,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The command executed successfully and printed \"PWSH_OK\". Now I need to reply with \"DONE\" and stop."}}}} +{"type":"assistant/chunk","seq":104,"time":1785678165693,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":105,"time":1785678165693,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":90,"outputTokens":28,"cacheReadTokens":1280,"reasoningTokens":25}}}} +{"type":"assistant/chunk","seq":106,"time":1785678165693,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":107,"time":1785678165693,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The command executed successfully and printed \"PWSH_OK\". Now I need to reply with \"DONE\" and stop."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"91f35706-53e9-4fcd-891e-2c9eafccde98"},"usage":{"inputTokens":90,"outputTokens":28,"cacheReadTokens":1280,"reasoningTokens":25}},"sourceEventSeqs":[74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106],"surfaceOp":"append"} +{"type":"step/end","seq":108,"time":1785678165694,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":109,"time":1785678165694,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/pwsh-tool-turn/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/pwsh-tool-turn/stdout.expected.jsonl new file mode 100644 index 0000000000..82ae8907ca --- /dev/null +++ b/examples/acp-agent/tests/snapshots/pwsh-tool-turn/stdout.expected.jsonl @@ -0,0 +1,4 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/pwsh-tool-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/pwsh-tool-turn/system-prompt.expected.md new file mode 100644 index 0000000000..f354648c41 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/pwsh-tool-turn/system-prompt.expected.md @@ -0,0 +1,7 @@ +You are an AI agent powered by the DeepSeek Harness SDK. + +You are a concise snapshot agent working in {{cwd}}. + +Non-zero exits are reported as `[exit code: N]` markers; investigate failures before moving on. On Windows a killed process settles as `[exit code: 1]` without a signal marker; treat a bare exit 1 after an interruption as a termination, not a command failure. + +Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. diff --git a/examples/acp-agent/tests/snapshots/pwsh-tool-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/pwsh-tool-turn/tool-schemas.expected.json new file mode 100644 index 0000000000..611de722e3 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/pwsh-tool-turn/tool-schemas.expected.json @@ -0,0 +1,90 @@ +{ + "initial": [ + { + "name": "pwsh", + "description": "Execute a PowerShell command (`pwsh -Command`) and return its stdout/stderr. Each call runs in a fresh pwsh process: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Paths use native Windows form (`C:\\...`); read environment variables with `$env:NAME`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$env:DSH_*` variables; inspect them when needed. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. On Windows a force-killed command settles as `[exit code: 1]` without a signal marker — treat it as an interruption, not a command failure. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The PowerShell command to execute." + }, + "description": { + "type": "string", + "description": "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"Get-Process\" → \"List running processes\"." + }, + "timeoutMs": { + "type": "number", + "description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry." + }, + "workdir": { + "type": "string", + "description": "Working directory for this command. Defaults to the session workspace; a relative path is resolved against it." + }, + "run_in_background": { + "type": "boolean", + "description": "Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies." + } + }, + "required": [ + "command", + "description" + ] + } + }, + { + "name": "task_kill", + "description": "Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the tool that started the background work." + }, + "reason": { + "type": "string", + "description": "Optional short reason, recorded in the log and forwarded to the task." + } + }, + "required": [ + "task_id" + ] + } + }, + { + "name": "task_list", + "description": "List your background tasks (running and finished) with their ids, kinds, and statuses.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "task_output", + "description": "Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the tool that started the background work." + }, + "wait": { + "type": "boolean", + "description": "Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive." + }, + "timeout_ms": { + "type": "number", + "description": "Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum." + } + }, + "required": [ + "task_id" + ] + } + } + ], + "changes": [] +} diff --git a/examples/headless-agent/tests/code-mode.e2e.ts b/examples/headless-agent/tests/code-mode.e2e.ts index 1f708ab601..b8362fc6f8 100644 --- a/examples/headless-agent/tests/code-mode.e2e.ts +++ b/examples/headless-agent/tests/code-mode.e2e.ts @@ -13,6 +13,7 @@ import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' @@ -57,6 +58,7 @@ async function codeModeHarness(cwd: string): Promise { await harness.plugin(AgentLoop, { agents: [] }) await harness.plugin(LlmDeepSeek) await harness.plugin(LocalSubprocessService) + await harness.plugin(BashEnvPlugin) await harness.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 }) await harness.plugin(ToolBash) await harness.plugin(WorkerCodeRuntime, {}) @@ -117,6 +119,7 @@ async function backgroundCodeModeHarness(cwd: string): Promise { await harness.plugin(LocalTaskService) await harness.plugin(ToolTasks, {}) await harness.plugin(LocalSubprocessService) + await harness.plugin(BashEnvPlugin) await harness.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 }) await harness.plugin(ToolBash) return harness diff --git a/examples/headless-agent/tests/harness.ts b/examples/headless-agent/tests/harness.ts index c354205388..756cc58e39 100644 --- a/examples/headless-agent/tests/harness.ts +++ b/examples/headless-agent/tests/harness.ts @@ -4,6 +4,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' @@ -61,6 +62,7 @@ export async function codingHarness(workdir: string, options: CodingHarnessOptio models: [{ id: 'deepseek-v4-flash', contextWindow: options.modelContextWindow }], }) await ctx.plugin(LocalSubprocessService) + await ctx.plugin(BashEnvPlugin) await ctx.plugin(LocalBashExecutor, { cwd: workdir, timeoutMs: 30_000 }) await ctx.plugin(ToolBash) await ctx.plugin(ToolTodo) diff --git a/examples/package.json b/examples/package.json index 849d8fc3ff..b2e2dd90ac 100644 --- a/examples/package.json +++ b/examples/package.json @@ -14,6 +14,7 @@ "@deepseek-ai/dsh-agent-spine-demo": "workspace:*", "@deepseek-ai/dsh-app-boot": "workspace:*", "@deepseek-ai/dsh-bash": "workspace:*", + "@deepseek-ai/dsh-bash-env": "workspace:*", "@deepseek-ai/dsh-bash-local": "workspace:*", "@deepseek-ai/dsh-bash-sandbox": "workspace:*", "@deepseek-ai/dsh-cli-demo": "workspace:*", @@ -42,6 +43,7 @@ "@deepseek-ai/dsh-plan-mode": "workspace:*", "@deepseek-ai/dsh-pty": "workspace:*", "@deepseek-ai/dsh-pty-local": "workspace:*", + "@deepseek-ai/dsh-pwsh-local": "workspace:*", "@deepseek-ai/dsh-repeat-tool-guard": "workspace:*", "@deepseek-ai/dsh-repository-plugin": "workspace:*", "@deepseek-ai/dsh-sandbox-local": "workspace:*", @@ -81,6 +83,7 @@ "@deepseek-ai/dsh-tool-goal": "workspace:*", "@deepseek-ai/dsh-tool-lsp": "workspace:*", "@deepseek-ai/dsh-tool-pty": "workspace:*", + "@deepseek-ai/dsh-tool-pwsh": "workspace:*", "@deepseek-ai/dsh-tool-ralph": "workspace:*", "@deepseek-ai/dsh-tool-session-query": "workspace:*", "@deepseek-ai/dsh-tool-skill": "workspace:*", diff --git a/knip.json b/knip.json index fd941b03ca..46fbff979e 100644 --- a/knip.json +++ b/knip.json @@ -76,6 +76,16 @@ "tests/**/*.ts" ] }, + "packages/host/directory-picker-native": { + "entry": [ + "tests/**/*.spec.{ts,tsx}", + "tests/**/*.e2e.ts" + ], + "project": [ + "src/**/*.{ts,tsx}", + "tests/**/*.{ts,tsx}" + ] + }, "packages/client/web-ui": { "entry": [ "tests/**/*.spec.{ts,tsx}" diff --git a/package.json b/package.json index fef0a1eb53..905c39f33b 100644 --- a/package.json +++ b/package.json @@ -105,6 +105,7 @@ "constraints": "tsx scripts/check-workspace-constraints.ts", "doc-sync": "tsx scripts/run-gates.ts doc-sync", "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-package-invariants && pnpm run verify-built-package-invariants && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-runtime-closure && pnpm run verify-vendored-links", + "publish:npm-baseline": "tsx scripts/publish-npm-baseline.ts", "dsh": "node --import tsx/esm apps/cli/src/bin.ts", "demo:headless": "node --import tsx packages/examples/cli-demo/src/bin.ts --config examples/headless-agent/cordis.yml", "demo:code-mode": "node scripts/demo-code-mode.mjs", diff --git a/packages/acp/acp/package.json b/packages/acp/acp/package.json index 6ef2f4def5..7aa9f8c4ef 100644 --- a/packages/acp/acp/package.json +++ b/packages/acp/acp/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { diff --git a/packages/bash/README.i18n.yaml b/packages/bash/README.i18n.yaml index 0af14fda76..66cc852e04 100644 --- a/packages/bash/README.i18n.yaml +++ b/packages/bash/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bash/README.md -README.md: e60ad9b0e4c48cf35a2601e7dec4d2d50807707b -README.zh.md: deb23ea820de40c99f0affd3726d9a49857039ea +README.md: ef82e9f4684ecf551ac7701d812088dd6b2ef6d0 +README.zh.md: 84ff244ec3d1ff5d385a3eb334e4cf9f1fe31e03 diff --git a/packages/bash/README.md b/packages/bash/README.md index e60ad9b0e4..ef82e9f468 100644 --- a/packages/bash/README.md +++ b/packages/bash/README.md @@ -2,13 +2,16 @@ English | [中文](README.zh.md) -The canonical three-package capability seam (see [capability seams](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): an abstract executor interface, concrete implementations, and the model-facing tool that consumes it. All **product** packages. +The capability family spans the canonical executor seam, its implementations, the shared shell environment, and the model-facing tools. All **product** packages. | Package | Role | ctx key | |---|---|---| | `bash/` | Abstract bash executor seam (interface + vocabulary; sandbox result facts carry the [`sandbox/`](../sandbox/README.md) seam's mode/enforcement vocabulary, and the managed-env/output vocabulary is re-exported from the [`subprocess/`](../subprocess/README.md) seam) | `ctx.bash` | | `bash-local/` | Local `BashExecutor` implementation over the [`subprocess/`](../subprocess/README.md) service (command defaulting, deadlines, terminal env, background-read merge) | (registers `ctx.bash`) | | `bash-sandbox/` | Sandbox-consuming `BashExecutor` (wraps every command argv via `ctx.sandbox`, stamps denial/enforcement facts; extends `bash-local`'s mechanics) | (registers `ctx.bash`) | +| `pwsh-local/` | Local PowerShell `BashExecutor` implementation over the [`subprocess/`](../subprocess/README.md) service (executable resolution, UTF-8-pinned spawn, Windows termination semantics) | (registers `ctx.bash`) | +| `bash-env/` | Tool-independent managed `DSH_*` shell environment registry shared by the shell tools (built-in facts + effect-scoped contributors) | (registers `ctx.bashEnv`) | | `tool-bash/` | Model-facing `bash` schema; background processes register with the generic [`tasks/`](../tasks/README.md) runtime | (registers on `ctx.tools`) | +| `tool-pwsh/` | Model-facing PowerShell-dialect `pwsh` schema (behavior mirrors `tool-bash` minus the sandbox surface); background processes register with the generic [`tasks/`](../tasks/README.md) runtime | (registers on `ctx.tools`) | The interface lives at `bash/bash/`. `bash-sandbox` replacing `bash-local` without touching the interface or the tool is the split doing exactly what it exists for — a leaf `cordis.yml` picks one executor entry, plus a `ctx.sandbox` provider entry for the confined one (see [the acp-agent example's default composition](../../examples/acp-agent/)). diff --git a/packages/bash/README.zh.md b/packages/bash/README.zh.md index deb23ea820..84ff244ec3 100644 --- a/packages/bash/README.zh.md +++ b/packages/bash/README.zh.md @@ -2,13 +2,16 @@ [English](README.md) | 中文 -规范的三包能力 seam(见[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):抽象执行器接口、具体实现,以及消费该接口的面向模型工具。这些全是**产品**包。 +能力家族横跨规范执行器 seam、其实现、共享 shell 环境与面向模型的工具。这些全是**产品**包。 | 包 | 职责 | ctx key | |---|---|---| | `bash/` | 抽象 bash 执行器 seam(接口 + 词汇;沙箱结果事实携带 [`sandbox/`](../sandbox/README.md) seam 的模式/强制执行词汇,受管环境/输出词汇则从 [`subprocess/`](../subprocess/README.md) seam 重导出) | `ctx.bash` | | `bash-local/` | 构建在 [`subprocess/`](../subprocess/README.md) 服务之上的本地 `BashExecutor` 实现(命令默认值补全、deadline、终端环境、后台读取合并) | (注册 `ctx.bash`) | | `bash-sandbox/` | 消费沙箱的 `BashExecutor`(通过 `ctx.sandbox` 包装每个命令 argv,标记拒绝/强制执行事实;扩展 `bash-local` 的机制) | (注册 `ctx.bash`) | +| `pwsh-local/` | 构建在 [`subprocess/`](../subprocess/README.md) 服务之上的本地 PowerShell `BashExecutor` 实现(可执行文件解析、UTF-8 固定 spawn、Windows 终止语义) | (注册 `ctx.bash`) | +| `bash-env/` | 工具无关的受管 `DSH_*` shell 环境注册表,由 shell 工具共享(内置事实 + 受 effect 作用域约束的 contributor) | (注册 `ctx.bashEnv`) | | `tool-bash/` | 面向模型的 `bash` schema;后台进程注册到通用 [`tasks/`](../tasks/README.md) 运行时 | (注册到 `ctx.tools`) | +| `tool-pwsh/` | 面向模型的 PowerShell 方言 `pwsh` schema(行为镜像 `tool-bash`,减去 sandbox 面);后台进程注册到通用 [`tasks/`](../tasks/README.md) 运行时 | (注册到 `ctx.tools`) | 接口位于 `bash/bash/`。以 `bash-sandbox` 替换 `bash-local`,同时不改动接口或工具,正是这种拆分存在的意义:叶级 `cordis.yml` 选择一个执行器插件条目;受限实现还需再选择一个 `ctx.sandbox` 提供方插件条目(见 [acp-agent 示例的默认组合](../../examples/acp-agent/))。 diff --git a/packages/bash/bash-env/README.i18n.yaml b/packages/bash/bash-env/README.i18n.yaml new file mode 100644 index 0000000000..90d43daa7b --- /dev/null +++ b/packages/bash/bash-env/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/bash/bash-env/README.md +README.md: 7b939326d4effd14fc83ef0ad4e133f019f1011f +README.zh.md: aeb33629def3fcc10294bbca19b37d43dcc73a0c diff --git a/packages/bash/bash-env/README.md b/packages/bash/bash-env/README.md new file mode 100644 index 0000000000..7b939326d4 --- /dev/null +++ b/packages/bash/bash-env/README.md @@ -0,0 +1,51 @@ +# @deepseek-ai/dsh-bash-env + +English | [中文](README.zh.md) + +The tool-independent shell environment plugin: owns the `ctx.bashEnv` registry of trusted, per-execution `DSH_*` variables that the model-facing shell tools (`dsh-tool-bash`, `dsh-tool-pwsh`) collect into every shell call's environment. Built-in shell facts (`DSH_HOME`, `DSH_SHELL=1`, `DSH_SESSION_ID`) are owned by the registry itself; other plugins register additional enumerable facts with effect-scoped disposal, and duplicate ownership or undeclared runtime keys fail loudly. + +The package root exports the Cordis plugin contract (`name`, `inject`, `Config`, `apply`) plus the `BashEnvRegistry` service class and its contributor types; consumers use `ctx.bashEnv` after loading this plugin. + +## Config + +```yaml +- id: bash-env + name: '@deepseek-ai/dsh-bash-env' + config: + dshHome: C:\Users\me\.dsh # default: $DSH_HOME, then ~/.dsh +``` + +## Managed environment + +Every foreground and background model shell call receives a newly collected trusted `DSH_*` environment. `DSH_HOME` is the absolute Harness home resolved by [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) (`dshHome` config, then ambient `$DSH_HOME`, then `~/.dsh`) and `DSH_SHELL=1` identifies the managed child. Agent calls additionally receive `DSH_SESSION_ID=agent.session.header.id`; when the active persistence seam locates a JSONL artifact they also receive `DSH_SESSION_JSONL=`. The JSONL path is a location hint: it may not exist before the first flush or contain the current buffered turn, and it is not an authorization credential. + +`ctx.bashEnv` owns collection. Other plugins can register an effect-scoped contributor with a stable name, declared keys/descriptions, and `resolve(execution: ToolExecution)`; duplicate ownership and undeclared runtime keys fail loudly, while `list()` enumerates declarations without executing providers. Harness built-ins reserve `DSH_HOME`, `DSH_SHELL`, and `DSH_SESSION_ID`; this plugin's persistence translator owns `DSH_SESSION_JSONL` by reading the backend-neutral `sessionPersistence.locate()` seam. + +```ts +import type { Context } from 'cordis' +import type {} from '@deepseek-ai/dsh-bash-env' + +export const inject = ['bashEnv'] + +export function apply(ctx: Context): void { + ctx.bashEnv.register({ + name: 'deployment-region', + variables: { DSH_DEPLOYMENT_REGION: { description: 'Current deployment region.' } }, + resolve: execution => execution.agent === undefined ? {} : { DSH_DEPLOYMENT_REGION: 'cn-north' }, + }) +} +``` + +The overlay is computed from the current `ToolExecution` and passed through the dedicated `BashExecRequest.dshEnv` channel. The local executors remove all inherited `DSH_*` before merging that snapshot, so nested harnesses and concurrent parent/child agents cannot leak stale identities. `process.env` is never modified. The shell tools' descriptions teach the generic `$DSH_*` convention rather than naming persistence-specific variables or adding a permanent system-prompt section. + +## Model Experience + +Indirectly, through the shell tools (`dsh-tool-bash`, `dsh-tool-pwsh`), which collect this registry's managed `DSH_*` snapshot into every shell-tool call. + +#### KV Cache effect + +No direct invalidation; the named consumers own any request-prefix changes. + +## Known Limitations and Deferred Work + +- **`list()` enumerates contributor-declared variables only** — registry-owned built-ins (`DSH_HOME`, `DSH_SHELL`, `DSH_SESSION_ID`) are not included, so diagnostics, prompt, or UI code must not treat `list()` as an exhaustive environment catalog. diff --git a/packages/bash/bash-env/README.zh.md b/packages/bash/bash-env/README.zh.md new file mode 100644 index 0000000000..aeb33629de --- /dev/null +++ b/packages/bash/bash-env/README.zh.md @@ -0,0 +1,51 @@ +# @deepseek-ai/dsh-bash-env + +[English](README.md) | 中文 + +工具无关的 shell 环境插件:拥有 `ctx.bashEnv` 注册表,管理受信任的、每次执行收集的 `DSH_*` 变量,供模型可见的 shell 工具(`dsh-tool-bash`、`dsh-tool-pwsh`)收集进每次 shell 调用的环境。内置 shell 事实(`DSH_HOME`、`DSH_SHELL=1`、`DSH_SESSION_ID`)归注册表自身所有;其他插件可以注册额外的可枚举事实,注册随插件纤维(fiber)释放,重复所有权或未声明的运行时键会响亮失败。 + +包根导出 Cordis 插件契约(`name`、`inject`、`Config`、`apply`)以及 `BashEnvRegistry` 服务类及其 contributor 类型;消费者在加载本插件后使用 `ctx.bashEnv`。 + +## Config + +```yaml +- id: bash-env + name: '@deepseek-ai/dsh-bash-env' + config: + dshHome: C:\Users\me\.dsh # default: $DSH_HOME, then ~/.dsh +``` + +## Managed environment + +每次前台与后台模型 shell 调用都会收到一份新收集的受信任 `DSH_*` 环境。`DSH_HOME` 是由 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 解析的 Harness 主目录绝对路径(`dshHome` 配置,然后环境变量 `$DSH_HOME`,然后 `~/.dsh`),`DSH_SHELL=1` 标识受管理的子进程。带 agent 的调用额外收到 `DSH_SESSION_ID=agent.session.header.id`;当活动的持久化 seam 定位到 JSONL 工件时,它们还会收到 `DSH_SESSION_JSONL=<绝对目标路径>`。JSONL 路径只是位置提示:首次 flush 之前它可能不存在,也不一定包含当前缓冲中的轮次,并且它不是授权凭据。 + +`ctx.bashEnv` 负责收集。其他插件可以注册一个受 effect 作用域约束的 contributor,带有稳定名称、已声明的键/描述以及 `resolve(execution: ToolExecution)`;重复所有权与未声明的运行时键会响亮失败,而 `list()` 只枚举声明、不执行 provider。Harness 内置键保留 `DSH_HOME`、`DSH_SHELL` 与 `DSH_SESSION_ID`;本插件的持久化翻译器通过读取与后端无关的 `sessionPersistence.locate()` seam 拥有 `DSH_SESSION_JSONL`。 + +```ts +import type { Context } from 'cordis' +import type {} from '@deepseek-ai/dsh-bash-env' + +export const inject = ['bashEnv'] + +export function apply(ctx: Context): void { + ctx.bashEnv.register({ + name: 'deployment-region', + variables: { DSH_DEPLOYMENT_REGION: { description: 'Current deployment region.' } }, + resolve: execution => execution.agent === undefined ? {} : { DSH_DEPLOYMENT_REGION: 'cn-north' }, + }) +} +``` + +覆盖层根据当前 `ToolExecution` 计算,并通过专用的 `BashExecRequest.dshEnv` 通道传递。本地执行器在合并该快照前移除所有继承的 `DSH_*`,因此嵌套 harness 与并发的父子 agent 无法泄漏过期的身份。`process.env` 永不被修改。shell 工具的描述只教授通用的 `$DSH_*` 约定,而不是点名持久化相关的变量或添加常驻的 system-prompt 段落。 + +## Model Experience + +Indirectly, through the shell tools (`dsh-tool-bash`, `dsh-tool-pwsh`), which collect this registry's managed `DSH_*` snapshot into every shell-tool call. + +#### KV Cache effect + +No direct invalidation; the named consumers own any request-prefix changes. + +## Known Limitations and Deferred Work + +- **`list()` 只枚举 contributor 声明的变量** — 注册表自有的内置键(`DSH_HOME`、`DSH_SHELL`、`DSH_SESSION_ID`)不包含在内,因此诊断、prompt 或 UI 代码不得把 `list()` 当作完整的环境目录。 diff --git a/packages/bash/bash-env/package.json b/packages/bash/bash-env/package.json new file mode 100644 index 0000000000..33243ab344 --- /dev/null +++ b/packages/bash/bash-env/package.json @@ -0,0 +1,48 @@ +{ + "name": "@deepseek-ai/dsh-bash-env", + "description": "Tool-independent managed DSH_* shell environment registry", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-bash": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-paths": "^0.0.1", + "@deepseek-ai/dsh-session-persistence": "^0.0.1", + "@deepseek-ai/dsh-tools": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-bash": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-paths": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/bash/bash-env/src/index.ts b/packages/bash/bash-env/src/index.ts new file mode 100644 index 0000000000..c7caa89f08 --- /dev/null +++ b/packages/bash/bash-env/src/index.ts @@ -0,0 +1,217 @@ +/** + * Tool-independent shell environment plugin: owns the `ctx.bashEnv` registry of + * trusted, per-execution `DSH_*` variables consumed by the model-facing shell + * tools (`dsh-tool-bash`, `dsh-tool-pwsh`). Built-in shell facts are owned by + * the registry itself while plugins can register additional, enumerable facts + * with effect-scoped disposal. + * + * @module @deepseek-ai/dsh-bash-env + */ + +import { Service, type Context } from 'cordis' +import z from 'schemastery' +import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-bash' +import type { DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-bash' +import { DSH_HOME_ENV, resolveDshHome } from '@deepseek-ai/dsh-paths' +import type { ToolExecution } from '@deepseek-ai/dsh-tools' +import type {} from '@deepseek-ai/dsh-session-persistence' + +declare module 'cordis' { + interface Context { + bashEnv: BashEnvRegistry + } +} + +export const name = 'bash-env' +export const inject: string[] = [] + +/** Plugin config (all optional — the built-in facts resolve without defaults). */ +export interface Config { + /** DeepSeek Harness home directory exposed as `DSH_HOME`; defaults to `$DSH_HOME` or `~/.dsh`. */ + dshHome?: string +} + +/** Runtime configuration schema for the bash-env plugin. */ +export const Config: z = z.object({ + dshHome: z.string(), +}) + +/** Model-visible metadata for one managed `DSH_*` environment variable. */ +export interface BashEnvVariable { + /** Concise description of the environment fact represented by the variable. */ + description: string +} + +/** + * A plugin contribution to the managed environment of each model shell call. + * Declared keys make ownership conflicts detectable before the first command; + * `resolve` computes only the values available for the current execution. + */ +export interface BashEnvContributor { + /** Stable contributor name used in diagnostics and duplicate detection. */ + name: string + /** Complete set of `DSH_*` keys this contributor may return. */ + variables: Readonly> + /** + * Resolve this contributor's available values for one tool execution. + * @param execution - the shell tool execution and its optional calling agent. + * @returns a partial map containing only keys declared in {@link variables}. + */ + resolve(execution: ToolExecution): Readonly>> +} + +/** An enumerable declaration returned by {@link BashEnvRegistry.list}. */ +export interface BashEnvVariableInfo extends BashEnvVariable { + /** Contributor that owns the variable. */ + contributor: string + /** Declared `DSH_*` environment variable name. */ + key: DshEnvironmentKey +} + +const DSH_SHELL_KEY = `${DSH_ENV_PREFIX}SHELL` as const +const DSH_SESSION_ID_KEY = `${DSH_ENV_PREFIX}SESSION_ID` as const +const DSH_SESSION_JSONL_KEY = `${DSH_ENV_PREFIX}SESSION_JSONL` as const +const RESERVED_BASH_ENV_KEYS = new Set([ + DSH_HOME_ENV, + DSH_SHELL_KEY, + DSH_SESSION_ID_KEY, +]) +const BASH_ENV_KEY_SUFFIX = /^[A-Z][A-Z0-9_]*$/ + +/** + * Registry (`ctx.bashEnv`) for trusted, per-execution `DSH_*` variables. + * The namespace is rebuilt for every model shell call: ambient `DSH_*` values + * are discarded by the executor, then the registry's current snapshot is + * injected. Built-in shell facts remain owned by the registry itself while + * plugins can register additional, enumerable facts with effect-scoped + * disposal. + */ +export class BashEnvRegistry extends Service { + private readonly contributors = new Map() + private readonly keyOwners = new Map() + private readonly dshHome: string + + /** + * Create and install the `ctx.bashEnv` service. + * @param ctx - Cordis context that owns the service and registrations. + * @param config - home-directory configuration for the built-in variables. + */ + constructor(ctx: Context, config: Config = {}) { + super(ctx, 'bashEnv') + this.dshHome = resolveDshHome(config.dshHome) + } + + /** + * Register one environment contributor. Names and keys are unique; built-in + * keys are reserved. Registration is disposed with the calling plugin fiber. + * @param contributor - declared key ownership and per-execution resolver. + * @returns the disposer that unregisters the contribution. + */ + register(contributor: BashEnvContributor): () => void { + const dispose = this.ctx.effect(function* (this: BashEnvRegistry) { + if (contributor.name.trim().length === 0) { + throw new Error('bash env contributor name must be non-empty') + } + if (this.contributors.has(contributor.name)) { + throw new Error(`bash env contributor "${contributor.name}" is already registered`) + } + + const variables = Object.entries(contributor.variables) as [DshEnvironmentKey, BashEnvVariable][] + for (const [key, variable] of variables) { + if (!key.startsWith(DSH_ENV_PREFIX) + || !BASH_ENV_KEY_SUFFIX.test(key.slice(DSH_ENV_PREFIX.length))) { + throw new Error(`bash env contributor "${contributor.name}" declared invalid key "${key}"`) + } + if (RESERVED_BASH_ENV_KEYS.has(key)) { + throw new Error(`bash env contributor "${contributor.name}" cannot own reserved key "${key}"`) + } + if (variable.description.trim().length === 0) { + throw new Error(`bash env contributor "${contributor.name}" must describe "${key}"`) + } + const owner = this.keyOwners.get(key) + if (owner !== undefined) { + throw new Error(`bash env key "${key}" is already owned by contributor "${owner}"; contributor "${contributor.name}" cannot also own it`) + } + } + + this.contributors.set(contributor.name, contributor) + for (const [key] of variables) this.keyOwners.set(key, contributor.name) + yield () => { + this.contributors.delete(contributor.name) + for (const [key] of variables) this.keyOwners.delete(key) + } + }.bind(this), 'bashEnv.register()') + return () => void dispose() + } + + /** + * Build the trusted `DSH_*` snapshot for one shell tool execution. + * @param execution - the current tool execution. + * @returns an immutable environment overlay containing built-ins and current contributions. + */ + collect(execution: ToolExecution): DshEnvironment { + const values: Record = { + [DSH_HOME_ENV]: this.dshHome, + [DSH_SHELL_KEY]: '1', + } + if (execution.agent !== undefined) { + values[DSH_SESSION_ID_KEY] = execution.agent.session.header.id + } + + for (const contributor of [...this.contributors.values()].sort((left, right) => left.name.localeCompare(right.name))) { + const resolved = contributor.resolve(execution) + for (const [rawKey, value] of Object.entries(resolved)) { + const key = rawKey as DshEnvironmentKey + if (!Object.hasOwn(contributor.variables, key)) { + throw new Error(`bash env contributor "${contributor.name}" returned undeclared key "${key}"`) + } + if (typeof value !== 'string') { + throw new Error(`bash env contributor "${contributor.name}" returned a non-string value for "${key}"`) + } + values[key] = value + } + } + + return Object.freeze(Object.fromEntries(Object.entries(values).sort(([left], [right]) => left.localeCompare(right)))) + } + + // TODO(bash-env-list-builtins): Include registry-owned built-ins before diagnostics, + // prompt, or UI code treats list() as an exhaustive environment catalog. + /** + * Enumerate plugin-contributed variables without executing their resolvers. + * @returns declarations sorted by environment variable name. + */ + list(): BashEnvVariableInfo[] { + return [...this.contributors.values()] + .flatMap(contributor => Object.entries(contributor.variables).map(([key, variable]) => ({ + contributor: contributor.name, + description: variable.description, + key: key as DshEnvironmentKey, + }))) + .sort((left, right) => left.key.localeCompare(right.key)) + } +} + +/** + * Load the bash-env plugin: register the `ctx.bashEnv` service and the + * shell-agnostic persistence contributor (`DSH_SESSION_JSONL`). + * @param ctx - Cordis context that owns the service and registrations. + * @param config - home-directory configuration for the built-in variables. + */ +export function apply(ctx: Context, config: Config = {}): void { + const registry = new BashEnvRegistry(ctx, config) + registry.register({ + name: 'session-persistence', + variables: { + [DSH_SESSION_JSONL_KEY]: { + description: 'Absolute target path of the current session JSONL when the active persistence backend provides one.', + }, + }, + resolve(execution) { + const agent = execution.agent + if (agent === undefined) return {} + const location = ctx.get('sessionPersistence')?.locate(agent.session.header) + return location?.kind === 'jsonl' ? { [DSH_SESSION_JSONL_KEY]: location.path } : {} + }, + }) +} diff --git a/packages/bash/bash-env/src/invariant.ts b/packages/bash/bash-env/src/invariant.ts new file mode 100644 index 0000000000..31f842c56d --- /dev/null +++ b/packages/bash/bash-env/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-bash-env`. + * @module @deepseek-ai/dsh-bash-env/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-bash-env' + +/** Cordis companion plugin name. */ +export const name = 'bash-env-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the environment registry validates ownership and collected values at each + * registration/collection; it publishes no independent snapshot that a companion could cross-check. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/bash/tool-bash/tests/bash-env.spec.ts b/packages/bash/bash-env/tests/bash-env.spec.ts similarity index 79% rename from packages/bash/tool-bash/tests/bash-env.spec.ts rename to packages/bash/bash-env/tests/bash-env.spec.ts index d988075c5b..c93a768f80 100644 --- a/packages/bash/tool-bash/tests/bash-env.spec.ts +++ b/packages/bash/bash-env/tests/bash-env.spec.ts @@ -1,3 +1,9 @@ +/** + * Registry tests for `@deepseek-ai/dsh-bash-env`: built-in facts, contributor + * ownership and validation, collection ordering, effect-scoped disposal, and + * the explicit disposer contract. + */ + import { homedir } from 'node:os' import { join, resolve } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -5,7 +11,8 @@ import { Context } from 'cordis' import { CallId } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' import type { ToolExecution } from '@deepseek-ai/dsh-tools' -import { BashEnvRegistry } from '@deepseek-ai/dsh-tool-bash' +import { BashEnvRegistry } from '@deepseek-ai/dsh-bash-env' +import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env' const testToolSignal = new AbortController().signal @@ -190,4 +197,41 @@ describe('BashEnvRegistry', () => { dispose() expect(registry.collect(execution())).not.toHaveProperty('DSH_EXPLICIT_DISPOSAL') }) + + it('the plugin registers the service and the persistence contributor on load', async () => { + const ctx = new Context() + await ctx.plugin(BashEnvPlugin) + expect(ctx.bashEnv).toBeInstanceOf(BashEnvRegistry) + expect(ctx.bashEnv.list()).toEqual([ + { + contributor: 'session-persistence', + description: 'Absolute target path of the current session JSONL when the active persistence backend provides one.', + key: 'DSH_SESSION_JSONL', + }, + ]) + }) + + it('the persistence contributor resolves DSH_SESSION_JSONL only for a jsonl backend', async () => { + const ctx = new Context() + await ctx.plugin(BashEnvPlugin) + ctx.provide('sessionPersistence', { + locate: () => ({ kind: 'jsonl' as const, path: 'C:\\sessions\\s.jsonl' }), + }) + expect(ctx.bashEnv.collect(execution('sess-p')).DSH_SESSION_JSONL).toBe('C:\\sessions\\s.jsonl') + }) + + it('the persistence contributor omits the variable for a non-jsonl backend', async () => { + const ctx = new Context() + await ctx.plugin(BashEnvPlugin) + ctx.provide('sessionPersistence', { + locate: () => ({ kind: 'sqlite' as const, path: 'C:\\sessions\\s.db' }), + }) + expect(ctx.bashEnv.collect(execution('sess-p'))).not.toHaveProperty('DSH_SESSION_JSONL') + }) + + it('the persistence contributor omits the variable without a persistence backend', async () => { + const ctx = new Context() + await ctx.plugin(BashEnvPlugin) + expect(ctx.bashEnv.collect(execution('sess-p'))).not.toHaveProperty('DSH_SESSION_JSONL') + }) }) diff --git a/packages/bash/bash-env/tsconfig.json b/packages/bash/bash-env/tsconfig.json new file mode 100644 index 0000000000..bcf5eb5229 --- /dev/null +++ b/packages/bash/bash-env/tsconfig.json @@ -0,0 +1,36 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../bash/bash" + }, + { + "path": "../../util/paths" + }, + { + "path": "../../core/tools" + }, + { + "path": "../../session-persistence/session-persistence" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/bash/bash-local/package.json b/packages/bash/bash-local/package.json index bacc7ac92d..c3c3a5c3e6 100644 --- a/packages/bash/bash-local/package.json +++ b/packages/bash/bash-local/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/bash/bash-sandbox/package.json b/packages/bash/bash-sandbox/package.json index 0f2240630c..e3d3c3deb3 100644 --- a/packages/bash/bash-sandbox/package.json +++ b/packages/bash/bash-sandbox/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/bash/bash/package.json b/packages/bash/bash/package.json index b8ff310f01..71e9ed8d9f 100644 --- a/packages/bash/bash/package.json +++ b/packages/bash/bash/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/bash/pwsh-local/README.i18n.yaml b/packages/bash/pwsh-local/README.i18n.yaml new file mode 100644 index 0000000000..1b78ca75f9 --- /dev/null +++ b/packages/bash/pwsh-local/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/bash/pwsh-local/README.md +README.md: 9deba9c1b63ccfdb9e1805b9896db33f144839bf +README.zh.md: e45c820e1d5e31aebd9ed365c6130850f1db2a62 diff --git a/packages/bash/pwsh-local/README.md b/packages/bash/pwsh-local/README.md new file mode 100644 index 0000000000..9deba9c1b6 --- /dev/null +++ b/packages/bash/pwsh-local/README.md @@ -0,0 +1,56 @@ +# @deepseek-ai/dsh-pwsh-local + +English | [中文](README.zh.md) + +Local PowerShell implementation of the `@deepseek-ai/dsh-bash` executor seam over the [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) service: `PwshLocalExecutor` spawns `pwsh -NoLogo -NoProfile -NonInteractive -Command ` per call as a managed process through `ctx.subprocess`, and owns everything PowerShell-shaped — executable resolution, command defaulting and caps, timeout/cancel classification, the model-friendly terminal environment, and the model-facing stdout/stderr merge for background reads. Group mechanics (bounded spill-backed output, credential scrub, kill escalation, disposal) are the subprocess service's. + +The command string rides as ONE argv element to `-Command`: PowerShell itself parses the text, and no intermediate shell exists, so there is no shell-quoting layer to escape (the `bash -c` string domain has no equivalent here). Native Win32 paths (`C:\...`) pass through unchanged. + +The package root exports the default and named `PwshLocalExecutor` plugin, its `Config`, the pure `resolvePwshPath`/`candidatePwshPaths` helpers, and the `ENV_OVERRIDES`/`ENCODING_PREAMBLE` constants the executor injects into every spawn. + +## Config + +```yaml +- id: bash + name: '@deepseek-ai/dsh-pwsh-local' + config: + cwd: C:\path\to\workspace # default: process.cwd() + timeoutMs: 120000 # default foreground timeout + maxTimeoutMs: 600000 # cap for per-call overrides + maxOutputBytes: 64000 # per-stream in-memory cap; overflow spills to disk + maxSpillBytes: 67108864 # per-stream full-output spill cap + graceMs: 3000 # kill escalation and post-exit pipe-drain grace + pwshPath: C:\Program Files\PowerShell\7\pwsh.exe # explicit executable; else well-known locations, then PATH +``` + +## Behavior (and where it came from) + +The Windows counterpart of `dsh-bash-local`, deliberately mirroring its semantics call-for-call: + +- **Spawn per call, no shell state** — every call is a fresh non-interactive `pwsh -Command` (deterministic; no profile files). The `-NoLogo -NoProfile -NonInteractive` flags disable startup banners, profile loading, and prompts that would garble tool output. +- **UTF-8 output pinned** — every command runs with `[Console]::OutputEncoding` and `$OutputEncoding` set to UTF-8 first, so the Windows PowerShell 5.1 fallback (or any host whose console code page is not UTF-8) cannot garble non-ASCII output: the subprocess collector decodes bytes as UTF-8. Input encoding is left at the host default; pwsh 7 defaults to UTF-8 and is unaffected. +- **Executable resolution** — `resolvePwshPath` prefers an explicit `pwshPath`, then on Windows probes PowerShell 7's install location, every PATH entry (Microsoft Store installs; surrounding quotes stripped), and Windows PowerShell 5.1 as a legacy last resort, checking `existsSync` on each; elsewhere it falls back to a bare `pwsh` resolved through PATH. Resolution is a pure function of `(configured, env, platform)` and happens once at construction. +- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs`. Tree termination (taskkill on Windows, process-group signals on POSIX), the post-exit pipe-drain grace, tail-keep truncation, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`. +- **Timeout and cancel classification** — `run()` fuses its config-clamped timeout with the caller's signal through one deadline; only the executor's own timeout reports `timedOut`, an upstream cancel reports `aborted`, and a self-terminated command reports neither ([timeout-library Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)). Windows reports forced termination as exit 1 without a signal, so signal-stamped facts (`signal`, `killed` status) are POSIX-only there; the timeout/abort classification is platform-independent. +- **Model-friendly terminal env** — `NO_COLOR=1 PAGER=cat GIT_PAGER=cat` (no `TERM=dumb`: that is a POSIX concept; `NO_COLOR` is honored by modern PowerShell renderers) merged as ordinary env under the service's credential scrub and `DSH_*` channel rules; an explicit caller entry still wins. +- **Background processes** — `start()` returns a live `BashProcess` handle immediately, no timeout applies, and the handle's `readOutput()` merges the service's offset-based stdout/stderr reads into one marked-section delta with a consuming cursor. A still-running process belongs to the subprocess service, so it survives executor reloads and dies (killed and joined) with the service's disposal. Everything task-shaped (ids, ownership, polling, notices) lives in the generic [`ctx.tasks` runtime](../../tasks/tasks/README.md), which the tool layer registers the handle with — this executor never sees a session or a registry. + +## Model Experience + +Indirectly, through `dsh-tool-pwsh`, which renders this executor's bounded stdout/stderr tails, background-process deltas (through the generic task runtime), spill-file paths, and infrastructure failures. + +#### KV Cache effect + +No direct invalidation; the named consumer owns any request-prefix changes. + +## Known Limitations and Deferred Work + +- **Unconfined by itself** — this executor always runs commands with the harness process's authority; deployments needing confinement compose a sandboxing bash executor or policy instead. +- **No persistent shell or PTY** — every call starts a fresh `pwsh -Command`; interactive terminal sessions remain deferred until the roadmap's pwsh TUI/GUI rendering work lands. +- **The command string is PowerShell text** — the `-Command` domain has no shell-quoting layer, but a model-facing command is parsed by PowerShell itself, so PowerShell syntax errors are command failures, not launch failures. +- **A background spawn-failure note is single-delivery** — the subprocess service buffers no output for a process that never ran, so the executor injects `spawn failed: …` into exactly one `readOutput()` delta; a reader that discards that delta cannot recover it. +- **Windows termination reports no signal** — a force-killed process settles as exit 1 with `signal: null`, so signal-based status classification (POSIX `killed`) does not apply on Windows; `kill()`-initiated stops still stamp `killed` directly. +- **The encoding preamble precedes the command** — PowerShell requires `param(...)`, `#requires`, and `using namespace`/`using assembly` statements at the very top of a script, so a command whose first statement is one of those cannot run under the UTF-8 output preamble. Wrap a `param(...)` script in `& { … }` (a param block legally heads a script block); `using` statements and `#requires` have no in-command workaround (`#requires` is inert inside `-Command` regardless of position) — run such scripts from a file instead. +- **Non-ASCII stdin under Windows PowerShell 5.1 may be mis-decoded** — the preamble pins output encoding only; `[Console]::InputEncoding` stays at the host default because setting it under redirected stdin throws. pwsh 7 defaults to UTF-8 and is unaffected. + +Scrub-heuristic and spill-retention caveats live with [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md), which owns those mechanics. diff --git a/packages/bash/pwsh-local/README.zh.md b/packages/bash/pwsh-local/README.zh.md new file mode 100644 index 0000000000..e45c820e1d --- /dev/null +++ b/packages/bash/pwsh-local/README.zh.md @@ -0,0 +1,56 @@ +# @deepseek-ai/dsh-pwsh-local + +[English](README.md) | 中文 + +`@deepseek-ai/dsh-bash` 执行器 seam 的本地 PowerShell 实现,基于 [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) 服务:`PwshLocalExecutor` 每次调用以受管进程的方式通过 `ctx.subprocess` spawn `pwsh -NoLogo -NoProfile -NonInteractive -Command `,并拥有所有 PowerShell 形状的职责——可执行文件解析、命令默认化与上限、超时/取消分类、面向模型的终端环境,以及后台读取的 stdout/stderr 合并。进程组机制(有界 spill 输出、凭据清理、终止升级、销毁)属于 subprocess 服务。 + +命令字符串作为 ONE argv 元素传给 `-Command`:由 PowerShell 自己解析文本,不存在中间 shell,因此没有需要转义的 shell 引号层(`bash -c` 字符串域在这里没有对应物)。原生 Win32 路径(`C:\...`)原样通过。 + +包根导出默认与具名 `PwshLocalExecutor` 插件、其 `Config`、纯函数 `resolvePwshPath`/`candidatePwshPaths` 辅助函数,以及执行器注入每次 spawn 的 `ENV_OVERRIDES`/`ENCODING_PREAMBLE` 常量。 + +## 配置 + +```yaml +- id: bash + name: '@deepseek-ai/dsh-pwsh-local' + config: + cwd: C:\path\to\workspace # default: process.cwd() + timeoutMs: 120000 # default foreground timeout + maxTimeoutMs: 600000 # cap for per-call overrides + maxOutputBytes: 64000 # per-stream in-memory cap; overflow spills to disk + maxSpillBytes: 67108864 # per-stream full-output spill cap + graceMs: 3000 # kill escalation and post-exit pipe-drain grace + pwshPath: C:\Program Files\PowerShell\7\pwsh.exe # explicit executable; else well-known locations, then PATH +``` + +## 行为(及其由来) + +作为 `dsh-bash-local` 的 Windows 对应物,逐调用地镜像其语义: + +- **每次调用新建进程,无 shell 状态**——每次调用都是全新的非交互 `pwsh -Command`(确定性;不加载 profile 文件)。`-NoLogo -NoProfile -NonInteractive` 关闭启动横幅、profile 加载与会干扰工具输出的提示符。 +- **UTF-8 输出固定**——每条命令都先以 UTF-8 设置 `[Console]::OutputEncoding` 与 `$OutputEncoding`,因此 Windows PowerShell 5.1 兜底(或任何控制台代码页非 UTF-8 的主机)不会破坏非 ASCII 输出:subprocess collector 以 UTF-8 解码字节。输入编码保持宿主默认;pwsh 7 默认为 UTF-8,不受影响。 +- **可执行文件解析**——`resolvePwshPath` 优先显式 `pwshPath`,然后在 Windows 上依次探测 PowerShell 7 安装位置、每个 PATH 条目(Microsoft Store 安装;剥离两端引号)以及作为遗留兜底的 Windows PowerShell 5.1,逐一检查 `existsSync`;其他平台回退为通过 PATH 解析的裸 `pwsh`。解析是 `(configured, env, platform)` 的纯函数,在构造时执行一次。 +- **受管进程组之上的配置预算**——`resolve()` 从配置填充 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务提供显式字节上限、spill 上限与 `graceMs`。进程树终止(Windows 用 taskkill,POSIX 用进程组信号)、退出后管道排空宽限、保尾截断与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为单个受信调用方提高 stdout 捕获预算;stderr 与后台运行仍使用 `maxOutputBytes`。 +- **超时与取消分类**——`run()` 通过一个 deadline 融合配置夹取的超时与调用方信号;只有执行器自身超时报告 `timedOut`,上游取消报告 `aborted`,自我终止的命令两者都不报告(见 [timeout 库 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。Windows 将强制终止报告为退出码 1 且无信号,因此基于信号的实情(`signal`、`killed` 状态)在那里仅限 POSIX;超时/取消分类与平台无关。 +- **面向模型的终端环境**——`NO_COLOR=1 PAGER=cat GIT_PAGER=cat`(没有 `TERM=dumb`:那是 POSIX 概念;现代 PowerShell 渲染器遵循 `NO_COLOR`),作为普通 env 在服务的凭据清理与 `DSH_*` 通道规则之下合并;显式调用方条目仍然优先。 +- **后台进程**——`start()` 立即返回存活的 `BashProcess` 句柄,不设超时;句柄的 `readOutput()` 把服务基于偏移的 stdout/stderr 读取合并为带标记分段的增量与消费游标。仍在运行的进程属于 subprocess 服务,因此它跨执行器重载存活,并随服务销毁(被终止并 join)。一切任务形状的职责(id、所有权、轮询、通知)都在通用 [`ctx.tasks` 运行时](../../tasks/tasks/README.md) 中,由工具层把句柄注册进去——本执行器从不接触会话或注册表。 + +## 模型体验 + +间接地,经由 `dsh-tool-pwsh` 呈现本执行器的有界 stdout/stderr 尾部、后台进程增量(经通用任务运行时)、spill 文件路径与基础设施失败。 + +#### KV Cache 影响 + +无直接失效;具名消费方拥有请求前缀的任何变更。 + +## 已知局限与延期工作 + +- **自身不设沙箱**——本执行器始终以 harness 进程的权限运行命令;需要约束的部署应组合沙箱化 bash 执行器或策略。 +- **无持久 shell 或 PTY**——每次调用都是全新的 `pwsh -Command`;交互式终端会话在路线图的 pwsh TUI/GUI 渲染工作落地之前保持延期。 +- **命令字符串是 PowerShell 文本**——`-Command` 域没有 shell 引号层,但面向模型的命令由 PowerShell 自己解析,因此 PowerShell 语法错误是命令失败,而非启动失败。 +- **后台 spawn 失败提示只投递一次**——subprocess 服务不会为从未运行的进程缓冲输出,因此执行器只把 `spawn failed: …` 注入一次 `readOutput()` 增量;丢弃该增量的读取方无法恢复它。 +- **Windows 终止不报告信号**——被强制终止的进程以退出码 1、`signal: null` 结束,因此基于信号的状态分类(POSIX `killed`)在 Windows 上不适用;`kill()` 发起的停止仍会直接盖上 `killed`。 +- **编码 preamble 位于命令之前**——PowerShell 要求 `param(...)`、`#requires` 与 `using namespace`/`using assembly` 语句位于脚本最顶部,因此以其中一种开头的命令无法在 UTF-8 输出 preamble 下运行。`param(...)` 脚本可包进 `& { … }`(param 块可以合法地位于脚本块开头);`using` 语句与 `#requires` 在命令内没有变通办法(`#requires` 在 `-Command` 中无论位置如何都不生效)——此类脚本请改从文件运行。 +- **Windows PowerShell 5.1 下的非 ASCII stdin 可能被错误解码**——preamble 只固定输出编码;`[Console]::InputEncoding` 保持主机默认,因为在重定向 stdin 下设置它会抛出异常。pwsh 7 默认 UTF-8,不受影响。 + +清理启发式与 spill 保留的注意事项由 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 持有,它拥有这些机制。 diff --git a/packages/bash/pwsh-local/package.json b/packages/bash/pwsh-local/package.json new file mode 100644 index 0000000000..f65d524904 --- /dev/null +++ b/packages/bash/pwsh-local/package.json @@ -0,0 +1,45 @@ +{ + "name": "@deepseek-ai/dsh-pwsh-local", + "description": "Local PowerShell implementation of the DeepSeek Harness bash executor seam", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-bash": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-subprocess": "^0.0.1", + "@deepseek-ai/dsh-timeout": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-bash": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/bash/pwsh-local/src/index.ts b/packages/bash/pwsh-local/src/index.ts new file mode 100644 index 0000000000..316d2c8651 --- /dev/null +++ b/packages/bash/pwsh-local/src/index.ts @@ -0,0 +1,288 @@ +/** + * Local PowerShell implementation of the bash executor seam. Each command runs + * as `pwsh -NoLogo -NoProfile -NonInteractive -Command ` in a managed + * process spawned through `ctx.subprocess`; the executor owns command + * defaulting, deadlines and cause classification, the model-friendly terminal + * environment, and the model-facing stdout/stderr merge for background reads. + * + * The command string is passed as ONE argv element to `-Command`: PowerShell + * itself parses the text, and no intermediate shell exists, so there is no + * shell-quoting layer to escape (the `bash -c` string domain has no + * equivalent here). Native Win32 paths (`C:\...`) pass through unchanged. + * + * @module @deepseek-ai/dsh-pwsh-local + */ + +import { Context } from 'cordis' +import z from 'schemastery' +import { BashExecutor } from '@deepseek-ai/dsh-bash' +import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash' +import type { SubprocessCollect, SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' +import { resolvePwshPath } from './resolve.ts' + +/* jscpd:ignore-start -- deliberate call-for-call mirror of dsh-bash-local (Agent Note: pwsh-tool-and-executor). */ +/** + * Model-friendly environment overrides for PowerShell: disable colors and + * pagers that would garble tool output. `TERM=dumb` is a POSIX concept and is + * deliberately absent; `NO_COLOR` is honored by modern pwsh renderers. + */ +export const ENV_OVERRIDES = { + NO_COLOR: '1', + PAGER: 'cat', + GIT_PAGER: 'cat', +} as const + +/** + * UTF-8 output pinning prepended to every command. The subprocess collector + * decodes output bytes as UTF-8, but Windows PowerShell 5.1 (the last-resort + * executable fallback) writes the console/OEM code page by default, which + * garbles non-ASCII output; pwsh 7 defaults to UTF-8 and is unaffected. The + * statements ride on line 1 after `; ` separators so PowerShell error line + * numbers stay accurate. + */ +export const ENCODING_PREAMBLE = + '[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); $OutputEncoding = [System.Text.UTF8Encoding]::new($false); ' + +/** Default SIGTERM→SIGKILL grace period (the `graceMs` config). */ +const DEFAULT_GRACE_MS = 3_000 + +/** Default per-stream spill cap (the `maxSpillBytes` config). */ +const DEFAULT_MAX_SPILL_BYTES = 64 * 1024 * 1024 + +/** Plugin config (all optional — `static Config` supplies the defaults). */ +export interface Config { + /** Default working directory for commands (default: process.cwd()). */ + cwd?: string + /** Default foreground timeout in milliseconds. */ + timeoutMs?: number + /** Upper bound for per-call timeout overrides. */ + maxTimeoutMs?: number + /** Per-stream in-memory output cap; overflow spills to a temp file. */ + maxOutputBytes?: number + /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */ + maxSpillBytes?: number + /** Grace period for kill escalation and for inherited pipes after shell exit. */ + graceMs?: number + /** + * Explicit pwsh executable. When omitted, well-known Windows install + * locations and PATH entries are probed in order (PowerShell 7 install, + * PATH entries such as the Microsoft Store install, then Windows + * PowerShell 5.1), falling back to a bare `pwsh` resolved through PATH. + */ + pwshPath?: string +} + +/** The shape after schemastery applied the defaults (cwd/pwshPath have none). */ +type ResolvedConfig = Required> & Pick + +// Resolution lives in its own dependency-free module so the repository's +// coverage-gate probe shares the exact definition the suites use. +export { candidatePwshPaths, resolvePwshPath } from './resolve.ts' + +/** Project a settled collect-mode reader into the final CollectedOutput shape. */ +function finalOutput(reader: SubprocessOutputReader): CollectedOutput { + const read = reader.readFrom(0) + return { + text: read.text, + truncated: read.lossy, + ...read.spillPath !== undefined ? { spillPath: read.spillPath } : {}, + } +} + +function assertPositiveFinite(name: string, value: number): void { + if (!Number.isFinite(value) || value <= 0) { + throw new Error(`pwsh-local: ${name} must be a positive finite number`) + } +} + +/** + * Local PowerShell executor over `ctx.subprocess`. Bounded output, spill + * files, and process-tree termination are the subprocess service's mechanics; + * this executor supplies their configured budgets per spawn. + */ +export class PwshLocalExecutor extends BashExecutor { + static inject = ['subprocess'] + + static Config: z = z.object({ + cwd: z.string(), + timeoutMs: z.number().default(120_000), + maxTimeoutMs: z.number().default(600_000), + maxOutputBytes: z.number().default(64_000), + maxSpillBytes: z.number().default(DEFAULT_MAX_SPILL_BYTES), + graceMs: z.number().default(DEFAULT_GRACE_MS), + pwshPath: z.string(), + }) + + /** Validated config (schemastery applied the defaults before construction). */ + readonly config: ResolvedConfig + + /** The pwsh executable resolved once at construction. */ + readonly pwshPath: string + + constructor(ctx: Context, config: Config) { + super(ctx) + // Schemastery fills these fields before construction; the type does not encode that step. + this.config = config as ResolvedConfig + assertPositiveFinite('timeoutMs', this.config.timeoutMs) + assertPositiveFinite('maxTimeoutMs', this.config.maxTimeoutMs) + assertPositiveFinite('maxOutputBytes', this.config.maxOutputBytes) + assertPositiveFinite('maxSpillBytes', this.config.maxSpillBytes) + assertPositiveFinite('graceMs', this.config.graceMs) + this.pwshPath = resolvePwshPath(this.config.pwshPath) + } + + /** + * Resolve a request into a fully-specified spec: fill `workdir` from + * `config.cwd` (else `process.cwd()`), and `timeoutMs` from + * `config.timeoutMs`, capped at `config.maxTimeoutMs`. + */ + resolve(request: BashExecRequest): BashExecSpec { + const timeoutMs = clampTimeout( + request.timeoutMs, + this.config.timeoutMs, + this.config.maxTimeoutMs, + 'pwsh-local: request.timeoutMs', + ) + const stdoutMaxBytes = request.stdoutMaxBytes ?? this.config.maxOutputBytes + assertPositiveFinite('request.stdoutMaxBytes', stdoutMaxBytes) + return { + command: request.command, + workdir: request.workdir ?? this.config.cwd ?? process.cwd(), + timeoutMs, + stdoutMaxBytes, + ...request.signal ? { signal: request.signal } : {}, + ...request.stdin !== undefined ? { stdin: request.stdin } : {}, + ...request.env !== undefined ? { env: request.env } : {}, + ...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {}, + sandboxPolicy: request.sandboxPolicy, + } + } + + /** Map one resolved bash spec onto a fully-specified subprocess spawn. */ + private spawnSpec(spec: BashExecSpec, stdoutMaxBytes: number, signal: AbortSignal | undefined): SubprocessSpawnSpec { + const collect = (maxBytes: number): SubprocessCollect => + ({ maxBytes, spill: { maxBytes: this.config.maxSpillBytes } }) + return { + argv: [this.pwshPath, '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', `${ENCODING_PREAMBLE}${spec.command}`], + cwd: spec.workdir, + stdio: { + stdin: spec.stdin !== undefined ? { data: spec.stdin } : 'ignore', + stdout: collect(stdoutMaxBytes), + stderr: collect(this.config.maxOutputBytes), + }, + graceMs: this.config.graceMs, + signal, + env: { ...ENV_OVERRIDES, ...spec.env, ...spec.dshEnv }, + } + } + + /** The collect-mode readers the executor itself requested (present by construction). */ + private static collected(handle: SubprocessHandle): { stdout: SubprocessOutputReader; stderr: SubprocessOutputReader } { + const { stdout, stderr } = handle.collected + /* v8 ignore start -- collect dispositions expose both readers by the seam contract; defensive. */ + if (stdout === undefined || stderr === undefined) { + throw new Error('pwsh-local: subprocess implementation dropped a requested collect stream') + } + /* v8 ignore stop */ + return { stdout, stderr } + } + + async run(spec: BashExecSpec): Promise { + // One deadline combines timeout and upstream cancellation; disposal clears its timer. + using d = deadline(spec.signal, spec.timeoutMs, 'BASH_TIMEOUT') + const handle = this.ctx.subprocess.spawn(this.spawnSpec(spec, spec.stdoutMaxBytes, d.signal)) + const outcome = await handle.done + const collected = PwshLocalExecutor.collected(handle) + // Only this executor's timeout reason counts as timedOut; outer deadlines count as aborts. + const timedOut = timeoutOf(d.signal, 'BASH_TIMEOUT') !== undefined + const aborted = d.signal.aborted && !timedOut + return { + ...outcome, + timedOut, + aborted, + timeoutMs: spec.timeoutMs, + stdout: finalOutput(collected.stdout), + stderr: finalOutput(collected.stderr), + } + } + + start(spec: BashExecSpec): BashProcess { + // Background runs ignore timeoutMs; callers stop them through kill() or spec.signal. + const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, this.config.maxOutputBytes, spec.signal)) + const collected = PwshLocalExecutor.collected(running) + + // A spawn failure produces no process output, so the subprocess service has nothing + // to buffer; the note is delivered exactly once through the read path. + let spawnFailureNote: string | undefined + const consumeSpawnFailure = (): string => { + const note = spawnFailureNote ?? '' + spawnFailureNote = undefined + return note + } + + let stdoutOffset = 0 + let stderrOffset = 0 + const proc: BashProcess = { + status: 'running', + exitCode: null, + signal: null, + done: running.done.then((outcome) => { + // Any signal termination is killed, including a command signaling itself. + if (proc.status === 'running') { + proc.status = spec.signal?.aborted === true || outcome.signal !== null ? 'killed' : 'completed' + } + proc.exitCode = outcome.exitCode + proc.signal = outcome.signal + this.onProcessDone(proc, collected.stderr.readFrom(0).text) + }, (error: unknown) => { + // Background spawn failures settle as killed and surface through the read path. + proc.status = 'killed' + spawnFailureNote = `spawn failed: ${String(error)}` + this.onProcessDone(proc, spawnFailureNote) + }), + readOutput: (): BashProcessRead => { + const out = collected.stdout.readFrom(stdoutOffset) + const err = collected.stderr.readFrom(stderrOffset) + stdoutOffset = out.nextOffset + stderrOffset = err.nextOffset + + // A failed spawn never produced process output, so the note and real + // stderr text are mutually exclusive. + const errText = err.text.length > 0 ? err.text : consumeSpawnFailure() + // Single newline between sections: stdout chunks usually end with one + // already; add it only when missing. + const separator = out.text.length > 0 && !out.text.endsWith('\n') ? '\n' : '' + const delta = out.text + + (errText.length > 0 ? `${separator}[stderr]\n${errText}` : '') + return { + delta, + lossy: out.lossy || err.lossy, + ...out.spillPath !== undefined ? { stdoutSpillPath: out.spillPath } : {}, + ...err.spillPath !== undefined ? { stderrSpillPath: err.spillPath } : {}, + } + }, + kill: (): boolean => { + if (proc.status !== 'running') return false + proc.status = 'killed' + running.terminate() + return true + }, + } + return proc + } + + /** + * Settlement hook for subclasses that attach execution facts to a process. + * The base implementation is intentionally empty. Mirrored from + * `dsh-bash-local` (whose sandboxing subclass consumes the same hook); it is + * the declared seam for a future pwsh-confining subclass and has no consumer + * in this package yet. + * @param _proc - the settled process handle. + * @param _stderr - the process's retained stderr tail used by subclasses for settlement classification. + */ + protected onProcessDone(_proc: BashProcess, _stderr: string): void {} +} +/* jscpd:ignore-end */ + +export default PwshLocalExecutor diff --git a/packages/bash/pwsh-local/src/invariant.ts b/packages/bash/pwsh-local/src/invariant.ts new file mode 100644 index 0000000000..4bb1c1ea30 --- /dev/null +++ b/packages/bash/pwsh-local/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-pwsh-local`. + * @module @deepseek-ai/dsh-pwsh-local/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-pwsh-local' + +/** Cordis companion plugin name. */ +export const name = 'pwsh-local-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: this package exposes no independent event sequence or mutable data relation + * beyond contracts enforced at its owning seam. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/bash/pwsh-local/src/resolve.ts b/packages/bash/pwsh-local/src/resolve.ts new file mode 100644 index 0000000000..c6ded2f883 --- /dev/null +++ b/packages/bash/pwsh-local/src/resolve.ts @@ -0,0 +1,60 @@ +/** + * PowerShell executable resolution, dependency-free so non-package consumers + * (the repository's coverage-gate probe in `vitest.config.ts`) can share the + * ONE resolution definition with the executor and its suites — a probe that + * resolved differently from the code under test could exempt a file whose + * suites actually run. + * + * @module @deepseek-ai/dsh-pwsh-local/resolve + */ + +import { existsSync } from 'node:fs' +import { join } from 'node:path' + +/** + * Well-known Windows PowerShell install locations plus PATH entries, newest + * first. Explicitly parameterized (env) so resolution is a pure function of + * its inputs on every platform. + * @param env - the environment to probe; defaults to the process environment. + * @returns candidate `pwsh` executable paths in resolution order. + */ +export function candidatePwshPaths(env: NodeJS.ProcessEnv = process.env): string[] { + const programFiles = env.ProgramFiles ?? 'C:\\Program Files' + const systemRoot = env.SystemRoot ?? 'C:\\Windows' + const candidates = [ + join(programFiles, 'PowerShell', '7', 'pwsh.exe'), + ] + // Microsoft Store installs (and any user-added location) live on PATH; + // entries may carry surrounding quotes from `setx`-style definitions. + for (const entry of (env.PATH ?? '').split(';')) { + const trimmed = entry.trim().replace(/^"|"$/g, '') + if (trimmed.length === 0) continue + candidates.push(join(trimmed, 'pwsh.exe')) + } + // Windows PowerShell 5.1 remains the last-resort fallback on legacy hosts. + candidates.push(join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')) + return candidates +} + +/** + * Resolve the pwsh executable this executor spawns. + * @param configured - an explicit `pwshPath` config value, trusted as-is. + * @param env - the environment to probe on Windows; defaults to the process environment. + * @param platform - the platform to resolve for; defaults to the process platform. + * @returns the first existing well-known location on Windows (PowerShell 7 + * install, a PATH entry such as the Microsoft Store install, then Windows + * PowerShell 5.1), else `pwsh` for PATH resolution. + */ +export function resolvePwshPath( + configured?: string, + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform, +): string { + if (configured !== undefined && configured.length > 0) return configured + if (platform === 'win32') { + for (const candidate of candidatePwshPaths(env)) { + if (existsSync(candidate)) return candidate + } + } + return 'pwsh' +} diff --git a/packages/bash/pwsh-local/tests/executor.spec.ts b/packages/bash/pwsh-local/tests/executor.spec.ts new file mode 100644 index 0000000000..4552f2eeec --- /dev/null +++ b/packages/bash/pwsh-local/tests/executor.spec.ts @@ -0,0 +1,454 @@ +/** + * Real-process tests for `@deepseek-ai/dsh-pwsh-local`: the LOCAL subprocess + * service plus a REAL pwsh executable, exercised through the executor seam + * (`resolve` → `run`/`start`). These verify the world — actual PowerShell + * runs, output capture, truncation and spill, deadlines, kill escalation, and + * the background-handle contract. The suite self-skips when no usable `pwsh` + * resolves (a CI accommodation for hosts without PowerShell); the pure unit tests + * (config validation, executable resolution) run on every platform. PowerShell + * writes CRLF on Windows, so exact text assertions normalize line endings. + */ + +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { spawnSync } from 'node:child_process' +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { PwshLocalExecutor, ENCODING_PREAMBLE, candidatePwshPaths, resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' +import SubprocessService from '@deepseek-ai/dsh-subprocess' +import type { SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import type { BashProcess } from '@deepseek-ai/dsh-bash' + +const spillDir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-exec-spec-')) + +// The probe follows the executor's own resolution (Program Files installs on +// Windows are found even when bare `pwsh` is not on PATH). +const hasPwsh = spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0 + +/** Normalize PowerShell's platform line endings (CRLF on Windows, LF elsewhere). */ +const lf = (text: string): string => text.replace(/\r\n/g, '\n') + +/** Case-insensitive path equality on Windows (Get-Location may re-case the drive). */ +function samePath(actual: string, expected: string): boolean { + const norm = (value: string) => (process.platform === 'win32' ? value.toLowerCase() : value) + return norm(actual) === norm(expected) +} + +async function setup(config: ConstructorParameters[1] = {}) { + const ctx = new Context() + await ctx.plugin(LocalSubprocessService) + ;(ctx.subprocess as LocalSubprocessService).internals = { spillDir } + // A short kill grace via the REAL config path, so escalation tests stay fast. + await ctx.plugin(PwshLocalExecutor, { graceMs: 200, ...config }) + const bash = ctx.bash as PwshLocalExecutor + return { ctx, bash } +} + +/** + * Poll a handle's consuming readOutput until the ACCUMULATED delta contains + * `expected`; returns the accumulation (reads never re-deliver, so the caller + * gets everything produced up to the match). + */ +async function readUntil(proc: BashProcess, expected: string, timeoutMs = 5_000): Promise { + const deadline = Date.now() + timeoutMs + let all = '' + while (Date.now() < deadline) { + all += proc.readOutput().delta + if (lf(all).includes(expected)) return lf(all) + await new Promise(resolve => setTimeout(resolve, 20)) + } + throw new Error(`process output did not include ${JSON.stringify(expected)}; accumulated ${JSON.stringify(lf(all))}`) +} + +describe('resolvePwshPath and candidatePwshPaths (pure, every platform)', () => { + it('trusts an explicit configured path verbatim', () => { + expect(resolvePwshPath('C:\\custom\\pwsh.exe')).toBe('C:\\custom\\pwsh.exe') + expect(resolvePwshPath('pwsh')).toBe('pwsh') + }) + + it('falls through an empty configured path to platform resolution', () => { + // SystemRoot points at a non-existent tree so the Windows PowerShell 5.1 + // fallback candidate cannot exist either. + expect(resolvePwshPath('', { PATH: 'P:\\Store', SystemRoot: 'S:\\no-windows' }, 'win32')).toBe('pwsh') + }) + + it('returns pwsh on non-Windows platforms regardless of the environment', () => { + expect(resolvePwshPath(undefined, { ProgramFiles: 'P:\\Program Files' }, 'linux')).toBe('pwsh') + expect(resolvePwshPath(undefined, { PATH: 'P:\\Store' }, 'darwin')).toBe('pwsh') + }) + + it('lists PowerShell 7, PATH entries (quotes stripped), then Windows PowerShell 5.1 on win32', () => { + const candidates = candidatePwshPaths({ + ProgramFiles: 'P:\\Program Files', + SystemRoot: 'S:\\Windows', + PATH: ';"Q:\\quoted store";' + ';', + }) + expect(candidates).toEqual([ + join('P:\\Program Files', 'PowerShell', '7', 'pwsh.exe'), + join('Q:\\quoted store', 'pwsh.exe'), + join('S:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'), + ]) + // A missing PATH contributes no entries (the empty-string fallback). + expect(candidatePwshPaths({ ProgramFiles: 'P:\\Program Files', SystemRoot: 'S:\\Windows' })) + .toEqual([ + join('P:\\Program Files', 'PowerShell', '7', 'pwsh.exe'), + join('S:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'), + ]) + }) + + it('returns the first EXISTING win32 candidate, else pwsh', () => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-resolve-')) + const store = join(dir, 'store') + mkdirSync(store, { recursive: true }) + writeFileSync(join(store, 'pwsh.exe'), '') + // The existing PATH entry wins over the non-existent Program Files install. + expect(resolvePwshPath(undefined, { ProgramFiles: join(dir, 'missing'), PATH: store }, 'win32')) + .toBe(join(store, 'pwsh.exe')) + // No candidate exists anywhere (SystemRoot points at a non-existent tree, + // so even the Windows PowerShell 5.1 fallback cannot exist) → the + // PATH-resolution fallback. + expect(resolvePwshPath(undefined, { ProgramFiles: join(dir, 'missing'), PATH: join(dir, 'empty'), SystemRoot: join(dir, 'no-windows') }, 'win32')) + .toBe('pwsh') + }) +}) + +describe('spawn construction (pure, every platform)', () => { + /** A subprocess service that records spawn specs and settles instantly. */ + class CapturingSubprocessService extends SubprocessService { + specs: SubprocessSpawnSpec[] = [] + private readonly reader: SubprocessOutputReader = { + readFrom: () => ({ text: '', lossy: false, nextOffset: 0 }), + } + override spawn(spec: SubprocessSpawnSpec): SubprocessHandle { + this.specs.push(spec) + return { + pid: -1, + stdin: undefined, + stdout: undefined, + stderr: undefined, + collected: { stdout: this.reader, stderr: this.reader }, + done: Promise.resolve({ exitCode: 0, signal: null }), + terminate: () => {}, + waitForExit: async () => true, + } + } + } + + it('runs every command as ONE argv element under the UTF-8 encoding preamble', async () => { + const ctx = new Context() + const subprocess = new CapturingSubprocessService(ctx) + await ctx.plugin(PwshLocalExecutor) + await ctx.bash.run(ctx.bash.resolve({ command: 'Write-Output 你好' })) + expect(subprocess.specs).toHaveLength(1) + const { argv } = subprocess.specs[0]! + expect(argv.slice(0, 5)).toEqual([expect.any(String), '-NoLogo', '-NoProfile', '-NonInteractive', '-Command']) + expect(argv[5]).toBe(`${ENCODING_PREAMBLE}Write-Output 你好`) + expect(ENCODING_PREAMBLE).toContain('[Console]::OutputEncoding') + expect(ENCODING_PREAMBLE).toContain('$OutputEncoding') + }) +}) + +describe.skipIf(!hasPwsh)('PwshLocalExecutor.run', () => { + it('resolves with output and the effective timeout', async () => { + const { bash } = await setup({ timeoutMs: 5_000 }) + const result = await bash.run(bash.resolve({ command: 'Write-Output hi' })) + expect(result.exitCode).toBe(0) + expect(lf(result.stdout.text)).toBe('hi\n') + expect(result.timeoutMs).toBe(5_000) + }) + + it('uses config cwd, overridable per call', async () => { + const first = mkdtempSync(join(tmpdir(), 'dsh-pwsh-cwd-a-')) + const second = mkdtempSync(join(tmpdir(), 'dsh-pwsh-cwd-b-')) + const { bash } = await setup({ cwd: first }) + const fromConfig = await bash.run(bash.resolve({ command: '(Get-Location).Path' })) + expect(samePath(fromConfig.stdout.text.trim(), first)).toBe(true) + const fromCall = await bash.run(bash.resolve({ command: '(Get-Location).Path', workdir: second })) + expect(samePath(fromCall.stdout.text.trim(), second)).toBe(true) + }) + + it('defaults cwd to process.cwd()', async () => { + const { bash } = await setup() + const result = await bash.run(bash.resolve({ command: '(Get-Location).Path' })) + expect(samePath(result.stdout.text.trim(), process.cwd())).toBe(true) + }) + + it('caps per-call timeouts at maxTimeoutMs', async () => { + const { bash } = await setup({ timeoutMs: 1_000, maxTimeoutMs: 2_000 }) + const result = await bash.run(bash.resolve({ command: 'Write-Output ok', timeoutMs: 99_999 })) + expect(result.timeoutMs).toBe(2_000) + }) + + it('rejects invalid numeric config and timeout overrides', async () => { + await expect(setup({ timeoutMs: Number.NaN })).rejects.toThrow(/timeoutMs/) + await expect(setup({ maxTimeoutMs: 0 })).rejects.toThrow(/maxTimeoutMs/) + await expect(setup({ maxOutputBytes: -1 })).rejects.toThrow(/maxOutputBytes/) + await expect(setup({ maxSpillBytes: 0 })).rejects.toThrow(/maxSpillBytes/) + await expect(setup({ graceMs: 0 })).rejects.toThrow(/graceMs/) + + const { bash } = await setup() + expect(() => bash.resolve({ command: 'Write-Output ok', timeoutMs: Number.NaN })).toThrow(/request\.timeoutMs/) + expect(() => bash.resolve({ command: 'Write-Output ok', timeoutMs: -1 })).toThrow(/request\.timeoutMs/) + expect(() => bash.resolve({ command: 'Write-Output ok', stdoutMaxBytes: Number.NaN })).toThrow(/request\.stdoutMaxBytes/) + expect(() => bash.resolve({ command: 'Write-Output ok', stdoutMaxBytes: -1 })).toThrow(/request\.stdoutMaxBytes/) + }) + + it('defaults stdoutMaxBytes to maxOutputBytes and lets foreground callers raise stdout only', async () => { + const { bash } = await setup({ maxOutputBytes: 100 }) + expect(bash.resolve({ command: 'Write-Output ok' }).stdoutMaxBytes).toBe(100) + + // Raw Console writes avoid PowerShell's own line-ending and formatting + // layers, so the byte counts are exact on every platform. + const result = await bash.run(bash.resolve({ + command: '[Console]::Out.Write("x" * 500); [Console]::Error.WriteLine("e" * 500)', + stdoutMaxBytes: 500, + })) + + expect(result.stdout.text).toBe('x'.repeat(500)) + expect(result.stdout.truncated).toBe(false) + expect(result.stderr.truncated).toBe(true) + expect(result.stderr.text.length).toBeLessThanOrEqual(100) + }) + + it('per-call timeout takes precedence under the cap and kills on expiry', async () => { + const { bash } = await setup({ timeoutMs: 60_000 }) + const result = await bash.run(bash.resolve({ command: 'Start-Sleep -Seconds 60', timeoutMs: 100 })) + expect(result.timedOut).toBe(true) + // Mutually exclusive: a timeout classifies as timedOut, never also aborted. + expect(result.aborted).toBe(false) + expect(result.timeoutMs).toBe(100) + }) + + it('propagates abort signals', async () => { + const { bash } = await setup() + const controller = new AbortController() + const pending = bash.run(bash.resolve({ command: 'Start-Sleep -Seconds 60', signal: controller.signal })) + setTimeout(() => { controller.abort() }, 50) + const result = await pending + expect(result.aborted).toBe(true) + // Mutually exclusive: an upstream cancel classifies as aborted, never also timedOut. + expect(result.timedOut).toBe(false) + }) + + it('classifies a self-killed command as neither timed out nor aborted', async () => { + const { bash } = await setup({ timeoutMs: 60_000 }) + const result = await bash.run(bash.resolve({ command: 'Stop-Process -Id $PID' })) + expect(result.timedOut).toBe(false) + expect(result.aborted).toBe(false) + // Windows reports a forced termination without a signal; POSIX reports the + // terminating signal PowerShell chose (SIGTERM, or SIGKILL for the hard kill). + if (process.platform === 'win32') { + expect(result.signal).toBeNull() + } else { + expect(['SIGTERM', 'SIGKILL']).toContain(result.signal) + } + }) + + it('rejects on spawn failure (bad workdir)', async () => { + const { bash } = await setup() + await expect(bash.run(bash.resolve({ command: 'Write-Output ok', workdir: '/nonexistent-dsh' }))).rejects.toThrow(/ENOENT/) + }) + + it('resolve() carries stdin/env/dshEnv onto the spec, and run() threads them to the command', async () => { + const { bash } = await setup() + const spec = bash.resolve({ + command: '$s = ([Console]::In.ReadToEnd()).TrimEnd(); Write-Output $s; Write-Output "[$env:SEAM_VAR][$env:DSH_SEAM_VAR]"', + stdin: 'piped\n', + env: { SEAM_VAR: 'env-ok' }, + dshEnv: { DSH_SEAM_VAR: 'dsh-ok' }, + }) + // resolve() keeps the optional input/environment fields verbatim. + expect(spec.stdin).toBe('piped\n') + expect(spec.env).toEqual({ SEAM_VAR: 'env-ok' }) + expect(spec.dshEnv).toEqual({ DSH_SEAM_VAR: 'dsh-ok' }) + const result = await bash.run(spec) + expect(lf(result.stdout.text)).toBe('piped\n[env-ok][dsh-ok]\n') + }) + + it('resolve() omits stdin/env/dshEnv when the request supplies none', async () => { + const { bash } = await setup() + const spec = bash.resolve({ command: 'Write-Output ok' }) + expect('stdin' in spec).toBe(false) + expect('env' in spec).toBe(false) + expect('dshEnv' in spec).toBe(false) + }) +}) + +describe.skipIf(!hasPwsh)('PwshLocalExecutor.start (background process handles)', () => { + it('start returns immediately with a running handle that settles as completed', async () => { + const { bash } = await setup() + const before = Date.now() + const proc = bash.start(bash.resolve({ command: 'Start-Sleep -Milliseconds 200; Write-Output done' })) + expect(Date.now() - before).toBeLessThan(150) + expect(proc.status).toBe('running') + await proc.done + expect(proc.status).toBe('completed') + expect(proc.exitCode).toBe(0) + }) + + it('threads stdin and extra env into a background process', async () => { + const { bash } = await setup() + const proc = bash.start(bash.resolve({ + command: '$s = ([Console]::In.ReadToEnd()).TrimEnd(); Write-Output $s; Write-Output "[$env:BG_VAR][$env:DSH_BG_VAR]"', + stdin: 'bg-stdin\n', + env: { BG_VAR: 'bg-env' }, + dshEnv: { DSH_BG_VAR: 'bg-dsh-env' }, + })) + const output = await readUntil(proc, '[bg-env][bg-dsh-env]') + expect(output).toBe('bg-stdin\n[bg-env][bg-dsh-env]\n') + await proc.done + expect(proc.exitCode).toBe(0) + }) + + it('readOutput is consuming: increments are never re-delivered, and reads stay valid after exit', async () => { + const { bash } = await setup() + const proc = bash.start(bash.resolve({ command: 'Write-Output first; Start-Sleep -Seconds 1; Write-Output second' })) + const first = await readUntil(proc, 'first\n') + expect(lf(first)).toBe('first\n') + await proc.done + // Read-after-exit returns the remaining buffered output — once. + const second = proc.readOutput() + expect(lf(second.delta)).toBe('second\n') + expect(second.lossy).toBe(false) + expect(proc.readOutput().delta).toBe('') + }) + + it('readOutput marks stderr sections', async () => { + const { bash } = await setup() + const proc = bash.start(bash.resolve({ command: 'Write-Output out; [Console]::Error.WriteLine("err")' })) + await proc.done + expect(lf(proc.readOutput().delta)).toBe('out\n[stderr]\nerr\n') + }) + + it('readOutput reports stderr-only deltas without a leading newline', async () => { + const { bash } = await setup() + const proc = bash.start(bash.resolve({ command: '[Console]::Error.WriteLine("err")' })) + await proc.done + expect(lf(proc.readOutput().delta)).toBe('[stderr]\nerr\n') + }) + + it('readOutput adds a separator only when stdout lacks a trailing newline', async () => { + const { bash } = await setup() + const proc = bash.start(bash.resolve({ command: '[Console]::Out.Write("out"); [Console]::Error.WriteLine("err")' })) + await proc.done + expect(lf(proc.readOutput().delta)).toBe('out\n[stderr]\nerr\n') + }) + + it('readOutput flags lossy reads and reports stdout spill paths', async () => { + const { bash } = await setup({ maxOutputBytes: 100 }) + const proc = bash.start(bash.resolve({ command: '1..100 | ForEach-Object { "line-$_" }' })) + await proc.done + const read = proc.readOutput() + // Window slid past offset 0 → lossy, spill path points at the full stream. + expect(read.lossy).toBe(true) + expect(read.stdoutSpillPath).toBeDefined() + }) + + it('readOutput reports stderr spill paths', async () => { + const { bash } = await setup({ maxOutputBytes: 100 }) + const proc = bash.start(bash.resolve({ command: '1..100 | ForEach-Object { [Console]::Error.WriteLine("line-$_") }' })) + await proc.done + const read = proc.readOutput() + expect(read.lossy).toBe(true) + expect(read.stderrSpillPath).toBeDefined() + expect(lf(read.delta)).toContain('[stderr]') + }) + + it('kill() terminates the process tree: true once, false after settlement', async () => { + const { bash } = await setup() + const proc = bash.start(bash.resolve({ command: 'Start-Sleep -Seconds 60' })) + expect(proc.kill()).toBe(true) + await proc.done + expect(proc.status).toBe('killed') + expect(proc.kill()).toBe(false) + }) + + it('kill() returns false for a naturally completed process', async () => { + const { bash } = await setup() + const proc = bash.start(bash.resolve({ command: 'Write-Output ok' })) + await proc.done + expect(proc.status).toBe('completed') + expect(proc.kill()).toBe(false) + }) + + it('a spec.signal abort settles the handle as killed, not completed', async () => { + const { bash } = await setup() + const controller = new AbortController() + const proc = bash.start(bash.resolve({ command: 'Start-Sleep -Seconds 60', signal: controller.signal })) + controller.abort() + await proc.done + expect(proc.status).toBe('killed') + }) + + it.skipIf(process.platform === 'win32')('a self-signal exit settles the handle as killed, not completed (POSIX)', async () => { + const { bash } = await setup() + const proc = bash.start(bash.resolve({ command: 'Stop-Process -Id $PID' })) + await proc.done + expect(proc.status).toBe('killed') + expect(proc.exitCode).toBeNull() + // PowerShell picks SIGTERM for Stop-Process, SIGKILL for the hard kill. + expect(['SIGTERM', 'SIGKILL']).toContain(proc.signal) + }) + + it('a background spawn failure settles as killed with the error readable on stderr', async () => { + const { bash } = await setup() + const proc = bash.start(bash.resolve({ command: 'Write-Output ok', workdir: '/nonexistent-dsh' })) + // done resolves (never rejects) even though the process never ran. + await expect(proc.done).resolves.toBeUndefined() + expect(proc.status).toBe('killed') + expect(proc.readOutput().delta).toContain('spawn failed:') + }) +}) + +describe.skipIf(!hasPwsh)('process lifecycle ownership (the subprocess service, not the executor)', () => { + it('a background process survives executor-fiber disposal and dies with the subprocess service', async () => { + const ctx = new Context() + const managerFiber = await ctx.plugin(LocalSubprocessService) + ;(ctx.subprocess as LocalSubprocessService).internals = { spillDir } + const executorFiber = await ctx.plugin(PwshLocalExecutor, { graceMs: 200 }) + const bash = ctx.bash as PwshLocalExecutor + + // The child prints its own pid so the test can probe liveness through the + // public read surface alone. + const proc = bash.start(bash.resolve({ command: 'Write-Output $PID; Start-Sleep -Seconds 60' })) + const pid = Number((await readUntil(proc, '\n')).trim()) + expect(Number.isInteger(pid) && pid > 0).toBe(true) + + // Executor reload/disposal leaves background work running — the + // handle stays live and readable, mirroring the task runtime's + // registrations-outlive-producer-fibers contract. + await executorFiber.dispose() + expect(proc.status).toBe('running') + expect(() => process.kill(pid, 0)).not.toThrow() + + // Service disposal kills the group and AWAITS its exit (no orphans). + await managerFiber.dispose() + expect(() => process.kill(pid, 0)).toThrow() + await proc.done + // POSIX reports the kill as a signal; Windows reports a forced + // termination as exit 1 with no signal (indistinguishable from a crash), + // so the status stamp follows the platform's exit facts. + expect(proc.status).toBe(process.platform === 'win32' ? 'completed' : 'killed') + }) + + it('service disposal settles running handles and leaves settled ones untouched', async () => { + const ctx = new Context() + const managerFiber = await ctx.plugin(LocalSubprocessService) + ;(ctx.subprocess as LocalSubprocessService).internals = { spillDir } + await ctx.plugin(PwshLocalExecutor, { graceMs: 200 }) + const bash = ctx.bash as PwshLocalExecutor + + const finished = bash.start(bash.resolve({ command: 'Write-Output done' })) + await finished.done + expect(finished.status).toBe('completed') + const running = bash.start(bash.resolve({ command: 'Start-Sleep -Seconds 60' })) + + await managerFiber.dispose() + // A settled process was untouched; the live one was terminated and joined. + expect(finished.status).toBe('completed') + await running.done + expect(running.status).toBe(process.platform === 'win32' ? 'completed' : 'killed') + }) +}) diff --git a/packages/bash/pwsh-local/tsconfig.json b/packages/bash/pwsh-local/tsconfig.json new file mode 100644 index 0000000000..53ccc94926 --- /dev/null +++ b/packages/bash/pwsh-local/tsconfig.json @@ -0,0 +1,36 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../util/brand" + }, + { + "path": "../../util/timeout" + }, + { + "path": "../../bash/bash" + }, + { + "path": "../../subprocess/subprocess" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/bash/tool-bash/README.i18n.yaml b/packages/bash/tool-bash/README.i18n.yaml index efcd0be5d4..fb33cffefb 100644 --- a/packages/bash/tool-bash/README.i18n.yaml +++ b/packages/bash/tool-bash/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bash/tool-bash/README.md -README.md: 29b9fba369e1fc6a4b8bb7bdd6543b7678df627d -README.zh.md: ed6cf2c9fa9d0173202b132a89f014b5a7d2e248 +README.md: 35a5647365dab6daa903c14cba8b83702b50305d +README.zh.md: eb7901f3445117988d77e6d39b73681480b7a754 diff --git a/packages/bash/tool-bash/README.md b/packages/bash/tool-bash/README.md index 29b9fba369..35a5647365 100644 --- a/packages/bash/tool-bash/README.md +++ b/packages/bash/tool-bash/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The model-facing `bash` tool registered over the `ctx.bash` executor seam. Foreground execution stays behind that seam; a background process handle is registered with the generic `ctx.tasks` runtime and controlled through `task_output`, `task_list`, and `task_kill` from `@deepseek-ai/dsh-tool-tasks`. -Requires a loaded executor implementation (e.g. `@deepseek-ai/dsh-bash-local`); the plugin stays pending until `ctx.bash` exists (`inject: ['tools', 'bash', 'systemPrompt']`). +Requires a loaded executor implementation (e.g. `@deepseek-ai/dsh-bash-local`) and the [`@deepseek-ai/dsh-bash-env`](../bash-env/README.md) registry; the plugin stays pending until every injected service exists (`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`). The tool contract is bash-dialect — mount a bash-parsing executor. The package root exposes only the Cordis plugin contract (`name`, `inject`, `Config`, `apply`); result rendering and background-process adaptation remain implementation details covered by same-package tests. @@ -28,26 +28,7 @@ The plugin also contributes the `tool:bash` prompt section (order 105): check th ### Managed shell environment -Every foreground and background model bash call receives a newly collected trusted `DSH_*` environment. `DSH_HOME` is the absolute Harness home resolved by [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) (`dshHome` config, then ambient `$DSH_HOME`, then `~/.dsh`) and `DSH_SHELL=1` identifies the managed child. Agent calls additionally receive `DSH_SESSION_ID=agent.session.header.id`; when the active persistence seam locates a JSONL artifact they also receive `DSH_SESSION_JSONL=`. The JSONL path is a location hint: it may not exist before the first flush or contain the current buffered turn, and it is not an authorization credential. - -`ctx.bashEnv` owns collection. Other plugins can register an effect-scoped contributor with a stable name, declared keys/descriptions, and `resolve(execution: ToolExecution)`; duplicate ownership and undeclared runtime keys fail loudly, while `list()` enumerates declarations without executing providers. Harness built-ins reserve `DSH_HOME`, `DSH_SHELL`, and `DSH_SESSION_ID`; tool-bash's persistence translator owns `DSH_SESSION_JSONL` by reading the backend-neutral `sessionPersistence.locate()` seam. - -```ts -import type { Context } from 'cordis' -import type {} from '@deepseek-ai/dsh-tool-bash' - -export const inject = ['bashEnv'] - -export function apply(ctx: Context): void { - ctx.bashEnv.register({ - name: 'deployment-region', - variables: { DSH_DEPLOYMENT_REGION: { description: 'Current deployment region.' } }, - resolve: execution => execution.agent === undefined ? {} : { DSH_DEPLOYMENT_REGION: 'cn-north' }, - }) -} -``` - -The overlay is computed from the current `ToolExecution` and passed through the dedicated `BashExecRequest.dshEnv` channel. The local executor removes all inherited `DSH_*` before merging that snapshot, so nested harnesses and concurrent parent/child agents cannot leak stale identities. `process.env` is never modified. The tool description teaches the generic `$DSH_*` convention rather than naming persistence-specific variables or adding a permanent system-prompt section. +Every foreground and background model bash call receives a freshly collected trusted `DSH_*` environment through the shared [`dsh-bash-env`](../bash-env/README.md) registry: `DSH_HOME` (the absolute Harness home), `DSH_SHELL=1`, the agent's `DSH_SESSION_ID`, and `DSH_SESSION_JSONL` when the active persistence backend locates one. The registry contract — contributor registration, loud duplicate/undeclared-key failure, the built-in reservations, and the contributor example — lives in that package's README. The snapshot passes through the dedicated `BashExecRequest.dshEnv` channel; the local executor removes all inherited `DSH_*` before merging it, so nested harnesses and concurrent parent/child agents cannot leak stale identities, and `process.env` is never modified. The tool description teaches the generic `$DSH_*` convention rather than naming persistence-specific variables or adding a permanent system-prompt section. Result text contains stdout, an optional `[stderr]` section, then applicable sandbox-denial, timeout, signal, exit-code, and truncation markers. Timeout is reported independently of final exit status; nonzero exit remains a model-interpreted result rather than `isError`. Truncation links a safe complete spill file or reports it unavailable. Only infrastructure failures such as spawn errors and aborts produce `isError`. @@ -141,7 +122,7 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -Validation and policy failures are normalized as `Error: `. This package's stable messages are `invalid command: expected a non-empty string`, `invalid description: expected a non-empty string`, `invalid timeoutMs: expected a positive number, got `, `invalid escalation: sandbox_permissions requires a justification`, `invalid escalation: justification is only valid together with sandbox_permissions`, `invalid justification: expected a non-empty sentence`, `background execution is disabled for this bash tool`, `background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`, `sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`, `sandbox escalation to "" is not strictly wider than this call's current "" mode`, the approval-availability/rejection/cancellation variants, and `command aborted`. +Validation and policy failures are normalized as `Error: `. This package's stable messages are `invalid command: expected a non-empty string`, `invalid description: expected a non-empty string`, `invalid timeoutMs: expected a positive number, got `, `invalid escalation: sandbox_permissions requires a justification`, `invalid escalation: justification is only valid together with sandbox_permissions`, `invalid justification: expected a non-empty sentence`, `background execution is disabled for this bash tool`, `background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`, `sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`, `sandbox escalation to "" is not strictly wider than this call's current "" mode`, the approval-availability/rejection/cancellation variants, and `tool call aborted`. #### Token effect diff --git a/packages/bash/tool-bash/README.zh.md b/packages/bash/tool-bash/README.zh.md index ed6cf2c9fa..eb7901f344 100644 --- a/packages/bash/tool-bash/README.zh.md +++ b/packages/bash/tool-bash/README.zh.md @@ -4,9 +4,9 @@ 模型侧 `bash` 工具,注册在 `ctx.bash` 执行器 seam 上。前台执行始终位于该 seam 之后;后台进程句柄会注册到通用 `ctx.tasks` 运行时,并通过 `task_output`、`task_list` 和 `task_kill` 控制;这些工具由 `@deepseek-ai/dsh-tool-tasks` 提供。 -需要加载执行器实现(例如 `@deepseek-ai/dsh-bash-local`);在 `ctx.bash` 可用之前,插件会保持等待状态(`inject: ['tools', 'bash', 'systemPrompt']`)。 +需要加载执行器实现(例如 `@deepseek-ai/dsh-bash-local`)与 [`@deepseek-ai/dsh-bash-env`](../bash-env/README.md) 注册表;在每个注入服务就绪之前,插件会保持等待状态(`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`)。工具契约是 bash 方言——请挂载能解析 bash 的执行器。 -包根只公开 Cordis 插件契约(`name`、`inject`、`Config`、`apply`);结果渲染和后台进程适配仍是实现细节,由同包测试覆盖。 +包(package)根只公开 Cordis 插件契约(`name`、`inject`、`Config`、`apply`);结果渲染和后台进程适配仍是实现细节,由同包测试覆盖。 插件还会提供 `tool:bash` 提示词段落(顺序 105):检查每个结果中的 `[exit code: N]` 标记,发现失败时先调查原因再继续。 @@ -28,26 +28,7 @@ ### 托管 shell 环境 -每次模型发起的前台或后台 bash 调用都会收到新收集的一组可信 `DSH_*` 环境变量。`DSH_HOME` 是由 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 解析出的 Harness home 绝对路径(依次采用 `dshHome` 配置、环境中的 `$DSH_HOME`、`~/.dsh`),`DSH_SHELL=1` 则标识受托管的子进程。Agent 调用还会收到 `DSH_SESSION_ID=agent.session.header.id`;当活跃的持久化 seam 找到 JSONL 产物时,也会收到 `DSH_SESSION_JSONL=`。JSONL 路径只是位置提示:首次 flush 前它可能尚不存在,也可能不包含当前缓冲的轮次,并且它不是授权凭据。 - -`ctx.bashEnv` 持有收集过程。其他插件可以注册具有 effect 作用域的贡献方,提供稳定名称、已声明的键/说明以及 `resolve(execution: ToolExecution)`;重复持有或运行时返回未声明的键会快速失败,而 `list()` 无需执行提供方即可列举声明。Harness 内置项保留 `DSH_HOME`、`DSH_SHELL` 和 `DSH_SESSION_ID`;tool-bash 的持久化转换器持有 `DSH_SESSION_JSONL`,其值来自后端无关的 `sessionPersistence.locate()` seam。 - -```ts -import type { Context } from 'cordis' -import type {} from '@deepseek-ai/dsh-tool-bash' - -export const inject = ['bashEnv'] - -export function apply(ctx: Context): void { - ctx.bashEnv.register({ - name: 'deployment-region', - variables: { DSH_DEPLOYMENT_REGION: { description: 'Current deployment region.' } }, - resolve: execution => execution.agent === undefined ? {} : { DSH_DEPLOYMENT_REGION: 'cn-north' }, - }) -} -``` - -overlay 根据当前 `ToolExecution` 计算,并通过专用的 `BashExecRequest.dshEnv` 通道传递。本地执行器会先删除继承的所有 `DSH_*`,再合并该快照,因此嵌套 harness 和并发的父/子 agent 不会泄漏陈旧身份。它绝不会修改 `process.env`。工具说明只教授通用 `$DSH_*` 约定,不会点名持久化专用变量,也不会添加永久的系统提示词段落。 +每次模型发起的前台或后台 bash 调用都会通过共享的 [`dsh-bash-env`](../bash-env/README.md) 注册表收到新收集的一组可信 `DSH_*` 环境变量:`DSH_HOME`(Harness home 绝对路径)、`DSH_SHELL=1`、agent 的 `DSH_SESSION_ID`,以及当活跃持久化后端能定位时的 `DSH_SESSION_JSONL`。注册表契约——贡献方注册、重复/未声明键的响亮失败、内置项保留与贡献方示例——住在该包的 README 里。快照通过专用的 `BashExecRequest.dshEnv` 通道传递;本地执行器会先删除继承的所有 `DSH_*` 再合并,因此嵌套 harness 和并发的父/子 agent 不会泄漏陈旧身份,且绝不修改 `process.env`。工具说明只教授通用 `$DSH_*` 约定,不会点名持久化专用变量,也不会添加永久的系统提示词段落。 结果文本依次包含 stdout、可选的 `[stderr]` 段落和适用的沙箱拒绝、超时、信号、退出代码及截断标记。超时与最终退出状态分别报告;非零退出仍是由模型解释的结果,不会成为 `isError`。截断结果会链接安全的完整 spill 文件,或报告文件不可用。只有 spawn 错误和中止等基础设施故障才会产生 `isError`。 @@ -61,7 +42,7 @@ overlay 根据当前 `ToolExecution` 计算,并通过专用的 `BashExecReques ## 工具仅使用具名参数构建请求 -`BashExecRequest` seam 携带可选的 `stdoutMaxBytes`、`stdin`、普通 `env` 和托管 `dshEnv`,供可信进程内插件及此工具的环境注册表使用。模型侧工具不公开 `stdoutMaxBytes`、`stdin` 或 `env`:它使用具名的命令/工作目录/超时/信号/沙箱字段,加上从注册表收集的 `dshEnv` 来构建请求。额外模型键会被忽略,无法替换托管值。Shell 语法可以提供等价的命令级行为,而本地执行器会清除环境中的凭据和陈旧 `DSH_*` 值。参见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md)。 +`BashExecRequest` seam 携带可选的 `stdoutMaxBytes`、`stdin`、普通 `env` 和托管 `dshEnv`,供可信进程内插件及此工具的环境注册表使用。模型侧工具不公开 `stdoutMaxBytes`、`stdin` 或 `env`:它使用具名的命令/工作目录/超时/信号/沙箱字段,加上从注册表收集的 `dshEnv` 来构建请求。额外模型键会被忽略,无法替换托管值。Shell 语法可以提供等价的命令级行为,而本地执行器会清除环境中的凭据和陈旧 `DSH_*` 值。参见 [stdin/env Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md)。 ## 权限与升权 @@ -121,7 +102,7 @@ renderer 先输出依数据而定的 stdout 尾部,再输出可选的 `[stderr #### KV Cache 影响 -仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。 +仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 ### 后台任务上下文与结果 @@ -135,13 +116,13 @@ renderer 先输出依数据而定的 stdout 尾部,再输出可选的 `[stderr #### KV Cache 影响 -仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。 +仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 ### 工具错误 #### 模型看到的内容 -验证和策略失败统一为 `Error: `。此包的稳定消息包括 `invalid command: expected a non-empty string`、`invalid description: expected a non-empty string`、`invalid timeoutMs: expected a positive number, got `、`invalid escalation: sandbox_permissions requires a justification`、`invalid escalation: justification is only valid together with sandbox_permissions`、`invalid justification: expected a non-empty sentence`、`background execution is disabled for this bash tool`、`background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`、`sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`、`sandbox escalation to "" is not strictly wider than this call's current "" mode`、审批不可用/拒绝/取消变体,以及 `command aborted`。 +验证和策略失败统一为 `Error: `。此包的稳定消息包括 `invalid command: expected a non-empty string`、`invalid description: expected a non-empty string`、`invalid timeoutMs: expected a positive number, got `、`invalid escalation: sandbox_permissions requires a justification`、`invalid escalation: justification is only valid together with sandbox_permissions`、`invalid justification: expected a non-empty sentence`、`background execution is disabled for this bash tool`、`background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`、`sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`、`sandbox escalation to "" is not strictly wider than this call's current "" mode`、审批不可用/拒绝/取消变体,以及 `tool call aborted`。 #### Token 影响 @@ -149,7 +130,7 @@ renderer 先输出依数据而定的 stdout 尾部,再输出可选的 `[stderr #### KV Cache 影响 -仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。 +仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 ## 已知限制与延期工作 diff --git a/packages/bash/tool-bash/package.json b/packages/bash/tool-bash/package.json index c34e1c6e7b..2e304ae913 100644 --- a/packages/bash/tool-bash/package.json +++ b/packages/bash/tool-bash/package.json @@ -21,20 +21,17 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-bash": "^0.0.1", + "@deepseek-ai/dsh-bash-env": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-paths": "^0.0.1", "@deepseek-ai/dsh-sandbox": "^0.0.1", "@deepseek-ai/dsh-sandbox-policy": "^0.0.1", - "@deepseek-ai/dsh-session-persistence": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-tasks": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", @@ -49,15 +46,14 @@ "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-bash": "workspace:^", + "@deepseek-ai/dsh-bash-env": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", - "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tasks": "workspace:^", diff --git a/packages/bash/tool-bash/src/index.ts b/packages/bash/tool-bash/src/index.ts index 3b3874bc59..91a88c9cca 100644 --- a/packages/bash/tool-bash/src/index.ts +++ b/packages/bash/tool-bash/src/index.ts @@ -8,205 +8,39 @@ * @module @deepseek-ai/dsh-tool-bash */ -import { Service, type Context } from 'cordis' +import type { Context } from 'cordis' import z from 'schemastery' import { isAbsolute, resolve as resolvePath } from 'node:path' import { defineTool, TOOL_ABORTED } from '@deepseek-ai/dsh-tools' import type { GenericCallView, TerminalCallView, ToolExecution, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools' import { HarnessError } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' -import type {} from '@deepseek-ai/dsh-session-persistence' import type {} from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tasks' import type {} from '@deepseek-ai/dsh-user-approval' +import type {} from '@deepseek-ai/dsh-bash-env' import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox' import { ESCALATION_TARGETS, approveEscalation, canonicalPath, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox' import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-bash' -import type { BashRunResult, DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-bash' -import { DSH_HOME_ENV, resolveDshHome } from '@deepseek-ai/dsh-paths' +import type { BashRunResult } from '@deepseek-ai/dsh-bash' import { processOutcome } from './background.ts' import { parseExitStatus, renderProcessRead, renderResult } from './render.ts' -declare module 'cordis' { - interface Context { - bashEnv: BashEnvRegistry - } -} - export const name = 'tool-bash' -export const inject = ['tools', 'bash', 'systemPrompt'] +export const inject = ['tools', 'bash', 'systemPrompt', 'bashEnv'] -/** Configuration for the bash tool and its managed child environment. */ +/** Configuration for the bash tool. */ export interface Config { /** Expose `run_in_background` (default true); disabled calls are also rejected. */ enableRunInBackground?: boolean - /** DeepSeek Harness home directory exposed as `DSH_HOME`; defaults to `$DSH_HOME` or `~/.dsh`. */ - dshHome?: string } /** Runtime configuration schema for the bash tool plugin. */ export const Config: z = z.object({ enableRunInBackground: z.boolean().default(true), - dshHome: z.string(), }) -/** Model-visible metadata for one managed `DSH_*` environment variable. */ -export interface BashEnvVariable { - /** Concise description of the environment fact represented by the variable. */ - description: string -} - -/** - * A plugin contribution to the managed environment of each model bash call. - * Declared keys make ownership conflicts detectable before the first command; - * `resolve` computes only the values available for the current execution. - */ -export interface BashEnvContributor { - /** Stable contributor name used in diagnostics and duplicate detection. */ - name: string - /** Complete set of `DSH_*` keys this contributor may return. */ - variables: Readonly> - /** - * Resolve this contributor's available values for one tool execution. - * @param execution - the bash tool execution and its optional calling agent. - * @returns a partial map containing only keys declared in {@link variables}. - */ - resolve(execution: ToolExecution): Readonly>> -} - -/** An enumerable declaration returned by {@link BashEnvRegistry.list}. */ -export interface BashEnvVariableInfo extends BashEnvVariable { - /** Contributor that owns the variable. */ - contributor: string - /** Declared `DSH_*` environment variable name. */ - key: DshEnvironmentKey -} - -const DSH_SHELL_KEY = `${DSH_ENV_PREFIX}SHELL` as const -const DSH_SESSION_ID_KEY = `${DSH_ENV_PREFIX}SESSION_ID` as const -const DSH_SESSION_JSONL_KEY = `${DSH_ENV_PREFIX}SESSION_JSONL` as const -const RESERVED_BASH_ENV_KEYS = new Set([ - DSH_HOME_ENV, - DSH_SHELL_KEY, - DSH_SESSION_ID_KEY, -]) -const BASH_ENV_KEY_SUFFIX = /^[A-Z][A-Z0-9_]*$/ - -/** - * Registry (`ctx.bashEnv`) for trusted, per-execution `DSH_*` variables. - * The namespace is rebuilt for every model bash call: ambient `DSH_*` values - * are discarded by the executor, then the registry's current snapshot is - * injected. Built-in shell facts remain owned by the registry itself while - * plugins can register additional, enumerable facts with effect-scoped - * disposal. - */ -export class BashEnvRegistry extends Service { - private readonly contributors = new Map() - private readonly keyOwners = new Map() - private readonly dshHome: string - - /** - * Create and install the `ctx.bashEnv` service. - * @param ctx - Cordis context that owns the service and registrations. - * @param config - home-directory configuration for the built-in variables. - */ - constructor(ctx: Context, config: Config = {}) { - super(ctx, 'bashEnv') - this.dshHome = resolveDshHome(config.dshHome) - } - - /** - * Register one environment contributor. Names and keys are unique; built-in - * keys are reserved. Registration is disposed with the calling plugin fiber. - * @param contributor - declared key ownership and per-execution resolver. - * @returns the disposer that unregisters the contribution. - */ - register(contributor: BashEnvContributor): () => void { - const dispose = this.ctx.effect(function* (this: BashEnvRegistry) { - if (contributor.name.trim().length === 0) { - throw new Error('bash env contributor name must be non-empty') - } - if (this.contributors.has(contributor.name)) { - throw new Error(`bash env contributor "${contributor.name}" is already registered`) - } - - const variables = Object.entries(contributor.variables) as [DshEnvironmentKey, BashEnvVariable][] - for (const [key, variable] of variables) { - if (!key.startsWith(DSH_ENV_PREFIX) - || !BASH_ENV_KEY_SUFFIX.test(key.slice(DSH_ENV_PREFIX.length))) { - throw new Error(`bash env contributor "${contributor.name}" declared invalid key "${key}"`) - } - if (RESERVED_BASH_ENV_KEYS.has(key)) { - throw new Error(`bash env contributor "${contributor.name}" cannot own reserved key "${key}"`) - } - if (variable.description.trim().length === 0) { - throw new Error(`bash env contributor "${contributor.name}" must describe "${key}"`) - } - const owner = this.keyOwners.get(key) - if (owner !== undefined) { - throw new Error(`bash env key "${key}" is already owned by contributor "${owner}"; contributor "${contributor.name}" cannot also own it`) - } - } - - this.contributors.set(contributor.name, contributor) - for (const [key] of variables) this.keyOwners.set(key, contributor.name) - yield () => { - this.contributors.delete(contributor.name) - for (const [key] of variables) this.keyOwners.delete(key) - } - }.bind(this), 'bashEnv.register()') - return () => void dispose() - } - - /** - * Build the trusted `DSH_*` snapshot for one bash tool execution. - * @param execution - the current tool execution. - * @returns an immutable environment overlay containing built-ins and current contributions. - */ - collect(execution: ToolExecution): DshEnvironment { - const values: Record = { - [DSH_HOME_ENV]: this.dshHome, - [DSH_SHELL_KEY]: '1', - } - if (execution.agent !== undefined) { - values[DSH_SESSION_ID_KEY] = execution.agent.session.header.id - } - - for (const contributor of [...this.contributors.values()].sort((left, right) => left.name.localeCompare(right.name))) { - const resolved = contributor.resolve(execution) - for (const [rawKey, value] of Object.entries(resolved)) { - const key = rawKey as DshEnvironmentKey - if (!Object.hasOwn(contributor.variables, key)) { - throw new Error(`bash env contributor "${contributor.name}" returned undeclared key "${key}"`) - } - if (typeof value !== 'string') { - throw new Error(`bash env contributor "${contributor.name}" returned a non-string value for "${key}"`) - } - values[key] = value - } - } - - return Object.freeze(Object.fromEntries(Object.entries(values).sort(([left], [right]) => left.localeCompare(right)))) - } - - // TODO(bash-env-list-builtins): Include registry-owned built-ins before diagnostics, - // prompt, or UI code treats list() as an exhaustive environment catalog. - /** - * Enumerate plugin-contributed variables without executing their resolvers. - * @returns declarations sorted by environment variable name. - */ - list(): BashEnvVariableInfo[] { - return [...this.contributors.values()] - .flatMap(contributor => Object.entries(contributor.variables).map(([key, variable]) => ({ - contributor: contributor.name, - description: variable.description, - key: key as DshEnvironmentKey, - }))) - .sort((left, right) => left.key.localeCompare(right.key)) - } -} - /** Parsed tool args; execute validates value constraints absent from ParameterSchemaSpec. */ interface BashToolArgs { command: string @@ -354,24 +188,6 @@ const BACKGROUND_OUTPUT_PROPERTIES = { } as const export function apply(ctx: Context, config: Config = {}): void { - // FIXME(bash-env-ownership): Move ctx.bashEnv to a tool-independent shell - // environment plugin; replacing this tool with persistent Bash must not - // remove the managed DSH_* contributor seam. - const bashEnv = new BashEnvRegistry(ctx, config) - bashEnv.register({ - name: 'session-persistence', - variables: { - [DSH_SESSION_JSONL_KEY]: { - description: 'Absolute target path of the current session JSONL when the active persistence backend provides one.', - }, - }, - resolve(execution) { - const agent = execution.agent - if (agent === undefined) return {} - const location = ctx.get('sessionPersistence')?.locate(agent.session.header) - return location?.kind === 'jsonl' ? { [DSH_SESSION_JSONL_KEY]: location.path } : {} - }, - }) const backgroundEnabled = config.enableRunInBackground ?? true const defaultMode = ctx.bash.sandboxMode const escalationModes: readonly SandboxMode[] = defaultMode === undefined ? [] : ESCALATION_TARGETS @@ -522,7 +338,7 @@ export function apply(ctx: Context, config: Config = {}): void { ? standingPolicy : { ...(standingPolicy as SandboxExecutionPolicy), mode: approvedMode } const workdir = resolveWorkdir(args.workdir, exec, standingPolicy?.workspaceRoot) - const dshEnv = bashEnv.collect(exec) + const dshEnv = ctx.bashEnv.collect(exec) const request = { command: args.command, ...workdir !== undefined ? { workdir } : {}, @@ -565,7 +381,11 @@ export function apply(ctx: Context, config: Config = {}): void { ...request, signal: exec.signal, })) - if (result.aborted) throw new Error('command aborted') + if (result.aborted) { + const error = new HarnessError('tool call aborted', TOOL_ABORTED) + error.name = 'AbortError' + throw error + } return { kind: 'foreground' as const, ...canonicalBashResult(result) } }, presentCall: presentBashCall, diff --git a/packages/bash/tool-bash/tests/integration.spec.ts b/packages/bash/tool-bash/tests/integration.spec.ts index 5616afcdef..b76f24bc04 100644 --- a/packages/bash/tool-bash/tests/integration.spec.ts +++ b/packages/bash/tool-bash/tests/integration.spec.ts @@ -14,6 +14,7 @@ import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' +import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' /** @@ -32,8 +33,9 @@ async function harness(adapter: MockAdapter, sessionRoot?: string, dshHome?: str await ctx.plugin(LocalTaskService) await ctx.plugin(ToolTasks) await ctx.plugin(LocalSubprocessService) + await ctx.plugin(BashEnvPlugin, dshHome === undefined ? {} : { dshHome }) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) - await ctx.plugin(ToolBash, dshHome === undefined ? {} : { dshHome }) + await ctx.plugin(ToolBash) ctx.llm.registerAdapter(['mock'], adapter) return ctx } diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index a52b3f16f1..4f913d78ee 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -20,6 +20,7 @@ import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' +import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env' import { processOutcome } from '../src/background.ts' import { renderProcessRead, renderResult } from '../src/render.ts' @@ -35,6 +36,7 @@ async function setup() { await ctx.plugin(AgentRegistry) await ctx.plugin(LocalSubprocessService) ;(ctx.subprocess as LocalSubprocessService).internals = { spillDir } + await ctx.plugin(BashEnvPlugin) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 }) await ctx.plugin(ToolBash) return ctx @@ -50,6 +52,7 @@ async function setupWithTasks() { await ctx.plugin(ToolTasks) await ctx.plugin(LocalSubprocessService) ;(ctx.subprocess as LocalSubprocessService).internals = { spillDir } + await ctx.plugin(BashEnvPlugin) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 }) await ctx.plugin(ToolBash) return ctx @@ -188,6 +191,7 @@ async function setupSandboxed(withApproval = false) { await ctx.plugin(SandboxPolicyService, {}) await ctx.plugin(RecordingSandboxExecutor) if (withApproval) await ctx.plugin(ApprovalService) + await ctx.plugin(BashEnvPlugin) await ctx.plugin(ToolBash) return { ctx, bash: ctx.bash as RecordingSandboxExecutor } } @@ -281,6 +285,7 @@ describe('bash tool', () => { await ctx.plugin(LocalSubprocessService) ;(ctx.subprocess as LocalSubprocessService).internals = { spillDir } await ctx.plugin(LocalBashExecutor, { maxOutputBytes: 100, graceMs: 200 }) + await ctx.plugin(BashEnvPlugin) await ctx.plugin(ToolBash) const result = await call(ctx, 'bash', { command: 'for i in $(seq 1 100); do printf "line-%04d\\n" $i; done', description: 'test command' }) expect(text(result)).toContain('[output truncated; full output: ') @@ -300,7 +305,7 @@ describe('bash tool', () => { expect(text(result)).toMatch(/ENOENT/) }) - it('surfaces foreground aborts as isError', async () => { + it('surfaces foreground aborts as the structured TOOL_ABORTED error', async () => { const ctx = await setup() const controller = new AbortController() const pending = ctx.tools.execute({ @@ -312,7 +317,10 @@ describe('bash tool', () => { setTimeout(() => { controller.abort() }, 50) const result = await pending expect(result.isError).toBe(true) - expect(text(result)).toMatch(/aborted/) + expect(result.error).toMatchObject({ + message: 'tool call aborted', + info: { name: 'AbortError', code: TOOL_ABORTED }, + }) }) // Type and required-key violations are rejected by the harness @@ -389,6 +397,7 @@ describe('bash tool', () => { await ctx.plugin(ToolRegistry) await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, {}) + await ctx.plugin(BashEnvPlugin) const fiber = await ctx.plugin(ToolBash) expect(ctx.tools.schemas()).toHaveLength(1) expect((await ctx.systemPrompt.assemble()).sections.map(s => s.name)).toEqual(['harness:identity', 'deployment:persona', 'tool:bash']) @@ -403,6 +412,7 @@ describe('bash tool', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) // inject: ['tools', 'bash'] keeps the plugin pending until bash exists. + await ctx.plugin(BashEnvPlugin) await ctx.plugin(ToolBash) expect(ctx.tools.schemas()).toHaveLength(0) await ctx.plugin(LocalSubprocessService) @@ -493,6 +503,7 @@ describe('background execution through the task runtime', () => { await ctx.plugin(LocalTaskService) await ctx.plugin(ToolTasks) await ctx.plugin(CountingStartExecutor) + await ctx.plugin(BashEnvPlugin) await ctx.plugin(ToolBash) const controller = new AbortController() @@ -520,6 +531,7 @@ describe('background execution through the task runtime', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(LocalTaskService) await ctx.plugin(CountingStartExecutor) + await ctx.plugin(BashEnvPlugin) await ctx.plugin(ToolBash) const result = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true }) @@ -534,6 +546,7 @@ describe('background execution through the task runtime', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(LocalSubprocessService) + await ctx.plugin(BashEnvPlugin) await ctx.plugin(LocalBashExecutor, {}) await ctx.plugin(ToolBash, { enableRunInBackground: false }) @@ -568,6 +581,7 @@ describe('sandbox escalation through the generic task producer', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(RecordingSandboxExecutor) + await ctx.plugin(BashEnvPlugin) await expect(ctx.plugin(ToolBash)).rejects.toThrow('tool-bash: the mounted bash executor confines but ctx.sandboxPolicy is missing') }) @@ -1003,9 +1017,9 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => { // not renderResult output, so a generic fenced card, no terminal output/exit. const out = ctx.tools.get('bash')!.presentResult!( { command: 'x', description: 'x' }, - { content: [{ type: 'text', text: 'command aborted' }], isError: true }, + { content: [{ type: 'text', text: 'tool call aborted' }], isError: true }, ) - expect(out).toEqual({ card: 'generic', content: [{ type: 'text', text: '```console\ncommand aborted\n```' }] }) + expect(out).toEqual({ card: 'generic', content: [{ type: 'text', text: '```console\ntool call aborted\n```' }] }) }) it('bash presentResult: leaves a non-text (unexpected) result untouched → undefined (UI keeps raw content)', async () => { @@ -1097,8 +1111,9 @@ describe('the model-facing bash tool builds its request from named args only (no } await ctx.plugin(LocalTaskService) await ctx.plugin(ToolTasks) + await ctx.plugin(BashEnvPlugin, { dshHome: recordingDshHome }) await ctx.plugin(RecordingBashExecutor) - await ctx.plugin(ToolBash, { dshHome: recordingDshHome }) + await ctx.plugin(ToolBash) return { ctx, bash: ctx.bash as RecordingBashExecutor } } diff --git a/packages/bash/tool-bash/tsconfig.json b/packages/bash/tool-bash/tsconfig.json index 00e9195f9f..b122ed58ca 100644 --- a/packages/bash/tool-bash/tsconfig.json +++ b/packages/bash/tool-bash/tsconfig.json @@ -26,21 +26,18 @@ { "path": "../../core/agent" }, - { - "path": "../../session-persistence/session-persistence" - }, { "path": "../../bash/bash" }, - { - "path": "../../util/paths" - }, { "path": "../../tasks/tasks" }, { "path": "../../core/system-prompt" }, + { + "path": "../../bash/bash-env" + }, { "path": "../../ui/user-approval" }, diff --git a/packages/bash/tool-pwsh/README.i18n.yaml b/packages/bash/tool-pwsh/README.i18n.yaml new file mode 100644 index 0000000000..030d24c7c2 --- /dev/null +++ b/packages/bash/tool-pwsh/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/bash/tool-pwsh/README.md +README.md: dfe26a63684d61dcdd6f969c2c2261dac79325c7 +README.zh.md: 2344f8477e5b15f2c4d366dd82b46358eacbc1b7 diff --git a/packages/bash/tool-pwsh/README.md b/packages/bash/tool-pwsh/README.md new file mode 100644 index 0000000000..dfe26a6368 --- /dev/null +++ b/packages/bash/tool-pwsh/README.md @@ -0,0 +1,125 @@ +# @deepseek-ai/dsh-tool-pwsh + +English | [中文](README.zh.md) + +The model-facing `pwsh` tool registered over the `ctx.bash` executor seam. Intended for Windows compositions where a PowerShell executor (e.g. `@deepseek-ai/dsh-pwsh-local`) backs `ctx.bash`; the tool contract is PowerShell-dialect: native `C:\...` paths and `$env:NAME` variables. Behavior mirrors `dsh-tool-bash` call-for-call minus the sandbox surface — foreground and `run_in_background` execution through the generic task runtime, the managed `DSH_*` environment through the shared `bash-env` registry, and the bash marker/truncation rendering story (a clean exit produces no marker). + +Requires a loaded executor implementation and the `bash-env` plugin; the tool stays pending until both exist (`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`). + +The package root exposes only the Cordis plugin contract (`name`, `inject`, `Config`, `apply`); result rendering (`src/render.ts`) and background-task adaptation (`src/background.ts`) mirror the bash tool's structure and stay reachable through the package's `./src/*` export. + +The plugin also contributes the `tool:pwsh` prompt section (order 105): non-zero exits are reported as `[exit code: N]` markers, and Windows interruption settles as exit 1 without a signal marker. + +## Tools + +### `pwsh` + +| Arg | Type | Notes | +|---|---|---| +| `command` | string (required) | Run via `pwsh -Command`. No state persists between calls — use `workdir`, not `cd`. | +| `description` | string (required) | One-line, active-voice summary of the command (5-10 words), for UI/log display only — no effect on execution. | +| `timeoutMs` | number | Timeout override in milliseconds. The executor applies its configured default and cap. | +| `workdir` | string | Working directory for this call. Defaults to the calling agent's session cwd (`session.header.cwd`) so each session runs in its own workspace; a relative `workdir` is resolved against that same identity. | +| `run_in_background` | boolean | Return a task id immediately; no timeout applies. | + +`command`, `workdir`, and `timeoutMs` are resolved against the executor's config defaults via `ctx.bash.resolve()` before execution. The workdir default is applied in the tool layer from the calling agent's `session.header.cwd` BEFORE `resolve()` — the per-session cwd must come from `exec.agent`, since N sessions share one executor; only when no session cwd is available does the executor fall back to its own config / `process.cwd()`. + +### Managed shell environment + +Every foreground and background model pwsh call receives a freshly collected trusted `DSH_*` environment through the shared [`dsh-bash-env`](../bash-env/) registry: `DSH_HOME` (the absolute Harness home), `DSH_SHELL=1`, the agent's `DSH_SESSION_ID`, and `DSH_SESSION_JSONL` when the active persistence backend locates one. Plugins contributing `DSH_*` facts to `ctx.bashEnv` apply to pwsh calls exactly as they do to bash calls. The snapshot passes through the dedicated `BashExecRequest.dshEnv` channel; `process.env` is never modified. The description teaches the generic `$env:DSH_*` convention rather than naming persistence-specific variables. + +Result text contains stdout, an optional `[stderr]` section, then applicable truncation, timeout, signal, and exit markers. A clean exit (0, no signal) produces no marker; an empty body renders as `(no output)`. Truncation links a safe complete spill file or reports it unavailable. Timeout is reported independently of final exit status; nonzero exit remains a model-interpreted result rather than `isError`. Windows reports forced termination as exit 1 without a signal, so `[killed by signal: …]` is POSIX-only there. Only infrastructure failures — spawn errors and aborts (`tool call aborted`) — produce `isError`. + +The canonical success is `{ kind: 'foreground', ...BashRunResult }` for a completed foreground process or `{ kind: 'background', taskId }` for a published task. The renderer preserves exactly `started background task ` for background acks; programmatic consumers use the typed fields without parsing the rendered text. + +When `run_in_background` is true, this plugin preflights `ctx.tasks.start()` before spawning, registers the calling agent as owner, and adapts the returned `BashProcess` handle into generic cancel/done/incremental-output hooks. The task runtime owns ids, cross-session isolation, completion notices, waiting, and disposal cleanup; this plugin only maps pwsh exit facts into task output and outcome detail. `enableRunInBackground: false` removes the parameter and rejects a forced background call at execution time. + +## UI presentation + +The tool owns its `presentCall`/`presentResult` render intent. A foreground call is a `terminal` card carrying command, description, and optional cwd; a `run_in_background` call is a `generic` card with the raw command, mirroring the bash tool's background presentation. A completed result is a `generic` card with the rendered output in a `console` fence. The bash tool's terminal card with its parsed exit-status pill has no pwsh counterpart yet — a PowerShell-aware presentation is roadmap work. These presenters are pure and replay-safe. + +## Model Experience + +### System prompt + +#### What the model sees + +Every request in this plugin's registration scope contains the pwsh guidance below. Scoped tool restrictions can hide the schema without removing this independently registered section. + +##### Pwsh guidance + +```markdown +Non-zero exits are reported as `[exit code: N]` markers; investigate failures before moving on. On Windows a killed process settles as `[exit code: 1]` without a signal marker; treat a bare exit 1 after an interruption as a termination, not a command failure. +``` + +#### Token effect + +Small fixed input cost per request while the plugin is active. + +#### KV Cache effect + +Prefix-stable while the registration scope and prompt text are unchanged. Plugin activation or disposal may invalidate reuse from this prompt section. + +### Tool schemas + +#### What the model sees + +The model sees the generated [`pwsh` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-pwsh). Agent-scoped tool restrictions can remove the definition for that agent. + +#### Token effect + +Fixed schema cost on every request where the tool is visible. + +#### KV Cache effect + +Prefix-stable while visibility and the tool definition are unchanged. A restriction or config change may invalidate reuse from the first changed token. + +### Foreground result + +#### What the model sees + +The renderer emits the data-dependent stdout tail, then optional `[stderr]` and the stderr tail. Conditional lines are exactly `[output truncated; full output: ]`, `[timed out after ms]`, `[killed by signal: ]`, and `[exit code: ]` (nonzero exits only); an empty body renders as `(no output)`. + +#### Token effect + +Zero result tokens before a call. Output is bounded per stream, while each emitted line remains in history until compaction. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + +### Background result + +#### What the model sees + +A background start renders exactly `started background task `; subsequent reads and status flow through the generic `task_output`/`task_kill` tools, including the lossy-read spill notice when in-memory truncation dropped unread bytes. + +#### Token effect + +The ack is a fixed short line; task output is bounded per read. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + +### Tool errors + +#### What the model sees + +Validation and infrastructure failures are normalized as `Error: `. This package's stable messages are `invalid command: expected a non-empty string`, `invalid description: expected a non-empty string`, `invalid timeoutMs: expected a positive number, got `, `run_in_background is disabled for this deployment (enableRunInBackground: false)`, `background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`, and `tool call aborted`. + +#### Token effect + +Only the failing call adds these retained tokens; an aborted call adds no command output. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + +## Known Limitations and Deferred Work + +- **No sandbox escalation** — `sandbox_permissions`/`justification` are absent; escalation waits for a Windows-confining executor (the bash tool's sandbox surface is not mirrored). +- **No persistent shell or PTY** — every call starts a fresh `pwsh -Command`; the PTY backends are Linux/macOS-only today, and a Windows ConPTY persistent shell is roadmap work. +- **PowerShell-dialect contract** — the model must write PowerShell (native paths, `$env:` variables), not bash; there is no dialect translation. +- **Generic UI presentation** — results use the generic card; a PowerShell-aware terminal card with exit-status pill is roadmap work. +- **Session-cwd identity is not canonicalized** — the workdir base is the session header cwd as-is, unlike the bash tool's sandbox-root-canonicalized identity; only the sandbox-less case applies here. diff --git a/packages/bash/tool-pwsh/README.zh.md b/packages/bash/tool-pwsh/README.zh.md new file mode 100644 index 0000000000..2344f8477e --- /dev/null +++ b/packages/bash/tool-pwsh/README.zh.md @@ -0,0 +1,125 @@ +# @deepseek-ai/dsh-tool-pwsh + +[English](README.md) | 中文 + +注册在 `ctx.bash` 执行器 seam 之上的模型可见 `pwsh` 工具。面向由 PowerShell 执行器(如 `@deepseek-ai/dsh-pwsh-local`)支撑 `ctx.bash` 的 Windows 组合;工具契约是 PowerShell 方言:原生 `C:\...` 路径与 `$env:NAME` 变量。行为与 `dsh-tool-bash` 逐调用对齐、减去 sandbox 面——通过通用任务运行时执行前台与 `run_in_background`、通过共享 `bash-env` 注册表管理 `DSH_*` 环境、以及 bash 的 marker/截断渲染故事(干净退出不产生 marker)。 + +需要已加载的执行器实现与 `bash-env` 插件;两者都存在前工具保持 pending(`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`)。 + +包根只导出 Cordis 插件契约(`name`、`inject`、`Config`、`apply`);结果渲染(`src/render.ts`)与后台任务适配(`src/background.ts`)镜像 bash 工具的结构,并可通过包的 `./src/*` 导出访问。 + +插件还贡献 `tool:pwsh` prompt section(order 105):非零退出以 `[exit code: N]` marker 报告,Windows 上的中断以无 signal 的 exit 1 结算。 + +## 工具 + +### `pwsh` + +| Arg | Type | Notes | +|---|---|---| +| `command` | string (required) | 通过 `pwsh -Command` 运行。调用之间不保留状态——用 `workdir`,不要用 `cd`。 | +| `description` | string (required) | 命令的一行主动语态摘要(5-10 词),仅用于 UI/日志展示——不影响执行。 | +| `timeoutMs` | number | 超时覆盖值(毫秒)。执行器应用其配置的默认值与上限。 | +| `workdir` | string | 本次调用的工作目录。默认取调用 agent 的会话 cwd(`session.header.cwd`),使每个会话在自己的工作区运行;相对 `workdir` 基于同一身份解析。 | +| `run_in_background` | boolean | 立即返回任务 id;不适用超时。 | + +`command`、`workdir` 与 `timeoutMs` 在执行前经 `ctx.bash.resolve()` 按执行器配置默认值解析。workdir 默认值在工具层于 `resolve()` 之前从调用 agent 的 `session.header.cwd` 取得——每次会话的 cwd 必须来自 `exec.agent`,因为 N 个会话共享一个执行器;仅当没有会话 cwd 时执行器才回退到自己的配置 / `process.cwd()`。 + +### Managed shell environment + +每次前台与后台模型 pwsh 调用都会通过共享的 [`dsh-bash-env`](../bash-env/) 注册表收到一份新收集的受信任 `DSH_*` 环境:`DSH_HOME`(Harness 主目录绝对路径)、`DSH_SHELL=1`、agent 的 `DSH_SESSION_ID`,以及活跃持久化后端定位到 JSONL 时的 `DSH_SESSION_JSONL`。向 `ctx.bashEnv` 贡献 `DSH_*` 事实的插件对 pwsh 调用与 bash 调用一视同仁。快照通过专用的 `BashExecRequest.dshEnv` 通道传递;`process.env` 永不被修改。描述只教授通用的 `$env:DSH_*` 约定,而不是点名持久化相关的变量。 + +结果文本包含 stdout、可选的 `[stderr]` 段,然后是适用的截断、超时、signal 与退出 marker。干净退出(0、无 signal)不产生 marker;空体渲染为 `(no output)`。截断会链接一个安全的完整 spill 文件,或报告其不可用。超时独立于最终退出状态报告;非零退出仍是模型解读的结果而非 `isError`。Windows 上强制终止以无 signal 的 exit 1 结算,因此 `[killed by signal: …]` 在那里仅存在于 POSIX。只有基础设施失败——spawn 错误与中止(`tool call aborted`)——产生 `isError`。 + +规范成功形态是已完成前台进程的 `{ kind: 'foreground', ...BashRunResult }` 或已发布任务的 `{ kind: 'background', taskId }`。渲染器对后台 ack 精确保留 `started background task `;编程消费者使用类型化字段而不解析渲染文本。 + +当 `run_in_background` 为 true 时,本插件在 spawn 前预检 `ctx.tasks.start()`,把调用 agent 注册为 owner,并将返回的 `BashProcess` 句柄适配为通用的 cancel/done/增量输出钩子。任务运行时拥有 id、跨会话隔离、完成通知、等待与清理;本插件只把 pwsh 退出事实映射进任务输出与结果明细。`enableRunInBackground: false` 会移除参数并在执行时拒绝强制的后台调用。 + +## UI presentation + +工具拥有自己的 `presentCall`/`presentResult` 呈现意图。前台调用是携带命令、描述与可选 cwd 的 `terminal` 卡;`run_in_background` 调用是携带原始命令的 `generic` 卡,镜像 bash 工具的后台呈现。完成的结果是以 `console` 围栏包裹渲染输出的 `generic` 卡。bash 工具那种带解析退出状态 pill 的 terminal 卡在 pwsh 侧暂无对应——PowerShell 感知的呈现属于路线图工作。这些 presenter 是纯函数且可重放。 + +## Model Experience + +### System prompt + +#### What the model sees + +本插件注册作用域内的每个请求都包含下面的 pwsh 指引。作用域工具限制可以隐藏 schema,但不会移除这个独立注册的段落。 + +##### Pwsh guidance + +```markdown +Non-zero exits are reported as `[exit code: N]` markers; investigate failures before moving on. On Windows a killed process settles as `[exit code: 1]` without a signal marker; treat a bare exit 1 after an interruption as a termination, not a command failure. +``` + +#### Token effect + +插件激活期间每次请求的固定小额输入成本。 + +#### KV Cache effect + +注册作用域与 prompt 文本不变时前缀稳定。插件激活或释放可能使该 prompt 段落的复用失效。 + +### Tool schemas + +#### What the model sees + +模型看到生成的 [`pwsh` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-pwsh)。按 agent 作用域的工具限制可以移除该 agent 的定义。 + +#### Token effect + +工具可见的每个请求上的固定 schema 成本。 + +#### KV Cache effect + +可见性与工具定义不变时前缀稳定。限制或配置变更可能从首个变化 token 起使复用失效。 + +### Foreground result + +#### What the model sees + +渲染器输出数据相关的 stdout 尾部,然后是可选的 `[stderr]` 与 stderr 尾部。条件行精确为 `[output truncated; full output: ]`、`[timed out after ms]`、`[killed by signal: ]` 与 `[exit code: ]`(仅非零退出);空体渲染为 `(no output)`。 + +#### Token effect + +调用前零结果 token。每个流的输出有界,而每条已发出的行保留在历史中直到压缩。 + +#### KV Cache effect + +仅追加;新出现的内容跟随可复用的请求前缀,不会使既有 KV-cache 条目失效。 + +### Background result + +#### What the model sees + +后台启动精确渲染为 `started background task `;随后的读取与状态通过通用 `task_output`/`task_kill` 工具流转,包括内存截断丢弃未读字节时的 lossy 读取 spill 通知。 + +#### Token effect + +ack 是固定短行;任务输出按读取有界。 + +#### KV Cache effect + +仅追加;新出现的内容跟随可复用的请求前缀,不会使既有 KV-cache 条目失效。 + +### Tool errors + +#### What the model sees + +校验与基础设施失败规范化为 `Error: `。本包的稳定消息包括 `invalid command: expected a non-empty string`、`invalid description: expected a non-empty string`、`invalid timeoutMs: expected a positive number, got `、`run_in_background is disabled for this deployment (enableRunInBackground: false)`、`background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks` 与 `tool call aborted`。 + +#### Token effect + +只有失败的调用会新增这些保留 token;被中止的调用不产生命令输出。 + +#### KV Cache effect + +仅追加;新出现的内容跟随可复用的请求前缀,不会使既有 KV-cache 条目失效。 + +## Known Limitations and Deferred Work + +- **无 sandbox 升级** — 没有 `sandbox_permissions`/`justification`;升级等待 Windows-confining 执行器(bash 工具的 sandbox 面不被镜像)。 +- **无持久 shell 或 PTY** — 每次调用都启动全新的 `pwsh -Command`;PTY 后端目前仅限 Linux/macOS,Windows ConPTY 持久 shell 属于路线图工作。 +- **PowerShell 方言契约** — 模型必须写 PowerShell(原生路径、`$env:` 变量),而不是 bash;没有方言翻译。 +- **通用 UI 呈现** — 结果使用 generic 卡;带退出状态 pill 的 PowerShell 感知 terminal 卡属于路线图工作。 +- **会话 cwd 身份不做规范化** — workdir 基座直接取会话头 cwd 原值,不同于 bash 工具经 sandbox-root 规范化的身份;此处只涉及无 sandbox 场景。 diff --git a/packages/bash/tool-pwsh/package.json b/packages/bash/tool-pwsh/package.json new file mode 100644 index 0000000000..0c25317faa --- /dev/null +++ b/packages/bash/tool-pwsh/package.json @@ -0,0 +1,57 @@ +{ + "name": "@deepseek-ai/dsh-tool-pwsh", + "description": "Model-facing pwsh tool over the bash executor seam", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-bash": "^0.0.1", + "@deepseek-ai/dsh-bash-env": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-system-prompt": "^0.0.1", + "@deepseek-ai/dsh-tasks": "^0.0.1", + "@deepseek-ai/dsh-tools": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-bash": "workspace:^", + "@deepseek-ai/dsh-bash-env": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-loader-smoke": "workspace:^", + "@deepseek-ai/dsh-pwsh-local": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tasks": "workspace:^", + "@deepseek-ai/dsh-tasks-local": "workspace:^", + "@deepseek-ai/dsh-tool-tasks": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/bash/tool-pwsh/src/background.ts b/packages/bash/tool-pwsh/src/background.ts new file mode 100644 index 0000000000..5e3464f76b --- /dev/null +++ b/packages/bash/tool-pwsh/src/background.ts @@ -0,0 +1,31 @@ +/** + * Generic-task adaptation for background pwsh process handles — the shell-agnostic + * twin of `dsh-tool-bash`'s background adaptation. + * + * @module @deepseek-ai/dsh-tool-pwsh/background + */ + +import type { BashProcess } from '@deepseek-ai/dsh-bash' + +/* jscpd:ignore-start -- deliberate twin of dsh-tool-bash/background.ts (Agent Note). */ + +/** + * Map a settled background process onto the generic task-outcome vocabulary: + * `killed` stays `killed` (detail: the signal when one is known), everything + * else is `completed` with the exit code as detail. A nonzero command exit is + * reported, not failed, exactly like the foreground rendering. + * @param proc - the settled process handle. + * @returns the outcome for the `ctx.tasks` registration. + */ +export function processOutcome(proc: BashProcess): { status: 'completed' | 'killed'; detail: string } { + // TODO(background-infrastructure-outcome): widen BashProcess with an explicit + // infrastructure-failure outcome, then map spawn failures and + // sandbox.runnerFailed to task `failed`. The current seam aliases a spawn + // failure with a signal-less kill and a runner failure with an ordinary + // wrapper exit; real nonzero command exits must remain `completed`. + if (proc.status === 'killed') { + return { status: 'killed', detail: proc.signal !== null ? `signal: ${proc.signal}` : 'killed before exit' } + } + return { status: 'completed', detail: `exit code: ${proc.exitCode ?? 0}` } +} +/* jscpd:ignore-end */ diff --git a/packages/bash/tool-pwsh/src/index.ts b/packages/bash/tool-pwsh/src/index.ts new file mode 100644 index 0000000000..9423fe36e6 --- /dev/null +++ b/packages/bash/tool-pwsh/src/index.ts @@ -0,0 +1,306 @@ +/** + * Model-facing `pwsh` tool over the `ctx.bash` executor seam. Intended for + * Windows compositions where a PowerShell executor (e.g. + * `@deepseek-ai/dsh-pwsh-local`) backs `ctx.bash`; the tool contract is + * PowerShell-dialect: native `C:\...` paths and `$env:NAME` variables. + * + * Behavior mirrors `dsh-tool-bash` call-for-call minus the sandbox surface: + * foreground and `run_in_background` execution (background handles register + * with the generic `ctx.tasks` runtime), the managed `DSH_*` environment + * through the shared `bash-env` registry, and the bash marker/truncation + * rendering story. UI presentation stays on the existing generic/terminal + * cards; a pwsh-specific rendering twin is roadmap work. + * + * @module @deepseek-ai/dsh-tool-pwsh + */ + +import { isAbsolute, resolve as resolvePath } from 'node:path' +import type { Context } from 'cordis' +import z from 'schemastery' +import { defineTool, TOOL_ABORTED } from '@deepseek-ai/dsh-tools' +import type { GenericCallView, TerminalCallView, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools' +import { HarnessError } from '@deepseek-ai/dsh-llm' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type {} from '@deepseek-ai/dsh-system-prompt' +import type {} from '@deepseek-ai/dsh-tasks' +import type {} from '@deepseek-ai/dsh-bash-env' +import type { BashRunResult } from '@deepseek-ai/dsh-bash' +import { processOutcome } from './background.ts' +import { renderPwshProcessRead, renderPwshResult } from './render.ts' + +declare module '@deepseek-ai/dsh-tasks' { + interface TaskKindMap { + pwsh: 'pwsh' + } +} + +export const name = 'tool-pwsh' +export const inject = ['tools', 'bash', 'systemPrompt', 'bashEnv'] + +/** Configuration for the pwsh tool. */ +export interface Config { + /** Expose `run_in_background` (default true); disabled calls are also rejected. */ + enableRunInBackground?: boolean +} + +/** Runtime configuration schema for the pwsh tool plugin. */ +export const Config: z = z.object({ + enableRunInBackground: z.boolean().default(true), +}) + +/** Parsed tool args; execute validates value constraints absent from ParameterSchemaSpec. */ +interface PwshToolArgs { + command: string + description: string + timeoutMs?: number + workdir?: string + run_in_background?: boolean +} + +/** The canonical foreground result of one pwsh call (the `output.schema` value shape). */ +interface PwshForegroundResult { + kind: 'foreground' + exitCode: number | null + signal: NodeJS.Signals | null + timedOut: boolean + aborted: boolean + timeoutMs: number + stdout: { text: string; truncated: boolean; spillPath?: string } + stderr: { text: string; truncated: boolean; spillPath?: string } +} + +/* jscpd:ignore-start -- minimal mirror of dsh-tool-bash's validation and execute plumbing (Agent Note). */ +function validatePwshArgs(args: PwshToolArgs): void { + if (args.command.trim().length === 0) { + throw new Error('invalid command: expected a non-empty string') + } + if (args.description.trim().length === 0) { + throw new Error('invalid description: expected a non-empty string') + } + if (args.timeoutMs !== undefined && (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) { + throw new Error(`invalid timeoutMs: expected a positive number, got ${JSON.stringify(args.timeoutMs)}`) + } +} +/* jscpd:ignore-end */ + +function pwshDescription(backgroundEnabled: boolean): string { + const background = backgroundEnabled + ? 'Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.' + : 'Background execution is not available; long-running commands must finish within the timeout.' + return 'Execute a PowerShell command (`pwsh -Command`) and return its stdout/stderr. ' + + 'Each call runs in a fresh pwsh process: no state (cwd, variables, functions) persists between calls — ' + + 'pass `workdir` instead of using `cd`. Paths use native Windows form (`C:\\...`); read environment ' + + 'variables with `$env:NAME`. Non-zero exits are reported as `[exit code: N]`. ' + + 'Current harness environment facts are exposed through managed `$env:DSH_*` variables; inspect them when needed. ' + + 'Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. ' + + 'On Windows a force-killed command settles as `[exit code: 1]` without a signal marker — treat it as an interruption, not a command failure. ' + + background +} + +/** + * Resolve an explicit workdir first, making a relative one session-workspace-relative; + * otherwise use the session header cwd and leave executor defaulting as the fallback. + */ +function resolveWorkdir(modelWorkdir: string | undefined, exec: { agent?: Agent }): string | undefined { + const headerCwd = exec.agent?.session.header.cwd + if (modelWorkdir === undefined) return headerCwd + if (headerCwd !== undefined && !isAbsolute(modelWorkdir)) { + return resolvePath(headerCwd, modelWorkdir) + } + return modelWorkdir +} + +/** Detach the executor DTO from readonly seam interfaces into plain JSON data. */ +function canonicalPwshResult(result: BashRunResult): PwshForegroundResult { + const output = (stream: BashRunResult['stdout']) => ({ + text: stream.text, + truncated: stream.truncated, + ...stream.spillPath !== undefined ? { spillPath: stream.spillPath } : {}, + }) + return { + kind: 'foreground', + exitCode: result.exitCode, + signal: result.signal, + timedOut: result.timedOut, + aborted: result.aborted, + timeoutMs: result.timeoutMs, + /* jscpd:ignore-start -- the canonical projection and background-handle shape mirror dsh-tool-bash's by design (Agent Note). */ + stdout: output(result.stdout), + stderr: output(result.stderr), + } +} + +/** Canonical background-handle properties shared by the pwsh output union. */ +const BACKGROUND_OUTPUT_PROPERTIES = { + kind: { type: 'string', required: true, const: 'background' }, + taskId: { type: 'string', required: true }, +} as const +/* jscpd:ignore-end */ + +export function apply(ctx: Context, config: Config = {}): void { + const backgroundEnabled = config.enableRunInBackground ?? true + + ctx.systemPrompt.section({ + name: 'tool:pwsh', + order: 105, + text: 'Non-zero exits are reported as `[exit code: N]` markers; investigate failures before moving on. ' + + 'On Windows a killed process settles as `[exit code: 1]` without a signal marker; treat a bare exit 1 after an interruption as a termination, not a command failure.', + }) + + ctx.tools.register(defineTool({ + name: 'pwsh', + description: pwshDescription(backgroundEnabled), + parameters: { + command: { type: 'string', required: true, description: 'The PowerShell command to execute.' }, + description: { + type: 'string', + required: true, + description: 'Clear, concise description of what this command does in active voice, ' + + '5-10 words (shown in the UI). Examples: "ls" → "List files in current directory"; ' + + '"git status" → "Show working tree status"; "Get-Process" → "List running processes".', + }, + timeoutMs: { type: 'number', description: 'Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry.' }, + workdir: { type: 'string', description: 'Working directory for this command. Defaults to the session workspace; a relative path is resolved against it.' }, + ...backgroundEnabled ? { + run_in_background: { type: 'boolean' as const, description: 'Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies.' }, + } : {}, + }, + output: { + // The foreground result wire shape mirrors dsh-tool-bash's by contract — + // consumers of one must accept the other (see the pwsh-tool-and-executor + // Agent Note). + /* jscpd:ignore-start -- deliberate result-schema symmetry with dsh-tool-bash. */ + schema: { + oneOf: [ + { + type: 'object', + additionalProperties: false, + properties: BACKGROUND_OUTPUT_PROPERTIES, + }, + { + type: 'object', + additionalProperties: false, + properties: { + kind: { type: 'string', required: true, const: 'foreground' }, + exitCode: { required: true, oneOf: [{ type: 'integer' }, { type: 'null' }] }, + signal: { required: true, oneOf: [{ type: 'string' }, { type: 'null' }] }, + timedOut: { type: 'boolean', required: true }, + aborted: { type: 'boolean', required: true }, + timeoutMs: { type: 'number', required: true }, + stdout: { + type: 'object', + additionalProperties: false, + required: true, + properties: { + text: { type: 'string', required: true }, + truncated: { type: 'boolean', required: true }, + spillPath: { type: 'string' }, + }, + }, + stderr: { + type: 'object', + additionalProperties: false, + required: true, + properties: { + text: { type: 'string', required: true }, + truncated: { type: 'boolean', required: true }, + spillPath: { type: 'string' }, + }, + }, + }, + }, + ], + }, + /* jscpd:ignore-end */ + render: (_args, value) => [{ + type: 'text', + text: value.kind === 'background' + ? `started background task ${value.taskId}` + : renderPwshResult(value), + }], + }, + /* jscpd:ignore-start -- the execute path mirrors dsh-tool-bash's by design (see the pwsh-tool-and-executor Agent Note). */ + async execute(args: PwshToolArgs, exec) { + validatePwshArgs(args) + const workdir = resolveWorkdir(args.workdir, exec) + const request = { + command: args.command, + ...workdir !== undefined ? { workdir } : {}, + ...args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {}, + dshEnv: ctx.bashEnv.collect(exec), + } + if (args.run_in_background === true) { + // Undeclared keys are allowed, so schema omission also needs enforcement. + if (!backgroundEnabled) { + throw new Error('run_in_background is disabled for this deployment (enableRunInBackground: false)') + } + const tasks = ctx.get('tasks') + if (tasks === undefined) { + throw new Error('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks') + } + // The caller owns cancellation until ctx.tasks commits detached ownership. + /* v8 ignore start -- the bash twin's branch is exercised by its sandbox-approval mid-call abort; + pwsh has no approval surface, and the tool registry's pre-dispatch abort check intercepts + already-aborted signals first, so this mirror-only guard has no reachable trigger. */ + if (exec.signal.aborted) { + const error = new HarnessError('tool call aborted', TOOL_ABORTED) + error.name = 'AbortError' + throw error + } + /* v8 ignore end */ + // Task preflight finishes before the starter can spawn a process. + const id = tasks.start({ + kind: 'pwsh', + label: args.command, + ...exec.agent ? { owner: exec.agent } : {}, + run: () => { + const proc = ctx.bash.start(ctx.bash.resolve(request)) + return { + cancel: () => void proc.kill(), + done: proc.done.then(() => processOutcome(proc)), + readOutput: () => renderPwshProcessRead(proc.readOutput()), + } + }, + }) + return { kind: 'background' as const, taskId: id } + } + const result = await ctx.bash.run(ctx.bash.resolve({ + ...request, + signal: exec.signal, + })) + if (result.aborted) { + const error = new HarnessError('tool call aborted', TOOL_ABORTED) + error.name = 'AbortError' + throw error + } + return canonicalPwshResult(result) + }, + /* jscpd:ignore-end */ + /* jscpd:ignore-start -- the background call card mirrors presentBashCall's by design (Agent Note). */ + presentCall: (args: PwshToolArgs): TerminalCallView | GenericCallView => { + // Background acknowledgements carry no terminal exit status; the generic + // card mirrors the bash tool's background presentation. + if (args.run_in_background === true) { + return { + card: 'generic', + title: args.command, + kind: 'execute', + rawInput: args.command, + content: [{ type: 'text', text: args.description }], + } + } + return { + card: 'terminal', + title: args.command, + description: args.description, + ...args.workdir !== undefined ? { cwd: args.workdir } : {}, + } + }, + /* jscpd:ignore-end */ + presentResult: (_args: unknown, result: ToolResult): ToolResultView | undefined => { + const block = result.content.length === 1 ? result.content[0] : undefined + if (block === undefined || block.type !== 'text') return undefined + return { card: 'generic', content: [{ type: 'text', text: `\`\`\`console\n${block.text.replace(/\n+$/, '')}\n\`\`\`` }] } + }, + })) +} diff --git a/packages/bash/tool-pwsh/src/invariant.ts b/packages/bash/tool-pwsh/src/invariant.ts new file mode 100644 index 0000000000..dd6370b490 --- /dev/null +++ b/packages/bash/tool-pwsh/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-tool-pwsh`. + * @module @deepseek-ai/dsh-tool-pwsh/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-tool-pwsh' + +/** Cordis companion plugin name. */ +export const name = 'tool-pwsh-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: this package exposes no independent event sequence or mutable data relation + * beyond contracts enforced at its owning seam. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/bash/tool-pwsh/src/render.ts b/packages/bash/tool-pwsh/src/render.ts new file mode 100644 index 0000000000..42f4bc696c --- /dev/null +++ b/packages/bash/tool-pwsh/src/render.ts @@ -0,0 +1,81 @@ +/** + * Model-facing result rendering for the pwsh tool — the PowerShell twin of + * `dsh-tool-bash`'s renderer minus the sandbox surface: stdout, a marked + * stderr section, truncation notices with spill paths, then exit-status + * markers. Non-zero exits are reported, not errored — the model decides how to + * react; only infrastructure failures (spawn errors, aborts) surface as + * isError results. + * + * @module @deepseek-ai/dsh-tool-pwsh/render + */ + +import type { BashProcessRead, CollectedOutput } from '@deepseek-ai/dsh-bash' + +/* jscpd:ignore-start -- deliberate twin of dsh-tool-bash/render.ts minus the sandbox surface (Agent Note). */ + +/** Append the truncation notice (with the full-output spill path) to a stream's text. */ +function streamText(output: CollectedOutput): string { + if (!output.truncated) return output.text + return `${output.text}\n[output truncated; full output: ${output.spillPath ?? '(unavailable)'}]` +} + +/** The renderable foreground result shape (the schema-derived value, no `kind`). */ +export interface RenderablePwshResult { + exitCode: number | null + signal: string | null + timedOut: boolean + timeoutMs: number + stdout: CollectedOutput + stderr: CollectedOutput +} + +/** + * Shape one finished run into the text the model sees: stdout, then a marked + * stderr section, then exit-status markers, matching the bash tool's story — + * a clean exit (0, no signal) produces no marker. + * @param result - the completed foreground run from the executor. + * @returns the model-facing text: output body (or `(no output)`), then any timeout/signal/exit markers, each on its own line. + */ +export function renderPwshResult(result: RenderablePwshResult): string { + const out = streamText(result.stdout) + const err = streamText(result.stderr) + + let body = out + if (err.length > 0) { + // Single newline between sections (stdout usually ends with one already). + if (body.length > 0 && !body.endsWith('\n')) body += '\n' + body += `[stderr]\n${err}` + } + if (body.length === 0) body = '(no output)' + + const markers: string[] = [] + // A command may trap the termination and exit 0 after timeout; still report interruption. + if (result.timedOut) markers.push(`[timed out after ${result.timeoutMs}ms]`) + if (result.signal !== null) { + markers.push(`[killed by signal: ${result.signal}]`) + } else if (result.exitCode !== 0) { + markers.push(`[exit code: ${result.exitCode}]`) + } + if (markers.length === 0) return body + + if (!body.endsWith('\n')) body += '\n' + return body + markers.join('\n') +} + +/** + * Shape one background-process read into the `task_output` delta the model + * sees: the incremental delta, plus the lossy-read notice (with full-stream + * spill paths) when in-memory truncation dropped unread bytes. + * @param read - one incremental read from the process handle. + * @returns the delta text with any loss notice appended. + */ +export function renderPwshProcessRead(read: BashProcessRead): string { + const notices: string[] = [] + if (read.lossy) { + const paths = [read.stdoutSpillPath, read.stderrSpillPath].filter((path): path is string => path !== undefined) + notices.push(`[some output was dropped from memory; full output: ${paths.length > 0 ? paths.join(', ') : '(unavailable)'}]`) + } + if (notices.length === 0) return read.delta + return `${read.delta}${read.delta.length > 0 && !read.delta.endsWith('\n') ? '\n' : ''}${notices.join('\n')}` +} +/* jscpd:ignore-end */ diff --git a/packages/bash/tool-pwsh/tests/integration.spec.ts b/packages/bash/tool-pwsh/tests/integration.spec.ts new file mode 100644 index 0000000000..c347866f50 --- /dev/null +++ b/packages/bash/tool-pwsh/tests/integration.spec.ts @@ -0,0 +1,154 @@ +/** + * Integration tests: the REAL `@deepseek-ai/dsh-pwsh-local` executor plus the + * `pwsh` tool, exercised through `ctx.tools.execute()` with a real PowerShell + * process. These verify the world — actual commands run, stdout/stderr come + * back, exit codes render, timeouts abort, background tasks settle through the + * generic task runtime, and per-session cwd resolution works. The suite + * self-skips when no usable `pwsh` resolves (a CI accommodation for hosts without + * PowerShell); the fake-executor suite (tools.spec.ts) carries the coverage + * gate. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { spawnSync } from 'node:child_process' +import { Context } from 'cordis' +import { CallId } from '@deepseek-ai/dsh-llm' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry, { TOOL_ABORTED } from '@deepseek-ai/dsh-tools' +import LocalTaskService from '@deepseek-ai/dsh-tasks-local' +import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' +import { PwshLocalExecutor, resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local' +import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh' +import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env' + +const testToolSignal = new AbortController().signal + +// The probe follows the executor's own resolution (Program Files installs on +// Windows are found even when bare `pwsh` is not on PATH). +const hasPwsh = spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0 + +/** Normalize PowerShell's platform line endings (CRLF on Windows, LF elsewhere). */ +const lf = (text: string): string => text.replace(/\r\n/g, '\n') + +let dir: string +let ctx: Context + +let callCounter = 0 +function call(name: string, args: unknown, agentObj?: object, signal?: AbortSignal) { + return ctx.tools.execute({ + signal: signal ?? testToolSignal, + callId: CallId(`it-${++callCounter}`), + name, + arguments: args, + ...agentObj ? { agent: agentObj as never } : {}, + }) +} + +function text(result: { content: { type: string; text?: string }[] }): string { + return result.content.filter(b => b.type === 'text').map(b => b.text).join('') +} + +describe.skipIf(!hasPwsh)('pwsh tool over the real pwsh executor', () => { + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'dsh-tool-pwsh-')) + await writeFile(join(dir, 'greeting.txt'), 'hello pwsh\n') + + ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(LocalTaskService) + await ctx.plugin(ToolTasks) + await ctx.plugin(LocalSubprocessService) + await ctx.plugin(BashEnvPlugin) + await ctx.plugin(PwshLocalExecutor, { timeoutMs: 20_000, graceMs: 200 }) + await ctx.plugin(ToolPwsh) + }) + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }) + }) + + const agent = () => ({ session: { header: { id: 'session-int', cwd: dir } } }) + + it('runs a command and returns stdout with no marker on a clean exit', async () => { + const result = await call('pwsh', { command: 'Write-Output hi', description: 'say hi' }, agent()) + expect(result.isError).toBe(false) + if (result.isError) throw new Error('expected pwsh success') + expect(result.value).toMatchObject({ kind: 'foreground', exitCode: 0 }) + expect(lf(text(result))).toBe('hi\n') + }) + + it('returns stderr in a marked section and a nonzero exit as a marker, not an error', async () => { + const result = await call('pwsh', { + command: '[Console]::Error.WriteLine("boom"); exit 3', + description: 'fail loudly', + }, agent()) + expect(result.isError).toBe(false) + expect(lf(text(result))).toBe('[stderr]\nboom\n[exit code: 3]') + }) + + it('resolves relative paths in the session workspace', async () => { + const result = await call('pwsh', { + command: 'Get-Content greeting.txt', + description: 'read greeting', + }, agent()) + expect(result.isError).toBe(false) + expect(lf(text(result))).toBe('hello pwsh\n') + }) + + it('a per-call timeout kills the run and reports the timed-out marker, not an error', async () => { + const result = await call('pwsh', { + command: 'Start-Sleep -Seconds 60', + description: 'sleep forever', + timeoutMs: 100, + }, agent()) + expect(result.isError).toBe(false) + if (result.isError) throw new Error('expected a timed-out foreground result') + expect(result.value).toMatchObject({ kind: 'foreground', timedOut: true, aborted: false }) + // Windows reports the forced termination as exit 1 without a signal; + // POSIX reports SIGTERM — the timeout marker is the stable fact. + expect(lf(text(result))).toContain('[timed out after 100ms]') + }) + + it('an upstream cancellation aborts the run', async () => { + const controller = new AbortController() + const pending = call('pwsh', { + command: 'Start-Sleep -Seconds 60', + description: 'sleep forever', + }, agent(), controller.signal) + setTimeout(() => { controller.abort() }, 50) + const result = await pending + expect(result.isError).toBe(true) + expect(result.error).toMatchObject({ info: { name: 'AbortError', code: TOOL_ABORTED } }) + }) + + it('a background run settles through the REAL task_output tool', async () => { + const started = await call('pwsh', { + command: 'Start-Sleep -Milliseconds 300; Write-Output bg-done', + description: 'background greeting', + run_in_background: true, + }) + expect(started.isError).toBe(false) + if (started.isError) throw new Error('expected background pwsh success') + expect(started.value).toMatchObject({ kind: 'background' }) + const taskId = (started.value as { taskId: string }).taskId + + // The output delta and the terminal status can land in separate reads + // (Windows flushes the child pipe at exit), so collect incrementally — + // the same two-step shape as the bash background suite. + const deadline = Date.now() + 10_000 + let output = '' + while (Date.now() < deadline) { + const read = await call('task_output', { task_id: taskId }) + output += text(read) + if (output.includes('bg-done') && output.includes('[status: completed, exit code: 0]')) break + await new Promise(resolve => setTimeout(resolve, 50)) + } + expect(output).toContain('bg-done') + expect(output).toContain('[status: completed, exit code: 0]') + }) +}) diff --git a/packages/bash/tool-pwsh/tests/loader.spec.ts b/packages/bash/tool-pwsh/tests/loader.spec.ts new file mode 100644 index 0000000000..7037162579 --- /dev/null +++ b/packages/bash/tool-pwsh/tests/loader.spec.ts @@ -0,0 +1,63 @@ +/** + * REAL-composition tier (packages/AGENTS.md): boot the examples-owned + * tool-pwsh Loader fixture as a subprocess through the same app/boot path a + * deployment uses, execute real foreground and background pwsh commands + * through the tool registry, and assert the assembled model-visible surface: + * schema, prompt section, and rendered results. Self-skips when no `pwsh` + * executable exists (a CI accommodation for hosts without PowerShell). + */ + +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { spawnSync } from 'node:child_process' +import { describe, expect, it } from 'vitest' +import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' +import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local' + +// The probe follows the executor's own resolution (Program Files installs on +// Windows are found even when bare `pwsh` is not on PATH). +const hasPwsh = spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0 + +const driver = fileURLToPath(new URL( + '../../../../examples/acp-agent/tests/fixtures/bash/tool-pwsh/driver.ts', + import.meta.url, +)) +const configPath = fileURLToPath(new URL( + '../../../../examples/acp-agent/tests/fixtures/bash/tool-pwsh/cordis.yml', + import.meta.url, +)) +const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) + +interface PwshLoaderReport { + schemaHasRunInBackground: boolean + promptHasMarkerSection: boolean + foregroundText: string + backgroundText: string +} + +describe.skipIf(!hasPwsh)('tool-pwsh through a real Loader composition', () => { + it('registers the pwsh surface and renders real foreground and background results', async () => { + let report: PwshLoaderReport | undefined + const { stderr } = await runLoaderSmoke({ + label: 'tool-pwsh loader smoke', + tempDirPrefix: 'tool-pwsh-loader-', + binScript: driver, + libBinScript: driver, + configPath, + tsconfigPath: repoTsconfig, + inspect: async (cwd) => { + report = JSON.parse(await readFile(join(cwd, 'pwsh-loader-report.json'), 'utf8')) as PwshLoaderReport + }, + }) + expect(stderr).not.toContain('UNHANDLED') + expect(report).toBeDefined() + expect(report).toMatchObject({ + schemaHasRunInBackground: true, + promptHasMarkerSection: true, + }) + expect(report?.foregroundText).toBe('loader-ok\n') + expect(report?.backgroundText).toContain('loader-bg-ok') + expect(report?.backgroundText).toContain('[status: completed, exit code: 0]') + }, LOADER_SMOKE_TEST_TIMEOUT_MS) +}) diff --git a/packages/bash/tool-pwsh/tests/tools.spec.ts b/packages/bash/tool-pwsh/tests/tools.spec.ts new file mode 100644 index 0000000000..218099326f --- /dev/null +++ b/packages/bash/tool-pwsh/tests/tools.spec.ts @@ -0,0 +1,637 @@ +/** + * Consumer-surface tests for the `pwsh` tool over a FAKE bash executor, + * exercised through `ctx.tools.execute()` so nothing bypasses the tool + * registry. The fake executor makes every seam outcome scriptable — output + * text, truncation, timeout, abort, nonzero exits, background handles — so + * these tests verify the schema, argument validation, workdir derivation, + * managed `DSH_*` collection, abort translation, canonical result projection, + * rendering, background task wiring, and the UI presenters. Real-pwsh behavior + * is pinned separately in integration.spec.ts. + */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve as resolvePath } from 'node:path' +import { CallId } from '@deepseek-ai/dsh-llm' +import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry, { TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' +import LocalTaskService from '@deepseek-ai/dsh-tasks-local' +import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { SessionId } from '@deepseek-ai/dsh-session' +import { BashExecutor } from '@deepseek-ai/dsh-bash' +import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash' +import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh' +import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env' +import type { BashProcessRead } from '@deepseek-ai/dsh-bash' +import { processOutcome } from '../src/background.ts' +import { renderPwshProcessRead } from '../src/render.ts' + +const testToolSignal = new AbortController().signal + +/** + * A scriptable fake executor: `resolve()` mirrors the real defaulting, `run()` + * returns the armed foreground script, `start()` returns the armed background + * handle. + */ +class FakeBash extends BashExecutor { + requests: BashExecRequest[] = [] + specs: BashExecSpec[] = [] + startCalls = 0 + handler: (spec: BashExecSpec) => BashRunResult = () => runResult('') + backgroundHandler: (spec: BashExecSpec) => BashProcess = () => fakeProcess('bg-ok\n') + + override resolve(request: BashExecRequest): BashExecSpec { + this.requests.push(request) + return { + command: request.command, + workdir: request.workdir ?? process.cwd(), + timeoutMs: request.timeoutMs ?? 60_000, + stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000, + ...request.signal ? { signal: request.signal } : {}, + ...request.stdin !== undefined ? { stdin: request.stdin } : {}, + ...request.env !== undefined ? { env: request.env } : {}, + ...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {}, + sandboxPolicy: request.sandboxPolicy, + } + } + + override async run(spec: BashExecSpec): Promise { + this.specs.push(spec) + return this.handler(spec) + } + + override start(spec: BashExecSpec): BashProcess { + this.startCalls++ + this.specs.push(spec) + return this.backgroundHandler(spec) + } +} + +/** A successful run result over the given stdout; overrides script the failure shapes. */ +function runResult(stdout: string, overrides?: Partial): BashRunResult { + return { + exitCode: 0, + signal: null, + timedOut: false, + aborted: false, + timeoutMs: 60_000, + stdout: { text: stdout, truncated: false }, + stderr: { text: '', truncated: false }, + ...overrides, + } +} + +/** A settled successful background handle; overrides script failure shapes. */ +function fakeProcess(delta = 'bg-ok\n'): BashProcess { + let consumed = false + return { + status: 'completed', + exitCode: 0, + signal: null, + done: Promise.resolve(), + readOutput: () => { + if (consumed) return { delta: '', lossy: false } + consumed = true + return { delta, lossy: false } + }, + kill: () => false, + } +} + +/** A running background handle whose kill() settles it as killed (like a real task_kill). */ +function killableProcess(): BashProcess { + let resolveDone: () => void = () => {} + const done = new Promise((resolve) => { resolveDone = resolve }) + const proc: BashProcess = { + status: 'running', + exitCode: null, + signal: null, + done, + readOutput: () => ({ delta: '', lossy: false }), + kill: () => { + if (proc.status !== 'running') return false + proc.status = 'killed' + proc.signal = 'SIGTERM' + resolveDone() + return true + }, + } + return proc +} + +async function setup(toolConfig: Partial = {}, dshHome?: string) { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(BashEnvPlugin, dshHome === undefined ? {} : { dshHome }) + await ctx.plugin(FakeBash) + await ctx.plugin(ToolPwsh, toolConfig) + const bash = ctx.bash as FakeBash + return { ctx, bash } +} + +/** Full harness: the generic task runtime + its control surface, then the pwsh tool. */ +async function setupWithTasks(toolConfig: Partial = {}, dshHome?: string) { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalTaskService) + await ctx.plugin(ToolTasks) + await ctx.plugin(BashEnvPlugin, dshHome === undefined ? {} : { dshHome }) + await ctx.plugin(FakeBash) + await ctx.plugin(ToolPwsh, toolConfig) + const bash = ctx.bash as FakeBash + return { ctx, bash } +} + +/** + * Build a fake {@link Agent} with the shared agent/session identity, give it a + * dedicated lifecycle fiber for `Agent.ctx`, and register it in `ctx.agents`. + */ +function registerFakeAgent(ctx: Context, sessionId: string): Agent { + const scopeFiber = ctx.plugin(() => {}) + const id = SessionId(sessionId) + const agent = { + id, + ctx: scopeFiber.ctx, + session: { id, header: { version: 0, id, createdAt: 0 } }, + } as unknown as Agent + ctx.agents.register(agent) + return agent +} + +let callCounter = 0 +function call(ctx: Context, name: string, args: unknown, agent?: Agent) { + return ctx.tools.execute({ + signal: testToolSignal, + callId: CallId(`call-${++callCounter}`), + name, + arguments: args, + ...agent ? { agent } : {}, + }) +} + +function text(result: { content: { type: string; text?: string }[] }): string { + return result.content.filter(b => b.type === 'text').map(b => b.text).join('') +} + +async function callUntilText( + ctx: Context, + name: string, + args: unknown, + expected: string, + timeoutMs = 5_000, +): Promise>> { + const deadline = Date.now() + timeoutMs + let last: Awaited> | undefined + while (Date.now() < deadline) { + last = await call(ctx, name, args) + if (text(last).includes(expected)) return last + await new Promise(resolve => setTimeout(resolve, 20)) + } + throw new Error(`tool output did not include ${JSON.stringify(expected)}; last text ${JSON.stringify(last === undefined ? '' : text(last))}`) +} + +describe('registration', () => { + it('registers the pwsh tool with its prompt section and schema', async () => { + const { ctx } = await setup() + const schema = ctx.tools.schemas().find(s => s.name === 'pwsh') + expect(schema).toBeDefined() + expect(schema?.description).toContain('PowerShell command') + expect(schema?.parameters.properties).toMatchObject({ + command: { type: 'string' }, + description: { type: 'string' }, + timeoutMs: { type: 'number' }, + workdir: { type: 'string' }, + run_in_background: { type: 'boolean' }, + }) + expect(schema?.parameters.required).toEqual(['command', 'description']) + const prompt = renderPrompt(await ctx.systemPrompt.assemble()) + expect(prompt).toContain('Non-zero exits are reported as `[exit code: N]` markers') + expect(prompt).toContain('without a signal marker') + }) + + it('stays pending until ctx.bash exists (inject)', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(ToolPwsh) + expect(ctx.tools.schemas()).toHaveLength(0) + }) + + it('unregisters everything on fiber disposal (HMR safety)', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(BashEnvPlugin) + await ctx.plugin(FakeBash) + const fiber = await ctx.plugin(ToolPwsh) + expect(ctx.tools.schemas()).toHaveLength(1) + await fiber.dispose() + expect(ctx.tools.schemas()).toHaveLength(0) + }) +}) + +describe('argument validation', () => { + it('rejects a blank command or description and a non-positive timeoutMs', async () => { + const { ctx } = await setup() + expect(text(await call(ctx, 'pwsh', { command: ' ', description: 'd' }))).toContain('expected a non-empty string') + expect(text(await call(ctx, 'pwsh', { command: 'Write-Output hi', description: ' ' }))).toContain('expected a non-empty string') + expect(text(await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'd', timeoutMs: -1 }))) + .toContain('invalid timeoutMs: expected a positive number') + }) +}) + +describe('execution through the bash seam', () => { + it('forwards command, session cwd, timeout, and managed DSH_* environment', async () => { + const dshHome = mkdtempSync(join(tmpdir(), 'dsh-tool-pwsh-home-')) + const { ctx, bash } = await setup({}, dshHome) + bash.handler = () => runResult('hi\n') + const agent = registerFakeAgent(ctx, 'session-1') + Object.assign(agent.session.header, { cwd: '/sessions/s1' }) + const result = await call(ctx, 'pwsh', { + command: 'Write-Output hi', + description: 'say hi', + timeoutMs: 1234, + }, agent) + expect(result.isError).toBe(false) + const request = bash.requests[0] + expect(request?.command).toBe('Write-Output hi') + expect(request?.workdir).toBe('/sessions/s1') + expect(request?.timeoutMs).toBe(1234) + expect(request?.dshEnv).toEqual({ + DSH_HOME: dshHome, + DSH_SHELL: '1', + DSH_SESSION_ID: 'session-1', + }) + expect(bash.specs[0]?.workdir).toBe('/sessions/s1') + }) + + it('resolves a relative workdir against the session cwd, absolute ones verbatim', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult('ok\n') + const agent = registerFakeAgent(ctx, 'session-cwd') + Object.assign(agent.session.header, { cwd: '/sessions/s1' }) + await call(ctx, 'pwsh', { command: 'pwd', description: 'cwd', workdir: 'sub/dir' }, agent) + expect(bash.requests[0]?.workdir).toBe(resolvePath('/sessions/s1', 'sub/dir')) + await call(ctx, 'pwsh', { command: 'pwd', description: 'cwd', workdir: resolvePath('/abs/path') }, agent) + expect(bash.requests[1]?.workdir).toBe(resolvePath('/abs/path')) + }) + + it('omits workdir and the session id without an agent, so executor defaulting applies', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult('ok\n') + await call(ctx, 'pwsh', { command: 'Write-Output ok', description: 'ok' }) + expect(bash.requests[0]).not.toHaveProperty('workdir') + const dshEnv = bash.requests[0]?.dshEnv + expect(dshEnv).toBeDefined() + expect(dshEnv?.['DSH_SHELL']).toBe('1') + expect(dshEnv?.['DSH_HOME']).toEqual(expect.any(String)) + expect(dshEnv).not.toHaveProperty('DSH_SESSION_ID') + }) + + it('forwards exec.signal into the resolved request', async () => { + const { ctx, bash } = await setup() + const controller = new AbortController() + bash.handler = () => runResult('ok\n') + await ctx.tools.execute({ + signal: controller.signal, + callId: CallId('call-signal'), + name: 'pwsh', + arguments: { command: 'Write-Output ok', description: 'ok' }, + }) + expect(bash.requests[0]?.signal).toBe(controller.signal) + }) + + it('projects the canonical foreground result with stdout, stderr, and exit facts', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult('out\n', { + exitCode: 2, + stderr: { text: 'err\n', truncated: false }, + timeoutMs: 5000, + }) + const result = await call(ctx, 'pwsh', { command: 'failing', description: 'fail' }) + expect(result.isError).toBe(false) + if (result.isError) throw new Error('expected pwsh success') + expect(result.value).toEqual({ + kind: 'foreground', + exitCode: 2, + signal: null, + timedOut: false, + aborted: false, + timeoutMs: 5000, + stdout: { text: 'out\n', truncated: false }, + stderr: { text: 'err\n', truncated: false }, + }) + expect(text(result)).toBe('out\n[stderr]\nerr\n[exit code: 2]') + }) + + it('renders a clean exit without a marker and an empty body as (no output)', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult('hi\n') + const clean = await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'say hi' }) + expect(text(clean)).toBe('hi\n') + + bash.handler = () => runResult('') + const empty = await call(ctx, 'pwsh', { command: 'Write-Output -NoNewline ""', description: 'nothing' }) + expect(text(empty)).toBe('(no output)') + }) + + it('renders stderr-only output without a stdout prefix', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult('', { + stderr: { text: 'err\n', truncated: false }, + exitCode: 1, + }) + const result = await call(ctx, 'pwsh', { command: 'fail', description: 'fail' }) + expect(text(result)).toBe('[stderr]\nerr\n[exit code: 1]') + }) + + it('inserts the separating newline before the stderr section when stdout lacks one', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult('out', { + stderr: { text: 'err\n', truncated: false }, + exitCode: 1, + }) + const result = await call(ctx, 'pwsh', { command: 'fail', description: 'fail' }) + expect(text(result)).toBe('out\n[stderr]\nerr\n[exit code: 1]') + }) + + it('renders the truncation notice with the spill path, then markers', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult('tail', { + stdout: { text: 'tail', truncated: true, spillPath: '/spill/out.log' }, + stderr: { text: '', truncated: false }, + }) + const result = await call(ctx, 'pwsh', { command: 'noisy', description: 'noise' }) + expect(text(result)).toBe('tail\n[output truncated; full output: /spill/out.log]') + + bash.handler = () => runResult('', { timedOut: true, exitCode: null, signal: 'SIGTERM', timeoutMs: 500 }) + const timedOut = await call(ctx, 'pwsh', { command: 'slow', description: 'slow' }) + // A timeout kill carries both facts, mirroring the bash tool's markers. + expect(text(timedOut)).toBe('(no output)\n[timed out after 500ms]\n[killed by signal: SIGTERM]') + }) + + it('renders the truncation notice with (unavailable) when no spill path exists', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult('tail', { + stdout: { text: 'tail', truncated: true }, + stderr: { text: '', truncated: false }, + }) + const result = await call(ctx, 'pwsh', { command: 'noisy', description: 'noise' }) + expect(text(result)).toBe('tail\n[output truncated; full output: (unavailable)]') + }) + + it('translates an aborted run into the TOOL_ABORTED HarnessError', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult('', { aborted: true, exitCode: null, signal: 'SIGTERM' }) + const result = await call(ctx, 'pwsh', { command: 'Start-Sleep -Seconds 60', description: 'sleep' }) + expect(result.isError).toBe(true) + expect(result.error).toMatchObject({ info: { name: 'AbortError', code: TOOL_ABORTED } }) + }) +}) + +describe('background execution through the task runtime', () => { + it('run_in_background acks with the task id, readable through the REAL task_output tool', async () => { + const { ctx } = await setupWithTasks() + const started = await call(ctx, 'pwsh', { command: 'Write-Output bg-ok', description: 'test command', run_in_background: true }) + expect(started.isError).toBe(false) + if (started.isError) throw new Error('expected background pwsh success') + expect(started.value).toEqual({ kind: 'background', taskId: 'pwsh-1' }) + expect(text(started)).toBe('started background task pwsh-1') + + const read = await callUntilText(ctx, 'task_output', { task_id: 'pwsh-1' }, 'bg-ok') + expect(text(read)).toContain('bg-ok') + // A later read reports the terminal outcome in the generic status line. + const final = await callUntilText(ctx, 'task_output', { task_id: 'pwsh-1' }, '[status: completed, exit code: 0]') + expect(final.isError).toBe(false) + }) + + it('a running background task is killable through the REAL task_kill tool', async () => { + const { ctx, bash } = await setupWithTasks() + bash.backgroundHandler = () => killableProcess() + await call(ctx, 'pwsh', { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true }) + + const killed = await call(ctx, 'task_kill', { task_id: 'pwsh-1' }) + expect(text(killed)).toBe('requested cancellation of task pwsh-1') + // The cancel reached the process handle; the task settles as killed with + // the signal detail mapped by processOutcome. + const final = await call(ctx, 'task_output', { task_id: 'pwsh-1', wait: true }) + expect(text(final)).toContain('[status: killed, signal: SIGTERM]') + }) + + it('a background task started by an agent is registered with that agent as owner', async () => { + const { ctx } = await setupWithTasks() + const agent = registerFakeAgent(ctx, 'sess-owner') + const started = await call(ctx, 'pwsh', { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true }, agent) + expect(text(started)).toBe('started background task pwsh-1') + + const anon = await call(ctx, 'task_output', { task_id: 'pwsh-1' }) + expect(anon.isError).toBe(true) + expect(text(anon)).toMatch(/belongs to another session/) + + const killed = await call(ctx, 'task_kill', { task_id: 'pwsh-1' }, agent) + expect(killed.isError).toBe(false) + await call(ctx, 'task_output', { task_id: 'pwsh-1', wait: true }, agent) // await settlement — no orphan + }) + + it('fails loud when the task runtime is not loaded', async () => { + const { ctx } = await setup() // no LocalTaskService / ToolTasks + const result = await call(ctx, 'pwsh', { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true }) + expect(result.isError).toBe(true) + expect(text(result)).toContain('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks') + }) + + it('a pre-aborted call is skipped before the process starts', async () => { + const { ctx, bash } = await setupWithTasks() + const controller = new AbortController() + controller.abort() + const result = await ctx.tools.execute({ + callId: CallId('call-pre-aborted'), + name: 'pwsh', + arguments: { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true }, + signal: controller.signal, + }) + expect(result.isError).toBe(true) + expect(result.error).toEqual({ + message: 'tool call aborted before dispatch', + info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }, + }) + expect(bash.startCalls).toBe(0) + }) + + it('never spawns the process when tasks.start preflight throws (no orphan, by construction)', async () => { + // With no control surface, task preflight fails before the executor can spawn. + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(LocalTaskService) + await ctx.plugin(BashEnvPlugin) + await ctx.plugin(FakeBash) + await ctx.plugin(ToolPwsh) + const bash = ctx.bash as FakeBash + + const result = await call(ctx, 'pwsh', { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true }) + expect(result.isError).toBe(true) + expect(text(result)).toContain('no control surface is attached') + // Declare-then-execute: the failed preflight means no process ever ran. + expect(bash.startCalls).toBe(0) + }) + + it('enableRunInBackground: false removes the parameter and flips the description', async () => { + const { ctx } = await setup({ enableRunInBackground: false }) + const schema = ctx.tools.schemas().find(s => s.name === 'pwsh')! + expect(Object.keys(schema.parameters.properties as Record)) + .toEqual(['command', 'description', 'timeoutMs', 'workdir']) + expect(schema.description).toContain('Background execution is not available') + expect(schema.description).not.toContain('run_in_background') + + // Schema omission is advertising; execution must also enforce the opt-out. + const forced = await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'test command', run_in_background: true }) + expect(forced.isError).toBe(true) + expect(text(forced)).toContain('run_in_background is disabled for this deployment') + const foreground = await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'test command' }) + expect(foreground.isError).toBe(false) + }) + + it('applies the built-in background default when apply() receives a bare config', async () => { + // Bypasses the schemastery defaults on purpose: apply() must stand on its + // own `?? true` fallback when embedded programmatically without the schema. + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(BashEnvPlugin) + await ctx.plugin(FakeBash) + ToolPwsh.apply(ctx, {}) + const schema = ctx.tools.schemas()[0]! + expect(schema.parameters.properties).toHaveProperty('run_in_background') + expect(schema.description).toContain('task_output') + }) +}) + +describe('UI presentation', () => { + it('a real execute renders the console view through the tool definition presenter', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult('hi\n') + const args = { command: 'Write-Output hi', description: 'say hi' } + const result = await call(ctx, 'pwsh', args) + const view = ctx.tools.get('pwsh')?.presentResult?.(args, result) + expect(view).toEqual({ + card: 'generic', + content: [{ type: 'text', text: '```console\nhi\n```' }], + }) + }) + + it('the pending call view is a terminal card carrying command, description, and optional cwd', async () => { + const { ctx } = await setup() + const definition = ctx.tools.get('pwsh') + expect(definition?.presentCall?.({ command: 'Get-Process', description: 'List processes' })) + .toEqual({ card: 'terminal', title: 'Get-Process', description: 'List processes' }) + expect(definition?.presentCall?.({ command: 'Get-Process', description: 'List processes', workdir: 'C:\\work' })) + .toMatchObject({ cwd: 'C:\\work' }) + }) + + it('a background pending call renders the generic card like the bash tool', async () => { + const { ctx } = await setup() + const definition = ctx.tools.get('pwsh') + expect(definition?.presentCall?.({ + command: 'Start-Sleep -Seconds 60', + description: 'long wait', + run_in_background: true, + })).toEqual({ + card: 'generic', + title: 'Start-Sleep -Seconds 60', + kind: 'execute', + rawInput: 'Start-Sleep -Seconds 60', + content: [{ type: 'text', text: 'long wait' }], + }) + }) + + it('presentResult falls back to undefined for multi-block or non-text content', async () => { + const { ctx } = await setup() + const definition = ctx.tools.get('pwsh') + const args = { command: 'Write-Output hi', description: 'say hi' } + const multi = { content: [{ type: 'text' as const, text: 'a' }, { type: 'text' as const, text: 'b' }], isError: false } + expect(definition?.presentResult?.(args, multi as never)).toBeUndefined() + const image = { content: [{ type: 'image' as const, text: 'a' }], isError: false } + expect(definition?.presentResult?.(args, image as never)).toBeUndefined() + }) +}) + +describe('renderPwshProcessRead', () => { + const base: BashProcessRead = { delta: 'out\n', lossy: false } + + it('returns the delta verbatim for a lossless read', () => { + expect(renderPwshProcessRead(base)).toBe('out\n') + expect(renderPwshProcessRead({ delta: '', lossy: false })).toBe('') + }) + + it('appends the loss notice with the available spill paths', () => { + expect(renderPwshProcessRead({ ...base, lossy: true, stdoutSpillPath: 'C:\\spill\\out.log' })) + .toBe('out\n[some output was dropped from memory; full output: C:\\spill\\out.log]') + expect(renderPwshProcessRead({ + ...base, + lossy: true, + stdoutSpillPath: 'C:\\spill\\out.log', + stderrSpillPath: 'C:\\spill\\err.log', + })) + .toBe('out\n[some output was dropped from memory; full output: C:\\spill\\out.log, C:\\spill\\err.log]') + }) + + it('reports (unavailable) when a lossy read has no safe spill path', () => { + expect(renderPwshProcessRead({ ...base, lossy: true })) + .toBe('out\n[some output was dropped from memory; full output: (unavailable)]') + }) + + it('an empty lossy delta is the notice alone', () => { + expect(renderPwshProcessRead({ delta: '', lossy: true, stderrSpillPath: 'C:\\spill\\err.log' })) + .toBe('[some output was dropped from memory; full output: C:\\spill\\err.log]') + }) + + it('inserts the separating newline only when the delta lacks one', () => { + expect(renderPwshProcessRead({ delta: 'tail', lossy: true })) + .toBe('tail\n[some output was dropped from memory; full output: (unavailable)]') + expect(renderPwshProcessRead({ delta: 'tail\n', lossy: true })) + .toBe('tail\n[some output was dropped from memory; full output: (unavailable)]') + }) +}) + +describe('processOutcome', () => { + function settled(over: Partial): BashProcess { + return { + status: 'completed', + exitCode: 0, + signal: null, + done: Promise.resolve(), + readOutput: () => ({ delta: '', lossy: false }), + kill: () => false, + ...over, + } + } + + it('maps a signal-killed process to killed with the signal detail', () => { + expect(processOutcome(settled({ status: 'killed', signal: 'SIGTERM' }))) + .toEqual({ status: 'killed', detail: 'signal: SIGTERM' }) + }) + + it('maps a killed process without a recorded signal (kill raced exit / spawn failure)', () => { + expect(processOutcome(settled({ status: 'killed', exitCode: null }))) + .toEqual({ status: 'killed', detail: 'killed before exit' }) + }) + + it('maps a completed process to its exit code', () => { + expect(processOutcome(settled({ exitCode: 3 }))) + .toEqual({ status: 'completed', detail: 'exit code: 3' }) + }) + + it('defensively reads a null exit code as 0 (handle shapes from other executors)', () => { + expect(processOutcome(settled({ exitCode: null }))) + .toEqual({ status: 'completed', detail: 'exit code: 0' }) + }) +}) diff --git a/packages/bash/tool-pwsh/tsconfig.json b/packages/bash/tool-pwsh/tsconfig.json new file mode 100644 index 0000000000..61b2c69448 --- /dev/null +++ b/packages/bash/tool-pwsh/tsconfig.json @@ -0,0 +1,45 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/tools" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../bash/bash" + }, + { + "path": "../../bash/bash-env" + }, + { + "path": "../../tasks/tasks" + }, + { + "path": "../../core/system-prompt" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index b14dfc80e3..929464fdc8 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -41,9 +41,7 @@ "lib/index.js", "lib/invariant.js", "lib/client.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "peerDependencies": { "@deepseek-ai/dsh-host-webserver": "^0.0.1", diff --git a/packages/client/hmr/package.json b/packages/client/hmr/package.json index 99f4d7a7fe..7f86a68b10 100644 --- a/packages/client/hmr/package.json +++ b/packages/client/hmr/package.json @@ -49,8 +49,6 @@ "lib/index.js", "lib/invariant.js", "lib/client.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ] } diff --git a/packages/client/locale/package.json b/packages/client/locale/package.json index 4d75496dc8..75742a80d0 100644 --- a/packages/client/locale/package.json +++ b/packages/client/locale/package.json @@ -51,9 +51,7 @@ "lib/index.js", "lib/invariant.js", "lib/client.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "scripts": { "bundle": "tsdown", diff --git a/packages/client/modules/package.json b/packages/client/modules/package.json index 468ffdf0bc..2283853a7d 100644 --- a/packages/client/modules/package.json +++ b/packages/client/modules/package.json @@ -42,9 +42,7 @@ "lib/index.js", "lib/invariant.js", "lib/client.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", diff --git a/packages/client/runtime/package.json b/packages/client/runtime/package.json index f9cc6a308c..b636316b68 100644 --- a/packages/client/runtime/package.json +++ b/packages/client/runtime/package.json @@ -59,8 +59,6 @@ "lib/index.js", "lib/invariant.js", "lib/client.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ] } diff --git a/packages/client/schema-form/package.json b/packages/client/schema-form/package.json index 175133894a..c1cd5e8018 100644 --- a/packages/client/schema-form/package.json +++ b/packages/client/schema-form/package.json @@ -33,8 +33,6 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ] } diff --git a/packages/client/test-runtime/package.json b/packages/client/test-runtime/package.json index e892d9cd52..c93a302124 100644 --- a/packages/client/test-runtime/package.json +++ b/packages/client/test-runtime/package.json @@ -49,8 +49,6 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ] } diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index cd736883eb..1e45991080 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -69,7 +69,7 @@ function browserSourcePath(source: string, sourcemapPath: string): string { * own tsdown.config.ts (a preset-side glob hides it from the mechanical check). * @returns tsdown user configs emitting lib/*.js and lib/client.js. */ -export function clientBundle(id: string, libEntry: readonly string[]): UserConfig[] { +export function clientBundle(id: string, libEntry: readonly string[]): [UserConfig, UserConfig] { return [{ entry: [...libEntry], outDir: 'lib', diff --git a/packages/client/ui-command/package.json b/packages/client/ui-command/package.json index 8144aea6c8..30f23bcbf2 100644 --- a/packages/client/ui-command/package.json +++ b/packages/client/ui-command/package.json @@ -69,8 +69,6 @@ "lib/index.js", "lib/invariant.js", "lib/client.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ] } diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 88c09b5550..ff4e74da5e 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -72,8 +72,6 @@ "lib/index.js", "lib/invariant.js", "lib/client.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ] } diff --git a/packages/client/ui-goal/package.json b/packages/client/ui-goal/package.json index 98e22fd9d2..9430da812f 100644 --- a/packages/client/ui-goal/package.json +++ b/packages/client/ui-goal/package.json @@ -67,8 +67,6 @@ "lib/index.js", "lib/invariant.js", "lib/client.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ] } diff --git a/packages/client/ui-layout/package.json b/packages/client/ui-layout/package.json index b816533474..1a6e2785b6 100644 --- a/packages/client/ui-layout/package.json +++ b/packages/client/ui-layout/package.json @@ -56,8 +56,6 @@ "lib/index.js", "lib/invariant.js", "lib/client.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ] } diff --git a/packages/client/ui-model/package.json b/packages/client/ui-model/package.json index 2e9a199805..6be5dcbc43 100644 --- a/packages/client/ui-model/package.json +++ b/packages/client/ui-model/package.json @@ -68,8 +68,6 @@ "lib/index.js", "lib/invariant.js", "lib/client.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ] } diff --git a/packages/client/ui-models/package.json b/packages/client/ui-models/package.json index 025cc4c440..abe8dbc0f5 100644 --- a/packages/client/ui-models/package.json +++ b/packages/client/ui-models/package.json @@ -65,8 +65,6 @@ "lib/index.js", "lib/invariant.js", "lib/client.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ] } diff --git a/packages/client/ui-permission/package.json b/packages/client/ui-permission/package.json index 33813d5af1..55d896dbec 100644 --- a/packages/client/ui-permission/package.json +++ b/packages/client/ui-permission/package.json @@ -70,8 +70,6 @@ "lib/index.js", "lib/invariant.js", "lib/client.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ] } diff --git a/packages/client/ui-plan/package.json b/packages/client/ui-plan/package.json index f80a2be1ae..2ded06429c 100644 --- a/packages/client/ui-plan/package.json +++ b/packages/client/ui-plan/package.json @@ -66,8 +66,6 @@ "lib/index.js", "lib/invariant.js", "lib/client.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ] } diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json index 36962c90cb..49d6c2c2b6 100644 --- a/packages/client/ui-primitives/package.json +++ b/packages/client/ui-primitives/package.json @@ -44,9 +44,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", diff --git a/packages/client/ui-question/package.json b/packages/client/ui-question/package.json index fbf1dbb098..7416596284 100644 --- a/packages/client/ui-question/package.json +++ b/packages/client/ui-question/package.json @@ -63,8 +63,6 @@ "lib/index.js", "lib/invariant.js", "lib/client.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ] } diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json index 8f78ce6acb..a3e8973280 100644 --- a/packages/client/ui-settings-general/package.json +++ b/packages/client/ui-settings-general/package.json @@ -70,8 +70,6 @@ "lib/index.js", "lib/invariant.js", "lib/client.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ] } diff --git a/packages/client/ui-settings/package.json b/packages/client/ui-settings/package.json index d4b872173f..6fa2fdc8cb 100644 --- a/packages/client/ui-settings/package.json +++ b/packages/client/ui-settings/package.json @@ -63,8 +63,6 @@ "lib/index.js", "lib/invariant.js", "lib/client.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ] } diff --git a/packages/client/ui-sidebar/package.json b/packages/client/ui-sidebar/package.json index 7bff9523ed..dfbeefbe44 100644 --- a/packages/client/ui-sidebar/package.json +++ b/packages/client/ui-sidebar/package.json @@ -63,8 +63,6 @@ "lib/index.js", "lib/invariant.js", "lib/client.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ] } diff --git a/packages/client/ui-skill/package.json b/packages/client/ui-skill/package.json index 4ada43cd1d..20d61cdb53 100644 --- a/packages/client/ui-skill/package.json +++ b/packages/client/ui-skill/package.json @@ -54,8 +54,6 @@ "lib/index.js", "lib/invariant.js", "lib/client.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ] } diff --git a/packages/client/ui-slash/package.json b/packages/client/ui-slash/package.json index c7448532df..7562a536e3 100644 --- a/packages/client/ui-slash/package.json +++ b/packages/client/ui-slash/package.json @@ -61,8 +61,6 @@ "lib/index.js", "lib/invariant.js", "lib/client.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ] } diff --git a/packages/client/ui-slots/package.json b/packages/client/ui-slots/package.json index d0346ccf16..9c459fd0f8 100644 --- a/packages/client/ui-slots/package.json +++ b/packages/client/ui-slots/package.json @@ -27,9 +27,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", diff --git a/packages/client/ui-subagent/package.json b/packages/client/ui-subagent/package.json index 9e6b120c1e..026b00196b 100644 --- a/packages/client/ui-subagent/package.json +++ b/packages/client/ui-subagent/package.json @@ -70,8 +70,6 @@ "lib/index.js", "lib/invariant.js", "lib/client.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ] } diff --git a/packages/client/ui-theme/package.json b/packages/client/ui-theme/package.json index 7046f3391b..1adad710cc 100644 --- a/packages/client/ui-theme/package.json +++ b/packages/client/ui-theme/package.json @@ -19,7 +19,7 @@ "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" }, - "./styles/*": "./src/styles/*", + "./styles/*": "./lib/styles/*", "./src/*": "./src/*", "./package.json": "./package.json" }, @@ -56,9 +56,8 @@ "lib/index.js", "lib/invariant.js", "lib/client.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/styles", + "lib/types/**/*.d.ts" ], "scripts": { "bundle": "tsdown", diff --git a/packages/client/ui-theme/tsdown.config.ts b/packages/client/ui-theme/tsdown.config.ts index 1bc83af0e9..08616753ce 100644 --- a/packages/client/ui-theme/tsdown.config.ts +++ b/packages/client/ui-theme/tsdown.config.ts @@ -1,3 +1,11 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-theme', ['lib/types/index.js', 'lib/types/invariant.js']) +const [lib, client] = clientBundle( + '@deepseek-ai/dsh-client-ui-theme', + ['lib/types/index.js', 'lib/types/invariant.js'], +) + +export default [{ + ...lib, + copy: [{ from: 'src/styles/*', to: 'lib/styles' }], +}, client] diff --git a/packages/client/ui-trajectory/package.json b/packages/client/ui-trajectory/package.json index 8da6559866..576b561711 100644 --- a/packages/client/ui-trajectory/package.json +++ b/packages/client/ui-trajectory/package.json @@ -58,8 +58,6 @@ "lib/index.js", "lib/invariant.js", "lib/client.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ] } diff --git a/packages/client/ui-workspace/package.json b/packages/client/ui-workspace/package.json index df810c4493..71148df7b6 100644 --- a/packages/client/ui-workspace/package.json +++ b/packages/client/ui-workspace/package.json @@ -65,8 +65,6 @@ "lib/index.js", "lib/invariant.js", "lib/client.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ] } diff --git a/packages/client/web-react/package.json b/packages/client/web-react/package.json index 2d5c08b53a..b5e242a00e 100644 --- a/packages/client/web-react/package.json +++ b/packages/client/web-react/package.json @@ -36,8 +36,6 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ] } diff --git a/packages/client/web/package.json b/packages/client/web/package.json index b235e2accb..847d126386 100644 --- a/packages/client/web/package.json +++ b/packages/client/web/package.json @@ -47,8 +47,6 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ] } diff --git a/packages/client/web/tests/base-styles.spec.ts b/packages/client/web/tests/base-styles.spec.ts index d87921cede..ecea9e9f2c 100644 --- a/packages/client/web/tests/base-styles.spec.ts +++ b/packages/client/web/tests/base-styles.spec.ts @@ -10,6 +10,9 @@ import { describe, expect, it } from 'vitest' const THEME_PACKAGE = '@deepseek-ai/dsh-client-ui-theme' const baseCss = readFileSync(fileURLToPath(new URL('../src/base.css', import.meta.url)), 'utf8') +const themeManifest = JSON.parse( + readFileSync(fileURLToPath(new URL('../../ui-theme/package.json', import.meta.url)), 'utf8'), +) as { exports: Record; files: string[] } /** * Import specifiers of the sheet, in source order. Quote style and surrounding @@ -24,9 +27,9 @@ function importOrder(css: string): string[] { } /** - * Resolve a `/styles/` specifier to its path in the workspace. - * The theme package maps `./styles/*` to `./src/styles/*`, so the sheets stay - * on the source plane rather than needing a build. + * Resolve a `/styles/` specifier to its source path for a + * clean-tree test. The package build copies these sheets to their public + * `lib/styles` export. * @param specifier - import specifier from base.css. * @returns absolute path of the file the specifier names. */ @@ -38,6 +41,11 @@ function resolveThemeSheet(specifier: string): string { const imports = importOrder(baseCss) describe('web shell base.css', () => { + it('publishes theme sheets from the built artifact plane', () => { + expect(themeManifest.exports['./styles/*']).toBe('./lib/styles/*') + expect(themeManifest.files).toContain('lib/styles') + }) + it('imports every sheet from the theme package and each one exists', () => { expect(imports.length).toBeGreaterThan(0) for (const specifier of imports) { diff --git a/packages/code-runtime/code-runtime-worker/package.json b/packages/code-runtime/code-runtime-worker/package.json index e1e1d6a2f6..cc72bf1b12 100644 --- a/packages/code-runtime/code-runtime-worker/package.json +++ b/packages/code-runtime/code-runtime-worker/package.json @@ -25,9 +25,7 @@ "lib/index.js", "lib/invariant.js", "lib/worker.cjs", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/code-runtime/code-runtime/package.json b/packages/code-runtime/code-runtime/package.json index 2d59302ec9..f6e3a08ce1 100644 --- a/packages/code-runtime/code-runtime/package.json +++ b/packages/code-runtime/code-runtime/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/compact/command-compact/package.json b/packages/compact/command-compact/package.json index 3b009b45d7..324051b188 100644 --- a/packages/compact/command-compact/package.json +++ b/packages/compact/command-compact/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/compact/compact-basic/package.json b/packages/compact/compact-basic/package.json index 6a93e249a3..2b0624e06e 100644 --- a/packages/compact/compact-basic/package.json +++ b/packages/compact/compact-basic/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/compact/compact-tool-result-prune/package.json b/packages/compact/compact-tool-result-prune/package.json index 7cd9ff6b98..f3b0945460 100644 --- a/packages/compact/compact-tool-result-prune/package.json +++ b/packages/compact/compact-tool-result-prune/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/compact/compact/package.json b/packages/compact/compact/package.json index f0874990a3..100a3f56f8 100644 --- a/packages/compact/compact/package.json +++ b/packages/compact/compact/package.json @@ -26,9 +26,7 @@ "lib/index.js", "lib/invariant.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/context/session-reference/package.json b/packages/context/session-reference/package.json index df2cd96c9f..48111ca09b 100644 --- a/packages/context/session-reference/package.json +++ b/packages/context/session-reference/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { diff --git a/packages/context/time-context/package.json b/packages/context/time-context/package.json index 2b3ad9fca6..8e8421fd01 100644 --- a/packages/context/time-context/package.json +++ b/packages/context/time-context/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { diff --git a/packages/context/tmux-context/package.json b/packages/context/tmux-context/package.json index 222cdfa55b..524036a667 100644 --- a/packages/context/tmux-context/package.json +++ b/packages/context/tmux-context/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { diff --git a/packages/context/workspace-context/package.json b/packages/context/workspace-context/package.json index 0c50b8cc17..6548fb49a8 100644 --- a/packages/context/workspace-context/package.json +++ b/packages/context/workspace-context/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/cordis/repository-plugin/README.i18n.yaml b/packages/cordis/repository-plugin/README.i18n.yaml index fd2f9a0f82..e8dd5ddbf5 100644 --- a/packages/cordis/repository-plugin/README.i18n.yaml +++ b/packages/cordis/repository-plugin/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/cordis/repository-plugin/README.md README.md: 0ba1ce86d99a12e0f94e7a39fd3ae44dc29889a7 -README.zh.md: a03dc2e5ba604cd24d603159e8f50246eb50eab5 +README.zh.md: d3046db8155a908dd4afd8f50a5ab4774085a40f diff --git a/packages/cordis/repository-plugin/README.zh.md b/packages/cordis/repository-plugin/README.zh.md index a03dc2e5ba..d3046db815 100644 --- a/packages/cordis/repository-plugin/README.zh.md +++ b/packages/cordis/repository-plugin/README.zh.md @@ -95,7 +95,7 @@ Namespace 插件:具名导出 `name`/`inject`/`apply`、准备阶段常量 稳定的已连接工具列表保持前缀稳定。插件生命周期或 MCP 工具列表变化可能从首个受影响定义开始改变后续工具 schema 前缀。 -## 已知限制与延后工作 +## 已知限制与暂缓事项 - **仅支持 skill 与 MCP**:commands、钩子、agent(智能体)、apps、任意 Cordis 代码、marketplace 和兼容 shim 均有意排除在该格式之外。 - **没有 MCP 认证协议**:静态 header 可以使用环境变量展开,但带 OAuth 的定义会被拒绝,私有 server 登录流程不在此实现。 diff --git a/packages/cordis/repository-plugin/package.json b/packages/cordis/repository-plugin/package.json index 07bfa924c2..87e88122f6 100644 --- a/packages/cordis/repository-plugin/package.json +++ b/packages/cordis/repository-plugin/package.json @@ -25,9 +25,7 @@ "lib/index.js", "lib/invariant.js", "lib/bin.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/cordis/tool-cordis/package.json b/packages/cordis/tool-cordis/package.json index 7e7b75cab3..0b1c78cf8f 100644 --- a/packages/cordis/tool-cordis/package.json +++ b/packages/cordis/tool-cordis/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 6efc54849c..73dd125905 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -184,7 +184,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'collect(execution: ToolExecution): DshEnvironment', - jsDoc: '/**\n * Build the trusted `DSH_*` snapshot for one bash tool execution.\n * @param execution - the current tool execution.\n * @returns an immutable environment overlay containing built-ins and current contributions.\n */', + jsDoc: '/**\n * Build the trusted `DSH_*` snapshot for one shell tool execution.\n * @param execution - the current tool execution.\n * @returns an immutable environment overlay containing built-ins and current contributions.\n */', }, { signature: 'list(): BashEnvVariableInfo[]', diff --git a/packages/core/agent-loop/package.json b/packages/core/agent-loop/package.json index 8e9a2b93bb..68c30d9e55 100644 --- a/packages/core/agent-loop/package.json +++ b/packages/core/agent-loop/package.json @@ -20,9 +20,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/core/agent/package.json b/packages/core/agent/package.json index 9113030eef..5e14fb69d1 100644 --- a/packages/core/agent/package.json +++ b/packages/core/agent/package.json @@ -26,9 +26,7 @@ "lib/index.js", "lib/invariant.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/core/scope/package.json b/packages/core/scope/package.json index 4679e2755a..a752ee4c77 100644 --- a/packages/core/scope/package.json +++ b/packages/core/scope/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/core/session/README.i18n.yaml b/packages/core/session/README.i18n.yaml index 885a1274f4..00c1f688ff 100644 --- a/packages/core/session/README.i18n.yaml +++ b/packages/core/session/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/session/README.md README.md: d78dc5bcfe1df2edd01280208f3859eb1b2d6763 -README.zh.md: 0288c6a2e87c7066821bc2d731480c2fd42a2818 +README.zh.md: 9ed9cab5c1c0a3eae6ac8b7bea6bb82b8c668e22 diff --git a/packages/core/session/README.zh.md b/packages/core/session/README.zh.md index 0288c6a2e8..9ed9cab5c1 100644 --- a/packages/core/session/README.zh.md +++ b/packages/core/session/README.zh.md @@ -141,7 +141,7 @@ 记录日志不会导致失效,精确重建会保持请求前缀一致。后续请求头若更改前缀、提示词或 schema,可能从第一处差异开始使复用失效。 -## 已知限制与暂缓工作 +## 已知限制与暂缓事项 - **会话分支/树**(pi 风格条目树):除非需要超越基于边界的 `fork()` 能力,否则暂缓。 - **`fork()` 仅在实时会话的稳定边界处切分**:所选前缀结束时不得有开放轮次,且源会话必须位于存储中;[fork API](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md) 不支持对已持久化但未加载的会话进行 fork。 diff --git a/packages/core/session/package.json b/packages/core/session/package.json index 05075a9bd4..83be69528e 100644 --- a/packages/core/session/package.json +++ b/packages/core/session/package.json @@ -30,9 +30,7 @@ "lib/index.js", "lib/invariant.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/core/system-prompt/package.json b/packages/core/system-prompt/package.json index 6cf94f477e..d601365df7 100644 --- a/packages/core/system-prompt/package.json +++ b/packages/core/system-prompt/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/core/tools/package.json b/packages/core/tools/package.json index 0e719ab255..3279f17729 100644 --- a/packages/core/tools/package.json +++ b/packages/core/tools/package.json @@ -26,9 +26,7 @@ "lib/index.js", "lib/invariant.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/core/tools/tests/gen-tool-catalog.spec.ts b/packages/core/tools/tests/gen-tool-catalog.spec.ts index 2c93403ce3..dae09f0591 100644 --- a/packages/core/tools/tests/gen-tool-catalog.spec.ts +++ b/packages/core/tools/tests/gen-tool-catalog.spec.ts @@ -23,7 +23,7 @@ describe('gen-tool-catalog collectToolCatalog', () => { it('boots every shipped tool package and harvests its model-facing schemas', async () => { const catalog = await collectToolCatalog() const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort() - expect(names).toEqual(['ask_user_question', 'bash', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'list_agents', 'lsp', 'ralph', 'read', 'report', 'run_code', 'send_message', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'str_replace_editor', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write']) + expect(names).toEqual(['ask_user_question', 'bash', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'list_agents', 'lsp', 'pwsh', 'ralph', 'read', 'report', 'run_code', 'send_message', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'str_replace_editor', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write']) // Every tool carries a JSON-Schema `parameters` object (what the model sees). for (const entry of catalog) { for (const schema of entry.schemas) { diff --git a/packages/credentials/credentials-local/package.json b/packages/credentials/credentials-local/package.json index 0b8924d7f2..6cfa6fad5f 100644 --- a/packages/credentials/credentials-local/package.json +++ b/packages/credentials/credentials-local/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/credentials/credentials/package.json b/packages/credentials/credentials/package.json index d907b0a1bb..42dd183fc4 100644 --- a/packages/credentials/credentials/package.json +++ b/packages/credentials/credentials/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/examples/acp-demo/package.json b/packages/examples/acp-demo/package.json index 8edf3a43d3..ce4b65466a 100644 --- a/packages/examples/acp-demo/package.json +++ b/packages/examples/acp-demo/package.json @@ -29,9 +29,7 @@ "lib/index.js", "lib/invariant.js", "lib/bin.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/examples/agent-spine-demo/README.i18n.yaml b/packages/examples/agent-spine-demo/README.i18n.yaml index 24e5d0e98a..8cbf8ce122 100644 --- a/packages/examples/agent-spine-demo/README.i18n.yaml +++ b/packages/examples/agent-spine-demo/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/examples/agent-spine-demo/README.md -README.md: 34d68b0791746c28528124853a4d8ea82b68138d -README.zh.md: 80068bed406c15f10125dd28590e1d92f4247b51 +README.md: 7ea2f4afbe5d0bea62cf0fc2c7ecdd1496d20aef +README.zh.md: cb4202ac0e72db7c85967425671144e743f31b62 diff --git a/packages/examples/agent-spine-demo/README.md b/packages/examples/agent-spine-demo/README.md index 34d68b0791..7ea2f4afbe 100644 --- a/packages/examples/agent-spine-demo/README.md +++ b/packages/examples/agent-spine-demo/README.md @@ -59,7 +59,7 @@ import type { Config } from '@deepseek-ai/dsh-agent-spine-demo' // workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults. ``` -The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — TUI and headless apps pre-create `main`, while the ACP app creates agents on demand at `session/new`; `includeHarnessIdentity`, `persona`, and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `sessionTitle` to the fallback title service; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); `invariants` to the invariant service; and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. It always mounts `dsh-llm-retry`, while each leaf adapter owns its nested `retryPolicy`. Omitted `sessionTitle` uses the explicit example policy of 5 words, 40 fallback bytes, and 80 accepted-title bytes. A `goals` object opts into the persisted domain, model tools, and same-session driver while forwarding `goals.domain` and `goals.tool` to their owners; omission or `false` leaves the stack absent so headless callers retain one-turn settlement. Set `skills.enabled: false` to omit both the local provider and model-facing skill tool, set `toolBash: false` when another plugin owns the `bash` tool name, and set `toolTasks: false` to retain the task service for foreground producers without exposing `task_output` / `task_list` / `task_kill`. It resolves `dshHome` once through [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) and forwards that absolute value to tool-bash's managed environment and enabled local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bundled bash producer; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields. +The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — TUI and headless apps pre-create `main`, while the ACP app creates agents on demand at `session/new`; `includeHarnessIdentity`, `persona`, and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `sessionTitle` to the fallback title service; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); `invariants` to the invariant service; and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. It always mounts `dsh-llm-retry`, while each leaf adapter owns its nested `retryPolicy`. Omitted `sessionTitle` uses the explicit example policy of 5 words, 40 fallback bytes, and 80 accepted-title bytes. A `goals` object opts into the persisted domain, model tools, and same-session driver while forwarding `goals.domain` and `goals.tool` to their owners; omission or `false` leaves the stack absent so headless callers retain one-turn settlement. Set `skills.enabled: false` to omit both the local provider and model-facing skill tool, set `toolBash: false` when another plugin owns the `bash` tool name, and set `toolTasks: false` to retain the task service for foreground producers without exposing `task_output` / `task_list` / `task_kill`. It resolves `dshHome` once through [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) and forwards that absolute value to the shared `bash-env` managed environment and enabled local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bundled bash producer; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields. For example, `{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/dsh-'], package_blocklist: ['agent-loop$'] } }` keeps the package-owned companions mounted but suppresses the blocked owner. Blocklist matches override allowlist matches; see [`dsh-invariants`](../../support/invariants/README.md) for regex and lifecycle rules. diff --git a/packages/examples/agent-spine-demo/README.zh.md b/packages/examples/agent-spine-demo/README.zh.md index 80068bed40..cb4202ac0e 100644 --- a/packages/examples/agent-spine-demo/README.zh.md +++ b/packages/examples/agent-spine-demo/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -将 **默认的不含执行器、不含 UI 的 agent(智能体)主干** 作为一个 Cordis 组合包插件。它加载每个 harness agent 所需的固定服务集合,包括本地 skill(技能)提供方,并将循环的 `agents` 列表作为自身配置转发。因此,应用包只需添加前端入口和可替换后端,就能组合出可工作的 agent。 +将 **默认的不含执行器、不含 UI 的 agent(智能体)主干** 作为一个 Cordis 组合包插件。它加载每个 harness agent 所需的固定服务集合,包括本地 skill(技能)提供方,并将循环的 `agents` 列表作为自身配置转发。因此,应用包(package)只需添加前端入口和可替换后端,就能组合出可工作的 agent。 阅读此包可了解完整插件树及其组合顺序。 @@ -59,7 +59,7 @@ import type { Config } from '@deepseek-ai/dsh-agent-spine-demo' // workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults. ``` -组合包将每个字段转发给拥有它的子节点:`agents` 与 `maxParallelToolCalls` 交给 `agent-loop`(`agents` 默认为 `[]`,上限在该处默认),因此每个应用提供自己的预创建 agent;TUI 和无头应用预创建 `main`,ACP 应用则在 `session/new` 按需创建 agent;`includeHarnessIdentity`、`persona` 与 `toolOrder` 交给 `dsh-system-prompt`;`tools` 交给工具注册表以配置呈现模式;`sessionTitle` 交给后备标题服务;`skills.registry`、`skills.local` 与 `skills.tool` 分别交给 skill 注册表、本地提供方和面向模型的消费方;必填的 `workspaceContext` 选择交给 `dsh-workspace-context`(`{ maxBytes }` 启用加载,`false` 禁用);`invariants` 交给不变式服务;`toolBash`/`toolTasks` 交给组合包拥有的两个面向模型工具插件。组合包始终挂载 `dsh-llm-retry`,而每个叶节点适配器拥有自己的嵌套 `retryPolicy`。省略 `sessionTitle` 时采用显式示例策略:5 个词、40 个后备字节、80 个可接受标题字节。`goals` 对象会选用持久化领域、模型工具和同会话 Goal Round 驱动器,并将 `goals.domain` 与 `goals.tool` 转发给各自拥有者;省略或设为 `false` 会让整个栈缺席,使无头调用方继续以单轮次结算。设置 `skills.enabled: false` 会同时省略本地提供方和面向模型的 skill 工具;当另一个插件拥有 `bash` 工具名时设置 `toolBash: false`;设置 `toolTasks: false` 会保留供前台生产方使用的任务服务,但不公开 `task_output`/`task_list`/`task_kill`。它对 `dshHome` 只解析一次,解析通过 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 完成,并将所得绝对值转发给 tool-bash 的托管环境和已启用的本地 skill 发现。顶层 `dshHome` 缺席时采用 `skills.local.dshHome`;两者同时提供但解析后的路径不同会明确失败。`toolBash.enableRunInBackground` 只控制内置 bash 生产方;独立加载的生产方保留各自配置。工作区指令先于 skill 目录注册,因此其会话前缀消息先渲染。应用包使用 `pickSpineConfig()`,只复制这些由组合包拥有的字段。 +组合包将每个字段转发给拥有它的子节点:`agents` 与 `maxParallelToolCalls` 交给 `agent-loop`(`agents` 默认为 `[]`,上限在该处默认),因此每个应用提供自己的预创建 agent;TUI 和无头应用预创建 `main`,ACP 应用则在 `session/new` 按需创建 agent;`includeHarnessIdentity`、`persona` 与 `toolOrder` 交给 `dsh-system-prompt`;`tools` 交给工具注册表以配置呈现模式;`sessionTitle` 交给后备标题服务;`skills.registry`、`skills.local` 与 `skills.tool` 分别交给 skill 注册表、本地提供方和面向模型的消费方;必填的 `workspaceContext` 选择交给 `dsh-workspace-context`(`{ maxBytes }` 启用加载,`false` 禁用);`invariants` 交给不变式服务;`toolBash`/`toolTasks` 交给组合包拥有的两个面向模型工具插件。组合包始终挂载 `dsh-llm-retry`,而每个叶节点适配器拥有自己的嵌套 `retryPolicy`。省略 `sessionTitle` 时采用显式示例策略:5 个词、40 个后备字节、80 个可接受标题字节。`goals` 对象会选用持久化领域、模型工具和同会话 Goal Round 驱动器,并将 `goals.domain` 与 `goals.tool` 转发给各自拥有者;省略或设为 `false` 会让整个栈缺席,使无头调用方继续以单轮次结算。设置 `skills.enabled: false` 会同时省略本地提供方和面向模型的 skill 工具;当另一个插件拥有 `bash` 工具名时设置 `toolBash: false`;设置 `toolTasks: false` 会保留供前台生产方使用的任务服务,但不公开 `task_output`/`task_list`/`task_kill`。它对 `dshHome` 只解析一次,解析通过 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 完成,并将所得绝对值转发给共享 `bash-env` 的托管环境和已启用的本地 skill 发现。顶层 `dshHome` 缺席时采用 `skills.local.dshHome`;两者同时提供但解析后的路径不同会明确失败。`toolBash.enableRunInBackground` 只控制内置 bash 生产方;独立加载的生产方保留各自配置。工作区指令先于 skill 目录注册,因此其会话前缀消息先渲染。应用包使用 `pickSpineConfig()`,只复制这些由组合包拥有的字段。 例如,`{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/dsh-'], package_blocklist: ['agent-loop$'] } }` 会让包拥有的配套插件保持挂载,但抑制被阻止的拥有者。Blocklist 匹配优先于 allowlist 匹配;正则表达式与生命周期规则见 [`dsh-invariants`](../../support/invariants/README.md)。 diff --git a/packages/examples/agent-spine-demo/package.json b/packages/examples/agent-spine-demo/package.json index 82178c48de..a4b18b9c0c 100644 --- a/packages/examples/agent-spine-demo/package.json +++ b/packages/examples/agent-spine-demo/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { @@ -43,6 +41,7 @@ "@deepseek-ai/dsh-skill-local": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-tasks-local": "^0.0.1", + "@deepseek-ai/dsh-bash-env": "^0.0.1", "@deepseek-ai/dsh-tool-bash": "^0.0.1", "@deepseek-ai/dsh-tool-goal": "^0.0.1", "@deepseek-ai/dsh-tool-skill": "^0.0.1", @@ -55,6 +54,7 @@ "@cordisjs/plugin-timer": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-bash-env": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-bash-sandbox": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", diff --git a/packages/examples/agent-spine-demo/src/index.ts b/packages/examples/agent-spine-demo/src/index.ts index 32e348bf75..30c51fc941 100644 --- a/packages/examples/agent-spine-demo/src/index.ts +++ b/packages/examples/agent-spine-demo/src/index.ts @@ -29,6 +29,7 @@ import * as agentInvariant from '@deepseek-ai/dsh-agent/invariant' import * as scopeInvariant from '@deepseek-ai/dsh-scope/invariant' import * as agentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant' import * as toolBash from '@deepseek-ai/dsh-tool-bash' +import * as bashEnv from '@deepseek-ai/dsh-bash-env' import * as workspaceContext from '@deepseek-ai/dsh-workspace-context' import * as toolSkill from '@deepseek-ai/dsh-tool-skill' import * as toolTasks from '@deepseek-ai/dsh-tool-tasks' @@ -234,7 +235,8 @@ export function apply(ctx: Context, config: Config): void { ctx.plugin(scopeInvariant) ctx.plugin(agentLoopInvariant) if (config.toolBash !== false) { - ctx.plugin(toolBash, Object.assign({}, config.toolBash, { dshHome })) + ctx.plugin(bashEnv, { dshHome }) + ctx.plugin(toolBash, config.toolBash ?? {}) } if (config.workspaceContext !== false) { ctx.plugin(workspaceContext, config.workspaceContext) diff --git a/packages/examples/agent-spine-demo/tsconfig.json b/packages/examples/agent-spine-demo/tsconfig.json index 670cd9a629..6a0091a6f6 100644 --- a/packages/examples/agent-spine-demo/tsconfig.json +++ b/packages/examples/agent-spine-demo/tsconfig.json @@ -68,6 +68,9 @@ { "path": "../../util/paths" }, + { + "path": "../../bash/bash-env" + }, { "path": "../../bash/tool-bash" }, diff --git a/packages/examples/cli-demo/package.json b/packages/examples/cli-demo/package.json index f977bc5986..afd129aac9 100644 --- a/packages/examples/cli-demo/package.json +++ b/packages/examples/cli-demo/package.json @@ -29,9 +29,7 @@ "lib/index.js", "lib/invariant.js", "lib/bin.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/examples/jsonrpc-demo/package.json b/packages/examples/jsonrpc-demo/package.json index d103e3bd00..155660d190 100644 --- a/packages/examples/jsonrpc-demo/package.json +++ b/packages/examples/jsonrpc-demo/package.json @@ -29,9 +29,7 @@ "lib/index.js", "lib/invariant.js", "lib/bin.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { diff --git a/packages/fs/fs-local/package.json b/packages/fs/fs-local/package.json index 098de71e6f..50135f0f50 100644 --- a/packages/fs/fs-local/package.json +++ b/packages/fs/fs-local/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/fs/fs-policy/package.json b/packages/fs/fs-policy/package.json index e74852ef7d..634fb114f5 100644 --- a/packages/fs/fs-policy/package.json +++ b/packages/fs/fs-policy/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/fs/fs-sandbox/package.json b/packages/fs/fs-sandbox/package.json index 2fa4ee3f5d..08d63895d0 100644 --- a/packages/fs/fs-sandbox/package.json +++ b/packages/fs/fs-sandbox/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/fs/fs/package.json b/packages/fs/fs/package.json index 41de454488..14989c2f77 100644 --- a/packages/fs/fs/package.json +++ b/packages/fs/fs/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/fs/tool-fs-search/package.json b/packages/fs/tool-fs-search/package.json index 8953aea77a..9e5b8c0374 100644 --- a/packages/fs/tool-fs-search/package.json +++ b/packages/fs/tool-fs-search/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { diff --git a/packages/fs/tool-fs/package.json b/packages/fs/tool-fs/package.json index 737f7ac26b..8af8bc77ba 100644 --- a/packages/fs/tool-fs/package.json +++ b/packages/fs/tool-fs/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { diff --git a/packages/fs/tool-str-replace-editor/package.json b/packages/fs/tool-str-replace-editor/package.json index 0fc6e54ec6..b9404e20d6 100644 --- a/packages/fs/tool-str-replace-editor/package.json +++ b/packages/fs/tool-str-replace-editor/package.json @@ -20,9 +20,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/goal/command-goal/package.json b/packages/goal/command-goal/package.json index b1007d79f3..7313daa4af 100644 --- a/packages/goal/command-goal/package.json +++ b/packages/goal/command-goal/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/goal/goal-session/package.json b/packages/goal/goal-session/package.json index 190cb795ad..86b58f3cda 100644 --- a/packages/goal/goal-session/package.json +++ b/packages/goal/goal-session/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/goal/goal/package.json b/packages/goal/goal/package.json index eaef4e6ad2..397e5717ba 100644 --- a/packages/goal/goal/package.json +++ b/packages/goal/goal/package.json @@ -30,9 +30,7 @@ "lib/index.js", "lib/invariant.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/goal/tool-goal/package.json b/packages/goal/tool-goal/package.json index 3fcc0c61a9..c7c87e44db 100644 --- a/packages/goal/tool-goal/package.json +++ b/packages/goal/tool-goal/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/guard/repeat-tool-guard/package.json b/packages/guard/repeat-tool-guard/package.json index c892ca99e4..9978865a5d 100644 --- a/packages/guard/repeat-tool-guard/package.json +++ b/packages/guard/repeat-tool-guard/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { diff --git a/packages/hooks/hook-protocol/package.json b/packages/hooks/hook-protocol/package.json index f357278db3..0e35d83513 100644 --- a/packages/hooks/hook-protocol/package.json +++ b/packages/hooks/hook-protocol/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/hooks/hooks-claude/package.json b/packages/hooks/hooks-claude/package.json index 4d52d1f0c5..cce1d3edb9 100644 --- a/packages/hooks/hooks-claude/package.json +++ b/packages/hooks/hooks-claude/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { diff --git a/packages/hooks/hooks-codex/package.json b/packages/hooks/hooks-codex/package.json index fb598b4da3..078b58fb08 100644 --- a/packages/hooks/hooks-codex/package.json +++ b/packages/hooks/hooks-codex/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index 03f31c6a49..426860eebc 100644 --- a/packages/host/apiproxy/package.json +++ b/packages/host/apiproxy/package.json @@ -34,9 +34,7 @@ "lib/index.js", "lib/invariant.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { diff --git a/packages/host/directory-picker-auto/README.i18n.yaml b/packages/host/directory-picker-auto/README.i18n.yaml index 2b46a7571d..9db380b121 100644 --- a/packages/host/directory-picker-auto/README.i18n.yaml +++ b/packages/host/directory-picker-auto/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/directory-picker-auto/README.md README.md: 10d1784590b79fdfef3cf6683d389182cd8437b6 -README.zh.md: cd07aa8f2c2225e9b45ba30f2b3435067248cc4a +README.zh.md: 468a698629061d105899013694ebb27d34bbda2c diff --git a/packages/host/directory-picker-auto/README.zh.md b/packages/host/directory-picker-auto/README.zh.md index cd07aa8f2c..468a698629 100644 --- a/packages/host/directory-picker-auto/README.zh.md +++ b/packages/host/directory-picker-auto/README.zh.md @@ -14,7 +14,7 @@ 无;该包既不组装也不发送提供方请求。 -## 已知限制与延期工作 +## 已知限制与暂缓事项 - **探测是从启动上下文推断操作者位置,而任何启动侧信号都无法证明这一点**——从 SSH 启动中脱离的 tmux 会话会丢失 `SSH_*` 标记;Aqua 会话之外的 darwin 进程仍被算作有显示;而 `ssh -L` 形态(在工作站本地启动、之后经转发端口访问,从 `127.0.0.1` 到达)会判定 `native`,把选择器弹在无人值守的工作站上。错误的 `native` 选择会退化为后端既有的可重试失败对话框,而对这类部署,直接组合 `-browse` 即固定住安全的交互。 - **Linux 选择器探查只读 `PATH`**——以其他途径可用的 zenity/kdialog(shell 别名、未装在 PATH 上)仍判定为 `browse`;把任一二进制装到 `PATH` 上,下次启动即恢复 `native` 资格。 diff --git a/packages/host/directory-picker-auto/package.json b/packages/host/directory-picker-auto/package.json index 888637231e..753f83845a 100644 --- a/packages/host/directory-picker-auto/package.json +++ b/packages/host/directory-picker-auto/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/host/directory-picker-browse/package.json b/packages/host/directory-picker-browse/package.json index ab0cb593dc..58f984159f 100644 --- a/packages/host/directory-picker-browse/package.json +++ b/packages/host/directory-picker-browse/package.json @@ -26,9 +26,7 @@ "lib/index.js", "lib/invariant.js", "lib/client.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { diff --git a/packages/host/directory-picker-native/README.i18n.yaml b/packages/host/directory-picker-native/README.i18n.yaml index 3689a06c57..c1b47710a7 100644 --- a/packages/host/directory-picker-native/README.i18n.yaml +++ b/packages/host/directory-picker-native/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/directory-picker-native/README.md -README.md: 0b54c651d4f5382021d0f8832ab4f1146b7652c8 -README.zh.md: 94e4822f044f4f7ab5891a423233b1bbb55ae443 +README.md: 3d270af441bd251c126c8fb3c3d2d7aec95655c9 +README.zh.md: b4a3d91b68c285aad7911ba711348e36ffc7a4c8 diff --git a/packages/host/directory-picker-native/README.md b/packages/host/directory-picker-native/README.md index 0b54c651d4..3d270af441 100644 --- a/packages/host/directory-picker-native/README.md +++ b/packages/host/directory-picker-native/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The **native-OS-chooser backend** of the [directory-picker seam](../directory-picker/README.md): `NativeDirectoryPicker` registers `ctx.directoryPicker` with the `native` capability, whose `pick(signal)` opens one native chooser per call and resolves the chosen absolute path (`null` on cancel). Platform tools run without a shell: `osascript` on macOS, an STA PowerShell `FolderBrowserDialog` on Windows, and Zenity with a KDialog fallback on Linux; the caller's abort terminates the native process. Only viable when the operator sits at the host's display — remote deployments compose [`-browse`](../directory-picker-browse/README.md) instead. The command boundary (`DirectoryPickerRunner`) and platform facts are injectable for deterministic tests. The shared no-shell subprocess runner lives in [`dsh-native-command`](../../util/native-command/README.md). +The **native-OS-chooser backend** of the [directory-picker seam](../directory-picker/README.md): `NativeDirectoryPicker` registers `ctx.directoryPicker` with the `native` capability, whose `pick(signal)` opens one native chooser per call and resolves the chosen absolute path (`null` on cancel). Platform tools run without a shell: `osascript` on macOS and Zenity with a KDialog fallback on Linux; the caller's abort terminates the native process. Windows opens the modern `IFileOpenDialog` in a spawned child process — a koffi-driven COM conversation on the child's main thread with the best thread DPI awareness the host accepts (per-monitor-v2 first), aborted by posting `WM_CLOSE` to the dialog thread. Only viable when the operator sits at the host's display — remote deployments compose [`-browse`](../directory-picker-browse/README.md) instead. The command boundary (`DirectoryPickerRunner`) and platform facts are injectable for deterministic tests. The shared no-shell subprocess runner lives in [`dsh-native-command`](../../util/native-command/README.md). **Dual-face package**: the browser half (`./client`) registers a renderless flow occupant into [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes — each `open` request drives `host.pickDirectory` and reports the one outcome (picked path / cancel / failure) through the hole's owner conversation. One cordis.yml row therefore composes both sides of the native interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind). @@ -17,3 +17,4 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **Linux requires desktop tooling** — with neither Zenity nor KDialog installed, `pick` rejects with an actionable error; it does not fall back to a typed-path prompt (the browse backend is that fallback at the composition level). +- **Windows has no mechanism fallback** — the child-process picker is the only tier: koffi is a packaged dependency whose availability the install guarantees, so a failed pick (COM refusal, dialog crash) surfaces the failure instead of degrading to a PowerShell-hosted dialog (the former `pwsh` → Windows PowerShell 5.1 chain was removed). The browse backend remains the fallback at the composition level. diff --git a/packages/host/directory-picker-native/README.zh.md b/packages/host/directory-picker-native/README.zh.md index 94e4822f04..b4a3d91b68 100644 --- a/packages/host/directory-picker-native/README.zh.md +++ b/packages/host/directory-picker-native/README.zh.md @@ -2,18 +2,19 @@ [English](README.md) | 中文 -[目录选择 seam](../directory-picker/README.md) 的**原生 OS 选择器后端**:`NativeDirectoryPicker` 以 `native` 能力注册 `ctx.directoryPicker`,其 `pick(signal)` 每次调用打开一个原生选择器并解析出所选绝对路径(取消时为 `null`)。调用平台工具时不经过 shell:macOS 使用 `osascript`,Windows 使用以 STA 模式运行的 PowerShell `FolderBrowserDialog`,Linux 使用 Zenity 并以 KDialog 回退;调用方的中止信号会终止原生进程。该后端仅适用于操作者可直接使用宿主显示器的场景;远程部署应组合 [`-browse`](../directory-picker-browse/README.md)。命令边界(`DirectoryPickerRunner`)与平台事实可注入,便于确定性测试。共享的免 shell 子进程运行器位于 [`dsh-native-command`](../../util/native-command/README.md)。 +[目录选择 seam](../directory-picker/README.md) 的**原生 OS 选择器后端**:`NativeDirectoryPicker` 以 `native` 能力注册 `ctx.directoryPicker`,其 `pick(signal)` 每次调用打开一个原生选择器并解析出所选绝对路径(取消时为 `null`)。平台工具不经 shell 调用:macOS 使用 `osascript`,Linux 使用 Zenity 并以 KDialog 回退;调用方的中止信号会终止原生进程。Windows 在 spawn 的子进程中打开现代 `IFileOpenDialog`——由 koffi 在子进程主线程上驱动的 COM 会话,采用宿主接受的最佳线程 DPI 感知(优先 per-monitor-v2),中止时向对话框线程投递 `WM_CLOSE`。只有操作者坐在宿主屏幕前时才可用——远程部署应组合 [`-browse`](../directory-picker-browse/README.md)。命令边界(`DirectoryPickerRunner`)与平台事实可注入,便于确定性测试。共享的免 shell 子进程运行器位于 [`dsh-native-command`](../../util/native-command/README.md)。 -**双面包**:browser half(`./client`)向 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞注册一个无渲染的流程占用者——每次 `open` 请求都会调用 `host.pickDirectory`,并通过洞的 owner 会话上报一次结果(所选路径/取消/失败)。因此一行 cordis.yml 同时组合原生交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载时失败(洞为 `single` kind)。 +**双面包**:browser half(`./client`)向 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞注册一个无渲染的流程占用者——每次 `open` 请求驱动 `host.pickDirectory`,并经洞的 owner 会话上报唯一结果(所选路径/取消/失败)。因此一行 cordis.yml 同时组合原生交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。 ## 模型体验 无。该后端服务于 GUI 宿主的目录选择;这里没有任何内容进入模型请求。 -#### KV Cache 影响 +#### KV 缓存影响 无;该包既不组装也不发送提供方请求。 -## 已知限制与暂缓事项 +## 已知限制与延期工作 -- **Linux 依赖桌面工具**——Zenity 与 KDialog 均未安装时,`pick` 会拒绝并返回包含解决建议的错误;它不会回退为手动输入路径的提示(组合层面的回退是 browse 后端)。 +- **Linux 依赖桌面工具**——Zenity 与 KDialog 均未安装时,`pick` 以包含解决建议的错误拒绝;它不会回退为手输路径提示(组合层面的回退是 browse 后端)。 +- **Windows 没有机制级回退**——子进程选择器是唯一层级:koffi 是打包依赖,其可用性由安装保证,因此一次失败的 pick(COM 拒绝、对话框崩溃)直接上报失败,不会降级到 PowerShell 承载的对话框(原有的 `pwsh` → Windows PowerShell 5.1 链已删除)。组合层面的回退仍是 browse 后端。 diff --git a/packages/host/directory-picker-native/package.json b/packages/host/directory-picker-native/package.json index bafe18e09f..2c75ba0a5e 100644 --- a/packages/host/directory-picker-native/package.json +++ b/packages/host/directory-picker-native/package.json @@ -19,21 +19,25 @@ "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" }, + "./worker": { + "types": "./lib/types/win32-dialog-worker.d.ts", + "default": "./lib/worker.cjs" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", "lib/invariant.js", + "lib/worker.cjs", "lib/client.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { "@deepseek-ai/dsh-host-directory-picker": "workspace:^", - "@deepseek-ai/dsh-native-command": "workspace:^" + "@deepseek-ai/dsh-native-command": "workspace:^", + "koffi": "^3.1.0" }, "peerDependencies": { "@deepseek-ai/dsh-client-runtime": "^0.0.1", @@ -50,7 +54,8 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", "cordis": "^4.0.0-rc.7", - "react": "^18.2.0" + "react": "^18.2.0", + "tsx": "^4.19.2" }, "dshClient": { "inject": [ diff --git a/packages/host/directory-picker-native/src/index.ts b/packages/host/directory-picker-native/src/index.ts index f131c8bb0c..3a7e3bd05f 100644 --- a/packages/host/directory-picker-native/src/index.ts +++ b/packages/host/directory-picker-native/src/index.ts @@ -1,9 +1,10 @@ /** * Native backend of the directory-picker seam: registers `ctx.directoryPicker` * with the `native` capability, opening one native OS chooser on the host - * display per pick (macOS `osascript`, Windows STA PowerShell - * `FolderBrowserDialog`, Linux Zenity with a KDialog fallback). Only viable - * when the operator sits at the host's screen; remote deployments compose the + * display per pick (macOS `osascript`, Linux Zenity with a KDialog fallback; + * Windows opens the modern `IFileOpenDialog` in a spawned child process — a + * koffi-driven COM conversation on the child's main thread). Only viable when + * the operator sits at the host's screen; remote deployments compose the * browse backend instead. * @module @deepseek-ai/dsh-host-directory-picker-native */ diff --git a/packages/host/directory-picker-native/src/native-picker.ts b/packages/host/directory-picker-native/src/native-picker.ts index 2c8e236acc..e25b04ce6c 100644 --- a/packages/host/directory-picker-native/src/native-picker.ts +++ b/packages/host/directory-picker-native/src/native-picker.ts @@ -1,6 +1,7 @@ /** Cross-platform native single-directory chooser behind the native backend's capability. */ import { runNativeCommand, type NativeCommandRunner } from '@deepseek-ai/dsh-native-command' +import { pickWin32Directory } from './win32-dialog.ts' /** Testable command boundary; native implementations never invoke a shell. */ export type DirectoryPickerRunner = NativeCommandRunner @@ -9,6 +10,8 @@ export type DirectoryPickerRunner = NativeCommandRunner export interface DirectoryPickerInternals { platform?: NodeJS.Platform run?: DirectoryPickerRunner + /** Replaces the in-process Win32 dialog (`pickWin32Directory`) for deterministic tests. */ + pickWin32Dialog?: (signal: AbortSignal) => Promise } function outputPath(stdout: string): string | null { @@ -64,20 +67,13 @@ export async function pickNativeDirectory( } if (platform === 'win32') { - const script = [ - "$ErrorActionPreference = 'Stop'", - 'Add-Type -AssemblyName System.Windows.Forms', - '$dialog = New-Object System.Windows.Forms.FolderBrowserDialog', - "$dialog.Description = 'Select Workspace Directory'", - '$dialog.ShowNewFolderButton = $true', - '$result = $dialog.ShowDialog()', - 'if ($result -eq [System.Windows.Forms.DialogResult]::OK) {', - ' [Console]::OutputEncoding = [System.Text.Encoding]::UTF8', - ' [Console]::WriteLine($dialog.SelectedPath)', - '}', - ].join('; ') - const result = await run('powershell.exe', ['-NoProfile', '-STA', '-Command', script], signal) - return outputPath(result.stdout) + // The koffi-backed IFileOpenDialog child process — the modern picker with + // per-monitor-v2 DPI and abort support. koffi is a packaged dependency + // whose availability the install guarantees, so there is no fallback + // tier: any failure surfaces as-is (the former PowerShell chain was + // removed — see the simplification Agent Note). + const pickDialog = internals.pickWin32Dialog ?? pickWin32Directory + return await pickDialog(signal) } if (platform === 'linux') { diff --git a/packages/host/directory-picker-native/src/win32-dialog-bindings.ts b/packages/host/directory-picker-native/src/win32-dialog-bindings.ts new file mode 100644 index 0000000000..654bbc5a74 --- /dev/null +++ b/packages/host/directory-picker-native/src/win32-dialog-bindings.ts @@ -0,0 +1,195 @@ +/** + * koffi-backed Win32 bindings for the folder dialog: the COM vtable calls + * behind {@link Win32DialogBindings} plus the cross-thread window closer the + * driver uses to service aborts. The module loads on every platform; koffi + * itself is imported lazily inside each function, so non-Windows processes + * never load it — the same containment as the repo's other `win32.ts` + * modules. + * + * The COM surface used here (IModalWindow/IFileDialog/IFileOpenDialog and + * IShellItem vtable order, the GUIDs, `FOS_*` and `SIGDN_FILESYSPATH`) is + * frozen Windows ABI since Vista; slots are offsets into the vtable at the + * object's first pointer. + */ + +import type { Win32DialogBindings, Win32FolderDialog } from './win32-dialog-logic.ts' + +interface KoffiFunction { (...args: unknown[]): unknown } +interface KoffiLibrary { func(convention: string, name: string, result: string, args: string[]): KoffiFunction } +interface Koffi { + load(path: string): KoffiLibrary + proto(declaration: string): unknown + pointer(type: unknown): unknown + call(pointer: unknown, proto: unknown, ...args: unknown[]): unknown + decode(value: unknown, offsetOrType: unknown, type?: unknown): unknown + register(fn: (...args: unknown[]) => unknown, type: unknown): unknown + unregister(callback: unknown): void + sizeof(type: string): number + view(ref: unknown, len: number): ArrayBuffer +} + +/** + * Read a NUL-terminated UTF-16 string at a native address. koffi's + * `_Out_ void **` out-params surface a raw address, and + * `koffi.decode(addr, 'str16')` would dereference it as a pointer — crash + * on real Windows — so view the memory directly instead. + */ +function readUtf16(koffi: Koffi, address: unknown): string { + const bytes = Buffer.from(koffi.view(address, 32768)) + let end = 0 + while (end + 1 < bytes.length && bytes[end] !== 0) end += 2 + return bytes.toString('utf16le', 0, end) +} + +const COINIT_APARTMENTTHREADED = 0x2 +const CLSCTX_INPROC_SERVER = 0x1 +const SIGDN_FILESYSPATH = 0x80058000 | 0 +/** + * Thread DPI awareness contexts, best first: per-monitor-v2 (Windows 10 + * 1703+), per-monitor (1607+), then system-aware. `SetThreadDpiAwarenessContext` + * returns NULL for an unsupported context instead of throwing, so the caller + * cascades to the best one the host accepts; DPI stays a cosmetic + * best-effort — an unsupported host still gets the modern dialog. + */ +const DPI_AWARENESS_CONTEXTS = [-4, -3, -2] +const WM_CLOSE = 0x10 + +/** IFileOpenDialog vtable slots (IUnknown 0-2, IModalWindow 3, IFileDialog 4+). */ +const SLOT_RELEASE = 2 +const SLOT_SHOW = 3 +const SLOT_SET_OPTIONS = 9 +const SLOT_SET_TITLE = 17 +const SLOT_GET_RESULT = 20 +/** IShellItem vtable slot for `GetDisplayName`. */ +const SLOT_GET_DISPLAY_NAME = 5 + +/** + * Encode a canonical GUID string as its 16 little-endian bytes. + * @param text - the `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` form. + * @returns the in-memory GUID bytes CoCreateInstance expects. + */ +function guidBytes(text: string): Buffer { + const match = /^([0-9a-f]{8})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{12})$/i.exec(text) as RegExpExecArray + const bytes = Buffer.alloc(16) + bytes.writeUInt32LE(parseInt(match[1] as string, 16), 0) + bytes.writeUInt16LE(parseInt(match[2] as string, 16), 4) + bytes.writeUInt16LE(parseInt(match[3] as string, 16), 6) + Buffer.from((match[4] as string) + (match[5] as string), 'hex').copy(bytes, 8) + return bytes +} + +const CLSID_FILE_OPEN_DIALOG = guidBytes('dc1c5a9c-e88a-4dde-a5a1-60f82a20aef7') +const IID_IFILE_OPEN_DIALOG = guidBytes('d57c7288-d4ad-4768-be02-9d969532d960') + +/** + * Load koffi and expose the dialog bindings for this thread. + * @returns the bindings {@link runFolderDialog} sequences against. + */ +export async function loadWin32DialogBindings(): Promise { + const koffi = (await import('koffi')).default as unknown as Koffi + const ole32 = koffi.load('ole32.dll') + const user32 = koffi.load('user32.dll') + const kernel32 = koffi.load('kernel32.dll') + + // Vtable slots and out-pointers are pointer-width offsets: 8 on x64/arm64, + // 4 on ia32 — koffi reports the running process's width. + const pointerSize = koffi.sizeof('void *') + const coInitializeEx = ole32.func('__stdcall', 'CoInitializeEx', 'int32', ['void *', 'uint32']) + const coUninitialize = ole32.func('__stdcall', 'CoUninitialize', 'void', []) + const coCreateInstance = ole32.func('__stdcall', 'CoCreateInstance', 'int32', ['void *', 'void *', 'uint32', 'void *', 'void *']) + const coTaskMemFree = ole32.func('__stdcall', 'CoTaskMemFree', 'void', ['void *']) + const getCurrentThreadId = kernel32.func('__stdcall', 'GetCurrentThreadId', 'uint32', []) + + const protoShow = koffi.proto('int32 __stdcall DshDialogShow(void *self, void *owner)') + const protoSetOptions = koffi.proto('int32 __stdcall DshDialogSetOptions(void *self, uint32 options)') + const protoSetTitle = koffi.proto('int32 __stdcall DshDialogSetTitle(void *self, str16 title)') + const protoGetResult = koffi.proto('int32 __stdcall DshDialogGetResult(void *self, _Out_ void **item)') + const protoGetDisplayName = koffi.proto('int32 __stdcall DshItemGetDisplayName(void *self, int32 form, _Out_ void **name)') + const protoRelease = koffi.proto('uint32 __stdcall DshComRelease(void *self)') + + /** Bind vtable slot `slot` of COM object `self` to a caller through `proto`. */ + const method = (self: unknown, slot: number, proto: unknown): (...args: unknown[]) => number => { + const vtable = koffi.decode(self, 'void *') + const fn = koffi.decode(vtable, slot * pointerSize, 'void *') + return (...args: unknown[]) => koffi.call(fn, proto, self, ...args) as number + } + + return { + setThreadDpiAwareness: () => { + let setContext: KoffiFunction + try { + setContext = user32.func('__stdcall', 'SetThreadDpiAwarenessContext', 'void *', ['intptr']) + } catch { + // Symbol absent (pre-1607 Windows): no per-thread DPI control exists. + // Proceed anyway — the cost is a blurry dialog above 100 % scaling on + // museum hosts, and the modern picker still beats dropping to the + // legacy 5.1 tree over a cosmetic concern. + return + } + for (const context of DPI_AWARENESS_CONTEXTS) { + if (setContext(context) !== null) return + } + // Unreachable in practice (SYSTEM_AWARE is accepted wherever the symbol + // exists); if a host ever refuses everything, the dialog still works — + // just without a DPI opt-in. + }, + coInitializeSta: () => coInitializeEx(null, COINIT_APARTMENTTHREADED) as number, + coUninitialize: () => { + coUninitialize() + }, + currentThreadId: () => getCurrentThreadId() as number, + createFolderDialog: (): Win32FolderDialog => { + const out = Buffer.alloc(pointerSize) + const created = coCreateInstance(CLSID_FILE_OPEN_DIALOG, null, CLSCTX_INPROC_SERVER, IID_IFILE_OPEN_DIALOG, out) as number + if (created < 0) throw new Error(`CoCreateInstance(FileOpenDialog) failed: HRESULT 0x${(created >>> 0).toString(16)}`) + const dialog = koffi.decode(out, 'void *') + return { + setOptions: options => method(dialog, SLOT_SET_OPTIONS, protoSetOptions)(options), + setTitle: title => method(dialog, SLOT_SET_TITLE, protoSetTitle)(title), + show: () => method(dialog, SLOT_SHOW, protoShow)(null), + resultPath: () => { + const itemOut: unknown[] = [null] + const gotItem = method(dialog, SLOT_GET_RESULT, protoGetResult)(itemOut) + if (gotItem < 0) return { hr: gotItem } + const item = itemOut[0] + try { + const nameOut: unknown[] = [null] + const gotName = method(item, SLOT_GET_DISPLAY_NAME, protoGetDisplayName)(SIGDN_FILESYSPATH, nameOut) + if (gotName < 0) return { hr: gotName } + const path = readUtf16(koffi, nameOut[0]) + coTaskMemFree(nameOut[0]) + return { hr: gotName, path } + } finally { + method(item, SLOT_RELEASE, protoRelease)() + } + }, + release: () => { + method(dialog, SLOT_RELEASE, protoRelease)() + }, + } + }, + } +} + +/** + * Post `WM_CLOSE` to every window of a native thread — the driver's abort + * lever against the worker blocked inside `Show`, after which `Show` returns + * `HRESULT_CANCELLED` and the worker unwinds normally. + * @param threadId - the dialog thread's native id (from the `showing` notice). + */ +export async function closeThreadWindows(threadId: number): Promise { + const koffi = (await import('koffi')).default as unknown as Koffi + const user32 = koffi.load('user32.dll') + const enumThreadWindows = user32.func('__stdcall', 'EnumThreadWindows', 'int', ['uint32', 'void *', 'intptr']) + const postMessageW = user32.func('__stdcall', 'PostMessageW', 'int', ['void *', 'uint32', 'uintptr', 'intptr']) + const protoEnumProc = koffi.proto('int __stdcall DshEnumThreadWndProc(void *hwnd, intptr lparam)') + const callback = koffi.register((hwnd: unknown) => { + postMessageW(hwnd, WM_CLOSE, 0, 0) + return 1 + }, koffi.pointer(protoEnumProc)) + try { + enumThreadWindows(threadId, callback, 0) + } finally { + koffi.unregister(callback) + } +} diff --git a/packages/host/directory-picker-native/src/win32-dialog-host.ts b/packages/host/directory-picker-native/src/win32-dialog-host.ts new file mode 100644 index 0000000000..7a60ab05ed --- /dev/null +++ b/packages/host/directory-picker-native/src/win32-dialog-host.ts @@ -0,0 +1,33 @@ +/** + * Real-process half of the Win32 dialog driver: spawn the dialog child + * process (source or built plane) and close a dialog thread's windows. The + * module itself loads everywhere (the import chain from native-picker.ts is + * static); what stays win32-only is koffi, imported dynamically inside the + * bindings' functions. The driver's logic is tested against fakes of this + * surface instead. + */ + +import { spawn, type StdioOptions } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import type { Win32DialogWorkerData } from './win32-dialog-worker.ts' + +/** + * Spawn the dialog child process. Built consumers launch the bundled CJS + * entry next to this module under plain node; unbuilt (source) consumers + * bootstrap tsx first, mirroring the dsh CLI's source launch. The dialog is + * the child's first window, so Windows activates it without a foreground + * call. + * @param data - the child payload (dialog title). + * @returns the spawned child process. + */ +export function spawnDialogWorker(data: Win32DialogWorkerData): ReturnType { + const env = { ...process.env, DSH_DIALOG_TITLE: data.title } + const stdio: StdioOptions = ['ignore', 'inherit', 'inherit', 'ipc'] + /* v8 ignore next 3 -- the built-output arm: tests always run unbuilt (src/) */ + if (!import.meta.url.endsWith('.ts')) { + return spawn(process.execPath, [fileURLToPath(new URL('./worker.cjs', import.meta.url))], { env, stdio, windowsHide: true }) + } + return spawn(process.execPath, ['--import', import.meta.resolve('tsx/esm'), fileURLToPath(new URL('./win32-dialog-worker.ts', import.meta.url))], { env, stdio, windowsHide: true }) +} + +export { closeThreadWindows } from './win32-dialog-bindings.ts' diff --git a/packages/host/directory-picker-native/src/win32-dialog-logic.ts b/packages/host/directory-picker-native/src/win32-dialog-logic.ts new file mode 100644 index 0000000000..aa9d1445c4 --- /dev/null +++ b/packages/host/directory-picker-native/src/win32-dialog-logic.ts @@ -0,0 +1,132 @@ +/** + * Pure sequencing of the Win32 `IFileOpenDialog` folder-picker COM + * conversation over an injectable bindings seam, so every outcome path + * (selection, cancellation, HRESULT failure, cleanup ordering) is testable on + * any platform. The koffi-backed bindings live in + * `win32-dialog-bindings.ts`, which only a real win32 process ever loads. + */ + +/** `HRESULT_FROM_WIN32(ERROR_CANCELLED)`: the user dismissed the dialog. */ +export const HRESULT_CANCELLED = 0x800704c7 | 0 + +/** `FOS_PICKFOLDERS`: the dialog selects directories, not files. */ +export const FOS_PICKFOLDERS = 0x20 +/** `FOS_FORCEFILESYSTEM`: only results with a filesystem path can be chosen. */ +export const FOS_FORCEFILESYSTEM = 0x40 +/** `FOS_NOCHANGEDIR`: never mutate the process working directory. */ +export const FOS_NOCHANGEDIR = 0x8 + +/** One created folder dialog: the vtable calls the sequencing needs. */ +export interface Win32FolderDialog { + /** + * `IFileDialog::SetOptions`. + * @param options - the `FOS_*` flag union to apply. + * @returns the call's HRESULT. + */ + setOptions(options: number): number + /** + * `IFileDialog::SetTitle`. + * @param title - the dialog title text. + * @returns the call's HRESULT. + */ + setTitle(title: string): number + /** + * `IModalWindow::Show` with no owner window; blocks the calling thread + * until the user selects or dismisses. + * @returns the call's HRESULT (`HRESULT_CANCELLED` on dismissal). + */ + show(): number + /** + * `IFileDialog::GetResult` + `IShellItem::GetDisplayName(SIGDN_FILESYSPATH)`, + * releasing the shell item and freeing the COM string. + * @returns the call chain's HRESULT and, on success, the selected path. + */ + resultPath(): { hr: number; path?: string } + /** Release the dialog's COM reference. */ + release(): void +} + +/** The thread-level native surface the dialog sequencing runs against. */ +export interface Win32DialogBindings { + /** + * Opt the calling thread into the best supported DPI awareness + * (per-monitor-v2, then per-monitor, then system-aware), checking each + * call's result. Best-effort on purpose: a host accepting none of them + * (or lacking the API, pre-1607) still shows the modern dialog — possibly + * blurry above 100 % scaling — because a cosmetic degradation must not + * cost the tier. + */ + setThreadDpiAwareness(): void + /** + * `CoInitializeEx(COINIT_APARTMENTTHREADED)` on the calling thread. + * @returns the call's HRESULT (`S_FALSE` re-entry is still a success). + */ + coInitializeSta(): number + /** + * `CoUninitialize` on the calling thread — COM requires one pairing call + * for every successful (including `S_FALSE`) `CoInitializeEx`, even on a + * thread that exits right after the conversation. + */ + coUninitialize(): void + /** + * `CoCreateInstance(CLSID_FileOpenDialog)`. + * @returns the created dialog surface; throws when creation fails. + */ + createFolderDialog(): Win32FolderDialog + /** + * `GetCurrentThreadId` — the native id a driver needs to close this + * thread's windows from outside. + * @returns the calling thread's native id. + */ + currentThreadId(): number +} + +/** + * Throw when an HRESULT signals failure. + * @param hr - the HRESULT to check. + * @param what - the failing call's name for the error message. + * @returns the (successful) HRESULT unchanged. + */ +function check(hr: number, what: string): number { + if (hr < 0) throw new Error(`${what} failed: HRESULT 0x${(hr >>> 0).toString(16)}`) + return hr +} + +/** + * Run one modal folder-picker conversation on the calling thread: DPI opt-in, + * STA init, dialog creation, `Show`, and result extraction, releasing the + * dialog on every path. + * @param bindings - the native surface (koffi-backed in production, fakes in tests). + * @param title - the dialog title text. + * @param onShowing - called with the native thread id immediately before the + * blocking `Show`, so a driver on another thread can close the dialog. + * @returns the selected filesystem path, or null when the user cancels. + */ +export function runFolderDialog( + bindings: Win32DialogBindings, + title: string, + onShowing: (threadId: number) => void, +): string | null { + bindings.setThreadDpiAwareness() + check(bindings.coInitializeSta(), 'CoInitializeEx') + // From here the apartment is initialized (S_OK or S_FALSE) and must be + // uninitialized exactly once on every path. + try { + const dialog = bindings.createFolderDialog() + try { + check(dialog.setOptions(FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_NOCHANGEDIR), 'SetOptions') + check(dialog.setTitle(title), 'SetTitle') + onShowing(bindings.currentThreadId()) + const shown = dialog.show() + if (shown === HRESULT_CANCELLED) return null + check(shown, 'Show') + const result = dialog.resultPath() + check(result.hr, 'GetResult') + return result.path as string + } finally { + dialog.release() + } + } finally { + bindings.coUninitialize() + } +} diff --git a/packages/host/directory-picker-native/src/win32-dialog-worker.ts b/packages/host/directory-picker-native/src/win32-dialog-worker.ts new file mode 100644 index 0000000000..0b422b3ca7 --- /dev/null +++ b/packages/host/directory-picker-native/src/win32-dialog-worker.ts @@ -0,0 +1,52 @@ +/** + * Child-process entry for the Win32 folder dialog: blocks THIS process + * inside the modal `Show` so the host event loop stays live, reporting over + * the IPC channel. Spawned as a child process (not a worker thread) so the + * dialog is the process's first window and Windows activates it without a + * manual foreground call. Protocol: `{kind:'showing',threadId}` right + * before the blocking call (the driver's abort lever needs the native + * thread id), then exactly one of `{kind:'done',path}` or + * `{kind:'error',message}`. + */ + +import { loadWin32DialogBindings } from './win32-dialog-bindings.ts' +import { runFolderDialog } from './win32-dialog-logic.ts' + +/** The driver-to-child payload: the dialog title (passed via env). */ +export interface Win32DialogWorkerData { title: string } + +/** One notice or outcome posted back to the driver. */ +export type Win32DialogWorkerMessage = + | { kind: 'showing'; threadId: number } + | { kind: 'done'; path: string | null } + | { kind: 'error'; message: string } + +const title = process.env.DSH_DIALOG_TITLE ?? '' +if (title === '') throw new Error('win32-dialog-worker: DSH_DIALOG_TITLE is required') +if (process.send === undefined) throw new Error('win32-dialog-worker must run as a child process with an IPC channel') +// node's internal `send` reads `this.connected`, so bind the receiver. +const send = process.send.bind(process) + +const post = (message: Win32DialogWorkerMessage): void => { + // Flush before closing the channel; the process exits when the loop drains. + /* v8 ignore next 3 -- disconnect needs a live IPC channel the unit lane must not sever (built-worker.e2e.ts owns the real close path). */ + send(message, () => { if (process.connected) process.disconnect() }) +} + +// A settled driver (or a dead parent) must not orphan a dialog still on screen. +/* v8 ignore next 3 -- the handler exits(0), which would kill the unit lane; built-worker.e2e.ts owns the real disconnect lifecycle. */ +process.on('disconnect', () => process.exit(0)) + +// No top-level await: the built worker ships as CJS, which cannot carry TLA. +void (async () => { + try { + const bindings = await loadWin32DialogBindings() + const path = runFolderDialog(bindings, title, (threadId) => { + post({ kind: 'showing', threadId } satisfies Win32DialogWorkerMessage) + }) + post({ kind: 'done', path } satisfies Win32DialogWorkerMessage) + } catch (error: unknown) { + const message = error instanceof Error ? (error.stack ?? error.message) : String(error) + post({ kind: 'error', message } satisfies Win32DialogWorkerMessage) + } +})() diff --git a/packages/host/directory-picker-native/src/win32-dialog.ts b/packages/host/directory-picker-native/src/win32-dialog.ts new file mode 100644 index 0000000000..247d9a1733 --- /dev/null +++ b/packages/host/directory-picker-native/src/win32-dialog.ts @@ -0,0 +1,159 @@ +/** + * Main-thread driver for the Win32 folder dialog: spawns the dialog child + * process (which blocks inside the modal `Show`), maps its message protocol + * onto a promise, and services aborts by posting `WM_CLOSE` to the dialog + * thread's windows until the child reports back. The real process/window + * surface is injectable so every driver path is testable on any platform. + */ + +import { closeThreadWindows as hostCloseThreadWindows, spawnDialogWorker } from './win32-dialog-host.ts' +import type { Win32DialogWorkerData, Win32DialogWorkerMessage } from './win32-dialog-worker.ts' + +/** The child-process surface the driver drives (satisfied by `node:child_process`). */ +export interface Win32DialogWorkerLike { + /** + * Subscribe to a child-process event. + * @param event - `message`, `error`, or `exit`. + * @param listener - the event consumer. + */ + on(event: 'message', listener: (message: Win32DialogWorkerMessage) => void): unknown + on(event: 'error', listener: (error: Error) => void): unknown + on(event: 'exit', listener: (code: number) => void): unknown + /** + * Force-stop the child; the abort path's last resort when `WM_CLOSE` + * never lands (e.g. the dialog window was never created). + * @returns whether a kill signal was delivered. + */ + kill(): boolean + /** + * Release the event-loop reference. Called once the pick settles so a + * child stuck in the native modal call never blocks process exit. + */ + unref?(): void +} + +/** Injectable process surface for deterministic driver tests. */ +export interface Win32DialogInternals { + /** Replaces the real child spawn (`win32-dialog-host.ts`). */ + spawnWorker?: (data: Win32DialogWorkerData) => Win32DialogWorkerLike + /** Replaces the real `WM_CLOSE` poster (`win32-dialog-host.ts`). */ + closeThreadWindows?: (threadId: number) => Promise + /** Abort-service cadence override so tests never wait wall-clock time. */ + closeRetryMs?: number +} + +/** The dialog title every host shows. */ +export const DIALOG_TITLE = 'Select Workspace Directory' + +/** `WM_CLOSE` re-post cadence while an abort waits for the worker to unwind. */ +const CLOSE_RETRY_MS = 150 +/** Abort-service attempts before force-terminating the worker. */ +const CLOSE_MAX_ATTEMPTS = 20 + +/** Fail loudly if the closed worker-to-driver union gains an unhandled member. */ +/* v8 ignore start -- closed-union backstop; unreachable without a TypeScript contract violation */ +function assertNever(value: never): never { + throw new TypeError(`unknown win32 dialog worker message kind: ${String(value)}`) +} +/* v8 ignore stop */ + +/** + * Open the modern Win32 folder picker off the event loop. + * @param signal - caller lifetime; abort closes the dialog and rejects. + * @param internals - worker/window seams for deterministic tests. + * @returns the selected path, or null when the user cancels. + */ +export async function pickWin32Directory( + signal: AbortSignal, + internals: Win32DialogInternals = {}, +): Promise { + if (signal.aborted) throw new Error('native directory picker aborted') + const spawnWorker = internals.spawnWorker ?? spawnDialogWorker + const closeWindows = internals.closeThreadWindows ?? hostCloseThreadWindows + const closeRetryMs = internals.closeRetryMs ?? CLOSE_RETRY_MS + + const worker: Win32DialogWorkerLike = spawnWorker({ title: DIALOG_TITLE }) + let dialogThreadId: number | undefined + let closeTimer: NodeJS.Timeout | undefined + let settled = false + + return await new Promise((resolve, reject) => { + const settle = (outcome: () => void): void => { + if (settled) return + settled = true + if (closeTimer !== undefined) clearInterval(closeTimer) + signal.removeEventListener('abort', onAbort) + worker.unref?.() + outcome() + } + + const postClose = (): void => { + // Before `showing` there is no window to close; the budget below still + // runs so a child that never reports cannot dangle the pick. A + // rejected close attempt (EnumThreadWindows/PostMessageW refusing) is + // discarded: the interval retries it and kill is the backstop. + if (dialogThreadId !== undefined) void closeWindows(dialogThreadId).catch(() => undefined) + } + + // Sole caller: the once-registered abort listener, so no re-entry guard. + const serviceAbort = (): void => { + let attempts = 0 + // The `showing` notice precedes the blocking `Show`, so the very first + // WM_CLOSE can race the window's creation; re-post until the child + // reports back, then force-kill as a last resort. The budget is + // unconditional — an abort before `showing` (child hung in koffi or + // COM init) still ends in kill instead of a dangling promise. + closeTimer = setInterval(() => { + attempts += 1 + if (attempts > CLOSE_MAX_ATTEMPTS) { + settle(() => { + worker.kill() + reject(new Error('native directory picker aborted (dialog unresponsive; worker killed)')) + }) + return + } + postClose() + }, closeRetryMs) + postClose() + } + + const onAbort = (): void => { + serviceAbort() + } + signal.addEventListener('abort', onAbort, { once: true }) + + worker.on('message', (message: Win32DialogWorkerMessage) => { + switch (message.kind) { + case 'showing': + dialogThreadId = message.threadId + // An abort that raced ahead of this notice now has a window to hit. + if (signal.aborted) postClose() + return + case 'done': + settle(() => { + if (signal.aborted) reject(new Error('native directory picker aborted')) + else resolve(message.path) + }) + return + case 'error': + settle(() => { + reject(new Error(`win32 folder dialog failed: ${message.message}`)) + }) + return + /* v8 ignore next 2 -- closed worker-owned union; a fourth kind becomes a compile error */ + default: + assertNever(message) + } + }) + worker.on('error', (error: Error) => { + settle(() => { + reject(error) + }) + }) + worker.on('exit', () => { + settle(() => { + reject(new Error('win32 folder dialog worker exited before reporting a result')) + }) + }) + }) +} diff --git a/packages/host/directory-picker-native/tests/built-worker.e2e.ts b/packages/host/directory-picker-native/tests/built-worker.e2e.ts new file mode 100644 index 0000000000..2c3b793a7d --- /dev/null +++ b/packages/host/directory-picker-native/tests/built-worker.e2e.ts @@ -0,0 +1,34 @@ +/** + * Keyless built-artifact guard (the `dsh-workflow-workerthread` built-worker + * shape): plain `node` runs `lib/worker.cjs` and the bundle reaches its + * real koffi requires. POSIX hosts prove the load path end to end through + * the deterministic ole32 rejection; win32 skips (a real dialog would + * open), where the win32-only smoke in win32-dialog.spec.ts covers the + * source plane instead. Skips until a build produces the artifact. + */ + +import { spawn } from 'node:child_process' +import { existsSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import type { Win32DialogWorkerMessage } from '../src/win32-dialog-worker.ts' + +const builtWorker = fileURLToPath(new URL('../lib/worker.cjs', import.meta.url)) + +describe.skipIf(!existsSync(builtWorker) || process.platform === 'win32')('built dialog worker (lib/worker.cjs)', () => { + it('loads under plain node and reports the native-surface failure', async () => { + const message = await new Promise((resolve, reject) => { + const child = spawn(process.execPath, [builtWorker], { + env: { ...process.env, DSH_DIALOG_TITLE: 'Built-artifact guard' }, + stdio: ['ignore', 'inherit', 'inherit', 'ipc'], + }) + child.on('message', resolve) + child.on('error', reject) + child.on('exit', (code) => { + reject(new Error(`worker exited (${code}) before reporting`)) + }) + }) + expect(message.kind).toBe('error') + expect((message as { kind: 'error'; message: string }).message).toMatch(/ole32|koffi/i) + }, 30_000) +}) diff --git a/packages/host/directory-picker-native/tests/native-picker.spec.ts b/packages/host/directory-picker-native/tests/native-picker.spec.ts index 87e25ff877..66f4845b4b 100644 --- a/packages/host/directory-picker-native/tests/native-picker.spec.ts +++ b/packages/host/directory-picker-native/tests/native-picker.spec.ts @@ -1,3 +1,9 @@ +/** + * Native picker tier selection and the execFile adapter: the Win32 dialog + * primary (failures surface as-is, no fallback tier), the abort rule, and + * the POSIX command tiers (osascript, Zenity → KDialog). + */ + type ExecFileCallback = ( error: (Error & { code?: string | number }) | null, stdout: string, @@ -23,6 +29,9 @@ function failure(code: string | number, stderr = ''): Error { const signal = () => new AbortController().signal +/** A Win32 dialog that always fails — the no-fallback case. */ +const noDialog = async (): Promise => { throw new Error('dialog unavailable') } + describe('native directory picker', () => { it('uses the macOS folder chooser and maps user cancellation to null', async () => { const run = vi.fn(async () => ({ stdout: '/Users/test/project/\n', stderr: '' })) @@ -46,46 +55,79 @@ describe('native directory picker', () => { await expect(pickNativeDirectory(signal(), { platform: 'darwin', run })).rejects.toBe(reason) }) - it('uses the Windows STA folder dialog and maps empty output to cancellation', async () => { - const run = vi.fn(async () => ({ stdout: 'C:\\work\\project\r\n', stderr: '' })) - await expect(pickNativeDirectory(signal(), { platform: 'win32', run })).resolves.toBe('C:\\work\\project') - expect(run).toHaveBeenCalledWith( - 'powershell.exe', - expect.arrayContaining(['-NoProfile', '-STA', '-Command']), - expect.any(AbortSignal), - ) - expect(run.mock.calls[0]?.[1].at(-1)).toContain("$ErrorActionPreference = 'Stop'") - run.mockResolvedValueOnce({ stdout: '', stderr: '' }) - await expect(pickNativeDirectory(signal(), { platform: 'win32', run })).resolves.toBeNull() - run.mockRejectedValueOnce(failure(1, 'Add-Type failed')) - await expect(pickNativeDirectory(signal(), { platform: 'win32', run })).rejects.toThrow('command failed') + it('uses the Win32 dialog and never spawns a command when it answers', async () => { + const run = vi.fn() + const pickWin32Dialog = vi.fn(async (): Promise => 'C:\\work\\selected') + await expect(pickNativeDirectory(signal(), { platform: 'win32', run, pickWin32Dialog })).resolves.toBe('C:\\work\\selected') + pickWin32Dialog.mockResolvedValueOnce(null) + await expect(pickNativeDirectory(signal(), { platform: 'win32', run, pickWin32Dialog })).resolves.toBeNull() + expect(run).not.toHaveBeenCalled() + }) + + it('surfaces the Win32 dialog failure with no fallback', async () => { + const run = vi.fn() + await expect(pickNativeDirectory(signal(), { platform: 'win32', run, pickWin32Dialog: noDialog })) + .rejects.toThrow('dialog unavailable') + expect(run).not.toHaveBeenCalled() + }) + + it('wires the real Win32 dialog as the default tier', async () => { + // A pre-aborted signal makes the DEFAULT dialog deterministic on every + // host: pickWin32Directory throws before spawning any worker or window. + const abort = new AbortController() + abort.abort() + const run = vi.fn() + await expect(pickNativeDirectory(abort.signal, { platform: 'win32', run })) + .rejects.toThrow('native directory picker aborted') + expect(run).not.toHaveBeenCalled() + }) + + it('does not fall back when the caller aborted the dialog', async () => { + const abort = new AbortController() + abort.abort(new Error('closed')) + const run = vi.fn() + await expect(pickNativeDirectory(abort.signal, { platform: 'win32', run, pickWin32Dialog: noDialog })).rejects.toThrow('dialog unavailable') + expect(run).not.toHaveBeenCalled() }) it('runs the default command adapter without a shell and preserves command failures', async () => { execFileMock.mockImplementationOnce((_command, _args, _options, callback) => { - callback(null, 'C:\\work\\default\r\n', '') + callback(null, '/home/test/project\n', '') }) - await expect(pickNativeDirectory(signal(), { platform: 'win32' })).resolves.toBe('C:\\work\\default') + await expect(pickNativeDirectory(signal(), { platform: 'linux' })).resolves.toBe('/home/test/project') const [command, args, options] = execFileMock.mock.calls[0]! - expect(command).toBe('powershell.exe') - expect(args).toEqual(expect.arrayContaining(['-NoProfile', '-STA', '-Command'])) + expect(command).toBe('zenity') + expect(args).toEqual(expect.arrayContaining(['--file-selection', '--directory'])) expect(options.encoding).toBe('utf8') expect(options.windowsHide).toBe(true) expect(options.signal).toBeInstanceOf(AbortSignal) - const commandError = Object.assign(new Error('powershell failed'), { code: 7 }) + // A non-cancellation command failure surfaces as-is with its cause and + // captured stdio attached; no tier masks or rewraps it. execFileMock.mockImplementationOnce((_command, _args, _options, callback) => { - callback(commandError, 'partial output', 'failure details') + callback(Object.assign(new Error('zenity failed'), { code: 7 }), 'partial output', 'failure details') }) - await expect(pickNativeDirectory(signal(), { platform: 'win32' })).rejects.toMatchObject({ - message: 'powershell failed', cause: commandError, code: 7, + const surfaced = await pickNativeDirectory(signal(), { platform: 'linux' }) + .then(() => { throw new Error('expected rejection') }, (error: unknown) => error as Error) + expect(surfaced).toMatchObject({ + message: 'zenity failed', code: 7, stdout: 'partial output', stderr: 'failure details', }) + expect((surfaced as { cause?: unknown }).cause).toBeInstanceOf(Error) }) it('uses the current process platform when no platform override is supplied', async () => { + // Deterministic on every host: the win32 tier answers from the dialog, + // the POSIX tiers from the command runner. const run = vi.fn(async () => ({ stdout: '/default/platform\n', stderr: '' })) - await expect(pickNativeDirectory(signal(), { run })).resolves.toBe('/default/platform') + const pickWin32Dialog = async (): Promise => 'C:\\default\\platform' + const expected = process.platform === 'win32' ? 'C:\\default\\platform' : '/default/platform' + await expect(pickNativeDirectory(signal(), { run, pickWin32Dialog })).resolves.toBe(expected) + }) + + it('maps empty command output to cancellation', async () => { + const run = vi.fn(async () => ({ stdout: '', stderr: '' })) + await expect(pickNativeDirectory(signal(), { platform: 'linux', run })).resolves.toBeNull() }) it('uses Zenity on Linux and falls back to KDialog only when Zenity is missing', async () => { diff --git a/packages/host/directory-picker-native/tests/win32-dialog-bindings.spec.ts b/packages/host/directory-picker-native/tests/win32-dialog-bindings.spec.ts new file mode 100644 index 0000000000..b8ff4c3f1a --- /dev/null +++ b/packages/host/directory-picker-native/tests/win32-dialog-bindings.spec.ts @@ -0,0 +1,354 @@ +/** + * The koffi-backed bindings against a mocked `koffi` module (the same + * technique as dsh-session-persistence-jsonl's win32 suite): a small in-memory + * COM world stands in for ole32/user32/kernel32, keeping the vtable dispatch, + * result extraction, memory hygiene, and the WM_CLOSE poster covered on every + * host. The worker entry is exercised the same way with a mocked process + * boundary (env title + `process.send`). Real-COM behavior is pinned by the + * win32-only smoke in win32-dialog.spec.ts. + */ + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { HRESULT_CANCELLED, runFolderDialog } from '../src/win32-dialog-logic.ts' + +const E_FAIL = 0x80004005 | 0 +const WM_CLOSE = 0x10 +/** + * Deliberately NOT 8: the bindings must derive vtable offsets and out-buffer + * sizes from koffi.sizeof('void *'), and a hardcoded 8 anywhere fails against + * this width (the win32-ia32 bug class). + */ +const FAKE_POINTER_SIZE = 4 + +interface ComWorld { + coInitHr: number + coCreateHr: number + showHr: number + getResultHr: number + getDisplayNameHr: number + hasThreadDpi: boolean + /** Contexts `SetThreadDpiAwarenessContext` accepts; others return NULL. */ + supportedDpiContexts: number[] + enumThrows: boolean + path: string + titles: string[] + options: number[] + dpiContexts: unknown[] + freed: unknown[] + released: string[] + posted: { hwnd: unknown; message: number }[] + registered: number + unregistered: number + uninitialized: number +} + +function comWorld(overrides: Partial = {}): ComWorld { + return { + coInitHr: 0, coCreateHr: 0, showHr: 0, getResultHr: 0, getDisplayNameHr: 0, + hasThreadDpi: true, supportedDpiContexts: [-4], enumThrows: false, + path: 'C:\\选中\\directory', + titles: [], options: [], dpiContexts: [], freed: [], released: [], posted: [], + registered: 0, unregistered: 0, uninitialized: 0, + ...overrides, + } +} + +/** Sentinel pointer objects standing in for native addresses. */ +interface FakePtr { kind: string; [key: string]: unknown } + +function installFakeKoffi(world: ComWorld): void { + const dialogPtr: FakePtr = { kind: 'dialog' } + const itemPtr: FakePtr = { kind: 'item' } + const namePtr: FakePtr = { kind: 'name', text: world.path } + const outBuffers = new Map() + + const dispatch = (self: FakePtr, slot: number, args: unknown[]): number => { + if (self.kind === 'dialog') { + switch (slot) { + case 9: world.options.push(args[0] as number); return 0 + case 17: world.titles.push(args[0] as string); return 0 + case 3: return world.showHr + case 20: { + if (world.getResultHr < 0) return world.getResultHr + ;(args[0] as unknown[])[0] = itemPtr + return 0 + } + case 2: world.released.push('dialog'); return 0 + default: throw new Error(`unexpected dialog slot ${slot}`) + } + } + switch (slot) { + case 5: { + if (world.getDisplayNameHr < 0) return world.getDisplayNameHr + ;(args[1] as unknown[])[0] = namePtr + return 0 + } + case 2: world.released.push('item'); return 0 + default: throw new Error(`unexpected item slot ${slot}`) + } + } + + vi.doMock('koffi', () => ({ + default: { + load: (dll: string) => ({ + func: (_convention: string, name: string, _result: string, _args: string[]) => { + switch (name) { + case 'CoInitializeEx': return () => world.coInitHr + case 'CoUninitialize': return () => { world.uninitialized += 1 } + case 'CoCreateInstance': return (...args: unknown[]) => { + if (world.coCreateHr < 0) return world.coCreateHr + // The out-pointer must be allocated at the fake's pointer width. + if ((args[4] as Buffer).length !== FAKE_POINTER_SIZE) { + throw new Error(`CoCreateInstance out buffer must be ${FAKE_POINTER_SIZE} bytes`) + } + outBuffers.set(args[4], dialogPtr) + return 0 + } + case 'CoTaskMemFree': return (ptr: unknown) => { world.freed.push(ptr) } + case 'GetCurrentThreadId': return () => 31337 + case 'SetThreadDpiAwarenessContext': { + if (!world.hasThreadDpi) throw new Error(`${dll}: SetThreadDpiAwarenessContext not found`) + return (context: unknown) => { + world.dpiContexts.push(context) + return world.supportedDpiContexts.includes(context as number) ? { kind: 'previous-context' } : null + } + } + case 'EnumThreadWindows': return (_tid: unknown, callback: { fn: (hwnd: unknown, lparam: unknown) => number }, lparam: unknown) => { + if (world.enumThrows) throw new Error('EnumThreadWindows refused') + callback.fn({ kind: 'hwnd', n: 1 }, lparam) + callback.fn({ kind: 'hwnd', n: 2 }, lparam) + return 1 + } + case 'PostMessageW': return (hwnd: unknown, message: number) => { world.posted.push({ hwnd, message }); return 1 } + default: throw new Error(`unexpected native import ${dll}/${name}`) + } + }, + }), + proto: (declaration: string) => ({ declaration }), + pointer: (type: unknown) => type, + sizeof: (type: string) => { void type; return FAKE_POINTER_SIZE }, + view: (value: unknown, len: number): ArrayBuffer => { + const bytes = Buffer.alloc(len) + bytes.write((value as FakePtr).text as string, 'utf16le') + return bytes.buffer + }, + register: (fn: (hwnd: unknown, lparam: unknown) => number) => { world.registered += 1; return { fn } }, + unregister: () => { world.unregistered += 1 }, + decode: (value: unknown, offsetOrType: unknown): unknown => { + if (offsetOrType === 'str16') return (value as FakePtr).text + if (typeof offsetOrType === 'number') { + // Vtable slot read: offsets must be multiples of the fake width. + if (offsetOrType % FAKE_POINTER_SIZE !== 0) throw new Error(`vtable offset ${offsetOrType} is not pointer-aligned`) + const owner = (value as { owner: FakePtr }).owner + return { call: (args: unknown[]) => dispatch(owner, offsetOrType / FAKE_POINTER_SIZE, args) } + } + // decode(x, 'void *'): out-buffer read or vtable read. + if (outBuffers.has(value)) return outBuffers.get(value) + return { owner: value as FakePtr } + }, + call: (fn: { call: (args: unknown[]) => number }, _proto: unknown, _self: unknown, ...args: unknown[]) => fn.call(args), + }, + })) +} + +async function loadBindingsModule(): Promise { + return await import('../src/win32-dialog-bindings.ts') +} + +afterEach(() => { + vi.doUnmock('koffi') + vi.doUnmock('node:worker_threads') + vi.doUnmock('../src/win32-dialog-bindings.ts') + vi.resetModules() +}) + +describe('loadWin32DialogBindings over the fake COM world', () => { + it('drives the full selection conversation with memory hygiene', async () => { + const world = comWorld() + installFakeKoffi(world) + const { loadWin32DialogBindings } = await loadBindingsModule() + const bindings = await loadWin32DialogBindings() + const showing = vi.fn() + + expect(runFolderDialog(bindings, '选择工作区目录', showing)).toBe('C:\\选中\\directory') + expect(world.dpiContexts).toEqual([-4]) + expect(world.titles).toEqual(['选择工作区目录']) + expect(world.options).toHaveLength(1) + expect(showing).toHaveBeenCalledWith(31337) + expect(world.freed).toHaveLength(1) + expect(world.released).toEqual(['item', 'dialog']) + expect(world.uninitialized).toBe(1) + }) + + it('maps dismissal and the S_FALSE CoInitializeEx', async () => { + const world = comWorld({ showHr: HRESULT_CANCELLED, coInitHr: 1 }) + installFakeKoffi(world) + const { loadWin32DialogBindings } = await loadBindingsModule() + const bindings = await loadWin32DialogBindings() + expect(runFolderDialog(bindings, 'Pick', vi.fn())).toBeNull() + expect(world.released).toEqual(['dialog']) + expect(world.uninitialized).toBe(1) + }) + + it('cascades DPI contexts to the first the host accepts', async () => { + const world = comWorld({ supportedDpiContexts: [-3] }) + installFakeKoffi(world) + const bindings = await (await loadBindingsModule()).loadWin32DialogBindings() + expect(runFolderDialog(bindings, 'Pick', vi.fn())).toBe('C:\\选中\\directory') + expect(world.dpiContexts).toEqual([-4, -3]) + }) + + it('keeps the tier when no DPI context is accepted or the symbol is absent', async () => { + // DPI is a cosmetic best-effort: the modern dialog still opens. + const rejecting = comWorld({ supportedDpiContexts: [] }) + installFakeKoffi(rejecting) + let bindings = await (await loadBindingsModule()).loadWin32DialogBindings() + expect(runFolderDialog(bindings, 'Pick', vi.fn())).toBe('C:\\选中\\directory') + expect(rejecting.dpiContexts).toEqual([-4, -3, -2]) + + vi.doUnmock('koffi') + vi.resetModules() + const preThreadDpi = comWorld({ hasThreadDpi: false }) + installFakeKoffi(preThreadDpi) + bindings = await (await loadBindingsModule()).loadWin32DialogBindings() + expect(runFolderDialog(bindings, 'Pick', vi.fn())).toBe('C:\\选中\\directory') + expect(preThreadDpi.dpiContexts).toEqual([]) + }) + + it('surfaces creation and extraction failures as HRESULT errors', async () => { + const creationWorld = comWorld({ coCreateHr: E_FAIL }) + installFakeKoffi(creationWorld) + let bindings = await (await loadBindingsModule()).loadWin32DialogBindings() + expect(() => bindings.createFolderDialog()).toThrow('CoCreateInstance(FileOpenDialog) failed: HRESULT 0x80004005') + + vi.doUnmock('koffi') + vi.resetModules() + const resultWorld = comWorld({ getResultHr: E_FAIL }) + installFakeKoffi(resultWorld) + bindings = await (await loadBindingsModule()).loadWin32DialogBindings() + expect(() => runFolderDialog(bindings, 'Pick', vi.fn())).toThrow('GetResult failed') + expect(resultWorld.released).toEqual(['dialog']) + + vi.doUnmock('koffi') + vi.resetModules() + const nameWorld = comWorld({ getDisplayNameHr: E_FAIL }) + installFakeKoffi(nameWorld) + bindings = await (await loadBindingsModule()).loadWin32DialogBindings() + expect(() => runFolderDialog(bindings, 'Pick', vi.fn())).toThrow('GetResult failed') + // The shell item is released even when its display name cannot be read. + expect(nameWorld.released).toEqual(['item', 'dialog']) + expect(nameWorld.freed).toHaveLength(0) + }) +}) + +describe('closeThreadWindows over the fake COM world', () => { + it('posts WM_CLOSE to every window of the thread and unregisters the callback', async () => { + const world = comWorld() + installFakeKoffi(world) + const { closeThreadWindows } = await loadBindingsModule() + await closeThreadWindows(777) + expect(world.posted).toEqual([ + { hwnd: { kind: 'hwnd', n: 1 }, message: WM_CLOSE }, + { hwnd: { kind: 'hwnd', n: 2 }, message: WM_CLOSE }, + ]) + expect(world.registered).toBe(1) + expect(world.unregistered).toBe(1) + }) + + it('unregisters the callback even when the enumeration itself throws', async () => { + const world = comWorld({ enumThrows: true }) + installFakeKoffi(world) + const { closeThreadWindows } = await loadBindingsModule() + await expect(closeThreadWindows(777)).rejects.toThrow('EnumThreadWindows refused') + expect(world.unregistered).toBe(1) + }) +}) + +describe('the worker entry over a mocked process boundary', () => { + const originalSend = process.send?.bind(process) + const originalTitle = process.env.DSH_DIALOG_TITLE + + const installBoundary = (): { posted: { kind: string; message?: string }[] } => { + const posted: { kind: string; message?: string }[] = [] + process.env.DSH_DIALOG_TITLE = 'Pick' + // Never invoke the post callback: it runs the worker's disconnect(), and + // this process is IPC-connected under the forks pool — severing vitest's + // own channel would kill the test worker. The real close lifecycle + // belongs to built-worker.e2e.ts. + ;(process as { send?: unknown }).send = (message: { kind: string }) => { + posted.push(message) + return true + } + return { posted } + } + + afterEach(() => { + delete (process as { send?: unknown }).send + if (originalSend !== undefined) (process as { send?: unknown }).send = originalSend + if (originalTitle === undefined) delete process.env.DSH_DIALOG_TITLE + else process.env.DSH_DIALOG_TITLE = originalTitle + vi.doUnmock('../src/win32-dialog-bindings.ts') + vi.resetModules() + }) + + it('posts showing then done for a completed conversation', async () => { + const { posted } = installBoundary() + vi.doMock('../src/win32-dialog-bindings.ts', () => ({ + loadWin32DialogBindings: async () => ({ + setThreadDpiAwareness: () => undefined, + coInitializeSta: () => 0, + coUninitialize: () => undefined, + currentThreadId: () => 11, + createFolderDialog: () => ({ + setOptions: () => 0, + setTitle: () => 0, + show: () => 0, + resultPath: () => ({ hr: 0, path: 'C:\\from-worker' }), + release: () => undefined, + }), + }), + })) + await import('../src/win32-dialog-worker.ts') + expect(posted).toEqual([ + { kind: 'showing', threadId: 11 }, + { kind: 'done', path: 'C:\\from-worker' }, + ]) + }) + + it('posts the failure message when the native surface cannot load', async () => { + const { posted } = installBoundary() + vi.doMock('../src/win32-dialog-bindings.ts', () => ({ + loadWin32DialogBindings: async () => { throw new Error('no ole32 here') }, + })) + await import('../src/win32-dialog-worker.ts') + expect(posted).toHaveLength(1) + expect(posted[0]?.kind).toBe('error') + expect(posted[0]?.message).toContain('no ole32 here') + }) + + it('stringifies stackless and non-Error failures', async () => { + const stackless = new Error('bare message') + delete stackless.stack + for (const [thrown, expected] of [[stackless, 'bare message'], ['plain refusal', 'plain refusal']] as const) { + vi.resetModules() + const { posted } = installBoundary() + vi.doMock('../src/win32-dialog-bindings.ts', () => ({ + loadWin32DialogBindings: async () => { throw thrown }, + })) + await import('../src/win32-dialog-worker.ts') + expect(posted[0]?.message).toBe(expected) + } + }) + + it('refuses to run without the dialog title', async () => { + delete process.env.DSH_DIALOG_TITLE + ;(process as { send?: unknown }).send = () => true + await expect(import('../src/win32-dialog-worker.ts')).rejects.toThrow('DSH_DIALOG_TITLE is required') + }) + + it('refuses to run outside a child process', async () => { + process.env.DSH_DIALOG_TITLE = 'Pick' + delete (process as { send?: unknown }).send + await expect(import('../src/win32-dialog-worker.ts')).rejects.toThrow('must run as a child process') + }) +}) diff --git a/packages/host/directory-picker-native/tests/win32-dialog-logic.spec.ts b/packages/host/directory-picker-native/tests/win32-dialog-logic.spec.ts new file mode 100644 index 0000000000..718c93c2c0 --- /dev/null +++ b/packages/host/directory-picker-native/tests/win32-dialog-logic.spec.ts @@ -0,0 +1,98 @@ +/** + * The COM conversation's sequencing against fake bindings: outcome mapping + * (selection / cancellation / HRESULT failures at every step) and the + * release-on-every-path guarantee, all platform-independent. + */ + +import { describe, expect, it, vi } from 'vitest' +import { + FOS_FORCEFILESYSTEM, FOS_NOCHANGEDIR, FOS_PICKFOLDERS, HRESULT_CANCELLED, + runFolderDialog, type Win32DialogBindings, type Win32FolderDialog, +} from '../src/win32-dialog-logic.ts' + +const E_FAIL = 0x80004005 | 0 + +interface FakeWorld { + bindings: Win32DialogBindings + dpi: ReturnType + createDialog: ReturnType + uninitialize: ReturnType + dialog: { + setOptions: ReturnType + setTitle: ReturnType + show: ReturnType + resultPath: ReturnType + release: ReturnType + } +} + +function world(overrides: Partial = {}, coInit = 0): FakeWorld { + const dialog = { + setOptions: vi.fn(() => 0), + setTitle: vi.fn(() => 0), + show: vi.fn(() => 0), + resultPath: vi.fn(() => ({ hr: 0, path: 'C:\\picked\\目录' })), + release: vi.fn(), + ...overrides, + } + const dpi = vi.fn() + const createDialog = vi.fn(() => dialog) + const uninitialize = vi.fn() + const bindings: Win32DialogBindings = { + setThreadDpiAwareness: dpi, + coInitializeSta: vi.fn(() => coInit), + coUninitialize: uninitialize, + createFolderDialog: createDialog, + currentThreadId: vi.fn(() => 4242), + } + return { bindings, dpi, createDialog, uninitialize, dialog: dialog as FakeWorld['dialog'] } +} + +describe('runFolderDialog', () => { + it('sequences DPI, STA, options, title, show, result extraction, and apartment teardown', () => { + const { bindings, dpi, dialog, uninitialize } = world() + const showing = vi.fn() + expect(runFolderDialog(bindings, 'Pick', showing)).toBe('C:\\picked\\目录') + expect(dpi).toHaveBeenCalledOnce() + expect(uninitialize).toHaveBeenCalledOnce() + expect(dialog.release.mock.invocationCallOrder[0]).toBeLessThan(uninitialize.mock.invocationCallOrder[0] as number) + expect(dialog.setOptions).toHaveBeenCalledWith(FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_NOCHANGEDIR) + expect(dialog.setTitle).toHaveBeenCalledWith('Pick') + expect(showing).toHaveBeenCalledWith(4242) + expect(showing.mock.invocationCallOrder[0]).toBeLessThan(dialog.show.mock.invocationCallOrder[0] as number) + expect(dialog.release).toHaveBeenCalledOnce() + }) + + it('maps the cancelled HRESULT to null and still releases the dialog and apartment', () => { + const { bindings, dialog, uninitialize } = world({ show: vi.fn(() => HRESULT_CANCELLED) }) + expect(runFolderDialog(bindings, 'Pick', vi.fn())).toBeNull() + expect(dialog.resultPath).not.toHaveBeenCalled() + expect(dialog.release).toHaveBeenCalledOnce() + expect(uninitialize).toHaveBeenCalledOnce() + }) + + it('accepts the S_FALSE re-entry HRESULT from CoInitializeEx', () => { + const { bindings } = world({}, 1) + expect(runFolderDialog(bindings, 'Pick', vi.fn())).toBe('C:\\picked\\目录') + }) + + it('throws on a failing CoInitializeEx without creating a dialog or uninitializing', () => { + const { bindings, createDialog, uninitialize } = world({}, E_FAIL) + expect(() => runFolderDialog(bindings, 'Pick', vi.fn())).toThrow('CoInitializeEx failed: HRESULT 0x80004005') + expect(createDialog).not.toHaveBeenCalled() + // A failed CoInitializeEx must NOT be paired with CoUninitialize. + expect(uninitialize).not.toHaveBeenCalled() + }) + + it.each([ + ['SetOptions', { setOptions: vi.fn(() => E_FAIL) }], + ['SetTitle', { setTitle: vi.fn(() => E_FAIL) }], + ['Show', { show: vi.fn(() => E_FAIL) }], + ['GetResult', { resultPath: vi.fn(() => ({ hr: E_FAIL })) }], + ] satisfies [string, Partial][])('releases the dialog and apartment when %s fails', (what, overrides) => { + const { bindings, dialog, uninitialize } = world(overrides) + expect(() => runFolderDialog(bindings, 'Pick', vi.fn())).toThrow(`${what} failed: HRESULT 0x80004005`) + expect(dialog.release).toHaveBeenCalledOnce() + expect(uninitialize).toHaveBeenCalledOnce() + }) +}) diff --git a/packages/host/directory-picker-native/tests/win32-dialog.spec.ts b/packages/host/directory-picker-native/tests/win32-dialog.spec.ts new file mode 100644 index 0000000000..8e7d6951b8 --- /dev/null +++ b/packages/host/directory-picker-native/tests/win32-dialog.spec.ts @@ -0,0 +1,163 @@ +/** + * Driver tests: the child-process message protocol mapped onto the promise, + * the WM_CLOSE abort service (including the show-race retry and the kill + * last resort) against fakes, plus the real spawn plumbing — POSIX hosts + * prove the default path rejects cleanly (koffi cannot load ole32 there), + * and win32 hosts briefly open and auto-abort a real dialog. + */ + +import { EventEmitter } from 'node:events' +import { describe, expect, it, vi } from 'vitest' +import { pickWin32Directory, type Win32DialogInternals, type Win32DialogWorkerLike } from '../src/win32-dialog.ts' +import type { Win32DialogWorkerMessage } from '../src/win32-dialog-worker.ts' + +class FakeWorker extends EventEmitter implements Win32DialogWorkerLike { + kill = vi.fn(() => true) + post(message: Win32DialogWorkerMessage): void { + this.emit('message', message) + } +} + +interface Harness { + worker: FakeWorker + internals: Win32DialogInternals + close: ReturnType +} + +function harness(overrides: Partial = {}): Harness { + const worker = new FakeWorker() + const close = vi.fn(async () => undefined) + return { + worker, + close, + internals: { + spawnWorker: () => worker, + closeThreadWindows: close, + closeRetryMs: 1, + ...overrides, + }, + } +} + +const live = (): AbortSignal => new AbortController().signal + +describe('pickWin32Directory', () => { + it('resolves the selected path and the cancellation null', async () => { + const first = harness() + const picked = pickWin32Directory(live(), first.internals) + first.worker.post({ kind: 'showing', threadId: 7 }) + first.worker.post({ kind: 'done', path: 'C:\\picked' }) + await expect(picked).resolves.toBe('C:\\picked') + expect(first.close).not.toHaveBeenCalled() + + const second = harness() + const cancelled = pickWin32Directory(live(), second.internals) + second.worker.post({ kind: 'done', path: null }) + await expect(cancelled).resolves.toBeNull() + }) + + it('rejects on a reported dialog failure, a worker crash, and a silent exit', async () => { + const reported = harness() + const failing = pickWin32Directory(live(), reported.internals) + reported.worker.post({ kind: 'error', message: 'CoCreateInstance failed' }) + await expect(failing).rejects.toThrow('win32 folder dialog failed: CoCreateInstance failed') + + const crashed = harness() + const crashing = pickWin32Directory(live(), crashed.internals) + crashed.worker.emit('error', new Error('worker blew up')) + await expect(crashing).rejects.toThrow('worker blew up') + + const silent = harness() + const exiting = pickWin32Directory(live(), silent.internals) + silent.worker.emit('exit', 0) + await expect(exiting).rejects.toThrow('exited before reporting a result') + }) + + it('settles once: a late exit after the result is inert', async () => { + const { worker, internals } = harness() + const picked = pickWin32Directory(live(), internals) + worker.post({ kind: 'done', path: 'C:\\once' }) + worker.emit('exit', 0) + await expect(picked).resolves.toBe('C:\\once') + }) + + it('throws immediately on an already-aborted signal without spawning', async () => { + const spawnWorker = vi.fn() + const controller = new AbortController() + controller.abort() + await expect(pickWin32Directory(controller.signal, { spawnWorker, closeThreadWindows: async () => undefined })) + .rejects.toThrow('native directory picker aborted') + expect(spawnWorker).not.toHaveBeenCalled() + }) + + it('services an abort by closing the dialog thread windows until the worker reports', async () => { + const { worker, internals, close } = harness() + const controller = new AbortController() + // Attach the expectation BEFORE driving the race: on a fast host the + // close budget can exhaust (and reject) between waitFor ticks, and a + // rejection with no listener yet would count as unhandled. + const picked = expect(pickWin32Directory(controller.signal, internals)).rejects.toThrow('native directory picker aborted') + worker.post({ kind: 'showing', threadId: 99 }) + controller.abort() + await vi.waitFor(() => { + expect(close).toHaveBeenCalledWith(99) + }) + worker.post({ kind: 'done', path: null }) + await picked + }) + + it('starts the close service on the showing notice when the abort came first', async () => { + const closeFailures = vi.fn(async () => { throw new Error('window not there yet') }) + const { worker, internals } = harness({ closeThreadWindows: closeFailures }) + const controller = new AbortController() + // Attached before the race for the same unhandled-rejection reason above. + const picked = expect(pickWin32Directory(controller.signal, internals)).rejects.toThrow('native directory picker aborted') + controller.abort() + expect(closeFailures).not.toHaveBeenCalled() + worker.post({ kind: 'showing', threadId: 12 }) + await vi.waitFor(() => { + expect(closeFailures.mock.calls.length).toBeGreaterThan(1) + }) + worker.post({ kind: 'done', path: null }) + await picked + }) + + it('kills a worker that never reports showing after an abort', async () => { + // The budget runs without a thread id (nothing to WM_CLOSE yet), so a + // worker hung before `showing` cannot dangle the pick. + const { worker, internals, close } = harness() + const controller = new AbortController() + const picked = expect(pickWin32Directory(controller.signal, internals)).rejects.toThrow('dialog unresponsive; worker killed') + controller.abort() + await picked + expect(worker.kill).toHaveBeenCalledOnce() + expect(close).not.toHaveBeenCalled() + }) + + it('kills an unresponsive worker after the close budget', async () => { + const { worker, internals, close } = harness() + const controller = new AbortController() + const picked = pickWin32Directory(controller.signal, internals) + worker.post({ kind: 'showing', threadId: 5 }) + controller.abort() + await expect(picked).rejects.toThrow('dialog unresponsive; worker killed') + expect(worker.kill).toHaveBeenCalledOnce() + expect(close.mock.calls.length).toBeGreaterThan(10) + }) + + // POSIX hosts exercise the REAL default plumbing end to end: the tsx-bootstrapped + // worker spawns, loads koffi, fails to load ole32.dll, and reports the error. + it.skipIf(process.platform === 'win32')('rejects through the real worker where the Win32 surface is unavailable', async () => { + await expect(pickWin32Directory(live())).rejects.toThrow('win32 folder dialog failed') + }, 30_000) + + // win32 hosts run the true COM smoke instead: a real dialog opens briefly + // and the abort service closes it (the same lever a disconnecting client pulls). + it.skipIf(process.platform !== 'win32')('opens and abort-closes a real dialog', async () => { + const controller = new AbortController() + setTimeout(() => { + controller.abort() + }, 400) + await expect(pickWin32Directory(controller.signal)).rejects.toThrow('native directory picker aborted') + }, 30_000) +}) diff --git a/packages/host/directory-picker-native/tsdown.config.ts b/packages/host/directory-picker-native/tsdown.config.ts index 4f280f8112..529fb7b2ac 100644 --- a/packages/host/directory-picker-native/tsdown.config.ts +++ b/packages/host/directory-picker-native/tsdown.config.ts @@ -1,3 +1,20 @@ import { clientBundle } from '../../client/tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-host-directory-picker-native', ['lib/types/index.js', 'lib/types/invariant.js']) +// The Win32 dialog worker builds as its own CJS entry (mirroring +// dsh-workflow-workerthread's worker): path-loaded by the driver, inlining +// the dialog logic while koffi stays an external native require. +export default [ + ...clientBundle('@deepseek-ai/dsh-host-directory-picker-native', ['lib/types/index.js', 'lib/types/invariant.js']), + { + // The artifact is lib/worker.cjs (the ./worker export the workspace + // constraint keys on), bundled from the descriptive source entry. + entry: { worker: 'lib/types/win32-dialog-worker.js' }, + outDir: 'lib', + format: ['cjs'] as ['cjs'], + platform: 'node' as const, + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + }, +] diff --git a/packages/host/directory-picker/package.json b/packages/host/directory-picker/package.json index 17d68c141b..c37b67a4d7 100644 --- a/packages/host/directory-picker/package.json +++ b/packages/host/directory-picker/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/host/webserver/package.json b/packages/host/webserver/package.json index 291294d1fe..4db9433935 100644 --- a/packages/host/webserver/package.json +++ b/packages/host/webserver/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/llm/llm-deepseek/package.json b/packages/llm/llm-deepseek/package.json index 2c39e2d920..9f9f305b83 100644 --- a/packages/llm/llm-deepseek/package.json +++ b/packages/llm/llm-deepseek/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/llm/llm-pi-ai/package.json b/packages/llm/llm-pi-ai/package.json index 43b97a14f0..cae6c07b7c 100644 --- a/packages/llm/llm-pi-ai/package.json +++ b/packages/llm/llm-pi-ai/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/llm/llm-retry/package.json b/packages/llm/llm-retry/package.json index 62dacddcb3..d3b6afebba 100644 --- a/packages/llm/llm-retry/package.json +++ b/packages/llm/llm-retry/package.json @@ -25,9 +25,7 @@ "lib/index.js", "lib/invariant.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/llm/llm/package.json b/packages/llm/llm/package.json index 7adb8e41d9..c0a6994240 100644 --- a/packages/llm/llm/package.json +++ b/packages/llm/llm/package.json @@ -34,9 +34,7 @@ "lib/index.js", "lib/invariant.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/llm/token-meter/package.json b/packages/llm/token-meter/package.json index b058f7cdbb..80c0d99c8a 100644 --- a/packages/llm/token-meter/package.json +++ b/packages/llm/token-meter/package.json @@ -26,9 +26,7 @@ "lib/index.js", "lib/invariant.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/lsp/lsp-local/package.json b/packages/lsp/lsp-local/package.json index 1d6499d9f8..b68745d617 100644 --- a/packages/lsp/lsp-local/package.json +++ b/packages/lsp/lsp-local/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/lsp/lsp/package.json b/packages/lsp/lsp/package.json index 6a96dfdf70..b4a962fb34 100644 --- a/packages/lsp/lsp/package.json +++ b/packages/lsp/lsp/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/lsp/tool-lsp/package.json b/packages/lsp/tool-lsp/package.json index febc303a48..28b5a34bdb 100644 --- a/packages/lsp/tool-lsp/package.json +++ b/packages/lsp/tool-lsp/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/mcp/mcp-client/package.json b/packages/mcp/mcp-client/package.json index 0d214de9e8..3f65271905 100644 --- a/packages/mcp/mcp-client/package.json +++ b/packages/mcp/mcp-client/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/plan/plan-mode/package.json b/packages/plan/plan-mode/package.json index 6507ea4a0c..a810981357 100644 --- a/packages/plan/plan-mode/package.json +++ b/packages/plan/plan-mode/package.json @@ -30,9 +30,7 @@ "lib/index.js", "lib/invariant.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/pty/pty-local/package.json b/packages/pty/pty-local/package.json index e86024516e..a859f482fa 100644 --- a/packages/pty/pty-local/package.json +++ b/packages/pty/pty-local/package.json @@ -21,12 +21,11 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "scripts/ensure-spawn-helper.mjs", + "lib/types/**/*.d.ts" ], "scripts": { - "postinstall": "node src/ensure-spawn-helper.mjs" + "postinstall": "node scripts/ensure-spawn-helper.mjs" }, "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/pty/pty-local/src/ensure-spawn-helper.mjs b/packages/pty/pty-local/scripts/ensure-spawn-helper.mjs similarity index 100% rename from packages/pty/pty-local/src/ensure-spawn-helper.mjs rename to packages/pty/pty-local/scripts/ensure-spawn-helper.mjs diff --git a/packages/pty/pty/package.json b/packages/pty/pty/package.json index c9038f2ce9..702426a2b7 100644 --- a/packages/pty/pty/package.json +++ b/packages/pty/pty/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/pty/tool-bash-persistent/package.json b/packages/pty/tool-bash-persistent/package.json index 5022026733..b5cede3920 100644 --- a/packages/pty/tool-bash-persistent/package.json +++ b/packages/pty/tool-bash-persistent/package.json @@ -20,9 +20,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/pty/tool-pty/package.json b/packages/pty/tool-pty/package.json index d8b2564736..5512daba92 100644 --- a/packages/pty/tool-pty/package.json +++ b/packages/pty/tool-pty/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { diff --git a/packages/sandbox/sandbox-local/package.json b/packages/sandbox/sandbox-local/package.json index 6750b92577..2683ab3654 100644 --- a/packages/sandbox/sandbox-local/package.json +++ b/packages/sandbox/sandbox-local/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/sandbox/sandbox-policy/package.json b/packages/sandbox/sandbox-policy/package.json index 6f516c222d..6124a75ff6 100644 --- a/packages/sandbox/sandbox-policy/package.json +++ b/packages/sandbox/sandbox-policy/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/sandbox/sandbox/package.json b/packages/sandbox/sandbox/package.json index 84266499b3..deb703dc15 100644 --- a/packages/sandbox/sandbox/package.json +++ b/packages/sandbox/sandbox/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/sdk/create-sdk/package.json b/packages/sdk/create-sdk/package.json index 27102154fc..262d375563 100644 --- a/packages/sdk/create-sdk/package.json +++ b/packages/sdk/create-sdk/package.json @@ -24,9 +24,7 @@ "lib/invariant.js", "lib/bin.js", "lib/assets", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { diff --git a/packages/sdk/helper/package.json b/packages/sdk/helper/package.json index 2884e04f72..bf9eacb813 100644 --- a/packages/sdk/helper/package.json +++ b/packages/sdk/helper/package.json @@ -20,9 +20,7 @@ "lib/index.js", "lib/invariant.js", "lib/assets", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { diff --git a/packages/sdk/helper/src/features/builtin/index.ts b/packages/sdk/helper/src/features/builtin/index.ts index 278b609dd8..b23325445d 100644 --- a/packages/sdk/helper/src/features/builtin/index.ts +++ b/packages/sdk/helper/src/features/builtin/index.ts @@ -34,6 +34,7 @@ export function createBuiltinRegistry(profile: ProjectProfile): FeatureRegistry required: true, baseResources: [ { kind: 'npm-cordis-config-entry', id: 'subprocess', package: '@deepseek-ai/dsh-subprocess-local' }, + { kind: 'npm-cordis-config-entry', id: 'bash-env', package: '@deepseek-ai/dsh-bash-env' }, { kind: 'npm-cordis-config-entry', id: 'tool-bash', package: '@deepseek-ai/dsh-tool-bash' }, ], options: [ diff --git a/packages/sdk/scripts/package.json b/packages/sdk/scripts/package.json index e4bb35491c..deef54831f 100644 --- a/packages/sdk/scripts/package.json +++ b/packages/sdk/scripts/package.json @@ -30,16 +30,14 @@ "lib/dev/tsdown-config.js", "lib/local-plugin-loader-hooks.js", "lib/assets", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { "@deepseek-ai/dsh-helper": "workspace:^", "@deepseek-ai/dsh-telemetry": "workspace:^", "commander": "^15.0.0", - "node-addon-require-builtin": "^0.1.3" + "node-addon-require-builtin": "^0.1.4" }, "peerDependencies": { "@deepseek-ai/dsh-app-boot": "workspace:^", diff --git a/packages/sdk/sdk-client/package.json b/packages/sdk/sdk-client/package.json index 27341f2040..3dbb236ab3 100644 --- a/packages/sdk/sdk-client/package.json +++ b/packages/sdk/sdk-client/package.json @@ -20,9 +20,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/sdk/sdk-protocol/package.json b/packages/sdk/sdk-protocol/package.json index bc4591a669..fe7d057c11 100644 --- a/packages/sdk/sdk-protocol/package.json +++ b/packages/sdk/sdk-protocol/package.json @@ -20,9 +20,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/sdk/telemetry/package.json b/packages/sdk/telemetry/package.json index aeb75c4b1f..6fe62e35fc 100644 --- a/packages/sdk/telemetry/package.json +++ b/packages/sdk/telemetry/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { diff --git a/packages/session-persistence/session-checkpoint-policy/package.json b/packages/session-persistence/session-checkpoint-policy/package.json index 5d0fe5f465..afbc62345e 100644 --- a/packages/session-persistence/session-checkpoint-policy/package.json +++ b/packages/session-persistence/session-checkpoint-policy/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/session-persistence/session-persistence-jsonl/package.json b/packages/session-persistence/session-persistence-jsonl/package.json index 91c8e81fdf..b09211669b 100644 --- a/packages/session-persistence/session-persistence-jsonl/package.json +++ b/packages/session-persistence/session-persistence-jsonl/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/session-persistence/session-persistence-sqlite/package.json b/packages/session-persistence/session-persistence-sqlite/package.json index f65bdebd16..90415bd8cc 100644 --- a/packages/session-persistence/session-persistence-sqlite/package.json +++ b/packages/session-persistence/session-persistence-sqlite/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/session-persistence/session-persistence/package.json b/packages/session-persistence/session-persistence/package.json index ca74dd487d..d1977d4512 100644 --- a/packages/session-persistence/session-persistence/package.json +++ b/packages/session-persistence/session-persistence/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/session-projection/session-projection-cache/package.json b/packages/session-projection/session-projection-cache/package.json index d06a49def0..2be692665f 100644 --- a/packages/session-projection/session-projection-cache/package.json +++ b/packages/session-projection/session-projection-cache/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { diff --git a/packages/session-projection/session-projection/README.i18n.yaml b/packages/session-projection/session-projection/README.i18n.yaml index da8eb96a26..92935e92c0 100644 --- a/packages/session-projection/session-projection/README.i18n.yaml +++ b/packages/session-projection/session-projection/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session-projection/session-projection/README.md README.md: f4898b8e567fa5998c18111c5f4e27a8a350a42e -README.zh.md: 87c17736cd71ba9a90e434fd03c11dcc7bb4e72b +README.zh.md: 520205e616cdc1421c8f33f00d2013a6e0947d3d diff --git a/packages/session-projection/session-projection/README.zh.md b/packages/session-projection/session-projection/README.zh.md index 87c17736cd..520205e616 100644 --- a/packages/session-projection/session-projection/README.zh.md +++ b/packages/session-projection/session-projection/README.zh.md @@ -39,7 +39,7 @@ 无;投影从不组装或发送提供方请求。 -## 已知限制与延期工作 +## 已知限制与暂缓事项 - **每个尾页携带每个已注册的 key**——尚无逐 key 的 opt-out 或惰性 key 请求形状;在值都是 UI 量级的全量状态(一张 todo 清单、一份 goal 快照)时可以接受,若某领域的值变大再重议。 - **主动驱动(eager drive)逐事件触达每个单元**——按构造开销很低(全量值规则、同引用闸门),但若出现热点路径,可加按单元的事件类型预过滤,契约不变。 diff --git a/packages/session-projection/session-projection/package.json b/packages/session-projection/session-projection/package.json index 473b878f3f..37a081abfa 100644 --- a/packages/session-projection/session-projection/package.json +++ b/packages/session-projection/session-projection/package.json @@ -26,9 +26,7 @@ "lib/index.js", "lib/invariant.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { diff --git a/packages/session-query/session-query-sqlite/package.json b/packages/session-query/session-query-sqlite/package.json index 2d4758ba3e..4813b76b10 100644 --- a/packages/session-query/session-query-sqlite/package.json +++ b/packages/session-query/session-query-sqlite/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/session-query/session-query/package.json b/packages/session-query/session-query/package.json index daf4d4e680..3a52808f02 100644 --- a/packages/session-query/session-query/package.json +++ b/packages/session-query/session-query/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/session-query/tool-session-query/package.json b/packages/session-query/tool-session-query/package.json index 791a376cec..39b5ada943 100644 --- a/packages/session-query/tool-session-query/package.json +++ b/packages/session-query/tool-session-query/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/session-title/session-title-all-messages-llm/package.json b/packages/session-title/session-title-all-messages-llm/package.json index 06bdaaf8a0..546387c682 100644 --- a/packages/session-title/session-title-all-messages-llm/package.json +++ b/packages/session-title/session-title-all-messages-llm/package.json @@ -17,7 +17,7 @@ }, "./package.json": "./package.json" }, - "files": ["lib/index.js", "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src"], + "files": ["lib/index.js", "lib/invariant.js", "lib/types/**/*.d.ts"], "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", diff --git a/packages/session-title/session-title-first-message-llm/package.json b/packages/session-title/session-title-first-message-llm/package.json index f2cecb77a9..50b4a4d14c 100644 --- a/packages/session-title/session-title-first-message-llm/package.json +++ b/packages/session-title/session-title-first-message-llm/package.json @@ -17,7 +17,7 @@ }, "./package.json": "./package.json" }, - "files": ["lib/index.js", "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src"], + "files": ["lib/index.js", "lib/invariant.js", "lib/types/**/*.d.ts"], "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", diff --git a/packages/session-title/session-title-llm/package.json b/packages/session-title/session-title-llm/package.json index b74cb4440c..64e4519662 100644 --- a/packages/session-title/session-title-llm/package.json +++ b/packages/session-title/session-title-llm/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/session-title/session-title/package.json b/packages/session-title/session-title/package.json index 8d6126236d..d167b257b2 100644 --- a/packages/session-title/session-title/package.json +++ b/packages/session-title/session-title/package.json @@ -30,9 +30,7 @@ "lib/index.js", "lib/invariant.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/settings/settings-local/package.json b/packages/settings/settings-local/package.json index 0040b65507..6a9185ec2c 100644 --- a/packages/settings/settings-local/package.json +++ b/packages/settings/settings-local/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/settings/settings/package.json b/packages/settings/settings/package.json index 7586b4bb24..09112147ec 100644 --- a/packages/settings/settings/package.json +++ b/packages/settings/settings/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/skill/skill-local/package.json b/packages/skill/skill-local/package.json index 8331a94a97..25306774b9 100644 --- a/packages/skill/skill-local/package.json +++ b/packages/skill/skill-local/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/skill/skill/package.json b/packages/skill/skill/package.json index 3148a3f577..73469b89a7 100644 --- a/packages/skill/skill/package.json +++ b/packages/skill/skill/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/skill/tool-skill/package.json b/packages/skill/tool-skill/package.json index 9d86da58dd..e3ff7520b5 100644 --- a/packages/skill/tool-skill/package.json +++ b/packages/skill/tool-skill/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/spill/spill-local/package.json b/packages/spill/spill-local/package.json index 25a20db1d5..f185ab2c11 100644 --- a/packages/spill/spill-local/package.json +++ b/packages/spill/spill-local/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/spill/spill-policy/package.json b/packages/spill/spill-policy/package.json index e8e9fb4631..f238acd3d6 100644 --- a/packages/spill/spill-policy/package.json +++ b/packages/spill/spill-policy/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/spill/spill/package.json b/packages/spill/spill/package.json index c66306ff0a..6a87d575a3 100644 --- a/packages/spill/spill/package.json +++ b/packages/spill/spill/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/storage/storage-domain/package.json b/packages/storage/storage-domain/package.json index 1dffd143a9..aaa277b244 100644 --- a/packages/storage/storage-domain/package.json +++ b/packages/storage/storage-domain/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/storage/storage-json/package.json b/packages/storage/storage-json/package.json index bcc20b6ba1..4dc9bec40b 100644 --- a/packages/storage/storage-json/package.json +++ b/packages/storage/storage-json/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/storage/storage-sqlite/package.json b/packages/storage/storage-sqlite/package.json index dc792fe350..cebde3d279 100644 --- a/packages/storage/storage-sqlite/package.json +++ b/packages/storage/storage-sqlite/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/storage/storage/package.json b/packages/storage/storage/package.json index 600eb997ee..1c78c1d434 100644 --- a/packages/storage/storage/package.json +++ b/packages/storage/storage/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/subagent/subagent-acp/package.json b/packages/subagent/subagent-acp/package.json index b06a5e50ea..382d2e219a 100644 --- a/packages/subagent/subagent-acp/package.json +++ b/packages/subagent/subagent-acp/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/subagent/subagent-dsh-sdk/package.json b/packages/subagent/subagent-dsh-sdk/package.json index 52df9c033c..07217be7c1 100644 --- a/packages/subagent/subagent-dsh-sdk/package.json +++ b/packages/subagent/subagent-dsh-sdk/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/subagent/subagent-fork/package.json b/packages/subagent/subagent-fork/package.json index aa93b83e03..1a77983d56 100644 --- a/packages/subagent/subagent-fork/package.json +++ b/packages/subagent/subagent-fork/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/subagent/subagent-inprocess/package.json b/packages/subagent/subagent-inprocess/package.json index 893fd4e342..e18752db74 100644 --- a/packages/subagent/subagent-inprocess/package.json +++ b/packages/subagent/subagent-inprocess/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/subagent/subagent-spawn/package.json b/packages/subagent/subagent-spawn/package.json index 647e0b005c..243e7afb52 100644 --- a/packages/subagent/subagent-spawn/package.json +++ b/packages/subagent/subagent-spawn/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/subagent/subagent-spawn/tests/harness.ts b/packages/subagent/subagent-spawn/tests/harness.ts index afa1d2a1d2..389ef5e2a7 100644 --- a/packages/subagent/subagent-spawn/tests/harness.ts +++ b/packages/subagent/subagent-spawn/tests/harness.ts @@ -3,6 +3,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' @@ -29,6 +30,7 @@ export async function spawnHarness(workdir: string): Promise { await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LlmDeepSeek) await ctx.plugin(LocalSubprocessService) + await ctx.plugin(BashEnvPlugin) await ctx.plugin(LocalBashExecutor, { cwd: workdir, timeoutMs: 30_000 }) await ctx.plugin(ToolBash) await ctx.plugin(SubagentService) diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json index 8cd78c5526..c00caf7fb9 100644 --- a/packages/subagent/subagent/package.json +++ b/packages/subagent/subagent/package.json @@ -26,9 +26,7 @@ "lib/index.js", "lib/invariant.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { diff --git a/packages/subagent/tool-subagent-control/package.json b/packages/subagent/tool-subagent-control/package.json index 087928dbd8..f0d57c52aa 100644 --- a/packages/subagent/tool-subagent-control/package.json +++ b/packages/subagent/tool-subagent-control/package.json @@ -26,9 +26,7 @@ "lib/index.js", "lib/invariant.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/subagent/tool-subagent-report/package.json b/packages/subagent/tool-subagent-report/package.json index 5b6d3c21d6..12e39c341c 100644 --- a/packages/subagent/tool-subagent-report/package.json +++ b/packages/subagent/tool-subagent-report/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/subagent/tool-subagent/package.json b/packages/subagent/tool-subagent/package.json index 353f67ff24..f7d4c4e84e 100644 --- a/packages/subagent/tool-subagent/package.json +++ b/packages/subagent/tool-subagent/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/subprocess/subprocess-local/package.json b/packages/subprocess/subprocess-local/package.json index 72ff50c422..2aa69b82e1 100644 --- a/packages/subprocess/subprocess-local/package.json +++ b/packages/subprocess/subprocess-local/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/subprocess/subprocess/package.json b/packages/subprocess/subprocess/package.json index 6771c19651..b7aa6308ad 100644 --- a/packages/subprocess/subprocess/package.json +++ b/packages/subprocess/subprocess/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/subprocess/subprocess/src/index.ts b/packages/subprocess/subprocess/src/index.ts index 5d0bbe2c76..d484faa24d 100644 --- a/packages/subprocess/subprocess/src/index.ts +++ b/packages/subprocess/subprocess/src/index.ts @@ -45,7 +45,10 @@ export const SENSITIVE_ENV_PATTERN = /KEY|PASSWORD|SECRET|TOKEN/i * `HOME`, locale, and proxy variables survive, so child CLIs run normally; * harness identity never leaks implicitly (a deliberately forwarded * credential or current `DSH_*` fact goes through the spec's explicit `env`, - * which merges after this scrub). Exported as a plain function so spawners + * which merges after this scrub). Both scrubs match case-insensitively: + * Windows environment names are case-insensitive, so a parent `dsh_*` entry + * would otherwise survive and read back as `$env:DSH_*` in the child; + * deliberate lowercase `dsh_*` names on POSIX are implausible. Exported as a plain function so spawners * that cannot route through the service (node-pty backends, SDK-managed * transports) share the one scrub definition. * @returns a fresh environment object safe to hand to a child spawn. @@ -53,7 +56,7 @@ export const SENSITIVE_ENV_PATTERN = /KEY|PASSWORD|SECRET|TOKEN/i export function scrubbedParentEnv(): Record { const env: Record = {} for (const [key, value] of Object.entries(process.env)) { - if (value !== undefined && !SENSITIVE_ENV_PATTERN.test(key) && !key.startsWith(DSH_ENV_PREFIX)) env[key] = value + if (value !== undefined && !SENSITIVE_ENV_PATTERN.test(key) && !key.toUpperCase().startsWith(DSH_ENV_PREFIX)) env[key] = value } return env } diff --git a/packages/subprocess/subprocess/tests/service.spec.ts b/packages/subprocess/subprocess/tests/service.spec.ts index 28a1dce299..d0ef5c9fd6 100644 --- a/packages/subprocess/subprocess/tests/service.spec.ts +++ b/packages/subprocess/subprocess/tests/service.spec.ts @@ -52,20 +52,23 @@ describe('SubprocessService seam', () => { await expect(ctx.plugin(SecondService)).rejects.toThrow(/service "subprocess" has been registered/) }) - it('scrubbedParentEnv drops credential-shaped and DSH_ names but keeps PATH', () => { + it('scrubbedParentEnv drops credential-shaped and DSH_ names (case-insensitively) but keeps PATH', () => { process.env.DSH_SCRUB_PROBE = 'stale' + process.env.dsh_scrub_probe_lower = 'stale' process.env.SCRUB_PROBE_TOKEN = 'secret' process.env.SCRUB_PROBE_PASSWORD = 'secret' process.env.SCRUB_PROBE_PLAIN = 'visible' try { const env = scrubbedParentEnv() expect(env.DSH_SCRUB_PROBE).toBeUndefined() + expect(env.dsh_scrub_probe_lower).toBeUndefined() expect(env.SCRUB_PROBE_TOKEN).toBeUndefined() expect(env.SCRUB_PROBE_PASSWORD).toBeUndefined() expect(env.SCRUB_PROBE_PLAIN).toBe('visible') expect(env.PATH).toBeDefined() } finally { delete process.env.DSH_SCRUB_PROBE + delete process.env.dsh_scrub_probe_lower delete process.env.SCRUB_PROBE_TOKEN delete process.env.SCRUB_PROBE_PASSWORD delete process.env.SCRUB_PROBE_PLAIN diff --git a/packages/support/acp-snapshot/README.i18n.yaml b/packages/support/acp-snapshot/README.i18n.yaml index cd302e5418..362de9fbd1 100644 --- a/packages/support/acp-snapshot/README.i18n.yaml +++ b/packages/support/acp-snapshot/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/support/acp-snapshot/README.md -README.md: e7988733827ef1d4de67d6d49764a4836e33d17c -README.zh.md: 1565ba85bb25abeca9509f7013d3e7d49bc5dad8 +README.md: c8fe6907848a7b661c4bfb60c871f077753fda8b +README.zh.md: 0aa28f4b2c3df1a30e5fd33a80401712f81d6614 diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index e798873382..c8fe690784 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -55,7 +55,7 @@ A scenario booting a differently-composed tree sets its own `configPath` (an ove A pin owns its generated `system-prompt.expected.md` or `tool-schemas.expected.json` by default; `systemPromptSource` and `toolSchemasSource` name another pin when the complete corresponding sequence is identical, so each distinct version is committed once. The pin's `session.jsonl` stores `"system":"{{system}}","tools":"{{tools}}"` while retaining config, reason, and any model-visible prefix. A pin with legitimate mid-run header changes declares `expectedHeaderChanges`; a shared source must declare the same count, and record/refresh rejects claimants that generate different bytes. -Every scenario compares `stdout.expected.jsonl` with cwd-rooted separators canonicalized to `/`. On Windows, `pinsNativeWindowsStdout` additionally compares the complete `stdout.expected.windows.jsonl` after the shared expected output and requires that sidecar exactly when enabled. A scenario requiring a non-Windows host declares `posixOnly`, which skips its run test on Windows while the fixture guards keep covering its committed files everywhere; examples include POSIX process semantics (e.g. cancelling a live bash call kills a detached process group) and generated paths Windows cannot represent. +Every scenario compares `stdout.expected.jsonl` with cwd-rooted separators canonicalized to `/`. On Windows, `pinsNativeWindowsStdout` additionally compares the complete `stdout.expected.windows.jsonl` after the shared expected output and requires that sidecar exactly when enabled. A scenario requiring a non-Windows host declares `posixOnly`, which skips its run test on Windows while the fixture guards keep covering its committed files everywhere; examples include POSIX process semantics (e.g. cancelling a live bash call kills a detached process group) and generated paths Windows cannot represent. A scenario whose composition needs a usable `pwsh` declares `pwshOnly`; the caller-supplied `hasPwsh` probe (the shipped acp-agent suite follows the executor's own resolution, so Program Files installs count) skips the run test when no usable `pwsh` resolves while the fixture guards keep covering its committed files everywhere. The example also ships a `cordis.snapshot.yml` replay overlay next to its `cordis.yml` (the bin swaps them under `DSH_SNAPSHOT=replay` — [single-source replay config Agent Note](../../../.agents/notes/archived/testing/2026-07-04-single-source-acp-replay-config.md)); replay fixtures are served by [`dsh-llm-replay`](../llm-replay/README.md), which this package points at via the `DSH_SNAPSHOT_*` env vars it sets on the child. `pnpm run test:snapshot:record` calls the live LLM and rewrites the recorded scenarios' model fixtures; `pnpm run test:snapshot:refresh` stays keyless, runs the replay overlay, and rewrites stdout, comparable session-log expected outputs, and owned prompt and tool-schema sidecars from the committed model scripts. Fixture roles, record/replay/refresh semantics, and scenario-table fields are documented on `Scenario` and in the [snapshot Agent Note](../../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md). diff --git a/packages/support/acp-snapshot/README.zh.md b/packages/support/acp-snapshot/README.zh.md index 1565ba85bb..0aa28f4b2c 100644 --- a/packages/support/acp-snapshot/README.zh.md +++ b/packages/support/acp-snapshot/README.zh.md @@ -55,7 +55,7 @@ defineAcpSnapshotSuite({ 每个 pin 默认拥有其生成的 `system-prompt.expected.md` 或 `tool-schemas.expected.json`;当完整的对应序列相同时,`systemPromptSource` 和 `toolSchemasSource` 指定另一个 pin 作为来源,因此每个不同版本只提交一次。该 pin 的 `session.jsonl` 存储 `"system":"{{system}}","tools":"{{tools}}"`,同时保留配置、原因和任何模型可见前缀。具有合法运行中 header 变更的 pin 声明 `expectedHeaderChanges`;共享来源必须声明相同的 header 变更数量,录制/刷新会拒绝生成不同字节的共享引用方。 -每个场景都比较 `stdout.expected.jsonl`,其中以 cwd 为根的分隔符规范化为 `/`。在 Windows 上,`pinsNativeWindowsStdout` 还会在共享预期输出之后比较完整 `stdout.expected.windows.jsonl`,并在启用时精确要求该 sidecar。需要非 Windows 主机的场景声明 `posixOnly`,在 Windows 上跳过运行测试,但 fixture 保护仍在所有平台覆盖其已提交文件;示例包括 POSIX 进程语义(例如取消实时 bash 调用会终止脱离进程组)和 Windows 无法表示的生成路径。 +每个场景都比较 `stdout.expected.jsonl`,其中以 cwd 为根的分隔符规范化为 `/`。在 Windows 上,`pinsNativeWindowsStdout` 还会在共享预期输出之后比较完整 `stdout.expected.windows.jsonl`,并在启用时精确要求该 sidecar。需要非 Windows 主机的场景声明 `posixOnly`,在 Windows 上跳过运行测试,但 fixture 保护仍在所有平台覆盖其已提交文件;示例包括 POSIX 进程语义(例如取消实时 bash 调用会终止脱离进程组)和 Windows 无法表示的生成路径。组合需要可用 `pwsh` 的场景声明 `pwshOnly`;调用方提供的 `hasPwsh` 探测(随附的 acp-agent 套件遵循执行器自身的解析,因此 Program Files 安装也计入)在解析不到可用 `pwsh` 时跳过运行测试,而 fixture 保护仍处处覆盖其已提交文件。 示例还发布 `cordis.snapshot.yml` 回放 overlay,位于 `cordis.yml` 旁边(bin 在 `DSH_SNAPSHOT=replay` 下交换它们,见[单源回放配置 Agent Note](../../../.agents/notes/archived/testing/2026-07-04-single-source-acp-replay-config.md));回放 fixture 由 [`dsh-llm-replay`](../llm-replay/README.md) 提供,该包通过对子级设置的 `DSH_SNAPSHOT_*` env var 指向它。`pnpm run test:snapshot:record` 调用实时 LLM,并重写已记录场景的模型 fixture;`pnpm run test:snapshot:refresh` 保持无密钥,运行回放 overlay,并从已提交模型脚本重写 stdout、可比较会话日志预期输出,以及各 pin 自有的提示词与工具 schema sidecar。Fixture 角色、录制/回放/刷新语义和场景表字段记录在 `Scenario` 以及[快照 Agent Note](../../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md) 中。 @@ -65,7 +65,7 @@ defineAcpSnapshotSuite({ 无。该测试专用 harness 记录、规范化并比较 ACP transcript,不会改变 agent 组装的模型请求。 -#### KV Cache 影响 +#### KV 缓存影响 无;该包既不组装也不发送提供方请求。 diff --git a/packages/support/acp-snapshot/package.json b/packages/support/acp-snapshot/package.json index e5e715238a..c231591103 100644 --- a/packages/support/acp-snapshot/package.json +++ b/packages/support/acp-snapshot/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { diff --git a/packages/support/acp-snapshot/src/suite.ts b/packages/support/acp-snapshot/src/suite.ts index c8434bb8c9..db59c9265b 100644 --- a/packages/support/acp-snapshot/src/suite.ts +++ b/packages/support/acp-snapshot/src/suite.ts @@ -161,25 +161,37 @@ export interface Scenario { * test is skipped on Windows; its fixtures stay guarded on every platform. */ posixOnly?: boolean + /** + * Whether the scenario boots a composition that needs a usable `pwsh` + * (the pwsh-tool-turn scenario). The run test is skipped when the suite's + * {@link SnapshotSuiteOptions.hasPwsh} probe is false; fixtures stay guarded + * on every platform. + */ + pwshOnly?: boolean } /** * Whether a scenario's run test is skipped for this mode and host: record mode - * skips authored (non-`recorded`) scenarios, and {@link Scenario.posixOnly} - * scenarios skip on Windows. + * skips authored (non-`recorded`) scenarios, {@link Scenario.posixOnly} + * scenarios skip on Windows, and {@link Scenario.pwshOnly} scenarios skip + * when the caller's `hasPwsh` probe is false. * * @param scenario The scenario whose run test is being registered. * @param recording Whether the suite runs in record mode. * @param platform The running Node platform, injectable for unit coverage. + * @param hasPwsh The caller's pwsh-availability probe; `pwshOnly` scenarios + * skip unless it is true. * @returns True when the scenario's run test must not execute. */ export function scenarioSkipped( scenario: Scenario, recording: boolean, platform: NodeJS.Platform = process.platform, + hasPwsh?: boolean, ): boolean { if (recording && !scenario.recorded) return true - return scenario.posixOnly === true && platform === 'win32' + if (scenario.posixOnly === true && platform === 'win32') return true + return scenario.pwshOnly === true && hasPwsh !== true } /** One stdout expected output selected for a platform run. */ @@ -220,6 +232,11 @@ export interface SnapshotSuiteOptions { * from `$DSH_SNAPSHOT` — env reading stays outside this library. */ mode: 'replay' | 'record' | 'refresh' + /** + * Whether a real `pwsh` executable is available on this host (the probe the + * caller owns; `pwshOnly` scenarios skip when this is not true). + */ + hasPwsh?: boolean } /** One scenario's generated claim on a shared snapshot file. */ @@ -973,8 +990,9 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { scenarioSuite('snapshot scenarios', () => { for (const scenario of scenarios) { // In RECORD mode, only re-run the `recorded` (live-API) scenarios; the `authored` ones - // (sidecar-driven errors/cancel) are never re-recorded. `posixOnly` scenarios skip on Windows. - it.skipIf(scenarioSkipped(scenario, RECORDING))(`snapshot: ${scenario.name} matches the expected outputs`, async ({ expect }) => { + // (sidecar-driven errors/cancel) are never re-recorded. `posixOnly` scenarios skip on Windows; + // `pwshOnly` scenarios skip when the caller's `hasPwsh` probe is false. + it.skipIf(scenarioSkipped(scenario, RECORDING, process.platform, options.hasPwsh))(`snapshot: ${scenario.name} matches the expected outputs`, async ({ expect }) => { const dir = join(snapshotsDir, scenario.name) const input = JSON.parse(await readFile(join(dir, 'input.json'), 'utf8')) as InputScript const overrideFile = join(dir, 'replay.override.json') diff --git a/packages/support/acp-snapshot/tests/suite.spec.ts b/packages/support/acp-snapshot/tests/suite.spec.ts index 6f16844595..d8cb177a16 100644 --- a/packages/support/acp-snapshot/tests/suite.spec.ts +++ b/packages/support/acp-snapshot/tests/suite.spec.ts @@ -459,6 +459,7 @@ describe('stdoutExpectedVariants', () => { describe('scenarioSkipped', () => { const authored: Scenario = { name: 'authored', hasModelTurn: true, recorded: false } const posix: Scenario = { name: 'posix-cancel', hasModelTurn: true, recorded: false, posixOnly: true } + const pwsh: Scenario = { name: 'pwsh-tool', hasModelTurn: true, recorded: false, pwshOnly: true } it('skips authored scenarios only while recording', () => { expect(scenarioSkipped(authored, true, 'linux')).toBe(true) @@ -471,6 +472,13 @@ describe('scenarioSkipped', () => { expect(scenarioSkipped(posix, false, 'darwin')).toBe(false) expect(scenarioSkipped(authored, false, 'win32')).toBe(false) }) + + it('skips pwshOnly scenarios when the host lacks pwsh, and runs them otherwise', () => { + expect(scenarioSkipped(pwsh, false, 'linux', false)).toBe(true) + expect(scenarioSkipped(pwsh, false, 'win32', true)).toBe(false) + expect(scenarioSkipped(pwsh, false, 'linux', true)).toBe(false) + expect(scenarioSkipped(authored, false, 'linux', false)).toBe(false) + }) }) describe('fixtureContext', () => { diff --git a/packages/support/agent-loop-testkit/package.json b/packages/support/agent-loop-testkit/package.json index aa04d85832..d6419f38dd 100644 --- a/packages/support/agent-loop-testkit/package.json +++ b/packages/support/agent-loop-testkit/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/support/invariants/package.json b/packages/support/invariants/package.json index d52dd0a14d..f41c6bfde6 100644 --- a/packages/support/invariants/package.json +++ b/packages/support/invariants/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/support/llm-mock-server/package.json b/packages/support/llm-mock-server/package.json index 4b14cb6d0b..20b1cf6d85 100644 --- a/packages/support/llm-mock-server/package.json +++ b/packages/support/llm-mock-server/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/support/llm-replay/package.json b/packages/support/llm-replay/package.json index 4a7e7dd8d8..708e84b57a 100644 --- a/packages/support/llm-replay/package.json +++ b/packages/support/llm-replay/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/support/loader-smoke/package.json b/packages/support/loader-smoke/package.json index ebdd62373a..1eeabb950d 100644 --- a/packages/support/loader-smoke/package.json +++ b/packages/support/loader-smoke/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { diff --git a/packages/tasks/tasks-local/package.json b/packages/tasks/tasks-local/package.json index cdcc823826..446b5825d3 100644 --- a/packages/tasks/tasks-local/package.json +++ b/packages/tasks/tasks-local/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/tasks/tasks/package.json b/packages/tasks/tasks/package.json index 9bc02879cf..eea8e2af6b 100644 --- a/packages/tasks/tasks/package.json +++ b/packages/tasks/tasks/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/tasks/tool-tasks/package.json b/packages/tasks/tool-tasks/package.json index c63e49ddc9..dfffbf4855 100644 --- a/packages/tasks/tool-tasks/package.json +++ b/packages/tasks/tool-tasks/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { diff --git a/packages/telemetry/session-telemetry-otel/package.json b/packages/telemetry/session-telemetry-otel/package.json index 49e9ef6cad..45de52bf3c 100644 --- a/packages/telemetry/session-telemetry-otel/package.json +++ b/packages/telemetry/session-telemetry-otel/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { diff --git a/packages/telemetry/session-telemetry/package.json b/packages/telemetry/session-telemetry/package.json index 71646c130e..ff6e31b025 100644 --- a/packages/telemetry/session-telemetry/package.json +++ b/packages/telemetry/session-telemetry/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/timeout/timeout-policy/package.json b/packages/timeout/timeout-policy/package.json index 43dc9dfa28..b77bd23716 100644 --- a/packages/timeout/timeout-policy/package.json +++ b/packages/timeout/timeout-policy/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/todo/tool-todo/package.json b/packages/todo/tool-todo/package.json index 85bd18ea8b..0fa3eb0c4f 100644 --- a/packages/todo/tool-todo/package.json +++ b/packages/todo/tool-todo/package.json @@ -26,9 +26,7 @@ "lib/index.js", "lib/invariant.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { diff --git a/packages/typert/generator/README.i18n.yaml b/packages/typert/generator/README.i18n.yaml index d0c4dffbfc..cd6588c0ab 100644 --- a/packages/typert/generator/README.i18n.yaml +++ b/packages/typert/generator/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/typert/generator/README.md README.md: c343fd9475a9407159037f0a10e3a0586a77c3da -README.zh.md: 397cd1710cc101b3ef24d9a7f6b5c51b5cd6c77b +README.zh.md: f9f863fe67d256b9744d7caeda0680f715808714 diff --git a/packages/typert/generator/README.zh.md b/packages/typert/generator/README.zh.md index 397cd1710c..f9f863fe67 100644 --- a/packages/typert/generator/README.zh.md +++ b/packages/typert/generator/README.zh.md @@ -32,7 +32,7 @@ TypeScript 项目分析器和模型驱动的 Typert 生成器。在生成任何 无。 -## 已知限制与暂缓工作 +## 已知限制与暂缓事项 - 系统会跳过包导出中的模式匹配;参与贡献的包需要具体的导出目标。 - 跨 face 的具名重新导出和星号重新导出会生成链接;在 `TypeTargetModel` 能够不经展平便表示模块命名空间之前,命名空间重新导出会失败。 diff --git a/packages/typert/generator/package.json b/packages/typert/generator/package.json index 90fb32e5af..3e9d8f7f61 100644 --- a/packages/typert/generator/package.json +++ b/packages/typert/generator/package.json @@ -26,9 +26,7 @@ "lib/index.js", "lib/invariant.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { diff --git a/packages/typert/loader/README.i18n.yaml b/packages/typert/loader/README.i18n.yaml index d28a4adaa0..b9b6275c20 100644 --- a/packages/typert/loader/README.i18n.yaml +++ b/packages/typert/loader/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/typert/loader/README.md README.md: ab9293de1630fdbe8c560bb9e6d00c272cc34161 -README.zh.md: 047e6d5af680faaf04bb3ca5ffa9c047be744388 +README.zh.md: f73beec4fb09d5376d905a5e22e8bb1ccb710f90 diff --git a/packages/typert/loader/README.zh.md b/packages/typert/loader/README.zh.md index 047e6d5af6..f73beec4fb 100644 --- a/packages/typert/loader/README.zh.md +++ b/packages/typert/loader/README.zh.md @@ -18,7 +18,7 @@ 无直接影响。 -## 已知限制与暂缓工作 +## 已知限制与暂缓事项 - 发现机制只会导入宿主侧产物;若要为客户端运行时添加等价的发现机制,需要先有独立的组合所有者。 - Loader 配置项会自动发现。嵌套插件或非 Loader 插件需要显式加入 `packages`,或由组合所有者直接负责调用 `ctx.typert.register()`。 diff --git a/packages/typert/loader/package.json b/packages/typert/loader/package.json index 5826b96b5a..ca6db771e3 100644 --- a/packages/typert/loader/package.json +++ b/packages/typert/loader/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/typert/registry/README.i18n.yaml b/packages/typert/registry/README.i18n.yaml index b1828d74e6..b8c97637c9 100644 --- a/packages/typert/registry/README.i18n.yaml +++ b/packages/typert/registry/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/typert/registry/README.md README.md: 83c03ab284abf2b7cab4dd1ee70d7e855184a1e0 -README.zh.md: 4b2ac8eccc0857e34cf7cba82241c42ec1514ffa +README.zh.md: db2140e51d85be53bbf6eb4d1dd86ec38ceefc58 diff --git a/packages/typert/registry/README.zh.md b/packages/typert/registry/README.zh.md index 4b2ac8eccc..db2140e51d 100644 --- a/packages/typert/registry/README.zh.md +++ b/packages/typert/registry/README.zh.md @@ -25,7 +25,7 @@ 无直接影响。将反射信息放入请求的消费方负责由此产生的前缀变化。 -## 已知限制与暂缓工作 +## 已知限制与暂缓事项 - 注册表存储生成的反射信息,但不会合并宿主侧与客户端侧的图,也不会解析 TypeScript 引用;这些由分析器和产物输出器负责。 - schema 键不包含 face,因为宿主侧和客户端侧在不同的上下文中运行。若在同一上下文中注册来自两个 face 的同名 schema,系统会将其作为重复项拒绝。 diff --git a/packages/typert/registry/package.json b/packages/typert/registry/package.json index 986ac55a37..b543589dc6 100644 --- a/packages/typert/registry/package.json +++ b/packages/typert/registry/package.json @@ -26,9 +26,7 @@ "lib/index.js", "lib/invariant.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { diff --git a/packages/ui/app-boot/package.json b/packages/ui/app-boot/package.json index 18a42a27a1..c1214953a5 100644 --- a/packages/ui/app-boot/package.json +++ b/packages/ui/app-boot/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { diff --git a/packages/ui/commands/package.json b/packages/ui/commands/package.json index e22dee8f3b..7d35603a79 100644 --- a/packages/ui/commands/package.json +++ b/packages/ui/commands/package.json @@ -26,9 +26,7 @@ "lib/index.js", "lib/invariant.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/ui/jsonrpc/package.json b/packages/ui/jsonrpc/package.json index ff51571ace..573f724ae0 100644 --- a/packages/ui/jsonrpc/package.json +++ b/packages/ui/jsonrpc/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { diff --git a/packages/ui/permission/package.json b/packages/ui/permission/package.json index c3554e3c4d..0ae48d6e2f 100644 --- a/packages/ui/permission/package.json +++ b/packages/ui/permission/package.json @@ -30,9 +30,7 @@ "lib/index.js", "lib/invariant.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/ui/tool-ask-user/package.json b/packages/ui/tool-ask-user/package.json index d2523ce220..7418c7ba2c 100644 --- a/packages/ui/tool-ask-user/package.json +++ b/packages/ui/tool-ask-user/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/ui/user-approval/package.json b/packages/ui/user-approval/package.json index 0cc005cfa1..5b5c43cb42 100644 --- a/packages/ui/user-approval/package.json +++ b/packages/ui/user-approval/package.json @@ -26,9 +26,7 @@ "lib/index.js", "lib/invariant.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/ui/user-interaction/package.json b/packages/ui/user-interaction/package.json index 07522d1d1a..5bed79e075 100644 --- a/packages/ui/user-interaction/package.json +++ b/packages/ui/user-interaction/package.json @@ -26,9 +26,7 @@ "lib/index.js", "lib/invariant.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/util/atomic-write/package.json b/packages/util/atomic-write/package.json index 147ecb1e05..00de333170 100644 --- a/packages/util/atomic-write/package.json +++ b/packages/util/atomic-write/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/util/brand/package.json b/packages/util/brand/package.json index 51ce4d795d..83676d1d5c 100644 --- a/packages/util/brand/package.json +++ b/packages/util/brand/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/util/native-command/package.json b/packages/util/native-command/package.json index a20e61c33d..d282a128f7 100644 --- a/packages/util/native-command/package.json +++ b/packages/util/native-command/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/util/paths/package.json b/packages/util/paths/package.json index 601a2941b2..cece1ce79e 100644 --- a/packages/util/paths/package.json +++ b/packages/util/paths/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/util/retention/package.json b/packages/util/retention/package.json index 312bebd624..80af828263 100644 --- a/packages/util/retention/package.json +++ b/packages/util/retention/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/util/timeout/package.json b/packages/util/timeout/package.json index 615d21759e..853cee4d79 100644 --- a/packages/util/timeout/package.json +++ b/packages/util/timeout/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/web/tool-web/package.json b/packages/web/tool-web/package.json index 9e1a54b6cd..d5d655b2dd 100644 --- a/packages/web/tool-web/package.json +++ b/packages/web/tool-web/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/web/web-fetch-local/package.json b/packages/web/web-fetch-local/package.json index 5c42bfa09c..e6dc5791d9 100644 --- a/packages/web/web-fetch-local/package.json +++ b/packages/web/web-fetch-local/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/web/web-search-deepseek/package.json b/packages/web/web-search-deepseek/package.json index e1dbf720f6..546ca2ec87 100644 --- a/packages/web/web-search-deepseek/package.json +++ b/packages/web/web-search-deepseek/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/web/web-search-exa/package.json b/packages/web/web-search-exa/package.json index 7d6b802d2e..7bffc94331 100644 --- a/packages/web/web-search-exa/package.json +++ b/packages/web/web-search-exa/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/web/web-search-perplexity/package.json b/packages/web/web-search-perplexity/package.json index 9aa7080431..a6f6a6bea6 100644 --- a/packages/web/web-search-perplexity/package.json +++ b/packages/web/web-search-perplexity/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/web/web/package.json b/packages/web/web/package.json index 7b732ec819..4ea964ee93 100644 --- a/packages/web/web/package.json +++ b/packages/web/web/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/workflow/tool-ralph/package.json b/packages/workflow/tool-ralph/package.json index fb12147445..83bf0d057c 100644 --- a/packages/workflow/tool-ralph/package.json +++ b/packages/workflow/tool-ralph/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/workflow/tool-workflow/package.json b/packages/workflow/tool-workflow/package.json index dd055ad9ec..705e4f6e08 100644 --- a/packages/workflow/tool-workflow/package.json +++ b/packages/workflow/tool-workflow/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/workflow/workflow-workerthread/package.json b/packages/workflow/workflow-workerthread/package.json index a915229449..e019bcaa51 100644 --- a/packages/workflow/workflow-workerthread/package.json +++ b/packages/workflow/workflow-workerthread/package.json @@ -26,9 +26,7 @@ "lib/index.js", "lib/invariant.js", "lib/worker.cjs", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/workflow/workflow/package.json b/packages/workflow/workflow/package.json index b8ce1ebc7f..53ef7e6f6e 100644 --- a/packages/workflow/workflow/package.json +++ b/packages/workflow/workflow/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/workspace/workspace/package.json b/packages/workspace/workspace/package.json index 8ef0c34382..7839b29702 100644 --- a/packages/workspace/workspace/package.json +++ b/packages/workspace/workspace/package.json @@ -26,9 +26,7 @@ "lib/index.js", "lib/invariant.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dfd4f4e1eb..f56f2dd154 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -146,6 +146,9 @@ importers: '@deepseek-ai/dsh-app-boot': specifier: workspace:^ version: link:../../packages/ui/app-boot + '@deepseek-ai/dsh-bash-env': + specifier: workspace:^ + version: link:../../packages/bash/bash-env '@deepseek-ai/dsh-bash-local': specifier: workspace:^ version: link:../../packages/bash/bash-local @@ -305,6 +308,9 @@ importers: '@deepseek-ai/dsh-pty-local': specifier: workspace:^ version: link:../../packages/pty/pty-local + '@deepseek-ai/dsh-pwsh-local': + specifier: workspace:^ + version: link:../../packages/bash/pwsh-local '@deepseek-ai/dsh-repeat-tool-guard': specifier: workspace:^ version: link:../../packages/guard/repeat-tool-guard @@ -416,6 +422,9 @@ importers: '@deepseek-ai/dsh-tool-goal': specifier: workspace:^ version: link:../../packages/goal/tool-goal + '@deepseek-ai/dsh-tool-pwsh': + specifier: workspace:^ + version: link:../../packages/bash/tool-pwsh '@deepseek-ai/dsh-tool-ralph': specifier: workspace:^ version: link:../../packages/workflow/tool-ralph @@ -479,6 +488,9 @@ importers: js-yaml: specifier: ^4.2.0 version: 4.2.0 + node-addon-require-builtin: + specifier: ^0.1.4 + version: 0.1.4 devDependencies: '@types/js-yaml': specifier: ^4.0.9 @@ -565,6 +577,9 @@ importers: '@deepseek-ai/dsh-bash': specifier: workspace:* version: link:../packages/bash/bash + '@deepseek-ai/dsh-bash-env': + specifier: workspace:* + version: link:../packages/bash/bash-env '@deepseek-ai/dsh-bash-local': specifier: workspace:* version: link:../packages/bash/bash-local @@ -649,6 +664,9 @@ importers: '@deepseek-ai/dsh-pty-local': specifier: workspace:* version: link:../packages/pty/pty-local + '@deepseek-ai/dsh-pwsh-local': + specifier: workspace:* + version: link:../packages/bash/pwsh-local '@deepseek-ai/dsh-repeat-tool-guard': specifier: workspace:* version: link:../packages/guard/repeat-tool-guard @@ -766,6 +784,9 @@ importers: '@deepseek-ai/dsh-tool-pty': specifier: workspace:* version: link:../packages/pty/tool-pty + '@deepseek-ai/dsh-tool-pwsh': + specifier: workspace:* + version: link:../packages/bash/tool-pwsh '@deepseek-ai/dsh-tool-ralph': specifier: workspace:* version: link:../packages/workflow/tool-ralph @@ -873,6 +894,37 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + packages/bash/bash-env: + dependencies: + schemastery: + specifier: ^3.18.0 + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-bash': + specifier: workspace:^ + version: link:../bash + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-paths': + specifier: workspace:^ + version: link:../../util/paths + '@deepseek-ai/dsh-session-persistence': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/bash/bash-local: dependencies: schemastery: @@ -928,6 +980,31 @@ importers: specifier: 0.0.0-test.0 version: 0.0.0-test.0 + packages/bash/pwsh-local: + dependencies: + schemastery: + specifier: ^3.18.0 + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/dsh-bash': + specifier: workspace:^ + version: link:../bash + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-subprocess': + specifier: workspace:^ + version: link:../../subprocess/subprocess + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/bash/tool-bash: dependencies: schemastery: @@ -946,6 +1023,9 @@ importers: '@deepseek-ai/dsh-bash': specifier: workspace:^ version: link:../bash + '@deepseek-ai/dsh-bash-env': + specifier: workspace:^ + version: link:../bash-env '@deepseek-ai/dsh-bash-local': specifier: workspace:^ version: link:../bash-local @@ -955,9 +1035,6 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm - '@deepseek-ai/dsh-paths': - specifier: workspace:^ - version: link:../../util/paths '@deepseek-ai/dsh-sandbox': specifier: workspace:^ version: link:../../sandbox/sandbox @@ -967,9 +1044,6 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session - '@deepseek-ai/dsh-session-persistence': - specifier: workspace:^ - version: link:../../session-persistence/session-persistence '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session-persistence/session-persistence-jsonl @@ -998,6 +1072,55 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + packages/bash/tool-pwsh: + dependencies: + schemastery: + specifier: ^3.18.0 + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-bash': + specifier: workspace:^ + version: link:../bash + '@deepseek-ai/dsh-bash-env': + specifier: workspace:^ + version: link:../bash-env + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-loader-smoke': + specifier: workspace:^ + version: link:../../support/loader-smoke + '@deepseek-ai/dsh-pwsh-local': + specifier: workspace:^ + version: link:../pwsh-local + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tasks': + specifier: workspace:^ + version: link:../../tasks/tasks + '@deepseek-ai/dsh-tasks-local': + specifier: workspace:^ + version: link:../../tasks/tasks-local + '@deepseek-ai/dsh-tool-tasks': + specifier: workspace:^ + version: link:../../tasks/tool-tasks + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/client/connection: dependencies: '@deepseek-ai/dsh-commands': @@ -2692,6 +2815,9 @@ importers: '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop + '@deepseek-ai/dsh-bash-env': + specifier: workspace:^ + version: link:../../bash/bash-env '@deepseek-ai/dsh-bash-local': specifier: workspace:^ version: link:../../bash/bash-local @@ -3520,6 +3646,9 @@ importers: '@deepseek-ai/dsh-native-command': specifier: workspace:^ version: link:../../util/native-command + koffi: + specifier: ^3.1.0 + version: 3.1.1 devDependencies: '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ @@ -3542,6 +3671,9 @@ importers: react: specifier: ^18.2.0 version: 18.3.1 + tsx: + specifier: ^4.19.2 + version: 4.22.4 packages/host/webserver: dependencies: @@ -4178,8 +4310,8 @@ importers: specifier: ^15.0.0 version: 15.0.0 node-addon-require-builtin: - specifier: ^0.1.3 - version: 0.1.3 + specifier: ^0.1.4 + version: 0.1.4 devDependencies: '@deepseek-ai/dsh-app-boot': specifier: workspace:^ @@ -6277,6 +6409,9 @@ importers: '@deepseek-ai/dsh-bash': specifier: workspace:^ version: link:../../packages/bash/bash + '@deepseek-ai/dsh-bash-env': + specifier: workspace:^ + version: link:../../packages/bash/bash-env '@deepseek-ai/dsh-bash-local': specifier: workspace:^ version: link:../../packages/bash/bash-local @@ -6632,8 +6767,8 @@ importers: specifier: ^1.8.1 version: link:../cosmokit node-addon-require-builtin: - specifier: ^0.1.3 - version: 0.1.3 + specifier: ^0.1.4 + version: 0.1.4 pnpm: specifier: 11.7.0 version: 11.7.0 @@ -10853,56 +10988,56 @@ packages: resolution: {integrity: sha512-c5qopltRonjW6+VinXYMp4FVi9Sxf8eQEb/9G82EksQQ+JC4CDprv+ko5URWmtyZ3wD4GFdeRTyw1AG5yCwJhQ==} engines: {node: '>=20'} - node-addon-native-custom-loader@0.1.3: - resolution: {integrity: sha512-uMG8D3aOtEMgh7dkNWAJP0fSpmpMwUf6Cj5JePQQqtxt72sW7RDzRetaLjKIKl3+DZtBX2FobAgRS6U3LO2qXQ==} + node-addon-native-custom-loader@0.1.4: + resolution: {integrity: sha512-DreegO6EoC1JHWYBv3j8Miwp2Zl/CyBeNyoeyCbnEdjyYFEulR4Gcb3wj9fXF7KMDY0ZJ5MWwHcXP8GVNyScnA==} engines: {node: '>=20'} - node-addon-require-builtin-darwin-arm64@0.1.3: - resolution: {integrity: sha512-uBZIRpq3gVG/lg4SV1w8xNfgoaAWZiv7B8Gn38/wd0uUE0LSTRikY69al3Yb4gMDmJPWBoXPN3gzTxAjhllzGg==} + node-addon-require-builtin-darwin-arm64@0.1.4: + resolution: {integrity: sha512-pqiTPbqlDKRIo8YKoWMjuFge4kyHbsdYkAcGW5MAVcJSCyU0M1hhpiLdWlvX753XKL3xlGeuPmv2NqTNRV0/hw==} engines: {node: '>=20'} cpu: [arm64] os: [darwin] - node-addon-require-builtin-darwin-x64@0.1.3: - resolution: {integrity: sha512-BLaBoaBjI7mpsgTpXvn444vCQSmrb1AC2t0tAHFuxwBtN56UT9Zaxl+gS2KZGrq5fShTPxFAUIiTcwroFXWWhg==} + node-addon-require-builtin-darwin-x64@0.1.4: + resolution: {integrity: sha512-i9oThh+w6d+H79YQuc3d3MZCx4YZ6aMWlQ0HYMgOTWvSRzppXmksa/xPV8O20G1gjpq5do/9/hgImixj3XIcjQ==} engines: {node: '>=20'} cpu: [x64] os: [darwin] - node-addon-require-builtin-linux-arm64-gnu@0.1.3: - resolution: {integrity: sha512-L+qNUfBarYxE0HSZjf2KGymS6ZKMieLs5esRXbAbO+q1k4L1t9oBNGpQuFe7/a1a98YrfHnkAg9Q6US5j/xnIw==} + node-addon-require-builtin-linux-arm64-gnu@0.1.4: + resolution: {integrity: sha512-qbmYtkiIFp7h1ZvYYHH0MGFQbc03OyvplkeS90j+t3VMomkRc/YwZ140WzVM3eYJYZ7XHq+s1GL5ZdFQFPUXBQ==} engines: {node: '>=20'} cpu: [arm64] os: [linux] libc: [glibc] - node-addon-require-builtin-linux-x64-gnu@0.1.3: - resolution: {integrity: sha512-Cy2ua4yy44GE5HAtf/o4LjzTa5aUJt5m0YLMjZCa8lRte5hU+C7aWm6bVkmKY82b1JnnQnHwUMEoFugLqVybSQ==} + node-addon-require-builtin-linux-x64-gnu@0.1.4: + resolution: {integrity: sha512-4jC617+yOrYYuKgNmN2KMD722G6BICpjEzAcmzA3j5tt+zmey5M/c/z9JxqdjnNmU9CQWseBaJu/fGdbHZXSOg==} engines: {node: '>=20'} cpu: [x64] os: [linux] libc: [glibc] - node-addon-require-builtin-win32-arm64-msvc@0.1.3: - resolution: {integrity: sha512-8j/VcAmgT6HPQzwUo1kBNzLE2d5iVmwfraEre5KAoznuBeOiuU12oqDYpkuHGIzSjSDJiVOj/SqOe5mUMRaZOg==} + node-addon-require-builtin-win32-arm64-msvc@0.1.4: + resolution: {integrity: sha512-4TW96aPR108R3RxJbUNLXU6FLTZ7n8fWtSpPbPtvYWXa9cXojvCMdH4BvrHM6W2M+Iu42nqMxRyylP3PeTjOmA==} engines: {node: '>=20'} cpu: [arm64] os: [win32] - node-addon-require-builtin-win32-ia32-msvc@0.1.3: - resolution: {integrity: sha512-Iqh+Wxmbu6SaP2lEJpEpIMkusVZeVljn914CIcz7HZtzvxTgxHZAmfsZuRMDtRhDg2Yf4AFBHLWpcJn7SeBQ/A==} + node-addon-require-builtin-win32-ia32-msvc@0.1.4: + resolution: {integrity: sha512-a3ZRkiMKaE7uRjI+H+Ic7dvhPIk0rW9mHV47IEiqJzoRZqnDp5JPjQfAnngu979HR59Ghy+mfexJ/kStBlP3eQ==} engines: {node: '>=20 <23'} cpu: [ia32] os: [win32] - node-addon-require-builtin-win32-x64-msvc@0.1.3: - resolution: {integrity: sha512-5iI7C/BwwRemDNKXO2b1J/iK1gTRp1278Cwfoy92zgn7KXhv7xsAP8klk/fDu8RWX/o9zk736tRSFTBBGSHf/Q==} + node-addon-require-builtin-win32-x64-msvc@0.1.4: + resolution: {integrity: sha512-EGx7AcJKB7fNxo/YHV32JTUg1Hetbdnh6uPaqPhklhyRSMM13/7hZK1pTACqMS9dwnR3Lakxl9HKG9/eAoIyyg==} engines: {node: '>=20'} cpu: [x64] os: [win32] - node-addon-require-builtin@0.1.3: - resolution: {integrity: sha512-u9ZRdwDCx+ksIcYwoLeoe5Rj3151GrzSF8ln9jp7P/Zhf0OrPs1X6a8wYw6brc532ypAwjwKlhruT/V3m8MCbg==} + node-addon-require-builtin@0.1.4: + resolution: {integrity: sha512-yuXz43GmtQyMrO75u2Z8KZAafMhnMH8RTOZBJWGDU9HoD2QxT6q4PF28iLNm/OS9BkS8MHwCKpgTk3d6qW584A==} engines: {node: '>=20'} node-domexception@1.0.0: @@ -16305,54 +16440,54 @@ snapshots: node-addon-landlock-run-linux-arm64: 0.0.0-test.0 node-addon-landlock-run-linux-x64: 0.0.0-test.0 - node-addon-native-custom-loader@0.1.3: {} + node-addon-native-custom-loader@0.1.4: {} - node-addon-require-builtin-darwin-arm64@0.1.3: + node-addon-require-builtin-darwin-arm64@0.1.4: dependencies: - node-addon-native-custom-loader: 0.1.3 + node-addon-native-custom-loader: 0.1.4 optional: true - node-addon-require-builtin-darwin-x64@0.1.3: + node-addon-require-builtin-darwin-x64@0.1.4: dependencies: - node-addon-native-custom-loader: 0.1.3 + node-addon-native-custom-loader: 0.1.4 optional: true - node-addon-require-builtin-linux-arm64-gnu@0.1.3: + node-addon-require-builtin-linux-arm64-gnu@0.1.4: dependencies: - node-addon-native-custom-loader: 0.1.3 + node-addon-native-custom-loader: 0.1.4 optional: true - node-addon-require-builtin-linux-x64-gnu@0.1.3: + node-addon-require-builtin-linux-x64-gnu@0.1.4: dependencies: - node-addon-native-custom-loader: 0.1.3 + node-addon-native-custom-loader: 0.1.4 optional: true - node-addon-require-builtin-win32-arm64-msvc@0.1.3: + node-addon-require-builtin-win32-arm64-msvc@0.1.4: dependencies: - node-addon-native-custom-loader: 0.1.3 + node-addon-native-custom-loader: 0.1.4 optional: true - node-addon-require-builtin-win32-ia32-msvc@0.1.3: + node-addon-require-builtin-win32-ia32-msvc@0.1.4: dependencies: - node-addon-native-custom-loader: 0.1.3 + node-addon-native-custom-loader: 0.1.4 optional: true - node-addon-require-builtin-win32-x64-msvc@0.1.3: + node-addon-require-builtin-win32-x64-msvc@0.1.4: dependencies: - node-addon-native-custom-loader: 0.1.3 + node-addon-native-custom-loader: 0.1.4 optional: true - node-addon-require-builtin@0.1.3: + node-addon-require-builtin@0.1.4: dependencies: - node-addon-native-custom-loader: 0.1.3 + node-addon-native-custom-loader: 0.1.4 optionalDependencies: - node-addon-require-builtin-darwin-arm64: 0.1.3 - node-addon-require-builtin-darwin-x64: 0.1.3 - node-addon-require-builtin-linux-arm64-gnu: 0.1.3 - node-addon-require-builtin-linux-x64-gnu: 0.1.3 - node-addon-require-builtin-win32-arm64-msvc: 0.1.3 - node-addon-require-builtin-win32-ia32-msvc: 0.1.3 - node-addon-require-builtin-win32-x64-msvc: 0.1.3 + node-addon-require-builtin-darwin-arm64: 0.1.4 + node-addon-require-builtin-darwin-x64: 0.1.4 + node-addon-require-builtin-linux-arm64-gnu: 0.1.4 + node-addon-require-builtin-linux-x64-gnu: 0.1.4 + node-addon-require-builtin-win32-arm64-msvc: 0.1.4 + node-addon-require-builtin-win32-ia32-msvc: 0.1.4 + node-addon-require-builtin-win32-x64-msvc: 0.1.4 node-domexception@1.0.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index df156af96a..fec185b114 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -61,6 +61,15 @@ minimumReleaseAgeExclude: # Fresh pi-ai releases carry the model catalog updates that are the whole # point of bumping it; waiting out the release age would defeat that. - '@earendil-works/pi-ai@0.82.1' + - node-addon-native-custom-loader@0.1.4 + - node-addon-require-builtin-darwin-arm64@0.1.4 + - node-addon-require-builtin-darwin-x64@0.1.4 + - node-addon-require-builtin-linux-arm64-gnu@0.1.4 + - node-addon-require-builtin-linux-x64-gnu@0.1.4 + - node-addon-require-builtin-win32-arm64-msvc@0.1.4 + - node-addon-require-builtin-win32-ia32-msvc@0.1.4 + - node-addon-require-builtin-win32-x64-msvc@0.1.4 + - node-addon-require-builtin@0.1.4 patchedDependencies: node-pty@1.1.0: patches/node-pty@1.1.0.patch diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index a4d555055d..4fd7291633 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -14,6 +14,7 @@ "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-bash": "workspace:^", + "@deepseek-ai/dsh-bash-env": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-code-runtime": "workspace:^", diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 0a664ca988..6bd613c2ea 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -7,6 +7,7 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs' import { join, relative, resolve } from 'node:path' +import { isForbiddenPublicationFile } from './publication-payload.ts' const root = resolve(import.meta.dirname, '..') // vendor/* is single-level; packages// nests one level deeper @@ -14,6 +15,7 @@ const root = resolve(import.meta.dirname, '..') const workspaceGlobs = [ { dir: 'vendor', depth: 1 }, { dir: 'packages', depth: 2 }, + { dir: 'apps', depth: 1 }, ] as const const vendoredPackages = new Set([ 'cordis', @@ -28,6 +30,10 @@ const vendoredPackages = new Set([ ]) const localArtifactDirs = new Set(['node_modules']) +const appPackageFiles: Readonly> = { + '@deepseek-ai/dsh': ['lib/*.js', 'config'], + '@deepseek-ai/dsh-frontend': ['dist'], +} /** The subset of package.json fields this constraint check cares about. */ interface PackageManifest { @@ -96,7 +102,9 @@ function workspaceManifests(): WorkspaceManifest[] { } const packageFileExtras: Readonly> = { + '@deepseek-ai/dsh-client-ui-theme': ['lib/styles'], '@deepseek-ai/dsh-helper': ['lib/assets'], + '@deepseek-ai/dsh-pty-local': ['scripts/ensure-spawn-helper.mjs'], '@deepseek-ai/dsh-scripts': [ 'lib/dev/tsdown-config.js', 'lib/local-plugin-loader-hooks.js', @@ -133,8 +141,6 @@ function expectedDshPackageFiles(manifest: PackageManifest): readonly string[] { // declarations. ...usesEmittedTreeDefaults(manifest) ? ['lib/types/**/*.js'] : [], 'lib/types/**/*.d.ts', - 'lib/types/**/*.d.ts.map', - 'src', ] } @@ -164,7 +170,24 @@ function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] { return errors } - if (manifest.name?.startsWith('@deepseek-ai/dsh-') && manifest.name !== '@deepseek-ai/dsh-root') { + if (manifest.name?.startsWith('@deepseek-ai/')) { + for (const file of manifest.files ?? []) { + if (isForbiddenPublicationFile(file)) { + errors.push(`${label}: package.json files must not publish ${JSON.stringify(file)}`) + } + } + } + + if (dir.startsWith('apps/') && manifest.name?.startsWith('@deepseek-ai/')) { + const expectedFiles = appPackageFiles[manifest.name] + if (expectedFiles === undefined) { + errors.push(`${label}: app package has no publication files policy`) + } else if (!sameStringList(manifest.files, expectedFiles)) { + errors.push(`${label}: package.json files must be ${JSON.stringify(expectedFiles)}`) + } + } + + if (dir.startsWith('packages/') && manifest.name?.startsWith('@deepseek-ai/dsh-')) { const peer = manifest.peerDependencies?.cordis const dev = manifest.devDependencies?.cordis diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index 5c88ab3fc2..9e8d6076ec 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -4,7 +4,7 @@ "docs/architecture.md": 2160, "docs/cordis-primer.md": 600, "docs/defensive-patterns.md": 550, - "docs/testing.md": 1100, + "docs/testing.md": 1150, "examples/AGENTS.md": 310, "packages/AGENTS.md": 675, "packages/README.md": 920 diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 36677e120e..b6f2f42152 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -328,16 +328,17 @@ const SERVICE_ROLES: ServiceRole[] = [ pkg: 'bash', title: 'Bash executor seam', mode: 'seam', - implementations: ['bash-local', 'bash-sandbox'], - consumers: ['tool-bash', 'hooks-claude', 'hooks-codex'], - note: 'The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors replace bash-local without touching them.', + implementations: ['bash-local', 'bash-sandbox', 'pwsh-local'], + consumers: ['tool-bash', 'tool-pwsh', 'hooks-claude', 'hooks-codex'], + note: 'The model-facing shell tools and hook bridges consume this seam; sandboxed, remote, or PowerShell executors replace bash-local without touching them.', }, { key: 'bashEnv', - pkg: 'tool-bash', + pkg: 'bash-env', title: 'Managed bash environment registry', mode: 'core', - note: 'Plugins declare effect-scoped DSH_* facts; tool-bash collects one trusted snapshot per execution and the executor rebuilds the namespace.', + consumers: ['tool-bash', 'tool-pwsh'], + note: 'Plugins declare effect-scoped DSH_* facts; each shell tool collects one trusted snapshot per execution and its executor rebuilds the namespace.', }, { key: 'pty', diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 1d214b6e8e..88e07f697c 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -19,6 +19,8 @@ import GoalService from '@deepseek-ai/dsh-goal' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools' import LocalBashExecutor from '@deepseek-ai/dsh-bash-local' +import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env' +import { PwshLocalExecutor } from '@deepseek-ai/dsh-pwsh-local' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' @@ -36,6 +38,7 @@ import * as SkillLocal from '@deepseek-ai/dsh-skill-local' import LocalTaskService from '@deepseek-ai/dsh-tasks-local' import * as ToolAskUser from '@deepseek-ai/dsh-tool-ask-user' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' +import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh' import * as ToolBashPersistent from '@deepseek-ai/dsh-tool-bash-persistent' import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' @@ -190,16 +193,35 @@ const TOOL_PACKAGES: ToolPackage[] = [ pkg: '@deepseek-ai/dsh-tool-bash', dir: 'tool-bash', source: 'packages/bash/tool-bash/src/index.ts', - requires: ['ctx.tools', 'ctx.bash', 'ctx.tasks at call time for run_in_background'], + requires: ['ctx.tools', 'ctx.bash', 'ctx.systemPrompt', 'ctx.bashEnv', 'ctx.tasks at call time for run_in_background'], writes: ['tool/call', 'tool/result'], async mount(ctx) { await ctx.plugin(LocalSubprocessService) + await ctx.plugin(BashEnvPlugin) await ctx.plugin(LocalBashExecutor) await ctx.plugin(ToolBash) }, note: 'The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled.', }, + { + pkg: '@deepseek-ai/dsh-tool-pwsh', + dir: 'tool-pwsh', + source: 'packages/bash/tool-pwsh/src/index.ts', + requires: ['ctx.tools', 'ctx.bash', 'ctx.systemPrompt', 'ctx.bashEnv', 'ctx.tasks at call time for run_in_background'], + writes: ['tool/call', 'tool/result'], + async mount(ctx) { + // The pwsh tool consumes the bash executor seam; the schema harvest + // mounts the pwsh-local implementation so the inject resolves without + // executing anything (registration never spawns a process). + await ctx.plugin(LocalSubprocessService) + await ctx.plugin(BashEnvPlugin) + await ctx.plugin(PwshLocalExecutor) + await ctx.plugin(ToolPwsh) + }, + note: + 'The pwsh tool is the PowerShell-dialect consumer of the bash executor seam for Windows compositions (a PowerShell executor such as `@deepseek-ai/dsh-pwsh-local` backs `ctx.bash`); it mirrors the bash tool call-for-call minus the sandbox surface — `run_in_background` runs register with the generic `ctx.tasks` runtime and are collected/stopped through the `task_*` tools, and the managed `DSH_*` environment comes from `@deepseek-ai/dsh-bash-env`. Each call runs in a fresh process (no persistent PTY session; ConPTY is roadmap work), with native `C:\\...` paths and `$env:NAME` variables.', + }, { pkg: '@deepseek-ai/dsh-tool-cordis', dir: 'tool-cordis', diff --git a/scripts/package-invariants.spec.ts b/scripts/package-invariants.spec.ts index 787202c9d1..32705c4c87 100644 --- a/scripts/package-invariants.spec.ts +++ b/scripts/package-invariants.spec.ts @@ -47,7 +47,7 @@ function fixture(options: { default: './lib/invariant.js', }, }, - files: ['lib/index.js', 'lib/invariant.js', 'src'], + files: ['lib/index.js', 'lib/invariant.js'], peerDependencies: options.invariantDependency === false ? {} : { '@deepseek-ai/dsh-invariants': '^0.0.1', }, diff --git a/scripts/publication-payload.spec.ts b/scripts/publication-payload.spec.ts new file mode 100644 index 0000000000..0f403bee7c --- /dev/null +++ b/scripts/publication-payload.spec.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' +import { isForbiddenPublicationFile, validateTarballPayload } from './publication-payload.ts' + +function validateFixtureTarball(files: readonly string[]): () => void { + return () => { + validateTarballPayload(files, 'fixture.tgz') + } +} + +describe('publication payload policy', () => { + it.each([ + 'lib/index.js', + 'lib/types/index.d.ts', + 'lib/styles/base.css', + ])('accepts %s', (file) => { + expect(isForbiddenPublicationFile(file)).toBe(false) + }) + + it.each([ + 'src', + './src', + 'src/', + 'src/index.ts', + './src/index.ts', + String.raw`src\index.ts`, + 'lib/types/index.d.ts.map', + './lib/types/index.d.ts.map', + ])('rejects static manifest path %s', (file) => { + expect(isForbiddenPublicationFile(file)).toBe(true) + }) + + it('rejects source members in packed tarballs', () => { + expect(validateFixtureTarball([ + 'package/package.json', + 'package/src/index.ts', + ])).toThrow('fixture.tgz publishes source file package/src/index.ts') + }) + + it('rejects declaration maps in packed tarballs', () => { + expect(validateFixtureTarball([ + 'package/package.json', + 'package/lib/types/index.d.ts.map', + ])).toThrow('fixture.tgz publishes declaration map package/lib/types/index.d.ts.map') + }) + + it('accepts a clean packed tarball', () => { + expect(validateFixtureTarball([ + 'package/package.json', + 'package/lib/index.js', + 'package/lib/types/index.d.ts', + 'package/lib/styles/base.css', + ])).not.toThrow() + }) +}) diff --git a/scripts/publication-payload.ts b/scripts/publication-payload.ts new file mode 100644 index 0000000000..9c16067fe7 --- /dev/null +++ b/scripts/publication-payload.ts @@ -0,0 +1,27 @@ +/** Publication payload policy shared by static manifests and packed tarballs. */ + +/** Normalize a package manifest path or npm tarball member to its payload-relative path. */ +function payloadPath(file: string): string { + const normalized = file.replaceAll('\\', '/').replace(/^\.\/+/, '').replace(/\/+$/, '') + return normalized.startsWith('package/') ? normalized.slice('package/'.length) : normalized +} + +/** Whether a package payload path exposes source or declaration-map intermediates. */ +export function isForbiddenPublicationFile(file: string): boolean { + const normalized = payloadPath(file) + return normalized === 'src' + || normalized.startsWith('src/') + || normalized.endsWith('.d.ts.map') +} + +/** Reject source and declaration-map members in a packed npm tarball. */ +export function validateTarballPayload(files: readonly string[], context: string): void { + for (const file of files) { + if (!isForbiddenPublicationFile(file)) continue + const normalized = payloadPath(file) + if (normalized === 'src' || normalized.startsWith('src/')) { + throw new Error(`${context} publishes source file ${file}`) + } + throw new Error(`${context} publishes declaration map ${file}`) + } +} diff --git a/scripts/publish-npm-baseline.ts b/scripts/publish-npm-baseline.ts new file mode 100644 index 0000000000..92df711f3e --- /dev/null +++ b/scripts/publish-npm-baseline.ts @@ -0,0 +1,1084 @@ +/** Build, publish, and verify one commit-addressed npm workspace baseline. */ + +import { spawnSync, type SpawnSyncReturns } from 'node:child_process' +import { createHash } from 'node:crypto' +import { + existsSync, + globSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + readdirSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, dirname, isAbsolute, join, normalize, relative, resolve, sep } from 'node:path' +import { createInterface } from 'node:readline/promises' +import { pathToFileURL } from 'node:url' +import { parseArgs } from 'node:util' +import { validateTarballPayload } from './publication-payload.ts' + +const DEFAULT_REGISTRY = 'https://registry.npm.harnessment.com' +const DEFAULT_OUTPUT_DIRECTORY = '.artifacts/npm-baseline' +const PACKAGE_PATTERNS = [ + 'vendor/*/package.json', + 'packages/*/*/package.json', + 'apps/*/package.json', +] as const +const DEPENDENCY_SECTIONS = [ + 'dependencies', + 'devDependencies', + 'optionalDependencies', + 'peerDependencies', +] as const +const RELEASE_MANIFEST_NAME = 'manifest.json' +const RELEASE_ENTRY_PACKAGE = '@deepseek-ai/dsh' +const LATEST_DIST_TAG = 'latest' +const POSIX_WEB_PROBE = String.raw` +import errno, os, pty, select, signal, sys, time +node, bin_path, cwd, timeout_seconds = sys.argv[1:] +pid, fd = pty.fork() +if pid == 0: + os.chdir(cwd) + os.execvpe(node, [node, bin_path, "web", "--host", "127.0.0.1", "--port", "0"], os.environ.copy()) + +output = bytearray() +ready_seen = False +termination_sent = False +deadline = time.monotonic() + float(timeout_seconds) +status = None +while time.monotonic() < deadline: + ready, _, _ = select.select([fd], [], [], 0.05) + if ready: + try: + chunk = os.read(fd, 65536) + except OSError as error: + if error.errno != errno.EIO: + raise + chunk = b"" + if chunk: + output.extend(chunk) + + snapshot = bytes(output) + if not termination_sent and b"dsh web: http://127.0.0.1:" in snapshot: + ready_seen = True + os.kill(pid, signal.SIGTERM) + termination_sent = True + + waited, candidate = os.waitpid(pid, os.WNOHANG) + if waited == pid: + status = candidate + break + +if status is None: + os.kill(pid, signal.SIGKILL) + _, status = os.waitpid(pid, 0) +sys.stdout.buffer.write(output) +if not ready_seen: + sys.stderr.write("installed dsh web did not reach its ready URL\n") + sys.exit(124) +actual_exit = os.waitstatus_to_exitcode(status) +if actual_exit != 0: + sys.stderr.write(f"installed dsh web exited {actual_exit}, expected 0\n") + sys.exit(125) +` + +interface CommandResult { + status: number + stdout: string + stderr: string +} + +interface PackageTarget { + name: string + directory: string + origin: PackageOrigin +} + +type PackageOrigin = 'harness' | 'vendor' + +interface PackedPackage { + name: string + tarball: string + sha256: string + integrity: string + origin: PackageOrigin +} + +interface ReleaseManifest { + schemaVersion: 1 + commit: string + version: string + distTag: string + registry: string + packages: PackedPackage[] +} + +interface PackOptions { + ref: string + registry: string + outputDirectory: string +} + +/** Fixes the identity of one pack attempt before any expensive work begins. */ +class BaselinePackPlan { + constructor( + readonly commit: string, + readonly shortCommit: string, + readonly timestamp: string, + readonly baseVersion: string, + readonly version: string, + readonly distTag: string, + readonly registry: string, + readonly artifactDirectory: string, + ) {} + + async confirm(assumeYes: boolean): Promise { + console.log('publish-npm-baseline: planned pack') + console.log(` commit: ${this.commit}`) + console.log(` timestamp: ${this.timestamp} UTC`) + console.log(` version: ${this.version}`) + console.log(` dist-tag: ${this.distTag}`) + console.log(` registry: ${this.registry}`) + console.log(` output: ${this.artifactDirectory}`) + if (assumeYes) return + await confirmEnter( + 'Press Enter to start packing or type anything to cancel: ', + 'pack requires an interactive terminal or --yes', + 'pack cancelled', + ) + } +} + +/** Runs child processes without involving a command shell. */ +class CommandRunner { + run( + command: string, + args: string[], + cwd: string, + environment: NodeJS.ProcessEnv = process.env, + ): void { + const result = spawnSync(command, args, { cwd, env: environment, stdio: 'inherit' }) + if (result.error !== undefined) throw result.error + if (result.status !== 0) { + throw new Error(`${formatCommand(command, args)} exited with status ${String(result.status)}`) + } + } + + capture( + command: string, + args: string[], + cwd: string, + environment: NodeJS.ProcessEnv = process.env, + ): string { + const result = this.result(command, args, cwd, environment) + if (result.status !== 0) throw commandFailure(command, args, result) + return result.stdout.trim() + } + + result( + command: string, + args: string[], + cwd: string, + environment: NodeJS.ProcessEnv = process.env, + ): CommandResult { + const result: SpawnSyncReturns = spawnSync(command, args, { + cwd, + encoding: 'utf8', + env: environment, + maxBuffer: 16 * 1024 * 1024, + }) + if (result.error !== undefined) throw result.error + return { + status: result.status ?? 1, + stdout: result.stdout, + stderr: result.stderr, + } + } +} + +/** Owns a temporary detached worktree and removes it after staging. */ +class DetachedWorktree { + private constructor( + readonly path: string, + private readonly temporaryRoot: string, + private readonly repositoryRoot: string, + private readonly runner: CommandRunner, + ) {} + + static create(repositoryRoot: string, commit: string, runner: CommandRunner): DetachedWorktree { + const temporaryRoot = mkdtempSync(join(tmpdir(), 'dsh-npm-baseline-')) + const path = join(temporaryRoot, 'worktree') + try { + runner.run('git', ['worktree', 'add', '--detach', path, commit], repositoryRoot) + return new DetachedWorktree(path, temporaryRoot, repositoryRoot, runner) + } catch (error: unknown) { + rmSync(temporaryRoot, { recursive: true, force: true }) + throw error + } + } + + dispose(): void { + const result = this.runner.result( + 'git', + ['worktree', 'remove', '--force', this.path], + this.repositoryRoot, + ) + if (result.status !== 0) { + console.error(`publish-npm-baseline: could not remove worktree ${this.path}`) + if (result.stderr.trim() !== '') console.error(result.stderr.trim()) + } + rmSync(this.temporaryRoot, { recursive: true, force: true }) + } +} + +/** Discovers and stages every package published in one repository baseline. */ +class WorkspacePackageSet { + private constructor( + readonly packages: PackageTarget[], + readonly baseVersion: string, + ) {} + + static discover(root: string): WorkspacePackageSet { + const manifestPaths = globSync(PACKAGE_PATTERNS, { cwd: root }).sort() + if (manifestPaths.length === 0) { + throw new Error('no package manifests found under vendor/, packages/, or apps/') + } + + const packages: PackageTarget[] = [] + const names = new Set() + const baseVersion = expectString(readObject(resolve(root, 'package.json')), 'version', 'package.json') + if (!/^\d+\.\d+\.\d+$/.test(baseVersion)) { + throw new Error(`package.json must have a stable X.Y.Z version, got ${baseVersion}`) + } + for (const manifestPath of manifestPaths) { + const manifest = readObject(resolve(root, manifestPath)) + const name = expectString(manifest, 'name', manifestPath) + const version = expectString(manifest, 'version', manifestPath) + const isVendored = manifestPath.startsWith('vendor/') + if (!isVendored && !name.startsWith('@deepseek-ai/')) { + throw new Error(`${manifestPath} must name an @deepseek-ai package`) + } + if (name === '@deepseek-ai/dsh-root') { + throw new Error(`${manifestPath} unexpectedly selected the workspace root`) + } + if (names.has(name)) throw new Error(`duplicate package name: ${name}`) + if (!isVendored && version !== baseVersion) { + throw new Error(`${manifestPath} has version ${version}; expected ${baseVersion}`) + } + names.add(name) + packages.push({ + name, + directory: dirname(manifestPath), + origin: isVendored ? 'vendor' : 'harness', + }) + } + packages.sort((left, right) => left.name.localeCompare(right.name)) + return new WorkspacePackageSet(packages, baseVersion) + } + + stage(root: string, releaseVersion: string): void { + const internalNames = new Set(this.packages.map(pkg => pkg.name)) + for (const target of this.packages) { + const manifestPath = resolve(root, target.directory, 'package.json') + const manifest = readObject(manifestPath) + manifest.version = releaseVersion + delete manifest.private + stageInternalDependencies(manifest, internalNames, releaseVersion, manifestPath) + writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`) + } + } +} + +/** Immutable local release bundle consumed by publish and verify. */ +class ReleaseBundle { + private constructor( + readonly directory: string, + readonly manifest: ReleaseManifest, + ) {} + + static create( + directory: string, + expectedPackages: PackageTarget[], + commit: string, + version: string, + distTag: string, + registry: string, + runner: CommandRunner, + ): ReleaseBundle { + const internalNames = new Set(expectedPackages.map(pkg => pkg.name)) + const expectedByName = new Map(expectedPackages.map(pkg => [pkg.name, pkg])) + const missingNames = new Set(internalNames) + const packages = readdirSync(directory) + .filter(name => name.endsWith('.tgz')) + .sort() + .map((tarball) => { + const artifact = inspectTarball(resolve(directory, tarball), runner) + const expected = expectedByName.get(artifact.name) + if (expected === undefined || !missingNames.delete(artifact.name)) { + throw new Error(`unexpected or duplicate packed package: ${artifact.name}`) + } + if (expected.origin === 'harness') validateTarballPayload(artifact.files, tarball) + if (artifact.version !== version) { + throw new Error(`${tarball} has version ${artifact.version}; expected ${version}`) + } + if (artifact.private === true) throw new Error(`${tarball} is still private`) + if (containsWorkspaceProtocol(artifact.manifest)) { + throw new Error(`${tarball} still contains a workspace: dependency`) + } + validateInternalDependencyPins(artifact.manifest, internalNames, version, tarball) + return packedPackage(artifact.name, resolve(directory, tarball), expected.origin) + }) + .sort((left, right) => left.name.localeCompare(right.name)) + + if (missingNames.size !== 0) { + throw new Error(`missing tarballs for: ${[...missingNames].sort().join(', ')}`) + } + const manifest: ReleaseManifest = { + schemaVersion: 1, + commit, + version, + distTag, + registry, + packages, + } + writeFileSync(resolve(directory, RELEASE_MANIFEST_NAME), `${JSON.stringify(manifest, null, 2)}\n`) + writeFileSync( + resolve(directory, 'SHA256SUMS'), + `${packages.map(pkg => `${pkg.sha256} ${pkg.tarball}`).join('\n')}\n`, + ) + return new ReleaseBundle(directory, manifest) + } + + static load(manifestPath: string, runner: CommandRunner): ReleaseBundle { + const absoluteManifestPath = resolve(manifestPath) + const raw = readObject(absoluteManifestPath) + if (raw.schemaVersion !== 1) { + throw new Error(`unsupported release manifest schema: ${String(raw.schemaVersion)}`) + } + const directory = dirname(absoluteManifestPath) + const packageValues = raw.packages + if (!Array.isArray(packageValues) || packageValues.length === 0) { + throw new Error('release manifest contains no packages') + } + const packages = packageValues.map((value, index) => parsePackedPackage(value, index)) + const names = new Set() + for (const pkg of packages) { + if (names.has(pkg.name)) throw new Error(`duplicate package in release manifest: ${pkg.name}`) + names.add(pkg.name) + } + const manifest: ReleaseManifest = { + schemaVersion: 1, + commit: expectString(raw, 'commit', RELEASE_MANIFEST_NAME), + version: expectString(raw, 'version', RELEASE_MANIFEST_NAME), + distTag: expectString(raw, 'distTag', RELEASE_MANIFEST_NAME), + registry: normalizeRegistry(expectString(raw, 'registry', RELEASE_MANIFEST_NAME)), + packages, + } + const bundle = new ReleaseBundle(directory, manifest) + bundle.verifyLocal(runner) + return bundle + } + + private verifyLocal(runner: CommandRunner): void { + const internalNames = new Set(this.manifest.packages.map(pkg => pkg.name)) + for (const pkg of this.manifest.packages) { + if (isAbsolute(pkg.tarball) || dirname(pkg.tarball) !== '.' || normalize(pkg.tarball) !== pkg.tarball) { + throw new Error(`invalid tarball path for ${pkg.name}: ${pkg.tarball}`) + } + const path = resolve(this.directory, pkg.tarball) + const actual = packedPackage(pkg.name, path, pkg.origin) + if (actual.sha256 !== pkg.sha256 || actual.integrity !== pkg.integrity) { + throw new Error(`tarball checksum mismatch: ${pkg.tarball}`) + } + const artifact = inspectTarball(path, runner) + if (pkg.origin === 'harness') validateTarballPayload(artifact.files, pkg.tarball) + if (artifact.name !== pkg.name || artifact.version !== this.manifest.version) { + throw new Error(`tarball identity mismatch: ${pkg.tarball}`) + } + if (artifact.private === true) throw new Error(`${pkg.tarball} is still private`) + if (containsWorkspaceProtocol(artifact.manifest)) { + throw new Error(`${pkg.tarball} still contains a workspace: dependency`) + } + validateInternalDependencyPins( + artifact.manifest, + internalNames, + this.manifest.version, + pkg.tarball, + ) + } + } + + tarballPath(pkg: PackedPackage): string { + return resolve(this.directory, pkg.tarball) + } +} + +/** Installs one complete bundle outside the workspace and probes the shipped dsh entry. */ +class InstalledBundleSmoke { + constructor( + private readonly bundle: ReleaseBundle, + private readonly runner: CommandRunner, + ) {} + + run(): void { + const consumerRoot = mkdtempSync(join(tmpdir(), 'dsh-npm-consumer-')) + try { + const dependencies = Object.fromEntries(this.bundle.manifest.packages.map(pkg => [ + pkg.name, + pathToFileURL(this.bundle.tarballPath(pkg)).href, + ])) + writeFileSync(resolve(consumerRoot, 'package.json'), `${JSON.stringify({ + name: 'dsh-npm-baseline-consumer', + version: '0.0.0', + private: true, + dependencies, + }, null, 2)}\n`) + + console.log( + `publish-npm-baseline: installing ${this.bundle.manifest.packages.length} local tarballs`, + ) + this.runner.run('npm', [ + 'install', + '--no-audit', + '--no-fund', + '--package-lock=false', + `--registry=${this.bundle.manifest.registry}`, + ], consumerRoot, npmClientEnvironment()) + + const bin = resolve(consumerRoot, 'node_modules/@deepseek-ai/dsh/lib/bin.js') + assertPathWithin(consumerRoot, bin, 'installed dsh bin') + const environment = installedArtifactEnvironment(consumerRoot) + const version = this.runner.capture( + process.execPath, + [bin, '--version'], + consumerRoot, + environment, + ) + if (version !== this.bundle.manifest.version) { + throw new Error( + `installed dsh --version returned ${JSON.stringify(version)}; ` + + `expected ${this.bundle.manifest.version}`, + ) + } + const config = this.runner.capture( + process.execPath, + [bin, '--dump-default-config'], + consumerRoot, + environment, + ) + if (config === '') throw new Error('installed dsh --dump-default-config returned no output') + this.probeWeb(bin, consumerRoot, environment) + console.log('publish-npm-baseline: installed dsh entry and Web startup probes passed') + } finally { + rmSync(consumerRoot, { recursive: true, force: true }) + } + } + + private probeWeb(bin: string, consumerRoot: string, environment: NodeJS.ProcessEnv): void { + if (process.platform === 'win32') { + throw new Error('installed dsh Web probe requires a POSIX host with python3') + } + const result = this.runner.result( + 'python3', + ['-c', POSIX_WEB_PROBE, process.execPath, bin, consumerRoot, '60'], + consumerRoot, + environment, + ) + if (result.status !== 0) { + throw commandFailure('python3', ['installed-dsh-web-probe'], result) + } + } +} + +/** Builds a release bundle without mutating the caller's checkout. */ +class BaselinePackager { + constructor( + private readonly repositoryRoot: string, + private readonly runner: CommandRunner, + private readonly now: () => Date = () => new Date(), + ) {} + + plan(options: PackOptions): BaselinePackPlan { + const timestamp = formatUtcTimestamp(this.now()) + const registry = normalizeRegistry(options.registry) + const commit = this.runner.capture( + 'git', + ['rev-parse', '--verify', `${options.ref}^{commit}`], + this.repositoryRoot, + ) + const shortCommit = this.runner.capture( + 'git', + ['rev-parse', '--short=10', commit], + this.repositoryRoot, + ) + const rootManifest = parseObject( + this.runner.capture('git', ['show', `${commit}:package.json`], this.repositoryRoot), + `${commit}:package.json`, + ) + const baseVersion = expectString(rootManifest, 'version', `${commit}:package.json`) + validateBaseVersion(baseVersion, `${commit}:package.json`) + const version = `${baseVersion}-${timestamp}-${shortCommit}` + const distTag = `dev-${baseVersion}` + validateDistTag(distTag) + const artifactDirectory = resolve(options.outputDirectory, version) + if (existsSync(artifactDirectory)) { + throw new Error(`output already exists: ${artifactDirectory}`) + } + return new BaselinePackPlan( + commit, + shortCommit, + timestamp, + baseVersion, + version, + distTag, + registry, + artifactDirectory, + ) + } + + pack(plan: BaselinePackPlan): ReleaseBundle { + const { artifactDirectory } = plan + if (existsSync(artifactDirectory)) { + throw new Error(`output already exists: ${artifactDirectory}`) + } + const worktree = DetachedWorktree.create(this.repositoryRoot, plan.commit, this.runner) + let createdArtifactDirectory = false + try { + const packageSet = WorkspacePackageSet.discover(worktree.path) + if (packageSet.baseVersion !== plan.baseVersion) { + throw new Error( + `workspace package version ${packageSet.baseVersion} does not match root version ` + + `${plan.baseVersion} at ${plan.commit}`, + ) + } + + console.log(`publish-npm-baseline: installing detached worktree ${plan.shortCommit}`) + this.runner.run('pnpm', ['install', '--frozen-lockfile'], worktree.path) + this.runner.run('pnpm', ['run', 'constraints'], worktree.path) + packageSet.stage(worktree.path, plan.version) + mkdirSync(artifactDirectory, { recursive: true }) + createdArtifactDirectory = true + + console.log( + `publish-npm-baseline: building ${packageSet.packages.length} packages as ${plan.version}`, + ) + this.runner.run('pnpm', ['run', 'build'], worktree.path) + this.runner.run('pnpm', ['run', 'publint'], worktree.path) + this.runner.run('pnpm', ['run', 'verify-built-package-invariants'], worktree.path) + this.runner.run('pnpm', [ + '--filter', './vendor/**', + '--filter', './packages/**', + '--filter', './apps/**', + '--recursive', + 'pack', + '--pack-destination', artifactDirectory, + ], worktree.path) + + const bundle = ReleaseBundle.create( + artifactDirectory, + packageSet.packages, + plan.commit, + plan.version, + plan.distTag, + plan.registry, + this.runner, + ) + new InstalledBundleSmoke(bundle, this.runner).run() + createdArtifactDirectory = false + console.log(`publish-npm-baseline: packed ${bundle.manifest.packages.length} packages`) + console.log(` version: ${bundle.manifest.version}`) + console.log(` dist-tag: ${bundle.manifest.distTag}`) + console.log(` manifest: ${resolve(bundle.directory, RELEASE_MANIFEST_NAME)}`) + console.log(' publish: ' + formatCopyableCommand('pnpm', [ + '--dir', + this.repositoryRoot, + 'exec', + 'tsx', + resolve(this.repositoryRoot, 'scripts/publish-npm-baseline.ts'), + 'publish', + '--manifest', + resolve(bundle.directory, RELEASE_MANIFEST_NAME), + '--yes', + ])) + return bundle + } finally { + worktree.dispose() + if (createdArtifactDirectory) { + rmSync(artifactDirectory, { recursive: true, force: true }) + } + } + } +} + +/** Publishes and verifies a release bundle against its recorded registry. */ +class RegistryPublication { + private readonly npmEnvironment = npmClientEnvironment() + private readonly npmWorkingDirectory = tmpdir() + + constructor( + private readonly bundle: ReleaseBundle, + private readonly runner: CommandRunner, + ) {} + + async publish(assumeYes: boolean): Promise { + this.pingRegistry() + this.requireIdentity() + if (!assumeYes) await this.confirm() + + for (const pkg of this.bundle.manifest.packages) { + const existingIntegrity = this.remoteIntegrity(pkg.name) + if (existingIntegrity === undefined) { + this.runner.run('npm', [ + 'publish', + this.bundle.tarballPath(pkg), + `--registry=${this.bundle.manifest.registry}`, + `--tag=${this.bundle.manifest.distTag}`, + ], this.npmWorkingDirectory, this.npmEnvironment) + } else { + if (existingIntegrity !== pkg.integrity) { + throw new Error( + `${pkg.name}@${this.bundle.manifest.version} already exists with different integrity`, + ) + } + console.log( + `publish-npm-baseline: already published ${pkg.name}@${this.bundle.manifest.version}`, + ) + } + this.ensureDistTag(pkg.name, this.bundle.manifest.distTag) + } + this.ensureDistTag(RELEASE_ENTRY_PACKAGE, LATEST_DIST_TAG) + this.verifyRemote() + this.verifyReleaseEntryDistTag() + } + + verify(): void { + this.pingRegistry() + this.verifyRemote() + this.verifyReleaseEntryDistTag() + } + + private verifyRemote(): void { + for (const pkg of this.bundle.manifest.packages) { + const integrity = this.remoteIntegrity(pkg.name) + if (integrity === undefined) { + throw new Error(`package is missing: ${pkg.name}@${this.bundle.manifest.version}`) + } + if (integrity !== pkg.integrity) { + throw new Error(`integrity mismatch: ${pkg.name}@${this.bundle.manifest.version}`) + } + const tagVersion = this.remoteDistTag(pkg.name, this.bundle.manifest.distTag) + if (tagVersion !== this.bundle.manifest.version) { + throw new Error( + `${pkg.name}@${this.bundle.manifest.distTag} points to ${tagVersion ?? ''}; ` + + `expected ${this.bundle.manifest.version}`, + ) + } + console.log(`publish-npm-baseline: verified ${pkg.name}@${this.bundle.manifest.version}`) + } + console.log( + `publish-npm-baseline: verified ${this.bundle.manifest.packages.length} packages and ` + + `dist-tag ${this.bundle.manifest.distTag}`, + ) + } + + private verifyReleaseEntryDistTag(): void { + const tagVersion = this.remoteDistTag(RELEASE_ENTRY_PACKAGE, LATEST_DIST_TAG) + if (tagVersion !== this.bundle.manifest.version) { + throw new Error( + `${RELEASE_ENTRY_PACKAGE}@${LATEST_DIST_TAG} points to ${tagVersion ?? ''}; ` + + `expected ${this.bundle.manifest.version}`, + ) + } + console.log( + `publish-npm-baseline: verified ${RELEASE_ENTRY_PACKAGE}@${LATEST_DIST_TAG} at ` + + this.bundle.manifest.version, + ) + } + + private pingRegistry(): void { + const { registry } = this.bundle.manifest + this.runner.capture( + 'npm', ['ping', `--registry=${registry}`], this.npmWorkingDirectory, this.npmEnvironment, + ) + } + + private requireIdentity(): void { + const { registry } = this.bundle.manifest + const identity = this.runner.capture( + 'npm', ['whoami', `--registry=${registry}`], this.npmWorkingDirectory, this.npmEnvironment, + ) + console.log(`publish-npm-baseline: registry identity ${identity} at ${registry}`) + } + + private async confirm(): Promise { + await confirmEnter( + `Publish ${this.bundle.manifest.packages.length} packages as ` + + `${this.bundle.manifest.version} to ${this.bundle.manifest.registry}? ` + + 'Press Enter to continue or type anything to cancel: ', + 'publish requires an interactive terminal or --yes', + 'publication cancelled', + ) + } + + private remoteIntegrity(name: string): string | undefined { + const { registry, version } = this.bundle.manifest + const result = this.runner.result( + 'npm', + ['view', `${name}@${version}`, 'dist.integrity', '--json', `--registry=${registry}`], + this.npmWorkingDirectory, + this.npmEnvironment, + ) + if (result.status !== 0) { + if (/E404|NOT_FOUND|404 Not Found/.test(`${result.stdout}\n${result.stderr}`)) return undefined + throw commandFailure('npm', ['view', `${name}@${version}`], result) + } + const value: unknown = result.stdout.trim() === '' ? undefined : JSON.parse(result.stdout) + if (typeof value !== 'string' || !value.startsWith('sha512-')) { + throw new Error(`registry returned no integrity for ${name}@${version}`) + } + return value + } + + private remoteDistTag(name: string, distTag: string): string | undefined { + const { registry } = this.bundle.manifest + const raw = this.runner.capture( + 'npm', + ['dist-tag', 'ls', name, `--registry=${registry}`], + this.npmWorkingDirectory, + this.npmEnvironment, + ) + return parseDistTagListing(raw, name).get(distTag) + } + + private ensureDistTag(name: string, distTag: string): void { + if (this.remoteDistTag(name, distTag) === this.bundle.manifest.version) return + const { registry, version } = this.bundle.manifest + this.runner.run( + 'npm', + ['dist-tag', 'add', `${name}@${version}`, distTag, `--registry=${registry}`], + this.npmWorkingDirectory, + this.npmEnvironment, + ) + } +} + +interface InspectedTarball { + name: string + version: string + private: unknown + manifest: Record + files: string[] +} + +function inspectTarball(path: string, runner: CommandRunner): InspectedTarball { + const manifest = JSON.parse( + runner.capture('tar', ['-xOf', path, 'package/package.json'], dirname(path)), + ) as unknown + if (!isRecord(manifest)) throw new Error(`${path} contains an invalid package.json`) + return { + name: expectString(manifest, 'name', path), + version: expectString(manifest, 'version', path), + private: manifest.private, + manifest, + files: runner.capture('tar', ['-tf', path], dirname(path)).split(/\r?\n/), + } +} + +function packedPackage(name: string, path: string, origin: PackageOrigin): PackedPackage { + const bytes = readFileSync(path) + return { + name, + tarball: basename(path), + sha256: createHash('sha256').update(bytes).digest('hex'), + integrity: `sha512-${createHash('sha512').update(bytes).digest('base64')}`, + origin, + } +} + +function parsePackedPackage(value: unknown, index: number): PackedPackage { + if (!isRecord(value)) throw new Error(`invalid release manifest package at index ${index}`) + const context = `release manifest package at index ${index}` + const name = expectString(value, 'name', context) + const origin = value.origin === undefined ? 'harness' : value.origin + if (origin !== 'harness' && origin !== 'vendor') { + throw new Error(`invalid package origin in release manifest: ${JSON.stringify(origin)}`) + } + if (origin === 'harness' && (!name.startsWith('@deepseek-ai/') || name === '@deepseek-ai/dsh-root')) { + throw new Error(`invalid package name in release manifest: ${name}`) + } + return { + name, + tarball: expectString(value, 'tarball', context), + sha256: expectString(value, 'sha256', context), + integrity: expectString(value, 'integrity', context), + origin, + } +} + +function containsWorkspaceProtocol(value: unknown): boolean { + if (typeof value === 'string') return value.startsWith('workspace:') + if (Array.isArray(value)) return value.some(containsWorkspaceProtocol) + return isRecord(value) && Object.values(value).some(containsWorkspaceProtocol) +} + +function stageInternalDependencies( + manifest: Record, + internalNames: ReadonlySet, + releaseVersion: string, + context: string, +): void { + for (const { dependencies, name } of internalDependencyEntries(manifest, internalNames, context)) { + dependencies[name] = releaseVersion + } +} + +function validateInternalDependencyPins( + manifest: Record, + internalNames: ReadonlySet, + releaseVersion: string, + context: string, +): void { + for (const { section, name, range } of internalDependencyEntries(manifest, internalNames, context)) { + if (range !== releaseVersion) { + throw new Error( + `${context} has internal ${section} ${name}@${String(range)}; ` + + `expected exact version ${releaseVersion}`, + ) + } + } +} + +function* internalDependencyEntries( + manifest: Record, + internalNames: ReadonlySet, + context: string, +): Generator<{ + section: typeof DEPENDENCY_SECTIONS[number] + dependencies: Record + name: string + range: unknown +}> { + for (const section of DEPENDENCY_SECTIONS) { + const dependencies = manifest[section] + if (dependencies === undefined) continue + if (!isRecord(dependencies)) throw new Error(`${context} ${section} must be an object`) + for (const [name, range] of Object.entries(dependencies)) { + if (!internalNames.has(name)) continue + yield { section, dependencies, name, range } + } + } +} + +function readObject(path: string): Record { + return parseObject(readFileSync(path, 'utf8'), path) +} + +function parseObject(source: string, context: string): Record { + const value: unknown = JSON.parse(source) + if (!isRecord(value)) throw new Error(`${context} must contain a JSON object`) + return value +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +function expectString(value: Record, key: string, context: string): string { + const result = value[key] + if (typeof result !== 'string' || result === '') { + throw new Error(`${context} must contain a non-empty ${key}`) + } + return result +} + +function normalizeRegistry(value: string): string { + const url = new URL(value) + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new Error(`registry must use HTTP or HTTPS: ${value}`) + } + return value.replace(/\/+$/, '') +} + +function npmClientEnvironment(): NodeJS.ProcessEnv { + const environment = { ...process.env } + delete environment.npm_config_user_agent + delete environment.NPM_CONFIG_USER_AGENT + return environment +} + +function installedArtifactEnvironment(consumerRoot: string): NodeJS.ProcessEnv { + const environment = npmClientEnvironment() + delete environment.NODE_OPTIONS + delete environment.NODE_PATH + environment.DSH_HOME = resolve(consumerRoot, '.dsh') + environment.DSH_AGENTS_HOME = resolve(consumerRoot, '.agents') + environment.DSH_TELEMETRY_DISABLED = '1' + environment.DEEPSEEK_API_KEY = 'keyless-installed-web-no-call' + environment.LANG = 'en_US.UTF-8' + environment.LC_ALL = 'en_US.UTF-8' + environment.LC_CTYPE = 'en_US.UTF-8' + environment.TERM = 'xterm-256color' + environment.COLUMNS = '100' + environment.LINES = '30' + delete environment.COLORTERM + return environment +} + +function assertPathWithin(root: string, path: string, label: string): void { + const rootPath = realpathSync.native(root) + const candidate = realpathSync.native(path) + const fromRoot = relative(rootPath, candidate) + if (fromRoot === '..' || fromRoot.startsWith(`..${sep}`) || isAbsolute(fromRoot)) { + throw new Error(`${label} resolved outside the isolated consumer: ${candidate}`) + } +} + +function validateDistTag(value: string): void { + if (value === '' || /\s/.test(value)) throw new Error(`invalid dist-tag: ${JSON.stringify(value)}`) +} + +function validateBaseVersion(value: string, context: string): void { + if (!/^\d+\.\d+\.\d+$/.test(value)) { + throw new Error(`${context} must have a stable X.Y.Z version, got ${value}`) + } +} + +function parseDistTagListing(raw: string, name: string): Map { + const tags = new Map() + for (const line of raw.split(/\r?\n/)) { + if (line === '') continue + const separator = line.indexOf(': ') + if (separator <= 0 || separator + 2 === line.length) { + throw new Error(`registry returned an invalid dist-tag for ${name}: ${line}`) + } + const tag = line.slice(0, separator) + if (tags.has(tag)) throw new Error(`registry returned duplicate dist-tag ${tag} for ${name}`) + tags.set(tag, line.slice(separator + 2)) + } + return tags +} + +async function confirmEnter( + prompt: string, + nonInteractiveError: string, + cancellationError: string, +): Promise { + if (!process.stdin.isTTY || !process.stdout.isTTY) throw new Error(nonInteractiveError) + const readline = createInterface({ input: process.stdin, output: process.stdout }) + try { + const answer = await readline.question(prompt) + if (answer !== '') throw new Error(cancellationError) + } finally { + readline.close() + } +} + +function formatUtcTimestamp(value: Date): string { + if (!Number.isFinite(value.getTime())) throw new Error('pack timestamp must be a valid date') + return value.toISOString().replaceAll(/[-:TZ.]/g, '').slice(0, 14) +} + +function commandFailure(command: string, args: string[], result: CommandResult): Error { + const detail = [result.stdout.trim(), result.stderr.trim()].filter(Boolean).join('\n') + return new Error( + `${formatCommand(command, args)} exited with status ${result.status}${detail === '' ? '' : `\n${detail}`}`, + ) +} + +function formatCommand(command: string, args: string[]): string { + return [command, ...args].map(value => JSON.stringify(value)).join(' ') +} + +function formatCopyableCommand(command: string, args: string[]): string { + return [command, ...args].map(quoteShellArgument).join(' ') +} + +function quoteShellArgument(value: string): string { + if (/^[\w./:@=+-]+$/.test(value)) return value + const singleQuote = String.fromCodePoint(39) + const escapedSingleQuote = `${singleQuote}"${singleQuote}"${singleQuote}` + return `${singleQuote}${value.replaceAll(singleQuote, escapedSingleQuote)}${singleQuote}` +} + +function printUsage(): void { + console.log(`Usage: + pnpm exec tsx scripts/publish-npm-baseline.ts pack [options] + pnpm exec tsx scripts/publish-npm-baseline.ts release [options] [--yes] + pnpm exec tsx scripts/publish-npm-baseline.ts publish --manifest [--yes] + pnpm exec tsx scripts/publish-npm-baseline.ts verify --manifest + +Pack/release options: + --ref Git commit to stage (default: HEAD) + --registry npm registry (default: ${DEFAULT_REGISTRY}) + --output-dir Artifact root (default: ${DEFAULT_OUTPUT_DIRECTORY}) + --yes pack/release without waiting for Enter`) +} + +async function main(): Promise { + const command = process.argv[2] + if (command === undefined || command === 'help' || command === '--help' || command === '-h') { + printUsage() + return + } + if (process.argv.slice(3).some(value => value === '--help' || value === '-h')) { + printUsage() + return + } + const runner = new CommandRunner() + const repositoryRoot = runner.capture('git', ['rev-parse', '--show-toplevel'], process.cwd()) + + if (command === 'pack' || command === 'release') { + const { values } = parseArgs({ + args: process.argv.slice(3), + options: { + ref: { type: 'string', default: 'HEAD' }, + registry: { type: 'string', default: DEFAULT_REGISTRY }, + 'output-dir': { type: 'string', default: resolve(repositoryRoot, DEFAULT_OUTPUT_DIRECTORY) }, + yes: { type: 'boolean', default: false }, + }, + strict: true, + }) + const packager = new BaselinePackager(repositoryRoot, runner) + const plan = packager.plan({ + ref: values.ref, + registry: values.registry, + outputDirectory: resolve(values['output-dir']), + }) + await plan.confirm(values.yes) + const bundle = packager.pack(plan) + if (command === 'release') { + await new RegistryPublication(bundle, runner).publish(values.yes) + } + return + } + + if (command === 'publish' || command === 'verify') { + const { values } = parseArgs({ + args: process.argv.slice(3), + options: { + manifest: { type: 'string' }, + yes: { type: 'boolean', default: false }, + }, + strict: true, + }) + if (values.manifest === undefined) throw new Error(`${command} requires --manifest`) + if (command === 'verify' && values.yes) throw new Error('verify does not accept --yes') + const bundle = ReleaseBundle.load(values.manifest, runner) + const publication = new RegistryPublication(bundle, runner) + if (command === 'publish') await publication.publish(values.yes) + else publication.verify() + return + } + + throw new Error(`unknown command: ${command}`) +} + +try { + await main() +} catch (error: unknown) { + console.error(`publish-npm-baseline: ${error instanceof Error ? error.message : String(error)}`) + process.exitCode = 1 +} diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 74d90a547d..238c513433 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -597,6 +597,7 @@ function builtBinSmokeGate(needs: string[] = ['build']): Gate { 'apps/cli/tests/built-bin.e2e.ts', 'packages/examples/cli-demo/tests/built-bin.e2e.ts', 'packages/examples/acp-demo/tests/built-bin.e2e.ts', + 'packages/host/directory-picker-native/tests/built-worker.e2e.ts', 'packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts', // The worker-entry packages' built bundles: the only automated proof // that lib/index.js resolves its sibling lib/worker.cjs under plain node diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 4104ff8fdc..041972cb9f 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -42,7 +42,9 @@ const NO_MODEL_EXPERIENCE_SECTION: Readonly> = { */ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/bash/bash': { kind: 'indirect', reason: 'The service interface delegates all model rendering to dsh-tool-bash.' }, + 'packages/bash/bash-env': { kind: 'indirect', reason: 'The env service surfaces managed DSH_* facts through the shell tools (dsh-tool-bash/dsh-tool-pwsh); it registers no prompt or schema of its own.' }, 'packages/bash/bash-local': { kind: 'indirect', reason: 'The executor backend delegates model rendering to dsh-tool-bash.' }, + 'packages/bash/pwsh-local': { kind: 'indirect', reason: 'The executor backend delegates model rendering to dsh-tool-pwsh.' }, 'packages/code-runtime/code-runtime': { kind: 'indirect', reason: 'The service interface delegates model rendering to Code Mode in dsh-tools.' }, 'packages/code-runtime/code-runtime-worker': { kind: 'indirect', reason: 'The worker backend delegates model rendering to Code Mode in dsh-tools.' }, 'packages/typert/registry': { kind: 'none', reason: 'Runtime type registry; consumers (cordis_inspect, wire faces, gates) own any model-visible projection of registry contents.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 43c643d9c2..9ba9ba5d84 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -53,6 +53,9 @@ "@deepseek-ai/dsh-session-title/client": ["./packages/session-title/session-title/src/client.ts"], "@deepseek-ai/dsh-plan-mode/types": ["./packages/plan/plan-mode/src/types.ts"], "@deepseek-ai/dsh-plan-mode/client": ["./packages/plan/plan-mode/src/client.ts"], + "@deepseek-ai/dsh-pwsh-local": ["./packages/bash/pwsh-local/src/index.ts"], + "@deepseek-ai/dsh-tool-pwsh": ["./packages/bash/tool-pwsh/src/index.ts"], + "@deepseek-ai/dsh-bash-env": ["./packages/bash/bash-env/src/index.ts"], "@deepseek-ai/dsh-goal/types": ["./packages/goal/goal/src/types.ts"], "@deepseek-ai/dsh-goal/client": ["./packages/goal/goal/src/client.ts"], "@deepseek-ai/dsh-llm/types": ["./packages/llm/llm/src/types.ts"], diff --git a/tsconfig.host.json b/tsconfig.host.json index bfc4f898e8..c3eac58af5 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -148,6 +148,9 @@ { "path": "./packages/llm/llm-deepseek" }, { "path": "./packages/llm/llm-pi-ai" }, { "path": "./packages/bash/bash-local" }, + { "path": "./packages/bash/bash-env" }, + { "path": "./packages/bash/pwsh-local" }, + { "path": "./packages/bash/tool-pwsh" }, { "path": "./packages/sandbox/sandbox" }, { "path": "./packages/sandbox/sandbox-local" }, { "path": "./packages/sandbox/sandbox-policy" }, diff --git a/vendor/README.md b/vendor/README.md index 6788409cb3..c59a86ccca 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -31,7 +31,7 @@ Intentionally **not** vendored (verified unused by this set): `reggol`, `@cordis Keep this log exhaustive — every divergence from upstream must be listed. 1. **`hmr/src/index.ts`**: removed the `./locales/en-US.yml` / `./locales/zh-CN.yml` imports, the `.i18n({...})` call on the `Config` schema, and the `src/locales/` directory. Rationale: those imports require a runtime YAML loader hook (`@cordisjs/unyaml`) that we do not vendor; the i18n texts only localize config descriptions. -2. **All `package.json` files**: regenerated — added `private: true`, added precise `files` entries for bundled runtime files and `lib/types/**/*.d.ts` / `.d.ts.map`, preserved `src` in `files` only for packages whose previous file list already shipped it, added a `./src/*` export where missing, pointed declaration metadata at `lib/types`, and removed upstream `devDependencies`/`scripts`/`repository` fields. Dependency and peer-dependency ranges preserved, except `hmr` declares `esbuild` as a direct dev dependency because its source imports the `BuildFailure` type and pnpm's strict workspace resolution requires the owner package to name that dependency. +2. **All `package.json` files**: regenerated — added `private: true`, added precise `files` entries for bundled runtime files and `lib/types/**/*.d.ts` / `.d.ts.map`, preserved `src` in `files` only for packages whose previous file list already shipped it, added a `./src/*` export where missing, pointed declaration metadata at `lib/types`, and removed upstream `devDependencies`/`scripts`/`repository` fields. Dependency and peer-dependency ranges are preserved except that `hmr` declares `esbuild` as a direct dev dependency because its source imports the `BuildFailure` type and pnpm's strict workspace resolution requires the owner package to name that dependency, and `loader` requires `node-addon-require-builtin@^0.1.4` to match the runtime used by published app packages. 3. **All `tsconfig.json` files**: regenerated to extend the repo-root `tsconfig.base.json`, emit TypeScript intermediates to `lib/types`, and declare project references. 4. **Vendored TypeScript source internal specifiers**: changed local relative imports/exports from upstream's specifier shape to explicit `.ts` specifiers so TypeScript rewrites emitted JS to `.js` while declarations keep explicit, NodeNext-safe `.ts` specifiers. This includes `loader/src/config/isolate.ts` using `declare module './entry.ts'`. 5. **`schemastery/tsdown.config.ts` and `logger-console/tsdown.config.ts`**: ours, not upstream files — per-package build-shape overrides (dual ESM+CJS output; separate node/browser entries) for the repo-root tsdown build. They read the JS emitted under `lib/types` and then write the publish runtime entries under `lib/`. Like the regenerated tsconfigs, they are not part of the upstream sync surface. diff --git a/vendor/loader/package.json b/vendor/loader/package.json index e24e0657a6..509c558ed3 100644 --- a/vendor/loader/package.json +++ b/vendor/loader/package.json @@ -29,7 +29,7 @@ "license": "MIT", "peerDependencies": { "cordis": "^4.0.0-rc.7", - "node-addon-require-builtin": "^0.1.3" + "node-addon-require-builtin": "^0.1.4" }, "peerDependenciesMeta": { "node-addon-require-builtin": { diff --git a/vitest.config.ts b/vitest.config.ts index ddf7741716..2909fb7459 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,4 +1,6 @@ +import { spawnSync } from 'node:child_process' import tsconfigPaths from 'vite-tsconfig-paths' +import { resolvePwshPath } from './packages/bash/pwsh-local/src/resolve.ts' import { defineConfig } from 'vitest/config' import { vitestExecArgv } from './vitest.shared.ts' import { COVERAGE_EXEMPT_ENV, coverageExemptHeavySuites } from './scripts/coverage-exempt.ts' @@ -11,7 +13,14 @@ const pathsPlugin = (): ReturnType => tsconfigPaths({ proj const windowsUnsupportedPackages = process.platform === 'win32' ? [ - 'packages/bash/*', + // Bash-requiring suites (a real POSIX shell is unavailable on Windows). + // The pwsh-requiring suites (pwsh-local, tool-pwsh) deliberately stay + // INCLUDED: PowerShell ships with Windows, so they run natively here. + // Replacing the old 'packages/bash/*' glob with this explicit list also + // newly INCLUDES packages/bash/bash (the pure seam package) on Windows. + 'packages/bash/bash-local', + 'packages/bash/bash-sandbox', + 'packages/bash/tool-bash', 'packages/hooks/*', 'packages/subprocess/*', 'packages/pty/pty-local', @@ -31,6 +40,17 @@ const windowsCoverageExclusions = process.platform === 'win32' ] : [] +// Mirrors windowsCoverageExclusions: pwsh-local's run/start/lifecycle suites +// self-skip without a real pwsh (executor.spec.ts hasPwsh), leaving this file +// far below per-file 100% on pwsh-less hosts; the exemption keeps those hosts +// green while CI runners ship pwsh and still enforce the full bar. The probe +// runs the suites' own resolution (the dependency-free resolve.ts module), +// so the exemption is active exactly when the suites skip — a mismatched +// narrower probe could exempt the file on hosts whose suites actually run. +const pwshCoverageExclusions = spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0 + ? [] + : ['packages/bash/pwsh-local/src/index.ts'] + const testIncludes = [ 'packages/*/*/tests/**/*.spec.{ts,tsx}', 'apps/*/tests/**/*.spec.ts', @@ -194,6 +214,7 @@ export default defineConfig({ 'packages/session-projection/session-projection/src/index.ts', ...windowsUnsupportedPackages.map(path => `${path}/src/**/*.ts`), ...windowsCoverageExclusions, + ...pwshCoverageExclusions, ], // 100% or it doesn't merge (docs/testing.md: excessive tests are welcome). // Per-file so a well-covered big file can't subsidize a bare one.