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,262 @@
1
+ // Agent-agnostic scan/parse helpers shared by every agent driver and the orphan engine. This is the LEAF
2
+ // layer: it imports nothing from the agent registry, so drivers (claude.js / codex.js) and the engine
3
+ // (orphans.js) can all depend on it without an import cycle.
4
+ //
5
+ // Everything here is about turning `ps`/`tmux`/`lsof` output and session jsonl files into structured data;
6
+ // none of it knows which coding agent it's looking at — the per-agent specifics (which process name, where
7
+ // sessions live, how to resume) live in the driver descriptors that USE these helpers.
8
+ import { execFile } from 'node:child_process';
9
+ import { promises as fsp } from 'node:fs';
10
+ import path from 'node:path';
11
+ import { parseTmuxRows } from '../tmux/format.js';
12
+ const isRecord = (value) => value !== null && typeof value === 'object';
13
+ // Tolerant promisified execFile: resolves '' on any error (no server, missing binary, non-zero exit).
14
+ // Detection is best-effort and must never throw the whole request.
15
+ export function defaultRun(cmd, args) {
16
+ return new Promise((resolve) => {
17
+ execFile(cmd, args, { maxBuffer: 8 * 1024 * 1024 }, (err, stdout) => {
18
+ resolve(err ? '' : String(stdout));
19
+ });
20
+ });
21
+ }
22
+ // A coding-agent session id is a UUID (Claude's jsonl filename; Codex's rollout-file trailing id). Validate
23
+ // strictly: takeover types `<bin> resume <id>` into a shell via send-keys, so a non-UUID id would be a
24
+ // shell-injection vector.
25
+ export const isSessionUuid = (value) => typeof value === 'string'
26
+ && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
27
+ // Strip /dev/ and fold "no controlling terminal" markers (macOS '??', Linux '?') to '' so ps ttys and
28
+ // tmux pane_ttys compare equal: ps 'ttys010' / tmux '/dev/ttys010' → 'ttys010'; ps 'pts/3' / tmux
29
+ // '/dev/pts/3' → 'pts/3'.
30
+ export function normTty(value) {
31
+ const s = String(value || '').trim();
32
+ if (!s || s === '??' || s === '?' || s === '-')
33
+ return '';
34
+ return s.replace(/^\/dev\//, '');
35
+ }
36
+ // ps `etime` (elapsed since start) → milliseconds. Formats (macOS + Linux, no spaces): "MM:SS",
37
+ // "HH:MM:SS", "DD-HH:MM:SS". Used only to derive a startedAt for display/recognition (the "A加成"),
38
+ // NOT for session attribution — a resumed session's process starts long after its jsonl's first event.
39
+ export function etimeToMs(etime) {
40
+ const s = String(etime).trim();
41
+ if (!s)
42
+ return 0;
43
+ let days = 0;
44
+ let rest = s;
45
+ const dash = s.indexOf('-');
46
+ if (dash >= 0) {
47
+ days = Number(s.slice(0, dash)) || 0;
48
+ rest = s.slice(dash + 1);
49
+ }
50
+ let sec = 0;
51
+ for (const p of rest.split(':'))
52
+ sec = sec * 60 + (Number(p) || 0);
53
+ return (days * 86400 + sec) * 1000;
54
+ }
55
+ // Parse `ps -Ao pid=,ppid=,stat=,etime=,tty=,args=` → LIVE agent processes only, each tagged with the id of
56
+ // the FIRST driver whose `procMatch` regex matches its argv. `agents` is the driver list; a proc matching
57
+ // none is dropped. args (last column) may contain spaces; etime has none. STOPPED (STAT 'T', a Ctrl-Z-
58
+ // suspended job-control stack — verified real: one terminal can hold 8 suspended `claude`s) and ZOMBIE
59
+ // ('Z') processes are dropped: they aren't active sessions to steer, and a suspended original can't write
60
+ // its jsonl so there's nothing to race.
61
+ export function parseAgentProcs(psOut, agents) {
62
+ const out = [];
63
+ for (const line of String(psOut).split('\n')) {
64
+ const m = line.match(/^\s*(\d+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(.*)$/);
65
+ if (!m)
66
+ continue;
67
+ const [, pid, ppid, stat, etime, tty, rawArgs] = m;
68
+ if (!pid || !ppid || !stat || !etime || !tty || rawArgs === undefined)
69
+ continue;
70
+ if (stat.startsWith('T') || stat.startsWith('Z'))
71
+ continue;
72
+ const args = rawArgs.trim();
73
+ const agent = agents.find((a) => a.procMatch.test(args));
74
+ if (!agent)
75
+ continue;
76
+ out.push({ pid: Number(pid), ppid: Number(ppid), etimeMs: etimeToMs(etime), tty: normTty(tty), args, agent: agent.id });
77
+ }
78
+ return out;
79
+ }
80
+ // Parse q-escaped `tmux list-panes` rows → the set of pane ttys and pane (shell) pids.
81
+ export function parsePaneMembership(tmuxOut) {
82
+ const ttys = new Set();
83
+ const pids = new Set();
84
+ for (const [tty, pid] of parseTmuxRows(tmuxOut, 2, 'pane membership')) {
85
+ const nt = normTty(tty);
86
+ if (nt)
87
+ ttys.add(nt);
88
+ const n = Number(pid);
89
+ if (n)
90
+ pids.add(n);
91
+ }
92
+ return { ttys, pids };
93
+ }
94
+ // Orphan = an agent proc WITH a real controlling tty that is neither one of tmux's pane ttys nor a child of
95
+ // a pane's shell. The tty requirement drops background/headless runs (SDK/`-p`/`exec` piped, tty '') —
96
+ // those aren't interactive sessions a user would "take over".
97
+ export function findOrphans(procs, membership) {
98
+ return procs.filter((p) => p.tty && !membership.ttys.has(p.tty) && !membership.pids.has(p.ppid));
99
+ }
100
+ // A real cwd → its ~/.claude/projects directory name. Claude replaces every non-alphanumeric char with
101
+ // '-' (verified: '/home/user/handmux' → '-home-user-handmux'; both '/'
102
+ // and '_' fold to '-'). The mapping is LOSSY (not reversible), so we only ever encode forward, then
103
+ // confirm each candidate jsonl's recorded `cwd` matches before trusting it.
104
+ export function encodeProjectDir(cwd) {
105
+ return String(cwd).replace(/[^A-Za-z0-9]/g, '-');
106
+ }
107
+ // Read the last `bytes` of a file (for the trailing conversation). The first line of the chunk may be
108
+ // truncated mid-JSON — callers skip lines that don't parse.
109
+ export async function readTail(file, bytes = 65536) {
110
+ const fh = await fsp.open(file, 'r');
111
+ try {
112
+ const { size } = await fh.stat();
113
+ const start = Math.max(0, size - bytes);
114
+ const len = size - start;
115
+ if (len <= 0)
116
+ return '';
117
+ const buf = Buffer.alloc(len);
118
+ await fh.read(buf, 0, len, start);
119
+ return buf.toString('utf8');
120
+ }
121
+ finally {
122
+ await fh.close();
123
+ }
124
+ }
125
+ // Read the first `bytes` of a file (the session header carries `cwd` early).
126
+ export async function readHead(file, bytes = 65536) {
127
+ const fh = await fsp.open(file, 'r');
128
+ try {
129
+ const buf = Buffer.alloc(bytes);
130
+ const { bytesRead } = await fh.read(buf, 0, bytes, 0);
131
+ return buf.slice(0, bytesRead).toString('utf8');
132
+ }
133
+ finally {
134
+ await fh.close();
135
+ }
136
+ }
137
+ // First `"cwd":"..."` anywhere in a chunk (both Claude and Codex record the session cwd early in the file).
138
+ export function firstCwd(headText) {
139
+ const m = String(headText).match(/"cwd"\s*:\s*"((?:[^"\\]|\\.)*)"/);
140
+ const encoded = m?.[1];
141
+ if (encoded === undefined)
142
+ return '';
143
+ try {
144
+ const parsed = JSON.parse(`"${encoded}"`);
145
+ return typeof parsed === 'string' ? parsed : encoded;
146
+ }
147
+ catch {
148
+ return encoded;
149
+ }
150
+ }
151
+ // Pull the last user-typed message text out of a Claude jsonl tail, for a recognizable one-line label.
152
+ // Entries are line-delimited JSON; conversational turns are type 'user' with message.role 'user', whose
153
+ // content is either a string or an array of blocks ({type:'text',text}). Meta rows (last-prompt/ai-title/
154
+ // mode/attachment/summary) are ignored. Scans newest-first.
155
+ export function lastUserSnippet(tailText, max = 80) {
156
+ const rows = String(tailText).split('\n');
157
+ for (let i = rows.length - 1; i >= 0; i--) {
158
+ const line = rows[i]?.trim() ?? '';
159
+ if (!line || line[0] !== '{')
160
+ continue;
161
+ let data;
162
+ try {
163
+ data = JSON.parse(line);
164
+ }
165
+ catch {
166
+ continue;
167
+ }
168
+ if (!isRecord(data) || data.type !== 'user' || !isRecord(data.message) || data.message.role !== 'user')
169
+ continue;
170
+ const c = data.message.content;
171
+ let text = '';
172
+ if (typeof c === 'string')
173
+ text = c;
174
+ else if (Array.isArray(c))
175
+ text = c
176
+ .filter((block) => isRecord(block) && block.type === 'text')
177
+ .map((block) => typeof block.text === 'string' ? block.text : '')
178
+ .join(' ');
179
+ text = text.replace(/\s+/g, ' ').trim();
180
+ if (text)
181
+ return text.length > max ? `${text.slice(0, max)}…` : text;
182
+ }
183
+ return '';
184
+ }
185
+ // Resolve a cwd to the newest jsonl in its ENCODED project dir whose recorded cwd matches (guards against
186
+ // the lossy encoding colliding two real paths). This is Claude's layout (~/.claude/projects/<enc-cwd>/
187
+ // <uuid>.jsonl); Codex has its own resolver. `snippet` extracts the last user turn (parser injected so
188
+ // each agent's jsonl shape is handled). Returns sessionId, a busy/idle guess (mtime recency), the snippet,
189
+ // and the last-activity timestamp; {} when the dir is absent or nothing matches.
190
+ export async function resolveEncodedDirSession(projectsDir, cwd, { busyMs = 8000, now = Date.now, snippet = lastUserSnippet } = {}) {
191
+ const dir = path.join(projectsDir, encodeProjectDir(cwd));
192
+ let names;
193
+ try {
194
+ names = (await fsp.readdir(dir)).filter((n) => n.endsWith('.jsonl'));
195
+ }
196
+ catch {
197
+ return {};
198
+ }
199
+ const stats = [];
200
+ for (const n of names) {
201
+ try {
202
+ stats.push({ n, mtime: (await fsp.stat(path.join(dir, n))).mtimeMs });
203
+ }
204
+ catch { /* gone */ }
205
+ }
206
+ stats.sort((a, b) => b.mtime - a.mtime);
207
+ for (const { n, mtime } of stats) {
208
+ const file = path.join(dir, n);
209
+ let head;
210
+ try {
211
+ head = await readHead(file);
212
+ }
213
+ catch {
214
+ continue;
215
+ }
216
+ if (firstCwd(head) !== cwd)
217
+ continue; // different real path collided onto the same dir → skip
218
+ let snip = '';
219
+ try {
220
+ snip = snippet(await readTail(file));
221
+ }
222
+ catch { /* best effort */ }
223
+ return {
224
+ sessionId: n.replace(/\.jsonl$/, ''),
225
+ state: now() - mtime < busyMs ? 'busy' : 'idle',
226
+ snippet: snip,
227
+ lastActivity: Math.round(mtime),
228
+ };
229
+ }
230
+ return {};
231
+ }
232
+ // A tmux session name derived from a cwd basename, kept within isValidSessionName ([A-Za-z0-9-], ≤16):
233
+ // `<prefix>-<alnum label, ≤8>-<n>`. n disambiguates against existing sessions.
234
+ export function takeoverSessionName(cwdLabel, n, prefix = 'cc') {
235
+ const base = String(cwdLabel || '').replace(/[^A-Za-z0-9]/g, '').slice(0, 8) || prefix;
236
+ return `${prefix}-${base}-${n}`.slice(0, 16);
237
+ }
238
+ // Coerce a user-typed takeover name into a valid tmux session name (isValidSessionName: [A-Za-z0-9-], ≤16):
239
+ // non-alnum runs → a single '-', trimmed of edge hyphens, capped at 16. Returns '' if nothing usable is
240
+ // left (caller then falls back to the generated name).
241
+ export function sanitizeSessionName(s) {
242
+ return String(s || '').replace(/[^A-Za-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 16).replace(/-+$/, '');
243
+ }
244
+ // First name in `<base>, base-2, base-3, …` (each capped at 16) that isn't already taken. null if none free.
245
+ export function freeSessionName(base, taken) {
246
+ if (!taken.has(base))
247
+ return base;
248
+ for (let i = 2; i < 1000; i++) {
249
+ const cand = `${base}-${i}`.slice(0, 16);
250
+ if (!taken.has(cand))
251
+ return cand;
252
+ }
253
+ return null;
254
+ }
255
+ export const isShell = (command) => /^-?(zsh|bash|sh|fish|dash|tcsh|csh|ksh)$/.test(String(command || ''));
256
+ export async function lsofCwd(run, pid) {
257
+ const out = await run('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn']);
258
+ for (const line of out.split('\n'))
259
+ if (line[0] === 'n')
260
+ return line.slice(1).trim();
261
+ return '';
262
+ }
@@ -0,0 +1,194 @@
1
+ export const providerTypes = ['deepseek', 'moonshot'];
2
+ export function isProviderType(value) {
3
+ return typeof value === 'string' && providerTypes.includes(value);
4
+ }
5
+ export class ProviderQueryError extends Error {
6
+ code;
7
+ retryAfterSeconds;
8
+ constructor(code, retryAfterSeconds) {
9
+ super(code);
10
+ this.name = 'ProviderQueryError';
11
+ this.code = code;
12
+ if (retryAfterSeconds !== undefined)
13
+ this.retryAfterSeconds = retryAfterSeconds;
14
+ }
15
+ }
16
+ const DECIMAL = /^\d{1,128}(?:\.\d{1,128})?$/;
17
+ const MAX_RESPONSE_BYTES = 64 * 1024;
18
+ const MAX_MOONSHOT_BALANCE = 1_000_000_000_000_000;
19
+ function isRecord(value) {
20
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
21
+ }
22
+ function hasExactKeys(value, keys) {
23
+ const actual = Object.keys(value).sort();
24
+ const expected = [...keys].sort();
25
+ return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
26
+ }
27
+ async function boundedResponseText(response) {
28
+ const declared = Number(response.headers.get('content-length'));
29
+ if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES)
30
+ throw new ProviderQueryError('unsupported_response');
31
+ if (!response.body)
32
+ return '';
33
+ const reader = response.body.getReader();
34
+ const chunks = [];
35
+ let size = 0;
36
+ while (true) {
37
+ const { done, value } = await reader.read();
38
+ if (done)
39
+ break;
40
+ size += value.byteLength;
41
+ if (size > MAX_RESPONSE_BYTES) {
42
+ await reader.cancel();
43
+ throw new ProviderQueryError('unsupported_response');
44
+ }
45
+ chunks.push(value);
46
+ }
47
+ const bytes = new Uint8Array(size);
48
+ let offset = 0;
49
+ for (const chunk of chunks) {
50
+ bytes.set(chunk, offset);
51
+ offset += chunk.byteLength;
52
+ }
53
+ return new TextDecoder().decode(bytes);
54
+ }
55
+ function decimalString(value) {
56
+ return typeof value === 'string' && DECIMAL.test(value);
57
+ }
58
+ function boundedAmount(value, allowNegative = false) {
59
+ return typeof value === 'number' && Number.isFinite(value) && Math.abs(value) <= MAX_MOONSHOT_BALANCE
60
+ && (allowNegative || value >= 0);
61
+ }
62
+ function parseDeepSeekBalance(value) {
63
+ if (!isRecord(value) || typeof value.is_available !== 'boolean'
64
+ || !Array.isArray(value.balance_infos) || value.balance_infos.length > 32) {
65
+ throw new ProviderQueryError('unsupported_response');
66
+ }
67
+ const balances = value.balance_infos.map((candidate) => {
68
+ if (!isRecord(candidate)
69
+ || !hasExactKeys(candidate, ['currency', 'total_balance', 'topped_up_balance', 'granted_balance'])
70
+ || typeof candidate.currency !== 'string'
71
+ || candidate.currency.length < 1 || candidate.currency.length > 32
72
+ || !decimalString(candidate.total_balance)
73
+ || !decimalString(candidate.topped_up_balance)
74
+ || !decimalString(candidate.granted_balance)) {
75
+ throw new ProviderQueryError('unsupported_response');
76
+ }
77
+ return {
78
+ currency: candidate.currency,
79
+ totalBalance: candidate.total_balance,
80
+ toppedUpBalance: candidate.topped_up_balance,
81
+ grantedBalance: candidate.granted_balance,
82
+ };
83
+ });
84
+ return { providerType: 'deepseek', isAvailable: value.is_available, balances };
85
+ }
86
+ function parseMoonshotBalance(value) {
87
+ if (!isRecord(value) || !hasExactKeys(value, ['code', 'data', 'scode', 'status'])
88
+ || value.code !== 0 || value.status !== true || typeof value.scode !== 'string') {
89
+ throw new ProviderQueryError('unsupported_response');
90
+ }
91
+ const data = value.data;
92
+ if (!isRecord(data) || !hasExactKeys(data, ['available_balance', 'voucher_balance', 'cash_balance'])
93
+ || !boundedAmount(data.available_balance) || !boundedAmount(data.voucher_balance)
94
+ || !boundedAmount(data.cash_balance, true)) {
95
+ throw new ProviderQueryError('unsupported_response');
96
+ }
97
+ return {
98
+ providerType: 'moonshot', currency: 'CNY',
99
+ availableBalance: data.available_balance,
100
+ voucherBalance: data.voucher_balance,
101
+ cashBalance: data.cash_balance,
102
+ };
103
+ }
104
+ function createApiKeyProvider({ type, label, endpoint, parse, fetchImpl, }) {
105
+ return {
106
+ type, label,
107
+ async queryBalance(credential, signal) {
108
+ let response;
109
+ try {
110
+ response = await fetchImpl(endpoint, {
111
+ method: 'GET', signal,
112
+ headers: { Accept: 'application/json', Authorization: `Bearer ${credential.value}` },
113
+ });
114
+ }
115
+ catch (error) {
116
+ if (signal.aborted || (error instanceof Error && error.name === 'AbortError')) {
117
+ throw new ProviderQueryError('provider_timeout');
118
+ }
119
+ throw new ProviderQueryError('provider_unreachable');
120
+ }
121
+ if (response.status === 401 || response.status === 403)
122
+ throw new ProviderQueryError('invalid_credential');
123
+ if (response.status === 429) {
124
+ const raw = Number(response.headers.get('retry-after'));
125
+ throw new ProviderQueryError('rate_limited', Number.isFinite(raw) && raw > 0 ? Math.ceil(raw) : undefined);
126
+ }
127
+ if (!response.ok)
128
+ throw new ProviderQueryError('provider_unreachable');
129
+ const text = await boundedResponseText(response);
130
+ let parsed;
131
+ try {
132
+ parsed = JSON.parse(text);
133
+ }
134
+ catch {
135
+ throw new ProviderQueryError('unsupported_response');
136
+ }
137
+ return parse(parsed);
138
+ },
139
+ };
140
+ }
141
+ export function createDeepSeekProvider(fetchImpl = fetch) {
142
+ return createApiKeyProvider({
143
+ type: 'deepseek', label: 'DeepSeek', endpoint: 'https://api.deepseek.com/user/balance',
144
+ parse: parseDeepSeekBalance, fetchImpl,
145
+ });
146
+ }
147
+ export function createMoonshotProvider(fetchImpl = fetch) {
148
+ return createApiKeyProvider({
149
+ type: 'moonshot', label: 'Moonshot (Kimi)', endpoint: 'https://api.moonshot.cn/v1/users/me/balance',
150
+ parse: parseMoonshotBalance, fetchImpl,
151
+ });
152
+ }
153
+ export const builtInApiAccountProviders = () => [
154
+ createDeepSeekProvider(), createMoonshotProvider(),
155
+ ];
156
+ function validDeepSeekResult(value) {
157
+ return hasExactKeys(value, ['providerType', 'isAvailable', 'balances'])
158
+ && value.providerType === 'deepseek' && typeof value.isAvailable === 'boolean'
159
+ && Array.isArray(value.balances) && value.balances.length <= 32
160
+ && value.balances.every((balance) => isRecord(balance)
161
+ && hasExactKeys(balance, ['currency', 'totalBalance', 'toppedUpBalance', 'grantedBalance'])
162
+ && typeof balance.currency === 'string' && balance.currency.length >= 1 && balance.currency.length <= 32
163
+ && decimalString(balance.totalBalance) && decimalString(balance.toppedUpBalance)
164
+ && decimalString(balance.grantedBalance));
165
+ }
166
+ function validMoonshotResult(value) {
167
+ return hasExactKeys(value, [
168
+ 'providerType', 'currency', 'availableBalance', 'voucherBalance', 'cashBalance',
169
+ ]) && value.providerType === 'moonshot' && value.currency === 'CNY'
170
+ && boundedAmount(value.availableBalance) && boundedAmount(value.voucherBalance)
171
+ && boundedAmount(value.cashBalance, true);
172
+ }
173
+ export function validProviderResult(value, providerType) {
174
+ if (!isRecord(value))
175
+ return false;
176
+ const valid = value.providerType === 'deepseek' ? validDeepSeekResult(value)
177
+ : value.providerType === 'moonshot' ? validMoonshotResult(value) : false;
178
+ return valid && (providerType === undefined || value.providerType === providerType);
179
+ }
180
+ export function publicProviderResult(result) {
181
+ switch (result.providerType) {
182
+ case 'deepseek': return {
183
+ providerType: 'deepseek', isAvailable: result.isAvailable,
184
+ balances: result.balances.map((balance) => ({
185
+ currency: balance.currency, totalBalance: balance.totalBalance,
186
+ toppedUpBalance: balance.toppedUpBalance, grantedBalance: balance.grantedBalance,
187
+ })),
188
+ };
189
+ case 'moonshot': return {
190
+ providerType: 'moonshot', currency: 'CNY', availableBalance: result.availableBalance,
191
+ voucherBalance: result.voucherBalance, cashBalance: result.cashBalance,
192
+ };
193
+ }
194
+ }