handmux 0.23.0 → 0.25.0

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 (327) hide show
  1. package/README.md +31 -8
  2. package/README.zh-CN.md +31 -8
  3. package/bin/handmux.js +14 -688
  4. package/bin/node-version.js +20 -0
  5. package/dist/bin/handmux.js +1015 -0
  6. package/dist/connectors/bridgeClient.js +385 -0
  7. package/dist/connectors/claude/index.js +476 -0
  8. package/dist/connectors/pi/bridgeClient.js +8 -0
  9. package/dist/connectors/pi/index.js +851 -0
  10. package/dist/hooks/handmux-notify.sh +64 -0
  11. package/{hooks → dist/hooks}/handmux-statusline.cjs +28 -6
  12. package/dist/hooks/handmux-write.cjs +264 -0
  13. package/dist/package.json +127 -0
  14. package/dist/public/assets/index-CTrqTqtT.css +32 -0
  15. package/dist/public/assets/index-DmjeMSQA.js +346 -0
  16. package/dist/public/assets/pcm-worklet-CxcDy7PB.js +1 -0
  17. package/{public → dist/public}/index.html +5 -4
  18. package/dist/src/agent-runtime/adapter.js +204 -0
  19. package/dist/src/agent-runtime/bridge.js +1006 -0
  20. package/dist/src/agent-runtime/bridgeStore.js +25 -0
  21. package/dist/src/agent-runtime/bridgeTransport.js +619 -0
  22. package/dist/src/agent-runtime/bridgeTypes.js +13 -0
  23. package/dist/src/agent-runtime/builtinRuntime.js +193 -0
  24. package/dist/src/agent-runtime/conversation.js +2179 -0
  25. package/dist/src/agent-runtime/conversationActivation.js +75 -0
  26. package/dist/src/agent-runtime/conversationActivity.js +69 -0
  27. package/dist/src/agent-runtime/conversationControls.js +206 -0
  28. package/dist/src/agent-runtime/conversationLiveHub.js +276 -0
  29. package/dist/src/agent-runtime/conversationStore.js +352 -0
  30. package/dist/src/agent-runtime/conversationTypes.js +1 -0
  31. package/dist/src/agent-runtime/conversationValidation.js +339 -0
  32. package/dist/src/agent-runtime/inbox.js +973 -0
  33. package/dist/src/agent-runtime/inboxPushProjection.js +69 -0
  34. package/dist/src/agent-runtime/inboxStore.js +25 -0
  35. package/dist/src/agent-runtime/inboxTypes.js +1 -0
  36. package/dist/src/agent-runtime/interaction.js +829 -0
  37. package/dist/src/agent-runtime/interactionLiveHub.js +225 -0
  38. package/dist/src/agent-runtime/interactionStore.js +18 -0
  39. package/dist/src/agent-runtime/interactionTypes.js +1 -0
  40. package/dist/src/agent-runtime/legacyInboxProjection.js +70 -0
  41. package/dist/src/agent-runtime/migrateLegacyCodexOutbox.js +336 -0
  42. package/dist/src/agent-runtime/resourceTypes.js +1 -0
  43. package/dist/src/agent-runtime/resources.js +245 -0
  44. package/dist/src/agent-runtime/run.js +328 -0
  45. package/dist/src/agent-runtime/runtime.js +793 -0
  46. package/dist/src/agent-runtime/sessionControl.js +164 -0
  47. package/dist/src/agent-runtime/subscriptionUsage.js +190 -0
  48. package/dist/src/agent-runtime/subscriptionUsageLegacy.js +18 -0
  49. package/dist/src/agent-runtime/tmuxRuntime.js +156 -0
  50. package/dist/src/agents/claude.js +203 -0
  51. package/dist/src/agents/claudeConversation.js +362 -0
  52. package/dist/src/agents/claudeInteraction.js +134 -0
  53. package/dist/src/agents/claudeSubscriptionUsage.js +61 -0
  54. package/dist/src/agents/codex.js +262 -0
  55. package/dist/src/agents/codexConversation.js +1103 -0
  56. package/dist/src/agents/codexConversationActivation.js +121 -0
  57. package/dist/src/agents/codexConversationControls.js +256 -0
  58. package/dist/src/agents/codexInteraction.js +306 -0
  59. package/dist/src/agents/codexSessionControl.js +118 -0
  60. package/dist/src/agents/codexSubscriptionUsage.js +313 -0
  61. package/dist/src/agents/codexTerminalControl.js +23 -0
  62. package/dist/src/agents/conversationProjectionSafety.js +416 -0
  63. package/dist/src/agents/index.js +39 -0
  64. package/dist/src/agents/nativeInbox.js +264 -0
  65. package/dist/src/agents/nativeInboxSources.js +115 -0
  66. package/dist/src/agents/pi.js +28 -0
  67. package/dist/src/agents/piConversation.js +523 -0
  68. package/dist/src/agents/piConversationHistory.js +502 -0
  69. package/dist/src/agents/piInboxBridge.js +368 -0
  70. package/dist/src/agents/piSessionControl.js +32 -0
  71. package/dist/src/agents/processIdentity.js +94 -0
  72. package/dist/src/agents/scanUtils.js +262 -0
  73. package/dist/src/apiAccountProviders.js +194 -0
  74. package/dist/src/apiAccounts.js +592 -0
  75. package/dist/src/apiErrors.js +86 -0
  76. package/{src → dist/src}/appName.js +12 -13
  77. package/dist/src/asr/iflyConfig.js +7 -0
  78. package/dist/src/asr/iflySign.js +14 -0
  79. package/dist/src/auth.js +27 -0
  80. package/dist/src/browser/bootstrap.js +72 -0
  81. package/dist/src/browser/cookieProfiles.js +413 -0
  82. package/dist/src/browser/coordinator.js +51 -0
  83. package/dist/src/browser/hammerheadCompat.js +104 -0
  84. package/dist/src/browser/hammerheadRedirectCompat.js +37 -0
  85. package/dist/src/browser/manager.js +560 -0
  86. package/dist/src/browser/originLabel.js +15 -0
  87. package/dist/src/browser/profilePersistence.js +235 -0
  88. package/dist/src/browser/publicProxy.js +176 -0
  89. package/dist/src/browser/routes.js +159 -0
  90. package/dist/src/browser/sessionStore.js +72 -0
  91. package/dist/src/browser/siteVersion.js +121 -0
  92. package/dist/src/browser/targetPolicy.js +163 -0
  93. package/{src → dist/src}/browser/worker.js +14 -18
  94. package/dist/src/browser/workerClient.js +286 -0
  95. package/dist/src/browser/workerServer.js +91 -0
  96. package/dist/src/captureBackground.js +95 -0
  97. package/dist/src/claudeEvents.js +525 -0
  98. package/dist/src/cli/agentIntegration.js +131 -0
  99. package/dist/src/cli/agentLauncher.js +26 -0
  100. package/dist/src/cli/claudeHooks.js +290 -0
  101. package/{src → dist/src}/cli/cloudflareUrl.js +2 -3
  102. package/dist/src/cli/cloudflared.js +106 -0
  103. package/dist/src/cli/codexManaged.js +80 -0
  104. package/{src → dist/src}/cli/cpolarUrl.js +8 -9
  105. package/dist/src/cli/drivers.js +98 -0
  106. package/dist/src/cli/hookScaffold.js +39 -0
  107. package/dist/src/cli/i18n/en.js +392 -0
  108. package/{src → dist/src}/cli/i18n/index.js +27 -25
  109. package/dist/src/cli/i18n/zh.js +391 -0
  110. package/dist/src/cli/legacyCodexHooks.js +52 -0
  111. package/{src → dist/src}/cli/natappUrl.js +2 -3
  112. package/dist/src/cli/options.js +245 -0
  113. package/dist/src/cli/piExtension.js +155 -0
  114. package/dist/src/cli/probe.js +16 -0
  115. package/{src → dist/src}/cli/prompt.js +4 -6
  116. package/dist/src/cli/pushCmd.js +102 -0
  117. package/{src → dist/src}/cli/qr.js +19 -19
  118. package/dist/src/cli/service.js +154 -0
  119. package/dist/src/cli/setupModel.js +194 -0
  120. package/dist/src/cli/setupWizard.js +647 -0
  121. package/dist/src/cli/shortcutEditor.js +351 -0
  122. package/dist/src/cli/sshTunnel.js +18 -0
  123. package/dist/src/cli/state.js +117 -0
  124. package/dist/src/cli/statusLine.js +115 -0
  125. package/dist/src/cli/supervisor.js +278 -0
  126. package/dist/src/cli/supervisorHealth.js +21 -0
  127. package/dist/src/cli/supervisorLaunch.js +148 -0
  128. package/dist/src/cli/supervisorProcesses.js +62 -0
  129. package/dist/src/cli/supervisorState.js +62 -0
  130. package/dist/src/cli/tmuxVersion.js +53 -0
  131. package/{src → dist/src}/cli/tunlite.js +7 -10
  132. package/dist/src/cli/tunnelClients.js +72 -0
  133. package/{src → dist/src}/cli/updateCheck.js +98 -69
  134. package/{src → dist/src}/cli/urlHost.js +8 -4
  135. package/dist/src/cli/workspaceCmd.js +347 -0
  136. package/dist/src/codexAppServer.js +3436 -0
  137. package/dist/src/codexConversationProjection.js +199 -0
  138. package/dist/src/codexDiff.js +118 -0
  139. package/dist/src/codexMessageIdentity.js +18 -0
  140. package/dist/src/codexPlan.js +30 -0
  141. package/dist/src/codexQueueProtocol.js +137 -0
  142. package/dist/src/codexStreamProtocol.js +207 -0
  143. package/dist/src/codexToolProtocol.js +68 -0
  144. package/dist/src/codexTranscriptParse.js +835 -0
  145. package/dist/src/codexUsageSnapshot.js +228 -0
  146. package/dist/src/config.js +8 -0
  147. package/{src → dist/src}/docPath.js +22 -24
  148. package/dist/src/docs.js +337 -0
  149. package/dist/src/git.js +284 -0
  150. package/dist/src/healthProtocol.js +97 -0
  151. package/dist/src/httpApi.js +96 -0
  152. package/dist/src/jsonStore.js +15 -0
  153. package/{src → dist/src}/keyNames.js +3 -3
  154. package/dist/src/notifications.js +111 -0
  155. package/dist/src/orphans.js +167 -0
  156. package/dist/src/paneInput.js +42 -0
  157. package/dist/src/pendingPrompt.js +194 -0
  158. package/dist/src/previewServer.js +202 -0
  159. package/dist/src/previews.js +179 -0
  160. package/dist/src/privateStateStore.js +124 -0
  161. package/dist/src/projectTask/backup.js +224 -0
  162. package/dist/src/projectTask/lock.js +108 -0
  163. package/dist/src/projectTask/migrations.js +90 -0
  164. package/dist/src/projectTask/routes.js +155 -0
  165. package/dist/src/projectTask/runtime.js +182 -0
  166. package/dist/src/projectTask/schema.js +56 -0
  167. package/dist/src/projectTask/store.js +457 -0
  168. package/dist/src/push.js +224 -0
  169. package/dist/src/routes/agents.js +631 -0
  170. package/dist/src/routes/apiAccounts.js +82 -0
  171. package/dist/src/routes/files.js +230 -0
  172. package/dist/src/routes/git.js +89 -0
  173. package/dist/src/routes/health.js +16 -0
  174. package/dist/src/routes/notifications.js +16 -0
  175. package/dist/src/routes/previews.js +43 -0
  176. package/dist/src/routes/push.js +145 -0
  177. package/dist/src/routes/sessions.js +244 -0
  178. package/dist/src/routes/system.js +220 -0
  179. package/dist/src/routes/terminal.js +244 -0
  180. package/dist/src/routes/workspace.js +267 -0
  181. package/dist/src/server.js +315 -0
  182. package/dist/src/shortcutConfig.js +83 -0
  183. package/{src → dist/src}/staticCache.js +5 -3
  184. package/{src → dist/src}/staticCompression.js +5 -6
  185. package/dist/src/terminalStream.js +441 -0
  186. package/{src → dist/src}/tmux/commands.js +185 -172
  187. package/dist/src/tmux/format.js +47 -0
  188. package/dist/src/transcriptParse.js +261 -0
  189. package/dist/src/transcriptReader.js +209 -0
  190. package/{src → dist/src}/trimCapture.js +11 -11
  191. package/dist/src/uploadTypes.js +31 -0
  192. package/dist/src/urlPolicy.js +18 -0
  193. package/dist/src/usagePaths.js +15 -0
  194. package/dist/src/workspace/agentRunner.js +301 -0
  195. package/dist/src/workspace/atomicJson.js +31 -0
  196. package/dist/src/workspace/capture.js +139 -0
  197. package/dist/src/workspace/checkpointer.js +228 -0
  198. package/dist/src/workspace/environment.js +71 -0
  199. package/dist/src/workspace/lock.js +137 -0
  200. package/dist/src/workspace/mapping.js +128 -0
  201. package/dist/src/workspace/operations.js +271 -0
  202. package/dist/src/workspace/paths.js +15 -0
  203. package/dist/src/workspace/planner.js +292 -0
  204. package/dist/src/workspace/restore.js +363 -0
  205. package/dist/src/workspace/runtime.js +318 -0
  206. package/dist/src/workspace/schema.js +156 -0
  207. package/dist/src/workspace/store.js +464 -0
  208. package/dist/src/workspace/tmuxAdapter.js +704 -0
  209. package/dist/src/workspaceProtocol.js +158 -0
  210. package/package.json +30 -17
  211. package/hooks/handmux-notify.sh +0 -22
  212. package/hooks/handmux-write.cjs +0 -78
  213. package/public/assets/index-C3N7OkyP.css +0 -32
  214. package/public/assets/index-D1DWcC5u.js +0 -337
  215. package/src/.gitkeep +0 -0
  216. package/src/agents/claude.js +0 -159
  217. package/src/agents/codex.js +0 -168
  218. package/src/agents/index.js +0 -21
  219. package/src/agents/processIdentity.js +0 -72
  220. package/src/agents/scanUtils.js +0 -225
  221. package/src/asr/iflyConfig.js +0 -10
  222. package/src/asr/iflySign.js +0 -16
  223. package/src/auth.js +0 -30
  224. package/src/browser/bootstrap.js +0 -68
  225. package/src/browser/cookieProfiles.js +0 -403
  226. package/src/browser/coordinator.js +0 -47
  227. package/src/browser/hammerheadCompat.js +0 -86
  228. package/src/browser/hammerheadRedirectCompat.js +0 -38
  229. package/src/browser/manager.js +0 -556
  230. package/src/browser/originLabel.js +0 -17
  231. package/src/browser/profilePersistence.js +0 -241
  232. package/src/browser/publicProxy.js +0 -175
  233. package/src/browser/routes.js +0 -154
  234. package/src/browser/sessionStore.js +0 -78
  235. package/src/browser/siteVersion.js +0 -124
  236. package/src/browser/targetPolicy.js +0 -137
  237. package/src/browser/workerClient.js +0 -253
  238. package/src/browser/workerServer.js +0 -98
  239. package/src/captureBackground.js +0 -93
  240. package/src/claudeEvents.js +0 -399
  241. package/src/cli/claudeHooks.js +0 -206
  242. package/src/cli/cloudflared.js +0 -99
  243. package/src/cli/codexManaged.js +0 -77
  244. package/src/cli/drivers.js +0 -97
  245. package/src/cli/hookScaffold.js +0 -33
  246. package/src/cli/i18n/en.js +0 -385
  247. package/src/cli/i18n/zh.js +0 -384
  248. package/src/cli/legacyCodexHooks.js +0 -49
  249. package/src/cli/options.js +0 -211
  250. package/src/cli/probe.js +0 -16
  251. package/src/cli/pushCmd.js +0 -60
  252. package/src/cli/service.js +0 -126
  253. package/src/cli/setupModel.js +0 -154
  254. package/src/cli/setupWizard.js +0 -506
  255. package/src/cli/shortcutEditor.js +0 -288
  256. package/src/cli/sshTunnel.js +0 -12
  257. package/src/cli/state.js +0 -90
  258. package/src/cli/statusLine.js +0 -90
  259. package/src/cli/supervisor.js +0 -183
  260. package/src/cli/supervisorProcesses.js +0 -57
  261. package/src/cli/tmuxVersion.js +0 -49
  262. package/src/cli/tunnelClients.js +0 -62
  263. package/src/cli/workspaceCmd.js +0 -300
  264. package/src/codexAppServer.js +0 -1620
  265. package/src/codexDiff.js +0 -108
  266. package/src/codexTranscriptParse.js +0 -413
  267. package/src/codexUsageSnapshot.cjs +0 -129
  268. package/src/config.js +0 -9
  269. package/src/docs.js +0 -228
  270. package/src/git.js +0 -208
  271. package/src/httpApi.js +0 -72
  272. package/src/jsonStore.js +0 -24
  273. package/src/notifications.js +0 -68
  274. package/src/orphans.js +0 -152
  275. package/src/pendingPrompt.js +0 -163
  276. package/src/previewServer.js +0 -168
  277. package/src/previews.js +0 -116
  278. package/src/push.js +0 -183
  279. package/src/routes/codex.js +0 -473
  280. package/src/routes/files.js +0 -172
  281. package/src/routes/git.js +0 -57
  282. package/src/routes/notifications.js +0 -19
  283. package/src/routes/previews.js +0 -35
  284. package/src/routes/push.js +0 -119
  285. package/src/routes/sessions.js +0 -168
  286. package/src/routes/system.js +0 -132
  287. package/src/routes/terminal.js +0 -210
  288. package/src/routes/transcript.js +0 -171
  289. package/src/routes/workspace.js +0 -267
  290. package/src/server.js +0 -186
  291. package/src/shortcutConfig.js +0 -75
  292. package/src/terminalStream.js +0 -391
  293. package/src/tmux/format.js +0 -45
  294. package/src/transcriptParse.js +0 -180
  295. package/src/transcriptReader.js +0 -101
  296. package/src/uploadTypes.js +0 -30
  297. package/src/urlPolicy.js +0 -14
  298. package/src/usage.js +0 -260
  299. package/src/workspace/agentRunner.js +0 -188
  300. package/src/workspace/atomicJson.js +0 -32
  301. package/src/workspace/capture.js +0 -139
  302. package/src/workspace/checkpointer.js +0 -187
  303. package/src/workspace/environment.js +0 -75
  304. package/src/workspace/lock.js +0 -128
  305. package/src/workspace/mapping.js +0 -124
  306. package/src/workspace/operations.js +0 -252
  307. package/src/workspace/paths.js +0 -16
  308. package/src/workspace/planner.js +0 -247
  309. package/src/workspace/restore.js +0 -279
  310. package/src/workspace/runtime.js +0 -314
  311. package/src/workspace/schema.js +0 -136
  312. package/src/workspace/store.js +0 -382
  313. package/src/workspace/tmuxAdapter.js +0 -554
  314. /package/{public → dist/public}/fonts/JetBrainsMonoNerdFontMono-Regular.woff2 +0 -0
  315. /package/{public → dist/public}/fonts/TWUnifont.woff2 +0 -0
  316. /package/{public → dist/public}/icons/apple-touch-icon.png +0 -0
  317. /package/{public → dist/public}/icons/badge-96.png +0 -0
  318. /package/{public → dist/public}/icons/icon-192-maskable.png +0 -0
  319. /package/{public → dist/public}/icons/icon-192.png +0 -0
  320. /package/{public → dist/public}/icons/icon-512-maskable.png +0 -0
  321. /package/{public → dist/public}/icons/icon-512.png +0 -0
  322. /package/{public → dist/public}/icons/logo.svg +0 -0
  323. /package/{public → dist/public}/manifest.webmanifest +0 -0
  324. /package/{public → dist/public}/offline.html +0 -0
  325. /package/{public → dist/public}/sw.js +0 -0
  326. /package/{src → dist/src}/browser/protocol.js +0 -0
  327. /package/{src → dist/src}/cli/cfNamed.js +0 -0
@@ -0,0 +1,261 @@
1
+ // Pure: a Claude Code jsonl session log (array of lines) → normalized chat messages. No I/O.
2
+ // Only user/assistant turns become bubbles; every other top-level `type` (attachment, system, last-prompt,
3
+ // mode, permission-mode, ai-title, file-history-snapshot, queue-operation, …) is skipped. tool_use and its
4
+ // later tool_result are folded into ONE tool message (paired by tool_use_id); an unmatched tool_result is
5
+ // dropped (its tool_use is in an earlier, not-yet-loaded chunk). Bad/blank lines are skipped, never thrown.
6
+ //
7
+ // Meta scaffolding is DROPPED so the 对话 lens shows only real turns; the reliable signal is the top-level
8
+ // boolean flags — `isMeta` (skill/workflow injections, "Base directory for this skill…", caveats) and
9
+ // `isCompactSummary` (the "session is being continued…" wall) — NOT text matching.
10
+ //
11
+ // Slash commands (`/compact`, `/model`, `/clear`, …) are NOT dropped — they surface as a quiet 'slash'
12
+ // marker (a centered system row), because a command the user ran IS part of the conversation and hiding it
13
+ // leaves the phone with no feedback. Claude Code logs each as a `<command-name>/x</command-name>` USER turn
14
+ // (the canonical form; the bare "/x" input is a separate queue-operation / plain user line we still drop, to
15
+ // avoid a double). The command's `<local-command-stdout>` echo is folded onto the preceding marker as its
16
+ // `.result` (ANSI-stripped, capped) — and its PRESENCE means the command COMPLETED. An interactive picker
17
+ // (bare `/model`, `/plugin`, …) still open in the terminal has written no result yet, which is exactly how
18
+ // the UI tells a finished command from one that needs the user to drop to the terminal lens and pick.
19
+ // Matching is a tag prefix ANCHORED at the start of a USER turn's text: an assistant reply that merely
20
+ // mentions `<command-name>` in prose (e.g. discussing this very code) must NOT be caught.
21
+ const KEEP = new Set(['user', 'assistant']);
22
+ const SCAFFOLD_RE = /^\s*<(?:command-name|command-message|command-args|local-command-stdout|local-command-caveat|bash-input|bash-stdout|bash-stderr)>/;
23
+ // Besides the <command-name> scaffold form, Claude Code ALSO logs the raw slash-command input as a plain,
24
+ // flagless user turn (content exactly "/compact", "/model sonnet", …) — redundant with the scaffold marker,
25
+ // and after a /compact it's the LAST turn, so leaving it in would make the 对话 lens read a trailing user
26
+ // bubble and light the "reply coming" typing wave forever. Drop it — anchored to a single leading /command
27
+ // token followed by whitespace/EOL, so a path-like message ("/Users/demo/foo.js …") or prose is NOT eaten.
28
+ const SLASH_CMD_RE = /^\s*\/[a-z][\w-]*(?:\s|$)/i;
29
+ // The canonical scaffold form of a slash command: capture the name and any args, so the marker can tell a
30
+ // bare `/model` (may open a picker) from `/model sonnet` (applies directly). The stdout echo is a separate
31
+ // user turn right after; its inner text (closing tag stripped, ANSI stripped, capped) becomes the result.
32
+ const CMD_NAME_RE = /^\s*<command-name>\s*\/?([\w-]+)\s*<\/command-name>/i;
33
+ const CMD_ARGS_RE = /<command-args>([\s\S]*?)<\/command-args>/i;
34
+ const STDOUT_RE = /^\s*<local-command-stdout>([\s\S]*)$/i;
35
+ const ANSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
36
+ const SLASH_RESULT_CAP = 140;
37
+ const stripAnsi = (value) => typeof value === 'string' ? value.replace(ANSI_RE, '') : '';
38
+ const record = (value) => (value !== null && typeof value === 'object' && !Array.isArray(value)
39
+ ? value
40
+ : null);
41
+ // A file-edit diff from a jsonl line's top-level `toolUseResult`, or null if the tool didn't edit a file.
42
+ // Non-empty structuredPatch → count +/- lines and keep the hunks (for the expandable coloured view). Empty
43
+ // patch but a create → every content line is an addition. Anything else (Bash/Read/…) → null.
44
+ function extractDiff(value) {
45
+ const r = record(value);
46
+ if (!r)
47
+ return null;
48
+ const patch = Array.isArray(r.structuredPatch) ? r.structuredPatch : null;
49
+ if (patch && patch.length) {
50
+ let added = 0, removed = 0;
51
+ const hunks = [];
52
+ for (const rawHunk of patch) {
53
+ const h = record(rawHunk) || {};
54
+ const lines = Array.isArray(h.lines) ? h.lines.filter((line) => typeof line === 'string') : [];
55
+ for (const ln of lines) {
56
+ const c = typeof ln === 'string' ? ln[0] : '';
57
+ if (c === '+')
58
+ added++;
59
+ else if (c === '-')
60
+ removed++;
61
+ }
62
+ hunks.push({ oldStart: h.oldStart, newStart: h.newStart, lines });
63
+ }
64
+ return { added, removed, hunks };
65
+ }
66
+ if (r.type === 'create' && typeof r.content === 'string') {
67
+ return { added: r.content ? r.content.split('\n').length : 0, removed: 0, hunks: null, created: true };
68
+ }
69
+ return null;
70
+ }
71
+ function resultText(content) {
72
+ if (typeof content === 'string')
73
+ return content;
74
+ if (Array.isArray(content))
75
+ return content.map((item) => {
76
+ const c = record(item);
77
+ return c?.type === 'text' && typeof c.text === 'string' ? c.text : '';
78
+ }).join('');
79
+ return '';
80
+ }
81
+ const MAX_COMPACTION_SUMMARY_BYTES = 240 * 1024;
82
+ function compactionSummary(content) {
83
+ const value = resultText(content);
84
+ if (!value)
85
+ return null;
86
+ const originalBytes = Buffer.byteLength(value);
87
+ if (originalBytes <= MAX_COMPACTION_SUMMARY_BYTES) {
88
+ return { text: value, truncated: false, originalBytes };
89
+ }
90
+ let text = Buffer.from(value).subarray(0, MAX_COMPACTION_SUMMARY_BYTES).toString('utf8');
91
+ while (Buffer.byteLength(text) > MAX_COMPACTION_SUMMARY_BYTES)
92
+ text = text.slice(0, -1);
93
+ return { text, truncated: true, originalBytes };
94
+ }
95
+ // Leading text of a message's content (string as-is, or the first text item of an array) — used only to
96
+ // probe for a scaffolding tag at the very start. tool_result-only user turns yield '' and are never matched.
97
+ function leadingText(content) {
98
+ if (typeof content === 'string')
99
+ return content;
100
+ if (Array.isArray(content)) {
101
+ const t = content.map(record).find((item) => item?.type === 'text');
102
+ return t && typeof t.text === 'string' ? t.text : '';
103
+ }
104
+ return '';
105
+ }
106
+ // Stateful form used by the transcript route's append-only file cache. `push()` may be called with
107
+ // successive complete JSONL batches: tool results and slash-command stdout in a later batch can still
108
+ // update the tool/slash message created by an earlier batch. The one-shot `parseTranscript()` wrapper
109
+ // below preserves the public pure-function API used everywhere else.
110
+ export function createTranscriptParser() {
111
+ const msgs = [];
112
+ const byToolId = new Map(); // tool_use_id → the tool message awaiting its result
113
+ let i = 0;
114
+ function push(lines) {
115
+ for (const raw of lines) {
116
+ const s = typeof raw === 'string' ? raw.trim() : '';
117
+ if (!s) {
118
+ i++;
119
+ continue;
120
+ }
121
+ let parsed;
122
+ try {
123
+ parsed = JSON.parse(s);
124
+ }
125
+ catch {
126
+ i++;
127
+ continue;
128
+ }
129
+ const o = record(parsed);
130
+ const m = record(o?.message);
131
+ if (!o || !KEEP.has(typeof o.type === 'string' ? o.type : '') || !m) {
132
+ i++;
133
+ continue;
134
+ }
135
+ if (o.isMeta === true) {
136
+ i++;
137
+ continue;
138
+ }
139
+ // A compaction wall (isCompactSummary): don't render the (huge) summary text as a bubble, but DO leave a
140
+ // quiet divider marker where it happened, so the 对话 lens shows "上下文已压缩" between the old and new
141
+ // context instead of the conversation silently jumping. (A no-op /compact writes no such entry → no
142
+ // divider, correctly.) Rendered centered like the interrupt marker.
143
+ if (o.isCompactSummary === true) {
144
+ const summary = compactionSummary(m.content);
145
+ msgs.push({
146
+ i, type: 'compact', ts: typeof o.timestamp === 'string' ? o.timestamp : undefined,
147
+ ...(summary ? { summary: summary.text } : {}),
148
+ ...(summary?.truncated ? {
149
+ summaryTruncated: true, summaryOriginalBytes: summary.originalBytes,
150
+ } : {}),
151
+ });
152
+ i++;
153
+ continue;
154
+ }
155
+ // The jsonl line's wall-clock (ISO string) — carried onto each message so the 对话 lens can show a
156
+ // time separator between turns. Absent on some lines (older logs) → undefined; the UI shows nothing
157
+ // rather than a fabricated time ("有地方取就要,没有就不要").
158
+ const ts = typeof o.timestamp === 'string' ? o.timestamp : undefined;
159
+ const role = m.role === 'user' ? 'user' : 'assistant';
160
+ if (role === 'user') {
161
+ const lead = leadingText(m.content);
162
+ // A slash command → a quiet centered 'slash' marker (see the header note). Name + optional args.
163
+ const nameM = CMD_NAME_RE.exec(lead);
164
+ if (nameM) {
165
+ const argsM = CMD_ARGS_RE.exec(lead);
166
+ const args = argsM?.[1]?.trim() || '';
167
+ const name = nameM[1];
168
+ if (!name) {
169
+ i++;
170
+ continue;
171
+ }
172
+ const mk = { i, type: 'slash', name: `/${name}`, ts };
173
+ if (args)
174
+ mk.args = args;
175
+ msgs.push(mk);
176
+ i++;
177
+ continue;
178
+ }
179
+ // Its stdout echo → fold onto the marker just pushed as `.result` (presence ⇒ the command completed).
180
+ const outM = STDOUT_RE.exec(lead);
181
+ if (outM) {
182
+ const last = msgs[msgs.length - 1];
183
+ if (last && last.type === 'slash' && last.result === undefined) {
184
+ const txt = stripAnsi((outM[1] || '').replace(/<\/local-command-stdout>\s*$/i, '')).trim();
185
+ if (txt)
186
+ last.result = txt.length > SLASH_RESULT_CAP ? txt.slice(0, SLASH_RESULT_CAP) + '…' : txt;
187
+ }
188
+ i++;
189
+ continue;
190
+ }
191
+ // Other scaffolding (<command-message>, caveat, bash-*) and the bare "/x" input line: still dropped.
192
+ if (SCAFFOLD_RE.test(lead) || SLASH_CMD_RE.test(lead)) {
193
+ i++;
194
+ continue;
195
+ }
196
+ }
197
+ // ESC-interrupt: Claude Code appends a standalone user line carrying a top-level `interruptedMessageId`
198
+ // (content is just "[Request interrupted by user…]"). It's NOT something the user typed — surface it as a
199
+ // quiet 'interrupt' marker (rendered as a small centered hint), never as a prominent user bubble. Detect
200
+ // by the structural field, falling back to the marker text for older logs.
201
+ if (typeof o.interruptedMessageId === 'string' || (role === 'user' && /^\[Request interrupted by user/.test(leadingText(m.content)))) {
202
+ msgs.push({ i, type: 'interrupt', ts });
203
+ i++;
204
+ continue;
205
+ }
206
+ const items = typeof m.content === 'string'
207
+ ? [{ type: 'text', text: m.content }]
208
+ : Array.isArray(m.content) ? m.content : [];
209
+ for (const rawItem of items) {
210
+ const it = record(rawItem);
211
+ if (!it)
212
+ continue;
213
+ // .trim() here is a truthiness guard only (dropping whitespace-only text items to avoid empty
214
+ // bubbles) — the pushed `it.text` below is the untrimmed original, so real text's internal/
215
+ // leading/trailing whitespace is preserved verbatim.
216
+ if (it.type === 'text' && typeof it.text === 'string' && it.text.trim()) {
217
+ msgs.push({ i, role, type: 'text', text: it.text, ts });
218
+ }
219
+ else if (it.type === 'thinking' && typeof it.thinking === 'string' && it.thinking) {
220
+ msgs.push({ i, role: 'assistant', type: 'thinking', text: it.thinking, ts });
221
+ }
222
+ else if (it.type === 'tool_use') {
223
+ const tm = {
224
+ i,
225
+ role: 'assistant',
226
+ type: 'tool',
227
+ ts,
228
+ tool: {
229
+ name: typeof it.name === 'string' ? it.name : '',
230
+ input: it.input || {},
231
+ result: null,
232
+ isError: false,
233
+ },
234
+ };
235
+ if (typeof it.id === 'string' && it.id)
236
+ byToolId.set(it.id, tm);
237
+ msgs.push(tm);
238
+ }
239
+ else if (it.type === 'tool_result') {
240
+ const tm = typeof it.tool_use_id === 'string' ? byToolId.get(it.tool_use_id) : undefined;
241
+ if (tm) {
242
+ tm.tool.result = resultText(it.content);
243
+ tm.tool.isError = !!it.is_error;
244
+ // Claude Code stores a real per-hunk diff for file edits on the SAME line's top-level
245
+ // `toolUseResult.structuredPatch` (hunks of {oldStart,newStart,lines[]}, each line prefixed
246
+ // +/-/space) — the exact data the CLI renders. Fold it into the tool message so the 对话 chip
247
+ // can show +A/−B and open the coloured diff, instead of the bland "…updated successfully" string.
248
+ // A file CREATE has an empty patch but `type:'create'` + `content` → treat every line as added.
249
+ tm.tool.diff = extractDiff(o.toolUseResult);
250
+ }
251
+ }
252
+ }
253
+ i++;
254
+ }
255
+ return msgs;
256
+ }
257
+ return { push, messages: msgs };
258
+ }
259
+ export function parseTranscript(lines) {
260
+ return createTranscriptParser().push(lines);
261
+ }
@@ -0,0 +1,209 @@
1
+ import { createReadStream, promises as fsp } from 'node:fs';
2
+ import { setImmediate as yieldImmediate } from 'node:timers/promises';
3
+ import { createTranscriptParser } from './transcriptParse.js';
4
+ // Append-aware JSONL reader for chat transcripts. A live Claude session is append-only, so after the
5
+ // first asynchronous scan we read and parse only newly completed lines. Replacement/truncation resets
6
+ // the parser; a small LRU bounds server memory across panes. Parsing yields periodically so the initial
7
+ // scan of a long session cannot monopolize the Node event loop.
8
+ export function createTranscriptReader({ maxEntries = 8, yieldEvery = 500 } = {}) {
9
+ const cache = new Map();
10
+ const inflight = new Map();
11
+ let generation = 0;
12
+ async function readCompleteLines(file, start, endExclusive) {
13
+ const lines = [];
14
+ let pending = Buffer.alloc(0);
15
+ let consumed = 0;
16
+ let bytesRead = 0;
17
+ if (endExclusive !== undefined && endExclusive <= start) {
18
+ return { lines, offset: start, size: start };
19
+ }
20
+ const options = endExclusive === undefined
21
+ ? { start }
22
+ : { start, end: endExclusive - 1 };
23
+ for await (const rawChunk of createReadStream(file, options)) {
24
+ const chunk = Buffer.isBuffer(rawChunk) ? rawChunk : Buffer.from(rawChunk);
25
+ bytesRead += chunk.length;
26
+ const data = pending.length ? Buffer.concat([pending, chunk]) : chunk;
27
+ let pos = 0;
28
+ for (;;) {
29
+ const nl = data.indexOf(0x0a, pos);
30
+ if (nl === -1)
31
+ break;
32
+ lines.push(data.subarray(pos, nl).toString('utf8'));
33
+ consumed += nl - pos + 1;
34
+ pos = nl + 1;
35
+ }
36
+ pending = data.subarray(pos);
37
+ }
38
+ return { lines, offset: start + consumed, size: start + bytesRead };
39
+ }
40
+ async function applyLines(parser, lines) {
41
+ for (let i = 0; i < lines.length; i += yieldEvery) {
42
+ parser.push(lines.slice(i, i + yieldEvery));
43
+ if (i + yieldEvery < lines.length)
44
+ await yieldImmediate();
45
+ }
46
+ }
47
+ function trim() {
48
+ if (cache.size <= maxEntries)
49
+ return;
50
+ const oldest = Array.from(cache.entries()).sort((a, b) => a[1].usedAt - b[1].usedAt);
51
+ for (const [file] of oldest) {
52
+ if (cache.size <= maxEntries)
53
+ break;
54
+ if (!inflight.has(file))
55
+ cache.delete(file);
56
+ }
57
+ }
58
+ async function load(file, createParser) {
59
+ let st;
60
+ try {
61
+ st = await fsp.stat(file);
62
+ }
63
+ catch {
64
+ cache.delete(file);
65
+ return [];
66
+ }
67
+ if (!st.isFile()) {
68
+ cache.delete(file);
69
+ return [];
70
+ }
71
+ let entry = cache.get(file);
72
+ const sameFile = Boolean(entry
73
+ && entry.createParser === createParser
74
+ && entry.dev === st.dev
75
+ && entry.ino === st.ino);
76
+ if (entry && sameFile && entry.size === st.size && entry.mtimeMs === st.mtimeMs) {
77
+ entry.usedAt = Date.now();
78
+ return entry.parser.messages;
79
+ }
80
+ // A larger file with the same inode is the normal append path. Anything else (truncate, atomic
81
+ // replace, or an in-place rewrite with unchanged size) gets a clean parser so stale messages cannot
82
+ // leak across /clear or log rotation.
83
+ const append = Boolean(sameFile && entry && st.size > entry.size);
84
+ if (!append || !entry) {
85
+ entry = {
86
+ parser: createParser(), createParser, offset: 0, dev: st.dev, ino: st.ino,
87
+ size: 0, mtimeMs: 0, usedAt: 0, generation: ++generation,
88
+ };
89
+ }
90
+ const { lines, offset, size } = await readCompleteLines(file, entry.offset);
91
+ await applyLines(entry.parser, lines);
92
+ // Re-stat after reading: the writer may have appended while the stream was open. Metadata reflects
93
+ // the latest observed file; any bytes not consumed as complete lines are deliberately retried later.
94
+ let end = st;
95
+ try {
96
+ end = await fsp.stat(file);
97
+ }
98
+ catch { /* return the last complete snapshot */ }
99
+ entry.offset = offset;
100
+ // Only adopt the post-read metadata if it describes exactly the bytes the stream consumed. If the
101
+ // file grew or was replaced in the race window, retaining the pre-read identity/observed size forces
102
+ // the next poll down the append/reset path instead of accidentally declaring unread bytes unchanged.
103
+ const caughtUp = end.dev === st.dev && end.ino === st.ino && end.size === size;
104
+ entry.dev = caughtUp ? end.dev : st.dev;
105
+ entry.ino = caughtUp ? end.ino : st.ino;
106
+ entry.size = size;
107
+ entry.mtimeMs = caughtUp ? end.mtimeMs : st.mtimeMs;
108
+ entry.usedAt = Date.now();
109
+ cache.set(file, entry);
110
+ trim();
111
+ return entry.parser.messages;
112
+ }
113
+ async function read(file, createParser = createTranscriptParser) {
114
+ const active = inflight.get(file);
115
+ if (active)
116
+ return active;
117
+ const run = load(file, createParser).finally(() => inflight.delete(file));
118
+ inflight.set(file, run);
119
+ return run;
120
+ }
121
+ async function readPrefix(file, endExclusive, createParser) {
122
+ if (!Number.isSafeInteger(endExclusive) || endExclusive < 0) {
123
+ throw new TypeError('Transcript prefix boundary must be a non-negative safe integer');
124
+ }
125
+ const parser = createParser();
126
+ if (endExclusive === 0)
127
+ return parser.messages;
128
+ const { lines } = await readCompleteLines(file, 0, endExclusive);
129
+ await applyLines(parser, lines);
130
+ return parser.messages;
131
+ }
132
+ async function readSnapshot(file, createParser) {
133
+ const messages = await read(file, createParser);
134
+ const entry = cache.get(file);
135
+ // `offset` advances only after complete JSONL records were parsed. Metadata distinguishes an in-place
136
+ // rewrite/replace that happens to end at the same byte boundary. Consumers can therefore skip all
137
+ // projection and hashing work when this token is unchanged.
138
+ const version = entry
139
+ ? `${entry.dev}:${entry.ino}:${entry.offset}:${entry.size}:${entry.mtimeMs}`
140
+ : 'missing';
141
+ return {
142
+ messages,
143
+ version,
144
+ changedFrom: entry
145
+ ? entry.parser.takeChangedFrom ? entry.parser.takeChangedFrom() : 0
146
+ : null,
147
+ ...(entry ? { generation: entry.generation } : {}),
148
+ };
149
+ }
150
+ async function readPrefixSnapshot(file, endExclusive, createParser) {
151
+ if (!Number.isSafeInteger(endExclusive) || endExclusive < 0) {
152
+ throw new TypeError('Transcript prefix boundary must be a non-negative safe integer');
153
+ }
154
+ // A normal read may already be advancing this parser. Wait for that mutation before deciding whether
155
+ // its append state can satisfy the immutable opening boundary.
156
+ await inflight.get(file)?.catch(() => { });
157
+ let st;
158
+ try {
159
+ st = await fsp.stat(file);
160
+ }
161
+ catch {
162
+ return { messages: [], version: 'missing', changedFrom: null };
163
+ }
164
+ if (!st.isFile() || endExclusive > st.size) {
165
+ return { messages: [], version: 'missing', changedFrom: null };
166
+ }
167
+ let entry = cache.get(file);
168
+ const reusable = Boolean(entry
169
+ && entry.createParser === createParser
170
+ && entry.dev === st.dev
171
+ && entry.ino === st.ino
172
+ && entry.offset <= endExclusive
173
+ && entry.size <= endExclusive);
174
+ if (!reusable || !entry) {
175
+ // A cached parser beyond the cutoff cannot be rewound. Build this rare historical prefix in
176
+ // isolation and leave the newer append cache intact.
177
+ const parser = createParser();
178
+ const { lines, offset, size } = await readCompleteLines(file, 0, endExclusive);
179
+ await applyLines(parser, lines);
180
+ return {
181
+ messages: parser.messages,
182
+ version: `${st.dev}:${st.ino}:${offset}:${size}:${st.mtimeMs}`,
183
+ changedFrom: parser.takeChangedFrom ? parser.takeChangedFrom() : 0,
184
+ generation: ++generation,
185
+ };
186
+ }
187
+ if (entry.size < endExclusive || entry.offset < endExclusive) {
188
+ const { lines, offset, size } = await readCompleteLines(file, entry.offset, endExclusive);
189
+ await applyLines(entry.parser, lines);
190
+ entry.offset = offset;
191
+ entry.size = size;
192
+ entry.mtimeMs = st.mtimeMs;
193
+ entry.usedAt = Date.now();
194
+ cache.set(file, entry);
195
+ trim();
196
+ }
197
+ return {
198
+ messages: entry.parser.messages,
199
+ version: `${entry.dev}:${entry.ino}:${entry.offset}:${entry.size}:${entry.mtimeMs}`,
200
+ changedFrom: entry.parser.takeChangedFrom ? entry.parser.takeChangedFrom() : 0,
201
+ generation: entry.generation,
202
+ };
203
+ }
204
+ return {
205
+ read, readPrefix, readSnapshot, readPrefixSnapshot,
206
+ clear: () => cache.clear(), size: () => cache.size,
207
+ };
208
+ }
209
+ export const transcriptReader = createTranscriptReader();
@@ -12,17 +12,17 @@
12
12
  // ambiguous blank rows before this step, so genuinely shaded padding carries its explicit SGR while
13
13
  // a default-background blank may be trimmed normally.
14
14
  export const MAX_TRAILING_BLANK = 3;
15
-
16
15
  const isBlank = (row) => row === '' || (/^[ \t]*$/.test(row) && !row.includes('\x1b'));
17
-
18
16
  export function capTrailingBlankRows(ansi, max = MAX_TRAILING_BLANK) {
19
- // capture-pane terminates every row with \n (including a trailing one). Peel that off so split
20
- // gives exactly the rows, then restore it so the output keeps capture-pane's shape (prepareSeed
21
- // drops a single trailing newline downstream).
22
- const hadNL = ansi.endsWith('\n');
23
- const rows = (hadNL ? ansi.slice(0, -1) : ansi).split('\n');
24
- let end = rows.length;
25
- while (end > 0 && isBlank(rows[end - 1])) end -= 1;
26
- if (rows.length - end > max) rows.length = end + max;
27
- return rows.join('\n') + (hadNL ? '\n' : '');
17
+ // capture-pane terminates every row with \n (including a trailing one). Peel that off so split
18
+ // gives exactly the rows, then restore it so the output keeps capture-pane's shape (prepareSeed
19
+ // drops a single trailing newline downstream).
20
+ const hadNL = ansi.endsWith('\n');
21
+ const rows = (hadNL ? ansi.slice(0, -1) : ansi).split('\n');
22
+ let end = rows.length;
23
+ while (end > 0 && isBlank(rows[end - 1] ?? ''))
24
+ end -= 1;
25
+ if (rows.length - end > max)
26
+ rows.length = end + max;
27
+ return rows.join('\n') + (hadNL ? '\n' : '');
28
28
  }
@@ -0,0 +1,31 @@
1
+ // Extension allow-list for uploads. By extension only (not magic bytes) — this is a "be tidy"
2
+ // guard, not a trust boundary (a token holder can write anything from the terminal anyway; see spec
3
+ // threat model). Override the whole set with HANDMUX_UPLOAD_EXTS (comma-separated).
4
+ export const DEFAULT_UPLOAD_EXTS = new Set([
5
+ // images
6
+ 'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'heic', 'heif', 'avif', 'ico', 'tiff',
7
+ // video
8
+ 'mp4', 'm4v', 'mov', 'webm', 'mkv', 'avi', 'wmv', 'flv', '3gp', 'ogv', 'mpeg', 'mpg',
9
+ // text / code
10
+ 'txt', 'md', 'markdown', 'rst', 'log', 'csv', 'tsv', 'json', 'yaml', 'yml', 'toml', 'ini',
11
+ 'conf', 'xml', 'html', 'htm', 'css', 'js', 'mjs', 'cjs', 'ts', 'tsx', 'jsx', 'py', 'rb', 'go',
12
+ 'rs', 'java', 'c', 'h', 'cpp', 'sh',
13
+ // documents / office
14
+ 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp', 'rtf',
15
+ // archives
16
+ 'zip',
17
+ ]);
18
+ // Read HANDMUX_UPLOAD_EXTS (comma-separated) → a Set, normalising leading dots/case/whitespace.
19
+ // Blank/absent → the default set (returned by reference so callers can `=== DEFAULT_UPLOAD_EXTS`).
20
+ export function loadUploadExts(env = process.env) {
21
+ const raw = env.HANDMUX_UPLOAD_EXTS;
22
+ if (!raw || !raw.trim())
23
+ return DEFAULT_UPLOAD_EXTS;
24
+ const exts = raw.split(',').map((s) => s.trim().replace(/^\.+/, '').toLowerCase()).filter(Boolean);
25
+ return new Set(exts);
26
+ }
27
+ // True if `name`'s final extension is in `exts`. No extension → false.
28
+ export function isAllowedUploadExt(name, exts) {
29
+ const m = /\.([A-Za-z0-9]+)$/.exec(name || '');
30
+ return m?.[1] ? exts.has(m[1].toLowerCase()) : false;
31
+ }
@@ -0,0 +1,18 @@
1
+ // Notification links are user-supplied by `handmux push --url`. Keep relative in-app links, but reject
2
+ // executable/non-web schemes (javascript:, data:, file:, …) before they reach storage or an <a href>.
3
+ export function sanitizeNotificationUrl(value) {
4
+ if (typeof value !== 'string')
5
+ return null;
6
+ const raw = value.trim();
7
+ if (!raw)
8
+ return null;
9
+ try {
10
+ const parsed = new URL(raw, 'https://handmux.invalid/');
11
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
12
+ return null;
13
+ return raw;
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ }
@@ -0,0 +1,15 @@
1
+ import path from 'node:path';
2
+ import { homedir } from 'node:os';
3
+ import { pocketHome } from './cli/state.js';
4
+ export function claudeUsagePath(home = homedir()) {
5
+ return path.join(pocketHome(home), 'claude-usage.json');
6
+ }
7
+ export function claudeContextDir(home = homedir()) {
8
+ return path.join(pocketHome(home), 'context');
9
+ }
10
+ export function codexUsagePath(home = homedir()) {
11
+ return path.join(pocketHome(home), 'codex-usage.json');
12
+ }
13
+ export function codexSessionsDir(home = homedir()) {
14
+ return path.join(home, '.codex', 'sessions');
15
+ }