From 631510f54e2f01d0ab5b173b46518f6766a94777 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 31 Jul 2026 20:22:40 +0800 Subject: [PATCH 01/19] feat(install): adopt an existing checkout into the managed layout Running scripts/install.sh from a checkout linked `dsh` straight at that checkout, producing an install that `dsh-upgrade` cannot upgrade (there is no `current` to repoint), that dangles if the checkout moves, and whose launcher resolves to an arbitrary working branch. In-repo mode still never clones and never touches the working tree, but it now offers to adopt the checkout, and adoption is the default. The container owns staging worktrees and `current`; the repository is discovered via `git rev-parse --git-common-dir` rather than owned, so a clone anywhere on disk converges on the same upgradable layout as a curl install and both share one worktree/exclude/lock/link sequence. Declining, or DSH_ADOPT=0, keeps the previous link-in-place behavior with a warning naming what it costs, preserving the path that makes this script testable against local source. All path comparisons run on physical paths: macOS resolves /var through a symlink to /private/var, and comparing a resolved path against an unresolved one misclassified an existing managed install as a foreign clone. Verified manually (no install.spec.ts, per request) with a harness driving the real script under a stubbed pnpm across 33 assertions, plus both interactive outcomes under tmux. --- ...staller-adopts-existing-checkout.i18n.yaml | 6 + ...7-31-installer-adopts-existing-checkout.md | 49 ++++ ...1-installer-adopts-existing-checkout.zh.md | 49 ++++ README.i18n.yaml | 4 +- README.md | 2 + README.zh.md | 2 + scripts/install.sh | 224 ++++++++++++++---- 7 files changed, 285 insertions(+), 51 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md create mode 100644 .agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md diff --git a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml new file mode 100644 index 0000000000..0aab25dae7 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.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/process/2026-07-31-installer-adopts-existing-checkout.md +2026-07-31-installer-adopts-existing-checkout.md: 2513b31410d045469507b49176236bacb138ff1e +2026-07-31-installer-adopts-existing-checkout.zh.md: 3fca61e83730bf30bdb84c8832c2fd2163723858 diff --git a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md new file mode 100644 index 0000000000..2513b31410 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md @@ -0,0 +1,49 @@ +# Agent Note: the installer adopts an existing checkout into the managed layout + +Status: implemented + +English | [中文](2026-07-31-installer-adopts-existing-checkout.zh.md) + +## Problem + +`scripts/install.sh` produced two incompatible install shapes. A `curl … | sh` install built the managed layout — a master clone at `~/.dsh/source/master`, a staging worktree on `dsh-staging/`, and the stable `current` symlink the PATH launcher resolves through. Running the same script from a checkout instead linked `dsh` straight at that checkout's `bin/dsh`, per the earlier [in-repo skip-clone decision](../../archived/process/2026-07-22-installer-in-repo-skip-clone.md). + +The direct link is a terminal state. `current` is what an upgrade repoints, so an install without it is not upgradable by [`dsh-upgrade`](../../../../skills/dsh-upgrade/SKILL.md); the PATH symlink dangles if the checkout moves; and the launcher resolves to whatever branch the contributor happened to have checked out, which the upgrade contract forbids as a launcher target. The upgrade skill already described this shape as a legacy install needing a one-time migration, so the layouts diverged at install time and were reconciled only later, if ever. + +## Decision + +In-repo mode still never clones and never modifies the working tree, but it now offers to **adopt** the checkout into the managed layout, and adoption is the default. + +The container owns staging worktrees and `current`; the repository is *discovered*, not owned. `git rev-parse --git-common-dir` resolves the shared git directory behind the checkout — for a linked worktree that is the real clone rather than the worktree itself — and its parent is the repository that serves as the upgrade base. A staging worktree branched from the checkout's `HEAD` is then created under `$DSH_SOURCE`, and `current` points at it. A clone anywhere on disk therefore converges on the same layout as a `curl` install, and the two paths share one worktree/exclude/lock/link sequence: they differ only in whether the repository was discovered by `git clone` or by `git rev-parse`. + +`$DSH_SOURCE/master.path` records the resolved repository, and only when that repository lives outside the container. A container holding its own master is self-contained and gets no file, so the file's presence is itself the signal that this container depends on an outside path: each staging worktree holds an absolute gitdir pointer into that clone, so deleting the clone breaks them. + +Adoption branches from `HEAD`, so committed work is what runs and uncommitted changes stay in the checkout; a dirty tree is warned about before the prompt and whenever `DSH_ADOPT=1` skips it. Declining, or `DSH_ADOPT=0`, keeps the previous link-in-place behavior with a warning naming what it costs, because that path is what makes this script testable against local source. A repository with no commits cannot be branched and falls back to link-in-place; a checkout that is not a git repository fails with the `DSH_ADOPT=0` escape hatch named. + +`DSH_ADOPT=1` also overrides the rule that an explicit `DSH_SOURCE` opts back into cloning. Naming a container while asking for adoption otherwise silently cloned a different tree — the opposite of the request. + +Every path comparison runs on physical paths through a `resolve_dir` helper, and every compared value is resolved at assignment rather than at the comparison. macOS resolves `/var` through a symlink to `/private/var`, so comparing a git-reported path against an unresolved one misclassified an existing managed install as a foreign clone and would have built a second container beside the real one. The same defect recurred twice more during review — once where a curl install's `REPO_ROOT` stayed unresolved and wrote a spurious `master.path`, and once where `x=$(resolve_dir …) || x=$fallback` left an empty path because the assignment succeeds even when the substitution fails. `resolve_dir` therefore echoes a missing path back itself, and callers that need "does not exist" test the directory explicitly. `git rev-parse --path-format=absolute` would do the same job but requires git 2.31+. + +Before `current` is repointed, the installer rejects a staging path that resolves to the repository itself, enforcing the upgrade contract that the launcher never resolves to the master clone. + +## Alternatives considered + +**Make `~/.dsh/source/master` a symlink to the arbitrary clone.** Rejected. Git resolves the symlink and records the *real* path: a worktree created through it stores `gitdir: …//.git/worktrees/`, and `git worktree list` reports the clone. The symlink is therefore decorative — nothing reads it — while implying the container owns the repository. It also fails silently: moving the clone leaves `master` present but dangling and every staging worktree dead with `fatal: not a git repository`. Worst, it aliases two names onto one tree, so the "current must never be the master clone" check passes by string comparison while being false. `~/.dsh/source/master` is a location, not a name, and only the location is authoritative. + +**Promote the checkout itself to the `current` target.** Rejected: the upgrade contract requires `current` to be a clean staging worktree on a staging branch, never a feature, review, or detached checkout. It would also make every upgrade rewrite the tree the contributor is editing. + +**Keep adoption opt-in.** Rejected as the default: the divergent shape was the actual defect, and leaving the fix behind a flag means the common `sh scripts/install.sh` invocation keeps producing unupgradable installs. Declining is one keystroke and `DSH_ADOPT=0` is scriptable. + +**Put an adopted clone's staging worktrees beside the clone** (`~/src/staging-*`) rather than in `~/.dsh/source`. Rejected: `current` and the PATH launcher are per-user singletons, so scattering worktrees across clone parents reintroduces the sibling-clone sprawl the source container exists to prevent. + +## Consequences + +One layout now serves both installs, so an adopted clone is upgradable by `dsh-upgrade` without the one-time migration that skill described. In-repo runs still never mutate the working tree, and the escape hatch that keeps this script testable against local source survives behind a prompt and `DSH_ADOPT=0`. + +The cost is that a container adopting an outside clone is no longer self-contained: deleting that clone breaks its staging worktrees. This is inherent to reusing an existing clone rather than a property of this design — the rejected symlink hides it rather than fixing it — and `master.path` is the mitigation, not a repair. + +## Testing + +`scripts/install.sh` has no automated test, and this change does not add one: the user directed that `install.spec.ts` be left out of scope. That is a known gap on a shipped user-facing path, and the `/var` resolution defect above is exactly the class of bug a test would have caught first. The standing [`FIXME(install-ts)`](../../../../scripts/install.sh) asking for this workflow to move into a tested TypeScript entrypoint is correspondingly more pressing. + +Verification was manual, through a throwaway harness driving the real script with a stubbed `pnpm`: adopting a standalone clone; adopting from a linked worktree into its existing container; `DSH_ADOPT=0` preserving link-in-place; a commitless repository falling back; a dirty tree warning while leaving uncommitted work behind; a non-git checkout failing with guidance; and a `curl`-style clone install asserting both the built layout and the absence of `master.path`, which is the regression that caught the unresolved-`REPO_ROOT` defect. Both interactive outcomes were exercised under tmux: accepting ends with the launcher running from the new staging worktree while the original checkout keeps its branch and clean status, and declining reproduces the legacy shape with no staging worktree and no `current`. diff --git a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md new file mode 100644 index 0000000000..3fca61e837 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md @@ -0,0 +1,49 @@ +# Agent Note: 安装器把已有检出接管进受管布局 + +Status: implemented + +[English](2026-07-31-installer-adopts-existing-checkout.md) | 中文 + +## Problem + +`scripts/install.sh`会产生两种互不兼容的安装形态。`curl … | sh`安装会构建受管布局——`~/.dsh/source/master`处的 master 克隆、位于`dsh-staging/<时间戳>`分支上的 staging worktree,以及 PATH 启动器据以解析的稳定`current`符号链接。而从检出中运行同一脚本时,则依据此前的[检出内跳过克隆决策](../../archived/process/2026-07-22-installer-in-repo-skip-clone.md),把`dsh`直接链接到该检出的`bin/dsh`。 + +这种直接链接是一种终态。升级重指的正是`current`,因此缺少它的安装无法通过[`dsh-upgrade`](../../../../skills/dsh-upgrade/SKILL.md)升级;检出一旦移动,PATH 符号链接就会失效;而且启动器会解析到贡献者恰好检出的任意分支,这正是升级契约禁止作为启动器目标的情形。升级技能早已把这种形态描述为需要一次性迁移的旧式安装,于是两种布局在安装时就已分叉,并且要到很久以后才会被调和——甚至永远不会。 + +## Decision + +检出内模式仍然绝不克隆、绝不修改工作树,但现在它会询问是否把该检出**接管**进受管布局,并且接管是默认选项。 + +容器拥有 staging worktree 和`current`;仓库是被*发现*的,而非被拥有的。`git rev-parse --git-common-dir`会解析出该检出背后的共享 git 目录——对于 linked worktree,那是真正的克隆而非 worktree 自身——其父目录即是充当升级基础的仓库。随后以该检出的`HEAD`为起点,在`$DSH_SOURCE`下创建 staging worktree,并让`current`指向它。因此,磁盘上任意位置的克隆都会收敛到与`curl`安装相同的布局,且两条路径共用同一套 worktree/exclude/lock/link 流程:二者的唯一差别,只在于仓库是由`git clone`发现的,还是由`git rev-parse`发现的。 + +`$DSH_SOURCE/master.path`记录解析出的仓库,且仅在该仓库位于容器之外时才记录。拥有自身 master 的容器是自包含的,不会生成该文件;因此该文件的存在本身就是一个信号,表明此容器依赖于外部路径:每个 staging worktree 都持有指向该克隆的绝对 gitdir 指针,删除该克隆就会破坏它们。 + +接管以`HEAD`为分支起点,因此运行的是已提交的内容,未提交的更改仍留在检出中;工作树不干净时,会在提示前发出警告,`DSH_ADOPT=1`跳过提示时同样警告。拒绝接管或设置`DSH_ADOPT=0`将保留原有的就地链接行为,并以警告说明其代价,因为正是这条路径使本脚本能针对本地源码进行测试。没有任何提交的仓库无法创建分支,会回退到就地链接;并非 git 仓库的检出则会失败,并在错误信息中给出`DSH_ADOPT=0`这一退路。 + +`DSH_ADOPT=1`同时会覆盖"显式`DSH_SOURCE`即回到克隆路径"的规则。否则,在请求接管的同时指定容器,反而会静默克隆另一棵树——与请求恰好相反。 + +所有路径比较都通过`resolve_dir`辅助函数在物理路径上进行,且每个参与比较的值都在赋值时解析,而非在比较时解析。macOS 会把`/var`经符号链接解析为`/private/var`,因此拿 git 报告的路径与未解析的路径相比较,会把已有的受管安装误判为外来克隆,并在真正的容器旁再建一个容器。同一缺陷在评审过程中又出现了两次——一次是 curl 安装的`REPO_ROOT`未经解析,导致写出多余的`master.path`;另一次是`x=$(resolve_dir …) || x=$fallback`留下了空路径,因为即使命令替换失败,赋值本身仍然成功。因此`resolve_dir`会在路径不存在时原样回显该路径,而需要判断"不存在"的调用方则显式检测该目录。`git rev-parse --path-format=absolute`能完成同样的工作,但要求 git 2.31 及以上版本。 + +在重指`current`之前,安装器会拒绝解析结果等于仓库自身的 staging 路径,以此落实"启动器绝不解析到 master 克隆"这一升级契约。 + +## Alternatives considered + +**把`~/.dsh/source/master`做成指向该任意克隆的符号链接。** 已否决。Git 会解析该符号链接并记录*真实*路径:经由它创建的 worktree 会存储`gitdir: …/<克隆>/.git/worktrees/<名称>`,而`git worktree list`报告的是该克隆。因此这个符号链接纯属装饰——没有任何代码读取它——却又暗示容器拥有该仓库。它还会静默失效:移动克隆后,`master`看似仍在却已悬空,而每个 staging worktree 都会以`fatal: not a git repository`失败。最糟的是,它把两个名称别名到同一棵树上,于是"current 绝不能是 master 克隆"这项检查会在字符串比较下通过,实则为假。`~/.dsh/source/master`是位置而非名称,且只有位置具有权威性。 + +**把检出自身提升为`current`的目标。** 已否决:升级契约要求`current`必须是位于 staging 分支上的干净 staging worktree,绝不能是 feature、review 或 detached 检出。这还会使每次升级都改写贡献者正在编辑的那棵树。 + +**让接管保持为可选项。** 作为默认行为已否决:分叉的形态本身才是真正的缺陷,把修复藏在开关之后,意味着常见的`sh scripts/install.sh`调用仍会产生无法升级的安装。拒绝只需一次按键,而`DSH_ADOPT=0`可用于脚本。 + +**把被接管克隆的 staging worktree 放在该克隆旁边**(`~/src/staging-*`),而非放进`~/.dsh/source`。已否决:`current`和 PATH 启动器都是每用户唯一的,因此把 worktree 散落到各个克隆的父目录中,会重新引入 source 容器本就为之而设、意在杜绝的同级克隆蔓延问题。 + +## Consequences + +现在一套布局同时服务于两种安装,因此被接管的克隆无需该技能所述的一次性迁移,即可由`dsh-upgrade`升级。检出内运行仍然绝不改动工作树,而使本脚本能针对本地源码进行测试的那条退路,也以提示和`DSH_ADOPT=0`的形式保留了下来。 + +代价是:接管外部克隆的容器不再自包含——删除该克隆会破坏其 staging worktree。这是复用已有克隆的固有属性,而非本设计带来的性质——被否决的符号链接方案只是掩盖它,而非修复它——`master.path`是缓解措施,不是修复。 + +## Testing + +`scripts/install.sh`没有自动化测试,本次变更也未添加:用户明确要求把`install.spec.ts`排除在范围之外。这是一条已交付的、面向用户的安装路径上的已知缺口,而上文那个`/var`解析缺陷,恰恰属于测试本应最先捕获的那类 bug。相应地,要求把这套流程迁移到有测试覆盖的 TypeScript 入口的既有[`FIXME(install-ts)`](../../../../scripts/install.sh)也变得更为紧迫。 + +验证是手工完成的,通过一个一次性测试装置以打桩的`pnpm`驱动真实脚本:接管独立克隆;从 linked worktree 接管进其已有容器;`DSH_ADOPT=0`保持就地链接;无提交的仓库发生回退;工作树不干净时发出警告并把未提交内容留在原处;非 git 检出失败并给出指引;以及`curl`式克隆安装同时断言所构建的布局和`master.path`的缺失——正是这项回归测试捕获了`REPO_ROOT`未解析的缺陷。两种交互结果都在 tmux 下走通:接受时,启动器最终从新的 staging worktree 运行,而原检出保持其分支不变且状态干净;拒绝时,则复现旧式形态,既无 staging worktree 也无`current`。 diff --git a/README.i18n.yaml b/README.i18n.yaml index b492ed9c37..07b5f5c2b2 100644 --- a/README.i18n.yaml +++ b/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 README.md -README.md: b447c9634189353854e8be9d0bf597a8b0c7e371 -README.zh.md: f8bbbc36bc670403c0b9a40977f32f598e77ee46 +README.md: 6266e9087e6cc6f53e61127559e166064cbd3970 +README.zh.md: 1343c099161b660d9d23d101a92deca1d35e441b diff --git a/README.md b/README.md index b447c96341..6266e9087e 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,8 @@ The installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` The installer keeps every checkout under `~/.dsh/source`: the master clone at `~/.dsh/source/master` and each install's staging checkout as a git worktree `~/.dsh/source/staging-`. The stable symlink `~/.dsh/source/current` points at the active staging worktree, and `dsh` in `~/.local/bin` links to `current/bin/dsh`, so an upgrade repoints one symlink and the `dsh` on PATH never moves. Re-running the command adds a fresh staging worktree from an updated master and repoints `current` at it. See [`scripts/install.sh`](scripts/install.sh) for alternate install locations and other options. +Running the script from an existing clone (`sh scripts/install.sh`) never clones and never modifies that working tree. It offers to *adopt* the clone: the repository behind the checkout becomes the upgrade base, and a staging worktree branched from the checkout's current `HEAD` lands under `~/.dsh/source` with `current` pointing at it, so a clone anywhere on disk gets the same upgradable layout. Adoption carries committed work only — uncommitted changes stay in the clone. Declining (or `DSH_ADOPT=0`) links `dsh` straight at that checkout instead, which is not upgradable and breaks if the checkout moves. + ## Use DeepSeek Harness ### Web UI diff --git a/README.zh.md b/README.zh.md index f8bbbc36bc..1343c09916 100644 --- a/README.zh.md +++ b/README.zh.md @@ -26,6 +26,8 @@ curl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/m 安装器会把所有检出都放在 `~/.dsh/source` 下:master 克隆位于 `~/.dsh/source/master`,每次安装的 staging 检出是一个 git worktree `~/.dsh/source/staging-<时间戳>`。稳定符号链接 `~/.dsh/source/current` 指向当前生效的 staging worktree,`~/.local/bin` 中的 `dsh` 链接到 `current/bin/dsh`,因此升级只需重指一个符号链接,PATH 上的 `dsh` 从不移动。再次运行该命令会基于更新后的 master 新增一个 staging worktree,并把 `current` 重指到它。其他安装位置和选项见 [`scripts/install.sh`](scripts/install.sh)。 +从现有克隆中运行该脚本(`sh scripts/install.sh`)不会进行任何克隆,也不会修改该工作树。它会询问是否*接管*该克隆:该检出所属的仓库将成为升级基础,以该检出当前的 `HEAD` 为起点创建的 staging worktree 会被放在 `~/.dsh/source` 下,并由 `current` 指向它,因此磁盘上任意位置的克隆都能获得相同的可升级布局。接管只会带入已提交的内容——未提交的更改仍留在克隆中。如果拒绝接管(或设置 `DSH_ADOPT=0`),则会改为将 `dsh` 直接链接到该检出;这种方式无法升级,且检出一旦移动,链接就会失效。 + ## 使用 DeepSeek Harness ### Web UI diff --git a/scripts/install.sh b/scripts/install.sh index 41d5c749c1..5c38f8974c 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -18,12 +18,27 @@ # of relinking PATH: the `dsh` on PATH never moves and can never dangle. # # When run from inside an existing checkout (e.g. `sh scripts/install.sh` rather -# than `curl ... | sh`) it reuses that checkout in place and skips the -# clone/worktree setup, leaving the working tree untouched and linking `dsh` -# straight at that checkout's `bin/dsh` (no `current` indirection — the checkout -# is not a managed staging worktree under the source container); DSH_REF is -# ignored in that mode. Setting DSH_SOURCE to a different directory opts back -# into the normal clone/worktree path. +# than `curl ... | sh`) it never clones and never touches that working tree; +# DSH_REF is ignored. Instead it *adopts* the checkout: `git rev-parse +# --git-common-dir` resolves the repository behind it (for a linked worktree that +# is the real clone, not the worktree), and a fresh staging worktree branched +# from the checkout's HEAD lands in the source container beside `current`. The +# container owns staging worktrees and `current`; the clone is discovered, not +# owned, so an arbitrary clone (~/src/dsh) and a managed one converge on one +# layout and stay upgradable. Adoption carries committed work only — uncommitted +# changes stay in the checkout — so a dirty tree is confirmed first. +# +# Declining adoption (or DSH_ADOPT=0) keeps the legacy behavior: link `dsh` +# straight at that checkout's `bin/dsh` with no `current` indirection. That +# leaves the install unupgradable (`current` is what an upgrade repoints) and the +# PATH symlink dangling if the checkout moves, but it is what makes this script +# testable against local source. Setting DSH_SOURCE to a different directory opts +# back into the normal clone/worktree path. +# +# Adopting an arbitrary clone leaves the container not self-contained: its +# staging worktrees hold an absolute gitdir pointer into that clone, so deleting +# it breaks them. $DSH_SOURCE/master.path records the resolved clone so the +# breakage is diagnosable. # # When run through `curl | sh` the script text arrives on stdin, so every # prompt and the final launch read the controlling terminal (/dev/tty) directly; @@ -37,6 +52,8 @@ # DSH_CURRENT stable symlink to the active worktree (default: $DSH_SOURCE/current) # DSH_BIN_DIR directory the `dsh` symlink lands in (default: ~/.local/bin) # DSH_HOME Harness home holding the personal config (default: ~/.dsh) +# DSH_ADOPT in-repo mode: 1 adopts the checkout into the managed +# layout, 0 links `dsh` straight at it (default: ask, adopt) # FIXME(install-ts): Move the post-checkout workflow into a tested TypeScript # entrypoint; keep this POSIX shell file as the curl/source bootstrap. set -eu @@ -60,26 +77,43 @@ DSH_STAMP=$(date -u +%Y%m%dT%H%M%SZ) DSH_STAGING_BRANCH=dsh-staging/$DSH_STAMP DSH_STAGING=$DSH_SOURCE/staging-$DSH_STAMP +# --- path helpers --------------------------------------------------------------- +# Every path comparison below runs on physical paths. macOS resolves /var through +# a symlink to /private/var, so comparing a git-reported (already resolved) path +# against an unresolved one silently misclassifies an existing managed install as +# a foreign clone and builds a second container beside the real one. +# `git rev-parse --path-format=absolute` would do this, but it needs git 2.31+. +# +# A not-yet-created directory (the container on a fresh install) has no physical +# path, so fall back to the literal argument here rather than at each call site: +# `x=$(cmd) || fallback` never fires, because the assignment succeeds even when +# the substitution fails, which would silently yield an empty path. +resolve_dir() { CDPATH= cd -- "$1" 2>/dev/null && pwd -P || printf '%s\n' "$1"; } + # --- in-repo detection --------------------------------------------------------- # Under `curl ... | sh` the script text arrives on stdin, so $0 is the shell # name and no file path resolves; running a checked-out copy (`sh # scripts/install.sh`) makes $0 the script file. When $0 is a readable file whose # parent is a scripts/ dir inside a real dsh checkout (bin/dsh launcher present), -# reuse that checkout in place — link `dsh` straight at it and skip the -# clone/worktree setup. An explicit DSH_SOURCE pointing elsewhere opts back into -# the clone/worktree path. +# this is in-repo mode: never clone, never touch that working tree. An explicit +# DSH_SOURCE pointing elsewhere opts back into the clone/worktree path, unless +# DSH_ADOPT=1 asks to adopt this checkout into that container — otherwise naming +# a container while requesting adoption would silently clone a different tree. IN_REPO=0 +DSH_CHECKOUT='' if [ -f "$0" ]; then - _self_dir=$(CDPATH= cd -- "$(dirname -- "$0")" 2>/dev/null && pwd -P) || _self_dir='' + _self_dir=$(resolve_dir "$(dirname -- "$0")") if [ -n "$_self_dir" ]; then _repo_root=$(dirname -- "$_self_dir") if [ "$(basename -- "$_self_dir")" = scripts ] \ && [ -x "$_repo_root/bin/dsh" ] && [ -f "$_repo_root/scripts/install.sh" ]; then - if [ "$DSH_SOURCE_EXPLICIT" = 0 ] || [ "$DSH_SOURCE" = "$_repo_root" ]; then + # Compare the explicit DSH_SOURCE physically: an unresolved but equivalent + # path must still count as "the caller meant this checkout". + _src_resolved=$(resolve_dir "$DSH_SOURCE") + if [ "$DSH_SOURCE_EXPLICIT" = 0 ] || [ "$_src_resolved" = "$_repo_root" ] \ + || [ "${DSH_ADOPT:-}" = 1 ]; then IN_REPO=1 - # In-repo reuse links `dsh` at this checkout as-is; the master/staging - # split applies only to fresh clone installs. - DSH_STAGING=$_repo_root + DSH_CHECKOUT=$_repo_root fi fi fi @@ -148,7 +182,7 @@ confirm() { printf '%s\n' "${B}DeepSeek Harness — dsh installer${RST}" if [ "$IN_REPO" = 1 ]; then - printf '%ssource %s (in-repo reuse) @ %s%s\n' "$DIM" "$DSH_STAGING" "$DSH_REF" "$RST" + printf '%scheckout %s%s\n' "$DIM" "$DSH_CHECKOUT" "$RST" else printf '%smaster %s @ %s%s\n' "$DIM" "$DSH_MASTER" "$DSH_REF" "$RST" printf '%sstaging %s%s\n' "$DIM" "$DSH_STAGING" "$RST" @@ -203,41 +237,125 @@ else fi fi -# --- 2. clone the master and lay out the staging worktree --------------------- -# Fresh installs keep one real clone at $DSH_MASTER and check the running code -# out as a git worktree at $DSH_STAGING, so every checkout lives under -# $DSH_SOURCE and shares one object store. In-repo reuse links `dsh` at the -# existing checkout untouched. +# --- 2. resolve the repository and lay out the staging worktree --------------- +# The source container owns staging worktrees and `current`; the repository is +# *discovered*, not owned. A curl install discovers it by cloning to $DSH_MASTER; +# in-repo adoption discovers it from the checkout. Both then run one shared +# worktree/exclude/lock path, so an arbitrary clone and a managed install +# converge on the same layout. +# +# ADOPT=1 means "build the managed layout" (clone install, or in-repo adoption); +# ADOPT=0 is in-repo legacy reuse, which links `dsh` at the checkout as-is. +ADOPT=1 +# REPO_COMMON is the shared git directory every worktree of the repository +# points at; REPO_ROOT is the working tree that owns it (the master clone). +REPO_COMMON='' +REPO_ROOT='' + if [ "$IN_REPO" = 1 ]; then - step "Using existing checkout at $DSH_STAGING" - info "running from inside the repo — skipping clone (DSH_REF ignored, working tree left untouched)" + step "Using existing checkout at $DSH_CHECKOUT" + info "running from inside the repo — never cloning, and DSH_REF is ignored" + + # Resolve the repository behind the checkout. --git-common-dir returns the + # SHARED git dir, so a linked worktree resolves to the real clone rather than + # itself; it is relative for a plain clone, so anchor it before resolving. + # Require the resolved git dir to exist: resolve_dir echoes its argument back + # for a missing path, so test the directory rather than the returned string. + if _common=$(git -C "$DSH_CHECKOUT" rev-parse --git-common-dir 2>/dev/null) && [ -n "$_common" ]; then + case "$_common" in /*) ;; *) _common=$DSH_CHECKOUT/$_common ;; esac + [ -d "$_common" ] && REPO_COMMON=$(resolve_dir "$_common") + fi + [ -n "$REPO_COMMON" ] || die "$DSH_CHECKOUT is not a git repository — cannot adopt it. Re-run with DSH_ADOPT=0 to link dsh at it as-is." + REPO_ROOT=$(dirname -- "$REPO_COMMON") + + # A repository with no commit cannot be branched, so adoption is impossible. + if ! git -C "$DSH_CHECKOUT" rev-parse --verify -q HEAD >/dev/null 2>&1; then + warn "checkout has no commits — cannot create a staging branch; linking dsh at it as-is." + ADOPT=0 + fi + + # Explicit DSH_ADOPT wins over the prompt in both directions. + if [ "${DSH_ADOPT:-}" = 0 ]; then + ADOPT=0 + elif [ "$ADOPT" = 1 ]; then + # Adoption branches from HEAD, so uncommitted work stays behind in the + # checkout and is NOT part of the install that ends up running. Warn even + # when DSH_ADOPT=1 skips the prompt: the surprise is the same either way. + if [ -n "$(git -C "$DSH_CHECKOUT" status --porcelain 2>/dev/null)" ]; then + warn "checkout has uncommitted changes; adoption branches from HEAD, so they stay here and will not be in the running install." + fi + fi + if [ "$ADOPT" = 1 ] && [ "${DSH_ADOPT:-}" != 1 ]; then + printf '%s\n' "${DIM}Adopting builds the managed layout under $DSH_SOURCE (staging worktree + current symlink) so this install stays upgradable.${RST}" + printf '%s\n' "${DIM}Declining links dsh straight at this checkout: not upgradable, and the PATH symlink breaks if the checkout moves.${RST}" + confirm "Adopt this checkout into the managed layout?" Y || ADOPT=0 + fi + + if [ "$ADOPT" = 0 ]; then + info "linking dsh at this checkout as-is (legacy in-repo reuse)" + DSH_STAGING=$DSH_CHECKOUT + else + # Reuse the container when the repository already lives inside it (the + # normal managed install re-running its own script); otherwise treat that + # clone as its own master and keep worktrees in the default container. + _src_resolved=$(resolve_dir "$DSH_SOURCE") + case "$REPO_ROOT/" in + "$_src_resolved"/*) info "repository $REPO_ROOT is already inside $DSH_SOURCE" ;; + *) info "adopting clone $REPO_ROOT as its own master" ;; + esac + DSH_MASTER=$REPO_ROOT + fi else -step "Fetching source into $DSH_MASTER" -if [ -d "$DSH_MASTER/.git" ]; then - info "existing master clone found — updating" - git -C "$DSH_MASTER" fetch origin "$DSH_REF" - # Reset the master checkout to the freshly fetched tip. FETCH_HEAD (not - # origin/) so this resolves for a tag as well as a branch, and -B makes - # the re-run idempotent whether or not DSH_REF changed since the last install. - git -C "$DSH_MASTER" checkout -q -B "$DSH_REF" FETCH_HEAD -else - mkdir -p "$DSH_SOURCE" - git clone --branch "$DSH_REF" "$DSH_REPO" "$DSH_MASTER" + step "Fetching source into $DSH_MASTER" + if [ -d "$DSH_MASTER/.git" ]; then + info "existing master clone found — updating" + git -C "$DSH_MASTER" fetch origin "$DSH_REF" + # Reset the master checkout to the freshly fetched tip. FETCH_HEAD (not + # origin/) so this resolves for a tag as well as a branch, and -B makes + # the re-run idempotent whether or not DSH_REF changed since the last install. + git -C "$DSH_MASTER" checkout -q -B "$DSH_REF" FETCH_HEAD + else + mkdir -p "$DSH_SOURCE" + git clone --branch "$DSH_REF" "$DSH_REPO" "$DSH_MASTER" + fi + REPO_COMMON=$DSH_MASTER/.git + # Physical, to match the adoption branch: every REPO_ROOT comparison below + # runs against resolved paths. + REPO_ROOT=$(resolve_dir "$DSH_MASTER") fi -step "Adding staging worktree at $DSH_STAGING" -[ -e "$DSH_STAGING" ] && die "staging path $DSH_STAGING already exists — remove it or set DSH_SOURCE elsewhere, then re-run." -# The staging worktree owns the branch dsh runs from; the master clone stays on -# $DSH_REF as the fetch/upgrade base. Exclude the per-worktree merge lock in the -# master clone's info/exclude, which every linked worktree inherits. -git -C "$DSH_MASTER" worktree add -b "$DSH_STAGING_BRANCH" "$DSH_STAGING" FETCH_HEAD 2>/dev/null \ - || git -C "$DSH_MASTER" worktree add -b "$DSH_STAGING_BRANCH" "$DSH_STAGING" HEAD -_exclude="$DSH_MASTER/.git/info/exclude" -if [ -f "$_exclude" ] && ! grep -qxF '.agents/merge.lock' "$_exclude" 2>/dev/null; then - printf '.agents/merge.lock\n' >>"$_exclude" -fi -mkdir -p "$DSH_STAGING/.agents" -: >"$DSH_STAGING/.agents/merge.lock" +if [ "$ADOPT" = 1 ]; then + step "Adding staging worktree at $DSH_STAGING" + [ -e "$DSH_STAGING" ] && die "staging path $DSH_STAGING already exists — remove it or set DSH_SOURCE elsewhere, then re-run." + mkdir -p "$DSH_SOURCE" + # The staging worktree owns the branch dsh runs from; the repository stays as + # the fetch/upgrade base and is never a launcher target. A clone install + # branches from the ref it just fetched; adoption branches from the checkout's + # HEAD so the contributor's committed work is what runs. + if [ "$IN_REPO" = 1 ]; then + git -C "$DSH_CHECKOUT" worktree add -b "$DSH_STAGING_BRANCH" "$DSH_STAGING" HEAD + else + git -C "$DSH_MASTER" worktree add -b "$DSH_STAGING_BRANCH" "$DSH_STAGING" FETCH_HEAD 2>/dev/null \ + || git -C "$DSH_MASTER" worktree add -b "$DSH_STAGING_BRANCH" "$DSH_STAGING" HEAD + fi + # Exclude the per-worktree merge lock in the shared git dir's info/exclude, + # which every linked worktree inherits. + _exclude="$REPO_COMMON/info/exclude" + if [ -f "$_exclude" ] && ! grep -qxF '.agents/merge.lock' "$_exclude" 2>/dev/null; then + printf '.agents/merge.lock\n' >>"$_exclude" + fi + mkdir -p "$DSH_STAGING/.agents" + : >"$DSH_STAGING/.agents/merge.lock" + # A staging worktree holds an absolute gitdir pointer into the repository, so + # a container whose repository lives OUTSIDE it is not self-contained: deleting + # that repository breaks every worktree here. Record it only in that case, so + # the file's presence itself means "this container depends on an outside path". + _src_resolved=$(resolve_dir "$DSH_SOURCE") + case "$REPO_ROOT/" in + "$_src_resolved"/*) ;; + *) printf '%s\n' "$REPO_ROOT" >"$DSH_SOURCE/master.path" + info "recorded external repository in $DSH_SOURCE/master.path" ;; + esac fi # --- 3. install dependencies (no build; the launcher runs from source) -------- @@ -247,16 +365,17 @@ step "Installing dependencies with pnpm (this can take a while)" [ -x "$DSH_STAGING/bin/dsh" ] || die "launcher $DSH_STAGING/bin/dsh missing after install — is DSH_REF a branch that ships apps/cli?" # --- 4. put `dsh` on PATH ------------------------------------------------------ -# Clone installs go through a stable `current` symlink so an upgrade repoints +# Managed installs go through a stable `current` symlink so an upgrade repoints # one symlink (current -> new worktree) and the PATH launcher never moves: -# PATH/dsh -> current/bin/dsh -> /bin/dsh. In-repo reuse links PATH +# PATH/dsh -> current/bin/dsh -> /bin/dsh. Declined adoption links PATH # straight at the checkout, since that checkout is not a managed worktree. step "Linking dsh into $DSH_BIN_DIR" mkdir -p "$DSH_BIN_DIR" -if [ "$IN_REPO" = 1 ]; then +if [ "$ADOPT" = 0 ]; then DSH_LAUNCH_TARGET=$DSH_STAGING/bin/dsh ln -sf "$DSH_LAUNCH_TARGET" "$DSH_BIN_DIR/dsh" info "linked $DSH_BIN_DIR/dsh -> $DSH_LAUNCH_TARGET" + warn "this install is not upgradable (no current symlink) and the PATH link breaks if $DSH_STAGING moves." else # Point `current` at this staging worktree with `ln -sfn`: -f replaces an # existing `current` (re-run or upgrade) and -n stops `ln` from dereferencing @@ -264,6 +383,13 @@ else # worktree. `mv` is unusable here — BSD/macOS `mv` follows the existing dir # symlink the same way. The swap is one unlink+symlink pair on a local fs; the # installer holds no other process racing this path. + # The launcher must resolve to a staging worktree, never to the repository + # itself: an upgrade repoints `current`, so aliasing it onto the master clone + # would make every upgrade rewrite the fetch/upgrade base. Compare physical + # paths — a symlinked or unresolved path would slip past a string compare. + _staging_resolved=$(resolve_dir "$DSH_STAGING") + [ -n "$REPO_ROOT" ] && [ "$_staging_resolved" = "$REPO_ROOT" ] \ + && die "refusing to point $DSH_CURRENT at the repository $REPO_ROOT — the launcher must resolve to a staging worktree." ln -sfn "$DSH_STAGING" "$DSH_CURRENT" info "pointed $DSH_CURRENT -> $DSH_STAGING" DSH_LAUNCH_TARGET=$DSH_CURRENT/bin/dsh From 00c5f2abd1922a25d177941734c028790426ca6d Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 31 Jul 2026 20:31:30 +0800 Subject: [PATCH 02/19] refactor(install): always adopt, dropping the link-in-place path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retaining link-in-place behind a prompt and DSH_ADOPT kept the divergent install shape that this change exists to remove, and cost a flag, a prompt, a dirty-tree warning, a no-commit fallback, and a second linking path. In-repo mode now adopts unconditionally. A dirty tree adopts silently: `worktree add` from HEAD cannot carry uncommitted work, so a prompt only adds a decision the user cannot act on differently. The original reason for link-in-place — keeping the script testable against local source — survives adoption, since the staging worktree branches from the checkout's HEAD and runs the same code. DSH_SOURCE remains the escape hatch for installing a separate tree. Net 47 fewer lines in the installer. --- ...staller-adopts-existing-checkout.i18n.yaml | 4 +- ...7-31-installer-adopts-existing-checkout.md | 16 +- ...1-installer-adopts-existing-checkout.zh.md | 16 +- README.i18n.yaml | 4 +- README.md | 2 +- README.zh.md | 2 +- scripts/install.sh | 183 +++++++----------- 7 files changed, 90 insertions(+), 137 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml index 0aab25dae7..c4689a5f4c 100644 --- a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md -2026-07-31-installer-adopts-existing-checkout.md: 2513b31410d045469507b49176236bacb138ff1e -2026-07-31-installer-adopts-existing-checkout.zh.md: 3fca61e83730bf30bdb84c8832c2fd2163723858 +2026-07-31-installer-adopts-existing-checkout.md: 75f71d1dc7f7d84674c7f11ec1affe616acbd0b2 +2026-07-31-installer-adopts-existing-checkout.zh.md: 381a3c0f67aa20198caa25b558f6d6d1b5591413 diff --git a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md index 2513b31410..75f71d1dc7 100644 --- a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md +++ b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md @@ -12,15 +12,13 @@ The direct link is a terminal state. `current` is what an upgrade repoints, so a ## Decision -In-repo mode still never clones and never modifies the working tree, but it now offers to **adopt** the checkout into the managed layout, and adoption is the default. +In-repo mode still never clones and never modifies the working tree, but it now **adopts** the checkout into the managed layout unconditionally. There is no opt-out: one layout serves every install. The container owns staging worktrees and `current`; the repository is *discovered*, not owned. `git rev-parse --git-common-dir` resolves the shared git directory behind the checkout — for a linked worktree that is the real clone rather than the worktree itself — and its parent is the repository that serves as the upgrade base. A staging worktree branched from the checkout's `HEAD` is then created under `$DSH_SOURCE`, and `current` points at it. A clone anywhere on disk therefore converges on the same layout as a `curl` install, and the two paths share one worktree/exclude/lock/link sequence: they differ only in whether the repository was discovered by `git clone` or by `git rev-parse`. `$DSH_SOURCE/master.path` records the resolved repository, and only when that repository lives outside the container. A container holding its own master is self-contained and gets no file, so the file's presence is itself the signal that this container depends on an outside path: each staging worktree holds an absolute gitdir pointer into that clone, so deleting the clone breaks them. -Adoption branches from `HEAD`, so committed work is what runs and uncommitted changes stay in the checkout; a dirty tree is warned about before the prompt and whenever `DSH_ADOPT=1` skips it. Declining, or `DSH_ADOPT=0`, keeps the previous link-in-place behavior with a warning naming what it costs, because that path is what makes this script testable against local source. A repository with no commits cannot be branched and falls back to link-in-place; a checkout that is not a git repository fails with the `DSH_ADOPT=0` escape hatch named. - -`DSH_ADOPT=1` also overrides the rule that an explicit `DSH_SOURCE` opts back into cloning. Naming a container while asking for adoption otherwise silently cloned a different tree — the opposite of the request. +Adoption branches from `HEAD`, so committed work is what runs and uncommitted changes stay in the checkout. This is not prompted or warned about: the installer builds the layout and gets out of the way. Setting `DSH_SOURCE` to a different directory remains the one documented way to opt back into cloning a separate tree. Every path comparison runs on physical paths through a `resolve_dir` helper, and every compared value is resolved at assignment rather than at the comparison. macOS resolves `/var` through a symlink to `/private/var`, so comparing a git-reported path against an unresolved one misclassified an existing managed install as a foreign clone and would have built a second container beside the real one. The same defect recurred twice more during review — once where a curl install's `REPO_ROOT` stayed unresolved and wrote a spurious `master.path`, and once where `x=$(resolve_dir …) || x=$fallback` left an empty path because the assignment succeeds even when the substitution fails. `resolve_dir` therefore echoes a missing path back itself, and callers that need "does not exist" test the directory explicitly. `git rev-parse --path-format=absolute` would do the same job but requires git 2.31+. @@ -32,13 +30,17 @@ Before `current` is repointed, the installer rejects a staging path that resolve **Promote the checkout itself to the `current` target.** Rejected: the upgrade contract requires `current` to be a clean staging worktree on a staging branch, never a feature, review, or detached checkout. It would also make every upgrade rewrite the tree the contributor is editing. -**Keep adoption opt-in.** Rejected as the default: the divergent shape was the actual defect, and leaving the fix behind a flag means the common `sh scripts/install.sh` invocation keeps producing unupgradable installs. Declining is one keystroke and `DSH_ADOPT=0` is scriptable. +**Keep link-in-place behind a prompt or a `DSH_ADOPT` flag.** Rejected, and an earlier revision of this change shipped exactly that before it was removed. The divergent shape was the defect itself, so retaining it as an option preserves the problem and doubles the states every later change must reason about — the prompt, the flag, the dirty-tree warning, and a second linking path all existed only to keep a shape nothing should produce. The original motivation for link-in-place, keeping the script testable against local source, survives adoption: a staging worktree branched from the checkout's `HEAD` runs the same code. `DSH_SOURCE` remains the escape hatch for installing a separate tree. + +**Warn or prompt when the tree is dirty.** Rejected: `worktree add` from `HEAD` cannot carry uncommitted work, so the behavior is determined and a prompt only adds a decision the user cannot act on differently. The contract is documented instead. **Put an adopted clone's staging worktrees beside the clone** (`~/src/staging-*`) rather than in `~/.dsh/source`. Rejected: `current` and the PATH launcher are per-user singletons, so scattering worktrees across clone parents reintroduces the sibling-clone sprawl the source container exists to prevent. ## Consequences -One layout now serves both installs, so an adopted clone is upgradable by `dsh-upgrade` without the one-time migration that skill described. In-repo runs still never mutate the working tree, and the escape hatch that keeps this script testable against local source survives behind a prompt and `DSH_ADOPT=0`. +One layout now serves every install, so an adopted clone is upgradable by `dsh-upgrade` without the one-time migration that skill described, and the installer has no branch that produces an unupgradable shape. In-repo runs still never mutate the working tree. + +The cost is that a contributor can no longer point PATH at a checkout and have `dsh` follow that working tree as they switch branches: the launcher now resolves to a staging worktree pinned to the `HEAD` adopted at install time. Re-running the installer adopts the current `HEAD` again. The cost is that a container adopting an outside clone is no longer self-contained: deleting that clone breaks its staging worktrees. This is inherent to reusing an existing clone rather than a property of this design — the rejected symlink hides it rather than fixing it — and `master.path` is the mitigation, not a repair. @@ -46,4 +48,4 @@ The cost is that a container adopting an outside clone is no longer self-contain `scripts/install.sh` has no automated test, and this change does not add one: the user directed that `install.spec.ts` be left out of scope. That is a known gap on a shipped user-facing path, and the `/var` resolution defect above is exactly the class of bug a test would have caught first. The standing [`FIXME(install-ts)`](../../../../scripts/install.sh) asking for this workflow to move into a tested TypeScript entrypoint is correspondingly more pressing. -Verification was manual, through a throwaway harness driving the real script with a stubbed `pnpm`: adopting a standalone clone; adopting from a linked worktree into its existing container; `DSH_ADOPT=0` preserving link-in-place; a commitless repository falling back; a dirty tree warning while leaving uncommitted work behind; a non-git checkout failing with guidance; and a `curl`-style clone install asserting both the built layout and the absence of `master.path`, which is the regression that caught the unresolved-`REPO_ROOT` defect. Both interactive outcomes were exercised under tmux: accepting ends with the launcher running from the new staging worktree while the original checkout keeps its branch and clean status, and declining reproduces the legacy shape with no staging worktree and no `current`. +Verification was manual, through a throwaway harness driving the real script with a stubbed `pnpm`: adopting a standalone clone; adopting from a linked worktree into its existing container; an explicit `DSH_SOURCE` still opting back into cloning; a dirty tree adopting silently with no prompt or warning while its uncommitted file stays behind; a non-git checkout failing with guidance; and a `curl`-style clone install asserting both the built layout and the absence of `master.path`, which is the regression that caught the unresolved-`REPO_ROOT` defect. The interactive path was exercised under tmux from a dirty checkout, confirming the run reaches the launcher with no adoption prompt and ends with `dsh` running from the new staging worktree while the original checkout keeps its branch and its uncommitted file. diff --git a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md index 3fca61e837..381a3c0f67 100644 --- a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md +++ b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md @@ -12,15 +12,13 @@ Status: implemented ## Decision -检出内模式仍然绝不克隆、绝不修改工作树,但现在它会询问是否把该检出**接管**进受管布局,并且接管是默认选项。 +检出内模式仍然绝不克隆、绝不修改工作树,但现在它会无条件地把该检出**接管**进受管布局。不存在退出选项:一套布局服务于所有安装。 容器拥有 staging worktree 和`current`;仓库是被*发现*的,而非被拥有的。`git rev-parse --git-common-dir`会解析出该检出背后的共享 git 目录——对于 linked worktree,那是真正的克隆而非 worktree 自身——其父目录即是充当升级基础的仓库。随后以该检出的`HEAD`为起点,在`$DSH_SOURCE`下创建 staging worktree,并让`current`指向它。因此,磁盘上任意位置的克隆都会收敛到与`curl`安装相同的布局,且两条路径共用同一套 worktree/exclude/lock/link 流程:二者的唯一差别,只在于仓库是由`git clone`发现的,还是由`git rev-parse`发现的。 `$DSH_SOURCE/master.path`记录解析出的仓库,且仅在该仓库位于容器之外时才记录。拥有自身 master 的容器是自包含的,不会生成该文件;因此该文件的存在本身就是一个信号,表明此容器依赖于外部路径:每个 staging worktree 都持有指向该克隆的绝对 gitdir 指针,删除该克隆就会破坏它们。 -接管以`HEAD`为分支起点,因此运行的是已提交的内容,未提交的更改仍留在检出中;工作树不干净时,会在提示前发出警告,`DSH_ADOPT=1`跳过提示时同样警告。拒绝接管或设置`DSH_ADOPT=0`将保留原有的就地链接行为,并以警告说明其代价,因为正是这条路径使本脚本能针对本地源码进行测试。没有任何提交的仓库无法创建分支,会回退到就地链接;并非 git 仓库的检出则会失败,并在错误信息中给出`DSH_ADOPT=0`这一退路。 - -`DSH_ADOPT=1`同时会覆盖"显式`DSH_SOURCE`即回到克隆路径"的规则。否则,在请求接管的同时指定容器,反而会静默克隆另一棵树——与请求恰好相反。 +接管以`HEAD`为分支起点,因此运行的是已提交的内容,未提交的更改仍留在检出中。这一点既不提示也不警告:安装器构建好布局后便不再打扰。把`DSH_SOURCE`设为其他目录,仍是唯一有文档记载的、回到克隆另一棵树的方式。 所有路径比较都通过`resolve_dir`辅助函数在物理路径上进行,且每个参与比较的值都在赋值时解析,而非在比较时解析。macOS 会把`/var`经符号链接解析为`/private/var`,因此拿 git 报告的路径与未解析的路径相比较,会把已有的受管安装误判为外来克隆,并在真正的容器旁再建一个容器。同一缺陷在评审过程中又出现了两次——一次是 curl 安装的`REPO_ROOT`未经解析,导致写出多余的`master.path`;另一次是`x=$(resolve_dir …) || x=$fallback`留下了空路径,因为即使命令替换失败,赋值本身仍然成功。因此`resolve_dir`会在路径不存在时原样回显该路径,而需要判断"不存在"的调用方则显式检测该目录。`git rev-parse --path-format=absolute`能完成同样的工作,但要求 git 2.31 及以上版本。 @@ -32,13 +30,17 @@ Status: implemented **把检出自身提升为`current`的目标。** 已否决:升级契约要求`current`必须是位于 staging 分支上的干净 staging worktree,绝不能是 feature、review 或 detached 检出。这还会使每次升级都改写贡献者正在编辑的那棵树。 -**让接管保持为可选项。** 作为默认行为已否决:分叉的形态本身才是真正的缺陷,把修复藏在开关之后,意味着常见的`sh scripts/install.sh`调用仍会产生无法升级的安装。拒绝只需一次按键,而`DSH_ADOPT=0`可用于脚本。 +**把就地链接保留在提示或`DSH_ADOPT`开关之后。** 已否决;本次变更的早期修订版本正是如此实现,之后被移除。分叉的形态本身就是缺陷,因此把它保留为一个选项等于保留了问题,并使此后每次改动需要推敲的状态翻倍——提示、开关、工作树不干净的警告,以及第二条链接路径,全都只为维持一种本不该产生的形态而存在。就地链接最初的动机——让脚本能针对本地源码进行测试——在接管方案下依然成立:以检出的`HEAD`为起点创建的 staging worktree 运行的是同一份代码。`DSH_SOURCE`仍是安装另一棵树的退路。 + +**在工作树不干净时发出警告或提示。** 已否决:以`HEAD`为起点的`worktree add`本就无法带上未提交的内容,因此该行为是确定的,提示只会增加一个用户无法做出不同选择的决策点。改为在文档中说明该契约。 **把被接管克隆的 staging worktree 放在该克隆旁边**(`~/src/staging-*`),而非放进`~/.dsh/source`。已否决:`current`和 PATH 启动器都是每用户唯一的,因此把 worktree 散落到各个克隆的父目录中,会重新引入 source 容器本就为之而设、意在杜绝的同级克隆蔓延问题。 ## Consequences -现在一套布局同时服务于两种安装,因此被接管的克隆无需该技能所述的一次性迁移,即可由`dsh-upgrade`升级。检出内运行仍然绝不改动工作树,而使本脚本能针对本地源码进行测试的那条退路,也以提示和`DSH_ADOPT=0`的形式保留了下来。 +现在一套布局服务于所有安装,因此被接管的克隆无需该技能所述的一次性迁移,即可由`dsh-upgrade`升级,而且安装器不再有任何一条分支会产生无法升级的形态。检出内运行仍然绝不改动工作树。 + +代价是:贡献者不能再把 PATH 指向某个检出、并让`dsh`随其切换分支而跟随该工作树;启动器现在解析到的是一个固定在安装时所接管`HEAD`上的 staging worktree。重新运行安装器会再次接管当前的`HEAD`。 代价是:接管外部克隆的容器不再自包含——删除该克隆会破坏其 staging worktree。这是复用已有克隆的固有属性,而非本设计带来的性质——被否决的符号链接方案只是掩盖它,而非修复它——`master.path`是缓解措施,不是修复。 @@ -46,4 +48,4 @@ Status: implemented `scripts/install.sh`没有自动化测试,本次变更也未添加:用户明确要求把`install.spec.ts`排除在范围之外。这是一条已交付的、面向用户的安装路径上的已知缺口,而上文那个`/var`解析缺陷,恰恰属于测试本应最先捕获的那类 bug。相应地,要求把这套流程迁移到有测试覆盖的 TypeScript 入口的既有[`FIXME(install-ts)`](../../../../scripts/install.sh)也变得更为紧迫。 -验证是手工完成的,通过一个一次性测试装置以打桩的`pnpm`驱动真实脚本:接管独立克隆;从 linked worktree 接管进其已有容器;`DSH_ADOPT=0`保持就地链接;无提交的仓库发生回退;工作树不干净时发出警告并把未提交内容留在原处;非 git 检出失败并给出指引;以及`curl`式克隆安装同时断言所构建的布局和`master.path`的缺失——正是这项回归测试捕获了`REPO_ROOT`未解析的缺陷。两种交互结果都在 tmux 下走通:接受时,启动器最终从新的 staging worktree 运行,而原检出保持其分支不变且状态干净;拒绝时,则复现旧式形态,既无 staging worktree 也无`current`。 +验证是手工完成的,通过一个一次性测试装置以打桩的`pnpm`驱动真实脚本:接管独立克隆;从 linked worktree 接管进其已有容器;显式`DSH_SOURCE`仍回到克隆路径;工作树不干净时静默接管、既不提示也不警告,且其未提交文件留在原处;非 git 检出失败并给出指引;以及`curl`式克隆安装同时断言所构建的布局和`master.path`的缺失——正是这项回归测试捕获了`REPO_ROOT`未解析的缺陷。交互路径在 tmux 下从一个不干净的检出走通,确认整个过程不出现接管提示即可到达启动器,最终`dsh`从新的 staging worktree 运行,而原检出保持其分支不变、未提交文件仍在。 diff --git a/README.i18n.yaml b/README.i18n.yaml index 07b5f5c2b2..cb06123784 100644 --- a/README.i18n.yaml +++ b/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 README.md -README.md: 6266e9087e6cc6f53e61127559e166064cbd3970 -README.zh.md: 1343c099161b660d9d23d101a92deca1d35e441b +README.md: f7c563bb9ade47890dbc24b23af67ef663dc92fd +README.zh.md: 49c4acca0853346d95ab9de949dc26b11552ddcd diff --git a/README.md b/README.md index 6266e9087e..f7c563bb9a 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ The installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` The installer keeps every checkout under `~/.dsh/source`: the master clone at `~/.dsh/source/master` and each install's staging checkout as a git worktree `~/.dsh/source/staging-`. The stable symlink `~/.dsh/source/current` points at the active staging worktree, and `dsh` in `~/.local/bin` links to `current/bin/dsh`, so an upgrade repoints one symlink and the `dsh` on PATH never moves. Re-running the command adds a fresh staging worktree from an updated master and repoints `current` at it. See [`scripts/install.sh`](scripts/install.sh) for alternate install locations and other options. -Running the script from an existing clone (`sh scripts/install.sh`) never clones and never modifies that working tree. It offers to *adopt* the clone: the repository behind the checkout becomes the upgrade base, and a staging worktree branched from the checkout's current `HEAD` lands under `~/.dsh/source` with `current` pointing at it, so a clone anywhere on disk gets the same upgradable layout. Adoption carries committed work only — uncommitted changes stay in the clone. Declining (or `DSH_ADOPT=0`) links `dsh` straight at that checkout instead, which is not upgradable and breaks if the checkout moves. +Running the script from an existing clone (`sh scripts/install.sh`) never clones and never modifies that working tree. It *adopts* the clone: the repository behind the checkout becomes the upgrade base, and a staging worktree branched from the checkout's current `HEAD` lands under `~/.dsh/source` with `current` pointing at it, so a clone anywhere on disk gets the same upgradable layout. Adoption carries committed work only — uncommitted changes stay in the clone. ## Use DeepSeek Harness diff --git a/README.zh.md b/README.zh.md index 1343c09916..49c4acca08 100644 --- a/README.zh.md +++ b/README.zh.md @@ -26,7 +26,7 @@ curl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/m 安装器会把所有检出都放在 `~/.dsh/source` 下:master 克隆位于 `~/.dsh/source/master`,每次安装的 staging 检出是一个 git worktree `~/.dsh/source/staging-<时间戳>`。稳定符号链接 `~/.dsh/source/current` 指向当前生效的 staging worktree,`~/.local/bin` 中的 `dsh` 链接到 `current/bin/dsh`,因此升级只需重指一个符号链接,PATH 上的 `dsh` 从不移动。再次运行该命令会基于更新后的 master 新增一个 staging worktree,并把 `current` 重指到它。其他安装位置和选项见 [`scripts/install.sh`](scripts/install.sh)。 -从现有克隆中运行该脚本(`sh scripts/install.sh`)不会进行任何克隆,也不会修改该工作树。它会询问是否*接管*该克隆:该检出所属的仓库将成为升级基础,以该检出当前的 `HEAD` 为起点创建的 staging worktree 会被放在 `~/.dsh/source` 下,并由 `current` 指向它,因此磁盘上任意位置的克隆都能获得相同的可升级布局。接管只会带入已提交的内容——未提交的更改仍留在克隆中。如果拒绝接管(或设置 `DSH_ADOPT=0`),则会改为将 `dsh` 直接链接到该检出;这种方式无法升级,且检出一旦移动,链接就会失效。 +从现有克隆中运行该脚本(`sh scripts/install.sh`)不会进行任何克隆,也不会修改该工作树。它会*接管*该克隆:该检出所属的仓库将成为升级基础,以该检出当前的 `HEAD` 为起点创建的 staging worktree 会被放在 `~/.dsh/source` 下,并由 `current` 指向它,因此磁盘上任意位置的克隆都能获得相同的可升级布局。接管只会带入已提交的内容——未提交的更改仍留在克隆中。 ## 使用 DeepSeek Harness diff --git a/scripts/install.sh b/scripts/install.sh index 5c38f8974c..b26beccf2c 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -25,15 +25,10 @@ # from the checkout's HEAD lands in the source container beside `current`. The # container owns staging worktrees and `current`; the clone is discovered, not # owned, so an arbitrary clone (~/src/dsh) and a managed one converge on one -# layout and stay upgradable. Adoption carries committed work only — uncommitted -# changes stay in the checkout — so a dirty tree is confirmed first. -# -# Declining adoption (or DSH_ADOPT=0) keeps the legacy behavior: link `dsh` -# straight at that checkout's `bin/dsh` with no `current` indirection. That -# leaves the install unupgradable (`current` is what an upgrade repoints) and the -# PATH symlink dangling if the checkout moves, but it is what makes this script -# testable against local source. Setting DSH_SOURCE to a different directory opts -# back into the normal clone/worktree path. +# layout and stay upgradable. Adoption carries committed work only: the staging +# worktree branches from HEAD, so uncommitted changes stay in the checkout. +# Setting DSH_SOURCE to a different directory opts back into the normal +# clone/worktree path. # # Adopting an arbitrary clone leaves the container not self-contained: its # staging worktrees hold an absolute gitdir pointer into that clone, so deleting @@ -52,8 +47,6 @@ # DSH_CURRENT stable symlink to the active worktree (default: $DSH_SOURCE/current) # DSH_BIN_DIR directory the `dsh` symlink lands in (default: ~/.local/bin) # DSH_HOME Harness home holding the personal config (default: ~/.dsh) -# DSH_ADOPT in-repo mode: 1 adopts the checkout into the managed -# layout, 0 links `dsh` straight at it (default: ask, adopt) # FIXME(install-ts): Move the post-checkout workflow into a tested TypeScript # entrypoint; keep this POSIX shell file as the curl/source bootstrap. set -eu @@ -96,9 +89,7 @@ resolve_dir() { CDPATH= cd -- "$1" 2>/dev/null && pwd -P || printf '%s\n' "$1"; # scripts/install.sh`) makes $0 the script file. When $0 is a readable file whose # parent is a scripts/ dir inside a real dsh checkout (bin/dsh launcher present), # this is in-repo mode: never clone, never touch that working tree. An explicit -# DSH_SOURCE pointing elsewhere opts back into the clone/worktree path, unless -# DSH_ADOPT=1 asks to adopt this checkout into that container — otherwise naming -# a container while requesting adoption would silently clone a different tree. +# DSH_SOURCE pointing elsewhere opts back into the clone/worktree path. IN_REPO=0 DSH_CHECKOUT='' if [ -f "$0" ]; then @@ -110,8 +101,7 @@ if [ -f "$0" ]; then # Compare the explicit DSH_SOURCE physically: an unresolved but equivalent # path must still count as "the caller meant this checkout". _src_resolved=$(resolve_dir "$DSH_SOURCE") - if [ "$DSH_SOURCE_EXPLICIT" = 0 ] || [ "$_src_resolved" = "$_repo_root" ] \ - || [ "${DSH_ADOPT:-}" = 1 ]; then + if [ "$DSH_SOURCE_EXPLICIT" = 0 ] || [ "$_src_resolved" = "$_repo_root" ]; then IN_REPO=1 DSH_CHECKOUT=$_repo_root fi @@ -244,9 +234,6 @@ fi # worktree/exclude/lock path, so an arbitrary clone and a managed install # converge on the same layout. # -# ADOPT=1 means "build the managed layout" (clone install, or in-repo adoption); -# ADOPT=0 is in-repo legacy reuse, which links `dsh` at the checkout as-is. -ADOPT=1 # REPO_COMMON is the shared git directory every worktree of the repository # points at; REPO_ROOT is the working tree that owns it (the master clone). REPO_COMMON='' @@ -265,46 +252,18 @@ if [ "$IN_REPO" = 1 ]; then case "$_common" in /*) ;; *) _common=$DSH_CHECKOUT/$_common ;; esac [ -d "$_common" ] && REPO_COMMON=$(resolve_dir "$_common") fi - [ -n "$REPO_COMMON" ] || die "$DSH_CHECKOUT is not a git repository — cannot adopt it. Re-run with DSH_ADOPT=0 to link dsh at it as-is." + [ -n "$REPO_COMMON" ] || die "$DSH_CHECKOUT is not a git repository — cannot adopt it." REPO_ROOT=$(dirname -- "$REPO_COMMON") - # A repository with no commit cannot be branched, so adoption is impossible. - if ! git -C "$DSH_CHECKOUT" rev-parse --verify -q HEAD >/dev/null 2>&1; then - warn "checkout has no commits — cannot create a staging branch; linking dsh at it as-is." - ADOPT=0 - fi - - # Explicit DSH_ADOPT wins over the prompt in both directions. - if [ "${DSH_ADOPT:-}" = 0 ]; then - ADOPT=0 - elif [ "$ADOPT" = 1 ]; then - # Adoption branches from HEAD, so uncommitted work stays behind in the - # checkout and is NOT part of the install that ends up running. Warn even - # when DSH_ADOPT=1 skips the prompt: the surprise is the same either way. - if [ -n "$(git -C "$DSH_CHECKOUT" status --porcelain 2>/dev/null)" ]; then - warn "checkout has uncommitted changes; adoption branches from HEAD, so they stay here and will not be in the running install." - fi - fi - if [ "$ADOPT" = 1 ] && [ "${DSH_ADOPT:-}" != 1 ]; then - printf '%s\n' "${DIM}Adopting builds the managed layout under $DSH_SOURCE (staging worktree + current symlink) so this install stays upgradable.${RST}" - printf '%s\n' "${DIM}Declining links dsh straight at this checkout: not upgradable, and the PATH symlink breaks if the checkout moves.${RST}" - confirm "Adopt this checkout into the managed layout?" Y || ADOPT=0 - fi - - if [ "$ADOPT" = 0 ]; then - info "linking dsh at this checkout as-is (legacy in-repo reuse)" - DSH_STAGING=$DSH_CHECKOUT - else - # Reuse the container when the repository already lives inside it (the - # normal managed install re-running its own script); otherwise treat that - # clone as its own master and keep worktrees in the default container. - _src_resolved=$(resolve_dir "$DSH_SOURCE") - case "$REPO_ROOT/" in - "$_src_resolved"/*) info "repository $REPO_ROOT is already inside $DSH_SOURCE" ;; - *) info "adopting clone $REPO_ROOT as its own master" ;; - esac - DSH_MASTER=$REPO_ROOT - fi + # Reuse the container when the repository already lives inside it (the normal + # managed install re-running its own script); otherwise treat that clone as + # its own master and keep worktrees in the default container. + _src_resolved=$(resolve_dir "$DSH_SOURCE") + case "$REPO_ROOT/" in + "$_src_resolved"/*) info "repository $REPO_ROOT is already inside $DSH_SOURCE" ;; + *) info "adopting clone $REPO_ROOT as its own master" ;; + esac + DSH_MASTER=$REPO_ROOT else step "Fetching source into $DSH_MASTER" if [ -d "$DSH_MASTER/.git" ]; then @@ -324,39 +283,37 @@ else REPO_ROOT=$(resolve_dir "$DSH_MASTER") fi -if [ "$ADOPT" = 1 ]; then - step "Adding staging worktree at $DSH_STAGING" - [ -e "$DSH_STAGING" ] && die "staging path $DSH_STAGING already exists — remove it or set DSH_SOURCE elsewhere, then re-run." - mkdir -p "$DSH_SOURCE" - # The staging worktree owns the branch dsh runs from; the repository stays as - # the fetch/upgrade base and is never a launcher target. A clone install - # branches from the ref it just fetched; adoption branches from the checkout's - # HEAD so the contributor's committed work is what runs. - if [ "$IN_REPO" = 1 ]; then - git -C "$DSH_CHECKOUT" worktree add -b "$DSH_STAGING_BRANCH" "$DSH_STAGING" HEAD - else - git -C "$DSH_MASTER" worktree add -b "$DSH_STAGING_BRANCH" "$DSH_STAGING" FETCH_HEAD 2>/dev/null \ - || git -C "$DSH_MASTER" worktree add -b "$DSH_STAGING_BRANCH" "$DSH_STAGING" HEAD - fi - # Exclude the per-worktree merge lock in the shared git dir's info/exclude, - # which every linked worktree inherits. - _exclude="$REPO_COMMON/info/exclude" - if [ -f "$_exclude" ] && ! grep -qxF '.agents/merge.lock' "$_exclude" 2>/dev/null; then - printf '.agents/merge.lock\n' >>"$_exclude" - fi - mkdir -p "$DSH_STAGING/.agents" - : >"$DSH_STAGING/.agents/merge.lock" - # A staging worktree holds an absolute gitdir pointer into the repository, so - # a container whose repository lives OUTSIDE it is not self-contained: deleting - # that repository breaks every worktree here. Record it only in that case, so - # the file's presence itself means "this container depends on an outside path". - _src_resolved=$(resolve_dir "$DSH_SOURCE") - case "$REPO_ROOT/" in - "$_src_resolved"/*) ;; - *) printf '%s\n' "$REPO_ROOT" >"$DSH_SOURCE/master.path" - info "recorded external repository in $DSH_SOURCE/master.path" ;; - esac +step "Adding staging worktree at $DSH_STAGING" +[ -e "$DSH_STAGING" ] && die "staging path $DSH_STAGING already exists — remove it or set DSH_SOURCE elsewhere, then re-run." +mkdir -p "$DSH_SOURCE" +# The staging worktree owns the branch dsh runs from; the repository stays as +# the fetch/upgrade base and is never a launcher target. A clone install +# branches from the ref it just fetched; adoption branches from the checkout's +# HEAD so the contributor's committed work is what runs. +if [ "$IN_REPO" = 1 ]; then + git -C "$DSH_CHECKOUT" worktree add -b "$DSH_STAGING_BRANCH" "$DSH_STAGING" HEAD +else + git -C "$DSH_MASTER" worktree add -b "$DSH_STAGING_BRANCH" "$DSH_STAGING" FETCH_HEAD 2>/dev/null \ + || git -C "$DSH_MASTER" worktree add -b "$DSH_STAGING_BRANCH" "$DSH_STAGING" HEAD fi +# Exclude the per-worktree merge lock in the shared git dir's info/exclude, +# which every linked worktree inherits. +_exclude="$REPO_COMMON/info/exclude" +if [ -f "$_exclude" ] && ! grep -qxF '.agents/merge.lock' "$_exclude" 2>/dev/null; then + printf '.agents/merge.lock\n' >>"$_exclude" +fi +mkdir -p "$DSH_STAGING/.agents" +: >"$DSH_STAGING/.agents/merge.lock" +# A staging worktree holds an absolute gitdir pointer into the repository, so +# a container whose repository lives OUTSIDE it is not self-contained: deleting +# that repository breaks every worktree here. Record it only in that case, so +# the file's presence itself means "this container depends on an outside path". +_src_resolved=$(resolve_dir "$DSH_SOURCE") +case "$REPO_ROOT/" in + "$_src_resolved"/*) ;; + *) printf '%s\n' "$REPO_ROOT" >"$DSH_SOURCE/master.path" + info "recorded external repository in $DSH_SOURCE/master.path" ;; +esac # --- 3. install dependencies (no build; the launcher runs from source) -------- step "Installing dependencies with pnpm (this can take a while)" @@ -365,37 +322,29 @@ step "Installing dependencies with pnpm (this can take a while)" [ -x "$DSH_STAGING/bin/dsh" ] || die "launcher $DSH_STAGING/bin/dsh missing after install — is DSH_REF a branch that ships apps/cli?" # --- 4. put `dsh` on PATH ------------------------------------------------------ -# Managed installs go through a stable `current` symlink so an upgrade repoints +# Every install goes through a stable `current` symlink so an upgrade repoints # one symlink (current -> new worktree) and the PATH launcher never moves: -# PATH/dsh -> current/bin/dsh -> /bin/dsh. Declined adoption links PATH -# straight at the checkout, since that checkout is not a managed worktree. +# PATH/dsh -> current/bin/dsh -> /bin/dsh. step "Linking dsh into $DSH_BIN_DIR" mkdir -p "$DSH_BIN_DIR" -if [ "$ADOPT" = 0 ]; then - DSH_LAUNCH_TARGET=$DSH_STAGING/bin/dsh - ln -sf "$DSH_LAUNCH_TARGET" "$DSH_BIN_DIR/dsh" - info "linked $DSH_BIN_DIR/dsh -> $DSH_LAUNCH_TARGET" - warn "this install is not upgradable (no current symlink) and the PATH link breaks if $DSH_STAGING moves." -else - # Point `current` at this staging worktree with `ln -sfn`: -f replaces an - # existing `current` (re-run or upgrade) and -n stops `ln` from dereferencing - # an existing symlink-to-directory and dropping the new link *inside* the old - # worktree. `mv` is unusable here — BSD/macOS `mv` follows the existing dir - # symlink the same way. The swap is one unlink+symlink pair on a local fs; the - # installer holds no other process racing this path. - # The launcher must resolve to a staging worktree, never to the repository - # itself: an upgrade repoints `current`, so aliasing it onto the master clone - # would make every upgrade rewrite the fetch/upgrade base. Compare physical - # paths — a symlinked or unresolved path would slip past a string compare. - _staging_resolved=$(resolve_dir "$DSH_STAGING") - [ -n "$REPO_ROOT" ] && [ "$_staging_resolved" = "$REPO_ROOT" ] \ - && die "refusing to point $DSH_CURRENT at the repository $REPO_ROOT — the launcher must resolve to a staging worktree." - ln -sfn "$DSH_STAGING" "$DSH_CURRENT" - info "pointed $DSH_CURRENT -> $DSH_STAGING" - DSH_LAUNCH_TARGET=$DSH_CURRENT/bin/dsh - ln -sf "$DSH_LAUNCH_TARGET" "$DSH_BIN_DIR/dsh" - info "linked $DSH_BIN_DIR/dsh -> $DSH_LAUNCH_TARGET" -fi +# The launcher must resolve to a staging worktree, never to the repository +# itself: an upgrade repoints `current`, so aliasing it onto the master clone +# would make every upgrade rewrite the fetch/upgrade base. Compare physical +# paths — a symlinked or unresolved path would slip past a string compare. +_staging_resolved=$(resolve_dir "$DSH_STAGING") +[ "$_staging_resolved" = "$REPO_ROOT" ] \ + && die "refusing to point $DSH_CURRENT at the repository $REPO_ROOT — the launcher must resolve to a staging worktree." +# Point `current` at this staging worktree with `ln -sfn`: -f replaces an +# existing `current` (re-run or upgrade) and -n stops `ln` from dereferencing +# an existing symlink-to-directory and dropping the new link *inside* the old +# worktree. `mv` is unusable here — BSD/macOS `mv` follows the existing dir +# symlink the same way. The swap is one unlink+symlink pair on a local fs; the +# installer holds no other process racing this path. +ln -sfn "$DSH_STAGING" "$DSH_CURRENT" +info "pointed $DSH_CURRENT -> $DSH_STAGING" +DSH_LAUNCH_TARGET=$DSH_CURRENT/bin/dsh +ln -sf "$DSH_LAUNCH_TARGET" "$DSH_BIN_DIR/dsh" +info "linked $DSH_BIN_DIR/dsh -> $DSH_LAUNCH_TARGET" case ":$PATH:" in *":$DSH_BIN_DIR:"*) ON_PATH=1 ;; From b694c33d18141304c94833db5fbc0f51fb178c08 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 31 Jul 2026 20:39:53 +0800 Subject: [PATCH 03/19] Default shipped UI sessions to workspace-write --- ...31-even-out-shipped-tool-rosters.i18n.yaml | 4 +- ...026-07-31-even-out-shipped-tool-rosters.md | 6 +-- ...-07-31-even-out-shipped-tool-rosters.zh.md | 6 +-- ...mission-default-for-new-sessions.i18n.yaml | 4 +- ...-31-permission-default-for-new-sessions.md | 2 +- ...-permission-default-for-new-sessions.zh.md | 2 +- .../2026-07-31-web-default-search.i18n.yaml | 4 +- .../feature/2026-07-31-web-default-search.md | 2 +- .../2026-07-31-web-default-search.zh.md | 2 +- ...-workspace-write-surface-default.i18n.yaml | 6 +++ ...6-07-31-workspace-write-surface-default.md | 35 +++++++++++++++ ...7-31-workspace-write-surface-default.zh.md | 35 +++++++++++++++ apps/cli/README.i18n.yaml | 4 +- apps/cli/README.md | 2 + apps/cli/README.zh.md | 2 + apps/cli/composition.md | 24 +++++++--- apps/cli/config/base.cordis.yml | 43 +++++++++++++++--- apps/cli/config/tui.cordis.yml | 2 +- apps/cli/config/web.cordis.yml | 44 ------------------- apps/cli/tests/shipped-composition.e2e.ts | 44 ++++++++++++------- apps/web/tests/access-confirmation.e2e.ts | 9 +--- apps/web/tests/seeded-history.e2e.ts | 12 ++--- apps/web/tests/settings-chrome.e2e.ts | 8 ++-- apps/web/tests/shipped-composition.e2e.ts | 10 ++--- .../snapshots/code-mode-round/ui.expected.md | 2 +- .../cordis-tool-round/ui.expected.md | 2 +- .../snapshots/fresh-round-trip/ui.expected.md | 2 +- .../lifecycle-chrome/hero.expected.md | 2 +- .../lifecycle-chrome/plan-active.expected.md | 2 +- .../lifecycle-chrome/reloaded.expected.md | 2 +- .../live-interactions/cancel.expected.md | 2 +- .../live-interactions/error-auth.expected.md | 2 +- .../live-interactions/loading.expected.md | 2 +- .../live-interactions/retry.expected.md | 2 +- .../snapshots/message-actions/ui.expected.md | 2 +- .../plan-review/approved.expected.md | 2 +- .../question-composer/answered.expected.md | 2 +- .../queue-actions/collapsed.expected.md | 2 +- .../queue-actions/editing.expected.md | 2 +- .../snapshots/queue-actions/ui.expected.md | 2 +- .../seeded-history/command-row.expected.md | 4 +- .../snapshots/seeded-history/ui.expected.md | 2 +- .../settings-chrome/dialog.expected.md | 4 +- .../snapshots/steering/settled.expected.md | 2 +- .../snapshots/web-search-round/ui.expected.md | 2 +- .../credentials-local/README.i18n.yaml | 4 +- .../credentials/credentials-local/README.md | 4 +- .../credentials-local/README.zh.md | 4 +- 48 files changed, 227 insertions(+), 143 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-31-workspace-write-surface-default.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-31-workspace-write-surface-default.md create mode 100644 .agents/notes/implemented/feature/2026-07-31-workspace-write-surface-default.zh.md diff --git a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml index 83e965b391..91d5d6c5ee 100644 --- a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.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-even-out-shipped-tool-rosters.md -2026-07-31-even-out-shipped-tool-rosters.md: 316e5045e559e2da162c53d64989ccecfd18b857 -2026-07-31-even-out-shipped-tool-rosters.zh.md: ed39212dc4877f4df1dc1c6e84142b61a866c548 +2026-07-31-even-out-shipped-tool-rosters.md: 5aaf4798c1297fc273cd715838feb6441ffc0d61 +2026-07-31-even-out-shipped-tool-rosters.zh.md: 79d8dbb8e0aa3cdf462f930ea63a5621dc2d9243 diff --git a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md index 316e5045e5..5aaf4798c1 100644 --- a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md +++ b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md @@ -16,7 +16,7 @@ The rows that are not surface-specific move into [`base.cordis.yml`](../../../.. Two rows stay surface-specific. `tmux-context` is TUI-only because a browser surface has no terminal multiplexer to describe. `session-reference` is TUI-only because it drives the shared session-query index from the launcher's process-local path, and the browser sidebar reconciles that index on its own first search. -**This change adds only.** No row is removed from either surface and no existing row's configuration is edited: the executors, the sandbox composition, the access defaults, `tools.mode`, and the workflow tool are exactly what they were. A reader comparing the two catalogs before and after should find additions and nothing else. +**This roster decision adds only.** No tool row is removed from either surface, and a catalog comparison finds additions and nothing else. The shared executors, sandbox composition, and access default are owned independently by the [workspace-write default decision](2026-07-31-workspace-write-surface-default.md). ### What stays unmounted, and why @@ -42,7 +42,7 @@ The layer that would make MCP a default is the one this repository does not have That tail also inserts [`composition-settled.ts`](../../../../apps/cli/tests/fixtures/composition-settled.ts), which announces settled Loader activation on the terminal stream. The TUI renders as soon as its own fiber starts, so a prompt typed at the banner can reach the loop while tool rows and persistence are still activating and assemble a partial catalog; gating the smoke's first prompt on that marker is what makes the assertion deterministic. -The same smoke pins the TUI's unchanged execution posture from the same artifact: `tool-bash` emits its `sandbox_permissions` escalation pair only when the mounted executor has wider modes to escalate to, so asserting its **absence** fails if a later change quietly sandboxes this surface. +The same smoke also pins the TUI execution posture from the same artifact. Those sandbox-schema and initial-permission assertions belong to the [workspace-write default decision](2026-07-31-workspace-write-surface-default.md), independently of this roster. [`apps/web/tests/shipped-composition.e2e.ts`](../../../../apps/web/tests/shipped-composition.e2e.ts) covers the Web surface in the built lane, asserting its catalog, that its access default is untouched, and that `workspace-write`'s writable roots include the temp directories — a trap that makes sandbox tests lie when the workspace sits under `/tmp` ([`roots.ts`](../../../../packages/sandbox/sandbox/src/roots.ts)). @@ -66,4 +66,4 @@ The same model gets the same tools on both surfaces, and the difference that exi `apps/cli` gains five workspace dependencies: four the shipped tree now mounts, plus `dsh-mcp-client`, which it does not mount and which exists so an installed `dsh` can. -Nothing about execution changed. The TUI still runs the model's commands through unrestricted executors with no approval seam, and the Web surface still defaults to `danger-full-access`. Both are pinned by assertions in this change, which makes them visible rather than fixed — the sandbox decision is still open. +Execution policy stays independent of the roster. The [shared workspace-write decision](2026-07-31-workspace-write-surface-default.md) owns both surfaces' sandboxed executors and default permission; changing that policy does not add or remove a tool. diff --git a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md index ed39212dc4..79d8dbb8e0 100644 --- a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md @@ -16,7 +16,7 @@ Status: implemented 有两行仍是 surface 专属。`tmux-context` 只在 TUI,因为浏览器 surface 没有终端复用器可描述。`session-reference` 只在 TUI,因为它以 launcher 的进程本地路径驱动共享的 session-query 索引,而浏览器侧边栏会在自己的首次搜索里重建该索引。 -**本次改动只做加法。** 两个 surface 都没有任何一行被移除,也没有任何既有行的配置被编辑:执行器、沙箱组合、访问默认值、`tools.mode` 以及 workflow 工具,全都保持原样。对比改动前后的两份目录,读者应当只看到新增,别无其他。 +**本次工具清单决策只做加法。** 两个 surface 均未移除任何工具行,目录对比只会发现新增,别无其他。共享执行器、沙箱组合与访问默认值独立归属[workspace-write 默认值决策](2026-07-31-workspace-write-surface-default.md)。 ### 什么保持不挂,以及为什么 @@ -42,7 +42,7 @@ Status: implemented 该尾部还插入了 [`composition-settled.ts`](../../../../apps/cli/tests/fixtures/composition-settled.ts),它在终端字节流上宣告 Loader 激活已 settle。TUI 在自己的 fiber 一启动就渲染,因此在 banner 处敲下的提示词可能在工具行与持久化仍在激活时就抵达循环,从而组装出不完整的目录;把冒烟的首个提示词 gate 在该标记上,正是断言得以确定的原因。 -同一份冒烟还从同一份产物上钉住 TUI 未改变的执行姿态:`tool-bash` 只在挂载的执行器确实有更宽模式可升级时才发出 `sandbox_permissions` 升级参数对,因此断言它的**缺席**会在日后有人悄悄给这个 surface 加上沙箱时失败。 +同一份冒烟还根据同一份产物固定 TUI 的执行姿态。那些沙箱 schema 与初始权限断言归[workspace-write 默认值决策](2026-07-31-workspace-write-surface-default.md)所有,独立于本工具清单决策。 [`apps/web/tests/shipped-composition.e2e.ts`](../../../../apps/web/tests/shipped-composition.e2e.ts) 在构建产物 lane 中覆盖 Web surface,断言它的工具目录、它的访问默认值未被触碰,以及 `workspace-write` 的可写根包含临时目录——一个会让沙箱测试说谎的陷阱,当工作区落在 `/tmp` 下时([`roots.ts`](../../../../packages/sandbox/sandbox/src/roots.ts))。 @@ -66,4 +66,4 @@ Status: implemented `apps/cli` 增加五个 workspace 依赖:四个是交付树现在挂载的,外加 `dsh-mcp-client`——它并不被挂载,存在的意义是让已安装的 `dsh` 能挂。 -执行相关的一切都没有变。TUI 仍以不受限执行器运行模型的命令且没有批准接缝,Web surface 仍默认 `danger-full-access`。两者都由本次改动中的断言钉住,这让它们变得可见而非被修复——沙箱那个决定仍然悬着。 +执行策略独立于工具清单。[共享 workspace-write 决策](2026-07-31-workspace-write-surface-default.md)拥有两个 surface 的沙箱执行器与默认权限;更改该策略不会增加或移除工具。 diff --git a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.i18n.yaml index 8d4467858b..3227fac451 100644 --- a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.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-permission-default-for-new-sessions.md -2026-07-31-permission-default-for-new-sessions.md: 35812b53d0c1448afd95b9a063eda6658fb1bef3 -2026-07-31-permission-default-for-new-sessions.zh.md: a75deaec323b57f2bc88e84dd4d5c7d7d98cd177 +2026-07-31-permission-default-for-new-sessions.md: ffa4c8a07bdd08ca52edbc14fe10372ad76e8cf8 +2026-07-31-permission-default-for-new-sessions.zh.md: 5fc42724754acc1653ed93b48644794a38f52ba7 diff --git a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md index 35812b53d0..ffa4c8a07b 100644 --- a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md +++ b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md @@ -22,7 +22,7 @@ ApiProxy explicitly adds `permission` to its Web settings allowlist beside the c Changing Permission in Settings updates `settings.yaml` and the selector immediately, but does not alter the open session. Every later session is reconstructable from its three pinned permission facts, including after the user changes the default again or the process restarts. Deployments whose composed sandbox and approval defaults match no preset must configure `defaultPreset` explicitly. -The assembled Web snapshot now contains a functional Permission selector. Its keyless browser scenario writes `read-only`, verifies an existing `danger-full-access` session is unchanged, and verifies a subsequently created session starts with the read-only event triplet. +The assembled Web snapshot contains a functional Permission selector. Its keyless browser scenario writes `read-only`, verifies an existing `workspace-write` session is unchanged, and verifies a subsequently created session starts with the read-only event triplet. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.zh.md b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.zh.md index a75deaec32..5fc4272475 100644 --- a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.zh.md @@ -22,7 +22,7 @@ ApiProxy 在可配置提供方 namespace 之外,将 `permission` 显式加入 在 Settings 中更改「权限」会立即更新 `settings.yaml` 和选择器,但不会改变已打开的会话。之后的每个会话都可以从三个已固定的权限事实中重建,即使用户再次更改默认值或进程重启也不受影响。如果部署中组合后的沙箱和审批默认值与任何 preset 都不匹配,则必须显式配置 `defaultPreset`。 -组装后的 Web 快照现在包含功能完整的「权限」选择器。其无密钥浏览器场景会写入 `read-only`,验证现有的 `danger-full-access` 会话保持不变,并验证随后创建的会话以 read-only 事件三元组启动。 +组装后的 Web 快照包含功能完整的「权限」选择器。其无密钥浏览器场景会写入 `read-only`,验证现有的 `workspace-write` 会话保持不变,并验证随后创建的会话以 read-only 事件三元组启动。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/feature/2026-07-31-web-default-search.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-web-default-search.i18n.yaml index 6b244f3d12..2c8f3eea48 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-default-search.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-web-default-search.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-web-default-search.md -2026-07-31-web-default-search.md: d9616c27410bb5be9b385a9aaa56c22f6054eeb1 -2026-07-31-web-default-search.zh.md: 27cd330427669a78c03b939c737b37f79fd7965a +2026-07-31-web-default-search.md: 121a1dff5fffd4223eefcc7475fff874276658aa +2026-07-31-web-default-search.zh.md: ac98f4806413cb6050a08354a553942437866fe1 diff --git a/.agents/notes/implemented/feature/2026-07-31-web-default-search.md b/.agents/notes/implemented/feature/2026-07-31-web-default-search.md index d9616c2741..121a1dff5f 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-default-search.md +++ b/.agents/notes/implemented/feature/2026-07-31-web-default-search.md @@ -16,7 +16,7 @@ DeepSeek search uses the same `DEEPSEEK_API_KEY` credential reference as the off Search keeps its endpoint distinct from chat completions: `DEEPSEEK_SEARCH_BASE_URL` overrides the Anthropic-compatible base, while `DEEPSEEK_BASE_URL` continues to configure conversation requests. Each `web_search` performs an auxiliary DeepSeek Messages call with the native search server tool. Immediately before dispatch, the provider appends a log-only `web/deepseek-search-llm-request` event to the initiating Agent session with the resolved endpoint, API version, and exact secret-free JSON body. Credential preflight remains provider-local and races caller cancellation; neither concern expands the generic Web or credentials seams. -The default mount does not create a Web-specific permission policy. `web_search` executes outside the bash/filesystem sandbox and approval presets, following `dsh-tool-web`'s existing contract. It does not mount `web_fetch` or a local fetch provider, so the default does not grant model-selected arbitrary URL retrieval. The shipped deployment already defaults to `danger-full-access`; a future restricted-network product stance must add a `tools/pre-execute` policy or capability-specific network confinement rather than implying that filesystem access mode governs Web calls. +The default mount does not create a Web-specific permission policy. `web_search` executes outside the bash/filesystem sandbox and approval presets, following `dsh-tool-web`'s existing contract. It does not mount `web_fetch` or a local fetch provider, so the default does not grant model-selected arbitrary URL retrieval. The shipped `workspace-write` default governs file mutations only; a restricted-network product stance requires a `tools/pre-execute` policy or capability-specific network confinement rather than implying that filesystem access mode governs Web calls. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-31-web-default-search.zh.md b/.agents/notes/implemented/feature/2026-07-31-web-default-search.zh.md index 27cd330427..ac98f48064 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-default-search.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-web-default-search.zh.md @@ -16,7 +16,7 @@ DeepSeek 搜索使用与官方会话适配器相同的 `DEEPSEEK_API_KEY` 凭据 搜索端点与 chat completions 保持独立:`DEEPSEEK_SEARCH_BASE_URL` 覆盖 Anthropic 兼容基址,`DEEPSEEK_BASE_URL` 则继续配置会话请求。每次 `web_search` 都会发起一次辅助 DeepSeek Messages 调用,并携带原生搜索服务器工具。发出请求前一刻,提供方会向发起请求的 agent(智能体)会话追加仅用于日志的 LLM(大语言模型)请求事件 `web/deepseek-search-llm-request`,其中包含已解析端点、API 版本,以及不含密钥的精确 JSON 请求体。凭据预检仍留在提供方内部,并与调用方取消存在竞态;这两项关注点都不会扩展通用 Web seam 或凭据 seam。 -默认挂载不会创建 Web 专用权限策略。`web_search` 在 bash/文件系统沙箱及审批预设之外执行,并遵循 `dsh-tool-web` 的现有契约。组合不挂载 `web_fetch` 或本地抓取提供方,因此默认配置不会允许模型自行选择任意 URL 进行抓取。已交付部署的默认值本就是 `danger-full-access`;未来如果产品采取受限网络策略,必须添加 `tools/pre-execute` 策略或按能力限制网络访问,而不能暗示文件系统访问模式会管辖 Web 调用。 +默认挂载不会创建 Web 专用权限策略。`web_search` 在 bash/文件系统沙箱及审批预设之外执行,并遵循 `dsh-tool-web` 的现有契约。组合不挂载 `web_fetch` 或本地抓取提供方,因此默认配置不会允许模型自行选择任意 URL 进行抓取。已交付的 `workspace-write` 默认值只管辖文件修改;若产品采取受限网络策略,就需要添加 `tools/pre-execute` 策略或按能力限制网络访问,而不能暗示文件系统访问模式会管辖 Web 调用。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/feature/2026-07-31-workspace-write-surface-default.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-workspace-write-surface-default.i18n.yaml new file mode 100644 index 0000000000..14facd28d9 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-workspace-write-surface-default.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-workspace-write-surface-default.md +2026-07-31-workspace-write-surface-default.md: a0b216122e301b5332ed761155d743dc78fa3bab +2026-07-31-workspace-write-surface-default.zh.md: 4391daa32b142ea976e3b04833163936913c17bc diff --git a/.agents/notes/implemented/feature/2026-07-31-workspace-write-surface-default.md b/.agents/notes/implemented/feature/2026-07-31-workspace-write-surface-default.md new file mode 100644 index 0000000000..a0b216122e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-workspace-write-surface-default.md @@ -0,0 +1,35 @@ +# Agent Note: Workspace-write defaults for shipped surfaces + +Status: implemented + +English | [中文](2026-07-31-workspace-write-surface-default.zh.md) + +## Problem + +The shipped terminal and browser surfaces exposed the same coding tools under different unconfined compositions. Web mounted the sandbox and permission services but selected `danger-full-access`; the TUI mounted the unrestricted local bash and filesystem providers directly. A fresh coding session could therefore mutate any path its same-UID process could reach before the user deliberately chose that authority. + +## Decision + +[`base.cordis.yml`](../../../../apps/cli/config/base.cordis.yml) owns one sandbox and permission stack for every shipped TUI, Web, and browser-backed headless session: `dsh-sandbox-local`, `dsh-sandbox-policy`, `dsh-bash-sandbox`, `dsh-fs-sandbox`, `dsh-user-approval`, and `dsh-permission`. The composition fallback is the `workspace-write` preset, which bundles `workspace-write` file effects with the `ask` approval policy. `DSH_PERMISSION_MODE` remains an explicit process override; a stored `permission.defaultPreset` remains the user preference for later sessions and outranks the fallback through the Settings seam. + +A genuinely fresh session pins `permission/preset: workspace-write`, `sandbox/mode: workspace-write`, and `approval/policy: ask` before execution. Existing and resumed sessions retain their logged permission, and changing the General-settings default affects only sessions created afterward. The browser keeps its Access picker, answerable approval cards, and risk confirmation for Full access. The TUI gains the existing `/permission` command because the shared Permission service activates its command child there. + +The mode governs file effects only. Sandboxed bash and filesystem mutations admit the session workspace and platform temporary roots; reads, network access, and process visibility remain outside this policy. If no platform runner can enforce a confined bash call, execution fails closed instead of falling through to an unrestricted command. + +## Testing + +The keyless shipped-TUI pseudo-terminal smoke boots the real Loader tree, reads the persisted first request, and asserts both the `sandbox_permissions`/`justification` bash schema and the initial workspace-write event triplet. The shipped-Web composition smoke asserts the same policy, approval, and Permission defaults. The assembled browser Settings snapshot opens on Workspace Write, preserves an existing workspace-write session while changing the future default, and still proves the confirmed Full-access path. + +## Alternatives considered + +**Keep the sandbox stack in `web.cordis.yml` and duplicate it into `tui.cordis.yml`.** Rejected because the plugin identities, presets, fallback, and executor swap are identical. Two copies would make a security default depend on keeping surface overlays synchronized; the shared base is their one owner. + +**Leave the TUI unrestricted and change only the browser fallback.** Rejected because it preserves the unexplained surface difference and leaves a fresh terminal session with the authority this decision removes. + +**Add a terminal approval dialog in the same change.** Rejected as a separate interaction and lifecycle decision. The TUI has no `approval/request` answerer, so a one-shot automatic escalation currently settles unavailable and fails closed; a user who needs wider authority can deliberately select another preset through `/permission`. + +## Consequences + +Fresh sessions can modify the active workspace and temporary roots without extra prompts, while an attempted mutation elsewhere is denied before it reaches the target. Full access remains available by explicit selection, and browser selection retains its acknowledgement dialog. Stored user defaults and logged session permissions are not rewritten. + +The browser-backed headless entry inherits the Web composition and therefore the same default. The TUI's missing approval answerer is a deliberate limitation of this change: automatic wider retries fail closed there instead of displaying a permission question. diff --git a/.agents/notes/implemented/feature/2026-07-31-workspace-write-surface-default.zh.md b/.agents/notes/implemented/feature/2026-07-31-workspace-write-surface-default.zh.md new file mode 100644 index 0000000000..4391daa32b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-workspace-write-surface-default.zh.md @@ -0,0 +1,35 @@ +# Agent Note: 已交付界面的 workspace-write 默认值 + +Status: implemented + +[English](2026-07-31-workspace-write-surface-default.md) | 中文 + +## 问题 + +已交付的终端和浏览器界面在两套不同的无约束组合下暴露相同的编码工具。Web 挂载了沙箱与权限服务,却选择 `danger-full-access`;TUI 则直接挂载不受限的本地 bash 与文件系统提供方。因此,在用户主动选择这类权限之前,全新的编码会话就能修改其同 UID 进程可达的任意路径。 + +## 决策 + +[`base.cordis.yml`](../../../../apps/cli/config/base.cordis.yml) 为所有已交付的 TUI、Web 以及由浏览器支撑的无头会话统一持有一套沙箱与权限栈:`dsh-sandbox-local`、`dsh-sandbox-policy`、`dsh-bash-sandbox`、`dsh-fs-sandbox`、`dsh-user-approval` 和 `dsh-permission`。组合回退值为 `workspace-write` preset,其中包含 `workspace-write` 文件效果模式与 `ask` 审批策略。`DSH_PERMISSION_MODE` 仍是显式的进程级覆盖;已存储的 `permission.defaultPreset` 仍是面向后续会话的用户偏好,并通过 Settings seam 优先于该回退值。 + +真正的新会话会在执行前固定 `permission/preset: workspace-write`、`sandbox/mode: workspace-write` 和 `approval/policy: ask`。现有会话和恢复的会话保留日志中记录的权限,更改「通用」设置中的默认值只影响之后创建的会话。浏览器保留 Access 选择器、可应答的审批卡片,以及选择 Full access 时的风险确认。共享 Permission 服务在 TUI 中激活其命令子件,因此 TUI 会获得现有的 `/permission` 命令。 + +该模式只管辖文件效果。受沙箱约束的 bash 与文件系统修改只允许写入会话工作区和平台临时根目录;读取、网络访问与进程可见性仍不受该策略约束。若没有平台 runner 能强制执行受限的 bash 调用,执行会以拒绝方式关闭,不会退回不受限命令。 + +## 测试 + +已交付 TUI 的无密钥伪终端冒烟测试会启动真实 Loader 树,读取已持久化的首个请求,并断言 bash schema 中的 `sandbox_permissions`/`justification`,以及初始的 workspace-write 事件三元组。已交付 Web 组合的冒烟测试断言相同的策略、审批与 Permission 默认值。组装后的浏览器 Settings 快照打开时选中 Workspace Write,在更改后续会话默认值时保持现有 `workspace-write` 会话不变,并仍然验证经确认后选择 Full access 的路径。 + +## 曾考虑的替代方案 + +**将沙箱栈留在 `web.cordis.yml`,并在 `tui.cordis.yml` 中复制一份。** 不予采纳,因为插件标识、preset、回退值与执行器替换完全相同。两份副本会让安全默认值依赖两个界面覆盖层持续同步;共享 base 才是它们的唯一归属。 + +**保留不受限的 TUI,只更改浏览器回退值。** 不予采纳,因为这会保留无法解释的界面差异,并让全新的终端会话继续拥有本决策要移除的权限。 + +**在同一次变更中添加终端审批对话框。** 不予采纳,因为这是另一个交互与生命周期决策。TUI 没有 `approval/request` 应答者,因此一次性自动升权当前会落定为不可用并以拒绝方式关闭;需要更宽权限的用户可以通过 `/permission` 主动选择其他 preset。 + +## 后果 + +全新的会话无需额外提示即可修改当前工作区与临时根目录,尝试修改其他位置则会在触及目标前被拒绝。Full access 仍可通过显式选择获得,浏览器选择时也仍会显示确认对话框。系统不会重写已存储的用户默认值和会话日志中记录的权限。 + +由浏览器支撑的无头入口继承 Web 组合,因此默认值相同。TUI 缺少审批应答者是本次变更的明确限制:自动请求更宽权限的重试会在那里以拒绝方式关闭,而不会显示权限询问。 diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index 9d93c662d1..e113087417 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: e2567bebea80b154c01d1cad8094008818e5fd13 -README.zh.md: a3b7f433040ce0ff02848e3741f407dffe8f8cb8 +README.md: c43658e066b42c58252b1665c2cd3883d5cb5d4b +README.zh.md: 0690231e637135f6f2481880bee520958ad5b5c0 diff --git a/apps/cli/README.md b/apps/cli/README.md index e2567bebea..c43658e066 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -23,6 +23,8 @@ The TUI surface: The Web and headless surfaces boot `base.cordis.yml` plus `web.cordis.yml`, then apply `$DSH_HOME/config.yaml`; an explicit `--config ` replaces that personal overlay. Both surfaces otherwise share the same composition: both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, opt into first-message model titles, use the same bounded transient model-request retry policy as the TUI, and mount a disposable in-memory SQLite content-index service. That service is ACTIVE at boot, while its `node:sqlite` module and database handle open only on the first content search. This keeps Node 22 startup output free of SQLite's experimental warning before search is used; the first actual search may still emit the runtime warning. Each service instance owns its database, so parallel invocations neither share unsupported SQLite state nor leave derived index files behind, and the first search lazily reconciles live and persisted logs. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`). +The shared composition defaults new TUI, Web, and headless sessions to the `workspace-write` permission preset (`workspace-write` file mode plus `ask` approval policy). Sandbox-enforced bash and filesystem mutations may write only under the session workspace and platform temporary roots; reads, network access, and process visibility are not confined. The browser answers one-shot approval requests and exposes the Access picker; the TUI exposes `/permission`, but has no approval-request answerer, so an automatic wider retry there fails closed until the user deliberately changes the session preset. `DSH_PERMISSION_MODE` changes the process fallback, while a stored General-settings Permission value applies to later sessions without changing an open one. + The shipped TUI and Web compositions register the native DeepSeek adapter plus pi-ai OpenAI and Anthropic profiles. Credentials and endpoint overrides come from the provider-standard `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`, `OPENAI_API_KEY` / `OPENAI_BASE_URL`, and `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL` pairs in the boot's layered environment. Every surface also registers `web_search` and only `web_search`. Search uses DeepSeek's Anthropic-compatible Messages endpoint, resolves the same `DEEPSEEK_API_KEY` reference for every call, and accepts the separate `DEEPSEEK_SEARCH_BASE_URL` endpoint override; each search is an auxiliary model request with its own latency and token cost. `web_fetch` remains disabled and the composition mounts no default fetch provider, so deployments that need arbitrary page retrieval must opt in through an overlay. The deployment decision and its security boundary live in the [default Web search Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-default-search.md). diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index a3b7f43304..0690231e63 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -23,6 +23,8 @@ TUI 界面: Web 和无头界面启动 `base.cordis.yml` 与 `web.cordis.yml`,随后应用 `$DSH_HOME/config.yaml`;显式的 `--config ` 会替代该个人覆盖。除此之外,两者共享同一套组合:两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,选用首条消息模型标题,采用与 TUI 相同的有界暂时性模型请求重试策略,并挂载一个可丢弃的内存 SQLite 内容索引服务。该服务在启动时处于 ACTIVE 状态,但其 `node:sqlite` 模块与数据库句柄分别要到首次内容搜索才会导入和打开。这样可使 Node 22 在尚未使用搜索时的启动输出不出现 SQLite 实验性警告;首次实际搜索仍可能发出运行时警告。每个服务实例独占自己的数据库,因此并行调用既不会共享不受支持的 SQLite 状态,也不会留下派生索引文件,首次搜索还会惰性对账实时日志与持久化日志。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。 +共享组合把新建 TUI、Web 和无头会话的权限默认设为 `workspace-write` preset(`workspace-write` 文件模式加 `ask` 审批策略)。由沙箱强制约束的 bash 与文件系统修改只能写入会话工作区和平台临时根目录;读取、网络访问和进程可见性不受该策略约束。浏览器可以应答一次性审批请求,并提供 Access 选择器;TUI 提供 `/permission`,但没有审批请求应答者,因此自动请求更宽权限的重试会以拒绝方式关闭,直到用户主动更改会话 preset。`DSH_PERMISSION_MODE` 会更改进程回退值,而「通用」设置中已存储的「权限」值只适用于之后的会话,不会更改已打开的会话。 + 已交付的 TUI 和 Web 组合会注册原生 DeepSeek 适配器,以及 pi-ai 的 OpenAI 和 Anthropic 提供方配置。凭据和端点覆盖来自启动分层环境中的提供方标准变量对:`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`、`OPENAI_API_KEY` / `OPENAI_BASE_URL` 和 `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL`。 每个界面也都只注册 `web_search` 这一个 Web 工具。搜索使用 DeepSeek 的 Anthropic 兼容 Messages 端点,每次调用都会解析同一个 `DEEPSEEK_API_KEY` 凭据引用,并接受独立的 `DEEPSEEK_SEARCH_BASE_URL` 端点覆盖;每次搜索都是一次辅助模型请求,会产生独立的延迟与 token 成本。`web_fetch` 仍处于禁用状态,组合也未挂载默认抓取提供方;需要任意页面抓取能力的部署必须通过覆盖层选择启用。部署决策及其安全边界见[默认 Web 搜索 Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-default-search.md)。 diff --git a/apps/cli/composition.md b/apps/cli/composition.md index b552570a5c..cb6b98a3b0 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -42,8 +42,16 @@ flowchart LR cfg --> plugin_tui_telemetry_otel plugin_tui_subprocess["subprocess
@deepseek-ai/dsh-subprocess-local"] cfg --> plugin_tui_subprocess - plugin_tui_bash_local["bash-local
@deepseek-ai/dsh-bash-local"] - cfg --> plugin_tui_bash_local + plugin_tui_sandbox["sandbox
@deepseek-ai/dsh-sandbox-local"] + cfg --> plugin_tui_sandbox + plugin_tui_sandbox_policy["sandbox-policy
@deepseek-ai/dsh-sandbox-policy"] + cfg --> plugin_tui_sandbox_policy + plugin_tui_bash_sandbox["bash-sandbox
@deepseek-ai/dsh-bash-sandbox"] + cfg --> plugin_tui_bash_sandbox + plugin_tui_approval["approval
@deepseek-ai/dsh-user-approval"] + cfg --> plugin_tui_approval + plugin_tui_permission["permission
@deepseek-ai/dsh-permission"] + cfg --> plugin_tui_permission plugin_tui_tool_bash["tool-bash
@deepseek-ai/dsh-tool-bash"] cfg --> plugin_tui_tool_bash plugin_tui_tool_tasks["tool-tasks
@deepseek-ai/dsh-tool-tasks"] @@ -126,8 +134,8 @@ flowchart LR cfg --> plugin_tui_system_prompt plugin_tui_agent_loop["agent-loop
@deepseek-ai/dsh-agent-loop"] cfg --> plugin_tui_agent_loop - plugin_tui_fs_local["fs-local
@deepseek-ai/dsh-fs-local"] - cfg --> plugin_tui_fs_local + plugin_tui_fs_sandbox["fs-sandbox
@deepseek-ai/dsh-fs-sandbox"] + cfg --> plugin_tui_fs_sandbox plugin_tui_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"] cfg --> plugin_tui_llm_deepseek ``` @@ -151,7 +159,11 @@ flowchart LR | `session-query-sqlite` | `@deepseek-ai/dsh-session-query-sqlite` | | `telemetry-otel` | `@deepseek-ai/dsh-session-telemetry-otel` | | `subprocess` | `@deepseek-ai/dsh-subprocess-local` | -| `bash-local` | `@deepseek-ai/dsh-bash-local` | +| `sandbox` | `@deepseek-ai/dsh-sandbox-local` | +| `sandbox-policy` | `@deepseek-ai/dsh-sandbox-policy` | +| `bash-sandbox` | `@deepseek-ai/dsh-bash-sandbox` | +| `approval` | `@deepseek-ai/dsh-user-approval` | +| `permission` | `@deepseek-ai/dsh-permission` | | `tool-bash` | `@deepseek-ai/dsh-tool-bash` | | `tool-tasks` | `@deepseek-ai/dsh-tool-tasks` | | `fs-policy` | `@deepseek-ai/dsh-fs-policy` | @@ -193,7 +205,7 @@ flowchart LR | `tools` | `@deepseek-ai/dsh-tools` | | `system-prompt` | `@deepseek-ai/dsh-system-prompt` | | `agent-loop` | `@deepseek-ai/dsh-agent-loop` | -| `fs-local` | `@deepseek-ai/dsh-fs-local` | +| `fs-sandbox` | `@deepseek-ai/dsh-fs-sandbox` | | `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | Source config: [`apps/cli/config/base.cordis.yml`](config/base.cordis.yml). diff --git a/apps/cli/config/base.cordis.yml b/apps/cli/config/base.cordis.yml index 7e648eedc8..e88cbdb39d 100644 --- a/apps/cli/config/base.cordis.yml +++ b/apps/cli/config/base.cordis.yml @@ -131,11 +131,42 @@ - id: subprocess name: '@deepseek-ai/dsh-subprocess-local' -- id: bash-local - name: '@deepseek-ai/dsh-bash-local' +# Every shipped product surface starts with the same file-effect boundary. +# The environment remains an explicit deployment override; otherwise fresh +# sessions pin workspace-write + ask through the permission service below. +- id: sandbox + name: '@deepseek-ai/dsh-sandbox-local' + +- id: sandbox-policy + name: '@deepseek-ai/dsh-sandbox-policy' + config: + mode: !!js process.env.DSH_PERMISSION_MODE ?? 'workspace-write' + workspaceRoot: !!js process.cwd() + +- id: bash-sandbox + name: '@deepseek-ai/dsh-bash-sandbox' config: timeoutMs: 60000 +- id: approval + name: '@deepseek-ai/dsh-user-approval' + config: + policy: !!js "(process.env.DSH_PERMISSION_MODE ?? 'workspace-write') === 'danger-full-access' ? 'never' : 'ask'" + +- id: permission + name: '@deepseek-ai/dsh-permission' + config: + presets: + read-only: + sandbox: read-only + approval: ask + workspace-write: + sandbox: workspace-write + approval: ask + danger-full-access: + sandbox: danger-full-access + approval: never + - id: tool-bash name: '@deepseek-ai/dsh-tool-bash' @@ -339,10 +370,10 @@ config: agents: [] -# The filesystem provider. `cwd` defaults to the package's `process.cwd()`; the -# TUI states it explicitly because that value is also the session workspace. -- id: fs-local - name: '@deepseek-ai/dsh-fs-local' +# The sandboxed filesystem provider. `cwd` defaults to `process.cwd()`; the TUI +# states it explicitly because that value is also the session workspace. +- id: fs-sandbox + name: '@deepseek-ai/dsh-fs-sandbox' # The native DeepSeek adapter. No key or endpoint is inlined: both resolve per # request from the `llm-deepseek:` settings section over this entry, with the diff --git a/apps/cli/config/tui.cordis.yml b/apps/cli/config/tui.cordis.yml index d9ce7de680..02d8649447 100644 --- a/apps/cli/config/tui.cordis.yml +++ b/apps/cli/config/tui.cordis.yml @@ -46,7 +46,7 @@ reasoningEffort: max # This single-session app resolves relative paths from the process cwd. -- id: fs-local +- id: fs-sandbox config: cwd: !!js process.cwd() diff --git a/apps/cli/config/web.cordis.yml b/apps/cli/config/web.cordis.yml index f0c2e5ea28..38bcd387f6 100644 --- a/apps/cli/config/web.cordis.yml +++ b/apps/cli/config/web.cordis.yml @@ -36,50 +36,6 @@ apiKey: !!js process.env.DEEPSEEK_API_KEY baseURL: !!js process.env.DEEPSEEK_BASE_URL -# The web surface replaces the unrestricted local executors with the shared -# sandbox policy. Its default preserves the previous unrestricted behavior; -# DSH_PERMISSION_MODE and the browser permission picker can confine a session. -- insert: - - id: sandbox - name: '@deepseek-ai/dsh-sandbox-local' - - - id: sandbox-policy - name: '@deepseek-ai/dsh-sandbox-policy' - config: - mode: !!js process.env.DSH_PERMISSION_MODE ?? 'danger-full-access' - workspaceRoot: !!js process.cwd() - - - id: bash-sandbox - name: '@deepseek-ai/dsh-bash-sandbox' - - - id: approval - name: '@deepseek-ai/dsh-user-approval' - config: - policy: !!js "(process.env.DSH_PERMISSION_MODE ?? 'danger-full-access') === 'danger-full-access' ? 'never' : 'ask'" - - - id: permission - name: '@deepseek-ai/dsh-permission' - config: - presets: - read-only: - sandbox: read-only - approval: ask - workspace-write: - sandbox: workspace-write - approval: ask - danger-full-access: - sandbox: danger-full-access - approval: never - - - id: fs-sandbox - name: '@deepseek-ai/dsh-fs-sandbox' - -- id: bash-local - disabled: true - -- id: fs-local - disabled: true - # ── web-only host rows, the transport layer, and the browser roster ───────── # `dshClient` rows are the browser roster the modules node half scans into diff --git a/apps/cli/tests/shipped-composition.e2e.ts b/apps/cli/tests/shipped-composition.e2e.ts index ac1e9d5456..aa1a803c85 100644 --- a/apps/cli/tests/shipped-composition.e2e.ts +++ b/apps/cli/tests/shipped-composition.e2e.ts @@ -11,6 +11,7 @@ import { acknowledgeTuiFirstRunWelcome } from '../src/tui-onboarding/tui-first-r const dshBinScript = fileURLToPath(new URL('../src/bin.ts', import.meta.url)) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) +const PERMISSION_SUMMARY = 'current preset workspace-write (available: read-only, workspace-write, danger-full-access)' // An overlay over the shipped tree, so the catalog under test is the one // `base.cordis.yml` + `tui.cordis.yml` assemble; the tail only swaps the model // and redirects session artifacts. @@ -67,6 +68,8 @@ interface LoggedHeader { names: string[] /** `bash`'s assembled parameter properties; the escalation pair is present only under a confining executor. */ bashArguments: Record + /** Initial permission facts pinned by the shipped composition. */ + permissionEvents: Array<[string, unknown]> } /** @@ -82,18 +85,22 @@ async function loggedHeader(cwd: string): Promise { // A single keyless run writes one session log. const logRelPath = entries.find(name => name.endsWith('.jsonl')) if (logRelPath === undefined) throw new Error(`no session log written under ${sessionsDir}`) - const lines = (await readFile(join(sessionsDir, logRelPath), 'utf8')).split('\n').filter(Boolean) - for (const line of lines) { - const event = JSON.parse(line) as SessionEvent - if (event.type !== 'request/header') continue - const tools = event.data.header.tools ?? [] - const bash = tools.find(schema => schema.name === 'bash') - return { - names: tools.map(schema => schema.name).sort(), - bashArguments: (bash?.parameters as { properties?: Record } | undefined)?.properties ?? {}, - } + const events = (await readFile(join(sessionsDir, logRelPath), 'utf8')).split('\n').filter(Boolean) + .map(line => JSON.parse(line) as SessionEvent) + const header = events.find(event => event.type === 'request/header') + if (header === undefined || header.type !== 'request/header') { + throw new Error(`session log ${logRelPath} has no request/header event`) + } + const tools = header.data.header.tools ?? [] + const bash = tools.find(schema => schema.name === 'bash') + return { + names: tools.map(schema => schema.name).sort(), + bashArguments: (bash?.parameters as { properties?: Record } | undefined)?.properties ?? {}, + permissionEvents: events.flatMap(event => + event.type === 'permission/preset' || event.type === 'sandbox/mode' || event.type === 'approval/policy' + ? [[event.type, event.data] as [string, unknown]] + : []), } - throw new Error(`session log ${logRelPath} has no request/header event`) } describe('shipped dsh composition (real Loader tree in a PTY)', () => { @@ -110,17 +117,22 @@ describe('shipped dsh composition (real Loader tree in a PTY)', () => { // Artifact CI builds and smokes concurrently on a contended runner. ...(process.env.DSH_EXAMPLE_MODE === 'lib' ? { timeoutMs: 60_000 } : {}), actions: [ - { waitFor: COMPOSITION_SETTLED_MARKER, send: 'Describe the shipped composition.\r' }, + { waitFor: COMPOSITION_SETTLED_MARKER, send: '/permission\r' }, + { waitFor: PERMISSION_SUMMARY, send: 'Describe the shipped composition.\r' }, { waitFor: COMPOSITION_REPLY_TEXT, send: '/exit\r' }, ], inspect: async (cwd) => { observed = await loggedHeader(cwd) }, }) expect(output).toContain(COMPOSITION_REPLY_TEXT) + expect(output).toContain(PERMISSION_SUMMARY) expect(observed?.names.filter(name => !RIPGREP_TOOLS.includes(name))).toEqual(EXPECTED_TUI_TOOLS) expect([[], RIPGREP_TOOLS]).toContainEqual(observed?.names.filter(name => RIPGREP_TOOLS.includes(name))) - // The TUI mounts the unrestricted local executors, so `tool-bash` emits no - // escalation pair. Pinning its absence keeps a later sandbox change from - // arriving here unannounced. - expect(Object.keys(observed?.bashArguments ?? {})).not.toContain('sandbox_permissions') + expect(observed?.bashArguments).toHaveProperty('sandbox_permissions') + expect(observed?.bashArguments).toHaveProperty('justification') + expect(observed?.permissionEvents).toEqual([ + ['permission/preset', { preset: 'workspace-write' }], + ['sandbox/mode', { mode: 'workspace-write' }], + ['approval/policy', { policy: 'ask' }], + ]) }, LOADER_SMOKE_TEST_TIMEOUT_MS) }) diff --git a/apps/web/tests/access-confirmation.e2e.ts b/apps/web/tests/access-confirmation.e2e.ts index fae329362b..c6c6e627bd 100644 --- a/apps/web/tests/access-confirmation.e2e.ts +++ b/apps/web/tests/access-confirmation.e2e.ts @@ -70,14 +70,7 @@ describe('web e2e: Full access confirmation', () => { const access = page.locator('button[aria-label^="访问模式"]').first() await access.waitFor({ timeout: 10_000 }) - // Normalize the starting preset through the real command path. The - // shipped web config may already start at Full access. - if ((await access.getAttribute('aria-label'))?.endsWith('Full access') === true) { - await access.click() - await page.getByRole('menuitem', { name: 'Workspace Write' }).click() - await expect.poll(() => access.getAttribute('aria-label'), { timeout: 10_000 }) - .toBe('访问模式,当前:Workspace Write') - } + expect(await access.getAttribute('aria-label')).toBe('访问模式,当前:Workspace Write') await access.click() await page.getByRole('menuitem', { name: 'Full access' }).click() diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 9acb0fa136..fb3bbeb186 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -339,19 +339,19 @@ describe('web e2e: seeded history renders through cold resume', () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-command-row')) // The Access chip submits `/permission ` — a host command with no // model call, so the settled row renders keylessly over this cold history. - // The row copy is the assertion: `permission · preset workspace-write`, + // The row copy is the assertion: `permission · preset read-only`, // where neither half repeats the other (the dispatched `/` and its // argument stay out of the title, and the settlement text never restates // the command's own name). - await page.getByRole('button', { name: 'Access mode, current: Full access' }).click() - await page.getByRole('menuitem', { name: 'Workspace Write' }).click() - await page.getByRole('button', { name: 'Access mode, current: Workspace Write' }).waitFor({ timeout: 10_000 }) + await page.getByRole('button', { name: 'Access mode, current: Workspace Write' }).click() + await page.getByRole('menuitem', { name: 'Read Only' }).click() + await page.getByRole('button', { name: 'Access mode, current: Read Only' }).waitFor({ timeout: 10_000 }) // Scoped to the row itself, so unrelated page text that happens to read // `permission` (a future resident slash menu) cannot satisfy or break it. - const row = page.locator('[data-variant="others"]').filter({ hasText: 'preset workspace-write' }) + const row = page.locator('[data-variant="others"]').filter({ hasText: 'preset read-only' }) await expect.poll(() => row.count(), { timeout: 10_000 }).toBe(1) expect(await row.getByText('permission', { exact: true }).count()).toBe(1) - expect(await row.getByText('/permission workspace-write', { exact: true }).count()).toBe(0) + expect(await row.getByText('/permission read-only', { exact: true }).count()).toBe(0) const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) .split(SEED_ID).join('{{seededId}}') await compareOrRefreshGolden(COMMAND_ROW_EXPECTED, snapshot, MODE) diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index a1d77657b8..7bdfcc1d59 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -57,7 +57,7 @@ describe('web e2e: settings modal and General preferences', () => { expect(await trigger.getAttribute('aria-expanded')).toBe('true') // General is active by default; Permission, Language and Appearance are functional. expect(await dialog.getByRole('button', { name: '通用设置' }).getAttribute('aria-current')).toBe('true') - await dialog.getByRole('button', { name: 'Full access' }).waitFor({ timeout: 10_000 }) + await dialog.getByRole('button', { name: 'Workspace Write' }).waitFor({ timeout: 10_000 }) await expect.poll(() => dialog.getByText('语言', { exact: true }).count(), { timeout: 5_000 }).toBe(1) await expect.poll(() => dialog.getByText('外观', { exact: true }).count(), { timeout: 5_000 }).toBe(1) // Golden of the freshly opened dialog (default zh, General active). @@ -82,12 +82,12 @@ describe('web e2e: settings modal and General preferences', () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-permission')) const existing = scaffold.ctx.sessions.create(SessionId('settings-permission-before')) expect(existing.events.find(event => event.type === 'permission/preset')?.data) - .toEqual({ preset: 'danger-full-access' }) + .toEqual({ preset: 'workspace-write' }) await page.getByRole('button', { name: '设置', exact: true }).click() const dialog = page.getByRole('dialog', { name: '设置' }) await dialog.waitFor({ timeout: 10_000 }) - const selector = dialog.getByRole('button', { name: 'Full access' }) + const selector = dialog.getByRole('button', { name: 'Workspace Write' }) await selector.waitFor({ timeout: 10_000 }) await expect.poll(() => selector.isEnabled(), { timeout: 5_000 }).toBe(true) await selector.click() @@ -98,7 +98,7 @@ describe('web e2e: settings modal and General preferences', () => { expect(document).toContain('permission:') expect(document).toContain('defaultPreset: read-only') expect(existing.events.find(event => event.type === 'permission/preset')?.data) - .toEqual({ preset: 'danger-full-access' }) + .toEqual({ preset: 'workspace-write' }) const created = scaffold.ctx.sessions.create(SessionId('settings-permission-after')) expect(created.events.map(event => [event.type, event.data])).toEqual([ diff --git a/apps/web/tests/shipped-composition.e2e.ts b/apps/web/tests/shipped-composition.e2e.ts index 0cad833303..671cc84906 100644 --- a/apps/web/tests/shipped-composition.e2e.ts +++ b/apps/web/tests/shipped-composition.e2e.ts @@ -10,6 +10,7 @@ import { canonicalPath, writableRoots } from '@deepseek-ai/dsh-sandbox' import type {} from '@deepseek-ai/dsh-tools' import type {} from '@deepseek-ai/dsh-sandbox-policy' import type {} from '@deepseek-ai/dsh-user-approval' +import type {} from '@deepseek-ai/dsh-permission' import { launchWebScaffold, type WebScaffold } from './scaffold.ts' /** @@ -63,7 +64,7 @@ afterEach(async () => { scaffold = undefined }) -it('assembles the shipped Web catalog and keeps its access default', async () => { +it('assembles the shipped Web catalog with the confined access default', async () => { scaffold = await launchWebScaffold() const names = scaffold.ctx.tools.schemas().map(schema => schema.name).sort() expect(names.filter(name => !RIPGREP_TOOLS.includes(name))).toEqual(EXPECTED_TOOLS) @@ -76,8 +77,7 @@ it('assembles the shipped Web catalog and keeps its access default', async () => expect(writableRoots(scaffold.ctx.sandboxPolicy.resolve({ mode: 'workspace-write' }))).toEqual( expect.arrayContaining([canonicalPath('/tmp'), canonicalPath(tmpdir())]), ) - // The Web surface keeps its shipped access default; the base's confined one - // reaches the TUI. Pinning both keeps a base change from moving Web silently. - expect(scaffold.ctx.sandboxPolicy.defaultMode).toBe('danger-full-access') - expect(scaffold.ctx.approval.config.policy).toBe('never') + expect(scaffold.ctx.sandboxPolicy.defaultMode).toBe('workspace-write') + expect(scaffold.ctx.approval.config.policy).toBe('ask') + expect(scaffold.ctx.permission.defaultPreset).toBe('workspace-write') }, 120_000) diff --git a/apps/web/tests/snapshots/code-mode-round/ui.expected.md b/apps/web/tests/snapshots/code-mode-round/ui.expected.md index 183bd366a0..8f4c7e5bf2 100644 --- a/apps/web/tests/snapshots/code-mode-round/ui.expected.md +++ b/apps/web/tests/snapshots/code-mode-round/ui.expected.md @@ -39,7 +39,7 @@ - textbox "Message the agent" - button "Commands": - img -- 'button "Access mode, current: Full access"': Full access +- 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md index 636b1e6d28..e4d5ac8426 100644 --- a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md +++ b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md @@ -54,7 +54,7 @@ - textbox "Message the agent" - button "Commands": - img -- 'button "Access mode, current: Full access"': Full access +- 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md index 3f7fa52b2e..facb7b58cc 100644 --- a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md +++ b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md @@ -34,7 +34,7 @@ - textbox "Message the agent" - button "Commands": - img -- 'button "Access mode, current: Full access"': Full access +- 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md index 7b6432ef87..8611ac5c0d 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md @@ -28,7 +28,7 @@ - textbox "Describe what you want to build" - button "Commands": - img -- 'button "Access mode, current: Full access"': Full access +- 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md index 4aad3112e1..a9fb7901d7 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md @@ -28,7 +28,7 @@ - textbox "Describe what you want to build" - button "Commands": - img -- 'button "Access mode, current: Full access"': Full access +- 'button "Access mode, current: Workspace Write"': Workspace Write - button "Plan mode on, press to turn off": Plan - button "Select model, current deepseek-v4-flash": - text: deepseek-v4-flash diff --git a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md index f58e5b77f2..5965797c69 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md @@ -26,7 +26,7 @@ - textbox "Message the agent" - button "Commands": - img -- 'button "Access mode, current: Full access"': Full access +- 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/live-interactions/cancel.expected.md b/apps/web/tests/snapshots/live-interactions/cancel.expected.md index 3333237798..d1e4d2bbef 100644 --- a/apps/web/tests/snapshots/live-interactions/cancel.expected.md +++ b/apps/web/tests/snapshots/live-interactions/cancel.expected.md @@ -23,7 +23,7 @@ - textbox "Message the agent" - button "Commands": - img -- 'button "Access mode, current: Full access"': Full access +- 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md index e6a93f2463..fb9337e978 100644 --- a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md +++ b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md @@ -19,7 +19,7 @@ - textbox "Message the agent" - button "Commands": - img -- 'button "Access mode, current: Full access"': Full access +- 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/live-interactions/loading.expected.md b/apps/web/tests/snapshots/live-interactions/loading.expected.md index b442b4345a..a5dfd08fb5 100644 --- a/apps/web/tests/snapshots/live-interactions/loading.expected.md +++ b/apps/web/tests/snapshots/live-interactions/loading.expected.md @@ -18,7 +18,7 @@ - textbox "Message the agent" - button "Commands": - img -- 'button "Access mode, current: Full access"': Full access +- 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/live-interactions/retry.expected.md b/apps/web/tests/snapshots/live-interactions/retry.expected.md index f34dddd7dd..6380eaf5c6 100644 --- a/apps/web/tests/snapshots/live-interactions/retry.expected.md +++ b/apps/web/tests/snapshots/live-interactions/retry.expected.md @@ -28,7 +28,7 @@ - textbox "Message the agent" - button "Commands": - img -- 'button "Access mode, current: Full access"': Full access +- 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/message-actions/ui.expected.md b/apps/web/tests/snapshots/message-actions/ui.expected.md index 613e9a3605..bf67498178 100644 --- a/apps/web/tests/snapshots/message-actions/ui.expected.md +++ b/apps/web/tests/snapshots/message-actions/ui.expected.md @@ -37,7 +37,7 @@ - textbox "Message the agent" - button "Commands": - img -- 'button "Access mode, current: Full access"': Full access +- 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current deepseek-v4-flash": - text: deepseek-v4-flash - img diff --git a/apps/web/tests/snapshots/plan-review/approved.expected.md b/apps/web/tests/snapshots/plan-review/approved.expected.md index c971a6b2e4..5be3f83247 100644 --- a/apps/web/tests/snapshots/plan-review/approved.expected.md +++ b/apps/web/tests/snapshots/plan-review/approved.expected.md @@ -39,7 +39,7 @@ - textbox "Message the agent" - button "Commands": - img -- 'button "Access mode, current: Full access"': Full access +- 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md index fec84d06db..28297569ab 100644 --- a/apps/web/tests/snapshots/question-composer/answered.expected.md +++ b/apps/web/tests/snapshots/question-composer/answered.expected.md @@ -34,7 +34,7 @@ - textbox "Message the agent" - button "Commands": - img -- 'button "Access mode, current: Full access"': Full access +- 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md index 829f21a70f..b9dee060ac 100644 --- a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md +++ b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md @@ -19,7 +19,7 @@ - textbox "Message the agent" - button "Commands": - img -- 'button "Access mode, current: Full access"': Full access +- 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/queue-actions/editing.expected.md b/apps/web/tests/snapshots/queue-actions/editing.expected.md index 169dde2c51..3a70840713 100644 --- a/apps/web/tests/snapshots/queue-actions/editing.expected.md +++ b/apps/web/tests/snapshots/queue-actions/editing.expected.md @@ -32,7 +32,7 @@ - textbox "Message the agent" - button "Commands": - img -- 'button "Access mode, current: Full access"': Full access +- 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/queue-actions/ui.expected.md b/apps/web/tests/snapshots/queue-actions/ui.expected.md index e2c91f7584..24edb57417 100644 --- a/apps/web/tests/snapshots/queue-actions/ui.expected.md +++ b/apps/web/tests/snapshots/queue-actions/ui.expected.md @@ -25,7 +25,7 @@ - textbox "Message the agent" - button "Commands": - img -- 'button "Access mode, current: Full access"': Full access +- 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/seeded-history/command-row.expected.md b/apps/web/tests/snapshots/seeded-history/command-row.expected.md index 0173726c38..b916a3add2 100644 --- a/apps/web/tests/snapshots/seeded-history/command-row.expected.md +++ b/apps/web/tests/snapshots/seeded-history/command-row.expected.md @@ -41,11 +41,11 @@ - img - text: Context injection - img -- text: permission preset workspace-write +- text: permission preset read-only - textbox "Message the agent" - button "Commands": - img -- 'button "Access mode, current: Workspace Write"': Workspace Write +- 'button "Access mode, current: Read Only"': Read Only - button "Select model, current deepseek-v4-flash": - text: deepseek-v4-flash - img diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md index 0062b6cfab..a168d5e2a3 100644 --- a/apps/web/tests/snapshots/seeded-history/ui.expected.md +++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md @@ -43,7 +43,7 @@ - textbox "Message the agent" - button "Commands": - img -- 'button "Access mode, current: Full access"': Full access +- 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current deepseek-v4-flash": - text: deepseek-v4-flash - img diff --git a/apps/web/tests/snapshots/settings-chrome/dialog.expected.md b/apps/web/tests/snapshots/settings-chrome/dialog.expected.md index e782d25c05..118e4ff3e1 100644 --- a/apps/web/tests/snapshots/settings-chrome/dialog.expected.md +++ b/apps/web/tests/snapshots/settings-chrome/dialog.expected.md @@ -11,8 +11,8 @@ - img - text: 关闭 - text: 权限 选择新会话的默认权限模式 - - button "Full access": - - text: Full access + - button "Workspace Write": + - text: Workspace Write - img - text: 语言 - button "中文": diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md index 2ab2f5970d..72e28598e5 100644 --- a/apps/web/tests/snapshots/steering/settled.expected.md +++ b/apps/web/tests/snapshots/steering/settled.expected.md @@ -35,7 +35,7 @@ - textbox "Message the agent" - button "Commands": - img -- 'button "Access mode, current: Full access"': Full access +- 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/web-search-round/ui.expected.md b/apps/web/tests/snapshots/web-search-round/ui.expected.md index 37d53a0df6..4ff674462e 100644 --- a/apps/web/tests/snapshots/web-search-round/ui.expected.md +++ b/apps/web/tests/snapshots/web-search-round/ui.expected.md @@ -26,7 +26,7 @@ - textbox "Message the agent" - button "Commands": - img -- 'button "Access mode, current: Full access"': Full access +- 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/packages/credentials/credentials-local/README.i18n.yaml b/packages/credentials/credentials-local/README.i18n.yaml index 89a8576683..b5fb4b2f0e 100644 --- a/packages/credentials/credentials-local/README.i18n.yaml +++ b/packages/credentials/credentials-local/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/credentials/credentials-local/README.md -README.md: 126140b10719dc6f7bc458a118ba1feb1f440270 -README.zh.md: c22575115ab44b5e86a847ffe8f1fa1a795b580d +README.md: 02b883958faf8b695a3a2abf2df77790cc2fca86 +README.zh.md: 59c7fd5747f327e8998882ca4db1473173e793b5 diff --git a/packages/credentials/credentials-local/README.md b/packages/credentials/credentials-local/README.md index 126140b107..02b883958f 100644 --- a/packages/credentials/credentials-local/README.md +++ b/packages/credentials/credentials-local/README.md @@ -32,7 +32,7 @@ External edits publish `credentials/updated` per changed reference after the sna ## Security boundary -The document is `0600` under a `0700` directory, which stops other OS users — **not** the model. Tool processes (bash, the filesystem tools) run as the same user, so under the shipped `danger-full-access` default they can read this file exactly like any other file the user owns, and no sandbox mode singles it out. What the harness does hold to is narrower: it never hands the model a resolved path to the document, and never loads it into the process environment (see [app-boot's Personal config](../../ui/app-boot/README.md#personal-config)), so reaching the value takes a deliberate read of a path the agent was not given. +The document is `0600` under a `0700` directory, which stops other OS users — **not** the model. Tool processes (bash, the filesystem tools) run as the same user, and the shipped `workspace-write` file policy confines mutations rather than reads, so they can read this file exactly like any other file the user owns; no sandbox mode singles it out. What the harness does hold to is narrower: it never hands the model a resolved path to the document, and never loads it into the process environment (see [app-boot's Personal config](../../ui/app-boot/README.md#personal-config)), so reaching the value takes a deliberate read of a path the agent was not given. That is discretion, not a boundary. A deployment that must keep provider keys away from its own agent cannot get there with file permissions; an OS-keychain provider — a store the model's processes cannot read at all — is the deferred answer and belongs beside this provider as a sibling package. @@ -48,7 +48,7 @@ No direct invalidation; credentials never enter a request prefix. - **Multi-line entries refuse `set`/`unset`** — the line editor will not rewrite an entry it would corrupt; `describe` reports them `writable: false` and edits must go to the file directly. - **Same-reference concurrent writes are last-write-wins** — the writer lock and the read-modify-write keep concurrent writers from dropping each other's entries, but two writers editing one reference still resolve to the later write; there is no revision check. -- **A same-UID process can read the document** — see [Security boundary](#security-boundary): only a confining sandbox mode denies it, and an OS-keychain provider is deferred. +- **A same-UID process can read the document** — see [Security boundary](#security-boundary): the file-effect sandbox modes do not deny reads, and an OS-keychain provider is deferred. - **Unrepresentable values fail loud** — control characters, or a mix of both quote styles with backslashes, cannot round-trip the dotenv line format. - **Environment changes are invisible** — `process.env` is read live per resolution, but no event can announce a change there. - **Atomic, not crash-durable** — inherited from `dsh-atomic-write`; the store re-reads on boot. diff --git a/packages/credentials/credentials-local/README.zh.md b/packages/credentials/credentials-local/README.zh.md index c22575115a..59c7fd5747 100644 --- a/packages/credentials/credentials-local/README.zh.md +++ b/packages/credentials/credentials-local/README.zh.md @@ -32,7 +32,7 @@ dotenv 格式,用 `dotenv` 解析;写回用物理行级编辑器,保留一 ## 安全边界 -文档在 `0700` 目录下以 `0600` 权限存放,这挡得住其他 OS 用户,**挡不住**模型。工具进程(bash、文件系统工具)以同一用户身份运行,因此在出厂默认的 `danger-full-access` 下,它们读这个文件与读该用户拥有的任何其他文件毫无二致,也没有任何沙箱模式会把它单独挑出来。harness 真正守住的更窄:它绝不把该文档的解析后路径交给模型,也绝不把它载入进程环境(见 [app-boot 的个人配置](../../ui/app-boot/README.md#personal-config)),因此要拿到这个值,需要刻意去读一条并未交给 agent 的路径。 +文档在 `0700` 目录下以 `0600` 权限存放,这挡得住其他 OS 用户,**挡不住**模型。工具进程(bash、文件系统工具)以同一用户身份运行,而已交付的 `workspace-write` 文件策略限制的是修改而非读取,因此它们读这个文件与读该用户拥有的任何其他文件毫无二致;也没有任何沙箱模式会把它单独挑出来。harness 真正守住的更窄:它绝不把该文档的解析后路径交给模型,也绝不把它载入进程环境(见 [app-boot 的个人配置](../../ui/app-boot/README.md#personal-config)),因此要拿到这个值,需要刻意去读一条并未交给 agent 的路径。 这是审慎,不是边界。必须让提供方密钥远离自身 agent 的部署无法靠文件权限做到;OS 钥匙串 provider——一个模型的进程根本读不到的存储——才是延后的答案,它应当作为平级包与本 provider 并列。 @@ -48,7 +48,7 @@ dotenv 格式,用 `dotenv` 解析;写回用物理行级编辑器,保留一 - **多行条目拒绝 `set`/`unset`**——行编辑器不改写会被它破坏的条目;`describe` 把它们报为 `writable: false`,编辑必须直接落到文件上。 - **同一引用的并发写入是后写胜出**——写锁加读-改-写让并发写入者不会丢掉彼此的条目,但两个写入者编辑同一个引用时仍以较后的写入为准;没有修订检查。 -- **同 UID 进程可以读取该文档**——见[安全边界](#security-boundary):只有受限沙箱模式会拒绝它,OS 钥匙串 provider 仍是延后项。 +- **同 UID 进程可以读取该文档**——见[安全边界](#security-boundary):文件效果沙箱模式不会拒绝读取,OS 钥匙串 provider 仍是延后项。 - **无法表示的值响亮失败**——控制字符,或同时混用两种引号又含反斜杠的值,无法在 dotenv 行格式中往返。 - **环境变化不可见**——每次解析实时读取 `process.env`,但那里的变化不可能发出事件。 - **原子但不保证崩溃持久**——继承自 `dsh-atomic-write`;存储在启动时重新读取。 From 575e1217bb8b0aa77ac8263daefdd790c2898f54 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Fri, 31 Jul 2026 10:48:50 +0800 Subject: [PATCH 04/19] fix: remove scoped bash --- .../2026-07-19-gui-web-client-architecture.md | 2 +- ...26-07-19-gui-web-client-architecture.zh.md | 2 +- .../2026-07-23-toolview-dissolution.md | 2 +- .../2026-07-23-toolview-dissolution.zh.md | 2 +- .../2026-07-27-web-session-fork-actions.md | 2 +- .../2026-07-27-web-session-fork-actions.zh.md | 2 +- apps/web/tests/built-boot.snapshot.ts | 2 +- apps/web/tests/code-mode-round.e2e.ts | 4 +- apps/web/tests/navigation-panes.e2e.ts | 8 +-- apps/web/tests/smoke-real.e2e.ts | 2 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../ui-conversation/src/client/apply.ts | 2 +- .../client/toolviews/bash-sample.module.css | 11 --- .../src/client/toolviews/bash-sample.tsx | 6 +- .../tests/assembly-surfaces.spec.tsx | 2 +- .../tests/chat-code-subcalls.spec.tsx | 4 +- .../tests/chat-stats-bash-sample.spec.tsx | 68 ++++--------------- .../tests/chat-toolview-slot.spec.tsx | 2 +- .../tests/coverage-tails.spec.tsx | 4 +- 20 files changed, 38 insertions(+), 93 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md index 63b6f5795c..b1f7771727 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md @@ -44,7 +44,7 @@ Implementation homes: registry core and the props-share types in `packages/clien A service is a plugin's only API surface toward other plugins (UI components and injection faces are not APIs; a plugin nobody calls mounts no service — ui-trajectory is the minimal-plugin exemplar: no ctx service, only view-slot registrations). The roster: `ctx.connection` (api client + stream handles), `ctx.slots` (registry wrapper emitting `slots/changed`, render entry, renderer install seam), `ctx.sessions` (list store, current-session state, scope tree), `ctx.loader`, `ctx.theme`, `ctx.i18n`, `ctx.layout` (cross-plugin view navigation), `ctx.conversation` (send/cancel/startSession). Viewing state that used to live in service stores (panel widths, selection, drafts) now lives in entry-declared stores per the [slot system standard](2026-07-22-slot-type-chain-implementation.md). -There is no registration model besides slots — the former view and tool rings both dissolved into it. Conversation views are entries of the `'conversation.view'` list slot ui-conversation declares, tab metadata rides the registration options (`id`/`order`/`label`), and per-view chrome lives inside the view components themselves. A tool row is a keyed child slot each view declares for itself — today `'conversation.chat.toolview'` (keyed/session), declared by the chat entry's `children` table; the key space is runtime-open (SlotMap declares slots, never keys), which is what the tool ring's open tool-name set required. The render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`; the owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails`), and `ToolRowProps` composes it with the session standard kit for registrant components. Registrants are plain plugins with zero dedicated machinery: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`, with `inject: ['slots', 'conversation']` as the load-order seam (the conversation service being present guarantees the slot is declared). Session-dimension differentiation happens inside the component — `useSessions` reading `parentId` — not in registry predicates; interaction drafts and other row state ride the ordinary store seat. Trajectory/waterfall get same-shaped slots (names fixed by the slot-naming discipline `..`, one shared owner type) that land with their own row render sites — RendersCheck rejects a declaration nobody renders, so the two slots cannot be declared early. +There is no registration model besides slots — the former view and tool rings both dissolved into it. Conversation views are entries of the `'conversation.view'` list slot ui-conversation declares, tab metadata rides the registration options (`id`/`order`/`label`), and per-view chrome lives inside the view components themselves. A tool row is a keyed child slot each view declares for itself — today `'conversation.chat.toolview'` (keyed/session), declared by the chat entry's `children` table; the key space is runtime-open (SlotMap declares slots, never keys), which is what the tool ring's open tool-name set required. The render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`; the owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails`), and `ToolRowProps` composes it with the session standard kit for registrant components. Registrants are plain plugins with zero dedicated machinery: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`, with `inject: ['slots', 'conversation']` as the load-order seam (the conversation service being present guarantees the slot is declared). Interaction drafts and other row state ride the ordinary store seat. Trajectory/waterfall get same-shaped slots (names fixed by the slot-naming discipline `..`, one shared owner type) that land with their own row render sites — RendersCheck rejects a declaration nobody renders, so the two slots cannot be declared early. **Scope addressing** mirrors the host's agent-scope idiom: services are root singletons whose methods take no sessionId — they read the caller's scope mark (`scopeOf(ctx)`). Inside a session scope, `ctx.conversation.send('hi', 'queue')` targets that session; cross-session calls re-target by switching ctx (`ctx.sessions.scope(id)!.conversation.send(...)`); calling a scoped method from root ctx throws. Client session scopes are minted like host agent scopes (a no-op plugin fiber + a scope-key extend), built lazily on first viewing and torn down only when the session is removed and unwatched — host-session death alone does not tear a scope (it freezes into a read-only viewport). diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md index 2d57c12eba..e43151b7d5 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md @@ -44,7 +44,7 @@ slot 体系有自己的 RFC——[slot 体系标准](2026-07-22-slot-type-chain- 服务是插件对其他插件的唯一 API 面(UI 组件与注入面都不是 API;无人调用的插件不挂服务——ui-trajectory 即最小插件样板:无 ctx 服务,只做视图坑注册)。名册:`ctx.connection`(api client + 流句柄)、`ctx.slots`(注册表包装层,发 `slots/changed`,渲染入口,渲染器安装缝)、`ctx.sessions`(列表 store、当前会话状态、scope 树)、`ctx.loader`、`ctx.theme`、`ctx.i18n`、`ctx.layout`(跨插件视图导航)、`ctx.conversation`(send/cancel/startSession)。过去住在服务 store 里的观看态(面板宽、选中、草稿)现按 [slot 体系标准](2026-07-22-slot-type-chain-implementation.md) 住 entry 声明的 store。 -slot 之外不存在第二种注册模型——原视图环与工具环都已溶解进来。会话视图即 ui-conversation 声明的 `'conversation.view'` list 坑的 entry,tab 元数据随注册 options(`id`/`order`/`label`)走,per-view chrome 住视图组件自身。工具行是各视图自己声明的 keyed 子槽——今天是 `'conversation.chat.toolview'`(keyed/session),由 chat 条目的 `children` 表声明;key 空间运行时开放(SlotMap 声明槽、从不声明 key),这正是工具环「tool 名开放集」的原需求。渲染点逐行以 `entryKey: toolName` 分发、以 `GenericToolCard` 作调用点 `fallback`;owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openDetails`),`ToolRowProps` 把它与 session 标配 kit 预组合供注册方组件取用。注册方就是普通插件、零专用设施:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作加载序缝(conversation 服务在场即保证槽已声明)。会话维差异化在组件内完成——`useSessions` 读 `parentId`——不走注册表谓词;交互草稿等行内状态走普通 store 席位。trajectory/waterfall 得同形槽(槽名按槽名纪律 `<域>.<条目>.<孔位>` 已定死,共用一张 owner 类型),随各自的行渲染点落地——RendersCheck 拒绝无人渲染的声明,两槽无法提前声明。 +slot 之外不存在第二种注册模型——原视图环与工具环都已溶解进来。会话视图即 ui-conversation 声明的 `'conversation.view'` list 坑的 entry,tab 元数据随注册 options(`id`/`order`/`label`)走,per-view chrome 住视图组件自身。工具行是各视图自己声明的 keyed 子槽——今天是 `'conversation.chat.toolview'`(keyed/session),由 chat 条目的 `children` 表声明;key 空间运行时开放(SlotMap 声明槽、从不声明 key),这正是工具环「tool 名开放集」的原需求。渲染点逐行以 `entryKey: toolName` 分发、以 `GenericToolCard` 作调用点 `fallback`;owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openDetails`),`ToolRowProps` 把它与 session 标配 kit 预组合供注册方组件取用。注册方就是普通插件、零专用设施:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作加载序缝(conversation 服务在场即保证槽已声明)。交互草稿等行内状态走普通 store 席位。trajectory/waterfall 得同形槽(槽名按槽名纪律 `<域>.<条目>.<孔位>` 已定死,共用一张 owner 类型),随各自的行渲染点落地——RendersCheck 拒绝无人渲染的声明,两槽无法提前声明。 **scope 寻址**与 host 侧 agent scope 惯例同构:服务是 root 单例,方法不收 sessionId——它们读调用方 ctx 上的 scope 标(`scopeOf(ctx)`)。在会话 scope 内,`ctx.conversation.send('hi', 'queue')` 自动打到该会话;跨会话调用换 ctx 定向(`ctx.sessions.scope(id)!.conversation.send(...)`);从 root ctx 直接调 scoped 方法即 throw。client 会话 scope 的铸造方式与 host agent scope 相同(no-op 插件 fiber + scope 键 extend),首次观看时惰性建,只有会话被移除且无人观看才拆——仅 host 会话死亡不拆 scope(冻结为只读视窗)。 diff --git a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.md b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.md index 80c2688b15..a695f98cb2 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.md +++ b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.md @@ -14,7 +14,7 @@ After the view ring dissolved into the slot system, the client kept exactly one The tool ring is gone as independent infrastructure: a tool row is a **keyed child slot each view declares for itself**, and the client has exactly one registration model. The justification above was hollow — a keyed slot's *key space* is already runtime-open (SlotMap declares slots, never keys; the ask-user composer's `key: 'question'` was the precedent), so the open tool-name set fits `entryKey` dispatch natively. -Shipped shape (current-state narrative also in the [architecture note](2026-07-19-gui-web-client-architecture.md)): the chat entry's `children` table declares `'conversation.chat.toolview'` (keyed/session); the render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback` (the default card is domain property; the fallback option is ordinary renderSlot grammar). The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails` — details being a session-level facility, not chat-private), and `ToolRowProps` pre-composes it with the session standard kit for registrant components. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam — apply mounts `ConversationService` *after* the chat registration, so the service being present guarantees the slot is declared, by construction. Session-dimension differentiation happens inside the component (`useSessions` reading `parentId` — the decision sits where all the information already is); the bash sample is the third-party-posture exemplar and paints the same ToolRow chrome as Think (`Bash · {description}`, with a scoped badge only in child sessions). Trajectory/waterfall toolview slots share this exact shape (names fixed by the slot-naming discipline `..`, one shared owner type) and land with their own row render sites — RendersCheck rejects a declaration nobody renders, so the type system, not convention, blocks early empty declarations. +Shipped shape (current-state narrative also in the [architecture note](2026-07-19-gui-web-client-architecture.md)): the chat entry's `children` table declares `'conversation.chat.toolview'` (keyed/session); the render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback` (the default card is domain property; the fallback option is ordinary renderSlot grammar). The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails` — details being a session-level facility, not chat-private), and `ToolRowProps` pre-composes it with the session standard kit for registrant components. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam — apply mounts `ConversationService` *after* the chat registration, so the service being present guarantees the slot is declared, by construction. The bash sample is the third-party-posture exemplar and paints the same ToolRow chrome as Think (`Bash · {description}`). Trajectory/waterfall toolview slots share this exact shape (names fixed by the slot-naming discipline `..`, one shared owner type) and land with their own row render sites — RendersCheck rejects a declaration nobody renders, so the type system, not convention, blocks early empty declarations. Registry-era responsibilities all have successor homes: inject caching and row error isolation ride the framework renderer (entry×scope cache, per-entry `SlotErrorBoundary`); subscribe/getVersion ride the slot core's per-key version machinery; the future "store seat" is the ordinary store seat keyed slots already have (interaction-draft durability is its first named consumer); miss fallback is the call-site `fallback` option. diff --git a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.zh.md b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.zh.md index 928c5f445d..e79f80216a 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.zh.md @@ -14,7 +14,7 @@ Status: implemented 工具环作为独立基础设施已消失:工具行是**各视图为自己声明的 keyed 子槽**,client 全域只剩一种注册模型。上述理由是空的——keyed slot 的 *key 空间*本就运行时开放(SlotMap 声明槽、从不声明 key;ask-user composer 的 `key: 'question'` 即先例),开放的 tool 名集合天然适配 `entryKey` 分发。 -落地形态(现状叙述同见[架构注](2026-07-19-gui-web-client-architecture.md)):chat 条目的 `children` 表声明 `'conversation.chat.toolview'`(keyed/session);渲染点逐行以 `entryKey: toolName` 分发、以 `GenericToolCard` 作调用点 `fallback`(默认卡片是域产权;fallback 选项就是普通 renderSlot 文法)。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openDetails`——details 是会话级设施,非 chat 私货),`ToolRowProps` 把它与 session 标配 kit 预组合供注册方组件取用。注册方就是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作加载序缝——apply 把 `ConversationService` 挂在 chat 注册*之后*,故服务在场即保证槽已声明,构造使然。会话维差异化在组件内完成(`useSessions` 读 `parentId`——决策放在已有全部信息的地方);bash 样例即第三方姿态的样板,并与 Think 绘制同一套 ToolRow chrome(`Bash · {description}`,scoped badge 仅出现在子会话)。trajectory/waterfall 的 toolview 槽共用这套形状(槽名按槽名纪律 `<域>.<条目>.<孔位>` 定死,共用一张 owner 类型),随各自的行渲染点落地——RendersCheck 拒绝无人渲染的声明,挡住提前空声明的是类型系统而非约定。 +落地形态(现状叙述同见[架构注](2026-07-19-gui-web-client-architecture.md)):chat 条目的 `children` 表声明 `'conversation.chat.toolview'`(keyed/session);渲染点逐行以 `entryKey: toolName` 分发、以 `GenericToolCard` 作调用点 `fallback`(默认卡片是域产权;fallback 选项就是普通 renderSlot 文法)。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openDetails`——details 是会话级设施,非 chat 私货),`ToolRowProps` 把它与 session 标配 kit 预组合供注册方组件取用。注册方就是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作加载序缝——apply 把 `ConversationService` 挂在 chat 注册*之后*,故服务在场即保证槽已声明,构造使然。bash 样例即第三方姿态的样板,并与 Think 绘制同一套 ToolRow chrome(`Bash · {description}`)。trajectory/waterfall 的 toolview 槽共用这套形状(槽名按槽名纪律 `<域>.<条目>.<孔位>` 定死,共用一张 owner 类型),随各自的行渲染点落地——RendersCheck 拒绝无人渲染的声明,挡住提前空声明的是类型系统而非约定。 registry 时代的职责各有后继居所:inject 缓存与行错误隔离乘框架渲染器(entry×scope 缓存、per-entry `SlotErrorBoundary`);subscribe/getVersion 乘 slot core 的 per-key 版本机;将来的「store 席位」就是 keyed slot 本就拥有的普通 store 席位(交互草稿耐久性是其首个具名消费者);miss 兜底即调用点 `fallback` 选项。 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.md b/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.md index b5dc7e820d..58960169a2 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.md @@ -14,7 +14,7 @@ The Web Session-row menu and message IconActions share the client runtime's `ses `forkAt(seq)` touches the session service only in ui-conversation's apply injection layer; message components report only the event `seq`. Session rows likewise initiate the operation only through ui-workspace's injected callback. Neither presentation package owns session mutation state or duplicates the host's boundary evaluation. -Session lineage is not projected into a list hierarchy. WorkSpace mode displays source sessions and all fork children as peer rows in the manual order from `WorkspaceView.sessionIds`; every row can be opened, searched, and dragged independently. In one list mode continues to sort strictly by `updatedAt`; the Ungrouped group also sorts by recency when no workspace ledger is available. `parentId` remains available for lineage, tool presentation, and later queries, but does not control session-list visibility. +Session lineage is not projected into a list hierarchy. WorkSpace mode displays source sessions and all fork children as peer rows in the manual order from `WorkspaceView.sessionIds`; every row can be opened, searched, and dragged independently. In one list mode continues to sort strictly by `updatedAt`; the Ungrouped group also sorts by recency when no workspace ledger is available. `parentId` remains available for lineage and later queries, but does not control session-list visibility. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.zh.md index 774cd74d69..ea2f9030f6 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.zh.md @@ -14,7 +14,7 @@ Web 的 session 行菜单与消息 IconActions 共用 client runtime 的 `sessio `forkAt(seq)` 只在 ui-conversation 的 apply 注入层接触 session 服务,消息组件只回传事件 `seq`。Session 行同理只通过 ui-workspace 的注入回调发起操作;两个呈现包都不持有 session mutation 状态,也不复制 host 的边界求值。 -Session lineage 不投影成列表层级。WorkSpace 模式按 `WorkspaceView.sessionIds` 的手动序把源会话与所有 fork 子会话显示为同级行,每行都可独立打开、搜索和拖拽;In one list 模式继续按 `updatedAt` 严格排序;Ungrouped 组在没有 workspace 账本时也按 recency 排序。`parentId` 仍用于 lineage、工具呈现和后续查询,但不控制 session 列表可见性。 +Session lineage 不投影成列表层级。WorkSpace 模式按 `WorkspaceView.sessionIds` 的手动序把源会话与所有 fork 子会话显示为同级行,每行都可独立打开、搜索和拖拽;In one list 模式继续按 `updatedAt` 严格排序;Ungrouped 组在没有 workspace 账本时也按 recency 排序。`parentId` 仍用于 lineage 和后续查询,但不控制 session 列表可见性。 ## Alternatives considered diff --git a/apps/web/tests/built-boot.snapshot.ts b/apps/web/tests/built-boot.snapshot.ts index d2f6d913dd..707d7b72a0 100644 --- a/apps/web/tests/built-boot.snapshot.ts +++ b/apps/web/tests/built-boot.snapshot.ts @@ -108,7 +108,7 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn // Opening a session reaches chat content through the fixture transport. fireEvent.click(await within(tree).findByText('Fixture 历史会话')) await waitFor(() => { - expect(document.querySelector('[data-sample="bash-global"]')).not.toBeNull() + expect(document.querySelector('[data-sample="bash"]')).not.toBeNull() }, { timeout: 10_000 }) // The write/edit turns render a real diff card through the assembled graph diff --git a/apps/web/tests/code-mode-round.e2e.ts b/apps/web/tests/code-mode-round.e2e.ts index 6a0379de94..fd103ac53d 100644 --- a/apps/web/tests/code-mode-round.e2e.ts +++ b/apps/web/tests/code-mode-round.e2e.ts @@ -112,7 +112,7 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => { // the bash sub-call landed in the bash sample registration. const nest = page.locator('[data-subcalls]').first() await nest.waitFor({ timeout: 10_000 }) - expect(await nest.locator('[data-sample="bash-global"]').count()).toBeGreaterThanOrEqual(1) + expect(await nest.locator('[data-sample="bash"]').count()).toBeGreaterThanOrEqual(1) // The failing read sub-call wears the same error state a native failed // row wears (the recorded program tolerates a read of missing.txt). expect(await nest.locator('[data-state="error"]').count()).toBeGreaterThanOrEqual(1) @@ -123,7 +123,7 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => { const nest = page.locator('[data-subcalls]').first() const frame = page.locator('[style*="grid-template-columns"]').first() expect(await frame.getAttribute('data-details-collapsed')).toBe('true') - await nest.locator('[data-sample="bash-global"]').first().click() + await nest.locator('[data-sample="bash"]').first().click() // Tool rows do not drive layout geometry; the Session's default panel stays closed. await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true') }) diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index d6fe3d0942..d8d6de9036 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -184,7 +184,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { it.skipIf(MODE === 'record')('bash and file-path rows leave the default details column closed', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-details')) await page.getByRole('tab', { name: 'Chat' }).click() - const bashRow = page.locator('[data-sample="bash-global"]').first() + const bashRow = page.locator('[data-sample="bash"]').first() await bashRow.waitFor({ timeout: 15_000 }) const frame = page.locator('[style*="grid-template-columns"]').first() expect(await frame.getAttribute('data-details-collapsed')).toBe('true') @@ -194,7 +194,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true') // The card's own controls are outside the summary row and must not open // details either — the expanded terminal card is read in place. - await page.locator('[data-sample="bash-global"] ~ div [data-terminal] [class*="_copyButton_"]').first().click() + await page.locator('[data-sample="bash"] ~ div [data-terminal] [class*="_copyButton_"]').first().click() await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true') // Read summaries are host-open file links; they also must not open details. const fileLink = page.locator('[data-variant="read"] button').first() @@ -210,10 +210,10 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { // tool-row interaction): open it if a previous case left it collapsed. // Expanded, the recorded command's own output sits in the message flow, // derived from the logged call/result presentations alone. - const bashRow = page.locator('[data-sample="bash-global"]').first() + const bashRow = page.locator('[data-sample="bash"]').first() await bashRow.waitFor({ timeout: 15_000 }) if (await bashRow.getAttribute('aria-expanded') !== 'true') await bashRow.click() - const card = page.locator('[data-sample="bash-global"] ~ div [data-terminal]').first() + const card = page.locator('[data-sample="bash"] ~ div [data-terminal]').first() await card.waitFor({ timeout: 15_000 }) // Real layout, not jsdom's stub (which computes no geometry at all): // squeeze the output pane below its content width and the line must keep diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index 4a104724fb..5161bbf85d 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/apps/web/tests/smoke-real.e2e.ts @@ -588,7 +588,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke // Bash renders through the third-party sample registration. Match that // exact row: other clickable variants (for example Think disclosure) // may precede the tool call in document order. - const toolRow = page.locator('[data-sample="bash-global"]') + const toolRow = page.locator('[data-sample="bash"]') await toolRow.waitFor({ timeout: 120_000 }) await screen(page, '08-bash-round') expect(await detailsTrack(page)).toBe(0) diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 68115f8f9b..d4387f8f05 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -28,7 +28,7 @@ The chat flow projects consecutive model-retry nodes across retry turns into one A `grep`/`glob` call declaring the `search` render intent renders its result inline, at the same render sites, through ui-primitives' `SearchBlock` — grep's matches grouped by file (each a collapsible header of `lineNumber: line` rows), glob's flat path list. `contract/search-card-model.ts` is the single derivation from the snapshot's `resultView`; unlike the terminal card it reads no `callView`, since a search has no matches or paths before `execute`, so a running search shows its summary alone. It yields null — the generic path — for any non-search result view, a `card` or `kind` this client version does not compile, and (because those ride the untrusted wire frame) a known kind whose `files`/`paths` is malformed. The keyed `SearchRow`, registered under both `grep` and `glob` since the derived `kind` decides the shape, composes the shared `ToolRow`, feeding the card as ToolRow's `search` body, so it is the row's collapsed-by-default expanded card; the render-site fallback routes it the same way. Both cap at `CHAT_SEARCH_MAX_LINES` (8) against the panel's 16. A capped search drops rows from the card, but the locator to the rest — grep/glob's `Full … stored at …` footer — lives only in the result text, so the derivation surfaces that as a recovery footer below the card when (and only when) the result was truncated; a settled call with no card at all (an errored search, a nested `run_code` sub-dispatch, a legacy generic result) surfaces its flattened result text through ToolRow's Output section so nothing is lost behind a bare summary ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md)). -Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openFile`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders). +Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openFile`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); the bash sample is the third-party-posture exemplar. Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders). The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`/ 已完成 · ` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: -1` — above the queue rows — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus `"/ tasks · in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 65a3b334ff..295954cf17 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -26,7 +26,7 @@ 声明 `search` 渲染意图的 `grep`/`glob` 调用,会在同样的渲染点上通过 ui-primitives 的 `SearchBlock` 内联渲染其结果——grep 的匹配按文件分组(每个是一个可折叠的头,下辖 `lineNumber: line` 行),glob 是扁平路径列表。`contract/search-card-model.ts` 是从快照的 `resultView` 推导的唯一位置;与终端卡片不同,它不读 `callView`,因为搜索在 `execute` 前没有匹配或路径,所以运行中的搜索只显示摘要。对任何非搜索的结果视图、当前客户端版本无法编译的 `card` 或 `kind`、以及(因为这些都与不可信的 wire 帧同行)一个 `files`/`paths` 格式错误的已知 kind,它都返回 null,落回通用路径。键控的 `SearchRow` 因推导出的 `kind` 决定形态而同时注册在 `grep` 与 `glob` 下,组合共享的 `ToolRow`,把卡片作为 ToolRow 的 `search` body 传入,因此它是该行默认折叠的展开卡片;渲染点兜底行以同样方式渲染它。两者上限都是 `CHAT_SEARCH_MAX_LINES`(8),面板为 16。被截断的搜索会从卡片里丢掉一些行,但通往其余部分的定位符——grep/glob 的 `Full … stored at …` 脚注——只存在于结果文本里,因此推导在(且仅在)结果被截断时把它作为恢复脚注画在卡片下方;一个完全没有卡片的已结算调用(出错的搜索、嵌套 `run_code` 子派发、旧日志的 generic 结果)则经 ToolRow 的 Output 区呈现其压平后的结果文本,从而不让任何内容丢失在一个光秃秃的摘要之后([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md))。 -工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openFile`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall(瀑布式事件)工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。 +工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openFile`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);bash 示例是第三方姿态的范例。Trajectory/waterfall(瀑布式事件)工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。 审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位(未实例化会话同样点亮)镜像该阻塞状态,其优先级高于运行中圆环,直至问题解决。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,其中 kebab-case 预设名渲染为 Title Case 标签;普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission `,而 `danger-full-access` 在界面中显示为 `Full access`,选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用;取消、Escape、关闭按钮与点击遮罩都不会提交命令。 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 489ad34e3f..4f3536f4b2 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -319,7 +319,7 @@ export function apply(ctx: Context): void { ctx.plugin(ConversationService, { input: inputHub }) // The bash sample rides that exact seam, in third-party posture - // (ToolRow-matching Bash · {description} chrome; scoped badge in child sessions). + // (ToolRow-matching Bash · {description} chrome). ctx.plugin(bashToolviewSample) // The read row rides the same seam (a product registration, not a sample): diff --git a/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css b/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css index fa607a0880..ef9f246dd7 100644 --- a/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css +++ b/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css @@ -99,17 +99,6 @@ opacity: 1; } -.scopeBadge { - flex: none; - margin-right: 8px; - padding: 0 6px; - border-radius: 6px; - font-size: 11px; - line-height: 18px; - color: var(--dsw-alias-label-primary-foreground); - background: var(--dsw-alias-state-business-primary); -} - .title { flex: none; font-size: 14px; diff --git a/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx b/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx index 06eb42f741..5a3e3f40fe 100644 --- a/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx @@ -1,8 +1,6 @@ // Bash toolview registrant: third-party posture over the keyed toolview hole // (ctx.slots.register + ToolRowProps only — never imports the chat domain). // Product chrome matches ToolRow / Think (figma: Bash · {description}). -// Child sessions keep a scoped badge so session-dimension differentiation stays -// observable inside the component (no parallel registry). // // A bash call declares the terminal render intent, so this row renders the // command's own output through TerminalBlock — expand-gated exactly like @@ -64,7 +62,6 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }: const state = model.state === 'ok' && terminal !== null && terminalFailed(terminal) ? 'error' : model.state - const isChild = useSessions(list => list.byId[sessionId]?.parentId !== undefined) const status = stateStatus(state, t) const [expanded, setExpanded] = useState(false) const expandable = terminal !== null @@ -92,7 +89,7 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }:
{leading} {status !== null && {status}} - {isChild && scoped} {model.title} {/* The terminal presenter's description is the contractual diff --git a/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx b/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx index 3ddaef873e..71141bcb1b 100644 --- a/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx +++ b/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx @@ -149,7 +149,7 @@ describe('terminal card assembly', () => { const view = runtime.renderRoot() // Keyed BashRow: collapsed by default, the whole summary row is the toggle. - const keyedRow = view.container.querySelector('[data-sample="bash-global"]') + const keyedRow = view.container.querySelector('[data-sample="bash"]') const keyed = keyedRow?.parentElement expect(keyed?.querySelector('[data-terminal]')).toBeNull() fireEvent.click(keyedRow!) diff --git a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx index bafc6fe709..eaae31f72d 100644 --- a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx @@ -181,7 +181,7 @@ describe('run_code sub-calls through the real chat machinery', () => { // sub-tool fell back to GenericToolCard at the same render site. const nest = view.container.querySelector('[data-subcalls]') expect(nest).not.toBeNull() - expect(nest!.querySelector('[data-sample="bash-global"]')).not.toBeNull() + expect(nest!.querySelector('[data-sample="bash"]')).not.toBeNull() expect(view.getByText('Bash')).toBeTruthy() expect(view.getByText('List notes')).toBeTruthy() expect(view.getByText('Tool call')).toBeTruthy() @@ -264,7 +264,7 @@ describe('run_code sub-calls through the real chat machinery', () => { expect(running).not.toBeNull() const nest = view.container.querySelector('[data-subcalls]') expect(nest).not.toBeNull() - expect(nest!.querySelector('[data-sample="bash-global"]')).not.toBeNull() + expect(nest!.querySelector('[data-sample="bash"]')).not.toBeNull() }) it('a started-but-unsettled sub-call renders the running state exactly like a native in-flight row', async () => { diff --git a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx index 512ca2a86c..2220aa3048 100644 --- a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx @@ -1,8 +1,7 @@ // @vitest-environment jsdom // StatsLine (composer.dock entry): totals derivation + the RFC -// hard acceptance — zero renders during streaming. Bash sample row: the -// canonical sub-agent differential decided INSIDE the component off the -// standard useSessions kit (no registry predicates — tool ring dissolved). +// hard acceptance — zero renders during streaming. Bash sample row: ToolRow +// chrome (Bash · description) without a row click target. import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, render } from '@testing-library/react' @@ -232,8 +231,7 @@ describe('StatsLine', () => { }) describe('bash sample row', () => { - const ROOT = 'root-1' as SessionId - const CHILD = 'child-1' as SessionId + const SID = 'root-1' as SessionId const result = (callId: string): ToolResultNode => ({ kind: 'tool-result', seq: 3, time: 3_000, callId, @@ -242,68 +240,30 @@ describe('bash sample row', () => { content: [], isError: false, callView: null, resultView: null, }) - /** Real list-store engine: the family fixture the in-component parentId branch reads. */ function listStore() { return createSnapshotStore({ - ids: [ROOT, CHILD], + ids: [SID], byId: { - [ROOT]: { id: ROOT, title: 'r', displayTitle: 'r', running: false, waitingApproval: false, blank: false, updatedAt: 0 }, - [CHILD]: { id: CHILD, title: 'c', displayTitle: 'c', parentId: ROOT, running: false, waitingApproval: false, blank: false, updatedAt: 0 }, + [SID]: { id: SID, title: 'r', displayTitle: 'r', running: false, waitingApproval: false, blank: false, updatedAt: 0 }, }, current: undefined, phase: 'ready', }) } - const rowProps = (sessionId: SessionId, over?: { - store?: ReturnType - }): BashRowProps => ({ + const rowProps = (): BashRowProps => ({ callId: 'c1', toolName: 'bash', block: result('c1'), openFile: vi.fn(), - sessionId, - useSessions: bindSnapshotSelector(over?.store ?? listStore()), + sessionId: SID, + useSessions: bindSnapshotSelector(listStore()), t, } as unknown as BashRowProps) - it('differential rendering: the scoped variant in sub-sessions, global at roots', () => { - const scoped = render() - expect(scoped.container.querySelector('[data-sample="bash-scoped"]')).not.toBeNull() - expect(scoped.getByText('scoped')).toBeTruthy() - const plain = render() - expect(plain.container.querySelector('[data-sample="bash-global"]')).not.toBeNull() - }) - - it('a session outside the list renders the global arm (no parent known)', () => { - const view = render() - expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull() - }) - - it('a live parentId write flips the row to the scoped variant (store subscription)', () => { - const store = listStore() - const orphan = 'late-child' as SessionId - store.update((d) => { - d.ids.push(orphan) - d.byId[orphan] = { id: orphan, title: 'l', displayTitle: 'l', running: false, waitingApproval: false, blank: false, updatedAt: 0 } - }) - const view = render() - expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull() - act(() => { - store.update((d) => { d.byId[orphan]!.parentId = ROOT }) - }) - expect(view.container.querySelector('[data-sample="bash-scoped"]')).not.toBeNull() - }) - - it('summarizes as Bash · description on both arms without row click targets', () => { - const global = render() - // Two renders share document.body: query inside each container. - const globalRow = global.container.querySelector('[data-sample="bash-global"]')! - expect(globalRow.textContent).toContain('Bash') - expect(globalRow.textContent).toContain('Build') - expect(globalRow.getAttribute('data-clickable')).toBeNull() - const scoped = render() - const scopedRow = scoped.container.querySelector('[data-sample="bash-scoped"]')! - expect(scopedRow.textContent).toContain('Bash') - expect(scopedRow.textContent).toContain('Build') - expect(scopedRow.getAttribute('data-clickable')).toBeNull() + it('summarizes as Bash · description without a row click target', () => { + const view = render() + const row = view.container.querySelector('[data-sample="bash"]')! + expect(row.textContent).toContain('Bash') + expect(row.textContent).toContain('Build') + expect(row.getAttribute('data-clickable')).toBeNull() }) }) diff --git a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx index eb48677d4f..34f2ff9cfd 100644 --- a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx @@ -91,7 +91,7 @@ describe('keyed toolview hole through the real machinery', () => { const view = b.runtime.renderRoot() // bash: the sample plugin's keyed registration took the row (root // session → global arm, decided inside the component off useSessions). - expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull() + expect(view.container.querySelector('[data-sample="bash"]')).not.toBeNull() expect(view.getByText('Bash')).toBeTruthy() expect(view.getByText('Build')).toBeTruthy() // mystery: no registration under that key → render-site fallback. diff --git a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx index 30ca8bccf7..24c900deeb 100644 --- a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx @@ -89,7 +89,7 @@ describe('tails', () => { expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull() }) - it('BashRow carries data-state for running (row sweep) and StateDots for error/stopped (root session arm)', () => { + it('BashRow carries data-state for running (row sweep) and StateDots for error/stopped', () => { const sid = 'root-1' as SessionId const list = createSnapshotStore({ ids: [sid], @@ -125,7 +125,7 @@ describe('tails', () => { runningView.unmount() const errorView = render() - expect(errorView.container.querySelector('[data-sample="bash-global"]')).not.toBeNull() + expect(errorView.container.querySelector('[data-sample="bash"]')).not.toBeNull() expect(errorView.container.querySelector('[data-state="error"]')).not.toBeNull() expect(errorView.getByText('失败')).toBeTruthy() errorView.unmount() From c3e4aeca90b37cc515ae445152424a206e3e21ab Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Fri, 31 Jul 2026 10:49:03 +0800 Subject: [PATCH 05/19] fix: missing notes --- .../implemented/architecture/2026-07-23-toolview-dissolution.md | 2 +- .../architecture/2026-07-23-toolview-dissolution.zh.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.md b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.md index a695f98cb2..406e5c181a 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.md +++ b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.md @@ -20,7 +20,7 @@ Registry-era responsibilities all have successor homes: inject caching and row e ## Accepted semantic changes -Four behavioral deltas were accepted deliberately, not overlooked. Cross-view appearance is per-view registration — a row must adapt to each view's layout anyway, so one registration per view is the correct coupling, and reuse is the same component in two register calls. Same-key double registration is a loud throw where the registry let later-wins silently override — a discipline correction, not a loss. Session-dimension dispatch moved from registry predicates into the component. Registry-level shape override by third parties (a scoped registration shadowing a global one) has no equivalent; a real future need routes through key-naming conventions or a small in-component resolver, never a revived parallel registry. +Four behavioral deltas were accepted deliberately, not overlooked. Cross-view appearance is per-view registration — a row must adapt to each view's layout anyway, so one registration per view is the correct coupling, and reuse is the same component in two register calls. Same-key double registration is a loud throw where the registry let later-wins silently override — a discipline correction, not a loss. Session-dimension dispatch, when a row needs it, belongs inside the component (the standard kit already carries `useSessions`), not in registry predicates — there is no shipped session-variant exemplar today. Registry-level shape override by third parties (a scoped registration shadowing a global one) has no equivalent; a real future need routes through key-naming conventions or a small in-component resolver, never a revived parallel registry. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.zh.md b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.zh.md index e79f80216a..311affcbd9 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.zh.md @@ -20,7 +20,7 @@ registry 时代的职责各有后继居所:inject 缓存与行错误隔离乘 ## 接受的语义变化 -四项行为增量是刻意接受而非疏漏。跨视图出场=逐视图注册——行本须适配各视图版式,一视图一注册是正确耦合,复用即同一组件写两次 register。同 key 重复注册从注册表的 later-wins 静默覆盖变为 loud throw——纪律修正而非损失。会话维分发从注册表谓词移入组件。第三方在 registry 级覆盖形态(scoped 注册压过 global)不复存在;真出现的未来需求走 key 命名空间约定或组件内小 resolver,永不复活平行注册表。 +四项行为增量是刻意接受而非疏漏。跨视图出场=逐视图注册——行本须适配各视图版式,一视图一注册是正确耦合,复用即同一组件写两次 register。同 key 重复注册从注册表的 later-wins 静默覆盖变为 loud throw——纪律修正而非损失。会话维分发若行需要,归组件内部(标配 kit 已带 `useSessions`),不走注册表谓词——今天没有已落地的会话变体样例。第三方在 registry 级覆盖形态(scoped 注册压过 global)不复存在;真出现的未来需求走 key 命名空间约定或组件内小 resolver,永不复活平行注册表。 ## Alternatives considered From 84255e1d7157e8fd6c2becf8f230a198043dd08b Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Fri, 31 Jul 2026 11:29:44 +0800 Subject: [PATCH 06/19] fix: ci --- .../architecture/2026-07-23-toolview-dissolution.i18n.yaml | 6 +++--- .../feature/2026-07-27-web-session-fork-actions.i18n.yaml | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.i18n.yaml index 2cba925d67..20745458d6 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.i18n.yaml @@ -1,6 +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 -2026-07-23-toolview-dissolution.md: 80c2688b152d1afe1236d4815633a5bf024db1d2 -2026-07-23-toolview-dissolution.zh.md: 928c5f445d601b2246d3ae2f9360232643814468 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.md +2026-07-23-toolview-dissolution.md: 406e5c181aabb635f9d6dcb12d8a9b8b6697368e +2026-07-23-toolview-dissolution.zh.md: 311affcbd9605ff81b78e974f75ee83328d93f68 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.i18n.yaml index e4b5cd7778..21eea20254 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.md -2026-07-27-web-session-fork-actions.md: b5dc7e820de069a68b38ed87c7d29ffbdb4867bc -2026-07-27-web-session-fork-actions.zh.md: 774cd74d69eb02d43ca01c8ec7ba1cf94c24b1dc +2026-07-27-web-session-fork-actions.md: 58960169a2e499d953840e5769e7689b5cd48047 +2026-07-27-web-session-fork-actions.zh.md: ea2f9030f672f00fb91bce3546836689a7d41004 From 0c59e3e0892be3ffe5886b8c55e94fa0003bcee6 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Fri, 31 Jul 2026 19:15:40 +0800 Subject: [PATCH 07/19] fix: cr --- apps/web/tests/search-card.snapshot.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tests/search-card.snapshot.ts b/apps/web/tests/search-card.snapshot.ts index 2bfa4f9244..0315a29e37 100644 --- a/apps/web/tests/search-card.snapshot.ts +++ b/apps/web/tests/search-card.snapshot.ts @@ -143,7 +143,7 @@ describe('assembled search card', () => { // Wait for chat content to reach the fixture's later turns (the bash sample // is turn 65, the grep card turn 66). await waitFor(() => { - expect(document.querySelector('[data-sample="bash-global"]')).not.toBeNull() + expect(document.querySelector('[data-sample="bash"]')).not.toBeNull() }, { timeout: 10_000 }) // The grep turn's keyed SearchRow composes ToolRow: the card is collapsed // by default, so wait for the summary row, then expand it to reach the card. From ddda8be7367a16c3a232d5ddbd1b04c7307ad54c Mon Sep 17 00:00:00 2001 From: kingwl Date: Fri, 31 Jul 2026 20:48:52 +0800 Subject: [PATCH 08/19] docs: record scoped bash translation pairs --- .../2026-07-19-gui-web-client-architecture.i18n.yaml | 4 ++-- packages/client/ui-conversation/README.i18n.yaml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml index 4530d5ee57..61e6a93e23 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.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-19-gui-web-client-architecture.md -2026-07-19-gui-web-client-architecture.md: 63b6f5795c3d49f25cd964cf04a0c9d41a667bfb -2026-07-19-gui-web-client-architecture.zh.md: 2d57c12ebae38aafa4e606da95af954990761b3c +2026-07-19-gui-web-client-architecture.md: b1f777172774f1cf8fef4d9494f15b38064d0c73 +2026-07-19-gui-web-client-architecture.zh.md: e43151b7d5ff096d574c786e3aae107523d22c96 diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index f8ac552846..0ef0573ed6 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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-conversation/README.md -README.md: 68115f8f9b9225d1f6b0e9cc93394d042c2befaa -README.zh.md: 65a3b334ff316a37dfb8506396eae8c17cccb40d +README.md: d4387f8f0547e81211f6d33cb806456b6441066f +README.zh.md: 295954cf172b5f6c46fd70a992a7680183fed028 From f3a1ff41b7e168df0cae4c926f14b8a388d623ec Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 31 Jul 2026 21:17:28 +0800 Subject: [PATCH 09/19] cleanup(install): drop the master.path record Nothing read it. The launcher, dsh-upgrade, and the installer's own re-run all ignored the file, and the diagnostic it was meant to feed was never built, so it was write-only state. Git already owns the fact it recorded: a staging worktree's .git file names the repository path, and `git worktree list` in that clone enumerates every worktree depending on it. An installer-written copy only adds state that can go stale while nothing validates it. The containment caveat it documented is real and stays in the script header and the Agent Note, now pointing at git's own records. --- ...31-installer-adopts-existing-checkout.i18n.yaml | 4 ++-- ...026-07-31-installer-adopts-existing-checkout.md | 8 ++++---- ...-07-31-installer-adopts-existing-checkout.zh.md | 8 ++++---- scripts/install.sh | 14 ++------------ 4 files changed, 12 insertions(+), 22 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml index c4689a5f4c..fa13789c9a 100644 --- a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md -2026-07-31-installer-adopts-existing-checkout.md: 75f71d1dc7f7d84674c7f11ec1affe616acbd0b2 -2026-07-31-installer-adopts-existing-checkout.zh.md: 381a3c0f67aa20198caa25b558f6d6d1b5591413 +2026-07-31-installer-adopts-existing-checkout.md: 5eede5d476d21c9f2bf0b63365eab9ac705bad60 +2026-07-31-installer-adopts-existing-checkout.zh.md: a137c585de7da5b1ccc1167c2c8e9d1ca939298b diff --git a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md index 75f71d1dc7..5eede5d476 100644 --- a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md +++ b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md @@ -16,11 +16,11 @@ In-repo mode still never clones and never modifies the working tree, but it now The container owns staging worktrees and `current`; the repository is *discovered*, not owned. `git rev-parse --git-common-dir` resolves the shared git directory behind the checkout — for a linked worktree that is the real clone rather than the worktree itself — and its parent is the repository that serves as the upgrade base. A staging worktree branched from the checkout's `HEAD` is then created under `$DSH_SOURCE`, and `current` points at it. A clone anywhere on disk therefore converges on the same layout as a `curl` install, and the two paths share one worktree/exclude/lock/link sequence: they differ only in whether the repository was discovered by `git clone` or by `git rev-parse`. -`$DSH_SOURCE/master.path` records the resolved repository, and only when that repository lives outside the container. A container holding its own master is self-contained and gets no file, so the file's presence is itself the signal that this container depends on an outside path: each staging worktree holds an absolute gitdir pointer into that clone, so deleting the clone breaks them. +The installer records nothing about where that repository lives. A container whose repository sits outside it is not self-contained — each staging worktree holds an absolute gitdir pointer into that clone, so deleting the clone breaks them — but git already owns that fact: the worktree's `.git` file names the path, and `git worktree list` in the clone enumerates every worktree depending on it. Adoption branches from `HEAD`, so committed work is what runs and uncommitted changes stay in the checkout. This is not prompted or warned about: the installer builds the layout and gets out of the way. Setting `DSH_SOURCE` to a different directory remains the one documented way to opt back into cloning a separate tree. -Every path comparison runs on physical paths through a `resolve_dir` helper, and every compared value is resolved at assignment rather than at the comparison. macOS resolves `/var` through a symlink to `/private/var`, so comparing a git-reported path against an unresolved one misclassified an existing managed install as a foreign clone and would have built a second container beside the real one. The same defect recurred twice more during review — once where a curl install's `REPO_ROOT` stayed unresolved and wrote a spurious `master.path`, and once where `x=$(resolve_dir …) || x=$fallback` left an empty path because the assignment succeeds even when the substitution fails. `resolve_dir` therefore echoes a missing path back itself, and callers that need "does not exist" test the directory explicitly. `git rev-parse --path-format=absolute` would do the same job but requires git 2.31+. +Every path comparison runs on physical paths through a `resolve_dir` helper, and every compared value is resolved at assignment rather than at the comparison. macOS resolves `/var` through a symlink to `/private/var`, so comparing a git-reported path against an unresolved one misclassified an existing managed install as a foreign clone and would have built a second container beside the real one. The same defect recurred twice more during review — once where a curl install's `REPO_ROOT` stayed unresolved and so compared unequal against every resolved path, and once where `x=$(resolve_dir …) || x=$fallback` left an empty path because the assignment succeeds even when the substitution fails. `resolve_dir` therefore echoes a missing path back itself, and callers that need "does not exist" test the directory explicitly. `git rev-parse --path-format=absolute` would do the same job but requires git 2.31+. Before `current` is repointed, the installer rejects a staging path that resolves to the repository itself, enforcing the upgrade contract that the launcher never resolves to the master clone. @@ -42,10 +42,10 @@ One layout now serves every install, so an adopted clone is upgradable by `dsh-u The cost is that a contributor can no longer point PATH at a checkout and have `dsh` follow that working tree as they switch branches: the launcher now resolves to a staging worktree pinned to the `HEAD` adopted at install time. Re-running the installer adopts the current `HEAD` again. -The cost is that a container adopting an outside clone is no longer self-contained: deleting that clone breaks its staging worktrees. This is inherent to reusing an existing clone rather than a property of this design — the rejected symlink hides it rather than fixing it — and `master.path` is the mitigation, not a repair. +A container adopting an outside clone is also no longer self-contained: deleting that clone breaks its staging worktrees. This is inherent to reusing an existing clone rather than a property of this design — the rejected symlink hides it rather than fixing it — and git's own worktree records are what diagnose it. ## Testing `scripts/install.sh` has no automated test, and this change does not add one: the user directed that `install.spec.ts` be left out of scope. That is a known gap on a shipped user-facing path, and the `/var` resolution defect above is exactly the class of bug a test would have caught first. The standing [`FIXME(install-ts)`](../../../../scripts/install.sh) asking for this workflow to move into a tested TypeScript entrypoint is correspondingly more pressing. -Verification was manual, through a throwaway harness driving the real script with a stubbed `pnpm`: adopting a standalone clone; adopting from a linked worktree into its existing container; an explicit `DSH_SOURCE` still opting back into cloning; a dirty tree adopting silently with no prompt or warning while its uncommitted file stays behind; a non-git checkout failing with guidance; and a `curl`-style clone install asserting both the built layout and the absence of `master.path`, which is the regression that caught the unresolved-`REPO_ROOT` defect. The interactive path was exercised under tmux from a dirty checkout, confirming the run reaches the launcher with no adoption prompt and ends with `dsh` running from the new staging worktree while the original checkout keeps its branch and its uncommitted file. +Verification was manual, through a throwaway harness driving the real script with a stubbed `pnpm`: adopting a standalone clone; adopting from a linked worktree into its existing container; an explicit `DSH_SOURCE` still opting back into cloning; a dirty tree adopting silently with no prompt or warning while its uncommitted file stays behind; a non-git checkout failing with guidance; and a `curl`-style clone install asserting the built layout, which is the regression that caught the unresolved-`REPO_ROOT` defect. The interactive path was exercised under tmux from a dirty checkout, confirming the run reaches the launcher with no adoption prompt and ends with `dsh` running from the new staging worktree while the original checkout keeps its branch and its uncommitted file. diff --git a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md index 381a3c0f67..a137c585de 100644 --- a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md +++ b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md @@ -16,11 +16,11 @@ Status: implemented 容器拥有 staging worktree 和`current`;仓库是被*发现*的,而非被拥有的。`git rev-parse --git-common-dir`会解析出该检出背后的共享 git 目录——对于 linked worktree,那是真正的克隆而非 worktree 自身——其父目录即是充当升级基础的仓库。随后以该检出的`HEAD`为起点,在`$DSH_SOURCE`下创建 staging worktree,并让`current`指向它。因此,磁盘上任意位置的克隆都会收敛到与`curl`安装相同的布局,且两条路径共用同一套 worktree/exclude/lock/link 流程:二者的唯一差别,只在于仓库是由`git clone`发现的,还是由`git rev-parse`发现的。 -`$DSH_SOURCE/master.path`记录解析出的仓库,且仅在该仓库位于容器之外时才记录。拥有自身 master 的容器是自包含的,不会生成该文件;因此该文件的存在本身就是一个信号,表明此容器依赖于外部路径:每个 staging worktree 都持有指向该克隆的绝对 gitdir 指针,删除该克隆就会破坏它们。 +安装器不会记录该仓库位于何处。仓库位于容器之外时,容器就不是自包含的——每个 staging worktree 都持有指向该克隆的绝对 gitdir 指针,删除该克隆就会破坏它们——但这一事实本就由 git 自己掌握:worktree 的`.git`文件写明了该路径,而在该克隆中执行`git worktree list`会列出依赖于它的每一个 worktree。 接管以`HEAD`为分支起点,因此运行的是已提交的内容,未提交的更改仍留在检出中。这一点既不提示也不警告:安装器构建好布局后便不再打扰。把`DSH_SOURCE`设为其他目录,仍是唯一有文档记载的、回到克隆另一棵树的方式。 -所有路径比较都通过`resolve_dir`辅助函数在物理路径上进行,且每个参与比较的值都在赋值时解析,而非在比较时解析。macOS 会把`/var`经符号链接解析为`/private/var`,因此拿 git 报告的路径与未解析的路径相比较,会把已有的受管安装误判为外来克隆,并在真正的容器旁再建一个容器。同一缺陷在评审过程中又出现了两次——一次是 curl 安装的`REPO_ROOT`未经解析,导致写出多余的`master.path`;另一次是`x=$(resolve_dir …) || x=$fallback`留下了空路径,因为即使命令替换失败,赋值本身仍然成功。因此`resolve_dir`会在路径不存在时原样回显该路径,而需要判断"不存在"的调用方则显式检测该目录。`git rev-parse --path-format=absolute`能完成同样的工作,但要求 git 2.31 及以上版本。 +所有路径比较都通过`resolve_dir`辅助函数在物理路径上进行,且每个参与比较的值都在赋值时解析,而非在比较时解析。macOS 会把`/var`经符号链接解析为`/private/var`,因此拿 git 报告的路径与未解析的路径相比较,会把已有的受管安装误判为外来克隆,并在真正的容器旁再建一个容器。同一缺陷在评审过程中又出现了两次——一次是 curl 安装的`REPO_ROOT`未经解析,从而与所有已解析路径比较时均不相等;另一次是`x=$(resolve_dir …) || x=$fallback`留下了空路径,因为即使命令替换失败,赋值本身仍然成功。因此`resolve_dir`会在路径不存在时原样回显该路径,而需要判断"不存在"的调用方则显式检测该目录。`git rev-parse --path-format=absolute`能完成同样的工作,但要求 git 2.31 及以上版本。 在重指`current`之前,安装器会拒绝解析结果等于仓库自身的 staging 路径,以此落实"启动器绝不解析到 master 克隆"这一升级契约。 @@ -42,10 +42,10 @@ Status: implemented 代价是:贡献者不能再把 PATH 指向某个检出、并让`dsh`随其切换分支而跟随该工作树;启动器现在解析到的是一个固定在安装时所接管`HEAD`上的 staging worktree。重新运行安装器会再次接管当前的`HEAD`。 -代价是:接管外部克隆的容器不再自包含——删除该克隆会破坏其 staging worktree。这是复用已有克隆的固有属性,而非本设计带来的性质——被否决的符号链接方案只是掩盖它,而非修复它——`master.path`是缓解措施,不是修复。 +此外,接管外部克隆的容器不再自包含:删除该克隆会破坏其 staging worktree。这是复用已有克隆的固有属性,而非本设计带来的性质——被否决的符号链接方案只是掩盖它,而非修复它——诊断依据则是 git 自身的 worktree 记录。 ## Testing `scripts/install.sh`没有自动化测试,本次变更也未添加:用户明确要求把`install.spec.ts`排除在范围之外。这是一条已交付的、面向用户的安装路径上的已知缺口,而上文那个`/var`解析缺陷,恰恰属于测试本应最先捕获的那类 bug。相应地,要求把这套流程迁移到有测试覆盖的 TypeScript 入口的既有[`FIXME(install-ts)`](../../../../scripts/install.sh)也变得更为紧迫。 -验证是手工完成的,通过一个一次性测试装置以打桩的`pnpm`驱动真实脚本:接管独立克隆;从 linked worktree 接管进其已有容器;显式`DSH_SOURCE`仍回到克隆路径;工作树不干净时静默接管、既不提示也不警告,且其未提交文件留在原处;非 git 检出失败并给出指引;以及`curl`式克隆安装同时断言所构建的布局和`master.path`的缺失——正是这项回归测试捕获了`REPO_ROOT`未解析的缺陷。交互路径在 tmux 下从一个不干净的检出走通,确认整个过程不出现接管提示即可到达启动器,最终`dsh`从新的 staging worktree 运行,而原检出保持其分支不变、未提交文件仍在。 +验证是手工完成的,通过一个一次性测试装置以打桩的`pnpm`驱动真实脚本:接管独立克隆;从 linked worktree 接管进其已有容器;显式`DSH_SOURCE`仍回到克隆路径;工作树不干净时静默接管、既不提示也不警告,且其未提交文件留在原处;非 git 检出失败并给出指引;以及`curl`式克隆安装断言所构建的布局——正是这项回归测试捕获了`REPO_ROOT`未解析的缺陷。交互路径在 tmux 下从一个不干净的检出走通,确认整个过程不出现接管提示即可到达启动器,最终`dsh`从新的 staging worktree 运行,而原检出保持其分支不变、未提交文件仍在。 diff --git a/scripts/install.sh b/scripts/install.sh index b26beccf2c..7393639a85 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -32,8 +32,8 @@ # # Adopting an arbitrary clone leaves the container not self-contained: its # staging worktrees hold an absolute gitdir pointer into that clone, so deleting -# it breaks them. $DSH_SOURCE/master.path records the resolved clone so the -# breakage is diagnosable. +# it breaks them. `git worktree list` in that clone is the record of which +# worktrees depend on it. # # When run through `curl | sh` the script text arrives on stdin, so every # prompt and the final launch read the controlling terminal (/dev/tty) directly; @@ -304,16 +304,6 @@ if [ -f "$_exclude" ] && ! grep -qxF '.agents/merge.lock' "$_exclude" 2>/dev/nul fi mkdir -p "$DSH_STAGING/.agents" : >"$DSH_STAGING/.agents/merge.lock" -# A staging worktree holds an absolute gitdir pointer into the repository, so -# a container whose repository lives OUTSIDE it is not self-contained: deleting -# that repository breaks every worktree here. Record it only in that case, so -# the file's presence itself means "this container depends on an outside path". -_src_resolved=$(resolve_dir "$DSH_SOURCE") -case "$REPO_ROOT/" in - "$_src_resolved"/*) ;; - *) printf '%s\n' "$REPO_ROOT" >"$DSH_SOURCE/master.path" - info "recorded external repository in $DSH_SOURCE/master.path" ;; -esac # --- 3. install dependencies (no build; the launcher runs from source) -------- step "Installing dependencies with pnpm (this can take a while)" From adb88ad36d254ae024b4f4c28e74d13ad84b7bf5 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 31 Jul 2026 22:14:32 +0800 Subject: [PATCH 10/19] docs(skills): the master clone may live outside the container Adoption installs the master wherever the adopted clone already is, so dsh-upgrade and dsh-customize can no longer state /master as fact. Both skills already derive the master from the launcher, so the procedures hold; only the layout description was wrong. dsh-upgrade now names `git rev-parse --git-common-dir` as the way to resolve it. The legacy-migration clauses stay: installs made before this change can still link PATH straight at a worktree. --- skills/dsh-customize/SKILL.md | 2 +- skills/dsh-upgrade/SKILL.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/skills/dsh-customize/SKILL.md b/skills/dsh-customize/SKILL.md index 74da68b578..c94723acd1 100644 --- a/skills/dsh-customize/SKILL.md +++ b/skills/dsh-customize/SKILL.md @@ -12,7 +12,7 @@ Make personal DSH changes in task worktrees and integrate them under the staging Do not assume a path or branch name. DSH is usually installed from source with a personal staging branch; create one for the user only when none exists. 1. Inspect `command -v dsh` in the user's launch environment before resolving symlinks. -2. Follow the launcher through the full symlink chain to identify the source checkout. The standard [`scripts/install.sh`](../../scripts/install.sh) keeps every checkout under one container `${DSH_SOURCE}` (default `~/.dsh/source`): the master clone at `${DSH_SOURCE}/master` and each staging checkout as a git worktree `${DSH_SOURCE}/staging-`. `${DSH_BIN_DIR}/dsh` links to `${DSH_SOURCE}/current/bin/dsh`, and the stable `current` symlink points at the active staging worktree, so resolve `current` to reach the real checkout. All paths are configurable; an older install may link PATH straight at a worktree (no `current`) or use scattered sibling clones — follow the launcher rather than assuming a layout. +2. Follow the launcher through the full symlink chain to identify the source checkout. The standard [`scripts/install.sh`](../../scripts/install.sh) keeps staging checkouts under one container `${DSH_SOURCE}` (default `~/.dsh/source`), each a git worktree `${DSH_SOURCE}/staging-`. The master clone is at `${DSH_SOURCE}/master` for a `curl` install, but installing from an existing clone adopts that clone as the master wherever it lives, so derive it from the checkout rather than assuming it sits in the container. `${DSH_BIN_DIR}/dsh` links to `${DSH_SOURCE}/current/bin/dsh`, and the stable `current` symlink points at the active staging worktree, so resolve `current` to reach the real checkout. All paths are configurable; an older install may link PATH straight at a worktree (no `current`) or use scattered sibling clones — follow the launcher rather than assuming a layout. 3. Verify the checkout with Git, then record its branch, tip, status, remotes, worktrees, in-progress operations, and applicable `AGENTS.md` files. 4. Treat the launcher checkout's branch as staging unless the user says otherwise. The installed launcher must resolve to a staging worktree on a staging branch, never the master clone or a task, preparation, review, publication, or detached checkout. Ask if the launcher, checkout, or branch ownership is ambiguous; warn explicitly for a detached HEAD, the master clone, or a non-staging branch. diff --git a/skills/dsh-upgrade/SKILL.md b/skills/dsh-upgrade/SKILL.md index 7570672cc8..307bfa3f55 100644 --- a/skills/dsh-upgrade/SKILL.md +++ b/skills/dsh-upgrade/SKILL.md @@ -9,7 +9,7 @@ Prepare and validate the upgrade in a fresh staging worktree of the master clone ## Layout -A source-installed DSH keeps every checkout under one container directory `` (default `~/.dsh/source`): the master clone at `/master` (remote tracking `master`, the fetch/upgrade base, never a launcher target) and each staging checkout as a git worktree `/staging-` on branch `dsh-staging/`. The stable symlink `/current` points at the active staging worktree, and the PATH launcher links to `/current/bin/dsh`, so the launcher resolves PATH -> `current` -> staging worktree. Cutover repoints `current` alone; the PATH launcher is written once at install and never moves. All worktrees share the master clone's single `.git` object store; the master clone's `.git/info/exclude` is inherited by every linked worktree, so one `.agents/merge.lock` entry there excludes the lock in all of them. An older install may link PATH straight at a worktree (no `current`) or use scattered sibling clones; if so, follow the recorded launcher checkout rather than assuming this layout, treat that sibling clone as its own master, and create `current` and repoint PATH to `current/bin/dsh` as a one-time migration at cutover. +A source-installed DSH keeps its staging checkouts and `current` under one container directory `` (default `~/.dsh/source`): each staging checkout is a git worktree `/staging-` on branch `dsh-staging/`. The master clone — remote tracking `master`, the fetch/upgrade base, never a launcher target — is at `/master` for a `curl` install, but the container owns worktrees rather than the repository: installing from an existing clone adopts that clone as the master wherever it already lives, so resolve it with `git rev-parse --git-common-dir` from the staging worktree instead of assuming a path. The stable symlink `/current` points at the active staging worktree, and the PATH launcher links to `/current/bin/dsh`, so the launcher resolves PATH -> `current` -> staging worktree. Cutover repoints `current` alone; the PATH launcher is written once at install and never moves. All worktrees share the master clone's single `.git` object store; the master clone's `.git/info/exclude` is inherited by every linked worktree, so one `.agents/merge.lock` entry there excludes the lock in all of them. An older install may link PATH straight at a worktree (no `current`) or use scattered sibling clones; if so, follow the recorded launcher checkout rather than assuming this layout, treat that sibling clone as its own master, and create `current` and repoint PATH to `current/bin/dsh` as a one-time migration at cutover. ## Names From 2c29fedaf96ae01d0d0d57e6aa8605c43f6e7ea1 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 31 Jul 2026 22:21:55 +0800 Subject: [PATCH 11/19] docs(skills): call it the main clone, not the master clone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Master clone" named the repository after a branch it need not be on. An adopted clone keeps whatever branch it had — verified: adopting a clone checked out on a feature branch leaves it there — so the name was wrong for every install that did not come from curl. Renamed to "main clone" in dsh-upgrade and dsh-customize, describing its actual role: the one real clone whose object store every worktree shares. dsh-upgrade also now says not to assume the main clone sits on `master` or that its `origin` is authoritative upstream, since an adopted clone may point at a fork. The fetch itself was already correct: step 1 resolves authoritative upstream separately, and step 4 fetches upstream `master` from it rather than from the clone's own branch. --- skills/dsh-customize/SKILL.md | 4 ++-- skills/dsh-upgrade/SKILL.md | 20 ++++++++++---------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/skills/dsh-customize/SKILL.md b/skills/dsh-customize/SKILL.md index c94723acd1..bca2c48b18 100644 --- a/skills/dsh-customize/SKILL.md +++ b/skills/dsh-customize/SKILL.md @@ -12,9 +12,9 @@ Make personal DSH changes in task worktrees and integrate them under the staging Do not assume a path or branch name. DSH is usually installed from source with a personal staging branch; create one for the user only when none exists. 1. Inspect `command -v dsh` in the user's launch environment before resolving symlinks. -2. Follow the launcher through the full symlink chain to identify the source checkout. The standard [`scripts/install.sh`](../../scripts/install.sh) keeps staging checkouts under one container `${DSH_SOURCE}` (default `~/.dsh/source`), each a git worktree `${DSH_SOURCE}/staging-`. The master clone is at `${DSH_SOURCE}/master` for a `curl` install, but installing from an existing clone adopts that clone as the master wherever it lives, so derive it from the checkout rather than assuming it sits in the container. `${DSH_BIN_DIR}/dsh` links to `${DSH_SOURCE}/current/bin/dsh`, and the stable `current` symlink points at the active staging worktree, so resolve `current` to reach the real checkout. All paths are configurable; an older install may link PATH straight at a worktree (no `current`) or use scattered sibling clones — follow the launcher rather than assuming a layout. +2. Follow the launcher through the full symlink chain to identify the source checkout. The standard [`scripts/install.sh`](../../scripts/install.sh) keeps staging checkouts under one container `${DSH_SOURCE}` (default `~/.dsh/source`), each a git worktree `${DSH_SOURCE}/staging-`. The main clone — the one real clone whose object store every worktree shares — is at `${DSH_SOURCE}/master` for a `curl` install, but installing from an existing clone adopts that clone wherever it lives, so derive it from the checkout rather than assuming it sits in the container or on any particular branch. `${DSH_BIN_DIR}/dsh` links to `${DSH_SOURCE}/current/bin/dsh`, and the stable `current` symlink points at the active staging worktree, so resolve `current` to reach the real checkout. All paths are configurable; an older install may link PATH straight at a worktree (no `current`) or use scattered sibling clones — follow the launcher rather than assuming a layout. 3. Verify the checkout with Git, then record its branch, tip, status, remotes, worktrees, in-progress operations, and applicable `AGENTS.md` files. -4. Treat the launcher checkout's branch as staging unless the user says otherwise. The installed launcher must resolve to a staging worktree on a staging branch, never the master clone or a task, preparation, review, publication, or detached checkout. Ask if the launcher, checkout, or branch ownership is ambiguous; warn explicitly for a detached HEAD, the master clone, or a non-staging branch. +4. Treat the launcher checkout's branch as staging unless the user says otherwise. The installed launcher must resolve to a staging worktree on a staging branch, never the main clone or a task, preparation, review, publication, or detached checkout. Ask if the launcher, checkout, or branch ownership is ambiguous; warn explicitly for a detached HEAD, the main clone, or a non-staging branch. ## Customize diff --git a/skills/dsh-upgrade/SKILL.md b/skills/dsh-upgrade/SKILL.md index 307bfa3f55..11f6613b4b 100644 --- a/skills/dsh-upgrade/SKILL.md +++ b/skills/dsh-upgrade/SKILL.md @@ -5,11 +5,11 @@ description: Upgrades a source-installed, personally customized DSH checkout to # DSH Upgrade -Prepare and validate the upgrade in a fresh staging worktree of the master clone, leave the worktree the installed launcher currently uses unchanged, then atomically repoint the stable `current` symlink once. Read and follow [`dsh-customize`](../dsh-customize/SKILL.md) before starting; it owns checkout discovery and lock handling. +Prepare and validate the upgrade in a fresh staging worktree of the main clone, leave the worktree the installed launcher currently uses unchanged, then atomically repoint the stable `current` symlink once. Read and follow [`dsh-customize`](../dsh-customize/SKILL.md) before starting; it owns checkout discovery and lock handling. ## Layout -A source-installed DSH keeps its staging checkouts and `current` under one container directory `` (default `~/.dsh/source`): each staging checkout is a git worktree `/staging-` on branch `dsh-staging/`. The master clone — remote tracking `master`, the fetch/upgrade base, never a launcher target — is at `/master` for a `curl` install, but the container owns worktrees rather than the repository: installing from an existing clone adopts that clone as the master wherever it already lives, so resolve it with `git rev-parse --git-common-dir` from the staging worktree instead of assuming a path. The stable symlink `/current` points at the active staging worktree, and the PATH launcher links to `/current/bin/dsh`, so the launcher resolves PATH -> `current` -> staging worktree. Cutover repoints `current` alone; the PATH launcher is written once at install and never moves. All worktrees share the master clone's single `.git` object store; the master clone's `.git/info/exclude` is inherited by every linked worktree, so one `.agents/merge.lock` entry there excludes the lock in all of them. An older install may link PATH straight at a worktree (no `current`) or use scattered sibling clones; if so, follow the recorded launcher checkout rather than assuming this layout, treat that sibling clone as its own master, and create `current` and repoint PATH to `current/bin/dsh` as a one-time migration at cutover. +A source-installed DSH keeps its staging checkouts and `current` under one container directory `` (default `~/.dsh/source`): each staging checkout is a git worktree `/staging-` on branch `dsh-staging/`. The main clone — the one real clone holding the object store every worktree shares, and never a launcher target — is at `/master` for a `curl` install, but the container owns worktrees rather than the repository: installing from an existing clone adopts that clone wherever it already lives, so resolve it with `git rev-parse --git-common-dir` from the staging worktree instead of assuming a path. Do not assume the main clone sits on `master` or that its `origin` is authoritative upstream — an adopted clone keeps whatever branch and remotes it had, and may point at a fork. The upgrade fetches upstream separately, per step 1. The stable symlink `/current` points at the active staging worktree, and the PATH launcher links to `/current/bin/dsh`, so the launcher resolves PATH -> `current` -> staging worktree. Cutover repoints `current` alone; the PATH launcher is written once at install and never moves. All worktrees share the main clone's single `.git` object store; the main clone's `.git/info/exclude` is inherited by every linked worktree, so one `.agents/merge.lock` entry there excludes the lock in all of them. An older install may link PATH straight at a worktree (no `current`) or use scattered sibling clones; if so, follow the recorded launcher checkout rather than assuming this layout, treat that sibling clone as its own main clone, and create `current` and repoint PATH to `current/bin/dsh` as a one-time migration at cutover. ## Names @@ -22,24 +22,24 @@ One upgrade attempt uses one UTC basic timestamp `YYYYMMDDTHHMMSSZ` for all name - recovery ref: `refs/dsh-upgrade/recovery-`; - recorded `current` target before cutover: the old staging worktree path, kept for symlink rollback. -The worktree name is always `staging-` under ``, never derived from the current staging directory name, so successive upgrades stay in one place and do not accumulate timestamps. The preparation branch and private refs are local-only and must never be pushed. Before starting, reject a current staging branch named exactly `dsh-staging`, because Git cannot also create `dsh-staging/`; require the user to choose a non-conflicting staging namespace rather than silently renaming it. If the new staging worktree path exists, resume only when it is a clean worktree of this master clone whose recorded old tip, upstream ref, recovery ref, and named branches exactly match this attempt; otherwise stop. Never add an ad hoc suffix or delete an unknown directory. +The worktree name is always `staging-` under ``, never derived from the current staging directory name, so successive upgrades stay in one place and do not accumulate timestamps. The preparation branch and private refs are local-only and must never be pushed. Before starting, reject a current staging branch named exactly `dsh-staging`, because Git cannot also create `dsh-staging/`; require the user to choose a non-conflicting staging namespace rather than silently renaming it. If the new staging worktree path exists, resume only when it is a clean worktree of this main clone whose recorded old tip, upstream ref, recovery ref, and named branches exactly match this attempt; otherwise stop. Never add an ad hoc suffix or delete an unknown directory. ## Upgrade -1. Resolve the installed launcher, its staging worktree and branch, the master clone, the current DSH process source, and authoritative upstream. Record exact tips, paths, clean status, remotes, dependencies, worktrees, and in-progress Git operations. Require the installed staging worktree to be clean and its `.agents/merge.lock` to exist and be Git-excluded. Never stash automatically. -2. Treat the staging worktree behind the installed launcher as immutable for the whole attempt: do not touch its branch, HEAD, index, tracked or untracked files, dependencies, worktree registration, or lock file. Fetching into the shared master clone and creating new branches, worktrees, and private refs there are allowed because they are append-only and never alter the old worktree's checkout; opening and holding the existing lock is the only operation on the old worktree. +1. Resolve the installed launcher, its staging worktree and branch, the main clone, the current DSH process source, and authoritative upstream. Record exact tips, paths, clean status, remotes, dependencies, worktrees, and in-progress Git operations. Require the installed staging worktree to be clean and its `.agents/merge.lock` to exist and be Git-excluded. Never stash automatically. +2. Treat the staging worktree behind the installed launcher as immutable for the whole attempt: do not touch its branch, HEAD, index, tracked or untracked files, dependencies, worktree registration, or lock file. Fetching into the shared main clone and creating new branches, worktrees, and private refs there are allowed because they are append-only and never alter the old worktree's checkout; opening and holding the existing lock is the only operation on the old worktree. 3. Allocate the timestamp and new staging worktree path. Acquire the installed worktree's existing `.agents/merge.lock`, repeat every precondition, and keep it through preparation, validation, and the `current` cutover. If staging moves while waiting, unlock and restart with a new timestamp; remove only attempt artifacts that this run created and verified as disposable. -4. In the master clone, create `refs/dsh-upgrade/recovery-` at the recorded old staging tip and `dsh-upgrade/prepare-` from that tip. Fetch exact authoritative upstream `master` into `refs/dsh-upgrade/upstream-` and record its object ID. Add a fresh worktree `/staging-` checked out on the preparation branch. Confirm the master clone's `.git/info/exclude` excludes `.agents/merge.lock`, which the new worktree inherits. +4. In the main clone, create `refs/dsh-upgrade/recovery-` at the recorded old staging tip and `dsh-upgrade/prepare-` from that tip. Fetch exact authoritative upstream `master` into `refs/dsh-upgrade/upstream-` and record its object ID. Add a fresh worktree `/staging-` checked out on the preparation branch. Confirm the main clone's `.git/info/exclude` excludes `.agents/merge.lock`, which the new worktree inherits. 5. Inspect the Git log and commit ranges between the staging base, old staging tip, and fetched upstream tip. Identify incoming upstream changes, personal commits to preserve, likely duplicates, and conflict-prone areas before rebasing. 6. In the new worktree, rebase the preparation branch onto the fetched upstream commit. Preserve intentional customizations and drop behavior already upstream. If upstream contains the customization and its remaining local diff only documents that customization, prefer upstream and drop the documentary diff rather than retaining a stale local account. Preserve documentation only when it adds a current, independently useful contract absent upstream. Abort without changing the installed launcher when resolution is uncertain. 7. Install dependencies in the new worktree, review the resulting diff, and run the repository-required checks. Fix failures and rerun affected checks. Test the new worktree's `bin/dsh` directly. -8. Point `dsh-staging/` at the validated prepared tip and check it out in the new worktree. Ensure its `.agents/merge.lock` exists (Git-excluded through the shared master exclude). Verify its branch, exact commit, clean status, remotes, dependencies, and absence of in-progress Git operations, then smoke its `bin/dsh` from a clean temporary workspace. The preparation branch remains temporary; the timestamped staging branch owns the installed commit. -9. Recheck the old worktree, existing lock, launcher, `current`, master clone, new worktree, refs, and exact tips. Record `current`'s pre-cutover target, then repoint `current` at the new staging worktree in one atomic swap with `ln -sfn` (the `-n` stops `ln` from dereferencing the existing directory symlink and writing the link inside the old worktree; `mv` behaves the same way and is unusable). Leave the PATH launcher alone once it already resolves through `current`; if a legacy install still links PATH straight at a worktree, create `current` and repoint PATH to `current/bin/dsh` as a one-time migration here. The `current` target must be a clean staging worktree on a staging branch and must never be the master clone or a preparation, feature, review, publication, or detached checkout. Smoke the installed `dsh` command from a clean temporary workspace. +8. Point `dsh-staging/` at the validated prepared tip and check it out in the new worktree. Ensure its `.agents/merge.lock` exists (Git-excluded through the shared main-clone exclude). Verify its branch, exact commit, clean status, remotes, dependencies, and absence of in-progress Git operations, then smoke its `bin/dsh` from a clean temporary workspace. The preparation branch remains temporary; the timestamped staging branch owns the installed commit. +9. Recheck the old worktree, existing lock, launcher, `current`, main clone, new worktree, refs, and exact tips. Record `current`'s pre-cutover target, then repoint `current` at the new staging worktree in one atomic swap with `ln -sfn` (the `-n` stops `ln` from dereferencing the existing directory symlink and writing the link inside the old worktree; `mv` behaves the same way and is unusable). Leave the PATH launcher alone once it already resolves through `current`; if a legacy install still links PATH straight at a worktree, create `current` and repoint PATH to `current/bin/dsh` as a one-time migration here. The `current` target must be a clean staging worktree on a staging branch and must never be the main clone or a preparation, feature, review, publication, or detached checkout. Smoke the installed `dsh` command from a clean temporary workspace. 10. On failure before the `current` cutover, leave `current`, the launcher, and the old worktree unchanged and remove only verified attempt artifacts created by this run (including the new worktree registration if empty). On failure during or after cutover, inspect `current`'s observed target before acting; if cutover did not verify, atomically repoint `current` back to its recorded pre-cutover target with `ln -sfn` and verify that `dsh` starts from the unchanged old staging worktree. This rollback is the sole exception allowing `current` to return to the old staging worktree. Never retry a side-effecting operation blindly. 11. Release the old worktree's lock and tell the user to restart DSH through the installed launcher. The current process may continue from the old worktree, but no operation may mutate or remove it until the restarted process proves that it runs from `dsh-staging/` and the user confirms stability. Avoid customization integration during this confirmation window; if rollback is required after new work lands, reconcile that work explicitly rather than silently stranding it. -12. After confirmation, remove the preparation branch if no process uses it. Keep the old staging worktree and branch, the recovery ref, and the recorded pre-cutover `current` target as rollback until the user explicitly approves their removal; leave the actual `git worktree remove` and directory deletion to the user. Report old, upstream, prepared, and new staging commits; both staging worktree paths and branches; the master clone path; process-source evidence; the `current` pre-cutover target and cutover; commands and checks; final status; recovery ref; and retained rollback artifacts. +12. After confirmation, remove the preparation branch if no process uses it. Keep the old staging worktree and branch, the recovery ref, and the recorded pre-cutover `current` target as rollback until the user explicitly approves their removal; leave the actual `git worktree remove` and directory deletion to the user. Report old, upstream, prepared, and new staging commits; both staging worktree paths and branches; the main clone path; process-source evidence; the `current` pre-cutover target and cutover; commands and checks; final status; recovery ref; and retained rollback artifacts. -The installed launcher always resolves through `current` to a staging worktree, never the master clone. Upgrade preparation adds a new worktree that shares the master object store while leaving the old worktree's checkout untouched; cutover is one atomic `current` repoint to the separately validated timestamped staging worktree, and the PATH launcher never moves. +The installed launcher always resolves through `current` to a staging worktree, never the main clone. Upgrade preparation adds a new worktree that shares the main clone's object store while leaving the old worktree's checkout untouched; cutover is one atomic `current` repoint to the separately validated timestamped staging worktree, and the PATH launcher never moves. ## Recommend upstream candidates From f3ff2e6ab49329878d600dfe4d5215430d8f0b93 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 31 Jul 2026 22:26:11 +0800 Subject: [PATCH 12/19] docs(skills): say how to resolve the main clone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both skills said to derive the main clone from the checkout without saying how, and dsh-upgrade names dsh-customize as the owner of checkout discovery — so the technique belonged there and was missing. dsh-customize now gives it: `git rev-parse --git-common-dir` from the checkout yields the shared git directory, whose parent is the main clone. It also names the two ways to get this wrong — the answer is relative for a plain clone, and paths must be compared physically, since macOS reaches /var through a symlink to /private/var. dsh-upgrade links to that procedure rather than restating it. Verified against both shapes: an adopted clone outside the container, and a curl-shaped install whose clone is at /master. --- skills/dsh-customize/SKILL.md | 2 +- skills/dsh-upgrade/SKILL.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/skills/dsh-customize/SKILL.md b/skills/dsh-customize/SKILL.md index bca2c48b18..9c9c714768 100644 --- a/skills/dsh-customize/SKILL.md +++ b/skills/dsh-customize/SKILL.md @@ -12,7 +12,7 @@ Make personal DSH changes in task worktrees and integrate them under the staging Do not assume a path or branch name. DSH is usually installed from source with a personal staging branch; create one for the user only when none exists. 1. Inspect `command -v dsh` in the user's launch environment before resolving symlinks. -2. Follow the launcher through the full symlink chain to identify the source checkout. The standard [`scripts/install.sh`](../../scripts/install.sh) keeps staging checkouts under one container `${DSH_SOURCE}` (default `~/.dsh/source`), each a git worktree `${DSH_SOURCE}/staging-`. The main clone — the one real clone whose object store every worktree shares — is at `${DSH_SOURCE}/master` for a `curl` install, but installing from an existing clone adopts that clone wherever it lives, so derive it from the checkout rather than assuming it sits in the container or on any particular branch. `${DSH_BIN_DIR}/dsh` links to `${DSH_SOURCE}/current/bin/dsh`, and the stable `current` symlink points at the active staging worktree, so resolve `current` to reach the real checkout. All paths are configurable; an older install may link PATH straight at a worktree (no `current`) or use scattered sibling clones — follow the launcher rather than assuming a layout. +2. Follow the launcher through the full symlink chain to identify the source checkout. The standard [`scripts/install.sh`](../../scripts/install.sh) keeps staging checkouts under one container `${DSH_SOURCE}` (default `~/.dsh/source`), each a git worktree `${DSH_SOURCE}/staging-`. The main clone — the one real clone whose object store every worktree shares — is at `${DSH_SOURCE}/master` for a `curl` install, but installing from an existing clone adopts that clone wherever it lives, so never assume it sits in the container or on any particular branch. Resolve it from the checkout: `git -C rev-parse --git-common-dir` gives the shared git directory (a linked worktree reports the real clone's, not its own), and its parent is the main clone. That answer is relative for a plain clone, so anchor it against the checkout before use, and resolve it physically — comparing a resolved path against an unresolved one silently misidentifies the clone, since macOS reaches `/var` through a symlink to `/private/var`. `${DSH_BIN_DIR}/dsh` links to `${DSH_SOURCE}/current/bin/dsh`, and the stable `current` symlink points at the active staging worktree, so resolve `current` to reach the real checkout. All paths are configurable; an older install may link PATH straight at a worktree (no `current`) or use scattered sibling clones — follow the launcher rather than assuming a layout. 3. Verify the checkout with Git, then record its branch, tip, status, remotes, worktrees, in-progress operations, and applicable `AGENTS.md` files. 4. Treat the launcher checkout's branch as staging unless the user says otherwise. The installed launcher must resolve to a staging worktree on a staging branch, never the main clone or a task, preparation, review, publication, or detached checkout. Ask if the launcher, checkout, or branch ownership is ambiguous; warn explicitly for a detached HEAD, the main clone, or a non-staging branch. diff --git a/skills/dsh-upgrade/SKILL.md b/skills/dsh-upgrade/SKILL.md index 11f6613b4b..cb56edffbd 100644 --- a/skills/dsh-upgrade/SKILL.md +++ b/skills/dsh-upgrade/SKILL.md @@ -9,7 +9,7 @@ Prepare and validate the upgrade in a fresh staging worktree of the main clone, ## Layout -A source-installed DSH keeps its staging checkouts and `current` under one container directory `` (default `~/.dsh/source`): each staging checkout is a git worktree `/staging-` on branch `dsh-staging/`. The main clone — the one real clone holding the object store every worktree shares, and never a launcher target — is at `/master` for a `curl` install, but the container owns worktrees rather than the repository: installing from an existing clone adopts that clone wherever it already lives, so resolve it with `git rev-parse --git-common-dir` from the staging worktree instead of assuming a path. Do not assume the main clone sits on `master` or that its `origin` is authoritative upstream — an adopted clone keeps whatever branch and remotes it had, and may point at a fork. The upgrade fetches upstream separately, per step 1. The stable symlink `/current` points at the active staging worktree, and the PATH launcher links to `/current/bin/dsh`, so the launcher resolves PATH -> `current` -> staging worktree. Cutover repoints `current` alone; the PATH launcher is written once at install and never moves. All worktrees share the main clone's single `.git` object store; the main clone's `.git/info/exclude` is inherited by every linked worktree, so one `.agents/merge.lock` entry there excludes the lock in all of them. An older install may link PATH straight at a worktree (no `current`) or use scattered sibling clones; if so, follow the recorded launcher checkout rather than assuming this layout, treat that sibling clone as its own main clone, and create `current` and repoint PATH to `current/bin/dsh` as a one-time migration at cutover. +A source-installed DSH keeps its staging checkouts and `current` under one container directory `` (default `~/.dsh/source`): each staging checkout is a git worktree `/staging-` on branch `dsh-staging/`. The main clone — the one real clone holding the object store every worktree shares, and never a launcher target — is at `/master` for a `curl` install, but the container owns worktrees rather than the repository: installing from an existing clone adopts that clone wherever it already lives, so resolve it from the staging worktree by the procedure in [`dsh-customize`](../dsh-customize/SKILL.md) instead of assuming a path. Do not assume the main clone sits on `master` or that its `origin` is authoritative upstream — an adopted clone keeps whatever branch and remotes it had, and may point at a fork. The upgrade fetches upstream separately, per step 1. The stable symlink `/current` points at the active staging worktree, and the PATH launcher links to `/current/bin/dsh`, so the launcher resolves PATH -> `current` -> staging worktree. Cutover repoints `current` alone; the PATH launcher is written once at install and never moves. All worktrees share the main clone's single `.git` object store; the main clone's `.git/info/exclude` is inherited by every linked worktree, so one `.agents/merge.lock` entry there excludes the lock in all of them. An older install may link PATH straight at a worktree (no `current`) or use scattered sibling clones; if so, follow the recorded launcher checkout rather than assuming this layout, treat that sibling clone as its own main clone, and create `current` and repoint PATH to `current/bin/dsh` as a one-time migration at cutover. ## Names From 8d624678237271e9a62caed00be896fd786fc3f5 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 31 Jul 2026 22:29:53 +0800 Subject: [PATCH 13/19] docs(skills): one canonical resolution, no DSH_SOURCE DSH_SOURCE is an install-time shell variable the installer never exports, so a skill reading ${DSH_SOURCE} at runtime reads nothing. Verified unset in a running dsh process. Git resolves the main clone identically for every install, so the curl-vs- adopted distinction was never a branch point in these workflows. Verified one launcher-then-Git recipe against three shapes: a curl install cloning into the container, an adopted clone nested far outside any container, and a custom DSH_SOURCE container. dsh-customize now states that single procedure and warns off the installer variables. dsh-upgrade's Layout describes what the resolution finds rather than a path convention, and no longer teaches install shapes as cases. --- skills/dsh-customize/SKILL.md | 4 +++- skills/dsh-upgrade/SKILL.md | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/skills/dsh-customize/SKILL.md b/skills/dsh-customize/SKILL.md index 9c9c714768..fc9e507ba6 100644 --- a/skills/dsh-customize/SKILL.md +++ b/skills/dsh-customize/SKILL.md @@ -12,7 +12,9 @@ Make personal DSH changes in task worktrees and integrate them under the staging Do not assume a path or branch name. DSH is usually installed from source with a personal staging branch; create one for the user only when none exists. 1. Inspect `command -v dsh` in the user's launch environment before resolving symlinks. -2. Follow the launcher through the full symlink chain to identify the source checkout. The standard [`scripts/install.sh`](../../scripts/install.sh) keeps staging checkouts under one container `${DSH_SOURCE}` (default `~/.dsh/source`), each a git worktree `${DSH_SOURCE}/staging-`. The main clone — the one real clone whose object store every worktree shares — is at `${DSH_SOURCE}/master` for a `curl` install, but installing from an existing clone adopts that clone wherever it lives, so never assume it sits in the container or on any particular branch. Resolve it from the checkout: `git -C rev-parse --git-common-dir` gives the shared git directory (a linked worktree reports the real clone's, not its own), and its parent is the main clone. That answer is relative for a plain clone, so anchor it against the checkout before use, and resolve it physically — comparing a resolved path against an unresolved one silently misidentifies the clone, since macOS reaches `/var` through a symlink to `/private/var`. `${DSH_BIN_DIR}/dsh` links to `${DSH_SOURCE}/current/bin/dsh`, and the stable `current` symlink points at the active staging worktree, so resolve `current` to reach the real checkout. All paths are configurable; an older install may link PATH straight at a worktree (no `current`) or use scattered sibling clones — follow the launcher rather than assuming a layout. +2. Follow the launcher through the full symlink chain to reach the source checkout, then ask Git for everything else. The `dsh` on PATH is a symlink, usually through a stable `current` symlink into the active staging worktree; resolve the chain physically and take the launcher's parent directory as the checkout. Derive the rest from that checkout rather than from any path convention: `git -C rev-parse --show-toplevel` confirms the checkout root, and `git -C rev-parse --git-common-dir` gives the shared git directory — a linked worktree reports the real clone's, not its own — whose parent is the main clone, the one real clone whose object store every worktree shares. `--git-common-dir` answers relatively for a plain clone, so anchor it against the checkout before use, and resolve it physically: comparing a resolved path against an unresolved one silently misidentifies the clone, since macOS reaches `/var` through a symlink to `/private/var`. `git -C
worktree list` then enumerates every checkout sharing it. + + This one procedure covers every install. [`scripts/install.sh`](../../scripts/install.sh) puts staging worktrees and `current` under a container directory (default `~/.dsh/source`), and a `curl` install also clones into that container while installing from an existing clone adopts that clone where it already lives — but nothing in this workflow depends on which happened, on the container's path, or on the main clone's branch. `DSH_SOURCE` and the installer's other variables exist only while the installer runs; they are never exported, so never read them here. An older install may link PATH straight at a worktree with no `current`, which the same launcher-then-Git procedure resolves unchanged. 3. Verify the checkout with Git, then record its branch, tip, status, remotes, worktrees, in-progress operations, and applicable `AGENTS.md` files. 4. Treat the launcher checkout's branch as staging unless the user says otherwise. The installed launcher must resolve to a staging worktree on a staging branch, never the main clone or a task, preparation, review, publication, or detached checkout. Ask if the launcher, checkout, or branch ownership is ambiguous; warn explicitly for a detached HEAD, the main clone, or a non-staging branch. diff --git a/skills/dsh-upgrade/SKILL.md b/skills/dsh-upgrade/SKILL.md index cb56edffbd..dca546258a 100644 --- a/skills/dsh-upgrade/SKILL.md +++ b/skills/dsh-upgrade/SKILL.md @@ -9,7 +9,9 @@ Prepare and validate the upgrade in a fresh staging worktree of the main clone, ## Layout -A source-installed DSH keeps its staging checkouts and `current` under one container directory `` (default `~/.dsh/source`): each staging checkout is a git worktree `/staging-` on branch `dsh-staging/`. The main clone — the one real clone holding the object store every worktree shares, and never a launcher target — is at `/master` for a `curl` install, but the container owns worktrees rather than the repository: installing from an existing clone adopts that clone wherever it already lives, so resolve it from the staging worktree by the procedure in [`dsh-customize`](../dsh-customize/SKILL.md) instead of assuming a path. Do not assume the main clone sits on `master` or that its `origin` is authoritative upstream — an adopted clone keeps whatever branch and remotes it had, and may point at a fork. The upgrade fetches upstream separately, per step 1. The stable symlink `/current` points at the active staging worktree, and the PATH launcher links to `/current/bin/dsh`, so the launcher resolves PATH -> `current` -> staging worktree. Cutover repoints `current` alone; the PATH launcher is written once at install and never moves. All worktrees share the main clone's single `.git` object store; the main clone's `.git/info/exclude` is inherited by every linked worktree, so one `.agents/merge.lock` entry there excludes the lock in all of them. An older install may link PATH straight at a worktree (no `current`) or use scattered sibling clones; if so, follow the recorded launcher checkout rather than assuming this layout, treat that sibling clone as its own main clone, and create `current` and repoint PATH to `current/bin/dsh` as a one-time migration at cutover. +Resolve the layout, never assume it. [`dsh-customize`](../dsh-customize/SKILL.md) owns the procedure: follow the PATH launcher to the staging worktree, then derive the main clone from that checkout with Git. It resolves every install the same way, so this workflow needs no special case for how DSH was installed and never reads the installer's variables, which exist only while the installer runs. + +The resolved layout is one container directory `` holding each staging checkout as a git worktree `/staging-` on branch `dsh-staging/`, plus the stable symlink `/current` pointing at the active one; the PATH launcher links to `/current/bin/dsh`, so it resolves PATH -> `current` -> staging worktree. The main clone is the one real clone whose object store every worktree shares, and is never a launcher target. It may live inside `` or anywhere else on disk, on any branch, with remotes that may point at a fork — so treat it strictly as the object store and worktree host, and take authoritative upstream from step 1 instead. Cutover repoints `current` alone; the PATH launcher is written once at install and never moves. The main clone's `.git/info/exclude` is inherited by every linked worktree, so one `.agents/merge.lock` entry there excludes the lock in all of them. An older install may link PATH straight at a worktree with no `current`; the same resolution finds it, and cutover then creates `current` and repoints PATH to `current/bin/dsh` as a one-time migration. ## Names From 6e913fe8bb682d582d064a21c9b5220920df3aa4 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 31 Jul 2026 22:35:27 +0800 Subject: [PATCH 14/19] docs(skills): drop installer details from the git workflows These skills resolve the layout from the PATH launcher and Git, so how the checkout was installed never enters the procedure. Describing install shapes, the installer script, and its variables added detail a reader must hold and would go stale whenever the installer changes. Both skills now describe the observable state they resolve. The cases that mattered survive as properties of that state: the main clone may sit anywhere on any branch, and a launcher may link straight at a worktree with no `current`. --- skills/dsh-customize/SKILL.md | 2 +- skills/dsh-upgrade/SKILL.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/skills/dsh-customize/SKILL.md b/skills/dsh-customize/SKILL.md index fc9e507ba6..865e21dba5 100644 --- a/skills/dsh-customize/SKILL.md +++ b/skills/dsh-customize/SKILL.md @@ -14,7 +14,7 @@ Do not assume a path or branch name. DSH is usually installed from source with a 1. Inspect `command -v dsh` in the user's launch environment before resolving symlinks. 2. Follow the launcher through the full symlink chain to reach the source checkout, then ask Git for everything else. The `dsh` on PATH is a symlink, usually through a stable `current` symlink into the active staging worktree; resolve the chain physically and take the launcher's parent directory as the checkout. Derive the rest from that checkout rather than from any path convention: `git -C rev-parse --show-toplevel` confirms the checkout root, and `git -C rev-parse --git-common-dir` gives the shared git directory — a linked worktree reports the real clone's, not its own — whose parent is the main clone, the one real clone whose object store every worktree shares. `--git-common-dir` answers relatively for a plain clone, so anchor it against the checkout before use, and resolve it physically: comparing a resolved path against an unresolved one silently misidentifies the clone, since macOS reaches `/var` through a symlink to `/private/var`. `git -C
worktree list` then enumerates every checkout sharing it. - This one procedure covers every install. [`scripts/install.sh`](../../scripts/install.sh) puts staging worktrees and `current` under a container directory (default `~/.dsh/source`), and a `curl` install also clones into that container while installing from an existing clone adopts that clone where it already lives — but nothing in this workflow depends on which happened, on the container's path, or on the main clone's branch. `DSH_SOURCE` and the installer's other variables exist only while the installer runs; they are never exported, so never read them here. An older install may link PATH straight at a worktree with no `current`, which the same launcher-then-Git procedure resolves unchanged. + This resolves every checkout, so depend on nothing else: not an environment variable, not a container path, not the main clone's location or branch. A checkout whose launcher links straight at it, with no `current` in the chain, resolves the same way. 3. Verify the checkout with Git, then record its branch, tip, status, remotes, worktrees, in-progress operations, and applicable `AGENTS.md` files. 4. Treat the launcher checkout's branch as staging unless the user says otherwise. The installed launcher must resolve to a staging worktree on a staging branch, never the main clone or a task, preparation, review, publication, or detached checkout. Ask if the launcher, checkout, or branch ownership is ambiguous; warn explicitly for a detached HEAD, the main clone, or a non-staging branch. diff --git a/skills/dsh-upgrade/SKILL.md b/skills/dsh-upgrade/SKILL.md index dca546258a..30c5cc7bdc 100644 --- a/skills/dsh-upgrade/SKILL.md +++ b/skills/dsh-upgrade/SKILL.md @@ -9,9 +9,9 @@ Prepare and validate the upgrade in a fresh staging worktree of the main clone, ## Layout -Resolve the layout, never assume it. [`dsh-customize`](../dsh-customize/SKILL.md) owns the procedure: follow the PATH launcher to the staging worktree, then derive the main clone from that checkout with Git. It resolves every install the same way, so this workflow needs no special case for how DSH was installed and never reads the installer's variables, which exist only while the installer runs. +Resolve the layout, never assume it. [`dsh-customize`](../dsh-customize/SKILL.md) owns the procedure: follow the PATH launcher to the staging worktree, then derive the main clone from that checkout with Git. One resolution covers every checkout, so this workflow needs no special case and depends on no environment variable. -The resolved layout is one container directory `` holding each staging checkout as a git worktree `/staging-` on branch `dsh-staging/`, plus the stable symlink `/current` pointing at the active one; the PATH launcher links to `/current/bin/dsh`, so it resolves PATH -> `current` -> staging worktree. The main clone is the one real clone whose object store every worktree shares, and is never a launcher target. It may live inside `` or anywhere else on disk, on any branch, with remotes that may point at a fork — so treat it strictly as the object store and worktree host, and take authoritative upstream from step 1 instead. Cutover repoints `current` alone; the PATH launcher is written once at install and never moves. The main clone's `.git/info/exclude` is inherited by every linked worktree, so one `.agents/merge.lock` entry there excludes the lock in all of them. An older install may link PATH straight at a worktree with no `current`; the same resolution finds it, and cutover then creates `current` and repoints PATH to `current/bin/dsh` as a one-time migration. +The resolved layout is one container directory `` holding each staging checkout as a git worktree `/staging-` on branch `dsh-staging/`, plus the stable symlink `/current` pointing at the active one; the PATH launcher links to `/current/bin/dsh`, so it resolves PATH -> `current` -> staging worktree. The main clone is the one real clone whose object store every worktree shares, and is never a launcher target. It may live inside `` or anywhere else on disk, on any branch, with remotes that may point at a fork — so treat it strictly as the object store and worktree host, and take authoritative upstream from step 1 instead. Cutover repoints `current` alone, so the PATH launcher itself never moves. The main clone's `.git/info/exclude` is inherited by every linked worktree, so one `.agents/merge.lock` entry there excludes the lock in all of them. When the launcher links straight at a worktree with no `current` in the chain, the same resolution finds it, and cutover creates `current` and repoints PATH to `current/bin/dsh` as a one-time migration. ## Names From db432c1e740fd4ec75078c43e398e286a6d471b5 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 31 Jul 2026 23:40:40 +0800 Subject: [PATCH 15/19] fix(install): correct a false claim about shell assignment semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review flagged the resolve_dir comment: it claimed `x=$(cmd) || fallback` never fires "because the assignment succeeds even when the substitution fails." That is wrong — command substitution propagates exit status and the fallback does fire, confirmed in sh, bash, dash, and zsh. Reproducing the original code shows the fallback also worked, so the second "recurrence" the Agent Note described never existed. Both real defects were the same one: comparing a resolved path against an unresolved one. The note now says that instead of inventing a mechanism. resolve_dir keeps its `|| printf` because it makes every caller a plain assignment, so no site can compare against an empty path by forgetting its own fallback — the reason is now stated accurately. Also from review: REPO_COMMON is now resolved on both branches, matching REPO_ROOT, and _repo_root notes why it is already physical without its own resolve_dir call. --- ...1-installer-adopts-existing-checkout.i18n.yaml | 4 ++-- ...26-07-31-installer-adopts-existing-checkout.md | 2 +- ...07-31-installer-adopts-existing-checkout.zh.md | 2 +- scripts/install.sh | 15 +++++++++------ 4 files changed, 13 insertions(+), 10 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml index fa13789c9a..b735ee0a05 100644 --- a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md -2026-07-31-installer-adopts-existing-checkout.md: 5eede5d476d21c9f2bf0b63365eab9ac705bad60 -2026-07-31-installer-adopts-existing-checkout.zh.md: a137c585de7da5b1ccc1167c2c8e9d1ca939298b +2026-07-31-installer-adopts-existing-checkout.md: f2f4a2bf87696bc2254a352dd7568ea73f8f900b +2026-07-31-installer-adopts-existing-checkout.zh.md: b7a545e6eb43bd8748185b26b6a7ee965353b79b diff --git a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md index 5eede5d476..f2f4a2bf87 100644 --- a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md +++ b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md @@ -20,7 +20,7 @@ The installer records nothing about where that repository lives. A container who Adoption branches from `HEAD`, so committed work is what runs and uncommitted changes stay in the checkout. This is not prompted or warned about: the installer builds the layout and gets out of the way. Setting `DSH_SOURCE` to a different directory remains the one documented way to opt back into cloning a separate tree. -Every path comparison runs on physical paths through a `resolve_dir` helper, and every compared value is resolved at assignment rather than at the comparison. macOS resolves `/var` through a symlink to `/private/var`, so comparing a git-reported path against an unresolved one misclassified an existing managed install as a foreign clone and would have built a second container beside the real one. The same defect recurred twice more during review — once where a curl install's `REPO_ROOT` stayed unresolved and so compared unequal against every resolved path, and once where `x=$(resolve_dir …) || x=$fallback` left an empty path because the assignment succeeds even when the substitution fails. `resolve_dir` therefore echoes a missing path back itself, and callers that need "does not exist" test the directory explicitly. `git rev-parse --path-format=absolute` would do the same job but requires git 2.31+. +Every path comparison runs on physical paths through a `resolve_dir` helper, and every compared value is resolved at assignment rather than at the comparison. macOS resolves `/var` through a symlink to `/private/var`, so comparing a git-reported path against an unresolved one misclassified an existing managed install as a foreign clone and would have built a second container beside the real one. The same defect recurred twice more during review, both times as one side of a comparison left unresolved: a curl install's `REPO_ROOT`, and the container path it was compared against. `resolve_dir` therefore echoes a missing path back rather than failing, so a not-yet-created container needs no per-call fallback and no site can compare against an empty path by forgetting one; callers that need "does not exist" test the directory explicitly. `git rev-parse --path-format=absolute` would do the same job but requires git 2.31+. Before `current` is repointed, the installer rejects a staging path that resolves to the repository itself, enforcing the upgrade contract that the launcher never resolves to the master clone. diff --git a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md index a137c585de..b7a545e6eb 100644 --- a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md +++ b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md @@ -20,7 +20,7 @@ Status: implemented 接管以`HEAD`为分支起点,因此运行的是已提交的内容,未提交的更改仍留在检出中。这一点既不提示也不警告:安装器构建好布局后便不再打扰。把`DSH_SOURCE`设为其他目录,仍是唯一有文档记载的、回到克隆另一棵树的方式。 -所有路径比较都通过`resolve_dir`辅助函数在物理路径上进行,且每个参与比较的值都在赋值时解析,而非在比较时解析。macOS 会把`/var`经符号链接解析为`/private/var`,因此拿 git 报告的路径与未解析的路径相比较,会把已有的受管安装误判为外来克隆,并在真正的容器旁再建一个容器。同一缺陷在评审过程中又出现了两次——一次是 curl 安装的`REPO_ROOT`未经解析,从而与所有已解析路径比较时均不相等;另一次是`x=$(resolve_dir …) || x=$fallback`留下了空路径,因为即使命令替换失败,赋值本身仍然成功。因此`resolve_dir`会在路径不存在时原样回显该路径,而需要判断"不存在"的调用方则显式检测该目录。`git rev-parse --path-format=absolute`能完成同样的工作,但要求 git 2.31 及以上版本。 +所有路径比较都通过`resolve_dir`辅助函数在物理路径上进行,且每个参与比较的值都在赋值时解析,而非在比较时解析。macOS 会把`/var`经符号链接解析为`/private/var`,因此拿 git 报告的路径与未解析的路径相比较,会把已有的受管安装误判为外来克隆,并在真正的容器旁再建一个容器。同一缺陷在评审过程中又出现了两次,两次都是比较的一侧未经解析:一次是 curl 安装的`REPO_ROOT`,一次是与之比较的容器路径。因此`resolve_dir`在路径不存在时原样回显该路径而非失败,这样尚未创建的容器无需在每个调用点单独兜底,也就没有调用点会因遗漏兜底而与空路径比较;需要判断"不存在"的调用方则显式检测该目录。`git rev-parse --path-format=absolute`能完成同样的工作,但要求 git 2.31 及以上版本。 在重指`current`之前,安装器会拒绝解析结果等于仓库自身的 staging 路径,以此落实"启动器绝不解析到 master 克隆"这一升级契约。 diff --git a/scripts/install.sh b/scripts/install.sh index 7393639a85..9a891f05d8 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -78,9 +78,9 @@ DSH_STAGING=$DSH_SOURCE/staging-$DSH_STAMP # `git rev-parse --path-format=absolute` would do this, but it needs git 2.31+. # # A not-yet-created directory (the container on a fresh install) has no physical -# path, so fall back to the literal argument here rather than at each call site: -# `x=$(cmd) || fallback` never fires, because the assignment succeeds even when -# the substitution fails, which would silently yield an empty path. +# path. Falling back here rather than at each call site keeps every caller a +# plain assignment, so no site can compare against an empty path by forgetting +# its own fallback. resolve_dir() { CDPATH= cd -- "$1" 2>/dev/null && pwd -P || printf '%s\n' "$1"; } # --- in-repo detection --------------------------------------------------------- @@ -95,6 +95,8 @@ DSH_CHECKOUT='' if [ -f "$0" ]; then _self_dir=$(resolve_dir "$(dirname -- "$0")") if [ -n "$_self_dir" ]; then + # Physical without its own resolve_dir: dirname is textual, so trimming a + # resolved path leaves one. The comparison below depends on that. _repo_root=$(dirname -- "$_self_dir") if [ "$(basename -- "$_self_dir")" = scripts ] \ && [ -x "$_repo_root/bin/dsh" ] && [ -f "$_repo_root/scripts/install.sh" ]; then @@ -277,9 +279,10 @@ else mkdir -p "$DSH_SOURCE" git clone --branch "$DSH_REF" "$DSH_REPO" "$DSH_MASTER" fi - REPO_COMMON=$DSH_MASTER/.git - # Physical, to match the adoption branch: every REPO_ROOT comparison below - # runs against resolved paths. + # Physical on both branches: REPO_ROOT is compared against resolved paths + # below, and REPO_COMMON stays symmetric with it so neither can be read as + # carrying a different kind of path. + REPO_COMMON=$(resolve_dir "$DSH_MASTER/.git") REPO_ROOT=$(resolve_dir "$DSH_MASTER") fi From c43535056126febbc8ee4a1044b0bb96ec58daf5 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 31 Jul 2026 23:47:58 +0800 Subject: [PATCH 16/19] docs(install): the path bug is symlinks, not /var MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comments and Agent Note blamed macOS resolving /var through private/var. That is only how the mismatch surfaced in temp fixtures, since mktemp hands back a /var path there. The real condition is broader: Git always reports resolved paths, so comparing one against an unresolved path disagrees whenever a symlink sits anywhere above the checkout. A symlinked home directory alone triggers it — reproduced with no /var involved — which is common wherever homes live behind a symlink or on a network mount. Naming the cause correctly keeps a reader from dismissing resolve_dir as macOS-only defensiveness. --- ...-07-31-installer-adopts-existing-checkout.i18n.yaml | 4 ++-- .../2026-07-31-installer-adopts-existing-checkout.md | 2 +- ...2026-07-31-installer-adopts-existing-checkout.zh.md | 2 +- scripts/install.sh | 10 ++++++---- skills/dsh-customize/SKILL.md | 2 +- 5 files changed, 11 insertions(+), 9 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml index b735ee0a05..1a179748f9 100644 --- a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md -2026-07-31-installer-adopts-existing-checkout.md: f2f4a2bf87696bc2254a352dd7568ea73f8f900b -2026-07-31-installer-adopts-existing-checkout.zh.md: b7a545e6eb43bd8748185b26b6a7ee965353b79b +2026-07-31-installer-adopts-existing-checkout.md: de3cd052f94a0d5256c7687e9a1a38ee69fd2caf +2026-07-31-installer-adopts-existing-checkout.zh.md: 2e8be804b4af6151e77e36f8b109616aab3a18e9 diff --git a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md index f2f4a2bf87..de3cd052f9 100644 --- a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md +++ b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md @@ -20,7 +20,7 @@ The installer records nothing about where that repository lives. A container who Adoption branches from `HEAD`, so committed work is what runs and uncommitted changes stay in the checkout. This is not prompted or warned about: the installer builds the layout and gets out of the way. Setting `DSH_SOURCE` to a different directory remains the one documented way to opt back into cloning a separate tree. -Every path comparison runs on physical paths through a `resolve_dir` helper, and every compared value is resolved at assignment rather than at the comparison. macOS resolves `/var` through a symlink to `/private/var`, so comparing a git-reported path against an unresolved one misclassified an existing managed install as a foreign clone and would have built a second container beside the real one. The same defect recurred twice more during review, both times as one side of a comparison left unresolved: a curl install's `REPO_ROOT`, and the container path it was compared against. `resolve_dir` therefore echoes a missing path back rather than failing, so a not-yet-created container needs no per-call fallback and no site can compare against an empty path by forgetting one; callers that need "does not exist" test the directory explicitly. `git rev-parse --path-format=absolute` would do the same job but requires git 2.31+. +Every path comparison runs on physical paths through a `resolve_dir` helper, and every compared value is resolved at assignment rather than at the comparison. Git always reports resolved paths, so comparing one against an unresolved path disagrees whenever a symlink sits anywhere above the checkout — a symlinked home directory is enough, and macOS reaches every `mktemp` path that way through `/var` -> `private/var`. The mismatch misclassified an existing managed install as a foreign clone and would have built a second container beside the real one. The same defect recurred twice more during review, both times as one side of a comparison left unresolved: a curl install's `REPO_ROOT`, and the container path it was compared against. `resolve_dir` therefore echoes a missing path back rather than failing, so a not-yet-created container needs no per-call fallback and no site can compare against an empty path by forgetting one; callers that need "does not exist" test the directory explicitly. `git rev-parse --path-format=absolute` would do the same job but requires git 2.31+. Before `current` is repointed, the installer rejects a staging path that resolves to the repository itself, enforcing the upgrade contract that the launcher never resolves to the master clone. diff --git a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md index b7a545e6eb..2e8be804b4 100644 --- a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md +++ b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md @@ -20,7 +20,7 @@ Status: implemented 接管以`HEAD`为分支起点,因此运行的是已提交的内容,未提交的更改仍留在检出中。这一点既不提示也不警告:安装器构建好布局后便不再打扰。把`DSH_SOURCE`设为其他目录,仍是唯一有文档记载的、回到克隆另一棵树的方式。 -所有路径比较都通过`resolve_dir`辅助函数在物理路径上进行,且每个参与比较的值都在赋值时解析,而非在比较时解析。macOS 会把`/var`经符号链接解析为`/private/var`,因此拿 git 报告的路径与未解析的路径相比较,会把已有的受管安装误判为外来克隆,并在真正的容器旁再建一个容器。同一缺陷在评审过程中又出现了两次,两次都是比较的一侧未经解析:一次是 curl 安装的`REPO_ROOT`,一次是与之比较的容器路径。因此`resolve_dir`在路径不存在时原样回显该路径而非失败,这样尚未创建的容器无需在每个调用点单独兜底,也就没有调用点会因遗漏兜底而与空路径比较;需要判断"不存在"的调用方则显式检测该目录。`git rev-parse --path-format=absolute`能完成同样的工作,但要求 git 2.31 及以上版本。 +所有路径比较都通过`resolve_dir`辅助函数在物理路径上进行,且每个参与比较的值都在赋值时解析,而非在比较时解析。git 报告的始终是已解析的路径,因此只要检出之上任意一层存在符号链接,拿它与未解析的路径相比较就会不相等——家目录本身是符号链接即已足够,而 macOS 通过`/var` -> `private/var`使每个`mktemp`路径都如此。这种不匹配会把已有的受管安装误判为外来克隆,并在真正的容器旁再建一个容器。同一缺陷在评审过程中又出现了两次,两次都是比较的一侧未经解析:一次是 curl 安装的`REPO_ROOT`,一次是与之比较的容器路径。因此`resolve_dir`在路径不存在时原样回显该路径而非失败,这样尚未创建的容器无需在每个调用点单独兜底,也就没有调用点会因遗漏兜底而与空路径比较;需要判断"不存在"的调用方则显式检测该目录。`git rev-parse --path-format=absolute`能完成同样的工作,但要求 git 2.31 及以上版本。 在重指`current`之前,安装器会拒绝解析结果等于仓库自身的 staging 路径,以此落实"启动器绝不解析到 master 克隆"这一升级契约。 diff --git a/scripts/install.sh b/scripts/install.sh index 9a891f05d8..bdce88f72b 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -71,10 +71,12 @@ DSH_STAGING_BRANCH=dsh-staging/$DSH_STAMP DSH_STAGING=$DSH_SOURCE/staging-$DSH_STAMP # --- path helpers --------------------------------------------------------------- -# Every path comparison below runs on physical paths. macOS resolves /var through -# a symlink to /private/var, so comparing a git-reported (already resolved) path -# against an unresolved one silently misclassifies an existing managed install as -# a foreign clone and builds a second container beside the real one. +# Every path comparison below runs on physical paths. Git always reports resolved +# paths, so comparing one against an unresolved path disagrees whenever a symlink +# sits anywhere above the checkout — a symlinked home directory is enough, and +# macOS reaches every mktemp path that way through /var -> private/var. The +# mismatch silently misclassifies an existing managed install as a foreign clone +# and builds a second container beside the real one. # `git rev-parse --path-format=absolute` would do this, but it needs git 2.31+. # # A not-yet-created directory (the container on a fresh install) has no physical diff --git a/skills/dsh-customize/SKILL.md b/skills/dsh-customize/SKILL.md index 865e21dba5..0da62ee563 100644 --- a/skills/dsh-customize/SKILL.md +++ b/skills/dsh-customize/SKILL.md @@ -12,7 +12,7 @@ Make personal DSH changes in task worktrees and integrate them under the staging Do not assume a path or branch name. DSH is usually installed from source with a personal staging branch; create one for the user only when none exists. 1. Inspect `command -v dsh` in the user's launch environment before resolving symlinks. -2. Follow the launcher through the full symlink chain to reach the source checkout, then ask Git for everything else. The `dsh` on PATH is a symlink, usually through a stable `current` symlink into the active staging worktree; resolve the chain physically and take the launcher's parent directory as the checkout. Derive the rest from that checkout rather than from any path convention: `git -C rev-parse --show-toplevel` confirms the checkout root, and `git -C rev-parse --git-common-dir` gives the shared git directory — a linked worktree reports the real clone's, not its own — whose parent is the main clone, the one real clone whose object store every worktree shares. `--git-common-dir` answers relatively for a plain clone, so anchor it against the checkout before use, and resolve it physically: comparing a resolved path against an unresolved one silently misidentifies the clone, since macOS reaches `/var` through a symlink to `/private/var`. `git -C
worktree list` then enumerates every checkout sharing it. +2. Follow the launcher through the full symlink chain to reach the source checkout, then ask Git for everything else. The `dsh` on PATH is a symlink, usually through a stable `current` symlink into the active staging worktree; resolve the chain physically and take the launcher's parent directory as the checkout. Derive the rest from that checkout rather than from any path convention: `git -C rev-parse --show-toplevel` confirms the checkout root, and `git -C rev-parse --git-common-dir` gives the shared git directory — a linked worktree reports the real clone's, not its own — whose parent is the main clone, the one real clone whose object store every worktree shares. `--git-common-dir` answers relatively for a plain clone, so anchor it against the checkout before use, and resolve it physically: Git reports resolved paths, so comparing one against an unresolved path silently misidentifies the clone whenever a symlink sits anywhere above the checkout, which a symlinked home directory alone is enough to cause. `git -C
worktree list` then enumerates every checkout sharing it. This resolves every checkout, so depend on nothing else: not an environment variable, not a container path, not the main clone's location or branch. A checkout whose launcher links straight at it, with no `current` in the chain, resolves the same way. 3. Verify the checkout with Git, then record its branch, tip, status, remotes, worktrees, in-progress operations, and applicable `AGENTS.md` files. From 43bbfce7ee4af0114f43213512cedaccb908dc3d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:33:45 +0800 Subject: [PATCH 17/19] test(web): refresh preserved queue access snapshot --- apps/web/tests/snapshots/queue-actions/preserved.expected.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tests/snapshots/queue-actions/preserved.expected.md b/apps/web/tests/snapshots/queue-actions/preserved.expected.md index 335610097d..4ef33d18b0 100644 --- a/apps/web/tests/snapshots/queue-actions/preserved.expected.md +++ b/apps/web/tests/snapshots/queue-actions/preserved.expected.md @@ -35,7 +35,7 @@ - textbox "Message the agent" - button "Commands": - img -- 'button "Access mode, current: Full access"': Full access +- 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img From 1d86be1b74c8ac6452d39b184edb7c2d96a79655 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:35:48 +0800 Subject: [PATCH 18/19] docs(install): correct managed-layout comments --- scripts/install.sh | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index bdce88f72b..485be277e0 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -53,16 +53,16 @@ set -eu DSH_REF=${DSH_REF:-master} DSH_REPO=${DSH_REPO:-https://github.com/deepseek-harness/deepseek-harness.git} -# DSH_SOURCE is the container directory that holds the master clone and every -# staging worktree; DSH_MASTER is the one real clone inside it. Remember whether -# the caller pinned the source container before defaulting it, so in-repo -# detection only repoints an unset DSH_SOURCE. +# DSH_SOURCE is the staging-worktree container and the default home of `current`. +# DSH_MASTER names the main clone: clone mode defaults it inside DSH_SOURCE, +# while adoption discovers an existing clone anywhere on disk. Remember whether +# DSH_SOURCE was explicit so a different path selects clone mode. if [ -n "${DSH_SOURCE:-}" ]; then DSH_SOURCE_EXPLICIT=1; else DSH_SOURCE_EXPLICIT=0; fi DSH_SOURCE=${DSH_SOURCE:-$HOME/.dsh/source} DSH_MASTER=${DSH_MASTER:-$DSH_SOURCE/master} -# The stable symlink the PATH launcher resolves through: PATH -> current/bin/dsh -# -> /bin/dsh. Fresh installs and upgrades repoint this one symlink; the -# PATH launcher itself is written once and never moves. In-repo reuse ignores it. +# The stable symlink the PATH launcher resolves through: PATH/dsh -> +# current/bin/dsh -> /bin/dsh. Installs and upgrades repoint `current`; +# the PATH target remains current/bin/dsh. DSH_CURRENT=${DSH_CURRENT:-$DSH_SOURCE/current} DSH_BIN_DIR=${DSH_BIN_DIR:-$HOME/.local/bin} # One UTC basic timestamp names this install's staging branch and worktree. From d22438e2b19ead2b14fae5bed5c788d2f9a5f036 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 1 Aug 2026 21:56:32 +0800 Subject: [PATCH 19/19] test(fs-search): re-record the glob-sampling snapshot against the real API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scenario previously carried an authored fixture; W4 of #1119 review requires a live transcript. Recording surfaced two composition bugs that are fixed here alongside it: - provider ids: the app and the replay catalog both named the old 'deepseek' provider, which no adapter registers; both now use 'deepseek-official' - the live config lacked persistenceCompression: none, so record-mode sessions were written zstd-compressed and could not be harvested (the snapshot twin already forced plaintext) Recorded logs also need deterministic replay: - packChunks: false in both configs — the eager-drain batch boundaries that split packed delta runs are timing-dependent, so a packed log of a long reasoning stream cannot replay-match its live record - the fixture's request/header config and request/context are normalized to the replay-produced minimal shape (the live adapter logs model capabilities llm-replay has no data for), and tool-result path separators are canonicalized to '/' for the Linux golden posixOnly is restored now that the fixture is recorded. --- examples/acp-agent/tests/acp.snapshot.ts | 13 +- .../tests/fs-search.cordis.snapshot.yml | 8 +- examples/acp-agent/tests/fs-search.cordis.yml | 7 +- .../snapshots/fs-glob-sampling/session.jsonl | 145 +++++++++++++++--- 4 files changed, 142 insertions(+), 31 deletions(-) diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 277d0b20ca..abdf10e367 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -181,16 +181,23 @@ const SCENARIOS: Scenario[] = [ // `--sort=modified` order, pinning over-cap glob sampling without depending // on a host-installed ripgrep binary or a PATH stand-in. POSIX-only because // the displayed paths carry `/` separators the session-log comparison - // cannot normalize. + // cannot normalize. Recorded (not authored): the assistant turn is a real + // model transcript; re-record with `test:snapshot:record -t fs-glob-sampling`. + // The composition disables packed chunk rows (fs-search.cordis.yml), whose + // run boundaries depend on eager-drain timing, and the recorded fixture's + // `request/header` config and `request/context` are normalized to the + // replay-produced minimal shape (the live adapter logs model capabilities + // like maxTokens/reasoningEffort that llm-replay has no data for), and its + // tool-result paths are canonicalized to `/` separators. { name: 'fs-glob-sampling', hasModelTurn: true, - recorded: false, + recorded: true, + posixOnly: true, pinsHeader: true, headerClass: 'fs-search', configPath: FS_SEARCH_CONFIG, prepareWorkspace: prepareFsSearchWorkspace, - posixOnly: true, }, { name: 'fs-read', hasModelTurn: true, recorded: true }, { name: 'fs-write', hasModelTurn: true, recorded: true }, diff --git a/examples/acp-agent/tests/fs-search.cordis.snapshot.yml b/examples/acp-agent/tests/fs-search.cordis.snapshot.yml index 141691a087..5fcb2248f3 100644 --- a/examples/acp-agent/tests/fs-search.cordis.snapshot.yml +++ b/examples/acp-agent/tests/fs-search.cordis.snapshot.yml @@ -3,7 +3,7 @@ name: '@deepseek-ai/dsh-llm-replay' config: providers: - - id: deepseek + - id: deepseek-official name: DeepSeek models: - id: deepseek-v4-pro @@ -17,10 +17,14 @@ - id: acp-agent name: '@deepseek-ai/dsh-acp-demo' config: - provider: deepseek + provider: deepseek-official model: deepseek-v4-pro persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' persistenceCompression: none + # Unpacked rows: the eager-drain batch boundaries that split packed delta + # runs are timing-dependent, so packed logs cannot replay-match a live + # record of a long reasoning stream. + packChunks: false workspaceContext: false skills: enabled: false diff --git a/examples/acp-agent/tests/fs-search.cordis.yml b/examples/acp-agent/tests/fs-search.cordis.yml index 153128f914..0f6d5d9a63 100644 --- a/examples/acp-agent/tests/fs-search.cordis.yml +++ b/examples/acp-agent/tests/fs-search.cordis.yml @@ -16,9 +16,14 @@ - id: acp-agent name: '@deepseek-ai/dsh-acp-demo' config: - provider: deepseek + provider: deepseek-official model: deepseek-v4-pro persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'" + # Unpacked rows: the eager-drain batch boundaries that split packed delta + # runs are timing-dependent, so packed logs cannot replay-match a live + # record of a long reasoning stream. + packChunks: false workspaceContext: false skills: enabled: false diff --git a/examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl b/examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl index ca51259632..f1c26ca911 100644 --- a/examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl @@ -1,25 +1,120 @@ -{"type":"session","version":0,"id":"f5a99d52-3eaa-4ce7-858d-61d4fd77df2a","createdAt":1785218400000,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1785218400001,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1785218400002,"data":{"content":[{"type":"text","text":"Call glob exactly once with pattern * and path tree. Then reply with exactly GLOB_SAMPLED and nothing else."}],"source":{"kind":"user"},"role":"user","id":"6790985f-1de2-42f8-a7f1-24e46d6439c7"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1785218400003,"data":{"title":"Call glob exactly once with","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"step/start","seq":3,"time":1785218400004,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1785218400005,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-pro"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":5,"time":1785483397569,"data":{"provider":"deepseek","model":"deepseek-v4-pro"}} -{"type":"assistant/chunk","seq":6,"time":1785218400007,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":7,"time":1785218400008,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"glob-sampling-call","name":"glob","argumentsDelta":"{\"pattern\":\"*\",\"path\":\"tree\"}"}}} -{"type":"assistant/chunk","seq":8,"time":1785218400009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"glob-sampling-call","name":"glob","arguments":"{\"pattern\":\"*\",\"path\":\"tree\"}"}}}} -{"type":"assistant/chunk","seq":9,"time":1785218400010,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1,"outputTokens":1}}}} -{"type":"assistant/chunk","seq":10,"time":1785483397579,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":11,"time":1785483397579,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"glob-sampling-call","name":"glob","arguments":"{\"pattern\":\"*\",\"path\":\"tree\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-pro"},"id":"a127cfe5-39fb-462c-8e5a-a8c79bd0e52b"},"usage":{"inputTokens":1,"outputTokens":1}},"sourceEventSeqs":[6,7,8,9,10],"surfaceOp":"append"} -{"type":"tool/call","seq":12,"time":1785483397579,"data":{"turn":1,"step":1,"callId":"glob-sampling-call","name":"glob","arguments":"{\"pattern\":\"*\",\"path\":\"tree\"}"}} -{"type":"tool/result","seq":13,"time":1785483398062,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"glob-sampling-call"},"content":[{"type":"tool-result","toolCallId":"glob-sampling-call","content":[{"type":"text","text":"tree/archive/a.ts\ntree/docs/guide.md\ntree/src/index.ts\ntree/test/spec.ts\n\n(Showing 4 of 8 paths, sampled across 4 of the 6 top-level entries this pattern matched instead of taken in modification-time order. Narrow path to inspect a specific subtree. The complete result could not be saved; narrow pattern or path to see more.)"}],"isError":false}],"role":"user","id":"2beecb2e-627d-43dc-a936-03e1dc874093"},"meta":{"shape":"paths","paths":["tree/archive/a.ts","tree/docs/guide.md","tree/src/index.ts","tree/test/spec.ts"],"truncated":true,"total":8}},"sourceEventSeqs":[12],"surfaceOp":"append"} -{"type":"step/end","seq":14,"time":1785483398062,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":15,"time":1785483398072,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":16,"time":1785218400017,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":17,"time":1785218400018,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"GLOB_SAMPLED"}}} -{"type":"assistant/chunk","seq":18,"time":1785218400019,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"GLOB_SAMPLED"}}}} -{"type":"assistant/chunk","seq":19,"time":1785218400020,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":1,"outputTokens":1}}}} -{"type":"assistant/chunk","seq":20,"time":1785483398078,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":21,"time":1785483398078,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"GLOB_SAMPLED"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-pro"},"id":"ce2334a4-be71-490b-a502-29186a9ced5c"},"usage":{"inputTokens":1,"outputTokens":1}},"sourceEventSeqs":[16,17,18,19,20],"surfaceOp":"append"} -{"type":"step/end","seq":22,"time":1785483398078,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":23,"time":1785483398079,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"4428b809-66d5-4ea2-9a03-89de742fcda1","createdAt":1785591986068,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"turn/start","seq":0,"time":1785591986072,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1785591986073,"data":{"content":[{"type":"text","text":"Call glob exactly once with pattern * and path tree. Then reply with exactly GLOB_SAMPLED and nothing else."}],"source":{"kind":"user"},"role":"user","id":"3d05fb76-4185-460b-9c6a-8c1b2495bc9f"},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":1785591986074,"data":{"title":"Call glob exactly once with","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":1785591986092,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":1785591986093,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-pro"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":5,"time":1785591986094,"data":{"provider":"deepseek-official","model":"deepseek-v4-pro"}} +{"type":"assistant/chunk","seq":6,"time":1785591987500,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":7,"time":1785591987500,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":8,"time":1785591987529,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":9,"time":1785591987587,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":10,"time":1785591987588,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":11,"time":1785591987588,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":12,"time":1785591987588,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" call"}}} +{"type":"assistant/chunk","seq":13,"time":1785591987588,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" glob"}}} +{"type":"assistant/chunk","seq":14,"time":1785591987639,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":15,"time":1785591987639,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" once"}}} +{"type":"assistant/chunk","seq":16,"time":1785591987639,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":17,"time":1785591987685,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" pattern"}}} +{"type":"assistant/chunk","seq":18,"time":1785591987685,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" *"}}} +{"type":"assistant/chunk","seq":19,"time":1785591987876,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":20,"time":1785591987877,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" path"}}} +{"type":"assistant/chunk","seq":21,"time":1785591987877,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tree"}}} +{"type":"assistant/chunk","seq":22,"time":1785591987877,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":23,"time":1785591987877,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} +{"type":"assistant/chunk","seq":24,"time":1785591987877,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":25,"time":1785591987877,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":26,"time":1785591987877,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":27,"time":1785591987877,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":28,"time":1785591987878,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"G"}}} +{"type":"assistant/chunk","seq":29,"time":1785591987878,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"LOB"}}} +{"type":"assistant/chunk","seq":30,"time":1785591987878,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_S"}}} +{"type":"assistant/chunk","seq":31,"time":1785591987878,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"AM"}}} +{"type":"assistant/chunk","seq":32,"time":1785591987878,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"PL"}}} +{"type":"assistant/chunk","seq":33,"time":1785591987878,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ED"}}} +{"type":"assistant/chunk","seq":34,"time":1785591987977,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":35,"time":1785591988034,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":36,"time":1785591988035,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":37,"time":1785591988090,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":38,"time":1785591988090,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":39,"time":1785591988090,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"pattern"}}} +{"type":"assistant/chunk","seq":40,"time":1785591988091,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":41,"time":1785591988136,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":42,"time":1785591988136,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":43,"time":1785591988136,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"*"}}} +{"type":"assistant/chunk","seq":44,"time":1785591988193,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":45,"time":1785591988207,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":46,"time":1785591988207,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":47,"time":1785591988207,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"path"}}} +{"type":"assistant/chunk","seq":48,"time":1785591988207,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":49,"time":1785591988207,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":50,"time":1785591988284,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":51,"time":1785591988284,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"tree"}}} +{"type":"assistant/chunk","seq":52,"time":1785591988284,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":53,"time":1785591988338,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":54,"time":1785591988427,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to call glob exactly once with pattern * and path tree, then reply with exactly \"GLOB_SAMPLED\"."}}}} +{"type":"assistant/chunk","seq":55,"time":1785591988427,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","arguments":"{\"pattern\": \"*\", \"path\": \"tree\"}"}}}} +{"type":"assistant/chunk","seq":56,"time":1785591988427,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1286,"outputTokens":87,"cacheReadTokens":0,"reasoningTokens":28}}}} +{"type":"assistant/chunk","seq":57,"time":1785591988427,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":58,"time":1785591988430,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to call glob exactly once with pattern * and path tree, then reply with exactly \"GLOB_SAMPLED\"."},{"type":"tool-call","id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","arguments":"{\"pattern\": \"*\", \"path\": \"tree\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"b74cbab2-c017-4e44-8c09-a7745d8b274a"},"usage":{"inputTokens":1286,"outputTokens":87,"cacheReadTokens":0,"reasoningTokens":28}},"sourceEventSeqs":[6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57],"surfaceOp":"append"} +{"type":"tool/call","seq":59,"time":1785591988431,"data":{"turn":1,"step":1,"callId":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","arguments":"{\"pattern\": \"*\", \"path\": \"tree\"}"}} +{"type":"tool/result","seq":60,"time":1785591988476,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_1cLZjkCW0vxVw0e3xVfh3430"},"content":[{"type":"tool-result","toolCallId":"call_00_1cLZjkCW0vxVw0e3xVfh3430","content":[{"type":"text","text":"tree/archive/a.ts\ntree/docs/guide.md\ntree/src/index.ts\ntree/test/spec.ts\n\n(Showing 4 of 8 paths, sampled across 4 of the 6 top-level entries this pattern matched instead of taken in modification-time order. Narrow path to inspect a specific subtree. The complete result could not be saved; narrow pattern or path to see more.)"}],"isError":false}],"role":"user","id":"10284f88-4890-49ed-9a17-56edbd6bfaa7"},"meta":{"shape":"paths","paths":["tree/archive/a.ts","tree/docs/guide.md","tree/src/index.ts","tree/test/spec.ts"],"truncated":true,"total":8}},"sourceEventSeqs":[59],"surfaceOp":"append"} +{"type":"step/end","seq":61,"time":1785591988476,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":62,"time":1785591988482,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":63,"time":1785591989939,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":64,"time":1785591989939,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":65,"time":1785591989939,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" glob"}}} +{"type":"assistant/chunk","seq":66,"time":1785591989939,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" result"}}} +{"type":"assistant/chunk","seq":67,"time":1785591989939,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" shows"}}} +{"type":"assistant/chunk","seq":68,"time":1785591989988,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":69,"time":1785591990024,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" was"}}} +{"type":"assistant/chunk","seq":70,"time":1785591990127,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" sampled"}}} +{"type":"assistant/chunk","seq":71,"time":1785591990128,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" -"}}} +{"type":"assistant/chunk","seq":72,"time":1785591990128,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}} +{"type":"assistant/chunk","seq":73,"time":1785591990128,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"4"}}} +{"type":"assistant/chunk","seq":74,"time":1785591990454,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" of"}}} +{"type":"assistant/chunk","seq":75,"time":1785591990454,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}} +{"type":"assistant/chunk","seq":76,"time":1785591990454,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"8"}}} +{"type":"assistant/chunk","seq":77,"time":1785591990454,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" paths"}}} +{"type":"assistant/chunk","seq":78,"time":1785591990454,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" across"}}} +{"type":"assistant/chunk","seq":79,"time":1785591990454,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}} +{"type":"assistant/chunk","seq":80,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"4"}}} +{"type":"assistant/chunk","seq":81,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" of"}}} +{"type":"assistant/chunk","seq":82,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}} +{"type":"assistant/chunk","seq":83,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"6"}}} +{"type":"assistant/chunk","seq":84,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" top"}}} +{"type":"assistant/chunk","seq":85,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"-level"}}} +{"type":"assistant/chunk","seq":86,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" entries"}}} +{"type":"assistant/chunk","seq":87,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":88,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":89,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":90,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":91,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":92,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":93,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":94,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":95,"time":1785591990456,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"G"}}} +{"type":"assistant/chunk","seq":96,"time":1785591990456,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"LOB"}}} +{"type":"assistant/chunk","seq":97,"time":1785591990456,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_S"}}} +{"type":"assistant/chunk","seq":98,"time":1785591990456,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"AM"}}} +{"type":"assistant/chunk","seq":99,"time":1785591990456,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"PL"}}} +{"type":"assistant/chunk","seq":100,"time":1785591990456,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ED"}}} +{"type":"assistant/chunk","seq":101,"time":1785591990456,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":102,"time":1785591990456,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" as"}}} +{"type":"assistant/chunk","seq":103,"time":1785591990456,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instructed"}}} +{"type":"assistant/chunk","seq":104,"time":1785591990470,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":105,"time":1785591990470,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":106,"time":1785591990470,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"G"}}} +{"type":"assistant/chunk","seq":107,"time":1785591990470,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"LOB"}}} +{"type":"assistant/chunk","seq":108,"time":1785591990470,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_S"}}} +{"type":"assistant/chunk","seq":109,"time":1785591990470,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"AM"}}} +{"type":"assistant/chunk","seq":110,"time":1785591990518,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"PL"}}} +{"type":"assistant/chunk","seq":111,"time":1785591990518,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ED"}}} +{"type":"assistant/chunk","seq":112,"time":1785591990526,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The glob result shows it was sampled - 4 of 8 paths across 4 of 6 top-level entries. I need to reply with exactly \"GLOB_SAMPLED\" as instructed."}}}} +{"type":"assistant/chunk","seq":113,"time":1785591990527,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"GLOB_SAMPLED"}}}} +{"type":"assistant/chunk","seq":114,"time":1785591990527,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":188,"outputTokens":48,"cacheReadTokens":1280,"reasoningTokens":41}}}} +{"type":"assistant/chunk","seq":115,"time":1785591990527,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":116,"time":1785591990527,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The glob result shows it was sampled - 4 of 8 paths across 4 of 6 top-level entries. I need to reply with exactly \"GLOB_SAMPLED\" as instructed."},{"type":"text","text":"GLOB_SAMPLED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"dd3a9c28-43b2-4fdc-8089-1547309a71c0"},"usage":{"inputTokens":188,"outputTokens":48,"cacheReadTokens":1280,"reasoningTokens":41}},"sourceEventSeqs":[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],"surfaceOp":"append"} +{"type":"step/end","seq":117,"time":1785591990527,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":118,"time":1785591990528,"data":{"turn":1,"reason":{"kind":"completed"}}}