@yeaft/webchat-agent 1.0.414 → 1.0.416

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.
@@ -3,6 +3,7 @@ import ctx from '../context.js';
3
3
  import { sendToServer, parseMessage } from './buffer.js';
4
4
  import { startAgentHeartbeat, stopAgentHeartbeat, scheduleReconnect } from './heartbeat.js';
5
5
  import { handleMessage } from './message-router.js';
6
+ import { cleanupTerminalsForDisconnect } from '../terminal.js';
6
7
 
7
8
  export function resetConnectionTransport() {
8
9
  ctx.sessionKey = null;
@@ -35,6 +36,7 @@ export function connect(WebSocketImpl = WebSocket) {
35
36
  console.log(`Disallowed tools: ${ctx.CONFIG.disallowedTools.join(', ')}`);
36
37
  }
37
38
 
39
+ const previousSocket = ctx.ws;
38
40
  const socket = new WebSocketImpl(url, {
39
41
  // Match server's permessage-deflate config (bounded memory,
40
42
  // skip compression for small frames). The `ws` library handles
@@ -46,6 +48,12 @@ export function connect(WebSocketImpl = WebSocket) {
46
48
  threshold: 1024
47
49
  }
48
50
  });
51
+ if (previousSocket && previousSocket !== socket) {
52
+ const closedTerminals = cleanupTerminalsForDisconnect();
53
+ if (closedTerminals > 0) {
54
+ console.log(`[PTY] Closed ${closedTerminals} terminal(s) before Agent transport replacement`);
55
+ }
56
+ }
49
57
  ctx.ws = socket;
50
58
 
51
59
  socket.on('open', () => {
@@ -99,6 +107,10 @@ export function connect(WebSocketImpl = WebSocket) {
99
107
  ctx.sessionKey = null;
100
108
  ctx.pendingAuthTempId = null;
101
109
  stopAgentHeartbeat();
110
+ const closedTerminals = cleanupTerminalsForDisconnect();
111
+ if (closedTerminals > 0) {
112
+ console.log(`[PTY] Closed ${closedTerminals} terminal(s) after Agent transport disconnect`);
113
+ }
102
114
 
103
115
  if (code === 1008) {
104
116
  console.error('Authentication failed. Check AGENT_SECRET configuration.');
package/index.js CHANGED
@@ -160,7 +160,7 @@ async function detectCapabilities() {
160
160
  // agent build can speak plaintext WS frames. New servers see this and
161
161
  // flip `agent.encryptOutbound = false`, stopping outbound encryption
162
162
  // to this peer. Old servers ignore the unknown capability token.
163
- const capabilities = ['background_tasks', 'file_editor', 'ping_session', 'plaintext-ok', SAFE_REMOTE_UPGRADE_CAPABILITY, 'work_center', 'work_center_message_v2', 'session_history_search', 'session_history_outline', 'session_history_window_prefetch'];
163
+ const capabilities = ['background_tasks', 'file_editor', 'ping_session', 'plaintext-ok', 'workbench_session_routes', SAFE_REMOTE_UPGRADE_CAPABILITY, 'work_center', 'work_center_message_v2', 'session_history_search', 'session_history_outline', 'session_history_window_prefetch'];
164
164
  if (process.platform === 'linux') capabilities.push('work_item_attachments');
165
165
  const pty = await loadNodePty();
166
166
  if (pty) capabilities.push('terminal');
@@ -0,0 +1,189 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
3
+ import { dirname, resolve } from 'node:path';
4
+
5
+ export const DEFAULT_AGENT_IMAGE = 'ghcr.io/yeaft/yeaft-web-code-agent-agent:dev';
6
+ const NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$/;
7
+
8
+ export class ContainerAgentError extends Error {
9
+ constructor(code, message = code) {
10
+ super(message);
11
+ this.name = 'ContainerAgentError';
12
+ this.code = code;
13
+ }
14
+ }
15
+
16
+ export function normalizeContainerAgentName(name) {
17
+ const value = String(name || '').trim();
18
+ if (!NAME_PATTERN.test(value)) throw new ContainerAgentError('CONTAINER_AGENT_INVALID_NAME');
19
+ return value;
20
+ }
21
+
22
+ export function containerNameForAgent(name) {
23
+ return `yeaft-agent-${normalizeContainerAgentName(name)}`;
24
+ }
25
+
26
+ export async function runDocker(args, { spawnImpl = spawn, allowFailure = false, stdout = 'pipe' } = {}) {
27
+ return new Promise((resolvePromise, reject) => {
28
+ const child = spawnImpl('docker', args, {
29
+ stdio: ['ignore', stdout, 'pipe'],
30
+ windowsHide: true,
31
+ });
32
+ const output = [];
33
+ const errors = [];
34
+ child.stdout?.on('data', chunk => output.push(chunk));
35
+ child.stderr?.on('data', chunk => errors.push(chunk));
36
+ child.once('error', error => reject(new ContainerAgentError('CONTAINER_AGENT_DOCKER_UNAVAILABLE', error.message)));
37
+ child.once('close', code => {
38
+ const result = {
39
+ code: code ?? 1,
40
+ stdout: Buffer.concat(output).toString('utf8').trim(),
41
+ stderr: Buffer.concat(errors).toString('utf8').trim(),
42
+ };
43
+ if (result.code === 0 || allowFailure) resolvePromise(result);
44
+ else reject(new ContainerAgentError('CONTAINER_AGENT_DOCKER_FAILED', result.stderr || `docker ${args[0]} failed`));
45
+ });
46
+ });
47
+ }
48
+
49
+ export async function writeAgentSecretFile(path, secret) {
50
+ const value = String(secret || '').trim();
51
+ if (!value) throw new ContainerAgentError('CONTAINER_AGENT_SECRET_REQUIRED');
52
+ const absolute = resolve(path);
53
+ await mkdir(dirname(absolute), { recursive: true, mode: 0o700 });
54
+ await writeFile(absolute, `${value}\n`, { mode: 0o600 });
55
+ await chmod(absolute, 0o600);
56
+ return absolute;
57
+ }
58
+
59
+ export function buildCreateArgs({
60
+ name,
61
+ serverUrl,
62
+ secretFile,
63
+ image = DEFAULT_AGENT_IMAGE,
64
+ dataVolume,
65
+ workspaceVolume,
66
+ restart = 'unless-stopped',
67
+ }) {
68
+ const agentName = normalizeContainerAgentName(name);
69
+ if (!String(serverUrl || '').match(/^wss?:\/\//)) {
70
+ throw new ContainerAgentError('CONTAINER_AGENT_INVALID_SERVER_URL');
71
+ }
72
+ if (!secretFile) throw new ContainerAgentError('CONTAINER_AGENT_SECRET_REQUIRED');
73
+ const containerName = containerNameForAgent(agentName);
74
+ const safeImage = String(image || '').trim();
75
+ if (!safeImage || safeImage.startsWith('-')) throw new ContainerAgentError('CONTAINER_AGENT_INVALID_IMAGE');
76
+ return [
77
+ 'create', '--name', containerName,
78
+ '--label', 'io.yeaft.container-agent=true',
79
+ '--label', `io.yeaft.agent-name=${agentName}`,
80
+ '--restart', restart,
81
+ '--init',
82
+ '--mount', `type=volume,src=${dataVolume || `${containerName}-data`},dst=/home/yeaft/.yeaft`,
83
+ '--mount', `type=volume,src=${workspaceVolume || `${containerName}-workspace`},dst=/workspace`,
84
+ '--mount', `type=bind,src=${resolve(secretFile)},dst=/run/yeaft-host-secret,readonly`,
85
+ '--env', `SERVER_URL=${serverUrl}`,
86
+ '--env', `AGENT_NAME=${agentName}`,
87
+ '--env', 'AGENT_SECRET_FILE=/run/yeaft-host-secret',
88
+ '--env', 'YEAFT_DIR=/home/yeaft/.yeaft',
89
+ '--env', 'WORK_DIR=/workspace',
90
+ safeImage,
91
+ ];
92
+ }
93
+
94
+ /**
95
+ * Verify that the Docker client can reach a daemon before the Server advertises
96
+ * container Agent lifecycle support.
97
+ *
98
+ * @param {object} options runDocker overrides used by tests and alternate runtimes
99
+ * @returns {Promise<{serverVersion: string|null}>}
100
+ */
101
+ export async function checkContainerAgentRuntime(options = {}) {
102
+ const result = await runDocker(['version', '--format', '{{.Server.Version}}'], options);
103
+ return { serverVersion: result.stdout || null };
104
+ }
105
+
106
+ export async function inspectContainerAgent(name, options = {}) {
107
+ const result = await runDocker([
108
+ 'inspect', '--format', '{{json .State}}', containerNameForAgent(name),
109
+ ], { ...options, allowFailure: true });
110
+ if (result.code !== 0) {
111
+ if (/no such (object|container)/i.test(result.stderr)) {
112
+ return { exists: false, status: 'absent', running: false };
113
+ }
114
+ throw new ContainerAgentError('CONTAINER_AGENT_DOCKER_FAILED', result.stderr || 'docker inspect failed');
115
+ }
116
+ try {
117
+ const state = JSON.parse(result.stdout);
118
+ return {
119
+ exists: true,
120
+ status: state.Status || 'unknown',
121
+ running: state.Running === true,
122
+ startedAt: state.StartedAt || null,
123
+ error: state.Error || null,
124
+ };
125
+ } catch {
126
+ throw new ContainerAgentError('CONTAINER_AGENT_INVALID_DOCKER_RESPONSE');
127
+ }
128
+ }
129
+
130
+ export async function createContainerAgent(options, runtime = {}) {
131
+ const current = await inspectContainerAgent(options.name, runtime);
132
+ if (current.exists) throw new ContainerAgentError('CONTAINER_AGENT_ALREADY_EXISTS');
133
+ const containerName = containerNameForAgent(options.name);
134
+ await runDocker(buildCreateArgs(options), runtime);
135
+ try {
136
+ await runDocker(['start', containerName], runtime);
137
+ } catch (error) {
138
+ await runDocker(['rm', '-f', containerName], { ...runtime, allowFailure: true });
139
+ throw error;
140
+ }
141
+ return inspectContainerAgent(options.name, runtime);
142
+ }
143
+
144
+ export async function startContainerAgent(name, runtime = {}) {
145
+ await runDocker(['start', containerNameForAgent(name)], runtime);
146
+ return inspectContainerAgent(name, runtime);
147
+ }
148
+
149
+ export async function stopContainerAgent(name, runtime = {}) {
150
+ await runDocker(['stop', '--time', '10', containerNameForAgent(name)], runtime);
151
+ return inspectContainerAgent(name, runtime);
152
+ }
153
+
154
+ function isMissingDockerVolume(stderr) {
155
+ return /no such volume/i.test(String(stderr || ''));
156
+ }
157
+
158
+ export async function removeContainerAgent(name, { removeVolumes = true, ...runtime } = {}) {
159
+ const containerName = containerNameForAgent(name);
160
+ const current = await inspectContainerAgent(name, runtime);
161
+ if (current.exists) await runDocker(['rm', '-f', containerName], runtime);
162
+ if (removeVolumes) {
163
+ for (const volume of [`${containerName}-data`, `${containerName}-workspace`]) {
164
+ const result = await runDocker(['volume', 'rm', volume], {
165
+ ...runtime,
166
+ allowFailure: true,
167
+ });
168
+ if (result.code !== 0 && !isMissingDockerVolume(result.stderr)) {
169
+ throw new ContainerAgentError(
170
+ 'CONTAINER_AGENT_DOCKER_FAILED',
171
+ result.stderr || `docker volume rm ${volume} failed`,
172
+ );
173
+ }
174
+ }
175
+ }
176
+ return { exists: false, status: 'absent', running: false };
177
+ }
178
+
179
+ export async function logsContainerAgent(name, { follow = false, ...runtime } = {}) {
180
+ const args = ['logs'];
181
+ if (follow) args.push('--follow');
182
+ args.push(containerNameForAgent(name));
183
+ return runDocker(args, { ...runtime, stdout: follow ? 'inherit' : 'pipe' });
184
+ }
185
+
186
+ export async function readSecretInput({ secret, secretFile }) {
187
+ if (secretFile) return (await readFile(resolve(secretFile), 'utf8')).trim();
188
+ return String(secret || '').trim();
189
+ }
@@ -0,0 +1,14 @@
1
+ export const WORKBENCH_ROUTE_PROTOCOL = 1;
2
+
3
+ /**
4
+ * Apply the explicit browser protocol hello to one Server-owned client record.
5
+ * Unknown or omitted fields leave legacy defaults unchanged.
6
+ */
7
+ export function applyClientHello(client, message) {
8
+ if (!client || message?.type !== 'client_hello') return false;
9
+ if (message.plaintextOk === true) client.encryptOutbound = false;
10
+ if (message.workbenchRouteProtocol === WORKBENCH_ROUTE_PROTOCOL) {
11
+ client.workbenchRouteProtocol = WORKBENCH_ROUTE_PROTOCOL;
12
+ }
13
+ return true;
14
+ }
@@ -29,8 +29,9 @@ export const directoryCache = new Map();
29
29
  export const DIR_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
30
30
  export const DIR_CACHE_MAX_SIZE = 500;
31
31
 
32
- // Phase 5: File Tab state storage
33
- // key: `${userId}:${agentId}` → { files: [{path}], activeIndex, timestamp }
32
+ // Workbench Files tab state. Route-aware writers use
33
+ // `${userId}:${routeKey}\0${workspaceGeneration}`; legacy pairs keep the
34
+ // historical `${userId}:${agentId}` key.
34
35
  export const userFileTabs = new Map();
35
36
 
36
37
  // Preview file cache for binary file preview (Office/PDF/Image)
@@ -1,130 +1,200 @@
1
1
  import { randomUUID } from 'crypto';
2
- import { previewFiles, webClients } from '../context.js';
2
+ import { CONFIG } from '../config.js';
3
+ import { agents, previewFiles, webClients } from '../context.js';
3
4
  import {
4
- sendToWebClient, forwardToClients,
5
- setCachedDir, invalidateParentDirCache, clearAgentDirCache
5
+ sendToAgent,
6
+ sendToWebClient,
7
+ setCachedDir,
8
+ invalidateParentDirCache,
9
+ clearAgentDirCache,
6
10
  } from '../ws-utils.js';
11
+ import {
12
+ currentWorkbenchWorkspaceGeneration,
13
+ workbenchRouteKeyFromConversationId,
14
+ } from '../workbench-route.js';
15
+ import {
16
+ consumeWorkbenchRequest,
17
+ deleteWorkbenchTerminalOwner,
18
+ getWorkbenchTerminalOwner,
19
+ registerWorkbenchTerminalOwner,
20
+ } from '../workbench-correlation.js';
7
21
 
8
- /**
9
- * Handle file, terminal, and git messages from agent.
10
- * Types: terminal_created, terminal_output, terminal_closed, terminal_error,
11
- * file_content, file_saved, directory_listing, file_op_result,
12
- * git_status_result, git_diff_result, git_op_result, file_search_result
13
- */
14
- export async function handleAgentFileTerminal(agentId, agent, msg) {
15
- switch (msg.type) {
16
- // Terminal messages (forward to web clients)
17
- case 'terminal_created':
18
- case 'terminal_output':
19
- case 'terminal_closed':
20
- case 'terminal_error': {
21
- const targetClient = msg._requestClientId ? webClients.get(msg._requestClientId) : null;
22
- const targetMatchesUser = !msg._requestUserId || targetClient?.userId === msg._requestUserId;
23
- if (targetClient?.authenticated && targetMatchesUser) {
24
- const { _requestClientId, _requestUserId, ...cleanMsg } = msg;
25
- await sendToWebClient(targetClient, cleanMsg);
26
- break;
27
- }
28
- const { _requestClientId, ...fallbackMsg } = msg;
29
- await forwardToClients(agentId, msg.conversationId, fallbackMsg);
30
- break;
31
- }
22
+ function stripAgentRouting(msg) {
23
+ const {
24
+ _requestClientId: _ignoredClientId,
25
+ _requestUserId: _ignoredUserId,
26
+ _workbenchRequestId: _ignoredRequestId,
27
+ workbenchRouteKey: _ignoredRouteKey,
28
+ workbenchWorkspaceGeneration: _ignoredGeneration,
29
+ ...visible
30
+ } = msg || {};
31
+ return visible;
32
+ }
32
33
 
33
- // File operation messages
34
- case 'file_content': {
35
- let fwdMsg = { ...msg, agentId };
36
- if (msg.binary) {
37
- // Binary file: cache on server, forward fileId instead of base64 content
38
- const fileId = randomUUID();
39
- const token = randomUUID();
40
- const filename = msg.filePath.split('/').pop() || 'file';
41
- previewFiles.set(fileId, {
42
- buffer: Buffer.from(msg.content, 'base64'),
43
- mimeType: msg.mimeType,
44
- filename,
45
- createdAt: Date.now(),
46
- token
47
- });
48
- console.log(`[Server] Cached binary preview: fileId=${fileId}, mime=${msg.mimeType}, path=${msg.filePath}`);
49
- fwdMsg = {
50
- type: 'file_content',
51
- agentId,
34
+ function pendingResponse(agentId, msg, pending) {
35
+ const { requestId: _agentRequestId, ...visible } = stripAgentRouting(msg);
36
+ return {
37
+ ...visible,
38
+ agentId,
39
+ conversationId: pending.conversationId,
40
+ ...(pending.publicRequestId ? { requestId: pending.publicRequestId } : {}),
41
+ workbenchRouteKey: pending.routeKey,
42
+ workbenchWorkspaceGeneration: pending.workspaceGeneration,
43
+ };
44
+ }
45
+
46
+ async function sendToPendingClient(agentId, msg, pending) {
47
+ const client = webClients.get(pending?.clientId);
48
+ if (!client?.authenticated || client.userId !== pending?.userId) return false;
49
+ await sendToWebClient(client, pendingResponse(agentId, msg, pending));
50
+ return true;
51
+ }
52
+
53
+ async function forwardLegacyResponse(agentId, msg) {
54
+ const visible = { ...stripAgentRouting(msg), agentId };
55
+ const agent = agents.get(agentId);
56
+ const conversation = agent?.conversations?.get?.(visible.conversationId);
57
+ const ownerId = conversation?.userId || agent?.ownerId || null;
58
+ for (const [, client] of webClients) {
59
+ if (!client?.authenticated) continue;
60
+ const allowed = CONFIG.skipAuth
61
+ || (ownerId ? client.userId === ownerId : client.role === 'admin');
62
+ if (allowed) await sendToWebClient(client, visible);
63
+ }
64
+ }
65
+
66
+ function cacheBinaryPreview(msg) {
67
+ const fileId = randomUUID();
68
+ const token = randomUUID();
69
+ const filename = msg.filePath.split('/').pop() || 'file';
70
+ previewFiles.set(fileId, {
71
+ buffer: Buffer.from(msg.content, 'base64'),
72
+ mimeType: msg.mimeType,
73
+ filename,
74
+ createdAt: Date.now(),
75
+ token,
76
+ });
77
+ const { content: _binaryContent, ...projected } = msg;
78
+ return {
79
+ ...projected,
80
+ binary: true,
81
+ fileId,
82
+ previewToken: token,
83
+ };
84
+ }
85
+
86
+ async function handleTerminalResponse(agentId, msg, routeKey) {
87
+ const terminalId = msg.terminalId || null;
88
+ 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) {
96
+ const agentRecord = agents.get(agentId);
97
+ if (agentRecord && terminalId && msg.workbenchWorkspaceGeneration) {
98
+ await sendToAgent(agentRecord, {
99
+ type: 'terminal_close',
52
100
  conversationId: msg.conversationId,
53
- requestId: msg.requestId,
54
- _requestUserId: msg._requestUserId,
55
- _requestClientId: msg._requestClientId,
56
- filePath: msg.filePath,
57
- requestedFilePath: msg.requestedFilePath,
58
- binary: true,
59
- fileId,
60
- previewToken: token,
61
- mimeType: msg.mimeType
62
- };
63
- } else {
64
- console.log(`[Server] Forwarding file_content to clients, conv=${msg.conversationId}, path=${msg.filePath}`);
65
- }
66
- const targetClient = fwdMsg._requestClientId ? webClients.get(fwdMsg._requestClientId) : null;
67
- const targetMatchesUser = !fwdMsg._requestUserId || targetClient?.userId === fwdMsg._requestUserId;
68
- if (targetClient?.authenticated && targetMatchesUser) {
69
- const { _requestClientId, _requestUserId, ...cleanMsg } = fwdMsg;
70
- await sendToWebClient(targetClient, cleanMsg);
71
- } else {
72
- const { _requestClientId, ...fallbackMsg } = fwdMsg;
73
- await forwardToClients(agentId, msg.conversationId, fallbackMsg);
101
+ terminalId,
102
+ workbenchRouteKey: routeKey,
103
+ workbenchWorkspaceGeneration: msg.workbenchWorkspaceGeneration,
104
+ });
74
105
  }
75
- break;
106
+ return;
76
107
  }
77
-
78
- case 'file_saved': {
79
- // Phase 4: 文件保存后失效父目录缓存
80
- invalidateParentDirCache(agentId, msg.filePath);
81
- const fwdMsg = { ...msg, agentId };
82
- const targetClient = msg._requestClientId ? webClients.get(msg._requestClientId) : null;
83
- const targetMatchesUser = !msg._requestUserId || targetClient?.userId === msg._requestUserId;
84
- if (targetClient?.authenticated && targetMatchesUser) {
85
- const { _requestClientId, _requestUserId, ...cleanMsg } = fwdMsg;
86
- await sendToWebClient(targetClient, cleanMsg);
87
- } else {
88
- const { _requestClientId, ...fallbackMsg } = fwdMsg;
89
- await forwardToClients(agentId, msg.conversationId, fallbackMsg);
90
- }
91
- break;
108
+ if (pending.routeKey !== routeKey || pending.terminalId !== terminalId) {
109
+ deleteWorkbenchTerminalOwner(agentId, pending.terminalId);
110
+ return;
92
111
  }
112
+ if (msg.success !== false) registerWorkbenchTerminalOwner({ ...pending, terminalId });
113
+ else deleteWorkbenchTerminalOwner(agentId, terminalId);
114
+ await sendToPendingClient(agentId, msg, pending);
115
+ return;
116
+ }
93
117
 
94
- case 'directory_listing': {
95
- // Phase 4: 缓存目录列表结果
96
- if (msg.dirPath && msg.entries && !msg.error) {
97
- setCachedDir(agentId, msg.dirPath, msg.entries);
98
- }
99
- // 优先定向发送给请求者
100
- const dirTargetClientId = msg._requestClientId;
101
- if (dirTargetClientId) {
102
- const targetClient = webClients.get(dirTargetClientId);
103
- if (targetClient?.authenticated) {
104
- const { _requestClientId, ...cleanMsg } = msg;
105
- await sendToWebClient(targetClient, cleanMsg);
106
- break;
107
- }
108
- }
109
- await forwardToClients(agentId, msg.conversationId, msg);
110
- break;
111
- }
118
+ // Create errors carry the one-shot create correlation even though a
119
+ // 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
+ });
127
+ if (pending?.terminalId) deleteWorkbenchTerminalOwner(agentId, pending.terminalId);
128
+ if (pending?.routeKey === routeKey) await sendToPendingClient(agentId, msg, pending);
129
+ return;
130
+ }
131
+
132
+ const owner = terminalId ? getWorkbenchTerminalOwner(agentId, terminalId) : null;
133
+ if (owner) {
134
+ if (owner.routeKey !== routeKey) return;
135
+ await sendToPendingClient(agentId, msg, owner);
136
+ if (msg.type === 'terminal_closed') deleteWorkbenchTerminalOwner(agentId, terminalId);
137
+ }
138
+ }
112
139
 
113
- case 'file_op_result':
114
- // Phase 4: 文件创建/删除/移动 — 清空该 agent 的所有目录缓存
115
- clearAgentDirCache(agentId);
116
- await forwardToClients(agentId, msg.conversationId, msg);
117
- break;
140
+ async function handleOneShotResponse(agentId, msg, routeKey) {
141
+ const pending = consumeWorkbenchRequest({
142
+ agentId,
143
+ requestId: msg._workbenchRequestId,
144
+ responseType: msg.type,
145
+ routeKey,
146
+ });
147
+ if (!pending) return;
148
+ const currentGeneration = currentWorkbenchWorkspaceGeneration({
149
+ route: pending.route,
150
+ userId: pending.userId,
151
+ role: pending.role,
152
+ });
153
+ if (!currentGeneration || currentGeneration !== pending.workspaceGeneration) return;
154
+ const projected = msg.type === 'file_content' && msg.binary
155
+ ? cacheBinaryPreview(msg)
156
+ : msg;
157
+ await sendToPendingClient(agentId, projected, pending);
158
+ }
118
159
 
119
- case 'git_status_result':
120
- case 'git_diff_result':
121
- case 'git_op_result':
122
- case 'file_search_result':
123
- await forwardToClients(agentId, msg.conversationId, msg);
124
- break;
160
+ /**
161
+ * Handle file, terminal, and git messages from an Agent. Route-scoped replies
162
+ * are delivered only through Server-owned correlations. Agent-supplied client
163
+ * or user ids never select a browser recipient.
164
+ */
165
+ export async function handleAgentFileTerminal(agentId, agent, rawMsg) {
166
+ const msg = rawMsg || {};
167
+ const routeKey = workbenchRouteKeyFromConversationId(msg.conversationId, agentId);
168
+ const terminalTypes = new Set([
169
+ 'terminal_created', 'terminal_output', 'terminal_closed', 'terminal_error',
170
+ ]);
171
+ const oneShotTypes = new Set([
172
+ 'file_content', 'file_saved', 'directory_listing', 'file_op_result',
173
+ 'git_status_result', 'git_diff_result', 'git_op_result', 'file_search_result',
174
+ ]);
175
+ if (!terminalTypes.has(msg.type) && !oneShotTypes.has(msg.type)) return false;
125
176
 
126
- default:
127
- return false; // Not handled
177
+ if (msg.type === 'file_saved') invalidateParentDirCache(agentId, msg.filePath);
178
+ if (msg.type === 'file_op_result') clearAgentDirCache(agentId);
179
+ if (msg.type === 'directory_listing' && msg.dirPath && msg.entries && !msg.error) {
180
+ setCachedDir(agentId, msg.dirPath, msg.entries);
128
181
  }
129
- return true; // Handled
182
+
183
+ if (routeKey) {
184
+ if (terminalTypes.has(msg.type)) await handleTerminalResponse(agentId, msg, routeKey);
185
+ else await handleOneShotResponse(agentId, msg, routeKey);
186
+ return true;
187
+ }
188
+
189
+ // `_workbench:` is reserved for Server-authored route conversations. An
190
+ // invalid or cross-Agent value is not a legacy conversation.
191
+ if (typeof msg.conversationId === 'string' && msg.conversationId.startsWith('_workbench:')) {
192
+ return true;
193
+ }
194
+
195
+ const projected = msg.type === 'file_content' && msg.binary
196
+ ? cacheBinaryPreview(msg)
197
+ : msg;
198
+ await forwardLegacyResponse(agentId, projected);
199
+ return true;
130
200
  }
@@ -53,6 +53,9 @@ function syncYeaftSessionMetadata(agentId, agent, event) {
53
53
 
54
54
  if (event.type === 'session_list_updated') {
55
55
  const rows = Array.isArray(event.sessions) ? event.sessions : [];
56
+ agent.yeaftSessions = new Map(rows
57
+ .filter(session => session?.id)
58
+ .map(session => [session.id, { ...session }]));
56
59
  try {
57
60
  if (ownerId) {
58
61
  reconcileAuthoritativeSessionSnapshot(ownerId, agentId, rows);
@@ -2,6 +2,7 @@ import { agents, userFileTabs } from '../context.js';
2
2
  import {
3
3
  sendToWebClient, forwardToAgent, broadcastAgentList
4
4
  } from '../ws-utils.js';
5
+ import { resolveWorkbenchRequest } from '../workbench-route.js';
5
6
 
6
7
  // Only Agents that explicitly advertise the package-replacement-safe updater
7
8
  // may receive remote upgrade commands. Version thresholds are insufficient:
@@ -73,11 +74,18 @@ export async function handleClientMisc(clientId, client, msg, checkAgentAccess)
73
74
 
74
75
  // File Tab 状态保存/恢复
75
76
  case 'update_file_tabs': {
76
- if (client.userId && client.currentAgent) {
77
- const key = `${client.userId}:${client.currentAgent}`;
77
+ const ftAgentId = msg.agentId || client.currentAgent;
78
+ if (client.userId && ftAgentId) {
79
+ if (!await checkAgentAccess(ftAgentId)) break;
80
+ const resolved = resolveWorkbenchRequest(client, msg, ftAgentId);
81
+ if (!resolved) break;
82
+ const identity = resolved.routeKey
83
+ ? `${resolved.routeKey}\u0000${resolved.workspaceGeneration}`
84
+ : ftAgentId;
85
+ const key = `${client.userId}:${identity}`;
78
86
  userFileTabs.set(key, {
79
87
  files: (msg.openFiles || []).map(f => ({ path: f.path })),
80
- activeIndex: msg.activeIndex || 0,
88
+ activeIndex: Number.isFinite(msg.activeIndex) ? msg.activeIndex : 0,
81
89
  timestamp: Date.now()
82
90
  });
83
91
  }
@@ -88,10 +96,19 @@ export async function handleClientMisc(clientId, client, msg, checkAgentAccess)
88
96
  const ftAgentId = msg.agentId || client.currentAgent;
89
97
  if (client.userId && ftAgentId) {
90
98
  if (!await checkAgentAccess(ftAgentId)) break;
91
- const key = `${client.userId}:${ftAgentId}`;
99
+ const resolved = resolveWorkbenchRequest(client, msg, ftAgentId);
100
+ if (!resolved) break;
101
+ const identity = resolved.routeKey
102
+ ? `${resolved.routeKey}\u0000${resolved.workspaceGeneration}`
103
+ : ftAgentId;
104
+ const key = `${client.userId}:${identity}`;
92
105
  const saved = userFileTabs.get(key);
93
106
  await sendToWebClient(client, {
94
107
  type: 'file_tabs_restored',
108
+ agentId: ftAgentId,
109
+ conversationId: resolved.conversationId || msg.conversationId || client.currentConversation,
110
+ workbenchRouteKey: resolved.routeKey,
111
+ workbenchWorkspaceGeneration: resolved.workspaceGeneration,
95
112
  openFiles: saved?.files || [],
96
113
  activeIndex: saved?.activeIndex || 0
97
114
  });