handmux 0.23.0 → 0.24.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 (252) hide show
  1. package/README.md +3 -3
  2. package/README.zh-CN.md +3 -3
  3. package/bin/handmux.js +3 -689
  4. package/dist/bin/handmux.js +850 -0
  5. package/{hooks → dist/hooks}/handmux-statusline.cjs +28 -6
  6. package/{hooks → dist/hooks}/handmux-write.cjs +24 -3
  7. package/dist/package.json +126 -0
  8. package/dist/public/assets/index-CicdY9E9.css +32 -0
  9. package/dist/public/assets/index-D4VP5t8h.js +337 -0
  10. package/dist/public/assets/pcm-worklet-CxcDy7PB.js +1 -0
  11. package/{public → dist/public}/index.html +5 -4
  12. package/{src → dist/src}/agents/claude.js +105 -83
  13. package/dist/src/agents/codex.js +217 -0
  14. package/{src → dist/src}/agents/index.js +7 -7
  15. package/dist/src/agents/processIdentity.js +80 -0
  16. package/dist/src/agents/scanUtils.js +262 -0
  17. package/dist/src/apiErrors.js +77 -0
  18. package/{src → dist/src}/appName.js +12 -13
  19. package/dist/src/asr/iflyConfig.js +7 -0
  20. package/dist/src/asr/iflySign.js +14 -0
  21. package/dist/src/auth.js +27 -0
  22. package/dist/src/browser/bootstrap.js +72 -0
  23. package/dist/src/browser/cookieProfiles.js +413 -0
  24. package/dist/src/browser/coordinator.js +51 -0
  25. package/dist/src/browser/hammerheadCompat.js +104 -0
  26. package/dist/src/browser/hammerheadRedirectCompat.js +37 -0
  27. package/dist/src/browser/manager.js +560 -0
  28. package/dist/src/browser/originLabel.js +15 -0
  29. package/dist/src/browser/profilePersistence.js +235 -0
  30. package/dist/src/browser/publicProxy.js +176 -0
  31. package/dist/src/browser/routes.js +159 -0
  32. package/dist/src/browser/sessionStore.js +72 -0
  33. package/dist/src/browser/siteVersion.js +121 -0
  34. package/dist/src/browser/targetPolicy.js +163 -0
  35. package/{src → dist/src}/browser/worker.js +14 -18
  36. package/dist/src/browser/workerClient.js +286 -0
  37. package/dist/src/browser/workerServer.js +91 -0
  38. package/dist/src/captureBackground.js +95 -0
  39. package/dist/src/claudeEvents.js +499 -0
  40. package/{src → dist/src}/cli/claudeHooks.js +146 -108
  41. package/{src → dist/src}/cli/cloudflareUrl.js +2 -3
  42. package/dist/src/cli/cloudflared.js +106 -0
  43. package/dist/src/cli/codexManaged.js +77 -0
  44. package/{src → dist/src}/cli/cpolarUrl.js +8 -9
  45. package/dist/src/cli/drivers.js +98 -0
  46. package/dist/src/cli/hookScaffold.js +36 -0
  47. package/dist/src/cli/i18n/en.js +366 -0
  48. package/{src → dist/src}/cli/i18n/index.js +27 -25
  49. package/dist/src/cli/i18n/zh.js +365 -0
  50. package/dist/src/cli/legacyCodexHooks.js +52 -0
  51. package/{src → dist/src}/cli/natappUrl.js +2 -3
  52. package/dist/src/cli/options.js +245 -0
  53. package/dist/src/cli/probe.js +16 -0
  54. package/{src → dist/src}/cli/prompt.js +4 -6
  55. package/dist/src/cli/pushCmd.js +102 -0
  56. package/{src → dist/src}/cli/qr.js +19 -19
  57. package/dist/src/cli/service.js +144 -0
  58. package/dist/src/cli/setupModel.js +194 -0
  59. package/dist/src/cli/setupWizard.js +647 -0
  60. package/dist/src/cli/shortcutEditor.js +351 -0
  61. package/dist/src/cli/sshTunnel.js +18 -0
  62. package/dist/src/cli/state.js +114 -0
  63. package/{src → dist/src}/cli/statusLine.js +63 -46
  64. package/dist/src/cli/supervisor.js +278 -0
  65. package/dist/src/cli/supervisorHealth.js +21 -0
  66. package/dist/src/cli/supervisorLaunch.js +148 -0
  67. package/dist/src/cli/supervisorProcesses.js +62 -0
  68. package/dist/src/cli/supervisorState.js +62 -0
  69. package/dist/src/cli/tmuxVersion.js +53 -0
  70. package/{src → dist/src}/cli/tunlite.js +7 -10
  71. package/dist/src/cli/tunnelClients.js +72 -0
  72. package/{src → dist/src}/cli/updateCheck.js +98 -69
  73. package/{src → dist/src}/cli/urlHost.js +8 -4
  74. package/dist/src/cli/workspaceCmd.js +347 -0
  75. package/dist/src/codexAppServer.js +2299 -0
  76. package/dist/src/codexConversationProjection.js +199 -0
  77. package/dist/src/codexDiff.js +118 -0
  78. package/dist/src/codexMessageIdentity.js +18 -0
  79. package/dist/src/codexPlan.js +30 -0
  80. package/dist/src/codexQueueProtocol.js +96 -0
  81. package/dist/src/codexStreamProtocol.js +207 -0
  82. package/dist/src/codexToolProtocol.js +68 -0
  83. package/dist/src/codexTranscriptParse.js +724 -0
  84. package/dist/src/codexUsageSnapshot.js +205 -0
  85. package/dist/src/config.js +8 -0
  86. package/{src → dist/src}/docPath.js +22 -24
  87. package/dist/src/docs.js +337 -0
  88. package/dist/src/git.js +272 -0
  89. package/dist/src/healthProtocol.js +97 -0
  90. package/dist/src/httpApi.js +68 -0
  91. package/dist/src/jsonStore.js +15 -0
  92. package/{src → dist/src}/keyNames.js +3 -3
  93. package/dist/src/notifications.js +111 -0
  94. package/dist/src/orphans.js +157 -0
  95. package/dist/src/pendingPrompt.js +194 -0
  96. package/dist/src/previewServer.js +202 -0
  97. package/dist/src/previews.js +179 -0
  98. package/dist/src/privateStateStore.js +89 -0
  99. package/dist/src/push.js +224 -0
  100. package/dist/src/routes/codex.js +606 -0
  101. package/dist/src/routes/files.js +230 -0
  102. package/dist/src/routes/git.js +78 -0
  103. package/dist/src/routes/health.js +16 -0
  104. package/dist/src/routes/notifications.js +16 -0
  105. package/dist/src/routes/previews.js +43 -0
  106. package/dist/src/routes/push.js +145 -0
  107. package/dist/src/routes/sessions.js +232 -0
  108. package/dist/src/routes/system.js +156 -0
  109. package/dist/src/routes/terminal.js +238 -0
  110. package/dist/src/routes/transcript.js +197 -0
  111. package/dist/src/routes/workspace.js +267 -0
  112. package/{src → dist/src}/server.js +100 -74
  113. package/dist/src/shortcutConfig.js +83 -0
  114. package/{src → dist/src}/staticCache.js +5 -3
  115. package/{src → dist/src}/staticCompression.js +5 -6
  116. package/dist/src/terminalStream.js +441 -0
  117. package/{src → dist/src}/tmux/commands.js +156 -171
  118. package/dist/src/tmux/format.js +47 -0
  119. package/dist/src/transcriptParse.js +240 -0
  120. package/dist/src/transcriptReader.js +113 -0
  121. package/{src → dist/src}/trimCapture.js +11 -11
  122. package/dist/src/uploadTypes.js +29 -0
  123. package/dist/src/urlPolicy.js +18 -0
  124. package/dist/src/usage.js +260 -0
  125. package/dist/src/workspace/agentRunner.js +204 -0
  126. package/dist/src/workspace/atomicJson.js +31 -0
  127. package/dist/src/workspace/capture.js +139 -0
  128. package/dist/src/workspace/checkpointer.js +228 -0
  129. package/dist/src/workspace/environment.js +71 -0
  130. package/dist/src/workspace/lock.js +137 -0
  131. package/dist/src/workspace/mapping.js +128 -0
  132. package/dist/src/workspace/operations.js +271 -0
  133. package/dist/src/workspace/paths.js +15 -0
  134. package/dist/src/workspace/planner.js +292 -0
  135. package/dist/src/workspace/restore.js +314 -0
  136. package/dist/src/workspace/runtime.js +318 -0
  137. package/dist/src/workspace/schema.js +156 -0
  138. package/dist/src/workspace/store.js +464 -0
  139. package/dist/src/workspace/tmuxAdapter.js +694 -0
  140. package/dist/src/workspaceProtocol.js +158 -0
  141. package/package.json +27 -15
  142. package/public/assets/index-C3N7OkyP.css +0 -32
  143. package/public/assets/index-D1DWcC5u.js +0 -337
  144. package/src/.gitkeep +0 -0
  145. package/src/agents/codex.js +0 -168
  146. package/src/agents/processIdentity.js +0 -72
  147. package/src/agents/scanUtils.js +0 -225
  148. package/src/asr/iflyConfig.js +0 -10
  149. package/src/asr/iflySign.js +0 -16
  150. package/src/auth.js +0 -30
  151. package/src/browser/bootstrap.js +0 -68
  152. package/src/browser/cookieProfiles.js +0 -403
  153. package/src/browser/coordinator.js +0 -47
  154. package/src/browser/hammerheadCompat.js +0 -86
  155. package/src/browser/hammerheadRedirectCompat.js +0 -38
  156. package/src/browser/manager.js +0 -556
  157. package/src/browser/originLabel.js +0 -17
  158. package/src/browser/profilePersistence.js +0 -241
  159. package/src/browser/publicProxy.js +0 -175
  160. package/src/browser/routes.js +0 -154
  161. package/src/browser/sessionStore.js +0 -78
  162. package/src/browser/siteVersion.js +0 -124
  163. package/src/browser/targetPolicy.js +0 -137
  164. package/src/browser/workerClient.js +0 -253
  165. package/src/browser/workerServer.js +0 -98
  166. package/src/captureBackground.js +0 -93
  167. package/src/claudeEvents.js +0 -399
  168. package/src/cli/cloudflared.js +0 -99
  169. package/src/cli/codexManaged.js +0 -77
  170. package/src/cli/drivers.js +0 -97
  171. package/src/cli/hookScaffold.js +0 -33
  172. package/src/cli/i18n/en.js +0 -385
  173. package/src/cli/i18n/zh.js +0 -384
  174. package/src/cli/legacyCodexHooks.js +0 -49
  175. package/src/cli/options.js +0 -211
  176. package/src/cli/probe.js +0 -16
  177. package/src/cli/pushCmd.js +0 -60
  178. package/src/cli/service.js +0 -126
  179. package/src/cli/setupModel.js +0 -154
  180. package/src/cli/setupWizard.js +0 -506
  181. package/src/cli/shortcutEditor.js +0 -288
  182. package/src/cli/sshTunnel.js +0 -12
  183. package/src/cli/state.js +0 -90
  184. package/src/cli/supervisor.js +0 -183
  185. package/src/cli/supervisorProcesses.js +0 -57
  186. package/src/cli/tmuxVersion.js +0 -49
  187. package/src/cli/tunnelClients.js +0 -62
  188. package/src/cli/workspaceCmd.js +0 -300
  189. package/src/codexAppServer.js +0 -1620
  190. package/src/codexDiff.js +0 -108
  191. package/src/codexTranscriptParse.js +0 -413
  192. package/src/codexUsageSnapshot.cjs +0 -129
  193. package/src/config.js +0 -9
  194. package/src/docs.js +0 -228
  195. package/src/git.js +0 -208
  196. package/src/httpApi.js +0 -72
  197. package/src/jsonStore.js +0 -24
  198. package/src/notifications.js +0 -68
  199. package/src/orphans.js +0 -152
  200. package/src/pendingPrompt.js +0 -163
  201. package/src/previewServer.js +0 -168
  202. package/src/previews.js +0 -116
  203. package/src/push.js +0 -183
  204. package/src/routes/codex.js +0 -473
  205. package/src/routes/files.js +0 -172
  206. package/src/routes/git.js +0 -57
  207. package/src/routes/notifications.js +0 -19
  208. package/src/routes/previews.js +0 -35
  209. package/src/routes/push.js +0 -119
  210. package/src/routes/sessions.js +0 -168
  211. package/src/routes/system.js +0 -132
  212. package/src/routes/terminal.js +0 -210
  213. package/src/routes/transcript.js +0 -171
  214. package/src/routes/workspace.js +0 -267
  215. package/src/shortcutConfig.js +0 -75
  216. package/src/terminalStream.js +0 -391
  217. package/src/tmux/format.js +0 -45
  218. package/src/transcriptParse.js +0 -180
  219. package/src/transcriptReader.js +0 -101
  220. package/src/uploadTypes.js +0 -30
  221. package/src/urlPolicy.js +0 -14
  222. package/src/usage.js +0 -260
  223. package/src/workspace/agentRunner.js +0 -188
  224. package/src/workspace/atomicJson.js +0 -32
  225. package/src/workspace/capture.js +0 -139
  226. package/src/workspace/checkpointer.js +0 -187
  227. package/src/workspace/environment.js +0 -75
  228. package/src/workspace/lock.js +0 -128
  229. package/src/workspace/mapping.js +0 -124
  230. package/src/workspace/operations.js +0 -252
  231. package/src/workspace/paths.js +0 -16
  232. package/src/workspace/planner.js +0 -247
  233. package/src/workspace/restore.js +0 -279
  234. package/src/workspace/runtime.js +0 -314
  235. package/src/workspace/schema.js +0 -136
  236. package/src/workspace/store.js +0 -382
  237. package/src/workspace/tmuxAdapter.js +0 -554
  238. /package/{hooks → dist/hooks}/handmux-notify.sh +0 -0
  239. /package/{public → dist/public}/fonts/JetBrainsMonoNerdFontMono-Regular.woff2 +0 -0
  240. /package/{public → dist/public}/fonts/TWUnifont.woff2 +0 -0
  241. /package/{public → dist/public}/icons/apple-touch-icon.png +0 -0
  242. /package/{public → dist/public}/icons/badge-96.png +0 -0
  243. /package/{public → dist/public}/icons/icon-192-maskable.png +0 -0
  244. /package/{public → dist/public}/icons/icon-192.png +0 -0
  245. /package/{public → dist/public}/icons/icon-512-maskable.png +0 -0
  246. /package/{public → dist/public}/icons/icon-512.png +0 -0
  247. /package/{public → dist/public}/icons/logo.svg +0 -0
  248. /package/{public → dist/public}/manifest.webmanifest +0 -0
  249. /package/{public → dist/public}/offline.html +0 -0
  250. /package/{public → dist/public}/sw.js +0 -0
  251. /package/{src → dist/src}/browser/protocol.js +0 -0
  252. /package/{src → dist/src}/cli/cfNamed.js +0 -0
@@ -0,0 +1 @@
1
+ (function(){"use strict";class s extends AudioWorkletProcessor{process(e){const r=e[0]&&e[0][0];return r&&r.length&&this.port.postMessage(r.slice(0)),!0}}registerProcessor("pcm-forwarder",s)})();
@@ -22,7 +22,7 @@
22
22
  <link rel="apple-touch-icon" href="/icons/apple-touch-icon.png?v=2" />
23
23
 
24
24
  <!-- Inline boot splash: paints instantly (before the JS bundle loads) so a cold launch shows the
25
- brand, not a blank dark screen. main.jsx calls window.__hideBootSplash() once React has painted.
25
+ brand, not a blank dark screen. main.tsx calls window.__hideBootSplash() once React has painted.
26
26
  Until then it must remain visible: a slow bundle should never expose the still-empty app root. -->
27
27
  <style>
28
28
  html, body { margin: 0; background: #1a1b1e; } /* opaque dark from frame one — no wallpaper showing through */
@@ -48,8 +48,8 @@
48
48
  @keyframes bootDot { 0%,100% { opacity: .22; transform: translateY(0); } 40% { opacity: 1; transform: translateY(-3px); } }
49
49
  @media (prefers-reduced-motion: reduce) { .boot-glow, .boot-dots i { animation: none; opacity: .8; } }
50
50
  </style>
51
- <script type="module" crossorigin src="/assets/index-D1DWcC5u.js"></script>
52
- <link rel="stylesheet" crossorigin href="/assets/index-C3N7OkyP.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" crossorigin href="/assets/index-C3N7OkyP.css"></noscript>
51
+ <script type="module" crossorigin src="/assets/index-D4VP5t8h.js"></script>
52
+ <link rel="stylesheet" crossorigin href="/assets/index-CicdY9E9.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" crossorigin href="/assets/index-CicdY9E9.css"></noscript>
53
53
  </head>
54
54
  <body>
55
55
  <div id="boot-splash" aria-hidden="true">
@@ -61,8 +61,9 @@
61
61
  <div class="boot-dots"><i></i><i></i><i></i></div>
62
62
  </div>
63
63
  <div id="root"></div>
64
+ <div id="overlay-root"></div>
64
65
  <script>
65
- // Hide the splash once the app has painted (main.jsx), but keep it up at least ~650ms so the glow
66
+ // Hide the splash once the app has painted (main.tsx), but keep it up at least ~650ms so the glow
66
67
  // is actually seen, not a flicker. Slow or unavailable bundles leave this branded loading state visible.
67
68
  window.__bootStart = Date.now();
68
69
  window.__hideBootSplash = function () {
@@ -13,7 +13,10 @@ import os from 'node:os';
13
13
  import { resolveEncodedDirSession, isSessionUuid } from './scanUtils.js';
14
14
  import { resolveByExecutable, executableBasename } from './processIdentity.js';
15
15
  import { createTranscriptParser, parseTranscript } from '../transcriptParse.js';
16
-
16
+ const record = (value) => (value !== null && typeof value === 'object' && !Array.isArray(value)
17
+ ? value
18
+ : null);
19
+ const text = (value) => typeof value === 'string' ? value : '';
17
20
  // The NATIVE installer names the real binary by version (~/.local/share/claude/versions/2.1.196 —
18
21
  // ~/.local/bin/claude is only a symlink to it), and tmux #{pane_current_command} follows that basename
19
22
  // ("2_1_196", dots sanitized to underscores) instead of "claude". ps can't tie it: ps `comm` shows the
@@ -29,61 +32,70 @@ import { createTranscriptParser, parseTranscript } from '../transcriptParse.js';
29
32
  // transient negative probe retries after 3s instead of poisoning the pane until server restart.
30
33
  const VERSION_COMM_RE = /^\d+[._]\d+[._]\d+$/;
31
34
  const CLAUDE_PATH_RE = /claude/;
32
-
33
35
  export async function resolveVersionedComms(panes, run, verdicts = new Map(), opts = {}) {
34
- return resolveByExecutable(panes, run, verdicts, {
35
- candidate: (cmd) => VERSION_COMM_RE.test(cmd),
36
- normalized: 'claude',
37
- matches: (exe, cmd) => {
38
- const base = executableBasename(exe);
39
- return (base === cmd || base === cmd.replace(/_/g, '.')) && CLAUDE_PATH_RE.test(exe);
40
- },
41
- ...opts,
42
- });
36
+ return resolveByExecutable(panes, run, verdicts, {
37
+ candidate: (cmd) => VERSION_COMM_RE.test(cmd),
38
+ normalized: 'claude',
39
+ matches: (exe, cmd) => {
40
+ const base = executableBasename(exe);
41
+ return (base === cmd || base === cmd.replace(/_/g, '.')) && CLAUDE_PATH_RE.test(exe);
42
+ },
43
+ ...opts,
44
+ });
43
45
  }
44
-
45
46
  // Build the 需要你 one-liner for a PermissionRequest, from the tool it's gating on (PermissionRequest
46
47
  // carries tool_name + tool_input, unlike the later permission_prompt Notification which only has an
47
48
  // English message).
48
49
  function permMsg(body) {
49
- const t = body.tool_name;
50
- if (t === 'AskUserQuestion') {
51
- const q = body.tool_input && body.tool_input.questions && body.tool_input.questions[0];
52
- const text = (q && (q.question || q.header)) || '';
53
- return text ? `需要你回答:${text}` : '需要你回答';
54
- }
55
- if (t === 'ExitPlanMode') return '需要你批准计划';
56
- return t ? `需要你授权:${t}` : '需要你';
50
+ const t = text(body.tool_name);
51
+ if (t === 'AskUserQuestion') {
52
+ const toolInput = record(body.tool_input);
53
+ const questions = toolInput?.questions;
54
+ const question = Array.isArray(questions) ? record(questions[0]) : null;
55
+ const prompt = text(question?.question) || text(question?.header);
56
+ return prompt ? `需要你回答:${prompt}` : '需要你回答';
57
+ }
58
+ if (t === 'ExitPlanMode')
59
+ return '需要你批准计划';
60
+ return t ? `需要你授权:${t}` : '需要你';
57
61
  }
58
-
59
62
  // Friendly Chinese for the StopFailure error type (matcher values, see the hooks doc). The payload shape
60
63
  // isn't verified against a live rate-limit yet, so read the type defensively from several likely fields and
61
64
  // always fall back to a bare 本轮出错 — a wrong field name degrades to the generic label, never throws.
62
65
  const STOPFAIL_LABEL = {
63
- rate_limit: '触发限流', overloaded: '服务过载', authentication_failed: '认证失败',
64
- oauth_org_not_allowed: '组织未授权', billing_error: '额度/账单问题', invalid_request: '请求无效',
65
- model_not_found: '模型不可用', server_error: '服务端错误', max_output_tokens: '输出超长', unknown: '未知错误',
66
+ rate_limit: '触发限流', overloaded: '服务过载', authentication_failed: '认证失败',
67
+ oauth_org_not_allowed: '组织未授权', billing_error: '额度/账单问题', invalid_request: '请求无效',
68
+ model_not_found: '模型不可用', server_error: '服务端错误', max_output_tokens: '输出超长', unknown: '未知错误',
66
69
  };
67
- function stopFailMsg(body = {}) {
68
- const type = body.error_type || body.reason || body.type
69
- || (body.error && (typeof body.error === 'string' ? body.error : body.error.type)) || '';
70
- if (STOPFAIL_LABEL[type]) return STOPFAIL_LABEL[type];
71
- const raw = typeof body.error === 'string' ? body.error : (body.message || '');
72
- return raw ? String(raw).replace(/\s+/g, ' ').trim().slice(0, 80) : '';
70
+ function stopFailMsg(body) {
71
+ const error = record(body.error);
72
+ const type = text(body.error_type) || text(body.reason) || text(body.type)
73
+ || text(body.error) || text(error?.type);
74
+ const label = STOPFAIL_LABEL[type];
75
+ if (label)
76
+ return label;
77
+ const raw = text(body.error) || text(body.message);
78
+ return raw ? String(raw).replace(/\s+/g, ' ').trim().slice(0, 80) : '';
73
79
  }
74
-
75
80
  // Build the 进行中 one-liner for a resume (PostToolUse after the user answered/approved), surfacing the
76
81
  // choice they just made — AskUserQuestion stores it in tool_input.answers, keyed by question.
77
82
  function resumeMsg(body) {
78
- const a = (body.tool_input && body.tool_input.answers) || (body.tool_response && body.tool_response.answers);
79
- if (a && typeof a === 'object') {
80
- const picks = Object.values(a).flat().filter(Boolean).join('、');
81
- if (picks) return `已答:${picks}`;
82
- }
83
- if (body.tool_name === 'ExitPlanMode') return '已批准计划';
84
- return '';
83
+ const toolInput = record(body.tool_input);
84
+ const toolResponse = record(body.tool_response);
85
+ const answers = record(toolInput?.answers) || record(toolResponse?.answers);
86
+ if (answers) {
87
+ const picks = Object.values(answers)
88
+ .flatMap((value) => Array.isArray(value) ? value : [value])
89
+ .filter(Boolean)
90
+ .map(String)
91
+ .join('、');
92
+ if (picks)
93
+ return `已答:${picks}`;
94
+ }
95
+ if (body.tool_name === 'ExitPlanMode')
96
+ return '已批准计划';
97
+ return '';
85
98
  }
86
-
87
99
  // Map a hook event (src + raw Claude payload) to a notification "kind". Pure — no I/O, easy to test.
88
100
  // stop → done (turn finished; carries last message)
89
101
  // prompt → working (UserPromptSubmit; carries the prompt)
@@ -109,51 +121,61 @@ function resumeMsg(body) {
109
121
  // stopfail → error (StopFailure: the turn ended on an API error — no Stop fires, so
110
122
  // this is the only signal that un-sticks the pane from 进行中)
111
123
  // anything else → null (ignored: auth_success, elicitation_*, etc.)
112
- export function classifyClaude(src, body = {}) {
113
- if (src === 'stop') return { kind: 'done', msg: body.last_assistant_message || '' };
114
- if (src === 'prompt') return { kind: 'working', msg: body.prompt || '' };
115
- if (src === 'resume') return { kind: 'working', msg: resumeMsg(body) };
116
- if (src === 'permreq') return { kind: 'permission', msg: permMsg(body) };
117
- if (src === 'compacting') return { kind: 'compacting', msg: '' }; // PreCompact: 压缩上下文进行中
118
- if (src === 'compact') return null; // PostCompact: done → clear 压缩中/进行中
119
- if (src === 'stopfail') return { kind: 'error', msg: stopFailMsg(body) }; // turn died on an API error
120
- if (src === 'end') return { kind: 'end' };
121
- if (src === 'start') return null; // SessionStart: only (re)binds pane→session
122
- if (src === 'notify') {
123
- if (body.notification_type === 'idle_prompt') return { kind: 'idle', msg: body.message || '' };
124
- if (body.notification_type === 'permission_prompt') return { kind: 'permission', msg: body.message || '' };
125
- }
126
- return null;
124
+ export function classifyClaude(src, rawBody = {}) {
125
+ const body = record(rawBody) || {};
126
+ if (src === 'stop')
127
+ return { kind: 'done', msg: text(body.last_assistant_message) };
128
+ if (src === 'prompt')
129
+ return { kind: 'working', msg: text(body.prompt) };
130
+ if (src === 'resume')
131
+ return { kind: 'working', msg: resumeMsg(body) };
132
+ if (src === 'permreq')
133
+ return { kind: 'permission', msg: permMsg(body) };
134
+ if (src === 'compacting')
135
+ return { kind: 'compacting', msg: '' }; // PreCompact: 压缩上下文进行中
136
+ if (src === 'compact')
137
+ return null; // PostCompact: done → clear 压缩中/进行中
138
+ if (src === 'stopfail')
139
+ return { kind: 'error', msg: stopFailMsg(body) }; // turn died on an API error
140
+ if (src === 'end')
141
+ return { kind: 'end' };
142
+ if (src === 'start')
143
+ return null; // SessionStart: only (re)binds pane→session
144
+ if (src === 'notify') {
145
+ if (body.notification_type === 'idle_prompt')
146
+ return { kind: 'idle', msg: text(body.message) };
147
+ if (body.notification_type === 'permission_prompt')
148
+ return { kind: 'permission', msg: text(body.message) };
149
+ }
150
+ return null;
127
151
  }
128
-
129
152
  export const projectsDir = (home = os.homedir()) => path.join(home, '.claude', 'projects');
130
-
131
153
  export const claude = {
132
- id: 'claude',
133
- label: 'Claude Code',
134
- procName: 'claude',
135
- // Which tmux #{pane_current_command} values mean "this agent is still the pane's foreground app" (inbox
136
- // liveness). Claude sets its process title to "claude", so an exact single-name match is right — for
137
- // native-install machines (whose comm is the VERSION string) resolveVersionedComms below normalizes the
138
- // cmd back to 'claude' at ingest, keeping every match here exact-name.
139
- procNames: ['claude'],
140
- // The `claude` CLI sets its process title to "claude" (verified via ps): match the program token at the
141
- // start of argv — bare "claude", "claude --continue", or an absolute path ending in /claude. Anchored so
142
- // "vim claude.js" / "node build/claude.js" don't match.
143
- procMatch: /^(\S*\/)?claude(\s|$)/,
144
- takeoverPrefix: 'cc', // tmux session name prefix for a takeover (cc-<label>-<n>)
145
- classify: classifyClaude,
146
- transcript: { createParser: createTranscriptParser, parse: parseTranscript },
147
- sessions: {
148
- isId: isSessionUuid,
149
- dirOptKey: 'projectsDir', // scanOrphans option that overrides `dir` (back-compat + tests)
150
- dir: projectsDir,
151
- // Resolve a cwd to its Claude session within `dir`: newest jsonl in the encoded project dir whose
152
- // recorded cwd matches. Layout: ~/.claude/projects/<enc-cwd>/<uuid>.jsonl.
153
- resolve: (dir, cwd, opts = {}) => resolveEncodedDirSession(dir, cwd, opts),
154
- // `claude --resume <uuid>` appends to the SAME jsonl with no OS lock, so the takeover kills the original
155
- // to guarantee a single writer (see orphans.js).
156
- resumeArgs: (id) => ['claude', '--resume', id],
157
- resumeCmd: (id) => `claude --resume ${id}`,
158
- },
154
+ id: 'claude',
155
+ label: 'Claude Code',
156
+ procName: 'claude',
157
+ // Which tmux #{pane_current_command} values mean "this agent is still the pane's foreground app" (inbox
158
+ // liveness). Claude sets its process title to "claude", so an exact single-name match is right — for
159
+ // native-install machines (whose comm is the VERSION string) resolveVersionedComms below normalizes the
160
+ // cmd back to 'claude' at ingest, keeping every match here exact-name.
161
+ procNames: ['claude'],
162
+ // The `claude` CLI sets its process title to "claude" (verified via ps): match the program token at the
163
+ // start of argv — bare "claude", "claude --continue", or an absolute path ending in /claude. Anchored so
164
+ // "vim claude.js" / "node build/claude.js" don't match.
165
+ procMatch: /^(\S*\/)?claude(\s|$)/,
166
+ takeoverPrefix: 'cc', // tmux session name prefix for a takeover (cc-<label>-<n>)
167
+ classify: classifyClaude,
168
+ transcript: { createParser: createTranscriptParser, parse: parseTranscript },
169
+ sessions: {
170
+ isId: isSessionUuid,
171
+ dirOptKey: 'projectsDir', // scanOrphans option that overrides `dir` (back-compat + tests)
172
+ dir: projectsDir,
173
+ // Resolve a cwd to its Claude session within `dir`: newest jsonl in the encoded project dir whose
174
+ // recorded cwd matches. Layout: ~/.claude/projects/<enc-cwd>/<uuid>.jsonl.
175
+ resolve: (dir, cwd, opts = {}) => resolveEncodedDirSession(dir, cwd, opts),
176
+ // `claude --resume <uuid>` appends to the SAME jsonl with no OS lock, so the takeover kills the original
177
+ // to guarantee a single writer (see orphans.js).
178
+ resumeArgs: (id) => ['claude', '--resume', id],
179
+ resumeCmd: (id) => `claude --resume ${id}`,
180
+ },
159
181
  };
@@ -0,0 +1,217 @@
1
+ // Codex process/session discovery. App Server owns chat, approvals, stopping, inbox state and exact thread
2
+ // identity; plain Codex is identified only as a live process until the user opts into managed mode.
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+ import { promises as fsp } from 'node:fs';
6
+ import { readHead, readTail, firstCwd, isSessionUuid } from './scanUtils.js';
7
+ import { resolveByExecutable, executableBasename } from './processIdentity.js';
8
+ import { createCodexTranscriptParser, isCodexSyntheticUserText, parseCodexTranscript, } from '../codexTranscriptParse.js';
9
+ const isRecord = (value) => (value !== null && typeof value === 'object' && !Array.isArray(value));
10
+ export const sessionsDir = (home = os.homedir()) => path.join(home, '.codex', 'sessions');
11
+ // A Codex rollout file is `rollout-<ISO-ish timestamp>-<uuid>.jsonl`; the session id Codex's `resume` wants
12
+ // is that trailing uuid. Pull it out of the basename.
13
+ export function rolloutSessionId(name) {
14
+ const m = String(name).match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i);
15
+ return m?.[1] || null;
16
+ }
17
+ const UUID_PARTS = '([0-9a-f]{8})\\s*-\\s*([0-9a-f]{4})\\s*-\\s*([0-9a-f]{4})\\s*-\\s*([0-9a-f]{4})\\s*-\\s*([0-9a-f]{12})';
18
+ function lastUuidAfter(text, prefix) {
19
+ const matches = [...String(text || '').matchAll(new RegExp(`${prefix}\\s*${UUID_PARTS}`, 'gi'))];
20
+ const match = matches.at(-1);
21
+ if (!match)
22
+ return null;
23
+ const parts = match.slice(1, 6);
24
+ return parts.length === 5 && parts.every(Boolean) ? parts.join('-').toLowerCase() : null;
25
+ }
26
+ // A normal Codex exit prints the exact command for the session that just exited. Keep this deliberately
27
+ // stricter than a generic `codex resume` search so conversation text or shell history is not mistaken for
28
+ // the current exit notice.
29
+ export function codexExitSessionId(text) {
30
+ return lastUuidAfter(text, 'To continue this session, run\\s+codex\\s+resume');
31
+ }
32
+ // Last user turn out of a Codex rollout tail, for a recognizable one-line label. Codex records turns as
33
+ // response_item messages: {payload:{type:'message',role:'user',content:[{type:'input_text',text}]}} (and a
34
+ // flatter {type:'message',role:'user',...} in some versions). Skips the synthetic context turns Codex
35
+ // injects (including repository instructions after a resume). Best-effort → '' on miss.
36
+ export function codexUserSnippet(tailText, max = 80) {
37
+ const rows = String(tailText).split('\n');
38
+ for (let i = rows.length - 1; i >= 0; i--) {
39
+ const line = rows[i]?.trim() ?? '';
40
+ if (!line || line[0] !== '{')
41
+ continue;
42
+ let data;
43
+ try {
44
+ data = JSON.parse(line);
45
+ }
46
+ catch {
47
+ continue;
48
+ }
49
+ if (!isRecord(data))
50
+ continue;
51
+ const payload = isRecord(data.payload) ? data.payload : null;
52
+ const msg = payload?.type === 'message' ? payload
53
+ : data.type === 'message' ? data : null;
54
+ if (!msg || msg.role !== 'user')
55
+ continue;
56
+ const c = msg.content;
57
+ let text = '';
58
+ if (typeof c === 'string')
59
+ text = c;
60
+ else if (Array.isArray(c))
61
+ text = c
62
+ .filter((block) => isRecord(block)
63
+ && (block.type === 'input_text' || block.type === 'text'))
64
+ .map((block) => typeof block.text === 'string' ? block.text : '')
65
+ .join(' ');
66
+ if (!text || isCodexSyntheticUserText(text))
67
+ continue;
68
+ text = text.replace(/\s+/g, ' ').trim();
69
+ if (!text)
70
+ continue;
71
+ return text.length > max ? `${text.slice(0, max)}…` : text;
72
+ }
73
+ return '';
74
+ }
75
+ // The npm-installed Codex launcher leaves `node` as tmux's pane_current_command. Never accept that
76
+ // ambiguous name by itself: prove a foreground process on the same TTY has a real executable named
77
+ // `codex` from a Codex path, then normalize the pane to the canonical agent name.
78
+ export async function resolveCodexComms(panes, run, verdicts = new Map(), opts = {}) {
79
+ return resolveByExecutable(panes, run, verdicts, {
80
+ candidate: (cmd) => cmd === 'node',
81
+ normalized: 'codex',
82
+ matches: (exe) => /^codex(?:\.exe)?$/i.test(executableBasename(exe)) && /codex/i.test(exe),
83
+ ...opts,
84
+ });
85
+ }
86
+ // Walk the date-nested sessions tree (…/YYYY/MM/DD/rollout-*.jsonl) newest-first and collect up to `limit`
87
+ // rollout files with their mtimes. Unlike Claude's flat encoded-cwd dir, Codex partitions by date, so we
88
+ // descend year→month→day in descending name order and stop once we have enough — the newest sessions are
89
+ // always in the latest date dir, so a live orphan's session is found without reading the whole history.
90
+ async function recentRollouts(dir, limit = 80) {
91
+ const out = [];
92
+ async function descend(d, depth) {
93
+ if (out.length >= limit)
94
+ return;
95
+ let entries;
96
+ try {
97
+ entries = await fsp.readdir(d, { withFileTypes: true });
98
+ }
99
+ catch {
100
+ return;
101
+ }
102
+ if (depth < 3) {
103
+ const subdirs = entries.filter((e) => e.isDirectory()).map((e) => e.name).sort().reverse();
104
+ for (const name of subdirs) {
105
+ if (out.length >= limit)
106
+ break;
107
+ await descend(path.join(d, name), depth + 1);
108
+ }
109
+ return;
110
+ }
111
+ const files = entries.filter((e) => e.isFile() && e.name.startsWith('rollout-') && e.name.endsWith('.jsonl'));
112
+ for (const f of files) {
113
+ try {
114
+ out.push({ file: path.join(d, f.name), name: f.name, mtime: (await fsp.stat(path.join(d, f.name))).mtimeMs });
115
+ }
116
+ catch { /* gone */ }
117
+ }
118
+ }
119
+ await descend(dir, 0);
120
+ out.sort((a, b) => b.mtime - a.mtime);
121
+ return out.slice(0, limit);
122
+ }
123
+ // Resolve one known App Server thread id to its exact rollout. This never falls back by cwd: several
124
+ // Codex panes can share a project, while the UUID suffix is the durable one-to-one session identity.
125
+ const rolloutPathCache = new Map();
126
+ export async function resolveCodexRollout(dir, sessionId) {
127
+ if (!isSessionUuid(sessionId))
128
+ return null;
129
+ const key = `${dir}\0${sessionId}`;
130
+ const cached = rolloutPathCache.get(key);
131
+ if (cached) {
132
+ try {
133
+ await fsp.access(cached);
134
+ return cached;
135
+ }
136
+ catch {
137
+ rolloutPathCache.delete(key);
138
+ }
139
+ }
140
+ const suffix = `-${sessionId}.jsonl`;
141
+ let found = null;
142
+ async function descend(current, depth) {
143
+ if (found)
144
+ return;
145
+ let entries;
146
+ try {
147
+ entries = await fsp.readdir(current, { withFileTypes: true });
148
+ }
149
+ catch {
150
+ return;
151
+ }
152
+ if (depth < 3) {
153
+ for (const entry of entries) {
154
+ if (!entry.isDirectory())
155
+ continue;
156
+ await descend(path.join(current, entry.name), depth + 1);
157
+ if (found)
158
+ return;
159
+ }
160
+ return;
161
+ }
162
+ const match = entries.find((entry) => entry.isFile()
163
+ && entry.name.startsWith('rollout-') && entry.name.endsWith(suffix));
164
+ if (match)
165
+ found = path.join(current, match.name);
166
+ }
167
+ await descend(dir, 0);
168
+ if (found)
169
+ rolloutPathCache.set(key, found);
170
+ return found;
171
+ }
172
+ // Resolve a live orphan's cwd to its Codex session: the newest rollout whose recorded cwd matches. Same
173
+ // shape as Claude's resolver ({ sessionId, state, snippet, lastActivity }) so the orphan engine is agnostic.
174
+ export async function resolveCodexSession(dir, cwd, { busyMs = 8000, now = Date.now } = {}) {
175
+ const files = await recentRollouts(dir);
176
+ for (const { file, name, mtime } of files) {
177
+ let head;
178
+ try {
179
+ head = await readHead(file);
180
+ }
181
+ catch {
182
+ continue;
183
+ }
184
+ if (firstCwd(head) !== cwd)
185
+ continue;
186
+ const sessionId = rolloutSessionId(name);
187
+ if (!sessionId)
188
+ continue;
189
+ let snippet = '';
190
+ try {
191
+ snippet = codexUserSnippet(await readTail(file));
192
+ }
193
+ catch { /* best effort */ }
194
+ return { sessionId, state: now() - mtime < busyMs ? 'busy' : 'idle', snippet, lastActivity: Math.round(mtime), file };
195
+ }
196
+ return {};
197
+ }
198
+ export const codex = {
199
+ id: 'codex',
200
+ label: 'Codex CLI',
201
+ procName: 'codex',
202
+ // Ambiguous `node` launchers are normalized by resolveCodexComms only after real-executable proof.
203
+ procNames: ['codex'],
204
+ procMatch: /^(\S*\/)?codex(\s|$)/,
205
+ takeoverPrefix: 'cx', // tmux session name prefix for a takeover (cx-<label>-<n>)
206
+ transcript: { createParser: createCodexTranscriptParser, parse: parseCodexTranscript },
207
+ sessions: {
208
+ isId: isSessionUuid,
209
+ dirOptKey: 'sessionsDir', // scanOrphans option that overrides `dir`
210
+ dir: sessionsDir,
211
+ resolve: (dir, cwd, opts = {}) => resolveCodexSession(dir, cwd, opts),
212
+ // `codex resume <uuid>` continues the session from its rollout file.
213
+ resumeArgs: (id) => ['codex', 'resume', id],
214
+ resumeCmd: (id) => `codex resume ${id}`,
215
+ managedResumeCmd: (id) => `handmux codex resume ${id}`,
216
+ },
217
+ };
@@ -3,19 +3,19 @@
3
3
  // engine consume drivers through this list, so process and session behavior stays centralized.
4
4
  import { claude } from './claude.js';
5
5
  import { codex } from './codex.js';
6
-
7
6
  // Order matters for proc matching (parseAgentProcs takes the first match) — keep the patterns disjoint so
8
7
  // order is irrelevant in practice, but list the flagship first.
9
8
  export const AGENTS = [claude, codex];
10
-
11
- const BY_ID = new Map(AGENTS.map((a) => [a.id, a]));
12
-
9
+ const BY_ID = new Map(AGENTS.map((agent) => [agent.id, agent]));
13
10
  // Resolve a driver by id. State-file entries written before agents existed carry no `agent` field, and the
14
11
  // only writer then was Claude — so an unknown/missing id defaults to Claude (back-compat, never undefined).
15
- export function getAgent(id) { return BY_ID.get(id) || claude; }
16
-
12
+ export function getAgent(id) {
13
+ return typeof id === 'string' ? BY_ID.get(id) || claude : claude;
14
+ }
17
15
  // The driver whose foreground process this tmux #{pane_current_command} is, or null. Used by the inbox to
18
16
  // decide a recorded pane is still running its agent (vs. the agent having exited to a shell). Matches the
19
17
  // canonical procName only — never the ambiguous extras in procNames (e.g. Codex's 'node'); native-install
20
18
  // Claude binaries (comm = version string) are normalized to 'claude' at ingest (resolveVersionedComms).
21
- export function agentForProc(cmd) { return AGENTS.find((a) => a.procName === cmd) || null; }
19
+ export function agentForProc(cmd) {
20
+ return AGENTS.find((agent) => agent.procName === cmd) || null;
21
+ }
@@ -0,0 +1,80 @@
1
+ import { promises as fsp } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { normTty } from './scanUtils.js';
4
+ const SUCCESS_TTL_MS = 30_000;
5
+ const FAILURE_TTL_MS = 3_000;
6
+ // Real executable path of a pid. lsof resolves launch symlinks on macOS; /proc is the cheap Linux path.
7
+ // A transient lsof failure is inconclusive, not a negative identity verdict that should escape the call.
8
+ export async function executablePath(run, pid) {
9
+ let out = '';
10
+ try {
11
+ out = await run('lsof', ['-a', '-p', String(pid), '-d', 'txt', '-Fn']);
12
+ }
13
+ catch { /* try /proc */ }
14
+ for (const line of String(out).split('\n'))
15
+ if (line[0] === 'n')
16
+ return line.slice(1).trim();
17
+ try {
18
+ return await fsp.readlink(`/proc/${pid}/exe`);
19
+ }
20
+ catch {
21
+ return '';
22
+ }
23
+ }
24
+ function parseForegroundProcesses(out) {
25
+ const rows = [];
26
+ for (const line of String(out).split('\n')) {
27
+ const m = line.match(/^\s*(\S+)\s+(\d+)\s+(\S+)\s*$/);
28
+ if (!m)
29
+ continue;
30
+ const [, rawTty, pid, stat] = m;
31
+ if (!rawTty || !pid || !stat)
32
+ continue;
33
+ const tty = normTty(rawTty);
34
+ if (tty && stat.includes('+'))
35
+ rows.push({ tty, pid });
36
+ }
37
+ return rows;
38
+ }
39
+ // Normalize ambiguous tmux pane_current_command values only after tying the pane's TTY to a foreground
40
+ // process whose REAL executable proves the agent identity. Every call refreshes the cheap ps snapshot;
41
+ // the cache only skips lsof while the exact foreground pid set is unchanged and its short TTL is live.
42
+ // This makes TTY reuse/process replacement invalidate immediately, and failed probes retry quickly.
43
+ export async function resolveByExecutable(panes, run, verdicts, { candidate, normalized, matches, now = () => Date.now(), successTtlMs = SUCCESS_TTL_MS, failureTtlMs = FAILURE_TTL_MS, }) {
44
+ const candidates = panes.filter((p) => p && p.tty && candidate(p.cmd || ''));
45
+ if (!candidates.length)
46
+ return panes;
47
+ const rows = parseForegroundProcesses(await run('ps', ['-Ao', 'tty=,pid=,stat=']));
48
+ for (const pane of candidates) {
49
+ const tty = normTty(pane.tty);
50
+ const procs = rows.filter((r) => r.tty === tty);
51
+ const signature = procs.map((r) => r.pid).sort().join(',');
52
+ const key = `${normalized}|${tty}|${pane.cmd}`;
53
+ const cached = verdicts.get(key);
54
+ if (cached && cached.signature === signature && cached.expiresAt > now()) {
55
+ if (cached.ok)
56
+ pane.cmd = normalized;
57
+ continue;
58
+ }
59
+ let ok = false;
60
+ let matchedPid = null;
61
+ for (const proc of procs) {
62
+ const exe = await executablePath(run, proc.pid);
63
+ if (matches(exe, pane.cmd || '')) {
64
+ ok = true;
65
+ matchedPid = proc.pid;
66
+ break;
67
+ }
68
+ }
69
+ verdicts.set(key, {
70
+ ok,
71
+ pid: matchedPid,
72
+ signature,
73
+ expiresAt: now() + (ok ? successTtlMs : failureTtlMs),
74
+ });
75
+ if (ok)
76
+ pane.cmd = normalized;
77
+ }
78
+ return panes;
79
+ }
80
+ export const executableBasename = (file) => path.basename(String(file || ''));