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/lib/migrate.js CHANGED
@@ -1,13 +1,14 @@
1
1
  /**
2
2
  * 迁移编排:取材 → 压缩工人 → 目标会话。
3
3
  *
4
- * 只依赖注入进来的 `Rpc`,不碰 process / argv / 文件系统,所以可以对着一个
5
- * 假的 RPC(见 test/migrate.test.mjs)跑完整条链路而不烧任何 token。
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(rpc) {
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 rpc('session.list', cursor === undefined ? {} : { cursor });
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(rpc, sessionId) {
37
- return (await listSessions(rpc)).find((row) => row.sessionId === sessionId);
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(rpc, sessionId) {
41
- const res = await rpc('workspace.list', {});
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(rpc) {
46
- const res = await rpc('agentPreset.list', {});
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(rpc, source, sessionId) {
51
- const workspaceId = await findWorkspaceId(rpc, sessionId).catch(() => undefined);
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(rpc, sessionId, tier, override = {}) {
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 rpc('session.models', { sessionId });
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(rpc) {
90
- const presets = await listPresets(rpc).catch(() => []);
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(rpc, sessionId, options = {}) {
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(rpc, sessionId).catch(() => []);
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(rpc, sessionId) {
125
- const res = await rpc('session.history', { sessionId, maxMessages: 2 }, 60_000);
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(rpc, sessionId) {
129
- const events = await tailSessionEvents(rpc, sessionId);
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(rpc, sessionId, options = {}) {
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 rpc('session.history', { sessionId, maxMessages: pageMessages, ...(beforeSeq === undefined ? {} : { beforeSeq }) }, 60_000);
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(rpc, sessionId) {
155
- const messages = await foldedHistory(rpc, sessionId, { pageMessages: 20, maxPages: 1 });
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(rpc, options) {
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(rpc, options.sessionId);
170
+ const sourceSession = options.sourceSession ?? await findSession(host, options.sessionId);
162
171
  progress('拉取并折叠会话历史…');
163
- const messages = await foldedHistory(rpc, options.sessionId);
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(rpc, options.sessionId, tier, options);
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(rpc);
175
- const where = await placement(rpc, sourceSession, options.sessionId);
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 rpc('session.create', {
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 rpc('session.selectModel', { sessionId: worker.sessionId, provider: route.provider, model: route.model })
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(rpc, worker.sessionId).catch(() => 0);
192
- await rpc('session.prompt', {
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(rpc, worker.sessionId, {
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 rpc('session.cancel', { sessionId: worker.sessionId }).catch(() => undefined);
214
+ await host.sessions.cancel({ sessionId: worker.sessionId }).catch(() => undefined);
206
215
  await sleep(2500);
207
216
  }
208
- const workerSummary = await lastAssistantText(rpc, worker.sessionId);
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 rpc('workspace.archiveSession', { sessionId: worker.sessionId }).catch(() => undefined);
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(rpc, sourceSessionId, refs) {
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 rpc('session.attachment', {
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('session.attachment', 'empty-image', `附件 ${ref.attachmentId} 没有返回图片字节。`);
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.method === 'session.attachment')
279
+ if (error.code === 'unavailable' || error.code === 'empty-image')
268
280
  return true;
269
- if (error.method !== 'session.prompt' || error.code !== 'attachment-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(rpc, options) {
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.trim();
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(rpc, options.sessionId);
295
- const where = await placement(rpc, sourceSession, options.sessionId);
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 rpc('session.models', { sessionId: options.sessionId });
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 rpc('session.create', {
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 rpc('session.selectModel', { sessionId: created.sessionId, ...sourceModel });
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 rpc('session.rename', { sessionId: created.sessionId, title: options.title });
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 rpc('goal.create', {
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 rpc('goal.pause', { sessionId: created.sessionId, ref: createdGoal.ref });
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
- await rpc('goal.clear', { sessionId: created.sessionId, ref: createdGoal.ref });
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 rpc('session.cancel', { sessionId: created.sessionId }).catch(() => undefined);
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(rpc, options.sessionId));
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(rpc, options.sessionId, unresolved.refs);
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 rpc('session.prompt', {
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 (!imageFallbackAllowed(error))
413
- throw error;
414
- warnings.push(`目标模型或 host 不能接收原图,已使用逐字视觉证据/未解析提示降级:${error instanceof Error ? error.message : String(error)}`);
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
- await rpc('session.prompt', {
419
- sessionId: created.sessionId,
420
- mode: 'queue',
421
- content: [{ type: 'text', text: baseText }],
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.2.11",
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
+ }