@yeaft/webchat-agent 1.0.490 → 1.0.491

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/index.js CHANGED
@@ -177,7 +177,7 @@ async function detectCapabilities() {
177
177
  // agent build can speak plaintext WS frames. New servers see this and
178
178
  // flip `agent.encryptOutbound = false`, stopping outbound encryption
179
179
  // to this peer. Old servers ignore the unknown capability token.
180
- const capabilities = ['background_tasks', 'file_editor', 'ping_session', 'plaintext-ok', 'workbench_session_routes', 'session_history_search', 'session_history_outline', 'session_history_window_prefetch', 'file_reference_resolution', 'yeaft_plugins', 'yeaft_managed_skills', 'settings_request_correlation'];
180
+ const capabilities = ['background_tasks', 'file_editor', 'ping_session', 'plaintext-ok', 'workbench_session_routes', 'workbench_request_correlation', 'workbench_terminal_cleanup_fence', 'session_history_search', 'session_history_outline', 'session_history_window_prefetch', 'file_reference_resolution', 'yeaft_plugins', 'yeaft_managed_skills', 'settings_request_correlation'];
181
181
  capabilities.push(getAgentUpgradeCapability());
182
182
  if (isWorkCenterEnabled()) {
183
183
  capabilities.push('work_center', 'work_center_message_v2');
@@ -9,14 +9,19 @@ import {
9
9
  clearAgentDirCache,
10
10
  } from '../ws-utils.js';
11
11
  import {
12
+ agentSupportsWorkbenchRequestCorrelation,
13
+ agentSupportsWorkbenchTerminalCleanupFence,
12
14
  currentWorkbenchWorkspaceGeneration,
13
15
  workbenchRouteKeyFromConversationId,
14
16
  } from '../workbench-route.js';
15
17
  import {
18
+ consumeLegacyWorkbenchRequest,
16
19
  consumeWorkbenchRequest,
17
20
  deleteWorkbenchTerminalOwner,
18
21
  getWorkbenchTerminalOwner,
22
+ isLegacyWorkbenchRequestQuarantined,
19
23
  registerWorkbenchTerminalOwner,
24
+ workbenchTerminalCleanupMessage,
20
25
  } from '../workbench-correlation.js';
21
26
 
22
27
  function stripAgentRouting(msg) {
@@ -50,6 +55,28 @@ async function sendToPendingClient(agentId, msg, pending) {
50
55
  return true;
51
56
  }
52
57
 
58
+ function consumeRouteResponse(agentId, agent, msg, routeKey) {
59
+ if (msg._workbenchRequestId) {
60
+ return consumeWorkbenchRequest({
61
+ agentId,
62
+ requestId: msg._workbenchRequestId,
63
+ responseType: msg.type,
64
+ routeKey,
65
+ });
66
+ }
67
+ const agentRecord = agents.get(agentId) || agent;
68
+ if (agentSupportsWorkbenchRequestCorrelation(agentRecord)) return null;
69
+ return consumeLegacyWorkbenchRequest({
70
+ agentId,
71
+ responseType: msg.type,
72
+ routeKey,
73
+ userId: msg._requestUserId,
74
+ clientId: msg._requestClientId || null,
75
+ publicRequestId: typeof msg.requestId === 'string' ? msg.requestId : null,
76
+ terminalId: msg.terminalId || null,
77
+ });
78
+ }
79
+
53
80
  async function forwardLegacyResponse(agentId, msg) {
54
81
  const visible = { ...stripAgentRouting(msg), agentId };
55
82
  const agent = agents.get(agentId);
@@ -83,25 +110,25 @@ function cacheBinaryPreview(msg) {
83
110
  };
84
111
  }
85
112
 
86
- async function handleTerminalResponse(agentId, msg, routeKey) {
113
+ async function handleTerminalResponse(agentId, agent, msg, routeKey) {
87
114
  const terminalId = msg.terminalId || null;
88
115
  if (msg.type === 'terminal_created') {
89
- const pending = consumeWorkbenchRequest({
90
- agentId,
91
- requestId: msg._workbenchRequestId,
92
- responseType: msg.type,
93
- routeKey,
94
- });
95
- if (!pending) {
116
+ const pending = consumeRouteResponse(agentId, agent, msg, routeKey);
117
+ const quarantined = isLegacyWorkbenchRequestQuarantined(pending);
118
+ if (!pending || quarantined) {
96
119
  const agentRecord = agents.get(agentId);
97
- if (agentRecord && terminalId && msg.workbenchWorkspaceGeneration) {
98
- await sendToAgent(agentRecord, {
99
- type: 'terminal_close',
100
- conversationId: msg.conversationId,
101
- terminalId,
102
- workbenchRouteKey: routeKey,
103
- workbenchWorkspaceGeneration: msg.workbenchWorkspaceGeneration,
104
- });
120
+ const cleanup = quarantined ? pending : {
121
+ requestId: msg._workbenchRequestId || null,
122
+ conversationId: msg.conversationId,
123
+ routeKey,
124
+ workspaceGeneration: msg.workbenchWorkspaceGeneration,
125
+ terminalId,
126
+ };
127
+ const closeMessage = workbenchTerminalCleanupMessage(cleanup);
128
+ if (agentRecord
129
+ && closeMessage
130
+ && agentSupportsWorkbenchTerminalCleanupFence(agentRecord)) {
131
+ await sendToAgent(agentRecord, closeMessage);
105
132
  }
106
133
  return;
107
134
  }
@@ -117,16 +144,18 @@ async function handleTerminalResponse(agentId, msg, routeKey) {
117
144
 
118
145
  // Create errors carry the one-shot create correlation even though a
119
146
  // terminal-id reservation already exists. Consume and release it first.
120
- if (msg.type === 'terminal_error' && msg._workbenchRequestId) {
121
- const pending = consumeWorkbenchRequest({
122
- agentId,
123
- requestId: msg._workbenchRequestId,
124
- responseType: msg.type,
125
- routeKey,
126
- });
147
+ if (msg.type === 'terminal_error') {
148
+ const pending = consumeRouteResponse(agentId, agent, msg, routeKey);
149
+ // Explicit opaque ids and quarantined legacy replies must never fall
150
+ // through to terminal ownership. A quarantine tombstone describes the
151
+ // expired create, not any same-id replacement owner.
152
+ if (isLegacyWorkbenchRequestQuarantined(pending)) return;
127
153
  if (pending?.terminalId) deleteWorkbenchTerminalOwner(agentId, pending.terminalId);
128
- if (pending?.routeKey === routeKey) await sendToPendingClient(agentId, msg, pending);
129
- return;
154
+ if (pending?.routeKey === routeKey) {
155
+ await sendToPendingClient(agentId, msg, pending);
156
+ return;
157
+ }
158
+ if (msg._workbenchRequestId) return;
130
159
  }
131
160
 
132
161
  const owner = terminalId ? getWorkbenchTerminalOwner(agentId, terminalId) : null;
@@ -148,13 +177,8 @@ async function handleAgentDirectoryPickerResponse(agentId, msg) {
148
177
  await sendToPendingClient(agentId, msg, pending);
149
178
  }
150
179
 
151
- async function handleOneShotResponse(agentId, msg, routeKey) {
152
- const pending = consumeWorkbenchRequest({
153
- agentId,
154
- requestId: msg._workbenchRequestId,
155
- responseType: msg.type,
156
- routeKey,
157
- });
180
+ async function handleOneShotResponse(agentId, agent, msg, routeKey) {
181
+ const pending = consumeRouteResponse(agentId, agent, msg, routeKey);
158
182
  if (!pending) return;
159
183
  const currentGeneration = currentWorkbenchWorkspaceGeneration({
160
184
  route: pending.route,
@@ -192,8 +216,8 @@ export async function handleAgentFileTerminal(agentId, agent, rawMsg) {
192
216
  }
193
217
 
194
218
  if (routeKey) {
195
- if (terminalTypes.has(msg.type)) await handleTerminalResponse(agentId, msg, routeKey);
196
- else await handleOneShotResponse(agentId, msg, routeKey);
219
+ if (terminalTypes.has(msg.type)) await handleTerminalResponse(agentId, agent, msg, routeKey);
220
+ else await handleOneShotResponse(agentId, agent, msg, routeKey);
197
221
  return true;
198
222
  }
199
223
 
@@ -204,6 +228,10 @@ export async function handleAgentFileTerminal(agentId, agent, rawMsg) {
204
228
  return true;
205
229
  }
206
230
 
231
+ // Opaque correlation ids are Server-owned. A response carrying one cannot
232
+ // downgrade to legacy routing when its conversation is invalid or stale.
233
+ if (msg._workbenchRequestId) return true;
234
+
207
235
  // `_workbench:` is reserved for Server-authored route conversations. An
208
236
  // invalid or cross-Agent value is not a legacy conversation.
209
237
  if (typeof msg.conversationId === 'string' && msg.conversationId.startsWith('_workbench:')) {
@@ -190,6 +190,9 @@ export async function handleClientMisc(clientId, client, msg, checkAgentAccess)
190
190
  conversationId: resolved.conversationId || msg.conversationId || client.currentConversation,
191
191
  workbenchRouteKey: resolved.routeKey,
192
192
  workbenchWorkspaceGeneration: resolved.workspaceGeneration,
193
+ restoreRequestId: typeof msg.restoreRequestId === 'string'
194
+ ? msg.restoreRequestId
195
+ : null,
193
196
  openFiles: saved?.files || [],
194
197
  activeIndex: saved?.activeIndex || 0
195
198
  });
@@ -4,7 +4,10 @@ import {
4
4
  sendToWebClient, forwardToAgent,
5
5
  verifyConversationOwnership, getCachedDir
6
6
  } from '../ws-utils.js';
7
- import { resolveWorkbenchRequest } from '../workbench-route.js';
7
+ import {
8
+ agentSupportsWorkbenchRequestCorrelation,
9
+ resolveWorkbenchRequest,
10
+ } from '../workbench-route.js';
8
11
  import {
9
12
  deleteWorkbenchRequest,
10
13
  getWorkbenchTerminalOwner,
@@ -61,6 +64,82 @@ const RESPONSE_TYPES = Object.freeze({
61
64
  upload_to_dir: ['file_op_result'],
62
65
  });
63
66
 
67
+ const TIMEOUT_RESPONSE_TYPES = Object.freeze({
68
+ terminal_create: 'terminal_error',
69
+ read_file: 'file_content',
70
+ resolve_file_references: 'file_references_resolved',
71
+ write_file: 'file_saved',
72
+ list_directory: 'directory_listing',
73
+ git_status: 'git_status_result',
74
+ git_diff: 'git_diff_result',
75
+ git_add: 'git_op_result',
76
+ git_reset: 'git_op_result',
77
+ git_restore: 'git_op_result',
78
+ git_commit: 'git_op_result',
79
+ git_push: 'git_op_result',
80
+ file_search: 'file_search_result',
81
+ create_file: 'file_op_result',
82
+ delete_files: 'file_op_result',
83
+ move_files: 'file_op_result',
84
+ copy_files: 'file_op_result',
85
+ upload_to_dir: 'file_op_result',
86
+ });
87
+
88
+ const FILE_OPERATIONS = Object.freeze({
89
+ create_file: 'create',
90
+ delete_files: 'delete',
91
+ move_files: 'move',
92
+ copy_files: 'copy',
93
+ upload_to_dir: 'upload',
94
+ });
95
+
96
+ function workbenchTimeoutResponse({ agentId, msg, resolved }) {
97
+ const type = TIMEOUT_RESPONSE_TYPES[msg.type];
98
+ if (!type) return null;
99
+ const error = 'Workbench request timed out';
100
+ const response = {
101
+ type,
102
+ agentId,
103
+ conversationId: resolved.conversationId,
104
+ workbenchRouteKey: resolved.routeKey,
105
+ workbenchWorkspaceGeneration: resolved.workspaceGeneration,
106
+ ...(typeof msg.requestId === 'string' ? { requestId: msg.requestId } : {}),
107
+ error,
108
+ };
109
+ if (msg.type === 'terminal_create') {
110
+ return { ...response, terminalId: msg.terminalId || null, message: error };
111
+ }
112
+ if (msg.type === 'read_file') {
113
+ return { ...response, filePath: msg.filePath };
114
+ }
115
+ if (msg.type === 'resolve_file_references') {
116
+ return { ...response, references: [] };
117
+ }
118
+ if (msg.type === 'write_file') {
119
+ return { ...response, filePath: msg.filePath, requestedFilePath: msg.filePath };
120
+ }
121
+ if (msg.type === 'list_directory') {
122
+ return { ...response, dirPath: msg.dirPath, entries: [] };
123
+ }
124
+ if (msg.type === 'git_status') {
125
+ return { ...response, files: [] };
126
+ }
127
+ if (msg.type === 'git_diff') {
128
+ return { ...response, diff: '' };
129
+ }
130
+ if (msg.type.startsWith('git_')) {
131
+ return { ...response, success: false, operation: msg.type.slice(4) };
132
+ }
133
+ if (msg.type === 'file_search') {
134
+ return { ...response, query: msg.query, results: [] };
135
+ }
136
+ return {
137
+ ...response,
138
+ success: false,
139
+ operation: FILE_OPERATIONS[msg.type] || msg.type,
140
+ };
141
+ }
142
+
64
143
  function canonicalWorkbenchMessage(msg, resolved, { canonicalWorkDir = false } = {}) {
65
144
  const {
66
145
  _requestUserId: _ignoredUserId,
@@ -93,6 +172,7 @@ function correlateWorkbenchRequest({ agentId, clientId, client, msg, resolved, c
93
172
  }
94
173
  const expectedResponseTypes = RESPONSE_TYPES[msg.type];
95
174
  if (!expectedResponseTypes) return canonical;
175
+ const supportsRequestCorrelation = agentSupportsWorkbenchRequestCorrelation(agents.get(agentId));
96
176
  const registration = {
97
177
  agentId,
98
178
  clientId,
@@ -106,6 +186,12 @@ function correlateWorkbenchRequest({ agentId, clientId, client, msg, resolved, c
106
186
  expectedResponseTypes,
107
187
  publicRequestId: typeof msg.requestId === 'string' ? msg.requestId : null,
108
188
  terminalId: msg.terminalId || null,
189
+ allowLegacyCorrelation: !supportsRequestCorrelation,
190
+ onTimeout: async () => {
191
+ if (!client.authenticated || client.userId !== registration.userId) return;
192
+ const response = workbenchTimeoutResponse({ agentId, msg, resolved });
193
+ if (response) await sendToWebClient(client, response);
194
+ },
109
195
  };
110
196
  const requestId = registerWorkbenchRequest(registration);
111
197
  if (!requestId) return null;
@@ -114,7 +200,14 @@ function correlateWorkbenchRequest({ agentId, clientId, client, msg, resolved, c
114
200
  deleteWorkbenchRequest({ agentId, requestId });
115
201
  return null;
116
202
  }
117
- return { ...canonical, _workbenchRequestId: requestId };
203
+ return {
204
+ ...canonical,
205
+ _workbenchRequestId: requestId,
206
+ ...(!supportsRequestCorrelation ? {
207
+ _requestUserId: client.userId,
208
+ _requestClientId: clientId,
209
+ } : {}),
210
+ };
118
211
  }
119
212
 
120
213
  async function forwardCorrelatedWorkbenchRequest({ agentId, clientId, client, msg, resolved, canonical }) {
@@ -4,8 +4,31 @@ const REQUEST_TTL_MS = 2 * 60 * 1000;
4
4
  const MAX_PENDING = 4096;
5
5
 
6
6
  const pendingRequests = new Map();
7
+ const expiredRequests = new Map();
7
8
  const terminalOwners = new Map();
8
9
 
10
+ export const LEGACY_WORKBENCH_REQUEST_QUARANTINED = Symbol(
11
+ 'legacy-workbench-request-quarantined',
12
+ );
13
+
14
+ export function isLegacyWorkbenchRequestQuarantined(value) {
15
+ return value?.quarantine === LEGACY_WORKBENCH_REQUEST_QUARANTINED;
16
+ }
17
+
18
+ export function workbenchTerminalCleanupMessage(owner) {
19
+ if (!owner?.terminalId || !owner?.conversationId || !owner?.routeKey
20
+ || !owner?.workspaceGeneration) return null;
21
+ const requestId = owner.requestId || owner.pendingRequestId || null;
22
+ return {
23
+ type: 'terminal_close',
24
+ conversationId: owner.conversationId,
25
+ terminalId: owner.terminalId,
26
+ workbenchRouteKey: owner.routeKey,
27
+ workbenchWorkspaceGeneration: owner.workspaceGeneration,
28
+ ...(requestId ? { _workbenchRequestId: requestId } : {}),
29
+ };
30
+ }
31
+
9
32
  function requestKey(agentId, requestId) {
10
33
  return `${String(agentId || '')}\u0000${String(requestId || '')}`;
11
34
  }
@@ -22,11 +45,56 @@ function releasePendingTerminalReservation(pending) {
22
45
  if (owner?.pendingRequestId === pending.requestId) terminalOwners.delete(key);
23
46
  }
24
47
 
48
+ function pruneExpiredRequests(now = Date.now()) {
49
+ for (const [key, expired] of expiredRequests) {
50
+ if (expired?.expiresAt > now) continue;
51
+ expiredRequests.delete(key);
52
+ }
53
+ }
54
+
55
+ function quarantineExpiredRequest(key, pending, now = Date.now()) {
56
+ if (!pending?.allowLegacyCorrelation) return;
57
+ pruneExpiredRequests(now);
58
+ while (expiredRequests.size >= MAX_PENDING) {
59
+ const oldest = expiredRequests.keys().next().value;
60
+ if (oldest == null) break;
61
+ expiredRequests.delete(oldest);
62
+ }
63
+ expiredRequests.set(key, {
64
+ agentId: pending.agentId,
65
+ requestId: pending.requestId,
66
+ conversationId: pending.conversationId,
67
+ workspaceGeneration: pending.workspaceGeneration,
68
+ responseTypes: new Set(pending.expectedResponseTypes),
69
+ routeKey: pending.routeKey,
70
+ userId: pending.userId,
71
+ clientId: pending.clientId,
72
+ publicRequestId: pending.publicRequestId,
73
+ terminalId: pending.terminalId,
74
+ expiresAt: now + REQUEST_TTL_MS,
75
+ });
76
+ }
77
+
78
+ function expirePending(key, pending, reason = 'timeout') {
79
+ if (pendingRequests.get(key) !== pending) return false;
80
+ pendingRequests.delete(key);
81
+ if (pending.timeout) clearTimeout(pending.timeout);
82
+ pending.timeout = null;
83
+ releasePendingTerminalReservation(pending);
84
+ quarantineExpiredRequest(key, pending);
85
+ if (typeof pending?.onTimeout === 'function') {
86
+ Promise.resolve(pending.onTimeout(pending, reason)).catch(error => {
87
+ console.error('[Server] Failed to report Workbench request timeout:', error);
88
+ });
89
+ }
90
+ return true;
91
+ }
92
+
25
93
  function prune(now = Date.now()) {
94
+ pruneExpiredRequests(now);
26
95
  for (const [key, pending] of pendingRequests) {
27
96
  if (pending && pending.expiresAt > now) continue;
28
- pendingRequests.delete(key);
29
- releasePendingTerminalReservation(pending);
97
+ expirePending(key, pending);
30
98
  }
31
99
  }
32
100
 
@@ -43,6 +111,8 @@ export function registerWorkbenchRequest({
43
111
  expectedResponseTypes,
44
112
  publicRequestId = null,
45
113
  terminalId = null,
114
+ allowLegacyCorrelation = false,
115
+ onTimeout = null,
46
116
  }) {
47
117
  if (!agentId || !clientId || !userId || !routeKey || !conversationId
48
118
  || !workspaceGeneration || !requestType) return null;
@@ -54,9 +124,7 @@ export function registerWorkbenchRequest({
54
124
  while (pendingRequests.size >= MAX_PENDING) {
55
125
  const oldest = pendingRequests.keys().next().value;
56
126
  if (oldest == null) break;
57
- const evicted = pendingRequests.get(oldest);
58
- pendingRequests.delete(oldest);
59
- releasePendingTerminalReservation(evicted);
127
+ expirePending(oldest, pendingRequests.get(oldest), 'capacity');
60
128
  }
61
129
  const requestId = randomUUID();
62
130
  const key = requestKey(agentId, requestId);
@@ -74,20 +142,26 @@ export function registerWorkbenchRequest({
74
142
  expectedResponseTypes: new Set(expected),
75
143
  publicRequestId,
76
144
  terminalId,
145
+ allowLegacyCorrelation: allowLegacyCorrelation === true,
146
+ onTimeout,
77
147
  expiresAt: Date.now() + REQUEST_TTL_MS,
78
148
  timeout: null,
79
149
  };
80
150
  pending.timeout = setTimeout(() => {
81
- const current = pendingRequests.get(key);
82
- if (current !== pending) return;
83
- pendingRequests.delete(key);
84
- releasePendingTerminalReservation(pending);
151
+ expirePending(key, pending);
85
152
  }, REQUEST_TTL_MS);
86
153
  pending.timeout.unref?.();
87
154
  pendingRequests.set(key, pending);
88
155
  return requestId;
89
156
  }
90
157
 
158
+ function consumePending(key, pending) {
159
+ pendingRequests.delete(key);
160
+ if (pending.timeout) clearTimeout(pending.timeout);
161
+ pending.timeout = null;
162
+ return pending;
163
+ }
164
+
91
165
  export function consumeWorkbenchRequest({ agentId, requestId, responseType, routeKey = null }) {
92
166
  if (!agentId || !requestId || !responseType) return null;
93
167
  prune();
@@ -95,17 +169,90 @@ export function consumeWorkbenchRequest({ agentId, requestId, responseType, rout
95
169
  const pending = pendingRequests.get(key);
96
170
  if (!pending || !pending.expectedResponseTypes.has(responseType)) return null;
97
171
  if (routeKey && pending.routeKey !== routeKey) return null;
98
- pendingRequests.delete(key);
99
- if (pending.timeout) clearTimeout(pending.timeout);
100
- pending.timeout = null;
101
- return pending;
172
+ return consumePending(key, pending);
173
+ }
174
+
175
+ /**
176
+ * Correlate responses from the short-lived Agent release window that supported
177
+ * Session Workbench routes but did not echo `_workbenchRequestId`. Agent fields
178
+ * only filter Server-owned pending records; they never directly select a Web
179
+ * client. Ambiguous matches are intentionally left pending to time out.
180
+ */
181
+ function matchesLegacyCorrelation(record, {
182
+ agentId,
183
+ responseType,
184
+ routeKey,
185
+ userId,
186
+ clientId,
187
+ publicRequestId,
188
+ terminalId,
189
+ }, responseTypes) {
190
+ if (record.agentId !== agentId
191
+ || record.routeKey !== routeKey
192
+ || record.userId !== userId
193
+ || !responseTypes?.has(responseType)) return false;
194
+ if (clientId && record.clientId !== clientId) return false;
195
+ if (publicRequestId && record.publicRequestId !== publicRequestId) return false;
196
+ if (terminalId && record.terminalId !== terminalId) return false;
197
+ return true;
198
+ }
199
+
200
+ export function consumeLegacyWorkbenchRequest({
201
+ agentId,
202
+ responseType,
203
+ routeKey,
204
+ userId,
205
+ clientId = null,
206
+ publicRequestId = null,
207
+ terminalId = null,
208
+ }) {
209
+ if (!agentId || !responseType || !routeKey || !userId) return null;
210
+ const correlation = {
211
+ agentId,
212
+ responseType,
213
+ routeKey,
214
+ userId,
215
+ clientId,
216
+ publicRequestId,
217
+ terminalId,
218
+ };
219
+ prune();
220
+ for (const [key, expired] of expiredRequests) {
221
+ if (!matchesLegacyCorrelation(expired, correlation, expired.responseTypes)) continue;
222
+ // A public request id identifies the expired request exactly, so one late
223
+ // response can retire its tombstone. Without one, old and retried responses
224
+ // are indistinguishable; keep the quarantine until TTL rather than risk
225
+ // consuming a newer pending request.
226
+ if (publicRequestId) expiredRequests.delete(key);
227
+ return {
228
+ quarantine: LEGACY_WORKBENCH_REQUEST_QUARANTINED,
229
+ agentId: expired.agentId,
230
+ requestId: expired.requestId,
231
+ conversationId: expired.conversationId,
232
+ routeKey: expired.routeKey,
233
+ workspaceGeneration: expired.workspaceGeneration,
234
+ terminalId: expired.terminalId,
235
+ };
236
+ }
237
+ const matches = [];
238
+ for (const [key, pending] of pendingRequests) {
239
+ if (!matchesLegacyCorrelation(pending, correlation, pending.expectedResponseTypes)) continue;
240
+ matches.push([key, pending]);
241
+ if (matches.length > 1) return null;
242
+ }
243
+ if (matches.length !== 1) return null;
244
+ return consumePending(matches[0][0], matches[0][1]);
102
245
  }
103
246
 
104
247
  export function deleteWorkbenchRequest({ agentId, requestId }) {
105
248
  const key = requestKey(agentId, requestId);
106
249
  const pending = pendingRequests.get(key);
107
250
  const deleted = pendingRequests.delete(key);
108
- if (deleted) releasePendingTerminalReservation(pending);
251
+ if (deleted) {
252
+ if (pending.timeout) clearTimeout(pending.timeout);
253
+ pending.timeout = null;
254
+ releasePendingTerminalReservation(pending);
255
+ }
109
256
  return deleted;
110
257
  }
111
258
 
@@ -144,17 +291,25 @@ export function deleteWorkbenchTerminalOwner(agentId, terminalId) {
144
291
 
145
292
  export function clearWorkbenchCorrelationsForClient(clientId) {
146
293
  if (!clientId) return [];
147
- for (const [key, pending] of pendingRequests) {
148
- if (pending?.clientId !== clientId) continue;
149
- pendingRequests.delete(key);
150
- releasePendingTerminalReservation(pending);
151
- }
294
+ // Capture terminal cleanup capabilities before releasing pending creates.
295
+ // releasePendingTerminalReservation() removes the matching owner, but the
296
+ // disconnect path still needs its exact create token to cancel Agent work.
152
297
  const terminals = [];
153
298
  for (const [key, owner] of terminalOwners) {
154
299
  if (owner?.clientId !== clientId) continue;
155
300
  terminals.push(owner);
156
301
  terminalOwners.delete(key);
157
302
  }
303
+ for (const [key, pending] of pendingRequests) {
304
+ if (pending?.clientId !== clientId) continue;
305
+ pendingRequests.delete(key);
306
+ if (pending.timeout) clearTimeout(pending.timeout);
307
+ pending.timeout = null;
308
+ releasePendingTerminalReservation(pending);
309
+ }
310
+ for (const [key, expired] of expiredRequests) {
311
+ if (expired?.clientId === clientId) expiredRequests.delete(key);
312
+ }
158
313
  return terminals;
159
314
  }
160
315
 
@@ -163,16 +318,26 @@ export function clearWorkbenchCorrelationsForAgent(agentId) {
163
318
  for (const [key, pending] of pendingRequests) {
164
319
  if (pending?.agentId !== agentId) continue;
165
320
  pendingRequests.delete(key);
321
+ if (pending.timeout) clearTimeout(pending.timeout);
322
+ pending.timeout = null;
166
323
  releasePendingTerminalReservation(pending);
167
324
  }
325
+ for (const [key, expired] of expiredRequests) {
326
+ if (expired?.agentId === agentId) expiredRequests.delete(key);
327
+ }
168
328
  for (const [key, owner] of terminalOwners) {
169
329
  if (owner?.agentId === agentId) terminalOwners.delete(key);
170
330
  }
171
331
  }
172
332
 
173
333
  export function __testResetWorkbenchCorrelations() {
174
- for (const pending of pendingRequests.values()) releasePendingTerminalReservation(pending);
334
+ for (const pending of pendingRequests.values()) {
335
+ if (pending.timeout) clearTimeout(pending.timeout);
336
+ pending.timeout = null;
337
+ releasePendingTerminalReservation(pending);
338
+ }
175
339
  pendingRequests.clear();
340
+ expiredRequests.clear();
176
341
  terminalOwners.clear();
177
342
  }
178
343
 
@@ -4,6 +4,20 @@ import { sessionDb } from './db/session-db.js';
4
4
  import { yeaftSessionDb } from './db/yeaft-session-db.js';
5
5
 
6
6
  export const WORKBENCH_SESSION_ROUTE_CAPABILITY = 'workbench_session_routes';
7
+ export const WORKBENCH_REQUEST_CORRELATION_CAPABILITY = 'workbench_request_correlation';
8
+ export const WORKBENCH_TERMINAL_CLEANUP_FENCE_CAPABILITY = 'workbench_terminal_cleanup_fence';
9
+
10
+ function agentHasCapability(agent, capability) {
11
+ return Array.isArray(agent?.capabilities) && agent.capabilities.includes(capability);
12
+ }
13
+
14
+ export function agentSupportsWorkbenchRequestCorrelation(agent) {
15
+ return agentHasCapability(agent, WORKBENCH_REQUEST_CORRELATION_CAPABILITY);
16
+ }
17
+
18
+ export function agentSupportsWorkbenchTerminalCleanupFence(agent) {
19
+ return agentHasCapability(agent, WORKBENCH_TERMINAL_CLEANUP_FENCE_CAPABILITY);
20
+ }
7
21
 
8
22
  const PROVIDERS = new Set(['yeaft', 'claude-code', 'copilot']);
9
23
  const SCOPES = new Set(['main', 'files-folder-picker', 'git-folder-picker']);
@@ -17,7 +17,11 @@ import {
17
17
  BROWSER_RUNTIME_SETUP_PROTOCOL,
18
18
  WORKBENCH_ROUTE_PROTOCOL,
19
19
  } from './client-protocol.js';
20
- import { clearWorkbenchCorrelationsForClient } from './workbench-correlation.js';
20
+ import {
21
+ clearWorkbenchCorrelationsForClient,
22
+ workbenchTerminalCleanupMessage,
23
+ } from './workbench-correlation.js';
24
+ import { agentSupportsWorkbenchTerminalCleanupFence } from './workbench-route.js';
21
25
  import { clearBrowserRuntimeForClient } from './browser-runtime-routes.js';
22
26
  import {
23
27
  parseMessage, sendToWebClient, sendToAgent,
@@ -30,6 +34,17 @@ import { handleClientMisc } from './handlers/client-misc.js';
30
34
  import { clearWorkCenterRequestsForClient, handleClientWorkCenter } from './handlers/client-work-center.js';
31
35
  import { recordPerfTraceEvent } from './perf-trace.js';
32
36
 
37
+ export function cleanupWorkbenchForDisconnectedClient(clientId) {
38
+ const ownedTerminals = clearWorkbenchCorrelationsForClient(clientId);
39
+ for (const owner of ownedTerminals) {
40
+ const agent = agents.get(owner.agentId);
41
+ const closeMessage = workbenchTerminalCleanupMessage(owner);
42
+ if (!agent || !closeMessage || !agentSupportsWorkbenchTerminalCleanupFence(agent)) continue;
43
+ void sendToAgent(agent, closeMessage)
44
+ .catch(error => console.warn('[Workbench] PTY disconnect cleanup failed:', error.message));
45
+ }
46
+ }
47
+
33
48
  export function handleWebConnection(ws, url, req = {}) {
34
49
  const clientId = randomUUID();
35
50
 
@@ -203,18 +218,7 @@ export function handleWebConnection(ws, url, req = {}) {
203
218
  },
204
219
  }).catch(error => console.warn('[BrowserRuntime] peer disconnect cleanup failed:', error.message));
205
220
  }
206
- const ownedTerminals = clearWorkbenchCorrelationsForClient(clientId);
207
- for (const owner of ownedTerminals) {
208
- const agent = agents.get(owner.agentId);
209
- if (!agent) continue;
210
- void sendToAgent(agent, {
211
- type: 'terminal_close',
212
- terminalId: owner.terminalId,
213
- conversationId: owner.conversationId,
214
- workbenchRouteKey: owner.routeKey,
215
- workbenchWorkspaceGeneration: owner.workspaceGeneration,
216
- }).catch(error => console.warn('[Workbench] PTY disconnect cleanup failed:', error.message));
217
- }
221
+ cleanupWorkbenchForDisconnectedClient(clientId);
218
222
  webClients.delete(clientId);
219
223
  console.log(`Web client disconnected: ${clientId}`);
220
224
  });
@@ -1 +1 @@
1
- {"version":"1.0.490"}
1
+ {"version":"1.0.491"}