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,245 @@
1
+ // Pure CLI option handling: a tiny hand-rolled flag parser (no dependency) + config resolution.
2
+ // Resolution order is flags > config file > env > built-in default. The token is ALWAYS materialised
3
+ // (generated when unset) so a public tunnel can never come up token-less.
4
+ import crypto from 'node:crypto';
5
+ import { normalizeShortcuts } from '../shortcutConfig.js';
6
+ export const TUNNELS = ['none', 'cloudflare', 'cloudflare-named', 'ssh', 'natapp', 'cpolar'];
7
+ const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
8
+ const isTunnel = (value) => typeof value === 'string' && TUNNELS.includes(value);
9
+ const camel = (value) => value.replace(/-([a-z])/g, (_match, char) => char.toUpperCase());
10
+ // argv = process.argv.slice(2). `command` is the first bare word; flags are `--key value`,
11
+ // `--key` (boolean true), `--no-key` (boolean false), and `-f` (alias for --foreground).
12
+ export function parseArgs(argv) {
13
+ const [command = 'help', ...rest] = argv;
14
+ const flags = {};
15
+ const positionals = [];
16
+ const unknownShortFlags = [];
17
+ const multiFlags = new Set(['session']);
18
+ const assign = (key, value) => {
19
+ if (!multiFlags.has(key) || flags[key] === undefined) {
20
+ flags[key] = value;
21
+ return;
22
+ }
23
+ flags[key] = Array.isArray(flags[key]) ? [...flags[key], value] : [flags[key], value];
24
+ };
25
+ for (let i = 0; i < rest.length; i++) {
26
+ const a = rest[i];
27
+ if (a === undefined)
28
+ continue;
29
+ if (a === '-f') {
30
+ flags.foreground = true;
31
+ continue;
32
+ }
33
+ if (!a.startsWith('--')) {
34
+ if (a.startsWith('-'))
35
+ unknownShortFlags.push(a);
36
+ else
37
+ positionals.push(a);
38
+ continue;
39
+ }
40
+ const key = a.slice(2);
41
+ if (key.startsWith('no-')) {
42
+ assign(camel(key.slice(3)), false);
43
+ continue;
44
+ }
45
+ const next = rest[i + 1];
46
+ if (next === undefined || next.startsWith('--')) {
47
+ assign(camel(key), true);
48
+ }
49
+ else {
50
+ assign(camel(key), next);
51
+ i++;
52
+ }
53
+ }
54
+ return {
55
+ command,
56
+ flags,
57
+ ...(positionals.length ? { positionals } : {}),
58
+ ...(unknownShortFlags.length ? { unknownShortFlags } : {}),
59
+ };
60
+ }
61
+ // ssh 回退公网地址:SSH 不生成 URL,无 --public-url 时用 host:remotePort(去掉 user@ 与 :sshPort)。
62
+ export function sshPublicFallback(sshHost, remotePort) {
63
+ const host = sshHost.replace(/^[^@]*@/, '').replace(/:\d+$/, '');
64
+ return `http://${host}:${remotePort}`;
65
+ }
66
+ export function resolveConfig(flags = {}, fileCfg = {}, env = process.env, gen = defaultGen) {
67
+ const pick = (key, ...fallbacks) => {
68
+ for (const v of [flags[key], fileCfg[key], ...fallbacks])
69
+ if (v !== undefined && v !== null)
70
+ return v;
71
+ return undefined;
72
+ };
73
+ const tunnel = pick('tunnel', 'none');
74
+ if (!isTunnel(tunnel))
75
+ throw new Error(`unknown tunnel: ${String(tunnel)} (use: ${TUNNELS.join(', ')})`);
76
+ const port = Number(pick('port', env.HANDMUX_PORT, 19999));
77
+ if (!Number.isInteger(port) || port < 1 || port > 65535)
78
+ throw new Error(`bad port: ${pick('port', env.HANDMUX_PORT, 19999)}`);
79
+ const cfg = {
80
+ tunnel,
81
+ port,
82
+ name: optionalString(pick('name', env.HANDMUX_APP_NAME), 'name'),
83
+ host: optionalString(pick('host', env.HANDMUX_HOST, '0.0.0.0'), 'host') ?? '0.0.0.0',
84
+ token: optionalString(pick('token', env.HANDMUX_TOKEN), 'token') ?? gen(),
85
+ foreground: !!pick('foreground', false),
86
+ qr: pick('qr', true) !== false,
87
+ // Unified config — what used to live in .env. The supervisor injects these into the server child's
88
+ // environment (HANDMUX_STATIC_DIR / VAPID_* / XFYUN_* …), which is exactly where the server reads them.
89
+ staticDir: optionalString(pick('staticDir', env.HANDMUX_STATIC_DIR), 'static-dir'),
90
+ uploadExts: optionalString(pick('uploadExts', env.HANDMUX_UPLOAD_EXTS), 'upload-exts'),
91
+ previewDomain: optionalString(pick('previewDomain', env.HANDMUX_PREVIEW_DOMAIN), 'preview-domain'),
92
+ vapid: parseVapid(fileCfg.vapid), // { public, private, subject } — push notifications
93
+ xfyun: parseXfyun(fileCfg.xfyun), // { appId, apiKey, apiSecret } — voice input
94
+ shortcuts: normalizeShortcuts(fileCfg.shortcuts),
95
+ // An explicit public URL is honoured for ANY tunnel mode — including 'none', so someone who runs their
96
+ // own tunnel/reverse-proxy can still have handmux advertise (print + QR) their real domain. The
97
+ // tunnel-specific blocks below only fill a *fallback* when it wasn't given.
98
+ //
99
+ // Guard: a publicUrl in the FILE was set for the file's tunnel, so it only carries over when the
100
+ // resolved tunnel still matches (or the file pins no tunnel). Otherwise a `--tunnel B` override would
101
+ // advertise tunnel A's URL. A flag/env publicUrl is explicit for THIS run and always wins.
102
+ publicUrl: optionalString(resolvePublicUrl(flags, fileCfg, env, tunnel), 'public-url'),
103
+ // tunnel-specific (null unless the relevant tunnel is selected)
104
+ sshHost: null, remotePort: null, sshJump: null,
105
+ cfHostname: null, cfTunnelName: null,
106
+ authtoken: null, cpolarRegion: null,
107
+ };
108
+ if (tunnel === 'ssh') {
109
+ cfg.sshHost = optionalString(pick('sshHost', env.HANDMUX_SSH_HOST), 'ssh-host');
110
+ if (!cfg.sshHost)
111
+ throw new Error('ssh tunnel needs --ssh-host user@host (or HANDMUX_SSH_HOST)');
112
+ const rp = Number(pick('remotePort', env.HANDMUX_REMOTE_PORT, port));
113
+ if (!Number.isInteger(rp) || rp < 1 || rp > 65535)
114
+ throw new Error(`bad remote-port: ${pick('remotePort', env.HANDMUX_REMOTE_PORT, port)}`);
115
+ cfg.remotePort = rp;
116
+ cfg.sshJump = optionalString(pick('sshJump', env.HANDMUX_SSH_JUMP), 'ssh-jump');
117
+ cfg.publicUrl = cfg.publicUrl || sshPublicFallback(cfg.sshHost, rp);
118
+ }
119
+ if (tunnel === 'cloudflare-named') {
120
+ cfg.cfHostname = optionalString(pick('cfHostname', env.HANDMUX_CF_HOSTNAME), 'cf-hostname');
121
+ if (!cfg.cfHostname)
122
+ throw new Error('cloudflare-named needs --cf-hostname handmux.example.com (or HANDMUX_CF_HOSTNAME)');
123
+ cfg.cfTunnelName = optionalString(pick('cfTunnelName', env.HANDMUX_CF_TUNNEL_NAME), 'cf-tunnel-name') || 'handmux';
124
+ cfg.publicUrl = cfg.publicUrl || `https://${cfg.cfHostname}`;
125
+ }
126
+ // natapp / cpolar: one shared credential (--authtoken), and named/fixed mode is just --public-url (the
127
+ // reserved domain) — no separate concept. A bare host is normalised to https:// so the user can paste the
128
+ // domain the provider gave them. cpolar also takes an optional edge --cpolar-region (cn = mainland China).
129
+ if (tunnel === 'natapp' || tunnel === 'cpolar') {
130
+ cfg.authtoken = optionalString(pick('authtoken', env.HANDMUX_AUTHTOKEN), 'authtoken');
131
+ if (!cfg.authtoken) {
132
+ const where = tunnel === 'natapp' ? 'natapp.cn' : 'cpolar.com';
133
+ throw new Error(`${tunnel} needs --authtoken <token> (or HANDMUX_AUTHTOKEN) — get it from ${where} after logging in`);
134
+ }
135
+ if (cfg.publicUrl && !/^https?:\/\//i.test(cfg.publicUrl))
136
+ cfg.publicUrl = `https://${cfg.publicUrl}`;
137
+ if (tunnel === 'cpolar')
138
+ cfg.cpolarRegion = optionalString(pick('cpolarRegion', env.HANDMUX_CPOLAR_REGION), 'cpolar-region');
139
+ }
140
+ return cfg;
141
+ }
142
+ // publicUrl resolution with the cross-tunnel guard (see resolveConfig). flag > file(if tunnel matches) > env.
143
+ function optionalString(value, name) {
144
+ if (value === undefined || value === null || value === '')
145
+ return null;
146
+ if (typeof value !== 'string')
147
+ throw new Error(`bad ${name}: expected text`);
148
+ return value;
149
+ }
150
+ function stringFields(value, keys) {
151
+ if (!isRecord(value))
152
+ return null;
153
+ const result = {};
154
+ for (const key of keys)
155
+ if (typeof value[key] === 'string')
156
+ result[key] = value[key];
157
+ return result;
158
+ }
159
+ function parseVapid(value) {
160
+ return stringFields(value, ['public', 'private', 'subject']);
161
+ }
162
+ function parseXfyun(value) {
163
+ return stringFields(value, ['appId', 'apiKey', 'apiSecret']);
164
+ }
165
+ function resolvePublicUrl(flags, fileCfg, env, tunnel) {
166
+ if (flags.publicUrl != null)
167
+ return flags.publicUrl;
168
+ const fileTunnel = fileCfg.tunnel;
169
+ const fileApplies = fileTunnel == null || fileTunnel === tunnel;
170
+ if (fileApplies && fileCfg.publicUrl != null)
171
+ return fileCfg.publicUrl;
172
+ if (env.HANDMUX_PUBLIC_URL != null)
173
+ return env.HANDMUX_PUBLIC_URL;
174
+ return null;
175
+ }
176
+ // Default token: 8 chars from a typing-friendly alphabet (lowercase + digits, look-alikes 0/o/1/l dropped)
177
+ // so it's quick to thumb in on a phone. A user-supplied token (flag/config/env) is used verbatim — any
178
+ // length, never regenerated. 8 chars over a 32-char alphabet ≈ 40 bits, fine for a single secret URL.
179
+ const TOKEN_ALPHABET = '23456789abcdefghijkmnpqrstuvwxyz';
180
+ export function defaultGen() {
181
+ let s = '';
182
+ for (let i = 0; i < 8; i++)
183
+ s += TOKEN_ALPHABET[crypto.randomInt(TOKEN_ALPHABET.length)];
184
+ return s;
185
+ }
186
+ function trace(flags, fileCfg, env, cfgPath, key, envKey, def) {
187
+ if (flags[key] != null)
188
+ return { value: flags[key], origin: 'flag' };
189
+ if (fileCfg[key] != null)
190
+ return { value: fileCfg[key], origin: cfgPath || 'file' };
191
+ if (envKey && env[envKey] != null)
192
+ return { value: env[envKey], origin: 'env' };
193
+ return { value: def, origin: 'default' };
194
+ }
195
+ // Build the rows for `handmux config`: the value each key WOULD resolve to plus where it came from.
196
+ // Lenient (never throws — unlike resolveConfig — so a half-finished config still prints) and secret-safe
197
+ // (token masked; push/voice shown only as on/off). Tunnel-specific rows appear only for the live tunnel.
198
+ export function explainConfig(flags = {}, fileCfg = {}, cfgPath = null, env = process.env) {
199
+ const rows = [];
200
+ const mask = (value) => (String(value).length <= 8 ? '••••' : `••••${String(value).slice(-4)}`);
201
+ const add = (key, traced, display) => {
202
+ rows.push({ key, origin: traced.origin, display: display ?? String(traced.value) });
203
+ };
204
+ const tunnel = trace(flags, fileCfg, env, cfgPath, 'tunnel', null, 'none');
205
+ add('tunnel', tunnel);
206
+ add('port', trace(flags, fileCfg, env, cfgPath, 'port', 'HANDMUX_PORT', 19999));
207
+ add('host', trace(flags, fileCfg, env, cfgPath, 'host', 'HANDMUX_HOST', '0.0.0.0'));
208
+ const name = trace(flags, fileCfg, env, cfgPath, 'name', 'HANDMUX_APP_NAME', null);
209
+ add('name', name, name.value == null ? '(default)' : String(name.value));
210
+ const lang = trace(flags, fileCfg, env, cfgPath, 'lang', 'HANDMUX_LANG', null);
211
+ add('lang', lang, lang.value == null ? '(auto — shell locale)' : String(lang.value));
212
+ const token = trace(flags, fileCfg, env, cfgPath, 'token', 'HANDMUX_TOKEN', null);
213
+ add('token', token, token.value == null ? '(generated each start)' : mask(token.value));
214
+ // publicUrl honours the same cross-tunnel guard as resolveConfig (file value only when tunnel matches).
215
+ const t = tunnel.value;
216
+ let pub = trace(flags, fileCfg, env, cfgPath, 'publicUrl', 'HANDMUX_PUBLIC_URL', null);
217
+ if (pub.origin !== 'flag' && pub.origin !== 'env' && fileCfg.tunnel != null && fileCfg.tunnel !== t) {
218
+ pub = { value: null, origin: 'default' };
219
+ }
220
+ add('publicUrl', pub, pub.value == null ? '(none — derived from tunnel if any)' : String(pub.value));
221
+ const previewDomain = trace(flags, fileCfg, env, cfgPath, 'previewDomain', 'HANDMUX_PREVIEW_DOMAIN', null);
222
+ add('previewDomain', previewDomain, previewDomain.value == null ? '(none)' : String(previewDomain.value));
223
+ if (t === 'ssh') {
224
+ add('sshHost', trace(flags, fileCfg, env, cfgPath, 'sshHost', 'HANDMUX_SSH_HOST', null));
225
+ add('remotePort', trace(flags, fileCfg, env, cfgPath, 'remotePort', 'HANDMUX_REMOTE_PORT', '(= port)'));
226
+ const jump = trace(flags, fileCfg, env, cfgPath, 'sshJump', 'HANDMUX_SSH_JUMP', null);
227
+ add('sshJump', jump, jump.value == null ? '(none)' : String(jump.value));
228
+ }
229
+ if (t === 'cloudflare-named') {
230
+ add('cfHostname', trace(flags, fileCfg, env, cfgPath, 'cfHostname', 'HANDMUX_CF_HOSTNAME', null));
231
+ add('cfTunnelName', trace(flags, fileCfg, env, cfgPath, 'cfTunnelName', 'HANDMUX_CF_TUNNEL_NAME', 'handmux'));
232
+ }
233
+ if (t === 'natapp' || t === 'cpolar') {
234
+ const at = trace(flags, fileCfg, env, cfgPath, 'authtoken', 'HANDMUX_AUTHTOKEN', null);
235
+ add('authtoken', at, at.value == null ? '(required — from the provider dashboard)' : mask(at.value));
236
+ }
237
+ if (t === 'cpolar') {
238
+ const rg = trace(flags, fileCfg, env, cfgPath, 'cpolarRegion', 'HANDMUX_CPOLAR_REGION', null);
239
+ add('cpolarRegion', rg, rg.value == null ? '(cpolar default)' : String(rg.value));
240
+ }
241
+ // Integrations live only in the file (secrets); show presence, never the keys.
242
+ rows.push({ key: 'push (vapid)', origin: fileCfg.vapid ? (cfgPath || 'file') : 'default', display: fileCfg.vapid ? 'on' : 'off' });
243
+ rows.push({ key: 'voice (xfyun)', origin: fileCfg.xfyun ? (cfgPath || 'file') : 'default', display: fileCfg.xfyun ? 'on' : 'off' });
244
+ return rows;
245
+ }
@@ -0,0 +1,155 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { fileURLToPath, pathToFileURL } from 'node:url';
5
+ const MANAGED_MARK = '// handmux-managed-pi-extension:v1';
6
+ const ENTRY_MARK = '// handmux-entry:';
7
+ export class PiExtensionInstallError extends Error {
8
+ code;
9
+ constructor(code, message) {
10
+ super(message);
11
+ this.code = code;
12
+ this.name = 'PiExtensionInstallError';
13
+ }
14
+ }
15
+ function extensionDirectory(home) {
16
+ return path.join(home, '.pi', 'agent', 'extensions', 'handmux');
17
+ }
18
+ export function piExtensionFile(home = homedir()) {
19
+ return path.join(extensionDirectory(home), 'index.ts');
20
+ }
21
+ function errorCode(error) {
22
+ if (!error || typeof error !== 'object' || !('code' in error))
23
+ return null;
24
+ return typeof error.code === 'string' ? error.code : null;
25
+ }
26
+ function readManaged(file) {
27
+ try {
28
+ const stat = fs.lstatSync(file);
29
+ if (!stat.isFile())
30
+ return 'conflict';
31
+ const content = fs.readFileSync(file, 'utf8');
32
+ const lines = content.split('\n');
33
+ if (lines[0] !== MANAGED_MARK || !lines[1]?.startsWith(ENTRY_MARK))
34
+ return 'conflict';
35
+ const entryUrl = lines[1].slice(ENTRY_MARK.length);
36
+ if (!entryUrl.startsWith('file:'))
37
+ return 'conflict';
38
+ try {
39
+ fileURLToPath(entryUrl);
40
+ }
41
+ catch {
42
+ return 'conflict';
43
+ }
44
+ return { content, entryUrl };
45
+ }
46
+ catch (error) {
47
+ if (errorCode(error) === 'ENOENT')
48
+ return null;
49
+ throw error;
50
+ }
51
+ }
52
+ function expectedEntryUrl(entryFile) {
53
+ try {
54
+ return pathToFileURL(fs.realpathSync(entryFile)).href;
55
+ }
56
+ catch {
57
+ return pathToFileURL(path.resolve(entryFile)).href;
58
+ }
59
+ }
60
+ function wrapper(entryFile) {
61
+ const entryUrl = expectedEntryUrl(entryFile);
62
+ return [
63
+ MANAGED_MARK,
64
+ `${ENTRY_MARK}${entryUrl}`,
65
+ '// This tiny wrapper is owned by Handmux. Pi loads it from its documented global extension path.',
66
+ `export { default } from ${JSON.stringify(entryUrl)};`,
67
+ '',
68
+ ].join('\n');
69
+ }
70
+ function validateEntry(entryFile) {
71
+ if (!path.isAbsolute(entryFile)) {
72
+ throw new PiExtensionInstallError('invalid_entry', 'Pi Extension entry must be absolute');
73
+ }
74
+ try {
75
+ const resolved = fs.realpathSync(entryFile);
76
+ if (!fs.statSync(resolved).isFile())
77
+ throw new Error('not a file');
78
+ return resolved;
79
+ }
80
+ catch {
81
+ throw new PiExtensionInstallError('entry_missing', 'Bundled Pi Extension entry is unavailable');
82
+ }
83
+ }
84
+ function referencedEntryExists(entryUrl) {
85
+ try {
86
+ return fs.statSync(fileURLToPath(entryUrl)).isFile();
87
+ }
88
+ catch {
89
+ return false;
90
+ }
91
+ }
92
+ export function piExtensionStatus(home = homedir(), options) {
93
+ const installed = readManaged(piExtensionFile(home));
94
+ if (installed === null)
95
+ return 'absent';
96
+ if (installed === 'conflict')
97
+ return 'conflict';
98
+ if (!referencedEntryExists(installed.entryUrl))
99
+ return 'stale';
100
+ if (options?.entryFile !== undefined
101
+ && installed.entryUrl !== expectedEntryUrl(options.entryFile))
102
+ return 'stale';
103
+ return 'installed';
104
+ }
105
+ function writeAtomic(file, content) {
106
+ const directory = path.dirname(file);
107
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
108
+ fs.chmodSync(directory, 0o700);
109
+ const temporary = `${file}.${process.pid}.${Date.now()}.tmp`;
110
+ try {
111
+ fs.writeFileSync(temporary, content, { flag: 'wx', mode: 0o600 });
112
+ fs.renameSync(temporary, file);
113
+ fs.chmodSync(file, 0o600);
114
+ }
115
+ finally {
116
+ try {
117
+ fs.unlinkSync(temporary);
118
+ }
119
+ catch { /* renamed or never created */ }
120
+ }
121
+ }
122
+ export function installPiExtension(home = homedir(), { entryFile }) {
123
+ const resolvedEntry = validateEntry(entryFile);
124
+ const file = piExtensionFile(home);
125
+ const existing = readManaged(file);
126
+ if (existing === 'conflict') {
127
+ throw new PiExtensionInstallError('conflict', 'Pi Extension target exists and is not owned by Handmux');
128
+ }
129
+ const content = wrapper(resolvedEntry);
130
+ const changed = existing?.content !== content;
131
+ if (changed)
132
+ writeAtomic(file, content);
133
+ return { status: 'installed', changed, file, entryFile: resolvedEntry };
134
+ }
135
+ export function syncPiExtension(home = homedir(), options) {
136
+ const status = piExtensionStatus(home, options);
137
+ if (status === 'absent' || status === 'conflict')
138
+ return null;
139
+ return installPiExtension(home, options);
140
+ }
141
+ export function uninstallPiExtension(home = homedir()) {
142
+ const file = piExtensionFile(home);
143
+ const existing = readManaged(file);
144
+ if (existing === null)
145
+ return { changed: false, file };
146
+ if (existing === 'conflict') {
147
+ throw new PiExtensionInstallError('conflict', 'Pi Extension target exists and is not owned by Handmux');
148
+ }
149
+ fs.unlinkSync(file);
150
+ try {
151
+ fs.rmdirSync(path.dirname(file));
152
+ }
153
+ catch { /* preserve non-empty/user-owned directory */ }
154
+ return { changed: true, file };
155
+ }
@@ -0,0 +1,16 @@
1
+ export async function probe(url, { fetchImpl = globalThis.fetch, timeoutMs = 6000, } = {}) {
2
+ if (!url)
3
+ return false;
4
+ const ac = new AbortController();
5
+ const t = setTimeout(() => ac.abort(), timeoutMs);
6
+ try {
7
+ await fetchImpl(url, { method: 'GET', redirect: 'manual', signal: ac.signal });
8
+ return true; // any HTTP response (even 401/404) means the chain is reachable
9
+ }
10
+ catch {
11
+ return false;
12
+ }
13
+ finally {
14
+ clearTimeout(t);
15
+ }
16
+ }
@@ -2,16 +2,14 @@
2
2
  // imports from here, so the prompt library stays swappable behind one seam and the wizard's pure logic
3
3
  // (configFromAnswers / validators / …) never depends on it.
4
4
  import { select, text, password, confirm, note, intro, outro, cancel, isCancel } from '@clack/prompts';
5
-
6
5
  export { select, text, password, confirm, note, intro, outro, cancel };
7
-
8
6
  // Thrown by ask() when the user cancels a prompt (Esc / Ctrl-C), so the wizard catches ONCE at the top
9
7
  // instead of checking isCancel after every call.
10
8
  export const CANCELLED = Symbol('setup-cancelled');
11
-
12
9
  // Await a clack prompt, converting a cancel into a throw. Usage: `const v = await ask(select({…}))`.
13
10
  export async function ask(promptResult) {
14
- const v = await promptResult;
15
- if (isCancel(v)) throw CANCELLED;
16
- return v;
11
+ const v = await promptResult;
12
+ if (isCancel(v))
13
+ throw CANCELLED;
14
+ return v;
17
15
  }
@@ -0,0 +1,102 @@
1
+ // `handmux push <title> <body> [--session X]... [--device K]... [--tag T] [--url U]` — fire one
2
+ // notification to the phone through the already-running server (loopback + the server's own token).
3
+ // Scope is mutually exclusive: --device (by key) or --session, else all. Pure parse + injectable
4
+ // runner so it unit-tests without spawning or real fetch.
5
+ import { readState } from './state.js';
6
+ import { sanitizeNotificationUrl } from '../urlPolicy.js';
7
+ const collect = (acc, value) => acc.concat(String(value ?? '').split(',').map((item) => item.trim()).filter(Boolean));
8
+ const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
9
+ const finiteNumber = (value) => typeof value === 'number' && Number.isFinite(value) ? value : 0;
10
+ // argv is process.argv.slice(2), i.e. ['push', title, body, ...flags]. The shared parseArgs() drops bare
11
+ // words after the command, so title/body are taken positionally; --session/--device may repeat.
12
+ export function parsePushArgs(argv) {
13
+ const rest = argv.slice(1);
14
+ const positional = [];
15
+ let sessions = [];
16
+ let devices = [];
17
+ let tag;
18
+ let url;
19
+ for (let i = 0; i < rest.length; i++) {
20
+ const a = rest[i] ?? '';
21
+ if (a === '--session') {
22
+ sessions = collect(sessions, rest[++i]);
23
+ }
24
+ else if (a === '--device') {
25
+ devices = collect(devices, rest[++i]);
26
+ }
27
+ else if (a === '--tag') {
28
+ tag = rest[++i];
29
+ }
30
+ else if (a === '--url') {
31
+ url = rest[++i];
32
+ }
33
+ else if (!a.startsWith('--'))
34
+ positional.push(a);
35
+ }
36
+ const [title, body] = positional;
37
+ if (!title || !body)
38
+ return { error: 'usage: handmux push <title> <body> [--session X]... [--device K]... [--tag T] [--url U]' };
39
+ if (sessions.length && devices.length)
40
+ return { error: 'use --session or --device, not both' };
41
+ const safeUrl = url == null ? null : sanitizeNotificationUrl(url);
42
+ if (url != null && !safeUrl)
43
+ return { error: '--url must be an http(s) URL or a relative path' };
44
+ const out = { title, body };
45
+ if (sessions.length)
46
+ out.sessions = sessions;
47
+ if (devices.length)
48
+ out.devices = devices;
49
+ if (tag)
50
+ out.tag = tag;
51
+ if (safeUrl)
52
+ out.url = safeUrl;
53
+ return out;
54
+ }
55
+ export async function runPush({ argv, home, fetchImpl = globalThis.fetch, log = console.log, err = console.error, }) {
56
+ const parsed = parsePushArgs(argv);
57
+ if (parsed.error) {
58
+ err(parsed.error);
59
+ return 1;
60
+ }
61
+ const st = readState(home);
62
+ if (!st || typeof st.localUrl !== 'string' || !st.localUrl
63
+ || typeof st.token !== 'string' || !st.token) {
64
+ err('handmux is not running — run `handmux start` first.');
65
+ return 1;
66
+ }
67
+ try {
68
+ const res = await fetchImpl(`${st.localUrl}/api/push/send-local`, {
69
+ method: 'POST',
70
+ headers: { Authorization: `Bearer ${st.token}`, 'Content-Type': 'application/json' },
71
+ body: JSON.stringify(parsed),
72
+ });
73
+ if (!res.ok) {
74
+ err(`push failed: ${res.status ?? 'unknown'}`);
75
+ return 1;
76
+ }
77
+ const raw = await res.json();
78
+ const out = isRecord(raw) ? raw : {};
79
+ if (out.configured === false) {
80
+ err('push is not configured (no VAPID keys) — run `handmux setup`.');
81
+ return 1;
82
+ }
83
+ const sent = finiteNumber(out.sent);
84
+ const failed = finiteNumber(out.failed);
85
+ const gone = finiteNumber(out.gone);
86
+ const counts = `sent: ${sent}, failed: ${failed}, gone: ${gone}`;
87
+ if (sent === 0) {
88
+ err(`no notification delivered (${counts})`);
89
+ return 1;
90
+ }
91
+ if (failed > 0) {
92
+ err(`push partially failed (${counts})`);
93
+ return 1;
94
+ }
95
+ log(`pushed (${counts})`);
96
+ return 0;
97
+ }
98
+ catch (e) {
99
+ err(`push failed: ${e instanceof Error ? e.message : String(e)}`);
100
+ return 1;
101
+ }
102
+ }
@@ -10,25 +10,25 @@
10
10
  //
11
11
  // matrix[r][c] === true means a DARK module. renderCompactQr is pure (no I/O) so it's unit-tested; the CLI
12
12
  // builds the matrix from qrcode-terminal's QR model and hands it here.
13
-
14
13
  export function renderCompactQr(matrix, { quiet = 2 } = {}) {
15
- const n = matrix.length;
16
- const side = n + quiet * 2;
17
- // A module is LIGHT when it's outside the code (quiet zone, incl. a dangling final half-row) or the matrix
18
- // cell is not dark.
19
- const light = (r, c) => {
20
- const mr = r - quiet, mc = c - quiet;
21
- if (mr < 0 || mr >= n || mc < 0 || mc >= n) return true;
22
- return !matrix[mr][mc];
23
- };
24
- let out = '';
25
- for (let r = 0; r < side; r += 2) {
26
- for (let c = 0; c < side; c++) {
27
- const top = light(r, c);
28
- const bottom = light(r + 1, c); // overflow row (odd side) is out-of-range → LIGHT, i.e. quiet border
29
- out += top && bottom ? '█' : top ? '▀' : bottom ? '▄' : ' ';
14
+ const n = matrix.length;
15
+ const side = n + quiet * 2;
16
+ // A module is LIGHT when it's outside the code (quiet zone, incl. a dangling final half-row) or the matrix
17
+ // cell is not dark.
18
+ const light = (r, c) => {
19
+ const mr = r - quiet, mc = c - quiet;
20
+ if (mr < 0 || mr >= n || mc < 0 || mc >= n)
21
+ return true;
22
+ return !(matrix[mr]?.[mc] ?? false);
23
+ };
24
+ let out = '';
25
+ for (let r = 0; r < side; r += 2) {
26
+ for (let c = 0; c < side; c++) {
27
+ const top = light(r, c);
28
+ const bottom = light(r + 1, c); // overflow row (odd side) is out-of-range → LIGHT, i.e. quiet border
29
+ out += top && bottom ? '█' : top ? '▀' : bottom ? '▄' : ' ';
30
+ }
31
+ out += '\n';
30
32
  }
31
- out += '\n';
32
- }
33
- return out;
33
+ return out;
34
34
  }