dsh-plugin-bridge 0.2.10 → 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;
@@ -95,39 +100,51 @@ export async function resolveWorkerPreset(rpc) {
95
100
  return presets.find((preset) => preset.isDefault)?.id;
96
101
  }
97
102
  /**
98
- * 等一个会话回到空闲。
103
+ * 等一个会话的新一轮写入 `turn/end`。
99
104
  *
100
- * 旧实现是「先 sleep 2s,再看 running 是不是 false」——host 排队稍慢一点,
101
- * 第一次轮询就会把「还没开始」误判成「已经跑完」,取到的是上一轮的回答。
102
- * 这里先等它真的 running 起来(有宽限期),再等它落回 false。
105
+ * `session.list` 是全局列表,拿它每两秒轮询一个 worker 会把会话总量放大成
106
+ * O(会话数 × 轮询次数)。`session.history` 则只读目标会话;用 prompt 前的事件
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);
114
+ const afterSeq = options.afterSeq ?? 0;
108
115
  let started = false;
109
116
  while (Date.now() < deadline) {
110
117
  await sleep(pollMs);
111
- const row = await findSession(rpc, sessionId).catch(() => undefined);
112
- if (row?.running) {
118
+ const events = await tailSessionEvents(host, sessionId).catch(() => []);
119
+ const fresh = events.filter((event) => typeof event.seq === 'number' && event.seq > afterSeq);
120
+ if (fresh.some((event) => event.type === 'turn/start'))
113
121
  started = true;
114
- continue;
115
- }
116
- if (started)
122
+ if (fresh.some((event) => event.type === 'turn/end'))
117
123
  return { idle: true, started: true };
118
- if (Date.now() > startBy)
124
+ if (!started && Date.now() > startBy)
119
125
  return { idle: true, started: false };
120
126
  }
121
127
  return { idle: false, started };
122
128
  }
129
+ /** 读取单个会话的尾页;不触发全局 session.list 扫描。 */
130
+ async function tailSessionEvents(input, sessionId) {
131
+ const host = asBridgeHost(input);
132
+ const res = await host.sessions.history({ sessionId, maxMessages: 2 }, { timeoutMs: 60_000 });
133
+ return (res.events ?? []).map((entry) => entry.event);
134
+ }
135
+ async function latestSessionSeq(host, sessionId) {
136
+ const events = await tailSessionEvents(host, sessionId);
137
+ return events.reduce((max, event) => (typeof event.seq === 'number' && event.seq > max ? event.seq : max), 0);
138
+ }
123
139
  /** 拉取并折叠会话历史(按需翻页)。 */
124
- export async function foldedHistory(rpc, sessionId, options = {}) {
140
+ export async function foldedHistory(input, sessionId, options = {}) {
141
+ const host = asBridgeHost(input);
125
142
  const pageMessages = options.pageMessages ?? HISTORY_PAGE_MESSAGES;
126
143
  const maxPages = options.maxPages ?? HISTORY_MAX_PAGES;
127
144
  let events = [];
128
145
  let beforeSeq;
129
146
  for (let page = 0; page < maxPages; page += 1) {
130
- 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 });
131
148
  const chunk = (res.events ?? []).map((entry) => entry.event);
132
149
  if (!chunk.length)
133
150
  break;
@@ -142,59 +159,62 @@ export async function foldedHistory(rpc, sessionId, options = {}) {
142
159
  return foldSessionEvents(events);
143
160
  }
144
161
  /** 会话里最后一条非空 assistant 文本。 */
145
- export async function lastAssistantText(rpc, sessionId) {
146
- 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 });
147
164
  return [...messages].reverse().find((m) => m.role === 'assistant' && m.content.trim())?.content.trim() ?? '';
148
165
  }
149
166
  /** 生成交接摘要:取材 → 起临时工人 → 收摘要 → 归档工人。 */
150
- export async function previewMigration(rpc, options) {
167
+ export async function previewMigration(input, options) {
168
+ const host = asBridgeHost(input);
151
169
  const progress = options.onProgress ?? (() => { });
152
- const sourceSession = await findSession(rpc, options.sessionId);
170
+ const sourceSession = options.sourceSession ?? await findSession(host, options.sessionId);
153
171
  progress('拉取并折叠会话历史…');
154
- const messages = await foldedHistory(rpc, options.sessionId);
172
+ const messages = await foldedHistory(host, options.sessionId);
155
173
  const source = buildBridgeSource(messages, { sourceCharBudget: options.sourceCharBudget });
156
174
  if (!source.text.trim()) {
157
175
  throw new RpcError('bridge.preview', 'empty-source', '这个会话还没有可迁移的内容(取材为空)。直接开一个新会话更省事。');
158
176
  }
159
177
  const lang = options.lang && options.lang !== 'auto' ? options.lang : detectLang(source.text);
160
178
  const tier = options.tier ?? 'pro';
161
- const route = await resolveWorkerModel(rpc, options.sessionId, tier, options);
179
+ const route = await resolveWorkerModel(host, options.sessionId, tier, options);
162
180
  if (options.dryRun) {
163
181
  return { summary: '', source, lang, worker: { ...route }, capped: false, sourceSession };
164
182
  }
165
- const preset = await resolveWorkerPreset(rpc);
166
- const where = await placement(rpc, sourceSession, options.sessionId);
183
+ const preset = await resolveWorkerPreset(host);
184
+ const where = await placement(host, sourceSession, options.sessionId);
167
185
  progress(`起压缩工人(${preset ?? '默认 preset'} / ${route.model || '会话默认模型'})…`);
168
- const worker = await rpc('session.create', {
186
+ const worker = await host.sessions.create({
169
187
  ...where,
170
188
  ...(preset === undefined ? {} : { agentPreset: preset }),
171
189
  });
172
190
  let capped = false;
173
191
  try {
174
192
  if (route.provider && route.model) {
175
- 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 })
176
194
  .catch((error) => {
177
195
  // 选模型失败不该让整次迁移失败:工人用会话默认模型照样能写摘要。
178
196
  progress(`选模型失败,改用默认模型(${error instanceof Error ? error.message : String(error)})`);
179
197
  });
180
198
  }
181
199
  const instruction = buildBridgeInstruction(lang, { summaryCharBudget: options.summaryCharBudget });
182
- await rpc('session.prompt', {
200
+ const workerBaselineSeq = await latestSessionSeq(host, worker.sessionId).catch(() => 0);
201
+ await host.sessions.prompt({
183
202
  sessionId: worker.sessionId,
184
203
  mode: 'queue',
185
204
  content: [{ type: 'text', text: `${instruction}${source.text}` }],
186
205
  });
187
206
  progress('等待摘要…');
188
- const settled = await waitIdle(rpc, worker.sessionId, {
207
+ const settled = await waitIdle(host, worker.sessionId, {
189
208
  timeoutMs: options.workerTimeoutMs ?? 360_000,
209
+ afterSeq: workerBaselineSeq,
190
210
  ...(options.pollMs === undefined ? {} : { pollMs: options.pollMs, startGraceMs: options.pollMs * 6 }),
191
211
  });
192
212
  if (!settled.idle) {
193
213
  capped = true;
194
- await rpc('session.cancel', { sessionId: worker.sessionId }).catch(() => undefined);
214
+ await host.sessions.cancel({ sessionId: worker.sessionId }).catch(() => undefined);
195
215
  await sleep(2500);
196
216
  }
197
- const workerSummary = await lastAssistantText(rpc, worker.sessionId);
217
+ const workerSummary = await lastAssistantText(host, worker.sessionId);
198
218
  if (!workerSummary) {
199
219
  throw new RpcError('bridge.preview', 'worker-empty', '压缩工人没有产出摘要(可能是模型不可用或被取消)。');
200
220
  }
@@ -210,7 +230,7 @@ export async function previewMigration(rpc, options) {
210
230
  }
211
231
  finally {
212
232
  // 工人是一次性的:无论成败都归档,不在侧栏留垃圾。
213
- await rpc('workspace.archiveSession', { sessionId: worker.sessionId }).catch(() => undefined);
233
+ await host.workspaces.archiveSession({ sessionId: worker.sessionId }).catch(() => undefined);
214
234
  }
215
235
  }
216
236
  /** 找出没有关联助手正文的图片引用;已有逐字视觉证据时默认不重复烧视觉 token。 */
@@ -232,14 +252,17 @@ function unresolvedImageRefs(messages) {
232
252
  }
233
253
  return { refs, missing };
234
254
  }
235
- 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');
236
259
  return Promise.all(refs.map(async (ref) => {
237
- const stored = await rpc('session.attachment', {
260
+ const stored = await readAttachment({
238
261
  sessionId: sourceSessionId,
239
262
  attachmentId: ref.attachmentId,
240
263
  });
241
264
  if (!stored.data || typeof stored.data !== 'string') {
242
- throw new RpcError('session.attachment', 'empty-image', `附件 ${ref.attachmentId} 没有返回图片字节。`);
265
+ throw new RpcError('bridge.host', 'empty-image', `附件 ${ref.attachmentId} 没有返回图片字节。`);
243
266
  }
244
267
  const attachment = stored.attachment ?? ref;
245
268
  return {
@@ -253,9 +276,9 @@ async function readPromptImages(rpc, sourceSessionId, refs) {
253
276
  function imageFallbackAllowed(error) {
254
277
  if (!(error instanceof RpcError))
255
278
  return false;
256
- if (error.method === 'session.attachment')
279
+ if (error.code === 'unavailable' || error.code === 'empty-image')
257
280
  return true;
258
- if (error.method !== 'session.prompt' || error.code !== 'attachment-error')
281
+ if (error.code !== 'attachment-error')
259
282
  return false;
260
283
  const reason = error.details && typeof error.details === 'object' && 'reason' in error.details
261
284
  ? String(error.details.reason)
@@ -272,19 +295,20 @@ function imageFallbackAllowed(error) {
272
295
  * 摘要能被模型看见依赖 goal-round-driver 把它渲染成 `<goal_round>` 提示,
273
296
  * 或模型主动调 `get_goal`。所以默认把摘要同时放进首轮提示:任何组装下都成立。
274
297
  */
275
- export async function executeMigration(rpc, options) {
298
+ export async function executeMigration(input, options) {
299
+ const host = asBridgeHost(input);
276
300
  const progress = options.onProgress ?? (() => { });
277
301
  const warnings = [];
278
302
  const inject = options.inject ?? 'both';
279
303
  const lang = options.lang ?? detectLang(options.summary);
280
- const summary = options.summary.trim();
281
- if (!summary)
304
+ const summary = options.summary;
305
+ if (!summary.trim())
282
306
  throw new RpcError('bridge.migrate', 'empty-summary', '摘要为空,拒绝迁移。');
283
- const sourceSession = await findSession(rpc, options.sessionId);
284
- 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);
285
309
  let sourceModel;
286
310
  try {
287
- const models = await rpc('session.models', { sessionId: options.sessionId });
311
+ const models = await host.sessions.models({ sessionId: options.sessionId });
288
312
  const current = models.current;
289
313
  if (typeof current?.provider === 'string' && current.provider
290
314
  && typeof current.model === 'string' && current.model) {
@@ -301,7 +325,7 @@ export async function executeMigration(rpc, options) {
301
325
  warnings.push(`读取源会话模型失败,目标将使用 host 默认模型:${error instanceof Error ? error.message : String(error)}`);
302
326
  }
303
327
  progress(`在 ${options.to} 模式下新建会话…`);
304
- const created = await rpc('session.create', {
328
+ const created = await host.sessions.create({
305
329
  ...where,
306
330
  agentPreset: options.to,
307
331
  });
@@ -312,7 +336,7 @@ export async function executeMigration(rpc, options) {
312
336
  let modelTransferred = false;
313
337
  if (sourceModel) {
314
338
  try {
315
- await rpc('session.selectModel', { sessionId: created.sessionId, ...sourceModel });
339
+ await host.sessions.selectModel({ sessionId: created.sessionId, ...sourceModel });
316
340
  modelTransferred = true;
317
341
  }
318
342
  catch (error) {
@@ -322,7 +346,7 @@ export async function executeMigration(rpc, options) {
322
346
  let titled = false;
323
347
  if (options.title) {
324
348
  try {
325
- await rpc('session.rename', { sessionId: created.sessionId, title: options.title });
349
+ await host.sessions.rename({ sessionId: created.sessionId, title: options.title });
326
350
  titled = true;
327
351
  }
328
352
  catch (error) {
@@ -336,14 +360,14 @@ export async function executeMigration(rpc, options) {
336
360
  let imagesSent = 0;
337
361
  if (inject === 'goal' || inject === 'both') {
338
362
  try {
339
- const createdGoal = await rpc('goal.create', {
363
+ const createdGoal = await host.goals.create({
340
364
  sessionId: created.sessionId,
341
365
  objective: summary,
342
366
  maxGoalRounds: options.goalRounds ?? 1,
343
367
  });
344
368
  goalCreated = true;
345
369
  try {
346
- await rpc('goal.pause', { sessionId: created.sessionId, ref: createdGoal.ref });
370
+ await host.goals.pause({ sessionId: created.sessionId, ref: createdGoal.ref });
347
371
  goalPaused = true;
348
372
  }
349
373
  catch (error) {
@@ -352,13 +376,16 @@ export async function executeMigration(rpc, options) {
352
376
  safeToKickoff = false;
353
377
  let goalCleared = false;
354
378
  try {
355
- 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 });
356
383
  goalCleared = true;
357
384
  }
358
385
  catch (clearError) {
359
386
  warnings.push(`清除未暂停的交接目标失败,已继续取消目标会话;请保持目标会话关闭并手动检查:${clearError instanceof Error ? clearError.message : String(clearError)}`);
360
387
  }
361
- await rpc('session.cancel', { sessionId: created.sessionId }).catch(() => undefined);
388
+ await host.sessions.cancel({ sessionId: created.sessionId }).catch(() => undefined);
362
389
  warnings.push(goalCleared
363
390
  ? `暂停交接目标失败,已清除目标并取消自动启动;请打开目标会话检查后手动继续:${error instanceof Error ? error.message : String(error)}`
364
391
  : `暂停交接目标失败,已取消自动启动但无法确认目标已清除;请打开目标会话检查后手动继续:${error instanceof Error ? error.message : String(error)}`);
@@ -376,7 +403,7 @@ export async function executeMigration(rpc, options) {
376
403
  progress('发送首轮交接指令…');
377
404
  let unresolved = { refs: [], missing: 0 };
378
405
  try {
379
- unresolved = unresolvedImageRefs(await foldedHistory(rpc, options.sessionId));
406
+ unresolved = unresolvedImageRefs(await foldedHistory(host, options.sessionId));
380
407
  }
381
408
  catch (error) {
382
409
  warnings.push(`读取图片状态失败,已按纯文本交接:${error instanceof Error ? error.message : String(error)}`);
@@ -384,33 +411,50 @@ export async function executeMigration(rpc, options) {
384
411
  if (unresolved.missing > 0) {
385
412
  warnings.push(`有 ${unresolved.missing} 张未解析图片缺少可复用的持久附件引用;目标会话必须回源会话核验。`);
386
413
  }
414
+ let uncertainImagePromptFailure = false;
387
415
  if (unresolved.refs.length) {
388
416
  try {
389
- const images = await readPromptImages(rpc, options.sessionId, unresolved.refs);
417
+ const images = await readPromptImages(host, options.sessionId, unresolved.refs);
390
418
  const transferNote = lang === 'en'
391
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.'
392
420
  : 'Bridge 搬运说明:上文列出的未解析源图片已附在本次 kickoff 中。请直接检查原图,不得推断看不清的细节。';
393
- await rpc('session.prompt', {
421
+ await host.sessions.prompt({
394
422
  sessionId: created.sessionId,
395
423
  mode: 'queue',
396
424
  content: [...images, { type: 'text', text: `${baseText}\n\n${transferNote}` }],
397
425
  });
398
426
  imagesSent = images.length;
427
+ kickoffSent = true;
399
428
  }
400
429
  catch (error) {
401
- if (!imageFallbackAllowed(error))
402
- throw error;
403
- 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
+ }
404
441
  }
405
442
  }
406
- if (imagesSent === 0) {
407
- await rpc('session.prompt', {
408
- sessionId: created.sessionId,
409
- mode: 'queue',
410
- content: [{ type: 'text', text: baseText }],
411
- });
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
+ }
412
457
  }
413
- kickoffSent = true;
414
458
  }
415
459
  return {
416
460
  sessionId: created.sessionId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-bridge",
3
- "version": "0.2.10",
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
+ }