@ai-workbench/remote-agent 0.8.15 → 0.8.17

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,4 +1,5 @@
1
- import { CloseDiagnostics, closeCode, closeErrorCodes, closeFailure } from "./close-diagnostics.js";
1
+ import { connectNeedMessage } from "./connect-need.js";
2
+ import { CloseDiagnostics, withFailureEvidence, withFirstFailureEvidence, failureEvidenceSummary, closeCode, closeErrorCodes, closeFailure } from "./close-diagnostics.js";
2
3
  import { runCatalog } from "./catalog-ssh.js";
3
4
  import { createCatalogDiagnostics, CatalogDiagnosticsRemote } from "./catalog-diagnostics.js";
4
5
  import { SessionId } from '@deepseek-ai/dsh-session';
@@ -254,10 +255,11 @@ export class CodexRemoteAdapter extends LlmAdapter {
254
255
  }
255
256
  catch (error) {
256
257
  if (error instanceof ModelConfigurationError)
257
- throw new LlmError(error.message, error.code, { cause: error });
258
+ throw new LlmError(error.message + (failureEvidenceSummary(error) ? ';' + failureEvidenceSummary(error) : ''), error.code, { cause: error });
258
259
  if (error instanceof NeedError) {
259
260
  const rpc = /RPC 错误码 (-?\d+)/u.exec(error.requires)?.[1];
260
- const message = employeeFailure(error.code, this.agent.loginProvider) + (rpc ? `(技术详情:RPC 错误码 ${rpc})` : '');
261
+ const evidence = failureEvidenceSummary(error);
262
+ const message = (connectNeedMessage(error) ?? employeeFailure(error.code, this.agent.loginProvider)) + (rpc ? `(技术详情:RPC 错误码 ${rpc})` : '') + (evidence ? ';' + evidence : '');
261
263
  const login = originalLoginFailure(error);
262
264
  let diagnostic = login?.technical ? JSON.stringify(login.technical) : '';
263
265
  if (login?.technical && this.reportLoginFailure) {
@@ -271,6 +273,8 @@ export class CodexRemoteAdapter extends LlmAdapter {
271
273
  }
272
274
  throw new LlmError(message, error.code, { cause: new Error(`诊断代码:${error.code};${error.requires}` + (diagnostic ? ';' + diagnostic : '')) });
273
275
  }
276
+ if (error instanceof LlmError && failureEvidenceSummary(error))
277
+ throw new LlmError(error.failure.message + ';' + failureEvidenceSummary(error), error.code, { cause: error });
274
278
  throw error;
275
279
  }
276
280
  }
@@ -303,6 +307,7 @@ export class CodexRemoteAdapter extends LlmAdapter {
303
307
  let catalog;
304
308
  let selected;
305
309
  let modelConfiguration;
310
+ let turnFailure;
306
311
  let employeeNoticeShown = false;
307
312
  const information = [];
308
313
  const recordInformation = () => { if (runtime?.details)
@@ -401,8 +406,9 @@ export class CodexRemoteAdapter extends LlmAdapter {
401
406
  throw new ModelConfigurationError('所选模型不在本次可信主机目录中,请重新选择模型');
402
407
  if (options.reasoningEffort !== undefined && !selected.reasoning.options.some(option => option.value === options.reasoningEffort))
403
408
  throw new ModelConfigurationError('所选推理等级不在本次模型目录中');
409
+ catalog.bindSelection?.(selected.id, options.reasoningEffort);
404
410
  const observeSignal = (source, initiator, reason) => { const observed = () => { const diagnostic = request?.closeDiagnostics ?? runtime?.closeDiagnostics; if (diagnostic)
405
- 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)); };
411
+ diagnostic.record({ layer: 'adapter', initiator, reason: catalog?.signal === source ? catalog.revocation?.()?.reason ?? reason : reason, action: 'abort', changedFields: catalog?.signal === source ? catalog.revocation?.()?.fields : undefined, changes: catalog?.signal === source ? catalog.revocation?.()?.changes ?? [{ field: 'adapter.turn-aborted', before: false, after: source.aborted }] : [{ field: 'adapter.turn-aborted', before: false, after: source.aborted }] }); }; source.addEventListener('abort', observed, { once: true }); abortOffs.push(() => source.removeEventListener('abort', observed)); };
406
412
  observeSignal(catalog.signal, 'signal.model-catalog', 'MODEL_CATALOG_REVOKED');
407
413
  observeSignal(signal, 'signal.turn-composite', 'ABORTED');
408
414
  let modelSignal = AbortSignal.any([signal, catalog.signal]);
@@ -421,6 +427,12 @@ export class CodexRemoteAdapter extends LlmAdapter {
421
427
  request.signal = modelSignal;
422
428
  modelSignal.throwIfAborted();
423
429
  }
430
+ if (runtime.contextSignal) {
431
+ modelSignal = AbortSignal.any([modelSignal, runtime.contextSignal]);
432
+ options.signal = modelSignal;
433
+ request.signal = modelSignal;
434
+ modelSignal.throwIfAborted();
435
+ }
424
436
  catalog.assertCurrent();
425
437
  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}`)
426
438
  throw new ModelConfigurationError('模型目录与当前会话可信员工主机不一致');
@@ -568,7 +580,7 @@ export class CodexRemoteAdapter extends LlmAdapter {
568
580
  const resolved = mapping[0].resolvedModel;
569
581
  if (typeof resolved !== 'string' || !resolved.trim())
570
582
  throw new NeedError('MODEL_RESOLUTION_UNCONFIRMED', '主机目录没有提供所选模型的具体型号,本轮未发送', 'CATALOG_RESOLVED_MODEL_MISSING');
571
- 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 } : {}) };
583
+ modelConfiguration = { selected: { ...mapping[0], ...(options.reasoningEffort === undefined ? {} : { effort: options.reasoningEffort }) }, configured: actual, resolvedModel: resolved, status: 'remote-configured', ...(this.agent.loginProvider === 'claude' ? { resolvedModelSource: 'host-user-catalog', sessionModelReported: false } : {}) };
572
584
  details.record({ kind: 'session', severity: 'info', code: 'MODEL_CONFIGURATION_CONFIRMED', summary: '本轮选择已由远端配置回执确认;' + modelResolutionLabel(modelConfiguration) + ';不代表推理效果已验证', payload: modelConfiguration });
573
585
  }
574
586
  catch (error) {
@@ -652,41 +664,44 @@ export class CodexRemoteAdapter extends LlmAdapter {
652
664
  yield { type: 'finish', reason: { kind: stop === 'max_tokens' || stop === 'max_turn_requests' ? 'max-tokens' : 'stop' }, replayState: { response: pointer } };
653
665
  }
654
666
  catch (error) {
667
+ // ACP cancellation must retain the source revocation that caused it.
668
+ // User cancellation and unrelated signals keep their existing semantics.
669
+ const sourceReason = runtime?.contextSignal?.aborted ? runtime.contextSignal.reason : undefined;
670
+ if (error instanceof NeedError && error.code === 'ABORTED' && sourceReason instanceof NeedError) {
671
+ error = Object.assign(new NeedError(sourceReason.code, sourceReason.message, sourceReason.requires), { ...(sourceReason.connectNeed ? { connectNeed: sourceReason.connectNeed } : {}), ...(sourceReason.officialReason ? { officialReason: sourceReason.officialReason } : {}), cause: new AggregateError([sourceReason, error], 'HOST_SOURCE_REVOKED') });
672
+ }
673
+ turnFailure = error;
655
674
  runtime?.closeDiagnostics?.record({ layer: 'adapter', initiator: 'adapter.turn-failure', reason: closeCode(error, signal.aborted ? 'ABORTED' : 'TURN_FAILED'), action: 'failure' });
656
675
  recordInformation();
657
676
  failureCode = error instanceof NeedError || error instanceof ModelConfigurationError ? error.code : error instanceof LlmError ? error.code : 'TURN_PREPARATION_FAILED';
658
677
  if (live && session && session.promptDispatchCount > promptBaseline)
659
678
  live.failure = { cause: error };
660
- let reportingFailure;
679
+ const reporting = [];
661
680
  if (request?.recordEarlyFailure) {
662
681
  const record = request.recordEarlyFailure;
663
682
  request.recordEarlyFailure = undefined;
664
- const code = error instanceof NeedError || error instanceof ModelConfigurationError ? error.code : error instanceof LlmError ? error.failure.code : signal.aborted ? 'ABORTED' : 'WORKSPACE_SOURCE_CONFIRMATION_FAILED';
665
683
  try {
666
- await record(Object.assign(new NeedError(code, '本轮在执行前失败。', '白名单阶段与错误代码'), { cause: error }), earlyStage);
684
+ await record(Object.assign(new NeedError(failureCode ?? 'TURN_PREPARATION_FAILED', '本轮在执行前失败。', '白名单阶段与错误代码'), { cause: error }), earlyStage);
667
685
  }
668
686
  catch (failure) {
669
- reportingFailure = { cause: failure };
687
+ reporting.push(failure);
670
688
  }
671
689
  }
672
690
  try {
673
691
  await runtime?.details?.flush();
674
692
  }
675
693
  catch (failure) {
676
- if (reportingFailure === undefined)
677
- reportingFailure = { cause: failure };
678
- else {
679
- const first = reportingFailure.cause;
680
- const originalCode = error instanceof NeedError || error instanceof ModelConfigurationError ? error.code : error instanceof LlmError ? error.failure.code : 'WORKSPACE_SOURCE_CONFIRMATION_FAILED';
681
- const flushCode = failure instanceof NeedError || failure instanceof ModelConfigurationError ? failure.code : failure instanceof LlmError ? failure.failure.code : 'CONVERSATION_DETAILS_FAILED';
682
- reportingFailure = { cause: Object.assign(new NeedError('CONVERSATION_DETAILS_FAILED', '本轮诊断保存及详情收尾失败,已停止。', '原错误代码 ' + originalCode + ';' + (first instanceof NeedError ? first.requires : '早期诊断保存失败') + ';详情收尾代码 ' + flushCode), { cause: new AggregateError([error, first, failure], 'ADMISSION_DIAGNOSTIC_AND_FLUSH_FAILED') }) };
683
- }
694
+ reporting.push(failure);
684
695
  }
685
696
  if (activeMapper)
686
697
  yield* activeMapper.flush();
687
- if (reportingFailure !== undefined)
688
- throw reportingFailure.cause;
689
- throw error;
698
+ const firstReason = request?.closeDiagnostics?.first?.reason ?? runtime?.closeDiagnostics?.first?.reason;
699
+ if (reporting.length) {
700
+ turnFailure = withFailureEvidence(error, reporting, 'diagnostic', firstReason, failureCode);
701
+ throw turnFailure;
702
+ }
703
+ turnFailure = firstReason && !(error instanceof NeedError || error instanceof ModelConfigurationError || error instanceof LlmError) ? withFailureEvidence(error, [], 'diagnostic', firstReason, failureCode) : withFirstFailureEvidence(error, firstReason, failureCode);
704
+ throw turnFailure;
690
705
  }
691
706
  finally {
692
707
  for (const off of abortOffs)
@@ -716,6 +731,9 @@ export class CodexRemoteAdapter extends LlmAdapter {
716
731
  if (succeeded && key)
717
732
  this.preparationFailures.delete(key);
718
733
  }
734
+ catch (cleanup) {
735
+ throw withFailureEvidence(turnFailure ?? cleanup, [cleanup], 'cleanup', request?.closeDiagnostics?.first?.reason ?? runtime?.closeDiagnostics?.first?.reason, failureCode);
736
+ }
719
737
  finally {
720
738
  if (key && this.turns.get(key) === turn)
721
739
  this.turns.delete(key);
@@ -20,7 +20,14 @@ export interface HostModel {
20
20
  };
21
21
  source: unknown;
22
22
  }
23
+ export interface CatalogRevocation {
24
+ readonly fields: readonly CatalogChangeField[];
25
+ readonly changes: readonly CloseChange[];
26
+ readonly reason?: 'MODEL_CATALOG_REVOKED' | 'MODEL_CATALOG_SCOPE_CHANGED';
27
+ }
23
28
  export interface CatalogLease {
29
+ revocation?(): CatalogRevocation | undefined;
30
+ bindSelection?(id: string, effort?: string): void;
24
31
  readonly target: ModelCatalogTarget;
25
32
  readonly models: readonly HostModel[];
26
33
  readonly signal: AbortSignal;
@@ -45,7 +52,7 @@ export declare function catalogDiagnostic(issues: readonly CatalogIssue[]): stri
45
52
  /** Defaults are provider declarations, never catalog order or display names. */
46
53
  export declare function declaredDefault(agent: AgentDefinition, models: readonly HostModel[]): HostModel | undefined;
47
54
  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';
55
+ export type CatalogChangeField = 'api-instance' | 'connection-instance' | 'connection-epoch' | 'connection-phase' | 'connection-need-code' | 'catalog-capability' | 'codex-target' | 'claude-target' | 'other-metadata' | 'selected-model';
49
56
  /** Share only pending work; settled results are never cached. */
50
57
  export declare class ModelCatalog implements ModelDirectory {
51
58
  private get;
@@ -62,10 +69,12 @@ export declare class ModelCatalog implements ModelDirectory {
62
69
  private disposed;
63
70
  private initialized;
64
71
  private active;
72
+ private revocations;
65
73
  private presentation?;
66
74
  private presentationNeedCode?;
67
75
  private information;
68
76
  private flights;
77
+ private selections;
69
78
  private serial;
70
79
  private latest;
71
80
  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);
@@ -73,6 +82,7 @@ export declare class ModelCatalog implements ModelDirectory {
73
82
  private trusted;
74
83
  private assertFlight;
75
84
  private failure;
85
+ private checkSelections;
76
86
  private start;
77
87
  read(agent: AgentDefinition, signal?: AbortSignal): Promise<CatalogLease>;
78
88
  dispose(): void;
@@ -93,6 +93,7 @@ export function parseHostCatalog(input, provider, username) {
93
93
  }
94
94
  return freeze(structuredClone({ models, issues }));
95
95
  }
96
+ function semanticJson(value) { const sort = (item) => Array.isArray(item) ? item.map(sort) : object(item) ? Object.fromEntries(Object.keys(item).sort().map(key => [key, sort(item[key])])) : item; return JSON.stringify(sort(value)); }
96
97
  function changedCatalogFields(previous, next, switched) {
97
98
  const before = previous ? JSON.parse(previous) : {}, after = JSON.parse(next), fields = [];
98
99
  if (switched)
@@ -148,10 +149,12 @@ export class ModelCatalog {
148
149
  disposed = false;
149
150
  initialized = false;
150
151
  active = new Set();
152
+ revocations = new WeakMap();
151
153
  presentation;
152
154
  presentationNeedCode;
153
155
  information = new Set();
154
156
  flights = new Map();
157
+ selections = new Set();
155
158
  serial = 0;
156
159
  latest = new Map();
157
160
  constructor(get, maxBytes, run = runCatalog, changed = () => { }, diagnostic = () => { }, loaded = () => { }, revoking = () => { }) {
@@ -172,7 +175,7 @@ export class ModelCatalog {
172
175
  // L's same-epoch recovery wording is presentation metadata, not an execution change.
173
176
  const executionState = state && { instanceId: state.instanceId, epoch: state.epoch, phase: state.phase };
174
177
  const executionTargets = targets?.map(result => result.ok ? result : { ok: false, code: result.error.code });
175
- const signature = JSON.stringify({ state: executionState, capability: info?.modelCatalogTarget, targets: executionTargets });
178
+ const signature = semanticJson({ state: executionState, capability: info?.modelCatalogTarget, targets: executionTargets });
176
179
  const switched = api !== this.api;
177
180
  const changed = this.initialized && (switched || signature !== this.signature);
178
181
  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');
@@ -202,8 +205,10 @@ export class ModelCatalog {
202
205
  this.revoking(changedFields, changes);
203
206
  this.revision++;
204
207
  this.latest.clear();
205
- for (const controller of this.active)
208
+ for (const controller of this.active) {
209
+ this.revocations.set(controller, freeze({ fields: changedFields, changes, reason: 'MODEL_CATALOG_SCOPE_CHANGED' }));
206
210
  controller.abort();
211
+ }
207
212
  this.active.clear();
208
213
  this.diagnostic('claude', undefined);
209
214
  this.diagnostic('codex', undefined);
@@ -242,6 +247,22 @@ export class ModelCatalog {
242
247
  // Each consumer needs its own Error identity for its original input/signal proof.
243
248
  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:目录查询失败,根因未查明');
244
249
  }
250
+ checkSelections(agent, api, target, models, serial) {
251
+ for (const lease of this.selections) {
252
+ const chosen = lease.selected, old = lease.flight.target;
253
+ if (lease.controller.signal.aborted || lease.flight.serial > serial || (lease.observed?.serial ?? 0) > serial || lease.flight.api !== api || old.provider !== agent.loginProvider || old.instanceId !== target.instanceId || old.epoch !== target.epoch || !sameTarget(old.target, target.target))
254
+ continue;
255
+ lease.observed = { serial, models };
256
+ if (!chosen)
257
+ continue;
258
+ const current = models.find(model => model.id === chosen.id && model.value === chosen.value);
259
+ if (current)
260
+ continue;
261
+ const changes = [{ field: 'catalog.' + agent.loginProvider + '.selected-model-valid', before: true, after: false }];
262
+ this.revocations.set(lease.controller, freeze({ fields: ['selected-model'], changes, reason: 'MODEL_CATALOG_REVOKED' }));
263
+ lease.controller.abort();
264
+ }
265
+ }
245
266
  start(agent, api, target, max, key) {
246
267
  const deferred = Promise.withResolvers(), controller = new AbortController();
247
268
  const flight = { key, api, target, revision: this.revision, serial: ++this.serial, controller, pending: true, users: 0, promise: deferred.promise };
@@ -255,6 +276,7 @@ export class ModelCatalog {
255
276
  const output = await this.run(target.target, '/usr/local/aw-host/bin/aw-model-catalog --provider ' + agent.loginProvider, controller.signal, max);
256
277
  this.assertFlight(flight);
257
278
  const parsed = parseHostCatalog(output, agent.loginProvider, target.target.username), diagnostic = catalogDiagnostic(parsed.issues);
279
+ this.checkSelections(agent, api, target, parsed.models, flight.serial);
258
280
  if (!parsed.models.length)
259
281
  fail('当前没有可选模型,请到设置 → 模型查看诊断。', diagnostic ?? '主机返回的完整模型目录为空(诊断代码:MODEL_CATALOG_EMPTY)');
260
282
  if (current())
@@ -278,10 +300,12 @@ export class ModelCatalog {
278
300
  return flight;
279
301
  }
280
302
  async read(agent, signal) {
281
- let flight, released = false;
303
+ let flight, released = false, selection;
304
+ const selectedController = new AbortController();
282
305
  const listeners = new Set();
283
306
  const release = () => { if (released || !flight)
284
- return; released = true; for (const listener of listeners)
307
+ return; released = true; if (selection)
308
+ this.selections.delete(selection); for (const listener of listeners)
285
309
  this.information.delete(listener); listeners.clear(); flight.users--; if (!flight.users) {
286
310
  if (flight.pending)
287
311
  flight.controller.abort();
@@ -323,7 +347,7 @@ export class ModelCatalog {
323
347
  }
324
348
  flight ??= this.start(agent, api, target, max, key);
325
349
  flight.users++;
326
- const own = flight, combined = signal ? AbortSignal.any([signal, own.controller.signal]) : own.controller.signal;
350
+ const own = flight, combined = AbortSignal.any([own.controller.signal, selectedController.signal, ...(signal ? [signal] : [])]);
327
351
  const assertCurrent = () => { if (combined.aborted)
328
352
  fail('目录查询已取消'); this.assertFlight(own); };
329
353
  const models = await new Promise((resolve, reject) => {
@@ -334,7 +358,11 @@ export class ModelCatalog {
334
358
  abort();
335
359
  });
336
360
  assertCurrent();
337
- return { target: own.target, models, signal: combined, assertCurrent, release, onInformation: listener => { if (released)
361
+ selection = { flight: own, controller: selectedController };
362
+ this.selections.add(selection);
363
+ return { target: own.target, get models() { return selection?.observed?.models ?? models; }, signal: combined, assertCurrent, release, bindSelection: (id, effort) => { assertCurrent(); if (released)
364
+ fail('模型目录租约已经释放'); const observed = selection.observed?.models ?? models, model = observed.find(model => model.id === id); if (!model || effort !== undefined && !model.reasoning.options.some(option => option.value === effort))
365
+ fail('所选型号或推理等级已不在当前主机目录中,请重新选择'); selection.selected = { id: model.id, value: model.value }; }, revocation: () => this.revocations.get(selectedController) ?? this.revocations.get(own.controller), onInformation: listener => { if (released)
338
366
  return () => { }; listeners.add(listener); this.information.add(listener); return () => { listeners.delete(listener); this.information.delete(listener); }; } };
339
367
  }
340
368
  catch (error) {
@@ -346,7 +374,7 @@ export class ModelCatalog {
346
374
  }
347
375
  }
348
376
  dispose() { this.disposed = true; this.unsubscribe?.(); this.flights.clear(); for (const controller of this.active)
349
- controller.abort(); this.active.clear(); this.information.clear(); }
377
+ controller.abort(); this.active.clear(); this.selections.clear(); this.information.clear(); }
350
378
  }
351
379
  export function modelInfo(provider, model) {
352
380
  // Official rc.2 represents no reasoning levels by absence, and rejects an empty efforts array.
@@ -1,3 +1,4 @@
1
+ import { withFailureEvidence, failureEvidenceSummary, primaryFailureCode } from "./close-diagnostics.js";
1
2
  import { SessionId } from '@deepseek-ai/dsh-session';
2
3
  import { LlmError } from '@deepseek-ai/dsh-llm';
3
4
  import { isDeepStrictEqual } from 'node:util';
@@ -91,12 +92,12 @@ export function createPreflightDiagnostics(options) {
91
92
  if (request.preflightFailureRecorded)
92
93
  return;
93
94
  request.preflightFailureRecorded = true;
94
- const scope = scopeFor(request), code = preflightCode(error);
95
+ const scope = scopeFor(request), code = primaryFailureCode(error, preflightCode(error));
95
96
  try {
96
- await record(request, provider, { kind: 'need', severity: 'action-required', code, summary: '本轮在连接前准备阶段停止;原始请求保留,未重发。', payload: { phase: 'workspace-source-admission', stage, ...scope.source, outcome: code === 'ABORTED' ? 'cancelled' : 'failed' } });
97
+ await record(request, provider, { kind: 'need', severity: 'action-required', code, summary: '本轮在连接前准备阶段停止;原始请求保留,未重发。' + failureEvidenceSummary(error), payload: { phase: 'workspace-source-admission', stage, ...scope.source, outcome: code === 'ABORTED' ? 'cancelled' : 'failed' } });
97
98
  }
98
99
  catch (diagnostic) {
99
- throw Object.assign(new NeedError('CONVERSATION_DETAILS_FAILED', '本轮连接前诊断未能保存,已停止。', '原错误代码 ' + code + ';诊断错误代码 ' + preflightCode(diagnostic)), { cause: new AggregateError([error, diagnostic], 'PREFLIGHT_AND_DIAGNOSTIC_FAILED') });
100
+ throw withFailureEvidence(error, [diagnostic], 'diagnostic', undefined, preflightCode(error));
100
101
  }
101
102
  },
102
103
  forPreparation(sessionId, context) {
@@ -9,11 +9,15 @@ export function employeeFailure(code, brand = 'codex') {
9
9
  WORKSPACE_SOURCE_CONFIRMATION_UNAVAILABLE: '我需要打开工作区来源确认,目前无法显示确认动作;本轮未执行,你的原话仍保留。',
10
10
  WORKSPACE_SOURCE_SELECTION_UNCONFIRMED: '无法核对工作区来源的确认结果,本轮已停止;你的原话仍保留。',
11
11
  WORKSPACE_SOURCE_CONFIRMATION_FAILED: '工作区来源确认未完成,本轮已停止;你的原话仍保留。',
12
+ PROJECT_BINDING_INVALID: '文件来源已改,请新建会话继续;原会话历史仍可查看。',
12
13
  WORKSPACE_LOCATION_REQUIRED: '我需要你确认这个工作区使用哪一个文件夹,本轮未执行;你的原话仍保留。',
13
14
  WORKSPACE_LOCATION_CONFLICT: '工作区来源选择已更新,本次确认不能覆盖新的选择;本轮未执行,你的原话仍保留。',
14
15
  LOCAL_USER_TURN_CHANGED: '本次输入已变化,本轮已停止,请确认对话后重新发送。',
15
16
  LOCAL_USER_TURN_UNCONFIRMED: '无法核对本次员工输入,本轮已停止;请在当前会话重新发送。',
16
17
  LOCAL_USER_TURN_ACCEPTANCE_INVALID: '本次输入的核验回执不一致,本轮已停止,请联系管理员。',
18
+ MODEL_CATALOG_REVOKED: '所选型号已不在当前主机目录中,本轮已停止;请在模型菜单核对选择。',
19
+ MODEL_CATALOG_SCOPE_CHANGED: '远程主机连接或可信目标已改变,本轮已停止;原输入没有自动重发。',
20
+ CONVERSATION_DETAILS_FAILED: '本轮已停止,技术详情保存未确认;下方分别列出运行与保存原因。',
17
21
  TECHNICAL_DETAILS_UNAVAILABLE: '技术详情暂不可用,请更新远程主机连接组件。',
18
22
  REMOTE_DETAIL_STORAGE_FAILED: '技术详情保存失败,本轮已停止,请联系管理员检查。',
19
23
  REMOTE_DETAIL_DELIVERY_UNKNOWN: '技术详情的持久结果无法核对,本轮已停止。',
@@ -29,6 +29,7 @@ export interface EmployeeExecution {
29
29
  export interface Runtime {
30
30
  identity: string;
31
31
  cwd: string;
32
+ contextSignal?: AbortSignal;
32
33
  execution?: EmployeeExecution;
33
34
  acpTransport?: {
34
35
  writable: WritableStream<Uint8Array>;
@@ -141,7 +142,7 @@ type Result<T> = {
141
142
  code: string;
142
143
  message: string;
143
144
  requires: string;
144
- } & Pick<ConnectNeed, 'officialReason'>;
145
+ } & Partial<Pick<ConnectNeed, 'officialReason' | 'layer' | 'actor' | 'action' | 'lastSeen'>>;
145
146
  };
146
147
  /** 消费正式connect-api 0.7公开子集;X不响应L登录票据、不直接调用登录。 */
147
148
  export interface ConnectApi extends Pick<LConnectApi, 'getConnectionState' | 'getLoginPromptState' | 'requestLoginPrompt' | 'refreshLoginPrompts'>, Partial<NativeWorkspaceApi>, Partial<Pick<LConnectApi, 'getWorkspaceBindingState' | 'getModelCatalogTarget' | 'onConnectionState' | 'onLoginPromptState' | 'appendRemoteConversationDetail' | 'getRemoteConversationDetailReceipt' | 'listRemoteConversationDetails' | 'getWorkspaceLocationState' | 'getLocalWorkspaceContext' | 'onWorkspaceLocationState' | 'acceptLocalWorkspaceUserTurn' | 'selectWorkspaceLocation'>> {
package/dist/provider.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { hostWorkspaceGuard } from "./host-workspace.js";
2
+ import { connectNeedError } from "./connect-need.js";
1
3
  import { loginDiagnostic, loginReason } from "./login-diagnostic.js";
2
4
  import { NeedError } from "./remote.js";
3
5
  import { CONNECTION_REQUIRED, TEST_HOST_NOTICE } from "./presentation.js";
@@ -38,8 +40,11 @@ function verifiedLogin(api, brand, expected) {
38
40
  throw new NeedError('CONNECTION_LOST', 'connection binding changed', 'current employee connection');
39
41
  requireLoginState(login);
40
42
  const reason = loginReason(login, connection, brand);
41
- if (reason)
42
- throw Object.assign(new NeedError(reason === 'LOGIN_REQUIRED' ? 'LOGIN_REQUIRED' : 'AUTH_STATUS_INVALID', reason === 'LOGIN_REQUIRED' ? '需要登录,请到设置 → 远程主机连接 点登录' : '无法确认当前品牌的登录状态', 'L核验的本品牌登录状态'), { technical: loginDiagnostic('verified-login', reason, brand, connection, login), ...(typeof login.providerNeeds[brand]?.officialReason === 'string' ? { officialReason: login.providerNeeds[brand].officialReason } : {}) });
43
+ if (reason) {
44
+ const original = (reason === 'LOGIN_PROVIDER_NEED' || reason === 'LOGIN_PHASE_BLOCKED') && login.instanceId === connection.instanceId ? login.providerNeeds[brand] : undefined;
45
+ const error = original ? connectNeedError(original) : new NeedError(reason === 'LOGIN_REQUIRED' ? 'LOGIN_REQUIRED' : 'AUTH_STATUS_INVALID', reason === 'LOGIN_REQUIRED' ? '需要登录,请到设置 → 远程主机连接 点登录' : '无法确认当前品牌的登录状态', 'L核验的本品牌登录状态');
46
+ throw Object.assign(error, { technical: loginDiagnostic('verified-login', reason, brand, connection, login), ...(typeof login.providerNeeds[brand]?.officialReason === 'string' ? { officialReason: login.providerNeeds[brand].officialReason } : {}) });
47
+ }
43
48
  return { ...connection };
44
49
  }
45
50
  /** Wait only for L's explicit in-progress evidence. No timer, polling, refresh or authorization. */
@@ -102,12 +107,12 @@ async function waitForLogin(api, brand, expected, get, signal) {
102
107
  async function ensureReady(api, brand, signal) {
103
108
  const result = await awaitL(() => api.ensureReady(brand), signal);
104
109
  if (!result.ok)
105
- throw Object.assign(new NeedError(result.error.code, result.error.message, result.error.requires), { technical: loginDiagnostic('ensure-ready', 'L_ENSURE_READY_FAILED', brand, api.getConnectionState(), api.getLoginPromptState(), result.error.officialReason), ...(typeof result.error.officialReason === 'string' ? { officialReason: result.error.officialReason } : {}) });
110
+ throw Object.assign(connectNeedError(result.error), { technical: loginDiagnostic('ensure-ready', 'L_ENSURE_READY_FAILED', brand, api.getConnectionState(), api.getLoginPromptState(), result.error.officialReason), ...(typeof result.error.officialReason === 'string' ? { officialReason: result.error.officialReason } : {}) });
106
111
  return result.value;
107
112
  }
108
113
  export function unwrap(result) {
109
114
  if (!result.ok)
110
- throw Object.assign(new NeedError(result.error.code, result.error.message, result.error.requires), typeof result.error.officialReason === 'string' ? { officialReason: result.error.officialReason } : {});
115
+ throw Object.assign(connectNeedError(result.error), typeof result.error.officialReason === 'string' ? { officialReason: result.error.officialReason } : {});
111
116
  return result.value;
112
117
  }
113
118
  export function validateTarget(t) {
@@ -155,6 +160,30 @@ export function sameTarget(a, b) {
155
160
  }
156
161
  export function connectProvider(get, options = {}) {
157
162
  const agent = options.agent ?? CODEX_AGENT;
163
+ const hostScopes = new WeakMap();
164
+ const hostMethods = new WeakMap();
165
+ const retainHostScope = (runtime, scope) => {
166
+ hostScopes.get(runtime)?.dispose();
167
+ hostScopes.set(runtime, scope);
168
+ runtime.contextSignal = scope.revocation;
169
+ if (hostMethods.has(runtime))
170
+ return;
171
+ const original = { dispose: runtime.dispose, detach: runtime.detach };
172
+ hostMethods.set(runtime, original);
173
+ const release = () => { hostScopes.get(runtime)?.dispose(); hostScopes.delete(runtime); };
174
+ runtime.dispose = async (cause) => { try {
175
+ await original.dispose?.(cause);
176
+ }
177
+ finally {
178
+ release();
179
+ } };
180
+ runtime.detach = async (cause) => { try {
181
+ await original.detach?.(cause);
182
+ }
183
+ finally {
184
+ release();
185
+ } };
186
+ };
158
187
  // Keep the legacy source SDK wrapper's strip-only import graph free of broker runtime syntax.
159
188
  // Cache the initialization promise so concurrent sessions share this provider's binding lifetimes.
160
189
  let employee;
@@ -203,6 +232,9 @@ export function connectProvider(get, options = {}) {
203
232
  request.localAdmissionCheck?.();
204
233
  admissions.set(request, { api, target, connection });
205
234
  sourceStage = 'local-admission';
235
+ const locations = api.getContractInfo().workspaceLocations === 1 ? api.getWorkspaceLocationState?.() : undefined;
236
+ if (locations && (locations.phase === 'failed' || locations.phase === 'disposed') && locations.instanceId === connection.instanceId && locations.epoch === connection.epoch && locations.need)
237
+ throw connectNeedError(locations.need);
206
238
  await options.local?.admit?.(request, api, connection, target);
207
239
  confirmedSource?.assertCurrent();
208
240
  }
@@ -228,88 +260,109 @@ export function connectProvider(get, options = {}) {
228
260
  request.localAdmissionCheck?.();
229
261
  if (!request.sessionId)
230
262
  throw new NeedError('PROJECT_BINDING_REQUIRED', '我需要当前会话和已选择的工作区,请使用原生工作区入口', 'DSH会话与原生工作区');
231
- if (options.employee) {
232
- employee ??= import("./employee-provider.js").then(module => module.createEmployeeProvider(get, options, agent));
233
- const local = await (await employee)({ api, target, connection, request, stable: signal => request.timing.run('connection-confirm', () => waitForLogin(api, agent.loginProvider, connection, get, signal)) });
234
- if (local) {
235
- local.loginRequired = () => offerLogin(api, request, get, agent);
236
- return local;
263
+ const host = await hostWorkspaceGuard(get, api, connection, target, request, options.workspaceSource?.workspace, agent.loginProvider);
264
+ try {
265
+ if (!host && options.employee) {
266
+ employee ??= import("./employee-provider.js").then(module => module.createEmployeeProvider(get, options, agent));
267
+ const local = await (await employee)({ api, target, connection, request, stable: signal => request.timing.run('connection-confirm', () => waitForLogin(api, agent.loginProvider, connection, get, signal)) });
268
+ if (local) {
269
+ local.loginRequired = () => offerLogin(api, request, get, agent);
270
+ return local;
271
+ }
237
272
  }
238
- }
239
- if (agent.loginProvider === 'claude' && options.local) {
240
- const runtime = await options.local(request, api, connection, target);
241
- if (runtime) {
273
+ if (!host && agent.loginProvider === 'claude' && options.local) {
274
+ const runtime = await options.local(request, api, connection, target);
275
+ if (runtime) {
276
+ try {
277
+ await stable();
278
+ }
279
+ catch (error) {
280
+ await runtime.dispose?.();
281
+ throw error;
282
+ }
283
+ return runtime;
284
+ }
285
+ }
286
+ if (!host) {
287
+ await options.notice?.reconcile(request, api);
288
+ await stable();
289
+ }
290
+ const resolved = await request.timing.run('workspace-resolve', async () => host ? host.resolve() : unwrap(await awaitL(() => api.resolveSessionProject(request.sessionId, { provider: agent.loginProvider, purpose: 'user-turn' }), request.signal)));
291
+ await stable();
292
+ host?.assertCurrent();
293
+ if (!resolved || resolved.instanceId !== connection.instanceId || resolved.epoch !== connection.epoch)
294
+ throw new NeedError('CONNECTION_LOST', '工作区解析回复已失效', '当前连接代次');
295
+ validateTarget(resolved.target);
296
+ const project = resolved.project;
297
+ const source = resolved.source;
298
+ if (resolved.sessionId !== request.sessionId || !resolved.workspaceId || !resolved.bindingId || !Number.isSafeInteger(resolved.bindingRevision) || resolved.bindingRevision < 1 || !sameTarget(target, resolved.target) || !project || (!host && (source !== undefined && source !== 'legacy-host' || typeof project.name !== 'string' || !project.name || project.name === '.' || project.name === '..' || /[\\/\x00-\x1f\x7f]/.test(project.name) || project.path !== resolved.target.projectRoot + '/' + project.name)))
299
+ throw new NeedError('PROJECT_BINDING_INVALID', '工作区绑定与当前会话或可信主机不一致', 'L核验的原生工作区绑定');
300
+ if (request.cwd && request.cwd !== project.path || request.bindingId && request.bindingId !== resolved.bindingId || request.workspaceId && request.workspaceId !== resolved.workspaceId)
301
+ throw new NeedError(host ? 'PROJECT_BINDING_INVALID' : 'SESSION_CONTEXT_MISMATCH', host ? '文件来源已改,请新建会话继续;原会话历史仍可查看。' : '原会话的工作区绑定已改变,请新建会话', '原工作区绑定');
302
+ const details = await request.timing.run('details-open', async () => options.details?.(request, api, connection, target));
303
+ try {
304
+ if (details)
305
+ request.timing.bind(detail => details.record(detail));
306
+ await stable();
307
+ host?.assertCurrent();
308
+ let workspaceNotice = undefined;
309
+ const notice = !host && 'notice' in resolved ? resolved.notice : undefined;
310
+ if (notice) {
311
+ if (!options.notice)
312
+ throw new NeedError('DEPENDENCY_REQUIRED', '我需要官方会话持久记录来确认工作区说明', '官方会话与持久化服务');
313
+ workspaceNotice = await request.timing.run('workspace-notice', () => options.notice.deliver(notice, resolved, request, api));
314
+ await stable();
315
+ }
316
+ if (!options.open)
317
+ throw new NeedError('RUNTIME_REQUIRED', '我需要主机运行适配器', '主机broker适配');
318
+ const observedBinding = host ? undefined : api.getWorkspaceBindingState?.();
319
+ const validateContext = async (ignoreCancellation = false) => {
320
+ const check = () => waitForLogin(api, agent.loginProvider, connection, get, ignoreCancellation ? undefined : request.signal);
321
+ await check();
322
+ if (host && !ignoreCancellation)
323
+ await host.resolve();
324
+ if (observedBinding) {
325
+ const current = api.getWorkspaceBindingState();
326
+ if (current.instanceId !== observedBinding.instanceId || !current.items.some(item => item.bindingId === resolved.bindingId && item.workspaceId === resolved.workspaceId && item.status === 'ready'))
327
+ throw new NeedError('SESSION_CONTEXT_MISMATCH', '当前工作区绑定已改变,原会话操作未执行', '原生会话原绑定');
328
+ }
329
+ await check();
330
+ if (!ignoreCancellation)
331
+ host?.assertCurrent();
332
+ };
333
+ host?.assertCurrent();
334
+ const runtime = await options.open(resolved.target, project, { ...request, ...(host ? { signal: host.signal } : {}), agent, bindingId: resolved.bindingId, workspaceId: resolved.workspaceId, bindingRevision: resolved.bindingRevision, detailChannel: details, validateContext });
242
335
  try {
243
336
  await stable();
337
+ host?.observeRuntime(runtime);
244
338
  }
245
339
  catch (error) {
246
340
  await runtime.dispose?.();
247
341
  throw error;
248
342
  }
343
+ if (host)
344
+ retainHostScope(runtime, host);
345
+ // The transport can outlive a turn; presentation belongs only to this invocation.
346
+ runtime.workspaceNotice = workspaceNotice || undefined;
347
+ runtime.details = details ?? runtime.details;
348
+ runtime.binding = { bindingId: resolved.bindingId, workspaceId: resolved.workspaceId, revision: resolved.bindingRevision };
349
+ runtime.connection = { ...connection };
350
+ runtime.loginRequired = () => offerLogin(api, request, get, agent);
249
351
  return runtime;
250
352
  }
251
- }
252
- await options.notice?.reconcile(request, api);
253
- await stable();
254
- const resolved = await request.timing.run('workspace-resolve', async () => unwrap(await awaitL(() => api.resolveSessionProject(request.sessionId, { provider: agent.loginProvider, purpose: 'user-turn' }), request.signal)));
255
- await stable();
256
- if (!resolved || resolved.instanceId !== connection.instanceId || resolved.epoch !== connection.epoch)
257
- throw new NeedError('CONNECTION_LOST', '工作区解析回复已失效', '当前连接代次');
258
- validateTarget(resolved.target);
259
- const project = resolved.project;
260
- if (resolved.sessionId !== request.sessionId || !resolved.workspaceId || !resolved.bindingId || !Number.isSafeInteger(resolved.bindingRevision) || resolved.bindingRevision < 1 || !sameTarget(target, resolved.target) || !project || typeof project.name !== 'string' || !project.name || project.name === '.' || project.name === '..' || /[\\/\x00-\x1f\x7f]/.test(project.name) || project.path !== resolved.target.projectRoot + '/' + project.name)
261
- throw new NeedError('PROJECT_BINDING_INVALID', '工作区绑定与当前会话或可信主机不一致', 'L核验的原生工作区绑定');
262
- if (request.cwd && request.cwd !== project.path || request.bindingId && request.bindingId !== resolved.bindingId || request.workspaceId && request.workspaceId !== resolved.workspaceId)
263
- throw new NeedError('SESSION_CONTEXT_MISMATCH', '原会话的工作区绑定已改变,请新建会话', '原工作区绑定');
264
- const details = await request.timing.run('details-open', async () => options.details?.(request, api, connection, target));
265
- try {
266
- if (details)
267
- request.timing.bind(detail => details.record(detail));
268
- await stable();
269
- let workspaceNotice = undefined;
270
- if (resolved.notice) {
271
- if (!options.notice)
272
- throw new NeedError('DEPENDENCY_REQUIRED', '我需要官方会话持久记录来确认工作区说明', '官方会话与持久化服务');
273
- workspaceNotice = await request.timing.run('workspace-notice', () => options.notice.deliver(resolved.notice, resolved, request, api));
274
- await stable();
275
- }
276
- if (!options.open)
277
- throw new NeedError('RUNTIME_REQUIRED', '我需要主机运行适配器', '主机broker适配');
278
- const observedBinding = api.getWorkspaceBindingState?.();
279
- const validateContext = async (ignoreCancellation = false) => {
280
- const check = () => waitForLogin(api, agent.loginProvider, connection, get, ignoreCancellation ? undefined : request.signal);
281
- await check();
282
- if (observedBinding) {
283
- const current = api.getWorkspaceBindingState();
284
- if (current.instanceId !== observedBinding.instanceId || !current.items.some(item => item.bindingId === resolved.bindingId && item.workspaceId === resolved.workspaceId && item.status === 'ready'))
285
- throw new NeedError('SESSION_CONTEXT_MISMATCH', '当前工作区绑定已改变,原会话操作未执行', '原生会话原绑定');
286
- }
287
- await check();
288
- };
289
- const runtime = await options.open(resolved.target, project, { ...request, agent, bindingId: resolved.bindingId, workspaceId: resolved.workspaceId, bindingRevision: resolved.bindingRevision, detailChannel: details, validateContext });
290
- try {
291
- await stable();
292
- }
293
353
  catch (error) {
294
- await runtime.dispose?.();
354
+ try {
355
+ await details?.flush();
356
+ }
357
+ catch (diagnostic) {
358
+ const code = (value) => value instanceof NeedError && /^[A-Z][A-Z0-9_]*$/.test(value.code) ? value.code : 'UNKNOWN_FAILURE';
359
+ throw new NeedError('CONVERSATION_DETAILS_FAILED', '技术详情未能保存;本轮已停止', `原错误代码 ${code(error)};诊断错误代码 ${code(diagnostic)}`);
360
+ }
295
361
  throw error;
296
362
  }
297
- // The transport can outlive a turn; presentation belongs only to this invocation.
298
- runtime.workspaceNotice = workspaceNotice || undefined;
299
- runtime.details = details ?? runtime.details;
300
- runtime.binding = { bindingId: resolved.bindingId, workspaceId: resolved.workspaceId, revision: resolved.bindingRevision };
301
- runtime.connection = { ...connection };
302
- runtime.loginRequired = () => offerLogin(api, request, get, agent);
303
- return runtime;
304
363
  }
305
364
  catch (error) {
306
- try {
307
- await details?.flush();
308
- }
309
- catch (diagnostic) {
310
- const code = (value) => value instanceof NeedError && /^[A-Z][A-Z0-9_]*$/.test(value.code) ? value.code : 'UNKNOWN_FAILURE';
311
- throw new NeedError('CONVERSATION_DETAILS_FAILED', '技术详情未能保存;本轮已停止', `原错误代码 ${code(error)};诊断错误代码 ${code(diagnostic)}`);
312
- }
365
+ host?.dispose();
313
366
  throw error;
314
367
  }
315
368
  };