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,284 @@
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
+ const READONLY = new Set(['rev-parse', 'status', 'log', 'for-each-ref', 'diff', 'show', 'diff-tree']);
9
+ const MAX_BUFFER = 8 * 1024 * 1024;
10
+ export function createGit({ home = homedir(), extraRoots = [] } = {}) {
11
+ const realHomeP = fs.realpath(home);
12
+ // Same multi-root allow-list as createDocs: $HOME plus a few roots OUTSIDE it (/tmp, $TMPDIR) so a repo
13
+ // an agent is working in under /tmp is reachable from the phone. Resolved once — realpath'd, deduped,
14
+ // missing ones skipped, extras already inside home dropped (home covers them). Keeps git browsing in
15
+ // lock-step with the file/doc browser; git.js used to be home-only, which rejected legit repos under
16
+ // /tmp with a red "outside home".
17
+ const rootsP = (async () => {
18
+ const rh = await realHomeP;
19
+ const out = [rh];
20
+ for (const root of extraRoots) {
21
+ if (typeof root !== 'string' || !root)
22
+ continue;
23
+ let real;
24
+ try {
25
+ real = await fs.realpath(root);
26
+ }
27
+ catch {
28
+ continue;
29
+ } // not present on this host → skip
30
+ if (isUnder(real, rh) || out.includes(real))
31
+ continue; // already covered by home / dup
32
+ out.push(real);
33
+ }
34
+ return out;
35
+ })();
36
+ // The allowed root that contains `real` (longest match wins should roots ever nest), or null.
37
+ const rootOf = (real, roots) => {
38
+ let best = null;
39
+ for (const root of roots)
40
+ if (isUnder(real, root) && (!best || root.length > best.length))
41
+ best = root;
42
+ return best;
43
+ };
44
+ function git(cwd, args) {
45
+ const sub = args[0];
46
+ if (!sub || !READONLY.has(sub))
47
+ return Promise.reject(new Error(`blocked subcommand: ${sub}`));
48
+ return new Promise((resolve, reject) => {
49
+ execFile('git', ['-C', cwd, '-c', 'core.quotepath=false', ...args], { maxBuffer: MAX_BUFFER, encoding: 'utf8' }, (err, stdout) => {
50
+ if (err)
51
+ reject(err);
52
+ else
53
+ resolve(stdout);
54
+ });
55
+ });
56
+ }
57
+ async function resolveRepo(rawPath) {
58
+ if (typeof rawPath !== 'string' || !isAbsolute(rawPath))
59
+ return { error: 'not absolute', status: 400 };
60
+ let real;
61
+ try {
62
+ real = await fs.realpath(rawPath);
63
+ }
64
+ catch {
65
+ return { error: 'not found', status: 404 };
66
+ }
67
+ if (!rootOf(real, await rootsP))
68
+ return { error: 'outside home', status: 400 };
69
+ return { real };
70
+ }
71
+ async function isRepo(dir) {
72
+ try {
73
+ return (await git(dir, ['rev-parse', '--is-inside-work-tree'])).trim() === 'true';
74
+ }
75
+ catch {
76
+ return false;
77
+ }
78
+ }
79
+ async function worktree(rawDir) {
80
+ const r = await resolveRepo(rawDir);
81
+ if ('error' in r)
82
+ return r;
83
+ try {
84
+ const path = (await git(r.real, ['rev-parse', '--show-toplevel'])).trim();
85
+ return path ? { worktree: { path } } : { worktree: null };
86
+ }
87
+ catch {
88
+ return { worktree: null };
89
+ }
90
+ }
91
+ // `realDir` is used for git operations; `displayPath` is what we expose (the caller's original path,
92
+ // avoiding macOS /private/var vs /var confusion when the caller passed a non-realpath'd path).
93
+ async function repoMeta(realDir, displayPath) {
94
+ const p = displayPath ?? realDir;
95
+ let branch = 'HEAD';
96
+ try {
97
+ branch = (await git(realDir, ['rev-parse', '--abbrev-ref', 'HEAD'])).trim();
98
+ }
99
+ catch { /* empty repo */ }
100
+ let dirty = false;
101
+ try {
102
+ dirty = (await git(realDir, ['status', '--porcelain'])).trim().length > 0;
103
+ }
104
+ catch { /* ignore */ }
105
+ return { name: basename(p), path: p, branch, dirty };
106
+ }
107
+ async function detectRepos(rawDir) {
108
+ const r = await resolveRepo(rawDir);
109
+ if ('error' in r)
110
+ return r;
111
+ if (await isRepo(r.real))
112
+ return { repos: [await repoMeta(r.real, rawDir)] };
113
+ const repos = [];
114
+ let dirents = [];
115
+ try {
116
+ dirents = await fs.readdir(r.real, { withFileTypes: true });
117
+ }
118
+ catch { /* ignore */ }
119
+ for (const d of dirents) {
120
+ if (!d.isDirectory())
121
+ continue;
122
+ const child = join(r.real, d.name);
123
+ const childDisplay = join(rawDir, d.name);
124
+ if (await isRepo(child))
125
+ repos.push(await repoMeta(child, childDisplay));
126
+ }
127
+ repos.sort((a, b) => a.name.localeCompare(b.name));
128
+ return { repos };
129
+ }
130
+ async function status(rawRepo) {
131
+ const r = await resolveRepo(rawRepo);
132
+ if ('error' in r)
133
+ return r;
134
+ if (!(await isRepo(r.real)))
135
+ return { error: 'not a repo', status: 400 };
136
+ let out;
137
+ try {
138
+ out = await git(r.real, ['status', '--porcelain']);
139
+ }
140
+ catch {
141
+ return { error: 'git error', status: 500 };
142
+ }
143
+ const changes = out.split('\n').filter(Boolean).map((line) => {
144
+ const x = line[0] ?? '', y = line[1] ?? '';
145
+ let path = line.slice(3);
146
+ const arrow = path.indexOf(' -> ');
147
+ if (arrow >= 0)
148
+ path = path.slice(arrow + 4);
149
+ return { x: x === ' ' ? '' : x, y: y === ' ' ? '' : y, path };
150
+ });
151
+ return { changes };
152
+ }
153
+ const SEP = '\x1f';
154
+ // 分支 / ref 名校验:挡选项注入(开头 '-')、路径穿越('..')、NUL,并限定到安全字符子集。
155
+ // 只读 git log 用,失败时 git 自己会再拒一次。
156
+ function safeRef(ref) {
157
+ if (typeof ref !== 'string' || !ref)
158
+ return null;
159
+ if (ref[0] === '-' || ref.includes('..') || ref.includes('\0'))
160
+ return null;
161
+ if (!/^[A-Za-z0-9._/-]+$/.test(ref))
162
+ return null;
163
+ return ref;
164
+ }
165
+ async function log(rawRepo, limit = 50, ref) {
166
+ const r = await resolveRepo(rawRepo);
167
+ if ('error' in r)
168
+ return r;
169
+ if (!(await isRepo(r.real)))
170
+ return { error: 'not a repo', status: 400 };
171
+ const n = Math.max(1, Math.min(500, Number(limit) || 50));
172
+ const safeR = ref == null || ref === '' ? null : safeRef(ref);
173
+ if (ref && !safeR)
174
+ return { error: 'bad ref', status: 400 };
175
+ const args = ['log', `-n${n}`, `--pretty=format:%H${SEP}%h${SEP}%s${SEP}%an${SEP}%ar`];
176
+ if (safeR)
177
+ args.push(safeR); // git log <ref> …(指定分支只读看历史,不动工作树)
178
+ let out = '';
179
+ try {
180
+ out = await git(r.real, args);
181
+ }
182
+ catch {
183
+ return { commits: [] };
184
+ } // 空仓库(无提交)/ 无此 ref → 空列表
185
+ const commits = out.split('\n').filter(Boolean).map((line) => {
186
+ const [hash = '', short = '', subject = '', author = '', relDate = ''] = line.split(SEP);
187
+ return { hash, short, subject, author, relDate };
188
+ });
189
+ return { commits };
190
+ }
191
+ async function branches(rawRepo) {
192
+ const r = await resolveRepo(rawRepo);
193
+ if ('error' in r)
194
+ return r;
195
+ if (!(await isRepo(r.real)))
196
+ return { error: 'not a repo', status: 400 };
197
+ const fmt = ['%(refname:short)', '%(HEAD)', '%(upstream:short)', '%(upstream:track)'].join(SEP);
198
+ let out;
199
+ try {
200
+ out = await git(r.real, ['for-each-ref', `--format=${fmt}`, 'refs/heads']);
201
+ }
202
+ catch {
203
+ return { branches: [] };
204
+ }
205
+ const branches = out.split('\n').filter(Boolean).map((line) => {
206
+ const [name = '', head = '', upstream = '', track = ''] = line.split(SEP);
207
+ const ahead = Number((track.match(/ahead (\d+)/) || [])[1] || 0);
208
+ const behind = Number((track.match(/behind (\d+)/) || [])[1] || 0);
209
+ return { name, current: head === '*', upstream: upstream || null, ahead, behind };
210
+ });
211
+ return { branches };
212
+ }
213
+ // 文件路径校验:相对、非绝对、不以 '-' 开头(防选项注入)、无 '..' 段、无 NUL。
214
+ function safeRelPath(p) {
215
+ if (typeof p !== 'string' || !p || p[0] === '-' || isAbsolute(p))
216
+ return null;
217
+ if (p.includes('\0') || p.split('/').some((seg) => seg === '..'))
218
+ return null;
219
+ return p;
220
+ }
221
+ const MAX_DIFF_BYTES = 512 * 1024;
222
+ function cap(text) {
223
+ if (text.length <= MAX_DIFF_BYTES)
224
+ return { diff: text, truncated: false };
225
+ return { diff: text.slice(0, MAX_DIFF_BYTES), truncated: true };
226
+ }
227
+ // diff 语义:仅 path → 工作区 vs HEAD;staged → 暂存区 vs HEAD;commit → 该提交 vs 其父。
228
+ async function diff(rawRepo, { path, commit, staged } = {}) {
229
+ const r = await resolveRepo(rawRepo);
230
+ if ('error' in r)
231
+ return r;
232
+ if (!(await isRepo(r.real)))
233
+ return { error: 'not a repo', status: 400 };
234
+ const rel = safeRelPath(path);
235
+ if (!rel)
236
+ return { error: 'bad path', status: 400 };
237
+ let args;
238
+ if (commit) {
239
+ if (typeof commit !== 'string' || !/^[0-9a-fA-F]{4,40}$/.test(commit))
240
+ return { error: 'bad commit', status: 400 };
241
+ args = ['show', '--format=', commit, '--', rel];
242
+ }
243
+ else if (staged) {
244
+ args = ['diff', '--staged', '--', rel];
245
+ }
246
+ else {
247
+ args = ['diff', 'HEAD', '--', rel];
248
+ }
249
+ let out = '';
250
+ try {
251
+ out = await git(r.real, args);
252
+ }
253
+ catch (e) {
254
+ return { error: 'diff failed', status: 500 };
255
+ }
256
+ return cap(out);
257
+ }
258
+ async function commit(rawRepo, hash) {
259
+ const r = await resolveRepo(rawRepo);
260
+ if ('error' in r)
261
+ return r;
262
+ if (!(await isRepo(r.real)))
263
+ return { error: 'not a repo', status: 400 };
264
+ if (typeof hash !== 'string' || !/^[0-9a-fA-F]{4,40}$/.test(hash))
265
+ return { error: 'bad commit', status: 400 };
266
+ let message;
267
+ let ns;
268
+ try {
269
+ message = (await git(r.real, ['show', '-s', '--format=%B', hash])).trim();
270
+ // --root 让首次提交(无父)也能列出文件。
271
+ ns = await git(r.real, ['diff-tree', '--no-commit-id', '--name-status', '-r', '--root', hash]);
272
+ }
273
+ catch {
274
+ return { error: 'git error', status: 500 };
275
+ }
276
+ const files = ns.split('\n').filter(Boolean).map((line) => {
277
+ const [code = '', ...rest] = line.split('\t');
278
+ return { x: code[0] ?? '', y: '', path: rest[rest.length - 1] ?? '' };
279
+ }).filter((file) => file.path);
280
+ return { message, files };
281
+ }
282
+ return { resolveRepo, isRepo, worktree, detectRepos, status, log, branches, diff, commit };
283
+ }
284
+ export const defaultGit = createGit({ home: homedir(), extraRoots: defaultExtraRoots() });
@@ -0,0 +1,97 @@
1
+ const SUBSYSTEM_NAMES = ['workspace', 'codex', 'browser'];
2
+ const SUBSYSTEM_STATUSES = ['starting', 'ready', 'degraded', 'disabled'];
3
+ const READY_STATUSES = ['starting', 'ready', 'degraded'];
4
+ function recordOf(value) {
5
+ return value != null && typeof value === 'object' && !Array.isArray(value)
6
+ ? value
7
+ : null;
8
+ }
9
+ function parseSubsystem(value) {
10
+ const record = recordOf(value);
11
+ if (!record || !SUBSYSTEM_STATUSES.includes(record.status)
12
+ || typeof record.required !== 'boolean'
13
+ || (record.detail !== null && (typeof record.detail !== 'string' || !record.detail)))
14
+ return null;
15
+ if (record.required && record.status === 'disabled')
16
+ return null;
17
+ return {
18
+ status: record.status,
19
+ required: record.required,
20
+ detail: record.detail,
21
+ };
22
+ }
23
+ export function parseHealthReadySnapshot(value) {
24
+ const record = recordOf(value);
25
+ const subsystemsValue = recordOf(record?.subsystems);
26
+ if (!record || !subsystemsValue || !READY_STATUSES.includes(record.status)
27
+ || typeof record.ready !== 'boolean' || typeof record.checkedAt !== 'number'
28
+ || !Number.isFinite(record.checkedAt))
29
+ return null;
30
+ const entries = SUBSYSTEM_NAMES.map((name) => [name, parseSubsystem(subsystemsValue[name])]);
31
+ if (entries.some(([, subsystem]) => !subsystem))
32
+ return null;
33
+ const subsystems = Object.fromEntries(entries);
34
+ const computedReady = Object.values(subsystems)
35
+ .every((subsystem) => !subsystem.required || subsystem.status === 'ready');
36
+ if (computedReady !== record.ready)
37
+ return null;
38
+ const requiredDegraded = Object.values(subsystems)
39
+ .some((subsystem) => subsystem.required && subsystem.status === 'degraded');
40
+ const anyDegraded = Object.values(subsystems).some((subsystem) => subsystem.status === 'degraded');
41
+ const computedStatus = computedReady
42
+ ? (anyDegraded ? 'degraded' : 'ready')
43
+ : (requiredDegraded ? 'degraded' : 'starting');
44
+ if (computedStatus !== record.status)
45
+ return null;
46
+ return {
47
+ status: computedStatus,
48
+ ready: computedReady,
49
+ checkedAt: record.checkedAt,
50
+ subsystems,
51
+ };
52
+ }
53
+ export function parseHealthLiveSnapshot(value) {
54
+ const record = recordOf(value);
55
+ return record?.status === 'live' && record.live === true
56
+ && typeof record.checkedAt === 'number' && Number.isFinite(record.checkedAt)
57
+ ? { status: 'live', live: true, checkedAt: record.checkedAt }
58
+ : null;
59
+ }
60
+ export class RuntimeHealth {
61
+ #subsystems;
62
+ #now;
63
+ constructor({ browserRequired = false, now = Date.now } = {}) {
64
+ this.#now = now;
65
+ this.#subsystems = {
66
+ workspace: { status: 'starting', required: true, detail: null },
67
+ codex: { status: 'starting', required: true, detail: null },
68
+ browser: browserRequired
69
+ ? { status: 'starting', required: true, detail: null }
70
+ : { status: 'disabled', required: false, detail: null },
71
+ };
72
+ }
73
+ set(name, status, detail = null) {
74
+ if (!SUBSYSTEM_NAMES.includes(name) || !SUBSYSTEM_STATUSES.includes(status)
75
+ || (detail !== null && !detail))
76
+ throw new Error('invalid health subsystem state');
77
+ const current = this.#subsystems[name];
78
+ if (current.required && status === 'disabled')
79
+ throw new Error(`${name} is required`);
80
+ this.#subsystems[name] = { status, required: current.required, detail };
81
+ }
82
+ live() {
83
+ return { status: 'live', live: true, checkedAt: this.#now() };
84
+ }
85
+ snapshot() {
86
+ const subsystems = structuredClone(this.#subsystems);
87
+ const ready = Object.values(subsystems)
88
+ .every((subsystem) => !subsystem.required || subsystem.status === 'ready');
89
+ const requiredDegraded = Object.values(subsystems)
90
+ .some((subsystem) => subsystem.required && subsystem.status === 'degraded');
91
+ const anyDegraded = Object.values(subsystems).some((subsystem) => subsystem.status === 'degraded');
92
+ const status = ready
93
+ ? (anyDegraded ? 'degraded' : 'ready')
94
+ : (requiredDegraded ? 'degraded' : 'starting');
95
+ return { status, ready, checkedAt: this.#now(), subsystems };
96
+ }
97
+ }
@@ -0,0 +1,96 @@
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 { bearerFrom, expressAuth, tokenEquals } 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 { DEFAULT_SHORTCUTS } from './shortcutConfig.js';
25
+ import { workspaceRoutes } from './routes/workspace.js';
26
+ import { browserRoutes } from './browser/routes.js';
27
+ import { apiErrorBoundary, apiNotFound, apiRequestContext } from './apiErrors.js';
28
+ import { agentRoutes } from './routes/agents.js';
29
+ import { projectTaskRoutes } from './projectTask/routes.js';
30
+ import { apiAccountRoutes } from './routes/apiAccounts.js';
31
+ // Re-exported for tests (test/keys.test.js) and any caller that imported it by this path historically.
32
+ export { isAllowedKey } from './routes/terminal.js';
33
+ export function createApiRouter({ token, commands = defaultCommands, docs = defaultDocs, git = defaultGit, events, uploadExts = DEFAULT_UPLOAD_EXTS, maxUploadBytes = MAX_TRANSFER_BYTES, asrEnv = process.env, previews, shortcuts = DEFAULT_SHORTCUTS, browser, browserBootstrap, previewDomain, workspace, agentRuntime, projectTask, apiAccounts, apiErrors, agentIntegrationContext, home = homedir(), stateFile = process.env.CLAUDE_STATE_FILE || claudeStatePath(homedir()), }) {
34
+ const r = express.Router();
35
+ r.use(apiRequestContext(apiErrors));
36
+ // Account responses, including auth failures and malformed JSON, must never be cached. Keep the
37
+ // generic auth response stable for every existing route while giving this new API its coded envelope.
38
+ if (apiAccounts) {
39
+ r.use('/api-accounts', (req, res, next) => {
40
+ res.setHeader('Cache-Control', 'no-store');
41
+ const provided = bearerFrom(req.get('authorization'));
42
+ if (provided && tokenEquals(provided, token))
43
+ next();
44
+ else
45
+ res.status(401).json({
46
+ error: 'unauthorized', code: 'unauthorized', requestId: res.locals.requestId,
47
+ });
48
+ });
49
+ }
50
+ r.use(expressAuth(token));
51
+ r.use(express.json());
52
+ const eventOptions = { commands, push };
53
+ const claudeEvents = events || createClaudeEvents(eventOptions);
54
+ const legacyAgentIdentity = {
55
+ identifyPanes: (panes) => (claudeEvents.identifyPaneAgents(panes.map((pane) => ({
56
+ id: pane.paneId,
57
+ command: pane.currentCommand,
58
+ tty: pane.tty ?? '',
59
+ })))),
60
+ };
61
+ const deps = {
62
+ token, commands, docs, git, push, notifications, claudeEvents,
63
+ agentIdentity: agentRuntime ?? legacyAgentIdentity,
64
+ uploadExts, maxUploadBytes, asrEnv, shortcuts, home, stateFile,
65
+ ...(agentRuntime !== undefined ? { agentRuntime } : {}),
66
+ ...(previews !== undefined ? { previews } : {}),
67
+ ...(workspace !== undefined ? { workspace } : {}),
68
+ ...(browser !== undefined ? { browser } : {}),
69
+ ...(browserBootstrap !== undefined ? { browserBootstrap } : {}),
70
+ ...(previewDomain !== undefined ? { previewDomain } : {}),
71
+ ...(agentIntegrationContext !== undefined ? { agentIntegrationContext } : {}),
72
+ };
73
+ r.use(sessionRoutes(deps));
74
+ r.use(terminalRoutes(deps));
75
+ r.use(gitRoutes(deps));
76
+ r.use(fileRoutes(deps));
77
+ r.use(pushRoutes(deps));
78
+ r.use(notificationRoutes(deps));
79
+ r.use(systemRoutes(deps));
80
+ if (apiAccounts)
81
+ r.use(apiAccountRoutes({ service: apiAccounts }));
82
+ r.use(previewRoutes(deps));
83
+ // In the main process browser APIs are forwarded to the isolated worker before this router. Keep
84
+ // preview-domain wiring out of this unused fallback route so capability reporting does not require
85
+ // the worker-owned bootstrap ticket store.
86
+ r.use('/browser-proxy', browserRoutes(browser ? deps : { ...deps, previewDomain: null }));
87
+ if (agentRuntime)
88
+ r.use(agentRoutes({ runtime: agentRuntime }));
89
+ if (projectTask)
90
+ r.use(projectTaskRoutes({ runtime: projectTask }));
91
+ if (workspace)
92
+ r.use(workspaceRoutes({ workspace }));
93
+ r.use(apiNotFound(apiErrors));
94
+ r.use(apiErrorBoundary(apiErrors));
95
+ return r;
96
+ }
@@ -0,0 +1,15 @@
1
+ // Shared JSON-store persistence for small single-writer runtime registries (push subscriptions, previews,
2
+ // and notification inboxes). The PrivateStateStore boundary supplies 0700/0600 permission migration,
3
+ // unique sibling temporaries, fsync, and atomic rename. Callers intentionally keep best-effort semantics:
4
+ // a failed flush is recoverable through device re-registration or lease renewal.
5
+ import { PrivateStateStore } from './privateStateStore.js';
6
+ export function readJsonArray(file) {
7
+ const value = new PrivateStateStore(file).read();
8
+ return Array.isArray(value) ? value : [];
9
+ }
10
+ export function writeJsonAtomic(file, value) {
11
+ try {
12
+ new PrivateStateStore(file).write(value);
13
+ }
14
+ catch { /* best effort — a lost flush is recoverable by the caller */ }
15
+ }
@@ -3,8 +3,8 @@
3
3
  const NAMED = 'Up|Down|Left|Right|Space|Enter|Escape|Tab|BTab|BSpace|Home|End|PageUp|PageDown';
4
4
  const NAMED_KEY = new RegExp(`^(?:C-)?(?:M-)?(?:S-)?(?:${NAMED})$`);
5
5
  const CHAR_KEY = /^(?:C-)?(?:M-)?[a-z0-9]$/;
6
-
7
6
  export function isAllowedKey(key) {
8
- if (typeof key !== 'string') return false;
9
- return NAMED_KEY.test(key) || (CHAR_KEY.test(key) && key.includes('-'));
7
+ if (typeof key !== 'string')
8
+ return false;
9
+ return NAMED_KEY.test(key) || (CHAR_KEY.test(key) && key.includes('-'));
10
10
  }
@@ -0,0 +1,111 @@
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
+ const here = path.dirname(fileURLToPath(import.meta.url));
13
+ const DIR = process.env.NOTIF_DIR || path.resolve(here, '../data/notifications');
14
+ const CAP = 100;
15
+ const genId = () => crypto.randomBytes(9).toString('base64url');
16
+ const DELIVERY_STATUSES = new Set(['pending', 'success', 'failed']);
17
+ const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
18
+ function parseDelivery(value) {
19
+ if (!isRecord(value) || typeof value.status !== 'string' || !DELIVERY_STATUSES.has(value.status)) {
20
+ return undefined;
21
+ }
22
+ const delivery = { status: value.status };
23
+ if (delivery.status === 'failed' && typeof value.reason === 'string')
24
+ delivery.reason = value.reason;
25
+ return delivery;
26
+ }
27
+ function parseStoredNotification(value) {
28
+ if (!isRecord(value)
29
+ || typeof value.id !== 'string'
30
+ || typeof value.ts !== 'number'
31
+ || !Number.isFinite(value.ts)
32
+ || typeof value.title !== 'string'
33
+ || typeof value.body !== 'string')
34
+ return null;
35
+ const notification = {
36
+ id: value.id,
37
+ ts: value.ts,
38
+ title: value.title,
39
+ body: value.body,
40
+ };
41
+ if (typeof value.tag === 'string')
42
+ notification.tag = value.tag;
43
+ if (typeof value.url === 'string') {
44
+ const safeUrl = sanitizeNotificationUrl(value.url);
45
+ if (safeUrl)
46
+ notification.url = safeUrl;
47
+ }
48
+ const delivery = parseDelivery(value.delivery);
49
+ if (delivery)
50
+ notification.delivery = delivery;
51
+ return notification;
52
+ }
53
+ // pushKey is base64url already; sanitize anyway so a hostile value can't escape DIR. Empty → null (skip).
54
+ function fileFor(key) {
55
+ const safe = String(key || '').replace(/[^A-Za-z0-9_-]/g, '');
56
+ return safe ? path.join(DIR, `${safe}.json`) : null;
57
+ }
58
+ const load = (file) => readJsonArray(file)
59
+ .map(parseStoredNotification)
60
+ .filter((notification) => notification !== null);
61
+ export function record(pushKeys, input = {}) {
62
+ const { title, body, tag, url } = input;
63
+ const rec = { id: genId(), ts: Date.now(), title: String(title ?? ''), body: String(body ?? '') };
64
+ if (tag)
65
+ rec.tag = String(tag);
66
+ const initialDelivery = parseDelivery(input.delivery);
67
+ if (initialDelivery?.status === 'pending')
68
+ rec.delivery = { status: 'pending' };
69
+ const safeUrl = sanitizeNotificationUrl(url);
70
+ if (safeUrl)
71
+ rec.url = safeUrl;
72
+ for (const key of pushKeys || []) {
73
+ const file = fileFor(key);
74
+ if (!file)
75
+ continue;
76
+ const items = load(file);
77
+ items.push(rec);
78
+ writeJsonAtomic(file, items.length > CAP ? items.slice(items.length - CAP) : items);
79
+ }
80
+ return rec;
81
+ }
82
+ export function updateDelivery(pushKey, id, delivery) {
83
+ const file = fileFor(pushKey);
84
+ if (!file)
85
+ return false;
86
+ const items = load(file);
87
+ const item = items.find((n) => n.id === id);
88
+ if (!item)
89
+ return false;
90
+ const nextDelivery = parseDelivery(delivery);
91
+ if (!nextDelivery)
92
+ return false;
93
+ item.delivery = nextDelivery;
94
+ writeJsonAtomic(file, items);
95
+ return true;
96
+ }
97
+ export function list(pushKey) {
98
+ const file = fileFor(pushKey);
99
+ return file ? load(file).reverse() : [];
100
+ }
101
+ export function remove(pushKey, id) {
102
+ const file = fileFor(pushKey);
103
+ if (!file)
104
+ return false;
105
+ const items = load(file);
106
+ const kept = items.filter((n) => n.id !== id);
107
+ if (kept.length === items.length)
108
+ return false;
109
+ writeJsonAtomic(file, kept);
110
+ return true;
111
+ }