@xmanrui/dsh-im 0.6.0 → 0.7.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.
@@ -108,7 +108,7 @@ export class DiscordApi {
108
108
  headers: {
109
109
  authorization: `Bot ${this.#token}`,
110
110
  'content-type': 'application/json',
111
- 'user-agent': 'DeepSeek-Harness-dsh-im (https://github.com/xmanrui/dsh-im, 0.6.0)',
111
+ 'user-agent': 'DeepSeek-Harness-dsh-im (https://github.com/xmanrui/dsh-im, 0.7.0)',
112
112
  },
113
113
  ...(body === undefined ? {} : { body: JSON.stringify(body) }),
114
114
  signal: requestSignal(signal, timeoutMs),
@@ -15,6 +15,8 @@ const HELP_TEXT = [
15
15
  '/new 开启一个全新会话',
16
16
  '/workspace 工作区绝对路径 切换工作区',
17
17
  '/workspacelist 列出工作区绝对路径',
18
+ '/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
19
+ '/session Session ID 将当前聊天绑定到指定会话',
18
20
  '/status 检查连接状态',
19
21
  '/help 显示本帮助',
20
22
  ].join('\n');
@@ -110,7 +112,7 @@ export class FeishuHarnessBridge {
110
112
  await this.#send(event.message.chat_id, '飞书机器人与 DeepSeek Harness 连接正常。');
111
113
  return;
112
114
  }
113
- const workspaceCommand = await runWorkspaceCommand(text, this.#harness);
115
+ const workspaceCommand = await runWorkspaceCommand(text, this.#harness, key);
114
116
  if (workspaceCommand) {
115
117
  for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
116
118
  await this.#send(event.message.chat_id, reply);
@@ -2,6 +2,8 @@ import { spawn } from 'node:child_process';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { isAbsolute } from 'node:path';
4
4
 
5
+ import { adoptRegisteredWorkspaceSession } from '../shared/harness-session-binding.mjs';
6
+
5
7
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
6
8
 
7
9
  function workspacePaths(value) {
@@ -11,6 +13,47 @@ function workspacePaths(value) {
11
13
  ));
12
14
  }
13
15
 
16
+ function workspaceFromList(workspacePath, workspaceList) {
17
+ if (!Array.isArray(workspaceList?.items)
18
+ || !Array.isArray(workspaceList?.archivedSessionIds)) {
19
+ throw new Error('Harness returned an invalid response for workspace.list');
20
+ }
21
+
22
+ const workspace = workspaceList.items.find((item) => item?.path === workspacePath);
23
+ if (!workspace) return null;
24
+ if (!Array.isArray(workspace.sessionIds)
25
+ || workspace.sessionIds.some((sessionId) => typeof sessionId !== 'string')) {
26
+ throw new Error('Harness returned invalid session IDs for workspace.list');
27
+ }
28
+ return workspace;
29
+ }
30
+
31
+ function workspaceSessions(workspace, archivedSessionIds, sessionList) {
32
+ if (!Array.isArray(sessionList?.items)) {
33
+ throw new Error('Harness returned an invalid response for session.list');
34
+ }
35
+
36
+ const archived = new Set(archivedSessionIds);
37
+ const summaries = new Map(sessionList.items.flatMap((item) => (
38
+ typeof item?.sessionId === 'string' ? [[item.sessionId, item]] : []
39
+ )));
40
+ return {
41
+ workspace: workspace.path,
42
+ sessions: workspace.sessionIds.map((sessionId) => {
43
+ const summary = summaries.get(sessionId);
44
+ const title = summary?.projections?.values?.title;
45
+ return {
46
+ sessionId,
47
+ title: typeof title === 'string' ? title : null,
48
+ archived: archived.has(sessionId),
49
+ blank: summary?.blank === true,
50
+ origin: summary?.origin === 'subagent' ? 'subagent' : null,
51
+ summaryAvailable: summary !== undefined,
52
+ };
53
+ }),
54
+ };
55
+ }
56
+
14
57
  function messageText(event) {
15
58
  return (event?.data?.message?.content ?? [])
16
59
  .filter((part) => part.type === 'text' && typeof part.text === 'string')
@@ -210,6 +253,19 @@ export class HarnessClient {
210
253
  return workspacePaths(await this.rpc('workspace.list', {}, 30000, options));
211
254
  }
212
255
 
256
+ async listWorkspaceSessions(workspacePath, options = {}) {
257
+ await this.ensureRunning();
258
+ const workspaceList = await this.rpc('workspace.list', {}, 30000, options);
259
+ const workspace = workspaceFromList(workspacePath, workspaceList);
260
+ if (!workspace) return { workspace: workspacePath, sessions: [] };
261
+ const sessionList = await this.rpc('session.list', {}, 30000, options);
262
+ return workspaceSessions(workspace, workspaceList.archivedSessionIds, sessionList);
263
+ }
264
+
265
+ async adoptWorkspaceSession(value, options = {}) {
266
+ return adoptRegisteredWorkspaceSession(this, value, options, 30000);
267
+ }
268
+
213
269
  async workspaceId(options = {}) {
214
270
  const workspace = options.workspace ?? this.#workspace;
215
271
  const { items } = await this.rpc('workspace.list', {});
@@ -8,6 +8,8 @@ const HELP_TEXT = [
8
8
  '/new 开启一个全新会话',
9
9
  '/workspace 工作区绝对路径 切换工作区',
10
10
  '/workspacelist 列出工作区绝对路径',
11
+ '/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
12
+ '/session Session ID 将当前聊天绑定到指定会话',
11
13
  '/status 检查连接状态',
12
14
  '/help 显示本帮助',
13
15
  ].join('\n');
@@ -126,7 +128,7 @@ export class QqHarnessBridge {
126
128
  await this.#state.markSeen(messageId);
127
129
  return;
128
130
  }
129
- const workspaceCommand = await runWorkspaceCommand(text, this.#harness);
131
+ const workspaceCommand = await runWorkspaceCommand(text, this.#harness, key);
130
132
  if (workspaceCommand) {
131
133
  for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
132
134
  await this.#bot.sendText(target, reply);
@@ -1,10 +1,37 @@
1
- import { mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
1
+ import {
2
+ mkdir,
3
+ readFile,
4
+ realpath,
5
+ rename,
6
+ stat,
7
+ unlink,
8
+ writeFile,
9
+ } from 'node:fs/promises';
2
10
  import { dirname, isAbsolute, resolve } from 'node:path';
3
11
 
4
12
  import { WORKSPACE_SESSION_STALE } from './workspace-session.mjs';
5
13
 
6
14
  const EMPTY_DOCUMENT = Object.freeze({ version: 1, workspaces: Object.freeze({}) });
7
15
 
16
+ function workspaceSessionStale(message) {
17
+ const error = new Error(message);
18
+ error.code = WORKSPACE_SESSION_STALE;
19
+ return error;
20
+ }
21
+
22
+ async function canonicalWorkspacePath(value) {
23
+ return resolve(await realpath(value));
24
+ }
25
+
26
+ async function sameWorkspacePath(left, right) {
27
+ if (left === right) return true;
28
+ try {
29
+ return await canonicalWorkspacePath(left) === await canonicalWorkspacePath(right);
30
+ } catch {
31
+ return false;
32
+ }
33
+ }
34
+
8
35
  function botIdOf(value) {
9
36
  if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(value)) {
10
37
  throw new TypeError('Invalid bot id');
@@ -179,6 +206,72 @@ export class BotWorkspaceStore {
179
206
  });
180
207
  }
181
208
 
209
+ async bindWorkspaceSession(botId, value, {
210
+ conversationKey,
211
+ sessionId,
212
+ clearSessions,
213
+ setSession,
214
+ incarnation,
215
+ expectedGeneration,
216
+ } = {}) {
217
+ const id = botIdOf(botId);
218
+ if (typeof conversationKey !== 'string' || !conversationKey
219
+ || typeof sessionId !== 'string' || !sessionId) {
220
+ throw new TypeError('conversationKey and sessionId are required');
221
+ }
222
+ if (typeof clearSessions !== 'function' || typeof setSession !== 'function') {
223
+ throw new TypeError('session state callbacks are required');
224
+ }
225
+ if (!this.has(id)
226
+ || (incarnation !== undefined && incarnation !== this.incarnationFor(id))) {
227
+ const error = new Error('找不到要修改的机器人。');
228
+ error.code = 'workspace-bot-not-found';
229
+ throw error;
230
+ }
231
+ const workspace = await canonicalWorkspacePath(await validateWorkspacePath(value));
232
+ return this.#enqueue(id, async () => {
233
+ if (!this.has(id)
234
+ || (incarnation !== undefined && incarnation !== this.incarnationFor(id))) {
235
+ const error = new Error('找不到要修改的机器人。');
236
+ error.code = 'workspace-bot-not-found';
237
+ throw error;
238
+ }
239
+ if (expectedGeneration !== undefined
240
+ && expectedGeneration !== this.generationFor(id)) {
241
+ throw workspaceSessionStale(
242
+ 'The bot workspace changed before the session binding could be committed.',
243
+ );
244
+ }
245
+
246
+ if (!(await sameWorkspacePath(workspace, this.workspaceFor(id)))) {
247
+ const previous = this.#workspaces[id];
248
+ // Fence every session resolved before this transition, then remove
249
+ // the old workspace mappings before publishing the new workspace.
250
+ this.#generations.set(id, this.#freshGeneration());
251
+ await clearSessions();
252
+ this.#workspaces[id] = workspace;
253
+ try {
254
+ await this.#persist();
255
+ } catch (error) {
256
+ // Session mappings stay cleared and the advanced generation stays
257
+ // fenced. Restoring either could pair an old session with a state
258
+ // transition whose durable outcome is unknown.
259
+ this.#workspaces[id] = previous;
260
+ throw error;
261
+ }
262
+ }
263
+
264
+ // This write remains inside the same bot transition as the workspace
265
+ // mutation, so another switch or bind cannot interleave between them.
266
+ await setSession(conversationKey, sessionId);
267
+ return {
268
+ workspace,
269
+ sessionId,
270
+ generation: this.#generations.get(id),
271
+ };
272
+ });
273
+ }
274
+
182
275
  async invalidateSessions(botId, { clearSessions } = {}) {
183
276
  const id = botIdOf(botId);
184
277
  return this.#enqueue(id, async () => {
@@ -417,20 +510,21 @@ export function createBotWorkspaceScope(harness, { botId, workspaces, state }) {
417
510
  }
418
511
  };
419
512
  }
420
- if (property === 'listWorkspaces' && typeof target.listWorkspaces === 'function') {
513
+ if ((property === 'listWorkspaces' || property === 'listWorkspaceSessions')
514
+ && typeof target[property] === 'function') {
421
515
  return async (...args) => {
422
516
  if (!isCurrentScope()) {
423
517
  const error = new Error('找不到要修改的机器人。');
424
518
  error.code = 'workspace-bot-not-found';
425
519
  throw error;
426
520
  }
427
- const paths = await target.listWorkspaces(...args);
521
+ const result = await target[property](...args);
428
522
  if (!isCurrentScope()) {
429
523
  const error = new Error('找不到要修改的机器人。');
430
524
  error.code = 'workspace-bot-not-found';
431
525
  throw error;
432
526
  }
433
- return paths;
527
+ return result;
434
528
  };
435
529
  }
436
530
  if (property === 'switchWorkspace') {
@@ -446,6 +540,62 @@ export function createBotWorkspaceScope(harness, { botId, workspaces, state }) {
446
540
  });
447
541
  };
448
542
  }
543
+ if (property === 'bindWorkspaceSession') {
544
+ return async (conversationKey, sessionId) => {
545
+ if (typeof conversationKey !== 'string' || !conversationKey
546
+ || typeof sessionId !== 'string' || !sessionId) {
547
+ throw new TypeError('conversationKey and sessionId are required');
548
+ }
549
+ if (!isCurrentScope()) {
550
+ const error = new Error('找不到要修改的机器人。');
551
+ error.code = 'workspace-bot-not-found';
552
+ throw error;
553
+ }
554
+ if (typeof target.adoptWorkspaceSession !== 'function') {
555
+ throw new TypeError('Harness does not support adopting workspace sessions');
556
+ }
557
+ const expectedGeneration = workspaces.generationFor(botId);
558
+ const adopted = await target.adoptWorkspaceSession(sessionId);
559
+ if (!isCurrentScope()) {
560
+ const error = new Error('找不到要修改的机器人。');
561
+ error.code = 'workspace-bot-not-found';
562
+ throw error;
563
+ }
564
+ if (expectedGeneration !== workspaces.generationFor(botId)) {
565
+ throw workspaceSessionStale(
566
+ 'The bot workspace changed while the session was being adopted.',
567
+ );
568
+ }
569
+ if (!adopted || typeof adopted !== 'object'
570
+ || adopted.sessionId !== sessionId || typeof adopted.workspace !== 'string') {
571
+ throw new TypeError('Harness returned an invalid adopted workspace session');
572
+ }
573
+ const bound = await workspaces.bindWorkspaceSession(botId, adopted.workspace, {
574
+ conversationKey,
575
+ sessionId,
576
+ clearSessions: () => state.clearSessions(),
577
+ setSession: (key, selectedSessionId) => state.setSession(key, selectedSessionId),
578
+ incarnation,
579
+ expectedGeneration,
580
+ });
581
+ if (!isCurrentScope()) {
582
+ const error = new Error('找不到要修改的机器人。');
583
+ error.code = 'workspace-bot-not-found';
584
+ throw error;
585
+ }
586
+ if (bound.generation !== workspaces.generationFor(botId)) {
587
+ throw workspaceSessionStale(
588
+ 'The bot workspace changed before the session binding completed.',
589
+ );
590
+ }
591
+ sessionGenerations.set(sessionId, bound.generation);
592
+ return {
593
+ ...adopted,
594
+ workspace: bound.workspace,
595
+ sessionId: bound.sessionId,
596
+ };
597
+ };
598
+ }
449
599
  if (property === 'createSession') {
450
600
  return async (options = {}) => {
451
601
  await workspaces.whenBotIdle(botId);
@@ -463,6 +613,37 @@ export function createBotWorkspaceScope(harness, { botId, workspaces, state }) {
463
613
  return sessionId;
464
614
  };
465
615
  }
616
+ if (property === 'workspaceSession') {
617
+ return (sessionId) => {
618
+ if (typeof sessionId !== 'string' || !sessionId) {
619
+ throw new TypeError('sessionId is required');
620
+ }
621
+ const generation = sessionGenerations.get(sessionId)
622
+ ?? workspaces.generationFor(botId);
623
+ // Transfer the mutable provenance entry into this immutable handle.
624
+ // A later handle for the same id captures its own generation instead
625
+ // of sharing deletion or rebinding state with this call.
626
+ sessionGenerations.delete(sessionId);
627
+ const isCurrentSession = () => isCurrentScope()
628
+ && generation === workspaces.generationFor(botId);
629
+ return Object.freeze({
630
+ sessionId,
631
+ async sessionExists(...args) {
632
+ if (!isCurrentSession()) return false;
633
+ const exists = await target.sessionExists(sessionId, ...args);
634
+ return isCurrentSession() && exists;
635
+ },
636
+ ask(...args) {
637
+ if (!isCurrentSession()) {
638
+ throw workspaceSessionStale(
639
+ 'The bot workspace changed before this prompt started.',
640
+ );
641
+ }
642
+ return target.ask(sessionId, ...args);
643
+ },
644
+ });
645
+ };
646
+ }
466
647
  if (property === 'sessionExists') {
467
648
  return (sessionId, ...args) => {
468
649
  if (!isCurrentScope()) return false;
@@ -0,0 +1,110 @@
1
+ import { isAbsolute } from 'node:path';
2
+
3
+ const MAX_SESSION_ID_LENGTH = 256;
4
+ const UNSAFE_SESSION_ID = /[\p{White_Space}\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u;
5
+ const UNSAFE_WORKSPACE_PATH = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u;
6
+
7
+ function bindingError(code, message) {
8
+ const error = new Error(message);
9
+ error.code = code;
10
+ return error;
11
+ }
12
+
13
+ function validatedSessionId(value) {
14
+ if (typeof value !== 'string' || !value || value.length > MAX_SESSION_ID_LENGTH
15
+ || UNSAFE_SESSION_ID.test(value)) {
16
+ throw bindingError('session-id-invalid', 'A non-empty, safe session id is required');
17
+ }
18
+ return value;
19
+ }
20
+
21
+ function sessionWorkspace(sessionId, value) {
22
+ if (!Array.isArray(value?.items) || !Array.isArray(value?.archivedSessionIds)
23
+ || value.archivedSessionIds.some((id) => typeof id !== 'string' || !id)) {
24
+ throw new Error('Harness returned an invalid response for workspace.list');
25
+ }
26
+
27
+ const owners = [];
28
+ for (const workspace of value.items) {
29
+ if (typeof workspace?.workspaceId !== 'string' || !workspace.workspaceId
30
+ || typeof workspace.path !== 'string' || !isAbsolute(workspace.path)
31
+ || !Array.isArray(workspace.sessionIds)
32
+ || workspace.sessionIds.some((id) => typeof id !== 'string' || !id)) {
33
+ throw new Error('Harness returned an invalid response for workspace.list');
34
+ }
35
+ for (const accountedId of workspace.sessionIds) {
36
+ if (accountedId === sessionId) owners.push(workspace);
37
+ }
38
+ }
39
+
40
+ if (owners.length === 0) {
41
+ throw bindingError('session-not-registered', 'The session is not registered to a Harness workspace');
42
+ }
43
+ if (owners.length !== 1) {
44
+ throw bindingError(
45
+ 'session-workspace-ambiguous',
46
+ 'The session is registered to more than one Harness workspace',
47
+ );
48
+ }
49
+ if (UNSAFE_WORKSPACE_PATH.test(owners[0].path)) {
50
+ throw new Error('Harness returned an unsafe workspace path for the session');
51
+ }
52
+ return {
53
+ workspace: owners[0],
54
+ archived: value.archivedSessionIds.includes(sessionId),
55
+ };
56
+ }
57
+
58
+ function sessionSummary(sessionId, value) {
59
+ if (!Array.isArray(value?.items)
60
+ || value.items.some((item) => typeof item?.sessionId !== 'string' || !item.sessionId)) {
61
+ throw new Error('Harness returned an invalid response for session.list');
62
+ }
63
+ const matches = value.items.filter((item) => item.sessionId === sessionId);
64
+ if (matches.length === 0) {
65
+ throw bindingError('session-summary-unavailable', 'The session is no longer available from Harness');
66
+ }
67
+ if (matches.length !== 1) {
68
+ throw new Error('Harness returned duplicate session summaries for session.list');
69
+ }
70
+
71
+ const [summary] = matches;
72
+ if (summary.origin === 'subagent') {
73
+ throw bindingError(
74
+ 'session-subagent-unsupported',
75
+ 'Subagent sessions cannot be adopted as a bot conversation',
76
+ );
77
+ }
78
+ if (summary.origin !== undefined) {
79
+ throw new Error('Harness returned an invalid session origin for session.list');
80
+ }
81
+ const title = summary.projections?.values?.title;
82
+ if (title !== undefined && title !== null && typeof title !== 'string') {
83
+ throw new Error('Harness returned an invalid session title for session.list');
84
+ }
85
+ return { title: typeof title === 'string' ? title : null };
86
+ }
87
+
88
+ export async function adoptRegisteredWorkspaceSession(client, value, options = {}, timeoutMs = 30_000) {
89
+ const sessionId = validatedSessionId(value);
90
+ await client.ensureRunning(options);
91
+ const workspaceList = await client.rpc('workspace.list', {}, timeoutMs, options);
92
+ const { workspace, archived } = sessionWorkspace(sessionId, workspaceList);
93
+ const summary = sessionSummary(
94
+ sessionId,
95
+ await client.rpc('session.list', {}, timeoutMs, options),
96
+ );
97
+ const adopted = await client.rpc('session.create', {
98
+ workspaceId: workspace.workspaceId,
99
+ sessionId,
100
+ }, timeoutMs, options);
101
+ if (!adopted || adopted.sessionId !== sessionId) {
102
+ throw new Error('Harness returned an invalid response for session.create');
103
+ }
104
+ return {
105
+ sessionId,
106
+ workspace: workspace.path,
107
+ title: summary.title,
108
+ archived,
109
+ };
110
+ }
@@ -102,6 +102,8 @@ export class TextHarnessBridge {
102
102
  '/new 开启一个全新会话',
103
103
  '/workspace 工作区绝对路径 切换工作区',
104
104
  '/workspacelist 列出工作区绝对路径',
105
+ '/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
106
+ '/session Session ID 将当前聊天绑定到指定会话',
105
107
  '/status 检查连接状态',
106
108
  '/help 显示本帮助',
107
109
  ].join('\n'));
@@ -114,7 +116,8 @@ export class TextHarnessBridge {
114
116
  await this.#state.markSeen(messageId);
115
117
  return;
116
118
  }
117
- const workspaceCommand = await runWorkspaceCommand(text, this.#harness);
119
+ const conversationKey = `${message.kind}:${message.conversationId}`;
120
+ const workspaceCommand = await runWorkspaceCommand(text, this.#harness, conversationKey);
118
121
  if (workspaceCommand) {
119
122
  for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
120
123
  await this.#bot.sendText(target, reply);
@@ -122,7 +125,6 @@ export class TextHarnessBridge {
122
125
  await this.#state.markSeen(messageId);
123
126
  return;
124
127
  }
125
- const conversationKey = `${message.kind}:${message.conversationId}`;
126
128
  if (command === '/new') {
127
129
  await this.#state.clearSession(conversationKey);
128
130
  await this.#bot.sendText(target, '已开启新会话。请发送你的问题。');