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.
- package/README.md +264 -90
- package/dist/attachment-magic.d.ts +13 -0
- package/dist/attachment-magic.d.ts.map +1 -1
- package/dist/attachment-magic.js +47 -0
- package/dist/attachment-magic.js.map +1 -1
- package/dist/attachment-registry.d.ts.map +1 -1
- package/dist/attachment-registry.js +13 -2
- package/dist/attachment-registry.js.map +1 -1
- package/dist/config/buffer-limits.d.ts +1 -2
- package/dist/config/buffer-limits.d.ts.map +1 -1
- package/dist/config/buffer-limits.js +1 -2
- package/dist/config/buffer-limits.js.map +1 -1
- package/dist/config/map-limits.d.ts +11 -0
- package/dist/config/map-limits.d.ts.map +1 -1
- package/dist/config/map-limits.js +11 -0
- package/dist/config/map-limits.js.map +1 -1
- package/dist/config/server-timing.d.ts +6 -0
- package/dist/config/server-timing.d.ts.map +1 -1
- package/dist/config/server-timing.js +9 -0
- package/dist/config/server-timing.js.map +1 -1
- package/dist/config/terminal-history.d.ts +11 -6
- package/dist/config/terminal-history.d.ts.map +1 -1
- package/dist/config/terminal-history.js +18 -8
- package/dist/config/terminal-history.js.map +1 -1
- package/dist/cron/cron-input.d.ts +30 -0
- package/dist/cron/cron-input.d.ts.map +1 -0
- package/dist/cron/cron-input.js +7 -0
- package/dist/cron/cron-input.js.map +1 -0
- package/dist/cron/cron-service.d.ts +92 -0
- package/dist/cron/cron-service.d.ts.map +1 -0
- package/dist/cron/cron-service.js +606 -0
- package/dist/cron/cron-service.js.map +1 -0
- package/dist/cron/cron-time.d.ts +29 -0
- package/dist/cron/cron-time.d.ts.map +1 -0
- package/dist/cron/cron-time.js +87 -0
- package/dist/cron/cron-time.js.map +1 -0
- package/dist/document-thumbnailer.d.ts +1 -1
- package/dist/document-thumbnailer.d.ts.map +1 -1
- package/dist/document-thumbnailer.js +11 -2
- package/dist/document-thumbnailer.js.map +1 -1
- package/dist/generated-artifact-attachments.d.ts +26 -0
- package/dist/generated-artifact-attachments.d.ts.map +1 -0
- package/dist/generated-artifact-attachments.js +61 -0
- package/dist/generated-artifact-attachments.js.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -1
- package/dist/mux-interface.d.ts +31 -5
- package/dist/mux-interface.d.ts.map +1 -1
- package/dist/remote-hosts.d.ts +66 -0
- package/dist/remote-hosts.d.ts.map +1 -0
- package/dist/remote-hosts.js +198 -0
- package/dist/remote-hosts.js.map +1 -0
- package/dist/services/unified-session-service.d.ts +115 -0
- package/dist/services/unified-session-service.d.ts.map +1 -0
- package/dist/services/unified-session-service.js +173 -0
- package/dist/services/unified-session-service.js.map +1 -0
- package/dist/session-cli-builder.d.ts +3 -1
- package/dist/session-cli-builder.d.ts.map +1 -1
- package/dist/session-cli-builder.js +26 -7
- package/dist/session-cli-builder.js.map +1 -1
- package/dist/session-pty-exit-breaker.d.ts +66 -0
- package/dist/session-pty-exit-breaker.d.ts.map +1 -0
- package/dist/session-pty-exit-breaker.js +77 -0
- package/dist/session-pty-exit-breaker.js.map +1 -0
- package/dist/session.d.ts +24 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +94 -11
- package/dist/session.js.map +1 -1
- package/dist/state-store.d.ts +14 -0
- package/dist/state-store.d.ts.map +1 -1
- package/dist/state-store.js +49 -0
- package/dist/state-store.js.map +1 -1
- package/dist/tmux-manager.d.ts +94 -8
- package/dist/tmux-manager.d.ts.map +1 -1
- package/dist/tmux-manager.js +222 -26
- package/dist/tmux-manager.js.map +1 -1
- package/dist/types/api.d.ts +11 -0
- package/dist/types/api.d.ts.map +1 -1
- package/dist/types/api.js.map +1 -1
- package/dist/types/app-state.d.ts +5 -0
- package/dist/types/app-state.d.ts.map +1 -1
- package/dist/types/app-state.js.map +1 -1
- package/dist/types/cron.d.ts +88 -0
- package/dist/types/cron.d.ts.map +1 -0
- package/dist/types/cron.js +13 -0
- package/dist/types/cron.js.map +1 -0
- package/dist/types/session.d.ts +57 -0
- package/dist/types/session.d.ts.map +1 -1
- package/dist/types/session.js.map +1 -1
- package/dist/web/heic-jpeg-converter.d.ts +24 -0
- package/dist/web/heic-jpeg-converter.d.ts.map +1 -0
- package/dist/web/heic-jpeg-converter.js +76 -0
- package/dist/web/heic-jpeg-converter.js.map +1 -0
- package/dist/web/heic-jpeg-worker.d.ts +44 -0
- package/dist/web/heic-jpeg-worker.d.ts.map +1 -0
- package/dist/web/heic-jpeg-worker.js +78 -0
- package/dist/web/heic-jpeg-worker.js.map +1 -0
- package/dist/web/ports/cron-port.d.ts +9 -0
- package/dist/web/ports/cron-port.d.ts.map +1 -0
- package/dist/web/ports/cron-port.js +6 -0
- package/dist/web/ports/cron-port.js.map +1 -0
- package/dist/web/ports/index.d.ts +1 -0
- package/dist/web/ports/index.d.ts.map +1 -1
- package/dist/web/public/api-client.c9b1cddc.js.gz +0 -0
- package/dist/web/public/app.b0534652.js +38 -0
- package/dist/web/public/app.b0534652.js.br +0 -0
- package/dist/web/public/app.b0534652.js.gz +0 -0
- package/dist/web/public/{constants.1c779517.js → constants.5b18ed64.js} +65 -0
- package/dist/web/public/constants.5b18ed64.js.br +0 -0
- package/dist/web/public/constants.5b18ed64.js.gz +0 -0
- package/dist/web/public/cron-ui.js +328 -0
- package/dist/web/public/cron-ui.js.br +0 -0
- package/dist/web/public/cron-ui.js.gz +0 -0
- package/dist/web/public/image-input.ee16ad88.js.gz +0 -0
- package/dist/web/public/index.html +313 -25
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/input-cjk.63794d0b.js +1 -0
- package/dist/web/public/input-cjk.63794d0b.js.br +0 -0
- package/dist/web/public/input-cjk.63794d0b.js.gz +0 -0
- package/dist/web/public/keyboard-accessory.3f91acef.js.gz +0 -0
- package/dist/web/public/mobile-handlers.b5a667ef.js.gz +0 -0
- package/dist/web/public/mobile.0644e3ac.css +1 -0
- package/dist/web/public/mobile.0644e3ac.css.br +0 -0
- package/dist/web/public/mobile.0644e3ac.css.gz +0 -0
- package/dist/web/public/notification-manager.ca9dc40d.js.gz +0 -0
- package/dist/web/public/orchestrator-panel.js.gz +0 -0
- package/dist/web/public/{panels-ui.772e92c9.js → panels-ui.ca03853f.js} +25 -17
- package/dist/web/public/panels-ui.ca03853f.js.br +0 -0
- package/dist/web/public/panels-ui.ca03853f.js.gz +0 -0
- package/dist/web/public/ralph-panel.6de2d0f8.js.gz +0 -0
- package/dist/web/public/ralph-wizard.13a1831e.js.gz +0 -0
- package/dist/web/public/respawn-ui.2d249da9.js.gz +0 -0
- package/dist/web/public/sanitize-html.bc7078d6.js.gz +0 -0
- package/dist/web/public/session-ui.b51814ef.js +49 -0
- package/dist/web/public/session-ui.b51814ef.js.br +0 -0
- package/dist/web/public/session-ui.b51814ef.js.gz +0 -0
- package/dist/web/public/settings-ui.6c67c125.js +67 -0
- package/dist/web/public/settings-ui.6c67c125.js.br +0 -0
- package/dist/web/public/settings-ui.6c67c125.js.gz +0 -0
- package/dist/web/public/styles.be7d626b.css +1 -0
- package/dist/web/public/styles.be7d626b.css.br +0 -0
- package/dist/web/public/styles.be7d626b.css.gz +0 -0
- package/dist/web/public/subagent-windows.69b60c35.js.gz +0 -0
- package/dist/web/public/sw.js.gz +0 -0
- package/dist/web/public/terminal-ui.dbf141fa.js +3 -0
- package/dist/web/public/terminal-ui.dbf141fa.js.br +0 -0
- package/dist/web/public/terminal-ui.dbf141fa.js.gz +0 -0
- package/dist/web/public/ultracode-panel.js.gz +0 -0
- package/dist/web/public/ultracode-windows.js.gz +0 -0
- package/dist/web/public/upload.html.gz +0 -0
- package/dist/web/public/vendor/dompurify.min.js.gz +0 -0
- package/dist/web/public/vendor/marked.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-serialize.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-zerolag-input.137ad9f0.js.gz +0 -0
- package/dist/web/public/vendor/xterm.css.gz +0 -0
- package/dist/web/public/vendor/xterm.min.js.gz +0 -0
- package/dist/web/public/voice-input.085e9e73.js.gz +0 -0
- package/dist/web/routes/case-routes.d.ts.map +1 -1
- package/dist/web/routes/case-routes.js +115 -1
- package/dist/web/routes/case-routes.js.map +1 -1
- package/dist/web/routes/cron-routes.d.ts +11 -0
- package/dist/web/routes/cron-routes.d.ts.map +1 -0
- package/dist/web/routes/cron-routes.js +70 -0
- package/dist/web/routes/cron-routes.js.map +1 -0
- package/dist/web/routes/index.d.ts +1 -0
- package/dist/web/routes/index.d.ts.map +1 -1
- package/dist/web/routes/index.js +1 -0
- package/dist/web/routes/index.js.map +1 -1
- package/dist/web/routes/respawn-routes.d.ts.map +1 -1
- package/dist/web/routes/respawn-routes.js +3 -0
- package/dist/web/routes/respawn-routes.js.map +1 -1
- package/dist/web/routes/session-routes.d.ts.map +1 -1
- package/dist/web/routes/session-routes.js +610 -68
- package/dist/web/routes/session-routes.js.map +1 -1
- package/dist/web/routes/ws-routes.d.ts.map +1 -1
- package/dist/web/routes/ws-routes.js +56 -17
- package/dist/web/routes/ws-routes.js.map +1 -1
- package/dist/web/schemas.d.ts +113 -0
- package/dist/web/schemas.d.ts.map +1 -1
- package/dist/web/schemas.js +145 -0
- package/dist/web/schemas.js.map +1 -1
- package/dist/web/server.d.ts +8 -2
- package/dist/web/server.d.ts.map +1 -1
- package/dist/web/server.js +75 -13
- package/dist/web/server.js.map +1 -1
- package/dist/web/session-listener-wiring.d.ts +5 -1
- package/dist/web/session-listener-wiring.d.ts.map +1 -1
- package/dist/web/session-listener-wiring.js +23 -1
- package/dist/web/session-listener-wiring.js.map +1 -1
- package/dist/web/sse-events.d.ts +15 -0
- package/dist/web/sse-events.d.ts.map +1 -1
- package/dist/web/sse-events.js +17 -0
- package/dist/web/sse-events.js.map +1 -1
- package/dist/web/ws-connection-registry.d.ts +77 -0
- package/dist/web/ws-connection-registry.d.ts.map +1 -0
- package/dist/web/ws-connection-registry.js +99 -0
- package/dist/web/ws-connection-registry.js.map +1 -0
- package/package.json +3 -1
- package/dist/web/public/app.a8d73c32.js +0 -38
- package/dist/web/public/app.a8d73c32.js.br +0 -0
- package/dist/web/public/app.a8d73c32.js.gz +0 -0
- package/dist/web/public/constants.1c779517.js.br +0 -0
- package/dist/web/public/constants.1c779517.js.gz +0 -0
- package/dist/web/public/input-cjk.4e950e0a.js +0 -1
- package/dist/web/public/input-cjk.4e950e0a.js.br +0 -0
- package/dist/web/public/input-cjk.4e950e0a.js.gz +0 -0
- package/dist/web/public/mobile.8a9aff7a.css +0 -1
- package/dist/web/public/mobile.8a9aff7a.css.br +0 -0
- package/dist/web/public/mobile.8a9aff7a.css.gz +0 -0
- package/dist/web/public/panels-ui.772e92c9.js.br +0 -0
- package/dist/web/public/panels-ui.772e92c9.js.gz +0 -0
- package/dist/web/public/session-ui.da3e53f2.js +0 -36
- package/dist/web/public/session-ui.da3e53f2.js.br +0 -0
- package/dist/web/public/session-ui.da3e53f2.js.gz +0 -0
- package/dist/web/public/settings-ui.38feb956.js +0 -61
- package/dist/web/public/settings-ui.38feb956.js.br +0 -0
- package/dist/web/public/settings-ui.38feb956.js.gz +0 -0
- package/dist/web/public/styles.1b3e6147.css +0 -1
- package/dist/web/public/styles.1b3e6147.css.br +0 -0
- package/dist/web/public/styles.1b3e6147.css.gz +0 -0
- package/dist/web/public/terminal-ui.865f21f6.js +0 -3
- package/dist/web/public/terminal-ui.865f21f6.js.br +0 -0
- 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
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
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
|
-
//
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
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
|
-
|
|
1066
|
-
|
|
1067
|
-
const
|
|
1068
|
-
if (!
|
|
1069
|
-
return createErrorResponse(ApiErrorCode.OPERATION_FAILED,
|
|
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
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
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
|
-
//
|
|
1080
|
-
//
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
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(
|
|
1099
|
-
mkdirSync(join(
|
|
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(
|
|
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(
|
|
1529
|
+
await writeHooksConfig(resolvedCasePath);
|
|
1108
1530
|
}
|
|
1109
|
-
ctx.broadcast(SseEvent.CaseCreated, { name: caseName, path:
|
|
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
|
-
|
|
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(
|
|
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:
|
|
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,
|
|
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
|
-
//
|
|
1633
|
-
//
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
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}`);
|