cli-link 0.0.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 (34) hide show
  1. package/README.md +271 -0
  2. package/bin/agentpilot.js +239 -0
  3. package/dist/client/assets/History-DR_K6WbO.js +3 -0
  4. package/dist/client/assets/MarkdownRenderer-D9IwexPM.js +1 -0
  5. package/dist/client/assets/PageTopBar-SnTIrSb5.js +1 -0
  6. package/dist/client/assets/Session-EFYFIC_X.js +11 -0
  7. package/dist/client/assets/Settings-DgmHC_Hw.js +1 -0
  8. package/dist/client/assets/Workspace-CJvVQVzU.js +8 -0
  9. package/dist/client/assets/WorkspaceLinkedText-D6hNg0T9.js +2 -0
  10. package/dist/client/assets/code-highlight-CEcsuMpw.js +1 -0
  11. package/dist/client/assets/index-Bk4_acsd.css +1 -0
  12. package/dist/client/assets/index-C89UCwGk.js +2 -0
  13. package/dist/client/assets/vendor-icons-S_ObYVVf.js +331 -0
  14. package/dist/client/assets/vendor-markdown-BDwu-Ux6.js +35 -0
  15. package/dist/client/assets/vendor-motion-n6Lx6G4a.js +9 -0
  16. package/dist/client/assets/vendor-react-DSV5aFEg.js +67 -0
  17. package/dist/client/assets/vendor-virtual-CcftJrIC.js +4 -0
  18. package/dist/client/favicon.svg +18 -0
  19. package/dist/client/icons/apple-touch-icon.png +0 -0
  20. package/dist/client/icons/icon-192.png +0 -0
  21. package/dist/client/icons/icon-512.png +0 -0
  22. package/dist/client/index.html +34 -0
  23. package/dist/client/manifest.webmanifest +59 -0
  24. package/dist/client/sw.js +143 -0
  25. package/dist/client//344/273/243/347/240/201/351/241/265/351/235/242.png +0 -0
  26. package/dist/client//345/216/206/345/217/262/350/256/260/345/275/225.png +0 -0
  27. package/dist/client//345/257/271/350/257/235/351/241/265/351/235/242.png +0 -0
  28. package/dist/client//350/256/276/347/275/256/351/241/265/351/235/242.png +0 -0
  29. package/dist/server/cli-manager.js +1532 -0
  30. package/dist/server/codex-history.js +280 -0
  31. package/dist/server/index.js +2097 -0
  32. package/dist/server/store.js +594 -0
  33. package/dist/server/terminal-qr.js +317 -0
  34. package/package.json +71 -0
@@ -0,0 +1,2097 @@
1
+ import { createServer as createHttpServer } from 'http';
2
+ import { createServer as createHttpsServer } from 'https';
3
+ import { WebSocketServer, WebSocket } from 'ws';
4
+ import { CLIManager } from './cli-manager.js';
5
+ import { renderTerminalQr } from './terminal-qr.js';
6
+ import * as store from './store.js';
7
+ import * as fs from 'fs';
8
+ import * as path from 'path';
9
+ import * as os from 'os';
10
+ import { timingSafeEqual } from 'crypto';
11
+ import { execFileSync } from 'child_process';
12
+ import { fileURLToPath } from 'url';
13
+ const PORT = parseInt(process.env.PORT || '3101', 10);
14
+ const HOST = process.env.HOST || '0.0.0.0';
15
+ const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
16
+ const STATIC_DIR = resolveStaticDir();
17
+ const IS_NPX_ENTRY = process.env.AGENTPILOT_NPX === '1';
18
+ const AUTH_COOKIE_NAME = 'agentpilot_token';
19
+ const AUTH_TOKEN = (process.env.AGENTPILOT_AUTH_TOKEN || '').trim();
20
+ const AUTH_ENABLED = AUTH_TOKEN.length > 0;
21
+ function resolveEnvPath(value) {
22
+ if (value === '~')
23
+ return os.homedir();
24
+ if (value.startsWith('~/'))
25
+ return path.join(os.homedir(), value.slice(2));
26
+ return path.resolve(value);
27
+ }
28
+ function loadHttpsOptions() {
29
+ const keyPath = process.env.AGENTPILOT_HTTPS_KEY || process.env.HTTPS_KEY;
30
+ const certPath = process.env.AGENTPILOT_HTTPS_CERT || process.env.HTTPS_CERT;
31
+ if (!keyPath && !certPath)
32
+ return null;
33
+ if (!keyPath || !certPath) {
34
+ throw new Error('Both AGENTPILOT_HTTPS_KEY and AGENTPILOT_HTTPS_CERT are required to enable HTTPS.');
35
+ }
36
+ return {
37
+ key: fs.readFileSync(resolveEnvPath(keyPath)),
38
+ cert: fs.readFileSync(resolveEnvPath(certPath)),
39
+ };
40
+ }
41
+ function resolveStaticDir() {
42
+ const configured = process.env.AGENTPILOT_STATIC_DIR;
43
+ const candidates = [
44
+ configured ? resolveEnvPath(configured) : '',
45
+ path.resolve(MODULE_DIR, '..', 'client'),
46
+ path.resolve(MODULE_DIR, '..', 'dist', 'client'),
47
+ path.resolve(process.cwd(), 'dist', 'client'),
48
+ ].filter(Boolean);
49
+ for (const candidate of candidates) {
50
+ try {
51
+ const stat = fs.statSync(path.join(candidate, 'index.html'));
52
+ if (stat.isFile())
53
+ return candidate;
54
+ }
55
+ catch { }
56
+ }
57
+ if (configured) {
58
+ console.warn(`[AgentPilot] Static client not found at ${resolveEnvPath(configured)}`);
59
+ }
60
+ return null;
61
+ }
62
+ function readStartupConfig() {
63
+ const config = {};
64
+ const workDir = process.env.AGENTPILOT_WORKDIR;
65
+ const cliType = process.env.AGENTPILOT_CLI_TYPE;
66
+ const cliCommand = process.env.AGENTPILOT_CLI_COMMAND;
67
+ if (workDir)
68
+ config.workDir = resolveEnvPath(workDir);
69
+ if (cliType === 'codex' || cliType === 'claude')
70
+ config.cliType = cliType;
71
+ if (cliCommand)
72
+ config.cliCommand = cliCommand;
73
+ return config;
74
+ }
75
+ // Initialize database
76
+ store.initDb();
77
+ const httpsOptions = loadHttpsOptions();
78
+ const server = httpsOptions ? createHttpsServer(httpsOptions, requestListener) : createHttpServer(requestListener);
79
+ const httpScheme = httpsOptions ? 'https' : 'http';
80
+ const wsScheme = httpsOptions ? 'wss' : 'ws';
81
+ function requestListener(req, res) {
82
+ handleHttpRequest(req, res).catch((err) => {
83
+ console.error('[HTTP] request failed:', err);
84
+ sendJson(res, 500, { error: err?.message || '服务端错误' });
85
+ });
86
+ }
87
+ const wss = new WebSocketServer({ noServer: true });
88
+ server.on('upgrade', (req, socket, head) => {
89
+ const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
90
+ if (!isRequestAuthorized(req, url)) {
91
+ socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\nContent-Length: 0\r\n\r\n');
92
+ socket.destroy();
93
+ return;
94
+ }
95
+ wss.handleUpgrade(req, socket, head, (ws) => {
96
+ wss.emit('connection', ws, req);
97
+ });
98
+ });
99
+ const cliManager = new CLIManager();
100
+ const clients = new Set();
101
+ function unquoteMetaValue(value) {
102
+ const trimmed = value.trim();
103
+ if ((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
104
+ (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
105
+ return trimmed.slice(1, -1);
106
+ }
107
+ return trimmed;
108
+ }
109
+ function readSkillMeta(filePath) {
110
+ try {
111
+ const content = fs.readFileSync(filePath, 'utf8');
112
+ const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
113
+ if (!match)
114
+ return {};
115
+ const meta = {};
116
+ for (const line of match[1].split('\n')) {
117
+ const idx = line.indexOf(':');
118
+ if (idx <= 0)
119
+ continue;
120
+ const key = line.slice(0, idx).trim();
121
+ const value = unquoteMetaValue(line.slice(idx + 1));
122
+ if (key === 'name')
123
+ meta.name = value;
124
+ if (key === 'description')
125
+ meta.description = value;
126
+ }
127
+ return meta;
128
+ }
129
+ catch {
130
+ return {};
131
+ }
132
+ }
133
+ function collectSkillFiles(root, maxDepth) {
134
+ const result = [];
135
+ const resolved = root.startsWith('~') ? root.replace('~', os.homedir()) : root;
136
+ function walk(dir, depth) {
137
+ if (depth > maxDepth)
138
+ return;
139
+ let entries;
140
+ try {
141
+ entries = fs.readdirSync(dir, { withFileTypes: true });
142
+ }
143
+ catch {
144
+ return;
145
+ }
146
+ for (const entry of entries) {
147
+ const entryPath = path.join(dir, entry.name);
148
+ if (entry.isFile() && entry.name === 'SKILL.md') {
149
+ result.push(entryPath);
150
+ }
151
+ else if (entry.isDirectory()) {
152
+ walk(entryPath, depth + 1);
153
+ }
154
+ }
155
+ }
156
+ walk(resolved, 0);
157
+ return result;
158
+ }
159
+ function getSkillSource(filePath) {
160
+ const home = os.homedir();
161
+ if (filePath.startsWith(path.join(home, '.agents', 'skills')))
162
+ return 'Agents';
163
+ if (filePath.startsWith(path.join(home, '.codex', 'skills')))
164
+ return 'Codex';
165
+ if (filePath.startsWith(path.join(home, '.claude', 'skills')))
166
+ return 'Claude';
167
+ if (filePath.includes(`${path.sep}.codex${path.sep}plugins${path.sep}cache${path.sep}`))
168
+ return 'Plugin';
169
+ return 'Workspace';
170
+ }
171
+ function discoverSkills(workDir) {
172
+ const home = os.homedir();
173
+ const resolvedWorkDir = workDir?.startsWith('~') ? workDir.replace('~', home) : workDir;
174
+ const roots = [
175
+ { dir: path.join(home, '.agents', 'skills'), depth: 3 },
176
+ { dir: path.join(home, '.codex', 'skills'), depth: 4 },
177
+ { dir: path.join(home, '.claude', 'skills'), depth: 4 },
178
+ { dir: path.join(home, '.codex', 'plugins', 'cache'), depth: 8 },
179
+ ...(resolvedWorkDir ? [
180
+ { dir: path.join(resolvedWorkDir, '.agents', 'skills'), depth: 4 },
181
+ { dir: path.join(resolvedWorkDir, '.codex', 'skills'), depth: 4 },
182
+ { dir: path.join(resolvedWorkDir, '.claude', 'skills'), depth: 4 },
183
+ ] : []),
184
+ ];
185
+ const byName = new Map();
186
+ for (const root of roots) {
187
+ for (const skillPath of collectSkillFiles(root.dir, root.depth)) {
188
+ const meta = readSkillMeta(skillPath);
189
+ const fallbackName = path.basename(path.dirname(skillPath));
190
+ const name = (meta.name || fallbackName).trim();
191
+ if (!name || byName.has(name))
192
+ continue;
193
+ byName.set(name, {
194
+ name,
195
+ command: `/${name}`,
196
+ description: meta.description || 'Skill',
197
+ path: skillPath,
198
+ source: getSkillSource(skillPath),
199
+ });
200
+ }
201
+ }
202
+ return Array.from(byName.values()).sort((a, b) => a.name.localeCompare(b.name));
203
+ }
204
+ // Map server event types to client-side MessageType values.
205
+ // Returns null for non-displayable types (status, confirm_mode_changed).
206
+ function mapMessageType(eventType) {
207
+ const mapping = {
208
+ 'user_message': 'user',
209
+ 'ai_message': 'ai',
210
+ 'thinking_message': 'thinking',
211
+ 'tool_call': 'tool',
212
+ 'confirm_request': 'confirm',
213
+ 'confirm_result': 'confirm',
214
+ 'ask_question': 'question',
215
+ 'ask_question_result': 'question',
216
+ 'system': 'system',
217
+ 'error': 'error',
218
+ 'status': null,
219
+ 'confirm_mode_changed': null,
220
+ };
221
+ if (eventType in mapping)
222
+ return mapping[eventType];
223
+ return eventType;
224
+ }
225
+ function parseJsonField(raw) {
226
+ if (!raw)
227
+ return undefined;
228
+ try {
229
+ return JSON.parse(raw);
230
+ }
231
+ catch {
232
+ return undefined;
233
+ }
234
+ }
235
+ function getSessionConfig(session) {
236
+ const parsed = parseJsonField(session?.cli_config);
237
+ return parsed && typeof parsed === 'object' ? parsed : {};
238
+ }
239
+ function getCliSessionIdFromMessages(messages) {
240
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
241
+ const details = parseJsonField(messages[i].details);
242
+ const sessionId = details?.session_id;
243
+ if (typeof sessionId === 'string' && sessionId.length > 8) {
244
+ return sessionId;
245
+ }
246
+ }
247
+ return undefined;
248
+ }
249
+ function getRestorableCliSessionId(sessionId, messages) {
250
+ if (!sessionId)
251
+ return undefined;
252
+ const session = store.getSessionById(sessionId);
253
+ const config = getSessionConfig(session);
254
+ if (typeof config.cliSessionId === 'string' && config.cliSessionId.length > 8) {
255
+ return config.cliSessionId;
256
+ }
257
+ if (messages) {
258
+ return getCliSessionIdFromMessages(messages);
259
+ }
260
+ return store.getLatestCliSessionIdFromMessages(sessionId);
261
+ }
262
+ function toClientMessage(m) {
263
+ const mappedType = mapMessageType(m.type);
264
+ if (mappedType === null)
265
+ return null;
266
+ const parsedDetails = parseJsonField(m.details);
267
+ const isQuestion = mappedType === 'question' && m.type === 'ask_question';
268
+ return {
269
+ seq: m.seq,
270
+ id: m.id,
271
+ type: mappedType,
272
+ content: m.content,
273
+ time: m.time,
274
+ status: m.status || undefined,
275
+ toolName: m.toolName || undefined,
276
+ toolDetails: m.toolDetails || undefined,
277
+ toolUseId: m.toolUseId || undefined,
278
+ toolResult: m.toolResult || undefined,
279
+ permission: parseJsonField(m.permission),
280
+ details: parsedDetails,
281
+ question: isQuestion && parsedDetails ? { questions: parsedDetails.questions || [], toolUseId: parsedDetails.toolUseId } : undefined,
282
+ };
283
+ }
284
+ function toClientMessages(messages) {
285
+ return messages.map(toClientMessage).filter((m) => m !== null);
286
+ }
287
+ function readPositiveInt(value, fallback = 0) {
288
+ const parsed = Number(value || 0);
289
+ if (!Number.isFinite(parsed) || parsed <= 0)
290
+ return fallback;
291
+ return Math.floor(parsed);
292
+ }
293
+ function calculateSessionTokenUsage(sessionId) {
294
+ return store.getSessionDetails(sessionId).reduce((usage, rawDetails) => {
295
+ const details = parseJsonField(rawDetails);
296
+ if (!details || details.subtype !== 'result')
297
+ return usage;
298
+ return {
299
+ inputTokens: usage.inputTokens + Number(details.inputTokens || 0),
300
+ outputTokens: usage.outputTokens + Number(details.outputTokens || 0),
301
+ cacheReadTokens: usage.cacheReadTokens + Number(details.cacheReadTokens || 0),
302
+ cacheCreationTokens: usage.cacheCreationTokens + Number(details.cacheCreationTokens || 0),
303
+ costUsd: usage.costUsd + Number(details.costUsd || 0),
304
+ };
305
+ }, { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0, costUsd: 0 });
306
+ }
307
+ function buildSessionData(sessionId, options = {}) {
308
+ const afterSeq = readPositiveInt(options.afterSeq);
309
+ const beforeSeq = readPositiveInt(options.beforeSeq);
310
+ const limit = readPositiveInt(options.limit);
311
+ let messages;
312
+ if (afterSeq > 0) {
313
+ messages = store.getSessionMessages(sessionId, afterSeq);
314
+ }
315
+ else if (beforeSeq > 0 && limit > 0) {
316
+ messages = store.getSessionMessagesBefore(sessionId, beforeSeq, limit);
317
+ }
318
+ else if (limit > 0) {
319
+ messages = store.getRecentSessionMessages(sessionId, limit);
320
+ }
321
+ else {
322
+ messages = store.getSessionMessages(sessionId);
323
+ }
324
+ const firstSeq = messages[0]?.seq || 0;
325
+ return {
326
+ sessionId,
327
+ messages: toClientMessages(messages),
328
+ lastSeq: store.getLastSeq(sessionId),
329
+ firstSeq,
330
+ hasMoreBefore: firstSeq > 0 ? store.hasSessionMessagesBefore(sessionId, firstSeq) : false,
331
+ tokenUsage: calculateSessionTokenUsage(sessionId),
332
+ };
333
+ }
334
+ function buildEditPrompt(prefixMessages, editedContent) {
335
+ const entries = [];
336
+ const pushEntry = (label, content) => {
337
+ const last = entries[entries.length - 1];
338
+ if (last && last.label === label && label === '助手') {
339
+ last.content += content;
340
+ return;
341
+ }
342
+ entries.push({ label, content });
343
+ };
344
+ for (const message of prefixMessages) {
345
+ const content = (message.content || '').trim();
346
+ if (!content)
347
+ continue;
348
+ if (message.type === 'user_message' || message.type === 'user') {
349
+ pushEntry('用户', content);
350
+ }
351
+ else if (message.type === 'ai_message' || message.type === 'ai') {
352
+ pushEntry('助手', content);
353
+ }
354
+ else if (message.type === 'tool_call' || message.type === 'tool') {
355
+ const parts = [content];
356
+ if (message.toolDetails)
357
+ parts.push(`输入: ${message.toolDetails}`);
358
+ if (message.toolResult)
359
+ parts.push(`输出: ${message.toolResult}`);
360
+ pushEntry('工具调用', parts.join('\n'));
361
+ }
362
+ else if (message.type === 'ask_question' || message.type === 'ask_question_result') {
363
+ pushEntry('澄清问题', content);
364
+ }
365
+ else if (message.type === 'confirm_request' || message.type === 'confirm_result') {
366
+ pushEntry('确认记录', content);
367
+ }
368
+ }
369
+ if (entries.length === 0)
370
+ return editedContent;
371
+ return [
372
+ '你正在一个新会话中接续一段用户已编辑的对话。',
373
+ '下面是编辑点之前保留的对话记录,仅作为上下文;不要复述这段记录,直接回应最后的用户消息。',
374
+ '',
375
+ '<conversation_history>',
376
+ entries.map(entry => `${entry.label}: ${entry.content}`).join('\n\n'),
377
+ '</conversation_history>',
378
+ '',
379
+ '<edited_user_message>',
380
+ editedContent,
381
+ '</edited_user_message>',
382
+ ].join('\n');
383
+ }
384
+ function toClientHistoryTask(t, includeMessages = false) {
385
+ const cliSessionId = getRestorableCliSessionId(t.session_id, t.messages);
386
+ const response = {
387
+ id: t.id,
388
+ workDir: t.work_dir || undefined,
389
+ status: t.status,
390
+ title: t.title,
391
+ confirmCount: t.confirm_count,
392
+ toolCount: t.tool_count,
393
+ duration: t.duration || undefined,
394
+ startTime: t.start_time,
395
+ endTime: t.end_time,
396
+ canResume: !!cliSessionId,
397
+ };
398
+ if (includeMessages) {
399
+ response.messages = toClientMessages(t.messages || []);
400
+ }
401
+ return response;
402
+ }
403
+ function toClientWorkDir(item) {
404
+ return {
405
+ path: item.path,
406
+ name: item.name,
407
+ lastUsedAt: item.last_used_at,
408
+ createdAt: item.created_at,
409
+ };
410
+ }
411
+ function getCurrentWorkDir() {
412
+ return cliManager.getConfig().workDir;
413
+ }
414
+ function recordCurrentWorkDir() {
415
+ return store.recordWorkDir(getCurrentWorkDir());
416
+ }
417
+ const WORKSPACE_SKIP_NAMES = new Set([
418
+ '.git',
419
+ '.DS_Store',
420
+ '.cache',
421
+ '.pnpm-store',
422
+ '.venv',
423
+ 'node_modules',
424
+ 'dist',
425
+ 'build',
426
+ 'coverage',
427
+ '.next',
428
+ '.turbo',
429
+ '.vite',
430
+ ]);
431
+ const MAX_FILE_BYTES = 512 * 1024;
432
+ const MAX_DIFF_BYTES = 1024 * 1024;
433
+ const MAX_COMMIT_CONTEXT_BYTES = 160 * 1024;
434
+ const PREVIEW_IMAGE_MIME_BY_EXTENSION = {
435
+ '.avif': 'image/avif',
436
+ '.bmp': 'image/bmp',
437
+ '.gif': 'image/gif',
438
+ '.ico': 'image/x-icon',
439
+ '.jpeg': 'image/jpeg',
440
+ '.jpg': 'image/jpeg',
441
+ '.png': 'image/png',
442
+ '.svg': 'image/svg+xml',
443
+ '.webp': 'image/webp',
444
+ };
445
+ function decodeGitOctalPath(raw) {
446
+ return raw.replace(/(?:\\[0-7]{3})+/g, (sequence) => {
447
+ const bytes = sequence.match(/\\([0-7]{3})/g)?.map(item => parseInt(item.slice(1), 8)) || [];
448
+ return Buffer.from(bytes).toString('utf8');
449
+ });
450
+ }
451
+ function normalizeWorkspacePath(raw) {
452
+ if (typeof raw !== 'string')
453
+ return '';
454
+ return decodeGitOctalPath(raw).replace(/\\/g, '/').replace(/^\/+/, '').split('/').filter(Boolean).join('/');
455
+ }
456
+ function toClientWorkspacePath(root, absolutePath) {
457
+ const rel = path.relative(root, absolutePath).split(path.sep).join('/');
458
+ return rel === '' ? '' : rel;
459
+ }
460
+ function resolveWorkspacePath(rawPath, mustExist = true) {
461
+ const root = path.resolve(getCurrentWorkDir());
462
+ const realRoot = fs.realpathSync(root);
463
+ const relativePath = normalizeWorkspacePath(rawPath);
464
+ const target = path.resolve(root, relativePath);
465
+ if (target !== root && !target.startsWith(`${root}${path.sep}`)) {
466
+ throw new Error('路径不在当前工作目录内');
467
+ }
468
+ if (mustExist) {
469
+ const realTarget = fs.realpathSync(target);
470
+ if (realTarget !== realRoot && !realTarget.startsWith(`${realRoot}${path.sep}`)) {
471
+ throw new Error('路径不在当前工作目录内');
472
+ }
473
+ }
474
+ return { root, target, relativePath };
475
+ }
476
+ function isLikelyBinary(buffer) {
477
+ const length = Math.min(buffer.length, 4096);
478
+ for (let i = 0; i < length; i += 1) {
479
+ if (buffer[i] === 0)
480
+ return true;
481
+ }
482
+ return false;
483
+ }
484
+ function getPreviewImageMime(filename) {
485
+ return PREVIEW_IMAGE_MIME_BY_EXTENSION[path.extname(filename).toLowerCase()];
486
+ }
487
+ function listWorkspaceDir(rawPath) {
488
+ const { root, target } = resolveWorkspacePath(rawPath);
489
+ const realRoot = fs.realpathSync(root);
490
+ const stat = fs.statSync(target);
491
+ if (!stat.isDirectory()) {
492
+ throw new Error('目标不是目录');
493
+ }
494
+ const entries = fs.readdirSync(target, { withFileTypes: true });
495
+ const dirs = [];
496
+ const files = [];
497
+ for (const entry of entries) {
498
+ if (WORKSPACE_SKIP_NAMES.has(entry.name))
499
+ continue;
500
+ const entryPath = path.join(target, entry.name);
501
+ try {
502
+ const entryStat = fs.statSync(entryPath);
503
+ const realEntry = fs.realpathSync(entryPath);
504
+ if (realEntry !== realRoot && !realEntry.startsWith(`${realRoot}${path.sep}`))
505
+ continue;
506
+ if (entryStat.isDirectory()) {
507
+ dirs.push({
508
+ name: entry.name,
509
+ path: toClientWorkspacePath(root, entryPath),
510
+ mtime: entryStat.mtimeMs,
511
+ });
512
+ }
513
+ else if (entryStat.isFile()) {
514
+ files.push({
515
+ name: entry.name,
516
+ path: toClientWorkspacePath(root, entryPath),
517
+ ext: path.extname(entry.name).toLowerCase(),
518
+ size: entryStat.size,
519
+ mtime: entryStat.mtimeMs,
520
+ });
521
+ }
522
+ }
523
+ catch {
524
+ // Skip unreadable or invalid symlink entries.
525
+ }
526
+ }
527
+ dirs.sort((a, b) => a.name.localeCompare(b.name));
528
+ files.sort((a, b) => a.name.localeCompare(b.name));
529
+ return {
530
+ workDir: root,
531
+ path: toClientWorkspacePath(root, target),
532
+ parent: target === root ? '' : toClientWorkspacePath(root, path.dirname(target)),
533
+ isRoot: target === root,
534
+ dirs,
535
+ files,
536
+ };
537
+ }
538
+ function readWorkspaceFile(rawPath) {
539
+ const { root, target } = resolveWorkspacePath(rawPath);
540
+ const stat = fs.statSync(target);
541
+ if (!stat.isFile()) {
542
+ throw new Error('目标不是文件');
543
+ }
544
+ if (stat.size > MAX_FILE_BYTES) {
545
+ const mimeType = getPreviewImageMime(target);
546
+ return {
547
+ workDir: root,
548
+ path: toClientWorkspacePath(root, target),
549
+ name: path.basename(target),
550
+ size: stat.size,
551
+ mtime: stat.mtimeMs,
552
+ content: '',
553
+ tooLarge: true,
554
+ isBinary: false,
555
+ mimeType,
556
+ isImage: Boolean(mimeType),
557
+ };
558
+ }
559
+ const buffer = fs.readFileSync(target);
560
+ const mimeType = getPreviewImageMime(target);
561
+ const isImage = Boolean(mimeType);
562
+ const isBinary = !isImage && isLikelyBinary(buffer);
563
+ return {
564
+ workDir: root,
565
+ path: toClientWorkspacePath(root, target),
566
+ name: path.basename(target),
567
+ size: stat.size,
568
+ mtime: stat.mtimeMs,
569
+ content: isBinary || isImage ? '' : buffer.toString('utf8'),
570
+ tooLarge: false,
571
+ isBinary,
572
+ mimeType,
573
+ isImage,
574
+ dataUrl: isImage ? `data:${mimeType};base64,${buffer.toString('base64')}` : undefined,
575
+ };
576
+ }
577
+ function runGit(args, allowExitCodes = [0]) {
578
+ try {
579
+ return execFileSync('git', args, {
580
+ cwd: getCurrentWorkDir(),
581
+ encoding: 'utf8',
582
+ maxBuffer: MAX_DIFF_BYTES * 5,
583
+ stdio: ['ignore', 'pipe', 'pipe'],
584
+ });
585
+ }
586
+ catch (err) {
587
+ if (typeof err?.status === 'number' && allowExitCodes.includes(err.status)) {
588
+ return typeof err.stdout === 'string' ? err.stdout : '';
589
+ }
590
+ const stderr = typeof err?.stderr === 'string' ? err.stderr.trim() : '';
591
+ throw new Error(stderr || err?.message || 'Git 命令执行失败');
592
+ }
593
+ }
594
+ function unquoteGitPath(raw) {
595
+ const trimmed = raw.trim();
596
+ if (!trimmed.startsWith('"') || !trimmed.endsWith('"'))
597
+ return trimmed;
598
+ try {
599
+ return JSON.parse(trimmed);
600
+ }
601
+ catch {
602
+ return decodeGitOctalPath(trimmed.slice(1, -1));
603
+ }
604
+ }
605
+ function describeGitStatus(indexStatus, worktreeStatus) {
606
+ const pair = `${indexStatus}${worktreeStatus}`;
607
+ if (pair === '??')
608
+ return '未跟踪';
609
+ if (pair === '!!')
610
+ return '已忽略';
611
+ if (indexStatus === 'A' || worktreeStatus === 'A')
612
+ return '新增';
613
+ if (indexStatus === 'D' || worktreeStatus === 'D')
614
+ return '删除';
615
+ if (indexStatus === 'R' || worktreeStatus === 'R')
616
+ return '重命名';
617
+ if (indexStatus === 'C' || worktreeStatus === 'C')
618
+ return '复制';
619
+ if (indexStatus === 'M' || worktreeStatus === 'M')
620
+ return '修改';
621
+ if (indexStatus === 'U' || worktreeStatus === 'U')
622
+ return '冲突';
623
+ return '变更';
624
+ }
625
+ function parseGitStatus(output, nulTerminated = false) {
626
+ if (nulTerminated) {
627
+ const records = output.split('\0').filter(Boolean);
628
+ const files = [];
629
+ for (let index = 0; index < records.length; index += 1) {
630
+ const line = records[index];
631
+ if (!line.trim())
632
+ continue;
633
+ const indexStatus = line[0] || ' ';
634
+ const worktreeStatus = line[1] || ' ';
635
+ const filePath = line.slice(3);
636
+ files.push({
637
+ path: filePath,
638
+ name: path.basename(filePath),
639
+ status: `${indexStatus}${worktreeStatus}`,
640
+ indexStatus,
641
+ worktreeStatus,
642
+ label: describeGitStatus(indexStatus, worktreeStatus),
643
+ });
644
+ if (indexStatus === 'R' || indexStatus === 'C' || worktreeStatus === 'R' || worktreeStatus === 'C') {
645
+ index += 1;
646
+ }
647
+ }
648
+ return files;
649
+ }
650
+ return output.split('\n').map(line => {
651
+ if (!line.trim())
652
+ return null;
653
+ const indexStatus = line[0] || ' ';
654
+ const worktreeStatus = line[1] || ' ';
655
+ const rawPath = line.slice(3);
656
+ const displayPath = rawPath.includes(' -> ') ? rawPath.split(' -> ').pop() || rawPath : rawPath;
657
+ const filePath = unquoteGitPath(displayPath);
658
+ return {
659
+ path: filePath,
660
+ name: path.basename(filePath),
661
+ status: `${indexStatus}${worktreeStatus}`,
662
+ indexStatus,
663
+ worktreeStatus,
664
+ label: describeGitStatus(indexStatus, worktreeStatus),
665
+ };
666
+ }).filter((item) => item !== null);
667
+ }
668
+ function truncateText(text, maxBytes) {
669
+ const buffer = Buffer.from(text, 'utf8');
670
+ if (buffer.length <= maxBytes) {
671
+ return { text, truncated: false };
672
+ }
673
+ return {
674
+ text: buffer.subarray(0, maxBytes).toString('utf8') + '\n\n... diff 内容过长,已截断 ...',
675
+ truncated: true,
676
+ };
677
+ }
678
+ function getWorkspaceChanges(rawPath) {
679
+ resolveWorkspacePath('', true);
680
+ try {
681
+ runGit(['rev-parse', '--is-inside-work-tree']);
682
+ }
683
+ catch {
684
+ return {
685
+ workDir: path.resolve(getCurrentWorkDir()),
686
+ isGitRepo: false,
687
+ branch: '',
688
+ files: [],
689
+ diff: '',
690
+ truncated: false,
691
+ };
692
+ }
693
+ const selectedPath = normalizeWorkspacePath(rawPath);
694
+ if (selectedPath) {
695
+ resolveWorkspacePath(selectedPath, false);
696
+ }
697
+ const branch = runGit(['rev-parse', '--abbrev-ref', 'HEAD']).trim();
698
+ const statusOutput = runGit(['status', '--porcelain=v1', '-z', '--untracked-files=all', '--', '.']);
699
+ const files = parseGitStatus(statusOutput, true);
700
+ const diffTarget = selectedPath || '.';
701
+ const staged = runGit(['diff', '--cached', '--', diffTarget]);
702
+ const unstaged = runGit(['diff', '--', diffTarget]);
703
+ const parts = [];
704
+ if (staged.trim())
705
+ parts.push(`--- 已暂存变更 ---\n${staged}`);
706
+ if (unstaged.trim())
707
+ parts.push(`--- 未暂存变更 ---\n${unstaged}`);
708
+ if (selectedPath && parts.length === 0) {
709
+ const selected = files.find(file => file.path === selectedPath);
710
+ if (selected?.status === '??') {
711
+ const { target } = resolveWorkspacePath(selectedPath, true);
712
+ const stat = fs.statSync(target);
713
+ if (stat.isFile() && stat.size <= MAX_FILE_BYTES) {
714
+ const untrackedDiff = runGit(['diff', '--no-index', '--', '/dev/null', target], [0, 1]);
715
+ if (untrackedDiff.trim())
716
+ parts.push(untrackedDiff);
717
+ }
718
+ }
719
+ }
720
+ const diffResult = truncateText(parts.join('\n'), MAX_DIFF_BYTES);
721
+ return {
722
+ workDir: path.resolve(getCurrentWorkDir()),
723
+ isGitRepo: true,
724
+ branch,
725
+ files,
726
+ diff: diffResult.text,
727
+ diffPath: selectedPath || undefined,
728
+ truncated: diffResult.truncated,
729
+ };
730
+ }
731
+ function trackWorkspaceFile(rawPath) {
732
+ resolveWorkspacePath('', true);
733
+ try {
734
+ runGit(['rev-parse', '--is-inside-work-tree']);
735
+ }
736
+ catch {
737
+ throw new Error('当前工作目录不是 Git 仓库');
738
+ }
739
+ const selectedPath = normalizeWorkspacePath(rawPath);
740
+ if (!selectedPath) {
741
+ throw new Error('请选择要追踪的文件');
742
+ }
743
+ const { target } = resolveWorkspacePath(selectedPath, true);
744
+ const stat = fs.statSync(target);
745
+ if (!stat.isFile()) {
746
+ throw new Error('只能追踪文件');
747
+ }
748
+ const statusOutput = runGit(['status', '--porcelain=v1', '-z', '--untracked-files=all', '--', selectedPath]);
749
+ const status = parseGitStatus(statusOutput, true).find(file => file.path === selectedPath);
750
+ if (!status || status.status !== '??') {
751
+ throw new Error('文件不是未跟踪状态');
752
+ }
753
+ runGit(['add', '--', selectedPath]);
754
+ return {
755
+ workDir: path.resolve(getCurrentWorkDir()),
756
+ path: selectedPath,
757
+ tracked: true,
758
+ };
759
+ }
760
+ function stageWorkspacePath(rawPath) {
761
+ ensureWorkspaceGitRepo();
762
+ const selectedPath = normalizeWorkspacePath(rawPath);
763
+ if (!selectedPath) {
764
+ runGit(['add', '--all', '--', '.']);
765
+ return {
766
+ workDir: path.resolve(getCurrentWorkDir()),
767
+ staged: true,
768
+ };
769
+ }
770
+ resolveWorkspacePath(selectedPath, false);
771
+ const statusOutput = runGit(['status', '--porcelain=v1', '-z', '--untracked-files=all', '--', selectedPath]);
772
+ const status = parseGitStatus(statusOutput, true).find(file => file.path === selectedPath);
773
+ if (!status) {
774
+ throw new Error('文件没有可暂存的变更');
775
+ }
776
+ runGit(['add', '--', selectedPath]);
777
+ return {
778
+ workDir: path.resolve(getCurrentWorkDir()),
779
+ path: selectedPath,
780
+ staged: true,
781
+ };
782
+ }
783
+ function unstageWorkspacePath(rawPath) {
784
+ ensureWorkspaceGitRepo();
785
+ const selectedPath = normalizeWorkspacePath(rawPath);
786
+ if (!selectedPath) {
787
+ runGit(['restore', '--staged', '--', '.']);
788
+ return {
789
+ workDir: path.resolve(getCurrentWorkDir()),
790
+ unstaged: true,
791
+ };
792
+ }
793
+ resolveWorkspacePath(selectedPath, false);
794
+ const statusOutput = runGit(['status', '--porcelain=v1', '-z', '--untracked-files=all', '--', selectedPath]);
795
+ const status = parseGitStatus(statusOutput, true).find(file => file.path === selectedPath);
796
+ if (!status || status.indexStatus === ' ' || status.indexStatus === '?') {
797
+ throw new Error('文件没有已暂存的变更');
798
+ }
799
+ runGit(['restore', '--staged', '--', selectedPath]);
800
+ return {
801
+ workDir: path.resolve(getCurrentWorkDir()),
802
+ path: selectedPath,
803
+ unstaged: true,
804
+ };
805
+ }
806
+ function ensureWorkspaceGitRepo() {
807
+ resolveWorkspacePath('', true);
808
+ try {
809
+ runGit(['rev-parse', '--is-inside-work-tree']);
810
+ }
811
+ catch {
812
+ throw new Error('当前工作目录不是 Git 仓库');
813
+ }
814
+ }
815
+ function normalizeCommitMessage(rawMessage) {
816
+ if (typeof rawMessage !== 'string')
817
+ return '';
818
+ return rawMessage.replace(/\r\n/g, '\n').replace(/\0/g, '').trim();
819
+ }
820
+ function normalizeAICommitMessage(rawMessage) {
821
+ let message = normalizeCommitMessage(rawMessage);
822
+ message = message.replace(/^```(?:\w+)?\n?/, '').replace(/\n?```$/, '').trim();
823
+ message = message.replace(/^commit message\s*:\s*/i, '').trim();
824
+ message = message.replace(/^["'“”‘’]+|["'“”‘’]+$/g, '').trim();
825
+ return message.split('\n').map(line => line.replace(/^\s*[-*]\s+/, '').trimEnd()).join('\n').trim();
826
+ }
827
+ function buildCommitMessagePrompt() {
828
+ ensureWorkspaceGitRepo();
829
+ const statusOutput = runGit(['status', '--porcelain=v1', '-z', '--untracked-files=all', '--', '.']);
830
+ const files = parseGitStatus(statusOutput, true);
831
+ const statusText = files.map(file => `${file.status} ${file.path}`).join('\n');
832
+ if (files.length === 0) {
833
+ throw new Error('没有可提交的代码变更');
834
+ }
835
+ const stagedFiles = runGit(['diff', '--cached', '--name-only']);
836
+ if (!stagedFiles.trim()) {
837
+ throw new Error('没有已暂存的代码变更,请先暂存要提交的文件');
838
+ }
839
+ const recentCommits = runGit(['log', '--oneline', '-8'], [0, 128]).trim();
840
+ const stagedStat = runGit(['diff', '--cached', '--stat']);
841
+ const stagedDiff = runGit(['diff', '--cached']);
842
+ const context = truncateText([
843
+ `当前分支:${runGit(['rev-parse', '--abbrev-ref', 'HEAD']).trim()}`,
844
+ recentCommits ? `最近提交:\n${recentCommits}` : '',
845
+ `状态:\n${statusText}`,
846
+ stagedStat.trim() ? `已暂存统计:\n${stagedStat.trim()}` : '',
847
+ stagedDiff.trim() ? `已暂存 diff:\n${stagedDiff}` : '',
848
+ ].filter(Boolean).join('\n\n'), MAX_COMMIT_CONTEXT_BYTES);
849
+ return [
850
+ '请基于下面的 Git 变更生成一条提交信息。',
851
+ '要求:',
852
+ '- 只输出 commit message,不要解释,不要 Markdown 代码块。',
853
+ '- 使用 Conventional Commits:<type>(<scope>): <subject> 或 <type>: <subject>。',
854
+ '- type 优先使用 feat/fix/docs/refactor/test/chore/style/build/ci/perf。',
855
+ '- subject 简短、具体、动词开头,使用英文小写;必要时可以补充简短 body。',
856
+ '- 不要提到 AI、不要提到你无法验证的内容。',
857
+ '',
858
+ context.truncated ? '注意:变更上下文已截断。' : '',
859
+ '已暂存 Git 变更上下文:',
860
+ context.text,
861
+ ].filter(Boolean).join('\n');
862
+ }
863
+ async function generateWorkspaceCommitMessage() {
864
+ const prompt = buildCommitMessagePrompt();
865
+ const output = await cliManager.runOneShot(prompt, 120000);
866
+ const message = normalizeAICommitMessage(output);
867
+ if (!message) {
868
+ throw new Error('AI 未生成有效提交信息');
869
+ }
870
+ return {
871
+ workDir: path.resolve(getCurrentWorkDir()),
872
+ message,
873
+ };
874
+ }
875
+ function commitWorkspaceChanges(rawMessage) {
876
+ ensureWorkspaceGitRepo();
877
+ const message = normalizeCommitMessage(rawMessage);
878
+ if (!message) {
879
+ throw new Error('提交信息不能为空');
880
+ }
881
+ const statusOutput = runGit(['status', '--porcelain=v1', '--untracked-files=all', '--', '.']);
882
+ if (!statusOutput.trim()) {
883
+ throw new Error('没有可提交的代码变更');
884
+ }
885
+ const stagedFiles = runGit(['diff', '--cached', '--name-only']);
886
+ if (!stagedFiles.trim()) {
887
+ throw new Error('没有已暂存的代码变更,请先暂存要提交的文件');
888
+ }
889
+ runGit(['commit', '-m', message]);
890
+ const commitHash = runGit(['rev-parse', '--short', 'HEAD']).trim();
891
+ const branch = runGit(['rev-parse', '--abbrev-ref', 'HEAD']).trim();
892
+ return {
893
+ workDir: path.resolve(getCurrentWorkDir()),
894
+ branch,
895
+ commitHash,
896
+ message,
897
+ };
898
+ }
899
+ function setCorsHeaders(res) {
900
+ res.setHeader('Access-Control-Allow-Origin', '*');
901
+ res.setHeader('Access-Control-Allow-Methods', 'GET,POST,DELETE,OPTIONS');
902
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-AgentPilot-Token');
903
+ }
904
+ function getHeaderValue(value) {
905
+ if (Array.isArray(value))
906
+ return value[0] || '';
907
+ return value || '';
908
+ }
909
+ function parseCookieHeader(header) {
910
+ const cookies = {};
911
+ for (const part of header.split(';')) {
912
+ const [rawName, ...rawValue] = part.trim().split('=');
913
+ if (!rawName || rawValue.length === 0)
914
+ continue;
915
+ try {
916
+ cookies[rawName] = decodeURIComponent(rawValue.join('='));
917
+ }
918
+ catch {
919
+ cookies[rawName] = rawValue.join('=');
920
+ }
921
+ }
922
+ return cookies;
923
+ }
924
+ function safeTokenEquals(candidate) {
925
+ if (!AUTH_ENABLED)
926
+ return true;
927
+ const actual = Buffer.from(AUTH_TOKEN);
928
+ const provided = Buffer.from(candidate);
929
+ if (provided.length !== actual.length)
930
+ return false;
931
+ return timingSafeEqual(provided, actual);
932
+ }
933
+ function readRequestTokens(req, url) {
934
+ const tokens = [];
935
+ const auth = getHeaderValue(req.headers.authorization);
936
+ const bearerMatch = auth.match(/^Bearer\s+(.+)$/i);
937
+ if (bearerMatch?.[1]?.trim())
938
+ tokens.push(bearerMatch[1].trim());
939
+ const headerToken = getHeaderValue(req.headers['x-agentpilot-token']).trim();
940
+ if (headerToken)
941
+ tokens.push(headerToken);
942
+ const queryToken = (url.searchParams.get('token') || url.searchParams.get('agentpilot_token') || '').trim();
943
+ if (queryToken)
944
+ tokens.push(queryToken);
945
+ const cookies = parseCookieHeader(getHeaderValue(req.headers.cookie));
946
+ const cookieToken = (cookies[AUTH_COOKIE_NAME] || '').trim();
947
+ if (cookieToken)
948
+ tokens.push(cookieToken);
949
+ return tokens;
950
+ }
951
+ function isRequestAuthorized(req, url) {
952
+ if (!AUTH_ENABLED)
953
+ return true;
954
+ return readRequestTokens(req, url).some(safeTokenEquals);
955
+ }
956
+ function appendSetCookie(res, cookie) {
957
+ const existing = res.getHeader('Set-Cookie');
958
+ if (!existing) {
959
+ res.setHeader('Set-Cookie', cookie);
960
+ return;
961
+ }
962
+ res.setHeader('Set-Cookie', Array.isArray(existing) ? [...existing, cookie] : [String(existing), cookie]);
963
+ }
964
+ function maybeSetAuthCookie(req, res, url) {
965
+ if (!AUTH_ENABLED)
966
+ return;
967
+ const hasTokenParam = url.searchParams.has('token') || url.searchParams.has('agentpilot_token');
968
+ if (!hasTokenParam && parseCookieHeader(getHeaderValue(req.headers.cookie))[AUTH_COOKIE_NAME])
969
+ return;
970
+ const secure = httpsOptions ? '; Secure' : '';
971
+ appendSetCookie(res, `${AUTH_COOKIE_NAME}=${encodeURIComponent(AUTH_TOKEN)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400${secure}`);
972
+ }
973
+ function sendAuthRequired(res) {
974
+ sendJson(res, 401, {
975
+ error: '访问 token 缺失或无效,请使用启动终端打印的链接重新打开。',
976
+ authRequired: true,
977
+ });
978
+ }
979
+ function sendJson(res, status, payload) {
980
+ if (res.writableEnded)
981
+ return;
982
+ setCorsHeaders(res);
983
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
984
+ res.end(JSON.stringify(payload));
985
+ }
986
+ function sendText(res, status, text) {
987
+ if (res.writableEnded)
988
+ return;
989
+ setCorsHeaders(res);
990
+ res.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8' });
991
+ res.end(text);
992
+ }
993
+ const STATIC_MIME_TYPES = {
994
+ '.css': 'text/css; charset=utf-8',
995
+ '.html': 'text/html; charset=utf-8',
996
+ '.js': 'text/javascript; charset=utf-8',
997
+ '.json': 'application/json; charset=utf-8',
998
+ '.map': 'application/json; charset=utf-8',
999
+ '.svg': 'image/svg+xml',
1000
+ '.png': 'image/png',
1001
+ '.jpg': 'image/jpeg',
1002
+ '.jpeg': 'image/jpeg',
1003
+ '.webp': 'image/webp',
1004
+ '.ico': 'image/x-icon',
1005
+ '.webmanifest': 'application/manifest+json; charset=utf-8',
1006
+ };
1007
+ function getStaticContentType(filePath) {
1008
+ return STATIC_MIME_TYPES[path.extname(filePath).toLowerCase()] || 'application/octet-stream';
1009
+ }
1010
+ function sendStaticFile(req, res, filePath) {
1011
+ if (res.writableEnded)
1012
+ return;
1013
+ setCorsHeaders(res);
1014
+ const ext = path.extname(filePath).toLowerCase();
1015
+ const headers = {
1016
+ 'Content-Type': getStaticContentType(filePath),
1017
+ };
1018
+ if (path.basename(filePath) === 'index.html' || path.basename(filePath) === 'sw.js') {
1019
+ headers['Cache-Control'] = 'no-cache';
1020
+ }
1021
+ else if (filePath.includes(`${path.sep}assets${path.sep}`)) {
1022
+ headers['Cache-Control'] = 'public, max-age=31536000, immutable';
1023
+ }
1024
+ else if (ext) {
1025
+ headers['Cache-Control'] = 'public, max-age=3600';
1026
+ }
1027
+ res.writeHead(200, headers);
1028
+ if (req.method === 'HEAD') {
1029
+ res.end();
1030
+ return;
1031
+ }
1032
+ fs.createReadStream(filePath).pipe(res);
1033
+ }
1034
+ function serveStaticClient(req, res, pathname) {
1035
+ if (!STATIC_DIR || (req.method !== 'GET' && req.method !== 'HEAD'))
1036
+ return false;
1037
+ let decodedPath;
1038
+ try {
1039
+ decodedPath = decodeURIComponent(pathname);
1040
+ }
1041
+ catch {
1042
+ sendJson(res, 400, { error: 'Bad request' });
1043
+ return true;
1044
+ }
1045
+ const normalizedPath = path.normalize(decodedPath).replace(/^(\.\.[/\\])+/, '');
1046
+ const relativePath = normalizedPath === path.sep ? 'index.html' : normalizedPath.replace(/^[/\\]+/, '');
1047
+ let target = path.resolve(STATIC_DIR, relativePath);
1048
+ if (target !== STATIC_DIR && !target.startsWith(`${STATIC_DIR}${path.sep}`)) {
1049
+ sendJson(res, 403, { error: 'Forbidden' });
1050
+ return true;
1051
+ }
1052
+ try {
1053
+ const stat = fs.statSync(target);
1054
+ if (stat.isDirectory()) {
1055
+ target = path.join(target, 'index.html');
1056
+ }
1057
+ if (fs.statSync(target).isFile()) {
1058
+ sendStaticFile(req, res, target);
1059
+ return true;
1060
+ }
1061
+ }
1062
+ catch { }
1063
+ const accept = req.headers.accept || '';
1064
+ if (req.method === 'GET' && accept.includes('text/html')) {
1065
+ sendStaticFile(req, res, path.join(STATIC_DIR, 'index.html'));
1066
+ return true;
1067
+ }
1068
+ sendJson(res, 404, { error: 'Not found' });
1069
+ return true;
1070
+ }
1071
+ function readJsonBody(req, maxBytes = 25 * 1024 * 1024) {
1072
+ return new Promise((resolve, reject) => {
1073
+ let body = '';
1074
+ let settled = false;
1075
+ const fail = (err) => {
1076
+ if (settled)
1077
+ return;
1078
+ settled = true;
1079
+ reject(err);
1080
+ };
1081
+ req.on('data', (chunk) => {
1082
+ if (settled)
1083
+ return;
1084
+ body += chunk.toString('utf8');
1085
+ if (Buffer.byteLength(body, 'utf8') > maxBytes) {
1086
+ fail(new Error('请求体过大'));
1087
+ req.destroy();
1088
+ }
1089
+ });
1090
+ req.on('end', () => {
1091
+ if (settled)
1092
+ return;
1093
+ settled = true;
1094
+ if (!body.trim()) {
1095
+ resolve({});
1096
+ return;
1097
+ }
1098
+ try {
1099
+ resolve(JSON.parse(body));
1100
+ }
1101
+ catch {
1102
+ reject(new Error('无效的 JSON 请求体'));
1103
+ }
1104
+ });
1105
+ req.on('error', fail);
1106
+ });
1107
+ }
1108
+ function getHistoryIdFromPath(pathname) {
1109
+ const match = pathname.match(/^\/api\/history\/([^/]+)$/);
1110
+ return match ? decodeURIComponent(match[1]) : null;
1111
+ }
1112
+ function listSystemDir(rawDir, includeFiles) {
1113
+ const baseDir = typeof rawDir === 'string' && rawDir ? rawDir : '~';
1114
+ const dir = baseDir.startsWith('~') ? baseDir.replace('~', os.homedir()) : baseDir;
1115
+ const resolved = path.resolve(dir);
1116
+ const entries = fs.readdirSync(resolved, { withFileTypes: true });
1117
+ const dirs = entries
1118
+ .filter(e => e.isDirectory() && !e.name.startsWith('.'))
1119
+ .map(e => ({
1120
+ name: e.name,
1121
+ path: path.join(resolved, e.name),
1122
+ }))
1123
+ .sort((a, b) => a.name.localeCompare(b.name));
1124
+ let files = [];
1125
+ if (includeFiles) {
1126
+ files = entries
1127
+ .filter(e => e.isFile() && !e.name.startsWith('.'))
1128
+ .map(e => ({
1129
+ name: e.name,
1130
+ path: path.join(resolved, e.name),
1131
+ ext: path.extname(e.name).toLowerCase(),
1132
+ }))
1133
+ .sort((a, b) => a.name.localeCompare(b.name));
1134
+ }
1135
+ const homeDir = os.homedir();
1136
+ const quickAccess = [
1137
+ { name: '主目录', path: homeDir, icon: 'home' },
1138
+ { name: '桌面', path: path.join(homeDir, 'Desktop'), icon: 'desktop' },
1139
+ { name: '文档', path: path.join(homeDir, 'Documents'), icon: 'documents' },
1140
+ { name: '下载', path: path.join(homeDir, 'Downloads'), icon: 'downloads' },
1141
+ ].filter(item => {
1142
+ try {
1143
+ return fs.statSync(item.path).isDirectory();
1144
+ }
1145
+ catch {
1146
+ return false;
1147
+ }
1148
+ });
1149
+ return {
1150
+ dir: resolved,
1151
+ parent: path.dirname(resolved),
1152
+ dirs,
1153
+ files,
1154
+ quickAccess,
1155
+ isRoot: resolved === path.dirname(resolved),
1156
+ };
1157
+ }
1158
+ async function handleHttpRequest(req, res) {
1159
+ setCorsHeaders(res);
1160
+ const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
1161
+ if (req.method === 'OPTIONS') {
1162
+ res.writeHead(204);
1163
+ res.end();
1164
+ return;
1165
+ }
1166
+ if (!isRequestAuthorized(req, url)) {
1167
+ sendAuthRequired(res);
1168
+ return;
1169
+ }
1170
+ maybeSetAuthCookie(req, res, url);
1171
+ const pathname = url.pathname;
1172
+ if (pathname === '/health') {
1173
+ sendText(res, 200, 'AgentPilot Server');
1174
+ return;
1175
+ }
1176
+ if (!pathname.startsWith('/api/') && serveStaticClient(req, res, pathname)) {
1177
+ return;
1178
+ }
1179
+ if (pathname === '/') {
1180
+ sendText(res, 200, 'AgentPilot Server');
1181
+ return;
1182
+ }
1183
+ if (!pathname.startsWith('/api/')) {
1184
+ sendJson(res, 404, { error: 'Not found' });
1185
+ return;
1186
+ }
1187
+ try {
1188
+ if (req.method === 'GET' && pathname === '/api/config') {
1189
+ sendJson(res, 200, {
1190
+ config: cliManager.getConfig(),
1191
+ status: cliManager.status,
1192
+ sessionId: currentSessionId,
1193
+ time: now(),
1194
+ });
1195
+ return;
1196
+ }
1197
+ if (req.method === 'GET' && pathname === '/api/skills') {
1198
+ const config = cliManager.getConfig();
1199
+ sendJson(res, 200, { skills: discoverSkills(config.workDir) });
1200
+ return;
1201
+ }
1202
+ if (req.method === 'GET' && pathname === '/api/workdirs') {
1203
+ recordCurrentWorkDir();
1204
+ sendJson(res, 200, {
1205
+ current: getCurrentWorkDir(),
1206
+ workdirs: store.getRecentWorkDirs().map(toClientWorkDir),
1207
+ });
1208
+ return;
1209
+ }
1210
+ if (req.method === 'GET' && pathname === '/api/session') {
1211
+ if (!currentSessionId) {
1212
+ sendJson(res, 200, {
1213
+ sessionId: null,
1214
+ messages: [],
1215
+ lastSeq: 0,
1216
+ firstSeq: 0,
1217
+ hasMoreBefore: false,
1218
+ tokenUsage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0, costUsd: 0 },
1219
+ });
1220
+ return;
1221
+ }
1222
+ sendJson(res, 200, buildSessionData(currentSessionId, {
1223
+ afterSeq: url.searchParams.get('afterSeq') || undefined,
1224
+ beforeSeq: url.searchParams.get('beforeSeq') || undefined,
1225
+ limit: url.searchParams.get('limit') || undefined,
1226
+ }));
1227
+ return;
1228
+ }
1229
+ if (req.method === 'GET' && pathname === '/api/history') {
1230
+ const tasks = store.getHistoryTasks({ workDir: getCurrentWorkDir() });
1231
+ sendJson(res, 200, { tasks: tasks.map(task => toClientHistoryTask(task)) });
1232
+ return;
1233
+ }
1234
+ const historyId = getHistoryIdFromPath(pathname);
1235
+ if (historyId && req.method === 'GET') {
1236
+ const task = store.getHistoryTask(historyId, getCurrentWorkDir());
1237
+ sendJson(res, 200, { task: task ? toClientHistoryTask(task, true) : null });
1238
+ return;
1239
+ }
1240
+ if (historyId && req.method === 'DELETE') {
1241
+ store.deleteHistoryTask(historyId, getCurrentWorkDir());
1242
+ sendJson(res, 200, { id: historyId });
1243
+ broadcast({ type: 'history_changed', time: now() });
1244
+ return;
1245
+ }
1246
+ if (req.method === 'DELETE' && pathname === '/api/history') {
1247
+ store.clearHistory(getCurrentWorkDir());
1248
+ sendJson(res, 200, { success: true });
1249
+ broadcast({ type: 'history_changed', time: now() });
1250
+ return;
1251
+ }
1252
+ if (req.method === 'POST' && pathname === '/api/migrate-local-data') {
1253
+ const body = await readJsonBody(req);
1254
+ store.importLocalData(body.messages || [], body.historyTasks || [], getCurrentWorkDir());
1255
+ sendJson(res, 200, { success: true });
1256
+ return;
1257
+ }
1258
+ if (req.method === 'GET' && pathname === '/api/dir') {
1259
+ const includeFiles = url.searchParams.get('includeFiles') === 'true';
1260
+ sendJson(res, 200, listSystemDir(url.searchParams.get('dir'), includeFiles));
1261
+ return;
1262
+ }
1263
+ if (req.method === 'GET' && pathname === '/api/workdir/tree') {
1264
+ sendJson(res, 200, listWorkspaceDir(url.searchParams.get('path') || ''));
1265
+ return;
1266
+ }
1267
+ if (req.method === 'GET' && pathname === '/api/workdir/file') {
1268
+ sendJson(res, 200, readWorkspaceFile(url.searchParams.get('path') || ''));
1269
+ return;
1270
+ }
1271
+ if (req.method === 'GET' && pathname === '/api/workdir/changes') {
1272
+ sendJson(res, 200, getWorkspaceChanges(url.searchParams.get('path') || undefined));
1273
+ return;
1274
+ }
1275
+ if (req.method === 'POST' && pathname === '/api/workdir/track') {
1276
+ const body = await readJsonBody(req);
1277
+ sendJson(res, 200, trackWorkspaceFile(body.path));
1278
+ return;
1279
+ }
1280
+ if (req.method === 'POST' && pathname === '/api/workdir/stage') {
1281
+ const body = await readJsonBody(req);
1282
+ sendJson(res, 200, stageWorkspacePath(body.path));
1283
+ return;
1284
+ }
1285
+ if (req.method === 'POST' && pathname === '/api/workdir/unstage') {
1286
+ const body = await readJsonBody(req);
1287
+ sendJson(res, 200, unstageWorkspacePath(body.path));
1288
+ return;
1289
+ }
1290
+ if (req.method === 'POST' && pathname === '/api/workdir/commit-message') {
1291
+ sendJson(res, 200, await generateWorkspaceCommitMessage());
1292
+ return;
1293
+ }
1294
+ if (req.method === 'POST' && pathname === '/api/workdir/commit') {
1295
+ const body = await readJsonBody(req);
1296
+ const result = commitWorkspaceChanges(body.message);
1297
+ sendJson(res, 200, result);
1298
+ broadcast({ type: 'history_changed', time: now() });
1299
+ return;
1300
+ }
1301
+ sendJson(res, 404, { error: 'Not found' });
1302
+ }
1303
+ catch (err) {
1304
+ sendJson(res, 400, { error: err?.message || '请求处理失败' });
1305
+ }
1306
+ }
1307
+ // Current active session ID
1308
+ let currentSessionId = null;
1309
+ // Restore or create session on startup
1310
+ const existingSession = store.getCurrentSession();
1311
+ if (existingSession) {
1312
+ currentSessionId = existingSession.id;
1313
+ console.log(`[Store] Restored session: ${currentSessionId}`);
1314
+ // Restore CLI config from the active session so refreshes keep the last used CLI.
1315
+ try {
1316
+ const config = existingSession.cli_config ? JSON.parse(existingSession.cli_config) : {};
1317
+ cliManager.restoreConfig(config);
1318
+ if (config.cliSessionId) {
1319
+ cliManager.restoreSessionId(config.cliSessionId);
1320
+ console.log(`[Store] Restored CLI session: ${config.cliSessionId.slice(0, 8)}...`);
1321
+ }
1322
+ if (config.workDir) {
1323
+ cliManager.restoreWorkDir(config.workDir);
1324
+ }
1325
+ }
1326
+ catch { }
1327
+ }
1328
+ else {
1329
+ currentSessionId = store.createSession();
1330
+ console.log(`[Store] Created new session: ${currentSessionId}`);
1331
+ }
1332
+ const startupConfig = readStartupConfig();
1333
+ if (Object.keys(startupConfig).length > 0) {
1334
+ // Startup config is the server-process default. Frontend workdir switches update
1335
+ // the active session for this process, but a server restart should start from
1336
+ // the directory where the NPX command is run again.
1337
+ const previousSession = store.getCurrentSession();
1338
+ const previousConfig = getSessionConfig(previousSession);
1339
+ const previousWorkDir = typeof previousConfig.workDir === 'string'
1340
+ ? path.resolve(previousConfig.workDir)
1341
+ : '';
1342
+ cliManager.restoreConfig(startupConfig);
1343
+ if (startupConfig.workDir) {
1344
+ cliManager.restoreWorkDir(startupConfig.workDir);
1345
+ }
1346
+ const nextWorkDir = path.resolve(cliManager.getConfig().workDir);
1347
+ const shouldStartFreshSession = IS_NPX_ENTRY &&
1348
+ currentSessionId &&
1349
+ previousWorkDir &&
1350
+ previousWorkDir !== nextWorkDir &&
1351
+ store.getLastSeq(currentSessionId) > 0;
1352
+ if (shouldStartFreshSession && currentSessionId) {
1353
+ store.archiveSession(currentSessionId, 'completed');
1354
+ currentSessionId = store.createSession({ ...cliManager.getConfig() });
1355
+ store.updateSessionStatus(currentSessionId, 'idle');
1356
+ }
1357
+ else if (currentSessionId) {
1358
+ store.updateSessionConfig(currentSessionId, { ...cliManager.getConfig() });
1359
+ }
1360
+ }
1361
+ store.backfillUnscopedHistoryWorkDir(cliManager.getConfig().workDir);
1362
+ recordCurrentWorkDir();
1363
+ // Message types that should be persisted
1364
+ const PERSISTABLE_TYPES = new Set([
1365
+ 'user_message',
1366
+ 'ai_message',
1367
+ 'thinking_message',
1368
+ 'tool_call',
1369
+ 'confirm_request',
1370
+ 'confirm_result',
1371
+ 'confirm_mode_changed',
1372
+ 'ask_question',
1373
+ 'ask_question_result',
1374
+ 'system',
1375
+ 'error',
1376
+ 'status',
1377
+ ]);
1378
+ function broadcast(event) {
1379
+ // Persist CLI session ID when we get it from system init or result
1380
+ if (currentSessionId && event.type === 'system' && event.details?.session_id) {
1381
+ try {
1382
+ const config = store.getCurrentSession()?.cli_config;
1383
+ const parsed = config ? JSON.parse(config) : {};
1384
+ parsed.cliSessionId = event.details.session_id;
1385
+ store.updateSessionConfig(currentSessionId, parsed);
1386
+ }
1387
+ catch { }
1388
+ }
1389
+ if (currentSessionId && event.type === 'system' && event.details?.subtype === 'result' && event.details?.session_id) {
1390
+ try {
1391
+ const config = store.getCurrentSession()?.cli_config;
1392
+ const parsed = config ? JSON.parse(config) : {};
1393
+ parsed.cliSessionId = event.details.session_id;
1394
+ store.updateSessionConfig(currentSessionId, parsed);
1395
+ }
1396
+ catch { }
1397
+ }
1398
+ // Persist to store before broadcasting
1399
+ if (currentSessionId && PERSISTABLE_TYPES.has(event.type)) {
1400
+ try {
1401
+ let persisted;
1402
+ // For tool_call events, 'details' maps to toolDetails (tool input)
1403
+ // For system events, 'details' maps to details (structured metadata)
1404
+ // For ask_question events, store questions data in 'details'
1405
+ const isToolCall = event.type === 'tool_call';
1406
+ const isAskQuestion = event.type === 'ask_question';
1407
+ const isAskQuestionResult = event.type === 'ask_question_result';
1408
+ const isToolResult = isToolCall && (event.status === 'success' || event.status === 'failed');
1409
+ const toolUseId = event.toolUseId || undefined;
1410
+ // Build details field: for ask_question, include questions data
1411
+ let detailsData = !isToolCall && event.details ? event.details : undefined;
1412
+ if (isAskQuestion && event.questions) {
1413
+ detailsData = { questions: event.questions, toolUseId: event.toolUseId };
1414
+ }
1415
+ if (isAskQuestionResult) {
1416
+ detailsData = { answered: true, answer: event.answer, toolUseId: event.toolUseId };
1417
+ }
1418
+ // If this is a tool result with a toolUseId, update the existing tool_call message
1419
+ if (isToolResult && toolUseId) {
1420
+ const updated = store.updateToolCallResult(currentSessionId, toolUseId, event.status, event.details || '');
1421
+ if (updated) {
1422
+ // Successfully updated existing message, skip appending a new row
1423
+ }
1424
+ else {
1425
+ // No matching tool_call found (edge case), append as new message
1426
+ persisted = store.appendMessage(currentSessionId, {
1427
+ id: event.id || undefined,
1428
+ type: event.type,
1429
+ content: event.content || '',
1430
+ time: event.time || now(),
1431
+ status: event.status || undefined,
1432
+ toolName: event.toolName || undefined,
1433
+ toolDetails: isToolCall ? (event.details || event.toolDetails) : event.toolDetails || undefined,
1434
+ toolUseId,
1435
+ toolResult: event.details || '',
1436
+ permission: event.permission || undefined,
1437
+ details: detailsData,
1438
+ });
1439
+ }
1440
+ }
1441
+ else {
1442
+ persisted = store.appendMessage(currentSessionId, {
1443
+ id: event.id || undefined,
1444
+ type: event.type,
1445
+ content: event.content || '',
1446
+ time: event.time || now(),
1447
+ status: event.status || undefined,
1448
+ toolName: event.toolName || undefined,
1449
+ toolDetails: isToolCall ? (event.details || event.toolDetails) : event.toolDetails || undefined,
1450
+ toolUseId,
1451
+ permission: event.permission || undefined,
1452
+ details: detailsData,
1453
+ });
1454
+ }
1455
+ if (persisted) {
1456
+ if (!event.id)
1457
+ event.id = persisted.id;
1458
+ event.seq = persisted.seq;
1459
+ }
1460
+ }
1461
+ catch (err) {
1462
+ console.error('[Store] Failed to persist message:', err);
1463
+ }
1464
+ }
1465
+ // Broadcast to all clients
1466
+ const data = JSON.stringify(event);
1467
+ for (const ws of clients) {
1468
+ if (ws.readyState === WebSocket.OPEN) {
1469
+ try {
1470
+ ws.send(data);
1471
+ }
1472
+ catch { }
1473
+ }
1474
+ }
1475
+ }
1476
+ cliManager.setEventHandler(broadcast);
1477
+ wss.on('connection', (ws) => {
1478
+ clients.add(ws);
1479
+ console.log(`[WS] Client connected (total: ${clients.size})`);
1480
+ ws.send(JSON.stringify({
1481
+ type: 'connected',
1482
+ config: cliManager.getConfig(),
1483
+ status: cliManager.status,
1484
+ sessionId: currentSessionId,
1485
+ }));
1486
+ ws.on('message', (data) => {
1487
+ let msg;
1488
+ try {
1489
+ msg = JSON.parse(data.toString());
1490
+ }
1491
+ catch (e) {
1492
+ ws.send(JSON.stringify({ type: 'error', content: '无效的消息格式', time: now() }));
1493
+ return;
1494
+ }
1495
+ try {
1496
+ handleMessage(ws, msg);
1497
+ }
1498
+ catch (e) {
1499
+ console.error('[WS] handleMessage error:', e);
1500
+ ws.send(JSON.stringify({ type: 'error', content: `处理消息失败: ${e.message || '未知错误'}`, time: now() }));
1501
+ }
1502
+ });
1503
+ ws.on('close', () => {
1504
+ clients.delete(ws);
1505
+ console.log(`[WS] Client disconnected (total: ${clients.size})`);
1506
+ });
1507
+ ws.on('error', (err) => {
1508
+ console.error('[WS] Error:', err.message);
1509
+ clients.delete(ws);
1510
+ });
1511
+ });
1512
+ function handleMessage(ws, msg) {
1513
+ switch (msg.type) {
1514
+ case 'start_cli': {
1515
+ // Archive current session
1516
+ if (currentSessionId) {
1517
+ store.archiveSession(currentSessionId, 'completed');
1518
+ }
1519
+ // Start CLI first so config is updated
1520
+ cliManager.start({
1521
+ cliType: msg.cliType,
1522
+ cliCommand: msg.cliCommand,
1523
+ workDir: msg.workDir,
1524
+ confirmMode: msg.confirmMode,
1525
+ cliArgs: msg.cliArgs,
1526
+ }, broadcast);
1527
+ recordCurrentWorkDir();
1528
+ // Create new session after CLI is configured
1529
+ currentSessionId = store.createSession({
1530
+ ...cliManager.getConfig(),
1531
+ });
1532
+ store.updateSessionStatus(currentSessionId, 'idle');
1533
+ store.updateSessionConfig(currentSessionId, { ...cliManager.getConfig() });
1534
+ // Broadcast session change with updated config
1535
+ broadcast({
1536
+ type: 'session_reset',
1537
+ sessionId: currentSessionId,
1538
+ config: cliManager.getConfig(),
1539
+ time: now(),
1540
+ });
1541
+ broadcast({ type: 'history_changed', time: now() });
1542
+ broadcast({ type: 'workdirs_changed', current: getCurrentWorkDir(), time: now() });
1543
+ break;
1544
+ }
1545
+ case 'send_message':
1546
+ cliManager.sendInput(msg.content);
1547
+ broadcast({
1548
+ type: 'user_message',
1549
+ content: msg.content,
1550
+ time: now(),
1551
+ });
1552
+ break;
1553
+ case 'confirm_response':
1554
+ cliManager.confirmResponse(msg.approved);
1555
+ break;
1556
+ case 'question_response':
1557
+ cliManager.questionResponse(msg.answer, msg.toolUseId);
1558
+ break;
1559
+ case 'interrupt':
1560
+ cliManager.interrupt();
1561
+ break;
1562
+ case 'restart_cli': {
1563
+ // Archive current session before restarting
1564
+ if (currentSessionId) {
1565
+ const taskStatus = cliManager.status === 'running' || cliManager.status === 'confirm' ? 'running' :
1566
+ cliManager.status === 'disconnected' ? 'failed' : 'completed';
1567
+ store.archiveSession(currentSessionId, taskStatus);
1568
+ }
1569
+ // Create new session
1570
+ currentSessionId = store.createSession({ ...cliManager.getConfig() });
1571
+ store.updateSessionStatus(currentSessionId, 'idle');
1572
+ // Broadcast session change
1573
+ broadcast({
1574
+ type: 'session_reset',
1575
+ sessionId: currentSessionId,
1576
+ config: cliManager.getConfig(),
1577
+ time: now(),
1578
+ });
1579
+ broadcast({ type: 'history_changed', time: now() });
1580
+ cliManager.restart();
1581
+ break;
1582
+ }
1583
+ case 'set_confirm_mode':
1584
+ cliManager.setConfirmMode(msg.mode);
1585
+ broadcast({
1586
+ type: 'confirm_mode_changed',
1587
+ mode: msg.mode,
1588
+ time: now(),
1589
+ });
1590
+ break;
1591
+ case 'get_config':
1592
+ ws.send(JSON.stringify({
1593
+ type: 'config',
1594
+ config: cliManager.getConfig(),
1595
+ status: cliManager.status,
1596
+ sessionId: currentSessionId,
1597
+ time: now(),
1598
+ }));
1599
+ break;
1600
+ case 'get_skills': {
1601
+ const config = cliManager.getConfig();
1602
+ ws.send(JSON.stringify({
1603
+ type: 'skills_data',
1604
+ requestId: msg.requestId,
1605
+ skills: discoverSkills(config.workDir),
1606
+ }));
1607
+ break;
1608
+ }
1609
+ // --- New protocol: Session data ---
1610
+ case 'get_session': {
1611
+ if (!currentSessionId) {
1612
+ ws.send(JSON.stringify({
1613
+ type: 'session_data',
1614
+ requestId: msg.requestId,
1615
+ sessionId: null,
1616
+ messages: [],
1617
+ lastSeq: 0,
1618
+ firstSeq: 0,
1619
+ hasMoreBefore: false,
1620
+ tokenUsage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0, costUsd: 0 },
1621
+ }));
1622
+ break;
1623
+ }
1624
+ ws.send(JSON.stringify({
1625
+ type: 'session_data',
1626
+ requestId: msg.requestId,
1627
+ ...buildSessionData(currentSessionId, {
1628
+ afterSeq: msg.afterSeq,
1629
+ beforeSeq: msg.beforeSeq,
1630
+ limit: msg.limit,
1631
+ }),
1632
+ }));
1633
+ break;
1634
+ }
1635
+ // --- New protocol: History ---
1636
+ case 'get_history': {
1637
+ const tasks = store.getHistoryTasks({ workDir: getCurrentWorkDir() });
1638
+ ws.send(JSON.stringify({
1639
+ type: 'history_data',
1640
+ requestId: msg.requestId,
1641
+ tasks: tasks.map(task => toClientHistoryTask(task)),
1642
+ }));
1643
+ break;
1644
+ }
1645
+ case 'get_history_task': {
1646
+ const task = store.getHistoryTask(msg.id, getCurrentWorkDir());
1647
+ ws.send(JSON.stringify({
1648
+ type: 'history_task_data',
1649
+ requestId: msg.requestId,
1650
+ task: task ? toClientHistoryTask(task, true) : null,
1651
+ }));
1652
+ break;
1653
+ }
1654
+ case 'resume_history': {
1655
+ if (cliManager.status === 'running' || cliManager.status === 'confirm' || cliManager.status === 'question') {
1656
+ ws.send(JSON.stringify({
1657
+ type: 'history_resumed',
1658
+ requestId: msg.requestId,
1659
+ success: false,
1660
+ error: '当前会话正在响应中,请等待完成后再继续该会话',
1661
+ }));
1662
+ break;
1663
+ }
1664
+ const task = store.getHistoryTask(msg.id, getCurrentWorkDir());
1665
+ const cliSessionId = task ? getRestorableCliSessionId(task.session_id, task.messages) : undefined;
1666
+ if (!task || !task.session_id || !cliSessionId) {
1667
+ ws.send(JSON.stringify({
1668
+ type: 'history_resumed',
1669
+ requestId: msg.requestId,
1670
+ success: false,
1671
+ error: '该会话记录缺少可恢复的 CLI 会话 ID,无法继续追问',
1672
+ }));
1673
+ break;
1674
+ }
1675
+ const restored = store.resumeHistoryTask(msg.id, currentSessionId, getCurrentWorkDir());
1676
+ if (!restored) {
1677
+ ws.send(JSON.stringify({
1678
+ type: 'history_resumed',
1679
+ requestId: msg.requestId,
1680
+ success: false,
1681
+ error: '恢复会话记录失败',
1682
+ }));
1683
+ break;
1684
+ }
1685
+ currentSessionId = restored.session.id;
1686
+ const restoredConfig = {
1687
+ ...getSessionConfig(restored.session),
1688
+ cliSessionId,
1689
+ };
1690
+ cliManager.restoreConfig(restoredConfig);
1691
+ cliManager.restoreSessionId(cliSessionId);
1692
+ if (typeof restoredConfig.workDir === 'string') {
1693
+ cliManager.restoreWorkDir(restoredConfig.workDir);
1694
+ }
1695
+ recordCurrentWorkDir();
1696
+ store.updateSessionConfig(currentSessionId, {
1697
+ ...cliManager.getConfig(),
1698
+ cliSessionId,
1699
+ });
1700
+ const sessionMessages = toClientMessages(restored.messages);
1701
+ const lastSeq = store.getLastSeq(currentSessionId);
1702
+ broadcast({
1703
+ type: 'session_restored',
1704
+ sessionId: currentSessionId,
1705
+ config: cliManager.getConfig(),
1706
+ status: cliManager.status,
1707
+ messages: sessionMessages,
1708
+ lastSeq,
1709
+ time: now(),
1710
+ });
1711
+ broadcast({ type: 'history_changed', time: now() });
1712
+ broadcast({ type: 'workdirs_changed', current: getCurrentWorkDir(), time: now() });
1713
+ const followUp = typeof msg.content === 'string' ? msg.content.trim() : '';
1714
+ if (followUp) {
1715
+ cliManager.sendInput(followUp);
1716
+ broadcast({
1717
+ type: 'user_message',
1718
+ content: followUp,
1719
+ time: now(),
1720
+ });
1721
+ }
1722
+ ws.send(JSON.stringify({
1723
+ type: 'history_resumed',
1724
+ requestId: msg.requestId,
1725
+ success: true,
1726
+ sessionId: currentSessionId,
1727
+ config: cliManager.getConfig(),
1728
+ messages: sessionMessages,
1729
+ lastSeq,
1730
+ sent: !!followUp,
1731
+ }));
1732
+ break;
1733
+ }
1734
+ case 'edit_user_message': {
1735
+ if (cliManager.status === 'running' || cliManager.status === 'confirm' || cliManager.status === 'question') {
1736
+ ws.send(JSON.stringify({
1737
+ type: 'user_message_edited',
1738
+ requestId: msg.requestId,
1739
+ success: false,
1740
+ error: '当前会话正在响应中,请等待完成后再编辑消息',
1741
+ }));
1742
+ break;
1743
+ }
1744
+ const messageId = typeof msg.messageId === 'string' ? msg.messageId : '';
1745
+ const editedContent = typeof msg.content === 'string' ? msg.content : '';
1746
+ if (!currentSessionId || !messageId || !editedContent.trim()) {
1747
+ ws.send(JSON.stringify({
1748
+ type: 'user_message_edited',
1749
+ requestId: msg.requestId,
1750
+ success: false,
1751
+ error: '编辑消息参数无效',
1752
+ }));
1753
+ break;
1754
+ }
1755
+ const sourceSessionId = currentSessionId;
1756
+ const targetMessage = store.getSessionMessageById(sourceSessionId, messageId);
1757
+ if (!targetMessage || !['user_message', 'user'].includes(targetMessage.type)) {
1758
+ ws.send(JSON.stringify({
1759
+ type: 'user_message_edited',
1760
+ requestId: msg.requestId,
1761
+ success: false,
1762
+ error: '未找到可编辑的用户消息',
1763
+ }));
1764
+ break;
1765
+ }
1766
+ const baseConfig = { ...cliManager.getConfig() };
1767
+ currentSessionId = store.createSession(baseConfig);
1768
+ store.copySessionMessagesBeforeSeq(sourceSessionId, currentSessionId, targetMessage.seq);
1769
+ const prefixMessages = store.getSessionMessages(currentSessionId);
1770
+ const branchConfig = { ...baseConfig };
1771
+ delete branchConfig.cliSessionId;
1772
+ cliManager.restoreConfig(branchConfig);
1773
+ cliManager.clearSessionId();
1774
+ if (typeof branchConfig.workDir === 'string') {
1775
+ cliManager.restoreWorkDir(branchConfig.workDir);
1776
+ }
1777
+ store.updateSessionConfig(currentSessionId, branchConfig);
1778
+ const sessionMessages = toClientMessages(prefixMessages);
1779
+ const lastSeq = store.getLastSeq(currentSessionId);
1780
+ broadcast({
1781
+ type: 'session_restored',
1782
+ sessionId: currentSessionId,
1783
+ config: cliManager.getConfig(),
1784
+ status: cliManager.status,
1785
+ messages: sessionMessages,
1786
+ lastSeq,
1787
+ time: now(),
1788
+ });
1789
+ broadcast({ type: 'history_changed', time: now() });
1790
+ cliManager.sendInput(buildEditPrompt(prefixMessages, editedContent));
1791
+ broadcast({
1792
+ type: 'user_message',
1793
+ content: editedContent,
1794
+ time: now(),
1795
+ });
1796
+ ws.send(JSON.stringify({
1797
+ type: 'user_message_edited',
1798
+ requestId: msg.requestId,
1799
+ success: true,
1800
+ sessionId: currentSessionId,
1801
+ }));
1802
+ break;
1803
+ }
1804
+ case 'delete_history': {
1805
+ store.deleteHistoryTask(msg.id, getCurrentWorkDir());
1806
+ ws.send(JSON.stringify({
1807
+ type: 'history_deleted',
1808
+ requestId: msg.requestId,
1809
+ id: msg.id,
1810
+ }));
1811
+ // Notify all clients
1812
+ broadcast({ type: 'history_changed', time: now() });
1813
+ break;
1814
+ }
1815
+ case 'clear_history': {
1816
+ store.clearHistory(getCurrentWorkDir());
1817
+ ws.send(JSON.stringify({
1818
+ type: 'history_cleared',
1819
+ requestId: msg.requestId,
1820
+ }));
1821
+ // Notify all clients
1822
+ broadcast({ type: 'history_changed', time: now() });
1823
+ break;
1824
+ }
1825
+ // --- New protocol: Migration from localStorage ---
1826
+ case 'migrate_local_data': {
1827
+ try {
1828
+ store.importLocalData(msg.messages || [], msg.historyTasks || [], getCurrentWorkDir());
1829
+ ws.send(JSON.stringify({
1830
+ type: 'migration_done',
1831
+ requestId: msg.requestId,
1832
+ success: true,
1833
+ }));
1834
+ }
1835
+ catch (err) {
1836
+ ws.send(JSON.stringify({
1837
+ type: 'migration_done',
1838
+ requestId: msg.requestId,
1839
+ success: false,
1840
+ error: err.message,
1841
+ }));
1842
+ }
1843
+ break;
1844
+ }
1845
+ case 'list_workdir': {
1846
+ try {
1847
+ const result = listWorkspaceDir(msg.path || '');
1848
+ ws.send(JSON.stringify({
1849
+ type: 'workdir_tree',
1850
+ requestId: msg.requestId,
1851
+ ...result,
1852
+ }));
1853
+ }
1854
+ catch (err) {
1855
+ ws.send(JSON.stringify({
1856
+ type: 'workdir_tree',
1857
+ requestId: msg.requestId,
1858
+ error: err.message || '读取工作目录失败',
1859
+ }));
1860
+ }
1861
+ break;
1862
+ }
1863
+ case 'read_workdir_file': {
1864
+ try {
1865
+ const result = readWorkspaceFile(msg.path || '');
1866
+ ws.send(JSON.stringify({
1867
+ type: 'workdir_file',
1868
+ requestId: msg.requestId,
1869
+ ...result,
1870
+ }));
1871
+ }
1872
+ catch (err) {
1873
+ ws.send(JSON.stringify({
1874
+ type: 'workdir_file',
1875
+ requestId: msg.requestId,
1876
+ error: err.message || '读取文件失败',
1877
+ }));
1878
+ }
1879
+ break;
1880
+ }
1881
+ case 'get_workdir_changes': {
1882
+ try {
1883
+ const result = getWorkspaceChanges(msg.path);
1884
+ ws.send(JSON.stringify({
1885
+ type: 'workdir_changes',
1886
+ requestId: msg.requestId,
1887
+ ...result,
1888
+ }));
1889
+ }
1890
+ catch (err) {
1891
+ ws.send(JSON.stringify({
1892
+ type: 'workdir_changes',
1893
+ requestId: msg.requestId,
1894
+ error: err.message || '读取变更失败',
1895
+ }));
1896
+ }
1897
+ break;
1898
+ }
1899
+ case 'track_workdir_file': {
1900
+ try {
1901
+ const result = trackWorkspaceFile(msg.path);
1902
+ ws.send(JSON.stringify({
1903
+ type: 'workdir_file_tracked',
1904
+ requestId: msg.requestId,
1905
+ ...result,
1906
+ }));
1907
+ }
1908
+ catch (err) {
1909
+ ws.send(JSON.stringify({
1910
+ type: 'workdir_file_tracked',
1911
+ requestId: msg.requestId,
1912
+ error: err.message || '追踪文件失败',
1913
+ }));
1914
+ }
1915
+ break;
1916
+ }
1917
+ case 'generate_workdir_commit_message': {
1918
+ generateWorkspaceCommitMessage()
1919
+ .then((result) => {
1920
+ ws.send(JSON.stringify({
1921
+ type: 'workdir_commit_message',
1922
+ requestId: msg.requestId,
1923
+ ...result,
1924
+ }));
1925
+ })
1926
+ .catch((err) => {
1927
+ ws.send(JSON.stringify({
1928
+ type: 'workdir_commit_message',
1929
+ requestId: msg.requestId,
1930
+ error: err.message || '生成提交信息失败',
1931
+ }));
1932
+ });
1933
+ break;
1934
+ }
1935
+ case 'commit_workdir_changes': {
1936
+ try {
1937
+ const result = commitWorkspaceChanges(msg.message);
1938
+ ws.send(JSON.stringify({
1939
+ type: 'workdir_committed',
1940
+ requestId: msg.requestId,
1941
+ ...result,
1942
+ }));
1943
+ broadcast({ type: 'history_changed', time: now() });
1944
+ }
1945
+ catch (err) {
1946
+ ws.send(JSON.stringify({
1947
+ type: 'workdir_committed',
1948
+ requestId: msg.requestId,
1949
+ error: err.message || '提交失败',
1950
+ }));
1951
+ }
1952
+ break;
1953
+ }
1954
+ case 'list_dir': {
1955
+ try {
1956
+ ws.send(JSON.stringify({
1957
+ type: 'dir_list',
1958
+ requestId: msg.requestId,
1959
+ ...listSystemDir(msg.dir, !!msg.includeFiles),
1960
+ }));
1961
+ }
1962
+ catch (err) {
1963
+ ws.send(JSON.stringify({
1964
+ type: 'dir_list',
1965
+ requestId: msg.requestId,
1966
+ dir: msg.dir,
1967
+ error: err.message,
1968
+ }));
1969
+ }
1970
+ break;
1971
+ }
1972
+ default:
1973
+ ws.send(JSON.stringify({ type: 'error', content: `未知消息类型: ${msg.type}`, time: now() }));
1974
+ }
1975
+ }
1976
+ function now() {
1977
+ return new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
1978
+ }
1979
+ function formatHostForUrl(host) {
1980
+ return host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
1981
+ }
1982
+ function withStartupToken(url) {
1983
+ if (!AUTH_ENABLED)
1984
+ return url;
1985
+ const parsed = new URL(url);
1986
+ parsed.searchParams.set('token', AUTH_TOKEN);
1987
+ return parsed.toString();
1988
+ }
1989
+ function getNetworkUrls() {
1990
+ const urls = [];
1991
+ const interfaces = os.networkInterfaces();
1992
+ for (const items of Object.values(interfaces)) {
1993
+ for (const item of items || []) {
1994
+ if (item.family !== 'IPv4' || item.internal)
1995
+ continue;
1996
+ urls.push(withStartupToken(`${httpScheme}://${item.address}:${PORT}`));
1997
+ }
1998
+ }
1999
+ return Array.from(new Set(urls));
2000
+ }
2001
+ function shouldUseColor() {
2002
+ return Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
2003
+ }
2004
+ const terminalStyle = {
2005
+ reset: '\x1b[0m',
2006
+ bold: '\x1b[1m',
2007
+ dim: '\x1b[2m',
2008
+ green: '\x1b[32m',
2009
+ cyan: '\x1b[36m',
2010
+ yellow: '\x1b[33m',
2011
+ };
2012
+ function colorize(text, ...codes) {
2013
+ if (!shouldUseColor())
2014
+ return text;
2015
+ return `${codes.join('')}${text}${terminalStyle.reset}`;
2016
+ }
2017
+ function printLine(label, value, emphasis = false) {
2018
+ const labelText = colorize(label.padEnd(8), terminalStyle.dim);
2019
+ const valueText = emphasis ? colorize(value, terminalStyle.bold, terminalStyle.cyan) : value;
2020
+ console.log(` ${labelText} ${valueText}`);
2021
+ }
2022
+ function printSection(title) {
2023
+ console.log(colorize(title.toUpperCase(), terminalStyle.bold, terminalStyle.dim));
2024
+ }
2025
+ function printMacSleepHint() {
2026
+ if (process.platform !== 'darwin')
2027
+ return;
2028
+ printSection('macOS long tasks');
2029
+ console.log(` ${colorize('手机长时间遥控任务前,建议先开启 Mac 防休眠,避免息屏后任务中断。', terminalStyle.yellow)}`);
2030
+ console.log(' System Settings > Battery > Options >');
2031
+ console.log(' 开启“使用电源适配器供电且显示器关闭时,防止自动进入睡眠”。');
2032
+ console.log('');
2033
+ }
2034
+ function printStartupInfo() {
2035
+ if (!IS_NPX_ENTRY) {
2036
+ console.log(`[AgentPilot] Server running on ${withStartupToken(`${httpScheme}://${HOST}:${PORT}`)}`);
2037
+ console.log(`[AgentPilot] WebSocket ready on ${wsScheme}://${HOST}:${PORT}`);
2038
+ if (AUTH_ENABLED)
2039
+ console.log('[AgentPilot] Access token checks are enabled.');
2040
+ return;
2041
+ }
2042
+ const localHost = HOST === '0.0.0.0' || HOST === '::' ? 'localhost' : HOST;
2043
+ const localUrl = withStartupToken(`${httpScheme}://${formatHostForUrl(localHost)}:${PORT}`);
2044
+ const networkUrls = HOST === '0.0.0.0' || HOST === '::' ? getNetworkUrls() : [];
2045
+ const config = cliManager.getConfig();
2046
+ const tokenStatus = AUTH_ENABLED ? 'enabled, required for browser/API/WebSocket access' : 'disabled';
2047
+ console.log('');
2048
+ console.log(colorize('AgentPilot', terminalStyle.bold, terminalStyle.cyan), colorize('READY', terminalStyle.bold, terminalStyle.green));
2049
+ console.log(colorize('Mobile-first remote console for your local agent', terminalStyle.dim));
2050
+ console.log('');
2051
+ if (networkUrls[0]) {
2052
+ printSection('open on mobile');
2053
+ printLine('Mobile', networkUrls[0], true);
2054
+ printLine('Scan', 'use the QR code below');
2055
+ const qr = renderTerminalQr(networkUrls[0], { color: shouldUseColor() });
2056
+ if (qr) {
2057
+ console.log('');
2058
+ console.log(colorize('QR CODE', terminalStyle.bold, terminalStyle.dim));
2059
+ console.log(qr);
2060
+ console.log('');
2061
+ }
2062
+ else {
2063
+ printLine('QR', 'mobile URL is too long to render in the terminal');
2064
+ }
2065
+ for (const url of networkUrls.slice(1, 3)) {
2066
+ printLine('Alt', url);
2067
+ }
2068
+ }
2069
+ else {
2070
+ printSection('open on mobile');
2071
+ printLine('Mobile', 'no LAN address detected; check VPN/network or use --host 0.0.0.0');
2072
+ }
2073
+ console.log('');
2074
+ printSection('local access');
2075
+ printLine('Local', localUrl, true);
2076
+ printLine('Token', tokenStatus, AUTH_ENABLED);
2077
+ console.log('');
2078
+ printSection('runtime');
2079
+ printLine('Workdir', config.workDir);
2080
+ printLine('CLI', `${config.cliCommand} (${config.cliType})`);
2081
+ console.log('');
2082
+ printMacSleepHint();
2083
+ }
2084
+ // Graceful shutdown
2085
+ process.on('SIGINT', () => {
2086
+ console.log('[AgentPilot] Shutting down...');
2087
+ store.closeDb();
2088
+ process.exit(0);
2089
+ });
2090
+ process.on('SIGTERM', () => {
2091
+ console.log('[AgentPilot] Shutting down...');
2092
+ store.closeDb();
2093
+ process.exit(0);
2094
+ });
2095
+ server.listen(PORT, HOST, () => {
2096
+ printStartupInfo();
2097
+ });