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,228 @@
1
+ // Persistent machine-wide Codex usage snapshot utilities. Kept as CommonJS so the ESM usage service can
2
+ // load the same bounded rollout reader without adding a second package boundary.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ const CHUNK_BYTES = 64 * 1024;
6
+ const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
7
+ const finiteNumber = (value) => typeof value === 'number' && Number.isFinite(value);
8
+ const nullableNumber = (value) => value === null || finiteNumber(value);
9
+ const numberOrNull = (value) => finiteNumber(value) ? value : null;
10
+ function ensurePrivateDirectory(directory) {
11
+ const resolved = path.resolve(directory);
12
+ const parsed = path.parse(resolved);
13
+ const parts = resolved.slice(parsed.root.length).split(path.sep).filter(Boolean);
14
+ const privateRoot = parts.indexOf('.handmux');
15
+ if (privateRoot < 0) {
16
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
17
+ return;
18
+ }
19
+ for (let i = privateRoot; i < parts.length; i++) {
20
+ const current = path.join(parsed.root, ...parts.slice(0, i + 1));
21
+ fs.mkdirSync(current, { recursive: true, mode: 0o700 });
22
+ fs.chmodSync(current, 0o700);
23
+ }
24
+ }
25
+ function windowUsage(value) {
26
+ if (!isRecord(value) || !finiteNumber(value.used_percent))
27
+ return null;
28
+ return {
29
+ usedPercent: value.used_percent,
30
+ windowMinutes: numberOrNull(value.window_minutes),
31
+ resetsAt: numberOrNull(value.resets_at),
32
+ };
33
+ }
34
+ function normalize(record) {
35
+ if (!isRecord(record) || !isRecord(record.payload))
36
+ return null;
37
+ const payload = record.payload;
38
+ if (payload.type !== 'token_count' || typeof record.timestamp !== 'string')
39
+ return null;
40
+ const updatedAt = Date.parse(record.timestamp);
41
+ if (!Number.isFinite(updatedAt))
42
+ return null;
43
+ const info = isRecord(payload.info) ? payload.info : {};
44
+ const totals = isRecord(info.total_token_usage) ? info.total_token_usage : {};
45
+ const rawLimits = isRecord(payload.rate_limits) ? payload.rate_limits : {};
46
+ // A rollout may carry a model-specific limit (for example GPT-5.3-Codex-Spark's
47
+ // `codex_bengalfox`) instead of the account's main Codex limit. Keep its token/context data, but never
48
+ // project that independent percentage as the generic Codex quota shown by HandMux.
49
+ const limitId = typeof rawLimits.limit_id === 'string' ? rawLimits.limit_id : null;
50
+ const limits = limitId === null || limitId === 'codex' ? rawLimits : {};
51
+ return {
52
+ updatedAt,
53
+ rateLimits: {
54
+ primary: windowUsage(limits.primary),
55
+ secondary: windowUsage(limits.secondary),
56
+ },
57
+ tokens: {
58
+ total: numberOrNull(totals.total_tokens),
59
+ input: numberOrNull(totals.input_tokens),
60
+ cachedInput: numberOrNull(totals.cached_input_tokens),
61
+ output: numberOrNull(totals.output_tokens),
62
+ reasoning: numberOrNull(totals.reasoning_output_tokens),
63
+ },
64
+ contextWindow: numberOrNull(info.model_context_window),
65
+ };
66
+ }
67
+ function contextUsage(record) {
68
+ if (!isRecord(record) || !isRecord(record.payload) || record.payload.type !== 'token_count')
69
+ return null;
70
+ const info = isRecord(record.payload.info) ? record.payload.info : null;
71
+ const last = isRecord(info?.last_token_usage) ? info.last_token_usage : null;
72
+ const usedTokens = last?.total_tokens;
73
+ const totalTokens = info?.model_context_window;
74
+ if (!finiteNumber(usedTokens) || usedTokens < 0
75
+ || !finiteNumber(totalTokens) || totalTokens <= 0)
76
+ return null;
77
+ return { usedTokens, totalTokens };
78
+ }
79
+ function parseLatest(lines, normalizeLine) {
80
+ for (let i = lines.length - 1; i >= 0; i--) {
81
+ const line = lines[i];
82
+ if (!line || !line.includes('token_count'))
83
+ continue;
84
+ try {
85
+ const usage = normalizeLine(JSON.parse(line));
86
+ if (usage)
87
+ return usage;
88
+ }
89
+ catch { /* damaged/incomplete line: continue toward older complete records */ }
90
+ }
91
+ return null;
92
+ }
93
+ function readLatest(transcriptPath, normalizeLine) {
94
+ let fd;
95
+ try {
96
+ fd = fs.openSync(transcriptPath, 'r');
97
+ let end = fs.fstatSync(fd).size;
98
+ let suffix = '';
99
+ while (end > 0) {
100
+ const start = Math.max(0, end - CHUNK_BYTES);
101
+ const buffer = Buffer.allocUnsafe(end - start);
102
+ fs.readSync(fd, buffer, 0, buffer.length, start);
103
+ const lines = `${buffer.toString('utf8')}${suffix}`.split('\n');
104
+ if (start > 0)
105
+ suffix = lines.shift() || '';
106
+ const usage = parseLatest(lines, normalizeLine);
107
+ if (usage)
108
+ return usage;
109
+ end = start;
110
+ }
111
+ return suffix ? parseLatest([suffix], normalizeLine) : null;
112
+ }
113
+ catch {
114
+ return null;
115
+ }
116
+ finally {
117
+ if (fd !== undefined) {
118
+ try {
119
+ fs.closeSync(fd);
120
+ }
121
+ catch { /* best effort */ }
122
+ }
123
+ }
124
+ }
125
+ export function readLatestUsage(transcriptPath) {
126
+ return readLatest(transcriptPath, normalize);
127
+ }
128
+ export function readLatestContextUsage(transcriptPath) {
129
+ return readLatest(transcriptPath, contextUsage);
130
+ }
131
+ function isUsageWindow(value) {
132
+ return isRecord(value)
133
+ && finiteNumber(value.usedPercent)
134
+ && nullableNumber(value.windowMinutes)
135
+ && nullableNumber(value.resetsAt);
136
+ }
137
+ function isCodexUsage(value) {
138
+ if (!isRecord(value)
139
+ || !finiteNumber(value.updatedAt)
140
+ || !isRecord(value.rateLimits)
141
+ || !isRecord(value.tokens)
142
+ || !nullableNumber(value.contextWindow))
143
+ return false;
144
+ const { primary, secondary } = value.rateLimits;
145
+ if (primary !== null && !isUsageWindow(primary))
146
+ return false;
147
+ if (secondary !== null && !isUsageWindow(secondary))
148
+ return false;
149
+ return nullableNumber(value.tokens.total)
150
+ && nullableNumber(value.tokens.input)
151
+ && nullableNumber(value.tokens.cachedInput)
152
+ && nullableNumber(value.tokens.output)
153
+ && nullableNumber(value.tokens.reasoning);
154
+ }
155
+ export function readSnapshot(snapshotPath) {
156
+ try {
157
+ ensurePrivateDirectory(path.dirname(snapshotPath));
158
+ fs.chmodSync(snapshotPath, 0o600);
159
+ const value = JSON.parse(fs.readFileSync(snapshotPath, 'utf8'));
160
+ if (!isRecord(value) || value.version !== 1 || !finiteNumber(value.checkedAt))
161
+ return null;
162
+ if (value.usage !== null && !isCodexUsage(value.usage))
163
+ return null;
164
+ return { version: 1, checkedAt: value.checkedAt, usage: value.usage };
165
+ }
166
+ catch {
167
+ return null;
168
+ }
169
+ }
170
+ function nap(ms) {
171
+ try {
172
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
173
+ }
174
+ catch {
175
+ const until = Date.now() + ms;
176
+ while (Date.now() < until) { /* spin */ }
177
+ }
178
+ }
179
+ export function writeSnapshot(snapshotPath, usage, { checkedAt } = {}) {
180
+ try {
181
+ ensurePrivateDirectory(path.dirname(snapshotPath));
182
+ }
183
+ catch {
184
+ return null;
185
+ }
186
+ const lock = `${snapshotPath}.lock`;
187
+ let held = false;
188
+ for (let i = 0; i < 40 && !held; i++) {
189
+ try {
190
+ fs.closeSync(fs.openSync(lock, 'wx', 0o600));
191
+ held = true;
192
+ }
193
+ catch {
194
+ try {
195
+ if (Date.now() - fs.statSync(lock).mtimeMs > 3000)
196
+ fs.unlinkSync(lock);
197
+ }
198
+ catch { /* retry */ }
199
+ nap(5);
200
+ }
201
+ }
202
+ if (!held)
203
+ return readSnapshot(snapshotPath)?.usage || null;
204
+ try {
205
+ const previous = readSnapshot(snapshotPath);
206
+ const priorUsage = previous?.usage || null;
207
+ const nextUsage = usage && (!priorUsage || usage.updatedAt >= priorUsage.updatedAt) ? usage : priorUsage;
208
+ const value = {
209
+ version: 1,
210
+ checkedAt: typeof checkedAt === 'number' ? checkedAt : (previous?.checkedAt || 0),
211
+ usage: nextUsage,
212
+ };
213
+ const tmp = `${snapshotPath}.${process.pid}.${Date.now()}.tmp`;
214
+ fs.writeFileSync(tmp, JSON.stringify(value), { mode: 0o600 });
215
+ fs.renameSync(tmp, snapshotPath);
216
+ fs.chmodSync(snapshotPath, 0o600);
217
+ return nextUsage;
218
+ }
219
+ catch {
220
+ return readSnapshot(snapshotPath)?.usage || null;
221
+ }
222
+ finally {
223
+ try {
224
+ fs.unlinkSync(lock);
225
+ }
226
+ catch { /* best effort */ }
227
+ }
228
+ }
@@ -0,0 +1,8 @@
1
+ import { normalizeShortcuts } from './shortcutConfig.js';
2
+ export function loadConfig(env = process.env) {
3
+ return {
4
+ host: env.HANDMUX_HOST || '0.0.0.0',
5
+ port: Number(env.HANDMUX_PORT) || 4000,
6
+ shortcuts: normalizeShortcuts(env.HANDMUX_SHORTCUTS === undefined ? undefined : JSON.parse(env.HANDMUX_SHORTCUTS)),
7
+ };
8
+ }
@@ -1,48 +1,46 @@
1
- // Renderable doc types by extension. Exported so the web mirror (docPath.js DOC_LINK_EXTS) can be
2
- // parity-checked against it — the client's tappable-path detector must open exactly what we can render.
3
1
  export const EXT = {
4
- '.md': 'markdown', '.markdown': 'markdown', '.html': 'html', '.htm': 'html',
5
- // Plain-text files: rendered verbatim in a <pre> (no markdown parsing).
6
- '.txt': 'text', '.log': 'text', '.sh': 'text',
2
+ '.md': 'markdown', '.markdown': 'markdown', '.html': 'html', '.htm': 'html',
3
+ // Plain-text files: rendered verbatim in a <pre> (no markdown parsing).
4
+ '.txt': 'text', '.log': 'text', '.sh': 'text',
7
5
  };
8
-
9
6
  // Map a filename to our renderable doc type by extension, or null. Case-insensitive.
10
7
  export function docTypeFor(name) {
11
- const m = /\.[A-Za-z0-9]+$/.exec(name || '');
12
- return m ? (EXT[m[0].toLowerCase()] ?? null) : null;
8
+ const m = /\.[A-Za-z0-9]+$/.exec(name || '');
9
+ return m ? (EXT[m[0].toLowerCase()] ?? null) : null;
13
10
  }
14
-
15
11
  // Image extensions the in-app viewer can show inline via <img> (GIF animates natively). SVG is safe
16
12
  // here because <img>-loaded SVG never runs its scripts. Returns 'image' or null. Case-insensitive.
17
13
  export const IMG_EXT = new Set(['png', 'jpg', 'jpeg', 'jfif', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif', 'apng']);
18
14
  export function imageTypeFor(name) {
19
- const m = /\.([A-Za-z0-9]+)$/.exec(name || '');
20
- return m && IMG_EXT.has(m[1].toLowerCase()) ? 'image' : null;
15
+ const m = /\.([A-Za-z0-9]+)$/.exec(name || '');
16
+ return m?.[1] && IMG_EXT.has(m[1].toLowerCase()) ? 'image' : null;
21
17
  }
22
-
23
18
  // True if `child` equals `parent` or sits inside it. Both are expected to be realpaths.
24
19
  // Guards the sibling-prefix trap: /home/ab is NOT under /home/a.
25
20
  export function isUnder(child, parent) {
26
- if (child === parent) return true;
27
- const p = parent.endsWith('/') ? parent : parent + '/';
28
- return child.startsWith(p);
21
+ if (child === parent)
22
+ return true;
23
+ const p = parent.endsWith('/') ? parent : parent + '/';
24
+ return child.startsWith(p);
29
25
  }
30
-
31
26
  // Sanitize a client-supplied upload filename to a single safe path segment, or null if unsafe.
32
27
  // Takes the basename (drops any dir part, handling both / and \), then rejects empty / '.' / '..'
33
28
  // and dotfiles (no hidden files). The result never contains a path separator.
34
29
  export function safeUploadName(raw) {
35
- if (typeof raw !== 'string') return null;
36
- const base = raw.split('/').pop().split('\\').pop();
37
- if (!base || base === '.' || base === '..' || base[0] === '.') return null;
38
- return base;
30
+ if (typeof raw !== 'string')
31
+ return null;
32
+ const slashBase = raw.split('/').pop() ?? '';
33
+ const base = slashBase.split('\\').pop();
34
+ if (!base || base === '.' || base === '..' || base[0] === '.')
35
+ return null;
36
+ return base;
39
37
  }
40
-
41
38
  // True if `real` (a realpath at/under `home`) has any path segment BELOW home that starts with '.'
42
39
  // — i.e. it lives inside a hidden directory like ~/.ssh or ~/.config. `home` is the realpath of
43
40
  // $HOME. The home root itself is not hidden.
44
41
  export function hasHiddenSegment(real, home) {
45
- if (real === home) return false;
46
- const rel = real.startsWith(home.endsWith('/') ? home : home + '/') ? real.slice(home.length) : real;
47
- return rel.split('/').filter(Boolean).some((seg) => seg.startsWith('.'));
42
+ if (real === home)
43
+ return false;
44
+ const rel = real.startsWith(home.endsWith('/') ? home : home + '/') ? real.slice(home.length) : real;
45
+ return rel.split('/').filter(Boolean).some((seg) => seg.startsWith('.'));
48
46
  }
@@ -0,0 +1,337 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join, basename } from 'node:path';
4
+ import { TextDecoder } from 'node:util';
5
+ import { docTypeFor, imageTypeFor, isUnder, hasHiddenSegment } from './docPath.js';
6
+ const MAX_READ_BYTES = 2 * 1024 * 1024; // 2MB cap for in-app text reads (readDoc)
7
+ // Single source of truth for the 50MB transfer cap, shared by download (maxDownloadBytes default
8
+ // below) and upload (httpApi.js imports this as the maxUploadBytes default). Server-side only.
9
+ export const MAX_TRANSFER_BYTES = 50 * 1024 * 1024;
10
+ // Decode only actual text. `Buffer.toString('utf8')` silently replaces malformed bytes, which would
11
+ // make arbitrary binaries look previewable; fatal decoders keep that boundary honest. UTF-16 needs a
12
+ // BOM because zero bytes are otherwise our strongest cheap binary signal.
13
+ function decodeText(buffer) {
14
+ let encoding = 'utf-8';
15
+ let offset = 0;
16
+ if (buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe) {
17
+ encoding = 'utf-16le';
18
+ offset = 2;
19
+ }
20
+ else if (buffer.length >= 2 && buffer[0] === 0xfe && buffer[1] === 0xff) {
21
+ encoding = 'utf-16be';
22
+ offset = 2;
23
+ }
24
+ else if (buffer.includes(0)) {
25
+ return null;
26
+ }
27
+ try {
28
+ return new TextDecoder(encoding, { fatal: true }).decode(buffer.subarray(offset));
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ }
34
+ // Flatten an absolute cwd into ONE filesystem-safe path segment for the per-project upload space,
35
+ // Claude-Code style: '/' → '-' (so /Users/x/proj → -Users-x-proj), and any other non-portable char
36
+ // → '_'. A valid cwd always starts with '/', so the key always starts with '-' — it can never be
37
+ // '..'/'.' and, having no separators, can never escape the uploads root. Empty/relative → '_default'.
38
+ export function encodeCwdKey(cwd) {
39
+ if (typeof cwd !== 'string' || cwd[0] !== '/')
40
+ return '_default';
41
+ return cwd.replace(/\//g, '-').replace(/[^A-Za-z0-9._-]/g, '_') || '_default';
42
+ }
43
+ export function createDocs({ home, extraRoots = [], maxDownloadBytes = MAX_TRANSFER_BYTES }) {
44
+ // $HOME is constant at runtime — resolve once at construction, reuse the same promise everywhere.
45
+ const realHomeP = fs.realpath(home);
46
+ // Browse / read / download / upload may also reach a few EXTRA roots OUTSIDE $HOME (e.g. /tmp,
47
+ // $TMPDIR) so transient files an agent drops there are reachable from the phone. Resolved once:
48
+ // realpath'd, deduped, missing ones skipped, and any extra already inside home dropped (home
49
+ // covers it). `home` is always roots[0]. Session-cwd (resolveCwd) and the upload stash stay
50
+ // home-only on purpose. The "under one of these roots" check replaces the old single isUnder(home).
51
+ const rootsP = (async () => {
52
+ const rh = await realHomeP;
53
+ const out = [rh];
54
+ for (const r of extraRoots) {
55
+ if (typeof r !== 'string' || !r)
56
+ continue;
57
+ let real;
58
+ try {
59
+ real = await fs.realpath(r);
60
+ }
61
+ catch {
62
+ continue;
63
+ } // not present on this host → skip
64
+ if (isUnder(real, rh) || out.includes(real))
65
+ continue; // already covered by home / dup
66
+ out.push(real);
67
+ }
68
+ return out;
69
+ })();
70
+ // The allowed root that contains `real` (longest match wins should roots ever nest), or null.
71
+ const rootOf = (real, roots) => {
72
+ let best = null;
73
+ for (const r of roots)
74
+ if (isUnder(real, r) && (!best || r.length > best.length))
75
+ best = r;
76
+ return best;
77
+ };
78
+ // `knownMtime` (ms) makes this a conditional read: if the file's mtime is unchanged the body is
79
+ // skipped and `{ notModified: true }` comes back (no readFile), so re-visiting an unchanged doc on
80
+ // the phone neither transfers content nor forces a re-render. `mtimeMs` is always returned so the
81
+ // client can store it for the next check.
82
+ async function readDoc(rawPath, knownMtime = null) {
83
+ if (typeof rawPath !== 'string' || rawPath[0] !== '/')
84
+ return { error: 'not absolute', status: 400 };
85
+ // Markdown/HTML keep their rich renderer; every other filename gets a content-checked plain-text
86
+ // preview. Extension is presentation metadata, never the permission to read a file as text.
87
+ const type = docTypeFor(rawPath) || 'text';
88
+ let real;
89
+ try {
90
+ real = await fs.realpath(rawPath);
91
+ }
92
+ catch {
93
+ return { error: 'not found', status: 404 };
94
+ }
95
+ if (!rootOf(real, await rootsP))
96
+ return { error: 'outside home', status: 400 };
97
+ let st;
98
+ try {
99
+ st = await fs.stat(real);
100
+ }
101
+ catch {
102
+ return { error: 'not accessible', status: 404 };
103
+ }
104
+ if (!st.isFile())
105
+ return { error: 'not a file', status: 400 };
106
+ if (st.size > MAX_READ_BYTES)
107
+ return { error: 'too large', status: 413 };
108
+ const mtimeMs = st.mtimeMs;
109
+ if (knownMtime != null && mtimeMs === knownMtime)
110
+ return { name: basename(real), type, mtimeMs, notModified: true };
111
+ const content = decodeText(await fs.readFile(real));
112
+ if (content == null)
113
+ return { error: 'not text', status: 415 };
114
+ return { name: basename(real), type, content, mtimeMs };
115
+ }
116
+ async function listDir(rawPath) {
117
+ const rh = await realHomeP;
118
+ const roots = await rootsP;
119
+ let real;
120
+ try {
121
+ real = await fs.realpath(rawPath ? rawPath : home);
122
+ }
123
+ catch {
124
+ return { error: 'not found', status: 404 };
125
+ }
126
+ if (!rootOf(real, roots))
127
+ return { error: 'outside home', status: 400 };
128
+ const st = await fs.stat(real);
129
+ if (!st.isDirectory())
130
+ return { error: 'not a directory', status: 400 };
131
+ const dirents = await fs.readdir(real, { withFileTypes: true });
132
+ // withFileTypes uses lstat semantics: d.isFile() is false for symlinks, so symlinks are
133
+ // intentionally NOT listed (security property). Every regular file is listed now (not just
134
+ // docs); the extension decides whether it opens in-app as a doc ('doc'), an image viewer
135
+ // ('image'), or can only be downloaded ('file').
136
+ const dirEntries = [];
137
+ const fileDirents = [];
138
+ for (const d of dirents) {
139
+ if (d.isDirectory())
140
+ dirEntries.push({ name: d.name, type: 'dir' });
141
+ else if (d.isFile())
142
+ fileDirents.push(d);
143
+ }
144
+ // stat every file IN PARALLEL — a big dir is thousands of files, and one awaited stat each
145
+ // (serial) is what made listing hang for seconds. Promise.all lets the OS pipeline them.
146
+ const fileEntries = await Promise.all(fileDirents.map(async (d) => {
147
+ const type = docTypeFor(d.name) ? 'doc' : imageTypeFor(d.name) ? 'image' : 'file';
148
+ let size = 0;
149
+ try {
150
+ size = (await fs.stat(join(real, d.name))).size;
151
+ }
152
+ catch { /* gone mid-list → size 0 */ }
153
+ return { name: d.name, type, size };
154
+ }));
155
+ const entries = [...dirEntries, ...fileEntries];
156
+ // dirs first; files (doc+file) interleaved alphabetically
157
+ entries.sort((a, b) => (a.type === 'dir' ? 0 : 1) - (b.type === 'dir' ? 0 : 1) || a.name.localeCompare(b.name));
158
+ // "up" stops at whichever allowed root we're in (each root is a ceiling), not only at home.
159
+ const parent = roots.includes(real) ? null : join(real, '..');
160
+ return { path: real, home: rh, roots, parent, entries };
161
+ }
162
+ // Resolve a path for DOWNLOAD: any regular file under home, no extension white-list (unlike
163
+ // readDoc). Same realpath+isUnder boundary; symlinks escaping home resolve outside → rejected.
164
+ async function statForDownload(rawPath) {
165
+ if (typeof rawPath !== 'string' || rawPath[0] !== '/')
166
+ return { error: 'not absolute', status: 400 };
167
+ let real;
168
+ try {
169
+ real = await fs.realpath(rawPath);
170
+ }
171
+ catch {
172
+ return { error: 'not found', status: 404 };
173
+ }
174
+ if (!rootOf(real, await rootsP))
175
+ return { error: 'outside home', status: 400 };
176
+ let st;
177
+ try {
178
+ st = await fs.stat(real);
179
+ }
180
+ catch {
181
+ return { error: 'not accessible', status: 404 };
182
+ }
183
+ if (!st.isFile())
184
+ return { error: 'not a file', status: 400 };
185
+ if (st.size > maxDownloadBytes)
186
+ return { error: 'too large', status: 413 };
187
+ return { real, name: basename(real), size: st.size, mtimeMs: st.mtimeMs };
188
+ }
189
+ // Resolve a path for UPLOAD TARGET: a directory under one of the allowed roots, not inside any
190
+ // hidden directory (relative to its own root). The $HOME root itself is off-limits (don't litter
191
+ // the home dir); an extra root like /tmp IS uploadable directly, since dropping files there is the
192
+ // whole point. Same realpath boundary as the rest.
193
+ async function resolveUploadDir(rawDir) {
194
+ if (typeof rawDir !== 'string' || rawDir[0] !== '/')
195
+ return { error: 'not absolute', status: 400 };
196
+ const rh = await realHomeP;
197
+ let real;
198
+ try {
199
+ real = await fs.realpath(rawDir);
200
+ }
201
+ catch {
202
+ return { error: 'not found', status: 404 };
203
+ }
204
+ const root = rootOf(real, await rootsP);
205
+ if (!root)
206
+ return { error: 'outside home', status: 400 };
207
+ if (real === rh)
208
+ return { error: 'home root not allowed', status: 400 };
209
+ if (hasHiddenSegment(real, root))
210
+ return { error: 'hidden directory not allowed', status: 400 };
211
+ let st;
212
+ try {
213
+ st = await fs.stat(real);
214
+ }
215
+ catch {
216
+ return { error: 'not accessible', status: 404 };
217
+ }
218
+ if (!st.isDirectory())
219
+ return { error: 'not a directory', status: 400 };
220
+ return { real };
221
+ }
222
+ // Resolve the STASH upload target: a per-project folder under ~/.handmux/uploads, created on
223
+ // demand. Deliberately OUTSIDE the user's project trees (so uploads can never be `git add`-ed by
224
+ // accident) yet still under $HOME, so the absolute path the chat box pastes stays readable by an
225
+ // agent running anywhere. Mirrors Claude Code's per-project layout: the caller's cwd is flattened
226
+ // ('/'→'-') into a single path segment (see encodeCwdKey), so each working directory gets its own
227
+ // space; an unknown cwd falls into `_default`. Returns the realpath'd dir.
228
+ async function resolveStashDir(rawCwd) {
229
+ const rh = await realHomeP;
230
+ const target = join(rh, '.handmux', 'uploads', encodeCwdKey(rawCwd));
231
+ try {
232
+ await fs.mkdir(target, { recursive: true });
233
+ }
234
+ catch {
235
+ return { error: 'mkdir failed', status: 500 };
236
+ }
237
+ let real;
238
+ try {
239
+ real = await fs.realpath(target);
240
+ } // re-resolve in case a segment is a symlink
241
+ catch {
242
+ return { error: 'not accessible', status: 404 };
243
+ }
244
+ if (!isUnder(real, rh))
245
+ return { error: 'outside home', status: 400 };
246
+ return { real };
247
+ }
248
+ // Resolve a path for use as a NEW session/window CWD: any directory under home (home root IS
249
+ // allowed, and hidden dirs ARE allowed — the browser can navigate into them). Same realpath +
250
+ // isUnder boundary as the rest; symlinks escaping home resolve outside → rejected.
251
+ async function resolveCwd(rawDir) {
252
+ if (typeof rawDir !== 'string' || rawDir[0] !== '/')
253
+ return { error: 'not absolute', status: 400 };
254
+ const rh = await realHomeP;
255
+ let real;
256
+ try {
257
+ real = await fs.realpath(rawDir);
258
+ }
259
+ catch {
260
+ return { error: 'not found', status: 404 };
261
+ }
262
+ if (!isUnder(real, rh))
263
+ return { error: 'outside home', status: 400 };
264
+ let st;
265
+ try {
266
+ st = await fs.stat(real);
267
+ }
268
+ catch {
269
+ return { error: 'not accessible', status: 404 };
270
+ }
271
+ if (!st.isDirectory())
272
+ return { error: 'not a directory', status: 400 };
273
+ return { real };
274
+ }
275
+ // Resolve a directory for BROWSE-side ops (the mkdir target): a directory under ANY allowed root
276
+ // (the root itself + hidden dirs allowed, like resolveCwd but multi-root). resolveCwd stays
277
+ // home-only — a new session's cwd should never land in a temp root by accident.
278
+ async function resolveBrowseDir(rawDir) {
279
+ if (typeof rawDir !== 'string' || rawDir[0] !== '/')
280
+ return { error: 'not absolute', status: 400 };
281
+ let real;
282
+ try {
283
+ real = await fs.realpath(rawDir);
284
+ }
285
+ catch {
286
+ return { error: 'not found', status: 404 };
287
+ }
288
+ if (!rootOf(real, await rootsP))
289
+ return { error: 'outside home', status: 400 };
290
+ let st;
291
+ try {
292
+ st = await fs.stat(real);
293
+ }
294
+ catch {
295
+ return { error: 'not accessible', status: 404 };
296
+ }
297
+ if (!st.isDirectory())
298
+ return { error: 'not a directory', status: 400 };
299
+ return { real };
300
+ }
301
+ // Create a new directory `name` inside `parentRaw`. The parent must be a directory under one of
302
+ // the allowed roots (root + hidden dirs allowed). `name` must be a single safe path segment.
303
+ async function makeDir(parentRaw, name) {
304
+ const nm = typeof name === 'string' ? name.trim() : '';
305
+ if (!nm || nm === '.' || nm === '..' || nm.includes('/') || nm.includes('\\') || nm.includes('\0')) {
306
+ return { error: 'bad name', status: 400 };
307
+ }
308
+ const parent = await resolveBrowseDir(parentRaw); // realpath + under-a-root + isDirectory
309
+ if ('error' in parent)
310
+ return parent;
311
+ const target = join(parent.real, nm);
312
+ try {
313
+ await fs.mkdir(target);
314
+ }
315
+ catch (e) {
316
+ if (errorCode(e) === 'EEXIST')
317
+ return { error: 'exists', status: 409 };
318
+ return { error: 'mkdir failed', status: 500 };
319
+ }
320
+ return { real: target };
321
+ }
322
+ return { readDoc, listDir, statForDownload, resolveUploadDir, resolveStashDir, resolveCwd, makeDir };
323
+ }
324
+ // The extra (outside-$HOME) roots the file browser may reach: the system temp dir and, if set, the
325
+ // per-user $TMPDIR (on macOS that's /var/folders/.../T). Missing ones are skipped at resolve time.
326
+ function errorCode(error) {
327
+ if (typeof error !== 'object' || error === null || !('code' in error))
328
+ return undefined;
329
+ return typeof error.code === 'string' ? error.code : undefined;
330
+ }
331
+ export function defaultExtraRoots(env = process.env) {
332
+ const roots = ['/tmp'];
333
+ if (env.TMPDIR)
334
+ roots.push(env.TMPDIR);
335
+ return roots;
336
+ }
337
+ export const defaultDocs = createDocs({ home: homedir(), extraRoots: defaultExtraRoots() });