@ai-workbench/remote-agent 0.8.21 → 0.8.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +28 -155
  2. package/dist/acp.d.ts +20 -15
  3. package/dist/acp.js +197 -224
  4. package/dist/agents.d.ts +0 -1
  5. package/dist/agents.js +2 -2
  6. package/dist/broker-runtime.d.ts +3 -5
  7. package/dist/broker-runtime.js +45 -342
  8. package/dist/broker.d.ts +6 -31
  9. package/dist/broker.js +42 -113
  10. package/dist/catalog-ssh.d.ts +2 -31
  11. package/dist/catalog-ssh.js +9 -39
  12. package/dist/claude-local.d.ts +1 -1
  13. package/dist/claude-local.js +40 -105
  14. package/dist/client.js +34 -127
  15. package/dist/close-diagnostics.d.ts +6 -20
  16. package/dist/close-diagnostics.js +13 -57
  17. package/dist/connect-contract.d.ts +4 -8
  18. package/dist/conversation-details.d.ts +0 -29
  19. package/dist/conversation-details.js +8 -99
  20. package/dist/employee-endpoint.d.ts +1 -3
  21. package/dist/employee-endpoint.js +8 -39
  22. package/dist/employee-exec.d.ts +4 -3
  23. package/dist/employee-exec.js +25 -13
  24. package/dist/employee-info.d.ts +5 -1
  25. package/dist/employee-info.js +16 -5
  26. package/dist/employee-location.js +3 -4
  27. package/dist/employee-mcp.js +44 -69
  28. package/dist/employee-native.d.ts +1 -3
  29. package/dist/employee-native.js +6 -27
  30. package/dist/employee-presentation.d.ts +0 -5
  31. package/dist/employee-presentation.js +0 -15
  32. package/dist/employee-provider.d.ts +3 -11
  33. package/dist/employee-provider.js +69 -205
  34. package/dist/employee-reasons.js +1 -5
  35. package/dist/employee-shell.d.ts +2 -1
  36. package/dist/employee-shell.js +10 -13
  37. package/dist/employee-ssh.d.ts +1 -3
  38. package/dist/employee-ssh.js +13 -16
  39. package/dist/employee-tools.d.ts +5 -5
  40. package/dist/failure-evidence.d.ts +2 -10
  41. package/dist/failure-evidence.js +12 -61
  42. package/dist/host-workspace.d.ts +4 -2
  43. package/dist/host-workspace.js +1 -1
  44. package/dist/index.d.ts +12 -42
  45. package/dist/index.js +203 -471
  46. package/dist/input.js +2 -3
  47. package/dist/local-user-turn.js +5 -6
  48. package/dist/mapping.d.ts +0 -3
  49. package/dist/mapping.js +0 -34
  50. package/dist/model-catalog.d.ts +2 -41
  51. package/dist/model-catalog.js +24 -282
  52. package/dist/model-configuration.d.ts +0 -3
  53. package/dist/model-configuration.js +8 -47
  54. package/dist/model-presentation.d.ts +1 -0
  55. package/dist/model-presentation.js +2 -2
  56. package/dist/permissions.d.ts +15 -0
  57. package/dist/permissions.js +30 -0
  58. package/dist/preflight-diagnostic.js +11 -22
  59. package/dist/preflight-payload.d.ts +1 -1
  60. package/dist/preflight-payload.js +2 -2
  61. package/dist/presentation.d.ts +0 -1
  62. package/dist/presentation.js +21 -42
  63. package/dist/provider.d.ts +12 -48
  64. package/dist/provider.js +18 -106
  65. package/dist/readiness.js +1 -7
  66. package/dist/remote-details.d.ts +41 -17
  67. package/dist/remote-details.js +84 -381
  68. package/dist/workspace-source.d.ts +1 -25
  69. package/dist/workspace-source.js +1 -138
  70. package/package.json +11 -12
  71. package/dist/catalog-diagnostics.d.ts +0 -16
  72. package/dist/catalog-diagnostics.js +0 -108
  73. package/dist/host-layout.d.ts +0 -8
  74. package/dist/host-layout.js +0 -9
  75. package/dist/job.d.ts +0 -17
  76. package/dist/job.js +0 -57
  77. package/dist/model-defaults.d.ts +0 -29
  78. package/dist/model-defaults.js +0 -62
  79. package/dist/oneshot.d.ts +0 -13
  80. package/dist/oneshot.js +0 -78
  81. package/dist/protocol-boundary.d.ts +0 -13
  82. package/dist/protocol-boundary.js +0 -49
  83. package/dist/provider-locale.d.ts +0 -19
  84. package/dist/provider-locale.js +0 -73
  85. package/dist/recovery-checkpoint.d.ts +0 -6
  86. package/dist/recovery-checkpoint.js +0 -71
  87. package/dist/recovery.d.ts +0 -51
  88. package/dist/recovery.js +0 -61
  89. package/dist/sdk-ssh-wrapper.d.ts +0 -2
  90. package/dist/sdk-ssh-wrapper.js +0 -34
  91. package/dist/stage-timing.d.ts +0 -31
  92. package/dist/stage-timing.js +0 -61
  93. package/dist/workspace-notice.d.ts +0 -47
  94. package/dist/workspace-notice.js +0 -273
  95. package/dist/workspace-source-confirmation.d.ts +0 -4
  96. package/dist/workspace-source-confirmation.js +0 -12
package/dist/index.js CHANGED
@@ -1,59 +1,45 @@
1
1
  import { connectNeedMessage } from "./connect-need.js";
2
- import { CloseDiagnostics, withFailureEvidence, withFirstFailureEvidence, failureEvidenceSummary, closeCode, closeErrorCodes, closeFailure } from "./close-diagnostics.js";
3
- import { withPendingCleanupEvidence, withRemoteFailureEvidence } from "./failure-evidence.js";
4
- import { runCatalog } from "./catalog-ssh.js";
5
- import { createCatalogDiagnostics, CatalogDiagnosticsRemote } from "./catalog-diagnostics.js";
2
+ import { CloseDiagnostics, withFirstFailureEvidence, failureEvidenceSummary, closeCode } from "./close-diagnostics.js";
3
+ import { withRemoteFailureEvidence } from "./failure-evidence.js";
6
4
  import { SessionId } from '@deepseek-ai/dsh-session';
7
5
  import Schema from '@deepseek-ai/schemastery';
8
6
  import { LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm';
9
- import { fileURLToPath } from 'node:url';
10
- import { createHash, randomUUID } from 'node:crypto';
11
- import { recoveryJournal, runtimeRecoveryKey, supportsCodexContextPolicy } from "./recovery.js";
7
+ import { createHash } from 'node:crypto';
12
8
  import * as unstable from '@agentclientprotocol/sdk/unstable';
13
9
  import * as acp from '@agentclientprotocol/sdk';
14
10
  import { AcpSession } from "./acp.js";
15
- import { runOneShot } from "./oneshot.js";
16
11
  import { StreamMapper } from "./mapping.js";
17
12
  import { connectProvider } from "./provider.js";
18
13
  import { brokerFactory } from "./broker.js";
19
14
  import { AGENTS, CODEX_AGENT } from "./agents.js";
20
- import { prepareInput, prepareContinuation, projectHostWorkspaceInput } from "./input.js";
21
- import { createWorkspaceNoticeConsumer, isWorkspaceNoticeMessage } from "./workspace-notice.js";
15
+ import { prepareInput, projectHostWorkspaceInput } from "./input.js";
22
16
  import { NeedError } from "./remote.js";
23
17
  import { employeeFailure } from "./presentation.js";
24
18
  import { createRemoteDetails } from "./remote-details.js";
25
- import { PROVIDER_NAMES, ProviderLocaleRemote } from "./provider-locale.js";
26
19
  import { approvalLabel } from "./approval-label.js";
27
- import { StageTiming } from "./stage-timing.js";
28
- import { verifyRecoveryCheckpoint, currentInputIds } from "./recovery-checkpoint.js";
29
20
  import { ModelCatalog, modelInfo } from "./model-catalog.js";
30
- import { ModelPresentation, unavailableModelInfo } from "./model-presentation.js";
31
- import { EMPLOYEE_EXECUTION_NOTICE, employeeNoticePreviouslyShown } from "./employee-presentation.js";
21
+ import { ModelPresentation } from "./model-presentation.js";
22
+ import { EMPLOYEE_EXECUTION_NOTICE } from "./employee-presentation.js";
32
23
  import { ModelConfigurationError } from "./model-configuration.js";
33
24
  import { officialEmployeeWorkspace } from "./employee-workspace.js";
34
- import { nativeWorkspaceSourceConfirmation } from "./workspace-source-confirmation.js";
35
25
  import { resolveEmployeeNative } from "./employee-native.js";
36
- import { createLocalUserTurnObserver } from "./local-user-turn.js";
37
26
  import { createClaudeLocalProvider } from "./claude-local.js";
38
27
  import { readLocalUserTurn } from "./local-user-turn.js";
39
28
  import { readEmployeeWorkspace } from "./employee-workspace.js";
40
- import { createModelDefaults, createDefaultSelectionWriter } from "./model-defaults.js";
41
29
  import { createPreflightDiagnostics } from "./preflight-diagnostic.js";
42
30
  import { installModelInputRetention, markModelPreparationFailure } from "./model-input-retention.js";
31
+ import { dshPermissionSource, permissionMode } from "./permissions.js";
32
+ export { readDshPermissions } from "./permissions.js";
43
33
  export const name = 'ai-workbench-remote-agent';
44
34
  export const settingsNamespace = 'ai-workbench-codex';
45
35
  // L can be installed/enabled after X; keeping X mounted lets it explain the prerequisite.
46
36
  export const inject = ['llm'];
47
37
  /** Owner-approved defaults; administrators may override through the existing settings. */
48
- export const DEFAULT_CONFIG = Object.freeze({ mode: 'session', acpMaxMessageBytes: 4194304, brokerMaxFrameBytes: 2097152, brokerControlMaxBytes: 1048576, oneShotSandbox: 'workspace-write', oneShotApproval: 'never', employeeShellKillGraceMs: 1000, employeeHttpMcpPolicy: 'ignore' });
38
+ export const DEFAULT_CONFIG = Object.freeze({ acpMaxMessageBytes: 4194304, brokerMaxFrameBytes: 2097152, brokerControlMaxBytes: 1048576, employeeShellKillGraceMs: 1000 });
49
39
  export const Config = Schema.object({
50
- employeeHttpMcpPolicy: Schema.union([Schema.const('reject').i18n({ zh: '拒绝本机 HTTP MCP', en: '拒绝本机 HTTP MCP' }), Schema.const('ignore').i18n({ zh: '忽略本机 HTTP MCP 后继续(默认)', en: '忽略本机 HTTP MCP 后继续(默认)' })]).i18n({ zh: '本机 HTTP MCP 策略;默认忽略;忽略仅剔除会话投影,不改员工配置。新连接生效,stdio 始终按原生规则忽略,名称仅在技术详情说明;HTTP 忽略在正文按扩展名称提示', en: '本机 HTTP MCP 策略;默认忽略;忽略仅剔除会话投影,不改员工配置。新连接生效,stdio 始终按原生规则忽略,名称仅在技术详情说明;HTTP 忽略在正文按扩展名称提示' }).default(DEFAULT_CONFIG.employeeHttpMcpPolicy).volatile(),
51
- mode: Schema.union([Schema.const('session').i18n({ en: 'Session', zh: '会话模式' }), Schema.const('one-shot').i18n({ en: 'One-shot', zh: '单次模式' })]).i18n({ en: 'Run mode; start a new session after switching modes', zh: 'Codex运行模式;Claude固定会话模式;切换后请新建会话' }).default(DEFAULT_CONFIG.mode).volatile(),
52
40
  acpMaxMessageBytes: Schema.number().min(1).step(1).i18n({ en: 'Session message limit; default 4 MiB, adjustable by administrators', zh: '会话消息容量;默认4 MiB,管理员可调整' }).default(DEFAULT_CONFIG.acpMaxMessageBytes).volatile(),
53
41
  brokerMaxFrameBytes: Schema.number().min(1).step(1).i18n({ en: 'Host output chunk limit; default 2 MiB, adjustable by administrators', zh: '主机回传单段容量;默认2 MiB,管理员可调整' }).default(DEFAULT_CONFIG.brokerMaxFrameBytes).volatile(),
54
42
  brokerControlMaxBytes: Schema.number().min(1).step(1).i18n({ en: 'Host session control reply limit; default 1 MiB, adjustable by administrators', zh: '主机会话管理回复容量;默认1 MiB,管理员可调整' }).default(DEFAULT_CONFIG.brokerControlMaxBytes).volatile(),
55
- oneShotSandbox: Schema.union(['read-only', 'workspace-write', 'danger-full-access']).i18n({ en: 'One-shot sandbox; workspace changes are allowed by default', zh: '单次任务沙箱;默认允许工作区内修改' }).default(DEFAULT_CONFIG.oneShotSandbox).volatile(),
56
- oneShotApproval: Schema.const('never').i18n({ en: 'Non-interactive approval; permissions follow the launch policy, never automatic approval', zh: '单次模式无交互审批;权限由启动策略决定,不表示自动批准' }).default(DEFAULT_CONFIG.oneShotApproval).volatile(),
57
43
  employeeShellKillGraceMs: Schema.number().min(0).max(2147483647).step(1).i18n({ en: 'Local command SSH cleanup grace in milliseconds; cancellation keeps the remote result unknown', zh: '本机命令SSH取消后的清理宽限毫秒;默认1000,取消仍保留员工端结果未知' }).default(DEFAULT_CONFIG.employeeShellKillGraceMs).volatile(),
58
44
  });
59
45
  /** DSH volatile settings are handles; adapters freeze their values once per invocation. */
@@ -65,23 +51,16 @@ function modelResolutionLabel(configuration) {
65
51
  ? `型号:${configuration.resolvedModel}(来源:主机目录映射 ${configuration.selected.value};会话未单独回报具体型号)`
66
52
  : `具体型号:${configuration.resolvedModel}`;
67
53
  }
68
- function validContextDigest(value) { return typeof value === 'string' && /^[a-fA-F0-9]{64}$/u.test(value); }
69
54
  function readPointer(options, agent) {
70
- const last = options.messages.findLast(message => message.role === 'assistant' && !isWorkspaceNoticeMessage(message));
55
+ const last = options.messages.findLast(message => message.role === 'assistant');
71
56
  if (!last || last.role !== 'assistant')
72
57
  return;
73
58
  if (last.source.provider !== options.provider)
74
- throw new NeedError('SESSION_STATE_MISSING', '已有对话属于其他模型通道,不能沿用其远端进程', '当前模型的远端恢复记录');
75
- if (last.source.replayState === undefined)
76
59
  return;
77
60
  const envelope = last.source.replayState;
78
61
  const pointer = envelope?.response;
79
- if (pointer?.version !== 2 || pointer.agent !== agent.id || (pointer.mode !== 'session' && pointer.mode !== 'one-shot') || ![pointer.identity, pointer.cwd, pointer.sessionId].every(value => typeof value === 'string' && value.length > 0))
80
- throw new NeedError('SESSION_STATE_INVALID', '原对话缺少新版工作区/模型绑定,不能静默迁移,请新建会话', '有效的新一代远端会话恢复记录');
81
- if (options.sessionId && pointer.dshSessionId !== options.sessionId)
82
- throw new NeedError('SESSION_CONTEXT_MISMATCH', '此恢复记录属于另一会话,复制或分叉后不能沿用原远端进程,请新建会话', '当前DSH会话自己的远端恢复记录');
83
- if (agent.loginProvider === 'codex' && (!validContextDigest(pointer.contextSha256) || (pointer.previousRemoteSessions !== undefined && (!Array.isArray(pointer.previousRemoteSessions) || !pointer.previousRemoteSessions.every(ref => ref && typeof ref.sessionId === 'string' && ref.sessionId.length > 0 && validContextDigest(ref.contextSha256))))))
84
- throw new NeedError('SESSION_CONTEXT_DIGEST_INVALID', '原Codex会话的初建上下文摘要未知或无效,不能猜测是否相同,本轮未发送', '原会话合法的64位十六进制初建上下文摘要');
62
+ if (pointer?.version !== 2 || pointer.agent !== agent.id || pointer.dshSessionId !== options.sessionId || ![pointer.identity, pointer.cwd, pointer.sessionId].every(value => typeof value === 'string' && value.length > 0))
63
+ return;
85
64
  return pointer;
86
65
  }
87
66
  // A reporting failure can wrap the original admission error. Preserve its
@@ -110,17 +89,16 @@ export class CodexRemoteAdapter extends LlmAdapter {
110
89
  reportLoginFailure;
111
90
  directory;
112
91
  preflightDiagnostics;
92
+ permissions;
113
93
  config;
114
94
  sessions = new Map();
115
95
  turns = new Map();
116
96
  lifetime = new AbortController();
117
97
  shuttingDown = false;
118
98
  detached = new Set();
119
- cleanups = new WeakMap();
120
- preparationFailures = new Map();
121
99
  modelPresentation = new ModelPresentation();
122
100
  employeeNoticeSessions = new Set();
123
- constructor(config, resolveRuntime, approve, providerName = () => PROVIDER_NAMES.en, agent = CODEX_AGENT, readImage, reportLoginFailure, directory, preflightDiagnostics) {
101
+ constructor(config, resolveRuntime, approve, providerName = () => CODEX_AGENT.name.zh, agent = CODEX_AGENT, readImage, reportLoginFailure, directory, preflightDiagnostics, permissions) {
124
102
  super();
125
103
  this.resolveRuntime = resolveRuntime;
126
104
  this.approve = approve;
@@ -130,6 +108,7 @@ export class CodexRemoteAdapter extends LlmAdapter {
130
108
  this.reportLoginFailure = reportLoginFailure;
131
109
  this.directory = directory;
132
110
  this.preflightDiagnostics = preflightDiagnostics;
111
+ this.permissions = permissions;
133
112
  this.config = typeof config === 'function' ? config : () => config;
134
113
  }
135
114
  providerInfo(provider) { return { id: provider, name: this.providerName() }; }
@@ -142,112 +121,47 @@ export class CodexRemoteAdapter extends LlmAdapter {
142
121
  finally {
143
122
  catalog.release();
144
123
  } }
145
- async resolveModel(provider, id, signal) { try {
146
- signal?.throwIfAborted();
124
+ async resolveModel(provider, id, signal) {
147
125
  const displayed = this.modelPresentation.resolve(provider, id);
148
126
  if (displayed)
149
127
  return displayed;
150
- const catalog = await this.catalog(signal);
128
+ const active = signal ? AbortSignal.any([signal, this.lifetime.signal]) : this.lifetime.signal;
129
+ const catalog = await this.catalog(active);
151
130
  try {
152
- const model = catalog.models.find(model => model.id === id);
153
- return model ? modelInfo(provider, model) : unavailableModelInfo(provider, id);
131
+ active.throwIfAborted();
132
+ this.modelPresentation.remember(catalog);
133
+ const resolved = this.modelPresentation.resolve(provider, id);
134
+ if (!resolved)
135
+ throw new LlmError('所选模型当前不可用,请打开模型菜单重新选择。', 'MODEL_CATALOG_UNAVAILABLE');
136
+ return resolved;
154
137
  }
155
138
  finally {
156
139
  catalog.release();
157
140
  }
158
141
  }
159
- catch (error) {
160
- markModelPreparationFailure(provider, error, signal);
161
- throw error;
162
- } }
163
142
  async prepareCall(provider, id, signal) {
164
143
  try {
165
- const catalog = await this.catalog(signal);
166
- try {
167
- catalog.assertCurrent();
168
- const selected = catalog.models.find(model => model.id === id);
169
- if (!selected)
170
- throw new LlmError('所选模型已不在当前主机目录中,请在模型菜单重新选择后再发送;本次输入尚未发送。', 'MODEL_CATALOG_UNAVAILABLE');
171
- return { model: modelInfo(provider, selected), stream: (options) => this.stream(options) };
172
- }
173
- finally {
174
- catalog.release();
175
- }
144
+ return { model: await this.resolveModel(provider, id, signal), stream: (options) => this.stream(options) };
176
145
  }
177
146
  catch (error) {
178
147
  markModelPreparationFailure(provider, error, signal);
179
148
  throw error;
180
149
  }
181
150
  }
182
- observeModelInvalidation(fields, changes = []) { for (const live of this.sessions.values())
183
- live.runtime.closeDiagnostics?.record({ layer: 'adapter', initiator: 'catalog.signature-change', reason: 'MODEL_CATALOG_SIGNATURE_CHANGED', action: 'invalidate', changedFields: fields, changes }); }
184
- async invalidateModelState() { for (const [id, turn] of this.turns) {
185
- this.sessions.get(id)?.runtime.closeDiagnostics?.record({ layer: 'adapter', initiator: 'adapter.model-invalidation', reason: 'MODEL_STATE_INVALIDATED', action: 'abort', changes: [{ field: 'adapter.turn-aborted', before: turn.signal.aborted, after: true }] });
186
- turn.abort();
187
- } await Promise.all([...this.sessions.keys()].map(id => this.disposeSession(id))); }
188
- async invalidateEmployeeState() { for (const live of this.sessions.values())
189
- if (live.runtime.employeeExecutionMeta || live.runtime.execution)
190
- live.runtime.closeDiagnostics?.record({ layer: 'adapter', initiator: 'adapter.employee-invalidation', reason: 'EMPLOYEE_SERVICE_CHANGED', action: 'invalidate' }); await Promise.all([...this.sessions].filter(([, live]) => live.runtime.employeeExecutionMeta || live.runtime.execution).map(([id]) => this.disposeSession(id))); }
191
151
  providerRetryPolicy() { return { mode: 'normal', maxRetries: 0, retryableCodes: [], initialDelayMs: 0, maxDelayMs: 0, jitterRatio: 0 }; }
192
152
  cleanupRuntime(runtime, cause = { layer: 'adapter', initiator: 'adapter.cleanup-runtime', reason: 'RUNTIME_CLEANUP', action: 'dispose' }) {
193
153
  runtime.closeDiagnostics?.record(cause);
194
- const existing = this.cleanups.get(runtime);
195
- if (existing)
196
- return existing;
197
- const cleanup = Promise.resolve().then(() => runtime.dispose?.(cause)).catch(error => {
198
- const codes = closeErrorCodes(error, 'REMOTE_CLEANUP_UNCONFIRMED');
199
- const failure = closeFailure('REMOTE_CLEANUP_UNCONFIRMED', '原主机会话的清理结果未确认,不能继续或重开此会话', '管理员核对原主机会话;收尾代码 ' + codes.join(','), codes);
200
- for (const live of this.sessions.values())
201
- if (live.runtime === runtime) {
202
- live.cleanupFailure = failure;
203
- live.failure ??= { cause: failure };
204
- }
205
- throw failure;
206
- });
207
- this.cleanups.set(runtime, cleanup);
208
- return cleanup;
209
- }
210
- /** Publish an already known failure without coupling the official stream to cleanup. */
211
- finishFailedTurn(key, live, primary, code, firstReason) {
212
- withPendingCleanupEvidence(primary, firstReason, code);
213
- const runtime = live.runtime;
214
- const disposal = this.cleanupRuntime(runtime).then(() => {
215
- if (this.sessions.get(key) !== live || live.disposal !== disposal)
216
- return;
217
- live.disposal = undefined;
218
- if (!live.failure) {
219
- if (typeof runtime.dispose === 'function') {
220
- this.sessions.delete(key);
221
- this.preparationFailures.set(key, code);
222
- }
223
- else
224
- live.cleanupFailure = new NeedError('REMOTE_CLEANUP_UNCONFIRMED', '缺少原实例结束证明,不能重开或重发', '原实例的条件结束与list消失证明');
225
- }
226
- }, error => {
227
- if (this.sessions.get(key) === live && live.disposal === disposal)
228
- live.cleanupFailure = withFailureEvidence(primary, [error], 'cleanup', firstReason, code);
229
- throw error;
230
- });
231
- live.disposal = disposal;
232
- const report = (reason) => {
233
- runtime.closeDiagnostics?.record({ layer: 'adapter', initiator: 'adapter.failed-turn-cleanup', reason, action: 'dispose' });
234
- // State settles before reporting. A delayed D19 write is not another release gate.
235
- void Promise.resolve().then(() => runtime.details?.flush()).catch(error => runtime.closeDiagnostics?.record({ layer: 'adapter', initiator: 'adapter.failed-turn-cleanup-report', reason: closeCode(error, 'CLOSE_FLUSH_FAILED'), action: 'failure' }));
236
- };
237
- void disposal.then(() => report(typeof runtime.dispose === 'function' ? 'RUNTIME_CLEANUP_CONFIRMED' : 'REMOTE_CLEANUP_UNCONFIRMED'), () => report('REMOTE_CLEANUP_UNCONFIRMED'));
154
+ void Promise.resolve().then(() => runtime.dispose?.(cause)).catch(() => console.warn('[remote-agent] 旧主机会话暂未清理,将由主机自动回收'));
155
+ return Promise.resolve();
238
156
  }
239
157
  async disposeSession(sessionId) {
240
- this.sessions.get(sessionId)?.runtime.closeDiagnostics?.record({ layer: 'adapter', initiator: 'adapter.dispose-session', reason: 'SESSION_DISPOSED', action: 'abort' });
241
158
  this.turns.get(sessionId)?.abort();
242
159
  const current = this.sessions.get(sessionId);
243
160
  if (!current)
244
161
  return;
245
- if (current.disposal)
246
- return current.disposal;
162
+ this.sessions.delete(sessionId);
247
163
  current.session.close({ layer: 'adapter', initiator: 'adapter.dispose-session', reason: 'SESSION_DISPOSED', action: 'close' });
248
- current.disposal = this.cleanupRuntime(current.runtime).then(() => { if (this.sessions.get(sessionId) === current)
249
- this.sessions.delete(sessionId); });
250
- return current.disposal;
164
+ await this.cleanupRuntime(current.runtime);
251
165
  }
252
166
  async detachSession(sessionId) {
253
167
  const current = this.sessions.get(sessionId), turn = this.turns.get(sessionId);
@@ -286,11 +200,11 @@ export class CodexRemoteAdapter extends LlmAdapter {
286
200
  }
287
201
  catch (error) {
288
202
  if (error instanceof ModelConfigurationError)
289
- throw new LlmError(error.message + (failureEvidenceSummary(error) ? ';' + failureEvidenceSummary(error) : ''), error.code, { cause: error });
203
+ throw new LlmError(employeeFailure(error.code, this.agent.loginProvider), error.code, { cause: error });
290
204
  if (error instanceof NeedError) {
291
205
  const rpc = /RPC 错误码 (-?\d+)/u.exec(error.requires)?.[1];
292
206
  const evidence = failureEvidenceSummary(error);
293
- const message = (connectNeedMessage(error) ?? employeeFailure(error.code, this.agent.loginProvider)) + (rpc ? `(技术详情:RPC 错误码 ${rpc})` : '') + (evidence ? ';' + evidence : '');
207
+ const message = connectNeedMessage(error) ?? employeeFailure(error.code, this.agent.loginProvider);
294
208
  const login = originalLoginFailure(error);
295
209
  let diagnostic = login?.technical ? JSON.stringify(login.technical) : '';
296
210
  if (login?.technical && this.reportLoginFailure) {
@@ -302,11 +216,9 @@ export class CodexRemoteAdapter extends LlmAdapter {
302
216
  diagnostic += ';诊断保存失败:' + (failure instanceof NeedError ? failure.code : 'DIAGNOSTIC_WRITE_FAILED');
303
217
  }
304
218
  }
305
- throw new LlmError(message, error.code, { cause: new Error(`诊断代码:${error.code};${error.requires}` + (diagnostic ? ';' + diagnostic : '')) });
219
+ throw new LlmError(message, error.code, { cause: new Error(`诊断代码:${error.code};${error.requires}` + (evidence ? ';' + evidence : '') + (diagnostic ? ';' + diagnostic : '')) });
306
220
  }
307
- if (error instanceof LlmError && failureEvidenceSummary(error))
308
- throw new LlmError(error.failure.message + ';' + failureEvidenceSummary(error), error.code, { cause: error });
309
- throw error;
221
+ throw new LlmError(employeeFailure('TURN_FAILED', this.agent.loginProvider), 'TURN_FAILED', { cause: error });
310
222
  }
311
223
  }
312
224
  async *generate(original) {
@@ -322,7 +234,6 @@ export class CodexRemoteAdapter extends LlmAdapter {
322
234
  this.turns.set(key, turn);
323
235
  const signal = AbortSignal.any([this.lifetime.signal, turn.signal, ...(original.signal ? [original.signal] : [])]);
324
236
  const options = { ...original, signal };
325
- const timing = new StageTiming();
326
237
  const abortOffs = [];
327
238
  let request;
328
239
  let earlyStage = 'input-prepare';
@@ -335,18 +246,19 @@ export class CodexRemoteAdapter extends LlmAdapter {
335
246
  let failureCode;
336
247
  let preserveRecoveredRuntime = false;
337
248
  let activeMapper;
338
- let catalog;
339
249
  let selected;
340
250
  let modelConfiguration;
341
251
  let turnFailure;
342
252
  let employeeNoticeShown = false;
253
+ let permissionOff;
254
+ let permissionEpoch = new AbortController();
343
255
  const information = [];
344
- const recordInformation = () => { timing.summarize(); if (runtime?.details)
256
+ const recordInformation = () => { if (runtime?.details)
345
257
  for (const entry of information.splice(0))
346
258
  runtime.details.record(entry); };
347
259
  const config = this.config();
348
- const mode = this.agent.oneShot ? config.mode : 'session';
349
- request = { sessionId: options.sessionId, mode: mode ?? 'session', timing, signal };
260
+ const mode = 'session';
261
+ request = { sessionId: options.sessionId, mode: mode ?? 'session', signal };
350
262
  const observeAbort = (source, initiator, reason) => { if (!source)
351
263
  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)); };
352
264
  observeAbort(original.signal, 'signal.caller', 'CALLER_ABORTED');
@@ -356,93 +268,48 @@ export class CodexRemoteAdapter extends LlmAdapter {
356
268
  if (this.preflightDiagnostics)
357
269
  request.recordEarlyFailure = (error, stage) => this.preflightDiagnostics.failure(request, this.agent.loginProvider, error, stage);
358
270
  try {
359
- if (!mode || (mode === 'session' && (!Number.isSafeInteger(config.acpMaxMessageBytes) || config.acpMaxMessageBytes < 1)))
360
- throw new NeedError('SETTINGS_REQUIRED', '远程模型连接设置无效,请联系管理员检查', '有效的mode/acpMaxMessageBytes');
271
+ if (!Number.isSafeInteger(config.acpMaxMessageBytes) || config.acpMaxMessageBytes < 1)
272
+ throw new NeedError('SETTINGS_REQUIRED', '远程模型连接设置无效,请联系管理员检查', '有效的acpMaxMessageBytes');
361
273
  const previous = readPointer(options, this.agent);
362
- employeeNoticeShown = !!key && this.employeeNoticeSessions.has(key) || employeeNoticePreviouslyShown(options);
274
+ employeeNoticeShown = !!key && this.employeeNoticeSessions.has(key) || false;
363
275
  if (employeeNoticeShown && key)
364
276
  this.employeeNoticeSessions.add(key);
365
- const last = options.messages.findLast(message => message.role === 'assistant' && !isWorkspaceNoticeMessage(message));
277
+ const last = options.messages.findLast(message => message.role === 'assistant');
366
278
  const missingPointer = !!last && !previous;
367
- let requireFreshSession = missingPointer || !!key && this.preparationFailures.has(key);
368
- if (previous && previous.mode !== mode)
369
- throw new NeedError('SESSION_CONTEXT_MISMATCH', '运行模式与原会话不一致,请新建会话', '原会话模式');
370
- if (previous && !previous.modelConfiguration)
371
- information.push({ kind: 'session', severity: 'info', code: 'TURN_GATE_INFORMATION', summary: '历史回复没有模型配置资料;本轮会重新确认你当前选择的模型。', payload: { reason: 'HISTORICAL_MODEL_CONFIGURATION_ABSENT' } });
372
- live = key ? this.sessions.get(key) : undefined;
373
- if (live?.disposal || live?.cleanupFailure)
374
- throw live.cleanupFailure ?? new NeedError('REMOTE_CLEANUP_UNCONFIRMED', '原主机会话正在结束,清理结果尚未确认,不能继续或重开此会话', '管理员核对原主机会话清理结果');
375
- if (live?.failure)
376
- throw new NeedError('INPUT_DELIVERY_UNCONFIRMED', '上一轮输入已经交给远端,但结果未确认,不能自动重发', '核对已发送输入及原主机会话状态');
377
- if (live && !previous) {
378
- if (typeof live.runtime.dispose !== 'function')
379
- throw new NeedError('REMOTE_CLEANUP_UNCONFIRMED', '原会话不能确认已安全结束,本轮尚未发送', '可确认结束原实例的通道');
380
- live.session.close({ layer: 'adapter', initiator: 'adapter.missing-pointer', reason: 'PRIVATE_RECOVERY_POINTER_ABSENT', action: 'close' });
381
- await this.cleanupRuntime(live.runtime, { layer: 'adapter', initiator: 'adapter.missing-pointer', reason: 'PRIVATE_RECOVERY_POINTER_ABSENT', action: 'dispose' });
382
- if (key && this.sessions.get(key) === live)
383
- this.sessions.delete(key);
384
- live = undefined;
385
- requireFreshSession = true;
386
- }
387
- const configuration = JSON.stringify([mode, config.acpMaxMessageBytes, config.brokerMaxFrameBytes, config.brokerControlMaxBytes, config.employeeShellKillGraceMs]);
388
- if (live && live.configuration !== configuration)
389
- throw new NeedError('SESSION_CONTEXT_MISMATCH', '会话运行设置已改变,请新建会话', '原会话运行设置');
390
- Object.assign(request, { timing, employeeHttpMcpPolicy: config.employeeHttpMcpPolicy ?? DEFAULT_CONFIG.employeeHttpMcpPolicy, acpMaxMessageBytes: config.acpMaxMessageBytes, resumeSessionId: previous?.sessionId, recoveryCheckpointId: previous?.recoveryCheckpointId, sessionId: options.sessionId, mode, cwd: previous?.cwd, identity: previous?.identity, bindingId: previous?.bindingId, workspaceId: previous?.workspaceId, agent: this.agent, retainProcess: !!key && mode === 'session', signal, brokerSettings: Object.freeze({ brokerMaxFrameBytes: config.brokerMaxFrameBytes, brokerControlMaxBytes: config.brokerControlMaxBytes }) });
391
- if (requireFreshSession) {
392
- request.requireFreshSession = true;
393
- request.resumeSessionId = undefined;
394
- request.recoveryCheckpointId = undefined;
395
- information.push({ kind: 'session', severity: 'warning', code: 'TURN_GATE_INFORMATION', summary: '缺少可用的旧会话恢复资料,或上次在发送前已失败;将核对旧实例安全结束后,只处理本次接纳的输入。', payload: { reason: missingPointer ? 'PRIVATE_RECOVERY_POINTER_ABSENT' : 'PRIOR_PREPARATION_FAILED' } });
396
- }
279
+ live = undefined;
280
+ 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 }) });
397
282
  request.previousContextSha256 = previous?.contextSha256;
398
- const after = options.messages.findLastIndex(message => message.role === 'assistant' && !isWorkspaceNoticeMessage(message));
399
- request.userInputIds = Object.freeze(options.messages.slice(after + 1).flatMap(message => message.role === 'user' && message.source?.kind === 'user' && typeof message.id === 'string' ? [message.id] : []));
283
+ const after = options.messages.findLastIndex(message => message.role === 'assistant');
284
+ const tail = options.messages.slice(after + 1);
285
+ const humanIds = tail.flatMap(message => message.role === 'user' && message.source?.kind === 'user' && typeof message.id === 'string' ? [message.id] : []);
286
+ // An official approval may continue the same native turn without another
287
+ // human message. This is a CAS candidate only: live turn/L proof must still
288
+ // bind it. The actual input delta remains prepareInput's unchanged tail.
289
+ if (!humanIds.length && tail.some(message => message.role === 'user' && message.source?.kind === 'user-approval')) {
290
+ const original = options.messages.slice(0, after + 1).findLast(message => message.role === 'user' && message.source?.kind === 'user' && typeof message.id === 'string');
291
+ if (typeof original?.id === 'string')
292
+ humanIds.push(original.id);
293
+ }
294
+ request.userInputIds = Object.freeze(humanIds);
400
295
  request.userMessageIds = request.userInputIds;
401
296
  earlyStage = 'source-context';
402
297
  await this.resolveRuntime.preflight?.(request);
403
298
  earlyStage = 'input-prepare';
404
- let input = await timing.run('input-prepare', () => prepareInput(options, this.readImage));
299
+ let input = await prepareInput(options, this.readImage);
405
300
  const contextSha256 = createHash('sha256').update(input.systemPrompt ?? '').digest('hex');
406
301
  request.contextSha256 = contextSha256;
407
302
  const codex = this.agent.loginProvider === 'codex';
408
- if (codex && live && previous && live.contextSha256 !== previous.contextSha256.toLowerCase())
409
- throw new NeedError('SESSION_CONTEXT_DIGEST_INVALID', '持久上下文摘要与当前远端会话的初建摘要不一致,本轮未发送', '原会话一致的初建上下文摘要');
410
- const contextChanged = !!previous && codex && previous.contextSha256.toLowerCase() !== contextSha256;
303
+ const contextChanged = !!previous && codex && (previous.contextSha256 ?? '').toLowerCase() !== contextSha256;
411
304
  // Only identical durable context can prove a legacy context-bound journal.
412
305
  // New Codex transport scopes are independent of the native context digest.
413
306
  if (contextChanged)
414
307
  request.contextSha256 = undefined;
415
308
  const claudeContextChanged = !!previous && !codex && previous.contextSha256 !== contextSha256;
416
- if (contextChanged)
417
- input = await prepareContinuation(options, input, this.readImage);
418
- if (live) {
419
- if (contextChanged)
420
- live.session.validateNewSessionContext({ systemPrompt: input.systemPrompt });
421
- else
422
- live.session.validateContext({ systemPrompt: input.systemPrompt });
423
- live.session.validatePrompt(input.prompt);
424
- }
425
- if (mode === 'one-shot' && (input.systemPrompt || input.prompt.some(block => block.type !== 'text')))
426
- throw new NeedError('ONESHOT_CONTEXT_REQUIRED', '首版单次模式不支持图片或系统/开发者上下文,请使用会话模式', 'Codex会话模式');
427
309
  if (mode === 'session' && Buffer.byteLength(JSON.stringify({ prompt: input.prompt, systemPrompt: input.systemPrompt }), 'utf8') > config.acpMaxMessageBytes)
428
310
  throw new NeedError('ACP_MESSAGE_TOO_LARGE', '本轮输入超过已设置的会话消息容量,未截断或发送', '管理员调整acpMaxMessageBytes');
429
- if (mode === 'one-shot')
430
- throw new NeedError('MODEL_CONFIGURATION_UNSUPPORTED', '单次模式不支持确认当前模型与推理等级,请改用会话模式', '支持模型配置确认的会话模式');
431
- request.localAdmissionCheck?.();
432
- earlyStage = 'model-catalog';
433
- catalog = await this.catalog(signal);
434
- selected = catalog.models.find(model => model.id === options.model);
435
- catalog.onInformation?.(entry => information.push(entry));
436
- if (!selected)
437
- throw new ModelConfigurationError('所选模型不在本次可信主机目录中,请重新选择模型');
438
- if (options.reasoningEffort !== undefined && !selected.reasoning.options.some(option => option.value === options.reasoningEffort))
439
- throw new ModelConfigurationError('所选推理等级不在本次模型目录中');
440
- catalog.bindSelection?.(selected.id, options.reasoningEffort);
441
- const observeSignal = (source, initiator, reason) => { const observed = () => { const diagnostic = request?.closeDiagnostics ?? runtime?.closeDiagnostics; if (diagnostic)
442
- diagnostic.record({ layer: 'adapter', initiator, reason: catalog?.signal === source ? catalog.revocation?.()?.reason ?? reason : reason, action: 'abort', changedFields: catalog?.signal === source ? catalog.revocation?.()?.fields : undefined, changes: catalog?.signal === source ? catalog.revocation?.()?.changes ?? [{ field: 'adapter.turn-aborted', before: false, after: source.aborted }] : [{ field: 'adapter.turn-aborted', before: false, after: source.aborted }] }); }; source.addEventListener('abort', observed, { once: true }); abortOffs.push(() => source.removeEventListener('abort', observed)); };
443
- observeSignal(catalog.signal, 'signal.model-catalog', 'MODEL_CATALOG_REVOKED');
444
- observeSignal(signal, 'signal.turn-composite', 'ABORTED');
445
- let modelSignal = AbortSignal.any([signal, catalog.signal]);
311
+ selected = this.modelPresentation.find(options.model) ?? { id: options.model, value: options.model, name: options.model, resolvedModel: null, reasoning: { options: [], defaultValue: null, defaultKnown: false }, source: null };
312
+ let modelSignal = signal;
446
313
  options.signal = modelSignal;
447
314
  request.signal = modelSignal;
448
315
  earlyStage = 'runtime-resolve';
@@ -450,8 +317,6 @@ export class CodexRemoteAdapter extends LlmAdapter {
450
317
  const closeSink = runtime.details;
451
318
  runtime.closeDiagnostics ??= new CloseDiagnostics(event => closeSink?.record(event));
452
319
  signal.throwIfAborted();
453
- if (requireFreshSession && (!runtime.freshSessionConfirmed || !runtime.restartNotice))
454
- throw new NeedError('REMOTE_CLEANUP_UNCONFIRMED', '未确认原实例安全结束或原位置为空,本轮没有发送', '同UUID条件结束与list消失证明,或原slot不存在证明');
455
320
  if (runtime.execution?.signal) {
456
321
  modelSignal = AbortSignal.any([modelSignal, runtime.execution.signal]);
457
322
  options.signal = modelSignal;
@@ -464,42 +329,17 @@ export class CodexRemoteAdapter extends LlmAdapter {
464
329
  request.signal = modelSignal;
465
330
  modelSignal.throwIfAborted();
466
331
  }
467
- catalog.assertCurrent();
468
- if (runtime.connection && (runtime.connection.instanceId !== catalog.target.instanceId || runtime.connection.epoch !== catalog.target.epoch) || runtime.identity !== `${catalog.target.target.username}@${catalog.target.target.host}:${catalog.target.target.port}`)
469
- throw new ModelConfigurationError('模型目录与当前会话可信员工主机不一致');
470
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))
471
333
  throw new NeedError('SESSION_CONTEXT_MISMATCH', '原会话的员工主机或工作区已改变,请新建会话', '原会话的可信目标和工作区');
472
- if (contextChanged && runtime.recoveredSession) {
473
- const capabilities = runtime.recoveredSession.capabilities;
474
- // A clean reattach has sent no ACP input. A known policy refusal must
475
- // detach it, preserving the original native session and checkpoint.
476
- if (!capabilities.sessionCapabilities?.systemPrompt || !supportsCodexContextPolicy(capabilities)) {
477
- preserveRecoveredRuntime = true;
478
- if (!capabilities.sessionCapabilities?.systemPrompt)
479
- throw new NeedError('SYSTEM_PROMPT_UNSUPPORTED', '远端未声明系统与开发者上下文能力,不能丢弃这些要求继续', '支持 systemPrompt 的公开 ACP 能力');
480
- throw new NeedError('SYSTEM_PROMPT_POLICY_UNSUPPORTED', '远端没有明确声明Codex上下文仅新建生效的兼容政策,本轮未发送', 'systemPrompt与精确的aiWorkbenchSystemPrompt新建政策');
481
- }
482
- }
483
- if (runtime.restartNotice) {
484
- const ids = runtime.restartInputIds;
485
- if (!ids?.length || new Set(ids).size !== ids.length || ids.some(id => input.users.filter(u => u.id === id).length !== 1))
486
- throw new NeedError('CURRENT_INPUT_UNCONFIRMED', '无法核对本次输入,尚未发送给模型', '官方当前步骤的已接纳用户输入');
487
- input = { ...input, prompt: ids.flatMap(id => input.users.find(u => u.id === id).prompt) };
488
- if (contextChanged)
489
- input = await timing.run('input-prepare', () => prepareContinuation(options, input, this.readImage));
490
- }
491
334
  input = projectHostWorkspaceInput(input, runtime.hostWorkspace);
492
335
  if (runtime.hostWorkspace && mode === 'session' && Buffer.byteLength(JSON.stringify({ prompt: input.prompt, systemPrompt: input.systemPrompt }), 'utf8') > config.acpMaxMessageBytes)
493
336
  throw new NeedError('ACP_MESSAGE_TOO_LARGE', '本轮输入超过已设置的会话消息容量,未截断或发送', '管理员调整acpMaxMessageBytes');
494
- const details = runtime.details;
495
- if (!details)
496
- throw new NeedError('TECHNICAL_DETAILS_UNAVAILABLE', '技术详情暂不可用,请更新远程主机连接组件。', 'L技术详情持久接收能力');
337
+ 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 });
497
338
  recordInformation();
498
- timing.bind(detail => details.record(detail));
499
339
  request.recordEarlyFailure = undefined;
500
340
  if (runtime.hostWorkspace)
501
341
  details.record({ kind: 'session', severity: 'info', code: 'HOST_WORKSPACE_CONTEXT', summary: '本轮模型工作区上下文及审批会话目录使用已确认的主机目录。', payload: { source: 'host', cwd: runtime.hostWorkspace.cwd } });
502
- const mapper = new StreamMapper(mode === 'session' && !contextChanged ? (runtime.restartNotice ? undefined : live ? live.usageSnapshot : previous ? (previous.usageSnapshot ?? null) : undefined) : undefined, observation => details.record(observation));
342
+ const mapper = new StreamMapper(mode === 'session' && !contextChanged ? (previous ? (previous.usageSnapshot ?? null) : undefined) : undefined, observation => details.record(observation));
503
343
  activeMapper = mapper;
504
344
  let id;
505
345
  let stop;
@@ -518,17 +358,7 @@ export class CodexRemoteAdapter extends LlmAdapter {
518
358
  }
519
359
  yield* mapper.flush();
520
360
  }
521
- if (runtime.workspaceNotice) {
522
- yield* mapper.text(runtime.workspaceNotice.text);
523
- yield* mapper.flush();
524
- }
525
- if (runtime.restartNotice) {
526
- yield* mapper.text(runtime.restartNotice + '\n\n');
527
- yield* mapper.flush();
528
- }
529
361
  if (mode === 'session') {
530
- if (live && live.runtime !== runtime)
531
- throw new NeedError('SESSION_TRANSPORT_MISMATCH', '原会话的主机通道已更换,不能自动切换进程', '原主机会话通道');
532
362
  if (!runtime.acpTransport)
533
363
  throw new NeedError('DEPENDENCY_REQUIRED', '我需要该员工主机上的ACP通道', 'S提供的ACP命令与aw-session');
534
364
  const queue = [];
@@ -536,7 +366,15 @@ export class CodexRemoteAdapter extends LlmAdapter {
536
366
  let done = false;
537
367
  let failure;
538
368
  const push = (chunks) => { queue.push(...chunks); wake?.(); wake = undefined; };
539
- const hooks = { closeDiagnostics: runtime.closeDiagnostics, broker: runtime.broker, employeeExecution: runtime.employeeExecution, timing, approve: async (permission, approvalSignal) => {
369
+ let promptPermit;
370
+ const hooks = { closeDiagnostics: runtime.closeDiagnostics, broker: runtime.broker, employeeExecution: runtime.employeeExecution, ...this.permissions ? { promptGuard: () => {
371
+ if (!promptPermit)
372
+ throw new NeedError('PERMISSION_POLICY_CHANGED', '当前权限没有保持确认,本次没有继续派发。', '当前权限代次和同值回执');
373
+ return promptPermit;
374
+ }, approvalGuard: () => {
375
+ const source = this.permissions, epoch = permissionEpoch, policy = JSON.stringify(source.read(options.sessionId));
376
+ return { signal: epoch.signal, isCurrent: () => !epoch.signal.aborted && JSON.stringify(source.read(options.sessionId)) === policy };
377
+ } } : {}, approve: async (permission, approvalSignal) => {
540
378
  const execution = runtime.execution;
541
379
  const toolName = permission.toolCall.name ?? permission.toolCall._meta?.claudeCode?.toolName;
542
380
  if (execution) {
@@ -545,11 +383,8 @@ export class CodexRemoteAdapter extends LlmAdapter {
545
383
  push(mapper.text('\n这类操作尚不支持在本机执行,已取消本次审批,没有在主机代跑。\n'));
546
384
  return 'cancelled';
547
385
  }
548
- await execution.validate(approvalSignal);
549
386
  }
550
387
  const answer = await this.approve?.(permission, approvalSignal, options, execution && toolName?.startsWith('mcp__employee__') ? 'employee-local' : undefined, runtime.hostWorkspace) ?? 'unavailable';
551
- if (execution && answer === 'allowed-once')
552
- await execution.validate(approvalSignal);
553
388
  return answer;
554
389
  }, onEmployeeNotice: notice => push(mapper.text(notice + '\n\n')), onUpdate: notification => { try {
555
390
  push(mapper.acp(notification.update));
@@ -558,86 +393,110 @@ export class CodexRemoteAdapter extends LlmAdapter {
558
393
  failure = error;
559
394
  session?.close({ layer: 'adapter', initiator: 'adapter.mapper-failure', reason: closeCode(error, 'MAPPING_FAILED'), action: 'close' });
560
395
  } }, onResponse: response => push(mapper.acpResponse(response)), onDetail: detail => details.record(detail) };
561
- const fresh = !live;
562
- if (fresh) {
563
- const transport = runtime.acpTransport instanceof acp.AgentApp || runtime.acpTransport instanceof unstable.AgentApp ? runtime.acpTransport : acp.ndJsonStream(runtime.acpTransport.writable, runtime.acpTransport.readable, { maxMessageBytes: config.acpMaxMessageBytes });
564
- session = new AcpSession(transport, hooks);
565
- live = { session, runtime, configuration, contextSha256, usageSnapshot: mapper.usageSnapshot };
566
- if (key)
567
- this.sessions.set(key, live);
568
- }
569
- else {
570
- session = live.session;
571
- session.setHooks(hooks);
572
- if (previous?.sessionId !== session.id)
573
- throw new NeedError('SESSION_STATE_INVALID', '持久会话标识与当前进程不一致', '原会话的恢复标识');
574
- }
396
+ const transport = runtime.acpTransport instanceof acp.AgentApp || runtime.acpTransport instanceof unstable.AgentApp ? runtime.acpTransport : acp.ndJsonStream(runtime.acpTransport.writable, runtime.acpTransport.readable, { maxMessageBytes: config.acpMaxMessageBytes });
397
+ session = new AcpSession(transport, hooks);
398
+ live = { session, runtime, configuration, contextSha256, usageSnapshot: mapper.usageSnapshot };
399
+ if (key)
400
+ this.sessions.set(key, live);
575
401
  promptBaseline = session.promptDispatchCount;
402
+ let permissionTail = Promise.resolve(), permissionKey, observedPermissionKey, permissionQueued = false;
403
+ const permissionFailed = (error) => {
404
+ failure ??= error;
405
+ runtime.closeDiagnostics?.record({ layer: 'adapter', initiator: 'adapter.permission-sync.failure', reason: closeCode(error, 'PERMISSION_MODE_UNCONFIRMED'), action: 'close' });
406
+ session.close({ layer: 'adapter', initiator: 'adapter.permission-sync.failure', reason: closeCode(error, 'PERMISSION_MODE_UNCONFIRMED'), action: 'close' });
407
+ wake?.();
408
+ };
409
+ const syncPermissions = () => {
410
+ if (!this.permissions)
411
+ return Promise.resolve(); // Explicit component-test port; production always supplies the official source.
412
+ return permissionTail = permissionTail.then(async () => {
413
+ const policy = this.permissions.read(options.sessionId), next = JSON.stringify(policy);
414
+ if (next === permissionKey)
415
+ return;
416
+ const remoteMode = permissionMode(this.agent.loginProvider, policy);
417
+ details.record({ kind: 'session', severity: 'info', code: 'DSH_PERMISSION_POLICY', summary: '按DSH当前会话权限同步远程工具;不会把关闭审批提示解释为自动批准。', payload: { ...policy, remoteMode } });
418
+ await session.selectPermissionMode(remoteMode, modelSignal, this.agent.loginProvider);
419
+ permissionKey = next;
420
+ });
421
+ };
422
+ const dispatchPrompt = async () => {
423
+ if (!this.permissions)
424
+ return session.prompt(input.prompt, modelSignal);
425
+ // New official policy events can append work while an ACK is awaited.
426
+ // Setters for those distinct policy changes are not retries. Dispatch
427
+ // only from one synchronous, stable epoch/ACK boundary.
428
+ for (;;) {
429
+ const barrier = syncPermissions();
430
+ await barrier;
431
+ if (barrier !== permissionTail || permissionQueued)
432
+ continue;
433
+ const source = this.permissions, policy = JSON.stringify(source.read(options.sessionId));
434
+ if (policy !== permissionKey)
435
+ continue;
436
+ const epoch = permissionEpoch;
437
+ promptPermit = { signal: epoch.signal, isCurrent: () => !epoch.signal.aborted && permissionKey === policy && JSON.stringify(source.read(options.sessionId)) === policy };
438
+ return session.prompt(input.prompt, modelSignal);
439
+ }
440
+ };
576
441
  const task = (async () => {
577
442
  remoteAttempted = true;
578
443
  try {
579
- if (fresh && runtime.recoveredSession) {
580
- if (previous && runtime.recoveredSession.sessionId !== previous.sessionId)
581
- throw new NeedError('SESSION_STATE_INVALID', '已接回的会话与持久标识不一致,本轮未发送', '原会话的恢复标识');
582
- session.restore(runtime.execution?.hostCwd ?? runtime.cwd, runtime.recoveredSession, { systemPrompt: input.systemPrompt, ...(runtime.execution ? { employeeRouting: runtime.execution.routing } : {}), ...(codex ? { codexNewSessionOnly: true } : {}) }, codex && !!previous && !contextChanged);
583
- session.validatePrompt(input.prompt);
584
- if (contextChanged)
585
- await session.newContextSession({ systemPrompt: input.systemPrompt }, modelSignal);
586
- else if (claudeContextChanged)
587
- await session.updateContext({ systemPrompt: input.systemPrompt }, modelSignal, true);
588
- }
589
- else if (fresh)
590
- await session.start(runtime.execution?.hostCwd ?? runtime.cwd, contextChanged || runtime.restartNotice ? undefined : previous?.sessionId, modelSignal, { systemPrompt: contextChanged ? (input.systemPrompt ?? '') : input.systemPrompt, ...(runtime.execution ? { employeeRouting: runtime.execution.routing } : {}), ...(codex ? { codexNewSessionOnly: true } : {}), ...(runtime.employeeExecutionMeta ? { employeeExecution: runtime.employeeExecutionMeta } : {}) }, input.prompt);
591
- else if (contextChanged)
592
- await session.newContextSession({ systemPrompt: input.systemPrompt }, modelSignal);
593
- else {
594
- await session.updateContext({ systemPrompt: input.systemPrompt }, modelSignal);
595
- }
596
- if (fresh && claudeContextChanged && !runtime.recoveredSession && !runtime.restartNotice)
597
- details.record({ kind: 'session', severity: 'warning', code: 'SYSTEM_PROMPT_UPDATE_DEFERRED', summary: '新要求已通过原会话恢复接口传送;官方仅承诺下次上下文整理后生效,本轮不能保证已立即采用。', payload: { reason: 'OFFICIAL_CONTEXT_UPDATE_DEFERRED' } });
598
- if (!codex)
599
- live.contextSha256 = contextSha256;
600
- if (contextChanged) {
601
- if (session.id === previous.sessionId)
602
- throw new NeedError('SESSION_STATE_INVALID', '新会话标识与原会话相同,无法确认独立承接,本轮未发送', '独立的新远端会话标识');
603
- live.contextSha256 = contextSha256;
604
- live.usageSnapshot = mapper.usageSnapshot;
605
- details.record({ kind: 'session', severity: 'info', code: 'CODEX_CONTEXT_SESSION_REPLACED', summary: 'Codex要求仅在新建生效,已保留原会话;新会话承接可见用户及助手正文,内部推理不转发', payload: { reason: 'system-context-changed', oldSessionId: previous.sessionId, newSessionId: session.id, oldContextSha256: previous.contextSha256, newContextSha256: contextSha256, history: input.historyTransfer } });
606
- }
607
- catalog.assertCurrent();
444
+ 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);
608
445
  const actual = await session.selectModel(selected.value, options.reasoningEffort, modelSignal);
609
- catalog.assertCurrent();
610
- session.assertModelSelection(selected.value, options.reasoningEffort);
611
446
  // This lease is tied to the trusted host-user and instance/epoch.
612
447
  // Its mapping and this turn's exact setter ACK are different facts.
613
- const mapping = catalog.models.filter(model => model.value === selected.value);
614
- if (mapping.length !== 1 || mapping[0].id !== selected.id)
615
- throw new NeedError('MODEL_RESOLUTION_UNCONFIRMED', '所选模型在当前主机目录中没有唯一对应型号,本轮未发送', 'CATALOG_VALUE_MAPPING_NOT_UNIQUE');
616
- const resolved = mapping[0].resolvedModel;
617
- if (typeof resolved !== 'string' || !resolved.trim())
618
- throw new NeedError('MODEL_RESOLUTION_UNCONFIRMED', '主机目录没有提供所选模型的具体型号,本轮未发送', 'CATALOG_RESOLVED_MODEL_MISSING');
619
- modelConfiguration = { selected: { ...mapping[0], ...(options.reasoningEffort === undefined ? {} : { effort: options.reasoningEffort }) }, configured: actual, resolvedModel: resolved, status: 'remote-configured', ...(this.agent.loginProvider === 'claude' ? { resolvedModelSource: 'host-user-catalog', sessionModelReported: false } : {}) };
620
- details.record({ kind: 'session', severity: 'info', code: 'MODEL_CONFIGURATION_CONFIRMED', summary: '本轮选择已由远端配置回执确认;' + modelResolutionLabel(modelConfiguration) + ';不代表推理效果已验证', payload: modelConfiguration });
448
+ modelConfiguration = { selected: { ...selected, ...(options.reasoningEffort === undefined ? {} : { effort: options.reasoningEffort }) }, configured: actual, resolvedModel: selected.resolvedModel ?? selected.value, status: 'remote-configured', ...(this.agent.loginProvider === 'claude' ? { resolvedModelSource: 'host-user-catalog', sessionModelReported: false } : {}) };
449
+ details.record({ kind: 'session', severity: 'info', code: 'MODEL_CONFIGURATION_CONFIRMED', summary: '本轮模型设置回应;' + modelResolutionLabel(modelConfiguration) + ';不代表推理效果已验证', payload: modelConfiguration });
621
450
  }
622
451
  catch (error) {
623
452
  details.record({ kind: 'session', severity: 'action-required', code: 'MODEL_CONFIGURATION_FAILED', summary: '本轮模型配置未获完整确认,未发送用户输入', payload: { selected, requestedEffort: options.reasoningEffort ?? null, actual: session.modelConfiguration ?? null, reason: error instanceof NeedError ? (error.code === 'MODEL_RESOLUTION_UNCONFIRMED' ? error.requires : error.code) : error instanceof ModelConfigurationError ? error.message : 'MODEL_CONFIGURATION_FAILED' } });
624
453
  throw error;
625
454
  }
626
- await timing.run('details-flush', () => details.flush());
455
+ if (this.permissions && options.sessionId) {
456
+ observedPermissionKey = JSON.stringify(this.permissions.read(options.sessionId));
457
+ permissionOff = this.permissions.subscribe(options.sessionId, () => {
458
+ let next;
459
+ try {
460
+ next = JSON.stringify(this.permissions.read(options.sessionId));
461
+ }
462
+ catch (error) {
463
+ permissionEpoch.abort();
464
+ permissionFailed(error);
465
+ return;
466
+ }
467
+ if (next === observedPermissionKey) {
468
+ details.record({ kind: 'session', severity: 'info', code: 'DSH_PERMISSION_EVENT_UNCHANGED', summary: '设置事件没有改变有效权限,原审批与执行许可保持有效。' });
469
+ return;
470
+ }
471
+ // Compare observed values, not the possibly lagging remote ACK.
472
+ // A real A→B→A still revokes the original permit twice.
473
+ observedPermissionKey = next;
474
+ permissionEpoch.abort();
475
+ permissionEpoch = new AbortController();
476
+ // One official preset writes identity and two knobs synchronously.
477
+ // Read the complete committed bundle after that append stack.
478
+ if (permissionQueued)
479
+ return;
480
+ permissionQueued = true;
481
+ queueMicrotask(() => { permissionQueued = false; if (!permissionOff)
482
+ return; void syncPermissions().catch(permissionFailed); });
483
+ });
484
+ await syncPermissions();
485
+ }
627
486
  recordInformation();
628
- await details.flush();
629
487
  details.beginTurn(runtime.detailContext?.() ?? { turn: null, step: null });
630
- catalog.assertCurrent();
631
- session.assertModelSelection(selected.value, options.reasoningEffort);
632
488
  await runtime.execution?.beginTurn(modelSignal);
633
489
  id = session.id;
634
490
  try {
635
- const result = await session.prompt(input.prompt, modelSignal);
636
- if (contextChanged)
637
- push(mapper.text(runtime.restartNotice ? '\n\n会话要求已变化,已把此前可见对话带入新 Codex 会话继续处理。' : '\n\n会话要求已变化,已新建 Codex 会话继续处理,原会话仍保留。'));
491
+ const result = await dispatchPrompt();
492
+ permissionOff?.();
493
+ permissionOff = undefined;
494
+ await permissionTail;
638
495
  return result;
639
496
  }
640
497
  finally {
498
+ permissionOff?.();
499
+ permissionOff = undefined;
641
500
  runtime.execution?.endTurn();
642
501
  details.endTurn();
643
502
  }
@@ -657,23 +516,9 @@ export class CodexRemoteAdapter extends LlmAdapter {
657
516
  for (const chunk of mapper.text('\n[远端拒绝] 此请求未执行。\n'))
658
517
  yield chunk;
659
518
  }
660
- else {
661
- if (!runtime.oneShot)
662
- throw new NeedError('DEPENDENCY_REQUIRED', '我需要主机单次运行助手', 'S安装的aw-codex-once');
663
- details.beginTurn(runtime.detailContext?.() ?? { turn: null, step: null });
664
- const text = input.prompt.map(block => block.type === 'text' ? block.text : '').join('\n');
665
- for await (const event of runOneShot(runtime.oneShot, { wrapperPath: fileURLToPath(new URL('./sdk-ssh-wrapper.js', import.meta.url)), sandboxMode: config.oneShotSandbox, approvalPolicy: config.oneShotApproval }, text, previous?.sessionId, signal)) {
666
- if (event.type === 'thread.started')
667
- id = event.thread_id;
668
- for (const chunk of mapper.sdk(event))
669
- yield chunk;
670
- }
671
- }
672
519
  mapper.flushMetadata();
673
520
  recordInformation();
674
521
  details.endTurn();
675
- await timing.run('details-flush', () => details.flush());
676
- await details.flush(); // The timing record is a new ordered write, not a retry.
677
522
  if (!id)
678
523
  throw new NeedError('SESSION_ID_MISSING', '远端没有返回会话标识', '有效的远端会话标识');
679
524
  if (live)
@@ -681,22 +526,12 @@ export class CodexRemoteAdapter extends LlmAdapter {
681
526
  if (modelConfiguration) {
682
527
  const actual = session.modelConfiguration;
683
528
  const changed = !actual?.confirmed || actual.model !== modelConfiguration.configured.model || actual.effort !== modelConfiguration.configured.effort;
684
- catalog.assertCurrent();
685
529
  if (changed) {
686
530
  details.record({ kind: 'session', severity: 'warning', code: 'MODEL_CONFIGURATION_CHANGED_DURING_TURN', summary: '本轮回复已经完成,期间远端配置发生变化;保留真实回复,具体执行型号未单独回报。', payload: { reason: 'POST_PROMPT_CONFIGURATION_CHANGED', configured: modelConfiguration.configured, actual: actual ?? null } });
687
- await details.flush();
688
531
  }
689
532
  }
690
533
  yield* mapper.flush();
691
- let recoveryCheckpointId;
692
- if (session?.checkpoint && runtime.saveCheckpoint) {
693
- const id = randomUUID();
694
- if (await runtime.saveCheckpoint(id, session.checkpoint))
695
- recoveryCheckpointId = id;
696
- }
697
- const previousRemoteSessions = [...(previous?.previousRemoteSessions ?? []), ...(contextChanged ? [{ sessionId: previous.sessionId, contextSha256: previous.contextSha256 }] : [])];
698
- const presentation = { ...(runtime.workspaceNotice ? { workspaceNotice: runtime.workspaceNotice } : {}), ...(employeeNoticeShown ? { employeeExecutionNotice: 'local-v1' } : {}) };
699
- const pointer = { ...(previousRemoteSessions.length ? { previousRemoteSessions } : {}), ...(modelConfiguration ? { modelConfiguration } : {}), ...(recoveryCheckpointId ? { recoveryCheckpointId } : {}), ...(Object.keys(presentation).length ? { presentation } : {}), version: 2, agent: this.agent.id, contextSha256, ...(mode === 'session' ? { usageSnapshot: mapper.usageSnapshot } : {}), ...(options.sessionId ? { dshSessionId: options.sessionId } : {}), mode, identity: runtime.identity, cwd: runtime.cwd, sessionId: id, ...(runtime.binding ? { bindingId: runtime.binding.bindingId, workspaceId: runtime.binding.workspaceId } : {}) };
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 } : {}) };
700
535
  succeeded = true;
701
536
  yield { type: 'finish', reason: { kind: stop === 'max_tokens' || stop === 'max_turn_requests' ? 'max-tokens' : 'stop' }, replayState: { response: pointer } };
702
537
  }
@@ -712,110 +547,44 @@ export class CodexRemoteAdapter extends LlmAdapter {
712
547
  recordInformation();
713
548
  failureCode = error instanceof NeedError || error instanceof ModelConfigurationError ? error.code : error instanceof LlmError ? error.code : 'TURN_PREPARATION_FAILED';
714
549
  withRemoteFailureEvidence(error, runtime?.employeeRemoteCloseReason?.(), failureCode);
715
- if (live && session && session.promptDispatchCount > promptBaseline)
716
- live.failure = { cause: error };
717
- const reporting = [];
718
- try {
719
- activeMapper?.flushMetadata();
720
- }
721
- catch (failure) {
722
- reporting.push(failure);
723
- }
724
- const reportFailure = async () => {
725
- if (request?.recordEarlyFailure) {
726
- const record = request.recordEarlyFailure;
727
- request.recordEarlyFailure = undefined;
728
- try {
729
- await record(Object.assign(new NeedError(failureCode ?? 'TURN_PREPARATION_FAILED', '本轮在执行前失败。', '白名单阶段与错误代码'), { cause: error }), earlyStage);
730
- }
731
- catch (failure) {
732
- reporting.push(failure);
733
- }
734
- }
735
- try {
736
- await runtime?.details?.flush();
737
- }
738
- catch (failure) {
739
- reporting.push(failure);
740
- }
741
- };
742
- if (runtime && key && live && live.runtime === runtime && this.sessions.get(key) === live) {
743
- const captured = runtime;
744
- void reportFailure().then(() => { for (const failure of reporting)
745
- for (const code of closeErrorCodes(failure, 'DIAGNOSTIC_WRITE_FAILED'))
746
- captured.closeDiagnostics?.record({ layer: 'adapter', initiator: 'adapter.failed-turn-details', reason: code, action: 'failure' }); }, failure => captured.closeDiagnostics?.record({ layer: 'adapter', initiator: 'adapter.failed-turn-details', reason: closeCode(failure, 'DIAGNOSTIC_WRITE_FAILED'), action: 'failure' }));
747
- }
748
- else
749
- await reportFailure();
550
+ activeMapper?.flushMetadata();
551
+ if (request?.recordEarlyFailure)
552
+ void request.recordEarlyFailure(error, earlyStage).catch(() => console.warn('[remote-agent] 预检诊断未保存'));
750
553
  if (activeMapper)
751
554
  yield* activeMapper.flush();
752
- const firstReason = request?.closeDiagnostics?.first?.reason ?? runtime?.closeDiagnostics?.first?.reason;
753
- if (reporting.length) {
754
- turnFailure = withFailureEvidence(error, reporting, 'diagnostic', firstReason, failureCode);
755
- throw turnFailure;
756
- }
757
- turnFailure = firstReason && !(error instanceof NeedError || error instanceof ModelConfigurationError || error instanceof LlmError) ? withFailureEvidence(error, [], 'diagnostic', firstReason, failureCode) : withFirstFailureEvidence(error, firstReason, failureCode);
758
- throw turnFailure;
555
+ throw withFirstFailureEvidence(error, runtime?.closeDiagnostics?.first?.reason, failureCode);
759
556
  }
760
557
  finally {
558
+ permissionOff?.();
559
+ permissionOff = undefined;
560
+ permissionEpoch.abort();
761
561
  for (const off of abortOffs)
762
562
  off();
763
563
  runtime?.execution?.endTurn();
764
- catalog?.release();
765
- try {
766
- if (preserveRecoveredRuntime || this.shuttingDown || key && this.detached.has(key)) {
767
- session?.close({ layer: 'adapter', initiator: 'adapter.turn-detach', reason: 'TURN_DETACHED', action: 'close' });
768
- await runtime?.detach?.({ layer: 'adapter', initiator: 'adapter.turn-detach', reason: 'TURN_DETACHED', action: 'detach' });
769
- }
770
- else if (!succeeded || !key || mode !== 'session') {
771
- session?.close({ layer: 'adapter', initiator: 'adapter.turn-finally', reason: succeeded ? 'TURN_FINISHED' : 'TURN_FAILED', action: 'close' });
772
- if (runtime) {
773
- if (!succeeded && turnFailure && key && live && live.runtime === runtime && this.sessions.get(key) === live)
774
- this.finishFailedTurn(key, live, turnFailure, failureCode ?? 'TURN_PREPARATION_FAILED', request?.closeDiagnostics?.first?.reason ?? runtime.closeDiagnostics?.first?.reason);
775
- else {
776
- await this.cleanupRuntime(runtime);
777
- if (key && live && !live.failure && this.sessions.get(key) === live) {
778
- if (typeof runtime.dispose === 'function') {
779
- this.sessions.delete(key);
780
- if (!succeeded)
781
- this.preparationFailures.set(key, failureCode ?? 'TURN_PREPARATION_FAILED');
782
- }
783
- else
784
- live.cleanupFailure = new NeedError('REMOTE_CLEANUP_UNCONFIRMED', '缺少原实例结束证明,不能重开或重发', '原实例的条件结束与list消失证明');
785
- }
786
- }
787
- }
788
- }
789
- if (succeeded && key)
790
- this.preparationFailures.delete(key);
791
- }
792
- catch (cleanup) {
793
- throw withFailureEvidence(turnFailure ?? cleanup, [cleanup], 'cleanup', request?.closeDiagnostics?.first?.reason ?? runtime?.closeDiagnostics?.first?.reason, failureCode);
794
- }
795
- finally {
796
- if (key && this.turns.get(key) === turn)
797
- this.turns.delete(key);
798
- if (key)
799
- this.detached.delete(key);
564
+ if (runtime) {
565
+ session?.close({ layer: 'adapter', initiator: 'adapter.turn-finally', reason: succeeded ? 'TURN_FINISHED' : 'TURN_FAILED', action: 'close' });
566
+ if (key && this.sessions.get(key) === live)
567
+ this.sessions.delete(key);
568
+ void this.cleanupRuntime(runtime);
800
569
  }
570
+ if (key && this.turns.get(key) === turn)
571
+ this.turns.delete(key);
572
+ if (key)
573
+ this.detached.delete(key);
801
574
  }
802
575
  }
803
576
  }
804
577
  /** One official registration owns both routes, preserving opaque replay ownership. */
805
578
  export class RemoteAgentsAdapter extends LlmAdapter {
806
579
  adapters = new Map();
807
- constructor(config, makeRuntime, approve, nameFor, readImage, reportLoginFailure, directory, preflightDiagnostics) { super(); for (const agent of AGENTS)
808
- this.adapters.set(agent.id, new CodexRemoteAdapter(config, makeRuntime(agent), approve, () => nameFor(agent), agent, readImage, reportLoginFailure, directory, preflightDiagnostics)); }
580
+ constructor(config, makeRuntime, approve, nameFor, readImage, reportLoginFailure, directory, preflightDiagnostics, permissions) { super(); for (const agent of AGENTS)
581
+ this.adapters.set(agent.id, new CodexRemoteAdapter(config, makeRuntime(agent), approve, () => nameFor(agent), agent, readImage, reportLoginFailure, directory, preflightDiagnostics, permissions)); }
809
582
  adapter(provider) { const adapter = this.adapters.get(provider); if (!adapter)
810
583
  throw new LlmError('远程模型未注册', 'PROVIDER_NOT_FOUND'); return adapter; }
811
584
  providerInfo(provider) { return this.adapter(provider).providerInfo(provider); }
812
585
  listModels(provider) { return this.adapter(provider).listModels(provider); }
813
586
  resolveModel(provider, model, signal) { return this.adapter(provider).resolveModel(provider, model, signal); }
814
587
  prepareCall(provider, model, signal) { return this.adapter(provider).prepareCall(provider, model, signal); }
815
- observeModelInvalidation(fields, changes = []) { for (const adapter of this.adapters.values())
816
- adapter.observeModelInvalidation(fields, changes); }
817
- async invalidateModelState() { await Promise.all([...this.adapters.values()].map(adapter => adapter.invalidateModelState())); }
818
- async invalidateEmployeeState() { await Promise.all([...this.adapters.values()].map(adapter => adapter.invalidateEmployeeState())); }
819
588
  providerRetryPolicy() { return { mode: 'normal', maxRetries: 0, retryableCodes: [], initialDelayMs: 0, maxDelayMs: 0, jitterRatio: 0 }; }
820
589
  stream(options) { return this.adapter(options.provider).stream(options); }
821
590
  async disposeSession(sessionId) { await Promise.all([...this.adapters.values()].map(adapter => adapter.disposeSession(sessionId))); }
@@ -825,7 +594,7 @@ export class RemoteAgentsAdapter extends LlmAdapter {
825
594
  export function apply(ctx, live) {
826
595
  const config = () => readConfig(live);
827
596
  const get = () => ctx.get('aiWorkbenchConnect');
828
- let language = 'en';
597
+ const language = 'zh';
829
598
  const readImage = async (ref, signal) => {
830
599
  const attachments = ctx.get('attachments');
831
600
  if (typeof attachments?.readImage !== 'function')
@@ -833,22 +602,19 @@ export function apply(ctx, live) {
833
602
  return attachments.readImage(ref, signal);
834
603
  };
835
604
  const openByAgent = new Map(AGENTS.map(agent => [agent.id, brokerFactory(get, undefined, config)]));
836
- const localTurns = createLocalUserTurnObserver(ctx);
837
605
  const detailManagers = new Map();
838
606
  const managerForProfile = () => {
839
607
  const profile = ctx.get('profileContext');
840
- if (typeof profile?.dir !== 'string')
841
- throw new NeedError('TECHNICAL_DETAILS_UNAVAILABLE', '技术详情暂不可用:缺少官方profile目录。', '官方profileContext.dir');
842
- let manager = detailManagers.get(profile.dir);
608
+ const profileDir = typeof profile?.dir === 'string' ? profile.dir : undefined;
609
+ let manager = detailManagers.get(profileDir ?? '');
843
610
  if (!manager) {
844
- manager = createRemoteDetails({ profileDir: profile.dir, getApi: get });
845
- detailManagers.set(profile.dir, manager);
611
+ manager = createRemoteDetails({ profileDir, warn: line => ctx.logger('remote-agent').warn(line) });
612
+ detailManagers.set(profileDir ?? '', manager);
846
613
  }
847
614
  return manager;
848
615
  };
616
+ ctx.provide('aiWorkbenchRemoteDiagnostics', { read: (query) => managerForProfile().read(query) });
849
617
  const preflightDiagnostics = createPreflightDiagnostics({ ctx, getApi: get, manager: managerForProfile });
850
- let changed = () => { };
851
- let observeCatalogRevocation = () => { };
852
618
  const diagnostics = new Map();
853
619
  const diagnostic = (scope, provider, message) => {
854
620
  let entry = diagnostics.get(provider);
@@ -863,25 +629,14 @@ export function apply(ctx, live) {
863
629
  else
864
630
  entry.set(scope, message);
865
631
  };
866
- let defaults;
867
- const catalogDiagnostics = createCatalogDiagnostics(() => { const profile = ctx.get('profileContext'); return profile?.dir; });
868
632
  // Public Loader Entry.options.id; optional when mounted without Loader.
869
633
  const actualSettingsNamespace = ctx.fiber.entry?.options.id;
870
- new CatalogDiagnosticsRemote(ctx, async () => ({ ...await catalogDiagnostics.snapshot(), settingsNamespace: actualSettingsNamespace ?? null, providers: AGENTS.map(agent => ({ provider: agent.id, name: agent.name[language], messages: [...(diagnostics.get(agent.id)?.values() ?? [])] })) }));
871
- const modelDirectory = new ModelCatalog(get, () => config().acpMaxMessageBytes, (...args) => { const state = get()?.getConnectionState(); const connection = state ? { instanceId: state.instanceId, epoch: state.epoch, phase: state.phase } : null; return runCatalog(...args, event => catalogDiagnostics.record({ ...event, connection })); }, () => changed(), (brand, message) => {
872
- diagnostic('catalog', AGENTS.find(agent => agent.loginProvider === brand).id, message);
873
- }, (agent, models, assertCurrent) => defaults?.observe(agent, models, assertCurrent), (fields, changes) => observeCatalogRevocation(fields, changes));
634
+ const modelDirectory = new ModelCatalog(get, () => config().acpMaxMessageBytes);
874
635
  const adapter = new RemoteAgentsAdapter(config, agent => {
875
636
  const details = async (request, api, connection, target) => {
876
- return managerForProfile().open({ sessionId: request.sessionId, provider: agent.loginProvider, identity: `${target.username}@${target.host}:${target.port}`, connection, localAcceptanceId: request.localAcceptanceId ?? request.localAcceptance?.acceptanceId });
637
+ return managerForProfile().open({ sessionId: request.sessionId, provider: agent.loginProvider, identity: `${target.username}@${target.host}:${target.port}`, connection });
877
638
  };
878
639
  const open = async (...args) => {
879
- const profile = ctx.get('profileContext');
880
- if (!profile?.dir)
881
- throw new NeedError('RECOVERY_STORAGE_REQUIRED', '会话恢复需要官方profile目录', '官方profileContext.dir');
882
- args[2].recoveryJournal = recoveryJournal(profile.dir, runtimeRecoveryKey(`${args[0].username}@${args[0].host}:${args[0].port}`, agent.id, args[2].sessionId, args[2].employeeScope));
883
- args[2].verifyRecovery = () => verifyRecoveryCheckpoint(ctx, args[2], agent.id);
884
- args[2].currentInputIds = await currentInputIds(ctx, args[2]);
885
640
  const runtime = await openByAgent.get(agent.id)(...args);
886
641
  runtime.detailContext = () => {
887
642
  const events = args[2].sessionId ? ctx.get('agents')?.get(SessionId(args[2].sessionId))?.session.snapshotEvents() : undefined;
@@ -911,14 +666,11 @@ export function apply(ctx, live) {
911
666
  const sessions = services.get('sessions');
912
667
  return officialEmployeeWorkspace(request.sessionId, registry, sessions?.get(request.sessionId)?.header);
913
668
  };
914
- return connectProvider(get, { agent, workspaceSource: {
915
- workspace, currentUserTurn: request => localTurns.current(request), confirm: nativeWorkspaceSourceConfirmation(ctx),
916
- record: (request, observation) => preflightDiagnostics.record(request, agent.loginProvider, observation),
917
- }, ...(agent.loginProvider === 'codex' ? { employee: {
918
- currentUserTurn: request => localTurns.current(request),
669
+ const provider = connectProvider(get, { agent, workspace, ...(agent.loginProvider === 'codex' ? { employee: {
919
670
  getNetwork: () => ctx.get('aiWorkbenchNetwork'),
920
671
  workspace, native: () => resolveEmployeeNative(),
921
- } } : {}), notice: createWorkspaceNoticeConsumer(ctx, agent), details, open, local });
672
+ } } : {}), details, open, local });
673
+ return provider;
922
674
  }, dshApproval(ctx), agent => agent.name[language], readImage, (sessionId, code, diagnostic) => {
923
675
  const events = sessionId ? ctx.get('agents')?.get(SessionId(sessionId))?.session.snapshotEvents() : undefined;
924
676
  let turn = null, step = null;
@@ -937,50 +689,30 @@ export function apply(ctx, live) {
937
689
  step = null;
938
690
  }
939
691
  return managerForProfile().recordLoginFailure({ sessionId, code, diagnostic, context: { turn, step } });
940
- }, modelDirectory, preflightDiagnostics);
692
+ }, modelDirectory, preflightDiagnostics, dshPermissionSource(ctx));
941
693
  const ids = AGENTS.map(agent => agent.id);
942
694
  const entries = () => AGENTS.map(agent => ({ provider: agent.id, displayName: agent.name[language], settingsNs: actualSettingsNamespace ?? settingsNamespace, settingsPath: [] }));
943
695
  const directory = ctx.llm.registerConfigurableProviders(entries());
944
696
  const registration = ctx.llm.registerAdapter(ids, adapter);
945
- installModelInputRetention(ctx, { isOurProvider: provider => ids.includes(provider), diagnosticForAttempt: position => preflightDiagnostics.forPreparation(position.sessionId, position), preflightDiagnostic: (provider, message) => diagnostic('preflight', provider, message), diagnostic: (provider, message) => diagnostic('input', provider, message) });
946
- const serviceValue = (name) => ctx.get(name);
947
- const refreshDefault = () => {
948
- const service = serviceValue('agentDefaultModel');
949
- const selected = service?.currentSelection();
950
- const agent = AGENTS.find(agent => agent.id === selected?.provider);
951
- if (!defaults || !agent)
952
- return;
953
- // Read through the same trusted, fresh catalog. Failures are already recorded there.
954
- void modelDirectory.read(agent).then(lease => lease.release(), () => { });
955
- };
956
- ctx.inject(['agentDefaultModel'], child => {
957
- const service = child.get('agentDefaultModel');
958
- const current = createModelDefaults(service, (provider, message) => diagnostic('default', provider, message), createDefaultSelectionWriter(service, () => serviceValue('configEditor')));
959
- defaults = current;
960
- refreshDefault();
961
- child.effect(() => () => { current.dispose(); if (defaults === current)
962
- defaults = undefined; });
697
+ let connection;
698
+ ctx.on('ai-workbench/connection-state', state => {
699
+ const previous = connection;
700
+ connection = state;
701
+ // Official replacement invalidates the browser's shared catalog and loads it.
702
+ if (state.phase === 'connected' && (previous?.phase !== 'connected' || previous.instanceId !== state.instanceId || previous.epoch !== state.epoch))
703
+ registration.replace(ids);
963
704
  });
964
- observeCatalogRevocation = (fields, changes) => adapter.observeModelInvalidation(fields, changes);
965
- changed = () => { registration.replace(ids); refreshDefault(); void adapter.invalidateModelState().catch(() => { ctx.logger('remote-agent').warn('原会话清理结果未确认,已阻断继续发送;请联系管理员核对。'); }); };
966
- let employeeConnect = serviceValue('aiWorkbenchConnect'), employeeNetwork = serviceValue('aiWorkbenchNetwork');
967
- ctx.on('internal/service', service => {
968
- if (service === 'aiWorkbenchConnect')
969
- modelDirectory.refresh();
970
- if (service !== 'aiWorkbenchConnect' && service !== 'aiWorkbenchNetwork')
971
- return;
972
- const nextConnect = serviceValue('aiWorkbenchConnect'), nextNetwork = serviceValue('aiWorkbenchNetwork');
973
- if (nextConnect === employeeConnect && nextNetwork === employeeNetwork)
974
- return;
975
- employeeConnect = nextConnect;
976
- employeeNetwork = nextNetwork;
977
- void adapter.invalidateEmployeeState().catch(() => { ctx.logger('remote-agent').warn('本机执行连接的清理结果未确认,已阻断继续发送;请联系管理员核对。'); });
978
- });
979
- new ProviderLocaleRemote(ctx, next => { language = next; directory.replace(entries()); registration.replace(ids); });
980
- const cleanup = (id) => adapter.detachSession(id);
705
+ installModelInputRetention(ctx, { isOurProvider: provider => ids.includes(provider), diagnosticForAttempt: position => preflightDiagnostics.forPreparation(position.sessionId, position), preflightDiagnostic: (provider, message) => diagnostic('preflight', provider, message), diagnostic: (provider, message) => diagnostic('input', provider, message) });
706
+ const cleanup = async (id) => { try {
707
+ await adapter.detachSession(id);
708
+ }
709
+ finally {
710
+ for (const manager of detailManagers.values())
711
+ manager.releaseSession(id);
712
+ } };
981
713
  ctx.on('agent/disposed', ({ agent }) => cleanup(agent.session.id));
982
714
  ctx.on('session/disposed', session => cleanup(session.id));
983
- ctx.effect(() => () => { modelDirectory.dispose(); return adapter.dispose(); });
715
+ ctx.effect(() => async () => { modelDirectory.dispose(); await adapter.dispose(); await Promise.all([...detailManagers.values()].map(manager => manager.dispose())); });
984
716
  }
985
717
  export function dshApproval(ctx) {
986
718
  return async (request, signal, options, context, hostWorkspace) => {