@wingsky-1/dsh-worktree-sidebar 0.2.5 → 0.2.7

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.
package/README.en.md CHANGED
@@ -7,6 +7,14 @@ Three tools that let the agent bind a **git worktree** to the current session, s
7
7
  This is a **transitional adapter**: if the official product ships native worktree sessions, this plugin retires.
8
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
9
 
10
+ ## Quick install
11
+
12
+ ```sh
13
+ dsh plugin --profile web add @wingsky-1/dsh-worktree-sidebar
14
+ ```
15
+
16
+ > After install / uninstall / update, **restart `dsh web` once** (bundle layers are only composed at startup) for changes to take effect.
17
+
10
18
  ## What it does
11
19
 
12
20
  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.
@@ -140,7 +148,9 @@ Four **UI semantics** cannot be covered by any automated gate (there is no brows
140
148
 
141
149
  ## Compatibility (read-only coupling)
142
150
 
143
- The plugin does not modify official sources, but it **reads** these contracts (baseline `@deepseek-ai/dsh 0.1.5-rc.1`):
151
+ The plugin does not modify official sources, but it **reads** these contracts from the sole
152
+ supported target runtime, dsh `0.1.7-rc.2`. No compatibility promise is made for any other
153
+ runtime version:
144
154
 
145
155
  - 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
156
  - the client `sidebarRightTabs` type registry and the keyed seat `sidebar.right.pane.tab` (including the `StoredEntry` shape: `component/inject/store/locale`);
package/README.md CHANGED
@@ -7,6 +7,14 @@
7
7
  这是一个**过渡适配层**:官方若推出原生 worktree 会话能力,本插件即退役。
8
8
  原理与运行机制(五域装配、宿主/客户端链路、自愈判据、命门清单)见 [架构图解](../../docs/architecture/dsh-worktree-sidebar.md)。
9
9
 
10
+ ## 一键安装
11
+
12
+ ```sh
13
+ dsh plugin --profile web add @wingsky-1/dsh-worktree-sidebar
14
+ ```
15
+
16
+ > 安装 / 卸载 / 更新后都需**重启一次** `dsh web`(bundle 层只在启动时组合)生效。
17
+
10
18
  ## 它做什么
11
19
 
12
20
  dsh 的右侧栏文件树根固定取 `session.header.cwd`,且该字段创建后不可变(显式收养不同 cwd 的会话会抛 `ApiSessionCwdConflict`)。于是在「会话开在主 checkout、实际改动在 worktree」这一常见工作方式下,树看的是主 checkout,看错了地方。
@@ -24,7 +32,7 @@ dsh 的右侧栏文件树根固定取 `session.header.cwd`,且该字段创建
24
32
 
25
33
  子 agent 的会话与**用户 fork 出来的会话**都继承父会话的登记:自己没有登记时,右侧栏的 Files 页签指向父链上**第一个持有登记**的那个会话所绑定的 worktree(父链到顶、那条登记被摘除即回退到自己的 cwd)。判据同在会话 header 的 `parentSession`,本插件刻意不区分这两种形态——fork 的 header 同样拷了父的 cwd,视图根跟着一起继承才与「文件根是会话 cwd 的改写」自洽。
26
34
 
27
- 工具对 **git 仓库内的所有 agent(含子 agent)**暴露:每个 agent 注册时按其会话目录判断,不在 git 仓库里就不注册。执行期还有一次兜底校验,环境在两次之间变了也不会按错误前提动作。
35
+ 工具对 **git 仓库内的所有 agent(含子 agent)**暴露:每个 agent 注册时按其会话目录判断,不在 git 仓库里就不注册。插件装载前已存在的 agent 会在装载时补注册;后续 `agent/created` 事件会等待本插件返回的 Promise,确保工具注册完成后事件才继续。执行期还有一次兜底校验,环境在两次之间变了也不会按错误前提动作。
28
36
 
29
37
  三个工具同时带一个可见的返回文本,写明当前指向哪个 worktree、分支是什么 —— 让模型不必额外调一次工具就能确认状态。
30
38
 
@@ -144,7 +152,7 @@ pnpm gate:pr # 开 PR 前;新增包与 catalog 条目另需 pn
144
152
 
145
153
  ## 兼容性(只读耦合点)
146
154
 
147
- 插件不改官方源码,但**读取**以下官方契约(基线 `@deepseek-ai/dsh 0.1.5-rc.1`);官方改版时这些点是唯一的失效面:
155
+ 插件不改官方源码,但**读取**以下官方契约(基线 `@deepseek-ai/dsh 0.1.7-rc.2`);官方改版时这些点是唯一的失效面:
148
156
 
149
157
  - 宿主 `typert` 的 `workspaceFileScope` 查表:本插件用 `lookups.configure` 注册自己的解析器,并在 miss 时委托**配置前捕获到的官方 resolve**;
150
158
  - 客户端 `sidebarRightTabs` 类型注册表与键控座位 `sidebar.right.pane.tab`(含 `StoredEntry` 的 `component/inject/store/locale` 形状);
package/cordis.patch.yml CHANGED
@@ -5,5 +5,5 @@
5
5
  # 浏览器端(exports "./client")在官方封装就绪后接管 files 页签的目录根。
6
6
  # 配置走 schema 默认值(enabled),行内不带 config。
7
7
  - insert:
8
- - id: ui-dsh-worktree-sidebar
8
+ - id: dsh-worktree-sidebar
9
9
  name: "@wingsky-1/dsh-worktree-sidebar"
@@ -36,17 +36,21 @@ export interface StoredEntryLike {
36
36
  readonly locale?: string | undefined;
37
37
  }
38
38
  /**
39
- * tab 类型定义,逐字对齐官方 SidebarRightTabDefinition。
39
+ * tab 类型定义,语义对齐官方 SidebarRightTabDefinition(只读,不改写类型表)。
40
40
  *
41
- * 这里**必须**把所有字段都列全(尤其 `guide`):类型表用的是官方定义的那一份(本插件只遮蔽
41
+ * 这里**必须**把在册定义的判据字段列全(尤其 `guide`):类型表用的是官方定义的那一份(本插件只遮蔽
42
42
  * 正文,从不改写类型表),官方注册表 refresh 后会用**在册定义**重算 guide 条目。丢掉 `guide` 的后果不是少一块文案,
43
43
  * 而是 guide 归零、所有会话(含从未登记的)默认页签从 Files 变成空的 Guide、文件树打不开。
44
+ * `multiple`/`keepMounted` 仅作读面声明(本包不写类型表):可选即双基线可编译;
45
+ * 隐藏页签无 abort 时仍在册,binding 刷新仍需 reseed(见 inject keepMounted 用例)。
44
46
  */
45
47
  export interface TabDefinitionLike {
46
48
  readonly id: string;
47
49
  readonly kind: string;
48
50
  readonly patterns?: readonly string[];
49
- readonly priority?: string;
51
+ readonly priority?: "extension" | "builtin" | "fallback";
52
+ readonly multiple?: boolean;
53
+ readonly keepMounted?: boolean;
50
54
  readonly canOpen?: (address: string) => boolean;
51
55
  readonly title: (address: string) => string;
52
56
  readonly guide?: readonly unknown[];
@@ -4,12 +4,13 @@
4
4
  * 接管只做一件事:以**官方 id** 为 key、**更低的 priority** 再登记一条正文,组件 / store /
5
5
  * locale 全部复用官方那条(inject 面由 `inject.ts` 包一层)。官方 ui-slots 的每个 cell 取
6
6
  * 「优先级最低的那条存活项」——它的注册表原话是 register at a different priority to shadow it
7
- * (lowest renders)(`@deepseek-ai/dsh-client-ui-slots` 的 `lib/index.js:77`;排序 `:130`;取当值项 `:187-200`)。
7
+ * (lowest renders)(`@deepseek-ai/dsh-client-ui-slots` 的遮蔽语义:同 key 不同 priority 方可并存、
8
+ * 按 priority 升序、当值取每 cell 首条存活项)。
8
9
  *
9
10
  * **那个包不在 dsh 安装树里**:它在构建期被内联进 `dsh-web-frontend/dist/assets/index-*.js`,
10
11
  * 所以要照注释去读原文时,可读副本在同仓 catalog 锁版处
11
- * (`node_modules/.pnpm/@deepseek-ai+dsh-client-ui-slots@0.1.5-rc.1_...` 下的
12
- * `node_modules/@deepseek-ai/dsh-client-ui-slots/lib/index.js`)。
12
+ * (`node_modules/.pnpm/@deepseek-ai+dsh-client-ui-slots@<catalog 锁版>_...` 下的
13
+ * `node_modules/@deepseek-ai/dsh-client-ui-slots/lib/index.js`,版本号见 `pnpm-workspace.yaml`,不硬编码)。
13
14
  * 树内可读的两处是类型面与运行时入口:`dsh-client-ui-renderer/lib/types/client/registry.d.ts:46/84/154/164`
14
15
  * (`class SlotRegistry`、`register`、`entries`、`entriesOfSlot`)与同一包 `lib/client.js:953/1191-1198/1388`。
15
16
  *
package/lib/client.js CHANGED
@@ -42,7 +42,7 @@
42
42
  // src/client/inject.ts
43
43
  var liveSeedings = /* @__PURE__ */ new Set();
44
44
  function releaseAllSeedings() {
45
- for (const release of [...liveSeedings]) release();
45
+ for (const release of [...liveSeedings].reverse()) release();
46
46
  }
47
47
  function createInjectWrapper(viewFor) {
48
48
  return (official) => {
@@ -73,48 +73,54 @@
73
73
  const reseed = () => {
74
74
  const next = view.root.getSnapshot();
75
75
  for (const [tabId, tab] of [...watched]) {
76
- if (tab.signal?.aborted === true) continue;
76
+ if (tab.abort?.signal.aborted === true) continue;
77
77
  const root = next ?? tab.fallback;
78
78
  if (root === tab.seeded) continue;
79
79
  tab.seeded = root;
80
- officialStart(tabId, root, tab.signal);
80
+ officialStart(tabId, root, tab.abort?.signal);
81
81
  }
82
82
  };
83
+ const detachAbort = (tab) => {
84
+ if (tab?.abort === void 0) return;
85
+ tab.abort.signal.removeEventListener("abort", tab.abort.listener);
86
+ };
83
87
  const release = () => {
84
- watched.clear();
85
- liveSeedings.delete(release);
86
- unsubscribe?.();
87
- unsubscribe = void 0;
88
+ for (const [, tab] of [...watched].reverse()) detachAbort(tab);
88
89
  detachVisible?.();
89
90
  detachVisible = void 0;
90
- };
91
- const watchAbort = (tabId, signal) => {
92
- signal?.addEventListener(
93
- "abort",
94
- () => {
95
- watched.delete(tabId);
96
- if (watched.size === 0) release();
97
- },
98
- { once: true }
99
- );
91
+ unsubscribe?.();
92
+ unsubscribe = void 0;
93
+ watched.clear();
94
+ liveSeedings.delete(release);
100
95
  };
101
96
  const attach = (tabId, fallback, seeded, signal) => {
102
97
  liveSeedings.add(release);
103
- watched.set(tabId, { seeded, fallback, signal });
98
+ detachAbort(watched.get(tabId));
99
+ const onAbort = () => {
100
+ const current = watched.get(tabId);
101
+ if (current === void 0 || current.abort !== abort) return;
102
+ detachAbort(current);
103
+ watched.delete(tabId);
104
+ if (watched.size === 0) release();
105
+ };
106
+ const abort = signal === void 0 ? void 0 : { signal, listener: onAbort };
107
+ const tab = { seeded, fallback, abort };
108
+ watched.set(tabId, tab);
104
109
  unsubscribe ?? (unsubscribe = view.root.subscribe(reseed));
105
110
  detachVisible ?? (detachVisible = refreshWhenVisible(refresh));
106
- watchAbort(tabId, signal);
111
+ if (abort !== void 0) abort.signal.addEventListener("abort", abort.listener, { once: true });
112
+ return tab;
107
113
  };
108
114
  const start = (tabId, root, signal) => {
109
- attach(tabId, root, root, signal);
115
+ if (signal?.aborted === true) return;
116
+ const tab = attach(tabId, root, root, signal);
110
117
  officialStart(tabId, root, signal);
111
118
  void view.root.refresh().then(() => {
112
- if (signal?.aborted === true) return;
119
+ if (watched.get(tabId) !== tab || tab.abort?.signal.aborted === true) return;
113
120
  const seeded = view.root.getSnapshot() ?? root;
114
- const tab = watched.get(tabId);
115
- if (tab === void 0 || tab.seeded === seeded) return;
121
+ if (tab.seeded === seeded) return;
116
122
  tab.seeded = seeded;
117
- officialStart(tabId, seeded, tab.signal);
123
+ officialStart(tabId, seeded, tab.abort?.signal);
118
124
  });
119
125
  };
120
126
  const load = (tabId, path, signal) => {
package/lib/index.js CHANGED
@@ -19,8 +19,9 @@ function bindAgents(host) {
19
19
  cwd: agent.session.header.cwd
20
20
  });
21
21
  return {
22
- subscribe: (handler) => host.on("agent/created", ({ agent }) => {
23
- handler(faceOf(agent));
22
+ subscribe: (handler) => host.on("agent/created", async ({ agent }) => {
23
+ await handler(faceOf(agent));
24
+ return void 0;
24
25
  }),
25
26
  list: () => host.all().map(faceOf),
26
27
  publish: (face, definitions) => {
@@ -107,24 +108,33 @@ function bindTypert(lookups) {
107
108
  }
108
109
 
109
110
  // ../../shared/loopback.js
110
- function isLoopbackRequest(request, options = {}) {
111
+ function isLoopbackPeerAddress(request) {
111
112
  const address = request.socket?.remoteAddress;
112
- if (address !== "127.0.0.1" && address !== "::1" && address !== "::ffff:127.0.0.1") return false;
113
+ return address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1";
114
+ }
115
+ function loopbackHostUrlOf(request) {
113
116
  const host = request.headers.host;
114
- if (typeof host !== "string") return false;
117
+ if (typeof host !== "string") return void 0;
115
118
  let hostUrl;
116
119
  try {
117
120
  hostUrl = new URL(`http://${host}`);
118
121
  } catch {
119
- return false;
122
+ return void 0;
120
123
  }
121
- if (hostUrl.hostname !== "127.0.0.1" && hostUrl.hostname !== "localhost" && hostUrl.hostname !== "[::1]")
122
- return false;
123
- if (request.headers["sec-fetch-site"] === "cross-site") {
124
- if (!(options.allowCrossSiteNoCors === true && request.headers["sec-fetch-mode"] === "no-cors")) {
125
- return false;
126
- }
124
+ if (hostUrl.hostname !== "127.0.0.1" && hostUrl.hostname !== "localhost" && hostUrl.hostname !== "[::1]") {
125
+ return void 0;
127
126
  }
127
+ return hostUrl;
128
+ }
129
+ function crossSiteRequestAllowed(request, options) {
130
+ if (request.headers["sec-fetch-site"] !== "cross-site") return true;
131
+ return options.allowCrossSiteNoCors === true && request.headers["sec-fetch-mode"] === "no-cors";
132
+ }
133
+ function isLoopbackRequest(request, options = {}) {
134
+ if (!isLoopbackPeerAddress(request)) return false;
135
+ const hostUrl = loopbackHostUrlOf(request);
136
+ if (hostUrl === void 0) return false;
137
+ if (!crossSiteRequestAllowed(request, options)) return false;
128
138
  const origin = request.headers.origin;
129
139
  if (origin === void 0) return true;
130
140
  try {
@@ -305,9 +315,9 @@ function validateRecord(value) {
305
315
  const branch = raw["branch"];
306
316
  const createdAt = raw["createdAt"];
307
317
  const sessionCreatedAt = raw["sessionCreatedAt"];
308
- if (typeof repoRoot !== "string" || repoRoot.length === 0)
318
+ if (!isNonEmptyString(repoRoot))
309
319
  return void 0;
310
- if (typeof worktreeRoot !== "string" || worktreeRoot.length === 0)
320
+ if (!isNonEmptyString(worktreeRoot))
311
321
  return void 0;
312
322
  if (typeof branch !== "string")
313
323
  return void 0;
@@ -317,33 +327,49 @@ function validateRecord(value) {
317
327
  return void 0;
318
328
  return { repoRoot, worktreeRoot, branch, createdAt, sessionCreatedAt };
319
329
  }
330
+ function isNonEmptyString(value) {
331
+ return typeof value === "string" && value.length > 0;
332
+ }
320
333
  function parseTable(text) {
321
- let parsed;
334
+ const parsed = parseJsonOrUndefined(text);
335
+ if (parsed === void 0)
336
+ return emptyTable();
337
+ const header = tableHeaderOf(parsed);
338
+ if (header === void 0)
339
+ return emptyTable();
340
+ return { version: BINDINGS_VERSION, revision: header.revision, bindings: recordsOf(header) };
341
+ }
342
+ function parseJsonOrUndefined(text) {
322
343
  try {
323
- parsed = JSON.parse(text);
344
+ return JSON.parse(text);
324
345
  } catch {
325
- return emptyTable();
346
+ return void 0;
326
347
  }
348
+ }
349
+ function tableHeaderOf(parsed) {
327
350
  if (typeof parsed !== "object" || parsed === null)
328
- return emptyTable();
351
+ return void 0;
329
352
  const raw = parsed;
330
353
  if (raw["version"] !== BINDINGS_VERSION)
331
- return emptyTable();
354
+ return void 0;
332
355
  const revision2 = raw["revision"];
333
356
  if (typeof revision2 !== "number" || !Number.isFinite(revision2) || revision2 < 0)
334
- return emptyTable();
357
+ return void 0;
335
358
  const entries = raw["bindings"];
336
359
  if (typeof entries !== "object" || entries === null)
337
- return emptyTable();
360
+ return void 0;
361
+ return { revision: revision2, entries };
362
+ }
363
+ function recordsOf(header) {
338
364
  const bindings = {};
339
- for (const [sessionId, value] of Object.entries(entries)) {
365
+ for (const [sessionId, value] of Object.entries(header.entries)) {
340
366
  if (sessionId.length === 0)
341
367
  continue;
342
368
  const record = validateRecord(value);
343
369
  if (record !== void 0)
344
370
  bindings[sessionId] = record;
345
371
  }
346
- return { version: BINDINGS_VERSION, revision: revision2, bindings };
372
+ return bindings;
347
373
  }
348
374
  function serializeTable(table) {
349
375
  return JSON.stringify(table, null, 2) + "\n";
@@ -364,9 +390,13 @@ function dropBinding(table, sessionId) {
364
390
  }
365
391
 
366
392
  // lib/server/shared/file-io.js
393
+ import { randomBytes } from "node:crypto";
367
394
  import { readFileSync } from "node:fs";
368
395
  import { mkdir, rename, writeFile } from "node:fs/promises";
369
396
  import { dirname } from "node:path";
397
+ function temporaryNameFor(file) {
398
+ return `${file}.tmp-${process.pid}.${Date.now().toString(36)}.${randomBytes(6).toString("hex")}.tmp`;
399
+ }
370
400
  function readTextFileSync(file) {
371
401
  try {
372
402
  return { ok: true, text: readFileSync(file, "utf8") };
@@ -375,7 +405,7 @@ function readTextFileSync(file) {
375
405
  }
376
406
  }
377
407
  async function writeTextAtomic(file, text) {
378
- const temporary = `${file}.tmp-${process.pid}`;
408
+ const temporary = temporaryNameFor(file);
379
409
  try {
380
410
  await mkdir(dirname(file), { recursive: true });
381
411
  await writeFile(temporary, text, "utf8");
@@ -386,9 +416,6 @@ async function writeTextAtomic(file, text) {
386
416
  }
387
417
  }
388
418
 
389
- // lib/server/shared/paths.js
390
- import { join as join2 } from "node:path";
391
-
392
419
  // ../../shared/dsh-home.js
393
420
  import { homedir } from "node:os";
394
421
  import { join } from "node:path";
@@ -401,11 +428,17 @@ function dshHome() {
401
428
  return env !== void 0 && env.trim().length > 0 ? env : join(userHome(), ".dsh");
402
429
  }
403
430
 
431
+ // ../../shared/paths.js
432
+ import { join as join2 } from "node:path";
433
+ function pluginHome(base, ...segments) {
434
+ return join2(base, ...segments);
435
+ }
436
+
404
437
  // lib/server/shared/paths.js
405
438
  var PACKAGE_DIR = "@wingsky-1/dsh-worktree-sidebar";
406
439
  var BINDINGS_FILE_NAME = "bindings.json";
407
440
  function bindingsFile() {
408
- return join2(dshHome(), PACKAGE_DIR, BINDINGS_FILE_NAME);
441
+ return pluginHome(dshHome(), PACKAGE_DIR, BINDINGS_FILE_NAME);
409
442
  }
410
443
 
411
444
  // lib/server/binding/impl/store/index.js
@@ -1215,21 +1248,17 @@ function argBool(args, key) {
1215
1248
 
1216
1249
  // lib/server/tools/impl/session/index.js
1217
1250
  function sessionOf(exec) {
1218
- if (typeof exec !== "object" || exec === null)
1219
- return void 0;
1220
1251
  const session = readObject(readObject(exec, "agent"), "session");
1221
1252
  if (session === void 0)
1222
1253
  return void 0;
1223
- const id = session.id;
1254
+ const id = readField(session, "id");
1224
1255
  if (typeof id !== "string" || id.length === 0)
1225
1256
  return void 0;
1226
1257
  const header = readObject(session, "header");
1227
- const cwd = header === void 0 ? void 0 : header.cwd;
1228
- const createdAt = header === void 0 ? void 0 : header.createdAt;
1229
1258
  return {
1230
1259
  id,
1231
- cwd: typeof cwd === "string" && cwd.length > 0 ? cwd : void 0,
1232
- createdAt: typeof createdAt === "number" && Number.isFinite(createdAt) ? createdAt : void 0
1260
+ cwd: nonEmptyTextOrUndefined(readField(header, "cwd")),
1261
+ createdAt: finiteNumberOrUndefined(readField(header, "createdAt"))
1233
1262
  };
1234
1263
  }
1235
1264
  function readObject(source, key) {
@@ -1238,8 +1267,44 @@ function readObject(source, key) {
1238
1267
  const value = source[key];
1239
1268
  return typeof value === "object" && value !== null ? value : void 0;
1240
1269
  }
1270
+ function readField(source, key) {
1271
+ if (typeof source !== "object" || source === null)
1272
+ return void 0;
1273
+ return source[key];
1274
+ }
1275
+ function nonEmptyTextOrUndefined(value) {
1276
+ return typeof value === "string" && value.length > 0 ? value : void 0;
1277
+ }
1278
+ function finiteNumberOrUndefined(value) {
1279
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
1280
+ }
1241
1281
 
1242
1282
  // lib/server/tools/impl/create/index.js
1283
+ async function checkBranch(deps, origin, branch) {
1284
+ if (branch === void 0)
1285
+ return { ok: true };
1286
+ if (await deps.git.checkRefFormat(branch))
1287
+ return { ok: true };
1288
+ return {
1289
+ ok: false,
1290
+ result: resultOf(origin, false, "Not a valid git branch name: " + branch + " (git check-ref-format --branch rejected it).")
1291
+ };
1292
+ }
1293
+ async function resolveStartPoint(deps, repo, origin, base) {
1294
+ if (base === void 0)
1295
+ return { ok: true };
1296
+ if (base.startsWith("-")) {
1297
+ return {
1298
+ ok: false,
1299
+ result: resultOf(origin, false, "Not a valid start point: " + base + ' (a start point must not begin with "-").')
1300
+ };
1301
+ }
1302
+ const startPoint = await deps.git.resolveCommit(repo, base);
1303
+ if (startPoint === void 0) {
1304
+ return { ok: false, result: resultOf(origin, false, "Not a valid start point: " + base + ".") };
1305
+ }
1306
+ return { ok: true, startPoint };
1307
+ }
1243
1308
  function buildCreateTool(deps) {
1244
1309
  return {
1245
1310
  name: "ws_worktree_create",
@@ -1289,20 +1354,13 @@ function buildCreateTool(deps) {
1289
1354
  const origin = read.origin;
1290
1355
  const target = resolveTarget(repo, raw);
1291
1356
  const branch = argString(args, "branch");
1292
- if (branch !== void 0 && !await deps.git.checkRefFormat(branch)) {
1293
- return resultOf(origin, false, "Not a valid git branch name: " + branch + " (git check-ref-format --branch rejected it).");
1294
- }
1295
- const base = argString(args, "base");
1296
- if (base !== void 0 && base.startsWith("-")) {
1297
- return resultOf(origin, false, "Not a valid start point: " + base + ' (a start point must not begin with "-").');
1298
- }
1299
- let startPoint;
1300
- if (base !== void 0) {
1301
- startPoint = await deps.git.resolveCommit(repo, base);
1302
- if (startPoint === void 0) {
1303
- return resultOf(origin, false, "Not a valid start point: " + base + ".");
1304
- }
1305
- }
1357
+ const branchCheck = await checkBranch(deps, origin, branch);
1358
+ if (!branchCheck.ok)
1359
+ return branchCheck.result;
1360
+ const start = await resolveStartPoint(deps, repo, origin, argString(args, "base"));
1361
+ if (!start.ok)
1362
+ return start.result;
1363
+ const startPoint = start.startPoint;
1306
1364
  const created = await deps.git.addWorktree(repo, target, branch, startPoint);
1307
1365
  if (!created.ok) {
1308
1366
  return resultOf(origin, false, "git worktree add failed: " + created.reason);
@@ -1323,6 +1381,32 @@ function buildCreateTool(deps) {
1323
1381
  }
1324
1382
 
1325
1383
  // lib/server/tools/impl/remove/index.js
1384
+ async function unbindAndRemove(deps, sessionId, origin, force) {
1385
+ const removed = await deps.git.removeWorktree(origin.record.repoRoot, origin.record.worktreeRoot, force);
1386
+ if (!removed.ok) {
1387
+ return resultOf(origin, false, "git worktree remove failed: " + removed.reason + " The binding is unchanged.");
1388
+ }
1389
+ const dropped = await deps.binding.drop(sessionId);
1390
+ if (!dropped.ok) {
1391
+ return resultOf(origin, false, "Removed the worktree directory, but the binding could not be dropped: " + dropped.reason + " It is now stale and will be treated as unbound; call this tool again to retry the unbind.");
1392
+ }
1393
+ const after = await readOrigin(deps, sessionId);
1394
+ if (after.problem !== void 0) {
1395
+ return resultOf(after.origin, true, "Unbound this session and removed the worktree directory with git worktree remove. The Files tab root could not be read back: " + after.problem);
1396
+ }
1397
+ return resultOf(after.origin, true, after.origin.kind === "inherited" ? "Unbound this session and removed the worktree directory with git worktree remove. The Files tab now follows the root inherited from session " + after.origin.ownerSessionId + "." : "Unbound this session and removed the worktree directory with git worktree remove.");
1398
+ }
1399
+ async function unbindOnly(deps, sessionId, origin) {
1400
+ const dropped = await deps.binding.drop(sessionId);
1401
+ if (!dropped.ok) {
1402
+ return resultOf(origin, false, "Could not persist the unbind: " + dropped.reason);
1403
+ }
1404
+ const after = await readOrigin(deps, sessionId);
1405
+ if (after.problem !== void 0) {
1406
+ return resultOf(after.origin, true, "Unbound this session's own binding. The Files tab root could not be read back: " + after.problem);
1407
+ }
1408
+ return resultOf(after.origin, true, after.origin.kind === "inherited" ? "Unbound this session's own binding. The Files tab now follows the root inherited from session " + after.origin.ownerSessionId + "." : "Unbound the worktree from this session. The directory was left in place; open or refresh the Files tab to return to the session cwd.");
1409
+ }
1326
1410
  function inheritedHelp(ownerSessionId, cwd) {
1327
1411
  const main = "Nothing to unbind here: the Files tab root belongs to session " + ownerSessionId + ", not to this session. To stop following it: (1) unbind it in that session with ws_worktree_remove, or (2) bind this session to another worktree with ws_worktree_create / ws_worktree_register.";
1328
1412
  if (cwd === void 0)
@@ -1371,30 +1455,9 @@ function buildRemoveTool(deps) {
1371
1455
  if (force && !removeDirectory) {
1372
1456
  return resultOf(origin, false, "force only applies together with removeDirectory: true; the binding is unchanged.");
1373
1457
  }
1374
- if (!removeDirectory) {
1375
- const dropped2 = await deps.binding.drop(session.id);
1376
- if (!dropped2.ok) {
1377
- return resultOf(origin, false, "Could not persist the unbind: " + dropped2.reason);
1378
- }
1379
- const after2 = await readOrigin(deps, session.id);
1380
- if (after2.problem !== void 0) {
1381
- return resultOf(after2.origin, true, "Unbound this session's own binding. The Files tab root could not be read back: " + after2.problem);
1382
- }
1383
- return resultOf(after2.origin, true, after2.origin.kind === "inherited" ? "Unbound this session's own binding. The Files tab now follows the root inherited from session " + after2.origin.ownerSessionId + "." : "Unbound the worktree from this session. The directory was left in place; open or refresh the Files tab to return to the session cwd.");
1384
- }
1385
- const removed = await deps.git.removeWorktree(origin.record.repoRoot, origin.record.worktreeRoot, force);
1386
- if (!removed.ok) {
1387
- return resultOf(origin, false, "git worktree remove failed: " + removed.reason + " The binding is unchanged.");
1388
- }
1389
- const dropped = await deps.binding.drop(session.id);
1390
- if (!dropped.ok) {
1391
- return resultOf(origin, false, "Removed the worktree directory, but the binding could not be dropped: " + dropped.reason + " It is now stale and will be treated as unbound; call this tool again to retry the unbind.");
1392
- }
1393
- const after = await readOrigin(deps, session.id);
1394
- if (after.problem !== void 0) {
1395
- return resultOf(after.origin, true, "Unbound this session and removed the worktree directory with git worktree remove. The Files tab root could not be read back: " + after.problem);
1396
- }
1397
- return resultOf(after.origin, true, after.origin.kind === "inherited" ? "Unbound this session and removed the worktree directory with git worktree remove. The Files tab now follows the root inherited from session " + after.origin.ownerSessionId + "." : "Unbound this session and removed the worktree directory with git worktree remove.");
1458
+ if (!removeDirectory)
1459
+ return unbindOnly(deps, session.id, origin);
1460
+ return unbindAndRemove(deps, session.id, origin, force);
1398
1461
  }
1399
1462
  };
1400
1463
  }
@@ -1474,7 +1537,7 @@ var ToolsService = class {
1474
1537
  const consider = (agent) => this.consider(generation, agent);
1475
1538
  this.unsubscribe = deps.agents.subscribe(consider);
1476
1539
  for (const agent of deps.agents.list())
1477
- consider(agent);
1540
+ void consider(agent);
1478
1541
  }
1479
1542
  /**
1480
1543
  * 卸载:退订、逐个摘掉每个 agent 的工具、丢掉映射与在飞的异步链,复位装配标记。重复调用无害。
@@ -1503,7 +1566,7 @@ var ToolsService = class {
1503
1566
  }
1504
1567
  /** 判定一个 agent 是否该装工具,该装就装。同一个 agent 只处理一次。 */
1505
1568
  consider(generation, agent) {
1506
- this.chain = this.chain.then(async () => {
1569
+ const chain = this.chain.then(async () => {
1507
1570
  const deps = this.deps;
1508
1571
  if (deps === void 0)
1509
1572
  return;
@@ -1526,6 +1589,8 @@ var ToolsService = class {
1526
1589
  const reason = cause instanceof Error ? cause.message : String(cause);
1527
1590
  deps.logger.warn("dsh-worktree-sidebar: 工具注册失败 — " + reason);
1528
1591
  });
1592
+ this.chain = chain;
1593
+ return chain;
1529
1594
  }
1530
1595
  };
1531
1596
  var toolsService = new ToolsService();
@@ -1546,7 +1611,11 @@ async function apply(ctx, config = {}) {
1546
1611
  logger: ctx.logger,
1547
1612
  register: (route) => ctx.webServer.register(route),
1548
1613
  agents: bindAgents({
1549
- on: (event, handler) => ctx.on(event, handler),
1614
+ // 0.1.7-rc.2 serial 会等待 listener:完整等待 tools 域的注册链,再让 agent 创建继续。
1615
+ on: (event, handler) => ctx.on(event, async (payload) => {
1616
+ await handler({ agent: payload.agent });
1617
+ return void 0;
1618
+ }),
1550
1619
  all: () => ctx.agents.list()
1551
1620
  }),
1552
1621
  typert: bindTypert(ctx.typert.lookups),
@@ -28,10 +28,15 @@ export interface HostAgentLike {
28
28
  }
29
29
  /** 宿主事件面与 agent 枚举面:只开本适配器要的两样。 */
30
30
  export interface AgentHostPort {
31
- /** 订阅 agent 发布。返回退订函数。 */
31
+ /**
32
+ * 订阅 agent 发布。返回退订函数。
33
+ *
34
+ * 0.1.7-rc.2 用 serial 派发并等待 listener;因此适配器把 tools 域的 thenable 留在返回链上,
35
+ * 不能在取出 `agent` 后把它截成 `undefined`。
36
+ */
32
37
  on(event: "agent/created", handler: (payload: {
33
38
  agent: HostAgentLike;
34
- }) => void): () => void;
39
+ }) => undefined | PromiseLike<undefined>): () => void;
35
40
  /** 当前**所有存活** agent 快照(含子 agent)。 */
36
41
  all(): readonly HostAgentLike[];
37
42
  }
@@ -33,8 +33,8 @@ export interface AgentFace {
33
33
  * 谁订事件、从哪枚举、怎么把工具装进某个 agent 的作用域,都是组合根的知识。
34
34
  */
35
35
  export interface AgentPort {
36
- /** 订阅「新 agent 发布」。返回退订函数。 */
37
- subscribe(handler: (agent: AgentFace) => void): () => void;
36
+ /** 订阅「新 agent 发布」。返回退订函数;listener 的 thenable 属于宿主 serial 等待链。 */
37
+ subscribe(handler: (agent: AgentFace) => void | PromiseLike<void>): () => void;
38
38
  /** 当前存活的所有 agent 快照(含子 agent;插件加载前就存在的那些都在里面)。 */
39
39
  list(): readonly AgentFace[];
40
40
  /** 把工具装进该 agent 的作用域,返回释放函数。 */
@@ -2,6 +2,11 @@ import type { WorktreeOrigin } from "../../../scope/interface.js";
2
2
  import type { AgentFace, ToolsDeps } from "../../deps.js";
3
3
  import type { SessionFace } from "../session/index.js";
4
4
  import type { ToolResultValue } from "../protocol/index.js";
5
+ /**
6
+ * 绑定来源的形态。本域内各工具一律从**这里**取,不各自直引 scope/interface:
7
+ * tools → scope 是域间引用,纪律要求它只出现在一个面上(bind 域已经替本域读过一次)。
8
+ */
9
+ export type { WorktreeOrigin };
5
10
  /** 当前的绑定状态(三个工具的返回信封都要它)。 */
6
11
  export interface BindingState {
7
12
  readonly bound: boolean;
@@ -1,12 +1,3 @@
1
- /**
2
- * `ws_worktree_remove`:摘掉登记;只有显式 `removeDirectory` 才连带 `git worktree remove`。
3
- *
4
- * 默认不删目录是刻意的:删除可能丢掉未提交改动,而「我只是想换个根」与「我要销毁这个工作区」
5
- * 是两件事,不该由同一个默认值承担。
6
- *
7
- * **继承态不摘任何东西**:本会话没有自己的登记时,右栏的根属于父会话——摘它是改别人的状态,
8
- * 删目录更是销毁别人的工作区。这一态只如实说明并给出路。
9
- */
10
1
  import type { ToolDefinition } from "@deepseek-ai/dsh-tools";
11
2
  import type { ToolsDeps } from "../../deps.js";
12
3
  export declare function buildRemoveTool(deps: ToolsDeps): ToolDefinition;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@wingsky-1/dsh-worktree-sidebar",
3
- "version": "0.2.5",
4
- "description": "把某个 git worktree 登记给当前会话,使该会话的官方右侧栏文件树根指向该 worktree;会话 cwd 不变。官方提供原生 worktree 会话能力即退役的过渡适配层。",
3
+ "version": "0.2.7",
4
+ "description": "Point the current session's right-sidebar file tree at a git worktree without changing the session cwd. 把某个 git worktree 登记给当前会话,使该会话的官方右侧栏文件树根指向该 worktree;会话 cwd 不变。官方提供原生 worktree 会话能力即退役的过渡适配层。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -20,6 +20,16 @@
20
20
  "patch": "./cordis.patch.yml",
21
21
  "bannerJs": "import { createRequire as __worktreeSidebarRequire } from \"node:module\";\nconst require = __worktreeSidebarRequire(import.meta.url);"
22
22
  },
23
+ "catalog": {
24
+ "category": "ui",
25
+ "summary": {
26
+ "en": "Point the current session's right-sidebar file tree at a git worktree without changing the session cwd.",
27
+ "zh": "将会话右侧栏文件树根指向 git worktree,会话 cwd 不变,原生能力上线即退役"
28
+ },
29
+ "capabilities": [
30
+ "ui-sidebar-right"
31
+ ]
32
+ },
23
33
  "client": {
24
34
  "inject": [
25
35
  "@deepseek-ai/dsh-client-ui-sidebar-right"
@@ -35,21 +45,22 @@
35
45
  "LICENSE"
36
46
  ],
37
47
  "devDependencies": {
38
- "@deepseek-ai/cordis": "4.0.2",
39
- "@deepseek-ai/dsh-agent": "0.1.5-rc.1",
40
- "@deepseek-ai/dsh-api-workspace-files": "0.1.5-rc.1",
41
- "@deepseek-ai/dsh-host-webserver": "0.1.5-rc.1",
42
- "@deepseek-ai/dsh-session": "0.1.5-rc.1",
43
- "@deepseek-ai/dsh-tools": "0.1.5-rc.1",
44
- "@deepseek-ai/dsh-typert-protocol": "0.1.5-rc.1"
48
+ "@deepseek-ai/cordis": "4.0.4",
49
+ "@deepseek-ai/dsh-agent": "0.1.7-rc.2",
50
+ "@deepseek-ai/dsh-api-workspace-files": "0.1.7-rc.2",
51
+ "@deepseek-ai/dsh-host-webserver": "0.1.7-rc.2",
52
+ "@deepseek-ai/dsh-session": "0.1.7-rc.2",
53
+ "@deepseek-ai/dsh-tools": "0.1.7-rc.2",
54
+ "@deepseek-ai/dsh-typert-protocol": "0.1.7-rc.2"
45
55
  },
46
56
  "peerDependencies": {
47
- "@deepseek-ai/cordis": "4.0.2",
48
- "@deepseek-ai/dsh-agent": "0.1.5-rc.1",
49
- "@deepseek-ai/dsh-api-workspace-files": "0.1.5-rc.1",
50
- "@deepseek-ai/dsh-host-webserver": "0.1.5-rc.1",
51
- "@deepseek-ai/dsh-tools": "0.1.5-rc.1",
52
- "@deepseek-ai/dsh-typert-protocol": "0.1.5-rc.1"
57
+ "@deepseek-ai/cordis": "4.0.4",
58
+ "@deepseek-ai/dsh-agent": "0.1.7-rc.2",
59
+ "@deepseek-ai/dsh-api-workspace-files": "0.1.7-rc.2",
60
+ "@deepseek-ai/dsh-host-webserver": "0.1.7-rc.2",
61
+ "@deepseek-ai/dsh-tools": "0.1.7-rc.2",
62
+ "@deepseek-ai/dsh-typert-protocol": "0.1.7-rc.2",
63
+ "@deepseek-ai/dsh-session": "0.1.7-rc.2"
53
64
  },
54
65
  "peerDependenciesMeta": {
55
66
  "@deepseek-ai/cordis": {
@@ -69,6 +80,9 @@
69
80
  },
70
81
  "@deepseek-ai/dsh-typert-protocol": {
71
82
  "optional": true
83
+ },
84
+ "@deepseek-ai/dsh-session": {
85
+ "optional": true
72
86
  }
73
87
  },
74
88
  "engines": {
@@ -86,6 +100,7 @@
86
100
  "keywords": [
87
101
  "dsh",
88
102
  "deepseek-harness",
103
+ "dsh-plugin",
89
104
  "plugin",
90
105
  "worktree",
91
106
  "git",
@@ -97,7 +112,7 @@
97
112
  },
98
113
  "scripts": {
99
114
  "build": "node ../../scripts/build/clean-lib.ts && tsc -p tsconfig.json && node ../../scripts/build/bundle-host.ts .",
100
- "test": "node ../../scripts/test/run-vitest.mjs --min 19",
115
+ "test": "node ../../scripts/test/run-vitest.mjs --min 21",
101
116
  "typecheck": "tsc -p tsconfig.json --noEmit"
102
117
  }
103
118
  }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * 插件主目录拼装(单一事实源,实现见 `./paths.js`):等同
3
+ * `join(base, ...segments)`,默认形态路径逐字节不变。
4
+ *
5
+ * 只收敛「包主目录」直拼;以下 7 类排除:legacy/旧根(旧版迁移读面)、
6
+ * settings 文档(宿主文档读面)、resolve 对比(比较/候选命中顺序是语义)、
7
+ * 用户输入解析(`~`/相对路径展开)、credentials-userHome(DSH_HOME 域外凭据)、
8
+ * 展示脱敏(字符串替换不落盘)、包内反推(随包分发不在 DSH home 下)。
9
+ * provider-registry 的旧 `pluginHome` 保留为包内 facade(公开签名不变)。
10
+ * 完整理由见 `./paths.js` 函数 JSDoc。
11
+ */
12
+ export declare function pluginHome(base: string, ...segments: string[]): string;
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * 宿主端「设置命名空间」注册(单一事实源)。
3
3
  *
4
- * 背景与语义见 settings-namespace.js 顶部注释。要点:DSH rc.7 起设置卡槽
5
- * `settings.plugin.item` 为 keyed,只有在「宿主 serve 的命名空间 ∩ 卡片 key」
6
- * 非空时渲染;为让卡片显示,宿主端需用此函数把命名空间注册进 settings 服务。
7
- * 零包依赖,服务面注入,等值复刻官方 `installSettingsSection`(dsh-settings)。
4
+ * 背景与语义见 settings-namespace.js 顶部注释。要点:宿主 settings 服务按 `ns`
5
+ * 定位描述项并合成 `base + user`(无层字段时兼容取 `value`);写经
6
+ * update/replace/mutate;热更新经 document-updated 订阅(内部直连,不对外暴露 watch 面)。
7
+ * 本文件只收窄能力面(全 unknown 结构面,无官方包导入)。
8
8
  */
9
9
 
10
10
  /** `installSettingsNamespace` 的 hooks 面。 */
@@ -13,15 +13,51 @@ export interface SettingsNamespaceHooks {
13
13
  setSource(source: () => unknown): void;
14
14
  /** 来源切换或命名空间值变化时触发,插件据此刷新/落盘。 */
15
15
  onChange(): void;
16
- /** 可选自定义校验,透传给 settings.register。 */
17
- validate?: unknown;
18
16
  /**
19
- * 可选;register 返回 owner scope 后立即回调(先于 setSource)。
20
- * 供存量配置迁移 / 写路径装配使用;settings 服务缺失时不触发。
17
+ * 可选;owning fiber ACTIVE 且 canonical namespace 被 settings 服务描述后回调一次。
18
+ * 供存量配置迁移 / 写路径装配使用;fiber / 服务 / namespace 未就绪时不触发。
21
19
  */
22
20
  onScope?(scope: unknown, service: unknown): void;
23
21
  }
24
22
 
23
+ /** 描述项窄面(按 ns 定位,合成 base + user,兼容 value 回退)。 */
24
+ export interface SettingsFormsDescriptor {
25
+ /** 命名空间键。 */
26
+ ns?: unknown;
27
+ /** 运行时解析值(缺少分层字段时的兼容回退)。 */
28
+ value?: unknown;
29
+ /** 基础配置层。 */
30
+ base?: unknown;
31
+ /** 原始 user 覆盖层。 */
32
+ user?: unknown;
33
+ /** 乐观并发修订号。 */
34
+ revision?: unknown;
35
+ }
36
+
37
+ /** owner scope 窄面(describe 定位读+写委托;订阅由接缝内部直连)。 */
38
+ export interface SettingsFormsScope {
39
+ /** 当前有效值(describe base + user,缺席回落 entry)。 */
40
+ get(): unknown;
41
+ /** 委托 settings.update(ns, …)。 */
42
+ update(patch: object, expectedRevision?: number): Promise<unknown>;
43
+ /** 委托 settings.replace(ns, …)。 */
44
+ replace(section: object, expectedRevision?: number): Promise<unknown>;
45
+ /** 委托 settings.mutate(ns, …);服务缺失时返回拒绝。 */
46
+ mutate(ops: readonly unknown[], expectedRevision?: number): Promise<unknown>;
47
+ }
48
+
49
+ /** settings 服务窄面(describe/写)。 */
50
+ export interface SettingsFormsService {
51
+ /** 按 entry id 定位描述项。 */
52
+ describe(options?: { redactSecrets?: boolean }): SettingsFormsDescriptor[];
53
+ /** 合并写。 */
54
+ update?(ns: string, patch: object, expectedRevision?: number): Promise<unknown>;
55
+ /** 整节写。 */
56
+ replace?(ns: string, section: object, expectedRevision?: number): Promise<unknown>;
57
+ /** 路径写(可选)。 */
58
+ mutate?(ns: string, ops: readonly unknown[], expectedRevision?: number): Promise<unknown>;
59
+ }
60
+
25
61
  /**
26
62
  * 日志兜底:logger 可能确实没有(极端降级),全部可选调用。
27
63
  * 单一事实源(#436):notifier / lan-proxy 曾各复刻一份,现统一引用本导出。
@@ -30,11 +66,11 @@ export declare function warnLog(ctx: unknown, message: string): void;
30
66
 
31
67
  /**
32
68
  * 注册插件自有 settings 命名空间(服务面注入,无包依赖)。
33
- * 等值语义参考官方 `installSettingsSection`(@deepseek-ai/dsh-settings@0.1.0-rc.7)。
69
+ * 对外签名保持不变(调用方零改)。
34
70
  * @param ctx - 插件宿主端 apply 收到的 cordis 上下文。
35
71
  * @param ns - 插件自有命名空间(小写 kebab,须唯一)。
36
- * @param schema - schemastery schema,解析该命名空间的值(通常为插件 Config)。
37
- * @param entry - 组合层配置,作为命名空间的 `base` 层。
72
+ * @param schema - 占位:schema 由宿主持有,本函数不注册。
73
+ * @param entry - 组合层配置(describe 缺席时的回落值)。
38
74
  * @param hooks - source 收藏与变更通知。
39
75
  */
40
76
  export declare function installSettingsNamespace(
@@ -0,0 +1,11 @@
1
+ /**
2
+ * 空步:立即完成,不碰任何存储。调用方把版本号写在自己的步骤表里,run 统一指到本函数。
3
+ *
4
+ * @returns {Promise<void>} 恒为完成态的 Promise。
5
+ */
6
+ export function tickUpgradeVersion(): Promise<void>;
7
+
8
+ /**
9
+ * 空步(同步链用):什么都不做,立即返回。
10
+ */
11
+ export function tickUpgradeVersionSync(): void;