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
package/src/docs.js DELETED
@@ -1,228 +0,0 @@
1
- import { promises as fs } from 'node:fs';
2
- import { homedir } from 'node:os';
3
- import { join, basename } from 'node:path';
4
- import { docTypeFor, imageTypeFor, isUnder, hasHiddenSegment } from './docPath.js';
5
-
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
-
11
- // Flatten an absolute cwd into ONE filesystem-safe path segment for the per-project upload space,
12
- // Claude-Code style: '/' → '-' (so /Users/x/proj → -Users-x-proj), and any other non-portable char
13
- // → '_'. A valid cwd always starts with '/', so the key always starts with '-' — it can never be
14
- // '..'/'.' and, having no separators, can never escape the uploads root. Empty/relative → '_default'.
15
- export function encodeCwdKey(cwd) {
16
- if (typeof cwd !== 'string' || cwd[0] !== '/') return '_default';
17
- return cwd.replace(/\//g, '-').replace(/[^A-Za-z0-9._-]/g, '_') || '_default';
18
- }
19
-
20
- // Factory bound to a `home` root. All reads pass through fs.realpath then isUnder(realHome):
21
- // realpath collapses ../ and resolves symlinks, so the home-containment check is the final word —
22
- // a symlink whose target escapes home resolves outside and gets rejected.
23
- export function createDocs({ home, extraRoots = [], maxDownloadBytes = MAX_TRANSFER_BYTES } = {}) {
24
- // $HOME is constant at runtime — resolve once at construction, reuse the same promise everywhere.
25
- const realHomeP = fs.realpath(home);
26
- // Browse / read / download / upload may also reach a few EXTRA roots OUTSIDE $HOME (e.g. /tmp,
27
- // $TMPDIR) so transient files an agent drops there are reachable from the phone. Resolved once:
28
- // realpath'd, deduped, missing ones skipped, and any extra already inside home dropped (home
29
- // covers it). `home` is always roots[0]. Session-cwd (resolveCwd) and the upload stash stay
30
- // home-only on purpose. The "under one of these roots" check replaces the old single isUnder(home).
31
- const rootsP = (async () => {
32
- const rh = await realHomeP;
33
- const out = [rh];
34
- for (const r of extraRoots) {
35
- if (typeof r !== 'string' || !r) continue;
36
- let real;
37
- try { real = await fs.realpath(r); } catch { continue; } // not present on this host → skip
38
- if (isUnder(real, rh) || out.includes(real)) continue; // already covered by home / dup
39
- out.push(real);
40
- }
41
- return out;
42
- })();
43
- // The allowed root that contains `real` (longest match wins should roots ever nest), or null.
44
- const rootOf = (real, roots) => {
45
- let best = null;
46
- for (const r of roots) if (isUnder(real, r) && (!best || r.length > best.length)) best = r;
47
- return best;
48
- };
49
-
50
- // `knownMtime` (ms) makes this a conditional read: if the file's mtime is unchanged the body is
51
- // skipped and `{ notModified: true }` comes back (no readFile), so re-visiting an unchanged doc on
52
- // the phone neither transfers content nor forces a re-render. `mtimeMs` is always returned so the
53
- // client can store it for the next check.
54
- async function readDoc(rawPath, knownMtime = null) {
55
- if (typeof rawPath !== 'string' || rawPath[0] !== '/') return { error: 'not absolute', status: 400 };
56
- const type = docTypeFor(rawPath);
57
- if (!type) return { error: 'bad extension', status: 400 };
58
- let real;
59
- try { real = await fs.realpath(rawPath); }
60
- catch { return { error: 'not found', status: 404 }; }
61
- if (!rootOf(real, await rootsP)) return { error: 'outside home', status: 400 };
62
- let st;
63
- try { st = await fs.stat(real); }
64
- catch { return { error: 'not accessible', status: 404 }; }
65
- if (!st.isFile()) return { error: 'not a file', status: 400 };
66
- if (st.size > MAX_READ_BYTES) return { error: 'too large', status: 413 };
67
- const mtimeMs = st.mtimeMs;
68
- if (knownMtime != null && mtimeMs === knownMtime) return { name: basename(real), type, mtimeMs, notModified: true };
69
- const content = await fs.readFile(real, 'utf8');
70
- return { name: basename(real), type, content, mtimeMs };
71
- }
72
-
73
- async function listDir(rawPath) {
74
- const rh = await realHomeP;
75
- const roots = await rootsP;
76
- let real;
77
- try { real = await fs.realpath(rawPath ? rawPath : home); }
78
- catch { return { error: 'not found', status: 404 }; }
79
- if (!rootOf(real, roots)) return { error: 'outside home', status: 400 };
80
- const st = await fs.stat(real);
81
- if (!st.isDirectory()) return { error: 'not a directory', status: 400 };
82
- const dirents = await fs.readdir(real, { withFileTypes: true });
83
- // withFileTypes uses lstat semantics: d.isFile() is false for symlinks, so symlinks are
84
- // intentionally NOT listed (security property). Every regular file is listed now (not just
85
- // docs); the extension decides whether it opens in-app as a doc ('doc'), an image viewer
86
- // ('image'), or can only be downloaded ('file').
87
- const dirEntries = [];
88
- const fileDirents = [];
89
- for (const d of dirents) {
90
- if (d.isDirectory()) dirEntries.push({ name: d.name, type: 'dir' });
91
- else if (d.isFile()) fileDirents.push(d);
92
- }
93
- // stat every file IN PARALLEL — a big dir is thousands of files, and one awaited stat each
94
- // (serial) is what made listing hang for seconds. Promise.all lets the OS pipeline them.
95
- const fileEntries = await Promise.all(fileDirents.map(async (d) => {
96
- const type = docTypeFor(d.name) ? 'doc' : imageTypeFor(d.name) ? 'image' : 'file';
97
- let size = 0;
98
- try { size = (await fs.stat(join(real, d.name))).size; } catch { /* gone mid-list → size 0 */ }
99
- return { name: d.name, type, size };
100
- }));
101
- const entries = [...dirEntries, ...fileEntries];
102
- // dirs first; files (doc+file) interleaved alphabetically
103
- entries.sort((a, b) =>
104
- (a.type === 'dir' ? 0 : 1) - (b.type === 'dir' ? 0 : 1) || a.name.localeCompare(b.name));
105
- // "up" stops at whichever allowed root we're in (each root is a ceiling), not only at home.
106
- const parent = roots.includes(real) ? null : join(real, '..');
107
- return { path: real, home: rh, roots, parent, entries };
108
- }
109
-
110
- // Resolve a path for DOWNLOAD: any regular file under home, no extension white-list (unlike
111
- // readDoc). Same realpath+isUnder boundary; symlinks escaping home resolve outside → rejected.
112
- async function statForDownload(rawPath) {
113
- if (typeof rawPath !== 'string' || rawPath[0] !== '/') return { error: 'not absolute', status: 400 };
114
- let real;
115
- try { real = await fs.realpath(rawPath); }
116
- catch { return { error: 'not found', status: 404 }; }
117
- if (!rootOf(real, await rootsP)) return { error: 'outside home', status: 400 };
118
- let st;
119
- try { st = await fs.stat(real); }
120
- catch { return { error: 'not accessible', status: 404 }; }
121
- if (!st.isFile()) return { error: 'not a file', status: 400 };
122
- if (st.size > maxDownloadBytes) return { error: 'too large', status: 413 };
123
- return { real, name: basename(real), size: st.size, mtimeMs: st.mtimeMs };
124
- }
125
-
126
- // Resolve a path for UPLOAD TARGET: a directory under one of the allowed roots, not inside any
127
- // hidden directory (relative to its own root). The $HOME root itself is off-limits (don't litter
128
- // the home dir); an extra root like /tmp IS uploadable directly, since dropping files there is the
129
- // whole point. Same realpath boundary as the rest.
130
- async function resolveUploadDir(rawDir) {
131
- if (typeof rawDir !== 'string' || rawDir[0] !== '/') return { error: 'not absolute', status: 400 };
132
- const rh = await realHomeP;
133
- let real;
134
- try { real = await fs.realpath(rawDir); }
135
- catch { return { error: 'not found', status: 404 }; }
136
- const root = rootOf(real, await rootsP);
137
- if (!root) return { error: 'outside home', status: 400 };
138
- if (real === rh) return { error: 'home root not allowed', status: 400 };
139
- if (hasHiddenSegment(real, root)) return { error: 'hidden directory not allowed', status: 400 };
140
- let st;
141
- try { st = await fs.stat(real); }
142
- catch { return { error: 'not accessible', status: 404 }; }
143
- if (!st.isDirectory()) return { error: 'not a directory', status: 400 };
144
- return { real };
145
- }
146
-
147
- // Resolve the STASH upload target: a per-project folder under ~/.handmux/uploads, created on
148
- // demand. Deliberately OUTSIDE the user's project trees (so uploads can never be `git add`-ed by
149
- // accident) yet still under $HOME, so the absolute path the chat box pastes stays readable by an
150
- // agent running anywhere. Mirrors Claude Code's per-project layout: the caller's cwd is flattened
151
- // ('/'→'-') into a single path segment (see encodeCwdKey), so each working directory gets its own
152
- // space; an unknown cwd falls into `_default`. Returns the realpath'd dir.
153
- async function resolveStashDir(rawCwd) {
154
- const rh = await realHomeP;
155
- const target = join(rh, '.handmux', 'uploads', encodeCwdKey(rawCwd));
156
- try { await fs.mkdir(target, { recursive: true }); }
157
- catch { return { error: 'mkdir failed', status: 500 }; }
158
- let real;
159
- try { real = await fs.realpath(target); } // re-resolve in case a segment is a symlink
160
- catch { return { error: 'not accessible', status: 404 }; }
161
- if (!isUnder(real, rh)) return { error: 'outside home', status: 400 };
162
- return { real };
163
- }
164
-
165
- // Resolve a path for use as a NEW session/window CWD: any directory under home (home root IS
166
- // allowed, and hidden dirs ARE allowed — the browser can navigate into them). Same realpath +
167
- // isUnder boundary as the rest; symlinks escaping home resolve outside → rejected.
168
- async function resolveCwd(rawDir) {
169
- if (typeof rawDir !== 'string' || rawDir[0] !== '/') return { error: 'not absolute', status: 400 };
170
- const rh = await realHomeP;
171
- let real;
172
- try { real = await fs.realpath(rawDir); }
173
- catch { return { error: 'not found', status: 404 }; }
174
- if (!isUnder(real, rh)) return { error: 'outside home', status: 400 };
175
- let st;
176
- try { st = await fs.stat(real); }
177
- catch { return { error: 'not accessible', status: 404 }; }
178
- if (!st.isDirectory()) return { error: 'not a directory', status: 400 };
179
- return { real };
180
- }
181
-
182
- // Resolve a directory for BROWSE-side ops (the mkdir target): a directory under ANY allowed root
183
- // (the root itself + hidden dirs allowed, like resolveCwd but multi-root). resolveCwd stays
184
- // home-only — a new session's cwd should never land in a temp root by accident.
185
- async function resolveBrowseDir(rawDir) {
186
- if (typeof rawDir !== 'string' || rawDir[0] !== '/') return { error: 'not absolute', status: 400 };
187
- let real;
188
- try { real = await fs.realpath(rawDir); }
189
- catch { return { error: 'not found', status: 404 }; }
190
- if (!rootOf(real, await rootsP)) return { error: 'outside home', status: 400 };
191
- let st;
192
- try { st = await fs.stat(real); }
193
- catch { return { error: 'not accessible', status: 404 }; }
194
- if (!st.isDirectory()) return { error: 'not a directory', status: 400 };
195
- return { real };
196
- }
197
-
198
- // Create a new directory `name` inside `parentRaw`. The parent must be a directory under one of
199
- // the allowed roots (root + hidden dirs allowed). `name` must be a single safe path segment.
200
- async function makeDir(parentRaw, name) {
201
- const nm = typeof name === 'string' ? name.trim() : '';
202
- if (!nm || nm === '.' || nm === '..' || nm.includes('/') || nm.includes('\\') || nm.includes('\0')) {
203
- return { error: 'bad name', status: 400 };
204
- }
205
- const parent = await resolveBrowseDir(parentRaw); // realpath + under-a-root + isDirectory
206
- if (parent.error) return parent;
207
- const target = join(parent.real, nm);
208
- try {
209
- await fs.mkdir(target);
210
- } catch (e) {
211
- if (e.code === 'EEXIST') return { error: 'exists', status: 409 };
212
- return { error: 'mkdir failed', status: 500 };
213
- }
214
- return { real: target };
215
- }
216
-
217
- return { readDoc, listDir, statForDownload, resolveUploadDir, resolveStashDir, resolveCwd, makeDir };
218
- }
219
-
220
- // The extra (outside-$HOME) roots the file browser may reach: the system temp dir and, if set, the
221
- // per-user $TMPDIR (on macOS that's /var/folders/.../T). Missing ones are skipped at resolve time.
222
- export function defaultExtraRoots(env = process.env) {
223
- const roots = ['/tmp'];
224
- if (env.TMPDIR) roots.push(env.TMPDIR);
225
- return roots;
226
- }
227
-
228
- export const defaultDocs = createDocs({ home: homedir(), extraRoots: defaultExtraRoots() });
package/src/git.js DELETED
@@ -1,208 +0,0 @@
1
- import { promises as fs } from 'node:fs';
2
- import { homedir } from 'node:os';
3
- import { join, basename, isAbsolute } from 'node:path';
4
- import { execFile } from 'node:child_process';
5
- import { isUnder } from './docPath.js';
6
- import { defaultExtraRoots } from './docs.js';
7
-
8
- // 只读子命令白名单:命令层硬过滤,杜绝任何写操作混入。
9
- const READONLY = new Set(['rev-parse', 'status', 'log', 'for-each-ref', 'diff', 'show', 'diff-tree']);
10
- const MAX_BUFFER = 8 * 1024 * 1024;
11
-
12
- export function createGit({ home, extraRoots = [] } = {}) {
13
- const realHomeP = fs.realpath(home);
14
- // Same multi-root allow-list as createDocs: $HOME plus a few roots OUTSIDE it (/tmp, $TMPDIR) so a repo
15
- // an agent is working in under /tmp is reachable from the phone. Resolved once — realpath'd, deduped,
16
- // missing ones skipped, extras already inside home dropped (home covers them). Keeps git browsing in
17
- // lock-step with the file/doc browser; git.js used to be home-only, which rejected legit repos under
18
- // /tmp with a red "outside home".
19
- const rootsP = (async () => {
20
- const rh = await realHomeP;
21
- const out = [rh];
22
- for (const r of extraRoots) {
23
- if (typeof r !== 'string' || !r) continue;
24
- let real;
25
- try { real = await fs.realpath(r); } catch { continue; } // not present on this host → skip
26
- if (isUnder(real, rh) || out.includes(real)) continue; // already covered by home / dup
27
- out.push(real);
28
- }
29
- return out;
30
- })();
31
- // The allowed root that contains `real` (longest match wins should roots ever nest), or null.
32
- const rootOf = (real, roots) => {
33
- let best = null;
34
- for (const r of roots) if (isUnder(real, r) && (!best || r.length > best.length)) best = r;
35
- return best;
36
- };
37
-
38
- function git(cwd, args) {
39
- const sub = args[0];
40
- if (!READONLY.has(sub)) return Promise.reject(new Error(`blocked subcommand: ${sub}`));
41
- return new Promise((resolve, reject) => {
42
- execFile('git', ['-C', cwd, '-c', 'core.quotepath=false', ...args], { maxBuffer: MAX_BUFFER }, (err, stdout) => {
43
- if (err) reject(err); else resolve(stdout);
44
- });
45
- });
46
- }
47
-
48
- async function resolveRepo(rawPath) {
49
- if (typeof rawPath !== 'string' || !isAbsolute(rawPath)) return { error: 'not absolute', status: 400 };
50
- let real;
51
- try { real = await fs.realpath(rawPath); } catch { return { error: 'not found', status: 404 }; }
52
- if (!rootOf(real, await rootsP)) return { error: 'outside home', status: 400 };
53
- return { real };
54
- }
55
-
56
- async function isRepo(dir) {
57
- try { return (await git(dir, ['rev-parse', '--is-inside-work-tree'])).trim() === 'true'; }
58
- catch { return false; }
59
- }
60
-
61
- // `realDir` is used for git operations; `displayPath` is what we expose (the caller's original path,
62
- // avoiding macOS /private/var vs /var confusion when the caller passed a non-realpath'd path).
63
- async function repoMeta(realDir, displayPath) {
64
- const p = displayPath ?? realDir;
65
- let branch = 'HEAD';
66
- try { branch = (await git(realDir, ['rev-parse', '--abbrev-ref', 'HEAD'])).trim(); } catch { /* empty repo */ }
67
- let dirty = false;
68
- try { dirty = (await git(realDir, ['status', '--porcelain'])).trim().length > 0; } catch { /* ignore */ }
69
- return { name: basename(p), path: p, branch, dirty };
70
- }
71
-
72
- async function detectRepos(rawDir) {
73
- const r = await resolveRepo(rawDir);
74
- if (r.error) return r;
75
- if (await isRepo(r.real)) return { repos: [await repoMeta(r.real, rawDir)] };
76
- const repos = [];
77
- let dirents = [];
78
- try { dirents = await fs.readdir(r.real, { withFileTypes: true }); } catch { /* ignore */ }
79
- for (const d of dirents) {
80
- if (!d.isDirectory()) continue;
81
- const child = join(r.real, d.name);
82
- const childDisplay = join(rawDir, d.name);
83
- if (await isRepo(child)) repos.push(await repoMeta(child, childDisplay));
84
- }
85
- repos.sort((a, b) => a.name.localeCompare(b.name));
86
- return { repos };
87
- }
88
-
89
- async function status(rawRepo) {
90
- const r = await resolveRepo(rawRepo);
91
- if (r.error) return r;
92
- if (!(await isRepo(r.real))) return { error: 'not a repo', status: 400 };
93
- let out;
94
- try { out = await git(r.real, ['status', '--porcelain']); }
95
- catch { return { error: 'git error', status: 500 }; }
96
- const changes = out.split('\n').filter(Boolean).map((line) => {
97
- const x = line[0], y = line[1];
98
- let path = line.slice(3);
99
- const arrow = path.indexOf(' -> ');
100
- if (arrow >= 0) path = path.slice(arrow + 4);
101
- return { x: x === ' ' ? '' : x, y: y === ' ' ? '' : y, path };
102
- });
103
- return { changes };
104
- }
105
-
106
- const SEP = '\x1f';
107
-
108
- // 分支 / ref 名校验:挡选项注入(开头 '-')、路径穿越('..')、NUL,并限定到安全字符子集。
109
- // 只读 git log 用,失败时 git 自己会再拒一次。
110
- function safeRef(ref) {
111
- if (typeof ref !== 'string' || !ref) return null;
112
- if (ref[0] === '-' || ref.includes('..') || ref.includes('\0')) return null;
113
- if (!/^[A-Za-z0-9._/-]+$/.test(ref)) return null;
114
- return ref;
115
- }
116
-
117
- async function log(rawRepo, limit = 50, ref) {
118
- const r = await resolveRepo(rawRepo);
119
- if (r.error) return r;
120
- if (!(await isRepo(r.real))) return { error: 'not a repo', status: 400 };
121
- const n = Math.max(1, Math.min(500, Number(limit) || 50));
122
- const safeR = ref == null || ref === '' ? null : safeRef(ref);
123
- if (ref && !safeR) return { error: 'bad ref', status: 400 };
124
- const args = ['log', `-n${n}`, `--pretty=format:%H${SEP}%h${SEP}%s${SEP}%an${SEP}%ar`];
125
- if (safeR) args.push(safeR); // git log <ref> …(指定分支只读看历史,不动工作树)
126
- let out = '';
127
- try { out = await git(r.real, args); }
128
- catch { return { commits: [] }; } // 空仓库(无提交)/ 无此 ref → 空列表
129
- const commits = out.split('\n').filter(Boolean).map((line) => {
130
- const [hash, short, subject, author, relDate] = line.split(SEP);
131
- return { hash, short, subject, author, relDate };
132
- });
133
- return { commits };
134
- }
135
-
136
- async function branches(rawRepo) {
137
- const r = await resolveRepo(rawRepo);
138
- if (r.error) return r;
139
- if (!(await isRepo(r.real))) return { error: 'not a repo', status: 400 };
140
- const fmt = ['%(refname:short)', '%(HEAD)', '%(upstream:short)', '%(upstream:track)'].join(SEP);
141
- let out;
142
- try { out = await git(r.real, ['for-each-ref', `--format=${fmt}`, 'refs/heads']); }
143
- catch { return { branches: [] }; }
144
- const branches = out.split('\n').filter(Boolean).map((line) => {
145
- const [name, head, upstream, track] = line.split(SEP);
146
- const ahead = Number((track.match(/ahead (\d+)/) || [])[1] || 0);
147
- const behind = Number((track.match(/behind (\d+)/) || [])[1] || 0);
148
- return { name, current: head === '*', upstream: upstream || null, ahead, behind };
149
- });
150
- return { branches };
151
- }
152
-
153
- // 文件路径校验:相对、非绝对、不以 '-' 开头(防选项注入)、无 '..' 段、无 NUL。
154
- function safeRelPath(p) {
155
- if (typeof p !== 'string' || !p || p[0] === '-' || isAbsolute(p)) return null;
156
- if (p.includes('\0') || p.split('/').some((seg) => seg === '..')) return null;
157
- return p;
158
- }
159
-
160
- const MAX_DIFF_BYTES = 512 * 1024;
161
- function cap(text) {
162
- if (text.length <= MAX_DIFF_BYTES) return { diff: text, truncated: false };
163
- return { diff: text.slice(0, MAX_DIFF_BYTES), truncated: true };
164
- }
165
-
166
- // diff 语义:仅 path → 工作区 vs HEAD;staged → 暂存区 vs HEAD;commit → 该提交 vs 其父。
167
- async function diff(rawRepo, { path, commit, staged } = {}) {
168
- const r = await resolveRepo(rawRepo);
169
- if (r.error) return r;
170
- if (!(await isRepo(r.real))) return { error: 'not a repo', status: 400 };
171
- const rel = safeRelPath(path);
172
- if (!rel) return { error: 'bad path', status: 400 };
173
- let args;
174
- if (commit) {
175
- if (!/^[0-9a-fA-F]{4,40}$/.test(commit)) return { error: 'bad commit', status: 400 };
176
- args = ['show', '--format=', commit, '--', rel];
177
- } else if (staged) {
178
- args = ['diff', '--staged', '--', rel];
179
- } else {
180
- args = ['diff', 'HEAD', '--', rel];
181
- }
182
- let out = '';
183
- try { out = await git(r.real, args); } catch (e) { return { error: 'diff failed', status: 500 }; }
184
- return cap(out);
185
- }
186
-
187
- async function commit(rawRepo, hash) {
188
- const r = await resolveRepo(rawRepo);
189
- if (r.error) return r;
190
- if (!(await isRepo(r.real))) return { error: 'not a repo', status: 400 };
191
- if (!/^[0-9a-fA-F]{4,40}$/.test(hash)) return { error: 'bad commit', status: 400 };
192
- let message, ns;
193
- try {
194
- message = (await git(r.real, ['show', '-s', '--format=%B', hash])).trim();
195
- // --root 让首次提交(无父)也能列出文件。
196
- ns = await git(r.real, ['diff-tree', '--no-commit-id', '--name-status', '-r', '--root', hash]);
197
- } catch { return { error: 'git error', status: 500 }; }
198
- const files = ns.split('\n').filter(Boolean).map((line) => {
199
- const [code, ...rest] = line.split('\t');
200
- return { x: code[0], y: '', path: rest[rest.length - 1] };
201
- });
202
- return { message, files };
203
- }
204
-
205
- return { resolveRepo, isRepo, detectRepos, status, log, branches, diff, commit };
206
- }
207
-
208
- export const defaultGit = createGit({ home: homedir(), extraRoots: defaultExtraRoots() });
package/src/httpApi.js DELETED
@@ -1,72 +0,0 @@
1
- // API composition root. Applies auth + JSON parsing, then mounts the per-domain route modules under
2
- // /api. Each module (routes/*.js) owns one domain and receives only the deps it needs; the closure deps
3
- // (commands, docs, git, previews, token, home, …) pass straight through. Behaviour is unchanged from when
4
- // every route lived here — this file is just the wiring.
5
- import express from 'express';
6
- import { expressAuth } from './auth.js';
7
- import * as defaultCommands from './tmux/commands.js';
8
- import { defaultDocs, MAX_TRANSFER_BYTES } from './docs.js';
9
- import { defaultGit } from './git.js';
10
- import * as push from './push.js';
11
- import * as notifications from './notifications.js';
12
- import { createClaudeEvents } from './claudeEvents.js';
13
- import { homedir } from 'node:os';
14
- import { DEFAULT_UPLOAD_EXTS } from './uploadTypes.js';
15
- import { claudeStatePath } from './cli/state.js';
16
- import { sessionRoutes } from './routes/sessions.js';
17
- import { terminalRoutes } from './routes/terminal.js';
18
- import { gitRoutes } from './routes/git.js';
19
- import { fileRoutes } from './routes/files.js';
20
- import { pushRoutes } from './routes/push.js';
21
- import { systemRoutes } from './routes/system.js';
22
- import { previewRoutes } from './routes/previews.js';
23
- import { notificationRoutes } from './routes/notifications.js';
24
- import { transcriptRoutes } from './routes/transcript.js';
25
- import { DEFAULT_SHORTCUTS } from './shortcutConfig.js';
26
- import { workspaceRoutes } from './routes/workspace.js';
27
- import { browserRoutes } from './browser/routes.js';
28
- import { codexRoutes } from './routes/codex.js';
29
-
30
- // Re-exported for tests (test/keys.test.js) and any caller that imported it by this path historically.
31
- export { isAllowedKey } from './routes/terminal.js';
32
-
33
- export function createApiRouter({
34
- token, commands = defaultCommands, docs = defaultDocs, git = defaultGit, events,
35
- uploadExts = DEFAULT_UPLOAD_EXTS, maxUploadBytes = MAX_TRANSFER_BYTES,
36
- asrEnv = process.env, previews,
37
- shortcuts = DEFAULT_SHORTCUTS,
38
- browser,
39
- browserBootstrap,
40
- previewDomain,
41
- workspace,
42
- codexApp,
43
- home = homedir(), stateFile = process.env.CLAUDE_STATE_FILE || claudeStatePath(homedir()),
44
- } = {}) {
45
- const r = express.Router();
46
- r.use(expressAuth(token));
47
- r.use(express.json());
48
- const claudeEvents = events || createClaudeEvents({ commands, push });
49
-
50
- const deps = {
51
- token, commands, docs, git, push, notifications, claudeEvents,
52
- uploadExts, maxUploadBytes, asrEnv, previews, shortcuts, home, stateFile, workspace, browser, browserBootstrap, previewDomain, codexApp,
53
- };
54
-
55
- r.use(sessionRoutes(deps));
56
- r.use(terminalRoutes(deps));
57
- r.use(gitRoutes(deps));
58
- r.use(fileRoutes(deps));
59
- r.use(pushRoutes(deps));
60
- r.use(notificationRoutes(deps));
61
- r.use(systemRoutes(deps));
62
- r.use(previewRoutes(deps));
63
- // In the main process browser APIs are forwarded to the isolated worker before this router. Keep
64
- // preview-domain wiring out of this unused fallback route so capability reporting does not require
65
- // the worker-owned bootstrap ticket store.
66
- r.use('/browser-proxy', browserRoutes(browser ? deps : { ...deps, previewDomain: null }));
67
- r.use(transcriptRoutes(deps));
68
- r.use(codexRoutes(deps));
69
- if (workspace) r.use(workspaceRoutes(deps));
70
-
71
- return r;
72
- }
package/src/jsonStore.js DELETED
@@ -1,24 +0,0 @@
1
- // Shared JSON-store persistence for the small single-writer runtime registries (push subs, previews).
2
- // Both hold their records IN MEMORY (loaded once at startup) and flush the whole array on each mutation —
3
- // there is only ever one server process, so the in-memory copy is the source of truth and a read never
4
- // touches disk. Writes are atomic (tmp + rename) so a concurrent reader (or a crash mid-write) can never
5
- // observe a half-written file. Persistence is best-effort: a lost flush just means the client re-subscribes
6
- // / the preview is re-registered next launch.
7
- import fs from 'node:fs';
8
- import path from 'node:path';
9
-
10
- // Read a JSON array from `file`, tolerating missing/corrupt/non-array content (→ []). Never throws.
11
- export function readJsonArray(file) {
12
- try { const v = JSON.parse(fs.readFileSync(file, 'utf8')); return Array.isArray(v) ? v : []; }
13
- catch { return []; }
14
- }
15
-
16
- // Atomic write: serialize `obj` to a sibling .tmp then rename over `file` (atomic on the same fs).
17
- export function writeJsonAtomic(file, obj) {
18
- try {
19
- fs.mkdirSync(path.dirname(file), { recursive: true });
20
- const tmp = `${file}.tmp`;
21
- fs.writeFileSync(tmp, JSON.stringify(obj));
22
- fs.renameSync(tmp, file);
23
- } catch { /* best effort — a lost flush is recoverable by the client re-registering */ }
24
- }
@@ -1,68 +0,0 @@
1
- // Per-device manual-push inbox: each subscribed device gets its own file `<NOTIF_DIR>/<pushKey>.json`, so a
2
- // `--device`/`--session`-scoped push only lands in the targeted devices' inboxes (delete/read are naturally
3
- // per-device too). NOTIF_DIR is injected by the CLI (~/.handmux/notifications) — NEVER the package-internal
4
- // default, which a global reinstall wipes. Low-frequency, so each op is a plain read-modify-write of one
5
- // device file (no in-memory state). The same push shares one record id across its target devices so a
6
- // notification tap's inboxId resolves on whichever device opens it.
7
- import path from 'node:path';
8
- import { fileURLToPath } from 'node:url';
9
- import crypto from 'node:crypto';
10
- import { readJsonArray, writeJsonAtomic } from './jsonStore.js';
11
- import { sanitizeNotificationUrl } from './urlPolicy.js';
12
-
13
- const here = path.dirname(fileURLToPath(import.meta.url));
14
- const DIR = process.env.NOTIF_DIR || path.resolve(here, '../data/notifications');
15
- const CAP = 100;
16
- const genId = () => crypto.randomBytes(9).toString('base64url');
17
-
18
- // pushKey is base64url already; sanitize anyway so a hostile value can't escape DIR. Empty → null (skip).
19
- function fileFor(key) {
20
- const safe = String(key || '').replace(/[^A-Za-z0-9_-]/g, '');
21
- return safe ? path.join(DIR, `${safe}.json`) : null;
22
- }
23
- const load = (file) => readJsonArray(file).filter((n) => n && typeof n.title === 'string');
24
-
25
- export function record(pushKeys, { title, body, tag, url, delivery } = {}) {
26
- const rec = { id: genId(), ts: Date.now(), title: String(title ?? ''), body: String(body ?? '') };
27
- if (tag) rec.tag = String(tag);
28
- if (delivery?.status === 'pending') rec.delivery = { status: 'pending' };
29
- const safeUrl = sanitizeNotificationUrl(url);
30
- if (safeUrl) rec.url = safeUrl;
31
- for (const key of pushKeys || []) {
32
- const file = fileFor(key);
33
- if (!file) continue;
34
- const items = load(file);
35
- items.push(rec);
36
- writeJsonAtomic(file, items.length > CAP ? items.slice(items.length - CAP) : items);
37
- }
38
- return rec;
39
- }
40
-
41
- export function updateDelivery(pushKey, id, delivery) {
42
- const file = fileFor(pushKey);
43
- if (!file) return false;
44
- const items = load(file);
45
- const item = items.find((n) => n.id === id);
46
- if (!item) return false;
47
- const status = delivery?.status;
48
- if (!['pending', 'success', 'failed'].includes(status)) return false;
49
- item.delivery = { status };
50
- if (status === 'failed' && typeof delivery.reason === 'string') item.delivery.reason = delivery.reason;
51
- writeJsonAtomic(file, items);
52
- return true;
53
- }
54
-
55
- export function list(pushKey) {
56
- const file = fileFor(pushKey);
57
- return file ? load(file).reverse() : [];
58
- }
59
-
60
- export function remove(pushKey, id) {
61
- const file = fileFor(pushKey);
62
- if (!file) return false;
63
- const items = load(file);
64
- const kept = items.filter((n) => n.id !== id);
65
- if (kept.length === items.length) return false;
66
- writeJsonAtomic(file, kept);
67
- return true;
68
- }