@ai-workbench/remote-agent 0.8.2 → 0.8.5

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/dist/index.js CHANGED
@@ -2,7 +2,8 @@ import { SessionId } from '@deepseek-ai/dsh-session';
2
2
  import Schema from '@deepseek-ai/schemastery';
3
3
  import { LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm';
4
4
  import { fileURLToPath } from 'node:url';
5
- import { createHash } from 'node:crypto';
5
+ import { createHash, randomUUID } from 'node:crypto';
6
+ import { recoveryJournal, recoveryKey } from "./recovery.js";
6
7
  import * as unstable from '@agentclientprotocol/sdk/unstable';
7
8
  import * as acp from '@agentclientprotocol/sdk';
8
9
  import { AcpSession } from "./acp.js";
@@ -17,6 +18,11 @@ import { NeedError } from "./remote.js";
17
18
  import { employeeFailure } from "./presentation.js";
18
19
  import { createRemoteDetails } from "./remote-details.js";
19
20
  import { PROVIDER_NAMES, ProviderLocaleRemote } from "./provider-locale.js";
21
+ import { approvalLabel } from "./approval-label.js";
22
+ import { StageTiming } from "./stage-timing.js";
23
+ import { verifyRecoveryCheckpoint, currentInputIds } from "./recovery-checkpoint.js";
24
+ import { ModelCatalog, modelInfo } from "./model-catalog.js";
25
+ import { ModelConfigurationError } from "./model-configuration.js";
20
26
  export const name = 'ai-workbench-remote-agent';
21
27
  export const settingsNamespace = 'ai-workbench-codex';
22
28
  // L can be installed/enabled after X; keeping X mounted lets it explain the prerequisite.
@@ -56,11 +62,15 @@ export class CodexRemoteAdapter extends LlmAdapter {
56
62
  agent;
57
63
  readImage;
58
64
  reportLoginFailure;
65
+ directory;
59
66
  config;
60
67
  sessions = new Map();
61
68
  turns = new Map();
62
69
  lifetime = new AbortController();
63
- constructor(config, resolveRuntime, approve, providerName = () => PROVIDER_NAMES.en, agent = CODEX_AGENT, readImage, reportLoginFailure) {
70
+ shuttingDown = false;
71
+ detached = new Set();
72
+ cleanups = new WeakMap();
73
+ constructor(config, resolveRuntime, approve, providerName = () => PROVIDER_NAMES.en, agent = CODEX_AGENT, readImage, reportLoginFailure, directory) {
64
74
  super();
65
75
  this.resolveRuntime = resolveRuntime;
66
76
  this.approve = approve;
@@ -68,23 +78,80 @@ export class CodexRemoteAdapter extends LlmAdapter {
68
78
  this.agent = agent;
69
79
  this.readImage = readImage;
70
80
  this.reportLoginFailure = reportLoginFailure;
81
+ this.directory = directory;
71
82
  this.config = typeof config === 'function' ? config : () => config;
72
83
  }
73
84
  providerInfo(provider) { return { id: provider, name: this.providerName() }; }
74
- async listModels(provider) { return [{ provider, id: 'remote', name: this.providerName(), inputModalities: ((this.agent.oneShot && this.config().mode === 'one-shot') ? ['text'] : ['text', 'image']) }]; }
85
+ catalog(signal) { if (!this.directory)
86
+ throw new LlmError('无法获取模型列表:缺少可信主机目录能力', 'MODEL_CATALOG_UNAVAILABLE'); return this.directory.read(this.agent, signal); }
87
+ async listModels(provider) { const catalog = await this.catalog(); try {
88
+ return catalog.models.map(model => modelInfo(provider, model));
89
+ }
90
+ finally {
91
+ catalog.release();
92
+ } }
93
+ async resolveModel(provider, id, signal) { const catalog = await this.catalog(signal); try {
94
+ const model = catalog.models.find(model => model.id === id);
95
+ if (!model)
96
+ throw new LlmError('无法获取模型列表:所选模型已不在当前主机目录中', 'MODEL_CATALOG_UNAVAILABLE');
97
+ return modelInfo(provider, model);
98
+ }
99
+ finally {
100
+ catalog.release();
101
+ } }
102
+ async invalidateModelState() { for (const turn of this.turns.values())
103
+ turn.abort(); await Promise.all([...this.sessions.keys()].map(id => this.disposeSession(id))); }
75
104
  providerRetryPolicy() { return { mode: 'normal', maxRetries: 0, retryableCodes: [], initialDelayMs: 0, maxDelayMs: 0, jitterRatio: 0 }; }
105
+ cleanupRuntime(runtime) {
106
+ const existing = this.cleanups.get(runtime);
107
+ if (existing)
108
+ return existing;
109
+ const cleanup = Promise.resolve().then(() => runtime.dispose?.()).catch(() => {
110
+ const failure = new NeedError('REMOTE_CLEANUP_UNCONFIRMED', '原主机会话的清理结果未确认,不能继续或重开此会话', '管理员核对并结束原主机会话');
111
+ for (const live of this.sessions.values())
112
+ if (live.runtime === runtime) {
113
+ live.cleanupFailure = failure;
114
+ live.failure ??= { cause: failure };
115
+ }
116
+ throw failure;
117
+ });
118
+ this.cleanups.set(runtime, cleanup);
119
+ return cleanup;
120
+ }
76
121
  async disposeSession(sessionId) {
77
122
  this.turns.get(sessionId)?.abort();
78
123
  const current = this.sessions.get(sessionId);
79
124
  if (!current)
80
125
  return;
81
- this.sessions.delete(sessionId);
126
+ if (current.disposal)
127
+ return current.disposal;
82
128
  current.session.close();
83
- await current.runtime.dispose?.();
129
+ current.disposal = this.cleanupRuntime(current.runtime).then(() => { if (this.sessions.get(sessionId) === current)
130
+ this.sessions.delete(sessionId); });
131
+ return current.disposal;
132
+ }
133
+ async detachSession(sessionId) {
134
+ const current = this.sessions.get(sessionId), turn = this.turns.get(sessionId);
135
+ if (!current && !turn)
136
+ return;
137
+ this.detached.add(sessionId);
138
+ if (current) {
139
+ try {
140
+ await current.runtime.detach?.();
141
+ }
142
+ finally {
143
+ current.session.close();
144
+ this.sessions.delete(sessionId);
145
+ turn?.abort();
146
+ }
147
+ }
148
+ else
149
+ turn?.abort();
84
150
  }
85
151
  async dispose() {
152
+ this.shuttingDown = true;
153
+ const results = await Promise.allSettled([...new Set([...this.sessions.keys(), ...this.turns.keys()])].map(id => this.detachSession(id)));
86
154
  this.lifetime.abort();
87
- const results = await Promise.allSettled([...this.sessions.keys()].map(id => this.disposeSession(id)));
88
155
  const failure = results.find(result => result.status === 'rejected');
89
156
  if (failure?.status === 'rejected')
90
157
  throw failure.reason;
@@ -94,6 +161,8 @@ export class CodexRemoteAdapter extends LlmAdapter {
94
161
  yield* this.generate(options);
95
162
  }
96
163
  catch (error) {
164
+ if (error instanceof ModelConfigurationError)
165
+ throw new LlmError(error.message, error.code, { cause: error });
97
166
  if (error instanceof NeedError) {
98
167
  const rpc = /RPC 错误码 (-?\d+)/u.exec(error.requires)?.[1];
99
168
  const message = employeeFailure(error.code) + (rpc ? `(技术详情:RPC 错误码 ${rpc})` : '');
@@ -125,12 +194,16 @@ export class CodexRemoteAdapter extends LlmAdapter {
125
194
  this.turns.set(key, turn);
126
195
  const signal = AbortSignal.any([this.lifetime.signal, turn.signal, ...(original.signal ? [original.signal] : [])]);
127
196
  const options = { ...original, signal };
197
+ const timing = new StageTiming();
128
198
  let runtime;
129
199
  let session;
130
200
  let live;
131
201
  let succeeded = false;
132
202
  let remoteAttempted = false;
133
203
  let activeMapper;
204
+ let catalog;
205
+ let selected;
206
+ let modelConfiguration;
134
207
  const config = this.config();
135
208
  const mode = this.agent.oneShot ? config.mode : 'session';
136
209
  try {
@@ -139,7 +212,11 @@ export class CodexRemoteAdapter extends LlmAdapter {
139
212
  const previous = readPointer(options, this.agent);
140
213
  if (previous && previous.mode !== mode)
141
214
  throw new NeedError('SESSION_CONTEXT_MISMATCH', '运行模式与原会话不一致,请新建会话', '原会话模式');
215
+ if (previous && !previous.modelConfiguration)
216
+ throw new NeedError('MODEL_SELECTION_REQUIRED', '旧远端恢复记录没有明确模型选择,请新建会话并选择模型', '本轮明确选择的模型');
142
217
  live = key ? this.sessions.get(key) : undefined;
218
+ if (live?.disposal || live?.cleanupFailure)
219
+ throw live.cleanupFailure ?? new NeedError('REMOTE_CLEANUP_UNCONFIRMED', '原主机会话正在结束,清理结果尚未确认,不能继续或重开此会话', '管理员核对原主机会话清理结果');
143
220
  if (live?.failure)
144
221
  throw new NeedError('SESSION_STATE_UNCONFIRMED', '上轮远端状态未确认,不能自动重发或重开,请新建会话并核对项目', '管理员核对原会话状态');
145
222
  if (live && !previous)
@@ -147,10 +224,11 @@ export class CodexRemoteAdapter extends LlmAdapter {
147
224
  const configuration = JSON.stringify([mode, config.acpMaxMessageBytes, config.brokerMaxFrameBytes, config.brokerControlMaxBytes]);
148
225
  if (live && live.configuration !== configuration)
149
226
  throw new NeedError('SESSION_CONTEXT_MISMATCH', '会话运行设置已改变,请新建会话', '原会话运行设置');
150
- const request = { sessionId: options.sessionId, mode, cwd: previous?.cwd, identity: previous?.identity, bindingId: previous?.bindingId, workspaceId: previous?.workspaceId, agent: this.agent, retainProcess: !!key && mode === 'session', signal, brokerSettings: Object.freeze({ brokerMaxFrameBytes: config.brokerMaxFrameBytes, brokerControlMaxBytes: config.brokerControlMaxBytes }) };
227
+ const request = { timing, acpMaxMessageBytes: config.acpMaxMessageBytes, resumeSessionId: previous?.sessionId, recoveryCheckpointId: previous?.recoveryCheckpointId, sessionId: options.sessionId, mode, cwd: previous?.cwd, identity: previous?.identity, bindingId: previous?.bindingId, workspaceId: previous?.workspaceId, agent: this.agent, retainProcess: !!key && mode === 'session', signal, brokerSettings: Object.freeze({ brokerMaxFrameBytes: config.brokerMaxFrameBytes, brokerControlMaxBytes: config.brokerControlMaxBytes }) };
151
228
  await this.resolveRuntime.preflight?.(request);
152
- const input = await prepareInput(options, this.readImage);
229
+ const input = await timing.run('input-prepare', () => prepareInput(options, this.readImage));
153
230
  const contextSha256 = createHash('sha256').update(input.systemPrompt ?? '').digest('hex');
231
+ request.contextSha256 = contextSha256;
154
232
  if (previous && this.agent.loginProvider === 'claude' && previous.contextSha256 !== contextSha256)
155
233
  throw new NeedError('SYSTEM_PROMPT_UPDATE_PENDING', 'Claude原会话的系统或开发者要求已变化;主机仅承诺下次上下文整理后生效,本轮未发送,请新建会话应用新要求', '新建Claude会话以立即应用当前系统与开发者要求');
156
234
  if (live) {
@@ -161,14 +239,35 @@ export class CodexRemoteAdapter extends LlmAdapter {
161
239
  throw new NeedError('ONESHOT_CONTEXT_REQUIRED', '首版单次模式不支持图片或系统/开发者上下文,请使用会话模式', 'Codex会话模式');
162
240
  if (mode === 'session' && Buffer.byteLength(JSON.stringify({ prompt: input.prompt, systemPrompt: input.systemPrompt }), 'utf8') > config.acpMaxMessageBytes)
163
241
  throw new NeedError('ACP_MESSAGE_TOO_LARGE', '本轮输入超过已设置的会话消息容量,未截断或发送', '管理员调整acpMaxMessageBytes');
242
+ if (mode === 'one-shot')
243
+ throw new NeedError('MODEL_CONFIGURATION_UNSUPPORTED', '单次模式不支持确认当前模型与推理等级,请改用会话模式', '支持模型配置确认的会话模式');
244
+ catalog = await this.catalog(signal);
245
+ selected = catalog.models.find(model => model.id === options.model);
246
+ if (!selected)
247
+ throw new ModelConfigurationError('所选模型不在本次可信主机目录中,请重新选择模型');
248
+ if (options.reasoningEffort !== undefined && !selected.reasoning.options.some(option => option.value === options.reasoningEffort))
249
+ throw new ModelConfigurationError('所选推理等级不在本次模型目录中');
250
+ const modelSignal = AbortSignal.any([signal, catalog.signal]);
251
+ options.signal = modelSignal;
252
+ request.signal = modelSignal;
164
253
  runtime = await this.resolveRuntime(request);
165
254
  signal.throwIfAborted();
255
+ catalog.assertCurrent();
256
+ if (runtime.connection && (runtime.connection.instanceId !== catalog.target.instanceId || runtime.connection.epoch !== catalog.target.epoch) || runtime.identity !== `${catalog.target.target.username}@${catalog.target.target.host}:${catalog.target.target.port}`)
257
+ throw new ModelConfigurationError('模型目录与当前会话可信员工主机不一致');
166
258
  if (previous && (previous.identity !== runtime.identity || previous.cwd !== runtime.cwd || previous.bindingId && previous.bindingId !== runtime.binding?.bindingId || previous.workspaceId && previous.workspaceId !== runtime.binding?.workspaceId))
167
259
  throw new NeedError('SESSION_CONTEXT_MISMATCH', '原会话的员工主机或工作区已改变,请新建会话', '原会话的可信目标和工作区');
260
+ if (runtime.restartNotice) {
261
+ const ids = runtime.restartInputIds;
262
+ if (!ids?.length || new Set(ids).size !== ids.length || ids.some(id => input.users.filter(u => u.id === id).length !== 1))
263
+ throw new NeedError('CURRENT_INPUT_UNCONFIRMED', '无法核对本次输入,尚未发送给模型', '官方当前步骤的已接纳用户输入');
264
+ input.prompt = ids.flatMap(id => input.users.find(u => u.id === id).prompt);
265
+ }
168
266
  const details = runtime.details;
169
267
  if (!details)
170
268
  throw new NeedError('TECHNICAL_DETAILS_UNAVAILABLE', '技术详情暂不可用,请更新远程主机连接组件。', 'L技术详情持久接收能力');
171
- const mapper = new StreamMapper(mode === 'session' ? (live ? live.usageSnapshot : previous ? (previous.usageSnapshot ?? null) : undefined) : undefined, observation => details.record(observation));
269
+ timing.bind(detail => details.record(detail));
270
+ const mapper = new StreamMapper(mode === 'session' ? (runtime.restartNotice ? undefined : live ? live.usageSnapshot : previous ? (previous.usageSnapshot ?? null) : undefined) : undefined, observation => details.record(observation));
172
271
  activeMapper = mapper;
173
272
  let id;
174
273
  let stop;
@@ -176,6 +275,10 @@ export class CodexRemoteAdapter extends LlmAdapter {
176
275
  yield* mapper.text(runtime.workspaceNotice.text);
177
276
  yield* mapper.flush();
178
277
  }
278
+ if (runtime.restartNotice) {
279
+ yield* mapper.text(runtime.restartNotice + '\n\n');
280
+ yield* mapper.flush();
281
+ }
179
282
  if (mode === 'session') {
180
283
  if (live && live.runtime !== runtime)
181
284
  throw new NeedError('SESSION_TRANSPORT_MISMATCH', '原会话的主机通道已更换,不能自动切换进程', '原主机会话通道');
@@ -186,7 +289,7 @@ export class CodexRemoteAdapter extends LlmAdapter {
186
289
  let done = false;
187
290
  let failure;
188
291
  const push = (chunks) => { queue.push(...chunks); wake?.(); wake = undefined; };
189
- const hooks = { broker: runtime.broker, approve: (permission, approvalSignal) => this.approve?.(permission, approvalSignal, options) ?? Promise.resolve('unavailable'), onUpdate: notification => { try {
292
+ const hooks = { broker: runtime.broker, timing, approve: (permission, approvalSignal) => this.approve?.(permission, approvalSignal, options) ?? Promise.resolve('unavailable'), onUpdate: notification => { try {
190
293
  push(mapper.acp(notification.update));
191
294
  }
192
295
  catch (error) {
@@ -209,18 +312,47 @@ export class CodexRemoteAdapter extends LlmAdapter {
209
312
  }
210
313
  const task = (async () => {
211
314
  remoteAttempted = true;
212
- if (fresh)
213
- await session.start(runtime.cwd, previous?.sessionId, signal, { systemPrompt: input.systemPrompt });
214
- else {
215
- const updated = await session.updateContext({ systemPrompt: input.systemPrompt }, signal);
216
- if (updated && this.agent.loginProvider === 'claude')
217
- throw new NeedError('SYSTEM_PROMPT_UPDATE_PENDING', 'Claude原会话的系统或开发者要求已变化;主机仅承诺下次上下文整理后生效,本轮未发送,请新建会话应用新要求', '新建Claude会话以立即应用当前系统与开发者要求');
315
+ try {
316
+ if (fresh) {
317
+ if (runtime.recoveredSession)
318
+ session.restore(runtime.cwd, runtime.recoveredSession, { systemPrompt: input.systemPrompt });
319
+ else
320
+ await session.start(runtime.cwd, runtime.restartNotice ? undefined : previous?.sessionId, modelSignal, { systemPrompt: input.systemPrompt });
321
+ }
322
+ else {
323
+ const updated = await session.updateContext({ systemPrompt: input.systemPrompt }, modelSignal);
324
+ if (updated && this.agent.loginProvider === 'claude')
325
+ throw new NeedError('SYSTEM_PROMPT_UPDATE_PENDING', 'Claude原会话的系统或开发者要求已变化;主机仅承诺下次上下文整理后生效,本轮未发送,请新建会话应用新要求', '新建Claude会话以立即应用当前系统与开发者要求');
326
+ }
327
+ catalog.assertCurrent();
328
+ const actual = await session.selectModel(selected.value, options.reasoningEffort, modelSignal);
329
+ catalog.assertCurrent();
330
+ session.assertModelSelection(selected.value, options.reasoningEffort);
331
+ let resolved = selected.resolvedModel;
332
+ if (this.agent.loginProvider === 'claude') {
333
+ const evidence = session.resolvedModelEvidence;
334
+ if (!resolved || (selected.value !== resolved && (!evidence || evidence.modelValue !== selected.value || evidence.resolvedModel !== resolved)))
335
+ throw new NeedError('MODEL_RESOLUTION_UNCONFIRMED', '所选模型的具体型号未获得当前会话确认,本轮未发送', '当前会话的具体型号证据');
336
+ if (evidence && evidence.resolvedModel !== resolved)
337
+ throw new NeedError('MODEL_RESOLUTION_UNCONFIRMED', '当前会话具体型号与所选目录不一致,本轮未发送', '完全一致的具体型号证据');
338
+ }
339
+ if (!resolved)
340
+ throw new NeedError('MODEL_RESOLUTION_UNCONFIRMED', '远端没有提供具体型号证据,本轮未发送', '具体型号证据');
341
+ modelConfiguration = { selected: { ...selected, ...(options.reasoningEffort === undefined ? {} : { effort: options.reasoningEffort }) }, configured: actual, resolvedModel: resolved, status: 'remote-configured' };
342
+ details.record({ kind: 'session', severity: 'info', code: 'MODEL_CONFIGURATION_CONFIRMED', summary: '本轮选择已由远端配置回执确认;不代表推理效果已验证', payload: modelConfiguration });
343
+ }
344
+ catch (error) {
345
+ details.record({ kind: 'session', severity: 'action-required', code: 'MODEL_CONFIGURATION_FAILED', summary: '本轮模型配置未获完整确认,未发送用户输入', payload: { selected, requestedEffort: options.reasoningEffort ?? null, actual: session.modelConfiguration ?? null, reason: error instanceof NeedError ? error.code : error instanceof ModelConfigurationError ? error.message : 'MODEL_CONFIGURATION_FAILED' } });
346
+ throw error;
218
347
  }
348
+ await timing.run('details-flush', () => details.flush());
219
349
  await details.flush();
220
350
  details.beginTurn(runtime.detailContext?.() ?? { turn: null, step: null });
351
+ catalog.assertCurrent();
352
+ session.assertModelSelection(selected.value, options.reasoningEffort);
221
353
  id = session.id;
222
354
  try {
223
- return await session.prompt(input.prompt, signal);
355
+ return await session.prompt(input.prompt, modelSignal);
224
356
  }
225
357
  finally {
226
358
  details.endTurn();
@@ -254,13 +386,27 @@ export class CodexRemoteAdapter extends LlmAdapter {
254
386
  }
255
387
  }
256
388
  details.endTurn();
257
- await details.flush();
389
+ await timing.run('details-flush', () => details.flush());
390
+ await details.flush(); // The timing record is a new ordered write, not a retry.
258
391
  if (!id)
259
392
  throw new NeedError('SESSION_ID_MISSING', '远端没有返回会话标识', '有效的远端会话标识');
260
393
  if (live)
261
394
  live.usageSnapshot = mapper.usageSnapshot;
395
+ if (modelConfiguration) {
396
+ const actual = session.modelConfiguration;
397
+ if (!actual?.confirmed || actual.model !== modelConfiguration.configured.model || actual.effort !== modelConfiguration.configured.effort)
398
+ throw new ModelConfigurationError('远端会话在本轮期间改变了模型配置,不能沿用旧回执');
399
+ catalog.assertCurrent();
400
+ yield* mapper.text(`\n\n本轮所选模型:${modelConfiguration.selected.name}(${modelConfiguration.selected.value});推理等级:${modelConfiguration.selected.effort ?? '沿用远端配置'}。远端已配置模型:${modelConfiguration.configured.model};具体型号:${modelConfiguration.resolvedModel};推理等级:${modelConfiguration.configured.effort ?? '远端未提供'}(配置已确认,推理效果未验证)。`);
401
+ }
262
402
  yield* mapper.flush();
263
- const pointer = { ...(runtime.workspaceNotice ? { presentation: { workspaceNotice: runtime.workspaceNotice } } : {}), version: 2, agent: this.agent.id, contextSha256, ...(mode === 'session' ? { usageSnapshot: mapper.usageSnapshot } : {}), ...(options.sessionId ? { dshSessionId: options.sessionId } : {}), mode, identity: runtime.identity, cwd: runtime.cwd, sessionId: id, ...(runtime.binding ? { bindingId: runtime.binding.bindingId, workspaceId: runtime.binding.workspaceId } : {}) };
403
+ let recoveryCheckpointId;
404
+ if (session?.checkpoint && runtime.saveCheckpoint) {
405
+ const id = randomUUID();
406
+ if (await runtime.saveCheckpoint(id, session.checkpoint))
407
+ recoveryCheckpointId = id;
408
+ }
409
+ const pointer = { ...(modelConfiguration ? { modelConfiguration } : {}), ...(recoveryCheckpointId ? { recoveryCheckpointId } : {}), ...(runtime.workspaceNotice ? { presentation: { workspaceNotice: runtime.workspaceNotice } } : {}), version: 2, agent: this.agent.id, contextSha256, ...(mode === 'session' ? { usageSnapshot: mapper.usageSnapshot } : {}), ...(options.sessionId ? { dshSessionId: options.sessionId } : {}), mode, identity: runtime.identity, cwd: runtime.cwd, sessionId: id, ...(runtime.binding ? { bindingId: runtime.binding.bindingId, workspaceId: runtime.binding.workspaceId } : {}) };
264
410
  succeeded = true;
265
411
  yield { type: 'finish', reason: { kind: stop === 'max_tokens' || stop === 'max_turn_requests' ? 'max-tokens' : 'stop' }, replayState: { response: pointer } };
266
412
  }
@@ -281,27 +427,42 @@ export class CodexRemoteAdapter extends LlmAdapter {
281
427
  throw error;
282
428
  }
283
429
  finally {
284
- if (!succeeded || !key || mode !== 'session') {
285
- session?.close();
286
- await runtime?.dispose?.();
430
+ catalog?.release();
431
+ try {
432
+ if (this.shuttingDown || key && this.detached.has(key)) {
433
+ session?.close();
434
+ await runtime?.detach?.();
435
+ }
436
+ else if (!succeeded || !key || mode !== 'session') {
437
+ session?.close();
438
+ if (runtime)
439
+ await this.cleanupRuntime(runtime);
440
+ }
441
+ }
442
+ finally {
443
+ if (key && this.turns.get(key) === turn)
444
+ this.turns.delete(key);
445
+ if (key)
446
+ this.detached.delete(key);
287
447
  }
288
- if (key && this.turns.get(key) === turn)
289
- this.turns.delete(key);
290
448
  }
291
449
  }
292
450
  }
293
451
  /** One official registration owns both routes, preserving opaque replay ownership. */
294
452
  export class RemoteAgentsAdapter extends LlmAdapter {
295
453
  adapters = new Map();
296
- constructor(config, makeRuntime, approve, nameFor, readImage, reportLoginFailure) { super(); for (const agent of AGENTS)
297
- this.adapters.set(agent.id, new CodexRemoteAdapter(config, makeRuntime(agent), approve, () => nameFor(agent), agent, readImage, reportLoginFailure)); }
454
+ constructor(config, makeRuntime, approve, nameFor, readImage, reportLoginFailure, directory) { super(); for (const agent of AGENTS)
455
+ this.adapters.set(agent.id, new CodexRemoteAdapter(config, makeRuntime(agent), approve, () => nameFor(agent), agent, readImage, reportLoginFailure, directory)); }
298
456
  adapter(provider) { const adapter = this.adapters.get(provider); if (!adapter)
299
457
  throw new LlmError('远程模型未注册', 'PROVIDER_NOT_FOUND'); return adapter; }
300
458
  providerInfo(provider) { return this.adapter(provider).providerInfo(provider); }
301
459
  listModels(provider) { return this.adapter(provider).listModels(provider); }
460
+ resolveModel(provider, model, signal) { return this.adapter(provider).resolveModel(provider, model, signal); }
461
+ async invalidateModelState() { await Promise.all([...this.adapters.values()].map(adapter => adapter.invalidateModelState())); }
302
462
  providerRetryPolicy() { return { mode: 'normal', maxRetries: 0, retryableCodes: [], initialDelayMs: 0, maxDelayMs: 0, jitterRatio: 0 }; }
303
463
  stream(options) { return this.adapter(options.provider).stream(options); }
304
464
  async disposeSession(sessionId) { await Promise.all([...this.adapters.values()].map(adapter => adapter.disposeSession(sessionId))); }
465
+ async detachSession(sessionId) { await Promise.all([...this.adapters.values()].map(adapter => adapter.detachSession(sessionId))); }
305
466
  async dispose() { await Promise.all([...this.adapters.values()].map(adapter => adapter.dispose())); }
306
467
  }
307
468
  export function apply(ctx, live) {
@@ -327,9 +488,17 @@ export function apply(ctx, live) {
327
488
  }
328
489
  return manager;
329
490
  };
491
+ let changed = () => { };
492
+ const modelDirectory = new ModelCatalog(get, () => config().acpMaxMessageBytes, undefined, () => changed());
330
493
  const adapter = new RemoteAgentsAdapter(config, agent => connectProvider(get, { agent, notice: createWorkspaceNoticeConsumer(ctx, agent), details: async (request, api, connection, target) => {
331
494
  return managerForProfile().open({ sessionId: request.sessionId, provider: agent.loginProvider, identity: `${target.username}@${target.host}:${target.port}`, connection });
332
495
  }, open: async (...args) => {
496
+ const profile = ctx.get('profileContext');
497
+ if (!profile?.dir)
498
+ throw new NeedError('RECOVERY_STORAGE_REQUIRED', '会话恢复需要官方profile目录', '官方profileContext.dir');
499
+ args[2].recoveryJournal = recoveryJournal(profile.dir, recoveryKey([`${args[0].username}@${args[0].host}:${args[0].port}`, agent.id, args[2].sessionId]));
500
+ args[2].verifyRecovery = () => verifyRecoveryCheckpoint(ctx, args[2], agent.id);
501
+ args[2].currentInputIds = await currentInputIds(ctx, args[2]);
333
502
  const runtime = await openByAgent.get(agent.id)(...args);
334
503
  runtime.detailContext = () => {
335
504
  const events = args[2].sessionId ? ctx.get('agents')?.get(SessionId(args[2].sessionId))?.session.snapshotEvents() : undefined;
@@ -369,17 +538,19 @@ export function apply(ctx, live) {
369
538
  step = null;
370
539
  }
371
540
  return managerForProfile().recordLoginFailure({ sessionId, code, diagnostic, context: { turn, step } });
372
- });
541
+ }, modelDirectory);
373
542
  const ids = AGENTS.map(agent => agent.id);
374
543
  const entries = () => AGENTS.map(agent => ({ provider: agent.id, displayName: agent.name[language], settingsNs: settingsNamespace, settingsPath: [] }));
375
544
  const directory = ctx.llm.registerConfigurableProviders(entries());
376
545
  const registration = ctx.llm.registerAdapter(ids, adapter);
546
+ changed = () => { registration.replace(ids); void adapter.invalidateModelState().catch(() => { ctx.logger('remote-agent').warn('原会话清理结果未确认,已阻断继续发送;请联系管理员核对。'); }); };
547
+ ctx.on('internal/service', service => { if (service === 'aiWorkbenchConnect')
548
+ modelDirectory.refresh(); });
377
549
  new ProviderLocaleRemote(ctx, next => { language = next; directory.replace(entries()); registration.replace(ids); });
378
- const cleanup = (id) => adapter.disposeSession(id);
550
+ const cleanup = (id) => adapter.detachSession(id);
379
551
  ctx.on('agent/disposed', ({ agent }) => cleanup(agent.session.id));
380
552
  ctx.on('session/disposed', session => cleanup(session.id));
381
- ctx.on('workspace/session-stop', ((request) => cleanup(request.sessionId)));
382
- ctx.effect(() => () => adapter.dispose());
553
+ ctx.effect(() => () => { modelDirectory.dispose(); return adapter.dispose(); });
383
554
  }
384
555
  export function dshApproval(ctx) {
385
556
  return async (request, signal, options, context) => {
@@ -387,7 +558,9 @@ export function dshApproval(ctx) {
387
558
  const approval = ctx.get('approval');
388
559
  if (!agent || !approval)
389
560
  return 'unavailable';
390
- return approval.request({ agent, toolName: request.toolCall.name ?? request.toolCall.title ?? '远端工具',
391
- reason: `${context ?? '主机'} · 会话 ${request.sessionId}:${request.toolCall.title ?? request.toolCall.toolCallId}`, signal });
561
+ const label = approvalLabel(request);
562
+ if (!label)
563
+ return 'unavailable';
564
+ return approval.request({ agent, ...label, signal });
392
565
  };
393
566
  }
package/dist/input.d.ts CHANGED
@@ -6,6 +6,10 @@ export type ImageReader = (ref: ImageBlock['attachment'], signal?: AbortSignal)
6
6
  }>;
7
7
  export interface PreparedInput {
8
8
  prompt: AcpContentBlock[];
9
+ users: {
10
+ id: string | undefined;
11
+ prompt: AcpContentBlock[];
12
+ }[];
9
13
  systemPrompt?: string;
10
14
  }
11
15
  /** The attachment service supplies verified bytes; client paths never cross SSH. */
package/dist/input.js CHANGED
@@ -19,7 +19,9 @@ export async function prepareInput(options, readImage) {
19
19
  if (!users.length)
20
20
  throw new NeedError('INPUT_REQUIRED', '我需要本轮用户输入', '用户输入');
21
21
  const prompt = [];
22
+ const preparedUsers = [];
22
23
  for (const message of users) {
24
+ const before = prompt.length;
23
25
  for (const block of message.content) {
24
26
  options.signal?.throwIfAborted();
25
27
  if (block.type === 'text') {
@@ -42,8 +44,9 @@ export async function prepareInput(options, readImage) {
42
44
  }
43
45
  throw new NeedError('INPUT_TYPE_REQUIRED', `我需要此输入类型的远程转接能力:${block.type}`, '对应类型的公开ACP输入映射');
44
46
  }
47
+ preparedUsers.push({ id: message.id, prompt: prompt.slice(before) });
45
48
  }
46
49
  if (!prompt.length)
47
50
  throw new NeedError('INPUT_REQUIRED', '我需要本轮用户输入', '用户输入');
48
- return { prompt, ...(context.length ? { systemPrompt: context.join('\n\n') } : {}) };
51
+ return { prompt, users: preparedUsers, ...(context.length ? { systemPrompt: context.join('\n\n') } : {}) };
49
52
  }
@@ -0,0 +1,55 @@
1
+ import { type LlmResolvedModelInfo } from '@deepseek-ai/dsh-llm';
2
+ import type { ModelCatalogTarget } from '@ai-workbench/connect/api';
3
+ import type { AgentDefinition } from './agents.ts';
4
+ import { type ConnectApi, type ConnectTarget } from './provider.ts';
5
+ export interface HostModel {
6
+ id: string;
7
+ value: string;
8
+ name: string;
9
+ resolvedModel: string | null;
10
+ reasoning: {
11
+ options: {
12
+ value: string;
13
+ name: string;
14
+ }[];
15
+ defaultValue: string | null;
16
+ defaultKnown: boolean;
17
+ };
18
+ source: unknown;
19
+ }
20
+ export interface CatalogLease {
21
+ readonly target: ModelCatalogTarget;
22
+ readonly models: readonly HostModel[];
23
+ readonly signal: AbortSignal;
24
+ assertCurrent(): void;
25
+ release(): void;
26
+ }
27
+ export interface ModelDirectory {
28
+ read(agent: AgentDefinition, signal?: AbortSignal): Promise<CatalogLease>;
29
+ }
30
+ export type CatalogRunner = (target: ConnectTarget, command: string, signal: AbortSignal, maxBytes: number) => Promise<string>;
31
+ export declare function parseHostCatalog(input: string, provider: string, username: string): HostModel[];
32
+ /** Only the public L sshArgs function chooses SSH trust and transport arguments. */
33
+ export declare const runCatalog: CatalogRunner;
34
+ /** No cache: every query binds to L's current object, host-user and generation. */
35
+ export declare class ModelCatalog implements ModelDirectory {
36
+ private get;
37
+ private maxBytes;
38
+ private run;
39
+ private changed;
40
+ private api?;
41
+ private unsubscribe?;
42
+ private signature?;
43
+ private revision;
44
+ private disposed;
45
+ private initialized;
46
+ private active;
47
+ constructor(get: () => ConnectApi | undefined, maxBytes: () => number | undefined, run?: CatalogRunner, changed?: () => void);
48
+ refresh(): void;
49
+ private trusted;
50
+ read(agent: AgentDefinition, signal?: AbortSignal): Promise<CatalogLease>;
51
+ dispose(): void;
52
+ }
53
+ export declare function modelInfo(provider: string, model: HostModel): LlmResolvedModelInfo & {
54
+ catalog: HostModel;
55
+ };