aws-runtime-bridge 1.5.0 → 1.6.1

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 (62) hide show
  1. package/README.md +1 -1
  2. package/dist/adapter/AdapterRegistry.d.ts +1 -1
  3. package/dist/adapter/AdapterRegistry.d.ts.map +1 -1
  4. package/dist/adapter/AdapterRegistry.js +0 -2
  5. package/dist/adapter/ClaudeSdkAdapter.d.ts +4 -0
  6. package/dist/adapter/ClaudeSdkAdapter.d.ts.map +1 -1
  7. package/dist/adapter/ClaudeSdkAdapter.js +11 -2
  8. package/dist/adapter/CodexSdkAdapter.js +1 -1
  9. package/dist/adapter/OpencodeSdkAdapter.js +2 -2
  10. package/dist/adapter/types.d.ts +10 -0
  11. package/dist/adapter/types.d.ts.map +1 -1
  12. package/dist/index.js +14 -43
  13. package/dist/middleware/auth.d.ts +5 -0
  14. package/dist/middleware/auth.d.ts.map +1 -1
  15. package/dist/middleware/auth.js +9 -1
  16. package/dist/routes/file-browser.d.ts.map +1 -1
  17. package/dist/routes/file-browser.js +21 -1
  18. package/dist/routes/file-browser.test.js +9 -0
  19. package/dist/routes/instance.d.ts +10 -0
  20. package/dist/routes/instance.d.ts.map +1 -1
  21. package/dist/routes/instance.js +93 -2
  22. package/dist/routes/instance.test.js +50 -0
  23. package/dist/routes/pty.d.ts +106 -0
  24. package/dist/routes/pty.d.ts.map +1 -0
  25. package/dist/routes/pty.js +526 -0
  26. package/dist/routes/pty.test.d.ts +2 -0
  27. package/dist/routes/pty.test.d.ts.map +1 -0
  28. package/dist/routes/pty.test.js +73 -0
  29. package/dist/routes/sessions.d.ts +1 -1
  30. package/dist/routes/sessions.d.ts.map +1 -1
  31. package/dist/routes/sessions.js +32 -213
  32. package/dist/routes/terminal.d.ts +32 -3
  33. package/dist/routes/terminal.d.ts.map +1 -1
  34. package/dist/routes/terminal.js +411 -243
  35. package/dist/routes/terminal.test.js +105 -29
  36. package/dist/services/agent-process-manager.d.ts +2 -2
  37. package/dist/services/agent-process-manager.d.ts.map +1 -1
  38. package/dist/services/agent-process-manager.js +3 -3
  39. package/dist/services/process-detector.d.ts +2 -4
  40. package/dist/services/process-detector.d.ts.map +1 -1
  41. package/dist/services/process-detector.js +9 -16
  42. package/dist/services/process-registry.d.ts +2 -2
  43. package/dist/services/process-registry.d.ts.map +1 -1
  44. package/dist/services/process-registry.js +1 -1
  45. package/dist/services/session-output.d.ts +15 -5
  46. package/dist/services/session-output.d.ts.map +1 -1
  47. package/dist/services/session-output.js +33 -3
  48. package/dist/services/session-output.test.js +43 -29
  49. package/dist/services/terminal-persistence.d.ts +9 -0
  50. package/dist/services/terminal-persistence.d.ts.map +1 -1
  51. package/dist/services/terminal-persistence.js +20 -0
  52. package/dist/services/tool-installer.d.ts +10 -0
  53. package/dist/services/tool-installer.d.ts.map +1 -1
  54. package/dist/services/tool-installer.js +126 -5
  55. package/dist/services/tool-installer.test.js +32 -1
  56. package/dist/services/workspace-files.d.ts +14 -0
  57. package/dist/services/workspace-files.d.ts.map +1 -1
  58. package/dist/services/workspace-files.js +52 -0
  59. package/dist/services/workspace-files.test.js +85 -1
  60. package/dist/types.d.ts +8 -4
  61. package/dist/types.d.ts.map +1 -1
  62. package/package.json +2 -1
@@ -1,22 +1,290 @@
1
1
  /**
2
2
  * 终端 API 路由
3
3
  *
4
- * 提供终端会话的启动、输入、调整大小和停止功能
5
- * 支持 PTY 模式和 SDK 模式
4
+ * 提供 SDK Agent 会话的启动、输入、调整大小和停止功能
5
+ * 终端 UI 仅作为 SDK Agent 的消息输入/状态输出载体
6
6
  */
7
+ import { spawn } from 'node:child_process';
8
+ import { promises as fs } from 'node:fs';
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+ import { TextDecoder } from 'node:util';
7
12
  import { Router } from 'express';
8
- import pty from 'node-pty';
9
13
  import { v4 as uuidv4 } from 'uuid';
10
14
  import { adapterRegistry, registerSdkProviders, resolveSdkProviderIdByCommand, } from '../adapter/index.js';
11
15
  import { validateToken } from '../middleware/auth.js';
12
16
  import { getAgentProcessManager } from '../services/agent-process-manager.js';
13
- import { findClaudeCodeProcesses, terminateProcessTree, waitForProcessExit } from '../services/process-detector.js';
14
- import { flushSessionOutput, scheduleOutputFlush, sendQuestionRequest, sendStatus, sessions } from '../services/session-output.js';
15
17
  import { enqueueMcpLaunchBinding } from '../services/mcp-launch-binding-queue.js';
16
- import { loadPersistedSessions, removePersistedSession, removePersistedSessionByAgentId, savePersistedSessions, upsertPersistedSession, } from '../services/terminal-persistence.js';
18
+ import { findClaudeCodeProcesses } from '../services/process-detector.js';
19
+ import { sendOutput, sendQuestionRequest, sendStatus } from '../services/session-output.js';
20
+ import { removePersistedSession, removePersistedSessionByAgentId, savePersistedSessions, updatePersistedSessionAutoCommands, upsertPersistedSession, } from '../services/terminal-persistence.js';
17
21
  export const terminalRouter = Router();
18
22
  // 导出 SDK 会话存储,供其他模块使用(如 sessions.ts)
19
23
  export const sdkSessions = new Map();
24
+ const TOOL_RESULT_PREVIEW_MAX_LENGTH = 4000;
25
+ const terminalCommandStates = new Map();
26
+ const DEFAULT_WINDOWS_TERMINAL_OUTPUT_ENCODING = 'gb18030';
27
+ const DEFAULT_TERMINAL_OUTPUT_ENCODING = 'utf-8';
28
+ /**
29
+ * 标准化终端输入:将前端回车符还原为 shell 可执行命令文本。
30
+ */
31
+ export function normalizeTerminalCommandInput(value) {
32
+ return String(value ?? '').replace(/\r/g, '\n').trim();
33
+ }
34
+ /**
35
+ * 解析终端命令输出解码编码。
36
+ * 主流程:优先使用环境变量覆盖 -> Windows 默认 GB18030 -> 其他平台默认 UTF-8。
37
+ */
38
+ export function resolveTerminalOutputEncoding(platform = process.platform, env = process.env) {
39
+ const configuredEncoding = env.AWS_TERMINAL_OUTPUT_ENCODING?.trim();
40
+ if (configuredEncoding) {
41
+ return configuredEncoding;
42
+ }
43
+ return platform === 'win32' ? DEFAULT_WINDOWS_TERMINAL_OUTPUT_ENCODING : DEFAULT_TERMINAL_OUTPUT_ENCODING;
44
+ }
45
+ /**
46
+ * 创建终端输出流式解码器,避免多字节中文被 chunk 边界截断。
47
+ */
48
+ export function createTerminalOutputDecoder(encoding = resolveTerminalOutputEncoding()) {
49
+ try {
50
+ return new TextDecoder(encoding, { fatal: false });
51
+ }
52
+ catch {
53
+ return new TextDecoder(DEFAULT_TERMINAL_OUTPUT_ENCODING, { fatal: false });
54
+ }
55
+ }
56
+ /**
57
+ * 解码终端输出 chunk;end=true 时刷新解码器内部剩余字节。
58
+ */
59
+ export function decodeTerminalOutputChunk(decoder, chunk, end = false) {
60
+ if (end) {
61
+ return decoder.decode();
62
+ }
63
+ if (!chunk || chunk.length === 0) {
64
+ return '';
65
+ }
66
+ return decoder.decode(chunk, { stream: true });
67
+ }
68
+ /**
69
+ * 解析 cd 命令目标;返回 undefined 表示不是目录切换命令,null 表示仅查看当前目录。
70
+ */
71
+ export function parseTerminalDirectoryChangeTarget(command) {
72
+ const match = command.trim().match(/^cd(?:\s+(.*))?$/i);
73
+ if (!match) {
74
+ return undefined;
75
+ }
76
+ const rawTarget = match[1]?.trim();
77
+ if (!rawTarget) {
78
+ return null;
79
+ }
80
+ const withoutDriveSwitch = rawTarget.replace(/^\/d\s+/i, '').trim();
81
+ if ((withoutDriveSwitch.startsWith('"') && withoutDriveSwitch.endsWith('"'))
82
+ || (withoutDriveSwitch.startsWith("'") && withoutDriveSwitch.endsWith("'"))) {
83
+ return withoutDriveSwitch.slice(1, -1);
84
+ }
85
+ return withoutDriveSwitch;
86
+ }
87
+ function getTerminalCommandState(entry) {
88
+ const existing = terminalCommandStates.get(entry.sessionId);
89
+ if (existing) {
90
+ return existing;
91
+ }
92
+ const state = {
93
+ currentDirectory: entry.workspacePath,
94
+ runningProcess: null,
95
+ };
96
+ terminalCommandStates.set(entry.sessionId, state);
97
+ return state;
98
+ }
99
+ function stopTerminalCommandProcess(sessionId) {
100
+ const state = terminalCommandStates.get(sessionId);
101
+ if (!state?.runningProcess) {
102
+ terminalCommandStates.delete(sessionId);
103
+ return;
104
+ }
105
+ try {
106
+ state.runningProcess.kill();
107
+ }
108
+ catch {
109
+ // ignore best-effort cleanup failures; session shutdown must continue
110
+ }
111
+ finally {
112
+ state.runningProcess = null;
113
+ terminalCommandStates.delete(sessionId);
114
+ }
115
+ }
116
+ function appendCommandOutput(entry, output) {
117
+ if (!output) {
118
+ return;
119
+ }
120
+ entry.seq += 1;
121
+ void sendOutput(entry.agentId, output, entry.sessionId, entry.seq);
122
+ }
123
+ /**
124
+ * 生成显示在终端输出流中的工作目录提示符。
125
+ */
126
+ export function formatTerminalPrompt(currentDirectory) {
127
+ return `\x1b[36m${currentDirectory}>\x1b[0m `;
128
+ }
129
+ function resolveDirectoryTarget(currentDirectory, target) {
130
+ if (target === '~') {
131
+ return os.homedir();
132
+ }
133
+ return path.resolve(currentDirectory, target);
134
+ }
135
+ /**
136
+ * 处理内置目录切换命令,保持终端后续命令的当前目录连续。
137
+ */
138
+ async function handleDirectoryChangeCommand(entry, state, command) {
139
+ const target = parseTerminalDirectoryChangeTarget(command);
140
+ if (target === undefined) {
141
+ return false;
142
+ }
143
+ if (target === null) {
144
+ appendCommandOutput(entry, `${state.currentDirectory}\r\n`);
145
+ return true;
146
+ }
147
+ const nextDirectory = resolveDirectoryTarget(state.currentDirectory, target);
148
+ try {
149
+ const stat = await fs.stat(nextDirectory);
150
+ if (!stat.isDirectory()) {
151
+ appendCommandOutput(entry, `cd: not a directory: ${nextDirectory}\r\n`);
152
+ return true;
153
+ }
154
+ state.currentDirectory = nextDirectory;
155
+ appendCommandOutput(entry, `${state.currentDirectory}\r\n`);
156
+ return true;
157
+ }
158
+ catch (error) {
159
+ const message = error instanceof Error ? error.message : String(error);
160
+ appendCommandOutput(entry, `cd: ${message}\r\n`);
161
+ return true;
162
+ }
163
+ }
164
+ /**
165
+ * 在当前 Agent 工作目录对应的终端上下文中执行命令。
166
+ */
167
+ async function executeTerminalCommand(sessionId, commandValue) {
168
+ const entry = sdkSessions.get(sessionId);
169
+ if (!entry) {
170
+ throw new Error('SDK session not found');
171
+ }
172
+ const command = normalizeTerminalCommandInput(commandValue);
173
+ const state = getTerminalCommandState(entry);
174
+ if (!command) {
175
+ return { started: false, cwd: state.currentDirectory };
176
+ }
177
+ if (state.runningProcess) {
178
+ throw new Error('A terminal command is already running for this session');
179
+ }
180
+ appendCommandOutput(entry, `${formatTerminalPrompt(state.currentDirectory)}${command}\r\n`);
181
+ const handledDirectoryChange = await handleDirectoryChangeCommand(entry, state, command);
182
+ if (handledDirectoryChange) {
183
+ return { started: false, cwd: state.currentDirectory };
184
+ }
185
+ await sendStatus(entry.agentId, sessionId, 'tool_using', {
186
+ actionType: 'bash',
187
+ actionLabel: '终端命令',
188
+ actionDetail: command,
189
+ });
190
+ const child = spawn(command, {
191
+ cwd: state.currentDirectory,
192
+ shell: true,
193
+ env: buildRuntimeEnv(entry.agentId, entry.workspacePath, process.env, entry.config.envOverrides),
194
+ });
195
+ state.runningProcess = child;
196
+ const stdoutDecoder = createTerminalOutputDecoder();
197
+ const stderrDecoder = createTerminalOutputDecoder();
198
+ child.stdout.on('data', (chunk) => {
199
+ appendCommandOutput(entry, decodeTerminalOutputChunk(stdoutDecoder, chunk));
200
+ });
201
+ child.stderr.on('data', (chunk) => {
202
+ appendCommandOutput(entry, decodeTerminalOutputChunk(stderrDecoder, chunk));
203
+ });
204
+ child.on('error', (error) => {
205
+ appendCommandOutput(entry, `\r\n[Terminal Error] ${error.message}\r\n`);
206
+ });
207
+ child.on('close', (code, signal) => {
208
+ state.runningProcess = null;
209
+ appendCommandOutput(entry, decodeTerminalOutputChunk(stdoutDecoder, undefined, true));
210
+ appendCommandOutput(entry, decodeTerminalOutputChunk(stderrDecoder, undefined, true));
211
+ if (code && code !== 0) {
212
+ appendCommandOutput(entry, `\r\n[exit ${code}]\r\n`);
213
+ }
214
+ else if (signal) {
215
+ appendCommandOutput(entry, `\r\n[signal ${signal}]\r\n`);
216
+ }
217
+ appendCommandOutput(entry, formatTerminalPrompt(state.currentDirectory));
218
+ void sendStatus(entry.agentId, sessionId, 'waiting_input');
219
+ });
220
+ return { started: true, cwd: state.currentDirectory };
221
+ }
222
+ export function buildRuntimeEnv(agentId, workspacePath, baseEnv, envOverrides) {
223
+ const env = {};
224
+ for (const [key, value] of Object.entries(baseEnv)) {
225
+ if (value !== undefined) {
226
+ env[key] = value;
227
+ }
228
+ }
229
+ if (workspacePath) {
230
+ env.AWS_WORKSPACE_PATH = String(workspacePath);
231
+ }
232
+ if (envOverrides) {
233
+ for (const [key, value] of Object.entries(envOverrides)) {
234
+ const normalizedKey = key.trim();
235
+ if (!normalizedKey || value === undefined || value === null) {
236
+ continue;
237
+ }
238
+ const normalizedValue = String(value).trim();
239
+ if (!normalizedValue) {
240
+ continue;
241
+ }
242
+ env[normalizedKey] = normalizedValue;
243
+ }
244
+ }
245
+ env.AWS_AGENT_ID = String(agentId);
246
+ env.AWS_MCP_CLAIM_LAUNCH_BINDING = 'true';
247
+ return env;
248
+ }
249
+ function formatToolResultForTimeline(result) {
250
+ if (result === undefined || result === null) {
251
+ return undefined;
252
+ }
253
+ const text = typeof result === 'string' ? result : JSON.stringify(result, null, 2);
254
+ const normalized = text.trim();
255
+ if (!normalized) {
256
+ return undefined;
257
+ }
258
+ if (normalized.length <= TOOL_RESULT_PREVIEW_MAX_LENGTH) {
259
+ return normalized;
260
+ }
261
+ return `${normalized.slice(0, TOOL_RESULT_PREVIEW_MAX_LENGTH)}\n...(结果过长,已截断)`;
262
+ }
263
+ function isMcpProviderEvent(event) {
264
+ const actionType = event.data.actionType;
265
+ if (actionType === 'mcp') {
266
+ return true;
267
+ }
268
+ const toolName = String(event.data.toolName || '').toLowerCase();
269
+ return toolName.startsWith('mcp__') || toolName.includes('-mcp__') || toolName.includes('mcp_');
270
+ }
271
+ export function formatSdkOutputEvent(event) {
272
+ if (event.type === 'text_delta' || event.type === 'thinking') {
273
+ return event.data.text || undefined;
274
+ }
275
+ if (event.type === 'error') {
276
+ return event.data.text ? `\r\n[SDK Error] ${event.data.text}\r\n` : undefined;
277
+ }
278
+ return undefined;
279
+ }
280
+ function forwardSdkOutputEvent(event, entry) {
281
+ const output = formatSdkOutputEvent(event);
282
+ if (!output) {
283
+ return;
284
+ }
285
+ entry.seq += 1;
286
+ void sendOutput(entry.agentId, output, event.sessionId, entry.seq);
287
+ }
20
288
  export function resolveSdkProviderId(command) {
21
289
  return resolveSdkProviderIdByCommand(command);
22
290
  }
@@ -33,6 +301,19 @@ function wireSdkAdapterEvents(adapter, definition) {
33
301
  const entry = sdkSessions.get(event.sessionId);
34
302
  if (entry) {
35
303
  console.log(`[${definition.displayName} Adapter] Event: ${event.type} for session ${event.sessionId}`);
304
+ forwardSdkOutputEvent(event, entry);
305
+ if (event.type === 'tool_use_end' && isMcpProviderEvent(event)) {
306
+ const actionResult = formatToolResultForTimeline(event.data.toolResult);
307
+ if (actionResult) {
308
+ void sendStatus(entry.agentId, event.sessionId, 'tool_result', {
309
+ actionType: 'mcp',
310
+ actionLabel: 'MCP返回',
311
+ actionDetail: event.data.toolName,
312
+ actionId: event.data.toolUseId,
313
+ actionResult,
314
+ });
315
+ }
316
+ }
36
317
  if (event.type === 'turn_complete' && event.data.usage) {
37
318
  sendStatus(entry.agentId, event.sessionId, 'waiting_input', undefined, event.data.usage);
38
319
  }
@@ -42,7 +323,7 @@ function wireSdkAdapterEvents(adapter, definition) {
42
323
  const entry = sdkSessions.get(sessionId);
43
324
  if (entry) {
44
325
  console.log(`[${definition.displayName} Adapter] Status changed: ${status} for agent ${entry.agentId}`, actionInfo || '');
45
- sendStatus(entry.agentId, sessionId, status, actionInfo);
326
+ void sendStatus(entry.agentId, sessionId, status, actionInfo);
46
327
  }
47
328
  });
48
329
  adapter.on('ask-user-question', (data) => {
@@ -60,17 +341,16 @@ function wireSdkAdapterEvents(adapter, definition) {
60
341
  });
61
342
  }
62
343
  /**
63
- * 启动终端会话
344
+ * 启动 SDK Agent 会话
64
345
  * POST /runtime/start
65
- * 支持 mode: 'pty' | 'sdk'
66
346
  */
67
347
  terminalRouter.post('/start', validateToken, async (req, res) => {
68
- const { agentId, workspacePath, command, mode = 'pty' } = req.body || {};
348
+ const { agentId, workspacePath, mode = 'sdk', } = req.body || {};
69
349
  if (!agentId || !workspacePath) {
70
350
  res.status(400).json({ error: 'agentId and workspacePath are required' });
71
351
  return;
72
352
  }
73
- const queuedBinding = enqueueMcpLaunchBinding({
353
+ enqueueMcpLaunchBinding({
74
354
  agentId: String(agentId),
75
355
  workspacePath: String(workspacePath),
76
356
  serverUrl: req.body?.serverUrl || req.body?.schedulerBaseUrl,
@@ -78,104 +358,10 @@ terminalRouter.post('/start', validateToken, async (req, res) => {
78
358
  userId: req.body?.userId,
79
359
  schedulerBaseUrl: req.body?.schedulerBaseUrl,
80
360
  });
81
- // SDK 模式
82
- if (mode === 'sdk') {
83
- await startSdkSession(req, res);
84
- return;
361
+ if (mode && mode !== 'sdk') {
362
+ console.warn(`[Runtime] Unsupported runtime mode "${mode}" requested for agent ${agentId}; starting SDK session instead.`);
85
363
  }
86
- // PTY 模式(原有逻辑)
87
- const oldSession = Array.from(sessions.entries()).find(([, value]) => value.agentId === agentId);
88
- if (oldSession) {
89
- try {
90
- oldSession[1].ptyProcess.kill();
91
- }
92
- catch {
93
- // ignore stale session cleanup errors
94
- }
95
- if (oldSession[1].flushTimer) {
96
- clearTimeout(oldSession[1].flushTimer);
97
- }
98
- sessions.delete(oldSession[0]);
99
- await removePersistedSession(oldSession[0]);
100
- }
101
- const sessionId = uuidv4();
102
- const shell = process.platform === 'win32' ? 'powershell.exe' : 'bash';
103
- const shellArgs = process.platform === 'win32' ? ['-NoLogo'] : [];
104
- const actualCommand = command || 'claude';
105
- const terminal = pty.spawn(shell, shellArgs, {
106
- name: 'xterm-color',
107
- cols: 120,
108
- rows: 30,
109
- cwd: workspacePath,
110
- env: {
111
- ...process.env,
112
- AWS_AGENT_ID: String(agentId),
113
- AWS_MCP_CLAIM_LAUNCH_BINDING: 'true',
114
- },
115
- });
116
- sessions.set(sessionId, {
117
- agentId,
118
- workspacePath,
119
- ptyProcess: terminal,
120
- outputBuffer: '',
121
- flushTimer: null,
122
- flushInFlight: null,
123
- seq: 0,
124
- });
125
- await upsertPersistedSession({
126
- sessionId,
127
- agentId,
128
- workspacePath,
129
- command: actualCommand,
130
- startedAt: new Date().toISOString(),
131
- status: 'running',
132
- mode: 'pty',
133
- // ★ PTY 模式:记录 pty 进程的 PID
134
- pid: terminal.pid,
135
- });
136
- // ★ 注册到进程管理器
137
- const processManager = getAgentProcessManager();
138
- await processManager.startProcess({
139
- agentId,
140
- sessionId,
141
- pid: terminal.pid,
142
- mode: 'pty',
143
- workspacePath,
144
- command: actualCommand,
145
- });
146
- await sendStatus(agentId, sessionId, 'running');
147
- terminal.onData((data) => {
148
- const session = sessions.get(sessionId);
149
- if (!session) {
150
- return;
151
- }
152
- session.outputBuffer += data;
153
- scheduleOutputFlush(sessionId);
154
- });
155
- terminal.onExit(async () => {
156
- const session = sessions.get(sessionId);
157
- if (session?.flushTimer) {
158
- clearTimeout(session.flushTimer);
159
- session.flushTimer = null;
160
- }
161
- await flushSessionOutput(sessionId);
162
- sessions.delete(sessionId);
163
- // 直接删除持久化会话,而不是改成 stopped,避免后续被恢复
164
- await removePersistedSession(sessionId);
165
- // ★ 从进程管理器中移除
166
- const processManager = getAgentProcessManager();
167
- await processManager.removeProcess(agentId);
168
- await sendStatus(agentId, null, 'stopped');
169
- });
170
- terminal.write(`${actualCommand}\r`);
171
- res.json({
172
- sessionId,
173
- status: 'running',
174
- agentId,
175
- workspacePath,
176
- command: actualCommand,
177
- mode: 'pty',
178
- });
364
+ await startSdkSession(req, res);
179
365
  });
180
366
  /**
181
367
  * 启动 SDK 会话
@@ -184,7 +370,7 @@ terminalRouter.post('/start', validateToken, async (req, res) => {
184
370
  async function startSdkSession(req, res) {
185
371
  const { agentId, workspacePath, command, autoAccept = true, initialPrompt,
186
372
  // MCP 配置 - 加载 aws-client-agent-mcp
187
- mcpConfigPath, extraMcpServers,
373
+ mcpConfigPath, extraMcpServers, envOverrides,
188
374
  // 空闲命令配置
189
375
  idleInputAutoCommand, nonInputAutoCommand, } = req.body || {};
190
376
  let sessionId = null;
@@ -208,11 +394,7 @@ async function startSdkSession(req, res) {
208
394
  // MCP 配置:不再默认注入 aws-mcp;由用户在面板中安装/配置
209
395
  mcpConfigPath,
210
396
  extraMcpServers: extraMcpServers || {},
211
- envOverrides: {
212
- AWS_AGENT_ID: String(agentId),
213
- AWS_WORKSPACE_PATH: String(workspacePath),
214
- AWS_MCP_CLAIM_LAUNCH_BINDING: 'true',
215
- },
397
+ envOverrides: buildRuntimeEnv(String(agentId), String(workspacePath), process.env, envOverrides),
216
398
  };
217
399
  // 存储会话信息
218
400
  sdkSessions.set(sessionId, {
@@ -221,6 +403,7 @@ async function startSdkSession(req, res) {
221
403
  sessionId,
222
404
  config,
223
405
  providerId, // 记录使用的 provider
406
+ seq: 0,
224
407
  });
225
408
  // 启动 SDK 会话
226
409
  await adapter.startSession(sessionId, config);
@@ -433,6 +616,7 @@ terminalRouter.post('/sdk/stop', validateToken, async (req, res) => {
433
616
  const adapter = adapterRegistry.get(providerId);
434
617
  await adapter.terminateSession(sessionId);
435
618
  sdkSessions.delete(sessionId);
619
+ stopTerminalCommandProcess(sessionId);
436
620
  await removePersistedSession(sessionId);
437
621
  // ★ 从进程管理器中移除
438
622
  const processManager = getAgentProcessManager();
@@ -479,27 +663,105 @@ terminalRouter.get('/sdk/status/:sessionId', validateToken, (req, res) => {
479
663
  res.status(500).json({ error: errorMessage });
480
664
  }
481
665
  });
482
- // ============ PTY 模式原有路由 ============
483
666
  /**
484
- * 发送输入到终端(PTY 模式)
667
+ * 执行当前 Agent 工作目录下的终端命令。
668
+ *
669
+ * 主流程:校验 session/command -> 读取会话 workspacePath -> 在该 cwd 下启动 shell 命令 -> 通过终端输出回调流式返回。
670
+ * POST /runtime/command
671
+ */
672
+ terminalRouter.post('/command', validateToken, async (req, res) => {
673
+ const { sessionId, command } = req.body || {};
674
+ if (!sessionId || command === undefined || command === null) {
675
+ res.status(400).json({ error: 'sessionId and command are required' });
676
+ return;
677
+ }
678
+ try {
679
+ const result = await executeTerminalCommand(String(sessionId), command);
680
+ res.json({ ok: true, mode: 'terminal', ...result });
681
+ }
682
+ catch (error) {
683
+ const errorMessage = error instanceof Error ? error.message : String(error);
684
+ res.status(500).json({ error: errorMessage });
685
+ }
686
+ });
687
+ // ============ 兼容终端 UI 的 SDK 路由 ============
688
+ /**
689
+ * 发送消息到 SDK 会话。
690
+ *
691
+ * 主流程:校验 session/input -> 定位 SDK 会话 -> 通过 provider adapter 发送用户消息。
485
692
  * POST /runtime/input
486
693
  */
487
- terminalRouter.post('/input', validateToken, (req, res) => {
488
- const { sessionId, input } = req.body || {};
694
+ terminalRouter.post('/input', validateToken, async (req, res) => {
695
+ const { sessionId, input, mode } = req.body || {};
489
696
  if (!sessionId || input === undefined || input === null) {
490
697
  res.status(400).json({ error: 'sessionId and input are required' });
491
698
  return;
492
699
  }
493
- const session = sessions.get(sessionId);
494
- if (!session) {
495
- res.status(404).json({ error: 'session not found' });
700
+ const entry = sdkSessions.get(sessionId);
701
+ if (!entry) {
702
+ res.status(404).json({ error: 'SDK session not found' });
496
703
  return;
497
704
  }
498
- session.ptyProcess.write(input);
499
- res.json({ ok: true });
705
+ try {
706
+ const providerId = entry.providerId || 'claude-code';
707
+ const adapter = adapterRegistry.get(providerId);
708
+ if (mode === 'shortcut') {
709
+ if (!adapter.sendShortcutInput) {
710
+ res.status(400).json({ error: 'shortcut input is not supported by this SDK provider' });
711
+ return;
712
+ }
713
+ await adapter.sendShortcutInput(sessionId, String(input));
714
+ res.json({ ok: true, mode: 'shortcut' });
715
+ return;
716
+ }
717
+ await adapter.sendMessage(sessionId, String(input));
718
+ res.json({ ok: true, mode: 'sdk' });
719
+ }
720
+ catch (error) {
721
+ const errorMessage = error instanceof Error ? error.message : String(error);
722
+ res.status(500).json({ error: errorMessage });
723
+ }
500
724
  });
501
725
  /**
502
- * 调整终端大小(PTY 模式)
726
+ * 热更新运行中 SDK 会话的自动命令配置。
727
+ *
728
+ * 主流程:按 agentId 定位 SDK 会话 -> 更新 adapter 内存配置 -> 同步持久化会话字段。
729
+ * POST /runtime/update-auto-commands
730
+ */
731
+ terminalRouter.post('/update-auto-commands', validateToken, async (req, res) => {
732
+ const { agentId, idleInputAutoCommand = '', nonInputAutoCommand = '' } = req.body || {};
733
+ const normalizedAgentId = String(agentId || '').trim();
734
+ if (!normalizedAgentId) {
735
+ res.status(400).json({ error: 'agentId is required' });
736
+ return;
737
+ }
738
+ const entry = [...sdkSessions.values()].find((session) => session.agentId === normalizedAgentId);
739
+ const commands = {
740
+ idleInputCommand: String(idleInputAutoCommand || ''),
741
+ nonInputCommand: String(nonInputAutoCommand || ''),
742
+ };
743
+ try {
744
+ let sessionUpdated = false;
745
+ if (entry) {
746
+ const adapter = adapterRegistry.get(entry.providerId || 'claude-code');
747
+ adapter.setIdleCommands?.(entry.sessionId, commands);
748
+ sessionUpdated = true;
749
+ }
750
+ const persistedUpdated = await updatePersistedSessionAutoCommands(normalizedAgentId, {
751
+ idleInputAutoCommand: commands.idleInputCommand,
752
+ nonInputAutoCommand: commands.nonInputCommand,
753
+ });
754
+ res.json({ ok: true, mode: 'sdk', sessionUpdated, persistedUpdated });
755
+ }
756
+ catch (error) {
757
+ const errorMessage = error instanceof Error ? error.message : String(error);
758
+ res.status(500).json({ ok: false, mode: 'sdk', error: errorMessage });
759
+ }
760
+ });
761
+ /**
762
+ * 兼容终端尺寸调整 API。
763
+ *
764
+ * SDK 会话没有终端尺寸概念;保留该端点用于前端 xterm fit 回调,返回 no-op。
503
765
  * POST /runtime/resize
504
766
  */
505
767
  terminalRouter.post('/resize', validateToken, (req, res) => {
@@ -508,16 +770,14 @@ terminalRouter.post('/resize', validateToken, (req, res) => {
508
770
  res.status(400).json({ error: 'sessionId, cols, rows are required' });
509
771
  return;
510
772
  }
511
- const session = sessions.get(sessionId);
512
- if (!session) {
513
- res.status(404).json({ error: 'session not found' });
773
+ if (!sdkSessions.has(sessionId)) {
774
+ res.status(404).json({ error: 'SDK session not found' });
514
775
  return;
515
776
  }
516
- session.ptyProcess.resize(Number(cols), Number(rows));
517
- res.json({ ok: true, cols: Number(cols), rows: Number(rows) });
777
+ res.json({ ok: true, mode: 'sdk', noop: true, cols: Number(cols), rows: Number(rows) });
518
778
  });
519
779
  /**
520
- * 停止终端会话(PTY 模式和 SDK 模式)
780
+ * 停止 SDK 会话。
521
781
  * POST /runtime/stop
522
782
  */
523
783
  terminalRouter.post('/stop', validateToken, async (req, res) => {
@@ -526,133 +786,41 @@ terminalRouter.post('/stop', validateToken, async (req, res) => {
526
786
  res.status(400).json({ error: 'sessionId is required' });
527
787
  return;
528
788
  }
529
- // 1. 检查是否是 SDK 会话
530
789
  const sdkEntry = sdkSessions.get(sessionId);
531
- if (sdkEntry) {
532
- const agentId = sdkEntry.agentId;
533
- try {
534
- // 根据 providerId 获取对应的 adapter
535
- const providerId = sdkEntry.providerId || 'claude-code';
536
- const adapter = adapterRegistry.get(providerId);
537
- await adapter.terminateSession(sessionId);
538
- sdkSessions.delete(sessionId);
539
- // 同时按 sessionId 和 agentId 删除,确保持久化会话被清除
540
- await removePersistedSession(sessionId);
541
- await removePersistedSessionByAgentId(agentId);
542
- // ★ 从进程管理器中移除
543
- const processManager = getAgentProcessManager();
544
- await processManager.removeProcess(agentId);
545
- await sendStatus(agentId, null, 'stopped');
546
- res.json({ ok: true, status: 'stopped', mode: 'sdk' });
547
- return;
548
- }
549
- catch (error) {
550
- const errorMessage = error instanceof Error ? error.message : String(error);
551
- console.error(`[Runtime] Failed to terminate SDK session ${sessionId}:`, errorMessage);
552
- res.status(500).json({ ok: false, status: 'stop_failed', mode: 'sdk', error: errorMessage });
553
- return;
554
- }
555
- }
556
- // 2. 检查是否是 PTY 会话
557
- const session = sessions.get(sessionId);
558
- if (!session) {
559
- // 3. 会话不存在于内存,直接清理持久化记录,避免后续恢复
790
+ if (!sdkEntry) {
560
791
  await removePersistedSession(sessionId);
561
- // 也从进程管理器清理(通过持久化会话查找 agentId)
562
- try {
563
- const persistedSessions = await loadPersistedSessions();
564
- const persistedSession = persistedSessions.find(p => p.sessionId === sessionId);
565
- if (persistedSession) {
566
- const processManager = getAgentProcessManager();
567
- await processManager.removeProcess(persistedSession.agentId);
568
- }
569
- }
570
- catch {
571
- // ignore cleanup errors
572
- }
573
792
  res.json({ ok: true, status: 'stopped', mode: 'none' });
574
793
  return;
575
794
  }
576
- // 4. 终止 PTY 进程
577
- const agentId = session.agentId;
578
- const pid = session.ptyProcess.pid;
579
- let exited = false;
580
- let terminated = 0;
581
- let failed = 0;
795
+ const agentId = sdkEntry.agentId;
582
796
  try {
583
- session.ptyProcess.kill();
584
- exited = await waitForProcessExit(pid, 3000);
797
+ const providerId = sdkEntry.providerId || 'claude-code';
798
+ const adapter = adapterRegistry.get(providerId);
799
+ await adapter.terminateSession(sessionId);
800
+ sdkSessions.delete(sessionId);
801
+ stopTerminalCommandProcess(sessionId);
802
+ await removePersistedSession(sessionId);
803
+ await removePersistedSessionByAgentId(agentId);
804
+ const processManager = getAgentProcessManager();
805
+ await processManager.removeProcess(agentId);
806
+ await sendStatus(agentId, null, 'stopped');
807
+ res.json({ ok: true, status: 'stopped', mode: 'sdk' });
585
808
  }
586
809
  catch (error) {
587
- console.warn(`[Runtime] Failed to kill PTY process gracefully: sessionId=${sessionId}, pid=${pid}`, error);
588
- }
589
- if (!exited) {
590
- const result = terminateProcessTree(pid);
591
- terminated = result.terminated;
592
- failed = result.failed;
593
- exited = await waitForProcessExit(pid, 5000);
594
- }
595
- if (!exited) {
596
- await sendStatus(agentId, sessionId, 'stop_failed');
597
- res.status(500).json({
598
- ok: false,
599
- status: 'stop_failed',
600
- mode: 'pty',
601
- pid,
602
- terminated,
603
- failed,
604
- error: `process ${pid} is still running after termination attempts`,
605
- });
606
- return;
810
+ const errorMessage = error instanceof Error ? error.message : String(error);
811
+ console.error(`[Runtime] Failed to terminate SDK session ${sessionId}:`, errorMessage);
812
+ res.status(500).json({ ok: false, status: 'stop_failed', mode: 'sdk', error: errorMessage });
607
813
  }
608
- if (session.flushTimer) {
609
- clearTimeout(session.flushTimer);
610
- session.flushTimer = null;
611
- }
612
- await flushSessionOutput(sessionId);
613
- sessions.delete(sessionId);
614
- // 同时按 sessionId 和 agentId 删除,确保持久化会话被清除
615
- await removePersistedSession(sessionId);
616
- await removePersistedSessionByAgentId(agentId);
617
- // ★ 从进程管理器中移除
618
- const processManager = getAgentProcessManager();
619
- await processManager.removeProcess(agentId);
620
- await sendStatus(agentId, null, 'stopped');
621
- res.json({ ok: true, status: 'stopped', mode: 'pty' });
622
814
  });
623
815
  /**
624
- * 停止所有终端会话(PTY SDK)
816
+ * 停止所有 SDK 会话
625
817
  * POST /runtime/stop-all
626
- * 用于 aws-mcp-server 重启时清理所有终端
818
+ * 用于 aws-mcp-server 重启时清理所有 SDK Agent 会话
627
819
  */
628
820
  terminalRouter.post('/stop-all', validateToken, async (_req, res) => {
629
- const stoppedPty = [];
630
821
  const stoppedSdk = [];
631
822
  const errors = [];
632
- // 1. 停止所有 PTY 会话
633
- for (const [sessionId, session] of sessions.entries()) {
634
- try {
635
- if (session.flushTimer) {
636
- clearTimeout(session.flushTimer);
637
- session.flushTimer = null;
638
- }
639
- await flushSessionOutput(sessionId);
640
- try {
641
- session.ptyProcess.kill();
642
- }
643
- catch {
644
- // ignore kill errors
645
- }
646
- sessions.delete(sessionId);
647
- stoppedPty.push(sessionId);
648
- await sendStatus(session.agentId, null, 'stopped');
649
- }
650
- catch (error) {
651
- const errorMessage = error instanceof Error ? error.message : String(error);
652
- errors.push(`PTY ${sessionId}: ${errorMessage}`);
653
- }
654
- }
655
- // 2. 停止所有 SDK 会话
823
+ // 1. 停止所有 SDK 会话
656
824
  for (const [sessionId, entry] of sdkSessions.entries()) {
657
825
  try {
658
826
  // 根据 providerId 获取对应的 adapter
@@ -660,6 +828,7 @@ terminalRouter.post('/stop-all', validateToken, async (_req, res) => {
660
828
  const adapter = adapterRegistry.get(providerId);
661
829
  await adapter.terminateSession(sessionId);
662
830
  sdkSessions.delete(sessionId);
831
+ stopTerminalCommandProcess(sessionId);
663
832
  stoppedSdk.push(sessionId);
664
833
  await sendStatus(entry.agentId, null, 'stopped');
665
834
  }
@@ -668,7 +837,7 @@ terminalRouter.post('/stop-all', validateToken, async (_req, res) => {
668
837
  errors.push(`SDK ${sessionId}: ${errorMessage}`);
669
838
  }
670
839
  }
671
- // 3. 清理持久化状态,避免 stop-all 后还能被恢复
840
+ // 2. 清理持久化状态,避免 stop-all 后还能被恢复
672
841
  // 直接清空整个持久化文件,确保不会有残留
673
842
  try {
674
843
  await savePersistedSessions([]);
@@ -681,7 +850,6 @@ terminalRouter.post('/stop-all', validateToken, async (_req, res) => {
681
850
  res.json({
682
851
  ok: true,
683
852
  stopped: {
684
- pty: stoppedPty.length,
685
853
  sdk: stoppedSdk.length,
686
854
  },
687
855
  errors: errors.length > 0 ? errors : undefined,