@wangchenglai/dsh-acp-session 0.0.0-stage → 0.1.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,3 +1,77 @@
1
- # Temporary Holding Version
1
+ # @wangchenglai/dsh-acp-session
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 给 DeepSeek Harness 的 ACP 补上三件事,让客户端只用**标准 ACP** 就能管理会话:
4
+
5
+ | 能力 | dsh 自带 | 本插件 |
6
+ |---|---|---|
7
+ | `session/list` 返回 `title` / `updatedAt` | ❌ 只有 id + cwd | ✅ 用 `sessionPersistence` 补上 |
8
+ | `session/load`(回放历史) | ❌ 未实现 | ✅ `session/resume` + 按 `session/update` 回放 |
9
+ | `session/delete` | ❌ 未实现 | ✅ 关闭会话 + 删除会话目录 |
10
+
11
+ ## 原理
12
+
13
+ 不 fork、不修改 dsh 源码,而是"套"在它外面:
14
+
15
+ ```
16
+ 浏览器 ──stdio──▶ [本插件:消息中继] ──进程内 stream──▶ @deepseek-ai/dsh-acp
17
+ ```
18
+
19
+ `@deepseek-ai/dsh-acp` 的配置里有 `stream` 字段(运行时 transport),本插件用它把
20
+ 上游 ACP 接到进程内的双工通道上,自己接管真正的 stdio:
21
+
22
+ - 普通消息:原样双向转发
23
+ - `session/list`:转发后补 `title` / `updatedAt`
24
+ - `session/load`:`session/resume`("already active" 视为成功)+ 回放历史 + 返回 `{}`
25
+ - `session/delete`:`session/close`(忽略失败)+ 删会话目录 + 返回 `{}`
26
+
27
+ 会话数据全部通过 dsh 自己的 `ctx.sessionPersistence`(`list` / `resolveCurrentLog` /
28
+ `readStoredLog`)读取,不再自己解压 `session.v4.jsonl.zstd`。
29
+
30
+ ## 安装(镜像内)
31
+
32
+ Dockerfile 里:
33
+
34
+ ```dockerfile
35
+ COPY --chown=root:root dsh-plugins/acp-session /opt/dsh-plugins/acp-session
36
+ RUN cd /opt/dsh-plugins/acp-session \
37
+ && npm install --omit=dev --no-audit --no-fund \
38
+ && chmod +x start-acp.sh
39
+ ```
40
+
41
+ `start-acp.sh` 负责:
42
+
43
+ 1. 把插件软链到 `$DSH_HOME/profiles/acp/node_modules/@wangchenglai/dsh-acp-session`
44
+ (不动用户的 `package.json`,也不需要联网跑 pnpm)
45
+ 2. `exec dsh --profile acp --patch /opt/dsh-plugins/acp-session/profile-patch.yml`
46
+ (`--patch` 是叠加层,用户已有的 profile 文件不受影响)
47
+
48
+ `jupyter_server_config.py` 里把 ACP 的启动命令改成:
49
+
50
+ ```python
51
+ 'command': ['node', '/usr/local/bin/acp-to-ws', '--port', '{port}', '--',
52
+ 'bash', '/opt/dsh-plugins/acp-session/start-acp.sh'],
53
+ ```
54
+
55
+ ## 本地验证
56
+
57
+ 在容器里(不需要平台后端):
58
+
59
+ ```bash
60
+ # 1. 确认插件被 dsh 认出来
61
+ dsh --profile acp --patch /opt/dsh-plugins/acp-session/profile-patch.yml --dump-config \
62
+ | grep -A3 acp-session
63
+
64
+ # 2. 用 ACP 探针跑一遍
65
+ node tools/acp-probe2.js # 依次 initialize / session/new / prompt /
66
+ # session/list / session/load / session/delete
67
+ ```
68
+
69
+ ## 已知边界
70
+
71
+ - `session/delete` 依赖 `resolveCurrentLog` 给出的路径,删除的是**整个会话目录**
72
+ (一个目录里只有该会话的日志)。
73
+ - 回放只覆盖 user/assistant 文本、推理和工具调用的状态;工具结果目前客户端也不渲染。
74
+ - `session/list` 为了拿标题会逐个读会话日志(dsh 的持久化层有缓存),
75
+ 会话数量到几百时会有可感知的延迟;真到那一步再改成只读头部。
76
+ - 上游 `session/resume` 对"已激活"的会话会报错,本插件按成功处理——这正是
77
+ `load` 想要的状态,但意味着调用方无法区分"刚恢复"和"本来就在跑"。
package/index.js ADDED
@@ -0,0 +1,526 @@
1
+ /**
2
+ * ACP 会话管理插件(DeepSeek Harness)
3
+ * ====================================
4
+ *
5
+ * 为什么需要它
6
+ * ------------
7
+ * dsh 自带的 ACP 插件(@deepseek-ai/dsh-acp)实现了:
8
+ * session/new, session/list, session/resume, session/prompt,
9
+ * session/cancel, session/close, session/set_config_option
10
+ *
11
+ * 但 ACP 1.4 里还有两件事它没做,另外 session/list 少填两个字段:
12
+ * - session/load :回放某个历史会话(ACP 用它做"恢复会话")
13
+ * - session/delete :删除会话
14
+ * - session/list :返回的 SessionInfo 只填了 sessionId 和 cwd,
15
+ * 没填 title / updatedAt(ACP 为此定义了字段)
16
+ *
17
+ * 于是平台之前只能在 backend 里加一个"外置会话管理器":用 Python 去解
18
+ * dsh 的 zstd 日志,再开三个 REST 接口给前端。那个方案能用,但把 dsh 的
19
+ * 私有存储格式复制到了 dsh 外面。
20
+ *
21
+ * 这个插件怎么工作
22
+ * ----------------
23
+ * 它不修改 dsh 的源码,而是"套"在 dsh-acp 外面做一个消息中继:
24
+ *
25
+ * 浏览器 ──stdio──▶ [本插件] ──进程内 stream──▶ dsh-acp(原样运行)
26
+ *
27
+ * 绝大多数 JSON-RPC 消息原样双向转发;只有三条被截获:
28
+ * - session/list :转发后,用 dsh 的 sessionPersistence 补上 title/updatedAt
29
+ * - session/load :先发 session/resume 让 dsh 恢复上下文,再把历史按
30
+ * session/update 通知回放给客户端,最后回一个空结果
31
+ * - session/delete:先 session/close(若在跑),再删掉会话目录
32
+ *
33
+ * 这样客户端(net-agent)看到的就是完整的标准 ACP,平台后端那套会话接口
34
+ * 可以整个删掉。
35
+ *
36
+ * 依赖的是 dsh 自己的服务:ctx.sessionPersistence(list / resolveCurrentLog /
37
+ * readStoredLog),所以不需要自己解压 zstd,也不会跟存储格式的演进脱节。
38
+ */
39
+
40
+ import { Readable, Writable } from 'node:stream';
41
+ import { ndJsonStream } from '@agentclientprotocol/sdk';
42
+ import * as acpPlugin from '@deepseek-ai/dsh-acp';
43
+ import { describeSession, readSessionEvents, removeSession } from './lib/store.js';
44
+ import { replayUpdates } from './lib/replay.js';
45
+
46
+ /** Cordis 插件名,profile 的 patch 里用它做 id。 */
47
+ export const name = 'acp-session';
48
+
49
+ /** 需要的 dsh 核心服务;acpAppStartup 保证 --help 时不会抢占 stdio。 */
50
+ export const inject = ['agents', 'llm', 'sessionPersistence', 'sessions', 'acpAppStartup'];
51
+
52
+ const JSON_RPC_INTERNAL_ERROR = -32603;
53
+
54
+ function describeError(error) {
55
+ if (error instanceof Error) return error.message;
56
+ if (error && typeof error === 'object' && typeof error.message === 'string') return error.message;
57
+ return String(error);
58
+ }
59
+
60
+ /** 给内部请求加超时,避免上游卡住时调用方无限等待。 */
61
+ function withTimeout(promise, ms, label) {
62
+ return new Promise((resolve, reject) => {
63
+ const timer = setTimeout(() => reject(new Error(`${label} 超时(${ms}ms)`)), ms);
64
+ promise.then(
65
+ (value) => {
66
+ clearTimeout(timer);
67
+ resolve(value);
68
+ },
69
+ (error) => {
70
+ clearTimeout(timer);
71
+ reject(error);
72
+ },
73
+ );
74
+ });
75
+ }
76
+
77
+ /**
78
+ * 挂载插件。
79
+ *
80
+ * @param ctx - Cordis 上下文
81
+ * @param config - provider/model(透传给 dsh-acp),以及可选的 stream(测试用)
82
+ */
83
+ export function apply(ctx, config = {}) {
84
+ const persistence = ctx.sessionPersistence;
85
+ const logger = ctx.logger;
86
+
87
+ // 对客户端的那一端:生产环境是 stdio,测试时可以注入。
88
+ const clientStream = config.stream ?? ndJsonStream(
89
+ Writable.toWeb(process.stdout),
90
+ Readable.toWeb(process.stdin),
91
+ );
92
+
93
+ // 进程内双工通道:本插件 ↔ 上游 dsh-acp。
94
+ const toAgent = new TransformStream();
95
+ const toBridge = new TransformStream();
96
+ const agentSide = { readable: toBridge.readable, writable: toAgent.writable };
97
+ const bridgeSide = { readable: toAgent.readable, writable: toBridge.writable };
98
+
99
+ // 上游 dsh-acp 原样挂载,只是把 transport 换成我们的进程内通道。
100
+ ctx.plugin(acpPlugin, {
101
+ provider: config.provider,
102
+ model: config.model,
103
+ stream: agentSide,
104
+ });
105
+
106
+ const clientReader = clientStream.readable.getReader();
107
+ const clientWriter = clientStream.writable.getWriter();
108
+ const bridgeReader = bridgeSide.readable.getReader();
109
+ const bridgeWriter = bridgeSide.writable.getWriter();
110
+
111
+ /** 客户端发来的 session/list 请求 id -> 该请求的 cwd(收到响应时补字段并过滤)。 */
112
+ const listRequestIds = new Map();
113
+ /** 客户端发来的 initialize 请求 id,收到响应时要补能力声明。 */
114
+ const initializeRequestIds = new Set();
115
+ /** 客户端发来的 session/new 请求 id -> cwd,用来记住新会话属于哪个工作目录。 */
116
+ const newRequestCwd = new Map();
117
+ /**
118
+ * 本连接里创建/恢复过的会话。
119
+ *
120
+ * dsh 的 session/list 会把**所有还活着的会话**排除掉(不只是当前那个),
121
+ * 所以点"新建会话"之后,上一条会从列表里消失。这里记下来,统一补回列表。
122
+ */
123
+ const liveSessions = new Map(); // sessionId -> { cwd, hasContent }
124
+ /** 最近一次创建/恢复的会话 id,用于跟踪输入内容与标题。 */
125
+ let activeSessionId = null;
126
+ /** 会话摘要缓存:活动会话被上游排除时,用它把标题/时间补回来。 */
127
+ const summaryCache = new Map();
128
+ /** 最近一次见到的 configOptions(模型、推理强度),load 时作为兜底。 */
129
+ let lastConfigOptions = null;
130
+ /** 我们发给上游、还在等响应的请求。 */
131
+ const pendingUpstream = new Map();
132
+ let upstreamSeq = 0;
133
+ let disposed = false;
134
+
135
+ async function writeClient(message) {
136
+ if (disposed) return;
137
+ try {
138
+ await clientWriter.write(message);
139
+ } catch (error) {
140
+ logger.warn(`acp-session: 写给客户端失败: ${describeError(error)}`);
141
+ }
142
+ }
143
+
144
+ async function writeAgent(message) {
145
+ try {
146
+ await bridgeWriter.write(message);
147
+ } catch (error) {
148
+ logger.warn(`acp-session: 写给上游失败: ${describeError(error)}`);
149
+ }
150
+ }
151
+
152
+ /** 向上游发一个请求并等它的响应;错误信息原样抛出。 */
153
+ function requestUpstream(method, params) {
154
+ const id = `cnic-${++upstreamSeq}`;
155
+ return new Promise((resolve, reject) => {
156
+ pendingUpstream.set(id, { resolve, reject });
157
+ writeAgent({ jsonrpc: '2.0', id, method, params });
158
+ });
159
+ }
160
+
161
+ async function answerError(id, error) {
162
+ await writeClient({
163
+ jsonrpc: '2.0',
164
+ id,
165
+ error: { code: JSON_RPC_INTERNAL_ERROR, message: describeError(error) },
166
+ });
167
+ }
168
+
169
+ /** session/delete:先尽量关掉会话,再删目录。 */
170
+ async function handleDelete(message) {
171
+ const sessionId = message?.params?.sessionId;
172
+ if (!sessionId) {
173
+ await answerError(message.id, new Error('缺少 sessionId'));
174
+ return;
175
+ }
176
+
177
+ // 会话没在跑时 close 会报 unknown session,忽略即可;超时也不阻塞删除。
178
+ await withTimeout(requestUpstream('session/close', { sessionId }), 5_000, 'session/close')
179
+ .catch(() => {});
180
+
181
+ let removed = false;
182
+ try {
183
+ removed = await removeSession(persistence, sessionId);
184
+ } catch (error) {
185
+ await answerError(message.id, error);
186
+ return;
187
+ }
188
+
189
+ logger.info(`acp-session: 删除会话 ${sessionId} -> ${removed ? '已删除' : '不存在'}`);
190
+ liveSessions.delete(sessionId);
191
+ if (activeSessionId === sessionId) activeSessionId = null;
192
+ summaryCache.delete(sessionId);
193
+ await writeClient({ jsonrpc: '2.0', id: message.id, result: {} });
194
+ }
195
+
196
+ /** session/load:resume 上游 + 回放历史。 */
197
+ async function handleLoad(message) {
198
+ const params = message?.params ?? {};
199
+ const sessionId = params.sessionId;
200
+ if (!sessionId) {
201
+ await answerError(message.id, new Error('缺少 sessionId'));
202
+ return;
203
+ }
204
+
205
+ try {
206
+ // 恢复的是历史会话,必然有内容
207
+ liveSessions.set(sessionId, { cwd: params.cwd ?? null, hasContent: true });
208
+ activeSessionId = sessionId;
209
+
210
+ let resumeResult = null;
211
+ try {
212
+ resumeResult = await requestUpstream('session/resume', {
213
+ sessionId,
214
+ cwd: params.cwd,
215
+ mcpServers: params.mcpServers ?? [],
216
+ });
217
+ } catch (error) {
218
+ // 会话已经在当前 dsh 进程里加载着时,resume 会被拒绝
219
+ // (Invalid params: session is already active)。
220
+ // 那正是我们想要的状态,继续回放历史即可。
221
+ if (!/already active/i.test(describeError(error))) throw error;
222
+ }
223
+
224
+ // 读不到日志(例如会话正被当前进程持有)时没有可回放的历史
225
+ const events = (await readSessionEvents(persistence, sessionId)) ?? [];
226
+ for (const update of replayUpdates(events)) {
227
+ await writeClient({
228
+ jsonrpc: '2.0',
229
+ method: 'session/update',
230
+ params: { sessionId, update },
231
+ });
232
+ }
233
+
234
+ // resume 的响应里带着当前的 configOptions(模型、推理强度),
235
+ // 一并回给客户端,避免恢复会话后模型选择器被清空。
236
+ // resume 没带回配置时(例如会话已在当前进程里激活、resume 被拒),
237
+ // 用最近一次见到的 configOptions 兜底,避免恢复会话后模型选择器变空。
238
+ const configOptions = resumeResult?.configOptions ?? lastConfigOptions;
239
+ await writeClient({
240
+ jsonrpc: '2.0',
241
+ id: message.id,
242
+ result: Array.isArray(configOptions) && configOptions.length > 0 ? { configOptions } : {},
243
+ });
244
+ } catch (error) {
245
+ logger.warn(`acp-session: 恢复会话 ${sessionId} 失败: ${describeError(error)}`);
246
+ await answerError(message.id, error);
247
+ }
248
+ }
249
+
250
+ /**
251
+ * session/list:转发后补 title / updatedAt,并按 cwd 过滤、去掉空会话。
252
+ *
253
+ * 上游的 list 会把所有工作目录的会话都返回,也不区分"打开页面就自动创建的空会话",
254
+ * 直接透传给客户端会让侧边栏混进别的应用、以及一堆没有内容的条目。
255
+ */
256
+ async function enrichListResponse(message, requestCwd) {
257
+ const sessions = message?.result?.sessions;
258
+ if (!Array.isArray(sessions)) return message;
259
+
260
+ const enriched = [];
261
+ for (const session of sessions) {
262
+ try {
263
+ const meta = await describeSession(persistence, session.sessionId);
264
+
265
+ // 空会话(只有会话头)不在列表里显示。
266
+ // messageCount 为 null 表示读不到日志(会话正被当前进程持有),
267
+ // 那是"未知"而不是"空",必须保留——否则正在聊的那条会从列表里消失。
268
+ if (meta.messageCount === 0) continue;
269
+
270
+ // 客户端指定了 cwd 时,只返回该工作目录下的会话
271
+ const cwd = meta.cwd || session.cwd;
272
+ if (requestCwd && cwd !== requestCwd) continue;
273
+
274
+ const item = {
275
+ ...session,
276
+ cwd: cwd || session.cwd,
277
+ title: meta.title || session.title || null,
278
+ updatedAt: meta.updatedAt ?? session.updatedAt ?? null,
279
+ };
280
+ enriched.push(item);
281
+ summaryCache.set(item.sessionId, {
282
+ cwd: item.cwd,
283
+ title: item.title,
284
+ updatedAt: item.updatedAt,
285
+ });
286
+ } catch (error) {
287
+ logger.warn(`acp-session: 读取会话摘要失败 ${session?.sessionId}: ${describeError(error)}`);
288
+ enriched.push(session);
289
+ }
290
+ }
291
+
292
+ // dsh 的 session/list 会排除所有还活着的会话(不只是当前那个):
293
+ // 刚聊完的那条、以及点"新建会话"之前的那些,都需要补回来。
294
+ for (const [sessionId, live] of liveSessions) {
295
+ if (!live.hasContent) continue; // 空会话不进列表
296
+ if (enriched.some((item) => item.sessionId === sessionId)) continue;
297
+ if (requestCwd && live.cwd && requestCwd !== live.cwd) continue;
298
+
299
+ let cached = summaryCache.get(sessionId) ?? {};
300
+ if (!cached.title) {
301
+ try {
302
+ const meta = await describeSession(persistence, sessionId);
303
+ cached = { ...cached, ...meta };
304
+ } catch (error) {
305
+ logger.warn(`acp-session: 读取活动会话摘要失败 ${sessionId}: ${describeError(error)}`);
306
+ }
307
+ }
308
+
309
+ enriched.unshift({
310
+ sessionId,
311
+ cwd: live.cwd ?? cached.cwd ?? requestCwd ?? '',
312
+ title: cached.title ?? null,
313
+ updatedAt: cached.updatedAt ?? null,
314
+ });
315
+ }
316
+
317
+ return { ...message, result: { ...message.result, sessions: enriched } };
318
+ }
319
+
320
+ /**
321
+ * initialize:把本插件补齐的能力声明出来。
322
+ *
323
+ * 上游只说支持 list / resume / close,客户端据此就不会调用
324
+ * session/load 与 session/delete。ACP 里 load 的能力位是顶层
325
+ * `loadSession: boolean`,delete 在 `sessionCapabilities.delete`。
326
+ */
327
+ function enrichInitializeResponse(message) {
328
+ const result = message?.result;
329
+ if (!result || typeof result !== 'object') return message;
330
+
331
+ const capabilities = result.agentCapabilities ?? {};
332
+ const sessionCapabilities = capabilities.sessionCapabilities ?? {};
333
+
334
+ return {
335
+ ...message,
336
+ result: {
337
+ ...result,
338
+ agentCapabilities: {
339
+ ...capabilities,
340
+ loadSession: true,
341
+ sessionCapabilities: {
342
+ ...sessionCapabilities,
343
+ delete: sessionCapabilities.delete ?? {},
344
+ },
345
+ },
346
+ },
347
+ };
348
+ }
349
+
350
+ // 客户端 → 上游
351
+ const clientPump = (async () => {
352
+ while (!disposed) {
353
+ let chunk;
354
+ try {
355
+ chunk = await clientReader.read();
356
+ } catch {
357
+ break;
358
+ }
359
+ if (chunk.done) break;
360
+
361
+ const message = chunk.value;
362
+ const isRequest = message && message.id !== undefined && typeof message.method === 'string';
363
+
364
+ try {
365
+ if (isRequest && message.method === 'session/delete') {
366
+ await handleDelete(message);
367
+ continue;
368
+ }
369
+ if (isRequest && message.method === 'session/load') {
370
+ await handleLoad(message);
371
+ continue;
372
+ }
373
+ if (isRequest && message.method === 'session/list') {
374
+ listRequestIds.set(message.id, message.params?.cwd ?? null);
375
+ }
376
+ if (isRequest && message.method === 'session/new') {
377
+ newRequestCwd.set(message.id, message.params?.cwd ?? null);
378
+ }
379
+ if (
380
+ isRequest &&
381
+ message.method === 'session/prompt' &&
382
+ liveSessions.has(message.params?.sessionId)
383
+ ) {
384
+ const sessionId = message.params.sessionId;
385
+ const live = liveSessions.get(sessionId);
386
+ live.hasContent = true;
387
+ // 读不到活动会话的日志,就把用户这次输入当作列表标题
388
+ if (!summaryCache.get(sessionId)?.title) {
389
+ const block = (message.params?.prompt ?? []).find((item) => item?.type === 'text');
390
+ const text = typeof block?.text === 'string' ? block.text.trim() : '';
391
+ if (text) {
392
+ summaryCache.set(sessionId, {
393
+ cwd: live.cwd,
394
+ title: text.slice(0, 20),
395
+ updatedAt: new Date().toISOString(),
396
+ });
397
+ }
398
+ }
399
+ }
400
+ if (
401
+ isRequest &&
402
+ message.method === 'session/close'
403
+ ) {
404
+ const closedId = message.params?.sessionId;
405
+ liveSessions.delete(closedId);
406
+ if (activeSessionId === closedId) activeSessionId = null;
407
+ }
408
+ if (isRequest && message.method === 'initialize') {
409
+ initializeRequestIds.add(message.id);
410
+ }
411
+ await writeAgent(message);
412
+ } catch (error) {
413
+ if (isRequest) await answerError(message.id, error);
414
+ }
415
+ }
416
+ })();
417
+
418
+ // 上游 → 客户端
419
+ const agentPump = (async () => {
420
+ while (!disposed) {
421
+ let chunk;
422
+ try {
423
+ chunk = await bridgeReader.read();
424
+ } catch {
425
+ break;
426
+ }
427
+ if (chunk.done) break;
428
+
429
+ const message = chunk.value;
430
+
431
+ if (Array.isArray(message?.result?.configOptions) && message.result.configOptions.length > 0) {
432
+ lastConfigOptions = message.result.configOptions;
433
+ }
434
+
435
+ // session/new 的响应:记住当前会话(列表要把它补回来)
436
+ if (message && message.id !== undefined && newRequestCwd.has(message.id)) {
437
+ const cwd = newRequestCwd.get(message.id);
438
+ newRequestCwd.delete(message.id);
439
+ const sessionId = message.result?.sessionId;
440
+ if (sessionId) {
441
+ liveSessions.set(sessionId, { cwd, hasContent: false });
442
+ activeSessionId = sessionId;
443
+ }
444
+ await writeClient(message);
445
+ continue;
446
+ }
447
+
448
+ // 活动会话每收到一次更新就刷新它的时间
449
+ if (
450
+ message &&
451
+ message.method === 'session/update' &&
452
+ liveSessions.has(message.params?.sessionId)
453
+ ) {
454
+ const sessionId = message.params.sessionId;
455
+ const live = liveSessions.get(sessionId);
456
+ const cached = summaryCache.get(sessionId);
457
+ const update = message.params?.update ?? {};
458
+ if (
459
+ update.sessionUpdate === 'user_message_chunk' ||
460
+ update.sessionUpdate === 'agent_message_chunk' ||
461
+ update.sessionUpdate === 'agent_thought_chunk' ||
462
+ update.sessionUpdate === 'tool_call'
463
+ ) {
464
+ live.hasContent = true;
465
+ }
466
+ // 读不到活动会话的日志时,用第一条用户消息当标题
467
+ if (
468
+ update.sessionUpdate === 'user_message_chunk' &&
469
+ !summaryCache.get(sessionId)?.title &&
470
+ typeof update.content?.text === 'string'
471
+ ) {
472
+ summaryCache.set(sessionId, {
473
+ cwd: live.cwd,
474
+ title: update.content.text.slice(0, 20),
475
+ updatedAt: new Date().toISOString(),
476
+ });
477
+ } else if (cached) {
478
+ cached.updatedAt = new Date().toISOString();
479
+ }
480
+ }
481
+
482
+ // 我们自己发的请求(session/resume、session/close)的响应
483
+ if (message && message.id !== undefined && pendingUpstream.has(message.id)) {
484
+ const pending = pendingUpstream.get(message.id);
485
+ pendingUpstream.delete(message.id);
486
+ if (message.error) {
487
+ const error = new Error(message.error.message ?? '上游返回错误');
488
+ error.code = message.error.code;
489
+ pending.reject(error);
490
+ } else {
491
+ pending.resolve(message.result);
492
+ }
493
+ continue;
494
+ }
495
+
496
+ // 客户端的 session/list 响应,补完再转回去
497
+ if (message && message.id !== undefined && listRequestIds.has(message.id)) {
498
+ const requestCwd = listRequestIds.get(message.id);
499
+ listRequestIds.delete(message.id);
500
+ await writeClient(await enrichListResponse(message, requestCwd));
501
+ continue;
502
+ }
503
+
504
+ // 客户端的 initialize 响应,补能力声明
505
+ if (message && message.id !== undefined && initializeRequestIds.has(message.id)) {
506
+ initializeRequestIds.delete(message.id);
507
+ await writeClient(enrichInitializeResponse(message));
508
+ continue;
509
+ }
510
+
511
+ await writeClient(message);
512
+ }
513
+ })();
514
+
515
+ ctx.effect(() => () => {
516
+ disposed = true;
517
+ for (const pending of pendingUpstream.values()) {
518
+ pending.reject(new Error('acp-session 插件已卸载'));
519
+ }
520
+ pendingUpstream.clear();
521
+ clientReader.cancel().catch(() => {});
522
+ bridgeReader.cancel().catch(() => {});
523
+ clientPump.catch(() => {});
524
+ agentPump.catch(() => {});
525
+ }, 'acp-session.relay');
526
+ }
package/lib/replay.js ADDED
@@ -0,0 +1,94 @@
1
+ /**
2
+ * 把 dsh 的会话事件翻译成 ACP 的 session/update 通知。
3
+ *
4
+ * 这对应 ACP 里 session/load 的语义:"把历史按标准更新回放给客户端"。
5
+ * 形状与 net-agent 的 AcpClientBridge.handleSessionUpdate 对得上:
6
+ * user_message_chunk / agent_message_chunk / agent_thought_chunk /
7
+ * tool_call / tool_call_update。
8
+ */
9
+
10
+ import { reasoningOf, textOf, isRealUserMessage } from './store.js';
11
+
12
+ function parseMaybeJson(value) {
13
+ if (typeof value !== 'string') return value;
14
+ try {
15
+ return JSON.parse(value);
16
+ } catch {
17
+ return value;
18
+ }
19
+ }
20
+
21
+ /**
22
+ * @param events - dsh 的 SessionEvent 数组
23
+ * @returns ACP SessionUpdate 数组(按原顺序)
24
+ */
25
+ export function replayUpdates(events) {
26
+ const updates = [];
27
+
28
+ for (const event of events) {
29
+ const data = event?.data ?? {};
30
+
31
+ switch (event?.type) {
32
+ case 'user/message': {
33
+ if (!isRealUserMessage(event)) break;
34
+ const text = textOf(data.content);
35
+ if (text) {
36
+ updates.push({
37
+ sessionUpdate: 'user_message_chunk',
38
+ content: { type: 'text', text },
39
+ });
40
+ }
41
+ break;
42
+ }
43
+
44
+ case 'assistant/message': {
45
+ const content = data?.message?.content;
46
+ const reasoning = reasoningOf(content);
47
+ if (reasoning) {
48
+ updates.push({
49
+ sessionUpdate: 'agent_thought_chunk',
50
+ content: { type: 'text', text: reasoning },
51
+ });
52
+ }
53
+ const text = textOf(content);
54
+ if (text) {
55
+ updates.push({
56
+ sessionUpdate: 'agent_message_chunk',
57
+ content: { type: 'text', text },
58
+ });
59
+ }
60
+ break;
61
+ }
62
+
63
+ case 'tool/call': {
64
+ if (!data.callId) break;
65
+ updates.push({
66
+ sessionUpdate: 'tool_call',
67
+ toolCallId: String(data.callId),
68
+ title: typeof data.name === 'string' && data.name ? data.name : 'tool',
69
+ kind: 'other',
70
+ status: 'completed',
71
+ rawInput: parseMaybeJson(data.arguments),
72
+ });
73
+ break;
74
+ }
75
+
76
+ case 'tool/result': {
77
+ const message = data?.message ?? {};
78
+ const callId = message.toolCallId ?? message?.source?.callId;
79
+ if (!callId) break;
80
+ updates.push({
81
+ sessionUpdate: 'tool_call_update',
82
+ toolCallId: String(callId),
83
+ status: message.isError ? 'failed' : 'completed',
84
+ });
85
+ break;
86
+ }
87
+
88
+ default:
89
+ break;
90
+ }
91
+ }
92
+
93
+ return updates;
94
+ }
package/lib/store.js ADDED
@@ -0,0 +1,156 @@
1
+ /**
2
+ * 通过 dsh 自己的 sessionPersistence 服务读取会话。
3
+ *
4
+ * 不直接解压 session.v4.jsonl.zstd:持久化后端已经做了分帧解压和格式校验,
5
+ * 用它既少一份解析代码,也不会跟着 dsh 的内部格式变动而失效。
6
+ */
7
+
8
+ import { rm } from 'node:fs/promises';
9
+ import { dirname } from 'node:path';
10
+
11
+ /** 取 content 数组里的文本片段。 */
12
+ export function textOf(content) {
13
+ if (!Array.isArray(content)) return '';
14
+ let text = '';
15
+ for (const item of content) {
16
+ if (item && item.type === 'text' && typeof item.text === 'string') text += item.text;
17
+ }
18
+ return text;
19
+ }
20
+
21
+ /** 取 content 数组里的推理片段。 */
22
+ export function reasoningOf(content) {
23
+ if (!Array.isArray(content)) return '';
24
+ let text = '';
25
+ for (const item of content) {
26
+ if (item && item.type === 'reasoning' && typeof item.text === 'string') text += item.text;
27
+ }
28
+ return text;
29
+ }
30
+
31
+ /**
32
+ * 是否是真人输入的消息。
33
+ *
34
+ * dsh 会把运行时注入的上下文也写成 user/message,只有 source.kind === 'user'
35
+ * 才是用户在聊天框里真的说的话。
36
+ */
37
+ export function isRealUserMessage(event) {
38
+ return event?.data?.source?.kind === 'user';
39
+ }
40
+
41
+ function firstUserText(events, limit) {
42
+ for (const event of events) {
43
+ if (event?.type !== 'user/message' || !isRealUserMessage(event)) continue;
44
+ const text = textOf(event?.data?.content).trim();
45
+ if (text) return text.slice(0, limit);
46
+ }
47
+ return '';
48
+ }
49
+
50
+ /**
51
+ * 从会话头的元数据和事件流里提炼列表页需要的字段。
52
+ *
53
+ * @param header - sessionPersistence.list()/stat() 给出的 header(可缺省)
54
+ * @param events - sessionPersistence.readStoredLog() 解出的事件
55
+ * @returns {{id: string|undefined, cwd: string, title: string, updatedAt: string|null, messageCount: number}}
56
+ */
57
+ export function summarize(header, events) {
58
+ let cwd = header?.cwd ?? '';
59
+ let title = '';
60
+ let updatedAt = typeof header?.createdAt === 'number' ? header.createdAt : null;
61
+ let messageCount = 0;
62
+
63
+ for (const event of events) {
64
+ const time = event?.time;
65
+ if (typeof time === 'number' && (updatedAt === null || time > updatedAt)) updatedAt = time;
66
+
67
+ switch (event?.type) {
68
+ case 'session':
69
+ if (!cwd && typeof event?.data?.cwd === 'string') cwd = event.data.cwd;
70
+ if (updatedAt === null && typeof event?.createdAt === 'number') updatedAt = event.createdAt;
71
+ break;
72
+ case 'session/title':
73
+ if (typeof event?.data?.title === 'string') title = event.data.title;
74
+ break;
75
+ case 'user/message':
76
+ if (isRealUserMessage(event)) messageCount += 1;
77
+ break;
78
+ case 'assistant/message':
79
+ messageCount += 1;
80
+ break;
81
+ default:
82
+ break;
83
+ }
84
+ }
85
+
86
+ if (!title) title = firstUserText(events, 20);
87
+
88
+ return {
89
+ id: header?.id,
90
+ cwd,
91
+ title,
92
+ updatedAt: updatedAt === null ? null : new Date(updatedAt).toISOString(),
93
+ messageCount,
94
+ };
95
+ }
96
+
97
+ /**
98
+ * 读出某个会话的全部事件。
99
+ *
100
+ * @returns 事件数组;**读不到日志时返回 null**(不是空数组)。
101
+ *
102
+ * 读不到有两种情况,必须和"确实是空会话"区分开:
103
+ * - 会话不存在
104
+ * - 会话正被当前 dsh 进程持有(正在聊天),此时 resolveCurrentLog 拿不到路径
105
+ * 后者如果当成空会话,正在用的那条就会从列表里消失。
106
+ */
107
+ export async function readSessionEvents(persistence, sessionId) {
108
+ const path = await persistence.resolveCurrentLog(sessionId);
109
+ if (!path) return null;
110
+ const stored = await persistence.readStoredLog(path, sessionId);
111
+ return Array.isArray(stored?.events) ? stored.events : null;
112
+ }
113
+
114
+ /**
115
+ * 提炼单个会话的摘要(标题、更新时间、消息数)。
116
+ *
117
+ * @returns messageCount 为 null 表示"读不到日志、内容未知"——调用方不应据此过滤。
118
+ */
119
+ export async function describeSession(persistence, sessionId) {
120
+ const events = await readSessionEvents(persistence, sessionId);
121
+ if (events !== null) return summarize({ id: sessionId }, events);
122
+
123
+ // 读不到日志时用元数据兜底:至少把 cwd 和创建时间填上
124
+ let header = { id: sessionId };
125
+ try {
126
+ const snapshot = await persistence.stat(sessionId);
127
+ if (snapshot?.header) header = snapshot.header;
128
+ } catch {
129
+ // 元数据也拿不到就保持最小信息
130
+ }
131
+
132
+ return {
133
+ id: sessionId,
134
+ cwd: header.cwd ?? '',
135
+ title: '',
136
+ updatedAt: typeof header.createdAt === 'number'
137
+ ? new Date(header.createdAt).toISOString()
138
+ : null,
139
+ messageCount: null,
140
+ };
141
+ }
142
+
143
+ /**
144
+ * 删除一个会话。
145
+ *
146
+ * dsh 的 SessionPersistence 没有 delete,但提供了 resolveCurrentLog 找到
147
+ * 当前产物路径;一个会话目录里只有它的日志,整目录删掉即可。
148
+ *
149
+ * @returns 是否真的删掉了(会话不存在时返回 false)
150
+ */
151
+ export async function removeSession(persistence, sessionId) {
152
+ const path = await persistence.resolveCurrentLog(sessionId);
153
+ if (!path) return false;
154
+ await rm(dirname(path), { recursive: true, force: true });
155
+ return true;
156
+ }
package/package.json CHANGED
@@ -1,6 +1,39 @@
1
1
  {
2
2
  "name": "@wangchenglai/dsh-acp-session",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "为 DeepSeek Harness 的 ACP 补上 session/load、session/delete,并让 session/list 带标题与更新时间",
5
+ "keywords": [
6
+ "acp",
7
+ "deepseek",
8
+ "dsh",
9
+ "agent"
10
+ ],
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "https://gitservice.cstcloud.cn/jfli/agent-backend.git",
14
+ "directory": "jupyter/dsh-plugins/acp-session"
15
+ },
16
+ "type": "module",
17
+ "main": "index.js",
18
+ "exports": {
19
+ ".": "./index.js"
20
+ },
21
+ "files": [
22
+ "index.js",
23
+ "lib",
24
+ "profile-patch.yml",
25
+ "start-acp.sh",
26
+ "README.md"
27
+ ],
28
+ "engines": {
29
+ "node": ">=20"
30
+ },
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "license": "MIT",
35
+ "dependencies": {
36
+ "@agentclientprotocol/sdk": "1.4.0",
37
+ "@deepseek-ai/dsh-acp": "0.2.0-rc.2"
38
+ }
39
+ }
@@ -0,0 +1,15 @@
1
+ # 叠加到 acp profile 上的一层 patch(用 dsh --profile acp --patch <本文件> 加载)。
2
+ #
3
+ # 1) 关掉 dsh 自带的 ACP 插件——stdio 只能由一个插件接管;
4
+ # 2) 挂载本插件,config 里的 provider/model 与原来的 acp 行保持一致。
5
+
6
+ - id: acp
7
+ disabled: true
8
+
9
+ - insert:
10
+ - id: acp-session
11
+ name: '@wangchenglai/dsh-acp-session'
12
+ inject: [acpAppStartup]
13
+ config:
14
+ provider: deepseek-official
15
+ model: deepseek-v4-flash
package/start-acp.sh ADDED
@@ -0,0 +1,24 @@
1
+ #!/bin/bash
2
+ # 启动 ACP 服务:先确保 profile 能按包名解析到本插件,再叠加 patch 启动 dsh。
3
+ #
4
+ # 为什么不直接改用户的 cordis.patch.yml:
5
+ # 那个文件在 ~/.dsh/profiles/acp/ 下,是每个用户首次运行时生成的(NFS 持久化),
6
+ # 改镜像里的模板不会影响已有用户。用 --patch 叠加,既不动用户文件,也永远是最新的。
7
+ set -euo pipefail
8
+
9
+ DSH_HOME="${DSH_HOME:-$HOME/.dsh}"
10
+ PROFILE_DIR="$DSH_HOME/profiles/acp"
11
+ PLUGIN_SRC="${DSH_ACP_SESSION_PLUGIN:-/opt/dsh-plugins/acp-session}"
12
+ PROFILE_PATCH="${DSH_ACP_SESSION_PATCH:-$PLUGIN_SRC/profile-patch.yml}"
13
+
14
+ mkdir -p "$PROFILE_DIR/node_modules/@wangchenglai"
15
+ ln -sfn "$PLUGIN_SRC" "$PROFILE_DIR/node_modules/@wangchenglai/dsh-acp-session"
16
+
17
+ # dsh 自身安装在全局 node_modules 下,再链一份作为兜底(不同版本的解析根可能不同)。
18
+ GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"
19
+ if [ -n "$GLOBAL_ROOT" ] && [ -d "$GLOBAL_ROOT" ] && [ -w "$GLOBAL_ROOT" ]; then
20
+ mkdir -p "$GLOBAL_ROOT/@wangchenglai"
21
+ ln -sfn "$PLUGIN_SRC" "$GLOBAL_ROOT/@wangchenglai/dsh-acp-session"
22
+ fi
23
+
24
+ exec dsh --profile acp --patch "$PROFILE_PATCH"