cli-link 0.0.5 → 0.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/bin/agentpilot.js +1 -0
  2. package/dist/client/assets/History-D2xDopni.js +4 -0
  3. package/dist/client/assets/ImageViewer-DuegU_fC.js +1 -0
  4. package/dist/client/assets/MarkdownRenderer-CsyizEL3.js +1 -0
  5. package/dist/client/assets/{PageTopBar-C8j-5s_3.js → PageTopBar-CQwjO6Af.js} +1 -1
  6. package/dist/client/assets/Session-B0s5zBGg.js +7 -0
  7. package/dist/client/assets/Settings-CfHFmJdV.js +1 -0
  8. package/dist/client/assets/Workspace-Cfl0mbNE.js +4 -0
  9. package/dist/client/assets/WorkspaceLinkedText-DCVYd9x-.js +2 -0
  10. package/dist/client/assets/c-BIGW1oBm.js +1 -0
  11. package/dist/client/assets/cpp-DIPi6g--.js +1 -0
  12. package/dist/client/assets/csharp-DSvCPggb.js +1 -0
  13. package/dist/client/assets/dart-bE4Kk8sk.js +1 -0
  14. package/dist/client/assets/dotenv-Da5cRb03.js +1 -0
  15. package/dist/client/assets/go-C27-OAKa.js +1 -0
  16. package/dist/client/assets/graphql-pNE0_Gx8.js +1 -0
  17. package/dist/client/assets/groovy-gcz8RCvz.js +1 -0
  18. package/dist/client/assets/index-BCg3ymV3.css +1 -0
  19. package/dist/client/assets/index-CrJqHlc8.js +2 -0
  20. package/dist/client/assets/java-VnEXKtx_.js +148 -0
  21. package/dist/client/assets/jsx-g9-lgVsj.js +1 -0
  22. package/dist/client/assets/kotlin-BdnUsdx6.js +1 -0
  23. package/dist/client/assets/less-B1dDrJ26.js +1 -0
  24. package/dist/client/assets/lua-BaeVxFsk.js +1 -0
  25. package/dist/client/assets/makefile-CHLpvVh8.js +1 -0
  26. package/dist/client/assets/php-BcCyJq-p.js +1 -0
  27. package/dist/client/assets/properties-DTPjHERo.js +1 -0
  28. package/dist/client/assets/ruby-BwImf3Ka.js +1 -0
  29. package/dist/client/assets/rust-B1yitclQ.js +1 -0
  30. package/dist/client/assets/scss-lMagJa-5.js +1 -0
  31. package/dist/client/assets/sql-CRqJ_cUM.js +1 -0
  32. package/dist/client/assets/svelte-B4a9v_or.js +1 -0
  33. package/dist/client/assets/swift-D82vCrfD.js +1 -0
  34. package/dist/client/assets/toml-vGWfd6FD.js +1 -0
  35. package/dist/client/assets/{vendor-icons-CNN4EKVi.js → vendor-icons-CMXJHDEv.js} +125 -65
  36. package/dist/client/assets/vendor-markdown--d-T3AbU.js +37 -0
  37. package/dist/client/assets/{vendor-motion-n6Lx6G4a.js → vendor-motion-D0ZmPdi9.js} +1 -1
  38. package/dist/client/assets/{vendor-react-DSV5aFEg.js → vendor-react-CcDXZHn_.js} +1 -1
  39. package/dist/client/assets/{vendor-virtual-CcftJrIC.js → vendor-virtual-DJI7OicV.js} +1 -1
  40. package/dist/client/assets/vue-DBXACu8K.js +1 -0
  41. package/dist/client/assets/workspace-return-FrQUv7g3.js +1 -0
  42. package/dist/client/index.html +18 -4
  43. package/dist/server/cli-manager.js +151 -26
  44. package/dist/server/codex-history.js +119 -17
  45. package/dist/server/index.js +1051 -65
  46. package/dist/server/store.js +369 -27
  47. package/dist/server/terminal-qr.js +17 -314
  48. package/package.json +5 -3
  49. package/dist/client/assets/History-BxJVDFpN.js +0 -3
  50. package/dist/client/assets/MarkdownRenderer-BO-KS_L1.js +0 -1
  51. package/dist/client/assets/Session-CQFXA2Sr.js +0 -11
  52. package/dist/client/assets/Settings-DYmjRmoN.js +0 -1
  53. package/dist/client/assets/Workspace-D8kv9euM.js +0 -8
  54. package/dist/client/assets/WorkspaceLinkedText-DQyPLk-X.js +0 -2
  55. package/dist/client/assets/code-highlight-CEcsuMpw.js +0 -1
  56. package/dist/client/assets/index-BXT2BylN.css +0 -1
  57. package/dist/client/assets/index-DOgH1Kf3.js +0 -2
  58. package/dist/client/assets/vendor-markdown-BDwu-Ux6.js +0 -35
@@ -3,11 +3,12 @@ import { createServer as createHttpsServer } from 'https';
3
3
  import { WebSocketServer, WebSocket } from 'ws';
4
4
  import { CLIManager } from './cli-manager.js';
5
5
  import { renderTerminalQr } from './terminal-qr.js';
6
+ import { syncCodexHistory } from './codex-history.js';
6
7
  import * as store from './store.js';
7
8
  import * as fs from 'fs';
8
9
  import * as path from 'path';
9
10
  import * as os from 'os';
10
- import { timingSafeEqual } from 'crypto';
11
+ import { createHash, randomUUID, timingSafeEqual } from 'crypto';
11
12
  import { execFileSync } from 'child_process';
12
13
  import { fileURLToPath } from 'url';
13
14
  const PORT = parseInt(process.env.PORT || '3101', 10);
@@ -19,6 +20,11 @@ const MAC_IDLE_SLEEP_PREVENTED = process.env.AGENTPILOT_CAFFEINATE === '1';
19
20
  const AUTH_COOKIE_NAME = 'agentpilot_token';
20
21
  const AUTH_TOKEN = (process.env.AGENTPILOT_AUTH_TOKEN || '').trim();
21
22
  const AUTH_ENABLED = AUTH_TOKEN.length > 0;
23
+ const PACKAGE_VERSION = (process.env.AGENTPILOT_PACKAGE_VERSION || '').trim();
24
+ const AUTH_REQUIRED_MESSAGE = '访问 token 缺失或无效,请使用启动终端打印的链接重新打开。';
25
+ const SHARE_TITLE = 'AgentPilot - AI Agent 遥控器';
26
+ const SHARE_DESCRIPTION = '在手机浏览器里遥控本机 Claude Code CLI 和 Codex CLI:发任务、看输出、审确认、切换工作目录、查看代码和历史。';
27
+ const SHARE_IMAGE_PATH = '/icons/icon-512.png';
22
28
  function resolveEnvPath(value) {
23
29
  if (value === '~')
24
30
  return os.homedir();
@@ -99,6 +105,119 @@ server.on('upgrade', (req, socket, head) => {
99
105
  });
100
106
  const cliManager = new CLIManager();
101
107
  const clients = new Set();
108
+ let promptQueue = [];
109
+ let promptQueuePaused = false;
110
+ let promptQueueIdCounter = 0;
111
+ let promptQueueDispatching = false;
112
+ let promptQueueHoldUntil = 0;
113
+ let promptQueueDrainTimer = null;
114
+ const PROMPT_QUEUE_IDLE_SETTLE_MS = 800;
115
+ function getPromptQueuePayload() {
116
+ return {
117
+ promptQueue: promptQueue.map(item => ({ ...item })),
118
+ promptQueuePaused,
119
+ };
120
+ }
121
+ function broadcastPromptQueueChanged() {
122
+ broadcast({
123
+ type: 'prompt_queue_changed',
124
+ ...getPromptQueuePayload(),
125
+ time: now(),
126
+ });
127
+ }
128
+ function clearPromptQueue() {
129
+ promptQueue = [];
130
+ promptQueuePaused = false;
131
+ promptQueueDispatching = false;
132
+ promptQueueHoldUntil = 0;
133
+ if (promptQueueDrainTimer) {
134
+ clearTimeout(promptQueueDrainTimer);
135
+ promptQueueDrainTimer = null;
136
+ }
137
+ broadcastPromptQueueChanged();
138
+ }
139
+ function hasPromptPayload(prompt) {
140
+ return !!prompt.content.trim() || prompt.attachments.length > 0;
141
+ }
142
+ function dispatchPromptToCli(prompt) {
143
+ if (!hasPromptPayload(prompt))
144
+ return;
145
+ cliManager.sendInput({
146
+ content: prompt.content,
147
+ attachments: prompt.attachments,
148
+ });
149
+ broadcast({
150
+ type: 'user_message',
151
+ content: prompt.content,
152
+ attachments: prompt.attachments,
153
+ details: prompt.attachments.length > 0 ? { attachments: prompt.attachments } : undefined,
154
+ time: now(),
155
+ });
156
+ }
157
+ function maybeDrainPromptQueue() {
158
+ const holdMs = promptQueueHoldUntil - Date.now();
159
+ if (holdMs > 0) {
160
+ schedulePromptQueueDrain(holdMs);
161
+ return;
162
+ }
163
+ if (promptQueueDispatching ||
164
+ promptQueuePaused ||
165
+ cliManager.status !== 'idle' ||
166
+ promptQueue.length === 0) {
167
+ return;
168
+ }
169
+ const nextPrompt = promptQueue.shift();
170
+ if (!nextPrompt)
171
+ return;
172
+ promptQueueDispatching = true;
173
+ broadcastPromptQueueChanged();
174
+ dispatchPromptToCli(nextPrompt);
175
+ }
176
+ function schedulePromptQueueDrain(delayMs) {
177
+ if (promptQueueDrainTimer)
178
+ clearTimeout(promptQueueDrainTimer);
179
+ promptQueueDrainTimer = setTimeout(() => {
180
+ promptQueueDrainTimer = null;
181
+ maybeDrainPromptQueue();
182
+ }, Math.max(0, delayMs));
183
+ }
184
+ function shouldQueueIncomingPrompt() {
185
+ return (promptQueue.length > 0 ||
186
+ promptQueuePaused ||
187
+ promptQueueDispatching ||
188
+ promptQueueHoldUntil > Date.now() ||
189
+ cliManager.status !== 'idle');
190
+ }
191
+ function enqueuePrompt(prompt) {
192
+ if (!hasPromptPayload(prompt))
193
+ return;
194
+ promptQueueIdCounter += 1;
195
+ promptQueue.push({
196
+ id: `queued-${Date.now()}-${promptQueueIdCounter}`,
197
+ content: prompt.content,
198
+ attachments: prompt.attachments,
199
+ });
200
+ broadcastPromptQueueChanged();
201
+ maybeDrainPromptQueue();
202
+ }
203
+ function removePromptFromQueue(id) {
204
+ const nextQueue = promptQueue.filter(item => item.id !== id);
205
+ if (nextQueue.length === promptQueue.length)
206
+ return;
207
+ promptQueue = nextQueue;
208
+ if (promptQueue.length === 0) {
209
+ promptQueuePaused = false;
210
+ promptQueueDispatching = false;
211
+ }
212
+ broadcastPromptQueueChanged();
213
+ }
214
+ function resumePromptQueue() {
215
+ if (!promptQueuePaused)
216
+ return;
217
+ promptQueuePaused = false;
218
+ broadcastPromptQueueChanged();
219
+ maybeDrainPromptQueue();
220
+ }
102
221
  function unquoteMetaValue(value) {
103
222
  const trimmed = value.trim();
104
223
  if ((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
@@ -233,6 +352,17 @@ function parseJsonField(raw) {
233
352
  return undefined;
234
353
  }
235
354
  }
355
+ function toClientAttachments(raw) {
356
+ try {
357
+ return normalizePromptAttachments(raw, {
358
+ maxBytes: MAX_GENERATED_IMAGE_ATTACHMENT_BYTES,
359
+ maxCount: MAX_GENERATED_IMAGE_ATTACHMENTS_PER_TURN,
360
+ });
361
+ }
362
+ catch {
363
+ return [];
364
+ }
365
+ }
236
366
  function getSessionConfig(session) {
237
367
  const parsed = parseJsonField(session?.cli_config);
238
368
  return parsed && typeof parsed === 'object' ? parsed : {};
@@ -266,6 +396,7 @@ function toClientMessage(m) {
266
396
  return null;
267
397
  const parsedDetails = parseJsonField(m.details);
268
398
  const isQuestion = mappedType === 'question' && m.type === 'ask_question';
399
+ const attachments = toClientAttachments(parsedDetails?.attachments);
269
400
  return {
270
401
  seq: m.seq,
271
402
  id: m.id,
@@ -279,12 +410,64 @@ function toClientMessage(m) {
279
410
  toolResult: m.toolResult || undefined,
280
411
  permission: parseJsonField(m.permission),
281
412
  details: parsedDetails,
413
+ attachments: attachments.length > 0 ? attachments : undefined,
282
414
  question: isQuestion && parsedDetails ? { questions: parsedDetails.questions || [], toolUseId: parsedDetails.toolUseId } : undefined,
283
415
  };
284
416
  }
285
417
  function toClientMessages(messages) {
286
418
  return messages.map(toClientMessage).filter((m) => m !== null);
287
419
  }
420
+ function cleanHistoryPreview(content, maxLength = 96) {
421
+ const normalized = content
422
+ .replace(/```[\s\S]*?```/g, ' 代码片段 ')
423
+ .replace(/`([^`]+)`/g, '$1')
424
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
425
+ .replace(/^#+\s*/gm, '')
426
+ .replace(/[>*_~]/g, ' ')
427
+ .split(/\r?\n/)
428
+ .map(line => line.trim())
429
+ .filter(Boolean)
430
+ .join(' ')
431
+ .replace(/\s+/g, ' ')
432
+ .trim();
433
+ if (!normalized)
434
+ return '';
435
+ return normalized.length > maxLength ? `${normalized.slice(0, maxLength).trimEnd()}...` : normalized;
436
+ }
437
+ function buildHistoryPreview(messages) {
438
+ const recentFirst = [...messages].sort((a, b) => b.seq - a.seq);
439
+ const preferredTypes = new Set(['user', 'ai', 'question', 'confirm', 'error']);
440
+ const fallbackTypes = new Set(['tool', 'system', 'thinking']);
441
+ const labels = {
442
+ user: '你',
443
+ ai: 'AI',
444
+ question: '问题',
445
+ confirm: '确认',
446
+ error: '错误',
447
+ tool: '工具',
448
+ system: '系统',
449
+ thinking: '思考',
450
+ };
451
+ const pick = (allowedTypes) => {
452
+ for (const raw of recentFirst) {
453
+ const msg = toClientMessage(raw);
454
+ if (!msg || !allowedTypes.has(msg.type))
455
+ continue;
456
+ const attachmentCount = Array.isArray(msg.attachments) ? msg.attachments.length : 0;
457
+ const snippet = cleanHistoryPreview(msg.content) || (attachmentCount > 0 ? `图片附件 ${attachmentCount} 张` : '');
458
+ if (!snippet)
459
+ continue;
460
+ const label = labels[msg.type] || '消息';
461
+ return {
462
+ preview: `${label}:${snippet}`,
463
+ lastMessageType: msg.type,
464
+ };
465
+ }
466
+ return {};
467
+ };
468
+ const preferred = pick(preferredTypes);
469
+ return preferred.preview ? preferred : pick(fallbackTypes);
470
+ }
288
471
  function readPositiveInt(value, fallback = 0) {
289
472
  const parsed = Number(value || 0);
290
473
  if (!Number.isFinite(parsed) || parsed <= 0)
@@ -384,11 +567,16 @@ function buildEditPrompt(prefixMessages, editedContent) {
384
567
  }
385
568
  function toClientHistoryTask(t, includeMessages = false) {
386
569
  const cliSessionId = getRestorableCliSessionId(t.session_id, t.messages);
570
+ const summaryMessages = t.messages || (t.session_id ? store.getRecentSessionMessages(t.session_id, 32) : []);
571
+ const summary = buildHistoryPreview(summaryMessages);
387
572
  const response = {
388
573
  id: t.id,
389
574
  workDir: t.work_dir || undefined,
390
575
  status: t.status,
391
576
  title: t.title,
577
+ preview: summary.preview,
578
+ lastMessageType: summary.lastMessageType,
579
+ messageCount: t.messages ? t.messages.length : (t.session_id ? store.getMessageCount(t.session_id) : 0),
392
580
  confirmCount: t.confirm_count,
393
581
  toolCount: t.tool_count,
394
582
  duration: t.duration || undefined,
@@ -409,6 +597,26 @@ function toClientWorkDir(item) {
409
597
  createdAt: item.created_at,
410
598
  };
411
599
  }
600
+ function toClientPromptHistoryItem(item) {
601
+ return {
602
+ id: item.id,
603
+ workDir: item.work_dir || undefined,
604
+ text: item.text,
605
+ useCount: item.use_count,
606
+ lastUsedAt: item.last_used_at,
607
+ createdAt: item.created_at,
608
+ };
609
+ }
610
+ function normalizeHistoryScope(value, fallback = 'global') {
611
+ return value === 'current' || value === 'project' ? 'current' : fallback;
612
+ }
613
+ function getHistoryWorkDirForScope(scope) {
614
+ return scope === 'current' ? getCurrentWorkDir() : null;
615
+ }
616
+ function getCurrentHistoryWorkDir() {
617
+ const workDir = getCurrentWorkDir();
618
+ return typeof workDir === 'string' && workDir.trim() ? workDir : null;
619
+ }
412
620
  function getCurrentWorkDir() {
413
621
  return cliManager.getConfig().workDir;
414
622
  }
@@ -431,7 +639,15 @@ const WORKSPACE_SKIP_NAMES = new Set([
431
639
  ]);
432
640
  const MAX_FILE_BYTES = 512 * 1024;
433
641
  const MAX_DIFF_BYTES = 1024 * 1024;
434
- const MAX_COMMIT_CONTEXT_BYTES = 160 * 1024;
642
+ const MAX_COMMIT_CONTEXT_BYTES = 96 * 1024;
643
+ const MAX_COMMIT_DIFF_BYTES = 64 * 1024;
644
+ const COMMIT_MESSAGE_CACHE_TTL_MS = 5 * 60 * 1000;
645
+ const MAX_IMAGE_ATTACHMENT_BYTES = 10 * 1024 * 1024;
646
+ const MAX_GENERATED_IMAGE_ATTACHMENT_BYTES = 30 * 1024 * 1024;
647
+ const MAX_GENERATED_IMAGE_ATTACHMENTS_PER_TURN = 8;
648
+ const MAX_IMAGE_ATTACHMENTS_PER_PROMPT = 4;
649
+ const ATTACHMENT_ROOT = path.join(os.homedir(), '.agentpilot', 'attachments');
650
+ const CODEX_GENERATED_IMAGES_ROOT = path.join(resolveEnvPath(process.env.CODEX_HOME || '~/.codex'), 'generated_images');
435
651
  const PREVIEW_IMAGE_MIME_BY_EXTENSION = {
436
652
  '.avif': 'image/avif',
437
653
  '.bmp': 'image/bmp',
@@ -443,6 +659,281 @@ const PREVIEW_IMAGE_MIME_BY_EXTENSION = {
443
659
  '.svg': 'image/svg+xml',
444
660
  '.webp': 'image/webp',
445
661
  };
662
+ const ATTACHMENT_EXTENSION_BY_MIME = {
663
+ 'image/avif': 'avif',
664
+ 'image/bmp': 'bmp',
665
+ 'image/gif': 'gif',
666
+ 'image/jpeg': 'jpg',
667
+ 'image/png': 'png',
668
+ 'image/webp': 'webp',
669
+ };
670
+ let generatedImageCapture = null;
671
+ function normalizeImageMime(raw) {
672
+ const mime = typeof raw === 'string' ? raw.trim().toLowerCase() : '';
673
+ return ATTACHMENT_EXTENSION_BY_MIME[mime] ? mime : '';
674
+ }
675
+ function sanitizeAttachmentName(raw, fallback = 'image') {
676
+ const base = path.basename(typeof raw === 'string' && raw.trim() ? raw.trim() : fallback);
677
+ return base.replace(/[\r\n"\\/:*?<>|\x00-\x1F]+/g, '_').slice(0, 120) || fallback;
678
+ }
679
+ function ensureAttachmentRoot() {
680
+ fs.mkdirSync(ATTACHMENT_ROOT, { recursive: true });
681
+ }
682
+ function assertAttachmentPath(rawPath) {
683
+ if (typeof rawPath !== 'string' || !rawPath.trim()) {
684
+ throw new Error('附件路径无效');
685
+ }
686
+ ensureAttachmentRoot();
687
+ const resolvedRoot = fs.realpathSync(ATTACHMENT_ROOT);
688
+ const resolvedPath = path.resolve(rawPath);
689
+ const realPath = fs.realpathSync(resolvedPath);
690
+ if (realPath !== resolvedRoot && !realPath.startsWith(`${resolvedRoot}${path.sep}`)) {
691
+ throw new Error('附件路径不在允许目录内');
692
+ }
693
+ return realPath;
694
+ }
695
+ function normalizePromptAttachments(rawAttachments, options = {}) {
696
+ if (!Array.isArray(rawAttachments))
697
+ return [];
698
+ const maxBytes = options.maxBytes ?? MAX_IMAGE_ATTACHMENT_BYTES;
699
+ const maxCount = options.maxCount ?? MAX_IMAGE_ATTACHMENTS_PER_PROMPT;
700
+ return rawAttachments.slice(0, maxCount).map((raw, index) => {
701
+ if (!raw || typeof raw !== 'object') {
702
+ throw new Error('附件格式无效');
703
+ }
704
+ const item = raw;
705
+ const filePath = assertAttachmentPath(item.path);
706
+ const stat = fs.statSync(filePath);
707
+ if (!stat.isFile())
708
+ throw new Error('附件不是文件');
709
+ if (stat.size > maxBytes)
710
+ throw new Error('图片附件过大');
711
+ const mimeType = normalizeImageMime(item.mimeType) || getPreviewImageMime(filePath) || '';
712
+ if (!mimeType || !ATTACHMENT_EXTENSION_BY_MIME[mimeType]) {
713
+ throw new Error('仅支持图片附件');
714
+ }
715
+ return {
716
+ id: typeof item.id === 'string' && item.id ? item.id : `attachment-${index + 1}`,
717
+ type: 'image',
718
+ name: sanitizeAttachmentName(item.name, path.basename(filePath)),
719
+ mimeType,
720
+ size: stat.size,
721
+ path: filePath,
722
+ createdAt: typeof item.createdAt === 'number' && Number.isFinite(item.createdAt) ? item.createdAt : stat.mtimeMs,
723
+ };
724
+ });
725
+ }
726
+ function normalizePromptPayload(raw) {
727
+ const content = typeof raw === 'string'
728
+ ? raw
729
+ : typeof raw?.content === 'string'
730
+ ? raw.content
731
+ : '';
732
+ const attachments = normalizePromptAttachments(typeof raw === 'object' && raw ? raw.attachments : undefined);
733
+ return { content, attachments };
734
+ }
735
+ function parseImageUploadBody(body) {
736
+ const dataUrl = typeof body?.dataUrl === 'string' ? body.dataUrl : '';
737
+ const dataUrlMatch = dataUrl.match(/^data:([^;,]+);base64,(.*)$/s);
738
+ const name = sanitizeAttachmentName(body?.name, 'image');
739
+ const mimeType = normalizeImageMime(body?.mimeType || body?.type || dataUrlMatch?.[1])
740
+ || normalizeImageMime(getPreviewImageMime(name));
741
+ const base64 = typeof body?.data === 'string' ? body.data : dataUrlMatch?.[2] || '';
742
+ if (!mimeType)
743
+ throw new Error('仅支持图片附件');
744
+ if (!base64)
745
+ throw new Error('图片数据为空');
746
+ const buffer = Buffer.from(base64, 'base64');
747
+ if (buffer.length === 0)
748
+ throw new Error('图片数据为空');
749
+ if (buffer.length > MAX_IMAGE_ATTACHMENT_BYTES)
750
+ throw new Error('图片不能超过 10MB');
751
+ return {
752
+ buffer,
753
+ mimeType,
754
+ name,
755
+ };
756
+ }
757
+ function saveImageAttachment(body) {
758
+ const { buffer, mimeType, name } = parseImageUploadBody(body);
759
+ const sessionDir = currentSessionId || 'draft';
760
+ const dir = path.join(ATTACHMENT_ROOT, sessionDir);
761
+ fs.mkdirSync(dir, { recursive: true });
762
+ const id = randomUUID();
763
+ const ext = ATTACHMENT_EXTENSION_BY_MIME[mimeType];
764
+ const filePath = path.join(dir, `${Date.now()}-${id}.${ext}`);
765
+ fs.writeFileSync(filePath, buffer, { flag: 'wx' });
766
+ return {
767
+ id,
768
+ type: 'image',
769
+ name,
770
+ mimeType,
771
+ size: buffer.length,
772
+ path: filePath,
773
+ createdAt: Date.now(),
774
+ };
775
+ }
776
+ function listGeneratedImageFiles() {
777
+ const files = [];
778
+ function walk(dir, depth) {
779
+ if (depth > 4)
780
+ return;
781
+ let entries;
782
+ try {
783
+ entries = fs.readdirSync(dir, { withFileTypes: true });
784
+ }
785
+ catch {
786
+ return;
787
+ }
788
+ for (const entry of entries) {
789
+ const entryPath = path.join(dir, entry.name);
790
+ if (entry.isDirectory()) {
791
+ walk(entryPath, depth + 1);
792
+ continue;
793
+ }
794
+ if (!entry.isFile())
795
+ continue;
796
+ const mimeType = getPreviewImageMime(entryPath);
797
+ if (mimeType && ATTACHMENT_EXTENSION_BY_MIME[mimeType]) {
798
+ files.push(path.resolve(entryPath));
799
+ }
800
+ }
801
+ }
802
+ walk(CODEX_GENERATED_IMAGES_ROOT, 0);
803
+ return files;
804
+ }
805
+ function maybeStartGeneratedImageCapture() {
806
+ if (generatedImageCapture)
807
+ return;
808
+ if (cliManager.getConfig().cliType !== 'codex')
809
+ return;
810
+ generatedImageCapture = {
811
+ startedAt: Date.now(),
812
+ baseline: new Set(listGeneratedImageFiles()),
813
+ };
814
+ }
815
+ function copyGeneratedImageToAttachment(sourcePath, index) {
816
+ const mimeType = getPreviewImageMime(sourcePath);
817
+ if (!mimeType || !ATTACHMENT_EXTENSION_BY_MIME[mimeType])
818
+ return null;
819
+ let sourceStat;
820
+ try {
821
+ sourceStat = fs.statSync(sourcePath);
822
+ }
823
+ catch {
824
+ return null;
825
+ }
826
+ if (!sourceStat.isFile() || sourceStat.size <= 0 || sourceStat.size > MAX_GENERATED_IMAGE_ATTACHMENT_BYTES) {
827
+ return null;
828
+ }
829
+ const id = randomUUID();
830
+ const ext = ATTACHMENT_EXTENSION_BY_MIME[mimeType];
831
+ const dir = path.join(ATTACHMENT_ROOT, currentSessionId || 'generated', 'generated');
832
+ fs.mkdirSync(dir, { recursive: true });
833
+ const filePath = path.join(dir, `${Date.now()}-${id}.${ext}`);
834
+ try {
835
+ fs.copyFileSync(sourcePath, filePath, fs.constants.COPYFILE_EXCL);
836
+ }
837
+ catch (err) {
838
+ console.warn(`[Attachments] Failed to copy generated image ${sourcePath}: ${err?.message || err}`);
839
+ return null;
840
+ }
841
+ const stat = fs.statSync(filePath);
842
+ return {
843
+ id,
844
+ type: 'image',
845
+ name: `generated-image-${index + 1}.${ext}`,
846
+ mimeType,
847
+ size: stat.size,
848
+ path: filePath,
849
+ createdAt: Date.now(),
850
+ };
851
+ }
852
+ function collectGeneratedImageAttachments(finish) {
853
+ const capture = generatedImageCapture;
854
+ if (!capture)
855
+ return [];
856
+ const minMtime = capture.startedAt - 5000;
857
+ const candidates = listGeneratedImageFiles()
858
+ .map((filePath) => {
859
+ try {
860
+ return { filePath, stat: fs.statSync(filePath) };
861
+ }
862
+ catch {
863
+ return null;
864
+ }
865
+ })
866
+ .filter((item) => !!item &&
867
+ item.stat.isFile() &&
868
+ item.stat.size > 0 &&
869
+ item.stat.size <= MAX_GENERATED_IMAGE_ATTACHMENT_BYTES &&
870
+ !capture.baseline.has(item.filePath) &&
871
+ item.stat.mtimeMs >= minMtime)
872
+ .sort((a, b) => a.stat.mtimeMs - b.stat.mtimeMs)
873
+ .slice(-MAX_GENERATED_IMAGE_ATTACHMENTS_PER_TURN);
874
+ const attachments = candidates
875
+ .map((candidate, index) => copyGeneratedImageToAttachment(candidate.filePath, index))
876
+ .filter((item) => item !== null);
877
+ if (attachments.length > 0 || finish) {
878
+ generatedImageCapture = null;
879
+ }
880
+ return attachments;
881
+ }
882
+ function attachGeneratedImagesToEvent(event) {
883
+ if (event.type !== 'system' || event.details?.subtype !== 'result')
884
+ return [];
885
+ const attachments = collectGeneratedImageAttachments(false);
886
+ if (attachments.length === 0)
887
+ return [];
888
+ event.details = {
889
+ ...(event.details || {}),
890
+ attachments: [
891
+ ...toClientAttachments(event.details?.attachments),
892
+ ...attachments,
893
+ ],
894
+ };
895
+ event.attachments = event.details.attachments;
896
+ return attachments;
897
+ }
898
+ function emitGeneratedImagesFallback() {
899
+ const attachments = collectGeneratedImageAttachments(true);
900
+ if (attachments.length === 0)
901
+ return;
902
+ broadcast({
903
+ type: 'system',
904
+ content: '生成图片',
905
+ time: now(),
906
+ details: {
907
+ subtype: 'result',
908
+ attachments,
909
+ },
910
+ attachments,
911
+ });
912
+ }
913
+ function sendAttachmentFile(req, res, rawPath) {
914
+ const filePath = assertAttachmentPath(rawPath);
915
+ const stat = fs.statSync(filePath);
916
+ if (!stat.isFile()) {
917
+ sendJson(res, 404, { error: '附件不存在' });
918
+ return;
919
+ }
920
+ const mimeType = getPreviewImageMime(filePath);
921
+ if (!mimeType || !ATTACHMENT_EXTENSION_BY_MIME[mimeType]) {
922
+ sendJson(res, 415, { error: '仅支持图片附件预览' });
923
+ return;
924
+ }
925
+ setCorsHeaders(res);
926
+ res.writeHead(200, {
927
+ 'Content-Type': mimeType,
928
+ 'Content-Length': String(stat.size),
929
+ 'Cache-Control': 'private, max-age=3600',
930
+ });
931
+ if (req.method === 'HEAD') {
932
+ res.end();
933
+ return;
934
+ }
935
+ fs.createReadStream(filePath).pipe(res);
936
+ }
446
937
  function decodeGitOctalPath(raw) {
447
938
  return raw.replace(/(?:\\[0-7]{3})+/g, (sequence) => {
448
939
  const bytes = sequence.match(/\\([0-7]{3})/g)?.map(item => parseInt(item.slice(1), 8)) || [];
@@ -616,6 +1107,28 @@ function runGit(args, allowExitCodes = [0]) {
616
1107
  throw new Error(stderr || err?.message || 'Git 命令执行失败');
617
1108
  }
618
1109
  }
1110
+ function getCurrentGitInfo() {
1111
+ try {
1112
+ const insideWorkTree = runGit(['rev-parse', '--is-inside-work-tree'], [0, 128]).trim() === 'true';
1113
+ if (!insideWorkTree)
1114
+ return { isGitRepo: false, gitBranch: '' };
1115
+ const branch = runGit(['rev-parse', '--abbrev-ref', 'HEAD'], [0, 128]).trim();
1116
+ if (branch && branch !== 'HEAD') {
1117
+ return { isGitRepo: true, gitBranch: branch };
1118
+ }
1119
+ const shortHash = runGit(['rev-parse', '--short', 'HEAD'], [0, 128]).trim();
1120
+ return { isGitRepo: true, gitBranch: shortHash ? `HEAD ${shortHash}` : branch || 'HEAD' };
1121
+ }
1122
+ catch {
1123
+ return { isGitRepo: false, gitBranch: '' };
1124
+ }
1125
+ }
1126
+ function getClientConfig() {
1127
+ return {
1128
+ ...cliManager.getConfig(),
1129
+ ...getCurrentGitInfo(),
1130
+ };
1131
+ }
619
1132
  function unquoteGitPath(raw) {
620
1133
  const trimmed = raw.trim();
621
1134
  if (!trimmed.startsWith('"') || !trimmed.endsWith('"'))
@@ -658,6 +1171,8 @@ function parseGitStatus(output, nulTerminated = false) {
658
1171
  const indexStatus = line[0] || ' ';
659
1172
  const worktreeStatus = line[1] || ' ';
660
1173
  const filePath = line.slice(3);
1174
+ const isMoved = indexStatus === 'R' || indexStatus === 'C' || worktreeStatus === 'R' || worktreeStatus === 'C';
1175
+ const oldPath = isMoved ? records[index + 1] : undefined;
661
1176
  files.push({
662
1177
  path: filePath,
663
1178
  name: path.basename(filePath),
@@ -665,8 +1180,9 @@ function parseGitStatus(output, nulTerminated = false) {
665
1180
  indexStatus,
666
1181
  worktreeStatus,
667
1182
  label: describeGitStatus(indexStatus, worktreeStatus),
1183
+ oldPath,
668
1184
  });
669
- if (indexStatus === 'R' || indexStatus === 'C' || worktreeStatus === 'R' || worktreeStatus === 'C') {
1185
+ if (isMoved) {
670
1186
  index += 1;
671
1187
  }
672
1188
  }
@@ -678,7 +1194,9 @@ function parseGitStatus(output, nulTerminated = false) {
678
1194
  const indexStatus = line[0] || ' ';
679
1195
  const worktreeStatus = line[1] || ' ';
680
1196
  const rawPath = line.slice(3);
681
- const displayPath = rawPath.includes(' -> ') ? rawPath.split(' -> ').pop() || rawPath : rawPath;
1197
+ const movedParts = rawPath.includes(' -> ') ? rawPath.split(' -> ') : [];
1198
+ const displayPath = movedParts.length > 0 ? movedParts[movedParts.length - 1] || rawPath : rawPath;
1199
+ const oldPath = movedParts.length > 1 ? unquoteGitPath(movedParts[0] || '') : undefined;
682
1200
  const filePath = unquoteGitPath(displayPath);
683
1201
  return {
684
1202
  path: filePath,
@@ -687,9 +1205,88 @@ function parseGitStatus(output, nulTerminated = false) {
687
1205
  indexStatus,
688
1206
  worktreeStatus,
689
1207
  label: describeGitStatus(indexStatus, worktreeStatus),
1208
+ oldPath,
690
1209
  };
691
1210
  }).filter((item) => item !== null);
692
1211
  }
1212
+ function emptyChangeStats() {
1213
+ return { additions: 0, deletions: 0 };
1214
+ }
1215
+ function normalizeGitDiffPath(raw) {
1216
+ const trimmed = raw.trim();
1217
+ if (!trimmed || trimmed === '/dev/null')
1218
+ return '';
1219
+ return unquoteGitPath(trimmed.replace(/^[ab]\//, ''));
1220
+ }
1221
+ function rememberDiffStats(map, filePath) {
1222
+ const normalized = normalizeGitDiffPath(filePath);
1223
+ if (!normalized)
1224
+ return null;
1225
+ let stats = map.get(normalized);
1226
+ if (!stats) {
1227
+ stats = emptyChangeStats();
1228
+ map.set(normalized, stats);
1229
+ }
1230
+ return stats;
1231
+ }
1232
+ function parseDiffStats(diff) {
1233
+ const statsByPath = new Map();
1234
+ let currentStats = null;
1235
+ for (const line of diff.split('\n')) {
1236
+ if (line.startsWith('diff --git ')) {
1237
+ const parts = line.split(' ');
1238
+ currentStats = rememberDiffStats(statsByPath, parts[3] || parts[2] || '');
1239
+ continue;
1240
+ }
1241
+ if (line.startsWith('+++ ')) {
1242
+ const nextPath = normalizeGitDiffPath(line.slice(4));
1243
+ if (nextPath)
1244
+ currentStats = rememberDiffStats(statsByPath, nextPath);
1245
+ continue;
1246
+ }
1247
+ if (!currentStats)
1248
+ continue;
1249
+ if (line.startsWith('--- '))
1250
+ continue;
1251
+ if (line.startsWith('+')) {
1252
+ currentStats.additions += 1;
1253
+ }
1254
+ else if (line.startsWith('-')) {
1255
+ currentStats.deletions += 1;
1256
+ }
1257
+ }
1258
+ return statsByPath;
1259
+ }
1260
+ function mergeStats(left, right) {
1261
+ return {
1262
+ additions: left.additions + right.additions,
1263
+ deletions: left.deletions + right.deletions,
1264
+ };
1265
+ }
1266
+ function countSmallTextFileLines(rawPath) {
1267
+ try {
1268
+ const { target } = resolveWorkspacePath(rawPath, true);
1269
+ const stat = fs.statSync(target);
1270
+ if (!stat.isFile() || stat.size > MAX_FILE_BYTES)
1271
+ return null;
1272
+ const buffer = fs.readFileSync(target);
1273
+ if (buffer.includes(0))
1274
+ return null;
1275
+ const text = buffer.toString('utf8');
1276
+ if (!text)
1277
+ return 0;
1278
+ const normalized = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
1279
+ return normalized.endsWith('\n')
1280
+ ? normalized.split('\n').length - 1
1281
+ : normalized.split('\n').length;
1282
+ }
1283
+ catch {
1284
+ return null;
1285
+ }
1286
+ }
1287
+ function sumStats(values) {
1288
+ return values.reduce((sum, stats) => mergeStats(sum, stats), emptyChangeStats());
1289
+ }
693
1290
  function truncateText(text, maxBytes) {
694
1291
  const buffer = Buffer.from(text, 'utf8');
695
1292
  if (buffer.length <= maxBytes) {
@@ -850,8 +1447,23 @@ function getWorkspaceChanges(rawPath) {
850
1447
  isGitRepo: false,
851
1448
  branch: '',
852
1449
  files: [],
1450
+ summary: {
1451
+ fileCount: 0,
1452
+ stagedFileCount: 0,
1453
+ unstagedFileCount: 0,
1454
+ additions: 0,
1455
+ deletions: 0,
1456
+ stagedAdditions: 0,
1457
+ stagedDeletions: 0,
1458
+ unstagedAdditions: 0,
1459
+ unstagedDeletions: 0,
1460
+ },
853
1461
  diff: '',
1462
+ stagedDiff: '',
1463
+ unstagedDiff: '',
854
1464
  truncated: false,
1465
+ stagedTruncated: false,
1466
+ unstagedTruncated: false,
855
1467
  };
856
1468
  }
857
1469
  const selectedPath = normalizeWorkspacePath(rawPath);
@@ -860,36 +1472,84 @@ function getWorkspaceChanges(rawPath) {
860
1472
  }
861
1473
  const branch = runGit(['rev-parse', '--abbrev-ref', 'HEAD']).trim();
862
1474
  const statusOutput = runGit(['status', '--porcelain=v1', '-z', '--untracked-files=all', '--', '.']);
863
- const files = parseGitStatus(statusOutput, true);
1475
+ const rawFiles = parseGitStatus(statusOutput, true);
864
1476
  const diffTarget = selectedPath || '.';
865
1477
  const staged = runGit(['diff', '--cached', '--', diffTarget]);
866
- const unstaged = runGit(['diff', '--', diffTarget]);
1478
+ let unstaged = runGit(['diff', '--', diffTarget]);
867
1479
  const parts = [];
868
1480
  if (staged.trim())
869
1481
  parts.push(`--- 已暂存变更 ---\n${staged}`);
870
1482
  if (unstaged.trim())
871
1483
  parts.push(`--- 未暂存变更 ---\n${unstaged}`);
872
1484
  if (selectedPath && parts.length === 0) {
873
- const selected = files.find(file => file.path === selectedPath);
1485
+ const selected = rawFiles.find(file => file.path === selectedPath);
874
1486
  if (selected?.status === '??') {
875
1487
  const { target } = resolveWorkspacePath(selectedPath, true);
876
1488
  const stat = fs.statSync(target);
877
1489
  if (stat.isFile() && stat.size <= MAX_FILE_BYTES) {
878
1490
  const untrackedDiff = runGit(['diff', '--no-index', '--', '/dev/null', target], [0, 1]);
879
- if (untrackedDiff.trim())
880
- parts.push(untrackedDiff);
1491
+ if (untrackedDiff.trim()) {
1492
+ unstaged = untrackedDiff;
1493
+ parts.push(`--- 未暂存变更 ---\n${untrackedDiff}`);
1494
+ }
881
1495
  }
882
1496
  }
883
1497
  }
1498
+ const stagedDiffResult = truncateText(staged, MAX_DIFF_BYTES);
1499
+ const unstagedDiffResult = truncateText(unstaged, MAX_DIFF_BYTES);
1500
+ const stagedStatsByPath = parseDiffStats(staged);
1501
+ const unstagedStatsByPath = parseDiffStats(unstaged);
1502
+ const files = rawFiles.map((file) => {
1503
+ const stagedStats = stagedStatsByPath.get(file.path) || emptyChangeStats();
1504
+ let unstagedStats = unstagedStatsByPath.get(file.path) || emptyChangeStats();
1505
+ if (file.status === '??' && unstagedStats.additions === 0 && unstagedStats.deletions === 0) {
1506
+ const additions = countSmallTextFileLines(file.path);
1507
+ if (additions != null) {
1508
+ unstagedStats = { additions, deletions: 0 };
1509
+ }
1510
+ }
1511
+ const totalStats = mergeStats(stagedStats, unstagedStats);
1512
+ const hasStaged = file.indexStatus !== ' ' && file.indexStatus !== '?' && file.indexStatus !== '!';
1513
+ const hasUnstaged = file.status === '??' || (file.worktreeStatus !== ' ' && file.worktreeStatus !== '?' && file.worktreeStatus !== '!');
1514
+ return {
1515
+ ...file,
1516
+ additions: totalStats.additions,
1517
+ deletions: totalStats.deletions,
1518
+ stagedAdditions: stagedStats.additions,
1519
+ stagedDeletions: stagedStats.deletions,
1520
+ unstagedAdditions: unstagedStats.additions,
1521
+ unstagedDeletions: unstagedStats.deletions,
1522
+ hasStaged,
1523
+ hasUnstaged,
1524
+ };
1525
+ });
1526
+ const stagedTotals = sumStats(files.map(file => ({ additions: file.stagedAdditions, deletions: file.stagedDeletions })));
1527
+ const unstagedTotals = sumStats(files.map(file => ({ additions: file.unstagedAdditions, deletions: file.unstagedDeletions })));
1528
+ const totalStats = mergeStats(stagedTotals, unstagedTotals);
884
1529
  const diffResult = truncateText(parts.join('\n'), MAX_DIFF_BYTES);
885
1530
  return {
886
1531
  workDir: path.resolve(getCurrentWorkDir()),
887
1532
  isGitRepo: true,
888
1533
  branch,
889
1534
  files,
1535
+ summary: {
1536
+ fileCount: files.length,
1537
+ stagedFileCount: files.filter(file => file.hasStaged).length,
1538
+ unstagedFileCount: files.filter(file => file.hasUnstaged).length,
1539
+ additions: totalStats.additions,
1540
+ deletions: totalStats.deletions,
1541
+ stagedAdditions: stagedTotals.additions,
1542
+ stagedDeletions: stagedTotals.deletions,
1543
+ unstagedAdditions: unstagedTotals.additions,
1544
+ unstagedDeletions: unstagedTotals.deletions,
1545
+ },
890
1546
  diff: diffResult.text,
1547
+ stagedDiff: stagedDiffResult.text,
1548
+ unstagedDiff: unstagedDiffResult.text,
891
1549
  diffPath: selectedPath || undefined,
892
1550
  truncated: diffResult.truncated,
1551
+ stagedTruncated: stagedDiffResult.truncated,
1552
+ unstagedTruncated: unstagedDiffResult.truncated,
893
1553
  };
894
1554
  }
895
1555
  function trackWorkspaceFile(rawPath) {
@@ -967,6 +1627,41 @@ function unstageWorkspacePath(rawPath) {
967
1627
  unstaged: true,
968
1628
  };
969
1629
  }
1630
+ function isSameOrNestedWorkspacePath(pathname, targetPath) {
1631
+ return !!pathname && (pathname === targetPath || pathname.startsWith(`${targetPath}/`));
1632
+ }
1633
+ function discardWorkspaceChanges(rawPath) {
1634
+ ensureWorkspaceGitRepo();
1635
+ const selectedPath = normalizeWorkspacePath(rawPath);
1636
+ if (selectedPath) {
1637
+ resolveWorkspacePath(selectedPath, false);
1638
+ }
1639
+ const allStatusOutput = runGit(['status', '--porcelain=v1', '-z', '--untracked-files=all', '--', '.']);
1640
+ const allFiles = parseGitStatus(allStatusOutput, true);
1641
+ const files = selectedPath
1642
+ ? allFiles.filter(file => isSameOrNestedWorkspacePath(file.path, selectedPath) ||
1643
+ isSameOrNestedWorkspacePath(file.oldPath, selectedPath))
1644
+ : allFiles;
1645
+ if (files.length === 0) {
1646
+ throw new Error(selectedPath ? '文件没有可清空的变更' : '没有可清空的代码变更');
1647
+ }
1648
+ const trackedFiles = files.filter(file => file.status !== '??');
1649
+ if (trackedFiles.length > 0) {
1650
+ const targets = selectedPath
1651
+ ? Array.from(new Set(trackedFiles.flatMap(file => [file.path, file.oldPath].filter(Boolean))))
1652
+ : ['.'];
1653
+ runGit(['restore', '--source=HEAD', '--staged', '--worktree', '--', ...targets]);
1654
+ }
1655
+ const hasUntrackedFiles = files.some(file => file.status === '??');
1656
+ if (hasUntrackedFiles) {
1657
+ runGit(['clean', '-fd', '--', selectedPath || '.']);
1658
+ }
1659
+ return {
1660
+ workDir: path.resolve(getCurrentWorkDir()),
1661
+ path: selectedPath || undefined,
1662
+ discarded: true,
1663
+ };
1664
+ }
970
1665
  function ensureWorkspaceGitRepo() {
971
1666
  resolveWorkspacePath('', true);
972
1667
  try {
@@ -988,52 +1683,98 @@ function normalizeAICommitMessage(rawMessage) {
988
1683
  message = message.replace(/^["'“”‘’]+|["'“”‘’]+$/g, '').trim();
989
1684
  return message.split('\n').map(line => line.replace(/^\s*[-*]\s+/, '').trimEnd()).join('\n').trim();
990
1685
  }
1686
+ const commitMessageCache = new Map();
1687
+ function hashText(value) {
1688
+ return createHash('sha256').update(value).digest('hex');
1689
+ }
1690
+ function getCommitMessageCacheKey(workDir, diffHash) {
1691
+ return `${workDir}\0${diffHash}`;
1692
+ }
1693
+ function getCachedCommitMessage(cacheKey) {
1694
+ const entry = commitMessageCache.get(cacheKey);
1695
+ if (!entry)
1696
+ return null;
1697
+ if (Date.now() - entry.createdAt > COMMIT_MESSAGE_CACHE_TTL_MS) {
1698
+ commitMessageCache.delete(cacheKey);
1699
+ return null;
1700
+ }
1701
+ return entry.message;
1702
+ }
1703
+ function setCachedCommitMessage(cacheKey, message) {
1704
+ commitMessageCache.set(cacheKey, {
1705
+ message,
1706
+ createdAt: Date.now(),
1707
+ });
1708
+ }
991
1709
  function buildCommitMessagePrompt() {
992
1710
  ensureWorkspaceGitRepo();
993
- const statusOutput = runGit(['status', '--porcelain=v1', '-z', '--untracked-files=all', '--', '.']);
994
- const files = parseGitStatus(statusOutput, true);
995
- const statusText = files.map(file => `${file.status} ${file.path}`).join('\n');
996
- if (files.length === 0) {
997
- throw new Error('没有可提交的代码变更');
998
- }
999
- const stagedFiles = runGit(['diff', '--cached', '--name-only']);
1000
- if (!stagedFiles.trim()) {
1711
+ const workDir = path.resolve(getCurrentWorkDir());
1712
+ const branch = runGit(['rev-parse', '--abbrev-ref', 'HEAD']).trim();
1713
+ const stagedNameStatus = runGit(['diff', '--cached', '--name-status', '--find-renames']).trim();
1714
+ if (!stagedNameStatus) {
1001
1715
  throw new Error('没有已暂存的代码变更,请先暂存要提交的文件');
1002
1716
  }
1003
- const recentCommits = runGit(['log', '--oneline', '-8'], [0, 128]).trim();
1004
- const stagedStat = runGit(['diff', '--cached', '--stat']);
1005
- const stagedDiff = runGit(['diff', '--cached']);
1717
+ const recentCommits = runGit(['log', '--oneline', '-5'], [0, 128]).trim();
1718
+ const stagedNumstat = runGit(['diff', '--cached', '--numstat', '--find-renames']).trim();
1719
+ const stagedStat = runGit(['diff', '--cached', '--stat']).trim();
1720
+ const stagedSummary = runGit(['diff', '--cached', '--summary', '--find-renames']).trim();
1721
+ const stagedDiffForHash = runGit(['diff', '--cached', '--full-index', '--binary']);
1722
+ const stagedDiff = runGit(['diff', '--cached', '--find-renames', '--unified=3']);
1723
+ const diffHash = hashText(stagedDiffForHash || stagedDiff || stagedNameStatus);
1724
+ const diffContext = truncateText(stagedDiff.trim(), MAX_COMMIT_DIFF_BYTES);
1006
1725
  const context = truncateText([
1007
- `当前分支:${runGit(['rev-parse', '--abbrev-ref', 'HEAD']).trim()}`,
1008
- recentCommits ? `最近提交:\n${recentCommits}` : '',
1009
- `状态:\n${statusText}`,
1010
- stagedStat.trim() ? `已暂存统计:\n${stagedStat.trim()}` : '',
1011
- stagedDiff.trim() ? `已暂存 diff:\n${stagedDiff}` : '',
1726
+ `当前分支:${branch}`,
1727
+ recentCommits ? `最近提交风格:\n${recentCommits}` : '',
1728
+ `已暂存文件:\n${stagedNameStatus}`,
1729
+ stagedNumstat ? `已暂存增删行:\n${stagedNumstat}` : '',
1730
+ stagedStat ? `已暂存统计:\n${stagedStat}` : '',
1731
+ stagedSummary ? `已暂存摘要:\n${stagedSummary}` : '',
1732
+ diffContext.text ? `已暂存 diff${diffContext.truncated ? ' 片段' : ''}:\n${diffContext.text}` : '',
1012
1733
  ].filter(Boolean).join('\n\n'), MAX_COMMIT_CONTEXT_BYTES);
1013
- return [
1014
- '请基于下面的 Git 变更生成一条提交信息。',
1734
+ const prompt = [
1735
+ '你是 commit message 生成器。只基于下面的“已暂存”Git 上下文生成一条提交信息。',
1015
1736
  '要求:',
1737
+ '- 不要调用命令或工具,不要读取文件。',
1016
1738
  '- 只输出 commit message,不要解释,不要 Markdown 代码块。',
1739
+ '- 只描述已暂存变更,不要描述未暂存或未跟踪内容。',
1017
1740
  '- 使用 Conventional Commits:<type>(<scope>): <subject> 或 <type>: <subject>。',
1018
1741
  '- type 优先使用 feat/fix/docs/refactor/test/chore/style/build/ci/perf。',
1019
- '- subject 简短、具体、动词开头,使用英文小写;必要时可以补充简短 body。',
1742
+ '- scope 优先从文件路径、模块名和最近提交风格判断。',
1743
+ '- subject 简短、具体、英文小写,尽量不超过 72 个字符;必要时可以补充 1-3 行 body。',
1020
1744
  '- 不要提到 AI、不要提到你无法验证的内容。',
1021
1745
  '',
1022
- context.truncated ? '注意:变更上下文已截断。' : '',
1746
+ context.truncated || diffContext.truncated ? '注意:变更上下文已截断,请优先依据文件列表、统计和可见 diff。' : '',
1023
1747
  '已暂存 Git 变更上下文:',
1024
1748
  context.text,
1025
1749
  ].filter(Boolean).join('\n');
1750
+ return {
1751
+ prompt,
1752
+ cacheKey: getCommitMessageCacheKey(workDir, diffHash),
1753
+ diffHash,
1754
+ };
1026
1755
  }
1027
1756
  async function generateWorkspaceCommitMessage() {
1028
- const prompt = buildCommitMessagePrompt();
1029
- const output = await cliManager.runOneShot(prompt, 120000);
1757
+ const payload = buildCommitMessagePrompt();
1758
+ const cachedMessage = getCachedCommitMessage(payload.cacheKey);
1759
+ if (cachedMessage) {
1760
+ return {
1761
+ workDir: path.resolve(getCurrentWorkDir()),
1762
+ message: cachedMessage,
1763
+ cached: true,
1764
+ diffHash: payload.diffHash,
1765
+ };
1766
+ }
1767
+ const output = await cliManager.runOneShot(payload.prompt, 60000);
1030
1768
  const message = normalizeAICommitMessage(output);
1031
1769
  if (!message) {
1032
1770
  throw new Error('AI 未生成有效提交信息');
1033
1771
  }
1772
+ setCachedCommitMessage(payload.cacheKey, message);
1034
1773
  return {
1035
1774
  workDir: path.resolve(getCurrentWorkDir()),
1036
1775
  message,
1776
+ cached: false,
1777
+ diffHash: payload.diffHash,
1037
1778
  };
1038
1779
  }
1039
1780
  function commitWorkspaceChanges(rawMessage) {
@@ -1135,9 +1876,120 @@ function maybeSetAuthCookie(req, res, url) {
1135
1876
  const secure = httpsOptions ? '; Secure' : '';
1136
1877
  appendSetCookie(res, `${AUTH_COOKIE_NAME}=${encodeURIComponent(AUTH_TOKEN)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400${secure}`);
1137
1878
  }
1138
- function sendAuthRequired(res) {
1879
+ function isPublicShareAssetPath(pathname) {
1880
+ let decodedPath;
1881
+ try {
1882
+ decodedPath = decodeURIComponent(pathname);
1883
+ }
1884
+ catch {
1885
+ return false;
1886
+ }
1887
+ if (decodedPath.includes('..') || decodedPath.includes('\\'))
1888
+ return false;
1889
+ return (decodedPath === '/favicon.svg' ||
1890
+ decodedPath === '/favicon.ico' ||
1891
+ /^\/icons\/[A-Za-z0-9._-]+\.(?:png|jpg|jpeg|webp|ico|svg)$/.test(decodedPath));
1892
+ }
1893
+ function shouldSendAuthSharePage(req, url) {
1894
+ if (req.method !== 'GET' && req.method !== 'HEAD')
1895
+ return false;
1896
+ if (url.pathname.startsWith('/api/'))
1897
+ return false;
1898
+ if (isPublicShareAssetPath(url.pathname))
1899
+ return false;
1900
+ const ext = path.extname(url.pathname).toLowerCase();
1901
+ if (ext && ext !== '.html')
1902
+ return false;
1903
+ const accept = getHeaderValue(req.headers.accept).toLowerCase();
1904
+ return !accept || accept.includes('text/html') || accept.includes('*/*');
1905
+ }
1906
+ function escapeHtml(value) {
1907
+ return value
1908
+ .replace(/&/g, '&amp;')
1909
+ .replace(/</g, '&lt;')
1910
+ .replace(/>/g, '&gt;')
1911
+ .replace(/"/g, '&quot;')
1912
+ .replace(/'/g, '&#39;');
1913
+ }
1914
+ function getRequestOrigin(req) {
1915
+ const forwardedProto = getHeaderValue(req.headers['x-forwarded-proto']).split(',')[0]?.trim().toLowerCase();
1916
+ const proto = forwardedProto === 'http' || forwardedProto === 'https' ? forwardedProto : httpScheme;
1917
+ const forwardedHost = getHeaderValue(req.headers['x-forwarded-host']).split(',')[0]?.trim();
1918
+ const host = forwardedHost || getHeaderValue(req.headers.host) || `localhost:${PORT}`;
1919
+ return `${proto}://${host}`;
1920
+ }
1921
+ function toAbsoluteRequestUrl(req, pathname) {
1922
+ try {
1923
+ return new URL(pathname, `${getRequestOrigin(req)}/`).toString();
1924
+ }
1925
+ catch {
1926
+ return pathname;
1927
+ }
1928
+ }
1929
+ function buildAuthRequiredHtml(req, url) {
1930
+ const title = escapeHtml(SHARE_TITLE);
1931
+ const description = escapeHtml(SHARE_DESCRIPTION);
1932
+ const message = escapeHtml(AUTH_REQUIRED_MESSAGE);
1933
+ const imageUrl = escapeHtml(toAbsoluteRequestUrl(req, SHARE_IMAGE_PATH));
1934
+ const pageUrl = escapeHtml(toAbsoluteRequestUrl(req, url.pathname));
1935
+ return `<!DOCTYPE html>
1936
+ <html lang="zh-CN">
1937
+ <head>
1938
+ <meta charset="UTF-8" />
1939
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
1940
+ <meta name="description" content="${description}" />
1941
+ <meta name="theme-color" content="#030712" />
1942
+ <meta property="og:type" content="website" />
1943
+ <meta property="og:site_name" content="AgentPilot" />
1944
+ <meta property="og:title" content="${title}" />
1945
+ <meta property="og:description" content="${description}" />
1946
+ <meta property="og:url" content="${pageUrl}" />
1947
+ <meta property="og:image" content="${imageUrl}" />
1948
+ <meta property="og:image:width" content="512" />
1949
+ <meta property="og:image:height" content="512" />
1950
+ <meta name="twitter:card" content="summary" />
1951
+ <meta name="twitter:title" content="${title}" />
1952
+ <meta name="twitter:description" content="${description}" />
1953
+ <meta name="twitter:image" content="${imageUrl}" />
1954
+ <link rel="icon" type="image/png" sizes="512x512" href="${imageUrl}" />
1955
+ <title>${title}</title>
1956
+ <style>
1957
+ html { color-scheme: dark; background: #030712; color: #e5e7eb; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
1958
+ body { margin: 0; min-height: 100vh; display: grid; place-items: center; padding: 24px; box-sizing: border-box; }
1959
+ main { max-width: 520px; }
1960
+ h1 { margin: 0 0 12px; font-size: 28px; line-height: 1.2; }
1961
+ p { margin: 0; color: #9ca3af; line-height: 1.7; }
1962
+ </style>
1963
+ </head>
1964
+ <body>
1965
+ <main>
1966
+ <h1>${title}</h1>
1967
+ <p>${message}</p>
1968
+ </main>
1969
+ </body>
1970
+ </html>`;
1971
+ }
1972
+ function sendHtml(req, res, status, html) {
1973
+ if (res.writableEnded)
1974
+ return;
1975
+ setCorsHeaders(res);
1976
+ res.writeHead(status, {
1977
+ 'Content-Type': 'text/html; charset=utf-8',
1978
+ 'Cache-Control': 'no-cache',
1979
+ });
1980
+ if (req.method === 'HEAD') {
1981
+ res.end();
1982
+ return;
1983
+ }
1984
+ res.end(html);
1985
+ }
1986
+ function sendAuthRequired(req, res, url) {
1987
+ if (shouldSendAuthSharePage(req, url)) {
1988
+ sendHtml(req, res, 200, buildAuthRequiredHtml(req, url));
1989
+ return;
1990
+ }
1139
1991
  sendJson(res, 401, {
1140
- error: '访问 token 缺失或无效,请使用启动终端打印的链接重新打开。',
1992
+ error: AUTH_REQUIRED_MESSAGE,
1141
1993
  authRequired: true,
1142
1994
  });
1143
1995
  }
@@ -1278,6 +2130,10 @@ function getHistoryIdFromPath(pathname) {
1278
2130
  const match = pathname.match(/^\/api\/history\/([^/]+)$/);
1279
2131
  return match ? decodeURIComponent(match[1]) : null;
1280
2132
  }
2133
+ function getPromptHistoryIdFromPath(pathname) {
2134
+ const match = pathname.match(/^\/api\/prompt-history\/([^/]+)$/);
2135
+ return match ? decodeURIComponent(match[1]) : null;
2136
+ }
1281
2137
  function listSystemDir(rawDir, includeFiles) {
1282
2138
  const baseDir = typeof rawDir === 'string' && rawDir ? rawDir : '~';
1283
2139
  const dir = baseDir.startsWith('~') ? baseDir.replace('~', os.homedir()) : baseDir;
@@ -1332,12 +2188,16 @@ async function handleHttpRequest(req, res) {
1332
2188
  res.end();
1333
2189
  return;
1334
2190
  }
1335
- if (!isRequestAuthorized(req, url)) {
1336
- sendAuthRequired(res);
2191
+ const pathname = url.pathname;
2192
+ const authorized = isRequestAuthorized(req, url);
2193
+ if (!authorized && isPublicShareAssetPath(pathname) && serveStaticClient(req, res, pathname)) {
2194
+ return;
2195
+ }
2196
+ if (!authorized) {
2197
+ sendAuthRequired(req, res, url);
1337
2198
  return;
1338
2199
  }
1339
2200
  maybeSetAuthCookie(req, res, url);
1340
- const pathname = url.pathname;
1341
2201
  if (pathname === '/health') {
1342
2202
  sendText(res, 200, 'AgentPilot Server');
1343
2203
  return;
@@ -1356,7 +2216,7 @@ async function handleHttpRequest(req, res) {
1356
2216
  try {
1357
2217
  if (req.method === 'GET' && pathname === '/api/config') {
1358
2218
  sendJson(res, 200, {
1359
- config: cliManager.getConfig(),
2219
+ config: getClientConfig(),
1360
2220
  status: cliManager.status,
1361
2221
  sessionId: currentSessionId,
1362
2222
  time: now(),
@@ -1396,24 +2256,58 @@ async function handleHttpRequest(req, res) {
1396
2256
  return;
1397
2257
  }
1398
2258
  if (req.method === 'GET' && pathname === '/api/history') {
1399
- const tasks = store.getHistoryTasks({ workDir: getCurrentWorkDir() });
2259
+ const workDir = getCurrentHistoryWorkDir();
2260
+ const tasks = workDir ? store.getHistoryTasks({ workDir }) : [];
1400
2261
  sendJson(res, 200, { tasks: tasks.map(task => toClientHistoryTask(task)) });
1401
2262
  return;
1402
2263
  }
2264
+ if (req.method === 'GET' && pathname === '/api/prompt-history') {
2265
+ const scope = normalizeHistoryScope(url.searchParams.get('scope'), 'current');
2266
+ const prompts = store.getPromptHistory({
2267
+ workDir: getHistoryWorkDirForScope(scope),
2268
+ query: url.searchParams.get('query') || undefined,
2269
+ limit: url.searchParams.get('limit') ? Number(url.searchParams.get('limit')) : undefined,
2270
+ });
2271
+ sendJson(res, 200, { prompts: prompts.map(toClientPromptHistoryItem) });
2272
+ return;
2273
+ }
2274
+ if (req.method === 'POST' && pathname === '/api/codex-history/sync') {
2275
+ const body = await readJsonBody(req);
2276
+ const result = syncCodexHistory({
2277
+ codexHome: process.env.AGENTPILOT_CODEX_HOME,
2278
+ limit: body.limit,
2279
+ workDir: getCurrentWorkDir(),
2280
+ });
2281
+ sendJson(res, 200, { result });
2282
+ broadcast({ type: 'history_changed', time: now() });
2283
+ return;
2284
+ }
2285
+ const promptHistoryId = getPromptHistoryIdFromPath(pathname);
2286
+ if (promptHistoryId && req.method === 'DELETE') {
2287
+ const scope = normalizeHistoryScope(url.searchParams.get('scope'), 'current');
2288
+ store.deletePromptHistory(promptHistoryId, getHistoryWorkDirForScope(scope));
2289
+ sendJson(res, 200, { id: promptHistoryId });
2290
+ return;
2291
+ }
1403
2292
  const historyId = getHistoryIdFromPath(pathname);
1404
2293
  if (historyId && req.method === 'GET') {
1405
- const task = store.getHistoryTask(historyId, getCurrentWorkDir());
2294
+ const workDir = getCurrentHistoryWorkDir();
2295
+ const task = workDir ? store.getHistoryTask(historyId, workDir) : null;
1406
2296
  sendJson(res, 200, { task: task ? toClientHistoryTask(task, true) : null });
1407
2297
  return;
1408
2298
  }
1409
2299
  if (historyId && req.method === 'DELETE') {
1410
- store.deleteHistoryTask(historyId, getCurrentWorkDir());
2300
+ const workDir = getCurrentHistoryWorkDir();
2301
+ if (workDir)
2302
+ store.deleteHistoryTask(historyId, workDir);
1411
2303
  sendJson(res, 200, { id: historyId });
1412
2304
  broadcast({ type: 'history_changed', time: now() });
1413
2305
  return;
1414
2306
  }
1415
2307
  if (req.method === 'DELETE' && pathname === '/api/history') {
1416
- store.clearHistory(getCurrentWorkDir());
2308
+ const workDir = getCurrentHistoryWorkDir();
2309
+ if (workDir)
2310
+ store.clearHistory(workDir);
1417
2311
  sendJson(res, 200, { success: true });
1418
2312
  broadcast({ type: 'history_changed', time: now() });
1419
2313
  return;
@@ -1429,6 +2323,15 @@ async function handleHttpRequest(req, res) {
1429
2323
  sendJson(res, 200, listSystemDir(url.searchParams.get('dir'), includeFiles));
1430
2324
  return;
1431
2325
  }
2326
+ if (req.method === 'POST' && pathname === '/api/attachments/image') {
2327
+ const body = await readJsonBody(req, MAX_IMAGE_ATTACHMENT_BYTES * 2);
2328
+ sendJson(res, 200, { attachment: saveImageAttachment(body) });
2329
+ return;
2330
+ }
2331
+ if ((req.method === 'GET' || req.method === 'HEAD') && pathname === '/api/attachments/file') {
2332
+ sendAttachmentFile(req, res, url.searchParams.get('path') || '');
2333
+ return;
2334
+ }
1432
2335
  if (req.method === 'GET' && pathname === '/api/workdir/tree') {
1433
2336
  sendJson(res, 200, listWorkspaceDir(url.searchParams.get('path') || ''));
1434
2337
  return;
@@ -1468,6 +2371,11 @@ async function handleHttpRequest(req, res) {
1468
2371
  sendJson(res, 200, unstageWorkspacePath(body.path));
1469
2372
  return;
1470
2373
  }
2374
+ if (req.method === 'POST' && pathname === '/api/workdir/discard') {
2375
+ const body = await readJsonBody(req);
2376
+ sendJson(res, 200, discardWorkspaceChanges(body.path));
2377
+ return;
2378
+ }
1471
2379
  if (req.method === 'POST' && pathname === '/api/workdir/commit-message') {
1472
2380
  sendJson(res, 200, await generateWorkspaceCommitMessage());
1473
2381
  return;
@@ -1557,6 +2465,14 @@ const PERSISTABLE_TYPES = new Set([
1557
2465
  'status',
1558
2466
  ]);
1559
2467
  function broadcast(event) {
2468
+ if (event.type === 'status' && event.status === 'running') {
2469
+ maybeStartGeneratedImageCapture();
2470
+ }
2471
+ attachGeneratedImagesToEvent(event);
2472
+ if (event.type === 'status' && event.status !== 'idle') {
2473
+ promptQueueDispatching = false;
2474
+ promptQueueHoldUntil = 0;
2475
+ }
1560
2476
  // Persist CLI session ID when we get it from system init or result
1561
2477
  if (currentSessionId && event.type === 'system' && event.details?.session_id) {
1562
2478
  try {
@@ -1637,6 +2553,14 @@ function broadcast(event) {
1637
2553
  if (!event.id)
1638
2554
  event.id = persisted.id;
1639
2555
  event.seq = persisted.seq;
2556
+ if (event.type === 'user_message') {
2557
+ store.recordPromptHistory({
2558
+ text: event.content || '',
2559
+ workDir: getCurrentWorkDir(),
2560
+ sessionId: currentSessionId,
2561
+ messageId: persisted.id,
2562
+ });
2563
+ }
1640
2564
  }
1641
2565
  }
1642
2566
  catch (err) {
@@ -1653,16 +2577,25 @@ function broadcast(event) {
1653
2577
  catch { }
1654
2578
  }
1655
2579
  }
2580
+ if (event.type === 'status' && event.status === 'idle') {
2581
+ emitGeneratedImagesFallback();
2582
+ promptQueueHoldUntil = Date.now() + PROMPT_QUEUE_IDLE_SETTLE_MS;
2583
+ schedulePromptQueueDrain(PROMPT_QUEUE_IDLE_SETTLE_MS);
2584
+ }
1656
2585
  }
1657
2586
  cliManager.setEventHandler(broadcast);
2587
+ if (cliManager.status === 'disconnected') {
2588
+ cliManager.restoreReady();
2589
+ }
1658
2590
  wss.on('connection', (ws) => {
1659
2591
  clients.add(ws);
1660
2592
  console.log(`[WS] Client connected (total: ${clients.size})`);
1661
2593
  ws.send(JSON.stringify({
1662
2594
  type: 'connected',
1663
- config: cliManager.getConfig(),
2595
+ config: getClientConfig(),
1664
2596
  status: cliManager.status,
1665
2597
  sessionId: currentSessionId,
2598
+ ...getPromptQueuePayload(),
1666
2599
  }));
1667
2600
  ws.on('message', (data) => {
1668
2601
  let msg;
@@ -1693,6 +2626,7 @@ wss.on('connection', (ws) => {
1693
2626
  function handleMessage(ws, msg) {
1694
2627
  switch (msg.type) {
1695
2628
  case 'start_cli': {
2629
+ clearPromptQueue();
1696
2630
  // Archive current session
1697
2631
  if (currentSessionId) {
1698
2632
  store.archiveSession(currentSessionId, 'completed');
@@ -1716,20 +2650,40 @@ function handleMessage(ws, msg) {
1716
2650
  broadcast({
1717
2651
  type: 'session_reset',
1718
2652
  sessionId: currentSessionId,
1719
- config: cliManager.getConfig(),
2653
+ config: getClientConfig(),
1720
2654
  time: now(),
1721
2655
  });
1722
2656
  broadcast({ type: 'history_changed', time: now() });
1723
2657
  broadcast({ type: 'workdirs_changed', current: getCurrentWorkDir(), time: now() });
1724
2658
  break;
1725
2659
  }
1726
- case 'send_message':
1727
- cliManager.sendInput(msg.content);
1728
- broadcast({
1729
- type: 'user_message',
1730
- content: msg.content,
1731
- time: now(),
1732
- });
2660
+ case 'send_message': {
2661
+ const prompt = normalizePromptPayload(msg);
2662
+ if (cliManager.status === 'disconnected') {
2663
+ broadcast({ type: 'error', content: 'CLI 未连接,无法发送', time: now() });
2664
+ }
2665
+ else if (!hasPromptPayload(prompt)) {
2666
+ broadcast({ type: 'error', content: '消息不能为空', time: now() });
2667
+ }
2668
+ else if (shouldQueueIncomingPrompt()) {
2669
+ enqueuePrompt(prompt);
2670
+ }
2671
+ else {
2672
+ dispatchPromptToCli(prompt);
2673
+ }
2674
+ break;
2675
+ }
2676
+ case 'queue_prompt':
2677
+ enqueuePrompt(normalizePromptPayload(msg));
2678
+ break;
2679
+ case 'remove_queued_prompt':
2680
+ removePromptFromQueue(msg.id);
2681
+ break;
2682
+ case 'clear_prompt_queue':
2683
+ clearPromptQueue();
2684
+ break;
2685
+ case 'resume_prompt_queue':
2686
+ resumePromptQueue();
1733
2687
  break;
1734
2688
  case 'confirm_response':
1735
2689
  cliManager.confirmResponse(msg.approved);
@@ -1738,9 +2692,14 @@ function handleMessage(ws, msg) {
1738
2692
  cliManager.questionResponse(msg.answer, msg.toolUseId);
1739
2693
  break;
1740
2694
  case 'interrupt':
2695
+ if (promptQueue.length > 0) {
2696
+ promptQueuePaused = true;
2697
+ broadcastPromptQueueChanged();
2698
+ }
1741
2699
  cliManager.interrupt();
1742
2700
  break;
1743
2701
  case 'restart_cli': {
2702
+ clearPromptQueue();
1744
2703
  // Archive current session before restarting
1745
2704
  if (currentSessionId) {
1746
2705
  const taskStatus = cliManager.status === 'running' || cliManager.status === 'confirm' ? 'running' :
@@ -1754,7 +2713,7 @@ function handleMessage(ws, msg) {
1754
2713
  broadcast({
1755
2714
  type: 'session_reset',
1756
2715
  sessionId: currentSessionId,
1757
- config: cliManager.getConfig(),
2716
+ config: getClientConfig(),
1758
2717
  time: now(),
1759
2718
  });
1760
2719
  broadcast({ type: 'history_changed', time: now() });
@@ -1772,7 +2731,7 @@ function handleMessage(ws, msg) {
1772
2731
  case 'get_config':
1773
2732
  ws.send(JSON.stringify({
1774
2733
  type: 'config',
1775
- config: cliManager.getConfig(),
2734
+ config: getClientConfig(),
1776
2735
  status: cliManager.status,
1777
2736
  sessionId: currentSessionId,
1778
2737
  time: now(),
@@ -1815,7 +2774,8 @@ function handleMessage(ws, msg) {
1815
2774
  }
1816
2775
  // --- New protocol: History ---
1817
2776
  case 'get_history': {
1818
- const tasks = store.getHistoryTasks({ workDir: getCurrentWorkDir() });
2777
+ const workDir = getCurrentHistoryWorkDir();
2778
+ const tasks = workDir ? store.getHistoryTasks({ workDir }) : [];
1819
2779
  ws.send(JSON.stringify({
1820
2780
  type: 'history_data',
1821
2781
  requestId: msg.requestId,
@@ -1824,7 +2784,8 @@ function handleMessage(ws, msg) {
1824
2784
  break;
1825
2785
  }
1826
2786
  case 'get_history_task': {
1827
- const task = store.getHistoryTask(msg.id, getCurrentWorkDir());
2787
+ const workDir = getCurrentHistoryWorkDir();
2788
+ const task = workDir ? store.getHistoryTask(msg.id, workDir) : null;
1828
2789
  ws.send(JSON.stringify({
1829
2790
  type: 'history_task_data',
1830
2791
  requestId: msg.requestId,
@@ -1842,7 +2803,8 @@ function handleMessage(ws, msg) {
1842
2803
  }));
1843
2804
  break;
1844
2805
  }
1845
- const task = store.getHistoryTask(msg.id, getCurrentWorkDir());
2806
+ const workDir = getCurrentHistoryWorkDir();
2807
+ const task = workDir ? store.getHistoryTask(msg.id, workDir) : null;
1846
2808
  const cliSessionId = task ? getRestorableCliSessionId(task.session_id, task.messages) : undefined;
1847
2809
  if (!task || !task.session_id || !cliSessionId) {
1848
2810
  ws.send(JSON.stringify({
@@ -1853,7 +2815,7 @@ function handleMessage(ws, msg) {
1853
2815
  }));
1854
2816
  break;
1855
2817
  }
1856
- const restored = store.resumeHistoryTask(msg.id, currentSessionId, getCurrentWorkDir());
2818
+ const restored = store.resumeHistoryTask(msg.id, currentSessionId, workDir);
1857
2819
  if (!restored) {
1858
2820
  ws.send(JSON.stringify({
1859
2821
  type: 'history_resumed',
@@ -1868,6 +2830,9 @@ function handleMessage(ws, msg) {
1868
2830
  ...getSessionConfig(restored.session),
1869
2831
  cliSessionId,
1870
2832
  };
2833
+ if (typeof restoredConfig.workDir !== 'string' && typeof restored.work_dir === 'string') {
2834
+ restoredConfig.workDir = restored.work_dir;
2835
+ }
1871
2836
  cliManager.restoreConfig(restoredConfig);
1872
2837
  cliManager.restoreSessionId(cliSessionId);
1873
2838
  if (typeof restoredConfig.workDir === 'string') {
@@ -1883,7 +2848,7 @@ function handleMessage(ws, msg) {
1883
2848
  broadcast({
1884
2849
  type: 'session_restored',
1885
2850
  sessionId: currentSessionId,
1886
- config: cliManager.getConfig(),
2851
+ config: getClientConfig(),
1887
2852
  status: cliManager.status,
1888
2853
  messages: sessionMessages,
1889
2854
  lastSeq,
@@ -1905,7 +2870,7 @@ function handleMessage(ws, msg) {
1905
2870
  requestId: msg.requestId,
1906
2871
  success: true,
1907
2872
  sessionId: currentSessionId,
1908
- config: cliManager.getConfig(),
2873
+ config: getClientConfig(),
1909
2874
  messages: sessionMessages,
1910
2875
  lastSeq,
1911
2876
  sent: !!followUp,
@@ -1961,7 +2926,7 @@ function handleMessage(ws, msg) {
1961
2926
  broadcast({
1962
2927
  type: 'session_restored',
1963
2928
  sessionId: currentSessionId,
1964
- config: cliManager.getConfig(),
2929
+ config: getClientConfig(),
1965
2930
  status: cliManager.status,
1966
2931
  messages: sessionMessages,
1967
2932
  lastSeq,
@@ -1983,7 +2948,9 @@ function handleMessage(ws, msg) {
1983
2948
  break;
1984
2949
  }
1985
2950
  case 'delete_history': {
1986
- store.deleteHistoryTask(msg.id, getCurrentWorkDir());
2951
+ const workDir = getCurrentHistoryWorkDir();
2952
+ if (workDir)
2953
+ store.deleteHistoryTask(msg.id, workDir);
1987
2954
  ws.send(JSON.stringify({
1988
2955
  type: 'history_deleted',
1989
2956
  requestId: msg.requestId,
@@ -1994,7 +2961,9 @@ function handleMessage(ws, msg) {
1994
2961
  break;
1995
2962
  }
1996
2963
  case 'clear_history': {
1997
- store.clearHistory(getCurrentWorkDir());
2964
+ const workDir = getCurrentHistoryWorkDir();
2965
+ if (workDir)
2966
+ store.clearHistory(workDir);
1998
2967
  ws.send(JSON.stringify({
1999
2968
  type: 'history_cleared',
2000
2969
  requestId: msg.requestId,
@@ -2155,7 +3124,7 @@ function handleMessage(ws, msg) {
2155
3124
  }
2156
3125
  }
2157
3126
  function now() {
2158
- return new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
3127
+ return new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hourCycle: 'h23' });
2159
3128
  }
2160
3129
  function formatHostForUrl(host) {
2161
3130
  return host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
@@ -2182,6 +3151,14 @@ function getNetworkUrls() {
2182
3151
  function shouldUseColor() {
2183
3152
  return Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
2184
3153
  }
3154
+ function getQrRenderMode() {
3155
+ const requested = (process.env.AGENTPILOT_QR_STYLE || '').trim().toLowerCase();
3156
+ if (requested === 'compact' || requested === 'small')
3157
+ return 'compact';
3158
+ if (requested === 'large' || requested === 'safe')
3159
+ return 'large';
3160
+ return process.env.TERM_PROGRAM === 'Apple_Terminal' ? 'large' : 'compact';
3161
+ }
2185
3162
  const terminalStyle = {
2186
3163
  reset: '\x1b[0m',
2187
3164
  bold: '\x1b[1m',
@@ -2225,8 +3202,10 @@ function printMacSleepHint() {
2225
3202
  console.log(' 这样电脑接入电源且显示器关闭后,AgentPilot 后端和底层 CLI 更不容易被系统睡眠中断。');
2226
3203
  console.log('');
2227
3204
  }
2228
- function printStartupInfo() {
3205
+ async function printStartupInfo() {
2229
3206
  if (!IS_NPX_ENTRY) {
3207
+ if (PACKAGE_VERSION)
3208
+ console.log(`[AgentPilot] Version ${PACKAGE_VERSION}`);
2230
3209
  console.log(`[AgentPilot] Server running on ${withStartupToken(`${httpScheme}://${HOST}:${PORT}`)}`);
2231
3210
  console.log(`[AgentPilot] WebSocket ready on ${wsScheme}://${HOST}:${PORT}`);
2232
3211
  if (AUTH_ENABLED)
@@ -2246,7 +3225,10 @@ function printStartupInfo() {
2246
3225
  printSection('open on mobile');
2247
3226
  printLine('Mobile', networkUrls[0], true);
2248
3227
  printLine('Scan', 'use the QR code below');
2249
- const qr = renderTerminalQr(networkUrls[0], { color: shouldUseColor() });
3228
+ const qr = await renderTerminalQr(networkUrls[0], {
3229
+ color: shouldUseColor(),
3230
+ mode: getQrRenderMode(),
3231
+ });
2250
3232
  if (qr) {
2251
3233
  console.log('');
2252
3234
  console.log(colorize('QR CODE', terminalStyle.bold, terminalStyle.dim));
@@ -2270,6 +3252,8 @@ function printStartupInfo() {
2270
3252
  printLine('Token', tokenStatus, AUTH_ENABLED);
2271
3253
  console.log('');
2272
3254
  printSection('runtime');
3255
+ if (PACKAGE_VERSION)
3256
+ printLine('Version', PACKAGE_VERSION);
2273
3257
  printLine('Workdir', config.workDir);
2274
3258
  printLine('CLI', `${config.cliCommand} (${config.cliType})`);
2275
3259
  console.log('');
@@ -2287,5 +3271,7 @@ process.on('SIGTERM', () => {
2287
3271
  process.exit(0);
2288
3272
  });
2289
3273
  server.listen(PORT, HOST, () => {
2290
- printStartupInfo();
3274
+ printStartupInfo().catch((err) => {
3275
+ console.warn(`[AgentPilot] Failed to print startup info: ${err?.message || err}`);
3276
+ });
2291
3277
  });