cc-viewer 1.6.348 → 1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (222) hide show
  1. package/README.md +13 -1
  2. package/cli.js +116 -37
  3. package/dist/assets/App-gUSmU6Ny.js +2 -0
  4. package/dist/assets/{MdxEditorPanel-ualoRFOL.js → MdxEditorPanel-DAR39A3S.js} +1 -1
  5. package/dist/assets/Mobile-qfRsRth0.js +1 -0
  6. package/dist/assets/{_baseUniq-CG81mgN_.js → _baseUniq-D9_E5ZDp.js} +1 -1
  7. package/dist/assets/{arc-C161J4f2.js → arc-D4iFrlhb.js} +1 -1
  8. package/dist/assets/{architectureDiagram-Q4EWVU46-BmbDMdn9.js → architectureDiagram-Q4EWVU46-f1neo7hg.js} +1 -1
  9. package/dist/assets/{blockDiagram-DXYQGD6D-DXpMvdxd.js → blockDiagram-DXYQGD6D-DWi9e9tG.js} +1 -1
  10. package/dist/assets/{c4Diagram-AHTNJAMY-8Mypg0IU.js → c4Diagram-AHTNJAMY-DAdj49A2.js} +1 -1
  11. package/dist/assets/{channel-B2khdmvc.js → channel-B3VthT0m.js} +1 -1
  12. package/dist/assets/{chunk-4BX2VUAB-I61r1g6z.js → chunk-4BX2VUAB-BrPcAHV3.js} +1 -1
  13. package/dist/assets/{chunk-4TB4RGXK-VFPlRE5E.js → chunk-4TB4RGXK-Bvopc1V9.js} +1 -1
  14. package/dist/assets/{chunk-55IACEB6-DvCamg_6.js → chunk-55IACEB6-Juw-7Z3i.js} +1 -1
  15. package/dist/assets/{chunk-EDXVE4YY-Dj4Q8RwR.js → chunk-EDXVE4YY-DVYP9tpB.js} +1 -1
  16. package/dist/assets/{chunk-FMBD7UC4-_bgQuuJh.js → chunk-FMBD7UC4-VQ7VOXl3.js} +1 -1
  17. package/dist/assets/{chunk-OYMX7WX6-3to38LyN.js → chunk-OYMX7WX6-F8Rj4ZUZ.js} +1 -1
  18. package/dist/assets/{chunk-QZHKN3VN-Zl26wKKz.js → chunk-QZHKN3VN-Dn7K3yi9.js} +1 -1
  19. package/dist/assets/{chunk-YZCP3GAM-RLYPrlh0.js → chunk-YZCP3GAM-UjO-KTsn.js} +1 -1
  20. package/dist/assets/classDiagram-6PBFFD2Q-DmSozxoO.js +1 -0
  21. package/dist/assets/classDiagram-v2-HSJHXN6E-DmSozxoO.js +1 -0
  22. package/dist/assets/clone-BQtvy9Ae.js +1 -0
  23. package/dist/assets/{cose-bilkent-S5V4N54A-CsYOjJ0V.js → cose-bilkent-S5V4N54A-BDhNj8rq.js} +1 -1
  24. package/dist/assets/{dagre-KV5264BT-C3YXXaFU.js → dagre-KV5264BT-BzdcDuyt.js} +1 -1
  25. package/dist/assets/{diagram-5BDNPKRD-DUCOg1ld.js → diagram-5BDNPKRD-DP_SX65N.js} +1 -1
  26. package/dist/assets/{diagram-G4DWMVQ6-BtlLl_zX.js → diagram-G4DWMVQ6-B-pBFw8Q.js} +1 -1
  27. package/dist/assets/{diagram-MMDJMWI5-1Qp9xfOk.js → diagram-MMDJMWI5-6XSfN1eN.js} +1 -1
  28. package/dist/assets/{diagram-TYMM5635-Cj7VMp3L.js → diagram-TYMM5635-DJvGxvpE.js} +1 -1
  29. package/dist/assets/{erDiagram-SMLLAGMA-CVZVn541.js → erDiagram-SMLLAGMA-CpqdpGYj.js} +1 -1
  30. package/dist/assets/{flowDiagram-DWJPFMVM-CiU9Fkkv.js → flowDiagram-DWJPFMVM-C9m7wjwI.js} +1 -1
  31. package/dist/assets/{ganttDiagram-T4ZO3ILL-aRaG97ID.js → ganttDiagram-T4ZO3ILL-CU0XOfY3.js} +1 -1
  32. package/dist/assets/{gitGraphDiagram-UUTBAWPF-Cr2LJlvX.js → gitGraphDiagram-UUTBAWPF-ipyGULnP.js} +1 -1
  33. package/dist/assets/{graph-B59lDyMn.js → graph-BR2KmES9.js} +1 -1
  34. package/dist/assets/{index-C1ptnCpq.js → index--wgRW_OB.js} +1 -1
  35. package/dist/assets/index-B51Ci0GL.js +2 -0
  36. package/dist/assets/{index-CRAWA8PC.js → index-C-be29ey.js} +1 -1
  37. package/dist/assets/{index-CSM1Kw8l.js → index-C16fiVhv.js} +1 -1
  38. package/dist/assets/{index-Ddk-kg22.js → index-D5YruOKD.js} +1 -1
  39. package/dist/assets/{index-BnhNIjbj.js → index-D5tSeMU7.js} +1 -1
  40. package/dist/assets/{index-vFEQq-NB.js → index-DuZQ6FqR.js} +1 -1
  41. package/dist/assets/{index-CO2hZ1s4.js → index-OWzSX2T8.js} +1 -1
  42. package/dist/assets/{infoDiagram-42DDH7IO-CnO22bw8.js → infoDiagram-42DDH7IO-BQWQjfFX.js} +1 -1
  43. package/dist/assets/{ishikawaDiagram-UXIWVN3A-ByNzpScH.js → ishikawaDiagram-UXIWVN3A-Df-KH_Hz.js} +1 -1
  44. package/dist/assets/{journeyDiagram-VCZTEJTY-yaVaCWPq.js → journeyDiagram-VCZTEJTY-CMbB_4G9.js} +1 -1
  45. package/dist/assets/{jszip.min-BdVFP3OW.js → jszip.min-B6k2t9Ch.js} +1 -1
  46. package/dist/assets/{kanban-definition-6JOO6SKY-RIPR_RQ3.js → kanban-definition-6JOO6SKY-CImdlKN3.js} +1 -1
  47. package/dist/assets/{layout-0rzel0v_.js → layout-DQl4aw09.js} +1 -1
  48. package/dist/assets/{linear-sktN0tB6.js → linear-CLD3aPlK.js} +1 -1
  49. package/dist/assets/mermaid.core-CN35dvKJ.js +7 -0
  50. package/dist/assets/{min-rspGOkz_.js → min-_D86henJ.js} +1 -1
  51. package/dist/assets/{mindmap-definition-QFDTVHPH-BsVzZzTz.js → mindmap-definition-QFDTVHPH-2JVcu1nk.js} +1 -1
  52. package/dist/assets/{pieDiagram-DEJITSTG-CsoWsw4f.js → pieDiagram-DEJITSTG-Dik-m5u7.js} +1 -1
  53. package/dist/assets/{quadrantDiagram-34T5L4WZ-CTMKuCAI.js → quadrantDiagram-34T5L4WZ-Bm5l40Jq.js} +1 -1
  54. package/dist/assets/{requirementDiagram-MS252O5E-C67256te.js → requirementDiagram-MS252O5E-Ck9w5rTj.js} +1 -1
  55. package/dist/assets/{sankeyDiagram-XADWPNL6-X8Y7KnW0.js → sankeyDiagram-XADWPNL6-BbGVCFEf.js} +1 -1
  56. package/dist/assets/{seqResourceLoaders-B5Zy_CC_.css → seqResourceLoaders-DFrHmgnK.css} +1 -1
  57. package/dist/assets/seqResourceLoaders-DZJxMMaR.js +2 -0
  58. package/dist/assets/{sequenceDiagram-FGHM5R23-Cils0TAp.js → sequenceDiagram-FGHM5R23-vjvEdksk.js} +1 -1
  59. package/dist/assets/{stateDiagram-FHFEXIEX-DFnohvpG.js → stateDiagram-FHFEXIEX-DTqMoiB2.js} +1 -1
  60. package/dist/assets/{stateDiagram-v2-QKLJ7IA2-BTX_4o81.js → stateDiagram-v2-QKLJ7IA2-CNbAwnhu.js} +1 -1
  61. package/dist/assets/{timeline-definition-GMOUNBTQ-DrIYFrzZ.js → timeline-definition-GMOUNBTQ-Cw8O-52d.js} +1 -1
  62. package/dist/assets/{vendor-antd-D5HmSDSg.js → vendor-antd-DI7JL-mE.js} +1 -1
  63. package/dist/assets/{vendor-codemirror-C5yuj7ze.js → vendor-codemirror-B9c49dtM.js} +1 -1
  64. package/dist/assets/{vendor-mdxeditor-BcEooTvb.js → vendor-mdxeditor-B6cpBtIE.js} +2 -2
  65. package/dist/assets/{vendor-qrcode-d9LCn0-9.js → vendor-qrcode-Dn90-s-u.js} +1 -1
  66. package/dist/assets/{vendor-virtuoso-K9m4MN6a.js → vendor-virtuoso-CpdSKD_6.js} +1 -1
  67. package/dist/assets/{vennDiagram-DHZGUBPP-lRqBYTr9.js → vennDiagram-DHZGUBPP-DCuqymb7.js} +1 -1
  68. package/dist/assets/{wardley-RL74JXVD-e8YfxpOe.js → wardley-RL74JXVD-BJ20wJUU.js} +1 -1
  69. package/dist/assets/{wardleyDiagram-NUSXRM2D-Bxrg774h.js → wardleyDiagram-NUSXRM2D-Vz6US3qt.js} +1 -1
  70. package/dist/assets/{xychartDiagram-5P7HB3ND-DWhZqhq8.js → xychartDiagram-5P7HB3ND-CzkLKrmi.js} +1 -1
  71. package/dist/index.html +4 -4
  72. package/findcc.js +4 -3
  73. package/package.json +3 -1
  74. package/server/i18n.js +21 -141
  75. package/server/interceptor.js +211 -403
  76. package/server/lib/async-write-queue.js +12 -1
  77. package/server/lib/delta-reconstructor.js +3 -3
  78. package/server/lib/error-report.js +45 -0
  79. package/server/lib/im-log-watcher.js +68 -8
  80. package/server/lib/interceptor-core.js +19 -174
  81. package/server/lib/log-management.js +214 -247
  82. package/server/lib/log-stream.js +59 -108
  83. package/server/lib/log-watcher.js +62 -274
  84. package/server/lib/log-zip.js +148 -0
  85. package/server/lib/session-boundary.js +266 -0
  86. package/server/lib/session-pin-store.js +10 -19
  87. package/server/lib/stats-worker.js +138 -267
  88. package/server/lib/user-prompt-extract.js +250 -0
  89. package/server/lib/v2/adapter.js +1124 -0
  90. package/server/lib/v2/blob-store.js +44 -0
  91. package/server/lib/v2/conversation-store.js +217 -0
  92. package/server/lib/v2/convert-manager.js +79 -0
  93. package/server/lib/v2/convert-worker.js +42 -0
  94. package/server/lib/v2/convert.js +409 -0
  95. package/server/lib/v2/identity.js +204 -0
  96. package/server/lib/v2/journal.js +62 -0
  97. package/server/lib/v2/layout.js +167 -0
  98. package/server/lib/v2/live-feed.js +558 -0
  99. package/server/lib/v2/meta-rows.js +360 -0
  100. package/server/lib/v2/migrate-prompt.js +54 -0
  101. package/server/lib/v2/replay.js +191 -0
  102. package/server/lib/v2/session-select.js +222 -0
  103. package/server/lib/v2/singleflight.js +104 -0
  104. package/server/lib/v2/v2-writer.js +528 -0
  105. package/server/lib/v2/verify.js +233 -0
  106. package/server/lib/wire-compress.js +167 -0
  107. package/server/routes/events.js +120 -150
  108. package/server/routes/files-fs.js +4 -2
  109. package/server/routes/im.js +11 -2
  110. package/server/routes/logs.js +209 -164
  111. package/server/routes/preferences.js +0 -3
  112. package/server/routes/project-meta.js +2 -3
  113. package/server/routes/session-pin.js +7 -8
  114. package/server/routes/v2.js +60 -0
  115. package/server/routes/workspaces.js +50 -14
  116. package/server/server.js +70 -66
  117. package/server/workspace-registry.js +24 -4
  118. package/src/utils/apiUrl.js +26 -0
  119. package/src/utils/askFallback.js +21 -0
  120. package/src/utils/askOptionDesc.js +55 -0
  121. package/src/utils/askPortalMatcher.js +54 -0
  122. package/src/utils/autoApproveOptions.js +27 -0
  123. package/src/utils/avatarAnimationPostPass.js +49 -0
  124. package/src/utils/builtinPresets.js +18 -0
  125. package/src/utils/clearCheckpoint.js +7 -0
  126. package/src/utils/commandValidator.js +45 -0
  127. package/src/utils/contentFilter.js +620 -0
  128. package/src/utils/contextRaw.js +18 -0
  129. package/src/utils/contextSidebarNavigation.js +16 -0
  130. package/src/utils/contextTurns.js +117 -0
  131. package/src/utils/displayScaleHelper.js +47 -0
  132. package/src/utils/effectiveModel.js +13 -0
  133. package/src/utils/entry-slim.js +563 -0
  134. package/src/utils/entryCache.js +144 -0
  135. package/src/utils/errorReport.js +6 -0
  136. package/src/utils/fileExpandedPathsStorage.js +70 -0
  137. package/src/utils/fileIcons.jsx +36 -0
  138. package/src/utils/fileOpen.js +37 -0
  139. package/src/utils/formatters.js +48 -0
  140. package/src/utils/gitApi.js +50 -0
  141. package/src/utils/gitTreeBuilder.js +19 -0
  142. package/src/utils/helpers.js +846 -0
  143. package/src/utils/identityHeal.js +99 -0
  144. package/src/utils/imConnState.js +59 -0
  145. package/src/utils/imOrigin.js +24 -0
  146. package/src/utils/imTr.js +7 -0
  147. package/src/utils/imageCompress.js +83 -0
  148. package/src/utils/imageDownscale.js +28 -0
  149. package/src/utils/imageResize.js +112 -0
  150. package/src/utils/ingestPipeline.js +47 -0
  151. package/src/utils/lazyWithReload.js +53 -0
  152. package/src/utils/markdown.js +30 -0
  153. package/src/utils/markdownIncremental.js +39 -0
  154. package/src/utils/markdownProfiler.js +113 -0
  155. package/src/utils/mdExtensionDetect.js +64 -0
  156. package/src/utils/memoryLinkParser.js +44 -0
  157. package/src/utils/modalMask.js +15 -0
  158. package/src/utils/monotime.js +6 -0
  159. package/src/utils/pinnedMenu.js +55 -0
  160. package/src/utils/presetShortcuts.js +14 -0
  161. package/src/utils/projectAlias.js +135 -0
  162. package/src/utils/promptClassifier.js +112 -0
  163. package/src/utils/promptDetect.js +196 -0
  164. package/src/utils/promptNav.js +60 -0
  165. package/src/utils/ptyChunkBuilder.js +251 -0
  166. package/src/utils/quickMenuHoverIntent.js +35 -0
  167. package/src/utils/rateLimitParser.js +119 -0
  168. package/src/utils/readResultPool.js +106 -0
  169. package/src/utils/refreshCachedItemProp.js +30 -0
  170. package/src/utils/requestType.js +208 -0
  171. package/src/utils/resizeCalc.js +21 -0
  172. package/src/utils/resolveLocalized.js +17 -0
  173. package/src/utils/searchApi.js +47 -0
  174. package/src/utils/searchReplace.js +51 -0
  175. package/src/utils/seqResourceLoaders.js +79 -0
  176. package/src/utils/sessionManager.js +471 -0
  177. package/src/utils/sessionMerge.js +180 -0
  178. package/src/utils/skillModalController.js +111 -0
  179. package/src/utils/skillsParser.js +116 -0
  180. package/src/utils/slashCommandLabels.js +80 -0
  181. package/src/utils/splitDragCalc.js +73 -0
  182. package/src/utils/stickyBottomController.js +643 -0
  183. package/src/utils/svgSanitize.js +48 -0
  184. package/src/utils/systemTags.js +47 -0
  185. package/src/utils/tClaude.js +20 -0
  186. package/src/utils/teamModalBuilder.js +351 -0
  187. package/src/utils/teamSessionParser.js +190 -0
  188. package/src/utils/teammateAvatars.js +194 -0
  189. package/src/utils/teammateDetector.js +121 -0
  190. package/src/utils/termDiag.js +114 -0
  191. package/src/utils/terminalClipboard.js +117 -0
  192. package/src/utils/terminalWriteQueue.js +398 -0
  193. package/src/utils/thinkingMerge.js +49 -0
  194. package/src/utils/toolCatalog.js +29 -0
  195. package/src/utils/toolResultBuilder.js +393 -0
  196. package/src/utils/toolResultClassifier.js +16 -0
  197. package/src/utils/toolResultCore.js +167 -0
  198. package/src/utils/toolsDiff.js +44 -0
  199. package/src/utils/toolsXmlFormatter.js +3 -0
  200. package/src/utils/ultraplanController.js +109 -0
  201. package/src/utils/ultraplanExperts.js +86 -0
  202. package/src/utils/ultraplanTemplates.js +154 -0
  203. package/src/utils/userImageRefs.js +49 -0
  204. package/src/utils/v3Assembler.js +112 -0
  205. package/src/utils/v3Rows.js +48 -0
  206. package/src/utils/voicePackPlayer.js +233 -0
  207. package/src/utils/webSearchGrouping.js +111 -0
  208. package/src/utils/workflowFormat.js +52 -0
  209. package/src/utils/workflowRuns.js +118 -0
  210. package/src/utils/workflowStore.js +94 -0
  211. package/dist/assets/App-VTo4NVdo.js +0 -2
  212. package/dist/assets/Mobile-CcmYvMm_.js +0 -1
  213. package/dist/assets/classDiagram-6PBFFD2Q-BXnyUgsj.js +0 -1
  214. package/dist/assets/classDiagram-v2-HSJHXN6E-BXnyUgsj.js +0 -1
  215. package/dist/assets/clone-md-Mc1-T.js +0 -1
  216. package/dist/assets/index-DCo650PG.js +0 -2
  217. package/dist/assets/mermaid.core-C2Parj1q.js +0 -7
  218. package/dist/assets/seqResourceLoaders-BVozq68i.js +0 -2
  219. package/server/lib/instance-registry.js +0 -57
  220. package/server/lib/jsonl-archive.js +0 -180
  221. package/server/lib/teammate-detect.js +0 -32
  222. package/server/lib/zip-safety.js +0 -68
@@ -1,11 +1,23 @@
1
1
  // Workspace routes (moved verbatim from server.js handleRequest).
2
2
  import { existsSync, statSync } from 'node:fs';
3
3
  import { basename } from 'node:path';
4
- import { LOG_FILE, initForWorkspace, resetWorkspace } from '../interceptor.js';
5
- import { watchLogFile, unwatchAll } from '../lib/log-watcher.js';
4
+ import { initForWorkspace, resetWorkspace, getLiveLogSource, markContinuedLaunch, markForkSession, markResumeSession, isContinuedLaunch } from '../interceptor.js';
5
+ import { LOG_DIR } from '../../findcc.js';
6
+ import { migrationStatus } from '../lib/v2/migrate-prompt.js';
7
+ import { reportSwallowed } from '../lib/error-report.js';
8
+
9
+ // P2: claude continuation flags — the workspace launcher injects `-c` itself
10
+ // (WorkspaceList's logCount heuristic), so argv scanning in cli.js never sees
11
+ // it; this is detection channel ② of interceptor.isContinuedLaunch().
12
+ const CONTINUE_FLAGS = new Set(['-c', '--continue', '-r', '--resume']);
13
+ // Explicit resume flags — continued for the migrate prompt, but `-c` folder
14
+ // adoption must NOT fire (it targets the latest main session, not the user's
15
+ // chosen one). Mirrors cli.js's CCV_CLAUDE_RESUME channel.
16
+ const RESUME_FLAGS = new Set(['-r', '--resume']);
6
17
  import { unwatchAllWorkflows } from '../lib/workflow-watcher.js';
7
18
  import { readClaudeProjectModel } from '../lib/context-watcher.js';
8
19
  import { countLogEntries, streamRawEntriesAsync } from '../lib/log-stream.js';
20
+ import { sseWrite } from '../lib/wire-compress.js';
9
21
 
10
22
  function workspacesList(req, res, parsedUrl, isLocal, deps) {
11
23
  import('../workspace-registry.js').then(async ({ getWorkspaces }) => {
@@ -49,18 +61,28 @@ function workspacesLaunch(req, res, parsedUrl, isLocal, deps) {
49
61
  const result = initForWorkspace(wsPath);
50
62
  process.env.CCV_PROJECT_DIR = wsPath;
51
63
 
52
- // 启动日志监听
53
- watchLogFile(deps.logWatcherOpts(LOG_FILE));
64
+ // 启动日志监听(S6b: v2 live feed when the v2 writer is active)
65
+ deps.startLogWatch();
54
66
 
55
67
  // 启动 stats worker(如果尚未启动)
56
68
  if (!deps.statsWorker) deps.startStatsWorker();
57
69
  deps.startStreamingStatusTimer();
58
70
 
71
+ // P2 检测通道②:workspace 启动器注入的 -c/--continue/-r/--resume(无论
72
+ // 是否走 PTY 分支都要打标——迁移提示的 continued 语义只取决于参数本身)。
73
+ const mergedArgs = [...deps.workspaceClaudeArgs, ...(Array.isArray(launchExtraArgs) ? launchExtraArgs : [])];
74
+ if (mergedArgs.some((a) => CONTINUE_FLAGS.has(a))) markContinuedLaunch();
75
+ // `--fork-session`: a continuation that mints a NEW session id on purpose —
76
+ // `-c` folder adoption must NOT fire for it.
77
+ if (mergedArgs.includes('--fork-session')) markForkSession();
78
+ // `-r`/`--resume`: user-chosen target session — adoption must not redirect
79
+ // it to the latest main session.
80
+ if (mergedArgs.some((a) => RESUME_FLAGS.has(a))) markResumeSession();
81
+
59
82
  // 启动 PTY
60
83
  const proxyPort = process.env.CCV_PROXY_PORT;
61
84
  if (proxyPort) {
62
85
  const { spawnClaude } = await import('../pty-manager.js');
63
- const mergedArgs = [...deps.workspaceClaudeArgs, ...(Array.isArray(launchExtraArgs) ? launchExtraArgs : [])];
64
86
  await spawnClaude(parseInt(proxyPort), wsPath, mergedArgs, deps.workspaceClaudePath, deps.workspaceIsNpmVersion, deps.actualPort, deps.protocol, deps.INTERNAL_TOKEN);
65
87
  }
66
88
 
@@ -70,24 +92,38 @@ function workspacesLaunch(req, res, parsedUrl, isLocal, deps) {
70
92
  const startedPayload = `event: workspace_started\ndata: ${JSON.stringify({ projectName: result.projectName, path: wsPath, claudeProjectModel: readClaudeProjectModel(wsPath) })}\n\n`;
71
93
  deps.clients.forEach(client => {
72
94
  try {
73
- client.write(startedPayload);
95
+ sseWrite(client, startedPayload);
74
96
  } catch {}
75
97
  });
76
98
 
77
99
  // 流式分段广播以刷新会话区域,避免全量加载 OOM
78
- const wsReloadTotal = await countLogEntries(LOG_FILE);
100
+ // S6b: the live source is the v2 session dir when the v2 writer is
101
+ // active (a fresh workspace has no session yet → empty stream, the live
102
+ // feed picks up from the first request).
103
+ const wsReloadSource = getLiveLogSource();
104
+ const wsReloadTotal = await countLogEntries(wsReloadSource);
79
105
  deps.clients.forEach(client => {
80
- try { client.write(`event: load_start\ndata: ${JSON.stringify({ total: wsReloadTotal, incremental: false })}\n\n`); } catch {}
106
+ try { sseWrite(client, `event: load_start\ndata: ${JSON.stringify({ total: wsReloadTotal, incremental: false })}\n\n`); } catch {}
81
107
  });
82
- await streamRawEntriesAsync(LOG_FILE, (raw) => {
108
+ await streamRawEntriesAsync(wsReloadSource, (raw) => {
83
109
  deps.clients.forEach(client => {
84
- try { client.write('event: load_chunk\ndata: ['); client.write(raw.replace(/\n/g, '')); client.write(']\n\n'); } catch {}
110
+ try { sseWrite(client, 'event: load_chunk\ndata: ['); sseWrite(client, raw.replace(/\n/g, '')); sseWrite(client, ']\n\n'); } catch {}
85
111
  });
86
112
  });
87
113
  deps.clients.forEach(client => {
88
- try { client.write(`event: load_end\ndata: {}\n\n`); } catch {}
114
+ try { sseWrite(client, `event: load_end\ndata: {}\n\n`); } catch {}
89
115
  });
90
116
 
117
+ // 1.7.0 迁移引导(P2):切进的项目仍有未转换 v1 日志 → 对存量连接广播
118
+ // migrate_prompt(新连接由 /events 的连接帧覆盖)。
119
+ try {
120
+ const mig = migrationStatus(LOG_DIR, result.projectName || '');
121
+ if (mig.pending) {
122
+ const frame = `event: migrate_prompt\ndata: ${JSON.stringify({ ...mig, continued: isContinuedLaunch() })}\n\n`;
123
+ deps.clients.forEach(client => { try { sseWrite(client, frame); } catch {} });
124
+ }
125
+ } catch (e) { reportSwallowed('sse.migrate_prompt', e); }
126
+
91
127
  res.writeHead(200, { 'Content-Type': 'application/json' });
92
128
  res.end(JSON.stringify({ ok: true, projectName: result.projectName }));
93
129
  } catch (err) {
@@ -139,8 +175,8 @@ function workspacesStop(req, res, parsedUrl, isLocal, deps) {
139
175
  ]).then(() => {
140
176
  // 接续原有清理流程
141
177
 
142
- // 停止日志监听
143
- unwatchAll();
178
+ // 停止日志监听(v1 tail + v2 live feed)
179
+ deps.stopLogWatch();
144
180
  unwatchAllWorkflows();
145
181
 
146
182
  // 重置 interceptor 状态
@@ -150,7 +186,7 @@ function workspacesStop(req, res, parsedUrl, isLocal, deps) {
150
186
  // 通知所有 SSE 客户端
151
187
  deps.clients.forEach(client => {
152
188
  try {
153
- client.write(`event: workspace_stopped\ndata: {}\n\n`);
189
+ sseWrite(client, `event: workspace_stopped\ndata: {}\n\n`);
154
190
  } catch {}
155
191
  });
156
192
 
package/server/server.js CHANGED
@@ -9,6 +9,7 @@ import { execFile, exec, spawn } from 'node:child_process';
9
9
  import { promisify } from 'node:util';
10
10
  import { Worker } from 'node:worker_threads';
11
11
  import { isPathContained } from './lib/file-api.js';
12
+ import { sseWrite, isWireV3Enabled } from './lib/wire-compress.js';
12
13
  import { setEntry as askStoreSetEntry, deleteEntry as askStoreDeleteEntry, pruneStale as askStorePruneStale, markAnswered as askStoreMarkAnswered, markCancelled as askStoreMarkCancelled, loadAskStore as askStoreLoad } from './lib/ask-store.js';
13
14
  import { ASK_TIMEOUT_MS, ASK_WAITER_REAP_INTERVAL_MS, ASK_WAITER_LIVENESS_MS } from './lib/ask-constants.js';
14
15
  import { reapDeadAskWaiters, sweepOrphanedDiskAsks } from './lib/ask-reaper.js';
@@ -33,6 +34,7 @@ import { searchRoutes } from './routes/search.js';
33
34
  import { workspacesRoutes } from './routes/workspaces.js';
34
35
  import { expertRoutes } from './routes/expert.js';
35
36
  import { eventsRoutes } from './routes/events.js';
37
+ import { v2Routes } from './routes/v2.js';
36
38
  import { askPermRoutes } from './routes/ask-perm.js';
37
39
  import { teamRoutes } from './routes/team.js';
38
40
  import { authRoutes } from './routes/auth.js';
@@ -74,18 +76,19 @@ function execWithStdin(cmd, args, input, options) {
74
76
  child.stdin.end();
75
77
  });
76
78
  }
77
- import { LOG_FILE, _initPromise, _resumeState, _projectName, _logDir, streamingState, resetStreamingState, PROFILE_PATH, setLivePort, getImLiveText, resetImLiveText } from './interceptor.js';
78
- import { recordInstance, listInstances } from './lib/instance-registry.js';
79
+ import { _initPromise, _projectName, _logDir, _v2Writer, streamingState, resetStreamingState, PROFILE_PATH, setLivePort, getImLiveText, resetImLiveText } from './interceptor.js';
80
+ import { V2LiveFeed } from './lib/v2/live-feed.js';
81
+ import { sanitizePathComponent } from './lib/v2/layout.js';
82
+ import { maybeResumeConvert } from './lib/v2/convert-manager.js';
79
83
  import { LOG_DIR, setLogDir, getClaudeConfigDir, isBrowserOpenSuppressed } from '../findcc.js';
80
84
  import { t, getLang, setLang } from './i18n.js';
81
85
  import { loadAuthConfig, loadAuthState, saveAuthConfig, clearProjectOverride, generatePassword, decideAuth, parseCookies, renderLoginPage, localeFromAcceptLanguage } from './lib/auth.js';
82
86
  import { checkAndUpdate } from './lib/updater.js';
83
87
  import { loadPlugins, runWaterfallHook, runParallelHook } from './lib/plugin-loader.js';
84
88
  import { CONTEXT_WINDOW_FILE, readModelContextSize } from './lib/context-watcher.js';
85
- import { watchLogFile, startWatching, unwatchAll, sendEventToClients, sendToClients } from './lib/log-watcher.js';
89
+ import { sendEventToClients, sendToClients } from './lib/log-watcher.js';
86
90
  import { createImLogWatcher } from './lib/im-log-watcher.js';
87
91
  import { unwatchAllWorkflows } from './lib/workflow-watcher.js';
88
- import { cleanupExtractCache } from './lib/jsonl-archive.js';
89
92
  import { backupConfigs } from './lib/config-backup.js';
90
93
  import { normalizeBasePath, validateBasePath, stripBasePath } from './lib/base-path.js';
91
94
  import { createHardenedCleanup } from './lib/term-signals.js';
@@ -111,12 +114,6 @@ try {
111
114
  const isCliMode = process.env.CCV_CLI_MODE === '1';
112
115
  const isSdkMode = process.env.CCV_SDK_MODE === '1';
113
116
  const isWorkspaceMode = process.env.CCV_WORKSPACE_MODE === '1';
114
- // Instance id from `ccv --pid <name>` (sanitized in cli.js). null = default mode (no
115
- // per-instance isolation): the session-pin file falls back to the project-shared key.
116
- const INSTANCE_ID = process.env.CCV_INSTANCE_ID || null;
117
- // Remember this id under the project so the CLI banner can list past ids next run (memory
118
- // aid only). Fire-and-forget; no-ops when there's no active project (e.g. workspace mode).
119
- if (INSTANCE_ID && _logDir) recordInstance(_logDir, INSTANCE_ID).catch(() => {});
120
117
  const _defaultProxyProfiles = { active: 'max', profiles: [{ id: 'max', name: 'Default' }] };
121
118
  const _maskApiKey = (k) => k && typeof k === 'string' && k.length > 4 ? '****' + k.slice(-4) : k ? '****' : '';
122
119
  const _maskProfiles = (data) => {
@@ -305,9 +302,12 @@ let _launchCallback = null;
305
302
  export function setLaunchCallback(fn) { _launchCallback = fn; }
306
303
  export function setWorkspaceLaunched(v) { _workspaceLaunched = v; }
307
304
  export function initPostLaunch() {
308
- watchLogFile(_logWatcherOpts(LOG_FILE));
305
+ startLogWatch();
309
306
  if (!statsWorker) startStatsWorker();
310
307
  startStreamingStatusTimer();
308
+ // wire-v2 S8: a migration the previous process left unfinished resumes here
309
+ // (resident-until-done, user decision; file-level checkpoints make it cheap).
310
+ try { if (_projectName) maybeResumeConvert(LOG_DIR, _projectName); } catch (err) { console.error('[CC Viewer] wire-v2 convert resume failed:', err.message); }
311
311
  }
312
312
 
313
313
  // Global POST body size limit (10MB) to prevent OOM from malicious/buggy clients
@@ -317,6 +317,22 @@ const MAX_POST_BODY = 10 * 1024 * 1024;
317
317
  // 防止长会话把数十 MB 历史一次性灌进浏览器导致 renderer OOM。
318
318
  // 用户显式 ?limit=0 可恢复全量加载(power-user 逃生口)。
319
319
  const DEFAULT_EVENTS_LIMIT = 1000;
320
+ // Wire v3 flag (V3.S6: DEFAULT ON) — read ONCE at startup; a read/UI-shape
321
+ // toggle the server and client must agree on per process, not per request
322
+ // (unlike wire-compress's per-connection content negotiation). Broadcast to
323
+ // clients via the server_config SSE frame; tests inject deps.wireV3 directly.
324
+ // CCV_WIRE_V3=0 (or =off) is the escape hatch back to the legacy full-entry
325
+ // wire — kept (not deleted) for one release cycle before the legacy live UI
326
+ // path is removed; v1 legacy FILES always use the legacy pipeline regardless.
327
+ const WIRE_V3 = isWireV3Enabled(process.env.CCV_WIRE_V3);
328
+ // Build stamp broadcast in server_config: a tab that survives a server
329
+ // upgrade reconnects with a STALE JS bundle that lacks the new wire's
330
+ // listeners (silent empty session). The fresh bundle compares this stamp on
331
+ // reconnect and reloads itself on mismatch (review P2-a; heals upgrades from
332
+ // this version onward — older bundles predate the guard).
333
+ const SERVER_BUILD = (() => {
334
+ try { return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8')).version || ''; } catch { return ''; }
335
+ })();
320
336
  // SSE 单客户端 backpressure 容忍上限:连续未排空 > 此时长则视为 dead 客户端剔除。
321
337
  // 调高至 30s:大会话首屏/重连重放时,渲染器(尤其 Windows 浏览器,大 DOM layout 更重)
322
338
  // 可能短暂忙到来不及排空 socket。过早剔除会触发「断开→EventSource 自动重连→再次重放」
@@ -418,16 +434,35 @@ const MIME_TYPES = {
418
434
  '.woff2': 'font/woff2',
419
435
  };
420
436
 
421
- // Helper to build log-watcher options object
422
- function _logWatcherOpts(logFile) {
423
- return {
424
- logFile: logFile || LOG_FILE,
425
- clients,
426
- getClaudePid,
427
- runParallelHook,
428
- notifyStatsWorker,
429
- getLogFile: () => LOG_FILE,
430
- };
437
+ // S6b: v2 live feed the live channel when the v2 writer is active. Lazy
438
+ // singleton so worker/test paths that never watch anything pay nothing.
439
+ let _v2LiveFeed = null;
440
+ function _ensureV2LiveFeed() {
441
+ if (!_v2LiveFeed) {
442
+ _v2LiveFeed = new V2LiveFeed({
443
+ clients,
444
+ getClaudePid,
445
+ runParallelHook,
446
+ // The stats worker's v2 units are session dirs (stats-worker.js v9).
447
+ notifyStatsWorker,
448
+ wireV3: WIRE_V3,
449
+ });
450
+ }
451
+ return _v2LiveFeed;
452
+ }
453
+
454
+ /** Start the live channel for the current project (v2 live feed). No-op until
455
+ * a project is bound (workspace mode defers to the launch route). */
456
+ function startLogWatch() {
457
+ if (!_projectName) return;
458
+ const feed = _ensureV2LiveFeed();
459
+ feed.start(join(LOG_DIR, sanitizePathComponent(_projectName)));
460
+ _v2Writer.setOnActivity((dir) => feed.tick(dir));
461
+ }
462
+
463
+ function stopLogWatch() {
464
+ if (_v2LiveFeed) _v2LiveFeed.stop();
465
+ _v2Writer.setOnActivity(null);
431
466
  }
432
467
 
433
468
  function getLocalIp() {
@@ -516,7 +551,8 @@ const deps = {
516
551
  persistAskEntry: _persistAskEntry,
517
552
  persistAskDelete: _persistAskDelete,
518
553
  notifyParentPending: _notifyParentPending,
519
- logWatcherOpts: _logWatcherOpts,
554
+ startLogWatch,
555
+ stopLogWatch,
520
556
  scheduleTurnEndBroadcast: _scheduleTurnEndBroadcast,
521
557
  ensureImWatch: _ensureImWatch,
522
558
  maskProfiles: _maskProfiles,
@@ -574,11 +610,12 @@ const deps = {
574
610
  WINDOWS_RESERVED_NAMES,
575
611
  DEFAULT_EVENTS_LIMIT,
576
612
  SSE_BACKPRESSURE_TIMEOUT_MS,
613
+ wireV3: WIRE_V3,
614
+ serverBuild: SERVER_BUILD,
577
615
  IGNORED_PATTERNS,
578
616
  isCliMode,
579
617
  isSdkMode,
580
618
  isWorkspaceMode,
581
- instanceId: INSTANCE_ID,
582
619
  defaultProxyProfiles: _defaultProxyProfiles,
583
620
  };
584
621
 
@@ -607,6 +644,7 @@ const _routes = [
607
644
  ...workspacesRoutes,
608
645
  ...expertRoutes,
609
646
  ...eventsRoutes,
647
+ ...v2Routes,
610
648
  ...askPermRoutes,
611
649
  ...teamRoutes,
612
650
  ...dingtalkRoutes,
@@ -855,9 +893,6 @@ export async function startViewer() {
855
893
  // 加载插件(需要在创建服务器之前,以便通过 hook 获取 HTTPS 证书)
856
894
  await loadPlugins();
857
895
 
858
- // 清理过期解压缓存(fire-and-forget;任何错误吞掉)
859
- setImmediate(() => { try { cleanupExtractCache(); } catch { /* ignore */ } });
860
-
861
896
  // 启动期配置备份:preferences/profile/workspaces → LOG_DIR 外的 cc-viewer-config-backups/
862
897
  // (滚动留 10 份)。2026-06-06 事故:配置随 LOG_DIR 整树丢失后无处可恢复。fire-and-forget。
863
898
  setImmediate(() => { try { backupConfigs(); } catch { /* ignore */ } });
@@ -1025,9 +1060,11 @@ export async function startViewer() {
1025
1060
  // 工作区模式下延迟到选择工作区后再启动监听
1026
1061
  if (!isWorkspaceMode) {
1027
1062
  readModelContextSize(); // Cache model→size mapping at startup
1028
- startWatching(_logWatcherOpts(LOG_FILE));
1063
+ startLogWatch();
1029
1064
  startStatsWorker();
1030
1065
  startStreamingStatusTimer();
1066
+ // wire-v2 S8: resume an unfinished migration (see initPostLaunch)
1067
+ try { if (_projectName) maybeResumeConvert(LOG_DIR, _projectName); } catch (err) { console.error('[CC Viewer] wire-v2 convert resume failed:', err.message); }
1031
1068
  }
1032
1069
  // CLI 模式下启动 WebSocket 服务 (必须 await,否则插件 hook 拿不到 upgrade listeners)
1033
1070
  if (isCliMode) {
@@ -2004,15 +2041,6 @@ export function getAuthConfig() {
2004
2041
  return authConfig;
2005
2042
  }
2006
2043
 
2007
- // Instance id (`--pid`) + the project's previously-used ids — printed by cli.js in the
2008
- // CLI-mode startup banner so the user can recall which id to reuse with `-c` next time.
2009
- export function getInstanceId() {
2010
- return INSTANCE_ID;
2011
- }
2012
- export function getKnownInstances() {
2013
- try { return listInstances(_logDir); } catch { return []; }
2014
- }
2015
-
2016
2044
  // In-process broadcast helper for the `turn_end` SSE event. Two callers:
2017
2045
  // 1. /api/turn-end-notify POST handler (PTY/CLI mode via Stop hook bridge)
2018
2046
  // 2. cli.js runSdkMode → sdkManager.initSdkSession({ onTurnEnd }) (SDK mode —
@@ -2238,28 +2266,12 @@ async function _doStop() {
2238
2266
  new Promise(r => setTimeout(r, 3000)),
2239
2267
  ]);
2240
2268
  } catch { }
2241
- // 如果用户未做选择,将临时文件转为正式文件
2242
- if (_resumeState && _resumeState.tempFile) {
2243
- try {
2244
- const { tempFile } = _resumeState;
2245
- if (existsSync(tempFile)) {
2246
- // 只有非空 temp 文件才 rename 为正式文件,空文件直接删除
2247
- const sz = statSync(tempFile).size;
2248
- if (sz > 0) {
2249
- const newPath = tempFile.replace('_temp.jsonl', '.jsonl');
2250
- renameSync(tempFile, newPath);
2251
- } else {
2252
- unlinkSync(tempFile);
2253
- }
2254
- }
2255
- } catch { }
2256
- }
2257
- unwatchAll();
2269
+ stopLogWatch();
2258
2270
  unwatchAllWorkflows();
2259
2271
  unwatchFile(CONTEXT_WINDOW_FILE);
2260
2272
  clients.forEach(client => client.end());
2261
2273
  // Truncate in place (not `clients = []`) so the array reference stays stable across
2262
- // stop/start cycles — deps.clients and _logWatcherOpts() hold this same reference.
2274
+ // stop/start cycles — deps.clients holds this same reference.
2263
2275
  clients.length = 0;
2264
2276
  if (server) {
2265
2277
  // 销毁所有活跃连接,防止 keep-alive 阻止进程退出
@@ -2385,7 +2397,7 @@ if (!isWorkspaceMode) {
2385
2397
  pendingMajorUpdate = { version: result.remoteVersion, source: result.status };
2386
2398
  const payload = JSON.stringify(pendingMajorUpdate);
2387
2399
  clients.forEach(client => {
2388
- try { client.write(`event: update_major_available\ndata: ${payload}\n\n`); } catch { }
2400
+ try { sseWrite(client, `event: update_major_available\ndata: ${payload}\n\n`); } catch { }
2389
2401
  });
2390
2402
  } else if (result.status === 'upgrading_in_background') {
2391
2403
  console.error(`[CC Viewer] background upgrade to ${result.remoteVersion} started (active after next launch)`);
@@ -2399,17 +2411,9 @@ if (!isWorkspaceMode) {
2399
2411
  });
2400
2412
  }
2401
2413
 
2402
- // 进程退出时,将未决的临时文件转为正式文件
2403
- function handleExit() {
2404
- if (_resumeState && _resumeState.tempFile) {
2405
- try {
2406
- if (existsSync(_resumeState.tempFile)) {
2407
- const newPath = _resumeState.tempFile.replace('_temp.jsonl', '.jsonl');
2408
- renameSync(_resumeState.tempFile, newPath);
2409
- }
2410
- } catch { }
2411
- }
2412
- }
2414
+ // 进程退出钩子(v1 时代负责临时文件转正;v2 无暂存文件,保留空挂点防
2415
+ // 未来清理动作无处可挂)
2416
+ function handleExit() {}
2413
2417
  // 防御性单次注册:ESM cache 已保证模块顶层只执行一次,但若未来重构把这段
2414
2418
  // 移入函数(cleanupViewer 等)或用 dev hot-reload,缺守卫会导致 handler 堆积
2415
2419
  // → 进程退出时多次调用 stopViewer。globalThis flag 兼容所有 import 路径。
@@ -3,6 +3,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from '
3
3
  import { readdir, stat } from 'node:fs/promises';
4
4
  import { renameSyncWithRetry } from './lib/file-api.js';
5
5
  import { withFileLockAsync } from './lib/async-file-lock.js';
6
+ import { dirSizeSync } from './lib/v2/layout.js';
6
7
  import { join, basename, resolve } from 'node:path';
7
8
  import { randomBytes } from 'node:crypto';
8
9
  import { LOG_DIR } from '../findcc.js';
@@ -92,19 +93,38 @@ export async function removeWorkspace(id) {
92
93
  export async function getWorkspaces() {
93
94
  const list = loadWorkspaces();
94
95
  const enriched = await Promise.all(list.map(async (w) => {
95
- let logCount = 0;
96
+ // wire-v2 (1.7.0): logs live in per-session dirs under sessions/. The v1
97
+ // *.jsonl glob is kept as unmigratedV1Count — the combined logCount must
98
+ // stay non-zero for a declined-migration workspace, because the launcher's
99
+ // `logCount > 0 → auto -c` heuristic hangs off it (WorkspaceList.jsx).
100
+ let sessionCount = 0;
101
+ let unmigratedV1Count = 0;
96
102
  let totalSize = 0;
97
103
  const logDir = join(LOG_DIR, w.projectName);
98
104
  try {
99
105
  const files = await readdir(logDir);
100
106
  for (const f of files) {
101
- if (f.endsWith('.jsonl')) {
102
- logCount++;
107
+ if (f.endsWith('.jsonl') && !f.endsWith('_temp.jsonl')) {
108
+ unmigratedV1Count++;
103
109
  try { totalSize += (await stat(join(logDir, f))).size; } catch { }
104
110
  }
105
111
  }
106
112
  } catch { }
107
- return { ...w, logCount, totalSize };
113
+ try {
114
+ const sids = await readdir(join(logDir, 'sessions'), { withFileTypes: true });
115
+ for (const e of sids) {
116
+ if (!e.isDirectory()) continue;
117
+ const sessionDir = join(logDir, 'sessions', e.name);
118
+ try {
119
+ await stat(join(sessionDir, 'journal.jsonl'));
120
+ // Folder size, not journal size — conv/blob/response files carry
121
+ // most of a session's bytes (journal alone undercounts ~12x).
122
+ totalSize += dirSizeSync(sessionDir);
123
+ sessionCount++;
124
+ } catch { /* dir without a journal is not a session yet */ }
125
+ }
126
+ } catch { }
127
+ return { ...w, logCount: sessionCount + unmigratedV1Count, sessionCount, unmigratedV1Count, totalSize };
108
128
  }));
109
129
  return enriched.sort((a, b) => new Date(b.lastUsed) - new Date(a.lastUsed));
110
130
  }
@@ -0,0 +1,26 @@
1
+ // 从 URL 中提取 LAN 访问 token,附加到所有 API 请求 / WebSocket 握手
2
+ const _urlToken = new URLSearchParams(window.location.search).get('token');
3
+
4
+ // 反向代理子路径部署: 从 <base> 标签或 SSR 注入的全局变量读取 base path,
5
+ // 使 API/WebSocket 等动态请求也能正确路由到代理后端。
6
+ export function getBasePath() {
7
+ if (typeof window !== 'undefined' && window.__CCV_BASE_PATH__) return window.__CCV_BASE_PATH__;
8
+ if (typeof document === 'undefined' || !document.querySelector) return '';
9
+ const base = document.querySelector('base');
10
+ if (base && base.href) return base.getAttribute('href');
11
+ return '';
12
+ }
13
+
14
+ // 把 token 追加到任意 URL(HTTP path 或 ws:// 完整 URL 皆可)。无 token 时原样返回。
15
+ // WS 握手必须也带 token —— 否则启用鉴权后远程「?token=」终端会被 socket.destroy()。
16
+ export function appendToken(url) {
17
+ if (!_urlToken) return url;
18
+ const sep = url.includes('?') ? '&' : '?';
19
+ return `${url}${sep}token=${_urlToken}`;
20
+ }
21
+
22
+ export function apiUrl(path) {
23
+ const base = getBasePath();
24
+ const fullPath = base ? base.replace(/\/$/, '') + path : path;
25
+ return appendToken(fullPath);
26
+ }
@@ -0,0 +1,21 @@
1
+ // Decision helper for ApprovalModal's fallback AskQuestionForm.
2
+ //
3
+ // The Question modal body is normally filled by the transcript tool_use block
4
+ // portaling an AskQuestionForm into the modal's ask slot (see askPortalMatcher.js).
5
+ // When no block portals in — a stale replayed ask whose block is old history, or a
6
+ // fresh ask arriving before transcript ingest — the modal would show an empty body.
7
+ // The fallback renders a form directly from the authoritative pending-ask broadcast.
8
+ //
9
+ // Contract: `slotOccupied` is DOM occupancy of the ask slot (MutationObserver on
10
+ // childList + sync initial read); `graceElapsed` flips true ASK_FALLBACK_GRACE_MS
11
+ // after the ask becomes visible, giving the real portal one commit cycle so fresh
12
+ // asks never flash the fallback before the portaled form mounts.
13
+
14
+ export const ASK_FALLBACK_GRACE_MS = 120;
15
+
16
+ export function shouldRenderAskFallback({ isAskActive, slotOccupied, graceElapsed, questions, submitHandler }) {
17
+ if (!isAskActive || slotOccupied || !graceElapsed) return false;
18
+ if (!Array.isArray(questions) || questions.length === 0) return false;
19
+ if (typeof submitHandler !== 'function') return false;
20
+ return true;
21
+ }
@@ -0,0 +1,55 @@
1
+ import { isPlaceholderAskId } from './askPortalMatcher.js';
2
+
3
+ // AskUserQuestion options[].description is schema-optional; centralize fallback
4
+ // so AskQuestionForm and ChatMessage recap stay aligned.
5
+
6
+ export function optionAriaLabel(opt) {
7
+ if (!opt || opt.label == null) return '';
8
+ return opt.description
9
+ ? `${opt.label}: ${opt.description}`
10
+ : String(opt.label);
11
+ }
12
+
13
+ export function hasOptionDescription(opt) {
14
+ return Boolean(opt && opt.description);
15
+ }
16
+
17
+ // Pick the questions the interactive AskUserQuestion card actually renders.
18
+ //
19
+ // The modal body is filled by the inline tool_use block (reconstructed from the log
20
+ // stream) portaling into the modal's askSlot — it does not read pendingAsk.questions
21
+ // (the authoritative copy broadcast over WS ask-hook-pending / sdk-ask-pending, fully
22
+ // parsed server-side before the hook fires) directly. During streaming of a large
23
+ // payload there is a window where the modal is already open but the reconstructed
24
+ // block's input is still partial JSON. Crucially, partial-JSON parsing materializes
25
+ // the outer questions[] array shape before the element content arrives, so the
26
+ // streamed copy can be HOLLOW AT EQUAL LENGTH (e.g. two question objects with empty
27
+ // text/options while big options[].preview strings are still streaming — the
28
+ // 2026-07-04 blank-popup regression; a strictly-longer length heuristic missed it).
29
+ //
30
+ // So: for the currently pending ask (toolId === lastPendingAskId, id-matched), always
31
+ // prefer the authoritative copy. It is complete by construction, and the submit path
32
+ // (handleAskQuestionSubmit in askFlowController) maps answer indices through the same
33
+ // authoritative _askHookQuestions copy — rendering it keeps rendered indices aligned
34
+ // with the mapping array. Historical blocks never match lastPendingAskId and always
35
+ // keep their own streamed questions (history view unchanged).
36
+ //
37
+ // Legacy/no-id servers use placeholder pendingAsk ids ('__ask__', 'ask_<ts>_<rnd>')
38
+ // that can never equal the real tool_use id. There the authoritative copy is only
39
+ // borrowed when the streamed render would otherwise be completely empty — it can fill
40
+ // a blank popup but never override visible content (a stale legacy pendingAsk must
41
+ // not inject a previous ask's questions over a rendered one).
42
+ export function resolveAskQuestions(streamedQuestions, toolId, lastPendingAskId, pendingAsk) {
43
+ const streamed = Array.isArray(streamedQuestions) ? streamedQuestions : [];
44
+ if (!pendingAsk || toolId == null || toolId !== lastPendingAskId) {
45
+ return streamed;
46
+ }
47
+ const idMatches = pendingAsk.id === toolId;
48
+ if (!idMatches && !(isPlaceholderAskId(pendingAsk.id) && streamed.length === 0)) {
49
+ return streamed;
50
+ }
51
+ const authoritative = Array.isArray(pendingAsk.questions) ? pendingAsk.questions : [];
52
+ if (authoritative.length === 0) return streamed; // defensive: WS handlers guarantee non-empty
53
+ if (streamed.length > authoritative.length) return streamed; // never shrink (belt-and-braces)
54
+ return authoritative;
55
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Portal 决策:判断 inline AskQuestionForm(在 ChatMessage 里渲染的某条 tool_use)
3
+ * 是否应该 createPortal 到 ApprovalModal 的 askSlot。
4
+ *
5
+ * activeAskId 三种合法形态(与 server 端协议绑定,改一边必须改另一边):
6
+ *
7
+ * 1. `toolu_xxx` — SDK 模式 / 新版 Claude Code 透传的真实 tool_use_id
8
+ * → 走 strict 匹配:activeAskId === toolId
9
+ *
10
+ * 2. `__ask__` — LEGACY 老 server(pre-Map 协议)的单槽占位
11
+ * → 走 owner 通配:仅 owner-idx 锁定的那条 ChatMessage(lastPendingAskId === toolId)命中
12
+ *
13
+ * 3. `ask_${ts}_${rnd}` — 老 Claude Code PreToolUse hook payload 不带 tool_use_id 时
14
+ * server.js 自生成的 fallback id(修复 modal 标题在但内容空白的根因)
15
+ * → 走 owner 通配:与 __ask__ 同语义
16
+ *
17
+ * 协议锚点:
18
+ * - server 端 fallback id 生成:server.js(搜索 `ask_${Date.now()}` 定位)
19
+ * - server 端 LEGACY 占位推送:server.js 推送 ask-hook-pending 事件时 id 为 '__ask__'
20
+ * - 前端 setState pendingAsk:src/components/chat/ChatView.jsx
21
+ *
22
+ * 改 server 端 fallback id 格式时,必须同步更新此文件的 isFallbackId 检测。
23
+ */
24
+
25
+ const FALLBACK_ID_PREFIX = 'ask_';
26
+ const LEGACY_PLACEHOLDER_ID = '__ask__';
27
+
28
+ /**
29
+ * True when the given pending-ask id is a placeholder that can never equal a real
30
+ * tool_use id: the LEGACY single-slot '__ask__' or a server-generated 'ask_<ts>_<rnd>'
31
+ * fallback id. Consumers (e.g. resolveAskQuestions) use this to apply the same
32
+ * owner-wildcard semantics as shouldPortalAskForm on the legacy paths.
33
+ */
34
+ export function isPlaceholderAskId(id) {
35
+ return id === LEGACY_PLACEHOLDER_ID
36
+ || (typeof id === 'string' && id.startsWith(FALLBACK_ID_PREFIX));
37
+ }
38
+
39
+ /**
40
+ * @param {*} activeAskId ApprovalPortalContext.Consumer 提供的 activeAskId(modal 当前 active ask 的 id)
41
+ * @param {*} toolId 当前 ChatMessage 里 tool_use 块的 id(toolu_xxx)
42
+ * @param {*} lastPendingAskId ChatMessage 收到的 owner-idx 派生信号;非 owner 永远为 null
43
+ * @returns {boolean} true 表示当前 ChatMessage 应该 portal 到 askSlot
44
+ */
45
+ export function shouldPortalAskForm(activeAskId, toolId, lastPendingAskId) {
46
+ if (activeAskId == null) return false;
47
+ // strict:SDK / 新 Claude Code 真实 tool_use_id
48
+ if (String(activeAskId) === String(toolId)) return true;
49
+ // owner 通配:__ask__(LEGACY 老 server)∪ ask_*(server fallback)
50
+ // 必须同时满足 owner-idx 锁定(lastPendingAskId === toolId),否则会让历史所有真实 toolId
51
+ // 都被通配命中,重现旧的双份 portal bug
52
+ if (isPlaceholderAskId(activeAskId) && lastPendingAskId === toolId) return true;
53
+ return false;
54
+ }
@@ -0,0 +1,27 @@
1
+ // 自动审批档位唯一事实源 —— 全部消费点共用同一组档位,防止各处字面量发散:
2
+ // 1. TerminalPanel 终端工具栏四芒星快捷菜单(权限/Plan 两行级联档位)
3
+ // 2. ChatInputBar 对话输入框四芒星快捷菜单(隐藏终端时,同款两行)
4
+ // 3. AppHeader 设置抽屉的权限/Plan 两个 Select
5
+ // 4. Mobile 设置面板的权限/Plan 两个 Select
6
+ // 三态语义(持久化于 preferences.autoApproveSeconds / approvalModal.planAutoApproveSeconds):
7
+ // 0 = 关闭(手动审批)
8
+ // > 0 = 倒计时该秒数后自动批准
9
+ // -1 = 免审批(AUTO_APPROVE_INSTANT):请求到达即放行,从源头绕过审批面板
10
+ // 本模块不依赖 helpers.js(它带 .svg?raw 等 Vite 专属 import),保持 node --test 可直接引用。
11
+ export const AUTO_APPROVE_INSTANT = -1;
12
+
13
+ export const PERM_AUTO_APPROVE_OPTIONS = [0, 3, 5, 10, AUTO_APPROVE_INSTANT];
14
+ export const PLAN_AUTO_APPROVE_OPTIONS = [0, 10, 30, 60, AUTO_APPROVE_INSTANT];
15
+
16
+ // 单个档位值 -> 显示文案;t 由调用方注入,避免本模块依赖前端 i18n。
17
+ // 档位之外的历史值(如 Plan 旧 3/5)同样落在 `${v}s` 分支正常显示。
18
+ export function autoApproveValueLabel(v, t) {
19
+ if (v === AUTO_APPROVE_INSTANT) return t('ui.permission.autoApprove.instant');
20
+ if (!v) return t('ui.permission.autoApprove.off');
21
+ return `${v}s`;
22
+ }
23
+
24
+ // 档位数组 -> antd Select options
25
+ export function autoApproveSelectOptions(values, t) {
26
+ return values.map(v => ({ value: v, label: autoApproveValueLabel(v, t) }));
27
+ }