@ai-workbench/remote-agent 0.8.13 → 0.8.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/broker.d.ts CHANGED
@@ -1,8 +1,10 @@
1
+ import { type CloseCause, type CloseObserver } from './close-diagnostics.ts';
1
2
  import { type ChildProcessWithoutNullStreams } from 'node:child_process';
2
3
  import { NeedError, type Need } from './remote.ts';
3
4
  import { type ConnectTarget } from './provider.ts';
4
5
  export type Attachment = ChildProcessWithoutNullStreams;
5
6
  export interface BrokerOptions {
7
+ observeClose?: CloseObserver;
6
8
  maxFrameBytes: number;
7
9
  initialSequence?: number;
8
10
  instanceId?: string;
@@ -64,8 +66,8 @@ export declare class BrokerTransport {
64
66
  onChange(fn: () => void): () => void;
65
67
  private changed;
66
68
  private notice;
67
- fail(error: NeedError): void;
68
- close(): void;
69
+ fail(error: NeedError, cause?: CloseCause): void;
70
+ close(cause?: CloseCause): void;
69
71
  waitOnline(signal?: AbortSignal): Promise<void>;
70
72
  connect(): Promise<void>;
71
73
  private attach;
package/dist/broker.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { closeCode } from "./close-diagnostics.js";
1
2
  import { spawn } from 'node:child_process';
2
3
  import { createHash } from 'node:crypto';
3
4
  import { ProtocolBoundary } from "./protocol-boundary.js";
@@ -39,8 +40,8 @@ export class BrokerTransport {
39
40
  this.boundary = new ProtocolBoundary(options.maxMessageBytes);
40
41
  this.recovering = !!options.recoverySessionId;
41
42
  this.lease.abort();
42
- this.readable = new ReadableStream({ start: c => { this.output = c; }, pull: () => { this.attachment?.stdout.resume(); }, cancel: () => { this.close(); } });
43
- this.writable = new WritableStream({ write: bytes => this.write(bytes), close: () => this.close(), abort: () => this.close() });
43
+ this.readable = new ReadableStream({ start: c => { this.output = c; }, pull: () => { this.attachment?.stdout.resume(); }, cancel: reason => { this.close({ layer: 'broker', initiator: 'broker.reader-cancel', reason: closeCode(reason, 'BROKER_READER_CANCELLED'), action: 'close' }); } });
44
+ this.writable = new WritableStream({ write: bytes => this.write(bytes), close: () => this.close({ layer: 'broker', initiator: 'broker.writer-close', reason: 'BROKER_WRITER_CLOSED', action: 'close' }), abort: reason => this.close({ layer: 'broker', initiator: 'broker.writer-abort', reason: closeCode(reason, 'BROKER_WRITER_ABORTED'), action: 'abort' }) });
44
45
  }
45
46
  get online() { return this.attached && !this.ended; }
46
47
  get generation() { return this.epoch; }
@@ -93,7 +94,8 @@ export class BrokerTransport {
93
94
  changed() { for (const fn of this.listeners)
94
95
  fn(); }
95
96
  notice(code, message) { this.options.onNeed?.({ code, message, requires: '恢复原员工主机连接或显式结束本轮' }); }
96
- fail(error) {
97
+ fail(error, cause = { layer: 'broker', initiator: 'broker.fail', reason: error.code, action: 'failure' }) {
98
+ this.options.observeClose?.({ ...cause, changes: cause.changes ?? [{ field: 'broker.ended', before: this.ended, after: true }] });
97
99
  if (this.ended)
98
100
  return;
99
101
  this.stopHeartbeat();
@@ -109,7 +111,8 @@ export class BrokerTransport {
109
111
  }
110
112
  catch { /* Reader already cancelled. */ }
111
113
  }
112
- close() {
114
+ close(cause = { layer: 'broker', initiator: 'broker.close', reason: 'BROKER_CLOSE_REQUESTED', action: 'close' }) {
115
+ this.options.observeClose?.({ ...cause, changes: cause.changes ?? [{ field: 'broker.ended', before: this.ended, after: true }] });
113
116
  if (this.ended)
114
117
  return;
115
118
  this.stopHeartbeat();
@@ -158,6 +161,7 @@ export class BrokerTransport {
158
161
  async attach() {
159
162
  const child = await this.options.attach(this.cursor);
160
163
  if (this.ended) {
164
+ this.options.observeClose?.({ layer: 'broker', initiator: 'broker.late-attachment', reason: 'SESSION_ENDED', action: 'terminate' });
161
165
  child.kill('SIGTERM');
162
166
  throw new NeedError('SESSION_ENDED', '本轮已结束', '重新连接');
163
167
  }
@@ -189,8 +193,10 @@ export class BrokerTransport {
189
193
  if (policy.enabled) {
190
194
  this.stopHeartbeat();
191
195
  this.heartbeat = setInterval(() => { if (this.online && this.attachment === child) {
192
- child.stdin.write('{"type":"heartbeat"}\n', error => { if (error)
193
- child.kill('SIGTERM'); });
196
+ child.stdin.write('{"type":"heartbeat"}\n', error => { if (error) {
197
+ this.options.observeClose?.({ layer: 'broker', initiator: 'broker.heartbeat-write', reason: 'BROKER_HEARTBEAT_WRITE_FAILED', action: 'terminate' });
198
+ child.kill('SIGTERM');
199
+ } });
194
200
  } }, policy.heartbeatIntervalMs);
195
201
  this.heartbeat.unref?.();
196
202
  }
@@ -206,6 +212,7 @@ export class BrokerTransport {
206
212
  const code = typeof f.code === 'string' && /^[A-Z][A-Z0-9_]{0,63}$/.test(f.code) ? f.code : 'BROKER_PROTOCOL_INVALID';
207
213
  const error = new NeedError(code, '主机会话无法续接(' + code + ');没有重放输入或代答审批', '恢复或显式结束原会话');
208
214
  if (!ready && f.code === 'ATTACH_BUSY') {
215
+ this.options.observeClose?.({ layer: 'broker', initiator: 'broker.attach-busy', reason: error.code, action: 'terminate' });
209
216
  attemptClosed = true;
210
217
  connected.reject(error);
211
218
  child.kill('SIGTERM');
@@ -261,7 +268,9 @@ export class BrokerTransport {
261
268
  fatal(error instanceof NeedError ? error : new NeedError('BROKER_PROTOCOL_INVALID', 'broker 输出帧无法解析', '有效的 broker 输出'));
262
269
  }
263
270
  });
264
- const detached = () => {
271
+ const detached = (cause) => {
272
+ const effective = !(attemptClosed || generation !== this.epoch || this.ended);
273
+ this.options.observeClose?.({ ...cause, changes: cause.changes ?? [{ field: 'broker.attached', before: this.attached, after: effective ? false : this.attached }, { field: 'broker.lease-aborted', before: this.lease.signal.aborted, after: effective ? true : this.lease.signal.aborted }] });
265
274
  if (attemptClosed || generation !== this.epoch || this.ended)
266
275
  return;
267
276
  this.stopHeartbeat();
@@ -281,8 +290,8 @@ export class BrokerTransport {
281
290
  this.notice('CONNECTION_LOST', '连接中断,主机可能正在等待你的审批;重连前不发送审批');
282
291
  void this.options.onDisconnect?.().catch(error => this.fail(error instanceof NeedError ? error : new NeedError('RECONNECT_FAILED', '续接失败,根因未查明', '恢复原连接')));
283
292
  };
284
- child.once('error', detached);
285
- child.once('close', detached);
293
+ child.once('error', () => detached({ layer: 'broker', initiator: 'broker.attachment-error', reason: 'BROKER_ATTACHMENT_ERROR', action: 'failure' }));
294
+ child.once('close', (code, signal) => detached({ layer: 'broker', initiator: 'broker.attachment-close', reason: 'BROKER_ATTACHMENT_CLOSED', action: 'exit', exitCode: code, signal }));
286
295
  return connected.promise.finally(off);
287
296
  }
288
297
  async write(bytes) {
@@ -1,3 +1,4 @@
1
+ import { type CloseCause } from './close-diagnostics.ts';
1
2
  import { type EmployeeLease, type EmployeeLeaseOptions, type EmployeeNetworkApi, type EmployeeWorkspace } from './employee-endpoint.ts';
2
3
  import { type EmployeeMcpOptions } from './employee-mcp.ts';
3
4
  import { createEmployeeShell, type EmployeeShell } from './employee-shell.ts';
@@ -27,7 +28,7 @@ interface Options {
27
28
  mcp: (options: EmployeeMcpOptions) => Promise<{
28
29
  routing: EmployeeRouting;
29
30
  signal?: AbortSignal;
30
- close(): Promise<void>;
31
+ close(cause?: CloseCause): Promise<void>;
31
32
  }>;
32
33
  shell: (options: Parameters<typeof createEmployeeShell>[0]) => EmployeeShell;
33
34
  };
@@ -6,6 +6,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
6
6
  }
7
7
  return path;
8
8
  };
9
+ import { CloseDiagnostics, closeCode } from "./close-diagnostics.js";
9
10
  import { isDeepStrictEqual } from 'node:util';
10
11
  import { openEmployeeLease } from "./employee-endpoint.js";
11
12
  import { openEmployeeMcp } from "./employee-mcp.js";
@@ -94,6 +95,9 @@ export function createClaudeLocalProvider(options) {
94
95
  if (entry && (entry.closing || !isDeepStrictEqual(entry.scope, scope) || entry.lease.signal.aborted))
95
96
  throw failure('EMPLOYEE_CONTEXT_CHANGED');
96
97
  const details = request.detailChannel;
98
+ const closeDiagnostics = entry?.runtime.closeDiagnostics ?? new CloseDiagnostics(event => details.record(event));
99
+ closeDiagnostics.bind(event => details.record(event));
100
+ request.closeDiagnostics = closeDiagnostics;
97
101
  request.timing?.bind(observation => details.record(observation));
98
102
  const trusted = async (ignoreCancellation = false) => {
99
103
  if (!ignoreCancellation)
@@ -130,60 +134,77 @@ export function createClaudeLocalProvider(options) {
130
134
  let privateFailure, privateTail = Promise.resolve();
131
135
  try {
132
136
  const unscoped = (observation) => { if (privateFailure)
133
- throw privateFailure; privateTail = privateTail.then(() => options.recordUnscoped(request, observation)).catch(error => { privateFailure ??= error; void lease?.close().catch(failure => { privateFailure ??= failure; }); }); };
134
- lease = await (options.ports?.lease ?? openEmployeeLease)({ getConnect: () => options.getConnect(), getNetwork: options.getNetwork, readWorkspace: () => options.readWorkspace(request.sessionId), host: target, sessionId: request.sessionId, control: async (_target, command, signal) => (await transport()).run(command, signal), detail: unscoped }, request.signal);
135
- const shell = (options.ports?.shell ?? createEmployeeShell)({ target: lease.target, maxBytes: maximum, killGraceMs: grace, attach: async (spec) => (await transport()).attach([spec.command, ...spec.args].map(shellQuote).join(' ')) });
136
- mcp = await (options.ports?.mcp ?? openEmployeeMcp)({ lease, maxBytes: maximum, killGraceMs: grace, runShell: shell, activeSignal: () => entry?.active?.signal, captureDetail: () => { const sink = entry?.active?.details; if (!sink)
137
+ throw privateFailure; privateTail = privateTail.then(() => options.recordUnscoped(request, observation)).catch(error => { privateFailure ??= error; closeDiagnostics.record({ layer: 'runtime', initiator: 'claude.private-diagnostic-failure', reason: 'CONVERSATION_DETAILS_FAILED', action: 'failure' }); void lease?.close({ layer: 'endpoint', initiator: 'claude.private-diagnostic-failure', reason: 'CONVERSATION_DETAILS_FAILED', action: 'close' }).catch(failure => { privateFailure ??= failure; }); }); };
138
+ lease = await (options.ports?.lease ?? openEmployeeLease)({ closeDiagnostics, getConnect: () => options.getConnect(), getNetwork: options.getNetwork, readWorkspace: () => options.readWorkspace(request.sessionId), host: target, sessionId: request.sessionId, control: async (_target, command, signal) => (await transport()).run(command, signal), detail: unscoped }, request.signal);
139
+ const shell = (options.ports?.shell ?? createEmployeeShell)({ closeDiagnostics, target: lease.target, maxBytes: maximum, killGraceMs: grace, attach: async (spec) => (await transport()).attach([spec.command, ...spec.args].map(shellQuote).join(' ')) });
140
+ mcp = await (options.ports?.mcp ?? openEmployeeMcp)({ closeDiagnostics, lease, maxBytes: maximum, killGraceMs: grace, runShell: shell, activeSignal: () => entry?.active?.signal, captureDetail: () => { const sink = entry?.active?.details; if (!sink)
137
141
  throw failure('EMPLOYEE_NO_ACTIVE_TURN'); return observation => sink.record(observation); }, detail: unscoped });
138
142
  entry = { runtime: undefined, scope, lease, details, request };
139
143
  const row = entry;
140
144
  base = await options.open(target, { name: workspace.workspaceId, path: target.session.root }, openRequest(row, true));
141
145
  const underlying = base, endpoint = mcp;
142
- const close = (detach) => row.closing ??= Promise.resolve().then(async () => {
143
- row.active = undefined;
144
- const errors = [];
145
- try {
146
- await endpoint.close();
147
- }
148
- catch (error) {
149
- errors.push(error);
150
- }
151
- await privateTail;
152
- if (privateFailure)
153
- errors.push(privateFailure);
154
- try {
155
- await (detach ? underlying.detach?.() : underlying.dispose?.());
156
- }
157
- catch (error) {
158
- errors.push(error);
159
- }
160
- if (errors.length)
161
- throw new AggregateError(errors, 'EMPLOYEE_CLEANUP_UNCONFIRMED');
162
- if (entries.get(request.sessionId) === row)
163
- entries.delete(request.sessionId);
164
- });
146
+ const close = (detach, cause = { layer: 'runtime', initiator: detach ? 'claude.runtime-detach' : 'claude.runtime-dispose', reason: detach ? 'RUNTIME_DETACH' : 'RUNTIME_DISPOSE', action: detach ? 'detach' : 'dispose' }) => {
147
+ closeDiagnostics.record(cause);
148
+ return row.closing ??= Promise.resolve().then(async () => {
149
+ row.active = undefined;
150
+ const errors = [];
151
+ try {
152
+ await endpoint.close({ layer: 'relay', initiator: 'claude.cleanup-mcp', reason: 'RUNTIME_CLEANUP', action: 'close' });
153
+ }
154
+ catch (error) {
155
+ errors.push(error);
156
+ }
157
+ await privateTail;
158
+ if (privateFailure)
159
+ errors.push(privateFailure);
160
+ try {
161
+ await (detach ? underlying.detach?.(cause) : underlying.dispose?.(cause));
162
+ }
163
+ catch (error) {
164
+ errors.push(error);
165
+ }
166
+ try {
167
+ await row.details.flush();
168
+ }
169
+ catch (error) {
170
+ errors.push(error);
171
+ }
172
+ try {
173
+ closeDiagnostics.assertRecorded();
174
+ }
175
+ catch (error) {
176
+ errors.push(error);
177
+ }
178
+ if (errors.length)
179
+ throw new AggregateError(errors, 'EMPLOYEE_CLEANUP_UNCONFIRMED');
180
+ if (entries.get(request.sessionId) === row)
181
+ entries.delete(request.sessionId);
182
+ });
183
+ };
165
184
  const executionSignal = AbortSignal.any([lease.signal, ...mcp.signal ? [mcp.signal] : []]);
166
- row.runtime = { ...underlying, cwd: workspace.directory.path, details, connection: { ...connection }, binding: { bindingId: item.bindingId, workspaceId: workspace.workspaceId, revision: state.revision }, execution: { hostCwd: target.session.root, routing: mcp.routing, notice: NOTICE, signal: executionSignal, scope: { source: 'local', cwd: workspace.directory.path, hostCwd: target.session.root, uid: lease.target.endpoint.remoteUid, claudeAcpVersion: '0.94.0', sdkVersion: '0.3.303', unsupported: ['subagents', 'internal-direct-calls', 'binary-read', 'background-shell'] }, validate: async (signal) => { executionSignal.throwIfAborted(); await row.lease.validate(signal); }, beginTurn: async (signal) => { executionSignal.throwIfAborted(); if (row.closing || row.active)
167
- throw failure('TURN_BUSY'); await row.lease.validate(signal); executionSignal.throwIfAborted(); row.active = { signal, details: row.details }; }, endTurn: () => { row.active = undefined; } }, dispose: () => close(false), detach: () => close(true) };
185
+ row.runtime = { ...underlying, closeDiagnostics, cwd: workspace.directory.path, details, connection: { ...connection }, binding: { bindingId: item.bindingId, workspaceId: workspace.workspaceId, revision: state.revision }, execution: { hostCwd: target.session.root, routing: mcp.routing, notice: NOTICE, signal: executionSignal, scope: { source: 'local', cwd: workspace.directory.path, hostCwd: target.session.root, uid: lease.target.endpoint.remoteUid, claudeAcpVersion: '0.94.0', sdkVersion: '0.3.303', unsupported: ['subagents', 'internal-direct-calls', 'binary-read', 'background-shell'] }, validate: async (signal) => { executionSignal.throwIfAborted(); await row.lease.validate(signal); }, beginTurn: async (signal) => { executionSignal.throwIfAborted(); if (row.closing || row.active)
186
+ throw failure('TURN_BUSY'); await row.lease.validate(signal); executionSignal.throwIfAborted(); row.active = { signal, details: row.details }; }, endTurn: () => { row.active = undefined; } }, dispose: cause => close(false, cause), detach: cause => close(true, cause) };
168
187
  entries.set(request.sessionId, row);
169
- lease.signal.addEventListener('abort', () => { void close(false).catch(() => { }); }, { once: true });
188
+ lease.signal.addEventListener('abort', () => { void close(false, { layer: 'endpoint', initiator: 'claude.lease-abort', reason: closeCode(lease?.signal.reason, 'EMPLOYEE_CONTEXT_CHANGED'), action: 'abort' }).catch(() => { }); }, { once: true });
170
189
  if (lease.signal.aborted) {
171
- await close(false);
190
+ await close(false, { layer: 'endpoint', initiator: 'claude.open-aborted', reason: closeCode(lease.signal.reason, 'EMPLOYEE_CONTEXT_CHANGED'), action: 'abort' });
172
191
  throw failure('EMPLOYEE_CONTEXT_CHANGED');
173
192
  }
174
193
  return row.runtime;
175
194
  }
176
195
  catch (error) {
196
+ closeDiagnostics.record({ layer: 'runtime', initiator: 'claude.open-failure', reason: closeCode(error, 'EMPLOYEE_OPEN_FAILED'), action: 'failure' });
197
+ const cause = { layer: 'runtime', initiator: 'claude.open-cleanup', reason: 'EMPLOYEE_OPEN_FAILED', action: 'close' };
177
198
  const cleanup = [];
178
199
  try {
179
- await mcp?.close();
180
- await lease?.close();
200
+ await mcp?.close(cause);
201
+ await lease?.close(cause);
181
202
  }
182
203
  catch (e) {
183
204
  cleanup.push(e);
184
205
  }
185
206
  try {
186
- await base?.dispose?.();
207
+ await base?.dispose?.(cause);
187
208
  }
188
209
  catch (e) {
189
210
  cleanup.push(e);
@@ -197,6 +218,12 @@ export function createClaudeLocalProvider(options) {
197
218
  catch (e) {
198
219
  cleanup.push(e);
199
220
  }
221
+ try {
222
+ closeDiagnostics.assertRecorded();
223
+ }
224
+ catch (e) {
225
+ cleanup.push(e);
226
+ }
200
227
  if (cleanup.length)
201
228
  throw new AggregateError([error, ...cleanup], 'EMPLOYEE_OPEN_CLEANUP_UNCONFIRMED');
202
229
  throw error;
@@ -0,0 +1,56 @@
1
+ import type { DetailObservation } from './conversation-details.ts';
2
+ import { NeedError } from './remote.ts';
3
+ export interface CloseChange {
4
+ field: string;
5
+ before: string | number | boolean | null;
6
+ after: string | number | boolean | null;
7
+ }
8
+ export interface CloseCause {
9
+ layer: 'acp' | 'adapter' | 'broker' | 'endpoint' | 'relay' | 'executor' | 'runtime';
10
+ initiator: string;
11
+ reason: string;
12
+ action?: 'close' | 'dispose' | 'detach' | 'invalidate' | 'abort' | 'failure' | 'exit' | 'eof' | 'terminate';
13
+ operation?: string;
14
+ exitCode?: number | null;
15
+ signal?: string | null;
16
+ remoteReason?: 'unavailable';
17
+ changedFields?: readonly string[];
18
+ changes?: readonly CloseChange[];
19
+ }
20
+ export type CloseObserver = (cause: CloseCause) => void;
21
+ /** Only typed codes, never exception messages, causes, stderr or RPC data. */
22
+ export declare function closeCode(error: unknown, fallback?: string): string;
23
+ /** Retain trusted typed codes through wrappers; never traverse error text or invoke getters. */
24
+ export declare function closeErrorCodes(error: unknown, fallback?: string): string[];
25
+ export declare function closeFailure(code: string, message: string, requires: string, codes: readonly string[]): NeedError;
26
+ /** Assign order before abort/kill; persist through the existing asynchronous D19 outbox. */
27
+ export declare class CloseDiagnostics {
28
+ private sink;
29
+ readonly parentId?: string | undefined;
30
+ private state;
31
+ readonly id: `${string}-${string}-${string}-${string}-${string}`;
32
+ private sequence;
33
+ private pending;
34
+ private firstValue?;
35
+ private readonly started;
36
+ stage: string;
37
+ constructor(sink: (detail: DetailObservation) => void, parentId?: string | undefined, state?: {
38
+ failure?: {
39
+ error: unknown;
40
+ };
41
+ });
42
+ run<T>(operation: string, work: () => Promise<T>): Promise<T>;
43
+ get recordingFailureCode(): string | undefined;
44
+ get first(): Readonly<Record<string, unknown>> | undefined;
45
+ bind(sink: (detail: DetailObservation) => void): void;
46
+ /** A delayed resource callback retains its original accepted detail channel. */
47
+ capture(): CloseObserver;
48
+ child(sink?: (detail: DetailObservation) => void): CloseDiagnostics;
49
+ record(cause: CloseCause): void;
50
+ private recordUsing;
51
+ assertRecorded(): void;
52
+ }
53
+ /** Run after owned cleanup; expose both the primary error code and persistence failure. */
54
+ export declare function flushCloseDiagnostics(diagnostics: CloseDiagnostics, flush: (() => Promise<void>) | undefined, primary?: {
55
+ error: unknown;
56
+ }): Promise<void>;
@@ -0,0 +1,104 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { NeedError } from "./remote.js";
3
+ /** Only typed codes, never exception messages, causes, stderr or RPC data. */
4
+ export function closeCode(error, fallback = 'CLOSE_REASON_UNKNOWN') {
5
+ return error instanceof NeedError && /^[A-Z][A-Z0-9_]*$/.test(error.code) ? error.code : fallback;
6
+ }
7
+ /** Retain trusted typed codes through wrappers; never traverse error text or invoke getters. */
8
+ export function closeErrorCodes(error, fallback = 'CLOSE_REASON_UNKNOWN') {
9
+ const queue = [error], seen = new Set(), codes = new Set();
10
+ while (queue.length) {
11
+ const next = queue.pop();
12
+ if (!(next instanceof NeedError || next instanceof AggregateError) || seen.has(next))
13
+ continue;
14
+ seen.add(next);
15
+ if (next instanceof NeedError) {
16
+ const code = Object.getOwnPropertyDescriptor(next, 'code');
17
+ if (code && 'value' in code && typeof code.value === 'string' && /^[A-Z][A-Z0-9_]*$/.test(code.value))
18
+ codes.add(code.value);
19
+ }
20
+ const cause = Object.getOwnPropertyDescriptor(next, 'cause');
21
+ if (cause && 'value' in cause)
22
+ queue.push(cause.value);
23
+ if (next instanceof AggregateError) {
24
+ const errors = Object.getOwnPropertyDescriptor(next, 'errors');
25
+ if (errors && 'value' in errors && Array.isArray(errors.value))
26
+ for (const child of errors.value)
27
+ queue.push(child);
28
+ }
29
+ }
30
+ return codes.size ? [...codes] : [fallback];
31
+ }
32
+ export function closeFailure(code, message, requires, codes) {
33
+ return Object.assign(new NeedError(code, message, requires), { cause: new AggregateError([...new Set(codes)].map(value => new NeedError(value, '关闭收尾代码 ' + value, '按安全代码核对')), 'CLOSE_AND_DIAGNOSTIC_FAILURES') });
34
+ }
35
+ /** Assign order before abort/kill; persist through the existing asynchronous D19 outbox. */
36
+ export class CloseDiagnostics {
37
+ sink;
38
+ parentId;
39
+ state;
40
+ id = randomUUID();
41
+ sequence = 0;
42
+ pending = new Map();
43
+ firstValue;
44
+ started = performance.now();
45
+ stage = 'runtime-admission';
46
+ constructor(sink, parentId, state = {}) {
47
+ this.sink = sink;
48
+ this.parentId = parentId;
49
+ this.state = state;
50
+ }
51
+ async run(operation, work) { this.pending.set(operation, (this.pending.get(operation) ?? 0) + 1); try {
52
+ return await work();
53
+ }
54
+ finally {
55
+ const count = this.pending.get(operation) - 1;
56
+ if (count)
57
+ this.pending.set(operation, count);
58
+ else
59
+ this.pending.delete(operation);
60
+ } }
61
+ get recordingFailureCode() { return this.state.failure ? closeCode(this.state.failure.error, 'CLOSE_RECORD_FAILED') : undefined; }
62
+ get first() { return this.firstValue; }
63
+ bind(sink) { this.sink = sink; }
64
+ /** A delayed resource callback retains its original accepted detail channel. */
65
+ capture() { const sink = this.sink; return cause => this.recordUsing(sink, cause); }
66
+ child(sink = this.sink) { const child = new CloseDiagnostics(sink, this.id, this.state); child.stage = this.stage; return child; }
67
+ record(cause) { this.recordUsing(this.sink, cause); }
68
+ recordUsing(sink, cause) {
69
+ const first = this.sequence === 0;
70
+ const metadata = { ...cause.changedFields ? { changedFields: Object.freeze([...cause.changedFields]) } : {}, changes: Object.freeze((cause.changes ?? [{ field: 'observer.close-sequence', before: this.sequence, after: this.sequence + 1 }]).map(change => Object.freeze({ field: change.field, before: change.before, after: change.after }))), layer: cause.layer, initiator: cause.initiator, reason: cause.reason, ...cause.action ? { action: cause.action } : {}, ...cause.operation ? { operation: cause.operation } : {}, ...cause.exitCode !== undefined ? { exitCode: cause.exitCode } : {}, ...cause.signal !== undefined ? { signal: cause.signal } : {}, ...cause.remoteReason ? { remoteReason: cause.remoteReason } : {} };
71
+ const payload = Object.freeze({ ...metadata, pendingOperations: Object.freeze([...this.pending.keys()]), closeId: this.id, ...this.parentId ? { parentCloseId: this.parentId } : {}, closeSequence: ++this.sequence, first, stage: this.stage, clock: 'client-monotonic', elapsedMs: performance.now() - this.started });
72
+ this.firstValue ??= payload;
73
+ try {
74
+ sink({ kind: 'session', severity: 'warning', code: first ? 'REMOTE_CLOSE_FIRST' : 'REMOTE_CLOSE_NEXT', summary: first ? '首次观察到关闭(不等同根因)' : '后续关闭或清理', payload });
75
+ }
76
+ catch (error) {
77
+ this.state.failure ??= { error };
78
+ }
79
+ }
80
+ assertRecorded() { if (this.state.failure)
81
+ throw new NeedError('CONVERSATION_DETAILS_FAILED', '关闭原因保存未完成;首次观察原因 ' + String(this.firstValue?.reason ?? 'CLOSE_REASON_UNKNOWN') + '。', '关闭诊断保存代码 ' + this.recordingFailureCode + ';不将保存失败当作原始关闭原因'); }
82
+ }
83
+ /** Run after owned cleanup; expose both the primary error code and persistence failure. */
84
+ export async function flushCloseDiagnostics(diagnostics, flush, primary) {
85
+ const reporting = [];
86
+ try {
87
+ await flush?.();
88
+ }
89
+ catch (error) {
90
+ reporting.push(...closeErrorCodes(error, 'CLOSE_FLUSH_FAILED'));
91
+ }
92
+ try {
93
+ diagnostics.assertRecorded();
94
+ }
95
+ catch (error) {
96
+ reporting.push(diagnostics.recordingFailureCode ?? closeCode(error, 'CLOSE_RECORD_FAILED'));
97
+ }
98
+ if (reporting.length) {
99
+ const primaryCodes = primary ? closeErrorCodes(primary.error, 'REMOTE_CLEANUP_UNCONFIRMED') : [];
100
+ throw closeFailure('CONVERSATION_DETAILS_FAILED', '关闭诊断保存未完成;原关闭与清理结果需核对。', '首次关闭 ' + String(diagnostics.first?.reason ?? 'CLOSE_REASON_UNKNOWN') + ';清理 ' + (primaryCodes.join(',') || 'closed') + ';保存 ' + reporting.join(','), [...primaryCodes, ...reporting]);
101
+ }
102
+ if (primary)
103
+ throw primary.error;
104
+ }
@@ -283,6 +283,10 @@ function modelConfigurationStructure(value, level = 'root') {
283
283
  return Object.fromEntries(Object.entries(value).map(([key, child]) => {
284
284
  if (typeof child === 'string' && strings[level].includes(key))
285
285
  return [key, child];
286
+ if (level === 'root' && key === 'resolvedModelSource' && child === 'host-user-catalog')
287
+ return [key, child];
288
+ if (level === 'root' && key === 'sessionModelReported' && child === false)
289
+ return [key, false];
286
290
  if (level === 'root' && key === 'status' && child === 'remote-configured')
287
291
  return [key, child];
288
292
  if (level === 'actual' && typeof child === 'boolean' && ['confirmed', 'effortAvailable'].includes(key))
@@ -331,6 +335,58 @@ function employeeStructure(value) {
331
335
  return [key, opaqueStructure(child)];
332
336
  }));
333
337
  }
338
+ /** Closure metadata has no arbitrary exception or transport payload fields. */
339
+ function closeStructure(value) {
340
+ if (!value || typeof value !== 'object' || Array.isArray(value))
341
+ return opaqueStructure(value);
342
+ return Object.fromEntries(Object.entries(value).map(([key, child]) => {
343
+ if (key === 'changes' && Array.isArray(child))
344
+ return [key, child.map(change => {
345
+ if (!change || typeof change !== 'object' || Array.isArray(change))
346
+ return opaqueStructure(change);
347
+ const field = change.field;
348
+ const known = typeof field === 'string' && (/^(?:observer.close-sequence|executor.ended|transport.closed|transport.termination-requested|provider.ended|broker.ended|broker.attached|broker.lease-aborted|lease.aborted|lease.closed|mcp.ended|mcp.closing|mcp.poisoned|shell.settled|shell.stop-code|tools.aborted|tools.stop-code|acp.close-requested|adapter.turn-aborted|adapter.detached|adapter.shutting-down|workspace.binding-id|workspace.source|workspace.status|workspace.match-count|network.phase|catalog.api-replaced|catalog.capability|catalog.signature-sha256|connection.instance-id|connection.epoch|connection.phase|connection.need-code)$/.test(field) || /^catalog\.(?:codex|claude)\.(?:ok|error-code|instance-id|epoch|provider|target-sha256|metadata-sha256)$/.test(field));
349
+ if (!known)
350
+ return opaqueStructure(change);
351
+ const scalar = (value) => value === null || typeof value === 'boolean' || typeof value === 'number' && Number.isFinite(value) || typeof value === 'string' && /^[a-zA-Z0-9_.:@-]+$/.test(value) ? value : opaqueStructure(value);
352
+ return Object.fromEntries(Object.entries(change).map(([name, value]) => [name, name === 'field' ? field : ['before', 'after'].includes(name) ? scalar(value) : opaqueStructure(value)]));
353
+ })];
354
+ if (key === 'pendingOperations' && Array.isArray(child))
355
+ return [key, child.map(field => typeof field === 'string' && /^[a-z][a-z0-9._/-]*$/.test(field) ? field : opaqueStructure(field))];
356
+ if (key === 'changedFields' && Array.isArray(child))
357
+ return [key, child.map(field => typeof field === 'string' && /^[a-z][a-z-]*$/.test(field) ? field : opaqueStructure(field))];
358
+ if (['closeId', 'parentCloseId'].includes(key) && typeof child === 'string' && /^[a-f0-9-]+$/.test(child))
359
+ return [key, child];
360
+ if (['layer', 'initiator', 'action', 'operation', 'stage', 'clock'].includes(key) && typeof child === 'string' && /^[a-z][a-z0-9._/-]*$/.test(child))
361
+ return [key, child];
362
+ if (key === 'reason' && typeof child === 'string' && /^[A-Z][A-Z0-9_]*$/.test(child))
363
+ return [key, child];
364
+ if (key === 'remoteReason' && child === 'unavailable' || key === 'first' && typeof child === 'boolean')
365
+ return [key, child];
366
+ if (['closeSequence', 'elapsedMs'].includes(key) && typeof child === 'number' && Number.isFinite(child) && child >= 0)
367
+ return [key, child];
368
+ if (key === 'exitCode' && (child === null || typeof child === 'number' && Number.isSafeInteger(child)))
369
+ return [key, child];
370
+ if (key === 'signal' && (child === null || typeof child === 'string' && /^SIG[A-Z]+$/.test(child)))
371
+ return [key, child];
372
+ return [key, opaqueStructure(child)];
373
+ }));
374
+ }
375
+ function catalogScopeStructure(value, nested = false) {
376
+ if (value === null || typeof value !== 'object' || Array.isArray(value) || isSanitizedRedaction(value))
377
+ return opaqueStructure(value);
378
+ return Object.fromEntries(Object.entries(value).map(([key, child]) => {
379
+ if (!nested && key === 'reason' && child === 'CATALOG_PRESENTATION_CHANGED')
380
+ return [key, child];
381
+ if (!nested && ['before', 'after'].includes(key))
382
+ return [key, catalogScopeStructure(child, true)];
383
+ if (nested && key === 'presentationSha256' && (child === null || typeof child === 'string' && /^[a-f0-9]{64}$/.test(child)))
384
+ return [key, child];
385
+ if (nested && key === 'needCode' && (child === null || typeof child === 'string' && /^[A-Z][A-Z0-9_]*$/.test(child)))
386
+ return [key, child];
387
+ return [key, opaqueStructure(child)];
388
+ }));
389
+ }
334
390
  export function sanitizeObservation(observation, protectModelMaterial = false) {
335
391
  const payload = snapshotPayload(observation.payload, new Set(), protectModelMaterial && observation.kind !== 'commands');
336
392
  return freezeDeep({
@@ -338,15 +394,17 @@ export function sanitizeObservation(observation, protectModelMaterial = false) {
338
394
  code: sanitizeDisplayLabel(observation.code), summary: sanitizeDisplayLabel(observation.summary),
339
395
  ...(observation.method === undefined ? {} : { method: sanitizeDisplayLabel(observation.method) }),
340
396
  payload: !protectModelMaterial ? payload
341
- : observation.kind === 'session' && observation.code === 'CODEX_CONTEXT_SESSION_REPLACED' ? contextTransitionStructure(payload)
342
- : observation.kind === 'session' && ['MODEL_CONFIGURATION_CONFIRMED', 'MODEL_CONFIGURATION_FAILED'].includes(observation.code) ? modelConfigurationStructure(payload)
343
- : observation.kind === 'commands' ? catalogStructure(payload)
344
- : observation.kind === 'usage' ? usageStructure(payload)
345
- : observation.kind === 'session' && observation.code === 'REMOTE_STAGE_TIMING' ? timingStructure(payload)
346
- : observation.kind === 'session' && ['CLAUDE_EMPLOYEE_OPERATION', 'CLAUDE_EMPLOYEE_PROCESS', 'CLAUDE_EMPLOYEE_REVOKED', 'CLAUDE_EMPLOYEE_SCOPE'].includes(observation.code) ? employeeStructure(payload)
347
- : observation.kind === 'need' ? payload
348
- : observation.kind === 'tool' || ['SDK_ITEM', 'SDK_TOOL', 'ACP_SESSION_UPDATE', 'ACP_IDLE_UPDATE'].includes(observation.code) ? toolStructure(payload)
349
- : opaqueStructure(payload),
397
+ : observation.kind === 'session' && observation.code === 'CATALOG_SCOPE_INFORMATION' ? catalogScopeStructure(payload)
398
+ : observation.kind === 'session' && observation.code === 'CODEX_CONTEXT_SESSION_REPLACED' ? contextTransitionStructure(payload)
399
+ : observation.kind === 'session' && ['MODEL_CONFIGURATION_CONFIRMED', 'MODEL_CONFIGURATION_FAILED', 'MODEL_CONFIGURATION_INFORMATION', 'MODEL_CONFIGURATION_CHANGED_DURING_TURN', 'TURN_GATE_INFORMATION'].includes(observation.code) ? modelConfigurationStructure(payload)
400
+ : observation.kind === 'session' && ['REMOTE_CLOSE_FIRST', 'REMOTE_CLOSE_NEXT'].includes(observation.code) ? closeStructure(payload)
401
+ : observation.kind === 'commands' ? catalogStructure(payload)
402
+ : observation.kind === 'usage' ? usageStructure(payload)
403
+ : observation.kind === 'session' && observation.code === 'REMOTE_STAGE_TIMING' ? timingStructure(payload)
404
+ : observation.kind === 'session' && ['CLAUDE_EMPLOYEE_OPERATION', 'CLAUDE_EMPLOYEE_PROCESS', 'CLAUDE_EMPLOYEE_REVOKED', 'CLAUDE_EMPLOYEE_SCOPE'].includes(observation.code) ? employeeStructure(payload)
405
+ : observation.kind === 'need' ? payload
406
+ : observation.kind === 'tool' || ['SDK_ITEM', 'SDK_TOOL', 'ACP_SESSION_UPDATE', 'ACP_IDLE_UPDATE'].includes(observation.code) ? toolStructure(payload)
407
+ : opaqueStructure(payload),
350
408
  });
351
409
  }
352
410
  function validatedScope(scope) {
@@ -1,3 +1,4 @@
1
+ import { type CloseCause, type CloseDiagnostics } from './close-diagnostics.ts';
1
2
  import type { ConnectApi as LApi, LocalWorkspaceContextRequest } from '@ai-workbench/connect/api';
2
3
  import type { ConnectTarget } from './provider.ts';
3
4
  import type { DetailObservation } from './conversation-details.ts';
@@ -66,6 +67,7 @@ export interface EmployeeWorkspace {
66
67
  };
67
68
  }
68
69
  export interface EmployeeLeaseOptions {
70
+ closeDiagnostics?: CloseDiagnostics;
69
71
  getConnect: () => EmployeeConnectApi | undefined;
70
72
  getNetwork: () => EmployeeNetworkApi | undefined;
71
73
  readWorkspace: () => Promise<EmployeeWorkspace>;
@@ -86,6 +88,6 @@ export interface EmployeeLease {
86
88
  request: Readonly<LocalWorkspaceContextRequest>;
87
89
  signal: AbortSignal;
88
90
  validate(callerSignal?: AbortSignal): Promise<EmployeeEndpointTarget>;
89
- close(): Promise<void>;
91
+ close(cause?: CloseCause): Promise<void>;
90
92
  }
91
93
  export declare function openEmployeeLease(options: EmployeeLeaseOptions, initialSignal?: AbortSignal): Promise<EmployeeLease>;
@@ -1,3 +1,4 @@
1
+ import { closeCode } from "./close-diagnostics.js";
1
2
  import { lstat, realpath } from 'node:fs/promises';
2
3
  import { isDeepStrictEqual } from 'node:util';
3
4
  import { sameTarget, unwrap } from "./provider.js";
@@ -41,7 +42,7 @@ export async function openEmployeeLease(options, initialSignal) {
41
42
  throw fail();
42
43
  const request = { instanceId: connection.instanceId, epoch: connection.epoch, workspaceId: workspace.workspaceId, expectedBindingId: items[0].bindingId };
43
44
  let target, closed = false, diagnosticFailure;
44
- const invalidate = (code) => { if (lifetime.signal.aborted)
45
+ const invalidate = (code, initiator = 'lease.validate') => { options.closeDiagnostics?.record({ layer: 'endpoint', initiator, reason: code, action: 'invalidate', changes: [{ field: 'lease.aborted', before: lifetime.signal.aborted, after: true }] }); if (lifetime.signal.aborted)
45
46
  return; try {
46
47
  options.detail({ kind: 'session', severity: 'warning', code: 'CLAUDE_EMPLOYEE_REVOKED', summary: '本机旧绑定已停止派发,在途结果按实际确认;不会重放', payload: { reason: code, workspaceId: request.workspaceId, bindingId: request.expectedBindingId } });
47
48
  }
@@ -59,14 +60,14 @@ export async function openEmployeeLease(options, initialSignal) {
59
60
  throw fail();
60
61
  lifetime.signal.throwIfAborted();
61
62
  };
62
- const changed = () => { try {
63
+ const changed = (initiator) => { try {
63
64
  current();
64
65
  }
65
66
  catch {
66
- invalidate('EMPLOYEE_CONTEXT_CHANGED');
67
+ invalidate('EMPLOYEE_CONTEXT_CHANGED', initiator);
67
68
  } };
68
- offs.push(api.onConnectionState(changed), api.onWorkspaceLocationState(changed), network.onState(event => { if (event?.kind === 'lost' || network.getState().phase !== 'online')
69
- invalidate('EMPLOYEE_ENDPOINT_UNAVAILABLE'); }));
69
+ offs.push(api.onConnectionState(() => changed('lease.connection-change')), api.onWorkspaceLocationState(() => changed('lease.workspace-change')), network.onState(event => { if (event?.kind === 'lost' || network.getState().phase !== 'online')
70
+ invalidate('EMPLOYEE_ENDPOINT_UNAVAILABLE', 'lease.network-loss'); }));
70
71
  async function validate(callerSignal) {
71
72
  if (diagnosticFailure)
72
73
  throw diagnosticFailure;
@@ -124,7 +125,7 @@ export async function openEmployeeLease(options, initialSignal) {
124
125
  }
125
126
  try {
126
127
  await validate(initialSignal);
127
- return { target: structuredClone(target), request: Object.freeze({ ...request }), signal: lifetime.signal, validate, close: async () => { if (!closed) {
128
+ return { target: structuredClone(target), request: Object.freeze({ ...request }), signal: lifetime.signal, validate, close: async (cause = { layer: 'endpoint', initiator: 'lease.close', reason: 'EMPLOYEE_EXECUTOR_CLOSED', action: 'close' }) => { options.closeDiagnostics?.record({ ...cause, changes: cause.changes ?? [{ field: 'lease.closed', before: closed, after: true }] }); if (!closed) {
128
129
  closed = true;
129
130
  lifetime.abort(fail('EMPLOYEE_EXECUTOR_CLOSED'));
130
131
  for (const off of offs)
@@ -133,6 +134,7 @@ export async function openEmployeeLease(options, initialSignal) {
133
134
  throw diagnosticFailure; } };
134
135
  }
135
136
  catch (error) {
137
+ options.closeDiagnostics?.record({ layer: 'endpoint', initiator: 'lease.open-failure', reason: closeCode(error), action: 'failure' });
136
138
  for (const off of offs)
137
139
  off();
138
140
  lifetime.abort();