dsh-plugin-bridge 0.2.11 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -11
- package/README.zh.md +17 -11
- package/docs/design.md +5 -3
- package/docs/guide.zh.md +13 -3
- package/docs/native-webui-feasibility.md +54 -28
- package/lib/api-rpc.d.ts +5 -2
- package/lib/api-rpc.js +10 -2
- package/lib/cli.js +12 -10
- package/lib/client-contract.d.ts +87 -0
- package/lib/client-contract.js +487 -0
- package/lib/client.d.ts +15 -0
- package/lib/client.js +1247 -0
- package/lib/client.js.map +1 -0
- package/lib/command.d.ts +10 -20
- package/lib/command.js +137 -16
- package/lib/host.d.ts +177 -0
- package/lib/host.js +102 -0
- package/lib/index.js +2 -2
- package/lib/migrate.d.ts +15 -38
- package/lib/migrate.js +104 -71
- package/package.json +48 -3
- package/reports/native-workbench-2026-08-25.md +30 -0
- package/reports/native-workbench-2026-08-25.raw.json +34 -0
package/lib/migrate.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 迁移编排:取材 → 压缩工人 → 目标会话。
|
|
3
3
|
*
|
|
4
|
-
* 只依赖注入进来的 `
|
|
5
|
-
*
|
|
4
|
+
* 只依赖注入进来的 `BridgeHost`,不碰 process / argv / 文件系统,所以可以对着
|
|
5
|
+
* 任意宿主 adapter(见 test/migrate.test.mjs)跑完整条链路而不烧任何 token。
|
|
6
6
|
* CLI(`cli.ts`)与客户端 GUI 都消费这里的函数,保证「被验证的」和
|
|
7
7
|
* 「被交付的」是同一条代码路径。
|
|
8
8
|
*/
|
|
9
9
|
import { buildBridgeInstruction, buildBridgeKickoff, buildBridgeSource, appendVisualEvidence, collectVisualEvidence, detectLang, } from './compression.js';
|
|
10
10
|
import { foldSessionEvents } from './fold.js';
|
|
11
|
+
import { asBridgeHost, missingHostCapability, } from './host.js';
|
|
11
12
|
import { RpcError, sleep } from './rpc.js';
|
|
12
13
|
/** 工人会话优先使用的 preset(工具越少越省,也不会误动工作区)。 */
|
|
13
14
|
const WORKER_PRESET_PREFERENCE = ['minimal', 'standard'];
|
|
@@ -21,11 +22,12 @@ const HISTORY_MAX_PAGES = 4;
|
|
|
21
22
|
* `session.list` 的 v1 一次返回全部,`cursor` 是预留位;这里仍按 cursor 取完,
|
|
22
23
|
* 免得上游哪天真的分页之后这里悄悄只看第一页。
|
|
23
24
|
*/
|
|
24
|
-
export async function listSessions(
|
|
25
|
+
export async function listSessions(input) {
|
|
26
|
+
const host = asBridgeHost(input);
|
|
25
27
|
const rows = [];
|
|
26
28
|
let cursor;
|
|
27
29
|
for (let page = 0; page < 20; page += 1) {
|
|
28
|
-
const res = await
|
|
30
|
+
const res = await host.sessions.list(cursor === undefined ? {} : { cursor });
|
|
29
31
|
rows.push(...(res.items ?? []));
|
|
30
32
|
if (!res.nextCursor)
|
|
31
33
|
break;
|
|
@@ -33,22 +35,24 @@ export async function listSessions(rpc) {
|
|
|
33
35
|
}
|
|
34
36
|
return rows;
|
|
35
37
|
}
|
|
36
|
-
export async function findSession(
|
|
37
|
-
return (await listSessions(
|
|
38
|
+
export async function findSession(host, sessionId) {
|
|
39
|
+
return (await listSessions(host)).find((row) => row.sessionId === sessionId);
|
|
38
40
|
}
|
|
39
41
|
/** 找出会话所属工作区;找不到就返回 undefined(调用方退回用 cwd 建会话)。 */
|
|
40
|
-
export async function findWorkspaceId(
|
|
41
|
-
const
|
|
42
|
+
export async function findWorkspaceId(input, sessionId) {
|
|
43
|
+
const host = asBridgeHost(input);
|
|
44
|
+
const res = await host.workspaces.list();
|
|
42
45
|
return res.items?.find((ws) => ws.sessionIds?.includes(sessionId))?.workspaceId;
|
|
43
46
|
}
|
|
44
47
|
/** 可作为迁移目标的 preset(去掉 broken 的)。 */
|
|
45
|
-
export async function listPresets(
|
|
46
|
-
const
|
|
48
|
+
export async function listPresets(input) {
|
|
49
|
+
const host = asBridgeHost(input);
|
|
50
|
+
const res = await host.presets.list();
|
|
47
51
|
return (res.presets ?? []).filter((preset) => preset.broken === undefined);
|
|
48
52
|
}
|
|
49
53
|
/** 新会话的落点:优先同工作区,否则同 cwd,再否则交给 host 默认。 */
|
|
50
|
-
async function placement(
|
|
51
|
-
const workspaceId = await findWorkspaceId(
|
|
54
|
+
async function placement(host, source, sessionId) {
|
|
55
|
+
const workspaceId = await findWorkspaceId(host, sessionId).catch(() => undefined);
|
|
52
56
|
if (workspaceId)
|
|
53
57
|
return { workspaceId };
|
|
54
58
|
if (source?.cwd)
|
|
@@ -62,11 +66,12 @@ async function placement(rpc, source, sessionId) {
|
|
|
62
66
|
* 里挑。挑不到就退回源会话当前模型——档位是省钱偏好,不该成为换 provider 的人
|
|
63
67
|
* 装不上的理由。
|
|
64
68
|
*/
|
|
65
|
-
export async function resolveWorkerModel(
|
|
69
|
+
export async function resolveWorkerModel(input, sessionId, tier, override = {}) {
|
|
70
|
+
const host = asBridgeHost(input);
|
|
66
71
|
if (override.provider && override.model) {
|
|
67
72
|
return { provider: override.provider, model: override.model, reason: 'configured' };
|
|
68
73
|
}
|
|
69
|
-
const models = await
|
|
74
|
+
const models = await host.sessions.models({ sessionId });
|
|
70
75
|
const current = models.current;
|
|
71
76
|
const fallback = {
|
|
72
77
|
provider: override.provider ?? current?.provider ?? '',
|
|
@@ -86,8 +91,8 @@ export async function resolveWorkerModel(rpc, sessionId, tier, override = {}) {
|
|
|
86
91
|
return fallback;
|
|
87
92
|
}
|
|
88
93
|
/** 压缩工人用哪个 preset:优先 minimal,其次 standard,再否则 host 默认。 */
|
|
89
|
-
export async function resolveWorkerPreset(
|
|
90
|
-
const presets = await listPresets(
|
|
94
|
+
export async function resolveWorkerPreset(input) {
|
|
95
|
+
const presets = await listPresets(input).catch(() => []);
|
|
91
96
|
for (const wanted of WORKER_PRESET_PREFERENCE) {
|
|
92
97
|
if (presets.some((preset) => preset.id === wanted))
|
|
93
98
|
return wanted;
|
|
@@ -101,7 +106,8 @@ export async function resolveWorkerPreset(rpc) {
|
|
|
101
106
|
* O(会话数 × 轮询次数)。`session.history` 则只读目标会话;用 prompt 前的事件
|
|
102
107
|
* 水位隔开旧轮次后,`turn/start` / `turn/end` 也比易过期的 running 快照更可靠。
|
|
103
108
|
*/
|
|
104
|
-
export async function waitIdle(
|
|
109
|
+
export async function waitIdle(input, sessionId, options = {}) {
|
|
110
|
+
const host = asBridgeHost(input);
|
|
105
111
|
const pollMs = options.pollMs ?? 2000;
|
|
106
112
|
const deadline = Date.now() + (options.timeoutMs ?? 360_000);
|
|
107
113
|
const startBy = Date.now() + (options.startGraceMs ?? 25_000);
|
|
@@ -109,7 +115,7 @@ export async function waitIdle(rpc, sessionId, options = {}) {
|
|
|
109
115
|
let started = false;
|
|
110
116
|
while (Date.now() < deadline) {
|
|
111
117
|
await sleep(pollMs);
|
|
112
|
-
const events = await tailSessionEvents(
|
|
118
|
+
const events = await tailSessionEvents(host, sessionId).catch(() => []);
|
|
113
119
|
const fresh = events.filter((event) => typeof event.seq === 'number' && event.seq > afterSeq);
|
|
114
120
|
if (fresh.some((event) => event.type === 'turn/start'))
|
|
115
121
|
started = true;
|
|
@@ -121,22 +127,24 @@ export async function waitIdle(rpc, sessionId, options = {}) {
|
|
|
121
127
|
return { idle: false, started };
|
|
122
128
|
}
|
|
123
129
|
/** 读取单个会话的尾页;不触发全局 session.list 扫描。 */
|
|
124
|
-
async function tailSessionEvents(
|
|
125
|
-
const
|
|
130
|
+
async function tailSessionEvents(input, sessionId) {
|
|
131
|
+
const host = asBridgeHost(input);
|
|
132
|
+
const res = await host.sessions.history({ sessionId, maxMessages: 2 }, { timeoutMs: 60_000 });
|
|
126
133
|
return (res.events ?? []).map((entry) => entry.event);
|
|
127
134
|
}
|
|
128
|
-
async function latestSessionSeq(
|
|
129
|
-
const events = await tailSessionEvents(
|
|
135
|
+
async function latestSessionSeq(host, sessionId) {
|
|
136
|
+
const events = await tailSessionEvents(host, sessionId);
|
|
130
137
|
return events.reduce((max, event) => (typeof event.seq === 'number' && event.seq > max ? event.seq : max), 0);
|
|
131
138
|
}
|
|
132
139
|
/** 拉取并折叠会话历史(按需翻页)。 */
|
|
133
|
-
export async function foldedHistory(
|
|
140
|
+
export async function foldedHistory(input, sessionId, options = {}) {
|
|
141
|
+
const host = asBridgeHost(input);
|
|
134
142
|
const pageMessages = options.pageMessages ?? HISTORY_PAGE_MESSAGES;
|
|
135
143
|
const maxPages = options.maxPages ?? HISTORY_MAX_PAGES;
|
|
136
144
|
let events = [];
|
|
137
145
|
let beforeSeq;
|
|
138
146
|
for (let page = 0; page < maxPages; page += 1) {
|
|
139
|
-
const res = await
|
|
147
|
+
const res = await host.sessions.history({ sessionId, maxMessages: pageMessages, ...(beforeSeq === undefined ? {} : { beforeSeq }) }, { timeoutMs: 60_000 });
|
|
140
148
|
const chunk = (res.events ?? []).map((entry) => entry.event);
|
|
141
149
|
if (!chunk.length)
|
|
142
150
|
break;
|
|
@@ -151,61 +159,62 @@ export async function foldedHistory(rpc, sessionId, options = {}) {
|
|
|
151
159
|
return foldSessionEvents(events);
|
|
152
160
|
}
|
|
153
161
|
/** 会话里最后一条非空 assistant 文本。 */
|
|
154
|
-
export async function lastAssistantText(
|
|
155
|
-
const messages = await foldedHistory(
|
|
162
|
+
export async function lastAssistantText(host, sessionId) {
|
|
163
|
+
const messages = await foldedHistory(host, sessionId, { pageMessages: 20, maxPages: 1 });
|
|
156
164
|
return [...messages].reverse().find((m) => m.role === 'assistant' && m.content.trim())?.content.trim() ?? '';
|
|
157
165
|
}
|
|
158
166
|
/** 生成交接摘要:取材 → 起临时工人 → 收摘要 → 归档工人。 */
|
|
159
|
-
export async function previewMigration(
|
|
167
|
+
export async function previewMigration(input, options) {
|
|
168
|
+
const host = asBridgeHost(input);
|
|
160
169
|
const progress = options.onProgress ?? (() => { });
|
|
161
|
-
const sourceSession = options.sourceSession ?? await findSession(
|
|
170
|
+
const sourceSession = options.sourceSession ?? await findSession(host, options.sessionId);
|
|
162
171
|
progress('拉取并折叠会话历史…');
|
|
163
|
-
const messages = await foldedHistory(
|
|
172
|
+
const messages = await foldedHistory(host, options.sessionId);
|
|
164
173
|
const source = buildBridgeSource(messages, { sourceCharBudget: options.sourceCharBudget });
|
|
165
174
|
if (!source.text.trim()) {
|
|
166
175
|
throw new RpcError('bridge.preview', 'empty-source', '这个会话还没有可迁移的内容(取材为空)。直接开一个新会话更省事。');
|
|
167
176
|
}
|
|
168
177
|
const lang = options.lang && options.lang !== 'auto' ? options.lang : detectLang(source.text);
|
|
169
178
|
const tier = options.tier ?? 'pro';
|
|
170
|
-
const route = await resolveWorkerModel(
|
|
179
|
+
const route = await resolveWorkerModel(host, options.sessionId, tier, options);
|
|
171
180
|
if (options.dryRun) {
|
|
172
181
|
return { summary: '', source, lang, worker: { ...route }, capped: false, sourceSession };
|
|
173
182
|
}
|
|
174
|
-
const preset = await resolveWorkerPreset(
|
|
175
|
-
const where = await placement(
|
|
183
|
+
const preset = await resolveWorkerPreset(host);
|
|
184
|
+
const where = await placement(host, sourceSession, options.sessionId);
|
|
176
185
|
progress(`起压缩工人(${preset ?? '默认 preset'} / ${route.model || '会话默认模型'})…`);
|
|
177
|
-
const worker = await
|
|
186
|
+
const worker = await host.sessions.create({
|
|
178
187
|
...where,
|
|
179
188
|
...(preset === undefined ? {} : { agentPreset: preset }),
|
|
180
189
|
});
|
|
181
190
|
let capped = false;
|
|
182
191
|
try {
|
|
183
192
|
if (route.provider && route.model) {
|
|
184
|
-
await
|
|
193
|
+
await host.sessions.selectModel({ sessionId: worker.sessionId, provider: route.provider, model: route.model })
|
|
185
194
|
.catch((error) => {
|
|
186
195
|
// 选模型失败不该让整次迁移失败:工人用会话默认模型照样能写摘要。
|
|
187
196
|
progress(`选模型失败,改用默认模型(${error instanceof Error ? error.message : String(error)})`);
|
|
188
197
|
});
|
|
189
198
|
}
|
|
190
199
|
const instruction = buildBridgeInstruction(lang, { summaryCharBudget: options.summaryCharBudget });
|
|
191
|
-
const workerBaselineSeq = await latestSessionSeq(
|
|
192
|
-
await
|
|
200
|
+
const workerBaselineSeq = await latestSessionSeq(host, worker.sessionId).catch(() => 0);
|
|
201
|
+
await host.sessions.prompt({
|
|
193
202
|
sessionId: worker.sessionId,
|
|
194
203
|
mode: 'queue',
|
|
195
204
|
content: [{ type: 'text', text: `${instruction}${source.text}` }],
|
|
196
205
|
});
|
|
197
206
|
progress('等待摘要…');
|
|
198
|
-
const settled = await waitIdle(
|
|
207
|
+
const settled = await waitIdle(host, worker.sessionId, {
|
|
199
208
|
timeoutMs: options.workerTimeoutMs ?? 360_000,
|
|
200
209
|
afterSeq: workerBaselineSeq,
|
|
201
210
|
...(options.pollMs === undefined ? {} : { pollMs: options.pollMs, startGraceMs: options.pollMs * 6 }),
|
|
202
211
|
});
|
|
203
212
|
if (!settled.idle) {
|
|
204
213
|
capped = true;
|
|
205
|
-
await
|
|
214
|
+
await host.sessions.cancel({ sessionId: worker.sessionId }).catch(() => undefined);
|
|
206
215
|
await sleep(2500);
|
|
207
216
|
}
|
|
208
|
-
const workerSummary = await lastAssistantText(
|
|
217
|
+
const workerSummary = await lastAssistantText(host, worker.sessionId);
|
|
209
218
|
if (!workerSummary) {
|
|
210
219
|
throw new RpcError('bridge.preview', 'worker-empty', '压缩工人没有产出摘要(可能是模型不可用或被取消)。');
|
|
211
220
|
}
|
|
@@ -221,7 +230,7 @@ export async function previewMigration(rpc, options) {
|
|
|
221
230
|
}
|
|
222
231
|
finally {
|
|
223
232
|
// 工人是一次性的:无论成败都归档,不在侧栏留垃圾。
|
|
224
|
-
await
|
|
233
|
+
await host.workspaces.archiveSession({ sessionId: worker.sessionId }).catch(() => undefined);
|
|
225
234
|
}
|
|
226
235
|
}
|
|
227
236
|
/** 找出没有关联助手正文的图片引用;已有逐字视觉证据时默认不重复烧视觉 token。 */
|
|
@@ -243,14 +252,17 @@ function unresolvedImageRefs(messages) {
|
|
|
243
252
|
}
|
|
244
253
|
return { refs, missing };
|
|
245
254
|
}
|
|
246
|
-
async function readPromptImages(
|
|
255
|
+
async function readPromptImages(host, sourceSessionId, refs) {
|
|
256
|
+
const readAttachment = host.sessions.attachment;
|
|
257
|
+
if (readAttachment === undefined)
|
|
258
|
+
throw missingHostCapability('session attachment reading');
|
|
247
259
|
return Promise.all(refs.map(async (ref) => {
|
|
248
|
-
const stored = await
|
|
260
|
+
const stored = await readAttachment({
|
|
249
261
|
sessionId: sourceSessionId,
|
|
250
262
|
attachmentId: ref.attachmentId,
|
|
251
263
|
});
|
|
252
264
|
if (!stored.data || typeof stored.data !== 'string') {
|
|
253
|
-
throw new RpcError('
|
|
265
|
+
throw new RpcError('bridge.host', 'empty-image', `附件 ${ref.attachmentId} 没有返回图片字节。`);
|
|
254
266
|
}
|
|
255
267
|
const attachment = stored.attachment ?? ref;
|
|
256
268
|
return {
|
|
@@ -264,9 +276,9 @@ async function readPromptImages(rpc, sourceSessionId, refs) {
|
|
|
264
276
|
function imageFallbackAllowed(error) {
|
|
265
277
|
if (!(error instanceof RpcError))
|
|
266
278
|
return false;
|
|
267
|
-
if (error.
|
|
279
|
+
if (error.code === 'unavailable' || error.code === 'empty-image')
|
|
268
280
|
return true;
|
|
269
|
-
if (error.
|
|
281
|
+
if (error.code !== 'attachment-error')
|
|
270
282
|
return false;
|
|
271
283
|
const reason = error.details && typeof error.details === 'object' && 'reason' in error.details
|
|
272
284
|
? String(error.details.reason)
|
|
@@ -283,19 +295,20 @@ function imageFallbackAllowed(error) {
|
|
|
283
295
|
* 摘要能被模型看见依赖 goal-round-driver 把它渲染成 `<goal_round>` 提示,
|
|
284
296
|
* 或模型主动调 `get_goal`。所以默认把摘要同时放进首轮提示:任何组装下都成立。
|
|
285
297
|
*/
|
|
286
|
-
export async function executeMigration(
|
|
298
|
+
export async function executeMigration(input, options) {
|
|
299
|
+
const host = asBridgeHost(input);
|
|
287
300
|
const progress = options.onProgress ?? (() => { });
|
|
288
301
|
const warnings = [];
|
|
289
302
|
const inject = options.inject ?? 'both';
|
|
290
303
|
const lang = options.lang ?? detectLang(options.summary);
|
|
291
|
-
const summary = options.summary
|
|
292
|
-
if (!summary)
|
|
304
|
+
const summary = options.summary;
|
|
305
|
+
if (!summary.trim())
|
|
293
306
|
throw new RpcError('bridge.migrate', 'empty-summary', '摘要为空,拒绝迁移。');
|
|
294
|
-
const sourceSession = options.sourceSession ?? await findSession(
|
|
295
|
-
const where = await placement(
|
|
307
|
+
const sourceSession = options.sourceSession ?? await findSession(host, options.sessionId);
|
|
308
|
+
const where = await placement(host, sourceSession, options.sessionId);
|
|
296
309
|
let sourceModel;
|
|
297
310
|
try {
|
|
298
|
-
const models = await
|
|
311
|
+
const models = await host.sessions.models({ sessionId: options.sessionId });
|
|
299
312
|
const current = models.current;
|
|
300
313
|
if (typeof current?.provider === 'string' && current.provider
|
|
301
314
|
&& typeof current.model === 'string' && current.model) {
|
|
@@ -312,7 +325,7 @@ export async function executeMigration(rpc, options) {
|
|
|
312
325
|
warnings.push(`读取源会话模型失败,目标将使用 host 默认模型:${error instanceof Error ? error.message : String(error)}`);
|
|
313
326
|
}
|
|
314
327
|
progress(`在 ${options.to} 模式下新建会话…`);
|
|
315
|
-
const created = await
|
|
328
|
+
const created = await host.sessions.create({
|
|
316
329
|
...where,
|
|
317
330
|
agentPreset: options.to,
|
|
318
331
|
});
|
|
@@ -323,7 +336,7 @@ export async function executeMigration(rpc, options) {
|
|
|
323
336
|
let modelTransferred = false;
|
|
324
337
|
if (sourceModel) {
|
|
325
338
|
try {
|
|
326
|
-
await
|
|
339
|
+
await host.sessions.selectModel({ sessionId: created.sessionId, ...sourceModel });
|
|
327
340
|
modelTransferred = true;
|
|
328
341
|
}
|
|
329
342
|
catch (error) {
|
|
@@ -333,7 +346,7 @@ export async function executeMigration(rpc, options) {
|
|
|
333
346
|
let titled = false;
|
|
334
347
|
if (options.title) {
|
|
335
348
|
try {
|
|
336
|
-
await
|
|
349
|
+
await host.sessions.rename({ sessionId: created.sessionId, title: options.title });
|
|
337
350
|
titled = true;
|
|
338
351
|
}
|
|
339
352
|
catch (error) {
|
|
@@ -347,14 +360,14 @@ export async function executeMigration(rpc, options) {
|
|
|
347
360
|
let imagesSent = 0;
|
|
348
361
|
if (inject === 'goal' || inject === 'both') {
|
|
349
362
|
try {
|
|
350
|
-
const createdGoal = await
|
|
363
|
+
const createdGoal = await host.goals.create({
|
|
351
364
|
sessionId: created.sessionId,
|
|
352
365
|
objective: summary,
|
|
353
366
|
maxGoalRounds: options.goalRounds ?? 1,
|
|
354
367
|
});
|
|
355
368
|
goalCreated = true;
|
|
356
369
|
try {
|
|
357
|
-
await
|
|
370
|
+
await host.goals.pause({ sessionId: created.sessionId, ref: createdGoal.ref });
|
|
358
371
|
goalPaused = true;
|
|
359
372
|
}
|
|
360
373
|
catch (error) {
|
|
@@ -363,13 +376,16 @@ export async function executeMigration(rpc, options) {
|
|
|
363
376
|
safeToKickoff = false;
|
|
364
377
|
let goalCleared = false;
|
|
365
378
|
try {
|
|
366
|
-
|
|
379
|
+
const clearGoal = host.goals.clear;
|
|
380
|
+
if (clearGoal === undefined)
|
|
381
|
+
throw missingHostCapability('goal clearing');
|
|
382
|
+
await clearGoal({ sessionId: created.sessionId, ref: createdGoal.ref });
|
|
367
383
|
goalCleared = true;
|
|
368
384
|
}
|
|
369
385
|
catch (clearError) {
|
|
370
386
|
warnings.push(`清除未暂停的交接目标失败,已继续取消目标会话;请保持目标会话关闭并手动检查:${clearError instanceof Error ? clearError.message : String(clearError)}`);
|
|
371
387
|
}
|
|
372
|
-
await
|
|
388
|
+
await host.sessions.cancel({ sessionId: created.sessionId }).catch(() => undefined);
|
|
373
389
|
warnings.push(goalCleared
|
|
374
390
|
? `暂停交接目标失败,已清除目标并取消自动启动;请打开目标会话检查后手动继续:${error instanceof Error ? error.message : String(error)}`
|
|
375
391
|
: `暂停交接目标失败,已取消自动启动但无法确认目标已清除;请打开目标会话检查后手动继续:${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -387,7 +403,7 @@ export async function executeMigration(rpc, options) {
|
|
|
387
403
|
progress('发送首轮交接指令…');
|
|
388
404
|
let unresolved = { refs: [], missing: 0 };
|
|
389
405
|
try {
|
|
390
|
-
unresolved = unresolvedImageRefs(await foldedHistory(
|
|
406
|
+
unresolved = unresolvedImageRefs(await foldedHistory(host, options.sessionId));
|
|
391
407
|
}
|
|
392
408
|
catch (error) {
|
|
393
409
|
warnings.push(`读取图片状态失败,已按纯文本交接:${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -395,33 +411,50 @@ export async function executeMigration(rpc, options) {
|
|
|
395
411
|
if (unresolved.missing > 0) {
|
|
396
412
|
warnings.push(`有 ${unresolved.missing} 张未解析图片缺少可复用的持久附件引用;目标会话必须回源会话核验。`);
|
|
397
413
|
}
|
|
414
|
+
let uncertainImagePromptFailure = false;
|
|
398
415
|
if (unresolved.refs.length) {
|
|
399
416
|
try {
|
|
400
|
-
const images = await readPromptImages(
|
|
417
|
+
const images = await readPromptImages(host, options.sessionId, unresolved.refs);
|
|
401
418
|
const transferNote = lang === 'en'
|
|
402
419
|
? 'Bridge transfer note: the unresolved source images listed above are attached to this kickoff. Inspect them directly; do not infer details that are not visible.'
|
|
403
420
|
: 'Bridge 搬运说明:上文列出的未解析源图片已附在本次 kickoff 中。请直接检查原图,不得推断看不清的细节。';
|
|
404
|
-
await
|
|
421
|
+
await host.sessions.prompt({
|
|
405
422
|
sessionId: created.sessionId,
|
|
406
423
|
mode: 'queue',
|
|
407
424
|
content: [...images, { type: 'text', text: `${baseText}\n\n${transferNote}` }],
|
|
408
425
|
});
|
|
409
426
|
imagesSent = images.length;
|
|
427
|
+
kickoffSent = true;
|
|
410
428
|
}
|
|
411
429
|
catch (error) {
|
|
412
|
-
if (
|
|
413
|
-
|
|
414
|
-
|
|
430
|
+
if (imageFallbackAllowed(error)) {
|
|
431
|
+
warnings.push(`目标模型或 host 不能接收原图,已使用逐字视觉证据/未解析提示降级:${error instanceof Error ? error.message : String(error)}`);
|
|
432
|
+
}
|
|
433
|
+
else {
|
|
434
|
+
// 目标已经存在且 goal 已暂停。对不确定是否送达的 prompt 不能盲目重试,
|
|
435
|
+
// 否则可能让模型收到两份 kickoff;把原目标交还给用户手动检查更安全。
|
|
436
|
+
uncertainImagePromptFailure = true;
|
|
437
|
+
warnings.push(lang === 'en'
|
|
438
|
+
? `The target session was created, but its image kickoff could not be confirmed. Open that target and continue manually; Bridge did not retry an ambiguously delivered prompt: ${error instanceof Error ? error.message : String(error)}`
|
|
439
|
+
: `目标会话已创建,但带图 kickoff 是否送达无法确认。请打开这个目标手动检查并继续;Bridge 没有盲目重试可能已送达的提示:${error instanceof Error ? error.message : String(error)}`);
|
|
440
|
+
}
|
|
415
441
|
}
|
|
416
442
|
}
|
|
417
|
-
if (imagesSent === 0) {
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
443
|
+
if (imagesSent === 0 && !uncertainImagePromptFailure) {
|
|
444
|
+
try {
|
|
445
|
+
await host.sessions.prompt({
|
|
446
|
+
sessionId: created.sessionId,
|
|
447
|
+
mode: 'queue',
|
|
448
|
+
content: [{ type: 'text', text: baseText }],
|
|
449
|
+
});
|
|
450
|
+
kickoffSent = true;
|
|
451
|
+
}
|
|
452
|
+
catch (error) {
|
|
453
|
+
warnings.push(lang === 'en'
|
|
454
|
+
? `The target session was created, but its kickoff could not be confirmed. Open that target and continue manually; Bridge did not create another session: ${error instanceof Error ? error.message : String(error)}`
|
|
455
|
+
: `目标会话已创建,但 kickoff 是否送达无法确认。请打开这个目标手动检查并继续;Bridge 没有再创建一个会话:${error instanceof Error ? error.message : String(error)}`);
|
|
456
|
+
}
|
|
423
457
|
}
|
|
424
|
-
kickoffSent = true;
|
|
425
458
|
}
|
|
426
459
|
return {
|
|
427
460
|
sessionId: created.sessionId,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-bridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Previewable cross-preset session migration for DeepSeek Harness with bounded, fixed-schema handoffs",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -11,12 +11,15 @@
|
|
|
11
11
|
"reports/v0.2.3-e2e-2026-08-20T13-19-13-924Z.raw.json",
|
|
12
12
|
"reports/v0.2.3-e2e-report.md",
|
|
13
13
|
"reports/v0.2.6-rc11-vision-report.md",
|
|
14
|
+
"reports/native-workbench-2026-08-25.md",
|
|
15
|
+
"reports/native-workbench-2026-08-25.raw.json",
|
|
14
16
|
"cordis.patch.yml",
|
|
15
17
|
"README.md",
|
|
16
18
|
"README.zh.md"
|
|
17
19
|
],
|
|
18
20
|
"scripts": {
|
|
19
|
-
"build": "tsc -p tsconfig.json",
|
|
21
|
+
"build": "tsc -p tsconfig.json && npm run build:client",
|
|
22
|
+
"build:client": "tsdown --config tsdown.config.ts",
|
|
20
23
|
"build:check": "npm run build && git diff --exit-code -- lib",
|
|
21
24
|
"typecheck": "tsc -p tsconfig.check.json",
|
|
22
25
|
"prepack": "npm run build",
|
|
@@ -41,20 +44,50 @@
|
|
|
41
44
|
"dsh": {
|
|
42
45
|
"bundle": {
|
|
43
46
|
"patch": "./cordis.patch.yml"
|
|
47
|
+
},
|
|
48
|
+
"client": {
|
|
49
|
+
"inject": [
|
|
50
|
+
"@deepseek-ai/dsh-api-remotes",
|
|
51
|
+
"@deepseek-ai/dsh-client-runtime",
|
|
52
|
+
"@deepseek-ai/dsh-client-ui-conversation"
|
|
53
|
+
],
|
|
54
|
+
"platform": "web"
|
|
44
55
|
}
|
|
45
56
|
},
|
|
46
57
|
"engines": {
|
|
47
58
|
"node": ">=22"
|
|
48
59
|
},
|
|
49
60
|
"peerDependencies": {
|
|
50
|
-
"@deepseek-ai/cordis": "^4.0.1"
|
|
61
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
62
|
+
"@deepseek-ai/dsh-api-remotes": "^0.1.0-rc.7 || ^0.1.1-rc.1",
|
|
63
|
+
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.7 || ^0.1.1-rc.1",
|
|
64
|
+
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.0-rc.7 || ^0.1.1-rc.1",
|
|
65
|
+
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.7 || ^0.1.1-rc.1",
|
|
66
|
+
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.7 || ^0.1.1-rc.1",
|
|
67
|
+
"react": "^18.2.0"
|
|
68
|
+
},
|
|
69
|
+
"peerDependenciesMeta": {
|
|
70
|
+
"@deepseek-ai/dsh-api-remotes": { "optional": true },
|
|
71
|
+
"@deepseek-ai/dsh-client-runtime": { "optional": true },
|
|
72
|
+
"@deepseek-ai/dsh-client-ui-conversation": { "optional": true },
|
|
73
|
+
"@deepseek-ai/dsh-client-ui-primitives": { "optional": true },
|
|
74
|
+
"@deepseek-ai/dsh-client-ui-slots": { "optional": true },
|
|
75
|
+
"react": { "optional": true }
|
|
51
76
|
},
|
|
52
77
|
"dependencies": {
|
|
53
78
|
"@deepseek-ai/schemastery": "^3.18.1"
|
|
54
79
|
},
|
|
55
80
|
"devDependencies": {
|
|
56
81
|
"@deepseek-ai/cordis": "4.0.1",
|
|
82
|
+
"@deepseek-ai/dsh-api-remotes": "^0.1.1-rc.2",
|
|
83
|
+
"@deepseek-ai/dsh-client-runtime": "^0.1.1-rc.2",
|
|
84
|
+
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.1-rc.2",
|
|
85
|
+
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.1-rc.2",
|
|
86
|
+
"@deepseek-ai/dsh-client-ui-slots": "^0.1.1-rc.2",
|
|
57
87
|
"@types/node": "^26.2.0",
|
|
88
|
+
"@types/react": "~18.3.1",
|
|
89
|
+
"react": "^18.3.1",
|
|
90
|
+
"tsdown": "^0.22.14",
|
|
58
91
|
"typescript": "^7.0.2"
|
|
59
92
|
},
|
|
60
93
|
"publishConfig": {
|
|
@@ -88,6 +121,10 @@
|
|
|
88
121
|
"types": "./lib/migrate.d.ts",
|
|
89
122
|
"default": "./lib/migrate.js"
|
|
90
123
|
},
|
|
124
|
+
"./host": {
|
|
125
|
+
"types": "./lib/host.d.ts",
|
|
126
|
+
"default": "./lib/host.js"
|
|
127
|
+
},
|
|
91
128
|
"./rpc": {
|
|
92
129
|
"types": "./lib/rpc.d.ts",
|
|
93
130
|
"default": "./lib/rpc.js"
|
|
@@ -100,6 +137,14 @@
|
|
|
100
137
|
"types": "./lib/command.d.ts",
|
|
101
138
|
"default": "./lib/command.js"
|
|
102
139
|
},
|
|
140
|
+
"./client": {
|
|
141
|
+
"types": "./lib/client.d.ts",
|
|
142
|
+
"default": "./lib/client.js"
|
|
143
|
+
},
|
|
144
|
+
"./client-contract": {
|
|
145
|
+
"types": "./lib/client-contract.d.ts",
|
|
146
|
+
"default": "./lib/client-contract.js"
|
|
147
|
+
},
|
|
103
148
|
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
104
149
|
"./package.json": "./package.json"
|
|
105
150
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Native WebUI workbench acceptance · 2026-08-25
|
|
2
|
+
|
|
3
|
+
Environment: isolated official `@deepseek-ai/dsh@0.1.1-rc.2` Web profile, locally packed Bridge `0.2.11` plus the current PR changes. The source, worker, and target sessions were synthetic and isolated from the user's normal DSH home.
|
|
4
|
+
|
|
5
|
+
## What was exercised
|
|
6
|
+
|
|
7
|
+
- the client half loaded through the official `dsh.client` module graph and occupied `conversation.chat.commandview`;
|
|
8
|
+
- `/bridge --doctor` rendered through the native card and reported 13/13 host capabilities;
|
|
9
|
+
- the running row showed immediate elapsed-time feedback without mutating the source;
|
|
10
|
+
- completed Markdown rendered through the official primitive, a complete JSON edit rendered as a keyboard-accessible tree, and the textarea round-tripped user changes;
|
|
11
|
+
- the reviewed payload was accepted by the host command without being recorded in command input;
|
|
12
|
+
- the created goal was paused, the first target turn restated the handoff, and the client automatically opened the target session.
|
|
13
|
+
|
|
14
|
+
## Fixed repeat gate
|
|
15
|
+
|
|
16
|
+
Each run carried the same five low-collision facts: port `8118`, PostgreSQL, MongoDB forbidden, `src/orders/router.ts`, and “idempotency tests only.”
|
|
17
|
+
|
|
18
|
+
| Run | Route | Preview facts | Target facts | Worker time | Target LLM time | Paused | Auto-open |
|
|
19
|
+
|---|---|---:|---:|---:|---:|---:|---:|
|
|
20
|
+
| R1 | standard → code | 5/5 | 5/5 | 12.846 s | 7.807 s | yes | yes |
|
|
21
|
+
| R2 | minimal → code | 5/5 | 5/5 | 9.713 s | 6.175 s | yes | yes |
|
|
22
|
+
| R3 | minimal → code | 5/5 | 5/5 | 7.421 s | 5.772 s | yes | yes |
|
|
23
|
+
|
|
24
|
+
R1 additionally changed `8118` to `8118(用户在 WebUI 校对)` inside the native editor; the exact marker appeared in both the stored target goal and the first target response.
|
|
25
|
+
|
|
26
|
+
The token counters and per-run records are in [`native-workbench-2026-08-25.raw.json`](native-workbench-2026-08-25.raw.json).
|
|
27
|
+
|
|
28
|
+
## Boundary
|
|
29
|
+
|
|
30
|
+
This is a three-run fixed release gate, not a latency distribution or population-level reliability claim. Worker and target latency, token use, and phrasing remain model-, preset-, cache-, and provider-dependent. Automated fake-host tests cover failure and payload bounds without spending tokens; this report covers the installed official-WebUI path that those tests cannot prove.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"date": "2026-08-25",
|
|
3
|
+
"dshVersion": "0.1.1-rc.2",
|
|
4
|
+
"bridgePackageVersion": "0.2.11",
|
|
5
|
+
"environment": "isolated official dsh web profile",
|
|
6
|
+
"workerModel": "deepseek-v4-flash",
|
|
7
|
+
"facts": ["8118", "PostgreSQL", "MongoDB forbidden", "src/orders/router.ts", "idempotency tests only"],
|
|
8
|
+
"runs": [
|
|
9
|
+
{
|
|
10
|
+
"id": "R1", "from": "standard", "to": "code",
|
|
11
|
+
"previewFacts": 5, "targetFacts": 5,
|
|
12
|
+
"workerMs": 12846, "targetMs": 7807,
|
|
13
|
+
"editedMarkerPreserved": true, "targetPaused": true, "autoOpened": true,
|
|
14
|
+
"workerTokens": { "uncachedInput": 931, "output": 1112, "cacheRead": 1024 },
|
|
15
|
+
"targetTokens": { "uncachedInput": 10415, "output": 436, "cacheRead": 0 }
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"id": "R2", "from": "minimal", "to": "code",
|
|
19
|
+
"previewFacts": 5, "targetFacts": 5,
|
|
20
|
+
"workerMs": 9713, "targetMs": 6175,
|
|
21
|
+
"targetPaused": true, "autoOpened": true,
|
|
22
|
+
"workerTokens": { "uncachedInput": 518, "output": 771, "cacheRead": 1024 },
|
|
23
|
+
"targetTokens": { "uncachedInput": 9670, "output": 278, "cacheRead": 640 }
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"id": "R3", "from": "minimal", "to": "code",
|
|
27
|
+
"previewFacts": 5, "targetFacts": 5,
|
|
28
|
+
"workerMs": 7421, "targetMs": 5772,
|
|
29
|
+
"targetPaused": true, "autoOpened": true,
|
|
30
|
+
"workerTokens": { "uncachedInput": 131, "output": 615, "cacheRead": 1408 },
|
|
31
|
+
"targetTokens": { "uncachedInput": 1622, "output": 290, "cacheRead": 8704 }
|
|
32
|
+
}
|
|
33
|
+
]
|
|
34
|
+
}
|