@ai-workbench/remote-agent 0.8.11 → 0.8.12

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,5 @@
1
+ import { runCatalog } from "./catalog-ssh.js";
2
+ import { createCatalogDiagnostics, CatalogDiagnosticsRemote } from "./catalog-diagnostics.js";
1
3
  import { SessionId } from '@deepseek-ai/dsh-session';
2
4
  import Schema from '@deepseek-ai/schemastery';
3
5
  import { LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm';
@@ -31,6 +33,7 @@ import { createClaudeLocalProvider } from "./claude-local.js";
31
33
  import { readLocalUserTurn } from "./local-user-turn.js";
32
34
  import { readEmployeeWorkspace } from "./employee-workspace.js";
33
35
  import { createModelDefaults, createDefaultSelectionWriter } from "./model-defaults.js";
36
+ import { createPreflightDiagnostics } from "./preflight-diagnostic.js";
34
37
  import { installModelInputRetention, markModelPreparationFailure } from "./model-input-retention.js";
35
38
  export const name = 'ai-workbench-remote-agent';
36
39
  export const settingsNamespace = 'ai-workbench-codex';
@@ -68,6 +71,23 @@ function readPointer(options, agent) {
68
71
  throw new NeedError('SESSION_CONTEXT_DIGEST_INVALID', '原Codex会话的初建上下文摘要未知或无效,不能猜测是否相同,本轮未发送', '原会话合法的64位十六进制初建上下文摘要');
69
72
  return pointer;
70
73
  }
74
+ // A reporting failure can wrap the original admission error. Preserve its
75
+ // already-safe login snapshot even when a newer preflight delivery is unknown.
76
+ function originalLoginFailure(error) {
77
+ const pending = [error], seen = new Set();
78
+ while (pending.length) {
79
+ const current = pending.pop();
80
+ if (!current || typeof current !== 'object' || seen.has(current))
81
+ continue;
82
+ seen.add(current);
83
+ if (current instanceof NeedError && current.technical)
84
+ return current;
85
+ if (current instanceof AggregateError)
86
+ pending.push(...current.errors);
87
+ if (current instanceof Error && current.cause !== undefined)
88
+ pending.push(current.cause);
89
+ }
90
+ }
71
91
  export class CodexRemoteAdapter extends LlmAdapter {
72
92
  resolveRuntime;
73
93
  approve;
@@ -76,6 +96,7 @@ export class CodexRemoteAdapter extends LlmAdapter {
76
96
  readImage;
77
97
  reportLoginFailure;
78
98
  directory;
99
+ preflightDiagnostics;
79
100
  config;
80
101
  sessions = new Map();
81
102
  turns = new Map();
@@ -83,7 +104,7 @@ export class CodexRemoteAdapter extends LlmAdapter {
83
104
  shuttingDown = false;
84
105
  detached = new Set();
85
106
  cleanups = new WeakMap();
86
- constructor(config, resolveRuntime, approve, providerName = () => PROVIDER_NAMES.en, agent = CODEX_AGENT, readImage, reportLoginFailure, directory) {
107
+ constructor(config, resolveRuntime, approve, providerName = () => PROVIDER_NAMES.en, agent = CODEX_AGENT, readImage, reportLoginFailure, directory, preflightDiagnostics) {
87
108
  super();
88
109
  this.resolveRuntime = resolveRuntime;
89
110
  this.approve = approve;
@@ -92,6 +113,7 @@ export class CodexRemoteAdapter extends LlmAdapter {
92
113
  this.readImage = readImage;
93
114
  this.reportLoginFailure = reportLoginFailure;
94
115
  this.directory = directory;
116
+ this.preflightDiagnostics = preflightDiagnostics;
95
117
  this.config = typeof config === 'function' ? config : () => config;
96
118
  }
97
119
  providerInfo(provider) { return { id: provider, name: this.providerName() }; }
@@ -187,10 +209,11 @@ export class CodexRemoteAdapter extends LlmAdapter {
187
209
  if (error instanceof NeedError) {
188
210
  const rpc = /RPC 错误码 (-?\d+)/u.exec(error.requires)?.[1];
189
211
  const message = employeeFailure(error.code, this.agent.loginProvider) + (rpc ? `(技术详情:RPC 错误码 ${rpc})` : '');
190
- let diagnostic = error.technical ? JSON.stringify(error.technical) : '';
191
- if (error.technical && this.reportLoginFailure) {
212
+ const login = originalLoginFailure(error);
213
+ let diagnostic = login?.technical ? JSON.stringify(login.technical) : '';
214
+ if (login?.technical && this.reportLoginFailure) {
192
215
  try {
193
- const receipt = await this.reportLoginFailure(options.sessionId, error.code, error.technical);
216
+ const receipt = await this.reportLoginFailure(options.sessionId, login.code, login.technical);
194
217
  diagnostic += ';诊断 eventId=' + receipt.eventId;
195
218
  }
196
219
  catch (failure) {
@@ -230,6 +253,10 @@ export class CodexRemoteAdapter extends LlmAdapter {
230
253
  let modelConfiguration;
231
254
  const config = this.config();
232
255
  const mode = this.agent.oneShot ? config.mode : 'session';
256
+ request = { sessionId: options.sessionId, mode: mode ?? 'session', timing, signal };
257
+ this.preflightDiagnostics?.begin(request, this.agent.loginProvider);
258
+ if (this.preflightDiagnostics)
259
+ request.recordEarlyFailure = (error, stage) => this.preflightDiagnostics.failure(request, this.agent.loginProvider, error, stage);
233
260
  try {
234
261
  if (!mode || (mode === 'session' && (!Number.isSafeInteger(config.acpMaxMessageBytes) || config.acpMaxMessageBytes < 1)))
235
262
  throw new NeedError('SETTINGS_REQUIRED', '远程模型连接设置无效,请联系管理员检查', '有效的mode/acpMaxMessageBytes');
@@ -248,11 +275,13 @@ export class CodexRemoteAdapter extends LlmAdapter {
248
275
  const configuration = JSON.stringify([mode, config.acpMaxMessageBytes, config.brokerMaxFrameBytes, config.brokerControlMaxBytes, config.employeeShellKillGraceMs]);
249
276
  if (live && live.configuration !== configuration)
250
277
  throw new NeedError('SESSION_CONTEXT_MISMATCH', '会话运行设置已改变,请新建会话', '原会话运行设置');
251
- 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 }) };
278
+ 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 }) });
252
279
  const after = options.messages.findLastIndex(message => message.role === 'assistant' && !isWorkspaceNoticeMessage(message));
253
280
  request.userInputIds = Object.freeze(options.messages.slice(after + 1).flatMap(message => message.role === 'user' && message.source?.kind === 'user' && typeof message.id === 'string' ? [message.id] : []));
254
281
  request.userMessageIds = request.userInputIds;
282
+ earlyStage = 'source-context';
255
283
  await this.resolveRuntime.preflight?.(request);
284
+ earlyStage = 'input-prepare';
256
285
  let input = await timing.run('input-prepare', () => prepareInput(options, this.readImage));
257
286
  const contextSha256 = createHash('sha256').update(input.systemPrompt ?? '').digest('hex');
258
287
  request.contextSha256 = contextSha256;
@@ -525,7 +554,7 @@ export class CodexRemoteAdapter extends LlmAdapter {
525
554
  request.recordEarlyFailure = undefined;
526
555
  const code = error instanceof NeedError || error instanceof ModelConfigurationError ? error.code : error instanceof LlmError ? error.failure.code : signal.aborted ? 'ABORTED' : 'WORKSPACE_SOURCE_CONFIRMATION_FAILED';
527
556
  try {
528
- await record(new NeedError(code, '来源确认后本轮在执行前失败。', '白名单阶段与错误代码'), earlyStage);
557
+ await record(Object.assign(new NeedError(code, '本轮在执行前失败。', '白名单阶段与错误代码'), { cause: error }), earlyStage);
529
558
  }
530
559
  catch (failure) {
531
560
  reportingFailure = { cause: failure };
@@ -576,8 +605,8 @@ export class CodexRemoteAdapter extends LlmAdapter {
576
605
  /** One official registration owns both routes, preserving opaque replay ownership. */
577
606
  export class RemoteAgentsAdapter extends LlmAdapter {
578
607
  adapters = new Map();
579
- constructor(config, makeRuntime, approve, nameFor, readImage, reportLoginFailure, directory) { super(); for (const agent of AGENTS)
580
- this.adapters.set(agent.id, new CodexRemoteAdapter(config, makeRuntime(agent), approve, () => nameFor(agent), agent, readImage, reportLoginFailure, directory)); }
608
+ constructor(config, makeRuntime, approve, nameFor, readImage, reportLoginFailure, directory, preflightDiagnostics) { super(); for (const agent of AGENTS)
609
+ this.adapters.set(agent.id, new CodexRemoteAdapter(config, makeRuntime(agent), approve, () => nameFor(agent), agent, readImage, reportLoginFailure, directory, preflightDiagnostics)); }
581
610
  adapter(provider) { const adapter = this.adapters.get(provider); if (!adapter)
582
611
  throw new LlmError('远程模型未注册', 'PROVIDER_NOT_FOUND'); return adapter; }
583
612
  providerInfo(provider) { return this.adapter(provider).providerInfo(provider); }
@@ -615,7 +644,8 @@ export function apply(ctx, live) {
615
644
  }
616
645
  return manager;
617
646
  };
618
- let changed = () => { }, publishDiagnostics = () => { };
647
+ const preflightDiagnostics = createPreflightDiagnostics({ ctx, getApi: get, manager: managerForProfile });
648
+ let changed = () => { };
619
649
  const diagnostics = new Map();
620
650
  const diagnostic = (scope, provider, message) => {
621
651
  let entry = diagnostics.get(provider);
@@ -629,10 +659,13 @@ export function apply(ctx, live) {
629
659
  entry.delete(scope);
630
660
  else
631
661
  entry.set(scope, message);
632
- publishDiagnostics();
633
662
  };
634
663
  let defaults;
635
- const modelDirectory = new ModelCatalog(get, () => config().acpMaxMessageBytes, undefined, () => changed(), (brand, message) => {
664
+ const catalogDiagnostics = createCatalogDiagnostics(() => { const profile = ctx.get('profileContext'); return profile?.dir; });
665
+ // Public Loader Entry.options.id; optional when mounted without Loader.
666
+ const actualSettingsNamespace = ctx.fiber.entry?.options.id;
667
+ 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
+ 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) => {
636
669
  diagnostic('catalog', AGENTS.find(agent => agent.loginProvider === brand).id, message);
637
670
  }, (agent, models, assertCurrent) => defaults?.observe(agent, models, assertCurrent));
638
671
  const adapter = new RemoteAgentsAdapter(config, agent => {
@@ -668,7 +701,7 @@ export function apply(ctx, live) {
668
701
  };
669
702
  return runtime;
670
703
  };
671
- const local = agent.loginProvider === 'claude' ? createClaudeLocalProvider({ getConnect: get, getNetwork: () => ctx.get('aiWorkbenchNetwork'), readWorkspace: id => readEmployeeWorkspace(ctx, id), readUserTurn: request => readLocalUserTurn(ctx, { sessionId: request.sessionId, route: agent.id, userMessageIds: request.userMessageIds ?? [] }), reportAdmissionFailure: (request, code) => managerForProfile().recordLocalFailure({ sessionId: request.sessionId, provider: 'claude', code }), recordUnscoped: (request, observation) => managerForProfile().recordLocalFailure({ sessionId: request.sessionId, provider: 'claude', code: observation.code, observation }), configuration: config, details, open }) : undefined;
704
+ const local = agent.loginProvider === 'claude' ? createClaudeLocalProvider({ getConnect: get, getNetwork: () => ctx.get('aiWorkbenchNetwork'), readWorkspace: id => readEmployeeWorkspace(ctx, id), readUserTurn: request => readLocalUserTurn(ctx, { sessionId: request.sessionId, route: agent.id, userMessageIds: request.userMessageIds ?? [] }), reportAdmissionFailure: (request, code) => preflightDiagnostics.failure(request, 'claude', code, 'local-admission'), recordUnscoped: (request, observation) => managerForProfile().recordLocalFailure({ sessionId: request.sessionId, provider: 'claude', code: observation.code, observation }), configuration: config, details, open }) : undefined;
672
705
  const workspace = async (request) => {
673
706
  const services = ctx;
674
707
  const registry = services.get('workspaceRegistry');
@@ -677,7 +710,7 @@ export function apply(ctx, live) {
677
710
  };
678
711
  return connectProvider(get, { agent, workspaceSource: {
679
712
  workspace, currentUserTurn: request => localTurns.current(request), confirm: nativeWorkspaceSourceConfirmation(ctx),
680
- record: (request, observation) => managerForProfile().recordLocalFailure({ sessionId: request.sessionId, provider: agent.loginProvider, code: observation.code, observation }),
713
+ record: (request, observation) => preflightDiagnostics.record(request, agent.loginProvider, observation),
681
714
  }, ...(agent.loginProvider === 'codex' ? { employee: {
682
715
  currentUserTurn: request => localTurns.current(request),
683
716
  getNetwork: () => ctx.get('aiWorkbenchNetwork'),
@@ -701,14 +734,13 @@ export function apply(ctx, live) {
701
734
  step = null;
702
735
  }
703
736
  return managerForProfile().recordLoginFailure({ sessionId, code, diagnostic, context: { turn, step } });
704
- }, modelDirectory);
737
+ }, modelDirectory, preflightDiagnostics);
705
738
  const ids = AGENTS.map(agent => agent.id);
706
- const entries = () => AGENTS.map(agent => ({ provider: agent.id, displayName: agent.name[language], settingsNs: settingsNamespace, settingsPath: [], ...(diagnostics.get(agent.id)?.size ? { error: [...diagnostics.get(agent.id).values()].join('\n') } : {}) }));
739
+ const entries = () => AGENTS.map(agent => ({ provider: agent.id, displayName: agent.name[language], settingsNs: actualSettingsNamespace ?? settingsNamespace, settingsPath: [] }));
707
740
  const directory = ctx.llm.registerConfigurableProviders(entries());
708
741
  const registration = ctx.llm.registerAdapter(ids, adapter);
709
- installModelInputRetention(ctx, { isOurProvider: provider => ids.includes(provider), diagnostic: (provider, message) => diagnostic('input', provider, message) });
742
+ installModelInputRetention(ctx, { isOurProvider: provider => ids.includes(provider), diagnosticForAttempt: position => preflightDiagnostics.forPreparation(position.sessionId, position), preflightDiagnostic: (provider, message) => diagnostic('preflight', provider, message), diagnostic: (provider, message) => diagnostic('input', provider, message) });
710
743
  const serviceValue = (name) => ctx.get(name);
711
- publishDiagnostics = () => directory.replace(entries());
712
744
  const refreshDefault = () => {
713
745
  const service = serviceValue('agentDefaultModel');
714
746
  const selected = service?.currentSelection();
@@ -2,6 +2,7 @@ import { type LlmResolvedModelInfo } from '@deepseek-ai/dsh-llm';
2
2
  import type { ModelCatalogTarget } from '@ai-workbench/connect/api';
3
3
  import type { AgentDefinition } from './agents.ts';
4
4
  import { type ConnectApi, type ConnectTarget } from './provider.ts';
5
+ export { runCatalog } from './catalog-ssh.ts';
5
6
  export interface HostModel {
6
7
  id: string;
7
8
  value: string;
@@ -41,9 +42,7 @@ export declare function catalogDiagnostic(issues: readonly CatalogIssue[]): stri
41
42
  /** Defaults are provider declarations, never catalog order or display names. */
42
43
  export declare function declaredDefault(agent: AgentDefinition, models: readonly HostModel[]): HostModel | undefined;
43
44
  export declare function parseHostCatalog(input: string, provider: string, username: string): ParsedCatalog;
44
- /** Only the public L sshArgs function chooses SSH trust and transport arguments. */
45
- export declare const runCatalog: CatalogRunner;
46
- /** No cache: every query binds to L's current object, host-user and generation. */
45
+ /** Share only pending work; settled results are never cached. */
47
46
  export declare class ModelCatalog implements ModelDirectory {
48
47
  private get;
49
48
  private maxBytes;
@@ -58,11 +57,15 @@ export declare class ModelCatalog implements ModelDirectory {
58
57
  private disposed;
59
58
  private initialized;
60
59
  private active;
60
+ private flights;
61
61
  private serial;
62
62
  private latest;
63
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>);
64
64
  refresh(): void;
65
65
  private trusted;
66
+ private assertFlight;
67
+ private failure;
68
+ private start;
66
69
  read(agent: AgentDefinition, signal?: AbortSignal): Promise<CatalogLease>;
67
70
  dispose(): void;
68
71
  }
@@ -1,23 +1,15 @@
1
- var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
- if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
- return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
- return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
- });
6
- }
7
- return path;
8
- };
9
- import { spawn } from 'node:child_process';
10
1
  import { LlmError, ReasoningEffortId } from '@deepseek-ai/dsh-llm';
11
2
  import { sameTarget, validateTarget } from "./provider.js";
12
3
  import { ModelConfigurationError } from "./model-configuration.js";
13
- import { sanitizeObservation } from "./conversation-details.js";
4
+ import { runCatalog, CatalogSshError, catalogSafeText } from "./catalog-ssh.js";
5
+ export { runCatalog } from "./catalog-ssh.js";
14
6
  const object = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
15
7
  const text = (value) => typeof value === 'string' && value.trim().length > 0;
16
8
  class CatalogError extends LlmError {
17
9
  diagnostic;
18
10
  constructor(reason, diagnostic) { super('无法获取模型列表:' + reason, 'MODEL_CATALOG_UNAVAILABLE'); this.diagnostic = diagnostic + '(诊断代码:MODEL_CATALOG_UNAVAILABLE)'; }
19
11
  }
20
- const safe = (value) => sanitizeObservation({ kind: 'session', severity: 'warning', code: 'MODEL_CATALOG_DIAGNOSTIC', summary: value, payload: {} }).summary;
12
+ const safe = catalogSafeText;
21
13
  function fail(reason, diagnostic = reason) { throw new CatalogError(reason, diagnostic); }
22
14
  function freeze(value) { if (value && typeof value === 'object') {
23
15
  for (const item of Object.values(value))
@@ -100,79 +92,7 @@ export function parseHostCatalog(input, provider, username) {
100
92
  }
101
93
  return freeze(structuredClone({ models, issues }));
102
94
  }
103
- /** Only the public L sshArgs function chooses SSH trust and transport arguments. */
104
- export const runCatalog = async (target, command, signal, maxBytes) => {
105
- let argv;
106
- try {
107
- const moduleName = '@ai-workbench/connect';
108
- const l = await import(__rewriteRelativeImportExtension(moduleName));
109
- if (typeof l.sshArgs !== 'function')
110
- fail('远程主机连接组件缺少可信 SSH 能力');
111
- argv = l.sshArgs(target);
112
- }
113
- catch (error) {
114
- if (error instanceof LlmError)
115
- throw error;
116
- fail('远程主机连接组件缺少可信 SSH 能力');
117
- }
118
- signal.throwIfAborted();
119
- return new Promise((resolve, reject) => {
120
- const child = spawn('ssh', ['-n', ...argv, command], { stdio: ['ignore', 'pipe', 'pipe'] });
121
- const parts = [];
122
- let total = 0, stderr = 0;
123
- let failure;
124
- const abort = () => { failure = new ModelConfigurationError('连接已改变或目录查询已取消'); child.kill('SIGTERM'); };
125
- signal.addEventListener('abort', abort, { once: true });
126
- const receive = (buffer, output) => { if (failure)
127
- return; total += buffer.length; if (total > maxBytes) {
128
- failure = new ModelConfigurationError('目录响应超过 acpMaxMessageBytes,未截断返回');
129
- child.kill('SIGTERM');
130
- return;
131
- } if (output)
132
- parts.push(buffer);
133
- else
134
- stderr += buffer.length; };
135
- child.stdout.on('data', (b) => receive(b, true));
136
- child.stderr.on('data', (b) => receive(b, false));
137
- child.on('error', () => { failure = new ModelConfigurationError('可信 SSH 进程启动失败'); });
138
- child.on('close', code => {
139
- signal.removeEventListener('abort', abort);
140
- if (failure) {
141
- reject(failure);
142
- return;
143
- }
144
- let output;
145
- try {
146
- output = new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(parts));
147
- }
148
- catch {
149
- reject(new ModelConfigurationError('主机目录响应不是有效 UTF-8,未替换模型值'));
150
- return;
151
- }
152
- // S publishes its typed failure JSON on stdout even on nonzero exit.
153
- if (code !== 0) {
154
- try {
155
- const wire = JSON.parse(output);
156
- if (wire?.ok === false) {
157
- resolve(output);
158
- return;
159
- }
160
- }
161
- catch { }
162
- reject(new ModelConfigurationError('可信 SSH 或主机目录命令失败(退出码 ' + String(code) + ')'));
163
- return;
164
- }
165
- if (stderr) {
166
- reject(new ModelConfigurationError('目录命令返回了非预期诊断输出'));
167
- return;
168
- }
169
- resolve(output);
170
- });
171
- if (signal.aborted)
172
- abort();
173
- });
174
- };
175
- /** No cache: every query binds to L's current object, host-user and generation. */
95
+ /** Share only pending work; settled results are never cached. */
176
96
  export class ModelCatalog {
177
97
  get;
178
98
  maxBytes;
@@ -187,6 +107,7 @@ export class ModelCatalog {
187
107
  disposed = false;
188
108
  initialized = false;
189
109
  active = new Set();
110
+ flights = new Map();
190
111
  serial = 0;
191
112
  latest = new Map();
192
113
  constructor(get, maxBytes, run = runCatalog, changed = () => { }, diagnostic = () => { }, loaded = () => { }) {
@@ -203,11 +124,10 @@ export class ModelCatalog {
203
124
  const state = api?.getConnectionState?.();
204
125
  const info = api?.getContractInfo?.();
205
126
  const targets = info?.modelCatalogTarget === 1 && api?.getModelCatalogTarget ? ['codex', 'claude'].map(brand => api.getModelCatalogTarget(brand)) : undefined;
206
- const login = api?.getLoginPromptState?.();
207
127
  // L's same-epoch recovery wording is presentation metadata, not an execution change.
208
128
  const executionState = state && { instanceId: state.instanceId, epoch: state.epoch, phase: state.phase, needCode: state.need?.code };
209
129
  const executionTargets = targets?.map(result => result.ok ? result : { ok: false, code: result.error.code });
210
- const signature = JSON.stringify({ state: executionState, capability: info?.modelCatalogTarget, targets: executionTargets, logins: login?.logins, authorizing: login?.phase === 'authorizing' });
130
+ const signature = JSON.stringify({ state: executionState, capability: info?.modelCatalogTarget, targets: executionTargets });
211
131
  const switched = api !== this.api;
212
132
  const changed = this.initialized && (switched || signature !== this.signature);
213
133
  this.initialized = true;
@@ -256,61 +176,118 @@ export class ModelCatalog {
256
176
  }
257
177
  return structuredClone(target);
258
178
  }
179
+ assertFlight(flight) {
180
+ if (flight.controller.signal.aborted || this.disposed || this.get() !== flight.api || flight.revision !== this.revision)
181
+ fail('连接已改变,原模型目录已失效');
182
+ const agent = { loginProvider: flight.target.provider }, current = this.trusted(agent, flight.api);
183
+ if (current.instanceId !== flight.target.instanceId || current.epoch !== flight.target.epoch || current.provider !== flight.target.provider || !sameTarget(current.target, flight.target.target))
184
+ fail('可信目标或连接代次已改变,原模型目录已失效');
185
+ }
186
+ failure(error) {
187
+ // Each consumer needs its own Error identity for its original input/signal proof.
188
+ return new CatalogError(error instanceof CatalogError || error instanceof ModelConfigurationError ? error.message.replace(/^无法获取模型列表:/, '') : '目录查询失败,请到设置 → 模型查看诊断。', error instanceof CatalogError ? error.diagnostic : error instanceof CatalogSshError ? error.diagnostic : error instanceof Error ? `${error instanceof LlmError ? error.code : 'MODEL_CATALOG_QUERY_FAILED'}:${safe(error.message)}` : 'MODEL_CATALOG_QUERY_FAILED:目录查询失败,根因未查明');
189
+ }
190
+ start(agent, api, target, max, key) {
191
+ const deferred = Promise.withResolvers(), controller = new AbortController();
192
+ const flight = { key, api, target, revision: this.revision, serial: ++this.serial, controller, pending: true, users: 0, promise: deferred.promise };
193
+ this.latest.set(agent.loginProvider, flight.serial);
194
+ this.flights.set(key, flight);
195
+ this.active.add(controller);
196
+ const current = () => !this.disposed && flight.revision === this.revision && api === this.get() && this.latest.get(agent.loginProvider) === flight.serial && !controller.signal.aborted;
197
+ const query = async () => {
198
+ try {
199
+ this.assertFlight(flight);
200
+ const output = await this.run(target.target, '/usr/local/aw-host/bin/aw-model-catalog --provider ' + agent.loginProvider, controller.signal, max);
201
+ this.assertFlight(flight);
202
+ const parsed = parseHostCatalog(output, agent.loginProvider, target.target.username), diagnostic = catalogDiagnostic(parsed.issues);
203
+ if (!parsed.models.length)
204
+ fail('当前没有可选模型,请到设置 → 模型查看诊断。', diagnostic ?? '主机返回的完整模型目录为空(诊断代码:MODEL_CATALOG_EMPTY)');
205
+ if (current())
206
+ this.diagnostic(agent.loginProvider, diagnostic);
207
+ if (current())
208
+ await this.loaded(agent, parsed.models, () => this.assertFlight(flight));
209
+ this.assertFlight(flight);
210
+ return parsed.models;
211
+ }
212
+ catch (error) {
213
+ const failure = this.failure(error);
214
+ if (current())
215
+ this.diagnostic(agent.loginProvider, failure.diagnostic);
216
+ throw failure;
217
+ }
218
+ };
219
+ const settled = () => { flight.pending = false; if (this.flights.get(key) === flight)
220
+ this.flights.delete(key); if (!flight.users)
221
+ this.active.delete(controller); };
222
+ void query().then(models => { settled(); deferred.resolve(models); }, error => { settled(); deferred.reject(error); });
223
+ return flight;
224
+ }
259
225
  async read(agent, signal) {
260
- let controller;
261
- let api;
262
- let revision = this.revision;
263
- const serial = ++this.serial;
264
- this.latest.set(agent.loginProvider, serial);
265
- const current = () => !this.disposed && revision === this.revision && api === this.get() && this.latest.get(agent.loginProvider) === serial;
266
- let reported = false;
267
- const report = (message) => { if (current()) {
268
- reported = true;
269
- this.diagnostic(agent.loginProvider, message);
226
+ let flight, released = false;
227
+ const release = () => { if (released || !flight)
228
+ return; released = true; flight.users--; if (!flight.users) {
229
+ if (flight.pending)
230
+ flight.controller.abort();
231
+ else
232
+ this.active.delete(flight.controller);
270
233
  } };
271
234
  try {
272
235
  if (this.disposed)
273
236
  fail('远程模型插件已关闭');
237
+ signal?.throwIfAborted();
274
238
  this.refresh();
275
- api = this.get();
276
- revision = this.revision;
239
+ const api = this.get();
277
240
  if (!api)
278
241
  fail('远程主机连接组件没有装好');
279
- // refresh may clear reads from the previous connection generation.
280
- this.latest.set(agent.loginProvider, serial);
281
- const trustedApi = api, target = freeze(this.trusted(agent, trustedApi));
282
- const max = this.maxBytes();
242
+ const target = freeze(this.trusted(agent, api)), max = this.maxBytes();
283
243
  if (!Number.isSafeInteger(max) || max < 1)
284
244
  fail('acpMaxMessageBytes 设置无效');
285
- controller = new AbortController();
286
- this.active.add(controller);
287
- const combined = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
288
- const assertCurrent = () => { if (combined.aborted || this.disposed || this.get() !== trustedApi || revision !== this.revision)
289
- fail('连接已改变,原模型目录已失效'); const current = this.trusted(agent, trustedApi); if (current.instanceId !== target.instanceId || current.epoch !== target.epoch || current.provider !== target.provider || !sameTarget(current.target, target.target))
290
- fail('可信目标或连接代次已改变,原模型目录已失效'); };
291
- assertCurrent();
292
- const output = await this.run(target.target, '/usr/local/aw-host/bin/aw-model-catalog --provider ' + agent.loginProvider, combined, max);
293
- assertCurrent();
294
- const parsed = parseHostCatalog(output, agent.loginProvider, target.target.username), diagnostic = catalogDiagnostic(parsed.issues);
295
- if (!parsed.models.length)
296
- fail('当前没有可选模型,请到设置 → 模型查看诊断。', diagnostic ?? '主机返回的完整模型目录为空(诊断代码:MODEL_CATALOG_EMPTY)');
297
- report(diagnostic);
298
- if (current())
299
- await this.loaded(agent, parsed.models, assertCurrent);
245
+ const key = JSON.stringify([agent.loginProvider, target.instanceId, target.epoch]);
246
+ flight = this.flights.get(key);
247
+ if (flight && (flight.api !== api || flight.revision !== this.revision || !sameTarget(flight.target.target, target.target) || flight.controller.signal.aborted)) {
248
+ const closing = flight;
249
+ closing.controller.abort();
250
+ flight = undefined;
251
+ // Closing work owns the tuple until SSH/runner actually settles. This new
252
+ // caller waits for cleanup, then begins its own query; it never uses old data.
253
+ const waiting = new AbortController(), revision = this.revision;
254
+ this.active.add(waiting);
255
+ const waitSignal = signal ? AbortSignal.any([signal, waiting.signal]) : waiting.signal;
256
+ try {
257
+ await new Promise((resolve, reject) => { const abort = () => { waitSignal.removeEventListener('abort', abort); reject(new ModelConfigurationError('目录查询已取消')); }; waitSignal.addEventListener('abort', abort, { once: true }); closing.promise.then(() => { }, () => { }).then(() => { waitSignal.removeEventListener('abort', abort); resolve(); }); if (waitSignal.aborted)
258
+ abort(); });
259
+ }
260
+ finally {
261
+ this.active.delete(waiting);
262
+ }
263
+ if (revision !== this.revision || this.get() !== api)
264
+ fail('连接已改变,原模型目录已失效');
265
+ return this.read(agent, signal);
266
+ }
267
+ flight ??= this.start(agent, api, target, max, key);
268
+ flight.users++;
269
+ const own = flight, combined = signal ? AbortSignal.any([signal, own.controller.signal]) : own.controller.signal;
270
+ const assertCurrent = () => { if (combined.aborted)
271
+ fail('目录查询已取消'); this.assertFlight(own); };
272
+ const models = await new Promise((resolve, reject) => {
273
+ const abort = () => { combined.removeEventListener('abort', abort); reject(new ModelConfigurationError('目录查询已取消')); };
274
+ combined.addEventListener('abort', abort, { once: true });
275
+ own.promise.then(value => { combined.removeEventListener('abort', abort); resolve(value); }, error => { combined.removeEventListener('abort', abort); reject(error); });
276
+ if (combined.aborted)
277
+ abort();
278
+ });
300
279
  assertCurrent();
301
- const own = controller;
302
- return { target, models: parsed.models, signal: combined, assertCurrent, release: () => this.active.delete(own) };
280
+ return { target: own.target, models, signal: combined, assertCurrent, release };
303
281
  }
304
282
  catch (error) {
305
- if (controller)
306
- this.active.delete(controller);
307
- const failure = error instanceof CatalogError ? error : new CatalogError(error instanceof ModelConfigurationError ? error.message.replace(/^无法获取模型列表:/, '') : '目录查询失败,请到设置 → 模型查看诊断。', error instanceof Error ? `${error instanceof LlmError ? error.code : 'MODEL_CATALOG_QUERY_FAILED'}:${safe(error.message)}` : 'MODEL_CATALOG_QUERY_FAILED:目录查询失败,根因未查明');
308
- if (!reported)
309
- report(failure.diagnostic);
283
+ release();
284
+ const failure = this.failure(error);
285
+ if (!flight && !this.disposed)
286
+ this.diagnostic(agent.loginProvider, failure.diagnostic);
310
287
  throw failure;
311
288
  }
312
289
  }
313
- dispose() { this.disposed = true; this.unsubscribe?.(); for (const controller of this.active)
290
+ dispose() { this.disposed = true; this.unsubscribe?.(); this.flights.clear(); for (const controller of this.active)
314
291
  controller.abort(); this.active.clear(); }
315
292
  }
316
293
  export function modelInfo(provider, model) {
@@ -2,6 +2,12 @@ import type { Context } from '@deepseek-ai/cordis';
2
2
  export interface ModelInputRetentionOptions {
3
3
  isOurProvider: (provider: string) => boolean;
4
4
  diagnostic: (provider: string, message: string | undefined) => void;
5
+ diagnosticForAttempt?: (position: {
6
+ sessionId: string;
7
+ turn: number;
8
+ step: number;
9
+ }) => ((provider: string, code: string) => Promise<void>);
10
+ preflightDiagnostic?: (provider: string, message: string | undefined) => void;
5
11
  }
6
12
  /** Mark only at the actual X adapter's preparation catch, then rethrow the same
7
13
  * error. One error object can prove only its first preparation, for that signal.
@@ -26,7 +26,7 @@ function committed(events, messages) {
26
26
  * admission failures; it never manufactures transcript messages or model routes.
27
27
  */
28
28
  export function installModelInputRetention(ctx, options) {
29
- const attempts = new Map();
29
+ const attempts = new Map(), pending = new Set();
30
30
  let disposed = false;
31
31
  const live = (a) => !disposed && attempts.get(a.agent) === a && !a.signal.aborted
32
32
  && ctx.get('agents')?.get(a.agent.id) === a.agent && ctx.get('sessions')?.get(a.session.id) === a.session && a.agent.session === a.session;
@@ -94,7 +94,7 @@ export function installModelInputRetention(ctx, options) {
94
94
  attempts.delete(payload.agent);
95
95
  return decision;
96
96
  }
97
- attempts.set(payload.agent, { agent: payload.agent, session: payload.agent.session, turn: payload.turn, step: payload.step, signal: payload.signal, messages, offset: payload.agent.session.snapshotEvents().length, scheduled: false, dispatched: false });
97
+ attempts.set(payload.agent, { agent: payload.agent, session: payload.agent.session, turn: payload.turn, step: payload.step, signal: payload.signal, messages, offset: payload.agent.session.snapshotEvents().length, scheduled: false, dispatched: false, preflight: options.diagnosticForAttempt?.({ sessionId: payload.agent.session.id, turn: payload.turn, step: payload.step }) });
98
98
  return decision;
99
99
  });
100
100
  ctx.on('agent/request', async (payload, next) => {
@@ -118,12 +118,20 @@ export function installModelInputRetention(ctx, options) {
118
118
  // Consume before further checks: canceled/unsafe attempts must not leave a
119
119
  // reusable proof that could authorize a later failure of the same object.
120
120
  proof.consumed = true;
121
- if (a.scheduled || !code || !preparationCodes.has(code) || !live(a) || !safe(a))
121
+ if (a.scheduled || !live(a) || !safe(a))
122
122
  return;
123
123
  a.provider = proof.provider;
124
+ if (a.preflight) {
125
+ const writing = a.preflight(proof.provider, code ?? 'PREFLIGHT_FAILED').catch(error => { if (!disposed)
126
+ options.preflightDiagnostic?.(proof.provider, `连接前诊断保存未能确认;没有自动重发。(原诊断代码:${code ?? 'PREFLIGHT_FAILED'};保存诊断代码:${codeOf(error) ?? 'DIAGNOSTIC_WRITE_FAILED'})`); });
127
+ pending.add(writing);
128
+ void writing.finally(() => pending.delete(writing));
129
+ }
130
+ if (!code || !preparationCodes.has(code))
131
+ return;
124
132
  a.scheduled = true;
125
133
  void restore(a, code);
126
134
  });
127
135
  ctx.on('agent/disposed', ({ agent }) => { attempts.delete(agent); });
128
- ctx.effect(() => () => { disposed = true; attempts.clear(); });
136
+ ctx.effect(() => async () => { disposed = true; attempts.clear(); await Promise.allSettled(pending); });
129
137
  }
@@ -0,0 +1,22 @@
1
+ import type { Context } from '@deepseek-ai/cordis';
2
+ import type { ConnectApi, RuntimeRequest } from './provider.ts';
3
+ import type { DetailObservation } from './conversation-details.ts';
4
+ import type { createRemoteDetails } from './remote-details.ts';
5
+ import type { SourceAdmissionStage } from './workspace-source.ts';
6
+ type Position = {
7
+ turn: string | number | null;
8
+ step: string | number | null;
9
+ };
10
+ export declare function preflightCode(error: unknown): string;
11
+ export declare function createPreflightDiagnostics(options: {
12
+ ctx: Pick<Context, 'get'>;
13
+ getApi: () => ConnectApi | undefined;
14
+ manager: () => Pick<ReturnType<typeof createRemoteDetails>, 'recordPreflightCheck'>;
15
+ }): {
16
+ begin(request: RuntimeRequest, _provider: "claude" | "codex"): void;
17
+ record: (request: RuntimeRequest, provider: "claude" | "codex", observation: DetailObservation) => Promise<void>;
18
+ failure(request: RuntimeRequest, provider: "claude" | "codex", error: unknown, stage: SourceAdmissionStage): Promise<void>;
19
+ forPreparation(sessionId: string, context: Position): (provider: string, code: string) => Promise<void>;
20
+ };
21
+ export type PreflightDiagnostics = ReturnType<typeof createPreflightDiagnostics>;
22
+ export {};