@ai-workbench/remote-agent 0.8.29 → 0.9.0-toolbridge.3

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,10 +1,20 @@
1
- # AI Workbench 远程助手 · @ai-workbench/remote-agent@0.8.29 · 插件 → 添加插件
1
+ # AI Workbench 远程助手 · @ai-workbench/remote-agent@0.9.0-toolbridge.3 · 插件 → 添加插件
2
+
3
+ ## 含工具桥预发布线
4
+
5
+ 本包为 `0.9.0-toolbridge.3`,在独立 `toolbridge` 分支开发;稳定 main/0.8.x 和原总包保持原样。员工安装由连接组件提供的「AI 写作工作台(含工具桥)」总包,与不含工具桥的版本二选一。预发布渠道为 `toolbridge`,本轮仅提供本地固定包,不发布 npm。
6
+
7
+ 选择本机文件夹后,Claude 和 Codex 可以把 DSH 已启用的 MCP、web、skill、ask_user、todo/present/goal 等工具交给 DSH 原生执行和审批。内置文件/命令、子代理/agent-team、run_code 不在桥接表;原有文件和命令执行方式保留。plan 不自动放行桥接权限;default 下 Claude 的桥接原生权限由插件单次应答,员工只在 DSH 审批实际工具。主机文件夹不开放工具桥。
8
+
9
+ 工具表按每次主机连接生成快照,新增/移除在下一次连接生效;不承诺 Codex 当前 MCP 会话热刷新。审批期间的插话等当前主机 prompt 结束后再发,停泊中切换助手会停止原轮次并明确提示。工具主动结束DSH轮次时,已提交结果仍交回原agent;确认原生工具收到后结束原prompt,下一条沿原线程继续,不在已关闭步骤追加模型回复。取消和主机结束等待后的结果不重放;工具已执行的副作用不能因取消而撤销,请按说明核对。图片结果由官方附件组件读取后交回。
10
+
11
+ Codex 需要 S 后续安装的独立受保护命令 `/usr/local/aw-host/bin/codex-acp-toolbridge`(aw.8),不会替换原 codex-acp/aw.6;Claude命令不变。指定桥接 server 的等待为 owner 确认的 **1800 秒**、审批交 DSH;不改其他server的调用时限,不新增插件设置项。含桥的本机Codex会话按原生规则等各扩展现有启动时限结算后再捕获工具;启动失败会明确说明,文件和命令仍按原方式执行,本轮不自动重试。N0.4.9、L0.7.37沿既有公开合同;真实界面/员工验收由编排安排。完整合同和本轮启动修复证据见 `docs/contracts/codex-toolbridge.md`、`docs/evidence/2026-10-27-toolbridge-startup/REPORT.md`。
2
12
 
3
13
  通过官方 DSH 插件入口使用 Claude(远程主机)与 Codex(远程主机)。登录和工作区来源由“远程主机连接”组件提供;员工在原生工作区和模型菜单中选择文件夹与型号。
4
14
 
5
15
  文件和命令在所选的本机或主机目录执行,订阅凭据留在主机。插件不改写 DSH 的默认模型,不为员工创建同名主机目录,已有主机会话只提取一次原有主机来源并保存。
6
16
 
7
- 需要远程主机连接组件 0.7.29 或更新的 0.7.x、联网助手 0.4.9,以及支持公开会话归属接口的主机组件 S0.4.26。主机来源保存于官方 storage.domain 的会话元数据表,每轮仅由连接组件检查一次主机目录,不检查本机占位目录。
17
+ 需要远程主机连接组件 0.7.37 或更新的 0.7.x、联网助手 0.4.9,以及支持公开会话归属接口的主机组件 S0.4.26。主机来源保存于官方 storage.domain 的会话元数据表,每轮仅由连接组件检查一次主机目录,不检查本机占位目录。
8
18
 
9
19
  Claude 本机 Bash 会单独消费员工桥代理的控制诊断,不把它交给模型当作命令输出;真实传输中断仍提示结果未知。命令自身的标准输出、标准错误与退出码照常返回。
10
20
 
package/dist/acp.d.ts CHANGED
@@ -37,6 +37,7 @@ export interface EmployeeExecutionMeta {
37
37
  acpMaxMessageBytes: number;
38
38
  }
39
39
  export interface AcpStartContext {
40
+ toolBridgeServers?: EmployeeRouting['mcpServers'];
40
41
  systemPrompt?: string;
41
42
  codexNewSessionOnly?: true;
42
43
  employeeExecution?: EmployeeExecutionMeta;
@@ -95,6 +96,12 @@ export declare class AcpSession {
95
96
  private detail;
96
97
  private employeeNoticeNames;
97
98
  private employeeNoticeUnshown;
99
+ private employeeBridge;
100
+ private pendingBridgeStartupFailures;
101
+ private bridgeStartupFailed;
102
+ private bridgeStartupNoticeDelivered;
103
+ private confirmBridgeStartupFailure;
104
+ private flushBridgeStartupNotice;
98
105
  private employeeInfo;
99
106
  private need;
100
107
  constructor(transport: acp.Stream | acp.AgentApp | stable.AgentApp, hooks: AcpHooks);
package/dist/acp.js CHANGED
@@ -226,7 +226,31 @@ export class AcpSession {
226
226
  }
227
227
  employeeNoticeNames = new Set();
228
228
  employeeNoticeUnshown = false;
229
+ employeeBridge = false;
230
+ pendingBridgeStartupFailures = new Set();
231
+ bridgeStartupFailed = false;
232
+ bridgeStartupNoticeDelivered = false;
233
+ confirmBridgeStartupFailure() {
234
+ if (!this.sessionId)
235
+ return;
236
+ const failed = this.pendingBridgeStartupFailures.has(this.sessionId);
237
+ this.pendingBridgeStartupFailures.clear();
238
+ if (failed && !this.bridgeStartupFailed) {
239
+ this.bridgeStartupFailed = true;
240
+ this.detail({ kind: 'extension', severity: 'warning', code: 'TOOLBRIDGE_STARTUP_FAILED', summary: '本轮DSH扩展工具桥启动失败;文件和命令继续使用原生路径,没有自动重试。', method: 'session/update', payload: { server: 'employee', status: 'failed', source: 'codex-acp/mcp-startup-report', nativeSessionId: this.sessionId } });
241
+ }
242
+ this.flushBridgeStartupNotice();
243
+ }
244
+ flushBridgeStartupNotice() {
245
+ if (!this.bridgeStartupFailed || this.bridgeStartupNoticeDelivered || !this.busy || this.closeRequested || this.turnSignal?.aborted || !this.hooks.onEmployeeNotice)
246
+ return;
247
+ this.hooks.onEmployeeNotice('本轮DSH里的扩展工具未能连接到Codex,暂时不可用。文件和命令仍按原方式执行;具体原因见技术详情。本轮不会自动重试。');
248
+ this.bridgeStartupNoticeDelivered = true;
249
+ }
229
250
  employeeInfo(value) {
251
+ const bridge = record(record(value)?.mcpBridge);
252
+ if (bridge)
253
+ this.detail({ kind: 'session', severity: 'info', code: 'TOOLBRIDGE_HOST_POLICY', summary: '主机回报本轮指定桥接server的等待与审批策略;不改变其他工具。', payload: { toolTimeoutSec: typeof bridge.toolTimeoutSec === 'number' && Number.isFinite(bridge.toolTimeoutSec) ? bridge.toolTimeoutSec : null, approval: bridge.approval === 'approve' ? 'approve' : 'UNKNOWN', source: bridge.source === 'owner-2026-10-26' ? bridge.source : 'UNKNOWN' } });
230
254
  const info = employeeExecutionInfo(value);
231
255
  if (!info)
232
256
  return;
@@ -260,6 +284,11 @@ export class AcpSession {
260
284
  this.detail({ kind: 'session', severity: 'warning', code: 'ACP_FOREIGN_SESSION_UPDATE', summary: '收到不属于当前远端会话的通知,未作为正文处理', method: 'session/update', payload: notification });
261
285
  return;
262
286
  }
287
+ const update = notification.update;
288
+ if (this.employeeBridge && !this.closeRequested && (update.sessionUpdate === 'tool_call' || update.sessionUpdate === 'tool_call_update') && update.status === 'failed' && update.title === 'mcp__employee__startup' && update.toolCallId.startsWith('mcp_startup.employee.') && update.toolCallId.length > 'mcp_startup.employee.'.length) {
289
+ this.pendingBridgeStartupFailures.add(notification.sessionId);
290
+ this.confirmBridgeStartupFailure();
291
+ }
263
292
  const meta = record(notification._meta);
264
293
  if (notification.update.sessionUpdate === 'current_mode_update') {
265
294
  this.nativeModeId = notification.update.currentModeId;
@@ -479,20 +508,21 @@ export class AcpSession {
479
508
  throw new NeedError('SESSION_ALREADY_STARTED', '此连接已用于建立会话,不能转用工作区或身份', '新的独立远端连接');
480
509
  this.startAttempted = true;
481
510
  this.employeeMeta = context?.employeeExecution;
511
+ this.employeeBridge = !!this.employeeMeta && !!context?.toolBridgeServers;
482
512
  const abort = () => this.close({ layer: 'acp', initiator: 'acp.start-signal', reason: 'ABORTED', action: 'abort' }, new NeedError('ABORTED', '会话建立已取消', '重新连接主机'));
483
513
  signal?.throwIfAborted();
484
514
  signal?.addEventListener('abort', abort, { once: true });
485
515
  try {
486
516
  const hello = await this.stage('acp-initialize', () => this.connection.agent.request(acp.methods.agent.initialize, {
487
- protocolVersion: acp.PROTOCOL_VERSION, clientInfo: { name: '@ai-workbench/remote-agent', version: '0.8.24' },
488
- clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false, session: { notices: {} }, ...(this.employeeMeta ? { _meta: { awEmployeeExecution: { version: 1, closeReason: 1 } } } : {}) },
517
+ protocolVersion: acp.PROTOCOL_VERSION, clientInfo: { name: '@ai-workbench/remote-agent', version: '0.9.0-toolbridge.3' },
518
+ clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false, session: { notices: {} }, ...(this.employeeMeta ? { _meta: { awEmployeeExecution: { version: 1, closeReason: 1, ...(context?.toolBridgeServers ? { mcpBridge: 1 } : {}) } } } : {}) },
489
519
  }, { cancellationSignal: signal }));
490
520
  if (hello.protocolVersion !== acp.PROTOCOL_VERSION)
491
521
  throw new NeedError('ACP_VERSION_MISMATCH', 'ACP 协议版本不兼容', '支持 ACP v1 的远端 agent');
492
522
  this.negotiated = hello.agentCapabilities ?? {};
493
523
  if (this.employeeMeta) {
494
524
  const policy = record(hello._meta?.awEmployeeExecution);
495
- if (!this.hooks.employeeExecution || !policy || Object.keys(policy).length !== 5 || policy.version !== 1 || policy.nativeCliVersion !== '0.160.1' || policy.native !== true || policy.perRpcGate !== true || policy.noHostFallback !== true)
525
+ if (!this.hooks.employeeExecution || !policy || Object.keys(policy).length !== (context?.toolBridgeServers ? 6 : 5) || context?.toolBridgeServers && policy.mcpBridge !== 1 || policy.version !== 1 || policy.nativeCliVersion !== '0.160.1' || policy.native !== true || policy.perRpcGate !== true || policy.noHostFallback !== true)
496
526
  throw new NeedError('EMPLOYEE_EXECUTION_UNSUPPORTED', '当前主机尚不支持在你的电脑执行Codex原生工具,本轮没有执行。', 'Codex员工端原生执行适配器与明确能力声明');
497
527
  this.employeeNegotiated = true;
498
528
  this.detail({ kind: 'session', severity: 'info', code: 'EMPLOYEE_EXECUTION_SELECTED', summary: '文件和命令在员工电脑的选定目录执行;订阅凭据留在主机,本机扩展的忽略详情单独记录。', payload: { bindingId: this.employeeMeta.bindingId, workspaceId: this.employeeMeta.workspaceId, cwd: this.employeeMeta.cwd, nativeCliVersion: '0.160.1', noHostFallback: true, agentConfigSource: 'host CODEX_HOME', unsupportedLocalFeatures: ['http-mcp-execution', 'hooks', 'plugins', 'cold-resume', 'slash-commands', 'host-file-reports'] } });
@@ -503,7 +533,7 @@ export class AcpSession {
503
533
  this.validatePrompt(prompt);
504
534
  const routing = context?.employeeRouting ? structuredClone(context.employeeRouting) : undefined;
505
535
  this.employeeRouting = routing;
506
- const params = { cwd, mcpServers: routing ? structuredClone(routing.mcpServers) : [], ...((routing || this.employeeMeta) ? { _meta: { ...(this.employeeMeta ? { awEmployeeExecution: this.employeeMeta } : {}), ...(routing ? { claudeCode: { options: employeeClaudeOptions(routing) } } : {}) } } : {}), ...(systemPrompt && !(resumeId && this.codexNewSessionOnly) ? { systemPrompt: { mode: 'append', content: [{ type: 'text', text: systemPrompt }] } } : {}) };
536
+ const params = { cwd, mcpServers: context?.toolBridgeServers ? structuredClone(context.toolBridgeServers) : routing ? structuredClone(routing.mcpServers) : [], ...((routing || this.employeeMeta) ? { _meta: { ...(this.employeeMeta ? { awEmployeeExecution: this.employeeMeta } : {}), ...(routing ? { claudeCode: { options: employeeClaudeOptions(routing) } } : {}) } } : {}), ...(systemPrompt && !(resumeId && this.codexNewSessionOnly) ? { systemPrompt: { mode: 'append', content: [{ type: 'text', text: systemPrompt }] } } : {}) };
507
537
  const configRevision = this.resetConfiguration();
508
538
  const restore = async (operation) => {
509
539
  try {
@@ -529,6 +559,8 @@ export class AcpSession {
529
559
  this.sessionId = resumeId;
530
560
  if (this.negotiated.sessionCapabilities?.resume) {
531
561
  const result = await restore(() => this.stage('acp-session-resume', () => this.connection.agent.request(acp.methods.agent.session.resume, { ...params, sessionId: resumeId }, { cancellationSignal: signal })));
562
+ if (this.employeeMeta)
563
+ this.employeeInfo(result._meta?.awEmployeeExecutionInfo);
532
564
  this.lifecycleConfiguration(result, configRevision);
533
565
  this.method = acp.methods.agent.session.resume;
534
566
  }
@@ -536,6 +568,8 @@ export class AcpSession {
536
568
  if (!this.negotiated.loadSession)
537
569
  throw new NeedError('RESUME_UNSUPPORTED', '远端未声明会话恢复或加载能力,不能恢复原会话', '支持 session/resume 或 session/load 的 agent');
538
570
  const result = await restore(() => this.stage('acp-session-load', () => this.connection.agent.request(acp.methods.agent.session.load, { ...params, sessionId: resumeId }, { cancellationSignal: signal })));
571
+ if (this.employeeMeta)
572
+ this.employeeInfo(result._meta?.awEmployeeExecutionInfo);
539
573
  this.lifecycleConfiguration(result, configRevision);
540
574
  this.method = acp.methods.agent.session.load;
541
575
  }
@@ -548,6 +582,7 @@ export class AcpSession {
548
582
  this.lifecycleConfiguration(result, configRevision);
549
583
  this.method = acp.methods.agent.session.new;
550
584
  }
585
+ this.confirmBridgeStartupFailure();
551
586
  await this.hooks.sessionOwnership?.bind(this.sessionId, signal);
552
587
  this.ready = true;
553
588
  this.cwd = cwd;
@@ -599,6 +634,7 @@ export class AcpSession {
599
634
  };
600
635
  signal?.addEventListener('abort', cancel, { once: true });
601
636
  try {
637
+ this.flushBridgeStartupNotice();
602
638
  const result = await this.stage('acp-prompt', () => {
603
639
  this.promptPermissions.set(params, { current, native });
604
640
  const request = this.connection.agent.request(acp.methods.agent.session.prompt, params, { cancellationSignal: signal });
package/dist/agents.js CHANGED
@@ -1,4 +1,4 @@
1
- export const CODEX_AGENT = Object.freeze({ id: 'codex-remote', name: Object.freeze({ zh: 'Codex(远程主机)', en: 'Codex (Remote Host)' }), command: '/usr/local/aw-host/bin/codex-acp', loginProvider: 'codex' });
1
+ export const CODEX_AGENT = Object.freeze({ id: 'codex-remote', name: Object.freeze({ zh: 'Codex(远程主机)', en: 'Codex (Remote Host)' }), command: '/usr/local/aw-host/bin/codex-acp-toolbridge', loginProvider: 'codex' });
2
2
  export const CLAUDE_AGENT = Object.freeze({ id: 'claude-code-workbench', name: Object.freeze({ zh: 'Claude(远程主机)', en: 'Claude (Remote Host)' }), command: '/usr/local/aw-host/bin/claude-agent-acp', loginProvider: 'claude' });
3
3
  export const AGENTS = Object.freeze([CODEX_AGENT, CLAUDE_AGENT]);
4
4
  export function agentForProvider(id) { return AGENTS.find(agent => agent.id === id); }
@@ -115,7 +115,8 @@ export function createClaudeLocalProvider(options) {
115
115
  const network = options.getNetwork();
116
116
  lease = await (options.ports?.lease ?? openEmployeeLease)({ closeDiagnostics, getConnect: () => options.getConnect(), getNetwork: options.getNetwork, readWorkspace: () => options.readWorkspace(request.sessionId), host: target, sessionId: request.sessionId, control: async (_target, command, signal) => (await transport()).run(command, signal), detail: unscoped }, request.signal);
117
117
  const shell = (options.ports?.shell ?? createEmployeeShell)({ closeDiagnostics, target: lease.target, sshArgs: lease.sshArgs, maxBytes: maximum, killGraceMs: grace, attach: async (spec) => (await transport()).attach([spec.command, ...spec.args].map(shellQuote).join(' ')) });
118
- mcp = await (options.ports?.mcp ?? openEmployeeMcp)({ closeDiagnostics, lease, maxBytes: maximum, killGraceMs: grace, runShell: shell, activeSignal: () => entry?.active?.signal, captureDetail: () => { const sink = entry?.active?.details; if (!sink)
118
+ request.toolBridge?.enable(event => details.record(event));
119
+ mcp = await (options.ports?.mcp ?? openEmployeeMcp)({ bridge: request.toolBridge, closeDiagnostics, lease, maxBytes: maximum, killGraceMs: grace, runShell: shell, activeSignal: () => entry?.active?.signal, captureDetail: () => { const sink = entry?.active?.details; if (!sink)
119
120
  throw failure('EMPLOYEE_NO_ACTIVE_TURN'); return observation => sink.record(observation); }, detail: unscoped });
120
121
  entry = { runtime: undefined, scope, lease, details, request };
121
122
  const row = entry;
@@ -1,3 +1,4 @@
1
+ import type { ToolBridge } from './toolbridge.ts';
1
2
  import { type CloseCause, type CloseDiagnostics, type CloseObserver } from './close-diagnostics.ts';
2
3
  import { type Socket } from 'node:net';
3
4
  import { type JSONRPCMessage, type Tool } from '@modelcontextprotocol/sdk/types.js';
@@ -28,6 +29,7 @@ export declare class EmployeeSocketTransport implements Transport {
28
29
  close(cause?: CloseCause): Promise<void>;
29
30
  }
30
31
  export interface EmployeeMcpOptions {
32
+ bridge?: ToolBridge;
31
33
  closeDiagnostics?: CloseDiagnostics;
32
34
  lease: EmployeeLease;
33
35
  maxBytes: number;
@@ -136,12 +136,14 @@ export async function openEmployeeMcp(options) {
136
136
  const mcp = new Server({ name: 'ai-workbench-employee', version: '0.8.14' }, { capabilities: { tools: {} } }), transport = new EmployeeSocketTransport(socket, options.maxBytes, observeConnection);
137
137
  clients.add(mcp);
138
138
  transports.add(transport);
139
- mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: EMPLOYEE_MCP_TOOLS }));
139
+ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [...EMPLOYEE_MCP_TOOLS, ...options.bridge?.tools ?? []] }));
140
140
  mcp.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
141
141
  const active = options.activeSignal();
142
142
  if (!active || active.aborted)
143
143
  return { isError: true, content: [{ type: 'text', text: 'EMPLOYEE_NO_ACTIVE_TURN:当前没有已接纳的用户轮次,本机工具未执行。' }] };
144
144
  const signal = AbortSignal.any([active, extra.signal, transport.signal, options.lease.signal]);
145
+ if (options.bridge?.isBridge(request.params.name))
146
+ return options.bridge.call(request.params.name, request.params.arguments ?? {}, signal);
145
147
  let sink;
146
148
  try {
147
149
  sink = options.captureDetail?.() ?? options.detail;
package/dist/index.d.ts CHANGED
@@ -57,6 +57,7 @@ export declare class CodexRemoteAdapter extends LlmAdapter {
57
57
  private readonly directory?;
58
58
  private readonly preflightDiagnostics?;
59
59
  private readonly permissions?;
60
+ private bridgeLoop;
60
61
  private config;
61
62
  private sessions;
62
63
  private turns;
@@ -65,7 +66,8 @@ export declare class CodexRemoteAdapter extends LlmAdapter {
65
66
  private detached;
66
67
  private modelPresentation;
67
68
  private employeeNoticeSessions;
68
- constructor(config: PluginConfig | (() => PluginConfig), resolveRuntime: RuntimeProvider, approve?: InvocationApproval | undefined, providerName?: () => string, agent?: AgentDefinition, readImage?: ImageReader | undefined, reportLoginFailure?: LoginFailureReporter | undefined, directory?: ModelDirectory | undefined, preflightDiagnostics?: PreflightDiagnostics | undefined, permissions?: PermissionSource | undefined);
69
+ private pendingEmployeeNotices;
70
+ constructor(config: PluginConfig | (() => PluginConfig), resolveRuntime: RuntimeProvider, approve?: InvocationApproval | undefined, providerName?: () => string, agent?: AgentDefinition, readImage?: ImageReader | undefined, reportLoginFailure?: LoginFailureReporter | undefined, directory?: ModelDirectory | undefined, preflightDiagnostics?: PreflightDiagnostics | undefined, permissions?: PermissionSource | undefined, bridgeStopped?: (sessionId: string, notice: string) => void);
69
71
  providerInfo(provider: string): {
70
72
  id: string;
71
73
  name: string;
@@ -88,6 +90,8 @@ export declare class CodexRemoteAdapter extends LlmAdapter {
88
90
  jitterRatio: number;
89
91
  };
90
92
  private cleanupRuntime;
93
+ completeDshTurn(sessionId: string, messages: GenerateOptions['messages']): Promise<void>;
94
+ hasBridgeSession(sessionId: string): boolean;
91
95
  disposeSession(sessionId: string): Promise<void>;
92
96
  detachSession(sessionId: string): Promise<void>;
93
97
  dispose(): Promise<void>;
@@ -97,7 +101,7 @@ export declare class CodexRemoteAdapter extends LlmAdapter {
97
101
  /** One official registration owns both routes, preserving opaque replay ownership. */
98
102
  export declare class RemoteAgentsAdapter extends LlmAdapter {
99
103
  private adapters;
100
- constructor(config: () => PluginConfig, makeRuntime: (agent: AgentDefinition) => RuntimeProvider, approve: InvocationApproval, nameFor: (agent: AgentDefinition) => string, readImage?: ImageReader, reportLoginFailure?: LoginFailureReporter, directory?: ModelDirectory, preflightDiagnostics?: PreflightDiagnostics, permissions?: PermissionSource);
104
+ constructor(config: () => PluginConfig, makeRuntime: (agent: AgentDefinition) => RuntimeProvider, approve: InvocationApproval, nameFor: (agent: AgentDefinition) => string, readImage?: ImageReader, reportLoginFailure?: LoginFailureReporter, directory?: ModelDirectory, preflightDiagnostics?: PreflightDiagnostics, permissions?: PermissionSource, bridgeStopped?: (sessionId: string, notice: string) => void);
101
105
  private adapter;
102
106
  providerInfo(provider: string): {
103
107
  id: string;
@@ -108,8 +112,8 @@ export declare class RemoteAgentsAdapter extends LlmAdapter {
108
112
  })[]>;
109
113
  resolveModel(provider: string, model: string, signal?: AbortSignal): Promise<LlmResolvedModelInfo>;
110
114
  prepareCall(provider: string, model: string, signal?: AbortSignal): Promise<{
111
- model: LlmResolvedModelInfo;
112
115
  stream: (options: GenerateOptions) => AsyncGenerator<StreamChunk, any, any>;
116
+ model: LlmResolvedModelInfo;
113
117
  }>;
114
118
  providerRetryPolicy(): {
115
119
  mode: "normal";
@@ -120,6 +124,7 @@ export declare class RemoteAgentsAdapter extends LlmAdapter {
120
124
  jitterRatio: number;
121
125
  };
122
126
  stream(options: GenerateOptions): AsyncGenerator<StreamChunk, any, any>;
127
+ completeDshTurn(sessionId: string, messages: GenerateOptions['messages']): Promise<void>;
123
128
  disposeSession(sessionId: string): Promise<void>;
124
129
  detachSession(sessionId: string): Promise<void>;
125
130
  dispose(): Promise<void>;
package/dist/index.js CHANGED
@@ -1,7 +1,10 @@
1
+ import { ToolBridgeLoop } from "./toolbridge-loop.js";
2
+ import { openToolBridgeMcp, TOOLBRIDGE_NOTICE } from "./toolbridge.js";
1
3
  import { connectNeedMessage } from "./connect-need.js";
2
4
  import { CloseDiagnostics, withFirstFailureEvidence, failureEvidenceSummary, closeCode } from "./close-diagnostics.js";
3
5
  import { withRemoteFailureEvidence } from "./failure-evidence.js";
4
6
  import { SessionId } from '@deepseek-ai/dsh-session';
7
+ import { createAssistantMessage } from '@deepseek-ai/dsh-llm';
5
8
  import Schema from '@deepseek-ai/schemastery';
6
9
  import { LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm';
7
10
  import { createHash } from 'node:crypto';
@@ -91,6 +94,7 @@ export class CodexRemoteAdapter extends LlmAdapter {
91
94
  directory;
92
95
  preflightDiagnostics;
93
96
  permissions;
97
+ bridgeLoop;
94
98
  config;
95
99
  sessions = new Map();
96
100
  turns = new Map();
@@ -99,7 +103,8 @@ export class CodexRemoteAdapter extends LlmAdapter {
99
103
  detached = new Set();
100
104
  modelPresentation = new ModelPresentation();
101
105
  employeeNoticeSessions = new Set();
102
- constructor(config, resolveRuntime, approve, providerName = () => CODEX_AGENT.name.zh, agent = CODEX_AGENT, readImage, reportLoginFailure, directory, preflightDiagnostics, permissions) {
106
+ pendingEmployeeNotices = new Set();
107
+ constructor(config, resolveRuntime, approve, providerName = () => CODEX_AGENT.name.zh, agent = CODEX_AGENT, readImage, reportLoginFailure, directory, preflightDiagnostics, permissions, bridgeStopped) {
103
108
  super();
104
109
  this.resolveRuntime = resolveRuntime;
105
110
  this.approve = approve;
@@ -110,6 +115,7 @@ export class CodexRemoteAdapter extends LlmAdapter {
110
115
  this.directory = directory;
111
116
  this.preflightDiagnostics = preflightDiagnostics;
112
117
  this.permissions = permissions;
118
+ this.bridgeLoop = new ToolBridgeLoop(readImage, bridgeStopped);
113
119
  this.config = typeof config === 'function' ? config : () => config;
114
120
  }
115
121
  providerInfo(provider) { return { id: provider, name: this.providerName() }; }
@@ -156,7 +162,10 @@ export class CodexRemoteAdapter extends LlmAdapter {
156
162
  void Promise.resolve().then(() => runtime.dispose?.(cause)).catch(() => console.warn('[remote-agent] 旧主机会话暂未清理,将由主机自动回收'));
157
163
  return Promise.resolve();
158
164
  }
165
+ completeDshTurn(sessionId, messages) { this.sessions.get(sessionId)?.runtime.execution?.endTurn(); return this.bridgeLoop.conclude(sessionId, messages); }
166
+ hasBridgeSession(sessionId) { return this.bridgeLoop.active(sessionId); }
159
167
  async disposeSession(sessionId) {
168
+ await this.bridgeLoop.cancel(sessionId);
160
169
  this.turns.get(sessionId)?.abort();
161
170
  const current = this.sessions.get(sessionId);
162
171
  if (!current)
@@ -166,6 +175,7 @@ export class CodexRemoteAdapter extends LlmAdapter {
166
175
  await this.cleanupRuntime(current.runtime);
167
176
  }
168
177
  async detachSession(sessionId) {
178
+ await this.bridgeLoop.cancel(sessionId);
169
179
  const current = this.sessions.get(sessionId), turn = this.turns.get(sessionId);
170
180
  if (!current && !turn)
171
181
  return;
@@ -188,8 +198,10 @@ export class CodexRemoteAdapter extends LlmAdapter {
188
198
  for (const live of this.sessions.values())
189
199
  live.runtime.closeDiagnostics?.record({ layer: 'adapter', initiator: 'adapter.dispose', reason: 'PLUGIN_DISPOSED', action: 'dispose', changes: [{ field: 'adapter.shutting-down', before: this.shuttingDown, after: true }] });
190
200
  this.shuttingDown = true;
201
+ await this.bridgeLoop.dispose();
191
202
  this.modelPresentation.clear();
192
203
  this.employeeNoticeSessions.clear();
204
+ this.pendingEmployeeNotices.clear();
193
205
  const results = await Promise.allSettled([...new Set([...this.sessions.keys(), ...this.turns.keys()])].map(id => this.detachSession(id)));
194
206
  this.lifetime.abort();
195
207
  const failure = results.find(result => result.status === 'rejected');
@@ -197,9 +209,15 @@ export class CodexRemoteAdapter extends LlmAdapter {
197
209
  throw failure.reason;
198
210
  }
199
211
  async *stream(options) {
200
- let promptDispatched = false;
212
+ let promptDispatched = !!options.sessionId && (this.sessions.get(options.sessionId)?.session.promptDispatchCount ?? 0) > 0;
201
213
  try {
202
- yield* this.generate(options, () => { promptDispatched = true; });
214
+ for await (const chunk of this.bridgeLoop.stream(options, (o, b) => this.generate(o, () => { promptDispatched = true; }, b))) {
215
+ if (options.sessionId && this.pendingEmployeeNotices.has(options.sessionId) && chunk.type === 'text-delta' && chunk.text.startsWith(EMPLOYEE_EXECUTION_NOTICE)) {
216
+ this.employeeNoticeSessions.add(options.sessionId);
217
+ this.pendingEmployeeNotices.delete(options.sessionId);
218
+ }
219
+ yield chunk;
220
+ }
203
221
  }
204
222
  catch (error) {
205
223
  if (error instanceof ModelConfigurationError)
@@ -223,8 +241,12 @@ export class CodexRemoteAdapter extends LlmAdapter {
223
241
  }
224
242
  throw new LlmError(employeeFailure('TURN_FAILED', this.agent.loginProvider, promptDispatched), 'TURN_FAILED', { cause: error });
225
243
  }
244
+ finally {
245
+ if (options.sessionId)
246
+ this.pendingEmployeeNotices.delete(options.sessionId);
247
+ }
226
248
  }
227
- async *generate(original, onPromptDispatched) {
249
+ async *generate(original, onPromptDispatched, bridge) {
228
250
  if (original.purpose)
229
251
  throw new NeedError('PURPOSE_UNSUPPORTED', '此远程模型不处理后台标题或上下文整理请求,未发送模型请求', '独立的后台请求能力');
230
252
  if (this.lifetime.signal.aborted)
@@ -238,6 +260,7 @@ export class CodexRemoteAdapter extends LlmAdapter {
238
260
  const signal = AbortSignal.any([this.lifetime.signal, turn.signal, ...(original.signal ? [original.signal] : [])]);
239
261
  const options = { ...original, signal };
240
262
  const abortOffs = [];
263
+ let bridgeMcp;
241
264
  let request;
242
265
  let earlyStage = 'input-prepare';
243
266
  let runtime;
@@ -261,7 +284,7 @@ export class CodexRemoteAdapter extends LlmAdapter {
261
284
  runtime.details.record(entry); };
262
285
  const config = this.config();
263
286
  const mode = 'session';
264
- request = { sessionId: options.sessionId, mode: mode ?? 'session', signal };
287
+ request = { sessionId: options.sessionId, mode: mode ?? 'session', signal, toolBridge: bridge };
265
288
  const observeAbort = (source, initiator, reason) => { if (!source)
266
289
  return; const record = () => { (request?.closeDiagnostics ?? runtime?.closeDiagnostics)?.record({ layer: 'adapter', initiator, reason, action: 'abort', changes: [{ field: 'adapter.turn-aborted', before: false, after: source.aborted }] }); }; source.addEventListener('abort', record, { once: true }); abortOffs.push(() => source.removeEventListener('abort', record)); };
267
290
  observeAbort(original.signal, 'signal.caller', 'CALLER_ABORTED');
@@ -329,6 +352,10 @@ export class CodexRemoteAdapter extends LlmAdapter {
329
352
  if (runtime.hostWorkspace && mode === 'session' && Buffer.byteLength(JSON.stringify({ prompt: input.prompt, systemPrompt: input.systemPrompt }), 'utf8') > config.acpMaxMessageBytes)
330
353
  throw new NeedError('ACP_MESSAGE_TOO_LARGE', '本轮输入超过已设置的会话消息容量,未截断或发送', '管理员调整acpMaxMessageBytes');
331
354
  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 });
355
+ if (bridge && runtime.employeeExecutionMeta && !runtime.hostWorkspace && bridge.table.size) {
356
+ bridge.enable(event => details.record(event));
357
+ bridgeMcp = await openToolBridgeMcp(bridge, config.acpMaxMessageBytes);
358
+ }
332
359
  recordInformation();
333
360
  request.recordEarlyFailure = undefined;
334
361
  if (input.historyTransfer)
@@ -339,16 +366,22 @@ export class CodexRemoteAdapter extends LlmAdapter {
339
366
  activeMapper = mapper;
340
367
  let id;
341
368
  let stop;
369
+ const pointerRuntime = runtime;
370
+ const makePointer = (nativeId) => ({ ...(pointerRuntime.hostSource ? { hostSource: pointerRuntime.hostSource } : {}), ...(modelConfiguration ? { modelConfiguration } : {}), version: 2, agent: this.agent.id, contextSha256, ...(mode === 'session' ? { usageSnapshot: mapper.usageSnapshot } : {}), ...(options.sessionId ? { dshSessionId: options.sessionId } : {}), identity: pointerRuntime.identity, cwd: pointerRuntime.cwd, sessionId: nativeId, ...(pointerRuntime.binding ? { bindingId: pointerRuntime.binding.bindingId, workspaceId: pointerRuntime.binding.workspaceId } : {}) });
342
371
  if (runtime.employeePresentation)
343
372
  details.record({ kind: 'session', severity: 'warning', code: 'EMPLOYEE_EXECUTION_SCOPE', summary: runtime.employeePresentation, payload: { source: 'local' } });
344
373
  if (runtime.execution)
345
374
  details.record({ kind: 'session', severity: 'warning', code: 'CLAUDE_EMPLOYEE_SCOPE', summary: runtime.execution.notice, payload: runtime.execution.scope });
346
375
  if ((runtime.employeePresentation || runtime.execution) && !employeeNoticeShown) {
347
- for (const chunk of mapper.text(EMPLOYEE_EXECUTION_NOTICE + '\n\n')) {
376
+ for (const chunk of mapper.text(EMPLOYEE_EXECUTION_NOTICE + (bridge?.enabled ? '\n\n' + TOOLBRIDGE_NOTICE : '') + '\n\n')) {
348
377
  if (chunk.type === 'text-delta') {
349
378
  employeeNoticeShown = true;
350
- if (key)
351
- this.employeeNoticeSessions.add(key);
379
+ if (key) {
380
+ if (bridge)
381
+ this.pendingEmployeeNotices.add(key);
382
+ else
383
+ this.employeeNoticeSessions.add(key);
384
+ }
352
385
  }
353
386
  yield chunk;
354
387
  }
@@ -363,6 +396,7 @@ export class CodexRemoteAdapter extends LlmAdapter {
363
396
  let failure;
364
397
  const push = (chunks) => { queue.push(...chunks); wake?.(); wake = undefined; };
365
398
  let promptPermit;
399
+ let confirmedNativeMode;
366
400
  const hooks = { closeDiagnostics: runtime.closeDiagnostics, broker: runtime.broker, sessionOwnership: runtime.sessionOwnership, employeeExecution: runtime.employeeExecution, ...this.permissions ? { promptGuard: () => {
367
401
  if (!promptPermit)
368
402
  throw new NeedError('PERMISSION_POLICY_CHANGED', '当前权限没有保持确认,本次没有继续派发。', '当前权限代次和同值回执');
@@ -373,6 +407,17 @@ export class CodexRemoteAdapter extends LlmAdapter {
373
407
  } } : {}, approve: async (permission, approvalSignal) => {
374
408
  const execution = runtime.execution;
375
409
  const toolName = permission.toolCall.name ?? permission.toolCall._meta?.claudeCode?.toolName;
410
+ if (execution && bridge?.enabled && bridge.isBridge(toolName)) {
411
+ const current = this.permissions ? permissionMode('claude', this.permissions.read(options.sessionId)) : undefined;
412
+ if (current === 'plan') {
413
+ details.record({ kind: 'tool', severity: 'info', code: 'TOOLBRIDGE_PLAN_PERMISSION_REJECTED', summary: '本轮为plan档,桥接原生权限请求未放行。' });
414
+ return 'rejected';
415
+ }
416
+ if (current === 'default' && confirmedNativeMode === 'default') {
417
+ details.record({ kind: 'tool', severity: 'info', code: 'TOOLBRIDGE_NATIVE_PERMISSION_ANSWERED', summary: '已确认桥接工具仅在DSH审批,原生请求自动答复一次。', payload: { tool: toolName } });
418
+ return 'allowed-once';
419
+ }
420
+ }
376
421
  if (execution) {
377
422
  if (toolName && ['Agent', 'Task', 'TaskOutput', 'TaskStop', 'Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash'].includes(toolName)) {
378
423
  details.record({ kind: 'need', severity: 'warning', code: 'CLAUDE_HOST_TOOL_UNSUPPORTED', summary: '这类原生调用没有经过员工端别名,本机来源不支持;本次审批已取消', payload: { operation: toolName } });
@@ -383,6 +428,7 @@ export class CodexRemoteAdapter extends LlmAdapter {
383
428
  const answer = await this.approve?.(permission, approvalSignal, options, execution && toolName?.startsWith('mcp__employee__') ? 'employee-local' : undefined, runtime.hostWorkspace) ?? 'unavailable';
384
429
  return answer;
385
430
  }, onEmployeeNotice: notice => push(mapper.text(notice + '\n\n')), onUpdate: notification => { try {
431
+ bridge?.observe(notification.update);
386
432
  push(mapper.acp(notification.update));
387
433
  }
388
434
  catch (error) {
@@ -412,6 +458,7 @@ export class CodexRemoteAdapter extends LlmAdapter {
412
458
  const remoteMode = permissionMode(this.agent.loginProvider, policy);
413
459
  details.record({ kind: 'session', severity: 'info', code: 'DSH_PERMISSION_POLICY', summary: '按DSH当前会话权限同步远程工具;不会把关闭审批提示解释为自动批准。', payload: { ...policy, remoteMode } });
414
460
  await session.selectPermissionMode(remoteMode, modelSignal, this.agent.loginProvider);
461
+ confirmedNativeMode = remoteMode;
415
462
  permissionKey = next;
416
463
  });
417
464
  };
@@ -437,7 +484,7 @@ export class CodexRemoteAdapter extends LlmAdapter {
437
484
  const task = (async () => {
438
485
  remoteAttempted = true;
439
486
  try {
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);
487
+ await session.start(runtime.execution?.hostCwd ?? runtime.cwd, previous?.sessionId, modelSignal, { systemPrompt: input.systemPrompt, ...(bridgeMcp ? { toolBridgeServers: bridgeMcp.mcpServers } : {}), ...(runtime.execution ? { employeeRouting: runtime.execution.routing } : {}), ...(codex ? { codexNewSessionOnly: true } : {}), ...(runtime.employeeExecutionMeta ? { employeeExecution: runtime.employeeExecutionMeta } : {}) }, input.prompt);
441
488
  }
442
489
  catch (error) {
443
490
  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 } });
@@ -488,9 +535,14 @@ export class CodexRemoteAdapter extends LlmAdapter {
488
535
  recordInformation();
489
536
  details.beginTurn(runtime.detailContext?.() ?? { turn: null, step: null });
490
537
  await runtime.execution?.beginTurn(modelSignal);
538
+ if (bridge?.enabled)
539
+ bridge.replayState = { response: makePointer(session.id) };
540
+ bridge?.begin(modelSignal);
491
541
  id = session.id;
492
542
  try {
493
543
  const result = await dispatchPrompt();
544
+ if (bridge?.unsettled)
545
+ bridge.abandon('HOST_PROMPT_ENDED_WITH_PENDING_CALL');
494
546
  permissionOff?.();
495
547
  permissionOff = undefined;
496
548
  await permissionTail;
@@ -499,6 +551,7 @@ export class CodexRemoteAdapter extends LlmAdapter {
499
551
  finally {
500
552
  permissionOff?.();
501
553
  permissionOff = undefined;
554
+ bridge?.end();
502
555
  runtime.execution?.endTurn();
503
556
  details.endTurn();
504
557
  }
@@ -533,13 +586,15 @@ export class CodexRemoteAdapter extends LlmAdapter {
533
586
  }
534
587
  }
535
588
  yield* mapper.flush();
536
- const pointer = { ...(runtime.hostSource ? { hostSource: runtime.hostSource } : {}), ...(modelConfiguration ? { modelConfiguration } : {}), version: 2, agent: this.agent.id, contextSha256, ...(mode === 'session' ? { usageSnapshot: mapper.usageSnapshot } : {}), ...(options.sessionId ? { dshSessionId: options.sessionId } : {}), identity: runtime.identity, cwd: runtime.cwd, sessionId: id, ...(runtime.binding ? { bindingId: runtime.binding.bindingId, workspaceId: runtime.binding.workspaceId } : {}) };
589
+ const pointer = makePointer(id);
537
590
  succeeded = true;
538
591
  yield { type: 'finish', reason: { kind: stop === 'max_tokens' || stop === 'max_turn_requests' ? 'max-tokens' : 'stop' }, replayState: { response: pointer } };
539
592
  }
540
593
  catch (error) {
541
594
  if (session && session.promptDispatchCount > promptBaseline)
542
595
  onPromptDispatched();
596
+ if (bridge?.unsettled && !signal.aborted)
597
+ bridge.abandon('HOST_PROMPT_FAILED');
543
598
  turnFailure = error;
544
599
  runtime?.closeDiagnostics?.record({ layer: 'adapter', initiator: 'adapter.turn-failure', reason: closeCode(error, signal.aborted ? 'ABORTED' : 'TURN_FAILED'), action: 'failure' });
545
600
  recordInformation();
@@ -559,6 +614,8 @@ export class CodexRemoteAdapter extends LlmAdapter {
559
614
  for (const off of abortOffs)
560
615
  off();
561
616
  runtime?.execution?.endTurn();
617
+ if (bridgeMcp)
618
+ await bridgeMcp.close();
562
619
  if (runtime) {
563
620
  session?.close({ layer: 'adapter', initiator: 'adapter.turn-finally', reason: succeeded ? 'TURN_FINISHED' : 'TURN_FAILED', action: 'close' });
564
621
  if (key && this.sessions.get(key) === live)
@@ -575,16 +632,25 @@ export class CodexRemoteAdapter extends LlmAdapter {
575
632
  /** One official registration owns both routes, preserving opaque replay ownership. */
576
633
  export class RemoteAgentsAdapter extends LlmAdapter {
577
634
  adapters = new Map();
578
- constructor(config, makeRuntime, approve, nameFor, readImage, reportLoginFailure, directory, preflightDiagnostics, permissions) { super(); for (const agent of AGENTS)
579
- this.adapters.set(agent.id, new CodexRemoteAdapter(config, makeRuntime(agent), approve, () => nameFor(agent), agent, readImage, reportLoginFailure, directory, preflightDiagnostics, permissions)); }
635
+ constructor(config, makeRuntime, approve, nameFor, readImage, reportLoginFailure, directory, preflightDiagnostics, permissions, bridgeStopped) { super(); for (const agent of AGENTS)
636
+ this.adapters.set(agent.id, new CodexRemoteAdapter(config, makeRuntime(agent), approve, () => nameFor(agent), agent, readImage, reportLoginFailure, directory, preflightDiagnostics, permissions, bridgeStopped)); }
580
637
  adapter(provider) { const adapter = this.adapters.get(provider); if (!adapter)
581
638
  throw new LlmError('远程模型未注册', 'PROVIDER_NOT_FOUND'); return adapter; }
582
639
  providerInfo(provider) { return this.adapter(provider).providerInfo(provider); }
583
640
  listModels(provider) { return this.adapter(provider).listModels(provider); }
584
641
  resolveModel(provider, model, signal) { return this.adapter(provider).resolveModel(provider, model, signal); }
585
- prepareCall(provider, model, signal) { return this.adapter(provider).prepareCall(provider, model, signal); }
642
+ async prepareCall(provider, model, signal) { const prepared = await this.adapter(provider).prepareCall(provider, model, signal); return { ...prepared, stream: (options) => this.stream(options) }; }
586
643
  providerRetryPolicy() { return { mode: 'normal', maxRetries: 0, retryableCodes: [], initialDelayMs: 0, maxDelayMs: 0, jitterRatio: 0 }; }
587
- stream(options) { return this.adapter(options.provider).stream(options); }
644
+ stream(options) {
645
+ if (options.sessionId)
646
+ for (const [provider, adapter] of this.adapters)
647
+ if (provider !== options.provider && adapter.hasBridgeSession(options.sessionId)) {
648
+ void adapter.disposeSession(options.sessionId);
649
+ throw new LlmError('原主机还在等待DSH工具结果,切换助手已停止原轮次;请核对已执行的工具后重新发送,结果不会重放。', 'TOOLBRIDGE_ROUTE_CHANGED');
650
+ }
651
+ return this.adapter(options.provider).stream(options);
652
+ }
653
+ async completeDshTurn(sessionId, messages) { await Promise.all([...this.adapters.values()].map(adapter => adapter.completeDshTurn(sessionId, messages))); }
588
654
  async disposeSession(sessionId) { await Promise.all([...this.adapters.values()].map(adapter => adapter.disposeSession(sessionId))); }
589
655
  async detachSession(sessionId) { await Promise.all([...this.adapters.values()].map(adapter => adapter.detachSession(sessionId))); }
590
656
  async dispose() { await Promise.all([...this.adapters.values()].map(adapter => adapter.dispose())); }
@@ -688,7 +754,33 @@ export function apply(ctx, live) {
688
754
  step = null;
689
755
  }
690
756
  return managerForProfile().recordLoginFailure({ sessionId, code, diagnostic, context: { turn, step } });
691
- }, modelDirectory, preflightDiagnostics, dshPermissionSource(ctx));
757
+ }, modelDirectory, preflightDiagnostics, dshPermissionSource(ctx), (sessionId, notice) => {
758
+ const agent = ctx.get('agents')?.get(SessionId(sessionId));
759
+ if (!agent)
760
+ return;
761
+ let turn = null, step = null;
762
+ for (const e of agent.session.snapshotEvents()) {
763
+ if (e.type === 'turn/start') {
764
+ turn = e.data.turn;
765
+ step = null;
766
+ }
767
+ else if (e.type === 'turn/end') {
768
+ turn = null;
769
+ step = null;
770
+ }
771
+ else if (e.type === 'step/start')
772
+ step = e.data.step;
773
+ else if (e.type === 'step/end')
774
+ step = null;
775
+ }
776
+ if (turn !== null && step !== null)
777
+ agent.session.append('assistant/message', { turn, step, message: createAssistantMessage({ content: [{ type: 'text', text: notice }], source: { provider: 'ai-workbench-toolbridge', model: 'notice' } }), stream: [] }, { surfaceOp: 'append' });
778
+ agent.cancel({ kind: 'hook', reason: 'DSH_BRIDGE_CALL_ABANDONED_BY_HOST' }, { keepInbox: true });
779
+ });
780
+ ctx.on('session/event', (session, event) => { if (event.type === 'turn/end') {
781
+ const operation = event.data.reason.kind === 'completed' ? adapter.completeDshTurn(session.id, session.deriveMessages()) : adapter.disposeSession(session.id);
782
+ void operation.catch(() => ctx.logger('remote-agent').warn('TOOLBRIDGE_RESULT_DELIVERY_UNCONFIRMED'));
783
+ } });
692
784
  const ids = AGENTS.map(agent => agent.id);
693
785
  const entries = () => AGENTS.map(agent => ({ provider: agent.id, displayName: agent.name[language], settingsNs: actualSettingsNamespace ?? settingsNamespace, settingsPath: [] }));
694
786
  const directory = ctx.llm.registerConfigurableProviders(entries());
@@ -1,3 +1,4 @@
1
+ import type { ToolBridge } from './toolbridge.ts';
1
2
  import { type SessionSource } from './host-workspace.ts';
2
3
  import type { CloseCause, CloseDiagnostics } from './close-diagnostics.ts';
3
4
  import type { AgentApp } from '@agentclientprotocol/sdk';
@@ -66,6 +67,7 @@ export interface Runtime {
66
67
  closeDiagnostics?: CloseDiagnostics;
67
68
  }
68
69
  export interface RuntimeRequest extends Invocation {
70
+ toolBridge?: ToolBridge;
69
71
  workspaceSource?: SessionSource;
70
72
  hostSource?: Extract<SessionSource, {
71
73
  source: 'host';
@@ -0,0 +1,19 @@
1
+ import { type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm';
2
+ import { ToolBridge } from './toolbridge.ts';
3
+ import type { ImageReader } from './input.ts';
4
+ type Producer = (options: GenerateOptions, bridge: ToolBridge) => AsyncGenerator<StreamChunk>;
5
+ /** Owns the original prompt across official generate/tool/generate steps. No tool execution. */
6
+ export declare class ToolBridgeLoop {
7
+ private readImage?;
8
+ private stopped?;
9
+ private runs;
10
+ private concluding;
11
+ constructor(readImage?: ImageReader | undefined, stopped?: ((sessionId: string, notice: string) => void) | undefined);
12
+ private launch;
13
+ conclude(id: string, messages: GenerateOptions['messages']): Promise<void>;
14
+ active(id: string): boolean;
15
+ cancel(id: string): void;
16
+ dispose(): Promise<void>;
17
+ stream(options: GenerateOptions, producer: Producer): AsyncGenerator<StreamChunk>;
18
+ }
19
+ export {};
@@ -0,0 +1,251 @@
1
+ import { createAssistantMessage } from '@deepseek-ai/dsh-llm';
2
+ import { ToolBridge } from "./toolbridge.js";
3
+ import { NeedError } from "./remote.js";
4
+ /** Rebase native text blocks at each DSH step; a parked stream must end all open blocks. */
5
+ class Projection {
6
+ blocks = new Map();
7
+ next = 0;
8
+ chunks(chunk) {
9
+ if (chunk.type === 'block-start') {
10
+ if (chunk.blockType !== 'text' && chunk.blockType !== 'reasoning')
11
+ throw Error('TOOLBRIDGE_NATIVE_BLOCK_UNSUPPORTED');
12
+ this.blocks.set(chunk.index, { type: chunk.blockType, text: '' });
13
+ return [];
14
+ }
15
+ if (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta') {
16
+ const block = this.blocks.get(chunk.index);
17
+ if (!block)
18
+ throw Error('TOOLBRIDGE_NATIVE_BLOCK_MISSING');
19
+ const result = [];
20
+ if (block.index === undefined) {
21
+ block.index = this.next++;
22
+ result.push({ type: 'block-start', index: block.index, blockType: block.type });
23
+ }
24
+ const text = chunk.type === 'text-delta' ? chunk.text : chunk.text;
25
+ block.text += text;
26
+ result.push({ ...chunk, index: block.index });
27
+ return result;
28
+ }
29
+ if (chunk.type === 'block-end') {
30
+ const block = this.blocks.get(chunk.index);
31
+ this.blocks.delete(chunk.index);
32
+ return block?.index === undefined ? [] : [{ type: 'block-end', index: block.index, block: { type: block.type, text: block.text } }];
33
+ }
34
+ return [chunk];
35
+ }
36
+ park() { const chunks = []; for (const block of this.blocks.values())
37
+ if (block.index !== undefined) {
38
+ chunks.push({ type: 'block-end', index: block.index, block: { type: block.type, text: block.text } });
39
+ block.index = undefined;
40
+ block.text = '';
41
+ } return chunks; }
42
+ }
43
+ /** Owns the original prompt across official generate/tool/generate steps. No tool execution. */
44
+ export class ToolBridgeLoop {
45
+ readImage;
46
+ stopped;
47
+ runs = new Map();
48
+ concluding = new Map();
49
+ constructor(readImage, stopped) {
50
+ this.readImage = readImage;
51
+ this.stopped = stopped;
52
+ }
53
+ launch(options, producer) {
54
+ const controller = new AbortController(), bridge = new ToolBridge(options, this.readImage);
55
+ const run = { bridge, controller, options, latest: options, queue: [], wake: Promise.withResolvers(), done: false, task: undefined, seen: new Set(options.messages.flatMap(m => 'id' in m && typeof m.id === 'string' ? [m.id] : [])), deferred: [], text: '', reading: false, projection: new Projection(), off: () => { } };
56
+ const signal = AbortSignal.any([controller.signal, ...options.signal ? [options.signal] : []]);
57
+ const changed = () => { run.wake.resolve(); };
58
+ bridge.changed = changed;
59
+ bridge.onAbandon = notice => { controller.abort(); if (options.sessionId)
60
+ this.stopped?.(options.sessionId, notice); };
61
+ const abort = () => { bridge.close(); changed(); };
62
+ signal.addEventListener('abort', abort, { once: true });
63
+ run.off = () => signal.removeEventListener('abort', abort);
64
+ run.task = (async () => {
65
+ try {
66
+ for await (const chunk of producer({ ...options, signal }, bridge)) {
67
+ if (chunk.type === 'finish') {
68
+ run.finish = chunk;
69
+ continue;
70
+ }
71
+ if (chunk.type === 'text-delta')
72
+ run.text += chunk.text;
73
+ run.queue.push(chunk);
74
+ changed();
75
+ }
76
+ if (bridge.unsettled)
77
+ bridge.abandon('HOST_PROMPT_ENDED_WITH_PENDING_CALL');
78
+ }
79
+ catch (error) {
80
+ run.error = error;
81
+ if (bridge.unsettled)
82
+ bridge.abandon('HOST_PROMPT_FAILED');
83
+ }
84
+ finally {
85
+ run.done = true;
86
+ changed();
87
+ }
88
+ })();
89
+ return run;
90
+ }
91
+ async conclude(id, messages) {
92
+ const existing = this.concluding.get(id);
93
+ if (existing)
94
+ return existing.flight;
95
+ const run = this.runs.get(id);
96
+ if (!run || !run.bridge.enabled || !run.bridge.unsettled) {
97
+ this.cancel(id);
98
+ return;
99
+ }
100
+ this.runs.delete(id);
101
+ run.bridge.beginConclude();
102
+ run.bridge.onConcludeAcknowledged = () => run.controller.abort();
103
+ const flight = (async () => {
104
+ try {
105
+ await run.bridge.results({ ...run.latest, messages, signal: run.controller.signal });
106
+ await run.task;
107
+ if (run.error && !run.bridge.concludeAcknowledged)
108
+ throw run.error;
109
+ }
110
+ catch (error) {
111
+ run.bridge.close();
112
+ run.controller.abort();
113
+ throw error;
114
+ }
115
+ finally {
116
+ run.off();
117
+ }
118
+ })();
119
+ this.concluding.set(id, { run, flight });
120
+ void flight.then(() => { if (this.concluding.get(id)?.flight === flight)
121
+ this.concluding.delete(id); }, () => { });
122
+ await flight;
123
+ }
124
+ active(id) { return (this.runs.get(id)?.bridge.enabled ?? false) || this.concluding.has(id); }
125
+ cancel(id) {
126
+ const closing = this.concluding.get(id);
127
+ if (closing) {
128
+ this.concluding.delete(id);
129
+ closing.run.bridge.close();
130
+ closing.run.controller.abort();
131
+ closing.run.off();
132
+ }
133
+ const run = this.runs.get(id);
134
+ if (!run)
135
+ return;
136
+ this.runs.delete(id);
137
+ run.bridge.close();
138
+ run.controller.abort();
139
+ run.off();
140
+ }
141
+ async dispose() { for (const id of new Set([...this.runs.keys(), ...this.concluding.keys()]))
142
+ this.cancel(id); }
143
+ async *stream(options, producer) {
144
+ const key = options.sessionId ?? '';
145
+ const closing = this.concluding.get(key);
146
+ if (closing) {
147
+ let aborted;
148
+ try {
149
+ await Promise.race([closing.flight, ...(options.signal ? [new Promise((_r, reject) => { aborted = () => reject(new NeedError('ABORTED', '本轮等待已取消,尚未发送新输入。', '原主机prompt收尾')); options.signal.addEventListener('abort', aborted, { once: true }); if (options.signal.aborted)
150
+ aborted(); })] : [])]);
151
+ }
152
+ catch (error) {
153
+ if (this.concluding.get(key) === closing)
154
+ this.cancel(key);
155
+ else {
156
+ closing.run.bridge.close();
157
+ closing.run.controller.abort();
158
+ closing.run.off();
159
+ }
160
+ throw error;
161
+ }
162
+ finally {
163
+ if (aborted)
164
+ options.signal?.removeEventListener('abort', aborted);
165
+ if (this.concluding.get(key) === closing)
166
+ this.concluding.delete(key);
167
+ }
168
+ }
169
+ let run = this.runs.get(key), boundary = false;
170
+ if (run?.reading)
171
+ throw new NeedError('TURN_BUSY', '工具桥仍在处理当前生成步骤。', '等待当前步骤结束');
172
+ if (!run) {
173
+ run = this.launch(options, producer);
174
+ if (key)
175
+ this.runs.set(key, run);
176
+ }
177
+ run.reading = true;
178
+ run.projection.next = 0;
179
+ try {
180
+ if (options.signal?.aborted)
181
+ throw new NeedError('ABORTED', '本轮已取消,工具结果没有重放。', '确认主机取消状态');
182
+ if (run.latest !== options) {
183
+ for (const m of options.messages)
184
+ if (m.role === 'user' && 'source' in m && m.source?.kind === 'user' && 'id' in m && typeof m.id === 'string' && !run.seen.has(m.id)) {
185
+ run.seen.add(m.id);
186
+ run.deferred.push(m);
187
+ }
188
+ run.latest = options;
189
+ await run.bridge.results(options);
190
+ }
191
+ for (;;) {
192
+ const wake = run.wake;
193
+ run.wake = Promise.withResolvers();
194
+ while (run.queue.length)
195
+ yield* run.projection.chunks(run.queue.shift());
196
+ if (run.error)
197
+ throw run.error;
198
+ if (run.bridge.pending.length && !run.done) {
199
+ yield* run.projection.park();
200
+ for (const p of run.bridge.pending) {
201
+ p.exported = true;
202
+ const index = run.projection.next++;
203
+ yield { type: 'block-start', index, blockType: 'tool-call' };
204
+ yield { type: 'tool-call-delta', index, id: p.block.id, name: p.block.name, argumentsDelta: p.block.arguments };
205
+ yield { type: 'block-end', index, block: p.block };
206
+ }
207
+ boundary = true;
208
+ yield { type: 'finish', reason: { kind: 'tool-calls' }, ...(run.bridge.replayState ? { replayState: run.bridge.replayState } : {}) };
209
+ return;
210
+ }
211
+ if (run.done) {
212
+ yield* run.projection.park();
213
+ run.off();
214
+ if (run.deferred.length) {
215
+ const deferred = run.deferred, ids = new Set(deferred.flatMap(m => 'id' in m ? [m.id] : []));
216
+ const last = createAssistantMessage({ content: run.text ? [{ type: 'text', text: run.text }] : [], source: { provider: run.options.provider, model: run.options.model, ...(run.finish?.replayState ? { replayState: { response: run.finish.replayState.response } } : {}) } });
217
+ const next = { ...options, messages: [...run.latest.messages.filter(m => !('id' in m && ids.has(m.id))), last, ...deferred] };
218
+ const nextIndex = run.projection.next;
219
+ run.bridge.close();
220
+ run = this.launch(next, producer);
221
+ run.projection.next = nextIndex;
222
+ run.reading = true;
223
+ if (key)
224
+ this.runs.set(key, run);
225
+ continue;
226
+ }
227
+ if (key)
228
+ this.runs.delete(key);
229
+ run.bridge.close();
230
+ boundary = true;
231
+ yield run.finish ?? { type: 'finish', reason: { kind: 'stop' } };
232
+ return;
233
+ }
234
+ if (options.signal?.aborted)
235
+ throw new NeedError('ABORTED', '本轮已取消,工具结果没有重放。', '确认主机取消状态');
236
+ await Promise.race([wake.promise, run.wake.promise]);
237
+ }
238
+ }
239
+ finally {
240
+ run.reading = false;
241
+ if (!boundary || options.signal?.aborted) {
242
+ run.bridge.close();
243
+ run.controller.abort();
244
+ run.off();
245
+ if (key && this.runs.get(key) === run)
246
+ this.runs.delete(key);
247
+ await run.task;
248
+ }
249
+ }
250
+ }
251
+ }
@@ -0,0 +1,10 @@
1
+ import type { ToolSchema } from '@deepseek-ai/dsh-llm';
2
+ import type { Tool } from '@modelcontextprotocol/sdk/types.js';
3
+ /** Fixed official tool names; no description heuristics or plugin-name branches. */
4
+ export declare const TOOLBRIDGE_EXCLUDED: readonly ["read", "write", "edit", "read_image", "glob", "grep", "bash", "pwsh", "str_replace_editor", "job_output", "job_list", "job_kill", "load_workspace_dependencies", "terminal_open", "terminal_send", "terminal_read", "terminal_signal", "terminal_close", "terminal_list", "subagent", "list_subagent_models", "send_message", "interrupt_agent", "spawn_teammate", "list_agents", "wait_agent", "team_task_create", "team_task_list", "team_task_get", "team_task_update", "run_code"];
5
+ export interface BridgeTool {
6
+ alias: string;
7
+ schema: ToolSchema;
8
+ wire: Tool;
9
+ }
10
+ export declare function bridgeTable(tools?: readonly ToolSchema[]): ReadonlyMap<string, BridgeTool>;
@@ -0,0 +1,27 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { NeedError } from "./remote.js";
3
+ /** Fixed official tool names; no description heuristics or plugin-name branches. */
4
+ export const TOOLBRIDGE_EXCLUDED = Object.freeze([
5
+ 'read', 'write', 'edit', 'read_image', 'glob', 'grep', 'bash', 'pwsh', 'str_replace_editor',
6
+ 'job_output', 'job_list', 'job_kill', 'load_workspace_dependencies',
7
+ 'terminal_open', 'terminal_send', 'terminal_read', 'terminal_signal', 'terminal_close', 'terminal_list',
8
+ 'subagent', 'list_subagent_models', 'send_message', 'interrupt_agent', 'spawn_teammate', 'list_agents', 'wait_agent',
9
+ 'team_task_create', 'team_task_list', 'team_task_get', 'team_task_update', 'run_code',
10
+ ]);
11
+ const excluded = new Set(TOOLBRIDGE_EXCLUDED);
12
+ export function bridgeTable(tools = []) {
13
+ const table = new Map(), names = new Set();
14
+ for (const tool of tools) {
15
+ if (excluded.has(tool.name))
16
+ continue;
17
+ if (names.has(tool.name))
18
+ throw new NeedError('TOOLBRIDGE_TOOL_DUPLICATE', '当前工具表存在重名,工具桥未开始。', '官方唯一工具名称');
19
+ names.add(tool.name);
20
+ const alias = 'dsh_' + createHash('sha256').update(tool.name).digest('hex').slice(0, 32);
21
+ if (table.has(alias))
22
+ throw new NeedError('TOOLBRIDGE_NAME_COLLISION', '工具桥名称冲突,未执行。', '唯一工具桥名称');
23
+ const schema = structuredClone(tool);
24
+ table.set(alias, { alias, schema, wire: { name: alias, description: 'DSH工具 ' + schema.name + ':' + schema.description, inputSchema: structuredClone(schema.parameters) } });
25
+ }
26
+ return table;
27
+ }
@@ -0,0 +1,114 @@
1
+ import { type CallToolResult } from '@modelcontextprotocol/sdk/types.js';
2
+ import type { GenerateOptions, ToolCallBlock } from '@deepseek-ai/dsh-llm';
3
+ import type { SessionUpdate } from '@agentclientprotocol/sdk/unstable';
4
+ import { type BridgeTool } from './toolbridge-table.ts';
5
+ import type { ImageReader } from './input.ts';
6
+ import type { DetailObservation } from './conversation-details.ts';
7
+ export declare const TOOLBRIDGE_TIMEOUT_SECONDS = 1800;
8
+ export declare const TOOLBRIDGE_NOTICE = "\u542B\u5DE5\u5177\u6865\uFF1A\u672C\u673A\u6587\u4EF6\u5939\u7684\u6269\u5C55\u5DE5\u5177\u7531DSH\u6267\u884C\u5E76\u6309\u5F53\u524D\u6743\u9650\u5BA1\u6279\uFF1B\u6587\u4EF6\u548C\u547D\u4EE4\u4FDD\u6301\u539F\u6267\u884C\u65B9\u5F0F\u3002\u5DE5\u5177\u8868\u5728\u65B0\u4E00\u8F6E\u4E3B\u673A\u8FDE\u63A5\u65F6\u66F4\u65B0\u3002";
9
+ export declare const TOOLBRIDGE_ENDED = "\u4E3B\u673A\u5DF2\u7ED3\u675F\u8FD9\u6B21\u5DE5\u5177\u7B49\u5F85\uFF1B\u672C\u8F6E\u5DF2\u505C\u6B62\uFF0C\u8FDF\u5230\u7ED3\u679C\u4E0D\u4F1A\u4EA4\u56DE\uFF0C\u4E5F\u4E0D\u4F1A\u91CD\u65B0\u6267\u884C\u3002\u8BF7\u6838\u5BF9\u5DF2\u53D1\u751F\u7684\u64CD\u4F5C\u3002";
10
+ interface Pending {
11
+ block: ToolCallBlock;
12
+ alias: string;
13
+ reply: ReturnType<typeof Promise.withResolvers<CallToolResult>>;
14
+ exported: boolean;
15
+ settled: boolean;
16
+ off(): void;
17
+ }
18
+ /** Only a protocol queue. Tool execution and authorization belong to DSH. */
19
+ export declare class ToolBridge {
20
+ private options;
21
+ private readImage?;
22
+ readonly controller: AbortController;
23
+ private snapshot?;
24
+ get table(): ReadonlyMap<string, BridgeTool>;
25
+ readonly calls: Map<string, Pending>;
26
+ enabled: boolean;
27
+ ended: boolean;
28
+ notice?: string;
29
+ replayState?: {
30
+ response: unknown;
31
+ };
32
+ localTurnEnded: boolean;
33
+ concludeAcknowledged: boolean;
34
+ onConcludeAcknowledged: () => void;
35
+ private nativeCompleted;
36
+ private nativeTerminated;
37
+ private concludeNative;
38
+ beginConclude(): void;
39
+ private active?;
40
+ begin(signal: AbortSignal): void;
41
+ end(): void;
42
+ changed: () => void;
43
+ record: (event: DetailObservation) => void;
44
+ onAbandon: (notice: string) => void;
45
+ private nativeBridgeIds;
46
+ constructor(options: GenerateOptions, readImage?: ImageReader | undefined);
47
+ enable(record: (event: DetailObservation) => void): void;
48
+ get tools(): {
49
+ inputSchema: {
50
+ [x: string]: unknown;
51
+ type: "object";
52
+ properties?: {
53
+ [x: string]: object;
54
+ } | undefined;
55
+ required?: string[] | undefined;
56
+ };
57
+ name: string;
58
+ description?: string | undefined;
59
+ outputSchema?: {
60
+ [x: string]: unknown;
61
+ type: "object";
62
+ properties?: {
63
+ [x: string]: object;
64
+ } | undefined;
65
+ required?: string[] | undefined;
66
+ } | undefined;
67
+ annotations?: {
68
+ title?: string | undefined;
69
+ readOnlyHint?: boolean | undefined;
70
+ destructiveHint?: boolean | undefined;
71
+ idempotentHint?: boolean | undefined;
72
+ openWorldHint?: boolean | undefined;
73
+ } | undefined;
74
+ execution?: {
75
+ taskSupport?: "optional" | "required" | "forbidden" | undefined;
76
+ } | undefined;
77
+ _meta?: {
78
+ [x: string]: unknown;
79
+ } | undefined;
80
+ icons?: {
81
+ src: string;
82
+ mimeType?: string | undefined;
83
+ sizes?: string[] | undefined;
84
+ theme?: "light" | "dark" | undefined;
85
+ }[] | undefined;
86
+ title?: string | undefined;
87
+ }[];
88
+ isBridge(name: unknown): boolean;
89
+ call(alias: string, args: Record<string, unknown>, signal: AbortSignal): Promise<CallToolResult>;
90
+ get pending(): Pending[];
91
+ get unsettled(): boolean;
92
+ get waiting(): boolean;
93
+ results(options: GenerateOptions): Promise<void>;
94
+ private mapResult;
95
+ private concludeAck;
96
+ observe(update: SessionUpdate): void;
97
+ /** A failed native bridge cancels the entire parked round; never guess between parallel identical calls. */
98
+ abandon(reason: string): void;
99
+ close(): void;
100
+ }
101
+ /** Codex executes nc in its existing employee environment, never on the host. */
102
+ export declare function openToolBridgeMcp(bridge: ToolBridge, maxBytes: number): Promise<{
103
+ mcpServers: {
104
+ name: string;
105
+ command: string;
106
+ args: string[];
107
+ env: {
108
+ name: string;
109
+ value: string;
110
+ }[];
111
+ }[];
112
+ close: () => Promise<void>;
113
+ }>;
114
+ export {};
@@ -0,0 +1,205 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { createServer } from 'node:net';
3
+ import { mkdtemp, lstat, rm } from 'node:fs/promises';
4
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
5
+ import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
6
+ import { EmployeeSocketTransport } from "./employee-mcp.js";
7
+ import { bridgeTable } from "./toolbridge-table.js";
8
+ import { NeedError } from "./remote.js";
9
+ export const TOOLBRIDGE_TIMEOUT_SECONDS = 1800;
10
+ export const TOOLBRIDGE_NOTICE = '含工具桥:本机文件夹的扩展工具由DSH执行并按当前权限审批;文件和命令保持原执行方式。工具表在新一轮主机连接时更新。';
11
+ export const TOOLBRIDGE_ENDED = '主机已结束这次工具等待;本轮已停止,迟到结果不会交回,也不会重新执行。请核对已发生的操作。';
12
+ const errorResult = (text) => ({ isError: true, content: [{ type: 'text', text }] });
13
+ /** Only a protocol queue. Tool execution and authorization belong to DSH. */
14
+ export class ToolBridge {
15
+ options;
16
+ readImage;
17
+ controller = new AbortController();
18
+ snapshot;
19
+ get table() { return this.snapshot ??= bridgeTable(this.options.tools); }
20
+ calls = new Map();
21
+ enabled = false;
22
+ ended = false;
23
+ notice;
24
+ replayState;
25
+ localTurnEnded = false;
26
+ concludeAcknowledged = false;
27
+ onConcludeAcknowledged = () => { };
28
+ nativeCompleted = new Set();
29
+ nativeTerminated = new Set();
30
+ concludeNative = new Set();
31
+ beginConclude() { this.localTurnEnded = true; this.concludeNative = new Set([...this.nativeBridgeIds].filter(id => !this.nativeTerminated.has(id))); }
32
+ active;
33
+ begin(signal) { this.active = signal; }
34
+ end() { this.active = undefined; }
35
+ changed = () => { };
36
+ record = () => { };
37
+ onAbandon = () => { };
38
+ nativeBridgeIds = new Set();
39
+ constructor(options, readImage) {
40
+ this.options = options;
41
+ this.readImage = readImage;
42
+ }
43
+ enable(record) { if (!this.table.size)
44
+ return; this.enabled = true; this.record = record; record({ kind: 'session', severity: 'info', code: 'TOOLBRIDGE_SELECTED', summary: TOOLBRIDGE_NOTICE, payload: { toolCount: this.table.size, toolTimeoutSec: TOOLBRIDGE_TIMEOUT_SECONDS, timeoutSource: 'owner-2026-10-26', snapshot: 'native-session-open', excluded: 'official-file-command-subagent-team-run_code' } }); }
45
+ get tools() { return [...this.table.values()].map(t => t.wire); }
46
+ isBridge(name) { return typeof name === 'string' && this.table.has(name.replace(/^mcp__employee__/, '')); }
47
+ async call(alias, args, signal) {
48
+ const tool = this.table.get(alias);
49
+ if (!this.enabled || this.ended || this.localTurnEnded || !this.active || this.active.aborted || !tool || signal.aborted)
50
+ return errorResult('工具桥当前不可用,未执行。');
51
+ const id = ('awb-' + randomUUID());
52
+ const abort = () => this.abandon('MCP_REQUEST_CANCELLED');
53
+ const pending = { block: { type: 'tool-call', id, name: tool.schema.name, arguments: JSON.stringify(args) }, alias, reply: Promise.withResolvers(), exported: false, settled: false, off: () => signal.removeEventListener('abort', abort) };
54
+ this.calls.set(id, pending);
55
+ signal.addEventListener('abort', abort, { once: true });
56
+ this.record({ kind: 'tool', severity: 'info', code: 'TOOLBRIDGE_CALL_PARKED', summary: '主机工具调用交给DSH执行与审批,原prompt继续等待。', payload: { callId: id, tool: tool.schema.name } });
57
+ this.changed();
58
+ if (signal.aborted)
59
+ abort();
60
+ return pending.reply.promise;
61
+ }
62
+ get pending() { return [...this.calls.values()].filter(p => !p.settled && !p.exported); }
63
+ get unsettled() { return [...this.calls.values()].some(p => !p.settled); }
64
+ get waiting() { return [...this.calls.values()].some(p => p.exported && !p.settled); }
65
+ async results(options) {
66
+ const batch = [];
67
+ for (const p of this.calls.values()) {
68
+ if (!p.exported || p.settled)
69
+ continue;
70
+ const matches = options.messages.filter((m) => m.role === 'tool' && m.toolCallId === p.block.id);
71
+ if (matches.length !== 1)
72
+ throw new NeedError('TOOLBRIDGE_RESULT_MISSING', '工具桥没有收到唯一匹配的DSH结果,本轮已停止;不会重跑。', '原toolCallId对应的官方tool结果');
73
+ batch.push({ pending: p, result: await this.mapResult(matches[0], options.signal) });
74
+ }
75
+ // Map the whole DSH batch before releasing any native Promise. A normal MCP
76
+ // isError must not race an unfinished image mapping from this same batch.
77
+ if (this.ended) {
78
+ for (const { pending: p } of batch)
79
+ this.record({ kind: 'tool', severity: 'warning', code: 'DSH_BRIDGE_LATE_RESULT_DROPPED', summary: TOOLBRIDGE_ENDED, payload: { callId: p.block.id } });
80
+ return;
81
+ }
82
+ if (this.localTurnEnded)
83
+ for (const p of this.calls.values())
84
+ if (!p.exported && !p.settled) {
85
+ batch.push({ pending: p, result: errorResult('DSH工具已结束本轮,这个尚未派发的调用没有执行。') });
86
+ this.record({ kind: 'tool', severity: 'info', code: 'TOOLBRIDGE_CONCLUDED_UNDISPATCHED', summary: 'DSH工具结束了本轮,尚未交给DSH的调用没有执行。', payload: { callId: p.block.id } });
87
+ }
88
+ const ready = batch.filter(row => !row.pending.settled);
89
+ for (const { pending: p } of ready) {
90
+ p.settled = true;
91
+ p.off();
92
+ }
93
+ for (const { pending: p, result } of ready)
94
+ p.reply.resolve(result);
95
+ this.concludeAck();
96
+ }
97
+ async mapResult(message, signal) {
98
+ const content = [];
99
+ for (const block of message.content) {
100
+ if (block.type === 'text') {
101
+ content.push({ type: 'text', text: block.text });
102
+ continue;
103
+ }
104
+ if (block.type === 'image') {
105
+ if (block.offloaded)
106
+ return errorResult('这张图片已按DSH策略移出上下文,工具桥未将占位内容冒充图片结果。');
107
+ if (!this.readImage)
108
+ throw new NeedError('ATTACHMENT_REQUIRED', '我需要官方附件组件读取工具返回的图片。', 'attachments.readImage');
109
+ const image = await this.readImage(block.attachment, signal);
110
+ signal?.throwIfAborted();
111
+ if (image.ref.attachmentId !== block.attachment.attachmentId || image.ref.bytes !== image.data.byteLength || !['image/png', 'image/jpeg', 'image/webp', 'image/gif'].includes(image.ref.mediaType))
112
+ throw new NeedError('ATTACHMENT_INVALID', '工具图片的附件内容无法核对,本轮没有交回。', '官方核验的图片字节与媒体类型');
113
+ content.push({ type: 'image', mimeType: image.ref.mediaType, data: Buffer.from(image.data).toString('base64') });
114
+ continue;
115
+ }
116
+ return errorResult('工具返回了本版工具桥尚不支持的内容类型:' + block.type + ';没有静默丢弃或重新执行。');
117
+ }
118
+ return { content, isError: !!message.isError };
119
+ }
120
+ concludeAck() {
121
+ if (this.localTurnEnded && !this.unsettled && !this.concludeAcknowledged && this.concludeNative.size && [...this.concludeNative].every(id => this.nativeCompleted.has(id))) {
122
+ this.concludeAcknowledged = true;
123
+ this.record({ kind: 'tool', severity: 'info', code: 'TOOLBRIDGE_CONCLUDED_RESULT_DELIVERED', summary: 'DSH工具结束了本轮,原生工具已确认收到结果;结束原prompt,不再派发新工具。' });
124
+ this.onConcludeAcknowledged();
125
+ }
126
+ }
127
+ observe(update) {
128
+ if (!this.enabled || this.ended)
129
+ return;
130
+ if (update.sessionUpdate !== 'tool_call' && update.sessionUpdate !== 'tool_call_update')
131
+ return;
132
+ const u = update;
133
+ const raw = u.rawInput;
134
+ const name = u.name ?? u._meta?.claudeCode?.toolName;
135
+ if (this.isBridge(name) || raw?.server === 'employee' && this.isBridge(raw.tool) || this.isBridge(u.title))
136
+ this.nativeBridgeIds.add(u.toolCallId);
137
+ if ((u.status === 'completed' || u.status === 'failed') && this.nativeBridgeIds.has(u.toolCallId))
138
+ this.nativeTerminated.add(u.toolCallId);
139
+ if (u.status === 'failed' && this.nativeBridgeIds.has(u.toolCallId) && !this.unsettled && u.rawOutput?.result?.isError === true && Array.isArray(u.rawOutput.result.content)) {
140
+ this.nativeCompleted.add(u.toolCallId);
141
+ this.concludeAck();
142
+ }
143
+ if (u.status === 'completed' && this.nativeBridgeIds.has(u.toolCallId)) {
144
+ this.nativeCompleted.add(u.toolCallId);
145
+ this.concludeAck();
146
+ }
147
+ if (u.status === 'failed' && this.nativeBridgeIds.has(u.toolCallId) && (!this.localTurnEnded || this.concludeNative.has(u.toolCallId)) && this.unsettled)
148
+ this.abandon('HOST_TOOL_FAILED');
149
+ }
150
+ /** A failed native bridge cancels the entire parked round; never guess between parallel identical calls. */
151
+ abandon(reason) {
152
+ if (this.ended)
153
+ return;
154
+ this.notice = TOOLBRIDGE_ENDED;
155
+ this.record({ kind: 'tool', severity: 'warning', code: 'DSH_BRIDGE_CALL_ABANDONED_BY_HOST', summary: this.notice, payload: { reason, toolTimeoutSec: TOOLBRIDGE_TIMEOUT_SECONDS, fixedTimeoutReasonAvailable: false, scope: 'entire-parked-round', replayed: false } });
156
+ this.close();
157
+ this.onAbandon(this.notice);
158
+ }
159
+ close() {
160
+ if (this.ended)
161
+ return;
162
+ this.ended = true;
163
+ this.controller.abort();
164
+ for (const p of this.calls.values())
165
+ if (!p.settled) {
166
+ p.settled = true;
167
+ p.off();
168
+ p.reply.resolve(errorResult('本轮工具等待已结束;结果不重放。'));
169
+ }
170
+ this.changed();
171
+ }
172
+ }
173
+ /** Codex executes nc in its existing employee environment, never on the host. */
174
+ export async function openToolBridgeMcp(bridge, maxBytes) {
175
+ const root = await mkdtemp('/private/tmp/aw-tb-'), socketPath = root + '/mcp';
176
+ const clients = new Set();
177
+ let closed = false;
178
+ const listener = createServer(socket => {
179
+ if (closed) {
180
+ socket.destroy();
181
+ return;
182
+ }
183
+ const server = new Server({ name: 'ai-workbench-toolbridge', version: '0.9.0' }, { capabilities: { tools: {} } });
184
+ clients.add(server);
185
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: bridge.tools }));
186
+ server.setRequestHandler(CallToolRequestSchema, (r, extra) => bridge.call(r.params.name, r.params.arguments ?? {}, extra.signal));
187
+ server.onclose = () => clients.delete(server);
188
+ server.onerror = () => bridge.abandon('MCP_TRANSPORT_FAILED');
189
+ void server.connect(new EmployeeSocketTransport(socket, maxBytes)).catch(() => { socket.destroy(); bridge.abandon('MCP_CONNECT_FAILED'); });
190
+ });
191
+ const close = async () => { if (closed)
192
+ return; closed = true; bridge.close(); await Promise.allSettled([...clients].map(c => c.close())); if (listener.listening)
193
+ await new Promise(r => listener.close(() => r())); await rm(root, { recursive: true }); };
194
+ try {
195
+ const stat = await lstat(root);
196
+ if (stat.uid !== process.getuid?.() || (stat.mode & 0o077) !== 0)
197
+ throw Error('TOOLBRIDGE_PRIVATE_DIRECTORY_REQUIRED');
198
+ await new Promise((yes, no) => { listener.once('error', no); listener.listen(socketPath, () => { listener.removeListener('error', no); yes(); }); });
199
+ return { mcpServers: [{ name: 'employee', command: '/usr/bin/nc', args: ['-U', socketPath], env: [] }], close };
200
+ }
201
+ catch (error) {
202
+ await close();
203
+ throw error;
204
+ }
205
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-workbench/remote-agent",
3
- "version": "0.8.29",
3
+ "version": "0.9.0-toolbridge.3",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -36,7 +36,7 @@
36
36
  "zod": "4.6.7"
37
37
  },
38
38
  "devDependencies": {
39
- "@ai-workbench/connect": "file:test/fixtures/ai-workbench-connect-0.7.29.tgz",
39
+ "@ai-workbench/connect": "file:test/fixtures/ai-workbench-connect-0.7.37.tgz",
40
40
  "@ai-workbench/network": "file:test/fixtures/ai-workbench-network-0.4.9.tgz",
41
41
  "@deepseek-ai/cordis": "4.0.4",
42
42
  "@deepseek-ai/cordis-plugin-group": "1.0.4",
@@ -67,7 +67,7 @@
67
67
  "@deepseek-ai/dsh-storage-domain": "0.2.0-rc.2"
68
68
  },
69
69
  "peerDependencies": {
70
- "@ai-workbench/connect": "~0.7.29",
70
+ "@ai-workbench/connect": "~0.7.37",
71
71
  "@ai-workbench/network": "~0.4.9",
72
72
  "@deepseek-ai/cordis": "4.0.4",
73
73
  "@deepseek-ai/dsh-agent": "0.2.0-rc.2",
@@ -102,10 +102,11 @@
102
102
  "optional": true
103
103
  }
104
104
  },
105
- "description": "通过远程主机运行Claude与Codex会话。",
105
+ "description": "AI 写作工作台(含工具桥):远程Claude与Codex,本机目录工具由DSH执行与审批。",
106
106
  "publishConfig": {
107
107
  "access": "public",
108
- "registry": "https://registry.npmjs.org/"
108
+ "registry": "https://registry.npmjs.org/",
109
+ "tag": "toolbridge"
109
110
  },
110
111
  "keywords": [
111
112
  "dsh",