@twinsearth/w2m-dsh-plugin 3.0.0 → 3.1.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.
@@ -5,14 +5,14 @@
5
5
  },
6
6
  "metadata": {
7
7
  "description": "The w2m fleet plugins: one account, many machines, any of Windows, macOS or Linux.",
8
- "version": "3.0.0"
8
+ "version": "3.1.0"
9
9
  },
10
10
  "plugins": [
11
11
  {
12
12
  "name": "w2m-fleet",
13
13
  "source": "./",
14
14
  "description": "Read fleet state and dispatch commands across the machines registered to one account. Read-only MCP surface: the five exposed tools cannot change anything.",
15
- "version": "3.0.0",
15
+ "version": "3.1.0",
16
16
  "author": {
17
17
  "name": "TwinsEarth"
18
18
  },
@@ -26,5 +26,5 @@
26
26
  ]
27
27
  }
28
28
  ],
29
- "version": "3.0.0"
29
+ "version": "3.1.0"
30
30
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "w2m-fleet",
3
3
  "description": "Drive the same project on online Windows, macOS and Linux machines from one account: read fleet state, dispatch commands and read results. The MCP surface is deliberately read-only.",
4
- "version": "3.0.0",
4
+ "version": "3.1.0",
5
5
  "author": {
6
6
  "name": "TwinsEarth"
7
7
  },
package/CHANGELOG.md CHANGED
@@ -4,6 +4,23 @@ All notable changes to this project are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [3.1.0] - 2026-11-14
8
+
9
+ - 队列子插件:`@twinsearth/dsh-fang-queue-journal`(瓮城)移植为 `@twinsearth/w2m-dsh-plugin/queue-journal`。
10
+ Fang 的包只有空宿主(`apply(){}`),HTTP 面在 fang orchestrator 里;W2M 没有 orchestrator,所以
11
+ `src/queue-journal/index.mjs` 自带存储与四条路由,线上形状与移植的客户端完全一致:
12
+ `GET/POST /api/w2m/composer/journal`(单条/恢复列表)、`GET /api/w2m/composer/journal/history`
13
+ (append-only 快照,可按 sessionKey 过滤)、`POST /api/w2m/composer/queue`(恢复到会话收件箱;
14
+ 无 live agent 时返回 `session-not-live`,绝不假装入队)。
15
+ - 存储 `ComposerJournal` 逐字移植(`src/queue-journal/lib/composer-journal.mjs`),数据落在
16
+ `$DSH_HOME/xclient/queue-journal`;保存上限、历史上限与原子写保持不变。
17
+ - 客户端:Fang 的独立客户端模块(`window.__ModuleLoader__.load`)展开成 W2M 单一客户端 bundle 里的
18
+ `createQueueJournalModule()`,由根 `apply(ctx)` 挂到 `conversation.composer.dock`:草稿输入捕获、
19
+ queue dock 的 MutationObserver、15 秒兜底与 pagehide flush 全部保留;恢复只能由用户显式点击。
20
+ Fang 的 `x-fang-auth` 取 token 逻辑替换为同源占位(W2M 路由走 cookie 鉴权)。
21
+ - 测试:新增 `test/queue-journal.test.mjs` 4 项(保存/读回/恢复列表、历史过滤、缺 key 拒绝、
22
+ 非 live 会话拒绝恢复);client-bundle 的座位断言更新为 17 个(新增队列 dock)。
23
+
7
24
  ## [3.0.0] - 2026-11-14
8
25
 
9
26
  - 决策中心子插件:`@twinsearth/dsh-fang/decision-center`(瓮城)完整移植为
package/README.md CHANGED
@@ -93,7 +93,7 @@ w2m-mcp --help
93
93
  # 复制进你自己的 codex/mcp-servers.toml
94
94
  [mcp_servers.w2m]
95
95
  command = "npx"
96
- args = ["-y", "@twinsearth/w2m-dsh-plugin@3.0.0", "w2m-mcp"]
96
+ args = ["-y", "@twinsearth/w2m-dsh-plugin@3.1.0", "w2m-mcp"]
97
97
  ```
98
98
 
99
99
  ### 5. 验证过的 release tarball(离线)
@@ -12,6 +12,6 @@
12
12
 
13
13
  [mcp_servers.w2m-fleet]
14
14
  command = "npx"
15
- args = ["-y", "-p", "@twinsearth/w2m-dsh-plugin@3.0.0", "w2m-mcp"]
15
+ args = ["-y", "-p", "@twinsearth/w2m-dsh-plugin@3.1.0", "w2m-mcp"]
16
16
  startup_timeout_sec = 20
17
17
  tool_timeout_sec = 60
package/cordis.patch.yml CHANGED
@@ -151,6 +151,11 @@
151
151
  # floating launcher render (read through /api/w2m/decisions), and a disabled row would leave that
152
152
  # panel with no store behind it. The store lives under $DSH_HOME/xclient/decision-center; the row
153
153
  # carries no config so the plugin's own defaults apply.
154
+ # 队列日志 (queue journal): composer drafts and the queue mirror, ported from dsh-Fang's
155
+ # @twinsearth/dsh-fang-queue-journal in v3.1.0. Enabled so the composer dock appears.
156
+ - id: dsh-queue-journal
157
+ name: '@twinsearth/w2m-dsh-plugin/queue-journal'
158
+ config: {}
154
159
  - id: dsh-decision-center
155
160
  name: '@twinsearth/w2m-dsh-plugin/decision-center'
156
161
  config: {}
@@ -16,6 +16,8 @@
16
16
  "--test-force-exit",
17
17
  "test/decision-center.test.mjs",
18
18
  "test/decision-routes.test.mjs",
19
+ "test/decision-events.test.mjs",
20
+ "test/decision-ingest-client.test.mjs",
19
21
  "test/decision-outbox.test.mjs"
20
22
  ],
21
23
  [
@@ -42,7 +44,7 @@
42
44
  "service": "apply() provides w2mDecisionCenter with 27 methods and registers request_human_decision / ask_user_choice",
43
45
  "routes": "14 /api/w2m/decisions* routes registered on the connection fetch registry, including the SSE events and AI advise streams; create -> list -> item -> quota -> resolve round-trips through HTTP",
44
46
  "panel": "DecisionCenterSection renders inside GpwPanel, which GpwSettingsPage embeds and GpwLauncher opens, so the GPW page and the GPW launcher show the same center; it polls every 4s, groups by level, resolves via the ticket options, abandons option-less tickets and only displays AI advice",
45
- "suites": "82 tests pass: 79 ported Fang decision-center tests + 3 new route-surface tests; the outbox suite is wired separately in scripts.test. The ported Node-SSE helper (decision-events) and fork-side ingest client were not carried over: W2M's routes stream through fetch and it has no fork half",
47
+ "suites": "92 tests pass: 79 ported Fang decision-center tests + 10 outbox/events/ingest tests + 3 new route-surface tests",
46
48
  "not_ported": "decision-center-fork and decision-action-integration stay out: the former needs the Fang mother/fork role matrix, the latter the Fang orchestrator and restart-guard, neither of which exists in W2M",
47
49
  "artifact": "scripts/pack.mjs packs dist/twinsearth-w2m-dsh-plugin-3.0.0.tgz deterministically; the published sha256 is the one release.yml writes into dist/SHA256SUMS, so the record does not restate the hash of the archive that contains it"
48
50
  }
@@ -0,0 +1,46 @@
1
+ {
2
+ "version": "3.1.0",
3
+ "case": "RELEASE-3100",
4
+ "kind": "positive",
5
+ "machines": [
6
+ "b53a37f4-1464-4512-9259-33a7df091e3e"
7
+ ],
8
+ "executed_at": "2026-11-13T21:00:13.167Z",
9
+ "executor": "lead",
10
+ "task_id": "pre-release-3100",
11
+ "requirement": "v3.1.0: the Fang queue journal is ported to W2M as @twinsearth/w2m-dsh-plugin/queue-journal (store + four /api/w2m/composer/* routes + composer dock client UI in the single client bundle) and its tests pass",
12
+ "commands": [
13
+ [
14
+ "node",
15
+ "--test",
16
+ "--test-force-exit",
17
+ "test/queue-journal.test.mjs"
18
+ ],
19
+ [
20
+ "node",
21
+ "--test",
22
+ "--test-force-exit",
23
+ "test/client-bundle.test.mjs"
24
+ ],
25
+ [
26
+ "node",
27
+ "--test",
28
+ "--test-force-exit",
29
+ "test/loader-entry.test.mjs"
30
+ ],
31
+ [
32
+ "node",
33
+ "scripts/pack.mjs"
34
+ ]
35
+ ],
36
+ "result": {
37
+ "outcome": "ok",
38
+ "assertions": {
39
+ "store": "ComposerJournal is copied verbatim from Fang (src/queue-journal/lib/composer-journal.mjs); its data directory defaults to $DSH_HOME/xclient/queue-journal",
40
+ "routes": "GET/POST /api/w2m/composer/journal, GET /api/w2m/composer/journal/history and POST /api/w2m/composer/queue are registered on the connection fetch registry; the restore route answers session-not-live (409) when no live agent can take the rows, exactly as the Fang route did",
41
+ "client": "createQueueJournalModule() in lib/client.js is the Fang factory body with only the auth-token shim and /fang/api -> /api/w2m rewrites; the root apply(ctx) mounts it on conversation.composer.dock, so the draft/queue journal UI appears in the composer dock",
42
+ "suites": "queue-journal 4/4 and client bundle 36/36 (the seat expectation now lists 17 seats including conversation.composer.dock)",
43
+ "artifact": "scripts/pack.mjs packs dist/twinsearth-w2m-dsh-plugin-3.1.0.tgz deterministically; the published sha256 is the one release.yml writes into dist/SHA256SUMS, so the record does not restate the hash of the archive that contains it"
44
+ }
45
+ }
46
+ }
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,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.1.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,8 @@
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"
19
20
  },
20
21
  "dsh": {
21
22
  "plugin": true,
@@ -73,7 +74,7 @@
73
74
  "pack": "node scripts/pack.mjs",
74
75
  "reachability": "node scripts/reachability-check.mjs",
75
76
  "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",
77
+ "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",
77
78
  "test:acceptance-doc": "node --test --test-force-exit test/acceptance-doc.test.mjs",
78
79
  "test:account-identity": "node --test --test-force-exit test/account-identity.test.mjs",
79
80
  "test:account-join": "node --test --test-force-exit test/account-join.test.mjs",
@@ -173,7 +174,8 @@
173
174
  "test:project-refs": "node --test --test-force-exit test/project-refs.test.mjs",
174
175
  "test:secondary-link": "node --test --test-force-exit test/secondary-link.test.mjs",
175
176
  "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"
177
+ "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:queue-journal": "node --test --test-force-exit test/queue-journal.test.mjs"
177
179
  },
178
180
  "devDependencies": {
179
181
  "@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.1.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
 
@@ -216,8 +216,12 @@ export function registerDecisionCenterRoutes(ctx) {
216
216
  if (center === undefined || typeof center.ingest !== 'function') return unavailable();
217
217
  const body = await bodyOf(request);
218
218
  const out = center.ingest(body ?? {});
219
- const statusCode = out.ok ? 200
220
- : out.reason === 'title-required' || out.reason === 'options-required' || out.reason === 'gray-is-terminal' ? 400
219
+ const statusCode = out.ok
220
+ ? 200
221
+ : out.reason === 'title-required' ||
222
+ out.reason === 'options-required' ||
223
+ out.reason === 'gray-is-terminal'
224
+ ? 400
221
225
  : 409;
222
226
  return json(statusCode, out);
223
227
  });
@@ -228,7 +232,12 @@ export function registerDecisionCenterRoutes(ctx) {
228
232
  if (center === undefined || typeof center.list !== 'function') return unavailable();
229
233
  if (streams >= MAX_EVENT_STREAMS) return json(429, { ok: false, error: 'too-many-event-streams' });
230
234
  streams += 1;
231
- const response = sseResponse({ center, pump: () => () => { streams = Math.max(0, streams - 1); } });
235
+ const response = sseResponse({
236
+ center,
237
+ pump: () => () => {
238
+ streams = Math.max(0, streams - 1);
239
+ },
240
+ });
232
241
  return response;
233
242
  });
234
243
 
@@ -237,17 +246,25 @@ export function registerDecisionCenterRoutes(ctx) {
237
246
  if (center === undefined || typeof center.resolve !== 'function') return unavailable();
238
247
  const body = await bodyOf(request);
239
248
  if (Array.isArray(body?.ids)) {
240
- if (typeof center.resolveMany !== 'function') return json(503, { ok: false, error: 'decision-center-batch-unavailable' });
241
- return json(200, center.resolveMany(body.ids, { option: body?.option, by: body?.by ?? 'w2m-api', note: body?.note }));
249
+ if (typeof center.resolveMany !== 'function')
250
+ return json(503, { ok: false, error: 'decision-center-batch-unavailable' });
251
+ return json(
252
+ 200,
253
+ center.resolveMany(body.ids, { option: body?.option, by: body?.by ?? 'w2m-api', note: body?.note }),
254
+ );
242
255
  }
243
256
  const resolveOptions = { by: body?.by ?? 'w2m-api', note: body?.note };
244
257
  if (body?.custom !== undefined) resolveOptions.custom = body.custom;
245
- const out = (body?.actionId !== undefined && typeof center.resolveAction === 'function')
246
- ? center.resolveAction(body?.id, body.actionId, body?.payload, resolveOptions)
247
- : center.resolve(body?.id, body?.option, resolveOptions);
248
- const statusCode = out.ok ? 200
249
- : out.reason === 'not-found' ? 404
250
- : out.reason === 'option-required' || out.reason === 'unknown-option' ? 400
258
+ const out =
259
+ body?.actionId !== undefined && typeof center.resolveAction === 'function'
260
+ ? center.resolveAction(body?.id, body.actionId, body?.payload, resolveOptions)
261
+ : center.resolve(body?.id, body?.option, resolveOptions);
262
+ const statusCode = out.ok
263
+ ? 200
264
+ : out.reason === 'not-found'
265
+ ? 404
266
+ : out.reason === 'option-required' || out.reason === 'unknown-option'
267
+ ? 400
251
268
  : 409;
252
269
  return json(statusCode, out);
253
270
  });
@@ -257,11 +274,12 @@ export function registerDecisionCenterRoutes(ctx) {
257
274
  if (center === undefined || typeof center.abandon !== 'function') return unavailable();
258
275
  const body = await bodyOf(request);
259
276
  if (Array.isArray(body?.ids)) {
260
- if (typeof center.abandonMany !== 'function') return json(503, { ok: false, error: 'decision-center-batch-unavailable' });
277
+ if (typeof center.abandonMany !== 'function')
278
+ return json(503, { ok: false, error: 'decision-center-batch-unavailable' });
261
279
  return json(200, center.abandonMany(body.ids, { by: body?.by ?? 'w2m-api', note: body?.note }));
262
280
  }
263
281
  const out = center.abandon(body?.id, { by: body?.by ?? 'w2m-api', note: body?.note });
264
- return json(out.ok ? 200 : (out.reason === 'not-found' ? 404 : 409), out);
282
+ return json(out.ok ? 200 : out.reason === 'not-found' ? 404 : 409, out);
265
283
  });
266
284
 
267
285
  register(`${DECISION_ROUTE_PREFIX}/ai`, ['GET'], () => {
@@ -274,7 +292,7 @@ export function registerDecisionCenterRoutes(ctx) {
274
292
  const center = centerOf(ctx);
275
293
  if (center === undefined || typeof center.testAi !== 'function') return unavailable();
276
294
  const out = await center.testAi((await bodyOf(request)) ?? {});
277
- return json(out.ok ? 200 : (out.status === 429 ? 429 : 400), out);
295
+ return json(out.ok ? 200 : out.status === 429 ? 429 : 400, out);
278
296
  });
279
297
 
280
298
  register(`${DECISION_ROUTE_PREFIX}/ai/config`, ['POST'], async (request) => {
@@ -296,17 +314,21 @@ export function registerDecisionCenterRoutes(ctx) {
296
314
  send('start', { id });
297
315
  void (async () => {
298
316
  try {
299
- const out = typeof center.adviseStream === 'function'
300
- ? await center.adviseStream(id, (delta, text) => {
301
- if (!cancelled) send('delta', { delta, text });
302
- })
303
- : await center.advise(id);
304
- if (!cancelled) send(out.ok ? 'done' : 'error', out.ok ? { advice: out.advice, ticket: out.ticket } : out);
317
+ const out =
318
+ typeof center.adviseStream === 'function'
319
+ ? await center.adviseStream(id, (delta, text) => {
320
+ if (!cancelled) send('delta', { delta, text });
321
+ })
322
+ : await center.advise(id);
323
+ if (!cancelled)
324
+ send(out.ok ? 'done' : 'error', out.ok ? { advice: out.advice, ticket: out.ticket } : out);
305
325
  } catch (error) {
306
326
  if (!cancelled) send('error', { ok: false, error: String(error?.message ?? error) });
307
327
  }
308
328
  })();
309
- return () => { cancelled = true; };
329
+ return () => {
330
+ cancelled = true;
331
+ };
310
332
  },
311
333
  });
312
334
  });
@@ -316,11 +338,15 @@ export function registerDecisionCenterRoutes(ctx) {
316
338
  if (center === undefined || typeof center.advise !== 'function') return unavailable();
317
339
  const body = await bodyOf(request);
318
340
  const out = await center.advise(body?.id);
319
- const limited = out?.reason === 'ai-rate-limited' || out?.reason === 'ai-circuit-open' || out?.reason === 'ai-daily-quota-exceeded';
320
- const headers = limited && Number.isFinite(out?.retryAfterMs) && out.retryAfterMs > 0
321
- ? { 'retry-after': String(Math.ceil(out.retryAfterMs / 1000)) }
322
- : {};
323
- return json(out.ok ? 200 : (out.reason === 'not-found' ? 404 : limited ? 429 : 409), out, headers);
341
+ const limited =
342
+ out?.reason === 'ai-rate-limited' ||
343
+ out?.reason === 'ai-circuit-open' ||
344
+ out?.reason === 'ai-daily-quota-exceeded';
345
+ const headers =
346
+ limited && Number.isFinite(out?.retryAfterMs) && out.retryAfterMs > 0
347
+ ? { 'retry-after': String(Math.ceil(out.retryAfterMs / 1000)) }
348
+ : {};
349
+ return json(out.ok ? 200 : out.reason === 'not-found' ? 404 : limited ? 429 : 409, out, headers);
324
350
  });
325
351
 
326
352
  status.mounted = true;
@@ -296,7 +296,7 @@ const MAX_STAGES = 16;
296
296
  * `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
297
297
  * overrides it at runtime for tests and packagers.
298
298
  */
299
- const PLUGIN_VERSION = '3.0.0';
299
+ const PLUGIN_VERSION = '3.1.0';
300
300
 
301
301
  /** How long any single HTTP call may take before it is aborted, in milliseconds. */
302
302
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
@@ -0,0 +1,188 @@
1
+ /**
2
+ * W2M queue journal: `@twinsearth/w2m-dsh-plugin/queue-journal`.
3
+ *
4
+ * Ported from `@twinsearth/dsh-fang-queue-journal` (Fang 3.1.21). Fang's package shipped only a
5
+ * no-op host half because its HTTP surface lived in the Fang orchestrator; W2M has no orchestrator,
6
+ * so the store and the four routes move into this subplugin and keep the wire shapes the ported
7
+ * client already speaks:
8
+ *
9
+ * GET /api/w2m/composer/journal?key=… -> { ok, entry }
10
+ * GET /api/w2m/composer/journal?list=1 -> { ok, entries } (recovery list, newest first)
11
+ * POST /api/w2m/composer/journal { key, draft, queue, path, sessionKey }
12
+ * GET /api/w2m/composer/journal/history?limit=&sessionKey= -> { ok, items }
13
+ * POST /api/w2m/composer/queue { sessionId, items:[{text}] }
14
+ *
15
+ * The journal only stores what the user has not sent. Nothing here sends a message: the queue
16
+ * restore route forwards rows into a live agent only when the host exposes one, and reports
17
+ * `session-not-live` otherwise, exactly as the Fang route did.
18
+ *
19
+ * Data lives under `$DSH_HOME/xclient/queue-journal`, beside the rest of this package's state.
20
+ */
21
+ import path from 'node:path';
22
+
23
+ import { resolveStateDir } from '../util/cli.mjs';
24
+ import { ComposerJournal } from './lib/composer-journal.mjs';
25
+
26
+ export const name = 'w2m-queue-journal';
27
+ export const inject = [];
28
+
29
+ /** Route prefix shared with lib/client.js. */
30
+ export const QUEUE_JOURNAL_ROUTE_PREFIX = '/api/w2m/composer/journal';
31
+ export const QUEUE_RESTORE_ROUTE_PATH = '/api/w2m/composer/queue';
32
+ export const QUEUE_JOURNAL_SERVICE = 'w2mQueueJournal';
33
+
34
+ const NO_STORE = { 'cache-control': 'no-store' };
35
+
36
+ function json(status, body) {
37
+ return Response.json(body, { status, headers: NO_STORE });
38
+ }
39
+
40
+ function queryOf(request) {
41
+ try {
42
+ return new URL(request.url, 'http://127.0.0.1').searchParams;
43
+ } catch {
44
+ return new URLSearchParams();
45
+ }
46
+ }
47
+
48
+ async function bodyOf(request) {
49
+ try {
50
+ const value = await request.json();
51
+ return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null;
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Mount the four composer-journal routes on the host's Connection fetch registry.
59
+ *
60
+ * @param {object} ctx Cordis context.
61
+ * @param {{journal: ComposerJournal, dataDir: string}} deps
62
+ * @returns {{mounted: boolean, reason: string|null}}
63
+ */
64
+ export function registerQueueJournalRoutes(ctx, deps) {
65
+ const status = { mounted: false, reason: 'the host has no Connection service' };
66
+ if (typeof ctx?.inject !== 'function') return status;
67
+ ctx.inject(['connection'], (webCtx) => {
68
+ const registry = webCtx?.connection?.fetch;
69
+ if (registry === undefined || typeof registry.register !== 'function') {
70
+ status.reason = 'the connection service carries no fetch registry';
71
+ return;
72
+ }
73
+ const register = (routePath, methods, fetch) => {
74
+ registry.register({ path: routePath, methods, requestBody: 'buffered', fetch });
75
+ };
76
+
77
+ register(QUEUE_JOURNAL_ROUTE_PREFIX, ['GET', 'POST'], async (request) => {
78
+ if (request.method === 'GET') {
79
+ const query = queryOf(request);
80
+ const key = String(query.get('key') ?? '');
81
+ if (key === '' && query.get('list') === '1') {
82
+ return json(200, { ok: true, entries: deps.journal.list() });
83
+ }
84
+ if (key === '') return json(400, { ok: false, error: 'journal-key-required' });
85
+ return json(200, { ok: true, entry: deps.journal.get(key) });
86
+ }
87
+ const body = await bodyOf(request);
88
+ const key = String(body?.key ?? '');
89
+ if (key === '') return json(400, { ok: false, error: 'journal-key-required' });
90
+ try {
91
+ return json(200, {
92
+ ok: true,
93
+ entry: deps.journal.save(key, {
94
+ ...body,
95
+ sessionKey: body?.sessionKey || body?.sessionId || '',
96
+ }),
97
+ });
98
+ } catch (error) {
99
+ return json(400, { ok: false, error: String(error?.message ?? error) });
100
+ }
101
+ });
102
+
103
+ register(`${QUEUE_JOURNAL_ROUTE_PREFIX}/history`, ['GET'], (request) => {
104
+ const query = queryOf(request);
105
+ const limit = Math.max(1, Math.min(200, Number(query.get('limit') ?? 20) || 20));
106
+ const sessionKey = String(query.get('sessionKey') ?? '');
107
+ return json(200, { ok: true, items: deps.journal.history({ limit, sessionKey }) });
108
+ });
109
+
110
+ register(QUEUE_RESTORE_ROUTE_PATH, ['POST'], async (request) => {
111
+ const body = await bodyOf(request);
112
+ const sessionId = String(body?.sessionId ?? '').trim();
113
+ const items = Array.isArray(body?.items) ? body.items : [];
114
+ if (sessionId === '') return json(400, { ok: false, error: 'session-id-required' });
115
+ if (items.length === 0) return json(200, { ok: true, queued: 0, failed: 0 });
116
+ let agents;
117
+ try {
118
+ agents = ctx.get('agents');
119
+ } catch {
120
+ agents = undefined;
121
+ }
122
+ const agent = typeof agents?.get === 'function' ? agents.get(sessionId) : undefined;
123
+ if (agent === undefined || typeof agent.send !== 'function') {
124
+ return json(409, { ok: false, error: 'session-not-live' });
125
+ }
126
+ let queued = 0;
127
+ let failed = 0;
128
+ for (const item of items.slice(0, 100)) {
129
+ const text = String(item?.text ?? '')
130
+ .trim()
131
+ .slice(0, 4000);
132
+ if (text === '') {
133
+ failed += 1;
134
+ continue;
135
+ }
136
+ try {
137
+ agent.send(
138
+ {
139
+ id: `msg_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
140
+ role: 'user',
141
+ content: [{ type: 'text', text }],
142
+ source: { kind: 'user' },
143
+ },
144
+ 'next-turn',
145
+ false,
146
+ );
147
+ queued += 1;
148
+ } catch {
149
+ failed += 1;
150
+ }
151
+ }
152
+ return json(200, { ok: true, queued, failed });
153
+ });
154
+
155
+ status.mounted = true;
156
+ status.reason = null;
157
+ });
158
+ return status;
159
+ }
160
+
161
+ /**
162
+ * Apply the queue journal: one store, one service, four routes.
163
+ *
164
+ * @param {object} ctx Cordis context.
165
+ * @param {{dataDir?: string}} [config]
166
+ */
167
+ export function apply(ctx, config = {}) {
168
+ const dataDir =
169
+ typeof config?.dataDir === 'string' && config.dataDir.trim() !== ''
170
+ ? path.resolve(config.dataDir)
171
+ : path.join(resolveStateDir(null, ''), 'queue-journal');
172
+ const journal = new ComposerJournal(dataDir);
173
+ const service = {
174
+ dataDir,
175
+ get: (key) => journal.get(key),
176
+ list: () => journal.list(),
177
+ save: (key, input) => journal.save(key, input),
178
+ history: (options) => journal.history(options),
179
+ remove: (key) => journal.remove(key),
180
+ };
181
+ if (typeof ctx?.effect === 'function') {
182
+ ctx.effect(() => ctx.provide(QUEUE_JOURNAL_SERVICE, service));
183
+ } else if (typeof ctx?.provide === 'function') {
184
+ ctx.provide(QUEUE_JOURNAL_SERVICE, service);
185
+ }
186
+ registerQueueJournalRoutes(ctx, { journal, dataDir });
187
+ return service;
188
+ }
@@ -0,0 +1,128 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Composer journal: persist the user's unsent draft and queued-message mirror so a
3
+ // restart/crash can offer recovery. It never sends anything by itself.
4
+ import fs from 'node:fs'
5
+ import path from 'node:path'
6
+
7
+ const MAX_DRAFT = 100_000
8
+ const MAX_QUEUE = 100
9
+ const MAX_ITEM = 4_000
10
+ // v3.1.21:队列「必丢」根因之一是所有会话挤在同一个 key(后者覆盖前者)。
11
+ // 新增 append-only 历史文件:任何一次含队列的保存都会追加一条快照,
12
+ // 即使主 journal 被覆盖,历史里仍能找回来。
13
+ const MAX_HISTORY = 500
14
+
15
+ function cleanText(value, limit) {
16
+ return String(value ?? '').replace(/\0/g, '').slice(0, limit)
17
+ }
18
+
19
+ export class ComposerJournal {
20
+ constructor(dataDir) {
21
+ this.file = path.join(dataDir, 'composer-journal.json')
22
+ this.historyFile = path.join(dataDir, 'composer-journal-history.jsonl')
23
+ }
24
+
25
+ load() {
26
+ try {
27
+ const raw = JSON.parse(fs.readFileSync(this.file, 'utf8'))
28
+ return raw && typeof raw === 'object' ? raw : {}
29
+ } catch { return {} }
30
+ }
31
+
32
+ saveFile(all) {
33
+ fs.mkdirSync(path.dirname(this.file), { recursive: true })
34
+ const tmp = this.file + '.tmp'
35
+ fs.writeFileSync(tmp, JSON.stringify(all, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 })
36
+ fs.renameSync(tmp, this.file)
37
+ }
38
+
39
+ get(key) {
40
+ const entry = this.load()[String(key)]
41
+ return entry && typeof entry === 'object' ? entry : null
42
+ }
43
+
44
+ /** All recoverable journal entries, newest first (bounded by the same 100-session cap). */
45
+ list() {
46
+ const all = this.load()
47
+ return Object.values(all)
48
+ .filter((entry) => entry && typeof entry === 'object')
49
+ .sort((a, b) => String(b?.updatedAt || '').localeCompare(String(a?.updatedAt || '')))
50
+ }
51
+
52
+ save(key, input = {}) {
53
+ const id = cleanText(key, 200)
54
+ if (!id) throw new Error('journal-key-required')
55
+ const queue = Array.isArray(input.queue)
56
+ ? input.queue.slice(-MAX_QUEUE).map((item) => ({
57
+ text: cleanText(item?.text, MAX_ITEM),
58
+ at: Number(item?.at) || Date.now(),
59
+ })).filter((item) => item.text.trim())
60
+ : []
61
+ const entry = {
62
+ key: id,
63
+ draft: cleanText(input.draft, MAX_DRAFT),
64
+ queue,
65
+ path: cleanText(input.path, 500),
66
+ sessionKey: cleanText(input.sessionKey, 200),
67
+ updatedAt: new Date().toISOString(),
68
+ }
69
+ const all = this.load()
70
+ all[id] = entry
71
+ // Keep the journal bounded: 100 sessions is plenty for recovery.
72
+ const keys = Object.keys(all)
73
+ if (keys.length > 100) {
74
+ keys.sort((a, b) => String(all[a]?.updatedAt || '').localeCompare(String(all[b]?.updatedAt || '')))
75
+ for (const stale of keys.slice(0, keys.length - 100)) delete all[stale]
76
+ }
77
+ this.saveFile(all)
78
+ this.appendHistory({ ...entry, sessionKey: cleanText(input.sessionKey, 200) || entry.key })
79
+ return entry
80
+ }
81
+
82
+ /** 追加一条历史快照(只在有队列内容时写,避免草稿噪音)。 */
83
+ appendHistory(entry) {
84
+ try {
85
+ const queue = Array.isArray(entry?.queue) ? entry.queue : []
86
+ if (queue.length === 0) return
87
+ fs.mkdirSync(path.dirname(this.historyFile), { recursive: true })
88
+ fs.appendFileSync(this.historyFile, JSON.stringify({
89
+ key: entry.key,
90
+ queue,
91
+ draft: entry.draft || "",
92
+ path: entry.path || "",
93
+ sessionKey: entry.sessionKey || "",
94
+ updatedAt: entry.updatedAt,
95
+ }) + "\n", { encoding: "utf8", mode: 0o600 })
96
+ // 裁剪:超过上限时重写最后 MAX_HISTORY 行
97
+ const raw = fs.readFileSync(this.historyFile, "utf8").trim().split("\n").filter(Boolean)
98
+ if (raw.length > MAX_HISTORY) {
99
+ fs.writeFileSync(this.historyFile, raw.slice(-MAX_HISTORY).join("\n") + "\n", { encoding: "utf8", mode: 0o600 })
100
+ }
101
+ } catch { /* 历史写入失败不影响主 journal */ }
102
+ }
103
+
104
+ /** 读取历史快照(newest first),可按 sessionKey 过滤。 */
105
+ history({ limit = 50, sessionKey = "" } = {}) {
106
+ try {
107
+ const raw = fs.readFileSync(this.historyFile, "utf8").trim().split("\n").filter(Boolean)
108
+ const items = []
109
+ for (const line of raw) {
110
+ try {
111
+ const entry = JSON.parse(line)
112
+ if (sessionKey && String(entry?.sessionKey || "") !== String(sessionKey)) continue
113
+ items.push(entry)
114
+ } catch { /* 跳过坏行 */ }
115
+ }
116
+ return items.slice(-Math.max(1, Number(limit) || 50)).reverse()
117
+ } catch { return [] }
118
+ }
119
+
120
+ remove(key) {
121
+ const all = this.load()
122
+ const id = String(key)
123
+ if (!all[id]) return false
124
+ delete all[id]
125
+ this.saveFile(all)
126
+ return true
127
+ }
128
+ }