@ai-workbench/remote-agent 0.8.13 → 0.8.14

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
@@ -1,3 +1,4 @@
1
+ import { CloseDiagnostics, closeCode, closeErrorCodes, closeFailure } from "./close-diagnostics.js";
1
2
  import { runCatalog } from "./catalog-ssh.js";
2
3
  import { createCatalogDiagnostics, CatalogDiagnosticsRemote } from "./catalog-diagnostics.js";
3
4
  import { SessionId } from '@deepseek-ai/dsh-session';
@@ -54,13 +55,20 @@ export const Config = Schema.object({
54
55
  export function readConfig(config) {
55
56
  return Object.fromEntries(Object.entries(config).map(([key, value]) => [key, typeof value === 'object' && value !== null && 'get' in value ? value.get() : value]));
56
57
  }
58
+ function modelResolutionLabel(configuration) {
59
+ return configuration.resolvedModelSource === 'host-user-catalog'
60
+ ? `型号:${configuration.resolvedModel}(来源:主机目录映射 ${configuration.selected.value};会话未单独回报具体型号)`
61
+ : `具体型号:${configuration.resolvedModel}`;
62
+ }
57
63
  function validContextDigest(value) { return typeof value === 'string' && /^[a-fA-F0-9]{64}$/u.test(value); }
58
64
  function readPointer(options, agent) {
59
65
  const last = options.messages.findLast(message => message.role === 'assistant' && !isWorkspaceNoticeMessage(message));
60
66
  if (!last || last.role !== 'assistant')
61
67
  return;
62
- if (last.source.provider !== options.provider || last.source.replayState === undefined)
63
- throw new NeedError('SESSION_STATE_MISSING', '已有对话缺少当前模型的远端恢复标识,请新建会话', '当前模型的远端恢复记录');
68
+ if (last.source.provider !== options.provider)
69
+ throw new NeedError('SESSION_STATE_MISSING', '已有对话属于其他模型通道,不能沿用其远端进程', '当前模型的远端恢复记录');
70
+ if (last.source.replayState === undefined)
71
+ return;
64
72
  const envelope = last.source.replayState;
65
73
  const pointer = envelope?.response;
66
74
  if (pointer?.version !== 2 || pointer.agent !== agent.id || (pointer.mode !== 'session' && pointer.mode !== 'one-shot') || ![pointer.identity, pointer.cwd, pointer.sessionId].every(value => typeof value === 'string' && value.length > 0))
@@ -104,6 +112,7 @@ export class CodexRemoteAdapter extends LlmAdapter {
104
112
  shuttingDown = false;
105
113
  detached = new Set();
106
114
  cleanups = new WeakMap();
115
+ preparationFailures = new Map();
107
116
  constructor(config, resolveRuntime, approve, providerName = () => PROVIDER_NAMES.en, agent = CODEX_AGENT, readImage, reportLoginFailure, directory, preflightDiagnostics) {
108
117
  super();
109
118
  this.resolveRuntime = resolveRuntime;
@@ -141,16 +150,24 @@ export class CodexRemoteAdapter extends LlmAdapter {
141
150
  markModelPreparationFailure(provider, error, signal);
142
151
  throw error;
143
152
  } }
144
- async invalidateModelState() { for (const turn of this.turns.values())
145
- turn.abort(); await Promise.all([...this.sessions.keys()].map(id => this.disposeSession(id))); }
146
- async invalidateEmployeeState() { await Promise.all([...this.sessions].filter(([, live]) => live.runtime.employeeExecutionMeta || live.runtime.execution).map(([id]) => this.disposeSession(id))); }
153
+ observeModelInvalidation(fields, changes = []) { for (const live of this.sessions.values())
154
+ live.runtime.closeDiagnostics?.record({ layer: 'adapter', initiator: 'catalog.signature-change', reason: 'MODEL_CATALOG_SIGNATURE_CHANGED', action: 'invalidate', changedFields: fields, changes }); }
155
+ async invalidateModelState() { for (const [id, turn] of this.turns) {
156
+ this.sessions.get(id)?.runtime.closeDiagnostics?.record({ layer: 'adapter', initiator: 'adapter.model-invalidation', reason: 'MODEL_STATE_INVALIDATED', action: 'abort', changes: [{ field: 'adapter.turn-aborted', before: turn.signal.aborted, after: true }] });
157
+ turn.abort();
158
+ } await Promise.all([...this.sessions.keys()].map(id => this.disposeSession(id))); }
159
+ async invalidateEmployeeState() { for (const live of this.sessions.values())
160
+ if (live.runtime.employeeExecutionMeta || live.runtime.execution)
161
+ live.runtime.closeDiagnostics?.record({ layer: 'adapter', initiator: 'adapter.employee-invalidation', reason: 'EMPLOYEE_SERVICE_CHANGED', action: 'invalidate' }); await Promise.all([...this.sessions].filter(([, live]) => live.runtime.employeeExecutionMeta || live.runtime.execution).map(([id]) => this.disposeSession(id))); }
147
162
  providerRetryPolicy() { return { mode: 'normal', maxRetries: 0, retryableCodes: [], initialDelayMs: 0, maxDelayMs: 0, jitterRatio: 0 }; }
148
- cleanupRuntime(runtime) {
163
+ cleanupRuntime(runtime, cause = { layer: 'adapter', initiator: 'adapter.cleanup-runtime', reason: 'RUNTIME_CLEANUP', action: 'dispose' }) {
164
+ runtime.closeDiagnostics?.record(cause);
149
165
  const existing = this.cleanups.get(runtime);
150
166
  if (existing)
151
167
  return existing;
152
- const cleanup = Promise.resolve().then(() => runtime.dispose?.()).catch(() => {
153
- const failure = new NeedError('REMOTE_CLEANUP_UNCONFIRMED', '原主机会话的清理结果未确认,不能继续或重开此会话', '管理员核对并结束原主机会话');
168
+ const cleanup = Promise.resolve().then(() => runtime.dispose?.(cause)).catch(error => {
169
+ const codes = closeErrorCodes(error, 'REMOTE_CLEANUP_UNCONFIRMED');
170
+ const failure = closeFailure('REMOTE_CLEANUP_UNCONFIRMED', '原主机会话的清理结果未确认,不能继续或重开此会话', '管理员核对原主机会话;收尾代码 ' + codes.join(','), codes);
154
171
  for (const live of this.sessions.values())
155
172
  if (live.runtime === runtime) {
156
173
  live.cleanupFailure = failure;
@@ -162,13 +179,14 @@ export class CodexRemoteAdapter extends LlmAdapter {
162
179
  return cleanup;
163
180
  }
164
181
  async disposeSession(sessionId) {
182
+ this.sessions.get(sessionId)?.runtime.closeDiagnostics?.record({ layer: 'adapter', initiator: 'adapter.dispose-session', reason: 'SESSION_DISPOSED', action: 'abort' });
165
183
  this.turns.get(sessionId)?.abort();
166
184
  const current = this.sessions.get(sessionId);
167
185
  if (!current)
168
186
  return;
169
187
  if (current.disposal)
170
188
  return current.disposal;
171
- current.session.close();
189
+ current.session.close({ layer: 'adapter', initiator: 'adapter.dispose-session', reason: 'SESSION_DISPOSED', action: 'close' });
172
190
  current.disposal = this.cleanupRuntime(current.runtime).then(() => { if (this.sessions.get(sessionId) === current)
173
191
  this.sessions.delete(sessionId); });
174
192
  return current.disposal;
@@ -177,13 +195,14 @@ export class CodexRemoteAdapter extends LlmAdapter {
177
195
  const current = this.sessions.get(sessionId), turn = this.turns.get(sessionId);
178
196
  if (!current && !turn)
179
197
  return;
198
+ current?.runtime.closeDiagnostics?.record({ layer: 'adapter', initiator: 'adapter.detach-session', reason: 'SESSION_DETACHED', action: 'detach', changes: [{ field: 'adapter.detached', before: this.detached.has(sessionId), after: true }] });
180
199
  this.detached.add(sessionId);
181
200
  if (current) {
182
201
  try {
183
- await current.runtime.detach?.();
202
+ await current.runtime.detach?.({ layer: 'adapter', initiator: 'adapter.detach-session', reason: 'SESSION_DETACHED', action: 'detach' });
184
203
  }
185
204
  finally {
186
- current.session.close();
205
+ current.session.close({ layer: 'adapter', initiator: 'adapter.detach-finally', reason: 'SESSION_DETACHED', action: 'close' });
187
206
  this.sessions.delete(sessionId);
188
207
  turn?.abort();
189
208
  }
@@ -192,6 +211,8 @@ export class CodexRemoteAdapter extends LlmAdapter {
192
211
  turn?.abort();
193
212
  }
194
213
  async dispose() {
214
+ for (const live of this.sessions.values())
215
+ live.runtime.closeDiagnostics?.record({ layer: 'adapter', initiator: 'adapter.dispose', reason: 'PLUGIN_DISPOSED', action: 'dispose', changes: [{ field: 'adapter.shutting-down', before: this.shuttingDown, after: true }] });
195
216
  this.shuttingDown = true;
196
217
  const results = await Promise.allSettled([...new Set([...this.sessions.keys(), ...this.turns.keys()])].map(id => this.detachSession(id)));
197
218
  this.lifetime.abort();
@@ -239,6 +260,7 @@ export class CodexRemoteAdapter extends LlmAdapter {
239
260
  const signal = AbortSignal.any([this.lifetime.signal, turn.signal, ...(original.signal ? [original.signal] : [])]);
240
261
  const options = { ...original, signal };
241
262
  const timing = new StageTiming();
263
+ const abortOffs = [];
242
264
  let request;
243
265
  let earlyStage = 'input-prepare';
244
266
  let runtime;
@@ -246,14 +268,25 @@ export class CodexRemoteAdapter extends LlmAdapter {
246
268
  let live;
247
269
  let succeeded = false;
248
270
  let remoteAttempted = false;
271
+ let promptBaseline = 0;
272
+ let failureCode;
249
273
  let preserveRecoveredRuntime = false;
250
274
  let activeMapper;
251
275
  let catalog;
252
276
  let selected;
253
277
  let modelConfiguration;
278
+ const information = [];
279
+ const recordInformation = () => { if (runtime?.details)
280
+ for (const entry of information.splice(0))
281
+ runtime.details.record(entry); };
254
282
  const config = this.config();
255
283
  const mode = this.agent.oneShot ? config.mode : 'session';
256
284
  request = { sessionId: options.sessionId, mode: mode ?? 'session', timing, signal };
285
+ const observeAbort = (source, initiator, reason) => { if (!source)
286
+ return; const record = () => { (request?.closeDiagnostics ?? runtime?.closeDiagnostics)?.record({ layer: 'adapter', initiator, reason, action: 'abort', changes: [{ field: 'adapter.turn-aborted', before: false, after: source.aborted }] }); }; source.addEventListener('abort', record, { once: true }); abortOffs.push(() => source.removeEventListener('abort', record)); };
287
+ observeAbort(original.signal, 'signal.caller', 'CALLER_ABORTED');
288
+ observeAbort(this.lifetime.signal, 'signal.plugin-lifetime', 'PLUGIN_DISPOSED');
289
+ observeAbort(turn.signal, 'signal.adapter-turn', 'TURN_ABORTED');
257
290
  this.preflightDiagnostics?.begin(request, this.agent.loginProvider);
258
291
  if (this.preflightDiagnostics)
259
292
  request.recordEarlyFailure = (error, stage) => this.preflightDiagnostics.failure(request, this.agent.loginProvider, error, stage);
@@ -261,21 +294,39 @@ export class CodexRemoteAdapter extends LlmAdapter {
261
294
  if (!mode || (mode === 'session' && (!Number.isSafeInteger(config.acpMaxMessageBytes) || config.acpMaxMessageBytes < 1)))
262
295
  throw new NeedError('SETTINGS_REQUIRED', '远程模型连接设置无效,请联系管理员检查', '有效的mode/acpMaxMessageBytes');
263
296
  const previous = readPointer(options, this.agent);
297
+ const last = options.messages.findLast(message => message.role === 'assistant' && !isWorkspaceNoticeMessage(message));
298
+ const missingPointer = !!last && !previous;
299
+ let requireFreshSession = missingPointer || !!key && this.preparationFailures.has(key);
264
300
  if (previous && previous.mode !== mode)
265
301
  throw new NeedError('SESSION_CONTEXT_MISMATCH', '运行模式与原会话不一致,请新建会话', '原会话模式');
266
302
  if (previous && !previous.modelConfiguration)
267
- throw new NeedError('MODEL_SELECTION_REQUIRED', '旧远端恢复记录没有明确模型选择,请新建会话并选择模型', '本轮明确选择的模型');
303
+ information.push({ kind: 'session', severity: 'info', code: 'TURN_GATE_INFORMATION', summary: '历史回复没有模型配置资料;本轮会重新确认你当前选择的模型。', payload: { reason: 'HISTORICAL_MODEL_CONFIGURATION_ABSENT' } });
268
304
  live = key ? this.sessions.get(key) : undefined;
269
305
  if (live?.disposal || live?.cleanupFailure)
270
306
  throw live.cleanupFailure ?? new NeedError('REMOTE_CLEANUP_UNCONFIRMED', '原主机会话正在结束,清理结果尚未确认,不能继续或重开此会话', '管理员核对原主机会话清理结果');
271
307
  if (live?.failure)
272
- throw new NeedError('SESSION_STATE_UNCONFIRMED', '上轮远端状态未确认,不能自动重发或重开,请新建会话并核对项目', '管理员核对原会话状态');
273
- if (live && !previous)
274
- throw new NeedError('SESSION_STATE_MISSING', '上一轮回复尚无持久恢复记录,不能继续当前远端会话', '原会话的持久回复记录');
308
+ throw new NeedError('INPUT_DELIVERY_UNCONFIRMED', '上一轮输入已经交给远端,但结果未确认,不能自动重发', '核对已发送输入及原主机会话状态');
309
+ if (live && !previous) {
310
+ if (typeof live.runtime.dispose !== 'function')
311
+ throw new NeedError('REMOTE_CLEANUP_UNCONFIRMED', '原会话不能确认已安全结束,本轮尚未发送', '可确认结束原实例的通道');
312
+ live.session.close({ layer: 'adapter', initiator: 'adapter.missing-pointer', reason: 'PRIVATE_RECOVERY_POINTER_ABSENT', action: 'close' });
313
+ await this.cleanupRuntime(live.runtime, { layer: 'adapter', initiator: 'adapter.missing-pointer', reason: 'PRIVATE_RECOVERY_POINTER_ABSENT', action: 'dispose' });
314
+ if (key && this.sessions.get(key) === live)
315
+ this.sessions.delete(key);
316
+ live = undefined;
317
+ requireFreshSession = true;
318
+ }
275
319
  const configuration = JSON.stringify([mode, config.acpMaxMessageBytes, config.brokerMaxFrameBytes, config.brokerControlMaxBytes, config.employeeShellKillGraceMs]);
276
320
  if (live && live.configuration !== configuration)
277
321
  throw new NeedError('SESSION_CONTEXT_MISMATCH', '会话运行设置已改变,请新建会话', '原会话运行设置');
278
322
  Object.assign(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 }) });
323
+ if (requireFreshSession) {
324
+ request.requireFreshSession = true;
325
+ request.resumeSessionId = undefined;
326
+ request.recoveryCheckpointId = undefined;
327
+ information.push({ kind: 'session', severity: 'warning', code: 'TURN_GATE_INFORMATION', summary: '缺少可用的旧会话恢复资料,或上次在发送前已失败;将核对旧实例安全结束后,只处理本次接纳的输入。', payload: { reason: missingPointer ? 'PRIVATE_RECOVERY_POINTER_ABSENT' : 'PRIOR_PREPARATION_FAILED' } });
328
+ }
329
+ request.previousContextSha256 = previous?.contextSha256;
279
330
  const after = options.messages.findLastIndex(message => message.role === 'assistant' && !isWorkspaceNoticeMessage(message));
280
331
  request.userInputIds = Object.freeze(options.messages.slice(after + 1).flatMap(message => message.role === 'user' && message.source?.kind === 'user' && typeof message.id === 'string' ? [message.id] : []));
281
332
  request.userMessageIds = request.userInputIds;
@@ -293,8 +344,7 @@ export class CodexRemoteAdapter extends LlmAdapter {
293
344
  // New Codex transport scopes are independent of the native context digest.
294
345
  if (contextChanged)
295
346
  request.contextSha256 = undefined;
296
- if (previous && this.agent.loginProvider === 'claude' && previous.contextSha256 !== contextSha256)
297
- throw new NeedError('SYSTEM_PROMPT_UPDATE_PENDING', 'Claude原会话的系统或开发者要求已变化;主机仅承诺下次上下文整理后生效,本轮未发送,请新建会话应用新要求', '新建Claude会话以立即应用当前系统与开发者要求');
347
+ const claudeContextChanged = !!previous && !codex && previous.contextSha256 !== contextSha256;
298
348
  if (contextChanged)
299
349
  input = await prepareContinuation(options, input, this.readImage);
300
350
  if (live) {
@@ -314,16 +364,25 @@ export class CodexRemoteAdapter extends LlmAdapter {
314
364
  earlyStage = 'model-catalog';
315
365
  catalog = await this.catalog(signal);
316
366
  selected = catalog.models.find(model => model.id === options.model);
367
+ catalog.onInformation?.(entry => information.push(entry));
317
368
  if (!selected)
318
369
  throw new ModelConfigurationError('所选模型不在本次可信主机目录中,请重新选择模型');
319
370
  if (options.reasoningEffort !== undefined && !selected.reasoning.options.some(option => option.value === options.reasoningEffort))
320
371
  throw new ModelConfigurationError('所选推理等级不在本次模型目录中');
372
+ const observeSignal = (source, initiator, reason) => { const observed = () => { const diagnostic = request?.closeDiagnostics ?? runtime?.closeDiagnostics; if (diagnostic)
373
+ diagnostic.record({ layer: 'adapter', initiator, reason, action: 'abort', changes: [{ field: 'adapter.turn-aborted', before: false, after: source.aborted }] }); }; source.addEventListener('abort', observed, { once: true }); abortOffs.push(() => source.removeEventListener('abort', observed)); };
374
+ observeSignal(catalog.signal, 'signal.model-catalog', 'MODEL_CATALOG_REVOKED');
375
+ observeSignal(signal, 'signal.turn-composite', 'ABORTED');
321
376
  let modelSignal = AbortSignal.any([signal, catalog.signal]);
322
377
  options.signal = modelSignal;
323
378
  request.signal = modelSignal;
324
379
  earlyStage = 'runtime-resolve';
325
380
  runtime = await this.resolveRuntime(request);
381
+ const closeSink = runtime.details;
382
+ runtime.closeDiagnostics ??= new CloseDiagnostics(event => closeSink?.record(event));
326
383
  signal.throwIfAborted();
384
+ if (requireFreshSession && (!runtime.freshSessionConfirmed || !runtime.restartNotice))
385
+ throw new NeedError('REMOTE_CLEANUP_UNCONFIRMED', '未确认原实例安全结束或原位置为空,本轮没有发送', '同UUID条件结束与list消失证明,或原slot不存在证明');
327
386
  if (runtime.execution?.signal) {
328
387
  modelSignal = AbortSignal.any([modelSignal, runtime.execution.signal]);
329
388
  options.signal = modelSignal;
@@ -357,6 +416,7 @@ export class CodexRemoteAdapter extends LlmAdapter {
357
416
  const details = runtime.details;
358
417
  if (!details)
359
418
  throw new NeedError('TECHNICAL_DETAILS_UNAVAILABLE', '技术详情暂不可用,请更新远程主机连接组件。', 'L技术详情持久接收能力');
419
+ recordInformation();
360
420
  timing.bind(detail => details.record(detail));
361
421
  request.recordEarlyFailure = undefined;
362
422
  const mapper = new StreamMapper(mode === 'session' && !contextChanged ? (runtime.restartNotice ? undefined : live ? live.usageSnapshot : previous ? (previous.usageSnapshot ?? null) : undefined) : undefined, observation => details.record(observation));
@@ -390,7 +450,7 @@ export class CodexRemoteAdapter extends LlmAdapter {
390
450
  let done = false;
391
451
  let failure;
392
452
  const push = (chunks) => { queue.push(...chunks); wake?.(); wake = undefined; };
393
- const hooks = { broker: runtime.broker, employeeExecution: runtime.employeeExecution, timing, approve: async (permission, approvalSignal) => {
453
+ const hooks = { closeDiagnostics: runtime.closeDiagnostics, broker: runtime.broker, employeeExecution: runtime.employeeExecution, timing, approve: async (permission, approvalSignal) => {
394
454
  const execution = runtime.execution;
395
455
  const toolName = permission.toolCall.name ?? permission.toolCall._meta?.claudeCode?.toolName;
396
456
  if (execution) {
@@ -410,7 +470,7 @@ export class CodexRemoteAdapter extends LlmAdapter {
410
470
  }
411
471
  catch (error) {
412
472
  failure = error;
413
- session?.close();
473
+ session?.close({ layer: 'adapter', initiator: 'adapter.mapper-failure', reason: closeCode(error, 'MAPPING_FAILED'), action: 'close' });
414
474
  } }, onResponse: response => push(mapper.acpResponse(response)), onDetail: detail => details.record(detail) };
415
475
  const fresh = !live;
416
476
  if (fresh) {
@@ -426,6 +486,7 @@ export class CodexRemoteAdapter extends LlmAdapter {
426
486
  if (previous?.sessionId !== session.id)
427
487
  throw new NeedError('SESSION_STATE_INVALID', '持久会话标识与当前进程不一致', '原会话的恢复标识');
428
488
  }
489
+ promptBaseline = session.promptDispatchCount;
429
490
  const task = (async () => {
430
491
  remoteAttempted = true;
431
492
  try {
@@ -436,16 +497,20 @@ export class CodexRemoteAdapter extends LlmAdapter {
436
497
  session.validatePrompt(input.prompt);
437
498
  if (contextChanged)
438
499
  await session.newContextSession({ systemPrompt: input.systemPrompt }, modelSignal);
500
+ else if (claudeContextChanged)
501
+ await session.updateContext({ systemPrompt: input.systemPrompt }, modelSignal, true);
439
502
  }
440
503
  else if (fresh)
441
504
  await session.start(runtime.execution?.hostCwd ?? runtime.cwd, contextChanged || runtime.restartNotice ? undefined : previous?.sessionId, modelSignal, { systemPrompt: contextChanged ? (input.systemPrompt ?? '') : input.systemPrompt, ...(runtime.execution ? { employeeRouting: runtime.execution.routing } : {}), ...(codex ? { codexNewSessionOnly: true } : {}), ...(runtime.employeeExecutionMeta ? { employeeExecution: runtime.employeeExecutionMeta } : {}) }, input.prompt);
442
505
  else if (contextChanged)
443
506
  await session.newContextSession({ systemPrompt: input.systemPrompt }, modelSignal);
444
507
  else {
445
- const updated = await session.updateContext({ systemPrompt: input.systemPrompt }, modelSignal);
446
- if (updated && this.agent.loginProvider === 'claude')
447
- throw new NeedError('SYSTEM_PROMPT_UPDATE_PENDING', 'Claude原会话的系统或开发者要求已变化;主机仅承诺下次上下文整理后生效,本轮未发送,请新建会话应用新要求', '新建Claude会话以立即应用当前系统与开发者要求');
508
+ await session.updateContext({ systemPrompt: input.systemPrompt }, modelSignal);
448
509
  }
510
+ if (fresh && claudeContextChanged && !runtime.recoveredSession && !runtime.restartNotice)
511
+ details.record({ kind: 'session', severity: 'warning', code: 'SYSTEM_PROMPT_UPDATE_DEFERRED', summary: '新要求已通过原会话恢复接口传送;官方仅承诺下次上下文整理后生效,本轮不能保证已立即采用。', payload: { reason: 'OFFICIAL_CONTEXT_UPDATE_DEFERRED' } });
512
+ if (!codex)
513
+ live.contextSha256 = contextSha256;
449
514
  if (contextChanged) {
450
515
  if (session.id === previous.sessionId)
451
516
  throw new NeedError('SESSION_STATE_INVALID', '新会话标识与原会话相同,无法确认独立承接,本轮未发送', '独立的新远端会话标识');
@@ -457,24 +522,23 @@ export class CodexRemoteAdapter extends LlmAdapter {
457
522
  const actual = await session.selectModel(selected.value, options.reasoningEffort, modelSignal);
458
523
  catalog.assertCurrent();
459
524
  session.assertModelSelection(selected.value, options.reasoningEffort);
460
- let resolved = selected.resolvedModel;
461
- if (this.agent.loginProvider === 'claude') {
462
- const evidence = session.resolvedModelEvidence;
463
- if (!resolved || (selected.value !== resolved && (!evidence || evidence.modelValue !== selected.value || evidence.resolvedModel !== resolved)))
464
- throw new NeedError('MODEL_RESOLUTION_UNCONFIRMED', '所选模型的具体型号未获得当前会话确认,本轮未发送', '当前会话的具体型号证据');
465
- if (evidence && evidence.resolvedModel !== resolved)
466
- throw new NeedError('MODEL_RESOLUTION_UNCONFIRMED', '当前会话具体型号与所选目录不一致,本轮未发送', '完全一致的具体型号证据');
467
- }
468
- if (!resolved)
469
- throw new NeedError('MODEL_RESOLUTION_UNCONFIRMED', '远端没有提供具体型号证据,本轮未发送', '具体型号证据');
470
- modelConfiguration = { selected: { ...selected, ...(options.reasoningEffort === undefined ? {} : { effort: options.reasoningEffort }) }, configured: actual, resolvedModel: resolved, status: 'remote-configured' };
471
- details.record({ kind: 'session', severity: 'info', code: 'MODEL_CONFIGURATION_CONFIRMED', summary: '本轮选择已由远端配置回执确认;不代表推理效果已验证', payload: modelConfiguration });
525
+ // This lease is tied to the trusted host-user and instance/epoch.
526
+ // Its mapping and this turn's exact setter ACK are different facts.
527
+ const mapping = catalog.models.filter(model => model.value === selected.value);
528
+ if (mapping.length !== 1 || mapping[0].id !== selected.id)
529
+ throw new NeedError('MODEL_RESOLUTION_UNCONFIRMED', '所选模型在当前主机目录中没有唯一对应型号,本轮未发送', 'CATALOG_VALUE_MAPPING_NOT_UNIQUE');
530
+ const resolved = mapping[0].resolvedModel;
531
+ if (typeof resolved !== 'string' || !resolved.trim())
532
+ throw new NeedError('MODEL_RESOLUTION_UNCONFIRMED', '主机目录没有提供所选模型的具体型号,本轮未发送', 'CATALOG_RESOLVED_MODEL_MISSING');
533
+ modelConfiguration = { selected: { ...selected, ...(options.reasoningEffort === undefined ? {} : { effort: options.reasoningEffort }) }, configured: actual, resolvedModel: resolved, status: 'remote-configured', ...(this.agent.loginProvider === 'claude' ? { resolvedModelSource: 'host-user-catalog', sessionModelReported: false } : {}) };
534
+ details.record({ kind: 'session', severity: 'info', code: 'MODEL_CONFIGURATION_CONFIRMED', summary: '本轮选择已由远端配置回执确认;' + modelResolutionLabel(modelConfiguration) + ';不代表推理效果已验证', payload: modelConfiguration });
472
535
  }
473
536
  catch (error) {
474
- 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' } });
537
+ 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 === 'MODEL_RESOLUTION_UNCONFIRMED' ? error.requires : error.code) : error instanceof ModelConfigurationError ? error.message : 'MODEL_CONFIGURATION_FAILED' } });
475
538
  throw error;
476
539
  }
477
540
  await timing.run('details-flush', () => details.flush());
541
+ recordInformation();
478
542
  await details.flush();
479
543
  details.beginTurn(runtime.detailContext?.() ?? { turn: null, step: null });
480
544
  catalog.assertCurrent();
@@ -519,6 +583,7 @@ export class CodexRemoteAdapter extends LlmAdapter {
519
583
  yield chunk;
520
584
  }
521
585
  }
586
+ recordInformation();
522
587
  details.endTurn();
523
588
  await timing.run('details-flush', () => details.flush());
524
589
  await details.flush(); // The timing record is a new ordered write, not a retry.
@@ -528,10 +593,13 @@ export class CodexRemoteAdapter extends LlmAdapter {
528
593
  live.usageSnapshot = mapper.usageSnapshot;
529
594
  if (modelConfiguration) {
530
595
  const actual = session.modelConfiguration;
531
- if (!actual?.confirmed || actual.model !== modelConfiguration.configured.model || actual.effort !== modelConfiguration.configured.effort)
532
- throw new ModelConfigurationError('远端会话在本轮期间改变了模型配置,不能沿用旧回执');
596
+ const changed = !actual?.confirmed || actual.model !== modelConfiguration.configured.model || actual.effort !== modelConfiguration.configured.effort;
533
597
  catalog.assertCurrent();
534
- yield* mapper.text(`\n\n本轮所选模型:${modelConfiguration.selected.name}(${modelConfiguration.selected.value});推理等级:${modelConfiguration.selected.effort ?? '沿用远端配置'}。远端已配置模型:${modelConfiguration.configured.model};具体型号:${modelConfiguration.resolvedModel};推理等级:${modelConfiguration.configured.effort ?? '远端未提供'}(配置已确认,推理效果未验证)。`);
598
+ if (changed) {
599
+ details.record({ kind: 'session', severity: 'warning', code: 'MODEL_CONFIGURATION_CHANGED_DURING_TURN', summary: '本轮回复已经完成,期间远端配置发生变化;保留真实回复,具体执行型号未单独回报。', payload: { reason: 'POST_PROMPT_CONFIGURATION_CHANGED', configured: modelConfiguration.configured, actual: actual ?? null } });
600
+ await details.flush();
601
+ }
602
+ yield* mapper.text(`\n\n本轮所选模型:${modelConfiguration.selected.name}(${modelConfiguration.selected.value});推理等级:${modelConfiguration.selected.effort ?? '沿用远端配置'}。发送前远端配置回执:${modelConfiguration.configured.model};${modelResolutionLabel(modelConfiguration)};推理等级:${modelConfiguration.configured.effort ?? '远端未提供'}(${changed ? '本轮期间配置有变化,实际执行型号未单独回报' : '发送前配置已确认,推理效果未验证'})。`);
535
603
  }
536
604
  yield* mapper.flush();
537
605
  let recoveryCheckpointId;
@@ -546,7 +614,10 @@ export class CodexRemoteAdapter extends LlmAdapter {
546
614
  yield { type: 'finish', reason: { kind: stop === 'max_tokens' || stop === 'max_turn_requests' ? 'max-tokens' : 'stop' }, replayState: { response: pointer } };
547
615
  }
548
616
  catch (error) {
549
- if (live && (remoteAttempted || runtime === live.runtime))
617
+ runtime?.closeDiagnostics?.record({ layer: 'adapter', initiator: 'adapter.turn-failure', reason: closeCode(error, signal.aborted ? 'ABORTED' : 'TURN_FAILED'), action: 'failure' });
618
+ recordInformation();
619
+ failureCode = error instanceof NeedError || error instanceof ModelConfigurationError ? error.code : error instanceof LlmError ? error.code : 'TURN_PREPARATION_FAILED';
620
+ if (live && session && session.promptDispatchCount > promptBaseline)
550
621
  live.failure = { cause: error };
551
622
  let reportingFailure;
552
623
  if (request?.recordEarlyFailure) {
@@ -580,18 +651,32 @@ export class CodexRemoteAdapter extends LlmAdapter {
580
651
  throw error;
581
652
  }
582
653
  finally {
654
+ for (const off of abortOffs)
655
+ off();
583
656
  runtime?.execution?.endTurn();
584
657
  catalog?.release();
585
658
  try {
586
659
  if (preserveRecoveredRuntime || this.shuttingDown || key && this.detached.has(key)) {
587
- session?.close();
588
- await runtime?.detach?.();
660
+ session?.close({ layer: 'adapter', initiator: 'adapter.turn-detach', reason: 'TURN_DETACHED', action: 'close' });
661
+ await runtime?.detach?.({ layer: 'adapter', initiator: 'adapter.turn-detach', reason: 'TURN_DETACHED', action: 'detach' });
589
662
  }
590
663
  else if (!succeeded || !key || mode !== 'session') {
591
- session?.close();
592
- if (runtime)
664
+ session?.close({ layer: 'adapter', initiator: 'adapter.turn-finally', reason: succeeded ? 'TURN_FINISHED' : 'TURN_FAILED', action: 'close' });
665
+ if (runtime) {
593
666
  await this.cleanupRuntime(runtime);
667
+ if (key && live && !live.failure && this.sessions.get(key) === live) {
668
+ if (typeof runtime.dispose === 'function') {
669
+ this.sessions.delete(key);
670
+ if (!succeeded)
671
+ this.preparationFailures.set(key, failureCode ?? 'TURN_PREPARATION_FAILED');
672
+ }
673
+ else
674
+ live.cleanupFailure = new NeedError('REMOTE_CLEANUP_UNCONFIRMED', '缺少原实例结束证明,不能重开或重发', '原实例的条件结束与list消失证明');
675
+ }
676
+ }
594
677
  }
678
+ if (succeeded && key)
679
+ this.preparationFailures.delete(key);
595
680
  }
596
681
  finally {
597
682
  if (key && this.turns.get(key) === turn)
@@ -612,6 +697,8 @@ export class RemoteAgentsAdapter extends LlmAdapter {
612
697
  providerInfo(provider) { return this.adapter(provider).providerInfo(provider); }
613
698
  listModels(provider) { return this.adapter(provider).listModels(provider); }
614
699
  resolveModel(provider, model, signal) { return this.adapter(provider).resolveModel(provider, model, signal); }
700
+ observeModelInvalidation(fields, changes = []) { for (const adapter of this.adapters.values())
701
+ adapter.observeModelInvalidation(fields, changes); }
615
702
  async invalidateModelState() { await Promise.all([...this.adapters.values()].map(adapter => adapter.invalidateModelState())); }
616
703
  async invalidateEmployeeState() { await Promise.all([...this.adapters.values()].map(adapter => adapter.invalidateEmployeeState())); }
617
704
  providerRetryPolicy() { return { mode: 'normal', maxRetries: 0, retryableCodes: [], initialDelayMs: 0, maxDelayMs: 0, jitterRatio: 0 }; }
@@ -646,6 +733,7 @@ export function apply(ctx, live) {
646
733
  };
647
734
  const preflightDiagnostics = createPreflightDiagnostics({ ctx, getApi: get, manager: managerForProfile });
648
735
  let changed = () => { };
736
+ let observeCatalogRevocation = () => { };
649
737
  const diagnostics = new Map();
650
738
  const diagnostic = (scope, provider, message) => {
651
739
  let entry = diagnostics.get(provider);
@@ -667,7 +755,7 @@ export function apply(ctx, live) {
667
755
  new CatalogDiagnosticsRemote(ctx, async () => ({ ...await catalogDiagnostics.snapshot(), settingsNamespace: actualSettingsNamespace ?? null, providers: AGENTS.map(agent => ({ provider: agent.id, name: agent.name[language], messages: [...(diagnostics.get(agent.id)?.values() ?? [])] })) }));
668
756
  const modelDirectory = new ModelCatalog(get, () => config().acpMaxMessageBytes, (...args) => { const state = get()?.getConnectionState(); const connection = state ? { instanceId: state.instanceId, epoch: state.epoch, phase: state.phase } : null; return runCatalog(...args, event => catalogDiagnostics.record({ ...event, connection })); }, () => changed(), (brand, message) => {
669
757
  diagnostic('catalog', AGENTS.find(agent => agent.loginProvider === brand).id, message);
670
- }, (agent, models, assertCurrent) => defaults?.observe(agent, models, assertCurrent));
758
+ }, (agent, models, assertCurrent) => defaults?.observe(agent, models, assertCurrent), (fields, changes) => observeCatalogRevocation(fields, changes));
671
759
  const adapter = new RemoteAgentsAdapter(config, agent => {
672
760
  const details = async (request, api, connection, target) => {
673
761
  return managerForProfile().open({ sessionId: request.sessionId, provider: agent.loginProvider, identity: `${target.username}@${target.host}:${target.port}`, connection, localAcceptanceId: request.localAcceptanceId ?? request.localAcceptance?.acceptanceId });
@@ -758,6 +846,7 @@ export function apply(ctx, live) {
758
846
  child.effect(() => () => { current.dispose(); if (defaults === current)
759
847
  defaults = undefined; });
760
848
  });
849
+ observeCatalogRevocation = (fields, changes) => adapter.observeModelInvalidation(fields, changes);
761
850
  changed = () => { registration.replace(ids); refreshDefault(); void adapter.invalidateModelState().catch(() => { ctx.logger('remote-agent').warn('原会话清理结果未确认,已阻断继续发送;请联系管理员核对。'); }); };
762
851
  let employeeConnect = serviceValue('aiWorkbenchConnect'), employeeNetwork = serviceValue('aiWorkbenchNetwork');
763
852
  ctx.on('internal/service', service => {
@@ -1,7 +1,9 @@
1
+ import type { CloseChange } from './close-diagnostics.ts';
1
2
  import { type LlmResolvedModelInfo } from '@deepseek-ai/dsh-llm';
2
3
  import type { ModelCatalogTarget } from '@ai-workbench/connect/api';
3
4
  import type { AgentDefinition } from './agents.ts';
4
5
  import { type ConnectApi, type ConnectTarget } from './provider.ts';
6
+ import type { DetailObservation } from './conversation-details.ts';
5
7
  export { runCatalog } from './catalog-ssh.ts';
6
8
  export interface HostModel {
7
9
  id: string;
@@ -24,6 +26,7 @@ export interface CatalogLease {
24
26
  readonly signal: AbortSignal;
25
27
  assertCurrent(): void;
26
28
  release(): void;
29
+ onInformation?(listener: (entry: DetailObservation) => void): () => void;
27
30
  }
28
31
  export interface ModelDirectory {
29
32
  read(agent: AgentDefinition, signal?: AbortSignal): Promise<CatalogLease>;
@@ -42,6 +45,7 @@ export declare function catalogDiagnostic(issues: readonly CatalogIssue[]): stri
42
45
  /** Defaults are provider declarations, never catalog order or display names. */
43
46
  export declare function declaredDefault(agent: AgentDefinition, models: readonly HostModel[]): HostModel | undefined;
44
47
  export declare function parseHostCatalog(input: string, provider: string, username: string): ParsedCatalog;
48
+ export type CatalogChangeField = 'api-instance' | 'connection-instance' | 'connection-epoch' | 'connection-phase' | 'connection-need-code' | 'catalog-capability' | 'codex-target' | 'claude-target' | 'other-metadata';
45
49
  /** Share only pending work; settled results are never cached. */
46
50
  export declare class ModelCatalog implements ModelDirectory {
47
51
  private get;
@@ -50,6 +54,7 @@ export declare class ModelCatalog implements ModelDirectory {
50
54
  private changed;
51
55
  private diagnostic;
52
56
  private loaded;
57
+ private revoking;
53
58
  private api?;
54
59
  private unsubscribe?;
55
60
  private signature?;
@@ -57,10 +62,13 @@ export declare class ModelCatalog implements ModelDirectory {
57
62
  private disposed;
58
63
  private initialized;
59
64
  private active;
65
+ private presentation?;
66
+ private presentationNeedCode?;
67
+ private information;
60
68
  private flights;
61
69
  private serial;
62
70
  private latest;
63
- constructor(get: () => ConnectApi | undefined, maxBytes: () => number | undefined, run?: CatalogRunner, changed?: () => void, diagnostic?: (provider: 'claude' | 'codex', message: string | undefined) => void, loaded?: (agent: AgentDefinition, models: readonly HostModel[], assertCurrent: () => void) => void | Promise<void>);
71
+ constructor(get: () => ConnectApi | undefined, maxBytes: () => number | undefined, run?: CatalogRunner, changed?: () => void, diagnostic?: (provider: 'claude' | 'codex', message: string | undefined) => void, loaded?: (agent: AgentDefinition, models: readonly HostModel[], assertCurrent: () => void) => void | Promise<void>, revoking?: (fields: readonly CatalogChangeField[], changes: readonly CloseChange[]) => void);
64
72
  refresh(): void;
65
73
  private trusted;
66
74
  private assertFlight;
@@ -1,3 +1,4 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import { LlmError, ReasoningEffortId } from '@deepseek-ai/dsh-llm';
2
3
  import { sameTarget, validateTarget } from "./provider.js";
3
4
  import { ModelConfigurationError } from "./model-configuration.js";
@@ -92,6 +93,45 @@ export function parseHostCatalog(input, provider, username) {
92
93
  }
93
94
  return freeze(structuredClone({ models, issues }));
94
95
  }
96
+ function changedCatalogFields(previous, next, switched) {
97
+ const before = previous ? JSON.parse(previous) : {}, after = JSON.parse(next), fields = [];
98
+ if (switched)
99
+ fields.push('api-instance');
100
+ for (const [key, label] of [['instanceId', 'connection-instance'], ['epoch', 'connection-epoch'], ['phase', 'connection-phase'], ['needCode', 'connection-need-code']])
101
+ if (JSON.stringify(before.state?.[key]) !== JSON.stringify(after.state?.[key]))
102
+ fields.push(label);
103
+ if (before.capability !== after.capability)
104
+ fields.push('catalog-capability');
105
+ for (const [index, label] of [[0, 'codex-target'], [1, 'claude-target']])
106
+ if (JSON.stringify(before.targets?.[index]) !== JSON.stringify(after.targets?.[index]))
107
+ fields.push(label);
108
+ return fields.length ? fields : ['other-metadata'];
109
+ }
110
+ function catalogChanges(previous, next, switched) {
111
+ const before = previous ? JSON.parse(previous) : {}, after = JSON.parse(next), changes = [];
112
+ const scalar = (value) => typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' ? value : null;
113
+ const compare = (field, old, current) => { if (JSON.stringify(old) !== JSON.stringify(current))
114
+ changes.push({ field, before: scalar(old), after: scalar(current) }); };
115
+ const fingerprint = (value) => value === undefined ? null : createHash('sha256').update(JSON.stringify(value)).digest('hex');
116
+ if (switched)
117
+ changes.push({ field: 'catalog.api-replaced', before: false, after: true });
118
+ for (const [key, name] of [['instanceId', 'instance-id'], ['epoch', 'epoch'], ['phase', 'phase'], ['needCode', 'need-code']])
119
+ compare('connection.' + name, before.state?.[key], after.state?.[key]);
120
+ compare('catalog.capability', before.capability, after.capability);
121
+ for (const [index, provider] of [[0, 'codex'], [1, 'claude']]) {
122
+ const a = before.targets?.[index], b = after.targets?.[index], prefix = 'catalog.' + provider + '.';
123
+ compare(prefix + 'ok', a?.ok, b?.ok);
124
+ compare(prefix + 'error-code', a?.code, b?.code);
125
+ for (const [key, name] of [['instanceId', 'instance-id'], ['epoch', 'epoch'], ['provider', 'provider']])
126
+ compare(prefix + name, a?.value?.[key], b?.value?.[key]);
127
+ // Protect executable argv, trust/config paths and arbitrary future metadata: values are digests.
128
+ compare(prefix + 'target-sha256', fingerprint(a?.value?.target), fingerprint(b?.value?.target));
129
+ compare(prefix + 'metadata-sha256', fingerprint(a), fingerprint(b));
130
+ }
131
+ if (!changes.length)
132
+ changes.push({ field: 'catalog.signature-sha256', before: fingerprint(before), after: fingerprint(after) });
133
+ return changes;
134
+ }
95
135
  /** Share only pending work; settled results are never cached. */
96
136
  export class ModelCatalog {
97
137
  get;
@@ -100,6 +140,7 @@ export class ModelCatalog {
100
140
  changed;
101
141
  diagnostic;
102
142
  loaded;
143
+ revoking;
103
144
  api;
104
145
  unsubscribe;
105
146
  signature;
@@ -107,16 +148,20 @@ export class ModelCatalog {
107
148
  disposed = false;
108
149
  initialized = false;
109
150
  active = new Set();
151
+ presentation;
152
+ presentationNeedCode;
153
+ information = new Set();
110
154
  flights = new Map();
111
155
  serial = 0;
112
156
  latest = new Map();
113
- constructor(get, maxBytes, run = runCatalog, changed = () => { }, diagnostic = () => { }, loaded = () => { }) {
157
+ constructor(get, maxBytes, run = runCatalog, changed = () => { }, diagnostic = () => { }, loaded = () => { }, revoking = () => { }) {
114
158
  this.get = get;
115
159
  this.maxBytes = maxBytes;
116
160
  this.run = run;
117
161
  this.changed = changed;
118
162
  this.diagnostic = diagnostic;
119
163
  this.loaded = loaded;
164
+ this.revoking = revoking;
120
165
  this.refresh();
121
166
  }
122
167
  refresh() {
@@ -125,11 +170,19 @@ export class ModelCatalog {
125
170
  const info = api?.getContractInfo?.();
126
171
  const targets = info?.modelCatalogTarget === 1 && api?.getModelCatalogTarget ? ['codex', 'claude'].map(brand => api.getModelCatalogTarget(brand)) : undefined;
127
172
  // L's same-epoch recovery wording is presentation metadata, not an execution change.
128
- const executionState = state && { instanceId: state.instanceId, epoch: state.epoch, phase: state.phase, needCode: state.need?.code };
173
+ const executionState = state && { instanceId: state.instanceId, epoch: state.epoch, phase: state.phase };
129
174
  const executionTargets = targets?.map(result => result.ok ? result : { ok: false, code: result.error.code });
130
175
  const signature = JSON.stringify({ state: executionState, capability: info?.modelCatalogTarget, targets: executionTargets });
131
176
  const switched = api !== this.api;
132
177
  const changed = this.initialized && (switched || signature !== this.signature);
178
+ const presentation = createHash('sha256').update(JSON.stringify({ need: { code: state?.need?.code, message: state?.need?.message, requires: state?.need?.requires }, recovery: state?.recovery && { phase: state.recovery.phase, message: state.recovery.message, startedAt: state.recovery.startedAt, administratorAfterMs: state.recovery.administratorAfterMs } })).digest('hex');
179
+ if (this.initialized && !changed && presentation !== this.presentation) {
180
+ const entry = { kind: 'session', severity: 'info', code: 'CATALOG_SCOPE_INFORMATION', summary: '连接说明已变化;主机身份、连接代际和可信目标未变,本轮继续。', payload: { reason: 'CATALOG_PRESENTATION_CHANGED', before: { needCode: this.presentationNeedCode ?? null, presentationSha256: this.presentation ?? null }, after: { needCode: state?.need?.code ?? null, presentationSha256: presentation } } };
181
+ for (const listener of this.information)
182
+ listener(entry);
183
+ }
184
+ this.presentation = presentation;
185
+ this.presentationNeedCode = state?.need?.code;
133
186
  this.initialized = true;
134
187
  if (switched) {
135
188
  this.unsubscribe?.();
@@ -143,8 +196,10 @@ export class ModelCatalog {
143
196
  this.unsubscribe = () => { for (const stop of off)
144
197
  stop(); };
145
198
  }
199
+ const changedFields = changed ? changedCatalogFields(this.signature, signature, switched) : [], changes = changed ? catalogChanges(this.signature, signature, switched) : [];
146
200
  this.signature = signature;
147
201
  if (changed) {
202
+ this.revoking(changedFields, changes);
148
203
  this.revision++;
149
204
  this.latest.clear();
150
205
  for (const controller of this.active)
@@ -224,8 +279,10 @@ export class ModelCatalog {
224
279
  }
225
280
  async read(agent, signal) {
226
281
  let flight, released = false;
282
+ const listeners = new Set();
227
283
  const release = () => { if (released || !flight)
228
- return; released = true; flight.users--; if (!flight.users) {
284
+ return; released = true; for (const listener of listeners)
285
+ this.information.delete(listener); listeners.clear(); flight.users--; if (!flight.users) {
229
286
  if (flight.pending)
230
287
  flight.controller.abort();
231
288
  else
@@ -277,7 +334,8 @@ export class ModelCatalog {
277
334
  abort();
278
335
  });
279
336
  assertCurrent();
280
- return { target: own.target, models, signal: combined, assertCurrent, release };
337
+ return { target: own.target, models, signal: combined, assertCurrent, release, onInformation: listener => { if (released)
338
+ return () => { }; listeners.add(listener); this.information.add(listener); return () => { listeners.delete(listener); this.information.delete(listener); }; } };
281
339
  }
282
340
  catch (error) {
283
341
  release();
@@ -288,7 +346,7 @@ export class ModelCatalog {
288
346
  }
289
347
  }
290
348
  dispose() { this.disposed = true; this.unsubscribe?.(); this.flights.clear(); for (const controller of this.active)
291
- controller.abort(); this.active.clear(); }
349
+ controller.abort(); this.active.clear(); this.information.clear(); }
292
350
  }
293
351
  export function modelInfo(provider, model) {
294
352
  // Official rc.2 represents no reasoning levels by absence, and rejects an empty efforts array.