@ai-workbench/remote-agent 0.8.1 → 0.8.5

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.
Files changed (43) hide show
  1. package/README.md +19 -4
  2. package/dist/acp.d.ts +25 -0
  3. package/dist/acp.js +159 -17
  4. package/dist/approval-label.d.ts +6 -0
  5. package/dist/approval-label.js +45 -0
  6. package/dist/auth-reason.d.ts +1 -0
  7. package/dist/auth-reason.js +27 -0
  8. package/dist/broker-runtime.d.ts +10 -0
  9. package/dist/broker-runtime.js +302 -0
  10. package/dist/broker.d.ts +29 -6
  11. package/dist/broker.js +141 -141
  12. package/dist/connect-contract.d.ts +2 -1
  13. package/dist/conversation-details.d.ts +1 -0
  14. package/dist/conversation-details.js +58 -8
  15. package/dist/index.d.ts +31 -14
  16. package/dist/index.js +248 -42
  17. package/dist/input.d.ts +4 -0
  18. package/dist/input.js +4 -1
  19. package/dist/login-diagnostic.d.ts +38 -0
  20. package/dist/login-diagnostic.js +29 -0
  21. package/dist/model-catalog.d.ts +55 -0
  22. package/dist/model-catalog.js +254 -0
  23. package/dist/model-configuration.d.ts +51 -0
  24. package/dist/model-configuration.js +212 -0
  25. package/dist/presentation.js +4 -0
  26. package/dist/protocol-boundary.d.ts +13 -0
  27. package/dist/protocol-boundary.js +49 -0
  28. package/dist/provider.d.ts +21 -3
  29. package/dist/provider.js +135 -41
  30. package/dist/readiness.js +7 -5
  31. package/dist/recovery-checkpoint.d.ts +6 -0
  32. package/dist/recovery-checkpoint.js +71 -0
  33. package/dist/recovery.d.ts +34 -0
  34. package/dist/recovery.js +52 -0
  35. package/dist/remote-details.d.ts +17 -1
  36. package/dist/remote-details.js +137 -6
  37. package/dist/remote.d.ts +3 -0
  38. package/dist/remote.js +2 -0
  39. package/dist/rpc-namespace.d.ts +3 -0
  40. package/dist/rpc-namespace.js +50 -0
  41. package/dist/stage-timing.d.ts +27 -0
  42. package/dist/stage-timing.js +39 -0
  43. package/package.json +4 -4
package/README.md CHANGED
@@ -1,14 +1,14 @@
1
- 在 DSH「插件 → 添加插件」填 `@ai-workbench/remote-agent@0.8.1`(由编排发布后可用),或运行 `dsh plugin --profile desktop add @ai-workbench/remote-agent@0.8.1`。
1
+ 在 DSH「插件 → 添加插件」填 `@ai-workbench/remote-agent@0.8.5`(由编排发布后可用);交付前可由官方插件管理安装固定 tgz。
2
2
 
3
3
  # 远程ACP插件
4
4
 
5
5
  一个插件提供 **Claude(远程主机)** 和 **Codex(远程主机)**,通过员工主机上的官方CLI运行。Codex保留单次模式;Claude固定会话模式。只用官方模型列表、插件管理和配置入口,不提供自制面板、问答或登录按钮。登录和原生工作区绑定唯一归连接插件L。
6
6
 
7
- 本轮为公开npm发布准备版0.8.0,包名采用`@ai-workbench/remote-agent`以涵盖两个品牌;发布前自测从固定tgz绝对路径安装。X仅准备,不执行实际发布,编排统一发布后才可按包名安装。正式L0.7.0已交付;开发fixture逐字节保留其固定包,公开类型与SSH导出从该包消费。运行时仍要求L的`getContractInfo()`同时返回API0.7.0、implemented、native-local-v1;旧L包、draft.3或只有版本号的候选会明确报缺项。依赖范围为`~0.7.0`,L由官方插件管理单独安装,不打进X。组件验证不代表真实开通、模型或leo E通过;HanaMesh另验。
7
+ 当前为公开npm发布准备版0.8.5,包名采用`@ai-workbench/remote-agent`以涵盖两个品牌;发布前自测从固定tgz绝对路径安装。X仅准备,不执行实际发布,编排统一发布后才可按包名安装。开发fixture逐字节保留L0.7.13固定包,公开类型与SSH导出从该包消费。运行时仍要求L的`getContractInfo()`同时返回API0.7.0、implemented、native-local-v1;旧L包、draft.3或只有版本号的候选会明确报缺项。依赖范围为`~0.7.13`,L由官方插件管理单独安装,不打进X。组件验证不代表真实开通、模型或leo E通过;HanaMesh另验。
8
8
 
9
9
  ## 员工路径
10
10
 
11
- 从官方插件管理安装,照常使用默认工作区/会话/模型入口。原生添加或选择本机工作区后,首次实际使用远程模型由L建立/绑定同名远端工作区。本地文件不自动上传,X不猜目录、不选首项。首次说明使用官方普通回复;先经官方会话提交、持久化并读回核对,才向L确认。持久记录不等于员工已看见,GUI另验;日志丢失后的未知claim由L保留,不宣称端到端恰好一次。
11
+ 从官方插件管理安装,照常使用默认工作区/会话/模型入口。L0.7.13沿唯一“远程主机连接”设置节记录文件来源;本统一版X只继续L已明确确认的legacy同名远端映射。旧映射需在L逐工作区确认,本地或新主机位置仍为pending,分别由L明确停止为WORKSPACE_EXECUTION_UNAVAILABLE/WORKSPACE_LOCATION_PROVIDER_REQUIRED,不退回同名目录或主机代跑。D20新执行位置尚未接通。本地文件不自动上传,X不猜目录、不选首项。首次说明使用官方普通回复;先经官方会话提交、持久化并读回核对,才向L确认。持久记录不等于员工已看见,GUI另验;日志丢失后的未知claim由L保留,不宣称端到端恰好一次。
12
12
 
13
13
  未登录时两个模型均回复:**需要登录,请到设置 → 远程主机连接 点登录**。无L或缺正式能力时直接说明缺项,不让员工填写主机、用户、路径或密钥。
14
14
 
@@ -56,4 +56,19 @@ Codex单次模式首版仅文本,不转接中途审批;图片或系统/开
56
56
 
57
57
  D19(0.8.1):相邻正文增量合成连续文本块,工作区首次说明合入真实回复,按官方持久记录核对后才回报“已记入可核对回复”。这不代表界面已经显示。
58
58
 
59
- 技术详情由 L 0.7.9 的唯一“远程主机连接”设置节展示,正文只放模型文本。命令目录保留完整描述;未知事件和工具输入输出保留字段、JSON类型及处理结果,未获准原文明确脱敏,数字用量可核对。没有 `remoteConversationDetails:1` 时明确提示“技术详情暂不可用”,本轮不发送模型请求。X 在官方当前 profile 的 `ai-workbench-remote-agent/diagnostics/` 保存私有诊断日志(目录700、文件600、落盘同步);写入/归属/顺序/容量失败中止本轮,不自动重发、换代重投、过期或淘汰。结果未知时只读核对原事件完整回执,未找到仍保持未知。X本轮无新增设置/阈值;L的单事件容量和分页设置在L设置节可见。
59
+ 技术详情由 L 0.7.11 的唯一“远程主机连接”设置节展示,正文只放模型文本。命令目录保留完整描述;未知事件和工具输入输出保留字段、JSON类型及处理结果,未获准原文明确脱敏,数字用量可核对。没有 `remoteConversationDetails:1` 时明确提示“技术详情暂不可用”,本轮不发送模型请求。X 在官方当前 profile 的 `ai-workbench-remote-agent/diagnostics/` 保存私有诊断日志(目录700、文件600、落盘同步);写入/归属/顺序/容量失败中止本轮,不自动重发、换代重投、过期或淘汰。结果未知时只读核对原事件完整回执,未找到仍保持未知。X本轮无新增设置/阈值;L的单事件容量和分页设置在L设置节可见。
60
+
61
+
62
+ 0.8.2 登录检查原交付需要 L `~0.7.11`(本统一版peer为`~0.7.13`),失败来源及固定分支保留到 L“登录前技术详情”(`loginDiagnostics:1`),员工正文保持原文。按 L 明确的本品牌 queued/checking/authorizing 状态订阅等待;不以全局checking猜测在途,不新增刷新、轮询、超时或重试。每轮ensureReady仍是合同规定的显式检查,L的新可信结果可以替代旧检查,较早返回不得覆盖它。
63
+
64
+ L安全规范的officialReason原串完整保留;旧/不支持格式明确记OFFICIAL_REASON_UNSUPPORTED与字节数/SHA,秘密原文不保存。L0.7.11的LOGIN_INSTANCE_MISMATCH记录通过observedLoginInstanceId与原始login.instanceId保存实际观测ID,事件及connection仍绑定当前可信ID/代次;L技术详情和X私有日志均保留真实双ID,不归一、不清空快照。复合未知官方原因以固定summary完整保存OFFICIAL_REASON_UNSUPPORTED及bytes/SHA,不交原文。X私有原快照先同步落盘,再追加L;ACK未知只读核实原事件,跨代不重写/重发。此版本的工程验证不替代leo员工路径复验,真实故障根因尚未证实。
65
+
66
+ 0.8.3 会话恢复:正常关闭客户端保留原broker;重开仅在私有clean检查点、完整ACP消息边界、原broker UUID及官方最新持久回复一致时接续。否则记录原因、独占并确认结束旧会话后新建,正文说明一次;旧审批不代答,按官方当前步骤保留本批全部输入,不重发历史未确认输入。S0.4.10 的实际冻结controlMaxBytes用于分块,心跳/等待严格消费主机公开策略与remainingLeaseMs;没有新增X超时或重试设置。编排00:05决定:0.4.10以前的x/a仅为测试会话,X不接入、不代答也不结束它们,直接新建r会话并说明;旧进程由编排安装S0.4.10时一次性整理。重开续接只对S0.4.10新broker承诺。
67
+
68
+ D22阶段耗时进入技术详情;D23文件审批用中文动作,原工具名和会话ID进入技术详情。恢复记录沿官方profile的ai-workbench-remote-agent/recovery,目录700/文件600/fsync,无过期或淘汰。产品设置仍为上列六项,无新增默认/阈值。含真实S进程的工程测试按文件串行运行,避免多个持久化/编译测试同时争用磁盘;测试观察期限和所有断言不变,工程证据不等于leo验收。
69
+
70
+ D21(0.8.5):两个品牌的官方模型菜单从 L 的 `modelCatalogTarget:1` / `getModelCatalogTarget` 取得当前可信员工目标,再只读调用 S 的 `aw-model-catalog --provider codex|claude`。目录按 host-user 作用域展示完整模型与逐模型推理等级,保留远端标识原值;失败显示“无法获取模型列表:原因”,不登录、不创建项目、不返回替代模型。L 必须宣告该正式能力,S 必须安装保护入口;这两项未就绪时不能据组件测试声称菜单可用。
71
+
72
+ 每轮先核对目录代次和所选模型,再经 ACP 逐项配置模型及推理等级,收到完整配置确认后才发送输入。Claude 别名必须有当前会话的具体型号证据;没有就明确拒绝。真实回复追加所选值和远端确认值,并在恢复标识与技术详情保留,确认配置不等于验证推理效果。连接/身份/目录变化清除旧确认;远端清理待定或结果未知时阻止派发,不自动清理重试或另开替代会话。Codex 单次模式当前不能确认此选择,会在发送前明确拒绝。新增设置 0;本轮工程证据、真实只读链路与产品未验项见 `docs/evidence/2026-10-22-X2-D21/`。
73
+
74
+ 0.8.5统一包对齐L `~0.7.13`。D8模型目录双事件长期监听保留;同epoch的recovery提示/失败消息变化不触发执行状态失效,真实实例/epoch/phase/可信目标/登录变化仍失效。完整D9两品牌正文/状态/就绪门呈现按编排00:50允许的范围记下一版,本版不宣称已消费完成;L的connectionMessages.administratorAfterMs仍只由L管理,X新增设置0。
package/dist/acp.d.ts CHANGED
@@ -2,9 +2,13 @@ import * as stable from '@agentclientprotocol/sdk';
2
2
  import * as acp from '@agentclientprotocol/sdk/unstable';
3
3
  import { NeedError, type Need } from './remote.ts';
4
4
  import type { BrokerTransport } from './broker.ts';
5
+ import { StageTiming } from './stage-timing.ts';
6
+ import { type AcpRecovery } from './recovery.ts';
5
7
  import { type SanitizedObservation } from './conversation-details.ts';
8
+ import { type ModelConfigurationSnapshot } from './model-configuration.ts';
6
9
  export type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable';
7
10
  export interface AcpHooks {
11
+ timing?: StageTiming;
8
12
  onUpdate?: (notification: acp.SessionNotification) => void;
9
13
  onResponse?: (response: acp.PromptResponse) => void;
10
14
  approve?: (request: acp.RequestPermissionRequest, signal?: AbortSignal) => Promise<ApprovalOutcome>;
@@ -31,8 +35,27 @@ export declare class AcpSession {
31
35
  private updating;
32
36
  private cwd?;
33
37
  private systemPrompt;
38
+ private configuration?;
39
+ private configRevision;
40
+ private selected?;
41
+ private selecting;
42
+ private resolvedEvidence?;
43
+ private pendingConfigurations;
44
+ get modelConfiguration(): ModelConfigurationSnapshot | undefined;
45
+ get resolvedModelEvidence(): Readonly<{
46
+ modelValue: string;
47
+ resolvedModel: string;
48
+ }> | undefined;
49
+ get models(): readonly import("./model-configuration.ts").ModelCatalogValue[];
50
+ private receiveConfiguration;
51
+ private resetConfiguration;
52
+ private lifecycleConfiguration;
53
+ selectModel(model: string, effort?: string, signal?: AbortSignal): Promise<ModelConfigurationSnapshot>;
54
+ assertModelSelection(model: string, effort?: string): void;
34
55
  private reports;
35
56
  private reportingFailure;
57
+ private readonly timing;
58
+ private stage;
36
59
  get diagnostics(): readonly Readonly<SanitizedObservation>[];
37
60
  private detail;
38
61
  private need;
@@ -42,6 +65,8 @@ export declare class AcpSession {
42
65
  get id(): string | undefined;
43
66
  get capabilities(): Readonly<acp.AgentCapabilities>;
44
67
  get startMethod(): AcpStartMethod | undefined;
68
+ get checkpoint(): AcpRecovery | undefined;
69
+ restore(cwd: string, saved: AcpRecovery, context: AcpStartContext): void;
45
70
  validatePrompt(prompt: acp.ContentBlock[]): void;
46
71
  validateContext(context: AcpStartContext): void;
47
72
  setHooks(hooks: AcpHooks): void;
package/dist/acp.js CHANGED
@@ -1,6 +1,10 @@
1
1
  import * as acp from '@agentclientprotocol/sdk/unstable';
2
2
  import { NeedError } from "./remote.js";
3
+ import { StageTiming } from "./stage-timing.js";
4
+ import { isolateRpcIds } from "./rpc-namespace.js";
5
+ import { recoveryCapabilities } from "./recovery.js";
3
6
  import { sanitizeObservation } from "./conversation-details.js";
7
+ import { ModelConfiguration, ModelConfigurationError } from "./model-configuration.js";
4
8
  /** ACP v1 with explicitly negotiated SDK 2.0 lifecycle/context extensions. */
5
9
  export class AcpSession {
6
10
  connection;
@@ -16,8 +20,86 @@ export class AcpSession {
16
20
  updating = false;
17
21
  cwd;
18
22
  systemPrompt = '';
23
+ configuration;
24
+ configRevision = 0;
25
+ selected;
26
+ selecting = false;
27
+ resolvedEvidence;
28
+ pendingConfigurations = new Map();
29
+ get modelConfiguration() { return this.configuration?.snapshot; }
30
+ get resolvedModelEvidence() { return this.resolvedEvidence; }
31
+ get models() { if (!this.configuration)
32
+ throw new ModelConfigurationError('远端没有提供完整配置列表'); return this.configuration.models; }
33
+ receiveConfiguration(options) {
34
+ this.configRevision++;
35
+ if (this.configuration)
36
+ this.configuration.update(options);
37
+ else
38
+ this.configuration = new ModelConfiguration(options, async (configId, value) => {
39
+ const result = await this.connection.agent.request(acp.methods.agent.session.setConfigOption, { sessionId: this.sessionId, configId, value }, { cancellationSignal: this.turnSignal });
40
+ if (this.resolvedEvidence && (this.configuration?.snapshot.model !== this.resolvedEvidence.modelValue || (configId === this.configuration?.snapshot.modelConfigId && value !== this.resolvedEvidence.modelValue)))
41
+ this.resolvedEvidence = undefined;
42
+ this.hooks.broker?.acknowledge(acp.methods.agent.session.setConfigOption);
43
+ return result;
44
+ });
45
+ if (this.resolvedEvidence && this.configuration.snapshot.model !== this.resolvedEvidence.modelValue)
46
+ this.resolvedEvidence = undefined;
47
+ }
48
+ resetConfiguration() {
49
+ this.selected = undefined;
50
+ this.resolvedEvidence = undefined;
51
+ if (this.configuration) {
52
+ try {
53
+ this.receiveConfiguration([]);
54
+ }
55
+ catch { /* Invalidated until this lifecycle operation supplies new evidence. */ }
56
+ }
57
+ return this.configRevision;
58
+ }
59
+ lifecycleConfiguration(result, revision) {
60
+ const pending = this.pendingConfigurations.get(this.sessionId);
61
+ this.pendingConfigurations.clear();
62
+ if (pending)
63
+ this.receiveConfiguration(pending);
64
+ // A notification observed during the operation is newer than its in-flight response.
65
+ else if (this.configRevision === revision && result.configOptions)
66
+ this.receiveConfiguration(result.configOptions);
67
+ const model = this.configuration?.snapshot.model;
68
+ if (!pending && this.configRevision === revision + 1 && model && typeof result.models?.currentModelId === 'string' && result.models.currentModelId)
69
+ this.resolvedEvidence = { modelValue: model, resolvedModel: result.models.currentModelId };
70
+ }
71
+ async selectModel(model, effort, signal) {
72
+ if (!this.ready || !this.sessionId)
73
+ throw new ModelConfigurationError('远端会话尚未建立');
74
+ if (this.busy || this.updating || this.selecting)
75
+ throw new ModelConfigurationError('上一项会话操作尚未完成');
76
+ signal?.throwIfAborted();
77
+ this.checkReporting();
78
+ this.selecting = true;
79
+ this.selected = { model, ...(effort === undefined ? {} : { effort }) };
80
+ this.turnSignal = signal;
81
+ try {
82
+ if (!this.configuration)
83
+ throw new ModelConfigurationError('远端没有提供完整配置列表');
84
+ const state = await this.configuration.select(model, effort);
85
+ signal?.throwIfAborted();
86
+ this.checkReporting();
87
+ return state;
88
+ }
89
+ finally {
90
+ this.turnSignal = undefined;
91
+ this.selecting = false;
92
+ }
93
+ }
94
+ assertModelSelection(model, effort) {
95
+ if (!this.configuration)
96
+ throw new ModelConfigurationError('远端没有提供完整配置列表');
97
+ this.configuration.assertSelection(model, effort);
98
+ }
19
99
  reports = [];
20
100
  reportingFailure;
101
+ timing = new StageTiming();
102
+ stage(phase, operation) { return (this.hooks.timing ?? this.timing).run(phase, operation); }
21
103
  get diagnostics() { return this.reports.map(entry => sanitizeObservation(entry)); }
22
104
  detail(observation) {
23
105
  const safe = sanitizeObservation(observation);
@@ -38,6 +120,7 @@ export class AcpSession {
38
120
  throw new NeedError('CONVERSATION_DETAILS_FAILED', '技术详情未能记录;本轮未继续发送', '可用的技术详情记录渠道'); }
39
121
  constructor(transport, hooks) {
40
122
  this.hooks = hooks;
123
+ this.timing.bind(detail => this.detail(detail));
41
124
  const app = acp.client({ name: '@ai-workbench/remote-agent' })
42
125
  .onNotification(acp.methods.client.session.update, ({ params: notification }) => {
43
126
  if (this.sessionId && notification.sessionId !== this.sessionId) {
@@ -45,6 +128,18 @@ export class AcpSession {
45
128
  return;
46
129
  }
47
130
  const meta = record(notification._meta);
131
+ if (notification.update.sessionUpdate === 'config_option_update') {
132
+ this.resolvedEvidence = undefined;
133
+ if (!this.sessionId)
134
+ this.pendingConfigurations.set(notification.sessionId, notification.update.configOptions);
135
+ else
136
+ try {
137
+ this.receiveConfiguration(notification.update.configOptions);
138
+ }
139
+ catch (error) {
140
+ this.detail({ kind: 'session', severity: 'action-required', code: 'MODEL_CONFIGURATION_INVALID', summary: '远端模型配置无效,本轮不可发送', method: 'session/update', payload: { reason: error instanceof ModelConfigurationError ? error.message : '配置无法解析' } });
141
+ }
142
+ }
48
143
  if (meta && Object.keys(meta).length)
49
144
  this.detail({ kind: 'extension', severity: 'info', code: 'ACP_NOTIFICATION_META', summary: '远端通知扩展', method: 'session/update', payload: notification._meta });
50
145
  if (this.busy) {
@@ -61,6 +156,7 @@ export class AcpSession {
61
156
  return { outcome: { outcome: 'cancelled' } };
62
157
  this.hooks.broker?.acknowledge(acp.methods.agent.session.prompt);
63
158
  const toolName = request.toolCall.name ?? record(request.toolCall._meta?.claudeCode)?.toolName;
159
+ this.detail({ kind: 'tool', severity: 'info', code: 'ACP_PERMISSION_REQUEST', summary: '远端工具审批标识', method: 'session/request_permission', payload: { sessionId: request.sessionId, toolCall: { toolCallId: request.toolCall.toolCallId, ...(typeof toolName === 'string' ? { name: toolName } : {}) } } });
64
160
  if (toolName === 'AskUserQuestion') {
65
161
  this.need({ code: 'INTERACTION_UNSUPPORTED', message: '首版不支持:远端提问;已取消,未提交答案', requires: '可承载此提问的官方交互接口' });
66
162
  return { outcome: { outcome: 'cancelled' } };
@@ -136,14 +232,14 @@ export class AcpSession {
136
232
  this.detail({ kind: 'extension', severity: 'warning', code: 'ACP_UNKNOWN_NOTIFICATION', summary: '收到未知远端扩展通知,已保留技术记录', method, payload: params });
137
233
  });
138
234
  if ('readable' in transport && 'writable' in transport)
139
- this.connection = app.connect(this.observe(transport));
235
+ this.connection = app.connect(isolateRpcIds(this.observe(transport)));
140
236
  else {
141
237
  // Stable and unstable apps use distinct private symbols. Bridge only their
142
238
  // public stream interfaces so either SDK entry point remains supported.
143
239
  const outward = new TransformStream();
144
240
  const inward = new TransformStream();
145
241
  const peer = transport.connect({ readable: outward.readable, writable: inward.writable });
146
- this.connection = app.connect(this.observe({ readable: inward.readable, writable: outward.writable }));
242
+ this.connection = app.connect(isolateRpcIds(this.observe({ readable: inward.readable, writable: outward.writable })));
147
243
  void this.connection.closed.then(() => peer.close());
148
244
  void peer.closed.then(() => this.connection.close());
149
245
  }
@@ -155,6 +251,13 @@ export class AcpSession {
155
251
  const raw = record(message);
156
252
  const params = record(raw?.params);
157
253
  const update = record(params?.update);
254
+ if (raw)
255
+ this.hooks.broker?.processedMessage(raw);
256
+ if (typeof raw?.method === 'string' && Object.hasOwn(raw, 'id') && this.hooks.broker && !this.hooks.broker.promptInFlight) {
257
+ const error = new NeedError('RECOVERY_UNSAFE_OUTPUT', '主机会话有不属于当前输入的旧审批,未自动回答', '安全结束旧会话并新建');
258
+ this.hooks.broker.fail(error);
259
+ throw error;
260
+ }
158
261
  if (raw?.method === acp.methods.client.session.update && typeof update?.sessionUpdate === 'string' && !knownUpdates.has(update.sessionUpdate)) {
159
262
  this.detail({ kind: 'extension', severity: 'warning', code: 'ACP_UNKNOWN_UPDATE', summary: '收到未知远端会话事件,已保留结构', method: 'session/update', payload: params });
160
263
  return;
@@ -166,6 +269,27 @@ export class AcpSession {
166
269
  get id() { return this.sessionId; }
167
270
  get capabilities() { return this.negotiated; }
168
271
  get startMethod() { return this.method; }
272
+ 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; }
273
+ restore(cwd, saved, context) {
274
+ if (this.startAttempted)
275
+ throw new NeedError('SESSION_ALREADY_STARTED', '此客户端已建立会话', '新的客户端连接');
276
+ this.startAttempted = true;
277
+ this.sessionId = saved.sessionId;
278
+ this.negotiated = recoveryCapabilities(saved.capabilities);
279
+ this.cwd = cwd;
280
+ this.systemPrompt = context.systemPrompt ?? '';
281
+ this.ready = true;
282
+ if (saved.modelConfiguration) {
283
+ this.receiveConfiguration(saved.modelConfiguration.configOptions);
284
+ this.resolvedEvidence = saved.modelConfiguration.resolvedEvidence;
285
+ for (const update of saved.configUpdates ?? []) {
286
+ this.resolvedEvidence = undefined;
287
+ this.receiveConfiguration(update);
288
+ }
289
+ }
290
+ this.hooks.broker?.finishRecovery();
291
+ this.detail({ kind: 'need', severity: 'info', code: 'ACP_CLIENT_REATTACHED', summary: '已接回原模型会话', payload: { sessionId: saved.sessionId } });
292
+ }
169
293
  validatePrompt(prompt) {
170
294
  const capability = this.negotiated.promptCapabilities;
171
295
  for (const block of prompt)
@@ -181,8 +305,8 @@ export class AcpSession {
181
305
  throw new NeedError('SYSTEM_PROMPT_UPDATE_UNSUPPORTED', '远端没有可更新原会话上下文的恢复或加载能力', '支持带 systemPrompt 的 session/resume 或 session/load');
182
306
  }
183
307
  setHooks(hooks) {
184
- if (this.busy || this.updating)
185
- throw new NeedError('TURN_BUSY', '上一轮或上下文更新结束前不能更换会话钩子', '上一轮结束');
308
+ if (this.busy || this.updating || this.selecting)
309
+ throw new NeedError('TURN_BUSY', '上一轮或配置更新结束前不能更换会话钩子', '上一轮结束');
186
310
  this.hooks = hooks;
187
311
  }
188
312
  get closed() { return this.connection.closed; }
@@ -194,10 +318,10 @@ export class AcpSession {
194
318
  signal?.throwIfAborted();
195
319
  signal?.addEventListener('abort', abort, { once: true });
196
320
  try {
197
- const hello = await this.connection.agent.request(acp.methods.agent.initialize, {
198
- protocolVersion: acp.PROTOCOL_VERSION, clientInfo: { name: '@ai-workbench/remote-agent', version: '0.8.1' },
321
+ const hello = await this.stage('acp-initialize', () => this.connection.agent.request(acp.methods.agent.initialize, {
322
+ protocolVersion: acp.PROTOCOL_VERSION, clientInfo: { name: '@ai-workbench/remote-agent', version: '0.8.5' },
199
323
  clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false, session: { notices: {} } },
200
- }, { cancellationSignal: signal });
324
+ }, { cancellationSignal: signal }));
201
325
  this.hooks.broker?.acknowledge(acp.methods.agent.initialize);
202
326
  if (hello.protocolVersion !== acp.PROTOCOL_VERSION)
203
327
  throw new NeedError('ACP_VERSION_MISMATCH', 'ACP 协议版本不兼容', '支持 ACP v1 的远端 agent');
@@ -208,22 +332,26 @@ export class AcpSession {
208
332
  throw new NeedError('SYSTEM_PROMPT_UNSUPPORTED', '远端未声明系统与开发者上下文能力,不能丢弃这些要求继续', '支持 systemPrompt 的公开 ACP 能力');
209
333
  }
210
334
  const params = { cwd, mcpServers: [], ...(systemPrompt ? { systemPrompt: { mode: 'append', content: [{ type: 'text', text: systemPrompt }] } } : {}) };
335
+ const configRevision = this.resetConfiguration();
211
336
  if (resumeId) {
212
337
  this.sessionId = resumeId;
213
338
  if (this.negotiated.sessionCapabilities?.resume) {
214
- await this.connection.agent.request(acp.methods.agent.session.resume, { ...params, sessionId: resumeId }, { cancellationSignal: signal });
339
+ const result = await this.stage('acp-session-resume', () => this.connection.agent.request(acp.methods.agent.session.resume, { ...params, sessionId: resumeId }, { cancellationSignal: signal }));
340
+ this.lifecycleConfiguration(result, configRevision);
215
341
  this.method = acp.methods.agent.session.resume;
216
342
  }
217
343
  else {
218
344
  if (!this.negotiated.loadSession)
219
345
  throw new NeedError('RESUME_UNSUPPORTED', '远端未声明会话恢复或加载能力,不能恢复原会话', '支持 session/resume 或 session/load 的 agent');
220
- await this.connection.agent.request(acp.methods.agent.session.load, { ...params, sessionId: resumeId }, { cancellationSignal: signal });
346
+ const result = await this.stage('acp-session-load', () => this.connection.agent.request(acp.methods.agent.session.load, { ...params, sessionId: resumeId }, { cancellationSignal: signal }));
347
+ this.lifecycleConfiguration(result, configRevision);
221
348
  this.method = acp.methods.agent.session.load;
222
349
  }
223
350
  }
224
351
  else {
225
- const result = await this.connection.agent.request(acp.methods.agent.session.new, params, { cancellationSignal: signal });
352
+ const result = await this.stage('acp-session-new', () => this.connection.agent.request(acp.methods.agent.session.new, params, { cancellationSignal: signal }));
226
353
  this.sessionId = result.sessionId;
354
+ this.lifecycleConfiguration(result, configRevision);
227
355
  this.method = acp.methods.agent.session.new;
228
356
  }
229
357
  this.hooks.broker?.acknowledge(this.method);
@@ -236,6 +364,10 @@ export class AcpSession {
236
364
  catch (error) {
237
365
  if (signal?.aborted)
238
366
  throw new NeedError('ABORTED', '会话建立已取消;尚无本轮输出', '重新连接主机');
367
+ if (error instanceof ModelConfigurationError) {
368
+ this.checkReporting();
369
+ throw error;
370
+ }
239
371
  this.checkReporting();
240
372
  throw acpNeed(error);
241
373
  }
@@ -247,8 +379,8 @@ export class AcpSession {
247
379
  this.checkReporting();
248
380
  if (!this.ready || !this.sessionId || !this.cwd)
249
381
  throw new NeedError('SESSION_REQUIRED', '我需要先建立远端会话', 'ACP 会话');
250
- if (this.busy || this.updating)
251
- throw new NeedError('TURN_BUSY', '此远端会话正在处理上一轮或更新上下文', '上一轮结束');
382
+ if (this.busy || this.updating || this.selecting)
383
+ throw new NeedError('TURN_BUSY', '此远端会话正在处理上一轮或更新配置', '上一轮结束');
252
384
  const text = context.systemPrompt ?? '';
253
385
  if (text === this.systemPrompt)
254
386
  return undefined;
@@ -262,7 +394,9 @@ export class AcpSession {
262
394
  try {
263
395
  const method = resume ? acp.methods.agent.session.resume : acp.methods.agent.session.load;
264
396
  const params = { sessionId: this.sessionId, cwd: this.cwd, mcpServers: [], systemPrompt: { mode: 'append', content: [{ type: 'text', text }] } };
265
- await this.connection.agent.request(method, params, { cancellationSignal: signal });
397
+ const configRevision = this.resetConfiguration();
398
+ const result = await this.stage(resume ? 'acp-session-resume' : 'acp-session-load', () => this.connection.agent.request(method, params, { cancellationSignal: signal }));
399
+ this.lifecycleConfiguration(result, configRevision);
266
400
  this.hooks.broker?.acknowledge(method);
267
401
  this.systemPrompt = text;
268
402
  this.ready = true;
@@ -273,6 +407,10 @@ export class AcpSession {
273
407
  catch (error) {
274
408
  if (signal?.aborted)
275
409
  throw new NeedError('ABORTED', '上下文更新已取消;原会话状态需确认', '重新连接原会话');
410
+ if (error instanceof ModelConfigurationError) {
411
+ this.checkReporting();
412
+ throw error;
413
+ }
276
414
  this.checkReporting();
277
415
  throw acpNeed(error);
278
416
  }
@@ -284,12 +422,14 @@ export class AcpSession {
284
422
  async prompt(input, signal) {
285
423
  if (!this.ready || !this.sessionId)
286
424
  throw new NeedError('SESSION_REQUIRED', '我需要先建立远端会话', 'ACP 会话');
287
- if (this.busy || this.updating)
288
- throw new NeedError('TURN_BUSY', '此远端会话正在处理上一轮', '上一轮结束');
425
+ if (this.busy || this.updating || this.selecting)
426
+ throw new NeedError('TURN_BUSY', '此远端会话正在处理上一轮或更新配置', '上一轮结束');
289
427
  signal?.throwIfAborted();
290
428
  const prompt = typeof input === 'string' ? [{ type: 'text', text: input }] : input;
291
429
  this.validatePrompt(prompt);
292
430
  this.checkReporting();
431
+ if (this.selected)
432
+ this.assertModelSelection(this.selected.model, this.selected.effort);
293
433
  this.busy = true;
294
434
  this.turnSignal = signal;
295
435
  this.turnToken = Symbol('turn');
@@ -303,8 +443,10 @@ export class AcpSession {
303
443
  };
304
444
  signal?.addEventListener('abort', cancel, { once: true });
305
445
  try {
306
- const request = this.connection.agent.request(acp.methods.agent.session.prompt, { sessionId: this.sessionId, prompt }, { cancellationSignal: signal });
307
- const result = await (signal ? Promise.race([request, interrupted]) : request);
446
+ const result = await this.stage('acp-prompt', () => {
447
+ const request = this.connection.agent.request(acp.methods.agent.session.prompt, { sessionId: this.sessionId, prompt }, { cancellationSignal: signal });
448
+ return signal ? Promise.race([request, interrupted]) : request;
449
+ });
308
450
  this.hooks.broker?.acknowledge(acp.methods.agent.session.prompt);
309
451
  signal?.throwIfAborted();
310
452
  this.hooks.broker?.completePrompt();
@@ -0,0 +1,6 @@
1
+ import type { RequestPermissionRequest } from '@agentclientprotocol/sdk/unstable';
2
+ /** ACP diff.oldText=null means a new file. Write alone may overwrite a file. */
3
+ export declare function approvalLabel(request: RequestPermissionRequest): {
4
+ toolName: string;
5
+ reason: string;
6
+ } | undefined;
@@ -0,0 +1,45 @@
1
+ import { sanitizeDisplayLabel } from "./conversation-details.js";
2
+ function display(value) {
3
+ const safe = sanitizeDisplayLabel(sanitizeDisplayLabel(value).replace(/[\u202a-\u202e\u2066-\u2069]/g, '')).trim();
4
+ // Redacted instructions/targets cannot support an informed approval.
5
+ return safe && !safe.includes('[已脱敏:') ? safe : undefined;
6
+ }
7
+ /** ACP diff.oldText=null means a new file. Write alone may overwrite a file. */
8
+ export function approvalLabel(request) {
9
+ const tool = request.toolCall;
10
+ const diffs = tool.content?.filter(item => item.type === 'diff') ?? [];
11
+ const name = tool.name ?? /^(Write|Edit|MultiEdit) /.exec(tool.title ?? '')?.[1];
12
+ const fileAction = diffs.length > 0 || name === 'Write' || name === 'Edit' || name === 'MultiEdit';
13
+ const paths = diffs.length ? diffs.map(diff => diff.path) : tool.locations?.map(location => location.path) ?? [];
14
+ let title = tool.title ?? '';
15
+ if (name && title.startsWith(name + ' '))
16
+ title = title.slice(name.length + 1);
17
+ else if (title === name || title === tool.toolCallId)
18
+ title = '';
19
+ if (fileAction) {
20
+ const action = diffs.length && diffs.every(diff => diff.oldText === null) ? '新建文件'
21
+ : (diffs.length && diffs.every(diff => typeof diff.oldText === 'string')) || name === 'Edit' || name === 'MultiEdit' ? '修改文件' : '写入文件';
22
+ // Observed Write/Edit title is a display hint, never used as a host path.
23
+ if (!paths.length && name && ['Write', 'Edit', 'MultiEdit'].includes(name) && title)
24
+ paths.push(title);
25
+ const unique = [...new Set(paths)];
26
+ const safe = unique.map(display);
27
+ if (!safe.length || safe.some(path => path === undefined) || new Set(safe).size !== safe.length)
28
+ return;
29
+ const names = safe.map(path => path.split(/[\\/]/).at(-1)?.trim());
30
+ if (names.some(name => !name))
31
+ return;
32
+ const files = safe.map((path, index) => names.filter(name => name === names[index]).length > 1 ? path : names[index]);
33
+ return { toolName: action, reason: action + ' ' + files.join('、') };
34
+ }
35
+ const action = { read: '读取文件', edit: '修改文件', delete: '删除文件', move: '移动文件', search: '搜索', execute: '执行主机命令', think: '分析', fetch: '获取内容', other: '远程操作' }[tool.kind ?? ''] ?? '远程操作';
36
+ const safePaths = paths.map(display);
37
+ if (safePaths.some(path => path === undefined))
38
+ return;
39
+ const description = title ? display(title) : undefined;
40
+ if (title && !description)
41
+ return;
42
+ if (!safePaths.length && !description)
43
+ return;
44
+ return { toolName: action, reason: action + (safePaths.length ? ' ' + safePaths.join('、') : '') + (description ? ':' + description : '') };
45
+ }
@@ -0,0 +1 @@
1
+ export declare function validOfficialReason(value: unknown): value is string;
@@ -0,0 +1,27 @@
1
+ /** L 0.7.10 officialReason v1 consumer validation; preserve canonical bytes only. */
2
+ const markers = ['AW_REMOTE_COMMAND_KILL_FAILED', 'AUTH_STATUS_JSON_INVALID:SyntaxError', 'Permission denied', 'Host key verification failed', 'Connection refused', 'Connection closed', 'command not found', 'No such file or directory'];
3
+ const names = ['Error', 'TypeError', 'SyntaxError', 'RangeError', 'AbortError', 'DOMException', 'UnknownError'];
4
+ const codes = ['ENOENT', 'EPERM', 'ESRCH', 'EACCES', 'EINVAL', 'EPIPE', 'ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'ABORT_ERR', 'UNKNOWN'];
5
+ const stages = ['ssh-status', 'ssh-result', 'remote-frame', 'auth-status', 'connect-check-login', 'integration-pre-login', 'process-spawn', 'process-stdin', 'process-output', 'process-runner'];
6
+ const reasons = ['CANCELLED', 'SSH_EXIT_NONZERO', 'SSH_STDERR_NONEMPTY', 'FRAME_TRUNCATED', 'FRAME_JSON_INVALID', 'FRAME_OBJECT_INVALID', 'FRAME_VERSION_INVALID', 'FRAME_NONCE_INVALID', 'FRAME_AFTER_END', 'FRAME_KEYS_INVALID', 'FRAME_START_MISSING', 'FRAME_BASE64_INVALID', 'REMOTE_COMMAND_SIGNAL', 'FRAME_END_EXIT_INVALID', 'FRAME_SEQUENCE_OR_VALUE_INVALID', 'FRAME_END_MISSING', 'STATUS_ARGUMENT_INVALID', 'STATUS_EXECUTION_FAILED', 'STATUS_JSON_INVALID', 'STATUS_OBJECT_INVALID', 'STATUS_KEYS_INVALID', 'STATUS_VERSION_INVALID', 'STATUS_PROVIDER_INVALID', 'STATUS_EXIT_MISMATCH', 'STATUS_LOGGED_IN_INVALID', 'STATUS_STDERR_NONEMPTY', 'STATUS_EXIT_INVALID', 'STATUS_EXCEPTION', 'STATUS_SUPERSEDED', 'SSH_PORT_MISSING', 'STDIN_FAILED', 'SPAWN_FAILED', 'OUTPUT_LIMIT_EXCEEDED', 'PROCESS_SIGNAL'];
7
+ const object = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
8
+ const integer = (v) => Number.isSafeInteger(v) && Number(v) >= 0;
9
+ const member = (list, v) => typeof v === 'string' && list.includes(v);
10
+ function summary(v) {
11
+ return object(v) && Object.keys(v).every(k => ['bytes', 'sha256', 'markers', 'cleanupErrors'].includes(k)) && integer(v.bytes) && typeof v.sha256 === 'string' && /^[a-f0-9]{64}$/.test(v.sha256) && Array.isArray(v.markers) && v.markers.every(m => member(markers, m)) && (v.cleanupErrors === undefined || Array.isArray(v.cleanupErrors) && v.cleanupErrors.every(e => object(e) && Object.keys(e).sort().join(',') === 'errorCode,errorName' && member(['Error', 'TypeError', 'RangeError', 'UnknownError'], e.errorName) && member(['EPERM', 'EACCES', 'EINVAL', 'UNKNOWN'], e.errorCode)));
12
+ }
13
+ export function validOfficialReason(value) {
14
+ if (typeof value !== 'string')
15
+ return false;
16
+ let v;
17
+ try {
18
+ v = JSON.parse(value);
19
+ }
20
+ catch {
21
+ return false;
22
+ }
23
+ if (!object(v) || JSON.stringify(v) !== value || v.version !== 1 || !member(stages, v.stage) || !member(reasons, v.reason))
24
+ return false;
25
+ const fields = { version: x => x === 1, stage: x => member(stages, x), reason: x => member(reasons, x), errorName: x => member(names, x), errorCode: x => member(codes, x), exitCode: x => x === null || typeof x === 'number' && Number.isFinite(x) && Number.isInteger(x), signal: x => member(['SIGTERM', 'SIGKILL', 'SIGABRT', 'SIGHUP', 'SIGINT', 'SIGSEGV', 'OTHER'], x), index: integer, unexpectedCount: integer, maxOutputBytes: x => integer(x) && Number(x) > 0, observedBytes: integer, stderr: summary, output: summary, missing: x => Array.isArray(x) && x.every(k => member(['nonce', 'protocolVersion', 'type', 'data', 'exitCode', 'signal', 'loggedIn', 'provider'], k)), loggedInType: x => member(['null', 'undefined', 'object', 'boolean', 'number', 'string', 'function', 'symbol', 'bigint'], x), exitCodeType: x => member(['undefined', 'object', 'boolean', 'number', 'string', 'function', 'symbol', 'bigint'], x) };
26
+ return Object.entries(v).every(([key, item]) => Object.hasOwn(fields, key) && fields[key](item));
27
+ }
@@ -0,0 +1,10 @@
1
+ import { type SshArgv, type BrokerIo, type BrokerSettings } from './broker.ts';
2
+ import { type ConnectApi, type ConnectTarget, type Project, type Runtime, type RuntimeRequest } from './provider.ts';
3
+ import type { Interaction } from './interaction.ts';
4
+ import { type RecoveryJournal } from './recovery.ts';
5
+ /** Test ports are explicit; deployed code always supplies the official-profile journal. */
6
+ export declare function brokerFactory(get: () => ConnectApi | undefined, _ask: Interaction | undefined, configuration: BrokerSettings | (() => BrokerSettings), provided?: {
7
+ argv?: SshArgv;
8
+ io?: (target: ConnectTarget) => BrokerIo;
9
+ journal?: (key: string) => RecoveryJournal;
10
+ }): (target: ConnectTarget, project: Project, initialRequest: RuntimeRequest) => Promise<Runtime>;