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/orphans.js DELETED
@@ -1,152 +0,0 @@
1
- // Detect "orphan" coding-agent sessions: a `claude`/`codex`/… process running on this host that is NOT
2
- // inside a tmux pane, so handmux can't see or steer it. We can't migrate a live process into tmux (reptyr
3
- // needs Linux ptrace+/proc — out on macOS — and breaks on multithreaded Node + child processes), so instead
4
- // we surface these in the Inbox and offer a "takeover": spawn the agent's `resume` command in a fresh tmux
5
- // pane (the agent's own persistence continues the conversation), then optionally kill the original.
6
- //
7
- // Detection is process-based, NOT a scan of the agents' session history (which is unbounded and can't tell
8
- // a live session from a dead one). Cost scales with the number of LIVE agent processes only.
9
- //
10
- // tmux membership is decided by TTY/PPID match against `tmux list-panes`, NOT by reading the process
11
- // environment: on macOS `ps eww` can't read another process's env (SIP), so `$TMUX` is a false signal.
12
- // A proc whose controlling tty is one of tmux's pane ttys (or whose parent is a pane's shell) is in tmux;
13
- // anything else with a real tty is an orphan.
14
- //
15
- // This module is now the agent-AGNOSTIC engine: which processes count, where a cwd's session lives, and how
16
- // to resume it all come from the driver descriptors in ./agents (parseAgentProcs tags each proc with its
17
- // agent; scan/takeover dispatch through getAgent). The pure parse/file helpers live in ./agents/scanUtils.
18
- import os from 'node:os';
19
- import path from 'node:path';
20
- import { AGENTS, getAgent } from './agents/index.js';
21
- import { isSessionId } from './tmux/commands.js';
22
- import { tmuxFormat } from './tmux/format.js';
23
- import {
24
- defaultRun, parseAgentProcs, parsePaneMembership, findOrphans,
25
- takeoverSessionName, sanitizeSessionName, freeSessionName, isShell, lsofCwd, isSessionUuid,
26
- } from './agents/scanUtils.js';
27
-
28
- // Back-compat re-exports: these were originally defined here and are imported by tests and callers by this
29
- // path. They're all agent-agnostic (or Claude's, kept as the default) and now live in scanUtils / claude.
30
- export {
31
- parsePaneMembership, findOrphans, encodeProjectDir, isSessionUuid,
32
- lastUserSnippet, etimeToMs, takeoverSessionName,
33
- resolveEncodedDirSession as resolveSession,
34
- } from './agents/scanUtils.js';
35
- export { projectsDir as defaultProjectsDir } from './agents/claude.js';
36
-
37
- // Parse `ps …` to LIVE Claude CLI procs only — the original Claude-specific helper, kept for the tests.
38
- // The general engine uses parseAgentProcs(psOut, AGENTS) directly.
39
- export function parseClaudeProcs(psOut) {
40
- return parseAgentProcs(psOut, [getAgent('claude')]);
41
- }
42
-
43
- // Take over an orphan: spawn the agent's `resume <sessionId>` in a fresh tmux session (target.mode 'new')
44
- // or a new window of an existing session ('window'), so handmux can steer the continued conversation.
45
- // Everything is re-verified server-side — the client's pid/sessionId are inputs to a fresh scan, never
46
- // trusted directly. The original process is SIGTERM'd only AFTER the resumed agent is confirmed up
47
- // (foreground command is no longer the shell) AND re-confirmed still the same orphan (guards pid reuse):
48
- // `<agent> resume` appends to the SAME session file with no OS lock (verified for Claude), so two live
49
- // writers corrupt history — killing guarantees a single writer. Injectable deps make it unit-testable.
50
- export async function takeoverOrphan(
51
- {
52
- commands, scanFn = scanOrphans, scanOpts = {},
53
- killProc = (pid, sig) => process.kill(pid, sig),
54
- delay = (ms) => new Promise((r) => setTimeout(r, ms)),
55
- pollTries = 16, pollMs = 400,
56
- },
57
- { pid, sessionId, target = { mode: 'new' }, kill = true, name: wantName } = {},
58
- ) {
59
- if (!Number.isInteger(pid) || pid <= 0) return { error: 'bad pid', status: 400 };
60
- // Both agents use UUID session ids; validate up front (takeover types the id into a shell via send-keys).
61
- if (!isSessionUuid(sessionId)) return { error: 'bad session id', status: 400 };
62
-
63
- const o = (await scanFn(scanOpts)).find((x) => x.pid === pid);
64
- if (!o) return { error: 'gone', status: 409 }; // no longer a live orphan
65
- if (o.sessionId !== sessionId) return { error: 'session changed', status: 409 };
66
- if (!o.cwd) return { error: 'no cwd', status: 409 };
67
- const agent = getAgent(o.agent);
68
- const cmd = agent.sessions.managedResumeCmd
69
- ? agent.sessions.managedResumeCmd(sessionId)
70
- : agent.sessions.resumeArgs(sessionId).join(' ');
71
-
72
- let sid;
73
- let wid;
74
- let name; // the target session NAME — returned so the client can bind it into its session list
75
- if (target.mode === 'window') {
76
- if (!isSessionId(target.session)) return { error: 'bad target session', status: 400 };
77
- sid = target.session;
78
- name = (await commands.listSessions()).find((s) => s.id === sid)?.name || null;
79
- wid = await commands.newWindow(sid, o.cwd, agent.procName, cmd);
80
- } else {
81
- const existing = new Set((await commands.listSessions()).map((s) => s.name));
82
- // Honour a user-typed name (sanitized to tmux rules, deduped with a numeric suffix); fall back to the
83
- // generated `<prefix>-<dir>-<n>` when the client sent nothing.
84
- const wanted = sanitizeSessionName(wantName);
85
- if (wanted) {
86
- name = freeSessionName(wanted, existing);
87
- } else {
88
- for (let i = 1; i < 1000 && !name; i++) {
89
- const cand = takeoverSessionName(o.cwdLabel, i, agent.takeoverPrefix);
90
- if (!existing.has(cand)) name = cand;
91
- }
92
- }
93
- if (!name) return { error: 'no free session name', status: 409 };
94
- sid = await commands.newSession(name, o.cwd, cmd);
95
- wid = (await commands.listWindows(sid))[0]?.id;
96
- }
97
-
98
- let up = false;
99
- let pane = null;
100
- for (let i = 0; i < pollTries && !up; i++) {
101
- await delay(pollMs);
102
- try {
103
- const p = (await commands.listPanes(wid))[0];
104
- if (p) { pane = p.id; if (!isShell(p.command)) up = true; }
105
- } catch { /* window/pane not ready yet */ }
106
- }
107
-
108
- let killed = false;
109
- if (kill && up) {
110
- const still = (await scanFn(scanOpts)).find((x) => x.pid === pid && x.sessionId === sessionId);
111
- if (still) { try { killProc(pid, 'SIGTERM'); killed = true; } catch { /* already exited */ } }
112
- }
113
- // claudeUp kept in the response for back-compat with the existing web client; agentUp is the neutral name.
114
- return { session: sid, name, window: wid, pane, agentUp: up, claudeUp: up, killed, agent: agent.id };
115
- }
116
-
117
- // Scan the host for orphan agent sessions across every registered driver. Best-effort: any failing
118
- // sub-command degrades to fewer/no results rather than throwing. `projectsDir`/`sessionsDir` override a
119
- // specific agent's session dir (each driver declares which option key it reads — used by tests and the
120
- // server, which pin the dir off the resolved $HOME).
121
- export async function scanOrphans({
122
- run = defaultRun, home = os.homedir(), busyMs = 8000, now = Date.now, agents = AGENTS, ...dirOverrides
123
- } = {}) {
124
- const [psOut, tmuxOut] = await Promise.all([
125
- run('ps', ['-Ao', 'pid=,ppid=,stat=,etime=,tty=,args=']),
126
- run('tmux', ['list-panes', '-a', '-F', tmuxFormat(['pane_tty', 'pane_pid'])]),
127
- ]);
128
- const orphans = findOrphans(parseAgentProcs(psOut, agents), parsePaneMembership(tmuxOut));
129
- const results = [];
130
- for (const o of orphans) {
131
- const agent = getAgent(o.agent);
132
- const cwd = await lsofCwd(run, o.pid);
133
- const dir = dirOverrides[agent.sessions.dirOptKey] || agent.sessions.dir(home);
134
- const meta = cwd ? await agent.sessions.resolve(dir, cwd, { busyMs, now }) : {};
135
- const cwdLabel = cwd ? path.basename(cwd) : '';
136
- results.push({
137
- pid: o.pid,
138
- agent: agent.id,
139
- agentLabel: agent.label,
140
- cwd: cwd || '',
141
- cwdLabel,
142
- // Default name the takeover sheet prefills (the same `<prefix>-<dir>-1` the server would auto-pick).
143
- suggestedName: takeoverSessionName(cwdLabel, 1, agent.takeoverPrefix),
144
- sessionId: meta.sessionId || null,
145
- state: meta.state || 'unknown',
146
- snippet: meta.snippet || '',
147
- lastActivity: meta.lastActivity || 0,
148
- startedAt: o.etimeMs ? Math.round(now() - o.etimeMs) : 0,
149
- });
150
- }
151
- return results;
152
- }
@@ -1,163 +0,0 @@
1
- // Parse a pending interactive PROMPT (an AskUserQuestion menu — single OR multi-question — or a tool-
2
- // permission menu) out of a Claude Code pane's `capture-pane` text. This is deliberately screen-scraping: a
3
- // pending prompt's options exist ONLY in the rendered TUI — they are NOT written to the session .jsonl until
4
- // AFTER the user answers (verified live), so the transcript can't be the source. Same approach amux/
5
- // VibeTunnel/Conductor use for the "attach to a running TUI" model; there is no structured channel short of
6
- // relaunching Claude via the SDK.
7
- //
8
- // Verified against a live Claude Code (2026-07-17):
9
- //
10
- // SINGLE question / permission menu: MULTI-question (tabbed): REVIEW / submit screen:
11
- // ☐ 颜色 ← header (optional) ← ☒ 水果 ☐ 颜色 ✔ Submit → ← ☒ 水果 ☒ 颜色 ✔ Submit →
12
- // 你喜欢哪个? ← question 选个颜色? ← current tab Review your answers
13
- // ❯ 1. 红色 ← ❯ marks the cursor ❯ 1. 红 ❯ 1. Submit answers
14
- // 热情、醒目 ← description (optional) 2. 蓝 2. Cancel
15
- // 2. 蓝色 Enter to select · Tab/… · Esc (NO footer line)
16
- // 4. Type something. ← built-in meta (dropped)
17
- // Enter to select · ↑/↓ to navigate · Esc to cancel
18
- //
19
- // Driving (verified live): sending the literal DIGIT of an option selects it. For a SINGLE question it
20
- // selects AND submits immediately; for MULTI it selects AND auto-advances to the next tab; on the review
21
- // screen digit 1 = "Submit answers". So the caller drives the whole flow by sending `String(option.n)` and
22
- // re-polling — each screen (Q1 → Q2 → review) is itself a parseable menu. Escape cancels.
23
- //
24
- // The parser ANCHORS on the ❯ cursor line, NOT a footer: the review screen has options but no footer.
25
-
26
- // Cursor-selected option line: ❯ (or ›»>) then "N.".
27
- const CURSOR_RE = /^\s*[❯›»>]\s*\d+\.\s/;
28
- // Any option row: optional cursor, then "N. label".
29
- const OPTION_RE = /^\s*[❯›»>]?\s*(\d+)\.\s+(.+?)\s*$/;
30
- // A description row: indented, not itself an option.
31
- const isDesc = (l) => /^\s{3,}\S/.test(l) && !OPTION_RE.test(l);
32
- // A horizontal rule Claude draws inside/around the card (kept within an option block so meta rows past it
33
- // are still seen and dropped).
34
- const RULE_RE = /^[\s─━—–\-_=·⎯]{8,}$/;
35
- // The multi-question tab strip: "← ☒ 水果 ☐ 颜色 ✔ Submit →".
36
- const TAB_BAR_RE = /✔\s*Submit|[☐☒].*[☐☒]/;
37
- // Claude's built-in trailing meta-options — not real answers.
38
- const META_LABELS = new Set(['type something', 'type something.', 'chat about this', 'chat about this.']);
39
- // Leading decoration on the header line.
40
- const HEADER_DECOR_RE = /^[\s☐☑☒◯●○·•\-*]+/;
41
- // A menu footer (single-question / permission), used only as a title boundary — NOT required to detect a menu.
42
- const FOOTER_RE = /enter to select|esc to (cancel|reject)/i;
43
- // A Claude activity/spinner line — the top edge of the card, above which is prior transcript.
44
- const ACTIVITY_RE = /^\s*[✻✳✶✽⏺]\s|worked for|cogitated|crafting|cooked for|crunched for|esc to interrupt/i;
45
- // The input cursor ❯ leading the user's echoed prompt (above the card).
46
- const PROMPT_ECHO_RE = /^\s*[❯›»>]\s*\D/; // ❯ NOT followed by a digit (that would be an option)
47
- const isTitleBoundary = (l) =>
48
- ACTIVITY_RE.test(l) || RULE_RE.test(l) || TAB_BAR_RE.test(l) || FOOTER_RE.test(l) || PROMPT_ECHO_RE.test(l);
49
-
50
- const stripRight = (s) => String(s == null ? '' : s).replace(/\s+$/, '');
51
- // Strip ANSI/OSC escapes so a capture taken WITH `-e` (SGR) still parses — belt-and-suspenders even though
52
- // the endpoint captures plain.
53
- const stripAnsi = (s) => String(s || '').replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '').replace(/\x1b[[0-9;?]*[ -/]*[@-~]/g, '');
54
-
55
- // The assistant text immediately PRECEDING the menu, scraped because the turn (text + AskUserQuestion) is NOT
56
- // flushed to the session jsonl until AFTER the user answers (verified live: probe + out-of-order line
57
- // timestamps) — so the 对话 lens's transcript view structurally can't show it while the gate is up. The title
58
- // walk below stops at the first boundary (activity spinner / box rule / prompt echo), which is exactly where
59
- // the lead-in text usually sits. Walk on upward: skip boundary chrome, take the nearest content block, keep
60
- // its LAST lines (the freshest context, "问题的最后一行"). `fromIdx` = the first line NOT consumed by the
61
- // title walk, so adjacent lead-in the title already absorbed is never duplicated here.
62
- const LEADIN_MAX = 2;
63
- const MSG_MARK_RE = /^\s*⏺/; // ⏺ opens an assistant message in the scrollback — include it, stop there
64
- function extractLeadIn(lines, fromIdx) {
65
- const block = [];
66
- let i = fromIdx;
67
- // Skip the chrome between the menu and the preceding text (spinner / rules / tab bar / stale footer /
68
- // blanks) — but never past a ❯ prompt echo (above it is the PREVIOUS exchange, stale context) nor past a
69
- // ⏺ message head (it IS the text's first line — the collect loop includes it).
70
- while (i >= 0 && (!lines[i].trim() || (isTitleBoundary(lines[i]) && !PROMPT_ECHO_RE.test(lines[i]) && !MSG_MARK_RE.test(lines[i])))) i--;
71
- for (; i >= 0 && block.length < 12; i--) {
72
- const l = lines[i];
73
- if (!l.trim()) break; // blank = the block's top (last paragraph)
74
- if (MSG_MARK_RE.test(l)) { block.unshift(l.trim()); break; } // the ⏺ message head: include & stop
75
- if (isTitleBoundary(l)) break; // hard chrome (incl. ❯ echo): stop
76
- block.unshift(l.trim());
77
- }
78
- const tail = block.slice(-LEADIN_MAX).map((l) => l.replace(/^⏺\s*/, '').trim()).filter(Boolean);
79
- return tail.length ? tail.join(' ') : null;
80
- }
81
-
82
- // Parse the multi-question tab strip → [{ label, answered }]. ☒ = answered, ☐ = not. "✔ Submit" is excluded.
83
- function parseTabs(line) {
84
- const tabs = [];
85
- const re = /([☐☒])\s*(\S+)/g;
86
- let m;
87
- while ((m = re.exec(line))) tabs.push({ label: m[2], answered: m[1] === '☒' });
88
- return tabs;
89
- }
90
-
91
- // capture-pane text → a normalized pending prompt, or null when no interactive menu is on screen.
92
- // { kind:'question'|'permission', title, options:[{n,label,description}], cursor,
93
- // multi, step, total, submit }
94
- // `n` is the digit to send to pick that option. For multi, `step`/`total` drive the "第 i/N 题" progress and
95
- // `submit` marks the final review screen (options are Submit answers / Cancel).
96
- export function parsePendingPrompt(text) {
97
- const lines = stripAnsi(text).split('\n').map(stripRight);
98
-
99
- // Anchor on the cursor-selected option (present in every menu: question, permission, review). Take the
100
- // LAST one so stale menus higher in the scrollback are ignored — the live menu is at the bottom.
101
- const anchor = lines.findLastIndex((l) => CURSOR_RE.test(l));
102
- if (anchor < 0) return null;
103
-
104
- // The option block = the contiguous run of option / description / rule lines around the anchor.
105
- const inBlock = (l) => OPTION_RE.test(l) || isDesc(l) || RULE_RE.test(l);
106
- let top = anchor;
107
- let bot = anchor;
108
- while (top - 1 >= 0 && inBlock(lines[top - 1])) top--;
109
- while (bot + 1 < lines.length && inBlock(lines[bot + 1])) bot++;
110
-
111
- const options = [];
112
- let cursor = null;
113
- for (let i = top; i <= bot; i++) {
114
- const m = lines[i].match(OPTION_RE);
115
- if (!m) continue;
116
- const n = Number(m[1]);
117
- const label = m[2].trim();
118
- if (CURSOR_RE.test(lines[i])) cursor = n;
119
- let description = '';
120
- for (let j = i + 1; j <= bot; j++) {
121
- if (OPTION_RE.test(lines[j])) break;
122
- if (!isDesc(lines[j])) break;
123
- description += (description ? ' ' : '') + lines[j].trim();
124
- }
125
- if (META_LABELS.has(label.toLowerCase())) continue; // drop Claude's built-in meta-options
126
- options.push({ n, label, description });
127
- }
128
- if (!options.length) return null;
129
-
130
- // Title = header/question text above the block, skipping in-card blanks, stopping at the card's top edge.
131
- const head = [];
132
- let headStop = top - 1;
133
- for (let i = top - 1; i >= 0 && head.length < 5; i--) {
134
- if (!lines[i].trim()) { headStop = i - 1; continue; }
135
- if (isTitleBoundary(lines[i])) { headStop = i; break; }
136
- head.unshift(lines[i]);
137
- headStop = i - 1;
138
- }
139
- const title = head.map((l) => l.replace(HEADER_DECOR_RE, '').trim()).filter(Boolean).join(' — ') || '需要你选择';
140
-
141
- // Multi-question: the tab strip drives progress. `answered` ☒ tabs → current step is the next unanswered.
142
- const tabLine = lines.find((l) => TAB_BAR_RE.test(l));
143
- const tabs = tabLine ? parseTabs(tabLine) : [];
144
- const multi = tabs.length > 1;
145
- const answered = tabs.filter((t) => t.answered).length;
146
- const submit = /^submit answers?$/i.test(options[0].label) || (multi && answered >= tabs.length);
147
-
148
- const kind = /do you want to proceed/i.test(title) || /^yes\b/i.test(options[0].label)
149
- ? 'permission' : 'question';
150
-
151
- const out = { kind, title, options, cursor };
152
- // Lead-in context the title walk didn't absorb (it stopped at a boundary or its 5-line cap) — the
153
- // assistant's last line(s) before the question, shown above the gate so the phone isn't asked blind.
154
- const leadIn = extractLeadIn(lines, headStop);
155
- if (leadIn) out.leadIn = leadIn;
156
- if (multi) {
157
- out.multi = true;
158
- out.total = tabs.length;
159
- out.step = Math.min(answered + 1, tabs.length);
160
- out.submit = submit;
161
- }
162
- return out;
163
- }
@@ -1,168 +0,0 @@
1
- // server/src/previewServer.js
2
- // Serves registered static-preview directories under a per-registration capability path. The main
3
- // Handmux token is never a preview credential. Absolute-rooted assets (/assets/...) are served from the
4
- // right preview dir via an authenticated Referer fallback (design's "方案 A").
5
- // Dynamic ports and arbitrary websites are handled by the built-in browser instead.
6
- import express from 'express';
7
- import { promises as fs } from 'node:fs';
8
- import path from 'node:path';
9
- import { tokenEquals } from './auth.js';
10
- function credentialOk(provided, token) {
11
- if (!provided) return false;
12
- try { return tokenEquals(provided, token); } catch { return false; }
13
- }
14
-
15
- // Resolve the on-disk file for a request path under a preview: '' or '<dir>/' → its index.html.
16
- function fileFor(rest) { return (!rest || rest.endsWith('/')) ? `${rest}index.html` : rest; }
17
-
18
- function previewHeaders(res) {
19
- res.setHeader('Cache-Control', 'no-store');
20
- res.setHeader('Referrer-Policy', 'same-origin');
21
- res.setHeader('X-Content-Type-Options', 'nosniff');
22
- // Keep preview code isolated even if its capability URL is opened outside the in-app iframe.
23
- res.setHeader('Content-Security-Policy', 'sandbox allow-scripts allow-forms allow-downloads allow-modals allow-popups');
24
- // A sandboxed static document intentionally has an opaque origin. Its capability path needs to remain
25
- // readable by relative fetch(), module scripts, and fonts without restoring same-origin parent access.
26
- res.setHeader('Access-Control-Allow-Origin', 'null');
27
- }
28
-
29
- function prefixedRoot(url, prefix) {
30
- return url.startsWith('/') && !url.startsWith('//') ? `${prefix}${url.slice(1)}` : url;
31
- }
32
-
33
- function rewriteCssRoots(source, prefix) {
34
- return source
35
- .replace(/(url\(\s*["']?)\/(?!\/)/gi, `$1${prefix}`)
36
- .replace(/(@import\s+["'])\/(?!\/)/gi, `$1${prefix}`);
37
- }
38
-
39
- function rewriteModuleRoots(source, prefix) {
40
- return source.replace(
41
- /(\b(?:from|import)\s*(?:\(\s*)?)(["'])\/(?!\/)/g,
42
- `$1$2${prefix}`,
43
- );
44
- }
45
-
46
- export function rewritePreviewText(source, extension, prefix) {
47
- if (extension === '.css') return rewriteCssRoots(source, prefix);
48
- if (extension === '.js' || extension === '.mjs') return rewriteModuleRoots(source, prefix);
49
- if (extension !== '.html' && extension !== '.htm') return source;
50
- let output = source.replace(
51
- /(\b(?:src|href|action|poster|data|formaction)\s*=\s*)(["'])(\/[^"']*)\2/gi,
52
- (_match, start, quote, url) => `${start}${quote}${prefixedRoot(url, prefix)}${quote}`,
53
- );
54
- output = output.replace(
55
- /(\bsrcset\s*=\s*)(["'])([^"']*)\2/gi,
56
- (_match, start, quote, value) => {
57
- const rewritten = value.split(',').map((candidate) => {
58
- const match = /^(\s*)(\S+)(.*)$/.exec(candidate);
59
- return match ? `${match[1]}${prefixedRoot(match[2], prefix)}${match[3]}` : candidate;
60
- }).join(',');
61
- return `${start}${quote}${rewritten}${quote}`;
62
- },
63
- );
64
- output = output.replace(
65
- /(<style\b[^>]*>)([\s\S]*?)(<\/style>)/gi,
66
- (_match, open, css, close) => `${open}${rewriteCssRoots(css, prefix)}${close}`,
67
- );
68
- output = output.replace(
69
- /(\bstyle\s*=\s*)(["'])([^"']*)\2/gi,
70
- (_match, start, quote, css) => `${start}${quote}${rewriteCssRoots(css, prefix)}${quote}`,
71
- );
72
- return output.replace(
73
- /(<script\b[^>]*\btype\s*=\s*["']module["'][^>]*>)([\s\S]*?)(<\/script>)/gi,
74
- (_match, open, script, close) => `${open}${rewriteModuleRoots(script, prefix)}${close}`,
75
- );
76
- }
77
-
78
- export function createPreview({
79
- previews,
80
- }) {
81
- const router = express.Router();
82
-
83
- function resolveEntry(name, accessToken, res) {
84
- const { state, entry } = previews.get(name);
85
- if (state === 'missing') return res.status(404).type('html').send('<!doctype html><meta charset="utf-8"><h1>预览不存在</h1>');
86
- if (state === 'expired') return res.status(410).type('html').send('<!doctype html><meta charset="utf-8"><h1>预览已过期</h1><p>请回到 app 重新启动预览。</p>');
87
- if (!credentialOk(accessToken, entry.accessToken)) return res.status(401).send('unauthorized');
88
- return entry;
89
- }
90
-
91
- async function serve(entry, rest, prefix, res) {
92
- previewHeaders(res);
93
- const requested = fileFor(rest);
94
- const segments = requested.split('/');
95
- const target = path.resolve(entry.dir, requested);
96
- const relative = path.relative(entry.dir, target);
97
- if (!relative || relative.startsWith('..') || path.isAbsolute(relative)
98
- || segments.some((segment) => segment.startsWith('.'))) {
99
- if (!relative && requested === 'index.html') {
100
- // The root index is the normal preview entry point.
101
- } else {
102
- res.status(404).end();
103
- return;
104
- }
105
- }
106
- const extension = path.extname(target).toLowerCase();
107
- if (['.html', '.htm', '.css', '.js', '.mjs'].includes(extension)) {
108
- try {
109
- const source = await fs.readFile(target, 'utf8');
110
- res.type(extension).send(rewritePreviewText(source, extension, prefix));
111
- } catch (error) {
112
- if (!res.headersSent) res.status(error?.code === 'ENOENT' ? 404 : 500).end();
113
- }
114
- return;
115
- }
116
- res.sendFile(requested, { root: entry.dir, dotfiles: 'deny' }, (err) => {
117
- if (err && !res.headersSent) res.status(err.statusCode || 404).end();
118
- });
119
- }
120
-
121
- // A name without its independent capability is never accepted, including the old ?token=<app-token>
122
- // shape. Keeping this explicit makes the failure an authentication result instead of an SPA fallback.
123
- router.get('/:name', (_req, res) => res.status(401).send('unauthorized'));
124
- router.get('/:name/:accessToken', (req, res) => {
125
- const entry = resolveEntry(req.params.name, req.params.accessToken, res);
126
- if (!entry || res.headersSent) return;
127
- const prefix = `/preview/${encodeURIComponent(req.params.name)}/${encodeURIComponent(req.params.accessToken)}/`;
128
- if (!req.url.split('?')[0].endsWith('/')) {
129
- return res.redirect(301, prefix);
130
- }
131
- return void serve(entry, '', prefix, res);
132
- });
133
- router.get('/:name/:accessToken/*', (req, res) => {
134
- const entry = resolveEntry(req.params.name, req.params.accessToken, res);
135
- if (!entry || res.headersSent) return;
136
- const prefix = `/preview/${encodeURIComponent(req.params.name)}/${encodeURIComponent(req.params.accessToken)}/`;
137
- void serve(entry, req.params[0], prefix, res);
138
- });
139
-
140
- // Referer fallback (mount AFTER /preview, BEFORE express.static): an absolute /assets/... request
141
- // whose Referer is a preview page is served from that preview's dir. Reuses credOk so it can never
142
- // read a dir without a valid token/cookie. Misses fall through to the normal static/SPA layer.
143
- function refererFallback(req, res, next) {
144
- if (req.method !== 'GET') return next();
145
- if (req.path.startsWith('/api') || req.path.startsWith('/preview')) return next();
146
- const ref = req.headers.referer;
147
- if (!ref) return next();
148
- let refPath;
149
- try { refPath = new URL(ref).pathname; } catch { return next(); }
150
- const m = /^\/preview\/([^/]+)\/([^/]+)\//.exec(refPath);
151
- if (!m) return next();
152
- let name;
153
- let accessToken;
154
- try {
155
- name = decodeURIComponent(m[1]);
156
- accessToken = decodeURIComponent(m[2]);
157
- } catch { return next(); }
158
- const { state, entry } = previews.get(name);
159
- if (state !== 'active') return next();
160
- if (!credentialOk(accessToken, entry.accessToken)) return next();
161
- previewHeaders(res);
162
- res.sendFile(req.path, { root: entry.dir, dotfiles: 'deny' }, (err) => {
163
- if (err && !res.headersSent) next();
164
- });
165
- }
166
-
167
- return { router, refererFallback };
168
- }
package/src/previews.js DELETED
@@ -1,116 +0,0 @@
1
- // server/src/previews.js
2
- // Preview registry. Maps a safe single-segment name to an on-disk directory under $HOME with a lease.
3
- // in-memory registry (loaded once at construction, flushed atomically on each mutation) — the previous
4
- // reload-and-write-back on every op was an unguarded read-modify-write that could lose an entry when a
5
- // GET's lease update raced a concurrent register(). Pure-ish: home/now/store/ttl are injected for tests.
6
- import fs from 'node:fs';
7
- import path from 'node:path';
8
- import { homedir } from 'node:os';
9
- import { randomBytes } from 'node:crypto';
10
- import { fileURLToPath } from 'node:url';
11
- import { isUnder } from './docPath.js';
12
- import { readJsonArray, writeJsonAtomic } from './jsonStore.js';
13
-
14
- export function safePreviewName(raw) {
15
- if (typeof raw !== 'string') return null;
16
- if (!/^[A-Za-z0-9._-]+$/.test(raw)) return null;
17
- if (raw === '.' || raw === '..' || raw[0] === '.') return null;
18
- // Keep lookups stable across clients that may normalize user-provided names differently.
19
- return raw.toLowerCase();
20
- }
21
-
22
- export function createPreviews({
23
- home = homedir(),
24
- store = process.env.PREVIEW_STORE || path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../data/previews.json'),
25
- now = () => Date.now(),
26
- ttlMs = 2 * 60 * 60_000,
27
- randomToken = () => randomBytes(24).toString('base64url'),
28
- } = {}) {
29
- let realHome;
30
- try { realHome = fs.realpathSync(home); } catch { realHome = home; }
31
-
32
- // Loaded ONCE — this in-memory array is the source of truth; every op mutates it and flushes atomically.
33
- let entries = readJsonArray(store);
34
- let flushedExpiries = new Map(entries.map((entry) => [entry?.name, entry?.expiresAt]));
35
- const flush = () => {
36
- // Access tokens are runtime capabilities. Open device tabs re-register after a restart and receive
37
- // a fresh token, so a stale registry file can never resurrect an old preview URL.
38
- writeJsonAtomic(store, entries.map(({ accessToken: _accessToken, ...entry }) => entry));
39
- flushedExpiries = new Map(entries.map((entry) => [entry?.name, entry?.expiresAt]));
40
- };
41
-
42
- const resultFor = (entry) => ({
43
- name: entry.name,
44
- kind: entry.kind,
45
- accessToken: entry.accessToken,
46
- expiresAt: entry.expiresAt,
47
- });
48
-
49
- // Re-registering the same active directory is a lease renewal. Preserve its capability so a
50
- // foreground check does not change the iframe URL and reload an already-mounted page. A changed
51
- // directory, expired row, or process-restored row without a runtime token receives a fresh one.
52
- const upsert = (fields) => {
53
- const ts = now();
54
- const current = entries.find((entry) => entry && entry.name === fields.name);
55
- if (current?.kind === fields.kind
56
- && current.dir === fields.dir
57
- && current.expiresAt > ts
58
- && typeof current.accessToken === 'string'
59
- && current.accessToken) {
60
- current.expiresAt = ts + ttlMs;
61
- flush();
62
- return resultFor(current);
63
- }
64
- entries = entries.filter((e) => e && e.name !== fields.name);
65
- const entry = {
66
- ...fields,
67
- accessToken: randomToken(),
68
- createdAt: ts,
69
- expiresAt: ts + ttlMs,
70
- };
71
- entries.push(entry);
72
- flush();
73
- return resultFor(entry);
74
- };
75
-
76
- async function register({ name, dir, port }) {
77
- const nm = safePreviewName(name);
78
- if (!nm) return { error: 'bad name', status: 400 };
79
- if (port !== undefined && port !== null && port !== '') return { error: 'bad request', status: 400 };
80
- if (typeof dir !== 'string' || dir[0] !== '/') return { error: 'not absolute', status: 400 };
81
- let real;
82
- try { real = fs.realpathSync(dir); } catch { return { error: 'not found', status: 404 }; }
83
- if (!isUnder(real, realHome)) return { error: 'outside home', status: 400 };
84
- let st;
85
- try { st = fs.statSync(real); } catch { return { error: 'not accessible', status: 404 }; }
86
- if (!st.isDirectory()) return { error: 'not a directory', status: 400 };
87
- return upsert({ name: nm, kind: 'static', dir: real });
88
- }
89
-
90
- function get(name) {
91
- const entry = entries.find((e) => e && e.name === name);
92
- if (!entry) return { state: 'missing' };
93
- const ts = now();
94
- if (entry.expiresAt <= ts) { entries = entries.filter((e) => e.name !== name); flush(); return { state: 'expired' }; }
95
- if (entry.kind === 'dynamic') { entries = entries.filter((e) => e.name !== name); flush(); return { state: 'missing' }; }
96
- // Match proxy leases: actual page/resource traffic renews the lease. Throttle persistence so a page
97
- // with many assets does not rewrite the registry once per request.
98
- const nextExpiry = ts + ttlMs;
99
- entry.expiresAt = nextExpiry;
100
- if (nextExpiry - (flushedExpiries.get(entry.name) || 0) >= 60_000) flush();
101
- return { state: 'active', entry: { kind: 'static', ...entry } }; // legacy rows (no kind) → static
102
- }
103
-
104
- function list() {
105
- const active = entries.filter((e) => e && e.kind !== 'dynamic' && e.expiresAt > now());
106
- if (active.length !== entries.length) { entries = active; flush(); }
107
- return active.map((e) => ({ name: e.name, kind: 'static', dir: e.dir, expiresAt: e.expiresAt }));
108
- }
109
-
110
- function remove(name) {
111
- const next = entries.filter((e) => e && e.name !== name);
112
- if (next.length !== entries.length) { entries = next; flush(); }
113
- }
114
-
115
- return { register, get, list, remove };
116
- }