@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/dist/broker.js CHANGED
@@ -1,17 +1,8 @@
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
1
  import { spawn } from 'node:child_process';
10
2
  import { createHash } from 'node:crypto';
11
- import { protectedOneShotLayout } from "./host-layout.js";
3
+ import { ProtocolBoundary } from "./protocol-boundary.js";
12
4
  import { NeedError, shellQuote } from "./remote.js";
13
5
  import { CODEX_AGENT } from "./agents.js";
14
- import { identity, unwrap, validateTarget, sameTarget } from "./provider.js";
15
6
  /** One byte stream / SDK app, multiple SSH attachments. Input is never replayed. */
16
7
  export class BrokerTransport {
17
8
  readable;
@@ -30,10 +21,23 @@ export class BrokerTransport {
30
21
  approvalSent = false;
31
22
  unconfirmed = new Set();
32
23
  failure;
24
+ heartbeat;
25
+ boundary;
26
+ recovering = false;
27
+ promptSent = false;
28
+ preparingInputs = 0;
29
+ inputTail = Promise.resolve();
30
+ exclusive(operation) { const task = this.inputTail.then(operation); this.inputTail = task.catch(() => { }); return task; }
33
31
  constructor(options) {
34
32
  if (!Number.isSafeInteger(options.maxFrameBytes) || options.maxFrameBytes < 1)
35
33
  throw new NeedError('SETTINGS_REQUIRED', '我需要 brokerMaxFrameBytes', '管理员确认 brokerMaxFrameBytes');
36
34
  this.options = options;
35
+ this.cursor = options.initialSequence ?? 0;
36
+ if (!Number.isSafeInteger(this.cursor) || this.cursor < 0)
37
+ throw new NeedError('RECOVERY_CURSOR_INVALID', '会话恢复位置无效', '已处理的安全游标');
38
+ if (options.maxMessageBytes)
39
+ this.boundary = new ProtocolBoundary(options.maxMessageBytes);
40
+ this.recovering = !!options.recoverySessionId;
37
41
  this.lease.abort();
38
42
  this.readable = new ReadableStream({ start: c => { this.output = c; }, pull: () => { this.attachment?.stdout.resume(); }, cancel: () => { this.close(); } });
39
43
  this.writable = new WritableStream({ write: bytes => this.write(bytes), close: () => this.close(), abort: () => this.close() });
@@ -43,11 +47,46 @@ export class BrokerTransport {
43
47
  get signal() { return this.lease.signal; }
44
48
  get lastSequence() { return this.cursor; }
45
49
  get error() { return this.failure; }
50
+ get promptInFlight() { return this.promptSent; }
51
+ processedMessage(message) { this.boundary?.consumed(message); }
52
+ checkpointSequence() { return this.online && this.preparingInputs === 0 && !!this.boundary?.complete && !this.approvalSent && this.unconfirmed.size === 0 ? this.cursor : null; }
53
+ async commitCheckpoint(commit) { return this.exclusive(async () => { const cursor = this.checkpointSequence(); if (cursor === null)
54
+ return false; await commit(cursor); return true; }); }
55
+ finishRecovery() { if (!this.boundary?.wholeLine)
56
+ throw new NeedError('RECOVERY_INCOMPLETE_OUTPUT', '原会话输出停在不完整消息中', '安全结束并新建会话'); this.recovering = false; }
57
+ async replayThrough(sequence, signal) {
58
+ const inspect = () => { signal?.throwIfAborted(); if (this.failure)
59
+ throw this.failure; if (this.cursor >= sequence) {
60
+ if (!this.boundary?.wholeLine)
61
+ throw new NeedError('RECOVERY_INCOMPLETE_OUTPUT', '原会话输出不完整', '安全结束并新建会话');
62
+ return true;
63
+ } return false; };
64
+ if (inspect())
65
+ return;
66
+ await new Promise((resolve, reject) => { let off = () => { }; const clear = () => { off(); signal?.removeEventListener('abort', changed); }; const changed = () => { try {
67
+ if (inspect()) {
68
+ clear();
69
+ resolve();
70
+ }
71
+ }
72
+ catch (error) {
73
+ clear();
74
+ reject(error);
75
+ } }; off = this.onChange(changed); signal?.addEventListener('abort', changed, { once: true }); changed(); });
76
+ }
77
+ stopHeartbeat() { if (this.heartbeat !== undefined)
78
+ clearInterval(this.heartbeat); this.heartbeat = undefined; }
79
+ recoveryMessage(message) {
80
+ const params = message.params;
81
+ if (Object.hasOwn(message, 'id') || message.method !== 'session/update' || params?.sessionId !== this.options.recoverySessionId || !['available_commands_update', 'current_mode_update', 'config_option_update', 'session_info_update', 'usage_update'].includes(params?.update?.sessionUpdate ?? ''))
82
+ throw new NeedError('RECOVERY_UNSAFE_OUTPUT', '原会话有未确认输出或旧交互,不能代答', '安全结束并新建会话');
83
+ this.options.onRecoveryUpdate?.(message);
84
+ }
46
85
  authorizeResponse(id, generation) { this.permits.set(JSON.stringify(id), generation); }
47
86
  /** Called only by the ACP protocol layer after a matching result or current-turn output. */
48
87
  acknowledge(method) { this.unconfirmed.delete(method); }
49
88
  /** A matching terminal prompt response settles all approval replies in that turn. */
50
- completePrompt() { this.unconfirmed.delete('session/prompt'); this.approvalSent = false; for (const key of this.unconfirmed)
89
+ completePrompt() { this.promptSent = false; this.unconfirmed.delete('session/prompt'); this.approvalSent = false; for (const key of this.unconfirmed)
51
90
  if (key.startsWith('response:'))
52
91
  this.unconfirmed.delete(key); }
53
92
  onChange(fn) { this.listeners.add(fn); return () => this.listeners.delete(fn); }
@@ -57,6 +96,7 @@ export class BrokerTransport {
57
96
  fail(error) {
58
97
  if (this.ended)
59
98
  return;
99
+ this.stopHeartbeat();
60
100
  this.failure = error;
61
101
  this.ended = true;
62
102
  this.attached = false;
@@ -72,6 +112,7 @@ export class BrokerTransport {
72
112
  close() {
73
113
  if (this.ended)
74
114
  return;
115
+ this.stopHeartbeat();
75
116
  this.ended = true;
76
117
  this.attached = false;
77
118
  this.lease.abort();
@@ -127,6 +168,7 @@ export class BrokerTransport {
127
168
  child.stdin.on('error', () => { });
128
169
  let partial = Buffer.alloc(0);
129
170
  let ready = false;
171
+ let attemptClosed = false;
130
172
  const connected = Promise.withResolvers();
131
173
  // A local end suppresses detached(), so settle the handshake independently.
132
174
  const off = this.onChange(() => { if (this.ended)
@@ -136,6 +178,23 @@ export class BrokerTransport {
136
178
  if (f.type === 'ready') {
137
179
  if (ready)
138
180
  throw new NeedError('BROKER_PROTOCOL_INVALID', 'broker 重复 ready', '有效的 broker 帧');
181
+ if (this.options.instanceId && f.instanceId !== this.options.instanceId)
182
+ throw new NeedError('SESSION_INSTANCE_MISMATCH', '主机会话已被替换,未接入或结束替代进程', '当前会话实例');
183
+ if (this.options.leased) {
184
+ if (f.controlMaxBytes !== this.options.controlMaxBytes || !Number.isSafeInteger(f.controlMaxBytes) || f.controlMaxBytes < Buffer.byteLength(JSON.stringify({ type: 'input', data: '' })) + 4)
185
+ throw new NeedError('LEASE_CAPACITY_INVALID', '主机会话容量无法承载租约输入', 'S实际冻结的controlMaxBytes');
186
+ const policy = f.lifecycle;
187
+ if (!policy || typeof policy.enabled !== 'boolean' || policy.enabled && (!Number.isSafeInteger(policy.heartbeatIntervalMs) || policy.heartbeatIntervalMs < 1 || !Number.isSafeInteger(policy.attachLeaseMs) || policy.attachLeaseMs < 3 * policy.heartbeatIntervalMs))
188
+ throw new NeedError('LEASE_POLICY_INVALID', '主机未给出有效客户端租约策略', 'S实际冻结的生命周期策略');
189
+ if (policy.enabled) {
190
+ this.stopHeartbeat();
191
+ this.heartbeat = setInterval(() => { if (this.online && this.attachment === child) {
192
+ child.stdin.write('{"type":"heartbeat"}\n', error => { if (error)
193
+ child.kill('SIGTERM'); });
194
+ } }, policy.heartbeatIntervalMs);
195
+ this.heartbeat.unref?.();
196
+ }
197
+ }
139
198
  ready = true;
140
199
  this.attached = true;
141
200
  this.lease = new AbortController();
@@ -143,8 +202,17 @@ export class BrokerTransport {
143
202
  connected.resolve();
144
203
  return;
145
204
  }
146
- if (f.type === 'error')
147
- throw new NeedError(typeof f.code === 'string' ? f.code : 'BROKER_FAILED', '主机会话无法续接(' + (typeof f.code === 'string' ? f.code : '未知错误') + ');没有重放输入或代答审批', '恢复或显式结束原会话');
205
+ if (f.type === 'error') {
206
+ const code = typeof f.code === 'string' && /^[A-Z][A-Z0-9_]{0,63}$/.test(f.code) ? f.code : 'BROKER_PROTOCOL_INVALID';
207
+ const error = new NeedError(code, '主机会话无法续接(' + code + ');没有重放输入或代答审批', '恢复或显式结束原会话');
208
+ if (!ready && f.code === 'ATTACH_BUSY') {
209
+ attemptClosed = true;
210
+ connected.reject(error);
211
+ child.kill('SIGTERM');
212
+ return;
213
+ }
214
+ throw error;
215
+ }
148
216
  if (f.type === 'exit') {
149
217
  connected.reject(new NeedError('AGENT_EXITED', '主机 agent 已退出', '重新建立会话'));
150
218
  this.fail(new NeedError('AGENT_EXITED', '主机 agent 已退出,退出状态不代表本轮完成', '确认主机状态'));
@@ -157,14 +225,19 @@ export class BrokerTransport {
157
225
  return;
158
226
  if (seq !== this.cursor + 1)
159
227
  throw new NeedError('REPLAY_GAP', '主机输出存在缺口,不能安全继续审批', '显式结束或恢复原会话');
160
- if (f.channel === 'stdout')
161
- this.output.enqueue(Buffer.from(f.data, 'base64'));
228
+ if (f.channel === 'stdout') {
229
+ const bytes = Buffer.from(f.data, 'base64');
230
+ this.boundary?.receive(bytes, this.recovering ? message => this.recoveryMessage(message) : undefined);
231
+ if (!this.recovering && !this.options.discardOutput)
232
+ this.output.enqueue(bytes);
233
+ }
162
234
  this.cursor = seq;
235
+ this.changed();
163
236
  if ((this.output.desiredSize ?? 0) <= 0)
164
237
  child.stdout.pause();
165
238
  };
166
239
  child.stdout.on('data', (bytes) => {
167
- if (this.ended || generation !== this.epoch)
240
+ if (attemptClosed || this.ended || generation !== this.epoch)
168
241
  return;
169
242
  try {
170
243
  partial = Buffer.concat([partial, bytes]);
@@ -178,7 +251,7 @@ export class BrokerTransport {
178
251
  if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object')
179
252
  throw Error('frame');
180
253
  frame(parsed);
181
- if (this.ended)
254
+ if (this.ended || attemptClosed)
182
255
  return;
183
256
  }
184
257
  if (partial.length > this.options.maxFrameBytes)
@@ -189,8 +262,9 @@ export class BrokerTransport {
189
262
  }
190
263
  });
191
264
  const detached = () => {
192
- if (generation !== this.epoch || this.ended)
265
+ if (attemptClosed || generation !== this.epoch || this.ended)
193
266
  return;
267
+ this.stopHeartbeat();
194
268
  this.attached = false;
195
269
  this.lease.abort();
196
270
  this.changed();
@@ -212,6 +286,15 @@ export class BrokerTransport {
212
286
  return connected.promise.finally(off);
213
287
  }
214
288
  async write(bytes) {
289
+ this.preparingInputs++;
290
+ try {
291
+ return await this.exclusive(() => this.send(bytes));
292
+ }
293
+ finally {
294
+ this.preparingInputs--;
295
+ }
296
+ }
297
+ async send(bytes) {
215
298
  if (!this.online || !this.attachment)
216
299
  throw new NeedError('INPUT_DELIVERY_UNCONFIRMED', '连接不在线,输入没有排队或重放', '恢复连接并确认原会话状态');
217
300
  let response;
@@ -222,22 +305,46 @@ export class BrokerTransport {
222
305
  throw new NeedError('ACP_INPUT_INVALID', 'ACP 输出不是一条完整 JSON 消息', '有效的 ACP SDK 字节输出');
223
306
  }
224
307
  const selected = response.result?.outcome?.outcome === 'selected';
225
- if (selected) {
226
- const key = JSON.stringify(response.id);
227
- const permit = this.permits.get(key);
228
- this.permits.delete(key);
229
- if (permit !== this.epoch)
308
+ const generation = this.epoch;
309
+ const child = this.attachment;
310
+ const eligible = () => {
311
+ if (!this.online || this.attachment !== child || this.epoch !== generation)
312
+ throw new NeedError('INPUT_DELIVERY_UNCONFIRMED', '连接已改变,未重放输入或审批', '原连接的有效发送许可');
313
+ if (selected && this.permits.get(JSON.stringify(response.id)) !== generation)
230
314
  throw new NeedError('APPROVAL_STALE', '连接已改变,旧审批未发送', '重连后的本次审批');
315
+ };
316
+ eligible();
317
+ await this.options.beforeInput?.();
318
+ eligible();
319
+ if (selected) {
320
+ this.permits.delete(JSON.stringify(response.id));
231
321
  this.approvalSent = true;
232
322
  }
233
323
  this.unconfirmed.add(response.method ?? 'response:' + JSON.stringify(response.id));
234
- const child = this.attachment;
235
- await new Promise((resolve, reject) => {
236
- const closed = () => { cleanup(); reject(new NeedError('INPUT_DELIVERY_UNCONFIRMED', '输入送达未确认,未重发', '确认原进程状态')); };
237
- const cleanup = () => { child.removeListener('close', closed); };
238
- child.once('close', closed);
239
- child.stdin.write(bytes, error => { cleanup(); error ? reject(new NeedError('INPUT_DELIVERY_UNCONFIRMED', '输入送达失败,未重发', '确认原进程状态')) : resolve(); });
240
- });
324
+ const chunks = [];
325
+ if (this.options.leased) {
326
+ const size = Math.floor((this.options.controlMaxBytes - Buffer.byteLength(JSON.stringify({ type: 'input', data: '' }))) / 4) * 3;
327
+ if (size < 1)
328
+ throw new NeedError('LEASE_CAPACITY_INVALID', '主机会话容量无法承载输入', '有效controlMaxBytes');
329
+ for (let offset = 0; offset < bytes.byteLength; offset += size)
330
+ chunks.push(Buffer.from(JSON.stringify({ type: 'input', data: Buffer.from(bytes.subarray(offset, offset + size)).toString('base64') }) + '\n'));
331
+ }
332
+ else
333
+ chunks.push(Buffer.from(bytes));
334
+ for (const [index, chunk] of chunks.entries()) {
335
+ if (!this.online || this.attachment !== child || this.epoch !== generation)
336
+ throw new NeedError('INPUT_DELIVERY_UNCONFIRMED', '分块发送期间断线,未重放输入', '原主机连接');
337
+ await new Promise((resolve, reject) => {
338
+ const closed = () => { cleanup(); reject(new NeedError('INPUT_DELIVERY_UNCONFIRMED', '输入送达未确认,未重发', '确认原进程状态')); };
339
+ const cleanup = () => { child.removeListener('close', closed); };
340
+ child.once('close', closed);
341
+ if (response.method === 'session/prompt' && index === chunks.length - 1)
342
+ this.promptSent = true;
343
+ child.stdin.write(chunk, error => { cleanup(); error ? reject(new NeedError('INPUT_DELIVERY_UNCONFIRMED', '输入送达失败,未重发', '确认原进程状态')) : resolve(); });
344
+ });
345
+ }
346
+ if (!response.method)
347
+ this.boundary?.responded(response.id);
241
348
  }
242
349
  }
243
350
  export function controlNeed(stderr) {
@@ -277,8 +384,9 @@ export function sshIo(target, argv, maxControlBytes) {
277
384
  }) };
278
385
  }
279
386
  export function brokerCommand(t, args) { return `AW_SESSION_ROOT=${shellQuote(t.session.root)} ${shellQuote(t.session.command)} ${args.map(shellQuote).join(' ')}`; }
280
- export function brokerId(sessionId, agentId = CODEX_AGENT.id) { return (agentId === CODEX_AGENT.id ? 'x' : 'a') + createHash('sha256').update(agentId === CODEX_AGENT.id ? sessionId : agentId + '/' + sessionId).digest('hex').slice(0, 23); }
281
- function controlJson(text) {
387
+ export function legacyBrokerId(sessionId, agentId = CODEX_AGENT.id) { return (agentId === CODEX_AGENT.id ? 'x' : 'a') + createHash('sha256').update(agentId === CODEX_AGENT.id ? sessionId : agentId + '/' + sessionId).digest('hex').slice(0, 23); }
388
+ export function brokerId(sessionId, agentId = CODEX_AGENT.id) { return 'r' + createHash('sha256').update(agentId + '/' + sessionId).digest('hex').slice(0, 23); }
389
+ export function controlJson(text) {
282
390
  try {
283
391
  return JSON.parse(text);
284
392
  }
@@ -286,112 +394,4 @@ function controlJson(text) {
286
394
  throw new NeedError('BROKER_PROTOCOL_INVALID', 'broker 控制响应不是有效 JSON,未复制原始输出', '有效的 broker 控制响应');
287
395
  }
288
396
  }
289
- export function brokerFactory(get, _ask, configuration, provided) {
290
- const active = new Set();
291
- const retained = new Map();
292
- const open = async (target, project, request) => {
293
- const settings = request.brokerSettings ?? (typeof configuration === 'function' ? configuration() : configuration);
294
- if (!request.sessionId)
295
- throw new NeedError('SESSION_REQUIRED', '我需要当前 DSH 会话标识', 'DSH sessionId');
296
- if (request.mode === 'one-shot') {
297
- return { identity: identity(target), cwd: project.path, oneShot: { target, cwd: project.path, ...protectedOneShotLayout(target) } };
298
- }
299
- if (![settings.brokerMaxFrameBytes, settings.brokerControlMaxBytes].every(n => Number.isSafeInteger(n) && n > 0))
300
- throw new NeedError('SETTINGS_REQUIRED', '我需要管理员确认 broker 帧和控制响应大小设置', 'brokerMaxFrameBytes / brokerControlMaxBytes(无默认)');
301
- let argv = provided?.argv;
302
- if (!provided?.io && !argv) {
303
- try {
304
- const moduleName = '@ai-workbench/connect';
305
- const supplied = await import(__rewriteRelativeImportExtension(moduleName));
306
- if (typeof supplied.sshArgs !== 'function')
307
- throw Error('missing sshArgs');
308
- argv = supplied.sshArgs;
309
- }
310
- catch {
311
- throw new NeedError('DEPENDENCY_REQUIRED', '我需要 connect 0.3.0 包导出的 sshArgs 来执行可信 SSH', '@ai-workbench/connect 0.3.0');
312
- }
313
- }
314
- const ioFor = (t) => provided?.io?.(t) ?? sshIo(t, argv, settings.brokerControlMaxBytes);
315
- const agent = request.agent ?? CODEX_AGENT;
316
- let io = ioFor(target);
317
- const id = brokerId(request.sessionId, agent.id);
318
- const command = (args) => brokerCommand(target, args);
319
- const list = controlJson(await io.run(command(['list']), request.signal));
320
- if (!Array.isArray(list) || list.some(s => typeof s?.id !== 'string'))
321
- throw new NeedError('BROKER_PROTOCOL_INVALID', '主机会话列表无效', '有效的 broker 列表');
322
- if (list.some(s => s.id === id)) {
323
- throw new NeedError('ACP_CLIENT_LOST', '我需要管理员确认并结束遗留的主机会话;当前保留原进程,没有代答旧审批', '管理员核对并明确结束对应主机会话');
324
- }
325
- const started = controlJson(await io.run(command(['start', id, '--cwd', project.path, '--settings', target.session.settingsPath, '--', agent.command]), request.signal));
326
- if (started?.protocolVersion !== 1 || started.id !== id || !Number.isSafeInteger(started.pid) || started.pid < 1 || typeof started.socket !== 'string')
327
- throw new NeedError('BROKER_START_UNCONFIRMED', 'broker 未返回匹配的启动确认,需确认遗留会话;未自动新建替代进程', `确认主机会话 ${id} 的状态`);
328
- let transport;
329
- transport = new BrokerTransport({ maxFrameBytes: settings.brokerMaxFrameBytes, attach: after => io.attach(command(['attach', id, '--after', String(after), '--framed'])), onDisconnect: async () => {
330
- const connect = get();
331
- if (!connect)
332
- throw new NeedError('DEPENDENCY_REQUIRED', '我需要连接插件恢复员工连接', 'aiWorkbenchConnect');
333
- const next = unwrap(await connect.getSshTarget());
334
- validateTarget(next);
335
- if (!sameTarget(next, target))
336
- throw new NeedError('SESSION_CONTEXT_MISMATCH', '重连目标与原会话不一致', '原员工主机、可信SSH参数和broker');
337
- io = ioFor(next);
338
- await transport.connect();
339
- } });
340
- const aborted = () => transport.fail(new NeedError('ABORTED', '本轮已取消,正在结束主机进程', '确认主机状态'));
341
- request.signal?.addEventListener('abort', aborted, { once: true });
342
- if (request.signal?.aborted)
343
- aborted();
344
- const dispose = async () => { request.signal?.removeEventListener('abort', aborted); transport.close(); try {
345
- await io.run(command(['kill', id]));
346
- }
347
- catch {
348
- throw new NeedError('REMOTE_CLEANUP_UNCONFIRMED', '无法确认原主机会话已结束;不会自动新建替代进程', '恢复连接并结束对应主机会话');
349
- } };
350
- try {
351
- await transport.connect();
352
- request.signal?.throwIfAborted();
353
- if (request.retainProcess)
354
- request.signal?.removeEventListener('abort', aborted);
355
- }
356
- catch (error) {
357
- await dispose();
358
- throw error;
359
- }
360
- return { identity: identity(target), cwd: project.path, broker: transport, acpTransport: transport, dispose };
361
- };
362
- return async (target, project, request) => {
363
- const key = (request.agent ?? CODEX_AGENT).id + '/' + identity(target) + '/' + request.sessionId;
364
- const existing = request.retainProcess ? retained.get(key) : undefined;
365
- if (existing) {
366
- const settings = request.brokerSettings ?? (typeof configuration === 'function' ? configuration() : configuration);
367
- if (existing.cwd !== project.path || existing.settings.brokerMaxFrameBytes !== settings.brokerMaxFrameBytes || existing.settings.brokerControlMaxBytes !== settings.brokerControlMaxBytes || !sameTarget(existing.target, target))
368
- throw new NeedError('SESSION_CONTEXT_MISMATCH', '会话工作区、可信SSH参数或主机运行设置已改变,请新建会话', '原会话的可信主机、工作区与运行设置');
369
- if (existing.runtime.broker?.error)
370
- throw existing.runtime.broker.error;
371
- return existing.runtime;
372
- }
373
- if (active.has(key))
374
- throw new NeedError('TURN_BUSY', '当前 DSH 会话已有远端运行,不能当作遗留进程结束', '等待当前运行结束');
375
- active.add(key);
376
- try {
377
- const runtime = await open(target, project, request);
378
- const dispose = runtime.dispose;
379
- let disposed = false;
380
- runtime.dispose = async () => { if (disposed)
381
- return; disposed = true; try {
382
- await dispose?.();
383
- }
384
- finally {
385
- active.delete(key);
386
- retained.delete(key);
387
- } };
388
- if (request.retainProcess && request.mode === 'session')
389
- retained.set(key, { runtime, cwd: project.path, settings: { ...(request.brokerSettings ?? (typeof configuration === 'function' ? configuration() : configuration)) }, target });
390
- return runtime;
391
- }
392
- catch (error) {
393
- active.delete(key);
394
- throw error;
395
- }
396
- };
397
- }
397
+ export { brokerFactory } from "./broker-runtime.js";
@@ -1,3 +1,4 @@
1
+ import type { ConnectNeed } from '@ai-workbench/connect/api';
1
2
  import type { ConnectTarget } from './provider.ts';
2
3
  import type { ContractInfo, Provider, WorkspaceUse, WorkspaceNoticeReceipt as LWorkspaceNoticeReceipt } from '@ai-workbench/connect/api';
3
4
  /** 正式L0.7公开类型;消费子集不表示已安装或已通过真实产品门。 */
@@ -16,7 +17,7 @@ export type ConnectResult<T> = {
16
17
  code: string;
17
18
  message: string;
18
19
  requires: string;
19
- };
20
+ } & Pick<ConnectNeed, 'officialReason'>;
20
21
  };
21
22
  export interface NativeWorkspaceApi {
22
23
  getContractInfo(): ContractInfo;
@@ -31,6 +31,7 @@ export type DetailSink = {
31
31
  };
32
32
  /** Only objects created by this module are trusted as redaction markers. */
33
33
  export declare function isSanitizedRedaction(value: unknown): boolean;
34
+ export declare function sanitizeDisplayLabel(value: string): string;
34
35
  /** Sanitize an internal observation without attaching any L wire semantics. */
35
36
  export declare function sanitizeObservation(observation: DetailObservation, protectModelMaterial?: boolean): Readonly<SanitizedObservation>;
36
37
  /** Internal diagnostic recorder; a sink is not an assertion that an L wire API exists. */
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
+ import { STAGES } from "./stage-timing.js";
2
3
  const redactionMarkers = new WeakSet();
3
4
  function redaction(fields) {
4
5
  const marker = { redacted: true, ...fields };
@@ -92,7 +93,7 @@ function safeText(value) {
92
93
  const url = cleanUrls(value);
93
94
  return url.changed ? redaction({ reason: 'URL敏感部分', value: url.value }) : value;
94
95
  }
95
- function safeLabel(value) {
96
+ export function sanitizeDisplayLabel(value) {
96
97
  if (typeof value !== 'string')
97
98
  throw new TypeError('Detail observation labels must be strings');
98
99
  // Inspect credentials before removing control characters, so a bearer newline cannot evade detection.
@@ -254,19 +255,68 @@ function usageStructure(value, key = '') {
254
255
  return value;
255
256
  return opaqueStructure(value);
256
257
  }
258
+ /** Only fixed local stage names and finite nonnegative millisecond observations. */
259
+ function timingStructure(value) {
260
+ if (!value || typeof value !== 'object' || Array.isArray(value))
261
+ return opaqueStructure(value);
262
+ return Object.fromEntries(Object.entries(value).map(([key, child]) => {
263
+ if (key === 'phase' && typeof child === 'string' && Object.hasOwn(STAGES, child))
264
+ return [key, child];
265
+ if (key === 'clock' && child === 'client-monotonic' || key === 'outcome' && (child === 'ok' || child === 'failed'))
266
+ return [key, child];
267
+ if (['durationMs', 'elapsedMs'].includes(key) && typeof child === 'number' && Number.isFinite(child) && child >= 0)
268
+ return [key, child];
269
+ return [key, opaqueStructure(child)];
270
+ }));
271
+ }
272
+ /** Only our two model-configuration observations approve these exact diagnostic
273
+ * paths. Directory source, config metadata and arbitrary subtrees stay opaque. */
274
+ function modelConfigurationStructure(value, level = 'root') {
275
+ if (!value || typeof value !== 'object' || Array.isArray(value))
276
+ return opaqueStructure(value);
277
+ if (isSanitizedRedaction(value))
278
+ return value;
279
+ const strings = {
280
+ root: ['resolvedModel', 'requestedEffort', 'reason'], selected: ['id', 'value', 'name', 'resolvedModel', 'effort'],
281
+ actual: ['model', 'effort', 'modelConfigId', 'effortConfigId'], reasoning: ['defaultValue'], effort: ['value', 'name'],
282
+ };
283
+ return Object.fromEntries(Object.entries(value).map(([key, child]) => {
284
+ if (typeof child === 'string' && strings[level].includes(key))
285
+ return [key, child];
286
+ if (level === 'root' && key === 'status' && child === 'remote-configured')
287
+ return [key, child];
288
+ if (level === 'actual' && typeof child === 'boolean' && ['confirmed', 'effortAvailable'].includes(key))
289
+ return [key, child];
290
+ if (level === 'reasoning' && key === 'defaultKnown' && typeof child === 'boolean')
291
+ return [key, child];
292
+ if (child === null && ((level === 'root' && ['actual', 'requestedEffort'].includes(key)) || (level === 'selected' && key === 'resolvedModel') || (level === 'reasoning' && key === 'defaultValue')))
293
+ return [key, null];
294
+ if (level === 'root' && key === 'selected')
295
+ return [key, modelConfigurationStructure(child, 'selected')];
296
+ if (level === 'root' && ['configured', 'actual'].includes(key))
297
+ return [key, modelConfigurationStructure(child, 'actual')];
298
+ if (level === 'selected' && key === 'reasoning')
299
+ return [key, modelConfigurationStructure(child, 'reasoning')];
300
+ if (level === 'reasoning' && key === 'options' && Array.isArray(child))
301
+ return [key, child.map(option => modelConfigurationStructure(option, 'effort'))];
302
+ return [key, opaqueStructure(child)];
303
+ }));
304
+ }
257
305
  /** Sanitize an internal observation without attaching any L wire semantics. */
258
306
  export function sanitizeObservation(observation, protectModelMaterial = false) {
259
307
  const payload = snapshotPayload(observation.payload, new Set(), protectModelMaterial && observation.kind !== 'commands');
260
308
  return freezeDeep({
261
309
  kind: observation.kind, severity: observation.severity,
262
- code: safeLabel(observation.code), summary: safeLabel(observation.summary),
263
- ...(observation.method === undefined ? {} : { method: safeLabel(observation.method) }),
310
+ code: sanitizeDisplayLabel(observation.code), summary: sanitizeDisplayLabel(observation.summary),
311
+ ...(observation.method === undefined ? {} : { method: sanitizeDisplayLabel(observation.method) }),
264
312
  payload: !protectModelMaterial ? payload
265
- : observation.kind === 'commands' ? catalogStructure(payload)
266
- : observation.kind === 'usage' ? usageStructure(payload)
267
- : observation.kind === 'need' ? payload
268
- : observation.kind === 'tool' || ['SDK_ITEM', 'SDK_TOOL', 'ACP_SESSION_UPDATE', 'ACP_IDLE_UPDATE'].includes(observation.code) ? toolStructure(payload)
269
- : opaqueStructure(payload),
313
+ : observation.kind === 'session' && ['MODEL_CONFIGURATION_CONFIRMED', 'MODEL_CONFIGURATION_FAILED'].includes(observation.code) ? modelConfigurationStructure(payload)
314
+ : observation.kind === 'commands' ? catalogStructure(payload)
315
+ : observation.kind === 'usage' ? usageStructure(payload)
316
+ : observation.kind === 'session' && observation.code === 'REMOTE_STAGE_TIMING' ? timingStructure(payload)
317
+ : observation.kind === 'need' ? payload
318
+ : observation.kind === 'tool' || ['SDK_ITEM', 'SDK_TOOL', 'ACP_SESSION_UPDATE', 'ACP_IDLE_UPDATE'].includes(observation.code) ? toolStructure(payload)
319
+ : opaqueStructure(payload),
270
320
  });
271
321
  }
272
322
  function validatedScope(scope) {
package/dist/index.d.ts CHANGED
@@ -5,6 +5,8 @@ import { type AcpHooks } from './acp.ts';
5
5
  import { type RuntimeProvider } from './provider.ts';
6
6
  import { type AgentDefinition } from './agents.ts';
7
7
  import { type ImageReader } from './input.ts';
8
+ import type { LoginDiagnostic } from './login-diagnostic.ts';
9
+ import { type ModelDirectory, type HostModel } from './model-catalog.ts';
8
10
  export declare const name = "ai-workbench-remote-agent";
9
11
  export declare const settingsNamespace = "ai-workbench-codex";
10
12
  export declare const inject: string[];
@@ -47,27 +49,38 @@ type LiveConfig = {
47
49
  };
48
50
  /** DSH volatile settings are handles; adapters freeze their values once per invocation. */
49
51
  export declare function readConfig(config: LiveConfig): PluginConfig;
52
+ export type LoginFailureReporter = (sessionId: string | undefined, code: string, diagnostic: LoginDiagnostic) => Promise<{
53
+ eventId: string;
54
+ recorded: true;
55
+ }>;
50
56
  export declare class CodexRemoteAdapter extends LlmAdapter {
51
57
  private resolveRuntime;
52
58
  private approve?;
53
59
  private readonly providerName;
54
60
  private readonly agent;
55
61
  private readonly readImage?;
62
+ private readonly reportLoginFailure?;
63
+ private readonly directory?;
56
64
  private config;
57
65
  private sessions;
58
66
  private turns;
59
67
  private lifetime;
60
- constructor(config: PluginConfig | (() => PluginConfig), resolveRuntime: RuntimeProvider, approve?: InvocationApproval | undefined, providerName?: () => string, agent?: AgentDefinition, readImage?: ImageReader | undefined);
68
+ private shuttingDown;
69
+ private detached;
70
+ private cleanups;
71
+ constructor(config: PluginConfig | (() => PluginConfig), resolveRuntime: RuntimeProvider, approve?: InvocationApproval | undefined, providerName?: () => string, agent?: AgentDefinition, readImage?: ImageReader | undefined, reportLoginFailure?: LoginFailureReporter | undefined, directory?: ModelDirectory | undefined);
61
72
  providerInfo(provider: string): {
62
73
  id: string;
63
74
  name: string;
64
75
  };
65
- listModels(provider: string): Promise<{
66
- provider: string;
67
- id: string;
68
- name: string;
69
- inputModalities: ("text" | "image")[];
70
- }[]>;
76
+ private catalog;
77
+ listModels(provider: string): Promise<(import("@deepseek-ai/dsh-llm").LlmResolvedModelInfo & {
78
+ catalog: HostModel;
79
+ })[]>;
80
+ resolveModel(provider: string, id: string, signal?: AbortSignal): Promise<import("@deepseek-ai/dsh-llm").LlmResolvedModelInfo & {
81
+ catalog: HostModel;
82
+ }>;
83
+ invalidateModelState(): Promise<void>;
71
84
  providerRetryPolicy(): {
72
85
  mode: "normal";
73
86
  maxRetries: number;
@@ -76,7 +89,9 @@ export declare class CodexRemoteAdapter extends LlmAdapter {
76
89
  maxDelayMs: number;
77
90
  jitterRatio: number;
78
91
  };
92
+ private cleanupRuntime;
79
93
  disposeSession(sessionId: string): Promise<void>;
94
+ detachSession(sessionId: string): Promise<void>;
80
95
  dispose(): Promise<void>;
81
96
  stream(options: GenerateOptions): AsyncGenerator<StreamChunk>;
82
97
  private generate;
@@ -84,18 +99,19 @@ export declare class CodexRemoteAdapter extends LlmAdapter {
84
99
  /** One official registration owns both routes, preserving opaque replay ownership. */
85
100
  export declare class RemoteAgentsAdapter extends LlmAdapter {
86
101
  private adapters;
87
- constructor(config: () => PluginConfig, makeRuntime: (agent: AgentDefinition) => RuntimeProvider, approve: InvocationApproval, nameFor: (agent: AgentDefinition) => string, readImage?: ImageReader);
102
+ constructor(config: () => PluginConfig, makeRuntime: (agent: AgentDefinition) => RuntimeProvider, approve: InvocationApproval, nameFor: (agent: AgentDefinition) => string, readImage?: ImageReader, reportLoginFailure?: LoginFailureReporter, directory?: ModelDirectory);
88
103
  private adapter;
89
104
  providerInfo(provider: string): {
90
105
  id: string;
91
106
  name: string;
92
107
  };
93
- listModels(provider: string): Promise<{
94
- provider: string;
95
- id: string;
96
- name: string;
97
- inputModalities: ("text" | "image")[];
98
- }[]>;
108
+ listModels(provider: string): Promise<(import("@deepseek-ai/dsh-llm").LlmResolvedModelInfo & {
109
+ catalog: HostModel;
110
+ })[]>;
111
+ resolveModel(provider: string, model: string, signal?: AbortSignal): Promise<import("@deepseek-ai/dsh-llm").LlmResolvedModelInfo & {
112
+ catalog: HostModel;
113
+ }>;
114
+ invalidateModelState(): Promise<void>;
99
115
  providerRetryPolicy(): {
100
116
  mode: "normal";
101
117
  maxRetries: number;
@@ -106,6 +122,7 @@ export declare class RemoteAgentsAdapter extends LlmAdapter {
106
122
  };
107
123
  stream(options: GenerateOptions): AsyncGenerator<StreamChunk, any, any>;
108
124
  disposeSession(sessionId: string): Promise<void>;
125
+ detachSession(sessionId: string): Promise<void>;
109
126
  dispose(): Promise<void>;
110
127
  }
111
128
  export declare function apply(ctx: Context, live: LiveConfig): void;