@ai-workbench/remote-agent 0.8.13 → 0.8.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,10 +1,10 @@
1
- **`@ai-workbench/remote-agent@0.8.13` 发布形态包;由编排发布后通过官方插件 → 添加插件安装。修复 Claude 模型目录及旧默认模型,供 leo 真机复验。Codex 线因主机适配器 aw.2 实装失败(UNSAFE_MODE_MISMATCH),待 S 修复后另验,当前未验证可用。由编排统一发布,X 未执行 publish。**
1
+ **`@ai-workbench/remote-agent@0.8.14` 发布形态包;消费审定的 B1–B9 校验分类及 X2 首次关闭原因观测,供 leo 真机复验。Claude 型号按本轮 setter 精确回执与同域目录映射确认,并如实注明来源。Codex 上次 `EMPLOYEE_EXEC_CLOSED` 的真机根因仍未查明,本版补充首因留痕,不宣称其已在真机修复。由编排发布后,通过官方插件 → 添加插件安装;X 不执行 publish。**
2
2
 
3
3
  # 远程ACP插件
4
4
 
5
5
  一个插件提供 **Claude(远程主机)** 和 **Codex(远程主机)**,通过员工主机上的官方CLI运行。Codex保留单次模式;Claude固定会话模式。只用官方模型列表、插件管理和配置入口,不提供自制面板、问答或登录按钮。登录和原生工作区绑定唯一归连接插件L。
6
6
 
7
- 当前版本号为0.8.13;包名采用`@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.14;包名采用`@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
 
@@ -24,11 +24,11 @@
24
24
 
25
25
  文本、思考、工具步骤/正文/补丁、计划、通知映射为官方普通流;远端工具不变成本机工具调用。图片输入由官方附件服务读取已核验字节,经ACP传给主机,不传员工文件路径。非文本输出无法得到官方持久附件引用时,明确说明收到的内容类型与所缺能力,不假造图片引用。远端结构化提问、未映射扩展会明确提示并取消,不静默丢弃。
26
26
 
27
- 首次系统/开发者上下文按协商的ACP系统能力传入。**首版不支持:已有Claude会话中立即生效的系统/开发者上下文变更。** 固定适配器说明恢复后的变更要到下次上下文整理才生效;X发现变更会停止本轮派发并提示新建会话,避免按旧约束执行,不自动整理、重试或新建替代会话。未声明系统能力的agent会明确报缺项。
27
+ 首次系统/开发者上下文按协商的ACP系统能力传入。**首版不支持:已有Claude会话中立即生效的系统/开发者上下文变更。** 固定适配器说明恢复后的变更要到下次上下文整理才生效;X按已有公开 resume/load 传送新要求,技术详情注明延迟生效,继续本轮;不宣称本轮已立即采用,不自动整理或重试。实际 RPC 拒绝或缺少公开更新能力仍会明确停止。未声明系统能力的agent会明确报缺项。
28
28
 
29
29
  `usage_update`的used/size是上下文占用,不能当作本轮输入/输出计费量;远端真实prompt响应usage及Claude quota令牌计数是会话累计,按保存在当前实例/恢复标识中的可信基线减差后进入DSH本轮用量;重复值不重复结算。恢复缺少基线、计数缺失、回退或来源改变时明确不计入该轮,重建可信基线,不能把历史用量算成本轮。
30
30
 
31
- 会话模式跨轮保留同一ACP客户端和主机进程;关闭实际agent/session或插件时精确清理。重开恢复优先协商resume,无能力时明确使用load,不静默新建。恢复标识绑定品牌、员工目标、DSH会话和L工作区;复制/分叉不能沿用父远端会话。主机来源沿用D24同实例自动恢复/安全重建。本地来源重开后原MCP socket失效,先由既有S同实例条件流程结束旧broker再重建,不代答旧审批;未获真实清理确认时保留未知,不自动重放。无TTL或隐式淘汰上限。
31
+ 会话模式跨轮保留同一ACP客户端和主机进程;关闭实际agent/session或插件时精确清理。重开恢复优先协商resume,无能力时明确使用load,不静默新建。恢复标识绑定品牌、员工目标、DSH会话和L工作区;复制/分叉不能沿用父远端会话。主机来源沿用D24同实例自动恢复/安全重建。本地来源重开后原MCP socket失效,先由既有S同实例条件流程结束旧broker再重建,不代答旧审批;未获真实清理确认时保留未知,不自动重放。无TTL或隐式淘汰上限。缺私有恢复 pointer 只撤回恢复资格;只有原 slot 不存在已核实,或同 UUID 条件结束与 list 消失均确认,才新建并仅使用官方本次接纳的输入 ID。纯发送前失败经确认清理后不永久封住新轮;真实输入送达未知、dirty 或清理未知仍阻断重放。
32
32
 
33
33
  Codex单次模式首版仅文本,不转接中途审批;图片或系统/开发者上下文需使用会话模式。文件传输、远端VFS与自动同步不在本轮。其他模型不锁定、不拦截、不记录。
34
34
 
@@ -74,9 +74,9 @@ D22阶段耗时进入技术详情;D23文件审批用中文动作,原工具
74
74
 
75
75
  D21(0.8.5):两个品牌的官方模型菜单从 L 的 `modelCatalogTarget:1` / `getModelCatalogTarget` 取得当前可信员工目标,再只读调用 S 的 `aw-model-catalog --provider codex|claude`。目录按 host-user 作用域展示完整模型与逐模型推理等级,保留远端标识原值;失败显示“无法获取模型列表:原因”,不登录、不创建项目、不返回替代模型。L 必须宣告该正式能力,S 必须安装保护入口;这两项未就绪时不能据组件测试声称菜单可用。
76
76
 
77
- 每轮先核对目录代次和所选模型,再经 ACP 逐项配置模型及推理等级,收到完整配置确认后才发送输入。Claude 别名必须有当前会话的具体型号证据;没有就明确拒绝。真实回复追加所选值和远端确认值,并在恢复标识与技术详情保留,确认配置不等于验证推理效果。连接/身份/目录变化清除旧确认;远端清理待定或结果未知时阻止派发,不自动清理重试或另开替代会话。Codex 单次模式当前不能确认此选择,会在发送前明确拒绝。新增设置 0;本轮工程证据、真实只读链路与产品未验项见 `docs/evidence/2026-10-22-X2-D21/`。
77
+ 每轮先核对目录代次和所选模型,再经 ACP 逐项配置模型及推理等级,收到完整配置确认后才发送输入。Claude 别名每轮须有精确的模型配置成功回执,并由同一员工主机、同一连接代际的目录唯一映射到具体型号。具体型号注明来自主机目录,会话未单独回报;旧恢复记录不作本轮证据。目录缺项、映射不唯一、回执不符或身份/代际变化时不发送输入。真实回复追加所选值和远端确认值,并在恢复标识与技术详情保留,确认配置不等于验证推理效果。连接/身份/目录变化清除旧确认;远端清理待定或结果未知时阻止派发,不自动清理重试或另开替代会话。Codex 单次模式当前不能确认此选择,会在发送前明确拒绝。新增设置 0;本轮工程证据、真实只读链路与产品未验项见 `docs/evidence/2026-10-22-X2-D21/`。
78
78
 
79
- 0.8.5统一包原依赖L `~0.7.13`;当前0.8.13对齐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.14对齐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
 
@@ -122,3 +122,5 @@ D20(0.8.8开发记录):本地来源的Codex原生文件与命令通过固
122
122
  0.8.12目录诊断修复:入口为“设置 → 模型 → 模型目录诊断”,官方既有 settings.models.footer 公开扩展位直接显示;“内置插件 → 远程助手参数”也可只读查看,即使参数命名空间暂未提供也保留入口;参数编辑使用当前插件公开Loader entry id对应的官方ConfigForms。该页只读本机记录,刷新不查询主机、不登录、不发送待发送原话。模型页原错误行仍通过公开提供方目录发布,诊断数据不再调用directory.replace或发布适配器拓扑变化;changed只负责真实连接/目标代次变化,菜单提示指向上述实际入口。
123
123
 
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
+
126
+ 0.8.14 不把空菜单、非穷举 picker、可选 UI category/label 或同值配置通知当作额外发送门槛;每轮仍要求可靠模型入口与 fresh exact setter ACK。本轮完成后的配置变化只记录 warning,保留真实回复与 finish,并明确未获单独的实际执行型号回报。同一身份、代际与可信目标的 needCode/诊断变化只记 info;真实身份、代际、目标或能力失效仍撤回。每次关闭/取消保留 caller、字段前后值和首次观测原因(不等于根因)。新增配置、阈值、超时、重试为 0。
package/dist/acp.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { CloseDiagnostics, type CloseCause } from './close-diagnostics.ts';
1
2
  import * as stable from '@agentclientprotocol/sdk';
2
3
  import * as acp from '@agentclientprotocol/sdk/unstable';
3
4
  import { NeedError, type Need } from './remote.ts';
@@ -9,6 +10,7 @@ import { type ModelConfigurationSnapshot } from './model-configuration.ts';
9
10
  import type { EmployeeRouting } from './employee-relay.ts';
10
11
  export type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable';
11
12
  export interface AcpHooks {
13
+ closeDiagnostics?: CloseDiagnostics;
12
14
  timing?: StageTiming;
13
15
  onUpdate?: (notification: acp.SessionNotification) => void;
14
16
  onResponse?: (response: acp.PromptResponse) => void;
@@ -36,6 +38,7 @@ export type AcpStartMethod = 'session/new' | 'session/resume' | 'session/load';
36
38
  export declare class AcpSession {
37
39
  private connection;
38
40
  private hooks;
41
+ private closeDiagnostics;
39
42
  private sessionId?;
40
43
  private turnSignal?;
41
44
  private turnToken?;
@@ -44,6 +47,7 @@ export declare class AcpSession {
44
47
  private negotiated;
45
48
  private method?;
46
49
  private ready;
50
+ private closeRequested;
47
51
  private updating;
48
52
  private cwd?;
49
53
  private systemPrompt;
@@ -55,15 +59,13 @@ export declare class AcpSession {
55
59
  private configRevision;
56
60
  private selected?;
57
61
  private selecting;
58
- private resolvedEvidence?;
59
62
  private pendingConfigurations;
63
+ private dispatchedPrompts;
64
+ get promptDispatchCount(): number;
60
65
  get modelConfiguration(): ModelConfigurationSnapshot | undefined;
61
- get resolvedModelEvidence(): Readonly<{
62
- modelValue: string;
63
- resolvedModel: string;
64
- }> | undefined;
65
66
  get models(): readonly import("./model-configuration.ts").ModelCatalogValue[];
66
67
  private receiveConfiguration;
68
+ private pendingConfiguration;
67
69
  private resetConfiguration;
68
70
  private lifecycleConfiguration;
69
71
  selectModel(model: string, effort?: string, signal?: AbortSignal): Promise<ModelConfigurationSnapshot>;
@@ -77,6 +79,9 @@ export declare class AcpSession {
77
79
  private need;
78
80
  private checkReporting;
79
81
  constructor(transport: acp.Stream | acp.AgentApp | stable.AgentApp, hooks: AcpHooks);
82
+ /** A rejected request on an already-closed SDK never reaches this write.
83
+ * Once a transport write starts, failure remains delivery-unknown. */
84
+ private observeDispatch;
80
85
  private observe;
81
86
  get id(): string | undefined;
82
87
  get capabilities(): Readonly<acp.AgentCapabilities>;
@@ -92,8 +97,8 @@ export declare class AcpSession {
92
97
  start(cwd: string, resumeId?: string, signal?: AbortSignal, context?: AcpStartContext, prompt?: acp.ContentBlock[]): Promise<string>;
93
98
  /** Codex context is immutable after creation; keep the old remote session intact. */
94
99
  newContextSession(context: AcpStartContext, signal?: AbortSignal): Promise<string>;
95
- updateContext(context: AcpStartContext, signal?: AbortSignal): Promise<AcpStartMethod | undefined>;
100
+ updateContext(context: AcpStartContext, signal?: AbortSignal, force?: boolean): Promise<AcpStartMethod | undefined>;
96
101
  prompt(input: string | acp.ContentBlock[], signal?: AbortSignal): Promise<acp.StopReason>;
97
- close(): void;
102
+ close(cause?: CloseCause, error?: Error): void;
98
103
  }
99
104
  export declare function acpNeed(error: unknown): NeedError;
package/dist/acp.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { CloseDiagnostics } from "./close-diagnostics.js";
1
2
  import * as acp from '@agentclientprotocol/sdk/unstable';
2
3
  import { NeedError } from "./remote.js";
3
4
  import { employeeFailure } from "./presentation.js";
@@ -10,6 +11,7 @@ import { ModelConfiguration, ModelConfigurationError } from "./model-configurati
10
11
  export class AcpSession {
11
12
  connection;
12
13
  hooks;
14
+ closeDiagnostics;
13
15
  sessionId;
14
16
  turnSignal;
15
17
  turnToken;
@@ -18,6 +20,7 @@ export class AcpSession {
18
20
  negotiated = {};
19
21
  method;
20
22
  ready = false;
23
+ closeRequested = false;
21
24
  updating = false;
22
25
  cwd;
23
26
  systemPrompt = '';
@@ -29,33 +32,29 @@ export class AcpSession {
29
32
  configRevision = 0;
30
33
  selected;
31
34
  selecting = false;
32
- resolvedEvidence;
33
35
  pendingConfigurations = new Map();
36
+ dispatchedPrompts = 0;
37
+ get promptDispatchCount() { return this.dispatchedPrompts; }
34
38
  get modelConfiguration() { return this.configuration?.snapshot; }
35
- get resolvedModelEvidence() { return this.resolvedEvidence; }
36
39
  get models() { if (!this.configuration)
37
40
  throw new ModelConfigurationError('远端没有提供完整配置列表'); return this.configuration.models; }
38
- receiveConfiguration(options) {
41
+ receiveConfiguration(options, routing) {
39
42
  this.configRevision++;
40
43
  if (this.configuration)
41
44
  this.configuration.update(options);
42
45
  else
43
46
  this.configuration = new ModelConfiguration(options, async (configId, value) => {
44
47
  const result = await this.connection.agent.request(acp.methods.agent.session.setConfigOption, { sessionId: this.sessionId, configId, value }, { cancellationSignal: this.turnSignal });
45
- if (this.resolvedEvidence && (this.configuration?.snapshot.model !== this.resolvedEvidence.modelValue || (configId === this.configuration?.snapshot.modelConfigId && value !== this.resolvedEvidence.modelValue)))
46
- this.resolvedEvidence = undefined;
47
48
  this.hooks.broker?.acknowledge(acp.methods.agent.session.setConfigOption);
48
49
  return result;
49
- });
50
- if (this.resolvedEvidence && this.configuration.snapshot.model !== this.resolvedEvidence.modelValue)
51
- this.resolvedEvidence = undefined;
50
+ }, reason => this.detail({ kind: 'session', severity: 'info', code: 'MODEL_CONFIGURATION_INFORMATION', summary: ({ PICKER_INCOMPLETE: '远端菜单没有穷举当前或所选值;以本轮精确配置回执为准。', CONFIG_UI_METADATA_INVALID: '远端配置的显示资料不完整;没有改变本轮选择。', UNRELATED_CONFIG_IGNORED: '远端有未使用的界面配置;未把它当模型配置入口。', OPTIONAL_CATEGORY_OMITTED: '远端省略了显示类别;继续使用本会话已核实的配置入口。', SAME_SELECTION_UPDATE: '收到同值或无关配置更新,本轮真实配置回执仍有效。' })[reason], payload: { reason } }), routing);
52
51
  }
53
- resetConfiguration() {
52
+ pendingConfiguration(sessionId, options) { const pending = this.pendingConfigurations.get(sessionId) ?? []; pending.push(options); this.pendingConfigurations.set(sessionId, pending); }
53
+ resetConfiguration(clearRoles = true) {
54
54
  this.selected = undefined;
55
- this.resolvedEvidence = undefined;
56
55
  if (this.configuration) {
57
56
  try {
58
- this.receiveConfiguration([]);
57
+ this.configuration.invalidate(clearRoles);
59
58
  }
60
59
  catch { /* Invalidated until this lifecycle operation supplies new evidence. */ }
61
60
  }
@@ -64,14 +63,22 @@ export class AcpSession {
64
63
  lifecycleConfiguration(result, revision) {
65
64
  const pending = this.pendingConfigurations.get(this.sessionId);
66
65
  this.pendingConfigurations.clear();
67
- if (pending)
68
- this.receiveConfiguration(pending);
66
+ if (pending) {
67
+ // A lifecycle response can identify selectors for this same session even
68
+ // when newer notifications omit UX categories. It never supplies an ACK.
69
+ if (result.configOptions)
70
+ try {
71
+ this.receiveConfiguration(result.configOptions);
72
+ }
73
+ catch {
74
+ this.detail({ kind: 'session', severity: 'info', code: 'MODEL_CONFIGURATION_INFORMATION', summary: '会话建立回执的配置资料由本会话较新通知重新核对,仍需本轮真实配置回执。', payload: { reason: 'LIFECYCLE_CONFIGURATION_SUPERSEDED' } });
75
+ }
76
+ for (const options of pending)
77
+ this.receiveConfiguration(options);
78
+ }
69
79
  // A notification observed during the operation is newer than its in-flight response.
70
80
  else if (this.configRevision === revision && result.configOptions)
71
81
  this.receiveConfiguration(result.configOptions);
72
- const model = this.configuration?.snapshot.model;
73
- if (!pending && this.configRevision === revision + 1 && model && typeof result.models?.currentModelId === 'string' && result.models.currentModelId)
74
- this.resolvedEvidence = { modelValue: model, resolvedModel: result.models.currentModelId };
75
82
  }
76
83
  async selectModel(model, effort, signal) {
77
84
  if (!this.ready || !this.sessionId)
@@ -104,7 +111,14 @@ export class AcpSession {
104
111
  reports = [];
105
112
  reportingFailure;
106
113
  timing = new StageTiming();
107
- stage(phase, operation) { return (this.hooks.timing ?? this.timing).run(phase, operation); }
114
+ async stage(phase, operation) { this.closeDiagnostics.stage = phase; try {
115
+ return await this.closeDiagnostics.run(phase, () => (this.hooks.timing ?? this.timing).run(phase, operation));
116
+ }
117
+ catch (error) {
118
+ if (['acp-session-new', 'acp-session-resume', 'acp-session-load', 'acp-initialize'].includes(phase))
119
+ this.closeDiagnostics.record({ layer: 'acp', initiator: phase === 'acp-session-new' ? 'acp.session-new.failure' : 'acp.lifecycle.failure', reason: acpNeed(error).code, operation: phase, action: 'failure' });
120
+ throw error;
121
+ } }
108
122
  get diagnostics() { return this.reports.map(entry => sanitizeObservation(entry)); }
109
123
  detail(observation, hooks = this.hooks) {
110
124
  const safe = sanitizeObservation(observation);
@@ -113,8 +127,10 @@ export class AcpSession {
113
127
  hooks.onDetail?.(safe);
114
128
  }
115
129
  catch (cause) {
130
+ const first = !this.reportingFailure;
116
131
  this.reportingFailure ??= { cause };
117
- this.connection?.close();
132
+ if (first)
133
+ this.close({ layer: 'acp', initiator: 'acp.reporting-failure', reason: 'CONVERSATION_DETAILS_FAILED', action: 'close' });
118
134
  }
119
135
  }
120
136
  need(need, hooks = this.hooks) {
@@ -125,12 +141,13 @@ export class AcpSession {
125
141
  throw new NeedError('CONVERSATION_DETAILS_FAILED', '技术详情未能记录;本轮未继续发送', '可用的技术详情记录渠道'); }
126
142
  constructor(transport, hooks) {
127
143
  this.hooks = hooks;
144
+ this.closeDiagnostics = hooks.closeDiagnostics ?? new CloseDiagnostics(event => this.detail(event));
128
145
  this.timing.bind(detail => this.detail(detail));
129
146
  const app = acp.client({ name: '@ai-workbench/remote-agent' })
130
147
  .onNotification(acp.methods.client.session.update, ({ params: notification }) => {
131
148
  if (this.creatingReplacement) {
132
149
  if (notification.sessionId !== this.sessionId && notification.update.sessionUpdate === 'config_option_update')
133
- this.pendingConfigurations.set(notification.sessionId, notification.update.configOptions);
150
+ this.pendingConfiguration(notification.sessionId, notification.update.configOptions);
134
151
  this.detail({ kind: 'session', severity: 'info', code: 'ACP_IDLE_UPDATE', summary: '新会话建立期间的轨迹,未作为正文处理', method: 'session/update', payload: notification });
135
152
  return;
136
153
  }
@@ -140,9 +157,8 @@ export class AcpSession {
140
157
  }
141
158
  const meta = record(notification._meta);
142
159
  if (notification.update.sessionUpdate === 'config_option_update') {
143
- this.resolvedEvidence = undefined;
144
160
  if (!this.sessionId)
145
- this.pendingConfigurations.set(notification.sessionId, notification.update.configOptions);
161
+ this.pendingConfiguration(notification.sessionId, notification.update.configOptions);
146
162
  else
147
163
  try {
148
164
  this.receiveConfiguration(notification.update.configOptions);
@@ -258,21 +274,27 @@ export class AcpSession {
258
274
  this.detail({ kind: 'extension', severity: 'warning', code: 'ACP_UNKNOWN_NOTIFICATION', summary: '收到未知远端扩展通知,已保留技术记录', method, payload: params });
259
275
  });
260
276
  if ('readable' in transport && 'writable' in transport)
261
- this.connection = app.connect(isolateRpcIds(this.observe(transport)));
277
+ this.connection = app.connect(isolateRpcIds(this.observe(transport), message => this.observeDispatch(message)));
262
278
  else {
263
279
  // Stable and unstable apps use distinct private symbols. Bridge only their
264
280
  // public stream interfaces so either SDK entry point remains supported.
265
281
  const outward = new TransformStream();
266
282
  const inward = new TransformStream();
267
283
  const peer = transport.connect({ readable: outward.readable, writable: inward.writable });
268
- this.connection = app.connect(isolateRpcIds(this.observe({ readable: inward.readable, writable: outward.writable })));
269
- void this.connection.closed.then(() => peer.close());
270
- void peer.closed.then(() => this.connection.close());
284
+ this.connection = app.connect(isolateRpcIds(this.observe({ readable: inward.readable, writable: outward.writable }), message => this.observeDispatch(message)));
285
+ const observeConnection = this.closeDiagnostics.capture();
286
+ void this.connection.closed.then(() => { observeConnection({ layer: 'acp', initiator: 'acp.connection-closed', reason: 'ACP_CONNECTION_CLOSED', action: 'close' }); peer.close(); });
287
+ void peer.closed.then(() => { observeConnection({ layer: 'acp', initiator: 'acp.peer-closed', reason: 'ACP_PEER_CLOSED', action: 'close' }); this.connection.close(); });
271
288
  }
272
289
  }
290
+ /** A rejected request on an already-closed SDK never reaches this write.
291
+ * Once a transport write starts, failure remains delivery-unknown. */
292
+ observeDispatch(message) { if (record(message)?.method === 'session/prompt' && Object.hasOwn(message, 'id'))
293
+ this.dispatchedPrompts++; }
273
294
  observe(stream) {
274
295
  // Observe already-parsed public Stream messages: no NDJSON parsing, second
275
296
  // byte buffer, message-size policy, or transport fallback is introduced.
297
+ const observe = this.closeDiagnostics.capture();
276
298
  const readable = stream.readable.pipeThrough(new TransformStream({ transform: (message, controller) => {
277
299
  const raw = record(message);
278
300
  const params = record(raw?.params);
@@ -289,13 +311,13 @@ export class AcpSession {
289
311
  return;
290
312
  }
291
313
  controller.enqueue(message);
292
- } }));
314
+ }, flush: () => observe({ layer: 'acp', initiator: 'acp.read-eof', reason: 'ACP_STREAM_EOF', action: 'eof' }) }));
293
315
  return { readable, writable: stream.writable };
294
316
  }
295
317
  get id() { return this.sessionId; }
296
318
  get capabilities() { return this.negotiated; }
297
319
  get startMethod() { return this.method; }
298
- get checkpoint() { return this.ready && !this.busy && !this.updating && !this.selecting && this.sessionId ? { sessionId: this.sessionId, capabilities: recoveryCapabilities(this.negotiated), ...(this.configuration ? { modelConfiguration: { configOptions: JSON.parse(JSON.stringify(this.configuration.snapshot.configOptions)), ...(this.resolvedEvidence ? { resolvedEvidence: { ...this.resolvedEvidence } } : {}) } } : {}) } : undefined; }
320
+ get checkpoint() { return this.ready && !this.busy && !this.updating && !this.selecting && this.sessionId ? { sessionId: this.sessionId, capabilities: recoveryCapabilities(this.negotiated), ...(this.configuration ? { modelConfiguration: { configOptions: JSON.parse(JSON.stringify(this.configuration.snapshot.configOptions)), selectorRoles: { model: this.configuration.snapshot.modelConfigId, effort: this.configuration.snapshot.effortConfigId } } } : {}) } : undefined; }
299
321
  restore(cwd, saved, context, verifiedSameContext = false) {
300
322
  if (context.employeeRouting)
301
323
  throw new NeedError('EMPLOYEE_CHECKPOINT_STALE', '本机工具通道需要重新建立,旧审批没有代答。', '按原主机实例安全结束后新建本机会话');
@@ -311,13 +333,12 @@ export class AcpSession {
311
333
  // Old journals may lack new-only metadata; never manufacture it for them.
312
334
  if (this.codexNewSessionOnly && !verifiedSameContext)
313
335
  this.validateNewContext(this.systemPrompt, context.systemPrompt !== undefined);
336
+ // Legacy currentModelId/resolvedEvidence is not a per-turn setter ACK and
337
+ // never proves the concrete model. Only restore selectable config options.
314
338
  if (saved.modelConfiguration) {
315
- this.receiveConfiguration(saved.modelConfiguration.configOptions);
316
- this.resolvedEvidence = saved.modelConfiguration.resolvedEvidence;
317
- for (const update of saved.configUpdates ?? []) {
318
- this.resolvedEvidence = undefined;
339
+ this.receiveConfiguration(saved.modelConfiguration.configOptions, saved.modelConfiguration.selectorRoles);
340
+ for (const update of saved.configUpdates ?? [])
319
341
  this.receiveConfiguration(update);
320
- }
321
342
  }
322
343
  this.hooks.broker?.finishRecovery();
323
344
  this.detail({ kind: 'need', severity: 'info', code: 'ACP_CLIENT_REATTACHED', summary: '已接回原模型会话', payload: { sessionId: saved.sessionId } });
@@ -364,12 +385,12 @@ export class AcpSession {
364
385
  throw new NeedError('SESSION_ALREADY_STARTED', '此连接已用于建立会话,不能转用工作区或身份', '新的独立远端连接');
365
386
  this.startAttempted = true;
366
387
  this.employeeMeta = context?.employeeExecution;
367
- const abort = () => this.connection.close(new NeedError('ABORTED', '会话建立已取消', '重新连接主机'));
388
+ const abort = () => this.close({ layer: 'acp', initiator: 'acp.start-signal', reason: 'ABORTED', action: 'abort' }, new NeedError('ABORTED', '会话建立已取消', '重新连接主机'));
368
389
  signal?.throwIfAborted();
369
390
  signal?.addEventListener('abort', abort, { once: true });
370
391
  try {
371
392
  const hello = await this.stage('acp-initialize', () => this.connection.agent.request(acp.methods.agent.initialize, {
372
- protocolVersion: acp.PROTOCOL_VERSION, clientInfo: { name: '@ai-workbench/remote-agent', version: '0.8.13' },
393
+ protocolVersion: acp.PROTOCOL_VERSION, clientInfo: { name: '@ai-workbench/remote-agent', version: '0.8.14' },
373
394
  clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false, session: { notices: {} }, ...(this.employeeMeta ? { _meta: { awEmployeeExecution: { version: 1 } } } : {}) },
374
395
  }, { cancellationSignal: signal }));
375
396
  this.hooks.broker?.acknowledge(acp.methods.agent.initialize);
@@ -453,7 +474,7 @@ export class AcpSession {
453
474
  this.ready = false;
454
475
  this.creatingReplacement = true;
455
476
  this.pendingConfigurations.clear();
456
- const abort = () => this.connection.close(new NeedError('ABORTED', '新会话建立已取消,远端状态需确认', '管理员核对远端状态'));
477
+ const abort = () => this.close({ layer: 'acp', initiator: 'acp.new-context-signal', reason: 'ABORTED', action: 'abort' }, new NeedError('ABORTED', '新会话建立已取消,远端状态需确认', '管理员核对远端状态'));
457
478
  signal?.addEventListener('abort', abort, { once: true });
458
479
  try {
459
480
  const revision = this.resetConfiguration();
@@ -481,32 +502,34 @@ export class AcpSession {
481
502
  signal?.removeEventListener('abort', abort);
482
503
  }
483
504
  }
484
- async updateContext(context, signal) {
505
+ async updateContext(context, signal, force = false) {
485
506
  this.checkReporting();
486
507
  if (!this.ready || !this.sessionId || !this.cwd)
487
508
  throw new NeedError('SESSION_REQUIRED', '我需要先建立远端会话', 'ACP 会话');
488
509
  if (this.busy || this.updating || this.selecting)
489
510
  throw new NeedError('TURN_BUSY', '此远端会话正在处理上一轮或更新配置', '上一轮结束');
490
511
  const text = context.systemPrompt ?? '';
491
- if (text === this.systemPrompt)
512
+ if (!force && text === this.systemPrompt)
492
513
  return undefined;
493
514
  this.validateContext(context);
515
+ if (force && (!this.negotiated.sessionCapabilities?.systemPrompt || !this.negotiated.sessionCapabilities?.resume && !this.negotiated.loadSession))
516
+ throw new NeedError('SYSTEM_PROMPT_UPDATE_UNSUPPORTED', '远端没有可传送更新要求的公开恢复能力', 'session/resume 或 session/load 的 systemPrompt 能力');
494
517
  const resume = !!this.negotiated.sessionCapabilities?.resume;
495
518
  signal?.throwIfAborted();
496
519
  this.updating = true;
497
520
  this.ready = false;
498
- const abort = () => this.connection.close(new NeedError('ABORTED', '上下文更新已取消,原会话状态需确认', '重新连接原会话'));
521
+ const abort = () => this.close({ layer: 'acp', initiator: 'acp.update-context-signal', reason: 'ABORTED', action: 'abort' }, new NeedError('ABORTED', '上下文更新已取消,原会话状态需确认', '重新连接原会话'));
499
522
  signal?.addEventListener('abort', abort, { once: true });
500
523
  try {
501
524
  const method = resume ? acp.methods.agent.session.resume : acp.methods.agent.session.load;
502
525
  const params = { sessionId: this.sessionId, cwd: this.cwd, mcpServers: [], systemPrompt: { mode: 'append', content: [{ type: 'text', text }] } };
503
- const configRevision = this.resetConfiguration();
526
+ const configRevision = this.resetConfiguration(false);
504
527
  const result = await this.stage(resume ? 'acp-session-resume' : 'acp-session-load', () => this.connection.agent.request(method, params, { cancellationSignal: signal }));
505
528
  this.lifecycleConfiguration(result, configRevision);
506
529
  this.hooks.broker?.acknowledge(method);
507
530
  this.systemPrompt = text;
508
531
  this.ready = true;
509
- this.need({ code: 'SYSTEM_PROMPT_UPDATE_DEFERRED', message: '系统与开发者上下文变更已通过原会话恢复接口传送。Claude 恢复会话沿用首次系统提示,新要求在下次上下文整理后生效;不能据此认定本轮已立即生效。', requires: '远端实际应用新要求;Claude 须完成下一次上下文整理' });
532
+ this.detail({ kind: 'session', severity: 'warning', code: 'SYSTEM_PROMPT_UPDATE_DEFERRED', summary: '系统与开发者要求已通过原会话恢复接口传送;新要求在下次上下文整理后生效,本轮不能保证已立即采用。', payload: { reason: 'OFFICIAL_CONTEXT_UPDATE_DEFERRED' } });
510
533
  this.checkReporting();
511
534
  return method;
512
535
  }
@@ -542,6 +565,7 @@ export class AcpSession {
542
565
  let rejectAbort;
543
566
  const interrupted = new Promise((_resolve, reject) => { rejectAbort = reject; });
544
567
  const cancel = () => {
568
+ this.closeDiagnostics.record({ layer: 'acp', initiator: 'acp.prompt-cancel', reason: 'ABORTED', operation: 'session/prompt', action: 'abort' });
545
569
  rejectAbort?.(new NeedError('ABORTED', '已请求取消本轮,远端状态需确认', '确认主机取消状态'));
546
570
  void this.connection.agent.notify(acp.methods.agent.session.cancel, { sessionId: this.sessionId }).catch(() => {
547
571
  this.need({ code: 'CANCEL_UNCONFIRMED', message: '连接中断,无法确认主机已取消本轮', requires: '恢复连接以确认远端状态' });
@@ -573,7 +597,7 @@ export class AcpSession {
573
597
  this.turnToken = undefined;
574
598
  }
575
599
  }
576
- close() { this.connection.close(); }
600
+ close(cause = { layer: 'acp', initiator: 'acp.close', reason: 'ACP_CLOSE_REQUESTED', action: 'close' }, error) { this.closeDiagnostics.record({ ...cause, changes: cause.changes ?? [{ field: 'acp.close-requested', before: this.closeRequested, after: true }] }); this.closeRequested = true; this.connection?.close(error); }
577
601
  }
578
602
  export function acpNeed(error) {
579
603
  if (error instanceof NeedError)
@@ -6,6 +6,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
6
6
  }
7
7
  return path;
8
8
  };
9
+ import { CloseDiagnostics, closeCode, flushCloseDiagnostics } from "./close-diagnostics.js";
9
10
  import { BrokerTransport, brokerCommand, brokerId, legacyBrokerId, sshIo, controlJson } from "./broker.js";
10
11
  import { CODEX_AGENT } from "./agents.js";
11
12
  import { protectedOneShotLayout } from "./host-layout.js";
@@ -63,15 +64,24 @@ export function brokerFactory(get, _ask, configuration, provided) {
63
64
  const contextBoundScope = recoveryKey([...scopeParts, request.contextSha256]);
64
65
  // A Codex ACP process can own several immutable native sessions. D18's durable
65
66
  // pointer controls their context; the broker still binds every transport identity.
66
- const scope = agent.loginProvider === 'codex' ? recoveryKey([...scopeParts, undefined]) : contextBoundScope;
67
+ const scope = recoveryKey([...scopeParts, undefined]);
68
+ const previousContextScope = agent.loginProvider === 'claude' && request.previousContextSha256 ? recoveryKey([...scopeParts, request.previousContextSha256]) : contextBoundScope;
67
69
  const existing = request.retainProcess ? retained.get(key) : undefined;
68
70
  if (existing) {
69
71
  if (existing.scope !== scope)
70
72
  throw need('SESSION_CONTEXT_MISMATCH');
71
73
  if (existing.runtime.broker?.error)
72
74
  throw existing.runtime.broker.error;
75
+ if (request.requireFreshSession) {
76
+ if (existing.runtime.broker?.promptInFlight)
77
+ throw need('INPUT_DELIVERY_UNCONFIRMED');
78
+ await existing.runtime.dispose?.({ layer: 'broker', initiator: 'broker.fresh-session-required', reason: 'PRIVATE_RECOVERY_POINTER_ABSENT', action: 'dispose' });
79
+ return open(target, project, request);
80
+ }
73
81
  existing.update(request);
74
82
  existing.runtime.restartNotice = undefined;
83
+ existing.runtime.restartInputIds = undefined;
84
+ existing.runtime.freshSessionConfirmed = undefined;
75
85
  existing.runtime.recoveredSession = undefined;
76
86
  return existing.runtime;
77
87
  }
@@ -83,7 +93,7 @@ export function brokerFactory(get, _ask, configuration, provided) {
83
93
  if (!journal)
84
94
  throw need('RECOVERY_STORAGE_REQUIRED');
85
95
  let state = await journal.read();
86
- const legacyContextScope = !!state && state.scope !== scope && agent.loginProvider === 'codex' && state.scope === contextBoundScope;
96
+ const legacyContextScope = !!state && state.scope !== scope && state.scope === previousContextScope;
87
97
  if (state && state.scope !== scope && !legacyContextScope)
88
98
  throw need(agent.loginProvider === 'codex' ? 'RECOVERY_CONTEXT_POLICY_UNSUPPORTED' : 'SESSION_CONTEXT_MISMATCH');
89
99
  if (legacyContextScope && state?.phase !== 'clean')
@@ -106,9 +116,11 @@ export function brokerFactory(get, _ask, configuration, provided) {
106
116
  const report = async (code, extra = {}, cleanup = false) => { request.detailChannel?.record({ kind: 'need', severity: 'warning', code, summary: '远端会话恢复状态:' + code, payload: extra }); await request.detailChannel?.flush(); await validate(cleanup); };
107
117
  const list = async (cleanup = false) => timing.run('broker-list', async () => { const rows = controlJson(await io.run(command(['list']), cleanup ? undefined : request.signal)); if (!Array.isArray(rows) || rows.some(row => typeof row?.id !== 'string'))
108
118
  throw need('BROKER_PROTOCOL_INVALID'); return rows; });
119
+ const detailSink = request.detailChannel;
120
+ const closeDiagnostics = request.closeDiagnostics ?? new CloseDiagnostics(event => detailSink?.record(event));
109
121
  let rows = await list();
110
122
  const id = brokerId(request.sessionId, employeeScope ? agent.id + '/employee/' + employeeScope : agent.id), oldId = legacyBrokerId(request.sessionId, agent.id);
111
- let restarted = false;
123
+ let restarted = !!request.requireFreshSession;
112
124
  const attachArgs = (entry, after) => ['attach', entry.id, '--after', String(after), '--framed', ...(modern(entry) ? ['--leased', '--if-instance', entry.instanceId] : [])];
113
125
  const connectWithLease = async (transport, entry, cleanup = false) => {
114
126
  try {
@@ -143,13 +155,13 @@ export function brokerFactory(get, _ask, configuration, provided) {
143
155
  }
144
156
  };
145
157
  const claim = async (entry, cleanup = false) => {
146
- const probe = new BrokerTransport({ discardOutput: true, onNeed: n => request.detailChannel?.record({ kind: 'need', severity: 'warning', code: n.code, summary: '主机会话连接状态', payload: { brokerId: entry.id, instanceId: entry.instanceId ?? null } }), maxFrameBytes: settings.brokerMaxFrameBytes, initialSequence: entry.lastSequence, instanceId: entry.instanceId, leased: modern(entry), controlMaxBytes: entry.controlMaxBytes, attach: after => io.attach(command(attachArgs(entry, after))) });
158
+ const probe = new BrokerTransport({ observeClose: closeDiagnostics.child().capture(), discardOutput: true, onNeed: n => request.detailChannel?.record({ kind: 'need', severity: 'warning', code: n.code, summary: '主机会话连接状态', payload: { brokerId: entry.id, instanceId: entry.instanceId ?? null } }), maxFrameBytes: settings.brokerMaxFrameBytes, initialSequence: entry.lastSequence, instanceId: entry.instanceId, leased: modern(entry), controlMaxBytes: entry.controlMaxBytes, attach: after => io.attach(command(attachArgs(entry, after))) });
147
159
  try {
148
160
  await connectWithLease(probe, entry, cleanup);
149
161
  }
150
162
  catch (error) {
151
163
  if (!(entry.state === 'exited' && error instanceof NeedError && error.code === 'AGENT_EXITED')) {
152
- probe.close();
164
+ probe.close({ layer: 'broker', initiator: 'broker.probe-close', reason: 'BROKER_PROBE_FINISHED', action: 'close' });
153
165
  throw error;
154
166
  }
155
167
  }
@@ -183,7 +195,7 @@ export function brokerFactory(get, _ask, configuration, provided) {
183
195
  throw need('REMOTE_CLEANUP_UNCONFIRMED');
184
196
  }
185
197
  finally {
186
- probe.close();
198
+ probe.close({ layer: 'broker', initiator: 'broker.probe-close', reason: 'BROKER_PROBE_FINISHED', action: 'close' });
187
199
  }
188
200
  };
189
201
  const legacy = employeeScope ? undefined : rows.find(row => row.id === oldId);
@@ -192,13 +204,13 @@ export function brokerFactory(get, _ask, configuration, provided) {
192
204
  let entry = rows.find(row => row.id === id);
193
205
  if (entry)
194
206
  validEntry(entry);
195
- if (legacyContextScope && !entry)
207
+ if (legacyContextScope && !entry && !request.requireFreshSession)
196
208
  throw need('RECOVERY_CONTEXT_POLICY_UNSUPPORTED');
197
209
  if (legacy && !entry)
198
210
  restarted = true;
199
211
  let transport;
200
212
  let resume;
201
- const build = (entry, after, recovery) => new BrokerTransport({ onNeed: n => request.detailChannel?.record({ kind: 'need', severity: 'warning', code: n.code, summary: '主机会话连接状态', payload: { brokerId: entry.id, instanceId: entry.instanceId } }), maxFrameBytes: settings.brokerMaxFrameBytes, maxMessageBytes: request.acpMaxMessageBytes ?? settings.brokerMaxFrameBytes, initialSequence: after, instanceId: entry.instanceId, leased: true, controlMaxBytes: entry.controlMaxBytes, beforeInput: async () => { await validate(); if (state?.phase !== 'dirty' || state.instanceId !== entry.instanceId) {
213
+ const build = (entry, after, recovery) => new BrokerTransport({ observeClose: closeDiagnostics.capture(), onNeed: n => request.detailChannel?.record({ kind: 'need', severity: 'warning', code: n.code, summary: '主机会话连接状态', payload: { brokerId: entry.id, instanceId: entry.instanceId } }), maxFrameBytes: settings.brokerMaxFrameBytes, maxMessageBytes: request.acpMaxMessageBytes ?? settings.brokerMaxFrameBytes, initialSequence: after, instanceId: entry.instanceId, leased: true, controlMaxBytes: entry.controlMaxBytes, beforeInput: async () => { await validate(); if (state?.phase !== 'dirty' || state.instanceId !== entry.instanceId) {
202
214
  const dirty = { version: 1, scope, brokerId: id, instanceId: entry.instanceId, pid: entry.pid, phase: 'dirty' };
203
215
  await journal.write(dirty);
204
216
  state = dirty;
@@ -225,6 +237,15 @@ export function brokerFactory(get, _ask, configuration, provided) {
225
237
  if (entry) {
226
238
  if (!modern(entry))
227
239
  throw need('BROKER_CAPABILITY_REQUIRED');
240
+ if (request.requireFreshSession) {
241
+ if (!state || !['clean', 'ended'].includes(state.phase) || state.instanceId !== entry.instanceId)
242
+ throw need('INPUT_DELIVERY_UNCONFIRMED');
243
+ await stop(entry, 'PRIVATE_RECOVERY_POINTER_ABSENT');
244
+ restarted = true;
245
+ entry = undefined;
246
+ }
247
+ }
248
+ if (entry) {
228
249
  const clean = !employeeScope && state?.phase === 'clean' && state.instanceId === entry.instanceId && state.acp?.sessionId === request.resumeSessionId && state.checkpointId === request.recoveryCheckpointId && state.cursor >= entry.firstSequence - 1 && state.cursor <= entry.lastSequence && entry.state === 'running' && (legacyContextScope ? await request.verifyRecovery?.() === true : await request.verifyRecovery?.() !== false);
229
250
  if (legacyContextScope && !clean)
230
251
  throw need('RECOVERY_CONTEXT_POLICY_UNSUPPORTED');
@@ -237,14 +258,14 @@ export function brokerFactory(get, _ask, configuration, provided) {
237
258
  await validate();
238
259
  if (legacyContextScope) {
239
260
  const migrated = { ...state, scope };
240
- await report('CODEX_RECOVERY_SCOPE_MIGRATED', { oldScope: state.scope, newScope: scope, sessionId: state.acp.sessionId, checkpointId: state.checkpointId, instanceId: entry.instanceId, cursor: state.cursor });
261
+ await report(agent.loginProvider === 'codex' ? 'CODEX_RECOVERY_SCOPE_MIGRATED' : 'CLAUDE_RECOVERY_SCOPE_MIGRATED', { oldScope: state.scope, newScope: scope, sessionId: state.acp.sessionId, checkpointId: state.checkpointId, instanceId: entry.instanceId, cursor: state.cursor });
241
262
  await journal.write(migrated);
242
263
  state = migrated;
243
264
  }
244
265
  resume = state.acp;
245
266
  }
246
267
  catch (error) {
247
- transport.close();
268
+ transport.close({ layer: 'broker', initiator: 'broker.replay-failure', reason: closeCode(error, 'RECOVERY_UNCONFIRMED'), action: 'close' });
248
269
  if (legacyContextScope || error instanceof NeedError && ['ATTACH_BUSY', 'SESSION_IN_USE', 'SESSION_INSTANCE_MISMATCH', 'ABORTED'].includes(error.code))
249
270
  throw error;
250
271
  await report('SESSION_REPLAY_UNRECOVERABLE', { brokerId: id, instanceId: entry.instanceId, reason: error instanceof NeedError ? error.code : 'UNKNOWN_FAILURE', recordedCursor: state?.cursor ?? null, firstSequence: entry.firstSequence, lastSequence: entry.lastSequence });
@@ -274,7 +295,7 @@ export function brokerFactory(get, _ask, configuration, provided) {
274
295
  validEntry(entry);
275
296
  transport = build(entry, 0);
276
297
  const opening = transport;
277
- const aborted = () => opening.fail(need('ABORTED'));
298
+ const aborted = () => opening.fail(need('ABORTED'), { layer: 'broker', initiator: 'broker.open-signal', reason: 'ABORTED', action: 'abort' });
278
299
  request.signal?.addEventListener('abort', aborted, { once: true });
279
300
  try {
280
301
  request.signal?.throwIfAborted();
@@ -284,7 +305,7 @@ export function brokerFactory(get, _ask, configuration, provided) {
284
305
  catch (error) {
285
306
  if (!(error instanceof NeedError && ['ATTACH_BUSY', 'SESSION_IN_USE', 'SESSION_INSTANCE_MISMATCH'].includes(error.code)))
286
307
  await stop(entry, 'SESSION_OPEN_FAILED', opening, true, true);
287
- opening.close();
308
+ opening.close({ layer: 'broker', initiator: 'broker.open-failure', reason: closeCode(error), action: 'close' });
288
309
  throw error;
289
310
  }
290
311
  finally {
@@ -294,8 +315,8 @@ export function brokerFactory(get, _ask, configuration, provided) {
294
315
  const ownedEntry = entry, ownedTransport = transport;
295
316
  let disposed = false;
296
317
  let disposal;
297
- const runtime = { identity: identity(target), cwd: project.path, broker: ownedTransport, acpTransport: ownedTransport,
298
- ...(resume ? { recoveredSession: resume } : {}), ...(restarted ? { restartNotice: '上次会话无法接回,已新建会话;本次从你的这条请求继续。', restartInputIds: request.currentInputIds } : {}),
318
+ const runtime = { closeDiagnostics, identity: identity(target), cwd: project.path, broker: ownedTransport, acpTransport: ownedTransport,
319
+ ...(resume ? { recoveredSession: resume } : {}), ...(restarted ? { restartNotice: '上次会话无法接回,已新建会话;本次从你的这条请求继续。', restartInputIds: request.currentInputIds } : {}), ...(request.requireFreshSession ? { freshSessionConfirmed: true } : {}),
299
320
  saveCheckpoint: async (checkpointId, acp) => { await validate(); const saved = await ownedTransport.commitCheckpoint(async (cursor) => { await validate(); const clean = { version: 1, phase: 'clean', scope, brokerId: id, instanceId: ownedEntry.instanceId, pid: ownedEntry.pid, cursor, checkpointId, acp }; try {
300
321
  await journal.write(clean);
301
322
  state = clean;
@@ -305,24 +326,29 @@ export function brokerFactory(get, _ask, configuration, provided) {
305
326
  throw error;
306
327
  } }); if (!saved)
307
328
  await report('SESSION_CHECKPOINT_NOT_CLEAN', { brokerId: id, instanceId: ownedEntry.instanceId }); return saved; },
308
- detach: async () => { ownedTransport.close(); active.delete(key); retained.delete(key); },
309
- dispose: () => disposal ??= (async () => { if (disposed)
310
- return; try {
329
+ detach: async (cause = { layer: 'broker', initiator: 'broker.runtime-detach', reason: 'BROKER_DETACH', action: 'detach' }) => { const sink = request.detailChannel; closeDiagnostics.record(cause); ownedTransport.close(cause); active.delete(key); retained.delete(key); await flushCloseDiagnostics(closeDiagnostics, sink ? () => sink.flush() : undefined); },
330
+ dispose: (cause = { layer: 'broker', initiator: 'broker.runtime-dispose', reason: 'BROKER_DISPOSE', action: 'dispose' }) => { const sink = request.detailChannel; closeDiagnostics.record(cause); return disposal ??= (async () => { if (disposed)
331
+ return; let primary; try {
311
332
  await stop(ownedEntry, 'SESSION_ENDED', ownedTransport, true);
312
333
  await journal.write({ version: 1, phase: 'ended', scope, brokerId: id, instanceId: ownedEntry.instanceId, pid: ownedEntry.pid });
313
334
  disposed = true;
314
335
  }
336
+ catch (error) {
337
+ primary = { error };
338
+ }
315
339
  finally {
316
- ownedTransport.close();
340
+ ownedTransport.close({ layer: 'broker', initiator: 'broker.dispose-finally', reason: 'BROKER_DISPOSE_CLEANUP', action: 'close' });
317
341
  active.delete(key);
318
342
  retained.delete(key);
319
- } })(), };
343
+ } await flushCloseDiagnostics(closeDiagnostics, sink ? () => sink.flush() : undefined, primary); })(); },
344
+ };
320
345
  if (employeeScope) {
321
346
  runtime.saveCheckpoint = undefined;
322
347
  runtime.detach = runtime.dispose;
323
348
  }
324
349
  if (request.retainProcess)
325
- retained.set(key, { runtime, scope, update: next => { request = next; timing = next.timing ?? new StageTiming(); } });
350
+ retained.set(key, { runtime, scope, update: next => { const sink = next.detailChannel; if (sink)
351
+ closeDiagnostics.bind(event => sink.record(event)); request = next; timing = next.timing ?? new StageTiming(); } });
326
352
  return runtime;
327
353
  }
328
354
  catch (error) {