@wingsky-1/dsh-worktree-sidebar 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +186 -0
  3. package/README.md +190 -0
  4. package/cordis.patch.yml +9 -0
  5. package/lib/client/bindings.d.ts +16 -0
  6. package/lib/client/index.d.ts +23 -0
  7. package/lib/client/inject.d.ts +21 -0
  8. package/lib/client/shared/ports.d.ts +141 -0
  9. package/lib/client/source.d.ts +22 -0
  10. package/lib/client/takeover.d.ts +51 -0
  11. package/lib/client.js +394 -0
  12. package/lib/index.d.ts +28 -0
  13. package/lib/index.js +1614 -0
  14. package/lib/server/api/deps.d.ts +35 -0
  15. package/lib/server/api/impl/handlers/index.d.ts +24 -0
  16. package/lib/server/api/impl/route/index.d.ts +25 -0
  17. package/lib/server/api/impl/service/index.d.ts +31 -0
  18. package/lib/server/api/interface.d.ts +12 -0
  19. package/lib/server/binding/deps.d.ts +9 -0
  20. package/lib/server/binding/impl/model/index.d.ts +16 -0
  21. package/lib/server/binding/impl/model/type.d.ts +37 -0
  22. package/lib/server/binding/impl/service/index.d.ts +67 -0
  23. package/lib/server/binding/impl/store/index.d.ts +6 -0
  24. package/lib/server/binding/interface.d.ts +24 -0
  25. package/lib/server/git/deps.d.ts +59 -0
  26. package/lib/server/git/impl/exec/index.d.ts +3 -0
  27. package/lib/server/git/impl/inspect/index.d.ts +47 -0
  28. package/lib/server/git/impl/service/index.d.ts +70 -0
  29. package/lib/server/git/interface.d.ts +41 -0
  30. package/lib/server/host/agents.d.ts +39 -0
  31. package/lib/server/host/sessions.d.ts +41 -0
  32. package/lib/server/host/typert.d.ts +11 -0
  33. package/lib/server/scope/deps.d.ts +110 -0
  34. package/lib/server/scope/impl/inherit/index.d.ts +25 -0
  35. package/lib/server/scope/impl/own/index.d.ts +21 -0
  36. package/lib/server/scope/impl/resolve/index.d.ts +40 -0
  37. package/lib/server/scope/impl/service/index.d.ts +115 -0
  38. package/lib/server/scope/interface.d.ts +40 -0
  39. package/lib/server/shared/file-io.d.ts +19 -0
  40. package/lib/server/shared/interface.d.ts +11 -0
  41. package/lib/server/shared/paths.d.ts +2 -0
  42. package/lib/server/shared/type.d.ts +6 -0
  43. package/lib/server/tools/deps.d.ts +52 -0
  44. package/lib/server/tools/impl/bind/index.d.ts +62 -0
  45. package/lib/server/tools/impl/create/index.d.ts +4 -0
  46. package/lib/server/tools/impl/protocol/index.d.ts +36 -0
  47. package/lib/server/tools/impl/register/index.d.ts +4 -0
  48. package/lib/server/tools/impl/remove/index.d.ts +12 -0
  49. package/lib/server/tools/impl/service/index.d.ts +28 -0
  50. package/lib/server/tools/impl/session/index.d.ts +18 -0
  51. package/lib/server/tools/interface.d.ts +14 -0
  52. package/lib/shared/contract.d.ts +26 -0
  53. package/lib/shared/interface.d.ts +8 -0
  54. package/package.json +103 -0
  55. package/shared/client/ensure-style.d.ts +21 -0
  56. package/shared/client/i18n.d.ts +15 -0
  57. package/shared/dsh-home.d.ts +15 -0
  58. package/shared/host-utils.d.ts +65 -0
  59. package/shared/loopback.d.ts +21 -0
  60. package/shared/settings-namespace.d.ts +46 -0
  61. package/shared/sse-hub.d.ts +76 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 wingsky-1
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.en.md ADDED
@@ -0,0 +1,186 @@
1
+ # @wingsky-1/dsh-worktree-sidebar
2
+ [![npm](https://img.shields.io/npm/v/@wingsky-1/dsh-worktree-sidebar)](https://www.npmjs.com/package/@wingsky-1/dsh-worktree-sidebar)
3
+ [![GitHub Releases](https://img.shields.io/github/v/release/wingsky-1/dsh-plugin-hub)](https://github.com/wingsky-1/dsh-plugin-hub/releases)
4
+
5
+ Three tools that let the agent bind a **git worktree** to the current session, so that session's right-sidebar file tree is rooted at that worktree — **without changing the session cwd**.
6
+
7
+ This is a **transitional adapter**: if the official product ships native worktree sessions, this plugin retires.
8
+ How it works (five host domains, host/client chains, self-healing rules, the six things to remember) is documented in [docs/architecture/dsh-worktree-sidebar.md](../../docs/architecture/dsh-worktree-sidebar.md).
9
+
10
+ ## What it does
11
+
12
+ The right sidebar's file tree is always rooted at `session.header.cwd`, and that field is immutable once the session is created (adopting a session with a different cwd throws `ApiSessionCwdConflict`). So when a session runs in the main checkout while the actual work happens in a worktree, the tree shows the wrong place.
13
+
14
+ The plugin exposes three tools to the agent:
15
+
16
+ | Tool | Effect |
17
+ |---|---|
18
+ | `ws_worktree_register` | Bind an **existing** worktree to the current session |
19
+ | `ws_worktree_create` | Run `git worktree add` first, then bind (path and branch come from the caller; the plugin imposes no layout convention); an optional `base` picks the start point, defaulting to the current HEAD of the repository the session working directory is in |
20
+ | `ws_worktree_remove` | Drop the binding; only removes the directory via `git worktree remove` when explicitly asked |
21
+
22
+ Once bound, **open or refresh** the Files tab and it lists the worktree; opening a file previews the worktree's copy.
23
+ The tab does **not** follow automatically: the plugin does not poll the host, and re-reads the binding only when you open the tab, hit the built-in refresh, or the window becomes visible/focused again.
24
+
25
+ Subagent sessions **and user-forked sessions** inherit their parent's binding: a session without a binding of its own roots its Files tab at the worktree of the **first session up the parent chain that holds a registration** (the walk stops at the top, and falls back to the session's own cwd once that registration is dropped). The criterion is the header's `parentSession`, and the plugin deliberately does not tell the two shapes apart — a fork copies the parent's cwd, so inheriting the view root keeps it consistent with "the file root is a rewrite of the session cwd".
26
+
27
+ Tools are exposed to **every agent inside a git repository, subagents included**, decided per agent when it is created; a second check at execution time covers environments that changed in between. Each tool's result text states which worktree is bound and on which branch, so the model need not call another tool to confirm.
28
+
29
+ `ws_worktree_create`'s `base` is a commit-ish (branch, tag, SHA — e.g. `origin/main`). Its shape is checked first (a value starting with `-` is rejected), then it is normalized to a SHA before git sees it: after `<path>`, git **restarts option parsing** (measured: `base: "-f"` / `"--force"` reports success yet checks out HEAD), and a normalized SHA gives that parsing nothing to latch onto.
30
+
31
+ ## Explicit non-goals (known inconsistencies)
32
+
33
+ These are deliberate trade-offs of "change only the view root", not a backlog:
34
+
35
+ - `@` file references, `present` targets and the skill catalog **still anchor `session.header.cwd`**. The tree points at the worktree while `@` lists the main checkout — the most easily misunderstood part of this plugin.
36
+ - **Under the `workspace-write` file policy the agent cannot write into the worktree**: the write fence's `sandboxPolicy.workspaceRoot` also derives from `header.cwd`, and that service cannot be replaced by a third-party plugin. Use `danger-full-access`, or open the session inside the worktree.
37
+ - **No diff marker** for same-named files between the main checkout and the worktree.
38
+ - "Tree root = worktree, execution cwd = original directory" is this design's premise. The agent's commands still run in the original cwd.
39
+ - The rewritten session view **affects only the single entry this plugin registers**: the client attaches the rewrite to `hooks.sessions` of the official entry inject face, so only that one entry's inject face is affected; every other `useSessions` consumer (preview, command palette, `@`, ...) still sees the session's real cwd. That is a direct consequence of "change only the view root", not a backlog item.
40
+
41
+ ## Install
42
+
43
+ Requires DeepSeek Harness with a working `dsh web` (see "dsh not installed globally" otherwise).
44
+
45
+ ### Install the plugin (add)
46
+
47
+ ```sh
48
+ dsh plugin --profile web add @wingsky-1/dsh-worktree-sidebar
49
+ ```
50
+
51
+ ### Remove the plugin (remove)
52
+
53
+ ```sh
54
+ dsh plugin --profile web remove @wingsky-1/dsh-worktree-sidebar
55
+ ```
56
+
57
+ After removal the binding table remains at `<DSH_HOME>/@wingsky-1/dsh-worktree-sidebar/bindings.json` but has no effect. Worktrees you created are **not** deleted; clean up yourself if needed:
58
+
59
+ ```sh
60
+ git worktree list # see what is left
61
+ git worktree prune # drop registrations whose directory was deleted by hand
62
+ ```
63
+
64
+ ### Update the plugin (update)
65
+
66
+ ```sh
67
+ dsh plugin --profile web update @wingsky-1/dsh-worktree-sidebar
68
+ ```
69
+
70
+ > Install / remove / update each require **one restart** of `dsh web` (bundles are composed at startup only).
71
+
72
+ ### Pin a version (@version)
73
+
74
+ Omit `@version` to install latest (recommended). Append it only when the registry lags or a specific release misbehaves in your environment:
75
+
76
+ ```sh
77
+ dsh plugin --profile web add @wingsky-1/dsh-worktree-sidebar@<version>
78
+ ```
79
+
80
+ ### dsh not installed globally
81
+
82
+ ```sh
83
+ npx @deepseek-ai/dsh plugin --profile web add @wingsky-1/dsh-worktree-sidebar
84
+ npx @deepseek-ai/dsh plugin --profile web remove @wingsky-1/dsh-worktree-sidebar
85
+ npx @deepseek-ai/dsh plugin --profile web update @wingsky-1/dsh-worktree-sidebar
86
+ ```
87
+
88
+ ## Configuration
89
+
90
+ - A single master switch `enabled` comes in via the plugin config (`WorktreeSidebarConfig`, `src/index.ts:41-45`): with `enabled === false` nothing is taken over, no tools are registered, no routes are mounted (`src/index.ts:98`); omitting it enables the plugin.
91
+ - The plugin never reads a user config file from disk; it owns its binding table at `<DSH_HOME>/@wingsky-1/dsh-worktree-sidebar/bindings.json`, derived from the single variable `DSH_HOME` (`src/server/shared/paths.ts:9-17`).
92
+ - `ws_worktree_create`'s `base` defaults to the current HEAD of the repository the session working directory is in; an explicit `base` is shape-checked (a leading `-` is rejected) and normalized to a SHA before reaching git, so the start point cannot be silently dropped.
93
+ - `base` is a commit-ish (branch, tag, SHA, e.g. `origin/main`); an omitted branch adds an explicit `--detach`, checking out the start point detached without creating a branch.
94
+ - With `enabled === false` the binding table is not even read; existing registrations stay on disk with no effect.
95
+
96
+ ## Contract
97
+
98
+ - Only two things must agree on both ends, defined once in `src/shared/contract.ts:12-28`: route paths (`ROUTES`) and the binding-query response shape (`BindingResponse`); routes are additionally injected into the client at build time via `__DSH_ROUTES__`, keeping both ends consistent. See §2.4 of the architecture doc.
99
+ - Both routes are GET and read-only: `GET /api/dsh-worktree-sidebar/bindings?session=<id>` answers `{ revision, worktreePath | null }`; `GET /api/dsh-worktree-sidebar/health` answers `{ ok, revision, scopeTakeover, scopeChain }` (`src/server/api/impl/handlers/index.ts:24-67`).
100
+ - Minimal exposure: the binding query never returns `repoRoot` — the client only needs the directory root; a missing `session` parameter is a 400, never an empty answer that could be misread as "unbound" (`handlers/index.ts:1-8`, `30-34`).
101
+ - `revision` is a content version, not a write counter: an empty table starts at 0, storing one binding bumps it by one; dropping a nonexistent target returns the table unchanged without bumping (`src/server/binding/impl/model/index.ts:10-13`, `67-86`).
102
+ - The client only compares for equality and guards monotonicity: no notification when revision and path are unchanged, and a smaller revision arriving out of order is discarded (`src/client/bindings.ts:40-48`).
103
+ - The query endpoint carries a self-healing side effect: it resolves the effective root first and reads the revision second, dropping registrations positively confirmed as stale; merely ignoring them would leave the revision unchanged and the tree pointing at a dead root (`handlers/index.ts:35-37`).
104
+
105
+ ## Verification
106
+
107
+ ```sh
108
+ pnpm build && pnpm test # unit + integration, including real git repos and real worktree add/remove
109
+ pnpm gate:pr # before opening a PR; a new package and catalog entry also need pnpm gate:full
110
+ ```
111
+
112
+ Four **UI semantics** cannot be covered by any automated gate (there is no browser in `gate:*`) and are pre-release manual evidence, verified in an isolated live environment:
113
+
114
+ 1. the file tree lists the worktree's contents;
115
+ 2. opening a file previews the worktree's copy;
116
+ 3. an unbound session — and an install without this plugin — behaves identically (no regression);
117
+ 4. a subagent or forked session's tree follows the binding held by the session that owns it up the parent chain, and the three tools report the same root.
118
+
119
+ ## Test strategy
120
+
121
+ - Two layers: `test/unit` drives `src` modules directly (pure logic, domain assembly, client contract assertions); `test/integration` goes through the composition root with real git repositories and real temp directories; all on-disk output goes to `mkdtempSync` isolation directories (see §12 of the proposal).
122
+ - Zero file exclusions in the mutation surface: `mutate` covers `src/**/*.ts`, the package config revokes the four shared-default literal exclusions, leaving the effective operator exclusion set empty (`scripts/data/gauntlet.config.json:51`, #847).
123
+ - Inheritance criterion: forked and subagent sessions resolve bindings up the parent chain; in the inherited state the tool surface never drops the parent record and never calls git, reporting only the owning session and the three ways out (`test/unit/tools.test.ts:587`, "tool surface in the inherited state (#847)").
124
+ - Routes always carry 403/405 fence cases plus a two-end route-consistency assertion (proposal §12; `src/server/api/impl/route/index.ts:41`, 403 before 405).
125
+ - This section is read-only description: commands and gate wording follow the "Verification" section and the repository-root AGENTS.md; no commands are promised here.
126
+
127
+ ## Troubleshooting
128
+
129
+ - Tools say bound while the sidebar still follows the cwd: check `scopeTakeover` in `/health` first — the two readings differ deliberately: the tool surface reports the registration fact (no takeover gate), and only `live` means the file root really switched (`src/server/scope/interface.ts:31-40`).
130
+ - Liveness and state queries (use the port your local `dsh web` actually listens on; the example uses the default 3080):
131
+
132
+ ```sh
133
+ curl 'http://127.0.0.1:3080/api/dsh-worktree-sidebar/health'
134
+ curl 'http://127.0.0.1:3080/api/dsh-worktree-sidebar/bindings?session=session-1'
135
+ ```
136
+
137
+ - Two common causes for a non-`live` `scopeTakeover`: `waiting` (provider not registered yet; startup order is not stable) and `abandoned` (the lookup table is held by a third party); `scopeChain` records the one silent degradation where the persistence read failed (`src/server/api/impl/handlers/index.ts:44-53`).
138
+ - A failed client fetch keeps the last good state (G6): every failure yields undefined, never null, so a first-ever failure falls back to the real cwd (`src/client/index.ts:37-56`, `src/client/bindings.ts:30-39`); both ends read the revision from the same in-memory snapshot, and the client never moves the root on mismatch (G7, see "Contract").
139
+ - Mount or takeover entry failures only speak through `console.warn`: nothing is registered and the right column keeps official behaviour (`src/client/index.ts:121-124`, `src/client/takeover.ts:226-233`).
140
+
141
+ ## Compatibility (read-only coupling)
142
+
143
+ The plugin does not modify official sources, but it **reads** these contracts (baseline `@deepseek-ai/dsh 0.1.5-rc.1`):
144
+
145
+ - the host's `typert` `workspaceFileScope` lookup: the plugin registers its resolver via `lookups.configure` and delegates to the **official resolve captured before configuring** on a miss;
146
+ - the client `sidebarRightTabs` type registry and the keyed seat `sidebar.right.pane.tab` (including the `StoredEntry` shape: `component/inject/store/locale`);
147
+ - the session hook source contract `{ getSnapshot(), subscribe(fn) }`, where `getSnapshot` must return a **reference-stable** snapshot;
148
+ - the official client package `@deepseek-ai/dsh-client-ui-sidebar-right` must be present.
149
+
150
+ On any of these failing the behaviour is **zero registration / fall back to official**: if the official tab implementation cannot be captured, no tab is registered at all and the tree shows the cwd exactly as before. **Visibly doing nothing beats silently showing the wrong place.**
151
+
152
+ ## Security model
153
+
154
+ - **Loopback-only routes**: non-loopback requests to `/api/dsh-worktree-sidebar/*` get 403; unknown methods get 405 (403 before 405). No file-reading surface is exposed to the browser.
155
+ - **The query endpoint has a self-healing side effect**: `GET /api/dsh-worktree-sidebar/bindings` may drop a binding it has positively confirmed as stale while resolving the effective root (rewriting `bindings.json` and bumping the revision). That is deliberate: the client uses the revision as its cache key, so merely ignoring a stale binding would leave the revision unchanged and the tree pointing at a root that no longer holds.
156
+ - **The main repository path is never returned**: the binding query answers only `{ revision, worktreePath | null }`.
157
+ - **git runs via `execFile` with argv only**, never a shell.
158
+ - **Branch names are validated by git itself** (`git check-ref-format --branch`); every positional argument follows `--`, so a path that looks like `--force` is never read as a flag.
159
+ - **An omitted branch adds an explicit `--detach`**: plain `git worktree add <path>` creates a **new branch named after the directory basename**, so a basename with a space (common in macOS home directories) is rejected as an invalid branch name and one starting with `-` is re-parsed as a flag — `--` only guards `worktree add`'s own option parsing. With `--detach`, "omit the branch" really means "check out the start point, detached" — the repository HEAD by default, or the `base` you passed.
160
+ - **Removal is explicit**: `ws_worktree_remove` only drops the binding unless explicitly told otherwise, and `--force` must be asked for separately. The plugin only ever runs `git worktree remove` — never `rm -rf`.
161
+ - **State lives under `DSH_HOME`**: `bindings.json` is written atomically (temp file + `rename`); a corrupt or future-versioned file is treated as empty rather than guessed at.
162
+ - **No credentials, no network**: tools only write the plugin's own binding table and invoke git.
163
+
164
+ ## Known limitations
165
+
166
+ - **Session ids are reused by new sessions after a restart, and the binding does not follow**: the official session id is an **in-process counter** (`session-1`, `session-2`, ...), so a restarted `dsh web` hands `session-1` to a brand-new session. Each binding therefore also stores the session header's `createdAt` as its identity: a mismatch (a different session) drops that binding and the tree falls back to cwd, while a genuinely restored session matches and keeps it (when the check cannot be read at all, the binding is conservatively kept — one IO hiccup must not permanently drop a user's binding).
167
+ - **Worktree directory deleted from outside**: treated as unbound and the tree returns to the real cwd (visible, never pointing at a missing directory). But when the **ownership reading is unavailable** (permissions, failing git) the binding is kept and a warning is emitted: only two positively-read, differing common git directories count as "no longer a worktree of this repository".
168
+ - **One worktree per session**: a second binding overwrites the first.
169
+ - **No system-prompt injection**: the model is not told the tool exists beyond the tool list and result text. This is deliberate (no always-on prompt cost); discovery depends on the model inspecting its tools.
170
+ - **Cannot write into the worktree under `workspace-write`** (see non-goals).
171
+ - **One `dsh web` restart is needed after install or upgrade.**
172
+ - **Per-session state is released with the plugin itself**: the client no longer infers liveness (per-session pruning was removed together with the polling), and uses the snapshot only to rewrite the single field `byId[sessionId].cwd`; views and subscriptions are released when the plugin unmounts (`releaseAllSeedings()` + `views.clear()` inside `ctx.effect`), and the view cache is capped at 128 entries, evicting the coldest sessions (each view is an independent reader of the same host fact, so an eviction never makes a tree read a different place).
173
+ - **A second assembly in the same process throws**: all five domains are in-process singletons (`install` / `release` pairs with an `installed` guard), so a second instance cannot mount and the second `install` throws instead of silently sharing state. If a profile mounts this package twice you get one explicit startup error; the old "two instances do not interfere" semantics is gone.
174
+ - **Inheritance is re-resolved along the parent chain every time**: the Files tab root comes from the first session up the chain that holds a registration; once that registration is dropped, or found invalid during resolution, descendants fall back to their own cwd (never silently pointing at a root that no longer holds).
175
+ - **When the takeover is not in effect, tools and the Files tab disagree (deliberately)**: the `workspaceFileScope` takeover has a waiting state (provider not registered yet) and an abandoned state (taken by a third party); in both, the plugin leaves the file root alone and the Files tab still follows the cwd, while the three tools report the **registration fact**. `scopeTakeover` in `/health` is the authority (nothing but `live` switches the root).
176
+ - **`ws_worktree_remove` in the inherited state drops nothing**: it never unbinds the parent session's registration and never removes the directory; it reports which session owns the root plus the ways out (unbind there / bind another worktree here / register this session to its own cwd).
177
+
178
+ ## Retirement criteria
179
+
180
+ Any one of these makes the plugin unnecessary:
181
+
182
+ - the official product ships native worktree sessions (a session carries its own working-area switch);
183
+ - the official product ships a supported way to switch the file root per session;
184
+ - `SessionHeader.cwd` becomes mutable, or `workspaceFileScope` gains a supported third-party extension point.
185
+
186
+ After retiring, clean up worktrees left behind: `git worktree list` to inspect, `git worktree prune` to drop stale registrations.
package/README.md ADDED
@@ -0,0 +1,190 @@
1
+ # @wingsky-1/dsh-worktree-sidebar
2
+ [![npm](https://img.shields.io/npm/v/@wingsky-1/dsh-worktree-sidebar)](https://www.npmjs.com/package/@wingsky-1/dsh-worktree-sidebar)
3
+ [![GitHub Releases](https://img.shields.io/github/v/release/wingsky-1/dsh-plugin-hub)](https://github.com/wingsky-1/dsh-plugin-hub/releases)
4
+
5
+ 给 agent 三个工具,把某个 **git worktree** 登记给当前会话,让该会话右侧栏的文件树根指向那个 worktree —— **会话 cwd 不变**。
6
+
7
+ 这是一个**过渡适配层**:官方若推出原生 worktree 会话能力,本插件即退役。
8
+ 原理与运行机制(五域装配、宿主/客户端链路、自愈判据、命门清单)见 [架构图解](../../docs/architecture/dsh-worktree-sidebar.md)。
9
+
10
+ ## 它做什么
11
+
12
+ dsh 的右侧栏文件树根固定取 `session.header.cwd`,且该字段创建后不可变(显式收养不同 cwd 的会话会抛 `ApiSessionCwdConflict`)。于是在「会话开在主 checkout、实际改动在 worktree」这一常见工作方式下,树看的是主 checkout,看错了地方。
13
+
14
+ 本插件给 agent 三个工具:
15
+
16
+ | 工具 | 作用 |
17
+ |---|---|
18
+ | `ws_worktree_register` | 把一个**已存在**的 worktree 登记给当前会话 |
19
+ | `ws_worktree_create` | 先 `git worktree add` 建出来,再登记(路径与分支由调用方给出,插件不设目录约定);可选 `base` 指定起点,缺省是会话工作目录所在仓库的当前 HEAD |
20
+ | `ws_worktree_remove` | 摘掉登记;只有显式给出参数才连带 `git worktree remove` 删目录 |
21
+
22
+ 登记之后**打开或刷新**右侧栏的 Files 页签,列的就是该 worktree 的内容,点开的文件预览读的也是 worktree 里的那一份。
23
+ 页签**不会自动跟随**:插件不轮询宿主,只在「打开页签 / 点官方刷新 / 窗口重新可见或获得焦点」这三种时机重读登记。
24
+
25
+ 子 agent 的会话与**用户 fork 出来的会话**都继承父会话的登记:自己没有登记时,右侧栏的 Files 页签指向父链上**第一个持有登记**的那个会话所绑定的 worktree(父链到顶、那条登记被摘除即回退到自己的 cwd)。判据同在会话 header 的 `parentSession`,本插件刻意不区分这两种形态——fork 的 header 同样拷了父的 cwd,视图根跟着一起继承才与「文件根是会话 cwd 的改写」自洽。
26
+
27
+ 工具对 **git 仓库内的所有 agent(含子 agent)**暴露:每个 agent 注册时按其会话目录判断,不在 git 仓库里就不注册。执行期还有一次兜底校验,环境在两次之间变了也不会按错误前提动作。
28
+
29
+ 三个工具同时带一个可见的返回文本,写明当前指向哪个 worktree、分支是什么 —— 让模型不必额外调一次工具就能确认状态。
30
+
31
+ `ws_worktree_create` 的 `base` 是 commit-ish(分支、tag、SHA,如 `origin/main`)。它会先做形态校验(拒绝 `-` 开头的值)再归一化成 SHA 交给 git:起点位置在 `<path>` 之后,git 会**重新开始选项解析**(实测 `base: "-f"` / `"--force"` 会报成功却从 HEAD 建),归一化成 SHA 之后无处下手。
32
+
33
+ ## 显式非目标(已知不一致清单)
34
+
35
+ 这些是**刻意的**取舍,不是待办;它们源于「只换视图根」这一决定:
36
+
37
+ - `@` 文件引用、`present` 交付物落点、skill 目录**仍锚 `session.header.cwd`**,不随视图根走。树指 worktree 而 `@` 列主 checkout,这是本插件最容易被误解的一处。
38
+ - **`workspace-write` 文件策略下 agent 写不进 worktree**:写围栏的 `sandboxPolicy.workspaceRoot` 同样取自 `header.cwd`,该服务不可被第三方插件替换。要写 worktree 请改用 `danger-full-access`,或把会话开在 worktree 里。
39
+ - 主 checkout 与 worktree 里的**同名文件在预览里没有差异标记** —— 树换了根,文件内容就按 worktree 那一份显示。
40
+ - 「树根 = worktree、执行 cwd = 原目录」是本方案的语义前提。agent 的命令仍在原 cwd 下执行。
41
+ - 被改写的会话视图**只作用于本插件注册的那一个 entry**:客户端把改写挂在官方 entry inject 面的 `hooks.sessions` 上,只影响这一个 entry 的注入面;其它 `useSessions` 消费方(预览、命令面板、`@` 等)仍读到会话真实 cwd。这是「只换视图根」的直接后果,不是待办。
42
+
43
+ ## 安装
44
+
45
+ 前提:已安装 DeepSeek Harness 且 `dsh web` 可正常启动(未全局安装 dsh 见下方「未全局安装 dsh」)。
46
+
47
+ ### 安装插件(add)
48
+
49
+ ```sh
50
+ dsh plugin --profile web add @wingsky-1/dsh-worktree-sidebar
51
+ ```
52
+
53
+ ### 卸载插件(remove)
54
+
55
+ ```sh
56
+ dsh plugin --profile web remove @wingsky-1/dsh-worktree-sidebar
57
+ ```
58
+
59
+ 卸载后登记表仍留在 `<DSH_HOME>/@wingsky-1/dsh-worktree-sidebar/bindings.json`,但不再有任何效果 —— 插件不在了,就没有人接管文件根。真正建出来的 worktree 目录**不会**被删除,需要时自行处理:
60
+
61
+ ```sh
62
+ git worktree list # 看还有哪些
63
+ git worktree prune # 清理已被手工删掉目录的登记项
64
+ ```
65
+
66
+ ### 更新插件(update)
67
+
68
+ ```sh
69
+ dsh plugin --profile web update @wingsky-1/dsh-worktree-sidebar
70
+ ```
71
+
72
+ > 安装 / 卸载 / 更新后都需**重启一次** `dsh web`(bundle 层只在启动时组合)生效。
73
+
74
+ ### 指定版本号(@version)
75
+
76
+ 省略 `@版本号` 即安装默认 latest(推荐)。仅当 registry 尚未同步到最新、或最新版在你的环境有问题时,在包名后追加 `@版本号`:
77
+
78
+ ```sh
79
+ dsh plugin --profile web add @wingsky-1/dsh-worktree-sidebar@<版本号>
80
+ ```
81
+
82
+ ### 未全局安装 dsh
83
+
84
+ 若本机没有全局 `dsh` 命令,用 `npx` 临时拉起(底层调用 `pnpm`,仍需本机装好 `pnpm` 与 `Node.js`):
85
+
86
+ ```sh
87
+ npx @deepseek-ai/dsh plugin --profile web add @wingsky-1/dsh-worktree-sidebar
88
+ npx @deepseek-ai/dsh plugin --profile web remove @wingsky-1/dsh-worktree-sidebar
89
+ npx @deepseek-ai/dsh plugin --profile web update @wingsky-1/dsh-worktree-sidebar
90
+ ```
91
+
92
+ ## 配置
93
+
94
+ - 总开关只有一个 `enabled`,经插件配置传入(`WorktreeSidebarConfig`,`src/index.ts:41-45`):`enabled === false` 时不接管、不注册工具、不挂路由(`src/index.ts:98`);缺省不填即启用。
95
+ - 插件不在盘上读任何用户配置文件,绑定表由插件自持:完整路径是 `<DSH_HOME>/@wingsky-1/dsh-worktree-sidebar/bindings.json`,只认 `DSH_HOME` 一个变量(`src/server/shared/paths.ts:9-17`)。
96
+ - `ws_worktree_create` 的 `base` 缺省是会话工作目录所在仓库的当前 HEAD;显式传 `base` 时先做形态校验(拒绝 `-` 开头),再归一化成 SHA 才交给 git,避免起点被静默忽略。
97
+ - `base` 是 commit-ish(分支、tag、SHA,如 `origin/main`);省略分支时显式加 `--detach`,即以 detached 形态 checkout 起点,不新建分支。
98
+ - `enabled === false` 时连绑定表都不读,已有登记原样留在盘上但无任何效果。
99
+
100
+ ## 契约
101
+
102
+ - 双端必须一致的只有两件事,单点定义在 `src/shared/contract.ts:12-28`:路由路径(`ROUTES`)与绑定查询的响应形状(`BindingResponse`);路由另经构建期 `__DSH_ROUTES__` 注入客户端,两端因此强一致。原理见架构文 §2.4。
103
+ - 两条路由都是 GET 只读:`GET /api/dsh-worktree-sidebar/bindings?session=<id>` 回 `{ revision, worktreePath | null }`;`GET /api/dsh-worktree-sidebar/health` 回 `{ ok, revision, scopeTakeover, scopeChain }`(`src/server/api/impl/handlers/index.ts:24-67`)。
104
+ - 最小暴露:绑定查询不回 `repoRoot`,客户端只需要目录根;缺 `session` 参数判 400 而不是回空,避免被误读成「没有绑定」(`handlers/index.ts:1-8`、`30-34`)。
105
+ - `revision` 是内容版本而非写入次数:空表从 0 起,落一条绑定加一;摘不存在的目标时原样返回、不涨 revision(`src/server/binding/impl/model/index.ts:10-13`、`67-86`)。
106
+ - 客户端只做相等比较与单调守卫:revision 与路径都相同则不通知,乱序回来的更小 revision 直接丢弃(`src/client/bindings.ts:40-48`)。
107
+ - 查询端点带自愈副作用:先生效根、再读 revision,顺带摘除已确认失效的登记;只忽略不摘的话 revision 不变,树会一直指向已不成立的根(`handlers/index.ts:35-37`)。
108
+
109
+ ## 验证
110
+
111
+ ```sh
112
+ pnpm build && pnpm test # 仓库内:构建 + 单测/集成(含真 git 仓库与真 worktree 的增删)
113
+ pnpm gate:pr # 开 PR 前;新增包与 catalog 条目另需 pnpm gate:full
114
+ ```
115
+
116
+ 四条**界面语义**进不了任何自动门禁(`gate:*` 里没有浏览器),只能靠隔离真机验证,属发布前人工证据:
117
+
118
+ 1. 打开或刷新 Files 页签后,文件树列的是 worktree 内容;
119
+ 2. 点开文件预览读的是 worktree 里的文件;
120
+ 3. 未登记会话、以及未安装本插件时的行为一致(无回归);
121
+ 4. 子 agent 会话与 fork 会话的树根跟随父链上持有登记的那个会话,且继承态下三个工具的读数与侧边栏一致。
122
+
123
+ ## 测试策略
124
+
125
+ - 两层:`test/unit` 直连 `src` 模块(纯逻辑、域装配、客户端契约断言),`test/integration` 走组合根与真 git 仓库、真临时目录;落盘一律进 `mkdtempSync` 隔离目录(见提案 §12)。
126
+ - 变异面零文件排除:`mutate` 覆盖 `src/**/*.ts`,包级配置撤销共享默认的四类字面量排除,有效算子排除集合为空(`scripts/data/gauntlet.config.json:51`,#847)。
127
+ - 继承判据:fork 与子 agent 会话沿父链解析登记;工具面在继承态下不摘父记录、不调 git,只报来源会话与三条出路(`test/unit/tools.test.ts:587` 起「继承态下的工具面」)。
128
+ - 路由必含 403/405 围栏用例与两端路由一致性断言(提案 §12;实现 `src/server/api/impl/route/index.ts:41`,403 先于 405)。
129
+ - 本节只作只读说明:命令与门禁口径以「验证」一节与仓库根 AGENTS.md 为准,这里不另承诺命令。
130
+
131
+ ## 排障
132
+
133
+ - 工具说已绑定、侧边栏仍按 cwd:先看 `/health` 的 `scopeTakeover`,这是刻意的两路读数——工具面读的是登记事实(不设 takeover 门),文件根有没有真换只有 `live` 才算(`src/server/scope/interface.ts:31-40`)。
134
+ - 存活与状态查询(端口以本机 `dsh web` 实际监听为准,下例用默认 3080):
135
+
136
+ ```sh
137
+ curl 'http://127.0.0.1:3080/api/dsh-worktree-sidebar/health'
138
+ curl 'http://127.0.0.1:3080/api/dsh-worktree-sidebar/bindings?session=session-1'
139
+ ```
140
+
141
+ - `scopeTakeover` 非 `live` 的两种常见成因:`waiting`(provider 尚未注册,启动先后无稳定保证)与 `abandoned`(查找表已被第三方占位);`scopeChain` 记录持久面读失败那次静默降级(`src/server/api/impl/handlers/index.ts:44-53`)。
142
+ - 客户端拉取失败保持上次成功态(G6):任何失败都回 undefined 而非 null,首次即失败时按真实 cwd(`src/client/index.ts:37-56`、`src/client/bindings.ts:30-39`);双端 revision 读同一内存快照,不一致时客户端不改根(G7,见「契约」一节)。
143
+ - 挂载或接管入口异常只经 `console.warn` 出声:不注册任何东西,右栏保持官方行为(`src/client/index.ts:121-124`、`src/client/takeover.ts:226-233`)。
144
+
145
+ ## 兼容性(只读耦合点)
146
+
147
+ 插件不改官方源码,但**读取**以下官方契约(基线 `@deepseek-ai/dsh 0.1.5-rc.1`);官方改版时这些点是唯一的失效面:
148
+
149
+ - 宿主 `typert` 的 `workspaceFileScope` 查表:本插件用 `lookups.configure` 注册自己的解析器,并在 miss 时委托**配置前捕获到的官方 resolve**;
150
+ - 客户端 `sidebarRightTabs` 类型注册表与键控座位 `sidebar.right.pane.tab`(含 `StoredEntry` 的 `component/inject/store/locale` 形状);
151
+ - 会话 hook 源契约 `{ getSnapshot(), subscribe(fn) }`,且 `getSnapshot` 必须返回**引用稳定**的快照;
152
+ - 官方客户端包 `@deepseek-ai/dsh-client-ui-sidebar-right` 必须存在(它提供 `sidebarRightTabs`)。
153
+
154
+ 任一点失效时的行为是**零注册 / 退回官方**:抓不到官方页签实现就一个页签都不注册,树按官方原样显示 cwd。**可感知地什么都不做,好过静默显示错的地方。**
155
+
156
+ ## 安全模型
157
+
158
+ - **路由只回环**:`/api/dsh-worktree-sidebar/*` 非回环请求一律 403,方法不在表里 405(403 先于 405)。插件不向浏览器暴露任何文件读取面。
159
+ - **查询端点带自愈副作用**:`GET /api/dsh-worktree-sidebar/bindings` 在解析生效根时会顺带摘除**已确认失效**的登记(写 `bindings.json` 并让 revision 自增)。这是刻意的:客户端以 revision 判定缓存有效性,只忽略不摘的话 revision 不变、树会一直指向已经不成立的根。
160
+ - **不回主仓库路径**:绑定查询只回 `{ revision, worktreePath | null }`,不回 `repoRoot` —— 客户端只需要目录根。
161
+ - **git 只经 `execFile` + argv**:不经 shell,路径与分支名里的空白、`;`、`$()` 不会被重新解释。
162
+ - **分支名交给 git 自己校验**(`git check-ref-format --branch`);所有位置参数前加 `--`,形如 `--force` 的路径不会被当成标志。
163
+ - **省略分支时显式 `--detach`**:`git worktree add <path>` 的默认行为是**新建一个以目录 basename 命名的分支**,basename 含空格(macOS 家目录常见)会被 git 拒为非法分支名,以 `-` 开头则会被它当成开关二次解析——而 `--` 只挡得住 worktree add 自己的选项解析。补上 `--detach` 之后「省略分支」才真的是「checkout 起点(detached)」——缺省起点是仓库 HEAD,提供 `base` 时 checkout 的就是 `base` 指定的那个起点。
164
+ - **删除是显式的**:`ws_worktree_remove` 默认只摘登记,只有显式参数才执行 `git worktree remove`,且 `--force` 需要再单独显式给出(默认不丢未提交改动)。插件只做 `git worktree remove`,不 `rm -rf`。
165
+ - **落盘在 `DSH_HOME` 下**:`bindings.json` 以临时文件 + `rename` 原子写;文件损坏、版本不符一律当空表,不猜着读。
166
+ - **工具不下发权限**:工具只写自己的登记表并调用 git;不读凭据、不联网。
167
+
168
+ ## 已知限制
169
+
170
+ - **会话 id 会被重启后的新会话复用,登记不跟着走**:官方会话 id 是**进程内计数器**(`session-1`、`session-2`…),重启后新会话会重新拿到同一个 id。所以登记表里额外存了该会话 header 的 `createdAt` 作为身份凭据:凭据对不上(=这是另一个会话)就摘掉那条登记、按未登记处理;真正的会话恢复读到的凭据一致,登记照常生效(只读受阻时保守保留,不会因一次 IO 抖动摘掉用户的登记)。
171
+ - **worktree 目录被外部删掉**时按「未登记」处理并让树回到真实 cwd(可感知,不会指向不存在的目录);但**归属读不出来**(权限、git 执行失败)时保留登记并出声——只有两侧都确实读到了公共 git 目录、且值不同,才判定「已不是该仓库的 worktree」。
172
+ - **一次一个 worktree**:一个会话同时只指向一个 worktree,再次登记会覆盖前一次。
173
+ - **不注入系统提示词**:模型不会被告知「你有这个工具」,只从工具清单与返回文本了解。这是刻意的(避免常驻提示词开销),代价是模型发现该工具依赖它主动查看工具列表。
174
+ - **`workspace-write` 下写不进 worktree**(见「显式非目标」)。
175
+ - **安装 / 升级后需重启一次** `dsh web`。
176
+ - **每会话状态随插件卸载一起释放**:客户端不再做存活性推断(按会话剪枝已在去轮询换根时删除),快照只用来改写 `byId[sessionId].cwd` 这一个字段;视图与订阅在本插件卸载时被收掉(`ctx.effect` 里 `releaseAllSeedings()` + `views.clear()`),视图缓存另有 128 条上限(淘汰最冷的会话;每个视图都是同一份宿主事实的独立读数,淘汰不会让树读到另一个地方)。
177
+ - **同进程第二次装配会显式抛错**:五个域都是进程内单例(`install` / `release` 成对 + `installed` 守卫),第二份实例挂不上并在第二次 `install` 时抛错,而不是静默共享状态。若某个 profile 把本包挂了两次,表现是启动期一条明确的报错;旧的「两份实例互不干扰」语义已不存在。
178
+ - **继承是每次沿父链解析的**:右栏的根来自父链上第一个持有登记的会话,那条登记被摘除、或在解析时被判定失效,后代会话立即回退到自己的 cwd(不会静默指向已不成立的根)。
179
+ - **接管未生效时工具与侧边栏会不一致(刻意)**:`workspaceFileScope` 的接管有等待态(provider 尚未注册)与让位态(被第三方占用),这两态下插件不动文件根、侧边栏仍按 cwd,而三个工具回报的是**登记事实**。以 `/health` 的 `scopeTakeover` 为准(`live` 之外都不换根)。
180
+ - **继承态下 `ws_worktree_remove` 不会摘父会话的登记、也不会删目录**:它只说明根属于哪个会话,并给出出路(在那条会话解绑 / 在本会话绑别的 worktree / 把本会话登记到它自己的 cwd)。
181
+
182
+ ## 落幕判据
183
+
184
+ 以下任一条件满足时,本插件即可退役:
185
+
186
+ - 官方提供原生的 worktree 会话能力(会话自带工作区切换);
187
+ - 官方提供受支持的「按会话切换文件根」能力;
188
+ - 官方让 `SessionHeader.cwd` 可变,或让 `workspaceFileScope` 有受支持的第三方扩展点。
189
+
190
+ 退役后建议从使用者的仓库里清理遗留 worktree:`git worktree list` 查看、`git worktree prune` 清登记。
@@ -0,0 +1,9 @@
1
+ # dsh-worktree-sidebar bundle patch:向 web profile 名册插入 worktree 侧边栏适配层。
2
+ # 主机端(exports ".")在宿主进程运行:注入 webServer / agents / typert / sessions,
3
+ # 提供三个 agent 工具(登记 / 创建 / 移除 worktree)、接管 workspaceFileScope 解析,
4
+ # 并提供 /api/dsh-worktree-sidebar/* 只读路由;
5
+ # 浏览器端(exports "./client")在官方封装就绪后接管 files 页签的目录根。
6
+ # 配置走 schema 默认值(enabled),行内不带 config。
7
+ - insert:
8
+ - id: ui-dsh-worktree-sidebar
9
+ name: "@wingsky-1/dsh-worktree-sidebar"
@@ -0,0 +1,16 @@
1
+ /**
2
+ * 绑定读取:每个会话一份状态,宿主回什么就是什么。
3
+ *
4
+ * 客户端**不做**「这个根还有效吗」的判断——宿主已经把「生效值」算好了(失效绑定会被它摘掉)。
5
+ * 两边各判一次就是 G7 说的那种分叉:客户端认为还绑着、宿主已经按 cwd 解析,
6
+ * 用户看到的是持续的 outside-workspace 报错。所以这里只负责搬运与保持上次成功态。
7
+ */
8
+ import type { ObservablePort, ReadBinding } from "./shared/ports.js";
9
+ /** 一个会话的绑定状态。同块使用,故不从本模块转出(对照 source.ts 的 SessionsSource)。 */
10
+ interface BindingState extends ObservablePort<string | null> {
11
+ /** 拉一次宿主;失败保持上次成功态(G6)。 */
12
+ refresh(): Promise<void>;
13
+ }
14
+ /** 造一个会话的绑定状态。 */
15
+ export declare function createBindingState(read: ReadBinding, sessionId: string): BindingState;
16
+ export {};
@@ -0,0 +1,23 @@
1
+ import type { ClientSlotsPort, ObservablePort, SessionsSnapshotLike, TabsPort } from "./shared/ports.js";
2
+ /**
3
+ * 浏览器端上下文的窄面。刻意不用 `any`:这几个面正是 `inject` 声明的那几个,
4
+ * 写成结构类型之后,「多用一个服务却忘了声明」会在类型层先露出来。
5
+ */
6
+ interface ClientContext {
7
+ readonly slots: ClientSlotsPort;
8
+ readonly sidebarRightTabs: TabsPort;
9
+ /** 服务对象本身**不是**快照源:数据在 `ISessions.list` 上(真机实测见 ports.ts 的注释)。 */
10
+ readonly sessions: {
11
+ readonly list: ObservablePort<SessionsSnapshotLike>;
12
+ };
13
+ readonly effect: (execute: () => () => void, label?: string) => unknown;
14
+ }
15
+ export declare function apply(ctx: ClientContext): void;
16
+ /**
17
+ * 客户端契约。`slots` 是接管的落点(`sidebarRightTabs` 只用来读官方类型 id,我们从不写它),
18
+ * `sessions.list` 是真实快照的来源,
19
+ * `locale` 随官方组件的完整装配面一起被继承(本包不自带文案)。漏声明会在属性访问处抛
20
+ * "without inject";`sidebarRightTabs` 不存在时整体不激活——那说明官方包不在,我们无话可说。
21
+ */
22
+ export declare const inject: string[];
23
+ export {};
@@ -0,0 +1,21 @@
1
+ /**
2
+ * 官方 entry inject 面的改写。本文件回答两件事:
3
+ * 1. `hooks.sessions` 指向按会话的改写源(官方正文读到的 cwd);
4
+ * 2. **树根的播种时机**——覆盖 `start`/`load`,把「根 = 首帧读到的 cwd」改成「根 = 绑定解析后的生效根」。
5
+ *
6
+ * 为什么必须覆盖 `start`:官方只在 `state === undefined` 时用当帧的 cwd 播一次种
7
+ * (dsh-client-ui-sidebar-files/lib/client.js:426-428),之后 cwd 再变也不会重设根;
8
+ * 刷新按钮只走 `actions.reset` + 按已展开路径重新 `load`(同文件 :455-458、:659-661),
9
+ * 同样不重设根。所以「读到新绑定但树还指着旧根」只能在这一层堵住。
10
+ *
11
+ * 请求只在用户可感知的时机发出:挂载页签(`start` 前读一次)、点刷新(`load` 命中已播种的根时读一次)、
12
+ * 窗口重新可见/获得焦点(读一次)。**没有定时轮询**——轮询既改不了已挂载页签的根,也白花请求。
13
+ *
14
+ * 作用域仍是**本 entry**:只改我们注册的那条正文。探测官方 entries 与三步注册在 `takeover.ts`;
15
+ * 两块零互引(ESLint 块间隔离),装配根把 `viewFor` 接进来、再把产出接到那边的 `wrapInject` 上。
16
+ */
17
+ import type { ViewFor, WrapInject } from "./shared/ports.js";
18
+ /** 整体释放:装配根在卸载路径上调用。重复调用无害(释放函数自己会把自己摘掉)。 */
19
+ export declare function releaseAllSeedings(): void;
20
+ /** 造一个改写器:外包官方工厂、保留它产出的一切,只改 hooks 与那两个播种方法。 */
21
+ export declare function createInjectWrapper(viewFor: ViewFor): WrapInject;
@@ -0,0 +1,141 @@
1
+ /**
2
+ * 客户端依赖面:全部是窄接口,浏览器对象与官方组件只出现在 `index.ts` 的适配层里。
3
+ *
4
+ * **这些窄接口必须逐条对着运行时的真实服务面写,不能对着某个类型包的声明写。**
5
+ * 本项目踩过两次,形态完全一样(照假设写的端口在真机上第一次求值就 TypeError,而假端口单测全绿):
6
+ * 1. `ctx.slots` 的运行时实现是 `dsh-client-ui-renderer` 的 `SlotRegistry`
7
+ * (registry.d.ts:46 的 `class SlotRegistry extends Service`,:84 的 `register = SlotCore[%27register%27]`),
8
+ * 而另一个同名类型包声明了一个 `isLive` 方法。
9
+ * 2. `ctx.sessions` 是 `ISessions`(客户端的会话服务),快照数据在它的 `.list` 上
10
+ * (`ObservableSnapshot<SessionListState>`),服务对象自己**没有** `getSnapshot`。
11
+ * 两次的教训是同一条:假端口只证明「代码与我的假设一致」,不证明「假设与运行时一致」。
12
+ *
13
+ * 同一个教训的第三份副本在本文件另一类注释里:曾把「往组件 props 里塞 `hooks.sessions` 不被读」
14
+ * 写成事实,并据此把接缝换成 session 作用域。官方 `bindInjectSources` 恰恰会把 entry inject 面的
15
+ * `hooks.<name>` 变成 `use<Name>` props——被读,且覆盖框架注入。所以本包用 entry 级接缝。
16
+ */
17
+ import type { BindingResponse } from "../../shared/interface.js";
18
+ /**
19
+ * 官方座位上的一条已登记项(运行时形状即 `dsh-client-ui-slots` 的 `StoredEntry`)。
20
+ *
21
+ * 类型面在树内读得到:`dsh-client-ui-renderer/lib/types/client/registry.d.ts:15` 从该包 import
22
+ * `StoredEntry`,`:154`/`:164` 是 `entries`/`entriesOfSlot` 的返回类型(实现包本身不在安装树里,
23
+ * 可读副本的位置见 takeover.ts 头部注释)。
24
+ */
25
+ export interface StoredEntryLike {
26
+ readonly component: unknown;
27
+ readonly options: {
28
+ readonly key?: string;
29
+ readonly id?: string;
30
+ readonly order?: number;
31
+ readonly priority?: number;
32
+ };
33
+ /** 官方组件的业务面工厂。我们整套保留它的产物,只在外面把 `hooks.sessions` 换成改写源。 */
34
+ readonly inject?: InjectFactory | undefined;
35
+ readonly store?: unknown;
36
+ readonly locale?: string | undefined;
37
+ }
38
+ /**
39
+ * tab 类型定义,逐字对齐官方 SidebarRightTabDefinition。
40
+ *
41
+ * 这里**必须**把所有字段都列全(尤其 `guide`):类型表用的是官方定义的那一份(本插件只遮蔽
42
+ * 正文,从不改写类型表),官方注册表 refresh 后会用**在册定义**重算 guide 条目。丢掉 `guide` 的后果不是少一块文案,
43
+ * 而是 guide 归零、所有会话(含从未登记的)默认页签从 Files 变成空的 Guide、文件树打不开。
44
+ */
45
+ export interface TabDefinitionLike {
46
+ readonly id: string;
47
+ readonly kind: string;
48
+ readonly patterns?: readonly string[];
49
+ readonly priority?: string;
50
+ readonly canOpen?: (address: string) => boolean;
51
+ readonly title: (address: string) => string;
52
+ readonly guide?: readonly unknown[];
53
+ }
54
+ /**
55
+ * 官方座位登记表(只声明本包用到的方法)。
56
+ *
57
+ * `isLive` 刻意**不在**这里:官方公开面没有它。存活性判据就是 `entriesOfSlot` 的返回集合本身——
58
+ * 它给的是「每个 cell 当前生效(非 abdicated)的那一条」,在册即存活。
59
+ */
60
+ export interface ClientSlotsPort {
61
+ /**
62
+ * 某座位的**原始账**(含被遮蔽的条目),与 {@link entriesOfSlot} 同源不同视图。
63
+ *
64
+ * 遮蔽生效之后「当值」恒为我们那条,官方那条只有这里看得见——官方文档原话:
65
+ * the raw entries view stays the inspection surface(renderer/lib/client.js:1181-1191)。
66
+ * 没有它就看不见官方组件被 HMR 换掉,会一直渲染旧模块的组件。
67
+ */
68
+ entries(key: string): readonly StoredEntryLike[];
69
+ /** 某座位每 cell 当前当值的那一条(优先级最低的存活项)。 */
70
+ entriesOfSlot(key: string): readonly StoredEntryLike[];
71
+ register(options: Record<string, unknown>, component: unknown): () => void;
72
+ subscribe(key: string, listener: () => void): () => void;
73
+ onEntryError(listener: (key: string, entry: StoredEntryLike, error: unknown) => void): () => void;
74
+ }
75
+ /**
76
+ * tab 类型注册表。**只读**:本包以「同 key 更低 priority 遮蔽正文」接管,不碰类型表,
77
+ * 所以这里没有 `register`——官方定义(含 `guide`)保持原样,也就没有「搬定义搬丢了」的失败形态。
78
+ */
79
+ export interface TabsPort {
80
+ /** 某 kind 当前生效的类型定义;`id` 恒为官方 id(我们从不改写它)。 */
81
+ get(kind: string): TabDefinitionLike | undefined;
82
+ }
83
+ /** uSES 观察源:`getSnapshot` 必须返回引用稳定的快照,否则每次渲染都会触发更新。 */
84
+ export interface ObservablePort<T> {
85
+ getSnapshot(): T;
86
+ subscribe(listener: () => void): () => void;
87
+ }
88
+ /** 官方 entry 的 inject 面工厂:吃渲染器传的参数(首个字符串是会话 id),返回业务面。 */
89
+ export type InjectFactory = (...args: unknown[]) => Record<string, unknown>;
90
+ /**
91
+ * 一个会话的生效根读数:同步读 + 变化订阅 + 主动拉取。
92
+ *
93
+ * 与 `bindings.ts` 的 `BindingState` **同形**(那边直接当这个端口用),刻意不另造适配层:
94
+ * 「宿主说生效根是哪个」与「改写源看到的是哪个」必须是同一个事实,多一层包装就多一次分叉。
95
+ */
96
+ export interface RootReader {
97
+ /** 同步读当前生效根;null 表示按真实 cwd 走。 */
98
+ getSnapshot(): string | null;
99
+ /** 变化订阅(只在实际变化时触发)。返回退订函数。 */
100
+ subscribe(listener: () => void): () => void;
101
+ /** 拉一次宿主;失败保持上次成功态。 */
102
+ refresh(): Promise<void>;
103
+ }
104
+ /** 一个会话的整套视图:伪造快照源(供 `hooks.sessions`)+ 生效根读数(供树根播种)。 */
105
+ export interface SessionView {
106
+ readonly source: ObservablePort<SessionsSnapshotLike>;
107
+ readonly root: RootReader;
108
+ }
109
+ /** 按会话取视图;同一 id 恒回同一对象(渲染器按源缓存订阅,换了对象就会被当成「状态一直在变」)。 */
110
+ export type ViewFor = (sessionId: string) => SessionView;
111
+ /**
112
+ * 官方 inject 面的改写器:吃官方工厂、吐同形状的新工厂。改写两件事——`hooks.sessions` 指向
113
+ * 改写源(树读到的 cwd),以及 `start`/`load` 换成「以生效根播种」(官方只在首帧播种一次根)。
114
+ *
115
+ * 契约放在本文件而不是 `inject.ts`:块间零互引(ESLint `no-restricted-imports`)意味着
116
+ * `takeover.ts` 连 `inject.ts` 的**类型**都引不到,跨块的形状只能由纯类型面承载。
117
+ */
118
+ export type WrapInject = (official: InjectFactory | undefined) => InjectFactory;
119
+ /**
120
+ * 一份注入面里的 hook 源表,键是 hook 名。
121
+ *
122
+ * 这正是官方 `bindInjectSources` 读的那个 `face["hooks"]`:它把每个 `hooks.<name>`
123
+ * 经 `standardHookPropName` 合成为 `use<Name>` props
124
+ * (`dsh-client-ui-renderer/lib/client.js:342-357`),而展开序 `{...kit, ...injected, ...}`
125
+ * (`:644-650` 与 `:653-658`)让 injected 覆盖框架注入——这就是改写 `useSessions` 的机制。
126
+ */
127
+ export interface SessionContribution {
128
+ readonly hooks: Readonly<Record<string, ObservablePort<unknown>>>;
129
+ }
130
+ /** 读一次宿主绑定。失败返回 undefined(调用方保持上次成功态)。 */
131
+ export type ReadBinding = (sessionId: string) => Promise<BindingResponse | undefined>;
132
+ /**
133
+ * 会话快照的最小形状:我们只改写 `byId[sessionId].cwd` 这一个字段,其余原样透传。
134
+ *
135
+ * 只声明真正读到的那一面:`ids` / `current` 曾经是「按会话剪枝」的证据面,剪枝随
136
+ * 去轮询一起删掉后它们没有读取点,留着只会让人以为还有一条存活性推断。
137
+ * `byId` 声明成可选:形态对不上时整体放弃改写,而不是凭空造出会话条目。
138
+ */
139
+ export interface SessionsSnapshotLike {
140
+ readonly byId?: Record<string, unknown> | undefined;
141
+ }