From bcb049bef308c3ee4a48bccb9023999b73c5471a Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 30 Jul 2026 14:57:27 +0800 Subject: [PATCH 1/7] fix(prompt): distinguish checkout from workdir --- ...rce-checkout-workdir-distinction.i18n.yaml | 6 ++ ...-30-source-checkout-workdir-distinction.md | 33 +++++++++ ...-source-checkout-workdir-distinction.zh.md | 33 +++++++++ .../source-checkout-workdir/session.jsonl | 30 +++++++++ .../terminal.expected.txt | 67 +++++++++++++++++++ .../tui-agent/tests/tui-keyless-smoke.e2e.ts | 9 +-- examples/tui-agent/tests/tui.snapshot.ts | 36 +++++++++- packages/ui/app-boot/README.i18n.yaml | 4 +- packages/ui/app-boot/README.md | 2 +- packages/ui/app-boot/README.zh.md | 2 +- packages/ui/app-boot/src/index.ts | 12 ++-- packages/ui/app-boot/tests/app-boot.spec.ts | 4 +- 12 files changed, 220 insertions(+), 18 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-07-30-source-checkout-workdir-distinction.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-07-30-source-checkout-workdir-distinction.md create mode 100644 .agents/notes/implemented/bug-fix/2026-07-30-source-checkout-workdir-distinction.zh.md create mode 100644 examples/tui-agent/tests/snapshots/source-checkout-workdir/session.jsonl create mode 100644 examples/tui-agent/tests/snapshots/source-checkout-workdir/terminal.expected.txt diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-source-checkout-workdir-distinction.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-source-checkout-workdir-distinction.i18n.yaml new file mode 100644 index 0000000000..311c4971d3 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-source-checkout-workdir-distinction.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/bug-fix/2026-07-30-source-checkout-workdir-distinction.md +2026-07-30-source-checkout-workdir-distinction.md: ba6d9dd12b55a54d4ae8d2e91ad83ac3c1dc47fd +2026-07-30-source-checkout-workdir-distinction.zh.md: ffc2ac7baa2b1bb8ce54607638c35869fc338825 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-source-checkout-workdir-distinction.md b/.agents/notes/implemented/bug-fix/2026-07-30-source-checkout-workdir-distinction.md new file mode 100644 index 0000000000..ba6d9dd12b --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-source-checkout-workdir-distinction.md @@ -0,0 +1,33 @@ +# Agent Note: Source checkout paths do not define working directories + +Status: implemented + +English | [中文](2026-07-30-source-checkout-workdir-distinction.zh.md) + +## Problem + +The `harness:source` prompt section follows the [source-location decision](../../archived/feature/2026-07-21-dsh-system-prompt-source-path.md), but its original wording called the checkout “your own source code” without distinguishing that path from the session workspace. In a normal TUI configuration that does not state `{{cwd}}` in its persona, this may be the only fixed absolute path near the start of the system prompt. DeepSeek V4 could therefore answer “what's the workdir?” with the harness checkout instead of determining the session's current working directory. + +A blanket statement that the checkout is not the working directory would also be false. `dsh meta` intentionally makes the source checkout both values. + +## Decision + +The section identifies the path as the “DeepSeek Harness implementation checkout.” It says that the checkout location and current working directory are separate values that may differ, forbids inferring the working directory from the checkout path, directs the model to use `pwd`, and limits the checkout's purpose to inspecting or extending DSH itself. + +The path derivation, global `harness:source` ownership, and `-99` ordering remain unchanged. Describing the values as conceptually separate rather than always unequal keeps the instruction accurate in both ordinary project sessions and `dsh meta`. + +## Verification + +The `dsh-app-boot` unit test pins the exact text and its ordering. The CLI keyless PTY smoke inspects the assembled request header. The TUI `source-checkout-workdir` snapshot mounts the section with `/opt/dsh-source`, asks “what's the workdir?” through a recorded DeepSeek V4 turn, and requires the replayed transcript to run `pwd` and report the generated workspace rather than the checkout. + +## Alternatives considered + +**Say that the checkout is never the working directory.** Rejected because `dsh meta` deliberately makes them the same path. + +**Put the current working directory in the global source section.** Rejected because the source section is launcher-global while the working directory belongs to each session; combining them would duplicate the loop's `cwd` ownership and make a stable source fact vary per agent. + +**Remove the source path from the prompt.** Rejected because self-referential DSH tools still need a reliable checkout location when the launcher starts from an unrelated project. + +## Consequences + +The prompt is longer and a direct working-directory question may spend one inexpensive `pwd` tool call. In exchange, the model no longer treats the harness implementation path as an implicit task workspace, while meta mode remains truthful when both values coincide. diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-source-checkout-workdir-distinction.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-source-checkout-workdir-distinction.zh.md new file mode 100644 index 0000000000..ffc2ac7baa --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-source-checkout-workdir-distinction.zh.md @@ -0,0 +1,33 @@ +# Agent Note: 源码 checkout 路径不定义工作目录 + +Status: implemented + +[English](2026-07-30-source-checkout-workdir-distinction.md) | 中文 + +## Problem + +`harness:source` 提示词段遵循[源码位置决策](../../archived/feature/2026-07-21-dsh-system-prompt-source-path.md),但原有措辞把 checkout 称为“你自己的源代码”,却没有区分该路径与会话 workspace。在 persona 不声明 `{{cwd}}` 的普通 TUI 配置中,这可能是系统提示词开头附近唯一固定的绝对路径。因此,DeepSeek V4 可能会直接用 harness checkout 回答“what's the workdir?”,而不是确定会话的当前工作目录。 + +直接断言 checkout 不是工作目录同样不准确。`dsh meta` 会有意让源码 checkout 同时充当这两个值。 + +## Decision + +该提示词段将路径标识为“DeepSeek Harness implementation checkout”。它说明 checkout 位置与当前工作目录是两个可能不同的值,禁止从 checkout 路径推断工作目录,指示模型使用 `pwd`,并限定该 checkout 只用于检查或扩展 DSH 自身。 + +路径推导方式、全局 `harness:source` 所有权和 `-99` 顺序均保持不变。将两者描述为概念上独立、而不是始终不相等,使这条指令在普通项目会话和 `dsh meta` 中都准确。 + +## Verification + +`dsh-app-boot` 单元测试固定了完整文本及其顺序。CLI 无密钥 PTY 冒烟测试检查组装后的请求 header。TUI 的 `source-checkout-workdir` 快照把该提示词段挂载为 `/opt/dsh-source`,通过录制的 DeepSeek V4 turn 提问“what's the workdir?”,并要求回放 transcript 运行 `pwd`,报告生成的 workspace 而不是 checkout。 + +## Alternatives considered + +**声明 checkout 永远不是工作目录。**拒绝:`dsh meta` 会有意让它们指向同一路径。 + +**把当前工作目录写入全局源码提示词段。**拒绝:源码提示词段由 launcher 全局持有,而工作目录属于各个会话;将两者合并会重复 loop 对 `cwd` 的所有权,还会让稳定的源码事实随 agent 变化。 + +**从提示词中删除源码路径。**拒绝:launcher 从无关项目启动时,自引用 DSH 工具仍需要可靠的 checkout 位置。 + +## Consequences + +提示词会变长,直接询问工作目录时可能多花一次廉价的 `pwd` 工具调用。作为交换,模型不再把 harness 实现路径当作隐含的任务 workspace;当 meta 模式使两个值重合时,提示词仍然准确。 diff --git a/examples/tui-agent/tests/snapshots/source-checkout-workdir/session.jsonl b/examples/tui-agent/tests/snapshots/source-checkout-workdir/session.jsonl new file mode 100644 index 0000000000..f7458c3b46 --- /dev/null +++ b/examples/tui-agent/tests/snapshots/source-checkout-workdir/session.jsonl @@ -0,0 +1,30 @@ +{"type":"session","version":0,"id":"main-session","createdAt":1784606400000,"cwd":"{{cwd}}"} +{"type":"turn/start","seq":0,"time":1784606400000,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1784606400000,"data":{"content":[{"type":"text","text":"what's the workdir?"}],"source":{"kind":"user"},"role":"user","id":"3fdc2885-1bea-4c6c-b4af-dbd5af7594f8"},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":1784606400000,"data":{"title":"what's the workdir?","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":1784606400000,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":1784606400000,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash","reasoningEffort":"high"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":5,"time":1784606400000,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":6,"time0":1784606400000,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," is"," asking"," about"," the"," current"," working"," directory","."," Let"," me"," check"," using"," p","wd","."]}} +{"type":"assistant/chunk","seq":23,"time":1784606400000,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":24,"time0":1784606400000,"data":{"turn":1,"step":1,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"id":"call_00_AqoWTncquNel5ZHsJHOo7491","name":"bash","args":["","{","\"","command","\"",": ","\"","p","wd","\"",", ","\"","description","\"",": ","\"","Print"," current"," working"," directory","\"","}"]}} +{"type":"assistant/chunk","seq":46,"time":1784606400000,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user is asking about the current working directory. Let me check using pwd."}}}} +{"type":"assistant/chunk","seq":47,"time":1784606400000,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_AqoWTncquNel5ZHsJHOo7491","name":"bash","arguments":"{\"command\": \"pwd\", \"description\": \"Print current working directory\"}"}}}} +{"type":"assistant/chunk","seq":48,"time":1784606400000,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3332,"outputTokens":80,"cacheReadTokens":0,"reasoningTokens":17}}}} +{"type":"assistant/chunk","seq":49,"time":1784606400000,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":50,"time":1784606400000,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user is asking about the current working directory. Let me check using pwd."},{"type":"tool-call","id":"call_00_AqoWTncquNel5ZHsJHOo7491","name":"bash","arguments":"{\"command\": \"pwd\", \"description\": \"Print current working directory\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"063a9245-32c3-4551-9ace-d43f10ed5582"},"usage":{"inputTokens":3332,"outputTokens":80,"cacheReadTokens":0,"reasoningTokens":17}},"sourceEventSeqs":[5,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],"surfaceOp":"append"} +{"type":"tool/call","seq":51,"time":1784606400000,"data":{"turn":1,"step":1,"callId":"call_00_AqoWTncquNel5ZHsJHOo7491","name":"bash","arguments":"{\"command\": \"pwd\", \"description\": \"Print current working directory\"}"}} +{"type":"tool/result","seq":52,"time":1784606400000,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_AqoWTncquNel5ZHsJHOo7491"},"content":[{"type":"tool-result","toolCallId":"call_00_AqoWTncquNel5ZHsJHOo7491","content":[{"type":"text","text":"{{cwd}}\n"}],"isError":false}],"role":"user","id":"16086d3b-6dfa-4970-a06e-78561475af8c"}},"sourceEventSeqs":[51],"surfaceOp":"append"} +{"type":"step/end","seq":53,"time":1784606400000,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":54,"time":1784606400000,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":55,"time":1784606400000,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":56,"time0":1784606400000,"data":{"turn":1,"step":2,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," current"," working"," directory"," is"," `/","private","/t","mp","/d","sh","-t","ui","-s","n","apshot","-source","-check","out","-work","dir","-","61","I","wn","K","`."]}} +{"type":"assistant/chunk","seq":83,"time":1784606400000,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":84,"time0":1784606400000,"data":{"turn":1,"step":2,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," current"," working"," directory"," is",":\n\n","```\n","/","private","/t","mp","/d","sh","-t","ui","-s","n","apshot","-source","-check","out","-work","dir","-","61","I","wn","K","\n","```\n\n","Note"," that"," the"," Deep","Se","ek"," Har","ness"," SDK"," checkout"," is"," at"," `/","opt","/d","sh","-source","`"," —"," that","'s"," a"," separate"," location"," used"," for"," inspecting"," or"," extending"," D","SH"," itself",","," while"," the"," working"," directory"," above"," is"," the"," current"," working"," directory"," for"," this"," session","."]}} +{"type":"assistant/chunk","seq":161,"time":1784606400000,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The current working directory is `{{cwd}}`."}}}} +{"type":"assistant/chunk","seq":162,"time":1784606400000,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The current working directory is:\n\n```\n{{cwd}}\n```\n\nNote that the DeepSeek Harness SDK checkout is at `/opt/dsh-source` — that's a separate location used for inspecting or extending DSH itself, while the working directory above is the current working directory for this session."}}}} +{"type":"assistant/chunk","seq":163,"time":1784606400000,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":117,"outputTokens":105,"cacheReadTokens":3328,"reasoningTokens":27}}}} +{"type":"assistant/chunk","seq":164,"time":1784606400000,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":165,"time":1784606400000,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The current working directory is `{{cwd}}`."},{"type":"text","text":"The current working directory is:\n\n```\n{{cwd}}\n```\n\nNote that the DeepSeek Harness SDK checkout is at `/opt/dsh-source` — that's a separate location used for inspecting or extending DSH itself, while the working directory above is the current working directory for this session."}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"76c18b85-00aa-45f6-a32f-a2bd75412470"},"usage":{"inputTokens":117,"outputTokens":105,"cacheReadTokens":3328,"reasoningTokens":27}},"sourceEventSeqs":[55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,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,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164],"surfaceOp":"append"} +{"type":"step/end","seq":166,"time":1784606400000,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":167,"time":1784606400000,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/tui-agent/tests/snapshots/source-checkout-workdir/terminal.expected.txt b/examples/tui-agent/tests/snapshots/source-checkout-workdir/terminal.expected.txt new file mode 100644 index 0000000000..81103c12bd --- /dev/null +++ b/examples/tui-agent/tests/snapshots/source-checkout-workdir/terminal.expected.txt @@ -0,0 +1,67 @@ +terminal 100x36 buffer=normal length=36 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "what's the workdir? — DSH TUI snapshot" +cursor hidden column=7 viewportRow=32 bufferRow=32 +buffer +0| " DEEPSEEK HARNESS" + style 1-8 fg=bright-magenta bold + style 10-16 bold +1| " what's the workdir?" + style 1-19 dim +2| " main-session" + style 1-12 dim +3| +4| "You " + style 0-2 fg=bright-magenta bold underline +5| "what's the workdir? " +6| +7| "Assistant " + style 0-8 fg=bright-magenta bold underline +8| "Reasoning " + style 0-8 dim italic +9| "The user is asking about the current working directory. Let me check using pwd. " + style 0-78 dim italic +10| +11| "● Tool / bash / Print current working directory" + style 0-46 fg=green +12| "$ pwd " + style 0-4 dim +13| "/workspace/project " + style 0-59 dim +14| "[exit 0] " + style 0-7 dim +15| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +16| +17| "Assistant " + style 0-8 fg=bright-magenta bold underline +18| "Reasoning " + style 0-8 dim italic +19| "The current working directory is /workspace/project. " + style 0-32 dim italic + style 33-84 fg=cyan + style 85-85 dim italic +20| "The current working directory is: " +21| " " +22| " " +23| " /workspace/project " + style 2-53 fg=cyan +24| " " +25| " " +26| "Note that the DeepSeek Harness SDK checkout is at /opt/dsh-source — that's a separate location used " + style 50-64 fg=cyan +27| "for inspecting or extending DSH itself, while the working directory above is the current working " +28| "directory for this session. " +29| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +30| +31| "/workspace/project deepseek-v4-flash ↑3.4k ↓185 cache 49% 3% c" + style 0-51 fg=bright-magenta bold + style 54-70 dim + style 73-93 dim + style 96-99 dim +32| " dsh ◍ " + style 1-3 fg=bright-magenta bold + style 5-6 dim + style 7-7 inverse +33-35| diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts index cd5f83e4e3..f31e3c6853 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -412,11 +412,12 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { expect(output).toContain('ui-tui: session "missing-session" failed to start:') }, LOADER_SMOKE_TEST_TIMEOUT_MS) - it('tells the model its source path and offers the bundled maintenance skills', async () => { + it('distinguishes its source path from the current workdir and offers the bundled maintenance skills', async () => { // The launcher resolves the checkout root three hops up from apps/cli/{src,lib}; // this test file sits an equal depth under the same root, so the same hop applies. - // The source-path line is a system-prompt section; the bundled skills reach the - // model through a durable user message, so each assertion targets its own field. + // The source-path line explicitly distinguishes that checkout from the current workdir; + // bundled skills reach the model through a durable user message, so each assertion + // targets its own field. const sourceRoot = fileURLToPath(new URL('../../..', import.meta.url)) let context: LoggedRequestContext = { system: '', skillCatalog: '' } await smoke({ @@ -432,7 +433,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { ], inspect: async (cwd) => { context = await readLoggedRequestContext(cwd) }, }) - expect(context.system).toContain(`Your own source code is the checkout at ${sourceRoot}; you can read it there to learn how dsh works and how to extend it.`) + expect(context.system).toContain(`The DeepSeek Harness implementation checkout is at ${sourceRoot}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself.`) expect(context.skillCatalog).toContain("- `dsh-customize`: Customize or maintain any dsh source checkout — the one powering the current DSH process, the installed `dsh` command, or a sibling dsh/deepseek-harness clone. Use before any requested action that alters such a checkout's files or git state. Read-only questions that only inspect the checkout do not trigger this. Do not edit the personal staging checkout directly.") expect(context.skillCatalog).toContain('- `dsh-upgrade`: Upgrades a source-installed, personally customized DSH checkout to upstream master while preserving local changes and an unchanged rollback worktree. Use when the user asks to update or upgrade DSH.') expect(context.skillCatalog).toContain('- `dsh-upstream-customization`: Classifies personal DSH customizations for upstream contribution and, after explicit per-feature approval, rebuilds one on upstream master and opens a draft pull request. Use when the user asks to contribute, publish, or upstream a local DSH change, or asks whether one is worth proposing.') diff --git a/examples/tui-agent/tests/tui.snapshot.ts b/examples/tui-agent/tests/tui.snapshot.ts index 95b960e3b6..b96aaaf691 100644 --- a/examples/tui-agent/tests/tui.snapshot.ts +++ b/examples/tui-agent/tests/tui.snapshot.ts @@ -7,6 +7,7 @@ import { Context } from 'cordis' import { scrubRequestHeaders, tokenizeSessionFixtureCwd } from '@deepseek-ai/dsh-acp-snapshot' import type { Agent } from '@deepseek-ai/dsh-agent' import * as AgentCore from '@deepseek-ai/dsh-agent-spine-demo' +import { addHarnessSourceSection } from '@deepseek-ai/dsh-app-boot' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import WorkerCodeRuntime from '@deepseek-ai/dsh-code-runtime-worker' @@ -53,6 +54,8 @@ interface Scenario { leavePlanModeAfterFirstTurn?: boolean recorded: boolean seedWorkspace?: boolean + /** Add the launcher's model-visible DSH source checkout at this fixed path. */ + harnessSourceRoot?: string /** * Load the opt-in `todo_write` tool for this scenario. The shipped tui-agent * config omits it, so only the todo-plan scenario (the enabled-path proof) @@ -94,6 +97,13 @@ const SCENARIOS: Scenario[] = [ expectedTools: ['bash'], recorded: true, }, + { + name: 'source-checkout-workdir', + composition: 'native', + expectedTools: ['bash'], + recorded: true, + harnessSourceRoot: '/opt/dsh-source', + }, { name: 'parallel-file-reads', composition: 'native', @@ -187,6 +197,12 @@ function rawSessionLog(session: Session): string { ].join('\n') } +async function materializeFixtureCwd(fixtureFile: string, cwd: string, replayRoot: string): Promise { + const realized = join(replayRoot, basename(fixtureFile)) + await writeFile(realized, (await readFile(fixtureFile, 'utf8')).split('{{cwd}}').join(cwd)) + return realized +} + function normalizeTerminalSnapshot(snapshot: string, cwd: string, displayCwd: string): string { return snapshot .split(`/private${cwd}`).join('/workspace/project') @@ -212,6 +228,7 @@ async function mountScenarioContext( displayCwd: string, fixtureFile: string, childFiles: string[], + replayRoot: string | undefined, ): Promise { class SnapshotLocalFileSystem extends LocalFileSystem { override async resolve( @@ -231,6 +248,7 @@ async function mountScenarioContext( tools: { mode: scenario.composition === 'code' ? 'code' : scenario.composition === 'advanced' ? 'both' : 'native' }, skills: { local: { agentsHome: join(cwd, '.agents') } }, }) + if (scenario.harnessSourceRoot !== undefined) addHarnessSourceSection(ctx, scenario.harnessSourceRoot) await ctx.plugin(TokenMeterService) await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 }) @@ -263,7 +281,12 @@ async function mountScenarioContext( if (MODE === 'record' && scenario.recorded) { await ctx.plugin(LlmDeepSeek) } else { - installLlmReplay(ctx, { file: fixtureFile, childFiles, providers: PROVIDERS }) + if (replayRoot === undefined) throw new Error('replay mode requires an isolated fixture directory') + // Recorded model text may name the generated cwd. Realize the portable token + // outside that cwd so tools see only the scenario workspace during replay. + const replayFile = await materializeFixtureCwd(fixtureFile, cwd, replayRoot) + const replayChildFiles = await Promise.all(childFiles.map(file => materializeFixtureCwd(file, cwd, replayRoot))) + installLlmReplay(ctx, { file: replayFile, childFiles: replayChildFiles, providers: PROVIDERS }) } return ctx } @@ -287,15 +310,19 @@ async function runScenario(scenario: Scenario): Promise { const cwd = await mkdtemp(join(SNAPSHOT_TMP_ROOT, `dsh-tui-snapshot-${scenario.name}-`)) const displayCwd = `/tmp/${basename(cwd)}` + let replayRoot: string | undefined let ctx: Context | undefined let controller: ReturnType | undefined const terminal = new HeadlessTerminal(100, 36) try { + if (!(MODE === 'record' && scenario.recorded)) { + replayRoot = await mkdtemp(join(SNAPSHOT_TMP_ROOT, `dsh-tui-replay-${scenario.name}-`)) + } if (scenario.seedWorkspace === true) { const source = join(scenarioDir(scenario), 'workspace') await cp(source, cwd, { recursive: true }) } - ctx = await mountScenarioContext(scenario, cwd, displayCwd, fixtureFile, childFiles) + ctx = await mountScenarioContext(scenario, cwd, displayCwd, fixtureFile, childFiles, replayRoot) const disposedSessions: Session[] = [] ctx.on('session/disposed', (session) => { disposedSessions.push(session) }) const workflowEvents: string[] = [] @@ -406,6 +433,10 @@ async function runScenario(scenario: Scenario): Promise { const firstHeader = events.find(event => event.type === 'request/header') expect(firstHeader?.type === 'request/header' && firstHeader.data.header.system) .toContain(FILE_REFERENCE_PROMPT) + if (scenario.harnessSourceRoot !== undefined) { + expect(firstHeader?.type === 'request/header' && firstHeader.data.header.system) + .toContain(`The DeepSeek Harness implementation checkout is at ${scenario.harnessSourceRoot}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself.`) + } expect(events.filter(event => event.type === 'tool/call').map(event => event.data.name)).toEqual(scenario.expectedTools) for (const [type, count] of Object.entries(scenario.expectedEventCounts ?? {})) { expect(events.filter(event => event.type === type), `${scenario.name} must emit ${type}`).toHaveLength(count) @@ -477,6 +508,7 @@ async function runScenario(scenario: Scenario): Promise { await ctx?.fiber.dispose() await terminal.dispose() await rm(cwd, { recursive: true, force: true }) + if (replayRoot !== undefined) await rm(replayRoot, { recursive: true, force: true }) clock.mockRestore() } } diff --git a/packages/ui/app-boot/README.i18n.yaml b/packages/ui/app-boot/README.i18n.yaml index 08a274fd25..4804a0ad66 100644 --- a/packages/ui/app-boot/README.i18n.yaml +++ b/packages/ui/app-boot/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/ui/app-boot/README.md -README.md: 0282d3e9559d55c3fe5b07df133747750c06ebad -README.zh.md: b7121bbd288cd6e3f9ef2301de6018ceb380eb06 +README.md: b187f691df4c6ca9c4fe1fe3bf7e631f14997b01 +README.zh.md: 6f86573fbb1e60bf290d90f67e4a574c96219e0a diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index 0282d3e955..b187f691df 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -13,7 +13,7 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c | `loadPersonalPatches(binName, dir?)` | Parse the optional `config.yaml` in the Harness home (default [`resolveDshHome()`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws | | `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, run optional host preparation before plugins mount (e.g. `ctx.provide(RESUME_SESSION_ID_KEY, id)`), then mount the Loader/include tree, await it, assert entries loaded, and return the root context | | `RESUME_SESSION_ID_KEY` | Context key a bin sets through `boot`'s `prepare` hook to hand a resume session id to the booted config; the config reads it as the bare identifier `resumeSessionId` in a `!!js` expression, so resuming needs no environment variable | -| `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to its own source checkout; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot | +| `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to the DSH implementation checkout while warning it not to infer the current working directory from that path and to use `pwd` instead; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot | | `HARNESS_SOURCE_SECTION` | The `'harness:source'` section name `addHarnessSourceSection` registers under | Two failure classes the guards handle: `loader.await()` swallows init rejections (`Promise.allSettled`) — Node still exits non-zero on the resulting unhandled rejection, and `installFailLoud` replaces the noisy dump with one labelled line and a guaranteed `exit(1)`; a failed plugin import is only logged by the Loader (the process would otherwise exit 0 on a usable config typo), leaving a fiber-less entry that `assertEntriesLoaded` turns into a `boot()` rejection naming every failed plugin. diff --git a/packages/ui/app-boot/README.zh.md b/packages/ui/app-boot/README.zh.md index b7121bbd28..6f86573fbb 100644 --- a/packages/ui/app-boot/README.zh.md +++ b/packages/ui/app-boot/README.zh.md @@ -13,7 +13,7 @@ | `loadPersonalPatches(binName, dir?)` | 解析 Harness home 中可选的 `config.yaml`(默认使用 [`resolveDshHome()`](../../util/paths/README.md):先取 `$DSH_HOME`,否则取 `~/.dsh`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 | | `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文,在插件挂载前执行可选的宿主准备操作(例如 `ctx.provide(RESUME_SESSION_ID_KEY, id)`),再挂载 Loader/include 树并等待其结算,断言所有条目均已加载,最后返回根上下文 | | `RESUME_SESSION_ID_KEY` | bin 通过 `boot` 的 `prepare` 钩子设置的上下文键,用于把要恢复的会话 id 交给已启动配置;配置以裸标识符 `resumeSessionId` 在 `!!js` 表达式中读取它,因此恢复操作无需环境变量 | -| `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent(智能体)自身源代码 checkout 的磁盘路径;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber,因此开发环境 HMR(热模块替换)重新加载系统提示词后,它会消失直至下次启动 | +| `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent(智能体)DSH 实现代码 checkout 的磁盘路径,同时提醒它不得据此推断当前工作目录,而应使用 `pwd`;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber,因此开发环境 HMR(热模块替换)重新加载系统提示词后,它会消失直至下次启动 | | `HARNESS_SOURCE_SECTION` | `'harness:source'` 段落名称,供 `addHarnessSourceSection` 注册使用 | 这些保护处理两类故障。`loader.await()` 会吞掉初始化 rejection(`Promise.allSettled`);Node 仍会因随后产生的未处理 rejection 以非零状态退出,而 `installFailLoud` 会把冗长转储替换为一行带标签的消息,并确保执行 `exit(1)`。插件导入失败则只会由 Loader 记录日志(否则,即使配置存在拼写错误,进程也会以代码 0 退出),并留下没有 fiber 的条目;`assertEntriesLoaded` 会将其转换为 `boot()` rejection,并在其中列出每个导入失败插件的名称。 diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index 4644912304..47e8fef5d2 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -213,11 +213,11 @@ export async function boot( export const HARNESS_SOURCE_SECTION = 'harness:source' /** - * Add a global prompt section naming the on-disk path to the harness source - * checkout the running bin was launched from, so the agent knows where its own - * source lives (the self-referential `dsh-tool-cordis` toolset reads and edits - * it). Call once on the settled boot context ({@link boot}); the section orders - * just after the harness identity opener (`-100`) and before the deployment + * Add a global prompt section naming the on-disk harness source checkout while + * explicitly distinguishing it from the task workspace and current working + * directory. The self-referential `dsh-tool-cordis` toolset reads and edits this + * checkout. Call once on the settled boot context ({@link boot}); the section + * orders just after the harness identity opener (`-100`) and before the deployment * persona (`0`). A booted tree with no `systemPrompt` service has no prompt to * augment, so this is then a no-op that returns `undefined`. The section is * registered against the `systemPrompt` service's fiber, so a dev HMR reload of @@ -232,6 +232,6 @@ export function addHarnessSourceSection(ctx: Context, sourceRoot: string): (() = return systemPrompt.section({ name: HARNESS_SOURCE_SECTION, order: -99, - text: `Your own source code is the checkout at ${sourceRoot}; you can read it there to learn how dsh works and how to extend it.`, + text: `The DeepSeek Harness implementation checkout is at ${sourceRoot}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself.`, }) } diff --git a/packages/ui/app-boot/tests/app-boot.spec.ts b/packages/ui/app-boot/tests/app-boot.spec.ts index db425c301a..ede5ba01de 100644 --- a/packages/ui/app-boot/tests/app-boot.spec.ts +++ b/packages/ui/app-boot/tests/app-boot.spec.ts @@ -193,9 +193,9 @@ describe('boot', () => { describe('addHarnessSourceSection', () => { const SOURCE_ROOT = `${sep}opt${sep}harness-src` - const EXPECTED = `Your own source code is the checkout at ${SOURCE_ROOT}; you can read it there to learn how dsh works and how to extend it.` + const EXPECTED = `The DeepSeek Harness implementation checkout is at ${SOURCE_ROOT}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself.` - it('adds the source path between the harness identity and the deployment persona', async () => { + it('distinguishes the source path from the current workdir between identity and persona', async () => { const ctx = new Context() try { await ctx.plugin(SystemPrompt, { persona: 'You are a coding agent.' }) From 9af9222871ead15b64871b8d57e7dba3f351c9bf Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 30 Jul 2026 15:35:21 +0800 Subject: [PATCH 2/7] test(tui): stabilize workdir snapshot output --- .../source-checkout-workdir/terminal.expected.txt | 4 ++-- examples/tui-agent/tests/tui.snapshot.ts | 11 +++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/examples/tui-agent/tests/snapshots/source-checkout-workdir/terminal.expected.txt b/examples/tui-agent/tests/snapshots/source-checkout-workdir/terminal.expected.txt index 81103c12bd..b9ecb5a553 100644 --- a/examples/tui-agent/tests/snapshots/source-checkout-workdir/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/source-checkout-workdir/terminal.expected.txt @@ -26,8 +26,8 @@ buffer style 0-46 fg=green 12| "$ pwd " style 0-4 dim -13| "/workspace/project " - style 0-59 dim +13| "/workspace/project " + style 0-17 dim 14| "[exit 0] " style 0-7 dim 15| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " diff --git a/examples/tui-agent/tests/tui.snapshot.ts b/examples/tui-agent/tests/tui.snapshot.ts index b96aaaf691..81cdfd6f22 100644 --- a/examples/tui-agent/tests/tui.snapshot.ts +++ b/examples/tui-agent/tests/tui.snapshot.ts @@ -56,6 +56,8 @@ interface Scenario { seedWorkspace?: boolean /** Add the launcher's model-visible DSH source checkout at this fixed path. */ harnessSourceRoot?: string + /** Replace the real `pwd` result with a portable fixed-length workspace path. */ + normalizePwdResult?: boolean /** * Load the opt-in `todo_write` tool for this scenario. The shipped tui-agent * config omits it, so only the todo-plan scenario (the enabled-path proof) @@ -103,6 +105,7 @@ const SCENARIOS: Scenario[] = [ expectedTools: ['bash'], recorded: true, harnessSourceRoot: '/opt/dsh-source', + normalizePwdResult: true, }, { name: 'parallel-file-reads', @@ -323,6 +326,14 @@ async function runScenario(scenario: Scenario): Promise { await cp(source, cwd, { recursive: true }) } ctx = await mountScenarioContext(scenario, cwd, displayCwd, fixtureFile, childFiles, replayRoot) + if (scenario.normalizePwdResult === true) { + ctx.on('tools/post-execute', async (exec, result, next) => { + const args = exec.arguments as { command?: unknown } + return exec.name === 'bash' && args.command === 'pwd' && !result.isError + ? { kind: 'accept', content: [{ type: 'text', text: '/workspace/project\n' }] } + : next() + }) + } const disposedSessions: Session[] = [] ctx.on('session/disposed', (session) => { disposedSessions.push(session) }) const workflowEvents: string[] = [] From 344ad0d6fbcf6ad72ebc71e3874bb16da7a08add Mon Sep 17 00:00:00 2001 From: ZiyaZhang Date: Fri, 31 Jul 2026 05:41:45 -0700 Subject: [PATCH 3/7] fix(client): floor the fork anchor to a real event seq MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fork button on a stopped assistant message was inert. Frozen interrupted nodes carry a flow-ordering seq of turnEnd.seq - 0.9, and session.fork takes a non-negative integer on the wire, so every such request was rejected as invalid-params before reaching the host — where an aborted turn's logged turn/end has always made it forkable. SessionsService.fork floors atSeq at the wire boundary. Flooring stays inside the anchor's own turn (every turn opens with turn/start), so the host's first-turn/end-at-or-after cut still closes on that turn. --- ...-fork-anchor-floors-to-event-seq.i18n.yaml | 6 ++++ ...6-07-31-fork-anchor-floors-to-event-seq.md | 35 +++++++++++++++++++ ...7-31-fork-anchor-floors-to-event-seq.zh.md | 35 +++++++++++++++++++ .../runtime/src/client/sessions/service.ts | 8 ++++- .../runtime/tests/sessions-service.spec.ts | 11 ++++++ .../apiproxy/tests/api-proxy-fork.spec.ts | 29 ++++++++++++--- 6 files changed, 119 insertions(+), 5 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-07-31-fork-anchor-floors-to-event-seq.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-07-31-fork-anchor-floors-to-event-seq.md create mode 100644 .agents/notes/implemented/bug-fix/2026-07-31-fork-anchor-floors-to-event-seq.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-fork-anchor-floors-to-event-seq.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-fork-anchor-floors-to-event-seq.i18n.yaml new file mode 100644 index 0000000000..c4a5595fea --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-31-fork-anchor-floors-to-event-seq.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/bug-fix/2026-07-31-fork-anchor-floors-to-event-seq.md +2026-07-31-fork-anchor-floors-to-event-seq.md: 2cc8d66942442aca69c930be880cc41d56bced13 +2026-07-31-fork-anchor-floors-to-event-seq.zh.md: 9f443f3d5165c01c6585d33bd6a37e90e9b3bf95 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-fork-anchor-floors-to-event-seq.md b/.agents/notes/implemented/bug-fix/2026-07-31-fork-anchor-floors-to-event-seq.md new file mode 100644 index 0000000000..2cc8d66942 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-31-fork-anchor-floors-to-event-seq.md @@ -0,0 +1,35 @@ +# Agent Note: Fork anchor floors to an event seq + +Status: implemented + +English | [中文](2026-07-31-fork-anchor-floors-to-event-seq.zh.md) + +## Problem + +The fork button on a stopped assistant message did nothing at all — no child session, no error, no visible reaction. + +The frozen node behind that message is not a log event. Both the live projection and the history replay mint it with a flow-ordering seq of `turnEnd.seq - 0.9`, placing it strictly after every event of the aborted turn and before the next one, and the chat view hands that node seq to the fork entry point unchanged. `session.fork` accepts a non-negative integer on the wire, so a fractional anchor is rejected as invalid-params before the request reaches the host, and the chat entry's fork call swallows failures. Nothing distinguished the rejection from an inert button. + +The host's cut rule was never the obstacle. An aborted turn ends with a logged `turn/end` carrying reason `aborted`, so it is a completed prefix like any other and the anchor simply never arrived. + +## Decision + +`SessionsService.fork` floors `atSeq` before the RPC. The fractional-seq convention belongs to `dsh-client-runtime`, which mints it in both the live and replay projections, so the same package converts it back to a real event seq at the wire boundary instead of every UI caller remembering to. Integer anchors are unaffected. + +Flooring lands inside the anchor's own turn rather than clipping backward: every turn opens with `turn/start`, so `turnEnd.seq - 1` cannot itself be an earlier turn's `turn/end`. The host's first-`turn/end`-at-or-after rule then closes on the turn the reader clicked, matching the whole-turn semantics the message-level fork button already promised for completed turns. + +The apiproxy fork suite pins the host half of the contract: a floored anchor inside an aborted turn cuts through that turn and seeds the child with it. + +## Alternatives considered + +**Accept fractional `atSeq` on the wire.** Rejected because the host contract is an event seq, not a position on a continuum; the fractional form is one client's rendering convention, and admitting it would leave `atSeq` alone among the seq-carrying payloads in taking non-integers. + +**Hide the fork button on interrupted messages.** Rejected because forking a turn the reader deliberately stopped is one of the strongest reasons to fork at all, and the capability worked host-side the whole time. + +**Floor in the chat entry's `forkAt` adapter.** Rejected because `ui-conversation` consumes the fractional convention without owning it; any second fork entry point would have to rediscover the same conversion. + +## Consequences + +Forking from a stopped turn produces a child seeded through that turn's `turn/end`. The frozen partial text is reconstructed from chunk events and was never an `assistant/message`, so it stays out of the child's model transcript exactly as it stays out of the source's on resume — the child resumes from the same context the source would. + +Fork failures stay silent in the chat entry. This bug survived because that call site discards its rejection; surfacing fork errors in the UI is a separate change. diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-fork-anchor-floors-to-event-seq.zh.md b/.agents/notes/implemented/bug-fix/2026-07-31-fork-anchor-floors-to-event-seq.zh.md new file mode 100644 index 0000000000..9f443f3d51 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-31-fork-anchor-floors-to-event-seq.zh.md @@ -0,0 +1,35 @@ +# Agent Note: fork 锚点向下取整到事件 seq + +Status: implemented + +[English](2026-07-31-fork-anchor-floors-to-event-seq.md) | 中文 + +## 问题 + +在已停止的助手消息上点 fork 毫无反应——没有子会话,没有报错,也没有任何可见变化。 + +这条消息背后的冻结节点并不是日志事件。实时投影和历史回放都用 `turnEnd.seq - 0.9` 这个排序坐标来生成它,让它严格落在被中断轮次的所有事件之后、下一轮之前,而 chat 视图原样把这个节点 seq 交给 fork 入口。`session.fork` 在 wire 上只接受非负整数,因此分数锚点在抵达 host 之前就被判为 invalid-params,而 chat 入口的 fork 调用又吞掉了失败。于是被拒绝和按钮失灵在表现上毫无区别。 + +host 的切分规则从来不是障碍。被中止的轮次会记录一条 reason 为 `aborted` 的 `turn/end`,它和其他轮次一样是可切分的完整前缀——只是锚点根本没送到。 + +## 决策 + +`SessionsService.fork` 在发起 RPC 前对 `atSeq` 向下取整。分数 seq 这个约定属于 `dsh-client-runtime`,实时投影和回放投影都由它生成,因此也由同一个包在跨出 wire 边界时把它换回真实事件 seq,而不是要求每个 UI 调用方各自记得转换。整数锚点不受影响。 + +向下取整落在锚点自身所在的轮次内,不会回退:每一轮都以 `turn/start` 开头,所以 `turnEnd.seq - 1` 不可能是上一轮的 `turn/end`。host 随后按「首个位于锚点或其之后的 `turn/end`」收口,命中的正是读者点击的那一轮,与消息级 fork 按钮在已完成轮次上一贯承诺的整轮语义一致。 + +apiproxy 的 fork 用例固定了 host 这一侧的契约:落在被中止轮次内的取整锚点会切穿该轮,并把它种进子会话。 + +## 备选方案 + +**让 wire 接受分数 `atSeq`。** 否决:host 契约要的是事件 seq,而不是连续坐标上的某个位置;分数形式只是某一个客户端的渲染约定,一旦放行,`atSeq` 会成为所有携带 seq 的载荷中唯一容忍非整数的字段。 + +**在已中断的消息上隐藏 fork 按钮。** 否决:从读者主动叫停的那一轮分叉,恰恰是最需要 fork 的场景之一,而 host 侧这个能力一直是好的。 + +**在 chat 入口的 `forkAt` 适配器里取整。** 否决:`ui-conversation` 只是分数约定的消费方,并不拥有它;将来任何第二个 fork 入口都得把同样的转换重新发现一遍。 + +## 影响 + +从已停止的轮次 fork 会得到一个种子切到该轮 `turn/end` 的子会话。被冻结的残缺文本是从 chunk 事件重建出来的,从未成为 `assistant/message`,因此它不会进入子会话的模型上下文——正如源会话恢复时它也不会进入一样,子会话拿到的上下文与源会话一致。 + +fork 失败在 chat 入口仍然是静默的。这个 bug 能存活至今,正是因为该调用点丢弃了自己的 rejection;把 fork 错误呈现到 UI 上是另一件事。 diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index 93ecb3c791..fcc89930fa 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -388,6 +388,9 @@ export class SessionsService implements ISessions { * cut (the boundary is the first turn/end at or after it; an in-log * anchor in an open turn is unavailable rather than clipped backward), * and whether to increment an inherited durable title before resolving. + * A fractional anchor floors to a real event seq: the frozen nodes of an + * interrupted turn carry flow-ordering seqs between two events, and the + * wire takes integers only. * @returns the child session id. * @throws {SessionForkError} with the source id. * @throws {Error} when a requested child-title rename fails after creation. @@ -402,7 +405,10 @@ export class SessionsService implements ISessions { : undefined const result = await this.manager.fork({ sessionId: opts.sessionId, - ...(opts.atSeq === undefined ? {} : { atSeq: opts.atSeq }), + // Flooring lands inside the anchor's own turn (every turn opens with a + // turn/start), so the host's first-turn/end-at-or-after cut still ends + // on that turn — never clipped back to the previous one. + ...(opts.atSeq === undefined ? {} : { atSeq: Math.floor(opts.atSeq) }), }) if (!result.ok) throw new SessionForkError(result.error, opts.sessionId) this.projectList() diff --git a/packages/client/runtime/tests/sessions-service.spec.ts b/packages/client/runtime/tests/sessions-service.spec.ts index 9fabb0d8de..eb12fd71cb 100644 --- a/packages/client/runtime/tests/sessions-service.spec.ts +++ b/packages/client/runtime/tests/sessions-service.spec.ts @@ -455,6 +455,17 @@ describe('fork', () => { }) }) + it('floors a fractional anchor to the real event seq the wire accepts', async () => { + const b = bench() + await feedList(b, [{ id: 'source', cwd: '/work' }]) + b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child') })) + + // The frozen node of an interrupted turn carries turnEnd.seq - 0.9. + await expect(b.svc.fork({ sessionId: sid('source'), atSeq: 41.1 })).resolves.toBe('child') + + expect(b.api.callsOf('session.fork')).toEqual([{ sessionId: 'source', atSeq: 41 }]) + }) + it('does not rename without the title policy or a durable source title', async () => { const b = bench() await feedList(b, [{ id: 'source', cwd: '/work' }]) diff --git a/packages/host/apiproxy/tests/api-proxy-fork.spec.ts b/packages/host/apiproxy/tests/api-proxy-fork.spec.ts index 797bca29f2..57f854c1ea 100644 --- a/packages/host/apiproxy/tests/api-proxy-fork.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-fork.spec.ts @@ -46,7 +46,10 @@ async function composed(): Promise { return ctx } -function liveAgent(ctx: Context, id: string, turns: number, openTail = false): Session { +/** Tail turn appended after the completed ones: left open, or closed as aborted (a stopped turn). */ +type Tail = 'none' | 'open' | 'aborted' + +function liveAgent(ctx: Context, id: string, turns: number, tail: Tail = 'none'): Session { const session = ctx.sessions.create(sid(id), { meta: { cwd: '/proj' } }) for (let turn = 1; turn <= turns; turn++) { session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) @@ -56,12 +59,13 @@ function liveAgent(ctx: Context, id: string, turns: number, openTail = false): S }), { surfaceOp: 'append' }) session.append('turn/end', { turn, reason: { kind: 'completed' } }) } - if (openTail) { + if (tail !== 'none') { session.append('turn/start', { turn: turns + 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'open prompt' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) + if (tail === 'aborted') session.append('turn/end', { turn: turns + 1, reason: { kind: 'aborted' } }) } ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) return session @@ -92,7 +96,7 @@ describe('sessions.fork', () => { it('uses the last completed turn only for omitted and past-end anchors', async () => { const ctx = await composed() - const source = liveAgent(ctx, 'session-tail', 2, true) + const source = liveAgent(ctx, 'session-tail', 2, 'open') const proxy = api(ctx) const expectedTypes = [ 'turn/start', 'user/message', 'turn/end', @@ -114,9 +118,26 @@ describe('sessions.fork', () => { await ctx.fiber.dispose() }) + it('cuts through an aborted turn: stopped is closed, not open', async () => { + const ctx = await composed() + const source = liveAgent(ctx, 'session-aborted', 1, 'aborted') + // What a stopped message's fork button anchors on: the frozen node sits + // one event before its turn/end, floored client-side to that event's seq. + const anchor = (source.events.at(-1)?.seq ?? 0) - 1 + const response = await api(ctx).sessions.fork(request({ sessionId: source.id, atSeq: anchor })) + expect(response.result.ok).toBe(true) + if (!response.result.ok) return + expect(ctx.sessions.get(response.result.value.sessionId)?.events.map(event => event.type)).toEqual([ + 'turn/start', 'user/message', 'turn/end', + 'turn/start', 'user/message', 'turn/end', + 'session/end-seed', + ]) + await ctx.fiber.dispose() + }) + it('rejects an in-log anchor whose turn is still open', async () => { const ctx = await composed() - const source = liveAgent(ctx, 'session-open', 1, true) + const source = liveAgent(ctx, 'session-open', 1, 'open') const anchor = source.events.at(-1)?.seq ?? 0 const response = await api(ctx).sessions.fork(request({ sessionId: source.id, atSeq: anchor })) expect(response.result).toMatchObject({ From 20e51abd4eac3ebfbfbc0aa2bc714f9d97e36cf1 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 31 Jul 2026 23:00:11 +0800 Subject: [PATCH 4/7] feat: rename experimental-meta/-upgrade to meta/upgrade behind --experimental gate --- ...8-launcher-owned-resume-identity.i18n.yaml | 4 +- ...26-07-28-launcher-owned-resume-identity.md | 4 +- ...07-28-launcher-owned-resume-identity.zh.md | 4 +- ...sh-guided-skill-session-commands.i18n.yaml | 4 +- ...07-28-dsh-guided-skill-session-commands.md | 8 +-- ...28-dsh-guided-skill-session-commands.zh.md | 8 +-- ...-07-28-dsh-meta-source-workspace.i18n.yaml | 4 +- .../2026-07-28-dsh-meta-source-workspace.md | 16 +++--- ...2026-07-28-dsh-meta-source-workspace.zh.md | 16 +++--- ...26-07-28-skill-invocation-policy.i18n.yaml | 4 +- .../2026-07-28-skill-invocation-policy.md | 2 +- .../2026-07-28-skill-invocation-policy.zh.md | 2 +- ...-31-experimental-subcommand-gate.i18n.yaml | 6 +++ ...2026-07-31-experimental-subcommand-gate.md | 31 +++++++++++ ...6-07-31-experimental-subcommand-gate.zh.md | 31 +++++++++++ apps/cli/README.i18n.yaml | 4 +- apps/cli/README.md | 8 +-- apps/cli/README.zh.md | 8 +-- apps/cli/src/args.ts | 54 ++++++++++++------- apps/cli/src/bin.ts | 3 +- apps/cli/src/tui.ts | 12 ++--- apps/cli/tests/args.spec.ts | 49 +++++++++++------ docs/config-catalog.md | 2 +- packages/ui/tui/README.i18n.yaml | 4 +- packages/ui/tui/README.md | 2 +- packages/ui/tui/README.zh.md | 2 +- packages/ui/tui/src/config.ts | 2 +- packages/ui/tui/src/index.ts | 4 +- 28 files changed, 200 insertions(+), 98 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-31-experimental-subcommand-gate.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-31-experimental-subcommand-gate.md create mode 100644 .agents/notes/implemented/feature/2026-07-31-experimental-subcommand-gate.zh.md diff --git a/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.i18n.yaml index 3b0e42df8c..c4612a4b7d 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.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/architecture/2026-07-28-launcher-owned-resume-identity.md -2026-07-28-launcher-owned-resume-identity.md: 167c9e848a9101c9d1e93cf3af968b00279db32b -2026-07-28-launcher-owned-resume-identity.zh.md: 218b69581e65e8566ff1047603bb71c9537d3486 +2026-07-28-launcher-owned-resume-identity.md: da9b4571d154137d34ef3690e7b4aa9bc7bb9082 +2026-07-28-launcher-owned-resume-identity.zh.md: 51ccffd7bb9c8eeda03afe2528d3cb4250e2d906 diff --git a/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.md b/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.md index 167c9e848a..da9b4571d1 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.md +++ b/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.md @@ -10,7 +10,7 @@ Two facts a launcher owns were shipped as deployment config keys on the TUI app Routing them through YAML made them silently droppable. `@cordisjs/plugin-include` applies a targeted patch by replacing whole top-level keys (`target[key] = value`), so a personal `~/.dsh/config.yaml` patching the `tui-agent` entry's `config` replaces the shipped block entirely. A user overlay written to change provider and model therefore deleted every resume key it did not restate, and nothing reported it: absent `resumeCommand` legitimately means "no fallback configured". -Both failures were live in one real overlay. The exit hint stopped printing, because the overlay omitted `resumeCommand`. Worse, the overlay carried `resumeSessionId: !!js process.env.RESUME_SESSION_ID` — a stale line from before [the env-var bridge was removed](../../archived/architecture/2026-07-24-dsh-commander-argument-adapter.md) — which overwrote the shipped `!!js "typeof resumeSessionId === 'string' ? …"` intake with a read of a variable nothing sets. `dsh --resume ` then started a *fresh* session and said nothing, reproduced directly: the banner showed a newly minted id, not the requested one. The [`dsh experimental-meta`](../feature/2026-07-28-dsh-meta-source-workspace.md) note had recorded this silent resume as an unexplained pre-existing defect; the overlay's shallow replacement is the cause. +Both failures were live in one real overlay. The exit hint stopped printing, because the overlay omitted `resumeCommand`. Worse, the overlay carried `resumeSessionId: !!js process.env.RESUME_SESSION_ID` — a stale line from before [the env-var bridge was removed](../../archived/architecture/2026-07-24-dsh-commander-argument-adapter.md) — which overwrote the shipped `!!js "typeof resumeSessionId === 'string' ? …"` intake with a read of a variable nothing sets. `dsh --resume ` then started a *fresh* session and said nothing, reproduced directly: the banner showed a newly minted id, not the requested one. The [`dsh meta`](../feature/2026-07-28-dsh-meta-source-workspace.md) note had recorded this silent resume as an unexplained pre-existing defect; the overlay's shallow replacement is the cause. A config key cannot express these facts safely, because the deployment is not the authority on them. @@ -25,7 +25,7 @@ Both sit beside the existing `tuiResumeHost` host capability, which set the prec Identity belongs to `agent-loop` because that is the plugin which creates configured agents, and because a patch replaces a row's whole `config`: an overlay repointing the agent row's model route would erase a launcher-set identity key. See [the shared-base overlay note](../simplification/2026-07-29-shared-base-config-overlays.md). -`apps/cli` mints or selects the id and builds the line from the invocation it is reproducing, sharing one `resumeArgs` helper with the `/resume` execve handoff so the printed command and the in-place handoff cannot diverge. The line names `--config` when one was passed. Resume always re-enters the default surface through `dsh --resume `; `dsh experimental-meta` accepts no options and always starts fresh. +`apps/cli` mints or selects the id and builds the line from the invocation it is reproducing, sharing one `resumeArgs` helper with the `/resume` execve handoff so the printed command and the in-place handoff cannot diverge. The line names `--config` when one was passed. Resume always re-enters the default surface through `dsh --resume `; `dsh meta` accepts no default-surface options and always starts fresh. **`ctx.provide` is the only channel from launcher argv into a Loader-mounted plugin.** Config `!!js` expressions evaluate as `with (entry.ctx) { eval(expr) }` (`vendor/loader/src/config/utils.ts`), so a bare identifier resolves against the entry's context and nothing else reaches it. The slot therefore cannot be removed while the app bundle is mounted from YAML; what changes is that it is now internal launcher↔app plumbing instead of a documented key a config author must wire correctly. diff --git a/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.zh.md b/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.zh.md index 218b69581e..51ccffd7bb 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.zh.md @@ -10,7 +10,7 @@ Status: implemented 把它们经由 YAML 传递,使其可被静默丢弃。`@cordisjs/plugin-include` 施加定向补丁的方式是替换整个顶层键(`target[key] = value`),因此一份对 `tui-agent` 条目的 `config` 打补丁的个人 `~/.dsh/config.yaml`,会把交付时的整块内容整体替换掉。于是,一份为改动 provider 和 model 而写的用户 overlay,会删掉它未重述的每一个 resume 键,且没有任何东西报告这一点:缺失 `resumeCommand` 合法地意味着「未配置回退」。 -两处失效在同一份真实的 overlay 中同时存在。退出提示不再打印,因为该 overlay 省略了 `resumeCommand`。更糟的是,该 overlay 带着 `resumeSessionId: !!js process.env.RESUME_SESSION_ID`——一行来自 [env 变量桥被移除](../../archived/architecture/2026-07-24-dsh-commander-argument-adapter.md)之前的陈旧代码——它用一次对某个无人设置的变量的读取,覆盖掉了交付时的 `!!js "typeof resumeSessionId === 'string' ? …"` 入口。此后 `dsh --resume ` 会开启一个*全新*会话且什么都不说,并被直接复现:banner 显示的是一个新铸造的 id,而非所请求的那个。[`dsh experimental-meta`](../feature/2026-07-28-dsh-meta-source-workspace.md) note 曾把这次静默的 resume 记为一处无法解释的既有缺陷;而 overlay 的浅层替换正是其成因。 +两处失效在同一份真实的 overlay 中同时存在。退出提示不再打印,因为该 overlay 省略了 `resumeCommand`。更糟的是,该 overlay 带着 `resumeSessionId: !!js process.env.RESUME_SESSION_ID`——一行来自 [env 变量桥被移除](../../archived/architecture/2026-07-24-dsh-commander-argument-adapter.md)之前的陈旧代码——它用一次对某个无人设置的变量的读取,覆盖掉了交付时的 `!!js "typeof resumeSessionId === 'string' ? …"` 入口。此后 `dsh --resume ` 会开启一个*全新*会话且什么都不说,并被直接复现:banner 显示的是一个新铸造的 id,而非所请求的那个。[`dsh meta`](../feature/2026-07-28-dsh-meta-source-workspace.md) note 曾把这次静默的 resume 记为一处无法解释的既有缺陷;而 overlay 的浅层替换正是其成因。 一个配置键无法安全地表达这些事实,因为部署方并非它们的权威。 @@ -25,7 +25,7 @@ Status: implemented 身份归属于 `agent-loop`,因为它才是创建所配置 agent 的插件;也因为 patch 会整体替换配置项的 `config`:重新指向 agent 配置项模型路由的 overlay 会抹掉启动器设置的身份键。参见[共享 base overlay note](../simplification/2026-07-29-shared-base-config-overlays.md)。 -`apps/cli` 铸造或选定 id,并依据它所复现的那次调用构建该行,与 `/resume` 的 execve 移交共用同一个 `resumeArgs` 助手,从而使打印出的命令与原地移交不会分歧。该行会在传入了 `--config` 时将其写入命令。恢复始终通过 `dsh --resume ` 重新进入默认界面;`dsh experimental-meta` 不接受任何选项,并且总是启动新会话。 +`apps/cli` 铸造或选定 id,并依据它所复现的那次调用构建该行,与 `/resume` 的 execve 移交共用同一个 `resumeArgs` 助手,从而使打印出的命令与原地移交不会分歧。该行会在传入了 `--config` 时将其写入命令。恢复始终通过 `dsh --resume ` 重新进入默认界面;`dsh meta` 不接受任何默认界面选项,并且总是启动新会话。 **`ctx.provide` 是从启动器 argv 进入被 Loader 挂载的插件的唯一通道。** 配置的 `!!js` 表达式会以 `with (entry.ctx) { eval(expr) }`(`vendor/loader/src/config/utils.ts`)求值,因此一个裸标识符会针对该条目的上下文解析,别无它物可达。于是只要应用 bundle 仍从 YAML 挂载,这个槽位就无法被移除;变化之处在于它现在是启动器↔应用之间的内部管线,而不再是一个配置作者必须正确接线的、有文档记载的键。 diff --git a/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.i18n.yaml index f50a69e7a8..880278708e 100644 --- a/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.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-28-dsh-guided-skill-session-commands.md -2026-07-28-dsh-guided-skill-session-commands.md: 9d5341880e92d88781278238f21380919c962820 -2026-07-28-dsh-guided-skill-session-commands.zh.md: 90c47d64173161fa4ef1f975de37b9cd42e02c99 +2026-07-28-dsh-guided-skill-session-commands.md: 241bf3c0af693f3ccc318bf44bbeccb6c5d73d94 +2026-07-28-dsh-guided-skill-session-commands.zh.md: f74b2540889900a01b7590344a3bff62e8a9fe4a diff --git a/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.md b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.md index 9d5341880e..241bf3c0af 100644 --- a/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.md +++ b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.md @@ -1,4 +1,4 @@ -# Agent Note: `dsh migrate`/`dsh experimental-upgrade` seed the first turn with a skill +# Agent Note: `dsh migrate`/`dsh upgrade` seed the first turn with a skill Status: implemented @@ -10,13 +10,13 @@ Two recurring flows begin with the user manually invoking one skill and answerin ## Decision -`dsh migrate` and `dsh experimental-upgrade` boot the ordinary TUI as a fresh session whose first turn auto-invokes a bundled skill (`dsh-migrate`, `dsh-upgrade`), exactly as if the user typed `/skill:` and pressed Enter. +`dsh migrate` and `dsh upgrade` boot the ordinary TUI as a fresh session whose first turn auto-invokes a bundled skill (`dsh-migrate`, `dsh-upgrade`), exactly as if the user typed `/skill:` and pressed Enter. The seed reuses the existing TUI skill path, not a new one. `createTuiChat` already has `invokeSkill(name, instructions)` — the code a typed `/skill:` runs, including the "Unknown skill" notice. The launcher passes the skill name to the TUI through a new boot-context slot `INITIAL_SKILL_KEY` (`tuiInitialSkill`), mirroring `CONFIGURED_AGENT_IDENTITIES_KEY`/`TUI_GOODBYE_MESSAGE_KEY`: `ctx.provide` is the only channel from launcher argv into a Loader-mounted plugin. The TUI's `apply()` reads the slot and folds it into `config.initialSkill`; after `ui.start()` succeeds, `createTuiChat` fires `invokeSkill(config.initialSkill, '')` once when set. **Freshness is gated in the launcher, not the TUI.** `runSkillSession` always mints a fresh session and provides the slot only when `resumeSessionId === undefined`, so a later `dsh --resume ` of that session is an ordinary TUI session with no re-injection. The TUI stays generic: it invokes whatever skill it is handed, once, at startup. -**`migrate`/`upgrade` take no options.** Unlike `meta`, they carry no `--resume`, `--config`, or `-p`; a guided fresh-session entry has nothing to resume or reconfigure. Any leaked default-surface option fails loud, matching the `web`/`meta` rejection pattern in the Commander adapter. The two modes share one `SkillSessionInvocation` discriminant (`mode: 'migrate' | 'upgrade'`); `bin.ts` maps the mode to `dsh-${mode}`. +**`migrate`/`upgrade` take no options beyond the [experimental gate](2026-07-31-experimental-subcommand-gate.md)'s `--experimental`.** They carry no `--resume`, `--config`, or `-p`; a guided fresh-session entry has nothing to resume or reconfigure. Any leaked default-surface option fails loud, matching the `web`/`meta` rejection pattern in the Commander adapter. The two modes share one `SkillSessionInvocation` discriminant (`mode: 'migrate' | 'upgrade'`); `bin.ts` maps the mode to `dsh-${mode}`. The `dsh-migrate` skill is bundled under `skills/` (shipped through `DSH_BUNDLED_SKILL_DIR`, like `dsh-upgrade`). It asks which source agent (opencode/pi/Claude Code/Codex) if unstated, then maps each capability — workspace instructions, personal overlay, skills, hooks, MCP, API/env — to its DSH equivalent, grounded in the actual repo surfaces (the `hooks-claude`/`hooks-codex` bridges, `~/.dsh/{config.yaml,.env,AGENTS.md,skills/}`, `AGENTS.md`/`CLAUDE.md`, `mcporter`), and states plainly when a capability has no equivalent. @@ -26,7 +26,7 @@ The `dsh-migrate` skill is bundled under `skills/` (shipped through `DSH_BUNDLED `packages/ui/tui/tests/tui.spec.ts` gains two fake-terminal cases in the existing skill describe block: `config.initialSkill` set delivers the rendered skill body as the first turn with no user input, and an unknown initial skill reports a notice without sending. `runSkillSession` itself is composition inside the module's `v8 ignore` block, like `runTui`/`runMeta`. -No keyless PTY snapshot: per the maintainer's scope call for this change, unit coverage plus interactive verification suffices, and the seed rides the already-snapshotted `/skill:` render path. Both commands were verified interactively in tmux from a scratch cwd: `dsh migrate` loaded `dsh-migrate` and asked which source agent; `dsh experimental-upgrade` loaded `dsh-upgrade`, which pulled in `dsh-customize` and began checkout discovery. +No keyless PTY snapshot: per the maintainer's scope call for this change, unit coverage plus interactive verification suffices, and the seed rides the already-snapshotted `/skill:` render path. Both commands were verified interactively in tmux from a scratch cwd: `dsh migrate` loaded `dsh-migrate` and asked which source agent; `dsh upgrade` loaded `dsh-upgrade`, which pulled in `dsh-customize` and began checkout discovery. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.zh.md b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.zh.md index 90c47d6417..f74b254088 100644 --- a/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.zh.md @@ -1,4 +1,4 @@ -# Agent Note:`dsh migrate`/`dsh experimental-upgrade` 以 skill 播种首轮 +# Agent Note:`dsh migrate`/`dsh upgrade` 以 skill 播种首轮 Status: implemented @@ -10,13 +10,13 @@ Status: implemented ## 决策 -`dsh migrate` 与 `dsh experimental-upgrade` 以全新会话启动普通 TUI,其首轮自动调用一个内置 skill(`dsh-migrate`、`dsh-upgrade`),效果等同于用户键入 `/skill:` 并回车。 +`dsh migrate` 与 `dsh upgrade` 以全新会话启动普通 TUI,其首轮自动调用一个内置 skill(`dsh-migrate`、`dsh-upgrade`),效果等同于用户键入 `/skill:` 并回车。 播种复用现有的 TUI skill 路径,而非新增一条。`createTuiChat` 已有 `invokeSkill(name, instructions)`——即键入 `/skill:` 所走的代码,包含“未知 skill”通知。启动器通过一个新的启动上下文槽 `INITIAL_SKILL_KEY`(`tuiInitialSkill`)把 skill 名称传给 TUI,与 `CONFIGURED_AGENT_IDENTITIES_KEY`/`TUI_GOODBYE_MESSAGE_KEY` 一致:`ctx.provide` 是从启动器 argv 进入 Loader 挂载插件的唯一通道。TUI 的 `apply()` 读取该槽并折叠进 `config.initialSkill`;`ui.start()` 成功后,`createTuiChat` 在其被设置时调用一次 `invokeSkill(config.initialSkill, '')`。 **新鲜性在启动器而非 TUI 中把关。** `runSkillSession` 总是创建全新会话,且仅在 `resumeSessionId === undefined` 时提供该槽,因此之后 `dsh --resume ` 恢复该会话时是普通 TUI 会话,不会重复注入。TUI 保持通用:它只是把接到的 skill 在启动时调用一次。 -**`migrate`/`upgrade` 不接受任何选项。** 与 `meta` 不同,它们不带 `--resume`、`--config` 或 `-p`;引导式全新会话入口没有可恢复或可重配置的内容。任何泄漏的默认界面选项都会明确报错,与 Commander 适配器中 `web`/`meta` 的拒绝模式一致。两个 mode 共用一个 `SkillSessionInvocation` 判别式(`mode: 'migrate' | 'upgrade'`);`bin.ts` 将 mode 映射为 `dsh-${mode}`。 +**`migrate`/`upgrade` 除[实验性门槛](2026-07-31-experimental-subcommand-gate.md)的 `--experimental` 外不接受任何选项。** 它们不带 `--resume`、`--config` 或 `-p`;引导式全新会话入口没有可恢复或可重配置的内容。任何泄漏的默认界面选项都会明确报错,与 Commander 适配器中 `web`/`meta` 的拒绝模式一致。两个 mode 共用一个 `SkillSessionInvocation` 判别式(`mode: 'migrate' | 'upgrade'`);`bin.ts` 将 mode 映射为 `dsh-${mode}`。 `dsh-migrate` skill 内置于 `skills/`(经 `DSH_BUNDLED_SKILL_DIR` 交付,与 `dsh-upgrade` 相同)。若未说明源 agent,它会先询问是哪个(opencode/pi/Claude Code/Codex),再把每项能力——workspace 指令、个人覆盖、skills、hooks、MCP、API/env——映射到对应的 DSH 等价物,并基于仓库实际的表面(`hooks-claude`/`hooks-codex` 桥、`~/.dsh/{config.yaml,.env,AGENTS.md,skills/}`、`AGENTS.md`/`CLAUDE.md`、`mcporter`)落地;当某能力无等价物时明确说明。 @@ -26,7 +26,7 @@ Status: implemented `packages/ui/tui/tests/tui.spec.ts` 在既有 skill describe 块中新增两个伪终端用例:设置 `config.initialSkill` 时无需用户输入即把渲染后的 skill 正文作为首轮投递;未知的初始 skill 以通知形式报告且不发送。`runSkillSession` 本身是模块 `v8 ignore` 块内的组装,与 `runTui`/`runMeta` 相同。 -无 keyless PTY 快照:依据维护者对本次改动的范围裁定,单元覆盖加交互式验证已足够,且播种走的是已有快照的 `/skill:` 渲染路径。两个命令均已在 tmux 中从临时 cwd 交互式验证:`dsh migrate` 加载 `dsh-migrate` 并询问源 agent;`dsh experimental-upgrade` 加载 `dsh-upgrade`,后者引入 `dsh-customize` 并开始 checkout 发现。 +无 keyless PTY 快照:依据维护者对本次改动的范围裁定,单元覆盖加交互式验证已足够,且播种走的是已有快照的 `/skill:` 渲染路径。两个命令均已在 tmux 中从临时 cwd 交互式验证:`dsh migrate` 加载 `dsh-migrate` 并询问源 agent;`dsh upgrade` 加载 `dsh-upgrade`,后者引入 `dsh-customize` 并开始 checkout 发现。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.i18n.yaml index 9f62d0d4ea..345e563890 100644 --- a/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.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-28-dsh-meta-source-workspace.md -2026-07-28-dsh-meta-source-workspace.md: be414ddbf63dd95791f9ca956b2f345b1fc8c685 -2026-07-28-dsh-meta-source-workspace.zh.md: 86fc135db290766cb6fc2abefb194120416bfd9d +2026-07-28-dsh-meta-source-workspace.md: ab6050150f6321197686e1ad649555d3eaa7c374 +2026-07-28-dsh-meta-source-workspace.zh.md: c6f570498f10f16746b7aa5756b2e04074ae6a9e diff --git a/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.md b/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.md index be414ddbf6..ab6050150f 100644 --- a/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.md +++ b/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.md @@ -1,4 +1,4 @@ -# Agent Note: `dsh experimental-meta` boots the TUI over the harness checkout +# Agent Note: `dsh meta` boots the TUI over the harness checkout Status: implemented @@ -10,19 +10,19 @@ English | [中文](2026-07-28-dsh-meta-source-workspace.zh.md) ## Decision -`dsh experimental-meta` boots the ordinary TUI with the harness checkout as the workspace, from any directory. +`dsh meta` boots the ordinary TUI with the harness checkout as the workspace, from any directory. The target is `SOURCE_ROOT` in `apps/cli/src/tui.ts` — `fileURLToPath(new URL('../../..', import.meta.url))`, three hops up from `apps/cli/{src,lib}` — the same constant the `harness:source` prompt section already names, so the workspace and the path advertised to the model cannot drift. It follows the launcher's real path, so a PATH symlink through `current` resolves to whichever staging worktree is active. -The mechanism is one `process.chdir(workspace)` inside `runTui`, guarded by an optional third parameter that only the `experimental-meta` dispatch passes. The cwd *is* the workspace seam in the shipped tree: `examples/tui-agent/cordis.yml` derives the session cwd (`!!js process.cwd()`), the `./.sessions` persistence root, and the HMR watch root (`root: ['.']`) from it, so one chdir moves all three together and meta sessions land in the checkout's gitignored `.sessions/`. It runs after both `.env` layers are loaded — the bin's invoking-directory load and the personal one — so the ambient > project > personal precedence is untouched. `DEFAULT_CONFIG` and `SOURCE_ROOT` are absolute and TUI mode passes no snapshot mode, so config resolution is chdir-independent. +The mechanism is one `process.chdir(workspace)` inside `runTui`, guarded by an optional third parameter that only the `meta` dispatch passes. The cwd *is* the workspace seam in the shipped tree: `examples/tui-agent/cordis.yml` derives the session cwd (`!!js process.cwd()`), the `./.sessions` persistence root, and the HMR watch root (`root: ['.']`) from it, so one chdir moves all three together and meta sessions land in the checkout's gitignored `.sessions/`. It runs after both `.env` layers are loaded — the bin's invoking-directory load and the personal one — so the ambient > project > personal precedence is untouched. `DEFAULT_CONFIG` and `SOURCE_ROOT` are absolute and TUI mode passes no snapshot mode, so config resolution is chdir-independent. -`experimental-meta` always starts a fresh session and accepts no options. `--config` would boot a foreign tree against the harness workspace, which is the default surface's `--config` case rather than this command; `-p` is not interactive, and resume re-enters the persisted session's own workspace through `dsh --resume `. Any leaked default-surface option fails loud. +`meta` always starts a fresh session and accepts no default-surface options; its only option is the [experimental gate](2026-07-31-experimental-subcommand-gate.md)'s `--experimental`. `--config` would boot a foreign tree against the harness workspace, which is the default surface's `--config` case rather than this command; `-p` is not interactive, and resume re-enters the persisted session's own workspace through `dsh --resume `. Any leaked default-surface option fails loud. ## Testing -`apps/cli/tests/args.spec.ts` pins routing for `experimental-meta`, rejection of every leaked default-surface option, and rejection of the former `meta` name. The dispatch itself is composition inside `bin.ts`'s existing `v8 ignore` block. +`apps/cli/tests/args.spec.ts` pins routing for `meta`, rejection of every leaked default-surface option, and rejection of the former `experimental-meta` name. The dispatch itself is composition inside `bin.ts`'s existing `v8 ignore` block. -There is no keyless PTY smoke for this mode. The smoke harness gives each run a temp cwd, but `dsh experimental-meta` deliberately chdirs to the real checkout, so a smoke would write `.sessions/` into the live tree mid-test. Covering it properly needs an injectable target directory — a test-only seam this note declines to add for a one-line chdir. +There is no keyless PTY smoke for this mode. The smoke harness gives each run a temp cwd, but `dsh meta` deliberately chdirs to the real checkout, so a smoke would write `.sessions/` into the live tree mid-test. Covering it properly needs an injectable target directory — a test-only seam this note declines to add for a one-line chdir. The mode was verified interactively instead. Launched from `$HOME`, a `pwd` tool call reports the checkout, git resolves to its branch, the session log lands under the checkout's `.sessions/` (leaving `~/.sessions` untouched and the tree free of unignored residue), and plain `dsh` from another directory still uses the invoking one. @@ -30,12 +30,12 @@ The mode was verified interactively instead. Launched from `$HOME`, a `pwd` tool **Thread an explicit workspace through `boot` and the config tree.** Avoids mutating process-wide state, but the shipped config reads the cwd in three places (`!!js process.cwd()`, `persistenceRoot`, HMR `root`), so each would need its own new plumbing and config key to stay consistent. `chdir` before boot expresses "this is the workspace" once, at the seam that already means it. -**An `--experimental-meta` flag on the default surface.** Rejected: the default surface is option-only so that subcommands do not collide with a positional, and a flag that silently relocates the workspace reads as a modifier of the current directory rather than a different target. `experimental-meta` alongside `web` matches the existing shape. +**An `--experimental-meta` flag on the default surface.** Rejected: the default surface is option-only so that subcommands do not collide with a positional, and a flag that silently relocates the workspace reads as a modifier of the current directory rather than a different target. `meta` alongside `web` matches the existing shape. **Resolve `~/.dsh/source/current` instead of the launcher's own path.** Rejected: it would diverge from the `harness:source` prompt path whenever a non-installed checkout's `bin/dsh` is invoked directly, telling the model one source root while working in another. ## Consequences -Starting a session on dsh's own source is `dsh experimental-meta` from anywhere, and the workspace is guaranteed to be the same checkout the model is told about. The command always starts fresh; an ordinary `dsh --resume ` later restores the session and enters its persisted workspace. +Starting a session on dsh's own source is `dsh meta` from anywhere, and the workspace is guaranteed to be the same checkout the model is told about. The command always starts fresh; an ordinary `dsh --resume ` later restores the session and enters its persisted workspace. `runTui` gains an optional third parameter, so the workspace override is visible at the one function that owns TUI composition rather than hidden in a second copy of it. diff --git a/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.zh.md b/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.zh.md index 86fc135db2..c6f570498f 100644 --- a/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.zh.md @@ -1,4 +1,4 @@ -# Agent Note:`dsh experimental-meta` 以 harness 检出为 workspace 启动 TUI +# Agent Note:`dsh meta` 以 harness 检出为 workspace 启动 TUI Status: implemented @@ -10,19 +10,19 @@ Status: implemented ## Decision -`dsh experimental-meta` 在任意目录下都以 harness 检出为 workspace 启动普通 TUI。 +`dsh meta` 在任意目录下都以 harness 检出为 workspace 启动普通 TUI。 目标是 `apps/cli/src/tui.ts` 中的 `SOURCE_ROOT`——`fileURLToPath(new URL('../../..', import.meta.url))`,从 `apps/cli/{src,lib}` 向上三级——与 `harness:source` 提示词段所用的常量完全相同,因此 workspace 与告知模型的路径不可能发生偏离。它跟随启动器的真实路径,所以经由 `current` 的 PATH 符号链接会解析到当前生效的那个 staging 工作树。 -机制是 `runTui` 内的一次 `process.chdir(workspace)`,由一个可选第三参数把守,只有 `experimental-meta` 分派会传入。在已交付的配置树中,cwd *就是* workspace 的接缝:`examples/tui-agent/cordis.yml` 由它派生出会话 cwd(`!!js process.cwd()`)、`./.sessions` 持久化根目录以及 HMR 监视根目录(`root: ['.']`),因此一次 chdir 会让三者一并移动,meta 会话则落在检出目录中被 gitignore 的 `.sessions/` 内。它在两层 `.env` 都加载之后执行——bin 对调用目录的加载与个人层加载——因此“环境中已有的值 > 项目 > 个人”的优先级不受影响。`DEFAULT_CONFIG` 与 `SOURCE_ROOT` 都是绝对路径,且 TUI 模式不传 snapshot mode,所以配置解析与 chdir 无关。 +机制是 `runTui` 内的一次 `process.chdir(workspace)`,由一个可选第三参数把守,只有 `meta` 分派会传入。在已交付的配置树中,cwd *就是* workspace 的接缝:`examples/tui-agent/cordis.yml` 由它派生出会话 cwd(`!!js process.cwd()`)、`./.sessions` 持久化根目录以及 HMR 监视根目录(`root: ['.']`),因此一次 chdir 会让三者一并移动,meta 会话则落在检出目录中被 gitignore 的 `.sessions/` 内。它在两层 `.env` 都加载之后执行——bin 对调用目录的加载与个人层加载——因此“环境中已有的值 > 项目 > 个人”的优先级不受影响。`DEFAULT_CONFIG` 与 `SOURCE_ROOT` 都是绝对路径,且 TUI 模式不传 snapshot mode,所以配置解析与 chdir 无关。 -`experimental-meta` 始终启动新会话,且不接受任何选项。`--config` 会针对 harness workspace 启动其他配置树,那是默认界面的 `--config` 场景,而不是该命令的场景;`-p` 并非交互式,恢复则通过 `dsh --resume ` 重新进入已持久化会话自身的 workspace。任何泄漏的默认界面选项都会明确报错。 +`meta` 始终启动新会话,且不接受任何默认界面选项;它唯一的选项是[实验性门槛](2026-07-31-experimental-subcommand-gate.md)的 `--experimental`。`--config` 会针对 harness workspace 启动其他配置树,那是默认界面的 `--config` 场景,而不是该命令的场景;`-p` 并非交互式,恢复则通过 `dsh --resume ` 重新进入已持久化会话自身的 workspace。任何泄漏的默认界面选项都会明确报错。 ## Testing -`apps/cli/tests/args.spec.ts` 钉住 `experimental-meta` 的路由、对每个泄漏的默认界面选项的拒绝,以及对旧名称 `meta` 的拒绝。该分派本身是 `bin.ts` 既有 `v8 ignore` 块内的组合代码。 +`apps/cli/tests/args.spec.ts` 钉住 `meta` 的路由、对每个泄漏的默认界面选项的拒绝,以及对旧名称 `experimental-meta` 的拒绝。该分派本身是 `bin.ts` 既有 `v8 ignore` 块内的组合代码。 -该 mode 没有 keyless PTY 冒烟测试。冒烟框架会为每次运行提供临时 cwd,但 `dsh experimental-meta` 刻意 chdir 到真实检出目录,因此冒烟测试会在测试中途把 `.sessions/` 写入实际工作树。要正确覆盖它需要一个可注入的目标目录——为了一行 chdir 而引入的测试专用 seam,本 note 不予采纳。 +该 mode 没有 keyless PTY 冒烟测试。冒烟框架会为每次运行提供临时 cwd,但 `dsh meta` 刻意 chdir 到真实检出目录,因此冒烟测试会在测试中途把 `.sessions/` 写入实际工作树。要正确覆盖它需要一个可注入的目标目录——为了一行 chdir 而引入的测试专用 seam,本 note 不予采纳。 取而代之的是交互式验证。从 `$HOME` 启动后,`pwd` 工具调用报告的是该检出目录,git 解析到其分支,会话日志落在该检出的 `.sessions/` 下(`~/.sessions` 未被触及,工作树也没有未被忽略的残留),并且从其他目录运行的普通 `dsh` 仍使用调用目录。 @@ -30,12 +30,12 @@ Status: implemented **通过 `boot` 与配置树显式传递 workspace。** 这可避免修改进程级状态,但已交付的配置在三处读取 cwd(`!!js process.cwd()`、`persistenceRoot`、HMR `root`),每一处都需要各自新增管线与配置键才能保持一致。启动前 chdir 只在本就表达该含义的接缝上表达一次“这就是 workspace”。 -**在默认界面上加一个 `--experimental-meta` 标志。** 拒绝:默认界面是纯选项形式,以免子命令与位置参数冲突;而一个会静默改变 workspace 的标志读起来像是对当前目录的修饰,而非另一个目标。`experimental-meta` 与 `web` 并列符合既有形态。 +**在默认界面上加一个 `--experimental-meta` 标志。** 拒绝:默认界面是纯选项形式,以免子命令与位置参数冲突;而一个会静默改变 workspace 的标志读起来像是对当前目录的修饰,而非另一个目标。`meta` 与 `web` 并列符合既有形态。 **解析 `~/.dsh/source/current` 而非启动器自身路径。** 拒绝:当直接调用某个非安装检出的 `bin/dsh` 时,它会与 `harness:source` 提示词路径产生偏离——告知模型一个源码根目录,却在另一个目录中工作。 ## Consequences -在 dsh 自身源码上开启会话变成了在任意位置执行 `dsh experimental-meta`,且该 workspace 必然就是告知模型的那个检出目录。该命令始终启动新会话;之后,普通的 `dsh --resume ` 会恢复该会话并进入其已持久化的 workspace。 +在 dsh 自身源码上开启会话变成了在任意位置执行 `dsh meta`,且该 workspace 必然就是告知模型的那个检出目录。该命令始终启动新会话;之后,普通的 `dsh --resume ` 会恢复该会话并进入其已持久化的 workspace。 `runTui` 新增一个可选第三参数,因此 workspace 覆盖是在拥有 TUI 组合逻辑的那唯一一个函数上可见的,而不是隐藏在它的第二份副本中。 diff --git a/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.i18n.yaml index c72f343373..f2b2b5b22e 100644 --- a/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.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-28-skill-invocation-policy.md -2026-07-28-skill-invocation-policy.md: e639db39c0e971ea6988ef6e9801ab71f8d1337f -2026-07-28-skill-invocation-policy.zh.md: 7e68f2cf28fbaa734dff477441fc8ab73e7367b7 +2026-07-28-skill-invocation-policy.md: f74b0bcfddb1699c48279b4d8b153cabf764b140 +2026-07-28-skill-invocation-policy.zh.md: 1a7117a382be224c5371964dd4ad3e916d4e0917 diff --git a/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.md b/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.md index e639db39c0..f74b0bcfdd 100644 --- a/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.md +++ b/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.md @@ -18,7 +18,7 @@ The local parser also exposed an internal camel-case spelling as frontmatter. Su The local provider accepts the exact kebab-case frontmatter keys `disable-model-invocation` and `user-invocable`. It accepts YAML booleans plus case-insensitive `true`/`false`, `yes`/`no`, `on`/`off`, and `1`/`0`, matching the practical boolean forms accepted by Claude skills. It maps `disable-model-invocation` to the inverse positive field and fills both positive fields from their defaults even when neither key is present. A camel-case external spelling or non-boolean invocation value drops the entire skill from discovery with a targeted warning; this pre-release repository does not keep an on-disk compatibility alias. Invocation data fails closed because ignoring it would default to permission and could expose the skill on a disabled surface, while wrong-typed optional `whenToUse` and `metadata` values are omitted because they do not decide invocation. -The model-facing `dsh-tool-skill` catalog and loader enforce `isModelInvocable`. The TUI `/skill:` autocomplete and exact loader enforce the user field locally, so a user-only skill is visible and loadable there even when it is absent from model discovery, without turning the optional skill peer into a runtime import. The launcher-seeded initial skill used by guided `dsh migrate` and `dsh experimental-upgrade` sessions follows this same TUI path and must remain user-invocable. The browser `skill.list` RPC serves a user-selected reference that still asks the model to load the skill, so it exposes the intersection of model- and user-invocable skills; no direct browser skill-loading RPC is added. +The model-facing `dsh-tool-skill` catalog and loader enforce `isModelInvocable`. The TUI `/skill:` autocomplete and exact loader enforce the user field locally, so a user-only skill is visible and loadable there even when it is absent from model discovery, without turning the optional skill peer into a runtime import. The launcher-seeded initial skill used by guided `dsh migrate` and `dsh upgrade` sessions follows this same TUI path and must remain user-invocable. The browser `skill.list` RPC serves a user-selected reference that still asks the model to load the skill, so it exposes the intersection of model- and user-invocable skills; no direct browser skill-loading RPC is added. These rules permit all four combinations: diff --git a/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.zh.md b/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.zh.md index 7e68f2cf28..1a7117a382 100644 --- a/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.zh.md @@ -18,7 +18,7 @@ skill 注册表最初将发现操作视为模型目录:`ctx.skills.list()` 会 本地提供方只接受拼写完全一致的 kebab-case frontmatter 键 `disable-model-invocation` 和 `user-invocable`。它接受 YAML 布尔值,以及不区分大小写的 `true`/`false`、`yes`/`no`、`on`/`off` 和 `1`/`0`,与 Claude skills 实际支持的布尔写法一致。它将 `disable-model-invocation` 映射为相反的正向字段,即使两个键都不存在,也会根据默认值填充两个正向字段。若使用外部驼峰式拼写或提供非布尔调用值,发现流程会丢弃整个 skill,并给出有针对性的警告;本仓库尚处于发布前阶段,因此不为磁盘格式保留兼容别名。调用数据校验遵循失败时默认拒绝原则,因为忽略这类数据会默认授予权限,可能使 skill 暴露在已禁用的接口上;与之不同,类型错误的可选 `whenToUse` 和 `metadata` 值会被省略,因为它们不参与调用判定。 -面向模型的 `dsh-tool-skill` 目录和 loader 执行 `isModelInvocable`。TUI 的 `/skill:` 自动补全与精确名称 loader 在本地执行用户字段,因此仅允许用户调用的 skill 即使不出现在模型发现结果中,仍会在此处显示并可加载,同时不会将可选的 skill peer 变成运行时导入。由 launcher 预置、供引导式 `dsh migrate` 和 `dsh experimental-upgrade` 会话使用的初始 skill 沿用同一条 TUI 路径,因此必须保持允许用户调用。浏览器的 `skill.list` RPC 提供的是由用户选择、但仍要求模型加载的引用,因此只公开同时允许模型和用户调用的 skill;本次改动不新增让浏览器直接加载 skill 的 RPC。 +面向模型的 `dsh-tool-skill` 目录和 loader 执行 `isModelInvocable`。TUI 的 `/skill:` 自动补全与精确名称 loader 在本地执行用户字段,因此仅允许用户调用的 skill 即使不出现在模型发现结果中,仍会在此处显示并可加载,同时不会将可选的 skill peer 变成运行时导入。由 launcher 预置、供引导式 `dsh migrate` 和 `dsh upgrade` 会话使用的初始 skill 沿用同一条 TUI 路径,因此必须保持允许用户调用。浏览器的 `skill.list` RPC 提供的是由用户选择、但仍要求模型加载的引用,因此只公开同时允许模型和用户调用的 skill;本次改动不新增让浏览器直接加载 skill 的 RPC。 这些规则允许以下四种组合: diff --git a/.agents/notes/implemented/feature/2026-07-31-experimental-subcommand-gate.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-experimental-subcommand-gate.i18n.yaml new file mode 100644 index 0000000000..511535d762 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-experimental-subcommand-gate.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-07-31-experimental-subcommand-gate.md +2026-07-31-experimental-subcommand-gate.md: 4f38d8cd83ecae7b4e104a61acdb7acca98c071d +2026-07-31-experimental-subcommand-gate.zh.md: 5fc2ff6513622c42e8b0eef711710a634f86870a diff --git a/.agents/notes/implemented/feature/2026-07-31-experimental-subcommand-gate.md b/.agents/notes/implemented/feature/2026-07-31-experimental-subcommand-gate.md new file mode 100644 index 0000000000..4f38d8cd83 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-experimental-subcommand-gate.md @@ -0,0 +1,31 @@ +# Agent Note: experimental subcommands gate behind `--experimental` or `DSH_EXPERIMENTAL=1` + +Status: implemented + +English | [中文](2026-07-31-experimental-subcommand-gate.zh.md) + +## Problem + +The `meta` and `upgrade` entry points carried their experimental status in their names: `dsh experimental-meta` and `dsh experimental-upgrade`. The prefix made every invocation verbose, and renaming a command at stabilization would break every reference to it — muscle memory, scripts, and docs alike. The status belongs in an opt-in gate, not in the name. + +## Decision + +`dsh experimental-meta` is `dsh meta` and `dsh experimental-upgrade` is `dsh upgrade`. Each runs only when the invocation passes its `--experimental` flag or the environment carries `DSH_EXPERIMENTAL=1`; otherwise the command fails loud on stderr with exit 1, naming both opt-ins. Per the pre-release stance, the old names are gone with no aliases, and `args.spec.ts` pins their rejection. + +The gate has two halves with one owner each. The per-invocation half is a Commander `--experimental` option on each experimental subcommand, checked inside its action after the leaked-parent-option rejection. The environment half is a boolean `parseDshArgs` parameter: `bin.ts` reads `process.env.DSH_EXPERIMENTAL === '1'` at the process boundary and passes the result down, so the parser stays a pure function of its inputs and the tests need no env mutation. `1` is the only enabling value — the variable is an explicit opt-in, not a truthiness check. + +Stabilizing a command later means deleting its `--experimental` option and `requireExperimental` call; the name does not move. + +## Alternatives considered + +**Keep the `experimental-` name prefix.** Rejected by the user's direction: the prefix taxes every invocation, and stabilization would be a breaking rename instead of deleting a gate. + +**A parent-level `--experimental` flag (`dsh --experimental meta`).** Rejected: the default surface is deliberately option-only with `enablePositionalOptions`, so parent options that leak across the subcommand boundary are treated as mistyped invocations. A parent flag consumed only by two subcommands would be exactly the leaked-option shape the adapter rejects everywhere else. + +**Read `process.env` inside `parseDshArgs`.** Rejected: the repo validates at the process boundary and keeps typed seams pure; tests would have to mutate and restore `process.env` around each case. + +**Accept any non-empty `DSH_EXPERIMENTAL`.** Rejected: the telemetry switch prefers off-by-mistake for a privacy control, but an experimental gate is an acknowledgement — `DSH_EXPERIMENTAL=0` must not enable the commands it names. + +## Consequences + +Daily invocations shorten to `dsh meta --experimental` and `dsh upgrade --experimental`, and a developer who sets `DSH_EXPERIMENTAL=1` in their environment gets the bare `dsh meta`/`dsh upgrade`. `dsh --help` marks both commands `(experimental)`. The gate costs one extra flag or env var until a command stabilizes, at which point the gate is deleted and the name is already final. diff --git a/.agents/notes/implemented/feature/2026-07-31-experimental-subcommand-gate.zh.md b/.agents/notes/implemented/feature/2026-07-31-experimental-subcommand-gate.zh.md new file mode 100644 index 0000000000..5fc2ff6513 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-experimental-subcommand-gate.zh.md @@ -0,0 +1,31 @@ +# Agent Note:实验性子命令由 `--experimental` 或 `DSH_EXPERIMENTAL=1` 把守 + +Status: implemented + +[English](2026-07-31-experimental-subcommand-gate.md) | 中文 + +## Problem + +`meta` 与 `upgrade` 两个入口把实验性状态写在名字里:`dsh experimental-meta` 和 `dsh experimental-upgrade`。前缀让每次调用都变得冗长,而在稳定时重命名命令会破坏对它的所有引用——肌肉记忆、脚本与文档皆然。这种状态应当由一个显式选择加入的门槛承载,而不是由名字承载。 + +## Decision + +`dsh experimental-meta` 改为 `dsh meta`,`dsh experimental-upgrade` 改为 `dsh upgrade`。二者只有在调用时传入各自的 `--experimental` 标志、或环境中带有 `DSH_EXPERIMENTAL=1` 时才会运行;否则命令在 stderr 上明确报错并以退出码 1 结束,同时指明两种选择加入方式。依据发布前立场,旧名称已移除且没有别名,`args.spec.ts` 钉住了对它们的拒绝。 + +该门槛分为两半,各有其归属。按调用的一半是每个实验性子命令上的 Commander `--experimental` 选项,在其 action 内、泄漏父级选项的拒绝之后检查。环境的一半是 `parseDshArgs` 的一个布尔参数:`bin.ts` 在进程边界读取 `process.env.DSH_EXPERIMENTAL === '1'` 并向下传递结果,因此解析器保持为其输入的纯函数,测试也无需改动环境变量。`1` 是唯一的启用值——该变量是显式的选择加入,而不是真值判断。 + +之后要稳定某个命令,只需删除它的 `--experimental` 选项和 `requireExperimental` 调用;名字不再变动。 + +## Alternatives considered + +**保留 `experimental-` 名称前缀。** 按用户的指示拒绝:前缀让每次调用都付出代价,稳定时也会变成破坏性的重命名,而不是删除一个门槛。 + +**父级 `--experimental` 标志(`dsh --experimental meta`)。** 拒绝:默认界面刻意保持纯选项形式并启用 `enablePositionalOptions`,跨子命令边界泄漏的父级选项都被视为拼错的调用。一个只被两个子命令消费的父级标志,恰恰就是适配器在其他所有地方都拒绝的泄漏选项形态。 + +**在 `parseDshArgs` 内部读取 `process.env`。** 拒绝:本仓库在进程边界做验证,并保持类型化接缝的纯粹性;否则测试必须在每个用例前后修改并恢复 `process.env`。 + +**接受任何非空的 `DSH_EXPERIMENTAL`。** 拒绝:遥测开关作为隐私控制倾向于误关而非误开,但实验性门槛是一种确认——`DSH_EXPERIMENTAL=0` 绝不能启用它所指名的命令。 + +## Consequences + +日常调用缩短为 `dsh meta --experimental` 和 `dsh upgrade --experimental`;在环境中设置了 `DSH_EXPERIMENTAL=1` 的开发者可以直接使用 `dsh meta`/`dsh upgrade`。`dsh --help` 将这两个命令标注为 `(experimental)`。在命令稳定之前,门槛的代价是一个额外的标志或环境变量;稳定时删除门槛即可,名字已是最终形态。 diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index 8fa53f8051..d36f8ae388 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/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 apps/cli/README.md -README.md: 7ef24dc6290af5aafe3eb53609b090fcbfb83ce8 -README.zh.md: b19d4979351940e925db3b7a616a5c87689770a1 +README.md: 3d3e5413f208e57ba03b5b51c670e6eed4bad373 +README.zh.md: 66d467995a2257af067a4d877fcba426e88e840d diff --git a/apps/cli/README.md b/apps/cli/README.md index 7ef24dc629..3d3e5413f2 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -3,21 +3,21 @@ English | [中文](README.zh.md) -Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`, `--dump-config`, `--dump-default-config`), whose `experimental-meta` subcommand is the same TUI over this checkout, whose `experimental-upgrade` subcommand is an option-less guided-session entry, and whose `web` subcommand is the browser UI. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or a mistyped `--resume` fails loud (stderr, exit 1) instead of misrouting. Every subcommand that shares no option with the default surface — `experimental-upgrade`, `web`, `experimental-meta` — rejects a leaked `--config`/`-p`/`--resume`/dump flag rather than running and dropping it. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the `dsh-host-webserver` schema is the single source of both the default (the shipped Web overlay value when a flag is absent) and validity, and rejects a bad value at boot. `--trusted-host` appends named authorities for the /api browser-trust fence; an all-interfaces bind additionally derives the machine's LAN IP literals itself ([`src/app-cli-entry.ts`](src/app-cli-entry.ts)), so the printed LAN URL works without flags. +Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`, `--dump-config`, `--dump-default-config`), whose `meta` subcommand is the same TUI over this checkout, whose `upgrade` subcommand is a guided-session entry, and whose `web` subcommand is the browser UI. `meta` and `upgrade` are experimental: each runs only with its `--experimental` flag or with `DSH_EXPERIMENTAL=1` in the environment, and fails loud (stderr, exit 1) otherwise. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or a mistyped `--resume` fails loud (stderr, exit 1) instead of misrouting. Every subcommand that shares no option with the default surface — `upgrade`, `web`, `meta` — rejects a leaked `--config`/`-p`/`--resume`/dump flag rather than running and dropping it. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the `dsh-host-webserver` schema is the single source of both the default (the shipped Web overlay value when a flag is absent) and validity, and rejects a bad value at boot. `--trusted-host` appends named authorities for the /api browser-trust fence; an all-interfaces bind additionally derives the machine's LAN IP literals itself ([`src/app-cli-entry.ts`](src/app-cli-entry.ts)), so the printed LAN URL works without flags. The TUI surface: - boots `base.cordis.yml` plus `tui.cordis.yml` through [`dsh-app-boot`](../../packages/ui/app-boot/README.md); `--config ` applies a patch-list overlay instead of the personal overlay, while `--config-replace ` boots that file as the complete tree; - resumes a persisted session with `dsh --resume ` and, when the Node host exposes `process.execve`, supplies the TUI's in-place handoff host: after selector preflight and current-session flush, the host disposes the app and replaces the process with a normalized resume invocation; runtimes without process replacement leave the session running and say so. This CLI owns session identity and the exit line rather than the config: it mints or selects the `main` session id and provides it, plus the exact command that reproduces this invocation, on the boot context ([`MAIN_SESSION_ID_KEY`](../../packages/ui/tui/README.md) and `TUI_GOODBYE_MESSAGE_KEY`). No `cordis.yml` key can drop resume, and a missing or unreadable id fails loud instead of creating a fresh session; -- treats the **invoking directory** as the workspace — sessions, relative paths, and workspace instructions resolve from the cwd (`dsh experimental-meta` is the sole exception, below); +- treats the **invoking directory** as the workspace — sessions, relative paths, and workspace instructions resolve from the cwd (`dsh meta` is the sole exception, below); - tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it; - applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `config.yaml` patches the booted tree, while `.env` there is the credential provider's own store (never hoisted into the environment, so keys stay rotatable). Environment precedence is ambient > project `.env`. - presents the [versioned first-run welcome](../../.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md) through the mounted TUI overlay service when its immutable marker is absent under `DSH_HOME`; only Enter creates that version's marker, while Escape, disposal, or process exit leaves it eligible. The official DeepSeek icon, responsive terminal rasters, all-locale Chinese copy, and notice version are static local owners; the overlay never writes a session event or model context. - registers bare `/compact`: while the agent is idle, it summarizes useful older history even below automatic pressure, rejects arguments, and reports success only after the standalone replacement bracket is durable. A prompt submitted during compaction keeps its queue identity and starts after that checkpoint; injected context remains visible. -`dsh experimental-meta` is that same TUI with this harness checkout as the workspace, so working on dsh itself needs no `cd`. It chdirs to the checkout root — resolved from the launcher's real path, the same root the source-path prompt section names — after the environment is settled, so precedence is unchanged while the session cwd and HMR watch root move together. Experimental meta always starts a fresh session and accepts no default-surface options; use ordinary `dsh --resume ` to resume a persisted session. +`dsh meta` is that same TUI with this harness checkout as the workspace, so working on dsh itself needs no `cd`. It chdirs to the checkout root — resolved from the launcher's real path, the same root the source-path prompt section names — after the environment is settled, so precedence is unchanged while the session cwd and HMR watch root move together. Meta always starts a fresh session and accepts no default-surface options; use ordinary `dsh --resume ` to resume a persisted session. -`dsh experimental-upgrade` is a guided fresh-session entry over the default TUI surface: it mints a fresh session in the invoking directory and seeds its first turn with the bundled `dsh-upgrade` skill, exactly as if the user typed `/skill:`. The launcher passes the skill name on the boot context ([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)), which the TUI auto-invokes once the chat is live. The command takes no options — `--config`, `-p`, and `--resume` fail loud — and seeds only on this first launch, so a later `dsh --resume ` of the session is an ordinary TUI session with no re-injection. +`dsh upgrade` is a guided fresh-session entry over the default TUI surface: it mints a fresh session in the invoking directory and seeds its first turn with the bundled `dsh-upgrade` skill, exactly as if the user typed `/skill:`. The launcher passes the skill name on the boot context ([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)), which the TUI auto-invokes once the chat is live. The command takes no options beyond the experimental gate — `--config`, `-p`, and `--resume` fail loud — and seeds only on this first launch, so a later `dsh --resume ` of the session is an ordinary TUI session with no re-injection. `dsh --dump-config` and `dsh web --dump-config` print the composed config tree — the shipped base, the surface overlay, and the `--config` or personal overlay, exactly the layers that surface would boot — as YAML on stdout and exit without booting; `--dump-default-config` stops at the surface overlay, so diffing the two shows precisely what the user layer changes. Each run of rows is preceded by a `# ==` comment naming the file it comes from and the layers that patched it (e.g. `# == base.cordis.yml, patched by tui.cordis.yml`), so the output shows provenance while staying one loadable document. Composition runs through the include's own patch algorithm and YAML dialect (`applyEntryPatches`/`entryListSchema` from `@cordisjs/plugin-include`), so the dump cannot drift from what boots; `!!js` expressions print verbatim and unevaluated, and a patch whose target row is absent is reported on stderr with its layer, mirroring the Loader's boot-time warning. Launcher-owned boot-context values (session identity, CLI-flag patches) are per-invocation facts outside the config tree and do not appear. The dump flags reject boot-only flags (`-p`, `--resume`, `--config-replace`) rather than silently ignoring them, and `--dump-default-config` takes no `--config`. diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index b19d497935..66d467995a 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -3,21 +3,21 @@ [English](README.md) | 中文 -Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([`src/args.ts`](src/args.ts))解析一次:同一个程序的默认形式(无子命令)是 TUI/无头界面(`--config`、`-p`/`--prompt`、`--resume`、`--dump-config`、`--dump-default-config`),`experimental-meta` 子命令是以本 checkout 为 workspace 的同一个 TUI,`experimental-upgrade` 子命令是无选项的引导会话入口,`web` 子命令则是浏览器 UI。`src/bin.ts` 按解析后的 mode 分支,仅动态导入该 mode 的模块。`dsh --help` 列出所有 mode,`dsh web --help` 渲染 Web 用法,`dsh --version` 打印此应用的版本;未知选项或拼错的 `--resume` 会明确报错(stderr,退出码 1),而不会被错路由。凡与默认界面不共享任何选项的子命令(`experimental-upgrade`、`web`、`experimental-meta`)都会拒绝泄漏进来的 `--config`/`-p`/`--resume`/dump 标志,而不会照常运行并丢弃它。`dsh web` 的 `--host`/`--port` 是未验证的直通覆盖:`dsh-host-webserver` schema 是默认值(标志缺失时使用已交付的 Web 覆盖层值)和有效性的唯一真源,并在启动时拒绝错误值。`--trusted-host` 为 /api 浏览器信任栅栏追加具名权威;全接口绑定还会自行推导本机的 LAN IP 字面量([`src/app-cli-entry.ts`](src/app-cli-entry.ts)),因此打印出的 LAN URL 无需任何标志即可使用。 +Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([`src/args.ts`](src/args.ts))解析一次:同一个程序的默认形式(无子命令)是 TUI/无头界面(`--config`、`-p`/`--prompt`、`--resume`、`--dump-config`、`--dump-default-config`),`meta` 子命令是以本 checkout 为 workspace 的同一个 TUI,`upgrade` 子命令是引导会话入口,`web` 子命令则是浏览器 UI。`meta` 与 `upgrade` 是实验性命令:只有带上各自的 `--experimental` 标志或在环境中设置 `DSH_EXPERIMENTAL=1` 才会运行,否则明确报错(stderr,退出码 1)。`src/bin.ts` 按解析后的 mode 分支,仅动态导入该 mode 的模块。`dsh --help` 列出所有 mode,`dsh web --help` 渲染 Web 用法,`dsh --version` 打印此应用的版本;未知选项或拼错的 `--resume` 会明确报错(stderr,退出码 1),而不会被错路由。凡与默认界面不共享任何选项的子命令(`upgrade`、`web`、`meta`)都会拒绝泄漏进来的 `--config`/`-p`/`--resume`/dump 标志,而不会照常运行并丢弃它。`dsh web` 的 `--host`/`--port` 是未验证的直通覆盖:`dsh-host-webserver` schema 是默认值(标志缺失时使用已交付的 Web 覆盖层值)和有效性的唯一真源,并在启动时拒绝错误值。`--trusted-host` 为 /api 浏览器信任栅栏追加具名权威;全接口绑定还会自行推导本机的 LAN IP 字面量([`src/app-cli-entry.ts`](src/app-cli-entry.ts)),因此打印出的 LAN URL 无需任何标志即可使用。 TUI 界面: - 通过 [`dsh-app-boot`](../../packages/ui/app-boot/README.md) 启动 `base.cordis.yml` 与 `tui.cordis.yml`;`--config ` 应用一个补丁列表覆盖并替代个人覆盖,而 `--config-replace ` 将指定文件作为完整配置树启动; - 使用 `dsh --resume ` 恢复已持久化会话。当 Node 宿主公开 `process.execve` 时,还会提供 TUI 的原地移交宿主:选择器预检并刷新当前会话后,宿主会释放应用,并以规范化的恢复调用替换进程;不支持进程替换的运行时会让会话继续运行并给出提示。会话身份与退出行由本 CLI 拥有,而非由配置指定:它创建或选定 `main` 会话 id,并把该 id 以及可复现本次调用的确切命令一起提供到启动上下文([`MAIN_SESSION_ID_KEY`](../../packages/ui/tui/README.md) 与 `TUI_GOODBYE_MESSAGE_KEY`)。任何 `cordis.yml` 键都无法移除恢复能力;缺失或无法读取的 id 会明确报错,而不会创建新会话; -- 将 **调用目录** 视为 workspace:会话、相对路径和 workspace 指令都从 cwd 解析(`dsh experimental-meta` 是唯一例外,见下文); +- 将 **调用目录** 视为 workspace:会话、相对路径和 workspace 指令都从 cwd 解析(`dsh meta` 是唯一例外,见下文); - 告知 agent 自身源码所在位置:启动后添加一个命名此 harness checkout 的提示词段。该路径从启动器的真实路径解析,因此在 PATH 符号链接和任意 cwd 下仍然有效,使自指的 `cordis` 工具集可以读取并修改它; - 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)):`config.yaml` 修补已启动的树,而那里的 `.env` 是凭据 provider 自己的存储(绝不会被提升进环境,因此密钥始终可轮换)。环境优先级为环境中已有的值 > 项目 `.env`。 - 当 `DSH_HOME` 下不存在不可变确认标记时,通过已挂载的 TUI overlay 服务呈现[版本化首次运行欢迎页](../../.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md);只有 Enter 会创建该版本的标记,Escape、资源释放或进程退出仍保留展示资格。官方 DeepSeek 图标、响应式终端栅格图、所有 locale 共用的中文文案和通知版本均由静态本地文件持有;overlay 不会写入会话事件或模型上下文。 - 注册裸 `/compact`:agent 空闲时,即使未达到自动压力,也会摘要有效的较早历史;该命令拒绝参数,并只在独立替换标记对持久化后报告成功。压缩(compaction)期间提交的提示词保留其队列身份,并在该检查点之后启动;注入的上下文仍保持可见。 -`dsh experimental-meta` 是以本 harness checkout 为 workspace 的同一个 TUI,因此开发 dsh 自身无需 `cd`。它在环境确定之后才 chdir 到 checkout 根目录(从启动器的真实路径解析,与源码路径提示词段所指的根目录相同),因此环境优先级不变,而会话 cwd 与 HMR 监视根目录会一并移动。Experimental meta 始终创建新会话,不接受默认界面的任何选项;恢复已持久化会话应使用普通的 `dsh --resume `。 +`dsh meta` 是以本 harness checkout 为 workspace 的同一个 TUI,因此开发 dsh 自身无需 `cd`。它在环境确定之后才 chdir 到 checkout 根目录(从启动器的真实路径解析,与源码路径提示词段所指的根目录相同),因此环境优先级不变,而会话 cwd 与 HMR 监视根目录会一并移动。Meta 始终创建新会话,不接受默认界面的任何选项;恢复已持久化会话应使用普通的 `dsh --resume `。 -`dsh experimental-upgrade` 是默认 TUI 界面之上的引导式全新会话入口:它在调用目录中创建一个全新会话,并以内置 `dsh-upgrade` skill 播种其首轮,效果等同于用户手动键入 `/skill:`。启动器将 skill 名称提供到启动上下文([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)),TUI 在聊天就绪后自动调用它。该命令不接受任何选项——`--config`、`-p`、`--resume` 都会明确报错——且仅在首次启动时播种,因此之后 `dsh --resume ` 恢复该会话时是普通 TUI 会话,不会重复注入。 +`dsh upgrade` 是默认 TUI 界面之上的引导式全新会话入口:它在调用目录中创建一个全新会话,并以内置 `dsh-upgrade` skill 播种其首轮,效果等同于用户手动键入 `/skill:`。启动器将 skill 名称提供到启动上下文([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)),TUI 在聊天就绪后自动调用它。该命令除实验性门槛外不接受任何选项——`--config`、`-p`、`--resume` 都会明确报错——且仅在首次启动时播种,因此之后 `dsh --resume ` 恢复该会话时是普通 TUI 会话,不会重复注入。 `dsh --dump-config` 和 `dsh web --dump-config` 把合成后的配置树——已交付的基础配置、界面覆盖层,以及 `--config` 或个人覆盖层,恰好是该界面启动时组装的那些层——以 YAML 打印到 stdout 后退出,不启动任何东西;`--dump-default-config` 止步于界面覆盖层,因此对两份输出做 diff 就能精确看出用户层改了什么。每段连续的行之前都有一条 `# ==` 注释,标明该段来自哪个文件以及被哪些层修补过(例如 `# == base.cordis.yml, patched by tui.cordis.yml`),因此输出既展示来源,又仍是一份可加载的文档。合成通过 include 自己的补丁算法和 YAML 方言(`@cordisjs/plugin-include` 的 `applyEntryPatches`/`entryListSchema`)完成,因此 dump 不可能与实际启动漂移;`!!js` 表达式原样打印、不求值,目标行不存在的补丁会连同其所在层报到 stderr,与 Loader 启动时的警告一致。由启动器持有的启动上下文值(会话身份、CLI 标志补丁)是每次调用的事实,位于配置树之外,不会出现。dump 标志会拒绝仅用于启动的标志(`-p`、`--resume`、`--config-replace`)而不是静默忽略它们,`--dump-default-config` 不接受 `--config`。 diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index 2c05e966bc..91d9c82d79 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -3,7 +3,9 @@ * parsed and routed to a mode. `bin.ts` switches on the returned discriminant * and dynamic-imports that mode's module. One program: the default (no * subcommand) is the TUI/headless surface with option-only flags; - * `experimental-meta` and `web` are real subcommands. Commander owns + * `meta`, `upgrade`, and `web` are real subcommands; the experimental ones + * (`meta`, `upgrade`) run only under the `--experimental` flag or + * `DSH_EXPERIMENTAL=1`. Commander owns * `--help`/`--version` and parse * errors — it prints and exits at the point of failure (a domain failure routes through * `command.error`), so this returns only a resolved mode. @@ -46,16 +48,17 @@ interface HeadlessInvocation { prompt: string } -/** Interactive fresh TUI over this harness checkout; accepts no default-surface options. */ +/** Interactive fresh TUI over this harness checkout; accepts no default-surface options, only the experimental gate. */ interface MetaInvocation { mode: 'meta' } /** - * Guided fresh-session entry: `dsh experimental-upgrade` seeds the first turn + * Guided fresh-session entry: `dsh upgrade` seeds the first turn * with the `dsh-upgrade` skill. It always mints a - * fresh session in the invoking directory and takes no options — `--resume`, - * `--config`, and `-p` are rejected as mistyped, so there is nothing to carry. + * fresh session in the invoking directory and takes no options beyond the + * experimental gate — `--resume`, `--config`, and `-p` are rejected as + * mistyped, so there is nothing to carry. */ interface SkillSessionInvocation { mode: 'upgrade' @@ -154,9 +157,11 @@ function resolveWeb(options: WebOptions): WebInvocation { * TUI/headless surface; `web` is a subcommand. * @param argv - the arguments after the node binary and script (`process.argv.slice(2)`). * @param version - the version string `--version` prints; read from this app's package.json. + * @param experimentalEnv - whether the environment opts into experimental + * subcommands (`DSH_EXPERIMENTAL=1`); the caller reads the process boundary. * @returns the resolved invocation (only reached on a valid, non-help invocation). */ -export function parseDshArgs(argv: readonly string[], version: string): DshInvocation { +export function parseDshArgs(argv: readonly string[], version: string, experimentalEnv: boolean): DshInvocation { let resolved: DshInvocation | undefined const program = new Command() .name('dsh') @@ -248,16 +253,27 @@ Examples: } } + // `meta` and `upgrade` are experimental: each runs only under its own + // `--experimental` flag or an environment-wide `DSH_EXPERIMENTAL=1` opt-in, + // and fails loud otherwise so the gate is never silently skipped. + const requireExperimental = (command: string, flag: boolean | undefined): void => { + if (flag !== true && !experimentalEnv) { + program.error(`error: ${command} is experimental; pass --experimental or set DSH_EXPERIMENTAL=1`) + } + } + // Registration order is the rendered help order, so daily use comes first - // and the harness-development surfaces (`web --dev`, `experimental-meta`) - // come last. `experimental-upgrade` is a guided fresh-session entry: it - // takes no options and always mints a fresh session, so nothing is left to - // carry. + // and the harness-development surfaces (`web --dev`, `meta`) + // come last. `upgrade` is a guided fresh-session entry: beyond the + // experimental gate it takes no options and always mints a fresh session, + // so nothing is left to carry. program - .command('experimental-upgrade') - .description('update this dsh installation to the latest version') - .action(() => { - rejectParentOptions('experimental-upgrade') + .command('upgrade') + .description('update this dsh installation to the latest version (experimental)') + .option('--experimental', 'acknowledge this subcommand is experimental') + .action((options: { experimental?: boolean }) => { + rejectParentOptions('upgrade') + requireExperimental('upgrade', options.experimental) resolved = { mode: 'upgrade' } }) @@ -285,10 +301,12 @@ Examples: }) program - .command('experimental-meta') - .description('work on the dsh source that runs this command, from any directory') - .action(() => { - rejectParentOptions('experimental-meta') + .command('meta') + .description('work on the dsh source that runs this command, from any directory (experimental)') + .option('--experimental', 'acknowledge this subcommand is experimental') + .action((options: { experimental?: boolean }) => { + rejectParentOptions('meta') + requireExperimental('meta', options.experimental) resolved = { mode: 'meta' } }) diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts index a1e46ccb1e..3886438bed 100644 --- a/apps/cli/src/bin.ts +++ b/apps/cli/src/bin.ts @@ -25,7 +25,8 @@ function readVersion(): string { } loadEnv('dsh') -const invocation = parseDshArgs(process.argv.slice(2), readVersion()) +// The env opt-in is read at the process boundary; `1` is the documented value. +const invocation = parseDshArgs(process.argv.slice(2), readVersion(), process.env.DSH_EXPERIMENTAL === '1') switch (invocation.mode) { case 'web': { diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index 15e7d6f77b..93903b2fe6 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -8,8 +8,8 @@ * from it, so `dsh` acts on whatever project it is launched in. Session storage * is the exception — it lives under the Harness home so `/resume` reaches every * workspace, and an in-place resume enters the selected session's own directory. - * `dsh experimental-meta` is the one exception — it makes this harness - * checkout the workspace. `dsh experimental-upgrade` is a fresh session whose + * `dsh meta` is the one exception — it makes this harness + * checkout the workspace. `dsh upgrade` is a fresh session whose * first turn auto-invokes a bundled skill. After boot, the agent's system * prompt is told the path to this harness checkout so it can find its own * source. @@ -71,7 +71,7 @@ const SESSION_QUERY_DB = `session-query-${String(process.pid)}-${randomUUID()}.d // The harness checkout root: three hops up from apps/cli/{src,lib}, resolved // from this bin's location so it holds however `dsh` is launched (a PATH // symlink, an arbitrary cwd). The agent is told where its own source lives. -/** The harness checkout used as the `dsh experimental-meta` workspace and source prompt path. */ +/** The harness checkout used as the `dsh meta` workspace and source prompt path. */ export const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) /* v8 ignore start -- composition over the unit-tested dsh-app-boot helpers; @@ -88,9 +88,9 @@ export const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) * {@link CONFIGURED_AGENT_IDENTITIES_KEY}, so no config key selects the session * and an overlay replacing the agent row cannot drop it. * @param workspace - a directory to make the workspace instead of the invoking - * one, or `undefined` to keep the cwd. Only `dsh experimental-meta` passes it. + * one, or `undefined` to keep the cwd. Only `dsh meta` passes it. * @param initialSkill - a bundled skill to auto-invoke as a fresh session's - * first turn, or `undefined`. Set only by `dsh experimental-upgrade` and + * first turn, or `undefined`. Set only by `dsh upgrade` and * ignored on a resume, so it never re-fires; reaches the app through * {@link INITIAL_SKILL_KEY}. * @param configReplace - a config path to boot as the ENTIRE tree, bypassing the @@ -140,7 +140,7 @@ export async function runTui( const entry = process.argv[1] const execve = process.execve?.bind(process) const app: { current?: Context } = {} - // Resume always enters the default surface because experimental-meta rejects + // Resume always enters the default surface because meta rejects // parent options, including `--resume`. The resumed session already persists // its cwd. const resumeArgs = (sessionId: string): string[] => [ diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts index 9da69fb91c..5b0e76323d 100644 --- a/apps/cli/tests/args.spec.ts +++ b/apps/cli/tests/args.spec.ts @@ -1,18 +1,18 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { parseDshArgs } from '../src/args.ts' -const parse = (argv: string[]) => parseDshArgs(argv, '1.2.3') +const parse = (argv: string[], experimentalEnv = false) => parseDshArgs(argv, '1.2.3', experimentalEnv) /** * `parseDshArgs` calls `process.exit` for `--help`/`--version`/errors and lets * Commander print to the real streams; capture the exit code and mute output. */ -function exitCode(argv: string[]): number { +function exitCode(argv: string[], experimentalEnv = false): number { const exit = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit') }) vi.spyOn(process.stdout, 'write').mockReturnValue(true) vi.spyOn(process.stderr, 'write').mockReturnValue(true) try { - parse(argv) + parse(argv, experimentalEnv) throw new Error(`expected ${JSON.stringify(argv)} to exit`) } catch { return exit.mock.calls.at(-1)?.[0] as number @@ -30,7 +30,9 @@ describe('parseDshArgs', () => { expect(parse(['--config-replace', 'tree.yml'])).toEqual({ mode: 'tui', configReplace: 'tree.yml' }) expect(parse(['--resume', 'sess', '--config', 'app.yml'])).toEqual({ mode: 'tui', config: 'app.yml', resume: 'sess' }) expect(parse(['-p', 'do the thing'])).toEqual({ mode: 'headless', prompt: 'do the thing' }) - expect(parse(['experimental-meta'])).toEqual({ mode: 'meta' }) + // Experimental subcommands run under the per-invocation flag or the env opt-in. + expect(parse(['meta', '--experimental'])).toEqual({ mode: 'meta' }) + expect(parse(['meta'], true)).toEqual({ mode: 'meta' }) // Bare `web` carries no host/port: the shipped Web overlay owns the default. expect(parse(['web'])).toEqual({ mode: 'web', dev: false }) expect(parse(['web', '--config', 'web.yml'])).toEqual({ mode: 'web', dev: false, config: 'web.yml' }) @@ -39,7 +41,8 @@ describe('parseDshArgs', () => { expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev', '--workspace-root', '/w'])) .toEqual({ mode: 'web', host: '0.0.0.0', port: 8080, dev: true, workspaceRoot: '/w' }) // Guided fresh-session entries carry nothing: bare mode discriminant only. - expect(parse(['experimental-upgrade'])).toEqual({ mode: 'upgrade' }) + expect(parse(['upgrade', '--experimental'])).toEqual({ mode: 'upgrade' }) + expect(parse(['upgrade'], true)).toEqual({ mode: 'upgrade' }) // --trusted-host is variadic and repeatable; authorities pass through unvalidated. expect(parse(['web', '--trusted-host', 'harness.internal:3080', 'lab.internal', '--trusted-host', '10.0.0.9'])) .toEqual({ mode: 'web', dev: false, trustedHosts: ['harness.internal:3080', 'lab.internal', '10.0.0.9'] }) @@ -64,8 +67,8 @@ describe('parseDshArgs', () => { expect(exitCode(['web', '--dump-config', '--dump-default-config'])).toBe(1) expect(exitCode(['web', '--dump-default-config', '--config', 'w.yml'])).toBe(1) // A leaked dump flag on a subcommand that has none is a mistyped invocation. - expect(exitCode(['experimental-meta', '--dump-config'])).toBe(1) - expect(exitCode(['experimental-upgrade', '--dump-config'])).toBe(1) + expect(exitCode(['meta', '--experimental', '--dump-config'])).toBe(1) + expect(exitCode(['upgrade', '--experimental', '--dump-config'])).toBe(1) }) it('exits nonzero instead of silently starting fresh or dropping inputs', () => { @@ -88,20 +91,32 @@ describe('parseDshArgs', () => { expect(exitCode(['--config-replace', 'tree.yml', 'web'])).toBe(1) // Same rule for each subcommand that shares no option with the default // surface, so a leaked flag is a typo, not something to ignore. - // `experimental-meta` fixes its own config tree and always starts fresh, + // `meta` fixes its own config tree and always starts fresh, // so every default-surface option is rejected. - expect(exitCode(['experimental-meta', '--resume', 's'])).toBe(1) - expect(exitCode(['experimental-meta', '--config', 'c.yml'])).toBe(1) - expect(exitCode(['experimental-meta', '--config-replace', 'tree.yml'])).toBe(1) - expect(exitCode(['experimental-meta', '-p', 'task'])).toBe(1) - // `experimental-upgrade` takes no options: any leaked default-surface flag - // is a mistyped invocation, not a silently-dropped input. - expect(exitCode(['experimental-upgrade', '--resume', 's'])).toBe(1) - expect(exitCode(['experimental-upgrade', '--config', 'c.yml'])).toBe(1) - expect(exitCode(['-p', 'task', 'experimental-upgrade'])).toBe(1) + expect(exitCode(['meta', '--experimental', '--resume', 's'])).toBe(1) + expect(exitCode(['meta', '--experimental', '--config', 'c.yml'])).toBe(1) + expect(exitCode(['meta', '--experimental', '--config-replace', 'tree.yml'])).toBe(1) + expect(exitCode(['meta', '--experimental', '-p', 'task'])).toBe(1) + // `upgrade` takes no options beyond the gate: any leaked default-surface + // flag is a mistyped invocation, not a silently-dropped input. + expect(exitCode(['upgrade', '--experimental', '--resume', 's'])).toBe(1) + expect(exitCode(['upgrade', '--experimental', '--config', 'c.yml'])).toBe(1) + expect(exitCode(['-p', 'task', 'upgrade', '--experimental'])).toBe(1) // The pre-release command names have no compatibility aliases. + expect(exitCode(['experimental-meta'])).toBe(1) + expect(exitCode(['experimental-upgrade'])).toBe(1) + }) + + it('gates experimental subcommands behind --experimental or the env opt-in', () => { + // Bare `meta`/`upgrade` without either opt-in must fail loud, not run. expect(exitCode(['meta'])).toBe(1) expect(exitCode(['upgrade'])).toBe(1) + // A leaked default-surface flag stays a typo even when the gate is passed + // by the environment alone. + expect(exitCode(['meta', '--resume', 's'], true)).toBe(1) + // The flag and the env opt-in may coexist. + expect(parse(['meta', '--experimental'], true)).toEqual({ mode: 'meta' }) + expect(parse(['upgrade', '--experimental'], true)).toEqual({ mode: 'upgrade' }) }) it('exits 0 for --help (disclosing web) and --version', () => { diff --git a/docs/config-catalog.md b/docs/config-catalog.md index a356a151e3..d339d216e1 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2010,7 +2010,7 @@ export interface Config extends TuiConfig { /** * Skill name auto-invoked as this session's first user turn, exactly as if * the user typed `/skill:`. Set only by a launcher for a fresh - * skill-guided session (`dsh migrate`/`dsh experimental-upgrade`); absent + * skill-guided session (`dsh migrate`/`dsh upgrade`); absent * leaves the first turn to the user. */ initialSkill?: string diff --git a/packages/ui/tui/README.i18n.yaml b/packages/ui/tui/README.i18n.yaml index 5eb774a646..930bbf2c27 100644 --- a/packages/ui/tui/README.i18n.yaml +++ b/packages/ui/tui/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/ui/tui/README.md -README.md: 20ad471faa707cce0e6f0ce4df2289a900f09b93 -README.zh.md: a7d918ba3239d3e6feaa2fb43181e4bcf33e1d89 +README.md: c81cac891403e5294c4456ce4d4048ecd74666ce +README.zh.md: 01055619f4df460284564f0a1816de366d809e01 diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 20ad471faa..c81cac8914 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -40,7 +40,7 @@ Selection repeats those checks and requires the current agent to be idle before The exit line is launcher-owned, not configurable. A launcher provides `TUI_GOODBYE_MESSAGE_KEY` on the boot context — for the shipped `dsh`, the command that resumes this session — and exiting prints it verbatim after the terminal is released; absent, exiting prints nothing. Only the launcher knows how it was invoked, so only it can name a command that works. The TUI escapes terminal controls before rendering and never executes the text. A launcher that also supplies `MAIN_SESSION_ID_KEY` fixes which session the mounted app binds to, so resume survives any config-level patch. -A launcher can seed a fresh session's first turn by providing `INITIAL_SKILL_KEY` (the skill name) on the boot context; the TUI auto-invokes it exactly as a typed `/skill:`, once the chat is live. The shipped `dsh migrate`/`dsh experimental-upgrade` set it and only for a fresh session, so a resumed session never re-invokes the skill; an unknown name is reported as a notice. +A launcher can seed a fresh session's first turn by providing `INITIAL_SKILL_KEY` (the skill name) on the boot context; the TUI auto-invokes it exactly as a typed `/skill:`, once the chat is live. The shipped `dsh migrate`/`dsh upgrade` set it and only for a fresh session, so a resumed session never re-invokes the skill; an unknown name is reported as a notice. ## Config diff --git a/packages/ui/tui/README.zh.md b/packages/ui/tui/README.zh.md index a7d918ba32..01055619f4 100644 --- a/packages/ui/tui/README.zh.md +++ b/packages/ui/tui/README.zh.md @@ -40,7 +40,7 @@ Footer 将会话报告的用量汇总为 `↑`;任 退出时打印的行由启动器拥有,不可通过配置指定。启动器在启动上下文上提供 `TUI_GOODBYE_MESSAGE_KEY`(对于随附的 `dsh`,即恢复本会话的命令),释放终端后退出会原样打印它;未提供时退出不打印任何内容。只有启动器知道自己是如何被调用的,因此只有它能给出可用的命令。TUI 在渲染前会转义终端控制字符,且绝不执行该文本。若启动器同时提供 `MAIN_SESSION_ID_KEY`,则会固定已挂载应用绑定的会话,因此恢复功能不受配置层修补影响。 -启动器可通过在启动上下文上提供 `INITIAL_SKILL_KEY`(skill 名称)来播种全新会话的首轮;聊天就绪后,TUI 会像用户手动键入 `/skill:` 一样自动调用它。随附的 `dsh migrate`/`dsh experimental-upgrade` 会设置该键,且仅对全新会话设置,因此恢复的会话绝不会重复调用该 skill;未知名称会以通知形式报告。 +启动器可通过在启动上下文上提供 `INITIAL_SKILL_KEY`(skill 名称)来播种全新会话的首轮;聊天就绪后,TUI 会像用户手动键入 `/skill:` 一样自动调用它。随附的 `dsh migrate`/`dsh upgrade` 会设置该键,且仅对全新会话设置,因此恢复的会话绝不会重复调用该 skill;未知名称会以通知形式报告。 ## 配置 diff --git a/packages/ui/tui/src/config.ts b/packages/ui/tui/src/config.ts index 97822d63b1..010fe3285c 100644 --- a/packages/ui/tui/src/config.ts +++ b/packages/ui/tui/src/config.ts @@ -122,7 +122,7 @@ export interface Config extends TuiConfig { /** * Skill name auto-invoked as this session's first user turn, exactly as if * the user typed `/skill:`. Set only by a launcher for a fresh - * skill-guided session (`dsh migrate`/`dsh experimental-upgrade`); absent + * skill-guided session (`dsh migrate`/`dsh upgrade`); absent * leaves the first turn to the user. */ initialSkill?: string diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 5a79cc8c5f..a2cf047e0a 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -227,7 +227,7 @@ export const TUI_GOODBYE_MESSAGE_KEY = 'tuiGoodbyeMessage' /** * Context key a launcher sets before any Loader entry mounts * (`ctx.provide(INITIAL_SKILL_KEY, name)`) to seed a fresh session's first user - * turn with `/skill:` — the `dsh migrate`/`dsh experimental-upgrade` + * turn with `/skill:` — the `dsh migrate`/`dsh upgrade` * guided-session entry. The launcher sets it only when minting a fresh session, * so it never re-fires on a resumed one. Absent leaves the first turn to the user. */ @@ -1690,7 +1690,7 @@ export function createTuiChat( }) startBannerReveal() - // A launcher-seeded first turn (`dsh migrate`/`dsh experimental-upgrade`): + // A launcher-seeded first turn (`dsh migrate`/`dsh upgrade`): // invoke the named skill exactly as a typed `/skill:` would, once the // chat is live and the agent is idle. The launcher sets this only for a fresh // session, so there is no prior turn to collide with; invokeSkill reports an From 2225ad435e190afc3fcbb6f262ae16c6d8426026 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 31 Jul 2026 23:23:12 +0800 Subject: [PATCH 5/7] test: cover experimental gate through the built bin; fix Agent Note facts --- ...sh-guided-skill-session-commands.i18n.yaml | 4 +-- ...07-28-dsh-guided-skill-session-commands.md | 2 +- ...28-dsh-guided-skill-session-commands.zh.md | 2 +- ...-07-28-dsh-meta-source-workspace.i18n.yaml | 4 +-- .../2026-07-28-dsh-meta-source-workspace.md | 2 +- ...2026-07-28-dsh-meta-source-workspace.zh.md | 2 +- ...-31-experimental-subcommand-gate.i18n.yaml | 4 +-- ...2026-07-31-experimental-subcommand-gate.md | 6 ++++- ...6-07-31-experimental-subcommand-gate.zh.md | 6 ++++- apps/cli/tests/built-bin.e2e.ts | 25 +++++++++++++++++++ 10 files changed, 45 insertions(+), 12 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.i18n.yaml index 880278708e..cd0da77981 100644 --- a/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.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-28-dsh-guided-skill-session-commands.md -2026-07-28-dsh-guided-skill-session-commands.md: 241bf3c0af693f3ccc318bf44bbeccb6c5d73d94 -2026-07-28-dsh-guided-skill-session-commands.zh.md: f74b2540889900a01b7590344a3bff62e8a9fe4a +2026-07-28-dsh-guided-skill-session-commands.md: 8a091f7a03c85b0723d96b4fc546875a4e6c0f95 +2026-07-28-dsh-guided-skill-session-commands.zh.md: 861e6cf6cf07c41fde4f78b0833e3f8eb9508767 diff --git a/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.md b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.md index 241bf3c0af..8a091f7a03 100644 --- a/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.md +++ b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.md @@ -16,7 +16,7 @@ The seed reuses the existing TUI skill path, not a new one. `createTuiChat` alre **Freshness is gated in the launcher, not the TUI.** `runSkillSession` always mints a fresh session and provides the slot only when `resumeSessionId === undefined`, so a later `dsh --resume ` of that session is an ordinary TUI session with no re-injection. The TUI stays generic: it invokes whatever skill it is handed, once, at startup. -**`migrate`/`upgrade` take no options beyond the [experimental gate](2026-07-31-experimental-subcommand-gate.md)'s `--experimental`.** They carry no `--resume`, `--config`, or `-p`; a guided fresh-session entry has nothing to resume or reconfigure. Any leaked default-surface option fails loud, matching the `web`/`meta` rejection pattern in the Commander adapter. The two modes share one `SkillSessionInvocation` discriminant (`mode: 'migrate' | 'upgrade'`); `bin.ts` maps the mode to `dsh-${mode}`. +**`migrate`/`upgrade` take no default-surface options** (`upgrade` additionally carries the [experimental gate](2026-07-31-experimental-subcommand-gate.md)'s `--experimental`). They carry no `--resume`, `--config`, or `-p`; a guided fresh-session entry has nothing to resume or reconfigure. Any leaked default-surface option fails loud, matching the `web`/`meta` rejection pattern in the Commander adapter. The two modes share one `SkillSessionInvocation` discriminant (`mode: 'migrate' | 'upgrade'`); `bin.ts` maps the mode to `dsh-${mode}`. The `dsh-migrate` skill is bundled under `skills/` (shipped through `DSH_BUNDLED_SKILL_DIR`, like `dsh-upgrade`). It asks which source agent (opencode/pi/Claude Code/Codex) if unstated, then maps each capability — workspace instructions, personal overlay, skills, hooks, MCP, API/env — to its DSH equivalent, grounded in the actual repo surfaces (the `hooks-claude`/`hooks-codex` bridges, `~/.dsh/{config.yaml,.env,AGENTS.md,skills/}`, `AGENTS.md`/`CLAUDE.md`, `mcporter`), and states plainly when a capability has no equivalent. diff --git a/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.zh.md b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.zh.md index f74b254088..861e6cf6cf 100644 --- a/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.zh.md @@ -16,7 +16,7 @@ Status: implemented **新鲜性在启动器而非 TUI 中把关。** `runSkillSession` 总是创建全新会话,且仅在 `resumeSessionId === undefined` 时提供该槽,因此之后 `dsh --resume ` 恢复该会话时是普通 TUI 会话,不会重复注入。TUI 保持通用:它只是把接到的 skill 在启动时调用一次。 -**`migrate`/`upgrade` 除[实验性门槛](2026-07-31-experimental-subcommand-gate.md)的 `--experimental` 外不接受任何选项。** 它们不带 `--resume`、`--config` 或 `-p`;引导式全新会话入口没有可恢复或可重配置的内容。任何泄漏的默认界面选项都会明确报错,与 Commander 适配器中 `web`/`meta` 的拒绝模式一致。两个 mode 共用一个 `SkillSessionInvocation` 判别式(`mode: 'migrate' | 'upgrade'`);`bin.ts` 将 mode 映射为 `dsh-${mode}`。 +**`migrate`/`upgrade` 不接受任何默认界面选项**(`upgrade` 另带[实验性门槛](2026-07-31-experimental-subcommand-gate.md)的 `--experimental`)。它们不带 `--resume`、`--config` 或 `-p`;引导式全新会话入口没有可恢复或可重配置的内容。任何泄漏的默认界面选项都会明确报错,与 Commander 适配器中 `web`/`meta` 的拒绝模式一致。两个 mode 共用一个 `SkillSessionInvocation` 判别式(`mode: 'migrate' | 'upgrade'`);`bin.ts` 将 mode 映射为 `dsh-${mode}`。 `dsh-migrate` skill 内置于 `skills/`(经 `DSH_BUNDLED_SKILL_DIR` 交付,与 `dsh-upgrade` 相同)。若未说明源 agent,它会先询问是哪个(opencode/pi/Claude Code/Codex),再把每项能力——workspace 指令、个人覆盖、skills、hooks、MCP、API/env——映射到对应的 DSH 等价物,并基于仓库实际的表面(`hooks-claude`/`hooks-codex` 桥、`~/.dsh/{config.yaml,.env,AGENTS.md,skills/}`、`AGENTS.md`/`CLAUDE.md`、`mcporter`)落地;当某能力无等价物时明确说明。 diff --git a/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.i18n.yaml index 345e563890..72c62d1e60 100644 --- a/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.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-28-dsh-meta-source-workspace.md -2026-07-28-dsh-meta-source-workspace.md: ab6050150f6321197686e1ad649555d3eaa7c374 -2026-07-28-dsh-meta-source-workspace.zh.md: c6f570498f10f16746b7aa5756b2e04074ae6a9e +2026-07-28-dsh-meta-source-workspace.md: 95270a276cd5df03ffd2dfb419a33289d7b5b901 +2026-07-28-dsh-meta-source-workspace.zh.md: 645b20705386a3501d026fb58ca224c49cc69a17 diff --git a/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.md b/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.md index ab6050150f..95270a276c 100644 --- a/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.md +++ b/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.md @@ -36,6 +36,6 @@ The mode was verified interactively instead. Launched from `$HOME`, a `pwd` tool ## Consequences -Starting a session on dsh's own source is `dsh meta` from anywhere, and the workspace is guaranteed to be the same checkout the model is told about. The command always starts fresh; an ordinary `dsh --resume ` later restores the session and enters its persisted workspace. +Starting a session on dsh's own source is `dsh meta --experimental` from anywhere (or bare `dsh meta` under `DSH_EXPERIMENTAL=1`), and the workspace is guaranteed to be the same checkout the model is told about. The command always starts fresh; an ordinary `dsh --resume ` later restores the session and enters its persisted workspace. `runTui` gains an optional third parameter, so the workspace override is visible at the one function that owns TUI composition rather than hidden in a second copy of it. diff --git a/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.zh.md b/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.zh.md index c6f570498f..645b207053 100644 --- a/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.zh.md @@ -36,6 +36,6 @@ Status: implemented ## Consequences -在 dsh 自身源码上开启会话变成了在任意位置执行 `dsh meta`,且该 workspace 必然就是告知模型的那个检出目录。该命令始终启动新会话;之后,普通的 `dsh --resume ` 会恢复该会话并进入其已持久化的 workspace。 +在 dsh 自身源码上开启会话变成了在任意位置执行 `dsh meta --experimental`(在 `DSH_EXPERIMENTAL=1` 下可直接执行 `dsh meta`),且该 workspace 必然就是告知模型的那个检出目录。该命令始终启动新会话;之后,普通的 `dsh --resume ` 会恢复该会话并进入其已持久化的 workspace。 `runTui` 新增一个可选第三参数,因此 workspace 覆盖是在拥有 TUI 组合逻辑的那唯一一个函数上可见的,而不是隐藏在它的第二份副本中。 diff --git a/.agents/notes/implemented/feature/2026-07-31-experimental-subcommand-gate.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-experimental-subcommand-gate.i18n.yaml index 511535d762..8d925d4eee 100644 --- a/.agents/notes/implemented/feature/2026-07-31-experimental-subcommand-gate.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-experimental-subcommand-gate.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-31-experimental-subcommand-gate.md -2026-07-31-experimental-subcommand-gate.md: 4f38d8cd83ecae7b4e104a61acdb7acca98c071d -2026-07-31-experimental-subcommand-gate.zh.md: 5fc2ff6513622c42e8b0eef711710a634f86870a +2026-07-31-experimental-subcommand-gate.md: 41447c2d23bc71964f990298de3c48b2fe7ef309 +2026-07-31-experimental-subcommand-gate.zh.md: 35598a61d98ff7490b34d175fc915043f5b5236a diff --git a/.agents/notes/implemented/feature/2026-07-31-experimental-subcommand-gate.md b/.agents/notes/implemented/feature/2026-07-31-experimental-subcommand-gate.md index 4f38d8cd83..41447c2d23 100644 --- a/.agents/notes/implemented/feature/2026-07-31-experimental-subcommand-gate.md +++ b/.agents/notes/implemented/feature/2026-07-31-experimental-subcommand-gate.md @@ -12,10 +12,14 @@ The `meta` and `upgrade` entry points carried their experimental status in their `dsh experimental-meta` is `dsh meta` and `dsh experimental-upgrade` is `dsh upgrade`. Each runs only when the invocation passes its `--experimental` flag or the environment carries `DSH_EXPERIMENTAL=1`; otherwise the command fails loud on stderr with exit 1, naming both opt-ins. Per the pre-release stance, the old names are gone with no aliases, and `args.spec.ts` pins their rejection. -The gate has two halves with one owner each. The per-invocation half is a Commander `--experimental` option on each experimental subcommand, checked inside its action after the leaked-parent-option rejection. The environment half is a boolean `parseDshArgs` parameter: `bin.ts` reads `process.env.DSH_EXPERIMENTAL === '1'` at the process boundary and passes the result down, so the parser stays a pure function of its inputs and the tests need no env mutation. `1` is the only enabling value — the variable is an explicit opt-in, not a truthiness check. +The gate has two halves with one owner each. The per-invocation half is a Commander `--experimental` option on each experimental subcommand, checked inside its action after the leaked-parent-option rejection. The environment half is a boolean `parseDshArgs` parameter: `bin.ts` reads `process.env.DSH_EXPERIMENTAL === '1'` at the process boundary (after `loadEnv`, so a project `.env` can set it) and passes the result down, so the parser's environment dependency is explicit in its signature and the tests need no env mutation. `1` is the only enabling value — the variable is an explicit opt-in, not a truthiness check. Stabilizing a command later means deleting its `--experimental` option and `requireExperimental` call; the name does not move. +## Testing + +`args.spec.ts` pins both admit paths, bare-name rejection, old-name rejection, and leaked-option rejection under the env opt-in. `built-bin.e2e.ts` proves the assembled entry end to end: the gate diagnostic on stderr with exit 1, and that `--experimental`, `DSH_EXPERIMENTAL=1`, but not `DSH_EXPERIMENTAL=0`, reach the TUI's piped-stdio refusal — the next gate past this one. Both gated commands were also verified interactively in tmux: `dsh meta --experimental` and `DSH_EXPERIMENTAL=1 dsh meta` boot the TUI over the checkout, and `DSH_EXPERIMENTAL=1 dsh upgrade` seeds the `dsh-upgrade` skill. + ## Alternatives considered **Keep the `experimental-` name prefix.** Rejected by the user's direction: the prefix taxes every invocation, and stabilization would be a breaking rename instead of deleting a gate. diff --git a/.agents/notes/implemented/feature/2026-07-31-experimental-subcommand-gate.zh.md b/.agents/notes/implemented/feature/2026-07-31-experimental-subcommand-gate.zh.md index 5fc2ff6513..35598a61d9 100644 --- a/.agents/notes/implemented/feature/2026-07-31-experimental-subcommand-gate.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-experimental-subcommand-gate.zh.md @@ -12,10 +12,14 @@ Status: implemented `dsh experimental-meta` 改为 `dsh meta`,`dsh experimental-upgrade` 改为 `dsh upgrade`。二者只有在调用时传入各自的 `--experimental` 标志、或环境中带有 `DSH_EXPERIMENTAL=1` 时才会运行;否则命令在 stderr 上明确报错并以退出码 1 结束,同时指明两种选择加入方式。依据发布前立场,旧名称已移除且没有别名,`args.spec.ts` 钉住了对它们的拒绝。 -该门槛分为两半,各有其归属。按调用的一半是每个实验性子命令上的 Commander `--experimental` 选项,在其 action 内、泄漏父级选项的拒绝之后检查。环境的一半是 `parseDshArgs` 的一个布尔参数:`bin.ts` 在进程边界读取 `process.env.DSH_EXPERIMENTAL === '1'` 并向下传递结果,因此解析器保持为其输入的纯函数,测试也无需改动环境变量。`1` 是唯一的启用值——该变量是显式的选择加入,而不是真值判断。 +该门槛分为两半,各有其归属。按调用的一半是每个实验性子命令上的 Commander `--experimental` 选项,在其 action 内、泄漏父级选项的拒绝之后检查。环境的一半是 `parseDshArgs` 的一个布尔参数:`bin.ts` 在进程边界读取 `process.env.DSH_EXPERIMENTAL === '1'`(在 `loadEnv` 之后,因此项目 `.env` 也可以设置它)并向下传递结果,因此解析器对环境的依赖显式体现在签名中,测试也无需改动环境变量。`1` 是唯一的启用值——该变量是显式的选择加入,而不是真值判断。 之后要稳定某个命令,只需删除它的 `--experimental` 选项和 `requireExperimental` 调用;名字不再变动。 +## Testing + +`args.spec.ts` 钉住两条准入路径、裸名称拒绝、旧名称拒绝,以及在环境选择加入下对泄漏选项的拒绝。`built-bin.e2e.ts` 端到端地证明组装后的入口:stderr 上的门槛诊断与退出码 1,以及 `--experimental`、`DSH_EXPERIMENTAL=1`(而非 `DSH_EXPERIMENTAL=0`)会到达 TUI 的管道 stdio 拒绝——即此门之后的下一道关卡。两个被把守的命令还在 tmux 中做了交互式验证:`dsh meta --experimental` 与 `DSH_EXPERIMENTAL=1 dsh meta` 以检出目录为 workspace 启动 TUI,`DSH_EXPERIMENTAL=1 dsh upgrade` 播种 `dsh-upgrade` skill。 + ## Alternatives considered **保留 `experimental-` 名称前缀。** 按用户的指示拒绝:前缀让每次调用都付出代价,稳定时也会变成破坏性的重命名,而不是删除一个门槛。 diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index c9e1b29969..9591ca3105 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -54,6 +54,31 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', expect(stdout).toBe('') }, 30_000) + describe('experimental subcommand gate', () => { + // The gate has two halves: a per-invocation --experimental flag parsed by + // Commander and an env opt-in read by bin.ts as exactly '1'. Passing the + // gate is proven by reaching the NEXT failure — the TUI's piped-stdio + // refusal — instead of the gate diagnostic. + it('rejects bare `meta`/`upgrade` LOUD, naming both opt-ins', async () => { + for (const command of ['meta', 'upgrade']) { + const { code, stderr } = await runBuiltBin([command], { DSH_EXPERIMENTAL: '' }) + expect(code).toBe(1) + expect(stderr).toContain(`${command} is experimental; pass --experimental or set DSH_EXPERIMENTAL=1`) + } + }, 30_000) + + it('admits --experimental and DSH_EXPERIMENTAL=1, but not other env values', async () => { + const flagged = await runBuiltBin(['meta', '--experimental'], { DSH_EXPERIMENTAL: '' }) + expect(flagged.stderr).toContain('requires stdin and stdout to be interactive TTYs') + const env = await runBuiltBin(['meta'], { DSH_EXPERIMENTAL: '1' }) + expect(env.stderr).toContain('requires stdin and stdout to be interactive TTYs') + // The env opt-in is exact: '0' (or any other value) does not enable. + const zero = await runBuiltBin(['meta'], { DSH_EXPERIMENTAL: '0' }) + expect(zero.code).toBe(1) + expect(zero.stderr).toContain('meta is experimental') + }, 30_000) + }) + describe('dsh --dump-config', () => { let home: string beforeEach(() => { home = mkdtempSync(join(tmpdir(), 'dsh-dump-bin-')) }) From 1e334fa9558fc3ba700efdf4b8098dfac13775cd Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Sat, 1 Aug 2026 00:05:23 -0700 Subject: [PATCH 6/7] feat(web): render TeX math in Markdown --- THIRD_PARTY_NOTICES.md | 3 + .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- packages/client/ui-primitives/package.json | 3 + .../src/markdown/MarkdownText.module.css | 8 +- .../src/markdown/MarkdownText.tsx | 9 +- .../ui-primitives/tests/markdown.spec.tsx | 23 +++ pnpm-lock.yaml | 189 ++++++++++++++++++ 9 files changed, 236 insertions(+), 7 deletions(-) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 075e1b34c4..754ae93d82 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -58,6 +58,7 @@ External packages that a workspace package resolves at runtime. `scripts/install | [`immer`](https://github.com/immerjs/immer) | MIT | | [`js-yaml`](https://github.com/nodeca/js-yaml) | MIT | | [`jsonc-parser`](https://github.com/microsoft/node-jsonc-parser) | MIT | +| [`katex`](https://github.com/KaTeX/KaTeX) | MIT | | [`koffi`](https://github.com/Koromix/koffi) | MIT | | [`mdast-util-from-markdown`](https://github.com/syntax-tree/mdast-util-from-markdown) | MIT | | [`mdast-util-gfm`](https://github.com/syntax-tree/mdast-util-gfm) | MIT | @@ -68,7 +69,9 @@ External packages that a workspace package resolves at runtime. `scripts/install | [`react`](https://github.com/facebook/react) | MIT | | [`react-dom`](https://github.com/facebook/react) | MIT | | [`react-markdown`](https://github.com/remarkjs/react-markdown) | MIT | +| [`rehype-katex`](https://github.com/remarkjs/remark-math/tree/main/packages/rehype-katex) | MIT | | [`remark-gfm`](https://github.com/remarkjs/remark-gfm) | MIT | +| [`remark-math`](https://github.com/remarkjs/remark-math/tree/main/packages/remark-math) | MIT | | [`saxes`](https://github.com/lddubeau/saxes) | ISC | | [`shiki`](https://github.com/shikijs/shiki) | MIT | | [`supports-color`](https://github.com/chalk/supports-color) | MIT | diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index b5bb7f0adb..f79b5073be 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/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/client/ui-primitives/README.md -README.md: f06e8c014c19d17197980a232e8b42080aac2901 -README.zh.md: feb97ee9fa834fbff10052909630ffcbbce9c78a +README.md: 8bdc3f043488631424c85a4319020c3d8ba5437b +README.zh.md: 52b507bb924ca4c05e92ed09d819339621c3bc20 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index f06e8c014c..8bdc3f0434 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -10,7 +10,7 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/ ## Markdown rendering -`MarkdownText` renders GFM from untrusted assistant output through React elements. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders image alt text without loading remote resources; `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars). +`MarkdownText` renders GFM and `$…$` / `$$…$$` TeX math from untrusted assistant output through React elements, with math typeset by KaTeX and trusted commands disabled. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders image alt text without loading remote resources; `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars). ## Terminal output diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index feb97ee9fa..52b507bb92 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -10,7 +10,7 @@ ## Markdown 渲染 -`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并只渲染图片 alt 文本而不加载远程资源;`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。 +`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$` / `$$…$$` TeX 公式,公式由 KaTeX 排版并禁用受信任命令。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并只渲染图片 alt 文本而不加载远程资源;`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。 ## 终端输出 diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json index 2ac25a6708..36962c90cb 100644 --- a/packages/client/ui-primitives/package.json +++ b/packages/client/ui-primitives/package.json @@ -23,13 +23,16 @@ "@shikijs/langs": "^4.3.1", "anser": "^2.3.5", "clsx": "^2.0.0", + "katex": "^0.16.47", "mdast-util-from-markdown": "^2.0.3", "mdast-util-gfm": "^3.1.0", "micromark-extension-gfm": "^3.0.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-markdown": "^10.1.0", + "rehype-katex": "^7.0.1", "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0", "shiki": "^4.3.1" }, "devDependencies": { diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css index a189528bc9..116c7c4976 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css @@ -1,5 +1,5 @@ /* Visual baseline: deepsuite `@deepseek/md` markdown.css, adapted to CSS - Modules. Cite pills, KaTeX, header anchors, and thinking-small variants are + Modules. Cite pills, header anchors, and thinking-small variants are intentionally absent (no matching DOM). Token names match that sheet. */ .markdown { @@ -160,6 +160,12 @@ font-family: var(--ds-font-family-code); } +.markdown :global(.katex-display) { + max-width: 100%; + overflow-x: auto; + overflow-y: hidden; +} + .markdown input[type='checkbox'] { margin: 0 8px 0 0; accent-color: var(--dsw-alias-label-secondary); diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx index 297275e80b..35d29ca720 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx @@ -1,11 +1,15 @@ import { isValidElement, useMemo } from 'react' import ReactMarkdown from 'react-markdown' import type { Components, UrlTransform } from 'react-markdown' +import rehypeKatex from 'rehype-katex' import remarkGfm from 'remark-gfm' +import remarkMath from 'remark-math' import { CodeBlock } from './CodeBlock.tsx' +import 'katex/dist/katex.min.css' import css from './MarkdownText.module.css' -const remarkPlugins = [remarkGfm] +const remarkPlugins = [remarkGfm, remarkMath] +const rehypePlugins = [rehypeKatex] function sanitizeUrl(url: string): string { try { @@ -93,7 +97,7 @@ const streamingComponents = buildComponents(true) * pass a reference-stable object (memoized per locale revision), because the * component table memoizes on its identity and a fresh literal per render * would rebuild it every streaming chunk. - * @returns A GFM document with raw HTML, relative links, unsafe protocols, and remote images disabled. + * @returns A GFM document with TeX math rendered through KaTeX and raw HTML, relative links, unsafe protocols, and remote images disabled. */ export function MarkdownText({ text, streaming = false, codeLabels }: { text: string @@ -110,6 +114,7 @@ export function MarkdownText({ text, streaming = false, codeLabels }: {
diff --git a/packages/client/ui-primitives/tests/markdown.spec.tsx b/packages/client/ui-primitives/tests/markdown.spec.tsx index b7f665c78a..1431e2ae5b 100644 --- a/packages/client/ui-primitives/tests/markdown.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown.spec.tsx @@ -94,6 +94,11 @@ describe('MarkdownText', () => { expect(done.container.querySelector('pre.shiki')).not.toBeNull() }) + it('forwards localized labels to fenced code blocks', () => { + render() + expect(screen.getByRole('button', { name: 'Copy code' })).toBeTruthy() + }) + it('neutralizes raw HTML, unsafe or relative links, and remote images', () => { const markdown = [ '', @@ -121,6 +126,24 @@ describe('MarkdownText', () => { expect(container.querySelectorAll('li')).toHaveLength(2) expect(screen.getByText('**unfinished')).toBeTruthy() }) + + it('renders inline and display TeX through KaTeX without enabling trusted commands', () => { + const source = [ + 'Einstein wrote $E = mc^2$.', + '', + '$$', + '\\frac{\\partial \\mathbf{u}}{\\partial t} + (\\mathbf{u} \\cdot \\nabla)\\mathbf{u} = -\\frac{1}{\\rho}\\nabla p', + '$$', + '', + '$\\href{javascript:alert(1)}{unsafe}$', + ].join('\n') + const { container } = render() + + expect(container.querySelectorAll('.katex')).toHaveLength(3) + expect(container.querySelectorAll('.katex-display')).toHaveLength(1) + expect(container.querySelector('.katex-display annotation')?.textContent).toContain('\\frac{\\partial \\mathbf{u}}') + expect(container.querySelector('a')).toBeNull() + }) }) describe('JsonBlock', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7073f04e4b..34eb7b8f87 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1569,6 +1569,9 @@ importers: clsx: specifier: ^2.0.0 version: 2.1.1 + katex: + specifier: ^0.16.47 + version: 0.16.47 mdast-util-from-markdown: specifier: ^2.0.3 version: 2.0.3 @@ -1587,9 +1590,15 @@ importers: react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@18.3.31)(react@18.3.1) + rehype-katex: + specifier: ^7.0.1 + version: 7.0.1 remark-gfm: specifier: ^4.0.1 version: 4.0.1 + remark-math: + specifier: ^6.0.0 + version: 6.0.0 shiki: specifier: ^4.3.1 version: 4.3.1 @@ -8835,6 +8844,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/katex@0.16.8': + resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==} + '@types/linkify-it@5.0.0': resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} @@ -9637,6 +9649,10 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + entities@7.0.1: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} @@ -9984,15 +10000,39 @@ packages: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} + hast-util-from-dom@5.0.1: + resolution: {integrity: sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==} + + hast-util-from-html-isomorphic@2.0.0: + resolution: {integrity: sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==} + + hast-util-from-html@2.0.3: + resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==} + + hast-util-from-parse5@8.0.3: + resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==} + + hast-util-is-element@3.0.0: + resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} + + hast-util-parse-selector@4.0.0: + resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} + hast-util-to-html@9.0.5: resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} hast-util-to-jsx-runtime@2.3.6: resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + hast-util-to-text@4.0.2: + resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==} + hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + hastscript@9.0.1: + resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + hono@4.12.29: resolution: {integrity: sha512-1hNiRjawYrLq/4m3DQQjPGFg0VZkk4RjQJDff/excI6Dm9BiL75qxGrd7/c6YOxPdq6AscP3LiXhQ6fKFC1Waw==} engines: {node: '>=16.9.0'} @@ -10508,6 +10548,9 @@ packages: mdast-util-gfm@3.1.0: resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + mdast-util-math@3.0.0: + resolution: {integrity: sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==} + mdast-util-mdx-expression@2.0.1: resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} @@ -10567,6 +10610,9 @@ packages: micromark-extension-gfm@3.0.0: resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + micromark-extension-math@3.1.0: + resolution: {integrity: sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==} + micromark-factory-destination@2.0.1: resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} @@ -10868,6 +10914,9 @@ packages: resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} engines: {node: '>=18'} + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parse5@8.0.1: resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} @@ -11052,9 +11101,15 @@ packages: resolution: {integrity: sha512-sZuz1dYW/ZsfG17WSAG7eS85r5a0dDsvg+7BiiYR5o6lKCAtUrEwdmRmaGF6rwVj3LcmAeYkOWKEPlbPzN3Y3A==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + rehype-katex@7.0.1: + resolution: {integrity: sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==} + remark-gfm@4.0.1: resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + remark-math@6.0.0: + resolution: {integrity: sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==} + remark-parse@11.0.0: resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} @@ -11485,12 +11540,18 @@ packages: unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + unist-util-find-after@5.0.0: + resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==} + unist-util-is@6.0.1: resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} unist-util-position@5.0.0: resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + unist-util-remove-position@5.0.0: + resolution: {integrity: sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==} + unist-util-stringify-position@4.0.0: resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} @@ -11529,6 +11590,9 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + vfile-location@5.0.3: + resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} + vfile-message@4.0.3: resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} @@ -11743,6 +11807,9 @@ packages: resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} engines: {node: 20 || >=22} + web-namespaces@2.0.1: + resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + web-streams-polyfill@3.3.3: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} @@ -13835,6 +13902,8 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/katex@0.16.8': {} + '@types/linkify-it@5.0.0': {} '@types/markdown-it@14.1.2': @@ -14673,6 +14742,8 @@ snapshots: encodeurl@2.0.0: {} + entities@6.0.1: {} + entities@7.0.1: {} entities@8.0.0: {} @@ -15157,6 +15228,47 @@ snapshots: dependencies: function-bind: 1.1.2 + hast-util-from-dom@5.0.1: + dependencies: + '@types/hast': 3.0.5 + hastscript: 9.0.1 + web-namespaces: 2.0.1 + + hast-util-from-html-isomorphic@2.0.0: + dependencies: + '@types/hast': 3.0.5 + hast-util-from-dom: 5.0.1 + hast-util-from-html: 2.0.3 + unist-util-remove-position: 5.0.0 + + hast-util-from-html@2.0.3: + dependencies: + '@types/hast': 3.0.5 + devlop: 1.1.0 + hast-util-from-parse5: 8.0.3 + parse5: 7.3.0 + vfile: 6.0.3 + vfile-message: 4.0.3 + + hast-util-from-parse5@8.0.3: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + devlop: 1.1.0 + hastscript: 9.0.1 + property-information: 7.2.0 + vfile: 6.0.3 + vfile-location: 5.0.3 + web-namespaces: 2.0.1 + + hast-util-is-element@3.0.0: + dependencies: + '@types/hast': 3.0.5 + + hast-util-parse-selector@4.0.0: + dependencies: + '@types/hast': 3.0.5 + hast-util-to-html@9.0.5: dependencies: '@types/hast': 3.0.5 @@ -15191,10 +15303,25 @@ snapshots: transitivePeerDependencies: - supports-color + hast-util-to-text@4.0.2: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + hast-util-is-element: 3.0.0 + unist-util-find-after: 5.0.0 + hast-util-whitespace@3.0.0: dependencies: '@types/hast': 3.0.5 + hastscript@9.0.1: + dependencies: + '@types/hast': 3.0.5 + comma-separated-tokens: 2.0.3 + hast-util-parse-selector: 4.0.0 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + hono@4.12.29: {} hookable@5.5.3: {} @@ -15713,6 +15840,18 @@ snapshots: transitivePeerDependencies: - supports-color + mdast-util-math@3.0.0: + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + longest-streak: 3.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + unist-util-remove-position: 5.0.0 + transitivePeerDependencies: + - supports-color + mdast-util-mdx-expression@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 @@ -15892,6 +16031,16 @@ snapshots: micromark-util-combine-extensions: 2.0.1 micromark-util-types: 2.0.2 + micromark-extension-math@3.1.0: + dependencies: + '@types/katex': 0.16.8 + devlop: 1.1.0 + katex: 0.16.47 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + micromark-factory-destination@2.0.1: dependencies: micromark-util-character: 2.1.1 @@ -16276,6 +16425,10 @@ snapshots: parse-ms@4.0.0: {} + parse5@7.3.0: + dependencies: + entities: 6.0.1 + parse5@8.0.1: dependencies: entities: 8.0.0 @@ -16461,6 +16614,16 @@ snapshots: '@eslint-community/regexpp': 4.12.2 refa: 0.12.1 + rehype-katex@7.0.1: + dependencies: + '@types/hast': 3.0.5 + '@types/katex': 0.16.8 + hast-util-from-html-isomorphic: 2.0.0 + hast-util-to-text: 4.0.2 + katex: 0.16.47 + unist-util-visit-parents: 6.0.2 + vfile: 6.0.3 + remark-gfm@4.0.1: dependencies: '@types/mdast': 4.0.4 @@ -16472,6 +16635,15 @@ snapshots: transitivePeerDependencies: - supports-color + remark-math@6.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-math: 3.0.0 + micromark-extension-math: 3.1.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + remark-parse@11.0.0: dependencies: '@types/mdast': 4.0.4 @@ -16958,6 +17130,11 @@ snapshots: trough: 2.2.0 vfile: 6.0.3 + unist-util-find-after@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-is@6.0.1: dependencies: '@types/unist': 3.0.3 @@ -16966,6 +17143,11 @@ snapshots: dependencies: '@types/unist': 3.0.3 + unist-util-remove-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-visit: 5.1.0 + unist-util-stringify-position@4.0.0: dependencies: '@types/unist': 3.0.3 @@ -17003,6 +17185,11 @@ snapshots: vary@1.1.2: {} + vfile-location@5.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile: 6.0.3 + vfile-message@4.0.3: dependencies: '@types/unist': 3.0.3 @@ -17283,6 +17470,8 @@ snapshots: walk-up-path@4.0.0: {} + web-namespaces@2.0.1: {} + web-streams-polyfill@3.3.3: {} webidl-conversions@8.0.1: {} From 8ce4f07f923067d81c4efb0bd82faccfa8a3b1b6 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Sat, 1 Aug 2026 00:58:50 -0700 Subject: [PATCH 7/7] fix(web): defer math rendering while streaming --- .../ui-primitives/src/markdown/MarkdownText.tsx | 11 ++++++----- .../client/ui-primitives/tests/markdown.spec.tsx | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx index 35d29ca720..a14f2c8a69 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx @@ -8,8 +8,9 @@ import { CodeBlock } from './CodeBlock.tsx' import 'katex/dist/katex.min.css' import css from './MarkdownText.module.css' -const remarkPlugins = [remarkGfm, remarkMath] -const rehypePlugins = [rehypeKatex] +const streamingRemarkPlugins = [remarkGfm] +const settledRemarkPlugins = [remarkGfm, remarkMath] +const settledRehypePlugins = [rehypeKatex] function sanitizeUrl(url: string): string { try { @@ -92,7 +93,7 @@ const streamingComponents = buildComponents(true) /** * Render untrusted assistant-authored Markdown as semantic React elements. * @param props - Markdown source text preserved by the session projection; - * `streaming` renders fences plain (highlighting lands on the finalize swap); + * `streaming` renders fences and TeX plain (highlighting and KaTeX land on the finalize swap); * `codeLabels` forwards localized copy-button labels to fence CodeBlocks — * pass a reference-stable object (memoized per locale revision), because the * component table memoizes on its identity and a fresh literal per render @@ -113,8 +114,8 @@ export function MarkdownText({ text, streaming = false, codeLabels }: { return (
diff --git a/packages/client/ui-primitives/tests/markdown.spec.tsx b/packages/client/ui-primitives/tests/markdown.spec.tsx index 1431e2ae5b..030856a261 100644 --- a/packages/client/ui-primitives/tests/markdown.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown.spec.tsx @@ -144,6 +144,21 @@ describe('MarkdownText', () => { expect(container.querySelector('.katex-display annotation')?.textContent).toContain('\\frac{\\partial \\mathbf{u}}') expect(container.querySelector('a')).toBeNull() }) + + it('defers TeX rendering while streaming so incomplete formulas never flash KaTeX errors', () => { + const partial = '$$\n\\frac{\\partial \\mathbf{u}}{\\partial' + const complete = '$$\n\\frac{\\partial \\mathbf{u}}{\\partial t}\n$$' + const live = render() + + expect(live.container.querySelector('.katex')).toBeNull() + expect(live.container.querySelector('.katex-error')).toBeNull() + expect(live.container.textContent).toContain('\\frac{\\partial \\mathbf{u}}{\\partial') + + live.rerender() + expect(live.container.querySelectorAll('.katex')).toHaveLength(1) + expect(live.container.querySelectorAll('.katex-display')).toHaveLength(1) + expect(live.container.querySelector('.katex-error')).toBeNull() + }) }) describe('JsonBlock', () => {