@xmanrui/dsh-im 4.20.2 → 4.21.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.
Files changed (61) hide show
  1. package/README.en.md +21 -9
  2. package/README.md +21 -9
  3. package/lib/client.js +116 -21
  4. package/lib/index.js +288 -287
  5. package/package.json +17 -1
  6. package/plugin-src/client/channel-logos.js +18 -5
  7. package/plugin-src/client/channels/imessage/styles.js +1 -1
  8. package/plugin-src/client/channels/slack/styles.js +1 -1
  9. package/plugin-src/client/channels/weixin/connection-error.js +4 -1
  10. package/plugin-src/client/i18n.js +8 -0
  11. package/plugin-src/client/model-setting.js +4 -2
  12. package/plugin-src/client/session-channel-logos.js +1 -2
  13. package/plugin-src/client/styles.js +3 -2
  14. package/plugin-src/host/channels/qq/production.mjs +1 -1
  15. package/plugin-src/host/channels/qq/rpc.mjs +2 -1
  16. package/plugin-src/host/index.mjs +7 -0
  17. package/plugin-src/host/injected-context.mjs +104 -0
  18. package/plugin-src/host/modern-harness-api.mjs +91 -3
  19. package/scripts/verify-model-setting.mjs +4 -1
  20. package/scripts/verify-package.mjs +3 -1
  21. package/src/channels/dingtalk/dingtalk-bridge.mjs +105 -27
  22. package/src/channels/discord/discord-runtime.mjs +4 -1
  23. package/src/channels/feishu/bridge.mjs +44 -3
  24. package/src/channels/feishu/feishu-channel.mjs +1 -1
  25. package/src/channels/qq/qq-bridge.mjs +36 -3
  26. package/src/channels/qq/qq-controller.mjs +11 -5
  27. package/src/channels/qq/state-error.mjs +17 -0
  28. package/src/channels/qq/state-store.mjs +35 -9
  29. package/src/channels/shared/batch-input.mjs +22 -2
  30. package/src/channels/shared/bot-workspace-store.mjs +46 -26
  31. package/src/channels/shared/config-read-error.mjs +24 -0
  32. package/src/channels/shared/context-enhancement.mjs +40 -3
  33. package/src/channels/shared/control-command.mjs +8 -1
  34. package/src/channels/shared/harness-client.mjs +20 -12
  35. package/src/channels/shared/harness-question.mjs +10 -2
  36. package/src/channels/shared/i18n-en/dingtalk.mjs +1 -0
  37. package/src/channels/shared/i18n-en/qq.mjs +3 -0
  38. package/src/channels/shared/i18n-en/shared-a.mjs +11 -0
  39. package/src/channels/shared/i18n-en/shared-c.mjs +2 -0
  40. package/src/channels/shared/i18n-en/weixin.mjs +2 -0
  41. package/src/channels/shared/im-source-guidance.mjs +65 -0
  42. package/src/channels/shared/injected-context.mjs +362 -0
  43. package/src/channels/shared/semantic/artifact.mjs +4 -4
  44. package/src/channels/shared/semantic/reply-reference.mjs +2 -1
  45. package/src/channels/shared/text-harness-bridge.mjs +221 -5
  46. package/src/channels/shared/token-config-store.mjs +23 -8
  47. package/src/channels/shared/workspace-session.mjs +16 -1
  48. package/src/channels/slack/slack-runtime.mjs +3 -1
  49. package/src/channels/telegram/telegram-api.mjs +62 -2
  50. package/src/channels/telegram/telegram-bridge.mjs +66 -1
  51. package/src/channels/telegram/telegram-rich-message.mjs +6 -4
  52. package/src/channels/telegram/telegram-runtime.mjs +128 -6
  53. package/src/channels/wecom/wecom-bridge.mjs +15 -1
  54. package/src/channels/wecom-app/config-store.mjs +3 -1
  55. package/src/channels/wecom-app/wecom-app-bridge.mjs +12 -1
  56. package/src/channels/weixin/config-store.mjs +24 -15
  57. package/src/channels/weixin/connection-error.en.mjs +21 -0
  58. package/src/channels/weixin/connection-error.mjs +21 -6
  59. package/src/channels/weixin/diagnostic-details.mjs +24 -1
  60. package/src/channels/weixin/weixin-api.mjs +52 -13
  61. package/src/channels/weixin/weixin-bridge.mjs +14 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmanrui/dsh-im",
3
- "version": "4.20.2",
3
+ "version": "4.21.1",
4
4
  "description": "把十一种 IM 渠道和公网 AI Office 接入本机 DeepSeek Harness。 Connect eleven IM channels and a public AI Office to a local DeepSeek Harness.",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -64,6 +64,22 @@
64
64
  {
65
65
  "name": "lyzhu86",
66
66
  "url": "https://github.com/lyzhu86"
67
+ },
68
+ {
69
+ "name": "Librazy",
70
+ "url": "https://github.com/Librazy"
71
+ },
72
+ {
73
+ "name": "Johnslee1207",
74
+ "url": "https://github.com/johnslee1207-commits"
75
+ },
76
+ {
77
+ "name": "Dong09",
78
+ "url": "https://github.com/Dong09"
79
+ },
80
+ {
81
+ "name": "wings1848",
82
+ "url": "https://github.com/wings1848"
67
83
  }
68
84
  ],
69
85
  "license": "MIT",
@@ -109,13 +109,26 @@ export function DiscordLogoGlyph({ size } = {}) {
109
109
  export function SlackLogoGlyph({ size } = {}) {
110
110
  return h('svg', {
111
111
  ...dimensions(size),
112
- viewBox: '0 0 24 24',
112
+ viewBox: '0 0 100 100',
113
113
  focusable: 'false',
114
114
  'aria-hidden': 'true',
115
115
  'data-im-channel-logo': 'slack',
116
- }, h('path', {
117
- fill: 'currentColor',
118
- d: 'M6 15a2 2 0 1 1-2-2h2v2Zm1 0a2 2 0 1 1 4 0v5a2 2 0 1 1-4 0v-5Zm2-8a2 2 0 1 1 2-2v2H9Zm0 1a2 2 0 1 1 0 4H4a2 2 0 1 1 0-4h5Zm8 2a2 2 0 1 1 2 2h-2v-2Zm-1 0a2 2 0 1 1-4 0V5a2 2 0 1 1 4 0v5Zm-2 8a2 2 0 1 1-2 2v-2h2Zm0-1a2 2 0 1 1 0-4h5a2 2 0 1 1 0 4h-5Z',
116
+ },
117
+ h('path', {
118
+ fill: '#36C5F0',
119
+ d: 'M36.5 0A10.5 10.5 0 0 0 36.5 21H47V10.5A10.5 10.5 0 0 0 36.5 0ZM10.5 26a10.5 10.5 0 0 0 0 21h26a10.5 10.5 0 0 0 0-21Z',
120
+ }),
121
+ h('path', {
122
+ fill: '#2EB67D',
123
+ d: 'M100 36.5A10.5 10.5 0 0 0 79 36.5V47h10.5A10.5 10.5 0 0 0 100 36.5ZM74 10.5a10.5 10.5 0 0 0-21 0v26a10.5 10.5 0 0 0 21 0Z',
124
+ }),
125
+ h('path', {
126
+ fill: '#ECB22E',
127
+ d: 'M63.5 100a10.5 10.5 0 0 0 0-21H53v10.5A10.5 10.5 0 0 0 63.5 100ZM89.5 74a10.5 10.5 0 0 0 0-21h-26a10.5 10.5 0 0 0 0 21Z',
128
+ }),
129
+ h('path', {
130
+ fill: '#E01E5A',
131
+ d: 'M0 63.5a10.5 10.5 0 0 0 21 0V53H10.5A10.5 10.5 0 0 0 0 63.5ZM26 89.5a10.5 10.5 0 0 0 21 0v-26a10.5 10.5 0 0 0-21 0Z',
119
132
  }));
120
133
  }
121
134
 
@@ -138,7 +151,7 @@ export function IMessageLogoGlyph({ size } = {}) {
138
151
  'aria-hidden': 'true', 'data-im-channel-logo': 'imessage',
139
152
  }, h('path', {
140
153
  fill: 'currentColor',
141
- d: 'M12 2.2c-5.52 0-10 3.75-10 8.38 0 2.63 1.52 4.98 3.9 6.52l-.92 3.43a.5.5 0 0 0 .72.56l3.54-1.96c.87.23 1.8.35 2.76.35 5.52 0 10-3.75 10-8.9S17.52 2.2 12 2.2Zm-3.4 8.2a1.2 1.2 0 1 1 0 2.4 1.2 1.2 0 0 1 0-2.4Zm3.4 0a1.2 1.2 0 1 1 0 2.4 1.2 1.2 0 0 1 0-2.4Zm3.4 0a1.2 1.2 0 1 1 0 2.4 1.2 1.2 0 0 1 0-2.4Z',
154
+ d: 'M12 2.5C5.65 2.5.5 6.66.5 11.8c0 2.76 1.49 5.24 3.85 6.94.28 1.34-.35 2.76-1.6 4.01 2.1-.2 3.87-.86 5.26-1.85 1.25.4 2.6.62 3.99.62 6.35 0 11.5-4.35 11.5-9.72S18.35 2.5 12 2.5Z',
142
155
  }));
143
156
  }
144
157
 
@@ -1,7 +1,7 @@
1
1
  export const IMESSAGE_STYLE_ID = 'xmanrui-dsh-im-imessage-settings';
2
2
  const CSS = String.raw`
3
3
  .dim-pageIMessage { --ddt-accent: #32a852; --ddt-accent-deep: #248a40; --ddt-accent-wash: #edf9f0; }
4
- .dim-avatarIMessage { color: #fff; background: #32a852; }
4
+ .dim-avatarIMessage { color: #fff; background: linear-gradient(180deg, #5bf675 0%, #28d944 50%, #0fbd2c 100%); box-shadow: inset 0 1px 1px rgb(255 255 255 / 45%), 0 1px 3px rgb(31 35 41 / 12%); }
5
5
  .dim-avatarIMessage svg { display: block; }
6
6
  .dim-imessagePermissionPanel { padding: 20px; }
7
7
  .dim-imessagePermissionPanel p { color: var(--dsw-alias-label-secondary, #646a73); }
@@ -2,7 +2,7 @@ export const SLACK_STYLE_ID = 'xmanrui-dsh-im-slack-settings';
2
2
 
3
3
  const CSS = String.raw`
4
4
  .dsl-page { --ddt-accent: #4a154b; --ddt-accent-deep: #321033; --ddt-accent-wash: #f7eef7; }
5
- .dsl-avatar { color: #fff; background: #4a154b; }
5
+ .dsl-avatar { background: linear-gradient(145deg, #fff, #f8fafb); border: 1px solid #e5e6eb; }
6
6
  .dsl-avatar svg { display: block; }
7
7
  .dsl-setup { display: grid; gap: 18px; }
8
8
  .dsl-guide { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: 18px; padding: 16px; border: 1px solid color-mix(in srgb, #4a154b 18%, var(--dsw-alias-border-l2, #e5e6eb)); border-radius: 11px; background: color-mix(in srgb, #4a154b 4%, var(--dsw-alias-bg-layer-1, #fff)); }
@@ -1,6 +1,7 @@
1
1
  import * as React from 'react';
2
2
  import { h, localizeText } from '../../i18n.js';
3
3
  import { normalizeConnectionError } from './api.js';
4
+ import { CONFIG_ISSUE_LABELS } from '../../../../src/channels/weixin/diagnostic-details.mjs';
4
5
 
5
6
  const STAGE_LABELS = {
6
7
  'startup.load': '加载微信配置', 'qr.begin': '申请二维码', 'qr.encode': '生成二维码图片', 'qr.poll': '查询扫码状态',
@@ -27,7 +28,7 @@ export function formatWeixinDiagnostic(value) {
27
28
  return [
28
29
  localizeText(error.message), details.hint ? localizeText(details.hint) : null,
29
30
  localizeText('错误码') + ': ' + error.code,
30
- ...['operation', 'stage', 'reason', 'httpStatus', 'providerCode', 'resource', 'referenceId', 'occurredAt', 'rollback', 'pluginVersion']
31
+ ...['operation', 'stage', 'reason', 'httpStatus', 'providerCode', 'resource', 'file', 'field', 'issue', 'referenceId', 'occurredAt', 'rollback', 'pluginVersion']
31
32
  .filter(field => details[field] !== undefined)
32
33
  .map(field => `${field}: ${details[field]}`),
33
34
  ].filter(Boolean).join('\n');
@@ -48,6 +49,8 @@ export function WeixinConnectionError({ error: value, warning = false }) {
48
49
  const fields = [
49
50
  ['错误码', error.code], ['失败阶段', localizeText(STAGE_LABELS[details.stage] ?? details.stage ?? '')],
50
51
  ['底层原因', details.reason], ['HTTP 状态', details.httpStatus], ['微信返回码', details.providerCode],
52
+ ['配置文件', details.file], ['配置字段', details.field],
53
+ ['校验原因', details.issue ? localizeText(CONFIG_ISSUE_LABELS[details.issue]) : undefined],
51
54
  ['参考号', details.referenceId], ['发生时间', details.occurredAt], ['插件版本', details.pluginVersion],
52
55
  ].filter(([, text]) => text !== undefined && text !== '');
53
56
  return h('div', { className: 'dxw-summary dim-cardSummary', 'data-weixin-diagnostic': true, role: warning ? 'status' : undefined },
@@ -1020,6 +1020,14 @@ function channelName(value) {
1020
1020
  }
1021
1021
 
1022
1022
  function translateDynamic(text) {
1023
+ const configMessage = /^微信配置格式错误:(config\.json|workspaces\.json)。请查看诊断详情,修复后重启 DSH。$/.exec(text);
1024
+ if (configMessage) return EN['微信配置格式错误:{file}。请查看诊断详情,修复后重启 DSH。'].replace('{file}', configMessage[1]);
1025
+ const configHint = /^(.*?)请检查微信渠道数据目录中的 (config\.json|workspaces\.json),修复后重启 DSH;“重新读取”不会重新加载配置。\s*(字段位置中的序号从 0 开始,按文件中的条目顺序计数,不包含真实账号标识。)?$/.exec(text);
1026
+ if (configHint) return [
1027
+ localizeText(configHint[1].trim()),
1028
+ EN['请检查微信渠道数据目录中的 {file},修复后重启 DSH;“重新读取”不会重新加载配置。'].replace('{file}', configHint[2]),
1029
+ configHint[3] ? localizeText(configHint[3]) : '',
1030
+ ].filter(Boolean).join(' ');
1023
1031
  const guidanceLimit = /^增强提示词不得超过 (\d+) 个字符。$/.exec(text);
1024
1032
  if (guidanceLimit) return `Guidance must not exceed ${guidanceLimit[1]} characters.`;
1025
1033
  let match = /^(\d+) \/ (\d+) 在线$/.exec(text);
@@ -141,6 +141,8 @@ export function ModelEditor({ model = null, disabled = false, onSave }) {
141
141
  'aria-label': '查看模型设置说明', 'aria-describedby': `${id}-help` },
142
142
  h('span', { 'aria-hidden': true }, '?')),
143
143
  h('span', { id: `${id}-help`, className: 'dim-presetTooltip', role: 'tooltip' },
144
+ effortHint && !effortUnavailable
145
+ ? h('span', { id: `${id}-hint` }, effortHint, ' ') : null,
144
146
  '只影响新建会话;若当前聊天已有会话,先发送 /new,再发送普通消息生效。'))),
145
147
  saving ? h('span', { className: 'dim-presetStatus', role: 'status' }, '保存中…') : null),
146
148
  row('model', '模型', entry?.name ?? (current ? modelSelectionId(current) : localizeText('跟随默认模型'))),
@@ -163,8 +165,8 @@ export function ModelEditor({ model = null, disabled = false, onSave }) {
163
165
  ...(reasoning?.efforts ?? []).map((level) => option(`effort:${level.id}`, level.name, level.description,
164
166
  effort === level.id, { ...current, reasoningEffort: level.id })),
165
167
  ]) : null,
166
- effortHint ? h('p', { id: `${id}-hint`, className: 'dim-modelHint',
167
- role: effortUnavailable ? 'status' : undefined }, effortHint) : null,
168
+ effortHint && effortUnavailable ? h('p', { id: `${id}-hint`, className: 'dim-modelHint',
169
+ role: 'status' }, effortHint) : null,
168
170
  error || !currentAvailable ? h('p', { className: 'dim-presetError', role: error ? 'alert' : 'status' },
169
171
  error ?? '当前模型已不可用,请选择其他模型或跟随默认模型。') : null);
170
172
  }
@@ -26,8 +26,7 @@ const SVG_NS = 'http://www.w3.org/2000/svg';
26
26
  const installations = new WeakMap();
27
27
 
28
28
  function hasClassPart(element, part) {
29
- const match = new RegExp(`(?:^|[_-])${part}(?:$|[_-])`, 'u');
30
- return [...element.classList].some((token) => match.test(token));
29
+ return [...element.classList].some((token) => token.split(/[_-]/u).includes(part));
31
30
  }
32
31
 
33
32
  function titleOf(row) {
@@ -126,10 +126,11 @@ const CSS = String.raw`
126
126
  .dim-logoOffice svg { width: 23px; height: 23px; }
127
127
  .dim-logoDiscord { color: white; background: #5865f2; }
128
128
  .dim-logoDiscord svg { width: 21px; height: 21px; }
129
- .dim-logoSlack { color: white; background: #4a154b; }
129
+ .dim-logoSlack { background: linear-gradient(145deg, #fff, #f8fafb); border: 1px solid #e5e6eb; }
130
130
  .dim-logoSlack svg { width: 21px; height: 21px; }
131
131
  .dim-logoWhatsapp { color: white; background: #25d366; }
132
- .dim-logoIMessage { color: white; background: #32a852; }
132
+ .dim-logoIMessage { color: white; background: linear-gradient(180deg, #5bf675 0%, #28d944 50%, #0fbd2c 100%); box-shadow: inset 0 1px 1px rgb(255 255 255 / 45%), 0 1px 3px rgb(31 35 41 / 12%); }
133
+ .dim-logoIMessage svg { width: 23px; height: 23px; }
133
134
  .dim-logoWhatsapp svg { width: 21px; height: 21px; }
134
135
  .dim-channelCopy { min-width: 0; display: grid; }
135
136
  .dim-channelCopy strong { overflow: hidden; color: inherit; font-size: 14px; line-height: 20px; font-weight: 680; text-overflow: ellipsis; white-space: nowrap; }
@@ -74,7 +74,7 @@ export async function createProductionController(ctx, config = {}, internals = {
74
74
  const stateFor = async (botId) => {
75
75
  let state = stateStores.get(botId);
76
76
  if (!state) {
77
- state = await new StateStore(statePath(botId)).load();
77
+ state = await new StateStore(statePath(botId), { logger }).load();
78
78
  stateStores.set(botId, state);
79
79
  }
80
80
  return state;
@@ -9,6 +9,7 @@ import { SET_CONTEXT_ENHANCEMENT_ENDPOINT, validContextEnhancementPayload } from
9
9
  import { SET_ACCESS_POLICY_ENDPOINT, validAccessPolicyPayload } from '../shared/access-policy-rpc.mjs';
10
10
  import { resolveRpcAuthority } from '../../rpc-authority.mjs';
11
11
  import { publicWorkspaceError, SET_WORKSPACE_ENDPOINT, validWorkspacePayload } from '../shared/workspace-rpc.mjs';
12
+ import { publicQqStateError } from '../../../../src/channels/qq/state-error.mjs';
12
13
  import { SET_AGENT_PRESET_ENDPOINT, validAgentPresetPayload } from '../shared/agent-preset-rpc.mjs';
13
14
  import { SET_MODEL_ENDPOINT, validModelPayload } from '../shared/model-setting-rpc.mjs';
14
15
 
@@ -232,7 +233,7 @@ export function createQqRpcHandler(controller, { encodeQr = qrDataUrl } = {}) {
232
233
  const workspaceError = publicWorkspaceError(error);
233
234
  return signal?.aborted
234
235
  ? { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } }
235
- : { ok: false, error: workspaceError
236
+ : { ok: false, error: publicQqStateError(error) ?? workspaceError
236
237
  ?? { code: 'qq-operation-failed', message: 'QQ 操作失败,请稍后重试。' } };
237
238
  }
238
239
  };
@@ -17,6 +17,7 @@ import { installDeliveryRpc } from './delivery-rpc.mjs';
17
17
  import { installDeliveryHttp } from './delivery-http.mjs';
18
18
  import { createDeliveryService } from './delivery-service.mjs';
19
19
  import { installInboundTtlRpc } from './inbound-ttl-rpc.mjs';
20
+ import { installInjectedContext } from './injected-context.mjs';
20
21
  import { installSessionSyncCoordinator } from './session-sync-coordinator.mjs';
21
22
  import { installSessionTitlePrefix } from './session-title-prefix.mjs';
22
23
  import { installUpdateRpc } from './update-rpc.mjs';
@@ -41,6 +42,7 @@ export function createImHostPlugin(internals = {}) {
41
42
  const startHostLanguageRpc = internals.installHostLanguageRpc ?? installHostLanguageRpc;
42
43
  const startUpdate = internals.installUpdateRpc ?? installUpdateRpc;
43
44
  const startInboundTtl = internals.installInboundTtlRpc ?? installInboundTtlRpc;
45
+ const startInjectedContext = internals.installInjectedContext ?? installInjectedContext;
44
46
  const startDelivery = internals.installDeliveryRpc ?? installDeliveryRpc;
45
47
  const startDeliveryHttp = internals.installDeliveryHttp ?? installDeliveryHttp;
46
48
  const startSessionSync = internals.installSessionSyncCoordinator
@@ -139,6 +141,11 @@ export function createImHostPlugin(internals = {}) {
139
141
  const logger = typeof ctx?.logger === 'function'
140
142
  ? ctx.logger(name)
141
143
  : (ctx?.logger ?? console);
144
+ try {
145
+ startInjectedContext(ctx, { logger });
146
+ } catch (error) {
147
+ logger.error?.('[dsh-im] failed to activate injected-context pairing; prompts keep the inline prefix', error);
148
+ }
142
149
  if (ctx?.connection?.fetch) {
143
150
  if (hostLanguage) {
144
151
  try {
@@ -0,0 +1,104 @@
1
+ import { getImHostLanguage } from '../../src/channels/shared/i18n.mjs';
2
+ import { rewriteInjectedContextMessages } from '../../src/channels/shared/injected-context.mjs';
3
+ import {
4
+ IM_SOURCE_GUIDANCE_CONTEXT,
5
+ IM_SOURCE_GUIDANCE_ORDER,
6
+ imSourceGuidance,
7
+ } from '../../src/channels/shared/im-source-guidance.mjs';
8
+
9
+ /** Row label for a quoted reply, in the mirrored Host interface language. */
10
+ const REPLY_LABELS = Object.freeze({ zh: '\u5f15\u7528', en: 'Quoted' });
11
+
12
+ /** Row label for a source block that names no readable field of its own. */
13
+ const SOURCE_LABELS = Object.freeze({ zh: '\u6765\u6e90', en: 'Source' });
14
+
15
+ /** Read the current reply-row label; an unknown language falls back to English. */
16
+ function replyLabel() {
17
+ try {
18
+ return getImHostLanguage() === 'en' ? REPLY_LABELS.en : REPLY_LABELS.zh;
19
+ } catch {
20
+ return REPLY_LABELS.en;
21
+ }
22
+ }
23
+
24
+ /** Read the current nameless-source-row label; unknown languages fall back. */
25
+ function sourceLabel() {
26
+ try {
27
+ return getImHostLanguage() === 'en' ? SOURCE_LABELS.en : SOURCE_LABELS.zh;
28
+ } catch {
29
+ return SOURCE_LABELS.en;
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Pair every injected context block with the user message that carried it, and
35
+ * materialize the source guidance as session-level prompt context.
36
+ *
37
+ * The prompt RPC cannot carry a message source, so a channel writes its source
38
+ * block, guidance and quoted reply into the prompt text. `agent/pre-step` is
39
+ * the one point where the claimed messages are already known and nothing is
40
+ * committed yet: splitting the blocks there records each as its own
41
+ * plugin-sourced message next to exactly the user message it belongs to,
42
+ * whatever the inbox interleaved meanwhile. A Host without the Agent loop never
43
+ * dispatches the event, and the prompt then keeps its inline blocks.
44
+ *
45
+ * Guidance is not per message but per conversation, so it is registered as
46
+ * dynamic prompt context instead: the Host appends one durable snapshot per
47
+ * Session and only re-renders it when a channel publishes different guidance.
48
+ * Deployment policy still decides whether such snapshots exist at all, which is
49
+ * why the message-side copy is kept until a channel has published a matching
50
+ * value for that Session.
51
+ *
52
+ * @param ctx - owning Host context.
53
+ * @param options.logger - Host logger for a rewrite that could not be trusted.
54
+ * @param options.registry - guidance registry; tests inject their own.
55
+ * @returns the Cordis disposer, or null when the context cannot listen.
56
+ */
57
+ export function installInjectedContext(ctx, { logger, registry = imSourceGuidance } = {}) {
58
+ if (typeof ctx?.on !== 'function') return null;
59
+
60
+ const startGuidanceContext = (promptCtx) => {
61
+ if (typeof promptCtx?.systemPrompt?.context !== 'function') return null;
62
+ return promptCtx.systemPrompt.context({
63
+ name: IM_SOURCE_GUIDANCE_CONTEXT,
64
+ order: IM_SOURCE_GUIDANCE_ORDER,
65
+ text: (assembly) => registry.get(assembly?.agent?.session?.id) ?? '',
66
+ });
67
+ };
68
+ if (typeof ctx.inject === 'function') {
69
+ // Same gating the outbound artifact tool uses: register once the service
70
+ // exists instead of requiring it at load time.
71
+ ctx.inject(['systemPrompt'], startGuidanceContext);
72
+ } else {
73
+ startGuidanceContext(ctx);
74
+ }
75
+
76
+ const disposeDisposed = ctx.on('agent/disposed', ({ agent }) => {
77
+ const sessionId = agent?.session?.id;
78
+ if (typeof sessionId === 'string') registry.forget(sessionId);
79
+ }, { global: true });
80
+
81
+ const listener = async ({ agent }, next) => {
82
+ const decision = await next();
83
+ if (decision === null || typeof decision !== 'object' || decision.kind !== 'enter') {
84
+ return decision;
85
+ }
86
+ try {
87
+ const rewritten = rewriteInjectedContextMessages(decision.messages, {
88
+ labels: { reply: replyLabel(), source: sourceLabel() },
89
+ ownedGuidance: registry.get(agent?.session?.id),
90
+ });
91
+ return rewritten === null ? decision : { ...decision, messages: rewritten };
92
+ } catch (error) {
93
+ // A failed split must never cost the user their message: the blocks then
94
+ // stay inline, which is the same fallback a Host without Agents uses.
95
+ logger?.warn?.('[dsh-im] unable to split the injected context blocks:', error?.message ?? error);
96
+ return decision;
97
+ }
98
+ };
99
+ const disposePreStep = ctx.on('agent/pre-step', listener, { global: true });
100
+ return () => {
101
+ disposePreStep?.();
102
+ disposeDisposed?.();
103
+ };
104
+ }
@@ -474,13 +474,97 @@ class ModernHarnessApi {
474
474
  #requestQuestion(request, next) {
475
475
  const owner = this.#claimableAgent(request?.agent);
476
476
  if (!owner) return next();
477
- if (request.signal?.aborted) {
477
+ const turnSignal = request.signal;
478
+ if (turnSignal?.aborted) {
478
479
  return Promise.reject(questionError(
479
480
  'ask_user_question was aborted before the user answered', 'ASK_ABORTED',
480
481
  ));
481
482
  }
482
- return new Promise((resolve, reject) => {
483
- const pending = {
483
+ // Give this question a lifetime of its own instead of lending it the enclosing
484
+ // turn's. `dsh-agent-loop` mints one abort controller per turn and every tool
485
+ // call in that turn shares the one signal, and under PTC two consumers of it
486
+ // react to the abort EVENT rather than to `signal.aborted`: `run_code` flips its
487
+ // own program controller from an abort listener (dsh-tools `onOuterAbort`) and
488
+ // the worker code runtime retires the worker the same way
489
+ // (dsh-code-runtime-worker-thread `onAbort`). Retiring the Web card by firing
490
+ // that event therefore also killed the program that was merely waiting for this
491
+ // answer — `code run failed (abort)`. A private controller keeps the retirement
492
+ // below local: real upstream cancellation is forwarded into it, and every
493
+ // listener on this waterfall — the Remote/Web forwarder that renders the card,
494
+ // and our own IM pending — sees a lifetime that ends with this question rather
495
+ // than with the turn.
496
+ //
497
+ // Handing it downstream means replacing `request.signal`, because the Cordis
498
+ // waterfall passes the same request object to every remaining listener and
499
+ // `next()` takes no replacement arguments. The object is a per-call copy made by
500
+ // `UserQuestionService.ask`, and the signal it carried stays reachable here as
501
+ // `turnSignal`, so the caller's own view of the request is untouched.
502
+ const question = new AbortController();
503
+ const forwardTurnAbort = () => question.abort(turnSignal?.reason);
504
+ turnSignal?.addEventListener('abort', forwardTurnAbort, { once: true });
505
+ request.signal = question.signal;
506
+ // Race the IM answer against the host's own answerers (Web/CLI). Claiming the
507
+ // request exclusively used to keep the question out of Web entirely, leaving
508
+ // whoever happened to be looking at that Session with a card they could not
509
+ // answer. Racing keeps both surfaces usable: the first answer wins.
510
+ const im = this.#askThroughIm(request, owner);
511
+ let answeredByIm = false;
512
+ const native = Promise.resolve()
513
+ .then(() => next())
514
+ .catch((error) => {
515
+ // A pure-IM session has no client connected, so the host rejects with
516
+ // NO_PROVIDER — an expected state rather than a failure — and ASK_ABORTED
517
+ // is this adapter's own retire signal below. Any other error is real, so
518
+ // log it instead of swallowing it silently; the question still stays open
519
+ // for IM rather than being failed outright.
520
+ if (error?.code !== 'NO_PROVIDER' && error?.code !== 'ASK_ABORTED') {
521
+ console.warn(
522
+ '[dsh-im] the host user-question answerer failed; waiting for the IM answer:',
523
+ error?.message ?? error,
524
+ );
525
+ }
526
+ return new Promise(() => {});
527
+ });
528
+ return Promise.race([
529
+ native,
530
+ im.promise.then((value) => {
531
+ answeredByIm = true;
532
+ return value;
533
+ }),
534
+ ]).finally(() => {
535
+ // Losing side cleanup. A host answer already settled this request, so drop
536
+ // the IM pending: leaving it registered would let a late press answer a
537
+ // question the model has moved past. Settling is idempotent.
538
+ im.dismiss(new Error('another client answered this question'));
539
+ turnSignal?.removeEventListener('abort', forwardTurnAbort);
540
+ if (answeredByIm) {
541
+ // The host's own answerer (DSH Web) keeps its question card until its own
542
+ // pending settles, and a host-side adapter has no other handle on it. A Web
543
+ // client holds that pending through the lifetime this question handed
544
+ // downstream, so ending that lifetime makes the client drop a card whose
545
+ // answer was already given on IM — otherwise it keeps offering choices for a
546
+ // question the model has moved past.
547
+ //
548
+ // Ending it is `abort`, not a dispatched `abort` event: a listener that
549
+ // reads `signal.aborted` must see the same thing one that listens for the
550
+ // event does. Either way it reaches only this question's controller, so the
551
+ // turn and every other tool call in it — a waiting `run_code` program above
552
+ // all — keep running. The reason carries `ASK_ABORTED` so the host answerer
553
+ // rejecting alongside us stays an expected retirement rather than a logged
554
+ // failure.
555
+ question.abort(questionError('the question was answered on IM', 'ASK_ABORTED'));
556
+ }
557
+ });
558
+ }
559
+
560
+ /**
561
+ * Present one question to IM clients. Returns the answer promise plus a
562
+ * `dismiss` used to retire the pending when the host answers first.
563
+ */
564
+ #askThroughIm(request, owner) {
565
+ let pending = null;
566
+ const promise = new Promise((resolve, reject) => {
567
+ pending = {
484
568
  rpcId: randomUUID(),
485
569
  sessionId: owner.sessionId,
486
570
  questions: request.questions,
@@ -505,6 +589,10 @@ class ModernHarnessApi {
505
589
  request.signal?.addEventListener('abort', onAbort, { once: true });
506
590
  this.#broadcast(this.#questionFrame(pending).payload, pending.rpcId);
507
591
  });
592
+ return {
593
+ promise,
594
+ dismiss: (reason) => pending?.settle('cancelled', reason),
595
+ };
508
596
  }
509
597
 
510
598
  #approvalFrame(pending) {
@@ -22,12 +22,15 @@ const built = await build({
22
22
  define: { 'process.env.NODE_ENV': '"development"' },
23
23
  });
24
24
  const htmlPath = join(output, 'preview.html');
25
+ const scriptOpen = '<scr' + 'ipt>';
26
+ const scriptClose = '</scr' + 'ipt>';
27
+ const scriptBody = built.outputFiles[0].text.replace(/<\/script/giu, '<\\/script');
25
28
  await writeFile(htmlPath, `<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>DSH-IM Model Settings</title>
26
29
  <style>
27
30
  body{margin:0;padding:24px;background:#fff;color:#1f2329;font:14px/20px -apple-system,BlinkMacSystemFont,sans-serif}#app{max-width:760px;margin:auto}#result{max-width:760px;margin:18px auto;font:11px/17px monospace;white-space:pre-wrap;color:#687380}
28
31
  @media(max-width:600px){body{padding:12px}}
29
32
  @media(prefers-color-scheme:dark){:root{color-scheme:dark;--dsw-alias-bg-layer-1:#202125;--dsw-alias-bg-layer-3:#282a30;--dsw-specific-menu:#282a30;--dsw-alias-bg-module-platform:#25272c;--dsw-alias-label-primary:#e9ebef;--dsw-alias-label-secondary:#b1b5bf;--dsw-alias-label-tertiary:#9a9faa;--dsw-alias-border-l1:#34363c;--dsw-alias-border-l2:#41434c;--dsw-alias-border-l3:#7b9ff9;--dsw-alias-interactive-bg-hover:#353842}body{background:#202125;color:#e9ebef}}
30
- </style><body><div id="app"></div><pre id="result">Running browser checks…</pre><script>${built.outputFiles[0].text.replace(/<\/script/giu, '<\\/script')}</script></body></html>`);
33
+ </style><body><div id="app"></div><pre id="result">Running browser checks…</pre>${scriptOpen}${scriptBody}${scriptClose}</body></html>`);
31
34
  for (const [name, size, extra, query] of [
32
35
  ['desktop', '1100,1100', [], ''],
33
36
  ['mobile', '390,1250', [], '?mobile'],
@@ -238,7 +238,9 @@ if (/(?:from\s*|import\s*\(|require\s*\()\s*["'](?:@larksuiteoapi\/node-sdk|@whi
238
238
  if (!/(?:from\s*|import\s*\()\s*["']undici["']/.test(host)) {
239
239
  throw new Error('host bundle must retain undici as an external runtime dependency');
240
240
  }
241
- if ((executable.mode & 0o111) === 0) throw new Error('dsh-im CLI is not executable');
241
+ if (process.platform !== 'win32' && (executable.mode & 0o111) === 0) {
242
+ throw new Error('dsh-im CLI is not executable');
243
+ }
242
244
  if (/private-bot-token|must-be-rolled-back|DEEPSEEK_API_KEY=/.test(client + host)) {
243
245
  throw new Error('built artifacts contain a test or environment secret marker');
244
246
  }