aicodeman 1.41.1 → 1.43.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 (276) hide show
  1. package/README.md +54 -24
  2. package/dist/claude-session-title.d.ts +6 -4
  3. package/dist/claude-session-title.d.ts.map +1 -1
  4. package/dist/claude-session-title.js +6 -4
  5. package/dist/claude-session-title.js.map +1 -1
  6. package/dist/cli-agent.js +1 -1
  7. package/dist/cli-agent.js.map +1 -1
  8. package/dist/config/cli-registry/patterns.d.ts +1 -1
  9. package/dist/config/cli-registry/patterns.d.ts.map +1 -1
  10. package/dist/config/cli-registry/patterns.js +3 -0
  11. package/dist/config/cli-registry/patterns.js.map +1 -1
  12. package/dist/config/cli-registry/schema.d.ts +3 -0
  13. package/dist/config/cli-registry/schema.d.ts.map +1 -1
  14. package/dist/config/cli-registry/schema.js +19 -3
  15. package/dist/config/cli-registry/schema.js.map +1 -1
  16. package/dist/config/cli-registry/stock.d.ts.map +1 -1
  17. package/dist/config/cli-registry/stock.js +302 -7
  18. package/dist/config/cli-registry/stock.js.map +1 -1
  19. package/dist/config/cli-registry/types.d.ts +37 -3
  20. package/dist/config/cli-registry/types.d.ts.map +1 -1
  21. package/dist/git-worktrees.d.ts +292 -0
  22. package/dist/git-worktrees.d.ts.map +1 -0
  23. package/dist/git-worktrees.js +791 -0
  24. package/dist/git-worktrees.js.map +1 -0
  25. package/dist/hooks-config.d.ts +9 -0
  26. package/dist/hooks-config.d.ts.map +1 -1
  27. package/dist/hooks-config.js +12 -0
  28. package/dist/hooks-config.js.map +1 -1
  29. package/dist/mcp-server.d.ts.map +1 -1
  30. package/dist/mcp-server.js +2 -1
  31. package/dist/mcp-server.js.map +1 -1
  32. package/dist/mcp-sync-targets.d.ts +6 -3
  33. package/dist/mcp-sync-targets.d.ts.map +1 -1
  34. package/dist/mcp-sync-targets.js +6 -3
  35. package/dist/mcp-sync-targets.js.map +1 -1
  36. package/dist/mux-interface.d.ts +7 -3
  37. package/dist/mux-interface.d.ts.map +1 -1
  38. package/dist/services/unified-session-service.d.ts +8 -0
  39. package/dist/services/unified-session-service.d.ts.map +1 -1
  40. package/dist/services/unified-session-service.js +21 -0
  41. package/dist/services/unified-session-service.js.map +1 -1
  42. package/dist/session-activity.d.ts +51 -0
  43. package/dist/session-activity.d.ts.map +1 -1
  44. package/dist/session-activity.js +62 -0
  45. package/dist/session-activity.js.map +1 -1
  46. package/dist/session-cli-registry-bridge.d.ts +2 -1
  47. package/dist/session-cli-registry-bridge.d.ts.map +1 -1
  48. package/dist/session-cli-registry-bridge.js.map +1 -1
  49. package/dist/session-submit-verifier.js +1 -1
  50. package/dist/session.d.ts +84 -9
  51. package/dist/session.d.ts.map +1 -1
  52. package/dist/session.js +232 -18
  53. package/dist/session.js.map +1 -1
  54. package/dist/tmux-manager.d.ts +62 -7
  55. package/dist/tmux-manager.d.ts.map +1 -1
  56. package/dist/tmux-manager.js +98 -20
  57. package/dist/tmux-manager.js.map +1 -1
  58. package/dist/types/session.d.ts +32 -4
  59. package/dist/types/session.d.ts.map +1 -1
  60. package/dist/types/session.js +2 -1
  61. package/dist/types/session.js.map +1 -1
  62. package/dist/utils/cli-installed-probes.d.ts +1 -1
  63. package/dist/utils/cli-installed-probes.d.ts.map +1 -1
  64. package/dist/utils/cli-installed-probes.js +4 -2
  65. package/dist/utils/cli-installed-probes.js.map +1 -1
  66. package/dist/utils/copilot-cli-resolver.d.ts +48 -0
  67. package/dist/utils/copilot-cli-resolver.d.ts.map +1 -0
  68. package/dist/utils/copilot-cli-resolver.js +112 -0
  69. package/dist/utils/copilot-cli-resolver.js.map +1 -0
  70. package/dist/utils/deepseek-cli-resolver.d.ts.map +1 -1
  71. package/dist/utils/deepseek-cli-resolver.js +1 -1
  72. package/dist/utils/deepseek-cli-resolver.js.map +1 -1
  73. package/dist/utils/index.d.ts +1 -0
  74. package/dist/utils/index.d.ts.map +1 -1
  75. package/dist/utils/index.js +1 -0
  76. package/dist/utils/index.js.map +1 -1
  77. package/dist/web/approval-inbox.d.ts +12 -2
  78. package/dist/web/approval-inbox.d.ts.map +1 -1
  79. package/dist/web/approval-inbox.js +7 -2
  80. package/dist/web/approval-inbox.js.map +1 -1
  81. package/dist/web/case-path.d.ts +4 -2
  82. package/dist/web/case-path.d.ts.map +1 -1
  83. package/dist/web/case-path.js +13 -4
  84. package/dist/web/case-path.js.map +1 -1
  85. package/dist/web/public/admin-ui.js.gz +0 -0
  86. package/dist/web/public/api-client.c9b1cddc.js.gz +0 -0
  87. package/dist/web/public/app.ae50029a.js +43 -0
  88. package/dist/web/public/app.ae50029a.js.br +0 -0
  89. package/dist/web/public/app.ae50029a.js.gz +0 -0
  90. package/dist/web/public/approvals-ui.js.gz +0 -0
  91. package/dist/web/public/{constants.17d77b65.js → constants.e5e588db.js} +156 -2
  92. package/dist/web/public/constants.e5e588db.js.br +0 -0
  93. package/dist/web/public/constants.e5e588db.js.gz +0 -0
  94. package/dist/web/public/cron-ui.js.gz +0 -0
  95. package/dist/web/public/entrance-animations.js.gz +0 -0
  96. package/dist/web/public/git-status-ui.js.gz +0 -0
  97. package/dist/web/public/home-sessions.js.gz +0 -0
  98. package/dist/web/public/host-wake-ui.js.gz +0 -0
  99. package/dist/web/public/i18n.8f291f06.js +2 -0
  100. package/dist/web/public/i18n.8f291f06.js.br +0 -0
  101. package/dist/web/public/i18n.8f291f06.js.gz +0 -0
  102. package/dist/web/public/{image-input.9ed1d0d2.js → image-input.941386e7.js} +41 -22
  103. package/dist/web/public/image-input.941386e7.js.br +0 -0
  104. package/dist/web/public/image-input.941386e7.js.gz +0 -0
  105. package/dist/web/public/index.html +88 -16
  106. package/dist/web/public/index.html.br +0 -0
  107. package/dist/web/public/index.html.gz +0 -0
  108. package/dist/web/public/input-cjk.8bc46081.js.gz +0 -0
  109. package/dist/web/public/{keyboard-accessory.c458eea8.js → keyboard-accessory.c283e827.js} +40 -29
  110. package/dist/web/public/keyboard-accessory.c283e827.js.br +0 -0
  111. package/dist/web/public/keyboard-accessory.c283e827.js.gz +0 -0
  112. package/dist/web/public/mobile-handlers.0ef9c311.js.gz +0 -0
  113. package/dist/web/public/mobile-ime-preview.2dbaf262.js.gz +0 -0
  114. package/dist/web/public/mobile-overview.js +1 -0
  115. package/dist/web/public/mobile-overview.js.br +0 -0
  116. package/dist/web/public/mobile-overview.js.gz +0 -0
  117. package/dist/web/public/mobile.13514863.css +1 -0
  118. package/dist/web/public/mobile.13514863.css.br +0 -0
  119. package/dist/web/public/mobile.13514863.css.gz +0 -0
  120. package/dist/web/public/notification-manager.feb82c04.js.gz +0 -0
  121. package/dist/web/public/orchestrator-panel.js.gz +0 -0
  122. package/dist/web/public/{panels-ui.73ec0137.js → panels-ui.b7bfb9da.js} +6 -6
  123. package/dist/web/public/panels-ui.b7bfb9da.js.br +0 -0
  124. package/dist/web/public/panels-ui.b7bfb9da.js.gz +0 -0
  125. package/dist/web/public/ralph-panel.b350e2ef.js.gz +0 -0
  126. package/dist/web/public/ralph-wizard.b6c0dd19.js.gz +0 -0
  127. package/dist/web/public/readmymind-ui.js.gz +0 -0
  128. package/dist/web/public/reboot-restore-ui.js.gz +0 -0
  129. package/dist/web/public/{respawn-ui.ff0dae4c.js → respawn-ui.d7adcf6a.js} +1 -1
  130. package/dist/web/public/respawn-ui.d7adcf6a.js.br +0 -0
  131. package/dist/web/public/respawn-ui.d7adcf6a.js.gz +0 -0
  132. package/dist/web/public/sanitize-html.e7147e76.js.gz +0 -0
  133. package/dist/web/public/security-notices.json +21 -0
  134. package/dist/web/public/session-lineage.js.gz +0 -0
  135. package/dist/web/public/session-ui.be076c88.js +100 -0
  136. package/dist/web/public/session-ui.be076c88.js.br +0 -0
  137. package/dist/web/public/session-ui.be076c88.js.gz +0 -0
  138. package/dist/web/public/settings-ui.fc4cc50a.js +97 -0
  139. package/dist/web/public/settings-ui.fc4cc50a.js.br +0 -0
  140. package/dist/web/public/settings-ui.fc4cc50a.js.gz +0 -0
  141. package/dist/web/public/spreadsheet-preview-worker.js.gz +0 -0
  142. package/dist/web/public/spreadsheet-preview.75c751de.js.gz +0 -0
  143. package/dist/web/public/spreadsheet-xlsx-core.js.gz +0 -0
  144. package/dist/web/public/styles.dcfa907f.css +1 -0
  145. package/dist/web/public/styles.dcfa907f.css.br +0 -0
  146. package/dist/web/public/styles.dcfa907f.css.gz +0 -0
  147. package/dist/web/public/subagent-windows.07277a58.js.gz +0 -0
  148. package/dist/web/public/sw.js +2 -2
  149. package/dist/web/public/sw.js.br +0 -0
  150. package/dist/web/public/sw.js.gz +0 -0
  151. package/dist/web/public/tab-layout-browser.2d6189f7.js.gz +0 -0
  152. package/dist/web/public/tab-rail-resize.7157e0bd.js.gz +0 -0
  153. package/dist/web/public/terminal-keycode229-recovery.af09f99a.js.gz +0 -0
  154. package/dist/web/public/terminal-split.js.gz +0 -0
  155. package/dist/web/public/terminal-tile.js +1 -0
  156. package/dist/web/public/terminal-tile.js.br +0 -0
  157. package/dist/web/public/terminal-tile.js.gz +0 -0
  158. package/dist/web/public/terminal-ui.ae01a7d1.js +2 -0
  159. package/dist/web/public/terminal-ui.ae01a7d1.js.br +0 -0
  160. package/dist/web/public/terminal-ui.ae01a7d1.js.gz +0 -0
  161. package/dist/web/public/tile-grid.js +3 -6
  162. package/dist/web/public/tile-grid.js.br +0 -0
  163. package/dist/web/public/tile-grid.js.gz +0 -0
  164. package/dist/web/public/ultracode-panel.js.gz +0 -0
  165. package/dist/web/public/ultracode-windows.js.gz +0 -0
  166. package/dist/web/public/vendor/dompurify.min.js.gz +0 -0
  167. package/dist/web/public/vendor/exceljs.min.js.gz +0 -0
  168. package/dist/web/public/vendor/fflate.min.js.gz +0 -0
  169. package/dist/web/public/vendor/marked.min.js.gz +0 -0
  170. package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
  171. package/dist/web/public/vendor/xterm-addon-serialize.min.js.gz +0 -0
  172. package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
  173. package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
  174. package/dist/web/public/vendor/xterm-predictive-echo.ae5e3d5d.js.gz +0 -0
  175. package/dist/web/public/vendor/xterm-zerolag-input.0b69623c.js.gz +0 -0
  176. package/dist/web/public/vendor/xterm.css.gz +0 -0
  177. package/dist/web/public/vendor/xterm.min.js.gz +0 -0
  178. package/dist/web/public/voice-input.2342c37b.js.gz +0 -0
  179. package/dist/web/public/voice-pcm-worklet.js.gz +0 -0
  180. package/dist/web/public/webview-tabs.js.gz +0 -0
  181. package/dist/web/public/whats-new.json +13 -0
  182. package/dist/web/response-viewer-transcript.d.ts +6 -0
  183. package/dist/web/response-viewer-transcript.d.ts.map +1 -1
  184. package/dist/web/response-viewer-transcript.js +134 -1
  185. package/dist/web/response-viewer-transcript.js.map +1 -1
  186. package/dist/web/routes/approval-routes.d.ts.map +1 -1
  187. package/dist/web/routes/approval-routes.js +12 -8
  188. package/dist/web/routes/approval-routes.js.map +1 -1
  189. package/dist/web/routes/case-routes.d.ts.map +1 -1
  190. package/dist/web/routes/case-routes.js +9 -2
  191. package/dist/web/routes/case-routes.js.map +1 -1
  192. package/dist/web/routes/index.d.ts +1 -0
  193. package/dist/web/routes/index.d.ts.map +1 -1
  194. package/dist/web/routes/index.js +1 -0
  195. package/dist/web/routes/index.js.map +1 -1
  196. package/dist/web/routes/mcp-sync-routes.d.ts +4 -4
  197. package/dist/web/routes/mcp-sync-routes.js +4 -4
  198. package/dist/web/routes/ralph-routes.d.ts.map +1 -1
  199. package/dist/web/routes/ralph-routes.js +19 -14
  200. package/dist/web/routes/ralph-routes.js.map +1 -1
  201. package/dist/web/routes/session-routes.d.ts +19 -7
  202. package/dist/web/routes/session-routes.d.ts.map +1 -1
  203. package/dist/web/routes/session-routes.js +227 -66
  204. package/dist/web/routes/session-routes.js.map +1 -1
  205. package/dist/web/routes/status-telemetry-routes.d.ts.map +1 -1
  206. package/dist/web/routes/status-telemetry-routes.js +9 -5
  207. package/dist/web/routes/status-telemetry-routes.js.map +1 -1
  208. package/dist/web/routes/system-routes.d.ts +9 -0
  209. package/dist/web/routes/system-routes.d.ts.map +1 -1
  210. package/dist/web/routes/system-routes.js +102 -12
  211. package/dist/web/routes/system-routes.js.map +1 -1
  212. package/dist/web/routes/worktree-routes.d.ts +52 -0
  213. package/dist/web/routes/worktree-routes.d.ts.map +1 -0
  214. package/dist/web/routes/worktree-routes.js +220 -0
  215. package/dist/web/routes/worktree-routes.js.map +1 -0
  216. package/dist/web/schemas.d.ts +30 -1
  217. package/dist/web/schemas.d.ts.map +1 -1
  218. package/dist/web/schemas.js +74 -3
  219. package/dist/web/schemas.js.map +1 -1
  220. package/dist/web/screen-blocked.d.ts +80 -0
  221. package/dist/web/screen-blocked.d.ts.map +1 -0
  222. package/dist/web/screen-blocked.js +106 -0
  223. package/dist/web/screen-blocked.js.map +1 -0
  224. package/dist/web/server.d.ts.map +1 -1
  225. package/dist/web/server.js +23 -2
  226. package/dist/web/server.js.map +1 -1
  227. package/dist/web/session-listener-wiring.d.ts +1 -0
  228. package/dist/web/session-listener-wiring.d.ts.map +1 -1
  229. package/dist/web/session-listener-wiring.js +20 -0
  230. package/dist/web/session-listener-wiring.js.map +1 -1
  231. package/dist/web/session-wait-registry.d.ts +27 -9
  232. package/dist/web/session-wait-registry.d.ts.map +1 -1
  233. package/dist/web/session-wait-registry.js +36 -13
  234. package/dist/web/session-wait-registry.js.map +1 -1
  235. package/dist/web/sse-events.d.ts +7 -2
  236. package/dist/web/sse-events.d.ts.map +1 -1
  237. package/dist/web/sse-events.js +7 -2
  238. package/dist/web/sse-events.js.map +1 -1
  239. package/package.json +2 -2
  240. package/skills/codeman/SKILL.md +21 -17
  241. package/skills/codeman/preamble.sh +14 -11
  242. package/skills/codeman/reference/endpoints.md +8 -8
  243. package/skills/codeman/reference/messaging.md +5 -5
  244. package/skills/codeman/reference/recipes.md +13 -10
  245. package/skills/codeman/reference/verbs.md +7 -5
  246. package/dist/web/public/app.24689381.js +0 -43
  247. package/dist/web/public/app.24689381.js.br +0 -0
  248. package/dist/web/public/app.24689381.js.gz +0 -0
  249. package/dist/web/public/constants.17d77b65.js.br +0 -0
  250. package/dist/web/public/constants.17d77b65.js.gz +0 -0
  251. package/dist/web/public/i18n.9afd8e4d.js +0 -2
  252. package/dist/web/public/i18n.9afd8e4d.js.br +0 -0
  253. package/dist/web/public/i18n.9afd8e4d.js.gz +0 -0
  254. package/dist/web/public/image-input.9ed1d0d2.js.br +0 -0
  255. package/dist/web/public/image-input.9ed1d0d2.js.gz +0 -0
  256. package/dist/web/public/keyboard-accessory.c458eea8.js.br +0 -0
  257. package/dist/web/public/keyboard-accessory.c458eea8.js.gz +0 -0
  258. package/dist/web/public/mobile.2dff1580.css +0 -1
  259. package/dist/web/public/mobile.2dff1580.css.br +0 -0
  260. package/dist/web/public/mobile.2dff1580.css.gz +0 -0
  261. package/dist/web/public/panels-ui.73ec0137.js.br +0 -0
  262. package/dist/web/public/panels-ui.73ec0137.js.gz +0 -0
  263. package/dist/web/public/respawn-ui.ff0dae4c.js.br +0 -0
  264. package/dist/web/public/respawn-ui.ff0dae4c.js.gz +0 -0
  265. package/dist/web/public/session-ui.dbc6819b.js +0 -100
  266. package/dist/web/public/session-ui.dbc6819b.js.br +0 -0
  267. package/dist/web/public/session-ui.dbc6819b.js.gz +0 -0
  268. package/dist/web/public/settings-ui.81ac264e.js +0 -97
  269. package/dist/web/public/settings-ui.81ac264e.js.br +0 -0
  270. package/dist/web/public/settings-ui.81ac264e.js.gz +0 -0
  271. package/dist/web/public/styles.900a28e4.css +0 -1
  272. package/dist/web/public/styles.900a28e4.css.br +0 -0
  273. package/dist/web/public/styles.900a28e4.css.gz +0 -0
  274. package/dist/web/public/terminal-ui.858f5da5.js +0 -2
  275. package/dist/web/public/terminal-ui.858f5da5.js.br +0 -0
  276. package/dist/web/public/terminal-ui.858f5da5.js.gz +0 -0
@@ -10,6 +10,7 @@ import { execFile } from 'node:child_process';
10
10
  import fs from 'node:fs/promises';
11
11
  import { finished } from 'node:stream/promises';
12
12
  import { randomBytes, randomUUID } from 'node:crypto';
13
+ import { isUtf8 } from 'node:buffer';
13
14
  import { performance } from 'node:perf_hooks';
14
15
  import { ApiErrorCode, createErrorResponse, getErrorMessage, } from '../../types.js';
15
16
  import { Session, cliTakesSessionModel, isAltScreenStripMode, isExternalCliMode, isMuxAltScreenOnlyStripMode, isMuxMouseStripMode, } from '../../session.js';
@@ -51,12 +52,13 @@ import { dataPath, getDataDir } from '../../config/instance.js';
51
52
  import { checkRemoteTmuxAvailable, readRemoteCases, readRemoteHosts, rehydrateRemoteHostFields, toAttachedSessionRemote, toSessionRemote, } from '../../remote-hosts.js';
52
53
  import { fetchRemoteScrollback } from '../../remote-pane-history.js';
53
54
  import { checkDockerAvailable, checkDockerConfigDrift, probeAdoptableContainer, checkDockerTmuxAvailable, ensureAgentBaseImage, DEFAULT_AGENT_IMAGE, persistDockerCaseClaudeSessionId, readDockerCases, readDockerHosts, toSessionDocker, } from '../../docker-hosts.js';
55
+ import { resolveCaseRepository, resolveManagedWorktree } from '../../git-worktrees.js';
54
56
  import { LRUMap } from '../../utils/lru-map.js';
55
57
  import { describeUnknownPath, probePathKind } from '../../utils/index.js';
56
58
  import { findLatestOmpSessionId } from '../../utils/omp-session-resolver.js';
57
59
  import { scanOmpSessionsHistory } from '../../omp-transcript.js';
58
60
  import { scanCodexSessionsHistory, codexThreadBySessionId } from '../../codex-transcript.js';
59
- import { getLastTranscriptResponse, isExternalCliTranscriptMode, parseExternalCliTranscript, selectLastAnsweredTurn, } from '../response-viewer-transcript.js';
61
+ import { transcriptNeedsScreenCapture, getLastTranscriptResponse, isExternalCliTranscriptMode, parseExternalCliTranscript, selectLastAnsweredTurn, } from '../response-viewer-transcript.js';
60
62
  import { readDeepSeekLastResponse } from '../../deepseek-transcript.js';
61
63
  import { appendClaudeCustomTitle } from '../../claude-session-title.js';
62
64
  import { UPLOADS_DIR } from '../paste-image-gc.js';
@@ -183,19 +185,53 @@ export function stripInkRedrawBloat(buffer) {
183
185
  return parts.join('');
184
186
  }
185
187
  /**
186
- * Validate image bytes against a declared extension. Sniffs the first ~12 bytes
187
- * for a known magic-number signature. Defends against polyglots (e.g. HTML or
188
- * SVG disguised under a `Content-Type: image/png` header) and against simple
189
- * extension-only spoofing — both the multipart filename and the Content-Type
190
- * are attacker-controlled, the raw bytes are not.
188
+ * The text half of the document allowlist: these are judged on their encoding
189
+ * (valid UTF-8, no NUL byte) rather than on a signature, since plain text has none.
190
+ */
191
+ const TEXT_UPLOAD_EXTS = new Set(['.txt', '.md', '.csv', '.tsv', '.json', '.log', '.yaml', '.yml', '.xml']);
192
+ /**
193
+ * Documents a user hands an agent to read: the office formats plus the text
194
+ * family. Curated on purpose, never SUPPORTED_ATTACHMENT_EXTENSIONS or
195
+ * EDITABLE_EXTENSIONS, which carry sh/py/js/html; a prompt upload is a thing
196
+ * to read, not a thing to run or serve. Each is judged on its bytes by
197
+ * imageMagicMatchesExt (signature, or UTF-8 without NUL for text). The browser
198
+ * keeps its own copies (test/upload-document-list-parity.test.ts pins them).
199
+ */
200
+ export const ALLOWED_DOCUMENT_EXTS = new Set([
201
+ '.pdf',
202
+ '.docx',
203
+ '.pptx',
204
+ '.xlsx',
205
+ '.zip',
206
+ ...TEXT_UPLOAD_EXTS,
207
+ ]);
208
+ /**
209
+ * Validate upload bytes against a declared extension. Sniffs the first ~12 bytes
210
+ * for a known magic-number signature (a text file is instead required to be
211
+ * UTF-8 without a NUL byte, the one check plain text allows). Defends against
212
+ * polyglots (e.g. HTML or SVG disguised under a `Content-Type: image/png`
213
+ * header) and against simple extension-only spoofing — both the multipart
214
+ * filename and the Content-Type are attacker-controlled, the raw bytes are not.
215
+ * Fail-closed: an extension this function does not know is never a match.
191
216
  *
192
217
  * Signatures: https://en.wikipedia.org/wiki/List_of_file_signatures
193
218
  */
194
219
  export function imageMagicMatchesExt(data, ext) {
220
+ if (TEXT_UPLOAD_EXTS.has(ext))
221
+ return data.length > 0 && !data.includes(0) && isUtf8(data);
195
222
  if (data.length < 12)
196
223
  return false;
197
224
  const u32be = (off) => data.readUInt32BE(off);
198
225
  switch (ext) {
226
+ case '.pdf':
227
+ // "%PDF-"
228
+ return u32be(0) === 0x25504446 && data[4] === 0x2d;
229
+ case '.docx':
230
+ case '.pptx':
231
+ case '.xlsx':
232
+ case '.zip':
233
+ // PK\x03\x04, the local-file header every non-empty zip (OOXML included) opens with
234
+ return u32be(0) === 0x504b0304;
199
235
  case '.png':
200
236
  return u32be(0) === 0x89504e47 && u32be(4) === 0x0d0a1a0a;
201
237
  case '.jpg':
@@ -294,11 +330,26 @@ async function ensureUploadDir(workingDir) {
294
330
  }
295
331
  return uploadDir;
296
332
  }
333
+ /**
334
+ * The part of an uploaded document's name that survives into the saved name,
335
+ * so `Q3 report (final).pdf` is findable as `paste-<ts>-<hex>-Q3-report-final.pdf`.
336
+ * Only `[A-Za-z0-9._-]` survive, runs of anything else become one `-`, and the
337
+ * result is capped at 40 chars; nothing in it can be a path separator or a
338
+ * second extension that matters (the allowlisted extension is appended after).
339
+ */
340
+ function sanitizeUploadStem(filename) {
341
+ const stem = basename(filename).replace(/\.[^.]*$/, '');
342
+ return stem
343
+ .replace(/[^A-Za-z0-9._-]+/g, '-')
344
+ .replace(/^[-.]+|[-.]+$/g, '')
345
+ .slice(0, 40);
346
+ }
297
347
  /** Open a fresh, uniquely named paste file for writing. */
298
- async function openPasteFile(imageDir, ext) {
348
+ async function openPasteFile(imageDir, ext, stem = '') {
299
349
  // Date.now() collides on same-ms uploads from two tabs (last-write wins
300
350
  // silently). Append 8 hex chars so concurrent pastes get distinct names.
301
- const filename = `paste-${Date.now()}-${randomBytes(4).toString('hex')}${ext}`;
351
+ // The `paste-` prefix is what the GC (paste-image-gc.ts) matches on.
352
+ const filename = `paste-${Date.now()}-${randomBytes(4).toString('hex')}${stem ? `-${stem}` : ''}${ext}`;
302
353
  const filepath = join(imageDir, filename);
303
354
  // O_EXCL: refuse to overwrite (collision is impossible with random suffix,
304
355
  // but defends against TOCTOU). O_NOFOLLOW: refuse if filepath is a symlink.
@@ -421,7 +472,7 @@ async function clampExternalCliBypassForOwner(owner, configs) {
421
472
  * named individually, so `privilegedParams` on anything outside that list was declared but
422
473
  * unreachable.
423
474
  */
424
- export async function _clampExternalCliBypassForOwner(owner, codexConfig, geminiConfig, antigravityConfig, piConfig, grokConfig, deepSeekConfig) {
475
+ export async function _clampExternalCliBypassForOwner(owner, codexConfig, geminiConfig, antigravityConfig, piConfig, grokConfig, deepSeekConfig, copilotConfig) {
425
476
  const out = await clampExternalCliBypassForOwner(owner, {
426
477
  codexConfig,
427
478
  geminiConfig,
@@ -429,6 +480,7 @@ export async function _clampExternalCliBypassForOwner(owner, codexConfig, gemini
429
480
  piConfig,
430
481
  grokConfig,
431
482
  deepSeekConfig,
483
+ copilotConfig,
432
484
  });
433
485
  return out;
434
486
  }
@@ -505,6 +557,23 @@ function currentSignalFor(session, dead) {
505
557
  return 'exit';
506
558
  return signalForStatus(session.status);
507
559
  }
560
+ /**
561
+ * `currentSignalFor`, answering `blocked` for a session whose pane is showing a dialog
562
+ * its CLI is blocked on (`Session.screenBlocked`, read off the screen for a CLI with no
563
+ * hook: codex) when the caller asked for it. A hook-raised `blocked` is an edge with no
564
+ * history, but this one is a level the server is still looking at, so a caller that
565
+ * asks after the dialog appeared is answered at once instead of waiting out its timeout
566
+ * for an edge that already passed. Only when `blocked` was asked for: such a pane is also
567
+ * `idle`, and a caller that asked for `idle` alone must keep getting `idle`. Used by
568
+ * `GET .../wait` and by a DUPLICATE send-and-wait, every place a wait answers from the
569
+ * session's current state.
570
+ */
571
+ function currentSignalForWait(session, dead, until) {
572
+ const signal = currentSignalFor(session, dead);
573
+ if (signal !== 'exit' && session.screenBlocked && until.includes('blocked'))
574
+ return 'blocked';
575
+ return signal;
576
+ }
508
577
  // ── Worker liveness for tmux-backed sessions ────────────────────────────────
509
578
  //
510
579
  // `session.pid` is the LOCAL `tmux attach` client, not the worker. Codeman sets
@@ -1050,7 +1119,7 @@ options = {}) {
1050
1119
  // Section 6.3: force non-granted users to a classifier-guarded mode.
1051
1120
  const effectiveClaudeMode = await resolveClaudeModeForUsername(claudeModeConfig.claudeMode, owner);
1052
1121
  // Section 6.3: clamp Codex/Gemini/Antigravity bypass switches for a non-granted owner (no-op single-user/granted).
1053
- const { codexConfig: gatedCodexConfig, geminiConfig: gatedGeminiConfig, antigravityConfig: gatedAntigravityConfig, piConfig: gatedPiConfig, grokConfig: gatedGrokConfig, deepSeekConfig: gatedDeepSeekConfig, } = await _clampExternalCliBypassForOwner(owner, launchBody.codexConfig, launchBody.geminiConfig, launchBody.antigravityConfig, launchBody.piConfig, launchBody.grokConfig, launchBody.deepSeekConfig);
1122
+ const { codexConfig: gatedCodexConfig, geminiConfig: gatedGeminiConfig, antigravityConfig: gatedAntigravityConfig, piConfig: gatedPiConfig, grokConfig: gatedGrokConfig, deepSeekConfig: gatedDeepSeekConfig, copilotConfig: gatedCopilotConfig, } = await _clampExternalCliBypassForOwner(owner, launchBody.codexConfig, launchBody.geminiConfig, launchBody.antigravityConfig, launchBody.piConfig, launchBody.grokConfig, launchBody.deepSeekConfig, launchBody.copilotConfig);
1054
1123
  const terminalHistoryConfig = await ctx.getTerminalHistoryConfig();
1055
1124
  const session = new Session({
1056
1125
  workingDir,
@@ -1068,6 +1137,7 @@ options = {}) {
1068
1137
  antigravityConfig: mode === 'antigravity' ? gatedAntigravityConfig : undefined,
1069
1138
  piConfig: mode === 'pi' ? gatedPiConfig : undefined,
1070
1139
  grokConfig: mode === 'grok' ? gatedGrokConfig : undefined,
1140
+ copilotConfig: mode === 'copilot' ? gatedCopilotConfig : undefined,
1071
1141
  deepSeekConfig: mode === 'deepseek' ? gatedDeepSeekConfig : undefined,
1072
1142
  ompConfig: resolveOmpConfigForCreate(mode, workingDir, launchBody.ompConfig),
1073
1143
  resumeSessionId: validatedResumeId,
@@ -1646,7 +1716,9 @@ options = {}) {
1646
1716
  // A DUPLICATE has no new turn coming, so it answers from the current state
1647
1717
  // instead of blocking for a transition that already happened.
1648
1718
  requireTransition: !duplicate,
1649
- currentSignal: duplicate ? currentSignalFor(session, workerDead) : undefined,
1719
+ // The `...ForWait` variant, as on GET .../wait: a screen-read dialog the first
1720
+ // delivery raised is a level, and a retry asking for `blocked` must find it.
1721
+ currentSignal: duplicate ? currentSignalForWait(session, workerDead, until) : undefined,
1650
1722
  });
1651
1723
  }
1652
1724
  catch (err) {
@@ -1663,7 +1735,7 @@ options = {}) {
1663
1735
  }
1664
1736
  const stopDeathWatch = wantsWait ? watchForDeadWorker(ctx.mux, session, id) : () => { };
1665
1737
  // Write input to PTY. Direct write is synchronous; writeViaMux
1666
- // (tmux send-keys) is fire-and-forget to avoid blocking the HTTP response.
1738
+ // (tmux paste + Enter) is fire-and-forget to avoid blocking the HTTP response.
1667
1739
  // Every write here is `fromUser`: this route carries a person's prompt, or an
1668
1740
  // agent's on their behalf, so it may name the tab (Ralph, respawn, cron and
1669
1741
  // approvals write through the session directly and never say so).
@@ -1685,7 +1757,7 @@ options = {}) {
1685
1757
  // A plain prompt (`<text>\r`) goes through the mux even when the caller did not
1686
1758
  // ask for it: written straight into the pane it arrives as one burst, and Claude
1687
1759
  // Code takes a long burst as a paste whose `\r` becomes a newline, so the prompt
1688
- // sat unsent (see isPlainPromptInput). The mux path types the text, presses Enter
1760
+ // sat unsent (see isPlainPromptInput). The mux path pastes the text, presses Enter
1689
1761
  // separately and arms the SubmitVerifier. An explicit `useMux: false` keeps the
1690
1762
  // raw write for a caller that really wants it.
1691
1763
  const autoMux = useMux === undefined && isPlainPromptInput(inputStr);
@@ -1833,7 +1905,7 @@ options = {}) {
1833
1905
  abortSignal: abort.signal,
1834
1906
  requireTransition: query.fresh === '1' || query.fresh === 'true',
1835
1907
  // Read BEFORE awaiting: this is the state the caller is asking about.
1836
- currentSignal: currentSignalFor(session, workerIsDead(ctx.mux, session)),
1908
+ currentSignal: currentSignalForWait(session, workerIsDead(ctx.mux, session), until),
1837
1909
  });
1838
1910
  return {
1839
1911
  success: true,
@@ -2363,7 +2435,13 @@ options = {}) {
2363
2435
  // where a real rollout file is the better source.
2364
2436
  if (isExternalCliTranscriptMode(session.mode)) {
2365
2437
  const externalQuery = req.query;
2366
- const blocks = parseExternalCliTranscript(session.terminalBuffer, session.mode);
2438
+ // A repainting full-screen TUI (copilot) streams absolute cursor moves, not lines, so the
2439
+ // buffer cannot be segmented; read the pane's visible screen instead, and fall back to the
2440
+ // buffer when there is no tmux pane to ask (direct PTY, tests).
2441
+ const screen = transcriptNeedsScreenCapture(session.mode) && session.muxName
2442
+ ? (ctx.mux.capturePaneText?.(session.muxName) ?? null)
2443
+ : null;
2444
+ const blocks = parseExternalCliTranscript(screen ?? session.terminalBuffer, session.mode);
2367
2445
  return {
2368
2446
  text: getLastTranscriptResponse(blocks),
2369
2447
  timestamp: '',
@@ -3173,7 +3251,7 @@ options = {}) {
3173
3251
  const capMsg = sessionCapacityMessage(ctx.sessions, owner);
3174
3252
  if (capMsg)
3175
3253
  return createErrorResponse(ApiErrorCode.SESSION_BUSY, capMsg);
3176
- const { caseName = 'testcase', sessionName, mode = 'claude', modelOverride, openCodeConfig, codexConfig, geminiConfig, antigravityConfig, piConfig, grokConfig, deepSeekConfig, ompConfig, envOverrides, effort, advisorModel, parentSessionId, agentOrigin, customModel, tabGroupId, } = parseBody(QuickStartSchema, req.body);
3254
+ const { caseName = 'testcase', sessionName, mode = 'claude', modelOverride, openCodeConfig, codexConfig, geminiConfig, antigravityConfig, piConfig, grokConfig, copilotConfig, deepSeekConfig, ompConfig, envOverrides, effort, advisorModel, parentSessionId, agentOrigin, customModel, tabGroupId, worktree, } = parseBody(QuickStartSchema, req.body);
3177
3255
  // Resolved ONCE here: the same value labels a case directory this request creates
3178
3256
  // (agent-case-marker.ts) and draws the tab lineage line on the session below.
3179
3257
  const qsParentSessionId = resolveParentSessionId(ctx, req, parentSessionId, owner);
@@ -3199,6 +3277,13 @@ options = {}) {
3199
3277
  const dockerCase = remoteCase
3200
3278
  ? undefined
3201
3279
  : (await readDockerCases(CODEMAN_CONFIG_DIR)).find((item) => item.name === caseName && canAccessOwned(getAuthUser(req), item.owner));
3280
+ // Worktrees are local only (src/git-worktrees.ts): refused BEFORE a remote host is woken or a
3281
+ // container is touched, since neither branch below could honour it.
3282
+ if (worktree && (remoteCase || dockerCase)) {
3283
+ return createErrorResponse(ApiErrorCode.INVALID_INPUT, remoteCase
3284
+ ? 'worktree is not available for remote (SSH) cases: the repository is on another machine'
3285
+ : 'worktree is not available for Docker cases: git would run on this machine over a workspace the container can write');
3286
+ }
3202
3287
  if (remoteCase) {
3203
3288
  const host = (await readRemoteHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === remoteCase.hostId);
3204
3289
  if (!host)
@@ -3216,6 +3301,7 @@ options = {}) {
3216
3301
  antigravityConfig ||
3217
3302
  piConfig ||
3218
3303
  grokConfig ||
3304
+ copilotConfig ||
3219
3305
  deepSeekConfig ||
3220
3306
  ompConfig ||
3221
3307
  openCodeConfig ||
@@ -3274,6 +3360,7 @@ options = {}) {
3274
3360
  antigravityConfig ||
3275
3361
  piConfig ||
3276
3362
  grokConfig ||
3363
+ copilotConfig ||
3277
3364
  deepSeekConfig ||
3278
3365
  ompConfig ||
3279
3366
  openCodeConfig ||
@@ -3358,6 +3445,7 @@ options = {}) {
3358
3445
  antigravityConfig,
3359
3446
  piConfig,
3360
3447
  grokConfig,
3448
+ copilotConfig,
3361
3449
  deepSeekConfig,
3362
3450
  }));
3363
3451
  if (qsLaunchError) {
@@ -3403,6 +3491,28 @@ options = {}) {
3403
3491
  if (localCaseState === 'unknown') {
3404
3492
  return createErrorResponse(ApiErrorCode.OPERATION_FAILED, describeUnknownPath('Case folder', resolvedCasePath, { pastCap: true }));
3405
3493
  }
3494
+ // Where the session runs: the case folder, or one of its git worktrees. A worktree is only
3495
+ // ever USED here (created through POST /api/cases/:name/worktrees), so a case that does not
3496
+ // exist yet is a 404 rather than a scaffold, and the worktree must be a registered, managed
3497
+ // one, every path component a real directory (src/git-worktrees.ts).
3498
+ let workspacePath = resolvedCasePath;
3499
+ if (worktree) {
3500
+ if (localCaseState === 'absent') {
3501
+ return createErrorResponse(ApiErrorCode.NOT_FOUND, `Case "${caseName}" not found`);
3502
+ }
3503
+ if (localCaseState !== 'directory') {
3504
+ return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Case path is not a folder');
3505
+ }
3506
+ const caseRepo = await resolveCaseRepository(resolvedCasePath, {
3507
+ dockerWorkspaces: (await readDockerCases(CODEMAN_CONFIG_DIR)).map((c) => c.hostWorkspacePath).filter(Boolean),
3508
+ });
3509
+ if (!caseRepo.ok)
3510
+ return createErrorResponse(ApiErrorCode[caseRepo.code], caseRepo.message);
3511
+ const managed = await resolveManagedWorktree(caseRepo.repo, worktree);
3512
+ if (!managed.ok)
3513
+ return createErrorResponse(ApiErrorCode[managed.code], managed.message);
3514
+ workspacePath = managed.path;
3515
+ }
3406
3516
  // Create case folder and CLAUDE.md if it doesn't exist (only for non-linked, non-remote,
3407
3517
  // non-docker cases — docker workspaces are scaffolded in their own block below)
3408
3518
  if (localCaseState === 'absent') {
@@ -3454,10 +3564,10 @@ options = {}) {
3454
3564
  // of its own. Skipped for remote cases — resolvedCasePath is a REMOTE path that
3455
3565
  // doesn't exist on the local filesystem.
3456
3566
  if (getCli(mode)?.capabilities.hooks === 'always') {
3457
- await applyWorkspaceHooks(resolvedCasePath, await ctx.getWorkspaceHooksEnabled());
3567
+ await applyWorkspaceHooks(workspacePath, await ctx.getWorkspaceHooksEnabled());
3458
3568
  }
3459
3569
  else {
3460
- await refreshStaleCodemanHooks(resolvedCasePath).catch(() => { });
3570
+ await refreshStaleCodemanHooks(workspacePath).catch(() => { });
3461
3571
  }
3462
3572
  }
3463
3573
  // Agent skill injection (docs/agent-control-plan.md §2): ADD-ONLY on create,
@@ -3468,7 +3578,7 @@ options = {}) {
3468
3578
  // hostWorkspacePath is a real host dir and the skill crosses the bind mount.
3469
3579
  const quickStartSkillDir = agentSkillDir(getCli(mode)?.capabilities);
3470
3580
  if (!remote && quickStartSkillDir && (await ctx.getAgentSkillEnabled())) {
3471
- await injectAgentSkill(resolvedCasePath, quickStartSkillDir);
3581
+ await injectAgentSkill(workspacePath, quickStartSkillDir);
3472
3582
  }
3473
3583
  // Docker cases: the workspace is a REAL host dir bind-mounted into the container.
3474
3584
  // Scaffold hooks (+ a CLAUDE.md) if MISSING so in-container permission prompts and
@@ -3501,13 +3611,13 @@ options = {}) {
3501
3611
  // docker — the docker workspace is a real host dir, so the settings file crosses
3502
3612
  // the bind mount and the in-container claude reads it). Remote was rejected above.
3503
3613
  if (getCli(mode)?.capabilities.model.source === 'claude-settings-file' && modelOverride !== undefined) {
3504
- await updateCaseModel(resolvedCasePath, modelOverride || null);
3614
+ await updateCaseModel(workspacePath, modelOverride || null);
3505
3615
  }
3506
3616
  // Strip stale disk entries for keys this request is actively setting (Claude only —
3507
3617
  // see POST /api/sessions for full rationale).
3508
3618
  // Same chain, same replacement as the create path above: byte-identical, drift-proof.
3509
3619
  if (!isExternalCliMode(mode) && !remote && envOverrides && Object.keys(envOverrides).length > 0) {
3510
- await stripCaseEnvKeys(resolvedCasePath, Object.keys(envOverrides));
3620
+ await stripCaseEnvKeys(workspacePath, Object.keys(envOverrides));
3511
3621
  }
3512
3622
  // Create a new session with the case as working directory
3513
3623
  // Apply global Nice priority config and model config from settings
@@ -3522,6 +3632,7 @@ options = {}) {
3522
3632
  antigravityConfig,
3523
3633
  piConfig,
3524
3634
  grokConfig,
3635
+ copilotConfig,
3525
3636
  deepSeekConfig,
3526
3637
  ompConfig,
3527
3638
  };
@@ -3540,10 +3651,10 @@ options = {}) {
3540
3651
  const qsClaudeModeConfig = await ctx.getClaudeModeConfig();
3541
3652
  const qsEffectiveClaudeMode = await resolveClaudeModeForUsername(qsClaudeModeConfig.claudeMode, owner);
3542
3653
  // Section 6.3: clamp Codex/Gemini/Antigravity bypass switches for a non-granted owner (no-op single-user/granted).
3543
- const { codexConfig: qsGatedCodexConfig, geminiConfig: qsGatedGeminiConfig, antigravityConfig: qsGatedAntigravityConfig, piConfig: qsGatedPiConfig, grokConfig: qsGatedGrokConfig, deepSeekConfig: qsGatedDeepSeekConfig, } = await _clampExternalCliBypassForOwner(owner, qsLaunchConfigs.codexConfig, qsLaunchConfigs.geminiConfig, qsLaunchConfigs.antigravityConfig, qsLaunchConfigs.piConfig, qsLaunchConfigs.grokConfig, qsLaunchConfigs.deepSeekConfig);
3654
+ const { codexConfig: qsGatedCodexConfig, geminiConfig: qsGatedGeminiConfig, antigravityConfig: qsGatedAntigravityConfig, piConfig: qsGatedPiConfig, grokConfig: qsGatedGrokConfig, deepSeekConfig: qsGatedDeepSeekConfig, copilotConfig: qsGatedCopilotConfig, } = await _clampExternalCliBypassForOwner(owner, qsLaunchConfigs.codexConfig, qsLaunchConfigs.geminiConfig, qsLaunchConfigs.antigravityConfig, qsLaunchConfigs.piConfig, qsLaunchConfigs.grokConfig, qsLaunchConfigs.deepSeekConfig, qsLaunchConfigs.copilotConfig);
3544
3655
  const qsTerminalHistoryConfig = await ctx.getTerminalHistoryConfig();
3545
3656
  const qsGatedEnvOverrides = await clampEnvOverridesForOwner(owner, envOverrides);
3546
- const qsResolvedOmpConfig = resolveOmpConfigForCreate(mode, resolvedCasePath, qsLaunchOmpConfig);
3657
+ const qsResolvedOmpConfig = resolveOmpConfigForCreate(mode, workspacePath, qsLaunchOmpConfig);
3547
3658
  // Custom Model Endpoint Profiles, applied AT CREATE TIME (docs/custom-model-endpoints-plan.md)
3548
3659
  // rather than via the dedicated restart-in-place route (POST /api/sessions/:id/custom-
3549
3660
  // model, still what an ALREADY-RUNNING session uses to switch later): computing the
@@ -3627,7 +3738,7 @@ options = {}) {
3627
3738
  // with, not a placeholder: `new Session({ id: ... })` accepts an explicit id for
3628
3739
  // exactly this reason.
3629
3740
  qsCustomModelSessionId = randomUUID();
3630
- const cmApplied = applyCustomModelInjection(cmEntry, cmEndpoint, customModel.modelId, qsCustomModelSessionId, cmContextLength, resolvedCasePath);
3741
+ const cmApplied = applyCustomModelInjection(cmEntry, cmEndpoint, customModel.modelId, qsCustomModelSessionId, cmContextLength, workspacePath);
3631
3742
  if (!cmApplied) {
3632
3743
  return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `${mode} has no known custom-model mechanism`);
3633
3744
  }
@@ -3659,7 +3770,7 @@ options = {}) {
3659
3770
  }
3660
3771
  const qsSessionOptions = {
3661
3772
  id: qsCustomModelSessionId,
3662
- workingDir: resolvedCasePath,
3773
+ workingDir: workspacePath,
3663
3774
  name: sessionName ? sessionName.slice(0, MAX_SESSION_NAME_LENGTH) : '',
3664
3775
  mux: ctx.mux,
3665
3776
  useMux: true,
@@ -3675,6 +3786,7 @@ options = {}) {
3675
3786
  antigravityConfig: mode === 'antigravity' ? qsGatedAntigravityConfig : undefined,
3676
3787
  piConfig: mode === 'pi' ? qsGatedPiConfig : undefined,
3677
3788
  grokConfig: mode === 'grok' ? qsGatedGrokConfig : undefined,
3789
+ copilotConfig: mode === 'copilot' ? qsGatedCopilotConfig : undefined,
3678
3790
  deepSeekConfig: mode === 'deepseek' ? qsGatedDeepSeekConfig : undefined,
3679
3791
  ompConfig: qsResolvedOmpConfig,
3680
3792
  envOverrides: qsCustomModelEnvOverrides,
@@ -3716,7 +3828,7 @@ options = {}) {
3716
3828
  // Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting
3717
3829
  // so the initial state already has the phrase configured (only if globally enabled)
3718
3830
  if (getCli(mode)?.capabilities.ralph && !remote && !docker && ctx.store.getConfig().ralphEnabled) {
3719
- autoConfigureRalph(session, resolvedCasePath, ctx);
3831
+ autoConfigureRalph(session, workspacePath, ctx);
3720
3832
  if (!session.ralphTracker.enabled) {
3721
3833
  session.ralphTracker.enable();
3722
3834
  session.ralphTracker.enableAutoEnable(); // Allow re-enabling on restart
@@ -3771,36 +3883,42 @@ options = {}) {
3771
3883
  if (docker && mode === 'claude') {
3772
3884
  void persistDockerCaseClaudeSessionId(CODEMAN_CONFIG_DIR, docker.containerName, session.claudeSessionId || session.id).catch(() => { });
3773
3885
  }
3774
- // Save lastUsedCase to settings for TUI/web sync
3775
- try {
3776
- const settingsFilePath = SETTINGS_PATH;
3777
- let settings = {};
3886
+ // Save lastUsedCase to settings for TUI/web sync. It is a key of the server-wide
3887
+ // settings.json, readable by every user through GET /api/settings, so a non-admin's
3888
+ // case name is not written there in multi-user mode (writes are admin-only, like
3889
+ // PUT /api/settings). isAdmin is always true in single-user mode.
3890
+ if (isAdmin(req)) {
3778
3891
  try {
3779
- settings = JSON.parse(await fs.readFile(settingsFilePath, 'utf-8'));
3892
+ const settingsFilePath = SETTINGS_PATH;
3893
+ let settings = {};
3894
+ try {
3895
+ settings = JSON.parse(await fs.readFile(settingsFilePath, 'utf-8'));
3896
+ }
3897
+ catch (err) {
3898
+ if (err.code !== 'ENOENT')
3899
+ throw err;
3900
+ }
3901
+ settings.lastUsedCase = caseName;
3902
+ const dir = dirname(settingsFilePath);
3903
+ if (!existsSync(dir)) {
3904
+ mkdirSync(dir, { recursive: true });
3905
+ }
3906
+ // Use async write to avoid blocking event loop
3907
+ fs.writeFile(settingsFilePath, JSON.stringify(settings, null, 2)).catch((err) => {
3908
+ // Non-critical but log for debugging
3909
+ console.warn('[Server] Failed to save settings (lastUsedCase):', err);
3910
+ });
3780
3911
  }
3781
3912
  catch (err) {
3782
- if (err.code !== 'ENOENT')
3783
- throw err;
3784
- }
3785
- settings.lastUsedCase = caseName;
3786
- const dir = dirname(settingsFilePath);
3787
- if (!existsSync(dir)) {
3788
- mkdirSync(dir, { recursive: true });
3789
- }
3790
- // Use async write to avoid blocking event loop
3791
- fs.writeFile(settingsFilePath, JSON.stringify(settings, null, 2)).catch((err) => {
3792
3913
  // Non-critical but log for debugging
3793
- console.warn('[Server] Failed to save settings (lastUsedCase):', err);
3794
- });
3795
- }
3796
- catch (err) {
3797
- // Non-critical but log for debugging
3798
- console.warn('[Server] Failed to prepare settings update:', err);
3914
+ console.warn('[Server] Failed to prepare settings update:', err);
3915
+ }
3799
3916
  }
3800
3917
  return {
3801
3918
  sessionId: session.id,
3802
3919
  casePath: resolvedCasePath,
3803
3920
  caseName,
3921
+ ...(worktree ? { worktree, workingDir: workspacePath } : {}),
3804
3922
  ...(customModel ? { modelSwapInProgress: qsCustomModelSwapInProgress } : {}),
3805
3923
  ...(tabGroupId ? { tabLayout } : {}),
3806
3924
  };
@@ -4701,6 +4819,20 @@ options = {}) {
4701
4819
  ogg: '.ogv',
4702
4820
  };
4703
4821
  // The per-file size cap (MAX_PASTE_IMAGE_BYTES) is enforced by @fastify/multipart (registered in server.ts).
4822
+ /** Write buffered upload bytes into the session's upload dir. Null: the dir is not a real directory. */
4823
+ async function writeBufferedUpload(workingDir, ext, bytes, stem = '') {
4824
+ const uploadDir = await ensureUploadDir(workingDir);
4825
+ if (!uploadDir)
4826
+ return null;
4827
+ const { filename, filepath, fh } = await openPasteFile(uploadDir, ext, stem);
4828
+ try {
4829
+ await fh.writeFile(bytes);
4830
+ }
4831
+ finally {
4832
+ await fh.close();
4833
+ }
4834
+ return { path: filepath, filename };
4835
+ }
4704
4836
  app.post('/api/sessions/:id/paste-image', async (req, reply) => {
4705
4837
  // CSRF defense: state-changing routes must come from same origin.
4706
4838
  // Cookies are SameSite=lax, multipart/form-data is a "simple" CORS request
@@ -4819,13 +4951,13 @@ options = {}) {
4819
4951
  reply.code(400);
4820
4952
  return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Unsupported video type. Allowed: ${[...ALLOWED_VIDEO_EXTS].join(', ')}`);
4821
4953
  }
4822
- const imageDir = await ensureUploadDir(session.workingDir);
4823
- if (!imageDir) {
4954
+ const uploadDir = await ensureUploadDir(session.workingDir);
4955
+ if (!uploadDir) {
4824
4956
  await finished(part.file.resume()).catch(() => { });
4825
4957
  reply.code(403);
4826
4958
  return createErrorResponse(ApiErrorCode.INVALID_INPUT, `${UPLOADS_DIR} is not a regular directory`);
4827
4959
  }
4828
- const { filename, filepath, fh } = await openPasteFile(imageDir, ext);
4960
+ const { filename, filepath, fh } = await openPasteFile(uploadDir, ext);
4829
4961
  // Written as it arrives: a phone video is far too large to buffer the way an
4830
4962
  // image is. The container is judged on the first 12 bytes as soon as they are
4831
4963
  // in, so a mislabeled upload stops touching disk at its first chunk; the rest
@@ -4887,13 +5019,41 @@ options = {}) {
4887
5019
  reply.code(400);
4888
5020
  return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Empty file');
4889
5021
  }
5022
+ // Dispatch mirrors the client's _promptAttachKind: an image MIME type takes
5023
+ // the image path whatever the name says, a document extension (the MIME a
5024
+ // phone gives a .md is anyone's guess) takes the document branch, anything
5025
+ // else the image path exactly as before, HEIC sniff included. See CLAUDE.md.
5026
+ const origExt = part.filename ? extname(part.filename).toLowerCase() : '';
5027
+ const imageMime = /^image\//i.test(part.mimetype || '');
5028
+ if (!imageMime && ALLOWED_DOCUMENT_EXTS.has(origExt)) {
5029
+ if (!imageMagicMatchesExt(imageBytes, origExt)) {
5030
+ // The first bytes of a text file are user content, so for text the log
5031
+ // names the failure and never the bytes; a signature is safe to print.
5032
+ // A text refusal names the encoding, since an Excel or Notepad export
5033
+ // (UTF-16 with a BOM, or Windows-1252) is not a corrupt file.
5034
+ const isText = TEXT_UPLOAD_EXTS.has(origExt);
5035
+ const bom = imageBytes.length >= 2 ? imageBytes.readUInt16BE(0) : 0;
5036
+ const utf16 = bom === 0xfffe || bom === 0xfeff;
5037
+ const detail = isText
5038
+ ? `reason=${utf16 ? 'utf16-bom' : imageBytes.includes(0) ? 'nul-byte' : 'not-utf8'}`
5039
+ : `magic=${imageBytes.subarray(0, 12).toString('hex')}`;
5040
+ console.warn(`[paste-image] document mismatch: filename=${JSON.stringify(part.filename)} mime=${JSON.stringify(part.mimetype)} declaredExt=${origExt} size=${imageBytes.length} ${detail}`);
5041
+ reply.code(415);
5042
+ return createErrorResponse(ApiErrorCode.INVALID_INPUT, isText
5043
+ ? `This ${origExt} file is ${utf16 ? 'UTF-16 text, not UTF-8' : 'not UTF-8 text'}; save it as UTF-8 and attach it again`
5044
+ : `File bytes do not match declared type ${origExt}`);
5045
+ }
5046
+ const saved = await writeBufferedUpload(session.workingDir, origExt, imageBytes, sanitizeUploadStem(part.filename));
5047
+ if (!saved) {
5048
+ reply.code(403);
5049
+ return createErrorResponse(ApiErrorCode.INVALID_INPUT, `${UPLOADS_DIR} is not a regular directory`);
5050
+ }
5051
+ return saved;
5052
+ }
4890
5053
  // Determine extension from filename or Content-Type.
4891
5054
  let ext = '.png';
4892
- if (part.filename) {
4893
- const origExt = extname(part.filename).toLowerCase();
4894
- if (ALLOWED_IMAGE_EXTS.has(origExt))
4895
- ext = origExt;
4896
- }
5055
+ if (ALLOWED_IMAGE_EXTS.has(origExt))
5056
+ ext = origExt;
4897
5057
  const mimeMatch = (part.mimetype || '').toLowerCase().match(/^image\/(png|jpeg|jpg|webp|gif|bmp|heic|heif)$/);
4898
5058
  if (mimeMatch) {
4899
5059
  const map = {
@@ -4929,6 +5089,15 @@ options = {}) {
4929
5089
  }
4930
5090
  }
4931
5091
  else if (!imageMagicMatchesExt(imageBytes, ext)) {
5092
+ // A foreign extension with no image MIME (a script, an HTML page, a video
5093
+ // without its flag) was never going to be an image: say so, rather than
5094
+ // the ".png" the default extension would name. Decided only HERE, after
5095
+ // the sniff, so anything the image path ever accepted by its bytes (a
5096
+ // HEIC under a generic name) still is.
5097
+ if (origExt && !ALLOWED_IMAGE_EXTS.has(origExt) && !imageMime) {
5098
+ reply.code(400);
5099
+ return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Unsupported file type: ${origExt}`);
5100
+ }
4932
5101
  // Sniff actual bytes — a polyglot HTML/PNG would otherwise pass and
4933
5102
  // serve back with image/png MIME. Log the real header so format
4934
5103
  // mismatches can be pinned down without a reproduce-and-guess loop. The
@@ -4937,20 +5106,12 @@ options = {}) {
4937
5106
  reply.code(415);
4938
5107
  return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Image bytes do not match declared type ${ext}`);
4939
5108
  }
4940
- // {workingDir}/.codeman-uploads/, see ensureUploadDir.
4941
- const imageDir = await ensureUploadDir(session.workingDir);
4942
- if (!imageDir) {
5109
+ const saved = await writeBufferedUpload(session.workingDir, ext, imageBytes);
5110
+ if (!saved) {
4943
5111
  reply.code(403);
4944
5112
  return createErrorResponse(ApiErrorCode.INVALID_INPUT, `${UPLOADS_DIR} is not a regular directory`);
4945
5113
  }
4946
- const { filename, filepath, fh } = await openPasteFile(imageDir, ext);
4947
- try {
4948
- await fh.writeFile(imageBytes);
4949
- }
4950
- finally {
4951
- await fh.close();
4952
- }
4953
- return { path: filepath, filename };
5114
+ return saved;
4954
5115
  });
4955
5116
  // Returned so the server can own the registry's LIFETIME (drop state when a session is
4956
5117
  // cleaned up on any of its paths, resolve in-flight wakes on shutdown). The wake-CAPABLE