aicodeman 1.2.2 → 1.3.1

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 (227) hide show
  1. package/README.md +264 -90
  2. package/dist/attachment-magic.d.ts +13 -0
  3. package/dist/attachment-magic.d.ts.map +1 -1
  4. package/dist/attachment-magic.js +47 -0
  5. package/dist/attachment-magic.js.map +1 -1
  6. package/dist/attachment-registry.d.ts.map +1 -1
  7. package/dist/attachment-registry.js +13 -2
  8. package/dist/attachment-registry.js.map +1 -1
  9. package/dist/config/buffer-limits.d.ts +1 -2
  10. package/dist/config/buffer-limits.d.ts.map +1 -1
  11. package/dist/config/buffer-limits.js +1 -2
  12. package/dist/config/buffer-limits.js.map +1 -1
  13. package/dist/config/map-limits.d.ts +11 -0
  14. package/dist/config/map-limits.d.ts.map +1 -1
  15. package/dist/config/map-limits.js +11 -0
  16. package/dist/config/map-limits.js.map +1 -1
  17. package/dist/config/server-timing.d.ts +6 -0
  18. package/dist/config/server-timing.d.ts.map +1 -1
  19. package/dist/config/server-timing.js +9 -0
  20. package/dist/config/server-timing.js.map +1 -1
  21. package/dist/config/terminal-history.d.ts +11 -6
  22. package/dist/config/terminal-history.d.ts.map +1 -1
  23. package/dist/config/terminal-history.js +18 -8
  24. package/dist/config/terminal-history.js.map +1 -1
  25. package/dist/cron/cron-input.d.ts +30 -0
  26. package/dist/cron/cron-input.d.ts.map +1 -0
  27. package/dist/cron/cron-input.js +7 -0
  28. package/dist/cron/cron-input.js.map +1 -0
  29. package/dist/cron/cron-service.d.ts +92 -0
  30. package/dist/cron/cron-service.d.ts.map +1 -0
  31. package/dist/cron/cron-service.js +606 -0
  32. package/dist/cron/cron-service.js.map +1 -0
  33. package/dist/cron/cron-time.d.ts +29 -0
  34. package/dist/cron/cron-time.d.ts.map +1 -0
  35. package/dist/cron/cron-time.js +87 -0
  36. package/dist/cron/cron-time.js.map +1 -0
  37. package/dist/document-thumbnailer.d.ts +1 -1
  38. package/dist/document-thumbnailer.d.ts.map +1 -1
  39. package/dist/document-thumbnailer.js +11 -2
  40. package/dist/document-thumbnailer.js.map +1 -1
  41. package/dist/generated-artifact-attachments.d.ts +26 -0
  42. package/dist/generated-artifact-attachments.d.ts.map +1 -0
  43. package/dist/generated-artifact-attachments.js +61 -0
  44. package/dist/generated-artifact-attachments.js.map +1 -0
  45. package/dist/index.js +11 -0
  46. package/dist/index.js.map +1 -1
  47. package/dist/mux-interface.d.ts +31 -5
  48. package/dist/mux-interface.d.ts.map +1 -1
  49. package/dist/remote-hosts.d.ts +66 -0
  50. package/dist/remote-hosts.d.ts.map +1 -0
  51. package/dist/remote-hosts.js +198 -0
  52. package/dist/remote-hosts.js.map +1 -0
  53. package/dist/services/unified-session-service.d.ts +115 -0
  54. package/dist/services/unified-session-service.d.ts.map +1 -0
  55. package/dist/services/unified-session-service.js +173 -0
  56. package/dist/services/unified-session-service.js.map +1 -0
  57. package/dist/session-cli-builder.d.ts +3 -1
  58. package/dist/session-cli-builder.d.ts.map +1 -1
  59. package/dist/session-cli-builder.js +26 -7
  60. package/dist/session-cli-builder.js.map +1 -1
  61. package/dist/session-pty-exit-breaker.d.ts +66 -0
  62. package/dist/session-pty-exit-breaker.d.ts.map +1 -0
  63. package/dist/session-pty-exit-breaker.js +77 -0
  64. package/dist/session-pty-exit-breaker.js.map +1 -0
  65. package/dist/session.d.ts +24 -1
  66. package/dist/session.d.ts.map +1 -1
  67. package/dist/session.js +94 -11
  68. package/dist/session.js.map +1 -1
  69. package/dist/state-store.d.ts +14 -0
  70. package/dist/state-store.d.ts.map +1 -1
  71. package/dist/state-store.js +49 -0
  72. package/dist/state-store.js.map +1 -1
  73. package/dist/tmux-manager.d.ts +94 -8
  74. package/dist/tmux-manager.d.ts.map +1 -1
  75. package/dist/tmux-manager.js +222 -26
  76. package/dist/tmux-manager.js.map +1 -1
  77. package/dist/types/api.d.ts +11 -0
  78. package/dist/types/api.d.ts.map +1 -1
  79. package/dist/types/api.js.map +1 -1
  80. package/dist/types/app-state.d.ts +5 -0
  81. package/dist/types/app-state.d.ts.map +1 -1
  82. package/dist/types/app-state.js.map +1 -1
  83. package/dist/types/cron.d.ts +88 -0
  84. package/dist/types/cron.d.ts.map +1 -0
  85. package/dist/types/cron.js +13 -0
  86. package/dist/types/cron.js.map +1 -0
  87. package/dist/types/session.d.ts +57 -0
  88. package/dist/types/session.d.ts.map +1 -1
  89. package/dist/types/session.js.map +1 -1
  90. package/dist/web/heic-jpeg-converter.d.ts +24 -0
  91. package/dist/web/heic-jpeg-converter.d.ts.map +1 -0
  92. package/dist/web/heic-jpeg-converter.js +76 -0
  93. package/dist/web/heic-jpeg-converter.js.map +1 -0
  94. package/dist/web/heic-jpeg-worker.d.ts +44 -0
  95. package/dist/web/heic-jpeg-worker.d.ts.map +1 -0
  96. package/dist/web/heic-jpeg-worker.js +78 -0
  97. package/dist/web/heic-jpeg-worker.js.map +1 -0
  98. package/dist/web/ports/cron-port.d.ts +9 -0
  99. package/dist/web/ports/cron-port.d.ts.map +1 -0
  100. package/dist/web/ports/cron-port.js +6 -0
  101. package/dist/web/ports/cron-port.js.map +1 -0
  102. package/dist/web/ports/index.d.ts +1 -0
  103. package/dist/web/ports/index.d.ts.map +1 -1
  104. package/dist/web/public/api-client.c9b1cddc.js.gz +0 -0
  105. package/dist/web/public/app.b0534652.js +38 -0
  106. package/dist/web/public/app.b0534652.js.br +0 -0
  107. package/dist/web/public/app.b0534652.js.gz +0 -0
  108. package/dist/web/public/{constants.1c779517.js → constants.5b18ed64.js} +65 -0
  109. package/dist/web/public/constants.5b18ed64.js.br +0 -0
  110. package/dist/web/public/constants.5b18ed64.js.gz +0 -0
  111. package/dist/web/public/cron-ui.js +328 -0
  112. package/dist/web/public/cron-ui.js.br +0 -0
  113. package/dist/web/public/cron-ui.js.gz +0 -0
  114. package/dist/web/public/image-input.ee16ad88.js.gz +0 -0
  115. package/dist/web/public/index.html +313 -25
  116. package/dist/web/public/index.html.br +0 -0
  117. package/dist/web/public/index.html.gz +0 -0
  118. package/dist/web/public/input-cjk.63794d0b.js +1 -0
  119. package/dist/web/public/input-cjk.63794d0b.js.br +0 -0
  120. package/dist/web/public/input-cjk.63794d0b.js.gz +0 -0
  121. package/dist/web/public/keyboard-accessory.3f91acef.js.gz +0 -0
  122. package/dist/web/public/mobile-handlers.b5a667ef.js.gz +0 -0
  123. package/dist/web/public/mobile.0644e3ac.css +1 -0
  124. package/dist/web/public/mobile.0644e3ac.css.br +0 -0
  125. package/dist/web/public/mobile.0644e3ac.css.gz +0 -0
  126. package/dist/web/public/notification-manager.ca9dc40d.js.gz +0 -0
  127. package/dist/web/public/orchestrator-panel.js.gz +0 -0
  128. package/dist/web/public/{panels-ui.772e92c9.js → panels-ui.ca03853f.js} +25 -17
  129. package/dist/web/public/panels-ui.ca03853f.js.br +0 -0
  130. package/dist/web/public/panels-ui.ca03853f.js.gz +0 -0
  131. package/dist/web/public/ralph-panel.6de2d0f8.js.gz +0 -0
  132. package/dist/web/public/ralph-wizard.13a1831e.js.gz +0 -0
  133. package/dist/web/public/respawn-ui.2d249da9.js.gz +0 -0
  134. package/dist/web/public/sanitize-html.bc7078d6.js.gz +0 -0
  135. package/dist/web/public/session-ui.b51814ef.js +49 -0
  136. package/dist/web/public/session-ui.b51814ef.js.br +0 -0
  137. package/dist/web/public/session-ui.b51814ef.js.gz +0 -0
  138. package/dist/web/public/settings-ui.6c67c125.js +67 -0
  139. package/dist/web/public/settings-ui.6c67c125.js.br +0 -0
  140. package/dist/web/public/settings-ui.6c67c125.js.gz +0 -0
  141. package/dist/web/public/styles.be7d626b.css +1 -0
  142. package/dist/web/public/styles.be7d626b.css.br +0 -0
  143. package/dist/web/public/styles.be7d626b.css.gz +0 -0
  144. package/dist/web/public/subagent-windows.69b60c35.js.gz +0 -0
  145. package/dist/web/public/sw.js.gz +0 -0
  146. package/dist/web/public/terminal-ui.dbf141fa.js +3 -0
  147. package/dist/web/public/terminal-ui.dbf141fa.js.br +0 -0
  148. package/dist/web/public/terminal-ui.dbf141fa.js.gz +0 -0
  149. package/dist/web/public/ultracode-panel.js.gz +0 -0
  150. package/dist/web/public/ultracode-windows.js.gz +0 -0
  151. package/dist/web/public/upload.html.gz +0 -0
  152. package/dist/web/public/vendor/dompurify.min.js.gz +0 -0
  153. package/dist/web/public/vendor/marked.min.js.gz +0 -0
  154. package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
  155. package/dist/web/public/vendor/xterm-addon-serialize.min.js.gz +0 -0
  156. package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
  157. package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
  158. package/dist/web/public/vendor/xterm-zerolag-input.137ad9f0.js.gz +0 -0
  159. package/dist/web/public/vendor/xterm.css.gz +0 -0
  160. package/dist/web/public/vendor/xterm.min.js.gz +0 -0
  161. package/dist/web/public/voice-input.085e9e73.js.gz +0 -0
  162. package/dist/web/routes/case-routes.d.ts.map +1 -1
  163. package/dist/web/routes/case-routes.js +115 -1
  164. package/dist/web/routes/case-routes.js.map +1 -1
  165. package/dist/web/routes/cron-routes.d.ts +11 -0
  166. package/dist/web/routes/cron-routes.d.ts.map +1 -0
  167. package/dist/web/routes/cron-routes.js +70 -0
  168. package/dist/web/routes/cron-routes.js.map +1 -0
  169. package/dist/web/routes/index.d.ts +1 -0
  170. package/dist/web/routes/index.d.ts.map +1 -1
  171. package/dist/web/routes/index.js +1 -0
  172. package/dist/web/routes/index.js.map +1 -1
  173. package/dist/web/routes/respawn-routes.d.ts.map +1 -1
  174. package/dist/web/routes/respawn-routes.js +3 -0
  175. package/dist/web/routes/respawn-routes.js.map +1 -1
  176. package/dist/web/routes/session-routes.d.ts.map +1 -1
  177. package/dist/web/routes/session-routes.js +610 -68
  178. package/dist/web/routes/session-routes.js.map +1 -1
  179. package/dist/web/routes/ws-routes.d.ts.map +1 -1
  180. package/dist/web/routes/ws-routes.js +56 -17
  181. package/dist/web/routes/ws-routes.js.map +1 -1
  182. package/dist/web/schemas.d.ts +113 -0
  183. package/dist/web/schemas.d.ts.map +1 -1
  184. package/dist/web/schemas.js +145 -0
  185. package/dist/web/schemas.js.map +1 -1
  186. package/dist/web/server.d.ts +8 -2
  187. package/dist/web/server.d.ts.map +1 -1
  188. package/dist/web/server.js +75 -13
  189. package/dist/web/server.js.map +1 -1
  190. package/dist/web/session-listener-wiring.d.ts +5 -1
  191. package/dist/web/session-listener-wiring.d.ts.map +1 -1
  192. package/dist/web/session-listener-wiring.js +23 -1
  193. package/dist/web/session-listener-wiring.js.map +1 -1
  194. package/dist/web/sse-events.d.ts +15 -0
  195. package/dist/web/sse-events.d.ts.map +1 -1
  196. package/dist/web/sse-events.js +17 -0
  197. package/dist/web/sse-events.js.map +1 -1
  198. package/dist/web/ws-connection-registry.d.ts +77 -0
  199. package/dist/web/ws-connection-registry.d.ts.map +1 -0
  200. package/dist/web/ws-connection-registry.js +99 -0
  201. package/dist/web/ws-connection-registry.js.map +1 -0
  202. package/package.json +3 -1
  203. package/dist/web/public/app.a8d73c32.js +0 -38
  204. package/dist/web/public/app.a8d73c32.js.br +0 -0
  205. package/dist/web/public/app.a8d73c32.js.gz +0 -0
  206. package/dist/web/public/constants.1c779517.js.br +0 -0
  207. package/dist/web/public/constants.1c779517.js.gz +0 -0
  208. package/dist/web/public/input-cjk.4e950e0a.js +0 -1
  209. package/dist/web/public/input-cjk.4e950e0a.js.br +0 -0
  210. package/dist/web/public/input-cjk.4e950e0a.js.gz +0 -0
  211. package/dist/web/public/mobile.8a9aff7a.css +0 -1
  212. package/dist/web/public/mobile.8a9aff7a.css.br +0 -0
  213. package/dist/web/public/mobile.8a9aff7a.css.gz +0 -0
  214. package/dist/web/public/panels-ui.772e92c9.js.br +0 -0
  215. package/dist/web/public/panels-ui.772e92c9.js.gz +0 -0
  216. package/dist/web/public/session-ui.da3e53f2.js +0 -36
  217. package/dist/web/public/session-ui.da3e53f2.js.br +0 -0
  218. package/dist/web/public/session-ui.da3e53f2.js.gz +0 -0
  219. package/dist/web/public/settings-ui.38feb956.js +0 -61
  220. package/dist/web/public/settings-ui.38feb956.js.br +0 -0
  221. package/dist/web/public/settings-ui.38feb956.js.gz +0 -0
  222. package/dist/web/public/styles.1b3e6147.css +0 -1
  223. package/dist/web/public/styles.1b3e6147.css.br +0 -0
  224. package/dist/web/public/styles.1b3e6147.css.gz +0 -0
  225. package/dist/web/public/terminal-ui.865f21f6.js +0 -3
  226. package/dist/web/public/terminal-ui.865f21f6.js.br +0 -0
  227. package/dist/web/public/terminal-ui.865f21f6.js.gz +0 -0
@@ -3,7 +3,7 @@
3
3
  * Covers session CRUD, input/output, terminal buffer, quick-start, quick-run,
4
4
  * auto-clear, auto-compact, image watcher, flicker filter, and logout.
5
5
  */
6
- import { join, dirname, extname } from 'node:path';
6
+ import { join, dirname, extname, basename } from 'node:path';
7
7
  import { homedir } from 'node:os';
8
8
  import { existsSync, statSync, mkdirSync, writeFileSync } from 'node:fs';
9
9
  import { execFile } from 'node:child_process';
@@ -12,20 +12,25 @@ import { randomBytes } from 'node:crypto';
12
12
  import { ApiErrorCode, createErrorResponse, getErrorMessage, } from '../../types.js';
13
13
  import { Session, isAltScreenStripMode } from '../../session.js';
14
14
  import { SseEvent } from '../sse-events.js';
15
- import { CreateSessionSchema, SessionNameSchema, SessionColorSchema, RunPromptSchema, SessionInputWithLimitSchema, ResizeSchema, AutoClearSchema, AutoCompactSchema, AutoResumeSchema, ImageWatcherSchema, FlickerFilterSchema, QuickRunSchema, QuickStartSchema, } from '../schemas.js';
15
+ import { CreateSessionSchema, SessionNameSchema, SessionColorSchema, RunPromptSchema, SessionInputWithLimitSchema, ResizeSchema, AutoClearSchema, AutoCompactSchema, AutoResumeSchema, ImageWatcherSchema, FlickerFilterSchema, QuickRunSchema, QuickStartSchema, InteractiveStartSchema, } from '../schemas.js';
16
16
  import { autoConfigureRalph, CASES_DIR, findSessionOrFail, parseBody, persistAndBroadcastSession, SETTINGS_PATH, validatePathWithinBase, } from '../route-helpers.js';
17
17
  import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
18
18
  import { writeHooksConfig, updateCaseModel, stripCaseEnvKeys, applyStatusLineConfig, refreshStaleHookSecret, } from '../../hooks-config.js';
19
19
  import { generateClaudeMd } from '../../templates/claude-md.js';
20
20
  import { imageWatcher } from '../../image-watcher.js';
21
+ import { convertHeicToJpeg } from '../heic-jpeg-converter.js';
21
22
  import { getLifecycleLog } from '../../session-lifecycle-log.js';
23
+ import { mergeUnifiedSessions, filterAndPaginate, } from '../../services/unified-session-service.js';
22
24
  import { MAX_CONCURRENT_SESSIONS } from '../../config/map-limits.js';
23
25
  import { RunSummaryTracker } from '../../run-summary.js';
24
26
  import { MAX_INPUT_LENGTH, MAX_SESSION_NAME_LENGTH } from '../../config/terminal-limits.js';
25
27
  import { MAX_PASTE_IMAGE_BYTES } from '../../config/buffer-limits.js';
26
- import { dataPath } from '../../config/instance.js';
28
+ import { dataPath, getDataDir } from '../../config/instance.js';
29
+ import { checkRemoteTmuxAvailable, readRemoteCases, readRemoteHosts, toSessionRemote } from '../../remote-hosts.js';
30
+ import { LRUMap } from '../../utils/lru-map.js';
27
31
  // Path to linked-cases registry (same file used by case-routes resolveCasePath)
28
32
  const LINKED_CASES_FILE = dataPath('linked-cases.json');
33
+ const CODEMAN_CONFIG_DIR = getDataDir();
29
34
  // Pre-compiled regex for terminal buffer cleaning (avoids per-request compilation)
30
35
  // eslint-disable-next-line no-control-regex
31
36
  const CLAUDE_BANNER_PATTERN = /\x1b\[1mClaud/;
@@ -145,6 +150,16 @@ export function imageMagicMatchesExt(data, ext) {
145
150
  return u32be(0) === 0x52494646 && u32be(8) === 0x57454250;
146
151
  case '.bmp':
147
152
  return data[0] === 0x42 && data[1] === 0x4d;
153
+ case '.heic':
154
+ case '.heif': {
155
+ // ISO Base Media File Format: size + "ftyp" + major brand. The brand
156
+ // list matches heic-decode's own isHeic() — accepting more brands here
157
+ // would only route bytes into a conversion that always throws.
158
+ if (u32be(4) !== 0x66747970)
159
+ return false;
160
+ const brand = data.subarray(8, 12).toString('ascii');
161
+ return ['heic', 'heix', 'hevc', 'hevx', 'mif1', 'msf1'].includes(brand);
162
+ }
148
163
  default:
149
164
  return false;
150
165
  }
@@ -490,6 +505,14 @@ export function registerSessionRoutes(app, ctx) {
490
505
  // ========== Start Interactive Mode ==========
491
506
  app.post('/api/sessions/:id/interactive', async (req) => {
492
507
  const { id } = req.params;
508
+ // Body is optional (auto-reattach callers send none) — same idiom as /interactive-respawn.
509
+ const bodyResult = req.body
510
+ ? InteractiveStartSchema.safeParse(req.body)
511
+ : { success: true, data: {} };
512
+ if (!bodyResult.success) {
513
+ return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
514
+ }
515
+ const { clearBreaker } = bodyResult.data;
493
516
  const session = findSessionOrFail(ctx, id);
494
517
  if (session.isBusy()) {
495
518
  return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
@@ -507,6 +530,20 @@ export function registerSessionRoutes(app, ctx) {
507
530
  session.ralphTracker.enable();
508
531
  }
509
532
  }
533
+ // COD-118: ONLY an explicit user-initiated restart (body {clearBreaker:true})
534
+ // clears a tripped PTY-exit circuit breaker. This endpoint is ALSO the frontend's
535
+ // automatic re-attach path (selectSession auto-POSTs it for any pid===null
536
+ // session), so an unconditional reset here would re-arm the exact crash loop
537
+ // the breaker exists to stop — auto-reattach sends no body and must not clear.
538
+ if (clearBreaker) {
539
+ session.resetRespawnBreaker();
540
+ }
541
+ // Re-attach listener wiring if a prior PTY exit detached it: the wiring exit
542
+ // handler removes ALL session listeners (incl. respawnBreakerTripped), and only
543
+ // session-create/boot-recovery paths ran setupSessionListeners before this fix —
544
+ // without this, a re-attached session's SSE/terminal/trip events go unobserved.
545
+ // setupSessionListeners is idempotent (no-op while refs are still attached).
546
+ await ctx.setupSessionListeners(session);
510
547
  await session.startInteractive();
511
548
  getLifecycleLog().log({
512
549
  event: 'started',
@@ -530,6 +567,8 @@ export function registerSessionRoutes(app, ctx) {
530
567
  return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
531
568
  }
532
569
  try {
570
+ // Re-attach listener wiring if a prior PTY exit detached it (see /interactive).
571
+ await ctx.setupSessionListeners(session);
533
572
  await session.startShell();
534
573
  getLifecycleLog().log({
535
574
  event: 'started',
@@ -716,6 +755,13 @@ export function registerSessionRoutes(app, ctx) {
716
755
  app.get('/api/sessions/:id/last-response', async (req) => {
717
756
  const { id } = req.params;
718
757
  const session = findSessionOrFail(ctx, id);
758
+ // Codex sessions don't write to ~/.claude/projects — their transcripts
759
+ // live in ~/.codex/sessions/**. Branch to a Codex-specific reader so the
760
+ // response-viewer works for Codex panes too.
761
+ if (session.mode === 'codex') {
762
+ const codexQuery = req.query;
763
+ return await readCodexLastResponse(session, codexQuery.context === 'full');
764
+ }
719
765
  // Scan ~/.claude/projects/*/ for the transcript file
720
766
  const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
721
767
  // Adopt the current conversation id if the user ran `/clear` — Claude CLI's
@@ -830,13 +876,321 @@ export function registerSessionRoutes(app, ctx) {
830
876
  timestamp: transcriptTimestamp,
831
877
  };
832
878
  });
879
+ function isCodexInjectedContext(text) {
880
+ return (/^# AGENTS\.md instructions\b/i.test(text) ||
881
+ /^<environment_context\b/i.test(text) ||
882
+ /^<turn_aborted\b/i.test(text) ||
883
+ /^<codex_internal_context\b/i.test(text) ||
884
+ /^<recommended_plugins\b/i.test(text) ||
885
+ /^<user_instructions\b/i.test(text) ||
886
+ /^# Options\b/i.test(text));
887
+ }
888
+ // ── Codex response-viewer support ───────────────────────────────────────────────────────
889
+ // Read the rollout's session_meta identity fields (plus turn_context cwd as
890
+ // a fallback when the huge session_meta line got truncated by the head read).
891
+ function readCodexRolloutMeta(head) {
892
+ let cwd;
893
+ let originator;
894
+ for (const line of head.split('\n')) {
895
+ if (!line)
896
+ continue;
897
+ try {
898
+ const entry = JSON.parse(line);
899
+ if (entry.type === 'session_meta') {
900
+ cwd ??= entry.payload?.cwd;
901
+ originator ??= entry.payload?.originator;
902
+ }
903
+ else if (entry.type === 'turn_context') {
904
+ cwd ??= entry.payload?.cwd;
905
+ }
906
+ }
907
+ catch {
908
+ // Malformed or truncated head line — keep scanning.
909
+ }
910
+ if (cwd && originator)
911
+ break;
912
+ }
913
+ return { cwd, originator };
914
+ }
915
+ // The pane's last Enter (Session.codexLastSubmitAt) correlated against
916
+ // ~/.codex/history.jsonl, which logs every submitted user message as
917
+ // {session_id, ts}. This identifies the thread the pane is ACTUALLY on and
918
+ // is the only signal that survives /resume, /new and /fork typed inside the
919
+ // codex TUI itself. An entry is credited to this pane only when its Enter is
920
+ // the closest among all codex panes, so a menu keystroke in another pane
921
+ // can't steal the attribution.
922
+ const codexHistoryPinCache = new LRUMap({ maxSize: 1024 });
923
+ async function resolveCodexThreadFromHistory(session, codexHome) {
924
+ const submitAt = session.codexLastSubmitAt || 0;
925
+ if (!submitAt)
926
+ return null;
927
+ const cached = codexHistoryPinCache.get(session.id);
928
+ if (cached && cached.submitAt === submitAt)
929
+ return cached.threadId;
930
+ const histPath = join(codexHome, 'history.jsonl');
931
+ const st = await fs.stat(histPath).catch(() => null);
932
+ if (!st || st.size === 0)
933
+ return null;
934
+ const tail = await readFileTail(histPath, Buffer.alloc(65536), st.size);
935
+ if (!tail)
936
+ return null;
937
+ const WINDOW_MS = 15_000;
938
+ const otherSubmits = [];
939
+ for (const s of ctx.sessions.values()) {
940
+ if (s.id !== session.id && s.mode === 'codex' && s.codexLastSubmitAt) {
941
+ otherSubmits.push(s.codexLastSubmitAt);
942
+ }
943
+ }
944
+ let best;
945
+ for (const line of tail.split('\n')) {
946
+ if (!line)
947
+ continue;
948
+ let e;
949
+ try {
950
+ e = JSON.parse(line);
951
+ }
952
+ catch {
953
+ continue; // first tail line may be cut mid-JSON
954
+ }
955
+ if (!e.session_id || typeof e.ts !== 'number')
956
+ continue;
957
+ const tsMs = e.ts * 1000; // history timestamps are unix seconds
958
+ const dist = Math.abs(tsMs - submitAt);
959
+ if (dist > WINDOW_MS)
960
+ continue;
961
+ if (otherSubmits.some((o) => Math.abs(tsMs - o) < dist))
962
+ continue; // another pane is closer
963
+ if (!best || dist < best.dist)
964
+ best = { threadId: e.session_id, dist };
965
+ }
966
+ if (!best)
967
+ return null;
968
+ codexHistoryPinCache.set(session.id, { submitAt, threadId: best.threadId });
969
+ return best.threadId;
970
+ }
971
+ // Locate THIS pane's rollout, in order of confidence:
972
+ // 0. history match — the thread the pane last submitted a message to
973
+ // (see resolveCodexThreadFromHistory); tracks the pane through
974
+ // /resume //new //fork typed inside the TUI.
975
+ // 1. originator match — Codeman spawns codex panes with
976
+ // CODEX_INTERNAL_ORIGINATOR_OVERRIDE=codeman_<sessionId>, which codex
977
+ // writes into session_meta.originator of every rollout it creates
978
+ // (including new files after /new in the same pane; newest match wins).
979
+ // 2. resume-id match — resumed rollouts keep their ORIGINAL session_meta
980
+ // (codex appends without rewriting it), so originator matching can't
981
+ // see them; but the rollout uuid is in the filename and we know the id.
982
+ // 3. legacy cwd+mtime heuristic — panes started before this feature, or
983
+ // TUI-resumed threads before their first tracked submit. Case-blind
984
+ // cwd compare (codex records the launch-time case, /mnt paths vary)
985
+ // and rollouts claimed by OTHER codeman panes are excluded.
986
+ async function findActiveCodexFile(session) {
987
+ const codexHome = process.env.CODEX_HOME || join(process.env.HOME || '/tmp', '.codex');
988
+ const sessionsDir = join(codexHome, 'sessions');
989
+ const files = [];
990
+ const walk = async (dir) => {
991
+ let entries;
992
+ try {
993
+ entries = await fs.readdir(dir, { withFileTypes: true });
994
+ }
995
+ catch {
996
+ return;
997
+ }
998
+ for (const entry of entries) {
999
+ const fullPath = join(dir, entry.name);
1000
+ if (entry.isDirectory()) {
1001
+ await walk(fullPath);
1002
+ continue;
1003
+ }
1004
+ if (!entry.isFile() || !entry.name.endsWith('.jsonl'))
1005
+ continue;
1006
+ const st = await fs.stat(fullPath).catch(() => null);
1007
+ if (!st || st.size < 100)
1008
+ continue;
1009
+ files.push({ path: fullPath, mtimeMs: st.mtimeMs });
1010
+ }
1011
+ };
1012
+ await walk(sessionsDir);
1013
+ files.sort((a, b) => b.mtimeMs - a.mtimeMs);
1014
+ const historyThreadId = await resolveCodexThreadFromHistory(session, codexHome);
1015
+ if (historyThreadId) {
1016
+ const hit = files.find((f) => basename(f.path).endsWith(`-${historyThreadId}.jsonl`));
1017
+ if (hit)
1018
+ return hit.path;
1019
+ }
1020
+ const rawResumeId = session.codexConfig?.resumeSessionId;
1021
+ const resumeId = rawResumeId && /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(rawResumeId)
1022
+ ? rawResumeId
1023
+ : undefined;
1024
+ const idMatch = resumeId ? files.find((f) => basename(f.path).endsWith(`-${resumeId}.jsonl`)) : undefined;
1025
+ // Scan newest-first for our originator; anything strictly older than the
1026
+ // id match can never beat it, so the head reads stop there (mtime ties are
1027
+ // still scanned — a /new rollout may land in the same clock tick). The
1028
+ // 128 KiB head budget covers the session_meta line, which embeds full
1029
+ // base_instructions (observed max ~22 KiB on codex 0.144).
1030
+ const originator = `codeman_${session.id}`;
1031
+ const wantCwd = session.workingDir.toLowerCase();
1032
+ const headBuf = Buffer.alloc(131072);
1033
+ let cwdFallback;
1034
+ for (const f of files) {
1035
+ if (idMatch && f.mtimeMs < idMatch.mtimeMs)
1036
+ break;
1037
+ const meta = await readCodexRolloutMetaCached(f.path, headBuf);
1038
+ if (!meta)
1039
+ continue;
1040
+ if (meta.originator === originator)
1041
+ return f.path; // newest-first → first hit wins
1042
+ if (!cwdFallback &&
1043
+ !idMatch &&
1044
+ meta.cwd?.toLowerCase() === wantCwd &&
1045
+ // A rollout stamped by another codeman pane belongs to that pane.
1046
+ !(meta.originator?.startsWith('codeman_') && meta.originator !== originator)) {
1047
+ cwdFallback = f;
1048
+ }
1049
+ }
1050
+ return idMatch?.path ?? cwdFallback?.path ?? null;
1051
+ }
1052
+ // session_meta is written once when codex creates the rollout and never
1053
+ // rewritten (verified: resume appends without touching it), so the parsed
1054
+ // identity of a given path can be cached forever. This turns the per-request
1055
+ // scan into stat calls plus head reads for new files only.
1056
+ const codexRolloutMetaCache = new LRUMap({ maxSize: 4096 });
1057
+ async function readCodexRolloutMetaCached(filePath, headBuf) {
1058
+ const cached = codexRolloutMetaCache.get(filePath);
1059
+ if (cached)
1060
+ return cached;
1061
+ const head = await readFileHead(filePath, headBuf);
1062
+ if (!head)
1063
+ return null;
1064
+ const meta = readCodexRolloutMeta(head);
1065
+ // Don't cache a still-incomplete head: a rollout being created may not
1066
+ // have flushed session_meta/turn_context yet.
1067
+ if (!meta.cwd && !meta.originator)
1068
+ return meta;
1069
+ codexRolloutMetaCache.set(filePath, meta);
1070
+ return meta;
1071
+ }
1072
+ function extractCodexBlockText(content, kinds) {
1073
+ if (typeof content === 'string')
1074
+ return content;
1075
+ if (!Array.isArray(content))
1076
+ return '';
1077
+ return content
1078
+ .filter((b) => !!b &&
1079
+ typeof b === 'object' &&
1080
+ kinds.includes(b.type || '') &&
1081
+ typeof b.text === 'string')
1082
+ .map((b) => b.text)
1083
+ .join('\n\n');
1084
+ }
1085
+ // Single pass over a Codex rollout: track the last assistant message (for the
1086
+ // default eye view) and, when `full`, the whole user/assistant thread.
1087
+ //
1088
+ // User turns come from event_msg/user_message when available: codex emits one
1089
+ // per REAL user input, and injected context (AGENTS.md, environment_context,
1090
+ // compaction summaries, …) never appears there — so no filtering heuristics.
1091
+ // response_item user rows duplicate those inputs mixed with the injections;
1092
+ // they are kept only as a fallback for old rollouts without event_msg rows.
1093
+ async function readCodexLastResponse(session, full) {
1094
+ const empty = full ? { text: '', timestamp: '', messages: [] } : { text: '', timestamp: '' };
1095
+ const filePath = await findActiveCodexFile(session);
1096
+ if (!filePath)
1097
+ return empty;
1098
+ let content;
1099
+ try {
1100
+ content = await fs.readFile(filePath, 'utf8');
1101
+ }
1102
+ catch {
1103
+ return empty;
1104
+ }
1105
+ let lastText = '';
1106
+ let lastTimestamp = '';
1107
+ const messages = [];
1108
+ // Multiset of event-sourced user texts: a real input appears BOTH as an
1109
+ // event_msg and as a response_item row, so each event text cancels exactly
1110
+ // one legacy twin. Legacy rows without an event twin (turns written by an
1111
+ // older codex appending to the same rollout) survive — a file-wide boolean
1112
+ // would wrongly drop them.
1113
+ const eventUserTexts = new Map();
1114
+ for (const line of content.split('\n')) {
1115
+ if (!line)
1116
+ continue;
1117
+ let entry;
1118
+ try {
1119
+ entry = JSON.parse(line);
1120
+ }
1121
+ catch {
1122
+ continue;
1123
+ }
1124
+ if (full && entry.type === 'event_msg' && entry.payload?.type === 'user_message') {
1125
+ let text = typeof entry.payload.message === 'string' ? entry.payload.message.trim() : '';
1126
+ if (text && isCodexInjectedContext(text))
1127
+ continue;
1128
+ if (text)
1129
+ eventUserTexts.set(text, (eventUserTexts.get(text) || 0) + 1);
1130
+ // Image-only (or image+text) inputs: the text field alone would make
1131
+ // the turn vanish, so surface a placeholder.
1132
+ const imageCount = (Array.isArray(entry.payload.images) ? entry.payload.images.length : 0) +
1133
+ (Array.isArray(entry.payload.local_images) ? entry.payload.local_images.length : 0);
1134
+ if (imageCount > 0)
1135
+ text = text ? `${text}\n\n*[image ×${imageCount}]*` : `*[image ×${imageCount}]*`;
1136
+ if (text)
1137
+ messages.push({ role: 'user', text, timestamp: entry.timestamp });
1138
+ continue;
1139
+ }
1140
+ if (entry.type !== 'response_item' || entry.payload?.type !== 'message')
1141
+ continue;
1142
+ const role = entry.payload?.role;
1143
+ if (role === 'assistant') {
1144
+ const text = extractCodexBlockText(entry.payload?.content, ['output_text', 'text']);
1145
+ if (text) {
1146
+ lastText = text;
1147
+ lastTimestamp = entry.timestamp || '';
1148
+ if (full)
1149
+ messages.push({ role: 'assistant', text, timestamp: entry.timestamp });
1150
+ }
1151
+ }
1152
+ else if (role === 'user' && full) {
1153
+ const text = extractCodexBlockText(entry.payload?.content, ['input_text', 'text']).trim();
1154
+ // Drop Codex's injected context turns (AGENTS.md, environment_context, …)
1155
+ // so the thread shows real user prompts only.
1156
+ if (text && !isCodexInjectedContext(text)) {
1157
+ messages.push({ role: 'user', text, timestamp: entry.timestamp, legacyUser: true });
1158
+ }
1159
+ }
1160
+ }
1161
+ const thread = messages
1162
+ .filter((m) => {
1163
+ if (!m.legacyUser)
1164
+ return true;
1165
+ const n = eventUserTexts.get(m.text) || 0;
1166
+ if (n > 0) {
1167
+ eventUserTexts.set(m.text, n - 1);
1168
+ return false; // duplicate of an event_msg row already in the thread
1169
+ }
1170
+ return true;
1171
+ })
1172
+ .map(({ role, text, timestamp }) => ({ role, text, timestamp }));
1173
+ return full
1174
+ ? { text: lastText, timestamp: lastTimestamp, messages: thread }
1175
+ : { text: lastText, timestamp: lastTimestamp };
1176
+ }
833
1177
  // ========== Get Terminal Buffer ==========
834
1178
  // Query params:
835
1179
  // tail=<bytes> - Only return last N bytes (faster initial load)
1180
+ // full=1 - Full page reload: replay the entire tmux scrollback (COD-47)
836
1181
  app.get('/api/sessions/:id/terminal', async (req) => {
837
1182
  const { id } = req.params;
838
1183
  const query = req.query;
839
1184
  const session = findSessionOrFail(ctx, id);
1185
+ // `full=1` is the EXPLICIT full-reload signal (COD-47): the browser reloaded
1186
+ // the page and wants the whole scroll history back, so we capture the ENTIRE
1187
+ // tmux scrollback and the user gets back history that scrolled off Codeman's
1188
+ // byte buffer. Requests WITHOUT it — tab switches (`tail=`) and the legacy
1189
+ // no-param callers (response-viewer fallback, clearTerminal refresh) — keep
1190
+ // the fast visible-frame capture.
1191
+ const tailBytes = query.tail ? parseInt(query.tail, 10) : 0;
1192
+ const isFullReload = query.full === '1' || query.full === 'true';
1193
+ const { tmuxHistoryLimit, terminalBufferMaxBytes } = await ctx.getTerminalHistoryConfig();
840
1194
  // Prepend the live tmux pane buffer so tab-switch replay shows the current
841
1195
  // on-screen frame, not just the accumulated byte history. This matters for
842
1196
  // TUI modes (codex/opencode) that repaint only their latest frame: the
@@ -846,22 +1200,53 @@ export function registerSessionRoutes(app, ctx) {
846
1200
  // returns null when unavailable, in which case we fall back to history.
847
1201
  const muxName = session.muxName;
848
1202
  const liveMuxBuffer = muxName && typeof ctx.mux.captureActivePaneBuffer === 'function'
849
- ? ctx.mux.captureActivePaneBuffer(muxName)
1203
+ ? ctx.mux.captureActivePaneBuffer(muxName, isFullReload
1204
+ ? { fullHistory: true, historyLimitLines: tmuxHistoryLimit, maxCaptureBytes: terminalBufferMaxBytes }
1205
+ : undefined)
850
1206
  : null;
851
- const rawBuffer = liveMuxBuffer !== null && liveMuxBuffer.length > 0
852
- ? session.terminalBufferLength > 0
853
- ? `${session.terminalBuffer}\x1b[H\x1b[2J${liveMuxBuffer}`
854
- : liveMuxBuffer
855
- : session.terminalBuffer;
856
- const tailBytes = query.tail ? parseInt(query.tail, 10) : 0;
1207
+ const hasLiveMuxBuffer = liveMuxBuffer !== null && liveMuxBuffer.length > 0;
1208
+ const source = hasLiveMuxBuffer
1209
+ ? isFullReload
1210
+ ? 'mux-full-history'
1211
+ : 'mux-visible'
1212
+ : 'history';
1213
+ let rawBuffer;
1214
+ if (liveMuxBuffer !== null && liveMuxBuffer.length > 0) {
1215
+ // Full-history capture is the RENDERED form of everything already in the
1216
+ // byte buffer (up to tmux eviction) — return it alone. Prepending the byte
1217
+ // history would replay the whole conversation twice: `\x1b[2J` clears only
1218
+ // the viewport, not xterm scrollback. The history+clear+frame concat stays
1219
+ // for the visible-frame path, where the single pane frame lacks history.
1220
+ rawBuffer = isFullReload
1221
+ ? liveMuxBuffer
1222
+ : session.terminalBufferLength > 0
1223
+ ? `${session.terminalBuffer}\x1b[H\x1b[2J${liveMuxBuffer}`
1224
+ : liveMuxBuffer;
1225
+ }
1226
+ else {
1227
+ rawBuffer = session.terminalBuffer;
1228
+ }
857
1229
  const fullSize = rawBuffer.length;
858
1230
  let truncated = false;
859
1231
  let cleanBuffer;
1232
+ // Cap the payload EARLY — before the regex normalization passes below run
1233
+ // over it. A full-history tmux capture can be tens of MB of scrollback;
1234
+ // normalizing all of it would stall the event loop only to discard most
1235
+ // bytes anyway. Keep the most RECENT bytes (slice from the end) and align
1236
+ // to a line boundary so we never start mid-ANSI-escape.
1237
+ if (terminalBufferMaxBytes > 0 && rawBuffer.length > terminalBufferMaxBytes) {
1238
+ rawBuffer = rawBuffer.slice(-terminalBufferMaxBytes);
1239
+ truncated = true;
1240
+ const capNewline = rawBuffer.indexOf('\n');
1241
+ if (capNewline > 0 && capNewline < 4096) {
1242
+ rawBuffer = rawBuffer.slice(capNewline + 1);
1243
+ }
1244
+ }
860
1245
  // Strip redundant Ink spinner/status redraws BEFORE tailing.
861
1246
  // During long thinking phases, Ink rewrites the same rows thousands of times
862
1247
  // (500KB+). Without stripping, tail mode returns only spinner frames and
863
1248
  // the terminal appears empty when switching tabs.
864
- let strippedBuffer = stripInkRedrawBloat(rawBuffer);
1249
+ let strippedBuffer = session.mode === 'shell' ? rawBuffer : stripInkRedrawBloat(rawBuffer);
865
1250
  // Strip alt-screen toggles and scrollback-erase from Codex/Claude byte
866
1251
  // streams. xterm.js obeys them by switching to its scrollback-less alt
867
1252
  // buffer and wiping saved lines, so conversation history disappears on tab
@@ -905,6 +1290,7 @@ export function registerSessionRoutes(app, ctx) {
905
1290
  status: session.status,
906
1291
  fullSize,
907
1292
  truncated,
1293
+ source,
908
1294
  };
909
1295
  });
910
1296
  // ═══════════════════════════════════════════════════════════════
@@ -1055,77 +1441,115 @@ export function registerSessionRoutes(app, ctx) {
1055
1441
  return createErrorResponse(ApiErrorCode.SESSION_BUSY, `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached.`);
1056
1442
  }
1057
1443
  const { caseName = 'testcase', mode = 'claude', openCodeConfig, codexConfig, geminiConfig, envOverrides, effort, } = parseBody(QuickStartSchema, req.body);
1058
- // Check OpenCode availability if requested
1059
- if (mode === 'opencode') {
1060
- const { isOpenCodeAvailable } = await import('../../utils/opencode-cli-resolver.js');
1061
- if (!isOpenCodeAvailable()) {
1062
- return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash');
1444
+ // Resolve the remote case FIRST — the CLI executes on the REMOTE host over ssh,
1445
+ // so the LOCAL availability gates below (isCodexAvailable() etc.) don't apply and
1446
+ // would wrongly reject a machine that hasn't got the CLI installed locally.
1447
+ let remote = undefined;
1448
+ let casePath = null;
1449
+ const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
1450
+ const remoteCase = remoteCases.find((item) => item.name === caseName);
1451
+ if (remoteCase) {
1452
+ const host = (await readRemoteHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === remoteCase.hostId);
1453
+ if (!host)
1454
+ return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found');
1455
+ // Per-session config that is applied to the LOCAL tmux/CLI wrapper (env vars via
1456
+ // tmux setenv, effort/model CLI args, codex/gemini/opencode config) does NOT
1457
+ // cross ssh, so it would silently no-op. Reject rather than pretend it worked —
1458
+ // remote command/env customization goes through the per-host command override.
1459
+ if ((envOverrides && Object.keys(envOverrides).length > 0) ||
1460
+ effort ||
1461
+ codexConfig ||
1462
+ geminiConfig ||
1463
+ openCodeConfig) {
1464
+ return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'envOverrides, effort, and per-CLI config are not supported for remote cases (they do not cross ssh). Configure the remote command via the host command override instead.');
1063
1465
  }
1064
- }
1065
- // Check Codex availability if requested
1066
- if (mode === 'codex') {
1067
- const { isCodexAvailable } = await import('../../utils/codex-cli-resolver.js');
1068
- if (!isCodexAvailable()) {
1069
- return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Codex CLI not found. Install with: npm install -g @openai/codex');
1466
+ // tmux is a hard prerequisite on the remote host (the agent runs inside a remote
1467
+ // tmux server so it survives ssh drops). Probe before spawning so a missing tmux
1468
+ // surfaces a clear, structured error instead of a dead "tmux: command not found" pane.
1469
+ const tmuxCheck = await checkRemoteTmuxAvailable(host);
1470
+ if (!tmuxCheck.ok) {
1471
+ return createErrorResponse(ApiErrorCode.OPERATION_FAILED, tmuxCheck.error || 'remote host is missing tmux');
1070
1472
  }
1473
+ casePath = remoteCase.remotePath;
1474
+ remote = toSessionRemote(host, remoteCase);
1071
1475
  }
1072
- // Check Gemini availability if requested
1073
- if (mode === 'gemini') {
1074
- const { isGeminiAvailable } = await import('../../utils/gemini-cli-resolver.js');
1075
- if (!isGeminiAvailable()) {
1076
- return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Gemini CLI not found. Install with: npm install -g @google/gemini-cli');
1476
+ else {
1477
+ // Check OpenCode availability if requested
1478
+ if (mode === 'opencode') {
1479
+ const { isOpenCodeAvailable } = await import('../../utils/opencode-cli-resolver.js');
1480
+ if (!isOpenCodeAvailable()) {
1481
+ return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash');
1482
+ }
1483
+ }
1484
+ // Check Codex availability if requested
1485
+ if (mode === 'codex') {
1486
+ const { isCodexAvailable } = await import('../../utils/codex-cli-resolver.js');
1487
+ if (!isCodexAvailable()) {
1488
+ return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Codex CLI not found. Install with: npm install -g @openai/codex');
1489
+ }
1490
+ }
1491
+ // Check Gemini availability if requested
1492
+ if (mode === 'gemini') {
1493
+ const { isGeminiAvailable } = await import('../../utils/gemini-cli-resolver.js');
1494
+ if (!isGeminiAvailable()) {
1495
+ return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Gemini CLI not found. Install with: npm install -g @google/gemini-cli');
1496
+ }
1497
+ }
1498
+ // Resolve case path: check linked-cases registry first, then fall back to CASES_DIR.
1499
+ // This mirrors the behaviour of resolveCasePath() in case-routes so that linked
1500
+ // external project directories are honoured by quick-start just like regular case routes.
1501
+ let linkedCases = {};
1502
+ try {
1503
+ const raw = await fs.readFile(LINKED_CASES_FILE, 'utf-8');
1504
+ linkedCases = JSON.parse(raw);
1505
+ }
1506
+ catch {
1507
+ // File missing or unparseable — treat as empty registry
1508
+ }
1509
+ casePath = linkedCases[caseName] || validatePathWithinBase(caseName, CASES_DIR);
1510
+ if (!casePath) {
1511
+ return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
1077
1512
  }
1078
1513
  }
1079
- // Resolve case path: check linked-cases registry first, then fall back to CASES_DIR.
1080
- // This mirrors the behaviour of resolveCasePath() in case-routes so that linked
1081
- // external project directories are honoured by quick-start just like regular case routes.
1082
- let linkedCases = {};
1083
- try {
1084
- const raw = await fs.readFile(LINKED_CASES_FILE, 'utf-8');
1085
- linkedCases = JSON.parse(raw);
1086
- }
1087
- catch {
1088
- // File missing or unparseable — treat as empty registry
1089
- }
1090
- const linkedCasePath = linkedCases[caseName];
1091
- const casePath = linkedCasePath || validatePathWithinBase(caseName, CASES_DIR);
1092
- if (!casePath) {
1093
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
1094
- }
1095
- // Create case folder and CLAUDE.md if it doesn't exist (only for non-linked cases)
1096
- if (!existsSync(casePath)) {
1514
+ // By this point casePath is guaranteed non-null: for remote cases it was set from remoteCase.remotePath,
1515
+ // for local cases the !casePath guard above returned early. TypeScript can't narrow across the if/else.
1516
+ const resolvedCasePath = casePath;
1517
+ // Create case folder and CLAUDE.md if it doesn't exist (only for non-linked, non-remote cases)
1518
+ if (!remote && !existsSync(resolvedCasePath)) {
1097
1519
  try {
1098
- mkdirSync(casePath, { recursive: true });
1099
- mkdirSync(join(casePath, 'src'), { recursive: true });
1520
+ mkdirSync(resolvedCasePath, { recursive: true });
1521
+ mkdirSync(join(resolvedCasePath, 'src'), { recursive: true });
1100
1522
  // Read settings to get custom template path
1101
1523
  const templatePath = await ctx.getDefaultClaudeMdPath();
1102
1524
  const claudeMd = generateClaudeMd(caseName, '', templatePath);
1103
- writeFileSync(join(casePath, 'CLAUDE.md'), claudeMd);
1525
+ writeFileSync(join(resolvedCasePath, 'CLAUDE.md'), claudeMd);
1104
1526
  // Write .claude/settings.local.json with hooks for desktop notifications
1105
1527
  // (Claude-specific — OpenCode, Codex, and Gemini use their own systems)
1106
1528
  if (mode !== 'opencode' && mode !== 'codex' && mode !== 'gemini') {
1107
- await writeHooksConfig(casePath);
1529
+ await writeHooksConfig(resolvedCasePath);
1108
1530
  }
1109
- ctx.broadcast(SseEvent.CaseCreated, { name: caseName, path: casePath });
1531
+ ctx.broadcast(SseEvent.CaseCreated, { name: caseName, path: resolvedCasePath });
1110
1532
  }
1111
1533
  catch (err) {
1112
1534
  return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to create case: ${getErrorMessage(err)}`);
1113
1535
  }
1114
1536
  }
1115
- else if (mode !== 'opencode') {
1537
+ else if (!remote && mode !== 'opencode') {
1116
1538
  // COD-91 self-heal for an EXISTING case: refresh a pre-secret hooks block so the
1117
1539
  // now-unconditional hook-secret gate keeps accepting its hook events. No-op when
1118
- // the hooks aren't ours or already carry the secret.
1119
- await refreshStaleHookSecret(casePath).catch(() => { });
1540
+ // the hooks aren't ours or already carry the secret. Skipped for remote cases —
1541
+ // resolvedCasePath is a REMOTE path that doesn't exist on the local filesystem.
1542
+ await refreshStaleHookSecret(resolvedCasePath).catch(() => { });
1120
1543
  }
1121
1544
  // Strip stale disk entries for keys this request is actively setting (Claude only —
1122
1545
  // see POST /api/sessions for full rationale).
1123
1546
  if (mode !== 'opencode' &&
1124
1547
  mode !== 'codex' &&
1125
1548
  mode !== 'gemini' &&
1549
+ !remote &&
1126
1550
  envOverrides &&
1127
1551
  Object.keys(envOverrides).length > 0) {
1128
- await stripCaseEnvKeys(casePath, Object.keys(envOverrides));
1552
+ await stripCaseEnvKeys(resolvedCasePath, Object.keys(envOverrides));
1129
1553
  }
1130
1554
  // Create a new session with the case as working directory
1131
1555
  // Apply global Nice priority config and model config from settings
@@ -1143,7 +1567,7 @@ export function registerSessionRoutes(app, ctx) {
1143
1567
  const qsClaudeModeConfig = await ctx.getClaudeModeConfig();
1144
1568
  const qsTerminalHistoryConfig = await ctx.getTerminalHistoryConfig();
1145
1569
  const session = new Session({
1146
- workingDir: casePath,
1570
+ workingDir: resolvedCasePath,
1147
1571
  mux: ctx.mux,
1148
1572
  useMux: true,
1149
1573
  mode: mode,
@@ -1156,12 +1580,13 @@ export function registerSessionRoutes(app, ctx) {
1156
1580
  geminiConfig: mode === 'gemini' ? geminiConfig : undefined,
1157
1581
  envOverrides,
1158
1582
  effort,
1583
+ remote,
1159
1584
  tmuxHistoryLimit: qsTerminalHistoryConfig.tmuxHistoryLimit,
1160
1585
  });
1161
1586
  // Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting
1162
1587
  // so the initial state already has the phrase configured (only if globally enabled)
1163
- if (mode === 'claude' && ctx.store.getConfig().ralphEnabled) {
1164
- autoConfigureRalph(session, casePath, ctx);
1588
+ if (mode === 'claude' && !remote && ctx.store.getConfig().ralphEnabled) {
1589
+ autoConfigureRalph(session, resolvedCasePath, ctx);
1165
1590
  if (!session.ralphTracker.enabled) {
1166
1591
  session.ralphTracker.enable();
1167
1592
  session.ralphTracker.enableAutoEnable(); // Allow re-enabling on restart
@@ -1230,7 +1655,7 @@ export function registerSessionRoutes(app, ctx) {
1230
1655
  }
1231
1656
  return {
1232
1657
  sessionId: session.id,
1233
- casePath,
1658
+ casePath: resolvedCasePath,
1234
1659
  caseName,
1235
1660
  };
1236
1661
  }
@@ -1518,10 +1943,112 @@ export function registerSessionRoutes(app, ctx) {
1518
1943
  results.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime());
1519
1944
  return { sessions: results.slice(0, 50) };
1520
1945
  });
1946
+ // Unified, read-only session list: merges live + persisted + lifecycle +
1947
+ // transcript history + mux stats into one de-duplicated, searchable list
1948
+ // (COD-121). Pure merge/filter logic lives in unified-session-service.ts.
1949
+ app.get('/api/sessions/unified', async (req) => {
1950
+ const query = req.query;
1951
+ if (ctx.testMode) {
1952
+ return { sessions: [], total: 0 };
1953
+ }
1954
+ // Live (in-memory) sessions.
1955
+ const live = [...ctx.sessions.values()].map((s) => {
1956
+ const st = s.toState();
1957
+ return {
1958
+ id: st.id,
1959
+ name: st.name,
1960
+ mode: st.mode,
1961
+ status: st.status,
1962
+ isWorking: s.isWorking,
1963
+ workingDir: st.workingDir,
1964
+ createdAt: st.createdAt,
1965
+ lastActivityAt: st.lastActivityAt,
1966
+ claudeSessionId: s.claudeSessionId ?? undefined,
1967
+ };
1968
+ });
1969
+ // Persisted sessions (state.json). resumeSessionId is the Claude
1970
+ // conversation UUID a resumed session continues — feed it to the merge's
1971
+ // alias map so its transcript row folds into this session.
1972
+ const persisted = Object.values(ctx.store.getState().sessions).map((p) => ({
1973
+ id: p.id,
1974
+ name: p.name,
1975
+ mode: p.mode,
1976
+ status: p.status,
1977
+ workingDir: p.workingDir,
1978
+ createdAt: p.createdAt,
1979
+ lastActivityAt: p.lastActivityAt,
1980
+ claudeSessionId: p.resumeSessionId,
1981
+ }));
1982
+ // Lifecycle audit log (newest-first, capped).
1983
+ let lifecycle = [];
1984
+ try {
1985
+ const entries = await getLifecycleLog().query({ limit: 2000 });
1986
+ lifecycle = entries.map((e) => ({
1987
+ sessionId: e.sessionId,
1988
+ name: e.name,
1989
+ mode: e.mode,
1990
+ ts: e.ts,
1991
+ event: e.event,
1992
+ }));
1993
+ }
1994
+ catch {
1995
+ // Lifecycle log may be unavailable; treat as empty.
1996
+ }
1997
+ // Transcript history (~/.claude/projects) — reuse the same scanner as the overview.
1998
+ const history = [];
1999
+ try {
2000
+ const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
2001
+ const headBuf = Buffer.alloc(16384);
2002
+ const projectDirs = await fs.readdir(projectsDir);
2003
+ for (const projDir of projectDirs) {
2004
+ const projPath = join(projectsDir, projDir);
2005
+ const list = await scanProjectDir(projPath, projDir, headBuf);
2006
+ for (const h of list) {
2007
+ history.push({
2008
+ sessionId: h.sessionId,
2009
+ workingDir: h.workingDir,
2010
+ sizeBytes: h.sizeBytes,
2011
+ lastModified: h.lastModified,
2012
+ firstPrompt: h.firstPrompt,
2013
+ projectKey: h.projectKey,
2014
+ });
2015
+ }
2016
+ }
2017
+ }
2018
+ catch {
2019
+ // Projects dir may not exist.
2020
+ }
2021
+ // Mux process stats (best-effort; guard against mocks lacking the method).
2022
+ let mux = [];
2023
+ try {
2024
+ const getStats = ctx.mux.getSessionsWithStats;
2025
+ if (typeof getStats === 'function') {
2026
+ const muxSessions = (await getStats.call(ctx.mux));
2027
+ mux = muxSessions.map((m) => ({
2028
+ sessionId: m.sessionId,
2029
+ muxName: m.muxName,
2030
+ mode: m.mode,
2031
+ remote: m.remote !== undefined ? true : undefined,
2032
+ stats: m.stats ? { memoryMB: m.stats.memoryMB, cpuPercent: m.stats.cpuPercent } : undefined,
2033
+ }));
2034
+ }
2035
+ }
2036
+ catch {
2037
+ // Mux stats are optional.
2038
+ }
2039
+ const merged = mergeUnifiedSessions({ live, persisted, lifecycle, history, mux });
2040
+ const offset = query.offset !== undefined ? parseInt(query.offset, 10) : undefined;
2041
+ const limit = query.limit !== undefined ? parseInt(query.limit, 10) : undefined;
2042
+ return filterAndPaginate(merged, {
2043
+ q: query.q,
2044
+ offset: Number.isNaN(offset) ? undefined : offset,
2045
+ limit: Number.isNaN(limit) ? undefined : limit,
2046
+ });
2047
+ });
1521
2048
  // ═══════════════════════════════════════════════════════════════
1522
2049
  // Paste Image (clipboard / drag-drop upload)
1523
2050
  // ═══════════════════════════════════════════════════════════════
1524
- const ALLOWED_IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp']);
2051
+ const ALLOWED_IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.heic', '.heif']);
1525
2052
  // The per-file size cap (MAX_PASTE_IMAGE_BYTES) is enforced by @fastify/multipart (registered in server.ts).
1526
2053
  app.post('/api/sessions/:id/paste-image', async (req, reply) => {
1527
2054
  // CSRF defense: state-changing routes must come from same origin.
@@ -1613,7 +2140,7 @@ export function registerSessionRoutes(app, ctx) {
1613
2140
  if (ALLOWED_IMAGE_EXTS.has(origExt))
1614
2141
  ext = origExt;
1615
2142
  }
1616
- const mimeMatch = (part.mimetype || '').toLowerCase().match(/^image\/(png|jpeg|jpg|webp|gif|bmp)$/);
2143
+ const mimeMatch = (part.mimetype || '').toLowerCase().match(/^image\/(png|jpeg|jpg|webp|gif|bmp|heic|heif)$/);
1617
2144
  if (mimeMatch) {
1618
2145
  const map = {
1619
2146
  png: '.png',
@@ -1622,6 +2149,8 @@ export function registerSessionRoutes(app, ctx) {
1622
2149
  webp: '.webp',
1623
2150
  gif: '.gif',
1624
2151
  bmp: '.bmp',
2152
+ heic: '.heic',
2153
+ heif: '.heif',
1625
2154
  };
1626
2155
  ext = map[mimeMatch[1]] ?? ext;
1627
2156
  }
@@ -1629,14 +2158,27 @@ export function registerSessionRoutes(app, ctx) {
1629
2158
  reply.code(400);
1630
2159
  return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Unsupported image type: ${ext}. Allowed: ${[...ALLOWED_IMAGE_EXTS].join(', ')}`);
1631
2160
  }
1632
- // Sniff actual bytes filename and Content-Type are both attacker-supplied.
1633
- // Polyglot HTML/PNG would otherwise pass and serve back with image/png MIME.
1634
- if (!imageMagicMatchesExt(imageBytes, ext)) {
1635
- // Diagnostic: on some Android galleries (e.g. MIUI) a WebP/HEIF is
1636
- // mislabeled as image/jpeg, so the declared ext passes the allowlist but
1637
- // the magic bytes do not. Log the real header so format mismatches can be
1638
- // pinned down without a reproduce-and-guess loop. The client now
1639
- // re-encodes images to JPEG/PNG before upload, so this should be rare.
2161
+ // Route HEIC on the raw bytes, NOT the declared ext/mime: on some Android
2162
+ // galleries (e.g. MIUI) a HEIF comes back mislabeled as image/jpeg, and
2163
+ // browsers that cannot decode HEIF upload the original file as-is — so a
2164
+ // HEIC payload can arrive under any declared type. Filename and
2165
+ // Content-Type are attacker-supplied anyway; only the bytes are trusted.
2166
+ if (imageMagicMatchesExt(imageBytes, '.heic')) {
2167
+ try {
2168
+ imageBytes = await convertHeicToJpeg(imageBytes);
2169
+ ext = '.jpg';
2170
+ }
2171
+ catch (err) {
2172
+ console.warn(`[paste-image] HEIC conversion failed: filename=${JSON.stringify(part.filename)} mime=${JSON.stringify(part.mimetype)} error=${getErrorMessage(err)}`);
2173
+ reply.code(415);
2174
+ return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Could not convert HEIC image to JPEG');
2175
+ }
2176
+ }
2177
+ else if (!imageMagicMatchesExt(imageBytes, ext)) {
2178
+ // Sniff actual bytes — a polyglot HTML/PNG would otherwise pass and
2179
+ // serve back with image/png MIME. Log the real header so format
2180
+ // mismatches can be pinned down without a reproduce-and-guess loop. The
2181
+ // client re-encodes images to JPEG/PNG before upload, so this is rare.
1640
2182
  console.warn(`[paste-image] magic mismatch: filename=${JSON.stringify(part.filename)} mime=${JSON.stringify(part.mimetype)} declaredExt=${ext} magic=${imageBytes.subarray(0, 12).toString('hex')}`);
1641
2183
  reply.code(415);
1642
2184
  return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Image bytes do not match declared type ${ext}`);