@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
@@ -0,0 +1,254 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ import { spawn } from 'node:child_process';
10
+ import { LlmError, ReasoningEffortId } from '@deepseek-ai/dsh-llm';
11
+ import { sameTarget, validateTarget } from "./provider.js";
12
+ import { ModelConfigurationError } from "./model-configuration.js";
13
+ import { sanitizeObservation } from "./conversation-details.js";
14
+ const object = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
15
+ const text = (value) => typeof value === 'string' && value.trim().length > 0;
16
+ function fail(reason) { throw new LlmError('无法获取模型列表:' + reason, 'MODEL_CATALOG_UNAVAILABLE'); }
17
+ function freeze(value) { if (value && typeof value === 'object') {
18
+ for (const item of Object.values(value))
19
+ freeze(item);
20
+ Object.freeze(value);
21
+ } return value; }
22
+ export function parseHostCatalog(input, provider, username) {
23
+ let wire;
24
+ try {
25
+ wire = JSON.parse(input);
26
+ }
27
+ catch {
28
+ fail('主机目录响应不是有效 JSON');
29
+ }
30
+ if (!object(wire) || wire.version !== 1 || wire.purpose !== 'model-catalog' || wire.scope !== 'host-user' || wire.provider !== provider)
31
+ fail('主机目录版本、品牌或作用域不匹配');
32
+ if (wire.ok === false) {
33
+ const error = object(wire.error) ? wire.error : undefined;
34
+ const code = typeof error?.code === 'string' && /^[A-Z][A-Z0-9_]*$/.test(error.code) ? error.code : 'PROTOCOL_INVALID';
35
+ const reason = text(error?.message) ? sanitizeObservation({ kind: 'session', severity: 'warning', code, summary: error.message, payload: {} }).summary : '主机未提供失败说明';
36
+ fail('主机目录查询失败(' + code + '):' + reason);
37
+ }
38
+ if (wire.ok !== true || wire.permissionProbe !== false || !object(wire.subject) || wire.subject.username !== username || !Number.isSafeInteger(wire.subject.uid) || !text(wire.observedAt) || !object(wire.source) || !object(wire.settings) || !Array.isArray(wire.models))
39
+ fail('主机目录缺少完整元数据');
40
+ const ids = new Set(), values = new Set();
41
+ for (const model of wire.models) {
42
+ if (!object(model) || !text(model.id) || !text(model.value) || !text(model.name) || (model.resolvedModel !== null && !text(model.resolvedModel)) || !object(model.source) || !object(model.reasoning))
43
+ fail('主机模型条目不完整');
44
+ if (provider === 'codex' && model.resolvedModel !== model.value)
45
+ fail('Codex目录具体型号与模型值不一致');
46
+ if (ids.has(model.id) || values.has(model.value))
47
+ fail('主机目录含重复模型标识或值');
48
+ ids.add(model.id);
49
+ values.add(model.value);
50
+ const r = model.reasoning;
51
+ if (!Array.isArray(r.options) || typeof r.defaultKnown !== 'boolean' || (r.defaultValue !== null && !text(r.defaultValue)) || (!r.defaultKnown && r.defaultValue !== null))
52
+ fail('主机推理等级或默认值无效');
53
+ const efforts = new Set();
54
+ for (const effort of r.options) {
55
+ if (!object(effort) || !text(effort.value) || !text(effort.name) || efforts.has(effort.value))
56
+ fail('主机推理等级条目无效或重复');
57
+ efforts.add(effort.value);
58
+ }
59
+ if (r.defaultKnown && (r.defaultValue === null || !efforts.has(r.defaultValue)))
60
+ fail('主机默认推理等级缺少对应选项');
61
+ }
62
+ if (!wire.models.length)
63
+ fail('主机返回的完整模型目录为空');
64
+ return freeze(structuredClone(wire.models));
65
+ }
66
+ /** Only the public L sshArgs function chooses SSH trust and transport arguments. */
67
+ export const runCatalog = async (target, command, signal, maxBytes) => {
68
+ let argv;
69
+ try {
70
+ const moduleName = '@ai-workbench/connect';
71
+ const l = await import(__rewriteRelativeImportExtension(moduleName));
72
+ if (typeof l.sshArgs !== 'function')
73
+ fail('远程主机连接组件缺少可信 SSH 能力');
74
+ argv = l.sshArgs(target);
75
+ }
76
+ catch (error) {
77
+ if (error instanceof LlmError)
78
+ throw error;
79
+ fail('远程主机连接组件缺少可信 SSH 能力');
80
+ }
81
+ signal.throwIfAborted();
82
+ return new Promise((resolve, reject) => {
83
+ const child = spawn('ssh', ['-n', ...argv, command], { stdio: ['ignore', 'pipe', 'pipe'] });
84
+ const parts = [];
85
+ let total = 0, stderr = 0;
86
+ let failure;
87
+ const abort = () => { failure = new ModelConfigurationError('连接已改变或目录查询已取消'); child.kill('SIGTERM'); };
88
+ signal.addEventListener('abort', abort, { once: true });
89
+ const receive = (buffer, output) => { if (failure)
90
+ return; total += buffer.length; if (total > maxBytes) {
91
+ failure = new ModelConfigurationError('目录响应超过 acpMaxMessageBytes,未截断返回');
92
+ child.kill('SIGTERM');
93
+ return;
94
+ } if (output)
95
+ parts.push(buffer);
96
+ else
97
+ stderr += buffer.length; };
98
+ child.stdout.on('data', (b) => receive(b, true));
99
+ child.stderr.on('data', (b) => receive(b, false));
100
+ child.on('error', () => { failure = new ModelConfigurationError('可信 SSH 进程启动失败'); });
101
+ child.on('close', code => {
102
+ signal.removeEventListener('abort', abort);
103
+ if (failure) {
104
+ reject(failure);
105
+ return;
106
+ }
107
+ let output;
108
+ try {
109
+ output = new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(parts));
110
+ }
111
+ catch {
112
+ reject(new ModelConfigurationError('主机目录响应不是有效 UTF-8,未替换模型值'));
113
+ return;
114
+ }
115
+ // S publishes its typed failure JSON on stdout even on nonzero exit.
116
+ if (code !== 0) {
117
+ try {
118
+ const wire = JSON.parse(output);
119
+ if (wire?.ok === false) {
120
+ resolve(output);
121
+ return;
122
+ }
123
+ }
124
+ catch { }
125
+ reject(new ModelConfigurationError('可信 SSH 或主机目录命令失败(退出码 ' + String(code) + ')'));
126
+ return;
127
+ }
128
+ if (stderr) {
129
+ reject(new ModelConfigurationError('目录命令返回了非预期诊断输出'));
130
+ return;
131
+ }
132
+ resolve(output);
133
+ });
134
+ if (signal.aborted)
135
+ abort();
136
+ });
137
+ };
138
+ /** No cache: every query binds to L's current object, host-user and generation. */
139
+ export class ModelCatalog {
140
+ get;
141
+ maxBytes;
142
+ run;
143
+ changed;
144
+ api;
145
+ unsubscribe;
146
+ signature;
147
+ revision = 0;
148
+ disposed = false;
149
+ initialized = false;
150
+ active = new Set();
151
+ constructor(get, maxBytes, run = runCatalog, changed = () => { }) {
152
+ this.get = get;
153
+ this.maxBytes = maxBytes;
154
+ this.run = run;
155
+ this.changed = changed;
156
+ this.refresh();
157
+ }
158
+ refresh() {
159
+ const api = this.get();
160
+ const state = api?.getConnectionState?.();
161
+ const info = api?.getContractInfo?.();
162
+ const targets = info?.modelCatalogTarget === 1 && api?.getModelCatalogTarget ? ['codex', 'claude'].map(brand => api.getModelCatalogTarget(brand)) : undefined;
163
+ const login = api?.getLoginPromptState?.();
164
+ // L's same-epoch recovery wording is presentation metadata, not an execution change.
165
+ const executionState = state && { instanceId: state.instanceId, epoch: state.epoch, phase: state.phase, needCode: state.need?.code };
166
+ const executionTargets = targets?.map(result => result.ok ? result : { ok: false, code: result.error.code });
167
+ const signature = JSON.stringify({ state: executionState, capability: info?.modelCatalogTarget, targets: executionTargets, logins: login?.logins, authorizing: login?.phase === 'authorizing' });
168
+ const switched = api !== this.api;
169
+ const changed = this.initialized && (switched || signature !== this.signature);
170
+ this.initialized = true;
171
+ if (switched) {
172
+ this.unsubscribe?.();
173
+ this.unsubscribe = undefined;
174
+ this.api = api;
175
+ const off = [];
176
+ if (typeof api?.onConnectionState === 'function')
177
+ off.push(api.onConnectionState(() => this.refresh()));
178
+ if (typeof api?.onLoginPromptState === 'function')
179
+ off.push(api.onLoginPromptState(() => this.refresh()));
180
+ this.unsubscribe = () => { for (const stop of off)
181
+ stop(); };
182
+ }
183
+ this.signature = signature;
184
+ if (changed) {
185
+ this.revision++;
186
+ for (const controller of this.active)
187
+ controller.abort();
188
+ this.active.clear();
189
+ this.changed();
190
+ }
191
+ }
192
+ trusted(agent, api) {
193
+ const info = api.getContractInfo?.();
194
+ if (info?.modelCatalogTarget !== 1 || typeof api.getModelCatalogTarget !== 'function')
195
+ fail('远程主机连接组件尚未提供模型目录目标能力');
196
+ const state = api.getConnectionState();
197
+ if (state.phase !== 'connected' || !text(state.instanceId) || !Number.isSafeInteger(state.epoch) || state.epoch < 0)
198
+ fail('当前没有已验证的远程主机连接');
199
+ const result = api.getModelCatalogTarget(agent.loginProvider);
200
+ if (!result.ok)
201
+ fail(result.error.message);
202
+ const target = result.value;
203
+ if (target.version !== 1 || target.purpose !== 'model-catalog' || target.scope !== 'host-user' || target.provider !== agent.loginProvider || target.instanceId !== state.instanceId || target.epoch !== state.epoch)
204
+ fail('模型目录目标与当前连接或品牌不匹配');
205
+ try {
206
+ validateTarget(target.target);
207
+ }
208
+ catch {
209
+ fail('模型目录的可信 SSH 目标不完整');
210
+ }
211
+ return structuredClone(target);
212
+ }
213
+ async read(agent, signal) {
214
+ let controller;
215
+ try {
216
+ if (this.disposed)
217
+ fail('远程模型插件已关闭');
218
+ this.refresh();
219
+ const api = this.get();
220
+ if (!api)
221
+ fail('远程主机连接组件没有装好');
222
+ const target = freeze(this.trusted(agent, api)), revision = this.revision;
223
+ const max = this.maxBytes();
224
+ if (!Number.isSafeInteger(max) || max < 1)
225
+ fail('acpMaxMessageBytes 设置无效');
226
+ controller = new AbortController();
227
+ this.active.add(controller);
228
+ const combined = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
229
+ const assertCurrent = () => { if (combined.aborted || this.disposed || this.get() !== api || revision !== this.revision)
230
+ fail('连接已改变,原模型目录已失效'); const current = this.trusted(agent, api); if (current.instanceId !== target.instanceId || current.epoch !== target.epoch || current.provider !== target.provider || !sameTarget(current.target, target.target))
231
+ fail('可信目标或连接代次已改变,原模型目录已失效'); };
232
+ assertCurrent();
233
+ const output = await this.run(target.target, '/usr/local/aw-host/bin/aw-model-catalog --provider ' + agent.loginProvider, combined, max);
234
+ assertCurrent();
235
+ const models = parseHostCatalog(output, agent.loginProvider, target.target.username);
236
+ const own = controller;
237
+ return { target, models, signal: combined, assertCurrent, release: () => this.active.delete(own) };
238
+ }
239
+ catch (error) {
240
+ if (controller)
241
+ this.active.delete(controller);
242
+ if (error instanceof LlmError)
243
+ throw error;
244
+ if (error instanceof ModelConfigurationError)
245
+ fail(error.message.replace(/^无法获取模型列表:/, ''));
246
+ fail('目录查询失败,根因未查明');
247
+ }
248
+ }
249
+ dispose() { this.disposed = true; this.unsubscribe?.(); for (const controller of this.active)
250
+ controller.abort(); this.active.clear(); }
251
+ }
252
+ export function modelInfo(provider, model) {
253
+ return { provider, id: model.id, name: model.name, inputModalities: ['text', 'image'], reasoning: { efforts: model.reasoning.options.map(option => ({ id: ReasoningEffortId(option.value), name: option.name })), ...(model.reasoning.defaultKnown && model.reasoning.defaultValue !== null ? { defaultEffort: ReasoningEffortId(model.reasoning.defaultValue) } : {}) }, catalog: model };
254
+ }
@@ -0,0 +1,51 @@
1
+ import type { SessionConfigOption } from '@agentclientprotocol/sdk/unstable';
2
+ export type DeepReadonly<T> = T extends readonly unknown[] ? {
3
+ readonly [K in keyof T]: DeepReadonly<T[K]>;
4
+ } : T extends object ? {
5
+ readonly [K in keyof T]: DeepReadonly<T[K]>;
6
+ } : T;
7
+ export interface ModelCatalogValue {
8
+ readonly value: string;
9
+ readonly name: string;
10
+ readonly description?: string | null;
11
+ }
12
+ export interface ModelConfigurationSnapshot {
13
+ readonly configOptions: DeepReadonly<SessionConfigOption[]>;
14
+ readonly models: readonly ModelCatalogValue[];
15
+ readonly modelConfigId?: string;
16
+ readonly effortConfigId?: string;
17
+ readonly model?: string;
18
+ readonly effort?: string;
19
+ readonly effortAvailable: boolean;
20
+ /** Acknowledges the selected values, not the availability of every catalog entry. */
21
+ readonly confirmed: boolean;
22
+ readonly failure?: Readonly<{
23
+ code: 'MODEL_CATALOG_UNAVAILABLE';
24
+ message: string;
25
+ }>;
26
+ }
27
+ export type ModelConfigurationSetter = (configId: string, value: string) => Promise<{
28
+ configOptions: SessionConfigOption[];
29
+ }>;
30
+ export declare class ModelConfigurationError extends Error {
31
+ readonly code = "MODEL_CATALOG_UNAVAILABLE";
32
+ constructor(reason: string, options?: ErrorOptions);
33
+ }
34
+ /** Pure ACP configuration state. All IDs and values come from the given peer data. */
35
+ export declare class ModelConfiguration {
36
+ private readonly rpcSet;
37
+ private state;
38
+ private parsed?;
39
+ private revision;
40
+ private busy;
41
+ constructor(options: SessionConfigOption[], rpcSet: ModelConfigurationSetter);
42
+ get models(): readonly ModelCatalogValue[];
43
+ get snapshot(): ModelConfigurationSnapshot;
44
+ update(options: SessionConfigOption[]): void;
45
+ private fail;
46
+ private checkAckRevision;
47
+ private set;
48
+ select(model: string, effort?: string): Promise<ModelConfigurationSnapshot>;
49
+ /** Call immediately before prompt; an idle/new/load/resume update withdraws the ACK. */
50
+ assertSelection(model: string, effort?: string): void;
51
+ }
@@ -0,0 +1,212 @@
1
+ export class ModelConfigurationError extends Error {
2
+ code = 'MODEL_CATALOG_UNAVAILABLE';
3
+ constructor(reason, options) {
4
+ super(`无法获取模型列表:${reason}`, options);
5
+ this.name = 'ModelConfigurationError';
6
+ }
7
+ }
8
+ function unavailable(reason) { throw new ModelConfigurationError(reason); }
9
+ function record(value) {
10
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
11
+ }
12
+ function text(value) { return typeof value === 'string' && value.trim().length > 0; }
13
+ function label(value) {
14
+ if (!text(value.name))
15
+ unavailable('配置名称为空或无效');
16
+ if (value.description !== undefined && value.description !== null && typeof value.description !== 'string')
17
+ unavailable('配置说明无效');
18
+ }
19
+ function select(option) {
20
+ if (!text(option.currentValue) || !Array.isArray(option.options) || !option.options.length)
21
+ unavailable('选择器缺少实际当前值或可选列表');
22
+ const values = [];
23
+ const seen = new Set();
24
+ const add = (value) => {
25
+ if (!record(value) || !text(value.value) || 'group' in value || 'options' in value)
26
+ unavailable('可选值格式无效');
27
+ label(value);
28
+ if (seen.has(value.value))
29
+ unavailable('选择器包含重复可选值');
30
+ seen.add(value.value);
31
+ values.push({ value: value.value, name: value.name, ...(value.description === undefined ? {} : { description: value.description }) });
32
+ };
33
+ const grouped = record(option.options[0]) && 'group' in option.options[0];
34
+ const groups = new Set();
35
+ for (const entry of option.options) {
36
+ if (grouped) {
37
+ if (!record(entry) || !text(entry.group) || 'value' in entry || !Array.isArray(entry.options) || !entry.options.length)
38
+ unavailable('分组选择器格式无效或分组为空');
39
+ label(entry);
40
+ if (groups.has(entry.group))
41
+ unavailable('选择器包含重复分组标识');
42
+ groups.add(entry.group);
43
+ for (const value of entry.options)
44
+ add(value);
45
+ }
46
+ else
47
+ add(entry);
48
+ }
49
+ if (!seen.has(option.currentValue))
50
+ unavailable('实际当前值不在远端可选列表中');
51
+ return { id: option.id, currentValue: option.currentValue, values };
52
+ }
53
+ function parse(options) {
54
+ if (!Array.isArray(options) || !options.length)
55
+ unavailable('远端没有提供完整配置列表');
56
+ const ids = new Set();
57
+ const models = [];
58
+ const efforts = [];
59
+ for (const option of options) {
60
+ if (!record(option) || !text(option.id))
61
+ unavailable('配置标识为空或无效');
62
+ label(option);
63
+ if (ids.has(option.id))
64
+ unavailable('配置列表包含重复标识');
65
+ ids.add(option.id);
66
+ if (option.category !== undefined && option.category !== null && typeof option.category !== 'string')
67
+ unavailable('配置类别无效');
68
+ if (option.type === 'select') {
69
+ const selector = select(option);
70
+ if (option.category === 'model')
71
+ models.push(selector);
72
+ if (option.category === 'thought_level')
73
+ efforts.push(selector);
74
+ }
75
+ else if (option.type === 'boolean') {
76
+ if (typeof option.currentValue !== 'boolean')
77
+ unavailable('布尔配置当前值无效');
78
+ if (option.category === 'model' || option.category === 'thought_level')
79
+ unavailable('模型或推理等级配置不是选择器');
80
+ }
81
+ else
82
+ unavailable('配置类型无法识别');
83
+ }
84
+ if (models.length !== 1)
85
+ unavailable(models.length ? '模型选择器不唯一' : '没有可识别的模型选择器');
86
+ if (efforts.length > 1)
87
+ unavailable('推理等级选择器不唯一');
88
+ return { model: models[0], effort: efforts[0] };
89
+ }
90
+ function freeze(value, seen = new WeakSet()) {
91
+ if (value !== null && typeof value === 'object' && !seen.has(value)) {
92
+ seen.add(value);
93
+ for (const item of Object.values(value))
94
+ freeze(item, seen);
95
+ Object.freeze(value);
96
+ }
97
+ return value;
98
+ }
99
+ /** Pure ACP configuration state. All IDs and values come from the given peer data. */
100
+ export class ModelConfiguration {
101
+ rpcSet;
102
+ state;
103
+ parsed;
104
+ revision = 0;
105
+ busy = false;
106
+ constructor(options, rpcSet) {
107
+ this.rpcSet = rpcSet;
108
+ this.update(options);
109
+ }
110
+ get models() {
111
+ if (!this.parsed)
112
+ throw new ModelConfigurationError('当前远端配置列表无效');
113
+ return this.state.models;
114
+ }
115
+ get snapshot() { return this.state; }
116
+ update(options) {
117
+ this.revision++;
118
+ let copy = [];
119
+ try {
120
+ // The wire is JSON data; clone before validation so caller mutations cannot race ACK checks.
121
+ const copied = structuredClone(options);
122
+ if (Array.isArray(copied))
123
+ copy = copied;
124
+ const parsed = parse(copied);
125
+ this.parsed = parsed;
126
+ this.state = freeze({
127
+ configOptions: copy, models: parsed.model.values,
128
+ modelConfigId: parsed.model.id, model: parsed.model.currentValue,
129
+ ...(parsed.effort ? { effortConfigId: parsed.effort.id, effort: parsed.effort.currentValue } : {}),
130
+ effortAvailable: !!parsed.effort, confirmed: false,
131
+ });
132
+ }
133
+ catch (cause) {
134
+ const error = cause instanceof ModelConfigurationError ? cause : new ModelConfigurationError('配置数据无法复制或解析', { cause });
135
+ this.parsed = undefined;
136
+ this.state = freeze({ configOptions: copy, models: [], effortAvailable: false, confirmed: false, failure: { code: error.code, message: error.message } });
137
+ throw error;
138
+ }
139
+ }
140
+ fail(error) {
141
+ this.state = freeze({ ...this.state, confirmed: false, failure: { code: error.code, message: error.message } });
142
+ }
143
+ checkAckRevision(revision, stage) {
144
+ if (revision !== this.revision)
145
+ unavailable(`${stage}回执之后收到其他配置更新,原选择未获确认`);
146
+ }
147
+ async set(id, value, stage) {
148
+ const revision = this.revision;
149
+ let response;
150
+ try {
151
+ response = await this.rpcSet(id, value);
152
+ }
153
+ catch (cause) {
154
+ throw new ModelConfigurationError(`${stage}配置请求失败,未获得远端确认`, { cause });
155
+ }
156
+ if (revision !== this.revision)
157
+ unavailable(`${stage}配置请求期间收到其他配置更新,无法确认回执顺序`);
158
+ if (!record(response) || !Array.isArray(response.configOptions))
159
+ unavailable(`${stage}配置回执缺少完整配置列表`);
160
+ this.update(response.configOptions);
161
+ return this.revision;
162
+ }
163
+ async select(model, effort) {
164
+ if (this.busy)
165
+ throw new ModelConfigurationError('另一个配置选择尚未完成');
166
+ this.busy = true;
167
+ this.state = freeze({ ...this.state, confirmed: false });
168
+ try {
169
+ if (!this.parsed)
170
+ unavailable('当前远端配置列表无效');
171
+ if (!this.parsed.model.values.some(entry => entry.value === model))
172
+ unavailable('所选模型不在实际远端列表中');
173
+ let ackRevision = await this.set(this.parsed.model.id, model, '模型');
174
+ this.checkAckRevision(ackRevision, '模型');
175
+ if (this.state.model !== model)
176
+ unavailable('模型配置回执与所选模型不一致');
177
+ if (effort !== undefined) {
178
+ const selector = this.parsed?.effort;
179
+ if (!selector)
180
+ unavailable('远端没有提供实际推理等级选择器');
181
+ if (!selector.values.some(entry => entry.value === effort))
182
+ unavailable('所选推理等级不在模型回执的实际列表中');
183
+ ackRevision = await this.set(selector.id, effort, '推理等级');
184
+ this.checkAckRevision(ackRevision, '推理等级');
185
+ if (this.state.model !== model)
186
+ unavailable('推理等级回执中的模型发生变化');
187
+ if (!this.state.effortAvailable || this.state.effort !== effort)
188
+ unavailable('推理等级配置回执与所选等级不一致');
189
+ }
190
+ this.checkAckRevision(ackRevision, '选择');
191
+ this.state = freeze({ ...this.state, confirmed: true });
192
+ return this.state;
193
+ }
194
+ catch (cause) {
195
+ const error = cause instanceof ModelConfigurationError ? cause : new ModelConfigurationError('配置选择未能完成核对', { cause });
196
+ this.fail(error);
197
+ throw error;
198
+ }
199
+ finally {
200
+ this.busy = false;
201
+ }
202
+ }
203
+ /** Call immediately before prompt; an idle/new/load/resume update withdraws the ACK. */
204
+ assertSelection(model, effort) {
205
+ if (this.busy || !this.parsed || !this.state.confirmed)
206
+ unavailable('当前模型选择尚未获得完整远端确认');
207
+ if (this.state.model !== model)
208
+ unavailable('当前实际模型与待发送选择不一致');
209
+ if (effort !== undefined && (!this.state.effortAvailable || this.state.effort !== effort))
210
+ unavailable('当前实际推理等级与待发送选择不一致');
211
+ }
212
+ }
@@ -40,6 +40,10 @@ export function employeeFailure(code) {
40
40
  ACP_MESSAGE_TOO_LARGE: '本轮输入超过会话消息容量,未截断或发送;请联系管理员调整acpMaxMessageBytes。',
41
41
  ACP_CLIENT_LOST: '我需要管理员核对并结束遗留的主机会话;当前保留原进程,没有代答旧审批。',
42
42
  ONESHOT_CONTEXT_REQUIRED: '首版单次模式不支持图片或系统与开发者上下文,请改用会话模式。',
43
+ MODEL_CONFIGURATION_UNSUPPORTED: '单次模式不支持确认当前模型与推理等级,本轮未发送;请改用会话模式。',
44
+ MODEL_SELECTION_REQUIRED: '旧远端恢复记录没有明确模型选择,本轮未发送;请新建会话并选择模型。',
45
+ MODEL_RESOLUTION_UNCONFIRMED: '所选模型的具体型号尚未获得当前会话确认,本轮未发送;请重新选择模型或联系管理员。',
46
+ REMOTE_CLEANUP_UNCONFIRMED: '原主机会话的清理结果尚未确认,已阻断继续发送;请联系管理员核对,不能自动重开。',
43
47
  };
44
48
  return messages[code] ?? `远程主机暂时无法完成这次操作,请联系管理员。技术详情:诊断代码 ${code}`;
45
49
  }
@@ -0,0 +1,13 @@
1
+ /** Byte fragments remain only in memory. A cursor is clean only after SDK consumption. */
2
+ export declare class ProtocolBoundary {
3
+ private readonly maxBytes;
4
+ private partial;
5
+ private pending;
6
+ private requests;
7
+ constructor(maxBytes: number);
8
+ receive(bytes: Uint8Array, gate?: (message: Record<string, unknown>) => void): void;
9
+ consumed(message: Record<string, unknown>): void;
10
+ responded(id: unknown): void;
11
+ get complete(): boolean;
12
+ get wholeLine(): boolean;
13
+ }
@@ -0,0 +1,49 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { NeedError } from "./remote.js";
3
+ const fingerprint = (value) => createHash('sha256').update(JSON.stringify(value)).digest('hex');
4
+ /** Byte fragments remain only in memory. A cursor is clean only after SDK consumption. */
5
+ export class ProtocolBoundary {
6
+ maxBytes;
7
+ partial = Buffer.alloc(0);
8
+ pending = [];
9
+ requests = new Set();
10
+ constructor(maxBytes) {
11
+ this.maxBytes = maxBytes;
12
+ }
13
+ receive(bytes, gate) {
14
+ this.partial = Buffer.concat([this.partial, bytes]);
15
+ let end;
16
+ while ((end = this.partial.indexOf(10)) >= 0) {
17
+ if (end > this.maxBytes)
18
+ throw new NeedError('ACP_MESSAGE_TOO_LARGE', '主机消息超过具名会话容量', 'acpMaxMessageBytes');
19
+ const line = this.partial.subarray(0, end).toString('utf8').trim();
20
+ this.partial = this.partial.subarray(end + 1);
21
+ if (!line)
22
+ continue;
23
+ let message;
24
+ try {
25
+ message = JSON.parse(line);
26
+ }
27
+ catch {
28
+ throw new NeedError('ACP_OUTPUT_INVALID', '主机消息不是有效JSON', '完整ACP消息');
29
+ }
30
+ if (!message || typeof message !== 'object' || Array.isArray(message))
31
+ throw new NeedError('ACP_OUTPUT_INVALID', '主机消息结构无效', '完整ACP消息');
32
+ if (gate)
33
+ gate(message);
34
+ else
35
+ this.pending.push(fingerprint(message));
36
+ }
37
+ if (this.partial.length > this.maxBytes)
38
+ throw new NeedError('ACP_MESSAGE_TOO_LARGE', '主机消息超过具名会话容量', 'acpMaxMessageBytes');
39
+ }
40
+ consumed(message) {
41
+ if (this.pending.shift() !== fingerprint(message))
42
+ throw new NeedError('ACP_BOUNDARY_UNCONFIRMED', '主机消息处理位置无法确认', '完整按序的ACP消息');
43
+ if (typeof message.method === 'string' && Object.hasOwn(message, 'id'))
44
+ this.requests.add(JSON.stringify(message.id));
45
+ }
46
+ responded(id) { this.requests.delete(JSON.stringify(id)); }
47
+ get complete() { return this.partial.length === 0 && this.pending.length === 0 && this.requests.size === 0; }
48
+ get wholeLine() { return this.partial.length === 0; }
49
+ }
@@ -1,6 +1,6 @@
1
1
  import type { AgentApp } from '@agentclientprotocol/sdk';
2
2
  import type { AgentApp as ExtendedAgentApp } from '@agentclientprotocol/sdk/unstable';
3
- import type { ConnectState, ConnectApi as LConnectApi, ConnectionState, LoginPromptState } from '@ai-workbench/connect/api';
3
+ import type { ConnectState, ConnectNeed, ConnectApi as LConnectApi, ConnectionState, LoginPromptState } from '@ai-workbench/connect/api';
4
4
  export type { ConnectionState, LoginPromptState } from '@ai-workbench/connect/api';
5
5
  import type { OneShotRuntime } from './job.ts';
6
6
  import type { BrokerTransport, BrokerSettings } from './broker.ts';
@@ -9,6 +9,8 @@ import { type AgentDefinition } from './agents.ts';
9
9
  import type { NativeWorkspaceApi, SessionProjectTarget, WorkspaceNotice, LoginProvider } from './connect-contract.ts';
10
10
  import type { DetailChannel } from './remote-details.ts';
11
11
  import type { WorkspaceNoticePresentation } from './workspace-notice.ts';
12
+ import { StageTiming } from './stage-timing.ts';
13
+ import type { RecoveryJournal, AcpRecovery } from './recovery.ts';
12
14
  export interface Runtime {
13
15
  identity: string;
14
16
  cwd: string;
@@ -28,12 +30,28 @@ export interface Runtime {
28
30
  connection?: ConnectionState;
29
31
  workspaceNotice?: WorkspaceNoticePresentation;
30
32
  details?: DetailChannel;
33
+ recoveredSession?: AcpRecovery;
34
+ restartNotice?: string;
35
+ detach?: () => Promise<void>;
36
+ restartInputIds?: readonly string[];
37
+ saveCheckpoint?: (id: string, acp: AcpRecovery) => Promise<boolean>;
31
38
  detailContext?: () => {
32
39
  turn: string | number | null;
33
40
  step: string | number | null;
34
41
  };
35
42
  }
36
43
  export interface RuntimeRequest extends Invocation {
44
+ recoveryJournal?: RecoveryJournal;
45
+ detailChannel?: DetailChannel;
46
+ validateContext?: (ignoreCancellation?: boolean) => Promise<void>;
47
+ verifyRecovery?: () => Promise<boolean>;
48
+ acpMaxMessageBytes?: number;
49
+ contextSha256?: string;
50
+ resumeSessionId?: string;
51
+ recoveryCheckpointId?: string;
52
+ bindingRevision?: number;
53
+ currentInputIds?: readonly string[];
54
+ timing?: StageTiming;
37
55
  mode: 'session' | 'one-shot';
38
56
  cwd?: string;
39
57
  identity?: string;
@@ -85,10 +103,10 @@ type Result<T> = {
85
103
  code: string;
86
104
  message: string;
87
105
  requires: string;
88
- };
106
+ } & Pick<ConnectNeed, 'officialReason'>;
89
107
  };
90
108
  /** 消费正式connect-api 0.7公开子集;X不响应L登录票据、不直接调用登录。 */
91
- export interface ConnectApi extends Pick<LConnectApi, 'getConnectionState' | 'getLoginPromptState' | 'requestLoginPrompt' | 'refreshLoginPrompts'>, Partial<NativeWorkspaceApi>, Partial<Pick<LConnectApi, 'appendRemoteConversationDetail' | 'getRemoteConversationDetailReceipt' | 'listRemoteConversationDetails'>> {
109
+ export interface ConnectApi extends Pick<LConnectApi, 'getConnectionState' | 'getLoginPromptState' | 'requestLoginPrompt' | 'refreshLoginPrompts'>, Partial<NativeWorkspaceApi>, Partial<Pick<LConnectApi, 'getWorkspaceBindingState' | 'getModelCatalogTarget' | 'onConnectionState' | 'onLoginPromptState' | 'appendRemoteConversationDetail' | 'getRemoteConversationDetailReceipt' | 'listRemoteConversationDetails'>> {
92
110
  getState?(): ConnectState;
93
111
  getSshTarget(): Promise<Result<ConnectTarget>>;
94
112
  checkLogin(provider: LoginProvider): Promise<Result<{