cc-viewer 1.7.23 → 1.8.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 (262) hide show
  1. package/README.md +20 -0
  2. package/cli.js +92 -11
  3. package/concepts/ar/GlobalSettings.md +2 -2
  4. package/concepts/ar/ToolsFirst.md +1 -1
  5. package/concepts/da/GlobalSettings.md +2 -2
  6. package/concepts/da/ToolsFirst.md +1 -1
  7. package/concepts/de/GlobalSettings.md +2 -2
  8. package/concepts/de/ToolsFirst.md +1 -1
  9. package/concepts/en/GlobalSettings.md +2 -2
  10. package/concepts/en/ToolsFirst.md +1 -1
  11. package/concepts/es/GlobalSettings.md +2 -2
  12. package/concepts/es/ToolsFirst.md +1 -1
  13. package/concepts/fr/GlobalSettings.md +2 -2
  14. package/concepts/fr/ToolsFirst.md +1 -1
  15. package/concepts/it/GlobalSettings.md +2 -2
  16. package/concepts/it/ToolsFirst.md +1 -1
  17. package/concepts/ja/GlobalSettings.md +2 -2
  18. package/concepts/ja/ToolsFirst.md +1 -1
  19. package/concepts/ko/GlobalSettings.md +2 -2
  20. package/concepts/ko/ToolsFirst.md +1 -1
  21. package/concepts/no/GlobalSettings.md +2 -2
  22. package/concepts/no/ToolsFirst.md +1 -1
  23. package/concepts/pl/GlobalSettings.md +2 -2
  24. package/concepts/pl/ToolsFirst.md +1 -1
  25. package/concepts/pt-BR/GlobalSettings.md +2 -2
  26. package/concepts/pt-BR/ToolsFirst.md +1 -1
  27. package/concepts/ru/GlobalSettings.md +2 -2
  28. package/concepts/ru/ToolsFirst.md +1 -1
  29. package/concepts/th/GlobalSettings.md +2 -2
  30. package/concepts/th/ToolsFirst.md +1 -1
  31. package/concepts/tr/GlobalSettings.md +2 -2
  32. package/concepts/tr/ToolsFirst.md +1 -1
  33. package/concepts/uk/GlobalSettings.md +2 -2
  34. package/concepts/uk/ToolsFirst.md +1 -1
  35. package/concepts/zh/GlobalSettings.md +2 -2
  36. package/concepts/zh/ToolsFirst.md +1 -1
  37. package/concepts/zh-TW/GlobalSettings.md +2 -2
  38. package/concepts/zh-TW/ToolsFirst.md +1 -1
  39. package/dist/assets/App-CqQJDjfT.js +2 -0
  40. package/dist/assets/App-ssYfrjwF.css +1 -0
  41. package/dist/assets/{MdxEditorPanel-e5DlfcqJ.js → MdxEditorPanel-DXwb5tSD.js} +1 -1
  42. package/dist/assets/{Mobile-B8xwCLgr.js → Mobile-DOjKURO3.js} +1 -1
  43. package/dist/assets/{ProxyStatsModal-_w677xPM.js → ProxyStatsModal-BEnxiRXF.js} +1 -1
  44. package/dist/assets/index-CH_N7vvX.js +2 -0
  45. package/dist/assets/seqResourceLoaders-BJhO6zhW.js +2 -0
  46. package/dist/assets/seqResourceLoaders-DI0G5nu1.css +41 -0
  47. package/dist/index.html +1 -1
  48. package/findcc.js +17 -8
  49. package/node_modules/@ccv/core/package.json +13 -0
  50. package/{server/lib → node_modules/@ccv/core/src}/approval-modal-prefs.js +1 -1
  51. package/node_modules/@ccv/core/src/clearCheckpoint.js +7 -0
  52. package/{src/utils → node_modules/@ccv/core/src}/contentFilter.js +207 -141
  53. package/{server/lib → node_modules/@ccv/core/src}/context-rules.js +2 -2
  54. package/{server/lib → node_modules/@ccv/core/src}/delta-reconstructor.js +60 -4
  55. package/{server/lib → node_modules/@ccv/core/src}/error-report.js +5 -4
  56. package/{server/lib → node_modules/@ccv/core/src}/session-boundary.js +3 -3
  57. package/{src/utils → node_modules/@ccv/core/src}/teammateDetector.js +9 -0
  58. package/{server/lib → node_modules/@ccv/core/src}/tools-xml-formatter.js +3 -3
  59. package/{server/lib → node_modules/@ccv/core/src}/v2-transcript-normalizer.js +1 -1
  60. package/{server/lib → node_modules/@ccv/core/src}/voice-pack-events.js +2 -2
  61. package/package.json +11 -97
  62. package/server/_paths.js +28 -7
  63. package/server/i18n.js +60 -0
  64. package/server/interceptor.js +36 -11
  65. package/server/lib/adapters/dingtalk-adapter.js +2 -2
  66. package/server/lib/adapters/discord-adapter.js +2 -2
  67. package/server/lib/adapters/feishu-adapter.js +2 -2
  68. package/server/lib/adapters/wecom-adapter.js +2 -2
  69. package/server/lib/approval-policy.js +29 -0
  70. package/server/lib/{ask-store.js → ask/ask-store.js} +3 -3
  71. package/server/lib/{perm-bridge.js → ask/perm-bridge.js} +5 -5
  72. package/server/lib/builtin-model-prompts.js +198 -0
  73. package/server/lib/chat-queue.js +341 -0
  74. package/server/lib/cli-inject.js +1 -1
  75. package/server/lib/context-watcher.js +3 -3
  76. package/server/lib/create_system_prompt.js +0 -9
  77. package/server/lib/enrich-plan-input.js +1 -1
  78. package/server/lib/ensure-hooks.js +2 -2
  79. package/server/lib/file-access-policy.js +6 -1
  80. package/server/lib/file-api.js +17 -1
  81. package/server/lib/{dingtalk-bridge.js → im/dingtalk-bridge.js} +1 -1
  82. package/server/lib/{im-append-system.js → im/im-append-system.js} +5 -13
  83. package/server/lib/{im-bridge-core.js → im/im-bridge-core.js} +12 -6
  84. package/server/lib/{im-config.js → im/im-config.js} +1 -1
  85. package/server/lib/im/im-deny.js +4 -0
  86. package/server/lib/{im-lang.js → im/im-lang.js} +1 -1
  87. package/server/lib/{im-lock.js → im/im-lock.js} +2 -2
  88. package/server/lib/{im-process-manager.js → im/im-process-manager.js} +1 -1
  89. package/server/lib/{im-senders.js → im/im-senders.js} +1 -1
  90. package/server/lib/{im-skills.js → im/im-skills.js} +2 -2
  91. package/server/lib/interceptor-core.js +8 -5
  92. package/server/lib/kv-cache-analyzer.js +6 -5
  93. package/server/lib/launch-config.js +329 -0
  94. package/server/lib/log-file-utils.js +21 -0
  95. package/server/lib/log-management.js +6 -17
  96. package/server/lib/log-stream.js +1 -1
  97. package/server/lib/log-watcher.js +1 -1
  98. package/server/lib/model-system-prompts.js +18 -2
  99. package/server/lib/prefs-store.js +2 -2
  100. package/server/lib/project-prefs.js +6 -6
  101. package/server/lib/project-state.js +15 -0
  102. package/server/lib/{proxy-retry.js → proxy/proxy-retry.js} +2 -2
  103. package/server/lib/{proxy-stats.js → proxy/proxy-stats.js} +3 -3
  104. package/server/lib/sdk-adapter.js +6 -74
  105. package/server/lib/sdk-manager.js +383 -278
  106. package/server/lib/skills-api.js +1 -1
  107. package/server/lib/spawn-model-resolver.js +21 -6
  108. package/server/lib/stats-worker.js +2 -2
  109. package/server/lib/system-prompt-files.js +53 -15
  110. package/server/lib/system-prompt-render.js +23 -7
  111. package/server/lib/system-prompt-snapshots.js +452 -0
  112. package/server/lib/updater.js +63 -3
  113. package/server/lib/user-prompt-extract.js +5 -5
  114. package/server/lib/v2/adapter.js +3 -3
  115. package/server/lib/v2/conversation-store.js +1 -1
  116. package/server/lib/v2/convert.js +3 -3
  117. package/server/lib/v2/jsonl-read.js +1 -1
  118. package/server/lib/v2/live-feed.js +3 -3
  119. package/server/lib/v2/meta-rows.js +3 -3
  120. package/server/lib/v2/replay.js +1 -1
  121. package/server/lib/v2/session-list.js +1 -1
  122. package/server/lib/v2/session-owner.js +1 -1
  123. package/server/lib/v2/v2-writer.js +26 -1
  124. package/server/lib/v2/verify.js +1 -1
  125. package/server/lib/voice-pack-manager.js +4 -5
  126. package/server/lib/wire-compress.js +1 -1
  127. package/server/proxy.js +5 -5
  128. package/server/pty-manager.js +285 -184
  129. package/server/routes/ask-perm.js +1 -1
  130. package/server/routes/dingtalk.js +3 -3
  131. package/server/routes/events.js +2 -2
  132. package/server/routes/expert.js +74 -13
  133. package/server/routes/im.js +5 -5
  134. package/server/routes/preferences.js +3 -3
  135. package/server/routes/workspaces.js +1 -1
  136. package/server/server.js +207 -58
  137. package/server/system-prompt-templates/systemPromptModel.md +0 -8
  138. package/server/system-prompt-templates/systemPromptVariables.ar.md +0 -7
  139. package/server/system-prompt-templates/systemPromptVariables.da.md +0 -7
  140. package/server/system-prompt-templates/systemPromptVariables.de.md +0 -7
  141. package/server/system-prompt-templates/systemPromptVariables.es.md +0 -7
  142. package/server/system-prompt-templates/systemPromptVariables.fr.md +0 -7
  143. package/server/system-prompt-templates/systemPromptVariables.it.md +0 -7
  144. package/server/system-prompt-templates/systemPromptVariables.ja.md +0 -7
  145. package/server/system-prompt-templates/systemPromptVariables.ko.md +0 -7
  146. package/server/system-prompt-templates/systemPromptVariables.md +0 -7
  147. package/server/system-prompt-templates/systemPromptVariables.no.md +0 -7
  148. package/server/system-prompt-templates/systemPromptVariables.pl.md +0 -7
  149. package/server/system-prompt-templates/systemPromptVariables.pt-BR.md +0 -7
  150. package/server/system-prompt-templates/systemPromptVariables.ru.md +0 -7
  151. package/server/system-prompt-templates/systemPromptVariables.th.md +0 -7
  152. package/server/system-prompt-templates/systemPromptVariables.tr.md +0 -7
  153. package/server/system-prompt-templates/systemPromptVariables.uk.md +0 -7
  154. package/server/system-prompt-templates/systemPromptVariables.zh-TW.md +0 -7
  155. package/server/system-prompt-templates/systemPromptVariables.zh.md +0 -7
  156. package/ultraAgents/README.md +3 -0
  157. package/ultraAgents/test-analysis-expert.json +45 -0
  158. package/dist/assets/App-BWajJyJh.css +0 -1
  159. package/dist/assets/App-CusqoEAB.js +0 -2
  160. package/dist/assets/index-D6FmFsIz.js +0 -2
  161. package/dist/assets/seqResourceLoaders-3K77Znll.js +0 -2
  162. package/dist/assets/seqResourceLoaders-BgD_Rwvc.css +0 -41
  163. package/src/utils/apiUrl.js +0 -26
  164. package/src/utils/askFallback.js +0 -21
  165. package/src/utils/askOptionDesc.js +0 -55
  166. package/src/utils/askPortalMatcher.js +0 -54
  167. package/src/utils/autoApproveOptions.js +0 -27
  168. package/src/utils/avatarAnimationPostPass.js +0 -49
  169. package/src/utils/builtinPresets.js +0 -18
  170. package/src/utils/clearCheckpoint.js +0 -7
  171. package/src/utils/commandValidator.js +0 -45
  172. package/src/utils/contextRaw.js +0 -18
  173. package/src/utils/contextSidebarNavigation.js +0 -16
  174. package/src/utils/contextTurns.js +0 -117
  175. package/src/utils/displayScaleHelper.js +0 -47
  176. package/src/utils/effectiveModel.js +0 -17
  177. package/src/utils/entry-slim.js +0 -563
  178. package/src/utils/entryCache.js +0 -144
  179. package/src/utils/errorReport.js +0 -6
  180. package/src/utils/fileExpandedPathsStorage.js +0 -70
  181. package/src/utils/fileIcons.jsx +0 -36
  182. package/src/utils/fileOpen.js +0 -37
  183. package/src/utils/formatters.js +0 -48
  184. package/src/utils/gitApi.js +0 -50
  185. package/src/utils/gitTreeBuilder.js +0 -19
  186. package/src/utils/helpers.js +0 -854
  187. package/src/utils/identityHeal.js +0 -108
  188. package/src/utils/imConnState.js +0 -59
  189. package/src/utils/imOrigin.js +0 -24
  190. package/src/utils/imTr.js +0 -7
  191. package/src/utils/imageCompress.js +0 -83
  192. package/src/utils/imageDownscale.js +0 -28
  193. package/src/utils/imageResize.js +0 -112
  194. package/src/utils/ingestPipeline.js +0 -47
  195. package/src/utils/isProxyMode.js +0 -11
  196. package/src/utils/lazyWithReload.js +0 -53
  197. package/src/utils/markdown.js +0 -30
  198. package/src/utils/markdownIncremental.js +0 -39
  199. package/src/utils/markdownProfiler.js +0 -113
  200. package/src/utils/mdExtensionDetect.js +0 -64
  201. package/src/utils/memoryLinkParser.js +0 -44
  202. package/src/utils/modalMask.js +0 -15
  203. package/src/utils/modelSuggestions.js +0 -48
  204. package/src/utils/monotime.js +0 -6
  205. package/src/utils/pinnedMenu.js +0 -55
  206. package/src/utils/presetShortcuts.js +0 -14
  207. package/src/utils/projectAlias.js +0 -135
  208. package/src/utils/promptClassifier.js +0 -112
  209. package/src/utils/promptDetect.js +0 -196
  210. package/src/utils/promptNav.js +0 -60
  211. package/src/utils/ptyChunkBuilder.js +0 -251
  212. package/src/utils/quickMenuHoverIntent.js +0 -35
  213. package/src/utils/rateLimitParser.js +0 -119
  214. package/src/utils/readResultPool.js +0 -106
  215. package/src/utils/refreshCachedItemProp.js +0 -30
  216. package/src/utils/resizeCalc.js +0 -21
  217. package/src/utils/resolveLocalized.js +0 -17
  218. package/src/utils/searchApi.js +0 -47
  219. package/src/utils/searchReplace.js +0 -51
  220. package/src/utils/seqResourceLoaders.js +0 -79
  221. package/src/utils/sessionManager.js +0 -482
  222. package/src/utils/sessionMerge.js +0 -239
  223. package/src/utils/skillModalController.js +0 -111
  224. package/src/utils/skillsParser.js +0 -116
  225. package/src/utils/slashCommandLabels.js +0 -80
  226. package/src/utils/splitDragCalc.js +0 -73
  227. package/src/utils/stickyBottomController.js +0 -643
  228. package/src/utils/svgSanitize.js +0 -48
  229. package/src/utils/systemTags.js +0 -47
  230. package/src/utils/tClaude.js +0 -20
  231. package/src/utils/teamModalBuilder.js +0 -351
  232. package/src/utils/teamSessionParser.js +0 -190
  233. package/src/utils/teammateAvatars.js +0 -194
  234. package/src/utils/termDiag.js +0 -114
  235. package/src/utils/terminalClipboard.js +0 -117
  236. package/src/utils/terminalWriteQueue.js +0 -398
  237. package/src/utils/thinkingMerge.js +0 -49
  238. package/src/utils/toolCatalog.js +0 -30
  239. package/src/utils/toolResultBuilder.js +0 -411
  240. package/src/utils/toolResultClassifier.js +0 -16
  241. package/src/utils/toolResultCore.js +0 -194
  242. package/src/utils/toolsDiff.js +0 -44
  243. package/src/utils/toolsXmlFormatter.js +0 -3
  244. package/src/utils/ultraplanController.js +0 -109
  245. package/src/utils/ultraplanExperts.js +0 -86
  246. package/src/utils/ultraplanTemplates.js +0 -154
  247. package/src/utils/userImageRefs.js +0 -49
  248. package/src/utils/v3Assembler.js +0 -125
  249. package/src/utils/v3Rows.js +0 -49
  250. package/src/utils/voicePackPlayer.js +0 -233
  251. package/src/utils/webSearchGrouping.js +0 -111
  252. package/src/utils/workflowFormat.js +0 -52
  253. package/src/utils/workflowRuns.js +0 -118
  254. package/src/utils/workflowStore.js +0 -94
  255. /package/{src/utils → node_modules/@ccv/core/src}/requestType.js +0 -0
  256. /package/server/lib/{ask-bridge.js → ask/ask-bridge.js} +0 -0
  257. /package/server/lib/{ask-constants.js → ask/ask-constants.js} +0 -0
  258. /package/server/lib/{ask-reaper.js → ask/ask-reaper.js} +0 -0
  259. /package/server/lib/{dingtalk-config.js → im/dingtalk-config.js} +0 -0
  260. /package/server/lib/{im-log-watcher.js → im/im-log-watcher.js} +0 -0
  261. /package/server/lib/{proxy-env.js → proxy/proxy-env.js} +0 -0
  262. /package/server/lib/{proxy-errors.js → proxy/proxy-errors.js} +0 -0
@@ -1,5 +1,5 @@
1
1
  // Discord adapter — the platform-specific half of the Discord bridge. The generic orchestration
2
- // (dedup, access control, queue, inject, chunk, turn-end reply) lives in server/lib/im-bridge-core.js;
2
+ // (dedup, access control, queue, inject, chunk, turn-end reply) lives in server/lib/im/im-bridge-core.js;
3
3
  // this module only knows discord.js's Gateway client, inbound message shape, and outbound send.
4
4
  //
5
5
  // Uses discord.js v14: the bot dials OUT to Discord's Gateway WebSocket (no public URL). Credential is
@@ -12,7 +12,7 @@
12
12
  // Console prerequisite (no code equivalent — surfaced in the UI help): in the Discord Developer Portal,
13
13
  // create an app + bot, ENABLE the Message Content Intent (else message.content is silently empty), copy
14
14
  // the bot token, and invite the bot with the `bot`+`applications.commands` scopes and View/Send perms.
15
- import { registerAdapter } from '../im-bridge-core.js';
15
+ import { registerAdapter } from '../im/im-bridge-core.js';
16
16
 
17
17
  // ─── test seam: a fake discord.js factory (zero real gateway / socket) ───
18
18
  let sdkFactory = null;
@@ -1,6 +1,6 @@
1
1
  // Feishu/Lark adapter — the platform-specific half of the Feishu bridge. The generic
2
2
  // orchestration (dedup, access control, queue, inject, chunk, turn-end reply) lives in
3
- // server/lib/im-bridge-core.js; this module only knows Feishu's WebSocket long-connection event
3
+ // server/lib/im/im-bridge-core.js; this module only knows Feishu's WebSocket long-connection event
4
4
  // client, inbound payload shape, and outbound send.
5
5
  //
6
6
  // Inbound uses the official SDK's WSClient long-connection (NOT a webhook) so it works on
@@ -11,7 +11,7 @@
11
11
  // Console prerequisite (no code equivalent — surfaced to the user in the UI/README): the custom
12
12
  // app must set Event Subscription to "long connection", subscribe `im.message.receive_v1`, grant
13
13
  // the `im:message` send scope, and be published.
14
- import { registerAdapter } from '../im-bridge-core.js';
14
+ import { registerAdapter } from '../im/im-bridge-core.js';
15
15
 
16
16
  // ─── test seam: a fake SDK factory ({ Client, WSClient, EventDispatcher, Domain, LoggerLevel })
17
17
  // so unit tests never import the real SDK or open a socket. ───
@@ -1,6 +1,6 @@
1
1
  // 企业微信 (WeCom) adapter — the platform-specific half of the WeCom bridge. The generic
2
2
  // orchestration (dedup, access control, queue, inject, chunk, turn-end reply) lives in
3
- // server/lib/im-bridge-core.js; this module only knows WeCom's smart-robot long-connection client,
3
+ // server/lib/im/im-bridge-core.js; this module only knows WeCom's smart-robot long-connection client,
4
4
  // inbound frame shape, and outbound send.
5
5
  //
6
6
  // Uses the 企业微信智能机器人长连接 (Smart Robot long-connection) mode via @wecom/aibot-node-sdk:
@@ -12,7 +12,7 @@
12
12
  // Console prerequisite (no code equivalent — surfaced in the UI help/README): create a 智能机器人,
13
13
  // set its API 接收模式 to 长连接, copy the botId + secret, and add the bot to a chat.
14
14
  import { randomUUID } from 'node:crypto';
15
- import { registerAdapter } from '../im-bridge-core.js';
15
+ import { registerAdapter } from '../im/im-bridge-core.js';
16
16
 
17
17
  // ─── test seam: a fake SDK factory (zero real SDK / socket) ───
18
18
  let sdkFactory = null;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * approval-policy.js — Shared approval-policy predicates for the two approval paths.
3
+ *
4
+ * Single source of truth for:
5
+ * - APPROVAL_TOOLS: the six mutating/external tools that require explicit user
6
+ * approval (previously duplicated literally at ask/perm-bridge.js and
7
+ * sdk-manager.js).
8
+ * - isPublishCommand: the npm-publish hard gate. `npm publish` is the one
9
+ * irreversible outward-facing command (commits can be rewritten, pushes
10
+ * force-pushed back) — it must NEVER be auto-allowed, even under
11
+ * --dangerously-skip-permissions. Both consumers force it through the Web UI
12
+ * approval modal: perm-bridge.js exempts it from the CCV_BYPASS_PERMISSIONS
13
+ * auto-allow; sdk-manager.js routes it into the perm approval branch even in
14
+ * bypassPermissions mode.
15
+ *
16
+ * Pure leaf module (L1-lib): no imports, no I/O — importable from both L1-lib
17
+ * (sdk-manager.js) and L1-sub (ask/perm-bridge.js) without boundary-gate edges.
18
+ */
19
+
20
+ export const APPROVAL_TOOLS = new Set(['Bash', 'Edit', 'Write', 'NotebookEdit', 'WebFetch', 'WebSearch']);
21
+
22
+ /**
23
+ * @param {string} toolName
24
+ * @param {object} [input] — tool input ({ command } for Bash)
25
+ * @returns {boolean} true when the call is an `npm publish` invocation
26
+ */
27
+ export function isPublishCommand(toolName, input) {
28
+ return toolName === 'Bash' && typeof input?.command === 'string' && /npm\s+publish/i.test(input.command);
29
+ }
@@ -12,9 +12,9 @@
12
12
  import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from 'node:fs';
13
13
  import { join } from 'node:path';
14
14
  import { randomBytes } from 'node:crypto';
15
- import { renameSyncWithRetry } from './file-api.js';
16
- import { withFileLockAsync } from './async-file-lock.js';
17
- import { LOG_DIR } from '../../findcc.js';
15
+ import { renameSyncWithRetry } from '../file-api.js';
16
+ import { withFileLockAsync } from '../async-file-lock.js';
17
+ import { LOG_DIR } from '../../../findcc.js';
18
18
 
19
19
  const SCHEMA_VERSION = 1;
20
20
 
@@ -14,7 +14,8 @@
14
14
  import { readFileSync } from 'node:fs';
15
15
  import http from 'node:http';
16
16
  import https from 'node:https';
17
- import { evaluateImDeny } from './im-deny.js';
17
+ import { evaluateImDeny } from '../im-deny.js';
18
+ import { APPROVAL_TOOLS, isPublishCommand } from '../approval-policy.js';
18
19
 
19
20
  const port = process.env.CCVIEWER_PORT;
20
21
  const rawProtocol = process.env.CCVIEWER_PROTOCOL;
@@ -59,9 +60,8 @@ if (!toolName || !toolInput) {
59
60
  // 硬拦截:npm publish 即使在 --d (bypass) 模式下也强制走 Web UI 审批
60
61
  // 这是安全底线,不受 --dangerously-skip-permissions 影响
61
62
  // 注:git commit / git push 不在硬拦截范围内 —— 提交可重写、push 可 force-push 回退,
62
- // 真正不可撤销的对外发布(npm publish)才走硬闸
63
- const isPublishCmd = toolName === 'Bash' && toolInput.command &&
64
- /npm\s+publish/i.test(toolInput.command);
63
+ // 真正不可撤销的对外发布(npm publish)才走硬闸。谓词共享自 approval-policy.js(SDK 链路同用)。
64
+ const isPublishCmd = isPublishCommand(toolName, toolInput);
65
65
 
66
66
  // IM worker (skip-permissions) 硬拦截 —— 必须在下面的 bypass auto-allow 之前求值,
67
67
  // 否则 CCV_BYPASS_PERMISSIONS=1 会把一切先放行(见 plan §安全 2)。仅对 IM worker 生效。
@@ -95,7 +95,7 @@ if (toolName === 'AskUserQuestion') {
95
95
  }
96
96
 
97
97
  // These tools need explicit user approval via Web UI (mutating or external access).
98
- const APPROVAL_TOOLS = new Set(['Bash', 'Edit', 'Write', 'NotebookEdit', 'WebFetch', 'WebSearch']);
98
+ // The six-tool set is shared with the SDK canUseTool path via approval-policy.js.
99
99
  if (!APPROVAL_TOOLS.has(toolName)) {
100
100
  process.stdout.write(JSON.stringify({
101
101
  hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow' },
@@ -0,0 +1,198 @@
1
+ // 内置模型提示词层:让 packages/app/server/system-prompt-templates/presets/ 的 6 个预设
2
+ // 成为「默认生效」的模型 system prompt——spawn 时模型匹配且用户无对应文件(workspace >
3
+ // global 两级用户文件优先)时自动注入;用户可通过墓碑文件禁用某个内置条目。
4
+ //
5
+ // 数据源复用 system-prompt-presets.js 的 listSystemPromptPresets()(manifest 读取 +
6
+ // renderPresetTemplate 边界剥离都已在里面,且注释承诺不触发 createSystemPromptVariables/
7
+ // git 子进程)。本模块函数对非法入参会 throw(setBuiltinDisabled/materializeBuiltinPrompt),
8
+ // spawn 注入链路的安全由调用点整层 try-catch 保证(system-prompt-files.js 失败回落
9
+ // sentinel,注入链路永不 throw)。
10
+ //
11
+ // Built-in model prompt layer: the shipped presets act as default-effective model
12
+ // system prompts. User files (workspace > global) always win; a per-scope tombstone
13
+ // file disables individual built-in entries.
14
+ import { createHash } from 'node:crypto';
15
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
16
+ import { tmpdir } from 'node:os';
17
+ import { join } from 'node:path';
18
+ import { expandModelIdVariants, normalizeModelName } from './model-system-prompts.js';
19
+ import { listSystemPromptPresets } from './system-prompt-presets.js';
20
+ import { renameSyncWithRetry } from './file-api.js';
21
+ import { reportSwallowed } from '@ccv/core/error-report';
22
+
23
+ // 墓碑文件名:放在对应 scope 的 modelPromptDir 里,内容是规范大写名的 JSON 数组。
24
+ // parseModelPromptFileName 对非 *_SYSTEM.md 文件名返回 null,天然不干扰条目列表。
25
+ // Tombstone file inside a scope's model-prompt dir: a JSON array of canonical names.
26
+ export const BUILTIN_DISABLED_FILE = '.builtin-disabled.json';
27
+
28
+ // 物化目录:preset 文本(边界已剥离、${...} 保持字面量,spawn 渲染管线再替换变量)
29
+ // 写成内容寻址的临时文件,供 --system-prompt-file 注入(文件对形式是快照钉扎的前提)。
30
+ // 惰性读取 env 覆盖(测试用):node --test 多进程并行时共享目录会被彼此的 GC 竞态误删。
31
+ // Materialized temp dir for boundary-stripped preset texts (content-addressed).
32
+ const materializeDir = () => process.env.CCV_BUILTIN_PROMPT_MATERIALIZE_DIR || join(tmpdir(), 'cc-viewer-builtin-prompts');
33
+
34
+ /**
35
+ * 列出全部内置模型条目。name = manifest match 的大写规范化(与用户条目同名体系,
36
+ * 用户同名文件天然形成覆盖);text = renderPresetTemplate 输出(无边界标记)。
37
+ * List all built-in model entries derived from the preset manifest.
38
+ *
39
+ * @returns {Array<{ id: string, title: string, name: string, mode: 'override'|'append', matchLower: string, text: string }>}
40
+ */
41
+ export function listBuiltinModelPrompts() {
42
+ let presets;
43
+ try {
44
+ presets = listSystemPromptPresets();
45
+ } catch {
46
+ return []; // manifest 损坏等:内置层整体缺席,调用方回落 sentinel
47
+ }
48
+ const out = [];
49
+ for (const p of presets) {
50
+ const name = normalizeModelName(typeof p?.match === 'string' ? p.match : '');
51
+ if (!name || typeof p.text !== 'string' || p.text.trim().length === 0) continue;
52
+ out.push({
53
+ id: p.id,
54
+ title: p.title || p.id,
55
+ name,
56
+ mode: p.defaultMode === 'override' ? 'override' : 'append',
57
+ matchLower: p.match.toLowerCase(),
58
+ text: p.text,
59
+ });
60
+ }
61
+ return out;
62
+ }
63
+
64
+ /**
65
+ * 按 modelId 匹配内置条目:别名展开(expandModelIdVariants,先 lowercase)后任一变体
66
+ * 包含 preset 的 match 即命中;多命中取 match 最长者、等长字典序(对齐 matchModelPrompt
67
+ * 的消歧规则)。
68
+ * Match a model id against built-in entries (alias-expanded substring, longest wins).
69
+ *
70
+ * @param {string|null} modelId
71
+ * @returns {{ id: string, name: string, mode: 'override'|'append', text: string } | null}
72
+ */
73
+ export function matchBuiltinModelPrompt(modelId) {
74
+ const variants = expandModelIdVariants(modelId);
75
+ if (!variants.length) return null;
76
+ const hits = listBuiltinModelPrompts().filter((e) => variants.some((v) => v.includes(e.matchLower)));
77
+ if (!hits.length) return null;
78
+ hits.sort((a, b) => b.matchLower.length - a.matchLower.length || a.name.localeCompare(b.name));
79
+ const e = hits[0];
80
+ return { id: e.id, name: e.name, mode: e.mode, text: e.text };
81
+ }
82
+
83
+ /**
84
+ * 读某 scope 目录的墓碑名单。目录/文件缺失 → [](良性静默);文件存在但 JSON 损坏或
85
+ * 形状非法 → console.warn + reportSwallowed 后仍宽容返回 [](fail-open 是刻意的:
86
+ * 墓碑是辅助状态,宁可恢复注入也不因损坏阻断功能——但用户显式 opt-out 被静默逆转
87
+ * 必须有诊断痕迹,三方审查一致要求)。
88
+ * Read a scope dir's tombstone list. Missing dir/file → []; corrupt file → warn +
89
+ * reportSwallowed, still [] (deliberate fail-open, but never silently).
90
+ *
91
+ * @param {string|null|undefined} modelPromptDir
92
+ * @returns {string[]} 规范大写名数组(已排序)
93
+ */
94
+ export function readBuiltinDisabled(modelPromptDir) {
95
+ if (!modelPromptDir) return [];
96
+ const target = join(modelPromptDir, BUILTIN_DISABLED_FILE);
97
+ if (!existsSync(target)) return [];
98
+ try {
99
+ const raw = readFileSync(target, 'utf-8');
100
+ const parsed = JSON.parse(raw);
101
+ if (!Array.isArray(parsed)) throw new Error('tombstone file is not a JSON array');
102
+ const names = parsed.map((n) => normalizeModelName(typeof n === 'string' ? n : '')).filter(Boolean);
103
+ return [...new Set(names)].sort();
104
+ } catch (err) {
105
+ console.warn(`[CC Viewer] built-in prompt tombstone ${target} unreadable (${err.message}); treating as no disables`);
106
+ reportSwallowed('builtin-model-prompts.readDisabled', err);
107
+ return [];
108
+ }
109
+ }
110
+
111
+ /**
112
+ * 写墓碑:disabled=true 把 name 加入名单,false 移出。tmp+rename 原子写,数组去重排序。
113
+ * Add/remove a name in a scope dir's tombstone list (atomic tmp+rename write).
114
+ *
115
+ * @param {string} modelPromptDir 目标 scope 的 modelPromptDir(自动 mkdir -p)
116
+ * @param {string} name 条目名(经 normalizeModelName 规范化,非法 throw)
117
+ * @param {boolean} disabled
118
+ * @returns {{ name: string, disabled: boolean, list: string[] }}
119
+ */
120
+ export function setBuiltinDisabled(modelPromptDir, name, disabled) {
121
+ if (!modelPromptDir) throw new Error('no target directory');
122
+ const canonical = normalizeModelName(name);
123
+ if (!canonical) throw new Error('invalid model prompt name');
124
+ const list = readBuiltinDisabled(modelPromptDir);
125
+ const next = disabled ? [...new Set([...list, canonical])].sort() : list.filter((n) => n !== canonical);
126
+ mkdirSync(modelPromptDir, { recursive: true });
127
+ const target = join(modelPromptDir, BUILTIN_DISABLED_FILE);
128
+ const tmp = `${target}.${process.pid}.tmp`;
129
+ writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`, 'utf-8');
130
+ renameSyncWithRetry(tmp, target); // Windows 杀软/索引瞬时 EPERM 重试(与仓库写路径一致)
131
+ return { name: canonical, disabled: !!disabled, list: next };
132
+ }
133
+
134
+ /**
135
+ * 多 scope 合成判断:任一目录的墓碑名单含 name 即视为禁用(workspace 墓碑禁本工作区、
136
+ * global 墓碑全局禁用;对单次启动而言两者都是「该禁」)。
137
+ * Combine tombstones across scopes: disabled when ANY dir's list contains the name.
138
+ *
139
+ * @param {string} name 规范大写名
140
+ * @param {...(string|null|undefined)} modelPromptDirs
141
+ * @returns {boolean}
142
+ */
143
+ export function isBuiltinDisabled(name, ...modelPromptDirs) {
144
+ const canonical = normalizeModelName(name);
145
+ if (!canonical) return false;
146
+ return modelPromptDirs.some((dir) => readBuiltinDisabled(dir).includes(canonical));
147
+ }
148
+
149
+ /**
150
+ * 把内置条目文本物化为内容寻址的临时文件(不存在才写,避并发截断;顺手清同 id
151
+ * 旧 hash 文件)。返回文件路径。
152
+ * Materialize a built-in entry's text into a content-addressed temp file.
153
+ *
154
+ * @param {string} id preset id(文件名安全字符校验)
155
+ * @param {string} text renderPresetTemplate 输出(边界已剥离、${...} 字面量保留)
156
+ * @returns {string} 物化文件绝对路径
157
+ */
158
+ export function materializeBuiltinPrompt(id, text) {
159
+ if (typeof id !== 'string' || !/^[A-Za-z0-9._-]+$/.test(id)) throw new Error('invalid preset id');
160
+ if (typeof text !== 'string' || text.trim().length === 0) throw new Error('empty preset text');
161
+ const hash = createHash('sha256').update(text).digest('hex').slice(0, 8);
162
+ const fileName = `${id}-${hash}.md`;
163
+ const dir = materializeDir();
164
+ mkdirSync(dir, { recursive: true, mode: 0o700 }); // 仅属主可读写:Linux 多用户 /tmp 下防跨用户预植
165
+ const target = join(dir, fileName);
166
+ // write-if-absent 必须回读校验内容 hash:可预测路径 + 预置恶意文件(hash 可算,
167
+ // preset 文本随包公开)会把注入内容掉包——不一致则覆盖。写入用 tmp+rename 原子落盘,
168
+ // 进程在写中途崩溃留下截断文件时 write-if-absent 永不愈合。
169
+ const writeAtomic = () => {
170
+ const tmp = `${target}.${process.pid}.tmp`;
171
+ writeFileSync(tmp, text, 'utf-8');
172
+ renameSyncWithRetry(tmp, target);
173
+ };
174
+ if (!existsSync(target)) {
175
+ writeAtomic();
176
+ } else {
177
+ try {
178
+ const existing = readFileSync(target, 'utf-8');
179
+ if (createHash('sha256').update(existing).digest('hex').slice(0, 8) !== hash) writeAtomic();
180
+ } catch {
181
+ writeAtomic(); // 读失败(截断/权限)→ 覆盖重写
182
+ }
183
+ }
184
+ // best-effort GC:同 id 的旧 hash 文件(版本升级后残留)。精确匹配 8 位 hex hash 防
185
+ // id 前缀误删(如未来 kimi-k3-turbo);只清 mtime 足够旧的文件,避免跨版本并发
186
+ // (新旧实例同跑)时删掉旧实例正要读的文件。失败无碍。
187
+ try {
188
+ const idRe = new RegExp(`^${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}-[0-9a-f]{8}\\.md$`);
189
+ for (const f of readdirSync(dir)) {
190
+ if (!idRe.test(f) || f === fileName) continue;
191
+ const p = join(dir, f);
192
+ try {
193
+ if (Date.now() - statSync(p).mtimeMs > 10 * 60_000) rmSync(p, { force: true });
194
+ } catch { /* ignore */ }
195
+ }
196
+ } catch { /* ignore */ }
197
+ return target;
198
+ }
@@ -0,0 +1,341 @@
1
+ /**
2
+ * chat-queue.js — Server-side busy queue for chat-composer messages (PTY / terminal-hidden mode).
3
+ *
4
+ * When Claude is mid-run and the user presses Enter in the web chat composer, the message is
5
+ * held here instead of being typed into the TUI immediately. The server broadcasts the queue
6
+ * (`queue-state`) so every client can render floating bubbles above the composer, and drains
7
+ * the queue into the PTY (bracketed paste + Enter) once the turn really ends.
8
+ *
9
+ * Per-bubble actions:
10
+ * - "Send now" (queue-send-now) interrupts the running turn (focus-in + ESC, same bytes as the
11
+ * web Stop button) and injects that message as soon as the TUI is observably idle.
12
+ * - "Remove" (queue-remove) drops it.
13
+ * Stop keeps the queue parked (product decision): `suppressNextDrain()` blocks the automatic
14
+ * turn-end drain until a NEW turn starts (streaming rising edge) or a message is sent now.
15
+ *
16
+ * Design notes:
17
+ * - This module NEVER imports pty-manager / server.js. All PTY access + state probes are
18
+ * injected via initChatQueue(deps), so unit tests run without node-pty (im-bridge-core
19
+ * convention).
20
+ * - Injection is single-flight (`_injecting`) and mutually exclusive with the IM bridge's
21
+ * own injection slot via the injected `hasExternalInjection` probe (im-bridge-core's
22
+ * anyActiveInjection).
23
+ * - Known limitation: keystrokes typed directly into a VISIBLE terminal are not serialized
24
+ * against an in-flight inject (same accepted risk class as the IM bridge today).
25
+ */
26
+
27
+ import { reportSwallowed } from '@ccv/core/error-report';
28
+ import { bracketPasteSubmit, sanitizeInbound } from './im/im-bridge-core.js';
29
+
30
+ const MAX_ITEMS = 50; // im-bridge-core MAX_QUEUE parity
31
+ const STOP_ESC_DELAY_MS = 50; // focus-in → ESC gap, mirrors ChatView's STOP_FOCUS_IN_ESC_DELAY_MS
32
+ const INJECT_POLL_MS = 100; // idle poll cadence before injecting
33
+ const INJECT_POLL_MAX_MS = 2000; // give up polling after this; NEVER inject unconditionally
34
+ const RESCUE_AFTER_MS = 3000; // stale-busy rescue: frontend guessed busy while the turn already ended
35
+
36
+ // Injected deps (see initChatQueue). Null until wired; every public entry no-ops safely then.
37
+ let _deps = null;
38
+ let _items = []; // [{ id, text, ts }] FIFO
39
+ let _injecting = false; // single-flight guard around one bracket-paste inject sequence
40
+ let _rescueTimer = null; // stale-busy rescue timer
41
+ let _pollTimer = null; // active idle-poll interval (single slot — see _pollUntilSafe)
42
+ let _escTimer = null; // sendNow's focus-in → ESC gap timer
43
+ let _suppressDrain = false; // Stop pressed → park the queue until a new turn starts
44
+ let _gen = 0; // generation counter: clear()/stop() invalidate in-flight injects
45
+
46
+ /**
47
+ * @param {object} deps
48
+ * @param {function} deps.writeToPty — (data:string) => void
49
+ * @param {function} deps.writeToPtySequential — (chunks:string[], onComplete:(ok:boolean)=>void, opts?) => void
50
+ * @param {function} deps.getPtyKind — () => 'claude' | 'shell' | null
51
+ * @param {function} deps.isStreaming — () => boolean (per-API-call streaming flag; flickers between tool calls)
52
+ * @param {function} deps.hasPendingApproval — () => boolean (web-visible ask/perm modal open)
53
+ * @param {function} [deps.hasExternalInjection] — () => boolean (IM bridge injection slot busy)
54
+ * @param {function} deps.broadcastWs — (msg:object) => void
55
+ */
56
+ export function initChatQueue(deps) {
57
+ _deps = deps;
58
+ }
59
+
60
+ export function getSnapshot() {
61
+ return _items.map(({ id, text, ts }) => ({ id, text, ts }));
62
+ }
63
+
64
+ function _broadcast() {
65
+ if (!_deps || !_deps.broadcastWs) return;
66
+ try { _deps.broadcastWs({ type: 'queue-state', items: getSnapshot() }); }
67
+ catch (err) { reportSwallowed('chat-queue.broadcast', err); }
68
+ }
69
+
70
+ function _makeItem(text) {
71
+ return {
72
+ id: 'q_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8),
73
+ text,
74
+ ts: Date.now(),
75
+ };
76
+ }
77
+
78
+ function _clearRescueTimer() {
79
+ if (_rescueTimer) { clearTimeout(_rescueTimer); _rescueTimer = null; }
80
+ }
81
+
82
+ function _clearPollTimer() {
83
+ if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null; }
84
+ }
85
+
86
+ function _clearEscTimer() {
87
+ if (_escTimer) { clearTimeout(_escTimer); _escTimer = null; }
88
+ }
89
+
90
+ /**
91
+ * True only when injecting is observably safe: claude TUI (not a bare shell), no API call in
92
+ * flight, no web-visible approval modal, no external (IM) injection holding the PTY.
93
+ * NOTE: a TUI-native fallback question (ask-bridge terminal fallback) is invisible to
94
+ * hasPendingApproval — which is why poll timeouts must NEVER fall through to an inject.
95
+ * NOTE 2: `isStreaming` (streamingState.active) flickers false between tool calls mid-turn;
96
+ * injecting into that gap is harmless (the TUI natively queues the paste) but it means an
97
+ * "immediate" inject can land inside the current turn's chain — accepted risk class.
98
+ */
99
+ function _safeToInject() {
100
+ if (!_deps) return false;
101
+ if (_injecting) return false;
102
+ // A throwing probe means the wiring regressed — fail safe (no inject) but never silently:
103
+ // an untagged swallow here would wedge the whole queue with no diagnostic.
104
+ try { if (_deps.getPtyKind && _deps.getPtyKind() !== 'claude') return false; }
105
+ catch (err) { reportSwallowed('chat-queue.probe', err); return false; }
106
+ try { if (_deps.isStreaming && _deps.isStreaming()) return false; }
107
+ catch (err) { reportSwallowed('chat-queue.probe', err); return false; }
108
+ try { if (_deps.hasPendingApproval && _deps.hasPendingApproval()) return false; }
109
+ catch (err) { reportSwallowed('chat-queue.probe', err); return false; }
110
+ try { if (_deps.hasExternalInjection && _deps.hasExternalInjection()) return false; }
111
+ catch (err) { reportSwallowed('chat-queue.probe', err); return false; }
112
+ return true;
113
+ }
114
+
115
+ /**
116
+ * Inject the head item. Caller must have verified _safeToInject() first (except the internal
117
+ * re-checks here, which keep this self-guarding for direct calls).
118
+ * Broadcasts on shift (so the bubble disappears the moment the message starts typing into the
119
+ * TUI) and again if the write fails and the item returns to the head.
120
+ */
121
+ function _injectHead() {
122
+ if (!_safeToInject()) return false;
123
+ const item = _items.shift();
124
+ if (!item) return false;
125
+ _broadcast();
126
+ _injecting = true;
127
+ const gen = _gen; // invalidate on clear()/stop(): a stale completion must not re-park
128
+ try {
129
+ _deps.writeToPtySequential(bracketPasteSubmit(item.text), (ok) => {
130
+ _injecting = false;
131
+ if (gen !== _gen) return;
132
+ if (!ok) {
133
+ // Dead PTY / write failure — never lose the message silently; put it back.
134
+ _items.unshift(item);
135
+ _broadcast();
136
+ }
137
+ }, { settleMs: 250 });
138
+ } catch (err) {
139
+ _injecting = false;
140
+ if (gen === _gen) {
141
+ _items.unshift(item);
142
+ _broadcast();
143
+ }
144
+ reportSwallowed('chat-queue.inject', err);
145
+ }
146
+ return true;
147
+ }
148
+
149
+ /**
150
+ * Poll (≤ INJECT_POLL_MAX_MS) until _safeToInject(), then run fn. On timeout: do NOT inject —
151
+ * invoke onTimeout instead (caller decides: keep queued / restore item). A timed-out blind
152
+ * write could land in a TUI-native question prompt and be submitted as its answer.
153
+ * Tick-counted (not Date-based) so mock.timers without Date mocking can drive it in tests.
154
+ */
155
+ function _pollUntilSafe(fn, onTimeout) {
156
+ _clearPollTimer();
157
+ const maxTicks = Math.ceil(INJECT_POLL_MAX_MS / INJECT_POLL_MS);
158
+ let ticks = 0;
159
+ _pollTimer = setInterval(() => {
160
+ ticks += 1;
161
+ if (ticks > maxTicks) {
162
+ _clearPollTimer();
163
+ if (onTimeout) onTimeout();
164
+ return;
165
+ }
166
+ if (!_safeToInject()) return;
167
+ _clearPollTimer();
168
+ fn();
169
+ }, INJECT_POLL_MS);
170
+ if (typeof _pollTimer.unref === 'function') _pollTimer.unref();
171
+ }
172
+
173
+ /**
174
+ * Enqueue a chat-composer message (Claude believed busy). Sanitizes control bytes (a crafted
175
+ * `\x1b[201~` would otherwise break the paste frame), broadcasts, and arms a rescue timer for
176
+ * the stale-busy case (frontend's isStreaming lags the real turn end by up to its debounce).
177
+ */
178
+ export function enqueue(text) {
179
+ if (!_deps) return null;
180
+ const clean = sanitizeInbound(text == null ? '' : String(text)).trim();
181
+ if (!clean) return null;
182
+ if (_items.length >= MAX_ITEMS) {
183
+ // Negative ack: the frontend cleared the composer optimistically — never fail silently.
184
+ console.warn('[chat-queue] queue full, rejecting message');
185
+ try { _deps.broadcastWs({ type: 'queue-rejected', reason: 'full' }); }
186
+ catch (err) { reportSwallowed('chat-queue.reject-broadcast', err); }
187
+ return null;
188
+ }
189
+ const item = _makeItem(clean);
190
+ _items.push(item);
191
+ _clearRescueTimer();
192
+ _broadcast();
193
+ let streaming = false;
194
+ try { streaming = !!(_deps.isStreaming && _deps.isStreaming()); } catch { streaming = false; }
195
+ if (!streaming) {
196
+ // Frontend guessed busy but the server sees no API call in flight: the turn may have ended
197
+ // just before enqueue and its turn-end hook already ran. Rescue after a short settle —
198
+ // falling back to the idle poll (not a single shot) so a transient busy/approval state
199
+ // retries within the poll budget instead of stranding the item.
200
+ _rescueTimer = setTimeout(() => {
201
+ _rescueTimer = null;
202
+ if (_suppressDrain || _pollTimer) return; // parked, or a drain/send-now poll already owns it
203
+ if (_safeToInject()) { _injectHead(); return; }
204
+ _pollUntilSafe(() => { if (!_suppressDrain) _injectHead(); }, () => { /* keep queued */ });
205
+ }, RESCUE_AFTER_MS);
206
+ if (typeof _rescueTimer.unref === 'function') _rescueTimer.unref();
207
+ }
208
+ return item;
209
+ }
210
+
211
+ /** Remove one queued item by id. Returns true when found. */
212
+ export function remove(id) {
213
+ const idx = _items.findIndex((it) => it.id === id);
214
+ if (idx < 0) return false;
215
+ _items.splice(idx, 1);
216
+ _broadcast();
217
+ return true;
218
+ }
219
+
220
+ /** Drop all queued items (session switch). Also cancels pending timers and invalidates any
221
+ * in-flight inject (gen bump) so an old-session message can never land in a fresh PTY.
222
+ * Cannot cancel bytes already written to the PTY (best-effort, ~330ms window). */
223
+ export function clear() {
224
+ _gen++;
225
+ _clearRescueTimer();
226
+ _clearPollTimer();
227
+ _clearEscTimer();
228
+ if (_items.length === 0) return;
229
+ _items = [];
230
+ _broadcast();
231
+ }
232
+
233
+ /**
234
+ * Send-now: interrupt the running turn and inject this item immediately.
235
+ * Guard order matters (review P0): refuse BEFORE touching the queue when an inject is in
236
+ * flight, so a double-action can never strand a removed-but-unsent message.
237
+ */
238
+ export function sendNow(id) {
239
+ if (!_deps) return false;
240
+ // Guard order matters (review P0): refuse BEFORE touching the queue. `_injecting` covers an
241
+ // in-flight paste; `_pollTimer` covers a prior send-now still in its ESC/idle-poll phase —
242
+ // a second poll would clobber the first one's timer and strand its already-removed item.
243
+ if (_injecting || _pollTimer) return false;
244
+ try { if (_deps.hasExternalInjection && _deps.hasExternalInjection()) return false; } catch { return false; }
245
+ const idx = _items.findIndex((it) => it.id === id);
246
+ if (idx < 0) return false;
247
+ const [item] = _items.splice(idx, 1);
248
+ _clearRescueTimer();
249
+ _broadcast();
250
+
251
+ const gen = _gen; // a clear()/stop() mid-flight invalidates the completion handler below
252
+ const inject = () => {
253
+ _injecting = true;
254
+ try {
255
+ _deps.writeToPtySequential(bracketPasteSubmit(item.text), (ok) => {
256
+ _injecting = false;
257
+ if (gen !== _gen) return;
258
+ if (!ok) { _items.unshift(item); _broadcast(); }
259
+ }, { settleMs: 250 });
260
+ } catch (err) {
261
+ _injecting = false;
262
+ if (gen === _gen) {
263
+ _items.unshift(item);
264
+ _broadcast();
265
+ }
266
+ reportSwallowed('chat-queue.send-now.inject', err);
267
+ }
268
+ };
269
+ const restore = () => {
270
+ // Never blind-inject (see _pollUntilSafe). Park the item back at the head.
271
+ if (gen !== _gen) return;
272
+ if (!_items.some((it) => it.id === item.id)) {
273
+ _items.unshift(item);
274
+ _broadcast();
275
+ }
276
+ };
277
+
278
+ let streaming = false;
279
+ try { streaming = !!(_deps.isStreaming && _deps.isStreaming()); } catch { streaming = false; }
280
+ if (streaming) {
281
+ // Interrupt first: focus-in (a hidden xterm is unfocused; Ink ignores ESC then) then ESC —
282
+ // byte-identical to the web Stop button (ChatView handleInputStop). The gap timer is
283
+ // tracked so stop()/clear() can cancel it before it lands in a fresh PTY.
284
+ try { _deps.writeToPty('\x1b[I'); } catch (err) { reportSwallowed('chat-queue.send-now.focus', err); }
285
+ _escTimer = setTimeout(() => {
286
+ _escTimer = null;
287
+ try { _deps.writeToPty('\x1b'); } catch (err) { reportSwallowed('chat-queue.send-now.esc', err); }
288
+ _pollUntilSafe(inject, restore);
289
+ }, STOP_ESC_DELAY_MS);
290
+ if (typeof _escTimer.unref === 'function') _escTimer.unref();
291
+ } else if (_safeToInject()) {
292
+ // Idle / parked queue (e.g. after Stop): no interrupt needed — inject right away.
293
+ inject();
294
+ } else {
295
+ _pollUntilSafe(inject, restore);
296
+ }
297
+ return true;
298
+ }
299
+
300
+ /**
301
+ * Turn-end hook (Stop-hook POST arrival + debounced emit; idempotent). Drains the head item
302
+ * once the TUI is observably idle. Suppressed after a user Stop until a new turn starts.
303
+ */
304
+ export function onTurnEnd() {
305
+ if (!_deps || _items.length === 0 || _injecting) return;
306
+ // A send-now poll is already pending — it owns the next inject; let it finish rather than
307
+ // clear its timer out from under it (clearing would strand the removed item).
308
+ if (_pollTimer) return;
309
+ _clearRescueTimer();
310
+ if (_suppressDrain) return;
311
+ if (_safeToInject()) { _injectHead(); return; }
312
+ _pollUntilSafe(() => { if (!_suppressDrain) _injectHead(); }, () => { /* keep queued; next turn-end retries */ });
313
+ }
314
+
315
+ /** Park the queue: the next turn-end(s) must not auto-drain (user pressed Stop). */
316
+ export function suppressNextDrain() {
317
+ _suppressDrain = true;
318
+ _clearRescueTimer();
319
+ }
320
+
321
+ /** A new turn started (streaming rising edge): re-arm automatic draining. */
322
+ export function onStreamingActivated() {
323
+ _suppressDrain = false;
324
+ }
325
+
326
+ /** Server shutdown: drop timers AND queue state — parked items belong to the dying session
327
+ * and must not drain into the fresh PTY after a stop/start cycle. */
328
+ export function stop() {
329
+ _clearRescueTimer();
330
+ _clearPollTimer();
331
+ _clearEscTimer();
332
+ _injecting = false;
333
+ _items = [];
334
+ _suppressDrain = false;
335
+ _gen++;
336
+ }
337
+
338
+ export function __resetForTests() {
339
+ stop();
340
+ _deps = null;
341
+ }