@ai-workbench/remote-agent 0.8.26 → 0.8.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # AI Workbench 远程助手 · @ai-workbench/remote-agent@0.8.26 · 插件 → 添加插件
1
+ # AI Workbench 远程助手 · @ai-workbench/remote-agent@0.8.28 · 插件 → 添加插件
2
2
 
3
3
  通过官方 DSH 插件入口使用 Claude(远程主机)与 Codex(远程主机)。登录和工作区来源由“远程主机连接”组件提供;员工在原生工作区和模型菜单中选择文件夹与型号。
4
4
 
@@ -8,11 +8,15 @@
8
8
 
9
9
  Claude 本机 Bash 会单独消费员工桥代理的控制诊断,不把它交给模型当作命令输出;真实传输中断仍提示结果未知。命令自身的标准输出、标准错误与退出码照常返回。
10
10
 
11
+ 模型列表读取会随连接状态或连接代际变化取消,连接恢复时通过官方机制刷新。目录失败原因写入当前 profile 的 `ai-workbench-remote-agent/logs/model-catalog.jsonl`(脱敏);未增加单次读取时限或重试。
12
+
11
13
  ## 会话行为
12
14
 
13
15
  每轮启动一个新助手进程,按原生会话编号恢复上下文;不接回旧进程或重放上一轮输入。主机会话始终使用创建时保存的主机和目录;工作区之后的选择只影响新会话。
14
16
 
15
- 断线会结束本轮;操作可能已部分执行,请先检查文件。恢复连接后可在原会话继续。旧进程清理和技术详情记录尽力而为,不阻止下一轮。原生本机会话恢复的兼容性及实际设备结果见本轮交付证据,未验证项不作为已通过。
17
+ 同一 DSH 会话切换 Claude/Codex 时,切回会接本品牌最近的远端会话,并补充它尚未见过的可见对话;首次使用某品牌则带入全部可见历史。工具内部状态、推理和技术详情不转交。历史超出既有 `acpMaxMessageBytes` 容量时,只带能容纳的最近完整轮,并在技术详情说明省略数量;当前输入保持完整,超限仍明确失败。没有新增设置、重试或时限。
18
+
19
+ 断线会结束本轮;已派发的操作可能已部分执行,请先检查文件。恢复连接后可在原会话继续。旧进程清理和技术详情记录尽力而为,不阻止发起下一轮;如果上一轮仍占用原生线程,续接会明确失败并说明本轮输入未发送,不新建替代线程。自动定位、清理同一会话/线程的已断开旧进程仍需 S 提供公开能力,当前不会自动杀旧进程或冒称已恢复。原生本机会话恢复的兼容性及实际设备结果见本轮交付证据,未验证项不作为已通过。
16
20
 
17
21
  DSH 启动和打开模型菜单时读取一次主机型号列表。发送时不再查询目录或预核所选型号;所选型号不可用时,请在模型菜单重新选择。
18
22
 
package/dist/acp.js CHANGED
@@ -605,6 +605,8 @@ export function acpNeed(error) {
605
605
  if (error instanceof NeedError)
606
606
  return error;
607
607
  if (error instanceof acp.RequestError) {
608
+ if (record(error.data)?.reason === 'thread_active_writer')
609
+ return new NeedError('REMOTE_SESSION_BUSY', employeeFailure('REMOTE_SESSION_BUSY'), '原远端线程仍被占用;需要按同一DSH会话和远端线程定位已断开的旧broker并确认释放;RPC 错误码 ' + error.code);
608
610
  // Host SDK1.9 uses Internal error + data.details for thrown adapter errors.
609
611
  // Only fixed codes cross this boundary; arbitrary detail strings are never copied.
610
612
  for (const value of [error.message, record(error.data)?.details]) {
@@ -14,7 +14,8 @@ export interface CatalogSshEvent {
14
14
  export declare function catalogSafeText(value: string): string;
15
15
  export declare class CatalogSshError extends ModelConfigurationError {
16
16
  readonly event: CatalogSshEvent;
17
- constructor(reason: string, event: CatalogSshEvent);
17
+ private readonly stderr;
18
+ constructor(reason: string, event: CatalogSshEvent, stderr?: string);
18
19
  get diagnostic(): string;
19
20
  }
20
21
  export type CatalogSshObserver = (event: Readonly<CatalogSshEvent>) => void | Promise<void>;
@@ -49,11 +49,13 @@ export function catalogSafeText(value) {
49
49
  }
50
50
  export class CatalogSshError extends ModelConfigurationError {
51
51
  event;
52
- constructor(reason, event) {
52
+ stderr;
53
+ constructor(reason, event, stderr = '') {
53
54
  super(reason);
54
55
  this.event = event;
56
+ this.stderr = stderr;
55
57
  }
56
- get diagnostic() { return '目录连接技术详情:' + JSON.stringify(this.event); }
58
+ get diagnostic() { return '目录连接技术详情:' + JSON.stringify(this.event) + (this.stderr ? '\nSSH诊断:' + this.stderr : ''); }
57
59
  }
58
60
  /** Each query starts a fresh strict L SSH invocation. No connection pool or retry. */
59
61
  export async function runCatalog(target, command, signal, maxBytes, observe) {
@@ -76,10 +78,12 @@ export async function runCatalog(target, command, signal, maxBytes, observe) {
76
78
  return new Promise((resolve, reject) => {
77
79
  const child = spawn('ssh', argv, { stdio: ['ignore', 'pipe', 'pipe'] });
78
80
  const output = [], diagnostics = [];
79
- let total = 0, stdoutBytes = 0, stderrBytes = 0, processErrorCode = null, failure;
80
- const abort = () => { failure ??= { code: 'MODEL_CATALOG_CANCELLED', reason: '连接已改变或目录查询已取消' }; child.kill('SIGTERM'); };
81
+ let settled = false, total = 0, stdoutBytes = 0, stderrBytes = 0, processErrorCode = null, failure;
82
+ const abort = () => { failure ??= { code: 'MODEL_CATALOG_CANCELLED', reason: '连接已变化或读取已取消;连接恢复后请重新打开模型菜单。' }; child.kill('SIGTERM'); complete(null, null); };
81
83
  signal.addEventListener('abort', abort, { once: true });
82
84
  const receive = (buffer, stdout) => {
85
+ if (settled)
86
+ return;
83
87
  if (stdout)
84
88
  stdoutBytes += buffer.length;
85
89
  else
@@ -88,46 +92,49 @@ export async function runCatalog(target, command, signal, maxBytes, observe) {
88
92
  if (failure)
89
93
  return;
90
94
  if (total > maxBytes) {
91
- failure = { code: 'MODEL_CATALOG_OUTPUT_LIMIT', reason: '目录响应超过 acpMaxMessageBytes,未截断返回' };
95
+ failure = { code: 'MODEL_CATALOG_OUTPUT_LIMIT', reason: '主机返回的模型列表过大,暂时无法显示;请联系管理员检查后重新打开模型菜单。' };
92
96
  child.kill('SIGTERM');
97
+ complete(null, null);
93
98
  return;
94
99
  }
95
100
  (stdout ? output : diagnostics).push(buffer);
96
101
  };
97
102
  child.stdout.on('data', (b) => receive(b, true));
98
103
  child.stderr.on('data', (b) => receive(b, false));
99
- child.on('error', (error) => { processErrorCode = typeof error.code === 'string' && /^[A-Z0-9_]+$/.test(error.code) ? error.code : 'SSH_SPAWN_FAILED'; failure ??= { code: processErrorCode, reason: '可信 SSH 进程启动失败' }; });
100
- child.once('close', (code, closeSignal) => {
101
- void (async () => {
102
- signal.removeEventListener('abort', abort);
103
- let value = '', typed = false;
104
- try {
105
- value = new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(output));
106
- }
107
- catch {
108
- failure ??= { code: 'MODEL_CATALOG_UTF8_INVALID', reason: '主机目录响应不是有效 UTF-8,未替换模型值' };
109
- }
110
- try {
111
- typed = JSON.parse(value)?.ok === false;
112
- }
113
- catch { }
114
- if (!failure && code !== 0 && !typed)
115
- failure = { code: 'SSH_EXIT_NONZERO', reason: '模型列表暂时无法读取,请检查连接后重新打开模型菜单。' };
116
- const final = { provider, exitCode: code ?? null, signal: closeSignal ?? null, stdoutBytes, stderrBytes, elapsedMs: performance.now() - tick, failureCode: failure?.code ?? null, processErrorCode };
117
- console.info('[remote-agent] model-list ' + provider + ' exit=' + final.exitCode + ' bytes=' + stdoutBytes + ' elapsedMs=' + final.elapsedMs.toFixed(1));
118
- try {
119
- void Promise.resolve(observe?.(final)).catch(() => console.warn('[remote-agent] 模型列表查询日志未保存'));
120
- }
121
- catch {
122
- console.warn('[remote-agent] 模型列表查询日志未保存');
123
- }
124
- if (failure) {
125
- reject(new CatalogSshError(failure.reason, final));
126
- return;
127
- }
128
- resolve(value);
129
- })().catch(reject);
130
- });
104
+ child.on('error', (error) => { processErrorCode = typeof error.code === 'string' && /^[A-Z0-9_]+$/.test(error.code) ? error.code : 'SSH_SPAWN_FAILED'; failure ??= { code: processErrorCode, reason: '连接程序未能启动,暂时读不到模型列表;请从远程主机连接检查组件后重新打开模型菜单。' }; complete(null, null); });
105
+ const complete = (code, closeSignal) => {
106
+ if (settled)
107
+ return;
108
+ settled = true;
109
+ signal.removeEventListener('abort', abort);
110
+ let value = '', typed = false;
111
+ try {
112
+ value = new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(output));
113
+ }
114
+ catch {
115
+ failure ??= { code: 'MODEL_CATALOG_UTF8_INVALID', reason: '主机返回的模型列表内容损坏,暂时无法读取;请联系管理员检查后重新打开模型菜单。' };
116
+ }
117
+ try {
118
+ typed = JSON.parse(value)?.ok === false;
119
+ }
120
+ catch { }
121
+ if (!failure && code !== 0 && !typed)
122
+ failure = { code: 'SSH_EXIT_NONZERO', reason: '模型列表暂时无法读取,请检查远程主机连接后重新打开模型菜单。' };
123
+ const final = { provider, exitCode: code ?? null, signal: closeSignal ?? null, stdoutBytes, stderrBytes, elapsedMs: performance.now() - tick, failureCode: failure?.code ?? null, processErrorCode };
124
+ console.info('[remote-agent] model-list ' + provider + ' exit=' + final.exitCode + ' bytes=' + stdoutBytes + ' elapsedMs=' + final.elapsedMs.toFixed(1));
125
+ try {
126
+ void Promise.resolve(observe?.(final)).catch(() => console.warn('[remote-agent] 模型列表查询日志未保存'));
127
+ }
128
+ catch {
129
+ console.warn('[remote-agent] 模型列表查询日志未保存');
130
+ }
131
+ if (failure) {
132
+ reject(new CatalogSshError(failure.reason, final, catalogSafeText(Buffer.concat(diagnostics).toString('utf8'))));
133
+ return;
134
+ }
135
+ resolve(value);
136
+ };
137
+ child.once('close', (code, closeSignal) => complete(code, closeSignal));
131
138
  if (signal.aborted)
132
139
  abort();
133
140
  });
package/dist/index.js CHANGED
@@ -12,7 +12,7 @@ import { StreamMapper } from "./mapping.js";
12
12
  import { connectProvider } from "./provider.js";
13
13
  import { brokerFactory } from "./broker.js";
14
14
  import { AGENTS, CODEX_AGENT } from "./agents.js";
15
- import { prepareInput, projectHostWorkspaceInput } from "./input.js";
15
+ import { prepareInput, prepareContinuation } from "./input.js";
16
16
  import { NeedError } from "./remote.js";
17
17
  import { employeeFailure } from "./presentation.js";
18
18
  import { createRemoteDetails } from "./remote-details.js";
@@ -53,16 +53,16 @@ function modelResolutionLabel(configuration) {
53
53
  : `具体型号:${configuration.resolvedModel}`;
54
54
  }
55
55
  function readPointer(options, agent) {
56
- const last = options.messages.findLast(message => message.role === 'assistant');
57
- if (!last || last.role !== 'assistant')
58
- return;
59
- if (last.source.provider !== options.provider)
60
- return;
61
- const envelope = last.source.replayState;
62
- const pointer = envelope?.response;
63
- if (pointer?.version !== 2 || pointer.agent !== agent.id || pointer.dshSessionId !== options.sessionId || ![pointer.identity, pointer.cwd, pointer.sessionId].every(value => typeof value === 'string' && value.length > 0))
64
- return;
65
- return pointer;
56
+ for (let index = options.messages.length - 1; index >= 0; index--) {
57
+ const message = options.messages[index];
58
+ if (message.role !== 'assistant' || message.source.provider !== options.provider)
59
+ continue;
60
+ const envelope = message.source.replayState;
61
+ const pointer = envelope?.response;
62
+ if (pointer?.version !== 2 || pointer.agent !== agent.id || pointer.dshSessionId !== options.sessionId || ![pointer.identity, pointer.cwd, pointer.sessionId].every(value => typeof value === 'string' && value.length > 0) || pointer.contextSha256 !== undefined && typeof pointer.contextSha256 !== 'string')
63
+ continue;
64
+ return { pointer: pointer, index };
65
+ }
66
66
  }
67
67
  // A reporting failure can wrap the original admission error. Preserve its
68
68
  // already-safe login snapshot even when a newer preflight delivery is unknown.
@@ -115,7 +115,8 @@ export class CodexRemoteAdapter extends LlmAdapter {
115
115
  providerInfo(provider) { return { id: provider, name: this.providerName() }; }
116
116
  catalog(signal) { if (!this.directory)
117
117
  throw new LlmError('无法获取模型列表:缺少可信主机目录能力', 'MODEL_CATALOG_UNAVAILABLE'); return this.directory.read(this.agent, signal); }
118
- async listModels(provider) { const catalog = await this.catalog(); try {
118
+ async listModels(provider) { const catalog = await this.catalog(this.lifetime.signal); try {
119
+ this.lifetime.signal.throwIfAborted();
119
120
  this.modelPresentation.remember(catalog);
120
121
  return catalog.models.map(model => modelInfo(provider, model));
121
122
  }
@@ -196,16 +197,17 @@ export class CodexRemoteAdapter extends LlmAdapter {
196
197
  throw failure.reason;
197
198
  }
198
199
  async *stream(options) {
200
+ let promptDispatched = false;
199
201
  try {
200
- yield* this.generate(options);
202
+ yield* this.generate(options, () => { promptDispatched = true; });
201
203
  }
202
204
  catch (error) {
203
205
  if (error instanceof ModelConfigurationError)
204
- throw new LlmError(employeeFailure(error.code, this.agent.loginProvider), error.code, { cause: error });
206
+ throw new LlmError(employeeFailure(error.code, this.agent.loginProvider, promptDispatched), error.code, { cause: error });
205
207
  if (error instanceof NeedError) {
206
208
  const rpc = /RPC 错误码 (-?\d+)/u.exec(error.requires)?.[1];
207
209
  const evidence = failureEvidenceSummary(error);
208
- const message = connectNeedMessage(error) ?? employeeFailure(error.code, this.agent.loginProvider);
210
+ const message = connectNeedMessage(error) ?? employeeFailure(error.code, this.agent.loginProvider, promptDispatched);
209
211
  const login = originalLoginFailure(error);
210
212
  let diagnostic = login?.technical ? JSON.stringify(login.technical) : '';
211
213
  if (login?.technical && this.reportLoginFailure) {
@@ -219,10 +221,10 @@ export class CodexRemoteAdapter extends LlmAdapter {
219
221
  }
220
222
  throw new LlmError(message, error.code, { cause: new Error(`诊断代码:${error.code};${error.requires}` + (evidence ? ';' + evidence : '') + (diagnostic ? ';' + diagnostic : '')) });
221
223
  }
222
- throw new LlmError(employeeFailure('TURN_FAILED', this.agent.loginProvider), 'TURN_FAILED', { cause: error });
224
+ throw new LlmError(employeeFailure('TURN_FAILED', this.agent.loginProvider, promptDispatched), 'TURN_FAILED', { cause: error });
223
225
  }
224
226
  }
225
- async *generate(original) {
227
+ async *generate(original, onPromptDispatched) {
226
228
  if (original.purpose)
227
229
  throw new NeedError('PURPOSE_UNSUPPORTED', '此远程模型不处理后台标题或上下文整理请求,未发送模型请求', '独立的后台请求能力');
228
230
  if (this.lifetime.signal.aborted)
@@ -271,12 +273,10 @@ export class CodexRemoteAdapter extends LlmAdapter {
271
273
  try {
272
274
  if (!Number.isSafeInteger(config.acpMaxMessageBytes) || config.acpMaxMessageBytes < 1)
273
275
  throw new NeedError('SETTINGS_REQUIRED', '远程模型连接设置无效,请联系管理员检查', '有效的acpMaxMessageBytes');
274
- const previous = readPointer(options, this.agent);
276
+ const checkpoint = readPointer(options, this.agent), previous = checkpoint?.pointer;
275
277
  employeeNoticeShown = !!key && this.employeeNoticeSessions.has(key) || false;
276
278
  if (employeeNoticeShown && key)
277
279
  this.employeeNoticeSessions.add(key);
278
- const last = options.messages.findLast(message => message.role === 'assistant');
279
- const missingPointer = !!last && !previous;
280
280
  live = undefined;
281
281
  const configuration = JSON.stringify([config.acpMaxMessageBytes, config.brokerMaxFrameBytes, config.brokerControlMaxBytes, config.employeeShellKillGraceMs]);
282
282
  Object.assign(request, { acpMaxMessageBytes: config.acpMaxMessageBytes, resumeSessionId: previous?.sessionId, sessionId: options.sessionId, mode, hostSource: previous?.hostSource, cwd: previous?.cwd, identity: previous?.identity, bindingId: previous?.bindingId, workspaceId: previous?.workspaceId, agent: this.agent, signal, brokerSettings: Object.freeze({ brokerMaxFrameBytes: config.brokerMaxFrameBytes, brokerControlMaxBytes: config.brokerControlMaxBytes }) });
@@ -306,7 +306,6 @@ export class CodexRemoteAdapter extends LlmAdapter {
306
306
  // New Codex transport scopes are independent of the native context digest.
307
307
  if (contextChanged)
308
308
  request.contextSha256 = undefined;
309
- const claudeContextChanged = !!previous && !codex && previous.contextSha256 !== contextSha256;
310
309
  if (mode === 'session' && Buffer.byteLength(JSON.stringify({ prompt: input.prompt, systemPrompt: input.systemPrompt }), 'utf8') > config.acpMaxMessageBytes)
311
310
  throw new NeedError('ACP_MESSAGE_TOO_LARGE', '本轮输入超过已设置的会话消息容量,未截断或发送', '管理员调整acpMaxMessageBytes');
312
311
  selected = this.modelPresentation.find(options.model) ?? { id: options.model, value: options.model, name: options.model, resolvedModel: null, reasoning: { options: [], defaultValue: null, defaultKnown: false }, source: null };
@@ -326,12 +325,14 @@ export class CodexRemoteAdapter extends LlmAdapter {
326
325
  }
327
326
  if (previous && !runtime.hostSource && (previous.identity !== runtime.identity || previous.cwd !== runtime.cwd || previous.bindingId && previous.bindingId !== runtime.binding?.bindingId || previous.workspaceId && previous.workspaceId !== runtime.binding?.workspaceId))
328
327
  throw new NeedError('SESSION_CONTEXT_MISMATCH', '原会话的员工主机或工作区已改变,请新建会话', '原会话的可信目标和工作区');
329
- input = projectHostWorkspaceInput(input, runtime.hostWorkspace);
328
+ input = await prepareContinuation({ ...options, messages: options.messages.slice((checkpoint?.index ?? -1) + 1) }, input, this.readImage, { maxBytes: config.acpMaxMessageBytes, host: runtime.hostWorkspace });
330
329
  if (runtime.hostWorkspace && mode === 'session' && Buffer.byteLength(JSON.stringify({ prompt: input.prompt, systemPrompt: input.systemPrompt }), 'utf8') > config.acpMaxMessageBytes)
331
330
  throw new NeedError('ACP_MESSAGE_TOO_LARGE', '本轮输入超过已设置的会话消息容量,未截断或发送', '管理员调整acpMaxMessageBytes');
332
331
  const details = runtime.details ?? await createRemoteDetails({ warn: line => console.warn(line) }).open({ sessionId: options.sessionId ?? 'unscoped', provider: this.agent.loginProvider, identity: runtime.identity, connection: runtime.connection });
333
332
  recordInformation();
334
333
  request.recordEarlyFailure = undefined;
334
+ if (input.historyTransfer)
335
+ details.record({ kind: 'session', severity: input.historyTransfer.omittedRounds ? 'warning' : 'info', code: input.historyTransfer.omittedRounds ? 'HISTORY_TRANSFER_TRIMMED' : 'HISTORY_TRANSFER', summary: input.historyTransfer.omittedRounds ? `可见历史超过既有会话消息容量,保留最近${input.historyTransfer.retainedRounds}轮,省略较早${input.historyTransfer.omittedRounds}轮;当前输入完整保留。` : '已补充当前远端会话尚未见过的可见对话,未重放工具或审批。', payload: input.historyTransfer });
335
336
  if (runtime.hostWorkspace)
336
337
  details.record({ kind: 'session', severity: 'info', code: 'HOST_WORKSPACE_CONTEXT', summary: '本轮模型工作区上下文及审批会话目录使用已确认的主机目录。', payload: { source: 'host', cwd: runtime.hostWorkspace.cwd } });
337
338
  const mapper = new StreamMapper(mode === 'session' && !contextChanged ? (previous ? (previous.usageSnapshot ?? null) : undefined) : undefined, observation => details.record(observation));
@@ -437,6 +438,12 @@ export class CodexRemoteAdapter extends LlmAdapter {
437
438
  remoteAttempted = true;
438
439
  try {
439
440
  await session.start(runtime.execution?.hostCwd ?? runtime.cwd, previous?.sessionId, modelSignal, { systemPrompt: input.systemPrompt, ...(runtime.execution ? { employeeRouting: runtime.execution.routing } : {}), ...(codex ? { codexNewSessionOnly: true } : {}), ...(runtime.employeeExecutionMeta ? { employeeExecution: runtime.employeeExecutionMeta } : {}) }, input.prompt);
441
+ }
442
+ catch (error) {
443
+ details.record({ kind: 'session', severity: 'action-required', code: 'SESSION_START_FAILED', summary: '远端会话建立或恢复失败,本轮用户输入未发送。', payload: { operation: previous ? 'resume-or-load' : 'new', reason: closeCode(error, 'ACP_FAILED'), promptDispatched: false } });
444
+ throw error;
445
+ }
446
+ try {
440
447
  const actual = await session.selectModel(selected.value, options.reasoningEffort, modelSignal);
441
448
  // This lease is tied to the trusted host-user and instance/epoch.
442
449
  // Its mapping and this turn's exact setter ACK are different facts.
@@ -531,6 +538,8 @@ export class CodexRemoteAdapter extends LlmAdapter {
531
538
  yield { type: 'finish', reason: { kind: stop === 'max_tokens' || stop === 'max_turn_requests' ? 'max-tokens' : 'stop' }, replayState: { response: pointer } };
532
539
  }
533
540
  catch (error) {
541
+ if (session && session.promptDispatchCount > promptBaseline)
542
+ onPromptDispatched();
534
543
  turnFailure = error;
535
544
  runtime?.closeDiagnostics?.record({ layer: 'adapter', initiator: 'adapter.turn-failure', reason: closeCode(error, signal.aborted ? 'ABORTED' : 'TURN_FAILED'), action: 'failure' });
536
545
  recordInformation();
@@ -621,7 +630,7 @@ export function apply(ctx, live) {
621
630
  };
622
631
  // Public Loader Entry.options.id; optional when mounted without Loader.
623
632
  const actualSettingsNamespace = ctx.fiber.entry?.options.id;
624
- const modelDirectory = new ModelCatalog(get, () => config().acpMaxMessageBytes);
633
+ const modelDirectory = new ModelCatalog(get, () => config().acpMaxMessageBytes, undefined, (provider, error) => managerForProfile().recordCatalogFailure(provider, error));
625
634
  const adapter = new RemoteAgentsAdapter(config, agent => {
626
635
  const details = async (request, api, connection, target) => {
627
636
  return managerForProfile().open({ sessionId: request.sessionId, provider: agent.loginProvider, identity: `${target.username}@${target.host}:${target.port}`, connection });
@@ -689,8 +698,10 @@ export function apply(ctx, live) {
689
698
  const previous = connection;
690
699
  connection = state;
691
700
  // Official replacement invalidates the browser's shared catalog and loads it.
692
- if (state.phase === 'connected' && (previous?.phase !== 'connected' || previous.instanceId !== state.instanceId || previous.epoch !== state.epoch))
701
+ if (previous?.phase !== state.phase || previous.instanceId !== state.instanceId || previous.epoch !== state.epoch) {
702
+ modelDirectory.cancelPending();
693
703
  registration.replace(ids);
704
+ }
694
705
  });
695
706
  installModelInputRetention(ctx, { isOurProvider: provider => ids.includes(provider), diagnosticForAttempt: position => preflightDiagnostics.forPreparation(position.sessionId, position), preflightDiagnostic: (provider, message) => diagnostic('preflight', provider, message), diagnostic: (provider, message) => diagnostic('input', provider, message) });
696
707
  const cleanup = async (id) => { try {
package/dist/input.d.ts CHANGED
@@ -11,6 +11,9 @@ export interface HistoryTransfer {
11
11
  offloadedImagePlaceholders: number;
12
12
  omittedReasoningBlocks: number;
13
13
  reasoningOmissionReason: 'dsh-internal-reasoning';
14
+ retainedRounds: number;
15
+ omittedRounds: number;
16
+ maxBytes: number;
14
17
  }
15
18
  export interface HostWorkspacePresentation {
16
19
  readonly cwd: string;
@@ -32,7 +35,10 @@ export interface PreparedInput {
32
35
  /** Invocation-only model context; never modifies the native session/history. */
33
36
  export declare function projectHostWorkspaceInput(input: PreparedInput, host?: HostWorkspacePresentation): PreparedInput;
34
37
  /** Role-labelled public history only; never serialize source/replay/tool internals. */
35
- export declare function prepareContinuation(options: GenerateOptions, input: PreparedInput, readImage?: ImageReader): Promise<PreparedInput>;
38
+ export declare function prepareContinuation(options: GenerateOptions, input: PreparedInput, readImage: ImageReader | undefined, capacity: {
39
+ maxBytes: number;
40
+ host?: HostWorkspacePresentation;
41
+ }): Promise<PreparedInput>;
36
42
  /** The attachment service supplies verified bytes; client paths never cross SSH. */
37
43
  export declare function prepareInput(options: GenerateOptions, readImage?: ImageReader): Promise<PreparedInput>;
38
44
  export {};
package/dist/input.js CHANGED
@@ -50,45 +50,64 @@ async function publicBlock(block, options, readImage, history = false) {
50
50
  throw new NeedError(history ? 'HISTORY_CONTENT_UNSUPPORTED' : 'INPUT_TYPE_REQUIRED', `我需要此${history ? '历史' : '输入'}类型的远程转接能力:${block.type};本轮未新建或发送`, '对应类型的公开ACP输入映射;内部推理及工具记录不可降为用户要求');
51
51
  }
52
52
  /** Role-labelled public history only; never serialize source/replay/tool internals. */
53
- export async function prepareContinuation(options, input, readImage) {
54
- const messages = options.messages;
55
- for (const message of messages)
56
- if (!['user', 'assistant', 'system', 'developer'].includes(message.role))
57
- throw new NeedError('HISTORY_CONTENT_UNSUPPORTED', `此前含有无法可靠承接的历史角色:${message.role};本轮未新建或发送`, '完整公开的用户及助手历史;工具记录的公开内容映射');
58
- const after = messages.findLastIndex(message => message.role === 'assistant');
59
- const prompt = [];
60
- const runtimeSnapshots = new Map(input.runtimeSnapshots);
61
- const historyTransfer = { scope: 'visible-user-assistant-text-and-images', textBlocks: 0, imageBlocks: 0, offloadedImagePlaceholders: 0, omittedReasoningBlocks: 0, reasoningOmissionReason: 'dsh-internal-reasoning' };
62
- for (const message of messages.slice(0, after + 1)) {
63
- if (message.role === 'system' || message.role === 'developer')
53
+ export async function prepareContinuation(options, input, readImage, capacity) {
54
+ const after = options.messages.findLastIndex(message => message.role === 'assistant');
55
+ const groups = [];
56
+ for (const message of options.messages.slice(0, after + 1)) {
57
+ // Runtime snapshots, approval answers and tool state are not employee-visible
58
+ // conversation. Current runtime/system context stays on its original path.
59
+ if (message.role !== 'assistant' && (message.role !== 'user' || message.source?.kind !== 'user'))
64
60
  continue;
65
- if (message.role !== 'user' && message.role !== 'assistant')
66
- throw new NeedError('HISTORY_CONTENT_UNSUPPORTED', `此前含有无法可靠承接的历史角色:${message.role};本轮未新建或发送`, '完整公开的用户及助手历史;工具记录的公开内容映射');
67
- prompt.push({ type: 'text', text: `此前可见对话(历史角色:${message.role};仅为历史记录):\n` });
68
- const sections = runtimeSections(message);
69
- for (const block of message.content) {
70
- // DSH explicitly distinguishes internal thinking from visible user text.
71
- if (block.type === 'reasoning') {
72
- historyTransfer.omittedReasoningBlocks++;
73
- continue;
74
- }
75
- const prepared = await publicBlock(block, options, readImage, true);
76
- prompt.push(prepared);
77
- if (sections)
78
- runtimeSnapshots.set(prepared, sections);
79
- if (block.type === 'text')
80
- historyTransfer.textBlocks++;
81
- else if (block.type === 'image') {
82
- if (block.offloaded)
83
- historyTransfer.offloadedImagePlaceholders++;
61
+ if (!message.content.some(block => block.type === 'text' || block.type === 'image'))
62
+ continue;
63
+ if (message.role === 'user' || !groups.length)
64
+ groups.push([]);
65
+ groups.at(-1).push(message);
66
+ }
67
+ const base = projectHostWorkspaceInput(input, capacity.host);
68
+ if (!groups.length)
69
+ return base;
70
+ const historyTransfer = { scope: 'visible-user-assistant-text-and-images', textBlocks: 0, imageBlocks: 0, offloadedImagePlaceholders: 0, omittedReasoningBlocks: 0, reasoningOmissionReason: 'dsh-internal-reasoning', retainedRounds: 0, omittedRounds: groups.length, maxBytes: capacity.maxBytes };
71
+ const separator = { type: 'text', text: '此前可见对话结束。以下为当前用户输入:\n' };
72
+ const retained = [];
73
+ // prepareInput guarantees a nonempty current prompt. Each inserted block adds
74
+ // its JSON bytes and one comma; count each round once, not the whole prefix.
75
+ let bytes = Buffer.byteLength(JSON.stringify({ prompt: base.prompt, systemPrompt: base.systemPrompt }), 'utf8') + Buffer.byteLength(JSON.stringify(separator), 'utf8') + 1;
76
+ // Whole consecutive rounds, newest first. The existing byte capacity is the
77
+ // only bound; N is computed from actual encoded text/image sizes, not a cap.
78
+ for (const group of groups.toReversed()) {
79
+ const blocks = [], counts = { textBlocks: 0, imageBlocks: 0, offloadedImagePlaceholders: 0, omittedReasoningBlocks: 0 };
80
+ for (const message of group) {
81
+ options.signal?.throwIfAborted();
82
+ blocks.push({ type: 'text', text: `此前可见对话(历史角色:${message.role};仅为历史记录):\n` });
83
+ for (const block of message.content) {
84
+ if (block.type === 'reasoning') {
85
+ counts.omittedReasoningBlocks++;
86
+ continue;
87
+ }
88
+ if (block.type !== 'text' && block.type !== 'image')
89
+ continue;
90
+ blocks.push(await publicBlock(block, options, readImage, true));
91
+ if (block.type === 'text')
92
+ counts.textBlocks++;
93
+ else if (block.offloaded)
94
+ counts.offloadedImagePlaceholders++;
84
95
  else
85
- historyTransfer.imageBlocks++;
96
+ counts.imageBlocks++;
86
97
  }
98
+ blocks.push({ type: 'text', text: `\n(历史角色:${message.role} 的记录结束)\n` });
87
99
  }
88
- prompt.push({ type: 'text', text: `\n(历史角色:${message.role} 的记录结束)\n` });
100
+ const additional = Buffer.byteLength(JSON.stringify(blocks), 'utf8') - 1; // Strip [] and add the comma before the retained suffix.
101
+ if (bytes + additional > capacity.maxBytes)
102
+ break;
103
+ bytes += additional;
104
+ retained.push(blocks);
105
+ historyTransfer.retainedRounds++;
106
+ historyTransfer.omittedRounds--;
107
+ for (const key of ['textBlocks', 'imageBlocks', 'offloadedImagePlaceholders', 'omittedReasoningBlocks'])
108
+ historyTransfer[key] += counts[key];
89
109
  }
90
- prompt.push({ type: 'text', text: '此前可见对话结束。以下为当前用户输入:\n' }, ...input.prompt);
91
- return { ...input, prompt, historyTransfer, runtimeSnapshots };
110
+ return { ...base, prompt: retained.length ? [...retained.toReversed().flat(), separator, ...base.prompt] : base.prompt, historyTransfer };
92
111
  }
93
112
  /** The attachment service supplies verified bytes; client paths never cross SSH. */
94
113
  export async function prepareInput(options, readImage) {
@@ -44,7 +44,10 @@ export declare class ModelCatalog implements ModelDirectory {
44
44
  private get;
45
45
  private maxBytes;
46
46
  private run;
47
- constructor(get: () => ConnectApi | undefined, maxBytes: () => number | undefined, run?: CatalogRunner);
47
+ private reportFailure?;
48
+ private pending;
49
+ constructor(get: () => ConnectApi | undefined, maxBytes: () => number | undefined, run?: CatalogRunner, reportFailure?: ((provider: "claude" | "codex", error: unknown) => void) | undefined);
50
+ cancelPending(): void;
48
51
  read(agent: AgentDefinition, signal?: AbortSignal): Promise<CatalogLease>;
49
52
  dispose(): void;
50
53
  }
@@ -1,5 +1,6 @@
1
1
  import { LlmError, ReasoningEffortId } from '@deepseek-ai/dsh-llm';
2
2
  import { validateTarget } from "./provider.js";
3
+ import { ModelConfigurationError } from "./model-configuration.js";
3
4
  import { runCatalog, catalogSafeText } from "./catalog-ssh.js";
4
5
  export { runCatalog } from "./catalog-ssh.js";
5
6
  const object = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
@@ -91,32 +92,72 @@ export class ModelCatalog {
91
92
  get;
92
93
  maxBytes;
93
94
  run;
94
- constructor(get, maxBytes, run = runCatalog) {
95
+ reportFailure;
96
+ pending = new Set();
97
+ constructor(get, maxBytes, run = runCatalog, reportFailure) {
95
98
  this.get = get;
96
99
  this.maxBytes = maxBytes;
97
100
  this.run = run;
101
+ this.reportFailure = reportFailure;
102
+ }
103
+ cancelPending() {
104
+ for (const controller of this.pending)
105
+ controller.abort(new CatalogError('连接已变化,本次模型列表读取已停止;连接恢复后请重新打开模型菜单。', 'MODEL_CATALOG_CONNECTION_CHANGED'));
106
+ this.pending.clear();
98
107
  }
99
108
  async read(agent, signal) {
100
- signal?.throwIfAborted();
101
- const api = this.get();
102
- if (!api || typeof api.getModelCatalogTarget !== 'function')
103
- fail('远程主机连接组件没有提供模型目录');
104
- const reply = api.getModelCatalogTarget(agent.loginProvider);
105
- if (!reply.ok)
106
- fail(reply.error.message);
107
- const target = reply.value;
108
- validateTarget(target.target);
109
109
  const controller = new AbortController(), combined = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
110
- const max = this.maxBytes();
111
- if (!Number.isSafeInteger(max) || max < 1)
112
- fail('模型目录容量设置无效');
113
- const output = await this.run(target.target, '/usr/local/aw-host/bin/aw-model-catalog --provider ' + agent.loginProvider, combined, max);
114
- const parsed = parseHostCatalog(output, agent.loginProvider, target.target.username);
115
- if (parsed.issues.length)
116
- console.warn('[remote-agent] 模型菜单部分条目无效:' + parsed.issues.map(issue => issue.code).join(','));
117
- return { target, models: parsed.models, signal: combined, release() { } };
110
+ let off;
111
+ this.pending.add(controller);
112
+ try {
113
+ combined.throwIfAborted();
114
+ const api = this.get();
115
+ if (!api || typeof api.getModelCatalogTarget !== 'function')
116
+ fail('远程主机连接组件尚未就绪,请等待连接恢复后重新打开模型菜单。');
117
+ const reply = api.getModelCatalogTarget(agent.loginProvider);
118
+ if (!reply.ok)
119
+ fail(reply.error.message);
120
+ const target = reply.value;
121
+ validateTarget(target.target);
122
+ off = api.onConnectionState?.(() => {
123
+ const state = api.getConnectionState();
124
+ if (state.phase !== 'connected' || state.instanceId !== target.instanceId || state.epoch !== target.epoch)
125
+ controller.abort(new CatalogError('连接已变化,本次模型列表读取已停止;连接恢复后请重新打开模型菜单。', 'MODEL_CATALOG_CONNECTION_CHANGED'));
126
+ });
127
+ const max = this.maxBytes();
128
+ if (!Number.isSafeInteger(max) || max < 1)
129
+ fail('模型目录容量设置无效,请联系管理员检查插件配置。');
130
+ const output = await this.run(target.target, '/usr/local/aw-host/bin/aw-model-catalog --provider ' + agent.loginProvider, combined, max);
131
+ combined.throwIfAborted();
132
+ let parsed;
133
+ try {
134
+ parsed = parseHostCatalog(output, agent.loginProvider, target.target.username);
135
+ }
136
+ catch (error) {
137
+ if (error instanceof CatalogError)
138
+ throw new CatalogError(error.message.slice('无法获取模型列表:'.length), error.diagnostic + '\n主机响应:' + safe(output));
139
+ throw error;
140
+ }
141
+ if (parsed.issues.length)
142
+ console.warn('[remote-agent] 模型菜单部分条目无效:' + parsed.issues.map(issue => issue.code).join(','));
143
+ return { target, models: parsed.models, signal: combined, release() { } };
144
+ }
145
+ catch (error) {
146
+ const failure = signal?.aborted ? signal.reason : error instanceof CatalogError || error instanceof ModelConfigurationError ? error : new CatalogError('暂时无法读取模型列表,请检查远程主机连接后重新打开模型菜单。', safe(error instanceof Error ? error.message : String(error)));
147
+ try {
148
+ this.reportFailure?.(agent.loginProvider, failure);
149
+ }
150
+ catch {
151
+ console.warn('[remote-agent] 模型列表失败日志未保存');
152
+ }
153
+ throw failure;
154
+ }
155
+ finally {
156
+ off?.();
157
+ this.pending.delete(controller);
158
+ }
118
159
  }
119
- dispose() { }
160
+ dispose() { this.cancelPending(); }
120
161
  }
121
162
  export function modelInfo(provider, model) {
122
163
  // Official rc.2 represents no reasoning levels by absence, and rejects an empty efforts array.
@@ -2,4 +2,4 @@
2
2
  export declare const REMOTE_CODEX_NAME = "Codex\uFF08\u8FDC\u7A0B\u4E3B\u673A\uFF09";
3
3
  export declare const CONNECTION_REQUIRED = "\u8FDC\u7A0B\u4E3B\u673A\u8FDE\u63A5\u7EC4\u4EF6\u6CA1\u6709\u88C5\u597D\uFF0C\u8BF7\u8054\u7CFB\u7BA1\u7406\u5458\u3002";
4
4
  /** Employee failures use business language; diagnostic codes remain separately available. */
5
- export declare function employeeFailure(code: string, brand?: 'codex' | 'claude'): string;
5
+ export declare function employeeFailure(code: string, brand?: 'codex' | 'claude', promptDispatched?: boolean): string;
@@ -2,7 +2,7 @@
2
2
  export const REMOTE_CODEX_NAME = 'Codex(远程主机)';
3
3
  export const CONNECTION_REQUIRED = '远程主机连接组件没有装好,请联系管理员。';
4
4
  /** Employee failures use business language; diagnostic codes remain separately available. */
5
- export function employeeFailure(code, brand = 'codex') {
5
+ export function employeeFailure(code, brand = 'codex', promptDispatched = true) {
6
6
  const messages = {
7
7
  PERMISSION_POLICY_REQUIRED: '我需要读取这条对话的权限设置,目前没有读到;本轮未执行,你的原话仍保留。',
8
8
  PERMISSION_MODE_UNSUPPORTED: '这个权限档位还没有对应的远程执行能力;我需要主机连接组件支持该档位,本轮未执行,你的原话仍保留。',
@@ -23,7 +23,7 @@ export function employeeFailure(code, brand = 'codex') {
23
23
  EMPLOYEE_CONTEXT_CHANGED: '本机文件夹或连接已变化,本轮已停止;恢复后可在原会话继续。',
24
24
  EMPLOYEE_WORKSPACE_INVALID: '无法核对这条对话所属的本机文件夹,本轮未执行;请在工作区中重新打开对话。',
25
25
  DEPENDENCY_REQUIRED: CONNECTION_REQUIRED,
26
- MODEL_CATALOG_UNAVAILABLE: '所选型号暂时不可用,请打开模型菜单重新选择后再发送。',
26
+ MODEL_CATALOG_UNAVAILABLE: '所选型号暂时不可用,请打开模型菜单重新选择后再发送。' + (!promptDispatched ? ' 本轮输入未发送。' : ''),
27
27
  LOGIN_REQUIRED: '需要登录,请到设置 → 远程主机连接 点登录',
28
28
  LOGIN_COMPLETED: '登录已完成,请重新发送本轮内容。',
29
29
  LOGIN_FAILED: '登录未完成,请到设置 → 远程主机连接 点登录;如仍有问题,请联系管理员。',
@@ -41,7 +41,8 @@ export function employeeFailure(code, brand = 'codex') {
41
41
  HOST_TRUST_REQUIRED: '远程主机连接信息还未准备好,请联系管理员。',
42
42
  POLICY_DENIED: '这个账号暂时不能使用,请联系公司账号管理员。',
43
43
  SESSION_CONTEXT_MISMATCH: '远程主机连接已改变,请重新开始本次检查。',
44
- ABORTED: '本轮已停止。操作可能已部分执行,请先确认文件;恢复连接后可在原会话继续。',
44
+ REMOTE_SESSION_BUSY: '原会话仍被主机上的上一轮占用,' + (promptDispatched ? '本轮请求未能完成' : '本轮输入未发送') + ';请稍后在原会话重新发送。若持续占用,请联系管理员检查上一轮主机会话。',
45
+ ABORTED: promptDispatched ? '本轮已停止。操作可能已部分执行,请先确认文件;恢复连接后可在原会话继续。' : '本轮已停止,本轮输入未发送;恢复连接后可在原会话重新发送。',
45
46
  CONFIRMATION_REQUIRED: '请先选择是否打开授权窗口。',
46
47
  CONTEXT_UNSUPPORTED: '远程 Codex 暂不支持这类会话内容,请联系管理员。',
47
48
  CONTENT_UNSUPPORTED: '远程 Codex 暂不支持这类附件,请联系管理员。',
@@ -79,6 +80,6 @@ export function employeeFailure(code, brand = 'codex') {
79
80
  if (code === 'AW_EMPLOYEE_BINARY_MISSING')
80
81
  return '本机执行组件没有装齐,本轮没有执行,请重新安装插件。';
81
82
  if (code.startsWith('AW_EMPLOYEE_') || code.startsWith('EMPLOYEE_'))
82
- return '你电脑上的执行连接已中断,本轮已停止;操作可能已部分执行,请先确认文件,恢复后可在原会话继续。';
83
- return '本轮没有完成。请检查连接,恢复后可在原会话继续;操作可能已部分执行,请先确认文件。';
83
+ return promptDispatched ? '你电脑上的执行连接已中断,本轮已停止;操作可能已部分执行,请先确认文件,恢复后可在原会话继续。' : '你电脑上的执行连接尚未就绪,本轮输入未发送;请检查连接,恢复后可在原会话重新发送。';
84
+ return promptDispatched ? '本轮没有完成。请检查连接,恢复后可在原会话继续;操作可能已部分执行,请先确认文件。' : '本轮输入未发送。请检查连接,恢复后可在原会话继续。';
84
85
  }
@@ -50,6 +50,8 @@ export declare function createRemoteDetails(options: {
50
50
  getApi?: () => unknown;
51
51
  warn?: (line: string) => void;
52
52
  }): {
53
+ /** Menu failures have no Session; keep them in the same private local logs. */
54
+ recordCatalogFailure(provider: "claude" | "codex", error: unknown): void;
53
55
  /** Await queued best-effort writes only when releasing the profile, never before dispatch. */
54
56
  dispose(): Promise<void>;
55
57
  releaseSession(sessionId: string): void;
@@ -1,3 +1,4 @@
1
+ import { catalogSafeText } from "./catalog-ssh.js";
1
2
  import { closeCode } from "./close-diagnostics.js";
2
3
  import { randomUUID, createHash } from 'node:crypto';
3
4
  import { constants } from 'node:fs';
@@ -74,6 +75,14 @@ export function createRemoteDetails(options) {
74
75
  return { eventId };
75
76
  }
76
77
  return {
78
+ /** Menu failures have no Session; keep them in the same private local logs. */
79
+ recordCatalogFailure(provider, error) {
80
+ const reason = error instanceof Error ? error.message : typeof error === 'string' ? error : '模型目录查询失败';
81
+ const diagnostic = error && typeof error === 'object' && 'diagnostic' in error && typeof error.diagnostic === 'string' ? error.diagnostic : reason;
82
+ const line = { version: 1, at: new Date().toISOString(), provider, code: 'MODEL_CATALOG_UNAVAILABLE', message: catalogSafeText(reason), diagnostic: catalogSafeText(diagnostic) };
83
+ writes = writes.then(async () => { if (!root)
84
+ throw failure('DIAGNOSTIC_CONTEXT_UNAVAILABLE'); await privateDirectory(join(root, '..')); await privateDirectory(root); await appendFile(join(root, 'model-catalog.jsonl'), JSON.stringify(line) + '\n', { mode: 0o600, flag: constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND | constants.O_NOFOLLOW }); }).catch(warn);
85
+ },
77
86
  /** Await queued best-effort writes only when releasing the profile, never before dispatch. */
78
87
  async dispose() { await writes; memory.clear(); },
79
88
  releaseSession(sessionId) { for (const provider of ['claude', 'codex'])
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-workbench/remote-agent",
3
- "version": "0.8.26",
3
+ "version": "0.8.28",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",