@ai-workbench/remote-agent 0.8.15 → 0.8.16

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/README.md CHANGED
@@ -1,10 +1,10 @@
1
- **`@ai-workbench/remote-agent@0.8.15` 发布形态包。模型菜单逐项解析与同次展示目录保持一致;失效选择明确提示重新选择,发送准备和每轮发送仍独立查可信目录。模型型号、推理等级和来源只进技术详情;本机执行说明每个会话首次一句,完整限制可在技术详情查阅。编排已回证0.8.14 Claude在leo本地文件夹首轮成功;0.8.15仍待真机复验,Codex历史EMPLOYEE_EXEC_CLOSED根因仍未查明。X2出固定包及SHA,不publish。通过官方插件 → 添加插件安装,由编排发布;X2不publish。**
1
+ **`@ai-workbench/remote-agent@0.8.16` 发布形态包。失败时在官方错误消息保留首次观察关闭、运行原因、诊断保存及清理结果的安全代码;未确认的详情保留 pending,不重发或伪造保存成功。目标对象键顺序与目录展示元数据不撤销当前选择;最新可信目录移除所选型号时明确停止。真实身份/代际/文件夹失配仍停止。0.8.15 leo 原始撤销字段与61秒慢查询根因未查明,本版真实 leo/员工链路未复验。通过官方插件 → 添加插件安装;固定包由编排发布,X 不 publish。**
2
2
 
3
3
  # 远程ACP插件
4
4
 
5
5
  一个插件提供 **Claude(远程主机)** 和 **Codex(远程主机)**,通过员工主机上的官方CLI运行。Codex保留单次模式;Claude固定会话模式。只用官方模型列表、插件管理和配置入口,不提供自制面板、问答或登录按钮。登录和原生工作区绑定唯一归连接插件L。
6
6
 
7
- 当前版本号为0.8.15;包名采用`@ai-workbench/remote-agent`以涵盖两个品牌;本轮按编排放行准备发布形态包,供 leo 真机自验;X 不执行实际发布,由编排统一发布。开发fixture逐字节保留L0.7.19固定包,公开类型与SSH导出从该包消费。运行时仍要求L的`getContractInfo()`同时返回API0.7.0、implemented、native-local-v1;旧L包、draft.3或只有版本号的不完整实现会明确报缺项。依赖范围为`~0.7.19`,L由官方插件管理单独安装,不打进X。组件验证不代表真实开通、模型或leo E通过;HanaMesh另验。
7
+ 当前版本号为0.8.16;包名采用`@ai-workbench/remote-agent`以涵盖两个品牌;本轮按编排放行准备发布形态包,供 leo 真机自验;X 不执行实际发布,由编排统一发布。开发fixture逐字节保留L0.7.19固定包,公开类型与SSH导出从该包消费。运行时仍要求L的`getContractInfo()`同时返回API0.7.0、implemented、native-local-v1;旧L包、draft.3或只有版本号的不完整实现会明确报缺项。依赖范围为`~0.7.19`,L由官方插件管理单独安装,不打进X。组件验证不代表真实开通、模型或leo E通过;HanaMesh另验。
8
8
 
9
9
  已包含L0.7.18消费增量:来源确认、输入接纳、登录、本机接纳、输入准备、模型目录及运行时解析的早期失败,用正式`preflightDiagnostics:1` / `preflight-check`进入L「连接前技术详情(含登录与工作区确认)」。阶段、真实来源/状态和错误代码可展开;未知来源为null,旧login-check的完整安全原因保留。记录不选择来源、不接纳输入、不自动重发;无原生会话的菜单查询仍在模型设置报告。当前工程和设置组件验证不等于leo GUI验收。
10
10
 
@@ -12,7 +12,7 @@
12
12
 
13
13
  ## 员工路径
14
14
 
15
- 从官方插件管理安装,照常使用默认工作区/会话/模型入口。L0.7.17沿唯一“远程主机连接”设置节记录文件来源;主机来源沿用L既有解析;本地来源接入N0.4.4/S0.4.11员工端点。旧映射可在本轮官方一次确认中选择改用本机文件夹;拒绝、取消或输入/来源变化时不切换、不执行。也可在L设置逐工作区确认沿用原主机关联,新主机位置仍按L现有合同停止为WORKSPACE_LOCATION_PROVIDER_REQUIRED。本地六类工具经Claude公开toolAliases路由到员工电脑:cwd是员工选定目录,Git使用本机仓库,不回退主机同名目录。本地真实员工输入先取得官方当前轮次CAS预期,再调用L0.7.17 acceptLocalWorkspaceUserTurn;核回包后才打开D19和本轮主机目录/执行。每轮localAcceptanceId冻结进原详情sink,迟到结果不借新轮ID;LOCAL_USER_TURN_CHANGED立即停止本轮,不重接纳或退legacy。已用固定真包完成本地D19组件联测;编排已回证0.8.14 Claude/Opus5/Default在leo本地目录首轮写文件成功;该回证不覆盖本0.8.15或Codex,产品验收以各版本leo实际结果为准。本地文件不自动上传,X不猜目录、不选首项。首次说明使用官方普通回复;先经官方会话提交、持久化并读回核对,才向L确认。持久记录不等于员工已看见,GUI另验;日志丢失后的未知claim由L保留,不宣称端到端恰好一次。
15
+ 从官方插件管理安装,照常使用默认工作区/会话/模型入口。L0.7.17沿唯一“远程主机连接”设置节记录文件来源;主机来源沿用L既有解析;本地来源接入N0.4.4/S0.4.11员工端点。旧映射可在本轮官方一次确认中选择改用本机文件夹;拒绝、取消或输入/来源变化时不切换、不执行。也可在L设置逐工作区确认沿用原主机关联,新主机位置仍按L现有合同停止为WORKSPACE_LOCATION_PROVIDER_REQUIRED。本地六类工具经Claude公开toolAliases路由到员工电脑:cwd是员工选定目录,Git使用本机仓库,不回退主机同名目录。本地真实员工输入先取得官方当前轮次CAS预期,再调用L0.7.17 acceptLocalWorkspaceUserTurn;核回包后才打开D19和本轮主机目录/执行。每轮localAcceptanceId冻结进原详情sink,迟到结果不借新轮ID;LOCAL_USER_TURN_CHANGED立即停止本轮,不重接纳或退legacy。已用固定真包完成本地D19组件联测;编排已回证0.8.14 Claude/Opus5/Default在leo本地目录首轮写文件成功;该回证不覆盖本0.8.16或Codex,产品验收以各版本leo实际结果为准。本地文件不自动上传,X不猜目录、不选首项。首次说明使用官方普通回复;先经官方会话提交、持久化并读回核对,才向L确认。持久记录不等于员工已看见,GUI另验;日志丢失后的未知claim由L保留,不宣称端到端恰好一次。
16
16
 
17
17
  未登录时两个模型均回复:**需要登录,请到设置 → 远程主机连接 点登录**。无L或缺正式能力时直接说明缺项,不让员工填写主机、用户、路径或密钥。
18
18
 
@@ -76,7 +76,7 @@ D21(0.8.5):两个品牌的官方模型菜单从 L 的 `modelCatalogTarget:
76
76
 
77
77
  每轮先核对目录代次和所选模型,再经 ACP 逐项配置模型及推理等级,收到完整配置确认后才发送输入。Claude 别名每轮须有精确的模型配置成功回执,并由同一员工主机、同一连接代际的目录唯一映射到具体型号。具体型号注明来自主机目录,会话未单独回报;旧恢复记录不作本轮证据。目录缺项、映射不唯一、回执不符或身份/代际变化时不发送输入。所选值、远端确认值及型号来源仅在恢复标识与技术详情保留,插件不向真实回复追加技术参数,确认配置不等于验证推理效果。连接/身份/目录变化清除旧确认;远端清理待定或结果未知时阻止派发,不自动清理重试或另开替代会话。Codex 单次模式当前不能确认此选择,会在发送前明确拒绝。新增设置 0;本轮工程证据、真实只读链路与产品未验项见 `docs/evidence/2026-10-22-X2-D21/`。
78
78
 
79
- 0.8.5统一包原依赖L `~0.7.13`;当前0.8.15对齐L `~0.7.19`。D8模型目录双事件长期监听保留;同epoch的recovery提示/失败消息变化不触发执行状态失效,真实实例/epoch/phase/可信目标/登录变化仍失效。完整D9两品牌正文/状态/就绪门呈现按编排00:50允许的范围记下一版,本版不宣称已消费完成;L的connectionMessages.administratorAfterMs仍只由L管理,X新增设置0。
79
+ 0.8.5统一包原依赖L `~0.7.13`;当前0.8.16对齐L `~0.7.19`。D8模型目录双事件长期监听保留;同epoch的recovery提示/失败消息变化不触发执行状态失效,真实实例/epoch/phase/可信目标/登录变化仍失效。完整D9两品牌正文/状态/就绪门呈现按编排00:50允许的范围记下一版,本版不宣称已消费完成;L的connectionMessages.administratorAfterMs仍只由L管理,X新增设置0。
80
80
 
81
81
  D18(0.8.6):Codex 需要主机自有 adapter `2.2.2-aw.1` 的明确新建会话上下文政策,官方 CLI 仍为未改的0.160.1。系统/开发者要求仅在新建时生效;恢复时与初建摘要相同则正常恢复且省略新建专用字段,不声称恢复接口会更新指令。要求不同或清空时,在同一真实回复说明“会话要求已变化,已新建 Codex 会话继续处理,原会话仍保留。”,并新建远端承接公开对话,保留旧标识和查询记录;旧/非法初建摘要明确拒绝,不能猜测。
82
82
 
@@ -124,3 +124,5 @@ D20(0.8.8开发记录):本地来源的Codex原生文件与命令通过固
124
124
  每次目录SSH保独立事件ID、品牌、UTC起止、耗时、PID、L实例/epoch/phase、严格L argv/目标/known_hosts/ProxyCommand、退出码/信号、完整脱敏stderr与原字节数/SHA256、stdout字节数/SHA及主机typed错误;不记录凭据或整份模型目录。日志在官方profile的 `ai-workbench-remote-agent/catalog-diagnostics/<eventId>-started.json` 与 `-closed.json`,700目录/600文件、fsync;无自动过期,最新两品牌在界面可展开,旧调用保留不可变历史。容量沿现有`acpMaxMessageBytes`,超限停止并标记stderrComplete=false,UTF-8替换另显式标记,未返回部分模型;保存失败明确显示仅内存,不冒称耐久。旧0811丢失的stderr无法还原。同品牌、instanceId、epoch只合并仍在进行的查询;完成后不缓存,身份/代际/可信目标改变即作废。调用方各持独立lease/取消与错误身份,一个取消不会取消其他等待者;全员取消才撤销该次查询,取消中的旧进程结束前同作用域新调用等待清理。登录检查的显示变化不会取消无需登录的host-user目录。此次没有增加超时、重试、SSH连接复用或产品设置;不等于255、Claude主机错误及N层根因已修复,也未进行leo GUI验收。
125
125
 
126
126
  0.8.14 不把空菜单、非穷举 picker、可选 UI category/label 或同值配置通知当作额外发送门槛;每轮仍要求可靠模型入口与 fresh exact setter ACK。本轮完成后的配置变化只记录 warning,保留真实回复与 finish,并明确未获单独的实际执行型号回报。同一身份、代际与可信目标的 needCode/诊断变化只记 info;真实身份、代际、目标或能力失效仍撤回。每次关闭/取消保留 caller、字段前后值和首次观测原因(不等于根因)。新增配置、阈值、超时、重试为 0。
127
+
128
+ 0.8.16 不新增 Config、默认值、阈值、超时或重试。首因可见、旧代不重投、实际选择与绑定一致属于不可关闭的正确性不变量。目录完成结果不用于后续请求缓存;活跃租约仅保留更新观测的选择有效性证据,释放即撤登记。断连/错代仍保留原详情信封及未确认状态,仅按旧合同读 receipt 核验。`unconfirmed-<eventId>.json` 私有记录追加是否尝试及失败阶段/固定码,不能当作 L 的 ACK;员工可见原因沿官方错误消息,无新增面板。
package/dist/acp.js CHANGED
@@ -390,7 +390,7 @@ export class AcpSession {
390
390
  signal?.addEventListener('abort', abort, { once: true });
391
391
  try {
392
392
  const hello = await this.stage('acp-initialize', () => this.connection.agent.request(acp.methods.agent.initialize, {
393
- protocolVersion: acp.PROTOCOL_VERSION, clientInfo: { name: '@ai-workbench/remote-agent', version: '0.8.15' },
393
+ protocolVersion: acp.PROTOCOL_VERSION, clientInfo: { name: '@ai-workbench/remote-agent', version: '0.8.16' },
394
394
  clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false, session: { notices: {} }, ...(this.employeeMeta ? { _meta: { awEmployeeExecution: { version: 1 } } } : {}) },
395
395
  }, { cancellationSignal: signal }));
396
396
  this.hooks.broker?.acknowledge(acp.methods.agent.initialize);
@@ -1,5 +1,4 @@
1
1
  import type { DetailObservation } from './conversation-details.ts';
2
- import { NeedError } from './remote.ts';
3
2
  export interface CloseChange {
4
3
  field: string;
5
4
  before: string | number | boolean | null;
@@ -18,11 +17,7 @@ export interface CloseCause {
18
17
  changes?: readonly CloseChange[];
19
18
  }
20
19
  export type CloseObserver = (cause: CloseCause) => void;
21
- /** Only typed codes, never exception messages, causes, stderr or RPC data. */
22
- export declare function closeCode(error: unknown, fallback?: string): string;
23
- /** Retain trusted typed codes through wrappers; never traverse error text or invoke getters. */
24
- export declare function closeErrorCodes(error: unknown, fallback?: string): string[];
25
- export declare function closeFailure(code: string, message: string, requires: string, codes: readonly string[]): NeedError;
20
+ export { closeCode, closeErrorCodes, closeFailure, withFailureEvidence, withFirstFailureEvidence, failureEvidenceSummary, primaryFailureCode } from './failure-evidence.ts';
26
21
  /** Assign order before abort/kill; persist through the existing asynchronous D19 outbox. */
27
22
  export declare class CloseDiagnostics {
28
23
  private sink;
@@ -1,37 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { NeedError } from "./remote.js";
3
- /** Only typed codes, never exception messages, causes, stderr or RPC data. */
4
- export function closeCode(error, fallback = 'CLOSE_REASON_UNKNOWN') {
5
- return error instanceof NeedError && /^[A-Z][A-Z0-9_]*$/.test(error.code) ? error.code : fallback;
6
- }
7
- /** Retain trusted typed codes through wrappers; never traverse error text or invoke getters. */
8
- export function closeErrorCodes(error, fallback = 'CLOSE_REASON_UNKNOWN') {
9
- const queue = [error], seen = new Set(), codes = new Set();
10
- while (queue.length) {
11
- const next = queue.pop();
12
- if (!(next instanceof NeedError || next instanceof AggregateError) || seen.has(next))
13
- continue;
14
- seen.add(next);
15
- if (next instanceof NeedError) {
16
- const code = Object.getOwnPropertyDescriptor(next, 'code');
17
- if (code && 'value' in code && typeof code.value === 'string' && /^[A-Z][A-Z0-9_]*$/.test(code.value))
18
- codes.add(code.value);
19
- }
20
- const cause = Object.getOwnPropertyDescriptor(next, 'cause');
21
- if (cause && 'value' in cause)
22
- queue.push(cause.value);
23
- if (next instanceof AggregateError) {
24
- const errors = Object.getOwnPropertyDescriptor(next, 'errors');
25
- if (errors && 'value' in errors && Array.isArray(errors.value))
26
- for (const child of errors.value)
27
- queue.push(child);
28
- }
29
- }
30
- return codes.size ? [...codes] : [fallback];
31
- }
32
- export function closeFailure(code, message, requires, codes) {
33
- return Object.assign(new NeedError(code, message, requires), { cause: new AggregateError([...new Set(codes)].map(value => new NeedError(value, '关闭收尾代码 ' + value, '按安全代码核对')), 'CLOSE_AND_DIAGNOSTIC_FAILURES') });
34
- }
3
+ import { closeCode, closeErrorCodes, withFailureEvidence } from "./failure-evidence.js";
4
+ export { closeCode, closeErrorCodes, closeFailure, withFailureEvidence, withFirstFailureEvidence, failureEvidenceSummary, primaryFailureCode } from "./failure-evidence.js";
35
5
  /** Assign order before abort/kill; persist through the existing asynchronous D19 outbox. */
36
6
  export class CloseDiagnostics {
37
7
  sink;
@@ -71,7 +41,7 @@ export class CloseDiagnostics {
71
41
  const payload = Object.freeze({ ...metadata, pendingOperations: Object.freeze([...this.pending.keys()]), closeId: this.id, ...this.parentId ? { parentCloseId: this.parentId } : {}, closeSequence: ++this.sequence, first, stage: this.stage, clock: 'client-monotonic', elapsedMs: performance.now() - this.started });
72
42
  this.firstValue ??= payload;
73
43
  try {
74
- sink({ kind: 'session', severity: 'warning', code: first ? 'REMOTE_CLOSE_FIRST' : 'REMOTE_CLOSE_NEXT', summary: first ? '首次观察到关闭(不等同根因)' : '后续关闭或清理', payload });
44
+ sink({ kind: 'session', severity: 'warning', code: first ? 'REMOTE_CLOSE_FIRST' : 'REMOTE_CLOSE_NEXT', summary: cause.changedFields?.includes('selected-model') ? '最新可信目录已不再提供本轮所选型号,本轮已停止;请在模型菜单核对选择。' : first ? '首次观察到关闭(不等同根因)' : '后续关闭或清理', payload });
75
45
  }
76
46
  catch (error) {
77
47
  this.state.failure ??= { error };
@@ -97,7 +67,7 @@ export async function flushCloseDiagnostics(diagnostics, flush, primary) {
97
67
  }
98
68
  if (reporting.length) {
99
69
  const primaryCodes = primary ? closeErrorCodes(primary.error, 'REMOTE_CLEANUP_UNCONFIRMED') : [];
100
- throw closeFailure('CONVERSATION_DETAILS_FAILED', '关闭诊断保存未完成;原关闭与清理结果需核对。', '首次关闭 ' + String(diagnostics.first?.reason ?? 'CLOSE_REASON_UNKNOWN') + ';清理 ' + (primaryCodes.join(',') || 'closed') + ';保存 ' + reporting.join(','), [...primaryCodes, ...reporting]);
70
+ throw withFailureEvidence(primary?.error ?? new NeedError(primaryCodes[0] ?? 'REMOTE_CLEANUP_UNCONFIRMED', '关闭收尾未确认', '核对实际结果'), reporting.map(code => new NeedError(code, '诊断保存未确认', '只读核对')), 'diagnostic', diagnostics.first?.reason);
101
71
  }
102
72
  if (primary)
103
73
  throw primary.error;
@@ -345,7 +345,7 @@ function closeStructure(value) {
345
345
  if (!change || typeof change !== 'object' || Array.isArray(change))
346
346
  return opaqueStructure(change);
347
347
  const field = change.field;
348
- const known = typeof field === 'string' && (/^(?:observer.close-sequence|executor.ended|transport.closed|transport.termination-requested|provider.ended|broker.ended|broker.attached|broker.lease-aborted|lease.aborted|lease.closed|mcp.ended|mcp.closing|mcp.poisoned|shell.settled|shell.stop-code|tools.aborted|tools.stop-code|acp.close-requested|adapter.turn-aborted|adapter.detached|adapter.shutting-down|workspace.binding-id|workspace.source|workspace.status|workspace.match-count|network.phase|catalog.api-replaced|catalog.capability|catalog.signature-sha256|connection.instance-id|connection.epoch|connection.phase|connection.need-code)$/.test(field) || /^catalog\.(?:codex|claude)\.(?:ok|error-code|instance-id|epoch|provider|target-sha256|metadata-sha256)$/.test(field));
348
+ const known = typeof field === 'string' && (/^(?:observer.close-sequence|executor.ended|transport.closed|transport.termination-requested|provider.ended|broker.ended|broker.attached|broker.lease-aborted|lease.aborted|lease.closed|mcp.ended|mcp.closing|mcp.poisoned|shell.settled|shell.stop-code|tools.aborted|tools.stop-code|acp.close-requested|adapter.turn-aborted|adapter.detached|adapter.shutting-down|workspace.binding-id|workspace.source|workspace.status|workspace.match-count|network.phase|catalog.api-replaced|catalog.capability|catalog.signature-sha256|connection.instance-id|connection.epoch|connection.phase|connection.need-code)$/.test(field) || /^catalog\.(?:codex|claude)\.(?:ok|error-code|instance-id|epoch|provider|target-sha256|metadata-sha256|selected-model-valid)$/.test(field));
349
349
  if (!known)
350
350
  return opaqueStructure(change);
351
351
  const scalar = (value) => value === null || typeof value === 'boolean' || typeof value === 'number' && Number.isFinite(value) || typeof value === 'string' && /^[a-zA-Z0-9_.:@-]+$/.test(value) ? value : opaqueStructure(value);
@@ -7,7 +7,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
7
7
  return path;
8
8
  };
9
9
  import { CODEX_EMPLOYEE_SCOPE_NOTICE } from "./employee-presentation.js";
10
- import { CloseDiagnostics, closeCode, closeErrorCodes } from "./close-diagnostics.js";
10
+ import { CloseDiagnostics, withFailureEvidence, closeCode, closeErrorCodes } from "./close-diagnostics.js";
11
11
  import { createHash } from 'node:crypto';
12
12
  import { admitEmployeeLocation } from "./employee-location.js";
13
13
  import { EmployeeExecution } from "./employee-exec.js";
@@ -336,13 +336,21 @@ export function createEmployeeProvider(get, options, agent) {
336
336
  return runtime;
337
337
  }
338
338
  catch (error) {
339
- details?.record({ kind: 'need', severity: 'action-required', code: errorCode(error), summary: asNeed(error).message, payload: { result: 'UNKNOWN' } });
339
+ const reporting = [];
340
+ try {
341
+ details?.record({ kind: 'need', severity: 'action-required', code: errorCode(error), summary: asNeed(error).message, payload: { result: 'UNKNOWN' } });
342
+ }
343
+ catch (failure) {
344
+ reporting.push(failure);
345
+ }
340
346
  try {
341
347
  await details?.flush();
342
348
  }
343
- catch {
344
- throw fail('CONVERSATION_DETAILS_FAILED');
349
+ catch (failure) {
350
+ reporting.push(failure);
345
351
  }
352
+ if (reporting.length)
353
+ throw withFailureEvidence(error, reporting, 'diagnostic', closeDiagnostics?.first?.reason);
346
354
  throw error;
347
355
  }
348
356
  };
@@ -0,0 +1,12 @@
1
+ import { NeedError } from './remote.ts';
2
+ /** Only typed codes, never exception messages, causes, stderr or RPC data. */
3
+ export declare function closeCode(error: unknown, fallback?: string): string;
4
+ /** Retain trusted typed codes through wrappers; never traverse error text or invoke getters. */
5
+ export declare function closeErrorCodes(error: unknown, fallback?: string): string[];
6
+ export declare function closeFailure(code: string, message: string, requires: string, codes: readonly string[]): NeedError;
7
+ /** Saving succeeded: retain the original trusted error type, code and presentation. */
8
+ export declare function withFirstFailureEvidence<T>(primary: T, firstReason: unknown, primaryCode: string): T;
9
+ /** Preserve causal roles without copying arbitrary exception text into employee output. */
10
+ export declare function withFailureEvidence(primary: unknown, secondary: readonly unknown[], kind: 'diagnostic' | 'cleanup', firstReason?: unknown, primaryCode?: string): NeedError;
11
+ export declare function primaryFailureCode(error: unknown, fallback: string): string;
12
+ export declare function failureEvidenceSummary(error: unknown): string;
@@ -0,0 +1,83 @@
1
+ import { NeedError } from "./remote.js";
2
+ /** Only typed codes, never exception messages, causes, stderr or RPC data. */
3
+ export function closeCode(error, fallback = 'CLOSE_REASON_UNKNOWN') {
4
+ return error instanceof NeedError && /^[A-Z][A-Z0-9_]*$/.test(error.code) ? error.code : fallback;
5
+ }
6
+ /** Retain trusted typed codes through wrappers; never traverse error text or invoke getters. */
7
+ export function closeErrorCodes(error, fallback = 'CLOSE_REASON_UNKNOWN') {
8
+ const queue = [error], seen = new Set(), codes = new Set();
9
+ while (queue.length) {
10
+ const next = queue.pop();
11
+ if (!(next instanceof NeedError || next instanceof AggregateError) || seen.has(next))
12
+ continue;
13
+ seen.add(next);
14
+ if (next instanceof NeedError) {
15
+ const code = Object.getOwnPropertyDescriptor(next, 'code');
16
+ if (code && 'value' in code && typeof code.value === 'string' && /^[A-Z][A-Z0-9_]*$/.test(code.value))
17
+ codes.add(code.value);
18
+ }
19
+ const cause = Object.getOwnPropertyDescriptor(next, 'cause');
20
+ if (cause && 'value' in cause)
21
+ queue.push(cause.value);
22
+ if (next instanceof AggregateError) {
23
+ const errors = Object.getOwnPropertyDescriptor(next, 'errors');
24
+ if (errors && 'value' in errors && Array.isArray(errors.value))
25
+ for (const child of errors.value)
26
+ queue.push(child);
27
+ }
28
+ }
29
+ return codes.size ? [...codes] : [fallback];
30
+ }
31
+ export function closeFailure(code, message, requires, codes) {
32
+ return Object.assign(new NeedError(code, message, requires), { cause: new AggregateError([...new Set(codes)].map(value => new NeedError(value, '关闭收尾代码 ' + value, '按安全代码核对')), 'CLOSE_AND_DIAGNOSTIC_FAILURES') });
33
+ }
34
+ const evidence = new WeakMap();
35
+ const safeCode = (code) => typeof code === 'string' && /^[A-Z][A-Z0-9_]*$/.test(code);
36
+ function retainedEvidence(error) {
37
+ const queue = [error], seen = new Set();
38
+ while (queue.length) {
39
+ const current = queue.shift();
40
+ if (current && typeof current === 'object') {
41
+ const known = evidence.get(current);
42
+ if (known)
43
+ return known;
44
+ }
45
+ if (!(current instanceof NeedError || current instanceof AggregateError) || seen.has(current))
46
+ continue;
47
+ seen.add(current);
48
+ const cause = Object.getOwnPropertyDescriptor(current, 'cause');
49
+ if (cause && 'value' in cause)
50
+ queue.push(cause.value);
51
+ const children = Object.getOwnPropertyDescriptor(current, 'errors');
52
+ if (children && 'value' in children && Array.isArray(children.value))
53
+ queue.push(...children.value);
54
+ }
55
+ return undefined;
56
+ }
57
+ /** Saving succeeded: retain the original trusted error type, code and presentation. */
58
+ export function withFirstFailureEvidence(primary, firstReason, primaryCode) {
59
+ if (primary && typeof primary === 'object' && safeCode(firstReason) && safeCode(primaryCode)) {
60
+ const previous = retainedEvidence(primary);
61
+ evidence.set(primary, Object.freeze({ first: previous?.first ?? firstReason, original: previous?.original ?? Object.freeze([primaryCode]), diagnostic: previous?.diagnostic ?? Object.freeze([]), cleanup: previous?.cleanup ?? Object.freeze([]) }));
62
+ }
63
+ return primary;
64
+ }
65
+ /** Preserve causal roles without copying arbitrary exception text into employee output. */
66
+ export function withFailureEvidence(primary, secondary, kind, firstReason, primaryCode) {
67
+ const previous = retainedEvidence(primary), first = previous?.first ?? (safeCode(firstReason) ? firstReason : undefined);
68
+ const original = previous?.original ?? (safeCode(primaryCode) ? [primaryCode] : closeErrorCodes(primary, 'TURN_FAILED'));
69
+ const secondaryFacts = secondary.map(retainedEvidence), added = [...new Set(secondary.flatMap((error, index) => secondaryFacts[index] ? secondaryFacts[index].original.filter(code => !original.includes(code)) : closeErrorCodes(error, kind === 'diagnostic' ? 'DIAGNOSTIC_WRITE_FAILED' : 'REMOTE_CLEANUP_UNCONFIRMED')))];
70
+ const facts = Object.freeze({ ...(first ? { first } : {}), original: Object.freeze(original), diagnostic: Object.freeze([...new Set([...(previous?.diagnostic ?? []), ...secondaryFacts.flatMap(f => f?.diagnostic ?? []), ...(kind === 'diagnostic' ? added : [])])]), cleanup: Object.freeze([...new Set([...(previous?.cleanup ?? []), ...secondaryFacts.flatMap(f => f?.cleanup ?? []), ...(kind === 'cleanup' ? added : [])])]) });
71
+ const code = kind === 'cleanup' ? 'REMOTE_CLEANUP_UNCONFIRMED' : !secondary.length ? primary instanceof NeedError ? primary.code : safeCode(primaryCode) ? primaryCode : 'TURN_FAILED' : primary instanceof NeedError && facts.diagnostic.length > 0 && facts.diagnostic.every(code => code === primary.code) && !facts.cleanup.length && !previous ? primary.code : 'CONVERSATION_DETAILS_FAILED';
72
+ const failure = Object.assign(new NeedError(code, '本轮已停止;运行原因与诊断或清理结果分别保留。', '核对原始原因及未确认的保存/清理结果'), { cause: new AggregateError([primary, ...(safeCode(primaryCode) && !(primary instanceof NeedError) ? [new NeedError(primaryCode, '运行错误代码', '可信调用点提供的固定代码')] : []), ...secondary, ...(first ? [new NeedError(first, '首次观察关闭代码', '不是已证明根因')] : [])], 'TURN_FAILURE_EVIDENCE') });
73
+ evidence.set(failure, facts);
74
+ failure.requires = '原错误代码 ' + facts.original.join('、') + (facts.diagnostic.length ? ';诊断错误代码 ' + facts.diagnostic.join('、') : '') + (facts.first ? ';首次观察关闭 ' + facts.first : '') + (facts.cleanup.length ? ';清理错误代码 ' + facts.cleanup.join('、') : '');
75
+ return failure;
76
+ }
77
+ export function primaryFailureCode(error, fallback) { return retainedEvidence(error)?.original[0] ?? fallback; }
78
+ export function failureEvidenceSummary(error) {
79
+ const facts = retainedEvidence(error);
80
+ if (!facts)
81
+ return '';
82
+ return '技术详情:' + [...(facts.first ? ['首次观察关闭 ' + facts.first] : []), '运行原因 ' + facts.original.join('、'), ...(facts.diagnostic.length ? ['详情保存未确认 ' + facts.diagnostic.join('、')] : []), ...(facts.cleanup.length ? ['清理未确认 ' + facts.cleanup.join('、')] : [])].join(';');
83
+ }
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { CloseDiagnostics, closeCode, closeErrorCodes, closeFailure } from "./close-diagnostics.js";
1
+ import { CloseDiagnostics, withFailureEvidence, withFirstFailureEvidence, failureEvidenceSummary, closeCode, closeErrorCodes, closeFailure } from "./close-diagnostics.js";
2
2
  import { runCatalog } from "./catalog-ssh.js";
3
3
  import { createCatalogDiagnostics, CatalogDiagnosticsRemote } from "./catalog-diagnostics.js";
4
4
  import { SessionId } from '@deepseek-ai/dsh-session';
@@ -254,10 +254,11 @@ export class CodexRemoteAdapter extends LlmAdapter {
254
254
  }
255
255
  catch (error) {
256
256
  if (error instanceof ModelConfigurationError)
257
- throw new LlmError(error.message, error.code, { cause: error });
257
+ throw new LlmError(error.message + (failureEvidenceSummary(error) ? ';' + failureEvidenceSummary(error) : ''), error.code, { cause: error });
258
258
  if (error instanceof NeedError) {
259
259
  const rpc = /RPC 错误码 (-?\d+)/u.exec(error.requires)?.[1];
260
- const message = employeeFailure(error.code, this.agent.loginProvider) + (rpc ? `(技术详情:RPC 错误码 ${rpc})` : '');
260
+ const evidence = failureEvidenceSummary(error);
261
+ const message = employeeFailure(error.code, this.agent.loginProvider) + (rpc ? `(技术详情:RPC 错误码 ${rpc})` : '') + (evidence ? ';' + evidence : '');
261
262
  const login = originalLoginFailure(error);
262
263
  let diagnostic = login?.technical ? JSON.stringify(login.technical) : '';
263
264
  if (login?.technical && this.reportLoginFailure) {
@@ -271,6 +272,8 @@ export class CodexRemoteAdapter extends LlmAdapter {
271
272
  }
272
273
  throw new LlmError(message, error.code, { cause: new Error(`诊断代码:${error.code};${error.requires}` + (diagnostic ? ';' + diagnostic : '')) });
273
274
  }
275
+ if (error instanceof LlmError && failureEvidenceSummary(error))
276
+ throw new LlmError(error.failure.message + ';' + failureEvidenceSummary(error), error.code, { cause: error });
274
277
  throw error;
275
278
  }
276
279
  }
@@ -303,6 +306,7 @@ export class CodexRemoteAdapter extends LlmAdapter {
303
306
  let catalog;
304
307
  let selected;
305
308
  let modelConfiguration;
309
+ let turnFailure;
306
310
  let employeeNoticeShown = false;
307
311
  const information = [];
308
312
  const recordInformation = () => { if (runtime?.details)
@@ -401,8 +405,9 @@ export class CodexRemoteAdapter extends LlmAdapter {
401
405
  throw new ModelConfigurationError('所选模型不在本次可信主机目录中,请重新选择模型');
402
406
  if (options.reasoningEffort !== undefined && !selected.reasoning.options.some(option => option.value === options.reasoningEffort))
403
407
  throw new ModelConfigurationError('所选推理等级不在本次模型目录中');
408
+ catalog.bindSelection?.(selected.id, options.reasoningEffort);
404
409
  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)); };
410
+ 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
411
  observeSignal(catalog.signal, 'signal.model-catalog', 'MODEL_CATALOG_REVOKED');
407
412
  observeSignal(signal, 'signal.turn-composite', 'ABORTED');
408
413
  let modelSignal = AbortSignal.any([signal, catalog.signal]);
@@ -568,7 +573,7 @@ export class CodexRemoteAdapter extends LlmAdapter {
568
573
  const resolved = mapping[0].resolvedModel;
569
574
  if (typeof resolved !== 'string' || !resolved.trim())
570
575
  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 } : {}) };
576
+ 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
577
  details.record({ kind: 'session', severity: 'info', code: 'MODEL_CONFIGURATION_CONFIRMED', summary: '本轮选择已由远端配置回执确认;' + modelResolutionLabel(modelConfiguration) + ';不代表推理效果已验证', payload: modelConfiguration });
573
578
  }
574
579
  catch (error) {
@@ -652,41 +657,38 @@ export class CodexRemoteAdapter extends LlmAdapter {
652
657
  yield { type: 'finish', reason: { kind: stop === 'max_tokens' || stop === 'max_turn_requests' ? 'max-tokens' : 'stop' }, replayState: { response: pointer } };
653
658
  }
654
659
  catch (error) {
660
+ turnFailure = error;
655
661
  runtime?.closeDiagnostics?.record({ layer: 'adapter', initiator: 'adapter.turn-failure', reason: closeCode(error, signal.aborted ? 'ABORTED' : 'TURN_FAILED'), action: 'failure' });
656
662
  recordInformation();
657
663
  failureCode = error instanceof NeedError || error instanceof ModelConfigurationError ? error.code : error instanceof LlmError ? error.code : 'TURN_PREPARATION_FAILED';
658
664
  if (live && session && session.promptDispatchCount > promptBaseline)
659
665
  live.failure = { cause: error };
660
- let reportingFailure;
666
+ const reporting = [];
661
667
  if (request?.recordEarlyFailure) {
662
668
  const record = request.recordEarlyFailure;
663
669
  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
670
  try {
666
- await record(Object.assign(new NeedError(code, '本轮在执行前失败。', '白名单阶段与错误代码'), { cause: error }), earlyStage);
671
+ await record(Object.assign(new NeedError(failureCode ?? 'TURN_PREPARATION_FAILED', '本轮在执行前失败。', '白名单阶段与错误代码'), { cause: error }), earlyStage);
667
672
  }
668
673
  catch (failure) {
669
- reportingFailure = { cause: failure };
674
+ reporting.push(failure);
670
675
  }
671
676
  }
672
677
  try {
673
678
  await runtime?.details?.flush();
674
679
  }
675
680
  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
- }
681
+ reporting.push(failure);
684
682
  }
685
683
  if (activeMapper)
686
684
  yield* activeMapper.flush();
687
- if (reportingFailure !== undefined)
688
- throw reportingFailure.cause;
689
- throw error;
685
+ const firstReason = request?.closeDiagnostics?.first?.reason ?? runtime?.closeDiagnostics?.first?.reason;
686
+ if (reporting.length) {
687
+ turnFailure = withFailureEvidence(error, reporting, 'diagnostic', firstReason, failureCode);
688
+ throw turnFailure;
689
+ }
690
+ turnFailure = firstReason && !(error instanceof NeedError || error instanceof ModelConfigurationError || error instanceof LlmError) ? withFailureEvidence(error, [], 'diagnostic', firstReason, failureCode) : withFirstFailureEvidence(error, firstReason, failureCode);
691
+ throw turnFailure;
690
692
  }
691
693
  finally {
692
694
  for (const off of abortOffs)
@@ -716,6 +718,9 @@ export class CodexRemoteAdapter extends LlmAdapter {
716
718
  if (succeeded && key)
717
719
  this.preparationFailures.delete(key);
718
720
  }
721
+ catch (cleanup) {
722
+ throw withFailureEvidence(turnFailure ?? cleanup, [cleanup], 'cleanup', request?.closeDiagnostics?.first?.reason ?? runtime?.closeDiagnostics?.first?.reason, failureCode);
723
+ }
719
724
  finally {
720
725
  if (key && this.turns.get(key) === turn)
721
726
  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) {
@@ -14,6 +14,9 @@ export function employeeFailure(code, brand = 'codex') {
14
14
  LOCAL_USER_TURN_CHANGED: '本次输入已变化,本轮已停止,请确认对话后重新发送。',
15
15
  LOCAL_USER_TURN_UNCONFIRMED: '无法核对本次员工输入,本轮已停止;请在当前会话重新发送。',
16
16
  LOCAL_USER_TURN_ACCEPTANCE_INVALID: '本次输入的核验回执不一致,本轮已停止,请联系管理员。',
17
+ MODEL_CATALOG_REVOKED: '所选型号已不在当前主机目录中,本轮已停止;请在模型菜单核对选择。',
18
+ MODEL_CATALOG_SCOPE_CHANGED: '远程主机连接或可信目标已改变,本轮已停止;原输入没有自动重发。',
19
+ CONVERSATION_DETAILS_FAILED: '本轮已停止,技术详情保存未确认;下方分别列出运行与保存原因。',
17
20
  TECHNICAL_DETAILS_UNAVAILABLE: '技术详情暂不可用,请更新远程主机连接组件。',
18
21
  REMOTE_DETAIL_STORAGE_FAILED: '技术详情保存失败,本轮已停止,请联系管理员检查。',
19
22
  REMOTE_DETAIL_DELIVERY_UNKNOWN: '技术详情的持久结果无法核对,本轮已停止。',
@@ -1,3 +1,4 @@
1
+ import { closeCode, withFailureEvidence } from "./close-diagnostics.js";
1
2
  import { createHash, randomUUID } from 'node:crypto';
2
3
  import { constants } from 'node:fs';
3
4
  import { mkdir, open, lstat, realpath, readdir } from 'node:fs/promises';
@@ -362,40 +363,67 @@ export function createRemoteDetails(options) {
362
363
  scope.maximum = sequence;
363
364
  const detail = Object.freeze({ ...safe, payload: wire(safe.payload), version: 1, eventId: randomUUID(), instanceId: request.connection.instanceId, epoch: request.connection.epoch, sessionId: request.sessionId, provider: request.provider, turn, step, sequence, kind: kind(observation) });
364
365
  enqueue(async () => {
365
- // A private X journal is retained even if L is disconnected or rejects it.
366
+ // Pending is not a delivery verdict. Keep the exact failed stage separately.
366
367
  const file = join(dir, sequence + '-' + detail.eventId + '.json');
368
+ let phase = 'journal', appendAttempted = false, outcomeKnown = false;
367
369
  try {
368
- await durableWrite(file, { state: 'pending', detail });
369
- }
370
- catch {
371
- throw failure('REMOTE_DETAIL_STORAGE_FAILED');
372
- }
373
- stable();
374
- let response;
375
- try {
376
- response = await (localScope === undefined ? api.appendRemoteConversationDetail(structuredClone(detail)) : api.appendRemoteConversationDetail(structuredClone(detail), localScope));
377
- }
378
- catch {
370
+ try {
371
+ await durableWrite(file, { state: 'pending', detail });
372
+ }
373
+ catch {
374
+ throw failure('REMOTE_DETAIL_STORAGE_FAILED');
375
+ }
376
+ phase = 'append-scope';
379
377
  stable();
380
- const receipt = unwrap(await api.getRemoteConversationDetailReceipt({ eventId: detail.eventId, sessionId: detail.sessionId, provider: detail.provider }));
378
+ let response;
379
+ try {
380
+ phase = 'append';
381
+ appendAttempted = true;
382
+ response = await (localScope === undefined ? api.appendRemoteConversationDetail(structuredClone(detail)) : api.appendRemoteConversationDetail(structuredClone(detail), localScope));
383
+ }
384
+ catch {
385
+ phase = 'receipt-scope';
386
+ stable();
387
+ phase = 'receipt';
388
+ const receipt = unwrap(await api.getRemoteConversationDetailReceipt({ eventId: detail.eventId, sessionId: detail.sessionId, provider: detail.provider }));
389
+ phase = 'receipt-scope';
390
+ stable();
391
+ if (receipt.eventId !== detail.eventId)
392
+ throw failure('REMOTE_DETAIL_RECEIPT_INVALID');
393
+ if (!receipt.recorded)
394
+ throw failure('REMOTE_DETAIL_DELIVERY_UNKNOWN');
395
+ if (!isDeepStrictEqual(receipt.detail, detail))
396
+ throw failure('REMOTE_DETAIL_RECEIPT_INVALID');
397
+ phase = 'settlement';
398
+ await durableWrite(join(dir, 'settled-' + detail.eventId + '.json'), { eventId: detail.eventId, state: 'recorded' });
399
+ outcomeKnown = true;
400
+ return;
401
+ }
402
+ phase = 'ack-scope';
381
403
  stable();
382
- if (receipt.eventId !== detail.eventId)
383
- throw failure('REMOTE_DETAIL_RECEIPT_INVALID');
384
- if (!receipt.recorded)
385
- throw failure('REMOTE_DETAIL_DELIVERY_UNKNOWN');
386
- if (!isDeepStrictEqual(receipt.detail, detail))
387
- throw failure('REMOTE_DETAIL_RECEIPT_INVALID');
404
+ if (response?.ok === false) {
405
+ phase = 'settlement';
406
+ await durableWrite(join(dir, 'settled-' + detail.eventId + '.json'), { eventId: detail.eventId, state: 'rejected', code: response.error.code });
407
+ outcomeKnown = true;
408
+ }
409
+ const ack = unwrap(response);
410
+ if (ack.eventId !== detail.eventId || ack.recorded !== true)
411
+ throw failure('REMOTE_DETAIL_RESPONSE_INVALID');
412
+ phase = 'settlement';
388
413
  await durableWrite(join(dir, 'settled-' + detail.eventId + '.json'), { eventId: detail.eventId, state: 'recorded' });
389
- return;
414
+ outcomeKnown = true;
390
415
  }
391
- stable();
392
- if (response?.ok === false) {
393
- await durableWrite(join(dir, 'settled-' + detail.eventId + '.json'), { eventId: detail.eventId, state: 'rejected', code: response.error.code });
416
+ catch (error) {
417
+ if (!outcomeKnown) {
418
+ try {
419
+ await durableWrite(join(dir, 'unconfirmed-' + detail.eventId + '.json'), { version: 1, eventId: detail.eventId, state: 'unconfirmed', phase, appendAttempted, code: closeCode(error, 'REMOTE_DETAIL_STORAGE_FAILED') });
420
+ }
421
+ catch {
422
+ throw withFailureEvidence(error, [failure('REMOTE_DETAIL_STORAGE_FAILED')], 'diagnostic');
423
+ }
424
+ }
425
+ throw error;
394
426
  }
395
- const ack = unwrap(response);
396
- if (ack.eventId !== detail.eventId || ack.recorded !== true)
397
- throw failure('REMOTE_DETAIL_RESPONSE_INVALID');
398
- await durableWrite(join(dir, 'settled-' + detail.eventId + '.json'), { eventId: detail.eventId, state: 'recorded' });
399
427
  });
400
428
  },
401
429
  async flush() { await tail; if (firstFailure)
@@ -1,9 +1,10 @@
1
+ import { withFailureEvidence } from "./failure-evidence.js";
1
2
  import { isDeepStrictEqual } from 'node:util';
2
3
  import { sanitizeDisplayLabel } from "./conversation-details.js";
3
4
  import { NeedError } from "./remote.js";
4
5
  const fail = (code) => new NeedError(code, '工作区来源尚未确认,本轮未执行;你的原话仍保留。', '当前工作区来源及原始员工输入');
5
6
  const codeOf = (value) => value instanceof NeedError ? value.code : 'WORKSPACE_SOURCE_CONFIRMATION_FAILED';
6
- const diagnosticFailure = (error, diagnostic) => Object.assign(new NeedError('CONVERSATION_DETAILS_FAILED', '工作区来源诊断未能保存,本轮已停止;原话保留。', '原错误代码 ' + codeOf(error) + ';诊断错误代码 ' + codeOf(diagnostic)), { cause: new AggregateError([error, diagnostic], 'WORKSPACE_SOURCE_AND_DIAGNOSTIC_FAILED') });
7
+ const diagnosticFailure = (error, diagnostic) => withFailureEvidence(error, [diagnostic], 'diagnostic', undefined, codeOf(error));
7
8
  /** Explicit human source transition only. No source selection is inferred from a folder path. */
8
9
  export async function confirmLegacyWorkspaceSource(get, api, request, hooks) {
9
10
  let initialContext = { workspaceId: null, bindingId: null, source: null, status: null };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-workbench/remote-agent",
3
- "version": "0.8.15",
3
+ "version": "0.8.16",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",