@yeaft/webchat-agent 0.0.234 → 0.0.236

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.
@@ -0,0 +1,294 @@
1
+ import { execFile, spawn } from 'child_process';
2
+ import { writeFileSync, mkdirSync, existsSync, cpSync } from 'fs';
3
+ import { join, dirname } from 'path';
4
+ import { fileURLToPath } from 'url';
5
+ import { platform } from 'os';
6
+ import ctx from '../context.js';
7
+ import { getConfigDir } from '../service.js';
8
+ import { sendToServer } from './buffer.js';
9
+ import { stopAgentHeartbeat } from './heartbeat.js';
10
+
11
+ // Shared cleanup logic for restart/upgrade
12
+ function cleanupAndExit(exitCode) {
13
+ setTimeout(() => {
14
+ for (const [, term] of ctx.terminals) {
15
+ if (term.pty) { try { term.pty.kill(); } catch {} }
16
+ if (term.timer) clearTimeout(term.timer);
17
+ }
18
+ ctx.terminals.clear();
19
+ for (const [, state] of ctx.conversations) {
20
+ if (state.abortController) state.abortController.abort();
21
+ if (state.inputStream) state.inputStream.done();
22
+ }
23
+ ctx.conversations.clear();
24
+ stopAgentHeartbeat();
25
+ if (ctx.ws) {
26
+ ctx.ws.removeAllListeners('close');
27
+ ctx.ws.close();
28
+ }
29
+ clearTimeout(ctx.reconnectTimer);
30
+ console.log(`[Agent] Cleanup done, exiting with code ${exitCode}...`);
31
+ process.exit(exitCode);
32
+ }, 500);
33
+ }
34
+
35
+ export function handleRestartAgent() {
36
+ console.log('[Agent] Restart requested, shutting down for PM2/systemd restart...');
37
+ sendToServer({ type: 'restart_agent_ack' });
38
+ cleanupAndExit(1);
39
+ }
40
+
41
+ export async function handleUpgradeAgent() {
42
+ console.log('[Agent] Upgrade requested, checking for updates...');
43
+ try {
44
+ const pkgName = ctx.pkgName || '@yeaft/webchat-agent';
45
+ // Check latest version (async to avoid blocking heartbeat)
46
+ const latestVersion = await new Promise((resolve, reject) => {
47
+ execFile('npm', ['view', pkgName, 'version'], { stdio: 'pipe', shell: true }, (err, stdout) => {
48
+ if (err) reject(err); else resolve(stdout.toString().trim());
49
+ });
50
+ });
51
+ if (latestVersion === ctx.agentVersion) {
52
+ console.log(`[Agent] Already at latest version (${ctx.agentVersion}), skipping upgrade.`);
53
+ sendToServer({ type: 'upgrade_agent_ack', success: true, alreadyLatest: true, version: ctx.agentVersion });
54
+ return;
55
+ }
56
+ console.log(`[Agent] Upgrading from ${ctx.agentVersion} to latest (${latestVersion})...`);
57
+
58
+ // 检测安装方式:npm install 的路径包含 node_modules,源码运行则不包含
59
+ const scriptPath = (process.argv[1] || '').replace(/\\/g, '/');
60
+ const nmIndex = scriptPath.lastIndexOf('/node_modules/');
61
+ const isNpmInstall = nmIndex !== -1;
62
+
63
+ if (!isNpmInstall) {
64
+ // 源码运行不支持远程升级(代码在 git repo 中,需要手动 git pull)
65
+ console.log('[Agent] Source-based install detected, remote upgrade not supported.');
66
+ sendToServer({ type: 'upgrade_agent_ack', success: false, error: 'Source-based install: please use git pull to upgrade' });
67
+ return;
68
+ }
69
+
70
+ // 提取 node_modules 的父目录
71
+ const installDir = scriptPath.substring(0, nmIndex);
72
+
73
+ // 判断全局安装 vs 局部安装
74
+ const isGlobalInstall = await new Promise((resolve) => {
75
+ execFile('npm', ['prefix', '-g'], { shell: true }, (err, stdout) => {
76
+ if (err) { resolve(false); return; }
77
+ const globalPrefix = stdout.toString().trim().replace(/\\/g, '/');
78
+ resolve(installDir === globalPrefix || installDir === globalPrefix + '/lib');
79
+ });
80
+ });
81
+
82
+ const isWindows = platform() === 'win32';
83
+
84
+ if (isWindows) {
85
+ spawnWindowsUpgradeScript(pkgName, installDir, isGlobalInstall, latestVersion);
86
+ } else {
87
+ spawnUnixUpgradeScript(pkgName, installDir, isGlobalInstall, latestVersion);
88
+ }
89
+
90
+ // 清理并退出,让升级脚本接管
91
+ cleanupAndExit(0);
92
+ } catch (e) {
93
+ console.error('[Agent] Upgrade failed:', e.message);
94
+ sendToServer({ type: 'upgrade_agent_ack', success: false, error: e.message });
95
+ }
96
+ }
97
+
98
+ function spawnWindowsUpgradeScript(pkgName, installDir, isGlobalInstall, latestVersion) {
99
+ const pid = process.pid;
100
+ const configDir = getConfigDir();
101
+ mkdirSync(configDir, { recursive: true });
102
+ const logDir = join(configDir, 'logs');
103
+ mkdirSync(logDir, { recursive: true });
104
+ const batPath = join(configDir, 'upgrade.bat');
105
+ const logPath = join(logDir, 'upgrade.log');
106
+ const isPm2 = !!process.env.pm_id;
107
+ const installDirWin = installDir.replace(/\//g, '\\');
108
+
109
+ // Copy upgrade-worker-template.js to config dir (runs as CJS there, away from ESM context)
110
+ const thisDir = dirname(fileURLToPath(import.meta.url));
111
+ const workerSrc = join(thisDir, 'upgrade-worker-template.js');
112
+ const workerDst = join(configDir, 'upgrade-worker.js');
113
+ cpSync(workerSrc, workerDst);
114
+
115
+ // Determine the target package directory inside node_modules
116
+ const pkgDir = join(installDir, 'node_modules', ...pkgName.split('/')).replace(/\//g, '\\');
117
+
118
+ const batLines = [
119
+ '@echo off',
120
+ 'setlocal',
121
+ `set PID=${pid}`,
122
+ `set PKG=${pkgName}@latest`,
123
+ `set INSTALL_DIR=${installDirWin}`,
124
+ `set PKG_DIR=${pkgDir}`,
125
+ `set LOGFILE=${logPath}`,
126
+ `set WORKER=${workerDst}`,
127
+ `set MAX_WAIT=30`,
128
+ `set COUNT=0`,
129
+ '',
130
+ ':: Change to temp dir to avoid EBUSY on cwd',
131
+ 'cd /d "%TEMP%"',
132
+ '',
133
+ ':: Redirect all output to log file',
134
+ 'echo [Upgrade] Started at %date% %time% > "%LOGFILE%"',
135
+ ];
136
+
137
+ batLines.push(
138
+ ':WAIT_LOOP',
139
+ 'tasklist /FI "PID eq %PID%" 2>NUL | find /I "%PID%" >NUL',
140
+ 'if errorlevel 1 goto PID_EXITED',
141
+ 'set /A COUNT+=1',
142
+ 'if %COUNT% GEQ %MAX_WAIT% (',
143
+ ' echo [Upgrade] Timeout waiting for PID %PID% to exit after 60s >> "%LOGFILE%"',
144
+ ' goto PID_EXITED',
145
+ ')',
146
+ 'ping -n 3 127.0.0.1 >NUL',
147
+ 'goto WAIT_LOOP',
148
+ ':PID_EXITED',
149
+ );
150
+
151
+ if (isPm2) {
152
+ batLines.push(
153
+ 'echo [Upgrade] Stopping pm2 to prevent autorestart... >> "%LOGFILE%"',
154
+ 'call pm2 stop yeaft-agent >> "%LOGFILE%" 2>&1',
155
+ ':: Wait for pm2 to fully terminate the process and release file locks',
156
+ 'ping -n 6 127.0.0.1 >NUL',
157
+ );
158
+ }
159
+
160
+ // Use Node.js worker for file-level upgrade (avoids EBUSY on directory rename)
161
+ batLines.push(
162
+ 'echo [Upgrade] Running upgrade worker... >> "%LOGFILE%"',
163
+ 'node "%WORKER%" "%PKG%" "%PKG_DIR%" "%LOGFILE%"',
164
+ 'if not "%errorlevel%"=="0" (',
165
+ ' echo [Upgrade] Worker failed with exit code %errorlevel% >> "%LOGFILE%"',
166
+ ' goto CLEANUP',
167
+ ')',
168
+ 'echo [Upgrade] Worker completed successfully >> "%LOGFILE%"',
169
+ );
170
+
171
+ batLines.push(':CLEANUP');
172
+
173
+ if (isPm2) {
174
+ batLines.push(
175
+ 'echo [Upgrade] Starting agent via pm2... >> "%LOGFILE%"',
176
+ 'call pm2 start yeaft-agent >> "%LOGFILE%" 2>&1',
177
+ );
178
+ }
179
+
180
+ // Clean up worker and bat script
181
+ batLines.push(
182
+ `del /F /Q "${workerDst}" 2>NUL`,
183
+ `del /F /Q "${batPath}"`,
184
+ );
185
+
186
+ writeFileSync(batPath, batLines.join('\r\n'));
187
+ const child = spawn('cmd.exe', ['/c', batPath], {
188
+ detached: true,
189
+ stdio: 'ignore',
190
+ windowsHide: true
191
+ });
192
+ child.unref();
193
+ console.log(`[Agent] Spawned upgrade script (PID wait for ${pid}, pm2=${isPm2}, dir=${installDir}): ${batPath}`);
194
+ sendToServer({ type: 'upgrade_agent_ack', success: true, version: latestVersion, pendingRestart: true });
195
+ }
196
+
197
+ function spawnUnixUpgradeScript(pkgName, installDir, isGlobalInstall, latestVersion) {
198
+ const pid = process.pid;
199
+ const configDir = getConfigDir();
200
+ mkdirSync(configDir, { recursive: true });
201
+ const shPath = join(configDir, 'upgrade.sh');
202
+ const isSystemd = existsSync(join(process.env.HOME || '', '.config', 'systemd', 'user', 'yeaft-agent.service'));
203
+ const isLaunchd = platform() === 'darwin' && existsSync(join(process.env.HOME || '', 'Library', 'LaunchAgents', 'com.yeaft.agent.plist'));
204
+ const cwd = isGlobalInstall ? undefined : installDir;
205
+
206
+ const shLines = [
207
+ '#!/bin/bash',
208
+ `PID=${pid}`,
209
+ `PKG="${pkgName}@latest"`,
210
+ `LOGFILE="${join(configDir, 'logs', 'upgrade.log')}"`,
211
+ `export PATH="${process.env.PATH}"`,
212
+ '',
213
+ '# Redirect all output to log file',
214
+ 'exec > "$LOGFILE" 2>&1',
215
+ 'echo "[Upgrade] Started at $(date)"',
216
+ '',
217
+ ...(cwd ? [`INSTALL_DIR="${cwd}"`] : []),
218
+ '',
219
+ '# Wait for current process to exit',
220
+ 'COUNT=0',
221
+ 'while kill -0 $PID 2>/dev/null; do',
222
+ ' COUNT=$((COUNT+1))',
223
+ ' if [ $COUNT -ge 30 ]; then',
224
+ ' echo "[Upgrade] Timeout waiting for PID $PID to exit"',
225
+ ' break',
226
+ ' fi',
227
+ ' sleep 2',
228
+ 'done',
229
+ '',
230
+ ];
231
+
232
+ // 停止服务管理器的自动重启
233
+ if (isSystemd) {
234
+ shLines.push(
235
+ '# Stop systemd service to prevent restart loop',
236
+ 'systemctl --user stop yeaft-agent 2>/dev/null',
237
+ 'sleep 1',
238
+ '',
239
+ );
240
+ } else if (isLaunchd) {
241
+ const plistPath = join(process.env.HOME || '', 'Library', 'LaunchAgents', 'com.yeaft.agent.plist');
242
+ shLines.push(
243
+ '# Unload launchd service to prevent restart loop',
244
+ `launchctl unload "${plistPath}" 2>/dev/null`,
245
+ 'sleep 1',
246
+ '',
247
+ );
248
+ }
249
+
250
+ // npm install
251
+ const npmCmd = isGlobalInstall
252
+ ? `npm install -g "$PKG"`
253
+ : `cd "$INSTALL_DIR" && npm install "$PKG"`;
254
+
255
+ shLines.push(
256
+ 'echo "[Upgrade] Installing $PKG..."',
257
+ npmCmd,
258
+ 'EXIT_CODE=$?',
259
+ 'if [ $EXIT_CODE -ne 0 ]; then',
260
+ ' echo "[Upgrade] npm install failed with exit code $EXIT_CODE"',
261
+ 'else',
262
+ ' echo "[Upgrade] Successfully installed $PKG"',
263
+ 'fi',
264
+ '',
265
+ );
266
+
267
+ // 重新启动服务
268
+ if (isSystemd) {
269
+ shLines.push(
270
+ '# Restart systemd service',
271
+ 'systemctl --user start yeaft-agent',
272
+ 'echo "[Upgrade] Service restarted via systemd"',
273
+ );
274
+ } else if (isLaunchd) {
275
+ const plistPath = join(process.env.HOME || '', 'Library', 'LaunchAgents', 'com.yeaft.agent.plist');
276
+ shLines.push(
277
+ '# Reload launchd service',
278
+ `launchctl load "${plistPath}"`,
279
+ 'echo "[Upgrade] Service restarted via launchd"',
280
+ );
281
+ }
282
+
283
+ // 清理脚本自身
284
+ shLines.push('', `rm -f "${shPath}"`);
285
+
286
+ writeFileSync(shPath, shLines.join('\n'), { mode: 0o755 });
287
+ const child = spawn('bash', [shPath], {
288
+ detached: true,
289
+ stdio: 'ignore',
290
+ });
291
+ child.unref();
292
+ console.log(`[Agent] Spawned upgrade script: ${shPath}`);
293
+ sendToServer({ type: 'upgrade_agent_ack', success: true, version: latestVersion, pendingRestart: true });
294
+ }
@@ -0,0 +1,364 @@
1
+ /**
2
+ * Crew — 控制操作
3
+ * pause, resume, stop, clear, abort, interrupt 等
4
+ */
5
+ import { join } from 'path';
6
+ import { promises as fs } from 'fs';
7
+ import { sendCrewMessage, sendCrewOutput, sendStatusUpdate, endRoleStreaming } from './ui-messages.js';
8
+ import { saveRoleSessionId, clearRoleSessionId, createRoleQuery } from './role-query.js';
9
+ import { saveSessionMeta, cleanupMessageShards } from './persistence.js';
10
+ import { executeRoute, dispatchToRole } from './routing.js';
11
+ import { cleanupWorktrees } from './worktree.js';
12
+ import { upsertCrewIndex } from './persistence.js';
13
+ import { processHumanQueue } from './human-interaction.js';
14
+
15
+ /**
16
+ * 处理控制命令
17
+ */
18
+ export async function handleCrewControl(msg) {
19
+ // Lazy import to avoid circular dependency
20
+ const { crewSessions } = await import('./session.js');
21
+
22
+ const { sessionId, action, targetRole } = msg;
23
+ const session = crewSessions.get(sessionId);
24
+ if (!session) {
25
+ console.warn(`[Crew] Session not found: ${sessionId}`);
26
+ return;
27
+ }
28
+
29
+ switch (action) {
30
+ case 'pause':
31
+ await pauseAll(session);
32
+ break;
33
+ case 'resume':
34
+ await resumeSession(session);
35
+ break;
36
+ case 'stop_role':
37
+ if (targetRole) await stopRole(session, targetRole);
38
+ break;
39
+ case 'interrupt_role':
40
+ if (targetRole && msg.content) {
41
+ await interruptRole(session, targetRole, msg.content, 'human');
42
+ }
43
+ break;
44
+ case 'abort_role':
45
+ if (targetRole) await abortRole(session, targetRole);
46
+ break;
47
+ case 'clear_role':
48
+ if (targetRole) await clearSingleRole(session, targetRole);
49
+ break;
50
+ case 'stop_all':
51
+ await stopAll(session);
52
+ break;
53
+ case 'clear':
54
+ await clearSession(session);
55
+ break;
56
+ default:
57
+ console.warn(`[Crew] Unknown control action: ${action}`);
58
+ }
59
+ }
60
+
61
+ /**
62
+ * 清空单个角色的对话
63
+ */
64
+ async function clearSingleRole(session, roleName) {
65
+ const roleState = session.roleStates.get(roleName);
66
+
67
+ if (roleState) {
68
+ if (roleState.abortController) {
69
+ roleState.abortController.abort();
70
+ }
71
+ roleState.query = null;
72
+ roleState.inputStream = null;
73
+ roleState.turnActive = false;
74
+ roleState.claudeSessionId = null;
75
+ roleState.consecutiveErrors = 0;
76
+ roleState.accumulatedText = '';
77
+ roleState.lastDispatchContent = null;
78
+ roleState.lastDispatchFrom = null;
79
+ roleState.lastDispatchTaskId = null;
80
+ roleState.lastDispatchTaskTitle = null;
81
+ }
82
+
83
+ await clearRoleSessionId(session.sharedDir, roleName);
84
+
85
+ sendCrewMessage({
86
+ type: 'crew_role_cleared',
87
+ sessionId: session.id,
88
+ role: roleName,
89
+ reason: 'manual'
90
+ });
91
+
92
+ sendCrewOutput(session, 'system', 'system', {
93
+ type: 'assistant',
94
+ message: { role: 'assistant', content: [{ type: 'text', text: `${roleName} 对话已清空` }] }
95
+ });
96
+ sendStatusUpdate(session);
97
+ console.log(`[Crew] Role ${roleName} cleared`);
98
+ }
99
+
100
+ /**
101
+ * 暂停所有角色
102
+ */
103
+ async function pauseAll(session) {
104
+ session.status = 'paused';
105
+
106
+ for (const [roleName, roleState] of session.roleStates) {
107
+ if (roleState.claudeSessionId) {
108
+ await saveRoleSessionId(session.sharedDir, roleName, roleState.claudeSessionId)
109
+ .catch(e => console.warn(`[Crew] Failed to save sessionId for ${roleName}:`, e.message));
110
+ }
111
+ if (roleState.abortController) {
112
+ roleState.abortController.abort();
113
+ }
114
+ roleState.wasActive = roleState.turnActive;
115
+ roleState.turnActive = false;
116
+ roleState.query = null;
117
+ roleState.inputStream = null;
118
+ }
119
+
120
+ console.log(`[Crew] Session ${session.id} paused, all active queries aborted`);
121
+
122
+ sendCrewOutput(session, 'system', 'system', {
123
+ type: 'assistant',
124
+ message: { role: 'assistant', content: [{ type: 'text', text: 'Session 已暂停' }] }
125
+ });
126
+ sendStatusUpdate(session);
127
+
128
+ await saveSessionMeta(session);
129
+ }
130
+
131
+ /**
132
+ * 恢复 session
133
+ */
134
+ async function resumeSession(session) {
135
+ if (session.status !== 'paused') return;
136
+
137
+ session.status = 'running';
138
+ console.log(`[Crew] Session ${session.id} resumed`);
139
+
140
+ sendCrewOutput(session, 'system', 'system', {
141
+ type: 'assistant',
142
+ message: { role: 'assistant', content: [{ type: 'text', text: 'Session 已恢复' }] }
143
+ });
144
+ sendStatusUpdate(session);
145
+
146
+ if (session.pendingRoutes.length > 0) {
147
+ const pending = session.pendingRoutes.slice();
148
+ session.pendingRoutes = [];
149
+ console.log(`[Crew] Replaying ${pending.length} pending route(s)`);
150
+ const results = await Promise.allSettled(pending.map(({ fromRole, route }) =>
151
+ executeRoute(session, fromRole, route)
152
+ ));
153
+ for (const r of results) {
154
+ if (r.status === 'rejected') {
155
+ console.warn(`[Crew] Pending route replay failed:`, r.reason);
156
+ }
157
+ }
158
+ return;
159
+ }
160
+
161
+ await processHumanQueue(session);
162
+ }
163
+
164
+ /**
165
+ * 中断角色当前 turn 并发送新消息
166
+ */
167
+ async function interruptRole(session, roleName, newContent, fromSource = 'human') {
168
+ const roleState = session.roleStates.get(roleName);
169
+ if (!roleState) {
170
+ console.warn(`[Crew] Cannot interrupt ${roleName}: no roleState`);
171
+ return;
172
+ }
173
+
174
+ console.log(`[Crew] Interrupting ${roleName}`);
175
+
176
+ endRoleStreaming(session, roleName);
177
+
178
+ if (roleState.claudeSessionId) {
179
+ await saveRoleSessionId(session.sharedDir, roleName, roleState.claudeSessionId)
180
+ .catch(e => console.warn(`[Crew] Failed to save sessionId for ${roleName}:`, e.message));
181
+ }
182
+
183
+ if (roleState.abortController) {
184
+ roleState.abortController.abort();
185
+ }
186
+
187
+ roleState.query = null;
188
+ roleState.inputStream = null;
189
+ roleState.turnActive = false;
190
+ roleState.accumulatedText = '';
191
+
192
+ sendCrewMessage({
193
+ type: 'crew_turn_completed',
194
+ sessionId: session.id,
195
+ role: roleName,
196
+ interrupted: true
197
+ });
198
+
199
+ sendStatusUpdate(session);
200
+
201
+ sendCrewOutput(session, 'system', 'system', {
202
+ type: 'assistant',
203
+ message: { role: 'assistant', content: [{ type: 'text', text: `${roleName} 被中断` }] }
204
+ });
205
+
206
+ await dispatchToRole(session, roleName, newContent, fromSource);
207
+ }
208
+
209
+ /**
210
+ * 中止角色当前 turn
211
+ */
212
+ async function abortRole(session, roleName) {
213
+ const roleState = session.roleStates.get(roleName);
214
+ if (!roleState) {
215
+ console.warn(`[Crew] Cannot abort ${roleName}: no roleState`);
216
+ return;
217
+ }
218
+
219
+ if (!roleState.turnActive) {
220
+ console.log(`[Crew] ${roleName} is not active, nothing to abort`);
221
+ return;
222
+ }
223
+
224
+ console.log(`[Crew] Aborting ${roleName}`);
225
+
226
+ endRoleStreaming(session, roleName);
227
+
228
+ if (roleState.claudeSessionId) {
229
+ await saveRoleSessionId(session.sharedDir, roleName, roleState.claudeSessionId)
230
+ .catch(e => console.warn(`[Crew] Failed to save sessionId for ${roleName}:`, e.message));
231
+ }
232
+
233
+ if (roleState.abortController) {
234
+ roleState.abortController.abort();
235
+ }
236
+
237
+ roleState.query = null;
238
+ roleState.inputStream = null;
239
+ roleState.turnActive = false;
240
+ roleState.accumulatedText = '';
241
+
242
+ sendCrewMessage({
243
+ type: 'crew_turn_completed',
244
+ sessionId: session.id,
245
+ role: roleName,
246
+ interrupted: true
247
+ });
248
+
249
+ sendStatusUpdate(session);
250
+
251
+ sendCrewOutput(session, 'system', 'system', {
252
+ type: 'assistant',
253
+ message: { role: 'assistant', content: [{ type: 'text', text: `${roleName} 已中止` }] }
254
+ });
255
+ }
256
+
257
+ async function stopRole(session, roleName) {
258
+ const roleState = session.roleStates.get(roleName);
259
+ if (roleState) {
260
+ if (roleState.claudeSessionId) {
261
+ await saveRoleSessionId(session.sharedDir, roleName, roleState.claudeSessionId)
262
+ .catch(e => console.warn(`[Crew] Failed to save sessionId for ${roleName}:`, e.message));
263
+ }
264
+ if (roleState.abortController) {
265
+ roleState.abortController.abort();
266
+ }
267
+ roleState.query = null;
268
+ roleState.inputStream = null;
269
+ roleState.turnActive = false;
270
+ session.roleStates.delete(roleName);
271
+ }
272
+
273
+ sendCrewOutput(session, 'system', 'system', {
274
+ type: 'assistant',
275
+ message: { role: 'assistant', content: [{ type: 'text', text: `${roleName} 已停止` }] }
276
+ });
277
+ sendStatusUpdate(session);
278
+ console.log(`[Crew] Role ${roleName} stopped`);
279
+ }
280
+
281
+ /**
282
+ * 终止整个 session
283
+ */
284
+ async function stopAll(session) {
285
+ const { crewSessions } = await import('./session.js');
286
+
287
+ session.status = 'stopped';
288
+
289
+ for (const [roleName, roleState] of session.roleStates) {
290
+ if (roleState.claudeSessionId) {
291
+ await saveRoleSessionId(session.sharedDir, roleName, roleState.claudeSessionId)
292
+ .catch(e => console.warn(`[Crew] Failed to save sessionId for ${roleName}:`, e.message));
293
+ }
294
+ if (roleState.abortController) {
295
+ roleState.abortController.abort();
296
+ }
297
+ console.log(`[Crew] Stopping role: ${roleName}`);
298
+ }
299
+ session.roleStates.clear();
300
+
301
+ sendCrewOutput(session, 'system', 'system', {
302
+ type: 'assistant',
303
+ message: { role: 'assistant', content: [{ type: 'text', text: 'Session 已终止' }] }
304
+ });
305
+ sendStatusUpdate(session);
306
+
307
+ await cleanupWorktrees(session.projectDir);
308
+
309
+ await saveSessionMeta(session);
310
+ await upsertCrewIndex(session);
311
+
312
+ crewSessions.delete(session.id);
313
+ console.log(`[Crew] Session ${session.id} stopped`);
314
+ }
315
+
316
+ /**
317
+ * 清空 session
318
+ */
319
+ async function clearSession(session) {
320
+ for (const [roleName, roleState] of session.roleStates) {
321
+ if (roleState.abortController) {
322
+ roleState.abortController.abort();
323
+ }
324
+ console.log(`[Crew] Clearing role: ${roleName}`);
325
+ }
326
+ session.roleStates.clear();
327
+
328
+ for (const [roleName] of session.roles) {
329
+ await clearRoleSessionId(session.sharedDir, roleName);
330
+ }
331
+
332
+ session.messageHistory = [];
333
+ session.uiMessages = [];
334
+ session.humanMessageQueue = [];
335
+ session.waitingHumanContext = null;
336
+ session.pendingRoutes = [];
337
+
338
+ // 清除 feature/task 数据,避免 UI 残留空 task 卡片
339
+ session.features.clear();
340
+ session._completedTaskIds = new Set();
341
+
342
+ session.round = 0;
343
+
344
+ const messagesPath = join(session.sharedDir, 'messages.json');
345
+ await fs.writeFile(messagesPath, '[]').catch(() => {});
346
+ await cleanupMessageShards(session.sharedDir);
347
+
348
+ session.status = 'running';
349
+
350
+ sendCrewMessage({
351
+ type: 'crew_session_cleared',
352
+ sessionId: session.id
353
+ });
354
+
355
+ sendCrewOutput(session, 'system', 'system', {
356
+ type: 'assistant',
357
+ message: { role: 'assistant', content: [{ type: 'text', text: '会话已清空,所有角色将使用全新对话' }] }
358
+ });
359
+ sendStatusUpdate(session);
360
+
361
+ await saveSessionMeta(session);
362
+
363
+ console.log(`[Crew] Session ${session.id} cleared`);
364
+ }