@xmanrui/dsh-im 4.33.0 → 4.34.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.
@@ -0,0 +1,149 @@
1
+ import { createCommandListSnapshots } from './command-list-snapshots.mjs';
2
+ import { t } from './i18n.mjs';
3
+ import { withSessionBindingLock } from './session-binding-lock.mjs';
4
+ import { splitWorkspaceCommandMessage } from './workspace-command.mjs';
5
+ import { WORKSPACE_SESSION_STALE } from './workspace-session.mjs';
6
+
7
+ const COMMAND = /^\/(?:permission|permissionlist)(?=$|\s)/iu;
8
+ const SNAPSHOTS = createCommandListSnapshots();
9
+ const USAGE = '用法:/permission 查看当前权限;/permissionlist 列出档位;/permission <序号或完整ID> 切换;纯数字 ID 使用 /permission id:<ID>。';
10
+ const SCOPE = '仅影响当前会话;沙箱与审批策略会同时按 Host 的档位定义更新。';
11
+ const REFRESH = '请先执行 /permissionlist,再按列表序号选择权限档位。';
12
+ const validId = (id) => typeof id === 'string' && /^[^\s\p{Cc}\p{Cf}]+$/u.test(id);
13
+ const display = (value) => typeof value === 'string'
14
+ ? value.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+/gu, ' ').trim() : '';
15
+
16
+ function result(message) {
17
+ return { handled: true, message, messages: splitWorkspaceCommandMessage(message) };
18
+ }
19
+
20
+ function permissions(value) {
21
+ if (!value || !validId(value.currentValue) || !Array.isArray(value.options)
22
+ || value.options.some((item) => !item || !validId(item.value))) {
23
+ throw new TypeError('Harness returned invalid permissions');
24
+ }
25
+ const seen = new Set();
26
+ return {
27
+ currentValue: value.currentValue,
28
+ options: value.options.filter((item) => {
29
+ if (item.value === 'custom' || seen.has(item.value)) return false;
30
+ seen.add(item.value);
31
+ return true;
32
+ }),
33
+ };
34
+ }
35
+
36
+ function description(item) {
37
+ // Translate known Host copy, not an assumed ID -> policy mapping: presets are configurable.
38
+ const text = display(item.description);
39
+ if (text === 'Full file access without approval prompts.') {
40
+ return t('完全文件访问,不再请求审批。');
41
+ }
42
+ if (text === 'Write inside the workspace and permitted temporary directories; wider retries require approval.') {
43
+ return t('可写工作区和允许的临时目录;扩大访问范围需要审批。');
44
+ }
45
+ return text;
46
+ }
47
+
48
+ function errorMessage(error, writing) {
49
+ const code = error?.code ?? error?.failure?.code;
50
+ if (code === WORKSPACE_SESSION_STALE || code === 'workspace-bot-not-found') {
51
+ return t('工作区或机器人状态已发生变化,请重试。');
52
+ }
53
+ if (['session-not-found', 'session/not-found', 'agent-not-found'].includes(code)) {
54
+ return t('当前聊天绑定的会话已不存在,请先绑定或建立会话。');
55
+ }
56
+ if (['commands-unavailable', 'permissions-unavailable', 'harness-api-not-found',
57
+ 'endpoint-not-found', 'gateway/endpoint-not-found'].includes(code)) {
58
+ return t('当前 Host 暂不支持从机器人查看或切换权限档位。');
59
+ }
60
+ return writing
61
+ ? t('未能确认权限切换结果,请执行 /permission 核对当前档位。')
62
+ : t('暂时无法获取权限档位,请稍后重试。');
63
+ }
64
+
65
+ export function isPermissionCommand(text) {
66
+ return typeof text === 'string' && COMMAND.test(text.trim());
67
+ }
68
+
69
+ export async function runPermissionCommand(text, harness, state, key, options = {}) {
70
+ if (!isPermissionCommand(text)) return null;
71
+ if (options.hasImages || options.hasFiles) {
72
+ return result(t('权限命令仅支持纯文字,请移除图片或文件后重试。'));
73
+ }
74
+ const match = /^\/(permission|permissionlist)(?:[ \t]+([^\s]+))?[ \t]*$/iu.exec(text.trim());
75
+ if (!match || (match[1].toLowerCase() === 'permissionlist' && match[2])) return result(t(USAGE));
76
+ const list = match[1].toLowerCase() === 'permissionlist';
77
+ const requested = match[2];
78
+ if (requested && (!validId(requested) || requested.startsWith('--'))) return result(t(USAGE));
79
+ const signal = options.signal
80
+ ? AbortSignal.any([options.signal, AbortSignal.timeout(30_000)]) : AbortSignal.timeout(30_000);
81
+ let writing = false;
82
+ try {
83
+ return await withSessionBindingLock(state, key, async () => {
84
+ signal.throwIfAborted();
85
+ const sessionId = state.sessionFor?.(key);
86
+ if (!sessionId) return result(t('当前聊天尚未绑定会话,请先发送普通消息建立会话,或使用 /session 绑定已有会话。'));
87
+ const session = typeof harness.workspaceSession === 'function'
88
+ ? harness.workspaceSession(sessionId, key)
89
+ : {
90
+ permissions: harness.getSessionPermissions && ((opts) => harness.getSessionPermissions(sessionId, opts)),
91
+ executeCommand: harness.executeCommand && ((line, opts) => harness.executeCommand(sessionId, line, opts)),
92
+ };
93
+ if (!requested) {
94
+ if (typeof session?.permissions !== 'function') return result(t('当前 Host 暂不支持从机器人查看或切换权限档位。'));
95
+ const current = permissions(await session.permissions({ signal }));
96
+ if (state.sessionFor(key) !== sessionId) return result(t('当前会话已变化,请重新执行 /permissionlist。'));
97
+ const lines = [t('当前会话权限:{id}', { id: current.currentValue }), ''];
98
+ if (list) {
99
+ SNAPSHOTS.save(state, key, { sessionId, ids: current.options.map((item) => item.value) });
100
+ for (const [index, item] of current.options.entries()) {
101
+ const label = display(item.name);
102
+ const detail = description(item);
103
+ lines.push(`${index + 1}. ${item.value}${label && label !== item.value ? `(${label})` : ''}${item.value === current.currentValue ? t(' ✓ 当前') : ''}${detail ? ` — ${detail}` : ''}`);
104
+ }
105
+ if (!current.options.length) lines.push(t('当前没有可用权限档位。'));
106
+ lines.push('', t('切换:/permission <序号或完整ID>'));
107
+ } else {
108
+ lines.push(t('查看可用档位与序号:/permissionlist'));
109
+ }
110
+ lines.push(t(SCOPE));
111
+ return result(lines.join('\n'));
112
+ }
113
+
114
+ let selected = requested;
115
+ const numericId = /^id:(\d+)$/iu.exec(requested);
116
+ if (numericId) selected = numericId[1];
117
+ else if (/^\d+$/u.test(requested)) {
118
+ const snapshot = SNAPSHOTS.load(state, key);
119
+ if (!snapshot || snapshot.sessionId !== sessionId) return result(t(REFRESH));
120
+ const index = Number(requested);
121
+ if (!Number.isSafeInteger(index) || index < 1 || !snapshot.ids[index - 1]) {
122
+ return result(t('权限档位序号无效,请重新执行 /permissionlist。'));
123
+ }
124
+ selected = snapshot.ids[index - 1];
125
+ }
126
+ if (selected === 'custom' || /^id:/iu.test(selected)) return result(t(USAGE));
127
+ if (typeof session?.executeCommand !== 'function') return result(t('当前 Host 暂不支持从机器人查看或切换权限档位。'));
128
+ signal.throwIfAborted();
129
+ writing = true;
130
+ const execution = await session.executeCommand(`/permission ${selected}`, { signal });
131
+ if (execution === undefined) return result(t('当前 Host 未注册 /permission 命令,请确认权限预设组件已启用。'));
132
+ const outcome = execution?.result;
133
+ if (!outcome || !['success', 'error'].includes(outcome.kind)
134
+ || (outcome.text !== undefined && typeof outcome.text !== 'string')) throw new TypeError('Invalid permission command result');
135
+ if (outcome.kind === 'error') {
136
+ if (outcome.text?.startsWith('unknown preset ')) {
137
+ return result(t('该权限档位不存在或已不可用,请重新执行 /permissionlist。'));
138
+ }
139
+ return result([t('权限切换失败。'), display(outcome.text)].filter(Boolean).join('\n'));
140
+ }
141
+ return result([
142
+ t('当前会话权限已切换为:{id}', { id: selected }), '', t(SCOPE),
143
+ ...(selected === 'danger-full-access' ? [t('恢复工作区权限:/permission workspace-write(需 Host 支持该档位)')] : []),
144
+ ].join('\n'));
145
+ });
146
+ } catch (error) {
147
+ return result(errorMessage(error, writing));
148
+ }
149
+ }
@@ -1,3 +1,4 @@
1
+ import { createCommandListSnapshots, COMMAND_LIST_TTL_MS, COMMAND_LIST_MAX_ENTRIES } from './command-list-snapshots.mjs';
1
2
  import {
2
3
  normalizeAgentPresetCatalog,
3
4
  normalizeAgentPresetId,
@@ -19,9 +20,9 @@ const PRESET_USAGE = [
19
20
  '/preset --default 跟随 Host 默认',
20
21
  ].join('\n');
21
22
  const UNSAFE_DISPLAY_TEXT_GLOBAL = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+/gu;
22
- const LIST_SNAPSHOTS = new WeakMap();
23
- export const PRESET_LIST_SNAPSHOT_TTL_MS = 15 * 60_000;
24
- export const PRESET_LIST_SNAPSHOT_MAX_ENTRIES = 256;
23
+ const LIST_SNAPSHOTS = createCommandListSnapshots();
24
+ export const PRESET_LIST_SNAPSHOT_TTL_MS = COMMAND_LIST_TTL_MS;
25
+ export const PRESET_LIST_SNAPSHOT_MAX_ENTRIES = COMMAND_LIST_MAX_ENTRIES;
25
26
 
26
27
  function commandResult(message) {
27
28
  return {
@@ -140,59 +141,13 @@ function formatUpdated(settings) {
140
141
  ].join('\n');
141
142
  }
142
143
 
143
- function stateSnapshots(state, { create = false } = {}) {
144
- if ((typeof state !== 'object' || state === null) && typeof state !== 'function') return null;
145
- let snapshots = LIST_SNAPSHOTS.get(state);
146
- if (!snapshots && create) {
147
- snapshots = new Map();
148
- LIST_SNAPSHOTS.set(state, snapshots);
149
- }
150
- return snapshots ?? null;
151
- }
152
-
153
- function pruneExpiredSnapshots(snapshots, now) {
154
- for (const [snapshotKey, snapshot] of snapshots) {
155
- if (snapshot.expiresAt <= now) snapshots.delete(snapshotKey);
156
- }
157
- }
158
-
159
- function saveSnapshot(state, key, items) {
160
- const snapshots = stateSnapshots(state, { create: true });
161
- if (!snapshots) return;
162
- const now = Date.now();
163
- pruneExpiredSnapshots(snapshots, now);
164
- snapshots.delete(key);
165
- snapshots.set(key, {
166
- expiresAt: now + PRESET_LIST_SNAPSHOT_TTL_MS,
167
- ids: items.map((item) => item.id),
168
- });
169
- while (snapshots.size > PRESET_LIST_SNAPSHOT_MAX_ENTRIES) {
170
- const oldest = snapshots.keys().next();
171
- if (oldest.done) break;
172
- snapshots.delete(oldest.value);
173
- }
174
- }
175
-
176
- function loadSnapshot(state, key) {
177
- const snapshots = stateSnapshots(state);
178
- const snapshot = snapshots?.get(key);
179
- if (!snapshots || !snapshot) return null;
180
- if (snapshot.expiresAt <= Date.now()) {
181
- snapshots.delete(key);
182
- return null;
183
- }
184
- snapshots.delete(key);
185
- snapshots.set(key, snapshot);
186
- return snapshot.ids;
187
- }
188
-
189
144
  function presetFromSnapshot(state, key, requested) {
190
145
  if (!/^\d+$/u.test(requested)) return { numeric: false, id: null };
191
146
  const index = Number(requested);
192
147
  if (!Number.isSafeInteger(index) || index < 1) {
193
148
  return { numeric: true, error: t('Agent Preset 序号无效,请先执行 /presetlist。') };
194
149
  }
195
- const snapshot = loadSnapshot(state, key);
150
+ const snapshot = LIST_SNAPSHOTS.load(state, key);
196
151
  if (!snapshot) {
197
152
  return { numeric: true, error: t('请先执行 /presetlist,再按列表序号选择 Agent Preset。') };
198
153
  }
@@ -260,7 +215,7 @@ export async function runPresetCommand(text, harness, state, key, options = {})
260
215
  if (!/^\/(?:presetlist|presets)[ \t]*$/iu.test(command)) return commandResult(t(PRESET_LIST_USAGE));
261
216
  try {
262
217
  const current = await settings(harness, requestOptions);
263
- saveSnapshot(state, key, current.agentPresetCatalog.items);
218
+ LIST_SNAPSHOTS.save(state, key, current.agentPresetCatalog.items.map((item) => item.id));
264
219
  return commandResult(formatList(current));
265
220
  } catch (error) {
266
221
  return commandResult(presetErrorMessage(error, 'list'));
@@ -1,5 +1,4 @@
1
1
  import { extractConnectionEvidence, createConnectionDiagnostics, atConnectionStage } from '../shared/connection-error.mjs';
2
- import { randomInt } from 'node:crypto';
3
2
 
4
3
  import { createEditableMessageStream } from '../shared/editable-message-stream.mjs';
5
4
  import { createTextDeliveryBlock } from '../shared/semantic/delivery.mjs';
@@ -357,6 +356,11 @@ function telegramMessageId(value) {
357
356
  return Number.isSafeInteger(value?.message_id) ? String(value.message_id) : null;
358
357
  }
359
358
 
359
+ function telegramMessageUnchanged(error) {
360
+ return error?.providerCode === 400
361
+ && /^(?:Bad Request: )?message is not modified(?::|$)/iu.test(error?.message ?? '');
362
+ }
363
+
360
364
  function telegramFailure(error) {
361
365
  const providerCode = Number(error?.providerCode);
362
366
  const status = Number(error?.status);
@@ -687,6 +691,9 @@ export class TelegramBotClient {
687
691
  messageId: placeholderMessageId,
688
692
  text: chunks[0],
689
693
  signal: this.#signal,
694
+ }).catch((error) => {
695
+ // The final answer may already be visible from the last progress edit.
696
+ if (!telegramMessageUnchanged(error)) throw error;
690
697
  });
691
698
  firstUnsent = 1;
692
699
  } catch (error) {
@@ -814,6 +821,8 @@ export class TelegramBotClient {
814
821
  messageId,
815
822
  richMessage: { markdown: chunks[0].markdown },
816
823
  signal: this.#signal,
824
+ }).catch((error) => {
825
+ if (!telegramMessageUnchanged(error)) throw error;
817
826
  });
818
827
  } catch (error) {
819
828
  const failure = telegramFailure(error);
@@ -869,31 +878,8 @@ export class TelegramBotClient {
869
878
  }
870
879
 
871
880
  async openDeliveryStream(target) {
872
- if (target.chatType === 'private') {
873
- const draftId = randomInt(1, 2_147_483_647);
874
- const updateDraft = async (block) => {
875
- const richMessage = { markdown: toTelegramRichMarkdown(block.text) };
876
- await this.#api.sendRichMessageDraft({
877
- chatId: target.chatId,
878
- draftId,
879
- richMessage,
880
- messageThreadId: target.messageThreadId,
881
- signal: this.#signal,
882
- });
883
- return deliveryResult('telegram-rich-draft', []);
884
- };
885
- const stream = new TelegramDeliveryStream({
886
- update: updateDraft,
887
- finish: (block) => this.#sendRich(target, block),
888
- fail: (block) => this.#sendPlain(target, block.text),
889
- presentation: 'telegram-rich-draft',
890
- keepalive: true,
891
- logger: this.#logger,
892
- });
893
- await stream.update(createTextDeliveryBlock(t('正在处理…'), 'plain'));
894
- return stream;
895
- }
896
-
881
+ // Real messages keep the Android composer available for /stop and /steer;
882
+ // private-chat Rich Drafts can block sending for the whole turn (#276).
897
883
  const placeholder = await this.#api.sendMessage({
898
884
  chatId: target.chatId,
899
885
  text: t('正在处理…'),