@xmanrui/dsh-im 4.34.2 → 4.35.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.
@@ -242,10 +242,12 @@ function turnStoppedError() {
242
242
  return error;
243
243
  }
244
244
 
245
- function workspacePaths(value) {
245
+ function workspaceEntries(value) {
246
246
  if (!Array.isArray(value?.items)) return [];
247
247
  return value.items.flatMap((item) => (
248
- typeof item?.path === 'string' && isAbsolute(item.path) ? [item.path] : []
248
+ typeof item?.path === 'string' && isAbsolute(item.path)
249
+ ? [{ path: item.path, title: typeof item.title === 'string' ? item.title : '' }]
250
+ : []
249
251
  ));
250
252
  }
251
253
 
@@ -378,6 +380,11 @@ export class AssistantTextAccumulator {
378
380
  #steps = new Map();
379
381
  #legacyText = '';
380
382
 
383
+ clear() {
384
+ this.#steps.clear();
385
+ this.#legacyText = '';
386
+ }
387
+
381
388
  appendDelta(step, index, text) {
382
389
  if (typeof text !== 'string' || !text) return;
383
390
  const stepNumber = Number.isSafeInteger(step) ? step : 0;
@@ -413,6 +420,22 @@ export class AssistantTextAccumulator {
413
420
  }
414
421
  }
415
422
 
423
+ /** Keep answer text after the last tool boundary, shared by live and recovered replies. */
424
+ export function accumulateFinalAssistantText(accumulator, event) {
425
+ if (event.type === 'tool/call') {
426
+ accumulator.clear();
427
+ } else if (event.type === 'assistant/chunk' && event.data?.chunk?.type === 'text-delta') {
428
+ accumulator.appendDelta(event.data.step, event.data.chunk.index, event.data.chunk.text);
429
+ } else if (event.type === 'assistant/message') {
430
+ const content = event.data?.message?.content;
431
+ const text = textFromHarnessContent(content);
432
+ // Canonical tool messages can arrive before or after the separate call.
433
+ // An explicitly empty final message must not revive an earlier preamble.
434
+ if (!text || content.some((part) => part?.type === 'tool-call')) accumulator.clear();
435
+ else accumulator.setCanonical(event.data.step, text);
436
+ }
437
+ }
438
+
416
439
  function nonEmptyText(value) {
417
440
  return typeof value === 'string' && value.trim() ? value.trim() : null;
418
441
  }
@@ -536,6 +559,7 @@ export class HarnessReplyTracker {
536
559
  #openTurn = null;
537
560
  #targetTurn = null;
538
561
  #assistantText = new AssistantTextAccumulator();
562
+ #finalAssistantText = null;
539
563
  #latestText = '';
540
564
  #finished = false;
541
565
  #reason = null;
@@ -546,13 +570,14 @@ export class HarnessReplyTracker {
546
570
  #pendingReasoningChars = 0;
547
571
  #reasoning = false;
548
572
 
549
- constructor({ promptRpcId, afterSeq = -1, reasoning = false }) {
573
+ constructor({ promptRpcId, afterSeq = -1, reasoning = false, finalAnswerOnly = false }) {
550
574
  this.#promptRpcId = promptRpcId;
551
575
  this.#lastSeq = afterSeq;
552
576
  // Reasoning updates are opt-in per consumer: only channels that surface
553
577
  // thinking traces (Telegram thinking mode) subscribe; every other channel
554
578
  // keeps its pre-thinking-traces update stream untouched.
555
579
  this.#reasoning = reasoning === true;
580
+ if (finalAnswerOnly === true) this.#finalAssistantText = new AssistantTextAccumulator();
556
581
  }
557
582
 
558
583
  get finished() {
@@ -568,6 +593,10 @@ export class HarnessReplyTracker {
568
593
  return this.#latestText.trim();
569
594
  }
570
595
 
596
+ get finalAnswer() {
597
+ return this.#finalAssistantText ? this.#finalAssistantText.text : this.answer;
598
+ }
599
+
571
600
  get reason() {
572
601
  return this.#reason;
573
602
  }
@@ -695,6 +724,8 @@ export class HarnessReplyTracker {
695
724
  }
696
725
  if (event.data?.turn !== this.#targetTurn) continue;
697
726
 
727
+ if (this.#finalAssistantText) accumulateFinalAssistantText(this.#finalAssistantText, event);
728
+
698
729
  if (event.type === 'assistant/chunk' && event.data?.chunk?.type === 'text-delta') {
699
730
  const step = event.data?.step ?? 0;
700
731
  const index = event.data.chunk.index ?? 0;
@@ -874,6 +905,14 @@ function harnessTurnSucceeded(reason) {
874
905
  return (nonEmptyText(reason?.kind) ?? nonEmptyText(reason)) === 'completed';
875
906
  }
876
907
 
908
+ function sessionArchivedError(sessionId, cause) {
909
+ const error = new Error('The Session is archived and cannot process this message.', { cause });
910
+ error.code = 'session-archived';
911
+ error.promptAccepted = false;
912
+ error.details = { sessionId };
913
+ return error;
914
+ }
915
+
877
916
  export class HarnessClient {
878
917
  #baseUrl;
879
918
  #apiProxy;
@@ -1081,8 +1120,12 @@ export class HarnessClient {
1081
1120
  }
1082
1121
 
1083
1122
  async listWorkspaces(options = {}) {
1123
+ return (await this.listWorkspaceEntries(options)).map((item) => item.path);
1124
+ }
1125
+
1126
+ async listWorkspaceEntries(options = {}) {
1084
1127
  await this.ensureRunning(options);
1085
- return workspacePaths(await this.rpc('workspace.list', {}, 30_000, options));
1128
+ return workspaceEntries(await this.rpc('workspace.list', {}, 30_000, options));
1086
1129
  }
1087
1130
 
1088
1131
  async listWorkspaceSessions(workspacePath, options = {}) {
@@ -1259,11 +1302,47 @@ export class HarnessClient {
1259
1302
  }, timeoutMs, options);
1260
1303
  }
1261
1304
 
1305
+ async #isSessionArchived(sessionId, options = {}) {
1306
+ const value = await this.rpc('workspace.list', {}, 30_000, options);
1307
+ if (!Array.isArray(value?.archivedSessionIds)
1308
+ || value.archivedSessionIds.some((id) => typeof id !== 'string' || !id)) {
1309
+ throw new HarnessTransportError('harness-response-invalid', 'workspace.list');
1310
+ }
1311
+ return value.archivedSessionIds.includes(sessionId);
1312
+ }
1313
+
1314
+ // A diagnostic recheck must not hide the original failure or cancellation.
1315
+ async #confirmedArchiveError(sessionId, options, cause) {
1316
+ try {
1317
+ if (await this.#isSessionArchived(sessionId, options)) return sessionArchivedError(sessionId, cause);
1318
+ } catch (error) {
1319
+ if (options?.signal?.aborted) throw options.signal.reason ?? error;
1320
+ }
1321
+ return null;
1322
+ }
1323
+
1324
+ async #sessionRpc(method, payload, timeoutMs, options) {
1325
+ try {
1326
+ return await this.rpc(method, payload, timeoutMs, options);
1327
+ } catch (error) {
1328
+ if (options?.signal?.aborted) throw options.signal.reason ?? error;
1329
+ if (error instanceof HarnessRpcError
1330
+ && ['internal', 'gateway/internal', 'session-not-found', 'session/not-found'].includes(error.code)) {
1331
+ throw await this.#confirmedArchiveError(payload.sessionId, { signal: options?.signal }, error) ?? error;
1332
+ }
1333
+ throw error;
1334
+ }
1335
+ }
1336
+
1262
1337
  async sessionExists(sessionId, options = {}) {
1338
+ // Archived Sessions still exist. Returning false here would silently bind
1339
+ // the conversation to a new Session; reading their active log can also fail.
1340
+ if (await this.#isSessionArchived(sessionId, options)) return true;
1263
1341
  try {
1264
- await this.rpc('session.history', { sessionId, maxMessages: 1 }, 30_000, options);
1342
+ await this.#sessionRpc('session.history', { sessionId, maxMessages: 1 }, 30_000, options);
1265
1343
  return true;
1266
1344
  } catch (error) {
1345
+ if (error.code === 'session-archived') return true;
1267
1346
  if (error instanceof HarnessRpcError && error.code === 'session-not-found') return false;
1268
1347
  throw error;
1269
1348
  }
@@ -1635,7 +1714,8 @@ export class HarnessClient {
1635
1714
  const inboundFiles = Array.isArray(options.files) ? options.files.filter(Boolean) : [];
1636
1715
  const inboundImages = Array.isArray(options.images) ? options.images.filter(Boolean) : [];
1637
1716
  await this.ensureRunning({ signal });
1638
- const before = await this.rpc(
1717
+ if (await this.#isSessionArchived(sessionId, { signal })) throw sessionArchivedError(sessionId);
1718
+ const before = await this.#sessionRpc(
1639
1719
  'session.history',
1640
1720
  { sessionId, maxMessages: 1 },
1641
1721
  30_000,
@@ -1644,7 +1724,10 @@ export class HarnessClient {
1644
1724
  const baselineSeq = Math.max(-1, ...(before.events ?? []).map(({ event }) => event.seq ?? -1));
1645
1725
  const promptRpcId = `${this.#rpcIdPrefix}-${randomUUID()}`;
1646
1726
  const releasePromptInputOrigin = registerImInputOrigin(this.#interactionRegistry, promptRpcId);
1647
- const tracker = new HarnessReplyTracker({ promptRpcId, afterSeq: baselineSeq, reasoning });
1727
+ const tracker = new HarnessReplyTracker({
1728
+ promptRpcId, afterSeq: baselineSeq, reasoning, finalAnswerOnly: options.finalAnswerOnly,
1729
+ });
1730
+ let checkedBlockedSeq = baselineSeq;
1648
1731
  let lastProgressAt = Date.now();
1649
1732
  let lastPollSeq = tracker.lastSeq;
1650
1733
  let progressTail = Promise.resolve();
@@ -1802,7 +1885,7 @@ export class HarnessClient {
1802
1885
  // message that merely looks like a guidance block is not configuration.
1803
1886
  imSourceGuidance.publish(sessionId, options.sourceGuidance);
1804
1887
  const clientTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
1805
- const sendPrompt = (promptContent) => this.rpc('session.prompt', {
1888
+ const sendPrompt = (promptContent) => this.#sessionRpc('session.prompt', {
1806
1889
  sessionId,
1807
1890
  mode: 'queue',
1808
1891
  content: promptContent,
@@ -1856,7 +1939,7 @@ export class HarnessClient {
1856
1939
  // active until turn/end and can therefore outlive a stalled turn.
1857
1940
  while (true) {
1858
1941
  await sleep(300, signal);
1859
- const history = await this.rpc(
1942
+ const history = await this.#sessionRpc(
1860
1943
  'session.history',
1861
1944
  { sessionId, maxMessages: 50 },
1862
1945
  30_000,
@@ -1871,16 +1954,19 @@ export class HarnessClient {
1871
1954
  if (tracker.finished) {
1872
1955
  turnFinished = true;
1873
1956
  if (!ownership?.stopRequested && !harnessTurnSucceeded(tracker.reason)) {
1874
- throw harnessTurnError(tracker.reason);
1957
+ const failure = harnessTurnError(tracker.reason);
1958
+ if (failure.code === 'turn-blocked') {
1959
+ throw await this.#confirmedArchiveError(sessionId, { signal }, failure) ?? failure;
1960
+ }
1961
+ throw failure;
1875
1962
  }
1876
1963
  // An accepted /stop revokes attachment delivery even when Harness
1877
1964
  // preserved a useful partial text answer for the existing UX.
1878
1965
  const artifactCount = ownership?.stopRequested
1879
1966
  ? 0
1880
1967
  : await deliverArtifacts();
1881
- if (tracker.answer) {
1882
- return tracker.answer;
1883
- }
1968
+ const answer = ownership?.stopRequested ? tracker.answer : tracker.finalAnswer;
1969
+ if (answer) return answer;
1884
1970
  if (artifactCount > 0) return '';
1885
1971
  if (ownership?.stopRequested) throw turnStoppedError();
1886
1972
  if (artifactHandoffError) throw artifactHandoffError;
@@ -1892,8 +1978,30 @@ export class HarnessClient {
1892
1978
  throw harnessTurnError(tracker.reason);
1893
1979
  }
1894
1980
 
1981
+ if (tracker.turn === null && !ownership?.stopRequested) {
1982
+ // An admission gate can block before user/message supplies rpcId.
1983
+ // This event only prompts a Session-level archive check; it never
1984
+ // grants ownership of another caller's turn, artifacts or approvals.
1985
+ const blockedSeq = Math.max(checkedBlockedSeq, ...(history.events ?? [])
1986
+ .map((entry) => entry?.event ?? entry)
1987
+ .filter((event) => event?.type === 'turn/end'
1988
+ && (event.data?.reason?.kind ?? event.data?.reason) === 'blocked'
1989
+ && Number.isSafeInteger(event.seq))
1990
+ .map((event) => event.seq));
1991
+ if (blockedSeq > checkedBlockedSeq) {
1992
+ checkedBlockedSeq = blockedSeq;
1993
+ const archived = await this.#confirmedArchiveError(sessionId, { signal });
1994
+ if (archived) throw archived;
1995
+ }
1996
+ }
1997
+
1895
1998
  if (Date.now() - lastProgressAt < timeoutMs) continue;
1896
1999
 
2000
+ if (!ownership?.stopRequested) {
2001
+ const archived = await this.#confirmedArchiveError(sessionId, { signal });
2002
+ if (archived) throw archived;
2003
+ }
2004
+
1897
2005
  let running = false;
1898
2006
  try {
1899
2007
  running = await this.isSessionRunning(sessionId, { signal });
@@ -1911,6 +2019,10 @@ export class HarnessClient {
1911
2019
  throw timeoutError;
1912
2020
  }
1913
2021
  } catch (error) {
2022
+ if (error.code === 'session-archived') {
2023
+ error.promptAccepted = promptAccepted;
2024
+ error.details = { ...error.details, promptRpcId, baselineSeq, turn: tracker.turn, lastSeq: tracker.lastSeq };
2025
+ }
1914
2026
  if (error instanceof HarnessTurnError) {
1915
2027
  error.details ??= {
1916
2028
  sessionId, promptRpcId, baselineSeq, turn: tracker.turn, lastSeq: tracker.lastSeq,
@@ -78,8 +78,12 @@ export default {
78
78
  'The model rejected the current content. Revise the request and try again.',
79
79
  '模型达到输出长度上限,但没有生成可显示的结果。请缩小任务范围后重试。':
80
80
  'The model reached its output limit without producing a displayable result. Reduce the task scope and try again.',
81
- '任务正在等待无法在当前渠道完成的操作。请在 DeepSeek Harness 中处理后再试。':
82
- 'The task is waiting for an action that cannot be completed in this channel. Handle it in DeepSeek Harness, then try again.',
81
+ '本轮处理被阻止,未能完成。请在 DSH 中查看会话状态和相关提示。':
82
+ 'This turn was blocked and did not finish. Check the Session status and related notices in DSH.',
83
+ '当前会话已归档,无法继续处理。请在 DSH 中取消归档,或发送 /new 创建新会话。':
84
+ 'The current Session is archived and cannot continue. Unarchive it in DSH, or send /new to create a new Session.',
85
+ '本条消息可能仍在会话队列中,请先到 DSH 查看状态,避免重复提交。':
86
+ 'This message may still be queued in the Session. Check its status in DSH before submitting it again.',
83
87
  '任务被意外中断,本次未完成。请重试。':
84
88
  'The task was unexpectedly interrupted and did not finish. Please try again.',
85
89
  '当前会话已不存在。请发送 /new 创建新会话后重试。':
@@ -67,11 +67,13 @@ const FAILURE_MESSAGES = Object.freeze({
67
67
  MODEL_OUTPUT_LIMIT:
68
68
  '模型达到输出长度上限,但没有生成可显示的结果。请缩小任务范围后重试。',
69
69
  TURN_BLOCKED:
70
- '任务正在等待无法在当前渠道完成的操作。请在 DeepSeek Harness 中处理后再试。',
70
+ '本轮处理被阻止,未能完成。请在 DSH 中查看会话状态和相关提示。',
71
71
  TURN_INTERRUPTED:
72
72
  '任务被意外中断,本次未完成。请重试。',
73
73
  SESSION_NOT_FOUND:
74
74
  '当前会话已不存在。请发送 /new 创建新会话后重试。',
75
+ SESSION_ARCHIVED:
76
+ '当前会话已归档,无法继续处理。请在 DSH 中取消归档,或发送 /new 创建新会话。',
75
77
  SESSION_BUSY:
76
78
  '当前会话仍在处理上一项任务。请等待完成,或发送 /stop 后重试。',
77
79
  SESSION_STALE:
@@ -137,6 +139,7 @@ function failureCode(error) {
137
139
  if (code === 'turn-blocked') return 'TURN_BLOCKED';
138
140
  if (['turn-interrupted', 'turn-aborted'].includes(code)) return 'TURN_INTERRUPTED';
139
141
  if (code === 'session-not-found') return 'SESSION_NOT_FOUND';
142
+ if (code === 'session-archived') return 'SESSION_ARCHIVED';
140
143
  if (code === 'agent-busy') return 'SESSION_BUSY';
141
144
  if (code === 'workspace-session-stale') return 'SESSION_STALE';
142
145
  if (code.startsWith('workspace-')) return 'WORKSPACE_UNAVAILABLE';
@@ -220,7 +223,8 @@ export function classifyMessageFailure(error, {
220
223
  reason: safeReason ?? safeFailureReason(error?.code) ?? code,
221
224
  message: typeof userMessage === 'string' && userMessage.trim()
222
225
  ? userMessage.trim()
223
- : t(FAILURE_MESSAGES[code]),
226
+ : t(FAILURE_MESSAGES[code]) + (code === 'SESSION_ARCHIVED' && error?.promptAccepted === true
227
+ ? `\n${t('本条消息可能仍在会话队列中,请先到 DSH 查看状态,避免重复提交。')}` : ''),
224
228
  referenceId: safeReferenceId(referenceId),
225
229
  at: Number.isFinite(at) ? at : Date.now(),
226
230
  });
@@ -62,17 +62,19 @@ export function parseSessionListArgument(value) {
62
62
  return { selector: '', limit };
63
63
  }
64
64
 
65
+ async function existingWorkspacePath(value) {
66
+ const workspace = normalizedWorkspacePath(value);
67
+ if (!workspace) return null;
68
+ try {
69
+ if (!(await stat(workspace)).isDirectory()) return null;
70
+ return normalizedWorkspacePath(await realpath(workspace));
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+
65
76
  async function existingWorkspacePaths(values) {
66
- const checked = await Promise.all(values.map(async (value) => {
67
- const workspace = normalizedWorkspacePath(value);
68
- if (!workspace) return null;
69
- try {
70
- if (!(await stat(workspace)).isDirectory()) return null;
71
- return normalizedWorkspacePath(await realpath(workspace));
72
- } catch {
73
- return null;
74
- }
75
- }));
77
+ const checked = await Promise.all(values.map(existingWorkspacePath));
76
78
  return [...new Set(checked.filter(Boolean))];
77
79
  }
78
80
 
@@ -109,16 +111,26 @@ async function selectedWorkspacePath(value) {
109
111
  }
110
112
  }
111
113
 
112
- export async function workspacePathSnapshot(harness, options = {}) {
113
- const listed = await harness.listWorkspaces(options);
114
+ export async function workspacePathSnapshot(harness, { includeTitles = false, ...options } = {}) {
115
+ const withEntries = includeTitles && typeof harness.listWorkspaceEntries === 'function';
116
+ const listed = await (withEntries ? harness.listWorkspaceEntries(options) : harness.listWorkspaces(options));
114
117
  const currentValue = typeof harness?.currentWorkspace === 'function'
115
118
  ? harness.currentWorkspace()
116
119
  : null;
117
- const [current] = currentValue ? await existingWorkspacePaths([currentValue]) : [];
118
- const registered = await existingWorkspacePaths(Array.isArray(listed) ? listed : []);
119
- const paths = [...new Set([...(current ? [current] : []), ...registered])];
120
+ const current = await existingWorkspacePath(currentValue);
121
+ const entries = await Promise.all((Array.isArray(listed) ? listed : []).map(async (item) => ({
122
+ path: await existingWorkspacePath(withEntries ? item?.path : item),
123
+ title: withEntries ? safeDisplayText(item?.title) : '',
124
+ })));
125
+ const paths = [...new Set([...(current ? [current] : []), ...entries.map((item) => item.path).filter(Boolean)])];
126
+ const titles = {};
127
+ if (includeTitles) {
128
+ for (const item of entries) {
129
+ if (item.path && item.title && !titles[item.path]) titles[item.path] = item.title;
130
+ }
131
+ }
120
132
  harness.assertWorkspaceScope?.();
121
- return { current: current ?? null, paths };
133
+ return { current: current ?? null, paths, ...(includeTitles ? { titles } : {}) };
122
134
  }
123
135
 
124
136
  export function splitWorkspaceCommandMessage(message) {