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,167 @@
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 { defaultRun, parseAgentProcs, parsePaneMembership, findOrphans, takeoverSessionName, sanitizeSessionName, freeSessionName, isShell, lsofCwd, isSessionUuid, } from './agents/scanUtils.js';
24
+ const record = (value) => (value !== null && typeof value === 'object' && !Array.isArray(value)
25
+ ? value
26
+ : null);
27
+ // Orphan candidates created before multi-Agent scanning had no `agent` field and were necessarily Claude.
28
+ // Preserve only that absent-field format default; an explicit unknown id must still fail closed.
29
+ function orphanAgent(id) {
30
+ return id === undefined ? getAgent('claude') : getAgent(id);
31
+ }
32
+ // Back-compat re-exports: these were originally defined here and are imported by tests and callers by this
33
+ // path. They're all agent-agnostic (or Claude's, kept as the default) and now live in scanUtils / claude.
34
+ export { parsePaneMembership, findOrphans, encodeProjectDir, isSessionUuid, lastUserSnippet, etimeToMs, takeoverSessionName, resolveEncodedDirSession as resolveSession, } from './agents/scanUtils.js';
35
+ export { projectsDir as defaultProjectsDir } from './agents/claude.js';
36
+ // Parse `ps …` to LIVE Claude CLI procs only — the original Claude-specific helper, kept for the tests.
37
+ // The general engine uses parseAgentProcs(psOut, AGENTS) directly.
38
+ export function parseClaudeProcs(psOut) {
39
+ const agent = getAgent('claude');
40
+ return agent ? parseAgentProcs(psOut, [agent]) : [];
41
+ }
42
+ // Take over an orphan: spawn the agent's `resume <sessionId>` in a fresh tmux session (target.mode 'new')
43
+ // or a new window of an existing session ('window'), so handmux can steer the continued conversation.
44
+ // Everything is re-verified server-side — the client's pid/sessionId are inputs to a fresh scan, never
45
+ // trusted directly. The original process is SIGTERM'd only AFTER the resumed agent is confirmed up
46
+ // (foreground command is no longer the shell) AND re-confirmed still the same orphan (guards pid reuse):
47
+ // `<agent> resume` appends to the SAME session file with no OS lock (verified for Claude), so two live
48
+ // writers corrupt history — killing guarantees a single writer. Injectable deps make it unit-testable.
49
+ export async function takeoverOrphan({ commands, scanFn = scanOrphans, scanOpts = {}, killProc = (pid, sig) => process.kill(pid, sig), delay = (ms) => new Promise((r) => setTimeout(r, ms)), pollTries = 16, pollMs = 400, }, { pid, sessionId, target = { mode: 'new' }, kill = true, name: wantName } = {}) {
50
+ if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0)
51
+ return { error: 'bad pid', status: 400 };
52
+ // Both agents use UUID session ids; validate up front (takeover types the id into a shell via send-keys).
53
+ if (!isSessionUuid(sessionId))
54
+ return { error: 'bad session id', status: 400 };
55
+ const o = (await scanFn(scanOpts)).find((x) => x.pid === pid);
56
+ if (!o)
57
+ return { error: 'gone', status: 409 }; // no longer a live orphan
58
+ if (o.sessionId !== sessionId)
59
+ return { error: 'session changed', status: 409 };
60
+ if (!o.cwd)
61
+ return { error: 'no cwd', status: 409 };
62
+ const agent = orphanAgent(o.agent);
63
+ if (!agent)
64
+ return { error: 'unknown agent', status: 409 };
65
+ const cmd = agent.sessions.managedResumeCmd
66
+ ? agent.sessions.managedResumeCmd(sessionId)
67
+ : agent.sessions.resumeArgs(sessionId).join(' ');
68
+ let sid;
69
+ let wid;
70
+ let name = null; // the target session NAME — returned so the client can bind it into its session list
71
+ const targetRecord = record(target) || { mode: 'new' };
72
+ if (targetRecord.mode === 'window') {
73
+ if (!isSessionId(targetRecord.session))
74
+ return { error: 'bad target session', status: 400 };
75
+ sid = targetRecord.session;
76
+ name = (await commands.listSessions()).find((s) => s.id === sid)?.name || null;
77
+ wid = await commands.newWindow(sid, o.cwd, agent.procName, cmd);
78
+ }
79
+ else {
80
+ const existing = new Set((await commands.listSessions()).map((s) => s.name));
81
+ // Honour a user-typed name (sanitized to tmux rules, deduped with a numeric suffix); fall back to the
82
+ // generated `<prefix>-<dir>-<n>` when the client sent nothing.
83
+ const wanted = sanitizeSessionName(wantName);
84
+ if (wanted) {
85
+ name = freeSessionName(wanted, existing);
86
+ }
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))
91
+ name = cand;
92
+ }
93
+ }
94
+ if (!name)
95
+ return { error: 'no free session name', status: 409 };
96
+ sid = await commands.newSession(name, o.cwd, cmd);
97
+ wid = (await commands.listWindows(sid))[0]?.id;
98
+ }
99
+ if (!wid)
100
+ return {
101
+ error: 'created window unavailable', status: 500, session: sid, name,
102
+ };
103
+ let up = false;
104
+ let pane = null;
105
+ for (let i = 0; i < pollTries && !up; i++) {
106
+ await delay(pollMs);
107
+ try {
108
+ const p = (await commands.listPanes(wid))[0];
109
+ if (p) {
110
+ pane = p.id;
111
+ if (!isShell(p.command))
112
+ up = true;
113
+ }
114
+ }
115
+ catch { /* window/pane not ready yet */ }
116
+ }
117
+ let killed = false;
118
+ if (kill && up) {
119
+ const still = (await scanFn(scanOpts)).find((x) => x.pid === pid && x.sessionId === sessionId);
120
+ if (still) {
121
+ try {
122
+ killProc(pid, 'SIGTERM');
123
+ killed = true;
124
+ }
125
+ catch { /* already exited */ }
126
+ }
127
+ }
128
+ // claudeUp kept in the response for back-compat with the existing web client; agentUp is the neutral name.
129
+ return { session: sid, name, window: wid, pane, agentUp: up, claudeUp: up, killed, agent: agent.id };
130
+ }
131
+ // Scan the host for orphan agent sessions across every registered driver. Best-effort: any failing
132
+ // sub-command degrades to fewer/no results rather than throwing. `projectsDir`/`sessionsDir` override a
133
+ // specific agent's session dir (each driver declares which option key it reads — used by tests and the
134
+ // server, which pin the dir off the resolved $HOME).
135
+ export async function scanOrphans({ run = defaultRun, home = os.homedir(), busyMs = 8000, now = Date.now, agents = AGENTS, ...dirOverrides } = {}) {
136
+ const [psOut, tmuxOut] = await Promise.all([
137
+ run('ps', ['-Ao', 'pid=,ppid=,stat=,etime=,tty=,args=']),
138
+ run('tmux', ['list-panes', '-a', '-F', tmuxFormat(['pane_tty', 'pane_pid'])]),
139
+ ]);
140
+ const orphans = findOrphans(parseAgentProcs(psOut, agents), parsePaneMembership(tmuxOut));
141
+ const results = [];
142
+ for (const o of orphans) {
143
+ const agent = orphanAgent(o.agent);
144
+ if (!agent)
145
+ continue;
146
+ const cwd = await lsofCwd(run, o.pid);
147
+ const override = dirOverrides[agent.sessions.dirOptKey];
148
+ const dir = typeof override === 'string' && override ? override : agent.sessions.dir(home);
149
+ const meta = cwd ? await agent.sessions.resolve(dir, cwd, { busyMs, now }) : {};
150
+ const cwdLabel = cwd ? path.basename(cwd) : '';
151
+ results.push({
152
+ pid: o.pid,
153
+ agent: agent.id,
154
+ agentLabel: agent.label,
155
+ cwd: cwd || '',
156
+ cwdLabel,
157
+ // Default name the takeover sheet prefills (the same `<prefix>-<dir>-1` the server would auto-pick).
158
+ suggestedName: takeoverSessionName(cwdLabel, 1, agent.takeoverPrefix),
159
+ sessionId: meta.sessionId || null,
160
+ state: meta.state || 'unknown',
161
+ snippet: meta.snippet || '',
162
+ lastActivity: meta.lastActivity || 0,
163
+ startedAt: o.etimeMs ? Math.round(now() - o.etimeMs) : 0,
164
+ });
165
+ }
166
+ return results;
167
+ }
@@ -0,0 +1,42 @@
1
+ const SUBMIT_GAP_MS = 120;
2
+ const paneInputTails = new Map();
3
+ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
4
+ // Every product surface that writes to a tmux pane shares this one per-pane critical section. Keeping
5
+ // text + submit in the same operation prevents two clients from interleaving `paste-buffer` and Enter.
6
+ export async function serializePaneInput(paneId, operation) {
7
+ const previous = paneInputTails.get(paneId) ?? Promise.resolve();
8
+ const current = previous.catch(() => { }).then(operation);
9
+ const tail = current.then(() => { }, () => { });
10
+ paneInputTails.set(paneId, tail);
11
+ try {
12
+ return await current;
13
+ }
14
+ finally {
15
+ if (paneInputTails.get(paneId) === tail)
16
+ paneInputTails.delete(paneId);
17
+ }
18
+ }
19
+ export function sendPanePrompt(commands, paneId, text, guard) {
20
+ return serializePaneInput(paneId, async () => {
21
+ if (guard && !await guard.validate())
22
+ return { nativeMutation: false };
23
+ await commands.exitCopyModeIfActive(paneId);
24
+ await commands.sendText(paneId, text);
25
+ if (text)
26
+ await delay(SUBMIT_GAP_MS);
27
+ await commands.sendEnter(paneId);
28
+ return { nativeMutation: true };
29
+ });
30
+ }
31
+ export function interruptPane(commands, paneId) {
32
+ return serializePaneInput(paneId, async () => {
33
+ await commands.exitCopyModeIfActive(paneId);
34
+ await commands.sendKey(paneId, 'C-c');
35
+ });
36
+ }
37
+ export function sendPaneChoice(commands, paneId, choice) {
38
+ return serializePaneInput(paneId, async () => {
39
+ await commands.exitCopyModeIfActive(paneId);
40
+ await commands.sendText(paneId, choice);
41
+ });
42
+ }
@@ -0,0 +1,194 @@
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
+ // Cursor-selected option line: ❯ (or ›»>) then "N.".
26
+ const CURSOR_RE = /^\s*[❯›»>]\s*\d+\.\s/;
27
+ // Any option row: optional cursor, then "N. label".
28
+ const OPTION_RE = /^\s*[❯›»>]?\s*(\d+)\.\s+(.+?)\s*$/;
29
+ // A description row: indented, not itself an option.
30
+ const isDesc = (line) => /^\s{3,}\S/.test(line) && !OPTION_RE.test(line);
31
+ // A horizontal rule Claude draws inside/around the card (kept within an option block so meta rows past it
32
+ // are still seen and dropped).
33
+ const RULE_RE = /^[\s─━—–\-_=·⎯]{8,}$/;
34
+ // The multi-question tab strip: "← ☒ 水果 ☐ 颜色 ✔ Submit →".
35
+ const TAB_BAR_RE = /✔\s*Submit|[☐☒].*[☐☒]/;
36
+ // Claude's built-in trailing meta-options — not real answers.
37
+ const META_LABELS = new Set(['type something', 'type something.', 'chat about this', 'chat about this.']);
38
+ // Leading decoration on the header line.
39
+ const HEADER_DECOR_RE = /^[\s☐☑☒◯●○·•\-*]+/;
40
+ // A menu footer (single-question / permission), used only as a title boundary — NOT required to detect a menu.
41
+ const FOOTER_RE = /enter to select|esc to (cancel|reject)/i;
42
+ // A Claude activity/spinner line — the top edge of the card, above which is prior transcript.
43
+ const ACTIVITY_RE = /^\s*[✻✳✶✽⏺]\s|worked for|cogitated|crafting|cooked for|crunched for|esc to interrupt/i;
44
+ // The input cursor ❯ leading the user's echoed prompt (above the card).
45
+ const PROMPT_ECHO_RE = /^\s*[❯›»>]\s*\D/; // ❯ NOT followed by a digit (that would be an option)
46
+ const isTitleBoundary = (line) => ACTIVITY_RE.test(line) || RULE_RE.test(line) || TAB_BAR_RE.test(line) || FOOTER_RE.test(line) || PROMPT_ECHO_RE.test(line);
47
+ const stripRight = (value) => String(value == null ? '' : value).replace(/\s+$/, '');
48
+ // Strip ANSI/OSC escapes so a capture taken WITH `-e` (SGR) still parses — belt-and-suspenders even though
49
+ // the endpoint captures plain.
50
+ const stripAnsi = (value) => String(value || '').replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '').replace(/\x1b[[0-9;?]*[ -/]*[@-~]/g, '');
51
+ // The assistant text immediately PRECEDING the menu, scraped because the turn (text + AskUserQuestion) is NOT
52
+ // flushed to the session jsonl until AFTER the user answers (verified live: probe + out-of-order line
53
+ // timestamps) — so the 对话 lens's transcript view structurally can't show it while the gate is up. The title
54
+ // walk below stops at the first boundary (activity spinner / box rule / prompt echo), which is exactly where
55
+ // the lead-in text usually sits. Walk on upward: skip boundary chrome, take the nearest content block, keep
56
+ // its LAST lines (the freshest context, "问题的最后一行"). `fromIdx` = the first line NOT consumed by the
57
+ // title walk, so adjacent lead-in the title already absorbed is never duplicated here.
58
+ const LEADIN_MAX = 2;
59
+ const MSG_MARK_RE = /^\s*⏺/; // ⏺ opens an assistant message in the scrollback — include it, stop there
60
+ function extractLeadIn(lines, fromIdx) {
61
+ const block = [];
62
+ let i = fromIdx;
63
+ // Skip the chrome between the menu and the preceding text (spinner / rules / tab bar / stale footer /
64
+ // blanks) — but never past a ❯ prompt echo (above it is the PREVIOUS exchange, stale context) nor past a
65
+ // ⏺ message head (it IS the text's first line — the collect loop includes it).
66
+ while (i >= 0) {
67
+ const line = lines[i] ?? '';
68
+ if (line.trim() && (!isTitleBoundary(line) || PROMPT_ECHO_RE.test(line) || MSG_MARK_RE.test(line)))
69
+ break;
70
+ i--;
71
+ }
72
+ for (; i >= 0 && block.length < 12; i--) {
73
+ const line = lines[i] ?? '';
74
+ if (!line.trim())
75
+ break; // blank = the block's top (last paragraph)
76
+ if (MSG_MARK_RE.test(line)) {
77
+ block.unshift(line.trim());
78
+ break;
79
+ } // the ⏺ message head: include & stop
80
+ if (isTitleBoundary(line))
81
+ break; // hard chrome (incl. ❯ echo): stop
82
+ block.unshift(line.trim());
83
+ }
84
+ const tail = block.slice(-LEADIN_MAX).map((line) => line.replace(/^⏺\s*/, '').trim()).filter(Boolean);
85
+ return tail.length ? tail.join(' ') : null;
86
+ }
87
+ function parseTabs(line) {
88
+ const tabs = [];
89
+ const re = /([☐☒])\s*(\S+)/g;
90
+ let match;
91
+ while ((match = re.exec(line))) {
92
+ const mark = match[1];
93
+ const label = match[2];
94
+ if (mark && label)
95
+ tabs.push({ label, answered: mark === '☒' });
96
+ }
97
+ return tabs;
98
+ }
99
+ // capture-pane text → a normalized pending prompt, or null when no interactive menu is on screen.
100
+ // { kind:'question'|'permission', title, options:[{n,label,description}], cursor,
101
+ // multi, step, total, submit }
102
+ // `n` is the digit to send to pick that option. For multi, `step`/`total` drive the "第 i/N 题" progress and
103
+ // `submit` marks the final review screen (options are Submit answers / Cancel).
104
+ export function parsePendingPrompt(text) {
105
+ const lines = stripAnsi(text).split('\n').map(stripRight);
106
+ // Anchor on the cursor-selected option (present in every menu: question, permission, review). Take the
107
+ // LAST one so stale menus higher in the scrollback are ignored — the live menu is at the bottom.
108
+ let anchor = -1;
109
+ for (let i = lines.length - 1; i >= 0; i--) {
110
+ if (CURSOR_RE.test(lines[i] ?? '')) {
111
+ anchor = i;
112
+ break;
113
+ }
114
+ }
115
+ if (anchor < 0)
116
+ return null;
117
+ // The option block = the contiguous run of option / description / rule lines around the anchor.
118
+ const inBlock = (line) => OPTION_RE.test(line) || isDesc(line) || RULE_RE.test(line);
119
+ let top = anchor;
120
+ let bot = anchor;
121
+ while (top - 1 >= 0 && inBlock(lines[top - 1] ?? ''))
122
+ top--;
123
+ while (bot + 1 < lines.length && inBlock(lines[bot + 1] ?? ''))
124
+ bot++;
125
+ const options = [];
126
+ let cursor = null;
127
+ for (let i = top; i <= bot; i++) {
128
+ const line = lines[i] ?? '';
129
+ const match = line.match(OPTION_RE);
130
+ const numberText = match?.[1];
131
+ const labelText = match?.[2];
132
+ if (!numberText || !labelText)
133
+ continue;
134
+ const n = Number(numberText);
135
+ const label = labelText.trim();
136
+ if (CURSOR_RE.test(line))
137
+ cursor = n;
138
+ let description = '';
139
+ for (let j = i + 1; j <= bot; j++) {
140
+ const candidate = lines[j] ?? '';
141
+ if (OPTION_RE.test(candidate))
142
+ break;
143
+ if (!isDesc(candidate))
144
+ break;
145
+ description += (description ? ' ' : '') + candidate.trim();
146
+ }
147
+ if (META_LABELS.has(label.toLowerCase()))
148
+ continue; // drop Claude's built-in meta-options
149
+ options.push({ n, label, description });
150
+ }
151
+ if (!options.length)
152
+ return null;
153
+ // Title = header/question text above the block, skipping in-card blanks, stopping at the card's top edge.
154
+ const head = [];
155
+ let headStop = top - 1;
156
+ for (let i = top - 1; i >= 0 && head.length < 5; i--) {
157
+ const line = lines[i] ?? '';
158
+ if (!line.trim()) {
159
+ headStop = i - 1;
160
+ continue;
161
+ }
162
+ if (isTitleBoundary(line)) {
163
+ headStop = i;
164
+ break;
165
+ }
166
+ head.unshift(line);
167
+ headStop = i - 1;
168
+ }
169
+ const title = head.map((line) => line.replace(HEADER_DECOR_RE, '').trim()).filter(Boolean).join(' — ') || '需要你选择';
170
+ // Multi-question: the tab strip drives progress. `answered` ☒ tabs → current step is the next unanswered.
171
+ const tabLine = lines.find((line) => TAB_BAR_RE.test(line));
172
+ const tabs = tabLine ? parseTabs(tabLine) : [];
173
+ const multi = tabs.length > 1;
174
+ const answered = tabs.filter((t) => t.answered).length;
175
+ const firstOption = options[0];
176
+ if (!firstOption)
177
+ return null;
178
+ const submit = /^submit answers?$/i.test(firstOption.label) || (multi && answered >= tabs.length);
179
+ const kind = /do you want to proceed/i.test(title) || /^yes\b/i.test(firstOption.label)
180
+ ? 'permission' : 'question';
181
+ const out = { kind, title, options, cursor };
182
+ // Lead-in context the title walk didn't absorb (it stopped at a boundary or its 5-line cap) — the
183
+ // assistant's last line(s) before the question, shown above the gate so the phone isn't asked blind.
184
+ const leadIn = extractLeadIn(lines, headStop);
185
+ if (leadIn)
186
+ out.leadIn = leadIn;
187
+ if (multi) {
188
+ out.multi = true;
189
+ out.total = tabs.length;
190
+ out.step = Math.min(answered + 1, tabs.length);
191
+ out.submit = submit;
192
+ }
193
+ return out;
194
+ }
@@ -0,0 +1,202 @@
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)
12
+ return false;
13
+ try {
14
+ return tokenEquals(provided, token);
15
+ }
16
+ catch {
17
+ return false;
18
+ }
19
+ }
20
+ // Resolve the on-disk file for a request path under a preview: '' or '<dir>/' → its index.html.
21
+ function fileFor(rest) { return (!rest || rest.endsWith('/')) ? `${rest}index.html` : rest; }
22
+ function previewHeaders(res) {
23
+ res.setHeader('Cache-Control', 'no-store');
24
+ res.setHeader('Referrer-Policy', 'same-origin');
25
+ res.setHeader('X-Content-Type-Options', 'nosniff');
26
+ // Keep preview code isolated even if its capability URL is opened outside the in-app iframe.
27
+ res.setHeader('Content-Security-Policy', 'sandbox allow-scripts allow-forms allow-downloads allow-modals allow-popups');
28
+ // A sandboxed static document intentionally has an opaque origin. Its capability path needs to remain
29
+ // readable by relative fetch(), module scripts, and fonts without restoring same-origin parent access.
30
+ res.setHeader('Access-Control-Allow-Origin', 'null');
31
+ }
32
+ function prefixedRoot(url, prefix) {
33
+ return url.startsWith('/') && !url.startsWith('//') ? `${prefix}${url.slice(1)}` : url;
34
+ }
35
+ function rewriteCssRoots(source, prefix) {
36
+ return source
37
+ .replace(/(url\(\s*["']?)\/(?!\/)/gi, `$1${prefix}`)
38
+ .replace(/(@import\s+["'])\/(?!\/)/gi, `$1${prefix}`);
39
+ }
40
+ function rewriteModuleRoots(source, prefix) {
41
+ return source.replace(/(\b(?:from|import)\s*(?:\(\s*)?)(["'])\/(?!\/)/g, `$1$2${prefix}`);
42
+ }
43
+ export function rewritePreviewText(source, extension, prefix) {
44
+ if (extension === '.css')
45
+ return rewriteCssRoots(source, prefix);
46
+ if (extension === '.js' || extension === '.mjs')
47
+ return rewriteModuleRoots(source, prefix);
48
+ if (extension !== '.html' && extension !== '.htm')
49
+ return source;
50
+ let output = source.replace(/(\b(?:src|href|action|poster|data|formaction)\s*=\s*)(["'])(\/[^"']*)\2/gi, (_match, start, quote, url) => `${start}${quote}${prefixedRoot(url, prefix)}${quote}`);
51
+ output = output.replace(/(\bsrcset\s*=\s*)(["'])([^"']*)\2/gi, (_match, start, quote, value) => {
52
+ const rewritten = value.split(',').map((candidate) => {
53
+ const match = /^(\s*)(\S+)(.*)$/.exec(candidate);
54
+ return match
55
+ ? `${match[1] ?? ''}${prefixedRoot(match[2] ?? '', prefix)}${match[3] ?? ''}`
56
+ : candidate;
57
+ }).join(',');
58
+ return `${start}${quote}${rewritten}${quote}`;
59
+ });
60
+ output = output.replace(/(<style\b[^>]*>)([\s\S]*?)(<\/style>)/gi, (_match, open, css, close) => `${open}${rewriteCssRoots(css, prefix)}${close}`);
61
+ output = output.replace(/(\bstyle\s*=\s*)(["'])([^"']*)\2/gi, (_match, start, quote, css) => `${start}${quote}${rewriteCssRoots(css, prefix)}${quote}`);
62
+ return output.replace(/(<script\b[^>]*\btype\s*=\s*["']module["'][^>]*>)([\s\S]*?)(<\/script>)/gi, (_match, open, script, close) => `${open}${rewriteModuleRoots(script, prefix)}${close}`);
63
+ }
64
+ const errorStatus = (error, fallback) => {
65
+ if (typeof error === 'object' && error !== null && 'statusCode' in error) {
66
+ const statusCode = Number(error.statusCode);
67
+ if (Number.isInteger(statusCode) && statusCode >= 400 && statusCode <= 599)
68
+ return statusCode;
69
+ }
70
+ return fallback;
71
+ };
72
+ const errorCode = (error) => {
73
+ if (typeof error !== 'object' || error === null || !('code' in error))
74
+ return null;
75
+ return typeof error.code === 'string' ? error.code : null;
76
+ };
77
+ export function createPreview({ previews }) {
78
+ const router = express.Router();
79
+ function resolveEntry(name, accessToken, res) {
80
+ const lookup = previews.get(name);
81
+ if (lookup.state === 'missing') {
82
+ res.status(404).type('html').send('<!doctype html><meta charset="utf-8"><h1>预览不存在</h1>');
83
+ return null;
84
+ }
85
+ if (lookup.state === 'expired') {
86
+ res.status(410).type('html').send('<!doctype html><meta charset="utf-8"><h1>预览已过期</h1><p>请回到 app 重新启动预览。</p>');
87
+ return null;
88
+ }
89
+ if (!credentialOk(accessToken, lookup.entry.accessToken)) {
90
+ res.status(401).send('unauthorized');
91
+ return null;
92
+ }
93
+ return lookup.entry;
94
+ }
95
+ async function serve(entry, rest, prefix, res) {
96
+ previewHeaders(res);
97
+ const requested = fileFor(rest);
98
+ const segments = requested.split('/');
99
+ const target = path.resolve(entry.dir, requested);
100
+ const relative = path.relative(entry.dir, target);
101
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative)
102
+ || segments.some((segment) => segment.startsWith('.'))) {
103
+ if (!relative && requested === 'index.html') {
104
+ // The root index is the normal preview entry point.
105
+ }
106
+ else {
107
+ res.status(404).end();
108
+ return;
109
+ }
110
+ }
111
+ const extension = path.extname(target).toLowerCase();
112
+ if (['.html', '.htm', '.css', '.js', '.mjs'].includes(extension)) {
113
+ try {
114
+ const source = await fs.readFile(target, 'utf8');
115
+ res.type(extension).send(rewritePreviewText(source, extension, prefix));
116
+ }
117
+ catch (error) {
118
+ if (!res.headersSent)
119
+ res.status(errorCode(error) === 'ENOENT' ? 404 : 500).end();
120
+ }
121
+ return;
122
+ }
123
+ res.sendFile(requested, { root: entry.dir, dotfiles: 'deny' }, (err) => {
124
+ if (err && !res.headersSent)
125
+ res.status(errorStatus(err, 404)).end();
126
+ });
127
+ }
128
+ // A name without its independent capability is never accepted, including the old ?token=<app-token>
129
+ // shape. Keeping this explicit makes the failure an authentication result instead of an SPA fallback.
130
+ router.get('/:name', (_req, res) => res.status(401).send('unauthorized'));
131
+ router.get('/:name/:accessToken', (req, res) => {
132
+ const name = req.params.name;
133
+ const accessToken = req.params.accessToken;
134
+ if (!name || !accessToken)
135
+ return res.status(401).send('unauthorized');
136
+ const entry = resolveEntry(name, accessToken, res);
137
+ if (!entry || res.headersSent)
138
+ return undefined;
139
+ const prefix = `/preview/${encodeURIComponent(name)}/${encodeURIComponent(accessToken)}/`;
140
+ if (!(req.url.split('?', 1)[0] ?? '').endsWith('/')) {
141
+ return res.redirect(301, prefix);
142
+ }
143
+ return void serve(entry, '', prefix, res);
144
+ });
145
+ router.get('/:name/:accessToken/*', (req, res) => {
146
+ const name = req.params.name;
147
+ const accessToken = req.params.accessToken;
148
+ if (!name || !accessToken)
149
+ return res.status(401).send('unauthorized');
150
+ const entry = resolveEntry(name, accessToken, res);
151
+ if (!entry || res.headersSent)
152
+ return undefined;
153
+ const prefix = `/preview/${encodeURIComponent(name)}/${encodeURIComponent(accessToken)}/`;
154
+ return void serve(entry, req.params[0] ?? '', prefix, res);
155
+ });
156
+ // Referer fallback (mount AFTER /preview, BEFORE express.static): an absolute /assets/... request
157
+ // whose Referer is a preview page is served from that preview's dir. Reuses credOk so it can never
158
+ // read a dir without a valid token/cookie. Misses fall through to the normal static/SPA layer.
159
+ function refererFallback(req, res, next) {
160
+ if (req.method !== 'GET')
161
+ return next();
162
+ if (req.path.startsWith('/api') || req.path.startsWith('/preview'))
163
+ return next();
164
+ const ref = req.headers.referer;
165
+ if (!ref)
166
+ return next();
167
+ let refPath;
168
+ try {
169
+ refPath = new URL(ref).pathname;
170
+ }
171
+ catch {
172
+ return next();
173
+ }
174
+ const m = /^\/preview\/([^/]+)\/([^/]+)\//.exec(refPath);
175
+ if (!m)
176
+ return next();
177
+ const encodedName = m[1];
178
+ const encodedToken = m[2];
179
+ if (!encodedName || !encodedToken)
180
+ return next();
181
+ let name;
182
+ let accessToken;
183
+ try {
184
+ name = decodeURIComponent(encodedName);
185
+ accessToken = decodeURIComponent(encodedToken);
186
+ }
187
+ catch {
188
+ return next();
189
+ }
190
+ const lookup = previews.get(name);
191
+ if (lookup.state !== 'active')
192
+ return next();
193
+ if (!credentialOk(accessToken, lookup.entry.accessToken))
194
+ return next();
195
+ previewHeaders(res);
196
+ res.sendFile(req.path, { root: lookup.entry.dir, dotfiles: 'deny' }, (err) => {
197
+ if (err && !res.headersSent)
198
+ next();
199
+ });
200
+ }
201
+ return { router, refererFallback };
202
+ }