@ai-workbench/remote-agent 0.8.24 → 0.8.25

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,12 +1,14 @@
1
- # AI Workbench 远程助手 · @ai-workbench/remote-agent@0.8.24 · 插件 → 添加插件
1
+ # AI Workbench 远程助手 · @ai-workbench/remote-agent@0.8.25 · 插件 → 添加插件
2
2
 
3
3
  通过官方 DSH 插件入口使用 Claude(远程主机)与 Codex(远程主机)。登录和工作区来源由“远程主机连接”组件提供;员工在原生工作区和模型菜单中选择文件夹与型号。
4
4
 
5
- 文件和命令在所选的本机或主机目录执行,订阅凭据留在主机。插件不改写 DSH 的默认模型,不为员工创建同名主机目录,不自动迁移旧会话的来源。
5
+ 文件和命令在所选的本机或主机目录执行,订阅凭据留在主机。插件不改写 DSH 的默认模型,不为员工创建同名主机目录,已有主机会话只提取一次原有主机来源并保存。
6
+
7
+ 需要远程主机连接组件 0.7.29 或更新的 0.7.x,以及联网助手 0.4.9。主机来源保存于官方 storage.domain 的会话元数据表,每轮仅由连接组件检查一次主机目录,不检查本机占位目录。
6
8
 
7
9
  ## 会话行为
8
10
 
9
- 每轮启动一个新助手进程,按原生会话编号恢复上下文;不接回旧进程或重放上一轮输入。改变文件来源后请新建会话。
11
+ 每轮启动一个新助手进程,按原生会话编号恢复上下文;不接回旧进程或重放上一轮输入。主机会话始终使用创建时保存的主机和目录;工作区之后的选择只影响新会话。
10
12
 
11
13
  断线会结束本轮;操作可能已部分执行,请先检查文件。恢复连接后可在原会话继续。旧进程清理和技术详情记录尽力而为,不阻止下一轮。原生本机会话恢复的兼容性及实际设备结果见本轮交付证据,未验证项不作为已通过。
12
14
 
@@ -17,7 +19,7 @@ DSH 启动和打开模型菜单时读取一次主机型号列表。发送时不
17
19
  - 员工选本机文件夹,就在员工电脑上执行;通道失败不会改在主机运行。
18
20
  - 订阅凭据留在主机,不放进命令参数、日志或技术详情。
19
21
  - 跟随 DSH 当前权限:完全权限不问、工作区内修改先问、仅可查看只读。需要审批时使用官方卡片。
20
- - 一条会话对应一个工作区来源;换来源请新建会话。
22
+ - 一条会话对应一个文件来源;主机来源不会随工作区后续选择改变,本机来源继续沿用原生绑定。
21
23
 
22
24
  Codex 员工端只使用随包提供的原生组件,通道等待本次组件的启动标记后才完成开通,不逐通道执行摘要或版本校验。当前员工端支持范围以发布验证记录为准,不让员工手填执行器路径。
23
25
 
@@ -40,7 +42,7 @@ Codex 员工端只使用随包提供的原生组件,通道等待本次组件
40
42
 
41
43
  技术详情只写官方 profile 下本地私有日志,并由既有技术详情界面展开显示。写入失败不覆盖原始错误、不等待远端投递回执。每个运行环境只记录首次关闭原因;首先观察到的原因不自动等同跨层根因。
42
44
 
43
- 工程验证、独立审查、固定包摘要及实际设备验收分别记录在 `docs/evidence/2026-10-24-X-executor-readiness/` 和项目 X 回执中。插件不修改 DSH 核心,不添加启动器或自定义 profile。
45
+ 工程验证和独立审查见 `docs/evidence/2026-10-25-X-host-source-v2/`;AUTH profile 夹具补齐与最终 main 全套复验见其中的 `auth-profile-return.md`。固定包摘要及真实设备验收状态以项目 X 回执为准。插件不修改 DSH 核心,不添加启动器或自定义 profile。
44
46
 
45
47
  远程助手参数页只显示官方 ConfigForms 当前值,不提供保存、放弃或恢复继承值;修改请使用 DSH 官方 Config“打开配置文件”入口。
46
48
 
@@ -20,5 +20,5 @@ export type SessionProjectResolution = HostSessionProjectTarget;
20
20
  export interface NativeWorkspaceApi {
21
21
  getContractInfo(): ContractInfo;
22
22
  ensureReady(provider: LoginProvider): Promise<ConnectResult<ConnectTarget>>;
23
- resolveSessionProject(sessionId: string, use: WorkspaceUse, location?: HostWorkspaceResolveContext): Promise<ConnectResult<SessionProjectResolution>>;
23
+ resolveSessionProject(sessionId: string, use: WorkspaceUse, location: HostWorkspaceResolveContext): Promise<ConnectResult<SessionProjectResolution>>;
24
24
  }
@@ -1,15 +1,10 @@
1
- import type { ConnectApi, ConnectTarget, ConnectionState, RuntimeRequest, Runtime } from './provider.ts';
2
- import type { NativeWorkspaceApi, HostSessionProjectTarget, LoginProvider } from './connect-contract.ts';
3
- export interface HostWorkspaceGuard {
4
- readonly signal: AbortSignal;
5
- readonly revocation: AbortSignal;
6
- resolve(): Promise<HostSessionProjectTarget>;
7
- assertCurrent(): void;
8
- observeRuntime(runtime: Runtime): void;
9
- dispose(): void;
10
- }
11
- /** Current official membership and explicit host selection; never infer source from a pathname. */
12
- export declare function hostWorkspaceGuard(get: () => ConnectApi | undefined, api: ConnectApi & NativeWorkspaceApi, connection: ConnectionState, target: ConnectTarget, request: RuntimeRequest, workspace: ((request: RuntimeRequest) => Promise<{
13
- id: string;
14
- path: string;
15
- }>) | undefined, provider: LoginProvider): Promise<HostWorkspaceGuard | undefined>;
1
+ import type { HostWorkspaceResolveContext, LoginProvider, NativeWorkspaceApi } from './connect-contract.ts';
2
+ import type { ConnectApi, RuntimeRequest } from './provider.ts';
3
+ export type SessionSource = Readonly<HostWorkspaceResolveContext> | Readonly<{
4
+ source: 'local';
5
+ }>;
6
+ export declare const absoluteHostPath: (path: unknown) => path is string;
7
+ /** Read only L's public source projection. Old host status/binding/physical fields have no meaning here. */
8
+ export declare function workspaceSessionSource(api: ConnectApi, workspaceId: string, request?: RuntimeRequest): SessionSource | undefined;
9
+ /** One L v2 resolution per user turn. L owns host/path checks and the current SSH target. */
10
+ export declare function resolveHostWorkspace(api: ConnectApi & NativeWorkspaceApi, request: RuntimeRequest, provider: LoginProvider): Promise<import("@ai-workbench/connect/api").HostSessionProjectTarget>;
@@ -1,114 +1,31 @@
1
- import { basename } from 'node:path/posix';
2
- import { awaitL, sameTarget, unwrap } from "./provider.js";
3
- import { connectNeedError } from "./connect-need.js";
1
+ import { awaitL, unwrap } from "./provider.js";
4
2
  import { NeedError } from "./remote.js";
5
- const invalid = () => new NeedError('PROJECT_BINDING_INVALID', '文件来源已改,请新建会话继续;原会话历史仍可查看。', 'L核验的同一主机位置与目录身份');
6
- const hostIdentity = (v) => JSON.stringify(v ? [v.host, v.username, v.port] : null);
7
- const absolute = (v) => typeof v === 'string' && v.startsWith('/') && !v.includes('\0');
8
- function physical(d) {
9
- if (!d || !absolute(d.requestedPath) || !absolute(d.path) || !(d.parentPath === null || absolute(d.parentPath)) || !d.identity || !d.entryIdentity || ![d.identity.device, d.identity.inode, d.entryIdentity.device, d.entryIdentity.inode].every(x => typeof x === 'string' && x.length > 0) || typeof d.isSymlink !== 'boolean' || !d.permissions || d.permissions.read !== true || d.permissions.execute !== true || !(typeof d.permissions.write === 'boolean' || d.permissions.write === null))
10
- throw invalid();
11
- // Directory contents change timestamps; neither timestamp is an execution identity.
12
- return JSON.stringify([d.requestedPath, d.path, d.parentPath, d.identity.device, d.identity.inode, d.entryIdentity.device, d.entryIdentity.inode, d.isSymlink, d.permissions.read, d.permissions.write, d.permissions.execute]);
13
- }
14
- /** Current official membership and explicit host selection; never infer source from a pathname. */
15
- export async function hostWorkspaceGuard(get, api, connection, target, request, workspace, provider) {
16
- if (api.getContractInfo().workspaceLocations !== 1 || typeof api.getWorkspaceLocationState !== 'function')
17
- return;
18
- const firstState = api.getWorkspaceLocationState();
19
- if (!workspace) {
20
- if (firstState.items.some(row => row.source === 'host'))
21
- throw new NeedError('DEPENDENCY_REQUIRED', '我需要核对当前会话所属工作区,才能使用所选主机文件夹。', '官方会话与唯一workspaceRegistry关系');
3
+ export const absoluteHostPath = (path) => typeof path === 'string' && path.startsWith('/') && !path.includes('\0');
4
+ const unavailable = () => new NeedError('WORKSPACE_LOCATION_REQUIRED', '我需要这条会话原来选择的文件来源,请在远程主机连接中查看工作区文件来源。', '会话的主机名称和绝对目录');
5
+ /** Read only L's public source projection. Old host status/binding/physical fields have no meaning here. */
6
+ export function workspaceSessionSource(api, workspaceId, request) {
7
+ const rows = api.getWorkspaceLocationState?.().items.filter(row => row.workspaceId === workspaceId) ?? [];
8
+ if (rows.length !== 1)
22
9
  return;
10
+ const row = rows[0];
11
+ if (row.source === 'local') {
12
+ if (request?.cwd && request.cwd !== row.localPath)
13
+ throw unavailable();
14
+ return Object.freeze({ source: 'local' });
23
15
  }
24
- const registered = { ...await workspace(request) };
25
- request.signal?.throwIfAborted();
26
- const state = api.getWorkspaceLocationState(), rows = state.items.filter(row => row.workspaceId === registered.id), selected = rows[0];
27
- if (rows.length !== 1 || !selected)
16
+ if (row.source !== 'host')
28
17
  return;
29
- if (selected.source !== 'host') {
30
- if (selected.source !== null && selected.source !== 'local')
31
- throw invalid();
32
- return;
33
- }
34
- if (api.getContractInfo().hostWorkspaceResolution !== 1 || typeof api.onWorkspaceLocationState !== 'function' || typeof api.onConnectionState !== 'function')
35
- throw new NeedError('DEPENDENCY_REQUIRED', '我需要支持主机位置解析的远程主机连接组件;本轮未执行,原话保留。', 'L >=0.7.20 / hostWorkspaceResolution:1');
36
- if (state.instanceId !== connection.instanceId || state.epoch !== connection.epoch)
37
- throw invalid();
38
- if (state.need)
39
- throw connectNeedError(state.need);
40
- if (selected.need)
41
- throw connectNeedError(selected.need);
42
- if (!registered.id || !absolute(registered.path) || state.phase !== 'ready' || selected.status !== 'selected' || !selected.bindingId)
43
- throw invalid();
44
- const trusted = structuredClone(target);
45
- if (selected.host && (selected.host.host !== trusted.host || selected.host.username !== trusted.username || selected.host.port !== trusted.port))
46
- throw invalid();
47
- const captured = structuredClone(selected), stamp = physical(captured.remote), context = { instanceId: connection.instanceId, epoch: connection.epoch, workspaceId: registered.id, expectedBindingId: selected.bindingId };
48
- const revoked = new AbortController(), offs = [];
49
- let runtime, disposed = false;
50
- const current = () => {
51
- request.signal?.throwIfAborted();
52
- revoked.signal.throwIfAborted();
53
- if (disposed || get() !== api || api.getContractInfo().hostWorkspaceResolution !== 1)
54
- throw invalid();
55
- const c = api.getConnectionState(), s = api.getWorkspaceLocationState(), items = s.items.filter(row => row.workspaceId === context.workspaceId), item = items[0];
56
- if (c.phase !== 'connected' || c.instanceId !== context.instanceId || c.epoch !== context.epoch)
57
- throw new NeedError('CONNECTION_LOST', '远程主机连接已变化,原派发已停止;请新建会话继续。', '本轮原连接代次');
58
- if (s.instanceId !== context.instanceId || s.epoch !== context.epoch)
59
- throw invalid();
60
- if (s.need)
61
- throw connectNeedError(s.need);
62
- if (item?.need)
63
- throw connectNeedError(item.need);
64
- if (hostIdentity(item?.host) !== hostIdentity(captured.host))
65
- throw invalid();
66
- if (s.phase !== 'ready' || items.length !== 1 || item.bindingId !== context.expectedBindingId || item.workspaceId !== context.workspaceId || item.source !== 'host' || item.status !== 'selected' || item.localPath !== captured.localPath || physical(item.remote) !== stamp)
67
- throw invalid();
68
- };
69
- const revoke = (error) => { if (revoked.signal.aborted)
70
- return; runtime?.closeDiagnostics?.record({ layer: 'adapter', initiator: 'host-workspace.observer', reason: error instanceof NeedError ? error.code : 'HOST_WORKSPACE_REVOKED', action: 'abort', changes: [{ field: 'host-workspace.valid', before: true, after: false }] }); revoked.abort(error); };
71
- const changed = () => { if (request.signal?.aborted && !revoked.signal.aborted)
72
- return; try {
73
- current();
74
- }
75
- catch (error) {
76
- revoke(error);
77
- } };
78
- const dispose = () => { if (disposed)
79
- return; disposed = true; for (const off of offs.splice(0))
80
- off(); };
81
- const membership = async () => { current(); const now = await workspace(request); current(); if (now.id !== registered.id || now.path !== registered.path)
82
- throw invalid(); };
83
- try {
84
- current();
85
- offs.push(api.onWorkspaceLocationState(changed));
86
- offs.push(api.onConnectionState(changed));
87
- current();
88
- return { revocation: revoked.signal, signal: AbortSignal.any([revoked.signal, ...(request.signal ? [request.signal] : [])]), assertCurrent: current, observeRuntime(value) { runtime = value; current(); }, dispose,
89
- async resolve() {
90
- try {
91
- await membership();
92
- const value = unwrap(await awaitL(() => api.resolveSessionProject(request.sessionId, { provider: provider, purpose: 'user-turn' }, { ...context }), AbortSignal.any([revoked.signal, ...(request.signal ? [request.signal] : [])])));
93
- await membership();
94
- if (!value || !('source' in value) || value.source !== 'host' || value.instanceId !== context.instanceId || value.epoch !== context.epoch || value.sessionId !== request.sessionId || value.workspaceId !== context.workspaceId || value.bindingId !== context.expectedBindingId || !Number.isSafeInteger(value.bindingRevision) || value.bindingRevision < 1 || (!value.target || !value.target.session || !sameTarget(trusted, value.target)) || physical(value.directory) !== stamp || value.project?.path !== value.directory.path || value.project?.name !== (basename(value.directory.path) || '/') || 'notice' in value && value.notice !== undefined)
95
- throw invalid();
96
- return value;
97
- }
98
- catch (error) {
99
- if (!request.signal?.aborted) {
100
- const cause = revoked.signal.reason;
101
- revoke(error);
102
- if (cause instanceof NeedError)
103
- throw cause;
104
- }
105
- throw error;
106
- }
107
- }
108
- };
109
- }
110
- catch (error) {
111
- dispose();
112
- throw error;
113
- }
18
+ const host = row.host?.host, path = request?.cwd ?? row.remote?.path;
19
+ if (typeof host !== 'string' || !host || !absoluteHostPath(path))
20
+ throw unavailable();
21
+ return Object.freeze({ source: 'host', host, path });
22
+ }
23
+ /** One L v2 resolution per user turn. L owns host/path checks and the current SSH target. */
24
+ export async function resolveHostWorkspace(api, request, provider) {
25
+ const source = request.workspaceSource;
26
+ if (source?.source !== 'host')
27
+ throw unavailable();
28
+ if ((api.getContractInfo().hostWorkspaceResolution ?? 0) < 2)
29
+ throw new NeedError('DEPENDENCY_REQUIRED', '我需要更新远程主机连接组件后再使用主机目录;本轮未执行,原话保留。', 'L >=0.7.29 / hostWorkspaceResolution >=2');
30
+ return unwrap(await awaitL(() => api.resolveSessionProject(request.sessionId, { provider, purpose: 'user-turn' }, { ...source }), request.signal));
114
31
  }
package/dist/index.js CHANGED
@@ -22,6 +22,7 @@ import { ModelPresentation } from "./model-presentation.js";
22
22
  import { EMPLOYEE_EXECUTION_NOTICE } from "./employee-presentation.js";
23
23
  import { ModelConfigurationError } from "./model-configuration.js";
24
24
  import { officialEmployeeWorkspace } from "./employee-workspace.js";
25
+ import { createSessionSources } from "./session-source.js";
25
26
  import { resolveEmployeeNative } from "./employee-native.js";
26
27
  import { createClaudeLocalProvider } from "./claude-local.js";
27
28
  import { readLocalUserTurn } from "./local-user-turn.js";
@@ -278,7 +279,7 @@ export class CodexRemoteAdapter extends LlmAdapter {
278
279
  const missingPointer = !!last && !previous;
279
280
  live = undefined;
280
281
  const configuration = JSON.stringify([config.acpMaxMessageBytes, config.brokerMaxFrameBytes, config.brokerControlMaxBytes, config.employeeShellKillGraceMs]);
281
- Object.assign(request, { acpMaxMessageBytes: config.acpMaxMessageBytes, resumeSessionId: previous?.sessionId, sessionId: options.sessionId, mode, 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 }) });
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 }) });
282
283
  request.previousContextSha256 = previous?.contextSha256;
283
284
  const after = options.messages.findLastIndex(message => message.role === 'assistant');
284
285
  const tail = options.messages.slice(after + 1);
@@ -323,13 +324,7 @@ export class CodexRemoteAdapter extends LlmAdapter {
323
324
  request.signal = modelSignal;
324
325
  modelSignal.throwIfAborted();
325
326
  }
326
- if (runtime.contextSignal) {
327
- modelSignal = AbortSignal.any([modelSignal, runtime.contextSignal]);
328
- options.signal = modelSignal;
329
- request.signal = modelSignal;
330
- modelSignal.throwIfAborted();
331
- }
332
- if (previous && (previous.identity !== runtime.identity || previous.cwd !== runtime.cwd || previous.bindingId && previous.bindingId !== runtime.binding?.bindingId || previous.workspaceId && previous.workspaceId !== runtime.binding?.workspaceId))
327
+ 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))
333
328
  throw new NeedError('SESSION_CONTEXT_MISMATCH', '原会话的员工主机或工作区已改变,请新建会话', '原会话的可信目标和工作区');
334
329
  input = projectHostWorkspaceInput(input, runtime.hostWorkspace);
335
330
  if (runtime.hostWorkspace && mode === 'session' && Buffer.byteLength(JSON.stringify({ prompt: input.prompt, systemPrompt: input.systemPrompt }), 'utf8') > config.acpMaxMessageBytes)
@@ -531,17 +526,11 @@ export class CodexRemoteAdapter extends LlmAdapter {
531
526
  }
532
527
  }
533
528
  yield* mapper.flush();
534
- const pointer = { ...(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 } : {}) };
529
+ 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 } : {}) };
535
530
  succeeded = true;
536
531
  yield { type: 'finish', reason: { kind: stop === 'max_tokens' || stop === 'max_turn_requests' ? 'max-tokens' : 'stop' }, replayState: { response: pointer } };
537
532
  }
538
533
  catch (error) {
539
- // ACP cancellation must retain the source revocation that caused it.
540
- // User cancellation and unrelated signals keep their existing semantics.
541
- const sourceReason = runtime?.contextSignal?.aborted ? runtime.contextSignal.reason : undefined;
542
- if (error instanceof NeedError && error.code === 'ABORTED' && sourceReason instanceof NeedError) {
543
- error = Object.assign(new NeedError(sourceReason.code, sourceReason.message, sourceReason.requires), { ...(sourceReason.connectNeed ? { connectNeed: sourceReason.connectNeed } : {}), ...(sourceReason.officialReason ? { officialReason: sourceReason.officialReason } : {}), cause: new AggregateError([sourceReason, error], 'HOST_SOURCE_REVOKED') });
544
- }
545
534
  turnFailure = error;
546
535
  runtime?.closeDiagnostics?.record({ layer: 'adapter', initiator: 'adapter.turn-failure', reason: closeCode(error, signal.aborted ? 'ABORTED' : 'TURN_FAILED'), action: 'failure' });
547
536
  recordInformation();
@@ -614,6 +603,7 @@ export function apply(ctx, live) {
614
603
  return manager;
615
604
  };
616
605
  ctx.provide('aiWorkbenchRemoteDiagnostics', { read: (query) => managerForProfile().read(query) });
606
+ const sessionSources = createSessionSources(ctx, get);
617
607
  const preflightDiagnostics = createPreflightDiagnostics({ ctx, getApi: get, manager: managerForProfile });
618
608
  const diagnostics = new Map();
619
609
  const diagnostic = (scope, provider, message) => {
@@ -666,7 +656,7 @@ export function apply(ctx, live) {
666
656
  const sessions = services.get('sessions');
667
657
  return officialEmployeeWorkspace(request.sessionId, registry, sessions?.get(request.sessionId)?.header);
668
658
  };
669
- const provider = connectProvider(get, { agent, workspace, ...(agent.loginProvider === 'codex' ? { employee: {
659
+ const provider = connectProvider(get, { agent, workspace, source: sessionSources, ...(agent.loginProvider === 'codex' ? { employee: {
670
660
  getNetwork: () => ctx.get('aiWorkbenchNetwork'),
671
661
  workspace, native: () => resolveEmployeeNative(),
672
662
  } } : {}), details, open, local });
@@ -49,7 +49,7 @@ export function createPreflightDiagnostics(options) {
49
49
  const rows = state?.items.filter(row => row.workspaceId === workspace.id) ?? [];
50
50
  if (state?.phase === 'ready' && state.instanceId === connection?.instanceId && state.epoch === connection.epoch && rows.length === 1) {
51
51
  const row = rows[0];
52
- source = { workspaceId: row.workspaceId, bindingId: row.bindingId, source: row.source === 'local' || row.source === 'host' ? row.source : null, status: row.status };
52
+ source = { workspaceId: row.workspaceId, bindingId: row.bindingId, source: row.source === 'local' || row.source === 'host' ? row.source : null, status: row.source === 'host' ? null : row.status ?? null };
53
53
  }
54
54
  }
55
55
  }
@@ -12,6 +12,7 @@ export function employeeFailure(code, brand = 'codex') {
12
12
  PERMISSION_POLICY_CHANGED: '当前权限已变化或无法核对,这次派发已停止;没有自动重发,你的原话仍保留,在途结果请查看技术详情。',
13
13
  APPROVAL_STALE: '连接或权限已变化,原审批已失效;没有自动重发,在途结果请查看技术详情。',
14
14
  PROJECT_BINDING_INVALID: '文件来源已改,请新建会话继续;原会话历史仍可查看。',
15
+ WORKSPACE_SOURCE_STORAGE_FAILED: '这条会话的文件来源未能保存,本轮工作区准备失败;请恢复后在原会话重试。',
15
16
  WORKSPACE_LOCATION_REQUIRED: '我需要你确认这个工作区使用哪一个文件夹,本轮未执行;你的原话仍保留。',
16
17
  WORKSPACE_LOCATION_CONFLICT: '工作区来源选择已更新,本次确认不能覆盖新的选择;本轮未执行,你的原话仍保留。',
17
18
  LOCAL_USER_TURN_CHANGED: '本次输入已变化,本轮已停止,请确认对话后重新发送。',
@@ -1,3 +1,4 @@
1
+ import { type SessionSource } from './host-workspace.ts';
1
2
  import type { CloseCause, CloseDiagnostics } from './close-diagnostics.ts';
2
3
  import type { AgentApp } from '@agentclientprotocol/sdk';
3
4
  import type { AgentApp as ExtendedAgentApp } from '@agentclientprotocol/sdk/unstable';
@@ -27,7 +28,9 @@ export interface Runtime {
27
28
  identity: string;
28
29
  cwd: string;
29
30
  hostWorkspace?: Readonly<HostWorkspacePresentation>;
30
- contextSignal?: AbortSignal;
31
+ hostSource?: Extract<SessionSource, {
32
+ source: 'host';
33
+ }>;
31
34
  execution?: EmployeeExecution;
32
35
  acpTransport?: {
33
36
  writable: WritableStream<Uint8Array>;
@@ -61,6 +64,10 @@ export interface Runtime {
61
64
  closeDiagnostics?: CloseDiagnostics;
62
65
  }
63
66
  export interface RuntimeRequest extends Invocation {
67
+ workspaceSource?: SessionSource;
68
+ hostSource?: Extract<SessionSource, {
69
+ source: 'host';
70
+ }>;
64
71
  closeDiagnostics?: CloseDiagnostics;
65
72
  previousContextSha256?: string;
66
73
  preflightFailureRecorded?: boolean;
@@ -169,6 +176,7 @@ export declare function connectProvider(get: () => ConnectApi | undefined, optio
169
176
  id: string;
170
177
  path: string;
171
178
  }>;
179
+ source?: (request: RuntimeRequest, api: ConnectApi) => Promise<SessionSource | undefined>;
172
180
  employee?: EmployeeProviderOptions;
173
181
  local?: LocalRuntimeProvider;
174
182
  details?: (request: RuntimeRequest, api: ConnectApi, connection: ConnectionState, target: ConnectTarget) => Promise<DetailChannel>;
package/dist/provider.js CHANGED
@@ -1,4 +1,4 @@
1
- import { hostWorkspaceGuard } from "./host-workspace.js";
1
+ import { resolveHostWorkspace, workspaceSessionSource } from "./host-workspace.js";
2
2
  import { connectNeedError } from "./connect-need.js";
3
3
  import { loginDiagnostic, loginReason } from "./login-diagnostic.js";
4
4
  import { NeedError } from "./remote.js";
@@ -145,30 +145,6 @@ export function sameTarget(a, b) {
145
145
  }
146
146
  export function connectProvider(get, options = {}) {
147
147
  const agent = options.agent ?? CODEX_AGENT;
148
- const hostScopes = new WeakMap();
149
- const hostMethods = new WeakMap();
150
- const retainHostScope = (runtime, scope) => {
151
- hostScopes.get(runtime)?.dispose();
152
- hostScopes.set(runtime, scope);
153
- runtime.contextSignal = scope.revocation;
154
- if (hostMethods.has(runtime))
155
- return;
156
- const original = { dispose: runtime.dispose, detach: runtime.detach };
157
- hostMethods.set(runtime, original);
158
- const release = () => { hostScopes.get(runtime)?.dispose(); hostScopes.delete(runtime); };
159
- runtime.dispose = async (cause) => { try {
160
- await original.dispose?.(cause);
161
- }
162
- finally {
163
- release();
164
- } };
165
- runtime.detach = async (cause) => { try {
166
- await original.detach?.(cause);
167
- }
168
- finally {
169
- release();
170
- } };
171
- };
172
148
  // Keep the legacy source SDK wrapper's strip-only import graph free of broker runtime syntax.
173
149
  // Cache the initialization promise so concurrent sessions share this provider's binding lifetimes.
174
150
  let employee;
@@ -182,7 +158,13 @@ export function connectProvider(get, options = {}) {
182
158
  requireLoginCoordinator(api);
183
159
  let sourceStage = 'input-acceptance';
184
160
  try {
185
- if (options.employee) {
161
+ request.workspaceSource = options.source ? await options.source(request, api) : request.hostSource;
162
+ if (!options.source && !request.workspaceSource && options.workspace && request.sessionId && api.getContractInfo().workspaceLocations === 1) {
163
+ const workspace = await options.workspace(request);
164
+ request.workspaceSource = workspaceSessionSource(api, workspace.id, request);
165
+ }
166
+ const host = request.workspaceSource?.source === 'host';
167
+ if (options.employee && !host) {
186
168
  const module = await import("./employee-provider.js");
187
169
  request.localContext = await module.inspectEmployeeWorkspace(get, options.employee, agent, api, request);
188
170
  }
@@ -198,15 +180,16 @@ export function connectProvider(get, options = {}) {
198
180
  const connection = await waitForLogin(api, agent.loginProvider, before, get, request.signal);
199
181
  return { target, connection };
200
182
  })();
201
- if (request.identity && request.identity !== identity(target))
183
+ if (request.workspaceSource?.source !== 'host' && request.identity && request.identity !== identity(target))
202
184
  throw new NeedError('SESSION_CONTEXT_MISMATCH', '员工主机与原会话不一致,请新建会话', '原员工主机');
203
185
  request.localAdmissionCheck?.();
204
186
  admissions.set(request, { api, target, connection });
205
187
  sourceStage = 'local-admission';
206
- const locations = api.getContractInfo().workspaceLocations === 1 ? api.getWorkspaceLocationState?.() : undefined;
207
- if (locations && (locations.phase === 'failed' || locations.phase === 'disposed') && locations.instanceId === connection.instanceId && locations.epoch === connection.epoch && locations.need)
188
+ const locations = !host && api.getContractInfo().workspaceLocations === 1 ? api.getWorkspaceLocationState?.() : undefined;
189
+ if (!host && locations && (locations.phase === 'failed' || locations.phase === 'disposed') && locations.instanceId === connection.instanceId && locations.epoch === connection.epoch && locations.need)
208
190
  throw connectNeedError(locations.need);
209
- await options.local?.admit?.(request, api, connection, target);
191
+ if (!host)
192
+ await options.local?.admit?.(request, api, connection, target);
210
193
  }
211
194
  catch (error) {
212
195
  admissions.delete(request);
@@ -225,59 +208,38 @@ export function connectProvider(get, options = {}) {
225
208
  const { api, target, connection } = admitted;
226
209
  if (!request.sessionId)
227
210
  throw new NeedError('PROJECT_BINDING_REQUIRED', '我需要当前会话和已选择的工作区,请使用原生工作区入口', 'DSH会话与原生工作区');
228
- const host = await hostWorkspaceGuard(get, api, connection, target, request, options.workspace, agent.loginProvider);
229
- try {
230
- if (!host && options.employee) {
231
- employee ??= import("./employee-provider.js").then(module => module.createEmployeeProvider(get, options, agent));
232
- const local = await (await employee)({ api, target, connection, request });
233
- if (local) {
234
- local.loginRequired = () => offerLogin(api, request, get, agent);
235
- return local;
236
- }
237
- }
238
- if (!host && agent.loginProvider === 'claude' && options.local) {
239
- const runtime = await options.local(request, api, connection, target);
240
- if (runtime)
241
- return runtime;
242
- }
243
- // A local source must have been handled by its employee provider above.
244
- // Host execution requires an explicit host location; no name-based resolver.
245
- if (!host)
246
- throw new NeedError('WORKSPACE_LOCATION_REQUIRED', '请选择文件夹来源;改变来源后请新建会话。', '已选择的本机或主机文件夹');
247
- const resolved = await host.resolve();
248
- const project = resolved.project;
249
- if (request.cwd && request.cwd !== project.path || request.bindingId && request.bindingId !== resolved.bindingId || request.workspaceId && request.workspaceId !== resolved.workspaceId)
250
- throw new NeedError(host ? 'PROJECT_BINDING_INVALID' : 'SESSION_CONTEXT_MISMATCH', host ? '文件来源已改,请新建会话继续;原会话历史仍可查看。' : '原会话的工作区绑定已改变,请新建会话', '原工作区绑定');
251
- const details = await options.details?.(request, api, connection, target);
252
- try {
253
- if (!options.open)
254
- throw new NeedError('RUNTIME_REQUIRED', '我需要主机运行适配器', '主机broker适配');
255
- const runtime = await options.open(resolved.target, project, { ...request, ...(host ? { signal: host.signal } : {}), agent, bindingId: resolved.bindingId, workspaceId: resolved.workspaceId, bindingRevision: resolved.bindingRevision, detailChannel: details });
256
- try {
257
- host?.observeRuntime(runtime);
258
- }
259
- catch (error) {
260
- void runtime.dispose?.().catch(() => console.warn('HOST_CLEANUP_FAILED'));
261
- throw error;
262
- }
263
- if (host)
264
- retainHostScope(runtime, host);
265
- runtime.hostWorkspace = host && 'directory' in resolved ? Object.freeze({ cwd: resolved.directory.path }) : undefined;
266
- // The transport can outlive a turn; presentation belongs only to this invocation.
267
- runtime.details = details ?? runtime.details;
268
- runtime.binding = { bindingId: resolved.bindingId, workspaceId: resolved.workspaceId, revision: resolved.bindingRevision };
269
- runtime.connection = { ...connection };
270
- runtime.loginRequired = () => offerLogin(api, request, get, agent);
271
- return runtime;
272
- }
273
- catch (error) {
274
- throw error;
211
+ const host = request.workspaceSource?.source === 'host';
212
+ if (!host && options.employee) {
213
+ employee ??= import("./employee-provider.js").then(module => module.createEmployeeProvider(get, options, agent));
214
+ const local = await (await employee)({ api, target, connection, request });
215
+ if (local) {
216
+ local.loginRequired = () => offerLogin(api, request, get, agent);
217
+ return local;
275
218
  }
276
219
  }
277
- catch (error) {
278
- host?.dispose();
279
- throw error;
220
+ if (!host && agent.loginProvider === 'claude' && options.local) {
221
+ const runtime = await options.local(request, api, connection, target);
222
+ if (runtime)
223
+ return runtime;
280
224
  }
225
+ // A local source must have been handled by its employee provider above.
226
+ // Host execution requires an explicit host location; no name-based resolver.
227
+ if (!host)
228
+ throw new NeedError('WORKSPACE_LOCATION_REQUIRED', '请选择文件夹来源;改变来源后请新建会话。', '已选择的本机或主机文件夹');
229
+ const resolved = await resolveHostWorkspace(api, request, agent.loginProvider);
230
+ const project = resolved.project;
231
+ const details = await options.details?.(request, api, connection, target);
232
+ if (!options.open)
233
+ throw new NeedError('RUNTIME_REQUIRED', '我需要主机运行适配器', '主机broker适配');
234
+ const runtime = await options.open(resolved.target, project, { ...request, agent, bindingId: undefined, workspaceId: undefined, bindingRevision: undefined, detailChannel: details });
235
+ runtime.hostSource = Object.freeze({ ...request.workspaceSource });
236
+ runtime.hostWorkspace = Object.freeze({ cwd: project.path });
237
+ // The transport can outlive a turn; presentation belongs only to this invocation.
238
+ runtime.details = details ?? runtime.details;
239
+ runtime.binding = undefined;
240
+ runtime.connection = { ...connection };
241
+ runtime.loginRequired = () => offerLogin(api, request, get, agent);
242
+ return runtime;
281
243
  };
282
244
  resolve.preflight = preflight;
283
245
  return resolve;
@@ -0,0 +1,18 @@
1
+ import type { Context } from '@deepseek-ai/cordis';
2
+ import { type SessionSource } from './host-workspace.ts';
3
+ import type { ConnectApi, RuntimeRequest } from './provider.ts';
4
+ export declare const sessionSourceSpec: {
5
+ name: string;
6
+ version: number;
7
+ tables: {
8
+ sessions: import("@deepseek-ai/dsh-storage-domain").DomainTableSpec<string, {
9
+ source: "host";
10
+ host: string;
11
+ path: string;
12
+ } | {
13
+ source: "local";
14
+ }>;
15
+ };
16
+ };
17
+ /** Official domain metadata, keyed by native session ID. No L files, local path probes or host identity hashes. */
18
+ export declare function createSessionSources(ctx: Context, getApi: () => ConnectApi | undefined): (request: RuntimeRequest, api: ConnectApi) => Promise<SessionSource | undefined>;
@@ -0,0 +1,90 @@
1
+ import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain';
2
+ import { SessionId } from '@deepseek-ai/dsh-session';
3
+ import { z } from 'zod';
4
+ import { absoluteHostPath, workspaceSessionSource } from "./host-workspace.js";
5
+ import { NeedError } from "./remote.js";
6
+ const sourceSchema = z.discriminatedUnion('source', [
7
+ z.object({ source: z.literal('host'), host: z.string().min(1), path: z.string().refine(absoluteHostPath) }).strict(),
8
+ z.object({ source: z.literal('local') }).strict(),
9
+ ]);
10
+ export const sessionSourceSpec = defineDomain({ name: 'ai_workbench_remote_sources', version: 1, tables: { sessions: domainTable(sourceSchema) } });
11
+ const storageNeed = () => new NeedError('WORKSPACE_SOURCE_STORAGE_FAILED', '这条会话的文件来源未能保存,本轮工作区准备失败;请恢复后在原会话重试。', '官方会话来源存储');
12
+ /** Official domain metadata, keyed by native session ID. No L files, local path probes or host identity hashes. */
13
+ export function createSessionSources(ctx, getApi) {
14
+ let domain;
15
+ const pending = new Map();
16
+ const captured = new WeakMap();
17
+ const open = () => {
18
+ if (!domain) {
19
+ const facility = ctx.get('storageDomain');
20
+ if (!facility)
21
+ throw storageNeed();
22
+ domain = facility.open(sessionSourceSpec).catch(error => { domain = undefined; throw error; });
23
+ }
24
+ return domain;
25
+ };
26
+ ctx.effect(() => async () => { if (domain)
27
+ await (await domain).close(); });
28
+ function candidate(session, api, request) {
29
+ if (request?.hostSource)
30
+ return sourceSchema.parse(request.hostSource);
31
+ const registry = ctx.get('workspaceRegistry');
32
+ // Creation can precede workspace membership publication. The official creation cwd identifies its workspace.
33
+ const rows = registry?.list().filter(row => request?.workspaceId ? row.id === request.workspaceId : row.sessionIds.includes(session.id) || row.path === session.header.cwd) ?? [];
34
+ if (rows.length !== 1)
35
+ return;
36
+ return workspaceSessionSource(api, rows[0].id, request);
37
+ }
38
+ function save(session, api, request) {
39
+ const id = session.id, existing = pending.get(id);
40
+ if (existing)
41
+ return existing;
42
+ const work = async () => {
43
+ const table = (await open()).table('sessions'), stored = table.get(id);
44
+ if (stored) {
45
+ const source = Object.freeze({ ...stored });
46
+ captured.set(session, source);
47
+ return source;
48
+ }
49
+ const chosen = captured.get(session) ?? candidate(session, api, request);
50
+ if (!chosen)
51
+ return;
52
+ captured.set(session, chosen);
53
+ await table.put(id, chosen);
54
+ return chosen;
55
+ };
56
+ const result = work().catch(error => { if (error instanceof NeedError)
57
+ throw error; throw Object.assign(storageNeed(), { cause: error }); });
58
+ pending.set(id, result);
59
+ void result.finally(() => pending.delete(id)).catch(() => { });
60
+ return result;
61
+ }
62
+ ctx.on('session/created', session => {
63
+ // Restored sessions migrate from their recorded replay/workspace at first turn.
64
+ if (session.firstLiveSeq !== 0)
65
+ return;
66
+ const api = getApi();
67
+ if (!api)
68
+ return;
69
+ try {
70
+ const source = candidate(session, api);
71
+ if (source)
72
+ captured.set(session, source);
73
+ }
74
+ catch {
75
+ ctx.logger('remote-agent').warn('创建时文件来源暂不可读;发送时将重试');
76
+ return;
77
+ }
78
+ if (!captured.has(session))
79
+ return;
80
+ void save(session, api).catch(() => ctx.logger('remote-agent').warn('会话文件来源暂未保存;发送时将重试'));
81
+ });
82
+ return async (request, api) => {
83
+ if (!request.sessionId || api.getContractInfo?.().workspaceLocations !== 1 && !request.hostSource)
84
+ return;
85
+ const session = ctx.get('sessions')?.get(SessionId(request.sessionId));
86
+ if (!session)
87
+ throw storageNeed();
88
+ return save(session, api, request);
89
+ };
90
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-workbench/remote-agent",
3
- "version": "0.8.24",
3
+ "version": "0.8.25",
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.27.tgz",
39
+ "@ai-workbench/connect": "file:test/fixtures/ai-workbench-connect-0.7.29.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",
@@ -63,10 +63,11 @@
63
63
  "react-dom": "18.3.1",
64
64
  "typescript": "5.9.3",
65
65
  "zustand": "4.4.7",
66
- "@deepseek-ai/dsh-sandbox-policy": "0.2.0-rc.2"
66
+ "@deepseek-ai/dsh-sandbox-policy": "0.2.0-rc.2",
67
+ "@deepseek-ai/dsh-storage-domain": "0.2.0-rc.2"
67
68
  },
68
69
  "peerDependencies": {
69
- "@ai-workbench/connect": "~0.7.27",
70
+ "@ai-workbench/connect": "~0.7.29",
70
71
  "@ai-workbench/network": "~0.4.9",
71
72
  "@deepseek-ai/cordis": "4.0.4",
72
73
  "@deepseek-ai/dsh-agent": "0.2.0-rc.2",
@@ -76,7 +77,8 @@
76
77
  "@deepseek-ai/dsh-typert-protocol": "0.2.0-rc.2",
77
78
  "@deepseek-ai/dsh-user-approval": "0.2.0-rc.2",
78
79
  "@deepseek-ai/schemastery": "3.18.4",
79
- "@deepseek-ai/dsh-sandbox-policy": "0.2.0-rc.2"
80
+ "@deepseek-ai/dsh-sandbox-policy": "0.2.0-rc.2",
81
+ "@deepseek-ai/dsh-storage-domain": "0.2.0-rc.2"
80
82
  },
81
83
  "dsh": {
82
84
  "bundle": {