cli-link 0.0.6 → 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 (56) hide show
  1. package/dist/client/assets/History-D2xDopni.js +4 -0
  2. package/dist/client/assets/ImageViewer-DuegU_fC.js +1 -0
  3. package/dist/client/assets/MarkdownRenderer-CsyizEL3.js +1 -0
  4. package/dist/client/assets/{PageTopBar-C8j-5s_3.js → PageTopBar-CQwjO6Af.js} +1 -1
  5. package/dist/client/assets/Session-B0s5zBGg.js +7 -0
  6. package/dist/client/assets/Settings-CfHFmJdV.js +1 -0
  7. package/dist/client/assets/Workspace-Cfl0mbNE.js +4 -0
  8. package/dist/client/assets/WorkspaceLinkedText-DCVYd9x-.js +2 -0
  9. package/dist/client/assets/c-BIGW1oBm.js +1 -0
  10. package/dist/client/assets/cpp-DIPi6g--.js +1 -0
  11. package/dist/client/assets/csharp-DSvCPggb.js +1 -0
  12. package/dist/client/assets/dart-bE4Kk8sk.js +1 -0
  13. package/dist/client/assets/dotenv-Da5cRb03.js +1 -0
  14. package/dist/client/assets/go-C27-OAKa.js +1 -0
  15. package/dist/client/assets/graphql-pNE0_Gx8.js +1 -0
  16. package/dist/client/assets/groovy-gcz8RCvz.js +1 -0
  17. package/dist/client/assets/index-BCg3ymV3.css +1 -0
  18. package/dist/client/assets/index-CrJqHlc8.js +2 -0
  19. package/dist/client/assets/java-VnEXKtx_.js +148 -0
  20. package/dist/client/assets/jsx-g9-lgVsj.js +1 -0
  21. package/dist/client/assets/kotlin-BdnUsdx6.js +1 -0
  22. package/dist/client/assets/less-B1dDrJ26.js +1 -0
  23. package/dist/client/assets/lua-BaeVxFsk.js +1 -0
  24. package/dist/client/assets/makefile-CHLpvVh8.js +1 -0
  25. package/dist/client/assets/php-BcCyJq-p.js +1 -0
  26. package/dist/client/assets/properties-DTPjHERo.js +1 -0
  27. package/dist/client/assets/ruby-BwImf3Ka.js +1 -0
  28. package/dist/client/assets/rust-B1yitclQ.js +1 -0
  29. package/dist/client/assets/scss-lMagJa-5.js +1 -0
  30. package/dist/client/assets/sql-CRqJ_cUM.js +1 -0
  31. package/dist/client/assets/svelte-B4a9v_or.js +1 -0
  32. package/dist/client/assets/swift-D82vCrfD.js +1 -0
  33. package/dist/client/assets/toml-vGWfd6FD.js +1 -0
  34. package/dist/client/assets/{vendor-icons-CNN4EKVi.js → vendor-icons-CMXJHDEv.js} +125 -65
  35. package/dist/client/assets/vendor-markdown--d-T3AbU.js +37 -0
  36. package/dist/client/assets/{vendor-motion-n6Lx6G4a.js → vendor-motion-D0ZmPdi9.js} +1 -1
  37. package/dist/client/assets/{vendor-react-DSV5aFEg.js → vendor-react-CcDXZHn_.js} +1 -1
  38. package/dist/client/assets/{vendor-virtual-CcftJrIC.js → vendor-virtual-DJI7OicV.js} +1 -1
  39. package/dist/client/assets/vue-DBXACu8K.js +1 -0
  40. package/dist/client/assets/workspace-return-FrQUv7g3.js +1 -0
  41. package/dist/client/index.html +4 -4
  42. package/dist/server/cli-manager.js +151 -26
  43. package/dist/server/codex-history.js +119 -17
  44. package/dist/server/index.js +906 -57
  45. package/dist/server/store.js +369 -27
  46. package/package.json +3 -3
  47. package/dist/client/assets/History-BxJVDFpN.js +0 -3
  48. package/dist/client/assets/MarkdownRenderer-BO-KS_L1.js +0 -1
  49. package/dist/client/assets/Session-CQFXA2Sr.js +0 -11
  50. package/dist/client/assets/Settings-DYmjRmoN.js +0 -1
  51. package/dist/client/assets/Workspace-D8kv9euM.js +0 -8
  52. package/dist/client/assets/WorkspaceLinkedText-DQyPLk-X.js +0 -2
  53. package/dist/client/assets/code-highlight-CEcsuMpw.js +0 -1
  54. package/dist/client/assets/index-BXT2BylN.css +0 -1
  55. package/dist/client/assets/index-DOgH1Kf3.js +0 -2
  56. 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);
@@ -104,6 +105,119 @@ server.on('upgrade', (req, socket, head) => {
104
105
  });
105
106
  const cliManager = new CLIManager();
106
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
+ }
107
221
  function unquoteMetaValue(value) {
108
222
  const trimmed = value.trim();
109
223
  if ((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
@@ -238,6 +352,17 @@ function parseJsonField(raw) {
238
352
  return undefined;
239
353
  }
240
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
+ }
241
366
  function getSessionConfig(session) {
242
367
  const parsed = parseJsonField(session?.cli_config);
243
368
  return parsed && typeof parsed === 'object' ? parsed : {};
@@ -271,6 +396,7 @@ function toClientMessage(m) {
271
396
  return null;
272
397
  const parsedDetails = parseJsonField(m.details);
273
398
  const isQuestion = mappedType === 'question' && m.type === 'ask_question';
399
+ const attachments = toClientAttachments(parsedDetails?.attachments);
274
400
  return {
275
401
  seq: m.seq,
276
402
  id: m.id,
@@ -284,12 +410,64 @@ function toClientMessage(m) {
284
410
  toolResult: m.toolResult || undefined,
285
411
  permission: parseJsonField(m.permission),
286
412
  details: parsedDetails,
413
+ attachments: attachments.length > 0 ? attachments : undefined,
287
414
  question: isQuestion && parsedDetails ? { questions: parsedDetails.questions || [], toolUseId: parsedDetails.toolUseId } : undefined,
288
415
  };
289
416
  }
290
417
  function toClientMessages(messages) {
291
418
  return messages.map(toClientMessage).filter((m) => m !== null);
292
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
+ }
293
471
  function readPositiveInt(value, fallback = 0) {
294
472
  const parsed = Number(value || 0);
295
473
  if (!Number.isFinite(parsed) || parsed <= 0)
@@ -389,11 +567,16 @@ function buildEditPrompt(prefixMessages, editedContent) {
389
567
  }
390
568
  function toClientHistoryTask(t, includeMessages = false) {
391
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);
392
572
  const response = {
393
573
  id: t.id,
394
574
  workDir: t.work_dir || undefined,
395
575
  status: t.status,
396
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),
397
580
  confirmCount: t.confirm_count,
398
581
  toolCount: t.tool_count,
399
582
  duration: t.duration || undefined,
@@ -414,6 +597,26 @@ function toClientWorkDir(item) {
414
597
  createdAt: item.created_at,
415
598
  };
416
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
+ }
417
620
  function getCurrentWorkDir() {
418
621
  return cliManager.getConfig().workDir;
419
622
  }
@@ -436,7 +639,15 @@ const WORKSPACE_SKIP_NAMES = new Set([
436
639
  ]);
437
640
  const MAX_FILE_BYTES = 512 * 1024;
438
641
  const MAX_DIFF_BYTES = 1024 * 1024;
439
- 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');
440
651
  const PREVIEW_IMAGE_MIME_BY_EXTENSION = {
441
652
  '.avif': 'image/avif',
442
653
  '.bmp': 'image/bmp',
@@ -448,6 +659,281 @@ const PREVIEW_IMAGE_MIME_BY_EXTENSION = {
448
659
  '.svg': 'image/svg+xml',
449
660
  '.webp': 'image/webp',
450
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
+ }
451
937
  function decodeGitOctalPath(raw) {
452
938
  return raw.replace(/(?:\\[0-7]{3})+/g, (sequence) => {
453
939
  const bytes = sequence.match(/\\([0-7]{3})/g)?.map(item => parseInt(item.slice(1), 8)) || [];
@@ -621,6 +1107,28 @@ function runGit(args, allowExitCodes = [0]) {
621
1107
  throw new Error(stderr || err?.message || 'Git 命令执行失败');
622
1108
  }
623
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
+ }
624
1132
  function unquoteGitPath(raw) {
625
1133
  const trimmed = raw.trim();
626
1134
  if (!trimmed.startsWith('"') || !trimmed.endsWith('"'))
@@ -663,6 +1171,8 @@ function parseGitStatus(output, nulTerminated = false) {
663
1171
  const indexStatus = line[0] || ' ';
664
1172
  const worktreeStatus = line[1] || ' ';
665
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;
666
1176
  files.push({
667
1177
  path: filePath,
668
1178
  name: path.basename(filePath),
@@ -670,8 +1180,9 @@ function parseGitStatus(output, nulTerminated = false) {
670
1180
  indexStatus,
671
1181
  worktreeStatus,
672
1182
  label: describeGitStatus(indexStatus, worktreeStatus),
1183
+ oldPath,
673
1184
  });
674
- if (indexStatus === 'R' || indexStatus === 'C' || worktreeStatus === 'R' || worktreeStatus === 'C') {
1185
+ if (isMoved) {
675
1186
  index += 1;
676
1187
  }
677
1188
  }
@@ -683,7 +1194,9 @@ function parseGitStatus(output, nulTerminated = false) {
683
1194
  const indexStatus = line[0] || ' ';
684
1195
  const worktreeStatus = line[1] || ' ';
685
1196
  const rawPath = line.slice(3);
686
- 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;
687
1200
  const filePath = unquoteGitPath(displayPath);
688
1201
  return {
689
1202
  path: filePath,
@@ -692,9 +1205,88 @@ function parseGitStatus(output, nulTerminated = false) {
692
1205
  indexStatus,
693
1206
  worktreeStatus,
694
1207
  label: describeGitStatus(indexStatus, worktreeStatus),
1208
+ oldPath,
695
1209
  };
696
1210
  }).filter((item) => item !== null);
697
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
+ }
698
1290
  function truncateText(text, maxBytes) {
699
1291
  const buffer = Buffer.from(text, 'utf8');
700
1292
  if (buffer.length <= maxBytes) {
@@ -855,8 +1447,23 @@ function getWorkspaceChanges(rawPath) {
855
1447
  isGitRepo: false,
856
1448
  branch: '',
857
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
+ },
858
1461
  diff: '',
1462
+ stagedDiff: '',
1463
+ unstagedDiff: '',
859
1464
  truncated: false,
1465
+ stagedTruncated: false,
1466
+ unstagedTruncated: false,
860
1467
  };
861
1468
  }
862
1469
  const selectedPath = normalizeWorkspacePath(rawPath);
@@ -865,36 +1472,84 @@ function getWorkspaceChanges(rawPath) {
865
1472
  }
866
1473
  const branch = runGit(['rev-parse', '--abbrev-ref', 'HEAD']).trim();
867
1474
  const statusOutput = runGit(['status', '--porcelain=v1', '-z', '--untracked-files=all', '--', '.']);
868
- const files = parseGitStatus(statusOutput, true);
1475
+ const rawFiles = parseGitStatus(statusOutput, true);
869
1476
  const diffTarget = selectedPath || '.';
870
1477
  const staged = runGit(['diff', '--cached', '--', diffTarget]);
871
- const unstaged = runGit(['diff', '--', diffTarget]);
1478
+ let unstaged = runGit(['diff', '--', diffTarget]);
872
1479
  const parts = [];
873
1480
  if (staged.trim())
874
1481
  parts.push(`--- 已暂存变更 ---\n${staged}`);
875
1482
  if (unstaged.trim())
876
1483
  parts.push(`--- 未暂存变更 ---\n${unstaged}`);
877
1484
  if (selectedPath && parts.length === 0) {
878
- const selected = files.find(file => file.path === selectedPath);
1485
+ const selected = rawFiles.find(file => file.path === selectedPath);
879
1486
  if (selected?.status === '??') {
880
1487
  const { target } = resolveWorkspacePath(selectedPath, true);
881
1488
  const stat = fs.statSync(target);
882
1489
  if (stat.isFile() && stat.size <= MAX_FILE_BYTES) {
883
1490
  const untrackedDiff = runGit(['diff', '--no-index', '--', '/dev/null', target], [0, 1]);
884
- if (untrackedDiff.trim())
885
- parts.push(untrackedDiff);
1491
+ if (untrackedDiff.trim()) {
1492
+ unstaged = untrackedDiff;
1493
+ parts.push(`--- 未暂存变更 ---\n${untrackedDiff}`);
1494
+ }
886
1495
  }
887
1496
  }
888
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);
889
1529
  const diffResult = truncateText(parts.join('\n'), MAX_DIFF_BYTES);
890
1530
  return {
891
1531
  workDir: path.resolve(getCurrentWorkDir()),
892
1532
  isGitRepo: true,
893
1533
  branch,
894
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
+ },
895
1546
  diff: diffResult.text,
1547
+ stagedDiff: stagedDiffResult.text,
1548
+ unstagedDiff: unstagedDiffResult.text,
896
1549
  diffPath: selectedPath || undefined,
897
1550
  truncated: diffResult.truncated,
1551
+ stagedTruncated: stagedDiffResult.truncated,
1552
+ unstagedTruncated: unstagedDiffResult.truncated,
898
1553
  };
899
1554
  }
900
1555
  function trackWorkspaceFile(rawPath) {
@@ -972,6 +1627,41 @@ function unstageWorkspacePath(rawPath) {
972
1627
  unstaged: true,
973
1628
  };
974
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
+ }
975
1665
  function ensureWorkspaceGitRepo() {
976
1666
  resolveWorkspacePath('', true);
977
1667
  try {
@@ -993,52 +1683,98 @@ function normalizeAICommitMessage(rawMessage) {
993
1683
  message = message.replace(/^["'“”‘’]+|["'“”‘’]+$/g, '').trim();
994
1684
  return message.split('\n').map(line => line.replace(/^\s*[-*]\s+/, '').trimEnd()).join('\n').trim();
995
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
+ }
996
1709
  function buildCommitMessagePrompt() {
997
1710
  ensureWorkspaceGitRepo();
998
- const statusOutput = runGit(['status', '--porcelain=v1', '-z', '--untracked-files=all', '--', '.']);
999
- const files = parseGitStatus(statusOutput, true);
1000
- const statusText = files.map(file => `${file.status} ${file.path}`).join('\n');
1001
- if (files.length === 0) {
1002
- throw new Error('没有可提交的代码变更');
1003
- }
1004
- const stagedFiles = runGit(['diff', '--cached', '--name-only']);
1005
- 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) {
1006
1715
  throw new Error('没有已暂存的代码变更,请先暂存要提交的文件');
1007
1716
  }
1008
- const recentCommits = runGit(['log', '--oneline', '-8'], [0, 128]).trim();
1009
- const stagedStat = runGit(['diff', '--cached', '--stat']);
1010
- 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);
1011
1725
  const context = truncateText([
1012
- `当前分支:${runGit(['rev-parse', '--abbrev-ref', 'HEAD']).trim()}`,
1013
- recentCommits ? `最近提交:\n${recentCommits}` : '',
1014
- `状态:\n${statusText}`,
1015
- stagedStat.trim() ? `已暂存统计:\n${stagedStat.trim()}` : '',
1016
- 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}` : '',
1017
1733
  ].filter(Boolean).join('\n\n'), MAX_COMMIT_CONTEXT_BYTES);
1018
- return [
1019
- '请基于下面的 Git 变更生成一条提交信息。',
1734
+ const prompt = [
1735
+ '你是 commit message 生成器。只基于下面的“已暂存”Git 上下文生成一条提交信息。',
1020
1736
  '要求:',
1737
+ '- 不要调用命令或工具,不要读取文件。',
1021
1738
  '- 只输出 commit message,不要解释,不要 Markdown 代码块。',
1739
+ '- 只描述已暂存变更,不要描述未暂存或未跟踪内容。',
1022
1740
  '- 使用 Conventional Commits:<type>(<scope>): <subject> 或 <type>: <subject>。',
1023
1741
  '- type 优先使用 feat/fix/docs/refactor/test/chore/style/build/ci/perf。',
1024
- '- subject 简短、具体、动词开头,使用英文小写;必要时可以补充简短 body。',
1742
+ '- scope 优先从文件路径、模块名和最近提交风格判断。',
1743
+ '- subject 简短、具体、英文小写,尽量不超过 72 个字符;必要时可以补充 1-3 行 body。',
1025
1744
  '- 不要提到 AI、不要提到你无法验证的内容。',
1026
1745
  '',
1027
- context.truncated ? '注意:变更上下文已截断。' : '',
1746
+ context.truncated || diffContext.truncated ? '注意:变更上下文已截断,请优先依据文件列表、统计和可见 diff。' : '',
1028
1747
  '已暂存 Git 变更上下文:',
1029
1748
  context.text,
1030
1749
  ].filter(Boolean).join('\n');
1750
+ return {
1751
+ prompt,
1752
+ cacheKey: getCommitMessageCacheKey(workDir, diffHash),
1753
+ diffHash,
1754
+ };
1031
1755
  }
1032
1756
  async function generateWorkspaceCommitMessage() {
1033
- const prompt = buildCommitMessagePrompt();
1034
- 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);
1035
1768
  const message = normalizeAICommitMessage(output);
1036
1769
  if (!message) {
1037
1770
  throw new Error('AI 未生成有效提交信息');
1038
1771
  }
1772
+ setCachedCommitMessage(payload.cacheKey, message);
1039
1773
  return {
1040
1774
  workDir: path.resolve(getCurrentWorkDir()),
1041
1775
  message,
1776
+ cached: false,
1777
+ diffHash: payload.diffHash,
1042
1778
  };
1043
1779
  }
1044
1780
  function commitWorkspaceChanges(rawMessage) {
@@ -1394,6 +2130,10 @@ function getHistoryIdFromPath(pathname) {
1394
2130
  const match = pathname.match(/^\/api\/history\/([^/]+)$/);
1395
2131
  return match ? decodeURIComponent(match[1]) : null;
1396
2132
  }
2133
+ function getPromptHistoryIdFromPath(pathname) {
2134
+ const match = pathname.match(/^\/api\/prompt-history\/([^/]+)$/);
2135
+ return match ? decodeURIComponent(match[1]) : null;
2136
+ }
1397
2137
  function listSystemDir(rawDir, includeFiles) {
1398
2138
  const baseDir = typeof rawDir === 'string' && rawDir ? rawDir : '~';
1399
2139
  const dir = baseDir.startsWith('~') ? baseDir.replace('~', os.homedir()) : baseDir;
@@ -1476,7 +2216,7 @@ async function handleHttpRequest(req, res) {
1476
2216
  try {
1477
2217
  if (req.method === 'GET' && pathname === '/api/config') {
1478
2218
  sendJson(res, 200, {
1479
- config: cliManager.getConfig(),
2219
+ config: getClientConfig(),
1480
2220
  status: cliManager.status,
1481
2221
  sessionId: currentSessionId,
1482
2222
  time: now(),
@@ -1516,24 +2256,58 @@ async function handleHttpRequest(req, res) {
1516
2256
  return;
1517
2257
  }
1518
2258
  if (req.method === 'GET' && pathname === '/api/history') {
1519
- const tasks = store.getHistoryTasks({ workDir: getCurrentWorkDir() });
2259
+ const workDir = getCurrentHistoryWorkDir();
2260
+ const tasks = workDir ? store.getHistoryTasks({ workDir }) : [];
1520
2261
  sendJson(res, 200, { tasks: tasks.map(task => toClientHistoryTask(task)) });
1521
2262
  return;
1522
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
+ }
1523
2292
  const historyId = getHistoryIdFromPath(pathname);
1524
2293
  if (historyId && req.method === 'GET') {
1525
- const task = store.getHistoryTask(historyId, getCurrentWorkDir());
2294
+ const workDir = getCurrentHistoryWorkDir();
2295
+ const task = workDir ? store.getHistoryTask(historyId, workDir) : null;
1526
2296
  sendJson(res, 200, { task: task ? toClientHistoryTask(task, true) : null });
1527
2297
  return;
1528
2298
  }
1529
2299
  if (historyId && req.method === 'DELETE') {
1530
- store.deleteHistoryTask(historyId, getCurrentWorkDir());
2300
+ const workDir = getCurrentHistoryWorkDir();
2301
+ if (workDir)
2302
+ store.deleteHistoryTask(historyId, workDir);
1531
2303
  sendJson(res, 200, { id: historyId });
1532
2304
  broadcast({ type: 'history_changed', time: now() });
1533
2305
  return;
1534
2306
  }
1535
2307
  if (req.method === 'DELETE' && pathname === '/api/history') {
1536
- store.clearHistory(getCurrentWorkDir());
2308
+ const workDir = getCurrentHistoryWorkDir();
2309
+ if (workDir)
2310
+ store.clearHistory(workDir);
1537
2311
  sendJson(res, 200, { success: true });
1538
2312
  broadcast({ type: 'history_changed', time: now() });
1539
2313
  return;
@@ -1549,6 +2323,15 @@ async function handleHttpRequest(req, res) {
1549
2323
  sendJson(res, 200, listSystemDir(url.searchParams.get('dir'), includeFiles));
1550
2324
  return;
1551
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
+ }
1552
2335
  if (req.method === 'GET' && pathname === '/api/workdir/tree') {
1553
2336
  sendJson(res, 200, listWorkspaceDir(url.searchParams.get('path') || ''));
1554
2337
  return;
@@ -1588,6 +2371,11 @@ async function handleHttpRequest(req, res) {
1588
2371
  sendJson(res, 200, unstageWorkspacePath(body.path));
1589
2372
  return;
1590
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
+ }
1591
2379
  if (req.method === 'POST' && pathname === '/api/workdir/commit-message') {
1592
2380
  sendJson(res, 200, await generateWorkspaceCommitMessage());
1593
2381
  return;
@@ -1677,6 +2465,14 @@ const PERSISTABLE_TYPES = new Set([
1677
2465
  'status',
1678
2466
  ]);
1679
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
+ }
1680
2476
  // Persist CLI session ID when we get it from system init or result
1681
2477
  if (currentSessionId && event.type === 'system' && event.details?.session_id) {
1682
2478
  try {
@@ -1757,6 +2553,14 @@ function broadcast(event) {
1757
2553
  if (!event.id)
1758
2554
  event.id = persisted.id;
1759
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
+ }
1760
2564
  }
1761
2565
  }
1762
2566
  catch (err) {
@@ -1773,16 +2577,25 @@ function broadcast(event) {
1773
2577
  catch { }
1774
2578
  }
1775
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
+ }
1776
2585
  }
1777
2586
  cliManager.setEventHandler(broadcast);
2587
+ if (cliManager.status === 'disconnected') {
2588
+ cliManager.restoreReady();
2589
+ }
1778
2590
  wss.on('connection', (ws) => {
1779
2591
  clients.add(ws);
1780
2592
  console.log(`[WS] Client connected (total: ${clients.size})`);
1781
2593
  ws.send(JSON.stringify({
1782
2594
  type: 'connected',
1783
- config: cliManager.getConfig(),
2595
+ config: getClientConfig(),
1784
2596
  status: cliManager.status,
1785
2597
  sessionId: currentSessionId,
2598
+ ...getPromptQueuePayload(),
1786
2599
  }));
1787
2600
  ws.on('message', (data) => {
1788
2601
  let msg;
@@ -1813,6 +2626,7 @@ wss.on('connection', (ws) => {
1813
2626
  function handleMessage(ws, msg) {
1814
2627
  switch (msg.type) {
1815
2628
  case 'start_cli': {
2629
+ clearPromptQueue();
1816
2630
  // Archive current session
1817
2631
  if (currentSessionId) {
1818
2632
  store.archiveSession(currentSessionId, 'completed');
@@ -1836,20 +2650,40 @@ function handleMessage(ws, msg) {
1836
2650
  broadcast({
1837
2651
  type: 'session_reset',
1838
2652
  sessionId: currentSessionId,
1839
- config: cliManager.getConfig(),
2653
+ config: getClientConfig(),
1840
2654
  time: now(),
1841
2655
  });
1842
2656
  broadcast({ type: 'history_changed', time: now() });
1843
2657
  broadcast({ type: 'workdirs_changed', current: getCurrentWorkDir(), time: now() });
1844
2658
  break;
1845
2659
  }
1846
- case 'send_message':
1847
- cliManager.sendInput(msg.content);
1848
- broadcast({
1849
- type: 'user_message',
1850
- content: msg.content,
1851
- time: now(),
1852
- });
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();
1853
2687
  break;
1854
2688
  case 'confirm_response':
1855
2689
  cliManager.confirmResponse(msg.approved);
@@ -1858,9 +2692,14 @@ function handleMessage(ws, msg) {
1858
2692
  cliManager.questionResponse(msg.answer, msg.toolUseId);
1859
2693
  break;
1860
2694
  case 'interrupt':
2695
+ if (promptQueue.length > 0) {
2696
+ promptQueuePaused = true;
2697
+ broadcastPromptQueueChanged();
2698
+ }
1861
2699
  cliManager.interrupt();
1862
2700
  break;
1863
2701
  case 'restart_cli': {
2702
+ clearPromptQueue();
1864
2703
  // Archive current session before restarting
1865
2704
  if (currentSessionId) {
1866
2705
  const taskStatus = cliManager.status === 'running' || cliManager.status === 'confirm' ? 'running' :
@@ -1874,7 +2713,7 @@ function handleMessage(ws, msg) {
1874
2713
  broadcast({
1875
2714
  type: 'session_reset',
1876
2715
  sessionId: currentSessionId,
1877
- config: cliManager.getConfig(),
2716
+ config: getClientConfig(),
1878
2717
  time: now(),
1879
2718
  });
1880
2719
  broadcast({ type: 'history_changed', time: now() });
@@ -1892,7 +2731,7 @@ function handleMessage(ws, msg) {
1892
2731
  case 'get_config':
1893
2732
  ws.send(JSON.stringify({
1894
2733
  type: 'config',
1895
- config: cliManager.getConfig(),
2734
+ config: getClientConfig(),
1896
2735
  status: cliManager.status,
1897
2736
  sessionId: currentSessionId,
1898
2737
  time: now(),
@@ -1935,7 +2774,8 @@ function handleMessage(ws, msg) {
1935
2774
  }
1936
2775
  // --- New protocol: History ---
1937
2776
  case 'get_history': {
1938
- const tasks = store.getHistoryTasks({ workDir: getCurrentWorkDir() });
2777
+ const workDir = getCurrentHistoryWorkDir();
2778
+ const tasks = workDir ? store.getHistoryTasks({ workDir }) : [];
1939
2779
  ws.send(JSON.stringify({
1940
2780
  type: 'history_data',
1941
2781
  requestId: msg.requestId,
@@ -1944,7 +2784,8 @@ function handleMessage(ws, msg) {
1944
2784
  break;
1945
2785
  }
1946
2786
  case 'get_history_task': {
1947
- const task = store.getHistoryTask(msg.id, getCurrentWorkDir());
2787
+ const workDir = getCurrentHistoryWorkDir();
2788
+ const task = workDir ? store.getHistoryTask(msg.id, workDir) : null;
1948
2789
  ws.send(JSON.stringify({
1949
2790
  type: 'history_task_data',
1950
2791
  requestId: msg.requestId,
@@ -1962,7 +2803,8 @@ function handleMessage(ws, msg) {
1962
2803
  }));
1963
2804
  break;
1964
2805
  }
1965
- const task = store.getHistoryTask(msg.id, getCurrentWorkDir());
2806
+ const workDir = getCurrentHistoryWorkDir();
2807
+ const task = workDir ? store.getHistoryTask(msg.id, workDir) : null;
1966
2808
  const cliSessionId = task ? getRestorableCliSessionId(task.session_id, task.messages) : undefined;
1967
2809
  if (!task || !task.session_id || !cliSessionId) {
1968
2810
  ws.send(JSON.stringify({
@@ -1973,7 +2815,7 @@ function handleMessage(ws, msg) {
1973
2815
  }));
1974
2816
  break;
1975
2817
  }
1976
- const restored = store.resumeHistoryTask(msg.id, currentSessionId, getCurrentWorkDir());
2818
+ const restored = store.resumeHistoryTask(msg.id, currentSessionId, workDir);
1977
2819
  if (!restored) {
1978
2820
  ws.send(JSON.stringify({
1979
2821
  type: 'history_resumed',
@@ -1988,6 +2830,9 @@ function handleMessage(ws, msg) {
1988
2830
  ...getSessionConfig(restored.session),
1989
2831
  cliSessionId,
1990
2832
  };
2833
+ if (typeof restoredConfig.workDir !== 'string' && typeof restored.work_dir === 'string') {
2834
+ restoredConfig.workDir = restored.work_dir;
2835
+ }
1991
2836
  cliManager.restoreConfig(restoredConfig);
1992
2837
  cliManager.restoreSessionId(cliSessionId);
1993
2838
  if (typeof restoredConfig.workDir === 'string') {
@@ -2003,7 +2848,7 @@ function handleMessage(ws, msg) {
2003
2848
  broadcast({
2004
2849
  type: 'session_restored',
2005
2850
  sessionId: currentSessionId,
2006
- config: cliManager.getConfig(),
2851
+ config: getClientConfig(),
2007
2852
  status: cliManager.status,
2008
2853
  messages: sessionMessages,
2009
2854
  lastSeq,
@@ -2025,7 +2870,7 @@ function handleMessage(ws, msg) {
2025
2870
  requestId: msg.requestId,
2026
2871
  success: true,
2027
2872
  sessionId: currentSessionId,
2028
- config: cliManager.getConfig(),
2873
+ config: getClientConfig(),
2029
2874
  messages: sessionMessages,
2030
2875
  lastSeq,
2031
2876
  sent: !!followUp,
@@ -2081,7 +2926,7 @@ function handleMessage(ws, msg) {
2081
2926
  broadcast({
2082
2927
  type: 'session_restored',
2083
2928
  sessionId: currentSessionId,
2084
- config: cliManager.getConfig(),
2929
+ config: getClientConfig(),
2085
2930
  status: cliManager.status,
2086
2931
  messages: sessionMessages,
2087
2932
  lastSeq,
@@ -2103,7 +2948,9 @@ function handleMessage(ws, msg) {
2103
2948
  break;
2104
2949
  }
2105
2950
  case 'delete_history': {
2106
- store.deleteHistoryTask(msg.id, getCurrentWorkDir());
2951
+ const workDir = getCurrentHistoryWorkDir();
2952
+ if (workDir)
2953
+ store.deleteHistoryTask(msg.id, workDir);
2107
2954
  ws.send(JSON.stringify({
2108
2955
  type: 'history_deleted',
2109
2956
  requestId: msg.requestId,
@@ -2114,7 +2961,9 @@ function handleMessage(ws, msg) {
2114
2961
  break;
2115
2962
  }
2116
2963
  case 'clear_history': {
2117
- store.clearHistory(getCurrentWorkDir());
2964
+ const workDir = getCurrentHistoryWorkDir();
2965
+ if (workDir)
2966
+ store.clearHistory(workDir);
2118
2967
  ws.send(JSON.stringify({
2119
2968
  type: 'history_cleared',
2120
2969
  requestId: msg.requestId,
@@ -2275,7 +3124,7 @@ function handleMessage(ws, msg) {
2275
3124
  }
2276
3125
  }
2277
3126
  function now() {
2278
- return new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
3127
+ return new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hourCycle: 'h23' });
2279
3128
  }
2280
3129
  function formatHostForUrl(host) {
2281
3130
  return host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;