@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.
- package/README.md +19 -4
- package/dist/acp.d.ts +25 -0
- package/dist/acp.js +159 -17
- package/dist/approval-label.d.ts +6 -0
- package/dist/approval-label.js +45 -0
- package/dist/auth-reason.d.ts +1 -0
- package/dist/auth-reason.js +27 -0
- package/dist/broker-runtime.d.ts +10 -0
- package/dist/broker-runtime.js +302 -0
- package/dist/broker.d.ts +29 -6
- package/dist/broker.js +141 -141
- package/dist/connect-contract.d.ts +2 -1
- package/dist/conversation-details.d.ts +1 -0
- package/dist/conversation-details.js +58 -8
- package/dist/index.d.ts +31 -14
- package/dist/index.js +248 -42
- package/dist/input.d.ts +4 -0
- package/dist/input.js +4 -1
- package/dist/login-diagnostic.d.ts +38 -0
- package/dist/login-diagnostic.js +29 -0
- package/dist/model-catalog.d.ts +55 -0
- package/dist/model-catalog.js +254 -0
- package/dist/model-configuration.d.ts +51 -0
- package/dist/model-configuration.js +212 -0
- package/dist/presentation.js +4 -0
- package/dist/protocol-boundary.d.ts +13 -0
- package/dist/protocol-boundary.js +49 -0
- package/dist/provider.d.ts +21 -3
- package/dist/provider.js +135 -41
- package/dist/readiness.js +7 -5
- package/dist/recovery-checkpoint.d.ts +6 -0
- package/dist/recovery-checkpoint.js +71 -0
- package/dist/recovery.d.ts +34 -0
- package/dist/recovery.js +52 -0
- package/dist/remote-details.d.ts +17 -1
- package/dist/remote-details.js +137 -6
- package/dist/remote.d.ts +3 -0
- package/dist/remote.js +2 -0
- package/dist/rpc-namespace.d.ts +3 -0
- package/dist/rpc-namespace.js +50 -0
- package/dist/stage-timing.d.ts +27 -0
- package/dist/stage-timing.js +39 -0
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
在 DSH「插件 → 添加插件」填 `@ai-workbench/remote-agent@0.8.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
307
|
-
|
|
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>;
|