@ai-workbench/remote-agent 0.8.13 → 0.8.15

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 (40) hide show
  1. package/README.md +10 -8
  2. package/dist/acp.d.ts +12 -7
  3. package/dist/acp.js +65 -41
  4. package/dist/broker-runtime.js +46 -20
  5. package/dist/broker.d.ts +4 -2
  6. package/dist/broker.js +18 -9
  7. package/dist/claude-local.d.ts +2 -1
  8. package/dist/claude-local.js +62 -35
  9. package/dist/close-diagnostics.d.ts +56 -0
  10. package/dist/close-diagnostics.js +104 -0
  11. package/dist/conversation-details.js +67 -9
  12. package/dist/employee-endpoint.d.ts +3 -1
  13. package/dist/employee-endpoint.js +8 -6
  14. package/dist/employee-exec.d.ts +7 -4
  15. package/dist/employee-exec.js +39 -29
  16. package/dist/employee-mcp.d.ts +6 -3
  17. package/dist/employee-mcp.js +75 -39
  18. package/dist/employee-presentation.d.ts +6 -0
  19. package/dist/employee-presentation.js +16 -0
  20. package/dist/employee-provider.js +59 -36
  21. package/dist/employee-shell.d.ts +2 -0
  22. package/dist/employee-shell.js +17 -5
  23. package/dist/employee-ssh.d.ts +2 -0
  24. package/dist/employee-ssh.js +6 -4
  25. package/dist/employee-tools.d.ts +3 -1
  26. package/dist/employee-tools.js +6 -5
  27. package/dist/index.d.ts +18 -4
  28. package/dist/index.js +183 -55
  29. package/dist/model-catalog.d.ts +9 -1
  30. package/dist/model-catalog.js +63 -5
  31. package/dist/model-configuration.d.ts +14 -1
  32. package/dist/model-configuration.js +122 -89
  33. package/dist/model-presentation.d.ts +13 -0
  34. package/dist/model-presentation.js +18 -0
  35. package/dist/presentation.js +2 -1
  36. package/dist/provider.d.ts +8 -2
  37. package/dist/recovery.d.ts +4 -0
  38. package/dist/rpc-namespace.d.ts +2 -2
  39. package/dist/rpc-namespace.js +2 -1
  40. package/package.json +1 -1
@@ -1,3 +1,4 @@
1
+ import { closeCode } from "./close-diagnostics.js";
1
2
  import { createServer } from 'node:net';
2
3
  import { mkdtemp, lstat, rm, access } from 'node:fs/promises';
3
4
  import { constants } from 'node:fs';
@@ -20,6 +21,7 @@ export const EMPLOYEE_MCP_TOOLS = [
20
21
  export class EmployeeSocketTransport {
21
22
  socket;
22
23
  maxBytes;
24
+ observeClose;
23
25
  onclose;
24
26
  onerror;
25
27
  onmessage;
@@ -29,9 +31,10 @@ export class EmployeeSocketTransport {
29
31
  get error() { return this.failure; }
30
32
  lifetime = new AbortController();
31
33
  signal = this.lifetime.signal;
32
- constructor(socket, maxBytes) {
34
+ constructor(socket, maxBytes, observeClose) {
33
35
  this.socket = socket;
34
36
  this.maxBytes = maxBytes;
37
+ this.observeClose = observeClose;
35
38
  }
36
39
  failed(error) { try {
37
40
  this.onerror?.(error);
@@ -40,11 +43,11 @@ export class EmployeeSocketTransport {
40
43
  this.failure = observation instanceof Error ? observation : new Error('EMPLOYEE_MCP_OBSERVATION_FAILED');
41
44
  }
42
45
  finally {
43
- void this.close();
46
+ void this.close({ layer: 'relay', initiator: 'mcp.socket-failure', reason: closeCode(error, 'EMPLOYEE_MCP_PROTOCOL_OR_SOCKET_ERROR'), action: 'failure' });
44
47
  } }
45
48
  async start() {
46
49
  this.socket.on('error', error => this.failed(error));
47
- this.socket.on('close', () => void this.close());
50
+ this.socket.on('close', () => void this.close({ layer: 'relay', initiator: 'mcp.socket-peer-close', reason: 'MCP_SOCKET_CLOSED', action: 'close' }));
48
51
  this.socket.on('data', (bytes) => {
49
52
  try {
50
53
  let cursor = 0;
@@ -71,12 +74,12 @@ export class EmployeeSocketTransport {
71
74
  throw new Error('EMPLOYEE_MCP_CLOSED');
72
75
  const wire = Buffer.from(JSON.stringify(message) + '\n');
73
76
  if (wire.length - 1 > this.maxBytes) {
74
- await this.close();
77
+ await this.close({ layer: 'relay', initiator: 'mcp.send-capacity', reason: 'EMPLOYEE_MCP_MESSAGE_TOO_LARGE', action: 'close' });
75
78
  throw new Error('EMPLOYEE_MCP_MESSAGE_TOO_LARGE');
76
79
  }
77
80
  await new Promise((yes, no) => this.socket.write(wire, error => error ? no(error) : yes()));
78
81
  }
79
- async close() { if (this.ended)
82
+ async close(cause = { layer: 'relay', initiator: 'mcp.transport-close', reason: 'MCP_TRANSPORT_CLOSED', action: 'close' }) { this.observeClose?.({ ...cause, changes: cause.changes ?? [{ field: 'mcp.ended', before: this.ended, after: true }] }); if (this.ended)
80
83
  return; this.ended = true; this.lifetime.abort(); this.socket.destroy(); this.onclose?.(); }
81
84
  }
82
85
  export async function openEmployeeMcp(options) {
@@ -85,7 +88,7 @@ export async function openEmployeeMcp(options) {
85
88
  if (!Number.isSafeInteger(options.maxBytes) || options.maxBytes < 1)
86
89
  throw new NeedError('SETTINGS_REQUIRED', '本机工具消息容量无效。', 'acpMaxMessageBytes');
87
90
  await access('/usr/bin/nc', constants.X_OK);
88
- const tools = createEmployeeTools({ cwd: options.lease.target.local.directory.path, shell: options.lease.target.endpoint.endpointShell, uid: options.lease.target.endpoint.remoteUid, maxBytes: options.maxBytes, killGraceMs: options.killGraceMs, detail: observe, runShell: options.runShell, validate: signal => options.lease.validate(signal).then(() => { }) });
91
+ const tools = createEmployeeTools({ closeDiagnostics: options.closeDiagnostics, cwd: options.lease.target.local.directory.path, shell: options.lease.target.endpoint.endpointShell, uid: options.lease.target.endpoint.remoteUid, maxBytes: options.maxBytes, killGraceMs: options.killGraceMs, detail: observe, runShell: options.runShell, validate: signal => options.lease.validate(signal).then(() => { }) });
89
92
  const root = await mkdtemp('/private/tmp/aw-x87-'), socketPath = root + '/mcp';
90
93
  try {
91
94
  const info = await lstat(root);
@@ -93,19 +96,40 @@ export async function openEmployeeMcp(options) {
93
96
  throw new NeedError('EMPLOYEE_MCP_STORAGE_INVALID', '本机工具私有通道不可用。', '当前员工的700私有Unix目录');
94
97
  }
95
98
  catch (error) {
96
- await tools.close();
97
- await rm(root, { recursive: true });
99
+ options.closeDiagnostics?.record({ layer: 'relay', initiator: 'mcp.storage-failure', reason: closeCode(error), action: 'failure' });
100
+ const failures = [error];
101
+ try {
102
+ await tools.close({ layer: 'executor', initiator: 'mcp.storage-cleanup', reason: 'MCP_STORAGE_FAILED', action: 'close' });
103
+ }
104
+ catch (cleanup) {
105
+ failures.push(cleanup);
106
+ }
107
+ try {
108
+ await rm(root, { recursive: true });
109
+ }
110
+ catch (cleanup) {
111
+ failures.push(cleanup);
112
+ }
113
+ try {
114
+ options.closeDiagnostics?.assertRecorded();
115
+ }
116
+ catch (reporting) {
117
+ failures.push(reporting);
118
+ }
119
+ if (failures.length > 1)
120
+ throw new AggregateError(failures, 'EMPLOYEE_MCP_STORAGE_AND_CLEANUP_FAILED');
98
121
  throw error;
99
122
  }
100
123
  const clients = new Set(), transports = new Set();
101
124
  let closing, diagnosticFailure;
102
125
  const failureLifetime = new AbortController();
103
126
  const poison = (error) => {
127
+ options.closeDiagnostics?.record({ layer: 'relay', initiator: 'mcp.poison', reason: closeCode(error, 'CONVERSATION_DETAILS_FAILED'), action: 'invalidate' });
104
128
  diagnosticFailure ??= error;
105
129
  failureLifetime.abort(error);
106
130
  // Close is memoized synchronously: no fresh connection/call can pass the fence.
107
- void options.lease.close().catch(failure => { diagnosticFailure ??= failure; });
108
- void close().catch(failure => { diagnosticFailure ??= failure; });
131
+ void options.lease.close({ layer: 'endpoint', initiator: 'mcp.poison-lease', reason: 'CONVERSATION_DETAILS_FAILED', action: 'close' }).catch(failure => { diagnosticFailure ??= failure; });
132
+ void close({ layer: 'relay', initiator: 'mcp.poison-close', reason: 'CONVERSATION_DETAILS_FAILED', action: 'close' }).catch(failure => { diagnosticFailure ??= failure; });
109
133
  };
110
134
  function observeUsing(event, sink) { if (diagnosticFailure)
111
135
  throw diagnosticFailure; try {
@@ -118,10 +142,12 @@ export async function openEmployeeMcp(options) {
118
142
  function observe(event) { observeUsing(event, options.detail); }
119
143
  const server = createServer(socket => {
120
144
  if (closing || failureLifetime.signal.aborted || options.lease.signal.aborted) {
145
+ options.closeDiagnostics?.record({ layer: 'relay', initiator: 'mcp.reject-late-socket', reason: 'MCP_CLOSING', action: 'close' });
121
146
  socket.destroy();
122
147
  return;
123
148
  }
124
- const mcp = new Server({ name: 'ai-workbench-employee', version: '0.8.13' }, { capabilities: { tools: {} } }), transport = new EmployeeSocketTransport(socket, options.maxBytes);
149
+ const observeConnection = options.closeDiagnostics?.child().capture();
150
+ const mcp = new Server({ name: 'ai-workbench-employee', version: '0.8.14' }, { capabilities: { tools: {} } }), transport = new EmployeeSocketTransport(socket, options.maxBytes, observeConnection);
125
151
  clients.add(mcp);
126
152
  transports.add(transport);
127
153
  mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: EMPLOYEE_MCP_TOOLS }));
@@ -133,40 +159,50 @@ export async function openEmployeeMcp(options) {
133
159
  const sink = options.captureDetail?.() ?? options.detail;
134
160
  return tools.call(request.params.name, request.params.arguments ?? {}, signal, event => observeUsing(event, sink));
135
161
  });
136
- mcp.onerror = () => { try {
162
+ mcp.onerror = () => { observeConnection?.({ layer: 'relay', initiator: 'mcp.server-error', reason: 'EMPLOYEE_MCP_PROTOCOL_OR_TRANSPORT_ERROR', action: 'failure' }); try {
137
163
  observe({ kind: 'session', severity: 'warning', code: 'CLAUDE_EMPLOYEE_MCP_UNKNOWN', summary: '本机工具连接出现协议或传输错误;在途结果未知,没有重放' });
138
164
  }
139
165
  catch (error) {
140
166
  poison(error);
141
167
  } };
142
- mcp.onclose = () => { clients.delete(mcp); transports.delete(transport); };
143
- void mcp.connect(transport).catch(() => { socket.destroy(); });
168
+ mcp.onclose = () => { observeConnection?.({ layer: 'relay', initiator: 'mcp.client-closed', reason: 'MCP_CLIENT_CLOSED', action: 'close' }); clients.delete(mcp); transports.delete(transport); };
169
+ void mcp.connect(transport).catch(error => { observeConnection?.({ layer: 'relay', initiator: 'mcp.connect-failure', reason: closeCode(error, 'MCP_CONNECT_FAILED'), action: 'close' }); socket.destroy(); });
144
170
  });
145
- const close = () => closing ??= Promise.resolve().then(async () => {
146
- options.lease.signal.removeEventListener('abort', aborted);
147
- const errors = [], clean = async (operation) => { try {
148
- await operation();
149
- }
150
- catch (error) {
151
- if (!errors.includes(error))
171
+ const close = (cause = { layer: 'relay', initiator: 'mcp.close', reason: 'MCP_CLOSE_REQUESTED', action: 'close' }) => {
172
+ options.closeDiagnostics?.record({ ...cause, changes: cause.changes ?? [{ field: 'mcp.closing', before: !!closing, after: true }] });
173
+ return closing ??= Promise.resolve().then(async () => {
174
+ options.lease.signal.removeEventListener('abort', aborted);
175
+ const errors = [], clean = async (operation) => { try {
176
+ await operation();
177
+ }
178
+ catch (error) {
179
+ if (!errors.includes(error))
180
+ errors.push(error);
181
+ } };
182
+ for (const transport of transports)
183
+ await clean(() => transport.close({ layer: 'relay', initiator: 'mcp.cleanup-transport', reason: 'MCP_CLEANUP', action: 'close' }));
184
+ await Promise.all([...clients].map(client => clean(() => client.close())));
185
+ await clean(() => tools.close({ layer: 'executor', initiator: 'mcp.cleanup-tools', reason: 'MCP_CLEANUP', action: 'close' }));
186
+ await clean(() => options.lease.close({ layer: 'endpoint', initiator: 'mcp.cleanup-lease', reason: 'MCP_CLEANUP', action: 'close' }));
187
+ options.closeDiagnostics?.record({ layer: 'relay', initiator: 'mcp.server-close', reason: 'MCP_CLEANUP', action: 'close' });
188
+ if (server.listening)
189
+ await clean(() => new Promise((yes, no) => server.close(error => error ? no(error) : yes())));
190
+ await clean(() => rm(root, { recursive: true }));
191
+ if (diagnosticFailure && !errors.includes(diagnosticFailure))
192
+ errors.unshift(diagnosticFailure);
193
+ try {
194
+ options.closeDiagnostics?.assertRecorded();
195
+ }
196
+ catch (error) {
152
197
  errors.push(error);
153
- } };
154
- for (const transport of transports)
155
- await clean(() => transport.close());
156
- await Promise.all([...clients].map(client => clean(() => client.close())));
157
- await clean(() => tools.close());
158
- await clean(() => options.lease.close());
159
- if (server.listening)
160
- await clean(() => new Promise((yes, no) => server.close(error => error ? no(error) : yes())));
161
- await clean(() => rm(root, { recursive: true }));
162
- if (diagnosticFailure && !errors.includes(diagnosticFailure))
163
- errors.unshift(diagnosticFailure);
164
- if (errors.length === 1)
165
- throw errors[0];
166
- if (errors.length)
167
- throw new AggregateError(errors, 'EMPLOYEE_MCP_CLEANUP_FAILED');
168
- });
169
- const aborted = () => { void close().catch(error => { diagnosticFailure ??= error; failureLifetime.abort(error); }); };
198
+ }
199
+ if (errors.length === 1)
200
+ throw errors[0];
201
+ if (errors.length)
202
+ throw new AggregateError(errors, 'EMPLOYEE_MCP_CLEANUP_FAILED');
203
+ });
204
+ };
205
+ const aborted = () => { void close({ layer: 'endpoint', initiator: 'mcp.lease-abort', reason: closeCode(options.lease.signal.reason, 'EMPLOYEE_CONTEXT_CHANGED'), action: 'abort' }).catch(error => { diagnosticFailure ??= error; failureLifetime.abort(error); }); };
170
206
  try {
171
207
  await new Promise((yes, no) => { server.once('error', no); server.listen(socketPath, () => { server.removeListener('error', no); yes(); }); });
172
208
  options.lease.signal.addEventListener('abort', aborted, { once: true });
@@ -175,7 +211,7 @@ export async function openEmployeeMcp(options) {
175
211
  return { routing: employeeRouting(options.lease.target, socketPath, options.maxBytes), signal: failureLifetime.signal, close };
176
212
  }
177
213
  catch (error) {
178
- await close();
214
+ await close({ layer: 'relay', initiator: 'mcp.open-failure', reason: closeCode(error, 'MCP_OPEN_FAILED'), action: 'failure' });
179
215
  throw error;
180
216
  }
181
217
  }
@@ -0,0 +1,6 @@
1
+ import type { GenerateOptions } from '@deepseek-ai/dsh-llm';
2
+ export declare const CLAUDE_EMPLOYEE_SCOPE_NOTICE = "\u672C\u8F6E\u516D\u7C7B\u6587\u4EF6\u4E0E\u547D\u4EE4\u5DE5\u5177\u5728\u4F60\u7684\u7535\u8111\u6267\u884C\uFF0C\u4F7F\u7528\u4F60\u9009\u62E9\u7684\u6587\u4EF6\u5939\uFF1BGit\u4E5F\u4F7F\u7528\u672C\u673A\u4ED3\u5E93\u3002\u5B50\u4EE3\u7406\u548C\u5185\u90E8\u76F4\u63A5\u8C03\u7528\u5C1A\u4E0D\u652F\u6301\u672C\u673A\uFF0C\u53EF\u80FD\u4ECD\u5728\u4E3B\u673A\uFF1B\u5DF2\u8BC6\u522B\u7684\u6B64\u7C7B\u5BA1\u6279\u4E0D\u4F1A\u6267\u884C\u3002PDF\u7B49\u4E8C\u8FDB\u5236\u8BFB\u53D6\u3001\u540E\u53F0\u547D\u4EE4\u6682\u4E0D\u652F\u6301\u3002";
3
+ export declare const CODEX_EMPLOYEE_SCOPE_NOTICE = "\u6587\u4EF6\u4E0E\u547D\u4EE4\u5728\u672C\u673A\u9009\u5B9A\u76EE\u5F55\u8FD0\u884C\uFF0CGit \u4F7F\u7528\u672C\u673A\u4ED3\u5E93\uFF1B\u6A21\u578B\u3001\u767B\u5F55\u548C\u5168\u5C40\u52A9\u624B\u8981\u6C42\u4ECD\u5728\u8FDC\u7A0B\u4E3B\u673A\u3002\u672C\u673A\u81EA\u5B9A\u4E49\u7684\u9644\u52A0\u5DE5\u5177\u3001\u81EA\u52A8\u52A8\u4F5C\u3001\u63D2\u4EF6\u53CA\u65AD\u5F00\u540E\u7684\u4F1A\u8BDD\u6062\u590D\u6682\u4E0D\u652F\u6301\u3002\u5B50\u4EFB\u52A1\u88AB\u7CFB\u7EDF\u56DE\u6536\u540E\u80FD\u5426\u7EE7\u7EED\uFF0C\u5C1A\u672A\u9A8C\u8BC1\u3002";
4
+ export declare const EMPLOYEE_EXECUTION_NOTICE = "\u6587\u4EF6\u548C\u547D\u4EE4\u5728\u4F60\u7684\u7535\u8111\u4E0A\u6267\u884C";
5
+ /** Presentation receipt only; it grants no process, workspace or replay authority. */
6
+ export declare function employeeNoticePreviouslyShown(options: Pick<GenerateOptions, 'messages' | 'sessionId'>): boolean;
@@ -0,0 +1,16 @@
1
+ import { AGENTS } from "./agents.js";
2
+ export const CLAUDE_EMPLOYEE_SCOPE_NOTICE = '本轮六类文件与命令工具在你的电脑执行,使用你选择的文件夹;Git也使用本机仓库。子代理和内部直接调用尚不支持本机,可能仍在主机;已识别的此类审批不会执行。PDF等二进制读取、后台命令暂不支持。';
3
+ export const CODEX_EMPLOYEE_SCOPE_NOTICE = '文件与命令在本机选定目录运行,Git 使用本机仓库;模型、登录和全局助手要求仍在远程主机。本机自定义的附加工具、自动动作、插件及断开后的会话恢复暂不支持。子任务被系统回收后能否继续,尚未验证。';
4
+ export const EMPLOYEE_EXECUTION_NOTICE = '文件和命令在你的电脑上执行';
5
+ /** Presentation receipt only; it grants no process, workspace or replay authority. */
6
+ export function employeeNoticePreviouslyShown(options) {
7
+ if (!options.sessionId)
8
+ return false;
9
+ return options.messages.some(message => {
10
+ if (message.role !== 'assistant' || !AGENTS.some(agent => agent.id === message.source.provider))
11
+ return false;
12
+ const pointer = message.source.replayState?.response;
13
+ const sameSession = !!pointer && pointer.version === 2 && pointer.agent === message.source.provider && pointer.dshSessionId === options.sessionId && [pointer.identity, pointer.cwd, pointer.sessionId].every(value => typeof value === 'string' && value.length > 0);
14
+ return sameSession && (pointer.presentation?.employeeExecutionNotice === 'local-v1' || message.content.some(block => block.type === 'text' && [CLAUDE_EMPLOYEE_SCOPE_NOTICE, CODEX_EMPLOYEE_SCOPE_NOTICE].some(notice => block.text.startsWith(notice + '\n\n'))));
15
+ });
16
+ }
@@ -6,6 +6,8 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
6
6
  }
7
7
  return path;
8
8
  };
9
+ import { CODEX_EMPLOYEE_SCOPE_NOTICE } from "./employee-presentation.js";
10
+ import { CloseDiagnostics, closeCode, closeErrorCodes } from "./close-diagnostics.js";
9
11
  import { createHash } from 'node:crypto';
10
12
  import { admitEmployeeLocation } from "./employee-location.js";
11
13
  import { EmployeeExecution } from "./employee-exec.js";
@@ -104,7 +106,7 @@ export function createEmployeeProvider(get, options, agent) {
104
106
  prior = live.get(sessionId);
105
107
  }
106
108
  if (prior && (prior.current.api !== api || info?.workspaceLocations !== 1 || info?.localWorkspaceContext !== 1)) {
107
- await prior.dispose(fail('EMPLOYEE_CONTEXT_CHANGED'));
109
+ await prior.dispose(fail('EMPLOYEE_CONTEXT_CHANGED'), { layer: 'endpoint', initiator: 'provider.scope-change', reason: 'EMPLOYEE_CONTEXT_CHANGED', action: 'dispose' });
108
110
  throw fail('EMPLOYEE_CONTEXT_CHANGED');
109
111
  }
110
112
  if (info?.workspaceLocations !== 1 && info?.localWorkspaceContext !== 1)
@@ -121,12 +123,12 @@ export function createEmployeeProvider(get, options, agent) {
121
123
  throw fail('EMPLOYEE_CONTEXT_CHANGED');
122
124
  if (row.source !== 'local') {
123
125
  if (prior)
124
- await prior.dispose(fail('EMPLOYEE_CONTEXT_CHANGED'));
126
+ await prior.dispose(fail('EMPLOYEE_CONTEXT_CHANGED'), { layer: 'endpoint', initiator: 'provider.scope-change', reason: 'EMPLOYEE_CONTEXT_CHANGED', action: 'dispose' });
125
127
  if (request.localAcceptance)
126
128
  throw fail('EMPLOYEE_CONTEXT_CHANGED');
127
129
  return;
128
130
  }
129
- let details;
131
+ let details, closeDiagnostics;
130
132
  try {
131
133
  const acceptance = request.localAcceptance;
132
134
  if (!acceptance || acceptance.instanceId !== connection.instanceId || acceptance.epoch !== connection.epoch || acceptance.sessionId !== sessionId || acceptance.provider !== agent.loginProvider || acceptance.workspaceId !== workspace.id || acceptance.bindingId !== row.bindingId)
@@ -134,6 +136,10 @@ export function createEmployeeProvider(get, options, agent) {
134
136
  details = await options.details?.(request, api, connection, target);
135
137
  if (details)
136
138
  request.timing?.bind(detail => details.record(detail));
139
+ const sink = details;
140
+ closeDiagnostics = prior?.runtime.closeDiagnostics ?? new CloseDiagnostics(event => sink?.record(event));
141
+ closeDiagnostics.bind(event => sink?.record(event));
142
+ request.closeDiagnostics = closeDiagnostics;
137
143
  const unsupported = request.mode !== 'session' || agent.loginProvider !== 'codex' || !!request.recoveryCheckpointId || !!request.resumeSessionId && !prior;
138
144
  if (unsupported)
139
145
  throw fail('EMPLOYEE_OPERATION_UNSUPPORTED');
@@ -145,7 +151,7 @@ export function createEmployeeProvider(get, options, agent) {
145
151
  if (!network || typeof network.prepareEmployeeEndpoint !== 'function' || typeof network.onState !== 'function')
146
152
  throw fail('DEPENDENCY_REQUIRED');
147
153
  if (prior && prior.network !== network) {
148
- await prior.dispose(fail('EMPLOYEE_CONTEXT_CHANGED'));
154
+ await prior.dispose(fail('EMPLOYEE_CONTEXT_CHANGED'), { layer: 'endpoint', initiator: 'provider.scope-change', reason: 'EMPLOYEE_CONTEXT_CHANGED', action: 'dispose' });
149
155
  throw fail('EMPLOYEE_CONTEXT_CHANGED');
150
156
  }
151
157
  const admitted = await admitEmployeeLocation({ api: api, network, workspace, connection, expectedLocal: { instanceId: acceptance.instanceId, epoch: acceptance.epoch, workspaceId: acceptance.workspaceId, bindingId: acceptance.bindingId, directory: { ...acceptance.directory } }, settingsPath: target.session.settingsPath, signal: request.signal });
@@ -167,7 +173,7 @@ export function createEmployeeProvider(get, options, agent) {
167
173
  native: { path: native.path, sha256: native.sha256 },
168
174
  });
169
175
  if (prior && (prior.scope !== scope || prior.ended))
170
- await prior.dispose(fail('EMPLOYEE_CONTEXT_CHANGED'));
176
+ await prior.dispose(fail('EMPLOYEE_CONTEXT_CHANGED'), { layer: 'endpoint', initiator: 'provider.scope-change', reason: 'EMPLOYEE_CONTEXT_CHANGED', action: 'dispose' });
171
177
  let entry = prior && prior.scope === scope && !prior.ended ? prior : undefined;
172
178
  let candidate = entry;
173
179
  const base = async (signal) => {
@@ -177,7 +183,7 @@ export function createEmployeeProvider(get, options, agent) {
177
183
  if (candidate?.ended)
178
184
  throw candidate.reason ?? fail('EMPLOYEE_EXEC_CLOSED');
179
185
  await (candidate?.current ?? input).stable(signal);
180
- await admitted.validate(signal);
186
+ await closeDiagnostics.run('endpoint.prepare', () => admitted.validate(signal));
181
187
  if (get() !== api || employee.getNetwork() !== network)
182
188
  throw fail('EMPLOYEE_CONTEXT_CHANGED');
183
189
  await (candidate?.current ?? input).stable(signal);
@@ -193,25 +199,26 @@ export function createEmployeeProvider(get, options, agent) {
193
199
  }
194
200
  const io = employee.io ?? sshIo(target, argv, request.acpMaxMessageBytes);
195
201
  const scopedIo = { run: (command, signal) => io.run(command, signal), attach: async (command) => {
202
+ const observe = closeDiagnostics.capture();
196
203
  const child = await io.attach(command);
197
- child.once('close', () => { if (candidate && !candidate.ended)
198
- void candidate.dispose(fail('EMPLOYEE_EXEC_UNKNOWN')).catch(() => { }); });
204
+ child.once('close', (code, signal) => { observe({ layer: 'relay', initiator: 'ssh.process.close', reason: 'EMPLOYEE_EXEC_UNKNOWN', action: 'exit', exitCode: code, signal }); if (candidate && !candidate.ended)
205
+ void candidate.dispose(fail('EMPLOYEE_EXEC_UNKNOWN'), { layer: 'runtime', initiator: 'provider.child-close', reason: 'EMPLOYEE_EXEC_UNKNOWN', action: 'dispose' }).catch(() => { }); });
199
206
  return child;
200
207
  } };
201
- const ssh = { target, endpoint: admitted.endpoint, employeeBinary: native.path, binarySha256: native.sha256, acpMaxMessageBytes: request.acpMaxMessageBytes, argv: argv ?? (() => []), validate: base, io: scopedIo };
208
+ const ssh = { closeDiagnostics, target, endpoint: admitted.endpoint, employeeBinary: native.path, binarySha256: native.sha256, acpMaxMessageBytes: request.acpMaxMessageBytes, argv: argv ?? (() => []), validate: base, io: scopedIo };
202
209
  const validate = async (signal) => { try {
203
210
  await validateEmployeeSshTarget(ssh, signal);
204
211
  }
205
212
  catch (error) {
206
213
  if (candidate && errorCode(error) !== 'ABORTED')
207
- await candidate.dispose(asNeed(error));
214
+ await candidate.dispose(asNeed(error), { layer: 'endpoint', initiator: 'provider.validation-failure', reason: closeCode(error), action: 'invalidate' });
208
215
  throw error;
209
216
  } };
210
217
  await validate(request.signal ?? new AbortController().signal);
211
218
  if (!options.open)
212
219
  throw fail('RUNTIME_REQUIRED');
213
220
  // Host cwd is only the protected agent bootstrap/config directory. Native tools use admitted.cwd.
214
- const host = await options.open(target, { name: 'employee', path: target.projectRoot }, { ...request, agent, employeeScope: scope, bindingId: admitted.bindingId, workspaceId: admitted.workspaceId, bindingRevision: state.revision, detailChannel: details, validateContext: async (ignore = false) => {
221
+ const host = await options.open(target, { name: 'employee', path: target.projectRoot }, { ...request, agent, employeeScope: scope, bindingId: admitted.bindingId, workspaceId: admitted.workspaceId, bindingRevision: state.revision, closeDiagnostics, detailChannel: details, validateContext: async (ignore = false) => {
215
222
  // Disposal addresses only the already-owned host broker instance through its frozen IO/CAS.
216
223
  // A withdrawn local source must prevent dispatch, but cannot forbid that instance's exact kill.
217
224
  if (ignore)
@@ -226,49 +233,65 @@ export function createEmployeeProvider(get, options, agent) {
226
233
  await validate(request.signal ?? new AbortController().signal);
227
234
  }
228
235
  catch (error) {
229
- await entry.dispose(asNeed(error));
236
+ await entry.dispose(asNeed(error), { layer: 'endpoint', initiator: 'provider.final-validation', reason: closeCode(error), action: 'dispose' });
230
237
  throw error;
231
238
  }
232
239
  entry.runtime.details = details;
233
240
  entry.runtime.connection = { ...connection };
234
241
  entry.runtime.binding = { bindingId: admitted.bindingId, workspaceId: admitted.workspaceId, revision: state.revision };
242
+ entry.runtime.restartNotice = host.restartNotice;
243
+ entry.runtime.restartInputIds = host.restartInputIds;
244
+ entry.runtime.freshSessionConfirmed = host.freshSessionConfirmed;
235
245
  return entry.runtime;
236
246
  }
237
247
  const disposers = [];
238
- const execution = new EmployeeExecution({ bindingId: admitted.bindingId, acpMaxMessageBytes: request.acpMaxMessageBytes, validate, transport: signal => createEmployeeSshTransport(ssh, signal) });
239
- const runtime = { ...host, cwd: admitted.cwd, details, connection: { ...connection }, binding: { bindingId: admitted.bindingId, workspaceId: admitted.workspaceId, revision: state.revision }, saveCheckpoint: undefined, recoveredSession: undefined, restartNotice: undefined, restartInputIds: undefined, employeeExecutionMeta: { version: 1, bindingId: admitted.bindingId, workspaceId: admitted.workspaceId, cwd: admitted.cwd, acpMaxMessageBytes: request.acpMaxMessageBytes }, employeePresentation: '文件与命令在本机选定目录运行,Git 使用本机仓库;模型、登录和全局助手要求仍在远程主机。本机自定义的附加工具、自动动作、插件及断开后的会话恢复暂不支持。子任务被系统回收后能否继续,尚未验证。' };
240
- entry = { scope, runtime, current: input, details, execution, network, ended: false, dispose: reason => {
248
+ const execution = new EmployeeExecution({ closeDiagnostics, bindingId: admitted.bindingId, acpMaxMessageBytes: request.acpMaxMessageBytes, validate, transport: signal => createEmployeeSshTransport(ssh, signal) });
249
+ const runtime = { ...host, closeDiagnostics, cwd: admitted.cwd, details, connection: { ...connection }, binding: { bindingId: admitted.bindingId, workspaceId: admitted.workspaceId, revision: state.revision }, saveCheckpoint: undefined, recoveredSession: undefined, employeeExecutionMeta: { version: 1, bindingId: admitted.bindingId, workspaceId: admitted.workspaceId, cwd: admitted.cwd, acpMaxMessageBytes: request.acpMaxMessageBytes }, employeePresentation: CODEX_EMPLOYEE_SCOPE_NOTICE };
250
+ entry = { scope, runtime, current: input, details, execution, network, ended: false, dispose: (reason, cause = { layer: 'runtime', initiator: 'provider.dispose', reason: reason?.code ?? 'EMPLOYEE_EXEC_CLOSED', action: 'dispose' }) => {
251
+ closeDiagnostics.record({ ...cause, changes: cause.changes ?? [{ field: 'provider.ended', before: entry.ended, after: true }] });
241
252
  if (entry.closing)
242
253
  return entry.closing;
243
254
  entry.ended = true;
244
255
  entry.reason = reason;
245
- execution.invalidate(reason ?? fail('EMPLOYEE_EXEC_CLOSED'));
256
+ execution.invalidate(reason ?? fail('EMPLOYEE_EXEC_CLOSED'), { layer: 'executor', initiator: 'provider.invalidate-executor', reason: reason?.code ?? 'EMPLOYEE_EXEC_CLOSED', action: 'invalidate' });
246
257
  for (const off of disposers)
247
258
  off();
248
259
  host.broker?.fail(reason ?? fail('EMPLOYEE_EXEC_CLOSED'));
249
260
  entry.closing = (async () => {
250
- let cleanup;
261
+ const cleanup = [], reporting = [];
251
262
  try {
252
- await execution.dispose();
263
+ await execution.dispose({ layer: 'executor', initiator: 'provider.cleanup-executor', reason: 'RUNTIME_CLEANUP', action: 'dispose' });
253
264
  }
254
265
  catch (error) {
255
- cleanup = error;
266
+ cleanup.push(...closeErrorCodes(error, 'EXECUTOR_CLEANUP_FAILED'));
256
267
  }
257
268
  try {
258
- await host.dispose?.();
269
+ await host.dispose?.({ layer: 'broker', initiator: 'provider.cleanup-host', reason: 'RUNTIME_CLEANUP', action: 'dispose' });
259
270
  }
260
271
  catch (error) {
261
- cleanup = error;
272
+ cleanup.push(...closeErrorCodes(error, 'HOST_CLEANUP_FAILED'));
273
+ }
274
+ const firstReason = String(closeDiagnostics.first?.reason ?? reason?.code ?? 'EMPLOYEE_EXEC_CLOSED');
275
+ try {
276
+ entry.details?.record({ kind: 'need', severity: 'warning', code: cleanup.length ? 'REMOTE_CLEANUP_UNCONFIRMED' : 'EMPLOYEE_EXEC_UNKNOWN', summary: '员工执行通道已关闭,命令结果 UNKNOWN', payload: { bindingId: admitted.bindingId, workspaceId: admitted.workspaceId, result: 'UNKNOWN', reason: reason?.code ?? 'EMPLOYEE_EXEC_CLOSED', firstReason, cleanup: cleanup.length ? 'unconfirmed' : 'closed', cleanupCodes: cleanup } });
277
+ }
278
+ catch (error) {
279
+ reporting.push(closeCode(error, 'CLOSE_RECORD_FAILED'));
262
280
  }
263
281
  try {
264
- entry.details?.record({ kind: 'need', severity: 'warning', code: cleanup ? 'REMOTE_CLEANUP_UNCONFIRMED' : 'EMPLOYEE_EXEC_UNKNOWN', summary: '员工执行通道已关闭,命令结果 UNKNOWN', payload: { bindingId: admitted.bindingId, workspaceId: admitted.workspaceId, result: 'UNKNOWN', reason: reason?.code ?? 'EMPLOYEE_EXEC_CLOSED', cleanup: cleanup ? 'unconfirmed' : 'closed' } });
265
282
  await entry.details?.flush();
266
283
  }
267
- catch {
268
- throw fail('CONVERSATION_DETAILS_FAILED');
284
+ catch (error) {
285
+ reporting.push(...closeErrorCodes(error, 'CLOSE_FLUSH_FAILED'));
286
+ }
287
+ try {
288
+ closeDiagnostics.assertRecorded();
289
+ }
290
+ catch (error) {
291
+ reporting.push(closeDiagnostics.recordingFailureCode ?? closeCode(error, 'CLOSE_RECORD_FAILED'));
269
292
  }
270
- if (cleanup)
271
- throw fail('REMOTE_CLEANUP_UNCONFIRMED');
293
+ if (cleanup.length || reporting.length)
294
+ throw Object.assign(new NeedError(reporting.length ? 'CONVERSATION_DETAILS_FAILED' : 'REMOTE_CLEANUP_UNCONFIRMED', '执行连接已关闭;首次原因 ' + firstReason + ',清理或诊断保存未确认。', '首次关闭 ' + firstReason + ';清理 ' + (cleanup.join(',') || 'closed') + ';保存 ' + (reporting.join(',') || 'recorded')), { cause: new AggregateError([firstReason, ...cleanup, ...reporting].map(code => new NeedError(code, '关闭收尾代码 ' + code, '技术详情中的固定代码')), 'EMPLOYEE_CLOSE_AND_DIAGNOSTIC_FAILURES') });
272
295
  if (live.get(sessionId) === entry)
273
296
  live.delete(sessionId);
274
297
  })();
@@ -279,35 +302,35 @@ export function createEmployeeProvider(get, options, agent) {
279
302
  try {
280
303
  const result = await execution.handle(params, signal);
281
304
  if (result.closed)
282
- await entry.dispose();
305
+ await entry.dispose(undefined, { layer: 'runtime', initiator: 'provider.rpc-closed-result', reason: 'EMPLOYEE_EXEC_CLOSED', operation: typeof params === 'object' && params && 'operation' in params && typeof params.operation === 'string' ? params.operation : 'unknown', action: 'dispose' });
283
306
  return { ...result };
284
307
  }
285
308
  catch (error) {
286
309
  // EmployeeExecution reserves ABORTED for an unconsumed read wait. Lost responses are UNKNOWN.
287
310
  if (!(params && typeof params === 'object' && 'operation' in params && params.operation === 'read' && errorCode(error) === 'ABORTED'))
288
- await entry.dispose(asNeed(error));
311
+ await entry.dispose(asNeed(error), { layer: 'runtime', initiator: 'provider.rpc-failure', reason: closeCode(error), action: 'dispose' });
289
312
  throw error;
290
313
  }
291
314
  };
292
- runtime.dispose = () => entry.dispose();
293
- runtime.detach = runtime.dispose;
294
- const revoke = (reason) => { void entry.dispose(reason).catch(() => { }); };
315
+ runtime.dispose = cause => entry.dispose(undefined, cause ?? { layer: 'runtime', initiator: 'runtime.dispose', reason: 'RUNTIME_DISPOSE', action: 'dispose' });
316
+ runtime.detach = cause => entry.dispose(undefined, cause ?? { layer: 'runtime', initiator: 'runtime.detach', reason: 'RUNTIME_DETACH', action: 'detach' });
317
+ const revoke = (reason, initiator, changes) => { void entry.dispose(reason, { layer: 'endpoint', initiator, reason: reason.code, action: 'invalidate', changes }).catch(() => { }); };
295
318
  disposers.push(api.onWorkspaceLocationState(next => {
296
319
  const matches = next.items.filter(item => item.workspaceId === admitted.workspaceId);
297
320
  if (get() !== api || next.phase !== 'ready' || next.instanceId !== connection.instanceId || next.epoch !== connection.epoch || matches.length !== 1 || matches[0].bindingId !== admitted.bindingId || matches[0].source !== 'local' || matches[0].status !== 'selected')
298
- revoke(fail('EMPLOYEE_CONTEXT_CHANGED'));
321
+ revoke(fail('EMPLOYEE_CONTEXT_CHANGED'), 'endpoint.workspace-change', [{ field: 'workspace.binding-id', before: admitted.bindingId, after: matches[0]?.bindingId ?? null }, { field: 'workspace.source', before: 'local', after: matches[0]?.source ?? null }, { field: 'workspace.status', before: 'selected', after: matches[0]?.status ?? null }, { field: 'workspace.match-count', before: 1, after: matches.length }]);
299
322
  }), api.onConnectionState(next => { if (get() !== api || next.phase !== 'connected' || next.instanceId !== connection.instanceId || next.epoch !== connection.epoch)
300
- revoke(fail('CONNECTION_LOST')); }), network.onState(event => { if (event.kind === 'lost' || employee.getNetwork() !== network)
301
- revoke(fail('CONNECTION_LOST')); }));
323
+ revoke(fail('CONNECTION_LOST'), 'endpoint.connection-change', [{ field: 'connection.instance-id', before: connection.instanceId, after: next.instanceId }, { field: 'connection.epoch', before: connection.epoch, after: next.epoch }, { field: 'connection.phase', before: 'connected', after: next.phase }]); }), network.onState(event => { if (event.kind === 'lost' || employee.getNetwork() !== network)
324
+ revoke(fail('CONNECTION_LOST'), 'endpoint.network-loss', [{ field: 'network.phase', before: null, after: event.state.phase }]); }));
302
325
  if (host.broker)
303
326
  disposers.push(host.broker.onChange(() => { if (!host.broker.online)
304
- revoke(fail('EMPLOYEE_EXEC_UNKNOWN')); }));
327
+ revoke(fail('EMPLOYEE_EXEC_UNKNOWN'), 'endpoint.broker-offline'); }));
305
328
  live.set(sessionId, entry);
306
329
  try {
307
330
  await validate(request.signal ?? new AbortController().signal);
308
331
  }
309
332
  catch (error) {
310
- await entry.dispose(asNeed(error));
333
+ await entry.dispose(asNeed(error), { layer: 'endpoint', initiator: 'provider.final-validation', reason: closeCode(error), action: 'dispose' });
311
334
  throw error;
312
335
  }
313
336
  return runtime;
@@ -1,3 +1,4 @@
1
+ import { type CloseDiagnostics } from './close-diagnostics.ts';
1
2
  import type { ChildProcessWithoutNullStreams } from 'node:child_process';
2
3
  import type { EmployeeEndpointTarget } from './employee-endpoint.ts';
3
4
  export interface EmployeeShellResult {
@@ -13,6 +14,7 @@ export type EmployeeShell = (request: {
13
14
  signal: AbortSignal;
14
15
  }) => Promise<EmployeeShellResult>;
15
16
  interface Options {
17
+ closeDiagnostics?: CloseDiagnostics;
16
18
  target: EmployeeEndpointTarget;
17
19
  maxBytes: number;
18
20
  killGraceMs: number;
@@ -1,3 +1,4 @@
1
+ import { closeCode } from "./close-diagnostics.js";
1
2
  import { randomUUID } from 'node:crypto';
2
3
  import { EMPLOYEE_TARGET_PYTHON, EMPLOYEE_PYTHON_ERRORS } from "./employee-relay.js";
3
4
  import { NeedError } from "./remote.js";
@@ -15,11 +16,13 @@ const unknown = (code) => new NeedError(code, '本机命令结果未知;没有
15
16
  export function createEmployeeShell(options) {
16
17
  return async ({ command, timeout, signal }) => {
17
18
  signal.throwIfAborted();
19
+ const diagnostics = options.closeDiagnostics?.child();
18
20
  const executionId = randomUUID(), target = options.target;
19
21
  const child = await options.attach({ command: '/usr/bin/python3', args: ['-c', SHELL_PYTHON, JSON.stringify({ endpoint: target.endpoint, bridge: target.bridge, directory: target.local.directory, maxBytes: options.maxBytes, executionId })] });
20
22
  return new Promise((yes, no) => {
21
23
  let settled = false, bytes = 0, stdout = [], stderr = [], reason, grace, timer;
22
24
  const finish = (error, code) => {
25
+ diagnostics?.record({ layer: 'executor', initiator: 'shell.finish', reason: closeCode(error, 'SHELL_FINISHED'), action: 'close', exitCode: code ?? null, changes: [{ field: 'shell.settled', before: settled, after: true }] });
23
26
  if (settled)
24
27
  return;
25
28
  settled = true;
@@ -62,11 +65,20 @@ export function createEmployeeShell(options) {
62
65
  no(unknown('EMPLOYEE_EXIT_UNKNOWN'));
63
66
  return;
64
67
  }
68
+ try {
69
+ diagnostics?.assertRecorded();
70
+ }
71
+ catch (failure) {
72
+ no(failure);
73
+ return;
74
+ }
65
75
  yes({ stdout: out, stderr: err.slice(header.length, start) + err.slice(start + started.length), stderrSource: 'combined-ssh-and-command', exitCode: code, signal: null });
66
76
  };
67
- const stop = (code) => { if (settled || reason)
68
- return; reason = unknown(code); child.kill('SIGTERM'); grace = setTimeout(() => { if (child.exitCode === null && child.signalCode === null)
69
- child.kill('SIGKILL'); finish(reason); }, options.killGraceMs); };
77
+ const stop = (code) => { diagnostics?.record({ layer: 'executor', initiator: 'shell.stop', reason: code, action: 'terminate', changes: [{ field: 'shell.stop-code', before: reason?.code ?? null, after: settled || reason ? reason?.code ?? null : code }] }); if (settled || reason)
78
+ return; reason = unknown(code); child.kill('SIGTERM'); grace = setTimeout(() => { if (child.exitCode === null && child.signalCode === null) {
79
+ diagnostics?.record({ layer: 'executor', initiator: 'shell.kill-grace', reason: reason?.code ?? 'SHELL_CLEANUP', action: 'terminate', signal: 'SIGKILL' });
80
+ child.kill('SIGKILL');
81
+ } finish(reason); }, options.killGraceMs); };
70
82
  const abort = () => stop('EMPLOYEE_CANCELLED_UNKNOWN');
71
83
  const collect = (destination, chunk) => { bytes += chunk.length; if (bytes > options.maxBytes) {
72
84
  stop('EMPLOYEE_OUTPUT_TOO_LARGE_UNKNOWN');
@@ -75,8 +87,8 @@ export function createEmployeeShell(options) {
75
87
  child.stdout.on('data', (b) => collect(stdout, b));
76
88
  child.stderr.on('data', (b) => collect(stderr, b));
77
89
  child.stdin.on('error', () => stop('EMPLOYEE_INPUT_UNKNOWN'));
78
- child.once('error', () => { finish(unknown('EMPLOYEE_TRANSPORT_UNKNOWN')); });
79
- child.once('close', code => finish(reason, code));
90
+ child.once('error', () => { diagnostics?.record({ layer: 'relay', initiator: 'shell.process-error', reason: 'EMPLOYEE_TRANSPORT_UNKNOWN', action: 'failure' }); finish(unknown('EMPLOYEE_TRANSPORT_UNKNOWN')); });
91
+ child.once('close', (code, exitSignal) => { diagnostics?.record({ layer: 'relay', initiator: 'shell.process-close', reason: reason?.code ?? 'PROCESS_EXIT', action: 'exit', exitCode: code, signal: exitSignal }); finish(reason, code); });
80
92
  signal.addEventListener('abort', abort, { once: true });
81
93
  if (signal.aborted)
82
94
  abort();
@@ -1,3 +1,4 @@
1
+ import { type CloseDiagnostics } from './close-diagnostics.ts';
1
2
  import type { EmployeeEndpointTarget } from '@ai-workbench/network/api';
2
3
  import { type BrokerIo, type SshArgv } from './broker.ts';
3
4
  import type { ConnectTarget } from './provider.ts';
@@ -5,6 +6,7 @@ import { type EmployeeExecutionTransport } from './employee-exec.ts';
5
6
  export interface EmployeeSshOptions {
6
7
  target: ConnectTarget;
7
8
  endpoint: EmployeeEndpointTarget;
9
+ closeDiagnostics?: CloseDiagnostics;
8
10
  /** Automatically discovered package binary path on this same employee device. */
9
11
  employeeBinary: string;
10
12
  binarySha256: string;
@@ -1,3 +1,4 @@
1
+ import { closeCode } from "./close-diagnostics.js";
1
2
  import { sshIo } from "./broker.js";
2
3
  import { createEmployeeProcessTransport } from "./employee-exec.js";
3
4
  import { NeedError, shellQuote } from "./remote.js";
@@ -92,7 +93,8 @@ async function currentTarget(options, signal) {
92
93
  await gate();
93
94
  const b = endpoint.bridge, e = endpoint.endpoint;
94
95
  const command = [b.command, 'target', '--settings', b.settingsPath, '--device', e.deviceId, '--if-instance', b.bridgeInstanceId, '--if-generation', String(b.generation), '--if-endpoint', e.endpointInstanceId, '--if-context', e.executionContextId].map(shellQuote).join(' ');
95
- const raw = await awaitSignal(io.run(command, signal), signal);
96
+ const run = () => awaitSignal(io.run(command, signal), signal);
97
+ const raw = await (options.closeDiagnostics ? options.closeDiagnostics.run('endpoint.target', run) : run());
96
98
  const args = readTarget(raw, endpoint, options.acpMaxMessageBytes);
97
99
  await gate();
98
100
  return { io, args, gate };
@@ -107,9 +109,9 @@ export async function createEmployeeSshTransport(options, signal = new AbortCont
107
109
  checkSignal(signal);
108
110
  // EmployeeExecution races the user's open wait; this factory retains any late child cleanup.
109
111
  const child = await io.attach('exec ' + ['/usr/bin/ssh', ...args, remote].map(shellQuote).join(' '));
110
- const transport = createEmployeeProcessTransport(child, options.acpMaxMessageBytes, ['AW_EMPLOYEE_BINARY_UNAVAILABLE', 'AW_EMPLOYEE_BINARY_HASH_FAILED', 'AW_EMPLOYEE_BINARY_HASH_MISMATCH', 'AW_EMPLOYEE_BINARY_VERSION_FAILED', 'AW_EMPLOYEE_BINARY_VERSION_MISMATCH']);
112
+ const transport = createEmployeeProcessTransport(child, options.acpMaxMessageBytes, ['AW_EMPLOYEE_BINARY_UNAVAILABLE', 'AW_EMPLOYEE_BINARY_HASH_FAILED', 'AW_EMPLOYEE_BINARY_HASH_MISMATCH', 'AW_EMPLOYEE_BINARY_VERSION_FAILED', 'AW_EMPLOYEE_BINARY_VERSION_MISMATCH'], options.closeDiagnostics?.capture());
111
113
  if (signal.aborted) {
112
- await transport.close();
114
+ await transport.close({ layer: 'endpoint', initiator: 'ssh.open-abort', reason: 'ABORTED', operation: 'open', action: 'abort' });
113
115
  throw fail('ABORTED');
114
116
  }
115
117
  try {
@@ -117,7 +119,7 @@ export async function createEmployeeSshTransport(options, signal = new AbortCont
117
119
  return transport;
118
120
  }
119
121
  catch (error) {
120
- await transport.close();
122
+ await transport.close({ layer: 'endpoint', initiator: 'ssh.post-open-validation', reason: closeCode(error), operation: 'open', action: 'failure' });
121
123
  throw error;
122
124
  }
123
125
  }
@@ -1,8 +1,10 @@
1
+ import { type CloseCause, type CloseDiagnostics } from './close-diagnostics.ts';
1
2
  import type { DetailObservation } from './conversation-details.ts';
2
3
  import type { EmployeeShell } from './employee-shell.ts';
3
4
  export declare const EMPLOYEE_TOOL_NAMES: readonly ["Read", "Write", "Edit", "Glob", "Grep", "Bash"];
4
5
  export type EmployeeToolName = typeof EMPLOYEE_TOOL_NAMES[number];
5
6
  interface Options {
7
+ closeDiagnostics?: CloseDiagnostics;
6
8
  cwd: string;
7
9
  shell: string;
8
10
  uid: number;
@@ -112,6 +114,6 @@ export declare function createEmployeeTools(options: Options): {
112
114
  } | undefined;
113
115
  isError?: boolean | undefined;
114
116
  }>;
115
- close(): Promise<void>;
117
+ close(cause?: CloseCause): Promise<void>;
116
118
  };
117
119
  export {};