@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
@@ -6,6 +6,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
6
6
  }
7
7
  return path;
8
8
  };
9
+ import { CloseDiagnostics, closeCode, flushCloseDiagnostics } from "./close-diagnostics.js";
9
10
  import { BrokerTransport, brokerCommand, brokerId, legacyBrokerId, sshIo, controlJson } from "./broker.js";
10
11
  import { CODEX_AGENT } from "./agents.js";
11
12
  import { protectedOneShotLayout } from "./host-layout.js";
@@ -63,15 +64,24 @@ export function brokerFactory(get, _ask, configuration, provided) {
63
64
  const contextBoundScope = recoveryKey([...scopeParts, request.contextSha256]);
64
65
  // A Codex ACP process can own several immutable native sessions. D18's durable
65
66
  // pointer controls their context; the broker still binds every transport identity.
66
- const scope = agent.loginProvider === 'codex' ? recoveryKey([...scopeParts, undefined]) : contextBoundScope;
67
+ const scope = recoveryKey([...scopeParts, undefined]);
68
+ const previousContextScope = agent.loginProvider === 'claude' && request.previousContextSha256 ? recoveryKey([...scopeParts, request.previousContextSha256]) : contextBoundScope;
67
69
  const existing = request.retainProcess ? retained.get(key) : undefined;
68
70
  if (existing) {
69
71
  if (existing.scope !== scope)
70
72
  throw need('SESSION_CONTEXT_MISMATCH');
71
73
  if (existing.runtime.broker?.error)
72
74
  throw existing.runtime.broker.error;
75
+ if (request.requireFreshSession) {
76
+ if (existing.runtime.broker?.promptInFlight)
77
+ throw need('INPUT_DELIVERY_UNCONFIRMED');
78
+ await existing.runtime.dispose?.({ layer: 'broker', initiator: 'broker.fresh-session-required', reason: 'PRIVATE_RECOVERY_POINTER_ABSENT', action: 'dispose' });
79
+ return open(target, project, request);
80
+ }
73
81
  existing.update(request);
74
82
  existing.runtime.restartNotice = undefined;
83
+ existing.runtime.restartInputIds = undefined;
84
+ existing.runtime.freshSessionConfirmed = undefined;
75
85
  existing.runtime.recoveredSession = undefined;
76
86
  return existing.runtime;
77
87
  }
@@ -83,7 +93,7 @@ export function brokerFactory(get, _ask, configuration, provided) {
83
93
  if (!journal)
84
94
  throw need('RECOVERY_STORAGE_REQUIRED');
85
95
  let state = await journal.read();
86
- const legacyContextScope = !!state && state.scope !== scope && agent.loginProvider === 'codex' && state.scope === contextBoundScope;
96
+ const legacyContextScope = !!state && state.scope !== scope && state.scope === previousContextScope;
87
97
  if (state && state.scope !== scope && !legacyContextScope)
88
98
  throw need(agent.loginProvider === 'codex' ? 'RECOVERY_CONTEXT_POLICY_UNSUPPORTED' : 'SESSION_CONTEXT_MISMATCH');
89
99
  if (legacyContextScope && state?.phase !== 'clean')
@@ -106,9 +116,11 @@ export function brokerFactory(get, _ask, configuration, provided) {
106
116
  const report = async (code, extra = {}, cleanup = false) => { request.detailChannel?.record({ kind: 'need', severity: 'warning', code, summary: '远端会话恢复状态:' + code, payload: extra }); await request.detailChannel?.flush(); await validate(cleanup); };
107
117
  const list = async (cleanup = false) => timing.run('broker-list', async () => { const rows = controlJson(await io.run(command(['list']), cleanup ? undefined : request.signal)); if (!Array.isArray(rows) || rows.some(row => typeof row?.id !== 'string'))
108
118
  throw need('BROKER_PROTOCOL_INVALID'); return rows; });
119
+ const detailSink = request.detailChannel;
120
+ const closeDiagnostics = request.closeDiagnostics ?? new CloseDiagnostics(event => detailSink?.record(event));
109
121
  let rows = await list();
110
122
  const id = brokerId(request.sessionId, employeeScope ? agent.id + '/employee/' + employeeScope : agent.id), oldId = legacyBrokerId(request.sessionId, agent.id);
111
- let restarted = false;
123
+ let restarted = !!request.requireFreshSession;
112
124
  const attachArgs = (entry, after) => ['attach', entry.id, '--after', String(after), '--framed', ...(modern(entry) ? ['--leased', '--if-instance', entry.instanceId] : [])];
113
125
  const connectWithLease = async (transport, entry, cleanup = false) => {
114
126
  try {
@@ -143,13 +155,13 @@ export function brokerFactory(get, _ask, configuration, provided) {
143
155
  }
144
156
  };
145
157
  const claim = async (entry, cleanup = false) => {
146
- const probe = new BrokerTransport({ discardOutput: true, onNeed: n => request.detailChannel?.record({ kind: 'need', severity: 'warning', code: n.code, summary: '主机会话连接状态', payload: { brokerId: entry.id, instanceId: entry.instanceId ?? null } }), maxFrameBytes: settings.brokerMaxFrameBytes, initialSequence: entry.lastSequence, instanceId: entry.instanceId, leased: modern(entry), controlMaxBytes: entry.controlMaxBytes, attach: after => io.attach(command(attachArgs(entry, after))) });
158
+ const probe = new BrokerTransport({ observeClose: closeDiagnostics.child().capture(), discardOutput: true, onNeed: n => request.detailChannel?.record({ kind: 'need', severity: 'warning', code: n.code, summary: '主机会话连接状态', payload: { brokerId: entry.id, instanceId: entry.instanceId ?? null } }), maxFrameBytes: settings.brokerMaxFrameBytes, initialSequence: entry.lastSequence, instanceId: entry.instanceId, leased: modern(entry), controlMaxBytes: entry.controlMaxBytes, attach: after => io.attach(command(attachArgs(entry, after))) });
147
159
  try {
148
160
  await connectWithLease(probe, entry, cleanup);
149
161
  }
150
162
  catch (error) {
151
163
  if (!(entry.state === 'exited' && error instanceof NeedError && error.code === 'AGENT_EXITED')) {
152
- probe.close();
164
+ probe.close({ layer: 'broker', initiator: 'broker.probe-close', reason: 'BROKER_PROBE_FINISHED', action: 'close' });
153
165
  throw error;
154
166
  }
155
167
  }
@@ -183,7 +195,7 @@ export function brokerFactory(get, _ask, configuration, provided) {
183
195
  throw need('REMOTE_CLEANUP_UNCONFIRMED');
184
196
  }
185
197
  finally {
186
- probe.close();
198
+ probe.close({ layer: 'broker', initiator: 'broker.probe-close', reason: 'BROKER_PROBE_FINISHED', action: 'close' });
187
199
  }
188
200
  };
189
201
  const legacy = employeeScope ? undefined : rows.find(row => row.id === oldId);
@@ -192,13 +204,13 @@ export function brokerFactory(get, _ask, configuration, provided) {
192
204
  let entry = rows.find(row => row.id === id);
193
205
  if (entry)
194
206
  validEntry(entry);
195
- if (legacyContextScope && !entry)
207
+ if (legacyContextScope && !entry && !request.requireFreshSession)
196
208
  throw need('RECOVERY_CONTEXT_POLICY_UNSUPPORTED');
197
209
  if (legacy && !entry)
198
210
  restarted = true;
199
211
  let transport;
200
212
  let resume;
201
- const build = (entry, after, recovery) => new BrokerTransport({ onNeed: n => request.detailChannel?.record({ kind: 'need', severity: 'warning', code: n.code, summary: '主机会话连接状态', payload: { brokerId: entry.id, instanceId: entry.instanceId } }), maxFrameBytes: settings.brokerMaxFrameBytes, maxMessageBytes: request.acpMaxMessageBytes ?? settings.brokerMaxFrameBytes, initialSequence: after, instanceId: entry.instanceId, leased: true, controlMaxBytes: entry.controlMaxBytes, beforeInput: async () => { await validate(); if (state?.phase !== 'dirty' || state.instanceId !== entry.instanceId) {
213
+ const build = (entry, after, recovery) => new BrokerTransport({ observeClose: closeDiagnostics.capture(), onNeed: n => request.detailChannel?.record({ kind: 'need', severity: 'warning', code: n.code, summary: '主机会话连接状态', payload: { brokerId: entry.id, instanceId: entry.instanceId } }), maxFrameBytes: settings.brokerMaxFrameBytes, maxMessageBytes: request.acpMaxMessageBytes ?? settings.brokerMaxFrameBytes, initialSequence: after, instanceId: entry.instanceId, leased: true, controlMaxBytes: entry.controlMaxBytes, beforeInput: async () => { await validate(); if (state?.phase !== 'dirty' || state.instanceId !== entry.instanceId) {
202
214
  const dirty = { version: 1, scope, brokerId: id, instanceId: entry.instanceId, pid: entry.pid, phase: 'dirty' };
203
215
  await journal.write(dirty);
204
216
  state = dirty;
@@ -225,6 +237,15 @@ export function brokerFactory(get, _ask, configuration, provided) {
225
237
  if (entry) {
226
238
  if (!modern(entry))
227
239
  throw need('BROKER_CAPABILITY_REQUIRED');
240
+ if (request.requireFreshSession) {
241
+ if (!state || !['clean', 'ended'].includes(state.phase) || state.instanceId !== entry.instanceId)
242
+ throw need('INPUT_DELIVERY_UNCONFIRMED');
243
+ await stop(entry, 'PRIVATE_RECOVERY_POINTER_ABSENT');
244
+ restarted = true;
245
+ entry = undefined;
246
+ }
247
+ }
248
+ if (entry) {
228
249
  const clean = !employeeScope && state?.phase === 'clean' && state.instanceId === entry.instanceId && state.acp?.sessionId === request.resumeSessionId && state.checkpointId === request.recoveryCheckpointId && state.cursor >= entry.firstSequence - 1 && state.cursor <= entry.lastSequence && entry.state === 'running' && (legacyContextScope ? await request.verifyRecovery?.() === true : await request.verifyRecovery?.() !== false);
229
250
  if (legacyContextScope && !clean)
230
251
  throw need('RECOVERY_CONTEXT_POLICY_UNSUPPORTED');
@@ -237,14 +258,14 @@ export function brokerFactory(get, _ask, configuration, provided) {
237
258
  await validate();
238
259
  if (legacyContextScope) {
239
260
  const migrated = { ...state, scope };
240
- await report('CODEX_RECOVERY_SCOPE_MIGRATED', { oldScope: state.scope, newScope: scope, sessionId: state.acp.sessionId, checkpointId: state.checkpointId, instanceId: entry.instanceId, cursor: state.cursor });
261
+ await report(agent.loginProvider === 'codex' ? 'CODEX_RECOVERY_SCOPE_MIGRATED' : 'CLAUDE_RECOVERY_SCOPE_MIGRATED', { oldScope: state.scope, newScope: scope, sessionId: state.acp.sessionId, checkpointId: state.checkpointId, instanceId: entry.instanceId, cursor: state.cursor });
241
262
  await journal.write(migrated);
242
263
  state = migrated;
243
264
  }
244
265
  resume = state.acp;
245
266
  }
246
267
  catch (error) {
247
- transport.close();
268
+ transport.close({ layer: 'broker', initiator: 'broker.replay-failure', reason: closeCode(error, 'RECOVERY_UNCONFIRMED'), action: 'close' });
248
269
  if (legacyContextScope || error instanceof NeedError && ['ATTACH_BUSY', 'SESSION_IN_USE', 'SESSION_INSTANCE_MISMATCH', 'ABORTED'].includes(error.code))
249
270
  throw error;
250
271
  await report('SESSION_REPLAY_UNRECOVERABLE', { brokerId: id, instanceId: entry.instanceId, reason: error instanceof NeedError ? error.code : 'UNKNOWN_FAILURE', recordedCursor: state?.cursor ?? null, firstSequence: entry.firstSequence, lastSequence: entry.lastSequence });
@@ -274,7 +295,7 @@ export function brokerFactory(get, _ask, configuration, provided) {
274
295
  validEntry(entry);
275
296
  transport = build(entry, 0);
276
297
  const opening = transport;
277
- const aborted = () => opening.fail(need('ABORTED'));
298
+ const aborted = () => opening.fail(need('ABORTED'), { layer: 'broker', initiator: 'broker.open-signal', reason: 'ABORTED', action: 'abort' });
278
299
  request.signal?.addEventListener('abort', aborted, { once: true });
279
300
  try {
280
301
  request.signal?.throwIfAborted();
@@ -284,7 +305,7 @@ export function brokerFactory(get, _ask, configuration, provided) {
284
305
  catch (error) {
285
306
  if (!(error instanceof NeedError && ['ATTACH_BUSY', 'SESSION_IN_USE', 'SESSION_INSTANCE_MISMATCH'].includes(error.code)))
286
307
  await stop(entry, 'SESSION_OPEN_FAILED', opening, true, true);
287
- opening.close();
308
+ opening.close({ layer: 'broker', initiator: 'broker.open-failure', reason: closeCode(error), action: 'close' });
288
309
  throw error;
289
310
  }
290
311
  finally {
@@ -294,8 +315,8 @@ export function brokerFactory(get, _ask, configuration, provided) {
294
315
  const ownedEntry = entry, ownedTransport = transport;
295
316
  let disposed = false;
296
317
  let disposal;
297
- const runtime = { identity: identity(target), cwd: project.path, broker: ownedTransport, acpTransport: ownedTransport,
298
- ...(resume ? { recoveredSession: resume } : {}), ...(restarted ? { restartNotice: '上次会话无法接回,已新建会话;本次从你的这条请求继续。', restartInputIds: request.currentInputIds } : {}),
318
+ const runtime = { closeDiagnostics, identity: identity(target), cwd: project.path, broker: ownedTransport, acpTransport: ownedTransport,
319
+ ...(resume ? { recoveredSession: resume } : {}), ...(restarted ? { restartNotice: '上次会话无法接回,已新建会话;本次从你的这条请求继续。', restartInputIds: request.currentInputIds } : {}), ...(request.requireFreshSession ? { freshSessionConfirmed: true } : {}),
299
320
  saveCheckpoint: async (checkpointId, acp) => { await validate(); const saved = await ownedTransport.commitCheckpoint(async (cursor) => { await validate(); const clean = { version: 1, phase: 'clean', scope, brokerId: id, instanceId: ownedEntry.instanceId, pid: ownedEntry.pid, cursor, checkpointId, acp }; try {
300
321
  await journal.write(clean);
301
322
  state = clean;
@@ -305,24 +326,29 @@ export function brokerFactory(get, _ask, configuration, provided) {
305
326
  throw error;
306
327
  } }); if (!saved)
307
328
  await report('SESSION_CHECKPOINT_NOT_CLEAN', { brokerId: id, instanceId: ownedEntry.instanceId }); return saved; },
308
- detach: async () => { ownedTransport.close(); active.delete(key); retained.delete(key); },
309
- dispose: () => disposal ??= (async () => { if (disposed)
310
- return; try {
329
+ detach: async (cause = { layer: 'broker', initiator: 'broker.runtime-detach', reason: 'BROKER_DETACH', action: 'detach' }) => { const sink = request.detailChannel; closeDiagnostics.record(cause); ownedTransport.close(cause); active.delete(key); retained.delete(key); await flushCloseDiagnostics(closeDiagnostics, sink ? () => sink.flush() : undefined); },
330
+ dispose: (cause = { layer: 'broker', initiator: 'broker.runtime-dispose', reason: 'BROKER_DISPOSE', action: 'dispose' }) => { const sink = request.detailChannel; closeDiagnostics.record(cause); return disposal ??= (async () => { if (disposed)
331
+ return; let primary; try {
311
332
  await stop(ownedEntry, 'SESSION_ENDED', ownedTransport, true);
312
333
  await journal.write({ version: 1, phase: 'ended', scope, brokerId: id, instanceId: ownedEntry.instanceId, pid: ownedEntry.pid });
313
334
  disposed = true;
314
335
  }
336
+ catch (error) {
337
+ primary = { error };
338
+ }
315
339
  finally {
316
- ownedTransport.close();
340
+ ownedTransport.close({ layer: 'broker', initiator: 'broker.dispose-finally', reason: 'BROKER_DISPOSE_CLEANUP', action: 'close' });
317
341
  active.delete(key);
318
342
  retained.delete(key);
319
- } })(), };
343
+ } await flushCloseDiagnostics(closeDiagnostics, sink ? () => sink.flush() : undefined, primary); })(); },
344
+ };
320
345
  if (employeeScope) {
321
346
  runtime.saveCheckpoint = undefined;
322
347
  runtime.detach = runtime.dispose;
323
348
  }
324
349
  if (request.retainProcess)
325
- retained.set(key, { runtime, scope, update: next => { request = next; timing = next.timing ?? new StageTiming(); } });
350
+ retained.set(key, { runtime, scope, update: next => { const sink = next.detailChannel; if (sink)
351
+ closeDiagnostics.bind(event => sink.record(event)); request = next; timing = next.timing ?? new StageTiming(); } });
326
352
  return runtime;
327
353
  }
328
354
  catch (error) {
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,8 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
6
6
  }
7
7
  return path;
8
8
  };
9
+ import { CLAUDE_EMPLOYEE_SCOPE_NOTICE as NOTICE } from "./employee-presentation.js";
10
+ import { CloseDiagnostics, closeCode } from "./close-diagnostics.js";
9
11
  import { isDeepStrictEqual } from 'node:util';
10
12
  import { openEmployeeLease } from "./employee-endpoint.js";
11
13
  import { openEmployeeMcp } from "./employee-mcp.js";
@@ -15,7 +17,6 @@ import { sshIo } from "./broker.js";
15
17
  import { NeedError, shellQuote } from "./remote.js";
16
18
  import { awaitL } from "./provider.js";
17
19
  const failure = (code) => new NeedError(code, '当前本机执行上下文无法确认,本轮已停止;没有改在主机执行。', 'L当前本地绑定、N员工端点与S同代桥');
18
- const NOTICE = '本轮六类文件与命令工具在你的电脑执行,使用你选择的文件夹;Git也使用本机仓库。子代理和内部直接调用尚不支持本机,可能仍在主机;已识别的此类审批不会执行。PDF等二进制读取、后台命令暂不支持。';
19
20
  /** Local source only. Host and legacy-host remain with the original provider. */
20
21
  export function createClaudeLocalProvider(options) {
21
22
  const entries = new Map();
@@ -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>;