@twinsearth/w2m-dsh-plugin 3.0.0 → 3.2.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/lib/client.js CHANGED
@@ -1014,6 +1014,440 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
1014
1014
  ]);
1015
1015
  }
1016
1016
 
1017
+ /**
1018
+ * 队列日志(v3.1.0,移植自 @twinsearth/dsh-fang-queue-journal 0.1.5)。
1019
+ *
1020
+ * 草稿/队列的持久化镜像是宿主服务 `/api/w2m/composer/journal*`(见 src/queue-journal/index.mjs);
1021
+ * 恢复必须由用户显式点击,本模块自身从不发送消息。Fang 的独立模块入口在这里展开成同一个客户端
1022
+ * bundle 内的工厂函数,React 与 h 复用外层。
1023
+ */
1024
+ function createQueueJournalModule() {
1025
+
1026
+ const DEBOUNCE_MS = 250
1027
+ const FALLBACK_MS = 15_000
1028
+
1029
+ /** 从 URL 里尝试取会话 id(兼容多种路由形态)。 */
1030
+ function sessionKeyFromUrl() {
1031
+ const hash = String(location.hash || '').replace(/^#/, '')
1032
+ const fromHash = /sessions?\/([^/?#]+)/.exec(hash)
1033
+ const fromPath = /sessions?\/([^/?#]+)/.exec(location.pathname)
1034
+ const q = new URLSearchParams(location.search).get('session') || new URLSearchParams(location.search).get('sessionId')
1035
+ return String(q || fromHash?.[1] || fromPath?.[1] || '').slice(0, 200)
1036
+ }
1037
+
1038
+ /** 从 DOM 上找宿主暴露的会话 id(DSH 可能在容器上打 data 属性)。 */
1039
+ function sessionKeyFromDom() {
1040
+ const selectors = ['[data-session-id]', '[data-sessionid]', '[data-current-session]', '[data-active-session]']
1041
+ for (const selector of selectors) {
1042
+ const el = document.querySelector(selector)
1043
+ const _value = el?.getAttribute(selector.replace(/[[\]]/g, '').split('=')[0]) || ''
1044
+ const attr = selector.slice(1, -1)
1045
+ const v = el?.getAttribute(attr)
1046
+ if (v) return String(v).slice(0, 200)
1047
+ }
1048
+ return ''
1049
+ }
1050
+
1051
+ /** 本页实例键:sessionStorage 里稳定存在,避免多会话挤在同一个 key。 */
1052
+ function instanceKey() {
1053
+ try {
1054
+ const cached = sessionStorage.getItem('fang-queue-journal-instance')
1055
+ if (cached) return cached
1056
+ const generated = 'page:' + Math.random().toString(36).slice(2, 10) + ':' + Date.now().toString(36)
1057
+ sessionStorage.setItem('fang-queue-journal-instance', generated)
1058
+ return generated
1059
+ } catch { return 'page:unknown' }
1060
+ }
1061
+
1062
+ let activeKey = ''
1063
+ let activeSessionId = ''
1064
+ function resolveKey() {
1065
+ // 优先级:slot 给的 SessionId > DOM > URL > 本页实例键(绝不使用 "/")
1066
+ if (activeSessionId) return activeSessionId
1067
+ const fromUrl = sessionKeyFromUrl()
1068
+ const fromDom = sessionKeyFromDom()
1069
+ const key = String(fromDom || fromUrl || instanceKey())
1070
+ activeKey = key === '/' || key === '' ? instanceKey() : key
1071
+ return activeKey
1072
+ }
1073
+
1074
+ async function fangToken() { return 'w2m-same-origin' }
1075
+
1076
+ async function journalRequest(method, entry, keyOverride) {
1077
+ const token = await fangToken()
1078
+ if (!token) return null
1079
+ const key = keyOverride || resolveKey()
1080
+ const res = await fetch(method === 'POST' ? '/api/w2m/composer/journal' : '/api/w2m/composer/journal?key=' + encodeURIComponent(key), {
1081
+ method,
1082
+ headers: { 'content-type': 'application/json', 'x-fang-auth': token },
1083
+ body: method === 'POST'
1084
+ ? JSON.stringify({ key, sessionId: activeSessionId || undefined, sessionKey: key, path: location.hash || location.pathname, ...entry })
1085
+ : undefined,
1086
+ })
1087
+ if (!res.ok) return null
1088
+ try { return await res.json() } catch { return null }
1089
+ }
1090
+
1091
+ async function journalHistory(limit = 10) {
1092
+ const token = await fangToken()
1093
+ if (!token) return []
1094
+ try {
1095
+ const res = await fetch('/api/w2m/composer/journal/history?limit=' + Number(limit) + '&sessionKey=' + encodeURIComponent(resolveKey()), {
1096
+ headers: { accept: 'application/json', 'x-fang-auth': token },
1097
+ })
1098
+ if (!res.ok) return []
1099
+ const body = await res.json()
1100
+ return Array.isArray(body?.items) ? body.items : []
1101
+ } catch { return [] }
1102
+ }
1103
+
1104
+ async function queueRestore(sessionId, items) {
1105
+ const token = await fangToken()
1106
+ if (!token) return { ok: false, error: 'auth-unavailable' }
1107
+ const res = await fetch('/api/w2m/composer/queue', {
1108
+ method: 'POST',
1109
+ headers: { 'content-type': 'application/json', 'x-fang-auth': token },
1110
+ body: JSON.stringify({
1111
+ sessionId,
1112
+ items: items.slice(0, 100).map((item) => ({ text: String(item?.text ?? '').slice(0, 4000) })),
1113
+ }),
1114
+ })
1115
+ let body = null
1116
+ try { body = await res.json() } catch { /* non-json */ }
1117
+ if (!res.ok) return { ok: false, error: body?.error || 'http-' + res.status, detail: body?.detail || '' }
1118
+ return { ok: true, queued: Number(body?.queued) || 0, failed: Number(body?.failed) || 0 }
1119
+ }
1120
+
1121
+ function findComposer() {
1122
+ const nodes = [...document.querySelectorAll('textarea, [contenteditable="true"]')]
1123
+ .filter((el) => {
1124
+ if (el.closest?.('[data-fang-entry]')) return false
1125
+ const r = el.getBoundingClientRect()
1126
+ return r.width > 80 && r.height > 16
1127
+ })
1128
+ return nodes.sort((a, b) => {
1129
+ const ra = a.getBoundingClientRect()
1130
+ const rb = b.getBoundingClientRect()
1131
+ return (rb.width * rb.height) - (ra.width * ra.height)
1132
+ })[0] || null
1133
+ }
1134
+
1135
+ function readComposer(el) {
1136
+ if (!el) return ''
1137
+ return el.tagName === 'TEXTAREA' ? String(el.value || '') : String(el.innerText || '')
1138
+ }
1139
+
1140
+ function writeComposer(text) {
1141
+ const el = findComposer()
1142
+ if (!el) return false
1143
+ el.focus()
1144
+ if (el.tagName === 'TEXTAREA') {
1145
+ const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set
1146
+ if (setter) setter.call(el, text)
1147
+ else el.value = text
1148
+ el.dispatchEvent(new Event('input', { bubbles: true }))
1149
+ } else {
1150
+ try {
1151
+ document.execCommand('selectAll', false, null)
1152
+ document.execCommand('insertText', false, text)
1153
+ } catch { /* fall back */ }
1154
+ if (!String(el.innerText || '').includes(text.slice(0, 20))) {
1155
+ el.textContent = text
1156
+ el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: text }))
1157
+ }
1158
+ }
1159
+ el.focus()
1160
+ return true
1161
+ }
1162
+
1163
+ function setDraftViaApi(props, text) {
1164
+ try {
1165
+ if (props?.inputActions && typeof props.inputActions.setDraft === 'function') {
1166
+ props.inputActions.setDraft(String(text || ''))
1167
+ return true
1168
+ }
1169
+ } catch { /* fall back to the DOM */ }
1170
+ return writeComposer(String(text || ''))
1171
+ }
1172
+
1173
+ const QUEUE_COUNT_RE = /(\d+)\s*(条排队消息|queued messages|件のキュー済みメッセージ|mensagens na fila|messages en file d'attente|nachrichten in der warteschlange|сообщения в очереди|mensajes en cola|mensagens na fila)/i
1174
+ const QUEUE_ACTION_LABELS = /^(插话发送|编辑排队消息|删除排队消息|保存排队消息|取消编辑|发送中…|排队消息图片|queued message image|edit queued message|remove queued message|save queued message|cancel edit|sending…|steer)$/i
1175
+
1176
+ /** 从 QueueDock 行里去掉操作按钮,只留消息正文。 */
1177
+ function queueRowText(row) {
1178
+ try {
1179
+ const clone = row.cloneNode(true)
1180
+ for (const node of clone.querySelectorAll('button, [role="button"]')) node.remove()
1181
+ const lines = String(clone.innerText || clone.textContent || '')
1182
+ .split(/\r?\n/)
1183
+ .map((line) => line.trim())
1184
+ .filter((line) => line && !QUEUE_ACTION_LABELS.test(line))
1185
+ return lines.join(' ').trim()
1186
+ } catch { return '' }
1187
+ }
1188
+
1189
+ /**
1190
+ * DSH QueueDock 的 CSS 是 hashed class(F07MqG_*),不能靠 class*=queue。
1191
+ * 以 header 的“N 条排队消息”文案定位,再通过 aria-controls 找到列表。
1192
+ */
1193
+ function scanQueueDock() {
1194
+ const out = []
1195
+ const seen = new Set()
1196
+ const pushText = (text) => {
1197
+ const value = String(text || '').trim()
1198
+ if (!value || value.length > 4000 || seen.has(value)) return
1199
+ seen.add(value)
1200
+ out.push({ text: value, at: Date.now() })
1201
+ }
1202
+ const docks = document.querySelectorAll('[data-queue-dock]')
1203
+ for (const dock of docks) {
1204
+ for (const row of dock.querySelectorAll('li')) pushText(queueRowText(row))
1205
+ }
1206
+ if (docks.length > 0) return out
1207
+
1208
+ const headers = [...document.querySelectorAll('button[aria-controls], [role="button"][aria-controls]')]
1209
+ .filter((el) => QUEUE_COUNT_RE.test(String(el.textContent || '')))
1210
+ for (const header of headers) {
1211
+ const listId = header.getAttribute('aria-controls')
1212
+ const list = (listId ? document.getElementById(listId) : null) || header.parentElement?.querySelector('ul')
1213
+ if (!list) continue
1214
+ for (const row of list.querySelectorAll('li')) pushText(queueRowText(row))
1215
+ }
1216
+ return out
1217
+ }
1218
+
1219
+ function scanQueue() {
1220
+ const found = scanQueueDock()
1221
+ if (found.length > 0) return found.slice(-100)
1222
+ const seen = new Set()
1223
+ const out = []
1224
+ const nodes = document.querySelectorAll('[class*="queue" i], [data-testid*="queue" i], [aria-label*="queue" i], [class*="pending" i], [data-testid*="pending" i], [aria-label*="排队"], [title*="排队"]')
1225
+ for (const el of nodes) {
1226
+ const text = String(el.innerText || '').trim()
1227
+ if (!text || text.length > 4000 || seen.has(text)) continue
1228
+ seen.add(text)
1229
+ out.push({ text, at: Date.now() })
1230
+ }
1231
+ return out.slice(-100)
1232
+ }
1233
+
1234
+ /** 回读校验:确认服务端存下来的队列文本与本地一致(并检测 "????" 乱码)。 */
1235
+ async function verifySaved(expectedQueue) {
1236
+ const body = await journalRequest('GET')
1237
+ const saved = Array.isArray(body?.entry?.queue) ? body.entry.queue.map((item) => String(item?.text || '')) : []
1238
+ const expected = expectedQueue.map((item) => String(item?.text || ''))
1239
+ const sameLength = saved.length === expected.length
1240
+ const sameText = sameLength && saved.every((text, index) => text === expected[index])
1241
+ const looksMojibake = saved.some((text) => /\?{3,}/.test(text) && !/\?{3,}/.test(expected[saved.indexOf(text)] || ''))
1242
+ return { ok: sameText && !looksMojibake, sameLength, sameText, looksMojibake, saved }
1243
+ }
1244
+
1245
+ function JournalDock(props) {
1246
+ const sessionId = String(props?.sessionId || '')
1247
+ const [entry, setEntry] = React.useState(null)
1248
+ const [history, setHistory] = React.useState([])
1249
+ const [status, setStatus] = React.useState('')
1250
+ const [showQueue, setShowQueue] = React.useState(false)
1251
+ const [showHistory, setShowHistory] = React.useState(false)
1252
+
1253
+ React.useEffect(() => {
1254
+ activeSessionId = sessionId
1255
+ setEntry(null)
1256
+ setShowQueue(false)
1257
+ setShowHistory(false)
1258
+ let alive = true
1259
+ journalRequest('GET').then(async (body) => {
1260
+ if (alive) setEntry(body?.entry ?? null)
1261
+ }).catch(() => {})
1262
+ journalHistory(10).then((items) => { if (alive) setHistory(items) }).catch(() => {})
1263
+ return () => { alive = false }
1264
+ }, [sessionId])
1265
+
1266
+ const queue = Array.isArray(entry?.queue) ? entry.queue : []
1267
+ const hasEntry = Boolean(entry && (entry.draft || queue.length))
1268
+
1269
+ if (!hasEntry && history.length === 0) return null
1270
+
1271
+ const restoreItems = async (items, draft) => {
1272
+ const draftText = String(draft || '')
1273
+ const draftOk = draftText ? setDraftViaApi(props, draftText) : true
1274
+ if (!draftOk) { setStatus('未找到输入框,请先打开会话'); return }
1275
+ if (items.length === 0) {
1276
+ await journalRequest('POST', { draft: '', queue: [] })
1277
+ setEntry(null)
1278
+ setStatus('草稿已放入官方输入框')
1279
+ return
1280
+ }
1281
+ if (!sessionId) { setStatus('草稿已恢复;队列需要先选中会话'); return }
1282
+ const restored = await queueRestore(sessionId, items)
1283
+ if (!restored.ok) {
1284
+ setStatus(restored.error === 'session-not-live'
1285
+ ? '草稿已恢复;会话未运行,队列保留待重试'
1286
+ : '草稿已恢复;队列恢复失败:' + restored.error)
1287
+ return
1288
+ }
1289
+ await journalRequest('POST', { draft: '', queue: [] })
1290
+ setEntry(null)
1291
+ setStatus(`已恢复 ${restored.queued} 条排队消息${restored.failed ? `(${restored.failed} 条失败)` : ''}`)
1292
+ }
1293
+
1294
+ const rowText = (item) => {
1295
+ const text = String(item?.text || '').replace(/\s+/g, ' ').trim()
1296
+ return text.length > 140 ? text.slice(0, 137) + '…' : text
1297
+ }
1298
+
1299
+ const snapshotLabel = (snap) => {
1300
+ const at = String(snap?.updatedAt || '')
1301
+ const count = Array.isArray(snap?.queue) ? snap.queue.length : 0
1302
+ return `${at ? at.replace('T', ' ').slice(0, 16) : '?'} · ${count} 条`
1303
+ }
1304
+
1305
+ return h('div', {
1306
+ 'data-fang-entry': 'queue-journal',
1307
+ style: {
1308
+ display: 'inline-flex', flexDirection: 'column', gap: 6, margin: '2px 0', maxWidth: '100%',
1309
+ padding: '3px 6px', border: '1px solid rgba(127,127,127,.3)', borderRadius: 9,
1310
+ fontSize: 12, background: 'rgba(127,127,127,.06)',
1311
+ },
1312
+ },
1313
+ h('div', { style: { display: 'inline-flex', alignItems: 'center', gap: 6 } },
1314
+ hasEntry ? h('button', {
1315
+ type: 'button',
1316
+ title: `恢复草稿${queue.length ? `,并把这 ${queue.length} 条排队消息放回官方输入队列` : ''}`,
1317
+ 'aria-label': '恢复全部草稿与队列',
1318
+ onClick: () => { void restoreItems(queue, entry?.draft) },
1319
+ style: { height: 26, padding: '0 8px', borderRadius: 7, cursor: 'pointer' },
1320
+ }, queue.length ? `恢复全部 (${queue.length + (entry?.draft ? 1 : 0)})` : '恢复草稿') : null,
1321
+ hasEntry && queue.length > 0 ? h('button', {
1322
+ type: 'button', title: '展开保存的排队消息列表', 'aria-expanded': showQueue ? 'true' : 'false',
1323
+ onClick: () => setShowQueue((value) => !value),
1324
+ style: { height: 26, padding: '0 8px', borderRadius: 7, cursor: 'pointer' },
1325
+ }, `队列 ${queue.length}`) : null,
1326
+ history.length > 0 ? h('button', {
1327
+ type: 'button', title: '从历史快照中挑选一份恢复(主记录被覆盖时的兜底)',
1328
+ 'aria-expanded': showHistory ? 'true' : 'false',
1329
+ onClick: () => setShowHistory((value) => !value),
1330
+ style: { height: 26, padding: '0 8px', borderRadius: 7, cursor: 'pointer' },
1331
+ }, `历史 ${history.length}`) : null,
1332
+ hasEntry ? h('button', {
1333
+ type: 'button', title: '清除本地保存的草稿与排队快照', 'aria-label': '清除草稿',
1334
+ onClick: async () => { await journalRequest('POST', { draft: '', queue: [] }); setEntry(null); setStatus('已清除记录') },
1335
+ style: { height: 26, padding: '0 8px', borderRadius: 7, cursor: 'pointer' },
1336
+ }, '清除') : null,
1337
+ ),
1338
+ showQueue && queue.length > 0 ? h('div', {
1339
+ role: 'list', style: { display: 'grid', gap: 4, maxHeight: 180, overflow: 'auto', padding: '2px 0' },
1340
+ }, ...queue.map((item, index) => h('div', {
1341
+ role: 'listitem', key: `${index}:${item?.at || ''}`, title: String(item?.text || ''),
1342
+ style: { padding: '3px 6px', border: '1px solid rgba(127,127,127,.22)', borderRadius: 6, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' },
1343
+ }, `${index + 1}. ${rowText(item)}`))) : null,
1344
+ showHistory && history.length > 0 ? h('div', {
1345
+ role: 'list', style: { display: 'grid', gap: 4, maxHeight: 200, overflow: 'auto', padding: '2px 0' },
1346
+ }, ...history.slice(0, 10).map((snap, index) => h('div', {
1347
+ role: 'listitem', key: `h${index}:${snap?.updatedAt || ''}`,
1348
+ style: { display: 'flex', alignItems: 'center', gap: 6, padding: '3px 6px', border: '1px solid rgba(127,127,127,.22)', borderRadius: 6 },
1349
+ }, h('span', { style: { flex: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' } }, snapshotLabel(snap)),
1350
+ h('button', {
1351
+ type: 'button', title: '把这份快照里的队列放回官方队列',
1352
+ onClick: async () => { await restoreItems(Array.isArray(snap?.queue) ? snap.queue : [], '') },
1353
+ style: { height: 22, padding: '0 6px', borderRadius: 6, cursor: 'pointer' },
1354
+ }, '恢复')))) : null,
1355
+ h('span', {
1356
+ role: 'status', 'aria-live': 'polite',
1357
+ style: status ? { padding: '1px 2px', opacity: .8 } : { position: 'absolute', width: 1, height: 1, overflow: 'hidden', clip: 'rect(0 0 0 0)', whiteSpace: 'nowrap' },
1358
+ }, status))
1359
+ }
1360
+
1361
+ let saveTimer = null
1362
+ let lastSignature = ''
1363
+ let flushTimer = null
1364
+
1365
+ function commitSave() {
1366
+ const text = readComposer(findComposer())
1367
+ const queue = scanQueue()
1368
+ const signature = JSON.stringify({ key: resolveKey(), text, queue: queue.map((item) => item.text) })
1369
+ if (signature === lastSignature) return
1370
+ lastSignature = signature
1371
+ void (async () => {
1372
+ const saved = await journalRequest('POST', { draft: text, queue })
1373
+ if (!saved) return
1374
+ const check = await verifySaved(queue)
1375
+ if (!check.ok) {
1376
+ // 回读不一致(或被截断/乱码):重试一次,并把诊断留在控制台
1377
+ try { window.console?.warn?.('[fang-queue-journal] 回读校验未通过', check) } catch { /* ignore */ }
1378
+ const retried = await journalRequest('POST', { draft: text, queue })
1379
+ if (retried) await verifySaved(queue)
1380
+ }
1381
+ })()
1382
+ }
1383
+
1384
+ function scheduleSave() {
1385
+ if (saveTimer) clearTimeout(saveTimer)
1386
+ saveTimer = setTimeout(() => { saveTimer = null; commitSave() }, DEBOUNCE_MS)
1387
+ }
1388
+
1389
+ /** 关闭/切页前同步 flush:fetch keepalive 保证请求活着离开页面。 */
1390
+ function flushNow() {
1391
+ const text = readComposer(findComposer())
1392
+ const queue = scanQueue()
1393
+ const signature = JSON.stringify({ key: resolveKey(), text, queue: queue.map((item) => item.text) })
1394
+ if (signature === lastSignature) return
1395
+ lastSignature = signature
1396
+ void (async () => {
1397
+ const token = await fangToken()
1398
+ if (!token) return
1399
+ try {
1400
+ fetch('/api/w2m/composer/journal', {
1401
+ method: 'POST',
1402
+ keepalive: true,
1403
+ headers: { 'content-type': 'application/json', 'x-fang-auth': token },
1404
+ body: JSON.stringify({ key: resolveKey(), sessionKey: resolveKey(), sessionId: activeSessionId || undefined, draft: text, queue, path: location.hash || location.pathname }),
1405
+ })
1406
+ } catch { /* ignore */ }
1407
+ })()
1408
+ }
1409
+
1410
+ return {
1411
+ inject: ['slots'],
1412
+ apply(ctx) {
1413
+ ctx.slots.inject('conversation.composer.dock', () => ctx.slots.register({
1414
+ name: 'conversation.composer.dock',
1415
+ id: 'fang-queue-journal',
1416
+ order: 90,
1417
+ }, JournalDock))
1418
+
1419
+ // ① 草稿输入:捕获阶段监听,避免被宿主 stopPropagation 吞掉
1420
+ document.addEventListener('input', (event) => {
1421
+ const target = event.target
1422
+ if (!target || (target.tagName !== 'TEXTAREA' && target.getAttribute?.('contenteditable') !== 'true')) return
1423
+ scheduleSave()
1424
+ }, true)
1425
+
1426
+ // ② 队列变化:只盯 QueueDock(body 全量只用 childList,避免 characterData 风暴)
1427
+ try {
1428
+ const queueObserver = new MutationObserver(() => scheduleSave())
1429
+ queueObserver.observe(document.body, { childList: true, subtree: true })
1430
+ // 有 [data-queue-dock] 时再额外盯 characterData(行文本可能原地更新)
1431
+ const docksObserver = new MutationObserver(() => scheduleSave())
1432
+ for (const dock of document.querySelectorAll('[data-queue-dock]')) {
1433
+ docksObserver.observe(dock, { childList: true, subtree: true, characterData: true })
1434
+ }
1435
+ queueObserver.__docksObserver = docksObserver
1436
+ } catch { /* DOM unavailable */ }
1437
+
1438
+ // ③ 15s 兜底(覆盖“宿主重渲染后新 dock 未被观察到”的情况)
1439
+ flushTimer = setInterval(() => scheduleSave(), FALLBACK_MS)
1440
+ try { flushTimer?.unref?.() } catch { /* browser timer */ }
1441
+
1442
+ // ④ 关闭/切页前 flush
1443
+ const onPageHide = () => flushNow()
1444
+ const onVisibility = () => { if (document.visibilityState === 'hidden') flushNow() }
1445
+ window.addEventListener('pagehide', onPageHide)
1446
+ document.addEventListener('visibilitychange', onVisibility)
1447
+ window.addEventListener('beforeunload', onPageHide)
1448
+ },
1449
+ }
1450
+ }
1017
1451
  /** v1.9.4: the left sidebar entry: globe + GPW, between Plugins and Automation. */
1018
1452
  function GpwSidebarIcon() {
1019
1453
  return h('button', {
@@ -3511,6 +3945,16 @@ const CHAT_EMOJIS = CHAT_COMMANDS;
3511
3945
 
3512
3946
 
3513
3947
 
3948
+ // v3.1.0: 队列日志(移植自 @twinsearth/dsh-fang-queue-journal):把草稿与队列镜像存到
3949
+ // /api/w2m/composer/journal*,恢复只由用户点击触发。
3950
+ try {
3951
+ if (typeof document !== 'undefined' && ctx !== null && typeof ctx === 'object' && ctx.slots !== undefined) {
3952
+ createQueueJournalModule().apply({ slots: ctx.slots });
3953
+ }
3954
+ } catch (error) {
3955
+ console.error('@twinsearth/w2m-dsh-plugin: the queue journal was not mounted; the rest of the page is unaffected', error);
3956
+ }
3957
+
3514
3958
  // v1.9.9: in-conversation search, in the session header next to Pin.
3515
3959
  try {
3516
3960
  slots.inject('conversation.session.header.actions', () => slots.register({
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Package-facing entry for W2M's interop contract layer: `@twinsearth/w2m-dsh-plugin/contract`.
3
+ *
4
+ * The documented subpath a profile row names to expose `w2m.*` methods (local service + HTTP routes)
5
+ * and to reach peers through the published contract SDK's local → http → file → legacy fallback.
6
+ * The vendored SDK copy and its provenance live in `src/interop/contract/`.
7
+ */
8
+ export {
9
+ CONTRACT_LOCAL_SERVICE,
10
+ CONTRACT_ROUTE_PREFIX,
11
+ W2M_CONTRACT_CAPABILITIES,
12
+ W2M_CONTRACT_SERVICE,
13
+ W2M_CONTRACT_VERSION,
14
+ W2M_METHODS,
15
+ W2M_METHOD_VERSION,
16
+ apply,
17
+ createW2mContractClient,
18
+ createW2mContractServer,
19
+ inject,
20
+ name,
21
+ registerContractRoutes,
22
+ } from '../src/interop/index.mjs';
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Package-facing entry for W2M's queue journal: `@twinsearth/w2m-dsh-plugin/queue-journal`.
3
+ *
4
+ * The documented subpath a profile row names to enable draft/queue persistence. The host logic is
5
+ * `src/queue-journal/index.mjs`; the composer UI ships in the package's one client bundle
6
+ * (`lib/client.js`), the same shape GPW and the decision center use.
7
+ */
8
+ export {
9
+ apply,
10
+ inject,
11
+ name,
12
+ QUEUE_JOURNAL_ROUTE_PREFIX,
13
+ QUEUE_JOURNAL_SERVICE,
14
+ QUEUE_RESTORE_ROUTE_PATH,
15
+ } from '../src/queue-journal/index.mjs';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "3.0.0",
3
+ "version": "3.2.0",
4
4
  "description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 11 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -15,7 +15,9 @@
15
15
  "./desktop": "./lib/desktop.js",
16
16
  "./decision-center": "./lib/decision-center.js",
17
17
  "./locale/en.json": "./locale/en.json",
18
- "./locale/zh.json": "./locale/zh.json"
18
+ "./locale/zh.json": "./locale/zh.json",
19
+ "./queue-journal": "./lib/queue-journal.js",
20
+ "./contract": "./lib/contract.js"
19
21
  },
20
22
  "dsh": {
21
23
  "plugin": true,
@@ -73,7 +75,7 @@
73
75
  "pack": "node scripts/pack.mjs",
74
76
  "reachability": "node scripts/reachability-check.mjs",
75
77
  "reachability:classify": "node scripts/reachability-classify.mjs",
76
- "test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/blake2s.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/fleet-merge.test.mjs test/fleet-memory-e2e.test.mjs test/fleet-concurrency.test.mjs test/fleet-merge-race.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/portable-plugins.test.mjs test/mcp-http.test.mjs test/mcp-http-live.test.mjs test/vision.test.mjs test/ilink-shape.test.mjs test/chatops-fleet-join.test.mjs test/admission.test.mjs test/admission-routes.test.mjs test/join-degradation.test.mjs test/account-identity.test.mjs test/join-challenge.test.mjs test/exec-env-injection.test.mjs test/account-join.test.mjs test/fleet-messages.test.mjs test/message-shards.test.mjs test/fleet-messages-http.test.mjs test/message-notify.test.mjs test/update-channels.test.mjs test/update-prerelease.test.mjs test/session-watch.test.mjs test/session-readonly.test.mjs test/session-sync.test.mjs test/fleet-chat-identity.test.mjs test/identity-reader.test.mjs test/intents.test.mjs test/dataflow.test.mjs test/council-fleet.test.mjs test/fleet-memory.test.mjs test/memory-view.test.mjs test/schema-spec.test.mjs test/fleet-weight-parity.test.mjs test/task-deps.test.mjs test/dataflow-wiring.test.mjs test/task-graph.test.mjs test/ask-store.test.mjs test/turn.test.mjs test/fleet-members.test.mjs test/redact.test.mjs test/policy-matrix.test.mjs test/envelope-signature.test.mjs test/chat-crypto.test.mjs test/reduce-suggestion.test.mjs test/merge-hint.test.mjs test/fleet-chat-route.test.mjs test/maintenance.test.mjs test/maintenance-ferry.test.mjs test/grant.test.mjs test/grant-gate.test.mjs test/worktree.test.mjs test/patch-artifact.test.mjs test/merge-queue.test.mjs test/m3-workflow.test.mjs test/project-room.test.mjs test/project-attribution.test.mjs test/project-locks.test.mjs test/project-reviews.test.mjs test/project-notifications.test.mjs test/blob-sessions.test.mjs test/outbox.test.mjs test/reliability-metrics.test.mjs test/evidence.test.mjs test/grant-issue.test.mjs test/project-refs.test.mjs test/exec-capability.test.mjs test/secondary-link.test.mjs test/bootstrap-cli.test.mjs test/bootstrap.test.mjs test/windows-hidden-task.test.mjs test/relay-failover.test.mjs test/npm-update.test.mjs test/agent-start-nonblocking.test.mjs test/fleet-chat-capture.test.mjs test/capture-route.test.mjs test/update-resolver.test.mjs test/agent-identity-safe.test.mjs test/re-pair-stale-token.test.mjs test/gpw-folder-policy.test.mjs test/gpw-activation.test.mjs test/gpw-hardening.test.mjs test/gpw-tamper-gate.test.mjs test/gpw-persistence.test.mjs test/gpw-colors.test.mjs test/gpw-recent-fold.test.mjs test/gpw-auto-approve.test.mjs test/joint-dev-dispatch.test.mjs test/joint-dev-loop.test.mjs test/joint-dev-audit.test.mjs test/joint-dev-redline.test.mjs test/relay-redline-guard.test.mjs test/relay-p0-projection.test.mjs test/guarded-dispatch.test.mjs test/p0-single-matcher.test.mjs test/path-policy.test.mjs test/path-policy-fs.test.mjs test/gpw-inheritance-table.test.mjs test/gpw-policy-adapter.test.mjs test/target-policy.test.mjs test/relay-redline-audit.test.mjs test/relay-repair-ledger.test.mjs test/relay-purge-blobs.test.mjs test/relay-scrub.test.mjs test/joint-dev-plan-store.test.mjs test/relay-basepath-blobs.test.mjs test/operator-token.test.mjs test/relay-health-capabilities.test.mjs test/joint-relay-dispatch.test.mjs test/operator-token-route.test.mjs test/relay-scrub-task-projection.test.mjs test/operator-key-approval.test.mjs test/relay-offer-journal.test.mjs test/joint-r5-wiring.test.mjs test/p1-hardening.test.mjs test/joint-merge-engine.test.mjs test/artifact-ref-dir-sink.test.mjs test/chat-sticker-guard.test.mjs test/relay-task-progress.test.mjs test/gpw-modes.test.mjs test/joint-cli.test.mjs test/joint-command-hash.test.mjs test/joint-dispatch-audit.test.mjs test/tools-cancel-auth.test.mjs test/fleet-mirror.test.mjs test/sink-git-ssh.test.mjs test/agent-sse-watchdog.test.mjs test/version-gate.test.mjs test/joint-merge-cli.test.mjs test/joint-progress.test.mjs test/project-room-truncation.test.mjs test/chat-crypto-aad.test.mjs test/agent-artifact-delivery.test.mjs test/dataplane-sink-route.test.mjs test/artifact-sink-e2e.test.mjs test/project-room-persistence.test.mjs test/project-room-disk-persistence.test.mjs test/project-stream-route.test.mjs test/chat-v2-gate.test.mjs test/xiangqi.test.mjs test/xfer-foundation.test.mjs test/xfer-ferry.test.mjs test/xfer-publish.test.mjs test/xfer-want-block.test.mjs test/p2p-cipher-diag.test.mjs test/chacha-fallback.test.mjs test/fleet-binding.test.mjs test/fleet-home.test.mjs test/fleet-config.test.mjs test/fleet-config-route.test.mjs test/xfer-channel.test.mjs test/p2p-race.test.mjs test/path-scheduler.test.mjs test/transport.test.mjs test/iroh-sidecar.test.mjs test/discovery.test.mjs test/p2p-rtt.test.mjs test/artifact-ref-v3.test.mjs test/xfer-recovery.test.mjs test/xfer-router.test.mjs test/remote-s2-capture.test.mjs test/view-capture.test.mjs test/view-frame-channel.test.mjs test/view-consent.test.mjs test/remote-view-route.test.mjs test/remote-media.test.mjs test/view-session-key.test.mjs test/remote-requests.test.mjs test/relay-target.test.mjs test/decision-center.test.mjs test/decision-routes.test.mjs test/decision-outbox.test.mjs",
78
+ "test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/blake2s.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/fleet-merge.test.mjs test/fleet-memory-e2e.test.mjs test/fleet-concurrency.test.mjs test/fleet-merge-race.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/portable-plugins.test.mjs test/mcp-http.test.mjs test/mcp-http-live.test.mjs test/vision.test.mjs test/ilink-shape.test.mjs test/chatops-fleet-join.test.mjs test/admission.test.mjs test/admission-routes.test.mjs test/join-degradation.test.mjs test/account-identity.test.mjs test/join-challenge.test.mjs test/exec-env-injection.test.mjs test/account-join.test.mjs test/fleet-messages.test.mjs test/message-shards.test.mjs test/fleet-messages-http.test.mjs test/message-notify.test.mjs test/update-channels.test.mjs test/update-prerelease.test.mjs test/session-watch.test.mjs test/session-readonly.test.mjs test/session-sync.test.mjs test/fleet-chat-identity.test.mjs test/identity-reader.test.mjs test/intents.test.mjs test/dataflow.test.mjs test/council-fleet.test.mjs test/fleet-memory.test.mjs test/memory-view.test.mjs test/schema-spec.test.mjs test/fleet-weight-parity.test.mjs test/task-deps.test.mjs test/dataflow-wiring.test.mjs test/task-graph.test.mjs test/ask-store.test.mjs test/turn.test.mjs test/fleet-members.test.mjs test/redact.test.mjs test/policy-matrix.test.mjs test/envelope-signature.test.mjs test/chat-crypto.test.mjs test/reduce-suggestion.test.mjs test/merge-hint.test.mjs test/fleet-chat-route.test.mjs test/maintenance.test.mjs test/maintenance-ferry.test.mjs test/grant.test.mjs test/grant-gate.test.mjs test/worktree.test.mjs test/patch-artifact.test.mjs test/merge-queue.test.mjs test/m3-workflow.test.mjs test/project-room.test.mjs test/project-attribution.test.mjs test/project-locks.test.mjs test/project-reviews.test.mjs test/project-notifications.test.mjs test/blob-sessions.test.mjs test/outbox.test.mjs test/reliability-metrics.test.mjs test/evidence.test.mjs test/grant-issue.test.mjs test/project-refs.test.mjs test/exec-capability.test.mjs test/secondary-link.test.mjs test/bootstrap-cli.test.mjs test/bootstrap.test.mjs test/windows-hidden-task.test.mjs test/relay-failover.test.mjs test/npm-update.test.mjs test/agent-start-nonblocking.test.mjs test/fleet-chat-capture.test.mjs test/capture-route.test.mjs test/update-resolver.test.mjs test/agent-identity-safe.test.mjs test/re-pair-stale-token.test.mjs test/gpw-folder-policy.test.mjs test/gpw-activation.test.mjs test/gpw-hardening.test.mjs test/gpw-tamper-gate.test.mjs test/gpw-persistence.test.mjs test/gpw-colors.test.mjs test/gpw-recent-fold.test.mjs test/gpw-auto-approve.test.mjs test/joint-dev-dispatch.test.mjs test/joint-dev-loop.test.mjs test/joint-dev-audit.test.mjs test/joint-dev-redline.test.mjs test/relay-redline-guard.test.mjs test/relay-p0-projection.test.mjs test/guarded-dispatch.test.mjs test/p0-single-matcher.test.mjs test/path-policy.test.mjs test/path-policy-fs.test.mjs test/gpw-inheritance-table.test.mjs test/gpw-policy-adapter.test.mjs test/target-policy.test.mjs test/relay-redline-audit.test.mjs test/relay-repair-ledger.test.mjs test/relay-purge-blobs.test.mjs test/relay-scrub.test.mjs test/joint-dev-plan-store.test.mjs test/relay-basepath-blobs.test.mjs test/operator-token.test.mjs test/relay-health-capabilities.test.mjs test/joint-relay-dispatch.test.mjs test/operator-token-route.test.mjs test/relay-scrub-task-projection.test.mjs test/operator-key-approval.test.mjs test/relay-offer-journal.test.mjs test/joint-r5-wiring.test.mjs test/p1-hardening.test.mjs test/joint-merge-engine.test.mjs test/artifact-ref-dir-sink.test.mjs test/chat-sticker-guard.test.mjs test/relay-task-progress.test.mjs test/gpw-modes.test.mjs test/joint-cli.test.mjs test/joint-command-hash.test.mjs test/joint-dispatch-audit.test.mjs test/tools-cancel-auth.test.mjs test/fleet-mirror.test.mjs test/sink-git-ssh.test.mjs test/agent-sse-watchdog.test.mjs test/version-gate.test.mjs test/joint-merge-cli.test.mjs test/joint-progress.test.mjs test/project-room-truncation.test.mjs test/chat-crypto-aad.test.mjs test/agent-artifact-delivery.test.mjs test/dataplane-sink-route.test.mjs test/artifact-sink-e2e.test.mjs test/project-room-persistence.test.mjs test/project-room-disk-persistence.test.mjs test/project-stream-route.test.mjs test/chat-v2-gate.test.mjs test/xiangqi.test.mjs test/xfer-foundation.test.mjs test/xfer-ferry.test.mjs test/xfer-publish.test.mjs test/xfer-want-block.test.mjs test/p2p-cipher-diag.test.mjs test/chacha-fallback.test.mjs test/fleet-binding.test.mjs test/fleet-home.test.mjs test/fleet-config.test.mjs test/fleet-config-route.test.mjs test/xfer-channel.test.mjs test/p2p-race.test.mjs test/path-scheduler.test.mjs test/transport.test.mjs test/iroh-sidecar.test.mjs test/discovery.test.mjs test/p2p-rtt.test.mjs test/artifact-ref-v3.test.mjs test/xfer-recovery.test.mjs test/xfer-router.test.mjs test/remote-s2-capture.test.mjs test/view-capture.test.mjs test/view-frame-channel.test.mjs test/view-consent.test.mjs test/remote-view-route.test.mjs test/remote-media.test.mjs test/view-session-key.test.mjs test/remote-requests.test.mjs test/relay-target.test.mjs test/decision-center.test.mjs test/decision-routes.test.mjs test/decision-outbox.test.mjs test/queue-journal.test.mjs test/contract-interop.test.mjs",
77
79
  "test:acceptance-doc": "node --test --test-force-exit test/acceptance-doc.test.mjs",
78
80
  "test:account-identity": "node --test --test-force-exit test/account-identity.test.mjs",
79
81
  "test:account-join": "node --test --test-force-exit test/account-join.test.mjs",
@@ -173,7 +175,10 @@
173
175
  "test:project-refs": "node --test --test-force-exit test/project-refs.test.mjs",
174
176
  "test:secondary-link": "node --test --test-force-exit test/secondary-link.test.mjs",
175
177
  "verify:fleet-isolation": "node scripts/verify-fleet-isolation.mjs",
176
- "test:decision-center": "node --test --test-force-exit test/decision-center.test.mjs test/decision-routes.test.mjs test/decision-outbox.test.mjs"
178
+ "test:decision-center": "node --test --test-force-exit test/decision-center.test.mjs test/decision-routes.test.mjs test/decision-outbox.test.mjs",
179
+ "test:queue-journal": "node --test --test-force-exit test/queue-journal.test.mjs",
180
+ "test:contract": "node --test --test-force-exit test/contract-interop.test.mjs",
181
+ "verify:contract": "node scripts/check-contract-vendor.mjs"
177
182
  },
178
183
  "devDependencies": {
179
184
  "@eslint/js": "^10.0.1",
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "twinsearth-w2m-dsh",
4
- "version": "3.0.0",
4
+ "version": "3.2.0",
5
5
  "description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 12 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
6
6
  "license": "MIT",
7
7
  "keywords": [
@@ -147,9 +147,8 @@ export function writeJsonAtomic(file, value, { indent = 2 } = {}) {
147
147
  export function resolveFangLayout(options = {}) {
148
148
  const { dataRoot = '' } = options;
149
149
  const explicit = String(dataRoot ?? '').trim();
150
- const dataDir = explicit !== ''
151
- ? path.resolve(explicit)
152
- : path.join(resolveStateDir(null, ''), 'decision-center');
150
+ const dataDir =
151
+ explicit !== '' ? path.resolve(explicit) : path.join(resolveStateDir(null, ''), 'decision-center');
153
152
  return { dataDir };
154
153
  }
155
154