@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
@@ -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();
@@ -1,3 +1,4 @@
1
+ import { type CloseCause, type CloseDiagnostics, type CloseObserver } from './close-diagnostics.ts';
1
2
  import type { ChildProcessWithoutNullStreams } from 'node:child_process';
2
3
  import { NeedError } from './remote.ts';
3
4
  export type EmployeeMessage = Record<string, unknown>;
@@ -5,10 +6,11 @@ export interface EmployeeExecutionTransport {
5
6
  write(message: EmployeeMessage, signal?: AbortSignal): Promise<void>;
6
7
  read(signal?: AbortSignal): Promise<EmployeeMessage | null>;
7
8
  /** Closing a carrier never proves that an employee command completed. */
8
- close(): void | Promise<void>;
9
+ close(cause?: CloseCause): void | Promise<void>;
9
10
  }
10
11
  export interface EmployeeExecutionOptions {
11
12
  bindingId: string;
13
+ closeDiagnostics?: CloseDiagnostics;
12
14
  acpMaxMessageBytes: number;
13
15
  /** Rechecks the current L CAS/directory/account and N/S five-tuple. No cached permit. */
14
16
  validate(signal: AbortSignal): Promise<void>;
@@ -42,12 +44,13 @@ export declare class EmployeeExecution {
42
44
  private gate;
43
45
  private stop;
44
46
  private closeTransport;
45
- invalidate(error?: NeedError): void;
47
+ invalidate(error?: NeedError, cause?: CloseCause): void;
46
48
  private cleanup;
47
- dispose(): Promise<void>;
49
+ dispose(cause?: CloseCause): Promise<void>;
48
50
  handle(params: unknown, signal?: AbortSignal): Promise<EmployeeExecutionResult>;
51
+ private handleRequest;
49
52
  private open;
50
53
  private read;
51
54
  }
52
55
  /** NDJSON carrier with byte bounds and backpressure derived solely from acpMaxMessageBytes. */
53
- export declare function createEmployeeProcessTransport(child: ChildProcessWithoutNullStreams, acpMaxMessageBytes: number, stderrCodes?: readonly string[]): EmployeeExecutionTransport;
56
+ export declare function createEmployeeProcessTransport(child: ChildProcessWithoutNullStreams, acpMaxMessageBytes: number, stderrCodes?: readonly string[], observe?: CloseObserver): EmployeeExecutionTransport;
@@ -1,3 +1,4 @@
1
+ import { closeCode } from "./close-diagnostics.js";
1
2
  import { randomUUID } from 'node:crypto';
2
3
  import { NeedError } from "./remote.js";
3
4
  const object = (value) => !!value && typeof value === 'object' && !Array.isArray(value) && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
@@ -80,7 +81,8 @@ export class EmployeeExecution {
80
81
  active() { if (this.ended)
81
82
  throw this.error ?? need('EMPLOYEE_EXEC_CLOSED'); }
82
83
  async gate(signal) { this.active(); cancelled(signal); await waiting(this.options.validate(signal), signal); this.active(); cancelled(signal); }
83
- stop(error) {
84
+ stop(error, cause = { layer: 'executor', initiator: 'executor.stop', reason: closeCode(error, 'EMPLOYEE_EXEC_CLOSED'), action: 'close' }, observe = this.options.closeDiagnostics?.capture()) {
85
+ observe?.({ ...cause, changes: cause.changes ?? [{ field: 'executor.ended', before: this.ended, after: true }] });
84
86
  if (this.ended)
85
87
  return;
86
88
  this.ended = true;
@@ -89,12 +91,13 @@ export class EmployeeExecution {
89
91
  const io = this.io;
90
92
  this.io = undefined;
91
93
  if (io)
92
- this.closeTransport(io);
94
+ this.closeTransport(io, undefined, observe);
93
95
  }
94
- closeTransport(io) {
96
+ closeTransport(io, cause = { layer: 'executor', initiator: 'executor.transport-cleanup', reason: 'EXECUTOR_CLEANUP', action: 'close' }, observe = this.options.closeDiagnostics?.capture()) {
97
+ observe?.(cause);
95
98
  let closing;
96
99
  try {
97
- closing = Promise.resolve(io.close());
100
+ closing = Promise.resolve(io.close(cause));
98
101
  }
99
102
  catch {
100
103
  closing = Promise.reject(need('EMPLOYEE_EXEC_UNKNOWN'));
@@ -103,28 +106,32 @@ export class EmployeeExecution {
103
106
  // Revocation starts cleanup without a caller waiting yet; retain rejection for close/dispose.
104
107
  void this.closing.catch(() => { });
105
108
  }
106
- invalidate(error = need('EMPLOYEE_CONTEXT_CHANGED')) { this.stop(error); }
109
+ invalidate(error = need('EMPLOYEE_CONTEXT_CHANGED'), cause) { this.stop(error, cause ?? { layer: 'executor', initiator: 'executor.invalidate', reason: error.code, action: 'invalidate' }); }
107
110
  async cleanup() { await this.factoryReceipt; await this.closing; }
108
- async dispose() { this.stop(); await this.cleanup(); }
111
+ async dispose(cause = { layer: 'executor', initiator: 'executor.dispose', reason: 'EXECUTOR_DISPOSE', action: 'dispose' }) { this.stop(undefined, cause); await this.cleanup(); }
109
112
  async handle(params, signal) {
110
- const request = this.parse(params);
113
+ const request = this.parse(params), observe = this.options.closeDiagnostics?.capture();
114
+ const run = () => this.handleRequest(request, signal, observe);
115
+ return this.options.closeDiagnostics ? this.options.closeDiagnostics.run('employee-exec.' + request.operation, run) : run();
116
+ }
117
+ async handleRequest(request, signal, observe) {
111
118
  cancelled(signal);
112
119
  if (request.operation === 'close') {
113
- this.stop();
120
+ this.stop(undefined, { layer: 'executor', initiator: 'remote.employee-exec.close', reason: 'REMOTE_REASON_UNAVAILABLE', remoteReason: 'unavailable', operation: 'close', action: 'close' }, observe);
114
121
  await this.cleanup();
115
122
  return { version: 1, closed: true, result: 'UNKNOWN' };
116
123
  }
117
124
  this.active();
118
125
  const current = signal ? AbortSignal.any([signal, this.lifetime.signal]) : this.lifetime.signal;
119
126
  if (request.operation === 'open')
120
- return this.open(current);
127
+ return this.open(current, observe);
121
128
  if (request.operation === 'read')
122
- return this.read(current);
129
+ return this.read(current, observe);
123
130
  const value = encoded(request.message, this.options.acpMaxMessageBytes);
124
131
  const size = value.line.byteLength;
125
132
  if (this.queuedWriteBytes + size > this.options.acpMaxMessageBytes) {
126
133
  const error = need('ACP_MESSAGE_TOO_LARGE');
127
- this.stop(error);
134
+ this.stop(error, { layer: 'executor', initiator: 'executor.write-capacity', reason: error.code, operation: 'write', action: 'failure' }, observe);
128
135
  throw error;
129
136
  }
130
137
  this.queuedWriteBytes += size;
@@ -140,7 +147,7 @@ export class EmployeeExecution {
140
147
  }
141
148
  catch (error) {
142
149
  const known = this.error ?? failure(error);
143
- this.stop(known);
150
+ this.stop(known, { layer: 'executor', initiator: 'executor.write-failure', reason: known.code, operation: 'write', action: 'failure' }, observe);
144
151
  throw known;
145
152
  }
146
153
  finally {
@@ -150,7 +157,7 @@ export class EmployeeExecution {
150
157
  this.writeTail = operation.catch(() => { });
151
158
  return operation;
152
159
  }
153
- async open(signal) {
160
+ async open(signal, observe) {
154
161
  if (this.opening || this.executionId)
155
162
  throw need('EMPLOYEE_EXEC_ALREADY_OPEN');
156
163
  this.opening = true;
@@ -158,7 +165,7 @@ export class EmployeeExecution {
158
165
  await this.gate(signal);
159
166
  // A factory may finish after revoke/abort. Its late executor belongs to this binding and must close.
160
167
  const created = Promise.resolve().then(() => { cancelled(signal); return this.options.transport(signal); }).then(async (io) => { if (this.ended || signal.aborted) {
161
- this.closeTransport(io);
168
+ this.closeTransport(io, { layer: 'executor', initiator: 'executor.late-factory', reason: this.error?.code ?? 'ABORTED', operation: 'open', action: 'close' }, observe);
162
169
  await this.closing;
163
170
  throw this.error ?? need('ABORTED');
164
171
  } this.io = io; return io; });
@@ -176,14 +183,14 @@ export class EmployeeExecution {
176
183
  }
177
184
  catch (error) {
178
185
  const known = this.error ?? failure(error);
179
- this.stop(known);
186
+ this.stop(known, { layer: 'executor', initiator: 'executor.open-failure', reason: known.code, operation: 'open', action: 'failure' }, observe);
180
187
  throw known;
181
188
  }
182
189
  finally {
183
190
  this.opening = false;
184
191
  }
185
192
  }
186
- async read(signal) {
193
+ async read(signal, observe) {
187
194
  if (this.pendingRead)
188
195
  throw need('EMPLOYEE_EXEC_READ_BUSY');
189
196
  this.pendingRead = true;
@@ -201,7 +208,7 @@ export class EmployeeExecution {
201
208
  }
202
209
  await this.gate(signal);
203
210
  if (message === null) {
204
- this.stop();
211
+ this.stop(undefined, { layer: 'executor', initiator: 'executor.read-eof', reason: 'EXECUTOR_EOF', operation: 'read', action: 'eof' }, observe);
205
212
  return { version: 1, messages: [], closed: true };
206
213
  }
207
214
  const value = encoded(message, this.options.acpMaxMessageBytes);
@@ -215,7 +222,7 @@ export class EmployeeExecution {
215
222
  if (signal.aborted && !this.ended) {
216
223
  if (consumed) {
217
224
  const unknown = need('EMPLOYEE_EXEC_UNKNOWN');
218
- this.stop(unknown);
225
+ this.stop(unknown, { layer: 'executor', initiator: 'executor.read-consumed-cancel', reason: unknown.code, operation: 'read', action: 'abort' }, observe);
219
226
  throw unknown;
220
227
  }
221
228
  throw need('ABORTED');
@@ -223,7 +230,7 @@ export class EmployeeExecution {
223
230
  if (this.ended && !this.error)
224
231
  return { version: 1, messages: [], closed: true };
225
232
  const known = failure(error);
226
- this.stop(known);
233
+ this.stop(known, { layer: 'executor', initiator: 'executor.read-failure', reason: known.code, operation: 'read', action: 'failure' }, observe);
227
234
  throw known;
228
235
  }
229
236
  finally {
@@ -232,7 +239,7 @@ export class EmployeeExecution {
232
239
  }
233
240
  }
234
241
  /** NDJSON carrier with byte bounds and backpressure derived solely from acpMaxMessageBytes. */
235
- export function createEmployeeProcessTransport(child, acpMaxMessageBytes, stderrCodes = []) {
242
+ export function createEmployeeProcessTransport(child, acpMaxMessageBytes, stderrCodes = [], observe) {
236
243
  capacity(acpMaxMessageBytes);
237
244
  let partial = Buffer.alloc(0), queuedBytes = 0, writingBytes = 0, closed = false, reason;
238
245
  let processClosed = false, terminationRequested = false;
@@ -247,6 +254,7 @@ export function createEmployeeProcessTransport(child, acpMaxMessageBytes, stderr
247
254
  const settle = () => { if (!pending)
248
255
  return; const reader = pending; pending = undefined; reader.clear(); reason ? reader.reject(reason) : reader.resolve(null); };
249
256
  const terminate = () => {
257
+ observe?.({ layer: 'relay', initiator: 'ssh.terminate', reason: 'TRANSPORT_CLEANUP', action: 'terminate', changes: [{ field: 'transport.termination-requested', before: terminationRequested, after: processClosed || terminationRequested ? terminationRequested : true }] });
250
258
  if (processClosed || terminationRequested)
251
259
  return;
252
260
  terminationRequested = true;
@@ -267,7 +275,8 @@ export function createEmployeeProcessTransport(child, acpMaxMessageBytes, stderr
267
275
  receipt.reject(need('EMPLOYEE_EXEC_UNKNOWN'));
268
276
  }
269
277
  };
270
- const finish = (error, kill = false) => {
278
+ const finish = (error, kill = false, cause = { layer: 'relay', initiator: 'ssh.protocol-failure', reason: closeCode(error, 'TRANSPORT_CLOSED'), action: 'failure' }) => {
279
+ observe?.({ ...cause, changes: cause.changes ?? [{ field: 'transport.closed', before: closed, after: true }] });
271
280
  if (!closed) {
272
281
  closed = true;
273
282
  reason = error;
@@ -353,9 +362,10 @@ export function createEmployeeProcessTransport(child, acpMaxMessageBytes, stderr
353
362
  }
354
363
  });
355
364
  child.stderr.resume();
356
- child.stdin.on('error', () => finish(need(diagnosticCode ?? 'EMPLOYEE_EXEC_UNKNOWN'), true));
357
- child.once('error', () => finish(need('EMPLOYEE_EXEC_UNKNOWN'), true));
358
- child.once('close', (code, signal) => { processClosed = true; finish(partial.length ? need('EMPLOYEE_EXEC_PROTOCOL_INVALID') : code === 0 && !signal ? undefined : need(diagnosticCode ?? 'EMPLOYEE_EXEC_UNKNOWN')); receipt.resolve(); });
365
+ child.stdin.on('error', () => finish(need(diagnosticCode ?? 'EMPLOYEE_EXEC_UNKNOWN'), true, { layer: 'relay', initiator: 'ssh.stdin-error', reason: diagnosticCode ?? 'EMPLOYEE_EXEC_UNKNOWN', action: 'failure' }));
366
+ child.once('error', () => finish(need('EMPLOYEE_EXEC_UNKNOWN'), true, { layer: 'relay', initiator: 'ssh.process-error', reason: 'EMPLOYEE_EXEC_UNKNOWN', action: 'failure' }));
367
+ child.prependOnceListener('close', (code, signal) => observe?.({ layer: 'relay', initiator: 'ssh.process.close', reason: diagnosticCode ?? (code === 0 && !signal ? 'PROCESS_EXIT' : 'EMPLOYEE_EXEC_UNKNOWN'), action: 'exit', exitCode: code, signal }));
368
+ child.once('close', (code, signal) => { processClosed = true; finish(partial.length ? need('EMPLOYEE_EXEC_PROTOCOL_INVALID') : code === 0 && !signal ? undefined : need(diagnosticCode ?? 'EMPLOYEE_EXEC_UNKNOWN'), false, { layer: 'relay', initiator: 'ssh.process-close-settle', reason: diagnosticCode ?? (code === 0 && !signal ? 'PROCESS_EXIT' : 'EMPLOYEE_EXEC_UNKNOWN'), action: 'exit', exitCode: code, signal }); receipt.resolve(); });
359
369
  return {
360
370
  write: async (message, signal) => {
361
371
  cancelled(signal);
@@ -364,7 +374,7 @@ export function createEmployeeProcessTransport(child, acpMaxMessageBytes, stderr
364
374
  const value = encoded(message, acpMaxMessageBytes);
365
375
  if (writingBytes + value.line.byteLength > acpMaxMessageBytes) {
366
376
  const error = need('ACP_MESSAGE_TOO_LARGE');
367
- finish(error, true);
377
+ finish(error, true, { layer: 'relay', initiator: 'ssh.write-capacity', reason: error.code, operation: 'write', action: 'failure' });
368
378
  throw error;
369
379
  }
370
380
  writingBytes += value.line.byteLength;
@@ -374,19 +384,19 @@ export function createEmployeeProcessTransport(child, acpMaxMessageBytes, stderr
374
384
  return false; done = true; writingBytes -= value.line.byteLength; writes.delete(fail); signal?.removeEventListener('abort', abort); return true; };
375
385
  const fail = (error) => { if (cleanup())
376
386
  reject(error); };
377
- const abort = () => { const error = need('EMPLOYEE_EXEC_UNKNOWN'); finish(error, true); fail(error); };
387
+ const abort = () => { const error = need('EMPLOYEE_EXEC_UNKNOWN'); finish(error, true, { layer: 'relay', initiator: 'ssh.write-abort', reason: error.code, operation: 'write', action: 'abort' }); fail(error); };
378
388
  writes.add(fail);
379
389
  signal?.addEventListener('abort', abort, { once: true });
380
390
  try {
381
391
  child.stdin.write(value.line, error => { if (error) {
382
- finish(need('EMPLOYEE_EXEC_UNKNOWN'), true);
392
+ finish(need('EMPLOYEE_EXEC_UNKNOWN'), true, { layer: 'relay', initiator: 'ssh.write-callback', reason: 'EMPLOYEE_EXEC_UNKNOWN', operation: 'write', action: 'failure' });
383
393
  fail(need('EMPLOYEE_EXEC_UNKNOWN'));
384
394
  }
385
395
  else if (cleanup())
386
396
  resolve(); });
387
397
  }
388
398
  catch {
389
- finish(need('EMPLOYEE_EXEC_UNKNOWN'), true);
399
+ finish(need('EMPLOYEE_EXEC_UNKNOWN'), true, { layer: 'relay', initiator: 'ssh.write-call', reason: 'EMPLOYEE_EXEC_UNKNOWN', operation: 'write', action: 'failure' });
390
400
  fail(need('EMPLOYEE_EXEC_UNKNOWN'));
391
401
  }
392
402
  if (signal?.aborted)
@@ -418,6 +428,6 @@ export function createEmployeeProcessTransport(child, acpMaxMessageBytes, stderr
418
428
  abort();
419
429
  });
420
430
  },
421
- close: () => { finish(undefined, true); queue.length = 0; queuedBytes = 0; return receipt.promise; }
431
+ close: (cause = { layer: 'relay', initiator: 'ssh.transport-close', reason: 'TRANSPORT_CLOSE', action: 'close' }) => { finish(undefined, true, cause); queue.length = 0; queuedBytes = 0; return receipt.promise; }
422
432
  };
423
433
  }
@@ -1,3 +1,4 @@
1
+ import { type CloseCause, type CloseDiagnostics, type CloseObserver } from './close-diagnostics.ts';
1
2
  import { type Socket } from 'node:net';
2
3
  import { type JSONRPCMessage, type Tool } from '@modelcontextprotocol/sdk/types.js';
3
4
  import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
@@ -10,6 +11,7 @@ export declare const EMPLOYEE_MCP_TOOLS: Tool[];
10
11
  export declare class EmployeeSocketTransport implements Transport {
11
12
  private socket;
12
13
  private maxBytes;
14
+ private observeClose?;
13
15
  onclose?: () => void;
14
16
  onerror?: (error: Error) => void;
15
17
  onmessage?: (message: JSONRPCMessage) => void;
@@ -19,13 +21,14 @@ export declare class EmployeeSocketTransport implements Transport {
19
21
  get error(): Error | undefined;
20
22
  private readonly lifetime;
21
23
  readonly signal: AbortSignal;
22
- constructor(socket: Socket, maxBytes: number);
24
+ constructor(socket: Socket, maxBytes: number, observeClose?: CloseObserver | undefined);
23
25
  private failed;
24
26
  start(): Promise<void>;
25
27
  send(message: JSONRPCMessage): Promise<void>;
26
- close(): Promise<void>;
28
+ close(cause?: CloseCause): Promise<void>;
27
29
  }
28
30
  export interface EmployeeMcpOptions {
31
+ closeDiagnostics?: CloseDiagnostics;
29
32
  lease: EmployeeLease;
30
33
  maxBytes: number;
31
34
  killGraceMs: number;
@@ -37,6 +40,6 @@ export interface EmployeeMcpOptions {
37
40
  export interface EmployeeMcp {
38
41
  routing: ReturnType<typeof employeeRouting>;
39
42
  signal: AbortSignal;
40
- close(): Promise<void>;
43
+ close(cause?: CloseCause): Promise<void>;
41
44
  }
42
45
  export declare function openEmployeeMcp(options: EmployeeMcpOptions): Promise<EmployeeMcp>;