@xmanrui/dsh-im 4.14.0 → 4.15.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.
- package/README.en.md +8 -1
- package/README.md +8 -1
- package/lib/client.js +252 -3
- package/lib/index.js +233 -233
- package/package.json +1 -1
- package/plugin-src/client/channels/feishu/index.js +1 -1
- package/plugin-src/client/i18n.js +1 -1
- package/plugin-src/client/index.js +3 -0
- package/plugin-src/client/session-channel-logos.js +241 -0
- package/plugin-src/host/index.mjs +14 -0
- package/plugin-src/host/session-title-prefix.mjs +122 -0
- package/scripts/verify-session-channel-logos.mjs +59 -0
- package/scripts/verify-session-title-prefix.mjs +149 -0
- package/src/channels/feishu/bridge.mjs +10 -3
- package/src/channels/feishu/plugin-controller.mjs +1 -0
- package/src/channels/feishu/repair-manager.mjs +3 -2
- package/src/channels/shared/i18n-en/feishu.mjs +2 -2
- package/src/channels/shared/session-channel-labels.mjs +26 -0
package/package.json
CHANGED
|
@@ -310,7 +310,7 @@ function QrPane({ provision, now, onRefresh, onCancel, busy }) {
|
|
|
310
310
|
? "使用飞书确认群消息权限"
|
|
311
311
|
: "使用飞书扫码创建机器人"),
|
|
312
312
|
h("p", null, repairing
|
|
313
|
-
? "扫码会更新现有飞书应用,增量补充当前缺少的卡片按钮回调、读取用户消息内图片或文件所需的 im:message:readonly(飞书显示为“获取单聊、群组消息”)、上传机器人图片或文件所需的 im:resource,以及原生命令面板所需的 application:app_slash_command:read / write;不会创建新应用。确认页只显示当前缺少项,完成后此机器人会短暂重连,其他机器人不受影响。"
|
|
313
|
+
? "扫码会更新现有飞书应用,增量补充当前缺少的卡片按钮回调、读取用户消息内图片或文件所需的 im:message:readonly(飞书显示为“获取单聊、群组消息”)、上传机器人图片或文件所需的 im:resource、接收群内其他机器人 @ 当前机器人所需的 im:message.group_at_msg.include_bot:readonly,以及原生命令面板所需的 application:app_slash_command:read / write;不会创建新应用。确认页只显示当前缺少项,完成后此机器人会短暂重连,其他机器人不受影响。"
|
|
314
314
|
: grantingGroupMessages
|
|
315
315
|
? "扫码会更新现有飞书应用,只增量开通“获取群组中所有消息”权限;不会创建新应用。确认后会自动启用“响应所有群消息”,其他机器人不受影响。"
|
|
316
316
|
: "扫码只会新增一个机器人,已接入的机器人会继续正常收发消息。"),
|
|
@@ -459,7 +459,7 @@ const EN = Object.freeze({
|
|
|
459
459
|
'权限配置已提交,正在启用全部消息模式并重连此机器人;此阶段无法取消,其他机器人不会中断。': 'The permission update was submitted. Enabling all-message mode and reconnecting this bot. This stage cannot be cancelled; other bots will not be interrupted.',
|
|
460
460
|
'正在为现有飞书应用申请群消息权限二维码,请稍候。': 'Requesting a group-message permission QR code for the existing Feishu app…',
|
|
461
461
|
'群消息权限没有开通完成': 'Group-message permission was not granted',
|
|
462
|
-
'扫码会更新现有飞书应用,增量补充当前缺少的卡片按钮回调、读取用户消息内图片或文件所需的 im:message:readonly(飞书显示为“获取单聊、群组消息”)、上传机器人图片或文件所需的 im:resource,以及原生命令面板所需的 application:app_slash_command:read / write;不会创建新应用。确认页只显示当前缺少项,完成后此机器人会短暂重连,其他机器人不受影响。': 'Scanning updates the existing Feishu app with the missing card-button callback, im:message:readonly for reading images or files in user messages (shown by Feishu as “Read direct and group messages”), im:resource for uploading images or files sent by the bot, and application:app_slash_command:read / write for the native command panel. It does not create a new app. The confirmation page shows only missing items; this bot reconnects briefly afterward, while other bots are unaffected.',
|
|
462
|
+
'扫码会更新现有飞书应用,增量补充当前缺少的卡片按钮回调、读取用户消息内图片或文件所需的 im:message:readonly(飞书显示为“获取单聊、群组消息”)、上传机器人图片或文件所需的 im:resource、接收群内其他机器人 @ 当前机器人所需的 im:message.group_at_msg.include_bot:readonly,以及原生命令面板所需的 application:app_slash_command:read / write;不会创建新应用。确认页只显示当前缺少项,完成后此机器人会短暂重连,其他机器人不受影响。': 'Scanning updates the existing Feishu app with the missing card-button callback, im:message:readonly for reading images or files in user messages (shown by Feishu as “Read direct and group messages”), im:resource for uploading images or files sent by the bot, im:message.group_at_msg.include_bot:readonly for receiving group messages from other bots that mention this bot, and application:app_slash_command:read / write for the native command panel. It does not create a new app. The confirmation page shows only missing items; this bot reconnects briefly afterward, while other bots are unaffected.',
|
|
463
463
|
'核对现有应用名称,并确认只新增当前缺少的上述配置': 'Review the existing app name and confirm that only the missing items described above are added',
|
|
464
464
|
'保持本页打开,等待权限与回调补全完成': 'Keep this page open until permissions and the callback are complete',
|
|
465
465
|
'取消补全': 'Cancel setup',
|
|
@@ -59,6 +59,7 @@ import {
|
|
|
59
59
|
replacePageLocation,
|
|
60
60
|
} from './loopback-recovery.js';
|
|
61
61
|
import { installImStyles } from './styles.js';
|
|
62
|
+
import { installSessionChannelLogos } from './session-channel-logos.js';
|
|
62
63
|
import { UpdatePanel, UPDATE_RPC_CHANNEL } from './update-panel.js';
|
|
63
64
|
import { WorkspaceDirectoryPickerContext } from './workspace-editor.js';
|
|
64
65
|
|
|
@@ -366,6 +367,8 @@ export function apply(ctx) {
|
|
|
366
367
|
const t = ctx.locale.bind(IM_LOCALE_NAMESPACE);
|
|
367
368
|
setImTranslator(t);
|
|
368
369
|
|
|
370
|
+
ctx.effect(() => installSessionChannelLogos(), 'im-settings: Session channel logos');
|
|
371
|
+
|
|
369
372
|
ctx.effect(() => {
|
|
370
373
|
const disposers = [
|
|
371
374
|
installFeishuStyles(),
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { parseSessionChannelTitle } from '../../src/channels/shared/session-channel-labels.mjs';
|
|
2
|
+
import {
|
|
3
|
+
WeixinLogoGlyph, FeishuLogoGlyph, DingtalkLogoGlyph, WecomLogoGlyph, QqLogoGlyph,
|
|
4
|
+
SlackLogoGlyph, TelegramLogoGlyph, DiscordLogoGlyph, WhatsappLogoGlyph, OfficeLogoGlyph,
|
|
5
|
+
} from './channel-logos.js';
|
|
6
|
+
|
|
7
|
+
const GLYPHS = Object.freeze({
|
|
8
|
+
weixin: [WeixinLogoGlyph, '#07c160'],
|
|
9
|
+
feishu: [FeishuLogoGlyph, '#3370ff'],
|
|
10
|
+
dingtalk: [DingtalkLogoGlyph, '#1677ff'],
|
|
11
|
+
wecom: [WecomLogoGlyph, '#3370ff'],
|
|
12
|
+
qq: [QqLogoGlyph, '#12b7f5'],
|
|
13
|
+
slack: [SlackLogoGlyph, '#e01e5a'],
|
|
14
|
+
telegram: [TelegramLogoGlyph, '#229ed9'],
|
|
15
|
+
discord: [DiscordLogoGlyph, '#5865f2'],
|
|
16
|
+
whatsapp: [WhatsappLogoGlyph, '#25d366'],
|
|
17
|
+
office: [OfficeLogoGlyph, '#f97316'],
|
|
18
|
+
});
|
|
19
|
+
const CHANNEL_ATTR = 'data-dsh-im-session-channel';
|
|
20
|
+
const TITLE_ATTR = 'data-dsh-im-session-text';
|
|
21
|
+
const MARKED = `[${CHANNEL_ATTR}]`;
|
|
22
|
+
const ROW = '[role="treeitem"][aria-selected]';
|
|
23
|
+
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
24
|
+
const installations = new WeakMap();
|
|
25
|
+
|
|
26
|
+
function hasClassPart(element, part) {
|
|
27
|
+
const match = new RegExp(`(?:^|[_-])${part}(?:$|[_-])`, 'u');
|
|
28
|
+
return [...element.classList].some((token) => match.test(token));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function titleOf(row) {
|
|
32
|
+
if (!row.matches(ROW) || row.closest('input, textarea, [contenteditable]:not([contenteditable="false"])')) return null;
|
|
33
|
+
let title;
|
|
34
|
+
if (row.tagName === 'DIV' && hasClassPart(row, 'sessionRow')) {
|
|
35
|
+
title = [...row.children].find((child) => child.tagName === 'SPAN' && hasClassPart(child, 'title'));
|
|
36
|
+
} else if (row.tagName === 'BUTTON' && hasClassPart(row, 'searchResultRow')) {
|
|
37
|
+
const heading = [...row.children].find((child) => hasClassPart(child, 'searchResultHeading'));
|
|
38
|
+
title = heading && [...heading.children].find((child) => child.tagName === 'SPAN' && hasClassPart(child, 'searchResultTitle'));
|
|
39
|
+
}
|
|
40
|
+
// Never cover inline rename inputs, highlighted search markup, or a future
|
|
41
|
+
// row structure that this adapter does not understand.
|
|
42
|
+
return title?.childNodes.length === 1 && title.firstChild.nodeType === 3 ? title : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Serialize the existing, static SVG glyphs without mounting React nodes. */
|
|
46
|
+
function logoUri(document, channel) {
|
|
47
|
+
const [Glyph, color] = GLYPHS[channel];
|
|
48
|
+
const render = (node) => {
|
|
49
|
+
if (!['svg', 'path'].includes(node.type)) throw new Error('Unsupported channel logo element');
|
|
50
|
+
const element = document.createElementNS(SVG_NS, node.type);
|
|
51
|
+
for (const [name, value] of Object.entries(node.props)) {
|
|
52
|
+
if (name === 'children') continue;
|
|
53
|
+
const attribute = name.startsWith('stroke')
|
|
54
|
+
? name.replace(/[A-Z]/gu, (letter) => `-${letter.toLowerCase()}`)
|
|
55
|
+
: name;
|
|
56
|
+
element.setAttribute(attribute, String(value));
|
|
57
|
+
}
|
|
58
|
+
for (const child of [node.props.children].flat().filter(Boolean)) element.appendChild(render(child));
|
|
59
|
+
return element;
|
|
60
|
+
};
|
|
61
|
+
const svg = render(Glyph({ size: 16 }));
|
|
62
|
+
svg.setAttribute('xmlns', SVG_NS);
|
|
63
|
+
svg.setAttribute('color', color);
|
|
64
|
+
return `data:image/svg+xml,${encodeURIComponent(svg.outerHTML)}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function createInstallation(document) {
|
|
68
|
+
const view = document.defaultView;
|
|
69
|
+
const ready = new Set();
|
|
70
|
+
const owned = new Map();
|
|
71
|
+
const queued = new Set();
|
|
72
|
+
const images = [];
|
|
73
|
+
let closed = false;
|
|
74
|
+
let scheduled = false;
|
|
75
|
+
const style = document.createElement('style');
|
|
76
|
+
style.dataset.pluginCss = 'dsh-im-session-channel-logos';
|
|
77
|
+
const uris = Object.fromEntries(Object.keys(GLYPHS).map((channel) => [channel, logoUri(document, channel)]));
|
|
78
|
+
style.textContent = `
|
|
79
|
+
${MARKED}[${TITLE_ATTR}] {
|
|
80
|
+
position: relative;
|
|
81
|
+
-webkit-text-fill-color: transparent;
|
|
82
|
+
text-overflow: clip !important;
|
|
83
|
+
overflow: hidden;
|
|
84
|
+
}
|
|
85
|
+
${MARKED}[${TITLE_ATTR}]::before {
|
|
86
|
+
content: "";
|
|
87
|
+
position: absolute;
|
|
88
|
+
inset-inline-start: 0;
|
|
89
|
+
top: 50%;
|
|
90
|
+
transform: translateY(-50%);
|
|
91
|
+
width: 16px;
|
|
92
|
+
height: 16px;
|
|
93
|
+
background: var(--dsh-im-session-logo) center / contain no-repeat;
|
|
94
|
+
text-indent: 0;
|
|
95
|
+
pointer-events: none;
|
|
96
|
+
}
|
|
97
|
+
${MARKED}[${TITLE_ATTR}]::after {
|
|
98
|
+
content: attr(${TITLE_ATTR}) / "";
|
|
99
|
+
position: absolute;
|
|
100
|
+
inset: 0;
|
|
101
|
+
inset-inline-start: 22px;
|
|
102
|
+
-webkit-text-fill-color: currentColor;
|
|
103
|
+
text-indent: 0;
|
|
104
|
+
overflow: hidden;
|
|
105
|
+
white-space: nowrap;
|
|
106
|
+
text-overflow: ellipsis;
|
|
107
|
+
pointer-events: none;
|
|
108
|
+
}
|
|
109
|
+
${Object.entries(uris).map(([channel, uri]) => `[${CHANNEL_ATTR}="${channel}"] { --dsh-im-session-logo: url("${uri}"); }`).join('\n')}
|
|
110
|
+
`;
|
|
111
|
+
document.head.appendChild(style);
|
|
112
|
+
|
|
113
|
+
const restore = (title) => {
|
|
114
|
+
const previous = owned.get(title);
|
|
115
|
+
if (!previous) return;
|
|
116
|
+
owned.delete(title);
|
|
117
|
+
for (const [attribute, value] of previous) {
|
|
118
|
+
if (value === null) title.removeAttribute(attribute);
|
|
119
|
+
else title.setAttribute(attribute, value);
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
const update = (row) => {
|
|
123
|
+
const title = row.isConnected ? titleOf(row) : null;
|
|
124
|
+
for (const marked of row.querySelectorAll(MARKED)) {
|
|
125
|
+
if (marked !== title) restore(marked);
|
|
126
|
+
}
|
|
127
|
+
if (!title) return;
|
|
128
|
+
const parsed = parseSessionChannelTitle(title.textContent);
|
|
129
|
+
if (!parsed || !ready.has(parsed.channel)) {
|
|
130
|
+
restore(title);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (!owned.has(title)) {
|
|
134
|
+
owned.set(title, [CHANNEL_ATTR, TITLE_ATTR].map((attribute) => [attribute, title.getAttribute(attribute)]));
|
|
135
|
+
}
|
|
136
|
+
// React retains its original text node, content, row, and handlers. Only
|
|
137
|
+
// plugin-owned attributes drive the visual replacement via pseudo-elements.
|
|
138
|
+
title.setAttribute(TITLE_ATTR, parsed.title);
|
|
139
|
+
title.setAttribute(CHANNEL_ATTR, parsed.channel);
|
|
140
|
+
};
|
|
141
|
+
const schedule = () => {
|
|
142
|
+
if (closed || scheduled || !queued.size) return;
|
|
143
|
+
scheduled = true;
|
|
144
|
+
queueMicrotask(() => {
|
|
145
|
+
scheduled = false;
|
|
146
|
+
if (closed) return;
|
|
147
|
+
const rows = [...queued];
|
|
148
|
+
queued.clear();
|
|
149
|
+
for (const row of rows) {
|
|
150
|
+
try {
|
|
151
|
+
update(row);
|
|
152
|
+
} catch {
|
|
153
|
+
for (const marked of row.querySelectorAll(MARKED)) restore(marked);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
};
|
|
158
|
+
const collect = (node, descendants = false) => {
|
|
159
|
+
const element = node.nodeType === 1 ? node : node.parentElement;
|
|
160
|
+
if (!element) return;
|
|
161
|
+
const row = element.closest(ROW);
|
|
162
|
+
if (row) queued.add(row);
|
|
163
|
+
if (descendants) {
|
|
164
|
+
for (const child of element.querySelectorAll(ROW)) queued.add(child);
|
|
165
|
+
// Also restore an already decorated title moved outside a recognized row.
|
|
166
|
+
const marked = [...element.querySelectorAll(MARKED)];
|
|
167
|
+
if (owned.has(element)) marked.push(element);
|
|
168
|
+
for (const title of marked) {
|
|
169
|
+
const owner = title.closest(ROW);
|
|
170
|
+
if (owner) queued.add(owner);
|
|
171
|
+
else restore(title);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
schedule();
|
|
175
|
+
};
|
|
176
|
+
const observer = new view.MutationObserver((records) => {
|
|
177
|
+
if (closed) return;
|
|
178
|
+
for (const record of records) {
|
|
179
|
+
collect(record.target, record.type === 'attributes');
|
|
180
|
+
if (record.type !== 'childList') continue;
|
|
181
|
+
for (const node of record.addedNodes) collect(node, true);
|
|
182
|
+
for (const node of record.removedNodes) {
|
|
183
|
+
if (node.nodeType !== 1 || node.isConnected) continue;
|
|
184
|
+
if (owned.has(node)) restore(node);
|
|
185
|
+
for (const title of node.querySelectorAll(MARKED)) restore(title);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
observer.observe(document.body, {
|
|
190
|
+
subtree: true, childList: true, characterData: true,
|
|
191
|
+
attributes: true, attributeFilter: ['class', 'role', 'aria-selected', 'contenteditable'],
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// A CSP or an image-loading failure leaves that channel's readable text
|
|
195
|
+
// prefix in place. No title is hidden until its actual SVG has loaded.
|
|
196
|
+
for (const [channel, uri] of Object.entries(uris)) {
|
|
197
|
+
const image = new view.Image();
|
|
198
|
+
images.push(image);
|
|
199
|
+
image.onload = () => {
|
|
200
|
+
if (closed) return;
|
|
201
|
+
ready.add(channel);
|
|
202
|
+
collect(document.body, true);
|
|
203
|
+
};
|
|
204
|
+
image.src = uri;
|
|
205
|
+
}
|
|
206
|
+
return () => {
|
|
207
|
+
closed = true;
|
|
208
|
+
observer.disconnect();
|
|
209
|
+
queued.clear();
|
|
210
|
+
for (const image of images) image.onload = null;
|
|
211
|
+
for (const title of [...owned.keys()]) restore(title);
|
|
212
|
+
style.remove();
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Browser-only enhancement; unknown/unsupported surfaces keep text prefixes. */
|
|
217
|
+
export function installSessionChannelLogos(document = globalThis.document) {
|
|
218
|
+
const view = document?.defaultView;
|
|
219
|
+
if (!document?.body || !document?.head || !view?.MutationObserver || !view?.Image
|
|
220
|
+
|| !view.CSS?.supports?.('content', `attr(${TITLE_ATTR}) / ""`)
|
|
221
|
+
|| !view.CSS.supports('-webkit-text-fill-color', 'transparent')) return () => {};
|
|
222
|
+
let entry = installations.get(document);
|
|
223
|
+
if (!entry) {
|
|
224
|
+
try {
|
|
225
|
+
entry = { references: 0, close: createInstallation(document) };
|
|
226
|
+
} catch {
|
|
227
|
+
return () => {};
|
|
228
|
+
}
|
|
229
|
+
installations.set(document, entry);
|
|
230
|
+
}
|
|
231
|
+
entry.references += 1;
|
|
232
|
+
let released = false;
|
|
233
|
+
return () => {
|
|
234
|
+
if (released) return;
|
|
235
|
+
released = true;
|
|
236
|
+
entry.references -= 1;
|
|
237
|
+
if (entry.references > 0) return;
|
|
238
|
+
entry.close();
|
|
239
|
+
installations.delete(document);
|
|
240
|
+
};
|
|
241
|
+
}
|
|
@@ -15,6 +15,7 @@ import { installDeliveryHttp } from './delivery-http.mjs';
|
|
|
15
15
|
import { createDeliveryService } from './delivery-service.mjs';
|
|
16
16
|
import { installInboundTtlRpc } from './inbound-ttl-rpc.mjs';
|
|
17
17
|
import { installSessionSyncCoordinator } from './session-sync-coordinator.mjs';
|
|
18
|
+
import { installSessionTitlePrefix } from './session-title-prefix.mjs';
|
|
18
19
|
import { installUpdateRpc } from './update-rpc.mjs';
|
|
19
20
|
|
|
20
21
|
export const name = 'dsh-im-host';
|
|
@@ -103,6 +104,19 @@ export function createImHostPlugin(internals = {}) {
|
|
|
103
104
|
|
|
104
105
|
async function activateChannels(ctx, config, deliveryService) {
|
|
105
106
|
setImHostLanguage(config.language ?? process.env.DSH_IM_LANGUAGE);
|
|
107
|
+
const startTitlePrefix = (titleCtx) => {
|
|
108
|
+
// The installer owns its cleanup through ctx.effect(). Cordis startup
|
|
109
|
+
// callbacks must not return its controller object as an effect.
|
|
110
|
+
installSessionTitlePrefix(titleCtx, {
|
|
111
|
+
logger: typeof titleCtx?.logger === 'function'
|
|
112
|
+
? titleCtx.logger('dsh-im:session-title') : (titleCtx?.logger ?? console),
|
|
113
|
+
});
|
|
114
|
+
};
|
|
115
|
+
if (typeof ctx?.inject === 'function') {
|
|
116
|
+
ctx.inject(['sessions'], startTitlePrefix);
|
|
117
|
+
} else if (ctx?.sessions && typeof ctx.on === 'function') {
|
|
118
|
+
startTitlePrefix(ctx);
|
|
119
|
+
}
|
|
106
120
|
if (typeof ctx?.inject === 'function') {
|
|
107
121
|
ctx.inject(['tools', 'systemPrompt'], (artifactCtx) => {
|
|
108
122
|
installOutboundArtifactTool(artifactCtx);
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { getImHostLanguage } from '../../src/channels/shared/i18n.mjs';
|
|
2
|
+
import { SESSION_CHANNEL_LABELS as CHANNEL_LABELS } from '../../src/channels/shared/session-channel-labels.mjs';
|
|
3
|
+
const UUID = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}';
|
|
4
|
+
// HarnessClient uses these ids for prompts and steering, independently of
|
|
5
|
+
// context enhancement. Read the durable source, never the message's text.
|
|
6
|
+
const IM_RPC_ID = new RegExp(`^(${Object.keys(CHANNEL_LABELS).join('|')})-(?:steer-)?${UUID}$`, 'u');
|
|
7
|
+
const LEGACY_SESSION_ID = new RegExp(`^(${Object.keys(CHANNEL_LABELS).join('|')})-.+`, 'u');
|
|
8
|
+
|
|
9
|
+
function eventsOf(session) {
|
|
10
|
+
const events = typeof session?.snapshotEvents === 'function'
|
|
11
|
+
? session.snapshotEvents()
|
|
12
|
+
: session?.events;
|
|
13
|
+
return Array.isArray(events) ? events : [];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function channelOf(session, events) {
|
|
17
|
+
// A Web-created fork must not acquire a channel just from its parent's
|
|
18
|
+
// messages. Both supported Session implementations expose this boundary.
|
|
19
|
+
const inherited = session.inheritedEventCount;
|
|
20
|
+
if (session.header?.parentSession && !Number.isSafeInteger(inherited)) return null;
|
|
21
|
+
for (const event of events) {
|
|
22
|
+
if (Number.isSafeInteger(inherited) && event.seq < inherited) continue;
|
|
23
|
+
if (event.type !== 'user/message' || event.data?.source?.kind !== 'user') continue;
|
|
24
|
+
const channel = IM_RPC_ID.exec(event.data.source.rpcId)?.[1];
|
|
25
|
+
if (channel) return channel;
|
|
26
|
+
}
|
|
27
|
+
if (session.header?.parentSession) return null;
|
|
28
|
+
return LEGACY_SESSION_ID.exec(session.id ?? session.sessionId)?.[1] ?? null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function withSessionChannelPrefix(title, channel, language = getImHostLanguage()) {
|
|
32
|
+
const labels = CHANNEL_LABELS[channel];
|
|
33
|
+
if (!labels || typeof title !== 'string' || !title.trim()) return title;
|
|
34
|
+
// Keep the original title intact, including Unicode graphemes. The Host
|
|
35
|
+
// has already normalized and capped its generated text before this point.
|
|
36
|
+
if (labels.some((label) => title.startsWith(`${label} · `))) return title;
|
|
37
|
+
return `${labels[language === 'en' ? 1 : 0]} · ${title}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function validTitleData(data) {
|
|
41
|
+
if (typeof data?.title !== 'string' || !data.title.trim()
|
|
42
|
+
|| !Array.isArray(data.messageSeqs)) return false;
|
|
43
|
+
if (data.source?.kind === 'user') return data.messageSeqs.length === 0;
|
|
44
|
+
if (!['fallback', 'provider'].includes(data.source?.kind)
|
|
45
|
+
|| data.messageSeqs.length === 0
|
|
46
|
+
|| data.messageSeqs.some((seq) => !Number.isSafeInteger(seq) || seq < 0)) return false;
|
|
47
|
+
return data.source.kind !== 'provider'
|
|
48
|
+
|| (typeof data.source.provider === 'string' && Boolean(data.source.provider));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Decorate accepted titles without session.rename(): rename pins them as
|
|
53
|
+
* user-owned and supersedes automatic generation. Appending a log-only title
|
|
54
|
+
* with the same source and citations preserves the Host's scheduling and
|
|
55
|
+
* explicit-rename semantics. No provider, prompt, or stored event is changed.
|
|
56
|
+
*/
|
|
57
|
+
export function installSessionTitlePrefix(ctx, { logger = console } = {}) {
|
|
58
|
+
if (typeof ctx?.on !== 'function' || typeof ctx?.sessions?.get !== 'function'
|
|
59
|
+
|| typeof ctx?.sessions?.list !== 'function') {
|
|
60
|
+
throw new TypeError('Session title prefixes require Host sessions and events');
|
|
61
|
+
}
|
|
62
|
+
const pending = new Map();
|
|
63
|
+
let closed = false;
|
|
64
|
+
|
|
65
|
+
const decorate = (session) => {
|
|
66
|
+
if (closed || ctx.sessions.get(session.id ?? session.sessionId) !== session
|
|
67
|
+
|| typeof session.append !== 'function') return;
|
|
68
|
+
const events = eventsOf(session);
|
|
69
|
+
const channel = channelOf(session, events);
|
|
70
|
+
if (!channel) return;
|
|
71
|
+
const latest = events.findLast((event) => event.type === 'session/title');
|
|
72
|
+
if (!validTitleData(latest?.data)) return;
|
|
73
|
+
const title = withSessionChannelPrefix(latest.data.title, channel);
|
|
74
|
+
if (title === latest.data.title) return;
|
|
75
|
+
// There is no await between the latest-title read and this append. A
|
|
76
|
+
// queued old result can therefore never replace a newer/manual title.
|
|
77
|
+
session.append('session/title', {
|
|
78
|
+
...structuredClone(latest.data),
|
|
79
|
+
title,
|
|
80
|
+
});
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const enqueue = (session) => {
|
|
84
|
+
if (closed || pending.has(session)) return;
|
|
85
|
+
// Leave the current append/notification stack before writing another
|
|
86
|
+
// event, so persistence and client projections observe monotonically
|
|
87
|
+
// ordered events. Read the newest title when the microtask runs.
|
|
88
|
+
const task = Promise.resolve().then(() => {
|
|
89
|
+
pending.delete(session);
|
|
90
|
+
decorate(session);
|
|
91
|
+
}).catch((error) => {
|
|
92
|
+
logger.warn?.('[dsh-im] unable to add Session channel prefix:', error?.message ?? error);
|
|
93
|
+
}).finally(() => {
|
|
94
|
+
if (pending.get(session) === task) pending.delete(session);
|
|
95
|
+
});
|
|
96
|
+
pending.set(session, task);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const disposeEvent = ctx.on('session/event', (session, event) => {
|
|
100
|
+
if (event?.type === 'session/title'
|
|
101
|
+
|| (event?.type === 'user/message' && IM_RPC_ID.test(event.data?.source?.rpcId))) {
|
|
102
|
+
enqueue(session);
|
|
103
|
+
}
|
|
104
|
+
}, { global: true });
|
|
105
|
+
const disposeCreated = ctx.on('session/created', enqueue, { global: true });
|
|
106
|
+
const close = () => {
|
|
107
|
+
if (closed) return;
|
|
108
|
+
closed = true;
|
|
109
|
+
disposeEvent?.();
|
|
110
|
+
disposeCreated?.();
|
|
111
|
+
};
|
|
112
|
+
if (typeof ctx.effect === 'function') {
|
|
113
|
+
ctx.effect(() => close, 'dsh-im: Session title channel prefixes');
|
|
114
|
+
}
|
|
115
|
+
for (const session of ctx.sessions.list()) enqueue(session);
|
|
116
|
+
return Object.freeze({
|
|
117
|
+
close,
|
|
118
|
+
async whenIdle() {
|
|
119
|
+
while (pending.size) await Promise.allSettled([...pending.values()]);
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Real browser/React verification, with no running DSH Host or IM accounts.
|
|
2
|
+
// CHROME_PATH overrides the browser executable. An optional argument is the output directory.
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
5
|
+
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { resolve, join } from 'node:path';
|
|
8
|
+
import { promisify } from 'node:util';
|
|
9
|
+
import { pathToFileURL } from 'node:url';
|
|
10
|
+
import { build } from 'esbuild';
|
|
11
|
+
|
|
12
|
+
const args = process.argv.slice(2);
|
|
13
|
+
const prepareOnly = args.includes('--prepare-only');
|
|
14
|
+
const outputArg = args.find((argument) => argument !== '--prepare-only');
|
|
15
|
+
const browser = process.env.CHROME_PATH ?? [
|
|
16
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
17
|
+
'/usr/bin/chromium', '/usr/bin/chromium-browser', '/usr/bin/google-chrome',
|
|
18
|
+
].find(existsSync);
|
|
19
|
+
if (!browser && !prepareOnly) throw new Error('Set CHROME_PATH to a Chromium/Chrome executable.');
|
|
20
|
+
const output = outputArg ? resolve(outputArg) : await mkdtemp(join(tmpdir(), 'dsh-im-session-logos-'));
|
|
21
|
+
await mkdir(output, { recursive: true });
|
|
22
|
+
const built = await build({
|
|
23
|
+
entryPoints: [resolve(import.meta.dirname, '../test/browser/session-channel-logos.fixture.js')],
|
|
24
|
+
bundle: true, write: false, format: 'iife', platform: 'browser', target: 'chrome100',
|
|
25
|
+
define: { 'process.env.NODE_ENV': '"development"' },
|
|
26
|
+
});
|
|
27
|
+
const html = `<!doctype html><html lang="zh-CN"><meta charset="utf-8"><title>DSH-IM Session Logo Preview</title>
|
|
28
|
+
<style>
|
|
29
|
+
*{box-sizing:border-box}body{margin:0;background:#f8f9fb;color:#202329;font:14px/20px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
|
|
30
|
+
.preview{display:flex;min-height:720px}aside{width:350px;flex:none;padding:24px 12px;background:#f0f2f5;border-right:1px solid #e4e6eb}header{padding:0 12px 26px}header strong{display:block;font-size:17px;line-height:26px}small{display:block;color:#7a818e;font-size:11px}.workspace{padding:8px 12px;color:#6f7684;font-size:12px}
|
|
31
|
+
._sessionRow_dsh_104{height:32px;display:flex;align-items:center;gap:0;border-radius:8px;padding:0 8px;cursor:pointer;user-select:none;color:inherit}._sessionRow_dsh_104:hover,._sessionRow_dsh_104.selected{background:#e0e5ec}._title_dsh_170{flex:1;min-width:0;margin:0 6px 0 4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:14px;line-height:20px}.status{width:16px;flex:none;color:#77a891;text-align:center}.time{font-size:10px;color:#8d95a1}button{font:inherit;cursor:pointer}aside button{border:0;background:none;color:#707a89;width:22px;height:24px}
|
|
32
|
+
main{flex:1;padding:55px 44px}.eyebrow{font-size:10px;font-weight:650;letter-spacing:.13em;color:#788292}h1{font-size:30px;line-height:40px;font-weight:650;margin:12px 0}main>p{color:#727c8a}.message{margin-top:35px;padding:18px 22px;border:1px solid #e3e7ed;border-radius:12px;background:#fff}.message strong{font-size:12px}.message p{color:#626d7c}h2{font-size:13px;font-weight:550;margin:26px 0 8px}._searchResultRow_dsh_25{display:flex;flex-direction:column;align-items:stretch;width:100%;border:1px solid #e3e7ed;border-radius:8px;padding:10px;background:white;text-align:left}._searchResultHeading_dsh_305{display:flex;min-width:0}._searchResultTitle_dsh_313{flex:1;min-width:0;font-size:14px;line-height:20px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.search small{margin-top:5px}.note{font-size:11px;margin-top:32px}#result{margin:0;padding:14px 24px;border-top:1px solid #e4e6eb;background:#eef6f1;color:#357759;font:10px/16px ui-monospace,monospace}body[data-result=failed] #result{color:#a22;background:#fee}
|
|
33
|
+
@media(prefers-color-scheme:dark){body{background:#1b1e23;color:#e4e7eb}aside{background:#22262d;border-color:#333941}._sessionRow_dsh_104:hover,._sessionRow_dsh_104.selected{background:#353d49}.message,._searchResultRow_dsh_25{background:#252a32;border-color:#39404b;color:inherit}.message p{color:#c1c8d1}#result{background:#23382e;color:#a2d0b2;border-color:#39404b}}
|
|
34
|
+
</style><body><div id="app"></div><pre id="result">Running browser checks…</pre>
|
|
35
|
+
<script>${built.outputFiles[0].text.replace(/<\/script/giu, '<\\/script')}</script></body></html>`;
|
|
36
|
+
const htmlPath = join(output, 'preview.html');
|
|
37
|
+
await writeFile(htmlPath, html
|
|
38
|
+
.replace('._sessionRow_dsh_104{', '._dsh_sessionRow,._sessionRow_dsh_104{')
|
|
39
|
+
.replaceAll('._sessionRow_dsh_104:hover,._sessionRow_dsh_104.selected{', '._dsh_sessionRow:hover,._dsh_sessionRow.selected,._sessionRow_dsh_104:hover,._sessionRow_dsh_104.selected{')
|
|
40
|
+
.replace('._title_dsh_170{', '._dsh_title,._title_dsh_170{')
|
|
41
|
+
.replace('._searchResultTitle_dsh_313{flex:1;', '._searchResultTitle_dsh_313{flex:0 auto;'));
|
|
42
|
+
if (prepareOnly) {
|
|
43
|
+
console.log(`Prepared browser fixture: ${htmlPath}`);
|
|
44
|
+
process.exit(0);
|
|
45
|
+
}
|
|
46
|
+
const profile = await mkdtemp(join(tmpdir(), 'dsh-im-logo-browser-'));
|
|
47
|
+
const screenshot = join(output, 'preview.png');
|
|
48
|
+
const { stdout, stderr } = await promisify(execFile)(browser, [
|
|
49
|
+
'--headless', '--disable-gpu', '--no-sandbox', '--hide-scrollbars',
|
|
50
|
+
`--user-data-dir=${profile}`, '--no-first-run', '--no-default-browser-check',
|
|
51
|
+
'--window-size=1100,800', '--force-device-scale-factor=1', '--virtual-time-budget=8000',
|
|
52
|
+
`--screenshot=${screenshot}`, '--dump-dom', pathToFileURL(htmlPath).href,
|
|
53
|
+
], { maxBuffer: 8 * 1024 * 1024, timeout: 30_000 }).finally(() => rm(profile, { recursive: true, force: true }));
|
|
54
|
+
await writeFile(join(output, 'browser.log'), stderr);
|
|
55
|
+
await writeFile(join(output, 'result.html'), stdout);
|
|
56
|
+
const result = stdout.match(/<pre id="result">([\s\S]*?)<\/pre>/u)?.[1] ?? 'No browser result';
|
|
57
|
+
if (!stdout.includes('data-result="passed"')) throw new Error(result);
|
|
58
|
+
console.log(result);
|
|
59
|
+
console.log(`Preview: ${screenshot}`);
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// Exercise the plugin against an actual built Harness checkout, without an
|
|
2
|
+
// LLM request or a running Host. Run:
|
|
3
|
+
// node scripts/verify-session-title-prefix.mjs /path/to/dsh [all-prompts|first-prompt]
|
|
4
|
+
import assert from 'node:assert/strict';
|
|
5
|
+
import { createRequire } from 'node:module';
|
|
6
|
+
import { resolve } from 'node:path';
|
|
7
|
+
import { pathToFileURL } from 'node:url';
|
|
8
|
+
|
|
9
|
+
import { createImHostPlugin } from '../plugin-src/host/index.mjs';
|
|
10
|
+
|
|
11
|
+
if (!process.argv[2]) throw new Error('Pass the path of a built DSH checkout.');
|
|
12
|
+
const root = resolve(process.argv[2]);
|
|
13
|
+
const automatic = process.argv[3] ?? 'all-prompts';
|
|
14
|
+
assert.ok(['all-prompts', 'first-prompt'].includes(automatic));
|
|
15
|
+
const fromHarness = createRequire(resolve(root, 'packages/session/session-title/package.json'));
|
|
16
|
+
const load = (name) => import(pathToFileURL(fromHarness.resolve(name)).href);
|
|
17
|
+
const { Context } = await load('@deepseek-ai/cordis');
|
|
18
|
+
const { default: SessionStore, SessionId } = await load('@deepseek-ai/dsh-session');
|
|
19
|
+
const { default: SessionProjectionRegistry } = await load('@deepseek-ai/dsh-session-projection');
|
|
20
|
+
const { default: SessionTitleService, SessionTitleProviderId } = await load('@deepseek-ai/dsh-session-title');
|
|
21
|
+
const { createUserMessage } = await load('@deepseek-ai/dsh-llm');
|
|
22
|
+
|
|
23
|
+
const ctx = new Context();
|
|
24
|
+
const fibers = [];
|
|
25
|
+
for (const [plugin, config] of [
|
|
26
|
+
[SessionStore],
|
|
27
|
+
[SessionProjectionRegistry],
|
|
28
|
+
[SessionTitleService, { fallbackMaxWords: 10, fallbackMaxBytes: 60, maxTitleBytes: 60 }],
|
|
29
|
+
]) {
|
|
30
|
+
const fiber = ctx.plugin(plugin, config);
|
|
31
|
+
fibers.push(fiber);
|
|
32
|
+
await fiber.await();
|
|
33
|
+
}
|
|
34
|
+
// Use the complete Host activation path: Cordis treats returned controller
|
|
35
|
+
// objects as invalid effects and disposes their listeners after startup.
|
|
36
|
+
for (const [name, value] of Object.entries({
|
|
37
|
+
connection: { rpc: { handle: () => () => {} } }, credentials: {},
|
|
38
|
+
typertGateway: { stream() {} }, sessionController: {}, workspaceController: {},
|
|
39
|
+
})) ctx.provide(name, value);
|
|
40
|
+
const internals = Object.fromEntries(
|
|
41
|
+
['Feishu', 'Weixin', 'Dingtalk', 'Wecom', 'Qq', 'Slack', 'Telegram', 'Discord', 'Whatsapp', 'Office']
|
|
42
|
+
.map((channel) => [`apply${channel}`, async () => {}]),
|
|
43
|
+
);
|
|
44
|
+
Object.assign(internals, {
|
|
45
|
+
installUpdateRpc: () => {}, installInboundTtlRpc: () => {},
|
|
46
|
+
installDeliveryRpc: () => {}, installSessionSyncCoordinator: () => {},
|
|
47
|
+
});
|
|
48
|
+
const host = ctx.plugin(createImHostPlugin(internals));
|
|
49
|
+
fibers.push(host);
|
|
50
|
+
await host.await();
|
|
51
|
+
const settle = async () => {
|
|
52
|
+
await new Promise((done) => setTimeout(done, 0));
|
|
53
|
+
};
|
|
54
|
+
await settle();
|
|
55
|
+
const prompt = (session, text, channel = 'weixin') => session.append('user/message', createUserMessage({
|
|
56
|
+
content: [{ type: 'text', text }],
|
|
57
|
+
source: { kind: 'user', rpcId: `${channel}-00000000-0000-4000-8000-000000000000` },
|
|
58
|
+
}), { surfaceOp: 'append' });
|
|
59
|
+
const request = (session) => session.append('request/header', {
|
|
60
|
+
header: { config: { provider: 'test-provider', model: 'test-model' } }, reason: 'change',
|
|
61
|
+
});
|
|
62
|
+
let calls = 0;
|
|
63
|
+
let release;
|
|
64
|
+
let aborted = false;
|
|
65
|
+
ctx.sessionTitle.register({
|
|
66
|
+
id: SessionTitleProviderId('test-title-provider'),
|
|
67
|
+
automatic,
|
|
68
|
+
async generate(input) {
|
|
69
|
+
calls += 1;
|
|
70
|
+
input.signal.addEventListener('abort', () => { aborted = true; });
|
|
71
|
+
if (calls === 1) await new Promise((done) => { release = done; });
|
|
72
|
+
return { title: `自动标题 ${calls}`, messageSeqs: input.messages.map((message) => message.seq) };
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
const session = ctx.sessions.create(SessionId('ordinary-host-generated-id'));
|
|
76
|
+
session.append('turn/start', { turn: 1 });
|
|
77
|
+
prompt(session, '第一次消息');
|
|
78
|
+
request(session);
|
|
79
|
+
await settle();
|
|
80
|
+
assert.equal(ctx.sessionTitle.get(session).title, '微信 · 第一次消息');
|
|
81
|
+
assert.equal(ctx.sessionTitle.get(session).source.kind, 'fallback');
|
|
82
|
+
assert.equal(calls, 1);
|
|
83
|
+
assert.equal(aborted, false, 'adding the fallback prefix must not abort the active title provider');
|
|
84
|
+
release();
|
|
85
|
+
await settle();
|
|
86
|
+
assert.equal(ctx.sessionTitle.get(session).title, '微信 · 自动标题 1');
|
|
87
|
+
assert.equal(ctx.sessionTitle.get(session).source.kind, 'provider');
|
|
88
|
+
assert.equal(ctx.sessionTitle.get(session).source.provider, 'test-title-provider');
|
|
89
|
+
|
|
90
|
+
prompt(session, '第二次消息');
|
|
91
|
+
request(session);
|
|
92
|
+
await settle();
|
|
93
|
+
const automaticCalls = automatic === 'all-prompts' ? 2 : 1;
|
|
94
|
+
assert.equal(calls, automaticCalls, 'the original automatic generation cadence must be preserved');
|
|
95
|
+
assert.equal(ctx.sessionTitle.get(session).title, `微信 · 自动标题 ${automaticCalls}`);
|
|
96
|
+
assert.equal(ctx.sessionProjections.stateOf(session, 'title'), `微信 · 自动标题 ${automaticCalls}`);
|
|
97
|
+
assert.equal(aborted, false);
|
|
98
|
+
|
|
99
|
+
ctx.sessionTitle.rename(session, '自定义标题');
|
|
100
|
+
await settle();
|
|
101
|
+
assert.equal(ctx.sessionTitle.get(session).title, '微信 · 自定义标题');
|
|
102
|
+
assert.equal(ctx.sessionTitle.get(session).source.kind, 'user');
|
|
103
|
+
prompt(session, '手动命名后的消息');
|
|
104
|
+
request(session);
|
|
105
|
+
await settle();
|
|
106
|
+
assert.equal(calls, automaticCalls, 'a real user rename must retain the normal pin');
|
|
107
|
+
await ctx.sessionTitle.refresh(session);
|
|
108
|
+
await settle();
|
|
109
|
+
assert.equal(calls, automaticCalls + 1);
|
|
110
|
+
assert.equal(ctx.sessionTitle.get(session).title, `微信 · 自动标题 ${automaticCalls + 1}`);
|
|
111
|
+
assert.equal(ctx.sessionTitle.get(session).source.kind, 'provider');
|
|
112
|
+
|
|
113
|
+
const ordinary = ctx.sessions.create(SessionId('web-created'));
|
|
114
|
+
ordinary.append('turn/start', { turn: 1 });
|
|
115
|
+
prompt(ordinary, '网页消息', 'web');
|
|
116
|
+
await settle();
|
|
117
|
+
assert.equal(ctx.sessionTitle.get(ordinary).title, '网页消息');
|
|
118
|
+
|
|
119
|
+
// The production Feishu path can create its initial title before the first
|
|
120
|
+
// prompt. The listener must still be alive after the Host has finished loading.
|
|
121
|
+
let feishu;
|
|
122
|
+
const creator = ctx.plugin({
|
|
123
|
+
name: 'feishu-session-regression', inject: ['sessions'],
|
|
124
|
+
apply(scoped) {
|
|
125
|
+
feishu = scoped.sessions.create(SessionId('feishu-host-generated-id'));
|
|
126
|
+
feishu.append('session/title', {
|
|
127
|
+
title: '你好,你是谁?', messageSeqs: [], source: { kind: 'user' },
|
|
128
|
+
});
|
|
129
|
+
prompt(feishu, '你好,你是谁?', 'feishu');
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
fibers.push(creator);
|
|
133
|
+
await creator.await();
|
|
134
|
+
await settle();
|
|
135
|
+
assert.equal(ctx.sessionTitle.get(feishu).title, '飞书 · 你好,你是谁?');
|
|
136
|
+
assert.equal(ctx.sessionTitle.get(feishu).source.kind, 'user');
|
|
137
|
+
|
|
138
|
+
const sourceEvents = session.snapshotEvents();
|
|
139
|
+
for (const event of sourceEvents.filter((entry) => entry.type === 'session/title')) {
|
|
140
|
+
assert.equal(event.data.messageSeqs.length === 0, event.data.source.kind === 'user');
|
|
141
|
+
for (const seq of event.data.messageSeqs) {
|
|
142
|
+
assert.equal(sourceEvents[seq].type, 'user/message');
|
|
143
|
+
assert.equal(sourceEvents[seq].data.source.kind, 'user');
|
|
144
|
+
assert.ok(seq < event.seq);
|
|
145
|
+
}
|
|
146
|
+
assert.ok(!event.data.title.includes('微信 · 微信 · '));
|
|
147
|
+
}
|
|
148
|
+
for (const fiber of fibers.reverse()) await fiber.dispose();
|
|
149
|
+
console.log(`Session channel prefix verified against ${root} (${automatic}): active generation, later generation, projections, manual rename, refresh, Web sessions, and title-source invariants passed.`);
|