dsh-new-session 0.1.0 → 0.2.0

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.md CHANGED
@@ -1,17 +1,24 @@
1
1
  # `dsh-new-session`
2
2
 
3
- 在输入框里敲 `/new`,直接在当前工作区开一个新会话——等价于点侧边栏的 **New Session**
4
- 按钮,但不用把手从键盘上拿开。
3
+ 在输入框里敲 `/clear`(别名 `/new`),**开一段全新的对话,但留在当前工作目录**——和 Claude
4
+ Code 的 `/clear` 对齐:旧对话完整留在侧边栏历史里,上下文从零开始。
5
5
 
6
6
  ## 它做什么
7
7
 
8
- - 在 `/` 菜单里注册一个 **New session / 新建会话** 命令(`/new`,客户端 action)。
9
- - 执行时调用 Web GUI 的工作区导航服务,行为和 New Session 按钮完全一致:
10
- - 当前工作区里已有空白会话时**复用**它——所以回到那个空白会话未发送的草稿;
11
- - 没有空白会话时新建一个;
12
- - 旧会话原样留在历史列表里,不会被删除或修改。
13
- - 命令只在当前 Peer 有 `session/create` 权限时出现;只读的远程访问者看不到这一行。
14
- - 不提交任何提示词:`action` 类型会消费掉 `/new` 这个 token,不会把它当消息发给模型。
8
+ - 在 `/` 菜单里注册两个客户端 action:**Clear conversation / 清除对话**(`/clear`)和
9
+ **New session / 新建会话**(`/new`,Claude Code 语义下的别名)。
10
+ - 执行时:
11
+ 1. 找到当前会话所在的 Workspace,向宿主要一个空白会话(有可复用的空白就复用,和
12
+ New Session 按钮一致,未发送的草稿也还在);
13
+ 2. 通过本插件的宿主路由,把新会话的**工作目录**设成当前会话的工作目录;
14
+ 3. 导航到新会话。
15
+ - **工作目录(包括 worktree 副本)会继承**:新会话的创建目录仍是 Workspace 路径,但工作
16
+ 目录与当前会话一致。因此在 worktree 会话里 `/clear` 会留在**同一个 worktree**,而不是像
17
+ 普通新会话那样让 `dsh-worktree-switch` 再建一份副本——`dsh-worktree-switch` 看到新会话已
18
+ 在副本里,只显示 ready,不再 prepare。
19
+ - 旧会话原样保留(侧边栏历史、`/resume` 语义上的可回)。
20
+ - 只在当前 Peer 有 `session/create` 权限时注册;只读的远程访问者看不到这两行。
21
+ - 不提交任何提示词:`action` 类型会消费掉 `/clear` / `/new` 这个 token。
15
22
 
16
23
  ## 安装
17
24
 
@@ -21,39 +28,36 @@ dsh plugin --profile web add /home/ubuntu/code/deepseek-plugins/packages/dsh-new
21
28
  dsh --profile web --dump-config # 应能看到 "# == dsh-new-session"
22
29
  ```
23
30
 
24
- 装完需要重启正在运行的 Web profile:浏览器半由宿主发布,页面刷新不够,Host 的
25
- client-module 扫描要重新 boot。
26
-
27
- 构建产物由 `prepare` 脚本(tsdown)产出;改完源码后重新
28
- `pnpm --filter dsh-new-session build` 即可。外部安装的插件不在 `pnpm run dev:web` 的监视
29
- 范围内,client-hmr 每 500ms 轮询 `lib/client.js`,重新构建后页面会自动换上新的浏览器半。
31
+ 装完需要重启正在运行的 Web profile:宿主半新增了一条 Connection 路由,浏览器半由宿主发布,
32
+ 页面刷新不够。构建产物由 `prepare` 脚本(tsdown)产出;改完源码后重新
33
+ `pnpm --filter dsh-new-session build` 即可(外部安装的插件由 client-hmr 轮询 `lib/client.js`)。
30
34
 
31
35
  ## 在 GUI 里走一遍
32
36
 
33
- 1. 重启 Web profile,打开任意一个会话。
34
- 2. 在输入框里输入 `/new`:`/` 菜单里出现 **新建会话** 一行(也可以直接按回车执行)。
35
- 3. 执行后当前视图切到一个空白会话;输入框里是空的(如果那个空白会话本来就有草稿,草稿还在)。
36
- 4. 侧边栏里原来的会话还在,点回去内容完全没变。
37
+ 1. 重启 Web profile,打开任意会话(在 worktree 里的会话也可以)。
38
+ 2. 输入 `/clear`(或 `/new`)回车:视图切到一个空白会话,工作目录还是刚才那个目录。
39
+ 3. 如果原来在 worktree 副本里,新会话仍在同一个副本里——不会多出一份副本;旧会话在侧边栏
40
+ 里还在。
37
41
 
38
42
  ## 实现
39
43
 
40
- 两半都只做一件事:
41
-
42
- - **宿主半**(`src/index.ts`)是空插件。它存在的意义是让 Loader 挂载这个包——只有挂载了,
43
- client-module 扫描才会把 `lib/client.js` 发布给页面。
44
- - **浏览器半**(`src/client/index.tsx`)用 `ctx.commandUi.register()` 注册一个
45
- `kind: 'action'` 的贡献;`run` 调用 `ctx.uiWorkspace.startSession()`。注册包在
46
- `ctx.remoteAccess.whenAccess('session/create', …)` 里,权限变化时自动装上/卸下。
47
-
48
- `/new` 这个名字没有对应宿主命令,所以不会和现有 command 撞名;
49
- `cordis.patch.yml` 只插一行,没有配置项。
44
+ - **宿主半**(`src/index.ts` + `src/host/adopt.ts`)在 Connection 上注册一条精确路由
45
+ `POST /api/dsh-new-session/adopt`。它只做浏览器做不到的事:把目标会话的工作目录设成源会话
46
+ 的当前目录(`ctx.workingDirectory.set`)。守卫:
47
+ - 目标会话必须还没有开始任何 turn;
48
+ - 源会话的工作目录必须位于它自己的创建目录(`header.cwd`)之内——`cd` 到项目外的情况
49
+ 报告为 no-op,避免把会话挪进不属于它的项目。
50
+ - **浏览器半**(`src/client/index.tsx`)用 `ctx.commandUi.register()` 注册 `/clear` 与 `/new`
51
+ 两个 `action`;`run` 里 `uiWorkspace.connectWorkspace()` 拿空白会话 → 调宿主路由 →
52
+ `uiWorkspace.openSession()`。注册包在 `ctx.remoteAccess.whenAccess('session/create', …)` 里。
53
+ - 没有 Workspace 的会话回退到普通的新会话导航(`startSession()`)。
50
54
 
51
55
  ## 已知边界
52
56
 
53
57
  - **只对 Web GUI 生效**。SDK / 桌面纯文本入口的斜杠命令识别只认内置白名单
54
58
  (dsh 的 `query-input.ts`),第三方插件命令不会从那条路进入;要覆盖那些入口需要改 dsh 本体。
55
- - 它是「新会话」而不是「原地清空上下文」:旧对话仍然在历史里,会话 id 也不变的是**新会话**
56
- 的 id。想在同一个会话里清空上下文是另一件事(surface replace),这个插件不做。
57
- - 复用空白会话意味着连续两次 `/new` 可能停在同一个空白会话上——和按钮行为一致。
58
- - 菜单行的文案来自浏览器半的字典(en / zh);`locale/*.json` 只给市场页的
59
- `meta.title` / `meta.description` 用。
59
+ - **没有 `/clear <name>` 参数**(Claude Code 用它给旧对话命名)。dsh 的客户端 action 只处理
60
+ 不带参数的调用;要支持参数得把命令做成宿主 command + 客户端装饰,留作后续。
61
+ - `/reset` 别名没有注册:dsh 的客户端命令没有别名机制,每个别名都要占用菜单里的一行,所以
62
+ 只保留 `/clear` 和 `/new` 两行。
63
+ - 旧会话不会被删除或改名,只是留在历史里。
package/lib/client.js CHANGED
@@ -6,48 +6,105 @@ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
6
6
  // src/client/locales.ts
7
7
  /** English copy. */
8
8
  const en = {
9
- label: "New session",
10
- description: "Start a fresh conversation in this workspace"
9
+ "clear.label": "Clear conversation",
10
+ "clear.description": "Start a fresh conversation here; the previous one stays in your history",
11
+ "new.label": "New session",
12
+ "new.description": "Same as /clear: start a fresh conversation here"
11
13
  };
12
14
  /** Simplified Chinese copy. */
13
15
  const zh = {
14
- label: "新建会话",
15
- description: "在当前工作区开一个全新的对话"
16
+ "clear.label": "清除对话",
17
+ "clear.description": "在这里开始全新对话,旧对话保留在历史里",
18
+ "new.label": "新建会话",
19
+ "new.description": "与 /clear 相同:在这里开始全新对话"
16
20
  };
17
21
  // src/client/index.tsx
18
22
  /** Browser module id: package name, bundle row id, plugin id. */
19
23
  const name = "dsh-new-session";
20
24
  /** Locale namespace this plugin owns. */
21
25
  const NS = "newSession";
22
- /** Client services the command cannot work without. */
26
+ /** Host route namespace; the package name and the wire method prefix are the same. */
27
+ const PLUGIN_ID = "dsh-new-session";
28
+ /** Client services the commands cannot work without. */
23
29
  const inject = [
24
30
  "commandUi",
31
+ "connection",
25
32
  "locale",
26
33
  "remoteAccess",
27
- "uiWorkspace"
34
+ "uiWorkspace",
35
+ "workspaces"
28
36
  ];
29
37
  /**
30
- * Build the `/new` contribution.
38
+ * Build the `/clear` contribution and its `/new` alias.
31
39
  *
32
- * Exported for the contract spec: the contribution is the unit under test, and
33
- * `apply` only decides when to install it.
40
+ * The client command registry has no alias mechanism (reserved for first-party
41
+ * host commands), so an alias is a second contribution with the same action;
42
+ * its copy says so.
34
43
  *
35
- * @param deps - translator and navigation callback.
36
- * @returns the silent action contribution.
44
+ * @param deps - translator and conversation starter.
45
+ * @returns both contributions, primary first.
37
46
  */
38
- function createNewSessionCommand(deps) {
39
- return {
40
- name: "new",
41
- label: () => deps.t("label"),
42
- description: () => deps.t("description"),
47
+ function createCommands(deps) {
48
+ const command = (name, prefix) => ({
49
+ name,
50
+ label: () => deps.t(`${prefix}.label`),
51
+ description: () => deps.t(`${prefix}.description`),
43
52
  available: () => true,
44
53
  ui: {
45
54
  kind: "action",
46
- run: () => {
47
- deps.start();
55
+ run: (session) => {
56
+ deps.start(session.sessionId);
48
57
  }
49
58
  }
50
- };
59
+ });
60
+ return [command("clear", "clear"), command("new", "new")];
61
+ }
62
+ /**
63
+ * Call the Host adoption verb.
64
+ * @param ctx - browser Cordis context providing `connection`.
65
+ * @param sourceSessionId - Session being left.
66
+ * @param targetSessionId - blank Session that should start where the source is.
67
+ * @throws {Error} when the Host refused or the reply is malformed.
68
+ */
69
+ async function adopt(ctx, sourceSessionId, targetSessionId) {
70
+ const connection = ctx.get("connection");
71
+ if (connection === void 0) throw new Error("the connection service is not mounted");
72
+ const result = await connection.rpc.call("/api", `${PLUGIN_ID}/adopt`, {
73
+ sourceSessionId,
74
+ targetSessionId
75
+ });
76
+ if (result === null || typeof result !== "object" || !("ok" in result)) throw new Error("malformed reply from the adopt verb");
77
+ if (!result.ok) throw new Error(result.error.message);
78
+ }
79
+ /**
80
+ * Start a fresh conversation in the source Session's working directory.
81
+ *
82
+ * Sessions that do not belong to a Workspace fall back to plain navigation:
83
+ * there is no Workspace whose blank Session could reuse the directory, and the
84
+ * previous behavior is still the honest answer for that shape.
85
+ *
86
+ * @param ctx - browser Cordis context.
87
+ * @param sessionId - Session the user is leaving.
88
+ */
89
+ async function start(ctx, sessionId) {
90
+ const workspace = ctx.workspaces.list.getSnapshot().items.find((item) => item.sessionIds.includes(sessionId));
91
+ if (workspace === void 0) {
92
+ ctx.uiWorkspace.startSession();
93
+ return;
94
+ }
95
+ let target;
96
+ try {
97
+ target = await ctx.uiWorkspace.connectWorkspace(workspace.workspaceId);
98
+ } catch (error) {
99
+ console.warn("dsh-new-session: could not create the next Session:", error);
100
+ return;
101
+ }
102
+ try {
103
+ await adopt(ctx, sessionId, target);
104
+ } catch (error) {
105
+ console.warn("dsh-new-session: could not inherit the working directory:", error);
106
+ }
107
+ ctx.uiWorkspace.openSession(target);
51
108
  }
52
109
  /**
53
110
  * Mount the browser half.
@@ -59,15 +116,20 @@ function apply(ctx) {
59
116
  zh
60
117
  }), "dsh-new-session: dictionaries");
61
118
  const t = ctx.locale.bind(NS);
62
- ctx.effect(() => ctx.remoteAccess.whenAccess("session/create", () => ctx.commandUi.register(createNewSessionCommand({
63
- t,
64
- start: () => {
65
- ctx.uiWorkspace.startSession();
66
- }
67
- }))), "dsh-new-session: /new command");
119
+ ctx.effect(() => ctx.remoteAccess.whenAccess("session/create", () => {
120
+ const disposers = createCommands({
121
+ t,
122
+ start: (sessionId) => {
123
+ start(ctx, sessionId);
124
+ }
125
+ }).map((command) => ctx.commandUi.register(command));
126
+ return () => {
127
+ for (const dispose of disposers) dispose();
128
+ };
129
+ }), "dsh-new-session: /clear commands");
68
130
  }
69
131
  exports.apply = apply;
70
- exports.createNewSessionCommand = createNewSessionCommand;
132
+ exports.createCommands = createCommands;
71
133
  exports.inject = inject;
72
134
  exports.name = name;
73
135
 
package/lib/index.d.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  import { Context } from "@deepseek-ai/cordis";
2
2
  /** Plugin id: package name, bundle row id, and browser module id. */
3
3
  export declare const name = "dsh-new-session";
4
+ /** Services this plugin cannot work without. */
5
+ export declare const inject: string[];
4
6
  /**
5
- * Mount the inert Host half.
6
- * @param ctx - the Cordis context.
7
+ * Mount the Host half.
8
+ * @param ctx - Host context.
7
9
  */
8
10
  export declare function apply(ctx: Context): void;
9
11
  //# sourceMappingURL=index.d.ts.map
package/lib/index.js CHANGED
@@ -1,11 +1,204 @@
1
+ import { isAbsolute, relative } from "node:path";
2
+ // src/host/adopt.ts
3
+ /**
4
+ * Host half of the conversation reset: adopt a freshly created Session into the
5
+ * source Session's working directory.
6
+ *
7
+ * This is the dsh equivalent of Claude Code recording a new conversation under
8
+ * the same working directory as the previous one. A dsh Session owns two
9
+ * directories: the immutable creation directory (`header.cwd`, which is what
10
+ * keeps it inside its Workspace) and the committed working directory (the
11
+ * `working-directory` projection). `/clear` must move the *working* directory
12
+ * only, so the new Session stays a member of the same Workspace while any
13
+ * worktree/isolation state the source had is carried over.
14
+ *
15
+ * @module dsh-new-session/host/adopt
16
+ */
17
+ /** Absolute prefix every route below lives under. */
18
+ const API_PREFIX = "/api/dsh-new-session";
19
+ /** Plugin id; the wire method prefix and the package name are the same string. */
20
+ const PLUGIN_ID = "dsh-new-session";
21
+ /** This plugin serves exactly one verb. */
22
+ const ENDPOINT = "adopt";
23
+ /**
24
+ * Whether `directory` is `root` or one of its descendants.
25
+ * @param directory - candidate absolute directory.
26
+ * @param root - absolute ancestor directory.
27
+ * @returns whether `directory` is contained in `root`.
28
+ */
29
+ function isInside(directory, root) {
30
+ const path = relative(root, directory);
31
+ return path === "" || !path.startsWith("..") && !isAbsolute(path);
32
+ }
33
+ /**
34
+ * Resolve one live Agent, resuming it through the Session Controller when
35
+ * available and falling back to the registry otherwise.
36
+ * @param ctx - Host context.
37
+ * @param sessionId - wire session identity.
38
+ * @param signal - caller cancellation.
39
+ * @returns the live Agent.
40
+ * @throws when the Session cannot be resolved.
41
+ */
42
+ async function agentOf(ctx, sessionId, signal) {
43
+ signal.throwIfAborted();
44
+ if (sessionId === "") throw new Error("a session id is required");
45
+ const live = ctx.agents.get(sessionId);
46
+ if (live !== void 0) return live;
47
+ const controller = ctx.get("sessionController");
48
+ if (controller !== void 0) {
49
+ const resolved = await controller.resolveAgent(sessionId);
50
+ if (!("error" in resolved)) return resolved.agent;
51
+ throw new Error(resolved.error.message);
52
+ }
53
+ throw new Error(`no live agent for session ${sessionId}`);
54
+ }
55
+ /**
56
+ * Build the adoption verb.
57
+ *
58
+ * Discovery is deliberately conservative: the target must be a Session that
59
+ * has not started a turn yet, and the source's working directory must live
60
+ * inside the source's own creation directory. Copying a directory from outside
61
+ * that tree (a Session that `cd`-ed into another repository) would contradict
62
+ * the target's project membership and make an isolating plugin misread it, so
63
+ * that case is a reported no-op instead.
64
+ *
65
+ * @param ctx - Host context carrying `agents` and `workingDirectory`.
66
+ * @returns the verb.
67
+ */
68
+ function createAdoption(ctx) {
69
+ return async (input, signal) => {
70
+ const source = await agentOf(ctx, input.sourceSessionId, signal);
71
+ const target = await agentOf(ctx, input.targetSessionId, signal);
72
+ signal.throwIfAborted();
73
+ if (source.session.id === target.session.id) throw new Error("the source and target sessions must differ");
74
+ const boundary = ctx.agents.turnBoundaryOf(target.session);
75
+ if (boundary.lastTurn !== 0 || boundary.openTurnStartSeq !== null) throw new Error(`target session "${target.session.id}" has already started`);
76
+ const workingDirectory = ctx.workingDirectory.get(source.session);
77
+ const home = source.session.header.cwd;
78
+ if (home === void 0 || !isInside(workingDirectory, home)) return {
79
+ adopted: false,
80
+ workingDirectory
81
+ };
82
+ await ctx.workingDirectory.set(target, workingDirectory, signal);
83
+ return {
84
+ adopted: true,
85
+ workingDirectory
86
+ };
87
+ };
88
+ }
89
+ /**
90
+ * Decode the browser's `client-request` envelope.
91
+ * @param body - parsed JSON body.
92
+ * @returns the request, or null when the envelope is malformed.
93
+ */
94
+ function readClientRequest(body) {
95
+ if (body === null || typeof body !== "object" || Array.isArray(body)) return null;
96
+ const record = body;
97
+ if (record["type"] !== "client-request") return null;
98
+ const { rpcId, method } = record;
99
+ if (typeof rpcId !== "string" || rpcId === "") return null;
100
+ if (typeof method !== "string" || method === "") return null;
101
+ const payload = record["payload"];
102
+ return {
103
+ rpcId,
104
+ method,
105
+ payload: payload !== null && typeof payload === "object" && !Array.isArray(payload) ? payload : {}
106
+ };
107
+ }
108
+ /**
109
+ * Read one required non-empty string field.
110
+ * @param payload - decoded payload.
111
+ * @param field - field name.
112
+ * @returns the value.
113
+ * @throws when the field is missing or empty.
114
+ */
115
+ function requiredString(payload, field) {
116
+ const value = payload[field];
117
+ if (typeof value !== "string" || value.trim() === "") throw new Error(`payload.${field} must be a non-empty string`);
118
+ return value;
119
+ }
120
+ /** A successful wire result. */
121
+ function ok(value) {
122
+ return {
123
+ ok: true,
124
+ value
125
+ };
126
+ }
127
+ /** A failed wire result carrying the stable code. */
128
+ function fail(code, message) {
129
+ return {
130
+ ok: false,
131
+ error: {
132
+ code,
133
+ message,
134
+ details: {}
135
+ }
136
+ };
137
+ }
138
+ /**
139
+ * Register the adoption route on the shared `/api` channel.
140
+ *
141
+ * Exact routes are the only shape an out-of-tree plugin can claim: `/api` is
142
+ * the one logical channel the API Gateway owns, so `rpc.handle` refuses it and
143
+ * a second `rpc.intercept` throws. The route answers owner peers only, which is
144
+ * what the local Web GUI is.
145
+ *
146
+ * @param ctx - Host context carrying `connection`.
147
+ * @param adopt - the verb this route serves.
148
+ */
149
+ function registerAdoptionRoute(ctx, adopt) {
150
+ ctx.effect(() => ctx.connection.fetch.register({
151
+ path: `${API_PREFIX}/${ENDPOINT}`,
152
+ methods: ["POST"],
153
+ requestBody: "buffered",
154
+ async fetch(request) {
155
+ if (request.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() !== "application/json") return new Response("content type must be application/json", { status: 415 });
156
+ let body;
157
+ try {
158
+ body = await request.json();
159
+ } catch {
160
+ return new Response("body is not JSON", { status: 400 });
161
+ }
162
+ const message = readClientRequest(body);
163
+ if (message === null) return new Response("invalid client-request message", { status: 400 });
164
+ if (message.method !== `dsh-new-session/adopt`) return Response.json({
165
+ type: "server-response",
166
+ rpcId: message.rpcId,
167
+ result: fail("bad-request", "RPC method does not match endpoint")
168
+ });
169
+ let result;
170
+ try {
171
+ result = ok(await adopt({
172
+ sourceSessionId: requiredString(message.payload, "sourceSessionId"),
173
+ targetSessionId: requiredString(message.payload, "targetSessionId")
174
+ }, request.signal));
175
+ } catch (error) {
176
+ result = fail("adopt-failed", error instanceof Error ? error.message : String(error));
177
+ }
178
+ return Response.json({
179
+ type: "server-response",
180
+ rpcId: message.rpcId,
181
+ result
182
+ });
183
+ }
184
+ }), `${PLUGIN_ID} ${ENDPOINT} route`);
185
+ }
1
186
  // src/index.ts
2
187
  /** Plugin id: package name, bundle row id, and browser module id. */
3
188
  const name = "dsh-new-session";
189
+ /** Services this plugin cannot work without. */
190
+ const inject = [
191
+ "agents",
192
+ "connection",
193
+ "workingDirectory"
194
+ ];
4
195
  /**
5
- * Mount the inert Host half.
6
- * @param ctx - the Cordis context.
196
+ * Mount the Host half.
197
+ * @param ctx - Host context.
7
198
  */
8
- function apply(ctx) {}
9
- export { apply, name };
199
+ function apply(ctx) {
200
+ registerAdoptionRoute(ctx, createAdoption(ctx));
201
+ }
202
+ export { apply, inject, name };
10
203
 
11
204
  //# sourceMappingURL=index.js.map
package/lib/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../src/index.ts"],"sourcesContent":["/**\n * Host half of the `/new` command plugin.\n *\n * The command itself is a browser contribution: the slash menu and the\n * new-Session navigation both live in the Web GUI, so this Host row only mounts\n * the package — which is enough for the client-module scanner to publish\n * `lib/client.js`, and for the plugin's lifecycle to follow the bundle.\n *\n * @module dsh-new-session\n */\n\nimport type { Context } from '@deepseek-ai/cordis'\n\n/** Plugin id: package name, bundle row id, and browser module id. */\nexport const name = 'dsh-new-session'\n\n/**\n * Mount the inert Host half.\n * @param ctx - the Cordis context.\n */\nexport function apply(ctx: Context): void {\n // No host-side capability on purpose: a Host handler could not move the\n // browser's current Session, so `/new` is a client-owned action instead.\n void ctx\n}\n"],"mappings":";;AAcA,MAAa,OAAO;;;;;AAMpB,SAAgB,MAAM,KAAoB,CAI1C"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../src/host/adopt.ts","../src/index.ts"],"sourcesContent":["/**\n * Host half of the conversation reset: adopt a freshly created Session into the\n * source Session's working directory.\n *\n * This is the dsh equivalent of Claude Code recording a new conversation under\n * the same working directory as the previous one. A dsh Session owns two\n * directories: the immutable creation directory (`header.cwd`, which is what\n * keeps it inside its Workspace) and the committed working directory (the\n * `working-directory` projection). `/clear` must move the *working* directory\n * only, so the new Session stays a member of the same Workspace while any\n * worktree/isolation state the source had is carried over.\n *\n * @module dsh-new-session/host/adopt\n */\n\nimport { isAbsolute, relative } from 'node:path'\nimport type { Context } from '@deepseek-ai/cordis'\nimport type { Agent } from '@deepseek-ai/dsh-agent'\nimport type { SessionId } from '@deepseek-ai/dsh-session'\nimport type { ConnectionRpcResult } from '@deepseek-ai/dsh-client-connection'\n\n/** Absolute prefix every route below lives under. */\nexport const API_PREFIX = '/api/dsh-new-session'\n\n/** Plugin id; the wire method prefix and the package name are the same string. */\nexport const PLUGIN_ID = 'dsh-new-session'\n\n/** This plugin serves exactly one verb. */\nexport const ENDPOINT = 'adopt' as const\n\n/** One decoded adoption request. */\nexport interface AdoptionInput {\n /** Session the user is leaving; its working directory is the target. */\n readonly sourceSessionId: string\n /** Freshly created Session that should start where the source is. */\n readonly targetSessionId: string\n}\n\n/** What the Host did with one adoption request. */\nexport interface AdoptionValue {\n /** Whether the target's working directory was moved. */\n readonly adopted: boolean\n /** The source's current working directory. */\n readonly workingDirectory: string | null\n}\n\n/** The verb implementation the route dispatches to. */\nexport type Adopt = (input: AdoptionInput, signal: AbortSignal) => Promise<AdoptionValue>\n\n/**\n * Whether `directory` is `root` or one of its descendants.\n * @param directory - candidate absolute directory.\n * @param root - absolute ancestor directory.\n * @returns whether `directory` is contained in `root`.\n */\nexport function isInside(directory: string, root: string): boolean {\n const path = relative(root, directory)\n return path === '' || (!path.startsWith('..') && !isAbsolute(path))\n}\n\n/**\n * Resolve one live Agent, resuming it through the Session Controller when\n * available and falling back to the registry otherwise.\n * @param ctx - Host context.\n * @param sessionId - wire session identity.\n * @param signal - caller cancellation.\n * @returns the live Agent.\n * @throws when the Session cannot be resolved.\n */\nasync function agentOf(ctx: Context, sessionId: string, signal: AbortSignal): Promise<Agent> {\n signal.throwIfAborted()\n if (sessionId === '') throw new Error('a session id is required')\n // The common case (the Session the user is in, and the blank Session just\n // created for them) is already live; the controller handles a cold Session.\n const live = ctx.agents.get(sessionId as SessionId)\n if (live !== undefined) return live\n const controller = ctx.get('sessionController')\n if (controller !== undefined) {\n const resolved = await controller.resolveAgent(sessionId as SessionId)\n if (!('error' in resolved)) return resolved.agent\n throw new Error(resolved.error.message)\n }\n throw new Error(`no live agent for session ${sessionId}`)\n}\n\n/**\n * Build the adoption verb.\n *\n * Discovery is deliberately conservative: the target must be a Session that\n * has not started a turn yet, and the source's working directory must live\n * inside the source's own creation directory. Copying a directory from outside\n * that tree (a Session that `cd`-ed into another repository) would contradict\n * the target's project membership and make an isolating plugin misread it, so\n * that case is a reported no-op instead.\n *\n * @param ctx - Host context carrying `agents` and `workingDirectory`.\n * @returns the verb.\n */\nexport function createAdoption(ctx: Context): Adopt {\n return async (input, signal) => {\n const source = await agentOf(ctx, input.sourceSessionId, signal)\n const target = await agentOf(ctx, input.targetSessionId, signal)\n signal.throwIfAborted()\n if (source.session.id === target.session.id) {\n throw new Error('the source and target sessions must differ')\n }\n const boundary = ctx.agents.turnBoundaryOf(target.session)\n if (boundary.lastTurn !== 0 || boundary.openTurnStartSeq !== null) {\n throw new Error(`target session \"${target.session.id}\" has already started`)\n }\n const workingDirectory = ctx.workingDirectory.get(source.session)\n const home = source.session.header.cwd\n if (home === undefined || !isInside(workingDirectory, home)) {\n return { adopted: false, workingDirectory }\n }\n await ctx.workingDirectory.set(target, workingDirectory, signal)\n return { adopted: true, workingDirectory }\n }\n}\n\n/** One decoded client request. */\ninterface ClientRequest {\n readonly rpcId: string\n readonly method: string\n readonly payload: Record<string, unknown>\n}\n\n/**\n * Decode the browser's `client-request` envelope.\n * @param body - parsed JSON body.\n * @returns the request, or null when the envelope is malformed.\n */\nfunction readClientRequest(body: unknown): ClientRequest | null {\n if (body === null || typeof body !== 'object' || Array.isArray(body)) return null\n const record = body as Record<string, unknown>\n if (record['type'] !== 'client-request') return null\n const { rpcId, method } = record\n if (typeof rpcId !== 'string' || rpcId === '') return null\n if (typeof method !== 'string' || method === '') return null\n const payload = record['payload']\n return {\n rpcId,\n method,\n payload: payload !== null && typeof payload === 'object' && !Array.isArray(payload)\n ? payload as Record<string, unknown>\n : {},\n }\n}\n\n/**\n * Read one required non-empty string field.\n * @param payload - decoded payload.\n * @param field - field name.\n * @returns the value.\n * @throws when the field is missing or empty.\n */\nfunction requiredString(payload: Record<string, unknown>, field: string): string {\n const value = payload[field]\n if (typeof value !== 'string' || value.trim() === '') {\n throw new Error(`payload.${field} must be a non-empty string`)\n }\n return value\n}\n\n/** A successful wire result. */\nfunction ok<T>(value: T): ConnectionRpcResult<T> {\n return { ok: true, value }\n}\n\n/** A failed wire result carrying the stable code. */\nfunction fail(code: string, message: string): ConnectionRpcResult<never> {\n return { ok: false, error: { code, message, details: {} } }\n}\n\n/**\n * Register the adoption route on the shared `/api` channel.\n *\n * Exact routes are the only shape an out-of-tree plugin can claim: `/api` is\n * the one logical channel the API Gateway owns, so `rpc.handle` refuses it and\n * a second `rpc.intercept` throws. The route answers owner peers only, which is\n * what the local Web GUI is.\n *\n * @param ctx - Host context carrying `connection`.\n * @param adopt - the verb this route serves.\n */\nexport function registerAdoptionRoute(ctx: Context, adopt: Adopt): void {\n ctx.effect(() => ctx.connection.fetch.register({\n path: `${API_PREFIX}/${ENDPOINT}`,\n methods: ['POST'],\n requestBody: 'buffered',\n async fetch(request) {\n const contentType = request.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase()\n if (contentType !== 'application/json') {\n return new Response('content type must be application/json', { status: 415 })\n }\n let body: unknown\n try {\n body = await request.json()\n } catch {\n return new Response('body is not JSON', { status: 400 })\n }\n const message = readClientRequest(body)\n if (message === null) return new Response('invalid client-request message', { status: 400 })\n if (message.method !== `${PLUGIN_ID}/${ENDPOINT}`) {\n return Response.json({\n type: 'server-response',\n rpcId: message.rpcId,\n result: fail('bad-request', 'RPC method does not match endpoint'),\n })\n }\n let result: ConnectionRpcResult<AdoptionValue>\n try {\n result = ok(await adopt({\n sourceSessionId: requiredString(message.payload, 'sourceSessionId'),\n targetSessionId: requiredString(message.payload, 'targetSessionId'),\n }, request.signal))\n } catch (error) {\n result = fail('adopt-failed', error instanceof Error ? error.message : String(error))\n }\n return Response.json({ type: 'server-response', rpcId: message.rpcId, result })\n },\n }), `${PLUGIN_ID} ${ENDPOINT} route`)\n}\n","/**\n * Host half of the conversation reset.\n *\n * The browser half owns the `/clear` (alias `/new`) command: it decides when\n * the user wants a fresh conversation and drives Workspace navigation. This\n * half owns the one thing only the Host can do — start the new Session in the\n * source Session's working directory — and exposes it as one exact route on the\n * Connection fetch bridge.\n *\n * @module dsh-new-session\n */\n\nimport type { Context } from '@deepseek-ai/cordis'\nimport type {} from '@deepseek-ai/dsh-agent'\nimport type {} from '@deepseek-ai/dsh-api-session-controller'\nimport type {} from '@deepseek-ai/dsh-client-connection'\nimport type {} from '@deepseek-ai/dsh-working-directory'\nimport { createAdoption, registerAdoptionRoute } from './host/adopt.ts'\n\n/** Plugin id: package name, bundle row id, and browser module id. */\nexport const name = 'dsh-new-session'\n\n/** Services this plugin cannot work without. */\nexport const inject = ['agents', 'connection', 'workingDirectory']\n\n/**\n * Mount the Host half.\n * @param ctx - Host context.\n */\nexport function apply(ctx: Context): void {\n registerAdoptionRoute(ctx, createAdoption(ctx))\n}\n"],"mappings":"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"}
package/locale/en.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
- "title": "New Session command",
4
- "description": "Type /new in the composer to start a fresh conversation in the current workspace."
3
+ "title": "Clear conversation command",
4
+ "description": "Type /clear (or /new) in the composer to start a fresh conversation in the current working directory; the previous one stays in your history."
5
5
  }
6
6
  }
package/locale/zh.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
- "title": "新建会话命令",
4
- "description": "在输入框里输入 /new,直接在当前工作区开一个全新的对话。"
3
+ "title": "清除对话命令",
4
+ "description": "输入 /clear(或 /new),在当前工作目录开一段全新的对话,旧对话保留在历史里。"
5
5
  }
6
6
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-new-session",
3
- "version": "0.1.0",
4
- "description": "A `/new` slash command in the Web GUI composer: start a fresh Session in the current Workspace without leaving the keyboard.",
3
+ "version": "0.2.0",
4
+ "description": "`/clear` (alias `/new`) in the Web GUI composer: start a fresh conversation in the current working directory and keep the previous one in your history.",
5
5
  "keywords": [
6
6
  "dsh",
7
7
  "dsh-plugin",
@@ -45,6 +45,8 @@
45
45
  "platform": "web",
46
46
  "inject": [
47
47
  "@deepseek-ai/dsh-api-remote-access",
48
+ "@deepseek-ai/dsh-api-workspace-controller",
49
+ "@deepseek-ai/dsh-client-connection",
48
50
  "@deepseek-ai/dsh-client-locale",
49
51
  "@deepseek-ai/dsh-client-ui-commands",
50
52
  "@deepseek-ai/dsh-client-ui-workspace"
@@ -53,21 +55,31 @@
53
55
  },
54
56
  "peerDependencies": {
55
57
  "@deepseek-ai/cordis": "^4.1.0",
58
+ "@deepseek-ai/dsh-agent": ">=0.2.2-rc.1 <0.2.3-0",
56
59
  "@deepseek-ai/dsh-api-remote-access": ">=0.2.2-rc.1 <0.2.3-0",
57
60
  "@deepseek-ai/dsh-api-session-controller": ">=0.2.2-rc.1 <0.2.3-0",
61
+ "@deepseek-ai/dsh-api-workspace-controller": ">=0.2.2-rc.1 <0.2.3-0",
62
+ "@deepseek-ai/dsh-client-connection": ">=0.2.2-rc.1 <0.2.3-0",
58
63
  "@deepseek-ai/dsh-client-locale": ">=0.2.2-rc.1 <0.2.3-0",
59
64
  "@deepseek-ai/dsh-client-ui-commands": ">=0.2.2-rc.1 <0.2.3-0",
60
65
  "@deepseek-ai/dsh-client-ui-slots": ">=0.2.2-rc.1 <0.2.3-0",
61
- "@deepseek-ai/dsh-client-ui-workspace": ">=0.2.2-rc.1 <0.2.3-0"
66
+ "@deepseek-ai/dsh-client-ui-workspace": ">=0.2.2-rc.1 <0.2.3-0",
67
+ "@deepseek-ai/dsh-session": ">=0.2.2-rc.1 <0.2.3-0",
68
+ "@deepseek-ai/dsh-working-directory": ">=0.2.2-rc.1 <0.2.3-0"
62
69
  },
63
70
  "devDependencies": {
64
71
  "@deepseek-ai/cordis": "4.1.0",
72
+ "@deepseek-ai/dsh-agent": "0.2.2-rc.1",
65
73
  "@deepseek-ai/dsh-api-remote-access": "0.2.2-rc.1",
66
74
  "@deepseek-ai/dsh-api-session-controller": "0.2.2-rc.1",
75
+ "@deepseek-ai/dsh-api-workspace-controller": "0.2.2-rc.1",
76
+ "@deepseek-ai/dsh-client-connection": "0.2.2-rc.1",
67
77
  "@deepseek-ai/dsh-client-locale": "0.2.2-rc.1",
68
78
  "@deepseek-ai/dsh-client-ui-commands": "0.2.2-rc.1",
69
79
  "@deepseek-ai/dsh-client-ui-slots": "0.2.2-rc.1",
70
80
  "@deepseek-ai/dsh-client-ui-workspace": "0.2.2-rc.1",
81
+ "@deepseek-ai/dsh-session": "0.2.2-rc.1",
82
+ "@deepseek-ai/dsh-working-directory": "0.2.2-rc.1",
71
83
  "@types/node": "^26.6.6",
72
84
  "tsdown": "^0.23.4",
73
85
  "typescript": "^7.0.2",