@ai-workbench/remote-agent 0.8.28 → 0.8.29

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,10 @@
1
- # AI Workbench 远程助手 · @ai-workbench/remote-agent@0.8.28 · 插件 → 添加插件
1
+ # AI Workbench 远程助手 · @ai-workbench/remote-agent@0.8.29 · 插件 → 添加插件
2
2
 
3
3
  通过官方 DSH 插件入口使用 Claude(远程主机)与 Codex(远程主机)。登录和工作区来源由“远程主机连接”组件提供;员工在原生工作区和模型菜单中选择文件夹与型号。
4
4
 
5
5
  文件和命令在所选的本机或主机目录执行,订阅凭据留在主机。插件不改写 DSH 的默认模型,不为员工创建同名主机目录,已有主机会话只提取一次原有主机来源并保存。
6
6
 
7
- 需要远程主机连接组件 0.7.29 或更新的 0.7.x,以及联网助手 0.4.9。主机来源保存于官方 storage.domain 的会话元数据表,每轮仅由连接组件检查一次主机目录,不检查本机占位目录。
7
+ 需要远程主机连接组件 0.7.29 或更新的 0.7.x、联网助手 0.4.9,以及支持公开会话归属接口的主机组件 S0.4.26。主机来源保存于官方 storage.domain 的会话元数据表,每轮仅由连接组件检查一次主机目录,不检查本机占位目录。
8
8
 
9
9
  Claude 本机 Bash 会单独消费员工桥代理的控制诊断,不把它交给模型当作命令输出;真实传输中断仍提示结果未知。命令自身的标准输出、标准错误与退出码照常返回。
10
10
 
@@ -16,7 +16,7 @@ Claude 本机 Bash 会单独消费员工桥代理的控制诊断,不把它交
16
16
 
17
17
  同一 DSH 会话切换 Claude/Codex 时,切回会接本品牌最近的远端会话,并补充它尚未见过的可见对话;首次使用某品牌则带入全部可见历史。工具内部状态、推理和技术详情不转交。历史超出既有 `acpMaxMessageBytes` 容量时,只带能容纳的最近完整轮,并在技术详情说明省略数量;当前输入保持完整,超限仍明确失败。没有新增设置、重试或时限。
18
18
 
19
- 断线会结束本轮;已派发的操作可能已部分执行,请先检查文件。恢复连接后可在原会话继续。旧进程清理和技术详情记录尽力而为,不阻止发起下一轮;如果上一轮仍占用原生线程,续接会明确失败并说明本轮输入未发送,不新建替代线程。自动定位、清理同一会话/线程的已断开旧进程仍需 S 提供公开能力,当前不会自动杀旧进程或冒称已恢复。原生本机会话恢复的兼容性及实际设备结果见本轮交付证据,未验证项不作为已通过。
19
+ 断线会结束本轮;已派发的操作可能已部分执行,请先检查文件。恢复连接后可在原会话继续。旧进程清理和技术详情记录尽力而为,不阻止发起下一轮。续接明确遇到原线程被占用时,使用 S0.4.26 的会话归属和原子结束接口:只有唯一匹配的旧进程确实已断开、主机明确确认退出后,才接回同一线程一次;不会新建替代线程、重发用户输入或抢占活跃会话。旧版无归属进程不能安全清理,需等待主机按现有策略回收;失败或释放结果未知则说明本轮输入未发送。原生本机会话恢复的兼容性及实际设备结果见本轮交付证据,未验证项不作为已通过。
20
20
 
21
21
  DSH 启动和打开模型菜单时读取一次主机型号列表。发送时不再查询目录或预核所选型号;所选型号不可用时,请在模型菜单重新选择。
22
22
 
package/dist/acp.d.ts CHANGED
@@ -6,6 +6,7 @@ import type { BrokerTransport } from './broker.ts';
6
6
  import { type SanitizedObservation } from './conversation-details.ts';
7
7
  import { type ModelConfigurationSnapshot } from './model-configuration.ts';
8
8
  import type { EmployeeRouting } from './employee-relay.ts';
9
+ import type { SessionOwnership } from './broker-ownership.ts';
9
10
  export type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable';
10
11
  export interface AcpHooks {
11
12
  closeDiagnostics?: CloseDiagnostics;
@@ -25,6 +26,7 @@ export interface AcpHooks {
25
26
  onEmployeeNotice?: (notice: string) => void;
26
27
  onDetail?: (detail: Readonly<SanitizedObservation>) => void;
27
28
  broker?: BrokerTransport;
29
+ sessionOwnership?: SessionOwnership;
28
30
  employeeExecution?: (params: unknown, signal?: AbortSignal) => Promise<Record<string, unknown>>;
29
31
  }
30
32
  export interface EmployeeExecutionMeta {
package/dist/acp.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { employeeExecutionInfo, employeeMcpSummary, employeeHttpNotice } from "./employee-info.js";
2
2
  import { employeeAdapterCodes, employeeOpenCauseCodes } from "./employee-reasons.js";
3
- import { CloseDiagnostics, withFirstFailureEvidence } from "./close-diagnostics.js";
3
+ import { CloseDiagnostics, closeCode, withFirstFailureEvidence } from "./close-diagnostics.js";
4
4
  import * as acp from '@agentclientprotocol/sdk/unstable';
5
5
  import { NeedError } from "./remote.js";
6
6
  import { employeeFailure } from "./presentation.js";
@@ -505,17 +505,37 @@ export class AcpSession {
505
505
  this.employeeRouting = routing;
506
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 }] } } : {}) };
507
507
  const configRevision = this.resetConfiguration();
508
+ const restore = async (operation) => {
509
+ try {
510
+ return await operation();
511
+ }
512
+ catch (error) {
513
+ if (!resumeId || acpNeed(error).code !== 'REMOTE_SESSION_BUSY' || !this.hooks.sessionOwnership)
514
+ throw error;
515
+ this.detail({ kind: 'session', severity: 'info', code: 'REMOTE_SESSION_BUSY', summary: '原远端会话被占用,正在核对同一会话与线程的已断开旧进程;尚未发送用户输入。' });
516
+ try {
517
+ await this.hooks.sessionOwnership.stopDetached(resumeId, signal);
518
+ signal?.throwIfAborted();
519
+ }
520
+ catch (failure) {
521
+ this.detail({ kind: 'session', severity: 'warning', code: 'REMOTE_SESSION_RELEASE_UNCONFIRMED', summary: '旧会话释放未获确认,本轮没有再次接续或发送用户输入。', payload: { reason: failure instanceof NeedError ? failure.requires : closeCode(failure, 'BROKER_CONTROL_FAILED') } });
522
+ throw failure;
523
+ }
524
+ this.detail({ kind: 'session', severity: 'info', code: 'REMOTE_SESSION_RELEASE_CONFIRMED', summary: '主机已确认原会话的已断开旧进程退出,继续接回同一远端会话一次。' });
525
+ return operation(); // Exactly one continuation, only after the explicit release receipt.
526
+ }
527
+ };
508
528
  if (resumeId) {
509
529
  this.sessionId = resumeId;
510
530
  if (this.negotiated.sessionCapabilities?.resume) {
511
- const result = await this.stage('acp-session-resume', () => this.connection.agent.request(acp.methods.agent.session.resume, { ...params, sessionId: resumeId }, { cancellationSignal: signal }));
531
+ const result = await restore(() => this.stage('acp-session-resume', () => this.connection.agent.request(acp.methods.agent.session.resume, { ...params, sessionId: resumeId }, { cancellationSignal: signal })));
512
532
  this.lifecycleConfiguration(result, configRevision);
513
533
  this.method = acp.methods.agent.session.resume;
514
534
  }
515
535
  else {
516
536
  if (!this.negotiated.loadSession)
517
537
  throw new NeedError('RESUME_UNSUPPORTED', '远端未声明会话恢复或加载能力,不能恢复原会话', '支持 session/resume 或 session/load 的 agent');
518
- const result = await this.stage('acp-session-load', () => this.connection.agent.request(acp.methods.agent.session.load, { ...params, sessionId: resumeId }, { cancellationSignal: signal }));
538
+ const result = await restore(() => this.stage('acp-session-load', () => this.connection.agent.request(acp.methods.agent.session.load, { ...params, sessionId: resumeId }, { cancellationSignal: signal })));
519
539
  this.lifecycleConfiguration(result, configRevision);
520
540
  this.method = acp.methods.agent.session.load;
521
541
  }
@@ -528,6 +548,7 @@ export class AcpSession {
528
548
  this.lifecycleConfiguration(result, configRevision);
529
549
  this.method = acp.methods.agent.session.new;
530
550
  }
551
+ await this.hooks.sessionOwnership?.bind(this.sessionId, signal);
531
552
  this.ready = true;
532
553
  this.cwd = cwd;
533
554
  this.systemPrompt = systemPrompt ?? '';
@@ -0,0 +1,10 @@
1
+ import { type BrokerIo } from './broker.ts';
2
+ export interface SessionOwnership {
3
+ bind(remoteSessionId: string, signal?: AbortSignal): Promise<void>;
4
+ stopDetached(remoteSessionId: string, signal?: AbortSignal): Promise<void>;
5
+ }
6
+ /** S0426 public control only: no process/lock scans or client ownership journal. */
7
+ export declare function brokerOwnership(io: BrokerIo, command: (args: string[]) => string, started: {
8
+ id: string;
9
+ instanceId: string;
10
+ }, receipt: unknown, dshSessionId: string, provider: 'codex' | 'claude'): SessionOwnership;
@@ -0,0 +1,59 @@
1
+ import { controlJson } from "./broker.js";
2
+ import { NeedError } from "./remote.js";
3
+ import { closeCode } from "./close-diagnostics.js";
4
+ const record = (v) => v !== null && typeof v === 'object' && !Array.isArray(v) ? v : undefined;
5
+ const line = (v) => typeof v === 'string' && v.length > 0 && !/[\r\n\0]/.test(v);
6
+ const same = (value, expected) => { const row = record(value); return row?.dshSessionId === expected.dshSessionId && row.provider === expected.provider && row.remoteSessionId === expected.remoteSessionId; };
7
+ const busy = (reason) => new NeedError('REMOTE_SESSION_BUSY', '原会话仍被上一轮占用,本轮输入未发送。', '已断开旧会话的安全释放尚未确认;' + reason + (reason === 'OWNERSHIP_NOT_FOUND_OR_LEGACY' ? ';没有可证明归属的旧会话,不结束任何旧进程,请等待主机按现有策略回收。' : ''));
8
+ /** S0426 public control only: no process/lock scans or client ownership journal. */
9
+ export function brokerOwnership(io, command, started, receipt, dshSessionId, provider) {
10
+ let owner = { dshSessionId, provider, remoteSessionId: null };
11
+ const initial = record(receipt);
12
+ if (!line(dshSessionId) || initial?.ownershipProtocolVersion !== 1 || !same(initial.ownership, owner))
13
+ throw new NeedError('BROKER_OWNERSHIP_UNAVAILABLE', '主机会话缺少归属能力,本轮输入未发送;请联系管理员更新主机组件。', 'S >=0.4.26 / ownershipProtocolVersion:1');
14
+ const flags = (value) => ['--dsh-session-id', value.dshSessionId, '--provider', value.provider, '--remote-session-id', value.remoteSessionId];
15
+ const confirmed = (value, id, instanceId, expected) => value?.id === id && value.instanceId === instanceId && value.ownershipProtocolVersion === 1 && same(value.ownership, expected);
16
+ return {
17
+ async bind(remoteSessionId, signal) {
18
+ signal?.throwIfAborted();
19
+ if (!line(remoteSessionId))
20
+ throw new NeedError('BROKER_OWNERSHIP_INVALID', '远端会话编号无法绑定,本轮输入未发送。', 'S0426单行原生会话编号');
21
+ const next = { ...owner, remoteSessionId };
22
+ const value = record(controlJson(await io.run(command(['bind', started.id, '--if-instance', started.instanceId, ...flags(next), ...(owner.remoteSessionId === null ? [] : ['--if-remote-session-id', owner.remoteSessionId])]), signal)));
23
+ signal?.throwIfAborted();
24
+ if (value?.type !== 'bound' || !confirmed(value, started.id, started.instanceId, next))
25
+ throw new NeedError('BROKER_OWNERSHIP_UNCONFIRMED', '主机未确认会话归属,本轮输入未发送。', 'S0426精确归属绑定回执');
26
+ owner = next;
27
+ },
28
+ async stopDetached(remoteSessionId, signal) {
29
+ try {
30
+ signal?.throwIfAborted();
31
+ if (!line(remoteSessionId))
32
+ throw busy('OWNERSHIP_INVALID');
33
+ const wanted = { ...owner, remoteSessionId };
34
+ const entries = controlJson(await io.run(command(['list']), signal));
35
+ signal?.throwIfAborted();
36
+ if (!Array.isArray(entries) || entries.some(row => !record(row)))
37
+ throw busy('OWNERSHIP_LIST_INVALID');
38
+ const matches = entries.map(record).filter(row => same(row?.ownership, wanted));
39
+ if (matches.length !== 1)
40
+ throw busy(matches.length ? 'OWNERSHIP_AMBIGUOUS' : 'OWNERSHIP_NOT_FOUND_OR_LEGACY');
41
+ const row = matches[0];
42
+ if (row.id === started.id || !line(row.id) || !line(row.instanceId) || row.ownershipProtocolVersion !== 1)
43
+ throw busy('OWNERSHIP_UNAVAILABLE');
44
+ const lifecycle = record(row.lifecycle);
45
+ if (!['running', 'exited'].includes(row.state) || lifecycle?.attached !== false || !line(lifecycle.detachedAt))
46
+ throw busy('SESSION_NOT_DETACHED');
47
+ const value = record(controlJson(await io.run(command(['stop-detached', row.id, '--if-instance', row.instanceId, ...flags(wanted)]), signal)));
48
+ signal?.throwIfAborted();
49
+ if (value?.type !== 'killed' || !confirmed(value, row.id, row.instanceId, wanted) || value.agentExited !== true || value.processGroupExited !== true)
50
+ throw busy('STOP_UNCONFIRMED');
51
+ }
52
+ catch (error) {
53
+ if (error instanceof NeedError && error.code === 'REMOTE_SESSION_BUSY')
54
+ throw error;
55
+ throw busy(closeCode(error, 'BROKER_CONTROL_FAILED'));
56
+ }
57
+ },
58
+ };
59
+ }
@@ -12,6 +12,7 @@ import { BrokerTransport, brokerCommand, brokerId, sshIo, controlJson } from "./
12
12
  import { CODEX_AGENT } from "./agents.js";
13
13
  import { NeedError } from "./remote.js";
14
14
  import { identity } from "./provider.js";
15
+ import { brokerOwnership } from "./broker-ownership.js";
15
16
  /** Each invocation owns a new agent. Context resumes by native session ID, never by reattaching a broker. */
16
17
  export function brokerFactory(_get, _ask, configuration, provided) {
17
18
  return async (target, project, request) => {
@@ -25,7 +26,7 @@ export function brokerFactory(_get, _ask, configuration, provided) {
25
26
  const command = (args) => brokerCommand(target, args);
26
27
  const diagnostics = request.closeDiagnostics ?? new CloseDiagnostics(detail => request.detailChannel?.record(detail));
27
28
  const id = brokerId(request.sessionId + '/' + randomUUID(), agent.id);
28
- const startCommand = command(['start', id, '--cwd', project.path, '--settings', target.session.settingsPath, '--', agent.command]);
29
+ const startCommand = command(['start', id, '--cwd', project.path, '--settings', target.session.settingsPath, '--dsh-session-id', request.sessionId, '--provider', agent.loginProvider, '--', agent.command]);
29
30
  // environments.toml overrides CODEX_EXEC_SERVER_URL. Refuse that host default rather
30
31
  // than allowing a local-workspace resume to select a host executor.
31
32
  const employeeStart = request.employeeScope && agent.loginProvider === 'codex';
@@ -47,7 +48,9 @@ export function brokerFactory(_get, _ask, configuration, provided) {
47
48
  };
48
49
  const aborted = () => channel.fail(new NeedError('ABORTED', '本轮已取消。', '确认主机状态'), { layer: 'broker', initiator: 'broker.open-signal', reason: 'ABORTED', action: 'abort' });
49
50
  request.signal?.addEventListener('abort', aborted, { once: true });
51
+ let sessionOwnership;
50
52
  try {
53
+ sessionOwnership = brokerOwnership(io, command, { id, instanceId: started.instanceId }, started, request.sessionId, agent.loginProvider);
51
54
  request.signal?.throwIfAborted();
52
55
  await channel.connect();
53
56
  }
@@ -58,6 +61,6 @@ export function brokerFactory(_get, _ask, configuration, provided) {
58
61
  finally {
59
62
  request.signal?.removeEventListener('abort', aborted);
60
63
  }
61
- return { identity: identity(target), cwd: project.path, closeDiagnostics: diagnostics, broker: channel, acpTransport: channel, dispose, detach: dispose };
64
+ return { identity: identity(target), cwd: project.path, closeDiagnostics: diagnostics, broker: channel, sessionOwnership, acpTransport: channel, dispose, detach: dispose };
62
65
  };
63
66
  }
package/dist/index.js CHANGED
@@ -363,7 +363,7 @@ export class CodexRemoteAdapter extends LlmAdapter {
363
363
  let failure;
364
364
  const push = (chunks) => { queue.push(...chunks); wake?.(); wake = undefined; };
365
365
  let promptPermit;
366
- const hooks = { closeDiagnostics: runtime.closeDiagnostics, broker: runtime.broker, employeeExecution: runtime.employeeExecution, ...this.permissions ? { promptGuard: () => {
366
+ const hooks = { closeDiagnostics: runtime.closeDiagnostics, broker: runtime.broker, sessionOwnership: runtime.sessionOwnership, employeeExecution: runtime.employeeExecution, ...this.permissions ? { promptGuard: () => {
367
367
  if (!promptPermit)
368
368
  throw new NeedError('PERMISSION_POLICY_CHANGED', '当前权限没有保持确认,本次没有继续派发。', '当前权限代次和同值回执');
369
369
  return promptPermit;
@@ -42,6 +42,9 @@ export function employeeFailure(code, brand = 'codex', promptDispatched = true)
42
42
  POLICY_DENIED: '这个账号暂时不能使用,请联系公司账号管理员。',
43
43
  SESSION_CONTEXT_MISMATCH: '远程主机连接已改变,请重新开始本次检查。',
44
44
  REMOTE_SESSION_BUSY: '原会话仍被主机上的上一轮占用,' + (promptDispatched ? '本轮请求未能完成' : '本轮输入未发送') + ';请稍后在原会话重新发送。若持续占用,请联系管理员检查上一轮主机会话。',
45
+ BROKER_OWNERSHIP_UNAVAILABLE: '主机会话组件缺少安全续接能力,本轮输入未发送;请联系管理员更新主机组件。',
46
+ BROKER_OWNERSHIP_UNCONFIRMED: '主机尚未确认这条会话的归属,本轮输入未发送;请检查远程主机连接后在原会话重试。',
47
+ BROKER_OWNERSHIP_INVALID: '远端会话编号无法确认,本轮输入未发送;请联系管理员查看技术详情。',
45
48
  ABORTED: promptDispatched ? '本轮已停止。操作可能已部分执行,请先确认文件;恢复连接后可在原会话继续。' : '本轮已停止,本轮输入未发送;恢复连接后可在原会话重新发送。',
46
49
  CONFIRMATION_REQUIRED: '请先选择是否打开授权窗口。',
47
50
  CONTEXT_UNSUPPORTED: '远程 Codex 暂不支持这类会话内容,请联系管理员。',
@@ -14,6 +14,7 @@ import type { SourceAdmissionStage } from './workspace-source.ts';
14
14
  import type { LocalWorkspaceContext } from '@ai-workbench/connect/api';
15
15
  import type { EmployeeRouting } from './employee-relay.ts';
16
16
  import type { HostWorkspacePresentation } from './input.ts';
17
+ import type { SessionOwnership } from './broker-ownership.ts';
17
18
  export interface EmployeeExecution {
18
19
  hostCwd: string;
19
20
  routing: EmployeeRouting;
@@ -37,6 +38,7 @@ export interface Runtime {
37
38
  readable: ReadableStream<Uint8Array>;
38
39
  } | AgentApp | ExtendedAgentApp;
39
40
  broker?: BrokerTransport;
41
+ sessionOwnership?: SessionOwnership;
40
42
  loginRequired?: () => Promise<void>;
41
43
  dispose?: (cause?: CloseCause) => Promise<void>;
42
44
  binding?: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-workbench/remote-agent",
3
- "version": "0.8.28",
3
+ "version": "0.8.29",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",