@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.
- package/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +17 -0
- package/README.md +1 -1
- package/codex/mcp-servers.toml +1 -1
- package/cordis.patch.yml +5 -0
- package/docs/acceptance/3.0.0/RELEASE-3000.json +3 -1
- package/docs/acceptance/3.1.0/RELEASE-3100.json +46 -0
- package/lib/client.js +444 -0
- package/lib/queue-journal.js +15 -0
- package/package.json +6 -4
- package/plugin.json +1 -1
- package/src/decision-center/kernel.mjs +2 -3
- package/src/decision-center/routes.mjs +52 -26
- package/src/plugin/tools.mjs +1 -1
- package/src/queue-journal/index.mjs +188 -0
- package/src/queue-journal/lib/composer-journal.mjs +128 -0
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
96
|
+
args = ["-y", "@twinsearth/w2m-dsh-plugin@3.1.0", "w2m-mcp"]
|
|
97
97
|
```
|
|
98
98
|
|
|
99
99
|
### 5. 验证过的 release tarball(离线)
|
package/codex/mcp-servers.toml
CHANGED
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": "
|
|
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.
|
|
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.
|
|
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 =
|
|
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
|
|
220
|
-
|
|
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({
|
|
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')
|
|
241
|
-
|
|
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 =
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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')
|
|
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 :
|
|
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 :
|
|
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 =
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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 () => {
|
|
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 =
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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;
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -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.
|
|
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
|
+
}
|