dsh-plugin-bridge 0.2.11 → 0.3.1

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.d.ts CHANGED
@@ -1,59 +1,38 @@
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 { type BridgeSource } from './compression.ts';
10
- import { type Rpc } from './rpc.ts';
10
+ import { type BridgeHostInput, type ModelSelection, type PresetRow, type SessionRow } from './host.ts';
11
11
  import type { ChatMessage } from './types.ts';
12
+ export type { ModelSelection, PresetRow, SessionRow } from './host.ts';
12
13
  export type ModelTier = 'flash' | 'current' | 'pro';
13
14
  export type InjectMode = 'goal' | 'prompt' | 'both';
14
15
  export type Lang = 'zh' | 'en' | 'auto';
15
- export interface SessionRow {
16
- sessionId: string;
17
- running?: boolean;
18
- blank?: boolean;
19
- cwd?: string;
20
- agentPreset?: string;
21
- parentSessionId?: string;
22
- projections?: {
23
- values?: Record<string, unknown>;
24
- };
25
- }
26
- export interface PresetRow {
27
- id: string;
28
- trust?: 'system' | 'user';
29
- isDefault?: boolean;
30
- name?: string;
31
- description?: string;
32
- broken?: string;
33
- }
34
16
  export interface ModelRoute {
35
17
  provider: string;
36
18
  model: string;
37
19
  /** 为什么选中它:configured / follow-session / tier:<tier> / fallback-current。 */
38
20
  reason: string;
39
21
  }
40
- export interface ModelSelection {
41
- provider: string;
42
- model: string;
43
- reasoningEffort?: string;
44
- }
45
22
  /**
46
23
  * 列出全部会话。
47
24
  *
48
25
  * `session.list` 的 v1 一次返回全部,`cursor` 是预留位;这里仍按 cursor 取完,
49
26
  * 免得上游哪天真的分页之后这里悄悄只看第一页。
50
27
  */
51
- export declare function listSessions(rpc: Rpc): Promise<SessionRow[]>;
52
- export declare function findSession(rpc: Rpc, sessionId: string): Promise<SessionRow | undefined>;
28
+ export declare function listSessions(input: BridgeHostInput): Promise<SessionRow[]>;
29
+ export declare function findSession(host: BridgeHostInput, sessionId: string): Promise<SessionRow | undefined>;
53
30
  /** 找出会话所属工作区;找不到就返回 undefined(调用方退回用 cwd 建会话)。 */
54
- export declare function findWorkspaceId(rpc: Rpc, sessionId: string): Promise<string | undefined>;
31
+ export declare function findWorkspaceId(input: BridgeHostInput, sessionId: string): Promise<string | undefined>;
55
32
  /** 可作为迁移目标的 preset(去掉 broken 的)。 */
56
- export declare function listPresets(rpc: Rpc): Promise<PresetRow[]>;
33
+ export declare function listPresets(input: BridgeHostInput): Promise<PresetRow[]>;
34
+ /** Map the one upstream internal rename without making either generation mandatory. */
35
+ export declare function resolvePresetTarget(requested: string, presets: readonly PresetRow[]): string;
57
36
  /**
58
37
  * 选压缩工人的模型。
59
38
  *
@@ -61,12 +40,12 @@ export declare function listPresets(rpc: Rpc): Promise<PresetRow[]>;
61
40
  * 里挑。挑不到就退回源会话当前模型——档位是省钱偏好,不该成为换 provider 的人
62
41
  * 装不上的理由。
63
42
  */
64
- export declare function resolveWorkerModel(rpc: Rpc, sessionId: string, tier: ModelTier, override?: {
43
+ export declare function resolveWorkerModel(input: BridgeHostInput, sessionId: string, tier: ModelTier, override?: {
65
44
  provider?: string;
66
45
  model?: string;
67
46
  }): Promise<ModelRoute>;
68
47
  /** 压缩工人用哪个 preset:优先 minimal,其次 standard,再否则 host 默认。 */
69
- export declare function resolveWorkerPreset(rpc: Rpc): Promise<string | undefined>;
48
+ export declare function resolveWorkerPreset(input: BridgeHostInput): Promise<string | undefined>;
70
49
  export interface WaitOptions {
71
50
  timeoutMs?: number;
72
51
  /** 等待新 `turn/start` 的宽限期;超过仍未出现就按未启动处理。 */
@@ -82,17 +61,17 @@ export interface WaitOptions {
82
61
  * O(会话数 × 轮询次数)。`session.history` 则只读目标会话;用 prompt 前的事件
83
62
  * 水位隔开旧轮次后,`turn/start` / `turn/end` 也比易过期的 running 快照更可靠。
84
63
  */
85
- export declare function waitIdle(rpc: Rpc, sessionId: string, options?: WaitOptions): Promise<{
64
+ export declare function waitIdle(input: BridgeHostInput, sessionId: string, options?: WaitOptions): Promise<{
86
65
  idle: boolean;
87
66
  started: boolean;
88
67
  }>;
89
68
  /** 拉取并折叠会话历史(按需翻页)。 */
90
- export declare function foldedHistory(rpc: Rpc, sessionId: string, options?: {
69
+ export declare function foldedHistory(input: BridgeHostInput, sessionId: string, options?: {
91
70
  pageMessages?: number;
92
71
  maxPages?: number;
93
72
  }): Promise<ChatMessage[]>;
94
73
  /** 会话里最后一条非空 assistant 文本。 */
95
- export declare function lastAssistantText(rpc: Rpc, sessionId: string): Promise<string>;
74
+ export declare function lastAssistantText(host: BridgeHostInput, sessionId: string): Promise<string>;
96
75
  export interface PreviewOptions {
97
76
  sessionId: string;
98
77
  /** 同一命令已经读取过的源会话行,避免重复扫描全局列表。 */
@@ -126,7 +105,7 @@ export interface PreviewResult {
126
105
  sourceSession: SessionRow | undefined;
127
106
  }
128
107
  /** 生成交接摘要:取材 → 起临时工人 → 收摘要 → 归档工人。 */
129
- export declare function previewMigration(rpc: Rpc, options: PreviewOptions): Promise<PreviewResult>;
108
+ export declare function previewMigration(input: BridgeHostInput, options: PreviewOptions): Promise<PreviewResult>;
130
109
  export interface MigrateOptions {
131
110
  sessionId: string;
132
111
  /** 同一流程已经读取过的源会话行,避免重复扫描全局列表。 */
@@ -175,7 +154,7 @@ export interface MigrateResult {
175
154
  * 摘要能被模型看见依赖 goal-round-driver 把它渲染成 `<goal_round>` 提示,
176
155
  * 或模型主动调 `get_goal`。所以默认把摘要同时放进首轮提示:任何组装下都成立。
177
156
  */
178
- export declare function executeMigration(rpc: Rpc, options: MigrateOptions): Promise<MigrateResult>;
157
+ export declare function executeMigration(input: BridgeHostInput, options: MigrateOptions): Promise<MigrateResult>;
179
158
  /** 默认标题:让新会话在侧栏里一眼看得出来源。 */
180
159
  export declare function migratedTitle(sourceTitle: string | undefined, to: string): string;
181
160
  /** 从 session.list 行里尽力取出标题(projection 形状随部署而异)。 */
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,34 @@ 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
  }
53
+ /** Map the one upstream internal rename without making either generation mandatory. */
54
+ export function resolvePresetTarget(requested, presets) {
55
+ if (presets.some(preset => preset.id === requested))
56
+ return requested;
57
+ if (requested === 'code' && presets.some(preset => preset.id === 'ptc'))
58
+ return 'ptc';
59
+ if (requested === 'ptc' && presets.some(preset => preset.id === 'code'))
60
+ return 'code';
61
+ return requested;
62
+ }
49
63
  /** 新会话的落点:优先同工作区,否则同 cwd,再否则交给 host 默认。 */
50
- async function placement(rpc, source, sessionId) {
51
- const workspaceId = await findWorkspaceId(rpc, sessionId).catch(() => undefined);
64
+ async function placement(host, source, sessionId) {
65
+ const workspaceId = await findWorkspaceId(host, sessionId).catch(() => undefined);
52
66
  if (workspaceId)
53
67
  return { workspaceId };
54
68
  if (source?.cwd)
@@ -62,11 +76,12 @@ async function placement(rpc, source, sessionId) {
62
76
  * 里挑。挑不到就退回源会话当前模型——档位是省钱偏好,不该成为换 provider 的人
63
77
  * 装不上的理由。
64
78
  */
65
- export async function resolveWorkerModel(rpc, sessionId, tier, override = {}) {
79
+ export async function resolveWorkerModel(input, sessionId, tier, override = {}) {
80
+ const host = asBridgeHost(input);
66
81
  if (override.provider && override.model) {
67
82
  return { provider: override.provider, model: override.model, reason: 'configured' };
68
83
  }
69
- const models = await rpc('session.models', { sessionId });
84
+ const models = await host.sessions.models({ sessionId });
70
85
  const current = models.current;
71
86
  const fallback = {
72
87
  provider: override.provider ?? current?.provider ?? '',
@@ -86,8 +101,8 @@ export async function resolveWorkerModel(rpc, sessionId, tier, override = {}) {
86
101
  return fallback;
87
102
  }
88
103
  /** 压缩工人用哪个 preset:优先 minimal,其次 standard,再否则 host 默认。 */
89
- export async function resolveWorkerPreset(rpc) {
90
- const presets = await listPresets(rpc).catch(() => []);
104
+ export async function resolveWorkerPreset(input) {
105
+ const presets = await listPresets(input).catch(() => []);
91
106
  for (const wanted of WORKER_PRESET_PREFERENCE) {
92
107
  if (presets.some((preset) => preset.id === wanted))
93
108
  return wanted;
@@ -101,7 +116,8 @@ export async function resolveWorkerPreset(rpc) {
101
116
  * O(会话数 × 轮询次数)。`session.history` 则只读目标会话;用 prompt 前的事件
102
117
  * 水位隔开旧轮次后,`turn/start` / `turn/end` 也比易过期的 running 快照更可靠。
103
118
  */
104
- export async function waitIdle(rpc, sessionId, options = {}) {
119
+ export async function waitIdle(input, sessionId, options = {}) {
120
+ const host = asBridgeHost(input);
105
121
  const pollMs = options.pollMs ?? 2000;
106
122
  const deadline = Date.now() + (options.timeoutMs ?? 360_000);
107
123
  const startBy = Date.now() + (options.startGraceMs ?? 25_000);
@@ -109,7 +125,7 @@ export async function waitIdle(rpc, sessionId, options = {}) {
109
125
  let started = false;
110
126
  while (Date.now() < deadline) {
111
127
  await sleep(pollMs);
112
- const events = await tailSessionEvents(rpc, sessionId).catch(() => []);
128
+ const events = await tailSessionEvents(host, sessionId).catch(() => []);
113
129
  const fresh = events.filter((event) => typeof event.seq === 'number' && event.seq > afterSeq);
114
130
  if (fresh.some((event) => event.type === 'turn/start'))
115
131
  started = true;
@@ -121,22 +137,24 @@ export async function waitIdle(rpc, sessionId, options = {}) {
121
137
  return { idle: false, started };
122
138
  }
123
139
  /** 读取单个会话的尾页;不触发全局 session.list 扫描。 */
124
- async function tailSessionEvents(rpc, sessionId) {
125
- const res = await rpc('session.history', { sessionId, maxMessages: 2 }, 60_000);
140
+ async function tailSessionEvents(input, sessionId) {
141
+ const host = asBridgeHost(input);
142
+ const res = await host.sessions.history({ sessionId, maxMessages: 2 }, { timeoutMs: 60_000 });
126
143
  return (res.events ?? []).map((entry) => entry.event);
127
144
  }
128
- async function latestSessionSeq(rpc, sessionId) {
129
- const events = await tailSessionEvents(rpc, sessionId);
145
+ async function latestSessionSeq(host, sessionId) {
146
+ const events = await tailSessionEvents(host, sessionId);
130
147
  return events.reduce((max, event) => (typeof event.seq === 'number' && event.seq > max ? event.seq : max), 0);
131
148
  }
132
149
  /** 拉取并折叠会话历史(按需翻页)。 */
133
- export async function foldedHistory(rpc, sessionId, options = {}) {
150
+ export async function foldedHistory(input, sessionId, options = {}) {
151
+ const host = asBridgeHost(input);
134
152
  const pageMessages = options.pageMessages ?? HISTORY_PAGE_MESSAGES;
135
153
  const maxPages = options.maxPages ?? HISTORY_MAX_PAGES;
136
154
  let events = [];
137
155
  let beforeSeq;
138
156
  for (let page = 0; page < maxPages; page += 1) {
139
- const res = await rpc('session.history', { sessionId, maxMessages: pageMessages, ...(beforeSeq === undefined ? {} : { beforeSeq }) }, 60_000);
157
+ const res = await host.sessions.history({ sessionId, maxMessages: pageMessages, ...(beforeSeq === undefined ? {} : { beforeSeq }) }, { timeoutMs: 60_000 });
140
158
  const chunk = (res.events ?? []).map((entry) => entry.event);
141
159
  if (!chunk.length)
142
160
  break;
@@ -151,61 +169,62 @@ export async function foldedHistory(rpc, sessionId, options = {}) {
151
169
  return foldSessionEvents(events);
152
170
  }
153
171
  /** 会话里最后一条非空 assistant 文本。 */
154
- export async function lastAssistantText(rpc, sessionId) {
155
- const messages = await foldedHistory(rpc, sessionId, { pageMessages: 20, maxPages: 1 });
172
+ export async function lastAssistantText(host, sessionId) {
173
+ const messages = await foldedHistory(host, sessionId, { pageMessages: 20, maxPages: 1 });
156
174
  return [...messages].reverse().find((m) => m.role === 'assistant' && m.content.trim())?.content.trim() ?? '';
157
175
  }
158
176
  /** 生成交接摘要:取材 → 起临时工人 → 收摘要 → 归档工人。 */
159
- export async function previewMigration(rpc, options) {
177
+ export async function previewMigration(input, options) {
178
+ const host = asBridgeHost(input);
160
179
  const progress = options.onProgress ?? (() => { });
161
- const sourceSession = options.sourceSession ?? await findSession(rpc, options.sessionId);
180
+ const sourceSession = options.sourceSession ?? await findSession(host, options.sessionId);
162
181
  progress('拉取并折叠会话历史…');
163
- const messages = await foldedHistory(rpc, options.sessionId);
182
+ const messages = await foldedHistory(host, options.sessionId);
164
183
  const source = buildBridgeSource(messages, { sourceCharBudget: options.sourceCharBudget });
165
184
  if (!source.text.trim()) {
166
185
  throw new RpcError('bridge.preview', 'empty-source', '这个会话还没有可迁移的内容(取材为空)。直接开一个新会话更省事。');
167
186
  }
168
187
  const lang = options.lang && options.lang !== 'auto' ? options.lang : detectLang(source.text);
169
188
  const tier = options.tier ?? 'pro';
170
- const route = await resolveWorkerModel(rpc, options.sessionId, tier, options);
189
+ const route = await resolveWorkerModel(host, options.sessionId, tier, options);
171
190
  if (options.dryRun) {
172
191
  return { summary: '', source, lang, worker: { ...route }, capped: false, sourceSession };
173
192
  }
174
- const preset = await resolveWorkerPreset(rpc);
175
- const where = await placement(rpc, sourceSession, options.sessionId);
193
+ const preset = await resolveWorkerPreset(host);
194
+ const where = await placement(host, sourceSession, options.sessionId);
176
195
  progress(`起压缩工人(${preset ?? '默认 preset'} / ${route.model || '会话默认模型'})…`);
177
- const worker = await rpc('session.create', {
196
+ const worker = await host.sessions.create({
178
197
  ...where,
179
198
  ...(preset === undefined ? {} : { agentPreset: preset }),
180
199
  });
181
200
  let capped = false;
182
201
  try {
183
202
  if (route.provider && route.model) {
184
- await rpc('session.selectModel', { sessionId: worker.sessionId, provider: route.provider, model: route.model })
203
+ await host.sessions.selectModel({ sessionId: worker.sessionId, provider: route.provider, model: route.model })
185
204
  .catch((error) => {
186
205
  // 选模型失败不该让整次迁移失败:工人用会话默认模型照样能写摘要。
187
206
  progress(`选模型失败,改用默认模型(${error instanceof Error ? error.message : String(error)})`);
188
207
  });
189
208
  }
190
209
  const instruction = buildBridgeInstruction(lang, { summaryCharBudget: options.summaryCharBudget });
191
- const workerBaselineSeq = await latestSessionSeq(rpc, worker.sessionId).catch(() => 0);
192
- await rpc('session.prompt', {
210
+ const workerBaselineSeq = await latestSessionSeq(host, worker.sessionId).catch(() => 0);
211
+ await host.sessions.prompt({
193
212
  sessionId: worker.sessionId,
194
213
  mode: 'queue',
195
214
  content: [{ type: 'text', text: `${instruction}${source.text}` }],
196
215
  });
197
216
  progress('等待摘要…');
198
- const settled = await waitIdle(rpc, worker.sessionId, {
217
+ const settled = await waitIdle(host, worker.sessionId, {
199
218
  timeoutMs: options.workerTimeoutMs ?? 360_000,
200
219
  afterSeq: workerBaselineSeq,
201
220
  ...(options.pollMs === undefined ? {} : { pollMs: options.pollMs, startGraceMs: options.pollMs * 6 }),
202
221
  });
203
222
  if (!settled.idle) {
204
223
  capped = true;
205
- await rpc('session.cancel', { sessionId: worker.sessionId }).catch(() => undefined);
224
+ await host.sessions.cancel({ sessionId: worker.sessionId }).catch(() => undefined);
206
225
  await sleep(2500);
207
226
  }
208
- const workerSummary = await lastAssistantText(rpc, worker.sessionId);
227
+ const workerSummary = await lastAssistantText(host, worker.sessionId);
209
228
  if (!workerSummary) {
210
229
  throw new RpcError('bridge.preview', 'worker-empty', '压缩工人没有产出摘要(可能是模型不可用或被取消)。');
211
230
  }
@@ -221,7 +240,7 @@ export async function previewMigration(rpc, options) {
221
240
  }
222
241
  finally {
223
242
  // 工人是一次性的:无论成败都归档,不在侧栏留垃圾。
224
- await rpc('workspace.archiveSession', { sessionId: worker.sessionId }).catch(() => undefined);
243
+ await host.workspaces.archiveSession({ sessionId: worker.sessionId }).catch(() => undefined);
225
244
  }
226
245
  }
227
246
  /** 找出没有关联助手正文的图片引用;已有逐字视觉证据时默认不重复烧视觉 token。 */
@@ -243,14 +262,17 @@ function unresolvedImageRefs(messages) {
243
262
  }
244
263
  return { refs, missing };
245
264
  }
246
- async function readPromptImages(rpc, sourceSessionId, refs) {
265
+ async function readPromptImages(host, sourceSessionId, refs) {
266
+ const readAttachment = host.sessions.attachment;
267
+ if (readAttachment === undefined)
268
+ throw missingHostCapability('session attachment reading');
247
269
  return Promise.all(refs.map(async (ref) => {
248
- const stored = await rpc('session.attachment', {
270
+ const stored = await readAttachment({
249
271
  sessionId: sourceSessionId,
250
272
  attachmentId: ref.attachmentId,
251
273
  });
252
274
  if (!stored.data || typeof stored.data !== 'string') {
253
- throw new RpcError('session.attachment', 'empty-image', `附件 ${ref.attachmentId} 没有返回图片字节。`);
275
+ throw new RpcError('bridge.host', 'empty-image', `附件 ${ref.attachmentId} 没有返回图片字节。`);
254
276
  }
255
277
  const attachment = stored.attachment ?? ref;
256
278
  return {
@@ -264,9 +286,9 @@ async function readPromptImages(rpc, sourceSessionId, refs) {
264
286
  function imageFallbackAllowed(error) {
265
287
  if (!(error instanceof RpcError))
266
288
  return false;
267
- if (error.method === 'session.attachment')
289
+ if (error.code === 'unavailable' || error.code === 'empty-image')
268
290
  return true;
269
- if (error.method !== 'session.prompt' || error.code !== 'attachment-error')
291
+ if (error.code !== 'attachment-error' && error.code !== 'session/attachment-invalid')
270
292
  return false;
271
293
  const reason = error.details && typeof error.details === 'object' && 'reason' in error.details
272
294
  ? String(error.details.reason)
@@ -283,19 +305,20 @@ function imageFallbackAllowed(error) {
283
305
  * 摘要能被模型看见依赖 goal-round-driver 把它渲染成 `<goal_round>` 提示,
284
306
  * 或模型主动调 `get_goal`。所以默认把摘要同时放进首轮提示:任何组装下都成立。
285
307
  */
286
- export async function executeMigration(rpc, options) {
308
+ export async function executeMigration(input, options) {
309
+ const host = asBridgeHost(input);
287
310
  const progress = options.onProgress ?? (() => { });
288
311
  const warnings = [];
289
312
  const inject = options.inject ?? 'both';
290
313
  const lang = options.lang ?? detectLang(options.summary);
291
- const summary = options.summary.trim();
292
- if (!summary)
314
+ const summary = options.summary;
315
+ if (!summary.trim())
293
316
  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);
317
+ const sourceSession = options.sourceSession ?? await findSession(host, options.sessionId);
318
+ const where = await placement(host, sourceSession, options.sessionId);
296
319
  let sourceModel;
297
320
  try {
298
- const models = await rpc('session.models', { sessionId: options.sessionId });
321
+ const models = await host.sessions.models({ sessionId: options.sessionId });
299
322
  const current = models.current;
300
323
  if (typeof current?.provider === 'string' && current.provider
301
324
  && typeof current.model === 'string' && current.model) {
@@ -312,7 +335,7 @@ export async function executeMigration(rpc, options) {
312
335
  warnings.push(`读取源会话模型失败,目标将使用 host 默认模型:${error instanceof Error ? error.message : String(error)}`);
313
336
  }
314
337
  progress(`在 ${options.to} 模式下新建会话…`);
315
- const created = await rpc('session.create', {
338
+ const created = await host.sessions.create({
316
339
  ...where,
317
340
  agentPreset: options.to,
318
341
  });
@@ -323,7 +346,7 @@ export async function executeMigration(rpc, options) {
323
346
  let modelTransferred = false;
324
347
  if (sourceModel) {
325
348
  try {
326
- await rpc('session.selectModel', { sessionId: created.sessionId, ...sourceModel });
349
+ await host.sessions.selectModel({ sessionId: created.sessionId, ...sourceModel });
327
350
  modelTransferred = true;
328
351
  }
329
352
  catch (error) {
@@ -333,7 +356,7 @@ export async function executeMigration(rpc, options) {
333
356
  let titled = false;
334
357
  if (options.title) {
335
358
  try {
336
- await rpc('session.rename', { sessionId: created.sessionId, title: options.title });
359
+ await host.sessions.rename({ sessionId: created.sessionId, title: options.title });
337
360
  titled = true;
338
361
  }
339
362
  catch (error) {
@@ -347,14 +370,14 @@ export async function executeMigration(rpc, options) {
347
370
  let imagesSent = 0;
348
371
  if (inject === 'goal' || inject === 'both') {
349
372
  try {
350
- const createdGoal = await rpc('goal.create', {
373
+ const createdGoal = await host.goals.create({
351
374
  sessionId: created.sessionId,
352
375
  objective: summary,
353
376
  maxGoalRounds: options.goalRounds ?? 1,
354
377
  });
355
378
  goalCreated = true;
356
379
  try {
357
- await rpc('goal.pause', { sessionId: created.sessionId, ref: createdGoal.ref });
380
+ await host.goals.pause({ sessionId: created.sessionId, ref: createdGoal.ref });
358
381
  goalPaused = true;
359
382
  }
360
383
  catch (error) {
@@ -363,13 +386,16 @@ export async function executeMigration(rpc, options) {
363
386
  safeToKickoff = false;
364
387
  let goalCleared = false;
365
388
  try {
366
- await rpc('goal.clear', { sessionId: created.sessionId, ref: createdGoal.ref });
389
+ const clearGoal = host.goals.clear;
390
+ if (clearGoal === undefined)
391
+ throw missingHostCapability('goal clearing');
392
+ await clearGoal({ sessionId: created.sessionId, ref: createdGoal.ref });
367
393
  goalCleared = true;
368
394
  }
369
395
  catch (clearError) {
370
396
  warnings.push(`清除未暂停的交接目标失败,已继续取消目标会话;请保持目标会话关闭并手动检查:${clearError instanceof Error ? clearError.message : String(clearError)}`);
371
397
  }
372
- await rpc('session.cancel', { sessionId: created.sessionId }).catch(() => undefined);
398
+ await host.sessions.cancel({ sessionId: created.sessionId }).catch(() => undefined);
373
399
  warnings.push(goalCleared
374
400
  ? `暂停交接目标失败,已清除目标并取消自动启动;请打开目标会话检查后手动继续:${error instanceof Error ? error.message : String(error)}`
375
401
  : `暂停交接目标失败,已取消自动启动但无法确认目标已清除;请打开目标会话检查后手动继续:${error instanceof Error ? error.message : String(error)}`);
@@ -387,7 +413,7 @@ export async function executeMigration(rpc, options) {
387
413
  progress('发送首轮交接指令…');
388
414
  let unresolved = { refs: [], missing: 0 };
389
415
  try {
390
- unresolved = unresolvedImageRefs(await foldedHistory(rpc, options.sessionId));
416
+ unresolved = unresolvedImageRefs(await foldedHistory(host, options.sessionId));
391
417
  }
392
418
  catch (error) {
393
419
  warnings.push(`读取图片状态失败,已按纯文本交接:${error instanceof Error ? error.message : String(error)}`);
@@ -395,33 +421,50 @@ export async function executeMigration(rpc, options) {
395
421
  if (unresolved.missing > 0) {
396
422
  warnings.push(`有 ${unresolved.missing} 张未解析图片缺少可复用的持久附件引用;目标会话必须回源会话核验。`);
397
423
  }
424
+ let uncertainImagePromptFailure = false;
398
425
  if (unresolved.refs.length) {
399
426
  try {
400
- const images = await readPromptImages(rpc, options.sessionId, unresolved.refs);
427
+ const images = await readPromptImages(host, options.sessionId, unresolved.refs);
401
428
  const transferNote = lang === 'en'
402
429
  ? '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
430
  : 'Bridge 搬运说明:上文列出的未解析源图片已附在本次 kickoff 中。请直接检查原图,不得推断看不清的细节。';
404
- await rpc('session.prompt', {
431
+ await host.sessions.prompt({
405
432
  sessionId: created.sessionId,
406
433
  mode: 'queue',
407
434
  content: [...images, { type: 'text', text: `${baseText}\n\n${transferNote}` }],
408
435
  });
409
436
  imagesSent = images.length;
437
+ kickoffSent = true;
410
438
  }
411
439
  catch (error) {
412
- if (!imageFallbackAllowed(error))
413
- throw error;
414
- warnings.push(`目标模型或 host 不能接收原图,已使用逐字视觉证据/未解析提示降级:${error instanceof Error ? error.message : String(error)}`);
440
+ if (imageFallbackAllowed(error)) {
441
+ warnings.push(`目标模型或 host 不能接收原图,已使用逐字视觉证据/未解析提示降级:${error instanceof Error ? error.message : String(error)}`);
442
+ }
443
+ else {
444
+ // 目标已经存在且 goal 已暂停。对不确定是否送达的 prompt 不能盲目重试,
445
+ // 否则可能让模型收到两份 kickoff;把原目标交还给用户手动检查更安全。
446
+ uncertainImagePromptFailure = true;
447
+ warnings.push(lang === 'en'
448
+ ? `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)}`
449
+ : `目标会话已创建,但带图 kickoff 是否送达无法确认。请打开这个目标手动检查并继续;Bridge 没有盲目重试可能已送达的提示:${error instanceof Error ? error.message : String(error)}`);
450
+ }
415
451
  }
416
452
  }
417
- if (imagesSent === 0) {
418
- await rpc('session.prompt', {
419
- sessionId: created.sessionId,
420
- mode: 'queue',
421
- content: [{ type: 'text', text: baseText }],
422
- });
453
+ if (imagesSent === 0 && !uncertainImagePromptFailure) {
454
+ try {
455
+ await host.sessions.prompt({
456
+ sessionId: created.sessionId,
457
+ mode: 'queue',
458
+ content: [{ type: 'text', text: baseText }],
459
+ });
460
+ kickoffSent = true;
461
+ }
462
+ catch (error) {
463
+ warnings.push(lang === 'en'
464
+ ? `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)}`
465
+ : `目标会话已创建,但 kickoff 是否送达无法确认。请打开这个目标手动检查并继续;Bridge 没有再创建一个会话:${error instanceof Error ? error.message : String(error)}`);
466
+ }
423
467
  }
424
- kickoffSent = true;
425
468
  }
426
469
  return {
427
470
  sessionId: created.sessionId,