@yeaft/webchat-agent 1.0.413 → 1.0.415

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 (58) hide show
  1. package/browser-runtime/browser-install.js +497 -0
  2. package/browser-runtime/cli.js +88 -0
  3. package/browser-runtime/config.js +116 -0
  4. package/browser-runtime/errors.js +8 -0
  5. package/browser-runtime/extension/manifest.json +18 -0
  6. package/browser-runtime/extension/offscreen.html +5 -0
  7. package/browser-runtime/extension/offscreen.js +101 -0
  8. package/browser-runtime/extension/popup.html +5 -0
  9. package/browser-runtime/extension/popup.js +1 -0
  10. package/browser-runtime/extension/service-worker.js +48 -0
  11. package/browser-runtime/extension.js +45 -0
  12. package/browser-runtime/index.js +5 -0
  13. package/browser-runtime/probe.js +427 -0
  14. package/browser-runtime/protocol.js +71 -0
  15. package/browser-runtime/service.js +132 -0
  16. package/browser-runtime/windows-version-job.ps1 +233 -0
  17. package/browser-runtime/windows-version-worker.js +75 -0
  18. package/browser-runtime/windows-version.js +85 -0
  19. package/cli.js +24 -7
  20. package/connection/index.js +12 -0
  21. package/context.js +1 -0
  22. package/index.js +19 -2
  23. package/llm-config-cli.js +24 -21
  24. package/local-runtime/server/client-protocol.js +14 -0
  25. package/local-runtime/server/context.js +3 -2
  26. package/local-runtime/server/handlers/agent-file-terminal.js +185 -115
  27. package/local-runtime/server/handlers/agent-output.js +3 -0
  28. package/local-runtime/server/handlers/client-misc.js +21 -4
  29. package/local-runtime/server/handlers/client-workbench.js +222 -41
  30. package/local-runtime/server/workbench-correlation.js +184 -0
  31. package/local-runtime/server/workbench-route.js +180 -0
  32. package/local-runtime/server/ws-agent.js +4 -0
  33. package/local-runtime/server/ws-client.js +25 -3
  34. package/local-runtime/version.json +1 -1
  35. package/local-runtime/web/app.bundle.js +191 -135
  36. package/local-runtime/web/app.bundle.js.gz +0 -0
  37. package/local-runtime/web/index.html +2 -2
  38. package/local-runtime/web/style.bundle.css +1 -1
  39. package/local-runtime/web/style.bundle.css.gz +0 -0
  40. package/package.json +5 -1
  41. package/service/config.js +23 -2
  42. package/service/index.js +1 -0
  43. package/service/linux.js +3 -2
  44. package/terminal.js +167 -30
  45. package/workbench/file-ops.js +21 -20
  46. package/workbench/file-search.js +4 -3
  47. package/workbench/git-ops.js +23 -22
  48. package/workbench/request-routing.js +16 -0
  49. package/yeaft/cli.js +57 -1
  50. package/yeaft/config-api.js +138 -192
  51. package/yeaft/config-store.js +192 -0
  52. package/yeaft/config.js +3 -0
  53. package/yeaft/init.js +20 -7
  54. package/yeaft/sessions/feature-flag.js +15 -33
  55. package/yeaft/sessions/session-manifest.js +114 -10
  56. package/yeaft/stdio-protocol.js +57 -0
  57. package/yeaft/storage/atomic.js +43 -17
  58. package/yeaft/tools/process-runner.js +86 -13
package/cli.js CHANGED
@@ -61,6 +61,14 @@ const SERVICE_COMMANDS = ['install', 'uninstall', 'start', 'stop', 'restart', 's
61
61
 
62
62
  if (command === 'doctor') {
63
63
  await handleDoctorCommand(subArgs);
64
+ } else if (command === 'browser') {
65
+ try {
66
+ const { handleBrowserCommand } = await import('./browser-runtime/cli.js');
67
+ await handleBrowserCommand(subArgs);
68
+ } catch (error) {
69
+ console.error(`Browser Runtime command failed: ${error.code || error.message}`);
70
+ process.exitCode = 1;
71
+ }
64
72
  } else if (command === 'llm') {
65
73
  await handleLlmCommand(subArgs);
66
74
  } else if (command === 'local') {
@@ -137,6 +145,10 @@ function printHelp() {
137
145
  yeaft-agent status [options] Show service status
138
146
  yeaft-agent logs [options] View service logs (follow mode)
139
147
  yeaft-agent doctor Diagnose service configuration
148
+ yeaft-agent browser install Install the pinned Chrome for Testing build
149
+ yeaft-agent browser probe Validate tabCapture → offscreen → WebRTC
150
+ yeaft-agent browser enable|disable Change the Browser Runtime feature flag
151
+ yeaft-agent browser status Show config and managed browser install status
140
152
  yeaft-agent llm <command> Configure local Yeaft LLM providers/models
141
153
  yeaft-agent container <command> Manage a Dockerized yeaft-agent
142
154
  yeaft-agent upgrade [--name <id>] Upgrade and restart the selected service
@@ -153,6 +165,11 @@ function printHelp() {
153
165
  --yeaft-dir <dir> Yeaft data directory for this instance
154
166
  --auto-upgrade Check for updates on startup
155
167
 
168
+ Browser options:
169
+ --executable <path> Explicit compatible Chrome for Testing executable
170
+ --headful Run the Browser Runtime probe with a visible browser
171
+ --name <id> Select the named Agent instance
172
+
156
173
  Environment variables (alternative to flags):
157
174
  YEAFT_AGENT_INSTANCE Deprecated local service instance id override
158
175
  SERVER_URL WebSocket server URL
@@ -174,7 +191,7 @@ function printHelp() {
174
191
  `);
175
192
  }
176
193
 
177
- function printLlmHelp() {
194
+ export function printLlmHelp() {
178
195
  console.log(`
179
196
  Configure local Yeaft LLM providers/models in ~/.yeaft/config.json.
180
197
 
@@ -257,7 +274,7 @@ async function handleLlmCommand(args) {
257
274
  const preset = args[1];
258
275
  if (preset === 'github-copilot') {
259
276
  result = await useGitHubCopilot(current, options);
260
- writeLocalLlmConfig(result.config, configPath);
277
+ writeLocalLlmConfig(result.config, configPath, current);
261
278
  console.log(`Configured GitHub Copilot provider with ${result.discovery.models.length} ${result.discovery.source} models.`);
262
279
  if (result.discovery.warning) console.log(`Warning: ${result.discovery.warning}`);
263
280
  console.log(`Primary model: ${result.config.primaryModel}`);
@@ -266,7 +283,7 @@ async function handleLlmCommand(args) {
266
283
  }
267
284
  if (preset === 'openai-compatible') {
268
285
  result = await useOpenAICompatible(current, options, process.env);
269
- writeLocalLlmConfig(result.config, configPath);
286
+ writeLocalLlmConfig(result.config, configPath, current);
270
287
  console.log(`Configured ${result.provider.name} with ${result.discovery.models.length} live models.`);
271
288
  console.log(`Primary model: ${result.config.primaryModel}`);
272
289
  if (result.config.fastModel) console.log(`Fast model: ${result.config.fastModel}`);
@@ -277,7 +294,7 @@ async function handleLlmCommand(args) {
277
294
 
278
295
  if (subcommand === 'add-provider') {
279
296
  result = addOrUpdateProvider(current, options, process.env);
280
- writeLocalLlmConfig(result.config, configPath);
297
+ writeLocalLlmConfig(result.config, configPath, current);
281
298
  console.log(`${result.replaced ? 'Updated' : 'Added'} provider: ${result.provider.name}`);
282
299
  if (result.config.primaryModel) console.log(`Primary model: ${result.config.primaryModel}`);
283
300
  if (result.config.fastModel) console.log(`Fast model: ${result.config.fastModel}`);
@@ -286,7 +303,7 @@ async function handleLlmCommand(args) {
286
303
 
287
304
  if (subcommand === 'set-model') {
288
305
  result = setLocalModels(current, options);
289
- writeLocalLlmConfig(result.config, configPath);
306
+ writeLocalLlmConfig(result.config, configPath, current);
290
307
  if (result.config.primaryModel) console.log(`Primary model: ${result.config.primaryModel}`);
291
308
  if (result.config.fastModel) console.log(`Fast model: ${result.config.fastModel}`);
292
309
  return;
@@ -294,7 +311,7 @@ async function handleLlmCommand(args) {
294
311
 
295
312
  if (subcommand === 'remove-provider') {
296
313
  result = removeProvider(current, options);
297
- writeLocalLlmConfig(result.config, configPath);
314
+ writeLocalLlmConfig(result.config, configPath, current);
298
315
  console.log(result.removed ? `Removed provider: ${options.name}` : `Provider not found: ${options.name}`);
299
316
  if (result.cleared.length) {
300
317
  console.log(`Cleared ${result.cleared.join(', ')} because it referenced ${options.name}`);
@@ -426,7 +443,7 @@ async function runLlmSetup(current, configPath) {
426
443
  const fastAnswer = (await rl.question('Fast model number or id (optional): ')).trim();
427
444
  const fast = fastAnswer ? (ids[Number(fastAnswer) - 1] || fastAnswer) : null;
428
445
  const result = await useGitHubCopilot(current, { model: primary, fast, allowUnknownModel: false });
429
- writeLocalLlmConfig(result.config, configPath);
446
+ writeLocalLlmConfig(result.config, configPath, current);
430
447
  console.log(`Configured GitHub Copilot with ${result.discovery.models.length} ${result.discovery.source} models.`);
431
448
  if (result.discovery.warning) console.log(`Warning: ${result.discovery.warning}`);
432
449
  console.log(`Primary model: ${result.config.primaryModel}`);
@@ -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/context.js CHANGED
@@ -20,6 +20,7 @@ export default {
20
20
  CONFIG: null,
21
21
  managedCliReady: Promise.resolve([]),
22
22
  agentCapabilities: [],
23
+ browserRuntime: null,
23
24
  // Agent 级别的 slash commands 缓存(所有 conversation 共用)
24
25
  slashCommands: [],
25
26
  // Slash command 描述映射: { commandName: description } — 从 plugin commands/*.md 提取
package/index.js CHANGED
@@ -23,6 +23,7 @@ import { connect } from './connection.js';
23
23
  import { loadMcpServers } from './mcp.js';
24
24
  import { SAFE_REMOTE_UPGRADE_CAPABILITY } from './upgrade-command.js';
25
25
  import { loadConfig as loadYeaftConfig } from './yeaft/config.js';
26
+ import { bootBrowserRuntime, shutdownBrowserRuntime } from './browser-runtime/index.js';
26
27
  import {
27
28
  ensureManagedCliTools,
28
29
  prepareManagedCliToolEnvironment,
@@ -104,7 +105,7 @@ const { agentName: AGENT_NAME, instanceId: INSTANCE_ID } = resolveRuntimeIdentit
104
105
  const YEAFT_DIR = process.env.YEAFT_DIR || fileConfig.yeaftDir || getDefaultYeaftDir(INSTANCE_ID);
105
106
  try {
106
107
  if (!existsSync(YEAFT_DIR)) {
107
- mkdirSync(YEAFT_DIR, { recursive: true });
108
+ mkdirSync(YEAFT_DIR, { recursive: true, mode: 0o700 });
108
109
  console.log(`[Agent] Created yeaft dir: ${YEAFT_DIR}`);
109
110
  }
110
111
  } catch (err) {
@@ -159,7 +160,7 @@ async function detectCapabilities() {
159
160
  // agent build can speak plaintext WS frames. New servers see this and
160
161
  // flip `agent.encryptOutbound = false`, stopping outbound encryption
161
162
  // to this peer. Old servers ignore the unknown capability token.
162
- 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'];
163
164
  if (process.platform === 'linux') capabilities.push('work_item_attachments');
164
165
  const pty = await loadNodePty();
165
166
  if (pty) capabilities.push('terminal');
@@ -356,6 +357,7 @@ function cleanup() {
356
357
  const { shutdownWorkCenter } = await import('./yeaft/work-center/bridge.js');
357
358
  await shutdownWorkCenter();
358
359
  } catch {}
360
+ try { await shutdownBrowserRuntime(); } catch {}
359
361
  if (ctx.ws) ctx.ws.close();
360
362
  });
361
363
  }
@@ -396,6 +398,21 @@ process.on('SIGTERM', async () => {
396
398
  } catch (error) {
397
399
  console.warn(`[Startup] managed rg environment setup failed; using built-in fallback: ${error?.message || error}`);
398
400
  }
401
+ try {
402
+ const runtimeConfig = loadYeaftConfig({ dir: YEAFT_DIR }).browserRuntime;
403
+ ctx.browserRuntime = await bootBrowserRuntime({
404
+ yeaftDir: YEAFT_DIR,
405
+ config: runtimeConfig,
406
+ });
407
+ const probe = ctx.browserRuntime.snapshot().probe;
408
+ if (probe?.ok) {
409
+ console.log(`[BrowserRuntime] ready: capture=${probe.captureMode} build=${probe.actualBuildId || probe.expectedBuildId}`);
410
+ } else if (runtimeConfig?.enabled) {
411
+ console.warn(`[BrowserRuntime] unavailable: ${probe?.code || 'probe_failed'}`);
412
+ }
413
+ } catch (err) {
414
+ console.warn(`[BrowserRuntime] startup probe failed: ${err?.message || err}`);
415
+ }
399
416
  ctx.agentCapabilities = await detectCapabilities();
400
417
  // Prime the models.dev community catalog so the Yeaft engine's *synchronous*
401
418
  // hot path (engine.js / config.js / cli.js all read context-window inline)
package/llm-config-cli.js CHANGED
@@ -1,7 +1,8 @@
1
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
2
- import { dirname, join } from 'path';
1
+ import { existsSync, readFileSync } from 'fs';
2
+ import { join } from 'path';
3
3
  import { homedir } from 'os';
4
4
  import { normalizePluginConfig } from './yeaft/plugins.js';
5
+ import { mutateAgentConfigPath } from './yeaft/config-store.js';
5
6
  import {
6
7
  discoverGitHubCopilotModels,
7
8
  discoverOpenAICompatibleModels,
@@ -40,31 +41,33 @@ export function readLocalLlmConfig(configPath = getDefaultYeaftConfigPath()) {
40
41
  return parsed;
41
42
  }
42
43
 
43
- export function writeLocalLlmConfig(config, configPath = getDefaultYeaftConfigPath()) {
44
- // The CLI exposes this writer publicly, so do not rely on callers having
45
- // already used readLocalLlmConfig(). An existing invalid Plugins policy must
46
- // remain on disk and keep runtime fail-closed until the user repairs it.
47
- const existing = existsSync(configPath) ? readLocalLlmConfig(configPath) : null;
44
+ export function writeLocalLlmConfig(config, configPath = getDefaultYeaftConfigPath(), baseConfig = null) {
48
45
  if (!config || typeof config !== 'object' || Array.isArray(config)
49
46
  || Object.getPrototypeOf(config) !== Object.prototype) {
50
47
  throw new Error(`Invalid config file: expected JSON object at ${configPath}`);
51
48
  }
52
- const next = { ...config };
53
- if (next.plugins === undefined
54
- && Object.prototype.hasOwnProperty.call(existing || {}, 'plugins')) {
55
- // LLM commands change provider/model fields. They must not remove a
56
- // separately-managed Plugin allowlist merely because their payload omits it.
57
- next.plugins = existing.plugins;
58
- }
59
- if (next.plugins !== undefined) {
49
+ if (config.plugins !== undefined) {
60
50
  try {
61
- normalizePluginConfig(next.plugins);
62
- } catch (err) {
63
- throw new Error(`Invalid config file: ${err?.message || err}`);
51
+ normalizePluginConfig(config.plugins);
52
+ } catch (error) {
53
+ throw new Error(`Invalid config file: ${error?.message || error}`);
64
54
  }
65
55
  }
66
- mkdirSync(dirname(configPath), { recursive: true });
67
- writeFileSync(configPath, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
56
+
57
+ mutateAgentConfigPath(configPath, current => {
58
+ const baseline = baseConfig && typeof baseConfig === 'object' && !Array.isArray(baseConfig)
59
+ ? baseConfig
60
+ : {};
61
+ const keys = new Set([...Object.keys(baseline), ...Object.keys(config)]);
62
+ for (const key of keys) {
63
+ if (key === 'plugins' && !Object.prototype.hasOwnProperty.call(config, key)) continue;
64
+ const before = baseline[key];
65
+ const after = config[key];
66
+ if (JSON.stringify(before) === JSON.stringify(after)) continue;
67
+ if (Object.prototype.hasOwnProperty.call(config, key)) current[key] = after;
68
+ else delete current[key];
69
+ }
70
+ });
68
71
  }
69
72
 
70
73
  export function parseModelsCsv(value) {
@@ -313,7 +316,7 @@ export async function tryAutoConfigureGitHubCopilot(configPath = getDefaultYeaft
313
316
  ...options,
314
317
  model: options.model || DEFAULT_GITHUB_COPILOT_MODEL,
315
318
  });
316
- writeLocalLlmConfig(result.config, configPath);
319
+ writeLocalLlmConfig(result.config, configPath, current);
317
320
  return { configured: true, reason: 'configured', ...result };
318
321
  } catch (error) {
319
322
  return {
@@ -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);