@wangchenglai/dsh-acp-session 0.1.0 → 0.1.1

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/index.js CHANGED
@@ -1,526 +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
- }
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
+ }